docs/pointer_map_v1.md

Pointer Map v1

Target: KH2 Final Mix PC (Steam Global)
Module base: KINGDOM HEARTS II FINAL MIX.exe
Source of truth for current constants: runtime/include/kh2coop/KH2Offsets.hpp

Status Legend

  • [CONFIRMED] = Verified live in Cheat Engine on target build
  • [KH2LIB] = Pulled from KH2 Lua library, not fully live-verified in this project
  • [GHIDRA] = Verified by static analysis of the target executable
  • [LIVE] = Observed through the injected DLL/scenario rig; evidence linked alongside the claim
  • [UNKNOWN] = Not mapped yet

Mapped vs Missing (Current Snapshot)

AreaStatusNotes
World/room identityDoneStatic NOW block + commit path mapped (staging at 0x717018, helper axaAppMain+0x3A00, KH2J table 0x9A98B0). See NOW commit path.
Cutscene/transition statePartialNative transition request and load-completion callbacks mapped and hooked; arrival requires a completed load plus the full target location. Cutscene hold remains separate work. See room transition lifecycle.
Unit slot stat blockPartialSLOT0_BASE, SLOT_STRIDE, slot::HP, slot::MAX_HP confirmed. MP offset unknown.
Actor transform (slot 0)DoneEntity struct layout fully mapped: position, rotation, velocity, airborne flags.
Actor transform (slot 1/2)DoneSlot1+0x220/+0x228 actor pointers, entity at actor+0x640. Same layout as slot 0.
Entity discoveryDoneTwo-strategy scan: camera actor pointer chain (primary), vtable+W+moveState heuristic (fallback). Auto-discovers on room transition via Tick().
Position buffer arrayDoneBuffer at exe+0xAD9100, stride 0x38. Dual-write for physics-active rooms.
Camera structDoneFull camera struct at exe+0x718C60 with look-at, eye position, actor pointer, distance.
Camera retargetDoneFake actor allocation + pointer redirect at camStruct+0x50. Implemented in WriteCameraTarget() / RestoreVanillaCamera().
Enemy list rootDoneActive-entity list head at exe+0x2A171C8; next handle at actor+0xA90; handle region table at exe+0x2B0D720. Enemy class is objentry type 3/4; the old moveState/name filter missed some mobs. Checked traversal completion is required to establish absence.
Input systemDoneFull pipeline mapped: raw collection (exe+0x105810), button mapping (exe+0x39C720), processed state (exe+0xBF31A0). Friends are AI-only; two injection strategies defined. See Input system.
Animation IDDoneactor+0x180 (DWORD), maps to OpenKH MotionSet enum. Verified IDLE/RUN/JUMP/FALL/LAND/ATTACK.
Entity update chainDoneFull call chain traced: update loop (0x3BF5E0) → per-entity update (0x3BFD30) → position physics (0x3B89A0) → position calc (0x3B9090) → MEMCPY_4FLOATS. Strategy B hook target identified; current live M3 work is on suppressing the friend vtable +0x28 pre-physics callback to remove residual Sora tethering.
Replica writebackPartialApplyReplicaActorState() works for slot 0 (position, rotation, flags, HP) and camera fake actor (all slots). Enemy replica still TODO.

Known Offsets

World / room

OffsetNameSource
0x0717008WORLD_ID[CONFIRMED]
0x0717009ROOM_ID[CONFIRMED]
0x071700ANOW_EXTRA / entrance door (byte)[CONFIRMED] third byte in NOW; commit helper 0x152BE0 writes only this byte, not a u16
0x071700CMAP_PROGRAM[KH2LIB]
0x071700EBATTLE_PROGRAM[KH2LIB]
0x0717010EVENT_PROGRAM[KH2LIB]
0x0717018NOW_STAGING_BASE[CONFIRMED] 8 bytes (movsd); first bytes become world/room after axaAppMain+0x3A00 commit (see below)
0x0717120NOW_STAGING_XMM_B[CONFIRMED] alternate branch: movsd [exe+0x717120], xmm0 from same [rdi] template
0x0717128NOW_STAGING_WORD[CONFIRMED] mov [exe+0x717128], ax from [rdi+08] (16-bit field)
0x09A98B0KH2J_PROGRAM_TABLE[CONFIRMED] table labeled "KH2J:" in CE; indexed from packed world/room to fill map/battle/event programs

NOW commit path (disassembly)

Live Steam Global build (Cheat Engine symbols). The authoritative world/room/program values in the NOW block are filled by a helper under the export KINGDOM HEARTS II FINAL MIX.axaAppMain:

CE labelRole
axaAppMain+0x37B0Staging fill (prologue sub rsp,20 …). rcx → rdi = pointer to a source record in RAM. r8d/edx/r9b carry packed args (seen as esi/ebx/bpl in callee). Two branches: (A) movsd xmm0,[rdi] then movsd [exe+0x717120],xmm0 at +0x37EC, then mov [exe+0x717128], ax from [rdi+08] at +0x37F8. (B) Other path reaches movsd [exe+0x717018], xmm0 at +0x387E (same xmm0 from [rdi]). Next op movzx eax,[rdi+08] at +0x3886 matches common hardware-hit RIP when watching 0x717018 (fault lines up on the following instruction). Ignore bogus rol byte ptr … if your listing starts earlier — align on +0x37B0.
axaAppMain+0x3A00Commit to NOW: rcx = exe+0x717018 (typical lea rcx,[exe+0x717018]). Reads bytes from staging, writes exe+0x717008 … 0x717010, using exe+0x9A98B0 (KH2J:).
axaAppMain+0x3A28mov [exe+0x717009], r11b — store to ROOM_ID (watchpoint may show RIP at +0x3A2F).
Callers above +0x3A00lea rcx,[exe+0x717018] then call axaAppMain+0x3A00 (e.g. +0x3994–+0x39A2 region).

RE implication: Trace callers of axaAppMain+0x37B0 (who sets rdi and the r8/rdx/r9 args) to find transition / request layer. rdi is the live location packet, not the static exe staging slot.

Call sites that call axaAppMain+0x37B0 (staging fill)

Captured 2026-03-25 via Cheat Engine MCP find_call_references on the function entry at axaAppMain+0x37B0 (same as disassembly prologue at +0x37B0). 23 distinct call sites in KINGDOM HEARTS II FINAL MIX.exe.

In CE: go to KINGDOM HEARTS II FINAL MIX.axaAppMain+37B0, then Find out what addresses call this address (e.g. Ctrl+R); the list below should match.

RVAs are relative to KINGDOM HEARTS II FINAL MIX.exe image base (use Ctrl+G → KINGDOM HEARTS II FINAL MIX.exe+<RVA>).

#exe RVANotes
1+0x15112CNear other axaAppMain code — good first stop for arg setup
2+0x154ECCSame
3+0x30A605Deeper / map-load style band
4+0x30BC89
5+0x38F827
6+0x3946E6
7+0x3A49D4
8+0x3F1DD4
9+0x3FCC44
10+0x3FF303
11+0x3FFD55
12+0x42E430
13+0x42E4A2
14+0x434A48
15+0x434CD2
16+0x434D1D
17+0x436E29
18+0x5437C6High-RVA cluster (often UI / flow helpers)
19+0x543A9A
20+0x545CA2
21+0x54648A
22+0x54806A
23+0x549153

Absolute VAs are ASLR-dependent; only RVAs are stable across runs.

Tracing caller #1 (exe+0x15112C) — worked example

Disassembly around KINGDOM HEARTS II FINAL MIX.exe+0x15112C (live MCP capture, Steam Global):

  1. call to staging fill — at exe+0x15112C: call → axaAppMain+0x37B0 (VA …492990).

  2. Register args right before the call (read upward a few lines):

    • lea rcx,[rsp+30] at exe+0x151123 → rcx = pointer to a stack-local “location packet” (8-byte movsd shape + 16-bit field at [rsp+38], matching [rdi] / [rdi+8] inside the callee).
    • xor r8d,r8d / xor r9d,r9d on this merge path → then lea edx,[r8+1] at exe+0x151128 → edx = 1.
    • So this path calls staging fill with packed edx/r8/r9 = (1, 0, 0) and rcx = packet on stack.
  3. Where the packet bytes come from (still above the call, same function):

    • One branch: call exe+0x3A0520 (helper just above exe+0x15108E in this capture), then movsd xmm0,[rax] / movzx from [rax+8] into [rsp+30] / [rsp+38] — so rax points at an in-memory source struct after that helper returns.
    • Another branch: movzx / mov from runtime globals (CE shows absolute 0x7FF7…C40719 range — heap / mutable, not a fixed exe+ offset) into [rsp+30]…[rsp+38].
  4. Next RE step (go up the chain):

    • Who calls this function? Put cursor on exe+0x15112C, Find out what addresses call this function on the containing routine (scroll up to the function prologue — push / sub rsp — first; the first bytes at exe+0x151080 may be misaligned in a raw dump).
    • Then repeat: at the parent call, note rcx/rdx/r8/r9 and any rax filled by a child call.
Step 1 done: child function + direct parents (exe+0x151000)
Itemexe RVANotes
Child (builds stack packet → call axaAppMain+0x37B0)0x151000–0x151155 (~342 B)Contains call at 0x15112C into staging fill.
Parent A (call → child)0x150CFEAfter call exe+0x152940, tests dword [C406B8]; non-zero → call exe+0x151000, then call exe+0x3DB640. Routine prologue ~0x150CA2 (push rbp, sub rsp,20).
Parent B0x150E2ASame [C406B8] gate; call exe+0x151000 then mov bpl,1. Same overall task as A, different branch (longer path from ~0x150D44: r8d=[C40724], table lea rdi,[exe+0x5B1510], calls 624210/624680, 6DD400, etc.).
Parent C0x15150CSame pattern as A: call exe+0x152940, [C406B8] gate, call exe+0x151000, call exe+0x3DB640.

Globals (runtime VAs in CE; names TBD): C406B8 = “call 0x151000” gate (dword). C406B4, C40724, C406B1, C406B0, C406C8/C406D0 appear in the same neighborhood.

Next (step 2 up): In CE, Find out what addresses call the routine at exe+0x150CA2 (or 0x150D44 for the wider function). If CE shows no xrefs, use “Find references” / AOB for the call E8 sequence or trace who sets [C406B8].

Step 2 automation (2026-03-25) — MCP + CE freeze
AttemptResult
find_call_references on exe+0x150CA0, 0x150CA2, 0x150D44 (absolute VAs)0 callers each (bridge/tool may miss tail calls, jmp entries, or non-standard prologues).
evaluate_lua: byte-step scan of whole KINGDOM HEARTS II FINAL MIX.exe for E8 rel32 targeting base+0x150CA2First short run: count = 0 (no call lands on that exact VA — entry may be 0x150CA0 only, or ff 25 / jmp).
evaluate_lua: extended scan (multiple targets, full module)Aborted; Cheat Engine froze — do not loop readByte/readInteger over ~45 MB in Lua inside CE.

Safe alternatives: CE disassembler → Find out what addresses call this address on exe+0x150CA2. Or xref mov [C406B8] / hardware write watch on C406B8 (sparse). Or aob_scan MCP with a narrow +X region, not a Lua per-byte walk.

Game state

OffsetNameSource
0x0ABB7F8PAUSE_STATUS[KH2LIB]
0x2A11384BATTLE_STATUS[KH2LIB]
0x2A0FC60BATTLE_END[KH2LIB]
0x2A17168CONTROLLABLE[KH2LIB]
0x0B64F18CUTSCENE_TIMER[KH2LIB]
0x0B64F34CUTSCENE_LEN[KH2LIB]
0x0B64F1CCUTSCENE_SKIP[KH2LIB]
0x0717424GAME_SPEED[KH2LIB]
0x08EC540LOADING_INDICATOR[KH2LIB]
0x07435D0CURRENT_OPEN_MENU — FF none, 01 save, 03 load, 05 moogle, 07 item popup, 08 pause (cutscene/fight), 0A pause[KH2LIB]
0x29FB500CONTINUE[KH2LIB]
0x2A0C4C0SVE — saved location[KH2LIB]
0x0BF3340MSN — mission[KH2LIB]
0x07535C0RNG[KH2LIB]
0x2AE5CF8SPAWNS[KH2LIB]
0x2A0F7A8ARD_POINTER — room script data[KH2LIB]
0x2A25030OBJENTRY_POINTER (00objentry.bin), first of three slots filled by 3E0E00. The KH2 Lua library's 0x2A24FB0 is 0x80 low on this build and reads ASCII[GHIDRA]; live E1-02 matched rows 84/90
0x2AE5E50 / 0x2AE5E58SYS3_POINTER / BTL0_POINTER (03system.bin / 00battle.bin), stored by loader 3F4C30. The library's 0x2AE5DD0/0x2AE5DD8 are 0x80 low and not pointers on this build (E1, 2026-10-06)[GHIDRA]
0x2A252E0 / 0x2A252E8MEMT_POINTER / MEMT_END (03system memt): 3E2E90 stores the section and section + 8 + count*0x34[CONFIRMED] (Ghidra + live E1-02)
0x2A25300RESOLVED_PARTY_MEMBERS: u16[18] object IDs (player, Donald, Goofy, world ally, forms...) resolved by 3E2EB0 on every area load (39C860). Entries match on world 0 or the current world, flag set and negation clear; later non-zero members override. Writing MEMT entry 6 member 0 = 0x5A made the GoA player Roxas after a same-room reload[CONFIRMED] (VUH-1513 E1-02, 2026-10-06)

CONTROLLABLE reads 3 during cutscenes and 0 when a minigame starts (GoA ROM script). Writing 2 to PAUSE_STATUS disables pausing (GoA ROM script).

Save region (Steam Global)

OffsetNameSource
0x09A98B0SAVE — live save body; starts with the magic "KH2J" (the KH2J_PROGRAM_TABLE label above is this block)Archipelago KH2 client (Steam), matches the live CE label. The KH2 Lua library's Steam table says 0x09A9830 — an older patch; several of its addresses sit 0x80 low (its Slot1 0x2A23518 vs Archipelago's 0x2A23598). Treat other [KH2LIB] addresses on this page as unverified until checked live.
SAVE+0x0C / +0x0D / +0x0Esaved world / room / door[KH2LIB] (GoA ROM Warp)
SAVE+0x10 + 0x180*world + 0x6*roomper-room map / btl / evt program table (3 shorts)[KH2LIB] (GoA ROM Warp)
SAVE+0x3534 + 4*worldworld party table: 4 bytes per world, [player, friend1, friend2, world ally], with 0x00 = playable character, 0x01 Donald, 0x02 Goofy, 0x12 empty. The GoA ROM writes 0x12020100 (full party) / 0x12121200 (Roxas only) at +0x353C (world 2, Twilight Town) and +0x357C (world 0x12). This is OpenKH WorldPartyMembers 0x3534 and Expert595's "party table at save +0x3534" with no offset shift. GoA (world 4) friend1 = SAVE+0x3545[KH2LIB] (GoA ROM), OpenKH, Expert595
SAVE+0x3524current form (6 = Anti)[KH2LIB] (GoA ROM)
SAVE+0x1C90 (19 × 0x20)world story flags, ending at +0x1EF0; the next bytes belong to separate auxiliary structuresNative readers/setter 0x39A980 / 0x39AFC0, table initialization 0x39ACD0
SAVE+0x22F8 (8 × 19)room-visited flags, ending at +0x2390Native reader/setter 0x3BAEA0 / 0x3BAF20, clear 0x3BAF00
SAVE+0x23AC (known chest indices 1…411)chest flags; GoA map chest uses +0x23DF mask 0x02 (overall flag 409). Unknown bits 0 and 412…415 are excluded from mirroringNative setter/reader 0x3A1B60 / 0x3A1EA0, chest initialization 0x402200; local treasure table corroboration
SAVE+0x24F0 (13 × 0x114)personal character stats, excluded from progress mirroringOpenKH layout; native personal preservation remains a live acceptance check
SAVE+0x3580 (length 0x140)personal inventory, excluded from progress mirroringOpenKH and local KH2 Lua inventory table

OpenKH vs in-memory offsets: There is no universal eight-byte shift. OpenKH places world ID at +0x0C, after magic, version and checksum. Native readers/writers independently establish the story and visited ranges above. OpenKH's proposed twentieth story block overlaps native auxiliary state; that state is excluded until its schema is understood. The progress allow list in common/include/kh2coop/ProgressAllowList.hpp also masks unverified chest bits. The known chest extent comes from a local mod treasure table, corroborated by native addressing and the GoA chest entry; it is not proof of the complete installed archive or all story-setter side effects.

The GoA ROM's Warp(W,R,D,M,B,E) writes the NOW block (world, room, door, programs) and the saved location, but only redirects a transition already in progress (from the world map). It also swaps party costumes by rewriting objentry model-name strings before rooms load: with obj0 = the pointer read from OBJENTRY_POINTER, obj0+0x16F0 is Donald's model name and obj0+0x1750 Goofy's.

Room transition request (cold warp) — verified live 2026-10-02

RVARoleSource
0x152990void __fastcall RequestTransition(const LocationPacket*, u32 fadeFlags, int mode, u8 flag, int extra). Starts the room-load task (callback 0x152A90) and copies the packet into NOW staging 0x717018/0x717020 (or 0x717120/0x717128 when byte 0x9BA8D1 is set). 23 traced callers include the room-script Jump handler 0x3A46E0 and the world-map flow 0x154E20 → 0x151000; this does not prove coverage of every initial-load or scripted path.[GHIDRA] + [CONFIRMED] + [LIVE]
0x152BE0void __fastcall CommitLocation(const LocationPacket*). Copies world/room/door bytes to NOW; resolves map/btl/evt from the save's room table unless the corresponding packet field is not 0xFFFF.[GHIDRA]
0x152680void __fastcall LoadRoom(void* task). Runs room loading, including script redirects, then schedules 0x152CD0 through 0x1506B0.[GHIDRA]
0x152CD0void __fastcall LoadComplete(void* task). Sets IN_FIELD (0x9BA8D0) to 1, runs native room finalizers, then calls 0x1500B0(task) (zeros the task callback and returns). The detour records completion after the original callback returns.[GHIDRA]; hook bytes verified and installed in the live run below
0x152F40void __fastcall LoadCompleteDirect(). Equivalent field-live/finalizer path without the task argument or task-clear tail. The detour also records completion after return.[GHIDRA]; hook bytes verified and installed, but the shared live completion log does not identify which callback fired
0x3A46E0Room-script Jump handler: builds a packet from the script operand and calls 0x152990(&packet, flags, 0 or 2, 0, op[6]).[GHIDRA]
0x154E20World-map exit: packet with programs 0xFFFF, then 0x152990(&packet, flags | 1, 0, 0, 0).[GHIDRA]

LocationPacket uses u8 world, u8 room, u16 door, u16 map, u16 btl (8 bytes), then u16 evt at +8; the DLL pads the buffer to 16 bytes. The native commit consumes only the low door byte, so host-follow requests reject doors above 0xFF and read current door as u8. Programs of 0xFFFF take the save's per-room values. The inject DLL calls 0x152990(&packet, 1, 0, 0, 0) from the PerEntityUpdate hook at the start of a frame (Warp.cpp, kh2ctl warp). All three request/completion hook targets must match their first 24 executable bytes before installation. The earlier cold-warp test's 50 consecutive warps across 13 rooms in three worlds loaded cleanly.

Load signals. The request stages a target; the commit changes NOW before the new room has finished loading. 0x9BA928 (the load task pointer written by 0x152990), byte 0x9BA8D1 and LOADING_INDICATOR 0x8EC540 didn't change when sampled every 50 ms through a load. Earlier code inferred loading from a ~0.55 s entity-update stall. That heuristic is superseded: a pause can stall updates, and a same-room reload can leave all location fields unchanged. Native completion callbacks now provide the load generation; NOW equality or elapsed time alone never proves arrival.

Lifecycle and authority (VUH-1496). Warp.cpp increments a transition serial when accepting a request and a load serial after a native completion callback returns. Client-native calls to 0x152990 are blocked; only the host-command path bypasses that detour through its trampoline. The current session role is checked at the native call, so disconnect releases the client lock. EnemySync.cpp and EntityHook.cpp discard old enemy/puppet/clone and hit-target caches without restoring fields through stale actor pointers. They rebind from subsequent entity updates, including after same-room reloads. If the lifecycle hooks cannot be verified, these actor writers remain suspended.

Every accepted host epoch, including a join to an already matching location, queues a real reload. TransitionAck(arrived=true) is queued only after a later load generation, the safe gameplay gate, and exact equality of world, room, door, map, battle and event. The gate requires no frozen entity groups, IN_FIELD != 0, no open menu, idle native timeline state and no active event context. The elapsed cutscene timer is diagnostic only. The host advances its epoch when the new room instance becomes live; request acceptance alone is not client arrival. This establishes completion behavior, not proof that every native exit path is intercepted.

First live host-follow evidence, 2026-10-02. The smoke report records a passing initial 04/1A checkpoint with zero puppet position error on both connected instances. The host log then records load serial 3 and host epoch 2 at 02/00 with door/map/btl/evt all 0. The client log orders epoch-2 queued → issued (transition 3) → load complete (serial 4) → arrived at the same full target, and the relay log records TransitionAck slot=1 epoch=2 room=02/00 arrived=1.

That run failed the before_late_join checkpoint: captured state reports both native friend positions at zero and a 500-unit puppet error in Twilight Town. It is evidence for request/load/ACK ordering, not a passing multiplayer acceptance run or proof of late join, same-room reload, or native exit blocking. The missing bindings are consistent with the documented R12 party-slot limitation; the capture lacks an actor roster/raw friend pointers needed to conclusively identify that cause. Both completion hooks use the same log line, so this run cannot separately establish which completion RVA fired.

[KH2LIB] state flags on this build. PAUSE_STATUS 0xABB7F8 reads garbage (0xE5E04CA0). +0x80 (0xABB878) reads 0. CONTROLLABLE 0x2A17168 reads 142 in normal play, and CURRENT_OPEN_MENU 0x7435D0 reads FF. None of them changed when tested (no menu could be opened by script), so they stay unverified.

Static +0x80 corrections ([GHIDRA], not yet checked live). The game's code addresses these at the library value +0x80:

RVAMeaning
0x2A11404 (BATTLE_STATUS+0x80)battle type: 0 none, 1 battle (enemies near), 2 forced "command cage". 0x3AB690 sets 2 (BDX trap_command_cage_on, table 1 #117, or a battle program flagged forced); 0x3AB4A0 clears it in field (trap_command_cage_off #118, or the battle program's end, table 2 #143) and re-evaluates 0/1; 0x3ABAB0 is the area-load reset; 0x3AB6B0 is the 0/1/2 setter; 0x3ABB20 tests != 0
0x2A171E8 (CONTROLLABLE+0x80)frozen-entity-group bitset; 0x3BFA40 skips an entity whose group bit is set. 0 = nothing frozen
0xABB878 (PAUSE_STATUS+0x80)pause-blocker bitmask; the pause watcher 0x1572B0 opens pause only when it is 0, 0x9BA8D0 != 0 and Start is pressed
0x9BA8D0"in field" byte; the load task 0x152A90 clears it at load start; 0x152CD0 / 0x152F40 set it before running finalizers, so the byte alone is not completion proof
0xB64F98 (CUTSCENE_TIMER+0x80)elapsed timeline position, retained after completion; not an active-event predicate
0xB65210 (CUTSCENE_STATE)int32 timeline lifecycle; updater 0x2CC8A0 runs only in state 3, teardown 0x2C85B0 and transition reset 0x2C87C0 clear this state without clearing the timer
0x2A11478 (EVENT_CONTEXT)active event-context pointer; native predicate 0x3AC220 tests nonzero, completion 0x3AC000 and cleanup 0x3AC0E0 restore event policies then clear it
0x8EC5C0 (LOADING_INDICATOR+0x80)loading indicator
0x2AE5D78 (SPAWNS+0x80)not an enemy toggle: a rotating 0–7 index for an effect's random spread (0x3F3FA0)

CURRENT_OPEN_MENU 0x7435D0 needs no shift. Current warp/arrival safe-state gate: 0x2A171E8 == 0 && 0x9BA8D0 != 0 && menu == 0xFF && 0xB65210 == 0 && 0x2A11478 == 0.

The native GoA map chest exposed the stale-timer gate on 2026-10-02: after the popup disappeared, frozen groups were 0, in-field 1 and menu 255, while the timer remained 90. Independent decompilation confirmed that the timer is retained. Runtime gate and event flags now use both lifecycle and context. The native chest calibration passed: an active popup had state 3 and a non-null context; completion left timer 90 with both predicates zero. Native warps then succeeded and ReadRoomState() reported no cutscene. Chest flag 409 initialized motion 152 after reload; the immediate native opening motion was 153. The separate three-instance chest run also passed client next-load mirroring, late join and a subsequent reload. All six client applies preserved personal bytes immediately around writes; full character/inventory/munny/EXP snapshots and four disk save hashes stayed unchanged. This establishes chest behavior, not all native story side effects.

Unit slot stat system

OffsetNameSource
0x2A23518SLOT0_BASE[KH2LIB]
+0x278SLOT_STRIDE[KH2LIB]
+0x80slot::HP (within slot)[CONFIRMED]
+0x84slot::MAX_HP (within slot)[CONFIRMED]

Actor object offsets (CE Dynamic Session — 2026-03-31, CONFIRMED)

OffsetNameSourceNotes
+0x180actor::ANIM_ID[CONFIRMED]DWORD, current animation/motion ID. Maps to OpenKH MotionSet: IDLE=0, WALK=1, RUN=2, JUMP=3, FALL=4, LAND=5, EX000=151. Verified via CE snapshot diff across all states.
+0x184actor::ANIM_SUB[CONFIRMED]DWORD, animation sub-state / variant index
+0x640actor::ENTITY_TRANSFORM[CONFIRMED]Entity transform struct base (position, rotation, etc.)
+0x9B8actor::FLAGS[CONFIRMED]DWORD, entity flags (OR'd with 0x4000, 0x500 by calc_motion)
+0x9C0actor::STATE_PTR[CONFIRMED]QWORD, state pointer (non-zero = active)
+0xA90actor::LINKED_NEXT_HANDLE[CONFIRMED]DWORD, next active-entity handle used by EntityUpdateLoop
+0xA58actor::ACCEL_X[CONFIRMED]Float, read by EntityPositionPhysics at exe+0x3B8C02 (movups xmm0,[rbx+0xA58]).
+0xA5Cactor::ACCEL_Y[CONFIRMED]Float, part of the acceleration vector consumed from actor+0xA58.
+0xA60actor::ACCEL_Z[CONFIRMED]Float, part of the acceleration vector consumed from actor+0xA58.
+0xB98actor::VELOCITY_X[CONFIRMED]Float, read by EntityPositionPhysics at exe+0x3B8B85 (movups xmm0,[rbx+0xB98]).
+0xB9Cactor::VELOCITY_Y[CONFIRMED]Float, part of the velocity vector consumed from actor+0xB98.
+0xBA0actor::VELOCITY_Z[CONFIRMED]Float, part of the velocity vector consumed from actor+0xB98.

Entity transform struct (relative to dynamic struct base)

OffsetNameSourceNotes
+0x00VTABLE_PTR[CONFIRMED]QWORD, exe 0x253xxxx range
+0x08AIRBORNE_FLAG[CONFIRMED]DWORD, sticky: set at a jump, stays 1 after landing. Use +0x104 for airborne (VUH-1490)
+0x30POS_X[CONFIRMED]float
+0x34POS_Y[CONFIRMED]float (negative = up)
+0x38POS_Z[CONFIRMED]float
+0x3CPOS_W[CONFIRMED]float (always 1.0)
+0x40COS_FACING[CONFIRMED]float
+0x48SIN_FACING[CONFIRMED]float
+0x4CROT_Y[CONFIRMED]float, radians
+0xA4VEL_Y[CONFIRMED]float (airborne Y velocity)
+0x100MOVE_STATE[CONFIRMED]DWORD, 2=ground, 3=air
+0x104AIRBORNE_SUB[CONFIRMED]DWORD, 0=ground, 1=air

Entity position buffer array

OffsetNameSourceNotes
0xAD9100buffer::ARRAY_BASE[CONFIRMED]static array
+0x38buffer::ENTRY_STRIDE[CONFIRMED]per entry
+0x00ENTRY_POS_X (within entry)[CONFIRMED]float
+0x04ENTRY_POS_Y[CONFIRMED]float
+0x08ENTRY_POS_Z[CONFIRMED]float
+0x0CENTRY_POS_W[CONFIRMED]float (1.0)

Entity discovery constants

ValueNameNotes
0x2500000..0x2600000Scan rangeEntity data region in exe
0x2530000..0x2540000Vtable rangeEntity vtable pointers fall here
0x1354E0POS_UPDATE_FUNCCode address (stable)
0x1A8E60MEMCPY_4FLOATSCode address (stable)
0x456696ENTITY_POS_WRITERCode address (stable)

Camera struct (relative to exe+0x718C60)

OffsetNameSourceNotes
+0x08SMOOTH_LOOKAT[CONFIRMED]Vec4 (X,Y,Z,W)
+0x18EYE_POS[CONFIRMED]Vec4 interpolated
+0x48CAMERA_TYPE[KH2LIB]DWORD camera mode
+0x50ACTOR_PTR[CONFIRMED]QWORD ptr to followed actor
+0x58DISTANCE[CONFIRMED]float (~500)
+0x64EYE_POS_RAW[CONFIRMED]Vec4
+0x74EYE_POS_COPY[CONFIRMED]Vec4
+0x84LOOKAT_RAW[CONFIRMED]Vec4
+0x94LOOKAT_COPY[CONFIRMED]Vec4
+0xA4HEIGHT_OFFSET[CONFIRMED]float (~1.5)

Camera actor pointer chain: camStruct+0x50 -> actorObj+0x640 -> entity+0x30 = position

Input / other

OffsetNameSource
0x0718CA8CAMERA_TYPE (legacy alias)[KH2LIB]
0x0BF3120INPUT[CONFIRMED]
0x0ABABDASOFT_RESET[KH2LIB]

Input system (Ghidra RE Session — 2026-03-31, CONFIRMED)

Full input pipeline traced via Ghidra static analysis. See docs/INPUT_RE_SESSION.md for the complete disassembly walkthrough.

Architecture:

Hardware (XInput / Steam Input / DXInput)
  │
  ▼
exe+0x105810  — main input collector (FUN_140105810)
  │  reads raw gamepads/keyboard/mouse
  │  supports up to 4 XInput + 4 Steam Input controllers
  │  SWAPS active controller data into slot 0
  │  writes to raw input slots at struct_base+0x18 + slot*0x44
  │
  ▼
exe+0x39BF00  — game loop input callback (FUN_14039bf00)
  │  calls exe+0x39C720 per entry — maps raw buttons→game actions
  │  via mapping table at exe+0x5C3420
  │  writes processed button bitmask to fixed-address array
  │
  ▼
Processed button state at exe+0xBF31A0 (2 entries, stride 0x68)
  │  Entry 0: exe+0xBF31A0  (current buttons, new-press, release, repeat, analog)
  │  Entry 1: exe+0xBF3208
  │
  ▼
~30 game systems read processed state via exe+0x39B580 context switch

Input struct pointers

OffsetNameSourceNotes
0x079CF00INPUT_STRUCT_PTR[CONFIRMED]QWORD ptr to input state struct
0x0BF3120INPUT (raw slot 0)[CONFIRMED]= struct_base + 0x18
0x0BF3164Raw slot 1[CONFIRMED]= struct_base + 0x18 + 0x44
0x0BF31A0Processed entry 0[CONFIRMED]0x68-byte processed button state
0x0BF3208Processed entry 1[CONFIRMED]same layout
0x05C3420Button mapping table[CONFIRMED]raw pad → game action bits
0x08BB290DXInput struct[CONFIRMED]keyboard/mouse subsystem state

Raw input slot layout (0x44 bytes per slot, at struct_base + 0x18 + slot * 0x44)

The March labels for the two sticks were reversed. Native movement/menu calibration on2026-10-02 corrected the layout below; it matches KH2Offsets.hpp and the current kh2ctl player-input path. See raw slot calibration.

OffsetTypeNameNotes
+0x00ushortBUTTONSRaw button bitmask
+0x02byteRSTICK_XRight stick X (0x80=center)
+0x03byteRSTICK_YRight stick Y (0x80=center)
+0x04byteLSTICK_XLeft stick X (0x80=center)
+0x05byteLSTICK_YLeft stick Y (0x80=center)

Processed entry layout (0x68 bytes per entry)

OffsetTypeNameNotes
+0x00ulonglongCurrent buttonsGame action bitmask
+0x08ulonglongNew pressNewly pressed this frame
+0x10ulonglongReleaseReleased this frame
+0x18ulonglongAuto-repeatRepeat trigger
+0x204 floatsAnalog dataLeft stick
+0x304 floatsAnalog data 2Right stick
+0x40pointerContextMode pointer
+0x48dwordFlagsbit 0=disabled, bit 1=type

Key code addresses (stable)

exe RVANameNotes
0x105810InputCollectorMain per-frame input collection
0x39BF00InputLoopCallbackRegistered game loop callback for button processing
0x39C720ButtonMapperMaps raw pad → game action bitmask
0x39B580ContextSwitchInput context switch (called ~30 sites)
0x134FF0PerControllerUpdatePer-controller raw read + dispatch
0x133970GamepadEnumBindXInput/Steam enumeration and binding
0x132F90DXInputPollDXInput keyboard/mouse poll with critical section

Critical finding: friends do not use input

KH2 consolidates all active controller input into slot 0 only. Friend characters (Donald/Goofy) are entirely AI-driven. The game swaps whichever controller has activity into slot 0; there is no per-party-slot input buffer.

RTTI class hierarchy for friends:

Friend@kn → FRIEND@YS → PARTY@YS → BTLOBJ@YS → STDOBJ@YS → OBJ@YS

Related RTTI classes: FriendPersonality@kn, FRIEND_FORMATION@gb, ACTION_FRIEND_FLY@kn.

Debug strings found: "friend attack:", "gentle friend", " friend1:", " friend2:", " friend3:", "FRIEND RECOV %3d".

M3 injection strategies

Strategy A — Puppet Mode (implement first): Write directly to entity position/rotation/action fields. Already partially working via ApplyReplicaActorState(). Needs animation ID offset for visual sync.

Strategy B — AI Replacement Hook (future): Hook the friend AI tick function via DLL injection (Panacea-style). Replace AI output with player input. Requires finding the FRIEND@YS vtable dispatch and the per-frame AI decision function. Use CE hardware write breakpoint on friend entity position to find the callstack.

Friend entity discovery (RE Session — 2026-03-26, CONFIRMED)

The first friend unit slot (Slot 1, at SLOT0_BASE + SLOT_STRIDE) stores actor object pointers to both friend party members:

Offset within Slot 1NameSourceNotes
+0x220slot::FRIEND1_ACTOR_PTR[CONFIRMED]QWORD, pointer to Friend 1 actor object
+0x228slot::FRIEND2_ACTOR_PTR[CONFIRMED]QWORD, pointer to Friend 2 actor object

Entity struct is at actor + 0x640 (same as Sora). Friends share the exact same entity struct layout as the player — position, rotation, velocity, airborne flags all at the same offsets.

Key differences from player entity:

  • Friends have moveState=0 (AI-controlled) vs player moveState=2 (ground) / 3 (air)
  • Slot 0 and Slot 2 do not have actor pointers at +0x220/+0x228 — only Slot 1 stores both
  • Actor addresses are dynamic and change per room transition (re-discover after each transition)

Verified in: TT Room 7, TT Room 8 (Dusk fight), Mysterious Tower Room 25. Friend entities were at different addresses in each room, but the Slot1+0x220/0x228 discovery path worked consistently.

Enemy entity layout (RE Session — 2026-03-26, PARTIAL)

Enemy entities use the same entity struct layout at actor + 0x640. Active enemies have moveState=8 (or 9 for alt state). Dead/freed enemy slots have garbage data.

Enemies of the same type are allocated in contiguous actor slots:

Enemy typeStrideWorld/Room
Dusk (TT Room 8)0x6C00World 2 Room 8
Nobodies (Mysterious Tower Room 25)0x72F0World 2 Room 25

Stride varies by enemy type. A fixed stride cannot be assumed. The canonical runtime container is not a dedicated enemy array; it is the active-entity linked list headed by exe+0x2A171C8.

Actor objentry descriptor (CE Session — 2026-03-31, PARTIAL)

actor+0x918 points into the exe objentry/descriptor table:

OffsetMeaningStatusNotes
+0x00objectId[CONFIRMED]dword ObjEntry ID
+0x04type/flags[PARTIAL]dword present, exact bit layout still under RE
+0x08object name[CONFIRMED]char[32], e.g. P_EX100, P_EX030, F_EX030_BB, N_BB080_TSURU1
+0x28mset name[CONFIRMED]char[32], e.g. P_EX100.mset

Live validation on 2026-03-31 from the current process:

  • P_EX100 -> id 84
  • P_EX030 -> id 93
  • F_EX030_BB -> id 321
  • N_BB080_TSURU1 -> id 397

Important caveat: a live non-combat N_... actor in the current room had moveState=8, so moveState 8/9 alone is not a safe enemy filter. Runtime enemy traversal now uses the objentry name prefix and only accepts B_... / M_... actors after the moveState check.

Active entity list / handle resolver (Ghidra + CE Session — 2026-03-31, CONFIRMED)

EntityUpdateLoop walks a global linked list of all active actors:

  • exe+0x2A171C8 — head pointer (DAT_142a171c8)
  • exe+0x2A171D0 — tail pointer (DAT_142a171d0)
  • actor+0xA90 — next-link handle for each actor node
  • exe+0x2B0D720 — 64-entry qword region table used to resolve handles to pointers

Handle resolution from FUN_1404ad3f0:

masked = handle & 0x7fffffff;
bucket = masked >> 25;
ptr = HANDLE_REGION_TABLE[bucket] | (masked & 0x01ffffff);

Live CE validation on 2026-03-31:

  • Current room: world 5, room 8
  • Active-list head read from exe+0x2A171C8: 0x7FF6EE0C2F60
  • First live links resolved cleanly through actor+0xA90:
    • 0x7FF6EE0C2F60 -> handle 0x8403A1B0 -> 0x7FF6EE03A1B0
    • 0x7FF6EE03A1B0 -> handle 0x84056FB0 -> 0x7FF6EE056FB0
    • 0x7FF6EE056FB0 -> handle 0x8407BA40 -> 0x7FF6EE07BA40
  • The current room contained 18 active list nodes total and 1 moveState 8/9 false positive (N_BB080_TSURU1) in a non-combat room
  • This established that active-list traversal needs an objentry-based class filter in addition to moveState

Result (superseded 2026-10-02, VUH-1486): an enemy is an active-list actor whose objentry type byte (actor+0x918 → record +0x04, OpenKH ObjectType) is 3 (BOSS) or 4 (ZAKO). Verified live in the Parlor Ambush: 8 M_EX020_RAW Shadows read type 4. Their team actor+0x4DC read 2; the party reads 1, props 0. The earlier moveState 8/9 + B_/M_ filter missed them because some enemies don't use the player's entity layout, so entity+0x100 reads garbage. kh2ctl entities lists every active actor with these fields. No enemy-only count global has been identified.

Entity update call chain (CE + Ghidra Session — 2026-03-31, CONFIRMED)

Traced via hardware write breakpoint on Friend1 entity position Y (actor+0x640+0x34). The breakpoint fired at exe+0x1A8E6F (inside MEMCPY_4FLOATS). Return address analysis + Ghidra xref tracing revealed the full call chain:

exe+0x3BF5E0  Entity Update Loop
  │  Iterates linked list at DAT_142a171c8 (all active entities)
  │
  ├─► exe+0x3BFD30  Per-Entity Update
  │     │  Dispatches to vtable virtual functions:
  │     │    vtable+0x08  — early update
  │     │    vtable+0x10  — main AI / action update (friend AI decision here)
  │     │    vtable+0x18  — post-main update
  │     │    vtable+0x20  — conditional update
  │     │    vtable+0x28  — pre-physics update
  │     │
  │     └─► exe+0x3B89A0  Position Physics
  │           │  Calculates velocity, gravity, applies movement delta
  │           │  Reads velocity from actor+0xB98, acceleration from actor+0xA58
  │           │  Live CE execution breakpoints confirmed both reads on Friend1:
  │           │    exe+0x3B8B85 (velocity) and exe+0x3B8C02 (acceleration)
  │           │
  │           └─► exe+0x3B9090  Position Calculator
  │                 │  Collision detection, final position computation
  │                 │  Writes to entity transform via MEMCPY_4FLOATS:
  │                 │    lea rcx,[rsi+0x670]  (entity+0x30 = position)
  │                 │    lea rdx,[rsi+0x700]  (computed position source)
  │                 │    call exe+0x1A8E60
  │                 │
  │                 └─► exe+0x1A8E60  MEMCPY_4FLOATS
  │
  └─► exe+0x3BEEC0  calc_motion (batch animation processing)
exe RVANameRole
0x3BF5E0Entity Update LoopIterates all entities, calls per-entity update
0x3BFD30Per-Entity UpdateDispatches vtable calls + calls position physics. Strategy B hook target.
0x3B89A0Position PhysicsVelocity / gravity / movement delta calculation
0x3B9090Position CalculatorCollision + writes final position to entity transform
0x3BEEC0calc_motionBatch animation/motion processing for all entities

EntityPositionPhysics consumes several independent movement terms. Its native epilogue clears actor+0xA48 through +0xA67, including +0xA64, with four qword stores at RVAs 0x3B9042/0x3B905F/0x3B9066/0x3B906D. Post-physics zero values therefore do not establish that those terms contributed nothing during the update.

Static movement terms (checked 2026-10-05)

Existing-binary decompilation and instruction bytes distinguish the following terms; these are capabilities, not attribution of a live position change.

Actor offsetMeaning on the normal physics path
+0x690 xyzDirect additive carried/transformed-frame delta; added at 0x3B8C8A to 0x3B8C96 independently of velocity.
+0xA48 xyzSeparate supplied displacement, passed to position calculation without the +0xA64 scale.
+0xA58 xyz / +0xA64Term multiplied by the global float at exe+0x717480 and +0xA64 before addition; physical units remain unassigned.
+0xA18 / +0xA28 / +0xA38 xyzSaved entry position, requested aggregate times exe+0x717488, and actual position difference times the same factor.
+0x6D0 xyzSolver start: copied from current +0x670 at 0x3B94CE; 0x16FEC0 receives the structure at this address.
+0x6E0 xyzSelected movement supplied to that solver. 0x16FEC0 / 0x1638F0 can rewrite it during solver passes; its post-call value need not equal the original input.
+0x700 xyzSolved position output, copied to +0x670 at 0x3B955C.
+0xA78 xyz(solved700 − old670 − S0) * exe[0x717488], written at 0x3B9535 to 0x3B9549. S0 is the pre-solver stack copy of +0x6E0 taken at 0x3B94DA, not sampled post-call +0x6E0.

0x3B81D0 retrieves polygon geometry and transforms a global vector into a surface basis; its old actor-pair separation label was incorrect. The explicit other-actor loop is in 0x3B9090, calling 0x40AD70 with the two actors' +0xA00 objects. The final solved position is copied from +0x700 to +0x670 at 0x3B955C. Alternate or skipped branches can leave summary fields stale. Sparse sequential reads cannot reconstruct all within-frame contributors. See exact static output and field references. The upstream calculator audit resolves the call-site pointer roles and the S0 distinction; these static capabilities do not identify a live originating force.

Strategy B hook target: exe+0x3BFD30 — intercept for friend entities, replace the vtable AI dispatch (vtable+0x10) with player input processing, then let exe+0x3B89A0 (physics) run normally for full game integration.

Live M3 note (2026-04-01): vtable +0x10 suppression is enough to hand local movement to Friend1, but not enough to remove all vanilla friend behavior. Left-stick solo control now works in practice, and the remaining Donald→Sora pull is currently attributed to the friend vtable +0x28 pre-physics callback (0x1401B0050 on the current build, tail-calling 0x1403D6870). That callback is now the main tether-removal candidate for the next live retest.

Still unknown (blocks further milestones)

NameNeeded forNotes
MP offset within unit slotM1 full statsKH2LIB GoA example suggests Slot+0x180/0x184

Runtime Bridge Coverage

File: runtime/src/GameBridgePC.cpp

MethodStatusNotes
Tick()ImplementedAuto-discovers entities (all slots) on room change, re-points camera
DiscoverEntityAddresses()ImplementedCamera chain (slot 0) + Slot1+0x220/0x228 (friends). Re-discovers on room transition.
ReadRoomState()ImplementedReads world/room/program/cutscene state
ReadActorState(slot)ImplementedAll slots: position, rotation, velocity, airborne, HP. Friends via Slot1 actor pointers.
ReadEnemyStates()PartialTraverses active-entity list head exe+0x2A171C8, resolves actor+0xA90 handles via exe+0x2B0D720, reads objectId from actor+0x918, filters objentry type 3/4. External traversal currently has silent read-failure/truncation paths; an empty result alone does not prove native absence.
WriteCameraTarget(slot)ImplementedFake actor allocation + pointer redirect
RestoreVanillaCamera()ImplementedRestores original pointer, frees memory
InjectOwnedInput(slot, input)TODOInput pipeline fully mapped (see Input System section). Friends are AI-only; injection requires Strategy A (direct entity write) or Strategy B (AI hook).
ApplyReplicaActorState(state)ImplementedAll slots: position/rotation/flags to entity struct. Slot 0: dual-write to buffer. All: HP + camera fake actor.
ApplyReplicaEnemyState(state)TODOEnemy traversal + objectId now exist; HP/spawn-group/damage writeback still needs mapping

Limit command gate ([GHIDRA], 2026-10-02, VUH-1491; not yet checked live)

RVARole
0x2AE5690limt table pointer (03system.bin, loaded by 0x3F4C30). 0x40-byte entries: +0x09..+0x0B required partner character ids, +0x30 u16 command id
0x2A161E8cmd table pointer
0x3E7C30(cmdId)plain limt lookup by command id
0x3E7800(cmdId)limt entry only if usable now (partners present and active via 0x3D5C70 / 0x3BA720, player-actor bit +0x6CB&1, a room flag)
0x3D88E0(actor, cmd*, state)per-command menu state for limits: 5 while a limit runs (0x2A24CC0) or MP recharges, 2 when MP is short; 5 read as greyed out
0x3D8B40executes a limit; re-checks 0x3E7800

To block limits while puppets are active, hook 0x3D88E0 and return 5 for any command 0x3E7C30 recognises.

Actor behaviour flags actor+0x18C ([GHIDRA], bit 6 corrected 2026-10-05; bit 6 effects not yet checked live)

BitEffect
6 (0x40)Skips the 0x3B81D0 surface-derived addition in 0x3B89A0. In 0x3B9090, selects unfiltered integrated + local supplied + linked movement for +0x6E0, then still reaches 0x16FEC0. Does not itself exclude the earlier 0x3BA970 / 0x40AD70 actor-pair path. Also gates later contact/support handling; not a general collision or ground-snap disable. The handler resolved from actor+0x0C has a +0x0C & 2 test in these surface/selection guards; this is not a proven universal equivalent.
14 (0x4000)TakeDamage (0x3D5E50) passes reactFlag 0 to ApplyStatDelta. This is a downstream argument gate, not a validated way to prevent Sora's knockback or contact displacement.
0x1000020Participates in 0x3BA9A0's unfiltered-movement selection and later contact/support guards in 0x3B9090; does not bypass the earlier actor-pair loop or the 0x16FEC0 call.

The post-solver contact/notification branch requires handler +0x0C bits 0/1 clear, actor+0x18C & 0x1000020 == 0, and bit 6 clear; failure can enter alternative notification handling. Later support-handle handling requires 0x3BA9A0 false, handler bit 1 clear, actor bit 6 clear and nonnull actor+0x748. These are selective guards after the solved-position copy, not proof that all terrain correction or ground snap is suppressed. See the byte-check note and retained provenance.

The 2026-10-05 bounded no-reaction lookup found no validated setter or lifetime contract for bit 14. ResolveHit and Sora's OnHit can run before this helper, and retained Sora damage already reaches ApplyStatDelta with react=0. The serialized ActorState.invuln field is not populated from native state or applied by the bridge; its value is not evidence of native invulnerability.

Native enemy provenance and appearance cache ([GHIDRA], 2026-10-02)

Independently corroborated static reads, additionally observed in the checked native census. They are diagnostic fields, not a validated suppression or replication boundary. Equal SAVE progress hashes do not cover this cache.

Address / offsetMeaning
actor+0x9E8Controller pointer attached by 0x3B4BD0; null is legal
actor+0x9F0Pointer to a 0x40-byte native spawn record, not inline bytes; record ID is u16 at +0x1E
exe+0x2AE5E60Four inline appearance-cache buckets, each 0x208 bytes: room tag i32, age i32, then 256 u16 record IDs
exe+0x2AE6680Active bucket pointer; must equal one of root +0, +0x208, +0x410, +0x618 before reading
exe+0x2AE6688u32 selection counter; IDs can change without this counter changing
0x3F5980Selects a bucket using the NOW room byte and age; tag is not a packed world/room identity
0x3F5900, 0x3F5BE0, 0x3F5CD0Test, insert and clear native record IDs; membership scans all 256 slots, including after zero gaps
0x3A0580, 0x3A0660Restore/back up 0x830 cache bytes from/to exe+0x2A0C550 during native load lifecycle

Native emission wrappers 0x3FE590/0x3FE650 call factory 0x3DF930 and attach this provenance. Some callers dereference the result without a null check, so returning null from a broad spawn hook is not safe. Compare a checked native linked-list census, controller/record state and cache contents before choosing an authoritative emission strategy.

Ordinary spawn-controller hook and lookup ABI (2026-10-02)

Static disassembly and checked geometry establish the following module-relative RVAs. They define call mechanics and bounded scope, not authoritative spawn convergence.

Address / offsetMeaning
0x2A10010, 0x2A10418Controller table: 16-byte {u32 key, u32 flags, pointer} entries and i32 count. Entry flags bit0 denotes an alternate script pointer, not an ordinary controller
Controller +0/+4/+8u32 group key / u32 flags / header pointer
Controller +0x10/+0x18/+0x20Runtime region head / tail / float cooldown
Controller +0x24/+0x28/+0x2CCurrent/initial eligible record counts, then u8 stage; eligible counts are not living actor counts
Controller +0x30/+0x38Spawn array / region descriptor array. Require spawnArray == header+0x2C, regionArray == spawnArray + spawnCount*0x40
Header +0/+1/+2/+4/+6u8 type, u8 flags, u16 ID, u16 spawn count, u16 region count. 0x3FE334: 0F B6 01 reads the type byte; do not combine type/flags into u16
Spawn record (0x40 bytes)u32 object ID +0; float XYZ +4/+8/+0xC; u8 mode +0x1C, position mode +0x1D; u16 record ID +0x1E, delay +0x2A; u8 stage +0x30
0x2A10420, 0x2A105D0Tracked activation actor / player actor; these need not match

0x3FF000 ABI: void (__fastcall*)(void* controller, const float* point4). RCX is the controller; RDX points to 16 readable bytes. The verified caller consumes no return value and returns to 0x3A5063. Use aligned local float[4] and call synchronously; native copies the vector and does not retain its pointer.

Exact installation fingerprints (verification spans, not manually selected trampoline overwrite lengths):

RVA / lengthBytes
0x3FF000 / 15-byte entry48 89 5C 24 10 48 89 6C 24 18 57 48 83 EC 30
0x3A5056 / 13-byte setup/call48 8B 0B 48 8D 54 24 20 E8 9D 9F 05 00
0x3E0F10 / 16-byte lookup entry48 89 5C 24 08 48 89 6C 24 10 48 89 74 24 18 57
0x1E2300 / 5-byte comparator2B 0A 8B C1 C3

The update clears transient flags 0x18, respects disabled bit0/event gating, decrements cooldown, tests linked regions and performs emission/cache bookkeeping. Bit3 means a region accepted; type2 sets header+0xE and dispatches delayed records. Qualified-client Hold skips the whole tick, including cooldown; it is a scoped pause. Unsupported/unavailable scope cannot be claimed controlled.

Direct read-only lookup 0x3E0F10 has ABI const void* (__fastcall*)(int32_t directObjectId) (ECX ID, RAX pointer/null). It tries three table pointers at 0x2A25030, +8, +0x10 in order. Each nonnull table has i32 count +4 and entries +8, stride 0x60. Its comparator subtracts entry ID from the low32 bits of a pointer-sized numeric key, not a pointer to ID storage. Do not replace this with OBJ0-only or base+ID*stride. Verify bytes, checked nonnegative table counts/spans, then use a POD-only SEH leaf. Validate returned span/alignment, ID, type and name; recheck roots/counts and lifecycle. Faults/changed tables are unavailable; null is unresolved, not combat. Caps64 controllers, 256 records and 65536 object entries are safety bounds, not discovered native maxima.

General resolver 0x3DFEB0 also maps party/drive/save aliases. The static direct-ID boundary excludes zero, high-four-bit modifiers and hexadecimal aliases 236,237,238,23B,23C,23D,23F,240,2C0,319,31A,3EE,62A,62B. Qualify an entire ordinary type2 controller only when every record, including later-stage/cached records, has mode2, positionMode0, finite fixed XYZ and a direct objentry type3/4 with name prefix other than F_. Mixed/unsupported records exclude the whole controller; header30 is observed coverage, not the scope definition. Do not rewrite records or invoke a subset of emitters.

Runtime region nodes are 0x70 bytes: vtable +0, inverse float4x4 +8, extents XYZ +0x48/0x4C/0x50, next encoded handle u32 +0x58, descriptor pointer +0x68. Each source descriptor is 0x40 bytes: u16 kind/category +0/+2, float position XYZ +4/+8/+0xC, extents +0x10/0x14/0x18, Y rotation +0x20. Walk the actual list, since native setup excludes some categories; require aligned source pointers inside the checked descriptor array/count.

KindVtable / predicate RVANative predicate
BOX (0)0x5D4F98 / 0x421180Inclusive -extent <= transformed XYZ <= extent
CYLINDER (1)0x5D4FB0 / 0x421250Inclusive Y bounds, then (x/extentX)^2+(z/extentZ)^2 <= 1
INFINITY (2)0x5D4FC8 / 0x421320Unconditional true (B0 01 C3), independent of point/matrix values

Predicates copy float4 and force w=1. Captured inverse matrix M transforms via q[j]=M[j]*x+M[j+4]*y+M[j+8]*z+M[j+12], j=0..2. Native accessor 0x3B5B40 selects actor+0x70 if parent handle u32 +0x6A0 resolves nonnull through 0x3DA520, otherwise +0x670. Generic resolution uses the 64-entry table at 0x2B0D720: table[(handle & 0x7FFFFFFF)>>25] | (handle & 0x01FFFFFF). Bit31 is ignored, raw handle0 is null, and invalid/failed reads cannot imply null.

The geometry03 artifact has eight valid native/causal captures out of nine; the overall run failed because snapshot0/client1 failed actor-identity stability. Later snapshots1/2 captured all seven header30 BOX regions on every peer. Source descriptor and inverse-matrix/extent bytes matched across valid captures. At snapshots1/2, each peer's geometry placed the host point outside all seven and both clients inside indices5/6, without edge uncertainty. This is offline float32 containment, not instrumented predicate returns or an authority-fix pass. See the extraction receipt and scenario limits.

Fixed type2 emission observation and replay limits (2026-10-02, static verified)

Successful fixed-wrapper return 0x3FE83F inside delayed type2 emitter 0x3FE6F0 is a verified creation boundary, not a complete replay API. The opt-in KH2COOP_SPAWN_TRACE=1 observer now covers all callers of fixed 0x3FE590 and generated 0x3FE650, plus explicit dispatcher/script scopes. It passes each original through once, preserves genuine returns and distinguishes checked nonnull, null and unavailable observations. Wrapper evidence outside an activation tick is retained separately; it cannot claim completed tick bookkeeping. Source and byte checks are offline evidence; native execution and diagnostic coverage still need a live run.

RVAWindows x64 ABI / role
0x3FE6F0void __fastcall(void* controller); native type2 occupancy/cache/stage/cooldown checks and record selection
0x3FE590void* __fastcall(const void* record, void* controller); RCX record, RDX controller, RAX actor/null; fixed native position/yaw, constructor, provenance and eligible cache insertion
0x3FE650void* __fastcall(const void* record, void* controller, const float* point4); R8 explicit point; separately traced generated-position path
0x3FE320uint64_t __fastcall(void* controller, const void* region); RCX/RDX; observer preserves full RAX, not just a decompiler's narrow return type
0x42DC10One RCX pointer to native uint32 handle storage; advances stage then tail-jumps to the dispatcher
0x3DF930void* __fastcall(uint32_t objectId, const float* point4, float yaw); ECX, RDX, XMM2; generic factory alone omits wrapper metadata
0x3B4BD0void __fastcall(void* actor, void* controller, const void* record); RCX/RDX/R8; provenance and actor parameters, already called by the wrapper's virtual path

Verified fixed-wrapper entry (15 bytes): 48 89 5C 24 08 48 89 74 24 10 57 48 83 EC 30. At 0x3FE83A, E8 51 FD FF FF calls that wrapper; return 0x3FE83F is before caller-owned cooldown assignment at 0x3FE99E and stage advancement. Generated wrapper call 0x3FE984 instead returns 0x3FE989; do not merge scopes. Observe enclosing controller tick completion to obtain truthful post-state.

Live2302 run20261015-163914 found successful fixed-wrapper returns with a valid objentry but actor+0x5C0 status still null. Wrapper virtual+0x68 has already run provenance initialization; 0x3B4BD0 writes controller+record at actor+0x9E8/+0x9F0, not status. Do not require a nonzero combat-status pointer at this early boundary. Retain the successful-read zero and mandatory handle/object/controller/record roots, then complete status once from a fresh complete census after all captured roots and indexed definition still match. Freeze the nonzero pointer thereafter. The combat classifier successfully excludes status-null actors, so even a complete combat census can precede initialization. Pending birth checks must separately witness current linked-list membership (the existing watched-actor census), bounded to32 observations and granting no binding identity. First absence or root drift refuses. A wrapper-return trace alone cannot claim census-ready combat metadata.

Wrapper cache insertion uses objentry-type mask 0xA02010: it includes mob type4 (also 13/21/23), not boss type3. A successful boss creation need not insert its record ID. The wrapper does not perform the caller's region, occupancy, cache, stage, cooldown or count/event reconciliation. 0x3FF780 is unsafe as a replay convenience: it increments initialCount and resets currentCount to initialCount, destroying accumulated death bookkeeping. Calling the whole emitter or stage helpers lets divergent local state make new population decisions. No safe single-call bookkeeping reconciliation API has been verified.

Controller task 0x3A4F90 is scheduled at priority 12000 by 0x3A4E10; actor updating uses priority 20000. Executing creation from a head-actor callback is not proven equivalent to the native controller lane. No runtime/worker thread may call native creation. Native lethal/count/event behavior must remain on its verified path; do not compensate with HP/count/cache writes.

Cross-peer addressing requires epoch/full location/session plus unique ordinary controller groupKey/headerId, recordIndex/nativeRecordId/objectId and validated static descriptor content. GroupKey alone repeats (headers 30..33 share b_00); pointers/table indices are local, and controller reinitialization can occur within a room. Record ID is not a creation generation: lookup 0x3B41E0 maps ID0 to 0x70, searches without a controller discriminator, and filters actors. Cache routines compare signed int16 slots while wrappers load IDs unsigned; an initial replay proof must reject zero/high-bit IDs and room-wide collisions, using IDs 1..0x7FFF until broader semantics are proved. These are replay limits, not changes to existing activation qualification.

Successful wrapper order need not equal the next census's manifest netId order. Correlate the returned actor under checked native identity before publishing a creation event; repeated emissions need distinct host event identities and idempotent delivery. Matched initial cache/count enrollment, alive removal versus death, stage/refill progression, mid-room reinitialization and late-join history remain blockers. Creating the visible actor alone cannot establish that state.

Alive removal, native death and script bypasses (2026-10-02, static verified)

These Windows x64 ABIs were checked by decompilation and register-bearing bytes in the installed executable. They are observation/API candidates, not permission to invoke a repair on an arbitrary actor.

RVAABI / native effect
0x3FFD90void(controller, actor) in RCX/RDX; removal bookkeeping only, no disposal
0x411800void(actor) in RCX; controller from actor+0x9E8, calls 3FFD90, then disposes through 3B45C0
0x411470void(typeHandler, actor) in RCX/RDX; common type-handler removal path with bookkeeping/disposal
0x3B45C0void(actor) in RCX; resource teardown and deferred deletion marker
0x3D4A40void(actor) in RCX; guarded lethal notification before setting actor+0x9B8 bit 2
0x3FED10void(controller, actor) in RCX/RDX; conditional death-count dispatch
0x3FED40void(controller) in RCX; native count decrement and events

For a living delayed-mode-2 record, 3FFD90 clears its appearance-cache ID and resets controller cooldown without decrementing the battle count. Dead removal recognizes bit 2 and normally retains the record's cache ID. Native count events include 3AABE0(3, headerId) and aggregate-zero 3AABE0(2, 0). Other death wrappers reach count bookkeeping independently; neither HP alone nor a later census disappearance proves which lifecycle side effects ran. Never call bookkeeping alone, represent alive removal as HP zero, or repeat native count/cache changes after a disposal path already performed them.

Generic actor update 3BFD30 can dispatch type-handler virtual slot +0x38 at 3BFF58 from normalized fade state (actor+0xA08, +0xAAC) and flags. Those values are not HP. A removed actor can cease blocking occupancy before allocation is freed. Exact subtype/caller proof is needed before replay or selective suppression; broad suppression would also affect death and teardown.

Selected actor retirement, 2026-10-05 (saved PE; not live lifetime proof). The deletion marker is actor+120 bit 0x80000. 3BF300 first processes the previous deferred list, then unlinks marked actors from the active list using encoded +A90 links and queues them for a later sweep. Active-list absence is therefore earlier than allocator release. The selected ordinary Shadow handler is 7528E8 -> vtable 5D2D68, slot0 419AB0.

RVAChecked selected release boundary
0x3B4E80RCX=actor; invokes handler slot +30 unless actor+9B8 bit 0x40 is already set, then marks it after normal return. This callback is separate from the later slot0 dispatch.
0x419AB0RCX=handler, RDX=actor; nonnull actor passes through subtype/base cleanup 3F8E00 -> 3B3D60, then original actor base goes to 152570 at 419AC8, return 419ACD. No actor read is safe after that release.
0x152570RCX=pointer becomes RDX; loads RCX from [9BA920] and tail-dispatches its vtable slot +10. Base-cleanup release at return 3B3DBC frees a loaded member, not the actor base.
0x19C470RCX=allocator domain, RDX=pointer; normal nonnull path synchronously unlinks the block header, subtracts its recorded size, poisons the block with 0xEFACCAFE, and clears its previous link. Arena release, not OS deallocation or demonstrated reuse.

Binding writers 152450 and 152680 use constructor 19C3A0, which installs the kn::MemoryAllocator primary vtable 5B2BB0; slot +10 resolves to 19C470. Release header is pointer-20, size is read at pointer-10; do not infer that size from the factory's 0xD50 request. Helper 3A08F0 and the paired synchronization helpers remain uncharacterized. Null, changed live binding, unwind or partial execution cannot establish successful retirement.

The current lifecycle probes do not observe these completion boundaries. Encoded handles identify an address/region, with no creation generation; same-address reuse remains ambiguous. These static facts do not authorize absent-dead reconstruction. Exact bodies, callsites and limits are in the release audit and allocator binding audit.

The full 68-byte body of script callback 42DC10 verifies stage advancement 3FFE40, followed by tail jump 42DC49 -> 3FE320(controller, null), bypassing 3FF000 even for type 2. Its original trampoline can leave the dispatcher with a DLL return address: an unavailable executable caller RVA is expected in that case. Explicit script TLS records dynamic enclosure, not immediate dispatcher ancestry. Return 3FE83F identifies the fixed emitter branch, not the full producer path. All-caller fixed/generated observations outside the activation tick retain wrapper evidence without claiming tick bookkeeping.

The opt-in lifecycle module independently byte-gates 3FFD90, 3B45C0, 3D4A40, 3FED10 and 3FED40. Its installed mask exposes partial coverage; the other subtype wrappers and 3AABE0 events are not hooked. Pre/post actor, controller/cache and full native stamps retain nested entry/parent sequence. Disposal can invalidate poststate, and count decrement has no actor argument. Only the first registered game thread samples role/Warp callbacks; unknown threads retain raw NOW and unavailable serials. These probes are observations, not a replay or reconciliation API. Generic factory bypasses, actual allocation release and same-address/controller reincarnation remain unproven; current census/netId correlation is diagnostic. See SCENARIOS.md for saved-log auditing and ENEMY_PARITY.md for the retained wave failure and offline test scope.

Scoped predicate extension, 2026-10-03 (saved-PE verification; no live install). Admission 3A1F00(float XMM0) -> AL reads float32 limit/used at 2A0F7DC / 2A0F830; the factory passes unsigned-byte objentry weight +54, returning to 3DFA0B. Type-4 allocation calls 152430(size_t RCX) -> RAX with 0xD50, returning to 3DFA7D. That allocator entry tail-forwards through a vtable; its decompiler void return is incorrect. Scoped observers preserve actual arguments/results and never dereference returned allocation storage.

Removal predicate 3DAC30(actor RCX) -> AL is reached by the type-4 tail thunk 419B90, retaining ordinary caller 3BFD6F. Its script child 3B4420 returns to 3DAC3E; auxiliary child 3CE550 returns to 3DAC9C and tests dword +14 on its genuine argument. Actor boundary operands are qword +5B0, dword +5B8, qwords +80 / +98 and encoded dword handle +BB4. They are sampled operands, not extra native resolutions or immutable identity. New predicate events end before the later virtual removal/disposal call. Full byte guards and caller-site checks gate the observers; partial masks, foreign calls, unwinds, repeated children and loss cannot establish a branch conclusion. The static capture contract records exact bytes against PE SHA256 9002B2DE6A1F91A790BD0673DE125D1CF833F7942BFEC827CDCF6BA64D5849ED.

Empty-seat package qualification correction (9002b2de, 2026-10-07)

2A0F7A8, historically labelled ARD pointer, is a script visibility mask in this executable. Native 3A2030 reads the loaded ARD BAR root at 2A0F828; 3A2060 prepares it before 3E2EB0. Entry offsets are encoded handles after 3A6840, decoded with table 2B0D720. See party-empty2/rev1 research/package-gate.md for primary bytes, ordering, package hashes and restricted read-only gate.

Sparse party menu consumers (9002b2de, 2026-10-08)

Native 2FC360 compacts nonzero member entries (stride0x20) and preserves the original row seat at entry+8. Pause caller3065C8 must project its compact index through that saved seat before2FC5B0 (return3065CD). Only the two companion-history callers returning36A46A/36A727 project Sora/Roxas to the existing zero-skip through2FC6D0; player history stays native. Five hook entries and three caller CALL signatures are guarded. No SAVE write is added. Primary evidence and other-consumer limits: party-empty2/rev6/research/consumer-audit.md. Fixture10 run20261008-024646 qualified pause and first Items selection with remote/0/local members, not Party/change or deeper Items consumers.