Skip to content

Log damage and health sync events to find doubled or missing party damage - #39

Draft
absol89 wants to merge 108 commits into
masterfrom
claude/project-thread-m32osa
Draft

absol89 wants to merge 108 commits into
masterfrom
claude/project-thread-m32osa

Conversation

@absol89

@absol89 absol89 commented Nov 14, 2026

Copy link
Copy Markdown
Owner

Requested by Eric · project thread

Diagnostic build based on tag v1.8.7-10. Draft so CI compiles it; not meant to merge as is.

What

Players report enemies barely hurting remote party members while the leader takes about twice the damage. This adds client-only [DmgDiag] lines to the normal client log:

  • HIT: every DamageActor call involving a player: path taken in HookDamageActor, whether this client applied it or left it to the owner, raw and difficulty-scaled damage, multiplier, health, damage resist, difficulty and party role.
  • APPLIED: health before/after the game applied the hit, next to the delta sent to other clients.
  • SENT / RECV: health deltas sent to and received from the server, matched to the hit.
  • SNAPSHOT: the owner's absolute health for a player (currently ignored for health) compared to the local copy.

Warnings carrying FLAG: DUP_LOCAL_HIT, SENT_VS_APPLIED, DUP_BROADCAST, LIKELY_DOUBLE_DAMAGE, DAMAGE_ON_OWNED_ACTOR, REMOTE_DAMAGE_DIFFERS, HEALTH_DRIFT.

No protocol, server or gameplay changes; HookDamageActor keeps the same branches and return values.

Verified / assumed

  • Uses only existing hooks; no new address IDs or engine layouts.
  • Not compiled locally. Needs an in-game test with a party.

Generated by Claude Code

absol89 and others added 30 commits November 2, 2026 09:19
PartyService::OnPartyKick called BroadcastPlayerList(pKick). That argument is
meant for a player who is leaving the server, so every client dropped the
kicked (still connected) player from NotifyPlayerList and the kicked player
got no list at all. The kick now broadcasts the full list.

The client's PartyService also keeps the last name seen per player id for the
session (GetKnownName), so a player who drops out of the list is still shown
by name. It is cleared on disconnect.

Verified:
- Source: the server kick path and BroadcastPlayerList's ignore argument
  (also used by OnPlayerLeave, which is unchanged).
- SkyrimTogetherServer and SkyrimTogetherClient build (releasedbg, MSVC).
- Played on the fork's 1.8-playtest builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d2f0e89)
Name tags (ImGui, NameTagService):
- Plain name text above other players, no level or id. The party leader gets
  the party widget's crown (crown.svg, drawn as convex pieces). An optional
  thin "Name backdrop" (On/Party/Off, default Party).
- Anchored on the neck bone ("NPC Neck [Neck]") plus the head height, so tags
  stay centred in profile, crouching and lying down, and stay on the body if a
  dismemberment mod removes the head. Helmet clearance of 16 world units,
  clamped to 14-56 px; text scale clamp(800 / distance, 0.65, 1.2).
- Fade start / invisible distances for names and for party (3000 / 5000).
- A tag hides while the crosshair targets that player, so the HUD's activate
  prompt stays readable.
- Hidden while full-screen menus are open; dead players only up close.
- Font: the overlay's Futura Condensed (Latin, Cyrillic).

Player UI settings (Angular, stored per client):
- Known names: All / Party / None. Unknown names show as an italic "Stranger"
  in tags, the player list, the party HUD, the party menu and party invites.
  An eye in the player list overrides it per username.
- Party invitations (on/off), Preview mini chat (On/Party/Off), Name tags
  (On/Party/Off, default Party), name tag size.
- Locales: English and Swedish; other languages fall back to English.

Verified:
- Ghidra (SkyrimSE 1.6.1170):
  - NiAVObject world translate at +0xA0 (decapitation code 0x140685200 walks
    parent +0x30, local transform +0x48, world +0x7C, reads +0xA0/A4/A8).
  - NiAVObject::GetByName is vtable slot 0x2A (+0x150), called with
    FixedStrings entries (0x1406b5c10); "NPC Neck [Neck]" is FixedStrings
    +0x238.
  - CrosshairPickData (ID 401585 -> 0x143138ed0): the activate code
    0x14073e980 reads the target handle at +0x4.
  - World-to-screen (ID 70640, 0x140d2c780) only rejects |w| <= tolerance,
    so the z <= epsilon check is what drops points behind the camera.
- SkyrimTogetherClient, SkyrimTogetherServer and the Angular production build
  succeed. Played on the fork's 1.8-playtest builds.

Assumed:
- Neck-to-head-top (24 units) and helmet clearance (16 units) are tuned by
  eye in game, not measured.
- Actor nodes and camera globals are read from the ImGui draw (Present path),
  as the original v1.8.1 name tags did; the thread was not verified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit f618aef)
Deduplicate effective UI settings including revealed player IDs, remove unconditional CEF event logs, and avoid replacing unchanged game-thread settings.

(cherry picked from commit ea837ff)
Depends on tiltedphoques#931 (player name tags with privacy
settings): this commit builds on its NameTagService, PlayerUiService and
settings, and is stacked on that branch. Merge tiltedphoques#931 first.

Floating subtitle chat (On/Party/Off, default Party):
- Party: party members' chat and dialogue choices float above them. On: also
  strangers' chat, never their dialogue choices (dialogue is only synced
  within the party).
- The line replaces the player's name tag while it shows: same anchor, helmet
  gap and distance scale. It starts with "Name:" in the F2 chat's colour for
  its type (stranger names italic), wraps at 300 px (22 px base) to two lines
  with "...", and shows for 2 s + 60 ms per character, at most 8 s. A newer
  line replaces the older one. Lines show even where the Name tags filter
  hides the tag. The layout is cached per line and only rebuilt when the
  size (half pixels) or the stranger label changes.
- Drawn by Skyrim Together itself; no SKSE plugin is needed.
- NotifyChatMessageBroadcast gains PlayerId (the sender; 0 for server and
  script messages), so clients can find the sender's character. The server
  sets it in sendPlayerMessage.

Known chat names (On/Party/Off, default On, the same as today):
- Chat sender names in the F2 chat and the mini chat follow it, and so does
  the name in front of a floating line. The eye in the player list overrides
  it per name. The name is decided when the message arrives.

Locales: English; Swedish keys are present but empty (fall back to English).

Verified:
- SkyrimTogetherClient, SkyrimTogetherServer, TPTests and the Angular
  production build succeed; TPTests include a NotifyChatMessageBroadcast round
  trip with PlayerId.
- Reviewed with a high-effort code review; played on the fork's 1.8-playtest
  builds.

Assumed / known limits:
- Floating lines are written from dispatcher events and read in the ImGui
  draw without a lock, like the name tag visibility data; this assumes the
  draw runs on the main thread.
- The UI treats a chat sender missing from the player list as the local
  player, so a player whose list entry hasn't arrived yet (just joined) is
  shown by name in chat.
- Chat names are matched by username; players sharing a name share the
  setting, as with the eye.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 7a3319b)
Translations by the user (KNOWN_CHAT_NAMES, KNOWN_CHAT_NAMES_HINT, FLOATING_CHAT,
FLOATING_CHAT_HINT).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit a338491)
Players can invite each other from the player list and trade items through a
trade window docked beside the chat. Both sides build an offer, ready up, and
the server swaps the items once both are ready and the offers still validate.

Server (TradeService):
- Invites, responses, offers, ready state, cancel reasons and completion.
- Validates every offer against the player's synced inventory (quest items,
  worn items, temporary forms and internal items are refused; quantities are
  reserved across the whole offer) and publishes neither inventory until both
  offers and the resulting stacks are valid.
- Drops malformed trade packets instead of crashing; large inventories are no
  longer sent as truncated messages.

Client (TradeService, engine helpers):
- Trades from the player's live inventory and removes the exact traded item.
- Recreates crafted potions and poisons (AddPotion / AddPoison) and weapons
  coated with crafted poisons; keeps resolvable effects of synced
  enchantments; never frees or fabricates forms while applying a trade.
- Each recipient only accepts the partner items it can resolve.
- Non-playable or nameless items (creature skins, transformation gear, bound
  weapons, traps, dummies) are kept out of trades, using the engine's
  IsPlayable (TESForm vtable slot 0x19).
- Trade partners are shown by their last known name, never by id.

UI:
- Trade window with search, Skyrim item categories, quantity controls, offer
  and partner offer, ready/countdown, and item details (enchantments,
  charge, poison, soul level, refinement, custom names).
- Trade invite popups and notifications. An optional TRADE_INVITE_POLICY hook
  lets another feature decide accepted invites and shown names.
- Locales: English and Swedish.

Tests: Code/tests/trading.cpp (validation, offers, encoding) and
Code/skyrim_ui/test/trade-ui.test.cjs (npm run test:trading).

This squashes the history of tiltedphoques#918 onto the
current dev.

Verified:
- Ghidra (SkyrimSE 1.6.1170): TESForm slot 0x19 is IsPlayable for every
  carryable item type (ARMO/MISC/BOOK/ALCH/INGR/KEYM/SLGM/SCRL/LIGH ->
  0x140141260 checks form flag 0x4; WEAP 0x140289040 and AMMO 0x140263d70
  check their own non-playable flags).
- SkyrimTogetherClient, SkyrimTogetherServer, TPTests and the Angular
  production build succeed; trade tests pass. Played on the fork's
  1.8-playtest builds.

Assumed:
- The server can't check item playability (no form data); like the other
  client-side trade rules it relies on the offering client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit cd40638)
Depends on tiltedphoques#931 (player name tags with privacy
settings): it uses tiltedphoques#931's PlayerUiService, Known names and Player UI
settings, and this branch is stacked on tiltedphoques#931. Merge tiltedphoques#931 first.

- PlayerUiService provides TRADE_INVITE_POLICY:
  - displayName: trade popups and the trade window show "Stranger" for
    players whose name isn't known, the same rule as the player list
    (Known names plus the eye).
  - acceptsTradeInvite: the new "Trade invitations" setting (On/Party/Off,
    default On). Party: only party members can invite you; other invites are
    declined silently.
- Settings row under Player UI filters; English and Swedish strings.

Tests: the policy paths are covered by trade-ui.test.cjs (refused invites
are declined without a popup, popups use the policy's name, no policy shows
real names).

Verified:
- Angular production build; trade UI tests pass. Played on the fork's
  1.8-playtest builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit ddb1c96)
… is handled

HookActivate does not sync an activation right away: it queues an
ActivateEvent through RunnerService::Trigger, and ObjectService::OnActivate
handles it during the next World::Update. The event held only raw pointers
to the activated object and to the activator. When the game deleted the
reference in between, for example an item an NPC picked up (activating it
moves it into the inventory and deletes the reference), OnActivate read the
freed object: its lock, its form ID, and then GetParentCellEx, which falls
back to the virtual GetSaveParentCell when parentCell is null and called
through a vtable pointer that now pointed into the heap.

Crash log (Skyrim 1.6.1170, a playtest build of the absol89 fork, in Fort
Dunstad with bandits and a skeleton fighting nearby):
https://pastebin.com/enXSqvPf

    EXCEPTION_ACCESS_VIOLATION, call [rax+0x4B8] with rax pointing into the heap
    TESObjectREFR::GetParentCellEx
    ObjectService::OnActivate
    RunnerService::Trigger<ActivateEvent> (the queued call)
    TiltedPhoques::TaskQueue::Drain
    World::Update
    HookVMUpdate

The event now also records the form IDs of the object and the activator.
OnActivate looks both up again with TESForm::GetById and handles the event
only if they still name the same objects; otherwise it skips it with a debug
log line. The pointers are no longer read before that check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 190394a)
Actor::KillImpl (ID 37896) adds the base's death items to every dying actor
except reanimated corpses, so a summoned flame atronach still gets its fire
salts. The summon vanishes right after, but the item add went through the
inventory hook and was synced to the server and every other player.

AddDeathItems now skips commanded actors, as it already skips remote ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 4586480)
Actor::SetMagicEquipment equipped whatever TESForm::GetById returned for
the owner's hand spells and shout. When the owner has magic from a mod
this client doesn't have, GetGameId returns 0 and a null form gets
equipped. Combat AI later walks the actor's spells (Actor::VisitSpells)
and reads the form type through that null pointer, crashing a BSJobs
thread with an access violation at SkyrimSE+0x811CF7 (ID 44882) when the
remote character fights nearby.

Seen with a Khajiit player using Moonlight Tales, which the other client
didn't have: crashes began once he reverted from werewolf form and
happened whenever he loaded near the Northwatch guards.

Skip and warn about hand spells that aren't MagicItems and voice-slot
forms that are neither shouts nor powers, as the equip-change path in
InventoryService already does for unknown items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Mdy6FmrJTc1m7KuVM1n3H
(cherry picked from commit 8199d24)
HookLaunch asserted that the engine's launch returned a resolvable
projectile and then read its power anyway. When the handle didn't resolve,
the game crashed on a null pointer, or froze on the assert's breakpoint with a
debugger attached. Such launches are now left unsynced with a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 2fcf2c4)
… cell

Loading a cell queues its references' saved changes on BGSSaveLoadGame
without a lock, while the background QueuedPromoteReferencesTasks drain
the same queue. MoveActor (and the teleport command and map overlay)
loaded cells without the pause the game uses for its own cell loads, so a
drain could run concurrently or later on freed references (crash at
SkyrimSE 35567+0x111, call [rdx+0x160]). Pause the promote tasks around
the load and apply the queued changes before resuming, as the game does.

Verified in Ghidra (SkyrimSE 1.6.1170):
- 403330 is BGSSaveLoadGame (0x348 bytes, flags at +0x340). +0x318 is a
  BSTArray of 0x28-byte BGSLoadFormData. Each entry holds a raw form
  pointer at +0x10 with no refcount.
- 35567 drains the queue (InitLoadGame/FinishLoadGame) under its own
  spinlock and sets flags |= 8 while it runs. 35569 is the only push and
  does not take that lock. Nothing removes an entry when its form is
  freed.
- 20460 TESWorldSpace::LoadCell -> TESObjectCELL::InitItemImpl
  (18881/18882) -> 35569 pushes and does not drain.
- 13333/13334 pause/resume the promote tasks (15335/15336). Pause
  cancels queued tasks and waits for running ones. Vanilla cell loads
  (coc/cow 22875/22876, 36641, 40437, 40745, grid update 13288) hold it.
  Both functions ignore `this`.
- The 2026-10-21 crash was on a QueuedPromoteLocationReferencesTask
  thread, 17 ms after a burst of NotifyActorTeleport -> MoveActor. The
  2026-10-18 crash was the main-thread grid drain.

Verified on 1.7.104 by comparing raw bytes of the unpacked exes (equal
apart from rel32 displacements): the BGSSaveLoadGame ctor/size/+0x340
flags, 35567's flag updates and +0x318/+0x328 accesses, 35569's
prologue and kLoading test, and 13333/13334 resolving to 15335/15336 on
400326.

Assumed: draining right after loading an unattached cell is safe, as
vanilla 15328 does the same from a worker thread. On 1.7.104, 20460
still reaching InitItemImpl was not checked. Not built or tested in
game yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 58d4d31)
…r mods

Behavior variables are synced by slot position, but the descriptor built for
Nemesis/Pandora-patched behaviors sorted its slots by the local graph index,
while the vanilla descriptor uses its own list order. Between a vanilla and a
patched client almost every slot landed in another variable, e.g. the owner's
currentDefaultState in the viewer's iIsInSneak, so NPCs and remote players
looked sneaking.

Modded descriptors now keep the vanilla slot order, then the replacer extras
sorted by name. Variables a graph lacks keep their slot as an out-of-range
sentinel that saving and loading skip. Variable diffs against a different-sized
previous now compare with zero, as the receiver does, so those empty slots
cost a bit each instead of a full value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit b2d5650)
…hain

Playtest only, not for upstream TiltedEvolution: the proper fix is a pull
request to powerof3/FloatingSubtitles (copy the hooked prologue with
relocation instead of verbatim). This branch keeps the fork's playtest builds
compatible with the current Floating Subtitles release until that lands.
Nothing else in the fork depends on the plugin being installed.

ST hooked SubtitleManager::ShowSubtitle (ID 52626) with TP_HOOK. That hook
is committed by TP_HOOK_COMMIT in main.cpp, before LoadScriptExtender(), so
ST's MinHook jump was already in the prologue when SKSE plugins loaded.
po3's Floating Subtitles hooks the same prologue with
stl::hook_function_prologue<ShowSubtitle, 5>, which copies the first 5 bytes
verbatim into its trampoline. It copied ST's relative jmp, which lands at
the wrong address when run from the trampoline.

The hook is now installed with TP_HOOK_IMMEDIATE on the first
TiltedOnlineApp::Update, i.e. after the plugins have hooked. MinHook
relocates a leading rel32 jmp, so the chain becomes ST -> plugin -> game.
RealShowSubtitle is still set at startup, so ST's own ShowSubtitle calls
for remote subtitles work before the first frame, and afterwards they pass
through the plugin's hook too.

Verified:
- Ghidra (SkyrimSE 1.6.1170): ShowSubtitle at 0x14096cb00 (1.7.104:
  0x140983e80) begins 4c 8b dc 55 56 (mov r11,rsp; push rbp; push rsi), the
  5 bytes po3 expects to copy.
- Ghidra: po3's other hooks don't overlap ST's. The call at 77226+0x275
  (0x140e42655) and the call at 52629+0x37 (0x14096d067) are real e8 calls;
  ST only MinHooks 77226's prologue and patches +0x175.
- Source: po3 FloatingSubtitles @ d966aef (src/PCH.h, src/Hooks.cpp,
  src/ImGui/Renderer.cpp); ST main.cpp hook/SKSE order; TiltedReverse
  FunctionHook TP_HOOK vs TP_HOOK_IMMEDIATE.
- SkyrimTogetherClient builds (releasedbg, MSVC).

Assumed / not tested:
- The first TiltedOnlineApp::Update runs after SKSE has sent kPostLoad (po3
  installs its subtitle hooks at kPostLoad).
- Not tested in game, with or without Floating Subtitles installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 4f2b3e7)
(cherry picked from commit f32927d)
…ltedphoques#810)

After the party leader leaves a cell, NPCs handed to another player could
stand still and never draw their weapon, spamming Unequip, until the original
owner came back. Actor::SetWeaponDrawnEx flips the internal weapon state when
the replica already reports the requested state so that the native call forces
the animation again; for a sheathed replica asked to sheathe, the behavior
graph refuses the forced Unequip and the actor stays in "wants to sheathe"
forever, so its AI can neither draw nor attack once it gains ownership.

Keep the flip and the forced call, but record the native result of the forced
action in HookPerformAction and, when the state had been flipped and the action
was refused or never issued, restore the requested stable state. The flip is
kept because a replica can carry the owner's weapon state from replayed actions
while its graph never received WeapEquip; there the forced action is accepted
and corrects the visible weapon.

Client targets build; TPTests pass (27 assertions in 5 test cases). Verified
in game on a v1.8.1 tracing build carrying this same change: the Embershard
bandit draws and attacks the player who received it while the leader is inside
the mine, with roles inverted, and after repeated hand-overs; players'
draw/sheathe sync, remote NPCs appearing with weapons drawn and neutral NPCs
unchanged.

(cherry picked from commit c72b1df)
Implements tiltedphoques#342: a server can now be configured in one docker command without editing STServer.ini first. A new EnvSettingsProvider maps each setting to the upper-cased key name from the issue's examples (UDIFFICULTY, BENABLEMODCHECK, SPASSWORD) and the dedicated runner applies the environment after the ini file and the command line, so environment values win. Includes unit tests for the name mapping and the overrides.

(cherry picked from commit 1f75853)
Squashed from tiltedphoques#879 (absol89/TiltedEvolutionScriptFixes
PR-branch, DJLegends1011/feat/mod-anim-event-sync): syncs animation events sent by
mods through the console `sae` command (including ConsoleUtil callers) and papyrus
Debug.SendAnimationEvent, translating targets to server ids, and adds /wave, which
plays the vanilla wave when weapons are sheathed.

Client and server must match: the server relays the new script animation messages.

Upstream PR head: bb65834
Ownership transfers only reach players the server considers in range, so
a client that still had the actor loaded could keep the old epoch and
drop every later inventory, equipment, actor value and death update,
while the leader's claims kept being rejected. Remotes now adopt a newer
epoch since the server only relays the current owner, a repeated spawn
catches the actor up, and a stale leader claim is answered with the
current owner so the leader can claim again.

(cherry picked from commit 2e74896)
The hooks can't send inventory changes before a `LocalComponent` exists,
so anything equipped between discovery and the assignment reply, such as
the outfit as the actor loads, never reached the server. Transfers and
remote spawns apply the server's inventory, so those NPCs ended up naked
for everyone. The new owner now sends its whole inventory through
`RequestInventorySnapshot` when it differs, and the server relays it to
the other clients in range.

(cherry picked from commit e44ab60)
The spawn inventory is applied once the 3D loads and overwrote any
changes relayed before that. Inventory changes now go into the spawn
snapshot, and equipment changes are queued in `WaitingFor3D` and
replayed after it is applied. A newer full inventory drops the queue.

(cherry picked from commit 121c25b)
A leader could decline a grant and then accept the assignment reply for
the same epoch, so it kept streaming an actor the server had already
removed while the next client to load it became a second owner.
`NotifyRemoveCharacter` now also drops a local claim, and an actor that
is still loaded asks for a fresh assignment instead of running unsynced.

(cherry picked from commit b1f7040)
Nearby NPCs are updated in parallel engine jobs, so AI equips such as
torches reach the hooks on worker threads, where the ownership lookup
raced with the main thread adding and removing Local/Remote components.
The hooks now only queue the event, and the owner and epoch are looked
up when the runner hands it to the main thread.

(cherry picked from commit 21d85fa)
The epoch rules were spread across `Utils`, `InventoryService`,
`ActorValueService` and `CharacterService`. They now live in one system:
`AcceptRemoteUpdate` and `AcceptOwnerUpdate` for relayed updates,
`GetOutgoingOwnership` for local changes, and `GetOwnershipEpoch` for
the transfer check. No behaviour change.

(cherry picked from commit ea11e63)
Keep one door/container binding per local reference and update reassigned
server IDs. Notify clients before server objects are retired, preserve
entity generation bits, and clear object bindings on disconnect.

Add regression coverage for client bindings, message encoding, and server
cell retirement. Quest progression, scenes, and dialogue are unchanged.

(cherry picked from commit 9b19246)
)

While connected, the client locks the game clock and CalendarService writes
the server's time every frame, so sleeping or waiting did nothing: the
sleep/wait menu advanced the clock and the next frame overwrote it. A party
had to disconnect or use /settime to skip the night.

The client now listens to the engine's sleep and wait start/stop events,
records the requested hours (the events carry start and desired end times in
game days) and, when the sleep or wait ends uninterrupted while online, sends
RequestTimeSkip. The server applies the same permission rule as /settime
(admins always, the party leader on private servers) and CalendarService
advances the shared clock with DateTime::AddHours, the day/month/year rollover
that Update already did, then resends ServerTimeSettings to everyone. When a
server time update jumps forward, the client adds the difference to
GameDaysPassed, measured against a mirror of the server clock so that a
midnight crossed on the server is not counted twice when the calendar is not
synced.

Client, server, runner, early-load and TPProcess build; TPTests (64/9) and
TPServerTests (145/9) pass. Verified with two local clients on a build
carrying this change: the leader waiting 3 h and sleeping 3, 2, 8 and 19 h
moves both clients together, including day changes; a non-leader's wait is
ignored; after recreating the party the new leader can skip time; /settime
and cell changes behave as before.

(cherry picked from commit 87b8d7e)
…nces

Items the local player drops are now registered by the server instead of being re-dropped on every client. The dropper simulates the physics and streams the item's transform until it settles. Other clients spawn a keyframed copy that follows that stream, then hand it back to physics once it comes to rest. The server keeps drops for the whole session (capped by uMaxDroppedItems) and tells each client which copies to spawn or delete as items come in and out of range. The first pickup deletes every other copy. The old receiver path that called DropObject with a newly allocated ExtraDataList is removed, along with the Drop flag on inventory messages. The engine matches that list by pointer, so it never worked. Item drops are now enabled by default (bEnableItemDrops).

(cherry picked from commit d7a71e7)
(cherry picked from commit 10c113c)
Derive the server's at-rest state from pSimulator, reuse UpdateInterest when
an item is dropped, and walk m_knownItems once in RemoveItem. On the client,
add SendPickUp and EnsureKeyframed helpers, take IsAtRest from the item in
SendMove, flatten UpdateSimulatedItem, and erase tracked items with
swap-and-pop.

(cherry picked from commit 07cb621)
Item drop sync (tiltedphoques#913) looks up the dropped item's base form by its server id. A player-crafted
potion or poison only has a temporary id, so other players' copies failed to spawn. The drop
already carries the recipe that player trading captures, so recreate the potion from it, the
same way traded and synced inventory potions are created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude and others added 29 commits November 5, 2026 01:16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur
The engine won't disarm essential actors and every player is essential, so
the shout and the perks silently did nothing. Lift that check only for the
local player being disarmed by another player. Perk disarms are sent to the
victim's client since they only run on the attacker's, and the weapon is
only dropped on the victim's client so it can't be duplicated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur
Commanded actors aren't hostile to a remote copy, as it has no combat group
of its own to mark them as enemies. Force the target while a fight between
the two players lasts. Also stop a remote player's summon from hurting the
local player with PvP off, which only remote players themselves were kept
from doing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur
Mass spells only ran on the caster's client, and hazards like the fire from
wall spells skipped the local player since it isn't hostile to a remote
player. Send self-cast area attacks to the other clients, and let hazards and
explosions from a remote player reach the local player while PvP is on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur
A cloak's copy on the victim's client can't be relied on, since a
concentration cloak only lasts while it's cast on the wearer's client. Let
that client decide on the hits and send them to the victim, whose own checks
can still refuse them, and ignore the hits from the copy's cloak.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur
Cloak damage only lands on targets hostile to the caster, and some area
effects skip the player's base form outright, so hits sent to the victim were
refused. Pass those two conditions for an attack from another player, as other
conditions like resistances still apply, and send concentration area effects
from the caster's client too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur
The weapon goes through DroppedItemService here instead of the Drop flag this
branch used, so other clients get the same single tracked reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur
A hostile spell counts as a hit before the poison and disarm forwards return,
so summons react to those too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur
Each forward to the victim's client keeps its own return after the hit event.
The attack check on the victim's client now covers disarms as well, and poisons
still reach it because they never auto-succeed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur
It's the same ice form paralysis that has been kept away from players, and its
effect refuses them on purpose. Stop overriding that condition for paralysis
and stop sending the channeled kind to the victim. The cloak's frost damage
and Mass Paralysis, which has no such condition, still land.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur
* Make the F2 UI usable on ultrawide screens

TiltedUI's D3D11 overlay handler copied and uploaded the whole CEF view on
every paint, through a deferred context. At 5120x1440 that is about 30 MB per
paint, and paints pile up in each game frame when the game runs below the UI's
60 fps, so the menu barely rendered and ignored clicks, worst outdoors.

Details in tiltedphoques/TiltedUI#20
---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit ed46728)
A corpse can stand because it was rebuilt for a leveled pick or got health back,
and saves keep it that way. Keep those from happening to dead actors, and while
connected check nearby bodies for a standing skeleton and ragdoll them, logging
BodyStand lines. Also log a respawn caused by a save disagreeing with the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur
(cherry picked from commit 879ac28)
RunRemoteUpdates called Kill() on every tick while the owner had an NPC dead and it was alive here.
When the game refused the kill, that meant about 100 calls and log lines a second until ownership moved
(1300 in 13 seconds in a playtest log). Retry once a second, give up after five tries with one warning,
and start over when the body reloads or dies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit faa5fff)
Every character the engine loads is constructed with form ID 0 and gets its ID later, so the
FormIdZeroActor constructor checks logged a stack trace for each NPC of every cell load (20 to 60
warnings per load). The before/after move placement lines added two more lines per assigned actor.
Log those at debug and skip the stack walk when debug is off. The UpdateReference3D form ID 0 check,
the move line and actor added/removed placement lines stay at their levels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The menu bar was fixed at 32vw, so labels that are one long word in some translations (Swedish
"Spelarhanterare") couldn't wrap and pushed the last button, Settings, out of the bar. The controls
column now grows to the menu's width (at least 32vw, at most the screen) with labels kept on one line,
and the chat fills that width without adding its own, so long chat lines still wrap. While trading the
column stays at 32vw because the trade dock is placed next to it, and the menu keeps wrapping its labels.

Swedish "Player Manager" is now "Hantera Spelare".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit ec2b4b8c287f81ab07330596e95ff14190202fa0)
Fitting the column to its labels made it far too wide at large font sizes and squeezed the trade
window. Go back to a fixed column, now 10% wider than 32vw and reaching to one gap from the trade
window, with menu labels that wrap between words. Without a trade the buttons can now shrink (they
are flex boxes, so they needed min-width: 0); while trading the columns size to their labels, so a
short label like Trade leaves room for one long word like Inställningar. The chat keeps the bar's width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 32b8a5a39e2dc5af2ad3e99c47791578bdfb4c16)
At small window widths or large fonts a menu label word that can't be broken (Swedish
"Inställningar") still ran into the ornament arrows. The menu is now one grid column per button with
the arrows positioned at the button edges, so labels wrap between words, every column is at least its
longest word plus padding, and the column grows past its usual width only when that minimum needs it.
The chat keeps the bar's width without adding its own, and the trade dock is placed one gap right of
wherever the column ends (measured by RootComponent into --app-root-controls-right).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 4001078dd8969b8aa727f8fc0ffe6282f1df18b0)
Add a non-overlapping minimize toolbar, a font-aware popup and compact inventory layout, and scrolling for short screens. Preserve the trade when minimized and update the Angular test mock.

(cherry picked from commit 17ac04d8e6d53c59c863710d60bb067e2bb67bad)
…a werewolf/vampire lord

(cherry picked from commit e461ce8)
Weredragon fades in its dragon skin armor with PlaySubGraphAnimation, which
wasn't synced, so other players saw only the gear floating where the dragon
was. Send it from the actor's owner and play it on the other players' copies,
then again a moment after the copy's gear or body changes, since the skin
can arrive after the event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur
The game registers PlaySubGraphAnimation on Actor, not ObjectReference,
so the hook never installed and SkinFadeIn was never sent. Checked in
the 1.6.1170 binary: the native is at 0x1409e9760 and takes
(VM*, stackId, Actor*, BSFixedString*), matching the hook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur
A duplicated OnEvent header broke the client build. A subgraph event
for a copy that isn't loaded yet was also dropped. It's now kept and
replayed once the copy loads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur
- Shorten the comments and drop the mention of an unmerged feature.
- Type the ExecuteCommand callback as void(const FxDelegateArgs*).
- Replace the ported CommonLibSSE-NG structs with GFxValue and
  FxDelegateArgs in IMenu.h, laid out after powerof3's CommonLibSSE, and
  use them for FxDelegateHandler::CallbackFn.
- Keep tcl, tfc, tgm, tm and coc (and their full names) allowed while
  typed commands are turned off. A reference prefix is still refused.

World has no const getter, so World::Get() stays as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fvQxR8LzniCZWnKRhyEtP
tgm makes a player invulnerable, which breaks PvP, and tcl lets a player
walk through walls and breaks enemy pathing. Players who are stuck can
use fast travel, teleport to a party member, or the Become Ethereal
shout instead. tfc, tm and coc stay allowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fvQxR8LzniCZWnKRhyEtP
It only changes the player's own camera field of view (fDefaultFOV,
fDefault1stPersonFOV and fDefaultWorldFOV), so it can't affect other
players.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur
Replaying SkinFadeIn after 1s, 3s and on every gear change restarted the
skin's fade each time, so other players lost the transform intro and saw
the body appear late. The event is now retried only until the copy's
skin graph takes it, then left alone, and retried again for a respawned
copy. Info logs cover sending, receiving and taking the events and the
beast form respawns, for the second-transform problem.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur
Include the TESRace definition before accessing its form ID in CharacterService.
…mage

Players report that enemies barely hurt remote party members while the
leader takes about twice the damage. This adds client-only [DmgDiag]
logging on the damage path: every hit involving a player (who applied it,
raw and difficulty-scaled damage, armor, health before and after), every
health delta sent and received, and the owner's health snapshots for
players. Lines that look like a duplicate or mismatch are logged as
warnings with a FLAG reason. No protocol or gameplay changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fvQxSwpo46aX4h3sn2BYB
@absol89 absol89 self-assigned this Nov 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants