Skip to content

Port: stop blending from motion sets whose memory was released - #125

Merged
Adubbz merged 4 commits into
TheMoonPeople:masterfrom
carrdylan52:pr/motion-lifetime
Oct 23, 2026
Merged

Adubbz merged 4 commits into
TheMoonPeople:masterfrom
carrdylan52:pr/motion-lifetime

Conversation

@carrdylan52

@carrdylan52 carrdylan52 commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Before

character_motion keeps a registry of motion sets keyed by their channel anchor, and a record of the action each frame tree last posed. The game releases characters, effect-script copies and their motions by resetting or rewinding arena memory, or by unlinking heap blocks, and never tells the registry. Stale entries survived. A frame tree built at a reused address, or a surviving frame tree whose motion memory was reused, took the old pose as its blend source and walked channel lists that now held other data. This is the failure mechanism the tests below reproduce. It matches a playtest crash in BlendActions when shooting a frog after switching guns, and FredTheSlug's crash in Rainbow Butterfly Wood through an effect-script character, but neither crash has been reproduced in game.

After

  • Constructing a frame drops the pose recorded for its address.
  • A registered motion set records the arena run holding its channels and anchor, and stops resolving as soon as any of that memory is about to be written again:
    • the port's mgCMemory reports every range it hands out (stack allocations, placement growth of the last run), every block header it writes (heap setup, stack-mode claims), and the span a heap or stack buffer import copies over;
    • the main-thread file loads report their destination before writing it: the sector-padded span for data and save reads, and the actual copy span for Luau script handles and file cache copies. This matters because callers such as MenuCommonReadData load at the top of an arena and reserve only the unpadded size afterwards.
  • One owner rewinds over memory that is still in use: a town map change (SetDataPacket) rewinds the character buffer and reserves the controlled character's block again while the character stays loaded there. Those two reservations go through MemoryAllocKept, by a unit fixup, and leave the registry alone. The other paths I traced—character setup and change, Monster Transform, dungeon and town initialization, and menu loading—allocate for replacement data or rebuild their characters, so they retain normal invalidation. I found no second reservation that needs to preserve a live motion set.
  • The anchor also carries a registration stamp, so an anchor overwritten some other way stops resolving. A stamp-rejected set is erased, and registering a set again forgets the old set's poses.
  • An unknown or invalidated set poses nothing; a blend whose recorded source is gone falls back to the target set.

How

Hooking the release points was not workable: stReset is inline in the retail header, many game functions rewind by writing stack_used directly, and heap Free only unlinks. None of them touch the contents, so the registry hears about memory when it is about to be overwritten instead. The worker threads that validate script timing read through ReadUnregistered, which is unchanged; only main-thread wrappers report. stAllocTest previews do not invalidate anything.

The registry is scanned linearly on each report. Measured with temporary counters (not committed) over 18,000 headless ticks each:

Run Reports per tick (avg / max) Registry high-water Scan time per tick (avg / max) Avg per report
Dungeon (dungeon:0:2, gun and wrench) 319 / 38,364 23 12.5 µs / 1.1 ms 39 ns
Town (town:10, walking and talking) 844 / 27,206 10 23.5 µs / 0.63 ms 28 ns

The per-report times include the two clock reads around each call. The maximum ticks are loads. Ranges of registered sets are disjoint, since an allocation over one forgets it, so the registry stays bounded by arena memory. I left the scan linear.

Tests

  • character_motion_test.cpp (merged with Port: fix the Help and Monica inventory crashes and effects stuck bright #122's cases; the plain cases use its CharacterMotion fixture). The arena cases lay memory out as BuildMotion does: channels first, anchor at the end of the run.
    • A surviving frame tree blends after an arena reset reused the channels while the anchor and its stamp stayed intact. This is a death test; without the fix it crashes with an access violation.
    • A reset arena reuses the channels; a freed heap block is claimed again; the same external heap buffer is bound again with the anchor in the root's headroom.
    • A rewound arena reserves the block again with MemoryAllocKept: the set keeps resolving, and a later reset that hands the memory out releases it.
    • An allocation past the set keeps it registered.
    • A stamp-rejected set stays forgotten after the stamp bytes are restored.
    • The original cases: a frame tree reconstructed at the same address, and a released set.
  • dataread_test.cpp: a 10-byte file loaded at the top of a reset arena, with the caller reserving only the file's size, overwrites channels in the sector padding while the anchor past 2 KiB survives; the set must be forgotten.
  • test_header_fixups.py: both ControlCharaBuff.Alloc(FixCharaBuffSize) calls in editloop.cpp become MemoryAllocKept, and the packet buffers keep Alloc.
  • With its hook disabled, each of these cases fails: surviving frame, reset arena, freed block, heap rebind, kept reservation, stamp restore and padded load. AllocationPastMotionStorageKeepsSet is the negative control and passes either way.
  • Full ctest on Windows: 1858 passed, 54 skipped, 1 failed, and 1 disabled. GeoramaParts.PaintedFencesMatchRetailRecursion fails intermittently: the same test binary can pass and fail repeated runs. It also fails in camera-only branches based on c27f3d041 that do not contain this PR, at the reference fence-connectivity assertion before PaintFence is called. The underlying nondeterminism is not diagnosed, and a clean-master binary was not tested in this review.
  • Headless replays on origin/master and this branch, with screenshots at two frames each compared byte for byte. The replays cover town walking, the Morton's shop round trip, the world map, menus, invention, camera, the Monster Transform menu, the dungeon floor, menus and gun, Ridepod, the Mt. Gundor carry, the quick change to Monica, Monica's opening, Ocean's Roar, Georama, and dungeon to town with Escape Powder. All are identical. Without the SetDataPacket fixup, --jump town:10 --fast-load with tools/input/area-town-building.input shows Max in his bind pose from the first controllable frame, while master animates him. Headless and screenshot runs disable the render thread, so these runs cover that configuration only.

@FredTheSlug

FredTheSlug commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

20261019-113821-e7bb.zip
got this one too on master (d51e74b), in Rainbow Butterfly Wood during a fight. mine went through an effect script chara:

EXCEPTION_ACCESS_VIOLATION reading 0x000000003f7b3c6d
#0  model_assets::BlendActions(...) at port/src/platform/model_assets.cpp:3641
#1  character_motion::Blend(...) at port/src/character_motion.cpp:200
    ChangeMotion(...) at port/src/gameutil.cpp:20 (inlined)
#2  CCharacter2::NormalDrive() at ps2/src/character.cpp:1275
#3  CCharacter2::Step() at ps2/src/character.cpp:973
#4  CEffectScriptMan::Step() at ps2/src/effscript.cpp:1031
#5  DngStep() at ps2/src/dng_main.cpp:1947

the bad channel was 0x3f7b3c61 (a float, ~0.98), so probably your BlendIgnoresPoseOfFrameTreePreviouslyAtSameAddress case since those charas get copied into work_memory every time a script starts.

I'm running this PR on top of master now. only conflict is character_motion_test.cpp because #122 added it too. I kept both, passed {} as the storage in #122's Register calls and made your TEST(CharacterMotion, ...) ones TEST_F since #122 has a CharacterMotion fixture. tests pass and seeded dungeon runs look identical to master. will update if it crashes again

@FredTheSlug

Copy link
Copy Markdown
Contributor

found a problem with this one after playing more. in town every location change (teleporting or just walking into a new area) leaves Max or Monica frozen in one pose sliding around until you switch characters.

it's SetDataPacket in editloop.cpp. on a map change it rewinds ControlCharaBuff and takes the character's memory back with ControlCharaBuff.Alloc(FixCharaBuffSize) (both branches do it), but the character is still loaded there. the Reuse hook in AllocQuadwords sees that as memory being handed out again and forgets the controlled character's set, so Pose has nothing to play after that. switching characters loads the other one and registers it again, which is why that "fixes" it.

what I'm running locally is an alloc that doesn't report, and the two Alloc(FixCharaBuffSize) calls in editloop.cpp go through it with a unit fixup:

u_long128 *MemoryAllocKept(mgCMemory &memory, int size) {
    g_keeping_contents = true;
    u_long128 *data = memory.Alloc(size);
    g_keeping_contents = false;
    return data;
}

Reuse skips while g_keeping_contents is set. I also added a test for it (rewind, MemoryAllocKept the same size, set still resolves). with that the town stuff is fine and I didn't see any other character lose its set going between town, dungeon, the world map and menus.

The game frees characters, effect-script copies and their motions by
resetting arena memory, so character_motion never hears that a motion
set or frame tree is gone. Its registry kept the released set's actions
and the record of what each frame tree last posed. When a new frame
tree was built at the same address, such as the next effect-script
character copied into the reused work buffer, its first blend took the
old tree's pose as the source and walked channel lists that now held
other data, crashing in BlendActions.

Constructing a frame now drops the pose recorded for its address, as it
already drops the frame's native conventions. Registering a motion set
also stamps its anchor, so a set whose memory has been reused, or
registered again at the same address, no longer resolves: the blend
falls back to the target set, and an unknown set poses nothing.
The anchor stamp only shows that the anchor's own bytes survived.
BuildMotion allocates the anchor after the action channels, so an arena
reset that hands the front of the run out again overwrites the channels
and leaves the anchor, stamp included, intact. A frame tree that
outlives the reset still finds the set through its recorded pose and
blends through the overwritten channels.

Registering a motion set now records the arena run that holds its
channels and anchor. The port's mgCMemory reports each range it hands
out or writes a block header over: stack allocations, placement growth
of the last run, the header of a stack-mode block claimed from the heap
and the headers of a heap set up again. Any set whose run overlaps that
range is forgotten.

Hooking the release points themselves would miss most of them. stReset
is inline in the retail header, many game functions write stack_used
directly to rewind an arena, and heap Free only unlinks the block. None
of them touch the contents, which are only overwritten once the arena
hands the memory out again, so that is where the registry hears of it.
The stamp stays as a guard against an anchor overwritten without going
through an arena.
Not every write over released motion memory goes through a hooked
allocation. File loads write the whole sector-padded destination, often
at the top of an arena, and the caller reserves only the file's size
afterwards, as MenuCommonReadData does. Channels in the padding were
overwritten while the anchor past the sector kept its stamp. Setting up
a heap over a buffer outside the arenas copies that buffer into its
arena root, and binding the same buffer again maps the same root, so the
copy could overwrite an interior set's channels while an anchor in the
root's headroom survived.

The main-thread loads now report their destination before writing it:
the padded size of a data or save file read, a Luau script handle, and a
file cache copy. The worker threads that validate script timing read
through ReadUnregistered, which stays untouched. SetHeapMem and the
stack buffer import report the span they copy over.

A set rejected by its stamp is now erased rather than skipped, and
registering a set again forgets the old one's poses, so stale entries do
not pile up in the registry.
SetDataPacket runs as the town loads and on every map change. It
rewinds the town's character buffer and reserves the controlled
character's block again with ControlCharaBuff.Alloc(FixCharaBuffSize),
while the character stays loaded there. The arena reports every range
it hands out as overwritten, so the motion registry forgot the
character's sets, and Max or Monica stood in the bind pose and slid
around until a character change loaded them again.

AllocQuadwords now takes whether the memory it hands out is released or
kept, and MemoryAllocKept reserves kept memory without reporting it. A
unit fixup routes both re-reservations in SetDataPacket through it. The
other places that rewind an arena and reserve it again, such as
GetCharaMemAllocPtr for set-up, character change and Monster Transform,
load new data straight after, so they still release the old sets.

Reported by FredTheSlug, who traced it to SetDataPacket.
@carrdylan52

Copy link
Copy Markdown
Contributor Author

Thanks Fred, both of these were spot on.

Conflict with #122: rebased onto master (c27f3d0) and resolved character_motion_test.cpp the way you did: both sets of cases in one file, {} as the storage in #122's Register calls, and the plain cases are now TEST_F on #122's CharacterMotion fixture (its TearDown clears the registry, so they no longer clear it themselves). The death tests stay TEST since they run in a child process.

Frozen Max/Monica in town: reproduced headless with --jump town:10 --fast-load and tools/input/area-town-building.input. On this PR Max stands in his bind pose from the first controllable frame and after the trip in and out of Morton's shop, while master animates him. Your diagnosis was right: SetDataPacket rewinds ControlCharaBuff and reserves FixCharaBuffSize again while the controlled character is still loaded there, and the Reuse hook in AllocQuadwords took that as new data.

The fix follows your approach, without the global flag. AllocQuadwords takes whether the memory it hands out is released or kept, and MemoryAllocKept (in mg_memory_port.hpp) reserves kept memory. Two unit fixups route the two ControlCharaBuff.Alloc(FixCharaBuffSize) calls in editloop.cpp through it. I also checked the other places that rewind an arena and reserve it again: GetCharaMemAllocPtr in charasetup.cpp (used by setup and by the menus' character change and monster transform), the dungeon and town buffer set-up, and the menu load stacks. Those paths allocate for replacement data or rebuild their characters, so they keep normal invalidation. I found no other live-character reservation needing this exception.

Tests:

  • CharacterMotion.KeptReservationAfterRewindKeepsSet: rewind, MemoryAllocKept the same size, and the set still resolves. A later reset that hands the memory out again still releases it. The case fails if MemoryAllocKept reports the range.
  • test_header_fixups.py checks that both re-reservations in editloop.cpp are rewritten and that the packet buffers still use Alloc.
  • Headless replays, master against this branch, with screenshots compared byte for byte: town walk, shop round trip, world map, menus, invention, camera, the Monster Transform menu, the dungeon floor, menus and gun, Ridepod, Mt. Gundor carry, quick change to Monica, Monica's opening, Ocean's Roar, Georama, and dungeon to town with Escape Powder. All are identical to master. These are sampled screenshots from headless runs with the render thread disabled. They do not test an actual monster transformation.

Thanks for running it on top of master and for tracking this one down.

@Adubbz
Adubbz merged commit d6ba73f into TheMoonPeople:master Oct 23, 2026
3 of 7 checks passed
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.

3 participants