[Scummvm-git-logs] scummvm master -> 9ecd0e3ebf06b9c6ae3ccad0cfbfd5dde38ee272

sev- noreply at scummvm.org
Sun Jun 28 21:32:44 UTC 2026


This automated email contains information about 467 new commits which have been
pushed to the 'scummvm' repo located at https://api.github.com/repos/scummvm/scummvm .

Summary:
129f579230 HARVESTER: Scaffold engine
c5ffcb22f4 HARVESTER: Add XFILE resource backbone
261e78c310 HARVESTER: Mount numbered XFILE archives
f9b299cc06 HARVESTER: Load startup town script
96ba4494df HARVESTER: Play startup FST intros
7090144722 HARVESTER: Load startup archive art
18d7fd160f HARVESTER: Load startup text resources
d94865a3bd HARVESTER: Add resource debug logging
bf097b8825 HARVESTER: Update harvester.exe hash
682523f47d HARVESTER: Restore post-intro startup UI surface
dab599fd56 HARVESTER: Load and show quick tips
d4048bd49f HARVESTER: Log startup display mode changes
b09f59a197 HARVESTER: Add startup flow after quick tips
e3ee58246f HARVESTER: Resolve START room setup from HARVEST.SCR
8995ee1136 HARVESTER: Resolve startup room command chains
592e701aee HARVESTER: Show startup room hotspot text
2ffc2b1465 HARVESTER: Follow startup hotspot closeup commands
d905790d4c HARVESTER: Draw visible startup room objects
cee4afa25f HARVESTER: Play startup hotspot WAV effects
1c02f54e29 HARVESTER: Show object inspect text in room stubs
e740ad2a13 HARVESTER: Align startup room transitions with RE
bc3df40506 HARVESTER: Add runtime cursor entity support
03da5e4a67 HARVESTER: Lift startup entities
ed8fc26ea2 HARVESTER: Recover startup object hit testing
4b90f9b6ea HARVESTER: Recover startup room background entities
65ce00115a HARVESTER: Fix runtime entity overlap gating
d5db123a90 HARVESTER: Record recent RE findings
9ee81377b7 HARVESTER: Sync FST playback to audio timing
cb08179d93 HARVESTER: Record video backend callback findings
1260dd84f2 HARVESTER: Recover FST censorship path
514df3efec HARVESTER: Sync runtime entity findings
c9ff18dd27 HARVESTER: Fix startup palette and cursor timing
73aadec836 HARVESTER: Add startup room loop and player actor
39b9b45b63 HARVESTER: Add startup room probe diagnostics
7b843cb624 HARVESTER: Port startup ident textbox modal
c7859e6b6b HARVESTER: Improve startup hover behavior
9d72feb473 HARVESTER: Enable inspect hotspots
84ba72dc7a HARVESTER: Add startup keyboard turn delay
5c00a14c60 HARVESTER: Restore startup idle animation
bf4a8aafb4 HARVESTER: Restore startup room music playback
205646fbc5 HARVESTER: Split startup WAV playback slots
30aa7bb001 HARVESTER: Honor startup room exit audio
c3067aec27 HARVESTER: Persist startup script room state
0e5a469b3e HARVESTER: Restore startup closeup pickup hotspots
fe78b7dfbd HARVESTER: Add startup inventory useitem handling
5b29be4aa0 HARVESTER: Restore startup menu entry points
c2349c8874 HARVESTER: Gate startup pickup on initial ident
ec163671c2 HARVESTER: Restore startup region room exits
3bd6e187ee HARVESTER: Align startup room movement with native xz targets
f53ed83c50 HARVESTER: Fix startup idle animation anchoring
b3f49749c6 HARVESTER: Document render entity runtime
2fc1f1066b HARVESTER: Use startup font for ESC room menu
a348c8b604 HARVESTER: Overlay ESC menu on the room scene
cf7764bc22 HARVESTER: Materialize startup room NPCs
c5b89630ea HARVESTER: Draw wait overlay over the live screen
50a84917bd HARVESTER: Fix room actor placement and npc targeting
1407c761a0 HARVESTER: Fix room entry actor offsets and pickups
cc2b0c8942 HARVESTER: Add hank room dialogue
f92ff568ae HARVESTER: Fix native hank dialogue menus
a470aed4dc HARVESTER: Extract startup dialogue and menu systems
a5ec55becd HARVESTER: Extract startup inventory and room systems
fde126c3a3 HARVESTER: Honor room animation active state
d790c5f6d9 HARVESTER: Streamline animation ticks
a6a3a3b7d1 HARVESTER: Improve dialogue font handling
77b593c031 HARVESTER: Support dialogue head variants
c59cf54644 HARVESTER: Refactor startup sound handling
c96c7b6b8d HARVESTER: Refactor startup music handling
513a92b399 HARVESTER: Add dialogue debug support
c4ea1c1fd4 HARVESTER: Improve dialogue topic handling
35f656c6e7 HARVESTER: Improve XFileArchive handling
21e7f3d249 HARVESTER: Add Mom dialogue state
7a439574e1 HARVESTER: Mirror npc death state for mom dialogue
708005505f HARVESTER: Add Jimmy dialogue state
b5a13ae947 HARVESTER: Implement startup SET_REGION and START_DIALOG
50524a1885 HARVESTER: Honor startup dialogue abort flow
f46bb5fb7d HARVESTER: Restore startup monsterfy replacement
6e5da3a330 HARVESTER: Handle startup GODEATHFLIC menu return
be563883fe HARVESTER: Align mom dialogue resources with native flow
643b57b534 HARVESTER: Restore mom dialogue menu loop
d9243c6062 HARVESTER: Restore hidden mom dialogue branches
fa0d637abf HARVESTER: Document DAT and dialogue evidence
7e53df9f9e HARVESTER: Mirror mom evidence state
a2924eb25e HARVESTER: Recover mom hidden dialogue branches
3712f33422 HARVESTER: Audit wasp woman dialogue helpers
b16463f0a7 HARVESTER: Port wasp woman startup dialogue
ce94b5d6b2 HARVESTER: Split dialogue system into npc handlers
05dfb6bab4 HARVESTER: Port pta mom startup dialogue
c26cc8ff16 HARVESTER: Port dwayne startup dialogue
cfb0deb039 HARVESTER: Move npc dialogue state into handlers
18404318ef HARVESTER: Port edna startup dialogue
3718f83e6e HARVESTER: Move npc dialogue logic into handlers
ed1f0d3c3d HARVESTER: Port herrill startup dialogue
b98e81be1b HARVESTER: Port johnson startup dialogue
209fc0a2eb HARVESTER: Audit neutral dialogue state wrappers
7eb83fe073 HARVESTER: Bound hidden boyle keyword block
650c100900 HARVESTER: Port curator dialogue
191f7b541b HARVESTER: Port lodge chef dialogue
aac5e18ef3 HARVESTER: Port chessmaster dialogue
ed9a6f9076 HARVESTER: Port mother dialogue
b11a428b10 HARVESTER: Port inquisitor dialogue
25e39985ea HARVESTER: Port authority dialogue
acbb5ff3af HARVESTER: Port maintenance man dialogue
34c4922730 HARVESTER: Port beggar dialogue
885707f6e0 HARVESTER: Port madam dialogue
5aab4e9b36 HARVESTER: Port gladiator dialogue
2ae20e906d HARVESTER: Port fireman1 dialogue
891b4fbab8 HARVESTER: Port fireman2 dialogue
bc28c3f9c2 HARVESTER: Port pastorelli dialogue
0236207f00 HARVESTER: Port priest dialogue
0b44eab9cd HARVESTER: Port valet dialogue
78c857d7d1 HARVESTER: Port vet dialogue
a53268a5d9 HARVESTER: Port cloak attendant dialogue
9b2dc3c692 HARVESTER: Port dark woman dialogue
a9549b8a15 HARVESTER: Port buster dialogue
7483ab72bc HARVESTER: Port butcher dialogue
aa5fe9f59d HARVESTER: Port phelps dialogue
f333587cc6 HARVESTER: Port swell dialogue
c17925e870 HARVESTER: Port parsons dialogue
3472b76700 HARVESTER: Port whaley dialogue
9ac15b4a4b HARVESTER: Port ryder dialogue
a2563badd9 HARVESTER: Port mcknight dialogue
350e3fd48b HARVESTER: Port librarian dialogue
ccbddbc0a8 HARVESTER: Port nude man dialogue
64e701260c HARVESTER: Port membership director dialogue
2f2d03de92 HARVESTER: Port karin dialogue
a054e52003 HARVESTER: Port loomis dialogue
1384493025 HARVESTER: Port moynahan dialogue
246f6457cb HARVESTER: Port mr potts dialogue
d3051f89d7 HARVESTER: Port mrs potts dialogue
ed79bd58c0 HARVESTER: Port sparky dialogue
8e36f3b45e HARVESTER: Port sergeant dialogue
6d2e4eaa8a HARVESTER: Port dad dialogue
b4fa6537b8 HARVESTER: Port boyle dialogue
2ec10ae623 HARVESTER: Port stephanie dialogue
be5183af01 HARVESTER: Rebuild mom dialogue from native flow
2b9decade5 HARVESTER: Rebuild whaley dialogue from native flow
ad0b88166d HARVESTER: Rebuild mr potts dialogue from native flow
8278a82a26 HARVESTER: Correct jimmy dialogue branches
9b5c3f7a09 HARVESTER: Rebuild boyle dialogue from native flow
60588d1f1d HARVESTER: Rebuild mrs potts dialogue from native flow
63757377a6 HARVESTER: Rebuild phelps dialogue from native flow
fbf2f333be HARVESTER: Rebuild moynahan dialogue from native flow
e33c7da94b HARVESTER: Rebuild stephanie dialogue from native flow
31942cf3c9 HARVESTER: Rebuild mrs potts dialogue from native flow
ee39e69974 HARVESTER: Rebuild johnson dialogue from native flow
17eb1fe041 HARVESTER: Correct herrill bye routing
a222f4f42b HARVESTER: Rebuild hank dialogue from native flow
b89486bbb1 HARVESTER: Correct sparky bye routing
5411d3c7c5 HARVESTER: Correct stephanie bye routing
5a369a9017 HARVESTER: Rebuild wasp woman dialogue from native flow
9c46ecd88b HARVESTER: Close out dialogue corrective audit
ddcd72f607 HARVESTER: Restore movie display mode for dialogue fst
999ca2355f HARVESTER: Refresh dialogue screen after fst playback
c876ecdd7e HARVESTER: Refresh room screen after fst playback
04e99136bf HARVESTER: Clamp ambient room npc animation bank
7abcba2e75 HARVESTER: Queue startup CHANGE_ROOM handoffs
5d2d91da2c HARVESTER: Preserve jimmy dialogue room handoff
5fa4636cee HARVESTER: Relax startup room background detection
401474022a HARVESTER: Add startup town map transitions
8ee2d7d7be HARVESTER: Restore startup exit overlap and fast clicks
290838d357 HARVESTER: Restore inventory portrait health state
10482e1477 HARVESTER: Stop depth-scaling room NPC spawns
e64cc33137 HARVESTER: Key inventory portrait blits
3bc7477b43 HARVESTER: Emulate vga dac palette uploads
a98a29dbe6 HARVESTER: Implement room options menu
f19beab20e HARVESTER: Implement room help menu
a96e6ea3aa HARVESTER: Implement room save game menu
c65ca087d2 HARVESTER: Implement room quit game confirm
cb70177ce8 HARVESTER: Match options menu draw order
35c75977b3 HARVESTER: Match startup inventory carry handoff
270522517b HARVESTER: Match native item carry flow
b0d0ccad76 HARVESTER: Match native closeup pickup branching
8328b77aa9 HARVESTER: Match native jimmy talk branches
b8d9aea6b0 HARVESTER: Prefer native dialogue response table
f159e6197b HARVESTER: Match native save and options menus
6f18036366 HARVESTER: Restore jimmy response menus
1e7212104a HARVESTER: Strip Startup prefix from helper classes
f6d1433c04 HARVESTER: Rename startup helper files
ef3dca90c7 HARVESTER: Centralize media resource management
8d981383d9 HARVESTER: Wire startup and load game menus
f974c6f3bb HARVESTER: Restore cold start room handoff
208dc5239c HARVESTER: Bound FST decode to video payload
f4f24f9b41 HARVESTER: Fix confirm dialog font layout
22b832088e HARVESTER: Preserve confirm prompt spacing
d5a874f8cb HARVESTER: Align true main menu rows
b495872d03 HARVESTER: Restore room menu palette after submenus
19b23e64cf HARVESTER: Add room timer runtime substrate
ff0aab0c67 HARVESTER: Widen confirmed town script coverage
9ed92f140b HARVESTER: Tighten inventory runtime flow
539bcf728b HARVESTER: Fix player idle animation timing
627eece00f HARVESTER: Use native map label font
e4f5cc01f4 HARVESTER: Fix dialogue textbox wrapping
db4cbbbc9d HARVESTER: Add room debug overlay command
1cbfadab20 HARVESTER: Dismiss quick tips on escape
8a8e236d51 HARVESTER: Skip background label in room debug
5bd858de86 HARVESTER: Label npcs in room debug overlay
d9367c1e6e HARVESTER: Apply Loomis magazine room-script side effects
cf10612c72 HARVESTER: Document npc useitem audit results
657e8c583b HARVESTER: Fix room turn direction and walk bounds
eb83fd482e HARVESTER: Route NPC item uses through script flow
717bbc05a7 HARVESTER: Restore native NPC item dialogue routing
3ac565a66f HARVESTER: Restore Loomis sheriff dialogue flow
36015d1373 HARVESTER: Play Loomis magazine handoff FST
48cb42475f HARVESTER: Close inventory on drag-out
739c488b74 HARVESTER: Log save and restore state
a4e1e09acb HARVESTER: Extract save/load module
fc41119761 HARVESTER: Replace atoi with Common-based parser
d64f664fa7 HARVESTER: Restore inventory secondary-click actions
a8423e9ccf HARVESTER: Restore POL_EVD exit cutscene playback
32c0afa199 HARVESTER: Slow room transition fades
ee7bc88846 HARVESTER: Restore native inventory hover tooltips
ba227cc336 HARVESTER: Add inventory click debug logging
2eb5a1b1ad HARVESTER: Restore inventory weekday label
fa0ab0617d HARVESTER: Map inventory note closeup action
311631201c HARVESTER: Skip inventory refresh after closeups
3a06dde494 HARVESTER: Restore sergeant application day handoff
6ecbf100d7 HARVESTER: Tighten FST playback parity
e41ea552a6 HARVESTER: Preserve room animation state across rebuilds
e5baf05687 HARVESTER: Defer room refresh until after GOFLIC
2175820703 HARVESTER: Fix room animation anchor drift
7747eeb493 HARVESTER: Recover room right-click attacks
902dd980e4 HARVESTER: Recover whaley bat reward
8c8a126674 HARVESTER: Restore room attack animation sounds
88da00e592 HARVESTER: Document formats and inventory toggle
0ddf63d873 HARVESTER: Restore Karin dialogue head variants
652b43bdbb HARVESTER: Extract room player and monster logic
24417c2f17 HARVESTER: Rename player and monster room helpers
98d7f3e898 HARVESTER: Name recovered record fields
57fc115d3d HARVESTER: Show save slots as 01-25
b716dcd301 HARVESTER: Restore Sergeant dialogue branches
d760ae48f9 HARVESTER: Add DEBUG_DIALOGUE console dump
47a39f38d9 HARVESTER: Revert save slot numbering
deff837af0 HARVESTER: Fix startup save slot label clipping
999c7ca865 HARVESTER: Dispatch expired timers by target tag
cf803e42a2 HARVESTER: Fix stephanie post-kidnap dialogue tail
d49b2cd3a7 HARVESTER: Restore Potts dialogue parity
f84f414a2e HARVESTER: Fix Stephanie peephole exit
73fbf8ad98 HARVESTER: Fix Stephanie kidnap tail
e97fbeb3e6 HARVESTER: Restore native room combat retaliation
64f1a9bb02 HARVESTER: Tighten room combat pursuit
f99506b78f HARVESTER: Mirror ctrl-arrow keyboard combat
e7cdca8197 HARVESTER: Restore monster chase and death states
f137a1d3d6 HARVESTER: Restore nude man dialogue flow
dbb8c742b5 HARVESTER: Fix Pottsdam cemetery interaction gating
39480f291b HARVESTER: Fix native dialogue head variants
40a3214db6 HARVESTER: Dim the town map palette at night
3bd682163e HARVESTER: Restore native valet and ryder dialogue flow
ae4fa5174c HARVESTER: Fix native ident overlay text
ec7923a703 HARVESTER: Restore black wait-screen backdrop
8aa63417aa HARVESTER: Restore ident text padding
389a28074a HARVESTER: Match VGA DAC palette expansion
2008a01a5a HARVESTER: Recover sergeant day 2 task menu
0dc6834bdd HARVESTER: Restore sergeant day 3 handoff
5ff2a880af HARVESTER: Remove debug dialogue console command
6ee968267b HARVESTER: Remove dialogue debug helper
75f0f961ae HARVESTER: Reimplement sergeant dialogue flow
c2b87ef752 HARVESTER: Align monster melee contact with native combat
d7ade5e70f HARVESTER: Align dialogue state with native save/load
8881d735de HARVESTER: Expand dialogue save state coverage
26a1faf058 HARVESTER: Fix monster combat frame-range checks
0c33314042 HARVESTER: Align Stephanie dialogue with native flow
31dd3b1f14 HARVESTER: Restore missing sergeant dialogue tails
4c645de3e2 HARVESTER: Align Hank dialogue with native flow
b9715d3bd3 HARVESTER: Align mom dialogue with ghidra
a3d097388e HARVESTER: Align Jimmy dialogue with native flow
cdd4213b13 HARVESTER: Align Mr Potts dialogue with native flow
938cd0adf0 HARVESTER: Reimplement Mrs. Potts dialogue
0925182cd1 HARVESTER: Sync Loomis dialogue with Ghidra
3462828ae8 HARVESTER: Reimplement Johnson dialogue handler
86ac4569dd HARVESTER: Reimplement Dwayne dialogue handler
cc2dfbb46c HARVESTER: Reimplement Karin dialogue handler
adba4e211a HARVESTER: Reimplement Edna dialogue handler
b47cfa1359 HARVESTER: Reimplement butcher dialogue handler
022b50081f HARVESTER: Reimplement Dad dialogue
36aef9fc38 HARVESTER: Fix Herrill dialogue flow
51de7bb07d HARVESTER: Reimplement moynahan dialogue handler
2e0d42b359 HARVESTER: Reimplement Boyle dialogue handler
4948c49572 HARVESTER: Reimplement mother dialogue handler
d7449e45b0 HARVESTER: Reimplement parsons dialogue
4fc2c04d16 HARVESTER: Fix Nude Man task gate
bc90cfe09c HARVESTER: Fix Phelps dialogue variants
ac77fc9866 HARVESTER: Realign Sparky dialogue with native flow
d92ba434be HARVESTER: Reimplement Buster dialogue from Ghidra
0b109e80d4 HARVESTER: Reimplement fireman1 dialogue followups
1d17572091 HARVESTER: Align fireman2 dialogue state with ghidra
6f70e4eb14 HARVESTER: Restore authority dialogue flow
51e762a4e5 HARVESTER: Improve FST format handling
b535e1e4f0 HARVESTER: Recover beggar dialogue flow
f293221f20 HARVESTER: Reimplement chessmaster dialogue
a42bb4f360 HARVESTER: Restore Ryder dialogue evidence state
1df864cc8f HARVESTER: Re-audit cloak attendant dialogue
c7e09b71db HARVESTER: Reimplement curator dialogue handler
429f1b5d08 HARVESTER: Restore dark woman dialogue flow
738835a266 HARVESTER: Reimplement gladiator dialogue
9c0fd4e9a1 HARVESTER: Restore inquisitor dialogue flow
5f0be709b1 HARVESTER: Reimplement librarian dialogue
4564bfb457 HARVESTER: Reimplement madam dialogue flow
f943278882 HARVESTER: Reimplement lodge chef dialogue
2ea7c8bac0 HARVESTER: Sync maint man dialogue with ghidra
5bba46ba2e HARVESTER: Restore MCKNIGHT dialogue flow
1b97a80f75 HARVESTER: Reimplement MEMB_DIR dialogue
97a271d9ae HARVESTER: Reimplement priest dialogue handler
da64f040dc HARVESTER: Fix PTA Mom response portrait tail
4f5be1dc9a HARVESTER: Align swell dialogue with ghidra
2b730a5960 HARVESTER: Sync swell dialogue state with native flow
fb2cf92780 HARVESTER: Align valet dialogue with native flow
24bb8e6033 HARVESTER: Reimplement vet dialogue
da31bf5097 HARVESTER: Reimplement wasp woman dialogue
ab9a26bd4b HARVESTER: Align Whaley dialogue with native state flow
d5d8b1730f HARVESTER: Reimplement Stephanie dialogue
3a938751c8 HARVESTER: Reimplement Dwayne dialogue
b793ddd343 HARVESTER: Reimplement Hank dialogue
5f360916c6 HARVESTER: Reimplement Jimmy dialogue
7f1c61e2cf HARVESTER: Reimplement Mom dialogue
0e937ead30 HARVESTER: Reimplement Mr. Potts dialogue
4abeef816d HARVESTER: Reimplement Mrs. Potts dialogue
bacdb4627b HARVESTER: Reimplement Edna dialogue
739d195e24 HARVESTER: Audit Karin dialogue
4e5e6dd5ba HARVESTER: Reimplement Loomis dialogue
14311dff55 HARVESTER: Reimplement Johnson dialogue
e967a266d0 HARVESTER: Route cemetery Karin dialogue
d92f225c46 HARVESTER: Add KARIN_STANDING dialogue alias
f67659458e HARVESTER: Add room NPC dialogue aliases
2cba1ce1e2 HARVESTER: Match native room actor depth anchor
ec65f38aeb HARVESTER: Add scene debug logging for Karin layering
2c387ca242 HARVESTER: Reinsert room actors after placement
768b123451 HARVESTER: Finalize room anim drift fix
357141e6f3 HARVESTER: Fix Stephanie BYE spyhole routing
0a3a865ecd HARVESTER: Preserve options menu label padding
1f2eb33598 HARVESTER: Close inventory on selected-item mouseout
28a6c86f9a HARVESTER: Cancel denied pickup carry state
257acc0329 HARVESTER: Preflight blocked pickup tags
1a883b084a HARVESTER: Align combat updates with native animation ticks
325f0c125e HARVESTER: Sync actor runtime naming
3d954839db HARVESTER: Restore native game-over menu flow
a84ecf6eb3 HARVESTER: Refactor combat distance checks
7f2f464af4 HARVESTER: Cache overlays and trigger game over
79e5f89454 HARVESTER: Preserve native main menu slots
b658febca6 HARVESTER: Match combat death flow to native
d519f8e8e1 HARVESTER: Split cursor, room, and player debug channels
18634c751a HARVESTER: Add Alex Bevilacqua to Harvester credits
585314c68e HARVESTER: Fix Moynahan embalming-room dialogue alias
4220bd7862 HARVESTER: Restore denied glue pickup after dialogue
b7549e8d30 HARVESTER: Fix glue pickup rollback crash
36a0fbef2d HARVESTER: Add combat debug overlay
65338671d9 HARVESTER: Fix native combat loadout damage tuning
bba1bc31ae HARVESTER: Add combat debug kill hotkey
d8b72cc63e HARVESTER: Restore sergeant remains tail parity
4f65e4b8b5 HARVESTER: Restart room after Sergeant CD3 prompt
b94cfb3c7c HARVESTER: Honor room blocker and hotspot objects
2567641eec HARVESTER: Add GOTO_ROOM debug command
05ed09c06a HARVESTER: Support prefixed multi-cd archives
0e411cbd71 HARVESTER: Add DEBUG_ACTIONS console command
3c4a05fc8a HARVESTER: Restore room-entry FST playback
374e02ab31 HARVESTER: Fix room-entry command timing
e9c911e4be HARVESTER: Fix DNAEXT interaction regressions
b42e77a90b HARVESTER: Prefer visible room object hits
d45ee94be8 HARVESTER: Apply room-entry mutations before first draw
5f9328c267 HARVESTER: Reload state after startup disc switches
e4bcd0a69b HARVESTER: Add documented debug command console path
d643de25b9 HARVESTER: Restore saved discs and list rooms
436c3b5865 HARVESTER: Improve disc switch resources
57a63deb39 HARVESTER: Implement FCMP warmup handling for audio streams
6b2a618d0a HARVESTER: Load resource sets from CD folders
c4d4672f3d HARVESTER: Implement town script reloading after disc changes
4a4f0a9e6e HARVESTER: Restart disc 3 reloads from START
7688fc3abf HARVESTER: Preserve START on sergeant disc swap
4d006e43dc HARVESTER: Reload town script on save restore
2952e0eb11 HARVESTER: Fix monster combat field order
239ad973e7 HARVESTER: Tighten monster melee entry range
d05f32d1cc HARVESTER: Restore native monster melee cadence
14dd767d71 HARVESTER: Restore native monster melee cooldown gate
8af7c77c38 HARVESTER: Repair legacy monster hp restore
ba9ae7f38f HARVESTER: Clear invalid loadouts after script reloads
5d4c60591b HARVESTER: Preserve monster corpses after death
df96046a64 HARVESTER: Update monster state save version
0fcdd156ba HARVESTER: Restore room after in-game save
5dbfd40e3f HARVESTER: Fix blocked room entry movement
71adee78af HARVESTER: Refresh live room hotspots after mutations
1d29d5d0f8 HARVESTER: Add debug logging for spawned room regions
a98a8642a1 HARVESTER: Match native frame advance class gate
a4e8831efb HARVESTER: Defer room NPC death transitions
d837c65c72 HARVESTER: Finalize room npc death removal
e3f20c8fb0 HARVESTER: Refine combat data docs
d2ebf72855 HARVESTER: Restore player hit reactions in combat
c83a422c4f HARVESTER: Restore native monster hit feedback
e3e6caf1b7 HARVESTER: Support closeup sprite backgrounds
b91783deb0 HARVESTER: Rename startup record structures
e18fb73545 HARVESTER: Refactor engine script and resource management
24adbb4213 HARVESTER: Rename runtime record arrays
2ead9a0182 HARVESTER: Preserve room combat state
11c23d93f1 HARVESTER: Align with ScummVM engine conventions
d6ad122ced HARVESTER: Restore active room animations on entry
4108f4b8b1 HARVESTER: Match closeup exit flow to the original game
f7488c2e1b HARVESTER: Match native room walk y tracking
c356829a1e HARVESTER: Clear held item on closeup use
6a61e89046 HARVESTER: Disambiguate runtime monster state by room
f76cea59c0 HARVESTER: Scope action dispatch by current room
50e30ec129 HARVESTER: Tighten monster room-entry combat
c8f8f38d74 HARVESTER: Restore inventory combat hud feedback
1994e879f3 HARVESTER: Recover native shotgun shell counts
7a77709a7d HARVESTER: Consume resources for player attacks
1cca90edd0 HARVESTER: Stop dry fire from reaching contact
86c52dbac5 HARVESTER: Fix live npc death and combat depth stepping
cd139cdbd8 HARVESTER: Fix Chessmaster death transition
de293f4569 HARVESTER: Hide save in closeup menus
4f022d04e0 HARVESTER: Match native click movement
611aa2b028 HARVESTER: Route click movement around blockers
85362ba6f6 HARVESTER: Fix scripted monsterfy transitions
d9a28b5e39 HARVESTER: Place monster corpses from saved frame
444deaf5fa HARVESTER: Match native PC_GOTO_XZ placement
87b5c1e183 HARVESTER: Match native chess battle sequencing
eb2d98a7c7 HARVESTER: Add pathfinding debug overlay
48d053d8d7 HARVESTER: Refine player blocker avoidance
234b21c133 HARVESTER: Validate pathfinding detours
b100267462 HARVESTER: Match native pathfinding detours
ff8e7864a1 HARVESTER: Match native actor movement state
e1a300e5ad HARVESTER: Fix passive room region triggers
0038a206f4 HARVESTER: Match native USEITEM dispatch
212cf3e46a HARVESTER: Match native monster pursuit waypoint
0fd95b8723 HARVESTER: Add projectile contact target fallback
cf6a23d96c HARVESTER: Fix chess king combat placement
4b0e0a5a35 HARVESTER: Add timer debug overlay
bbde52a4f0 HARVESTER: Match native object delete materialization
c4b568bfd6 HARVESTER: Preserve global timers across rooms
de0b23c979 HARVESTER: Avoid resetting active global timers
e2ba03e214 HARVESTER: Keep duplicate monster records distinct
c3f76d3622 HARVESTER: Preserve Big Eye death frame
7ad436bb24 HARVESTER: Avoid premature palette refresh after flags
9f860f0714 HARVESTER: Clear timers when loading saves
e97d13f490 HARVESTER: Tick offscreen global timers
df0f4c67f1 HARVESTER: Match native timer lookup
1e3c6493e2 HARVESTER: Defer triggered room animations
b3e824cf72 HARVESTER: Handle script-driven player death
d99af5510a HARVESTER: Preserve dialogue action room context
9b1541d3a9 HARVESTER: Route Potts flesh NPC dialogue
75486a167d HARVESTER: Queue Dark Woman monsterfy dialogue
5975cf10eb HARVESTER: Queue live monsterfy dialogue transitions
fd7652e627 HARVESTER: Match native monsterfy actor placement
ba716a4db7 HARVESTER: Queue Grand Muck live transition
b4dfa20d31 HARVESTER: Keep NPC death sprites in current room
75a00695d1 HARVESTER: Fix Stephanie choice prompts
bd8bf84145 HARVESTER: Queue Herrill death after Muckey
a630529349 HARVESTER: Document NO87 runtime details
7f77459b98 HARVESTER: Match native Chessmaster FLC sequencing
db4245beb2 HARVESTER: Fix FST video block decoding
64fa5ea38d HARVESTER: Revert Chessmaster FLC sequencing
dc98ee4234 HARVESTER: Trigger Chessmaster death after puzzle
d700da1ef7 HARVESTER: Refresh room after Loomis leaves
4034b04b03 HARVESTER: Fix inventory use prompt labels
dae5bb1d2d HARVESTER: Fix Wasp Woman BYE handling
8f5df2010a HARVESTER: Hide unsigned meat permission after Dad signs it
c464b55a30 HARVESTER: Preserve live palette during room mutations
cc613021ff HARVESTER: Fix Moynahan glue trade state
92d4f68713 HARVESTER: Fix global timer sync on room exit
feea0499c7 HARVESTER: Fix region exit movement target completion
c879b94044 HARVESTER: Avoid duplicate chessmaster death sprite
f9baa8c275 HARVESTER: Fix Roman bath exit targeting
d2868ac68a HARVESTER: Split room combat and interaction code
76fae8e7c1 HARVESTER: Fix monster depth sort after chase movement
3180cb0932 HARVESTER: Fix save description build
dc6fc5c3e7 HARVESTER: Remove RE docs and saveload tweak
6539c386fa HARVESTER: Fix cross-disc save room restores
6a8282bc75 HARVESTER: Finalize save format version
07edd4e407 HARVESTER: Adjust animation clock divisor for improved timing accuracy
8aa7e78855 HARVESTER: Clean up animation clock comments
9ecd0e3ebf HARVESTER: Sort module.mk files


Commit: 129f57923075002aaa6ef014a79fb087e135394e
    https://github.com/scummvm/scummvm/commit/129f57923075002aaa6ef014a79fb087e135394e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:26+02:00

Commit Message:
HARVESTER: Scaffold engine

Changed paths:
  A engines/harvester/POTFILES
  A engines/harvester/configure.engine
  A engines/harvester/console.cpp
  A engines/harvester/console.h
  A engines/harvester/credits.pl
  A engines/harvester/detection.cpp
  A engines/harvester/detection.h
  A engines/harvester/detection_tables.h
  A engines/harvester/harvester.cpp
  A engines/harvester/harvester.h
  A engines/harvester/metaengine.cpp
  A engines/harvester/metaengine.h
  A engines/harvester/module.mk


diff --git a/engines/harvester/POTFILES b/engines/harvester/POTFILES
new file mode 100644
index 00000000000..0c540f9b5f7
--- /dev/null
+++ b/engines/harvester/POTFILES
@@ -0,0 +1 @@
+engines/harvester/metaengine.cpp
diff --git a/engines/harvester/configure.engine b/engines/harvester/configure.engine
new file mode 100644
index 00000000000..143bccb10c6
--- /dev/null
+++ b/engines/harvester/configure.engine
@@ -0,0 +1,3 @@
+# This file is included from the main "configure" script
+# add_engine [name] [desc] [build-by-default] [subengines] [base games] [deps] [components]
+add_engine harvester "Harvester" no "" "" "" ""
diff --git a/engines/harvester/console.cpp b/engines/harvester/console.cpp
new file mode 100644
index 00000000000..5e406af68c5
--- /dev/null
+++ b/engines/harvester/console.cpp
@@ -0,0 +1,38 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/console.h"
+
+namespace Harvester {
+
+Console::Console() : GUI::Debugger() {
+	registerCmd("about", WRAP_METHOD(Console, Cmd_about));
+}
+
+Console::~Console() {
+}
+
+bool Console::Cmd_about(int argc, const char **argv) {
+	debugPrintf("Harvester engine scaffold\n");
+	return true;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/console.h b/engines/harvester/console.h
new file mode 100644
index 00000000000..e01fc87f1ce
--- /dev/null
+++ b/engines/harvester/console.h
@@ -0,0 +1,40 @@
+
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_CONSOLE_H
+#define HARVESTER_CONSOLE_H
+
+#include "gui/debugger.h"
+
+namespace Harvester {
+
+class Console : public GUI::Debugger {
+private:
+	bool Cmd_about(int argc, const char **argv);
+public:
+	Console();
+	~Console() override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_CONSOLE_H
diff --git a/engines/harvester/credits.pl b/engines/harvester/credits.pl
new file mode 100644
index 00000000000..9cd5cfff20c
--- /dev/null
+++ b/engines/harvester/credits.pl
@@ -0,0 +1,3 @@
+begin_section("Harvester");
+	# TODO: add engine maintainers here once the scaffold has an owner.
+end_section();
diff --git a/engines/harvester/detection.cpp b/engines/harvester/detection.cpp
new file mode 100644
index 00000000000..a3773d9eeb7
--- /dev/null
+++ b/engines/harvester/detection.cpp
@@ -0,0 +1,37 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "base/plugins.h"
+
+#include "harvester/detection.h"
+#include "harvester/detection_tables.h"
+
+const DebugChannelDef HarvesterMetaEngineDetection::debugFlagList[] = {
+	{ Harvester::kDebugGeneral, "general", "General debug level" },
+	DEBUG_CHANNEL_END
+};
+
+HarvesterMetaEngineDetection::HarvesterMetaEngineDetection() : AdvancedMetaEngineDetection(
+	Harvester::gameDescriptions, Harvester::harvesterGames) {
+	_maxScanDepth = 1;
+}
+
+REGISTER_PLUGIN_STATIC(HARVESTER_DETECTION, PLUGIN_TYPE_ENGINE_DETECTION, HarvesterMetaEngineDetection);
diff --git a/engines/harvester/detection.h b/engines/harvester/detection.h
new file mode 100644
index 00000000000..4d2db0dae33
--- /dev/null
+++ b/engines/harvester/detection.h
@@ -0,0 +1,63 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_DETECTION_H
+#define HARVESTER_DETECTION_H
+
+#include "engines/advancedDetector.h"
+
+namespace Harvester {
+
+enum HarvesterDebugChannels {
+	kDebugGeneral = 1 << 0
+};
+
+extern const PlainGameDescriptor harvesterGames[];
+
+extern const ADGameDescription gameDescriptions[];
+
+} // End of namespace Harvester
+
+class HarvesterMetaEngineDetection : public AdvancedMetaEngineDetection<ADGameDescription> {
+	static const DebugChannelDef debugFlagList[];
+
+public:
+	HarvesterMetaEngineDetection();
+	~HarvesterMetaEngineDetection() override {}
+
+	const char *getName() const override {
+		return "harvester";
+	}
+
+	const char *getEngineName() const override {
+		return "Harvester";
+	}
+
+	const char *getOriginalCopyright() const override {
+		return "Harvester (C) DigiFX Interactive";
+	}
+
+	const DebugChannelDef *getDebugChannels() const override {
+		return debugFlagList;
+	}
+};
+
+#endif // HARVESTER_DETECTION_H
diff --git a/engines/harvester/detection_tables.h b/engines/harvester/detection_tables.h
new file mode 100644
index 00000000000..d15ad34e3ef
--- /dev/null
+++ b/engines/harvester/detection_tables.h
@@ -0,0 +1,45 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace Harvester {
+
+const PlainGameDescriptor harvesterGames[] = {
+	{ "harvester", "Harvester" },
+	{ 0, 0 }
+};
+
+const ADGameDescription gameDescriptions[] = {
+	// Placeholder detection entry from the scaffold. Replace this with a real
+	// Harvester signature before expecting launcher detection to succeed.
+	{
+		"harvester",
+		nullptr,
+		AD_ENTRY1s("file1.bin", "00000000000000000000000000000000", 11111),
+		Common::EN_ANY,
+		Common::kPlatformDOS,
+		ADGF_UNSTABLE,
+		GUIO1(GUIO_NONE)
+	},
+
+	AD_TABLE_END_MARKER
+};
+
+} // End of namespace Harvester
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
new file mode 100644
index 00000000000..17f196626fe
--- /dev/null
+++ b/engines/harvester/harvester.cpp
@@ -0,0 +1,128 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/harvester.h"
+
+#include "common/config-manager.h"
+#include "common/events.h"
+#include "common/system.h"
+#include "engines/util.h"
+#include "graphics/framelimiter.h"
+#include "graphics/paletteman.h"
+#include "harvester/console.h"
+#include "harvester/detection.h"
+
+namespace Harvester {
+
+HarvesterEngine *g_engine = nullptr;
+
+HarvesterEngine::HarvesterEngine(OSystem *syst, const ADGameDescription *gameDesc) : Engine(syst),
+	_gameDescription(gameDesc), _randomSource("Harvester") {
+	g_engine = this;
+}
+
+HarvesterEngine::~HarvesterEngine() {
+	delete _screen;
+	g_engine = nullptr;
+}
+
+Common::String HarvesterEngine::getGameId() const {
+	return _gameDescription->gameId;
+}
+
+Common::Error HarvesterEngine::run() {
+	// Initialize 320x200 paletted graphics mode
+	initGraphics(320, 200);
+	_screen = new Graphics::Screen();
+
+	// Set the engine's debugger console
+	setDebugger(new Console());
+
+	// If a savegame was selected from the launcher, load it
+	int saveSlot = ConfMan.getInt("save_slot");
+	if (saveSlot != -1)
+		(void)loadGameState(saveSlot);
+
+	// Draw a series of boxes on screen as a sample
+	for (int i = 0; i < 100; ++i)
+		_screen->frameRect(Common::Rect(i, i, 320 - i, 200 - i), i);
+	_screen->update();
+
+	// Simple event handling loop
+	byte pal[256 * 3] = { 0 };
+	Common::Event e;
+	int offset = 0;
+
+	Graphics::FrameLimiter limiter(g_system, 60);
+	while (!shouldQuit()) {
+		while (g_system->getEventManager()->pollEvent(e)) {
+			switch (e.type) {
+			case Common::EVENT_QUIT:
+			case Common::EVENT_RETURN_TO_LAUNCHER:
+				return Common::kNoError;
+			default:
+				break;
+			}
+		}
+
+		// Cycle through a simple palette
+		++offset;
+		for (int i = 0; i < 256; ++i)
+			pal[i * 3 + 1] = (i + offset) % 256;
+		g_system->getPaletteManager()->setPalette(pal, 0, 256);
+		// Delay for a bit. All events loops should have a delay
+		// to prevent the system being unduly loaded
+		limiter.delayBeforeSwap();
+		_screen->update();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+bool HarvesterEngine::hasFeature(EngineFeature f) const {
+	return (f == kSupportsLoadingDuringRuntime) ||
+	       (f == kSupportsSavingDuringRuntime) ||
+	       (f == kSupportsReturnToLauncher);
+}
+
+Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
+	// The Serializer has methods isLoading() and isSaving()
+	// if you need to specific steps; for example setting
+	// an array size after reading it's length, whereas
+	// for saving it would write the existing array's length
+	uint32 dummy = 0;
+	s.syncAsUint32LE(dummy);
+
+	return Common::kNoError;
+}
+
+Common::Error HarvesterEngine::saveGameStream(Common::WriteStream *stream, bool isAutosave) {
+	Common::Serializer serializer(nullptr, stream);
+	return syncGame(serializer);
+}
+
+Common::Error HarvesterEngine::loadGameStream(Common::SeekableReadStream *stream) {
+	Common::Serializer serializer(stream, nullptr);
+	return syncGame(serializer);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
new file mode 100644
index 00000000000..96008d8b986
--- /dev/null
+++ b/engines/harvester/harvester.h
@@ -0,0 +1,77 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_H
+#define HARVESTER_H
+
+#include "common/error.h"
+#include "common/random.h"
+#include "common/serializer.h"
+#include "engines/engine.h"
+#include "graphics/screen.h"
+
+struct ADGameDescription;
+
+namespace Harvester {
+
+class HarvesterEngine : public Engine {
+public:
+	HarvesterEngine(OSystem *syst, const ADGameDescription *gameDesc);
+	~HarvesterEngine() override;
+
+	Common::Error run() override;
+	bool hasFeature(EngineFeature f) const override;
+
+	/**
+	 * Returns the game Id
+	 */
+	Common::String getGameId() const;
+
+	/**
+	 * Gets a random number
+	 */
+	uint32 getRandomNumber(uint maxNum) {
+		return _randomSource.getRandomNumber(maxNum);
+	}
+
+	bool canLoadGameStateCurrently(Common::U32String *msg = nullptr) override { return true; }
+	bool canSaveGameStateCurrently(Common::U32String *msg = nullptr) override { return true; }
+
+	/**
+	 * Uses a serializer to allow implementing savegame
+	 * loading and saving using a single method
+	 */
+	Common::Error syncGame(Common::Serializer &s);
+	Common::Error saveGameStream(Common::WriteStream *stream, bool isAutosave = false) override;
+	Common::Error loadGameStream(Common::SeekableReadStream *stream) override;
+
+private:
+	const ADGameDescription *const _gameDescription;
+	Common::RandomSource _randomSource;
+	Graphics::Screen *_screen = nullptr;
+};
+
+extern HarvesterEngine *g_engine;
+#define SHOULD_QUIT ::Harvester::g_engine->shouldQuit()
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_H
diff --git a/engines/harvester/metaengine.cpp b/engines/harvester/metaengine.cpp
new file mode 100644
index 00000000000..92ff7aaf637
--- /dev/null
+++ b/engines/harvester/metaengine.cpp
@@ -0,0 +1,45 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "base/plugins.h"
+
+#include "harvester/metaengine.h"
+#include "harvester/harvester.h"
+
+const char *HarvesterMetaEngine::getName() const {
+	return "harvester";
+}
+
+Common::Error HarvesterMetaEngine::createInstance(OSystem *syst, Engine **engine, const ADGameDescription *desc) const {
+	*engine = new Harvester::HarvesterEngine(syst, desc);
+	return Common::kNoError;
+}
+
+bool HarvesterMetaEngine::hasFeature(MetaEngineFeature f) const {
+	return checkExtendedSaves(f) ||
+		(f == kSupportsLoadingDuringStartup);
+}
+
+#if PLUGIN_ENABLED_DYNAMIC(HARVESTER)
+REGISTER_PLUGIN_DYNAMIC(HARVESTER, PLUGIN_TYPE_ENGINE, HarvesterMetaEngine);
+#else
+REGISTER_PLUGIN_STATIC(HARVESTER, PLUGIN_TYPE_ENGINE, HarvesterMetaEngine);
+#endif
diff --git a/engines/harvester/metaengine.h b/engines/harvester/metaengine.h
new file mode 100644
index 00000000000..2c7d398801e
--- /dev/null
+++ b/engines/harvester/metaengine.h
@@ -0,0 +1,41 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_METAENGINE_H
+#define HARVESTER_METAENGINE_H
+
+#include "engines/advancedDetector.h"
+
+class HarvesterMetaEngine : public AdvancedMetaEngine<ADGameDescription> {
+public:
+	const char *getName() const override;
+
+	Common::Error createInstance(OSystem *syst, Engine **engine, const ADGameDescription *desc) const override;
+
+	/**
+	 * Determine whether the engine supports the specified MetaEngine feature.
+	 *
+	 * Used by e.g. the launcher to determine whether to enable the Load button.
+	 */
+	bool hasFeature(MetaEngineFeature f) const override;
+};
+
+#endif // HARVESTER_METAENGINE_H
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
new file mode 100644
index 00000000000..7008997455f
--- /dev/null
+++ b/engines/harvester/module.mk
@@ -0,0 +1,17 @@
+MODULE := engines/harvester
+
+MODULE_OBJS = \
+	harvester.o \
+	console.o \
+	metaengine.o
+
+# This module can be built as a plugin
+ifeq ($(ENABLE_HARVESTER), DYNAMIC_PLUGIN)
+PLUGIN := 1
+endif
+
+# Include common rules
+include $(srcdir)/rules.mk
+
+# Detection objects
+DETECT_OBJS += $(MODULE)/detection.o


Commit: c5ffcb22f44455dc53fc1b5faacc3b5f2e255f1e
    https://github.com/scummvm/scummvm/commit/c5ffcb22f44455dc53fc1b5faacc3b5f2e255f1e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:29+02:00

Commit Message:
HARVESTER: Add XFILE resource backbone

Add a reusable Harvester resource manager that normalizes DOS-style
resource paths and resolves them through ScummVM's archive/search
framework.

This also replaces the placeholder game detection entry with a real
Harvester executable signature so the engine can recognize extracted DOS
installs while the startup loaders are being implemented.

Changed paths:
  A engines/harvester/resources.cpp
  A engines/harvester/resources.h
    engines/harvester/detection.cpp
    engines/harvester/detection.h
    engines/harvester/detection_tables.h
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/module.mk


diff --git a/engines/harvester/detection.cpp b/engines/harvester/detection.cpp
index a3773d9eeb7..94d8986cd47 100644
--- a/engines/harvester/detection.cpp
+++ b/engines/harvester/detection.cpp
@@ -26,6 +26,7 @@
 
 const DebugChannelDef HarvesterMetaEngineDetection::debugFlagList[] = {
 	{ Harvester::kDebugGeneral, "general", "General debug level" },
+	{ Harvester::kDebugResources, "resources", "Resource loading" },
 	DEBUG_CHANNEL_END
 };
 
diff --git a/engines/harvester/detection.h b/engines/harvester/detection.h
index 4d2db0dae33..350d3dc19ae 100644
--- a/engines/harvester/detection.h
+++ b/engines/harvester/detection.h
@@ -27,7 +27,8 @@
 namespace Harvester {
 
 enum HarvesterDebugChannels {
-	kDebugGeneral = 1 << 0
+	kDebugGeneral   = 1 << 0,
+	kDebugResources = 1 << 1
 };
 
 extern const PlainGameDescriptor harvesterGames[];
diff --git a/engines/harvester/detection_tables.h b/engines/harvester/detection_tables.h
index d15ad34e3ef..384eb282d98 100644
--- a/engines/harvester/detection_tables.h
+++ b/engines/harvester/detection_tables.h
@@ -27,12 +27,10 @@ const PlainGameDescriptor harvesterGames[] = {
 };
 
 const ADGameDescription gameDescriptions[] = {
-	// Placeholder detection entry from the scaffold. Replace this with a real
-	// Harvester signature before expecting launcher detection to succeed.
 	{
 		"harvester",
 		nullptr,
-		AD_ENTRY1s("file1.bin", "00000000000000000000000000000000", 11111),
+		AD_ENTRY1s("harvest.exe", "f2a8689edf91552dd0baa35ecc325c0a", 1173079),
 		Common::EN_ANY,
 		Common::kPlatformDOS,
 		ADGF_UNSTABLE,
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 17f196626fe..1909a5b6ffd 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -29,6 +29,7 @@
 #include "graphics/paletteman.h"
 #include "harvester/console.h"
 #include "harvester/detection.h"
+#include "harvester/resources.h"
 
 namespace Harvester {
 
@@ -40,6 +41,7 @@ HarvesterEngine::HarvesterEngine(OSystem *syst, const ADGameDescription *gameDes
 }
 
 HarvesterEngine::~HarvesterEngine() {
+	delete _resources;
 	delete _screen;
 	g_engine = nullptr;
 }
@@ -49,6 +51,8 @@ Common::String HarvesterEngine::getGameId() const {
 }
 
 Common::Error HarvesterEngine::run() {
+	_resources = new ResourceManager();
+
 	// Initialize 320x200 paletted graphics mode
 	initGraphics(320, 200);
 	_screen = new Graphics::Screen();
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 96008d8b986..24e90193464 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -32,6 +32,8 @@ struct ADGameDescription;
 
 namespace Harvester {
 
+class ResourceManager;
+
 class HarvesterEngine : public Engine {
 public:
 	HarvesterEngine(OSystem *syst, const ADGameDescription *gameDesc);
@@ -63,10 +65,13 @@ public:
 	Common::Error saveGameStream(Common::WriteStream *stream, bool isAutosave = false) override;
 	Common::Error loadGameStream(Common::SeekableReadStream *stream) override;
 
+	ResourceManager *getResources() const { return _resources; }
+
 private:
 	const ADGameDescription *const _gameDescription;
 	Common::RandomSource _randomSource;
 	Graphics::Screen *_screen = nullptr;
+	ResourceManager *_resources = nullptr;
 };
 
 extern HarvesterEngine *g_engine;
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 7008997455f..1065efcf726 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -3,7 +3,8 @@ MODULE := engines/harvester
 MODULE_OBJS = \
 	harvester.o \
 	console.o \
-	metaengine.o
+	metaengine.o \
+	resources.o
 
 # This module can be built as a plugin
 ifeq ($(ENABLE_HARVESTER), DYNAMIC_PLUGIN)
diff --git a/engines/harvester/resources.cpp b/engines/harvester/resources.cpp
new file mode 100644
index 00000000000..cce65f7f129
--- /dev/null
+++ b/engines/harvester/resources.cpp
@@ -0,0 +1,90 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/resources.h"
+
+#include "common/stream.h"
+
+namespace Harvester {
+
+ResourceManager::ResourceManager() {
+	reset();
+}
+
+ResourceManager::~ResourceManager() {
+	_search.clear();
+}
+
+void ResourceManager::reset() {
+	_search.clear();
+	_search.add("harvester-loose-files", &SearchMan, 0, false);
+}
+
+Common::String ResourceManager::normalizeResourcePath(const Common::String &path) const {
+	Common::String normalized(path);
+
+	for (uint i = 0; i < normalized.size(); ++i) {
+		if (normalized[i] == '\\')
+			normalized.setChar('/', i);
+	}
+
+	while (normalized.hasPrefix("./"))
+		normalized.erase(0, 2);
+
+	if (normalized.size() >= 3 && normalized[1] == ':' && normalized[2] == '/')
+		normalized.erase(0, 3);
+
+	while (!normalized.empty() && normalized[0] == '/')
+		normalized.erase(0, 1);
+
+	return normalized;
+}
+
+bool ResourceManager::hasFile(const Common::String &path) const {
+	const Common::String normalized = normalizeResourcePath(path);
+	return !normalized.empty() && _search.hasFile(Common::Path(normalized, '/'));
+}
+
+Common::SeekableReadStream *ResourceManager::openFile(const Common::String &path) const {
+	const Common::String normalized = normalizeResourcePath(path);
+	if (normalized.empty())
+		return nullptr;
+
+	return _search.createReadStreamForMember(Common::Path(normalized, '/'));
+}
+
+bool ResourceManager::loadFile(const Common::String &path, Common::Array<byte> &data) const {
+	Common::ScopedPtr<Common::SeekableReadStream> stream(openFile(path));
+	if (!stream)
+		return false;
+
+	data.resize(stream->size());
+	if (!data.empty())
+		stream->read(data.data(), data.size());
+
+	return true;
+}
+
+void ResourceManager::mountArchive(const Common::String &name, Common::Archive *archive, int priority, bool autoFree) {
+	_search.add(name, archive, priority, autoFree);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/resources.h b/engines/harvester/resources.h
new file mode 100644
index 00000000000..d24ab43b01c
--- /dev/null
+++ b/engines/harvester/resources.h
@@ -0,0 +1,50 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_RESOURCES_H
+#define HARVESTER_RESOURCES_H
+
+#include "common/archive.h"
+#include "common/array.h"
+
+namespace Harvester {
+
+class ResourceManager {
+public:
+	ResourceManager();
+	~ResourceManager();
+
+	void reset();
+
+	bool hasFile(const Common::String &path) const;
+	Common::SeekableReadStream *openFile(const Common::String &path) const;
+	bool loadFile(const Common::String &path, Common::Array<byte> &data) const;
+
+	Common::String normalizeResourcePath(const Common::String &path) const;
+	void mountArchive(const Common::String &name, Common::Archive *archive, int priority = 10, bool autoFree = true);
+
+private:
+	Common::SearchSet _search;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_RESOURCES_H


Commit: 261e78c310d2abaa64df28adb1219f75fb9b0f87
    https://github.com/scummvm/scummvm/commit/261e78c310d2abaa64df28adb1219f75fb9b0f87
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:29+02:00

Commit Message:
HARVESTER: Mount numbered XFILE archives

Add a Harvester XFILE archive reader for the DAT/INDEX.00N pairs used by
the DOS startup path.

The archive loader parses the fixed-size XFLE index records, mounts the
startup sets in priority order, and handles both stored and packed
entries so later startup code can resolve archive-only resources through
the normal resource manager.

Changed paths:
  A engines/harvester/xfile_archive.cpp
  A engines/harvester/xfile_archive.h
    engines/harvester/harvester.cpp
    engines/harvester/module.mk
    engines/harvester/resources.cpp
    engines/harvester/resources.h


diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 1909a5b6ffd..557a0e5985a 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -52,6 +52,7 @@ Common::String HarvesterEngine::getGameId() const {
 
 Common::Error HarvesterEngine::run() {
 	_resources = new ResourceManager();
+	_resources->mountStartupArchives();
 
 	// Initialize 320x200 paletted graphics mode
 	initGraphics(320, 200);
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 1065efcf726..c1f6d1cfda3 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -4,7 +4,8 @@ MODULE_OBJS = \
 	harvester.o \
 	console.o \
 	metaengine.o \
-	resources.o
+	resources.o \
+	xfile_archive.o
 
 # This module can be built as a plugin
 ifeq ($(ENABLE_HARVESTER), DYNAMIC_PLUGIN)
diff --git a/engines/harvester/resources.cpp b/engines/harvester/resources.cpp
index cce65f7f129..e690a02a4cf 100644
--- a/engines/harvester/resources.cpp
+++ b/engines/harvester/resources.cpp
@@ -22,23 +22,11 @@
 #include "harvester/resources.h"
 
 #include "common/stream.h"
+#include "harvester/xfile_archive.h"
 
 namespace Harvester {
 
-ResourceManager::ResourceManager() {
-	reset();
-}
-
-ResourceManager::~ResourceManager() {
-	_search.clear();
-}
-
-void ResourceManager::reset() {
-	_search.clear();
-	_search.add("harvester-loose-files", &SearchMan, 0, false);
-}
-
-Common::String ResourceManager::normalizeResourcePath(const Common::String &path) const {
+Common::String normalizeHarvesterResourcePath(const Common::String &path) {
 	Common::String normalized(path);
 
 	for (uint i = 0; i < normalized.size(); ++i) {
@@ -58,6 +46,50 @@ Common::String ResourceManager::normalizeResourcePath(const Common::String &path
 	return normalized;
 }
 
+ResourceManager::ResourceManager() {
+	reset();
+}
+
+ResourceManager::~ResourceManager() {
+	_search.clear();
+}
+
+void ResourceManager::reset() {
+	_search.clear();
+	_search.add("harvester-loose-files", &SearchMan, 0, false);
+}
+
+Common::String ResourceManager::normalizeResourcePath(const Common::String &path) const {
+	return normalizeHarvesterResourcePath(path);
+}
+
+bool ResourceManager::mountStartupArchives() {
+	static const struct {
+		const char *archiveName;
+		const char *indexPath;
+		const char *dataPath;
+		int priority;
+	} kArchiveSpecs[] = {
+		{ "harvester-xfile-1", "INDEX.001", "HARVEST.DAT", 30 },
+		{ "harvester-xfile-2", "INDEX.002", "SOUND.DAT", 29 },
+		{ "harvester-xfile-3", "INDEX.003", "HARVEST2.DAT", 28 }
+	};
+
+	bool mountedAny = false;
+	for (const auto &spec : kArchiveSpecs) {
+		XFileArchive *archive = new XFileArchive();
+		if (!archive->open(spec.indexPath, spec.dataPath)) {
+			delete archive;
+			continue;
+		}
+
+		mountArchive(spec.archiveName, archive, spec.priority, true);
+		mountedAny = true;
+	}
+
+	return mountedAny;
+}
+
 bool ResourceManager::hasFile(const Common::String &path) const {
 	const Common::String normalized = normalizeResourcePath(path);
 	return !normalized.empty() && _search.hasFile(Common::Path(normalized, '/'));
diff --git a/engines/harvester/resources.h b/engines/harvester/resources.h
index d24ab43b01c..dc79c4735e6 100644
--- a/engines/harvester/resources.h
+++ b/engines/harvester/resources.h
@@ -27,12 +27,15 @@
 
 namespace Harvester {
 
+Common::String normalizeHarvesterResourcePath(const Common::String &path);
+
 class ResourceManager {
 public:
 	ResourceManager();
 	~ResourceManager();
 
 	void reset();
+	bool mountStartupArchives();
 
 	bool hasFile(const Common::String &path) const;
 	Common::SeekableReadStream *openFile(const Common::String &path) const;
diff --git a/engines/harvester/xfile_archive.cpp b/engines/harvester/xfile_archive.cpp
new file mode 100644
index 00000000000..bc2f086df44
--- /dev/null
+++ b/engines/harvester/xfile_archive.cpp
@@ -0,0 +1,175 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/xfile_archive.h"
+
+#include "common/file.h"
+#include "common/memstream.h"
+#include "common/ptr.h"
+#include "common/stream.h"
+#include "common/substream.h"
+#include "harvester/resources.h"
+
+namespace Harvester {
+
+namespace {
+
+static const uint32 kEntrySize = 0x94;
+static const uint32 kSignatureXfle = MKTAG('X', 'F', 'L', 'E');
+
+} // End of anonymous namespace
+
+XFileArchive::XFileArchive() : Common::Archive() {
+}
+
+XFileArchive::~XFileArchive() {
+	close();
+}
+
+bool XFileArchive::open(const Common::String &indexPath, const Common::String &dataPath) {
+	close();
+
+	Common::ScopedPtr<Common::SeekableReadStream> indexStream(SearchMan.createReadStreamForMember(Common::Path(indexPath, '/')));
+	if (!indexStream)
+		return false;
+
+	_dataPath = Common::Path(dataPath, '/');
+	if (!SearchMan.hasFile(_dataPath)) {
+		close();
+		return false;
+	}
+
+	const uint32 fileCount = indexStream->size() / kEntrySize;
+	for (uint32 i = 0; i < fileCount; ++i) {
+		if (indexStream->readUint32LE() != kSignatureXfle) {
+			close();
+			return false;
+		}
+
+		Common::String resourcePath;
+		for (uint32 j = 0; j < 0x80; ++j) {
+			const char c = indexStream->readByte();
+			if (c != '\0')
+				resourcePath += c;
+		}
+
+		Entry entry;
+		entry._archiveOffset = indexStream->readUint32LE();
+		entry._storedSize = indexStream->readUint32LE();
+		entry._packedFlag = indexStream->readUint32LE();
+		entry._unpackedSize = indexStream->readUint32LE();
+
+		const Common::String normalized = normalizeHarvesterResourcePath(resourcePath);
+		if (!normalized.empty())
+			_entries[Common::Path(normalized, '/')] = entry;
+	}
+
+	return !_entries.empty();
+}
+
+void XFileArchive::close() {
+	_dataPath.clear();
+	_entries.clear();
+}
+
+bool XFileArchive::hasFile(const Common::Path &path) const {
+	return _entries.contains(path);
+}
+
+int XFileArchive::listMembers(Common::ArchiveMemberList &list) const {
+	for (const auto &entry : _entries)
+		list.push_back(getMember(entry._key));
+
+	return _entries.size();
+}
+
+const Common::ArchiveMemberPtr XFileArchive::getMember(const Common::Path &path) const {
+	if (!hasFile(path))
+		return Common::ArchiveMemberPtr();
+
+	return Common::ArchiveMemberPtr(new Common::GenericArchiveMember(path, *this));
+}
+
+Common::SeekableReadStream *XFileArchive::createReadStreamForMember(const Common::Path &path) const {
+	if (!_entries.contains(path))
+		return nullptr;
+
+	const Entry &entry = _entries[path];
+	if (entry._packedFlag == 0)
+		return openStoredEntry(entry);
+
+	return openPackedEntry(entry);
+}
+
+Common::SeekableReadStream *XFileArchive::openStoredEntry(const Entry &entry) const {
+	Common::File *dataFile = new Common::File();
+	if (!dataFile->open(_dataPath)) {
+		delete dataFile;
+		return nullptr;
+	}
+
+	return new Common::SeekableSubReadStream(dataFile, entry._archiveOffset, entry._archiveOffset + entry._storedSize, DisposeAfterUse::YES);
+}
+
+Common::SeekableReadStream *XFileArchive::openPackedEntry(const Entry &entry) const {
+	Common::File dataFile;
+	if (!dataFile.open(_dataPath))
+		return nullptr;
+
+	dataFile.seek(entry._archiveOffset);
+
+	Common::Array<byte> compressed;
+	compressed.resize(entry._storedSize);
+	if (!compressed.empty())
+		dataFile.read(compressed.data(), compressed.size());
+
+	byte *output = (byte *)malloc(entry._unpackedSize);
+	if (!output)
+		return nullptr;
+
+	uint32 src = 0;
+	uint32 dst = 0;
+	while (src < compressed.size() && dst < entry._unpackedSize) {
+		const byte control = compressed[src++];
+		if (control < 0x81) {
+			const uint32 literalCount = MIN<uint32>(control, MIN<uint32>(compressed.size() - src, entry._unpackedSize - dst));
+			for (uint32 i = 0; i < literalCount; ++i)
+				output[dst++] = compressed[src++];
+		} else {
+			if (src >= compressed.size())
+				break;
+
+			const byte value = compressed[src++];
+			const uint32 repeatCount = MIN<uint32>(control - 0x80, entry._unpackedSize - dst);
+			memset(output + dst, value, repeatCount);
+			dst += repeatCount;
+		}
+	}
+
+	if (dst != entry._unpackedSize) {
+		free(output);
+		return nullptr;
+	}
+
+	return new Common::MemoryReadStream(output, entry._unpackedSize, DisposeAfterUse::YES);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/xfile_archive.h b/engines/harvester/xfile_archive.h
new file mode 100644
index 00000000000..4c231586288
--- /dev/null
+++ b/engines/harvester/xfile_archive.h
@@ -0,0 +1,61 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_XFILE_ARCHIVE_H
+#define HARVESTER_XFILE_ARCHIVE_H
+
+#include "common/archive.h"
+#include "common/hashmap.h"
+
+namespace Harvester {
+
+class XFileArchive : public Common::Archive {
+public:
+	XFileArchive();
+	~XFileArchive() override;
+
+	bool open(const Common::String &indexPath, const Common::String &dataPath);
+	void close();
+
+	bool hasFile(const Common::Path &path) const override;
+	int listMembers(Common::ArchiveMemberList &list) const override;
+	const Common::ArchiveMemberPtr getMember(const Common::Path &path) const override;
+	Common::SeekableReadStream *createReadStreamForMember(const Common::Path &path) const override;
+
+private:
+	struct Entry {
+		uint32 _archiveOffset = 0;
+		uint32 _storedSize = 0;
+		uint32 _packedFlag = 0;
+		uint32 _unpackedSize = 0;
+	};
+
+	Common::SeekableReadStream *openStoredEntry(const Entry &entry) const;
+	Common::SeekableReadStream *openPackedEntry(const Entry &entry) const;
+
+	Common::Path _dataPath;
+	typedef Common::HashMap<Common::Path, Entry, Common::Path::IgnoreCase_Hash, Common::Path::IgnoreCase_EqualTo> EntryMap;
+	EntryMap _entries;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_XFILE_ARCHIVE_H


Commit: f9b299cc06c6250217fe72a39f6a15d2c5e059d7
    https://github.com/scummvm/scummvm/commit/f9b299cc06c6250217fe72a39f6a15d2c5e059d7
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:30+02:00

Commit Message:
HARVESTER: Load startup town script

Read CONFIG.INI through the new resource manager and resolve the startup
town script before the engine enters the placeholder loop.

The loaded script is deobfuscated with the original 0xAA XOR pass so
later startup work can consume the decoded data directly.

Changed paths:
  A engines/harvester/startup_script.cpp
  A engines/harvester/startup_script.h
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/module.mk


diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 557a0e5985a..97a2fe30252 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -30,6 +30,7 @@
 #include "harvester/console.h"
 #include "harvester/detection.h"
 #include "harvester/resources.h"
+#include "harvester/startup_script.h"
 
 namespace Harvester {
 
@@ -41,6 +42,7 @@ HarvesterEngine::HarvesterEngine(OSystem *syst, const ADGameDescription *gameDes
 }
 
 HarvesterEngine::~HarvesterEngine() {
+	delete _startupScript;
 	delete _resources;
 	delete _screen;
 	g_engine = nullptr;
@@ -53,6 +55,9 @@ Common::String HarvesterEngine::getGameId() const {
 Common::Error HarvesterEngine::run() {
 	_resources = new ResourceManager();
 	_resources->mountStartupArchives();
+	_startupScript = new StartupScript();
+	if (!_startupScript->load(*_resources))
+		return Common::kReadingFailed;
 
 	// Initialize 320x200 paletted graphics mode
 	initGraphics(320, 200);
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 24e90193464..6191aab8af0 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -33,6 +33,7 @@ struct ADGameDescription;
 namespace Harvester {
 
 class ResourceManager;
+class StartupScript;
 
 class HarvesterEngine : public Engine {
 public:
@@ -66,12 +67,14 @@ public:
 	Common::Error loadGameStream(Common::SeekableReadStream *stream) override;
 
 	ResourceManager *getResources() const { return _resources; }
+	StartupScript *getStartupScript() const { return _startupScript; }
 
 private:
 	const ADGameDescription *const _gameDescription;
 	Common::RandomSource _randomSource;
 	Graphics::Screen *_screen = nullptr;
 	ResourceManager *_resources = nullptr;
+	StartupScript *_startupScript = nullptr;
 };
 
 extern HarvesterEngine *g_engine;
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index c1f6d1cfda3..b903d168652 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -5,6 +5,7 @@ MODULE_OBJS = \
 	console.o \
 	metaengine.o \
 	resources.o \
+	startup_script.o \
 	xfile_archive.o
 
 # This module can be built as a plugin
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
new file mode 100644
index 00000000000..e90b0d69b44
--- /dev/null
+++ b/engines/harvester/startup_script.cpp
@@ -0,0 +1,86 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/startup_script.h"
+
+#include "common/debug.h"
+#include "common/formats/ini-file.h"
+#include "common/memstream.h"
+#include "harvester/resources.h"
+
+namespace Harvester {
+
+static const char *const kStartupConfigPath = "CONFIG.INI";
+static const char *const kConfigSectionName = "harvester";
+static const char *const kDefaultTownScript = "HARVEST.SCR";
+static const byte kTownScriptXorKey = 0xaa;
+
+bool StartupScript::load(ResourceManager &resources) {
+	_path = kDefaultTownScript;
+	_data.clear();
+
+	loadConfig(resources);
+
+	if (!resources.loadFile(_path, _data)) {
+		warning("Harvester: unable to load town script '%s'", _path.c_str());
+		return false;
+	}
+
+	decode();
+	debug(1, "Harvester: loaded startup script '%s' (%u bytes)", _path.c_str(), (uint)_data.size());
+	return true;
+}
+
+bool StartupScript::loadConfig(ResourceManager &resources) {
+	Common::Array<byte> configData;
+	if (!resources.loadFile(kStartupConfigPath, configData)) {
+		warning("Harvester: unable to load startup config '%s', using defaults", kStartupConfigPath);
+		return false;
+	}
+
+	Common::MemoryReadStream stream(configData.data(), configData.size());
+	Common::INIFile config;
+	config.setDefaultSectionName(kConfigSectionName);
+	config.requireKeyValueDelimiter();
+	config.suppressValuelessLineWarning();
+
+	if (!config.loadFromStream(stream)) {
+		warning("Harvester: unable to parse startup config '%s', using defaults", kStartupConfigPath);
+		return false;
+	}
+
+	Common::String townPath;
+	if (config.getKey("TOWN", kConfigSectionName, townPath))
+		_path = resources.normalizeResourcePath(townPath);
+
+	return true;
+}
+
+void StartupScript::decode() {
+	for (byte &value : _data) {
+		if (value == '\r' || value == '\n')
+			continue;
+
+		value ^= kTownScriptXorKey;
+	}
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
new file mode 100644
index 00000000000..83a27133918
--- /dev/null
+++ b/engines/harvester/startup_script.h
@@ -0,0 +1,49 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_STARTUP_SCRIPT_H
+#define HARVESTER_STARTUP_SCRIPT_H
+
+#include "common/array.h"
+#include "common/str.h"
+
+namespace Harvester {
+
+class ResourceManager;
+
+class StartupScript {
+public:
+	bool load(ResourceManager &resources);
+
+	const Common::String &getPath() const { return _path; }
+	const Common::Array<byte> &getData() const { return _data; }
+
+private:
+	bool loadConfig(ResourceManager &resources);
+	void decode();
+
+	Common::String _path;
+	Common::Array<byte> _data;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_STARTUP_SCRIPT_H


Commit: 96ba4494df304d815a8db16795ffb301842b2325
    https://github.com/scummvm/scummvm/commit/96ba4494df304d815a8db16795ffb301842b2325
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:30+02:00

Commit Message:
HARVESTER: Play startup FST intros

Add a Harvester FST player that uses the existing ScummVM screen,
palette, and mixer services while decoding the original 4x4 delta frame
format and 16-bit PCM audio.

Wire the verified startup movie order into the engine so boot now
reaches VIRGLOGO.FST, FVLOGO.FST, and INTROFIN.FST through the new
resource layer.

Changed paths:
  A engines/harvester/fst_player.cpp
  A engines/harvester/fst_player.h
    engines/harvester/harvester.cpp
    engines/harvester/module.mk


diff --git a/engines/harvester/fst_player.cpp b/engines/harvester/fst_player.cpp
new file mode 100644
index 00000000000..db84f578cfe
--- /dev/null
+++ b/engines/harvester/fst_player.cpp
@@ -0,0 +1,284 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/fst_player.h"
+
+#include "audio/audiostream.h"
+#include "audio/decoders/raw.h"
+#include "audio/mixer.h"
+#include "common/debug.h"
+#include "common/endian.h"
+#include "common/events.h"
+#include "common/ptr.h"
+#include "common/stream.h"
+#include "common/system.h"
+#include "common/textconsole.h"
+#include "graphics/paletteman.h"
+#include "harvester/harvester.h"
+#include "harvester/resources.h"
+
+namespace Harvester {
+
+namespace {
+
+struct FstHeader {
+	uint32 magic = 0;
+	uint32 width = 0;
+	uint32 height = 0;
+	uint32 maxFrameSize = 0;
+	uint32 frameCount = 0;
+	uint32 frameRate = 0;
+	uint32 sampleRate = 0;
+	uint32 bitsPerSample = 0;
+};
+
+struct FstFrameEntry {
+	uint32 videoSize = 0;
+	uint16 audioSize = 0;
+};
+
+class FstBitReader {
+public:
+	FstBitReader(const byte *data, uint32 bitCount) : _data(data), _bitCount(bitCount) {}
+
+	bool readBit() {
+		if (!_data || _bitIndex >= _bitCount)
+			return false;
+
+		const bool value = (_data[_bitIndex >> 3] & (0x80 >> (_bitIndex & 7))) != 0;
+		++_bitIndex;
+		return value;
+	}
+
+private:
+	const byte *_data = nullptr;
+	uint32 _bitCount = 0;
+	uint32 _bitIndex = 0;
+};
+
+static const uint32 kFstMagic = MKTAG('F', 'S', 'T', '2');
+
+static byte expand6BitColor(byte value) {
+	return (value * 255 + 31) / 63;
+}
+
+static void convertVgaPalette(const byte *source, byte *dest) {
+	for (uint i = 0; i < 256 * 3; ++i)
+		dest[i] = expand6BitColor(source[i]);
+}
+
+static bool pumpVideoEvents(HarvesterEngine &vm, bool &skipRequested) {
+	Common::Event event;
+	while (g_system->getEventManager()->pollEvent(event)) {
+		switch (event.type) {
+		case Common::EVENT_QUIT:
+		case Common::EVENT_RETURN_TO_LAUNCHER:
+			return false;
+		case Common::EVENT_KEYDOWN:
+		case Common::EVENT_LBUTTONDOWN:
+		case Common::EVENT_RBUTTONDOWN:
+			skipRequested = true;
+			break;
+		default:
+			break;
+		}
+	}
+
+	return !vm.shouldQuit();
+}
+
+static void decodeMaskBlock(byte *dest, int pitch, const byte *source) {
+	const byte color0 = source[0];
+	const byte color1 = source[1];
+	const uint16 mask = READ_LE_UINT16(source + 2);
+	const byte maskRows[4] = {
+		(byte)((mask >> 8) & 0xf0),
+		(byte)((mask >> 8) & 0x0f),
+		(byte)(mask & 0xf0),
+		(byte)(mask & 0x0f)
+	};
+
+	for (int y = 0; y < 4; ++y) {
+		byte *row = dest + y * pitch;
+		const byte rowMask = maskRows[y];
+		for (int x = 0; x < 4; ++x) {
+			const byte bit = (y & 1) ? (1 << x) : (0x10 << x);
+			row[x] = (rowMask & bit) ? color1 : color0;
+		}
+	}
+}
+
+static bool decodeFrame(const FstHeader &header, const Common::Array<byte> &frameData, Common::Array<byte> &pixels, byte *palette) {
+	if (frameData.size() < 2)
+		return false;
+
+	const uint16 bitCount = READ_LE_UINT16(frameData.data());
+	const uint32 bitstreamSize = (bitCount >> 3) + 1;
+	if (frameData.size() < 2 + bitstreamSize)
+		return false;
+
+	FstBitReader bitReader(frameData.data() + 2, bitCount);
+	uint32 payloadOffset = 2 + bitstreamSize;
+
+	if (bitReader.readBit()) {
+		if (frameData.size() < payloadOffset + 256 * 3)
+			return false;
+
+		convertVgaPalette(frameData.data() + payloadOffset, palette);
+		payloadOffset += 256 * 3;
+	}
+
+	const int width = (int)header.width;
+	const int height = (int)header.height;
+	const int blocksX = width / 4;
+	const int blocksY = height / 4;
+
+	for (int blockY = 0; blockY < blocksY; ++blockY) {
+		for (int blockX = 0; blockX < blocksX; ++blockX) {
+			if (!bitReader.readBit())
+				continue;
+
+			const uint32 blockOffset = blockY * 4 * width + blockX * 4;
+			byte *dest = pixels.data() + blockOffset;
+
+			if (bitReader.readBit()) {
+				if (frameData.size() < payloadOffset + 4)
+					return false;
+
+				decodeMaskBlock(dest, width, frameData.data() + payloadOffset);
+				payloadOffset += 4;
+			} else {
+				if (frameData.size() < payloadOffset + 16)
+					return false;
+
+				for (int y = 0; y < 4; ++y)
+					memcpy(dest + y * width, frameData.data() + payloadOffset + y * 4, 4);
+				payloadOffset += 16;
+			}
+		}
+	}
+
+	return true;
+}
+
+} // End of anonymous namespace
+
+FstPlayer::FstPlayer(HarvesterEngine &vm) : _vm(vm) {}
+
+bool FstPlayer::play(const Common::String &path) {
+	Common::ScopedPtr<Common::SeekableReadStream> stream(_vm.getResources()->openFile(path));
+	if (!stream) {
+		warning("Harvester: unable to open FST '%s'", path.c_str());
+		return false;
+	}
+
+	FstHeader header;
+	header.magic = stream->readUint32LE();
+	header.width = stream->readUint32LE();
+	header.height = stream->readUint32LE();
+	header.maxFrameSize = stream->readUint32LE();
+	header.frameCount = stream->readUint32LE();
+	header.frameRate = stream->readUint32LE();
+	header.sampleRate = stream->readUint32LE();
+	header.bitsPerSample = stream->readUint32LE();
+
+	if (header.magic != kFstMagic || header.width == 0 || header.height == 0 || header.frameCount == 0 || header.frameRate == 0) {
+		warning("Harvester: invalid FST header in '%s'", path.c_str());
+		return false;
+	}
+
+	Common::Array<FstFrameEntry> frames;
+	frames.resize(header.frameCount);
+	for (uint32 i = 0; i < header.frameCount; ++i) {
+		frames[i].videoSize = stream->readUint32LE();
+		frames[i].audioSize = stream->readUint16LE();
+	}
+
+	Common::Array<byte> pixels;
+	pixels.resize(header.width * header.height);
+	Common::fill(pixels.begin(), pixels.end(), 0);
+
+	byte palette[256 * 3] = { 0 };
+	g_system->getPaletteManager()->setPalette(palette, 0, 256);
+
+	Audio::SoundHandle audioHandle;
+	Audio::QueuingAudioStream *audioStream = nullptr;
+	const bool hasPcmAudio = header.sampleRate != 0 && header.bitsPerSample == 16;
+	if (hasPcmAudio)
+		audioStream = Audio::makeQueuingAudioStream(header.sampleRate, false);
+
+	bool audioStarted = false;
+	bool skipRequested = false;
+	uint32 nextFrameTime = g_system->getMillis();
+
+	for (uint32 frameIndex = 0; frameIndex < header.frameCount; ++frameIndex) {
+		const uint32 totalFrameSize = frames[frameIndex].videoSize + frames[frameIndex].audioSize;
+		Common::Array<byte> frameData;
+		frameData.resize(totalFrameSize);
+		if (stream->read(frameData.data(), totalFrameSize) != totalFrameSize) {
+			warning("Harvester: short read while decoding '%s' frame %u", path.c_str(), frameIndex);
+			break;
+		}
+
+		if (audioStream && frames[frameIndex].audioSize != 0) {
+			byte *audioBuffer = (byte *)malloc(frames[frameIndex].audioSize);
+			if (!audioBuffer)
+				error("Harvester: unable to allocate audio buffer");
+
+			memcpy(audioBuffer, frameData.data() + frames[frameIndex].videoSize, frames[frameIndex].audioSize);
+			audioStream->queueBuffer(audioBuffer, frames[frameIndex].audioSize, DisposeAfterUse::YES,
+				Audio::FLAG_16BITS | Audio::FLAG_LITTLE_ENDIAN);
+
+			if (!audioStarted) {
+				g_system->getMixer()->playStream(Audio::Mixer::kSFXSoundType, &audioHandle, audioStream, -1,
+					Audio::Mixer::kMaxChannelVolume, 0, DisposeAfterUse::NO);
+				audioStarted = true;
+			}
+		}
+
+		if (!decodeFrame(header, frameData, pixels, palette)) {
+			warning("Harvester: unable to decode '%s' frame %u", path.c_str(), frameIndex);
+			break;
+		}
+
+		g_system->getPaletteManager()->setPalette(palette, 0, 256);
+		g_system->copyRectToScreen(pixels.data(), header.width, 0, 0, header.width, header.height);
+		g_system->updateScreen();
+
+		nextFrameTime += 1000 / header.frameRate;
+		while (!skipRequested && g_system->getMillis() < nextFrameTime) {
+			if (!pumpVideoEvents(_vm, skipRequested))
+				break;
+			g_system->delayMillis(1);
+		}
+
+		if (!pumpVideoEvents(_vm, skipRequested) || skipRequested)
+			break;
+	}
+
+	if (audioStarted)
+		g_system->getMixer()->stopHandle(audioHandle);
+	delete audioStream;
+
+	return !_vm.shouldQuit();
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/fst_player.h b/engines/harvester/fst_player.h
new file mode 100644
index 00000000000..4cdd9575f6b
--- /dev/null
+++ b/engines/harvester/fst_player.h
@@ -0,0 +1,43 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_FST_PLAYER_H
+#define HARVESTER_FST_PLAYER_H
+
+#include "common/str.h"
+
+namespace Harvester {
+
+class HarvesterEngine;
+
+class FstPlayer {
+public:
+	explicit FstPlayer(HarvesterEngine &vm);
+
+	bool play(const Common::String &path);
+
+private:
+	HarvesterEngine &_vm;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_FST_PLAYER_H
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 97a2fe30252..8193bce792f 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -29,6 +29,7 @@
 #include "graphics/paletteman.h"
 #include "harvester/console.h"
 #include "harvester/detection.h"
+#include "harvester/fst_player.h"
 #include "harvester/resources.h"
 #include "harvester/startup_script.h"
 
@@ -53,6 +54,12 @@ Common::String HarvesterEngine::getGameId() const {
 }
 
 Common::Error HarvesterEngine::run() {
+	static const char *const kIntroPaths[] = {
+		"GRAPHIC/FST/VIRGLOGO.FST",
+		"GRAPHIC/FST/FVLOGO.FST",
+		"GRAPHIC/FST/INTROFIN.FST"
+	};
+
 	_resources = new ResourceManager();
 	_resources->mountStartupArchives();
 	_startupScript = new StartupScript();
@@ -66,6 +73,12 @@ Common::Error HarvesterEngine::run() {
 	// Set the engine's debugger console
 	setDebugger(new Console());
 
+	FstPlayer fstPlayer(*this);
+	for (const char *path : kIntroPaths) {
+		if (!fstPlayer.play(path))
+			return Common::kReadingFailed;
+	}
+
 	// If a savegame was selected from the launcher, load it
 	int saveSlot = ConfMan.getInt("save_slot");
 	if (saveSlot != -1)
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index b903d168652..5d6b93fc21a 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -3,6 +3,7 @@ MODULE := engines/harvester
 MODULE_OBJS = \
 	harvester.o \
 	console.o \
+	fst_player.o \
 	metaengine.o \
 	resources.o \
 	startup_script.o \


Commit: 709014472208c2c36cfe85aa87476d8844224ad6
    https://github.com/scummvm/scummvm/commit/709014472208c2c36cfe85aa87476d8844224ad6
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:30+02:00

Commit Message:
HARVESTER: Load startup archive art

Add loaders for the archive-backed WAIT palette, BM bitmaps, and ABM
animation frames using the verified Harvester resource layouts.

After the intro movies finish, load the initial wait/menu art set and
draw the first WAIT.ABM frame so startup reaches the post-video asset
bootstrap instead of falling straight back to the placeholder loop.

Changed paths:
  A engines/harvester/startup_art.cpp
  A engines/harvester/startup_art.h
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/module.mk


diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 8193bce792f..348a1c1e44f 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -31,6 +31,7 @@
 #include "harvester/detection.h"
 #include "harvester/fst_player.h"
 #include "harvester/resources.h"
+#include "harvester/startup_art.h"
 #include "harvester/startup_script.h"
 
 namespace Harvester {
@@ -43,6 +44,7 @@ HarvesterEngine::HarvesterEngine(OSystem *syst, const ADGameDescription *gameDes
 }
 
 HarvesterEngine::~HarvesterEngine() {
+	delete _startupArt;
 	delete _startupScript;
 	delete _resources;
 	delete _screen;
@@ -79,6 +81,11 @@ Common::Error HarvesterEngine::run() {
 			return Common::kReadingFailed;
 	}
 
+	_startupArt = new StartupArt();
+	if (!_startupArt->load(*_resources))
+		return Common::kReadingFailed;
+	_startupArt->drawWaitFrame();
+
 	// If a savegame was selected from the launcher, load it
 	int saveSlot = ConfMan.getInt("save_slot");
 	if (saveSlot != -1)
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 6191aab8af0..ca083d7bbf9 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -34,6 +34,7 @@ namespace Harvester {
 
 class ResourceManager;
 class StartupScript;
+class StartupArt;
 
 class HarvesterEngine : public Engine {
 public:
@@ -68,6 +69,7 @@ public:
 
 	ResourceManager *getResources() const { return _resources; }
 	StartupScript *getStartupScript() const { return _startupScript; }
+	StartupArt *getStartupArt() const { return _startupArt; }
 
 private:
 	const ADGameDescription *const _gameDescription;
@@ -75,6 +77,7 @@ private:
 	Graphics::Screen *_screen = nullptr;
 	ResourceManager *_resources = nullptr;
 	StartupScript *_startupScript = nullptr;
+	StartupArt *_startupArt = nullptr;
 };
 
 extern HarvesterEngine *g_engine;
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 5d6b93fc21a..df432e7a21c 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -6,6 +6,7 @@ MODULE_OBJS = \
 	fst_player.o \
 	metaengine.o \
 	resources.o \
+	startup_art.o \
 	startup_script.o \
 	xfile_archive.o
 
diff --git a/engines/harvester/startup_art.cpp b/engines/harvester/startup_art.cpp
new file mode 100644
index 00000000000..2a8dae09cf3
--- /dev/null
+++ b/engines/harvester/startup_art.cpp
@@ -0,0 +1,172 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/startup_art.h"
+
+#include "common/debug.h"
+#include "common/endian.h"
+#include "common/system.h"
+#include "graphics/paletteman.h"
+#include "harvester/resources.h"
+
+namespace Harvester {
+
+namespace {
+
+static byte expand6BitColor(byte value) {
+	return (value * 255 + 31) / 63;
+}
+
+} // End of anonymous namespace
+
+bool StartupArt::load(ResourceManager &resources) {
+	_waitFrames.clear();
+	_inventoryBitmap = IndexedBitmap();
+	_logoBitmap = IndexedBitmap();
+
+	return loadPalette(resources, "1:/GRAPHIC/PAL/WAIT.PAL", _waitPalette) &&
+	       loadAnimation(resources, "1:/GRAPHIC/OTHER/WAIT.ABM", _waitFrames) &&
+	       loadBitmap(resources, "1:/GRAPHIC/OTHER/INVENTRY.BM", _inventoryBitmap) &&
+	       loadBitmap(resources, "1:/GRAPHIC/OTHER/HARVLOGO.BM", _logoBitmap);
+}
+
+void StartupArt::drawWaitFrame() const {
+	if (_waitFrames.empty() || !_waitFrames[0].isValid())
+		return;
+
+	const AbmFrame &frame = _waitFrames[0];
+	const int x = MAX<int>(0, (320 - (int)frame.width) / 2);
+	const int y = MAX<int>(0, (200 - (int)frame.height) / 2);
+
+	g_system->getPaletteManager()->setPalette(_waitPalette, 0, 256);
+	g_system->copyRectToScreen(frame.pixels.data(), frame.width, x, y, frame.width, frame.height);
+	g_system->updateScreen();
+}
+
+bool StartupArt::loadPalette(ResourceManager &resources, const Common::String &path, byte *dest) const {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 256 * 3) {
+		warning("Harvester: unable to load palette '%s'", path.c_str());
+		return false;
+	}
+
+	for (uint i = 0; i < 256 * 3; ++i)
+		dest[i] = expand6BitColor(data[i]);
+
+	return true;
+}
+
+bool StartupArt::loadBitmap(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) const {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 12) {
+		warning("Harvester: unable to load bitmap '%s'", path.c_str());
+		return false;
+	}
+
+	bitmap.width = READ_LE_UINT32(data.data());
+	bitmap.height = READ_LE_UINT32(data.data() + 4);
+	const uint32 pixelCount = bitmap.width * bitmap.height;
+	if (bitmap.width == 0 || bitmap.height == 0 || data.size() < 12 + pixelCount) {
+		warning("Harvester: invalid bitmap '%s'", path.c_str());
+		return false;
+	}
+
+	bitmap.pixels.resize(pixelCount);
+	memcpy(bitmap.pixels.data(), data.data() + 12, pixelCount);
+	return true;
+}
+
+bool StartupArt::loadAnimation(ResourceManager &resources, const Common::String &path, Common::Array<AbmFrame> &frames) const {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 8) {
+		warning("Harvester: unable to load animation '%s'", path.c_str());
+		return false;
+	}
+
+	const uint32 frameCount = READ_LE_UINT32(data.data());
+	uint32 offset = 8;
+	frames.resize(frameCount);
+
+	for (uint32 i = 0; i < frameCount; ++i) {
+		if (data.size() < offset + 25) {
+			warning("Harvester: short ABM header in '%s'", path.c_str());
+			return false;
+		}
+
+		AbmFrame &frame = frames[i];
+		frame.xOffset = (int32)READ_LE_UINT32(data.data() + offset);
+		frame.yOffset = (int32)READ_LE_UINT32(data.data() + offset + 4);
+		frame.width = READ_LE_UINT32(data.data() + offset + 8);
+		frame.height = READ_LE_UINT32(data.data() + offset + 12);
+		const bool compressed = data[offset + 16] != 0;
+		const uint32 sourceSize = READ_LE_UINT32(data.data() + offset + 17);
+		const uint32 pixelCount = frame.width * frame.height;
+		const uint32 payloadOffset = offset + 25;
+
+		if (frame.width == 0 || frame.height == 0 || data.size() < payloadOffset + sourceSize) {
+			warning("Harvester: invalid ABM frame %u in '%s'", i, path.c_str());
+			return false;
+		}
+
+		frame.pixels.resize(pixelCount);
+		if (!decodeAnimationFrame(data.data() + payloadOffset, sourceSize, compressed, frame.pixels))
+			return false;
+
+		offset = payloadOffset + sourceSize;
+	}
+
+	debug(1, "Harvester: loaded %u WAIT.ABM frames", (uint)frames.size());
+	return true;
+}
+
+bool StartupArt::decodeAnimationFrame(const byte *source, uint32 sourceSize, bool compressed, Common::Array<byte> &dest) const {
+	if (!compressed) {
+		if (sourceSize < dest.size())
+			return false;
+
+		memcpy(dest.data(), source, dest.size());
+		return true;
+	}
+
+	uint32 srcOffset = 0;
+	uint32 dstOffset = 0;
+
+	while (srcOffset < sourceSize && dstOffset < dest.size()) {
+		const byte control = source[srcOffset++];
+		if ((control & 0x80) == 0) {
+			const uint32 literalCount = MIN<uint32>(control, MIN<uint32>(sourceSize - srcOffset, dest.size() - dstOffset));
+			memcpy(dest.data() + dstOffset, source + srcOffset, literalCount);
+			srcOffset += literalCount;
+			dstOffset += literalCount;
+		} else {
+			if (srcOffset >= sourceSize)
+				return false;
+
+			const uint32 repeatCount = MIN<uint32>(control & 0x7f, dest.size() - dstOffset);
+			memset(dest.data() + dstOffset, source[srcOffset++], repeatCount);
+			dstOffset += repeatCount;
+		}
+	}
+
+	return dstOffset == dest.size();
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/startup_art.h b/engines/harvester/startup_art.h
new file mode 100644
index 00000000000..b33ff3f32b5
--- /dev/null
+++ b/engines/harvester/startup_art.h
@@ -0,0 +1,70 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_STARTUP_ART_H
+#define HARVESTER_STARTUP_ART_H
+
+#include "common/array.h"
+
+namespace Harvester {
+
+class ResourceManager;
+
+struct IndexedBitmap {
+	uint32 width = 0;
+	uint32 height = 0;
+	Common::Array<byte> pixels;
+
+	bool isValid() const {
+		return width != 0 && height != 0 && pixels.size() >= width * height;
+	}
+};
+
+struct AbmFrame : IndexedBitmap {
+	int32 xOffset = 0;
+	int32 yOffset = 0;
+};
+
+class StartupArt {
+public:
+	bool load(ResourceManager &resources);
+	void drawWaitFrame() const;
+
+	const byte *getWaitPalette() const { return _waitPalette; }
+	const Common::Array<AbmFrame> &getWaitFrames() const { return _waitFrames; }
+	const IndexedBitmap &getInventoryBitmap() const { return _inventoryBitmap; }
+	const IndexedBitmap &getLogoBitmap() const { return _logoBitmap; }
+
+private:
+	bool loadPalette(ResourceManager &resources, const Common::String &path, byte *dest) const;
+	bool loadBitmap(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) const;
+	bool loadAnimation(ResourceManager &resources, const Common::String &path, Common::Array<AbmFrame> &frames) const;
+	bool decodeAnimationFrame(const byte *source, uint32 sourceSize, bool compressed, Common::Array<byte> &dest) const;
+
+	byte _waitPalette[256 * 3] = { 0 };
+	Common::Array<AbmFrame> _waitFrames;
+	IndexedBitmap _inventoryBitmap;
+	IndexedBitmap _logoBitmap;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_STARTUP_ART_H


Commit: 18d7fd160fa88b315ac33ce1dd620412db55da57
    https://github.com/scummvm/scummvm/commit/18d7fd160fa88b315ac33ce1dd620412db55da57
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:31+02:00

Commit Message:
HARVESTER: Load startup text resources

Add loaders for the XOR-obfuscated dialogue index and the six CFT fonts
observed during startup, preserving the decoded dialogue buffer, entry
table, font headers, and font atlases for later UI work.

Replace the temporary sample rendering loop with an idle wait on the
loaded startup screen so the engine now stops at the real post-intro
bootstrap state.

Changed paths:
  A engines/harvester/startup_text.cpp
  A engines/harvester/startup_text.h
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/module.mk


diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 348a1c1e44f..159cc15ed8f 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -33,6 +33,7 @@
 #include "harvester/resources.h"
 #include "harvester/startup_art.h"
 #include "harvester/startup_script.h"
+#include "harvester/startup_text.h"
 
 namespace Harvester {
 
@@ -44,6 +45,7 @@ HarvesterEngine::HarvesterEngine(OSystem *syst, const ADGameDescription *gameDes
 }
 
 HarvesterEngine::~HarvesterEngine() {
+	delete _startupText;
 	delete _startupArt;
 	delete _startupScript;
 	delete _resources;
@@ -86,20 +88,16 @@ Common::Error HarvesterEngine::run() {
 		return Common::kReadingFailed;
 	_startupArt->drawWaitFrame();
 
+	_startupText = new StartupText();
+	if (!_startupText->load(*_resources))
+		return Common::kReadingFailed;
+
 	// If a savegame was selected from the launcher, load it
 	int saveSlot = ConfMan.getInt("save_slot");
 	if (saveSlot != -1)
 		(void)loadGameState(saveSlot);
 
-	// Draw a series of boxes on screen as a sample
-	for (int i = 0; i < 100; ++i)
-		_screen->frameRect(Common::Rect(i, i, 320 - i, 200 - i), i);
-	_screen->update();
-
-	// Simple event handling loop
-	byte pal[256 * 3] = { 0 };
 	Common::Event e;
-	int offset = 0;
 
 	Graphics::FrameLimiter limiter(g_system, 60);
 	while (!shouldQuit()) {
@@ -113,15 +111,7 @@ Common::Error HarvesterEngine::run() {
 			}
 		}
 
-		// Cycle through a simple palette
-		++offset;
-		for (int i = 0; i < 256; ++i)
-			pal[i * 3 + 1] = (i + offset) % 256;
-		g_system->getPaletteManager()->setPalette(pal, 0, 256);
-		// Delay for a bit. All events loops should have a delay
-		// to prevent the system being unduly loaded
 		limiter.delayBeforeSwap();
-		_screen->update();
 		limiter.startFrame();
 	}
 
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index ca083d7bbf9..df7611d132f 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -35,6 +35,7 @@ namespace Harvester {
 class ResourceManager;
 class StartupScript;
 class StartupArt;
+class StartupText;
 
 class HarvesterEngine : public Engine {
 public:
@@ -70,6 +71,7 @@ public:
 	ResourceManager *getResources() const { return _resources; }
 	StartupScript *getStartupScript() const { return _startupScript; }
 	StartupArt *getStartupArt() const { return _startupArt; }
+	StartupText *getStartupText() const { return _startupText; }
 
 private:
 	const ADGameDescription *const _gameDescription;
@@ -78,6 +80,7 @@ private:
 	ResourceManager *_resources = nullptr;
 	StartupScript *_startupScript = nullptr;
 	StartupArt *_startupArt = nullptr;
+	StartupText *_startupText = nullptr;
 };
 
 extern HarvesterEngine *g_engine;
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index df432e7a21c..f7709916357 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -8,6 +8,7 @@ MODULE_OBJS = \
 	resources.o \
 	startup_art.o \
 	startup_script.o \
+	startup_text.o \
 	xfile_archive.o
 
 # This module can be built as a plugin
diff --git a/engines/harvester/startup_text.cpp b/engines/harvester/startup_text.cpp
new file mode 100644
index 00000000000..ed7d8d2e525
--- /dev/null
+++ b/engines/harvester/startup_text.cpp
@@ -0,0 +1,150 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/startup_text.h"
+
+#include "common/debug.h"
+#include "common/endian.h"
+#include "harvester/resources.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kStartupFontPaths[] = {
+	"GRAPHIC/FONT/HARVFONT.CFT",
+	"GRAPHIC/FONT/HARVFNT2.CFT",
+	"GRAPHIC/FONT/TEXTFONT.CFT",
+	"GRAPHIC/FONT/TEXTFNT2.CFT",
+	"GRAPHIC/FONT/MEDFONT1.CFT",
+	"GRAPHIC/FONT/MEDFONT2.CFT"
+};
+
+static const uint32 kCftHeaderSize = 0x448;
+static const uint32 kCftBitmapHeaderSize = kCftHeaderSize + 12;
+
+static bool isDialogueDelimiter(byte value) {
+	return value == 0 || value == '\n' || value == '\r' || value == '\f';
+}
+
+} // End of anonymous namespace
+
+bool StartupText::load(ResourceManager &resources) {
+	_dialogueData.clear();
+	_dialogueEntries.clear();
+	_fonts.clear();
+
+	if (!loadDialogueIndex(resources))
+		return false;
+
+	for (const char *path : kStartupFontPaths) {
+		if (!loadFont(resources, path))
+			return false;
+	}
+
+	debug(1, "Harvester: loaded %u dialogue entries and %u startup fonts",
+		(uint)_dialogueEntries.size(), (uint)_fonts.size());
+	return true;
+}
+
+bool StartupText::loadDialogueIndex(ResourceManager &resources) {
+	if (!resources.loadFile("DIALOGUE.IDX", _dialogueData) || _dialogueData.empty()) {
+		warning("Harvester: unable to load DIALOGUE.IDX");
+		return false;
+	}
+
+	decodeXorText(_dialogueData);
+
+	uint32 cursor = 0;
+	while (cursor < _dialogueData.size()) {
+		while (cursor < _dialogueData.size() && isDialogueDelimiter(_dialogueData[cursor]))
+			++cursor;
+		if (cursor >= _dialogueData.size())
+			break;
+
+		const uint32 wavIdStart = cursor;
+		while (cursor < _dialogueData.size() && !isDialogueDelimiter(_dialogueData[cursor]))
+			++cursor;
+
+		const Common::String wavIdString((const char *)_dialogueData.data() + wavIdStart, cursor - wavIdStart);
+		const int wavId = atoi(wavIdString.c_str());
+
+		while (cursor < _dialogueData.size() && isDialogueDelimiter(_dialogueData[cursor]))
+			++cursor;
+		if (cursor >= _dialogueData.size())
+			break;
+
+		DialogueIndexEntry entry;
+		entry.wavId = wavId;
+		entry.textOffset = cursor;
+		while (cursor < _dialogueData.size() && !isDialogueDelimiter(_dialogueData[cursor]))
+			++cursor;
+		entry.textLength = MIN<uint32>(cursor - entry.textOffset, 0x19a);
+
+		if (entry.wavId > 0 && entry.textLength != 0)
+			_dialogueEntries.push_back(entry);
+	}
+
+	return !_dialogueEntries.empty();
+}
+
+bool StartupText::loadFont(ResourceManager &resources, const Common::String &path) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < kCftBitmapHeaderSize) {
+		warning("Harvester: unable to load font '%s'", path.c_str());
+		return false;
+	}
+
+	CftFontResource font;
+	font.path = path;
+	font.header.resize(kCftHeaderSize);
+	memcpy(font.header.data(), data.data(), kCftHeaderSize);
+
+	for (uint i = 0; i < 0x40 && i < data.size(); ++i) {
+		if (data[i] == 0)
+			break;
+		font.name += (char)data[i];
+	}
+
+	font.atlasWidth = READ_LE_UINT32(data.data() + kCftHeaderSize);
+	font.atlasHeight = READ_LE_UINT32(data.data() + kCftHeaderSize + 4);
+	const uint32 pixelCount = font.atlasWidth * font.atlasHeight;
+	if (font.atlasWidth == 0 || font.atlasHeight == 0 || data.size() < kCftBitmapHeaderSize + pixelCount) {
+		warning("Harvester: invalid font '%s'", path.c_str());
+		return false;
+	}
+
+	font.atlasPixels.resize(pixelCount);
+	memcpy(font.atlasPixels.data(), data.data() + kCftBitmapHeaderSize, pixelCount);
+
+	_fonts.push_back(font);
+	return true;
+}
+
+void StartupText::decodeXorText(Common::Array<byte> &data) const {
+	for (byte &value : data) {
+		if (value == '\n' || value == '\r')
+			continue;
+		value ^= 0xaa;
+	}
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/startup_text.h b/engines/harvester/startup_text.h
new file mode 100644
index 00000000000..a3d467c4da1
--- /dev/null
+++ b/engines/harvester/startup_text.h
@@ -0,0 +1,67 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_STARTUP_TEXT_H
+#define HARVESTER_STARTUP_TEXT_H
+
+#include "common/array.h"
+#include "common/str.h"
+
+namespace Harvester {
+
+class ResourceManager;
+
+struct DialogueIndexEntry {
+	int wavId = 0;
+	uint32 textOffset = 0;
+	uint32 textLength = 0;
+};
+
+struct CftFontResource {
+	Common::String path;
+	Common::String name;
+	Common::Array<byte> header;
+	uint32 atlasWidth = 0;
+	uint32 atlasHeight = 0;
+	Common::Array<byte> atlasPixels;
+};
+
+class StartupText {
+public:
+	bool load(ResourceManager &resources);
+
+	const Common::Array<byte> &getDialogueData() const { return _dialogueData; }
+	const Common::Array<DialogueIndexEntry> &getDialogueEntries() const { return _dialogueEntries; }
+	const Common::Array<CftFontResource> &getFonts() const { return _fonts; }
+
+private:
+	bool loadDialogueIndex(ResourceManager &resources);
+	bool loadFont(ResourceManager &resources, const Common::String &path);
+	void decodeXorText(Common::Array<byte> &data) const;
+
+	Common::Array<byte> _dialogueData;
+	Common::Array<DialogueIndexEntry> _dialogueEntries;
+	Common::Array<CftFontResource> _fonts;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_STARTUP_TEXT_H


Commit: d94865a3bd32adb007f0f54c3e472ac0ac2410fa
    https://github.com/scummvm/scummvm/commit/d94865a3bd32adb007f0f54c3e472ac0ac2410fa
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:31+02:00

Commit Message:
HARVESTER: Add resource debug logging

Log resource mounting and file-opening details.

Changed paths:
    engines/harvester/resources.cpp
    engines/harvester/xfile_archive.cpp


diff --git a/engines/harvester/resources.cpp b/engines/harvester/resources.cpp
index e690a02a4cf..a208e714972 100644
--- a/engines/harvester/resources.cpp
+++ b/engines/harvester/resources.cpp
@@ -21,7 +21,9 @@
 
 #include "harvester/resources.h"
 
+#include "common/debug.h"
 #include "common/stream.h"
+#include "harvester/detection.h"
 #include "harvester/xfile_archive.h"
 
 namespace Harvester {
@@ -79,11 +81,13 @@ bool ResourceManager::mountStartupArchives() {
 	for (const auto &spec : kArchiveSpecs) {
 		XFileArchive *archive = new XFileArchive();
 		if (!archive->open(spec.indexPath, spec.dataPath)) {
+			debugC(1, kDebugResources, "Harvester: failed to mount %s + %s", spec.indexPath, spec.dataPath);
 			delete archive;
 			continue;
 		}
 
 		mountArchive(spec.archiveName, archive, spec.priority, true);
+		debugC(1, kDebugResources, "Harvester: mounted %s + %s", spec.indexPath, spec.dataPath);
 		mountedAny = true;
 	}
 
@@ -100,7 +104,9 @@ Common::SeekableReadStream *ResourceManager::openFile(const Common::String &path
 	if (normalized.empty())
 		return nullptr;
 
-	return _search.createReadStreamForMember(Common::Path(normalized, '/'));
+	Common::SeekableReadStream *stream = _search.createReadStreamForMember(Common::Path(normalized, '/'));
+	debugC(3, kDebugResources, "Harvester: openFile('%s' -> '%s') %s", path.c_str(), normalized.c_str(), stream ? "hit" : "miss");
+	return stream;
 }
 
 bool ResourceManager::loadFile(const Common::String &path, Common::Array<byte> &data) const {
diff --git a/engines/harvester/xfile_archive.cpp b/engines/harvester/xfile_archive.cpp
index bc2f086df44..d2cf5a99f4b 100644
--- a/engines/harvester/xfile_archive.cpp
+++ b/engines/harvester/xfile_archive.cpp
@@ -21,11 +21,13 @@
 
 #include "harvester/xfile_archive.h"
 
+#include "common/debug.h"
 #include "common/file.h"
 #include "common/memstream.h"
 #include "common/ptr.h"
 #include "common/stream.h"
 #include "common/substream.h"
+#include "harvester/detection.h"
 #include "harvester/resources.h"
 
 namespace Harvester {
@@ -48,28 +50,34 @@ bool XFileArchive::open(const Common::String &indexPath, const Common::String &d
 	close();
 
 	Common::ScopedPtr<Common::SeekableReadStream> indexStream(SearchMan.createReadStreamForMember(Common::Path(indexPath, '/')));
-	if (!indexStream)
+	if (!indexStream) {
+		debugC(1, kDebugResources, "Harvester: missing archive index %s", indexPath.c_str());
 		return false;
+	}
 
 	_dataPath = Common::Path(dataPath, '/');
 	if (!SearchMan.hasFile(_dataPath)) {
+		debugC(1, kDebugResources, "Harvester: missing archive data %s", dataPath.c_str());
 		close();
 		return false;
 	}
 
 	const uint32 fileCount = indexStream->size() / kEntrySize;
 	for (uint32 i = 0; i < fileCount; ++i) {
-		if (indexStream->readUint32LE() != kSignatureXfle) {
+		if (indexStream->readUint32BE() != kSignatureXfle) {
+			debugC(1, kDebugResources, "Harvester: invalid XFLE signature in %s at entry %u", indexPath.c_str(), i);
 			close();
 			return false;
 		}
 
-		Common::String resourcePath;
-		for (uint32 j = 0; j < 0x80; ++j) {
-			const char c = indexStream->readByte();
-			if (c != '\0')
-				resourcePath += c;
-		}
+		char resourcePathBuffer[0x80];
+		indexStream->read(resourcePathBuffer, sizeof(resourcePathBuffer));
+
+		uint32 resourcePathLength = 0;
+		while (resourcePathLength < sizeof(resourcePathBuffer) && resourcePathBuffer[resourcePathLength] != '\0')
+			++resourcePathLength;
+
+		Common::String resourcePath(resourcePathBuffer, resourcePathLength);
 
 		Entry entry;
 		entry._archiveOffset = indexStream->readUint32LE();
@@ -82,6 +90,8 @@ bool XFileArchive::open(const Common::String &indexPath, const Common::String &d
 			_entries[Common::Path(normalized, '/')] = entry;
 	}
 
+	debugC(1, kDebugResources, "Harvester: indexed %u members from %s", (uint)_entries.size(), indexPath.c_str());
+
 	return !_entries.empty();
 }
 


Commit: bf097b88251905183236e0721f0929efa69a14cb
    https://github.com/scummvm/scummvm/commit/bf097b88251905183236e0721f0929efa69a14cb
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:31+02:00

Commit Message:
HARVESTER: Update harvester.exe hash

Refresh the game description entry for the Harvester executable.

Changed paths:
    engines/harvester/detection_tables.h


diff --git a/engines/harvester/detection_tables.h b/engines/harvester/detection_tables.h
index 384eb282d98..9f355ffa96b 100644
--- a/engines/harvester/detection_tables.h
+++ b/engines/harvester/detection_tables.h
@@ -30,7 +30,7 @@ const ADGameDescription gameDescriptions[] = {
 	{
 		"harvester",
 		nullptr,
-		AD_ENTRY1s("harvest.exe", "f2a8689edf91552dd0baa35ecc325c0a", 1173079),
+		AD_ENTRY1s("harvest.exe", "787e43b868ebfaca614010af3ab66b6d", 1173079),
 		Common::EN_ANY,
 		Common::kPlatformDOS,
 		ADGF_UNSTABLE,


Commit: 682523f47d5396f6578826ee6d6b9b285274e014
    https://github.com/scummvm/scummvm/commit/682523f47d5396f6578826ee6d6b9b285274e014
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:32+02:00

Commit Message:
HARVESTER: Restore post-intro startup UI surface

Changed paths:
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/startup_art.cpp
    engines/harvester/startup_art.h


diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 159cc15ed8f..22127d45bc1 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -57,6 +57,14 @@ Common::String HarvesterEngine::getGameId() const {
 	return _gameDescription->gameId;
 }
 
+void HarvesterEngine::setDisplayMode(int width, int height) {
+	delete _screen;
+	_screen = nullptr;
+
+	initGraphics(width, height);
+	_screen = new Graphics::Screen();
+}
+
 Common::Error HarvesterEngine::run() {
 	static const char *const kIntroPaths[] = {
 		"GRAPHIC/FST/VIRGLOGO.FST",
@@ -70,9 +78,8 @@ Common::Error HarvesterEngine::run() {
 	if (!_startupScript->load(*_resources))
 		return Common::kReadingFailed;
 
-	// Initialize 320x200 paletted graphics mode
-	initGraphics(320, 200);
-	_screen = new Graphics::Screen();
+	// The intro FST files play on the narrower startup movie surface.
+	setDisplayMode(320, 200);
 
 	// Set the engine's debugger console
 	setDebugger(new Console());
@@ -83,6 +90,9 @@ Common::Error HarvesterEngine::run() {
 			return Common::kReadingFailed;
 	}
 
+	// The original executable switches back to the gameplay UI surface after INTROFIN.FST.
+	setDisplayMode(640, 480);
+
 	_startupArt = new StartupArt();
 	if (!_startupArt->load(*_resources))
 		return Common::kReadingFailed;
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index df7611d132f..925198450ba 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -74,6 +74,8 @@ public:
 	StartupText *getStartupText() const { return _startupText; }
 
 private:
+	void setDisplayMode(int width, int height);
+
 	const ADGameDescription *const _gameDescription;
 	Common::RandomSource _randomSource;
 	Graphics::Screen *_screen = nullptr;
diff --git a/engines/harvester/startup_art.cpp b/engines/harvester/startup_art.cpp
index 2a8dae09cf3..8d7c4019fcc 100644
--- a/engines/harvester/startup_art.cpp
+++ b/engines/harvester/startup_art.cpp
@@ -39,11 +39,13 @@ static byte expand6BitColor(byte value) {
 
 bool StartupArt::load(ResourceManager &resources) {
 	_waitFrames.clear();
+	_pointerFrames.clear();
 	_inventoryBitmap = IndexedBitmap();
 	_logoBitmap = IndexedBitmap();
 
 	return loadPalette(resources, "1:/GRAPHIC/PAL/WAIT.PAL", _waitPalette) &&
 	       loadAnimation(resources, "1:/GRAPHIC/OTHER/WAIT.ABM", _waitFrames) &&
+	       loadAnimation(resources, "1:/GRAPHIC/POINTERS/POINTERS.ABM", _pointerFrames) &&
 	       loadBitmap(resources, "1:/GRAPHIC/OTHER/INVENTRY.BM", _inventoryBitmap) &&
 	       loadBitmap(resources, "1:/GRAPHIC/OTHER/HARVLOGO.BM", _logoBitmap);
 }
@@ -52,12 +54,29 @@ void StartupArt::drawWaitFrame() const {
 	if (_waitFrames.empty() || !_waitFrames[0].isValid())
 		return;
 
-	const AbmFrame &frame = _waitFrames[0];
-	const int x = MAX<int>(0, (320 - (int)frame.width) / 2);
-	const int y = MAX<int>(0, (200 - (int)frame.height) / 2);
-
 	g_system->getPaletteManager()->setPalette(_waitPalette, 0, 256);
-	g_system->copyRectToScreen(frame.pixels.data(), frame.width, x, y, frame.width, frame.height);
+
+	const int screenWidth = g_system->getWidth();
+	const int screenHeight = g_system->getHeight();
+	g_system->fillScreen(0);
+
+	if (_inventoryBitmap.isValid()) {
+		const int inventoryX = MAX<int>(0, (screenWidth - (int)_inventoryBitmap.width) / 2);
+		const int inventoryY = MAX<int>(0, (screenHeight - (int)_inventoryBitmap.height) / 2);
+		g_system->copyRectToScreen(_inventoryBitmap.pixels.data(), _inventoryBitmap.width, inventoryX, inventoryY,
+			_inventoryBitmap.width, _inventoryBitmap.height);
+	}
+
+	if (_logoBitmap.isValid()) {
+		const int logoX = MAX<int>(0, (screenWidth - (int)_logoBitmap.width) / 2);
+		g_system->copyRectToScreen(_logoBitmap.pixels.data(), _logoBitmap.width, logoX, 0,
+			_logoBitmap.width, _logoBitmap.height);
+	}
+
+	const AbmFrame &frame = _waitFrames[0];
+	const int waitX = MAX<int>(0, (screenWidth - (int)frame.width) / 2);
+	const int waitY = MAX<int>(0, (screenHeight - (int)frame.height) / 2);
+	g_system->copyRectToScreen(frame.pixels.data(), frame.width, waitX, waitY, frame.width, frame.height);
 	g_system->updateScreen();
 }
 
diff --git a/engines/harvester/startup_art.h b/engines/harvester/startup_art.h
index b33ff3f32b5..180c5cc22cb 100644
--- a/engines/harvester/startup_art.h
+++ b/engines/harvester/startup_art.h
@@ -50,6 +50,7 @@ public:
 
 	const byte *getWaitPalette() const { return _waitPalette; }
 	const Common::Array<AbmFrame> &getWaitFrames() const { return _waitFrames; }
+	const Common::Array<AbmFrame> &getPointerFrames() const { return _pointerFrames; }
 	const IndexedBitmap &getInventoryBitmap() const { return _inventoryBitmap; }
 	const IndexedBitmap &getLogoBitmap() const { return _logoBitmap; }
 
@@ -61,6 +62,7 @@ private:
 
 	byte _waitPalette[256 * 3] = { 0 };
 	Common::Array<AbmFrame> _waitFrames;
+	Common::Array<AbmFrame> _pointerFrames;
 	IndexedBitmap _inventoryBitmap;
 	IndexedBitmap _logoBitmap;
 };


Commit: dab599fd56f5c28a69d89eb6ac6dce2d3288e827
    https://github.com/scummvm/scummvm/commit/dab599fd56f5c28a69d89eb6ac6dce2d3288e827
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:32+02:00

Commit Message:
HARVESTER: Load and show quick tips

Load quick tips resources and display them during startup.

Changed paths:
    engines/harvester/harvester.cpp
    engines/harvester/startup_art.cpp
    engines/harvester/startup_art.h


diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 22127d45bc1..c6438bce8cc 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -71,6 +71,10 @@ Common::Error HarvesterEngine::run() {
 		"GRAPHIC/FST/FVLOGO.FST",
 		"GRAPHIC/FST/INTROFIN.FST"
 	};
+	Graphics::ModeList modes;
+	modes.push_back(Graphics::Mode(320, 200));
+	modes.push_back(Graphics::Mode(640, 480));
+	initGraphicsModes(modes);
 
 	_resources = new ResourceManager();
 	_resources->mountStartupArchives();
@@ -102,6 +106,10 @@ Common::Error HarvesterEngine::run() {
 	if (!_startupText->load(*_resources))
 		return Common::kReadingFailed;
 
+	if (!_startupArt->loadQuickTipsResources(*_resources))
+		return Common::kReadingFailed;
+	_startupArt->drawQuickTipsScreen();
+
 	// If a savegame was selected from the launcher, load it
 	int saveSlot = ConfMan.getInt("save_slot");
 	if (saveSlot != -1)
diff --git a/engines/harvester/startup_art.cpp b/engines/harvester/startup_art.cpp
index 8d7c4019fcc..e8b9c3fc23a 100644
--- a/engines/harvester/startup_art.cpp
+++ b/engines/harvester/startup_art.cpp
@@ -35,13 +35,44 @@ static byte expand6BitColor(byte value) {
 	return (value * 255 + 31) / 63;
 }
 
+static const int kInventoryX = 64;
+static const int kInventoryY = 48;
+static const int kLogoX = 160;
+static const int kLogoY = 0;
+static const int kWaitX = 250;
+static const int kWaitY = 160;
+static const int kQuickTipsOverlayX = 177;
+static const int kQuickTipsOverlayY = 85;
+static const int kCursorX = 320;
+static const int kCursorY = 200;
+
+static const char *const kTextboxPaths[] = {
+	"1:/GRAPHIC/OTHER/TEXTBOX1.BM",
+	"1:/GRAPHIC/OTHER/TEXTBOX2.BM",
+	"1:/GRAPHIC/OTHER/TEXTBOX3.BM",
+	"1:/GRAPHIC/OTHER/TEXTBOX4.BM"
+};
+
+static const char *const kAmmoIconPaths[] = {
+	"1:/GRAPHIC/ROOMOBJ/BULLET.BM",
+	"1:/GRAPHIC/OTHER/SHOTSHEL.BM",
+	"1:/GRAPHIC/ROOMOBJ/NAIL.BM",
+	"1:/GRAPHIC/ROOMOBJ/GASCAN.BM"
+};
+
 } // End of anonymous namespace
 
 bool StartupArt::load(ResourceManager &resources) {
 	_waitFrames.clear();
 	_pointerFrames.clear();
+	_textboxes.clear();
+	_ammoIcons.clear();
 	_inventoryBitmap = IndexedBitmap();
 	_logoBitmap = IndexedBitmap();
+	_pcRoomBitmap = IndexedBitmap();
+	_pcRoomMaskA = IndexedBitmap();
+	_pcRoomMaskB = IndexedBitmap();
+	_tipsBitmap = IndexedBitmap();
 
 	return loadPalette(resources, "1:/GRAPHIC/PAL/WAIT.PAL", _waitPalette) &&
 	       loadAnimation(resources, "1:/GRAPHIC/OTHER/WAIT.ABM", _waitFrames) &&
@@ -50,33 +81,49 @@ bool StartupArt::load(ResourceManager &resources) {
 	       loadBitmap(resources, "1:/GRAPHIC/OTHER/HARVLOGO.BM", _logoBitmap);
 }
 
+bool StartupArt::loadQuickTipsResources(ResourceManager &resources) {
+	_textboxes.resize(ARRAYSIZE(kTextboxPaths));
+	for (uint i = 0; i < _textboxes.size(); ++i) {
+		if (!loadBitmap(resources, kTextboxPaths[i], _textboxes[i]))
+			return false;
+	}
+
+	_ammoIcons.resize(ARRAYSIZE(kAmmoIconPaths));
+	for (uint i = 0; i < _ammoIcons.size(); ++i) {
+		if (!loadBitmap(resources, kAmmoIconPaths[i], _ammoIcons[i]))
+			return false;
+	}
+
+	return loadAnimation(resources, "1:/GRAPHIC/MONSTERS/PC/PC0.ABM", _pcActorFrames) &&
+	       loadPalette(resources, "1:/GRAPHIC/PAL/PCROOM.PAL", _pcRoomPalette) &&
+	       loadBitmap(resources, "1:/GRAPHIC/ROOMS/PCROOM.BM", _pcRoomBitmap) &&
+	       loadBitmap(resources, "1:/GRAPHIC/MASKS/PCRPRTA.BM", _pcRoomMaskA) &&
+	       loadBitmap(resources, "1:/GRAPHIC/MASKS/PCRPRTB.BM", _pcRoomMaskB) &&
+	       loadAnimation(resources, "1:/GRAPHIC/ROOMANIM/PCRMCLOK.ABM", _pcRoomClockFrames) &&
+	       loadBitmap(resources, "1:/GRAPHIC/OTHER/TIPS.BM", _tipsBitmap);
+}
+
 void StartupArt::drawWaitFrame() const {
 	if (_waitFrames.empty() || !_waitFrames[0].isValid())
 		return;
 
 	g_system->getPaletteManager()->setPalette(_waitPalette, 0, 256);
-
-	const int screenWidth = g_system->getWidth();
-	const int screenHeight = g_system->getHeight();
 	g_system->fillScreen(0);
+	blitBitmap(_inventoryBitmap, kInventoryX, kInventoryY);
+	blitBitmap(_logoBitmap, kLogoX, kLogoY);
+	blitAnimationFrame(_waitFrames, 0, kWaitX, kWaitY);
+	g_system->updateScreen();
+}
 
-	if (_inventoryBitmap.isValid()) {
-		const int inventoryX = MAX<int>(0, (screenWidth - (int)_inventoryBitmap.width) / 2);
-		const int inventoryY = MAX<int>(0, (screenHeight - (int)_inventoryBitmap.height) / 2);
-		g_system->copyRectToScreen(_inventoryBitmap.pixels.data(), _inventoryBitmap.width, inventoryX, inventoryY,
-			_inventoryBitmap.width, _inventoryBitmap.height);
-	}
-
-	if (_logoBitmap.isValid()) {
-		const int logoX = MAX<int>(0, (screenWidth - (int)_logoBitmap.width) / 2);
-		g_system->copyRectToScreen(_logoBitmap.pixels.data(), _logoBitmap.width, logoX, 0,
-			_logoBitmap.width, _logoBitmap.height);
-	}
+void StartupArt::drawQuickTipsScreen() const {
+	if (!_pcRoomBitmap.isValid())
+		return;
 
-	const AbmFrame &frame = _waitFrames[0];
-	const int waitX = MAX<int>(0, (screenWidth - (int)frame.width) / 2);
-	const int waitY = MAX<int>(0, (screenHeight - (int)frame.height) / 2);
-	g_system->copyRectToScreen(frame.pixels.data(), frame.width, waitX, waitY, frame.width, frame.height);
+	g_system->getPaletteManager()->setPalette(_pcRoomPalette, 0, 256);
+	g_system->fillScreen(0);
+	blitBitmap(_pcRoomBitmap, 0, 0);
+	blitBitmap(_tipsBitmap, kQuickTipsOverlayX, kQuickTipsOverlayY);
+	blitAnimationFrame(_pointerFrames, 0, kCursorX, kCursorY);
 	g_system->updateScreen();
 }
 
@@ -152,7 +199,7 @@ bool StartupArt::loadAnimation(ResourceManager &resources, const Common::String
 		offset = payloadOffset + sourceSize;
 	}
 
-	debug(1, "Harvester: loaded %u WAIT.ABM frames", (uint)frames.size());
+	debug(1, "Harvester: loaded %u frames from '%s'", (uint)frames.size(), path.c_str());
 	return true;
 }
 
@@ -188,4 +235,19 @@ bool StartupArt::decodeAnimationFrame(const byte *source, uint32 sourceSize, boo
 	return dstOffset == dest.size();
 }
 
+void StartupArt::blitBitmap(const IndexedBitmap &bitmap, int x, int y) const {
+	if (!bitmap.isValid())
+		return;
+
+	g_system->copyRectToScreen(bitmap.pixels.data(), bitmap.width, x, y, bitmap.width, bitmap.height);
+}
+
+void StartupArt::blitAnimationFrame(const Common::Array<AbmFrame> &frames, uint frameIndex, int x, int y) const {
+	if (frameIndex >= frames.size() || !frames[frameIndex].isValid())
+		return;
+
+	const AbmFrame &frame = frames[frameIndex];
+	blitBitmap(frame, x + frame.xOffset, y + frame.yOffset);
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/startup_art.h b/engines/harvester/startup_art.h
index 180c5cc22cb..f75ec808857 100644
--- a/engines/harvester/startup_art.h
+++ b/engines/harvester/startup_art.h
@@ -46,7 +46,9 @@ struct AbmFrame : IndexedBitmap {
 class StartupArt {
 public:
 	bool load(ResourceManager &resources);
+	bool loadQuickTipsResources(ResourceManager &resources);
 	void drawWaitFrame() const;
+	void drawQuickTipsScreen() const;
 
 	const byte *getWaitPalette() const { return _waitPalette; }
 	const Common::Array<AbmFrame> &getWaitFrames() const { return _waitFrames; }
@@ -59,12 +61,23 @@ private:
 	bool loadBitmap(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) const;
 	bool loadAnimation(ResourceManager &resources, const Common::String &path, Common::Array<AbmFrame> &frames) const;
 	bool decodeAnimationFrame(const byte *source, uint32 sourceSize, bool compressed, Common::Array<byte> &dest) const;
+	void blitBitmap(const IndexedBitmap &bitmap, int x, int y) const;
+	void blitAnimationFrame(const Common::Array<AbmFrame> &frames, uint frameIndex, int x, int y) const;
 
 	byte _waitPalette[256 * 3] = { 0 };
+	byte _pcRoomPalette[256 * 3] = { 0 };
 	Common::Array<AbmFrame> _waitFrames;
 	Common::Array<AbmFrame> _pointerFrames;
+	Common::Array<AbmFrame> _pcRoomClockFrames;
+	Common::Array<AbmFrame> _pcActorFrames;
+	Common::Array<IndexedBitmap> _textboxes;
+	Common::Array<IndexedBitmap> _ammoIcons;
 	IndexedBitmap _inventoryBitmap;
 	IndexedBitmap _logoBitmap;
+	IndexedBitmap _pcRoomBitmap;
+	IndexedBitmap _pcRoomMaskA;
+	IndexedBitmap _pcRoomMaskB;
+	IndexedBitmap _tipsBitmap;
 };
 
 } // End of namespace Harvester


Commit: d4048bd49f0c281314364a9ff66abecf12f95dcb
    https://github.com/scummvm/scummvm/commit/d4048bd49f0c281314364a9ff66abecf12f95dcb
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:32+02:00

Commit Message:
HARVESTER: Log startup display mode changes

Changed paths:
    engines/harvester/harvester.cpp


diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index c6438bce8cc..288b598019e 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -63,6 +63,7 @@ void HarvesterEngine::setDisplayMode(int width, int height) {
 
 	initGraphics(width, height);
 	_screen = new Graphics::Screen();
+	debugC(1, kDebugGeneral, "Harvester: switched display mode to %dx%d", width, height);
 }
 
 Common::Error HarvesterEngine::run() {


Commit: b09f59a1979f11f6b250bb9307a6118d7b329f6a
    https://github.com/scummvm/scummvm/commit/b09f59a1979f11f6b250bb9307a6118d7b329f6a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:33+02:00

Commit Message:
HARVESTER: Add startup flow after quick tips

Changed paths:
  A engines/harvester/startup_flow.cpp
  A engines/harvester/startup_flow.h
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/module.mk
    engines/harvester/startup_art.cpp
    engines/harvester/startup_art.h
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 288b598019e..87544b9ab28 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -22,16 +22,15 @@
 #include "harvester/harvester.h"
 
 #include "common/config-manager.h"
-#include "common/events.h"
 #include "common/system.h"
 #include "engines/util.h"
-#include "graphics/framelimiter.h"
 #include "graphics/paletteman.h"
 #include "harvester/console.h"
 #include "harvester/detection.h"
 #include "harvester/fst_player.h"
 #include "harvester/resources.h"
 #include "harvester/startup_art.h"
+#include "harvester/startup_flow.h"
 #include "harvester/startup_script.h"
 #include "harvester/startup_text.h"
 
@@ -109,32 +108,17 @@ Common::Error HarvesterEngine::run() {
 
 	if (!_startupArt->loadQuickTipsResources(*_resources))
 		return Common::kReadingFailed;
-	_startupArt->drawQuickTipsScreen();
 
 	// If a savegame was selected from the launcher, load it
 	int saveSlot = ConfMan.getInt("save_slot");
 	if (saveSlot != -1)
 		(void)loadGameState(saveSlot);
 
-	Common::Event e;
-
-	Graphics::FrameLimiter limiter(g_system, 60);
-	while (!shouldQuit()) {
-		while (g_system->getEventManager()->pollEvent(e)) {
-			switch (e.type) {
-			case Common::EVENT_QUIT:
-			case Common::EVENT_RETURN_TO_LAUNCHER:
-				return Common::kNoError;
-			default:
-				break;
-			}
-		}
-
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
+	StartupFlow startupFlow(*this);
+	if (!startupFlow.load())
+		return Common::kReadingFailed;
 
-	return Common::kNoError;
+	return startupFlow.run();
 }
 
 bool HarvesterEngine::hasFeature(EngineFeature f) const {
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 925198450ba..92915065bcb 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -69,6 +69,7 @@ public:
 	Common::Error loadGameStream(Common::SeekableReadStream *stream) override;
 
 	ResourceManager *getResources() const { return _resources; }
+	Graphics::Screen *getScreen() const { return _screen; }
 	StartupScript *getStartupScript() const { return _startupScript; }
 	StartupArt *getStartupArt() const { return _startupArt; }
 	StartupText *getStartupText() const { return _startupText; }
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index f7709916357..c7c7f46397d 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -7,6 +7,7 @@ MODULE_OBJS = \
 	metaengine.o \
 	resources.o \
 	startup_art.o \
+	startup_flow.o \
 	startup_script.o \
 	startup_text.o \
 	xfile_archive.o
diff --git a/engines/harvester/startup_art.cpp b/engines/harvester/startup_art.cpp
index e8b9c3fc23a..5b7c2761d41 100644
--- a/engines/harvester/startup_art.cpp
+++ b/engines/harvester/startup_art.cpp
@@ -41,8 +41,8 @@ static const int kLogoX = 160;
 static const int kLogoY = 0;
 static const int kWaitX = 250;
 static const int kWaitY = 160;
-static const int kQuickTipsOverlayX = 177;
-static const int kQuickTipsOverlayY = 85;
+static const int kQuickTipsOverlayX = 167;
+static const int kQuickTipsOverlayY = 200;
 static const int kCursorX = 320;
 static const int kCursorY = 200;
 
diff --git a/engines/harvester/startup_art.h b/engines/harvester/startup_art.h
index f75ec808857..4cbe893c382 100644
--- a/engines/harvester/startup_art.h
+++ b/engines/harvester/startup_art.h
@@ -51,10 +51,13 @@ public:
 	void drawQuickTipsScreen() const;
 
 	const byte *getWaitPalette() const { return _waitPalette; }
+	const byte *getPcRoomPalette() const { return _pcRoomPalette; }
 	const Common::Array<AbmFrame> &getWaitFrames() const { return _waitFrames; }
 	const Common::Array<AbmFrame> &getPointerFrames() const { return _pointerFrames; }
 	const IndexedBitmap &getInventoryBitmap() const { return _inventoryBitmap; }
 	const IndexedBitmap &getLogoBitmap() const { return _logoBitmap; }
+	const IndexedBitmap &getPcRoomBitmap() const { return _pcRoomBitmap; }
+	const IndexedBitmap &getTipsBitmap() const { return _tipsBitmap; }
 
 private:
 	bool loadPalette(ResourceManager &resources, const Common::String &path, byte *dest) const;
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
new file mode 100644
index 00000000000..dd094515070
--- /dev/null
+++ b/engines/harvester/startup_flow.cpp
@@ -0,0 +1,430 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/startup_flow.h"
+
+#include "common/debug.h"
+#include "common/events.h"
+#include "common/formats/ini-file.h"
+#include "common/memstream.h"
+#include "graphics/font.h"
+#include "graphics/fontman.h"
+#include "graphics/framelimiter.h"
+#include "harvester/detection.h"
+#include "harvester/harvester.h"
+#include "harvester/resources.h"
+#include "harvester/startup_art.h"
+#include "harvester/startup_script.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kQuickTipsPath = "ADJHEAD.RCS";
+static const char *const kMenuPath = "MENU.INI";
+static const char *const kMenuSectionName = "menu";
+
+static const int kInventoryX = 64;
+static const int kInventoryY = 48;
+static const int kLogoX = 160;
+static const int kLogoY = 0;
+static const int kQuickTipsOverlayX = 167;
+static const int kQuickTipsOverlayY = 200;
+
+static const int kQuickTipTextX = 180;
+static const int kQuickTipTextY = 228;
+static const int kQuickTipTextWidth = 280;
+
+static const int kMenuStartY = 100;
+static const int kMenuLineSpacing = 28;
+
+static const byte kTextColorNormal = 255;
+static const byte kTextColorHover = 251;
+static const byte kTextColorDim = 248;
+static const byte kShadowColor = 0;
+static const byte kPanelFillColor = 1;
+
+static Common::Rect quickTipsExitRect() {
+	return Common::Rect(180, 280, 238, 291);
+}
+
+static Common::Rect quickTipsNextRect() {
+	return Common::Rect(420, 280, 492, 291);
+}
+
+static Common::Rect quickTipsToggleRect() {
+	return Common::Rect(258, 280, 366, 291);
+}
+
+static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
+	if (!bitmap.isValid())
+		return;
+
+	screen.copyRectToSurface(bitmap.pixels.data(), bitmap.width, x, y, bitmap.width, bitmap.height);
+}
+
+static void blitAnimationFrame(Graphics::Screen &screen, const Common::Array<AbmFrame> &frames, uint frameIndex, int x, int y) {
+	if (frameIndex >= frames.size() || !frames[frameIndex].isValid())
+		return;
+
+	const AbmFrame &frame = frames[frameIndex];
+	blitBitmap(screen, frame, x + frame.xOffset, y + frame.yOffset);
+}
+
+static void drawShadowedString(Graphics::Screen &screen, const Graphics::Font &font, const Common::String &text,
+		int x, int y, int width, byte color, Graphics::TextAlign align = Graphics::kTextAlignLeft) {
+	font.drawString(&screen, text, x + 1, y + 1, width, kShadowColor, align);
+	font.drawString(&screen, text, x, y, width, color, align);
+}
+
+static void drawWrappedShadowedText(Graphics::Screen &screen, const Graphics::Font &font, const Common::String &text,
+		int x, int y, int width, byte color) {
+	Common::Array<Common::String> lines;
+	font.wordWrapText(text, width, lines);
+
+	const int lineHeight = font.getFontHeight() + 2;
+	for (uint i = 0; i < lines.size(); ++i)
+		drawShadowedString(screen, font, lines[i], x, y + i * lineHeight, width, color);
+}
+
+static Common::String trimAsciiLine(const Common::String &value) {
+	uint start = 0;
+	uint end = value.size();
+
+	while (start < end && (value[start] == ' ' || value[start] == '\t'))
+		++start;
+	while (end > start && (value[end - 1] == ' ' || value[end - 1] == '\t' || value[end - 1] == '\r'))
+		--end;
+
+	return value.substr(start, end - start);
+}
+
+} // End of anonymous namespace
+
+StartupFlow::StartupFlow(HarvesterEngine &engine) : _engine(engine), _mousePos(320, 200) {
+}
+
+bool StartupFlow::load() {
+	return loadQuickTips() && loadMenuItems();
+}
+
+Common::Error StartupFlow::run() {
+	Common::Error error = runQuickTips();
+	if (error.getCode() != Common::kNoError)
+		return error;
+
+	return runMainMenuStub();
+}
+
+bool StartupFlow::loadQuickTips() {
+	_quickTips.clear();
+
+	Common::Array<byte> data;
+	if (!_engine.getResources()->loadFile(kQuickTipsPath, data)) {
+		warning("Harvester: unable to load quick tips '%s'", kQuickTipsPath);
+		return true;
+	}
+
+	Common::String currentLine;
+	for (uint i = 0; i < data.size(); ++i) {
+		const char ch = (char)data[i];
+		if (ch == '\r')
+			continue;
+		if (ch == '\n') {
+			const Common::String trimmed = trimAsciiLine(currentLine);
+			if (!trimmed.empty())
+				_quickTips.push_back(trimmed);
+			currentLine.clear();
+			continue;
+		}
+
+		currentLine += ch;
+	}
+
+	const Common::String trimmed = trimAsciiLine(currentLine);
+	if (!trimmed.empty())
+		_quickTips.push_back(trimmed);
+
+	debugC(1, kDebugGeneral, "Harvester: loaded %u quick tips from '%s'", (uint)_quickTips.size(), kQuickTipsPath);
+	return true;
+}
+
+bool StartupFlow::loadMenuItems() {
+	_menuItems.clear();
+
+	Common::Array<byte> data;
+	if (!_engine.getResources()->loadFile(kMenuPath, data)) {
+		warning("Harvester: unable to load startup menu '%s'", kMenuPath);
+		return true;
+	}
+
+	Common::MemoryReadStream stream(data.data(), data.size());
+	Common::INIFile menu;
+	menu.setDefaultSectionName(kMenuSectionName);
+	menu.requireKeyValueDelimiter();
+	menu.suppressValuelessLineWarning();
+	if (!menu.loadFromStream(stream)) {
+		warning("Harvester: unable to parse startup menu '%s'", kMenuPath);
+		return true;
+	}
+
+	for (uint i = 1; i <= 6; ++i) {
+		Common::String key = Common::String::format("main_menu_%u", i);
+		Common::String value;
+		if (menu.getKey(key, kMenuSectionName, value) && !value.empty())
+			_menuItems.push_back(value);
+	}
+
+	debugC(1, kDebugGeneral, "Harvester: loaded %u startup menu items from '%s'", (uint)_menuItems.size(), kMenuPath);
+	return true;
+}
+
+Common::Error StartupFlow::runQuickTips() {
+	if (!_engine.getStartupScript()->isQuickTipsEnabled() || _quickTips.empty())
+		return Common::kNoError;
+
+	uint tipIndex = _engine.getRandomNumber(_quickTips.size() - 1);
+	bool needsRedraw = true;
+	Graphics::FrameLimiter limiter(g_system, 60);
+
+	while (!_engine.shouldQuit()) {
+		if (needsRedraw) {
+			renderQuickTipsScreen(_quickTips[tipIndex]);
+			needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (handleSystemEvent(event, result))
+				return result;
+
+			switch (event.type) {
+			case Common::EVENT_MOUSEMOVE:
+				needsRedraw = true;
+				break;
+			case Common::EVENT_LBUTTONDOWN:
+				if (quickTipsExitRect().contains(_mousePos))
+					return Common::kNoError;
+
+				if (quickTipsNextRect().contains(_mousePos)) {
+					tipIndex = (tipIndex + 1) % _quickTips.size();
+					needsRedraw = true;
+				} else if (quickTipsToggleRect().contains(_mousePos)) {
+					_engine.getStartupScript()->setQuickTipsEnabled(!_engine.getStartupScript()->isQuickTipsEnabled());
+					needsRedraw = true;
+				}
+				break;
+			default:
+				break;
+			}
+		}
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error StartupFlow::runMainMenuStub() {
+	Graphics::FrameLimiter limiter(g_system, 60);
+	Common::String statusMessage = "Stub handoff after room_setup(\"START\", \"QUICK_TIPS\").";
+	int selectedItem = _menuItems.empty() ? -1 : 0;
+	bool needsRedraw = true;
+
+	while (!_engine.shouldQuit()) {
+		if (needsRedraw) {
+			renderMainMenuStub(selectedItem, statusMessage);
+			needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (handleSystemEvent(event, result))
+				return result;
+
+			switch (event.type) {
+			case Common::EVENT_MOUSEMOVE: {
+				const int hoveredItem = getMenuItemAt(_mousePos);
+				if (hoveredItem != -1 && hoveredItem != selectedItem) {
+					selectedItem = hoveredItem;
+					needsRedraw = true;
+				}
+				break;
+			}
+			case Common::EVENT_KEYDOWN:
+				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+					return Common::kNoError;
+
+				if (_menuItems.empty())
+					break;
+
+				if (event.kbd.keycode == Common::KEYCODE_UP) {
+					selectedItem = (selectedItem + _menuItems.size() - 1) % _menuItems.size();
+					needsRedraw = true;
+				} else if (event.kbd.keycode == Common::KEYCODE_DOWN) {
+					selectedItem = (selectedItem + 1) % _menuItems.size();
+					needsRedraw = true;
+				} else if (event.kbd.keycode == Common::KEYCODE_RETURN || event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
+					if (_menuItems[selectedItem].equalsIgnoreCase("QUIT GAME"))
+						return Common::kNoError;
+
+					statusMessage = Common::String::format("%s is stubbed; room setup and gameplay loop are next.",
+						_menuItems[selectedItem].c_str());
+					needsRedraw = true;
+				}
+				break;
+			case Common::EVENT_LBUTTONDOWN:
+				if (_menuItems.empty())
+					break;
+
+				selectedItem = getMenuItemAt(_mousePos);
+				if (selectedItem == -1)
+					break;
+
+				if (_menuItems[selectedItem].equalsIgnoreCase("QUIT GAME"))
+					return Common::kNoError;
+
+				statusMessage = Common::String::format("%s is stubbed; room setup and gameplay loop are next.",
+					_menuItems[selectedItem].c_str());
+				needsRedraw = true;
+				break;
+			default:
+				break;
+			}
+		}
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+void StartupFlow::renderQuickTipsScreen(const Common::String &tipText) const {
+	Graphics::Screen *screen = _engine.getScreen();
+	const StartupArt *art = _engine.getStartupArt();
+	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	if (!screen || !art || !font)
+		return;
+
+	screen->setPalette(art->getPcRoomPalette());
+	screen->fillRect(screen->getBounds(), 0);
+
+	blitBitmap(*screen, art->getPcRoomBitmap(), 0, 0);
+	blitBitmap(*screen, art->getTipsBitmap(), kQuickTipsOverlayX, kQuickTipsOverlayY);
+
+	drawWrappedShadowedText(*screen, *font, tipText, kQuickTipTextX, kQuickTipTextY, kQuickTipTextWidth, kTextColorNormal);
+	const Common::Rect exitRect = quickTipsExitRect();
+	const Common::Rect nextRect = quickTipsNextRect();
+	const Common::Rect toggleRect = quickTipsToggleRect();
+	drawShadowedString(*screen, *font, "Exit", exitRect.left, exitRect.top, exitRect.width(),
+		exitRect.contains(_mousePos) ? kTextColorHover : kTextColorNormal);
+	drawShadowedString(*screen, *font, "Next", nextRect.left, nextRect.top, nextRect.width(),
+		nextRect.contains(_mousePos) ? kTextColorHover : kTextColorNormal);
+	drawShadowedString(*screen, *font,
+		_engine.getStartupScript()->isQuickTipsEnabled() ? "Show_Tips_ON" : "Show_Tips_OFF",
+		toggleRect.left, toggleRect.top, toggleRect.width(),
+		toggleRect.contains(_mousePos) ? kTextColorHover : kTextColorNormal);
+
+	blitAnimationFrame(*screen, art->getPointerFrames(), 0, _mousePos.x, _mousePos.y);
+	screen->makeAllDirty();
+	screen->update();
+}
+
+void StartupFlow::renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const {
+	Graphics::Screen *screen = _engine.getScreen();
+	const StartupArt *art = _engine.getStartupArt();
+	const Graphics::Font *titleFont = FontMan.getFontByUsage(Graphics::FontManager::kBigGUIFont);
+	const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	if (!screen || !art || !titleFont || !bodyFont)
+		return;
+
+	screen->setPalette(art->getWaitPalette());
+	screen->fillRect(screen->getBounds(), 0);
+	blitBitmap(*screen, art->getInventoryBitmap(), kInventoryX, kInventoryY);
+	blitBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
+
+	const Common::Rect panel(96, 96, 544, 432);
+	screen->fillRect(panel, kPanelFillColor);
+
+	drawShadowedString(*screen, *titleFont, "Startup Main Menu Stub", panel.left, 110, panel.width(),
+		kTextColorNormal, Graphics::kTextAlignCenter);
+
+	for (uint i = 0; i < _menuItems.size(); ++i) {
+		const int itemY = kMenuStartY + (int)i * kMenuLineSpacing;
+		const byte color = ((int)i == selectedItem) ? kTextColorHover : kTextColorNormal;
+		drawShadowedString(*screen, *bodyFont, _menuItems[i], panel.left, itemY, panel.width(), color,
+			Graphics::kTextAlignCenter);
+	}
+
+	drawShadowedString(*screen, *bodyFont, "This is the next startup handoff after quick tips.", panel.left, 330,
+		panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
+	drawWrappedShadowedText(*screen, *bodyFont, statusMessage, panel.left + 24, 360, panel.width() - 48,
+		kTextColorNormal);
+	drawShadowedString(*screen, *bodyFont, "Use mouse or arrow keys. Enter activates. Esc returns to launcher.",
+		panel.left, 404, panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
+
+	blitAnimationFrame(*screen, art->getPointerFrames(), 0, _mousePos.x, _mousePos.y);
+	screen->makeAllDirty();
+	screen->update();
+}
+
+bool StartupFlow::handleSystemEvent(const Common::Event &event, Common::Error &result) {
+	switch (event.type) {
+	case Common::EVENT_QUIT:
+	case Common::EVENT_RETURN_TO_LAUNCHER:
+		result = Common::kNoError;
+		return true;
+	case Common::EVENT_MOUSEMOVE:
+	case Common::EVENT_LBUTTONDOWN:
+	case Common::EVENT_LBUTTONUP:
+	case Common::EVENT_RBUTTONDOWN:
+	case Common::EVENT_RBUTTONUP:
+		_mousePos = event.mouse;
+		break;
+	default:
+		break;
+	}
+
+	return false;
+}
+
+int StartupFlow::getMenuItemAt(const Common::Point &mousePos) const {
+	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	if (!font)
+		return -1;
+
+	for (uint i = 0; i < _menuItems.size(); ++i) {
+		const int width = font->getStringWidth(_menuItems[i]);
+		const int x = (640 - width) / 2;
+		const int y = kMenuStartY + (int)i * kMenuLineSpacing;
+		const Common::Rect bounds(x - 12, y - 2, x + width + 12, y + font->getFontHeight() + 2);
+		if (bounds.contains(mousePos))
+			return (int)i;
+	}
+
+	return -1;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/startup_flow.h
new file mode 100644
index 00000000000..e8b8ff59484
--- /dev/null
+++ b/engines/harvester/startup_flow.h
@@ -0,0 +1,63 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_STARTUP_FLOW_H
+#define HARVESTER_STARTUP_FLOW_H
+
+#include "common/array.h"
+#include "common/error.h"
+#include "common/rect.h"
+#include "common/str.h"
+
+namespace Common {
+struct Event;
+}
+
+namespace Harvester {
+
+class HarvesterEngine;
+
+class StartupFlow {
+public:
+	explicit StartupFlow(HarvesterEngine &engine);
+
+	bool load();
+	Common::Error run();
+
+private:
+	bool loadQuickTips();
+	bool loadMenuItems();
+	Common::Error runQuickTips();
+	Common::Error runMainMenuStub();
+	void renderQuickTipsScreen(const Common::String &tipText) const;
+	void renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const;
+	bool handleSystemEvent(const Common::Event &event, Common::Error &result);
+	int getMenuItemAt(const Common::Point &mousePos) const;
+
+	HarvesterEngine &_engine;
+	Common::Array<Common::String> _quickTips;
+	Common::Array<Common::String> _menuItems;
+	Common::Point _mousePos;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_STARTUP_FLOW_H
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index e90b0d69b44..88483b1611c 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -36,6 +36,7 @@ static const byte kTownScriptXorKey = 0xaa;
 bool StartupScript::load(ResourceManager &resources) {
 	_path = kDefaultTownScript;
 	_data.clear();
+	_quickTipsEnabled = true;
 
 	loadConfig(resources);
 
@@ -71,6 +72,10 @@ bool StartupScript::loadConfig(ResourceManager &resources) {
 	if (config.getKey("TOWN", kConfigSectionName, townPath))
 		_path = resources.normalizeResourcePath(townPath);
 
+	Common::String quickTipsValue;
+	if (config.getKey("QUICK_TIPS", kConfigSectionName, quickTipsValue))
+		_quickTipsEnabled = quickTipsValue.equalsIgnoreCase("ON");
+
 	return true;
 }
 
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 83a27133918..7dff6bda621 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -35,6 +35,8 @@ public:
 
 	const Common::String &getPath() const { return _path; }
 	const Common::Array<byte> &getData() const { return _data; }
+	bool isQuickTipsEnabled() const { return _quickTipsEnabled; }
+	void setQuickTipsEnabled(bool enabled) { _quickTipsEnabled = enabled; }
 
 private:
 	bool loadConfig(ResourceManager &resources);
@@ -42,6 +44,7 @@ private:
 
 	Common::String _path;
 	Common::Array<byte> _data;
+	bool _quickTipsEnabled = true;
 };
 
 } // End of namespace Harvester


Commit: e3ee58246ff4840af45a2d7c2f958392671a01f8
    https://github.com/scummvm/scummvm/commit/e3ee58246ff4840af45a2d7c2f958392671a01f8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:33+02:00

Commit Message:
HARVESTER: Resolve START room setup from HARVEST.SCR

Changed paths:
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_flow.h
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index dd094515070..ac1aefd0255 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -22,6 +22,7 @@
 #include "harvester/startup_flow.h"
 
 #include "common/debug.h"
+#include "common/endian.h"
 #include "common/events.h"
 #include "common/formats/ini-file.h"
 #include "common/memstream.h"
@@ -62,6 +63,10 @@ static const byte kTextColorDim = 248;
 static const byte kShadowColor = 0;
 static const byte kPanelFillColor = 1;
 
+static byte expand6BitColor(byte value) {
+	return (value * 255 + 31) / 63;
+}
+
 static Common::Rect quickTipsExitRect() {
 	return Common::Rect(180, 280, 238, 291);
 }
@@ -105,6 +110,33 @@ static void drawWrappedShadowedText(Graphics::Screen &screen, const Graphics::Fo
 		drawShadowedString(screen, font, lines[i], x, y + i * lineHeight, width, color);
 }
 
+static bool loadPaletteResource(ResourceManager &resources, const Common::String &path, byte *dest) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 256 * 3)
+		return false;
+
+	for (uint i = 0; i < 256 * 3; ++i)
+		dest[i] = expand6BitColor(data[i]);
+
+	return true;
+}
+
+static bool loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 12)
+		return false;
+
+	bitmap.width = READ_LE_UINT32(data.data());
+	bitmap.height = READ_LE_UINT32(data.data() + 4);
+	const uint32 pixelCount = bitmap.width * bitmap.height;
+	if (bitmap.width == 0 || bitmap.height == 0 || data.size() < 12 + pixelCount)
+		return false;
+
+	bitmap.pixels.resize(pixelCount);
+	memcpy(bitmap.pixels.data(), data.data() + 12, pixelCount);
+	return true;
+}
+
 static Common::String trimAsciiLine(const Common::String &value) {
 	uint start = 0;
 	uint end = value.size();
@@ -289,9 +321,22 @@ Common::Error StartupFlow::runMainMenuStub() {
 					if (_menuItems[selectedItem].equalsIgnoreCase("QUIT GAME"))
 						return Common::kNoError;
 
-					statusMessage = Common::String::format("%s is stubbed; room setup and gameplay loop are next.",
-						_menuItems[selectedItem].c_str());
-					needsRedraw = true;
+					if (_menuItems[selectedItem].equalsIgnoreCase("NEW GAME")) {
+						Common::Error roomError = runRoomSetupStub("START");
+						if (roomError.getCode() == Common::kReadingFailed) {
+							statusMessage = "Unable to resolve START room setup from HARVEST.SCR.";
+							needsRedraw = true;
+						} else if (roomError.getCode() != Common::kNoError) {
+							return roomError;
+						} else {
+							statusMessage = "Resolved START room handoff from HARVEST.SCR.";
+							needsRedraw = true;
+						}
+					} else {
+						statusMessage = Common::String::format("%s is stubbed; room setup and gameplay loop are next.",
+							_menuItems[selectedItem].c_str());
+						needsRedraw = true;
+					}
 				}
 				break;
 			case Common::EVENT_LBUTTONDOWN:
@@ -305,10 +350,102 @@ Common::Error StartupFlow::runMainMenuStub() {
 				if (_menuItems[selectedItem].equalsIgnoreCase("QUIT GAME"))
 					return Common::kNoError;
 
-				statusMessage = Common::String::format("%s is stubbed; room setup and gameplay loop are next.",
-					_menuItems[selectedItem].c_str());
+				if (_menuItems[selectedItem].equalsIgnoreCase("NEW GAME")) {
+					Common::Error roomError = runRoomSetupStub("START");
+					if (roomError.getCode() == Common::kReadingFailed) {
+						statusMessage = "Unable to resolve START room setup from HARVEST.SCR.";
+						needsRedraw = true;
+					} else if (roomError.getCode() != Common::kNoError) {
+						return roomError;
+					} else {
+						statusMessage = "Resolved START room handoff from HARVEST.SCR.";
+						needsRedraw = true;
+					}
+				} else {
+					statusMessage = Common::String::format("%s is stubbed; room setup and gameplay loop are next.",
+						_menuItems[selectedItem].c_str());
+					needsRedraw = true;
+				}
+				break;
+			default:
+				break;
+			}
+		}
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName) {
+	Common::String roomName;
+	Common::String palettePath;
+	Common::String backgroundPath;
+	if (!_engine.getStartupScript()->resolveRoomSetup(entranceName, roomName, palettePath, backgroundPath))
+		return Common::kReadingFailed;
+
+	byte palette[256 * 3] = { 0 };
+	IndexedBitmap background;
+	if (!loadPaletteResource(*_engine.getResources(), palettePath, palette) ||
+		!loadBitmapResource(*_engine.getResources(), backgroundPath, background)) {
+		return Common::kReadingFailed;
+	}
+
+	Graphics::Screen *screen = _engine.getScreen();
+	const StartupArt *art = _engine.getStartupArt();
+	const Graphics::Font *titleFont = FontMan.getFontByUsage(Graphics::FontManager::kBigGUIFont);
+	const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	if (!screen || !art || !titleFont || !bodyFont)
+		return Common::kNoError;
+
+	const Common::String statusMessage = Common::String::format(
+		"Resolved %s -> %s using %s and %s. Room setup is still stubbed.",
+		entranceName.c_str(), roomName.c_str(), palettePath.c_str(), backgroundPath.c_str());
+	bool needsRedraw = true;
+	Graphics::FrameLimiter limiter(g_system, 60);
+
+	while (!_engine.shouldQuit()) {
+		if (needsRedraw) {
+			screen->setPalette(palette);
+			screen->fillRect(screen->getBounds(), 0);
+			blitBitmap(*screen, background, 0, 0);
+
+			const Common::Rect panel(72, 336, 568, 468);
+			screen->fillRect(panel, kPanelFillColor);
+			drawShadowedString(*screen, *titleFont, Common::String::format("Room Setup Stub: %s", roomName.c_str()),
+				panel.left, 348, panel.width(), kTextColorNormal, Graphics::kTextAlignCenter);
+			drawWrappedShadowedText(*screen, *bodyFont, statusMessage, panel.left + 24, 386, panel.width() - 48,
+				kTextColorNormal);
+			drawShadowedString(*screen, *bodyFont, "Press Enter, Escape, or click to return to the menu stub.",
+				panel.left, 430, panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
+
+			blitAnimationFrame(*screen, art->getPointerFrames(), 0, _mousePos.x, _mousePos.y);
+			screen->makeAllDirty();
+			screen->update();
+			needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (handleSystemEvent(event, result))
+				return result;
+
+			switch (event.type) {
+			case Common::EVENT_MOUSEMOVE:
 				needsRedraw = true;
 				break;
+			case Common::EVENT_LBUTTONDOWN:
+				return Common::kNoError;
+			case Common::EVENT_KEYDOWN:
+				if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+					event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
+					event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					return Common::kNoError;
+				}
+				break;
 			default:
 				break;
 			}
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/startup_flow.h
index e8b8ff59484..b4bae15e8eb 100644
--- a/engines/harvester/startup_flow.h
+++ b/engines/harvester/startup_flow.h
@@ -47,6 +47,7 @@ private:
 	bool loadMenuItems();
 	Common::Error runQuickTips();
 	Common::Error runMainMenuStub();
+	Common::Error runRoomSetupStub(const Common::String &entranceName);
 	void renderQuickTipsScreen(const Common::String &tipText) const;
 	void renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const;
 	bool handleSystemEvent(const Common::Event &event, Common::Error &result);
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 88483b1611c..83e6ff824ec 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -33,9 +33,51 @@ static const char *const kConfigSectionName = "harvester";
 static const char *const kDefaultTownScript = "HARVEST.SCR";
 static const byte kTownScriptXorKey = 0xaa;
 
+static Common::String trimAsciiLine(const Common::String &value) {
+	uint start = 0;
+	uint end = value.size();
+
+	while (start < end && (value[start] == ' ' || value[start] == '\t'))
+		++start;
+	while (end > start && (value[end - 1] == ' ' || value[end - 1] == '\t' || value[end - 1] == '\r'))
+		--end;
+
+	return value.substr(start, end - start);
+}
+
+static void tokenizeTownScriptLine(const Common::String &line, Common::Array<Common::String> &tokens) {
+	tokens.clear();
+
+	for (uint i = 0; i < line.size();) {
+		while (i < line.size() && (line[i] == ' ' || line[i] == '\t'))
+			++i;
+		if (i >= line.size())
+			break;
+
+		if (line[i] == '"') {
+			++i;
+			Common::String token;
+			while (i < line.size() && line[i] != '"')
+				token += line[i++];
+			if (i < line.size() && line[i] == '"')
+				++i;
+			tokens.push_back(token);
+			continue;
+		}
+
+		Common::String token;
+		while (i < line.size() && line[i] != ' ' && line[i] != '\t')
+			token += line[i++];
+		tokens.push_back(token);
+	}
+}
+
 bool StartupScript::load(ResourceManager &resources) {
 	_path = kDefaultTownScript;
 	_data.clear();
+	_entrances.clear();
+	_rooms.clear();
+	_objects.clear();
 	_quickTipsEnabled = true;
 
 	loadConfig(resources);
@@ -46,6 +88,7 @@ bool StartupScript::load(ResourceManager &resources) {
 	}
 
 	decode();
+	parseTownRecords(resources);
 	debug(1, "Harvester: loaded startup script '%s' (%u bytes)", _path.c_str(), (uint)_data.size());
 	return true;
 }
@@ -88,4 +131,144 @@ void StartupScript::decode() {
 	}
 }
 
+void StartupScript::parseTownRecords(ResourceManager &resources) {
+	_entrances.clear();
+	_rooms.clear();
+	_objects.clear();
+
+	auto parseLine = [&](const Common::String &rawLine) {
+		const Common::String line = trimAsciiLine(rawLine);
+		if (line.empty() || line[0] == '{')
+			return;
+
+		Common::Array<Common::String> tokens;
+		tokenizeTownScriptLine(line, tokens);
+		if (tokens.empty())
+			return;
+
+		uint tagIndex = tokens.size();
+		for (uint i = 0; i < tokens.size(); ++i) {
+			if (tokens[i] == "ENTRANCE" || tokens[i] == "ROOM" || tokens[i] == "OBJECT") {
+				tagIndex = i;
+				break;
+			}
+		}
+		if (tagIndex == tokens.size())
+			return;
+
+		const Common::String &tag = tokens[tagIndex];
+		if (tag == "ENTRANCE") {
+			if (tokens.size() < tagIndex + 4)
+				return;
+
+			StartupEntranceRecord entrance;
+			entrance.direction = tokens[tagIndex + 1];
+			entrance.roomName = tokens[tagIndex + 2];
+			entrance.entranceName = tokens[tagIndex + 3];
+			if (!entrance.roomName.empty() && !entrance.entranceName.empty())
+				_entrances.push_back(entrance);
+			return;
+		}
+
+		if (tag == "ROOM") {
+			if (tokens.size() < tagIndex + 10)
+				return;
+
+			StartupRoomRecord room;
+			room.roomName = tokens[tagIndex + 1];
+			room.palettePath = resources.normalizeResourcePath(tokens[tagIndex + 6]);
+			room.dimmable = tokens[tagIndex + 7].equalsIgnoreCase("T");
+			room.onEnterCommand = tokens[tagIndex + 8];
+			room.onExitCommand = tokens[tagIndex + 9];
+			if (!room.roomName.empty())
+				_rooms.push_back(room);
+			return;
+		}
+
+		if (tokens.size() < tagIndex + 13)
+			return;
+
+		StartupObjectRecord object;
+		object.ownerOrRoom = tokens[tagIndex + 1];
+		object.objectName = tokens[tagIndex + 2];
+		object.resourcePath = resources.normalizeResourcePath(tokens[tagIndex + 3]);
+		object.visible = tokens[tagIndex + 9].equalsIgnoreCase("T");
+		object.active = tokens[tagIndex + 10].equalsIgnoreCase("T");
+		object.identTextKey = tokens[tagIndex + 11];
+		object.displayName = tokens[tagIndex + 12];
+		if (!object.ownerOrRoom.empty() && !object.objectName.empty())
+			_objects.push_back(object);
+	};
+
+	Common::String line;
+	for (uint i = 0; i < _data.size(); ++i) {
+		const char ch = (char)_data[i];
+		if (ch == '\r')
+			continue;
+		if (ch == '\n') {
+			parseLine(line);
+			line.clear();
+			continue;
+		}
+
+		line += ch;
+	}
+
+	parseLine(line);
+
+	debug(1, "Harvester: parsed %u entrances, %u rooms, %u objects from '%s'",
+		(uint)_entrances.size(), (uint)_rooms.size(), (uint)_objects.size(), _path.c_str());
+}
+
+bool StartupScript::resolveRoomSetup(const Common::String &entranceName, Common::String &roomName,
+		Common::String &palettePath, Common::String &backgroundPath) const {
+	roomName.clear();
+	palettePath.clear();
+	backgroundPath.clear();
+
+	const StartupEntranceRecord *entrance = nullptr;
+	for (const StartupEntranceRecord &candidate : _entrances) {
+		if (candidate.entranceName.equalsIgnoreCase(entranceName)) {
+			entrance = &candidate;
+			break;
+		}
+	}
+
+	Common::String resolvedRoomName;
+	if (entrance)
+		resolvedRoomName = entrance->roomName;
+	else
+		resolvedRoomName = entranceName;
+
+	const StartupRoomRecord *room = nullptr;
+	for (const StartupRoomRecord &candidate : _rooms) {
+		if (candidate.roomName.equalsIgnoreCase(resolvedRoomName)) {
+			room = &candidate;
+			break;
+		}
+	}
+	if (!room)
+		return false;
+
+	const StartupObjectRecord *background = nullptr;
+	for (const StartupObjectRecord &candidate : _objects) {
+		if (!candidate.ownerOrRoom.equalsIgnoreCase(room->roomName) ||
+			!candidate.objectName.equalsIgnoreCase(room->roomName) ||
+			!candidate.resourcePath.hasPrefixIgnoreCase("GRAPHIC/ROOMS/") ||
+			!candidate.resourcePath.hasSuffixIgnoreCase(".BM")) {
+			continue;
+		}
+
+		background = &candidate;
+		break;
+	}
+	if (!background)
+		return false;
+
+	roomName = room->roomName;
+	palettePath = room->palettePath;
+	backgroundPath = background->resourcePath;
+	return true;
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 7dff6bda621..5bd4da2a33b 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -29,21 +29,54 @@ namespace Harvester {
 
 class ResourceManager;
 
+struct StartupEntranceRecord {
+	Common::String direction;
+	Common::String roomName;
+	Common::String entranceName;
+};
+
+struct StartupRoomRecord {
+	Common::String roomName;
+	Common::String palettePath;
+	Common::String onEnterCommand;
+	Common::String onExitCommand;
+	bool dimmable = false;
+};
+
+struct StartupObjectRecord {
+	Common::String ownerOrRoom;
+	Common::String objectName;
+	Common::String resourcePath;
+	Common::String identTextKey;
+	Common::String displayName;
+	bool visible = false;
+	bool active = false;
+};
+
 class StartupScript {
 public:
 	bool load(ResourceManager &resources);
 
 	const Common::String &getPath() const { return _path; }
 	const Common::Array<byte> &getData() const { return _data; }
+	const Common::Array<StartupEntranceRecord> &getEntrances() const { return _entrances; }
+	const Common::Array<StartupRoomRecord> &getRooms() const { return _rooms; }
+	const Common::Array<StartupObjectRecord> &getObjects() const { return _objects; }
 	bool isQuickTipsEnabled() const { return _quickTipsEnabled; }
 	void setQuickTipsEnabled(bool enabled) { _quickTipsEnabled = enabled; }
+	bool resolveRoomSetup(const Common::String &entranceName, Common::String &roomName,
+		Common::String &palettePath, Common::String &backgroundPath) const;
 
 private:
 	bool loadConfig(ResourceManager &resources);
 	void decode();
+	void parseTownRecords(ResourceManager &resources);
 
 	Common::String _path;
 	Common::Array<byte> _data;
+	Common::Array<StartupEntranceRecord> _entrances;
+	Common::Array<StartupRoomRecord> _rooms;
+	Common::Array<StartupObjectRecord> _objects;
 	bool _quickTipsEnabled = true;
 };
 


Commit: 8995ee1136a266989daf8700d35fd8abdeefdb59
    https://github.com/scummvm/scummvm/commit/8995ee1136a266989daf8700d35fd8abdeefdb59
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:33+02:00

Commit Message:
HARVESTER: Resolve startup room command chains

Changed paths:
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index ac1aefd0255..97205c51b44 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -380,19 +380,30 @@ Common::Error StartupFlow::runMainMenuStub() {
 }
 
 Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName) {
-	Common::String roomName;
-	Common::String palettePath;
-	Common::String backgroundPath;
-	if (!_engine.getStartupScript()->resolveRoomSetup(entranceName, roomName, palettePath, backgroundPath))
+	StartupRoomSetupState state;
+	if (!_engine.getStartupScript()->resolveRoomSetupState(entranceName, state, *_engine.getResources()))
 		return Common::kReadingFailed;
 
 	byte palette[256 * 3] = { 0 };
 	IndexedBitmap background;
-	if (!loadPaletteResource(*_engine.getResources(), palettePath, palette) ||
-		!loadBitmapResource(*_engine.getResources(), backgroundPath, background)) {
+	if (!loadPaletteResource(*_engine.getResources(), state.palettePath, palette) ||
+		!loadBitmapResource(*_engine.getResources(), state.backgroundPath, background)) {
 		return Common::kReadingFailed;
 	}
 
+	struct OverlayBitmap {
+		StartupObjectRecord object;
+		IndexedBitmap bitmap;
+	};
+	Common::Array<OverlayBitmap> overlays;
+	for (const StartupObjectRecord &object : state.activeObjects) {
+		OverlayBitmap overlay;
+		overlay.object = object;
+		if (!loadBitmapResource(*_engine.getResources(), object.resourcePath, overlay.bitmap))
+			continue;
+		overlays.push_back(overlay);
+	}
+
 	Graphics::Screen *screen = _engine.getScreen();
 	const StartupArt *art = _engine.getStartupArt();
 	const Graphics::Font *titleFont = FontMan.getFontByUsage(Graphics::FontManager::kBigGUIFont);
@@ -400,9 +411,14 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 	if (!screen || !art || !titleFont || !bodyFont)
 		return Common::kNoError;
 
-	const Common::String statusMessage = Common::String::format(
-		"Resolved %s -> %s using %s and %s. Room setup is still stubbed.",
-		entranceName.c_str(), roomName.c_str(), palettePath.c_str(), backgroundPath.c_str());
+	Common::String statusMessage = Common::String::format(
+		"Resolved %s -> %s using %s and %s.",
+		entranceName.c_str(), state.roomName.c_str(), state.palettePath.c_str(), state.backgroundPath.c_str());
+	if (!state.musicPath.empty())
+		statusMessage += Common::String::format(" Music: %s.", state.musicPath.c_str());
+	if (!overlays.empty())
+		statusMessage += Common::String::format(" Startup overlays: %u.", (uint)overlays.size());
+	statusMessage += " Room setup is still stubbed.";
 	bool needsRedraw = true;
 	Graphics::FrameLimiter limiter(g_system, 60);
 
@@ -411,10 +427,12 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 			screen->setPalette(palette);
 			screen->fillRect(screen->getBounds(), 0);
 			blitBitmap(*screen, background, 0, 0);
+			for (const OverlayBitmap &overlay : overlays)
+				blitBitmap(*screen, overlay.bitmap, overlay.object.x, overlay.object.y);
 
 			const Common::Rect panel(72, 336, 568, 468);
 			screen->fillRect(panel, kPanelFillColor);
-			drawShadowedString(*screen, *titleFont, Common::String::format("Room Setup Stub: %s", roomName.c_str()),
+			drawShadowedString(*screen, *titleFont, Common::String::format("Room Setup Stub: %s", state.roomName.c_str()),
 				panel.left, 348, panel.width(), kTextColorNormal, Graphics::kTextAlignCenter);
 			drawWrappedShadowedText(*screen, *bodyFont, statusMessage, panel.left + 24, 386, panel.width() - 48,
 				kTextColorNormal);
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 83e6ff824ec..21ae3e7faad 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -21,6 +21,8 @@
 
 #include "harvester/startup_script.h"
 
+#include <cstdlib>
+
 #include "common/debug.h"
 #include "common/formats/ini-file.h"
 #include "common/memstream.h"
@@ -78,6 +80,8 @@ bool StartupScript::load(ResourceManager &resources) {
 	_entrances.clear();
 	_rooms.clear();
 	_objects.clear();
+	_flags.clear();
+	_commands.clear();
 	_quickTipsEnabled = true;
 
 	loadConfig(resources);
@@ -135,6 +139,8 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 	_entrances.clear();
 	_rooms.clear();
 	_objects.clear();
+	_flags.clear();
+	_commands.clear();
 
 	auto parseLine = [&](const Common::String &rawLine) {
 		const Common::String line = trimAsciiLine(rawLine);
@@ -148,7 +154,8 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 
 		uint tagIndex = tokens.size();
 		for (uint i = 0; i < tokens.size(); ++i) {
-			if (tokens[i] == "ENTRANCE" || tokens[i] == "ROOM" || tokens[i] == "OBJECT") {
+			if (tokens[i] == "ENTRANCE" || tokens[i] == "ROOM" || tokens[i] == "OBJECT" ||
+				tokens[i] == "FLAG" || tokens[i] == "COMMAND") {
 				tagIndex = i;
 				break;
 			}
@@ -157,6 +164,44 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 			return;
 
 		const Common::String &tag = tokens[tagIndex];
+		if (tag == "FLAG") {
+			if (tokens.size() < tagIndex + 3)
+				return;
+
+			StartupFlagRecord flag;
+			flag.name = tokens[tagIndex + 1];
+			flag.value = tokens[tagIndex + 2].equalsIgnoreCase("T");
+			if (!flag.name.empty())
+				_flags.push_back(flag);
+			return;
+		}
+
+		if (tag == "COMMAND") {
+			if (tokens.size() < tagIndex + 6)
+				return;
+
+			StartupCommandRecord command;
+			command.triggerTag = tokens[tagIndex + 1];
+			command.opcodeName = tokens[tagIndex + 2];
+			command.arg1 = resources.normalizeResourcePath(tokens[tagIndex + 3]);
+			command.arg2 = tokens[tagIndex + 4];
+			command.arg3 = tokens[tagIndex + 5];
+			command.arg4 = tokens.size() > tagIndex + 6 ? tokens[tagIndex + 6] : Common::String();
+
+			// Only path-like operands should be normalized.
+			if (!command.opcodeName.equalsIgnoreCase("SPOOL_MUSIC") &&
+				!command.opcodeName.equalsIgnoreCase("GOFLIC") &&
+				!command.opcodeName.equalsIgnoreCase("START_WAV") &&
+				!command.opcodeName.equalsIgnoreCase("LOAD_WAV") &&
+				!command.opcodeName.equalsIgnoreCase("START_SINGLE_WAV")) {
+				command.arg1 = tokens[tagIndex + 3];
+			}
+
+			if (!command.triggerTag.empty() && !command.opcodeName.empty())
+				_commands.push_back(command);
+			return;
+		}
+
 		if (tag == "ENTRANCE") {
 			if (tokens.size() < tagIndex + 4)
 				return;
@@ -189,6 +234,10 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 			return;
 
 		StartupObjectRecord object;
+		if (tagIndex >= 2) {
+			object.x = atoi(tokens[0].c_str());
+			object.y = atoi(tokens[1].c_str());
+		}
 		object.ownerOrRoom = tokens[tagIndex + 1];
 		object.objectName = tokens[tagIndex + 2];
 		object.resourcePath = resources.normalizeResourcePath(tokens[tagIndex + 3]);
@@ -216,15 +265,14 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 
 	parseLine(line);
 
-	debug(1, "Harvester: parsed %u entrances, %u rooms, %u objects from '%s'",
-		(uint)_entrances.size(), (uint)_rooms.size(), (uint)_objects.size(), _path.c_str());
+	debug(1, "Harvester: parsed %u entrances, %u rooms, %u objects, %u flags, %u commands from '%s'",
+		(uint)_entrances.size(), (uint)_rooms.size(), (uint)_objects.size(),
+		(uint)_flags.size(), (uint)_commands.size(), _path.c_str());
 }
 
-bool StartupScript::resolveRoomSetup(const Common::String &entranceName, Common::String &roomName,
-		Common::String &palettePath, Common::String &backgroundPath) const {
-	roomName.clear();
-	palettePath.clear();
-	backgroundPath.clear();
+bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
+		ResourceManager &resources) const {
+	state = StartupRoomSetupState();
 
 	const StartupEntranceRecord *entrance = nullptr;
 	for (const StartupEntranceRecord &candidate : _entrances) {
@@ -265,9 +313,111 @@ bool StartupScript::resolveRoomSetup(const Common::String &entranceName, Common:
 	if (!background)
 		return false;
 
-	roomName = room->roomName;
-	palettePath = room->palettePath;
-	backgroundPath = background->resourcePath;
+	state.roomName = room->roomName;
+	state.palettePath = room->palettePath;
+	state.backgroundPath = background->resourcePath;
+
+	Common::Array<StartupFlagRecord> resolvedFlags = _flags;
+	auto getFlagValue = [&](const Common::String &flagName) {
+		for (const StartupFlagRecord &flag : resolvedFlags) {
+			if (flag.name.equalsIgnoreCase(flagName))
+				return flag.value;
+		}
+		return false;
+	};
+	auto setFlagValue = [&](const Common::String &flagName, bool value) {
+		for (StartupFlagRecord &flag : resolvedFlags) {
+			if (flag.name.equalsIgnoreCase(flagName)) {
+				flag.value = value;
+				return;
+			}
+		}
+
+		StartupFlagRecord flag;
+		flag.name = flagName;
+		flag.value = value;
+		resolvedFlags.push_back(flag);
+	};
+	auto addObject = [&](const Common::String &ownerOrRoom, const Common::String &objectName) {
+		for (const StartupObjectRecord &candidate : _objects) {
+			if (!candidate.ownerOrRoom.equalsIgnoreCase(ownerOrRoom) ||
+				!candidate.objectName.equalsIgnoreCase(objectName) ||
+				candidate.resourcePath.empty() ||
+				!candidate.resourcePath.hasSuffixIgnoreCase(".BM")) {
+				continue;
+			}
+
+			for (const StartupObjectRecord &activeObject : state.activeObjects) {
+				if (activeObject.ownerOrRoom.equalsIgnoreCase(candidate.ownerOrRoom) &&
+					activeObject.objectName.equalsIgnoreCase(candidate.objectName)) {
+					return;
+				}
+			}
+
+			state.activeObjects.push_back(candidate);
+			return;
+		}
+	};
+	auto removeObject = [&](const Common::String &ownerOrRoom, const Common::String &objectName) {
+		for (uint i = 0; i < state.activeObjects.size(); ++i) {
+			if (state.activeObjects[i].ownerOrRoom.equalsIgnoreCase(ownerOrRoom) &&
+				state.activeObjects[i].objectName.equalsIgnoreCase(objectName)) {
+				state.activeObjects.remove_at(i);
+				return;
+			}
+		}
+	};
+	auto findCommand = [&](const Common::String &tag) -> const StartupCommandRecord * {
+		for (const StartupCommandRecord &command : _commands) {
+			if (command.triggerTag.equalsIgnoreCase(tag))
+				return &command;
+		}
+		return nullptr;
+	};
+
+	Common::String currentTag = room->onEnterCommand;
+	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
+		const StartupCommandRecord *command = findCommand(currentTag);
+		if (!command) {
+			debug(1, "Harvester: unresolved startup command tag '%s' for room '%s'",
+				currentTag.c_str(), room->roomName.c_str());
+			break;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
+			currentTag = getFlagValue(command->arg1) ? command->arg2 : command->arg3;
+			continue;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("SET_FLAG")) {
+			setFlagValue(command->arg1, command->arg2.equalsIgnoreCase("T"));
+			currentTag = command->arg4;
+			continue;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("SPOOL_MUSIC")) {
+			state.musicPath = resources.normalizeResourcePath(command->arg1);
+			currentTag = command->arg4;
+			continue;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("ADD")) {
+			addObject(command->arg1, command->arg2);
+			currentTag = command->arg4;
+			continue;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("DELETE")) {
+			removeObject(command->arg1, command->arg2);
+			currentTag = command->arg4;
+			continue;
+		}
+
+		debug(1, "Harvester: unsupported startup command '%s' for tag '%s'",
+			command->opcodeName.c_str(), command->triggerTag.c_str());
+		break;
+	}
+
 	return true;
 }
 
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 5bd4da2a33b..e7fab3de2cf 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -44,6 +44,8 @@ struct StartupRoomRecord {
 };
 
 struct StartupObjectRecord {
+	int x = 0;
+	int y = 0;
 	Common::String ownerOrRoom;
 	Common::String objectName;
 	Common::String resourcePath;
@@ -53,6 +55,28 @@ struct StartupObjectRecord {
 	bool active = false;
 };
 
+struct StartupFlagRecord {
+	Common::String name;
+	bool value = false;
+};
+
+struct StartupCommandRecord {
+	Common::String triggerTag;
+	Common::String opcodeName;
+	Common::String arg1;
+	Common::String arg2;
+	Common::String arg3;
+	Common::String arg4;
+};
+
+struct StartupRoomSetupState {
+	Common::String roomName;
+	Common::String palettePath;
+	Common::String backgroundPath;
+	Common::String musicPath;
+	Common::Array<StartupObjectRecord> activeObjects;
+};
+
 class StartupScript {
 public:
 	bool load(ResourceManager &resources);
@@ -62,10 +86,12 @@ public:
 	const Common::Array<StartupEntranceRecord> &getEntrances() const { return _entrances; }
 	const Common::Array<StartupRoomRecord> &getRooms() const { return _rooms; }
 	const Common::Array<StartupObjectRecord> &getObjects() const { return _objects; }
+	const Common::Array<StartupFlagRecord> &getFlags() const { return _flags; }
+	const Common::Array<StartupCommandRecord> &getCommands() const { return _commands; }
 	bool isQuickTipsEnabled() const { return _quickTipsEnabled; }
 	void setQuickTipsEnabled(bool enabled) { _quickTipsEnabled = enabled; }
-	bool resolveRoomSetup(const Common::String &entranceName, Common::String &roomName,
-		Common::String &palettePath, Common::String &backgroundPath) const;
+	bool resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
+		ResourceManager &resources) const;
 
 private:
 	bool loadConfig(ResourceManager &resources);
@@ -77,6 +103,8 @@ private:
 	Common::Array<StartupEntranceRecord> _entrances;
 	Common::Array<StartupRoomRecord> _rooms;
 	Common::Array<StartupObjectRecord> _objects;
+	Common::Array<StartupFlagRecord> _flags;
+	Common::Array<StartupCommandRecord> _commands;
 	bool _quickTipsEnabled = true;
 };
 


Commit: 592e701aeeab5adec60ddedd6cc882b8bddb3e7c
    https://github.com/scummvm/scummvm/commit/592e701aeeab5adec60ddedd6cc882b8bddb3e7c
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:34+02:00

Commit Message:
HARVESTER: Show startup room hotspot text

Changed paths:
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 97205c51b44..c852f714553 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -137,6 +137,13 @@ static bool loadBitmapResource(ResourceManager &resources, const Common::String
 	return true;
 }
 
+static Common::Rect getObjectBounds(const StartupObjectRecord &object) {
+	if (object.right > object.left && object.bottom > object.top)
+		return Common::Rect(object.left, object.top, object.right, object.bottom);
+
+	return Common::Rect();
+}
+
 static Common::String trimAsciiLine(const Common::String &value) {
 	uint start = 0;
 	uint end = value.size();
@@ -428,13 +435,24 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 			screen->fillRect(screen->getBounds(), 0);
 			blitBitmap(*screen, background, 0, 0);
 			for (const OverlayBitmap &overlay : overlays)
-				blitBitmap(*screen, overlay.bitmap, overlay.object.x, overlay.object.y);
+				blitBitmap(*screen, overlay.bitmap, overlay.object.left, overlay.object.top);
 
 			const Common::Rect panel(72, 336, 568, 468);
 			screen->fillRect(panel, kPanelFillColor);
 			drawShadowedString(*screen, *titleFont, Common::String::format("Room Setup Stub: %s", state.roomName.c_str()),
 				panel.left, 348, panel.width(), kTextColorNormal, Graphics::kTextAlignCenter);
-			drawWrappedShadowedText(*screen, *bodyFont, statusMessage, panel.left + 24, 386, panel.width() - 48,
+			Common::String hoverMessage;
+			for (const StartupObjectRecord &object : state.roomObjects) {
+				const Common::Rect bounds = getObjectBounds(object);
+				if (!bounds.isEmpty() && bounds.contains(_mousePos)) {
+					hoverMessage = _engine.getStartupScript()->resolveObjectLabel(object);
+					break;
+				}
+			}
+			if (hoverMessage.empty())
+				hoverMessage = statusMessage;
+
+			drawWrappedShadowedText(*screen, *bodyFont, hoverMessage, panel.left + 24, 386, panel.width() - 48,
 				kTextColorNormal);
 			drawShadowedString(*screen, *bodyFont, "Press Enter, Escape, or click to return to the menu stub.",
 				panel.left, 430, panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 21ae3e7faad..597c24fea68 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -82,6 +82,7 @@ bool StartupScript::load(ResourceManager &resources) {
 	_objects.clear();
 	_flags.clear();
 	_commands.clear();
+	_texts.clear();
 	_quickTipsEnabled = true;
 
 	loadConfig(resources);
@@ -141,6 +142,7 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 	_objects.clear();
 	_flags.clear();
 	_commands.clear();
+	_texts.clear();
 
 	auto parseLine = [&](const Common::String &rawLine) {
 		const Common::String line = trimAsciiLine(rawLine);
@@ -155,7 +157,7 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 		uint tagIndex = tokens.size();
 		for (uint i = 0; i < tokens.size(); ++i) {
 			if (tokens[i] == "ENTRANCE" || tokens[i] == "ROOM" || tokens[i] == "OBJECT" ||
-				tokens[i] == "FLAG" || tokens[i] == "COMMAND") {
+				tokens[i] == "FLAG" || tokens[i] == "COMMAND" || tokens[i] == "TEXT") {
 				tagIndex = i;
 				break;
 			}
@@ -202,6 +204,23 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 			return;
 		}
 
+		if (tag == "TEXT") {
+			if (tokens.size() < tagIndex + 4)
+				return;
+
+			StartupTextRecord textRecord;
+			textRecord.key = tokens[tagIndex + 1];
+			textRecord.boxName = tokens[tagIndex + 2];
+			textRecord.value = tokens[tagIndex + 3];
+			for (uint i = 0; i < textRecord.value.size(); ++i) {
+				if (textRecord.value[i] == '_')
+					textRecord.value.setChar(' ', i);
+			}
+			if (!textRecord.key.empty())
+				_texts.push_back(textRecord);
+			return;
+		}
+
 		if (tag == "ENTRANCE") {
 			if (tokens.size() < tagIndex + 4)
 				return;
@@ -234,9 +253,11 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 			return;
 
 		StartupObjectRecord object;
-		if (tagIndex >= 2) {
-			object.x = atoi(tokens[0].c_str());
-			object.y = atoi(tokens[1].c_str());
+		if (tagIndex >= 4) {
+			object.left = atoi(tokens[0].c_str());
+			object.top = atoi(tokens[1].c_str());
+			object.right = atoi(tokens[2].c_str());
+			object.bottom = atoi(tokens[3].c_str());
 		}
 		object.ownerOrRoom = tokens[tagIndex + 1];
 		object.objectName = tokens[tagIndex + 2];
@@ -265,9 +286,9 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 
 	parseLine(line);
 
-	debug(1, "Harvester: parsed %u entrances, %u rooms, %u objects, %u flags, %u commands from '%s'",
+	debug(1, "Harvester: parsed %u entrances, %u rooms, %u objects, %u flags, %u commands, %u texts from '%s'",
 		(uint)_entrances.size(), (uint)_rooms.size(), (uint)_objects.size(),
-		(uint)_flags.size(), (uint)_commands.size(), _path.c_str());
+		(uint)_flags.size(), (uint)_commands.size(), (uint)_texts.size(), _path.c_str());
 }
 
 bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
@@ -316,6 +337,10 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 	state.roomName = room->roomName;
 	state.palettePath = room->palettePath;
 	state.backgroundPath = background->resourcePath;
+	for (const StartupObjectRecord &object : _objects) {
+		if (object.ownerOrRoom.equalsIgnoreCase(room->roomName))
+			state.roomObjects.push_back(object);
+	}
 
 	Common::Array<StartupFlagRecord> resolvedFlags = _flags;
 	auto getFlagValue = [&](const Common::String &flagName) {
@@ -421,4 +446,20 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 	return true;
 }
 
+Common::String StartupScript::resolveObjectLabel(const StartupObjectRecord &object) const {
+	if (!object.identTextKey.empty()) {
+		for (const StartupTextRecord &textRecord : _texts) {
+			if (textRecord.key.equalsIgnoreCase(object.identTextKey))
+				return textRecord.value;
+		}
+	}
+
+	Common::String label = object.displayName;
+	for (uint i = 0; i < label.size(); ++i) {
+		if (label[i] == '_')
+			label.setChar(' ', i);
+	}
+	return label;
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index e7fab3de2cf..bf09f43ce4b 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -44,8 +44,10 @@ struct StartupRoomRecord {
 };
 
 struct StartupObjectRecord {
-	int x = 0;
-	int y = 0;
+	int left = 0;
+	int top = 0;
+	int right = 0;
+	int bottom = 0;
 	Common::String ownerOrRoom;
 	Common::String objectName;
 	Common::String resourcePath;
@@ -69,12 +71,19 @@ struct StartupCommandRecord {
 	Common::String arg4;
 };
 
+struct StartupTextRecord {
+	Common::String key;
+	Common::String boxName;
+	Common::String value;
+};
+
 struct StartupRoomSetupState {
 	Common::String roomName;
 	Common::String palettePath;
 	Common::String backgroundPath;
 	Common::String musicPath;
 	Common::Array<StartupObjectRecord> activeObjects;
+	Common::Array<StartupObjectRecord> roomObjects;
 };
 
 class StartupScript {
@@ -88,10 +97,12 @@ public:
 	const Common::Array<StartupObjectRecord> &getObjects() const { return _objects; }
 	const Common::Array<StartupFlagRecord> &getFlags() const { return _flags; }
 	const Common::Array<StartupCommandRecord> &getCommands() const { return _commands; }
+	const Common::Array<StartupTextRecord> &getTexts() const { return _texts; }
 	bool isQuickTipsEnabled() const { return _quickTipsEnabled; }
 	void setQuickTipsEnabled(bool enabled) { _quickTipsEnabled = enabled; }
 	bool resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
 		ResourceManager &resources) const;
+	Common::String resolveObjectLabel(const StartupObjectRecord &object) const;
 
 private:
 	bool loadConfig(ResourceManager &resources);
@@ -105,6 +116,7 @@ private:
 	Common::Array<StartupObjectRecord> _objects;
 	Common::Array<StartupFlagRecord> _flags;
 	Common::Array<StartupCommandRecord> _commands;
+	Common::Array<StartupTextRecord> _texts;
 	bool _quickTipsEnabled = true;
 };
 


Commit: 2ffc2b1465435f7396538050162bf7babfcc2d83
    https://github.com/scummvm/scummvm/commit/2ffc2b1465435f7396538050162bf7babfcc2d83
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:34+02:00

Commit Message:
HARVESTER: Follow startup hotspot closeup commands

Changed paths:
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index c852f714553..2ba4732f517 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -144,6 +144,20 @@ static Common::Rect getObjectBounds(const StartupObjectRecord &object) {
 	return Common::Rect();
 }
 
+static const StartupObjectRecord *findRoomObjectAtPoint(const StartupRoomSetupState &state, const Common::Point &point) {
+	for (int i = (int)state.roomObjects.size() - 1; i >= 0; --i) {
+		const StartupObjectRecord &object = state.roomObjects[(uint)i];
+		if (!object.active)
+			continue;
+
+		const Common::Rect bounds = getObjectBounds(object);
+		if (!bounds.isEmpty() && bounds.contains(point))
+			return &object;
+	}
+
+	return nullptr;
+}
+
 static Common::String trimAsciiLine(const Common::String &value) {
 	uint start = 0;
 	uint end = value.size();
@@ -425,7 +439,7 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 		statusMessage += Common::String::format(" Music: %s.", state.musicPath.c_str());
 	if (!overlays.empty())
 		statusMessage += Common::String::format(" Startup overlays: %u.", (uint)overlays.size());
-	statusMessage += " Room setup is still stubbed.";
+	statusMessage += " Click an active hotspot to follow its startup command chain.";
 	bool needsRedraw = true;
 	Graphics::FrameLimiter limiter(g_system, 60);
 
@@ -442,19 +456,18 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 			drawShadowedString(*screen, *titleFont, Common::String::format("Room Setup Stub: %s", state.roomName.c_str()),
 				panel.left, 348, panel.width(), kTextColorNormal, Graphics::kTextAlignCenter);
 			Common::String hoverMessage;
-			for (const StartupObjectRecord &object : state.roomObjects) {
-				const Common::Rect bounds = getObjectBounds(object);
-				if (!bounds.isEmpty() && bounds.contains(_mousePos)) {
-					hoverMessage = _engine.getStartupScript()->resolveObjectLabel(object);
-					break;
-				}
+			const StartupObjectRecord *hoveredObject = findRoomObjectAtPoint(state, _mousePos);
+			if (hoveredObject) {
+				hoverMessage = _engine.getStartupScript()->resolveObjectLabel(*hoveredObject);
+				if (!hoveredObject->interactionCommandTag.empty())
+					hoverMessage += " Click to follow the scripted startup interaction.";
 			}
 			if (hoverMessage.empty())
 				hoverMessage = statusMessage;
 
 			drawWrappedShadowedText(*screen, *bodyFont, hoverMessage, panel.left + 24, 386, panel.width() - 48,
 				kTextColorNormal);
-			drawShadowedString(*screen, *bodyFont, "Press Enter, Escape, or click to return to the menu stub.",
+			drawShadowedString(*screen, *bodyFont, "Press Enter or Escape to return to the menu stub.",
 				panel.left, 430, panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
 
 			blitAnimationFrame(*screen, art->getPointerFrames(), 0, _mousePos.x, _mousePos.y);
@@ -473,8 +486,43 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 			case Common::EVENT_MOUSEMOVE:
 				needsRedraw = true;
 				break;
-			case Common::EVENT_LBUTTONDOWN:
-				return Common::kNoError;
+			case Common::EVENT_LBUTTONDOWN: {
+				const StartupObjectRecord *clickedObject = findRoomObjectAtPoint(state, _mousePos);
+				if (!clickedObject) {
+					statusMessage = "No active startup hotspot under the cursor.";
+					needsRedraw = true;
+					break;
+				}
+
+				const Common::String objectLabel = _engine.getStartupScript()->resolveObjectLabel(*clickedObject);
+				StartupInteractionResult interaction;
+				if (!_engine.getStartupScript()->resolveObjectInteraction(*clickedObject, interaction)) {
+					statusMessage = Common::String::format("%s has no supported startup interaction yet.",
+						objectLabel.c_str());
+					needsRedraw = true;
+					break;
+				}
+
+				statusMessage = Common::String::format("Following %s.", objectLabel.c_str());
+				if (!interaction.soundPath.empty())
+					statusMessage += Common::String::format(" Sound: %s.", interaction.soundPath.c_str());
+				needsRedraw = true;
+
+				if (!interaction.nextRoomName.empty()) {
+					Common::Error roomError = runRoomSetupStub(interaction.nextRoomName);
+					if (roomError.getCode() == Common::kReadingFailed) {
+						statusMessage = Common::String::format("Unable to resolve closeup room '%s'.",
+							interaction.nextRoomName.c_str());
+						needsRedraw = true;
+					} else if (roomError.getCode() != Common::kNoError) {
+						return roomError;
+					} else {
+						statusMessage = Common::String::format("Returned from %s.", interaction.nextRoomName.c_str());
+						needsRedraw = true;
+					}
+				}
+				break;
+			}
 			case Common::EVENT_KEYDOWN:
 				if (event.kbd.keycode == Common::KEYCODE_RETURN ||
 					event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 597c24fea68..737d8b627ef 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -262,9 +262,10 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 		object.ownerOrRoom = tokens[tagIndex + 1];
 		object.objectName = tokens[tagIndex + 2];
 		object.resourcePath = resources.normalizeResourcePath(tokens[tagIndex + 3]);
+		object.identTextKey = tokens[tagIndex + 8];
 		object.visible = tokens[tagIndex + 9].equalsIgnoreCase("T");
 		object.active = tokens[tagIndex + 10].equalsIgnoreCase("T");
-		object.identTextKey = tokens[tagIndex + 11];
+		object.interactionCommandTag = tokens[tagIndex + 11];
 		object.displayName = tokens[tagIndex + 12];
 		if (!object.ownerOrRoom.empty() && !object.objectName.empty())
 			_objects.push_back(object);
@@ -322,7 +323,6 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 	const StartupObjectRecord *background = nullptr;
 	for (const StartupObjectRecord &candidate : _objects) {
 		if (!candidate.ownerOrRoom.equalsIgnoreCase(room->roomName) ||
-			!candidate.objectName.equalsIgnoreCase(room->roomName) ||
 			!candidate.resourcePath.hasPrefixIgnoreCase("GRAPHIC/ROOMS/") ||
 			!candidate.resourcePath.hasSuffixIgnoreCase(".BM")) {
 			continue;
@@ -446,6 +446,88 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 	return true;
 }
 
+bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result) const {
+	result = StartupInteractionResult();
+	if (object.interactionCommandTag.empty())
+		return false;
+
+	Common::Array<StartupFlagRecord> resolvedFlags = _flags;
+	auto getFlagValue = [&](const Common::String &flagName) {
+		for (const StartupFlagRecord &flag : resolvedFlags) {
+			if (flag.name.equalsIgnoreCase(flagName))
+				return flag.value;
+		}
+		return false;
+	};
+	auto setFlagValue = [&](const Common::String &flagName, bool value) {
+		for (StartupFlagRecord &flag : resolvedFlags) {
+			if (flag.name.equalsIgnoreCase(flagName)) {
+				flag.value = value;
+				return;
+			}
+		}
+
+		StartupFlagRecord flag;
+		flag.name = flagName;
+		flag.value = value;
+		resolvedFlags.push_back(flag);
+	};
+	auto findCommand = [&](const Common::String &tag) -> const StartupCommandRecord * {
+		for (const StartupCommandRecord &command : _commands) {
+			if (command.triggerTag.equalsIgnoreCase(tag))
+				return &command;
+		}
+		return nullptr;
+	};
+
+	Common::String currentTag = object.interactionCommandTag;
+	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
+		const StartupCommandRecord *command = findCommand(currentTag);
+		if (!command) {
+			debug(1, "Harvester: unresolved interaction command tag '%s' for object '%s'",
+				currentTag.c_str(), object.objectName.c_str());
+			break;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
+			currentTag = getFlagValue(command->arg1) ? command->arg2 : command->arg3;
+			continue;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("SET_FLAG")) {
+			setFlagValue(command->arg1, command->arg2.equalsIgnoreCase("T"));
+			currentTag = command->arg4;
+			continue;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("START_WAV") ||
+			command->opcodeName.equalsIgnoreCase("LOAD_WAV") ||
+			command->opcodeName.equalsIgnoreCase("START_SINGLE_WAV")) {
+			if (result.soundPath.empty())
+				result.soundPath = command->arg1;
+			currentTag = command->arg4;
+			continue;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("SPOOL_MUSIC")) {
+			currentTag = command->arg4;
+			continue;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
+			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
+			result.nextRoomName = command->arg1;
+			return !result.nextRoomName.empty() || !result.soundPath.empty();
+		}
+
+		debug(1, "Harvester: unsupported interaction command '%s' for tag '%s'",
+			command->opcodeName.c_str(), command->triggerTag.c_str());
+		break;
+	}
+
+	return !result.nextRoomName.empty() || !result.soundPath.empty();
+}
+
 Common::String StartupScript::resolveObjectLabel(const StartupObjectRecord &object) const {
 	if (!object.identTextKey.empty()) {
 		for (const StartupTextRecord &textRecord : _texts) {
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index bf09f43ce4b..5c12fc31781 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -52,6 +52,7 @@ struct StartupObjectRecord {
 	Common::String objectName;
 	Common::String resourcePath;
 	Common::String identTextKey;
+	Common::String interactionCommandTag;
 	Common::String displayName;
 	bool visible = false;
 	bool active = false;
@@ -86,6 +87,11 @@ struct StartupRoomSetupState {
 	Common::Array<StartupObjectRecord> roomObjects;
 };
 
+struct StartupInteractionResult {
+	Common::String soundPath;
+	Common::String nextRoomName;
+};
+
 class StartupScript {
 public:
 	bool load(ResourceManager &resources);
@@ -102,6 +108,7 @@ public:
 	void setQuickTipsEnabled(bool enabled) { _quickTipsEnabled = enabled; }
 	bool resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
 		ResourceManager &resources) const;
+	bool resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result) const;
 	Common::String resolveObjectLabel(const StartupObjectRecord &object) const;
 
 private:


Commit: d905790d4c2deaae6522743d458d56df8749eecf
    https://github.com/scummvm/scummvm/commit/d905790d4c2deaae6522743d458d56df8749eecf
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:34+02:00

Commit Message:
HARVESTER: Draw visible startup room objects

Changed paths:
    engines/harvester/startup_flow.cpp


diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 2ba4732f517..d2dab00a009 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -417,7 +417,28 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 		IndexedBitmap bitmap;
 	};
 	Common::Array<OverlayBitmap> overlays;
-	for (const StartupObjectRecord &object : state.activeObjects) {
+	Common::Array<StartupObjectRecord> drawableObjects;
+	auto queueDrawableObject = [&](const StartupObjectRecord &object) {
+		if (object.resourcePath.empty() || !object.resourcePath.hasSuffixIgnoreCase(".BM"))
+			return;
+
+		for (const StartupObjectRecord &drawable : drawableObjects) {
+			if (drawable.ownerOrRoom.equalsIgnoreCase(object.ownerOrRoom) &&
+				drawable.objectName.equalsIgnoreCase(object.objectName)) {
+				return;
+			}
+		}
+
+		drawableObjects.push_back(object);
+	};
+	for (const StartupObjectRecord &object : state.roomObjects) {
+		if (object.visible)
+			queueDrawableObject(object);
+	}
+	for (const StartupObjectRecord &object : state.activeObjects)
+		queueDrawableObject(object);
+
+	for (const StartupObjectRecord &object : drawableObjects) {
 		OverlayBitmap overlay;
 		overlay.object = object;
 		if (!loadBitmapResource(*_engine.getResources(), object.resourcePath, overlay.bitmap))
@@ -438,7 +459,7 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 	if (!state.musicPath.empty())
 		statusMessage += Common::String::format(" Music: %s.", state.musicPath.c_str());
 	if (!overlays.empty())
-		statusMessage += Common::String::format(" Startup overlays: %u.", (uint)overlays.size());
+		statusMessage += Common::String::format(" Visible scene objects: %u.", (uint)overlays.size());
 	statusMessage += " Click an active hotspot to follow its startup command chain.";
 	bool needsRedraw = true;
 	Graphics::FrameLimiter limiter(g_system, 60);


Commit: cee4afa25ff155a675bb06f2b2117495d9afd1a5
    https://github.com/scummvm/scummvm/commit/cee4afa25ff155a675bb06f2b2117495d9afd1a5
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:35+02:00

Commit Message:
HARVESTER: Play startup hotspot WAV effects

Changed paths:
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/startup_flow.cpp


diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 87544b9ab28..2dd40d2aec1 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -21,6 +21,8 @@
 
 #include "harvester/harvester.h"
 
+#include "audio/audiostream.h"
+#include "audio/decoders/wave.h"
 #include "common/config-manager.h"
 #include "common/system.h"
 #include "engines/util.h"
@@ -44,6 +46,7 @@ HarvesterEngine::HarvesterEngine(OSystem *syst, const ADGameDescription *gameDes
 }
 
 HarvesterEngine::~HarvesterEngine() {
+	stopStartupSound();
 	delete _startupText;
 	delete _startupArt;
 	delete _startupScript;
@@ -56,6 +59,32 @@ Common::String HarvesterEngine::getGameId() const {
 	return _gameDescription->gameId;
 }
 
+bool HarvesterEngine::playStartupSound(const Common::String &path) {
+	stopStartupSound();
+	if (path.empty() || !_resources)
+		return false;
+
+	Common::SeekableReadStream *stream = _resources->openFile(path);
+	if (!stream) {
+		warning("Harvester: unable to load startup sound '%s'", path.c_str());
+		return false;
+	}
+
+	Audio::SeekableAudioStream *audioStream = Audio::makeWAVStream(stream, DisposeAfterUse::YES);
+	if (!audioStream) {
+		warning("Harvester: unable to decode startup sound '%s'", path.c_str());
+		return false;
+	}
+
+	g_system->getMixer()->playStream(Audio::Mixer::kSFXSoundType, &_startupSoundHandle, audioStream);
+	return true;
+}
+
+void HarvesterEngine::stopStartupSound() {
+	if (g_system && g_system->getMixer())
+		g_system->getMixer()->stopHandle(_startupSoundHandle);
+}
+
 void HarvesterEngine::setDisplayMode(int width, int height) {
 	delete _screen;
 	_screen = nullptr;
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 92915065bcb..42ead929ca0 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -22,6 +22,7 @@
 #ifndef HARVESTER_H
 #define HARVESTER_H
 
+#include "audio/mixer.h"
 #include "common/error.h"
 #include "common/random.h"
 #include "common/serializer.h"
@@ -73,12 +74,15 @@ public:
 	StartupScript *getStartupScript() const { return _startupScript; }
 	StartupArt *getStartupArt() const { return _startupArt; }
 	StartupText *getStartupText() const { return _startupText; }
+	bool playStartupSound(const Common::String &path);
+	void stopStartupSound();
 
 private:
 	void setDisplayMode(int width, int height);
 
 	const ADGameDescription *const _gameDescription;
 	Common::RandomSource _randomSource;
+	Audio::SoundHandle _startupSoundHandle;
 	Graphics::Screen *_screen = nullptr;
 	ResourceManager *_resources = nullptr;
 	StartupScript *_startupScript = nullptr;
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index d2dab00a009..ec6efff923c 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -525,8 +525,11 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 				}
 
 				statusMessage = Common::String::format("Following %s.", objectLabel.c_str());
-				if (!interaction.soundPath.empty())
-					statusMessage += Common::String::format(" Sound: %s.", interaction.soundPath.c_str());
+				if (!interaction.soundPath.empty()) {
+					const bool soundStarted = _engine.playStartupSound(interaction.soundPath);
+					statusMessage += Common::String::format(" Sound: %s (%s).", interaction.soundPath.c_str(),
+						soundStarted ? "playing" : "load failed");
+				}
 				needsRedraw = true;
 
 				if (!interaction.nextRoomName.empty()) {


Commit: 1c02f54e29fb077a0bcc895260ae4cb3f47aaf45
    https://github.com/scummvm/scummvm/commit/1c02f54e29fb077a0bcc895260ae4cb3f47aaf45
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:35+02:00

Commit Message:
HARVESTER: Show object inspect text in room stubs

Changed paths:
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index ec6efff923c..07e33a63944 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -461,6 +461,11 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 	if (!overlays.empty())
 		statusMessage += Common::String::format(" Visible scene objects: %u.", (uint)overlays.size());
 	statusMessage += " Click an active hotspot to follow its startup command chain.";
+	const Common::String baseStatusMessage = statusMessage;
+	StartupResolvedText inspectText;
+	Common::String inspectObjectLabel;
+	bool showingInspectText = false;
+	bool inspectCanDismiss = false;
 	bool needsRedraw = true;
 	Graphics::FrameLimiter limiter(g_system, 60);
 
@@ -477,18 +482,31 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 			drawShadowedString(*screen, *titleFont, Common::String::format("Room Setup Stub: %s", state.roomName.c_str()),
 				panel.left, 348, panel.width(), kTextColorNormal, Graphics::kTextAlignCenter);
 			Common::String hoverMessage;
-			const StartupObjectRecord *hoveredObject = findRoomObjectAtPoint(state, _mousePos);
-			if (hoveredObject) {
-				hoverMessage = _engine.getStartupScript()->resolveObjectLabel(*hoveredObject);
-				if (!hoveredObject->interactionCommandTag.empty())
-					hoverMessage += " Click to follow the scripted startup interaction.";
+			Common::String footerMessage = "Press Enter or Escape to return to the menu stub.";
+			if (showingInspectText) {
+				hoverMessage = inspectText.value;
+				footerMessage = inspectCanDismiss ? "Click, Enter, or Escape to dismiss this inspect text."
+					: "Release the mouse button, then click again to dismiss this inspect text.";
+				drawShadowedString(*screen, *bodyFont, Common::String::format("Inspect: %s", inspectObjectLabel.c_str()),
+					panel.left, 372, panel.width(), kTextColorHover, Graphics::kTextAlignCenter);
+			} else {
+				const StartupObjectRecord *hoveredObject = findRoomObjectAtPoint(state, _mousePos);
+				if (hoveredObject) {
+					hoverMessage = _engine.getStartupScript()->resolveObjectLabel(*hoveredObject);
+					if (!hoveredObject->interactionCommandTag.empty())
+						hoverMessage += " Click to follow the scripted startup interaction.";
+					else if (!_engine.getStartupScript()->resolveObjectInspectText(*hoveredObject, inspectText))
+						inspectText = StartupResolvedText();
+					else
+						hoverMessage += " Click to inspect.";
+				}
 			}
 			if (hoverMessage.empty())
 				hoverMessage = statusMessage;
 
 			drawWrappedShadowedText(*screen, *bodyFont, hoverMessage, panel.left + 24, 386, panel.width() - 48,
 				kTextColorNormal);
-			drawShadowedString(*screen, *bodyFont, "Press Enter or Escape to return to the menu stub.",
+			drawShadowedString(*screen, *bodyFont, footerMessage,
 				panel.left, 430, panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
 
 			blitAnimationFrame(*screen, art->getPointerFrames(), 0, _mousePos.x, _mousePos.y);
@@ -507,7 +525,23 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 			case Common::EVENT_MOUSEMOVE:
 				needsRedraw = true;
 				break;
+			case Common::EVENT_LBUTTONUP:
+				if (showingInspectText)
+					inspectCanDismiss = true;
+				break;
 			case Common::EVENT_LBUTTONDOWN: {
+				if (showingInspectText) {
+					if (inspectCanDismiss) {
+						showingInspectText = false;
+						inspectCanDismiss = false;
+						inspectText = StartupResolvedText();
+						inspectObjectLabel.clear();
+						statusMessage = baseStatusMessage;
+						needsRedraw = true;
+					}
+					break;
+				}
+
 				const StartupObjectRecord *clickedObject = findRoomObjectAtPoint(state, _mousePos);
 				if (!clickedObject) {
 					statusMessage = "No active startup hotspot under the cursor.";
@@ -518,8 +552,15 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 				const Common::String objectLabel = _engine.getStartupScript()->resolveObjectLabel(*clickedObject);
 				StartupInteractionResult interaction;
 				if (!_engine.getStartupScript()->resolveObjectInteraction(*clickedObject, interaction)) {
-					statusMessage = Common::String::format("%s has no supported startup interaction yet.",
-						objectLabel.c_str());
+					if (_engine.getStartupScript()->resolveObjectInspectText(*clickedObject, inspectText)) {
+						inspectObjectLabel = objectLabel;
+						showingInspectText = true;
+						inspectCanDismiss = false;
+						statusMessage = Common::String::format("Showing inspect text for %s.", objectLabel.c_str());
+					} else {
+						statusMessage = Common::String::format("%s has no supported startup interaction yet.",
+							objectLabel.c_str());
+					}
 					needsRedraw = true;
 					break;
 				}
@@ -548,6 +589,21 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 				break;
 			}
 			case Common::EVENT_KEYDOWN:
+				if (showingInspectText) {
+					if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+						event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
+						event.kbd.keycode == Common::KEYCODE_ESCAPE ||
+						event.kbd.keycode == Common::KEYCODE_SPACE) {
+						showingInspectText = false;
+						inspectCanDismiss = false;
+						inspectText = StartupResolvedText();
+						inspectObjectLabel.clear();
+						statusMessage = baseStatusMessage;
+						needsRedraw = true;
+					}
+					break;
+				}
+
 				if (event.kbd.keycode == Common::KEYCODE_RETURN ||
 					event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
 					event.kbd.keycode == Common::KEYCODE_ESCAPE) {
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 737d8b627ef..6b2ce2e68e3 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -262,6 +262,8 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 		object.ownerOrRoom = tokens[tagIndex + 1];
 		object.objectName = tokens[tagIndex + 2];
 		object.resourcePath = resources.normalizeResourcePath(tokens[tagIndex + 3]);
+		object.inventoryBitmapPath = resources.normalizeResourcePath(tokens[tagIndex + 4]);
+		object.shortTextKey = tokens[tagIndex + 6];
 		object.identTextKey = tokens[tagIndex + 8];
 		object.visible = tokens[tagIndex + 9].equalsIgnoreCase("T");
 		object.active = tokens[tagIndex + 10].equalsIgnoreCase("T");
@@ -528,20 +530,48 @@ bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object,
 	return !result.nextRoomName.empty() || !result.soundPath.empty();
 }
 
-Common::String StartupScript::resolveObjectLabel(const StartupObjectRecord &object) const {
-	if (!object.identTextKey.empty()) {
-		for (const StartupTextRecord &textRecord : _texts) {
-			if (textRecord.key.equalsIgnoreCase(object.identTextKey))
-				return textRecord.value;
-		}
+const StartupTextRecord *StartupScript::findTextRecord(const Common::String &key) const {
+	if (key.empty())
+		return nullptr;
+
+	for (const StartupTextRecord &textRecord : _texts) {
+		if (textRecord.key.equalsIgnoreCase(key))
+			return &textRecord;
 	}
 
+	return nullptr;
+}
+
+bool StartupScript::resolveObjectInspectText(const StartupObjectRecord &object, StartupResolvedText &text) const {
+	text = StartupResolvedText();
+
+	const StartupTextRecord *textRecord = findTextRecord(object.identTextKey);
+	if (!textRecord)
+		return false;
+
+	text.boxName = textRecord->boxName;
+	text.value = textRecord->value;
+	return !text.value.empty();
+}
+
+Common::String StartupScript::resolveObjectLabel(const StartupObjectRecord &object) const {
+	const StartupTextRecord *textRecord = findTextRecord(object.shortTextKey);
+	if (textRecord && !textRecord->value.empty())
+		return textRecord->value;
+
 	Common::String label = object.displayName;
 	for (uint i = 0; i < label.size(); ++i) {
 		if (label[i] == '_')
 			label.setChar(' ', i);
 	}
-	return label;
+	if (!label.empty())
+		return label;
+
+	textRecord = findTextRecord(object.identTextKey);
+	if (textRecord)
+		return textRecord->value;
+
+	return Common::String();
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 5c12fc31781..6ed1b6fc9a5 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -51,6 +51,8 @@ struct StartupObjectRecord {
 	Common::String ownerOrRoom;
 	Common::String objectName;
 	Common::String resourcePath;
+	Common::String inventoryBitmapPath;
+	Common::String shortTextKey;
 	Common::String identTextKey;
 	Common::String interactionCommandTag;
 	Common::String displayName;
@@ -92,6 +94,11 @@ struct StartupInteractionResult {
 	Common::String nextRoomName;
 };
 
+struct StartupResolvedText {
+	Common::String boxName;
+	Common::String value;
+};
+
 class StartupScript {
 public:
 	bool load(ResourceManager &resources);
@@ -109,12 +116,14 @@ public:
 	bool resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
 		ResourceManager &resources) const;
 	bool resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result) const;
+	bool resolveObjectInspectText(const StartupObjectRecord &object, StartupResolvedText &text) const;
 	Common::String resolveObjectLabel(const StartupObjectRecord &object) const;
 
 private:
 	bool loadConfig(ResourceManager &resources);
 	void decode();
 	void parseTownRecords(ResourceManager &resources);
+	const StartupTextRecord *findTextRecord(const Common::String &key) const;
 
 	Common::String _path;
 	Common::Array<byte> _data;


Commit: e740ad2a13a6c1d01553fb82b693bfd7081ba247
    https://github.com/scummvm/scummvm/commit/e740ad2a13a6c1d01553fb82b693bfd7081ba247
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:35+02:00

Commit Message:
HARVESTER: Align startup room transitions with RE

Changed paths:
  A ARCHITECTURE.md
  A TRACKER.md
    engines/harvester/startup_art.cpp
    engines/harvester/startup_art.h
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_flow.h
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 00000000000..15581ae6e12
--- /dev/null
+++ b/ARCHITECTURE.md
@@ -0,0 +1,655 @@
+# HARVEST.LE Reverse Engineering Notes
+
+## Scope
+
+This file captures preliminary reverse-engineering findings for `HARVEST.LE` from the entry point through the main startup path and into room/event transitions. Names below reflect what has already been applied in Ghidra when confidence was high.
+
+## Startup Chain
+
+- `_entry` at `0x810e8` is only an LE loader thunk.
+- `watcom_le_crt_startup` at `0x81160` is Watcom DOS-extender CRT setup. It captures the command tail and environment block, counts `NO87=` overrides, clears startup/BSS state, runs init callbacks, and hands off to C startup.
+- `run_init_callbacks_by_priority` at `0x888a6` walks a 6-byte callback table and executes pending init callbacks in priority order.
+- `crt_initialize_and_run_program` at `0x88856` finishes CRT setup and transfers control into the program startup path.
+- `startup_main` at `0x10010` is the first game-specific startup routine. It stores `argc/argv`, resolves `TEMP` / `TMP` / `.\` working paths, loads options such as `FX_VOLUME`, `MUSIC_VOLUME`, `GAMMA`, `CLICK`, and `QUICK_TIPS`, handles the `Z.EXE` CLI/help path, and then enters the normal game startup chain.
+
+## Resource Layer
+
+- `initialize_extended_file_system` at `0x1c640` performs one-time setup for the game's extended/XFILE resource system.
+- The routine copies startup/config path strings, resolves the configured `CD_ROM` root, and builds in-memory entry lists for numbered resource sets.
+- `shutdown_extended_file_system` at `0x1ca80` is the teardown half of that subsystem.
+  - It closes open numbered-set handles, frees the per-set path strings and entry lists, clears the cached lookup tables, and drops the initialized flag so `initialize_extended_file_system` can rebuild the state.
+- The global cache state for that subsystem is now partially explicit:
+  - `g_extended_file_system_initialized` gates the one-time init/teardown pair.
+  - `g_xfile_set_root_paths[10]` stores the per-set root/path strings used to locate numbered resource sets.
+  - `g_xfile_set_handles[10]` stores the open handles for those numbered resource sets.
+  - `g_xfile_set_entry_lists[10]` stores the linked-list heads for the per-set in-memory entry catalogs.
+  - `g_xfile_set_read_offsets[10]` caches the current archive read offset per set so `xfile_read` can avoid redundant seeks.
+  - `g_xfile_stream_slots[16]` is the transient stream table shared by dialogue voice, music, sound-effect, and script/resource loads that read through the XFILE abstraction.
+- The in-memory XFILE cache records are now typed:
+  - `XFileEntryListNode` is an 8-byte linked-list node with `entry` and `next` pointers.
+  - `XFileArchiveEntry` is the 0x94-byte per-file record copied from either the numbered set index file or the inline archive headers. The confirmed fields are:
+    - `signature[4]`
+    - `path[0x80]`
+    - `archive_offset`
+    - `stored_size`
+    - `packed_flag`
+    - `unpacked_size`
+- `xfile_size` at `0x1cc80` returns file sizes either from mounted archive entries or from direct CD-ROM paths.
+- `build_cdrom_path` at `0x1ed80` resolves the configured `CD_ROM` root and appends a caller-supplied relative asset path into the shared direct-file path buffer used by FST and other non-archive loads.
+- `xfile_read` at `0x1cde0` reads through the same abstraction. It supports direct file reads and numbered archive-backed entries, including a packed-entry decode path.
+- The XFILE stream helpers are now explicit:
+  - `open_xfile_stream` at `0x1e530` reserves one of 16 transient stream slots, either binds it to a direct CD-ROM file handle or maps it onto an archive entry inside a numbered set, and seeds the slot's start/current/end offsets.
+  - `read_xfile_stream` at `0x1e750` is the chunked reader for those transient slots. Archive-backed reads reuse `g_xfile_set_read_offsets` to avoid redundant seeks and clamp reads to the mapped entry range.
+  - `seek_xfile_stream` at `0x1e900` repositions an XFILE stream slot relative to its mapped entry start, current position, or end; direct-file slots delegate to the lower handle-layer seek helper.
+  - `close_xfile_stream` at `0x1e710` releases a transient stream slot and closes the direct file handle when the slot is not archive-backed.
+- The lower DOS file-handle layer under both XFILE and the game's buffered FILE-stream helpers is now named:
+  - `open_file_handle` at `0x81576`
+  - `open_file_handle_with_flags` at `0x8159a`
+  - `seek_file_handle` at `0x81ecf`
+  - `read_file_handle` at `0x81f63`
+  - `get_file_handle_size` at `0x85d65`
+  - `close_file_handle` at `0x887ad`
+- The buffered FILE-stream layer above those raw handles is now explicit:
+  - `FileStream` is a typed 0x1a-byte buffered stream record with `cursor`, `buffer_count`, `buffer_base`, `flags`, `file_handle`, `buffer_size`, an inline one-byte fallback buffer, and a temp-file id byte.
+  - `FileStreamListNode` is the 8-byte linked-list node used to track active and recycled stream descriptors.
+  - `g_file_stream_slots` at `0xc52f4` is the 20-slot static stream table; `g_stdout_stream` and `g_stderr_stream` are the confirmed standard output/error slots inside that table.
+  - `g_open_file_stream_nodes` and `g_free_file_stream_nodes` are the active/recycled descriptor lists used when the runtime allocates or releases stream objects.
+  - `open_file_stream` allocates a `FileStream`, parses mode strings through `parse_file_stream_mode_flags`, opens the backing DOS handle, and seeks to end when the mode starts with `a`.
+  - `close_file_stream` tears the stream down through `shutdown_file_stream` and `close_and_release_file_stream`.
+  - `seek_file_stream`, `rewind_file_stream`, `flush_file_stream`, and `tell_file_stream` are the standard buffered positioning/flush helpers over that layer.
+  - `ensure_file_stream_buffer` allocates the backing buffer on demand, falling back to the inline one-byte buffer when heap allocation fails.
+  - `printf_ascii`, `fprintf_ascii`, `vfprintf_ascii`, `sprintf_ascii`, and `format_ascii_to_callback` are the shared ASCII formatting layer built on top of those streams.
+  - `write_c_string_to_file_stream` and `write_char_to_file_stream` are the low-level emitters used by `puts_ascii` and the formatting callbacks.
+  - `is_file_handle_device`, `get_cached_file_handle_flags`, and `set_cached_file_handle_flags` cache DOS handle/device state under that stream layer.
+  - `write_text_file_handle` is the translated text-mode DOS write path used by the save/load menus; it expands `\n` to `\r\n` unless the cached handle flags mark the handle as binary and seeks to end first when the append bit is set.
+  - `sync_file_stream_device_flags` is the bridge from newly opened buffered streams into that cached DOS handle flag table; it queries `int 21h AX=4400` through `is_file_handle_device` and mirrors the device bit into the stream's local flags.
+- The protected-mode/DPMI support under those DOS-facing helpers is now partially explicit:
+  - `DpmiInt31RegisterBlock` is the confirmed 0x2c-byte register marshalling block used by the recovered `int 31h` helper path.
+  - `InterruptRegisterState` is the 0x1c-byte general-register snapshot used by the software-interrupt path; the confirmed fields are `eax`, `ebx`, `ecx`, `edx`, `esi`, `edi`, and `carry_mask`.
+  - `SegmentRegisterSnapshot` is the 0x0c-byte companion segment snapshot used by that same path; the confirmed fields are `es`, `cs`, `ss`, `ds`, `fs`, and `gs`.
+  - `call_dpmi_service_with_reg_block` loads `DS`, `ES`, `EDI`, `ESI`, `EDX`, and `ECX` from that block, issues `int 31h` with caller-supplied `AX`/`BX`, then writes the post-call `EAX`, `ECX`, `EDX`, and flags back into the same block.
+  - `get_selector_base_address` is the `int 31h AX=0006` wrapper that returns the linear base for a protected-mode selector.
+  - `get_code_segment_selector` and `get_stack_segment_selector` are the tiny selector helpers used when the runtime needs to derive lockable linear addresses from the current code or stack segment.
+  - `set_linear_region_lock_state` wraps DPMI services `0x0600` / `0x0601` over a linear base/length pair, and `lock_linear_region` / `unlock_linear_region` are its direct public wrappers.
+  - `g_software_interrupt_stub_table` at `0x89acc` is the 256-entry `INT n ; RET` stub table used by the generic software-interrupt core.
+  - `dispatch_interrupt_stub_with_state` loads `ES` / `DS` from `SegmentRegisterSnapshot`, restores `EAX`..`EDI` from `InterruptRegisterState`, and returns directly into the selected `INT n ; RET` stub.
+  - `invoke_interrupt_stub_and_store_state` is the post-dispatch collector that snapshots `EAX`..`EDI`, the carry bit, and the updated `ES` / `DS` values back into those same state records.
+  - `invoke_software_interrupt_with_state` is the public wrapper over that stub-table path.
+  - `allocate_dos_memory_block` and `free_dos_memory_block` are the `int 31h AX=0100` / `AX=0101` helpers used by the VESA probe path to allocate and release conventional-memory buffers.
+  - `DpmiRealModeCallStructure` is the confirmed 0x32-byte DPMI `AX=0300` real-mode register frame: `edi`, `esi`, `ebp`, `reserved_0c`, `ebx`, `edx`, `ecx`, `eax`, `flags`, `es`, `ds`, `fs`, `gs`, `ip`, `cs`, `sp`, `ss`.
+  - `g_dpmi_real_mode_call_structure` at `0xa0000` is the shared instance of that structure used by the mouse, VESA BIOS, and MSCDEX probe paths.
+  - `simulate_real_mode_interrupt` and `simulate_real_mode_interrupt_with_stack_words` marshal `int 31h AX=0300` around that shared real-mode frame, with the second helper exposing the caller-supplied stack-word count.
+  - Those helpers are used by `install_keyboard_irq1_handler` / `remove_keyboard_irq1_handler` to lock the IRQ1 handler code and shared scancode state, by `initialize_pcm_sound_state` to lock live PCM buffers, and by the video/runtime setup path to lock persistent surface state once.
+- The temporary DOS critical-error path used by CD-ROM probing is now explicit:
+  - `install_dos_critical_error_handler` installs `dos_critical_error_handler` on `int 24h` through DOS `AH=25h` and stores a caller-supplied far callback in `g_dos_critical_error_callback_far`.
+  - `dos_critical_error_handler` snapshots the interrupted stack in `g_dos_critical_error_saved_stack_far`, records the current DOS critical-error context, and then invokes that caller callback.
+  - `dos_critical_error_handler_iret_thunk` is the chained return stub that invokes the stored callback, restores the saved stack, and resumes the interrupted DOS path with `IRETD`.
+  - `dos_find_first_with_dta` is the disc-probe helper that sets a temporary DTA with DOS `AH=1Ah`, performs `findfirst` via `AH=4Eh`, and is used by `match_requested_game_disc` and `prompt_for_cdrom_disc_change` to read the inserted disc label.
+  - `copy_dos_current_dta_record` is the DOS 9 compatibility helper under that path; it queries the current DTA pointer with `AH=2Fh` and copies the 0x2b-byte record into the caller's buffer after `findfirst`.
+  - `store_segment_registers` is the small support helper used by `invoke_real_mode_interrupt` to seed its segment snapshot before handing off to the still-untyped real-mode interrupt core.
+  - `probe_mscdex_installation_status` uses `int 2Fh AX=1100h` with the documented `DADAh` stack signature to probe MSCDEX installation state during startup.
+  - `get_mscdex_first_drive_index` uses `int 2Fh AX=1500h` and returns the starting CD-ROM drive index from `g_dpmi_real_mode_call_structure.ecx` when MSCDEX reports one or more installed drives in `ebx`.
+  - `get_mscdex_version_bcd` uses `int 2Fh AX=150Ch` and returns the MSCDEX version number from `g_dpmi_real_mode_call_structure.ebx`.
+- `prompt_for_cdrom_disc_change` at `0x488a0` is the user-facing disc-swap / drive-not-ready path reached from menus and scripted transitions.
+  - It stops the active music stream, frees dialogue/XFILE cache state, shows the `CD-ROM DRIVE NOT READY!` / `PLEASE TRY AGAIN...` prompt, then reruns `shutdown_extended_file_system`, `initialize_extended_file_system`, and the `dialogue.idx` reopen path once the requested disc becomes available.
+
+## Town Script Runtime
+
+- `initialize_town_script_runtime` at `0x42fc0` bootstraps the game's town script system.
+- It builds a `cold(%s)` startup expression from the configured `TOWN` value.
+- It loads the town script blob through XFILE and deobfuscates it by XORing bytes with `0xAA` except CR/LF.
+- The function then registers a large vocabulary of words/primitives, including `create`, `lit`, `var`, `const`, `if`, `else`, `branch`, `quote`, `.quote`, `do_do`, `do_loop`, and related operators.
+- This strongly suggests a compact Forth-like or stack-based scripting runtime rather than a simple linear bytecode interpreter.
+
+## Confirmed Cold-Start Resource Order
+
+- `startup_main` at `0x10010` enters the normal game boot path in this order:
+  - `initialize_extended_file_system`
+  - `initialize_town_script_runtime`
+  - `run_harvester_main_loop`
+- Before the XFILE mount, the runtime opens `CONFIG.INI`, `menu.ini`, `hmidet.386`, `SOUND.CFG`, and `hmidrv.386`; that stage is configuration, mouse setup, and HMI sound-driver bootstrap rather than archive resource loading.
+- `initialize_extended_file_system` mounts numbered resource sets before any script or UI bootstrap runs. The observed cold-start order is:
+  - `HARVEST.DAT` then `INDEX.001`
+  - `SOUND.DAT` then `INDEX.002`
+  - `HARVEST2.DAT` then `INDEX.003`
+- The sidecar `INDEX.00N` files are read as repeated `0x94`-byte records, matching the confirmed `XFileArchiveEntry` size.
+- `xfile_read` and `xfile_size` use two distinct path modes:
+  - `<digit>:\...` paths are archive-backed lookups through the numbered sets mounted by `initialize_extended_file_system`
+  - bare relative paths are opened directly from the configured `CD_ROM` root
+- `initialize_town_script_runtime` next opens `HARVEST.SCR`, loads the full script blob through XFILE, and XOR-deobfuscates it in memory before registering the script vocabulary.
+- `run_harvester_main_loop` begins its presentation bootstrap by playing three direct-path FST sequences in this exact order:
+  - `GRAPHIC\FST\VIRGLOGO.FST`
+  - `GRAPHIC\FST\FVLOGO.FST`
+  - `GRAPHIC\FST\INTROFIN.FST`
+- After the FST logo/intro playback, the startup path begins loading archive-backed UI resources from set 1, including:
+  - `1:\graphic\pal\wait.pal`
+  - `1:\graphic\other\wait.abm`
+  - `1:\graphic\pointers\pointers.abm`
+  - `1:\graphic\other\inventry.bm`
+  - `1:\graphic\other\harvlogo.bm`
+- Only after the FST playback does the main-loop startup open direct-path dialogue/font assets such as `dialogue.idx`, `graphic\font\harvfont.cft`, `graphic\font\harvfnt2.cft`, `graphic\font\textfont.cft`, `graphic\font\textfnt2.cft`, `graphic\font\medfont1.cft`, and `graphic\font\medfont2.cft`.
+
+## Main Loop
+
+- `run_harvester_main_loop` at `0x6dc70` is the primary Harvester boot and gameplay loop.
+- It prints the `Running Harvester v1.0` banner.
+- It loads startup art and UI resources including logo FSTs, palettes, fonts, textbox art, cursor resources, and room-object art.
+- On the cold-start path it only seeds the first town transition: after the world-record parsers and script runtime have populated the live record lists, it preloads `POINTERS.ABM`, stores the spawned cursor entity, preloads the initial room palette into `g_current_palette_buffer`, and then hands off to `room_setup("QUICK_TIPS")`.
+- It seeds the initial `START` state, enters the main loop, processes state-driven transitions and interactions, and performs cleanup on shutdown.
+  - The startup-side helpers hanging directly off that path are now bounded:
+    - `printf_ascii` prints the copyright line through `g_stdout_stream` after the `puts_ascii` version banner.
+    - `set_random_seed` seeds `g_random_seed` from the first startup tick, and `next_random_value` is the 15-bit LCG step used by script/random branches.
+    - `configure_video_surface` swaps `g_video_surface_context` between the normal gameplay surface and the narrower FST playback surface before the movie path hands off to `run_fst_sequence_player`.
+  - The VESA backend records behind that selector are now bounded as four 8bpp banked-mode descriptors:
+    - `g_vesa_mode_640x400_8bpp`
+    - `g_vesa_mode_640x480_8bpp`
+    - `g_vesa_mode_800x600_8bpp`
+    - `g_vesa_mode_1024x768_8bpp`
+    - `g_vesa_video_mode_backends` is the pointer table `configure_video_surface` scans to match width / height / pixel format.
+    - `configure_video_surface` matches each descriptor on width / height / bpp / backend class, calls the descriptor's probe and activate callbacks, and tears down the previously active backend through the record's shutdown callback before switching.
+    - `VesaModeBackend` is now a typed 64-byte descriptor record with confirmed width/height/pixel-format fields, probe/activate/shutdown/display-start callbacks, pixel read/write callbacks, the shared blit callback, and the runtime bank/window fields consumed by FST playback.
+    - `VideoSurfaceContext` is now a typed 16-byte context that caches the active backend pointer plus the selected width/height/bpp/class.
+  - The shared VESA helper layer is now partially named:
+    - `query_vesa_controller_info` validates the controller signature through BIOS `int 10h AX=4f00`.
+    - `set_vesa_video_mode` checks the current VESA mode with `AX=4f03` and only issues `AX=4f02` when a switch is required.
+    - `set_vesa_window_bank` is the shared `AX=4f05` wrapper used by the banked blitters and single-pixel helpers.
+    - `set_vesa_display_start` is the shared `AX=4f07` wrapper used by the per-mode display-start preset callbacks.
+    - `initialize_vesa_banked_mode` probes and installs a banked VESA mode by mode number, builds the per-scanline bank/offset tables, and initializes the active read/write windows.
+    - `restore_text_mode_3` restores BIOS text mode 3 for the current backend.
+    - `noop_vesa_mode_shutdown` is the no-op teardown callback used by the 640x400, 640x480, and 800x600 descriptor records.
+    - The per-mode callback sets are now explicit for the four descriptor records: each record carries a probe helper, an activation helper, a display-start preset helper, and single-pixel read/write helpers for its specific geometry.
+    - `fill_vesa_banked_rect` fills a rectangular byte region in banked VRAM using the per-scanline bank split tables.
+    - `blit_vesa_banked_rect` is the shared rectangle blitter used by the VESA mode records.
+    - `select_vesa_bank_for_scanline` adjusts the active bank/window based on a scanline index.
+
+## Room And Event Flow
+
+- `room_setup` at `0x73540` is confirmed by in-binary debug strings:
+  - `room_setup(): no START entrance`
+  - `room_setup() ERROR: No such room defined %s`
+  - `room_setup: loading_game / current_music_file = %s`
+- `room_setup` resolves the requested room or `START` entrance, finds the matching room definition, loads the room palette, instantiates exits / hotspots / overlays / room actors, positions the player, runs room-enter handlers, and updates the current music file.
+- `g_current_palette_buffer` at `0xd6084` is the shared room/menu palette buffer used by `run_harvester_main_loop`, `room_setup`, `dispatch_room_event_actions`, inventory/help screens, and the CD prompt path.
+  - `room_setup` first resolves the target room / entrance and, when `g_current_room_def->palette_path` is non-null, rereads that room palette into `g_current_palette_buffer`.
+  - It then reloads `WAIT.PAL` and `WAIT.ABM`, hides the cursor entity, flushes the wait transition, uploads the wait palette, builds the room entities, and only after that uploads the room palette from `g_current_palette_buffer`.
+  - After the initial black upload, `room_setup` uses `ramp_palette_brightness` at `0x23a30` to fade `g_current_palette_buffer` in from black. The helper steps brightness in `0.1` increments on an approximately 4 ms timer and also drives the room dimming path when the room is marked dimmable and `DAY_FLAG` is clear.
+- `upload_palette_to_vga` at `0x22770` writes a caller-supplied RGB palette to the VGA DAC, forces palette index 0 to black, and applies a caller-supplied brightness scalar before clamping each channel to the hardware `0x3f` range.
+- `ramp_palette_brightness` at `0x23a30` is the shared timed palette ramp helper.
+  - `room_setup` uses it as a fade-in from `0.0` to either `1.0` or the dimmed target `0.6`.
+  - The helper updates music stream state between palette uploads instead of blocking in a tight delay loop.
+- `dispatch_room_event_actions` at `0x60ee0` interprets action records keyed by a tag string. It is used by `room_setup`, the main loop, and nested action groups.
+- `dispatch_room_event_actions` supports:
+  - Conditional branching between alternate action tags
+  - Recursive dispatch of named action groups
+  - Room transitions via `room_setup`
+  - Palette and fade mode changes
+  - FST / cutscene style playback
+  - Sound and music loading / playback / stop operations
+  - Object, hotspot, overlay, and room actor visibility or active-state changes
+  - Combat/stat side effects such as damage-type selection and player stat increments
+  - Save-game handoff through the `SAVE_GAME` marker
+
+## World Record Parsers
+
+- `register_world_record_parsers` at `0x60350` registers parser callbacks for the script/world record tags:
+  - `ANIM`
+  - `COMMAND`
+  - `ENTRANCE`
+  - `EXEC_LIST`
+  - `FLAG`
+  - `HEAD`
+  - `MAP_ENTRANCE`
+  - `MAP_LOCATION`
+  - `MONSTER`
+  - `NPC`
+  - `OBJECT`
+  - `REGION`
+  - `ROOM`
+  - `TEXT`
+  - `TIMER`
+  - `USEITEM`
+- The registered callbacks are now individually named in Ghidra:
+  - `parse_anim_record` at `0x5e120`
+  - `parse_command_record` at `0x5e410`
+  - `parse_entrance_record` at `0x5eb80`
+  - `parse_exec_list_record` at `0x5ecb0`
+  - `parse_flag_record` at `0x5ed80`
+  - `parse_head_record` at `0x5ee20`
+  - `parse_map_entrance_record` at `0x5ef20`
+  - `parse_map_location_record` at `0x5efb0`
+  - `parse_monster_record` at `0x5f080`
+  - `parse_npc_record` at `0x5f7e0`
+  - `parse_object_record` at `0x5fa40`
+  - `parse_region_record` at `0x5fd30`
+  - `parse_room_record` at `0x5fea0`
+  - `parse_text_record` at `0x600b0`
+  - `parse_timer_record` at `0x60160`
+  - `parse_useitem_record` at `0x602c0`
+- This parser registry is the clearest anchor for the global linked-list heads used by `room_setup`, `dispatch_room_event_actions`, inventory handling, and dialogue.
+
+## Record Lists
+
+- `g_command_records` at `0xd5a94` is the `COMMAND` list consumed by `dispatch_room_event_actions`.
+- `g_room_entrances` at `0xd5a98` is the `ENTRANCE` list. Each record carries at least:
+  - Entrance tag name at `+0x14`
+  - Destination room name at `+0x10`
+  - Facing/orientation at `+0x0c`
+  - Next pointer at `+0x18`
+- `g_current_room_entrance` at `0xd5a9c` tracks the active entrance/spawn marker.
+- `g_exec_list_records` at `0xd5aa0` is the `EXEC_LIST` list.
+- `g_flag_records` at `0xd5aa4` is the `FLAG` list used by `get_flag_value` / `set_flag_value`.
+- `g_head_records` at `0xd5aa8` is the `HEAD` list used by the dialogue/head UI system.
+- `g_town_map_entrypoints` at `0xd5aac` is the `MAP_ENTRANCE` list.
+- `g_town_map_hotspots` at `0xd5ab0` is the `MAP_LOCATION` list used by the town map selector.
+- `g_monster_records` at `0xd5ab4` is the `MONSTER` list.
+- `g_npc_records` at `0xd5ab8` is the `NPC` list.
+- `g_object_records` at `0xd5abc` is the `OBJECT` list.
+- `g_region_records` at `0xd5ac0` is the `REGION` list.
+- `g_current_room_def` at `0xd5ac4` tracks the current `ROOM` record.
+- `g_room_definitions` at `0xd5ac8` is the `ROOM` list. Observed high-confidence fields:
+  - Enter/action tag at `+0x24`
+  - Room name at `+0x2c`
+  - Current music filename at `+0x30`
+  - Palette filename/resource at `+0x34`
+  - Next pointer at `+0x48`
+- `g_text_records` at `0xd5acc` is the `TEXT` list.
+- `g_timer_records` at `0xd5ad0` is the `TIMER` list.
+- `g_useitem_records` at `0xd5ad4` is the `USEITEM` list.
+- `g_pending_room_name` at `0xd60a4` is the pending next-room string consumed by the main loop.
+
+## Menus And UI States
+
+- `run_main_menu` at `0x67390` is the main-title/menu state.
+  - It loads `main_menu_1` through `main_menu_6` strings from the text system.
+  - It builds the menu UI, highlights selections, and branches into startup options.
+- `run_load_game_menu` at `0x64910` is the load-game UI.
+  - It loads `loadgame.pal` and `loadgame.bm`.
+  - It enumerates `GAME_01` through `GAME_25` slots and reads slot names from the save file.
+- `run_save_game_menu` at `0x632c0` is the matching save-game UI.
+  - It loads `savegame.pal` and `savegame.bm`.
+  - It enumerates the same 25 save slots and supports text entry for save names.
+  - The object-state portion of the slot format persists only `object_name`, `current_x`, `current_y`, `current_z`, `current_owner_or_room`, `visible`, and `ident_shown`; the other object heap strings are reloaded from world data rather than restored from the save blob.
+
+## Map And Dialogue Helpers
+
+- `resolve_room_entrance` at `0x62d80` first searches `g_room_entrances` by entrance tag, then falls back to `g_town_map_entrypoints`.
+  - On direct entrance matches it updates `g_pending_room_name`.
+  - On map-entry matches it launches the town map selector and then updates the pending room / player position from the chosen destination.
+- `select_town_map_destination` at `0x66460` is the town-map travel UI.
+  - It loads `harvmap.pal` and `harvmap1.bm` through `harvmap4.bm`.
+  - It seeds the initial map panel from `g_town_map_entrypoints`.
+  - It uses `g_town_map_hotspots` to resolve the selected destination back into a named entrance record.
+- `get_flag_value` at `0x7c790` and `set_flag_value` at `0x7c7e0` are the core `FLAG` accessors used throughout startup, room setup, and event dispatch.
+- `run_npc_dialogue` at `0x79e50` is the conversation/head UI for named NPCs.
+  - It validates the NPC through `g_npc_records`.
+  - It uses `g_head_records` to resolve portrait variants and keyword/head assets.
+  - It drives the textbox / keyword / voice interaction loop until the exchange completes or is interrupted.
+  - `g_talk_to_handler_entries` at `0xc2e88` is the fixed pre-dialogue talk-to dispatch table used by this path.
+    - Each record is `0x24` bytes: a 32-byte NPC/object name token followed by a handler function pointer at `+0x20`.
+    - `has_talk_to_handler_for_name` at `0x3a160` linearly scans that table until the empty-name terminator and is used by `run_harvester_main_loop`, `run_inventory_screen`, and `handle_target_interaction` to decide whether a target can enter NPC dialogue.
+    - `dispatch_talk_to_handler_for_name` at `0x39f80` formats `talk_to(%s)` into the shared scratch buffer, finds the matching table entry by name, and calls the per-entry handler before `run_npc_dialogue` continues with the portrait/text loop.
+    - The indirect table targets are now explicit talk-to handlers, including `handle_talk_to_authority`, `handle_talk_to_beggar`, `handle_talk_to_boyle`, `handle_talk_to_buster`, `handle_talk_to_butcher`, `handle_talk_to_dwayne`, `handle_talk_to_karin`, `handle_talk_to_mom`, `handle_talk_to_mr_potts`, `handle_talk_to_mrs_potts`, and the remaining named NPC variants exposed by the same table.
+    - Direct table inspection also recovered handler starts that Ghidra had left undefined:
+      - `handle_talk_to_mr_potts` at `0x26000` is the shared target for `MR_POTTS`, `MR_POTTS_CEM10`, and `MR_POTTS_HALL`.
+      - `handle_talk_to_mrs_potts` at `0x2ee70` is the shared target for `MRS_POTTS` and `MRS_POTTS_ST_BEDRM`.
+      - Additional table targets that were still undefined are now explicit handlers: `handle_talk_to_whaley`, `handle_talk_to_moynahan`, `handle_talk_to_swell`, `handle_talk_to_priest`, `handle_talk_to_phelps`, `handle_talk_to_nude_man`, `handle_talk_to_valet`, `handle_talk_to_vet`, `handle_talk_to_wasp_woman`, `handle_talk_to_sergeant`, `handle_talk_to_pta_mom`, `handle_talk_to_stephanie`, `handle_talk_to_pastorelli`, `handle_talk_to_ryder`, `handle_talk_to_sparky`, and `handle_talk_to_parsons`.
+      - Shared-address alias groups now confirmed from the table include `MOYNAHAN` / `MOYNAHAN_MBLM`, `PTA_MOM1` through `PTA_MOM5`, and `RYDER` / `RYDER_TV_OUT`.
+      - The apparent `FUN_0002f7fd` helper is only an overlapping inner keyword-loop region inside `handle_talk_to_mrs_potts`, not the dispatch-table target itself.
+- `load_dialogue_response_line` at `0x3a1a0` is the indexed `dialog.rsp` line loader used by the talk-to handlers.
+  - It opens the ASCII `dialog.rsp` file, reads forward to the requested zero-based line index, strips the trailing CR/LF, and returns the shared `g_dialogue_response_line_buffer`.
+  - Handlers such as `handle_talk_to_dwayne`, `handle_talk_to_hank`, and `handle_talk_to_mom` use it to map keyword-menu selections back to concrete topic strings before branching.
+- The shared dialogue evidence/topic state shims behind those handlers are now partially explicit:
+  - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
+  - `get_set_discussed_note_checkbook_evidence` at `0x38330` gates the shared `NOTE` / `NOTE_PHOTOCOPY` / `CHECKBOOK` / `CHECKBOOK_PHOTOCOPY` evidence path used by Dwayne, Edna, Herrill, Johnson, Mom, and related handlers.
+  - `get_set_discussed_tv_deed_evidence` at `0x38340` gates the `TV_DEED` / `TV_DEED_PHOTOCOPY` evidence path used by Edna, Herrill, Johnson, and Mom.
+  - `get_set_discussed_ledger_evidence` at `0x38350` gates the shared dual-ledger evidence path reached when `HAVE_BOTH_LEDGERS` is set.
+  - `get_set_discussed_casket_photo_evidence` at `0x38360` gates the `CASKET_PHOTO` / `CASKET_PHOTOCOPY` evidence path shared across Boyle, Dwayne, Edna, Hank, Herrill, Johnson, Mom, and others.
+  - `get_set_discussed_whaley_herrill_photo` at `0x38370` gates the shared `PHOTO_OF_WHALEY_HERRILL` evidence path.
+  - `get_set_discussed_karin_purse` at `0x38390` gates Dwayne's `K_PURSE` evidence state and the later `KARIN_FOUND_DEAD` follow-up that depends on whether he has seen the purse yet.
+  - `get_set_dwayne_pending_karin_alive_followup` at `0x38260` is a Dwayne-local state bit set by the `BRING_KARIN_TO_SHERIFF` / `KARIN_FOUND_ALIVE` path, then read and cleared when the delayed next-day follow-up bark triggers.
+  - `get_set_dwayne_discussed_boyles_button` at `0x38320` is the Dwayne-local `BOYLES_BUTTON` evidence bit; `handle_talk_to_dwayne` sets it when shown the button, and nearby hidden dialogue logic reads it at `0x2d262`.
+  - `get_set_mom_father_topic_state` at `0x383b0` is a Mom-local state bit set from the `FATHER` keyword branch.
+  - `get_set_mom_good_cause_day5_state` at `0x383c0` is a Mom-local day-5 `GOOD CAUSE` state bit; the visible caller currently reads it, but the concrete setter is still outside the recovered call graph.
+  - `get_set_dad_meat_permission_state` at `0x38400` is the Dad-local `MEAT_PERMISSION0` state bit read on the related item branch.
+  - `get_set_shared_dialogue_state_d2f04` at `0x383e0` is a neutral shared wrapper over `g_shared_dialogue_state_d2f04`; raw call tracing shows it is used from hidden regions in Moynahan, Mr. Potts, Johnson, Phelps, Butcher, Dad, McKnight, Loomis, Edna, Sparky, and Dwayne, but the surrounding subtitle lines still span too many unrelated topics for a stronger semantic rename.
+  - `get_set_wasp_woman_mom_dialogue_state_d2f10` at `0x38420` is a neutral two-handler wrapper over `g_wasp_woman_mom_dialogue_state_d2f10`; raw calls only appear in the hidden Wasp Woman and Mom regions, with nearby `dialog.rsp` anchors in the `Sparky/Wasp Woman` and `SPARKY` / `P.T.A.` keyword cluster, but the exact shared topic is still unresolved.
+  - `get_set_discussed_mr_potts_tuesday_night_alibi` at `0x38440` is set on the hidden `handle_talk_to_mr_potts` Tuesday-night branch and later read by Johnson and Stephanie; Stephanie's hidden `0x4980` line, "Pottsdam lied. He said he stayed home Tuesday night.", makes the semantics explicit.
+  - `get_set_discussed_mrs_potts_tuesday_night_alibi` at `0x38460` is set on the hidden `handle_talk_to_mrs_potts` Tuesday-night branch and later read by Stephanie; the hidden `0x4984` / `0x4989` / `0x498f` exchange anchors it to Mrs. Potts's version of Pottsdam's Tuesday-night alibi.
+  - `get_set_confronted_mr_potts_about_spyhole` at `0x384a0` is set on the hidden `handle_talk_to_mr_potts` spyhole confrontation and later read by Stephanie; the paired `0x2d35` / `0x2d39` / `0x2d3e` and `0x4a12` / `0x4a19` / `0x4a1e` / `0x4a23` subtitle lines make the semantics explicit.
+  - `get_set_discussed_lodge_topic` at `0x384c0` is the shared Lodge-topic flag set from the `dialog.rsp` keyword lines used by Dwayne, Hank, and Mom.
+  - `get_set_dwayne_whaley_discipline_followup_state` at `0x38500` is a Dwayne-local follow-up state bit read just before the `0x34b3` Miss Whaley school-discipline exchange and its immediate `0x79` response menu.
+  - Each helper is a tiny read/write shim over a dedicated global (`g_discussed_note_checkbook_evidence`, `g_discussed_tv_deed_evidence`, `g_discussed_ledger_evidence`, `g_discussed_casket_photo_evidence`, `g_discussed_whaley_herrill_photo`, `g_discussed_karin_purse`, `g_discussed_lodge_topic`).
+- Boyle's hidden `GASCAN` return branch is now explicit through `get_set_boyle_gascan_application_state` at `0x38240`.
+  - When that state is nonzero, `handle_talk_to_boyle` plays the `0x1f2` / `0x1f6` / `0x1fa` / `0x206` exchange, removes `GASCAN` from inventory, awards `LODGE_APPLICATION`, and sets `HAVE_LODGE_APP`.
+- The adjacent `0x383d0`-`0x384e0` range is now confirmed to be another overlapping run of tiny read/write dialogue-state wrappers over globals in the same contiguous block (`0xd2f00` through `0xd2f28`).
+  - `get_set_sergeant_completed_first_task_state` at `0x384e0` is the Sergeant-local bit set on the `SCRATCHED_TUCKER` branch immediately before dialogue `0x420d` ("So, you have completed your first task.").
+  - The same block also contains the recovered Pottsdam alibi bits at `0x38440` and `0x38460`, so it is no longer Sergeant-only even though several remaining wrappers in that span are still only identified by locality.
+  - `handle_talk_to_sergeant` calls into that run for quest-driven lines tied to `REMAINS`, `INVITE`, `BARBER_POLE`, `BOLTCLTH`, `SCRATCHED_TUCKER`, and `COMPLETED_LODGE_APPLICATION`.
+  - `handle_talk_to_dad` also reads the neighboring `FUN_00038400` wrapper on the `MEAT_PERMISSION0` item path.
+  - The wrapper mechanics are confirmed, but most of the remaining local quest-state semantics in that run are still left unnamed pending stronger caller/readback evidence from the hidden no-function regions.
+- `reset_all_talk_to_handler_state` at `0x3a0b0` is the startup/menu bulk reset for the talk-to subsystem.
+  - It calls the per-NPC talk-to reset helpers plus `reset_shared_talk_to_discussion_state` before the handlers are reused.
+- `reset_shared_talk_to_discussion_state` at `0x38530` zeroes the contiguous shared dialogue-state block behind the `get_set_discussed_*` wrappers and related talk-to follow-up flags.
+- `get_current_story_day_index` at `0x38bc0` collapses the `DAY_n` / `NIGHT_n` flags into a single numeric day index and is used by the dialogue handlers to suppress repeat barks or unlock later-day follow-ups.
+- `load_dialogue_index` at `0x79a20` parses `dialogue.idx` into `g_dialogue_index_entries`.
+  - The table is now typed as `DialogueIndexEntry[3000]`, with each entry storing `wav_id`, `text_offset`, and `text_length`.
+  - `play_dialogue_line` binary-searches that table by WAV id, seeks through `g_dialogue_idx_handle`, and reads the subtitle text by offset/length.
+- `play_dialogue_line` at `0x7a690` is the inner dialogue-line player.
+  - It resolves a `VOICE` asset, finds subtitle text for the WAV id, updates current head/textbox visuals, and waits for playback or interruption to finish.
+  - `decode_xor_aa_text_buffer` at `0x42f80` is the simple subtitle/text deobfuscator used by that path; it XORs each byte with `0xaa` while leaving CR/LF untouched.
+  - The live portrait globals used by that path are now explicit:
+    - `g_dialogue_head_left_entity` is the left-side spawned head bitmap entity created by `run_npc_dialogue`.
+    - `g_dialogue_head_right_entity` is the matching right-side head bitmap entity.
+    - `g_dialogue_head_bitmap_path` is the shared path buffer used by `run_npc_dialogue` and `play_dialogue_line` when selecting the current head bitmap resource.
+  - `reload_bitmap_entity_pixels_from_resource` at `0x4b7a0` refreshes the pixel payload of an already spawned bitmap entity from a same-sized XFILE bitmap resource; `play_dialogue_line` uses it to swap the currently visible left or right head portrait without respawning the entity.
+- `handle_target_interaction` at `0x7ff50` is the high-level click/use dispatcher for world targets.
+  - It can branch into NPC dialogue, inventory transfer, `USEITEM` actions, or generic fallback responses depending on the clicked entity and current item state.
+- `show_target_ident_text` at `0x80270` renders `IDENT` text for the current target by resolving the matching `TEXT` record and showing it until dismissal.
+
+## Keyboard Input
+
+- `keyboard_irq1_handler` at `0x48460` is the custom IRQ1 keyboard handler.
+  - It reads scan codes from port `0x60`, updates `g_last_keyboard_scancode` plus the `g_keyboard_scancode_pressed` table, clears key-release events by masking bit `0x80`, and acknowledges the PIC with `out 0x20, 0x20`.
+- `install_keyboard_irq1_handler` at `0x484f0` and `remove_keyboard_irq1_handler` at `0x485b0` lock/unlock the handler code and data, zero the scan-code table, and install/remove interrupt vector `9`.
+- `dos_get_interrupt_vector` at `0x866f8` and `dos_set_interrupt_vector` at `0x8672c` are the DOS `int 21h` AH=`35h` / AH=`25h` wrappers used by that install/remove path.
+- The recovered input bytes now separate into concrete scan-code slots:
+  - `g_input_up_pressed` / `g_input_left_pressed` / `g_input_right_pressed` / `g_input_down_pressed` are the `0x48` / `0x4b` / `0x4d` / `0x50` slots in `g_keyboard_scancode_pressed`.
+  - `g_input_right_shift_pressed` at `0xd5942` is scan code `0x36` and is the reverse-step modifier used by `update_player_combat_avatar_state`.
+  - `g_player_attack_pressed` at `0xd5929` is the scan-code `0x1d` slot consumed by the player-combat attack path.
+
+## Pointer / Cursor Input
+
+- `g_mouse_driver_present` at `0xd5971` gates all observed `int 33h` mouse-service calls.
+  - `run_harvester_main_loop`, `select_town_map_destination`, `run_save_game_menu`, `run_load_game_menu`, `run_main_menu`, and `FUN_00072550` all test it before setting mouse bounds or pushing a new cursor position through `int 33h`.
+- `g_cursor_screen_x` / `g_cursor_screen_y` at `0xd5978` / `0xd597c` are the shared cursor coordinates.
+  - Menus, dialogue, inventory, town-map travel, and runtime target-hit helpers all consume them directly for screen-space hit testing.
+  - Keyboard-driven UI motion also writes these globals and then mirrors the result back into the DOS mouse driver when `g_mouse_driver_present` is nonzero.
+- `g_input_mouse_primary_pressed` at `0xd5974` and `g_input_mouse_secondary_pressed` at `0xd5972` are the current pointer-button state bytes.
+  - Confirmation/selection loops consume the primary byte.
+  - Cancel/back paths consume the secondary byte.
+  - Input-release loops in `dispatch_room_event_actions`, `play_dialogue_line`, and menu states wait until both bytes and `g_last_keyboard_scancode` have returned to zero.
+- `g_cursor_entity` at `0xd6094` stores the active runtime cursor entity.
+- `spawn_cursor_entity` at `0x79920` instantiates the `MOUSE` runtime entity from `graphic\\pointers\\pointers.abm` at `g_cursor_screen_x` / `g_cursor_screen_y`.
+- `set_entity_animation_sequence` at `0x79990` maps cursor sequence indices to 10-frame runs, so room sequence `7` is pointer frames `70..79` in `POINTERS.ABM`.
+- `spawn_cursor_entity` seeds the runtime cursor at sequence `0` / frames `0..9` with animation rate `10`; `room_setup` then switches `g_cursor_entity` to sequence `7`.
+- `sync_cursor_entity_position` at `0x799e0` repositions a runtime entity to the shared cursor coordinates and then advances its visual state through the normal entity animation tick path.
+- `flush_dirty_rects_to_screen` reads `g_cursor_entity` and calls `sync_cursor_entity_position` every frame before the dirty-rect blit work, so cursor placement is part of the normal render pipeline rather than a one-shot startup blit.
+- `is_cursor_within_entity_bounds` at `0x4b670` is the rectangle-only cursor test for runtime entities.
+- `is_cursor_over_entity_hitmask` at `0x4b5b0` adds the per-pixel transparency test used by the main loop before interaction dispatch.
+
+## Idle Animation Timing
+
+- `g_idle_animation_activity_timestamp` at `0xd5968` is one side of the main-loop idle timer input.
+  - `keyboard_irq1_handler` updates it from the shared tick source on IRQ1 activity.
+  - `run_harvester_main_loop` also refreshes it after `show_target_ident_text`, which prevents the idle animation branch from immediately retriggering after the IDENT prompt is dismissed.
+- `g_idle_animation_reset_timestamp` at `0xd5980` is the non-IRQ reset side of the same timer.
+  - `room_setup`, `run_save_game_menu`, `run_load_game_menu`, the intro/logo sequence helper, the main-menu return path, and the idle-animation cleanup path all refresh it from the current tick source.
+- `g_idle_animation_trigger_time` at `0xd60c4` is the computed threshold for the `IDLE_ANIM` room-animation path.
+  - `run_harvester_main_loop` recomputes it as `max(g_idle_animation_activity_timestamp, g_idle_animation_reset_timestamp) + 3000`, skips the branch in excluded rooms, and otherwise spawns `IDLE_ANIM` from `graphic\\roomanim\\pcloun02.abm`.
+  - After the idle animation exits, the main loop re-arms the threshold by another 3000 ticks.
+- `show_pause_message` at `0x797c0` renders the `HARVESTER IS PAUSED` overlay and backdates both idle timestamps by `0xc1c`, allowing the idle-animation gate to become eligible again immediately after pause dismissal.
+
+## CD-ROM Media Check
+
+- `startup_main` now verifies MSCDEX directly before the rest of game startup:
+  - `probe_mscdex_installation_status` is the initial install-state probe.
+  - `get_mscdex_first_drive_index` caches the base CD-ROM drive index for later disc-label path construction.
+  - `get_mscdex_version_bcd` records the detected MSCDEX version for later runtime checks.
+- `g_cdrom_drive_not_ready` at `0xd596c` is the status byte used by `prompt_for_cdrom_disc_change`'s CD/media prompt loop.
+  - When it is nonzero, the function raises the `CD-ROM DRIVE NOT READY!` and `PLEASE TRY AGAIN...` messages.
+  - The callback registered at `0x48890` is the only recovered producer-side writer so far.
+
+## Struct Recovery
+
+- Shared runtime constructor layer now identified:
+  - `initialize_entity_common_fields` at `0x4ab70` seeds the common runtime entity header from register/stack arguments for `name`, `x`, `y`, `z`, a class/type argument, and one extra init argument.
+  - `spawn_abm_entity_base` at `0x4b890` calls `initialize_entity_common_fields`, loads an ABM/bitmap resource through XFILE, decodes its frames, and seeds common animation state.
+  - `spawn_monster_entity_base` at `0x4d140` layers monster-specific blood/effect and combat/AI state on top of `spawn_abm_entity_base`.
+  - `advance_entity_animation_frame` at `0x4c8a0` advances or forces the active frame/sequence, applies loop/ping-pong behavior, updates motion, and clamps X movement to the current active bounds.
+- The constructor helpers called by `room_setup` are now named:
+  - `spawn_object_entity_from_record` at `0x4aee0`
+  - `spawn_region_entity_from_record` at `0x4b2e0`
+  - `spawn_anim_entity_from_record` at `0x4bbe0`
+  - `spawn_npc_entity_from_record` at `0x4cd00`
+  - `spawn_monster_entity_from_record` at `0x53a10`
+- `CommandRecord` is now stable enough to use directly in the event interpreter:
+  - `opcode`, `trigger_tag`, `arg1`..`arg4`, `runtime_flag`, `next`
+- `EntranceRecord` is stable and drives room transitions and save/load handoff:
+  - `pos_x`, `pos_y`, `pos_z`, `facing`, `room_name`, `entrance_name`, `next`
+- `HeadRecord` is now explicit:
+  - `head_id`, `portrait_path`, `next`
+- `FlagRecord` is now explicit:
+  - `flag_name`, `value`, `next`
+- `ObjectRecord` now has a confirmed position/ownership split:
+  - `initial_x`, `initial_y`, `initial_z`
+  - `current_x`, `current_y`, `current_z`
+  - `bounds_x2`, `bounds_y2`
+  - `action_tag`, `sprite_path`, `alt_sprite_path`, `object_name`, `ident_text_key`, `inventory_text_key`
+  - `current_owner_or_room`, `initial_owner_or_room`, `interaction_label`
+  - parser booleans are `operatable` and `visible`
+  - `runtime_flag_4e` is initialized from `visible`
+  - `inventory_text_key` is the `TEXT` lookup used by the inventory screen tooltip/description panel.
+  - `ident_shown` defaults true when `ident_text_key` is missing, is copied into the runtime object entity, and is set after the IDENT text has been shown or when an object is moved into inventory.
+  - `action_tag` was corrected from the older `pickup_action_tag` label: the main loop dispatches it on initial object activation, on successful carry/pickup into inventory, and on later carried-object activation, so the field is broader than pickup-only handling.
+  - Save/load only persists the mutable object state: `current_x`, `current_y`, `current_z`, `current_owner_or_room`, `visible`, and `ident_shown`, keyed by `object_name`.
+  - `interaction_label` is copied into the runtime object entity and drives the main-loop hover / use prompt text; a `NULL_ID` label suppresses prompt generation.
+  - `z_extent` is copied into the runtime object entity at offset `+0x20`; `FUN_0004cae0` uses the live interval `[entity + 0x1c, entity + 0x1c + 0x20]` before the 2D overlap test, which confirms that the field participates in third-axis collision gating.
+  - `field_40` is a heap string freed by `free_loaded_world_data`, but no confirmed live consumer has been recovered yet.
+- `RegionRecord` is now structurally clear:
+  - `min_x`, `min_y`, `max_x`, `max_y`, `floor_z`, `ceiling_z`
+  - `facing`, `region_name`, `room_name`, `action_tag`
+  - `start_enabled`, `runtime_enabled`, `flag_2a`, `next`
+- `NpcRecord` now has stable placement/runtime fields:
+  - `pos_x`, `pos_y`, `pos_z`, `frame_delay`
+  - `model_path`, `npc_name`, `room_name`, `audio_path`, `entity_init_arg`
+  - `runtime_spawned`, `runtime_actor`
+  - parser booleans are `active` and `visible`; a third byte mirrors `visible` at load time and appears to be the persisted visibility state
+  - `monsterfy_target_name` is consumed by the `change2monster` / monsterfy path in `FUN_0004d750`
+  - `on_death_action_tag` is dispatched when the NPC dies without transforming
+- `MonsterRecord` now has stable placement/spawn state and resource fields:
+  - `pos_x`, `pos_y`, `pos_z`, `engage_distance`
+  - `damage_amount`, `initial_hit_points`, `current_hit_points`
+  - `damage_type`, `facing`, `runtime_actor`
+  - `sprite_path`, `monster_name`, `room_name`
+  - sound slots:
+    - `attack_sound_1` through `attack_sound_3`
+    - `hit_sound_1` through `hit_sound_3`
+    - `footstep_sound_left`, `footstep_sound_right`, `death_sound`
+  - `min_x_bound`, `max_x_bound`
+  - parser booleans are `active` and `visible`; `visible` drives actor spawn/remove in `SET_MONSTER`, while `active` is copied into the live actor's `0x52` state byte
+  - `on_death_action_tag` is dispatched when the monster finishes its death/remove state
+  - `damage_amount` is copied into the live actor state at `+0x1180` and is subtracted from target HP on a successful hit; the player combat avatar seeds the same slot from weapon presets.
+  - `field_70` is the attack-sound trigger frame
+  - `field_78` is the walk / footstep-sound trigger frame
+  - `saved_enabled`, `runtime_spawned`, `next`
+  - `current_hit_points` is the save/load-persisted monster HP field; parser initialization copies `initial_hit_points` into it.
+  - `min_x_bound` defaults to `0x14` and `max_x_bound` defaults to `0x26b` when omitted by the parser.
+  - The sound-slot mapping is confirmed both by `spawn_monster_entity_from_record` and by the player combat avatar builder, which installs the same runtime sound layout explicitly.
+  - `field_38`, `field_3c`, `field_44`, and `field_48` are heap strings freed during world teardown, but no confirmed live consumers have been recovered yet.
+  - `field_74` and `field_7c` are copied into live runtime slots `+0x1170` and `+0x1178`, but the current binary only writes those slots; no read-side consumers were recovered.
+  - Remaining unknowns include `field_1c`, `field_38`, `field_3c`, `field_44`, `field_48`, `field_74`, `field_7c`
+- `AnimRecord` now has stable owner/resource identity plus mirrored runtime state:
+  - `pos_x`, `pos_y`, `pos_z`, `frame_delay`
+  - `room_name`, `anim_path`, `anim_name`
+  - script booleans: `active`, `visible`, `loop`, `backward`, `ping_pong`, `remove`
+  - runtime mirrors `runtime_active`, `runtime_visible`, `runtime_state`
+- `TextRecord` now has stable string identity:
+  - `text_id`, `panel_id`, `text_value`, `next`
+  - The parser normalizes `_` to space characters in `text_value`
+- `DialogueIndexEntry` is now explicit:
+  - `wav_id`, `text_offset`, `text_length`
+- `DirtyRectNode` is now explicit:
+  - `left`, `top`, `right`, `bottom`, `next`
+- `RawBitmap` is now explicit:
+  - `width`, `height`, `pixels`
+- `TimerRecord` now has stable timing/key fields:
+  - `initial_value`, `current_value`, `timer_name`, `arg1`, `arg2`, `enabled`, `next`
+  - parser booleans are `loop` and `global`
+- `UseItemRecord` is stable:
+  - `item_name`, `target_name`, `action_tag`, `next`
+- `RoomRecord` is partly recovered:
+  - stable fields: `room_index`, `min_z`, `max_z`, `max_z_screen_y`, `min_z_screen_y`, `full_scale_z`, `perspective_scale`, `max_z_scale_percent`, `z_velocity_step`, `on_enter_tag`, `on_exit_tag`, `room_name`, `music_path`, `palette_path`, `dimmable`, `next`
+  - `max_z_screen_y` / `min_z_screen_y` define the screen-Y band that maps cursor position to room depth between `max_z` and `min_z`
+  - `full_scale_z` is the room Z depth where entities remain at 100% sprite scale.
+  - `max_z_scale_percent` is the sprite scale percentage at `max_z`; the parser derives `perspective_scale` from `(100 - max_z_scale_percent) / (max_z - full_scale_z)`, and the runtime applies it with the additional `0.01` factor stored at `0xbed80`.
+  - `z_velocity_step` is the room-specific vertical movement increment used by the main actor state machine
+  - the trailing unknown room slots at `field_38`, `field_3c`, and `field_40` are confirmed `char *` fields.
+  - parser order is now explicit: after `room_name` and `music_path`, the parser reads three additional strings into `field_38`, `field_3c`, and `field_40`, then reads the separately validated `palette_path`, then the `dimmable` token, then `on_enter_tag` / `on_exit_tag`.
+  - `free_loaded_world_data` frees `palette_path`, `field_38`, `field_3c`, and `field_40` as room-owned strings, which confirms that the old `runtime_40` interpretation was wrong.
+
+## Helper Functions
+
+- `strcmp_ascii` at `0x86430` is a fast `strcmp`-style helper that returns `0` on equality.
+- `atoi_decimal` at `0x869d4` is a small decimal parser used heavily by the room event system.
+- `rescale_entity_sprite_for_depth` at `0x4b440` rescales an entity sprite from the current room depth model.
+  - It applies the room-wide depth slope derived from `full_scale_z`, `max_z_scale_percent`, and `max_z`, rescales the backing bitmap, and updates the runtime width / height / anchor offsets.
+- `set_entity_screen_position` at `0x4b6f0` writes the live entity screen-space `x`, `y`, and depth/z anchor used by overlap and render ordering.
+- `do_entity_screen_bounds_overlap` at `0x4b700` is the rectangle-only overlap test used before the more specific cursor-hit and actor/region gating paths.
+- `hide_entity_visual` at `0x4c260` and `show_entity_visual` at `0x4c290` are the nested runtime visibility wrappers for render entities.
+  - `hide_entity_visual` decrements the visibility nesting and removes the entity from `g_render_entity_list` only on the transition to hidden.
+  - `show_entity_visual` increments the nesting and, when the entity becomes visible again, refreshes the current frame bitmap and re-adds it to `g_render_entity_list`.
+- `spawn_scaled_abm_entity_from_resource` at `0x4bda0` is the scale-managed variant of `spawn_abm_entity_from_resource`.
+  - It loads the same ABM resource format, but additionally marks the runtime entity for depth scaling and seeds the live sprite-scale field before the first `show_entity_visual` / render-list add.
+  - `run_harvester_main_loop` uses it for `IDLE_ANIM` with `graphic\\roomanim\\pcloun02.abm`, copying the live player avatar's current scale so the temporary idle actor inherits the same room-depth sizing.
+- `tick_monster_entity_runtime` at `0x54140` is the class-6 monster wrapper around `update_actor_runtime_state`.
+  - `run_harvester_main_loop` uses it for live monster entities, and the wrapper tears down the runtime object when the shared actor state machine reports removal.
+- `spawn_player_combat_avatar` at `0x54220` reuses the monster runtime entity base for the player during combat / weapon-view mode.
+  - It seeds player HP/state and installs the weapon-dependent attack / hit / footstep / death sounds.
+- `set_player_combat_loadout` at `0x55c10` applies the persisted player combat loadout selection stored at live actor field `+0x11bc`.
+  - It is called by the inventory UI when the player changes weapons and by save/load restoration when the saved selection differs from the current live one.
+  - It tears down the current attack sound bank, loads the new weapon-dependent attack sounds, reloads the `pc*.abm` actor art, and refreshes the player combat avatar's attack tuning.
+  - The ABM path table it indexes is now labeled `g_player_combat_loadout_abm_paths` at `0xc3eb4`.
+- `sync_player_combat_weapon_resource_icons` at `0x792c0` updates the HUD resource-icon strip/count pair for the current player combat loadout.
+  - The loadout-to-resource mapping now confirmed from `set_player_combat_loadout` is:
+    - `2`: nailgun
+    - `3`: shotgun shells
+    - `4`: `9GUN` bullet strip (`MMBULLET%i`, `bullet.bm`)
+    - `5`: `38GUN` shotshell strip (`SHOTSHEL%i`, `shotshel.bm`)
+    - `0xe`: chainsaw fuel
+  - The corresponding globals are now named `g_nailgun_ammo_icon_strip`, `g_shotgun_shell_icon_strip`, `g_9gun_bullet_icon_strip`, `g_38gun_shotshell_icon_strip`, `g_chainsaw_fuel_icon_strip`, their matching count fields, and `g_visible_weapon_resource_icon_count`.
+- `teardown_player_combat_avatar` at `0x55010` removes the live player combat avatar and frees its weapon-dependent runtime resources before a rebuild or shutdown path.
+- `check_player_region_interaction` at `0x559e0` scans `g_region_records` for the first enabled region whose bounds overlap the live player avatar and whose facing gate matches the player's orientation.
+  - On success it returns the matching `RegionRecord *` so the main loop can dispatch the region's `action_tag`.
+- `reset_player_combat_avatar` at `0x56a40` is the player-avatar reset helper used on teardown / restart paths.
+  - It zeroes transient combat/runtime state, restores the default health/state baseline, and if the current loadout is non-default it rebuilds the player combat avatar back through loadout `0`.
+- `update_actor_runtime_state` at `0x4d750` is the shared per-frame actor state machine for live actor entities.
+  - It is called from `run_harvester_main_loop`, the dialogue/response/keyword modal loops, and the player-combat wrapper, so actor motion/combat continues advancing while blocking UI is active.
+  - It handles class-4/5/6 actor entities, advances animation/state transitions, maintains pursuit spacing against the player using `engage_distance`, applies room Z bounds and vertical motion, fires frame-timed sound hooks, resolves hit damage through `damage_amount`, and returns `0` when the caller should remove the actor entity from the world list.
+  - It also contains the confirmed NPC-to-monster replacement path used by the `change2monster` flow.
+  - Runtime bytes `+0x11a1` and `+0x11a0` are now bounded separately:
+    - `+0x11a1` is the actor dispatch-state byte; `update_actor_runtime_state` switches on it directly.
+    - `+0x11a0` is the latched animation/state byte; it is usually mirrored into `+0x11a1` on direct state changes, but remains distinct during entry, turn, and terminal sequences.
+  - The state families are now bounded at a subsystem level:
+    - `0` / `4` are the room-`min_z` locomotion family.
+    - `3` / `0xb` are the room-`max_z` locomotion family.
+    - `1` / `7` and `2` / `0xe` are the two opposing horizontal locomotion families.
+    - `5`, `6`, `9`, `0xa`, `0xc`, `0xd`, `0x10`, and `0x11` are bridging turn/transition handlers between those locomotion families.
+    - `0x16` through `0x1b` are attack states; on a confirmed hit they assign victim states `0x1c` through `0x21` via `attacker_state + 6`, which bounds `0x1c` through `0x21` as hit-reaction states.
+    - `0x28` through `0x33` are death-animation entry states. They play the actor death sound, run the final animation bank, and fall into terminal state `0x38`.
+    - `0x35` is the NPC death / monsterfy transition state, and `0x38` is the terminal dead / removed state that dispatches `on_death_action_tag`.
+- `update_player_combat_avatar_state` at `0x553a0` is the player-side wrapper around `update_actor_runtime_state`.
+  - It reads combat input globals, translates them into desired player actor states, updates the player combat avatar, and then delegates the shared animation/combat resolution to `update_actor_runtime_state`.
+  - Shared input globals recovered from both the town-map UI and player-combat wrapper are now named:
+    - `g_input_up_pressed` at `0xd5954`
+    - `g_input_left_pressed` at `0xd5957`
+    - `g_input_right_pressed` at `0xd5959`
+    - `g_input_down_pressed` at `0xd595c`
+  - `g_player_attack_pressed` at `0xd5929` is the player-only combat input that selects attack states `0x16` through `0x1b`, and `g_player_attack_cooldown_deadline` at `0xd5a74` rate-limits repeated attack entry.
+  - The same wrapper also confirms that live actor field `+0x11bc` is the player combat loadout selection: it is seeded by `spawn_player_combat_avatar`, changed by `set_player_combat_loadout`, and serialized through the save/load path.
+  - `g_input_right_shift_pressed` at `0xd5942` is the separate Right Shift modifier gate, not a duplicate attack input.
+  - Under that gate, `g_input_up_pressed` selects states `0x12` / `0x13` by facing, and those states apply horizontal velocity `-16` / `+16`, opposite the current facing family. That bounds `0x12` / `0x13` as fast reverse-step states.
+  - The paired down-input states `0x14` / `0x15` are selected from the same modifier gate and use the slower signed horizontal speeds `-8` / `+8`, again opposite the current facing family. That bounds `0x14` / `0x15` as the slower reverse-step pair.
+  - States `0x39` and `0x3a` are now bounded as forced horizontal shove states. They install fixed left/right-facing animation banks, drive `x` velocity as `-8` / `+8`, and fall back to locomotion states `1` / `2` once the actor hits world bounds or a blocking entity.
+- `set_object_visibility_for_owner_or_room` at `0x62f10` updates an object's `visible` byte for a specific owner/room string and synchronizes the live entity when that location is currently active.
+  - Callers pass `EBX = 1` for show and `EBX = 0` for hide; the helper is used by room-event opcodes and by the inventory-status panel toggles.
+- `is_object_in_inventory` at `0x7c870` checks whether a named object exists, is currently owned by `INVENTORY`, and is visible.
+- `add_object_to_inventory` at `0x7c8b0` moves a named object into `INVENTORY`, forces it visible, and marks its IDENT text as already shown.
+- `dispatch_action_tag_if_set` at `0x7cb80` is a tiny guard thunk that jumps into `dispatch_room_event_actions` only when the supplied action-tag pointer is non-null.
+- `run_inventory_screen` at `0x7df10` is the inventory UI loop.
+  - It lays out carried objects, shows `inventory_text_key` strings from `TEXT` records, handles use-on-target dispatch, and swaps the status panel object based on current health.
+- `run_game_over_screen` at `0x7c540` is the death/game-over UI branch reached from the main loop after the player combat avatar enters its terminal failure path.
+- The low-level PCM/sample helpers are now bounded:
+  - `load_sound_sample` at `0x18470` loads a one-shot sample through XFILE, accepting raw `WAVE` data or `FCMP`-compressed audio, and seeds the sample format/rate metadata used by the audio backend.
+  - `start_sound_state_playback` at `0x197b0` allocates a backend voice slot for an initialized `PcmSoundState` and records the active playback token in that state.
+  - `is_sound_state_playing` at `0x198e0` polls whether the recorded voice/token pair for a `PcmSoundState` is still active.
+  - `stop_sound_state_playback` at `0x19840` stops the active backend voice for a `PcmSoundState` without freeing the sample data.
+  - `free_sound_effect_sample` at `0x18a30` stops any active playback and releases the loaded sample buffer.
+  - The backend state those helpers rely on is now partially explicit:
+    - `g_sound_driver_initialized` gates all recovered backend voice/sample operations.
+    - `g_sound_voice_bank_index` selects the current bank inside `g_sound_voice_slot_refs`.
+    - `g_sound_voice_slot_refs` at `0xc445c` is a banked table of 32 six-byte far pointers to backend voice control blocks per bank.
+    - `g_sound_voice_token_counter` increments on each new playback start and is copied into both the sample state and the backend voice block to invalidate stale slot handles.
+  - The slot-level backend helpers are now named:
+    - `allocate_sound_voice_slot` at `0x82f06`
+    - `is_sound_voice_slot_stopped` at `0x85bf1`
+    - `stop_sound_voice_slot` at `0x8351f`
+    - `set_sound_voice_volume` at `0x85510`
+    - `get_sound_voice_playback_position` at `0x856e6`
+  - The backend timer/rate control path is now explicit:
+    - `set_sound_voice_sample_rate` at `0x85050` stores a requested playback rate in `g_sound_voice_sample_rates`, recomputes the per-voice fixed-point step in `g_sound_voice_step_increments`, and lowers the shared mixer divisor when a faster voice requires it.
+    - `clear_sound_voice_sample_rate` at `0x851c8` clears one voice's requested rate, recomputes the highest remaining active rate across the 16 backend voice slots, and rebuilds the shared divisor/step table.
+    - `set_sound_mixer_timer_divisor` at `0x853a1` writes `g_sound_mixer_timer_divisor` and immediately calls `program_irq0_pit_divisor`.
+    - `program_irq0_pit_divisor` at `0x8b1ad` masks IRQ0, programs PIT channel 0 through ports `0x43` / `0x40`, and then unmasks IRQ0 again.
+    - `install_irq0_timer_handler` at `0x8b30d`, together with `mask_irq0_timer_interrupt` and `unmask_irq0_timer_interrupt`, is the low-level IRQ0 refresh path that reinstalls the recovered timer handler and restarts the counter.
+    - The constant `0x1234dc` in the rate math is the PIT input clock `1193180`, which is what anchors the divisor and step-increment interpretation.
+  - The higher-level streamed-music state on top of that backend is now explicit:
+    - `g_music_stream_state` at `0xca1d4` is the persistent room/menu/game-over music stream object.
+    - `g_current_music_path` caches the current music filename so room transitions and scripted overrides can restore it.
+    - `g_music_volume_level_index` indexes `g_audio_volume_curve`, the shared 16-bit volume ramp table used by both steady-state music volume and the fade helpers.
+    - `PcmSoundState` is the confirmed 0x88-byte base header shared by music streams and one-shot PCM/sample playback.
+      - The recovered fields are the active voice slot/token, ring-buffer pointer/segment/size, current volume-curve value, backend PCM flags, sample rate, and output bits per sample.
+    - `MusicStreamState` extends that base with the backing file handle/size, refill/pause bytes, fade controller fields, decoded chunk buffer pointer, embedded `ImaAdpcmDecodeState`, the raw 14-byte `FCMP` header buffer, the transient compressed chunk buffer, and the cached current path string.
+    - `ImaAdpcmDecodeState` is the embedded 0x30-byte `FCMP` decode sub-structure at `MusicStreamState+0xbc`.
+      - The recovered fields are the source/destination far pointers, compressed and decoded byte counts, nibble index, predictor, delta, cached source byte, current nibble, step size, step index, and output bits per sample.
+  - The stream-layer helpers are now named:
+    - `start_music_stream` at `0x1bfc0` swaps to a new room/menu music file, opens the backing XFILE stream, primes the first audio chunk, and allocates a backend voice slot.
+    - `update_music_stream` at `0x1b9a0` is the per-frame pump used by menus, dialogue waits, room logic, and the main loop; it refills stream buffers and applies pending volume ramps.
+    - `pause_music_stream` at `0x1a620` stops the active voice without closing the underlying stream.
+    - `resume_music_stream` at `0x1a740` restarts a previously paused music stream.
+    - `fade_in_music_stream` at `0x1adf0` and `fade_out_music_stream` at `0x1b3b0` are the paired ramp controllers used by town-map and scripted transition paths.
+    - `set_music_stream_volume` at `0x19950` updates the live stream volume against the active backend voice.
+    - `get_sound_state_playback_position` at `0x199f0` returns the current playback cursor for an active `PcmSoundState` and is used by the FST movie player for A/V pacing.
+  - The shared PCM setup helpers beneath the sample and movie paths are now explicit:
+    - `initialize_sound_state_defaults` zeros the generic sound-state header used by dialogue-line playback and FST one-shots, then seeds the default volume-curve entry and baseline flags.
+    - `initialize_pcm_sound_state` allocates and zeroes the ring/sample buffer for a PCM stream, seeds sample rate / sample width metadata, and installs the default effects-volume curve entry.
+    - `get_pcm_byte_rate` returns `sample_rate * (bits_per_sample / 8)` for an initialized sound state.
+    - `queue_pcm_chunk_into_ring_buffer` copies a decoded PCM chunk into the shared ring buffer without overwriting the live playback cursor reported by the backend voice slot.
+    - The `FCMP` compressed-audio path is now bounded as IMA ADPCM:
+      - `initialize_ima_adpcm_decode_state` resets the embedded decode-state predictor, nibble index, and initial step values before a new compressed stream begins.
+      - `decode_ima_adpcm_chunk` expands `FCMP` payload bytes into 8-bit or 16-bit PCM using `g_ima_adpcm_index_adjust_table` and `g_ima_adpcm_step_table`.
+    - `set_sound_state_driver_control_handle`, `set_sound_state_driver_control_value`, `set_driver_control_entry_value`, and `g_sound_driver_control_entry_refs` expose a second, driver-side control indirection table:
+      - the sound state stores a handle/index at offset `+0x18`
+      - the wrapper path uses that handle to write a 16-bit value into offset `+4` of the selected driver control entry
+      - the exact gameplay meaning of that control value is still unresolved, but the indirection itself is confirmed
+  - `load_dialogue_voice_sample` at `0x191d0` is the `play_dialogue_line`-specific loader that accepts either `WAVE` or `FCMP` voice assets and prepares them for one-shot playback through the shared sample backend.
+  - `stop_sound_state_and_close_stream_file` at `0x1c4e0` is the shared cleanup path that stops active backend playback for a sound state and closes its backing stream/file handle at offset `+0x88`.
+  - `run_fst_sequence_player` at `0x12b00` is the inner FST decoder/player reached from `play_fst_sequence`.
+    - It reads the FST frame table, optional audio payload, performs the actual frame decode loop, and uses `get_sound_state_playback_position` to keep movie frames aligned with the loaded soundtrack.
+    - `upload_vga_palette` at `0x482c0` is the shared 256-entry VGA DAC upload helper used by the FST player when it restores or swaps palette pages.
+    - `load_pcx_bitmap` at `0x4a230` is the direct-file `PCX` loader used by the FST censorship path; it reads the whole file through XFILE, RLE-decodes the `0x80`-byte-header payload into a `RawBitmap`, trims oversized stride bytes, and optionally uploads the trailing 256-color palette.
+- `run_harvester_main_loop` maintains `g_current_interaction_text`, `g_pending_interaction_text`, and `g_interaction_text_entity` for hover / use prompts.
+  - It allocates both 100-byte text buffers during startup, formats prompts such as `Examine %s`, `Talk to %s`, `Pick up the %s`, `Operate the %s`, and `Use %s on %s`, suppresses prompts when the active label is `NULL_ID`, and only rebuilds the rendered label when the pending text differs from the current text.
+- `run_text_entry_dialog` at `0x59fe0` is the shared keyboard text-entry helper.
+  - It is called from the save-game UI and the dialogue keyword UI, allocates a caller-sized entry buffer, handles keyboard editing and cancel/accept paths, and returns the entered string or `NULL`.
+- `spawn_text_entity` at `0x59c00` and `spawn_text_entry_entity` at `0x59f40` are the shared rendered-text constructors for menus, prompts, quick tips, and dialogue UI.
+  - They consume the `load_font_resource` font blobs, attach their results to `g_render_entity_list`, and rely on `g_dirty_rect_list` plus `flush_dirty_rects_to_screen` for incremental redraw.
+- `run_dialogue_response_menu` at `0x7af10` is the slash-delimited response list UI used by the NPC dialogue system.
+- `run_dialogue_keyword_menu` at `0x7ba40` is the slash-delimited keyword menu plus free-text `Other` path used by the NPC dialogue system.
+
+## Current Blockers
+
+- The remaining work is no longer list recovery. The blocker is semantic naming for fields whose shape is clear but whose gameplay meaning is still ambiguous.
+- The main unresolved clusters are:
+  - `MonsterRecord.field_1c`, which is still an untyped numeric field with no confirmed consumer
+  - `MonsterRecord.field_38`, `field_3c`, `field_44`, and `field_48`, which are bounded as monster-owned heap strings but still have no confirmed live consumers
+  - `MonsterRecord.field_74` and `field_7c`, which are copied into live runtime slots at `+0x1170` and `+0x1178`; constructor/reset writes were recovered, but no read-side consumers were found in the current binary
+  - the exact persisted-visibility semantics of `NpcRecord.saved_visible`
+  - `ObjectRecord.field_34`, which is a parsed heap string but is still absent from the direct `find_object_record_by_name` consumers and from the object save/load blob
+  - `ObjectRecord.field_40`, which is now bounded as an object-owned heap string, but still has no confirmed live consumer
+  - `RoomRecord` remaining string fields at `field_38`, `field_3c`, `field_40`
+  - `TextRecord.field_00` / `field_04`
+- The monster frame slots at `field_74` and `field_7c` are now more tightly bounded: they are copied into the shared runtime sound-trigger area (`+0x1170` and `+0x1178`) by the monster and player constructors, but no confirmed consumers have been recovered yet, so they remain unnamed.
+- The remaining actor-state work is narrower now:
+  - the shared input/UI bytes at `0xd596c` and `0xd5971` through `0xd597c`; the keyboard producer side is now resolved through the IRQ1 handler, but the mouse/button producer path is still not named
+- On March 13, 2026 I resumed with a live bridge and used Java Ghidra scripts to push the recovered struct names back into `HARVEST.LE`.
+- The active Ghidra session is still not PyGhidra-enabled, so Python scripts remain unavailable inside Ghidra. That is no longer a hard blocker, but it makes broad decompiler-search automation heavier and pushes this pass toward narrower Java-script or endpoint-driven searches.
diff --git a/TRACKER.md b/TRACKER.md
new file mode 100644
index 00000000000..47f10aae632
--- /dev/null
+++ b/TRACKER.md
@@ -0,0 +1,21 @@
+# TRACKER
+
+## Last Confirmed Action
+
+- Confirmed the `room_setup` palette handoff and runtime cursor animation path before extending the startup stub.
+  - Renamed `FUN_00023a30` to `ramp_palette_brightness`; it fades a palette in `0.1` steps on an approximately 4 ms timer by repeatedly calling `upload_palette_to_vga`.
+  - `upload_palette_to_vga` forces DAC color 0 to black and scales the remaining palette entries by a brightness factor before writing ports `0x3c8` / `0x3c9`.
+  - `set_entity_animation_sequence` maps each cursor sequence to a 10-frame run, so sequence `7` is `POINTERS.ABM` frames `70..79`.
+  - `sync_cursor_entity_position` updates `g_cursor_entity` to `g_cursor_screen_x` / `g_cursor_screen_y` and then advances the normal entity visual-state tick, which is why the runtime cursor animates inside the dirty-rect render path.
+  - ScummVM startup flow now mirrors that evidence closely enough for the stub: it shows the wait transition first, fades room palettes in from black toward `1.0` or `0.6`, and animates the startup cursor with the recovered sequence-`7` timing instead of blitting frame `0`.
+- Current live-state counts:
+  - `HARVEST.LE` currently has `751` total functions
+  - `377` have custom/documented names
+  - `374` still remain unnamed / `FUN_*`
+
+## Next Suggested Action
+
+- Highest-value targets:
+  - type the `ROOM` record more aggressively from `room_setup`, starting with the already supported fields around room name, palette path, music path, enter/exit tags, dimmable, and the perspective/scale values still showing as raw offsets
+  - type the runtime cursor/entity record next around the confirmed animation fields at `+0x105c`, `+0x1060`, `+0x106c`, `+0x1078`, `+0x112c`, and `+0x1130`
+  - once those structs are cleaner, revisit `room_setup`, `spawn_object_entity_from_record`, `spawn_anim_entity_from_record`, and `dispatch_room_event_actions` so the remaining entity-build side of the first room can move out of placeholder status without speculative names
diff --git a/engines/harvester/startup_art.cpp b/engines/harvester/startup_art.cpp
index 5b7c2761d41..d18e14ccf1d 100644
--- a/engines/harvester/startup_art.cpp
+++ b/engines/harvester/startup_art.cpp
@@ -41,10 +41,6 @@ static const int kLogoX = 160;
 static const int kLogoY = 0;
 static const int kWaitX = 250;
 static const int kWaitY = 160;
-static const int kQuickTipsOverlayX = 167;
-static const int kQuickTipsOverlayY = 200;
-static const int kCursorX = 320;
-static const int kCursorY = 200;
 
 static const char *const kTextboxPaths[] = {
 	"1:/GRAPHIC/OTHER/TEXTBOX1.BM",
@@ -69,9 +65,6 @@ bool StartupArt::load(ResourceManager &resources) {
 	_ammoIcons.clear();
 	_inventoryBitmap = IndexedBitmap();
 	_logoBitmap = IndexedBitmap();
-	_pcRoomBitmap = IndexedBitmap();
-	_pcRoomMaskA = IndexedBitmap();
-	_pcRoomMaskB = IndexedBitmap();
 	_tipsBitmap = IndexedBitmap();
 
 	return loadPalette(resources, "1:/GRAPHIC/PAL/WAIT.PAL", _waitPalette) &&
@@ -94,13 +87,7 @@ bool StartupArt::loadQuickTipsResources(ResourceManager &resources) {
 			return false;
 	}
 
-	return loadAnimation(resources, "1:/GRAPHIC/MONSTERS/PC/PC0.ABM", _pcActorFrames) &&
-	       loadPalette(resources, "1:/GRAPHIC/PAL/PCROOM.PAL", _pcRoomPalette) &&
-	       loadBitmap(resources, "1:/GRAPHIC/ROOMS/PCROOM.BM", _pcRoomBitmap) &&
-	       loadBitmap(resources, "1:/GRAPHIC/MASKS/PCRPRTA.BM", _pcRoomMaskA) &&
-	       loadBitmap(resources, "1:/GRAPHIC/MASKS/PCRPRTB.BM", _pcRoomMaskB) &&
-	       loadAnimation(resources, "1:/GRAPHIC/ROOMANIM/PCRMCLOK.ABM", _pcRoomClockFrames) &&
-	       loadBitmap(resources, "1:/GRAPHIC/OTHER/TIPS.BM", _tipsBitmap);
+	return loadBitmap(resources, "1:/GRAPHIC/OTHER/TIPS.BM", _tipsBitmap);
 }
 
 void StartupArt::drawWaitFrame() const {
@@ -115,18 +102,6 @@ void StartupArt::drawWaitFrame() const {
 	g_system->updateScreen();
 }
 
-void StartupArt::drawQuickTipsScreen() const {
-	if (!_pcRoomBitmap.isValid())
-		return;
-
-	g_system->getPaletteManager()->setPalette(_pcRoomPalette, 0, 256);
-	g_system->fillScreen(0);
-	blitBitmap(_pcRoomBitmap, 0, 0);
-	blitBitmap(_tipsBitmap, kQuickTipsOverlayX, kQuickTipsOverlayY);
-	blitAnimationFrame(_pointerFrames, 0, kCursorX, kCursorY);
-	g_system->updateScreen();
-}
-
 bool StartupArt::loadPalette(ResourceManager &resources, const Common::String &path, byte *dest) const {
 	Common::Array<byte> data;
 	if (!resources.loadFile(path, data) || data.size() < 256 * 3) {
diff --git a/engines/harvester/startup_art.h b/engines/harvester/startup_art.h
index 4cbe893c382..d76d286dd22 100644
--- a/engines/harvester/startup_art.h
+++ b/engines/harvester/startup_art.h
@@ -48,15 +48,12 @@ public:
 	bool load(ResourceManager &resources);
 	bool loadQuickTipsResources(ResourceManager &resources);
 	void drawWaitFrame() const;
-	void drawQuickTipsScreen() const;
 
 	const byte *getWaitPalette() const { return _waitPalette; }
-	const byte *getPcRoomPalette() const { return _pcRoomPalette; }
 	const Common::Array<AbmFrame> &getWaitFrames() const { return _waitFrames; }
 	const Common::Array<AbmFrame> &getPointerFrames() const { return _pointerFrames; }
 	const IndexedBitmap &getInventoryBitmap() const { return _inventoryBitmap; }
 	const IndexedBitmap &getLogoBitmap() const { return _logoBitmap; }
-	const IndexedBitmap &getPcRoomBitmap() const { return _pcRoomBitmap; }
 	const IndexedBitmap &getTipsBitmap() const { return _tipsBitmap; }
 
 private:
@@ -68,18 +65,12 @@ private:
 	void blitAnimationFrame(const Common::Array<AbmFrame> &frames, uint frameIndex, int x, int y) const;
 
 	byte _waitPalette[256 * 3] = { 0 };
-	byte _pcRoomPalette[256 * 3] = { 0 };
 	Common::Array<AbmFrame> _waitFrames;
 	Common::Array<AbmFrame> _pointerFrames;
-	Common::Array<AbmFrame> _pcRoomClockFrames;
-	Common::Array<AbmFrame> _pcActorFrames;
 	Common::Array<IndexedBitmap> _textboxes;
 	Common::Array<IndexedBitmap> _ammoIcons;
 	IndexedBitmap _inventoryBitmap;
 	IndexedBitmap _logoBitmap;
-	IndexedBitmap _pcRoomBitmap;
-	IndexedBitmap _pcRoomMaskA;
-	IndexedBitmap _pcRoomMaskB;
 	IndexedBitmap _tipsBitmap;
 };
 
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 07e33a63944..be9e8b081cf 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -26,6 +26,7 @@
 #include "common/events.h"
 #include "common/formats/ini-file.h"
 #include "common/memstream.h"
+#include "common/system.h"
 #include "graphics/font.h"
 #include "graphics/fontman.h"
 #include "graphics/framelimiter.h"
@@ -56,6 +57,13 @@ static const int kQuickTipTextWidth = 280;
 
 static const int kMenuStartY = 100;
 static const int kMenuLineSpacing = 28;
+static const uint kCursorSequence7FirstFrame = 70;
+static const uint kCursorSequence7LastFrame = 79;
+static const uint kCursorAnimationRate = 10;
+static const uint32 kCursorAnimationTickMs = 100 / kCursorAnimationRate;
+static const uint32 kPaletteFadeTickMs = 4;
+static const float kPaletteFadeStep = 0.1f;
+static const float kPaletteBrightnessBlack = 0.0f;
 
 static const byte kTextColorNormal = 255;
 static const byte kTextColorHover = 251;
@@ -63,6 +71,19 @@ static const byte kTextColorDim = 248;
 static const byte kShadowColor = 0;
 static const byte kPanelFillColor = 1;
 
+struct StartupSceneOverlay {
+	StartupObjectRecord object;
+	IndexedBitmap bitmap;
+};
+
+struct StartupRoomSceneResources {
+	StartupRoomSetupState state;
+	byte palette[256 * 3] = { 0 };
+	IndexedBitmap background;
+	Common::Array<StartupSceneOverlay> overlays;
+	float targetPaletteBrightness = 1.0f;
+};
+
 static byte expand6BitColor(byte value) {
 	return (value * 255 + 31) / 63;
 }
@@ -110,6 +131,26 @@ static void drawWrappedShadowedText(Graphics::Screen &screen, const Graphics::Fo
 		drawShadowedString(screen, font, lines[i], x, y + i * lineHeight, width, color);
 }
 
+static void buildScaledPalette(const byte *source, float brightness, byte *dest) {
+	memset(dest, 0, 256 * 3);
+	if (!source)
+		return;
+
+	for (uint color = 1; color < 256; ++color) {
+		for (uint channel = 0; channel < 3; ++channel) {
+			const uint index = color * 3 + channel;
+			const int scaled = (int)(source[index] * brightness + 0.5f);
+			dest[index] = (byte)MIN<int>(scaled, 255);
+		}
+	}
+}
+
+static void setScaledPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
+	byte scaledPalette[256 * 3];
+	buildScaledPalette(palette, brightness, scaledPalette);
+	screen.setPalette(scaledPalette);
+}
+
 static bool loadPaletteResource(ResourceManager &resources, const Common::String &path, byte *dest) {
 	Common::Array<byte> data;
 	if (!resources.loadFile(path, data) || data.size() < 256 * 3)
@@ -144,9 +185,59 @@ static Common::Rect getObjectBounds(const StartupObjectRecord &object) {
 	return Common::Rect();
 }
 
-static const StartupObjectRecord *findRoomObjectAtPoint(const StartupRoomSetupState &state, const Common::Point &point) {
-	for (int i = (int)state.roomObjects.size() - 1; i >= 0; --i) {
-		const StartupObjectRecord &object = state.roomObjects[(uint)i];
+static void queueDrawableRoomObject(const StartupObjectRecord &object, Common::Array<StartupObjectRecord> &drawableObjects) {
+	if (object.resourcePath.empty() || !object.resourcePath.hasSuffixIgnoreCase(".BM"))
+		return;
+
+	for (const StartupObjectRecord &drawable : drawableObjects) {
+		if (drawable.ownerOrRoom.equalsIgnoreCase(object.ownerOrRoom) &&
+			drawable.objectName.equalsIgnoreCase(object.objectName)) {
+			return;
+		}
+	}
+
+	drawableObjects.push_back(object);
+}
+
+static bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceManager &resources, StartupRoomSceneResources &scene) {
+	scene = StartupRoomSceneResources();
+	scene.state = state;
+	scene.targetPaletteBrightness = state.paletteBrightness;
+	if (!loadPaletteResource(resources, state.palettePath, scene.palette) ||
+		!loadBitmapResource(resources, state.backgroundPath, scene.background)) {
+		return false;
+	}
+
+	Common::Array<StartupObjectRecord> drawableObjects;
+	for (const StartupObjectRecord &object : state.roomObjects) {
+		if (object.visible)
+			queueDrawableRoomObject(object, drawableObjects);
+	}
+	for (const StartupObjectRecord &object : state.activeObjects)
+		queueDrawableRoomObject(object, drawableObjects);
+
+	for (const StartupObjectRecord &object : drawableObjects) {
+		StartupSceneOverlay overlay;
+		overlay.object = object;
+		if (!loadBitmapResource(resources, object.resourcePath, overlay.bitmap))
+			continue;
+		scene.overlays.push_back(overlay);
+	}
+
+	return true;
+}
+
+static void drawRoomScene(Graphics::Screen &screen, const StartupRoomSceneResources &scene, float brightness) {
+	setScaledPalette(screen, scene.palette, brightness);
+	screen.fillRect(screen.getBounds(), 0);
+	blitBitmap(screen, scene.background, 0, 0);
+	for (const StartupSceneOverlay &overlay : scene.overlays)
+		blitBitmap(screen, overlay.bitmap, overlay.object.left, overlay.object.top);
+}
+
+static const StartupObjectRecord *findObjectAtPoint(const Common::Array<StartupObjectRecord> &objects, const Common::Point &point) {
+	for (int i = (int)objects.size() - 1; i >= 0; --i) {
+		const StartupObjectRecord &object = objects[(uint)i];
 		if (!object.active)
 			continue;
 
@@ -158,6 +249,14 @@ static const StartupObjectRecord *findRoomObjectAtPoint(const StartupRoomSetupSt
 	return nullptr;
 }
 
+static const StartupObjectRecord *findRoomObjectAtPoint(const StartupRoomSetupState &state, const Common::Point &point) {
+	const StartupObjectRecord *object = findObjectAtPoint(state.activeObjects, point);
+	if (object)
+		return object;
+
+	return findObjectAtPoint(state.roomObjects, point);
+}
+
 static Common::String trimAsciiLine(const Common::String &value) {
 	uint start = 0;
 	uint end = value.size();
@@ -170,6 +269,67 @@ static Common::String trimAsciiLine(const Common::String &value) {
 	return value.substr(start, end - start);
 }
 
+static uint getStartupCursorSequenceFirstFrame(const StartupArt &art) {
+	const Common::Array<AbmFrame> &frames = art.getPointerFrames();
+	if (frames.empty())
+		return 0;
+
+	return MIN<uint>(kCursorSequence7FirstFrame, frames.size() - 1);
+}
+
+static uint getStartupCursorSequenceLastFrame(const StartupArt &art) {
+	const Common::Array<AbmFrame> &frames = art.getPointerFrames();
+	if (frames.empty())
+		return 0;
+
+	return MIN<uint>(kCursorSequence7LastFrame, frames.size() - 1);
+}
+
+static void drawStartupCursor(Graphics::Screen &screen, const StartupArt &art, const Common::Point &mousePos,
+		uint frameIndex, bool visible) {
+	if (!visible)
+		return;
+
+	blitAnimationFrame(screen, art.getPointerFrames(), frameIndex, mousePos.x, mousePos.y);
+}
+
+static bool loadQuickTipsScene(HarvesterEngine &engine, StartupRoomSceneResources &scene) {
+	StartupRoomSetupState state;
+	if (!engine.getStartupScript()->resolveRoomSetupState("QUICK_TIPS", state, *engine.getResources()))
+		return false;
+
+	return loadRoomSceneResources(state, *engine.getResources(), scene);
+}
+
+static void renderQuickTipsScreen(HarvesterEngine &engine, const StartupRoomSceneResources &scene,
+		const Common::Point &mousePos, const Common::String &tipText, uint cursorFrameIndex, bool cursorVisible) {
+	Graphics::Screen *screen = engine.getScreen();
+	const StartupArt *art = engine.getStartupArt();
+	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	if (!screen || !art || !font)
+		return;
+
+	drawRoomScene(*screen, scene, scene.targetPaletteBrightness);
+	blitBitmap(*screen, art->getTipsBitmap(), kQuickTipsOverlayX, kQuickTipsOverlayY);
+
+	drawWrappedShadowedText(*screen, *font, tipText, kQuickTipTextX, kQuickTipTextY, kQuickTipTextWidth, kTextColorNormal);
+	const Common::Rect exitRect = quickTipsExitRect();
+	const Common::Rect nextRect = quickTipsNextRect();
+	const Common::Rect toggleRect = quickTipsToggleRect();
+	drawShadowedString(*screen, *font, "Exit", exitRect.left, exitRect.top, exitRect.width(),
+		exitRect.contains(mousePos) ? kTextColorHover : kTextColorNormal);
+	drawShadowedString(*screen, *font, "Next", nextRect.left, nextRect.top, nextRect.width(),
+		nextRect.contains(mousePos) ? kTextColorHover : kTextColorNormal);
+	drawShadowedString(*screen, *font,
+		engine.getStartupScript()->isQuickTipsEnabled() ? "Show_Tips_ON" : "Show_Tips_OFF",
+		toggleRect.left, toggleRect.top, toggleRect.width(),
+		toggleRect.contains(mousePos) ? kTextColorHover : kTextColorNormal);
+
+	drawStartupCursor(*screen, *art, mousePos, cursorFrameIndex, cursorVisible);
+	screen->makeAllDirty();
+	screen->update();
+}
+
 } // End of anonymous namespace
 
 StartupFlow::StartupFlow(HarvesterEngine &engine) : _engine(engine), _mousePos(320, 200) {
@@ -180,6 +340,8 @@ bool StartupFlow::load() {
 }
 
 Common::Error StartupFlow::run() {
+	resetCursorAnimationSequence();
+
 	Common::Error error = runQuickTips();
 	if (error.getCode() != Common::kNoError)
 		return error;
@@ -254,13 +416,34 @@ Common::Error StartupFlow::runQuickTips() {
 	if (!_engine.getStartupScript()->isQuickTipsEnabled() || _quickTips.empty())
 		return Common::kNoError;
 
+	Common::Error transitionError = beginRoomSetupTransition();
+	if (transitionError.getCode() != Common::kNoError)
+		return transitionError;
+
+	StartupRoomSceneResources scene;
+	if (!loadQuickTipsScene(_engine, scene))
+		return Common::kReadingFailed;
+
+	Graphics::Screen *screen = _engine.getScreen();
+	if (screen) {
+		drawRoomScene(*screen, scene, kPaletteBrightnessBlack);
+		screen->makeAllDirty();
+		screen->update();
+
+		transitionError = fadeInRoomScene(scene.palette, scene.targetPaletteBrightness);
+		if (transitionError.getCode() != Common::kNoError)
+			return transitionError;
+	}
+
+	resetCursorAnimationSequence();
+
 	uint tipIndex = _engine.getRandomNumber(_quickTips.size() - 1);
 	bool needsRedraw = true;
 	Graphics::FrameLimiter limiter(g_system, 60);
 
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
-			renderQuickTipsScreen(_quickTips[tipIndex]);
+			renderQuickTipsScreen(_engine, scene, _mousePos, _quickTips[tipIndex], _cursorFrameIndex, _cursorVisible);
 			needsRedraw = false;
 		}
 
@@ -291,6 +474,9 @@ Common::Error StartupFlow::runQuickTips() {
 			}
 		}
 
+		if (tickCursorAnimation())
+			needsRedraw = true;
+
 		limiter.delayBeforeSwap();
 		limiter.startFrame();
 	}
@@ -300,9 +486,16 @@ Common::Error StartupFlow::runQuickTips() {
 
 Common::Error StartupFlow::runMainMenuStub() {
 	Graphics::FrameLimiter limiter(g_system, 60);
-	Common::String statusMessage = "Stub handoff after room_setup(\"START\", \"QUICK_TIPS\").";
+	Common::String statusMessage = "Stub handoff after room_setup(\"QUICK_TIPS\") and before the main menu loop.";
 	int selectedItem = _menuItems.empty() ? -1 : 0;
 	bool needsRedraw = true;
+	const StartupArt *art = _engine.getStartupArt();
+	if (art) {
+		const uint firstFrame = getStartupCursorSequenceFirstFrame(*art);
+		const uint lastFrame = getStartupCursorSequenceLastFrame(*art);
+		if (_cursorFrameIndex < firstFrame || _cursorFrameIndex > lastFrame || !_cursorVisible)
+			resetCursorAnimationSequence();
+	}
 
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
@@ -393,6 +586,9 @@ Common::Error StartupFlow::runMainMenuStub() {
 			}
 		}
 
+		if (tickCursorAnimation())
+			needsRedraw = true;
+
 		limiter.delayBeforeSwap();
 		limiter.startFrame();
 	}
@@ -405,46 +601,13 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 	if (!_engine.getStartupScript()->resolveRoomSetupState(entranceName, state, *_engine.getResources()))
 		return Common::kReadingFailed;
 
-	byte palette[256 * 3] = { 0 };
-	IndexedBitmap background;
-	if (!loadPaletteResource(*_engine.getResources(), state.palettePath, palette) ||
-		!loadBitmapResource(*_engine.getResources(), state.backgroundPath, background)) {
-		return Common::kReadingFailed;
-	}
-
-	struct OverlayBitmap {
-		StartupObjectRecord object;
-		IndexedBitmap bitmap;
-	};
-	Common::Array<OverlayBitmap> overlays;
-	Common::Array<StartupObjectRecord> drawableObjects;
-	auto queueDrawableObject = [&](const StartupObjectRecord &object) {
-		if (object.resourcePath.empty() || !object.resourcePath.hasSuffixIgnoreCase(".BM"))
-			return;
-
-		for (const StartupObjectRecord &drawable : drawableObjects) {
-			if (drawable.ownerOrRoom.equalsIgnoreCase(object.ownerOrRoom) &&
-				drawable.objectName.equalsIgnoreCase(object.objectName)) {
-				return;
-			}
-		}
-
-		drawableObjects.push_back(object);
-	};
-	for (const StartupObjectRecord &object : state.roomObjects) {
-		if (object.visible)
-			queueDrawableObject(object);
-	}
-	for (const StartupObjectRecord &object : state.activeObjects)
-		queueDrawableObject(object);
+	Common::Error transitionError = beginRoomSetupTransition();
+	if (transitionError.getCode() != Common::kNoError)
+		return transitionError;
 
-	for (const StartupObjectRecord &object : drawableObjects) {
-		OverlayBitmap overlay;
-		overlay.object = object;
-		if (!loadBitmapResource(*_engine.getResources(), object.resourcePath, overlay.bitmap))
-			continue;
-		overlays.push_back(overlay);
-	}
+	StartupRoomSceneResources scene;
+	if (!loadRoomSceneResources(state, *_engine.getResources(), scene))
+		return Common::kReadingFailed;
 
 	Graphics::Screen *screen = _engine.getScreen();
 	const StartupArt *art = _engine.getStartupArt();
@@ -453,13 +616,23 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 	if (!screen || !art || !titleFont || !bodyFont)
 		return Common::kNoError;
 
+	drawRoomScene(*screen, scene, kPaletteBrightnessBlack);
+	screen->makeAllDirty();
+	screen->update();
+
+	transitionError = fadeInRoomScene(scene.palette, scene.targetPaletteBrightness);
+	if (transitionError.getCode() != Common::kNoError)
+		return transitionError;
+
+	resetCursorAnimationSequence();
+
 	Common::String statusMessage = Common::String::format(
 		"Resolved %s -> %s using %s and %s.",
 		entranceName.c_str(), state.roomName.c_str(), state.palettePath.c_str(), state.backgroundPath.c_str());
 	if (!state.musicPath.empty())
 		statusMessage += Common::String::format(" Music: %s.", state.musicPath.c_str());
-	if (!overlays.empty())
-		statusMessage += Common::String::format(" Visible scene objects: %u.", (uint)overlays.size());
+	if (!scene.overlays.empty())
+		statusMessage += Common::String::format(" Visible scene objects: %u.", (uint)scene.overlays.size());
 	statusMessage += " Click an active hotspot to follow its startup command chain.";
 	const Common::String baseStatusMessage = statusMessage;
 	StartupResolvedText inspectText;
@@ -471,11 +644,7 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
-			screen->setPalette(palette);
-			screen->fillRect(screen->getBounds(), 0);
-			blitBitmap(*screen, background, 0, 0);
-			for (const OverlayBitmap &overlay : overlays)
-				blitBitmap(*screen, overlay.bitmap, overlay.object.left, overlay.object.top);
+			drawRoomScene(*screen, scene, scene.targetPaletteBrightness);
 
 			const Common::Rect panel(72, 336, 568, 468);
 			screen->fillRect(panel, kPanelFillColor);
@@ -509,7 +678,7 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 			drawShadowedString(*screen, *bodyFont, footerMessage,
 				panel.left, 430, panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
 
-			blitAnimationFrame(*screen, art->getPointerFrames(), 0, _mousePos.x, _mousePos.y);
+			drawStartupCursor(*screen, *art, _mousePos, _cursorFrameIndex, _cursorVisible);
 			screen->makeAllDirty();
 			screen->update();
 			needsRedraw = false;
@@ -615,6 +784,9 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 			}
 		}
 
+		if (tickCursorAnimation())
+			needsRedraw = true;
+
 		limiter.delayBeforeSwap();
 		limiter.startFrame();
 	}
@@ -622,35 +794,95 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 	return Common::kNoError;
 }
 
-void StartupFlow::renderQuickTipsScreen(const Common::String &tipText) const {
-	Graphics::Screen *screen = _engine.getScreen();
+Common::Error StartupFlow::beginRoomSetupTransition() {
+	_cursorVisible = false;
+
 	const StartupArt *art = _engine.getStartupArt();
-	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	if (!screen || !art || !font)
-		return;
+	if (art)
+		art->drawWaitFrame();
 
-	screen->setPalette(art->getPcRoomPalette());
-	screen->fillRect(screen->getBounds(), 0);
+	Common::Error result = Common::kNoError;
+	if (pumpTransitionEvents(result))
+		return result;
 
-	blitBitmap(*screen, art->getPcRoomBitmap(), 0, 0);
-	blitBitmap(*screen, art->getTipsBitmap(), kQuickTipsOverlayX, kQuickTipsOverlayY);
+	return Common::kNoError;
+}
 
-	drawWrappedShadowedText(*screen, *font, tipText, kQuickTipTextX, kQuickTipTextY, kQuickTipTextWidth, kTextColorNormal);
-	const Common::Rect exitRect = quickTipsExitRect();
-	const Common::Rect nextRect = quickTipsNextRect();
-	const Common::Rect toggleRect = quickTipsToggleRect();
-	drawShadowedString(*screen, *font, "Exit", exitRect.left, exitRect.top, exitRect.width(),
-		exitRect.contains(_mousePos) ? kTextColorHover : kTextColorNormal);
-	drawShadowedString(*screen, *font, "Next", nextRect.left, nextRect.top, nextRect.width(),
-		nextRect.contains(_mousePos) ? kTextColorHover : kTextColorNormal);
-	drawShadowedString(*screen, *font,
-		_engine.getStartupScript()->isQuickTipsEnabled() ? "Show_Tips_ON" : "Show_Tips_OFF",
-		toggleRect.left, toggleRect.top, toggleRect.width(),
-		toggleRect.contains(_mousePos) ? kTextColorHover : kTextColorNormal);
+Common::Error StartupFlow::fadeInRoomScene(const byte *palette, float targetBrightness) {
+	Graphics::Screen *screen = _engine.getScreen();
+	if (!screen || !palette)
+		return Common::kNoError;
+
+	for (float brightness = kPaletteFadeStep; brightness < targetBrightness; brightness += kPaletteFadeStep) {
+		setScaledPalette(*screen, palette, brightness);
+		screen->makeAllDirty();
+		screen->update();
 
-	blitAnimationFrame(*screen, art->getPointerFrames(), 0, _mousePos.x, _mousePos.y);
+		const uint32 nextTick = g_system->getMillis() + kPaletteFadeTickMs;
+		while ((int32)(nextTick - g_system->getMillis()) > 0) {
+			Common::Error result = Common::kNoError;
+			if (pumpTransitionEvents(result))
+				return result;
+			g_system->delayMillis(1);
+		}
+	}
+
+	setScaledPalette(*screen, palette, targetBrightness);
 	screen->makeAllDirty();
 	screen->update();
+	return Common::kNoError;
+}
+
+bool StartupFlow::pumpTransitionEvents(Common::Error &result) {
+	Common::Event event;
+	while (g_system->getEventManager()->pollEvent(event)) {
+		if (handleSystemEvent(event, result))
+			return true;
+	}
+
+	return false;
+}
+
+void StartupFlow::resetCursorAnimationSequence() {
+	const StartupArt *art = _engine.getStartupArt();
+	if (!art) {
+		_cursorFrameIndex = 0;
+		_cursorNextFrameTick = 0;
+		_cursorVisible = false;
+		return;
+	}
+
+	_cursorFrameIndex = getStartupCursorSequenceFirstFrame(*art);
+	_cursorNextFrameTick = g_system->getMillis() + kCursorAnimationTickMs;
+	_cursorVisible = true;
+}
+
+bool StartupFlow::tickCursorAnimation() {
+	if (!_cursorVisible)
+		return false;
+
+	const StartupArt *art = _engine.getStartupArt();
+	if (!art || art->getPointerFrames().empty())
+		return false;
+
+	const uint firstFrame = getStartupCursorSequenceFirstFrame(*art);
+	const uint lastFrame = getStartupCursorSequenceLastFrame(*art);
+	if (_cursorFrameIndex < firstFrame || _cursorFrameIndex > lastFrame)
+		_cursorFrameIndex = firstFrame;
+
+	if (firstFrame == lastFrame)
+		return false;
+
+	const uint32 now = g_system->getMillis();
+	if ((int32)(now - _cursorNextFrameTick) < 0)
+		return false;
+
+	do {
+		_cursorFrameIndex = (_cursorFrameIndex >= lastFrame) ? firstFrame : _cursorFrameIndex + 1;
+		_cursorNextFrameTick += kCursorAnimationTickMs;
+	} while ((int32)(now - _cursorNextFrameTick) >= 0);
+
+	return true;
 }
 
 void StartupFlow::renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const {
@@ -686,7 +918,7 @@ void StartupFlow::renderMainMenuStub(int selectedItem, const Common::String &sta
 	drawShadowedString(*screen, *bodyFont, "Use mouse or arrow keys. Enter activates. Esc returns to launcher.",
 		panel.left, 404, panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
 
-	blitAnimationFrame(*screen, art->getPointerFrames(), 0, _mousePos.x, _mousePos.y);
+	drawStartupCursor(*screen, *art, _mousePos, _cursorFrameIndex, _cursorVisible);
 	screen->makeAllDirty();
 	screen->update();
 }
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/startup_flow.h
index b4bae15e8eb..e7f0122ea79 100644
--- a/engines/harvester/startup_flow.h
+++ b/engines/harvester/startup_flow.h
@@ -48,7 +48,11 @@ private:
 	Common::Error runQuickTips();
 	Common::Error runMainMenuStub();
 	Common::Error runRoomSetupStub(const Common::String &entranceName);
-	void renderQuickTipsScreen(const Common::String &tipText) const;
+	Common::Error beginRoomSetupTransition();
+	Common::Error fadeInRoomScene(const byte *palette, float targetBrightness);
+	bool pumpTransitionEvents(Common::Error &result);
+	void resetCursorAnimationSequence();
+	bool tickCursorAnimation();
 	void renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const;
 	bool handleSystemEvent(const Common::Event &event, Common::Error &result);
 	int getMenuItemAt(const Common::Point &mousePos) const;
@@ -57,6 +61,9 @@ private:
 	Common::Array<Common::String> _quickTips;
 	Common::Array<Common::String> _menuItems;
 	Common::Point _mousePos;
+	uint _cursorFrameIndex = 0;
+	uint32 _cursorNextFrameTick = 0;
+	bool _cursorVisible = true;
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 6b2ce2e68e3..131e61cabc1 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -34,6 +34,8 @@ static const char *const kStartupConfigPath = "CONFIG.INI";
 static const char *const kConfigSectionName = "harvester";
 static const char *const kDefaultTownScript = "HARVEST.SCR";
 static const byte kTownScriptXorKey = 0xaa;
+static const float kDefaultPaletteBrightness = 1.0f;
+static const float kDimmedPaletteBrightness = 0.6f;
 
 static Common::String trimAsciiLine(const Common::String &value) {
 	uint start = 0;
@@ -445,6 +447,10 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 		break;
 	}
 
+	state.paletteBrightness = (room->dimmable && !getFlagValue("DAY_FLAG"))
+		? kDimmedPaletteBrightness
+		: kDefaultPaletteBrightness;
+
 	return true;
 }
 
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 6ed1b6fc9a5..a166a41bdf6 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -85,6 +85,7 @@ struct StartupRoomSetupState {
 	Common::String palettePath;
 	Common::String backgroundPath;
 	Common::String musicPath;
+	float paletteBrightness = 1.0f;
 	Common::Array<StartupObjectRecord> activeObjects;
 	Common::Array<StartupObjectRecord> roomObjects;
 };


Commit: bc3df40506aad8aea822377f1707f6a5134558f7
    https://github.com/scummvm/scummvm/commit/bc3df40506aad8aea822377f1707f6a5134558f7
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:36+02:00

Commit Message:
HARVESTER: Add runtime cursor entity support

Changed paths:
  A engines/harvester/runtime_entity.cpp
  A engines/harvester/runtime_entity.h
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/module.mk
    engines/harvester/startup_art.cpp
    engines/harvester/startup_art.h
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_flow.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 15581ae6e12..1635da4f50d 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -374,6 +374,13 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `spawn_cursor_entity` seeds the runtime cursor at sequence `0` / frames `0..9` with animation rate `10`; `room_setup` then switches `g_cursor_entity` to sequence `7`.
 - `sync_cursor_entity_position` at `0x799e0` repositions a runtime entity to the shared cursor coordinates and then advances its visual state through the normal entity animation tick path.
 - `flush_dirty_rects_to_screen` reads `g_cursor_entity` and calls `sync_cursor_entity_position` every frame before the dirty-rect blit work, so cursor placement is part of the normal render pipeline rather than a one-shot startup blit.
+- The shared runtime entity animation/timing cluster is now bounded enough to emulate conservatively:
+  - `+0x105c` stores the next animation tick deadline used by `tick_entity_visual_state`
+  - `+0x1060` stores the tick interval derived from `set_entity_animation_rate`
+  - `+0x1064` stores the current frame index
+  - `+0x1068` stores the caller-facing animation rate value
+  - `+0x106c` / `+0x1078` store the active sequence end/start frame indices
+  - `+0x112c` stores the active sequence index
 - `is_cursor_within_entity_bounds` at `0x4b670` is the rectangle-only cursor test for runtime entities.
 - `is_cursor_over_entity_hitmask` at `0x4b5b0` adds the per-pixel transparency test used by the main loop before interaction dispatch.
 
@@ -592,6 +599,10 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `program_irq0_pit_divisor` at `0x8b1ad` masks IRQ0, programs PIT channel 0 through ports `0x43` / `0x40`, and then unmasks IRQ0 again.
     - `install_irq0_timer_handler` at `0x8b30d`, together with `mask_irq0_timer_interrupt` and `unmask_irq0_timer_interrupt`, is the low-level IRQ0 refresh path that reinstalls the recovered timer handler and restarts the counter.
     - The constant `0x1234dc` in the rate math is the PIT input clock `1193180`, which is what anchors the divisor and step-increment interpretation.
+    - `set_sound_state_sample_rate` at `0x183c0` is the recovered table-driven wrapper above that slot layer; it forwards the caller-provided rate into `set_sound_voice_sample_rate` using the state-local slot index at offset `+0x14`.
+    - `unlock_sound_driver_linear_regions` at `0x83c34` walks a fixed list of backend code/data ranges and calls DPMI `int 31h, ax=0601` through `unlock_linear_region_via_dpmi` on each one.
+    - `release_sound_driver_linear_region_locks` at `0x83f73` is the public wrapper that runs that unlock pass and clears the backing region-lock flag.
+    - `refresh_irq0_timer_handler_if_active` at `0x84e09` is the small guard wrapper that reruns the mask/install/unmask IRQ0 sequence only when the backend timer layer is active.
   - The higher-level streamed-music state on top of that backend is now explicit:
     - `g_music_stream_state` at `0xca1d4` is the persistent room/menu/game-over music stream object.
     - `g_current_music_path` caches the current music filename so room transitions and scripted overrides can restore it.
diff --git a/TRACKER.md b/TRACKER.md
index 47f10aae632..cb8d58931e3 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,12 +2,11 @@
 
 ## Last Confirmed Action
 
-- Confirmed the `room_setup` palette handoff and runtime cursor animation path before extending the startup stub.
-  - Renamed `FUN_00023a30` to `ramp_palette_brightness`; it fades a palette in `0.1` steps on an approximately 4 ms timer by repeatedly calling `upload_palette_to_vga`.
-  - `upload_palette_to_vga` forces DAC color 0 to black and scales the remaining palette entries by a brightness factor before writing ports `0x3c8` / `0x3c9`.
-  - `set_entity_animation_sequence` maps each cursor sequence to a 10-frame run, so sequence `7` is `POINTERS.ABM` frames `70..79`.
-  - `sync_cursor_entity_position` updates `g_cursor_entity` to `g_cursor_screen_x` / `g_cursor_screen_y` and then advances the normal entity visual-state tick, which is why the runtime cursor animates inside the dirty-rect render path.
-  - ScummVM startup flow now mirrors that evidence closely enough for the stub: it shows the wait transition first, fades room palettes in from black toward `1.0` or `0.6`, and animates the startup cursor with the recovered sequence-`7` timing instead of blitting frame `0`.
+- Implemented the first Harvester runtime-entity slice directly from the recovered cursor path.
+  - Added a minimal `runtime_entity` module that loads ABM resources, tracks the recovered frame/timer/sequence state, applies sequence changes in 10-frame blocks, and advances visual state from the same timing model used by `tick_entity_visual_state`.
+  - `HarvesterEngine` now owns the runtime-entity manager instead of leaving cursor animation logic inside `StartupFlow`.
+  - The startup cursor is now spawned from `1:/GRAPHIC/POINTERS/POINTERS.ABM` through the runtime-entity subsystem, switched to sequence `7`, hidden during the wait transition, and advanced through the recovered `sync cursor position + tick` path each frame.
+  - `StartupArt` no longer treats `POINTERS.ABM` as static startup UI art; it only keeps the wait/menu resources while the runtime subsystem owns cursor visuals.
 - Current live-state counts:
   - `HARVEST.LE` currently has `751` total functions
   - `377` have custom/documented names
@@ -16,6 +15,6 @@
 ## Next Suggested Action
 
 - Highest-value targets:
-  - type the `ROOM` record more aggressively from `room_setup`, starting with the already supported fields around room name, palette path, music path, enter/exit tags, dimmable, and the perspective/scale values still showing as raw offsets
-  - type the runtime cursor/entity record next around the confirmed animation fields at `+0x105c`, `+0x1060`, `+0x106c`, `+0x1078`, `+0x112c`, and `+0x1130`
-  - once those structs are cleaner, revisit `room_setup`, `spawn_object_entity_from_record`, `spawn_anim_entity_from_record`, and `dispatch_room_event_actions` so the remaining entity-build side of the first room can move out of placeholder status without speculative names
+  - type the runtime entity record more aggressively around the still-unmodeled header/state fields (`+0x4c` .. `+0x54`, `+0x1084`, `+0x109c`, `+0x1124`, `+0x1130`) so the ScummVM runtime-entity layer can expand beyond cursor animation without speculative flags
+  - use that typed record to lift `spawn_object_entity_from_record` and `spawn_anim_entity_from_record` next; those are the cleanest path from the current startup-room placeholder scene into real runtime entities
+  - after the object/anim spawn path is cleaner, revisit `room_setup` and the render-list tick helpers so room overlays and scripted anims move through the same subsystem as the cursor
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 2dd40d2aec1..f40197113f4 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -31,6 +31,7 @@
 #include "harvester/detection.h"
 #include "harvester/fst_player.h"
 #include "harvester/resources.h"
+#include "harvester/runtime_entity.h"
 #include "harvester/startup_art.h"
 #include "harvester/startup_flow.h"
 #include "harvester/startup_script.h"
@@ -50,6 +51,7 @@ HarvesterEngine::~HarvesterEngine() {
 	delete _startupText;
 	delete _startupArt;
 	delete _startupScript;
+	delete _runtimeEntities;
 	delete _resources;
 	delete _screen;
 	g_engine = nullptr;
@@ -107,6 +109,7 @@ Common::Error HarvesterEngine::run() {
 
 	_resources = new ResourceManager();
 	_resources->mountStartupArchives();
+	_runtimeEntities = new RuntimeEntityManager(*_resources);
 	_startupScript = new StartupScript();
 	if (!_startupScript->load(*_resources))
 		return Common::kReadingFailed;
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 42ead929ca0..66ee8484930 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -34,6 +34,7 @@ struct ADGameDescription;
 namespace Harvester {
 
 class ResourceManager;
+class RuntimeEntityManager;
 class StartupScript;
 class StartupArt;
 class StartupText;
@@ -70,6 +71,7 @@ public:
 	Common::Error loadGameStream(Common::SeekableReadStream *stream) override;
 
 	ResourceManager *getResources() const { return _resources; }
+	RuntimeEntityManager *getRuntimeEntities() const { return _runtimeEntities; }
 	Graphics::Screen *getScreen() const { return _screen; }
 	StartupScript *getStartupScript() const { return _startupScript; }
 	StartupArt *getStartupArt() const { return _startupArt; }
@@ -85,6 +87,7 @@ private:
 	Audio::SoundHandle _startupSoundHandle;
 	Graphics::Screen *_screen = nullptr;
 	ResourceManager *_resources = nullptr;
+	RuntimeEntityManager *_runtimeEntities = nullptr;
 	StartupScript *_startupScript = nullptr;
 	StartupArt *_startupArt = nullptr;
 	StartupText *_startupText = nullptr;
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index c7c7f46397d..1262cb1ea4e 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -6,6 +6,7 @@ MODULE_OBJS = \
 	fst_player.o \
 	metaengine.o \
 	resources.o \
+	runtime_entity.o \
 	startup_art.o \
 	startup_flow.o \
 	startup_script.o \
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
new file mode 100644
index 00000000000..80f9956aabc
--- /dev/null
+++ b/engines/harvester/runtime_entity.cpp
@@ -0,0 +1,305 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/runtime_entity.h"
+
+#include "common/debug.h"
+#include "common/endian.h"
+#include "common/system.h"
+#include "graphics/screen.h"
+#include "harvester/resources.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kCursorEntityName = "MOUSE";
+static const char *const kCursorResourcePath = "1:/GRAPHIC/POINTERS/POINTERS.ABM";
+static const float kCursorEntityZ = -100.0f;
+static const int kCursorAnimationRate = 10;
+static const int kFramesPerSequence = 10;
+
+static void blitAnimationFrame(Graphics::Screen &screen, const Common::Array<AbmFrame> &frames, uint frameIndex,
+		int x, int y) {
+	if (frameIndex >= frames.size() || !frames[frameIndex].isValid())
+		return;
+
+	const AbmFrame &frame = frames[frameIndex];
+	screen.copyRectToSurface(frame.pixels.data(), frame.width, x + frame.xOffset, y + frame.yOffset,
+		frame.width, frame.height);
+}
+
+static bool decodeAnimationFrame(const byte *source, uint32 sourceSize, bool compressed, Common::Array<byte> &dest) {
+	if (!compressed) {
+		if (sourceSize < dest.size())
+			return false;
+
+		memcpy(dest.data(), source, dest.size());
+		return true;
+	}
+
+	uint32 srcOffset = 0;
+	uint32 dstOffset = 0;
+	while (srcOffset < sourceSize && dstOffset < dest.size()) {
+		const byte control = source[srcOffset++];
+		if ((control & 0x80) == 0) {
+			const uint32 literalCount = MIN<uint32>(control,
+				MIN<uint32>(sourceSize - srcOffset, dest.size() - dstOffset));
+			memcpy(dest.data() + dstOffset, source + srcOffset, literalCount);
+			srcOffset += literalCount;
+			dstOffset += literalCount;
+		} else {
+			if (srcOffset >= sourceSize)
+				return false;
+
+			const uint32 repeatCount = MIN<uint32>(control & 0x7f, dest.size() - dstOffset);
+			memset(dest.data() + dstOffset, source[srcOffset++], repeatCount);
+			dstOffset += repeatCount;
+		}
+	}
+
+	return dstOffset == dest.size();
+}
+
+} // End of anonymous namespace
+
+bool RuntimeEntity::loadAbmResource(ResourceManager &resources, const Common::String &path) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 8) {
+		warning("Harvester: unable to load runtime entity animation '%s'", path.c_str());
+		return false;
+	}
+
+	const uint32 frameCount = READ_LE_UINT32(data.data());
+	uint32 offset = 8;
+	_frames.resize(frameCount);
+
+	for (uint32 i = 0; i < frameCount; ++i) {
+		if (data.size() < offset + 25) {
+			warning("Harvester: short runtime ABM header in '%s'", path.c_str());
+			return false;
+		}
+
+		AbmFrame &frame = _frames[i];
+		frame.xOffset = (int32)READ_LE_UINT32(data.data() + offset);
+		frame.yOffset = (int32)READ_LE_UINT32(data.data() + offset + 4);
+		frame.width = READ_LE_UINT32(data.data() + offset + 8);
+		frame.height = READ_LE_UINT32(data.data() + offset + 12);
+		const bool compressed = data[offset + 16] != 0;
+		const uint32 sourceSize = READ_LE_UINT32(data.data() + offset + 17);
+		const uint32 pixelCount = frame.width * frame.height;
+		const uint32 payloadOffset = offset + 25;
+
+		if (frame.width == 0 || frame.height == 0 || data.size() < payloadOffset + sourceSize) {
+			warning("Harvester: invalid runtime ABM frame %u in '%s'", i, path.c_str());
+			return false;
+		}
+
+		frame.pixels.resize(pixelCount);
+		if (!decodeAnimationFrame(data.data() + payloadOffset, sourceSize, compressed, frame.pixels))
+			return false;
+
+		offset = payloadOffset + sourceSize;
+	}
+
+	_resourcePath = path;
+	_currentFrame = _frames.empty() ? -1 : 0;
+	_firstFrame = _currentFrame;
+	_lastFrame = _frames.empty() ? -1 : (int)_frames.size() - 1;
+	_animationEnabled = !_frames.empty();
+	return true;
+}
+
+void RuntimeEntity::setPosition(int x, int y, float z) {
+	_x = x;
+	_y = y;
+	_z = z;
+}
+
+void RuntimeEntity::setAnimationRate(int rate) {
+	if (rate == 0)
+		_animationTickInterval = 0;
+	else
+		_animationTickInterval = 100 / rate;
+
+	if (rate != _animationRate) {
+		_nextAnimationTick = 0;
+		_animationRate = rate;
+	}
+}
+
+void RuntimeEntity::setAnimationSequence(int sequence) {
+	if (_frames.empty() || sequence == _animationSequence)
+		return;
+
+	_animationSequence = sequence;
+	_looping = true;
+	_playBackwards = false;
+	_animationEnabled = true;
+	_firstFrame = MIN<int>(sequence * kFramesPerSequence, (int)_frames.size() - 1);
+	_lastFrame = MIN<int>(_firstFrame + kFramesPerSequence - 1, (int)_frames.size() - 1);
+	advanceAnimationFrame(_firstFrame);
+}
+
+bool RuntimeEntity::tickVisualState(uint32 now) {
+	if (!_animationEnabled || _currentFrame < 0 || _animationTickInterval == 0)
+		return false;
+	if (now < _nextAnimationTick)
+		return false;
+
+	bool advanced = false;
+	do {
+		advanceAnimationFrame(_playBackwards ? -1 : -2);
+		_nextAnimationTick = now + _animationTickInterval;
+		advanced = true;
+	} while (_animationTickInterval != 0 && now >= _nextAnimationTick);
+
+	return advanced;
+}
+
+void RuntimeEntity::draw(Graphics::Screen &screen) const {
+	if (!_visible || _currentFrame < 0)
+		return;
+
+	blitAnimationFrame(screen, _frames, _currentFrame, _x, _y);
+}
+
+void RuntimeEntity::advanceAnimationFrame(int directive) {
+	if (_frames.empty())
+		return;
+
+	if (directive == -1) {
+		--_currentFrame;
+		if (_currentFrame >= _firstFrame)
+			return;
+
+		if (!_looping) {
+			_currentFrame = _firstFrame;
+			if (_classId == kRuntimeEntityClassAnimation)
+				_animationEnabled = false;
+			return;
+		}
+
+		if (!_pingPong) {
+			_currentFrame = _lastFrame;
+			return;
+		}
+
+		_playBackwards = false;
+		_currentFrame = _firstFrame;
+		return;
+	}
+
+	if (directive == -2) {
+		++_currentFrame;
+		if (_currentFrame <= _lastFrame)
+			return;
+
+		if (!_looping) {
+			_currentFrame = _lastFrame;
+			if (_classId == kRuntimeEntityClassAnimation)
+				_animationEnabled = false;
+			return;
+		}
+
+		if (_pingPong) {
+			_playBackwards = true;
+			_currentFrame = _lastFrame;
+			return;
+		}
+
+		_currentFrame = _firstFrame;
+		return;
+	}
+
+	_currentFrame = CLIP<int>(directive, 0, (int)_frames.size() - 1);
+}
+
+RuntimeEntityManager::RuntimeEntityManager(ResourceManager &resources) : _resources(resources) {
+}
+
+RuntimeEntityManager::~RuntimeEntityManager() {
+	clear();
+}
+
+void RuntimeEntityManager::clear() {
+	for (RuntimeEntity *entity : _entities)
+		delete entity;
+	_entities.clear();
+	_cursorEntity = nullptr;
+}
+
+RuntimeEntity *RuntimeEntityManager::spawnAbmEntityFromResource(const Common::String &name,
+		const Common::String &resourcePath, int classId, const Common::Point &position, float z,
+		int animationRate, bool looping, bool pingPong) {
+	RuntimeEntity *entity = new RuntimeEntity();
+	if (!entity->loadAbmResource(_resources, resourcePath)) {
+		delete entity;
+		return nullptr;
+	}
+
+	entity->setName(name);
+	entity->setClassId(classId);
+	entity->setPosition(position.x, position.y, z);
+	entity->setLooping(looping);
+	entity->setPingPong(pingPong);
+	entity->setAnimationRate(animationRate);
+	_entities.push_back(entity);
+	return entity;
+}
+
+RuntimeEntity *RuntimeEntityManager::spawnCursorEntity(const Common::Point &position) {
+	if (_cursorEntity)
+		return _cursorEntity;
+
+	_cursorEntity = spawnAbmEntityFromResource(kCursorEntityName, kCursorResourcePath,
+		kRuntimeEntityClassCursor, position, kCursorEntityZ, kCursorAnimationRate, true, false);
+	if (_cursorEntity)
+		_cursorEntity->setAnimationSequence(0);
+
+	return _cursorEntity;
+}
+
+void RuntimeEntityManager::hideCursor() {
+	if (_cursorEntity)
+		_cursorEntity->setVisible(false);
+}
+
+void RuntimeEntityManager::showCursor() {
+	if (_cursorEntity)
+		_cursorEntity->setVisible(true);
+}
+
+bool RuntimeEntityManager::syncCursorEntityPosition(const Common::Point &position) {
+	if (!_cursorEntity)
+		return false;
+
+	const bool moved = _cursorEntity->getX() != position.x || _cursorEntity->getY() != position.y;
+	_cursorEntity->setPosition(position.x, position.y, kCursorEntityZ);
+	return _cursorEntity->tickVisualState(g_system->getMillis()) || moved;
+}
+
+void RuntimeEntityManager::drawCursor(Graphics::Screen &screen) const {
+	if (_cursorEntity)
+		_cursorEntity->draw(screen);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
new file mode 100644
index 00000000000..a0a918e1747
--- /dev/null
+++ b/engines/harvester/runtime_entity.h
@@ -0,0 +1,119 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_RUNTIME_ENTITY_H
+#define HARVESTER_RUNTIME_ENTITY_H
+
+#include "common/array.h"
+#include "common/rect.h"
+#include "common/str.h"
+#include "harvester/startup_art.h"
+
+namespace Graphics {
+class Screen;
+}
+
+namespace Harvester {
+
+class ResourceManager;
+
+enum RuntimeEntityClass {
+	kRuntimeEntityClassAnimation = 1,
+	kRuntimeEntityClassCursor = 2
+};
+
+class RuntimeEntity {
+public:
+	bool loadAbmResource(ResourceManager &resources, const Common::String &path);
+
+	void setName(const Common::String &name) { _name = name; }
+	const Common::String &getName() const { return _name; }
+
+	void setClassId(int classId) { _classId = classId; }
+	int getClassId() const { return _classId; }
+
+	void setLooping(bool looping) { _looping = looping; }
+	void setPingPong(bool pingPong) { _pingPong = pingPong; }
+	void setVisible(bool visible) { _visible = visible; }
+	bool isVisible() const { return _visible; }
+
+	void setPosition(int x, int y, float z);
+	int getX() const { return _x; }
+	int getY() const { return _y; }
+	float getZ() const { return _z; }
+
+	void setAnimationRate(int rate);
+	int getAnimationRate() const { return _animationRate; }
+	void setAnimationSequence(int sequence);
+	int getAnimationSequence() const { return _animationSequence; }
+
+	bool hasFrames() const { return !_frames.empty(); }
+	bool tickVisualState(uint32 now);
+	void draw(Graphics::Screen &screen) const;
+
+private:
+	void advanceAnimationFrame(int directive);
+
+	Common::String _name;
+	Common::String _resourcePath;
+	Common::Array<AbmFrame> _frames;
+	int _classId = 0;
+	int _x = 0;
+	int _y = 0;
+	float _z = 0.0f;
+	int _currentFrame = -1;
+	int _firstFrame = -1;
+	int _lastFrame = -1;
+	int _animationRate = 0;
+	uint32 _animationTickInterval = 0;
+	uint32 _nextAnimationTick = 0;
+	int _animationSequence = -1;
+	bool _looping = true;
+	bool _pingPong = false;
+	bool _playBackwards = false;
+	bool _animationEnabled = true;
+	bool _visible = true;
+};
+
+class RuntimeEntityManager {
+public:
+	explicit RuntimeEntityManager(ResourceManager &resources);
+	~RuntimeEntityManager();
+
+	void clear();
+	RuntimeEntity *spawnAbmEntityFromResource(const Common::String &name, const Common::String &resourcePath,
+		int classId, const Common::Point &position, float z, int animationRate, bool looping, bool pingPong);
+	RuntimeEntity *spawnCursorEntity(const Common::Point &position);
+	RuntimeEntity *getCursorEntity() const { return _cursorEntity; }
+	void hideCursor();
+	void showCursor();
+	bool syncCursorEntityPosition(const Common::Point &position);
+	void drawCursor(Graphics::Screen &screen) const;
+
+private:
+	ResourceManager &_resources;
+	Common::Array<RuntimeEntity *> _entities;
+	RuntimeEntity *_cursorEntity = nullptr;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_RUNTIME_ENTITY_H
diff --git a/engines/harvester/startup_art.cpp b/engines/harvester/startup_art.cpp
index d18e14ccf1d..553c3208b09 100644
--- a/engines/harvester/startup_art.cpp
+++ b/engines/harvester/startup_art.cpp
@@ -60,7 +60,6 @@ static const char *const kAmmoIconPaths[] = {
 
 bool StartupArt::load(ResourceManager &resources) {
 	_waitFrames.clear();
-	_pointerFrames.clear();
 	_textboxes.clear();
 	_ammoIcons.clear();
 	_inventoryBitmap = IndexedBitmap();
@@ -69,7 +68,6 @@ bool StartupArt::load(ResourceManager &resources) {
 
 	return loadPalette(resources, "1:/GRAPHIC/PAL/WAIT.PAL", _waitPalette) &&
 	       loadAnimation(resources, "1:/GRAPHIC/OTHER/WAIT.ABM", _waitFrames) &&
-	       loadAnimation(resources, "1:/GRAPHIC/POINTERS/POINTERS.ABM", _pointerFrames) &&
 	       loadBitmap(resources, "1:/GRAPHIC/OTHER/INVENTRY.BM", _inventoryBitmap) &&
 	       loadBitmap(resources, "1:/GRAPHIC/OTHER/HARVLOGO.BM", _logoBitmap);
 }
diff --git a/engines/harvester/startup_art.h b/engines/harvester/startup_art.h
index d76d286dd22..f1bf63a29a6 100644
--- a/engines/harvester/startup_art.h
+++ b/engines/harvester/startup_art.h
@@ -51,7 +51,6 @@ public:
 
 	const byte *getWaitPalette() const { return _waitPalette; }
 	const Common::Array<AbmFrame> &getWaitFrames() const { return _waitFrames; }
-	const Common::Array<AbmFrame> &getPointerFrames() const { return _pointerFrames; }
 	const IndexedBitmap &getInventoryBitmap() const { return _inventoryBitmap; }
 	const IndexedBitmap &getLogoBitmap() const { return _logoBitmap; }
 	const IndexedBitmap &getTipsBitmap() const { return _tipsBitmap; }
@@ -66,7 +65,6 @@ private:
 
 	byte _waitPalette[256 * 3] = { 0 };
 	Common::Array<AbmFrame> _waitFrames;
-	Common::Array<AbmFrame> _pointerFrames;
 	Common::Array<IndexedBitmap> _textboxes;
 	Common::Array<IndexedBitmap> _ammoIcons;
 	IndexedBitmap _inventoryBitmap;
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index be9e8b081cf..9b95e752e80 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -33,6 +33,7 @@
 #include "harvester/detection.h"
 #include "harvester/harvester.h"
 #include "harvester/resources.h"
+#include "harvester/runtime_entity.h"
 #include "harvester/startup_art.h"
 #include "harvester/startup_script.h"
 
@@ -57,10 +58,7 @@ static const int kQuickTipTextWidth = 280;
 
 static const int kMenuStartY = 100;
 static const int kMenuLineSpacing = 28;
-static const uint kCursorSequence7FirstFrame = 70;
-static const uint kCursorSequence7LastFrame = 79;
-static const uint kCursorAnimationRate = 10;
-static const uint32 kCursorAnimationTickMs = 100 / kCursorAnimationRate;
+static const int kCursorSequence7 = 7;
 static const uint32 kPaletteFadeTickMs = 4;
 static const float kPaletteFadeStep = 0.1f;
 static const float kPaletteBrightnessBlack = 0.0f;
@@ -107,14 +105,6 @@ static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, in
 	screen.copyRectToSurface(bitmap.pixels.data(), bitmap.width, x, y, bitmap.width, bitmap.height);
 }
 
-static void blitAnimationFrame(Graphics::Screen &screen, const Common::Array<AbmFrame> &frames, uint frameIndex, int x, int y) {
-	if (frameIndex >= frames.size() || !frames[frameIndex].isValid())
-		return;
-
-	const AbmFrame &frame = frames[frameIndex];
-	blitBitmap(screen, frame, x + frame.xOffset, y + frame.yOffset);
-}
-
 static void drawShadowedString(Graphics::Screen &screen, const Graphics::Font &font, const Common::String &text,
 		int x, int y, int width, byte color, Graphics::TextAlign align = Graphics::kTextAlignLeft) {
 	font.drawString(&screen, text, x + 1, y + 1, width, kShadowColor, align);
@@ -269,30 +259,6 @@ static Common::String trimAsciiLine(const Common::String &value) {
 	return value.substr(start, end - start);
 }
 
-static uint getStartupCursorSequenceFirstFrame(const StartupArt &art) {
-	const Common::Array<AbmFrame> &frames = art.getPointerFrames();
-	if (frames.empty())
-		return 0;
-
-	return MIN<uint>(kCursorSequence7FirstFrame, frames.size() - 1);
-}
-
-static uint getStartupCursorSequenceLastFrame(const StartupArt &art) {
-	const Common::Array<AbmFrame> &frames = art.getPointerFrames();
-	if (frames.empty())
-		return 0;
-
-	return MIN<uint>(kCursorSequence7LastFrame, frames.size() - 1);
-}
-
-static void drawStartupCursor(Graphics::Screen &screen, const StartupArt &art, const Common::Point &mousePos,
-		uint frameIndex, bool visible) {
-	if (!visible)
-		return;
-
-	blitAnimationFrame(screen, art.getPointerFrames(), frameIndex, mousePos.x, mousePos.y);
-}
-
 static bool loadQuickTipsScene(HarvesterEngine &engine, StartupRoomSceneResources &scene) {
 	StartupRoomSetupState state;
 	if (!engine.getStartupScript()->resolveRoomSetupState("QUICK_TIPS", state, *engine.getResources()))
@@ -302,7 +268,7 @@ static bool loadQuickTipsScene(HarvesterEngine &engine, StartupRoomSceneResource
 }
 
 static void renderQuickTipsScreen(HarvesterEngine &engine, const StartupRoomSceneResources &scene,
-		const Common::Point &mousePos, const Common::String &tipText, uint cursorFrameIndex, bool cursorVisible) {
+		const Common::Point &mousePos, const Common::String &tipText) {
 	Graphics::Screen *screen = engine.getScreen();
 	const StartupArt *art = engine.getStartupArt();
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
@@ -325,7 +291,8 @@ static void renderQuickTipsScreen(HarvesterEngine &engine, const StartupRoomScen
 		toggleRect.left, toggleRect.top, toggleRect.width(),
 		toggleRect.contains(mousePos) ? kTextColorHover : kTextColorNormal);
 
-	drawStartupCursor(*screen, *art, mousePos, cursorFrameIndex, cursorVisible);
+	if (engine.getRuntimeEntities())
+		engine.getRuntimeEntities()->drawCursor(*screen);
 	screen->makeAllDirty();
 	screen->update();
 }
@@ -340,7 +307,8 @@ bool StartupFlow::load() {
 }
 
 Common::Error StartupFlow::run() {
-	resetCursorAnimationSequence();
+	if (!ensureCursorEntity())
+		return Common::kReadingFailed;
 
 	Common::Error error = runQuickTips();
 	if (error.getCode() != Common::kNoError)
@@ -443,7 +411,7 @@ Common::Error StartupFlow::runQuickTips() {
 
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
-			renderQuickTipsScreen(_engine, scene, _mousePos, _quickTips[tipIndex], _cursorFrameIndex, _cursorVisible);
+			renderQuickTipsScreen(_engine, scene, _mousePos, _quickTips[tipIndex]);
 			needsRedraw = false;
 		}
 
@@ -489,13 +457,7 @@ Common::Error StartupFlow::runMainMenuStub() {
 	Common::String statusMessage = "Stub handoff after room_setup(\"QUICK_TIPS\") and before the main menu loop.";
 	int selectedItem = _menuItems.empty() ? -1 : 0;
 	bool needsRedraw = true;
-	const StartupArt *art = _engine.getStartupArt();
-	if (art) {
-		const uint firstFrame = getStartupCursorSequenceFirstFrame(*art);
-		const uint lastFrame = getStartupCursorSequenceLastFrame(*art);
-		if (_cursorFrameIndex < firstFrame || _cursorFrameIndex > lastFrame || !_cursorVisible)
-			resetCursorAnimationSequence();
-	}
+	resetCursorAnimationSequence();
 
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
@@ -678,7 +640,8 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 			drawShadowedString(*screen, *bodyFont, footerMessage,
 				panel.left, 430, panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
 
-			drawStartupCursor(*screen, *art, _mousePos, _cursorFrameIndex, _cursorVisible);
+			if (_engine.getRuntimeEntities())
+				_engine.getRuntimeEntities()->drawCursor(*screen);
 			screen->makeAllDirty();
 			screen->update();
 			needsRedraw = false;
@@ -794,8 +757,19 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 	return Common::kNoError;
 }
 
+bool StartupFlow::ensureCursorEntity() {
+	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+	if (!runtimeEntities)
+		return false;
+	if (runtimeEntities->getCursorEntity())
+		return true;
+
+	return runtimeEntities->spawnCursorEntity(_mousePos) != nullptr;
+}
+
 Common::Error StartupFlow::beginRoomSetupTransition() {
-	_cursorVisible = false;
+	if (_engine.getRuntimeEntities())
+		_engine.getRuntimeEntities()->hideCursor();
 
 	const StartupArt *art = _engine.getStartupArt();
 	if (art)
@@ -844,45 +818,23 @@ bool StartupFlow::pumpTransitionEvents(Common::Error &result) {
 }
 
 void StartupFlow::resetCursorAnimationSequence() {
-	const StartupArt *art = _engine.getStartupArt();
-	if (!art) {
-		_cursorFrameIndex = 0;
-		_cursorNextFrameTick = 0;
-		_cursorVisible = false;
+	if (!ensureCursorEntity())
+		return;
+
+	RuntimeEntity *cursor = _engine.getRuntimeEntities()->getCursorEntity();
+	if (!cursor)
 		return;
-	}
 
-	_cursorFrameIndex = getStartupCursorSequenceFirstFrame(*art);
-	_cursorNextFrameTick = g_system->getMillis() + kCursorAnimationTickMs;
-	_cursorVisible = true;
+	cursor->setVisible(true);
+	cursor->setAnimationSequence(kCursorSequence7);
+	(void)_engine.getRuntimeEntities()->syncCursorEntityPosition(_mousePos);
 }
 
 bool StartupFlow::tickCursorAnimation() {
-	if (!_cursorVisible)
+	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+	if (!runtimeEntities)
 		return false;
-
-	const StartupArt *art = _engine.getStartupArt();
-	if (!art || art->getPointerFrames().empty())
-		return false;
-
-	const uint firstFrame = getStartupCursorSequenceFirstFrame(*art);
-	const uint lastFrame = getStartupCursorSequenceLastFrame(*art);
-	if (_cursorFrameIndex < firstFrame || _cursorFrameIndex > lastFrame)
-		_cursorFrameIndex = firstFrame;
-
-	if (firstFrame == lastFrame)
-		return false;
-
-	const uint32 now = g_system->getMillis();
-	if ((int32)(now - _cursorNextFrameTick) < 0)
-		return false;
-
-	do {
-		_cursorFrameIndex = (_cursorFrameIndex >= lastFrame) ? firstFrame : _cursorFrameIndex + 1;
-		_cursorNextFrameTick += kCursorAnimationTickMs;
-	} while ((int32)(now - _cursorNextFrameTick) >= 0);
-
-	return true;
+	return runtimeEntities->syncCursorEntityPosition(_mousePos);
 }
 
 void StartupFlow::renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const {
@@ -918,7 +870,8 @@ void StartupFlow::renderMainMenuStub(int selectedItem, const Common::String &sta
 	drawShadowedString(*screen, *bodyFont, "Use mouse or arrow keys. Enter activates. Esc returns to launcher.",
 		panel.left, 404, panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
 
-	drawStartupCursor(*screen, *art, _mousePos, _cursorFrameIndex, _cursorVisible);
+	if (_engine.getRuntimeEntities())
+		_engine.getRuntimeEntities()->drawCursor(*screen);
 	screen->makeAllDirty();
 	screen->update();
 }
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/startup_flow.h
index e7f0122ea79..395c18c2f79 100644
--- a/engines/harvester/startup_flow.h
+++ b/engines/harvester/startup_flow.h
@@ -48,6 +48,7 @@ private:
 	Common::Error runQuickTips();
 	Common::Error runMainMenuStub();
 	Common::Error runRoomSetupStub(const Common::String &entranceName);
+	bool ensureCursorEntity();
 	Common::Error beginRoomSetupTransition();
 	Common::Error fadeInRoomScene(const byte *palette, float targetBrightness);
 	bool pumpTransitionEvents(Common::Error &result);
@@ -61,9 +62,6 @@ private:
 	Common::Array<Common::String> _quickTips;
 	Common::Array<Common::String> _menuItems;
 	Common::Point _mousePos;
-	uint _cursorFrameIndex = 0;
-	uint32 _cursorNextFrameTick = 0;
-	bool _cursorVisible = true;
 };
 
 } // End of namespace Harvester


Commit: 03da5e4a67993a111a1b1aacffb3dd9b96922520
    https://github.com/scummvm/scummvm/commit/03da5e4a67993a111a1b1aacffb3dd9b96922520
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:36+02:00

Commit Message:
HARVESTER: Lift startup entities

Promote startup room objects and animations into runtime entities.

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_flow.h
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 1635da4f50d..44a373f5b38 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -172,6 +172,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `g_current_palette_buffer` at `0xd6084` is the shared room/menu palette buffer used by `run_harvester_main_loop`, `room_setup`, `dispatch_room_event_actions`, inventory/help screens, and the CD prompt path.
   - `room_setup` first resolves the target room / entrance and, when `g_current_room_def->palette_path` is non-null, rereads that room palette into `g_current_palette_buffer`.
   - It then reloads `WAIT.PAL` and `WAIT.ABM`, hides the cursor entity, flushes the wait transition, uploads the wait palette, builds the room entities, and only after that uploads the room palette from `g_current_palette_buffer`.
+  - Once the wait transition is flushed, `room_setup` rebuilds the room render list in this order: matching enabled regions, room timers, visible object records whose `current_owner_or_room` matches the room, then room `ANIM` records whose `active` or `visible` state is set.
   - After the initial black upload, `room_setup` uses `ramp_palette_brightness` at `0x23a30` to fade `g_current_palette_buffer` in from black. The helper steps brightness in `0.1` increments on an approximately 4 ms timer and also drives the room dimming path when the room is marked dimmable and `DAY_FLAG` is clear.
 - `upload_palette_to_vga` at `0x22770` writes a caller-supplied RGB palette to the VGA DAC, forces palette index 0 to black, and applies a caller-supplied brightness scalar before clamping each channel to the hardware `0x3f` range.
 - `ramp_palette_brightness` at `0x23a30` is the shared timed palette ramp helper.
@@ -476,10 +477,11 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `field_74` and `field_7c` are copied into live runtime slots `+0x1170` and `+0x1178`, but the current binary only writes those slots; no read-side consumers were recovered.
   - Remaining unknowns include `field_1c`, `field_38`, `field_3c`, `field_44`, `field_48`, `field_74`, `field_7c`
 - `AnimRecord` now has stable owner/resource identity plus mirrored runtime state:
-  - `pos_x`, `pos_y`, `pos_z`, `frame_delay`
+  - leading numeric field at `+0x00` still unresolved
+  - `pos_x`, `pos_y`, `frame_delay`
   - `room_name`, `anim_path`, `anim_name`
   - script booleans: `active`, `visible`, `loop`, `backward`, `ping_pong`, `remove`
-  - runtime mirrors `runtime_active`, `runtime_visible`, `runtime_state`
+  - `room_setup` and room teardown reuse the same live bytes as runtime state: `active`, `visible`, and `runtime_state`
 - `TextRecord` now has stable string identity:
   - `text_id`, `panel_id`, `text_value`, `next`
   - The parser normalizes `_` to space characters in `text_value`
@@ -599,10 +601,18 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `program_irq0_pit_divisor` at `0x8b1ad` masks IRQ0, programs PIT channel 0 through ports `0x43` / `0x40`, and then unmasks IRQ0 again.
     - `install_irq0_timer_handler` at `0x8b30d`, together with `mask_irq0_timer_interrupt` and `unmask_irq0_timer_interrupt`, is the low-level IRQ0 refresh path that reinstalls the recovered timer handler and restarts the counter.
     - The constant `0x1234dc` in the rate math is the PIT input clock `1193180`, which is what anchors the divisor and step-increment interpretation.
-    - `set_sound_state_sample_rate` at `0x183c0` is the recovered table-driven wrapper above that slot layer; it forwards the caller-provided rate into `set_sound_voice_sample_rate` using the state-local slot index at offset `+0x14`.
+    - `set_selected_sound_voice_sample_rate` at `0x183c0` is the recovered wrapper above that slot layer; it forwards the caller-provided rate into `set_sound_voice_sample_rate` using the selected voice-slot index stored at offset `+0x14` of the driver callback context.
     - `unlock_sound_driver_linear_regions` at `0x83c34` walks a fixed list of backend code/data ranges and calls DPMI `int 31h, ax=0601` through `unlock_linear_region_via_dpmi` on each one.
     - `release_sound_driver_linear_region_locks` at `0x83f73` is the public wrapper that runs that unlock pass and clears the backing region-lock flag.
     - `refresh_irq0_timer_handler_if_active` at `0x84e09` is the small guard wrapper that reruns the mask/install/unmask IRQ0 sequence only when the backend timer layer is active.
+    - `count_active_sound_voice_slots_in_bank` at `0x85b68` counts the backend voice slots within one bank whose status word at `+0x30` still carries the active `0x8000` bit.
+    - The callback object rooted at `0xca194` is now partially constrained:
+      - offset `+0x18` overlaps `g_sound_voice_bank_index`
+      - offset `+0x20` overlaps `g_sound_driver_initialized`
+      - `set_selected_sound_driver_control_value` at `0x183e0` writes a 16-bit value through `set_driver_control_entry_value` for the currently selected bank/control index when the backend is initialized
+      - `count_active_sound_voice_slots_for_selected_bank` at `0x18400` returns the active-slot count for that same selected bank
+      - `release_selected_sound_bank_resources` at `0x18380` clears the selected voice-rate entry, releases the selected bank through `release_sound_bank_resources`, refreshes IRQ0 timing state, and then drops the linear-region locks
+    - `release_sound_bank_resources` at `0x84873` is the shared bank teardown helper beneath that callback layer; it frees the bank's transient DOS buffer when present, clears per-bank tables, and may trigger the additional DPMI unlock/free path depending on the hidden caller flag.
   - The higher-level streamed-music state on top of that backend is now explicit:
     - `g_music_stream_state` at `0xca1d4` is the persistent room/menu/game-over music stream object.
     - `g_current_music_path` caches the current music filename so room transitions and scripted overrides can restore it.
@@ -628,9 +638,9 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - The `FCMP` compressed-audio path is now bounded as IMA ADPCM:
       - `initialize_ima_adpcm_decode_state` resets the embedded decode-state predictor, nibble index, and initial step values before a new compressed stream begins.
       - `decode_ima_adpcm_chunk` expands `FCMP` payload bytes into 8-bit or 16-bit PCM using `g_ima_adpcm_index_adjust_table` and `g_ima_adpcm_step_table`.
-    - `set_sound_state_driver_control_handle`, `set_sound_state_driver_control_value`, `set_driver_control_entry_value`, and `g_sound_driver_control_entry_refs` expose a second, driver-side control indirection table:
-      - the sound state stores a handle/index at offset `+0x18`
-      - the wrapper path uses that handle to write a 16-bit value into offset `+4` of the selected driver control entry
+    - `set_sound_state_driver_control_handle`, `set_driver_control_entry_value`, and `g_sound_driver_control_entry_refs` expose a second, driver-side control indirection table:
+      - `set_sound_state_driver_control_handle` stores a handle/index into the per-sound-state field at offset `+0x18` and marks the state with the recovered `0x40` flag at `+0x35`
+      - the separate driver callback object at `0xca194` reuses that same low-level `set_driver_control_entry_value` helper through `set_selected_sound_driver_control_value`
       - the exact gameplay meaning of that control value is still unresolved, but the indirection itself is confirmed
   - `load_dialogue_voice_sample` at `0x191d0` is the `play_dialogue_line`-specific loader that accepts either `WAVE` or `FCMP` voice assets and prepares them for one-shot playback through the shared sample backend.
   - `stop_sound_state_and_close_stream_file` at `0x1c4e0` is the shared cleanup path that stops active backend playback for a sound state and closes its backing stream/file handle at offset `+0x88`.
diff --git a/TRACKER.md b/TRACKER.md
index cb8d58931e3..b0ffed159d0 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,19 +2,19 @@
 
 ## Last Confirmed Action
 
-- Implemented the first Harvester runtime-entity slice directly from the recovered cursor path.
-  - Added a minimal `runtime_entity` module that loads ABM resources, tracks the recovered frame/timer/sequence state, applies sequence changes in 10-frame blocks, and advances visual state from the same timing model used by `tick_entity_visual_state`.
-  - `HarvesterEngine` now owns the runtime-entity manager instead of leaving cursor animation logic inside `StartupFlow`.
-  - The startup cursor is now spawned from `1:/GRAPHIC/POINTERS/POINTERS.ABM` through the runtime-entity subsystem, switched to sequence `7`, hidden during the wait transition, and advanced through the recovered `sync cursor position + tick` path each frame.
-  - `StartupArt` no longer treats `POINTERS.ABM` as static startup UI art; it only keeps the wait/menu resources while the runtime subsystem owns cursor visuals.
+- Extended the startup-room lift from the cursor-only runtime entity into the room object / `ANIM` spawn path.
+  - `room_setup` decompilation confirms the post-wait rebuild order: visible room objects whose `current_owner_or_room` matches the destination room are spawned before matching `ANIM` records whose `active` or `visible` state is set.
+  - `parse_anim_record` is now bounded enough to model conservatively: one unresolved leading numeric field, then `x`, `y`, `frame_delay`, `room_name`, `anim_path`, `anim_name`, and the script booleans `active`, `visible`, `loop`, `backward`, `ping_pong`, `remove`.
+  - ScummVM now parses `ANIM` records from `HARVEST.SCR`, lifts deduped visible room objects plus active/visible room anims into the runtime-entity subsystem, and ticks scene ABMs through the recovered frame-delay path instead of baking them into the background scene.
+  - The current lift is still intentionally short of the original render-list ordering, overlap checks, and collision/runtime trigger behavior; this pass only covers the spawn and per-frame visual tick slice.
 - Current live-state counts:
-  - `HARVEST.LE` currently has `751` total functions
-  - `377` have custom/documented names
-  - `374` still remain unnamed / `FUN_*`
+  - `HARVEST.LE` currently has `755` total functions
+  - `387` have custom/documented names
+  - `368` still remain unnamed / `FUN_*`
 
 ## Next Suggested Action
 
 - Highest-value targets:
-  - type the runtime entity record more aggressively around the still-unmodeled header/state fields (`+0x4c` .. `+0x54`, `+0x1084`, `+0x109c`, `+0x1124`, `+0x1130`) so the ScummVM runtime-entity layer can expand beyond cursor animation without speculative flags
-  - use that typed record to lift `spawn_object_entity_from_record` and `spawn_anim_entity_from_record` next; those are the cleanest path from the current startup-room placeholder scene into real runtime entities
-  - after the object/anim spawn path is cleaner, revisit `room_setup` and the render-list tick helpers so room overlays and scripted anims move through the same subsystem as the cursor
+  - recover the original render-list ordering helpers next, especially the insertion/sort behavior around `add_entity_to_render_list`, before trying to match room foreground/background overlap exactly
+  - then lift the low-risk subset of `spawn_object_entity_from_record` that chooses between sprite-backed objects and pure hotspot boxes, so active-but-invisible objects can participate in startup-room interactions without being rendered
+  - keep collision / overlap behavior tied to recovered helpers such as `do_entity_screen_bounds_overlap` and the cursor-hit tests, rather than inventing a scene graph policy in ScummVM first
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index 80f9956aabc..8e97178434e 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -81,6 +81,35 @@ static bool decodeAnimationFrame(const byte *source, uint32 sourceSize, bool com
 
 } // End of anonymous namespace
 
+bool RuntimeEntity::loadBitmapResource(ResourceManager &resources, const Common::String &path) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 12) {
+		warning("Harvester: unable to load runtime entity bitmap '%s'", path.c_str());
+		return false;
+	}
+
+	AbmFrame frame;
+	frame.width = READ_LE_UINT32(data.data());
+	frame.height = READ_LE_UINT32(data.data() + 4);
+	const uint32 pixelCount = frame.width * frame.height;
+	if (frame.width == 0 || frame.height == 0 || data.size() < 12 + pixelCount) {
+		warning("Harvester: invalid runtime bitmap '%s'", path.c_str());
+		return false;
+	}
+
+	frame.pixels.resize(pixelCount);
+	memcpy(frame.pixels.data(), data.data() + 12, pixelCount);
+
+	_frames.clear();
+	_frames.push_back(frame);
+	_resourcePath = path;
+	_currentFrame = 0;
+	_firstFrame = 0;
+	_lastFrame = 0;
+	_animationEnabled = false;
+	return true;
+}
+
 bool RuntimeEntity::loadAbmResource(ResourceManager &resources, const Common::String &path) {
 	Common::Array<byte> data;
 	if (!resources.loadFile(path, data) || data.size() < 8) {
@@ -146,6 +175,13 @@ void RuntimeEntity::setAnimationRate(int rate) {
 	}
 }
 
+void RuntimeEntity::setCurrentFrame(int frame) {
+	if (_frames.empty())
+		return;
+
+	advanceAnimationFrame(frame);
+}
+
 void RuntimeEntity::setAnimationSequence(int sequence) {
 	if (_frames.empty() || sequence == _animationSequence)
 		return;
@@ -241,12 +277,17 @@ RuntimeEntityManager::~RuntimeEntityManager() {
 }
 
 void RuntimeEntityManager::clear() {
-	for (RuntimeEntity *entity : _entities)
-		delete entity;
-	_entities.clear();
+	clearSceneEntities();
+	delete _cursorEntity;
 	_cursorEntity = nullptr;
 }
 
+void RuntimeEntityManager::clearSceneEntities() {
+	for (RuntimeEntity *entity : _sceneEntities)
+		delete entity;
+	_sceneEntities.clear();
+}
+
 RuntimeEntity *RuntimeEntityManager::spawnAbmEntityFromResource(const Common::String &name,
 		const Common::String &resourcePath, int classId, const Common::Point &position, float z,
 		int animationRate, bool looping, bool pingPong) {
@@ -262,7 +303,20 @@ RuntimeEntity *RuntimeEntityManager::spawnAbmEntityFromResource(const Common::St
 	entity->setLooping(looping);
 	entity->setPingPong(pingPong);
 	entity->setAnimationRate(animationRate);
-	_entities.push_back(entity);
+	return entity;
+}
+
+RuntimeEntity *RuntimeEntityManager::spawnBitmapEntityFromResource(const Common::String &name,
+		const Common::String &resourcePath, int classId, const Common::Point &position, float z) {
+	RuntimeEntity *entity = new RuntimeEntity();
+	if (!entity->loadBitmapResource(_resources, resourcePath)) {
+		delete entity;
+		return nullptr;
+	}
+
+	entity->setName(name);
+	entity->setClassId(classId);
+	entity->setPosition(position.x, position.y, z);
 	return entity;
 }
 
@@ -278,6 +332,39 @@ RuntimeEntity *RuntimeEntityManager::spawnCursorEntity(const Common::Point &posi
 	return _cursorEntity;
 }
 
+RuntimeEntity *RuntimeEntityManager::spawnSceneBitmapEntity(const Common::String &name,
+		const Common::String &resourcePath, const Common::Point &position, float z) {
+	RuntimeEntity *entity = spawnBitmapEntityFromResource(name, resourcePath, kRuntimeEntityClassObject,
+		position, z);
+	if (entity)
+		_sceneEntities.push_back(entity);
+	return entity;
+}
+
+RuntimeEntity *RuntimeEntityManager::spawnSceneAnimationEntity(const Common::String &name,
+		const Common::String &resourcePath, const Common::Point &position, float z, int animationRate,
+		bool active, bool visible, bool looping, bool playBackwards, bool pingPong, int initialFrame) {
+	RuntimeEntity *entity = spawnAbmEntityFromResource(name, resourcePath, kRuntimeEntityClassAnimation,
+		position, z, animationRate, looping, pingPong);
+	if (!entity)
+		return nullptr;
+
+	entity->setVisible(visible);
+	entity->setPlayBackwards(playBackwards);
+	if (initialFrame >= 0)
+		entity->setCurrentFrame(initialFrame);
+	else if (playBackwards)
+		entity->setCurrentFrame(entity->getLastFrame());
+	else
+		entity->setCurrentFrame(0);
+
+	if (!active && !visible)
+		entity->setVisible(false);
+
+	_sceneEntities.push_back(entity);
+	return entity;
+}
+
 void RuntimeEntityManager::hideCursor() {
 	if (_cursorEntity)
 		_cursorEntity->setVisible(false);
@@ -288,6 +375,16 @@ void RuntimeEntityManager::showCursor() {
 		_cursorEntity->setVisible(true);
 }
 
+bool RuntimeEntityManager::tickSceneEntities() {
+	const uint32 now = g_system->getMillis();
+	bool changed = false;
+
+	for (RuntimeEntity *entity : _sceneEntities)
+		changed |= entity->tickVisualState(now);
+
+	return changed;
+}
+
 bool RuntimeEntityManager::syncCursorEntityPosition(const Common::Point &position) {
 	if (!_cursorEntity)
 		return false;
@@ -302,4 +399,9 @@ void RuntimeEntityManager::drawCursor(Graphics::Screen &screen) const {
 		_cursorEntity->draw(screen);
 }
 
+void RuntimeEntityManager::drawSceneEntities(Graphics::Screen &screen) const {
+	for (RuntimeEntity *entity : _sceneEntities)
+		entity->draw(screen);
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index a0a918e1747..ebd241e1c92 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -36,12 +36,14 @@ namespace Harvester {
 class ResourceManager;
 
 enum RuntimeEntityClass {
+	kRuntimeEntityClassObject = 0,
 	kRuntimeEntityClassAnimation = 1,
 	kRuntimeEntityClassCursor = 2
 };
 
 class RuntimeEntity {
 public:
+	bool loadBitmapResource(ResourceManager &resources, const Common::String &path);
 	bool loadAbmResource(ResourceManager &resources, const Common::String &path);
 
 	void setName(const Common::String &name) { _name = name; }
@@ -52,6 +54,7 @@ public:
 
 	void setLooping(bool looping) { _looping = looping; }
 	void setPingPong(bool pingPong) { _pingPong = pingPong; }
+	void setPlayBackwards(bool playBackwards) { _playBackwards = playBackwards; }
 	void setVisible(bool visible) { _visible = visible; }
 	bool isVisible() const { return _visible; }
 
@@ -62,6 +65,9 @@ public:
 
 	void setAnimationRate(int rate);
 	int getAnimationRate() const { return _animationRate; }
+	void setCurrentFrame(int frame);
+	int getCurrentFrame() const { return _currentFrame; }
+	int getLastFrame() const { return _lastFrame; }
 	void setAnimationSequence(int sequence);
 	int getAnimationSequence() const { return _animationSequence; }
 
@@ -99,18 +105,28 @@ public:
 	~RuntimeEntityManager();
 
 	void clear();
+	void clearSceneEntities();
 	RuntimeEntity *spawnAbmEntityFromResource(const Common::String &name, const Common::String &resourcePath,
 		int classId, const Common::Point &position, float z, int animationRate, bool looping, bool pingPong);
+	RuntimeEntity *spawnBitmapEntityFromResource(const Common::String &name, const Common::String &resourcePath,
+		int classId, const Common::Point &position, float z);
 	RuntimeEntity *spawnCursorEntity(const Common::Point &position);
+	RuntimeEntity *spawnSceneBitmapEntity(const Common::String &name, const Common::String &resourcePath,
+		const Common::Point &position, float z);
+	RuntimeEntity *spawnSceneAnimationEntity(const Common::String &name, const Common::String &resourcePath,
+		const Common::Point &position, float z, int animationRate, bool active, bool visible, bool looping,
+		bool playBackwards, bool pingPong, int initialFrame);
 	RuntimeEntity *getCursorEntity() const { return _cursorEntity; }
 	void hideCursor();
 	void showCursor();
+	bool tickSceneEntities();
 	bool syncCursorEntityPosition(const Common::Point &position);
+	void drawSceneEntities(Graphics::Screen &screen) const;
 	void drawCursor(Graphics::Screen &screen) const;
 
 private:
 	ResourceManager &_resources;
-	Common::Array<RuntimeEntity *> _entities;
+	Common::Array<RuntimeEntity *> _sceneEntities;
 	RuntimeEntity *_cursorEntity = nullptr;
 };
 
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 9b95e752e80..353b4ce1a8e 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -69,16 +69,12 @@ static const byte kTextColorDim = 248;
 static const byte kShadowColor = 0;
 static const byte kPanelFillColor = 1;
 
-struct StartupSceneOverlay {
-	StartupObjectRecord object;
-	IndexedBitmap bitmap;
-};
-
 struct StartupRoomSceneResources {
 	StartupRoomSetupState state;
 	byte palette[256 * 3] = { 0 };
 	IndexedBitmap background;
-	Common::Array<StartupSceneOverlay> overlays;
+	Common::Array<StartupObjectRecord> drawableObjects;
+	Common::Array<StartupAnimRecord> drawableAnimations;
 	float targetPaletteBrightness = 1.0f;
 };
 
@@ -206,23 +202,22 @@ static bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceM
 	for (const StartupObjectRecord &object : state.activeObjects)
 		queueDrawableRoomObject(object, drawableObjects);
 
-	for (const StartupObjectRecord &object : drawableObjects) {
-		StartupSceneOverlay overlay;
-		overlay.object = object;
-		if (!loadBitmapResource(resources, object.resourcePath, overlay.bitmap))
-			continue;
-		scene.overlays.push_back(overlay);
+	scene.drawableObjects = drawableObjects;
+	for (const StartupAnimRecord &anim : state.roomAnimations) {
+		if (anim.active || anim.visible)
+			scene.drawableAnimations.push_back(anim);
 	}
 
 	return true;
 }
 
-static void drawRoomScene(Graphics::Screen &screen, const StartupRoomSceneResources &scene, float brightness) {
+static void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const StartupRoomSceneResources &scene,
+		float brightness) {
 	setScaledPalette(screen, scene.palette, brightness);
 	screen.fillRect(screen.getBounds(), 0);
 	blitBitmap(screen, scene.background, 0, 0);
-	for (const StartupSceneOverlay &overlay : scene.overlays)
-		blitBitmap(screen, overlay.bitmap, overlay.object.left, overlay.object.top);
+	if (engine.getRuntimeEntities())
+		engine.getRuntimeEntities()->drawSceneEntities(screen);
 }
 
 static const StartupObjectRecord *findObjectAtPoint(const Common::Array<StartupObjectRecord> &objects, const Common::Point &point) {
@@ -275,7 +270,7 @@ static void renderQuickTipsScreen(HarvesterEngine &engine, const StartupRoomScen
 	if (!screen || !art || !font)
 		return;
 
-	drawRoomScene(*screen, scene, scene.targetPaletteBrightness);
+	drawRoomScene(engine, *screen, scene, scene.targetPaletteBrightness);
 	blitBitmap(*screen, art->getTipsBitmap(), kQuickTipsOverlayX, kQuickTipsOverlayY);
 
 	drawWrappedShadowedText(*screen, *font, tipText, kQuickTipTextX, kQuickTipTextY, kQuickTipTextWidth, kTextColorNormal);
@@ -394,7 +389,10 @@ Common::Error StartupFlow::runQuickTips() {
 
 	Graphics::Screen *screen = _engine.getScreen();
 	if (screen) {
-		drawRoomScene(*screen, scene, kPaletteBrightnessBlack);
+		if (!populateRoomSceneEntities(scene.drawableObjects, scene.drawableAnimations))
+			return Common::kReadingFailed;
+
+		drawRoomScene(_engine, *screen, scene, kPaletteBrightnessBlack);
 		screen->makeAllDirty();
 		screen->update();
 
@@ -442,7 +440,7 @@ Common::Error StartupFlow::runQuickTips() {
 			}
 		}
 
-		if (tickCursorAnimation())
+		if (tickRuntimeEntities())
 			needsRedraw = true;
 
 		limiter.delayBeforeSwap();
@@ -457,6 +455,8 @@ Common::Error StartupFlow::runMainMenuStub() {
 	Common::String statusMessage = "Stub handoff after room_setup(\"QUICK_TIPS\") and before the main menu loop.";
 	int selectedItem = _menuItems.empty() ? -1 : 0;
 	bool needsRedraw = true;
+	if (_engine.getRuntimeEntities())
+		_engine.getRuntimeEntities()->clearSceneEntities();
 	resetCursorAnimationSequence();
 
 	while (!_engine.shouldQuit()) {
@@ -548,7 +548,7 @@ Common::Error StartupFlow::runMainMenuStub() {
 			}
 		}
 
-		if (tickCursorAnimation())
+		if (tickRuntimeEntities())
 			needsRedraw = true;
 
 		limiter.delayBeforeSwap();
@@ -578,7 +578,10 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 	if (!screen || !art || !titleFont || !bodyFont)
 		return Common::kNoError;
 
-	drawRoomScene(*screen, scene, kPaletteBrightnessBlack);
+	if (!populateRoomSceneEntities(scene.drawableObjects, scene.drawableAnimations))
+		return Common::kReadingFailed;
+
+	drawRoomScene(_engine, *screen, scene, kPaletteBrightnessBlack);
 	screen->makeAllDirty();
 	screen->update();
 
@@ -593,8 +596,10 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 		entranceName.c_str(), state.roomName.c_str(), state.palettePath.c_str(), state.backgroundPath.c_str());
 	if (!state.musicPath.empty())
 		statusMessage += Common::String::format(" Music: %s.", state.musicPath.c_str());
-	if (!scene.overlays.empty())
-		statusMessage += Common::String::format(" Visible scene objects: %u.", (uint)scene.overlays.size());
+	if (!scene.drawableObjects.empty())
+		statusMessage += Common::String::format(" Visible scene objects: %u.", (uint)scene.drawableObjects.size());
+	if (!scene.drawableAnimations.empty())
+		statusMessage += Common::String::format(" Room anims: %u.", (uint)scene.drawableAnimations.size());
 	statusMessage += " Click an active hotspot to follow its startup command chain.";
 	const Common::String baseStatusMessage = statusMessage;
 	StartupResolvedText inspectText;
@@ -606,7 +611,7 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
-			drawRoomScene(*screen, scene, scene.targetPaletteBrightness);
+			drawRoomScene(_engine, *screen, scene, scene.targetPaletteBrightness);
 
 			const Common::Rect panel(72, 336, 568, 468);
 			screen->fillRect(panel, kPanelFillColor);
@@ -708,12 +713,16 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 				if (!interaction.nextRoomName.empty()) {
 					Common::Error roomError = runRoomSetupStub(interaction.nextRoomName);
 					if (roomError.getCode() == Common::kReadingFailed) {
+						(void)populateRoomSceneEntities(scene.drawableObjects, scene.drawableAnimations);
+						resetCursorAnimationSequence();
 						statusMessage = Common::String::format("Unable to resolve closeup room '%s'.",
 							interaction.nextRoomName.c_str());
 						needsRedraw = true;
 					} else if (roomError.getCode() != Common::kNoError) {
 						return roomError;
 					} else {
+						(void)populateRoomSceneEntities(scene.drawableObjects, scene.drawableAnimations);
+						resetCursorAnimationSequence();
 						statusMessage = Common::String::format("Returned from %s.", interaction.nextRoomName.c_str());
 						needsRedraw = true;
 					}
@@ -747,7 +756,7 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 			}
 		}
 
-		if (tickCursorAnimation())
+		if (tickRuntimeEntities())
 			needsRedraw = true;
 
 		limiter.delayBeforeSwap();
@@ -767,6 +776,32 @@ bool StartupFlow::ensureCursorEntity() {
 	return runtimeEntities->spawnCursorEntity(_mousePos) != nullptr;
 }
 
+bool StartupFlow::populateRoomSceneEntities(const Common::Array<StartupObjectRecord> &drawableObjects,
+		const Common::Array<StartupAnimRecord> &drawableAnimations) {
+	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+	if (!runtimeEntities)
+		return false;
+
+	runtimeEntities->clearSceneEntities();
+	for (const StartupObjectRecord &object : drawableObjects) {
+		if (!runtimeEntities->spawnSceneBitmapEntity(object.objectName, object.resourcePath,
+				Common::Point(object.left, object.top), 0.0f)) {
+			debug(1, "Harvester: unable to spawn room object entity '%s' from '%s'",
+				object.objectName.c_str(), object.resourcePath.c_str());
+		}
+	}
+	for (const StartupAnimRecord &anim : drawableAnimations) {
+		if (!runtimeEntities->spawnSceneAnimationEntity(anim.animName, anim.resourcePath,
+				Common::Point(anim.x, anim.y), 0.0f, anim.animationRate, anim.active, anim.visible,
+				anim.looping, anim.backward, anim.pingPong, anim.initialFrame)) {
+			debug(1, "Harvester: unable to spawn room anim entity '%s' from '%s'",
+				anim.animName.c_str(), anim.resourcePath.c_str());
+		}
+	}
+
+	return true;
+}
+
 Common::Error StartupFlow::beginRoomSetupTransition() {
 	if (_engine.getRuntimeEntities())
 		_engine.getRuntimeEntities()->hideCursor();
@@ -830,11 +865,11 @@ void StartupFlow::resetCursorAnimationSequence() {
 	(void)_engine.getRuntimeEntities()->syncCursorEntityPosition(_mousePos);
 }
 
-bool StartupFlow::tickCursorAnimation() {
+bool StartupFlow::tickRuntimeEntities() {
 	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
 	if (!runtimeEntities)
 		return false;
-	return runtimeEntities->syncCursorEntityPosition(_mousePos);
+	return runtimeEntities->tickSceneEntities() || runtimeEntities->syncCursorEntityPosition(_mousePos);
 }
 
 void StartupFlow::renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const {
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/startup_flow.h
index 395c18c2f79..186d41daadf 100644
--- a/engines/harvester/startup_flow.h
+++ b/engines/harvester/startup_flow.h
@@ -26,6 +26,7 @@
 #include "common/error.h"
 #include "common/rect.h"
 #include "common/str.h"
+#include "harvester/startup_script.h"
 
 namespace Common {
 struct Event;
@@ -49,11 +50,13 @@ private:
 	Common::Error runMainMenuStub();
 	Common::Error runRoomSetupStub(const Common::String &entranceName);
 	bool ensureCursorEntity();
+	bool populateRoomSceneEntities(const Common::Array<StartupObjectRecord> &drawableObjects,
+		const Common::Array<StartupAnimRecord> &drawableAnimations);
 	Common::Error beginRoomSetupTransition();
 	Common::Error fadeInRoomScene(const byte *palette, float targetBrightness);
 	bool pumpTransitionEvents(Common::Error &result);
 	void resetCursorAnimationSequence();
-	bool tickCursorAnimation();
+	bool tickRuntimeEntities();
 	void renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const;
 	bool handleSystemEvent(const Common::Event &event, Common::Error &result);
 	int getMenuItemAt(const Common::Point &mousePos) const;
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 131e61cabc1..50007ce5da1 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -82,6 +82,7 @@ bool StartupScript::load(ResourceManager &resources) {
 	_entrances.clear();
 	_rooms.clear();
 	_objects.clear();
+	_animations.clear();
 	_flags.clear();
 	_commands.clear();
 	_texts.clear();
@@ -142,6 +143,7 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 	_entrances.clear();
 	_rooms.clear();
 	_objects.clear();
+	_animations.clear();
 	_flags.clear();
 	_commands.clear();
 	_texts.clear();
@@ -158,7 +160,7 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 
 		uint tagIndex = tokens.size();
 		for (uint i = 0; i < tokens.size(); ++i) {
-			if (tokens[i] == "ENTRANCE" || tokens[i] == "ROOM" || tokens[i] == "OBJECT" ||
+			if (tokens[i] == "ANIM" || tokens[i] == "ENTRANCE" || tokens[i] == "ROOM" || tokens[i] == "OBJECT" ||
 				tokens[i] == "FLAG" || tokens[i] == "COMMAND" || tokens[i] == "TEXT") {
 				tagIndex = i;
 				break;
@@ -251,6 +253,31 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 			return;
 		}
 
+		if (tag == "ANIM") {
+			if (tokens.size() < tagIndex + 10)
+				return;
+
+			StartupAnimRecord anim;
+			if (tagIndex >= 4) {
+				anim.initialFrame = atoi(tokens[0].c_str());
+				anim.x = atoi(tokens[1].c_str());
+				anim.y = atoi(tokens[2].c_str());
+				anim.animationRate = atoi(tokens[3].c_str());
+			}
+			anim.roomName = tokens[tagIndex + 1];
+			anim.resourcePath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
+			anim.animName = tokens[tagIndex + 3];
+			anim.active = tokens[tagIndex + 4].equalsIgnoreCase("T");
+			anim.visible = tokens[tagIndex + 5].equalsIgnoreCase("T");
+			anim.looping = tokens[tagIndex + 6].equalsIgnoreCase("T");
+			anim.backward = tokens[tagIndex + 7].equalsIgnoreCase("T");
+			anim.pingPong = tokens[tagIndex + 8].equalsIgnoreCase("T");
+			anim.remove = tokens[tagIndex + 9].equalsIgnoreCase("T");
+			if (!anim.roomName.empty() && !anim.resourcePath.empty() && !anim.animName.empty())
+				_animations.push_back(anim);
+			return;
+		}
+
 		if (tokens.size() < tagIndex + 13)
 			return;
 
@@ -291,8 +318,8 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 
 	parseLine(line);
 
-	debug(1, "Harvester: parsed %u entrances, %u rooms, %u objects, %u flags, %u commands, %u texts from '%s'",
-		(uint)_entrances.size(), (uint)_rooms.size(), (uint)_objects.size(),
+	debug(1, "Harvester: parsed %u entrances, %u rooms, %u objects, %u anims, %u flags, %u commands, %u texts from '%s'",
+		(uint)_entrances.size(), (uint)_rooms.size(), (uint)_objects.size(), (uint)_animations.size(),
 		(uint)_flags.size(), (uint)_commands.size(), (uint)_texts.size(), _path.c_str());
 }
 
@@ -345,6 +372,10 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 		if (object.ownerOrRoom.equalsIgnoreCase(room->roomName))
 			state.roomObjects.push_back(object);
 	}
+	for (const StartupAnimRecord &anim : _animations) {
+		if (anim.roomName.equalsIgnoreCase(room->roomName))
+			state.roomAnimations.push_back(anim);
+	}
 
 	Common::Array<StartupFlagRecord> resolvedFlags = _flags;
 	auto getFlagValue = [&](const Common::String &flagName) {
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index a166a41bdf6..9af32e9c188 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -60,6 +60,22 @@ struct StartupObjectRecord {
 	bool active = false;
 };
 
+struct StartupAnimRecord {
+	int initialFrame = 0;
+	int x = 0;
+	int y = 0;
+	int animationRate = 0;
+	Common::String roomName;
+	Common::String resourcePath;
+	Common::String animName;
+	bool active = false;
+	bool visible = false;
+	bool looping = false;
+	bool backward = false;
+	bool pingPong = false;
+	bool remove = false;
+};
+
 struct StartupFlagRecord {
 	Common::String name;
 	bool value = false;
@@ -88,6 +104,7 @@ struct StartupRoomSetupState {
 	float paletteBrightness = 1.0f;
 	Common::Array<StartupObjectRecord> activeObjects;
 	Common::Array<StartupObjectRecord> roomObjects;
+	Common::Array<StartupAnimRecord> roomAnimations;
 };
 
 struct StartupInteractionResult {
@@ -109,6 +126,7 @@ public:
 	const Common::Array<StartupEntranceRecord> &getEntrances() const { return _entrances; }
 	const Common::Array<StartupRoomRecord> &getRooms() const { return _rooms; }
 	const Common::Array<StartupObjectRecord> &getObjects() const { return _objects; }
+	const Common::Array<StartupAnimRecord> &getAnimations() const { return _animations; }
 	const Common::Array<StartupFlagRecord> &getFlags() const { return _flags; }
 	const Common::Array<StartupCommandRecord> &getCommands() const { return _commands; }
 	const Common::Array<StartupTextRecord> &getTexts() const { return _texts; }
@@ -131,6 +149,7 @@ private:
 	Common::Array<StartupEntranceRecord> _entrances;
 	Common::Array<StartupRoomRecord> _rooms;
 	Common::Array<StartupObjectRecord> _objects;
+	Common::Array<StartupAnimRecord> _animations;
 	Common::Array<StartupFlagRecord> _flags;
 	Common::Array<StartupCommandRecord> _commands;
 	Common::Array<StartupTextRecord> _texts;


Commit: ed8fc26ea23771d52a2eb2fb36c9b26e1abf18e4
    https://github.com/scummvm/scummvm/commit/ed8fc26ea23771d52a2eb2fb36c9b26e1abf18e4
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:36+02:00

Commit Message:
HARVESTER: Recover startup object hit testing

Changed paths:
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index 8e97178434e..57c6bc5998f 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -107,6 +107,10 @@ bool RuntimeEntity::loadBitmapResource(ResourceManager &resources, const Common:
 	_firstFrame = 0;
 	_lastFrame = 0;
 	_animationEnabled = false;
+	_drawEnabled = true;
+	_boundsWidth = frame.width;
+	_boundsHeight = frame.height;
+	_hitTestMode = kRuntimeEntityHitTestOpaquePixels;
 	return true;
 }
 
@@ -154,6 +158,10 @@ bool RuntimeEntity::loadAbmResource(ResourceManager &resources, const Common::St
 	_firstFrame = _currentFrame;
 	_lastFrame = _frames.empty() ? -1 : (int)_frames.size() - 1;
 	_animationEnabled = !_frames.empty();
+	_drawEnabled = true;
+	_boundsWidth = _frames.empty() ? 0 : _frames[0].width;
+	_boundsHeight = _frames.empty() ? 0 : _frames[0].height;
+	_hitTestMode = kRuntimeEntityHitTestOpaquePixels;
 	return true;
 }
 
@@ -195,6 +203,18 @@ void RuntimeEntity::setAnimationSequence(int sequence) {
 	advanceAnimationFrame(_firstFrame);
 }
 
+void RuntimeEntity::configureHotspotBounds(int width, int height) {
+	_frames.clear();
+	_currentFrame = -1;
+	_firstFrame = -1;
+	_lastFrame = -1;
+	_animationEnabled = false;
+	_drawEnabled = false;
+	_boundsWidth = MAX(width, 1);
+	_boundsHeight = MAX(height, 1);
+	_hitTestMode = kRuntimeEntityHitTestBounds;
+}
+
 bool RuntimeEntity::tickVisualState(uint32 now) {
 	if (!_animationEnabled || _currentFrame < 0 || _animationTickInterval == 0)
 		return false;
@@ -212,12 +232,43 @@ bool RuntimeEntity::tickVisualState(uint32 now) {
 }
 
 void RuntimeEntity::draw(Graphics::Screen &screen) const {
-	if (!_visible || _currentFrame < 0)
+	if (!_visible || !_drawEnabled || _currentFrame < 0)
 		return;
 
 	blitAnimationFrame(screen, _frames, _currentFrame, _x, _y);
 }
 
+Common::Rect RuntimeEntity::getFrameRect() const {
+	if (!_frames.empty() && _currentFrame >= 0 && (uint)_currentFrame < _frames.size()) {
+		const AbmFrame &frame = _frames[(uint)_currentFrame];
+		return Common::Rect(_x + frame.xOffset, _y + frame.yOffset,
+			_x + frame.xOffset + frame.width, _y + frame.yOffset + frame.height);
+	}
+
+	return Common::Rect(_x, _y, _x + _boundsWidth, _y + _boundsHeight);
+}
+
+bool RuntimeEntity::hitTest(const Common::Point &point) const {
+	if (_hitTestMode == kRuntimeEntityHitTestNone)
+		return false;
+
+	const Common::Rect bounds = getFrameRect();
+	if (!bounds.contains(point))
+		return false;
+	if (_hitTestMode != kRuntimeEntityHitTestOpaquePixels)
+		return true;
+	if (_frames.empty() || _currentFrame < 0 || (uint)_currentFrame >= _frames.size())
+		return false;
+
+	const AbmFrame &frame = _frames[(uint)_currentFrame];
+	const int relativeX = point.x - bounds.left;
+	const int relativeY = point.y - bounds.top;
+	if (relativeX < 0 || relativeY < 0 || relativeX >= (int)frame.width || relativeY >= (int)frame.height)
+		return false;
+
+	return frame.pixels[(uint)relativeY * frame.width + (uint)relativeX] != 0;
+}
+
 void RuntimeEntity::advanceAnimationFrame(int directive) {
 	if (_frames.empty())
 		return;
@@ -328,6 +379,8 @@ RuntimeEntity *RuntimeEntityManager::spawnCursorEntity(const Common::Point &posi
 		kRuntimeEntityClassCursor, position, kCursorEntityZ, kCursorAnimationRate, true, false);
 	if (_cursorEntity)
 		_cursorEntity->setAnimationSequence(0);
+	if (_cursorEntity)
+		_cursorEntity->setHitTestMode(kRuntimeEntityHitTestNone);
 
 	return _cursorEntity;
 }
@@ -337,13 +390,24 @@ RuntimeEntity *RuntimeEntityManager::spawnSceneBitmapEntity(const Common::String
 	RuntimeEntity *entity = spawnBitmapEntityFromResource(name, resourcePath, kRuntimeEntityClassObject,
 		position, z);
 	if (entity)
-		_sceneEntities.push_back(entity);
+		insertSceneEntity(entity);
+	return entity;
+}
+
+RuntimeEntity *RuntimeEntityManager::spawnSceneHotspotEntity(const Common::String &name,
+		const Common::Rect &bounds, float z) {
+	RuntimeEntity *entity = new RuntimeEntity();
+	entity->setName(name);
+	entity->setClassId(kRuntimeEntityClassObject);
+	entity->setPosition(bounds.left, bounds.top, z);
+	entity->configureHotspotBounds(bounds.width(), bounds.height());
+	insertSceneEntity(entity);
 	return entity;
 }
 
 RuntimeEntity *RuntimeEntityManager::spawnSceneAnimationEntity(const Common::String &name,
 		const Common::String &resourcePath, const Common::Point &position, float z, int animationRate,
-		bool active, bool visible, bool looping, bool playBackwards, bool pingPong, int initialFrame) {
+		bool active, bool visible, bool looping, bool playBackwards, bool pingPong) {
 	RuntimeEntity *entity = spawnAbmEntityFromResource(name, resourcePath, kRuntimeEntityClassAnimation,
 		position, z, animationRate, looping, pingPong);
 	if (!entity)
@@ -351,9 +415,8 @@ RuntimeEntity *RuntimeEntityManager::spawnSceneAnimationEntity(const Common::Str
 
 	entity->setVisible(visible);
 	entity->setPlayBackwards(playBackwards);
-	if (initialFrame >= 0)
-		entity->setCurrentFrame(initialFrame);
-	else if (playBackwards)
+	entity->setHitTestMode(kRuntimeEntityHitTestNone);
+	if (playBackwards)
 		entity->setCurrentFrame(entity->getLastFrame());
 	else
 		entity->setCurrentFrame(0);
@@ -361,7 +424,7 @@ RuntimeEntity *RuntimeEntityManager::spawnSceneAnimationEntity(const Common::Str
 	if (!active && !visible)
 		entity->setVisible(false);
 
-	_sceneEntities.push_back(entity);
+	insertSceneEntity(entity);
 	return entity;
 }
 
@@ -404,4 +467,23 @@ void RuntimeEntityManager::drawSceneEntities(Graphics::Screen &screen) const {
 		entity->draw(screen);
 }
 
+const RuntimeEntity *RuntimeEntityManager::findTopSceneEntityAt(const Common::Point &point, int classIdFilter) const {
+	for (int i = (int)_sceneEntities.size() - 1; i >= 0; --i) {
+		const RuntimeEntity *entity = _sceneEntities[(uint)i];
+		if (classIdFilter >= 0 && entity->getClassId() != classIdFilter)
+			continue;
+		if (entity->hitTest(point))
+			return entity;
+	}
+
+	return nullptr;
+}
+
+void RuntimeEntityManager::insertSceneEntity(RuntimeEntity *entity) {
+	uint insertIndex = 0;
+	while (insertIndex < _sceneEntities.size() && _sceneEntities[insertIndex]->getZ() >= entity->getZ())
+		++insertIndex;
+	_sceneEntities.insert_at(insertIndex, entity);
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index ebd241e1c92..49b0d9fb19c 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -41,6 +41,12 @@ enum RuntimeEntityClass {
 	kRuntimeEntityClassCursor = 2
 };
 
+enum RuntimeEntityHitTestMode {
+	kRuntimeEntityHitTestNone = 0,
+	kRuntimeEntityHitTestBounds = 1,
+	kRuntimeEntityHitTestOpaquePixels = 2
+};
+
 class RuntimeEntity {
 public:
 	bool loadBitmapResource(ResourceManager &resources, const Common::String &path);
@@ -70,12 +76,16 @@ public:
 	int getLastFrame() const { return _lastFrame; }
 	void setAnimationSequence(int sequence);
 	int getAnimationSequence() const { return _animationSequence; }
+	void configureHotspotBounds(int width, int height);
+	void setHitTestMode(RuntimeEntityHitTestMode mode) { _hitTestMode = mode; }
 
 	bool hasFrames() const { return !_frames.empty(); }
 	bool tickVisualState(uint32 now);
 	void draw(Graphics::Screen &screen) const;
+	bool hitTest(const Common::Point &point) const;
 
 private:
+	Common::Rect getFrameRect() const;
 	void advanceAnimationFrame(int directive);
 
 	Common::String _name;
@@ -97,6 +107,10 @@ private:
 	bool _playBackwards = false;
 	bool _animationEnabled = true;
 	bool _visible = true;
+	bool _drawEnabled = true;
+	int _boundsWidth = 0;
+	int _boundsHeight = 0;
+	RuntimeEntityHitTestMode _hitTestMode = kRuntimeEntityHitTestNone;
 };
 
 class RuntimeEntityManager {
@@ -113,9 +127,10 @@ public:
 	RuntimeEntity *spawnCursorEntity(const Common::Point &position);
 	RuntimeEntity *spawnSceneBitmapEntity(const Common::String &name, const Common::String &resourcePath,
 		const Common::Point &position, float z);
+	RuntimeEntity *spawnSceneHotspotEntity(const Common::String &name, const Common::Rect &bounds, float z);
 	RuntimeEntity *spawnSceneAnimationEntity(const Common::String &name, const Common::String &resourcePath,
 		const Common::Point &position, float z, int animationRate, bool active, bool visible, bool looping,
-		bool playBackwards, bool pingPong, int initialFrame);
+		bool playBackwards, bool pingPong);
 	RuntimeEntity *getCursorEntity() const { return _cursorEntity; }
 	void hideCursor();
 	void showCursor();
@@ -123,8 +138,11 @@ public:
 	bool syncCursorEntityPosition(const Common::Point &position);
 	void drawSceneEntities(Graphics::Screen &screen) const;
 	void drawCursor(Graphics::Screen &screen) const;
+	const RuntimeEntity *findTopSceneEntityAt(const Common::Point &point, int classIdFilter = -1) const;
 
 private:
+	void insertSceneEntity(RuntimeEntity *entity);
+
 	ResourceManager &_resources;
 	Common::Array<RuntimeEntity *> _sceneEntities;
 	RuntimeEntity *_cursorEntity = nullptr;
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 353b4ce1a8e..ce49840aa27 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -73,8 +73,8 @@ struct StartupRoomSceneResources {
 	StartupRoomSetupState state;
 	byte palette[256 * 3] = { 0 };
 	IndexedBitmap background;
-	Common::Array<StartupObjectRecord> drawableObjects;
-	Common::Array<StartupAnimRecord> drawableAnimations;
+	Common::Array<StartupObjectRecord> sceneObjects;
+	Common::Array<StartupAnimRecord> sceneAnimations;
 	float targetPaletteBrightness = 1.0f;
 };
 
@@ -164,25 +164,39 @@ static bool loadBitmapResource(ResourceManager &resources, const Common::String
 	return true;
 }
 
-static Common::Rect getObjectBounds(const StartupObjectRecord &object) {
-	if (object.right > object.left && object.bottom > object.top)
-		return Common::Rect(object.left, object.top, object.right, object.bottom);
+static Common::Rect getHotspotBounds(const StartupObjectRecord &object) {
+	if (object.boundsX2 > object.currentX && object.boundsY2 > object.currentY)
+		return Common::Rect(object.currentX, object.currentY, object.boundsX2 + 1, object.boundsY2 + 1);
 
 	return Common::Rect();
 }
 
-static void queueDrawableRoomObject(const StartupObjectRecord &object, Common::Array<StartupObjectRecord> &drawableObjects) {
-	if (object.resourcePath.empty() || !object.resourcePath.hasSuffixIgnoreCase(".BM"))
+static Common::String resolveSceneObjectSpritePath(const StartupObjectRecord &object) {
+	const bool atInitialPlacement = object.currentX == object.initialX &&
+		object.currentY == object.initialY &&
+		object.currentOwnerOrRoom.equalsIgnoreCase(object.initialOwnerOrRoom);
+	if (!object.altSpritePath.empty() &&
+		(!atInitialPlacement || object.currentOwnerOrRoom.equalsIgnoreCase("INVENTORY")))
+		return object.altSpritePath;
+
+	return object.spritePath;
+}
+
+static void queueVisibleSceneObject(const StartupObjectRecord &object, const Common::String &backgroundPath,
+		Common::Array<StartupObjectRecord> &sceneObjects) {
+	if (!object.visible)
+		return;
+	if (!backgroundPath.empty() && object.spritePath.equalsIgnoreCase(backgroundPath))
 		return;
 
-	for (const StartupObjectRecord &drawable : drawableObjects) {
-		if (drawable.ownerOrRoom.equalsIgnoreCase(object.ownerOrRoom) &&
-			drawable.objectName.equalsIgnoreCase(object.objectName)) {
+	for (const StartupObjectRecord &sceneObject : sceneObjects) {
+		if (sceneObject.currentOwnerOrRoom.equalsIgnoreCase(object.currentOwnerOrRoom) &&
+			sceneObject.objectName.equalsIgnoreCase(object.objectName)) {
 			return;
 		}
 	}
 
-	drawableObjects.push_back(object);
+	sceneObjects.push_back(object);
 }
 
 static bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceManager &resources, StartupRoomSceneResources &scene) {
@@ -194,18 +208,16 @@ static bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceM
 		return false;
 	}
 
-	Common::Array<StartupObjectRecord> drawableObjects;
-	for (const StartupObjectRecord &object : state.roomObjects) {
-		if (object.visible)
-			queueDrawableRoomObject(object, drawableObjects);
-	}
+	Common::Array<StartupObjectRecord> sceneObjects;
+	for (const StartupObjectRecord &object : state.roomObjects)
+		queueVisibleSceneObject(object, state.backgroundPath, sceneObjects);
 	for (const StartupObjectRecord &object : state.activeObjects)
-		queueDrawableRoomObject(object, drawableObjects);
+		queueVisibleSceneObject(object, state.backgroundPath, sceneObjects);
 
-	scene.drawableObjects = drawableObjects;
+	scene.sceneObjects = sceneObjects;
 	for (const StartupAnimRecord &anim : state.roomAnimations) {
 		if (anim.active || anim.visible)
-			scene.drawableAnimations.push_back(anim);
+			scene.sceneAnimations.push_back(anim);
 	}
 
 	return true;
@@ -220,26 +232,27 @@ static void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, con
 		engine.getRuntimeEntities()->drawSceneEntities(screen);
 }
 
-static const StartupObjectRecord *findObjectAtPoint(const Common::Array<StartupObjectRecord> &objects, const Common::Point &point) {
-	for (int i = (int)objects.size() - 1; i >= 0; --i) {
-		const StartupObjectRecord &object = objects[(uint)i];
-		if (!object.active)
-			continue;
-
-		const Common::Rect bounds = getObjectBounds(object);
-		if (!bounds.isEmpty() && bounds.contains(point))
+static const StartupObjectRecord *findSceneObjectByName(const Common::Array<StartupObjectRecord> &objects,
+		const Common::String &objectName) {
+	for (const StartupObjectRecord &object : objects) {
+		if (object.objectName.equalsIgnoreCase(objectName))
 			return &object;
 	}
 
 	return nullptr;
 }
 
-static const StartupObjectRecord *findRoomObjectAtPoint(const StartupRoomSetupState &state, const Common::Point &point) {
-	const StartupObjectRecord *object = findObjectAtPoint(state.activeObjects, point);
-	if (object)
-		return object;
+static const StartupObjectRecord *findRoomObjectAtPoint(HarvesterEngine &engine,
+		const Common::Array<StartupObjectRecord> &sceneObjects, const Common::Point &point) {
+	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
+	if (!runtimeEntities)
+		return nullptr;
 
-	return findObjectAtPoint(state.roomObjects, point);
+	const RuntimeEntity *entity = runtimeEntities->findTopSceneEntityAt(point, kRuntimeEntityClassObject);
+	if (!entity)
+		return nullptr;
+
+	return findSceneObjectByName(sceneObjects, entity->getName());
 }
 
 static Common::String trimAsciiLine(const Common::String &value) {
@@ -389,7 +402,7 @@ Common::Error StartupFlow::runQuickTips() {
 
 	Graphics::Screen *screen = _engine.getScreen();
 	if (screen) {
-		if (!populateRoomSceneEntities(scene.drawableObjects, scene.drawableAnimations))
+		if (!populateRoomSceneEntities(scene.sceneObjects, scene.sceneAnimations))
 			return Common::kReadingFailed;
 
 		drawRoomScene(_engine, *screen, scene, kPaletteBrightnessBlack);
@@ -578,7 +591,7 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 	if (!screen || !art || !titleFont || !bodyFont)
 		return Common::kNoError;
 
-	if (!populateRoomSceneEntities(scene.drawableObjects, scene.drawableAnimations))
+	if (!populateRoomSceneEntities(scene.sceneObjects, scene.sceneAnimations))
 		return Common::kReadingFailed;
 
 	drawRoomScene(_engine, *screen, scene, kPaletteBrightnessBlack);
@@ -596,10 +609,10 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 		entranceName.c_str(), state.roomName.c_str(), state.palettePath.c_str(), state.backgroundPath.c_str());
 	if (!state.musicPath.empty())
 		statusMessage += Common::String::format(" Music: %s.", state.musicPath.c_str());
-	if (!scene.drawableObjects.empty())
-		statusMessage += Common::String::format(" Visible scene objects: %u.", (uint)scene.drawableObjects.size());
-	if (!scene.drawableAnimations.empty())
-		statusMessage += Common::String::format(" Room anims: %u.", (uint)scene.drawableAnimations.size());
+	if (!scene.sceneObjects.empty())
+		statusMessage += Common::String::format(" Visible scene objects: %u.", (uint)scene.sceneObjects.size());
+	if (!scene.sceneAnimations.empty())
+		statusMessage += Common::String::format(" Room anims: %u.", (uint)scene.sceneAnimations.size());
 	statusMessage += " Click an active hotspot to follow its startup command chain.";
 	const Common::String baseStatusMessage = statusMessage;
 	StartupResolvedText inspectText;
@@ -613,32 +626,32 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 		if (needsRedraw) {
 			drawRoomScene(_engine, *screen, scene, scene.targetPaletteBrightness);
 
-			const Common::Rect panel(72, 336, 568, 468);
-			screen->fillRect(panel, kPanelFillColor);
-			drawShadowedString(*screen, *titleFont, Common::String::format("Room Setup Stub: %s", state.roomName.c_str()),
-				panel.left, 348, panel.width(), kTextColorNormal, Graphics::kTextAlignCenter);
-			Common::String hoverMessage;
-			Common::String footerMessage = "Press Enter or Escape to return to the menu stub.";
-			if (showingInspectText) {
-				hoverMessage = inspectText.value;
-				footerMessage = inspectCanDismiss ? "Click, Enter, or Escape to dismiss this inspect text."
-					: "Release the mouse button, then click again to dismiss this inspect text.";
-				drawShadowedString(*screen, *bodyFont, Common::String::format("Inspect: %s", inspectObjectLabel.c_str()),
-					panel.left, 372, panel.width(), kTextColorHover, Graphics::kTextAlignCenter);
-			} else {
-				const StartupObjectRecord *hoveredObject = findRoomObjectAtPoint(state, _mousePos);
-				if (hoveredObject) {
-					hoverMessage = _engine.getStartupScript()->resolveObjectLabel(*hoveredObject);
-					if (!hoveredObject->interactionCommandTag.empty())
-						hoverMessage += " Click to follow the scripted startup interaction.";
-					else if (!_engine.getStartupScript()->resolveObjectInspectText(*hoveredObject, inspectText))
-						inspectText = StartupResolvedText();
-					else
-						hoverMessage += " Click to inspect.";
+				const Common::Rect panel(72, 336, 568, 468);
+				screen->fillRect(panel, kPanelFillColor);
+				drawShadowedString(*screen, *titleFont, Common::String::format("Room Setup Stub: %s", state.roomName.c_str()),
+					panel.left, 348, panel.width(), kTextColorNormal, Graphics::kTextAlignCenter);
+				Common::String hoverMessage;
+				Common::String footerMessage = "Press Enter or Escape to return to the menu stub.";
+				if (showingInspectText) {
+					hoverMessage = inspectText.value;
+					footerMessage = inspectCanDismiss ? "Click, Enter, or Escape to dismiss this inspect text."
+						: "Release the mouse button, then click again to dismiss this inspect text.";
+					drawShadowedString(*screen, *bodyFont, Common::String::format("Inspect: %s", inspectObjectLabel.c_str()),
+						panel.left, 372, panel.width(), kTextColorHover, Graphics::kTextAlignCenter);
+				} else {
+					const StartupObjectRecord *hoveredObject = findRoomObjectAtPoint(_engine, scene.sceneObjects, _mousePos);
+					if (hoveredObject) {
+						hoverMessage = _engine.getStartupScript()->resolveObjectLabel(*hoveredObject);
+						if (!hoveredObject->actionTag.empty())
+							hoverMessage += " Click to follow the scripted startup interaction.";
+						else if (!_engine.getStartupScript()->resolveObjectInspectText(*hoveredObject, inspectText))
+							inspectText = StartupResolvedText();
+						else
+							hoverMessage += " Click to inspect.";
+					}
 				}
-			}
-			if (hoverMessage.empty())
-				hoverMessage = statusMessage;
+				if (hoverMessage.empty())
+					hoverMessage = statusMessage;
 
 			drawWrappedShadowedText(*screen, *bodyFont, hoverMessage, panel.left + 24, 386, panel.width() - 48,
 				kTextColorNormal);
@@ -679,12 +692,12 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 					break;
 				}
 
-				const StartupObjectRecord *clickedObject = findRoomObjectAtPoint(state, _mousePos);
-				if (!clickedObject) {
-					statusMessage = "No active startup hotspot under the cursor.";
-					needsRedraw = true;
-					break;
-				}
+					const StartupObjectRecord *clickedObject = findRoomObjectAtPoint(_engine, scene.sceneObjects, _mousePos);
+					if (!clickedObject) {
+						statusMessage = "No active startup hotspot under the cursor.";
+						needsRedraw = true;
+						break;
+					}
 
 				const Common::String objectLabel = _engine.getStartupScript()->resolveObjectLabel(*clickedObject);
 				StartupInteractionResult interaction;
@@ -708,25 +721,25 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 					statusMessage += Common::String::format(" Sound: %s (%s).", interaction.soundPath.c_str(),
 						soundStarted ? "playing" : "load failed");
 				}
-				needsRedraw = true;
+					needsRedraw = true;
 
-				if (!interaction.nextRoomName.empty()) {
-					Common::Error roomError = runRoomSetupStub(interaction.nextRoomName);
-					if (roomError.getCode() == Common::kReadingFailed) {
-						(void)populateRoomSceneEntities(scene.drawableObjects, scene.drawableAnimations);
-						resetCursorAnimationSequence();
-						statusMessage = Common::String::format("Unable to resolve closeup room '%s'.",
-							interaction.nextRoomName.c_str());
-						needsRedraw = true;
-					} else if (roomError.getCode() != Common::kNoError) {
-						return roomError;
-					} else {
-						(void)populateRoomSceneEntities(scene.drawableObjects, scene.drawableAnimations);
-						resetCursorAnimationSequence();
-						statusMessage = Common::String::format("Returned from %s.", interaction.nextRoomName.c_str());
-						needsRedraw = true;
+					if (!interaction.nextRoomName.empty()) {
+						Common::Error roomError = runRoomSetupStub(interaction.nextRoomName);
+						if (roomError.getCode() == Common::kReadingFailed) {
+							(void)populateRoomSceneEntities(scene.sceneObjects, scene.sceneAnimations);
+							resetCursorAnimationSequence();
+							statusMessage = Common::String::format("Unable to resolve closeup room '%s'.",
+								interaction.nextRoomName.c_str());
+							needsRedraw = true;
+						} else if (roomError.getCode() != Common::kNoError) {
+							return roomError;
+						} else {
+							(void)populateRoomSceneEntities(scene.sceneObjects, scene.sceneAnimations);
+							resetCursorAnimationSequence();
+							statusMessage = Common::String::format("Returned from %s.", interaction.nextRoomName.c_str());
+							needsRedraw = true;
+						}
 					}
-				}
 				break;
 			}
 			case Common::EVENT_KEYDOWN:
@@ -784,16 +797,26 @@ bool StartupFlow::populateRoomSceneEntities(const Common::Array<StartupObjectRec
 
 	runtimeEntities->clearSceneEntities();
 	for (const StartupObjectRecord &object : drawableObjects) {
-		if (!runtimeEntities->spawnSceneBitmapEntity(object.objectName, object.resourcePath,
-				Common::Point(object.left, object.top), 0.0f)) {
+		RuntimeEntity *entity = nullptr;
+		const Common::String spritePath = resolveSceneObjectSpritePath(object);
+		if (!spritePath.empty() && spritePath.hasSuffixIgnoreCase(".BM")) {
+			entity = runtimeEntities->spawnSceneBitmapEntity(object.objectName, spritePath,
+				Common::Point(object.currentX, object.currentY), (float)object.currentZ);
+		} else {
+			const Common::Rect hotspotBounds = getHotspotBounds(object);
+			if (!hotspotBounds.isEmpty())
+				entity = runtimeEntities->spawnSceneHotspotEntity(object.objectName, hotspotBounds, (float)object.currentZ);
+		}
+
+		if (!entity) {
 			debug(1, "Harvester: unable to spawn room object entity '%s' from '%s'",
-				object.objectName.c_str(), object.resourcePath.c_str());
+				object.objectName.c_str(), spritePath.c_str());
 		}
 	}
 	for (const StartupAnimRecord &anim : drawableAnimations) {
 		if (!runtimeEntities->spawnSceneAnimationEntity(anim.animName, anim.resourcePath,
-				Common::Point(anim.x, anim.y), 0.0f, anim.animationRate, anim.active, anim.visible,
-				anim.looping, anim.backward, anim.pingPong, anim.initialFrame)) {
+				Common::Point(anim.x, anim.y), (float)anim.z, anim.frameDelay, anim.active, anim.visible,
+				anim.looping, anim.backward, anim.pingPong)) {
 			debug(1, "Harvester: unable to spawn room anim entity '%s' from '%s'",
 				anim.animName.c_str(), anim.resourcePath.c_str());
 		}
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 50007ce5da1..87e5e8eb103 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -49,6 +49,16 @@ static Common::String trimAsciiLine(const Common::String &value) {
 	return value.substr(start, end - start);
 }
 
+static Common::String normalizeInteractionLabel(const Common::String &value) {
+	Common::String label = value;
+	for (uint i = 0; i < label.size(); ++i) {
+		if (label[i] == '_')
+			label.setChar(' ', i);
+	}
+
+	return label;
+}
+
 static void tokenizeTownScriptLine(const Common::String &line, Common::Array<Common::String> &tokens) {
 	tokens.clear();
 
@@ -253,54 +263,66 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 			return;
 		}
 
-		if (tag == "ANIM") {
-			if (tokens.size() < tagIndex + 10)
-				return;
+			if (tag == "ANIM") {
+				if (tokens.size() < tagIndex + 10)
+					return;
 
-			StartupAnimRecord anim;
-			if (tagIndex >= 4) {
-				anim.initialFrame = atoi(tokens[0].c_str());
-				anim.x = atoi(tokens[1].c_str());
-				anim.y = atoi(tokens[2].c_str());
-				anim.animationRate = atoi(tokens[3].c_str());
+				StartupAnimRecord anim;
+				if (tagIndex >= 4) {
+					anim.x = atoi(tokens[0].c_str());
+					anim.y = atoi(tokens[1].c_str());
+					anim.z = atoi(tokens[2].c_str());
+					anim.frameDelay = atoi(tokens[3].c_str());
+				}
+				anim.roomName = tokens[tagIndex + 1];
+				anim.resourcePath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
+				anim.animName = tokens[tagIndex + 3];
+				anim.active = tokens[tagIndex + 4].equalsIgnoreCase("T");
+				anim.visible = tokens[tagIndex + 5].equalsIgnoreCase("T");
+				anim.looping = tokens[tagIndex + 6].equalsIgnoreCase("T");
+				anim.backward = tokens[tagIndex + 7].equalsIgnoreCase("T");
+				anim.pingPong = tokens[tagIndex + 8].equalsIgnoreCase("T");
+				anim.remove = tokens[tagIndex + 9].equalsIgnoreCase("T");
+				anim.runtimeActive = anim.active;
+				anim.runtimeVisible = anim.visible;
+				if (!anim.roomName.empty() && !anim.resourcePath.empty() && !anim.animName.empty())
+					_animations.push_back(anim);
+				return;
 			}
-			anim.roomName = tokens[tagIndex + 1];
-			anim.resourcePath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
-			anim.animName = tokens[tagIndex + 3];
-			anim.active = tokens[tagIndex + 4].equalsIgnoreCase("T");
-			anim.visible = tokens[tagIndex + 5].equalsIgnoreCase("T");
-			anim.looping = tokens[tagIndex + 6].equalsIgnoreCase("T");
-			anim.backward = tokens[tagIndex + 7].equalsIgnoreCase("T");
-			anim.pingPong = tokens[tagIndex + 8].equalsIgnoreCase("T");
-			anim.remove = tokens[tagIndex + 9].equalsIgnoreCase("T");
-			if (!anim.roomName.empty() && !anim.resourcePath.empty() && !anim.animName.empty())
-				_animations.push_back(anim);
-			return;
-		}
 
-		if (tokens.size() < tagIndex + 13)
-			return;
+			if (tokens.size() < tagIndex + 13)
+				return;
 
-		StartupObjectRecord object;
-		if (tagIndex >= 4) {
-			object.left = atoi(tokens[0].c_str());
-			object.top = atoi(tokens[1].c_str());
-			object.right = atoi(tokens[2].c_str());
-			object.bottom = atoi(tokens[3].c_str());
-		}
-		object.ownerOrRoom = tokens[tagIndex + 1];
-		object.objectName = tokens[tagIndex + 2];
-		object.resourcePath = resources.normalizeResourcePath(tokens[tagIndex + 3]);
-		object.inventoryBitmapPath = resources.normalizeResourcePath(tokens[tagIndex + 4]);
-		object.shortTextKey = tokens[tagIndex + 6];
-		object.identTextKey = tokens[tagIndex + 8];
-		object.visible = tokens[tagIndex + 9].equalsIgnoreCase("T");
-		object.active = tokens[tagIndex + 10].equalsIgnoreCase("T");
-		object.interactionCommandTag = tokens[tagIndex + 11];
-		object.displayName = tokens[tagIndex + 12];
-		if (!object.ownerOrRoom.empty() && !object.objectName.empty())
-			_objects.push_back(object);
-	};
+			StartupObjectRecord object;
+			if (tagIndex >= 6) {
+				object.initialX = atoi(tokens[0].c_str());
+				object.initialY = atoi(tokens[1].c_str());
+				object.boundsX2 = atoi(tokens[2].c_str());
+				object.boundsY2 = atoi(tokens[3].c_str());
+				object.initialZ = atoi(tokens[4].c_str());
+				object.zExtent = atoi(tokens[5].c_str());
+			}
+			object.currentX = object.initialX;
+			object.currentY = object.initialY;
+			object.currentZ = object.initialZ;
+			object.initialOwnerOrRoom = tokens[tagIndex + 1];
+			object.objectName = tokens[tagIndex + 2];
+			object.spritePath = resources.normalizeResourcePath(tokens[tagIndex + 3]);
+			object.altSpritePath = resources.normalizeResourcePath(tokens[tagIndex + 4]);
+			object.field40 = tokens[tagIndex + 5];
+			object.inventoryTextKey = tokens[tagIndex + 6];
+			object.field34 = tokens[tagIndex + 7];
+			object.identTextKey = tokens[tagIndex + 8];
+			object.operatable = tokens[tagIndex + 9].equalsIgnoreCase("T");
+			object.visible = tokens[tagIndex + 10].equalsIgnoreCase("T");
+			object.actionTag = tokens[tagIndex + 11];
+			object.interactionLabel = tokens[tagIndex + 12];
+			object.currentOwnerOrRoom = object.initialOwnerOrRoom;
+			object.runtimeVisible = object.visible;
+			object.identShown = object.identTextKey.empty();
+			if (!object.initialOwnerOrRoom.empty() && !object.objectName.empty())
+				_objects.push_back(object);
+		};
 
 	Common::String line;
 	for (uint i = 0; i < _data.size(); ++i) {
@@ -353,9 +375,9 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 
 	const StartupObjectRecord *background = nullptr;
 	for (const StartupObjectRecord &candidate : _objects) {
-		if (!candidate.ownerOrRoom.equalsIgnoreCase(room->roomName) ||
-			!candidate.resourcePath.hasPrefixIgnoreCase("GRAPHIC/ROOMS/") ||
-			!candidate.resourcePath.hasSuffixIgnoreCase(".BM")) {
+		if (!candidate.currentOwnerOrRoom.equalsIgnoreCase(room->roomName) ||
+			!candidate.spritePath.hasPrefixIgnoreCase("GRAPHIC/ROOMS/") ||
+			!candidate.spritePath.hasSuffixIgnoreCase(".BM")) {
 			continue;
 		}
 
@@ -367,9 +389,9 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 
 	state.roomName = room->roomName;
 	state.palettePath = room->palettePath;
-	state.backgroundPath = background->resourcePath;
+	state.backgroundPath = background->spritePath;
 	for (const StartupObjectRecord &object : _objects) {
-		if (object.ownerOrRoom.equalsIgnoreCase(room->roomName))
+		if (object.currentOwnerOrRoom.equalsIgnoreCase(room->roomName))
 			state.roomObjects.push_back(object);
 	}
 	for (const StartupAnimRecord &anim : _animations) {
@@ -400,27 +422,27 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 	};
 	auto addObject = [&](const Common::String &ownerOrRoom, const Common::String &objectName) {
 		for (const StartupObjectRecord &candidate : _objects) {
-			if (!candidate.ownerOrRoom.equalsIgnoreCase(ownerOrRoom) ||
-				!candidate.objectName.equalsIgnoreCase(objectName) ||
-				candidate.resourcePath.empty() ||
-				!candidate.resourcePath.hasSuffixIgnoreCase(".BM")) {
+			if (!candidate.initialOwnerOrRoom.equalsIgnoreCase(ownerOrRoom) ||
+				!candidate.objectName.equalsIgnoreCase(objectName)) {
 				continue;
 			}
 
 			for (const StartupObjectRecord &activeObject : state.activeObjects) {
-				if (activeObject.ownerOrRoom.equalsIgnoreCase(candidate.ownerOrRoom) &&
+				if (activeObject.currentOwnerOrRoom.equalsIgnoreCase(ownerOrRoom) &&
 					activeObject.objectName.equalsIgnoreCase(candidate.objectName)) {
 					return;
 				}
 			}
 
-			state.activeObjects.push_back(candidate);
+			StartupObjectRecord object = candidate;
+			object.currentOwnerOrRoom = ownerOrRoom;
+			state.activeObjects.push_back(object);
 			return;
 		}
 	};
 	auto removeObject = [&](const Common::String &ownerOrRoom, const Common::String &objectName) {
 		for (uint i = 0; i < state.activeObjects.size(); ++i) {
-			if (state.activeObjects[i].ownerOrRoom.equalsIgnoreCase(ownerOrRoom) &&
+			if (state.activeObjects[i].currentOwnerOrRoom.equalsIgnoreCase(ownerOrRoom) &&
 				state.activeObjects[i].objectName.equalsIgnoreCase(objectName)) {
 				state.activeObjects.remove_at(i);
 				return;
@@ -487,7 +509,7 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 
 bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result) const {
 	result = StartupInteractionResult();
-	if (object.interactionCommandTag.empty())
+	if (object.actionTag.empty())
 		return false;
 
 	Common::Array<StartupFlagRecord> resolvedFlags = _flags;
@@ -519,7 +541,7 @@ bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object,
 		return nullptr;
 	};
 
-	Common::String currentTag = object.interactionCommandTag;
+	Common::String currentTag = object.actionTag;
 	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
 		const StartupCommandRecord *command = findCommand(currentTag);
 		if (!command) {
@@ -592,22 +614,17 @@ bool StartupScript::resolveObjectInspectText(const StartupObjectRecord &object,
 }
 
 Common::String StartupScript::resolveObjectLabel(const StartupObjectRecord &object) const {
-	const StartupTextRecord *textRecord = findTextRecord(object.shortTextKey);
+	if (!object.interactionLabel.empty() && !object.interactionLabel.equalsIgnoreCase("NULL_ID"))
+		return normalizeInteractionLabel(object.interactionLabel);
+
+	const StartupTextRecord *textRecord = findTextRecord(object.identTextKey);
 	if (textRecord && !textRecord->value.empty())
 		return textRecord->value;
 
-	Common::String label = object.displayName;
-	for (uint i = 0; i < label.size(); ++i) {
-		if (label[i] == '_')
-			label.setChar(' ', i);
-	}
-	if (!label.empty())
+	Common::String label = normalizeInteractionLabel(object.objectName);
+	if (!label.empty() && !label.equalsIgnoreCase("NULL ID"))
 		return label;
 
-	textRecord = findTextRecord(object.identTextKey);
-	if (textRecord)
-		return textRecord->value;
-
 	return Common::String();
 }
 
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 9af32e9c188..a4ced84f0fd 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -44,27 +44,37 @@ struct StartupRoomRecord {
 };
 
 struct StartupObjectRecord {
-	int left = 0;
-	int top = 0;
-	int right = 0;
-	int bottom = 0;
-	Common::String ownerOrRoom;
+	int initialX = 0;
+	int initialY = 0;
+	int initialZ = 0;
+	int currentX = 0;
+	int currentY = 0;
+	int currentZ = 0;
+	int boundsX2 = 0;
+	int boundsY2 = 0;
+	int zExtent = 0;
+	Common::String actionTag;
+	Common::String spritePath;
+	Common::String altSpritePath;
 	Common::String objectName;
-	Common::String resourcePath;
-	Common::String inventoryBitmapPath;
-	Common::String shortTextKey;
+	Common::String field34;
 	Common::String identTextKey;
-	Common::String interactionCommandTag;
-	Common::String displayName;
+	Common::String currentOwnerOrRoom;
+	Common::String field40;
+	Common::String inventoryTextKey;
+	Common::String initialOwnerOrRoom;
+	Common::String interactionLabel;
+	bool operatable = false;
 	bool visible = false;
-	bool active = false;
+	bool runtimeVisible = false;
+	bool identShown = false;
 };
 
 struct StartupAnimRecord {
-	int initialFrame = 0;
 	int x = 0;
 	int y = 0;
-	int animationRate = 0;
+	int z = 0;
+	int frameDelay = 0;
 	Common::String roomName;
 	Common::String resourcePath;
 	Common::String animName;
@@ -74,6 +84,9 @@ struct StartupAnimRecord {
 	bool backward = false;
 	bool pingPong = false;
 	bool remove = false;
+	bool runtimeActive = false;
+	bool runtimeVisible = false;
+	int runtimeState = -1;
 };
 
 struct StartupFlagRecord {


Commit: 4b90f9b6eaf96de1cdc1428f2767d457cc365e7b
    https://github.com/scummvm/scummvm/commit/4b90f9b6eaf96de1cdc1428f2767d457cc365e7b
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:37+02:00

Commit Message:
HARVESTER: Recover startup room background entities

Changed paths:
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h
    engines/harvester/startup_flow.cpp


diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index 57c6bc5998f..eef12ca41b2 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -81,6 +81,12 @@ static bool decodeAnimationFrame(const byte *source, uint32 sourceSize, bool com
 
 } // End of anonymous namespace
 
+static bool isRectangleOnlyHitClass(int classId) {
+	return classId == kRuntimeEntityClassRectHotspot ||
+		classId == kRuntimeEntityClassRectHotspot18 ||
+		classId == kRuntimeEntityClassRectHotspot19;
+}
+
 bool RuntimeEntity::loadBitmapResource(ResourceManager &resources, const Common::String &path) {
 	Common::Array<byte> data;
 	if (!resources.loadFile(path, data) || data.size() < 12) {
@@ -231,34 +237,59 @@ bool RuntimeEntity::tickVisualState(uint32 now) {
 	return advanced;
 }
 
+Common::Point RuntimeEntity::getDrawOrigin() const {
+	if (!_frames.empty() && _currentFrame >= 0 && (uint)_currentFrame < _frames.size()) {
+		const AbmFrame &frame = _frames[(uint)_currentFrame];
+		int drawX = _x;
+		int drawY = _y;
+		if (_anchorMode == kRuntimeEntityAnchorCentered) {
+			drawX -= (int)frame.width / 2;
+			drawY -= (int)frame.height / 2;
+		}
+
+		return Common::Point(drawX + frame.xOffset, drawY + frame.yOffset);
+	}
+
+	int drawX = _x;
+	int drawY = _y;
+	if (_anchorMode == kRuntimeEntityAnchorCentered) {
+		drawX -= _boundsWidth / 2;
+		drawY -= _boundsHeight / 2;
+	}
+
+	return Common::Point(drawX, drawY);
+}
+
 void RuntimeEntity::draw(Graphics::Screen &screen) const {
 	if (!_visible || !_drawEnabled || _currentFrame < 0)
 		return;
 
-	blitAnimationFrame(screen, _frames, _currentFrame, _x, _y);
+	const Common::Point drawOrigin = getDrawOrigin();
+	blitAnimationFrame(screen, _frames, _currentFrame, drawOrigin.x, drawOrigin.y);
 }
 
 Common::Rect RuntimeEntity::getFrameRect() const {
+	const Common::Point drawOrigin = getDrawOrigin();
 	if (!_frames.empty() && _currentFrame >= 0 && (uint)_currentFrame < _frames.size()) {
 		const AbmFrame &frame = _frames[(uint)_currentFrame];
-		return Common::Rect(_x + frame.xOffset, _y + frame.yOffset,
-			_x + frame.xOffset + frame.width, _y + frame.yOffset + frame.height);
+		return Common::Rect(drawOrigin.x, drawOrigin.y,
+			drawOrigin.x + frame.width, drawOrigin.y + frame.height);
 	}
 
-	return Common::Rect(_x, _y, _x + _boundsWidth, _y + _boundsHeight);
+	return Common::Rect(drawOrigin.x, drawOrigin.y, drawOrigin.x + _boundsWidth, drawOrigin.y + _boundsHeight);
 }
 
-bool RuntimeEntity::hitTest(const Common::Point &point) const {
-	if (_hitTestMode == kRuntimeEntityHitTestNone)
+bool RuntimeEntity::hasOpaqueFrame() const {
+	return !_frames.empty() && _currentFrame >= 0 && (uint)_currentFrame < _frames.size();
+}
+
+bool RuntimeEntity::isOpaqueAt(const Common::Point &point) const {
+	if (!hasOpaqueFrame())
 		return false;
 
 	const Common::Rect bounds = getFrameRect();
 	if (!bounds.contains(point))
 		return false;
-	if (_hitTestMode != kRuntimeEntityHitTestOpaquePixels)
-		return true;
-	if (_frames.empty() || _currentFrame < 0 || (uint)_currentFrame >= _frames.size())
-		return false;
 
 	const AbmFrame &frame = _frames[(uint)_currentFrame];
 	const int relativeX = point.x - bounds.left;
@@ -269,6 +300,62 @@ bool RuntimeEntity::hitTest(const Common::Point &point) const {
 	return frame.pixels[(uint)relativeY * frame.width + (uint)relativeX] != 0;
 }
 
+bool RuntimeEntity::hitTest(const Common::Point &point) const {
+	if (!_visible)
+		return false;
+	if (_classId == kRuntimeEntityClassDisabledHotspot)
+		return false;
+	if (_classId == kRuntimeEntityClassBackground)
+		return true;
+	if (_hitTestMode == kRuntimeEntityHitTestNone)
+		return false;
+
+	const Common::Rect bounds = getFrameRect();
+	if (!bounds.contains(point))
+		return false;
+	if (_hitTestMode != kRuntimeEntityHitTestOpaquePixels || isRectangleOnlyHitClass(_classId))
+		return true;
+
+	return isOpaqueAt(point);
+}
+
+bool RuntimeEntity::overlapsEntity(const RuntimeEntity &other) const {
+	if (this == &other || !_visible || !other._visible)
+		return false;
+	if (!hasOpaqueFrame() || !other.hasOpaqueFrame())
+		return false;
+	if (_classId == kRuntimeEntityClassAnimation || _classId == kRuntimeEntityClassBackground ||
+		_classId == kRuntimeEntityClassRectHotspot || _classId == kRuntimeEntityClassRectHotspot19 ||
+		other._classId == kRuntimeEntityClassAnimation || other._classId == kRuntimeEntityClassBackground ||
+		other._classId == kRuntimeEntityClassRectHotspot || other._classId == kRuntimeEntityClassRectHotspot19) {
+		return false;
+	}
+
+	const float thisZMax = _z + _zExtent;
+	const float otherZMax = other._z + other._zExtent;
+	if (thisZMax < other._z || otherZMax < _z)
+		return false;
+
+	const Common::Rect thisRect = getFrameRect();
+	const Common::Rect otherRect = other.getFrameRect();
+	const int left = MAX(thisRect.left, otherRect.left);
+	const int top = MAX(thisRect.top, otherRect.top);
+	const int right = MIN(thisRect.right, otherRect.right);
+	const int bottom = MIN(thisRect.bottom, otherRect.bottom);
+	if (left >= right || top >= bottom)
+		return false;
+
+	for (int y = top; y < bottom; ++y) {
+		for (int x = left; x < right; ++x) {
+			const Common::Point point(x, y);
+			if (isOpaqueAt(point) && other.isOpaqueAt(point))
+				return true;
+		}
+	}
+
+	return false;
+}
+
 void RuntimeEntity::advanceAnimationFrame(int directive) {
 	if (_frames.empty())
 		return;
@@ -413,6 +500,8 @@ RuntimeEntity *RuntimeEntityManager::spawnSceneAnimationEntity(const Common::Str
 	if (!entity)
 		return nullptr;
 
+	entity->setAnchorMode(kRuntimeEntityAnchorCentered);
+	entity->setZExtent(3.0f);
 	entity->setVisible(visible);
 	entity->setPlayBackwards(playBackwards);
 	entity->setHitTestMode(kRuntimeEntityHitTestNone);
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index 49b0d9fb19c..f4415267a66 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -38,7 +38,12 @@ class ResourceManager;
 enum RuntimeEntityClass {
 	kRuntimeEntityClassObject = 0,
 	kRuntimeEntityClassAnimation = 1,
-	kRuntimeEntityClassCursor = 2
+	kRuntimeEntityClassCursor = 2,
+	kRuntimeEntityClassBackground = 3,
+	kRuntimeEntityClassRectHotspot = 0x15,
+	kRuntimeEntityClassDisabledHotspot = 0x16,
+	kRuntimeEntityClassRectHotspot18 = 0x18,
+	kRuntimeEntityClassRectHotspot19 = 0x19
 };
 
 enum RuntimeEntityHitTestMode {
@@ -47,6 +52,11 @@ enum RuntimeEntityHitTestMode {
 	kRuntimeEntityHitTestOpaquePixels = 2
 };
 
+enum RuntimeEntityAnchorMode {
+	kRuntimeEntityAnchorTopLeft = 0,
+	kRuntimeEntityAnchorCentered = 1
+};
+
 class RuntimeEntity {
 public:
 	bool loadBitmapResource(ResourceManager &resources, const Common::String &path);
@@ -57,6 +67,9 @@ public:
 
 	void setClassId(int classId) { _classId = classId; }
 	int getClassId() const { return _classId; }
+	void setAnchorMode(RuntimeEntityAnchorMode anchorMode) { _anchorMode = anchorMode; }
+	void setZExtent(float zExtent) { _zExtent = zExtent; }
+	float getZExtent() const { return _zExtent; }
 
 	void setLooping(bool looping) { _looping = looping; }
 	void setPingPong(bool pingPong) { _pingPong = pingPong; }
@@ -78,14 +91,20 @@ public:
 	int getAnimationSequence() const { return _animationSequence; }
 	void configureHotspotBounds(int width, int height);
 	void setHitTestMode(RuntimeEntityHitTestMode mode) { _hitTestMode = mode; }
+	int getBoundsWidth() const { return _boundsWidth; }
+	int getBoundsHeight() const { return _boundsHeight; }
 
 	bool hasFrames() const { return !_frames.empty(); }
 	bool tickVisualState(uint32 now);
 	void draw(Graphics::Screen &screen) const;
 	bool hitTest(const Common::Point &point) const;
+	bool overlapsEntity(const RuntimeEntity &other) const;
 
 private:
+	Common::Point getDrawOrigin() const;
 	Common::Rect getFrameRect() const;
+	bool hasOpaqueFrame() const;
+	bool isOpaqueAt(const Common::Point &point) const;
 	void advanceAnimationFrame(int directive);
 
 	Common::String _name;
@@ -111,6 +130,8 @@ private:
 	int _boundsWidth = 0;
 	int _boundsHeight = 0;
 	RuntimeEntityHitTestMode _hitTestMode = kRuntimeEntityHitTestNone;
+	RuntimeEntityAnchorMode _anchorMode = kRuntimeEntityAnchorTopLeft;
+	float _zExtent = 0.0f;
 };
 
 class RuntimeEntityManager {
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index ce49840aa27..ec99940fea3 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -72,7 +72,6 @@ static const byte kPanelFillColor = 1;
 struct StartupRoomSceneResources {
 	StartupRoomSetupState state;
 	byte palette[256 * 3] = { 0 };
-	IndexedBitmap background;
 	Common::Array<StartupObjectRecord> sceneObjects;
 	Common::Array<StartupAnimRecord> sceneAnimations;
 	float targetPaletteBrightness = 1.0f;
@@ -148,22 +147,6 @@ static bool loadPaletteResource(ResourceManager &resources, const Common::String
 	return true;
 }
 
-static bool loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) {
-	Common::Array<byte> data;
-	if (!resources.loadFile(path, data) || data.size() < 12)
-		return false;
-
-	bitmap.width = READ_LE_UINT32(data.data());
-	bitmap.height = READ_LE_UINT32(data.data() + 4);
-	const uint32 pixelCount = bitmap.width * bitmap.height;
-	if (bitmap.width == 0 || bitmap.height == 0 || data.size() < 12 + pixelCount)
-		return false;
-
-	bitmap.pixels.resize(pixelCount);
-	memcpy(bitmap.pixels.data(), data.data() + 12, pixelCount);
-	return true;
-}
-
 static Common::Rect getHotspotBounds(const StartupObjectRecord &object) {
 	if (object.boundsX2 > object.currentX && object.boundsY2 > object.currentY)
 		return Common::Rect(object.currentX, object.currentY, object.boundsX2 + 1, object.boundsY2 + 1);
@@ -182,12 +165,21 @@ static Common::String resolveSceneObjectSpritePath(const StartupObjectRecord &ob
 	return object.spritePath;
 }
 
-static void queueVisibleSceneObject(const StartupObjectRecord &object, const Common::String &backgroundPath,
-		Common::Array<StartupObjectRecord> &sceneObjects) {
+static bool isBackgroundSceneObject(const StartupObjectRecord &object, const RuntimeEntity &entity) {
+	return object.initialX == 0 && object.initialY == 0 &&
+		entity.getBoundsWidth() == 640 && entity.getBoundsHeight() == 480;
+}
+
+static int resolveSceneObjectClass(const StartupObjectRecord &object, const RuntimeEntity *entity) {
+	if (entity)
+		return isBackgroundSceneObject(object, *entity) ? kRuntimeEntityClassBackground : kRuntimeEntityClassObject;
+
+	return object.actionTag.empty() ? kRuntimeEntityClassDisabledHotspot : kRuntimeEntityClassRectHotspot;
+}
+
+static void queueVisibleSceneObject(const StartupObjectRecord &object, Common::Array<StartupObjectRecord> &sceneObjects) {
 	if (!object.visible)
 		return;
-	if (!backgroundPath.empty() && object.spritePath.equalsIgnoreCase(backgroundPath))
-		return;
 
 	for (const StartupObjectRecord &sceneObject : sceneObjects) {
 		if (sceneObject.currentOwnerOrRoom.equalsIgnoreCase(object.currentOwnerOrRoom) &&
@@ -203,16 +195,15 @@ static bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceM
 	scene = StartupRoomSceneResources();
 	scene.state = state;
 	scene.targetPaletteBrightness = state.paletteBrightness;
-	if (!loadPaletteResource(resources, state.palettePath, scene.palette) ||
-		!loadBitmapResource(resources, state.backgroundPath, scene.background)) {
+	if (!loadPaletteResource(resources, state.palettePath, scene.palette)) {
 		return false;
 	}
 
 	Common::Array<StartupObjectRecord> sceneObjects;
 	for (const StartupObjectRecord &object : state.roomObjects)
-		queueVisibleSceneObject(object, state.backgroundPath, sceneObjects);
+		queueVisibleSceneObject(object, sceneObjects);
 	for (const StartupObjectRecord &object : state.activeObjects)
-		queueVisibleSceneObject(object, state.backgroundPath, sceneObjects);
+		queueVisibleSceneObject(object, sceneObjects);
 
 	scene.sceneObjects = sceneObjects;
 	for (const StartupAnimRecord &anim : state.roomAnimations) {
@@ -227,7 +218,6 @@ static void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, con
 		float brightness) {
 	setScaledPalette(screen, scene.palette, brightness);
 	screen.fillRect(screen.getBounds(), 0);
-	blitBitmap(screen, scene.background, 0, 0);
 	if (engine.getRuntimeEntities())
 		engine.getRuntimeEntities()->drawSceneEntities(screen);
 }
@@ -248,7 +238,7 @@ static const StartupObjectRecord *findRoomObjectAtPoint(HarvesterEngine &engine,
 	if (!runtimeEntities)
 		return nullptr;
 
-	const RuntimeEntity *entity = runtimeEntities->findTopSceneEntityAt(point, kRuntimeEntityClassObject);
+	const RuntimeEntity *entity = runtimeEntities->findTopSceneEntityAt(point);
 	if (!entity)
 		return nullptr;
 
@@ -707,6 +697,8 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 						showingInspectText = true;
 						inspectCanDismiss = false;
 						statusMessage = Common::String::format("Showing inspect text for %s.", objectLabel.c_str());
+					} else if (objectLabel.empty()) {
+						statusMessage = "No active startup hotspot under the cursor.";
 					} else {
 						statusMessage = Common::String::format("%s has no supported startup interaction yet.",
 							objectLabel.c_str());
@@ -811,7 +803,12 @@ bool StartupFlow::populateRoomSceneEntities(const Common::Array<StartupObjectRec
 		if (!entity) {
 			debug(1, "Harvester: unable to spawn room object entity '%s' from '%s'",
 				object.objectName.c_str(), spritePath.c_str());
+			continue;
 		}
+
+		entity->setClassId(resolveSceneObjectClass(object, entity->hasFrames() ? entity : nullptr));
+		entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
+		entity->setZExtent((float)object.zExtent);
 	}
 	for (const StartupAnimRecord &anim : drawableAnimations) {
 		if (!runtimeEntities->spawnSceneAnimationEntity(anim.animName, anim.resourcePath,


Commit: 65ce00115a216c5facf93840ac4b261f766e103d
    https://github.com/scummvm/scummvm/commit/65ce00115a216c5facf93840ac4b261f766e103d
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:37+02:00

Commit Message:
HARVESTER: Fix runtime entity overlap gating

Changed paths:
    engines/harvester/runtime_entity.cpp


diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index eef12ca41b2..99123dcb14d 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -324,9 +324,9 @@ bool RuntimeEntity::overlapsEntity(const RuntimeEntity &other) const {
 		return false;
 	if (!hasOpaqueFrame() || !other.hasOpaqueFrame())
 		return false;
-	if (_classId == kRuntimeEntityClassAnimation || _classId == kRuntimeEntityClassBackground ||
+	if (_classId == kRuntimeEntityClassCursor || _classId == kRuntimeEntityClassBackground ||
 		_classId == kRuntimeEntityClassRectHotspot || _classId == kRuntimeEntityClassRectHotspot19 ||
-		other._classId == kRuntimeEntityClassAnimation || other._classId == kRuntimeEntityClassBackground ||
+		other._classId == kRuntimeEntityClassCursor || other._classId == kRuntimeEntityClassBackground ||
 		other._classId == kRuntimeEntityClassRectHotspot || other._classId == kRuntimeEntityClassRectHotspot19) {
 		return false;
 	}


Commit: d5db123a90819a21213b2e1bf1ef297b33e99eb2
    https://github.com/scummvm/scummvm/commit/d5db123a90819a21213b2e1bf1ef297b33e99eb2
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:37+02:00

Commit Message:
HARVESTER: Record recent RE findings

Changed paths:
    ARCHITECTURE.md
    TRACKER.md


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 44a373f5b38..76e0566a0ff 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -100,6 +100,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - It loads the town script blob through XFILE and deobfuscates it by XORing bytes with `0xAA` except CR/LF.
 - The function then registers a large vocabulary of words/primitives, including `create`, `lit`, `var`, `const`, `if`, `else`, `branch`, `quote`, `.quote`, `do_do`, `do_loop`, and related operators.
 - This strongly suggests a compact Forth-like or stack-based scripting runtime rather than a simple linear bytecode interpreter.
+- `g_town_script_runtime_state` at `0xd3050` is the 0x20-byte base state block zeroed by `reset_town_script_runtime_state` at `0x48000`.
+  - The mixed Borland runtime descriptor block still binds a no-op paired callback above it, but the owning state object itself is now explicit from the `initialize_town_script_runtime` and `run_town_script_interpreter` xrefs.
 
 ## Confirmed Cold-Start Resource Order
 
@@ -149,6 +151,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `configure_video_surface` matches each descriptor on width / height / bpp / backend class, calls the descriptor's probe and activate callbacks, and tears down the previously active backend through the record's shutdown callback before switching.
     - `VesaModeBackend` is now a typed 64-byte descriptor record with confirmed width/height/pixel-format fields, probe/activate/shutdown/display-start callbacks, pixel read/write callbacks, the shared blit callback, and the runtime bank/window fields consumed by FST playback.
     - `VideoSurfaceContext` is now a typed 16-byte context that caches the active backend pointer plus the selected width/height/bpp/class.
+    - The mixed Borland runtime descriptor block also binds `set_vesa_video_mode_3` and `reset_video_surface_context` to `g_video_surface_context`; that pair is the small lifecycle wrapper layer above the shared context object.
   - The shared VESA helper layer is now partially named:
     - `query_vesa_controller_info` validates the controller signature through BIOS `int 10h AX=4f00`.
     - `set_vesa_video_mode` checks the current VESA mode with `AX=4f03` and only issues `AX=4f02` when a switch is required.
@@ -352,6 +355,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `keyboard_irq1_handler` at `0x48460` is the custom IRQ1 keyboard handler.
   - It reads scan codes from port `0x60`, updates `g_last_keyboard_scancode` plus the `g_keyboard_scancode_pressed` table, clears key-release events by masking bit `0x80`, and acknowledges the PIC with `out 0x20, 0x20`.
 - `install_keyboard_irq1_handler` at `0x484f0` and `remove_keyboard_irq1_handler` at `0x485b0` lock/unlock the handler code and data, zero the scan-code table, and install/remove interrupt vector `9`.
+- `install_keyboard_irq1_handler_state` at `0x48700` and `remove_keyboard_irq1_handler_state` at `0x48660` are the explicit-state forms of that same path.
+  - `g_keyboard_irq1_handler_state` at `0xd5900` is the small backing object that caches the previous IRQ1 vector and the installed flag used by the text-entry/dialogue code and the mixed Borland runtime descriptor block.
 - `dos_get_interrupt_vector` at `0x866f8` and `dos_set_interrupt_vector` at `0x8672c` are the DOS `int 21h` AH=`35h` / AH=`25h` wrappers used by that install/remove path.
 - The recovered input bytes now separate into concrete scan-code slots:
   - `g_input_up_pressed` / `g_input_left_pressed` / `g_input_right_pressed` / `g_input_down_pressed` are the `0x48` / `0x4b` / `0x4d` / `0x50` slots in `g_keyboard_scancode_pressed`.
@@ -384,6 +389,9 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `+0x112c` stores the active sequence index
 - `is_cursor_within_entity_bounds` at `0x4b670` is the rectangle-only cursor test for runtime entities.
 - `is_cursor_over_entity_hitmask` at `0x4b5b0` adds the per-pixel transparency test used by the main loop before interaction dispatch.
+  - Class `0x16` skips the cursor gate entirely.
+  - Classes `0x15`, `0x18`, and `0x19` stop at the rectangle gate and do not require an opaque pixel.
+  - Class `3` bypasses the cursor bounds/mask checks and acts as the room-background fallback entity when nothing higher in the render list matches.
 
 ## Idle Animation Timing
 
@@ -420,6 +428,9 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `spawn_anim_entity_from_record` at `0x4bbe0`
   - `spawn_npc_entity_from_record` at `0x4cd00`
   - `spawn_monster_entity_from_record` at `0x53a10`
+  - `spawn_object_entity_from_record` promotes sprite-backed objects whose initial coordinates are `(0, 0)` and whose loaded bitmap is `640x480` to runtime class `3`, the room-background object class.
+  - Hotspot-only objects with no action tag become class `0x16`; the other hotspot-only objects become class `0x15`.
+  - `spawn_anim_entity_from_record` immediately drives `show_entity_visual`, so room `ANIM` records use the centered anchor path during room setup, while room bitmap objects are inserted at their record `x` / `y` without that initial recentering step.
 - `CommandRecord` is now stable enough to use directly in the event interpreter:
   - `opcode`, `trigger_tag`, `arg1`..`arg4`, `runtime_flag`, `next`
 - `EntranceRecord` is stable and drives room transitions and save/load handoff:
@@ -443,6 +454,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `interaction_label` is copied into the runtime object entity and drives the main-loop hover / use prompt text; a `NULL_ID` label suppresses prompt generation.
   - `z_extent` is copied into the runtime object entity at offset `+0x20`; `FUN_0004cae0` uses the live interval `[entity + 0x1c, entity + 0x1c + 0x20]` before the 2D overlap test, which confirms that the field participates in third-axis collision gating.
   - `field_40` is a heap string freed by `free_loaded_world_data`, but no confirmed live consumer has been recovered yet.
+  - `spawn_object_entity_from_record` uses `sprite_path` while the object remains at its initial position / owner outside `INVENTORY`, otherwise it switches to `alt_sprite_path`.
+  - When `sprite_path` is null, the same helper allocates a rectangle-only hotspot from `current_x`, `current_y`, `bounds_x2`, and `bounds_y2` instead of a bitmap-backed render entity.
 - `RegionRecord` is now structurally clear:
   - `min_x`, `min_y`, `max_x`, `max_y`, `floor_z`, `ceiling_z`
   - `facing`, `region_name`, `room_name`, `action_tag`
@@ -476,12 +489,11 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `field_38`, `field_3c`, `field_44`, and `field_48` are heap strings freed during world teardown, but no confirmed live consumers have been recovered yet.
   - `field_74` and `field_7c` are copied into live runtime slots `+0x1170` and `+0x1178`, but the current binary only writes those slots; no read-side consumers were recovered.
   - Remaining unknowns include `field_1c`, `field_38`, `field_3c`, `field_44`, `field_48`, `field_74`, `field_7c`
-- `AnimRecord` now has stable owner/resource identity plus mirrored runtime state:
-  - leading numeric field at `+0x00` still unresolved
-  - `pos_x`, `pos_y`, `frame_delay`
+- `AnimRecord` now has stable placement/resource identity plus mirrored runtime state:
+  - `pos_x`, `pos_y`, `pos_z`, `frame_delay`
   - `room_name`, `anim_path`, `anim_name`
   - script booleans: `active`, `visible`, `loop`, `backward`, `ping_pong`, `remove`
-  - `room_setup` and room teardown reuse the same live bytes as runtime state: `active`, `visible`, and `runtime_state`
+  - `room_setup` and room teardown reuse the same live bytes as runtime state: `runtime_active`, `runtime_visible`, and `runtime_state`
 - `TextRecord` now has stable string identity:
   - `text_id`, `panel_id`, `text_value`, `next`
   - The parser normalizes `_` to space characters in `text_value`
@@ -513,10 +525,32 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `rescale_entity_sprite_for_depth` at `0x4b440` rescales an entity sprite from the current room depth model.
   - It applies the room-wide depth slope derived from `full_scale_z`, `max_z_scale_percent`, and `max_z`, rescales the backing bitmap, and updates the runtime width / height / anchor offsets.
 - `set_entity_screen_position` at `0x4b6f0` writes the live entity screen-space `x`, `y`, and depth/z anchor used by overlap and render ordering.
+- `update_visible_entity_screen_position` at `0x4c860` is the visible-entity wrapper used by `sync_cursor_entity_position`.
+  - It only runs while the entity's visibility nesting count is nonnegative.
+  - It forwards the entity plus the shared dirty-rect list into `update_render_entity_screen_position`, using the entity's live anchor offsets when deriving the new screen-space position.
+- `add_entity_to_render_list` at `0x5c130` inserts render entities in descending live-depth order from offset `+0x1c`, rescales them before insertion, and merges the affected dirty rect into `g_dirty_rect_list`.
+- `update_render_entity_screen_position` at `0x5cc10` is the shared screen-position updater for live render entities.
+  - When the depth/z anchor is unchanged, it keeps the entity in place in the render list and merges the old/new screen rectangles into `g_dirty_rect_list`.
+  - When the depth/z anchor changes, it removes the entity from `g_render_entity_list`, rewrites the live `x` / `y` / `z`, rescales the sprite for depth, reinserts the entity in depth order, and then merges the new dirty region.
+  - It uses `do_rects_overlap` at `0x5e0d0` and `compute_rect_area` at `0x5e0c0` to decide when dirty rectangles should be merged.
 - `do_entity_screen_bounds_overlap` at `0x4b700` is the rectangle-only overlap test used before the more specific cursor-hit and actor/region gating paths.
+- `tick_entity_visual_state` at `0x4cae0` is the shared entity animation/collision step for render-list classes below the actor layer.
+  - For moving/entity-overlap users it first checks whether the live depth intervals `[z, z + z_extent]` overlap.
+  - Only after that Z gate does it call `do_entity_screen_bounds_overlap`.
+  - The final collision confirmation is `FUN_0004a9e0`, which compares two live bitmap payloads and only reports overlap when both pixels are nonzero.
+  - Class `3`, `0x15`, and `0x19` are skipped by that entity/entity overlap path.
+  - On collision it stores the blocking render-entity pointer at runtime offset `+0x109c`; the confirmed read-side consumers of that slot are in `update_actor_runtime_state`.
+- `update_actor_runtime_state` reuses that blocking-entity slot in two confirmed ways:
+  - In the directional movement states it treats `+0x109c` as the current blocker, ignores null/hidden/self-recent blockers, and otherwise cancels the current move state, zeros the movement deltas, and copies the blocker into one of the per-direction remembered-blocker slots at `+0x108c` / `+0x1090` / `+0x1094` / `+0x1098`.
+  - In the close-range attack/grapple states it treats `+0x109c` as the current target entity, links the attacker/target pair through runtime field `+0x11b8`, mirrors the attacker's current depth into the target, and can force the target into a reaction / death sequence depending on the target class and active scene flags.
 - `hide_entity_visual` at `0x4c260` and `show_entity_visual` at `0x4c290` are the nested runtime visibility wrappers for render entities.
   - `hide_entity_visual` decrements the visibility nesting and removes the entity from `g_render_entity_list` only on the transition to hidden.
   - `show_entity_visual` increments the nesting and, when the entity becomes visible again, refreshes the current frame bitmap and re-adds it to `g_render_entity_list`.
+  - The recentering path only runs when the internal anchor flag at `+0x4f` is clear.
+  - That path subtracts half the current bitmap width and height from the stored screen position and subtracts half `z_extent` from the live depth before the render-list update.
+- `is_cursor_within_entity_bounds` at `0x4b670` is the rectangle gate for runtime entities; class `0x16` skips hit-testing entirely.
+- `is_cursor_over_entity_hitmask` at `0x4b5b0` adds the transparent-pixel test on top of that rectangle gate.
+  - Runtime classes `0x15`, `0x18`, and `0x19` bypass the per-pixel transparency check and behave as rectangle-only hotspots.
 - `spawn_scaled_abm_entity_from_resource` at `0x4bda0` is the scale-managed variant of `spawn_abm_entity_from_resource`.
   - It loads the same ABM resource format, but additionally marks the runtime entity for depth scaling and seeds the live sprite-scale field before the first `show_entity_visual` / render-list add.
   - `run_harvester_main_loop` uses it for `IDLE_ANIM` with `graphic\\roomanim\\pcloun02.abm`, copying the live player avatar's current scale so the temporary idle actor inherits the same room-depth sizing.
@@ -607,12 +641,26 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `refresh_irq0_timer_handler_if_active` at `0x84e09` is the small guard wrapper that reruns the mask/install/unmask IRQ0 sequence only when the backend timer layer is active.
     - `count_active_sound_voice_slots_in_bank` at `0x85b68` counts the backend voice slots within one bank whose status word at `+0x30` still carries the active `0x8000` bit.
     - The callback object rooted at `0xca194` is now partially constrained:
+      - `g_sound_driver_callback_context` at `0xca194` is now typed as a minimal `SoundDriverCallbackContext`
+      - offset `+0x14` is `selected_voice_slot_index`; it is also labeled at `0xca1a8` as `g_selected_sound_voice_slot_index`
       - offset `+0x18` overlaps `g_sound_voice_bank_index`
       - offset `+0x20` overlaps `g_sound_driver_initialized`
+      - the descriptor blob at `0xc0c98` points at this callback object directly, which is what ties the table-driven wrappers back to the current-bank driver context
       - `set_selected_sound_driver_control_value` at `0x183e0` writes a 16-bit value through `set_driver_control_entry_value` for the currently selected bank/control index when the backend is initialized
       - `count_active_sound_voice_slots_for_selected_bank` at `0x18400` returns the active-slot count for that same selected bank
       - `release_selected_sound_bank_resources` at `0x18380` clears the selected voice-rate entry, releases the selected bank through `release_sound_bank_resources`, refreshes IRQ0 timing state, and then drops the linear-region locks
+      - the shared packed callback adapter at `0x81448` is now `report_undefined_constructor_or_destructor_called`; it emits the exact runtime string `undefined constructor or destructor called!` and aborts through the Borland runtime error path.
+      - the smaller packed descriptor entries at `0xc0ccc`, `0xc0cec`, and `0xc0d0c` are therefore mixed Borland object-lifecycle records rather than a PCM-only callback table:
+        - each `0x20`-byte entry begins with `3`, then a direct callback pointer and a state-object pointer
+        - the trailing callback/size fields are stored as 24-bit values left-shifted by 8 inside 32-bit slots
+        - those packed fields resolve to an object-specific secondary callback, the shared undefined-ctor/dtor adapter, the direct callback again, and the state size
+        - representative entries pair `set_vesa_video_mode_3` / `reset_video_surface_context` with `g_video_surface_context` and size `0x10`, `FUN_00047ff0` / `reset_town_script_runtime_state` with `g_town_script_runtime_state` and size `0x20`, and `remove_keyboard_irq1_handler_state` / `install_keyboard_irq1_handler_state` with `g_keyboard_irq1_handler_state` and size `0x8`
+      - the first music-facing entry at `0xc0c90` is an extended variant of that same packed format: it binds `release_selected_sound_bank_resources` to `g_sound_driver_callback_context` and `release_music_stream_buffers` at `0x1a440` to `g_music_stream_state`, but the remaining packed size/order fields still do not type cleanly enough to formalize as a struct
     - `release_sound_bank_resources` at `0x84873` is the shared bank teardown helper beneath that callback layer; it frees the bank's transient DOS buffer when present, clears per-bank tables, and may trigger the additional DPMI unlock/free path depending on the hidden caller flag.
+    - The special teardown branch inside `release_sound_bank_resources` is now bounded as an `int 0x4B` service path:
+      - `call_int4b_service_8108_with_temp_dos_block` at `0x84c87` frees an existing LDT selector, allocates a temporary 32-paragraph DOS block via DPMI `int 31h, ax=0100`, copies the 16-byte `g_int4b_service_8108_request_template` into that block, simulates interrupt `0x4B` with `AX=0x8108` through `g_int4b_real_mode_registers`, and then frees the DOS block again
+      - `simulate_int4b_service_8107_and_test_status_flag` at `0x874f0` and `simulate_int4b_service_8108` at `0x8755c` are the paired simulated-real-mode wrappers for services `0x8107` and `0x8108`; the exact driver semantics behind those services are still unresolved
+      - `allocate_dos_memory_block_via_dpmi`, `free_dos_memory_block_selector_via_dpmi`, and `free_ldt_selector_via_dpmi` are the supporting DPMI helpers beneath that path
   - The higher-level streamed-music state on top of that backend is now explicit:
     - `g_music_stream_state` at `0xca1d4` is the persistent room/menu/game-over music stream object.
     - `g_current_music_path` caches the current music filename so room transitions and scripted overrides can restore it.
@@ -625,6 +673,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - The stream-layer helpers are now named:
     - `start_music_stream` at `0x1bfc0` swaps to a new room/menu music file, opens the backing XFILE stream, primes the first audio chunk, and allocates a backend voice slot.
     - `update_music_stream` at `0x1b9a0` is the per-frame pump used by menus, dialogue waits, room logic, and the main loop; it refills stream buffers and applies pending volume ramps.
+    - `release_music_stream_buffers` at `0x1a440` frees the stream's decode/ring buffers, unlocks their linear regions, and stops the active voice slot if that slot/token still belongs to the stream.
     - `pause_music_stream` at `0x1a620` stops the active voice without closing the underlying stream.
     - `resume_music_stream` at `0x1a740` restarts a previously paused music stream.
     - `fade_in_music_stream` at `0x1adf0` and `fade_out_music_stream` at `0x1b3b0` are the paired ramp controllers used by town-map and scripted transition paths.
@@ -645,7 +694,15 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `load_dialogue_voice_sample` at `0x191d0` is the `play_dialogue_line`-specific loader that accepts either `WAVE` or `FCMP` voice assets and prepares them for one-shot playback through the shared sample backend.
   - `stop_sound_state_and_close_stream_file` at `0x1c4e0` is the shared cleanup path that stops active backend playback for a sound state and closes its backing stream/file handle at offset `+0x88`.
   - `run_fst_sequence_player` at `0x12b00` is the inner FST decoder/player reached from `play_fst_sequence`.
-    - It reads the FST frame table, optional audio payload, performs the actual frame decode loop, and uses `get_sound_state_playback_position` to keep movie frames aligned with the loaded soundtrack.
+    - `FstFileHeader` is now a confirmed 0x20-byte header with fields `magic`, `width`, `height`, `max_frame_size`, `frame_count`, `frame_rate`, `sample_rate`, and `bits_per_sample`.
+    - `FstFrameIndexEntry` is the 6-byte per-frame index record `{ video_size, audio_size }`; `run_fst_sequence_player` reads `frame_count` of them immediately after the header and then reads `video_size + audio_size` bytes for each frame.
+    - It configures `g_video_surface_context` from the header width/height at 8bpp backend class `1`, seeds PCM playback from the header sample rate / bits per sample, computes `bytes_per_frame = get_pcm_byte_rate(...) / frame_rate`, and uses `get_sound_state_playback_position` to keep movie frames aligned with the loaded soundtrack.
+    - The per-frame payload begins with a 16-bit bit-count, followed by the packed bitstream and then the block payload bytes.
+      - The first bit controls whether a 256-color VGA palette chunk is present.
+      - The frame body then decodes 4x4 tiles either from raw 16-byte pixel blocks or from compact 4-byte two-color mask blocks before blitting through the active VESA backend.
+    - `g_fst_censorship_toggle_entries` at `0xc1014` is now typed as `FstCensorshipToggleEntry[25]`.
+      - Each `0x118`-byte entry contains a movie basename in `sequence_name[0x100]` followed by six `toggle_frame_indices`.
+      - `run_fst_sequence_player` scans those records by basename and, on matching frame indices, toggles between restoring the saved palette and blitting `GRAPHIC\\OTHER\\CENSORED.PCX`.
     - `upload_vga_palette` at `0x482c0` is the shared 256-entry VGA DAC upload helper used by the FST player when it restores or swaps palette pages.
     - `load_pcx_bitmap` at `0x4a230` is the direct-file `PCX` loader used by the FST censorship path; it reads the whole file through XFILE, RLE-decodes the `0x80`-byte-header payload into a `RawBitmap`, trims oversized stride bytes, and optionally uploads the trailing 256-color palette.
 - `run_harvester_main_loop` maintains `g_current_interaction_text`, `g_pending_interaction_text`, and `g_interaction_text_entity` for hover / use prompts.
diff --git a/TRACKER.md b/TRACKER.md
index b0ffed159d0..5c6a02620d0 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,19 +2,63 @@
 
 ## Last Confirmed Action
 
-- Extended the startup-room lift from the cursor-only runtime entity into the room object / `ANIM` spawn path.
-  - `room_setup` decompilation confirms the post-wait rebuild order: visible room objects whose `current_owner_or_room` matches the destination room are spawned before matching `ANIM` records whose `active` or `visible` state is set.
-  - `parse_anim_record` is now bounded enough to model conservatively: one unresolved leading numeric field, then `x`, `y`, `frame_delay`, `room_name`, `anim_path`, `anim_name`, and the script booleans `active`, `visible`, `loop`, `backward`, `ping_pong`, `remove`.
-  - ScummVM now parses `ANIM` records from `HARVEST.SCR`, lifts deduped visible room objects plus active/visible room anims into the runtime-entity subsystem, and ticks scene ABMs through the recovered frame-delay path instead of baking them into the background scene.
-  - The current lift is still intentionally short of the original render-list ordering, overlap checks, and collision/runtime trigger behavior; this pass only covers the spawn and per-frame visual tick slice.
+- Continued the `run_fst_sequence_player` pass far enough to formalize the on-disk movie layout and the special-case censorship side table.
+  - Created the exact FST types in Ghidra:
+    - `FstFileHeader` size `0x20`
+    - `FstFrameIndexEntry` size `0x6`
+    - `FstCensorshipToggleEntry` size `0x118`
+  - Applied `FstCensorshipToggleEntry[25]` at `0xc1014` and labeled it `g_fst_censorship_toggle_entries`.
+    - The record layout is now explicit: `sequence_name[0x100]` plus six 32-bit `toggle_frame_indices`.
+    - `run_fst_sequence_player` scans those 25 entries by FST basename and, on matching frame indices, toggles between restoring the saved palette and blitting `GRAPHIC\\OTHER\\CENSORED.PCX`.
+  - Confirmed the top-level FST playback layout from the binary call setup:
+    - a `0x20`-byte header
+    - `frame_count` frame-index records of `6` bytes each
+    - per-frame reads of `video_size + audio_size`
+    - audio pacing based on `get_pcm_byte_rate(...) / frame_rate`
+- Extended the runtime-entity pass into the shared movement/update helpers.
+  - Renamed `update_visible_entity_screen_position` at `0x4c860`, `update_render_entity_screen_position` at `0x5cc10`, `do_rects_overlap` at `0x5e0d0`, and `compute_rect_area` at `0x5e0c0`.
+  - `update_render_entity_screen_position` now has a bounded role: when depth is unchanged it only updates dirty rectangles for the old/new screen bounds, and when depth changes it removes the entity from the render list, rescales it, reinserts it by depth, and merges the resulting dirty region.
+  - A scripted scan of offset `+0x109c` confirmed that the collision/blocking-entity pointer written by `tick_entity_visual_state` is consumed by `update_actor_runtime_state`; no separate movement subsystem has shown up yet.
+- Closed the shared packed-callback adapter pass and reclassified the remaining `0xc0c70` records as mixed Borland object-lifecycle descriptors rather than as one more PCM-only callback table.
+  - Created and renamed `report_undefined_constructor_or_destructor_called` at `0x81448`.
+    - It emits the exact string `undefined constructor or destructor called!` through the Borland runtime fatal path, which is what identifies the shared packed adapter field as an undefined ctor/dtor fallback.
+  - Renamed the recovered lifecycle helpers bound by those mixed records:
+    - `set_vesa_video_mode_3` at `0x3c8a0`
+    - `reset_video_surface_context` at `0x3c8c0`
+    - `reset_town_script_runtime_state` at `0x48000`
+    - `install_keyboard_irq1_handler_state` at `0x48700`
+    - `remove_keyboard_irq1_handler_state` at `0x48660`
+  - Labeled the newly explicit state objects:
+    - `g_town_script_runtime_state` at `0xd3050`
+    - `g_keyboard_irq1_handler_state` at `0xd5900`
+  - The remaining unresolved first record at `0xc0c90` is still an extended music/callback-context variant, but the adapter pass itself is now exhausted cleanly.
+- Extended the PCM descriptor-blob pass with one new high-confidence stream helper and a bounded record shape.
+  - Created and renamed `release_music_stream_buffers` at `0x1a440`.
+    - The code frees the music stream's decode/ring buffers, unlocks their linear regions, and stops the active voice slot only when the slot/token still matches that stream.
+    - The current table xref at `0xc0c9c` is what ties that callback back to `g_music_stream_state` at `0xca1d4`.
+  - The smaller packed records at `0xc0ccc`, `0xc0cec`, and `0xc0d0c` now have a confirmed repeated shape:
+    - direct callback pointer
+    - state-object pointer
+    - secondary callback, shared adapter `0x81448`, repeated direct callback, and state size, all packed as 24-bit values shifted left by 8 inside 32-bit slots
+  - The first entry at `0xc0c90` is an extended music/callback-context variant of that layout, but its remaining packed size/order fields still do not converge cleanly enough for a formal struct.
+- Confirmed and implemented the next room-setup runtime-entity slice.
+  - `tick_entity_visual_state` now has a bounded collision path: Z-interval overlap first, then screen-bounds overlap, then opaque-pixel overlap.
+  - `spawn_object_entity_from_record` classifies `640x480` bitmap objects rooted at initial `(0, 0)` as class `3` room backgrounds, and classifies hotspot-only objects as `0x15` / `0x16`.
+  - `show_entity_visual` only applies the centered-anchor path when the internal `+0x4f` flag is clear, which separates the centered `ANIM` path from the top-left room-object spawn path.
+- Typed the callback/driver object rooted at `0xca194` conservatively enough to stop conflating it with `PcmSoundState`.
+  - `g_sound_driver_callback_context` at `0xca194` is now typed as a minimal `SoundDriverCallbackContext`.
+  - The proven fields are now explicit in the type and labels:
+    - `+0x14` is `selected_voice_slot_index`, also labeled as `g_selected_sound_voice_slot_index` at `0xca1a8`
+    - `+0x18` overlaps the existing `g_sound_voice_bank_index`
+    - `+0x20` overlaps the existing `g_sound_driver_initialized`
+  - The descriptor blob entry at `0xc0c98` points directly at this object, which confirms that the table-driven wrappers around `0x18380` / `0x183c0` / `0x183e0` / `0x18400` are operating on the current-bank driver context rather than on a `PcmSoundState`.
 - Current live-state counts:
-  - `HARVEST.LE` currently has `755` total functions
-  - `387` have custom/documented names
-  - `368` still remain unnamed / `FUN_*`
+  - `HARVEST.LE` currently has `764` total functions
+  - `404` have custom/documented names
+  - `360` still remain unnamed / `FUN_*`
 
 ## Next Suggested Action
 
 - Highest-value targets:
-  - recover the original render-list ordering helpers next, especially the insertion/sort behavior around `add_entity_to_render_list`, before trying to match room foreground/background overlap exactly
-  - then lift the low-risk subset of `spawn_object_entity_from_record` that chooses between sprite-backed objects and pure hotspot boxes, so active-but-invisible objects can participate in startup-room interactions without being rendered
-  - keep collision / overlap behavior tied to recovered helpers such as `do_entity_screen_bounds_overlap` and the cursor-hit tests, rather than inventing a scene graph policy in ScummVM first
+  - continue `run_fst_sequence_player` into the inner frame decoder, especially the 4x4 block expansion path and the banked-blit branch around the scanline-bank callback
+  - if the inner decode loop still resists clean factoring, shift back outward and recover the remaining `update_actor_runtime_state` consumers of the confirmed blocking-entity pointer at `+0x109c`


Commit: 9ee81377b7888f0c48291df6d166bde5a0884d64
    https://github.com/scummvm/scummvm/commit/9ee81377b7888f0c48291df6d166bde5a0884d64
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:38+02:00

Commit Message:
HARVESTER: Sync FST playback to audio timing

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/fst_player.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 76e0566a0ff..38acaf9377f 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -697,9 +697,18 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `FstFileHeader` is now a confirmed 0x20-byte header with fields `magic`, `width`, `height`, `max_frame_size`, `frame_count`, `frame_rate`, `sample_rate`, and `bits_per_sample`.
     - `FstFrameIndexEntry` is the 6-byte per-frame index record `{ video_size, audio_size }`; `run_fst_sequence_player` reads `frame_count` of them immediately after the header and then reads `video_size + audio_size` bytes for each frame.
     - It configures `g_video_surface_context` from the header width/height at 8bpp backend class `1`, seeds PCM playback from the header sample rate / bits per sample, computes `bytes_per_frame = get_pcm_byte_rate(...) / frame_rate`, and uses `get_sound_state_playback_position` to keep movie frames aligned with the loaded soundtrack.
+    - The frame loop queues each frame's PCM chunk before decode/blit and then waits until the cumulative playback cursor reaches the current frame's byte target; timer pacing is only the silent fallback.
+    - `initialize_vesa_banked_mode` precomputes the banked-surface row tables that the movie decoder relies on:
+      - `g_vesa_scanline_start_bank_indices` at `0xc7fca` gives the starting write-window bank for each possible scanline
+      - `g_vesa_scanline_window_offsets` at `0xc87cc` gives the in-window linear offset for that scanline's left edge
+      - `g_vesa_scanline_bank_split_x` at `0xc97cc` is `-1` when the full row fits in one bank, otherwise it is the first `x` coordinate that spills into the next bank
     - The per-frame payload begins with a 16-bit bit-count, followed by the packed bitstream and then the block payload bytes.
       - The first bit controls whether a 256-color VGA palette chunk is present.
+      - Each 4x4 tile then consumes one changed/unchanged bit, and changed tiles consume one raw-vs-two-color-mask bit before reading either 16 raw pixels or 4 mask bytes.
       - The frame body then decodes 4x4 tiles either from raw 16-byte pixel blocks or from compact 4-byte two-color mask blocks before blitting through the active VESA backend.
+      - On banked VESA surfaces, the decoder handles those same 4x4 tiles in 4-scanline bands:
+        - if all four rows in the current band have `split_x == -1`, it writes directly into the mapped VESA window
+        - otherwise it expands the 4-row band into a temporary scratch buffer and then flushes each row in one or two pieces across the bank boundary, advancing banks through `select_bank_for_scanline_callback`
     - `g_fst_censorship_toggle_entries` at `0xc1014` is now typed as `FstCensorshipToggleEntry[25]`.
       - Each `0x118`-byte entry contains a movie basename in `sequence_name[0x100]` followed by six `toggle_frame_indices`.
       - `run_fst_sequence_player` scans those records by basename and, on matching frame indices, toggles between restoring the saved palette and blitting `GRAPHIC\\OTHER\\CENSORED.PCX`.
diff --git a/TRACKER.md b/TRACKER.md
index 5c6a02620d0..c4114132579 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,6 +2,28 @@
 
 ## Last Confirmed Action
 
+- Continued `run_fst_sequence_player` into the banked VESA row-flush path.
+  - Renamed and typed the runtime banked-mode tables and state in Ghidra:
+    - `g_vesa_scanline_start_bank_indices` as `ushort[1024]` at `0xc7fca`
+    - `g_vesa_scanline_window_offsets` as `int[1024]` at `0xc87cc`
+    - `g_vesa_scanline_bank_split_x` as `short[1024]` at `0xc97cc`
+    - `g_vesa_window_granularity_kb`, `g_vesa_window_size_kb`, `g_vesa_bytes_per_scanline`, `g_vesa_read_window_index`, `g_vesa_write_window_index`, `g_vesa_read_window_segment_base`, `g_vesa_write_window_segment_base`, `g_vesa_current_write_bank_index`, `g_vesa_mode_width`, `g_vesa_mode_height`, and `g_vesa_window_size_bytes`
+  - Confirmed `initialize_vesa_banked_mode` precomputes, for each possible scanline, the starting bank index, the in-window linear offset for the left edge, and a signed split-`x` sentinel that stays `-1` when the whole row fits inside one bank.
+  - Confirmed the remaining banked branch inside `run_fst_sequence_player` is a 4-scanline-band fallback, not a different tile codec:
+    - if all four rows in the current band have `split_x == -1`, tiles expand directly into the mapped VESA window
+    - otherwise the same 4x4 tiles expand into a temporary 4-row scratch band and then flush row-by-row across the bank boundary using the precomputed scanline tables plus `select_bank_for_scanline_callback`
+  - Renamed the key FST locals in Ghidra so the decoder state now reads as `frame_index_table`, `frame_payload_buffer`, `frame_bitstream_cursor`, `frame_block_payload_cursor`, `frameband_dst`, and `banked_frameband_scratch`.
+  - Saved `HARVEST.LE`.
+- Continued `run_fst_sequence_player` into the per-frame pacing and tile-decode path.
+  - The inner movie loop now converges cleanly enough to rule out a block-format mismatch in the current engine:
+    - one frame-level palette-present bit
+    - then, per 4x4 tile, one changed/unchanged bit and one raw-vs-mask bit for changed tiles
+    - changed tiles expand from either 16 raw pixels or 4 bytes `{ color0, color1, mask16 }`
+  - Confirmed the original movie pacing is audio-driven when PCM is present.
+    - `run_fst_sequence_player` computes `bytes_per_frame = get_pcm_byte_rate(...) / frame_rate`
+    - it queues each frame's audio chunk before decode/blit
+    - it then waits on `get_sound_state_playback_position` against the cumulative byte target before presenting the next frame
+  - ScummVM now follows that recovered pacing rule in `engines/harvester/fst_player.cpp`, using mixer elapsed time for PCM-backed FST playback and keeping the timer path only for silent movies.
 - Continued the `run_fst_sequence_player` pass far enough to formalize the on-disk movie layout and the special-case censorship side table.
   - Created the exact FST types in Ghidra:
     - `FstFileHeader` size `0x20`
@@ -60,5 +82,5 @@
 ## Next Suggested Action
 
 - Highest-value targets:
-  - continue `run_fst_sequence_player` into the inner frame decoder, especially the 4x4 block expansion path and the banked-blit branch around the scanline-bank callback
-  - if the inner decode loop still resists clean factoring, shift back outward and recover the remaining `update_actor_runtime_state` consumers of the confirmed blocking-entity pointer at `+0x109c`
+  - if the FST/video thread still needs more precision, recover the remaining banked-mode callback slot around `0x14f40` and the still-unnamed VESA backend function-pointer fields that sit beside `select_bank_for_scanline_callback`
+  - otherwise shift back outward and recover the remaining `update_actor_runtime_state` consumers of the confirmed blocking-entity pointer at `+0x109c`, especially the read-side meaning of the four remembered blocker slots
diff --git a/engines/harvester/fst_player.cpp b/engines/harvester/fst_player.cpp
index db84f578cfe..06a0ef199cc 100644
--- a/engines/harvester/fst_player.cpp
+++ b/engines/harvester/fst_player.cpp
@@ -105,6 +105,26 @@ static bool pumpVideoEvents(HarvesterEngine &vm, bool &skipRequested) {
 	return !vm.shouldQuit();
 }
 
+static bool waitForAudioSync(HarvesterEngine &vm, Audio::Mixer &mixer, Audio::SoundHandle audioHandle,
+		uint64 targetPlaybackBytes, uint32 audioByteRate, bool &skipRequested) {
+	if (audioByteRate == 0)
+		return true;
+
+	const uint32 targetPlaybackMs = (uint32)((targetPlaybackBytes * 1000ULL) / audioByteRate);
+	while (!skipRequested) {
+		if (!pumpVideoEvents(vm, skipRequested))
+			return false;
+		if (skipRequested || !mixer.isSoundHandleActive(audioHandle))
+			return true;
+		if (mixer.getSoundElapsedTime(audioHandle) >= targetPlaybackMs)
+			return true;
+
+		g_system->delayMillis(1);
+	}
+
+	return true;
+}
+
 static void decodeMaskBlock(byte *dest, int pitch, const byte *source) {
 	const byte color0 = source[0];
 	const byte color1 = source[1];
@@ -228,6 +248,10 @@ bool FstPlayer::play(const Common::String &path) {
 	bool audioStarted = false;
 	bool skipRequested = false;
 	uint32 nextFrameTime = g_system->getMillis();
+	const uint32 audioByteRate = hasPcmAudio ? header.sampleRate * 2 : 0;
+	const uint32 audioBytesPerFrame = (audioByteRate != 0 && header.frameRate != 0) ? audioByteRate / header.frameRate : 0;
+	uint64 nextAudioSyncBytes = 0;
+	Audio::Mixer *mixer = g_system->getMixer();
 
 	for (uint32 frameIndex = 0; frameIndex < header.frameCount; ++frameIndex) {
 		const uint32 totalFrameSize = frames[frameIndex].videoSize + frames[frameIndex].audioSize;
@@ -248,12 +272,15 @@ bool FstPlayer::play(const Common::String &path) {
 				Audio::FLAG_16BITS | Audio::FLAG_LITTLE_ENDIAN);
 
 			if (!audioStarted) {
-				g_system->getMixer()->playStream(Audio::Mixer::kSFXSoundType, &audioHandle, audioStream, -1,
+				mixer->playStream(Audio::Mixer::kSFXSoundType, &audioHandle, audioStream, -1,
 					Audio::Mixer::kMaxChannelVolume, 0, DisposeAfterUse::NO);
 				audioStarted = true;
 			}
 		}
 
+		if (audioStarted && !waitForAudioSync(_vm, *mixer, audioHandle, nextAudioSyncBytes, audioByteRate, skipRequested))
+			break;
+
 		if (!decodeFrame(header, frameData, pixels, palette)) {
 			warning("Harvester: unable to decode '%s' frame %u", path.c_str(), frameIndex);
 			break;
@@ -263,11 +290,15 @@ bool FstPlayer::play(const Common::String &path) {
 		g_system->copyRectToScreen(pixels.data(), header.width, 0, 0, header.width, header.height);
 		g_system->updateScreen();
 
-		nextFrameTime += 1000 / header.frameRate;
-		while (!skipRequested && g_system->getMillis() < nextFrameTime) {
-			if (!pumpVideoEvents(_vm, skipRequested))
-				break;
-			g_system->delayMillis(1);
+		if (audioStarted && audioBytesPerFrame != 0) {
+			nextAudioSyncBytes += audioBytesPerFrame;
+		} else {
+			nextFrameTime += 1000 / header.frameRate;
+			while (!skipRequested && g_system->getMillis() < nextFrameTime) {
+				if (!pumpVideoEvents(_vm, skipRequested))
+					break;
+				g_system->delayMillis(1);
+			}
 		}
 
 		if (!pumpVideoEvents(_vm, skipRequested) || skipRequested)
@@ -275,7 +306,7 @@ bool FstPlayer::play(const Common::String &path) {
 	}
 
 	if (audioStarted)
-		g_system->getMixer()->stopHandle(audioHandle);
+		mixer->stopHandle(audioHandle);
 	delete audioStream;
 
 	return !_vm.shouldQuit();


Commit: cb08179d931ab0f225d922cb6ce9bf797c058560
    https://github.com/scummvm/scummvm/commit/cb08179d931ab0f225d922cb6ce9bf797c058560
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:38+02:00

Commit Message:
HARVESTER: Record video backend callback findings

Changed paths:
    ARCHITECTURE.md
    TRACKER.md


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 38acaf9377f..1916cc23e2c 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -142,18 +142,22 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `printf_ascii` prints the copyright line through `g_stdout_stream` after the `puts_ascii` version banner.
     - `set_random_seed` seeds `g_random_seed` from the first startup tick, and `next_random_value` is the 15-bit LCG step used by script/random branches.
     - `configure_video_surface` swaps `g_video_surface_context` between the normal gameplay surface and the narrower FST playback surface before the movie path hands off to `run_fst_sequence_player`.
-  - The VESA backend records behind that selector are now bounded as four 8bpp banked-mode descriptors:
+  - The video backend records behind that selector are now bounded as one linear VGA descriptor plus four 8bpp banked VESA descriptors:
+    - `g_vga_mode_320x200_8bpp`
     - `g_vesa_mode_640x400_8bpp`
     - `g_vesa_mode_640x480_8bpp`
     - `g_vesa_mode_800x600_8bpp`
     - `g_vesa_mode_1024x768_8bpp`
-    - `g_vesa_video_mode_backends` is the pointer table `configure_video_surface` scans to match width / height / pixel format.
+    - `g_video_mode_backends` is the 6-entry pointer table `configure_video_surface` scans to match width / height / pixel format; it contains those five concrete backends plus the sentinel that terminates the probe loop.
     - `configure_video_surface` matches each descriptor on width / height / bpp / backend class, calls the descriptor's probe and activate callbacks, and tears down the previously active backend through the record's shutdown callback before switching.
-    - `VesaModeBackend` is now a typed 64-byte descriptor record with confirmed width/height/pixel-format fields, probe/activate/shutdown/display-start callbacks, pixel read/write callbacks, the shared blit callback, and the runtime bank/window fields consumed by FST playback.
+    - `VesaModeBackend` is now a typed 64-byte video-mode descriptor record despite the legacy name.
+      - Confirmed fields now include width/height/pixel-format, probe/activate/shutdown callbacks, the display-start preset callback, pixel read/write callbacks, `fill_horizontal_span_callback`, `fill_rect_callback`, `blit_rect_callback`, and the runtime bank/window fields consumed by FST playback.
+      - The VGA `320x200x8` record makes the generic callback roles explicit through `probe_vga_320x200_mode`, `activate_vga_320x200_mode`, `write_vga_320x200_pixel`, `read_vga_320x200_pixel`, `fill_vga_320x200_horizontal_span`, `fill_vga_320x200_rect`, and `blit_vga_320x200_rect`.
     - `VideoSurfaceContext` is now a typed 16-byte context that caches the active backend pointer plus the selected width/height/bpp/class.
     - The mixed Borland runtime descriptor block also binds `set_vesa_video_mode_3` and `reset_video_surface_context` to `g_video_surface_context`; that pair is the small lifecycle wrapper layer above the shared context object.
   - The shared VESA helper layer is now partially named:
     - `query_vesa_controller_info` validates the controller signature through BIOS `int 10h AX=4f00`.
+    - `set_vesa_write_window_bank` is the write-window-fixed wrapper above `set_vesa_window_bank`.
     - `set_vesa_video_mode` checks the current VESA mode with `AX=4f03` and only issues `AX=4f02` when a switch is required.
     - `set_vesa_window_bank` is the shared `AX=4f05` wrapper used by the banked blitters and single-pixel helpers.
     - `set_vesa_display_start` is the shared `AX=4f07` wrapper used by the per-mode display-start preset callbacks.
diff --git a/TRACKER.md b/TRACKER.md
index c4114132579..ef8e142cde8 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,6 +2,28 @@
 
 ## Last Confirmed Action
 
+- Continued the remaining video-backend callback pass around `configure_video_surface`.
+  - Recovered the hidden shared wrapper at `0x14f40` as `set_vesa_write_window_bank`; it is the write-window-fixed wrapper above `set_vesa_window_bank`.
+  - Corrected the backend table itself from VESA-only to generic video-mode state:
+    - `g_video_mode_backends` at `0xc3bcc` is now typed as a 6-entry pointer table
+    - the table contains five concrete backends plus the sentinel used by `configure_video_surface`
+    - the extra non-VESA record at `0xc0fd4` is now labeled `g_vga_mode_320x200_8bpp`
+  - Created and named the previously undefined VGA backend entrypoints from that `320x200x8` record:
+    - `probe_vga_320x200_mode`
+    - `activate_vga_320x200_mode`
+    - `noop_vga_mode_shutdown`
+    - `write_vga_320x200_pixel`
+    - `read_vga_320x200_pixel`
+    - `fill_vga_320x200_horizontal_span`
+    - `fill_vga_320x200_rect`
+    - `blit_vga_320x200_rect`
+  - Renamed the confirmed `VesaModeBackend` fill slots from generic offsets to `fill_horizontal_span_callback` and `fill_rect_callback`.
+  - The per-mode VESA slot at offset `+0x18` is still unresolved semantically, but all four VESA targets are now explicitly bounded as no-op stubs:
+    - `noop_vesa_640x400_callback_18`
+    - `noop_vesa_640x480_callback_18`
+    - `noop_vesa_800x600_callback_18`
+    - `noop_vesa_1024x768_callback_18`
+  - Saved `HARVEST.LE`.
 - Continued `run_fst_sequence_player` into the banked VESA row-flush path.
   - Renamed and typed the runtime banked-mode tables and state in Ghidra:
     - `g_vesa_scanline_start_bank_indices` as `ushort[1024]` at `0xc7fca`
@@ -75,12 +97,12 @@
     - `+0x20` overlaps the existing `g_sound_driver_initialized`
   - The descriptor blob entry at `0xc0c98` points directly at this object, which confirms that the table-driven wrappers around `0x18380` / `0x183c0` / `0x183e0` / `0x18400` are operating on the current-bank driver context rather than on a `PcmSoundState`.
 - Current live-state counts:
-  - `HARVEST.LE` currently has `764` total functions
-  - `404` have custom/documented names
-  - `360` still remain unnamed / `FUN_*`
+  - `HARVEST.LE` currently has `774` total functions
+  - `417` have custom/documented names
+  - `357` still remain unnamed / `FUN_*`
 
 ## Next Suggested Action
 
 - Highest-value targets:
-  - if the FST/video thread still needs more precision, recover the remaining banked-mode callback slot around `0x14f40` and the still-unnamed VESA backend function-pointer fields that sit beside `select_bank_for_scanline_callback`
+  - if the video thread still needs more precision, resolve the last ambiguous backend slot at `VesaModeBackend.callback_18`, especially the shared VGA/VESA no-op at `0x11030` and any direct callers that still bypass typed `VideoSurfaceContext` uses
   - otherwise shift back outward and recover the remaining `update_actor_runtime_state` consumers of the confirmed blocking-entity pointer at `+0x109c`, especially the read-side meaning of the four remembered blocker slots


Commit: 1260dd84f299cba89a502dd61749782860005a57
    https://github.com/scummvm/scummvm/commit/1260dd84f299cba89a502dd61749782860005a57
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:38+02:00

Commit Message:
HARVESTER: Recover FST censorship path

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/detection.h
    engines/harvester/detection_tables.h
    engines/harvester/fst_player.cpp
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/metaengine.cpp
    engines/harvester/metaengine.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 1916cc23e2c..898c9747fac 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -151,7 +151,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `g_video_mode_backends` is the 6-entry pointer table `configure_video_surface` scans to match width / height / pixel format; it contains those five concrete backends plus the sentinel that terminates the probe loop.
     - `configure_video_surface` matches each descriptor on width / height / bpp / backend class, calls the descriptor's probe and activate callbacks, and tears down the previously active backend through the record's shutdown callback before switching.
     - `VesaModeBackend` is now a typed 64-byte video-mode descriptor record despite the legacy name.
-      - Confirmed fields now include width/height/pixel-format, probe/activate/shutdown callbacks, the display-start preset callback, pixel read/write callbacks, `fill_horizontal_span_callback`, `fill_rect_callback`, `blit_rect_callback`, and the runtime bank/window fields consumed by FST playback.
+      - Confirmed fields now include width/height/pixel-format, probe/activate/shutdown callbacks, `unused_callback`, the display-start preset callback, pixel read/write callbacks, `fill_horizontal_span_callback`, `fill_rect_callback`, `blit_rect_callback`, and the runtime bank/window fields consumed by FST playback.
+      - `unused_callback` is now explicitly bounded as dead slot baggage in this binary: the concrete VGA/VESA implementations are all no-op stubs, and an instruction-level scan only found indirect backend calls through `blit_rect_callback` and `select_bank_for_scanline_callback`.
       - The VGA `320x200x8` record makes the generic callback roles explicit through `probe_vga_320x200_mode`, `activate_vga_320x200_mode`, `write_vga_320x200_pixel`, `read_vga_320x200_pixel`, `fill_vga_320x200_horizontal_span`, `fill_vga_320x200_rect`, and `blit_vga_320x200_rect`.
     - `VideoSurfaceContext` is now a typed 16-byte context that caches the active backend pointer plus the selected width/height/bpp/class.
     - The mixed Borland runtime descriptor block also binds `set_vesa_video_mode_3` and `reset_video_surface_context` to `g_video_surface_context`; that pair is the small lifecycle wrapper layer above the shared context object.
@@ -716,6 +717,10 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `g_fst_censorship_toggle_entries` at `0xc1014` is now typed as `FstCensorshipToggleEntry[25]`.
       - Each `0x118`-byte entry contains a movie basename in `sequence_name[0x100]` followed by six `toggle_frame_indices`.
       - `run_fst_sequence_player` scans those records by basename and, on matching frame indices, toggles between restoring the saved palette and blitting `GRAPHIC\\OTHER\\CENSORED.PCX`.
+    - `g_gore_enabled` at `0xc0fbc` is the persisted `GORE` setting gate for that censorship path.
+      - `startup_main` defaults it to `1` and only clears it when the config value `GORE` equals `NO`.
+      - `run_fst_sequence_player` only consults `g_fst_censorship_toggle_entries` when `g_gore_enabled == 0`.
+      - While censorship is active, the player keeps advancing audio timing and still captures later movie palette chunks into the saved movie palette buffer, but it skips the normal frame decode/blit until a later toggle restores the movie view.
     - `upload_vga_palette` at `0x482c0` is the shared 256-entry VGA DAC upload helper used by the FST player when it restores or swaps palette pages.
     - `load_pcx_bitmap` at `0x4a230` is the direct-file `PCX` loader used by the FST censorship path; it reads the whole file through XFILE, RLE-decodes the `0x80`-byte-header payload into a `RawBitmap`, trims oversized stride bytes, and optionally uploads the trailing 256-color palette.
 - `run_harvester_main_loop` maintains `g_current_interaction_text`, `g_pending_interaction_text`, and `g_interaction_text_entity` for hover / use prompts.
diff --git a/TRACKER.md b/TRACKER.md
index ef8e142cde8..b751895ded8 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,6 +2,20 @@
 
 ## Last Confirmed Action
 
+- Closed the last ambiguous backend slot in `VesaModeBackend`.
+  - Renamed `callback_18` to `unused_callback` after an instruction-level scan showed the only indirect backend calls in the current binary are `blit_rect_callback` and `select_bank_for_scanline_callback` from `run_fst_sequence_player`; no callers use slot `+0x18`.
+  - Renamed the shared VGA/VESA no-op implementations to match that evidence:
+    - `noop_vga_unused_callback`
+    - `noop_vesa_640x400_unused_callback`
+    - `noop_vesa_640x480_unused_callback`
+    - `noop_vesa_800x600_unused_callback`
+    - `noop_vesa_1024x768_unused_callback`
+  - Saved `HARVEST.LE`.
+- Closed the remaining `run_fst_sequence_player` censorship gate from the binary through the ScummVM startup player.
+  - Renamed `g_gore_enabled` at `0xc0fbc` in Ghidra and saved `HARVEST.LE`.
+  - Confirmed `startup_main` defaults that persisted flag to `1` and clears it only when config key `GORE` equals `NO`.
+  - Confirmed `run_fst_sequence_player` only consults `g_fst_censorship_toggle_entries` when `g_gore_enabled == 0`, keeps advancing audio timing while censored, and keeps capturing later movie palettes so the next uncensored frame can restore the saved movie palette before decode when needed.
+  - ScummVM now exposes that path as a Harvester launcher option and mirrors the original behavior in `engines/harvester/fst_player.cpp` by toggling `GRAPHIC/OTHER/CENSORED.PCX` against the recovered 25-entry frame table.
 - Continued the remaining video-backend callback pass around `configure_video_surface`.
   - Recovered the hidden shared wrapper at `0x14f40` as `set_vesa_write_window_bank`; it is the write-window-fixed wrapper above `set_vesa_window_bank`.
   - Corrected the backend table itself from VESA-only to generic video-mode state:
@@ -98,11 +112,11 @@
   - The descriptor blob entry at `0xc0c98` points directly at this object, which confirms that the table-driven wrappers around `0x18380` / `0x183c0` / `0x183e0` / `0x18400` are operating on the current-bank driver context rather than on a `PcmSoundState`.
 - Current live-state counts:
   - `HARVEST.LE` currently has `774` total functions
-  - `417` have custom/documented names
-  - `357` still remain unnamed / `FUN_*`
+  - `418` have custom/documented names
+  - `356` still remain unnamed / `FUN_*`
 
 ## Next Suggested Action
 
 - Highest-value targets:
-  - if the video thread still needs more precision, resolve the last ambiguous backend slot at `VesaModeBackend.callback_18`, especially the shared VGA/VESA no-op at `0x11030` and any direct callers that still bypass typed `VideoSurfaceContext` uses
-  - otherwise shift back outward and recover the remaining `update_actor_runtime_state` consumers of the confirmed blocking-entity pointer at `+0x109c`, especially the read-side meaning of the four remembered blocker slots
+  - shift back outward and recover the remaining `update_actor_runtime_state` consumers of the confirmed blocking-entity pointer at `+0x109c`, especially the read-side meaning of the four remembered blocker slots
+  - if that thread stalls, revisit the still-unused `display_start_preset_callback` slot family to confirm whether it is fully dead in this binary or only dormant
diff --git a/engines/harvester/detection.h b/engines/harvester/detection.h
index 350d3dc19ae..43cba9bbab7 100644
--- a/engines/harvester/detection.h
+++ b/engines/harvester/detection.h
@@ -24,6 +24,8 @@
 
 #include "engines/advancedDetector.h"
 
+#define GAMEOPTION_GORE GUIO_GAMEOPTIONS1
+
 namespace Harvester {
 
 enum HarvesterDebugChannels {
diff --git a/engines/harvester/detection_tables.h b/engines/harvester/detection_tables.h
index 9f355ffa96b..986a8e6f33c 100644
--- a/engines/harvester/detection_tables.h
+++ b/engines/harvester/detection_tables.h
@@ -34,7 +34,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::EN_ANY,
 		Common::kPlatformDOS,
 		ADGF_UNSTABLE,
-		GUIO1(GUIO_NONE)
+		GUIO1(GAMEOPTION_GORE)
 	},
 
 	AD_TABLE_END_MARKER
diff --git a/engines/harvester/fst_player.cpp b/engines/harvester/fst_player.cpp
index 06a0ef199cc..86f4b910f6e 100644
--- a/engines/harvester/fst_player.cpp
+++ b/engines/harvester/fst_player.cpp
@@ -32,8 +32,10 @@
 #include "common/system.h"
 #include "common/textconsole.h"
 #include "graphics/paletteman.h"
+#include "graphics/surface.h"
 #include "harvester/harvester.h"
 #include "harvester/resources.h"
+#include "image/pcx.h"
 
 namespace Harvester {
 
@@ -55,6 +57,18 @@ struct FstFrameEntry {
 	uint16 audioSize = 0;
 };
 
+struct FstCensorshipToggleEntry {
+	const char *sequenceName;
+	int32 toggleFrameIndices[6];
+};
+
+struct FstOverlayFrame {
+	uint16 width = 0;
+	uint16 height = 0;
+	Common::Array<byte> pixels;
+	Common::Array<byte> palette;
+};
+
 class FstBitReader {
 public:
 	FstBitReader(const byte *data, uint32 bitCount) : _data(data), _bitCount(bitCount) {}
@@ -75,6 +89,33 @@ private:
 };
 
 static const uint32 kFstMagic = MKTAG('F', 'S', 'T', '2');
+static const FstCensorshipToggleEntry kFstCensorshipToggleEntries[] = {
+	{ "C001B.FST", { 170, 369, -1, -1, -1, -1 } },
+	{ "C004.FST", { 23, 174, 860, 995, 1834, -1 } },
+	{ "C005.FST", { 475, 550, -1, -1, -1, -1 } },
+	{ "C006.FST", { 699, 851, -1, -1, -1, -1 } },
+	{ "C028.FST", { 0, -1, -1, -1, -1, -1 } },
+	{ "C057.FST", { 0, -1, -1, -1, -1, -1 } },
+	{ "C086.FST", { 48, 65, -1, -1, -1, -1 } },
+	{ "C109.FST", { 44, -1, -1, -1, -1, -1 } },
+	{ "C125.FST", { 7, -1, -1, -1, -1, -1 } },
+	{ "C136.FST", { 128, -1, -1, -1, -1, -1 } },
+	{ "CLOKBLST.FST", { 0, -1, -1, -1, -1, -1 } },
+	{ "HEADTRAP.FST", { 0, -1, -1, -1, -1, -1 } },
+	{ "PCPLANT.FST", { 20, -1, -1, -1, -1, -1 } },
+	{ "C072.FST", { 0, -1, -1, -1, -1, -1 } },
+	{ "C073.FST", { 30, -1, -1, -1, -1, -1 } },
+	{ "C145A.FST", { 9, -1, -1, -1, -1, -1 } },
+	{ "C145B.FST", { 9, -1, -1, -1, -1, -1 } },
+	{ "C119.FST", { 0, -1, -1, -1, -1, -1 } },
+	{ "C119A.FST", { 0, -1, -1, -1, -1, -1 } },
+	{ "C119B.FST", { 0, -1, -1, -1, -1, -1 } },
+	{ "C119C.FST", { 0, -1, -1, -1, -1, -1 } },
+	{ "C117.FST", { 0, -1, -1, -1, -1, -1 } },
+	{ "C065.FST", { 0, -1, -1, -1, -1, -1 } },
+	{ "C010.FST", { 47, 109, 169, -1, -1, -1 } },
+	{ "RANGSHOT.FST", { 0, -1, -1, -1, -1, -1 } }
+};
 
 static byte expand6BitColor(byte value) {
 	return (value * 255 + 31) / 63;
@@ -85,6 +126,78 @@ static void convertVgaPalette(const byte *source, byte *dest) {
 		dest[i] = expand6BitColor(source[i]);
 }
 
+static const FstCensorshipToggleEntry *findCensorshipToggleEntry(const Common::String &path) {
+	const Common::String basename = Common::lastPathComponent(normalizeHarvesterResourcePath(path), '/');
+
+	for (const FstCensorshipToggleEntry &entry : kFstCensorshipToggleEntries) {
+		if (basename.equalsIgnoreCase(entry.sequenceName))
+			return &entry;
+	}
+
+	return nullptr;
+}
+
+static bool loadIndexedPcx(ResourceManager &resources, const Common::String &path, FstOverlayFrame &overlay) {
+	Common::ScopedPtr<Common::SeekableReadStream> stream(resources.openFile(path));
+	if (!stream)
+		return false;
+
+	Image::PCXDecoder decoder;
+	if (!decoder.loadStream(*stream))
+		return false;
+
+	const Graphics::Surface *surface = decoder.getSurface();
+	if (!surface || surface->format.bytesPerPixel != 1)
+		return false;
+
+	const Graphics::Palette &palette = decoder.getPalette();
+	if (palette.size() != 256)
+		return false;
+
+	overlay.width = surface->w;
+	overlay.height = surface->h;
+	overlay.pixels.resize((uint32)overlay.width * overlay.height);
+	for (uint16 y = 0; y < overlay.height; ++y)
+		memcpy(overlay.pixels.data() + y * overlay.width, (const byte *)surface->getBasePtr(0, y), overlay.width);
+
+	overlay.palette.resize(256 * 3);
+	memcpy(overlay.palette.data(), palette.data(), overlay.palette.size());
+	return true;
+}
+
+static void blitIndexedFrame(const FstOverlayFrame &overlay) {
+	if (overlay.palette.size() != 256 * 3 || overlay.pixels.empty())
+		return;
+
+	g_system->getPaletteManager()->setPalette(overlay.palette.data(), 0, 256);
+	g_system->copyRectToScreen(overlay.pixels.data(), overlay.width, 0, 0, overlay.width, overlay.height);
+	g_system->updateScreen();
+}
+
+static bool extractFramePalette(const byte *frameData, uint32 frameSize, Common::Array<byte> &palette, bool &hasPalette) {
+	hasPalette = false;
+	if (!frameData || frameSize < 2)
+		return false;
+
+	const uint16 bitCount = READ_LE_UINT16(frameData);
+	const uint32 bitstreamSize = (bitCount >> 3) + 1;
+	if (frameSize < 2 + bitstreamSize)
+		return false;
+
+	FstBitReader bitReader(frameData + 2, bitCount);
+	if (!bitReader.readBit())
+		return true;
+
+	const uint32 paletteOffset = 2 + bitstreamSize;
+	if (frameSize < paletteOffset + 256 * 3)
+		return false;
+
+	palette.resize(256 * 3);
+	convertVgaPalette(frameData + paletteOffset, palette.data());
+	hasPalette = true;
+	return true;
+}
+
 static bool pumpVideoEvents(HarvesterEngine &vm, bool &skipRequested) {
 	Common::Event event;
 	while (g_system->getEventManager()->pollEvent(event)) {
@@ -239,6 +352,18 @@ bool FstPlayer::play(const Common::String &path) {
 	byte palette[256 * 3] = { 0 };
 	g_system->getPaletteManager()->setPalette(palette, 0, 256);
 
+	const bool censorshipEnabled = !_vm.isGoreEnabled();
+	const FstCensorshipToggleEntry *censorshipEntry = censorshipEnabled ? findCensorshipToggleEntry(path) : nullptr;
+	FstOverlayFrame censorshipOverlay;
+	if (censorshipEntry && !loadIndexedPcx(*_vm.getResources(), "GRAPHIC/OTHER/CENSORED.PCX", censorshipOverlay)) {
+		warning("Harvester: unable to load censorship overlay for '%s'", path.c_str());
+		censorshipEntry = nullptr;
+	}
+	Common::Array<byte> savedMoviePalette;
+	bool haveSavedMoviePalette = false;
+	bool censorshipActive = false;
+	uint nextCensorshipToggleIndex = 0;
+
 	Audio::SoundHandle audioHandle;
 	Audio::QueuingAudioStream *audioStream = nullptr;
 	const bool hasPcmAudio = header.sampleRate != 0 && header.bitsPerSample == 16;
@@ -281,6 +406,54 @@ bool FstPlayer::play(const Common::String &path) {
 		if (audioStarted && !waitForAudioSync(_vm, *mixer, audioHandle, nextAudioSyncBytes, audioByteRate, skipRequested))
 			break;
 
+		const byte *videoData = frameData.data();
+		const uint32 videoSize = frames[frameIndex].videoSize;
+		Common::Array<byte> framePalette;
+		bool frameHasPalette = false;
+		if (censorshipEntry && !extractFramePalette(videoData, videoSize, framePalette, frameHasPalette)) {
+			warning("Harvester: unable to extract palette from '%s' frame %u", path.c_str(), frameIndex);
+			break;
+		}
+
+		if (frameHasPalette) {
+			savedMoviePalette = framePalette;
+			haveSavedMoviePalette = true;
+		}
+
+		bool restoreSavedPalette = false;
+		while (censorshipEntry && nextCensorshipToggleIndex < ARRAYSIZE(censorshipEntry->toggleFrameIndices) &&
+				censorshipEntry->toggleFrameIndices[nextCensorshipToggleIndex] >= 0 &&
+				(uint32)censorshipEntry->toggleFrameIndices[nextCensorshipToggleIndex] == frameIndex) {
+			censorshipActive = !censorshipActive;
+			if (censorshipActive) {
+				blitIndexedFrame(censorshipOverlay);
+			} else if (!frameHasPalette) {
+				restoreSavedPalette = true;
+			}
+
+			++nextCensorshipToggleIndex;
+		}
+
+		if (restoreSavedPalette && haveSavedMoviePalette)
+			memcpy(palette, savedMoviePalette.data(), sizeof(palette));
+
+		if (censorshipActive) {
+			if (audioStarted && audioBytesPerFrame != 0) {
+				nextAudioSyncBytes += audioBytesPerFrame;
+			} else {
+				nextFrameTime += 1000 / header.frameRate;
+				while (!skipRequested && g_system->getMillis() < nextFrameTime) {
+					if (!pumpVideoEvents(_vm, skipRequested))
+						break;
+					g_system->delayMillis(1);
+				}
+			}
+
+			if (!pumpVideoEvents(_vm, skipRequested) || skipRequested)
+				break;
+			continue;
+		}
+
 		if (!decodeFrame(header, frameData, pixels, palette)) {
 			warning("Harvester: unable to decode '%s' frame %u", path.c_str(), frameIndex);
 			break;
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index f40197113f4..208aef92e51 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -61,6 +61,10 @@ Common::String HarvesterEngine::getGameId() const {
 	return _gameDescription->gameId;
 }
 
+bool HarvesterEngine::isGoreEnabled() const {
+	return !ConfMan.hasKey("gore") || ConfMan.getBool("gore");
+}
+
 bool HarvesterEngine::playStartupSound(const Common::String &path) {
 	stopStartupSound();
 	if (path.empty() || !_resources)
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 66ee8484930..360aa5803ea 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -76,6 +76,7 @@ public:
 	StartupScript *getStartupScript() const { return _startupScript; }
 	StartupArt *getStartupArt() const { return _startupArt; }
 	StartupText *getStartupText() const { return _startupText; }
+	bool isGoreEnabled() const;
 	bool playStartupSound(const Common::String &path);
 	void stopStartupSound();
 
diff --git a/engines/harvester/metaengine.cpp b/engines/harvester/metaengine.cpp
index 92ff7aaf637..00f7db6fa18 100644
--- a/engines/harvester/metaengine.cpp
+++ b/engines/harvester/metaengine.cpp
@@ -21,13 +21,35 @@
 
 #include "base/plugins.h"
 
+#include "common/translation.h"
+#include "harvester/detection.h"
 #include "harvester/metaengine.h"
 #include "harvester/harvester.h"
 
+static const ADExtraGuiOptionsMap gameGuiOptions[] = {
+	{
+		GAMEOPTION_GORE,
+		{
+			_s("Enable gore"),
+			_s("When disabled, the startup movie player follows the original GORE=NO censorship path."),
+			"gore",
+			true,
+			0,
+			0
+		}
+	},
+
+	AD_EXTRA_GUI_OPTIONS_TERMINATOR
+};
+
 const char *HarvesterMetaEngine::getName() const {
 	return "harvester";
 }
 
+const ADExtraGuiOptionsMap *HarvesterMetaEngine::getAdvancedExtraGuiOptions() const {
+	return gameGuiOptions;
+}
+
 Common::Error HarvesterMetaEngine::createInstance(OSystem *syst, Engine **engine, const ADGameDescription *desc) const {
 	*engine = new Harvester::HarvesterEngine(syst, desc);
 	return Common::kNoError;
diff --git a/engines/harvester/metaengine.h b/engines/harvester/metaengine.h
index 2c7d398801e..0a9a4fbfbe3 100644
--- a/engines/harvester/metaengine.h
+++ b/engines/harvester/metaengine.h
@@ -27,6 +27,7 @@
 class HarvesterMetaEngine : public AdvancedMetaEngine<ADGameDescription> {
 public:
 	const char *getName() const override;
+	const ADExtraGuiOptionsMap *getAdvancedExtraGuiOptions() const override;
 
 	Common::Error createInstance(OSystem *syst, Engine **engine, const ADGameDescription *desc) const override;
 


Commit: 514df3efecad82343a8d14c6413310b8cd7c9791
    https://github.com/scummvm/scummvm/commit/514df3efecad82343a8d14c6413310b8cd7c9791
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:38+02:00

Commit Message:
HARVESTER: Sync runtime entity findings

Changed paths:
    ARCHITECTURE.md
    TRACKER.md


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 898c9747fac..3923765e896 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -151,8 +151,9 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `g_video_mode_backends` is the 6-entry pointer table `configure_video_surface` scans to match width / height / pixel format; it contains those five concrete backends plus the sentinel that terminates the probe loop.
     - `configure_video_surface` matches each descriptor on width / height / bpp / backend class, calls the descriptor's probe and activate callbacks, and tears down the previously active backend through the record's shutdown callback before switching.
     - `VesaModeBackend` is now a typed 64-byte video-mode descriptor record despite the legacy name.
-      - Confirmed fields now include width/height/pixel-format, probe/activate/shutdown callbacks, `unused_callback`, the display-start preset callback, pixel read/write callbacks, `fill_horizontal_span_callback`, `fill_rect_callback`, `blit_rect_callback`, and the runtime bank/window fields consumed by FST playback.
+      - Confirmed fields now include width/height/pixel-format, probe/activate/shutdown callbacks, `unused_callback`, `unused_display_start_preset_callback`, pixel read/write callbacks, `fill_horizontal_span_callback`, `fill_rect_callback`, `blit_rect_callback`, and the runtime bank/window fields consumed by FST playback.
       - `unused_callback` is now explicitly bounded as dead slot baggage in this binary: the concrete VGA/VESA implementations are all no-op stubs, and an instruction-level scan only found indirect backend calls through `blit_rect_callback` and `select_bank_for_scanline_callback`.
+      - `unused_display_start_preset_callback` is also dead in this binary: the four `set_vesa_*_display_start_preset` helpers only survive as data references from the backend records, while `configure_video_surface` only dispatches the probe/activate/shutdown slots and the per-mode activate helpers call `initialize_vesa_banked_mode` directly.
       - The VGA `320x200x8` record makes the generic callback roles explicit through `probe_vga_320x200_mode`, `activate_vga_320x200_mode`, `write_vga_320x200_pixel`, `read_vga_320x200_pixel`, `fill_vga_320x200_horizontal_span`, `fill_vga_320x200_rect`, and `blit_vga_320x200_rect`.
     - `VideoSurfaceContext` is now a typed 16-byte context that caches the active backend pointer plus the selected width/height/bpp/class.
     - The mixed Borland runtime descriptor block also binds `set_vesa_video_mode_3` and `reset_video_surface_context` to `g_video_surface_context`; that pair is the small lifecycle wrapper layer above the shared context object.
@@ -546,7 +547,13 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - Class `3`, `0x15`, and `0x19` are skipped by that entity/entity overlap path.
   - On collision it stores the blocking render-entity pointer at runtime offset `+0x109c`; the confirmed read-side consumers of that slot are in `update_actor_runtime_state`.
 - `update_actor_runtime_state` reuses that blocking-entity slot in two confirmed ways:
-  - In the directional movement states it treats `+0x109c` as the current blocker, ignores null/hidden/self-recent blockers, and otherwise cancels the current move state, zeros the movement deltas, and copies the blocker into one of the per-direction remembered-blocker slots at `+0x108c` / `+0x1090` / `+0x1094` / `+0x1098`.
+  - In the directional movement states it treats `+0x109c` as the current blocker, ignores null/hidden/self-recent blockers, and otherwise cancels the current move state, zeros the movement deltas, and copies the blocker into one of four directional blocker-history slots.
+  - The directional mapping is now explicit from the branch-local state writes and signed movement deltas:
+    - `+0x108c` is the upward blocker-history slot
+    - `+0x1090` is the downward blocker-history slot
+    - `+0x1094` is the leftward blocker-history slot
+    - `+0x1098` is the rightward blocker-history slot
+  - `set_object_visibility_for_owner_or_room` clears those four slots individually when a hidden entity still matches one of the remembered blockers, which confirms that they are persistent blocker pointers rather than generic movement flags.
   - In the close-range attack/grapple states it treats `+0x109c` as the current target entity, links the attacker/target pair through runtime field `+0x11b8`, mirrors the attacker's current depth into the target, and can force the target into a reaction / death sequence depending on the target class and active scene flags.
 - `hide_entity_visual` at `0x4c260` and `show_entity_visual` at `0x4c290` are the nested runtime visibility wrappers for render entities.
   - `hide_entity_visual` decrements the visibility nesting and removes the entity from `g_render_entity_list` only on the transition to hidden.
@@ -563,6 +570,10 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `run_harvester_main_loop` uses it for live monster entities, and the wrapper tears down the runtime object when the shared actor state machine reports removal.
 - `spawn_player_combat_avatar` at `0x54220` reuses the monster runtime entity base for the player during combat / weapon-view mode.
   - It seeds player HP/state and installs the weapon-dependent attack / hit / footstep / death sounds.
+  - The same setup now bounds four live actor runtime fields tightly enough to treat them as confirmed:
+    - `+0x11bc` is the selected player combat loadout / equipped weapon id.
+    - `+0x1160` and `+0x1164` are the alternating movement-step sample slots.
+    - `+0x1168` is the death sample slot.
 - `set_player_combat_loadout` at `0x55c10` applies the persisted player combat loadout selection stored at live actor field `+0x11bc`.
   - It is called by the inventory UI when the player changes weapons and by save/load restoration when the saved selection differs from the current live one.
   - It tears down the current attack sound bank, loads the new weapon-dependent attack sounds, reloads the `pc*.abm` actor art, and refreshes the player combat avatar's attack tuning.
@@ -576,6 +587,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `0xe`: chainsaw fuel
   - The corresponding globals are now named `g_nailgun_ammo_icon_strip`, `g_shotgun_shell_icon_strip`, `g_9gun_bullet_icon_strip`, `g_38gun_shotshell_icon_strip`, `g_chainsaw_fuel_icon_strip`, their matching count fields, and `g_visible_weapon_resource_icon_count`.
 - `teardown_player_combat_avatar` at `0x55010` removes the live player combat avatar and frees its weapon-dependent runtime resources before a rebuild or shutdown path.
+- `teardown_entity_runtime_state` at `0x55030` is the shared runtime-entity cleanup layer under the combat-avatar teardown/rebuild paths.
+  - It clears the linked spawned actor at `+0x11a8`, frees the runtime sound/sample fields from `+0x1148` through `+0x1168`, resets the entity to a hidden offscreen idle state, and frees the current bitmap buffers without freeing the outer runtime-entity allocation.
 - `check_player_region_interaction` at `0x559e0` scans `g_region_records` for the first enabled region whose bounds overlap the live player avatar and whose facing gate matches the player's orientation.
   - On success it returns the matching `RegionRecord *` so the main loop can dispatch the region's `action_tag`.
 - `reset_player_combat_avatar` at `0x56a40` is the player-avatar reset helper used on teardown / restart paths.
@@ -595,6 +608,9 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `0x16` through `0x1b` are attack states; on a confirmed hit they assign victim states `0x1c` through `0x21` via `attacker_state + 6`, which bounds `0x1c` through `0x21` as hit-reaction states.
     - `0x28` through `0x33` are death-animation entry states. They play the actor death sound, run the final animation bank, and fall into terminal state `0x38`.
     - `0x35` is the NPC death / monsterfy transition state, and `0x38` is the terminal dead / removed state that dispatches `on_death_action_tag`.
+- `apply_pending_entity_knockback` at `0x53770` is the shared hit-reaction helper called by both `tick_monster_entity_runtime` and `update_player_combat_avatar_state`.
+  - Confirmed hit resolution inside `update_actor_runtime_state` seeds the victim's pending knockback pair as a signed shove at `+0x11ac` (`-18` or `+18`) plus decay step `+0x11b0 = 3`.
+  - The helper copies that pending value into the live shove slot at `+0x1088` when idle, then decays the pending value toward zero.
 - `update_player_combat_avatar_state` at `0x553a0` is the player-side wrapper around `update_actor_runtime_state`.
   - It reads combat input globals, translates them into desired player actor states, updates the player combat avatar, and then delegates the shared animation/combat resolution to `update_actor_runtime_state`.
   - Shared input globals recovered from both the town-map UI and player-combat wrapper are now named:
diff --git a/TRACKER.md b/TRACKER.md
index b751895ded8..aaa878c7216 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,6 +2,29 @@
 
 ## Last Confirmed Action
 
+- Continued the runtime-entity combat-link pass around the confirmed hit-reaction fields.
+  - Renamed `apply_pending_entity_knockback` at `0x53770`.
+  - Confirmed `update_actor_runtime_state` seeds victim `+0x11ac` with signed `-18` / `+18` and `+0x11b0 = 3` on confirmed hits, and the new helper copies that pending value into live shove slot `+0x1088` before decaying it toward zero.
+  - `+0x11b8` is still only written reciprocally in that hit block and still has no recovered consumer, so it remains unnamed.
+  - Saved `HARVEST.LE`.
+- Continued the runtime-entity combat-avatar field pass around `update_player_combat_avatar_state`.
+  - Renamed `teardown_entity_runtime_state` at `0x55030` and added plate comments at the shared cleanup and player-combat helper sites.
+  - Confirmed live actor field `+0x11bc` is the player combat loadout / equipped weapon id from `spawn_player_combat_avatar`, `set_player_combat_loadout`, the inventory weapon-selection path, save/load restoration, and `sync_player_combat_weapon_resource_icons`.
+  - Confirmed `+0x1160` / `+0x1164` are the alternating movement-step sample slots and `+0x1168` is the death sample slot; monster/NPC spawn helpers seed the same fields, so they are shared runtime-actor sound slots rather than player-only state.
+  - Saved `HARVEST.LE`.
+- Closed the remaining `VesaModeBackend.display_start_preset_callback` pass.
+  - Renamed that struct field to `unused_display_start_preset_callback` in Ghidra and saved `HARVEST.LE`.
+  - Confirmed the four `set_vesa_*_display_start_preset` helpers have no code callers; they only remain as data references from the backend records.
+  - Confirmed `configure_video_surface` only dispatches the probe/activate/shutdown slots, and the per-mode activate helpers go straight into `initialize_vesa_banked_mode`, so this preset slot is dead in the current binary.
+- Closed the remaining `update_actor_runtime_state` blocker-slot pass.
+  - Confirmed `tick_entity_visual_state` writes the current opaque overlap blocker at runtime offset `+0x109c`, and `update_actor_runtime_state` consumes that slot as the current blocker in the directional avoid branches.
+  - The four remembered blocker slots now have explicit directional meaning from the branch-local state writes plus the signed movement deltas:
+    - `+0x108c` upward blocker history
+    - `+0x1090` downward blocker history
+    - `+0x1094` leftward blocker history
+    - `+0x1098` rightward blocker history
+  - `set_object_visibility_for_owner_or_room` clears those four slots individually when a hidden entity still matches one of the remembered blockers, which confirms they are persistent blocker pointers rather than generic movement flags.
+  - Added Ghidra plate/decompiler comments at the key read/write sites and saved `HARVEST.LE`.
 - Closed the last ambiguous backend slot in `VesaModeBackend`.
   - Renamed `callback_18` to `unused_callback` after an instruction-level scan showed the only indirect backend calls in the current binary are `blit_rect_callback` and `select_bank_for_scanline_callback` from `run_fst_sequence_player`; no callers use slot `+0x18`.
   - Renamed the shared VGA/VESA no-op implementations to match that evidence:
@@ -112,11 +135,11 @@
   - The descriptor blob entry at `0xc0c98` points directly at this object, which confirms that the table-driven wrappers around `0x18380` / `0x183c0` / `0x183e0` / `0x18400` are operating on the current-bank driver context rather than on a `PcmSoundState`.
 - Current live-state counts:
   - `HARVEST.LE` currently has `774` total functions
-  - `418` have custom/documented names
-  - `356` still remain unnamed / `FUN_*`
+  - `421` have custom/documented names
+  - `353` still remain unnamed / `FUN_*`
 
 ## Next Suggested Action
 
 - Highest-value targets:
-  - shift back outward and recover the remaining `update_actor_runtime_state` consumers of the confirmed blocking-entity pointer at `+0x109c`, especially the read-side meaning of the four remembered blocker slots
-  - if that thread stalls, revisit the still-unused `display_start_preset_callback` slot family to confirm whether it is fully dead in this binary or only dormant
+  - continue outward on the unresolved reciprocal hit pointer at `+0x11b8`, starting from any downstream hit-reaction, death, or scripted-event consumers reachable from the confirmed attack-hit block
+  - if that thread stays cold, keep walking the neighboring runtime flag at `+0x11b4` and its remaining room-event/main-loop toggles


Commit: c9ff18dd27d733099e2538dd4078b15cc87c06e4
    https://github.com/scummvm/scummvm/commit/c9ff18dd27d733099e2538dd4078b15cc87c06e4
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:39+02:00

Commit Message:
HARVESTER: Fix startup palette and cursor timing

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/runtime_entity.cpp
    engines/harvester/startup_art.cpp
    engines/harvester/startup_flow.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 3923765e896..3018f9f8773 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -136,7 +136,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `run_harvester_main_loop` at `0x6dc70` is the primary Harvester boot and gameplay loop.
 - It prints the `Running Harvester v1.0` banner.
 - It loads startup art and UI resources including logo FSTs, palettes, fonts, textbox art, cursor resources, and room-object art.
-- On the cold-start path it only seeds the first town transition: after the world-record parsers and script runtime have populated the live record lists, it preloads `POINTERS.ABM`, stores the spawned cursor entity, preloads the initial room palette into `g_current_palette_buffer`, and then hands off to `room_setup("QUICK_TIPS")`.
+- On the cold-start path it only seeds the first town transition: after the world-record parsers and script runtime have populated the live record lists, it preloads `POINTERS.ABM`, stores the spawned cursor entity, preloads the initial `START -> PCROOM` room palette into `g_current_palette_buffer`, and then hands off to `room_setup("START")`; the quick-tips screen is a later overlay on top of that live room scene rather than a standalone room handoff.
 - It seeds the initial `START` state, enters the main loop, processes state-driven transitions and interactions, and performs cleanup on shutdown.
   - The startup-side helpers hanging directly off that path are now bounded:
     - `printf_ascii` prints the copyright line through `g_stdout_stream` after the `puts_ascii` version banner.
@@ -182,8 +182,9 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `room_setup` first resolves the target room / entrance and, when `g_current_room_def->palette_path` is non-null, rereads that room palette into `g_current_palette_buffer`.
   - It then reloads `WAIT.PAL` and `WAIT.ABM`, hides the cursor entity, flushes the wait transition, uploads the wait palette, builds the room entities, and only after that uploads the room palette from `g_current_palette_buffer`.
   - Once the wait transition is flushed, `room_setup` rebuilds the room render list in this order: matching enabled regions, room timers, visible object records whose `current_owner_or_room` matches the room, then room `ANIM` records whose `active` or `visible` state is set.
-  - After the initial black upload, `room_setup` uses `ramp_palette_brightness` at `0x23a30` to fade `g_current_palette_buffer` in from black. The helper steps brightness in `0.1` increments on an approximately 4 ms timer and also drives the room dimming path when the room is marked dimmable and `DAY_FLAG` is clear.
+  - After the initial black upload, `room_setup` uses `ramp_palette_brightness` at `0x23a30` to fade `g_current_palette_buffer` in from black. The helper steps brightness in `0.1` increments against the shared centisecond clock from `get_elapsed_milliseconds()` while also driving the room dimming path when the room is marked dimmable and `DAY_FLAG` is clear.
 - `upload_palette_to_vga` at `0x22770` writes a caller-supplied RGB palette to the VGA DAC, forces palette index 0 to black, and applies a caller-supplied brightness scalar before clamping each channel to the hardware `0x3f` range.
+  - Archive inspection plus the helper body confirm the room/menu `.PAL` resources are already stored as 768-byte 8-bit RGB triplets; the upload path shifts those bytes down to VGA `0..63` before brightness scaling instead of expanding 6-bit source values.
 - `ramp_palette_brightness` at `0x23a30` is the shared timed palette ramp helper.
   - `room_setup` uses it as a fade-in from `0.0` to either `1.0` or the dimmed target `0.6`.
   - The helper updates music stream state between palette uploads instead of blocking in a tight delay loop.
@@ -234,7 +235,13 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `parse_room_record` at `0x5fea0`
   - `parse_text_record` at `0x600b0`
   - `parse_timer_record` at `0x60160`
-  - `parse_useitem_record` at `0x602c0`
+- `parse_command_record` now has enough parser evidence to anchor the room-event combat-control opcodes directly.
+  - `KILL_NPC -> 0x0c`
+  - `KILL_PC -> 0x0d`
+  - `MONSTERFY -> 0x0e`
+  - `PAUSE_PC -> 0x25`
+  - `RESUME_PC -> 0x26`
+- `parse_useitem_record` at `0x602c0`
 - This parser registry is the clearest anchor for the global linked-list heads used by `room_setup`, `dispatch_room_event_actions`, inventory handling, and dialogue.
 
 ## Record Lists
@@ -387,6 +394,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `sync_cursor_entity_position` at `0x799e0` repositions a runtime entity to the shared cursor coordinates and then advances its visual state through the normal entity animation tick path.
 - `flush_dirty_rects_to_screen` reads `g_cursor_entity` and calls `sync_cursor_entity_position` every frame before the dirty-rect blit work, so cursor placement is part of the normal render pipeline rather than a one-shot startup blit.
 - The shared runtime entity animation/timing cluster is now bounded enough to emulate conservatively:
+  - `get_elapsed_milliseconds` at `0x82102` is misnamed; it derives elapsed time from the DOS clock in centiseconds.
+  - `set_entity_animation_rate` therefore stores centisecond intervals as `100 / rate`, so the cursor's recovered rate `10` advances once every `0.1` seconds rather than every `10` wall-clock milliseconds.
   - `+0x105c` stores the next animation tick deadline used by `tick_entity_visual_state`
   - `+0x1060` stores the tick interval derived from `set_entity_animation_rate`
   - `+0x1064` stores the current frame index
@@ -486,6 +495,19 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - parser booleans are `active` and `visible`; `visible` drives actor spawn/remove in `SET_MONSTER`, while `active` is copied into the live actor's `0x52` state byte
   - `on_death_action_tag` is dispatched when the monster finishes its death/remove state
   - `damage_amount` is copied into the live actor state at `+0x1180` and is subtracted from target HP on a successful hit; the player combat avatar seeds the same slot from weapon presets.
+  - `damage_type` is parsed from the strings `BLUDGE`, `SLASH`, and `PROJ` into the low-bit values `1`, `2`, and `4`.
+  - Monster construction copies that `damage_type` word into live actor `+0x118c` before OR-ing extra low-byte animation/capability bits from the loaded ABM frame banks; the player combat-avatar path reuses the same low damage bits on top of its own `0x00fffff8` base mask.
+  - `spawn_monster_entity_from_record` also derives the neighboring runtime capability bytes `+0x118d` and `+0x118e` directly from ABM frame-count checks, so those bytes are animation-bank availability flags rather than generic AI state.
+  - The low byte of runtime `+0x118c` now has partial facing-bank semantics from that same spawn selector:
+    - `0x08` = front-facing base bank
+    - `0x10` = left-facing base bank
+    - `0x40` = right-facing base bank
+    - `0x20` = back-facing base bank
+  - The high nibble of runtime `+0x118e` provides alternate initial stance banks for those same facings:
+    - `0x40` = front-facing alternate bank
+    - `0x10` = left-facing alternate bank
+    - `0x20` = right-facing alternate bank
+    - `0x80` = back-facing alternate bank
   - `field_70` is the attack-sound trigger frame
   - `field_78` is the walk / footstep-sound trigger frame
   - `saved_enabled`, `runtime_spawned`, `next`
@@ -566,6 +588,15 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `spawn_scaled_abm_entity_from_resource` at `0x4bda0` is the scale-managed variant of `spawn_abm_entity_from_resource`.
   - It loads the same ABM resource format, but additionally marks the runtime entity for depth scaling and seeds the live sprite-scale field before the first `show_entity_visual` / render-list add.
   - `run_harvester_main_loop` uses it for `IDLE_ANIM` with `graphic\\roomanim\\pcloun02.abm`, copying the live player avatar's current scale so the temporary idle actor inherits the same room-depth sizing.
+- `attach_abm_resource_to_entity` at `0x4c0f0` is the shared decoded-ABM binding layer under the runtime-entity spawners and combat-avatar reloads.
+  - It stores the decoded ABM blob on the live entity, builds the per-frame pointer table, allocates the current frame bitmap storage, and seeds animation frame `0`.
+  - Callers now confirmed from the binary are `spawn_abm_entity_base`, `spawn_abm_entity_from_resource`, `spawn_scaled_abm_entity_from_resource`, `spawn_anim_entity_from_record`, `set_player_combat_loadout`, and `reset_player_combat_avatar`.
+- `queue_npc_death_or_monsterfy_transition` at `0x53810` is the small helper that queues runtime actor state `0x35` for a live NPC target.
+  - `parse_command_record` now confirms the room-event callers are `KILL_NPC` and `MONSTERFY`.
+  - It is also shared by the close-range NPC hit path inside `update_actor_runtime_state`.
+  - It clears live byte `+0x50`, preserves the current animation bank, and reseeds the current animation frame after forcing state `0x35`.
+- `teardown_monster_entity_runtime_state` at `0x541c0` is the monster-specific wrapper over the shared runtime-entity teardown path.
+  - `destroy_entity_list` and `dispatch_room_event_actions` use it only on class-6 monster removal flows.
 - `tick_monster_entity_runtime` at `0x54140` is the class-6 monster wrapper around `update_actor_runtime_state`.
   - `run_harvester_main_loop` uses it for live monster entities, and the wrapper tears down the runtime object when the shared actor state machine reports removal.
 - `spawn_player_combat_avatar` at `0x54220` reuses the monster runtime entity base for the player during combat / weapon-view mode.
@@ -574,10 +605,18 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `+0x11bc` is the selected player combat loadout / equipped weapon id.
     - `+0x1160` and `+0x1164` are the alternating movement-step sample slots.
     - `+0x1168` is the death sample slot.
+  - The same writer path now also bounds the loadout-specific attack cluster:
+    - `+0x1134` is the attack sample count.
+    - `+0x1148`, `+0x114c`, and `+0x1150` are the attack sample slots.
+    - `+0x116c` is the attack-sound trigger frame offset.
+    - `+0x113c` is the attack contact frame offset.
+    - `+0x1180` is the per-hit damage amount.
 - `set_player_combat_loadout` at `0x55c10` applies the persisted player combat loadout selection stored at live actor field `+0x11bc`.
   - It is called by the inventory UI when the player changes weapons and by save/load restoration when the saved selection differs from the current live one.
   - It tears down the current attack sound bank, loads the new weapon-dependent attack sounds, reloads the `pc*.abm` actor art, and refreshes the player combat avatar's attack tuning.
+  - The tuning it refreshes matches the spawn path exactly: `+0x1134` attack sample count, `+0x1148/+0x114c/+0x1150` attack sample slots, `+0x116c` attack-sound trigger frame offset, `+0x113c` attack contact frame offset, and `+0x1180` per-hit damage amount.
   - The ABM path table it indexes is now labeled `g_player_combat_loadout_abm_paths` at `0xc3eb4`.
+  - That table is now bounded as a direct 21-entry loadout map from ids `0..0x14` to `1:\\graphic\\monsters\\pc\\pc0.abm` through `1:\\graphic\\monsters\\pc\\pc20.abm`.
 - `sync_player_combat_weapon_resource_icons` at `0x792c0` updates the HUD resource-icon strip/count pair for the current player combat loadout.
   - The loadout-to-resource mapping now confirmed from `set_player_combat_loadout` is:
     - `2`: nailgun
@@ -597,6 +636,33 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - It is called from `run_harvester_main_loop`, the dialogue/response/keyword modal loops, and the player-combat wrapper, so actor motion/combat continues advancing while blocking UI is active.
   - It handles class-4/5/6 actor entities, advances animation/state transitions, maintains pursuit spacing against the player using `engage_distance`, applies room Z bounds and vertical motion, fires frame-timed sound hooks, resolves hit damage through `damage_amount`, and returns `0` when the caller should remove the actor entity from the world list.
   - It also contains the confirmed NPC-to-monster replacement path used by the `change2monster` flow.
+  - The attack-state reader side is now bounded directly from states `0x16` through `0x1b`:
+    - it treats `+0x1134` as the attack sample count and randomly selects from `+0x1148/+0x114c/+0x1150`
+    - it plays the selected attack sample when `current_frame == anim_base + +0x116c`
+    - it waits until `current_frame >= anim_base + +0x113c` before resolving contact
+    - on a confirmed hit it subtracts attacker `+0x1180` from victim HP
+  - Runtime dword `+0x118c` is now bounded as a mixed combat/capability mask rather than as a pure damage-type field.
+    - its low bits carry the attack damage type (`1 = BLUDGE`, `2 = SLASH`, `4 = PROJ`)
+    - the player combat-avatar path seeds it with `0x00fffff8` before adding those low bits, while monster construction copies `MonsterRecord.damage_type` and then ORs extra low-byte availability bits from ABM frame counts
+    - in the player attack path, `(+0x118c & 4) == 0` stays on the close-range hit-resolution branch, while class-5 attacks with bit `4` set use the separate ranged target-acquisition / ammo-count branch; the chainsaw fuel check is a separate loadout-specific pre-branch case
+    - its confirmed low-byte facing-bank bits are `0x08` front, `0x10` left, `0x40` right, and `0x20` back; monster spawn uses those as the default initial stance banks when the matching alternate `+0x118e` high-nibble bank is absent
+    - the same low-byte bits are later consumed by the locomotion/turn-family entries rooted at states `4`, `7/0xf`, `8/0xe`, and `0xb`
+  - Runtime byte `+0x118d` is an attack / hit-reaction bank-availability byte.
+    - bits `0x08`, `0x10`, and `0x20` gate the attack-state pairs `0x16/0x19`, `0x17/0x1a`, and `0x18/0x1b`
+    - bits `0x40` and `0x80`, together with `+0x118e & 0x01`, gate the hit-reaction state pairs `0x1c/0x1f`, `0x1d/0x20`, and `0x1e/0x21`
+  - Runtime byte `+0x118e` is the death-bank availability byte, with one shared hit-reaction gate in its low bit.
+    - bit `0x01` is the third hit-reaction gate used by states `0x1e/0x21`
+    - bits `0x02`, `0x04`, and `0x08` gate the death-state families `0x28/0x29/0x2e/0x2f`, `0x2a/0x2b/0x30/0x31`, and `0x2c/0x2d/0x32/0x33`
+  - Runtime byte `+0x11b4` is now bounded for the player-combat path:
+    - `parse_command_record` maps room-event commands `PAUSE_PC` / `RESUME_PC` to action cases `0x25` / `0x26`, and those cases set and clear `g_player + 0x11b4`
+    - `update_actor_runtime_state` consults that byte only for class-5 player combat-avatar updates and returns early while it is set
+    - `run_harvester_main_loop` and `run_load_game_menu` clear it on return/reload paths
+  - Runtime dword `+0x1188` is a damage-type flag field.
+    - `spawn_npc_entity_from_record` and `spawn_monster_entity_base` clear it to `0`.
+    - `KILL_NPC` and `KILL_PC` assign `1`, `2`, and `4` for `BLUDGE`, `SLASH`, and `PROJ`.
+    - close-range hit resolution mirrors attacker `+0x118c` into victim `+0x1188`, and the death-state selector only consults the low damage-type bits there
+    - `update_actor_runtime_state` tests those bits during hit-reaction and death-state selection.
+  - Runtime dword `+0x11b8` is still only written reciprocally inside the close-range hit-resolution block and still has no recovered reader, so it remains an unresolved reciprocal combat-link slot.
   - Runtime bytes `+0x11a1` and `+0x11a0` are now bounded separately:
     - `+0x11a1` is the actor dispatch-state byte; `update_actor_runtime_state` switches on it directly.
     - `+0x11a0` is the latched animation/state byte; it is usually mirrored into `+0x11a1` on direct state changes, but remains distinct during entry, turn, and terminal sequences.
diff --git a/TRACKER.md b/TRACKER.md
index aaa878c7216..d625365316c 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,6 +2,48 @@
 
 ## Last Confirmed Action
 
+- Closed the startup palette-format / cursor-animation clock pass.
+  - Confirmed by archive inspection that `WAIT.PAL`, `PCROOM.PAL`, and the rest of the indexed room palettes are exact 768-byte resources already storing 8-bit RGB triplets.
+  - Confirmed in Ghidra that `upload_palette_to_vga` shifts those palette bytes down to VGA DAC range instead of expanding 6-bit source values, which explains the bad startup colors in the current engine.
+  - Confirmed in Ghidra that runtime entity animation timing uses the centisecond DOS clock behind `get_elapsed_milliseconds`: `set_entity_animation_rate` stores `100 / rate`, and `tick_entity_visual_state` compares that interval against centisecond ticks.
+  - Patched the ScummVM startup palette loaders to keep the raw 8-bit palette bytes, switched runtime entity ticking over to a centisecond-style clock, and added targeted palette/cursor debug logs.
+- Closed the first low-byte facing-bank pass around runtime `+0x118c`.
+  - Confirmed `spawn_monster_entity_from_record` uses the low byte of `+0x118c` as the default facing-bank selector when the alternate `+0x118e` high-nibble bank is absent: `0x08` front, `0x10` left, `0x40` right, and `0x20` back.
+  - Confirmed the matching `+0x118e` high-nibble bits are the alternate initial stance banks for those same facings: `0x40` front, `0x10` left, `0x20` right, and `0x80` back.
+  - Confirmed `update_actor_runtime_state` reuses those `+0x118c` low-byte bits in the locomotion / turn-family entries rooted at states `4`, `7/0xf`, `8/0xe`, and `0xb`.
+  - Added Ghidra comments at `spawn_monster_entity_from_record` and `update_actor_runtime_state`, and saved `HARVEST.LE`.
+- Closed the first runtime capability-byte pass around `+0x118d` / `+0x118e`.
+  - Confirmed `spawn_monster_entity_from_record` seeds `+0x118c`, `+0x118d`, and `+0x118e` from ABM frame-count checks rather than from generic AI state.
+  - Confirmed `+0x118d` bits `0x08/0x10/0x20` gate attack-state pairs `0x16/0x19`, `0x17/0x1a`, and `0x18/0x1b`.
+  - Confirmed `+0x118d` bits `0x40/0x80` plus `+0x118e & 0x01` gate the hit-reaction state pairs `0x1c/0x1f`, `0x1d/0x20`, and `0x1e/0x21`, and `+0x118e` bits `0x02/0x04/0x08` gate the death-state families `0x28` through `0x33`.
+  - Added Ghidra comments at `spawn_monster_entity_from_record` and `update_actor_runtime_state`, and saved `HARVEST.LE`.
+- Closed the `+0x118c` damage-type / ranged-split pass.
+  - Confirmed `parse_monster_record` parses `MonsterRecord.damage_type` from `BLUDGE`, `SLASH`, and `PROJ` into the low-bit values `1`, `2`, and `4`.
+  - Confirmed runtime `+0x118c` is a mixed combat/capability mask, not a pure damage-type field: the player combat-avatar path seeds `0x00fffff8` before adding the low damage bits, while monster construction copies `MonsterRecord.damage_type` and then ORs extra low-byte availability bits from ABM frame counts.
+  - Confirmed `update_actor_runtime_state` keeps `(+0x118c & 4) == 0` on the close-range hit-resolution path, while class-5 attacks with bit `4` set use the separate ranged target-acquisition / ammo-count branch; close-range hits mirror attacker `+0x118c` into victim `+0x1188`.
+  - Added Ghidra comments at `parse_monster_record`, `spawn_player_combat_avatar`, and `update_actor_runtime_state`, and saved `HARVEST.LE`.
+- Closed the startup quick-tips / initial palette sequencing pass from `run_harvester_main_loop`.
+  - Confirmed the original binary seeds `"START"`, preloads `1:\graphic\pal\pcroom.pal` into `g_current_palette_buffer`, and enters `room_setup` before the quick-tips overlay is shown.
+  - Confirmed `run_quick_tips_screen` is a separate overlay path: it adds `1:\GRAPHIC\OTHER\TIPS.BM` and text entities on top of the already active room instead of resolving a standalone `QUICK_TIPS` room.
+  - That rules out the current startup stub's fake `resolveRoomSetupState("QUICK_TIPS")` path and narrows the palette mismatch to startup sequencing / palette application rather than to the basic `.PAL` or `.BM` file formats.
+- Closed the player-combat loadout attack-tuning pass.
+  - Confirmed `spawn_player_combat_avatar`, `set_player_combat_loadout`, and the monster-record-driven runtime reseed block all share the same attack cluster: `+0x1134` attack sample count, `+0x1148/+0x114c/+0x1150` attack sample slots, `+0x116c` attack-sound trigger frame offset, `+0x113c` attack contact frame offset, and `+0x1180` per-hit damage amount.
+  - Confirmed `update_actor_runtime_state` attack states `0x16` through `0x1b` randomly choose from that sample table at `anim_base + +0x116c`, then resolve the hit once `anim_base + +0x113c` is reached and subtract `+0x1180` from victim HP.
+  - Added Ghidra comments at `spawn_player_combat_avatar`, `set_player_combat_loadout`, and `update_actor_runtime_state`, and saved `HARVEST.LE`.
+- Closed the parser-name gap for the room-event combat-control opcodes.
+  - Confirmed `parse_command_record` maps `KILL_NPC -> 0x0c`, `KILL_PC -> 0x0d`, `MONSTERFY -> 0x0e`, `PAUSE_PC -> 0x25`, and `RESUME_PC -> 0x26`.
+  - That locks the room-event callers behind `queue_npc_death_or_monsterfy_transition` and the player damage-type path to stable parser names instead of raw case numbers.
+  - Added Ghidra comments at the parser, dispatcher, and NPC transition helper sites and saved `HARVEST.LE`.
+- Continued the runtime-entity NPC transition/loadout pass around `update_actor_runtime_state` and `set_player_combat_loadout`.
+  - Renamed `attach_abm_resource_to_entity` at `0x4c0f0`, `queue_npc_death_or_monsterfy_transition` at `0x53810`, and `teardown_monster_entity_runtime_state` at `0x541c0`.
+  - Confirmed `g_player_combat_loadout_abm_paths` at `0xc3eb4` is a direct 21-entry `pc0.abm` through `pc20.abm` table shared by the player combat-avatar spawn, reset, loadout-switch, and load-game restore paths.
+  - Confirmed runtime field `+0x1188` is a damage-type flag word: spawn helpers clear it, room-event helpers assign `BLUDGE = 1`, `SLASH = 2`, and `PROJ = 4`, and `update_actor_runtime_state` tests those bits during hit/death-state selection.
+  - Confirmed `+0x11b8` is still only written reciprocally in the close-range hit block and still has no recovered reader, so it remains unnamed.
+  - Saved `HARVEST.LE`.
+- Continued the runtime-entity room-event suppression pass around `+0x11b4`.
+  - Confirmed room-event action cases `0x25` / `0x26` set and clear `g_player + 0x11b4`.
+  - Confirmed `update_actor_runtime_state` consults that byte only for class-5 player combat-avatar updates and returns immediately while it is set; `run_harvester_main_loop` and `run_load_game_menu` clear it on return/reload paths.
+  - Added Ghidra comments at the key read/write sites and saved `HARVEST.LE`.
 - Continued the runtime-entity combat-link pass around the confirmed hit-reaction fields.
   - Renamed `apply_pending_entity_knockback` at `0x53770`.
   - Confirmed `update_actor_runtime_state` seeds victim `+0x11ac` with signed `-18` / `+18` and `+0x11b0 = 3` on confirmed hits, and the new helper copies that pending value into live shove slot `+0x1088` before decaying it toward zero.
@@ -135,11 +177,15 @@
   - The descriptor blob entry at `0xc0c98` points directly at this object, which confirms that the table-driven wrappers around `0x18380` / `0x183c0` / `0x183e0` / `0x18400` are operating on the current-bank driver context rather than on a `PcmSoundState`.
 - Current live-state counts:
   - `HARVEST.LE` currently has `774` total functions
-  - `421` have custom/documented names
-  - `353` still remain unnamed / `FUN_*`
+  - `423` have custom/documented names
+  - `351` still remain unnamed / `FUN_*`
 
 ## Next Suggested Action
 
+- Re-run the startup path with the new logs and verify two concrete points in one capture:
+  - `PCROOM.PAL` should now log raw values like `idx0=(0,0,56)` instead of the bogus expanded blue channel.
+  - The cursor should stay on sequence `7` while advancing frames `70..79` on a centisecond clock rather than racing every render tick.
+- If either behavior still diverges from DOS, take a fresh Ghidra pass through `room_setup` after the wait-transition flush and `flush_dirty_rects_to_screen` / `sync_cursor_entity_position` to compare exact first-frame ordering and palette upload order against the port.
 - Highest-value targets:
-  - continue outward on the unresolved reciprocal hit pointer at `+0x11b8`, starting from any downstream hit-reaction, death, or scripted-event consumers reachable from the confirmed attack-hit block
-  - if that thread stays cold, keep walking the neighboring runtime flag at `+0x11b4` and its remaining room-event/main-loop toggles
+  - continue outward on the remaining partial `+0x118c` semantics, especially the still-unresolved low-byte bit `0x80` and the high attack-picker gates `0x1000` / `0x10000`
+  - if that thread stays cold, revisit the unresolved reciprocal combat-link slot at `+0x11b8` from the confirmed close-range hit block
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index 99123dcb14d..0802f66fc58 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -25,6 +25,7 @@
 #include "common/endian.h"
 #include "common/system.h"
 #include "graphics/screen.h"
+#include "harvester/detection.h"
 #include "harvester/resources.h"
 
 namespace Harvester {
@@ -36,6 +37,12 @@ static const char *const kCursorResourcePath = "1:/GRAPHIC/POINTERS/POINTERS.ABM
 static const float kCursorEntityZ = -100.0f;
 static const int kCursorAnimationRate = 10;
 static const int kFramesPerSequence = 10;
+static const uint32 kAnimationClockDivisorMs = 10;
+
+static uint32 getAnimationClockTicks() {
+	// The original runtime entity animation timer is driven from a centisecond DOS clock.
+	return g_system ? (g_system->getMillis() / kAnimationClockDivisorMs) : 0;
+}
 
 static void blitAnimationFrame(Graphics::Screen &screen, const Common::Array<AbmFrame> &frames, uint frameIndex,
 		int x, int y) {
@@ -207,6 +214,12 @@ void RuntimeEntity::setAnimationSequence(int sequence) {
 	_firstFrame = MIN<int>(sequence * kFramesPerSequence, (int)_frames.size() - 1);
 	_lastFrame = MIN<int>(_firstFrame + kFramesPerSequence - 1, (int)_frames.size() - 1);
 	advanceAnimationFrame(_firstFrame);
+
+	if (_classId == kRuntimeEntityClassCursor) {
+		debugC(1, kDebugGeneral,
+			"Harvester: cursor animation sequence=%d frames=%d..%d current=%d",
+			_animationSequence, _firstFrame, _lastFrame, _currentFrame);
+	}
 }
 
 void RuntimeEntity::configureHotspotBounds(int width, int height) {
@@ -468,6 +481,14 @@ RuntimeEntity *RuntimeEntityManager::spawnCursorEntity(const Common::Point &posi
 		_cursorEntity->setAnimationSequence(0);
 	if (_cursorEntity)
 		_cursorEntity->setHitTestMode(kRuntimeEntityHitTestNone);
+	if (_cursorEntity) {
+		const uint32 animationInterval = _cursorEntity->getAnimationRate() == 0 ? 0 :
+			(100U / (uint32)_cursorEntity->getAnimationRate());
+		debugC(1, kDebugGeneral,
+			"Harvester: spawned cursor entity rate=%d intervalTicks=%u clock=centiseconds frame=%d..%d pos=(%d,%d)",
+			_cursorEntity->getAnimationRate(), animationInterval, _cursorEntity->getCurrentFrame(),
+			_cursorEntity->getLastFrame(), position.x, position.y);
+	}
 
 	return _cursorEntity;
 }
@@ -528,7 +549,7 @@ void RuntimeEntityManager::showCursor() {
 }
 
 bool RuntimeEntityManager::tickSceneEntities() {
-	const uint32 now = g_system->getMillis();
+	const uint32 now = getAnimationClockTicks();
 	bool changed = false;
 
 	for (RuntimeEntity *entity : _sceneEntities)
@@ -543,7 +564,7 @@ bool RuntimeEntityManager::syncCursorEntityPosition(const Common::Point &positio
 
 	const bool moved = _cursorEntity->getX() != position.x || _cursorEntity->getY() != position.y;
 	_cursorEntity->setPosition(position.x, position.y, kCursorEntityZ);
-	return _cursorEntity->tickVisualState(g_system->getMillis()) || moved;
+	return _cursorEntity->tickVisualState(getAnimationClockTicks()) || moved;
 }
 
 void RuntimeEntityManager::drawCursor(Graphics::Screen &screen) const {
diff --git a/engines/harvester/startup_art.cpp b/engines/harvester/startup_art.cpp
index 553c3208b09..e8df76c1f9b 100644
--- a/engines/harvester/startup_art.cpp
+++ b/engines/harvester/startup_art.cpp
@@ -25,14 +25,48 @@
 #include "common/endian.h"
 #include "common/system.h"
 #include "graphics/paletteman.h"
+#include "harvester/detection.h"
 #include "harvester/resources.h"
 
 namespace Harvester {
 
 namespace {
 
-static byte expand6BitColor(byte value) {
-	return (value * 255 + 31) / 63;
+static void getPaletteByteRange(const byte *palette, byte &minValue, byte &maxValue) {
+	minValue = 255;
+	maxValue = 0;
+
+	for (uint i = 0; i < 256 * 3; ++i) {
+		minValue = MIN(minValue, palette[i]);
+		maxValue = MAX(maxValue, palette[i]);
+	}
+}
+
+static uint32 hashPalette(const byte *palette) {
+	uint32 hash = 2166136261U;
+	for (uint i = 0; i < 256 * 3; ++i) {
+		hash ^= palette[i];
+		hash *= 16777619U;
+	}
+
+	return hash;
+}
+
+static void logPaletteSummary(const char *label, const Common::String &path, const byte *palette) {
+	if (!palette)
+		return;
+
+	byte minValue = 0;
+	byte maxValue = 0;
+	getPaletteByteRange(palette, minValue, maxValue);
+
+	debugC(1, kDebugGeneral,
+		"Harvester: %s '%s' bytes=%u range=[%u,%u] hash=%08x idx0=(%u,%u,%u) idx1=(%u,%u,%u) idx127=(%u,%u,%u) idx255=(%u,%u,%u)",
+		label, path.c_str(), 256U * 3U, minValue, maxValue, hashPalette(palette),
+		palette[0], palette[1], palette[2],
+		palette[3], palette[4], palette[5],
+		palette[127 * 3], palette[127 * 3 + 1], palette[127 * 3 + 2],
+		palette[255 * 3], palette[255 * 3 + 1], palette[255 * 3 + 2]);
 }
 
 static const int kInventoryX = 64;
@@ -92,6 +126,7 @@ void StartupArt::drawWaitFrame() const {
 	if (_waitFrames.empty() || !_waitFrames[0].isValid())
 		return;
 
+	logPaletteSummary("applying wait palette", "1:/GRAPHIC/PAL/WAIT.PAL", _waitPalette);
 	g_system->getPaletteManager()->setPalette(_waitPalette, 0, 256);
 	g_system->fillScreen(0);
 	blitBitmap(_inventoryBitmap, kInventoryX, kInventoryY);
@@ -107,9 +142,9 @@ bool StartupArt::loadPalette(ResourceManager &resources, const Common::String &p
 		return false;
 	}
 
-	for (uint i = 0; i < 256 * 3; ++i)
-		dest[i] = expand6BitColor(data[i]);
+	memcpy(dest, data.data(), 256 * 3);
 
+	logPaletteSummary("loaded palette", path, dest);
 	return true;
 }
 
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index ec99940fea3..7befe108f72 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -77,8 +77,40 @@ struct StartupRoomSceneResources {
 	float targetPaletteBrightness = 1.0f;
 };
 
-static byte expand6BitColor(byte value) {
-	return (value * 255 + 31) / 63;
+static void getPaletteByteRange(const byte *palette, byte &minValue, byte &maxValue) {
+	minValue = 255;
+	maxValue = 0;
+
+	for (uint i = 0; i < 256 * 3; ++i) {
+		minValue = MIN(minValue, palette[i]);
+		maxValue = MAX(maxValue, palette[i]);
+	}
+}
+
+static uint32 hashPalette(const byte *palette) {
+	uint32 hash = 2166136261U;
+	for (uint i = 0; i < 256 * 3; ++i) {
+		hash ^= palette[i];
+		hash *= 16777619U;
+	}
+
+	return hash;
+}
+
+static void logScenePaletteSummary(const char *label, const StartupRoomSceneResources &scene, float brightness) {
+	const byte *palette = scene.palette;
+	byte minValue = 0;
+	byte maxValue = 0;
+	getPaletteByteRange(palette, minValue, maxValue);
+
+	debugC(1, kDebugGeneral,
+		"Harvester: %s room='%s' palette='%s' bytes=%u range=[%u,%u] brightness=%.2f hash=%08x idx0=(%u,%u,%u) idx1=(%u,%u,%u) idx127=(%u,%u,%u) idx255=(%u,%u,%u)",
+		label, scene.state.roomName.c_str(), scene.state.palettePath.c_str(), 256U * 3U, minValue, maxValue,
+		(double)brightness, hashPalette(palette),
+		palette[0], palette[1], palette[2],
+		palette[3], palette[4], palette[5],
+		palette[127 * 3], palette[127 * 3 + 1], palette[127 * 3 + 2],
+		palette[255 * 3], palette[255 * 3 + 1], palette[255 * 3 + 2]);
 }
 
 static Common::Rect quickTipsExitRect() {
@@ -141,8 +173,16 @@ static bool loadPaletteResource(ResourceManager &resources, const Common::String
 	if (!resources.loadFile(path, data) || data.size() < 256 * 3)
 		return false;
 
-	for (uint i = 0; i < 256 * 3; ++i)
-		dest[i] = expand6BitColor(data[i]);
+	memcpy(dest, data.data(), 256 * 3);
+
+	byte minValue = 0;
+	byte maxValue = 0;
+	getPaletteByteRange(dest, minValue, maxValue);
+	debugC(1, kDebugGeneral,
+		"Harvester: decoded room palette '%s' bytes=%u range=[%u,%u] idx0=(%u,%u,%u) idx255=(%u,%u,%u)",
+		path.c_str(), (uint)data.size(), minValue, maxValue,
+		dest[0], dest[1], dest[2],
+		dest[255 * 3], dest[255 * 3 + 1], dest[255 * 3 + 2]);
 
 	return true;
 }
@@ -211,6 +251,12 @@ static bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceM
 			scene.sceneAnimations.push_back(anim);
 	}
 
+	debugC(1, kDebugGeneral,
+		"Harvester: loadRoomSceneResources room='%s' palette='%s' background='%s' brightness=%.2f objects=%u activeObjects=%u sceneObjects=%u anims=%u visibleAnims=%u",
+		state.roomName.c_str(), state.palettePath.c_str(), state.backgroundPath.c_str(), (double)state.paletteBrightness,
+		(uint)state.roomObjects.size(), (uint)state.activeObjects.size(), (uint)scene.sceneObjects.size(),
+		(uint)state.roomAnimations.size(), (uint)scene.sceneAnimations.size());
+
 	return true;
 }
 
@@ -259,7 +305,8 @@ static Common::String trimAsciiLine(const Common::String &value) {
 
 static bool loadQuickTipsScene(HarvesterEngine &engine, StartupRoomSceneResources &scene) {
 	StartupRoomSetupState state;
-	if (!engine.getStartupScript()->resolveRoomSetupState("QUICK_TIPS", state, *engine.getResources()))
+	// The original startup path enters the START room first and overlays quick tips on top of it.
+	if (!engine.getStartupScript()->resolveRoomSetupState("START", state, *engine.getResources()))
 		return false;
 
 	return loadRoomSceneResources(state, *engine.getResources(), scene);
@@ -395,10 +442,12 @@ Common::Error StartupFlow::runQuickTips() {
 		if (!populateRoomSceneEntities(scene.sceneObjects, scene.sceneAnimations))
 			return Common::kReadingFailed;
 
+		logScenePaletteSummary("quick tips scene palette", scene, kPaletteBrightnessBlack);
 		drawRoomScene(_engine, *screen, scene, kPaletteBrightnessBlack);
 		screen->makeAllDirty();
 		screen->update();
 
+		logScenePaletteSummary("quick tips fade target", scene, scene.targetPaletteBrightness);
 		transitionError = fadeInRoomScene(scene.palette, scene.targetPaletteBrightness);
 		if (transitionError.getCode() != Common::kNoError)
 			return transitionError;
@@ -584,10 +633,12 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 	if (!populateRoomSceneEntities(scene.sceneObjects, scene.sceneAnimations))
 		return Common::kReadingFailed;
 
+	logScenePaletteSummary("room setup stub palette", scene, kPaletteBrightnessBlack);
 	drawRoomScene(_engine, *screen, scene, kPaletteBrightnessBlack);
 	screen->makeAllDirty();
 	screen->update();
 
+	logScenePaletteSummary("room setup fade target", scene, scene.targetPaletteBrightness);
 	transitionError = fadeInRoomScene(scene.palette, scene.targetPaletteBrightness);
 	if (transitionError.getCode() != Common::kNoError)
 		return transitionError;


Commit: 73aadec836067aa69761abbcf9050894af9d3c8b
    https://github.com/scummvm/scummvm/commit/73aadec836067aa69761abbcf9050894af9d3c8b
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:39+02:00

Commit Message:
HARVESTER: Add startup room loop and player actor

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_flow.h
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 3018f9f8773..6c0cd9dd546 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -137,6 +137,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - It prints the `Running Harvester v1.0` banner.
 - It loads startup art and UI resources including logo FSTs, palettes, fonts, textbox art, cursor resources, and room-object art.
 - On the cold-start path it only seeds the first town transition: after the world-record parsers and script runtime have populated the live record lists, it preloads `POINTERS.ABM`, stores the spawned cursor entity, preloads the initial `START -> PCROOM` room palette into `g_current_palette_buffer`, and then hands off to `room_setup("START")`; the quick-tips screen is a later overlay on top of that live room scene rather than a standalone room handoff.
+- That same cold-start path also stores the live player actor before the first room transition; `room_setup` later re-adds that existing actor to the room render list instead of constructing a fresh PCROOM-local record.
 - It seeds the initial `START` state, enters the main loop, processes state-driven transitions and interactions, and performs cleanup on shutdown.
   - The startup-side helpers hanging directly off that path are now bounded:
     - `printf_ascii` prints the copyright line through `g_stdout_stream` after the `puts_ascii` version banner.
@@ -180,8 +181,12 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `room_setup` resolves the requested room or `START` entrance, finds the matching room definition, loads the room palette, instantiates exits / hotspots / overlays / room actors, positions the player, runs room-enter handlers, and updates the current music file.
 - `g_current_palette_buffer` at `0xd6084` is the shared room/menu palette buffer used by `run_harvester_main_loop`, `room_setup`, `dispatch_room_event_actions`, inventory/help screens, and the CD prompt path.
   - `room_setup` first resolves the target room / entrance and, when `g_current_room_def->palette_path` is non-null, rereads that room palette into `g_current_palette_buffer`.
-  - It then reloads `WAIT.PAL` and `WAIT.ABM`, hides the cursor entity, flushes the wait transition, uploads the wait palette, builds the room entities, and only after that uploads the room palette from `g_current_palette_buffer`.
+  - It then reloads `WAIT.PAL` and `WAIT.ABM`, hides the cursor entity, flushes the wait transition while the cursor is hidden, re-shows the cursor, uploads the wait palette, builds the room entities, and only after that uploads the room palette from `g_current_palette_buffer`.
   - Once the wait transition is flushed, `room_setup` rebuilds the room render list in this order: matching enabled regions, room timers, visible object records whose `current_owner_or_room` matches the room, then room `ANIM` records whose `active` or `visible` state is set.
+  - On the player-present branch it then re-adds the stored live player actor from `DAT_000d5bd4`, places it from `g_current_room_entrance`, and seeds exact facing frames `0x3b`, `0x0e`, `0x2c`, and `0x28` for entrance-facing values `0`, `1`, `2`, and `3`.
+  - Before the live room flush it explicitly resets the cursor entity to animation sequence `7`.
+  - The final room palette/upload order is now explicit: after optional room-enter handlers, `room_setup` uploads `g_current_palette_buffer` and then calls `flush_dirty_rects_to_screen`.
+  - `flush_dirty_rects_to_screen` begins by calling `sync_cursor_entity_position(g_cursor_entity)`, so the first cursor sync / animation tick after room construction happens under the room palette rather than before that upload.
   - After the initial black upload, `room_setup` uses `ramp_palette_brightness` at `0x23a30` to fade `g_current_palette_buffer` in from black. The helper steps brightness in `0.1` increments against the shared centisecond clock from `get_elapsed_milliseconds()` while also driving the room dimming path when the room is marked dimmable and `DAY_FLAG` is clear.
 - `upload_palette_to_vga` at `0x22770` writes a caller-supplied RGB palette to the VGA DAC, forces palette index 0 to black, and applies a caller-supplied brightness scalar before clamping each channel to the hardware `0x3f` range.
   - Archive inspection plus the helper body confirm the room/menu `.PAL` resources are already stored as 768-byte 8-bit RGB triplets; the upload path shifts those bytes down to VGA `0..63` before brightness scaling instead of expanding 6-bit source values.
@@ -406,6 +411,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `is_cursor_over_entity_hitmask` at `0x4b5b0` adds the per-pixel transparency test used by the main loop before interaction dispatch.
   - Class `0x16` skips the cursor gate entirely.
   - Classes `0x15`, `0x18`, and `0x19` stop at the rectangle gate and do not require an opaque pixel.
+  - For the normal sprite classes, palette index `0` fails that hitmask test and therefore behaves as transparent.
   - Class `3` bypasses the cursor bounds/mask checks and acts as the room-background fallback entity when nothing higher in the render list matches.
 
 ## Idle Animation Timing
@@ -503,11 +509,16 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `0x10` = left-facing base bank
     - `0x40` = right-facing base bank
     - `0x20` = back-facing base bank
+    - `0x80` is still only bounded as the separate case-1 stance/turn-family gate; monster construction derives it from the ABM frame slot reached through live actor pointer `+0x84`
   - The high nibble of runtime `+0x118e` provides alternate initial stance banks for those same facings:
     - `0x40` = front-facing alternate bank
     - `0x10` = left-facing alternate bank
     - `0x20` = right-facing alternate bank
     - `0x80` = back-facing alternate bank
+  - `update_actor_runtime_state` now also confirms two higher `+0x118c` bits are attack-family compatibility gates rather than locomotion flags:
+    - attacker `0x1000` must be set before attack states `0x17/0x1a` can be chosen
+    - target `0x10000` must be set before attack states `0x18/0x1b` can be chosen
+    - the player combat-avatar base mask `0x00fffff8` keeps both bits set by default
   - `field_70` is the attack-sound trigger frame
   - `field_78` is the walk / footstep-sound trigger frame
   - `saved_enabled`, `runtime_spawned`, `next`
@@ -647,6 +658,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - in the player attack path, `(+0x118c & 4) == 0` stays on the close-range hit-resolution branch, while class-5 attacks with bit `4` set use the separate ranged target-acquisition / ammo-count branch; the chainsaw fuel check is a separate loadout-specific pre-branch case
     - its confirmed low-byte facing-bank bits are `0x08` front, `0x10` left, `0x40` right, and `0x20` back; monster spawn uses those as the default initial stance banks when the matching alternate `+0x118e` high-nibble bank is absent
     - the same low-byte bits are later consumed by the locomotion/turn-family entries rooted at states `4`, `7/0xf`, `8/0xe`, and `0xb`
+    - low-byte bit `0x80` is still only bounded as the separate case-1 stance/turn-family availability gate: state-family `1` refuses its own idle/turn transitions unless that bit is present, and monster spawn derives it from the ABM frame slot exposed through live actor pointer `+0x84`
+    - outside the low byte, `0x1000` and `0x10000` are confirmed close-range attack-picker gates rather than generic locomotion bits: attacker `0x1000` is required before states `0x17/0x1a` can be chosen, while target `0x10000` is required before states `0x18/0x1b` can be chosen; the player combat-avatar base mask keeps both bits set
   - Runtime byte `+0x118d` is an attack / hit-reaction bank-availability byte.
     - bits `0x08`, `0x10`, and `0x20` gate the attack-state pairs `0x16/0x19`, `0x17/0x1a`, and `0x18/0x1b`
     - bits `0x40` and `0x80`, together with `+0x118e & 0x01`, gate the hit-reaction state pairs `0x1c/0x1f`, `0x1d/0x20`, and `0x1e/0x21`
diff --git a/TRACKER.md b/TRACKER.md
index d625365316c..9885ff166f7 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,6 +2,28 @@
 
 ## Last Confirmed Action
 
+- Closed the startup room-loop / player-presence pass.
+  - Confirmed in Ghidra that `run_harvester_main_loop` stores the live player actor before the first `room_setup`, and `room_setup` later re-adds that actor from `DAT_000d5bd4` using the active entrance coordinates/facing instead of constructing a fresh PCROOM-local NPC.
+  - Confirmed `room_setup` seeds exact startup-facing frames `0x3b`, `0x0e`, `0x2c`, and `0x28` for entrance-facing values `0`, `1`, `2`, and `3`.
+  - Confirmed `is_cursor_over_entity_hitmask` treats palette index `0` as transparent for normal sprite classes, which explains the black-backed cursor regression when the port blits raw indexed pixels.
+  - Patched the engine to key runtime-entity rendering on palette index `0`, parse entrance spawn/facing state from `HARVEST.SCR`, spawn the startup player actor from `1:\GRAPHIC\MONSTERS\PC\PC0.ABM`, and hand startup straight into the interactive room loop after quick tips.
+  - Built `build-vscode-harvester-debug/scummvm` successfully and left the Ghidra function counts unchanged.
+- Closed the native startup verification capture with the complete local data set.
+  - Using `/Users/alex/Downloads/Harvester_1996/HARVEST/iso/Harvester`, the local ScummVM run mounted `INDEX.001` / `INDEX.002` / `INDEX.003`, loaded `HARVEST.SCR`, and reached the startup room handoff normally.
+  - Confirmed in `/Users/alex/Temp/harvester_startup_native.log` that the startup palette path now logs `decoded room palette 'GRAPHIC/PAL/PCROOM.PAL' ... idx0=(0,0,56)`, matching the expected raw 8-bit palette bytes rather than the old expanded-blue mismatch.
+  - Confirmed in the same native capture that the cursor reaches `sequence=7 frames=70..79 current=70`, matching the centisecond-timed cursor sequencing expected from the binary.
+  - This removes the local startup data-path blocker; function counts remain unchanged.
+- Closed the startup room-setup ordering fallback pass.
+  - Tried the native ScummVM startup capture first, but the available local data set only contains `HARVEST2.DAT`; `INDEX.001`, `INDEX.002`, `INDEX.003`, and `HARVEST.SCR` are missing, so the engine never reaches the palette / cursor startup path in this environment.
+  - Confirmed in Ghidra that `room_setup` flushes the wait transition while the cursor is hidden, then re-shows the cursor and uploads `WAIT.PAL`.
+  - Confirmed the first live room flush happens in the opposite order: after room construction and optional `on_enter` handlers, `room_setup` uploads `g_current_palette_buffer` and only then calls `flush_dirty_rects_to_screen`.
+  - Confirmed `flush_dirty_rects_to_screen` always begins by calling `sync_cursor_entity_position(g_cursor_entity)`, so the first cursor sync / animation tick after room construction happens under the room palette, not before it; `room_setup` also resets the cursor entity to animation sequence `7` before that final flush.
+  - Added Ghidra comments at `room_setup`, saved `HARVEST.LE`, and left function counts unchanged.
+- Closed the next partial `+0x118c` capability-bit pass.
+  - Confirmed `update_actor_runtime_state` uses the higher `+0x118c` bits `0x1000` and `0x10000` as close-range attack-family compatibility gates rather than as generic locomotion flags: attacker `0x1000` is required before states `0x17/0x1a` can be chosen, while target `0x10000` is required before states `0x18/0x1b` can be chosen.
+  - Confirmed the player combat-avatar base mask `0x00fffff8` keeps both of those higher `+0x118c` gates set by default.
+  - Narrowed low-byte `+0x118c` bit `0x80` to the separate case-1 stance / turn-family gate: state-family `1` refuses its own transitions unless the bit is present, and the monster spawn path derives it from the ABM frame slot reached through live actor pointer `+0x84`.
+  - Added Ghidra comments at `update_actor_runtime_state`, and saved `HARVEST.LE`.
 - Closed the startup palette-format / cursor-animation clock pass.
   - Confirmed by archive inspection that `WAIT.PAL`, `PCROOM.PAL`, and the rest of the indexed room palettes are exact 768-byte resources already storing 8-bit RGB triplets.
   - Confirmed in Ghidra that `upload_palette_to_vga` shifts those palette bytes down to VGA DAC range instead of expanding 6-bit source values, which explains the bad startup colors in the current engine.
@@ -182,10 +204,7 @@
 
 ## Next Suggested Action
 
-- Re-run the startup path with the new logs and verify two concrete points in one capture:
-  - `PCROOM.PAL` should now log raw values like `idx0=(0,0,56)` instead of the bogus expanded blue channel.
-  - The cursor should stay on sequence `7` while advancing frames `70..79` on a centisecond clock rather than racing every render tick.
-- If either behavior still diverges from DOS, take a fresh Ghidra pass through `room_setup` after the wait-transition flush and `flush_dirty_rects_to_screen` / `sync_cursor_entity_position` to compare exact first-frame ordering and palette upload order against the port.
 - Highest-value targets:
-  - continue outward on the remaining partial `+0x118c` semantics, especially the still-unresolved low-byte bit `0x80` and the high attack-picker gates `0x1000` / `0x10000`
-  - if that thread stays cold, revisit the unresolved reciprocal combat-link slot at `+0x11b8` from the confirmed close-range hit block
+  - run the normal startup capture and verify three concrete points in one pass: transparent cursor background, Steve visible in `PCROOM` at the `START` entrance, and preserved hotspot hover/click behavior through the direct quick-tips -> room-loop handoff
+  - if player placement is still off, take a focused Ghidra pass through `set_entity_screen_position` / `rescale_entity_sprite_for_depth` callsites to recover the exact screen-position math before porting movement
+  - port `show_target_ident_text` / `handle_target_interaction` behavior into the room loop so hover text and click dispatch can move off the current prototype overlay
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index 0802f66fc58..f493d78843e 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -38,6 +38,7 @@ static const float kCursorEntityZ = -100.0f;
 static const int kCursorAnimationRate = 10;
 static const int kFramesPerSequence = 10;
 static const uint32 kAnimationClockDivisorMs = 10;
+static const byte kTransparentPaletteIndex = 0;
 
 static uint32 getAnimationClockTicks() {
 	// The original runtime entity animation timer is driven from a centisecond DOS clock.
@@ -50,8 +51,8 @@ static void blitAnimationFrame(Graphics::Screen &screen, const Common::Array<Abm
 		return;
 
 	const AbmFrame &frame = frames[frameIndex];
-	screen.copyRectToSurface(frame.pixels.data(), frame.width, x + frame.xOffset, y + frame.yOffset,
-		frame.width, frame.height);
+	screen.copyRectToSurfaceWithKey(frame.pixels.data(), frame.width,
+		x + frame.xOffset, y + frame.yOffset, frame.width, frame.height, kTransparentPaletteIndex);
 }
 
 static bool decodeAnimationFrame(const byte *source, uint32 sourceSize, bool compressed, Common::Array<byte> &dest) {
@@ -538,6 +539,21 @@ RuntimeEntity *RuntimeEntityManager::spawnSceneAnimationEntity(const Common::Str
 	return entity;
 }
 
+RuntimeEntity *RuntimeEntityManager::spawnSceneActorEntity(const Common::String &name,
+		const Common::String &resourcePath, const Common::Point &position, float z, int initialFrame) {
+	RuntimeEntity *entity = spawnAbmEntityFromResource(name, resourcePath, kRuntimeEntityClassObject,
+		position, z, 0, false, false);
+	if (!entity)
+		return nullptr;
+
+	entity->setAnchorMode(kRuntimeEntityAnchorCentered);
+	entity->setZExtent(3.0f);
+	entity->setHitTestMode(kRuntimeEntityHitTestNone);
+	entity->setCurrentFrame(initialFrame);
+	insertSceneEntity(entity);
+	return entity;
+}
+
 void RuntimeEntityManager::hideCursor() {
 	if (_cursorEntity)
 		_cursorEntity->setVisible(false);
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index f4415267a66..961bae81413 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -152,6 +152,8 @@ public:
 	RuntimeEntity *spawnSceneAnimationEntity(const Common::String &name, const Common::String &resourcePath,
 		const Common::Point &position, float z, int animationRate, bool active, bool visible, bool looping,
 		bool playBackwards, bool pingPong);
+	RuntimeEntity *spawnSceneActorEntity(const Common::String &name, const Common::String &resourcePath,
+		const Common::Point &position, float z, int initialFrame);
 	RuntimeEntity *getCursorEntity() const { return _cursorEntity; }
 	void hideCursor();
 	void showCursor();
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 7befe108f72..a3dd62bde55 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -59,6 +59,8 @@ static const int kQuickTipTextWidth = 280;
 static const int kMenuStartY = 100;
 static const int kMenuLineSpacing = 28;
 static const int kCursorSequence7 = 7;
+static const char *const kPlayerActorEntityName = "PLAYER";
+static const char *const kPlayerActorResourcePath = "1:/GRAPHIC/MONSTERS/PC/PC0.ABM";
 static const uint32 kPaletteFadeTickMs = 4;
 static const float kPaletteFadeStep = 0.1f;
 static const float kPaletteBrightnessBlack = 0.0f;
@@ -77,6 +79,21 @@ struct StartupRoomSceneResources {
 	float targetPaletteBrightness = 1.0f;
 };
 
+static int resolvePlayerFacingFrame(int facing) {
+	switch (facing) {
+	case 0:
+		return 0x3b;
+	case 1:
+		return 0x0e;
+	case 2:
+		return 0x2c;
+	case 3:
+		return 0x28;
+	default:
+		return 0x0e;
+	}
+}
+
 static void getPaletteByteRange(const byte *palette, byte &minValue, byte &maxValue) {
 	minValue = 255;
 	maxValue = 0;
@@ -359,7 +376,7 @@ Common::Error StartupFlow::run() {
 	if (error.getCode() != Common::kNoError)
 		return error;
 
-	return runMainMenuStub();
+	return runRoomLoop("START");
 }
 
 bool StartupFlow::loadQuickTips() {
@@ -439,7 +456,7 @@ Common::Error StartupFlow::runQuickTips() {
 
 	Graphics::Screen *screen = _engine.getScreen();
 	if (screen) {
-		if (!populateRoomSceneEntities(scene.sceneObjects, scene.sceneAnimations))
+		if (!populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
 			return Common::kReadingFailed;
 
 		logScenePaletteSummary("quick tips scene palette", scene, kPaletteBrightnessBlack);
@@ -550,7 +567,7 @@ Common::Error StartupFlow::runMainMenuStub() {
 						return Common::kNoError;
 
 					if (_menuItems[selectedItem].equalsIgnoreCase("NEW GAME")) {
-						Common::Error roomError = runRoomSetupStub("START");
+						Common::Error roomError = runRoomLoop("START");
 						if (roomError.getCode() == Common::kReadingFailed) {
 							statusMessage = "Unable to resolve START room setup from HARVEST.SCR.";
 							needsRedraw = true;
@@ -579,7 +596,7 @@ Common::Error StartupFlow::runMainMenuStub() {
 					return Common::kNoError;
 
 				if (_menuItems[selectedItem].equalsIgnoreCase("NEW GAME")) {
-					Common::Error roomError = runRoomSetupStub("START");
+					Common::Error roomError = runRoomLoop("START");
 					if (roomError.getCode() == Common::kReadingFailed) {
 						statusMessage = "Unable to resolve START room setup from HARVEST.SCR.";
 						needsRedraw = true;
@@ -610,7 +627,7 @@ Common::Error StartupFlow::runMainMenuStub() {
 	return Common::kNoError;
 }
 
-Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName) {
+Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 	StartupRoomSetupState state;
 	if (!_engine.getStartupScript()->resolveRoomSetupState(entranceName, state, *_engine.getResources()))
 		return Common::kReadingFailed;
@@ -630,7 +647,7 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 	if (!screen || !art || !titleFont || !bodyFont)
 		return Common::kNoError;
 
-	if (!populateRoomSceneEntities(scene.sceneObjects, scene.sceneAnimations))
+	if (!populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
 		return Common::kReadingFailed;
 
 	logScenePaletteSummary("room setup stub palette", scene, kPaletteBrightnessBlack);
@@ -646,15 +663,14 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 	resetCursorAnimationSequence();
 
 	Common::String statusMessage = Common::String::format(
-		"Resolved %s -> %s using %s and %s.",
-		entranceName.c_str(), state.roomName.c_str(), state.palettePath.c_str(), state.backgroundPath.c_str());
+		"Entered %s via %s. Move the cursor over active hotspots and click to inspect or follow startup room transitions.",
+		state.roomName.c_str(), entranceName.c_str());
 	if (!state.musicPath.empty())
 		statusMessage += Common::String::format(" Music: %s.", state.musicPath.c_str());
 	if (!scene.sceneObjects.empty())
 		statusMessage += Common::String::format(" Visible scene objects: %u.", (uint)scene.sceneObjects.size());
 	if (!scene.sceneAnimations.empty())
 		statusMessage += Common::String::format(" Room anims: %u.", (uint)scene.sceneAnimations.size());
-	statusMessage += " Click an active hotspot to follow its startup command chain.";
 	const Common::String baseStatusMessage = statusMessage;
 	StartupResolvedText inspectText;
 	Common::String inspectObjectLabel;
@@ -666,38 +682,48 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
 			drawRoomScene(_engine, *screen, scene, scene.targetPaletteBrightness);
-
-				const Common::Rect panel(72, 336, 568, 468);
-				screen->fillRect(panel, kPanelFillColor);
-				drawShadowedString(*screen, *titleFont, Common::String::format("Room Setup Stub: %s", state.roomName.c_str()),
-					panel.left, 348, panel.width(), kTextColorNormal, Graphics::kTextAlignCenter);
-				Common::String hoverMessage;
-				Common::String footerMessage = "Press Enter or Escape to return to the menu stub.";
-				if (showingInspectText) {
-					hoverMessage = inspectText.value;
-					footerMessage = inspectCanDismiss ? "Click, Enter, or Escape to dismiss this inspect text."
-						: "Release the mouse button, then click again to dismiss this inspect text.";
-					drawShadowedString(*screen, *bodyFont, Common::String::format("Inspect: %s", inspectObjectLabel.c_str()),
-						panel.left, 372, panel.width(), kTextColorHover, Graphics::kTextAlignCenter);
-				} else {
-					const StartupObjectRecord *hoveredObject = findRoomObjectAtPoint(_engine, scene.sceneObjects, _mousePos);
-					if (hoveredObject) {
-						hoverMessage = _engine.getStartupScript()->resolveObjectLabel(*hoveredObject);
-						if (!hoveredObject->actionTag.empty())
-							hoverMessage += " Click to follow the scripted startup interaction.";
-						else if (!_engine.getStartupScript()->resolveObjectInspectText(*hoveredObject, inspectText))
-							inspectText = StartupResolvedText();
-						else
-							hoverMessage += " Click to inspect.";
-					}
-				}
-				if (hoverMessage.empty())
-					hoverMessage = statusMessage;
-
-			drawWrappedShadowedText(*screen, *bodyFont, hoverMessage, panel.left + 24, 386, panel.width() - 48,
-				kTextColorNormal);
-			drawShadowedString(*screen, *bodyFont, footerMessage,
-				panel.left, 430, panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
+			const StartupObjectRecord *hoveredObject = showingInspectText ? nullptr :
+				findRoomObjectAtPoint(_engine, scene.sceneObjects, _mousePos);
+			Common::String hoverMessage;
+			if (hoveredObject) {
+				hoverMessage = _engine.getStartupScript()->resolveObjectLabel(*hoveredObject);
+				if (!hoveredObject->actionTag.empty())
+					hoverMessage += " Click to follow the scripted startup interaction.";
+				else if (!_engine.getStartupScript()->resolveObjectInspectText(*hoveredObject, inspectText))
+					inspectText = StartupResolvedText();
+				else
+					hoverMessage += " Click to inspect.";
+			}
+			if (hoverMessage.empty())
+				hoverMessage = statusMessage;
+
+			const Common::Rect headerPanel(16, 16, 624, 84);
+			screen->fillRect(headerPanel, kPanelFillColor);
+			drawShadowedString(*screen, *titleFont, state.roomName,
+				headerPanel.left + 16, headerPanel.top + 8, headerPanel.width() - 32,
+				kTextColorNormal, Graphics::kTextAlignCenter);
+			drawWrappedShadowedText(*screen, *bodyFont, hoverMessage,
+				headerPanel.left + 16, headerPanel.top + 34, headerPanel.width() - 32, kTextColorNormal);
+
+			if (showingInspectText) {
+				const Common::Rect inspectPanel(56, 338, 584, 468);
+				const Common::String footerMessage = inspectCanDismiss
+					? "Click, Enter, Space, or Escape to dismiss."
+					: "Release the mouse button, then click again to dismiss.";
+				screen->fillRect(inspectPanel, kPanelFillColor);
+				drawShadowedString(*screen, *titleFont,
+					Common::String::format("Inspect: %s", inspectObjectLabel.c_str()),
+					inspectPanel.left, inspectPanel.top + 10, inspectPanel.width(),
+					kTextColorHover, Graphics::kTextAlignCenter);
+				drawWrappedShadowedText(*screen, *bodyFont, inspectText.value,
+					inspectPanel.left + 20, inspectPanel.top + 40, inspectPanel.width() - 40, kTextColorNormal);
+				drawShadowedString(*screen, *bodyFont, footerMessage,
+					inspectPanel.left, inspectPanel.bottom - 20, inspectPanel.width(),
+					kTextColorDim, Graphics::kTextAlignCenter);
+			} else {
+				drawShadowedString(*screen, *bodyFont, "Esc exits this prototype room loop.",
+					0, 458, 640, kTextColorDim, Graphics::kTextAlignCenter);
+			}
 
 			if (_engine.getRuntimeEntities())
 				_engine.getRuntimeEntities()->drawCursor(*screen);
@@ -767,9 +793,9 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 					needsRedraw = true;
 
 					if (!interaction.nextRoomName.empty()) {
-						Common::Error roomError = runRoomSetupStub(interaction.nextRoomName);
+						Common::Error roomError = runRoomLoop(interaction.nextRoomName);
 						if (roomError.getCode() == Common::kReadingFailed) {
-							(void)populateRoomSceneEntities(scene.sceneObjects, scene.sceneAnimations);
+							(void)populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations);
 							resetCursorAnimationSequence();
 							statusMessage = Common::String::format("Unable to resolve closeup room '%s'.",
 								interaction.nextRoomName.c_str());
@@ -777,7 +803,7 @@ Common::Error StartupFlow::runRoomSetupStub(const Common::String &entranceName)
 						} else if (roomError.getCode() != Common::kNoError) {
 							return roomError;
 						} else {
-							(void)populateRoomSceneEntities(scene.sceneObjects, scene.sceneAnimations);
+							(void)populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations);
 							resetCursorAnimationSequence();
 							statusMessage = Common::String::format("Returned from %s.", interaction.nextRoomName.c_str());
 							needsRedraw = true;
@@ -832,7 +858,8 @@ bool StartupFlow::ensureCursorEntity() {
 	return runtimeEntities->spawnCursorEntity(_mousePos) != nullptr;
 }
 
-bool StartupFlow::populateRoomSceneEntities(const Common::Array<StartupObjectRecord> &drawableObjects,
+bool StartupFlow::populateRoomSceneEntities(const StartupRoomSetupState &state,
+		const Common::Array<StartupObjectRecord> &drawableObjects,
 		const Common::Array<StartupAnimRecord> &drawableAnimations) {
 	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
 	if (!runtimeEntities)
@@ -869,6 +896,21 @@ bool StartupFlow::populateRoomSceneEntities(const Common::Array<StartupObjectRec
 				anim.animName.c_str(), anim.resourcePath.c_str());
 		}
 	}
+	if (state.hasEntrance) {
+		const int playerFrame = resolvePlayerFacingFrame(state.playerFacing);
+		RuntimeEntity *player = runtimeEntities->spawnSceneActorEntity(kPlayerActorEntityName,
+			kPlayerActorResourcePath, Common::Point(state.playerSpawnX, state.playerSpawnY),
+			(float)state.playerSpawnZ, playerFrame);
+		if (!player) {
+			debug(1, "Harvester: unable to spawn startup player actor from '%s'", kPlayerActorResourcePath);
+		} else {
+			debugC(1, kDebugGeneral,
+				"Harvester: spawned startup player actor '%s' entrance='%s' pos=(%d,%d,%d) facing=%d frame=%d",
+				kPlayerActorResourcePath, state.entranceName.c_str(),
+				state.playerSpawnX, state.playerSpawnY, state.playerSpawnZ,
+				state.playerFacing, playerFrame);
+		}
+	}
 
 	return true;
 }
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/startup_flow.h
index 186d41daadf..5a0515485db 100644
--- a/engines/harvester/startup_flow.h
+++ b/engines/harvester/startup_flow.h
@@ -48,9 +48,10 @@ private:
 	bool loadMenuItems();
 	Common::Error runQuickTips();
 	Common::Error runMainMenuStub();
-	Common::Error runRoomSetupStub(const Common::String &entranceName);
+	Common::Error runRoomLoop(const Common::String &entranceName);
 	bool ensureCursorEntity();
-	bool populateRoomSceneEntities(const Common::Array<StartupObjectRecord> &drawableObjects,
+	bool populateRoomSceneEntities(const StartupRoomSetupState &state,
+		const Common::Array<StartupObjectRecord> &drawableObjects,
 		const Common::Array<StartupAnimRecord> &drawableAnimations);
 	Common::Error beginRoomSetupTransition();
 	Common::Error fadeInRoomScene(const byte *palette, float targetBrightness);
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 87e5e8eb103..c688f3aa763 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -26,6 +26,7 @@
 #include "common/debug.h"
 #include "common/formats/ini-file.h"
 #include "common/memstream.h"
+#include "harvester/detection.h"
 #include "harvester/resources.h"
 
 namespace Harvester {
@@ -59,6 +60,19 @@ static Common::String normalizeInteractionLabel(const Common::String &value) {
 	return label;
 }
 
+static int parseEntranceFacing(const Common::String &direction) {
+	if (direction.equalsIgnoreCase("FRONT"))
+		return 0;
+	if (direction.equalsIgnoreCase("LEFT"))
+		return 1;
+	if (direction.equalsIgnoreCase("RIGHT"))
+		return 2;
+	if (direction.equalsIgnoreCase("BACK"))
+		return 3;
+
+	return -1;
+}
+
 static void tokenizeTownScriptLine(const Common::String &line, Common::Array<Common::String> &tokens) {
 	tokens.clear();
 
@@ -240,7 +254,13 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 				return;
 
 			StartupEntranceRecord entrance;
+			if (tagIndex >= 3) {
+				entrance.posX = atoi(tokens[0].c_str());
+				entrance.posY = atoi(tokens[1].c_str());
+				entrance.posZ = atoi(tokens[2].c_str());
+			}
 			entrance.direction = tokens[tagIndex + 1];
+			entrance.facing = parseEntranceFacing(entrance.direction);
 			entrance.roomName = tokens[tagIndex + 2];
 			entrance.entranceName = tokens[tagIndex + 3];
 			if (!entrance.roomName.empty() && !entrance.entranceName.empty())
@@ -390,6 +410,14 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 	state.roomName = room->roomName;
 	state.palettePath = room->palettePath;
 	state.backgroundPath = background->spritePath;
+	if (entrance) {
+		state.entranceName = entrance->entranceName;
+		state.hasEntrance = true;
+		state.playerSpawnX = entrance->posX;
+		state.playerSpawnY = entrance->posY;
+		state.playerSpawnZ = entrance->posZ;
+		state.playerFacing = entrance->facing;
+	}
 	for (const StartupObjectRecord &object : _objects) {
 		if (object.currentOwnerOrRoom.equalsIgnoreCase(room->roomName))
 			state.roomObjects.push_back(object);
@@ -504,6 +532,14 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 		? kDimmedPaletteBrightness
 		: kDefaultPaletteBrightness;
 
+	debugC(1, kDebugGeneral,
+		"Harvester: resolveRoomSetupState('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u",
+		entranceName.c_str(), state.roomName.c_str(), state.entranceName.c_str(),
+		state.playerSpawnX, state.playerSpawnY, state.playerSpawnZ, state.playerFacing,
+		state.palettePath.c_str(), state.backgroundPath.c_str(), state.musicPath.c_str(),
+		(double)state.paletteBrightness, (uint)state.roomObjects.size(),
+		(uint)state.activeObjects.size(), (uint)state.roomAnimations.size());
+
 	return true;
 }
 
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index a4ced84f0fd..02a48ad5a66 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -30,6 +30,10 @@ namespace Harvester {
 class ResourceManager;
 
 struct StartupEntranceRecord {
+	int posX = 0;
+	int posY = 0;
+	int posZ = 0;
+	int facing = -1;
 	Common::String direction;
 	Common::String roomName;
 	Common::String entranceName;
@@ -110,10 +114,16 @@ struct StartupTextRecord {
 };
 
 struct StartupRoomSetupState {
+	Common::String entranceName;
 	Common::String roomName;
 	Common::String palettePath;
 	Common::String backgroundPath;
 	Common::String musicPath;
+	bool hasEntrance = false;
+	int playerSpawnX = 0;
+	int playerSpawnY = 0;
+	int playerSpawnZ = 0;
+	int playerFacing = -1;
 	float paletteBrightness = 1.0f;
 	Common::Array<StartupObjectRecord> activeObjects;
 	Common::Array<StartupObjectRecord> roomObjects;


Commit: 39b9b45b634f00e90e182fec6a39a46dbcd632c9
    https://github.com/scummvm/scummvm/commit/39b9b45b634f00e90e182fec6a39a46dbcd632c9
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:39+02:00

Commit Message:
HARVESTER: Add startup room probe diagnostics

Changed paths:
    TRACKER.md
    engines/harvester/harvester.cpp
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h
    engines/harvester/startup_flow.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 9885ff166f7..df98147fd64 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,6 +2,13 @@
 
 ## Last Confirmed Action
 
+- Closed the startup room-loop verification pass with a bounded local probe.
+  - Added opt-in Harvester debug probes behind `harvester_debug_skip_startup_movies` and `harvester_debug_probe_startup_room` so the local capture can skip the intro FST trio and log room-loop facts without changing the default startup path.
+  - Using `/Users/alex/Temp/harvester_overlay_ugy21l06` with `QUICK_TIPS=OFF` plus `/Users/alex/Temp/harvester_probe_scummvm.ini`, the bounded run in `/Users/alex/Temp/harvester_startup_probe.log` now reaches `resolveRoomSetupState('START') -> room='PCROOM' entrance='START' spawn=(360,405,15) facing=1`.
+  - Confirmed the startup cursor reaches `sequence=7 frames=70..79 current=70`, and the runtime cursor blit preserves all `232` transparent pixels in frame `71`, matching the palette-index-`0` transparency fix rather than the old black-backed cursor path.
+  - Confirmed Steve is present in `PCROOM` at the `START` entrance: the probe logs `spawned startup player actor ... pos=(360,405,15) facing=1 frame=14`, and the live entity check reports `visible=1`, `intersects_screen=1`, and `opaque_pixels=3143`.
+  - Confirmed the direct room-loop handoff resolves active startup interactions: the probe hits `PC_DRESR1` at `(142,339)`, resolves label `dresser`, keeps action tag `COM1A`, and reports a supported interaction into closeup room `PCDRWR`.
+  - Rebuilt `build-vscode-harvester-debug/scummvm` successfully; Ghidra function counts remain unchanged.
 - Closed the startup room-loop / player-presence pass.
   - Confirmed in Ghidra that `run_harvester_main_loop` stores the live player actor before the first `room_setup`, and `room_setup` later re-adds that actor from `DAT_000d5bd4` using the active entrance coordinates/facing instead of constructing a fresh PCROOM-local NPC.
   - Confirmed `room_setup` seeds exact startup-facing frames `0x3b`, `0x0e`, `0x2c`, and `0x28` for entrance-facing values `0`, `1`, `2`, and `3`.
@@ -205,6 +212,6 @@
 ## Next Suggested Action
 
 - Highest-value targets:
-  - run the normal startup capture and verify three concrete points in one pass: transparent cursor background, Steve visible in `PCROOM` at the `START` entrance, and preserved hotspot hover/click behavior through the direct quick-tips -> room-loop handoff
-  - if player placement is still off, take a focused Ghidra pass through `set_entity_screen_position` / `rescale_entity_sprite_for_depth` callsites to recover the exact screen-position math before porting movement
-  - port `show_target_ident_text` / `handle_target_interaction` behavior into the room loop so hover text and click dispatch can move off the current prototype overlay
+  - port `show_target_ident_text` / `handle_target_interaction` behavior into the room loop so the verified `PCROOM` hover/click path can move off the current prototype overlay text
+  - once that path is in place, rerun the bounded startup probe and a normal startup capture to confirm the dresser `COM1A -> PCDRWR` interaction follows the same status/message flow as the original room loop
+  - if player placement regresses during that port, take a focused Ghidra pass through `set_entity_screen_position` / `rescale_entity_sprite_for_depth` callsites to recover the exact screen-position math before touching movement
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 208aef92e51..132339cb7dc 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -39,6 +39,15 @@
 
 namespace Harvester {
 
+namespace {
+
+static bool shouldSkipStartupMoviesForDebug() {
+	return ConfMan.hasKey("harvester_debug_skip_startup_movies") &&
+		ConfMan.getBool("harvester_debug_skip_startup_movies");
+}
+
+} // End of anonymous namespace
+
 HarvesterEngine *g_engine = nullptr;
 
 HarvesterEngine::HarvesterEngine(OSystem *syst, const ADGameDescription *gameDesc) : Engine(syst),
@@ -124,10 +133,14 @@ Common::Error HarvesterEngine::run() {
 	// Set the engine's debugger console
 	setDebugger(new Console());
 
-	FstPlayer fstPlayer(*this);
-	for (const char *path : kIntroPaths) {
-		if (!fstPlayer.play(path))
-			return Common::kReadingFailed;
+	if (shouldSkipStartupMoviesForDebug()) {
+		debugC(1, kDebugGeneral, "Harvester: debug skip enabled for startup intro FST playback");
+	} else {
+		FstPlayer fstPlayer(*this);
+		for (const char *path : kIntroPaths) {
+			if (!fstPlayer.play(path))
+				return Common::kReadingFailed;
+		}
 	}
 
 	// The original executable switches back to the gameplay UI surface after INTROFIN.FST.
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index f493d78843e..b9a60d7a616 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -24,6 +24,7 @@
 #include "common/debug.h"
 #include "common/endian.h"
 #include "common/system.h"
+#include "graphics/blit.h"
 #include "graphics/screen.h"
 #include "harvester/detection.h"
 #include "harvester/resources.h"
@@ -51,8 +52,35 @@ static void blitAnimationFrame(Graphics::Screen &screen, const Common::Array<Abm
 		return;
 
 	const AbmFrame &frame = frames[frameIndex];
-	screen.copyRectToSurfaceWithKey(frame.pixels.data(), frame.width,
-		x + frame.xOffset, y + frame.yOffset, frame.width, frame.height, kTransparentPaletteIndex);
+	int destX = x + frame.xOffset;
+	int destY = y + frame.yOffset;
+	int srcX = 0;
+	int srcY = 0;
+	int width = (int)frame.width;
+	int height = (int)frame.height;
+
+	if (destX < 0) {
+		srcX = -destX;
+		width += destX;
+		destX = 0;
+	}
+	if (destY < 0) {
+		srcY = -destY;
+		height += destY;
+		destY = 0;
+	}
+	if (destX >= screen.w || destY >= screen.h || width <= 0 || height <= 0)
+		return;
+
+	width = MIN<int>(width, screen.w - destX);
+	height = MIN<int>(height, screen.h - destY);
+	if (width <= 0 || height <= 0)
+		return;
+
+	const byte *src = frame.pixels.data() + srcY * frame.width + srcX;
+	byte *dst = (byte *)screen.getBasePtr(destX, destY);
+	Graphics::keyBlit(dst, src, screen.pitch, frame.width, width, height,
+		screen.format.bytesPerPixel, kTransparentPaletteIndex);
 }
 
 static bool decodeAnimationFrame(const byte *source, uint32 sourceSize, bool compressed, Common::Array<byte> &dest) {
@@ -274,6 +302,10 @@ Common::Point RuntimeEntity::getDrawOrigin() const {
 	return Common::Point(drawX, drawY);
 }
 
+Common::Rect RuntimeEntity::getScreenRect() const {
+	return getFrameRect();
+}
+
 void RuntimeEntity::draw(Graphics::Screen &screen) const {
 	if (!_visible || !_drawEnabled || _currentFrame < 0)
 		return;
@@ -370,6 +402,38 @@ bool RuntimeEntity::overlapsEntity(const RuntimeEntity &other) const {
 	return false;
 }
 
+bool RuntimeEntity::measureCurrentFrameTransparency(uint32 &framePixels, uint32 &transparentPixels,
+		uint32 &preservedPixels) const {
+	framePixels = 0;
+	transparentPixels = 0;
+	preservedPixels = 0;
+
+	if (!hasOpaqueFrame())
+		return false;
+
+	const AbmFrame &frame = _frames[(uint)_currentFrame];
+	framePixels = frame.width * frame.height;
+	Common::Array<byte> opaqueMask;
+	opaqueMask.resize(framePixels);
+	for (uint32 i = 0; i < framePixels; ++i)
+		opaqueMask[i] = frame.pixels[i] == kTransparentPaletteIndex ? 0 : 1;
+
+	Common::Array<byte> probeSurface;
+	probeSurface.resize(framePixels);
+	memset(probeSurface.data(), 0x7f, probeSurface.size());
+	Graphics::keyBlit(probeSurface.data(), opaqueMask.data(), frame.width, frame.width,
+		frame.width, frame.height, 1, kTransparentPaletteIndex);
+
+	for (uint32 i = 0; i < framePixels; ++i) {
+		if (frame.pixels[i] == kTransparentPaletteIndex)
+			++transparentPixels;
+		if (probeSurface[i] == 0x7f)
+			++preservedPixels;
+	}
+
+	return true;
+}
+
 void RuntimeEntity::advanceAnimationFrame(int directive) {
 	if (_frames.empty())
 		return;
@@ -605,6 +669,15 @@ const RuntimeEntity *RuntimeEntityManager::findTopSceneEntityAt(const Common::Po
 	return nullptr;
 }
 
+const RuntimeEntity *RuntimeEntityManager::findSceneEntityByName(const Common::String &name) const {
+	for (const RuntimeEntity *entity : _sceneEntities) {
+		if (entity->getName().equalsIgnoreCase(name))
+			return entity;
+	}
+
+	return nullptr;
+}
+
 void RuntimeEntityManager::insertSceneEntity(RuntimeEntity *entity) {
 	uint insertIndex = 0;
 	while (insertIndex < _sceneEntities.size() && _sceneEntities[insertIndex]->getZ() >= entity->getZ())
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index 961bae81413..aa45ce210f9 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -93,12 +93,15 @@ public:
 	void setHitTestMode(RuntimeEntityHitTestMode mode) { _hitTestMode = mode; }
 	int getBoundsWidth() const { return _boundsWidth; }
 	int getBoundsHeight() const { return _boundsHeight; }
+	Common::Rect getScreenRect() const;
 
 	bool hasFrames() const { return !_frames.empty(); }
 	bool tickVisualState(uint32 now);
 	void draw(Graphics::Screen &screen) const;
 	bool hitTest(const Common::Point &point) const;
 	bool overlapsEntity(const RuntimeEntity &other) const;
+	bool measureCurrentFrameTransparency(uint32 &framePixels, uint32 &transparentPixels,
+		uint32 &preservedPixels) const;
 
 private:
 	Common::Point getDrawOrigin() const;
@@ -162,6 +165,7 @@ public:
 	void drawSceneEntities(Graphics::Screen &screen) const;
 	void drawCursor(Graphics::Screen &screen) const;
 	const RuntimeEntity *findTopSceneEntityAt(const Common::Point &point, int classIdFilter = -1) const;
+	const RuntimeEntity *findSceneEntityByName(const Common::String &name) const;
 
 private:
 	void insertSceneEntity(RuntimeEntity *entity);
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index a3dd62bde55..a5c0a77bf87 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -21,6 +21,7 @@
 
 #include "harvester/startup_flow.h"
 
+#include "common/config-manager.h"
 #include "common/debug.h"
 #include "common/endian.h"
 #include "common/events.h"
@@ -277,6 +278,11 @@ static bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceM
 	return true;
 }
 
+static bool shouldRunStartupRoomProbe() {
+	return ConfMan.hasKey("harvester_debug_probe_startup_room") &&
+		ConfMan.getBool("harvester_debug_probe_startup_room");
+}
+
 static void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const StartupRoomSceneResources &scene,
 		float brightness) {
 	setScaledPalette(screen, scene.palette, brightness);
@@ -308,6 +314,106 @@ static const StartupObjectRecord *findRoomObjectAtPoint(HarvesterEngine &engine,
 	return findSceneObjectByName(sceneObjects, entity->getName());
 }
 
+static bool findRoomObjectProbePoint(HarvesterEngine &engine, const Common::Array<StartupObjectRecord> &sceneObjects,
+		const StartupObjectRecord &object, Common::Point &probePoint) {
+	const Common::Rect bounds = getHotspotBounds(object);
+	if (bounds.isEmpty())
+		return false;
+
+	const Common::Point center((bounds.left + bounds.right - 1) / 2, (bounds.top + bounds.bottom - 1) / 2);
+	const Common::Point corners[] = {
+		center,
+		Common::Point(bounds.left, bounds.top),
+		Common::Point(bounds.right - 1, bounds.top),
+		Common::Point(bounds.left, bounds.bottom - 1),
+		Common::Point(bounds.right - 1, bounds.bottom - 1)
+	};
+	for (const Common::Point &candidate : corners) {
+		const StartupObjectRecord *hit = findRoomObjectAtPoint(engine, sceneObjects, candidate);
+		if (hit && hit->objectName.equalsIgnoreCase(object.objectName)) {
+			probePoint = candidate;
+			return true;
+		}
+	}
+
+	const int xStep = MAX<int>(1, bounds.width() / 6);
+	const int yStep = MAX<int>(1, bounds.height() / 6);
+	for (int y = bounds.top; y < bounds.bottom; y += yStep) {
+		for (int x = bounds.left; x < bounds.right; x += xStep) {
+			const Common::Point candidate(x, y);
+			const StartupObjectRecord *hit = findRoomObjectAtPoint(engine, sceneObjects, candidate);
+			if (hit && hit->objectName.equalsIgnoreCase(object.objectName)) {
+				probePoint = candidate;
+				return true;
+			}
+		}
+	}
+
+	return false;
+}
+
+static void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResources &scene,
+		const Common::String &entranceName, Common::Point &mousePos) {
+	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
+	StartupScript *startupScript = engine.getStartupScript();
+	if (!runtimeEntities || !startupScript)
+		return;
+
+	if (const RuntimeEntity *cursor = runtimeEntities->getCursorEntity()) {
+		uint32 framePixels = 0;
+		uint32 transparentPixels = 0;
+		uint32 preservedPixels = 0;
+		if (cursor->measureCurrentFrameTransparency(framePixels, transparentPixels, preservedPixels)) {
+			debugC(1, kDebugGeneral,
+				"Harvester: startup probe cursor sequence=%d frame=%d pixels=%u transparent_pixels=%u preserved_pixels=%u",
+				cursor->getAnimationSequence(), cursor->getCurrentFrame(), framePixels, transparentPixels, preservedPixels);
+		}
+	}
+
+	if (const RuntimeEntity *player = runtimeEntities->findSceneEntityByName(kPlayerActorEntityName)) {
+		uint32 framePixels = 0;
+		uint32 transparentPixels = 0;
+		uint32 preservedPixels = 0;
+		(void)player->measureCurrentFrameTransparency(framePixels, transparentPixels, preservedPixels);
+		const Common::Rect playerRect = player->getScreenRect();
+		const Common::Rect screenRect(0, 0, 640, 480);
+		debugC(1, kDebugGeneral,
+			"Harvester: startup probe player room='%s' entrance='%s' frame=%d rect=(%d,%d)-(%d,%d) visible=%d intersects_screen=%d opaque_pixels=%u",
+			scene.state.roomName.c_str(), entranceName.c_str(), player->getCurrentFrame(),
+			playerRect.left, playerRect.top, playerRect.right, playerRect.bottom,
+			player->isVisible(), playerRect.intersects(screenRect), framePixels - transparentPixels);
+	}
+
+	for (const StartupObjectRecord &object : scene.sceneObjects) {
+		if (getHotspotBounds(object).isEmpty())
+			continue;
+
+		Common::Point probePoint;
+		if (!findRoomObjectProbePoint(engine, scene.sceneObjects, object, probePoint))
+			continue;
+
+		const StartupObjectRecord *hoveredObject = findRoomObjectAtPoint(engine, scene.sceneObjects, probePoint);
+		if (!hoveredObject)
+			continue;
+
+		mousePos = probePoint;
+		const Common::String objectLabel = startupScript->resolveObjectLabel(*hoveredObject);
+		StartupInteractionResult interaction;
+		StartupResolvedText inspectText;
+		const bool hasInteraction = startupScript->resolveObjectInteraction(*hoveredObject, interaction);
+		const bool hasInspectText = startupScript->resolveObjectInspectText(*hoveredObject, inspectText);
+		debugC(1, kDebugGeneral,
+			"Harvester: startup probe hotspot room='%s' object='%s' point=(%d,%d) label='%s' action_tag='%s' interaction=%d next_room='%s' inspect=%d",
+			scene.state.roomName.c_str(), hoveredObject->objectName.c_str(), probePoint.x, probePoint.y,
+			objectLabel.c_str(), hoveredObject->actionTag.c_str(), hasInteraction,
+			interaction.nextRoomName.c_str(), hasInspectText);
+		return;
+	}
+
+	debugC(1, kDebugGeneral, "Harvester: startup probe found no interactive hotspot in room '%s'",
+		scene.state.roomName.c_str());
+}
+
 static Common::String trimAsciiLine(const Common::String &value) {
 	uint start = 0;
 	uint end = value.size();
@@ -679,6 +785,9 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 	bool needsRedraw = true;
 	Graphics::FrameLimiter limiter(g_system, 60);
 
+	if (shouldRunStartupRoomProbe())
+		logStartupRoomProbe(_engine, scene, entranceName, _mousePos);
+
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
 			drawRoomScene(_engine, *screen, scene, scene.targetPaletteBrightness);


Commit: 7b843cb624cd638e7edd1e86ae30e8dd6c11d184
    https://github.com/scummvm/scummvm/commit/7b843cb624cd638e7edd1e86ae30e8dd6c11d184
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:40+02:00

Commit Message:
HARVESTER: Port startup ident textbox modal

Changed paths:
    TRACKER.md
    engines/harvester/startup_art.h
    engines/harvester/startup_flow.cpp


diff --git a/TRACKER.md b/TRACKER.md
index df98147fd64..9cda8abd7a8 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,6 +2,32 @@
 
 ## Last Confirmed Action
 
+- Closed the startup IDENT textbox modal pass against the native main-loop helpers.
+  - Traced the native caller sites in `run_harvester_main_loop`: click interaction still dispatches through `handle_target_interaction` at `0x6f9b3`, while the modal inspect helper `show_target_ident_text` is reached from `0x70c0f` and `0x7189e` after the current target has been resolved and gated on its "ident shown" byte.
+  - Exposed the already loaded startup textbox bitmaps in `StartupArt` and replaced the room-loop placeholder inspect panel with the confirmed IDENT textbox path:
+    - `BOX1`..`BOX4` now map to `TEXTBOX1.BM`..`TEXTBOX4.BM`
+    - the popup uses the native shared panel position `(85,177)`
+    - IDENT text is rendered with the native `+10,+5` inset and color `0xd3`
+  - Tightened the room-loop modal dismissal path to the confirmed helper behavior: the first click must be released before dismissal is allowed, and once shown the popup dismisses on a fresh click or `Esc` instead of the old `Enter` / `Space` placeholder behavior.
+  - Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
+  - The bounded capture in `/Users/alex/Temp/harvester_startup_probe_modal.log` still reaches `START -> PCROOM`, keeps the corrected startup player placement `screen_base=(182,208)` / `rect=(346,257)-(375,405)`, and still resolves `PC_DRESR1` with `prompt='Examine dresser'`, `cursor_sequence=1`, `action_tag='COM1A'`, and `next_room='PCDRWR'`.
+  - The no-probe capture in `/Users/alex/Temp/harvester_startup_noprobe_modal.log` still reaches the same startup handoff and advances the cursor to sequence `7`, so the textbox-modal port did not regress the default room entry.
+  - Live click replay for the modal textbox itself is still unverified; the current confirmation is from the native control-flow trace, the script/resource mapping (`PC_DRESR1_LTEXT -> BOX1`), and the successful local rebuild/captures rather than from an automated input recording.
+  - Ghidra function counts remain unchanged: `774` total functions, `423` named/documented, `351` still `FUN_*`.
+- Closed the native startup actor world-to-screen / depth-placement port.
+  - Extended the startup room parser to carry the native room depth fields (`min_z`, `max_z`, `max_z_screen_y`, `min_z_screen_y`, `full_scale_z`, `max_z_scale_percent`, and derived `perspective_scale`) plus the room music path through `resolveRoomSetupState`.
+  - Updated the startup player handoff to mirror the native two-pass `room_setup` placement: compute `screen_x = spawn_x - x_offset - width / 2` and `screen_y = spawn_y - height - y_offset`, store that screen-space base point, rescale the ABM for depth, then recompute the same screen-space base point from the scaled metrics.
+  - Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
+  - The bounded probe in `/Users/alex/Temp/harvester_startup_probe_new.log` now shows `startup player placement ... screen_base=(182,208) size=29x148 offsets=(164,49) scale=0.970` and `startup probe player ... rect=(346,257)-(375,405)`, replacing the old bad rect `(514,380)-(544,533)` while keeping the player bottom at entrance `y=405`.
+  - The same bounded probe still resolves `PC_DRESR1` at `(142,339)` with `action_tag='COM1A'` and `next_room='PCDRWR'`, so the direct startup room-loop interaction handoff remains intact.
+  - A no-probe startup capture in `/Users/alex/Temp/harvester_startup_noprobe.log` also reaches `START -> PCROOM`, applies the same `screen_base=(182,208)` / `scale=0.970` placement, and advances the cursor to sequence `7`, so the fix is not dependent on the probe hook.
+  - Ghidra function counts remain unchanged.
+- Closed the startup player-display root-cause pass against Ghidra.
+  - The local startup logs already showed the actor resource and entrance state were present: `PC0.ABM` loads, `resolveRoomSetupState('START')` resolves `PCROOM`, and the player spawn stays `(360,405,15)` facing `1`.
+  - Ghidra now confirms the missing native behavior is screen placement, not loading. In `room_setup`, the player path re-adds `DAT_000d5bd4`, computes `screen_y = entrance_y - sprite_height - y_offset`, computes `screen_x = entrance_x - x_offset - sprite_width / 2`, calls `set_entity_screen_position`, rescales with `rescale_entity_sprite_for_depth`, then repeats the same placement with the scaled metrics.
+  - The current port is still treating the entrance coordinates as direct screen pixels and adding ABM frame offsets during draw, which matches the bad startup probe rectangle `(514,380)-(544,533)` from the same `(360,405,15)` entrance and explains why Steve loads but is not visibly placed in the room.
+  - This narrows the regression to the missing native world-to-screen / depth-rescale path for startup actors; archive loading, palette upload, quick tips, and room-loop dispatch are not the blocker.
+  - Ghidra function counts remain unchanged.
 - Closed the startup room-loop verification pass with a bounded local probe.
   - Added opt-in Harvester debug probes behind `harvester_debug_skip_startup_movies` and `harvester_debug_probe_startup_room` so the local capture can skip the intro FST trio and log room-loop facts without changing the default startup path.
   - Using `/Users/alex/Temp/harvester_overlay_ugy21l06` with `QUICK_TIPS=OFF` plus `/Users/alex/Temp/harvester_probe_scummvm.ini`, the bounded run in `/Users/alex/Temp/harvester_startup_probe.log` now reaches `resolveRoomSetupState('START') -> room='PCROOM' entrance='START' spawn=(360,405,15) facing=1`.
@@ -212,6 +238,6 @@
 ## Next Suggested Action
 
 - Highest-value targets:
-  - port `show_target_ident_text` / `handle_target_interaction` behavior into the room loop so the verified `PCROOM` hover/click path can move off the current prototype overlay text
-  - once that path is in place, rerun the bounded startup probe and a normal startup capture to confirm the dresser `COM1A -> PCDRWR` interaction follows the same status/message flow as the original room loop
-  - if player placement regresses during that port, take a focused Ghidra pass through `set_entity_screen_position` / `rescale_entity_sprite_for_depth` callsites to recover the exact screen-position math before touching movement
+  - add a bounded input replay or one-shot debug hook for the first `PC_DRESR1` click so the new ScummVM modal path can be confirmed end-to-end: first click shows `BOX1` IDENT text, second click follows `COM1A -> PCDRWR`
+  - after that live click-path verification, continue tracing the native bottom-screen hover text entity path around the existing `spawn_text_entity` / `destroy_text_entity` calls so the current prompt-string overlay can be replaced with the original label lifecycle
+  - if the live dresser replay exposes further room-loop regressions, trace the remaining `handle_target_interaction` side paths (generic responses, inventory pickup/use-item dispatch, and NPC dialogue gating) before broadening movement or click-to-walk work
diff --git a/engines/harvester/startup_art.h b/engines/harvester/startup_art.h
index f1bf63a29a6..c4f3bda6cae 100644
--- a/engines/harvester/startup_art.h
+++ b/engines/harvester/startup_art.h
@@ -54,6 +54,9 @@ public:
 	const IndexedBitmap &getInventoryBitmap() const { return _inventoryBitmap; }
 	const IndexedBitmap &getLogoBitmap() const { return _logoBitmap; }
 	const IndexedBitmap &getTipsBitmap() const { return _tipsBitmap; }
+	const IndexedBitmap *getTextboxBitmap(uint index) const {
+		return index < _textboxes.size() ? &_textboxes[index] : nullptr;
+	}
 
 private:
 	bool loadPalette(ResourceManager &resources, const Common::String &path, byte *dest) const;
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index a5c0a77bf87..ef06b4d5e64 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -19,6 +19,8 @@
  *
  */
 
+#include <math.h>
+
 #include "harvester/startup_flow.h"
 
 #include "common/config-manager.h"
@@ -60,17 +62,33 @@ static const int kQuickTipTextWidth = 280;
 static const int kMenuStartY = 100;
 static const int kMenuLineSpacing = 28;
 static const int kCursorSequence7 = 7;
+static const int kIdentTextboxX = 85;
+static const int kIdentTextboxY = 177;
+static const int kIdentTextboxTextInsetX = 10;
+static const int kIdentTextboxTextInsetY = 5;
 static const char *const kPlayerActorEntityName = "PLAYER";
 static const char *const kPlayerActorResourcePath = "1:/GRAPHIC/MONSTERS/PC/PC0.ABM";
 static const uint32 kPaletteFadeTickMs = 4;
 static const float kPaletteFadeStep = 0.1f;
 static const float kPaletteBrightnessBlack = 0.0f;
+static const float kRoomPlayerMoveStep = 4.0f;
 
+static const byte kIdentTextColor = 0xd3;
 static const byte kTextColorNormal = 255;
 static const byte kTextColorHover = 251;
 static const byte kTextColorDim = 248;
 static const byte kShadowColor = 0;
 static const byte kPanelFillColor = 1;
+static const byte kQuickTipActionColor = 0xc3;
+static const byte kRoomPromptColor = 0xce;
+
+static const int kCursorSequenceWalk = 0;
+static const int kCursorSequenceExamine = 1;
+static const int kCursorSequenceTalk = 2;
+static const int kCursorSequenceOperate = 4;
+static const int kCursorSequencePickup = 5;
+static const int kCursorSequenceTransition = 6;
+static const int kCursorSequenceNeutral = 7;
 
 struct StartupRoomSceneResources {
 	StartupRoomSetupState state;
@@ -80,6 +98,28 @@ struct StartupRoomSceneResources {
 	float targetPaletteBrightness = 1.0f;
 };
 
+struct StartupRoomPlayerState {
+	RuntimeEntity *entity = nullptr;
+	int centerX = 0;
+	int bottomY = 0;
+	float z = 0.0f;
+	int facing = -1;
+	bool hasMoveTarget = false;
+	int targetX = 0;
+	int targetY = 0;
+	float targetZ = 0.0f;
+};
+
+struct StartupRoomHoverState {
+	const StartupObjectRecord *object = nullptr;
+	Common::String promptText;
+	int cursorSequence = kCursorSequenceNeutral;
+};
+
+static int roundToInt(float value) {
+	return value >= 0.0f ? (int)floorf(value + 0.5f) : (int)ceilf(value - 0.5f);
+}
+
 static int resolvePlayerFacingFrame(int facing) {
 	switch (facing) {
 	case 0:
@@ -95,6 +135,88 @@ static int resolvePlayerFacingFrame(int facing) {
 	}
 }
 
+static bool roomSupportsMovementBand(const StartupRoomSetupState &state) {
+	return state.roomMaxZScreenY >= 0 &&
+		state.roomMinZScreenY >= state.roomMaxZScreenY;
+}
+
+static int clampRoomMovementY(const StartupRoomSetupState &state, int screenY) {
+	if (!roomSupportsMovementBand(state))
+		return screenY;
+
+	return CLIP<int>(screenY, state.roomMaxZScreenY, state.roomMinZScreenY);
+}
+
+static float mapRoomScreenYToDepth(const StartupRoomSetupState &state, int screenY) {
+	if (!roomSupportsMovementBand(state))
+		return (float)state.playerSpawnZ;
+
+	if (screenY <= state.roomMaxZScreenY)
+		return (float)state.roomMaxZ;
+	if (screenY >= state.roomMinZScreenY)
+		return (float)state.roomMinZ;
+
+	return (float)(state.roomMaxZ - ((state.roomMaxZ - state.roomMinZ) *
+		(screenY - state.roomMaxZScreenY)) /
+		(state.roomMinZScreenY - state.roomMaxZScreenY));
+}
+
+static float computeActorDepthScale(const StartupRoomSetupState &state, float z) {
+	float scale = 1.0f;
+	if (state.roomPerspectiveScale != 0.0f) {
+		scale -= (z - (float)state.roomFullScaleZ) * state.roomPerspectiveScale;
+		if (scale <= 0.0f)
+			scale = 1.0f;
+	}
+
+	return scale;
+}
+
+static void setRoomActorScreenPosition(RuntimeEntity &entity, int centerX, int bottomY, float z,
+		int width, int height, int xOffset, int yOffset) {
+	entity.setPosition(centerX - xOffset - width / 2, bottomY - height - yOffset, z);
+}
+
+static bool applyRoomActorPlacement(const StartupRoomSetupState &state, RuntimeEntity &entity,
+		int centerX, int bottomY, float z, const Common::String *entranceName = nullptr) {
+	int width = 0;
+	int height = 0;
+	int xOffset = 0;
+	int yOffset = 0;
+	if (!entity.getCurrentFrameMetrics(width, height, xOffset, yOffset))
+		return false;
+
+	entity.setAnchorMode(kRuntimeEntityAnchorTopLeft);
+	setRoomActorScreenPosition(entity, centerX, bottomY, z, width, height, xOffset, yOffset);
+
+	const float depthScale = computeActorDepthScale(state, z);
+	entity.setDepthScale(depthScale);
+	if (!entity.getCurrentFrameMetrics(width, height, xOffset, yOffset))
+		return false;
+	setRoomActorScreenPosition(entity, centerX, bottomY, z, width, height, xOffset, yOffset);
+
+	if (entranceName) {
+		debugC(1, kDebugGeneral,
+			"Harvester: startup player placement entrance='%s' spawn=(%d,%d,%d) screen_base=(%d,%d) size=%dx%d offsets=(%d,%d) scale=%.3f",
+			entranceName->c_str(), centerX, bottomY, roundToInt(z),
+			entity.getX(), entity.getY(), width, height, xOffset, yOffset, (double)depthScale);
+	}
+	return true;
+}
+
+static bool applyStartupActorPlacement(const StartupRoomSetupState &state, RuntimeEntity &entity) {
+	return applyRoomActorPlacement(state, entity,
+		state.playerSpawnX, state.playerSpawnY, (float)state.playerSpawnZ, &state.entranceName);
+}
+
+static int resolveFacingFromMovementDelta(int dx, int dy) {
+	if (ABS(dx) > ABS(dy))
+		return dx < 0 ? 1 : 2;
+	if (dy < 0)
+		return 3;
+	return 0;
+}
+
 static void getPaletteByteRange(const byte *palette, byte &minValue, byte &maxValue) {
 	minValue = 255;
 	maxValue = 0;
@@ -301,6 +423,16 @@ static const StartupObjectRecord *findSceneObjectByName(const Common::Array<Star
 	return nullptr;
 }
 
+static StartupObjectRecord *findSceneObjectByName(Common::Array<StartupObjectRecord> &objects,
+		const Common::String &objectName) {
+	for (StartupObjectRecord &object : objects) {
+		if (object.objectName.equalsIgnoreCase(objectName))
+			return &object;
+	}
+
+	return nullptr;
+}
+
 static const StartupObjectRecord *findRoomObjectAtPoint(HarvesterEngine &engine,
 		const Common::Array<StartupObjectRecord> &sceneObjects, const Common::Point &point) {
 	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
@@ -310,10 +442,161 @@ static const StartupObjectRecord *findRoomObjectAtPoint(HarvesterEngine &engine,
 	const RuntimeEntity *entity = runtimeEntities->findTopSceneEntityAt(point);
 	if (!entity)
 		return nullptr;
+	if (entity->getClassId() == kRuntimeEntityClassBackground)
+		return nullptr;
 
 	return findSceneObjectByName(sceneObjects, entity->getName());
 }
 
+static const IndexedBitmap *resolveInspectTextboxBitmap(const StartupArt &art, const StartupResolvedText &text) {
+	if (text.boxName.equalsIgnoreCase("BOX1"))
+		return art.getTextboxBitmap(0);
+	if (text.boxName.equalsIgnoreCase("BOX2"))
+		return art.getTextboxBitmap(1);
+	if (text.boxName.equalsIgnoreCase("BOX3"))
+		return art.getTextboxBitmap(2);
+	if (text.boxName.equalsIgnoreCase("BOX4"))
+		return art.getTextboxBitmap(3);
+
+	return nullptr;
+}
+
+static void drawRoomInspectText(Graphics::Screen &screen, const StartupArt &art, const Graphics::Font &font,
+		const StartupResolvedText &inspectText) {
+	const IndexedBitmap *textbox = resolveInspectTextboxBitmap(art, inspectText);
+	if (!textbox || !textbox->isValid())
+		return;
+
+	blitBitmap(screen, *textbox, kIdentTextboxX, kIdentTextboxY);
+	drawWrappedShadowedText(screen, font, inspectText.value,
+		kIdentTextboxX + kIdentTextboxTextInsetX,
+		kIdentTextboxY + kIdentTextboxTextInsetY,
+		MAX<int>(0, (int)textbox->width - (kIdentTextboxTextInsetX + 2)),
+		kIdentTextColor);
+}
+
+static int resolveRoomObjectCursorSequence(const StartupObjectRecord &object, StartupScript &startupScript) {
+	StartupResolvedText inspectText;
+	if (!object.identShown && startupScript.resolveObjectInspectText(object, inspectText))
+		return kCursorSequenceExamine;
+	if (object.operatable)
+		return kCursorSequenceOperate;
+
+	StartupInteractionResult interaction;
+	if (startupScript.resolveObjectInteraction(object, interaction))
+		return kCursorSequenceExamine;
+	if (startupScript.resolveObjectInspectText(object, inspectText))
+		return kCursorSequenceExamine;
+
+	return kCursorSequenceNeutral;
+}
+
+static Common::String buildRoomObjectPrompt(const StartupObjectRecord &object, StartupScript &startupScript,
+		int cursorSequence) {
+	const Common::String label = startupScript.resolveObjectLabel(object);
+	if (label.empty())
+		return Common::String();
+
+	if (cursorSequence == kCursorSequenceOperate)
+		return Common::String::format("Operate the %s", label.c_str());
+	if (cursorSequence == kCursorSequencePickup)
+		return Common::String::format("Pick up the %s", label.c_str());
+	if (cursorSequence == kCursorSequenceTalk)
+		return Common::String::format("Talk to %s", label.c_str());
+	if (cursorSequence == kCursorSequenceTransition)
+		return Common::String::format("Go to %s", label.c_str());
+
+	return Common::String::format("Examine %s", label.c_str());
+}
+
+static StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		const Common::Array<StartupObjectRecord> &sceneObjects, const Common::Point &mousePos) {
+	StartupRoomHoverState hoverState;
+	StartupScript *startupScript = engine.getStartupScript();
+	if (!startupScript)
+		return hoverState;
+
+	hoverState.object = findRoomObjectAtPoint(engine, sceneObjects, mousePos);
+	if (hoverState.object) {
+		hoverState.cursorSequence = resolveRoomObjectCursorSequence(*hoverState.object, *startupScript);
+		hoverState.promptText = buildRoomObjectPrompt(*hoverState.object, *startupScript, hoverState.cursorSequence);
+		return hoverState;
+	}
+
+	if (roomSupportsMovementBand(state) &&
+		mousePos.y >= state.roomMaxZScreenY &&
+		mousePos.y <= state.roomMinZScreenY) {
+		hoverState.cursorSequence = kCursorSequenceWalk;
+	}
+
+	return hoverState;
+}
+
+static bool setPlayerFacingFrame(StartupRoomPlayerState &playerState, int facing) {
+	if (!playerState.entity)
+		return false;
+	if (playerState.facing == facing)
+		return false;
+
+	playerState.facing = facing;
+	playerState.entity->setCurrentFrame(resolvePlayerFacingFrame(facing));
+	return true;
+}
+
+static void setPlayerMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
+		int targetX, int targetBottomY) {
+	playerState.hasMoveTarget = true;
+	playerState.targetX = CLIP<int>(targetX, 0, 639);
+	playerState.targetY = clampRoomMovementY(state, targetBottomY);
+	playerState.targetZ = mapRoomScreenYToDepth(state, playerState.targetY);
+}
+
+static bool stepPlayerMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState) {
+	if (!playerState.entity || !playerState.hasMoveTarget)
+		return false;
+
+	const float dx = (float)(playerState.targetX - playerState.centerX);
+	const float dy = (float)(playerState.targetY - playerState.bottomY);
+	const float distance = sqrtf(dx * dx + dy * dy);
+	if (distance <= kRoomPlayerMoveStep) {
+		playerState.centerX = playerState.targetX;
+		playerState.bottomY = playerState.targetY;
+		playerState.z = playerState.targetZ;
+		playerState.hasMoveTarget = false;
+	} else {
+		playerState.centerX += roundToInt((dx / distance) * kRoomPlayerMoveStep);
+		playerState.bottomY += roundToInt((dy / distance) * kRoomPlayerMoveStep);
+		playerState.bottomY = clampRoomMovementY(state, playerState.bottomY);
+		playerState.z = mapRoomScreenYToDepth(state, playerState.bottomY);
+	}
+
+	(void)setPlayerFacingFrame(playerState,
+		resolveFacingFromMovementDelta(playerState.targetX - playerState.centerX,
+			playerState.targetY - playerState.bottomY));
+	return applyRoomActorPlacement(state, *playerState.entity,
+		playerState.centerX, playerState.bottomY, playerState.z);
+}
+
+static bool stepPlayerKeyboardMovement(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
+		bool moveLeft, bool moveRight, bool moveUp, bool moveDown) {
+	if (!playerState.entity || !roomSupportsMovementBand(state))
+		return false;
+
+	const int dx = (moveRight ? 1 : 0) - (moveLeft ? 1 : 0);
+	const int dy = (moveDown ? 1 : 0) - (moveUp ? 1 : 0);
+	if (dx == 0 && dy == 0)
+		return false;
+
+	playerState.hasMoveTarget = false;
+	playerState.centerX = CLIP<int>(playerState.centerX + roundToInt(dx * kRoomPlayerMoveStep), 0, 639);
+	playerState.bottomY = clampRoomMovementY(state,
+		playerState.bottomY + roundToInt(dy * kRoomPlayerMoveStep));
+	playerState.z = mapRoomScreenYToDepth(state, playerState.bottomY);
+	(void)setPlayerFacingFrame(playerState, resolveFacingFromMovementDelta(dx, dy));
+	return applyRoomActorPlacement(state, *playerState.entity,
+		playerState.centerX, playerState.bottomY, playerState.z);
+}
+
 static bool findRoomObjectProbePoint(HarvesterEngine &engine, const Common::Array<StartupObjectRecord> &sceneObjects,
 		const StartupObjectRecord &object, Common::Point &probePoint) {
 	const Common::Rect bounds = getHotspotBounds(object);
@@ -400,13 +683,62 @@ static void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneR
 		const Common::String objectLabel = startupScript->resolveObjectLabel(*hoveredObject);
 		StartupInteractionResult interaction;
 		StartupResolvedText inspectText;
+		const StartupRoomHoverState hoverState = resolveRoomHoverState(engine, scene.state, scene.sceneObjects, probePoint);
 		const bool hasInteraction = startupScript->resolveObjectInteraction(*hoveredObject, interaction);
 		const bool hasInspectText = startupScript->resolveObjectInspectText(*hoveredObject, inspectText);
 		debugC(1, kDebugGeneral,
-			"Harvester: startup probe hotspot room='%s' object='%s' point=(%d,%d) label='%s' action_tag='%s' interaction=%d next_room='%s' inspect=%d",
+			"Harvester: startup probe hotspot room='%s' object='%s' point=(%d,%d) label='%s' prompt='%s' cursor_sequence=%d action_tag='%s' interaction=%d next_room='%s' inspect=%d",
 			scene.state.roomName.c_str(), hoveredObject->objectName.c_str(), probePoint.x, probePoint.y,
-			objectLabel.c_str(), hoveredObject->actionTag.c_str(), hasInteraction,
+			objectLabel.c_str(), hoverState.promptText.c_str(), hoverState.cursorSequence,
+			hoveredObject->actionTag.c_str(), hasInteraction,
 			interaction.nextRoomName.c_str(), hasInspectText);
+
+		if (roomSupportsMovementBand(scene.state)) {
+			Common::Point floorProbe;
+			bool foundFloorProbe = false;
+			for (int y = scene.state.roomMaxZScreenY; y <= scene.state.roomMinZScreenY && !foundFloorProbe; y += 12) {
+				for (int x = 48; x < 592; x += 16) {
+					const StartupRoomHoverState candidateHover = resolveRoomHoverState(
+						engine, scene.state, scene.sceneObjects, Common::Point(x, y));
+					if (candidateHover.cursorSequence == kCursorSequenceWalk) {
+						floorProbe = Common::Point(x, y);
+						foundFloorProbe = true;
+						break;
+					}
+				}
+			}
+
+			const StartupRoomHoverState floorHover = foundFloorProbe
+				? resolveRoomHoverState(engine, scene.state, scene.sceneObjects, floorProbe)
+				: StartupRoomHoverState();
+			debugC(1, kDebugGeneral,
+				"Harvester: startup probe floor room='%s' point=(%d,%d) found=%d cursor_sequence=%d prompt='%s'",
+				scene.state.roomName.c_str(), floorProbe.x, floorProbe.y, foundFloorProbe,
+				floorHover.cursorSequence, floorHover.promptText.c_str());
+
+			RuntimeEntity *player = runtimeEntities->findSceneEntityByName(kPlayerActorEntityName);
+			if (player && foundFloorProbe && floorHover.cursorSequence == kCursorSequenceWalk) {
+				StartupRoomPlayerState probePlayer;
+				probePlayer.entity = player;
+				probePlayer.centerX = scene.state.playerSpawnX;
+				probePlayer.bottomY = scene.state.playerSpawnY;
+				probePlayer.z = (float)scene.state.playerSpawnZ;
+				probePlayer.facing = scene.state.playerFacing;
+				setPlayerMoveTarget(scene.state, probePlayer, floorProbe.x, floorProbe.y);
+				for (int i = 0; i < 32 && probePlayer.hasMoveTarget; ++i)
+					(void)stepPlayerMoveTarget(scene.state, probePlayer);
+
+				const Common::Rect movedRect = player->getScreenRect();
+				debugC(1, kDebugGeneral,
+					"Harvester: startup probe movement room='%s' target=(%d,%d) final_rect=(%d,%d)-(%d,%d) z=%.2f",
+					scene.state.roomName.c_str(), floorProbe.x, floorProbe.y,
+					movedRect.left, movedRect.top, movedRect.right, movedRect.bottom, (double)probePlayer.z);
+
+				player->setCurrentFrame(resolvePlayerFacingFrame(scene.state.playerFacing));
+				(void)applyRoomActorPlacement(scene.state, *player,
+					scene.state.playerSpawnX, scene.state.playerSpawnY, (float)scene.state.playerSpawnZ);
+			}
+		}
 		return;
 	}
 
@@ -440,7 +772,8 @@ static void renderQuickTipsScreen(HarvesterEngine &engine, const StartupRoomScen
 	Graphics::Screen *screen = engine.getScreen();
 	const StartupArt *art = engine.getStartupArt();
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	if (!screen || !art || !font)
+	StartupScript *startupScript = engine.getStartupScript();
+	if (!screen || !art || !font || !startupScript)
 		return;
 
 	drawRoomScene(engine, *screen, scene, scene.targetPaletteBrightness);
@@ -450,14 +783,14 @@ static void renderQuickTipsScreen(HarvesterEngine &engine, const StartupRoomScen
 	const Common::Rect exitRect = quickTipsExitRect();
 	const Common::Rect nextRect = quickTipsNextRect();
 	const Common::Rect toggleRect = quickTipsToggleRect();
+	const Common::String toggleLabel = startupScript->resolveTextValue(
+		startupScript->isQuickTipsEnabled() ? "Show_Tips_ON" : "Show_Tips_OFF");
 	drawShadowedString(*screen, *font, "Exit", exitRect.left, exitRect.top, exitRect.width(),
-		exitRect.contains(mousePos) ? kTextColorHover : kTextColorNormal);
+		kQuickTipActionColor);
 	drawShadowedString(*screen, *font, "Next", nextRect.left, nextRect.top, nextRect.width(),
-		nextRect.contains(mousePos) ? kTextColorHover : kTextColorNormal);
-	drawShadowedString(*screen, *font,
-		engine.getStartupScript()->isQuickTipsEnabled() ? "Show_Tips_ON" : "Show_Tips_OFF",
-		toggleRect.left, toggleRect.top, toggleRect.width(),
-		toggleRect.contains(mousePos) ? kTextColorHover : kTextColorNormal);
+		kQuickTipActionColor);
+	drawShadowedString(*screen, *font, toggleLabel,
+		toggleRect.left, toggleRect.top, toggleRect.width(), kQuickTipActionColor);
 
 	if (engine.getRuntimeEntities())
 		engine.getRuntimeEntities()->drawCursor(*screen);
@@ -578,6 +911,11 @@ Common::Error StartupFlow::runQuickTips() {
 
 	resetCursorAnimationSequence();
 
+	debugC(1, kDebugGeneral,
+		"Harvester: quick tips labels exit='Exit' next='Next' toggle='%s'",
+		_engine.getStartupScript()->resolveTextValue(
+			_engine.getStartupScript()->isQuickTipsEnabled() ? "Show_Tips_ON" : "Show_Tips_OFF").c_str());
+
 	uint tipIndex = _engine.getRandomNumber(_quickTips.size() - 1);
 	bool needsRedraw = true;
 	Graphics::FrameLimiter limiter(g_system, 60);
@@ -748,9 +1086,8 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 
 	Graphics::Screen *screen = _engine.getScreen();
 	const StartupArt *art = _engine.getStartupArt();
-	const Graphics::Font *titleFont = FontMan.getFontByUsage(Graphics::FontManager::kBigGUIFont);
 	const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	if (!screen || !art || !titleFont || !bodyFont)
+	if (!screen || !art || !bodyFont)
 		return Common::kNoError;
 
 	if (!populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
@@ -767,21 +1104,20 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 		return transitionError;
 
 	resetCursorAnimationSequence();
-
-	Common::String statusMessage = Common::String::format(
-		"Entered %s via %s. Move the cursor over active hotspots and click to inspect or follow startup room transitions.",
-		state.roomName.c_str(), entranceName.c_str());
-	if (!state.musicPath.empty())
-		statusMessage += Common::String::format(" Music: %s.", state.musicPath.c_str());
-	if (!scene.sceneObjects.empty())
-		statusMessage += Common::String::format(" Visible scene objects: %u.", (uint)scene.sceneObjects.size());
-	if (!scene.sceneAnimations.empty())
-		statusMessage += Common::String::format(" Room anims: %u.", (uint)scene.sceneAnimations.size());
-	const Common::String baseStatusMessage = statusMessage;
+	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+	StartupRoomPlayerState playerState;
+	playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName(kPlayerActorEntityName) : nullptr;
+	playerState.centerX = state.playerSpawnX;
+	playerState.bottomY = state.playerSpawnY;
+	playerState.z = (float)state.playerSpawnZ;
+	playerState.facing = state.playerFacing;
 	StartupResolvedText inspectText;
-	Common::String inspectObjectLabel;
 	bool showingInspectText = false;
 	bool inspectCanDismiss = false;
+	bool moveLeft = false;
+	bool moveRight = false;
+	bool moveUp = false;
+	bool moveDown = false;
 	bool needsRedraw = true;
 	Graphics::FrameLimiter limiter(g_system, 60);
 
@@ -790,52 +1126,24 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
-			drawRoomScene(_engine, *screen, scene, scene.targetPaletteBrightness);
-			const StartupObjectRecord *hoveredObject = showingInspectText ? nullptr :
-				findRoomObjectAtPoint(_engine, scene.sceneObjects, _mousePos);
-			Common::String hoverMessage;
-			if (hoveredObject) {
-				hoverMessage = _engine.getStartupScript()->resolveObjectLabel(*hoveredObject);
-				if (!hoveredObject->actionTag.empty())
-					hoverMessage += " Click to follow the scripted startup interaction.";
-				else if (!_engine.getStartupScript()->resolveObjectInspectText(*hoveredObject, inspectText))
-					inspectText = StartupResolvedText();
-				else
-					hoverMessage += " Click to inspect.";
+			const StartupRoomHoverState hoverState = showingInspectText
+				? StartupRoomHoverState()
+				: resolveRoomHoverState(_engine, scene.state, scene.sceneObjects, _mousePos);
+			if (RuntimeEntity *cursor = runtimeEntities ? runtimeEntities->getCursorEntity() : nullptr) {
+				cursor->setAnimationSequence(showingInspectText ? kCursorSequenceNeutral : hoverState.cursorSequence);
 			}
-			if (hoverMessage.empty())
-				hoverMessage = statusMessage;
 
-			const Common::Rect headerPanel(16, 16, 624, 84);
-			screen->fillRect(headerPanel, kPanelFillColor);
-			drawShadowedString(*screen, *titleFont, state.roomName,
-				headerPanel.left + 16, headerPanel.top + 8, headerPanel.width() - 32,
-				kTextColorNormal, Graphics::kTextAlignCenter);
-			drawWrappedShadowedText(*screen, *bodyFont, hoverMessage,
-				headerPanel.left + 16, headerPanel.top + 34, headerPanel.width() - 32, kTextColorNormal);
+			drawRoomScene(_engine, *screen, scene, scene.targetPaletteBrightness);
 
 			if (showingInspectText) {
-				const Common::Rect inspectPanel(56, 338, 584, 468);
-				const Common::String footerMessage = inspectCanDismiss
-					? "Click, Enter, Space, or Escape to dismiss."
-					: "Release the mouse button, then click again to dismiss.";
-				screen->fillRect(inspectPanel, kPanelFillColor);
-				drawShadowedString(*screen, *titleFont,
-					Common::String::format("Inspect: %s", inspectObjectLabel.c_str()),
-					inspectPanel.left, inspectPanel.top + 10, inspectPanel.width(),
-					kTextColorHover, Graphics::kTextAlignCenter);
-				drawWrappedShadowedText(*screen, *bodyFont, inspectText.value,
-					inspectPanel.left + 20, inspectPanel.top + 40, inspectPanel.width() - 40, kTextColorNormal);
-				drawShadowedString(*screen, *bodyFont, footerMessage,
-					inspectPanel.left, inspectPanel.bottom - 20, inspectPanel.width(),
-					kTextColorDim, Graphics::kTextAlignCenter);
-			} else {
-				drawShadowedString(*screen, *bodyFont, "Esc exits this prototype room loop.",
-					0, 458, 640, kTextColorDim, Graphics::kTextAlignCenter);
+				drawRoomInspectText(*screen, *art, *bodyFont, inspectText);
+			} else if (!hoverState.promptText.empty()) {
+				drawShadowedString(*screen, *bodyFont, hoverState.promptText,
+					0, 462, 640, kRoomPromptColor, Graphics::kTextAlignCenter);
 			}
 
-			if (_engine.getRuntimeEntities())
-				_engine.getRuntimeEntities()->drawCursor(*screen);
+			if (runtimeEntities)
+				runtimeEntities->drawCursor(*screen);
 			screen->makeAllDirty();
 			screen->update();
 			needsRedraw = false;
@@ -861,92 +1169,133 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 						showingInspectText = false;
 						inspectCanDismiss = false;
 						inspectText = StartupResolvedText();
-						inspectObjectLabel.clear();
-						statusMessage = baseStatusMessage;
 						needsRedraw = true;
 					}
 					break;
 				}
 
-					const StartupObjectRecord *clickedObject = findRoomObjectAtPoint(_engine, scene.sceneObjects, _mousePos);
-					if (!clickedObject) {
-						statusMessage = "No active startup hotspot under the cursor.";
+				const StartupRoomHoverState hoverState = resolveRoomHoverState(
+					_engine, scene.state, scene.sceneObjects, _mousePos);
+				StartupObjectRecord *clickedObject = hoverState.object
+					? findSceneObjectByName(scene.sceneObjects, hoverState.object->objectName)
+					: nullptr;
+				if (!clickedObject) {
+					if (hoverState.cursorSequence == kCursorSequenceWalk && playerState.entity) {
+						setPlayerMoveTarget(scene.state, playerState, _mousePos.x, _mousePos.y);
 						needsRedraw = true;
-						break;
 					}
+					break;
+				}
+
+				StartupResolvedText resolvedInspectText;
+				const bool hasInspectText =
+					_engine.getStartupScript()->resolveObjectInspectText(*clickedObject, resolvedInspectText);
+				const bool canShowInspectText = hasInspectText &&
+					resolveInspectTextboxBitmap(*art, resolvedInspectText);
+				if (!clickedObject->identShown && canShowInspectText) {
+					inspectText = resolvedInspectText;
+					clickedObject->identShown = true;
+					showingInspectText = true;
+					inspectCanDismiss = false;
+					playerState.hasMoveTarget = false;
+					needsRedraw = true;
+					break;
+				}
 
-				const Common::String objectLabel = _engine.getStartupScript()->resolveObjectLabel(*clickedObject);
 				StartupInteractionResult interaction;
 				if (!_engine.getStartupScript()->resolveObjectInteraction(*clickedObject, interaction)) {
-					if (_engine.getStartupScript()->resolveObjectInspectText(*clickedObject, inspectText)) {
-						inspectObjectLabel = objectLabel;
+					if (canShowInspectText) {
+						inspectText = resolvedInspectText;
 						showingInspectText = true;
 						inspectCanDismiss = false;
-						statusMessage = Common::String::format("Showing inspect text for %s.", objectLabel.c_str());
-					} else if (objectLabel.empty()) {
-						statusMessage = "No active startup hotspot under the cursor.";
-					} else {
-						statusMessage = Common::String::format("%s has no supported startup interaction yet.",
-							objectLabel.c_str());
+					} else if (hasInspectText) {
+						debug(1, "Harvester: unsupported IDENT textbox '%s' for object '%s'",
+							resolvedInspectText.boxName.c_str(), clickedObject->objectName.c_str());
 					}
 					needsRedraw = true;
 					break;
 				}
 
-				statusMessage = Common::String::format("Following %s.", objectLabel.c_str());
+				playerState.hasMoveTarget = false;
 				if (!interaction.soundPath.empty()) {
-					const bool soundStarted = _engine.playStartupSound(interaction.soundPath);
-					statusMessage += Common::String::format(" Sound: %s (%s).", interaction.soundPath.c_str(),
-						soundStarted ? "playing" : "load failed");
+					(void)_engine.playStartupSound(interaction.soundPath);
 				}
-					needsRedraw = true;
+				needsRedraw = true;
 
-					if (!interaction.nextRoomName.empty()) {
-						Common::Error roomError = runRoomLoop(interaction.nextRoomName);
-						if (roomError.getCode() == Common::kReadingFailed) {
-							(void)populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations);
-							resetCursorAnimationSequence();
-							statusMessage = Common::String::format("Unable to resolve closeup room '%s'.",
-								interaction.nextRoomName.c_str());
-							needsRedraw = true;
-						} else if (roomError.getCode() != Common::kNoError) {
-							return roomError;
-						} else {
-							(void)populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations);
-							resetCursorAnimationSequence();
-							statusMessage = Common::String::format("Returned from %s.", interaction.nextRoomName.c_str());
-							needsRedraw = true;
-						}
+				if (!interaction.nextRoomName.empty()) {
+					Common::Error roomError = runRoomLoop(interaction.nextRoomName);
+					if (roomError.getCode() == Common::kReadingFailed) {
+						(void)populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations);
+						resetCursorAnimationSequence();
+						playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName(kPlayerActorEntityName) : nullptr;
+						playerState.centerX = state.playerSpawnX;
+						playerState.bottomY = state.playerSpawnY;
+						playerState.z = (float)state.playerSpawnZ;
+						playerState.facing = state.playerFacing;
+						playerState.hasMoveTarget = false;
+						needsRedraw = true;
+					} else if (roomError.getCode() != Common::kNoError) {
+						return roomError;
+					} else {
+						(void)populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations);
+						resetCursorAnimationSequence();
+						playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName(kPlayerActorEntityName) : nullptr;
+						playerState.centerX = state.playerSpawnX;
+						playerState.bottomY = state.playerSpawnY;
+						playerState.z = (float)state.playerSpawnZ;
+						playerState.facing = state.playerFacing;
+						playerState.hasMoveTarget = false;
+						needsRedraw = true;
 					}
+				}
 				break;
 			}
 			case Common::EVENT_KEYDOWN:
 				if (showingInspectText) {
-					if (event.kbd.keycode == Common::KEYCODE_RETURN ||
-						event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
-						event.kbd.keycode == Common::KEYCODE_ESCAPE ||
-						event.kbd.keycode == Common::KEYCODE_SPACE) {
+					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
 						showingInspectText = false;
 						inspectCanDismiss = false;
 						inspectText = StartupResolvedText();
-						inspectObjectLabel.clear();
-						statusMessage = baseStatusMessage;
 						needsRedraw = true;
 					}
 					break;
 				}
 
+				if (event.kbd.keycode == Common::KEYCODE_LEFT)
+					moveLeft = true;
+				else if (event.kbd.keycode == Common::KEYCODE_RIGHT)
+					moveRight = true;
+				else if (event.kbd.keycode == Common::KEYCODE_UP)
+					moveUp = true;
+				else if (event.kbd.keycode == Common::KEYCODE_DOWN)
+					moveDown = true;
+
 				if (event.kbd.keycode == Common::KEYCODE_RETURN ||
 					event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
 					event.kbd.keycode == Common::KEYCODE_ESCAPE) {
 					return Common::kNoError;
 				}
 				break;
+			case Common::EVENT_KEYUP:
+				if (event.kbd.keycode == Common::KEYCODE_LEFT)
+					moveLeft = false;
+				else if (event.kbd.keycode == Common::KEYCODE_RIGHT)
+					moveRight = false;
+				else if (event.kbd.keycode == Common::KEYCODE_UP)
+					moveUp = false;
+				else if (event.kbd.keycode == Common::KEYCODE_DOWN)
+					moveDown = false;
+				break;
 			default:
 				break;
 			}
 		}
 
+		if (stepPlayerKeyboardMovement(scene.state, playerState, moveLeft, moveRight, moveUp, moveDown))
+			needsRedraw = true;
+		else if (stepPlayerMoveTarget(scene.state, playerState))
+			needsRedraw = true;
+
 		if (tickRuntimeEntities())
 			needsRedraw = true;
 
@@ -1013,6 +1362,10 @@ bool StartupFlow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 		if (!player) {
 			debug(1, "Harvester: unable to spawn startup player actor from '%s'", kPlayerActorResourcePath);
 		} else {
+			if (!applyStartupActorPlacement(state, *player)) {
+				debug(1, "Harvester: unable to apply startup player placement for entrance '%s'",
+					state.entranceName.c_str());
+			}
 			debugC(1, kDebugGeneral,
 				"Harvester: spawned startup player actor '%s' entrance='%s' pos=(%d,%d,%d) facing=%d frame=%d",
 				kPlayerActorResourcePath, state.entranceName.c_str(),


Commit: c7859e6b6b740b63d04784123e6daee70759da36
    https://github.com/scummvm/scummvm/commit/c7859e6b6b740b63d04784123e6daee70759da36
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:40+02:00

Commit Message:
HARVESTER: Improve startup hover behavior

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 6c0cd9dd546..fe74158ba8d 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -11,6 +11,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `run_init_callbacks_by_priority` at `0x888a6` walks a 6-byte callback table and executes pending init callbacks in priority order.
 - `crt_initialize_and_run_program` at `0x88856` finishes CRT setup and transfers control into the program startup path.
 - `startup_main` at `0x10010` is the first game-specific startup routine. It stores `argc/argv`, resolves `TEMP` / `TMP` / `.\` working paths, loads options such as `FX_VOLUME`, `MUSIC_VOLUME`, `GAMMA`, `CLICK`, and `QUICK_TIPS`, handles the `Z.EXE` CLI/help path, and then enters the normal game startup chain.
+- `run_quick_tips_screen` at `0x6c890` is the startup quick-tips overlay shown on top of the already loaded `START -> PCROOM` room state.
+  - It spawns the bottom action labels through `spawn_text_entity`, renders all three actions in color `0xc3`, and resolves the toggle through the `TEXT` records so `Show_Tips_ON` / `Show_Tips_OFF` display their text values rather than the raw keys.
 
 ## Resource Layer
 
@@ -820,6 +822,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `load_pcx_bitmap` at `0x4a230` is the direct-file `PCX` loader used by the FST censorship path; it reads the whole file through XFILE, RLE-decodes the `0x80`-byte-header payload into a `RawBitmap`, trims oversized stride bytes, and optionally uploads the trailing 256-color palette.
 - `run_harvester_main_loop` maintains `g_current_interaction_text`, `g_pending_interaction_text`, and `g_interaction_text_entity` for hover / use prompts.
   - It allocates both 100-byte text buffers during startup, formats prompts such as `Examine %s`, `Talk to %s`, `Pick up the %s`, `Operate the %s`, and `Use %s on %s`, suppresses prompts when the active label is `NULL_ID`, and only rebuilds the rendered label when the pending text differs from the current text.
+  - It also drives the room-loop cursor classification: sequence `0` for walkable floor-band movement, `1` for inspect / IDENT-first targets, `2` for talk, `4` for operate, `5` for pickup / use-item, `6` for region or room-transition targets, and `7` for neutral hover.
+  - Empty-floor clicks map the cursor's screen `x` directly into the player move target and derive the target depth from the current room's `max_z_screen_y` / `min_z_screen_y` movement band; the same loop also accepts directional-key movement.
 - `run_text_entry_dialog` at `0x59fe0` is the shared keyboard text-entry helper.
   - It is called from the save-game UI and the dialogue keyword UI, allocates a caller-sized entry buffer, handles keyboard editing and cancel/accept paths, and returns the entered string or `NULL`.
 - `spawn_text_entity` at `0x59c00` and `spawn_text_entry_entity` at `0x59f40` are the shared rendered-text constructors for menus, prompts, quick tips, and dialogue UI.
diff --git a/TRACKER.md b/TRACKER.md
index 9cda8abd7a8..54266c25e88 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,6 +2,20 @@
 
 ## Last Confirmed Action
 
+- Closed the startup quick-tips label/value, hotspot cursor, and room-band movement pass against the native main loop.
+  - Confirmed in Ghidra that `run_quick_tips_screen` at `0x6c890` renders the bottom `Exit`, `Next`, and toggle actions in color `0xc3`, and that the toggle label resolves through the `TEXT` records instead of displaying the raw `Show_Tips_ON` / `Show_Tips_OFF` keys.
+  - Confirmed in Ghidra that `run_harvester_main_loop` classifies hover targets into cursor sequences `0` walk, `1` inspect, `2` talk, `4` operate, `5` pickup/use, `6` transition/region, and `7` neutral, and that empty-floor clicks map directly into room-band movement targets with the same loop also honoring directional-key movement.
+  - Patched the startup stub accordingly:
+    - quick tips now resolve `Show_Tips_ON` / `Show_Tips_OFF` through `resolveTextValue()` and render the bottom actions in the native red `0xc3`
+    - the room loop now ignores the background hitmask, derives hover prompts/cursor sequences from the active startup objects, and treats the room movement band as walkable floor with click-to-walk plus arrow-key movement
+    - the startup stub now uses the native IDENT textbox bitmaps and inset/color layout instead of the old placeholder inspect panel
+  - Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
+  - The bounded probe in `/Users/alex/Temp/harvester_startup_probe.log` now confirms all three room-loop checks in one pass:
+    - hotspot hover resolves `PC_DRESR1` with `prompt='Examine dresser'` and `cursor_sequence=1`
+    - empty floor at `(48,350)` is now recognized with `cursor_sequence=0`
+    - the movement preview reaches `final_rect=(219,234)-(246,373) z=23.00`, proving the click-to-walk placement path is live
+  - A separate no-probe capture against `/Users/alex/Downloads/Harvester_1996/HARVEST/iso/Harvester` with `QUICK_TIPS=ON` logs `Harvester: quick tips labels exit='Exit' next='Next' toggle='Show Tips ON'` in `/Users/alex/Temp/harvester_quicktips.log`, confirming the toggle now resolves to the text value rather than the label key.
+  - Ghidra function counts remain unchanged: `774` total functions, `423` named/documented, `351` still `FUN_*`.
 - Closed the startup IDENT textbox modal pass against the native main-loop helpers.
   - Traced the native caller sites in `run_harvester_main_loop`: click interaction still dispatches through `handle_target_interaction` at `0x6f9b3`, while the modal inspect helper `show_target_ident_text` is reached from `0x70c0f` and `0x7189e` after the current target has been resolved and gated on its "ident shown" byte.
   - Exposed the already loaded startup textbox bitmaps in `StartupArt` and replaced the room-loop placeholder inspect panel with the confirmed IDENT textbox path:
@@ -238,6 +252,6 @@
 ## Next Suggested Action
 
 - Highest-value targets:
-  - add a bounded input replay or one-shot debug hook for the first `PC_DRESR1` click so the new ScummVM modal path can be confirmed end-to-end: first click shows `BOX1` IDENT text, second click follows `COM1A -> PCDRWR`
-  - after that live click-path verification, continue tracing the native bottom-screen hover text entity path around the existing `spawn_text_entity` / `destroy_text_entity` calls so the current prompt-string overlay can be replaced with the original label lifecycle
-  - if the live dresser replay exposes further room-loop regressions, trace the remaining `handle_target_interaction` side paths (generic responses, inventory pickup/use-item dispatch, and NPC dialogue gating) before broadening movement or click-to-walk work
+  - replace the current bottom-screen prompt overlay with the native `g_current_interaction_text` / `g_pending_interaction_text` / `g_interaction_text_entity` lifecycle around `spawn_text_entity` / `destroy_text_entity`
+  - trace the startup-room locomotion branch in `run_harvester_main_loop` and `update_actor_runtime_state` far enough to recover the native walk/stand animation state handling, since the current startup click-to-walk path only mirrors placement and facing
+  - if that movement/state pass stays too broad, add a bounded input replay for the first `PC_DRESR1` click so the IDENT-first then `COM1A -> PCDRWR` path can be confirmed end-to-end before widening room-loop coverage
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index b9a60d7a616..19d5e6b2df9 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -19,6 +19,8 @@
  *
  */
 
+#include <math.h>
+
 #include "harvester/runtime_entity.h"
 
 #include "common/debug.h"
@@ -41,6 +43,30 @@ static const int kFramesPerSequence = 10;
 static const uint32 kAnimationClockDivisorMs = 10;
 static const byte kTransparentPaletteIndex = 0;
 
+static int roundToInt(float value) {
+	return value >= 0.0f ? (int)floorf(value + 0.5f) : (int)ceilf(value - 0.5f);
+}
+
+static int scaleDimension(uint32 value, float scale) {
+	return MAX<int>(roundToInt((float)value * scale), 1);
+}
+
+static void scaleIndexedBitmapNearest(const IndexedBitmap &source, IndexedBitmap &dest,
+		int scaledWidth, int scaledHeight) {
+	dest.width = (uint32)scaledWidth;
+	dest.height = (uint32)scaledHeight;
+	dest.pixels.resize((uint32)scaledWidth * (uint32)scaledHeight);
+
+	for (int y = 0; y < scaledHeight; ++y) {
+		const uint32 srcY = MIN<uint32>(((uint32)y * source.height) / (uint32)scaledHeight, source.height - 1);
+		for (int x = 0; x < scaledWidth; ++x) {
+			const uint32 srcX = MIN<uint32>(((uint32)x * source.width) / (uint32)scaledWidth, source.width - 1);
+			dest.pixels[(uint32)y * (uint32)scaledWidth + (uint32)x] =
+				source.pixels[srcY * source.width + srcX];
+		}
+	}
+}
+
 static uint32 getAnimationClockTicks() {
 	// The original runtime entity animation timer is driven from a centisecond DOS clock.
 	return g_system ? (g_system->getMillis() / kAnimationClockDivisorMs) : 0;
@@ -144,14 +170,15 @@ bool RuntimeEntity::loadBitmapResource(ResourceManager &resources, const Common:
 
 	_frames.clear();
 	_frames.push_back(frame);
+	_baseFrames = _frames;
 	_resourcePath = path;
 	_currentFrame = 0;
 	_firstFrame = 0;
 	_lastFrame = 0;
 	_animationEnabled = false;
 	_drawEnabled = true;
-	_boundsWidth = frame.width;
-	_boundsHeight = frame.height;
+	_depthScale = 1.0f;
+	updateBoundsFromCurrentFrame();
 	_hitTestMode = kRuntimeEntityHitTestOpaquePixels;
 	return true;
 }
@@ -196,13 +223,14 @@ bool RuntimeEntity::loadAbmResource(ResourceManager &resources, const Common::St
 	}
 
 	_resourcePath = path;
+	_baseFrames = _frames;
 	_currentFrame = _frames.empty() ? -1 : 0;
 	_firstFrame = _currentFrame;
 	_lastFrame = _frames.empty() ? -1 : (int)_frames.size() - 1;
 	_animationEnabled = !_frames.empty();
 	_drawEnabled = true;
-	_boundsWidth = _frames.empty() ? 0 : _frames[0].width;
-	_boundsHeight = _frames.empty() ? 0 : _frames[0].height;
+	_depthScale = 1.0f;
+	updateBoundsFromCurrentFrame();
 	_hitTestMode = kRuntimeEntityHitTestOpaquePixels;
 	return true;
 }
@@ -253,6 +281,7 @@ void RuntimeEntity::setAnimationSequence(int sequence) {
 
 void RuntimeEntity::configureHotspotBounds(int width, int height) {
 	_frames.clear();
+	_baseFrames.clear();
 	_currentFrame = -1;
 	_firstFrame = -1;
 	_lastFrame = -1;
@@ -261,6 +290,34 @@ void RuntimeEntity::configureHotspotBounds(int width, int height) {
 	_boundsWidth = MAX(width, 1);
 	_boundsHeight = MAX(height, 1);
 	_hitTestMode = kRuntimeEntityHitTestBounds;
+	_depthScale = 1.0f;
+}
+
+bool RuntimeEntity::getCurrentFrameMetrics(int &width, int &height, int &xOffset, int &yOffset) const {
+	if (_frames.empty() || _currentFrame < 0 || (uint)_currentFrame >= _frames.size())
+		return false;
+
+	const AbmFrame &frame = _frames[(uint)_currentFrame];
+	width = (int)frame.width;
+	height = (int)frame.height;
+	xOffset = frame.xOffset;
+	yOffset = frame.yOffset;
+	return true;
+}
+
+void RuntimeEntity::setDepthScale(float scale) {
+	if (_frames.empty())
+		return;
+
+	const float newScale = scale > 0.0f ? scale : 1.0f;
+	if (fabsf(_depthScale - newScale) < 0.0001f)
+		return;
+
+	if (_baseFrames.empty())
+		_baseFrames = _frames;
+
+	_depthScale = newScale;
+	rebuildScaledFrames();
 }
 
 bool RuntimeEntity::tickVisualState(uint32 now) {
@@ -441,48 +498,91 @@ void RuntimeEntity::advanceAnimationFrame(int directive) {
 	if (directive == -1) {
 		--_currentFrame;
 		if (_currentFrame >= _firstFrame)
-			return;
+			goto done;
 
 		if (!_looping) {
 			_currentFrame = _firstFrame;
 			if (_classId == kRuntimeEntityClassAnimation)
 				_animationEnabled = false;
-			return;
+			goto done;
 		}
 
 		if (!_pingPong) {
 			_currentFrame = _lastFrame;
-			return;
+			goto done;
 		}
 
 		_playBackwards = false;
 		_currentFrame = _firstFrame;
-		return;
+		goto done;
 	}
 
 	if (directive == -2) {
 		++_currentFrame;
 		if (_currentFrame <= _lastFrame)
-			return;
+			goto done;
 
 		if (!_looping) {
 			_currentFrame = _lastFrame;
 			if (_classId == kRuntimeEntityClassAnimation)
 				_animationEnabled = false;
-			return;
+			goto done;
 		}
 
 		if (_pingPong) {
 			_playBackwards = true;
 			_currentFrame = _lastFrame;
-			return;
+			goto done;
 		}
 
 		_currentFrame = _firstFrame;
-		return;
+		goto done;
 	}
 
 	_currentFrame = CLIP<int>(directive, 0, (int)_frames.size() - 1);
+
+done:
+	updateBoundsFromCurrentFrame();
+}
+
+void RuntimeEntity::updateBoundsFromCurrentFrame() {
+	if (_frames.empty()) {
+		_boundsWidth = 0;
+		_boundsHeight = 0;
+		return;
+	}
+
+	const int frameIndex = (_currentFrame >= 0 && (uint)_currentFrame < _frames.size()) ? _currentFrame : 0;
+	_boundsWidth = (int)_frames[(uint)frameIndex].width;
+	_boundsHeight = (int)_frames[(uint)frameIndex].height;
+}
+
+void RuntimeEntity::rebuildScaledFrames() {
+	if (_baseFrames.empty()) {
+		_frames.clear();
+		updateBoundsFromCurrentFrame();
+		return;
+	}
+
+	if (fabsf(_depthScale - 1.0f) < 0.0001f) {
+		_frames = _baseFrames;
+		updateBoundsFromCurrentFrame();
+		return;
+	}
+
+	_frames.resize(_baseFrames.size());
+	for (uint i = 0; i < _baseFrames.size(); ++i) {
+		const AbmFrame &source = _baseFrames[i];
+		AbmFrame &scaled = _frames[i];
+		const int scaledWidth = scaleDimension(source.width, _depthScale);
+		const int scaledHeight = scaleDimension(source.height, _depthScale);
+
+		scaleIndexedBitmapNearest(source, scaled, scaledWidth, scaledHeight);
+		scaled.xOffset = roundToInt((float)source.xOffset * _depthScale);
+		scaled.yOffset = roundToInt((float)source.yOffset * _depthScale);
+	}
+
+	updateBoundsFromCurrentFrame();
 }
 
 RuntimeEntityManager::RuntimeEntityManager(ResourceManager &resources) : _resources(resources) {
@@ -678,6 +778,15 @@ const RuntimeEntity *RuntimeEntityManager::findSceneEntityByName(const Common::S
 	return nullptr;
 }
 
+RuntimeEntity *RuntimeEntityManager::findSceneEntityByName(const Common::String &name) {
+	for (RuntimeEntity *entity : _sceneEntities) {
+		if (entity->getName().equalsIgnoreCase(name))
+			return entity;
+	}
+
+	return nullptr;
+}
+
 void RuntimeEntityManager::insertSceneEntity(RuntimeEntity *entity) {
 	uint insertIndex = 0;
 	while (insertIndex < _sceneEntities.size() && _sceneEntities[insertIndex]->getZ() >= entity->getZ())
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index aa45ce210f9..cad2ce3d2ec 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -93,6 +93,8 @@ public:
 	void setHitTestMode(RuntimeEntityHitTestMode mode) { _hitTestMode = mode; }
 	int getBoundsWidth() const { return _boundsWidth; }
 	int getBoundsHeight() const { return _boundsHeight; }
+	bool getCurrentFrameMetrics(int &width, int &height, int &xOffset, int &yOffset) const;
+	void setDepthScale(float scale);
 	Common::Rect getScreenRect() const;
 
 	bool hasFrames() const { return !_frames.empty(); }
@@ -109,10 +111,13 @@ private:
 	bool hasOpaqueFrame() const;
 	bool isOpaqueAt(const Common::Point &point) const;
 	void advanceAnimationFrame(int directive);
+	void updateBoundsFromCurrentFrame();
+	void rebuildScaledFrames();
 
 	Common::String _name;
 	Common::String _resourcePath;
 	Common::Array<AbmFrame> _frames;
+	Common::Array<AbmFrame> _baseFrames;
 	int _classId = 0;
 	int _x = 0;
 	int _y = 0;
@@ -135,6 +140,7 @@ private:
 	RuntimeEntityHitTestMode _hitTestMode = kRuntimeEntityHitTestNone;
 	RuntimeEntityAnchorMode _anchorMode = kRuntimeEntityAnchorTopLeft;
 	float _zExtent = 0.0f;
+	float _depthScale = 1.0f;
 };
 
 class RuntimeEntityManager {
@@ -166,6 +172,7 @@ public:
 	void drawCursor(Graphics::Screen &screen) const;
 	const RuntimeEntity *findTopSceneEntityAt(const Common::Point &point, int classIdFilter = -1) const;
 	const RuntimeEntity *findSceneEntityByName(const Common::String &name) const;
+	RuntimeEntity *findSceneEntityByName(const Common::String &name);
 
 private:
 	void insertSceneEntity(RuntimeEntity *entity);
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index c688f3aa763..7a7d0f9afde 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -273,7 +273,23 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 				return;
 
 			StartupRoomRecord room;
+			if (tagIndex >= 6) {
+				room.minZ = atoi(tokens[0].c_str());
+				room.maxZ = atoi(tokens[1].c_str());
+				room.maxZScreenY = atoi(tokens[2].c_str());
+				room.minZScreenY = atoi(tokens[3].c_str());
+				room.fullScaleZ = atoi(tokens[4].c_str());
+				room.maxZScalePercent = atoi(tokens[5].c_str());
+				if (room.maxZ != room.fullScaleZ) {
+					room.perspectiveScale = ((100.0f - (float)room.maxZScalePercent) /
+						(float)(room.maxZ - room.fullScaleZ)) * 0.01f;
+				}
+			}
 			room.roomName = tokens[tagIndex + 1];
+			room.musicPath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
+			room.field38 = tokens[tagIndex + 3];
+			room.field3c = tokens[tagIndex + 4];
+			room.field40 = tokens[tagIndex + 5];
 			room.palettePath = resources.normalizeResourcePath(tokens[tagIndex + 6]);
 			room.dimmable = tokens[tagIndex + 7].equalsIgnoreCase("T");
 			room.onEnterCommand = tokens[tagIndex + 8];
@@ -410,6 +426,15 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 	state.roomName = room->roomName;
 	state.palettePath = room->palettePath;
 	state.backgroundPath = background->spritePath;
+	state.roomMinZ = room->minZ;
+	state.roomMaxZ = room->maxZ;
+	state.roomMaxZScreenY = room->maxZScreenY;
+	state.roomMinZScreenY = room->minZScreenY;
+	state.roomFullScaleZ = room->fullScaleZ;
+	state.roomMaxZScalePercent = room->maxZScalePercent;
+	state.roomPerspectiveScale = room->perspectiveScale;
+	state.roomZVelocityStep = room->zVelocityStep;
+	state.musicPath = room->musicPath;
 	if (entrance) {
 		state.entranceName = entrance->entranceName;
 		state.hasEntrance = true;
@@ -664,4 +689,12 @@ Common::String StartupScript::resolveObjectLabel(const StartupObjectRecord &obje
 	return Common::String();
 }
 
+Common::String StartupScript::resolveTextValue(const Common::String &key) const {
+	const StartupTextRecord *textRecord = findTextRecord(key);
+	if (textRecord && !textRecord->value.empty())
+		return textRecord->value;
+
+	return normalizeInteractionLabel(key);
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 02a48ad5a66..f43303280e9 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -40,7 +40,19 @@ struct StartupEntranceRecord {
 };
 
 struct StartupRoomRecord {
+	int minZ = 0;
+	int maxZ = 0;
+	int maxZScreenY = 0;
+	int minZScreenY = 0;
+	int fullScaleZ = 0;
+	int maxZScalePercent = 100;
+	float perspectiveScale = 0.0f;
+	float zVelocityStep = 1.0f;
 	Common::String roomName;
+	Common::String musicPath;
+	Common::String field38;
+	Common::String field3c;
+	Common::String field40;
 	Common::String palettePath;
 	Common::String onEnterCommand;
 	Common::String onExitCommand;
@@ -119,6 +131,14 @@ struct StartupRoomSetupState {
 	Common::String palettePath;
 	Common::String backgroundPath;
 	Common::String musicPath;
+	int roomMinZ = 0;
+	int roomMaxZ = 0;
+	int roomMaxZScreenY = 0;
+	int roomMinZScreenY = 0;
+	int roomFullScaleZ = 0;
+	int roomMaxZScalePercent = 100;
+	float roomPerspectiveScale = 0.0f;
+	float roomZVelocityStep = 1.0f;
 	bool hasEntrance = false;
 	int playerSpawnX = 0;
 	int playerSpawnY = 0;
@@ -160,6 +180,7 @@ public:
 	bool resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result) const;
 	bool resolveObjectInspectText(const StartupObjectRecord &object, StartupResolvedText &text) const;
 	Common::String resolveObjectLabel(const StartupObjectRecord &object) const;
+	Common::String resolveTextValue(const Common::String &key) const;
 
 private:
 	bool loadConfig(ResourceManager &resources);


Commit: 9d72feb4736e7f4c0d33905706e9cd404a7a6577
    https://github.com/scummvm/scummvm/commit/9d72feb4736e7f4c0d33905706e9cd404a7a6577
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:40+02:00

Commit Message:
HARVESTER: Enable inspect hotspots

Restore startup walk ranges alongside inspect hotspot handling.

Changed paths:
    engines/harvester/detection.cpp
    engines/harvester/detection.h
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_script.cpp


diff --git a/engines/harvester/detection.cpp b/engines/harvester/detection.cpp
index 94d8986cd47..9429eeb8c66 100644
--- a/engines/harvester/detection.cpp
+++ b/engines/harvester/detection.cpp
@@ -27,6 +27,7 @@
 const DebugChannelDef HarvesterMetaEngineDetection::debugFlagList[] = {
 	{ Harvester::kDebugGeneral, "general", "General debug level" },
 	{ Harvester::kDebugResources, "resources", "Resource loading" },
+	{ Harvester::kDebugScene, "scene", "Startup room, hotspot, and actor debug" },
 	DEBUG_CHANNEL_END
 };
 
diff --git a/engines/harvester/detection.h b/engines/harvester/detection.h
index 43cba9bbab7..15039ef9456 100644
--- a/engines/harvester/detection.h
+++ b/engines/harvester/detection.h
@@ -30,7 +30,8 @@ namespace Harvester {
 
 enum HarvesterDebugChannels {
 	kDebugGeneral   = 1 << 0,
-	kDebugResources = 1 << 1
+	kDebugResources = 1 << 1,
+	kDebugScene     = 1 << 2
 };
 
 extern const PlainGameDescriptor harvesterGames[];
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index 19d5e6b2df9..c2adba270b3 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -260,6 +260,27 @@ void RuntimeEntity::setCurrentFrame(int frame) {
 	advanceAnimationFrame(frame);
 }
 
+void RuntimeEntity::setAnimationFrameRange(int firstFrame, int lastFrame, bool looping) {
+	if (_frames.empty())
+		return;
+
+	firstFrame = CLIP<int>(firstFrame, 0, (int)_frames.size() - 1);
+	lastFrame = CLIP<int>(lastFrame, 0, (int)_frames.size() - 1);
+	if (lastFrame < firstFrame)
+		SWAP(firstFrame, lastFrame);
+
+	_animationSequence = -1;
+	_looping = looping;
+	_playBackwards = false;
+	_firstFrame = firstFrame;
+	_lastFrame = lastFrame;
+	_animationEnabled = looping && firstFrame != lastFrame;
+	if (_currentFrame < _firstFrame || _currentFrame > _lastFrame)
+		advanceAnimationFrame(_firstFrame);
+	else
+		updateBoundsFromCurrentFrame();
+}
+
 void RuntimeEntity::setAnimationSequence(int sequence) {
 	if (_frames.empty() || sequence == _animationSequence)
 		return;
@@ -714,6 +735,11 @@ RuntimeEntity *RuntimeEntityManager::spawnSceneActorEntity(const Common::String
 	entity->setZExtent(3.0f);
 	entity->setHitTestMode(kRuntimeEntityHitTestNone);
 	entity->setCurrentFrame(initialFrame);
+	debugC(1, kDebugScene,
+		"Harvester: spawned scene actor '%s' resource='%s' frames=0..%d initial_frame=%d animation_rate=%d sequence=%d pos=(%d,%d,z=%.2f)",
+		name.c_str(), resourcePath.c_str(), entity->getLastFrame(), initialFrame,
+		entity->getAnimationRate(), entity->getAnimationSequence(),
+		position.x, position.y, (double)z);
 	insertSceneEntity(entity);
 	return entity;
 }
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index cad2ce3d2ec..177794ba3de 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -87,6 +87,7 @@ public:
 	void setCurrentFrame(int frame);
 	int getCurrentFrame() const { return _currentFrame; }
 	int getLastFrame() const { return _lastFrame; }
+	void setAnimationFrameRange(int firstFrame, int lastFrame, bool looping);
 	void setAnimationSequence(int sequence);
 	int getAnimationSequence() const { return _animationSequence; }
 	void configureHotspotBounds(int width, int height);
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index ef06b4d5e64..50acffa6c66 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -72,6 +72,7 @@ static const uint32 kPaletteFadeTickMs = 4;
 static const float kPaletteFadeStep = 0.1f;
 static const float kPaletteBrightnessBlack = 0.0f;
 static const float kRoomPlayerMoveStep = 4.0f;
+static const int kRoomPlayerWalkAnimationRate = 17;
 
 static const byte kIdentTextColor = 0xd3;
 static const byte kTextColorNormal = 255;
@@ -116,6 +117,16 @@ struct StartupRoomHoverState {
 	int cursorSequence = kCursorSequenceNeutral;
 };
 
+struct PlayerAnimationRange {
+	PlayerAnimationRange() {}
+	PlayerAnimationRange(int walkFirstFrame, int walkLastFrame, int idleFrame)
+		: walkFirstFrame(walkFirstFrame), walkLastFrame(walkLastFrame), idleFrame(idleFrame) {}
+
+	int walkFirstFrame = 0;
+	int walkLastFrame = 0;
+	int idleFrame = 0;
+};
+
 static int roundToInt(float value) {
 	return value >= 0.0f ? (int)floorf(value + 0.5f) : (int)ceilf(value - 0.5f);
 }
@@ -135,6 +146,21 @@ static int resolvePlayerFacingFrame(int facing) {
 	}
 }
 
+static PlayerAnimationRange resolvePlayerAnimationRange(int facing) {
+	switch (facing) {
+	case 0:
+		return PlayerAnimationRange{ 0x00, 0x09, 0x3b };
+	case 1:
+		return PlayerAnimationRange{ 0x0f, 0x18, 0x0e };
+	case 2:
+		return PlayerAnimationRange{ 0x2d, 0x36, 0x2c };
+	case 3:
+		return PlayerAnimationRange{ 0x1e, 0x27, 0x28 };
+	default:
+		return PlayerAnimationRange{ 0x00, 0x09, 0x3b };
+	}
+}
+
 static bool roomSupportsMovementBand(const StartupRoomSetupState &state) {
 	return state.roomMaxZScreenY >= 0 &&
 		state.roomMinZScreenY >= state.roomMaxZScreenY;
@@ -345,30 +371,62 @@ static Common::String resolveSceneObjectSpritePath(const StartupObjectRecord &ob
 	return object.spritePath;
 }
 
+static void logSceneObjectSelection(const char *decision, const char *source, const StartupObjectRecord &object,
+		const Common::String &detail = Common::String()) {
+	const Common::Rect hotspotBounds = getHotspotBounds(object);
+	const Common::String resolvedSpritePath = resolveSceneObjectSpritePath(object);
+	debugC(1, kDebugScene,
+		"Harvester: scene object %s source='%s' object='%s' owner='%s' visible=%d runtimeVisible=%d sprite='%s' alt='%s' resolved='%s' pos=(%d,%d,%d) bounds=(%d,%d)-(%d,%d) action='%s' detail='%s'",
+		decision, source, object.objectName.c_str(), object.currentOwnerOrRoom.c_str(),
+		object.visible, object.runtimeVisible, object.spritePath.c_str(), object.altSpritePath.c_str(),
+		resolvedSpritePath.c_str(),
+		object.currentX, object.currentY, object.currentZ,
+		hotspotBounds.left, hotspotBounds.top, hotspotBounds.right, hotspotBounds.bottom,
+		object.actionTag.c_str(), detail.c_str());
+}
+
 static bool isBackgroundSceneObject(const StartupObjectRecord &object, const RuntimeEntity &entity) {
 	return object.initialX == 0 && object.initialY == 0 &&
 		entity.getBoundsWidth() == 640 && entity.getBoundsHeight() == 480;
 }
 
-static int resolveSceneObjectClass(const StartupObjectRecord &object, const RuntimeEntity *entity) {
+static bool isInteractiveSceneHotspot(const StartupObjectRecord &object, StartupScript *startupScript) {
+	if (object.operatable || !object.actionTag.empty())
+		return true;
+	if (!startupScript)
+		return false;
+
+	StartupResolvedText inspectText;
+	return startupScript->resolveObjectInspectText(object, inspectText);
+}
+
+static int resolveSceneObjectClass(const StartupObjectRecord &object, const RuntimeEntity *entity,
+		StartupScript *startupScript) {
 	if (entity)
 		return isBackgroundSceneObject(object, *entity) ? kRuntimeEntityClassBackground : kRuntimeEntityClassObject;
 
-	return object.actionTag.empty() ? kRuntimeEntityClassDisabledHotspot : kRuntimeEntityClassRectHotspot;
+	return isInteractiveSceneHotspot(object, startupScript)
+		? kRuntimeEntityClassRectHotspot
+		: kRuntimeEntityClassDisabledHotspot;
 }
 
-static void queueVisibleSceneObject(const StartupObjectRecord &object, Common::Array<StartupObjectRecord> &sceneObjects) {
-	if (!object.visible)
+static void queueVisibleSceneObject(const char *source, const StartupObjectRecord &object,
+		Common::Array<StartupObjectRecord> &sceneObjects) {
+	if (!object.visible) {
+		logSceneObjectSelection("skipped", source, object, "visible=0");
 		return;
+	}
 
 	for (const StartupObjectRecord &sceneObject : sceneObjects) {
 		if (sceneObject.currentOwnerOrRoom.equalsIgnoreCase(object.currentOwnerOrRoom) &&
 			sceneObject.objectName.equalsIgnoreCase(object.objectName)) {
+			logSceneObjectSelection("skipped", source, object, "duplicate owner/object");
 			return;
 		}
 	}
 
 	sceneObjects.push_back(object);
+	logSceneObjectSelection("queued", source, object);
 }
 
 static bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceManager &resources, StartupRoomSceneResources &scene) {
@@ -381,9 +439,9 @@ static bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceM
 
 	Common::Array<StartupObjectRecord> sceneObjects;
 	for (const StartupObjectRecord &object : state.roomObjects)
-		queueVisibleSceneObject(object, sceneObjects);
+		queueVisibleSceneObject("room", object, sceneObjects);
 	for (const StartupObjectRecord &object : state.activeObjects)
-		queueVisibleSceneObject(object, sceneObjects);
+		queueVisibleSceneObject("active", object, sceneObjects);
 
 	scene.sceneObjects = sceneObjects;
 	for (const StartupAnimRecord &anim : state.roomAnimations) {
@@ -532,14 +590,44 @@ static StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, cons
 	return hoverState;
 }
 
-static bool setPlayerFacingFrame(StartupRoomPlayerState &playerState, int facing) {
+static bool setPlayerIdleAnimation(StartupRoomPlayerState &playerState, int facing) {
+	if (!playerState.entity)
+		return false;
+
+	const PlayerAnimationRange range = resolvePlayerAnimationRange(facing);
+	const bool changed = playerState.facing != facing ||
+		playerState.entity->getCurrentFrame() != range.idleFrame ||
+		playerState.entity->getAnimationRate() != 0;
+	playerState.facing = facing;
+	playerState.entity->setAnimationRate(0);
+	playerState.entity->setAnimationFrameRange(range.idleFrame, range.idleFrame, false);
+	playerState.entity->setCurrentFrame(range.idleFrame);
+	if (changed) {
+		debugC(1, kDebugScene,
+			"Harvester: player idle animation facing=%d frame=%d",
+			facing, range.idleFrame);
+	}
+	return changed;
+}
+
+static bool setPlayerWalkAnimation(StartupRoomPlayerState &playerState, int facing) {
 	if (!playerState.entity)
 		return false;
-	if (playerState.facing == facing)
+
+	const PlayerAnimationRange range = resolvePlayerAnimationRange(facing);
+	const int currentFrame = playerState.entity->getCurrentFrame();
+	const bool alreadyWalking = playerState.facing == facing &&
+		playerState.entity->getAnimationRate() == kRoomPlayerWalkAnimationRate &&
+		currentFrame >= range.walkFirstFrame && currentFrame <= range.walkLastFrame;
+	if (alreadyWalking)
 		return false;
 
 	playerState.facing = facing;
-	playerState.entity->setCurrentFrame(resolvePlayerFacingFrame(facing));
+	playerState.entity->setAnimationFrameRange(range.walkFirstFrame, range.walkLastFrame, true);
+	playerState.entity->setAnimationRate(kRoomPlayerWalkAnimationRate);
+	debugC(1, kDebugScene,
+		"Harvester: player walk animation facing=%d frames=%d..%d rate=%d",
+		facing, range.walkFirstFrame, range.walkLastFrame, kRoomPlayerWalkAnimationRate);
 	return true;
 }
 
@@ -549,6 +637,10 @@ static void setPlayerMoveTarget(const StartupRoomSetupState &state, StartupRoomP
 	playerState.targetX = CLIP<int>(targetX, 0, 639);
 	playerState.targetY = clampRoomMovementY(state, targetBottomY);
 	playerState.targetZ = mapRoomScreenYToDepth(state, playerState.targetY);
+	debugC(1, kDebugScene,
+		"Harvester: player move target room='%s' current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f)",
+		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
+		playerState.targetX, playerState.targetY, (double)playerState.targetZ);
 }
 
 static bool stepPlayerMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState) {
@@ -558,23 +650,30 @@ static bool stepPlayerMoveTarget(const StartupRoomSetupState &state, StartupRoom
 	const float dx = (float)(playerState.targetX - playerState.centerX);
 	const float dy = (float)(playerState.targetY - playerState.bottomY);
 	const float distance = sqrtf(dx * dx + dy * dy);
+	const int moveFacing = distance > 0.0f
+		? resolveFacingFromMovementDelta((int)dx, (int)dy)
+		: playerState.facing;
 	if (distance <= kRoomPlayerMoveStep) {
 		playerState.centerX = playerState.targetX;
 		playerState.bottomY = playerState.targetY;
 		playerState.z = playerState.targetZ;
 		playerState.hasMoveTarget = false;
+		(void)setPlayerIdleAnimation(playerState, moveFacing);
 	} else {
+		(void)setPlayerWalkAnimation(playerState, moveFacing);
 		playerState.centerX += roundToInt((dx / distance) * kRoomPlayerMoveStep);
 		playerState.bottomY += roundToInt((dy / distance) * kRoomPlayerMoveStep);
 		playerState.bottomY = clampRoomMovementY(state, playerState.bottomY);
 		playerState.z = mapRoomScreenYToDepth(state, playerState.bottomY);
 	}
-
-	(void)setPlayerFacingFrame(playerState,
-		resolveFacingFromMovementDelta(playerState.targetX - playerState.centerX,
-			playerState.targetY - playerState.bottomY));
-	return applyRoomActorPlacement(state, *playerState.entity,
+	const bool placed = applyRoomActorPlacement(state, *playerState.entity,
 		playerState.centerX, playerState.bottomY, playerState.z);
+	debugC(playerState.hasMoveTarget ? 2 : 1, kDebugScene,
+		"Harvester: player move step room='%s' pos=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f) facing=%d frame=%d active=%d placed=%d",
+		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
+		playerState.targetX, playerState.targetY, (double)playerState.targetZ,
+		playerState.facing, playerState.entity->getCurrentFrame(), playerState.hasMoveTarget, placed);
+	return placed;
 }
 
 static bool stepPlayerKeyboardMovement(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
@@ -588,13 +687,18 @@ static bool stepPlayerKeyboardMovement(const StartupRoomSetupState &state, Start
 		return false;
 
 	playerState.hasMoveTarget = false;
+	(void)setPlayerWalkAnimation(playerState, resolveFacingFromMovementDelta(dx, dy));
 	playerState.centerX = CLIP<int>(playerState.centerX + roundToInt(dx * kRoomPlayerMoveStep), 0, 639);
 	playerState.bottomY = clampRoomMovementY(state,
 		playerState.bottomY + roundToInt(dy * kRoomPlayerMoveStep));
 	playerState.z = mapRoomScreenYToDepth(state, playerState.bottomY);
-	(void)setPlayerFacingFrame(playerState, resolveFacingFromMovementDelta(dx, dy));
-	return applyRoomActorPlacement(state, *playerState.entity,
+	const bool placed = applyRoomActorPlacement(state, *playerState.entity,
 		playerState.centerX, playerState.bottomY, playerState.z);
+	debugC(2, kDebugScene,
+		"Harvester: player keyboard move room='%s' delta=(%d,%d) pos=(%d,%d,z=%.2f) facing=%d frame=%d placed=%d",
+		state.roomName.c_str(), dx, dy, playerState.centerX, playerState.bottomY, (double)playerState.z,
+		playerState.facing, playerState.entity->getCurrentFrame(), placed);
+	return placed;
 }
 
 static bool findRoomObjectProbePoint(HarvesterEngine &engine, const Common::Array<StartupObjectRecord> &sceneObjects,
@@ -1176,6 +1280,11 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 
 				const StartupRoomHoverState hoverState = resolveRoomHoverState(
 					_engine, scene.state, scene.sceneObjects, _mousePos);
+				debugC(1, kDebugScene,
+					"Harvester: room click room='%s' mouse=(%d,%d) object='%s' cursor_sequence=%d prompt='%s'",
+					scene.state.roomName.c_str(), _mousePos.x, _mousePos.y,
+					hoverState.object ? hoverState.object->objectName.c_str() : "",
+					hoverState.cursorSequence, hoverState.promptText.c_str());
 				StartupObjectRecord *clickedObject = hoverState.object
 					? findSceneObjectByName(scene.sceneObjects, hoverState.object->objectName)
 					: nullptr;
@@ -1295,6 +1404,9 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 			needsRedraw = true;
 		else if (stepPlayerMoveTarget(scene.state, playerState))
 			needsRedraw = true;
+		else if (!moveLeft && !moveRight && !moveUp && !moveDown && !playerState.hasMoveTarget &&
+				playerState.entity && playerState.facing >= 0 && setPlayerIdleAnimation(playerState, playerState.facing))
+			needsRedraw = true;
 
 		if (tickRuntimeEntities())
 			needsRedraw = true;
@@ -1320,6 +1432,7 @@ bool StartupFlow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 		const Common::Array<StartupObjectRecord> &drawableObjects,
 		const Common::Array<StartupAnimRecord> &drawableAnimations) {
 	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+	StartupScript *startupScript = _engine.getStartupScript();
 	if (!runtimeEntities)
 		return false;
 
@@ -1327,24 +1440,39 @@ bool StartupFlow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 	for (const StartupObjectRecord &object : drawableObjects) {
 		RuntimeEntity *entity = nullptr;
 		const Common::String spritePath = resolveSceneObjectSpritePath(object);
+		const Common::Rect hotspotBounds = getHotspotBounds(object);
 		if (!spritePath.empty() && spritePath.hasSuffixIgnoreCase(".BM")) {
 			entity = runtimeEntities->spawnSceneBitmapEntity(object.objectName, spritePath,
 				Common::Point(object.currentX, object.currentY), (float)object.currentZ);
 		} else {
-			const Common::Rect hotspotBounds = getHotspotBounds(object);
 			if (!hotspotBounds.isEmpty())
 				entity = runtimeEntities->spawnSceneHotspotEntity(object.objectName, hotspotBounds, (float)object.currentZ);
 		}
 
 		if (!entity) {
+			debugC(1, kDebugScene,
+				"Harvester: scene entity skipped room='%s' object='%s' resolved='%s' bounds=(%d,%d)-(%d,%d) reason='%s'",
+				state.roomName.c_str(), object.objectName.c_str(), spritePath.c_str(),
+				hotspotBounds.left, hotspotBounds.top, hotspotBounds.right, hotspotBounds.bottom,
+				spritePath.empty() || !spritePath.hasSuffixIgnoreCase(".BM")
+					? "no_bitmap_and_no_hotspot_bounds"
+					: "spawn_failed");
 			debug(1, "Harvester: unable to spawn room object entity '%s' from '%s'",
 				object.objectName.c_str(), spritePath.c_str());
 			continue;
 		}
 
-		entity->setClassId(resolveSceneObjectClass(object, entity->hasFrames() ? entity : nullptr));
+		entity->setClassId(resolveSceneObjectClass(object, entity->hasFrames() ? entity : nullptr, startupScript));
 		entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
 		entity->setZExtent((float)object.zExtent);
+		const Common::Rect entityRect = entity->getScreenRect();
+		debugC(1, kDebugScene,
+			"Harvester: scene entity spawned room='%s' object='%s' type='%s' class=0x%x pos=(%d,%d,z=%.2f) rect=(%d,%d)-(%d,%d) sprite='%s' action='%s' operatable=%d ident='%s'",
+			state.roomName.c_str(), object.objectName.c_str(),
+			entity->hasFrames() ? "bitmap" : "hotspot", entity->getClassId(),
+			entity->getX(), entity->getY(), (double)entity->getZ(),
+			entityRect.left, entityRect.top, entityRect.right, entityRect.bottom,
+			spritePath.c_str(), object.actionTag.c_str(), object.operatable, object.identTextKey.c_str());
 	}
 	for (const StartupAnimRecord &anim : drawableAnimations) {
 		if (!runtimeEntities->spawnSceneAnimationEntity(anim.animName, anim.resourcePath,
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 7a7d0f9afde..66fca720d0a 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -519,30 +519,45 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 			break;
 		}
 
+		debugC(1, kDebugScene,
+			"Harvester: room setup command room='%s' step=%u tag='%s' opcode='%s' args=['%s','%s','%s','%s']",
+			room->roomName.c_str(), step, currentTag.c_str(), command->opcodeName.c_str(),
+			command->arg1.c_str(), command->arg2.c_str(), command->arg3.c_str(), command->arg4.c_str());
+
 		if (command->opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
+			debugC(1, kDebugScene, "Harvester: room setup flag '%s' -> %d",
+				command->arg1.c_str(), getFlagValue(command->arg1));
 			currentTag = getFlagValue(command->arg1) ? command->arg2 : command->arg3;
 			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("SET_FLAG")) {
+			debugC(1, kDebugScene, "Harvester: room setup set flag '%s' = %d",
+				command->arg1.c_str(), command->arg2.equalsIgnoreCase("T"));
 			setFlagValue(command->arg1, command->arg2.equalsIgnoreCase("T"));
 			currentTag = command->arg4;
 			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("SPOOL_MUSIC")) {
+			debugC(1, kDebugScene, "Harvester: room setup spool music '%s'",
+				command->arg1.c_str());
 			state.musicPath = resources.normalizeResourcePath(command->arg1);
 			currentTag = command->arg4;
 			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("ADD")) {
+			debugC(1, kDebugScene, "Harvester: room setup add object owner='%s' object='%s'",
+				command->arg1.c_str(), command->arg2.c_str());
 			addObject(command->arg1, command->arg2);
 			currentTag = command->arg4;
 			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("DELETE")) {
+			debugC(1, kDebugScene, "Harvester: room setup delete object owner='%s' object='%s'",
+				command->arg1.c_str(), command->arg2.c_str());
 			removeObject(command->arg1, command->arg2);
 			currentTag = command->arg4;
 			continue;
@@ -557,6 +572,23 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 		? kDimmedPaletteBrightness
 		: kDefaultPaletteBrightness;
 
+	for (const StartupObjectRecord &object : state.roomObjects) {
+		debugC(1, kDebugScene,
+			"Harvester: room setup room object room='%s' object='%s' owner='%s' visible=%d runtimeVisible=%d sprite='%s' alt='%s' pos=(%d,%d,%d) bounds=(%d,%d)-(%d,%d) action='%s'",
+			state.roomName.c_str(), object.objectName.c_str(), object.currentOwnerOrRoom.c_str(),
+			object.visible, object.runtimeVisible, object.spritePath.c_str(), object.altSpritePath.c_str(),
+			object.currentX, object.currentY, object.currentZ,
+			object.currentX, object.currentY, object.boundsX2, object.boundsY2, object.actionTag.c_str());
+	}
+	for (const StartupObjectRecord &object : state.activeObjects) {
+		debugC(1, kDebugScene,
+			"Harvester: room setup active object room='%s' object='%s' owner='%s' visible=%d runtimeVisible=%d sprite='%s' alt='%s' pos=(%d,%d,%d) bounds=(%d,%d)-(%d,%d) action='%s'",
+			state.roomName.c_str(), object.objectName.c_str(), object.currentOwnerOrRoom.c_str(),
+			object.visible, object.runtimeVisible, object.spritePath.c_str(), object.altSpritePath.c_str(),
+			object.currentX, object.currentY, object.currentZ,
+			object.currentX, object.currentY, object.boundsX2, object.boundsY2, object.actionTag.c_str());
+	}
+
 	debugC(1, kDebugGeneral,
 		"Harvester: resolveRoomSetupState('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u",
 		entranceName.c_str(), state.roomName.c_str(), state.entranceName.c_str(),


Commit: 84ba72dc7a8a34bc69ad453756059d2d52f566f2
    https://github.com/scummvm/scummvm/commit/84ba72dc7a8a34bc69ad453756059d2d52f566f2
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:41+02:00

Commit Message:
HARVESTER: Add startup keyboard turn delay

Changed paths:
    engines/harvester/startup_flow.cpp


diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 50acffa6c66..fc95bc16cf8 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -109,6 +109,12 @@ struct StartupRoomPlayerState {
 	int targetX = 0;
 	int targetY = 0;
 	float targetZ = 0.0f;
+	bool turnActive = false;
+	int turnTargetFacing = -1;
+	int turnFirstFrame = -1;
+	int turnLastFrame = -1;
+	int turnEndFrame = -1;
+	bool turnPlayBackwards = false;
 };
 
 struct StartupRoomHoverState {
@@ -127,6 +133,16 @@ struct PlayerAnimationRange {
 	int idleFrame = 0;
 };
 
+struct PlayerTurnAnimationRange {
+	PlayerTurnAnimationRange() {}
+	PlayerTurnAnimationRange(int firstFrame, int lastFrame, bool playBackwards)
+		: firstFrame(firstFrame), lastFrame(lastFrame), playBackwards(playBackwards) {}
+
+	int firstFrame = 0;
+	int lastFrame = 0;
+	bool playBackwards = false;
+};
+
 static int roundToInt(float value) {
 	return value >= 0.0f ? (int)floorf(value + 0.5f) : (int)ceilf(value - 0.5f);
 }
@@ -161,6 +177,44 @@ static PlayerAnimationRange resolvePlayerAnimationRange(int facing) {
 	}
 }
 
+static bool resolvePlayerTurnAnimationRange(int fromFacing, int toFacing, PlayerTurnAnimationRange &range) {
+	// Ghidra confirms stationary startup keyboard turn states between the locomotion banks.
+	if (fromFacing == 0 && toFacing == 1) {
+		range = PlayerTurnAnimationRange{ 0x0a, 0x0e, true };
+		return true;
+	}
+	if (fromFacing == 1 && toFacing == 0) {
+		range = PlayerTurnAnimationRange{ 0x0a, 0x0e, false };
+		return true;
+	}
+	if (fromFacing == 1 && toFacing == 3) {
+		range = PlayerTurnAnimationRange{ 0x19, 0x1d, true };
+		return true;
+	}
+	if (fromFacing == 3 && toFacing == 1) {
+		range = PlayerTurnAnimationRange{ 0x19, 0x1d, false };
+		return true;
+	}
+	if (fromFacing == 3 && toFacing == 2) {
+		range = PlayerTurnAnimationRange{ 0x28, 0x2c, true };
+		return true;
+	}
+	if (fromFacing == 2 && toFacing == 3) {
+		range = PlayerTurnAnimationRange{ 0x28, 0x2c, false };
+		return true;
+	}
+	if (fromFacing == 2 && toFacing == 0) {
+		range = PlayerTurnAnimationRange{ 0x37, 0x3b, true };
+		return true;
+	}
+	if (fromFacing == 0 && toFacing == 2) {
+		range = PlayerTurnAnimationRange{ 0x37, 0x3b, false };
+		return true;
+	}
+
+	return false;
+}
+
 static bool roomSupportsMovementBand(const StartupRoomSetupState &state) {
 	return state.roomMaxZScreenY >= 0 &&
 		state.roomMinZScreenY >= state.roomMaxZScreenY;
@@ -631,6 +685,51 @@ static bool setPlayerWalkAnimation(StartupRoomPlayerState &playerState, int faci
 	return true;
 }
 
+static bool startPlayerTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing) {
+	if (!playerState.entity || playerState.facing < 0 || playerState.facing == targetFacing)
+		return false;
+
+	PlayerTurnAnimationRange range;
+	if (!resolvePlayerTurnAnimationRange(playerState.facing, targetFacing, range))
+		return false;
+
+	playerState.turnActive = true;
+	playerState.turnTargetFacing = targetFacing;
+	playerState.turnFirstFrame = range.firstFrame;
+	playerState.turnLastFrame = range.lastFrame;
+	playerState.turnEndFrame = range.playBackwards ? range.firstFrame : range.lastFrame;
+	playerState.turnPlayBackwards = range.playBackwards;
+	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
+	playerState.entity->setPlayBackwards(range.playBackwards);
+	playerState.entity->setAnimationRate(kRoomPlayerWalkAnimationRate);
+	playerState.entity->setCurrentFrame(range.playBackwards ? range.lastFrame : range.firstFrame);
+	debugC(1, kDebugScene,
+		"Harvester: player turn animation from=%d to=%d frames=%d..%d backwards=%d rate=%d",
+		playerState.facing, targetFacing, range.firstFrame, range.lastFrame,
+		range.playBackwards, kRoomPlayerWalkAnimationRate);
+	return true;
+}
+
+static bool updatePlayerTurnAnimationState(StartupRoomPlayerState &playerState) {
+	if (!playerState.turnActive || !playerState.entity)
+		return false;
+	if (playerState.entity->getCurrentFrame() != playerState.turnEndFrame)
+		return false;
+
+	playerState.turnActive = false;
+	playerState.facing = playerState.turnTargetFacing;
+	playerState.turnTargetFacing = -1;
+	playerState.turnFirstFrame = -1;
+	playerState.turnLastFrame = -1;
+	playerState.turnEndFrame = -1;
+	playerState.turnPlayBackwards = false;
+	playerState.entity->setAnimationRate(0);
+	debugC(1, kDebugScene,
+		"Harvester: player turn complete facing=%d frame=%d",
+		playerState.facing, playerState.entity->getCurrentFrame());
+	return true;
+}
+
 static void setPlayerMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
 		int targetX, int targetBottomY) {
 	playerState.hasMoveTarget = true;
@@ -644,7 +743,7 @@ static void setPlayerMoveTarget(const StartupRoomSetupState &state, StartupRoomP
 }
 
 static bool stepPlayerMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState) {
-	if (!playerState.entity || !playerState.hasMoveTarget)
+	if (!playerState.entity || !playerState.hasMoveTarget || playerState.turnActive)
 		return false;
 
 	const float dx = (float)(playerState.targetX - playerState.centerX);
@@ -685,9 +784,15 @@ static bool stepPlayerKeyboardMovement(const StartupRoomSetupState &state, Start
 	const int dy = (moveDown ? 1 : 0) - (moveUp ? 1 : 0);
 	if (dx == 0 && dy == 0)
 		return false;
+	if (playerState.turnActive)
+		return false;
+
+	const int desiredFacing = resolveFacingFromMovementDelta(dx, dy);
+	if (desiredFacing != playerState.facing && startPlayerTurnAnimation(playerState, desiredFacing))
+		return true;
 
 	playerState.hasMoveTarget = false;
-	(void)setPlayerWalkAnimation(playerState, resolveFacingFromMovementDelta(dx, dy));
+	(void)setPlayerWalkAnimation(playerState, desiredFacing);
 	playerState.centerX = CLIP<int>(playerState.centerX + roundToInt(dx * kRoomPlayerMoveStep), 0, 639);
 	playerState.bottomY = clampRoomMovementY(state,
 		playerState.bottomY + roundToInt(dy * kRoomPlayerMoveStep));
@@ -1215,6 +1320,8 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 	playerState.bottomY = state.playerSpawnY;
 	playerState.z = (float)state.playerSpawnZ;
 	playerState.facing = state.playerFacing;
+	playerState.turnActive = false;
+	playerState.turnTargetFacing = -1;
 	StartupResolvedText inspectText;
 	bool showingInspectText = false;
 	bool inspectCanDismiss = false;
@@ -1307,6 +1414,8 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 					showingInspectText = true;
 					inspectCanDismiss = false;
 					playerState.hasMoveTarget = false;
+					playerState.turnActive = false;
+					playerState.turnTargetFacing = -1;
 					needsRedraw = true;
 					break;
 				}
@@ -1326,6 +1435,8 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 				}
 
 				playerState.hasMoveTarget = false;
+				playerState.turnActive = false;
+				playerState.turnTargetFacing = -1;
 				if (!interaction.soundPath.empty()) {
 					(void)_engine.playStartupSound(interaction.soundPath);
 				}
@@ -1342,6 +1453,8 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 						playerState.z = (float)state.playerSpawnZ;
 						playerState.facing = state.playerFacing;
 						playerState.hasMoveTarget = false;
+						playerState.turnActive = false;
+						playerState.turnTargetFacing = -1;
 						needsRedraw = true;
 					} else if (roomError.getCode() != Common::kNoError) {
 						return roomError;
@@ -1354,6 +1467,8 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 						playerState.z = (float)state.playerSpawnZ;
 						playerState.facing = state.playerFacing;
 						playerState.hasMoveTarget = false;
+						playerState.turnActive = false;
+						playerState.turnTargetFacing = -1;
 						needsRedraw = true;
 					}
 				}
@@ -1400,11 +1515,15 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 			}
 		}
 
+		if (updatePlayerTurnAnimationState(playerState))
+			needsRedraw = true;
+
 		if (stepPlayerKeyboardMovement(scene.state, playerState, moveLeft, moveRight, moveUp, moveDown))
 			needsRedraw = true;
 		else if (stepPlayerMoveTarget(scene.state, playerState))
 			needsRedraw = true;
 		else if (!moveLeft && !moveRight && !moveUp && !moveDown && !playerState.hasMoveTarget &&
+				!playerState.turnActive &&
 				playerState.entity && playerState.facing >= 0 && setPlayerIdleAnimation(playerState, playerState.facing))
 			needsRedraw = true;
 


Commit: 5c00a14c604cafd9754dfd945f395d586d62b322
    https://github.com/scummvm/scummvm/commit/5c00a14c604cafd9754dfd945f395d586d62b322
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:41+02:00

Commit Message:
HARVESTER: Restore startup idle animation

Changed paths:
    engines/harvester/runtime_entity.cpp
    engines/harvester/startup_flow.cpp


diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index c2adba270b3..e8f4ef99d54 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -274,7 +274,7 @@ void RuntimeEntity::setAnimationFrameRange(int firstFrame, int lastFrame, bool l
 	_playBackwards = false;
 	_firstFrame = firstFrame;
 	_lastFrame = lastFrame;
-	_animationEnabled = looping && firstFrame != lastFrame;
+	_animationEnabled = firstFrame != lastFrame;
 	if (_currentFrame < _firstFrame || _currentFrame > _lastFrame)
 		advanceAnimationFrame(_firstFrame);
 	else
@@ -523,8 +523,7 @@ void RuntimeEntity::advanceAnimationFrame(int directive) {
 
 		if (!_looping) {
 			_currentFrame = _firstFrame;
-			if (_classId == kRuntimeEntityClassAnimation)
-				_animationEnabled = false;
+			_animationEnabled = false;
 			goto done;
 		}
 
@@ -545,8 +544,7 @@ void RuntimeEntity::advanceAnimationFrame(int directive) {
 
 		if (!_looping) {
 			_currentFrame = _lastFrame;
-			if (_classId == kRuntimeEntityClassAnimation)
-				_animationEnabled = false;
+			_animationEnabled = false;
 			goto done;
 		}
 
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index fc95bc16cf8..83abb3da480 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -90,6 +90,15 @@ static const int kCursorSequenceOperate = 4;
 static const int kCursorSequencePickup = 5;
 static const int kCursorSequenceTransition = 6;
 static const int kCursorSequenceNeutral = 7;
+static const char *const kPlayerIdleAnimationEntityName = "IDLE_ANIM";
+static const char *const kPlayerIdleAnimationResourcePath = "1:/GRAPHIC/ROOMANIM/PCLOUN02.ABM";
+static const uint32 kRuntimeClockDivisorMs = 10;
+static const uint32 kRoomPlayerIdleDelayTicks = 3000;
+static const int kRoomPlayerIdleAnimationRate = 30;
+static const int kRoomPlayerIdleLoopStartFrame = 0x0f;
+static const int kRoomPlayerIdleLoopLastFrame = 0xb2;
+static const int kRoomPlayerIdleExitLastFrame = 0xbf;
+static const int kRoomPlayerIdleYOffset = 4;
 
 struct StartupRoomSceneResources {
 	StartupRoomSetupState state;
@@ -123,6 +132,17 @@ struct StartupRoomHoverState {
 	int cursorSequence = kCursorSequenceNeutral;
 };
 
+struct StartupRoomIdleAnimationState {
+	RuntimeEntity *entity = nullptr;
+	int restoreFacing = -1;
+	uint32 activityTick = 0;
+	uint32 resetTick = 0;
+	uint32 triggerTick = 0;
+	bool active = false;
+	bool loopStarted = false;
+	bool exiting = false;
+};
+
 struct PlayerAnimationRange {
 	PlayerAnimationRange() {}
 	PlayerAnimationRange(int walkFirstFrame, int walkLastFrame, int idleFrame)
@@ -297,6 +317,44 @@ static int resolveFacingFromMovementDelta(int dx, int dy) {
 	return 0;
 }
 
+static uint32 getRuntimeClockTicks() {
+	return g_system ? (g_system->getMillis() / kRuntimeClockDivisorMs) : 0;
+}
+
+static bool isIdleAnimationExcludedRoom(const Common::String &roomName) {
+	static const char *const kExcludedRooms[] = {
+		"JAWS",
+		"SUPPLY1",
+		"SUPPLY2",
+		"SUPPLY3",
+		"LAVAPIT",
+		"RMNBATH",
+		"SERGENTRM",
+		"CHAP2"
+	};
+
+	for (const char *excludedRoom : kExcludedRooms) {
+		if (roomName.equalsIgnoreCase(excludedRoom))
+			return true;
+	}
+
+	return false;
+}
+
+static void updatePlayerIdleTrigger(StartupRoomIdleAnimationState &idleState) {
+	idleState.triggerTick = MAX(idleState.activityTick, idleState.resetTick) + kRoomPlayerIdleDelayTicks;
+}
+
+static void notePlayerActivity(StartupRoomIdleAnimationState &idleState) {
+	idleState.activityTick = getRuntimeClockTicks();
+	updatePlayerIdleTrigger(idleState);
+}
+
+static void notePlayerIdleReset(StartupRoomIdleAnimationState &idleState) {
+	idleState.resetTick = getRuntimeClockTicks();
+	updatePlayerIdleTrigger(idleState);
+}
+
 static void getPaletteByteRange(const byte *palette, byte &minValue, byte &maxValue) {
 	minValue = 255;
 	maxValue = 0;
@@ -806,6 +864,154 @@ static bool stepPlayerKeyboardMovement(const StartupRoomSetupState &state, Start
 	return placed;
 }
 
+static void positionPlayerIdleAnimationEntity(const StartupRoomSetupState &state,
+		const StartupRoomPlayerState &playerState, RuntimeEntity &idleEntity) {
+	if (!playerState.entity)
+		return;
+
+	idleEntity.setAnchorMode(kRuntimeEntityAnchorTopLeft);
+	idleEntity.setDepthScale(computeActorDepthScale(state, playerState.z));
+	idleEntity.setPosition(playerState.entity->getX(), playerState.entity->getY() + kRoomPlayerIdleYOffset, playerState.z);
+}
+
+static RuntimeEntity *ensurePlayerIdleAnimationEntity(HarvesterEngine &engine,
+		const StartupRoomSetupState &state, const StartupRoomPlayerState &playerState,
+		StartupRoomIdleAnimationState &idleState) {
+	if (idleState.entity || !playerState.entity)
+		return idleState.entity;
+
+	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
+	if (!runtimeEntities)
+		return nullptr;
+
+	idleState.entity = runtimeEntities->spawnSceneAnimationEntity(
+		kPlayerIdleAnimationEntityName, kPlayerIdleAnimationResourcePath,
+		Common::Point(playerState.entity->getX(), playerState.entity->getY() + kRoomPlayerIdleYOffset),
+		playerState.z, 0, false, false, false, false, false);
+	if (!idleState.entity)
+		return nullptr;
+
+	positionPlayerIdleAnimationEntity(state, playerState, *idleState.entity);
+	idleState.entity->setVisible(false);
+	debugC(1, kDebugScene,
+		"Harvester: spawned player idle animation '%s' room='%s' frames=0..%d",
+		kPlayerIdleAnimationResourcePath, state.roomName.c_str(), idleState.entity->getLastFrame());
+	return idleState.entity;
+}
+
+static bool finishPlayerIdleAnimation(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
+		StartupRoomIdleAnimationState &idleState) {
+	if (!idleState.active)
+		return false;
+
+	if (idleState.entity) {
+		idleState.entity->setVisible(false);
+		idleState.entity->setAnimationRate(0);
+		idleState.entity->setCurrentFrame(0);
+		positionPlayerIdleAnimationEntity(state, playerState, *idleState.entity);
+	}
+	if (playerState.entity) {
+		playerState.entity->setVisible(true);
+		if (idleState.restoreFacing >= 0)
+			(void)setPlayerIdleAnimation(playerState, idleState.restoreFacing);
+	}
+
+	idleState.active = false;
+	idleState.loopStarted = false;
+	idleState.exiting = false;
+	idleState.restoreFacing = -1;
+	notePlayerIdleReset(idleState);
+	debugC(1, kDebugScene,
+		"Harvester: player idle animation finished room='%s' facing=%d",
+		state.roomName.c_str(), playerState.facing);
+	return true;
+}
+
+static bool requestPlayerIdleAnimationExit(const StartupRoomSetupState &state,
+		StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState) {
+	if (!idleState.active || idleState.exiting || !idleState.entity)
+		return false;
+
+	const int lastFrame = idleState.entity->getLastFrame();
+	const int exitStartFrame = MIN(lastFrame, kRoomPlayerIdleLoopLastFrame);
+	const int exitLastFrame = MIN(lastFrame, kRoomPlayerIdleExitLastFrame);
+	if (exitLastFrame <= exitStartFrame)
+		return finishPlayerIdleAnimation(state, playerState, idleState);
+
+	positionPlayerIdleAnimationEntity(state, playerState, *idleState.entity);
+	idleState.exiting = true;
+	idleState.entity->setAnimationFrameRange(exitStartFrame, exitLastFrame, false);
+	idleState.entity->setCurrentFrame(exitStartFrame);
+	idleState.entity->setAnimationRate(kRoomPlayerIdleAnimationRate);
+	debugC(1, kDebugScene,
+		"Harvester: player idle animation exit room='%s' frames=%d..%d rate=%d",
+		state.roomName.c_str(), exitStartFrame, exitLastFrame, kRoomPlayerIdleAnimationRate);
+	return true;
+}
+
+static bool startPlayerIdleAnimation(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState) {
+	if (!playerState.entity || idleState.active || isIdleAnimationExcludedRoom(state.roomName))
+		return false;
+
+	RuntimeEntity *idleEntity = ensurePlayerIdleAnimationEntity(engine, state, playerState, idleState);
+	if (!idleEntity)
+		return false;
+
+	const int lastFrame = idleEntity->getLastFrame();
+	const int loopLastFrame = MIN(lastFrame, kRoomPlayerIdleLoopLastFrame);
+	positionPlayerIdleAnimationEntity(state, playerState, *idleEntity);
+	idleState.restoreFacing = playerState.facing;
+	idleState.active = true;
+	idleState.loopStarted = false;
+	idleState.exiting = false;
+	idleEntity->setVisible(true);
+	idleEntity->setAnimationFrameRange(0, loopLastFrame, false);
+	idleEntity->setCurrentFrame(0);
+	idleEntity->setAnimationRate(kRoomPlayerIdleAnimationRate);
+	playerState.hasMoveTarget = false;
+	playerState.turnActive = false;
+	playerState.turnTargetFacing = -1;
+	playerState.entity->setVisible(false);
+	debugC(1, kDebugScene,
+		"Harvester: player idle animation start room='%s' facing=%d frames=%d..%d rate=%d trigger_tick=%u",
+		state.roomName.c_str(), idleState.restoreFacing, 0, loopLastFrame,
+		kRoomPlayerIdleAnimationRate, idleState.triggerTick);
+	return true;
+}
+
+static bool updatePlayerIdleAnimation(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
+		StartupRoomIdleAnimationState &idleState) {
+	if (!idleState.active || !idleState.entity)
+		return false;
+
+	const int lastFrame = idleState.entity->getLastFrame();
+	const int loopLastFrame = MIN(lastFrame, kRoomPlayerIdleLoopLastFrame);
+	if (!idleState.exiting && !idleState.loopStarted &&
+			idleState.entity->getCurrentFrame() >= loopLastFrame) {
+		const int loopStartFrame = MIN(lastFrame, kRoomPlayerIdleLoopStartFrame);
+		idleState.loopStarted = true;
+		if (loopStartFrame < loopLastFrame) {
+			idleState.entity->setAnimationFrameRange(loopStartFrame, loopLastFrame, true);
+			idleState.entity->setCurrentFrame(loopStartFrame);
+			idleState.entity->setAnimationRate(kRoomPlayerIdleAnimationRate);
+		}
+		debugC(1, kDebugScene,
+			"Harvester: player idle animation loop room='%s' frames=%d..%d rate=%d",
+			state.roomName.c_str(), loopStartFrame, loopLastFrame, kRoomPlayerIdleAnimationRate);
+		return true;
+	}
+
+	if (!idleState.exiting)
+		return false;
+
+	const int exitLastFrame = MIN(lastFrame, kRoomPlayerIdleExitLastFrame);
+	if (idleState.entity->getCurrentFrame() < exitLastFrame)
+		return false;
+
+	return finishPlayerIdleAnimation(state, playerState, idleState);
+}
+
 static bool findRoomObjectProbePoint(HarvesterEngine &engine, const Common::Array<StartupObjectRecord> &sceneObjects,
 		const StartupObjectRecord &object, Common::Point &probePoint) {
 	const Common::Rect bounds = getHotspotBounds(object);
@@ -1329,6 +1535,10 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 	bool moveRight = false;
 	bool moveUp = false;
 	bool moveDown = false;
+	StartupRoomIdleAnimationState idleState;
+	idleState.activityTick = getRuntimeClockTicks();
+	idleState.resetTick = idleState.activityTick;
+	updatePlayerIdleTrigger(idleState);
 	bool needsRedraw = true;
 	Graphics::FrameLimiter limiter(g_system, 60);
 
@@ -1337,11 +1547,12 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
-			const StartupRoomHoverState hoverState = showingInspectText
+			const bool suppressHover = showingInspectText || idleState.active || idleState.exiting;
+			const StartupRoomHoverState hoverState = suppressHover
 				? StartupRoomHoverState()
 				: resolveRoomHoverState(_engine, scene.state, scene.sceneObjects, _mousePos);
 			if (RuntimeEntity *cursor = runtimeEntities ? runtimeEntities->getCursorEntity() : nullptr) {
-				cursor->setAnimationSequence(showingInspectText ? kCursorSequenceNeutral : hoverState.cursorSequence);
+				cursor->setAnimationSequence(suppressHover ? kCursorSequenceNeutral : hoverState.cursorSequence);
 			}
 
 			drawRoomScene(_engine, *screen, scene, scene.targetPaletteBrightness);
@@ -1370,11 +1581,26 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 			case Common::EVENT_MOUSEMOVE:
 				needsRedraw = true;
 				break;
+			case Common::EVENT_RBUTTONDOWN:
+				notePlayerActivity(idleState);
+				if (idleState.active || idleState.exiting) {
+					if (requestPlayerIdleAnimationExit(scene.state, playerState, idleState))
+						needsRedraw = true;
+					break;
+				}
+				break;
 			case Common::EVENT_LBUTTONUP:
 				if (showingInspectText)
 					inspectCanDismiss = true;
 				break;
 			case Common::EVENT_LBUTTONDOWN: {
+				notePlayerActivity(idleState);
+				if (idleState.active || idleState.exiting) {
+					if (requestPlayerIdleAnimationExit(scene.state, playerState, idleState))
+						needsRedraw = true;
+					break;
+				}
+
 				if (showingInspectText) {
 					if (inspectCanDismiss) {
 						showingInspectText = false;
@@ -1494,6 +1720,13 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 				else if (event.kbd.keycode == Common::KEYCODE_DOWN)
 					moveDown = true;
 
+				notePlayerActivity(idleState);
+				if (idleState.active || idleState.exiting) {
+					if (requestPlayerIdleAnimationExit(scene.state, playerState, idleState))
+						needsRedraw = true;
+					break;
+				}
+
 				if (event.kbd.keycode == Common::KEYCODE_RETURN ||
 					event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
 					event.kbd.keycode == Common::KEYCODE_ESCAPE) {
@@ -1518,17 +1751,34 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 		if (updatePlayerTurnAnimationState(playerState))
 			needsRedraw = true;
 
-		if (stepPlayerKeyboardMovement(scene.state, playerState, moveLeft, moveRight, moveUp, moveDown))
-			needsRedraw = true;
-		else if (stepPlayerMoveTarget(scene.state, playerState))
-			needsRedraw = true;
-		else if (!moveLeft && !moveRight && !moveUp && !moveDown && !playerState.hasMoveTarget &&
-				!playerState.turnActive &&
-				playerState.entity && playerState.facing >= 0 && setPlayerIdleAnimation(playerState, playerState.facing))
-			needsRedraw = true;
+		if (!idleState.active && !idleState.exiting) {
+			if (stepPlayerKeyboardMovement(scene.state, playerState, moveLeft, moveRight, moveUp, moveDown)) {
+				notePlayerActivity(idleState);
+				needsRedraw = true;
+			} else if (stepPlayerMoveTarget(scene.state, playerState)) {
+				notePlayerActivity(idleState);
+				needsRedraw = true;
+			} else if (!moveLeft && !moveRight && !moveUp && !moveDown && !playerState.hasMoveTarget &&
+					!playerState.turnActive &&
+					playerState.entity && playerState.facing >= 0 &&
+					setPlayerIdleAnimation(playerState, playerState.facing)) {
+				needsRedraw = true;
+			}
+
+			if (!showingInspectText && !moveLeft && !moveRight && !moveUp && !moveDown &&
+					!playerState.hasMoveTarget && !playerState.turnActive &&
+					playerState.entity && playerState.facing >= 0 &&
+					!isIdleAnimationExcludedRoom(scene.state.roomName) &&
+					getRuntimeClockTicks() > idleState.triggerTick &&
+					startPlayerIdleAnimation(_engine, scene.state, playerState, idleState)) {
+				needsRedraw = true;
+			}
+		}
 
 		if (tickRuntimeEntities())
 			needsRedraw = true;
+		if (updatePlayerIdleAnimation(scene.state, playerState, idleState))
+			needsRedraw = true;
 
 		limiter.delayBeforeSwap();
 		limiter.startFrame();


Commit: bf4a8aafb4a175c92935068bec7254a844a49e6e
    https://github.com/scummvm/scummvm/commit/bf4a8aafb4a175c92935068bec7254a844a49e6e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:41+02:00

Commit Message:
HARVESTER: Restore startup room music playback

Trace the native startup audio path in Ghidra and mirror the split
between looping room music and one-shot samples.

Decode FCMP/CMP startup audio, start room music from the resolved room
setup, and carry SPOOL_MUSIC through interaction handling without
reusing the one-shot sound handle.

Changed paths:
    TRACKER.md
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/TRACKER.md b/TRACKER.md
index 54266c25e88..68f3e0df1f8 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -1,257 +1,35 @@
 # TRACKER
 
+## Current Focus
+
+- Ghidra-backed startup engine alignment from `run_harvester_main_loop`
+- Match the native startup room idle/movement behavior before expanding further subsystem coverage
+- Keep symbol recovery and engine changes tied to direct script data, call sites, and visible side effects
+
+## Progress
+
+- Program: `HARVEST.LE`
+- Total functions: `774`
+- Named/documented: `468`
+- Still `FUN_*` / undocumented: `306`
+
 ## Last Confirmed Action
 
-- Closed the startup quick-tips label/value, hotspot cursor, and room-band movement pass against the native main loop.
-  - Confirmed in Ghidra that `run_quick_tips_screen` at `0x6c890` renders the bottom `Exit`, `Next`, and toggle actions in color `0xc3`, and that the toggle label resolves through the `TEXT` records instead of displaying the raw `Show_Tips_ON` / `Show_Tips_OFF` keys.
-  - Confirmed in Ghidra that `run_harvester_main_loop` classifies hover targets into cursor sequences `0` walk, `1` inspect, `2` talk, `4` operate, `5` pickup/use, `6` transition/region, and `7` neutral, and that empty-floor clicks map directly into room-band movement targets with the same loop also honoring directional-key movement.
-  - Patched the startup stub accordingly:
-    - quick tips now resolve `Show_Tips_ON` / `Show_Tips_OFF` through `resolveTextValue()` and render the bottom actions in the native red `0xc3`
-    - the room loop now ignores the background hitmask, derives hover prompts/cursor sequences from the active startup objects, and treats the room movement band as walkable floor with click-to-walk plus arrow-key movement
-    - the startup stub now uses the native IDENT textbox bitmaps and inset/color layout instead of the old placeholder inspect panel
-  - Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
-  - The bounded probe in `/Users/alex/Temp/harvester_startup_probe.log` now confirms all three room-loop checks in one pass:
-    - hotspot hover resolves `PC_DRESR1` with `prompt='Examine dresser'` and `cursor_sequence=1`
-    - empty floor at `(48,350)` is now recognized with `cursor_sequence=0`
-    - the movement preview reaches `final_rect=(219,234)-(246,373) z=23.00`, proving the click-to-walk placement path is live
-  - A separate no-probe capture against `/Users/alex/Downloads/Harvester_1996/HARVEST/iso/Harvester` with `QUICK_TIPS=ON` logs `Harvester: quick tips labels exit='Exit' next='Next' toggle='Show Tips ON'` in `/Users/alex/Temp/harvester_quicktips.log`, confirming the toggle now resolves to the text value rather than the label key.
-  - Ghidra function counts remain unchanged: `774` total functions, `423` named/documented, `351` still `FUN_*`.
-- Closed the startup IDENT textbox modal pass against the native main-loop helpers.
-  - Traced the native caller sites in `run_harvester_main_loop`: click interaction still dispatches through `handle_target_interaction` at `0x6f9b3`, while the modal inspect helper `show_target_ident_text` is reached from `0x70c0f` and `0x7189e` after the current target has been resolved and gated on its "ident shown" byte.
-  - Exposed the already loaded startup textbox bitmaps in `StartupArt` and replaced the room-loop placeholder inspect panel with the confirmed IDENT textbox path:
-    - `BOX1`..`BOX4` now map to `TEXTBOX1.BM`..`TEXTBOX4.BM`
-    - the popup uses the native shared panel position `(85,177)`
-    - IDENT text is rendered with the native `+10,+5` inset and color `0xd3`
-  - Tightened the room-loop modal dismissal path to the confirmed helper behavior: the first click must be released before dismissal is allowed, and once shown the popup dismisses on a fresh click or `Esc` instead of the old `Enter` / `Space` placeholder behavior.
-  - Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
-  - The bounded capture in `/Users/alex/Temp/harvester_startup_probe_modal.log` still reaches `START -> PCROOM`, keeps the corrected startup player placement `screen_base=(182,208)` / `rect=(346,257)-(375,405)`, and still resolves `PC_DRESR1` with `prompt='Examine dresser'`, `cursor_sequence=1`, `action_tag='COM1A'`, and `next_room='PCDRWR'`.
-  - The no-probe capture in `/Users/alex/Temp/harvester_startup_noprobe_modal.log` still reaches the same startup handoff and advances the cursor to sequence `7`, so the textbox-modal port did not regress the default room entry.
-  - Live click replay for the modal textbox itself is still unverified; the current confirmation is from the native control-flow trace, the script/resource mapping (`PC_DRESR1_LTEXT -> BOX1`), and the successful local rebuild/captures rather than from an automated input recording.
-  - Ghidra function counts remain unchanged: `774` total functions, `423` named/documented, `351` still `FUN_*`.
-- Closed the native startup actor world-to-screen / depth-placement port.
-  - Extended the startup room parser to carry the native room depth fields (`min_z`, `max_z`, `max_z_screen_y`, `min_z_screen_y`, `full_scale_z`, `max_z_scale_percent`, and derived `perspective_scale`) plus the room music path through `resolveRoomSetupState`.
-  - Updated the startup player handoff to mirror the native two-pass `room_setup` placement: compute `screen_x = spawn_x - x_offset - width / 2` and `screen_y = spawn_y - height - y_offset`, store that screen-space base point, rescale the ABM for depth, then recompute the same screen-space base point from the scaled metrics.
-  - Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
-  - The bounded probe in `/Users/alex/Temp/harvester_startup_probe_new.log` now shows `startup player placement ... screen_base=(182,208) size=29x148 offsets=(164,49) scale=0.970` and `startup probe player ... rect=(346,257)-(375,405)`, replacing the old bad rect `(514,380)-(544,533)` while keeping the player bottom at entrance `y=405`.
-  - The same bounded probe still resolves `PC_DRESR1` at `(142,339)` with `action_tag='COM1A'` and `next_room='PCDRWR'`, so the direct startup room-loop interaction handoff remains intact.
-  - A no-probe startup capture in `/Users/alex/Temp/harvester_startup_noprobe.log` also reaches `START -> PCROOM`, applies the same `screen_base=(182,208)` / `scale=0.970` placement, and advances the cursor to sequence `7`, so the fix is not dependent on the probe hook.
-  - Ghidra function counts remain unchanged.
-- Closed the startup player-display root-cause pass against Ghidra.
-  - The local startup logs already showed the actor resource and entrance state were present: `PC0.ABM` loads, `resolveRoomSetupState('START')` resolves `PCROOM`, and the player spawn stays `(360,405,15)` facing `1`.
-  - Ghidra now confirms the missing native behavior is screen placement, not loading. In `room_setup`, the player path re-adds `DAT_000d5bd4`, computes `screen_y = entrance_y - sprite_height - y_offset`, computes `screen_x = entrance_x - x_offset - sprite_width / 2`, calls `set_entity_screen_position`, rescales with `rescale_entity_sprite_for_depth`, then repeats the same placement with the scaled metrics.
-  - The current port is still treating the entrance coordinates as direct screen pixels and adding ABM frame offsets during draw, which matches the bad startup probe rectangle `(514,380)-(544,533)` from the same `(360,405,15)` entrance and explains why Steve loads but is not visibly placed in the room.
-  - This narrows the regression to the missing native world-to-screen / depth-rescale path for startup actors; archive loading, palette upload, quick tips, and room-loop dispatch are not the blocker.
-  - Ghidra function counts remain unchanged.
-- Closed the startup room-loop verification pass with a bounded local probe.
-  - Added opt-in Harvester debug probes behind `harvester_debug_skip_startup_movies` and `harvester_debug_probe_startup_room` so the local capture can skip the intro FST trio and log room-loop facts without changing the default startup path.
-  - Using `/Users/alex/Temp/harvester_overlay_ugy21l06` with `QUICK_TIPS=OFF` plus `/Users/alex/Temp/harvester_probe_scummvm.ini`, the bounded run in `/Users/alex/Temp/harvester_startup_probe.log` now reaches `resolveRoomSetupState('START') -> room='PCROOM' entrance='START' spawn=(360,405,15) facing=1`.
-  - Confirmed the startup cursor reaches `sequence=7 frames=70..79 current=70`, and the runtime cursor blit preserves all `232` transparent pixels in frame `71`, matching the palette-index-`0` transparency fix rather than the old black-backed cursor path.
-  - Confirmed Steve is present in `PCROOM` at the `START` entrance: the probe logs `spawned startup player actor ... pos=(360,405,15) facing=1 frame=14`, and the live entity check reports `visible=1`, `intersects_screen=1`, and `opaque_pixels=3143`.
-  - Confirmed the direct room-loop handoff resolves active startup interactions: the probe hits `PC_DRESR1` at `(142,339)`, resolves label `dresser`, keeps action tag `COM1A`, and reports a supported interaction into closeup room `PCDRWR`.
-  - Rebuilt `build-vscode-harvester-debug/scummvm` successfully; Ghidra function counts remain unchanged.
-- Closed the startup room-loop / player-presence pass.
-  - Confirmed in Ghidra that `run_harvester_main_loop` stores the live player actor before the first `room_setup`, and `room_setup` later re-adds that actor from `DAT_000d5bd4` using the active entrance coordinates/facing instead of constructing a fresh PCROOM-local NPC.
-  - Confirmed `room_setup` seeds exact startup-facing frames `0x3b`, `0x0e`, `0x2c`, and `0x28` for entrance-facing values `0`, `1`, `2`, and `3`.
-  - Confirmed `is_cursor_over_entity_hitmask` treats palette index `0` as transparent for normal sprite classes, which explains the black-backed cursor regression when the port blits raw indexed pixels.
-  - Patched the engine to key runtime-entity rendering on palette index `0`, parse entrance spawn/facing state from `HARVEST.SCR`, spawn the startup player actor from `1:\GRAPHIC\MONSTERS\PC\PC0.ABM`, and hand startup straight into the interactive room loop after quick tips.
-  - Built `build-vscode-harvester-debug/scummvm` successfully and left the Ghidra function counts unchanged.
-- Closed the native startup verification capture with the complete local data set.
-  - Using `/Users/alex/Downloads/Harvester_1996/HARVEST/iso/Harvester`, the local ScummVM run mounted `INDEX.001` / `INDEX.002` / `INDEX.003`, loaded `HARVEST.SCR`, and reached the startup room handoff normally.
-  - Confirmed in `/Users/alex/Temp/harvester_startup_native.log` that the startup palette path now logs `decoded room palette 'GRAPHIC/PAL/PCROOM.PAL' ... idx0=(0,0,56)`, matching the expected raw 8-bit palette bytes rather than the old expanded-blue mismatch.
-  - Confirmed in the same native capture that the cursor reaches `sequence=7 frames=70..79 current=70`, matching the centisecond-timed cursor sequencing expected from the binary.
-  - This removes the local startup data-path blocker; function counts remain unchanged.
-- Closed the startup room-setup ordering fallback pass.
-  - Tried the native ScummVM startup capture first, but the available local data set only contains `HARVEST2.DAT`; `INDEX.001`, `INDEX.002`, `INDEX.003`, and `HARVEST.SCR` are missing, so the engine never reaches the palette / cursor startup path in this environment.
-  - Confirmed in Ghidra that `room_setup` flushes the wait transition while the cursor is hidden, then re-shows the cursor and uploads `WAIT.PAL`.
-  - Confirmed the first live room flush happens in the opposite order: after room construction and optional `on_enter` handlers, `room_setup` uploads `g_current_palette_buffer` and only then calls `flush_dirty_rects_to_screen`.
-  - Confirmed `flush_dirty_rects_to_screen` always begins by calling `sync_cursor_entity_position(g_cursor_entity)`, so the first cursor sync / animation tick after room construction happens under the room palette, not before it; `room_setup` also resets the cursor entity to animation sequence `7` before that final flush.
-  - Added Ghidra comments at `room_setup`, saved `HARVEST.LE`, and left function counts unchanged.
-- Closed the next partial `+0x118c` capability-bit pass.
-  - Confirmed `update_actor_runtime_state` uses the higher `+0x118c` bits `0x1000` and `0x10000` as close-range attack-family compatibility gates rather than as generic locomotion flags: attacker `0x1000` is required before states `0x17/0x1a` can be chosen, while target `0x10000` is required before states `0x18/0x1b` can be chosen.
-  - Confirmed the player combat-avatar base mask `0x00fffff8` keeps both of those higher `+0x118c` gates set by default.
-  - Narrowed low-byte `+0x118c` bit `0x80` to the separate case-1 stance / turn-family gate: state-family `1` refuses its own transitions unless the bit is present, and the monster spawn path derives it from the ABM frame slot reached through live actor pointer `+0x84`.
-  - Added Ghidra comments at `update_actor_runtime_state`, and saved `HARVEST.LE`.
-- Closed the startup palette-format / cursor-animation clock pass.
-  - Confirmed by archive inspection that `WAIT.PAL`, `PCROOM.PAL`, and the rest of the indexed room palettes are exact 768-byte resources already storing 8-bit RGB triplets.
-  - Confirmed in Ghidra that `upload_palette_to_vga` shifts those palette bytes down to VGA DAC range instead of expanding 6-bit source values, which explains the bad startup colors in the current engine.
-  - Confirmed in Ghidra that runtime entity animation timing uses the centisecond DOS clock behind `get_elapsed_milliseconds`: `set_entity_animation_rate` stores `100 / rate`, and `tick_entity_visual_state` compares that interval against centisecond ticks.
-  - Patched the ScummVM startup palette loaders to keep the raw 8-bit palette bytes, switched runtime entity ticking over to a centisecond-style clock, and added targeted palette/cursor debug logs.
-- Closed the first low-byte facing-bank pass around runtime `+0x118c`.
-  - Confirmed `spawn_monster_entity_from_record` uses the low byte of `+0x118c` as the default facing-bank selector when the alternate `+0x118e` high-nibble bank is absent: `0x08` front, `0x10` left, `0x40` right, and `0x20` back.
-  - Confirmed the matching `+0x118e` high-nibble bits are the alternate initial stance banks for those same facings: `0x40` front, `0x10` left, `0x20` right, and `0x80` back.
-  - Confirmed `update_actor_runtime_state` reuses those `+0x118c` low-byte bits in the locomotion / turn-family entries rooted at states `4`, `7/0xf`, `8/0xe`, and `0xb`.
-  - Added Ghidra comments at `spawn_monster_entity_from_record` and `update_actor_runtime_state`, and saved `HARVEST.LE`.
-- Closed the first runtime capability-byte pass around `+0x118d` / `+0x118e`.
-  - Confirmed `spawn_monster_entity_from_record` seeds `+0x118c`, `+0x118d`, and `+0x118e` from ABM frame-count checks rather than from generic AI state.
-  - Confirmed `+0x118d` bits `0x08/0x10/0x20` gate attack-state pairs `0x16/0x19`, `0x17/0x1a`, and `0x18/0x1b`.
-  - Confirmed `+0x118d` bits `0x40/0x80` plus `+0x118e & 0x01` gate the hit-reaction state pairs `0x1c/0x1f`, `0x1d/0x20`, and `0x1e/0x21`, and `+0x118e` bits `0x02/0x04/0x08` gate the death-state families `0x28` through `0x33`.
-  - Added Ghidra comments at `spawn_monster_entity_from_record` and `update_actor_runtime_state`, and saved `HARVEST.LE`.
-- Closed the `+0x118c` damage-type / ranged-split pass.
-  - Confirmed `parse_monster_record` parses `MonsterRecord.damage_type` from `BLUDGE`, `SLASH`, and `PROJ` into the low-bit values `1`, `2`, and `4`.
-  - Confirmed runtime `+0x118c` is a mixed combat/capability mask, not a pure damage-type field: the player combat-avatar path seeds `0x00fffff8` before adding the low damage bits, while monster construction copies `MonsterRecord.damage_type` and then ORs extra low-byte availability bits from ABM frame counts.
-  - Confirmed `update_actor_runtime_state` keeps `(+0x118c & 4) == 0` on the close-range hit-resolution path, while class-5 attacks with bit `4` set use the separate ranged target-acquisition / ammo-count branch; close-range hits mirror attacker `+0x118c` into victim `+0x1188`.
-  - Added Ghidra comments at `parse_monster_record`, `spawn_player_combat_avatar`, and `update_actor_runtime_state`, and saved `HARVEST.LE`.
-- Closed the startup quick-tips / initial palette sequencing pass from `run_harvester_main_loop`.
-  - Confirmed the original binary seeds `"START"`, preloads `1:\graphic\pal\pcroom.pal` into `g_current_palette_buffer`, and enters `room_setup` before the quick-tips overlay is shown.
-  - Confirmed `run_quick_tips_screen` is a separate overlay path: it adds `1:\GRAPHIC\OTHER\TIPS.BM` and text entities on top of the already active room instead of resolving a standalone `QUICK_TIPS` room.
-  - That rules out the current startup stub's fake `resolveRoomSetupState("QUICK_TIPS")` path and narrows the palette mismatch to startup sequencing / palette application rather than to the basic `.PAL` or `.BM` file formats.
-- Closed the player-combat loadout attack-tuning pass.
-  - Confirmed `spawn_player_combat_avatar`, `set_player_combat_loadout`, and the monster-record-driven runtime reseed block all share the same attack cluster: `+0x1134` attack sample count, `+0x1148/+0x114c/+0x1150` attack sample slots, `+0x116c` attack-sound trigger frame offset, `+0x113c` attack contact frame offset, and `+0x1180` per-hit damage amount.
-  - Confirmed `update_actor_runtime_state` attack states `0x16` through `0x1b` randomly choose from that sample table at `anim_base + +0x116c`, then resolve the hit once `anim_base + +0x113c` is reached and subtract `+0x1180` from victim HP.
-  - Added Ghidra comments at `spawn_player_combat_avatar`, `set_player_combat_loadout`, and `update_actor_runtime_state`, and saved `HARVEST.LE`.
-- Closed the parser-name gap for the room-event combat-control opcodes.
-  - Confirmed `parse_command_record` maps `KILL_NPC -> 0x0c`, `KILL_PC -> 0x0d`, `MONSTERFY -> 0x0e`, `PAUSE_PC -> 0x25`, and `RESUME_PC -> 0x26`.
-  - That locks the room-event callers behind `queue_npc_death_or_monsterfy_transition` and the player damage-type path to stable parser names instead of raw case numbers.
-  - Added Ghidra comments at the parser, dispatcher, and NPC transition helper sites and saved `HARVEST.LE`.
-- Continued the runtime-entity NPC transition/loadout pass around `update_actor_runtime_state` and `set_player_combat_loadout`.
-  - Renamed `attach_abm_resource_to_entity` at `0x4c0f0`, `queue_npc_death_or_monsterfy_transition` at `0x53810`, and `teardown_monster_entity_runtime_state` at `0x541c0`.
-  - Confirmed `g_player_combat_loadout_abm_paths` at `0xc3eb4` is a direct 21-entry `pc0.abm` through `pc20.abm` table shared by the player combat-avatar spawn, reset, loadout-switch, and load-game restore paths.
-  - Confirmed runtime field `+0x1188` is a damage-type flag word: spawn helpers clear it, room-event helpers assign `BLUDGE = 1`, `SLASH = 2`, and `PROJ = 4`, and `update_actor_runtime_state` tests those bits during hit/death-state selection.
-  - Confirmed `+0x11b8` is still only written reciprocally in the close-range hit block and still has no recovered reader, so it remains unnamed.
-  - Saved `HARVEST.LE`.
-- Continued the runtime-entity room-event suppression pass around `+0x11b4`.
-  - Confirmed room-event action cases `0x25` / `0x26` set and clear `g_player + 0x11b4`.
-  - Confirmed `update_actor_runtime_state` consults that byte only for class-5 player combat-avatar updates and returns immediately while it is set; `run_harvester_main_loop` and `run_load_game_menu` clear it on return/reload paths.
-  - Added Ghidra comments at the key read/write sites and saved `HARVEST.LE`.
-- Continued the runtime-entity combat-link pass around the confirmed hit-reaction fields.
-  - Renamed `apply_pending_entity_knockback` at `0x53770`.
-  - Confirmed `update_actor_runtime_state` seeds victim `+0x11ac` with signed `-18` / `+18` and `+0x11b0 = 3` on confirmed hits, and the new helper copies that pending value into live shove slot `+0x1088` before decaying it toward zero.
-  - `+0x11b8` is still only written reciprocally in that hit block and still has no recovered consumer, so it remains unnamed.
-  - Saved `HARVEST.LE`.
-- Continued the runtime-entity combat-avatar field pass around `update_player_combat_avatar_state`.
-  - Renamed `teardown_entity_runtime_state` at `0x55030` and added plate comments at the shared cleanup and player-combat helper sites.
-  - Confirmed live actor field `+0x11bc` is the player combat loadout / equipped weapon id from `spawn_player_combat_avatar`, `set_player_combat_loadout`, the inventory weapon-selection path, save/load restoration, and `sync_player_combat_weapon_resource_icons`.
-  - Confirmed `+0x1160` / `+0x1164` are the alternating movement-step sample slots and `+0x1168` is the death sample slot; monster/NPC spawn helpers seed the same fields, so they are shared runtime-actor sound slots rather than player-only state.
-  - Saved `HARVEST.LE`.
-- Closed the remaining `VesaModeBackend.display_start_preset_callback` pass.
-  - Renamed that struct field to `unused_display_start_preset_callback` in Ghidra and saved `HARVEST.LE`.
-  - Confirmed the four `set_vesa_*_display_start_preset` helpers have no code callers; they only remain as data references from the backend records.
-  - Confirmed `configure_video_surface` only dispatches the probe/activate/shutdown slots, and the per-mode activate helpers go straight into `initialize_vesa_banked_mode`, so this preset slot is dead in the current binary.
-- Closed the remaining `update_actor_runtime_state` blocker-slot pass.
-  - Confirmed `tick_entity_visual_state` writes the current opaque overlap blocker at runtime offset `+0x109c`, and `update_actor_runtime_state` consumes that slot as the current blocker in the directional avoid branches.
-  - The four remembered blocker slots now have explicit directional meaning from the branch-local state writes plus the signed movement deltas:
-    - `+0x108c` upward blocker history
-    - `+0x1090` downward blocker history
-    - `+0x1094` leftward blocker history
-    - `+0x1098` rightward blocker history
-  - `set_object_visibility_for_owner_or_room` clears those four slots individually when a hidden entity still matches one of the remembered blockers, which confirms they are persistent blocker pointers rather than generic movement flags.
-  - Added Ghidra plate/decompiler comments at the key read/write sites and saved `HARVEST.LE`.
-- Closed the last ambiguous backend slot in `VesaModeBackend`.
-  - Renamed `callback_18` to `unused_callback` after an instruction-level scan showed the only indirect backend calls in the current binary are `blit_rect_callback` and `select_bank_for_scanline_callback` from `run_fst_sequence_player`; no callers use slot `+0x18`.
-  - Renamed the shared VGA/VESA no-op implementations to match that evidence:
-    - `noop_vga_unused_callback`
-    - `noop_vesa_640x400_unused_callback`
-    - `noop_vesa_640x480_unused_callback`
-    - `noop_vesa_800x600_unused_callback`
-    - `noop_vesa_1024x768_unused_callback`
-  - Saved `HARVEST.LE`.
-- Closed the remaining `run_fst_sequence_player` censorship gate from the binary through the ScummVM startup player.
-  - Renamed `g_gore_enabled` at `0xc0fbc` in Ghidra and saved `HARVEST.LE`.
-  - Confirmed `startup_main` defaults that persisted flag to `1` and clears it only when config key `GORE` equals `NO`.
-  - Confirmed `run_fst_sequence_player` only consults `g_fst_censorship_toggle_entries` when `g_gore_enabled == 0`, keeps advancing audio timing while censored, and keeps capturing later movie palettes so the next uncensored frame can restore the saved movie palette before decode when needed.
-  - ScummVM now exposes that path as a Harvester launcher option and mirrors the original behavior in `engines/harvester/fst_player.cpp` by toggling `GRAPHIC/OTHER/CENSORED.PCX` against the recovered 25-entry frame table.
-- Continued the remaining video-backend callback pass around `configure_video_surface`.
-  - Recovered the hidden shared wrapper at `0x14f40` as `set_vesa_write_window_bank`; it is the write-window-fixed wrapper above `set_vesa_window_bank`.
-  - Corrected the backend table itself from VESA-only to generic video-mode state:
-    - `g_video_mode_backends` at `0xc3bcc` is now typed as a 6-entry pointer table
-    - the table contains five concrete backends plus the sentinel used by `configure_video_surface`
-    - the extra non-VESA record at `0xc0fd4` is now labeled `g_vga_mode_320x200_8bpp`
-  - Created and named the previously undefined VGA backend entrypoints from that `320x200x8` record:
-    - `probe_vga_320x200_mode`
-    - `activate_vga_320x200_mode`
-    - `noop_vga_mode_shutdown`
-    - `write_vga_320x200_pixel`
-    - `read_vga_320x200_pixel`
-    - `fill_vga_320x200_horizontal_span`
-    - `fill_vga_320x200_rect`
-    - `blit_vga_320x200_rect`
-  - Renamed the confirmed `VesaModeBackend` fill slots from generic offsets to `fill_horizontal_span_callback` and `fill_rect_callback`.
-  - The per-mode VESA slot at offset `+0x18` is still unresolved semantically, but all four VESA targets are now explicitly bounded as no-op stubs:
-    - `noop_vesa_640x400_callback_18`
-    - `noop_vesa_640x480_callback_18`
-    - `noop_vesa_800x600_callback_18`
-    - `noop_vesa_1024x768_callback_18`
-  - Saved `HARVEST.LE`.
-- Continued `run_fst_sequence_player` into the banked VESA row-flush path.
-  - Renamed and typed the runtime banked-mode tables and state in Ghidra:
-    - `g_vesa_scanline_start_bank_indices` as `ushort[1024]` at `0xc7fca`
-    - `g_vesa_scanline_window_offsets` as `int[1024]` at `0xc87cc`
-    - `g_vesa_scanline_bank_split_x` as `short[1024]` at `0xc97cc`
-    - `g_vesa_window_granularity_kb`, `g_vesa_window_size_kb`, `g_vesa_bytes_per_scanline`, `g_vesa_read_window_index`, `g_vesa_write_window_index`, `g_vesa_read_window_segment_base`, `g_vesa_write_window_segment_base`, `g_vesa_current_write_bank_index`, `g_vesa_mode_width`, `g_vesa_mode_height`, and `g_vesa_window_size_bytes`
-  - Confirmed `initialize_vesa_banked_mode` precomputes, for each possible scanline, the starting bank index, the in-window linear offset for the left edge, and a signed split-`x` sentinel that stays `-1` when the whole row fits inside one bank.
-  - Confirmed the remaining banked branch inside `run_fst_sequence_player` is a 4-scanline-band fallback, not a different tile codec:
-    - if all four rows in the current band have `split_x == -1`, tiles expand directly into the mapped VESA window
-    - otherwise the same 4x4 tiles expand into a temporary 4-row scratch band and then flush row-by-row across the bank boundary using the precomputed scanline tables plus `select_bank_for_scanline_callback`
-  - Renamed the key FST locals in Ghidra so the decoder state now reads as `frame_index_table`, `frame_payload_buffer`, `frame_bitstream_cursor`, `frame_block_payload_cursor`, `frameband_dst`, and `banked_frameband_scratch`.
-  - Saved `HARVEST.LE`.
-- Continued `run_fst_sequence_player` into the per-frame pacing and tile-decode path.
-  - The inner movie loop now converges cleanly enough to rule out a block-format mismatch in the current engine:
-    - one frame-level palette-present bit
-    - then, per 4x4 tile, one changed/unchanged bit and one raw-vs-mask bit for changed tiles
-    - changed tiles expand from either 16 raw pixels or 4 bytes `{ color0, color1, mask16 }`
-  - Confirmed the original movie pacing is audio-driven when PCM is present.
-    - `run_fst_sequence_player` computes `bytes_per_frame = get_pcm_byte_rate(...) / frame_rate`
-    - it queues each frame's audio chunk before decode/blit
-    - it then waits on `get_sound_state_playback_position` against the cumulative byte target before presenting the next frame
-  - ScummVM now follows that recovered pacing rule in `engines/harvester/fst_player.cpp`, using mixer elapsed time for PCM-backed FST playback and keeping the timer path only for silent movies.
-- Continued the `run_fst_sequence_player` pass far enough to formalize the on-disk movie layout and the special-case censorship side table.
-  - Created the exact FST types in Ghidra:
-    - `FstFileHeader` size `0x20`
-    - `FstFrameIndexEntry` size `0x6`
-    - `FstCensorshipToggleEntry` size `0x118`
-  - Applied `FstCensorshipToggleEntry[25]` at `0xc1014` and labeled it `g_fst_censorship_toggle_entries`.
-    - The record layout is now explicit: `sequence_name[0x100]` plus six 32-bit `toggle_frame_indices`.
-    - `run_fst_sequence_player` scans those 25 entries by FST basename and, on matching frame indices, toggles between restoring the saved palette and blitting `GRAPHIC\\OTHER\\CENSORED.PCX`.
-  - Confirmed the top-level FST playback layout from the binary call setup:
-    - a `0x20`-byte header
-    - `frame_count` frame-index records of `6` bytes each
-    - per-frame reads of `video_size + audio_size`
-    - audio pacing based on `get_pcm_byte_rate(...) / frame_rate`
-- Extended the runtime-entity pass into the shared movement/update helpers.
-  - Renamed `update_visible_entity_screen_position` at `0x4c860`, `update_render_entity_screen_position` at `0x5cc10`, `do_rects_overlap` at `0x5e0d0`, and `compute_rect_area` at `0x5e0c0`.
-  - `update_render_entity_screen_position` now has a bounded role: when depth is unchanged it only updates dirty rectangles for the old/new screen bounds, and when depth changes it removes the entity from the render list, rescales it, reinserts it by depth, and merges the resulting dirty region.
-  - A scripted scan of offset `+0x109c` confirmed that the collision/blocking-entity pointer written by `tick_entity_visual_state` is consumed by `update_actor_runtime_state`; no separate movement subsystem has shown up yet.
-- Closed the shared packed-callback adapter pass and reclassified the remaining `0xc0c70` records as mixed Borland object-lifecycle descriptors rather than as one more PCM-only callback table.
-  - Created and renamed `report_undefined_constructor_or_destructor_called` at `0x81448`.
-    - It emits the exact string `undefined constructor or destructor called!` through the Borland runtime fatal path, which is what identifies the shared packed adapter field as an undefined ctor/dtor fallback.
-  - Renamed the recovered lifecycle helpers bound by those mixed records:
-    - `set_vesa_video_mode_3` at `0x3c8a0`
-    - `reset_video_surface_context` at `0x3c8c0`
-    - `reset_town_script_runtime_state` at `0x48000`
-    - `install_keyboard_irq1_handler_state` at `0x48700`
-    - `remove_keyboard_irq1_handler_state` at `0x48660`
-  - Labeled the newly explicit state objects:
-    - `g_town_script_runtime_state` at `0xd3050`
-    - `g_keyboard_irq1_handler_state` at `0xd5900`
-  - The remaining unresolved first record at `0xc0c90` is still an extended music/callback-context variant, but the adapter pass itself is now exhausted cleanly.
-- Extended the PCM descriptor-blob pass with one new high-confidence stream helper and a bounded record shape.
-  - Created and renamed `release_music_stream_buffers` at `0x1a440`.
-    - The code frees the music stream's decode/ring buffers, unlocks their linear regions, and stops the active voice slot only when the slot/token still matches that stream.
-    - The current table xref at `0xc0c9c` is what ties that callback back to `g_music_stream_state` at `0xca1d4`.
-  - The smaller packed records at `0xc0ccc`, `0xc0cec`, and `0xc0d0c` now have a confirmed repeated shape:
-    - direct callback pointer
-    - state-object pointer
-    - secondary callback, shared adapter `0x81448`, repeated direct callback, and state size, all packed as 24-bit values shifted left by 8 inside 32-bit slots
-  - The first entry at `0xc0c90` is an extended music/callback-context variant of that layout, but its remaining packed size/order fields still do not converge cleanly enough for a formal struct.
-- Confirmed and implemented the next room-setup runtime-entity slice.
-  - `tick_entity_visual_state` now has a bounded collision path: Z-interval overlap first, then screen-bounds overlap, then opaque-pixel overlap.
-  - `spawn_object_entity_from_record` classifies `640x480` bitmap objects rooted at initial `(0, 0)` as class `3` room backgrounds, and classifies hotspot-only objects as `0x15` / `0x16`.
-  - `show_entity_visual` only applies the centered-anchor path when the internal `+0x4f` flag is clear, which separates the centered `ANIM` path from the top-left room-object spawn path.
-- Typed the callback/driver object rooted at `0xca194` conservatively enough to stop conflating it with `PcmSoundState`.
-  - `g_sound_driver_callback_context` at `0xca194` is now typed as a minimal `SoundDriverCallbackContext`.
-  - The proven fields are now explicit in the type and labels:
-    - `+0x14` is `selected_voice_slot_index`, also labeled as `g_selected_sound_voice_slot_index` at `0xca1a8`
-    - `+0x18` overlaps the existing `g_sound_voice_bank_index`
-    - `+0x20` overlaps the existing `g_sound_driver_initialized`
-  - The descriptor blob entry at `0xc0c98` points directly at this object, which confirms that the table-driven wrappers around `0x18380` / `0x183c0` / `0x183e0` / `0x18400` are operating on the current-bank driver context rather than on a `PcmSoundState`.
-- Current live-state counts:
-  - `HARVEST.LE` currently has `774` total functions
-  - `423` have custom/documented names
-  - `351` still remain unnamed / `FUN_*`
+- Traced the native startup-room audio paths in Ghidra and aligned the startup engine with the confirmed split between looping room music and one-shot samples.
+  - `room_setup` starts `g_current_room_def->music_path` through `start_music_stream(&g_music_stream_state, ...)` and caches the active filename in `g_current_music_path`.
+  - `dispatch_room_event_actions` opcode `0x19` is the scripted music-spool path; it starts a replacement stream immediately and updates `g_current_music_path` without requiring a room transition.
+  - `dispatch_room_event_actions` opcodes `0x1b` and `0x2b` load one-shot samples through `load_sound_sample` / `start_sound_state_playback`, separate from the persistent music stream.
+  - `SOUND/MUSIC/MORNING2.CMP` is an `FCMP` file with a 14-byte header (`magic`, compressed payload size, sample rate, decoded bits-per-sample) followed by raw DVI/IMA ADPCM payload.
+- Patched the startup ScummVM path to mirror that split.
+  - Added looping startup-room music playback on top of `FCMP`/`CMP` and `WAV` decoding.
+  - Room-entry `musicPath` now starts persistent background music, while interaction-triggered `SPOOL_MUSIC` commands now swap that background stream without reusing the one-shot sample handle.
+  - One-shot startup sounds continue to use their own handle so room music can continue underneath them.
 
 ## Next Suggested Action
 
-- Highest-value targets:
-  - replace the current bottom-screen prompt overlay with the native `g_current_interaction_text` / `g_pending_interaction_text` / `g_interaction_text_entity` lifecycle around `spawn_text_entity` / `destroy_text_entity`
-  - trace the startup-room locomotion branch in `run_harvester_main_loop` and `update_actor_runtime_state` far enough to recover the native walk/stand animation state handling, since the current startup click-to-walk path only mirrors placement and facing
-  - if that movement/state pass stays too broad, add a bounded input replay for the first `PC_DRESR1` click so the IDENT-first then `COM1A -> PCDRWR` path can be confirmed end-to-end before widening room-loop coverage
+1. Recover the native startup/event opcode split for `LOAD_WAV`, `START_WAV`, and `START_SINGLE_WAV` so the stub can mirror the rotating versus dedicated sound-sample slots instead of collapsing every effect to one handle.
+2. Parse `REGION` records from `HARVEST.SCR` into the startup script layer.
+3. Mirror native startup transition handling for class `0x19` region entities.
+  - Spawn enabled region hotspots from `room_setup` state.
+  - Drive cursor sequence `6` over active regions.
+  - Gate region activation on player overlap plus facing, following `check_player_region_interaction`.
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 132339cb7dc..dbf1010367f 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -19,9 +19,12 @@
  *
  */
 
+#include <cstring>
+
 #include "harvester/harvester.h"
 
 #include "audio/audiostream.h"
+#include "audio/decoders/adpcm.h"
 #include "audio/decoders/wave.h"
 #include "common/config-manager.h"
 #include "common/system.h"
@@ -46,6 +49,37 @@ static bool shouldSkipStartupMoviesForDebug() {
 		ConfMan.getBool("harvester_debug_skip_startup_movies");
 }
 
+static Audio::SeekableAudioStream *openStartupAudioStream(ResourceManager &resources, const Common::String &path) {
+	Common::SeekableReadStream *stream = resources.openFile(path);
+	if (!stream)
+		return nullptr;
+
+	if (stream->size() >= 14) {
+		char magic[4];
+		stream->read(magic, sizeof(magic));
+		if (!memcmp(magic, "FCMP", sizeof(magic))) {
+			const uint32 payloadSizeFromHeader = stream->readUint32LE();
+			const uint32 sampleRate = stream->readUint32LE();
+			const uint16 bitsPerSample = stream->readUint16LE();
+			const uint32 availablePayloadSize = stream->size() - 14;
+			const uint32 payloadSize = payloadSizeFromHeader != 0
+				? MIN<uint32>(payloadSizeFromHeader, availablePayloadSize)
+				: availablePayloadSize;
+
+			if (sampleRate == 0 || bitsPerSample != 16 || payloadSize == 0) {
+				delete stream;
+				return nullptr;
+			}
+
+			return Audio::makeADPCMStream(stream, DisposeAfterUse::YES, payloadSize,
+				Audio::kADPCMDVI, sampleRate, 1);
+		}
+	}
+
+	stream->seek(0);
+	return Audio::makeWAVStream(stream, DisposeAfterUse::YES);
+}
+
 } // End of anonymous namespace
 
 HarvesterEngine *g_engine = nullptr;
@@ -56,6 +90,7 @@ HarvesterEngine::HarvesterEngine(OSystem *syst, const ADGameDescription *gameDes
 }
 
 HarvesterEngine::~HarvesterEngine() {
+	stopStartupMusic();
 	stopStartupSound();
 	delete _startupText;
 	delete _startupArt;
@@ -74,18 +109,41 @@ bool HarvesterEngine::isGoreEnabled() const {
 	return !ConfMan.hasKey("gore") || ConfMan.getBool("gore");
 }
 
-bool HarvesterEngine::playStartupSound(const Common::String &path) {
-	stopStartupSound();
-	if (path.empty() || !_resources)
+bool HarvesterEngine::playStartupMusic(const Common::String &path) {
+	if (path.empty() || !_resources || !g_system || !g_system->getMixer())
 		return false;
 
-	Common::SeekableReadStream *stream = _resources->openFile(path);
-	if (!stream) {
-		warning("Harvester: unable to load startup sound '%s'", path.c_str());
+	const Common::String normalizedPath = _resources->normalizeResourcePath(path);
+	if (_startupMusicPath.equalsIgnoreCase(normalizedPath) &&
+			g_system->getMixer()->isSoundHandleActive(_startupMusicHandle)) {
+		return true;
+	}
+
+	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(*_resources, path);
+	if (!audioStream) {
+		warning("Harvester: unable to decode startup music '%s'", path.c_str());
 		return false;
 	}
 
-	Audio::SeekableAudioStream *audioStream = Audio::makeWAVStream(stream, DisposeAfterUse::YES);
+	stopStartupMusic();
+	g_system->getMixer()->playStream(Audio::Mixer::kMusicSoundType, &_startupMusicHandle,
+		Audio::makeLoopingAudioStream(audioStream, 0));
+	_startupMusicPath = normalizedPath;
+	return true;
+}
+
+void HarvesterEngine::stopStartupMusic() {
+	if (g_system && g_system->getMixer())
+		g_system->getMixer()->stopHandle(_startupMusicHandle);
+	_startupMusicPath.clear();
+}
+
+bool HarvesterEngine::playStartupSound(const Common::String &path) {
+	stopStartupSound();
+	if (path.empty() || !_resources || !g_system || !g_system->getMixer())
+		return false;
+
+	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(*_resources, path);
 	if (!audioStream) {
 		warning("Harvester: unable to decode startup sound '%s'", path.c_str());
 		return false;
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 360aa5803ea..9e49587f4b8 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -77,6 +77,9 @@ public:
 	StartupArt *getStartupArt() const { return _startupArt; }
 	StartupText *getStartupText() const { return _startupText; }
 	bool isGoreEnabled() const;
+	const Common::String &getStartupMusicPath() const { return _startupMusicPath; }
+	bool playStartupMusic(const Common::String &path);
+	void stopStartupMusic();
 	bool playStartupSound(const Common::String &path);
 	void stopStartupSound();
 
@@ -85,7 +88,9 @@ private:
 
 	const ADGameDescription *const _gameDescription;
 	Common::RandomSource _randomSource;
+	Audio::SoundHandle _startupMusicHandle;
 	Audio::SoundHandle _startupSoundHandle;
+	Common::String _startupMusicPath;
 	Graphics::Screen *_screen = nullptr;
 	ResourceManager *_resources = nullptr;
 	RuntimeEntityManager *_runtimeEntities = nullptr;
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 83abb3da480..79c439ad50b 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -1456,6 +1456,7 @@ Common::Error StartupFlow::runMainMenuStub() {
 
 				if (_menuItems[selectedItem].equalsIgnoreCase("NEW GAME")) {
 					Common::Error roomError = runRoomLoop("START");
+					_engine.stopStartupMusic();
 					if (roomError.getCode() == Common::kReadingFailed) {
 						statusMessage = "Unable to resolve START room setup from HARVEST.SCR.";
 						needsRedraw = true;
@@ -1519,6 +1520,8 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 		return transitionError;
 
 	resetCursorAnimationSequence();
+	if (!scene.state.musicPath.empty())
+		(void)_engine.playStartupMusic(scene.state.musicPath);
 	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
 	StartupRoomPlayerState playerState;
 	playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName(kPlayerActorEntityName) : nullptr;
@@ -1663,6 +1666,11 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 				playerState.hasMoveTarget = false;
 				playerState.turnActive = false;
 				playerState.turnTargetFacing = -1;
+				Common::String restoreMusicPath = _engine.getStartupMusicPath();
+				if (!interaction.musicPath.empty()) {
+					(void)_engine.playStartupMusic(interaction.musicPath);
+					restoreMusicPath = _engine.getStartupMusicPath();
+				}
 				if (!interaction.soundPath.empty()) {
 					(void)_engine.playStartupSound(interaction.soundPath);
 				}
@@ -1670,6 +1678,10 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 
 				if (!interaction.nextRoomName.empty()) {
 					Common::Error roomError = runRoomLoop(interaction.nextRoomName);
+					if (!restoreMusicPath.empty())
+						(void)_engine.playStartupMusic(restoreMusicPath);
+					else
+						_engine.stopStartupMusic();
 					if (roomError.getCode() == Common::kReadingFailed) {
 						(void)populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations);
 						resetCursorAnimationSequence();
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 66fca720d0a..6e2f5c5ccfa 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -664,6 +664,8 @@ bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object,
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("SPOOL_MUSIC")) {
+			if (result.musicPath.empty())
+				result.musicPath = command->arg1;
 			currentTag = command->arg4;
 			continue;
 		}
@@ -671,7 +673,7 @@ bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object,
 		if (command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
 			result.nextRoomName = command->arg1;
-			return !result.nextRoomName.empty() || !result.soundPath.empty();
+			return !result.nextRoomName.empty() || !result.musicPath.empty() || !result.soundPath.empty();
 		}
 
 		debug(1, "Harvester: unsupported interaction command '%s' for tag '%s'",
@@ -679,7 +681,7 @@ bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object,
 		break;
 	}
 
-	return !result.nextRoomName.empty() || !result.soundPath.empty();
+	return !result.nextRoomName.empty() || !result.musicPath.empty() || !result.soundPath.empty();
 }
 
 const StartupTextRecord *StartupScript::findTextRecord(const Common::String &key) const {
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index f43303280e9..565d574e0b4 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -151,6 +151,7 @@ struct StartupRoomSetupState {
 };
 
 struct StartupInteractionResult {
+	Common::String musicPath;
 	Common::String soundPath;
 	Common::String nextRoomName;
 };


Commit: 205646fbc52c4f09c9ca297e0ba924dfa827bc6b
    https://github.com/scummvm/scummvm/commit/205646fbc52c4f09c9ca297e0ba924dfa827bc6b
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:42+02:00

Commit Message:
HARVESTER: Split startup WAV playback slots

Mirror the native startup one-shot audio split between START_WAV,
START_SINGLE_WAV, and LOAD/PLAY/DELETE_WAV.

Carry explicit startup audio commands through room setup and interaction
handling so rotating, single, and indexed preloaded samples no longer
collapse to one immediate handle.

Changed paths:
    TRACKER.md
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_flow.h
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/TRACKER.md b/TRACKER.md
index 68f3e0df1f8..2701615cb41 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,19 +15,18 @@
 
 ## Last Confirmed Action
 
-- Traced the native startup-room audio paths in Ghidra and aligned the startup engine with the confirmed split between looping room music and one-shot samples.
-  - `room_setup` starts `g_current_room_def->music_path` through `start_music_stream(&g_music_stream_state, ...)` and caches the active filename in `g_current_music_path`.
-  - `dispatch_room_event_actions` opcode `0x19` is the scripted music-spool path; it starts a replacement stream immediately and updates `g_current_music_path` without requiring a room transition.
-  - `dispatch_room_event_actions` opcodes `0x1b` and `0x2b` load one-shot samples through `load_sound_sample` / `start_sound_state_playback`, separate from the persistent music stream.
-  - `SOUND/MUSIC/MORNING2.CMP` is an `FCMP` file with a 14-byte header (`magic`, compressed payload size, sample rate, decoded bits-per-sample) followed by raw DVI/IMA ADPCM payload.
-- Patched the startup ScummVM path to mirror that split.
-  - Added looping startup-room music playback on top of `FCMP`/`CMP` and `WAV` decoding.
-  - Room-entry `musicPath` now starts persistent background music, while interaction-triggered `SPOOL_MUSIC` commands now swap that background stream without reusing the one-shot sample handle.
-  - One-shot startup sounds continue to use their own handle so room music can continue underneath them.
+- Traced the native startup/event one-shot audio opcode split in Ghidra and aligned the startup stub with the confirmed slot model.
+  - `START_WAV` (`dispatch_room_event_actions` case `0x1b`) uses an 8-slot rotating pool keyed by sample path.
+  - `START_SINGLE_WAV` (`0x2b`) uses a dedicated single sample slot.
+  - `LOAD_WAV` / `PLAY_WAV` / `DELETE_WAV` (`0x30` / `0x31` / `0x32`) manage four indexed preloaded sample slots.
+  - Decoded `HARVEST.SCR` confirms those preloaded slots are used by startup tags such as `CEM2_STARTUP`, `CEM10_STARTUP`, and `ARRESTED_STARTUP`, with later timers/actions driving `PLAY_WAV` and exit tags issuing `DELETE_WAV`.
+- Patched the startup ScummVM path to preserve that distinction instead of collapsing every effect to one immediate handle.
+  - Startup room setup state and object interactions now carry explicit audio commands rather than a single `soundPath`.
+  - The engine now executes rotating, single, and indexed preloaded startup sample commands separately while keeping looping room music independent.
 
 ## Next Suggested Action
 
-1. Recover the native startup/event opcode split for `LOAD_WAV`, `START_WAV`, and `START_SINGLE_WAV` so the stub can mirror the rotating versus dedicated sound-sample slots instead of collapsing every effect to one handle.
+1. Recover the startup-room exit-command path so `onExitCommand`/`EXITCOM` actions can drive native-style cleanup such as `DELETE_WAV` and any other room-teardown effects.
 2. Parse `REGION` records from `HARVEST.SCR` into the startup script layer.
 3. Mirror native startup transition handling for class `0x19` region entities.
   - Spawn enabled region hotspots from `room_setup` state.
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index dbf1010367f..b6a2fb86e7c 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -27,6 +27,7 @@
 #include "audio/decoders/adpcm.h"
 #include "audio/decoders/wave.h"
 #include "common/config-manager.h"
+#include "common/memstream.h"
 #include "common/system.h"
 #include "engines/util.h"
 #include "graphics/paletteman.h"
@@ -49,8 +50,7 @@ static bool shouldSkipStartupMoviesForDebug() {
 		ConfMan.getBool("harvester_debug_skip_startup_movies");
 }
 
-static Audio::SeekableAudioStream *openStartupAudioStream(ResourceManager &resources, const Common::String &path) {
-	Common::SeekableReadStream *stream = resources.openFile(path);
+static Audio::SeekableAudioStream *decodeStartupAudioStream(Common::SeekableReadStream *stream) {
 	if (!stream)
 		return nullptr;
 
@@ -80,6 +80,10 @@ static Audio::SeekableAudioStream *openStartupAudioStream(ResourceManager &resou
 	return Audio::makeWAVStream(stream, DisposeAfterUse::YES);
 }
 
+static Audio::SeekableAudioStream *openStartupAudioStream(ResourceManager &resources, const Common::String &path) {
+	return decodeStartupAudioStream(resources.openFile(path));
+}
+
 } // End of anonymous namespace
 
 HarvesterEngine *g_engine = nullptr;
@@ -138,24 +142,134 @@ void HarvesterEngine::stopStartupMusic() {
 	_startupMusicPath.clear();
 }
 
+bool HarvesterEngine::executeStartupAudioCommand(const StartupAudioCommand &command) {
+	switch (command.type) {
+	case kStartupAudioCommandStartWav:
+		return playStartupSound(command.path);
+	case kStartupAudioCommandStartSingleWav:
+		return playStartupSingleSound(command.path);
+	case kStartupAudioCommandLoadWav:
+		return loadStartupSound(command.slot, command.path);
+	case kStartupAudioCommandPlayWav:
+		return playStartupLoadedSound(command.slot);
+	case kStartupAudioCommandDeleteWav:
+		return deleteStartupLoadedSound(command.slot);
+	default:
+		return false;
+	}
+}
+
 bool HarvesterEngine::playStartupSound(const Common::String &path) {
-	stopStartupSound();
 	if (path.empty() || !_resources || !g_system || !g_system->getMixer())
 		return false;
 
+	const Common::String normalizedPath = _resources->normalizeResourcePath(path);
+	for (int i = 0; i < ARRAYSIZE(_startupSoundPaths); ++i) {
+		if (_startupSoundPaths[i].equalsIgnoreCase(normalizedPath)) {
+			stopStartupSoundHandle(_startupSoundHandles[i]);
+			_startupSoundPaths[i].clear();
+		}
+	}
+
+	_startupSoundSlotIndex = (_startupSoundSlotIndex + 1) % ARRAYSIZE(_startupSoundHandles);
+	stopStartupSoundHandle(_startupSoundHandles[_startupSoundSlotIndex]);
+
 	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(*_resources, path);
 	if (!audioStream) {
 		warning("Harvester: unable to decode startup sound '%s'", path.c_str());
 		return false;
 	}
 
-	g_system->getMixer()->playStream(Audio::Mixer::kSFXSoundType, &_startupSoundHandle, audioStream);
+	g_system->getMixer()->playStream(Audio::Mixer::kSFXSoundType,
+		&_startupSoundHandles[_startupSoundSlotIndex], audioStream);
+	_startupSoundPaths[_startupSoundSlotIndex] = normalizedPath;
+	return true;
+}
+
+bool HarvesterEngine::playStartupSingleSound(const Common::String &path) {
+	stopStartupSoundHandle(_startupSingleSoundHandle);
+	if (path.empty() || !_resources || !g_system || !g_system->getMixer())
+		return false;
+
+	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(*_resources, path);
+	if (!audioStream) {
+		warning("Harvester: unable to decode startup sound '%s'", path.c_str());
+		return false;
+	}
+
+	g_system->getMixer()->playStream(Audio::Mixer::kSFXSoundType, &_startupSingleSoundHandle, audioStream);
+	return true;
+}
+
+bool HarvesterEngine::loadStartupSound(int slot, const Common::String &path) {
+	if (!validateStartupLoadedSoundSlot(slot) || path.empty() || !_resources)
+		return false;
+
+	stopStartupSoundHandle(_startupLoadedSoundHandles[slot]);
+	_startupLoadedSoundPaths[slot].clear();
+	_startupLoadedSoundData[slot].clear();
+
+	if (!_resources->loadFile(path, _startupLoadedSoundData[slot])) {
+		warning("Harvester: unable to load startup sound '%s' into slot %d", path.c_str(), slot);
+		return false;
+	}
+
+	_startupLoadedSoundPaths[slot] = _resources->normalizeResourcePath(path);
+	return true;
+}
+
+bool HarvesterEngine::playStartupLoadedSound(int slot) {
+	if (!validateStartupLoadedSoundSlot(slot) || !g_system || !g_system->getMixer())
+		return false;
+	if (_startupLoadedSoundData[slot].empty())
+		return false;
+
+	stopStartupSoundHandle(_startupLoadedSoundHandles[slot]);
+
+	Common::SeekableReadStream *stream = new Common::MemoryReadStream(
+		_startupLoadedSoundData[slot].data(), _startupLoadedSoundData[slot].size(), DisposeAfterUse::NO);
+	Audio::SeekableAudioStream *audioStream = decodeStartupAudioStream(stream);
+	if (!audioStream) {
+		warning("Harvester: unable to decode startup sound slot %d ('%s')",
+			slot, _startupLoadedSoundPaths[slot].c_str());
+		return false;
+	}
+
+	g_system->getMixer()->playStream(Audio::Mixer::kSFXSoundType, &_startupLoadedSoundHandles[slot], audioStream);
+	return true;
+}
+
+bool HarvesterEngine::deleteStartupLoadedSound(int slot) {
+	if (!validateStartupLoadedSoundSlot(slot))
+		return false;
+
+	stopStartupSoundHandle(_startupLoadedSoundHandles[slot]);
+	_startupLoadedSoundPaths[slot].clear();
+	_startupLoadedSoundData[slot].clear();
 	return true;
 }
 
 void HarvesterEngine::stopStartupSound() {
+	stopStartupSoundHandle(_startupSingleSoundHandle);
+	for (int i = 0; i < ARRAYSIZE(_startupSoundHandles); ++i) {
+		stopStartupSoundHandle(_startupSoundHandles[i]);
+		_startupSoundPaths[i].clear();
+	}
+	for (int i = 0; i < ARRAYSIZE(_startupLoadedSoundHandles); ++i) {
+		stopStartupSoundHandle(_startupLoadedSoundHandles[i]);
+		_startupLoadedSoundPaths[i].clear();
+		_startupLoadedSoundData[i].clear();
+	}
+	_startupSoundSlotIndex = -1;
+}
+
+void HarvesterEngine::stopStartupSoundHandle(Audio::SoundHandle &handle) {
 	if (g_system && g_system->getMixer())
-		g_system->getMixer()->stopHandle(_startupSoundHandle);
+		g_system->getMixer()->stopHandle(handle);
+}
+
+bool HarvesterEngine::validateStartupLoadedSoundSlot(int slot) const {
+	return slot >= 0 && slot < ARRAYSIZE(_startupLoadedSoundHandles);
 }
 
 void HarvesterEngine::setDisplayMode(int width, int height) {
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 9e49587f4b8..6c1ef2552e4 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -22,12 +22,14 @@
 #ifndef HARVESTER_H
 #define HARVESTER_H
 
+#include "common/array.h"
 #include "audio/mixer.h"
 #include "common/error.h"
 #include "common/random.h"
 #include "common/serializer.h"
 #include "engines/engine.h"
 #include "graphics/screen.h"
+#include "harvester/startup_script.h"
 
 struct ADGameDescription;
 
@@ -80,17 +82,30 @@ public:
 	const Common::String &getStartupMusicPath() const { return _startupMusicPath; }
 	bool playStartupMusic(const Common::String &path);
 	void stopStartupMusic();
+	bool executeStartupAudioCommand(const StartupAudioCommand &command);
 	bool playStartupSound(const Common::String &path);
+	bool playStartupSingleSound(const Common::String &path);
+	bool loadStartupSound(int slot, const Common::String &path);
+	bool playStartupLoadedSound(int slot);
+	bool deleteStartupLoadedSound(int slot);
 	void stopStartupSound();
 
 private:
 	void setDisplayMode(int width, int height);
+	void stopStartupSoundHandle(Audio::SoundHandle &handle);
+	bool validateStartupLoadedSoundSlot(int slot) const;
 
 	const ADGameDescription *const _gameDescription;
 	Common::RandomSource _randomSource;
 	Audio::SoundHandle _startupMusicHandle;
-	Audio::SoundHandle _startupSoundHandle;
+	Audio::SoundHandle _startupSingleSoundHandle;
 	Common::String _startupMusicPath;
+	int _startupSoundSlotIndex = -1;
+	Common::String _startupSoundPaths[8];
+	Audio::SoundHandle _startupSoundHandles[8];
+	Common::String _startupLoadedSoundPaths[4];
+	Common::Array<byte> _startupLoadedSoundData[4];
+	Audio::SoundHandle _startupLoadedSoundHandles[4];
 	Graphics::Screen *_screen = nullptr;
 	ResourceManager *_resources = nullptr;
 	RuntimeEntityManager *_runtimeEntities = nullptr;
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 79c439ad50b..de29f1d0c8d 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -1457,6 +1457,7 @@ Common::Error StartupFlow::runMainMenuStub() {
 				if (_menuItems[selectedItem].equalsIgnoreCase("NEW GAME")) {
 					Common::Error roomError = runRoomLoop("START");
 					_engine.stopStartupMusic();
+					_engine.stopStartupSound();
 					if (roomError.getCode() == Common::kReadingFailed) {
 						statusMessage = "Unable to resolve START room setup from HARVEST.SCR.";
 						needsRedraw = true;
@@ -1520,6 +1521,7 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 		return transitionError;
 
 	resetCursorAnimationSequence();
+	executeStartupAudioCommands(scene.state.audioCommands);
 	if (!scene.state.musicPath.empty())
 		(void)_engine.playStartupMusic(scene.state.musicPath);
 	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
@@ -1671,9 +1673,7 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 					(void)_engine.playStartupMusic(interaction.musicPath);
 					restoreMusicPath = _engine.getStartupMusicPath();
 				}
-				if (!interaction.soundPath.empty()) {
-					(void)_engine.playStartupSound(interaction.soundPath);
-				}
+				executeStartupAudioCommands(interaction.audioCommands);
 				needsRedraw = true;
 
 				if (!interaction.nextRoomName.empty()) {
@@ -1936,6 +1936,11 @@ bool StartupFlow::pumpTransitionEvents(Common::Error &result) {
 	return false;
 }
 
+void StartupFlow::executeStartupAudioCommands(const Common::Array<StartupAudioCommand> &commands) {
+	for (const StartupAudioCommand &command : commands)
+		(void)_engine.executeStartupAudioCommand(command);
+}
+
 void StartupFlow::resetCursorAnimationSequence() {
 	if (!ensureCursorEntity())
 		return;
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/startup_flow.h
index 5a0515485db..aa9d129234c 100644
--- a/engines/harvester/startup_flow.h
+++ b/engines/harvester/startup_flow.h
@@ -56,6 +56,7 @@ private:
 	Common::Error beginRoomSetupTransition();
 	Common::Error fadeInRoomScene(const byte *palette, float targetBrightness);
 	bool pumpTransitionEvents(Common::Error &result);
+	void executeStartupAudioCommands(const Common::Array<StartupAudioCommand> &commands);
 	void resetCursorAnimationSequence();
 	bool tickRuntimeEntities();
 	void renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const;
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 6e2f5c5ccfa..e2436f244c0 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -73,6 +73,51 @@ static int parseEntranceFacing(const Common::String &direction) {
 	return -1;
 }
 
+static bool appendStartupAudioCommand(const StartupCommandRecord &command, Common::Array<StartupAudioCommand> &commands) {
+	if (command.opcodeName.equalsIgnoreCase("START_WAV")) {
+		StartupAudioCommand audioCommand;
+		audioCommand.type = kStartupAudioCommandStartWav;
+		audioCommand.path = command.arg1;
+		commands.push_back(audioCommand);
+		return true;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("START_SINGLE_WAV")) {
+		StartupAudioCommand audioCommand;
+		audioCommand.type = kStartupAudioCommandStartSingleWav;
+		audioCommand.path = command.arg1;
+		commands.push_back(audioCommand);
+		return true;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("LOAD_WAV")) {
+		StartupAudioCommand audioCommand;
+		audioCommand.type = kStartupAudioCommandLoadWav;
+		audioCommand.path = command.arg1;
+		audioCommand.slot = command.arg2.empty() ? -1 : atoi(command.arg2.c_str());
+		commands.push_back(audioCommand);
+		return true;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("PLAY_WAV")) {
+		StartupAudioCommand audioCommand;
+		audioCommand.type = kStartupAudioCommandPlayWav;
+		audioCommand.slot = command.arg1.empty() ? -1 : atoi(command.arg1.c_str());
+		commands.push_back(audioCommand);
+		return true;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("DELETE_WAV")) {
+		StartupAudioCommand audioCommand;
+		audioCommand.type = kStartupAudioCommandDeleteWav;
+		audioCommand.slot = command.arg1.empty() ? -1 : atoi(command.arg1.c_str());
+		commands.push_back(audioCommand);
+		return true;
+	}
+
+	return false;
+}
+
 static void tokenizeTownScriptLine(const Common::String &line, Common::Array<Common::String> &tokens) {
 	tokens.clear();
 
@@ -547,6 +592,11 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 			continue;
 		}
 
+		if (appendStartupAudioCommand(*command, state.audioCommands)) {
+			currentTag = command->arg4;
+			continue;
+		}
+
 		if (command->opcodeName.equalsIgnoreCase("ADD")) {
 			debugC(1, kDebugScene, "Harvester: room setup add object owner='%s' object='%s'",
 				command->arg1.c_str(), command->arg2.c_str());
@@ -654,11 +704,7 @@ bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object,
 			continue;
 		}
 
-		if (command->opcodeName.equalsIgnoreCase("START_WAV") ||
-			command->opcodeName.equalsIgnoreCase("LOAD_WAV") ||
-			command->opcodeName.equalsIgnoreCase("START_SINGLE_WAV")) {
-			if (result.soundPath.empty())
-				result.soundPath = command->arg1;
+		if (appendStartupAudioCommand(*command, result.audioCommands)) {
 			currentTag = command->arg4;
 			continue;
 		}
@@ -673,7 +719,7 @@ bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object,
 		if (command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
 			result.nextRoomName = command->arg1;
-			return !result.nextRoomName.empty() || !result.musicPath.empty() || !result.soundPath.empty();
+			return !result.nextRoomName.empty() || !result.musicPath.empty() || !result.audioCommands.empty();
 		}
 
 		debug(1, "Harvester: unsupported interaction command '%s' for tag '%s'",
@@ -681,7 +727,7 @@ bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object,
 		break;
 	}
 
-	return !result.nextRoomName.empty() || !result.musicPath.empty() || !result.soundPath.empty();
+	return !result.nextRoomName.empty() || !result.musicPath.empty() || !result.audioCommands.empty();
 }
 
 const StartupTextRecord *StartupScript::findTextRecord(const Common::String &key) const {
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 565d574e0b4..8e184b726de 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -125,6 +125,20 @@ struct StartupTextRecord {
 	Common::String value;
 };
 
+enum StartupAudioCommandType {
+	kStartupAudioCommandStartWav,
+	kStartupAudioCommandStartSingleWav,
+	kStartupAudioCommandLoadWav,
+	kStartupAudioCommandPlayWav,
+	kStartupAudioCommandDeleteWav
+};
+
+struct StartupAudioCommand {
+	StartupAudioCommandType type = kStartupAudioCommandStartWav;
+	Common::String path;
+	int slot = -1;
+};
+
 struct StartupRoomSetupState {
 	Common::String entranceName;
 	Common::String roomName;
@@ -148,12 +162,13 @@ struct StartupRoomSetupState {
 	Common::Array<StartupObjectRecord> activeObjects;
 	Common::Array<StartupObjectRecord> roomObjects;
 	Common::Array<StartupAnimRecord> roomAnimations;
+	Common::Array<StartupAudioCommand> audioCommands;
 };
 
 struct StartupInteractionResult {
 	Common::String musicPath;
-	Common::String soundPath;
 	Common::String nextRoomName;
+	Common::Array<StartupAudioCommand> audioCommands;
 };
 
 struct StartupResolvedText {


Commit: 30aa7bb0013b1bca71565638be4979e0c7ecf360
    https://github.com/scummvm/scummvm/commit/30aa7bb0013b1bca71565638be4979e0c7ecf360
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:42+02:00

Commit Message:
HARVESTER: Honor startup room exit audio

Mirror the native room_setup ordering by collecting startup room
onExitCommand audio chains and dispatching them before room handoff and
room-loop close.

This lets startup EXITCOM DELETE_WAV cleanup run for rooms such as CEM2,
CEM10, and JAIL while preserving the current recursive-room stub by
reapplying parent room-entry audio after a nested room returns.

Changed paths:
    TRACKER.md
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/TRACKER.md b/TRACKER.md
index 2701615cb41..736674114c8 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,20 +15,20 @@
 
 ## Last Confirmed Action
 
-- Traced the native startup/event one-shot audio opcode split in Ghidra and aligned the startup stub with the confirmed slot model.
-  - `START_WAV` (`dispatch_room_event_actions` case `0x1b`) uses an 8-slot rotating pool keyed by sample path.
-  - `START_SINGLE_WAV` (`0x2b`) uses a dedicated single sample slot.
-  - `LOAD_WAV` / `PLAY_WAV` / `DELETE_WAV` (`0x30` / `0x31` / `0x32`) manage four indexed preloaded sample slots.
-  - Decoded `HARVEST.SCR` confirms those preloaded slots are used by startup tags such as `CEM2_STARTUP`, `CEM10_STARTUP`, and `ARRESTED_STARTUP`, with later timers/actions driving `PLAY_WAV` and exit tags issuing `DELETE_WAV`.
-- Patched the startup ScummVM path to preserve that distinction instead of collapsing every effect to one immediate handle.
-  - Startup room setup state and object interactions now carry explicit audio commands rather than a single `soundPath`.
-  - The engine now executes rotating, single, and indexed preloaded startup sample commands separately while keeping looping room music independent.
+- Confirmed in Ghidra that `room_setup` dispatches the current room's `on_exit_tag` before resolving and loading the destination room.
+  - Startup exit tags such as `CEM2_EXITCOM`, `CEM10_EXITCOM0`, and `ARRESTED_EXITCOM` were decoded from `HARVEST.SCR`.
+  - The confirmed startup exit opcode mix is broader than audio alone: `DELETE_WAV`, `SET_FLAG`, `SET_NPC`, `SET_TIMER`, `KILL_TIMER`, `SET_ANIM`, `ADD`, `DELETE`, and `CHECK_FLAG` all appear on room-exit chains.
+- Patched the startup stub to mirror the confirmed room-exit audio cleanup path.
+  - `resolveRoomSetupState()` now collects room-exit audio commands alongside room-enter audio commands.
+  - `runRoomLoop()` now executes those exit audio commands before recursive room handoff and again when a stub room loop closes, so startup `DELETE_WAV` cleanup no longer depends on the global `stopStartupSound()` fallback.
+  - Parent rooms now reapply their room-enter audio setup after a nested room loop unwinds, preserving the current recursive-room stub behavior.
 
 ## Next Suggested Action
 
-1. Recover the startup-room exit-command path so `onExitCommand`/`EXITCOM` actions can drive native-style cleanup such as `DELETE_WAV` and any other room-teardown effects.
-2. Parse `REGION` records from `HARVEST.SCR` into the startup script layer.
-3. Mirror native startup transition handling for class `0x19` region entities.
+1. Introduce persistent startup script runtime state so `SET_FLAG`, `ADD`, and `DELETE` effects from room-enter, room-exit, and object-interaction chains survive across room transitions instead of being recomputed from `_flags` and `_objects` defaults.
+2. Extend startup exit handling beyond audio once that state exists, starting with the confirmed room-exit opcodes already present in startup rooms: `SET_NPC`, `SET_TIMER`, `KILL_TIMER`, and `SET_ANIM`.
+3. Parse `REGION` records from `HARVEST.SCR` into the startup script layer.
+4. Mirror native startup transition handling for class `0x19` region entities.
   - Spawn enabled region hotspots from `room_setup` state.
   - Drive cursor sequence `6` over active regions.
   - Gate region activation on player overlap plus facing, following `check_player_region_interaction`.
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index de29f1d0c8d..230b2753923 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -1677,38 +1677,29 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 				needsRedraw = true;
 
 				if (!interaction.nextRoomName.empty()) {
+					executeStartupAudioCommands(scene.state.exitAudioCommands);
 					Common::Error roomError = runRoomLoop(interaction.nextRoomName);
+					if (roomError.getCode() != Common::kReadingFailed &&
+						roomError.getCode() != Common::kNoError) {
+						return roomError;
+					}
+
+					(void)populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations);
+					resetCursorAnimationSequence();
+					executeStartupAudioCommands(scene.state.audioCommands);
 					if (!restoreMusicPath.empty())
 						(void)_engine.playStartupMusic(restoreMusicPath);
 					else
 						_engine.stopStartupMusic();
-					if (roomError.getCode() == Common::kReadingFailed) {
-						(void)populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations);
-						resetCursorAnimationSequence();
-						playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName(kPlayerActorEntityName) : nullptr;
-						playerState.centerX = state.playerSpawnX;
-						playerState.bottomY = state.playerSpawnY;
-						playerState.z = (float)state.playerSpawnZ;
-						playerState.facing = state.playerFacing;
-						playerState.hasMoveTarget = false;
-						playerState.turnActive = false;
-						playerState.turnTargetFacing = -1;
-						needsRedraw = true;
-					} else if (roomError.getCode() != Common::kNoError) {
-						return roomError;
-					} else {
-						(void)populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations);
-						resetCursorAnimationSequence();
-						playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName(kPlayerActorEntityName) : nullptr;
-						playerState.centerX = state.playerSpawnX;
-						playerState.bottomY = state.playerSpawnY;
-						playerState.z = (float)state.playerSpawnZ;
-						playerState.facing = state.playerFacing;
-						playerState.hasMoveTarget = false;
-						playerState.turnActive = false;
-						playerState.turnTargetFacing = -1;
-						needsRedraw = true;
-					}
+					playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName(kPlayerActorEntityName) : nullptr;
+					playerState.centerX = state.playerSpawnX;
+					playerState.bottomY = state.playerSpawnY;
+					playerState.z = (float)state.playerSpawnZ;
+					playerState.facing = state.playerFacing;
+					playerState.hasMoveTarget = false;
+					playerState.turnActive = false;
+					playerState.turnTargetFacing = -1;
+					needsRedraw = true;
 				}
 				break;
 			}
@@ -1742,6 +1733,7 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 				if (event.kbd.keycode == Common::KEYCODE_RETURN ||
 					event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
 					event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					executeStartupAudioCommands(scene.state.exitAudioCommands);
 					return Common::kNoError;
 				}
 				break;
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index e2436f244c0..67df3c5300d 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -618,6 +618,41 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 		break;
 	}
 
+	currentTag = room->onExitCommand;
+	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
+		const StartupCommandRecord *command = findCommand(currentTag);
+		if (!command) {
+			debug(1, "Harvester: unresolved startup room-exit command tag '%s' for room '%s'",
+				currentTag.c_str(), room->roomName.c_str());
+			break;
+		}
+
+		debugC(1, kDebugScene,
+			"Harvester: room exit command room='%s' step=%u tag='%s' opcode='%s' args=['%s','%s','%s','%s']",
+			room->roomName.c_str(), step, currentTag.c_str(), command->opcodeName.c_str(),
+			command->arg1.c_str(), command->arg2.c_str(), command->arg3.c_str(), command->arg4.c_str());
+
+		if (command->opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
+			currentTag = getFlagValue(command->arg1) ? command->arg2 : command->arg3;
+			continue;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("SET_FLAG")) {
+			setFlagValue(command->arg1, command->arg2.equalsIgnoreCase("T"));
+			currentTag = command->arg4;
+			continue;
+		}
+
+		if (appendStartupAudioCommand(*command, state.exitAudioCommands)) {
+			currentTag = command->arg4;
+			continue;
+		}
+
+		// Preserve the confirmed teardown audio chain even when later exit opcodes
+		// still depend on broader persistent startup script state we do not model yet.
+		currentTag = command->arg4;
+	}
+
 	state.paletteBrightness = (room->dimmable && !getFlagValue("DAY_FLAG"))
 		? kDimmedPaletteBrightness
 		: kDefaultPaletteBrightness;
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 8e184b726de..addaacd9c13 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -163,6 +163,7 @@ struct StartupRoomSetupState {
 	Common::Array<StartupObjectRecord> roomObjects;
 	Common::Array<StartupAnimRecord> roomAnimations;
 	Common::Array<StartupAudioCommand> audioCommands;
+	Common::Array<StartupAudioCommand> exitAudioCommands;
 };
 
 struct StartupInteractionResult {


Commit: c3067aec27e7abb64dc33375209df8861545109d
    https://github.com/scummvm/scummvm/commit/c3067aec27e7abb64dc33375209df8861545109d
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:42+02:00

Commit Message:
HARVESTER: Persist startup script room state

Changed paths:
    TRACKER.md
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/TRACKER.md b/TRACKER.md
index 736674114c8..0ec7a2c2a70 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,18 +15,19 @@
 
 ## Last Confirmed Action
 
-- Confirmed in Ghidra that `room_setup` dispatches the current room's `on_exit_tag` before resolving and loading the destination room.
-  - Startup exit tags such as `CEM2_EXITCOM`, `CEM10_EXITCOM0`, and `ARRESTED_EXITCOM` were decoded from `HARVEST.SCR`.
-  - The confirmed startup exit opcode mix is broader than audio alone: `DELETE_WAV`, `SET_FLAG`, `SET_NPC`, `SET_TIMER`, `KILL_TIMER`, `SET_ANIM`, `ADD`, `DELETE`, and `CHECK_FLAG` all appear on room-exit chains.
-- Patched the startup stub to mirror the confirmed room-exit audio cleanup path.
-  - `resolveRoomSetupState()` now collects room-exit audio commands alongside room-enter audio commands.
-  - `runRoomLoop()` now executes those exit audio commands before recursive room handoff and again when a stub room loop closes, so startup `DELETE_WAV` cleanup no longer depends on the global `stopStartupSound()` fallback.
-  - Parent rooms now reapply their room-enter audio setup after a nested room loop unwinds, preserving the current recursive-room stub behavior.
+- Confirmed in Ghidra that the native startup path keeps mutable town-script runtime state beyond room-local audio.
+  - `initialize_town_script_runtime` is called from the startup path before `room_setup`, which aligns with the native `ADD`/`DELETE` and `SET_ANIM` behavior observed in decoded `HARVEST.SCR`.
+  - Native room transitions still dispatch the current room's `on_exit_tag` before the destination room is resolved and loaded.
+  - Decoded startup exit chains such as `CEM10_EXITCOM0`, `DNALFT_EXITCOM`, and `SCH_HALL_EXITCOM` include `SET_FLAG`, `SET_NPC`, `SET_TIMER`, `KILL_TIMER`, `SET_ANIM`, `ADD`, `DELETE`, `DELETE_WAV`, and `CHECK_FLAG`.
+- Patched the startup stub to persist the runtime state that the current engine can model directly.
+  - `StartupScript` now keeps mutable runtime copies of flags, objects, and animations instead of rebuilding them from `_flags`, `_objects`, and `_animations` on every query.
+  - `resolveRoomSetupState()` mutates that runtime state through room-enter commands, `executeRoomExitCommands()` now applies room-exit chains at handoff time, and `materializeRoomState()` rebuilds the current room from live runtime state without re-running room-enter commands.
+  - `runRoomLoop()` now refreshes the current room from runtime state after same-room command mutations and after nested room unwind, preserving the player's current placement while carrying forward `SET_FLAG`, `ADD`, `DELETE`, and `SET_ANIM`.
 
 ## Next Suggested Action
 
-1. Introduce persistent startup script runtime state so `SET_FLAG`, `ADD`, and `DELETE` effects from room-enter, room-exit, and object-interaction chains survive across room transitions instead of being recomputed from `_flags` and `_objects` defaults.
-2. Extend startup exit handling beyond audio once that state exists, starting with the confirmed room-exit opcodes already present in startup rooms: `SET_NPC`, `SET_TIMER`, `KILL_TIMER`, and `SET_ANIM`.
+1. Parse startup NPC and timer records from `HARVEST.SCR`, then add persistent runtime state for the remaining confirmed exit and interaction opcodes: `SET_NPC`, `SET_TIMER`, and `KILL_TIMER`.
+2. Confirm the native side effects of those opcodes in Ghidra before naming any new runtime structures or script helpers.
 3. Parse `REGION` records from `HARVEST.SCR` into the startup script layer.
 4. Mirror native startup transition handling for class `0x19` region entities.
   - Spawn enabled region hotspots from `room_setup` state.
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 230b2753923..a9c516cb804 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -652,8 +652,7 @@ static int resolveRoomObjectCursorSequence(const StartupObjectRecord &object, St
 	if (object.operatable)
 		return kCursorSequenceOperate;
 
-	StartupInteractionResult interaction;
-	if (startupScript.resolveObjectInteraction(object, interaction))
+	if (startupScript.hasObjectInteraction(object))
 		return kCursorSequenceExamine;
 	if (startupScript.resolveObjectInspectText(object, inspectText))
 		return kCursorSequenceExamine;
@@ -1096,17 +1095,16 @@ static void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneR
 
 		mousePos = probePoint;
 		const Common::String objectLabel = startupScript->resolveObjectLabel(*hoveredObject);
-		StartupInteractionResult interaction;
 		StartupResolvedText inspectText;
 		const StartupRoomHoverState hoverState = resolveRoomHoverState(engine, scene.state, scene.sceneObjects, probePoint);
-		const bool hasInteraction = startupScript->resolveObjectInteraction(*hoveredObject, interaction);
+		const bool hasInteraction = startupScript->hasObjectInteraction(*hoveredObject);
 		const bool hasInspectText = startupScript->resolveObjectInspectText(*hoveredObject, inspectText);
 		debugC(1, kDebugGeneral,
 			"Harvester: startup probe hotspot room='%s' object='%s' point=(%d,%d) label='%s' prompt='%s' cursor_sequence=%d action_tag='%s' interaction=%d next_room='%s' inspect=%d",
 			scene.state.roomName.c_str(), hoveredObject->objectName.c_str(), probePoint.x, probePoint.y,
 			objectLabel.c_str(), hoverState.promptText.c_str(), hoverState.cursorSequence,
 			hoveredObject->actionTag.c_str(), hasInteraction,
-			interaction.nextRoomName.c_str(), hasInspectText);
+			"", hasInspectText);
 
 		if (roomSupportsMovementBand(scene.state)) {
 			Common::Point floorProbe;
@@ -1226,10 +1224,12 @@ Common::Error StartupFlow::run() {
 	if (!ensureCursorEntity())
 		return Common::kReadingFailed;
 
+	_engine.getStartupScript()->resetRuntimeState();
 	Common::Error error = runQuickTips();
 	if (error.getCode() != Common::kNoError)
 		return error;
 
+	_engine.getStartupScript()->resetRuntimeState();
 	return runRoomLoop("START");
 }
 
@@ -1426,6 +1426,7 @@ Common::Error StartupFlow::runMainMenuStub() {
 						return Common::kNoError;
 
 					if (_menuItems[selectedItem].equalsIgnoreCase("NEW GAME")) {
+						_engine.getStartupScript()->resetRuntimeState();
 						Common::Error roomError = runRoomLoop("START");
 						if (roomError.getCode() == Common::kReadingFailed) {
 							statusMessage = "Unable to resolve START room setup from HARVEST.SCR.";
@@ -1455,6 +1456,7 @@ Common::Error StartupFlow::runMainMenuStub() {
 					return Common::kNoError;
 
 				if (_menuItems[selectedItem].equalsIgnoreCase("NEW GAME")) {
+					_engine.getStartupScript()->resetRuntimeState();
 					Common::Error roomError = runRoomLoop("START");
 					_engine.stopStartupMusic();
 					_engine.stopStartupSound();
@@ -1541,9 +1543,56 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 	bool moveUp = false;
 	bool moveDown = false;
 	StartupRoomIdleAnimationState idleState;
-	idleState.activityTick = getRuntimeClockTicks();
-	idleState.resetTick = idleState.activityTick;
-	updatePlayerIdleTrigger(idleState);
+	auto resetIdleState = [&]() {
+		idleState = StartupRoomIdleAnimationState();
+		idleState.activityTick = getRuntimeClockTicks();
+		idleState.resetTick = idleState.activityTick;
+		updatePlayerIdleTrigger(idleState);
+	};
+	resetIdleState();
+	auto refreshCurrentScene = [&](bool preservePlayerPlacement) {
+		const Common::Array<StartupAudioCommand> entryAudioCommands = scene.state.audioCommands;
+		StartupRoomSetupState refreshedState;
+		if (!_engine.getStartupScript()->materializeRoomState(
+				scene.state.entranceName, scene.state.roomName, refreshedState)) {
+			return false;
+		}
+
+		refreshedState.audioCommands = entryAudioCommands;
+		if (!loadRoomSceneResources(refreshedState, *_engine.getResources(), scene))
+			return false;
+		if (!populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
+			return false;
+
+		playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName(kPlayerActorEntityName) : nullptr;
+		if (playerState.entity) {
+			if (!preservePlayerPlacement || playerState.facing < 0) {
+				playerState.centerX = scene.state.playerSpawnX;
+				playerState.bottomY = scene.state.playerSpawnY;
+				playerState.z = (float)scene.state.playerSpawnZ;
+				playerState.facing = scene.state.playerFacing;
+			}
+
+			const int facing = playerState.facing >= 0 ? playerState.facing : scene.state.playerFacing;
+			(void)setPlayerIdleAnimation(playerState, facing);
+			(void)applyRoomActorPlacement(scene.state, *playerState.entity,
+				playerState.centerX, playerState.bottomY, playerState.z);
+		}
+
+		playerState.hasMoveTarget = false;
+		playerState.turnActive = false;
+		playerState.turnTargetFacing = -1;
+		resetIdleState();
+		resetCursorAnimationSequence();
+		return true;
+	};
+	auto runRoomExitCommands = [&]() {
+		Common::Array<StartupAudioCommand> exitAudioCommands;
+		if (!_engine.getStartupScript()->executeRoomExitCommands(scene.state.roomName, exitAudioCommands))
+			return false;
+		executeStartupAudioCommands(exitAudioCommands);
+		return true;
+	};
 	bool needsRedraw = true;
 	Graphics::FrameLimiter limiter(g_system, 60);
 
@@ -1642,6 +1691,7 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 				if (!clickedObject->identShown && canShowInspectText) {
 					inspectText = resolvedInspectText;
 					clickedObject->identShown = true;
+					_engine.getStartupScript()->markObjectIdentShown(*clickedObject);
 					showingInspectText = true;
 					inspectCanDismiss = false;
 					playerState.hasMoveTarget = false;
@@ -1677,28 +1727,26 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 				needsRedraw = true;
 
 				if (!interaction.nextRoomName.empty()) {
-					executeStartupAudioCommands(scene.state.exitAudioCommands);
+					if (!runRoomExitCommands())
+						return Common::kReadingFailed;
+
 					Common::Error roomError = runRoomLoop(interaction.nextRoomName);
 					if (roomError.getCode() != Common::kReadingFailed &&
 						roomError.getCode() != Common::kNoError) {
 						return roomError;
 					}
 
-					(void)populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations);
-					resetCursorAnimationSequence();
+					if (!refreshCurrentScene(true))
+						return Common::kReadingFailed;
+
 					executeStartupAudioCommands(scene.state.audioCommands);
 					if (!restoreMusicPath.empty())
 						(void)_engine.playStartupMusic(restoreMusicPath);
 					else
 						_engine.stopStartupMusic();
-					playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName(kPlayerActorEntityName) : nullptr;
-					playerState.centerX = state.playerSpawnX;
-					playerState.bottomY = state.playerSpawnY;
-					playerState.z = (float)state.playerSpawnZ;
-					playerState.facing = state.playerFacing;
-					playerState.hasMoveTarget = false;
-					playerState.turnActive = false;
-					playerState.turnTargetFacing = -1;
+				} else if (interaction.mutatedRuntimeState) {
+					if (!refreshCurrentScene(true))
+						return Common::kReadingFailed;
 					needsRedraw = true;
 				}
 				break;
@@ -1733,7 +1781,8 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 				if (event.kbd.keycode == Common::KEYCODE_RETURN ||
 					event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
 					event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-					executeStartupAudioCommands(scene.state.exitAudioCommands);
+					if (!runRoomExitCommands())
+						return Common::kReadingFailed;
 					return Common::kNoError;
 				}
 				break;
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 67df3c5300d..a48191e5428 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -166,6 +166,7 @@ bool StartupScript::load(ResourceManager &resources) {
 
 	decode();
 	parseTownRecords(resources);
+	resetRuntimeState();
 	debug(1, "Harvester: loaded startup script '%s' (%u bytes)", _path.c_str(), (uint)_data.size());
 	return true;
 }
@@ -427,36 +428,221 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 }
 
 bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
-		ResourceManager &resources) const {
+		ResourceManager &resources) {
+	(void)resources;
 	state = StartupRoomSetupState();
 
-	const StartupEntranceRecord *entrance = nullptr;
-	for (const StartupEntranceRecord &candidate : _entrances) {
-		if (candidate.entranceName.equalsIgnoreCase(entranceName)) {
-			entrance = &candidate;
-			break;
-		}
-	}
+	const StartupEntranceRecord *entrance = findEntranceRecord(entranceName);
+	const Common::String resolvedRoomName = entrance ? entrance->roomName : entranceName;
+	const StartupRoomRecord *room = findRoomRecord(resolvedRoomName);
+	if (!room)
+		return false;
 
-	Common::String resolvedRoomName;
-	if (entrance)
-		resolvedRoomName = entrance->roomName;
-	else
-		resolvedRoomName = entranceName;
+	Common::String musicPath;
+	Common::Array<StartupAudioCommand> audioCommands;
+	bool mutatedRuntimeState = false;
+	executeCommandChain(room->onEnterCommand, "room setup command", room->roomName, false,
+		&musicPath, &audioCommands, nullptr, &mutatedRuntimeState);
 
-	const StartupRoomRecord *room = nullptr;
-	for (const StartupRoomRecord &candidate : _rooms) {
-		if (candidate.roomName.equalsIgnoreCase(resolvedRoomName)) {
-			room = &candidate;
-			break;
-		}
+	if (!buildRuntimeRoomState(*room, entrance, state))
+		return false;
+
+	state.audioCommands = audioCommands;
+	if (!musicPath.empty())
+		state.musicPath = musicPath;
+
+	debugC(1, kDebugGeneral,
+		"Harvester: resolveRoomSetupState('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u mutated=%d",
+		entranceName.c_str(), state.roomName.c_str(), state.entranceName.c_str(),
+		state.playerSpawnX, state.playerSpawnY, state.playerSpawnZ, state.playerFacing,
+		state.palettePath.c_str(), state.backgroundPath.c_str(), state.musicPath.c_str(),
+		(double)state.paletteBrightness, (uint)state.roomObjects.size(),
+		(uint)state.activeObjects.size(), (uint)state.roomAnimations.size(), mutatedRuntimeState);
+
+	return true;
+}
+
+void StartupScript::resetRuntimeState() {
+	_runtimeFlags = _flags;
+	_runtimeObjects = _objects;
+	_runtimeAnimations = _animations;
+
+	for (StartupObjectRecord &object : _runtimeObjects) {
+		object.currentX = object.initialX;
+		object.currentY = object.initialY;
+		object.currentZ = object.initialZ;
+		object.currentOwnerOrRoom = object.initialOwnerOrRoom;
+		object.runtimeVisible = object.visible;
+		object.identShown = object.identTextKey.empty();
 	}
+
+	for (StartupAnimRecord &anim : _runtimeAnimations) {
+		anim.runtimeActive = anim.active;
+		anim.runtimeVisible = anim.visible;
+		anim.runtimeState = -1;
+	}
+}
+
+bool StartupScript::materializeRoomState(const Common::String &entranceName, const Common::String &roomName,
+		StartupRoomSetupState &state) const {
+	state = StartupRoomSetupState();
+
+	const StartupEntranceRecord *entrance = findEntranceRecord(entranceName);
+	const Common::String resolvedRoomName = !roomName.empty()
+		? roomName
+		: (entrance ? entrance->roomName : Common::String());
+	if (resolvedRoomName.empty())
+		return false;
+
+	const StartupRoomRecord *room = findRoomRecord(resolvedRoomName);
+	if (!room)
+		return false;
+
+	return buildRuntimeRoomState(*room, entrance, state);
+}
+
+bool StartupScript::executeRoomExitCommands(const Common::String &roomName,
+		Common::Array<StartupAudioCommand> &audioCommands) {
+	audioCommands.clear();
+
+	const StartupRoomRecord *room = findRoomRecord(roomName);
 	if (!room)
 		return false;
 
+	bool mutatedRuntimeState = false;
+	executeCommandChain(room->onExitCommand, "room exit command", room->roomName, false,
+		nullptr, &audioCommands, nullptr, &mutatedRuntimeState);
+	return true;
+}
+
+bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result) {
+	result = StartupInteractionResult();
+	if (object.actionTag.empty())
+		return false;
+
+	executeCommandChain(object.actionTag, "interaction command", object.objectName, true,
+		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.mutatedRuntimeState);
+
+	return !result.nextRoomName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
+		result.mutatedRuntimeState || hasActionableCommandChain(object.actionTag);
+}
+
+bool StartupScript::hasObjectInteraction(const StartupObjectRecord &object) const {
+	if (object.actionTag.empty())
+		return false;
+
+	return hasActionableCommandChain(object.actionTag);
+}
+
+void StartupScript::markObjectIdentShown(const StartupObjectRecord &object) {
+	if (StartupObjectRecord *runtimeObject = findRuntimeObject(object.currentOwnerOrRoom, object.objectName))
+		runtimeObject->identShown = true;
+}
+
+const StartupEntranceRecord *StartupScript::findEntranceRecord(const Common::String &entranceName) const {
+	if (entranceName.empty())
+		return nullptr;
+
+	for (const StartupEntranceRecord &entrance : _entrances) {
+		if (entrance.entranceName.equalsIgnoreCase(entranceName))
+			return &entrance;
+	}
+
+	return nullptr;
+}
+
+const StartupRoomRecord *StartupScript::findRoomRecord(const Common::String &roomName) const {
+	if (roomName.empty())
+		return nullptr;
+
+	for (const StartupRoomRecord &room : _rooms) {
+		if (room.roomName.equalsIgnoreCase(roomName))
+			return &room;
+	}
+
+	return nullptr;
+}
+
+const StartupCommandRecord *StartupScript::findCommandRecord(const Common::String &tag) const {
+	if (tag.empty())
+		return nullptr;
+
+	for (const StartupCommandRecord &command : _commands) {
+		if (command.triggerTag.equalsIgnoreCase(tag))
+			return &command;
+	}
+
+	return nullptr;
+}
+
+const StartupFlagRecord *StartupScript::findRuntimeFlag(const Common::String &flagName) const {
+	if (flagName.empty())
+		return nullptr;
+
+	for (const StartupFlagRecord &flag : _runtimeFlags) {
+		if (flag.name.equalsIgnoreCase(flagName))
+			return &flag;
+	}
+
+	return nullptr;
+}
+
+StartupFlagRecord *StartupScript::findRuntimeFlag(const Common::String &flagName) {
+	if (flagName.empty())
+		return nullptr;
+
+	for (StartupFlagRecord &flag : _runtimeFlags) {
+		if (flag.name.equalsIgnoreCase(flagName))
+			return &flag;
+	}
+
+	return nullptr;
+}
+
+StartupObjectRecord *StartupScript::findRuntimeObject(const Common::String &ownerOrRoom,
+		const Common::String &objectName) {
+	if (objectName.empty())
+		return nullptr;
+
+	StartupObjectRecord *fallback = nullptr;
+	for (StartupObjectRecord &object : _runtimeObjects) {
+		if (!object.objectName.equalsIgnoreCase(objectName))
+			continue;
+
+		if (ownerOrRoom.empty())
+			return &object;
+
+		if (object.currentOwnerOrRoom.equalsIgnoreCase(ownerOrRoom) ||
+			object.initialOwnerOrRoom.equalsIgnoreCase(ownerOrRoom)) {
+			return &object;
+		}
+
+		if (!fallback)
+			fallback = &object;
+	}
+
+	return fallback;
+}
+
+StartupAnimRecord *StartupScript::findRuntimeAnim(const Common::String &animName) {
+	if (animName.empty())
+		return nullptr;
+
+	for (StartupAnimRecord &anim : _runtimeAnimations) {
+		if (anim.animName.equalsIgnoreCase(animName))
+			return &anim;
+	}
+
+	return nullptr;
+}
+
+bool StartupScript::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupEntranceRecord *entrance,
+		StartupRoomSetupState &state) const {
+	state = StartupRoomSetupState();
+
 	const StartupObjectRecord *background = nullptr;
-	for (const StartupObjectRecord &candidate : _objects) {
-		if (!candidate.currentOwnerOrRoom.equalsIgnoreCase(room->roomName) ||
+	for (const StartupObjectRecord &candidate : _runtimeObjects) {
+		if (!candidate.currentOwnerOrRoom.equalsIgnoreCase(room.roomName) ||
 			!candidate.spritePath.hasPrefixIgnoreCase("GRAPHIC/ROOMS/") ||
 			!candidate.spritePath.hasSuffixIgnoreCase(".BM")) {
 			continue;
@@ -468,19 +654,19 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 	if (!background)
 		return false;
 
-	state.roomName = room->roomName;
-	state.palettePath = room->palettePath;
+	state.roomName = room.roomName;
+	state.palettePath = room.palettePath;
 	state.backgroundPath = background->spritePath;
-	state.roomMinZ = room->minZ;
-	state.roomMaxZ = room->maxZ;
-	state.roomMaxZScreenY = room->maxZScreenY;
-	state.roomMinZScreenY = room->minZScreenY;
-	state.roomFullScaleZ = room->fullScaleZ;
-	state.roomMaxZScalePercent = room->maxZScalePercent;
-	state.roomPerspectiveScale = room->perspectiveScale;
-	state.roomZVelocityStep = room->zVelocityStep;
-	state.musicPath = room->musicPath;
-	if (entrance) {
+	state.roomMinZ = room.minZ;
+	state.roomMaxZ = room.maxZ;
+	state.roomMaxZScreenY = room.maxZScreenY;
+	state.roomMinZScreenY = room.minZScreenY;
+	state.roomFullScaleZ = room.fullScaleZ;
+	state.roomMaxZScalePercent = room.maxZScalePercent;
+	state.roomPerspectiveScale = room.perspectiveScale;
+	state.roomZVelocityStep = room.zVelocityStep;
+	state.musicPath = room.musicPath;
+	if (entrance && entrance->roomName.equalsIgnoreCase(room.roomName)) {
 		state.entranceName = entrance->entranceName;
 		state.hasEntrance = true;
 		state.playerSpawnX = entrance->posX;
@@ -488,281 +674,208 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 		state.playerSpawnZ = entrance->posZ;
 		state.playerFacing = entrance->facing;
 	}
-	for (const StartupObjectRecord &object : _objects) {
-		if (object.currentOwnerOrRoom.equalsIgnoreCase(room->roomName))
+	for (const StartupObjectRecord &object : _runtimeObjects) {
+		if (object.currentOwnerOrRoom.equalsIgnoreCase(room.roomName))
 			state.roomObjects.push_back(object);
 	}
-	for (const StartupAnimRecord &anim : _animations) {
-		if (anim.roomName.equalsIgnoreCase(room->roomName))
+	for (const StartupAnimRecord &anim : _runtimeAnimations) {
+		if (anim.roomName.equalsIgnoreCase(room.roomName))
 			state.roomAnimations.push_back(anim);
 	}
 
-	Common::Array<StartupFlagRecord> resolvedFlags = _flags;
-	auto getFlagValue = [&](const Common::String &flagName) {
-		for (const StartupFlagRecord &flag : resolvedFlags) {
-			if (flag.name.equalsIgnoreCase(flagName))
-				return flag.value;
-		}
-		return false;
-	};
-	auto setFlagValue = [&](const Common::String &flagName, bool value) {
-		for (StartupFlagRecord &flag : resolvedFlags) {
-			if (flag.name.equalsIgnoreCase(flagName)) {
-				flag.value = value;
-				return;
-			}
-		}
+	const StartupFlagRecord *dayFlag = findRuntimeFlag("DAY_FLAG");
+	state.paletteBrightness = (room.dimmable && (!dayFlag || !dayFlag->value))
+		? kDimmedPaletteBrightness
+		: kDefaultPaletteBrightness;
 
-		StartupFlagRecord flag;
-		flag.name = flagName;
-		flag.value = value;
-		resolvedFlags.push_back(flag);
-	};
-	auto addObject = [&](const Common::String &ownerOrRoom, const Common::String &objectName) {
-		for (const StartupObjectRecord &candidate : _objects) {
-			if (!candidate.initialOwnerOrRoom.equalsIgnoreCase(ownerOrRoom) ||
-				!candidate.objectName.equalsIgnoreCase(objectName)) {
-				continue;
-			}
+	for (const StartupObjectRecord &object : state.roomObjects) {
+		debugC(1, kDebugScene,
+			"Harvester: materialized room object room='%s' object='%s' owner='%s' visible=%d runtimeVisible=%d sprite='%s' alt='%s' pos=(%d,%d,%d) bounds=(%d,%d)-(%d,%d) action='%s'",
+			state.roomName.c_str(), object.objectName.c_str(), object.currentOwnerOrRoom.c_str(),
+			object.visible, object.runtimeVisible, object.spritePath.c_str(), object.altSpritePath.c_str(),
+			object.currentX, object.currentY, object.currentZ,
+			object.currentX, object.currentY, object.boundsX2, object.boundsY2, object.actionTag.c_str());
+	}
 
-			for (const StartupObjectRecord &activeObject : state.activeObjects) {
-				if (activeObject.currentOwnerOrRoom.equalsIgnoreCase(ownerOrRoom) &&
-					activeObject.objectName.equalsIgnoreCase(candidate.objectName)) {
-					return;
-				}
-			}
+	debugC(1, kDebugGeneral,
+		"Harvester: materializeRoomState room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u roomAnims=%u",
+		state.roomName.c_str(), state.entranceName.c_str(), state.playerSpawnX, state.playerSpawnY,
+		state.playerSpawnZ, state.playerFacing, state.palettePath.c_str(), state.backgroundPath.c_str(),
+		state.musicPath.c_str(), (double)state.paletteBrightness,
+		(uint)state.roomObjects.size(), (uint)state.roomAnimations.size());
 
-			StartupObjectRecord object = candidate;
-			object.currentOwnerOrRoom = ownerOrRoom;
-			state.activeObjects.push_back(object);
-			return;
-		}
-	};
-	auto removeObject = [&](const Common::String &ownerOrRoom, const Common::String &objectName) {
-		for (uint i = 0; i < state.activeObjects.size(); ++i) {
-			if (state.activeObjects[i].currentOwnerOrRoom.equalsIgnoreCase(ownerOrRoom) &&
-				state.activeObjects[i].objectName.equalsIgnoreCase(objectName)) {
-				state.activeObjects.remove_at(i);
-				return;
-			}
-		}
+	return true;
+}
+
+void StartupScript::executeCommandChain(const Common::String &initialTag, const char *contextLabel,
+		const Common::String &contextName, bool allowTransitions, Common::String *musicPath,
+		Common::Array<StartupAudioCommand> *audioCommands, Common::String *nextRoomName,
+		bool *mutatedRuntimeState) {
+	auto isTruthy = [](const Common::String &value) {
+		return value.equalsIgnoreCase("T") || value.equalsIgnoreCase("ON") || value.equalsIgnoreCase("TRUE");
 	};
-	auto findCommand = [&](const Common::String &tag) -> const StartupCommandRecord * {
-		for (const StartupCommandRecord &command : _commands) {
-			if (command.triggerTag.equalsIgnoreCase(tag))
-				return &command;
-		}
-		return nullptr;
+	auto noteMutation = [&](bool changed) {
+		if (changed && mutatedRuntimeState)
+			*mutatedRuntimeState = true;
 	};
 
-	Common::String currentTag = room->onEnterCommand;
+	Common::String currentTag = initialTag;
 	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
-		const StartupCommandRecord *command = findCommand(currentTag);
+		const StartupCommandRecord *command = findCommandRecord(currentTag);
 		if (!command) {
-			debug(1, "Harvester: unresolved startup command tag '%s' for room '%s'",
-				currentTag.c_str(), room->roomName.c_str());
+			debug(1, "Harvester: unresolved %s tag '%s' for '%s'",
+				contextLabel, currentTag.c_str(), contextName.c_str());
 			break;
 		}
 
 		debugC(1, kDebugScene,
-			"Harvester: room setup command room='%s' step=%u tag='%s' opcode='%s' args=['%s','%s','%s','%s']",
-			room->roomName.c_str(), step, currentTag.c_str(), command->opcodeName.c_str(),
+			"Harvester: %s '%s' step=%u tag='%s' opcode='%s' args=['%s','%s','%s','%s']",
+			contextLabel, contextName.c_str(), step, currentTag.c_str(), command->opcodeName.c_str(),
 			command->arg1.c_str(), command->arg2.c_str(), command->arg3.c_str(), command->arg4.c_str());
 
 		if (command->opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
-			debugC(1, kDebugScene, "Harvester: room setup flag '%s' -> %d",
-				command->arg1.c_str(), getFlagValue(command->arg1));
-			currentTag = getFlagValue(command->arg1) ? command->arg2 : command->arg3;
+			const StartupFlagRecord *flag = findRuntimeFlag(command->arg1);
+			const bool flagValue = flag && flag->value;
+			debugC(1, kDebugScene, "Harvester: %s '%s' flag '%s' -> %d",
+				contextLabel, contextName.c_str(), command->arg1.c_str(), flagValue);
+			currentTag = flagValue ? command->arg2 : command->arg3;
 			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("SET_FLAG")) {
-			debugC(1, kDebugScene, "Harvester: room setup set flag '%s' = %d",
-				command->arg1.c_str(), command->arg2.equalsIgnoreCase("T"));
-			setFlagValue(command->arg1, command->arg2.equalsIgnoreCase("T"));
+			const bool flagValue = isTruthy(command->arg2);
+			StartupFlagRecord *flag = findRuntimeFlag(command->arg1);
+			bool changed = false;
+			if (flag) {
+				changed = flag->value != flagValue;
+				flag->value = flagValue;
+			} else {
+				StartupFlagRecord newFlag;
+				newFlag.name = command->arg1;
+				newFlag.value = flagValue;
+				_runtimeFlags.push_back(newFlag);
+				changed = true;
+			}
+			noteMutation(changed);
 			currentTag = command->arg4;
 			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("SPOOL_MUSIC")) {
-			debugC(1, kDebugScene, "Harvester: room setup spool music '%s'",
-				command->arg1.c_str());
-			state.musicPath = resources.normalizeResourcePath(command->arg1);
+			if (musicPath)
+				*musicPath = command->arg1;
 			currentTag = command->arg4;
 			continue;
 		}
 
-		if (appendStartupAudioCommand(*command, state.audioCommands)) {
-			currentTag = command->arg4;
-			continue;
+		if (audioCommands) {
+			if (appendStartupAudioCommand(*command, *audioCommands)) {
+				currentTag = command->arg4;
+				continue;
+			}
+		} else {
+			Common::Array<StartupAudioCommand> ignoredAudioCommands;
+			if (appendStartupAudioCommand(*command, ignoredAudioCommands)) {
+				currentTag = command->arg4;
+				continue;
+			}
 		}
 
-		if (command->opcodeName.equalsIgnoreCase("ADD")) {
-			debugC(1, kDebugScene, "Harvester: room setup add object owner='%s' object='%s'",
-				command->arg1.c_str(), command->arg2.c_str());
-			addObject(command->arg1, command->arg2);
-			currentTag = command->arg4;
-			continue;
-		}
+		if (command->opcodeName.equalsIgnoreCase("ADD") ||
+			command->opcodeName.equalsIgnoreCase("DELETE")) {
+			StartupObjectRecord *runtimeObject = findRuntimeObject(command->arg1, command->arg2);
+			if (!runtimeObject) {
+				debug(1, "Harvester: unresolved object for %s '%s' owner='%s' object='%s'",
+					contextLabel, contextName.c_str(), command->arg1.c_str(), command->arg2.c_str());
+				currentTag = command->arg4;
+				continue;
+			}
 
-		if (command->opcodeName.equalsIgnoreCase("DELETE")) {
-			debugC(1, kDebugScene, "Harvester: room setup delete object owner='%s' object='%s'",
-				command->arg1.c_str(), command->arg2.c_str());
-			removeObject(command->arg1, command->arg2);
+			const bool visible = command->opcodeName.equalsIgnoreCase("ADD");
+			const bool changed = runtimeObject->visible != visible ||
+				runtimeObject->runtimeVisible != visible;
+			runtimeObject->visible = visible;
+			runtimeObject->runtimeVisible = visible;
+			if (visible && runtimeObject->currentOwnerOrRoom.equalsIgnoreCase("INVENTORY"))
+				runtimeObject->identShown = true;
+			noteMutation(changed);
 			currentTag = command->arg4;
 			continue;
 		}
 
-		debug(1, "Harvester: unsupported startup command '%s' for tag '%s'",
-			command->opcodeName.c_str(), command->triggerTag.c_str());
-		break;
-	}
-
-	currentTag = room->onExitCommand;
-	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
-		const StartupCommandRecord *command = findCommand(currentTag);
-		if (!command) {
-			debug(1, "Harvester: unresolved startup room-exit command tag '%s' for room '%s'",
-				currentTag.c_str(), room->roomName.c_str());
-			break;
-		}
-
-		debugC(1, kDebugScene,
-			"Harvester: room exit command room='%s' step=%u tag='%s' opcode='%s' args=['%s','%s','%s','%s']",
-			room->roomName.c_str(), step, currentTag.c_str(), command->opcodeName.c_str(),
-			command->arg1.c_str(), command->arg2.c_str(), command->arg3.c_str(), command->arg4.c_str());
-
-		if (command->opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
-			currentTag = getFlagValue(command->arg1) ? command->arg2 : command->arg3;
-			continue;
-		}
+		if (command->opcodeName.equalsIgnoreCase("SET_ANIM")) {
+			StartupAnimRecord *runtimeAnim = findRuntimeAnim(command->arg1);
+			if (!runtimeAnim) {
+				debug(1, "Harvester: unresolved anim for %s '%s' anim='%s'",
+					contextLabel, contextName.c_str(), command->arg1.c_str());
+				currentTag = command->arg4;
+				continue;
+			}
 
-		if (command->opcodeName.equalsIgnoreCase("SET_FLAG")) {
-			setFlagValue(command->arg1, command->arg2.equalsIgnoreCase("T"));
+			const bool active = isTruthy(command->arg2);
+			const bool visible = isTruthy(command->arg3);
+			const bool changed = runtimeAnim->active != active ||
+				runtimeAnim->visible != visible ||
+				runtimeAnim->runtimeActive != active ||
+				runtimeAnim->runtimeVisible != visible;
+			runtimeAnim->active = active;
+			runtimeAnim->visible = visible;
+			runtimeAnim->runtimeActive = active;
+			runtimeAnim->runtimeVisible = visible;
+			noteMutation(changed);
 			currentTag = command->arg4;
 			continue;
 		}
 
-		if (appendStartupAudioCommand(*command, state.exitAudioCommands)) {
-			currentTag = command->arg4;
-			continue;
+		if (command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
+			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
+			if (allowTransitions && nextRoomName)
+				*nextRoomName = command->arg1;
+			else
+				debugC(1, kDebugScene, "Harvester: skipped transition opcode '%s' while processing %s '%s'",
+					command->opcodeName.c_str(), contextLabel, contextName.c_str());
+			return;
 		}
 
-		// Preserve the confirmed teardown audio chain even when later exit opcodes
-		// still depend on broader persistent startup script state we do not model yet.
+		debug(1, "Harvester: unsupported startup command '%s' for %s '%s', continuing",
+			command->opcodeName.c_str(), contextLabel, contextName.c_str());
 		currentTag = command->arg4;
 	}
-
-	state.paletteBrightness = (room->dimmable && !getFlagValue("DAY_FLAG"))
-		? kDimmedPaletteBrightness
-		: kDefaultPaletteBrightness;
-
-	for (const StartupObjectRecord &object : state.roomObjects) {
-		debugC(1, kDebugScene,
-			"Harvester: room setup room object room='%s' object='%s' owner='%s' visible=%d runtimeVisible=%d sprite='%s' alt='%s' pos=(%d,%d,%d) bounds=(%d,%d)-(%d,%d) action='%s'",
-			state.roomName.c_str(), object.objectName.c_str(), object.currentOwnerOrRoom.c_str(),
-			object.visible, object.runtimeVisible, object.spritePath.c_str(), object.altSpritePath.c_str(),
-			object.currentX, object.currentY, object.currentZ,
-			object.currentX, object.currentY, object.boundsX2, object.boundsY2, object.actionTag.c_str());
-	}
-	for (const StartupObjectRecord &object : state.activeObjects) {
-		debugC(1, kDebugScene,
-			"Harvester: room setup active object room='%s' object='%s' owner='%s' visible=%d runtimeVisible=%d sprite='%s' alt='%s' pos=(%d,%d,%d) bounds=(%d,%d)-(%d,%d) action='%s'",
-			state.roomName.c_str(), object.objectName.c_str(), object.currentOwnerOrRoom.c_str(),
-			object.visible, object.runtimeVisible, object.spritePath.c_str(), object.altSpritePath.c_str(),
-			object.currentX, object.currentY, object.currentZ,
-			object.currentX, object.currentY, object.boundsX2, object.boundsY2, object.actionTag.c_str());
-	}
-
-	debugC(1, kDebugGeneral,
-		"Harvester: resolveRoomSetupState('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u",
-		entranceName.c_str(), state.roomName.c_str(), state.entranceName.c_str(),
-		state.playerSpawnX, state.playerSpawnY, state.playerSpawnZ, state.playerFacing,
-		state.palettePath.c_str(), state.backgroundPath.c_str(), state.musicPath.c_str(),
-		(double)state.paletteBrightness, (uint)state.roomObjects.size(),
-		(uint)state.activeObjects.size(), (uint)state.roomAnimations.size());
-
-	return true;
 }
 
-bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result) const {
-	result = StartupInteractionResult();
-	if (object.actionTag.empty())
-		return false;
-
-	Common::Array<StartupFlagRecord> resolvedFlags = _flags;
-	auto getFlagValue = [&](const Common::String &flagName) {
-		for (const StartupFlagRecord &flag : resolvedFlags) {
-			if (flag.name.equalsIgnoreCase(flagName))
-				return flag.value;
-		}
-		return false;
-	};
-	auto setFlagValue = [&](const Common::String &flagName, bool value) {
-		for (StartupFlagRecord &flag : resolvedFlags) {
-			if (flag.name.equalsIgnoreCase(flagName)) {
-				flag.value = value;
-				return;
-			}
-		}
-
-		StartupFlagRecord flag;
-		flag.name = flagName;
-		flag.value = value;
-		resolvedFlags.push_back(flag);
-	};
-	auto findCommand = [&](const Common::String &tag) -> const StartupCommandRecord * {
-		for (const StartupCommandRecord &command : _commands) {
-			if (command.triggerTag.equalsIgnoreCase(tag))
-				return &command;
-		}
-		return nullptr;
-	};
-
-	Common::String currentTag = object.actionTag;
+bool StartupScript::hasActionableCommandChain(const Common::String &initialTag) const {
+	Common::String currentTag = initialTag;
 	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
-		const StartupCommandRecord *command = findCommand(currentTag);
+		const StartupCommandRecord *command = findCommandRecord(currentTag);
 		if (!command) {
-			debug(1, "Harvester: unresolved interaction command tag '%s' for object '%s'",
-				currentTag.c_str(), object.objectName.c_str());
+			debug(1, "Harvester: unresolved interaction probe tag '%s'", currentTag.c_str());
 			break;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
-			currentTag = getFlagValue(command->arg1) ? command->arg2 : command->arg3;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("SET_FLAG")) {
-			setFlagValue(command->arg1, command->arg2.equalsIgnoreCase("T"));
-			currentTag = command->arg4;
+			const StartupFlagRecord *flag = findRuntimeFlag(command->arg1);
+			currentTag = (flag && flag->value) ? command->arg2 : command->arg3;
 			continue;
 		}
 
-		if (appendStartupAudioCommand(*command, result.audioCommands)) {
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("SPOOL_MUSIC")) {
-			if (result.musicPath.empty())
-				result.musicPath = command->arg1;
-			currentTag = command->arg4;
-			continue;
-		}
-
-		if (command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
+		if (command->opcodeName.equalsIgnoreCase("SET_FLAG") ||
+			command->opcodeName.equalsIgnoreCase("SPOOL_MUSIC") ||
+			command->opcodeName.equalsIgnoreCase("ADD") ||
+			command->opcodeName.equalsIgnoreCase("DELETE") ||
+			command->opcodeName.equalsIgnoreCase("SET_ANIM") ||
+			command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
-			result.nextRoomName = command->arg1;
-			return !result.nextRoomName.empty() || !result.musicPath.empty() || !result.audioCommands.empty();
+			return true;
 		}
 
-		debug(1, "Harvester: unsupported interaction command '%s' for tag '%s'",
-			command->opcodeName.c_str(), command->triggerTag.c_str());
-		break;
+		Common::Array<StartupAudioCommand> audioCommands;
+		if (appendStartupAudioCommand(*command, audioCommands))
+			return true;
+
+		currentTag = command->arg4;
 	}
 
-	return !result.nextRoomName.empty() || !result.musicPath.empty() || !result.audioCommands.empty();
+	return false;
 }
 
 const StartupTextRecord *StartupScript::findTextRecord(const Common::String &key) const {
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index addaacd9c13..5af0f2a25ff 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -163,13 +163,13 @@ struct StartupRoomSetupState {
 	Common::Array<StartupObjectRecord> roomObjects;
 	Common::Array<StartupAnimRecord> roomAnimations;
 	Common::Array<StartupAudioCommand> audioCommands;
-	Common::Array<StartupAudioCommand> exitAudioCommands;
 };
 
 struct StartupInteractionResult {
 	Common::String musicPath;
 	Common::String nextRoomName;
 	Common::Array<StartupAudioCommand> audioCommands;
+	bool mutatedRuntimeState = false;
 };
 
 struct StartupResolvedText {
@@ -192,9 +192,15 @@ public:
 	const Common::Array<StartupTextRecord> &getTexts() const { return _texts; }
 	bool isQuickTipsEnabled() const { return _quickTipsEnabled; }
 	void setQuickTipsEnabled(bool enabled) { _quickTipsEnabled = enabled; }
+	void resetRuntimeState();
 	bool resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
-		ResourceManager &resources) const;
-	bool resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result) const;
+		ResourceManager &resources);
+	bool materializeRoomState(const Common::String &entranceName, const Common::String &roomName,
+		StartupRoomSetupState &state) const;
+	bool executeRoomExitCommands(const Common::String &roomName, Common::Array<StartupAudioCommand> &audioCommands);
+	bool resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result);
+	bool hasObjectInteraction(const StartupObjectRecord &object) const;
+	void markObjectIdentShown(const StartupObjectRecord &object);
 	bool resolveObjectInspectText(const StartupObjectRecord &object, StartupResolvedText &text) const;
 	Common::String resolveObjectLabel(const StartupObjectRecord &object) const;
 	Common::String resolveTextValue(const Common::String &key) const;
@@ -203,7 +209,21 @@ private:
 	bool loadConfig(ResourceManager &resources);
 	void decode();
 	void parseTownRecords(ResourceManager &resources);
+	const StartupEntranceRecord *findEntranceRecord(const Common::String &entranceName) const;
+	const StartupRoomRecord *findRoomRecord(const Common::String &roomName) const;
+	const StartupCommandRecord *findCommandRecord(const Common::String &tag) const;
 	const StartupTextRecord *findTextRecord(const Common::String &key) const;
+	const StartupFlagRecord *findRuntimeFlag(const Common::String &flagName) const;
+	StartupFlagRecord *findRuntimeFlag(const Common::String &flagName);
+	StartupObjectRecord *findRuntimeObject(const Common::String &ownerOrRoom, const Common::String &objectName);
+	StartupAnimRecord *findRuntimeAnim(const Common::String &animName);
+	bool buildRuntimeRoomState(const StartupRoomRecord &room, const StartupEntranceRecord *entrance,
+		StartupRoomSetupState &state) const;
+	void executeCommandChain(const Common::String &initialTag, const char *contextLabel,
+		const Common::String &contextName, bool allowTransitions, Common::String *musicPath,
+		Common::Array<StartupAudioCommand> *audioCommands, Common::String *nextRoomName,
+		bool *mutatedRuntimeState);
+	bool hasActionableCommandChain(const Common::String &initialTag) const;
 
 	Common::String _path;
 	Common::Array<byte> _data;
@@ -214,6 +234,9 @@ private:
 	Common::Array<StartupFlagRecord> _flags;
 	Common::Array<StartupCommandRecord> _commands;
 	Common::Array<StartupTextRecord> _texts;
+	Common::Array<StartupFlagRecord> _runtimeFlags;
+	Common::Array<StartupObjectRecord> _runtimeObjects;
+	Common::Array<StartupAnimRecord> _runtimeAnimations;
 	bool _quickTipsEnabled = true;
 };
 


Commit: 0e5a469b3e49061be23fb14eaafb50084b75dd25
    https://github.com/scummvm/scummvm/commit/0e5a469b3e49061be23fb14eaafb50084b75dd25
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:43+02:00

Commit Message:
HARVESTER: Restore startup closeup pickup hotspots

Changed paths:
    TRACKER.md
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/TRACKER.md b/TRACKER.md
index 0ec7a2c2a70..95cbe4d43f0 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,21 +15,21 @@
 
 ## Last Confirmed Action
 
-- Confirmed in Ghidra that the native startup path keeps mutable town-script runtime state beyond room-local audio.
-  - `initialize_town_script_runtime` is called from the startup path before `room_setup`, which aligns with the native `ADD`/`DELETE` and `SET_ANIM` behavior observed in decoded `HARVEST.SCR`.
-  - Native room transitions still dispatch the current room's `on_exit_tag` before the destination room is resolved and loaded.
-  - Decoded startup exit chains such as `CEM10_EXITCOM0`, `DNALFT_EXITCOM`, and `SCH_HALL_EXITCOM` include `SET_FLAG`, `SET_NPC`, `SET_TIMER`, `KILL_TIMER`, `SET_ANIM`, `ADD`, `DELETE`, `DELETE_WAV`, and `CHECK_FLAG`.
-- Patched the startup stub to persist the runtime state that the current engine can model directly.
-  - `StartupScript` now keeps mutable runtime copies of flags, objects, and animations instead of rebuilding them from `_flags`, `_objects`, and `_animations` on every query.
-  - `resolveRoomSetupState()` mutates that runtime state through room-enter commands, `executeRoomExitCommands()` now applies room-exit chains at handoff time, and `materializeRoomState()` rebuilds the current room from live runtime state without re-running room-enter commands.
-  - `runRoomLoop()` now refreshes the current room from runtime state after same-room command mutations and after nested room unwind, preserving the player's current placement while carrying forward `SET_FLAG`, `ADD`, `DELETE`, and `SET_ANIM`.
+- Confirmed in Ghidra how native startup closeups and pickup hotspots differ from ordinary object interactions.
+  - `spawn_object_entity_from_record()` drives pickup cursor state from the object record's alternate inventory sprite path, while `run_harvester_main_loop()` uses that flag to select the native `Pick up the %s` prompt instead of `Operate the %s`.
+  - The native click path moves pickup targets into `INVENTORY` by changing the object record's owner, not by toggling visibility.
+  - `room_setup()` injects the global `EXIT_BM` and `EXIT_HS` objects whenever the room is entered in closeup mode, which explains the exit sign shown over rooms such as `PCDRWR`.
+- Patched the startup stub to mirror that native behavior.
+  - Startup pickup targets now use the pickup cursor, move into `INVENTORY` on click, and rebuild the current room from runtime state so items such as `PC_PEN` and `QUARTER` disappear from the closeup after pickup.
+  - Closeup rooms without an entrance now materialize the global exit sign/hotspot pair, and clicking that exit unwinds back to the parent room through the existing recursive room-loop restore path so the player returns to the coordinates they had before entering the closeup.
 
 ## Next Suggested Action
 
-1. Parse startup NPC and timer records from `HARVEST.SCR`, then add persistent runtime state for the remaining confirmed exit and interaction opcodes: `SET_NPC`, `SET_TIMER`, and `KILL_TIMER`.
-2. Confirm the native side effects of those opcodes in Ghidra before naming any new runtime structures or script helpers.
-3. Parse `REGION` records from `HARVEST.SCR` into the startup script layer.
-4. Mirror native startup transition handling for class `0x19` region entities.
+1. Confirm and implement the remaining native inventory transfer paths exposed in `HARVEST.SCR`, especially script-driven `ADD2INV` and `USEITEM` flows that go beyond the implicit pickup rule.
+2. Parse startup NPC and timer records from `HARVEST.SCR`, then add persistent runtime state for the remaining confirmed exit and interaction opcodes: `SET_NPC`, `SET_TIMER`, and `KILL_TIMER`.
+3. Confirm the native side effects of those opcodes in Ghidra before naming any new runtime structures or script helpers.
+4. Parse `REGION` records from `HARVEST.SCR` into the startup script layer.
+5. Mirror native startup transition handling for class `0x19` region entities.
   - Spawn enabled region hotspots from `room_setup` state.
   - Drive cursor sequence `6` over active regions.
   - Gate region activation on player overlap plus facing, following `check_player_region_interaction`.
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index a9c516cb804..201f7262f87 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -646,11 +646,16 @@ static void drawRoomInspectText(Graphics::Screen &screen, const StartupArt &art,
 }
 
 static int resolveRoomObjectCursorSequence(const StartupObjectRecord &object, StartupScript &startupScript) {
+	if (object.objectName.equalsIgnoreCase("EXIT_BM") || object.objectName.equalsIgnoreCase("EXIT_HS"))
+		return kCursorSequenceTransition;
+	if (startupScript.isPickupObject(object))
+		return kCursorSequencePickup;
+
 	StartupResolvedText inspectText;
+	if (object.operatable && startupScript.hasObjectInteraction(object))
+		return kCursorSequenceOperate;
 	if (!object.identShown && startupScript.resolveObjectInspectText(object, inspectText))
 		return kCursorSequenceExamine;
-	if (object.operatable)
-		return kCursorSequenceOperate;
 
 	if (startupScript.hasObjectInteraction(object))
 		return kCursorSequenceExamine;
@@ -1682,13 +1687,20 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 					}
 					break;
 				}
+				if (clickedObject->objectName.equalsIgnoreCase("EXIT_BM") ||
+					clickedObject->objectName.equalsIgnoreCase("EXIT_HS")) {
+					if (!runRoomExitCommands())
+						return Common::kReadingFailed;
+					return Common::kNoError;
+				}
 
 				StartupResolvedText resolvedInspectText;
 				const bool hasInspectText =
 					_engine.getStartupScript()->resolveObjectInspectText(*clickedObject, resolvedInspectText);
+				const bool isPickupObject = _engine.getStartupScript()->isPickupObject(*clickedObject);
 				const bool canShowInspectText = hasInspectText &&
 					resolveInspectTextboxBitmap(*art, resolvedInspectText);
-				if (!clickedObject->identShown && canShowInspectText) {
+				if (!isPickupObject && !clickedObject->identShown && canShowInspectText) {
 					inspectText = resolvedInspectText;
 					clickedObject->identShown = true;
 					_engine.getStartupScript()->markObjectIdentShown(*clickedObject);
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index a48191e5428..f95fc2f681a 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -34,6 +34,7 @@ namespace Harvester {
 static const char *const kStartupConfigPath = "CONFIG.INI";
 static const char *const kConfigSectionName = "harvester";
 static const char *const kDefaultTownScript = "HARVEST.SCR";
+static const char *const kInventoryOwnerName = "INVENTORY";
 static const byte kTownScriptXorKey = 0xaa;
 static const float kDefaultPaletteBrightness = 1.0f;
 static const float kDimmedPaletteBrightness = 0.6f;
@@ -517,8 +518,18 @@ bool StartupScript::executeRoomExitCommands(const Common::String &roomName,
 
 bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result) {
 	result = StartupInteractionResult();
+
+	if (isPickupObject(object)) {
+		if (StartupObjectRecord *runtimeObject = findRuntimeObject(object.currentOwnerOrRoom, object.objectName)) {
+			if (!runtimeObject->currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)) {
+				runtimeObject->currentOwnerOrRoom = kInventoryOwnerName;
+				runtimeObject->identShown = true;
+				result.mutatedRuntimeState = true;
+			}
+		}
+	}
 	if (object.actionTag.empty())
-		return false;
+		return result.mutatedRuntimeState;
 
 	executeCommandChain(object.actionTag, "interaction command", object.objectName, true,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.mutatedRuntimeState);
@@ -527,7 +538,15 @@ bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object,
 		result.mutatedRuntimeState || hasActionableCommandChain(object.actionTag);
 }
 
+bool StartupScript::isPickupObject(const StartupObjectRecord &object) const {
+	return object.operatable &&
+		!object.altSpritePath.empty() &&
+		!object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName);
+}
+
 bool StartupScript::hasObjectInteraction(const StartupObjectRecord &object) const {
+	if (isPickupObject(object))
+		return true;
 	if (object.actionTag.empty())
 		return false;
 
@@ -678,6 +697,17 @@ bool StartupScript::buildRuntimeRoomState(const StartupRoomRecord &room, const S
 		if (object.currentOwnerOrRoom.equalsIgnoreCase(room.roomName))
 			state.roomObjects.push_back(object);
 	}
+	if (!state.hasEntrance) {
+		static const char *const kCloseupExitObjects[] = { "EXIT_BM", "EXIT_HS" };
+		for (const char *name : kCloseupExitObjects) {
+			for (const StartupObjectRecord &object : _runtimeObjects) {
+				if (object.objectName.equalsIgnoreCase(name)) {
+					state.roomObjects.push_back(object);
+					break;
+				}
+			}
+		}
+	}
 	for (const StartupAnimRecord &anim : _runtimeAnimations) {
 		if (anim.roomName.equalsIgnoreCase(room.roomName))
 			state.roomAnimations.push_back(anim);
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 5af0f2a25ff..04d8393983e 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -199,6 +199,7 @@ public:
 		StartupRoomSetupState &state) const;
 	bool executeRoomExitCommands(const Common::String &roomName, Common::Array<StartupAudioCommand> &audioCommands);
 	bool resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result);
+	bool isPickupObject(const StartupObjectRecord &object) const;
 	bool hasObjectInteraction(const StartupObjectRecord &object) const;
 	void markObjectIdentShown(const StartupObjectRecord &object);
 	bool resolveObjectInspectText(const StartupObjectRecord &object, StartupResolvedText &text) const;


Commit: fe78b7dfbde6246894d74a88ffa1f86656e491ee
    https://github.com/scummvm/scummvm/commit/fe78b7dfbde6246894d74a88ffa1f86656e491ee
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:43+02:00

Commit Message:
HARVESTER: Add startup inventory useitem handling

Changed paths:
    TRACKER.md
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/TRACKER.md b/TRACKER.md
index 95cbe4d43f0..337905a1c86 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,17 +15,17 @@
 
 ## Last Confirmed Action
 
-- Confirmed in Ghidra how native startup closeups and pickup hotspots differ from ordinary object interactions.
-  - `spawn_object_entity_from_record()` drives pickup cursor state from the object record's alternate inventory sprite path, while `run_harvester_main_loop()` uses that flag to select the native `Pick up the %s` prompt instead of `Operate the %s`.
-  - The native click path moves pickup targets into `INVENTORY` by changing the object record's owner, not by toggling visibility.
-  - `room_setup()` injects the global `EXIT_BM` and `EXIT_HS` objects whenever the room is entered in closeup mode, which explains the exit sign shown over rooms such as `PCDRWR`.
+- Confirmed in Ghidra how native startup inventory transfers work beyond the implicit pickup rule.
+  - `run_inventory_screen()` lays out visible `INVENTORY` objects, tracks the selected inventory item state, cancels that state on secondary click, and applies the selected item against either inventory targets or room targets while the inventory panel is open.
+  - `add_object_to_inventory()` moves named objects into `INVENTORY` and marks them visible/active there, which is the native backend used by script-driven `ADD2INV`.
+  - `g_useitem_records` stores `item_name`, `owner_or_room`, `target_name`, and `action_tag`; matching `USEITEM` records dispatch room event actions, while clicking a pickup-class room target with an item selected still picks the target up first.
 - Patched the startup stub to mirror that native behavior.
-  - Startup pickup targets now use the pickup cursor, move into `INVENTORY` on click, and rebuild the current room from runtime state so items such as `PC_PEN` and `QUARTER` disappear from the closeup after pickup.
-  - Closeup rooms without an entrance now materialize the global exit sign/hotspot pair, and clicking that exit unwinds back to the parent room through the existing recursive room-loop restore path so the player returns to the coordinates they had before entering the closeup.
+  - `HARVEST.SCR` parsing now includes `USEITEM`, startup command execution now applies `ADD2INV`, and actionable-command probing treats `ADD2INV` as a real interaction for cursor selection.
+  - The startup inventory overlay opens on `I`, keeps the selected item inside the overlay loop, and routes clicks against inventory or room targets through the native pickup-first / `USEITEM` precedence so script-driven transfers can mutate the runtime room and inventory state.
 
 ## Next Suggested Action
 
-1. Confirm and implement the remaining native inventory transfer paths exposed in `HARVEST.SCR`, especially script-driven `ADD2INV` and `USEITEM` flows that go beyond the implicit pickup rule.
+1. Runtime-test the newly wired startup inventory flows against DOSBox, especially `PC_PEN` on `LODGE_APPLICATION` and `DOLLY` on `PC_CABNET`, and fix any prompt/cursor mismatches that still appear.
 2. Parse startup NPC and timer records from `HARVEST.SCR`, then add persistent runtime state for the remaining confirmed exit and interaction opcodes: `SET_NPC`, `SET_TIMER`, and `KILL_TIMER`.
 3. Confirm the native side effects of those opcodes in Ghidra before naming any new runtime structures or script helpers.
 4. Parse `REGION` records from `HARVEST.SCR` into the startup script layer.
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 201f7262f87..8cfea2ecb14 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -50,6 +50,10 @@ static const char *const kMenuSectionName = "menu";
 
 static const int kInventoryX = 64;
 static const int kInventoryY = 48;
+static const int kInventoryItemStartX = 73;
+static const int kInventoryItemStartY = 115;
+static const int kInventoryItemMaxRight = 564;
+static const int kInventoryItemSpacing = 5;
 static const int kLogoX = 160;
 static const int kLogoY = 0;
 static const int kQuickTipsOverlayX = 167;
@@ -132,6 +136,20 @@ struct StartupRoomHoverState {
 	int cursorSequence = kCursorSequenceNeutral;
 };
 
+struct StartupInventoryVisual {
+	StartupObjectRecord object;
+	IndexedBitmap bitmap;
+	Common::Rect bounds;
+	bool hasBitmap = false;
+};
+
+struct StartupInventoryOverlayState {
+	Common::Array<StartupInventoryVisual> items;
+	bool open = false;
+	Common::String selectedItemName;
+	Common::String promptText;
+};
+
 struct StartupRoomIdleAnimationState {
 	RuntimeEntity *entity = nullptr;
 	int restoreFacing = -1;
@@ -483,6 +501,126 @@ static Common::String resolveSceneObjectSpritePath(const StartupObjectRecord &ob
 	return object.spritePath;
 }
 
+static bool loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 12)
+		return false;
+
+	bitmap = IndexedBitmap();
+	bitmap.width = READ_LE_UINT32(data.data());
+	bitmap.height = READ_LE_UINT32(data.data() + 4);
+	const uint32 pixelCount = bitmap.width * bitmap.height;
+	if (bitmap.width == 0 || bitmap.height == 0 || data.size() < 12 + pixelCount)
+		return false;
+
+	bitmap.pixels.resize(pixelCount);
+	memcpy(bitmap.pixels.data(), data.data() + 12, pixelCount);
+	return true;
+}
+
+static bool isInventoryExitObject(const StartupObjectRecord &object) {
+	return object.objectName.equalsIgnoreCase("INV_EXIT");
+}
+
+static bool isInventoryStatusObject(const StartupObjectRecord &object) {
+	return object.objectName.hasPrefixIgnoreCase("INV_STAT");
+}
+
+static bool buildInventoryVisuals(StartupScript &startupScript, ResourceManager &resources,
+		Common::Array<StartupInventoryVisual> &items) {
+	items.clear();
+
+	Common::Array<StartupObjectRecord> inventoryObjects;
+	startupScript.getVisibleInventoryObjects(inventoryObjects);
+	int nextX = kInventoryItemStartX;
+	int nextY = kInventoryItemStartY;
+	int rowHeight = 0;
+
+	for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+		StartupInventoryVisual visual;
+		visual.object = inventoryObject;
+
+		if (isInventoryExitObject(inventoryObject)) {
+			visual.bounds = getHotspotBounds(inventoryObject);
+			items.push_back(visual);
+			continue;
+		}
+		if (isInventoryStatusObject(inventoryObject))
+			continue;
+
+		const Common::String spritePath = resolveSceneObjectSpritePath(inventoryObject);
+		if (!spritePath.empty() && loadBitmapResource(resources, spritePath, visual.bitmap)) {
+			visual.hasBitmap = true;
+			if (nextX + (int)visual.bitmap.width > kInventoryItemMaxRight) {
+				nextX = kInventoryItemStartX;
+				nextY += rowHeight + kInventoryItemSpacing;
+				rowHeight = 0;
+			}
+
+			visual.object.currentX = nextX;
+			visual.object.currentY = nextY;
+			visual.bounds = Common::Rect(nextX, nextY, nextX + visual.bitmap.width, nextY + visual.bitmap.height);
+			nextX += visual.bitmap.width + kInventoryItemSpacing;
+			rowHeight = MAX<int>(rowHeight, visual.bitmap.height);
+		} else {
+			visual.bounds = getHotspotBounds(inventoryObject);
+		}
+
+		items.push_back(visual);
+	}
+
+	return true;
+}
+
+static const StartupInventoryVisual *findInventoryVisualAtPoint(
+		const Common::Array<StartupInventoryVisual> &items, const Common::Point &point) {
+	for (int i = (int)items.size() - 1; i >= 0; --i) {
+		if (items[i].bounds.contains(point))
+			return &items[i];
+	}
+
+	return nullptr;
+}
+
+static Common::String buildUseItemPrompt(const Common::String &itemLabel, const Common::String &targetLabel) {
+	if (itemLabel.empty())
+		return Common::String();
+	if (targetLabel.empty())
+		return Common::String::format("Use %s on ...", itemLabel.c_str());
+
+	return Common::String::format("Use %s on %s", itemLabel.c_str(), targetLabel.c_str());
+}
+
+static Common::Rect getInventoryPanelBounds(const StartupArt &art) {
+	const IndexedBitmap &bitmap = art.getInventoryBitmap();
+	return Common::Rect(kInventoryX, kInventoryY,
+		kInventoryX + (int)bitmap.width, kInventoryY + (int)bitmap.height);
+}
+
+static void drawInventoryOverlay(Graphics::Screen &screen, const StartupArt &art, StartupScript &startupScript,
+		const Graphics::Font &font, const Common::Array<StartupInventoryVisual> &items,
+		const Common::String &selectedItemName, const Common::String &promptText) {
+	blitBitmap(screen, art.getInventoryBitmap(), kInventoryX, kInventoryY);
+
+	for (const StartupInventoryVisual &item : items) {
+		if (item.hasBitmap && item.bitmap.isValid())
+			blitBitmap(screen, item.bitmap, item.object.currentX, item.object.currentY);
+	}
+
+	Common::String overlayPrompt = promptText;
+	if (overlayPrompt.empty() && !selectedItemName.empty()) {
+		for (const StartupInventoryVisual &item : items) {
+			if (item.object.objectName.equalsIgnoreCase(selectedItemName)) {
+				overlayPrompt = buildUseItemPrompt(startupScript.resolveObjectLabel(item.object), Common::String());
+				break;
+			}
+		}
+	}
+
+	if (!overlayPrompt.empty())
+		drawShadowedString(screen, font, overlayPrompt, 0, 462, 640, kRoomPromptColor, Graphics::kTextAlignCenter);
+}
+
 static void logSceneObjectSelection(const char *decision, const char *source, const StartupObjectRecord &object,
 		const Common::String &detail = Common::String()) {
 	const Common::Rect hotspotBounds = getHotspotBounds(object);
@@ -1547,7 +1685,9 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 	bool moveRight = false;
 	bool moveUp = false;
 	bool moveDown = false;
+	StartupInventoryOverlayState inventoryState;
 	StartupRoomIdleAnimationState idleState;
+	bool needsRedraw = true;
 	auto resetIdleState = [&]() {
 		idleState = StartupRoomIdleAnimationState();
 		idleState.activityTick = getRuntimeClockTicks();
@@ -1555,6 +1695,67 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 		updatePlayerIdleTrigger(idleState);
 	};
 	resetIdleState();
+	auto refreshInventoryState = [&]() {
+		if (!buildInventoryVisuals(*_engine.getStartupScript(), *_engine.getResources(), inventoryState.items))
+			return false;
+
+		if (inventoryState.selectedItemName.empty())
+			return true;
+
+		for (const StartupInventoryVisual &item : inventoryState.items) {
+			if (item.object.objectName.equalsIgnoreCase(inventoryState.selectedItemName))
+				return true;
+		}
+
+		inventoryState.selectedItemName.clear();
+		inventoryState.promptText.clear();
+		return true;
+	};
+	auto resolveSelectedInventoryLabel = [&]() {
+		if (inventoryState.selectedItemName.empty())
+			return Common::String();
+
+		for (const StartupInventoryVisual &item : inventoryState.items) {
+			if (item.object.objectName.equalsIgnoreCase(inventoryState.selectedItemName))
+				return _engine.getStartupScript()->resolveObjectLabel(item.object);
+		}
+
+		Common::Array<StartupObjectRecord> inventoryObjects;
+		_engine.getStartupScript()->getVisibleInventoryObjects(inventoryObjects);
+		for (const StartupObjectRecord &item : inventoryObjects) {
+			if (item.objectName.equalsIgnoreCase(inventoryState.selectedItemName))
+				return _engine.getStartupScript()->resolveObjectLabel(item);
+		}
+
+		return normalizeHarvesterResourcePath(inventoryState.selectedItemName);
+	};
+	auto clearInventorySelection = [&]() {
+		if (inventoryState.selectedItemName.empty() && inventoryState.promptText.empty())
+			return false;
+
+		inventoryState.selectedItemName.clear();
+		inventoryState.promptText.clear();
+		return true;
+	};
+	auto closeInventoryOverlay = [&]() {
+		bool changed = clearInventorySelection();
+		if (inventoryState.open)
+			changed = true;
+		inventoryState.open = false;
+		return changed;
+	};
+	auto openInventoryOverlay = [&]() {
+		moveLeft = false;
+		moveRight = false;
+		moveUp = false;
+		moveDown = false;
+		playerState.hasMoveTarget = false;
+		playerState.turnActive = false;
+		playerState.turnTargetFacing = -1;
+		inventoryState.open = true;
+		inventoryState.promptText.clear();
+		return refreshInventoryState();
+	};
 	auto refreshCurrentScene = [&](bool preservePlayerPlacement) {
 		const Common::Array<StartupAudioCommand> entryAudioCommands = scene.state.audioCommands;
 		StartupRoomSetupState refreshedState;
@@ -1589,7 +1790,7 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 		playerState.turnTargetFacing = -1;
 		resetIdleState();
 		resetCursorAnimationSequence();
-		return true;
+		return refreshInventoryState();
 	};
 	auto runRoomExitCommands = [&]() {
 		Common::Array<StartupAudioCommand> exitAudioCommands;
@@ -1598,7 +1799,69 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 		executeStartupAudioCommands(exitAudioCommands);
 		return true;
 	};
-	bool needsRedraw = true;
+	auto handleInteractionResult = [&](const StartupInteractionResult &interaction) -> Common::Error {
+		playerState.hasMoveTarget = false;
+		playerState.turnActive = false;
+		playerState.turnTargetFacing = -1;
+
+		Common::String restoreMusicPath = _engine.getStartupMusicPath();
+		if (!interaction.musicPath.empty()) {
+			(void)_engine.playStartupMusic(interaction.musicPath);
+			restoreMusicPath = _engine.getStartupMusicPath();
+		}
+		executeStartupAudioCommands(interaction.audioCommands);
+
+		if (!interaction.nextRoomName.empty()) {
+			if (!runRoomExitCommands())
+				return Common::kReadingFailed;
+
+			Common::Error roomError = runRoomLoop(interaction.nextRoomName);
+			if (roomError.getCode() != Common::kReadingFailed &&
+				roomError.getCode() != Common::kNoError) {
+				return roomError;
+			}
+
+			if (!refreshCurrentScene(true))
+				return Common::kReadingFailed;
+
+			executeStartupAudioCommands(scene.state.audioCommands);
+			if (!restoreMusicPath.empty())
+				(void)_engine.playStartupMusic(restoreMusicPath);
+			else
+				_engine.stopStartupMusic();
+		} else if (interaction.mutatedRuntimeState) {
+			if (!refreshCurrentScene(true))
+				return Common::kReadingFailed;
+		}
+
+		return Common::kNoError;
+	};
+	auto handleInventoryTargetInteraction = [&](const StartupObjectRecord &target, bool preferPickup) -> Common::Error {
+		if (inventoryState.selectedItemName.empty())
+			return Common::kNoError;
+
+		StartupInteractionResult interaction;
+		bool handled = false;
+		if (preferPickup && _engine.getStartupScript()->isPickupObject(target))
+			handled = _engine.getStartupScript()->resolveObjectInteraction(target, interaction);
+		else
+			handled = _engine.getStartupScript()->resolveUseItemInteraction(
+				inventoryState.selectedItemName, target, interaction);
+		if (!handled)
+			return Common::kNoError;
+
+		clearInventorySelection();
+		Common::Error interactionError = handleInteractionResult(interaction);
+		if (interactionError.getCode() != Common::kNoError)
+			return interactionError;
+		if (!refreshInventoryState())
+			return Common::kReadingFailed;
+
+		needsRedraw = true;
+		return Common::kNoError;
+	};
+	if (!refreshInventoryState())
+		return Common::kReadingFailed;
 	Graphics::FrameLimiter limiter(g_system, 60);
 
 	if (shouldRunStartupRoomProbe())
@@ -1606,20 +1869,49 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
-			const bool suppressHover = showingInspectText || idleState.active || idleState.exiting;
-			const StartupRoomHoverState hoverState = suppressHover
+			const Common::Rect inventoryPanelBounds = getInventoryPanelBounds(*art);
+			const bool inventoryPanelContainsMouse = inventoryState.open && inventoryPanelBounds.contains(_mousePos);
+			const bool suppressHover = showingInspectText || idleState.active || idleState.exiting ||
+				(inventoryState.open && (inventoryPanelContainsMouse || inventoryState.selectedItemName.empty()));
+			StartupRoomHoverState hoverState = suppressHover
 				? StartupRoomHoverState()
 				: resolveRoomHoverState(_engine, scene.state, scene.sceneObjects, _mousePos);
+			Common::String promptText;
+			if (inventoryState.open) {
+				const StartupInventoryVisual *inventoryHover = findInventoryVisualAtPoint(inventoryState.items, _mousePos);
+				if (!inventoryState.selectedItemName.empty()) {
+					Common::String targetLabel;
+					if (inventoryHover && !isInventoryExitObject(inventoryHover->object) &&
+						!isInventoryStatusObject(inventoryHover->object)) {
+						targetLabel = _engine.getStartupScript()->resolveObjectLabel(inventoryHover->object);
+					} else if (!inventoryPanelContainsMouse && hoverState.object) {
+						targetLabel = _engine.getStartupScript()->resolveObjectLabel(*hoverState.object);
+					}
+					promptText = buildUseItemPrompt(resolveSelectedInventoryLabel(), targetLabel);
+				} else if (inventoryHover) {
+					promptText = _engine.getStartupScript()->resolveObjectLabel(inventoryHover->object);
+				}
+				inventoryState.promptText = promptText;
+				hoverState.cursorSequence = kCursorSequenceNeutral;
+			} else {
+				promptText = hoverState.promptText;
+			}
 			if (RuntimeEntity *cursor = runtimeEntities ? runtimeEntities->getCursorEntity() : nullptr) {
-				cursor->setAnimationSequence(suppressHover ? kCursorSequenceNeutral : hoverState.cursorSequence);
+				cursor->setAnimationSequence(
+					(showingInspectText || idleState.active || idleState.exiting || inventoryState.open)
+						? kCursorSequenceNeutral
+						: hoverState.cursorSequence);
 			}
 
 			drawRoomScene(_engine, *screen, scene, scene.targetPaletteBrightness);
+			if (inventoryState.open)
+				drawInventoryOverlay(*screen, *art, *_engine.getStartupScript(), *bodyFont,
+					inventoryState.items, inventoryState.selectedItemName, inventoryState.promptText);
 
 			if (showingInspectText) {
 				drawRoomInspectText(*screen, *art, *bodyFont, inspectText);
-			} else if (!hoverState.promptText.empty()) {
-				drawShadowedString(*screen, *bodyFont, hoverState.promptText,
+			} else if (!promptText.empty()) {
+				drawShadowedString(*screen, *bodyFont, promptText,
 					0, 462, 640, kRoomPromptColor, Graphics::kTextAlignCenter);
 			}
 
@@ -1647,6 +1939,11 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 						needsRedraw = true;
 					break;
 				}
+				if (inventoryState.open) {
+					if (clearInventorySelection() || closeInventoryOverlay())
+						needsRedraw = true;
+					break;
+				}
 				break;
 			case Common::EVENT_LBUTTONUP:
 				if (showingInspectText)
@@ -1670,6 +1967,60 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 					break;
 				}
 
+				const Common::Rect inventoryPanelBounds = getInventoryPanelBounds(*art);
+				if (inventoryState.open) {
+					const StartupInventoryVisual *inventoryHover =
+						findInventoryVisualAtPoint(inventoryState.items, _mousePos);
+					if (inventoryHover) {
+						if (isInventoryExitObject(inventoryHover->object)) {
+							if (closeInventoryOverlay())
+								needsRedraw = true;
+							break;
+						}
+
+						if (inventoryState.selectedItemName.empty()) {
+							inventoryState.selectedItemName = inventoryHover->object.objectName;
+							inventoryState.promptText = buildUseItemPrompt(
+								resolveSelectedInventoryLabel(), Common::String());
+							needsRedraw = true;
+							break;
+						}
+
+						if (!inventoryHover->object.objectName.equalsIgnoreCase(inventoryState.selectedItemName)) {
+							Common::Error interactionError =
+								handleInventoryTargetInteraction(inventoryHover->object, false);
+							if (interactionError.getCode() != Common::kNoError)
+								return interactionError;
+						}
+						needsRedraw = true;
+						break;
+					}
+
+					if (!inventoryState.selectedItemName.empty()) {
+						const StartupRoomHoverState useHoverState = resolveRoomHoverState(
+							_engine, scene.state, scene.sceneObjects, _mousePos);
+						const StartupObjectRecord *roomTarget = useHoverState.object
+							? findSceneObjectByName(scene.sceneObjects, useHoverState.object->objectName)
+							: nullptr;
+						if (roomTarget) {
+							Common::Error interactionError =
+								handleInventoryTargetInteraction(*roomTarget, true);
+							if (interactionError.getCode() != Common::kNoError)
+								return interactionError;
+							needsRedraw = true;
+							break;
+						}
+					}
+
+					if (!inventoryPanelBounds.contains(_mousePos) && !inventoryState.selectedItemName.empty()) {
+						needsRedraw = true;
+						break;
+					}
+					if (!inventoryPanelBounds.contains(_mousePos) && closeInventoryOverlay())
+						needsRedraw = true;
+					break;
+				}
+
 				const StartupRoomHoverState hoverState = resolveRoomHoverState(
 					_engine, scene.state, scene.sceneObjects, _mousePos);
 				debugC(1, kDebugScene,
@@ -1727,40 +2078,10 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 					break;
 				}
 
-				playerState.hasMoveTarget = false;
-				playerState.turnActive = false;
-				playerState.turnTargetFacing = -1;
-				Common::String restoreMusicPath = _engine.getStartupMusicPath();
-				if (!interaction.musicPath.empty()) {
-					(void)_engine.playStartupMusic(interaction.musicPath);
-					restoreMusicPath = _engine.getStartupMusicPath();
-				}
-				executeStartupAudioCommands(interaction.audioCommands);
+				Common::Error interactionError = handleInteractionResult(interaction);
+				if (interactionError.getCode() != Common::kNoError)
+					return interactionError;
 				needsRedraw = true;
-
-				if (!interaction.nextRoomName.empty()) {
-					if (!runRoomExitCommands())
-						return Common::kReadingFailed;
-
-					Common::Error roomError = runRoomLoop(interaction.nextRoomName);
-					if (roomError.getCode() != Common::kReadingFailed &&
-						roomError.getCode() != Common::kNoError) {
-						return roomError;
-					}
-
-					if (!refreshCurrentScene(true))
-						return Common::kReadingFailed;
-
-					executeStartupAudioCommands(scene.state.audioCommands);
-					if (!restoreMusicPath.empty())
-						(void)_engine.playStartupMusic(restoreMusicPath);
-					else
-						_engine.stopStartupMusic();
-				} else if (interaction.mutatedRuntimeState) {
-					if (!refreshCurrentScene(true))
-						return Common::kReadingFailed;
-					needsRedraw = true;
-				}
 				break;
 			}
 			case Common::EVENT_KEYDOWN:
@@ -1774,6 +2095,26 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 					break;
 				}
 
+				notePlayerActivity(idleState);
+				if (idleState.active || idleState.exiting) {
+					if (requestPlayerIdleAnimationExit(scene.state, playerState, idleState))
+						needsRedraw = true;
+					break;
+				}
+
+				if (inventoryState.open) {
+					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+						if (clearInventorySelection() || closeInventoryOverlay())
+							needsRedraw = true;
+					} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+							event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
+							event.kbd.keycode == Common::KEYCODE_i) {
+						if (closeInventoryOverlay())
+							needsRedraw = true;
+					}
+					break;
+				}
+
 				if (event.kbd.keycode == Common::KEYCODE_LEFT)
 					moveLeft = true;
 				else if (event.kbd.keycode == Common::KEYCODE_RIGHT)
@@ -1782,11 +2123,10 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 					moveUp = true;
 				else if (event.kbd.keycode == Common::KEYCODE_DOWN)
 					moveDown = true;
-
-				notePlayerActivity(idleState);
-				if (idleState.active || idleState.exiting) {
-					if (requestPlayerIdleAnimationExit(scene.state, playerState, idleState))
-						needsRedraw = true;
+				else if (event.kbd.keycode == Common::KEYCODE_i) {
+					if (!openInventoryOverlay())
+						return Common::kReadingFailed;
+					needsRedraw = true;
 					break;
 				}
 
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index f95fc2f681a..15fe531ffcf 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -156,6 +156,7 @@ bool StartupScript::load(ResourceManager &resources) {
 	_flags.clear();
 	_commands.clear();
 	_texts.clear();
+	_useItems.clear();
 	_quickTipsEnabled = true;
 
 	loadConfig(resources);
@@ -218,6 +219,7 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 	_flags.clear();
 	_commands.clear();
 	_texts.clear();
+	_useItems.clear();
 
 	auto parseLine = [&](const Common::String &rawLine) {
 		const Common::String line = trimAsciiLine(rawLine);
@@ -232,7 +234,7 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 		uint tagIndex = tokens.size();
 		for (uint i = 0; i < tokens.size(); ++i) {
 			if (tokens[i] == "ANIM" || tokens[i] == "ENTRANCE" || tokens[i] == "ROOM" || tokens[i] == "OBJECT" ||
-				tokens[i] == "FLAG" || tokens[i] == "COMMAND" || tokens[i] == "TEXT") {
+				tokens[i] == "FLAG" || tokens[i] == "COMMAND" || tokens[i] == "TEXT" || tokens[i] == "USEITEM") {
 				tagIndex = i;
 				break;
 			}
@@ -296,6 +298,20 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 			return;
 		}
 
+		if (tag == "USEITEM") {
+			if (tokens.size() < tagIndex + 5)
+				return;
+
+			StartupUseItemRecord useItem;
+			useItem.itemName = tokens[tagIndex + 1];
+			useItem.ownerOrRoom = tokens[tagIndex + 2];
+			useItem.targetName = tokens[tagIndex + 3];
+			useItem.actionTag = tokens[tagIndex + 4];
+			if (!useItem.itemName.empty() && !useItem.targetName.empty())
+				_useItems.push_back(useItem);
+			return;
+		}
+
 		if (tag == "ENTRANCE") {
 			if (tokens.size() < tagIndex + 4)
 				return;
@@ -423,9 +439,9 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 
 	parseLine(line);
 
-	debug(1, "Harvester: parsed %u entrances, %u rooms, %u objects, %u anims, %u flags, %u commands, %u texts from '%s'",
+	debug(1, "Harvester: parsed %u entrances, %u rooms, %u objects, %u anims, %u flags, %u commands, %u texts, %u useitems from '%s'",
 		(uint)_entrances.size(), (uint)_rooms.size(), (uint)_objects.size(), (uint)_animations.size(),
-		(uint)_flags.size(), (uint)_commands.size(), (uint)_texts.size(), _path.c_str());
+		(uint)_flags.size(), (uint)_commands.size(), (uint)_texts.size(), (uint)_useItems.size(), _path.c_str());
 }
 
 bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
@@ -538,6 +554,20 @@ bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object,
 		result.mutatedRuntimeState || hasActionableCommandChain(object.actionTag);
 }
 
+bool StartupScript::resolveUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target,
+		StartupInteractionResult &result) {
+	result = StartupInteractionResult();
+
+	const StartupUseItemRecord *useItem = findUseItemRecord(itemName, target);
+	if (!useItem)
+		return false;
+
+	executeCommandChain(useItem->actionTag, "useitem command",
+		Common::String::format("%s -> %s", itemName.c_str(), target.objectName.c_str()), true,
+		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.mutatedRuntimeState);
+	return true;
+}
+
 bool StartupScript::isPickupObject(const StartupObjectRecord &object) const {
 	return object.operatable &&
 		!object.altSpritePath.empty() &&
@@ -553,6 +583,19 @@ bool StartupScript::hasObjectInteraction(const StartupObjectRecord &object) cons
 	return hasActionableCommandChain(object.actionTag);
 }
 
+bool StartupScript::hasUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target) const {
+	return findUseItemRecord(itemName, target) != nullptr;
+}
+
+void StartupScript::getVisibleInventoryObjects(Common::Array<StartupObjectRecord> &objects) const {
+	objects.clear();
+
+	for (const StartupObjectRecord &object : _runtimeObjects) {
+		if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName) && object.visible)
+			objects.push_back(object);
+	}
+}
+
 void StartupScript::markObjectIdentShown(const StartupObjectRecord &object) {
 	if (StartupObjectRecord *runtimeObject = findRuntimeObject(object.currentOwnerOrRoom, object.objectName))
 		runtimeObject->identShown = true;
@@ -570,6 +613,28 @@ const StartupEntranceRecord *StartupScript::findEntranceRecord(const Common::Str
 	return nullptr;
 }
 
+const StartupUseItemRecord *StartupScript::findUseItemRecord(const Common::String &itemName,
+		const StartupObjectRecord &target) const {
+	if (itemName.empty() || target.objectName.empty())
+		return nullptr;
+
+	for (const StartupUseItemRecord &useItem : _useItems) {
+		if (!useItem.itemName.equalsIgnoreCase(itemName) ||
+			!useItem.targetName.equalsIgnoreCase(target.objectName)) {
+			continue;
+		}
+
+		if (!useItem.ownerOrRoom.empty() &&
+			!useItem.ownerOrRoom.equalsIgnoreCase(target.currentOwnerOrRoom)) {
+			continue;
+		}
+
+		return &useItem;
+	}
+
+	return nullptr;
+}
+
 const StartupRoomRecord *StartupScript::findRoomRecord(const Common::String &roomName) const {
 	if (roomName.empty())
 		return nullptr;
@@ -655,6 +720,20 @@ StartupAnimRecord *StartupScript::findRuntimeAnim(const Common::String &animName
 	return nullptr;
 }
 
+bool StartupScript::addRuntimeObjectToInventory(const Common::String &objectName) {
+	StartupObjectRecord *runtimeObject = findRuntimeObject(Common::String(), objectName);
+	if (!runtimeObject)
+		return false;
+
+	const bool changed = !runtimeObject->currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName) ||
+		!runtimeObject->visible || !runtimeObject->runtimeVisible || !runtimeObject->identShown;
+	runtimeObject->currentOwnerOrRoom = kInventoryOwnerName;
+	runtimeObject->visible = true;
+	runtimeObject->runtimeVisible = true;
+	runtimeObject->identShown = true;
+	return changed;
+}
+
 bool StartupScript::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupEntranceRecord *entrance,
 		StartupRoomSetupState &state) const {
 	state = StartupRoomSetupState();
@@ -833,6 +912,12 @@ void StartupScript::executeCommandChain(const Common::String &initialTag, const
 			continue;
 		}
 
+		if (command->opcodeName.equalsIgnoreCase("ADD2INV")) {
+			noteMutation(addRuntimeObjectToInventory(command->arg1));
+			currentTag = command->arg4;
+			continue;
+		}
+
 		if (command->opcodeName.equalsIgnoreCase("SET_ANIM")) {
 			StartupAnimRecord *runtimeAnim = findRuntimeAnim(command->arg1);
 			if (!runtimeAnim) {
@@ -892,6 +977,7 @@ bool StartupScript::hasActionableCommandChain(const Common::String &initialTag)
 			command->opcodeName.equalsIgnoreCase("SPOOL_MUSIC") ||
 			command->opcodeName.equalsIgnoreCase("ADD") ||
 			command->opcodeName.equalsIgnoreCase("DELETE") ||
+			command->opcodeName.equalsIgnoreCase("ADD2INV") ||
 			command->opcodeName.equalsIgnoreCase("SET_ANIM") ||
 			command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
@@ -933,6 +1019,12 @@ bool StartupScript::resolveObjectInspectText(const StartupObjectRecord &object,
 }
 
 Common::String StartupScript::resolveObjectLabel(const StartupObjectRecord &object) const {
+	if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)) {
+		const StartupTextRecord *inventoryText = findTextRecord(object.inventoryTextKey);
+		if (inventoryText && !inventoryText->value.empty())
+			return inventoryText->value;
+	}
+
 	if (!object.interactionLabel.empty() && !object.interactionLabel.equalsIgnoreCase("NULL_ID"))
 		return normalizeInteractionLabel(object.interactionLabel);
 
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 04d8393983e..f2301055478 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -125,6 +125,13 @@ struct StartupTextRecord {
 	Common::String value;
 };
 
+struct StartupUseItemRecord {
+	Common::String itemName;
+	Common::String ownerOrRoom;
+	Common::String targetName;
+	Common::String actionTag;
+};
+
 enum StartupAudioCommandType {
 	kStartupAudioCommandStartWav,
 	kStartupAudioCommandStartSingleWav,
@@ -190,6 +197,7 @@ public:
 	const Common::Array<StartupFlagRecord> &getFlags() const { return _flags; }
 	const Common::Array<StartupCommandRecord> &getCommands() const { return _commands; }
 	const Common::Array<StartupTextRecord> &getTexts() const { return _texts; }
+	const Common::Array<StartupUseItemRecord> &getUseItems() const { return _useItems; }
 	bool isQuickTipsEnabled() const { return _quickTipsEnabled; }
 	void setQuickTipsEnabled(bool enabled) { _quickTipsEnabled = enabled; }
 	void resetRuntimeState();
@@ -199,8 +207,12 @@ public:
 		StartupRoomSetupState &state) const;
 	bool executeRoomExitCommands(const Common::String &roomName, Common::Array<StartupAudioCommand> &audioCommands);
 	bool resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result);
+	bool resolveUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target,
+		StartupInteractionResult &result);
 	bool isPickupObject(const StartupObjectRecord &object) const;
 	bool hasObjectInteraction(const StartupObjectRecord &object) const;
+	bool hasUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target) const;
+	void getVisibleInventoryObjects(Common::Array<StartupObjectRecord> &objects) const;
 	void markObjectIdentShown(const StartupObjectRecord &object);
 	bool resolveObjectInspectText(const StartupObjectRecord &object, StartupResolvedText &text) const;
 	Common::String resolveObjectLabel(const StartupObjectRecord &object) const;
@@ -214,10 +226,12 @@ private:
 	const StartupRoomRecord *findRoomRecord(const Common::String &roomName) const;
 	const StartupCommandRecord *findCommandRecord(const Common::String &tag) const;
 	const StartupTextRecord *findTextRecord(const Common::String &key) const;
+	const StartupUseItemRecord *findUseItemRecord(const Common::String &itemName, const StartupObjectRecord &target) const;
 	const StartupFlagRecord *findRuntimeFlag(const Common::String &flagName) const;
 	StartupFlagRecord *findRuntimeFlag(const Common::String &flagName);
 	StartupObjectRecord *findRuntimeObject(const Common::String &ownerOrRoom, const Common::String &objectName);
 	StartupAnimRecord *findRuntimeAnim(const Common::String &animName);
+	bool addRuntimeObjectToInventory(const Common::String &objectName);
 	bool buildRuntimeRoomState(const StartupRoomRecord &room, const StartupEntranceRecord *entrance,
 		StartupRoomSetupState &state) const;
 	void executeCommandChain(const Common::String &initialTag, const char *contextLabel,
@@ -235,6 +249,7 @@ private:
 	Common::Array<StartupFlagRecord> _flags;
 	Common::Array<StartupCommandRecord> _commands;
 	Common::Array<StartupTextRecord> _texts;
+	Common::Array<StartupUseItemRecord> _useItems;
 	Common::Array<StartupFlagRecord> _runtimeFlags;
 	Common::Array<StartupObjectRecord> _runtimeObjects;
 	Common::Array<StartupAnimRecord> _runtimeAnimations;


Commit: 5b29be4aa06316291aa64d5aea9f3a53732ac6ef
    https://github.com/scummvm/scummvm/commit/5b29be4aa06316291aa64d5aea9f3a53732ac6ef
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:43+02:00

Commit Message:
HARVESTER: Restore startup menu entry points

Changed paths:
    TRACKER.md
    engines/harvester/runtime_entity.h
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_flow.h


diff --git a/TRACKER.md b/TRACKER.md
index 337905a1c86..1ebea1cfecc 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,21 +15,22 @@
 
 ## Last Confirmed Action
 
-- Confirmed in Ghidra how native startup inventory transfers work beyond the implicit pickup rule.
-  - `run_inventory_screen()` lays out visible `INVENTORY` objects, tracks the selected inventory item state, cancels that state on secondary click, and applies the selected item against either inventory targets or room targets while the inventory panel is open.
-  - `add_object_to_inventory()` moves named objects into `INVENTORY` and marks them visible/active there, which is the native backend used by script-driven `ADD2INV`.
-  - `g_useitem_records` stores `item_name`, `owner_or_room`, `target_name`, and `action_tag`; matching `USEITEM` records dispatch room event actions, while clicking a pickup-class room target with an item selected still picks the target up first.
+- Confirmed in Ghidra how the native startup room loop reaches both the game menu and the inventory screen.
+  - `run_harvester_main_loop()` routes `g_last_keyboard_scancode == 1` (`Esc`) into `run_main_menu()`, not a room-exit path, and `run_main_menu()` reads the same `main_menu_1..6` entries from `MENU.INI` that the startup stub already loads.
+  - The same loop routes `g_last_keyboard_scancode == 0x17` (`I`) into `run_inventory_screen()`, and it also calls `run_inventory_screen()` when the clicked entity is class `5` and the player avatar animation is idle/complete.
+  - In the native hover scan, entity class `5` uses cursor sequence `1`, which matches a player-avatar hotspot rather than a walk target or ordinary room object.
 - Patched the startup stub to mirror that native behavior.
-  - `HARVEST.SCR` parsing now includes `USEITEM`, startup command execution now applies `ADD2INV`, and actionable-command probing treats `ADD2INV` as a real interaction for cursor selection.
-  - The startup inventory overlay opens on `I`, keeps the selected item inside the overlay loop, and routes clicks against inventory or room targets through the native pickup-first / `USEITEM` precedence so script-driven transfers can mutate the runtime room and inventory state.
+  - The startup player actor now has a dedicated hit-testable player class so clicking directly on the avatar opens the inventory overlay only when the actor is idle, matching the native class-`5` gate.
+  - `Esc` in the startup room loop now opens a dedicated room-menu stub backed by the existing `MENU.INI` entries instead of incorrectly unwinding the room loop as if it were a closeup exit.
 
 ## Next Suggested Action
 
-1. Runtime-test the newly wired startup inventory flows against DOSBox, especially `PC_PEN` on `LODGE_APPLICATION` and `DOLLY` on `PC_CABNET`, and fix any prompt/cursor mismatches that still appear.
-2. Parse startup NPC and timer records from `HARVEST.SCR`, then add persistent runtime state for the remaining confirmed exit and interaction opcodes: `SET_NPC`, `SET_TIMER`, and `KILL_TIMER`.
-3. Confirm the native side effects of those opcodes in Ghidra before naming any new runtime structures or script helpers.
-4. Parse `REGION` records from `HARVEST.SCR` into the startup script layer.
-5. Mirror native startup transition handling for class `0x19` region entities.
+1. Runtime-test the startup room menu and player-click inventory entry against DOSBox, and adjust prompt/cursor behavior if the player hotspot needs a native text label rather than just the cursor change.
+2. Flesh out the room-menu stub now that `Esc` reaches the correct native entry point, especially the per-item behaviors inside `run_main_menu()` such as resume/save/load/options/quit gating.
+3. Parse startup NPC and timer records from `HARVEST.SCR`, then add persistent runtime state for the remaining confirmed exit and interaction opcodes: `SET_NPC`, `SET_TIMER`, and `KILL_TIMER`.
+4. Confirm the native side effects of those opcodes in Ghidra before naming any new runtime structures or script helpers.
+5. Parse `REGION` records from `HARVEST.SCR` into the startup script layer.
+6. Mirror native startup transition handling for class `0x19` region entities.
   - Spawn enabled region hotspots from `room_setup` state.
   - Drive cursor sequence `6` over active regions.
   - Gate region activation on player overlap plus facing, following `check_player_region_interaction`.
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index 177794ba3de..ad71fe2f550 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -40,6 +40,7 @@ enum RuntimeEntityClass {
 	kRuntimeEntityClassAnimation = 1,
 	kRuntimeEntityClassCursor = 2,
 	kRuntimeEntityClassBackground = 3,
+	kRuntimeEntityClassPlayer = 5,
 	kRuntimeEntityClassRectHotspot = 0x15,
 	kRuntimeEntityClassDisabledHotspot = 0x16,
 	kRuntimeEntityClassRectHotspot18 = 0x18,
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 8cfea2ecb14..d1fdda306df 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -132,6 +132,7 @@ struct StartupRoomPlayerState {
 
 struct StartupRoomHoverState {
 	const StartupObjectRecord *object = nullptr;
+	const RuntimeEntity *playerEntity = nullptr;
 	Common::String promptText;
 	int cursorSequence = kCursorSequenceNeutral;
 };
@@ -756,6 +757,18 @@ static const StartupObjectRecord *findRoomObjectAtPoint(HarvesterEngine &engine,
 	return findSceneObjectByName(sceneObjects, entity->getName());
 }
 
+static const RuntimeEntity *findRoomPlayerAtPoint(HarvesterEngine &engine, const Common::Point &point) {
+	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
+	if (!runtimeEntities)
+		return nullptr;
+
+	const RuntimeEntity *entity = runtimeEntities->findTopSceneEntityAt(point);
+	if (!entity || entity->getClassId() != kRuntimeEntityClassPlayer)
+		return nullptr;
+
+	return entity;
+}
+
 static const IndexedBitmap *resolveInspectTextboxBitmap(const StartupArt &art, const StartupResolvedText &text) {
 	if (text.boxName.equalsIgnoreCase("BOX1"))
 		return art.getTextboxBitmap(0);
@@ -824,10 +837,15 @@ static Common::String buildRoomObjectPrompt(const StartupObjectRecord &object, S
 static StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const StartupRoomSetupState &state,
 		const Common::Array<StartupObjectRecord> &sceneObjects, const Common::Point &mousePos) {
 	StartupRoomHoverState hoverState;
+	if (const RuntimeEntity *playerEntity = findRoomPlayerAtPoint(engine, mousePos)) {
+		hoverState.playerEntity = playerEntity;
+		hoverState.cursorSequence = kCursorSequenceExamine;
+		return hoverState;
+	}
+
 	StartupScript *startupScript = engine.getStartupScript();
 	if (!startupScript)
 		return hoverState;
-
 	hoverState.object = findRoomObjectAtPoint(engine, sceneObjects, mousePos);
 	if (hoverState.object) {
 		hoverState.cursorSequence = resolveRoomObjectCursorSequence(*hoverState.object, *startupScript);
@@ -1633,6 +1651,85 @@ Common::Error StartupFlow::runMainMenuStub() {
 	return Common::kNoError;
 }
 
+Common::Error StartupFlow::runRoomMenuStub() {
+	Graphics::FrameLimiter limiter(g_system, 60);
+	Common::String statusMessage =
+		"Native Esc enters run_main_menu(); room-menu actions still need a dedicated implementation.";
+	int selectedItem = _menuItems.empty() ? -1 : 0;
+	bool needsRedraw = true;
+	resetCursorAnimationSequence();
+
+	while (!_engine.shouldQuit()) {
+		if (needsRedraw) {
+			renderRoomMenuStub(selectedItem, statusMessage);
+			needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (handleSystemEvent(event, result))
+				return result;
+
+			switch (event.type) {
+			case Common::EVENT_MOUSEMOVE: {
+				const int hoveredItem = getMenuItemAt(_mousePos);
+				if (hoveredItem != -1 && hoveredItem != selectedItem) {
+					selectedItem = hoveredItem;
+					needsRedraw = true;
+				}
+				break;
+			}
+			case Common::EVENT_KEYDOWN:
+				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+					return Common::kNoError;
+
+				if (_menuItems.empty())
+					break;
+
+				if (event.kbd.keycode == Common::KEYCODE_UP) {
+					selectedItem = (selectedItem + _menuItems.size() - 1) % _menuItems.size();
+					needsRedraw = true;
+				} else if (event.kbd.keycode == Common::KEYCODE_DOWN) {
+					selectedItem = (selectedItem + 1) % _menuItems.size();
+					needsRedraw = true;
+				} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+						event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
+					statusMessage = Common::String::format(
+						"%s is still stubbed here; Esc resumes gameplay.",
+						_menuItems[selectedItem].c_str());
+					needsRedraw = true;
+				}
+				break;
+			case Common::EVENT_LBUTTONDOWN:
+				if (_menuItems.empty())
+					break;
+
+				selectedItem = getMenuItemAt(_mousePos);
+				if (selectedItem == -1)
+					break;
+
+				statusMessage = Common::String::format(
+					"%s is still stubbed here; Esc resumes gameplay.",
+					_menuItems[selectedItem].c_str());
+				needsRedraw = true;
+				break;
+			default:
+				break;
+			}
+		}
+
+		RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+		if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
+			needsRedraw = true;
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
 Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 	StartupRoomSetupState state;
 	if (!_engine.getStartupScript()->resolveRoomSetupState(entranceName, state, *_engine.getResources()))
@@ -2028,6 +2125,18 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 					scene.state.roomName.c_str(), _mousePos.x, _mousePos.y,
 					hoverState.object ? hoverState.object->objectName.c_str() : "",
 					hoverState.cursorSequence, hoverState.promptText.c_str());
+				if (hoverState.playerEntity) {
+					if (!playerState.entity || hoverState.playerEntity != playerState.entity ||
+						idleState.active || idleState.exiting ||
+						playerState.hasMoveTarget || playerState.turnActive ||
+						playerState.entity->getAnimationRate() != 0) {
+						break;
+					}
+					if (!openInventoryOverlay())
+						return Common::kReadingFailed;
+					needsRedraw = true;
+					break;
+				}
 				StartupObjectRecord *clickedObject = hoverState.object
 					? findSceneObjectByName(scene.sceneObjects, hoverState.object->objectName)
 					: nullptr;
@@ -2128,11 +2237,25 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 						return Common::kReadingFailed;
 					needsRedraw = true;
 					break;
+				} else if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					moveLeft = false;
+					moveRight = false;
+					moveUp = false;
+					moveDown = false;
+					playerState.hasMoveTarget = false;
+					playerState.turnActive = false;
+					playerState.turnTargetFacing = -1;
+					Common::Error menuError = runRoomMenuStub();
+					if (menuError.getCode() != Common::kNoError)
+						return menuError;
+					resetCursorAnimationSequence();
+					resetIdleState();
+					needsRedraw = true;
+					break;
 				}
 
 				if (event.kbd.keycode == Common::KEYCODE_RETURN ||
-					event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
-					event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
 					if (!runRoomExitCommands())
 						return Common::kReadingFailed;
 					return Common::kNoError;
@@ -2264,6 +2387,8 @@ bool StartupFlow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 		if (!player) {
 			debug(1, "Harvester: unable to spawn startup player actor from '%s'", kPlayerActorResourcePath);
 		} else {
+			player->setClassId(kRuntimeEntityClassPlayer);
+			player->setHitTestMode(kRuntimeEntityHitTestOpaquePixels);
 			if (!applyStartupActorPlacement(state, *player)) {
 				debug(1, "Harvester: unable to apply startup player placement for entrance '%s'",
 					state.entranceName.c_str());
@@ -2393,6 +2518,45 @@ void StartupFlow::renderMainMenuStub(int selectedItem, const Common::String &sta
 	screen->update();
 }
 
+void StartupFlow::renderRoomMenuStub(int selectedItem, const Common::String &statusMessage) const {
+	Graphics::Screen *screen = _engine.getScreen();
+	const StartupArt *art = _engine.getStartupArt();
+	const Graphics::Font *titleFont = FontMan.getFontByUsage(Graphics::FontManager::kBigGUIFont);
+	const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	if (!screen || !art || !titleFont || !bodyFont)
+		return;
+
+	screen->setPalette(art->getWaitPalette());
+	screen->fillRect(screen->getBounds(), 0);
+	blitBitmap(*screen, art->getInventoryBitmap(), kInventoryX, kInventoryY);
+	blitBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
+
+	const Common::Rect panel(96, 96, 544, 432);
+	screen->fillRect(panel, kPanelFillColor);
+
+	drawShadowedString(*screen, *titleFont, "Game Menu Stub", panel.left, 110, panel.width(),
+		kTextColorNormal, Graphics::kTextAlignCenter);
+
+	for (uint i = 0; i < _menuItems.size(); ++i) {
+		const int itemY = kMenuStartY + (int)i * kMenuLineSpacing;
+		const byte color = ((int)i == selectedItem) ? kTextColorHover : kTextColorNormal;
+		drawShadowedString(*screen, *bodyFont, _menuItems[i], panel.left, itemY, panel.width(), color,
+			Graphics::kTextAlignCenter);
+	}
+
+	drawShadowedString(*screen, *bodyFont, "Native Esc routes through run_main_menu().", panel.left, 330,
+		panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
+	drawWrappedShadowedText(*screen, *bodyFont, statusMessage, panel.left + 24, 360, panel.width() - 48,
+		kTextColorNormal);
+	drawShadowedString(*screen, *bodyFont, "Use mouse or arrow keys. Esc resumes gameplay.",
+		panel.left, 404, panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
+
+	if (_engine.getRuntimeEntities())
+		_engine.getRuntimeEntities()->drawCursor(*screen);
+	screen->makeAllDirty();
+	screen->update();
+}
+
 bool StartupFlow::handleSystemEvent(const Common::Event &event, Common::Error &result) {
 	switch (event.type) {
 	case Common::EVENT_QUIT:
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/startup_flow.h
index aa9d129234c..d85059307e0 100644
--- a/engines/harvester/startup_flow.h
+++ b/engines/harvester/startup_flow.h
@@ -48,6 +48,7 @@ private:
 	bool loadMenuItems();
 	Common::Error runQuickTips();
 	Common::Error runMainMenuStub();
+	Common::Error runRoomMenuStub();
 	Common::Error runRoomLoop(const Common::String &entranceName);
 	bool ensureCursorEntity();
 	bool populateRoomSceneEntities(const StartupRoomSetupState &state,
@@ -60,6 +61,7 @@ private:
 	void resetCursorAnimationSequence();
 	bool tickRuntimeEntities();
 	void renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const;
+	void renderRoomMenuStub(int selectedItem, const Common::String &statusMessage) const;
 	bool handleSystemEvent(const Common::Event &event, Common::Error &result);
 	int getMenuItemAt(const Common::Point &mousePos) const;
 


Commit: c2349c8874c1453c82812bf3d8c08e60a51d0048
    https://github.com/scummvm/scummvm/commit/c2349c8874c1453c82812bf3d8c08e60a51d0048
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:44+02:00

Commit Message:
HARVESTER: Gate startup pickup on initial ident

Changed paths:
    TRACKER.md
    engines/harvester/startup_flow.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 1ebea1cfecc..acd1cf18363 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,22 +15,23 @@
 
 ## Last Confirmed Action
 
-- Confirmed in Ghidra how the native startup room loop reaches both the game menu and the inventory screen.
-  - `run_harvester_main_loop()` routes `g_last_keyboard_scancode == 1` (`Esc`) into `run_main_menu()`, not a room-exit path, and `run_main_menu()` reads the same `main_menu_1..6` entries from `MENU.INI` that the startup stub already loads.
-  - The same loop routes `g_last_keyboard_scancode == 0x17` (`I`) into `run_inventory_screen()`, and it also calls `run_inventory_screen()` when the clicked entity is class `5` and the player avatar animation is idle/complete.
-  - In the native hover scan, entity class `5` uses cursor sequence `1`, which matches a player-avatar hotspot rather than a walk target or ordinary room object.
+- Confirmed in Ghidra how native startup pickup objects are gated behind a first-pass examine click.
+  - `spawn_object_entity_from_record()` copies the object record's alternate inventory-sprite presence into entity byte `+9`, the operatable flag into entity byte `+0xb`, and the persistent identified-state byte at object offset `+0x4f` into entity byte `+10`.
+  - In `run_harvester_main_loop()`, room objects with pickup or operate capability stay on cursor sequence `1` while entity byte `+10` is clear; once it is set, the same hover path switches to cursor sequence `5` for pickup-capable objects and cursor sequence `4` for non-pickup operatable objects.
+  - Clicking that initial examine cursor calls `show_target_ident_text()`, then writes entity byte `+10 = 1` and persists object byte `+0x4f = 1`, so the next hover/click uses the unlocked pickup or operate path even if no IDENT panel was shown.
 - Patched the startup stub to mirror that native behavior.
-  - The startup player actor now has a dedicated hit-testable player class so clicking directly on the avatar opens the inventory overlay only when the actor is idle, matching the native class-`5` gate.
-  - `Esc` in the startup room loop now opens a dedicated room-menu stub backed by the existing `MENU.INI` entries instead of incorrectly unwinding the room loop as if it were a closeup exit.
+  - Startup pickup-capable and operatable room objects now resolve to the examine cursor until their first examine click marks them identified.
+  - That first click now attempts to show the IDENT textbox when supported, but it still unlocks the object's later pickup or operate cursor even if the IDENT lookup has no usable panel, matching the native writeback behavior.
 
 ## Next Suggested Action
 
-1. Runtime-test the startup room menu and player-click inventory entry against DOSBox, and adjust prompt/cursor behavior if the player hotspot needs a native text label rather than just the cursor change.
-2. Flesh out the room-menu stub now that `Esc` reaches the correct native entry point, especially the per-item behaviors inside `run_main_menu()` such as resume/save/load/options/quit gating.
-3. Parse startup NPC and timer records from `HARVEST.SCR`, then add persistent runtime state for the remaining confirmed exit and interaction opcodes: `SET_NPC`, `SET_TIMER`, and `KILL_TIMER`.
-4. Confirm the native side effects of those opcodes in Ghidra before naming any new runtime structures or script helpers.
-5. Parse `REGION` records from `HARVEST.SCR` into the startup script layer.
-6. Mirror native startup transition handling for class `0x19` region entities.
+1. Runtime-test first-click IDENT gating on startup pickup objects such as `PC_PEN` against DOSBox, especially the exact cursor/prompt transition after dismissing the textbox.
+2. Confirm whether any startup objects use the native "unlock on first examine" path without a valid IDENT panel, then mirror any edge-case prompt behavior if the current stub differs.
+3. Flesh out the room-menu stub now that `Esc` reaches the correct native entry point, especially the per-item behaviors inside `run_main_menu()` such as resume/save/load/options/quit gating.
+4. Parse startup NPC and timer records from `HARVEST.SCR`, then add persistent runtime state for the remaining confirmed exit and interaction opcodes: `SET_NPC`, `SET_TIMER`, and `KILL_TIMER`.
+5. Confirm the native side effects of those opcodes in Ghidra before naming any new runtime structures or script helpers.
+6. Parse `REGION` records from `HARVEST.SCR` into the startup script layer.
+7. Mirror native startup transition handling for class `0x19` region entities.
   - Spawn enabled region hotspots from `room_setup` state.
   - Drive cursor sequence `6` over active regions.
   - Gate region activation on player overlap plus facing, following `check_player_region_interaction`.
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index d1fdda306df..ab1f43fe74c 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -796,22 +796,28 @@ static void drawRoomInspectText(Graphics::Screen &screen, const StartupArt &art,
 		kIdentTextColor);
 }
 
+static bool unlocksRoomObjectInteractionAfterInitialExamine(const StartupObjectRecord &object,
+		StartupScript &startupScript) {
+	return object.operatable || startupScript.isPickupObject(object);
+}
+
 static int resolveRoomObjectCursorSequence(const StartupObjectRecord &object, StartupScript &startupScript) {
 	if (object.objectName.equalsIgnoreCase("EXIT_BM") || object.objectName.equalsIgnoreCase("EXIT_HS"))
 		return kCursorSequenceTransition;
-	if (startupScript.isPickupObject(object))
-		return kCursorSequencePickup;
 
 	StartupResolvedText inspectText;
-	if (object.operatable && startupScript.hasObjectInteraction(object))
+	const bool pickupObject = startupScript.isPickupObject(object);
+	if (!object.identShown && unlocksRoomObjectInteractionAfterInitialExamine(object, startupScript))
+		return kCursorSequenceExamine;
+	if (pickupObject)
+		return kCursorSequencePickup;
+	if (object.operatable)
 		return kCursorSequenceOperate;
-	if (!object.identShown && startupScript.resolveObjectInspectText(object, inspectText))
+	if (startupScript.resolveObjectInspectText(object, inspectText))
 		return kCursorSequenceExamine;
 
 	if (startupScript.hasObjectInteraction(object))
 		return kCursorSequenceExamine;
-	if (startupScript.resolveObjectInspectText(object, inspectText))
-		return kCursorSequenceExamine;
 
 	return kCursorSequenceNeutral;
 }
@@ -2157,15 +2163,21 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 				StartupResolvedText resolvedInspectText;
 				const bool hasInspectText =
 					_engine.getStartupScript()->resolveObjectInspectText(*clickedObject, resolvedInspectText);
-				const bool isPickupObject = _engine.getStartupScript()->isPickupObject(*clickedObject);
+				const bool unlocksAfterInitialExamine =
+					unlocksRoomObjectInteractionAfterInitialExamine(*clickedObject, *_engine.getStartupScript());
 				const bool canShowInspectText = hasInspectText &&
 					resolveInspectTextboxBitmap(*art, resolvedInspectText);
-				if (!isPickupObject && !clickedObject->identShown && canShowInspectText) {
-					inspectText = resolvedInspectText;
+				if (!clickedObject->identShown && unlocksAfterInitialExamine) {
 					clickedObject->identShown = true;
 					_engine.getStartupScript()->markObjectIdentShown(*clickedObject);
-					showingInspectText = true;
-					inspectCanDismiss = false;
+					if (canShowInspectText) {
+						inspectText = resolvedInspectText;
+						showingInspectText = true;
+						inspectCanDismiss = false;
+					} else if (hasInspectText) {
+						debug(1, "Harvester: unsupported IDENT textbox '%s' for object '%s'",
+							resolvedInspectText.boxName.c_str(), clickedObject->objectName.c_str());
+					}
 					playerState.hasMoveTarget = false;
 					playerState.turnActive = false;
 					playerState.turnTargetFacing = -1;


Commit: ec163671c2847fbbb7506affe1e8eba8a6ae1e48
    https://github.com/scummvm/scummvm/commit/ec163671c2847fbbb7506affe1e8eba8a6ae1e48
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:44+02:00

Commit Message:
HARVESTER: Restore startup region room exits

Changed paths:
    TRACKER.md
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/TRACKER.md b/TRACKER.md
index acd1cf18363..19c6fb036d7 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,23 +15,22 @@
 
 ## Last Confirmed Action
 
-- Confirmed in Ghidra how native startup pickup objects are gated behind a first-pass examine click.
-  - `spawn_object_entity_from_record()` copies the object record's alternate inventory-sprite presence into entity byte `+9`, the operatable flag into entity byte `+0xb`, and the persistent identified-state byte at object offset `+0x4f` into entity byte `+10`.
-  - In `run_harvester_main_loop()`, room objects with pickup or operate capability stay on cursor sequence `1` while entity byte `+10` is clear; once it is set, the same hover path switches to cursor sequence `5` for pickup-capable objects and cursor sequence `4` for non-pickup operatable objects.
-  - Clicking that initial examine cursor calls `show_target_ident_text()`, then writes entity byte `+10 = 1` and persists object byte `+0x4f = 1`, so the next hover/click uses the unlocked pickup or operate path even if no IDENT panel was shown.
-- Patched the startup stub to mirror that native behavior.
-  - Startup pickup-capable and operatable room objects now resolve to the examine cursor until their first examine click marks them identified.
-  - That first click now attempts to show the IDENT textbox when supported, but it still unlocks the object's later pickup or operate cursor even if the IDENT lookup has no usable panel, matching the native writeback behavior.
+- Confirmed in Ghidra how native startup room exits and entrance placement are wired.
+  - `room_setup()` resolves the requested `ENTRANCE` record and applies its three integer fields directly as player `x`, `y`, and `z`, using `set_entity_screen_position()` with `(entrance_x - frame_x_offset - width / 2, entrance_y - height - frame_y_offset, entrance_z)` before and after `rescale_entity_sprite_for_depth()`.
+  - The `START` and `PC_ROOM_HOUSE_START` entries in `HARVEST.SCR` both point to `PCROOM` at `(360,405,15)` facing `LEFT`, so native room setup does not add any extra centering offset beyond the entrance record itself.
+  - `PCDOOR` is just a mask object with interaction label `NULL_ID`; the actual bedroom exit is `REGION "PCX1"` with command tag `PCEXIT1`, and `run_harvester_main_loop()` only gives class `0x19` regions cursor sequence `6` with no prompt text.
+  - `check_player_region_interaction()` requires screen-rect overlap, z-range overlap, a matching facing, and `start_enabled != 0` before dispatching the region command chain.
+- Patched the startup stub to mirror that native split.
+  - `HARVEST.SCR` `REGION` records are now parsed into the startup script layer and materialized into room state.
+  - Startup room hover/click now prefers active regions over neutral `NULL_ID` masks, so `PCDOOR` no longer fabricates a prompt while `PCX1` drives the transition cursor and room-change command path.
+  - The startup room loop now keeps a pending region target, waits for player overlap plus facing, and only then dispatches the region command chain, matching the native closeup/door transition shape more closely.
 
 ## Next Suggested Action
 
-1. Runtime-test first-click IDENT gating on startup pickup objects such as `PC_PEN` against DOSBox, especially the exact cursor/prompt transition after dismissing the textbox.
-2. Confirm whether any startup objects use the native "unlock on first examine" path without a valid IDENT panel, then mirror any edge-case prompt behavior if the current stub differs.
-3. Flesh out the room-menu stub now that `Esc` reaches the correct native entry point, especially the per-item behaviors inside `run_main_menu()` such as resume/save/load/options/quit gating.
-4. Parse startup NPC and timer records from `HARVEST.SCR`, then add persistent runtime state for the remaining confirmed exit and interaction opcodes: `SET_NPC`, `SET_TIMER`, and `KILL_TIMER`.
-5. Confirm the native side effects of those opcodes in Ghidra before naming any new runtime structures or script helpers.
-6. Parse `REGION` records from `HARVEST.SCR` into the startup script layer.
-7. Mirror native startup transition handling for class `0x19` region entities.
-  - Spawn enabled region hotspots from `room_setup` state.
-  - Drive cursor sequence `6` over active regions.
-  - Gate region activation on player overlap plus facing, following `check_player_region_interaction`.
+1. Runtime-test `PCROOM` against DOSBox with the new region path, especially the `PCDOOR` hover, the `PCX1` transition cursor, and the player restore point after returning from closeups.
+2. Compare startup player placement visually in `PCROOM` after confirming the native `START` coordinates above; if it still reads too far left, the remaining mismatch is in runtime sprite metrics or render anchoring rather than script-room setup.
+3. Revisit native locomotion in `update_actor_runtime_state()` and the main loop before changing movement speed or walk bounds.
+  - The current startup stub still uses direct screen-space interpolation and does not mirror the native movement-state machine, blocker history, or z-aware overlap tests.
+  - Any further speed or walk-area changes should come from that recovered actor-state path, not from ad hoc constants.
+4. Flesh out the room-menu stub now that `Esc` reaches the correct native entry point, especially the per-item behaviors inside `run_main_menu()` such as resume/save/load/options/quit gating.
+5. Parse startup NPC and timer records from `HARVEST.SCR`, then add persistent runtime state for the remaining confirmed exit and interaction opcodes: `SET_NPC`, `SET_TIMER`, and `KILL_TIMER`.
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index ab1f43fe74c..7e573ba4ba4 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -109,6 +109,7 @@ struct StartupRoomSceneResources {
 	byte palette[256 * 3] = { 0 };
 	Common::Array<StartupObjectRecord> sceneObjects;
 	Common::Array<StartupAnimRecord> sceneAnimations;
+	Common::Array<StartupRegionRecord> sceneRegions;
 	float targetPaletteBrightness = 1.0f;
 };
 
@@ -132,6 +133,7 @@ struct StartupRoomPlayerState {
 
 struct StartupRoomHoverState {
 	const StartupObjectRecord *object = nullptr;
+	const StartupRegionRecord *region = nullptr;
 	const RuntimeEntity *playerEntity = nullptr;
 	Common::String promptText;
 	int cursorSequence = kCursorSequenceNeutral;
@@ -491,6 +493,13 @@ static Common::Rect getHotspotBounds(const StartupObjectRecord &object) {
 	return Common::Rect();
 }
 
+static Common::Rect getRegionBounds(const StartupRegionRecord &region) {
+	if (region.right > region.left && region.bottom > region.top)
+		return Common::Rect(region.left, region.top, region.right + 1, region.bottom + 1);
+
+	return Common::Rect();
+}
+
 static Common::String resolveSceneObjectSpritePath(const StartupObjectRecord &object) {
 	const bool atInitialPlacement = object.currentX == object.initialX &&
 		object.currentY == object.initialY &&
@@ -699,12 +708,13 @@ static bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceM
 		if (anim.active || anim.visible)
 			scene.sceneAnimations.push_back(anim);
 	}
+	scene.sceneRegions = state.roomRegions;
 
 	debugC(1, kDebugGeneral,
-		"Harvester: loadRoomSceneResources room='%s' palette='%s' background='%s' brightness=%.2f objects=%u activeObjects=%u sceneObjects=%u anims=%u visibleAnims=%u",
+		"Harvester: loadRoomSceneResources room='%s' palette='%s' background='%s' brightness=%.2f objects=%u activeObjects=%u sceneObjects=%u anims=%u visibleAnims=%u regions=%u",
 		state.roomName.c_str(), state.palettePath.c_str(), state.backgroundPath.c_str(), (double)state.paletteBrightness,
 		(uint)state.roomObjects.size(), (uint)state.activeObjects.size(), (uint)scene.sceneObjects.size(),
-		(uint)state.roomAnimations.size(), (uint)scene.sceneAnimations.size());
+		(uint)state.roomAnimations.size(), (uint)scene.sceneAnimations.size(), (uint)scene.sceneRegions.size());
 
 	return true;
 }
@@ -732,6 +742,16 @@ static const StartupObjectRecord *findSceneObjectByName(const Common::Array<Star
 	return nullptr;
 }
 
+static const StartupRegionRecord *findSceneRegionByName(const Common::Array<StartupRegionRecord> &regions,
+		const Common::String &regionName) {
+	for (const StartupRegionRecord &region : regions) {
+		if (region.regionName.equalsIgnoreCase(regionName))
+			return ®ion;
+	}
+
+	return nullptr;
+}
+
 static StartupObjectRecord *findSceneObjectByName(Common::Array<StartupObjectRecord> &objects,
 		const Common::String &objectName) {
 	for (StartupObjectRecord &object : objects) {
@@ -748,13 +768,48 @@ static const StartupObjectRecord *findRoomObjectAtPoint(HarvesterEngine &engine,
 	if (!runtimeEntities)
 		return nullptr;
 
-	const RuntimeEntity *entity = runtimeEntities->findTopSceneEntityAt(point);
-	if (!entity)
+	const RuntimeEntity *topEntity = nullptr;
+	for (const StartupObjectRecord &object : sceneObjects) {
+		if (object.objectName.empty())
+			continue;
+
+		const RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(object.objectName);
+		if (!entity || !entity->hitTest(point))
+			continue;
+		if (entity->getClassId() == kRuntimeEntityClassBackground ||
+			entity->getClassId() == kRuntimeEntityClassPlayer ||
+			entity->getClassId() == kRuntimeEntityClassRectHotspot19) {
+			continue;
+		}
+		if (!topEntity || entity->getZ() <= topEntity->getZ())
+			topEntity = entity;
+	}
+	if (!topEntity)
 		return nullptr;
-	if (entity->getClassId() == kRuntimeEntityClassBackground)
+
+	return findSceneObjectByName(sceneObjects, topEntity->getName());
+}
+
+static const StartupRegionRecord *findRoomRegionAtPoint(HarvesterEngine &engine,
+		const Common::Array<StartupRegionRecord> &sceneRegions, const Common::Point &point) {
+	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
+	if (!runtimeEntities)
 		return nullptr;
 
-	return findSceneObjectByName(sceneObjects, entity->getName());
+	const RuntimeEntity *topEntity = nullptr;
+	for (const StartupRegionRecord &region : sceneRegions) {
+		const RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(region.regionName);
+		if (!entity || !entity->hitTest(point))
+			continue;
+		if (entity->getClassId() != kRuntimeEntityClassRectHotspot19)
+			continue;
+		if (!topEntity || entity->getZ() <= topEntity->getZ())
+			topEntity = entity;
+	}
+	if (!topEntity)
+		return nullptr;
+
+	return findSceneRegionByName(sceneRegions, topEntity->getName());
 }
 
 static const RuntimeEntity *findRoomPlayerAtPoint(HarvesterEngine &engine, const Common::Point &point) {
@@ -840,8 +895,24 @@ static Common::String buildRoomObjectPrompt(const StartupObjectRecord &object, S
 	return Common::String::format("Examine %s", label.c_str());
 }
 
+static bool doesPlayerFacingMatchRegion(int playerFacing, const StartupRegionRecord &region) {
+	return region.desiredFacing < 0 || playerFacing == region.desiredFacing;
+}
+
+static bool doesPlayerOverlapRegion(const RuntimeEntity &playerEntity, const StartupRegionRecord &region) {
+	const Common::Rect regionBounds = getRegionBounds(region);
+	if (regionBounds.isEmpty())
+		return false;
+	if (!playerEntity.getScreenRect().intersects(regionBounds))
+		return false;
+
+	const float playerMaxZ = playerEntity.getZ() + playerEntity.getZExtent();
+	return playerMaxZ >= (float)region.minZ && (float)region.maxZ >= playerEntity.getZ();
+}
+
 static StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		const Common::Array<StartupObjectRecord> &sceneObjects, const Common::Point &mousePos) {
+		const Common::Array<StartupObjectRecord> &sceneObjects,
+		const Common::Array<StartupRegionRecord> &sceneRegions, const Common::Point &mousePos) {
 	StartupRoomHoverState hoverState;
 	if (const RuntimeEntity *playerEntity = findRoomPlayerAtPoint(engine, mousePos)) {
 		hoverState.playerEntity = playerEntity;
@@ -853,9 +924,17 @@ static StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, cons
 	if (!startupScript)
 		return hoverState;
 	hoverState.object = findRoomObjectAtPoint(engine, sceneObjects, mousePos);
+	hoverState.region = findRoomRegionAtPoint(engine, sceneRegions, mousePos);
 	if (hoverState.object) {
 		hoverState.cursorSequence = resolveRoomObjectCursorSequence(*hoverState.object, *startupScript);
 		hoverState.promptText = buildRoomObjectPrompt(*hoverState.object, *startupScript, hoverState.cursorSequence);
+		if (hoverState.cursorSequence != kCursorSequenceNeutral || !hoverState.promptText.empty())
+			return hoverState;
+	}
+	if (hoverState.region && hoverState.region->cursorEnabled) {
+		hoverState.object = nullptr;
+		hoverState.cursorSequence = kCursorSequenceTransition;
+		hoverState.promptText.clear();
 		return hoverState;
 	}
 
@@ -1263,7 +1342,8 @@ static void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneR
 		mousePos = probePoint;
 		const Common::String objectLabel = startupScript->resolveObjectLabel(*hoveredObject);
 		StartupResolvedText inspectText;
-		const StartupRoomHoverState hoverState = resolveRoomHoverState(engine, scene.state, scene.sceneObjects, probePoint);
+		const StartupRoomHoverState hoverState = resolveRoomHoverState(
+			engine, scene.state, scene.sceneObjects, scene.sceneRegions, probePoint);
 		const bool hasInteraction = startupScript->hasObjectInteraction(*hoveredObject);
 		const bool hasInspectText = startupScript->resolveObjectInspectText(*hoveredObject, inspectText);
 		debugC(1, kDebugGeneral,
@@ -1279,7 +1359,7 @@ static void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneR
 			for (int y = scene.state.roomMaxZScreenY; y <= scene.state.roomMinZScreenY && !foundFloorProbe; y += 12) {
 				for (int x = 48; x < 592; x += 16) {
 					const StartupRoomHoverState candidateHover = resolveRoomHoverState(
-						engine, scene.state, scene.sceneObjects, Common::Point(x, y));
+						engine, scene.state, scene.sceneObjects, scene.sceneRegions, Common::Point(x, y));
 					if (candidateHover.cursorSequence == kCursorSequenceWalk) {
 						floorProbe = Common::Point(x, y);
 						foundFloorProbe = true;
@@ -1289,7 +1369,7 @@ static void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneR
 			}
 
 			const StartupRoomHoverState floorHover = foundFloorProbe
-				? resolveRoomHoverState(engine, scene.state, scene.sceneObjects, floorProbe)
+				? resolveRoomHoverState(engine, scene.state, scene.sceneObjects, scene.sceneRegions, floorProbe)
 				: StartupRoomHoverState();
 			debugC(1, kDebugGeneral,
 				"Harvester: startup probe floor room='%s' point=(%d,%d) found=%d cursor_sequence=%d prompt='%s'",
@@ -1788,6 +1868,7 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 	bool moveRight = false;
 	bool moveUp = false;
 	bool moveDown = false;
+	Common::String pendingRegionName;
 	StartupInventoryOverlayState inventoryState;
 	StartupRoomIdleAnimationState idleState;
 	bool needsRedraw = true;
@@ -1852,6 +1933,7 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 		moveRight = false;
 		moveUp = false;
 		moveDown = false;
+		pendingRegionName.clear();
 		playerState.hasMoveTarget = false;
 		playerState.turnActive = false;
 		playerState.turnTargetFacing = -1;
@@ -1891,6 +1973,7 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 		playerState.hasMoveTarget = false;
 		playerState.turnActive = false;
 		playerState.turnTargetFacing = -1;
+		pendingRegionName.clear();
 		resetIdleState();
 		resetCursorAnimationSequence();
 		return refreshInventoryState();
@@ -1906,6 +1989,7 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 		playerState.hasMoveTarget = false;
 		playerState.turnActive = false;
 		playerState.turnTargetFacing = -1;
+		pendingRegionName.clear();
 
 		Common::String restoreMusicPath = _engine.getStartupMusicPath();
 		if (!interaction.musicPath.empty()) {
@@ -1963,6 +2047,51 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 		needsRedraw = true;
 		return Common::kNoError;
 	};
+	auto queueRegionInteraction = [&](const StartupRegionRecord &region) {
+		pendingRegionName = region.regionName;
+		if (!playerState.entity)
+			return;
+
+		if (doesPlayerOverlapRegion(*playerState.entity, region))
+			return;
+
+		int targetBottomY = region.bottom;
+		setPlayerMoveTarget(scene.state, playerState, (region.left + region.right) / 2, targetBottomY);
+	};
+	auto runRegionInteraction = [&](const StartupRegionRecord &region) -> Common::Error {
+		StartupInteractionResult interaction;
+		if (!_engine.getStartupScript()->resolveRegionInteraction(region, interaction))
+			return Common::kNoError;
+
+		Common::Error interactionError = handleInteractionResult(interaction);
+		if (interactionError.getCode() != Common::kNoError)
+			return interactionError;
+
+		if (!refreshInventoryState())
+			return Common::kReadingFailed;
+		needsRedraw = true;
+		return Common::kNoError;
+	};
+	auto tryActivatePendingRegion = [&]() -> Common::Error {
+		if (pendingRegionName.empty() || !playerState.entity)
+			return Common::kNoError;
+
+		const StartupRegionRecord *region = findSceneRegionByName(scene.sceneRegions, pendingRegionName);
+		if (!region || !region->startEnabled) {
+			pendingRegionName.clear();
+			return Common::kNoError;
+		}
+		if (!doesPlayerOverlapRegion(*playerState.entity, *region))
+			return Common::kNoError;
+		if (!doesPlayerFacingMatchRegion(playerState.facing, *region)) {
+			if (!playerState.hasMoveTarget && !playerState.turnActive && region->desiredFacing >= 0)
+				(void)startPlayerTurnAnimation(playerState, region->desiredFacing);
+			return Common::kNoError;
+		}
+
+		pendingRegionName.clear();
+		return runRegionInteraction(*region);
+	};
 	if (!refreshInventoryState())
 		return Common::kReadingFailed;
 	Graphics::FrameLimiter limiter(g_system, 60);
@@ -1978,7 +2107,7 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 				(inventoryState.open && (inventoryPanelContainsMouse || inventoryState.selectedItemName.empty()));
 			StartupRoomHoverState hoverState = suppressHover
 				? StartupRoomHoverState()
-				: resolveRoomHoverState(_engine, scene.state, scene.sceneObjects, _mousePos);
+				: resolveRoomHoverState(_engine, scene.state, scene.sceneObjects, scene.sceneRegions, _mousePos);
 			Common::String promptText;
 			if (inventoryState.open) {
 				const StartupInventoryVisual *inventoryHover = findInventoryVisualAtPoint(inventoryState.items, _mousePos);
@@ -2042,6 +2171,7 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 						needsRedraw = true;
 					break;
 				}
+				pendingRegionName.clear();
 				if (inventoryState.open) {
 					if (clearInventorySelection() || closeInventoryOverlay())
 						needsRedraw = true;
@@ -2101,7 +2231,7 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 
 					if (!inventoryState.selectedItemName.empty()) {
 						const StartupRoomHoverState useHoverState = resolveRoomHoverState(
-							_engine, scene.state, scene.sceneObjects, _mousePos);
+							_engine, scene.state, scene.sceneObjects, scene.sceneRegions, _mousePos);
 						const StartupObjectRecord *roomTarget = useHoverState.object
 							? findSceneObjectByName(scene.sceneObjects, useHoverState.object->objectName)
 							: nullptr;
@@ -2125,11 +2255,12 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 				}
 
 				const StartupRoomHoverState hoverState = resolveRoomHoverState(
-					_engine, scene.state, scene.sceneObjects, _mousePos);
+					_engine, scene.state, scene.sceneObjects, scene.sceneRegions, _mousePos);
 				debugC(1, kDebugScene,
-					"Harvester: room click room='%s' mouse=(%d,%d) object='%s' cursor_sequence=%d prompt='%s'",
+					"Harvester: room click room='%s' mouse=(%d,%d) object='%s' region='%s' cursor_sequence=%d prompt='%s'",
 					scene.state.roomName.c_str(), _mousePos.x, _mousePos.y,
 					hoverState.object ? hoverState.object->objectName.c_str() : "",
+					hoverState.region ? hoverState.region->regionName.c_str() : "",
 					hoverState.cursorSequence, hoverState.promptText.c_str());
 				if (hoverState.playerEntity) {
 					if (!playerState.entity || hoverState.playerEntity != playerState.entity ||
@@ -2143,16 +2274,26 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 					needsRedraw = true;
 					break;
 				}
+				if (hoverState.region && playerState.entity) {
+					queueRegionInteraction(*hoverState.region);
+					Common::Error interactionError = tryActivatePendingRegion();
+					if (interactionError.getCode() != Common::kNoError)
+						return interactionError;
+					needsRedraw = true;
+					break;
+				}
 				StartupObjectRecord *clickedObject = hoverState.object
 					? findSceneObjectByName(scene.sceneObjects, hoverState.object->objectName)
 					: nullptr;
 				if (!clickedObject) {
+					pendingRegionName.clear();
 					if (hoverState.cursorSequence == kCursorSequenceWalk && playerState.entity) {
 						setPlayerMoveTarget(scene.state, playerState, _mousePos.x, _mousePos.y);
 						needsRedraw = true;
 					}
 					break;
 				}
+				pendingRegionName.clear();
 				if (clickedObject->objectName.equalsIgnoreCase("EXIT_BM") ||
 					clickedObject->objectName.equalsIgnoreCase("EXIT_HS")) {
 					if (!runRoomExitCommands())
@@ -2254,6 +2395,7 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 					moveRight = false;
 					moveUp = false;
 					moveDown = false;
+					pendingRegionName.clear();
 					playerState.hasMoveTarget = false;
 					playerState.turnActive = false;
 					playerState.turnTargetFacing = -1;
@@ -2314,6 +2456,9 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 				needsRedraw = true;
 			}
 		}
+		Common::Error pendingRegionError = tryActivatePendingRegion();
+		if (pendingRegionError.getCode() != Common::kNoError)
+			return pendingRegionError;
 
 		if (tickRuntimeEntities())
 			needsRedraw = true;
@@ -2341,11 +2486,37 @@ bool StartupFlow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 		const Common::Array<StartupObjectRecord> &drawableObjects,
 		const Common::Array<StartupAnimRecord> &drawableAnimations) {
 	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
-	StartupScript *startupScript = _engine.getStartupScript();
 	if (!runtimeEntities)
 		return false;
 
 	runtimeEntities->clearSceneEntities();
+	for (const StartupRegionRecord &region : state.roomRegions) {
+		const Common::Rect regionBounds = getRegionBounds(region);
+		if (regionBounds.isEmpty())
+			continue;
+
+		RuntimeEntity *entity = runtimeEntities->spawnSceneHotspotEntity(
+			region.regionName, regionBounds, (float)region.minZ);
+		if (!entity) {
+			debugC(1, kDebugScene,
+				"Harvester: scene region skipped room='%s' region='%s' bounds=(%d,%d)-(%d,%d) z=[%d,%d]",
+				state.roomName.c_str(), region.regionName.c_str(),
+				regionBounds.left, regionBounds.top, regionBounds.right, regionBounds.bottom,
+				region.minZ, region.maxZ);
+			continue;
+		}
+
+		entity->setClassId(kRuntimeEntityClassRectHotspot19);
+		entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
+		entity->setZExtent((float)MAX(0, region.maxZ - region.minZ));
+		if (!region.cursorEnabled)
+			entity->setHitTestMode(kRuntimeEntityHitTestNone);
+		debugC(1, kDebugScene,
+			"Harvester: scene region spawned room='%s' region='%s' class=0x%x bounds=(%d,%d)-(%d,%d) z=[%d,%d] facing=%d cursor=%d action='%s'",
+			state.roomName.c_str(), region.regionName.c_str(), entity->getClassId(),
+			regionBounds.left, regionBounds.top, regionBounds.right, regionBounds.bottom,
+			region.minZ, region.maxZ, region.desiredFacing, region.cursorEnabled, region.actionTag.c_str());
+	}
 	for (const StartupObjectRecord &object : drawableObjects) {
 		RuntimeEntity *entity = nullptr;
 		const Common::String spritePath = resolveSceneObjectSpritePath(object);
@@ -2371,7 +2542,7 @@ bool StartupFlow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 			continue;
 		}
 
-		entity->setClassId(resolveSceneObjectClass(object, entity->hasFrames() ? entity : nullptr, startupScript));
+		entity->setClassId(resolveSceneObjectClass(object, entity->hasFrames() ? entity : nullptr, _engine.getStartupScript()));
 		entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
 		entity->setZExtent((float)object.zExtent);
 		const Common::Rect entityRect = entity->getScreenRect();
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 15fe531ffcf..9ffd55e33c8 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -153,6 +153,7 @@ bool StartupScript::load(ResourceManager &resources) {
 	_rooms.clear();
 	_objects.clear();
 	_animations.clear();
+	_regions.clear();
 	_flags.clear();
 	_commands.clear();
 	_texts.clear();
@@ -216,6 +217,7 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 	_rooms.clear();
 	_objects.clear();
 	_animations.clear();
+	_regions.clear();
 	_flags.clear();
 	_commands.clear();
 	_texts.clear();
@@ -234,6 +236,7 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 		uint tagIndex = tokens.size();
 		for (uint i = 0; i < tokens.size(); ++i) {
 			if (tokens[i] == "ANIM" || tokens[i] == "ENTRANCE" || tokens[i] == "ROOM" || tokens[i] == "OBJECT" ||
+				tokens[i] == "REGION" ||
 				tokens[i] == "FLAG" || tokens[i] == "COMMAND" || tokens[i] == "TEXT" || tokens[i] == "USEITEM") {
 				tagIndex = i;
 				break;
@@ -362,65 +365,90 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 			return;
 		}
 
-			if (tag == "ANIM") {
-				if (tokens.size() < tagIndex + 10)
-					return;
-
-				StartupAnimRecord anim;
-				if (tagIndex >= 4) {
-					anim.x = atoi(tokens[0].c_str());
-					anim.y = atoi(tokens[1].c_str());
-					anim.z = atoi(tokens[2].c_str());
-					anim.frameDelay = atoi(tokens[3].c_str());
-				}
-				anim.roomName = tokens[tagIndex + 1];
-				anim.resourcePath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
-				anim.animName = tokens[tagIndex + 3];
-				anim.active = tokens[tagIndex + 4].equalsIgnoreCase("T");
-				anim.visible = tokens[tagIndex + 5].equalsIgnoreCase("T");
-				anim.looping = tokens[tagIndex + 6].equalsIgnoreCase("T");
-				anim.backward = tokens[tagIndex + 7].equalsIgnoreCase("T");
-				anim.pingPong = tokens[tagIndex + 8].equalsIgnoreCase("T");
-				anim.remove = tokens[tagIndex + 9].equalsIgnoreCase("T");
-				anim.runtimeActive = anim.active;
-				anim.runtimeVisible = anim.visible;
-				if (!anim.roomName.empty() && !anim.resourcePath.empty() && !anim.animName.empty())
-					_animations.push_back(anim);
+		if (tag == "REGION") {
+			if (tokens.size() < tagIndex + 7)
 				return;
+
+			StartupRegionRecord region;
+			if (tagIndex >= 6) {
+				region.left = atoi(tokens[0].c_str());
+				region.top = atoi(tokens[1].c_str());
+				region.right = atoi(tokens[2].c_str());
+				region.bottom = atoi(tokens[3].c_str());
+				region.minZ = atoi(tokens[4].c_str());
+				region.maxZ = atoi(tokens[5].c_str());
 			}
+			region.regionName = tokens[tagIndex + 1];
+			region.direction = tokens[tagIndex + 2];
+			region.desiredFacing = parseEntranceFacing(region.direction);
+			region.roomName = tokens[tagIndex + 3];
+			region.actionTag = tokens[tagIndex + 4];
+			region.startEnabled = tokens[tagIndex + 5].equalsIgnoreCase("T");
+			region.cursorEnabled = tokens[tagIndex + 6].equalsIgnoreCase("T");
+			if (!region.roomName.empty() && !region.regionName.empty())
+				_regions.push_back(region);
+			return;
+		}
 
-			if (tokens.size() < tagIndex + 13)
+		if (tag == "ANIM") {
+			if (tokens.size() < tagIndex + 10)
 				return;
 
-			StartupObjectRecord object;
-			if (tagIndex >= 6) {
-				object.initialX = atoi(tokens[0].c_str());
-				object.initialY = atoi(tokens[1].c_str());
-				object.boundsX2 = atoi(tokens[2].c_str());
-				object.boundsY2 = atoi(tokens[3].c_str());
-				object.initialZ = atoi(tokens[4].c_str());
-				object.zExtent = atoi(tokens[5].c_str());
+			StartupAnimRecord anim;
+			if (tagIndex >= 4) {
+				anim.x = atoi(tokens[0].c_str());
+				anim.y = atoi(tokens[1].c_str());
+				anim.z = atoi(tokens[2].c_str());
+				anim.frameDelay = atoi(tokens[3].c_str());
 			}
-			object.currentX = object.initialX;
-			object.currentY = object.initialY;
-			object.currentZ = object.initialZ;
-			object.initialOwnerOrRoom = tokens[tagIndex + 1];
-			object.objectName = tokens[tagIndex + 2];
-			object.spritePath = resources.normalizeResourcePath(tokens[tagIndex + 3]);
-			object.altSpritePath = resources.normalizeResourcePath(tokens[tagIndex + 4]);
-			object.field40 = tokens[tagIndex + 5];
-			object.inventoryTextKey = tokens[tagIndex + 6];
-			object.field34 = tokens[tagIndex + 7];
-			object.identTextKey = tokens[tagIndex + 8];
-			object.operatable = tokens[tagIndex + 9].equalsIgnoreCase("T");
-			object.visible = tokens[tagIndex + 10].equalsIgnoreCase("T");
-			object.actionTag = tokens[tagIndex + 11];
-			object.interactionLabel = tokens[tagIndex + 12];
-			object.currentOwnerOrRoom = object.initialOwnerOrRoom;
-			object.runtimeVisible = object.visible;
-			object.identShown = object.identTextKey.empty();
-			if (!object.initialOwnerOrRoom.empty() && !object.objectName.empty())
-				_objects.push_back(object);
+			anim.roomName = tokens[tagIndex + 1];
+			anim.resourcePath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
+			anim.animName = tokens[tagIndex + 3];
+			anim.active = tokens[tagIndex + 4].equalsIgnoreCase("T");
+			anim.visible = tokens[tagIndex + 5].equalsIgnoreCase("T");
+			anim.looping = tokens[tagIndex + 6].equalsIgnoreCase("T");
+			anim.backward = tokens[tagIndex + 7].equalsIgnoreCase("T");
+			anim.pingPong = tokens[tagIndex + 8].equalsIgnoreCase("T");
+			anim.remove = tokens[tagIndex + 9].equalsIgnoreCase("T");
+			anim.runtimeActive = anim.active;
+			anim.runtimeVisible = anim.visible;
+			if (!anim.roomName.empty() && !anim.resourcePath.empty() && !anim.animName.empty())
+				_animations.push_back(anim);
+			return;
+		}
+
+		if (tokens.size() < tagIndex + 13)
+			return;
+
+		StartupObjectRecord object;
+		if (tagIndex >= 6) {
+			object.initialX = atoi(tokens[0].c_str());
+			object.initialY = atoi(tokens[1].c_str());
+			object.boundsX2 = atoi(tokens[2].c_str());
+			object.boundsY2 = atoi(tokens[3].c_str());
+			object.initialZ = atoi(tokens[4].c_str());
+			object.zExtent = atoi(tokens[5].c_str());
+		}
+		object.currentX = object.initialX;
+		object.currentY = object.initialY;
+		object.currentZ = object.initialZ;
+		object.initialOwnerOrRoom = tokens[tagIndex + 1];
+		object.objectName = tokens[tagIndex + 2];
+		object.spritePath = resources.normalizeResourcePath(tokens[tagIndex + 3]);
+		object.altSpritePath = resources.normalizeResourcePath(tokens[tagIndex + 4]);
+		object.field40 = tokens[tagIndex + 5];
+		object.inventoryTextKey = tokens[tagIndex + 6];
+		object.field34 = tokens[tagIndex + 7];
+		object.identTextKey = tokens[tagIndex + 8];
+		object.operatable = tokens[tagIndex + 9].equalsIgnoreCase("T");
+		object.visible = tokens[tagIndex + 10].equalsIgnoreCase("T");
+		object.actionTag = tokens[tagIndex + 11];
+		object.interactionLabel = tokens[tagIndex + 12];
+		object.currentOwnerOrRoom = object.initialOwnerOrRoom;
+		object.runtimeVisible = object.visible;
+		object.identShown = object.identTextKey.empty();
+		if (!object.initialOwnerOrRoom.empty() && !object.objectName.empty())
+			_objects.push_back(object);
 		};
 
 	Common::String line;
@@ -439,8 +467,9 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 
 	parseLine(line);
 
-	debug(1, "Harvester: parsed %u entrances, %u rooms, %u objects, %u anims, %u flags, %u commands, %u texts, %u useitems from '%s'",
+	debug(1, "Harvester: parsed %u entrances, %u rooms, %u objects, %u anims, %u regions, %u flags, %u commands, %u texts, %u useitems from '%s'",
 		(uint)_entrances.size(), (uint)_rooms.size(), (uint)_objects.size(), (uint)_animations.size(),
+		(uint)_regions.size(),
 		(uint)_flags.size(), (uint)_commands.size(), (uint)_texts.size(), (uint)_useItems.size(), _path.c_str());
 }
 
@@ -469,12 +498,13 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 		state.musicPath = musicPath;
 
 	debugC(1, kDebugGeneral,
-		"Harvester: resolveRoomSetupState('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u mutated=%d",
+		"Harvester: resolveRoomSetupState('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u roomRegions=%u mutated=%d",
 		entranceName.c_str(), state.roomName.c_str(), state.entranceName.c_str(),
 		state.playerSpawnX, state.playerSpawnY, state.playerSpawnZ, state.playerFacing,
 		state.palettePath.c_str(), state.backgroundPath.c_str(), state.musicPath.c_str(),
 		(double)state.paletteBrightness, (uint)state.roomObjects.size(),
-		(uint)state.activeObjects.size(), (uint)state.roomAnimations.size(), mutatedRuntimeState);
+		(uint)state.activeObjects.size(), (uint)state.roomAnimations.size(),
+		(uint)state.roomRegions.size(), mutatedRuntimeState);
 
 	return true;
 }
@@ -554,6 +584,17 @@ bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object,
 		result.mutatedRuntimeState || hasActionableCommandChain(object.actionTag);
 }
 
+bool StartupScript::resolveRegionInteraction(const StartupRegionRecord &region, StartupInteractionResult &result) {
+	result = StartupInteractionResult();
+	if (region.actionTag.empty())
+		return false;
+
+	executeCommandChain(region.actionTag, "region command", region.regionName, true,
+		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.mutatedRuntimeState);
+	return !result.nextRoomName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
+		result.mutatedRuntimeState || hasActionableCommandChain(region.actionTag);
+}
+
 bool StartupScript::resolveUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target,
 		StartupInteractionResult &result) {
 	result = StartupInteractionResult();
@@ -791,6 +832,10 @@ bool StartupScript::buildRuntimeRoomState(const StartupRoomRecord &room, const S
 		if (anim.roomName.equalsIgnoreCase(room.roomName))
 			state.roomAnimations.push_back(anim);
 	}
+	for (const StartupRegionRecord &region : _regions) {
+		if (region.roomName.equalsIgnoreCase(room.roomName) && region.startEnabled)
+			state.roomRegions.push_back(region);
+	}
 
 	const StartupFlagRecord *dayFlag = findRuntimeFlag("DAY_FLAG");
 	state.paletteBrightness = (room.dimmable && (!dayFlag || !dayFlag->value))
@@ -807,11 +852,11 @@ bool StartupScript::buildRuntimeRoomState(const StartupRoomRecord &room, const S
 	}
 
 	debugC(1, kDebugGeneral,
-		"Harvester: materializeRoomState room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u roomAnims=%u",
+		"Harvester: materializeRoomState room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u roomAnims=%u roomRegions=%u",
 		state.roomName.c_str(), state.entranceName.c_str(), state.playerSpawnX, state.playerSpawnY,
 		state.playerSpawnZ, state.playerFacing, state.palettePath.c_str(), state.backgroundPath.c_str(),
 		state.musicPath.c_str(), (double)state.paletteBrightness,
-		(uint)state.roomObjects.size(), (uint)state.roomAnimations.size());
+		(uint)state.roomObjects.size(), (uint)state.roomAnimations.size(), (uint)state.roomRegions.size());
 
 	return true;
 }
@@ -1027,6 +1072,8 @@ Common::String StartupScript::resolveObjectLabel(const StartupObjectRecord &obje
 
 	if (!object.interactionLabel.empty() && !object.interactionLabel.equalsIgnoreCase("NULL_ID"))
 		return normalizeInteractionLabel(object.interactionLabel);
+	if (object.interactionLabel.equalsIgnoreCase("NULL_ID"))
+		return Common::String();
 
 	const StartupTextRecord *textRecord = findTextRecord(object.identTextKey);
 	if (textRecord && !textRecord->value.empty())
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index f2301055478..c35d45bf537 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -105,6 +105,22 @@ struct StartupAnimRecord {
 	int runtimeState = -1;
 };
 
+struct StartupRegionRecord {
+	int left = 0;
+	int top = 0;
+	int right = 0;
+	int bottom = 0;
+	int minZ = 0;
+	int maxZ = 0;
+	int desiredFacing = -1;
+	Common::String regionName;
+	Common::String direction;
+	Common::String roomName;
+	Common::String actionTag;
+	bool startEnabled = false;
+	bool cursorEnabled = false;
+};
+
 struct StartupFlagRecord {
 	Common::String name;
 	bool value = false;
@@ -169,6 +185,7 @@ struct StartupRoomSetupState {
 	Common::Array<StartupObjectRecord> activeObjects;
 	Common::Array<StartupObjectRecord> roomObjects;
 	Common::Array<StartupAnimRecord> roomAnimations;
+	Common::Array<StartupRegionRecord> roomRegions;
 	Common::Array<StartupAudioCommand> audioCommands;
 };
 
@@ -194,6 +211,7 @@ public:
 	const Common::Array<StartupRoomRecord> &getRooms() const { return _rooms; }
 	const Common::Array<StartupObjectRecord> &getObjects() const { return _objects; }
 	const Common::Array<StartupAnimRecord> &getAnimations() const { return _animations; }
+	const Common::Array<StartupRegionRecord> &getRegions() const { return _regions; }
 	const Common::Array<StartupFlagRecord> &getFlags() const { return _flags; }
 	const Common::Array<StartupCommandRecord> &getCommands() const { return _commands; }
 	const Common::Array<StartupTextRecord> &getTexts() const { return _texts; }
@@ -207,6 +225,7 @@ public:
 		StartupRoomSetupState &state) const;
 	bool executeRoomExitCommands(const Common::String &roomName, Common::Array<StartupAudioCommand> &audioCommands);
 	bool resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result);
+	bool resolveRegionInteraction(const StartupRegionRecord &region, StartupInteractionResult &result);
 	bool resolveUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target,
 		StartupInteractionResult &result);
 	bool isPickupObject(const StartupObjectRecord &object) const;
@@ -246,6 +265,7 @@ private:
 	Common::Array<StartupRoomRecord> _rooms;
 	Common::Array<StartupObjectRecord> _objects;
 	Common::Array<StartupAnimRecord> _animations;
+	Common::Array<StartupRegionRecord> _regions;
 	Common::Array<StartupFlagRecord> _flags;
 	Common::Array<StartupCommandRecord> _commands;
 	Common::Array<StartupTextRecord> _texts;


Commit: 3bd6e187eef125748c2a712cc5ca0ea97d3ea24a
    https://github.com/scummvm/scummvm/commit/3bd6e187eef125748c2a712cc5ca0ea97d3ea24a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:44+02:00

Commit Message:
HARVESTER: Align startup room movement with native xz targets

Changed paths:
    TRACKER.md
    engines/harvester/startup_flow.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 19c6fb036d7..642fd57589c 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,22 +15,21 @@
 
 ## Last Confirmed Action
 
-- Confirmed in Ghidra how native startup room exits and entrance placement are wired.
-  - `room_setup()` resolves the requested `ENTRANCE` record and applies its three integer fields directly as player `x`, `y`, and `z`, using `set_entity_screen_position()` with `(entrance_x - frame_x_offset - width / 2, entrance_y - height - frame_y_offset, entrance_z)` before and after `rescale_entity_sprite_for_depth()`.
-  - The `START` and `PC_ROOM_HOUSE_START` entries in `HARVEST.SCR` both point to `PCROOM` at `(360,405,15)` facing `LEFT`, so native room setup does not add any extra centering offset beyond the entrance record itself.
-  - `PCDOOR` is just a mask object with interaction label `NULL_ID`; the actual bedroom exit is `REGION "PCX1"` with command tag `PCEXIT1`, and `run_harvester_main_loop()` only gives class `0x19` regions cursor sequence `6` with no prompt text.
-  - `check_player_region_interaction()` requires screen-rect overlap, z-range overlap, a matching facing, and `start_enabled != 0` before dispatching the region command chain.
-- Patched the startup stub to mirror that native split.
-  - `HARVEST.SCR` `REGION` records are now parsed into the startup script layer and materialized into room state.
-  - Startup room hover/click now prefers active regions over neutral `NULL_ID` masks, so `PCDOOR` no longer fabricates a prompt while `PCX1` drives the transition cursor and room-change command path.
-  - The startup room loop now keeps a pending region target, waits for player overlap plus facing, and only then dispatches the region command chain, matching the native closeup/door transition shape more closely.
+- Confirmed in Ghidra how native startup-room locomotion is driven.
+  - `run_harvester_main_loop()` does not walk to a screen-space `y`; floor clicks store player target `x = g_cursor_screen_x` and player target `z` derived from `g_cursor_screen_y` via the active room's `min_z`, `max_z`, `max_z_screen_y`, and `min_z_screen_y`.
+  - Region clicks also target `x/z`, not hotspot-bottom `y`. For class `0x19` regions the native path uses the region center, subtracts `10` pixels when that center is left of the player's current horizontal footprint, and chooses target `z` from the region by desired facing: `0 -> max_z`, `3 -> min_z`, otherwise midpoint.
+  - `update_actor_runtime_state()` uses depth-scaled horizontal movement (`round(depth_scale * 8.0f)`) and a separate room-depth step (`z +=/-= 1.0f`, matching the startup room state's `zVelocityStep` default) rather than a flat screen-space speed.
+  - `tick_entity_visual_state()` and the actor update path constrain movement through opaque overlap blockers plus z-range overlap; the native startup walk bounds are not script walk boxes.
+- Patched the startup stub to follow that recovered model.
+  - Startup movement targets now track `x/z`, with screen `y` derived back from room depth when placing the player sprite.
+  - Mouse floor clicks, keyboard movement, and pending region transitions now use depth-scaled horizontal stepping, room-depth stepping, and opaque overlap checks against spawned room objects and animations.
+  - Region transitions now use the recovered native target placement instead of walking to `region.bottom`, which keeps `PCX1`-style exits aligned with the bedroom/closeup handoff path.
 
 ## Next Suggested Action
 
-1. Runtime-test `PCROOM` against DOSBox with the new region path, especially the `PCDOOR` hover, the `PCX1` transition cursor, and the player restore point after returning from closeups.
-2. Compare startup player placement visually in `PCROOM` after confirming the native `START` coordinates above; if it still reads too far left, the remaining mismatch is in runtime sprite metrics or render anchoring rather than script-room setup.
-3. Revisit native locomotion in `update_actor_runtime_state()` and the main loop before changing movement speed or walk bounds.
-  - The current startup stub still uses direct screen-space interpolation and does not mirror the native movement-state machine, blocker history, or z-aware overlap tests.
-  - Any further speed or walk-area changes should come from that recovered actor-state path, not from ad hoc constants.
+1. Runtime-test `PCROOM`, `PCX1`, and `PCDRWR` against DOSBox with the new movement path, focusing on spawn placement, walk speed, blocker stops, and the restore point after returning from closeups.
+2. Recover more of `update_actor_runtime_state()` if obstacle navigation still differs.
+  - The startup stub now matches native target axes, step sizes, and opaque blocking, but it still stops/slide-tests directly instead of reproducing the native blocker-history detour slots at `+0x108c`, `+0x1090`, `+0x1094`, `+0x1098`, and `+0x109c`.
+3. Confirm whether startup room records ever override the default `zVelocityStep = 1.0f`; the current startup parser still relies on the struct default rather than a recovered script field.
 4. Flesh out the room-menu stub now that `Esc` reaches the correct native entry point, especially the per-item behaviors inside `run_main_menu()` such as resume/save/load/options/quit gating.
 5. Parse startup NPC and timer records from `HARVEST.SCR`, then add persistent runtime state for the remaining confirmed exit and interaction opcodes: `SET_NPC`, `SET_TIMER`, and `KILL_TIMER`.
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 7e573ba4ba4..6b5641fe7ad 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -75,7 +75,9 @@ static const char *const kPlayerActorResourcePath = "1:/GRAPHIC/MONSTERS/PC/PC0.
 static const uint32 kPaletteFadeTickMs = 4;
 static const float kPaletteFadeStep = 0.1f;
 static const float kPaletteBrightnessBlack = 0.0f;
-static const float kRoomPlayerMoveStep = 4.0f;
+static const float kRoomPlayerHorizontalMoveBase = 8.0f;
+static const int kRoomRegionTargetXBias = 10;
+static const float kRoomDepthCompareEpsilon = 0.01f;
 static const int kRoomPlayerWalkAnimationRate = 17;
 
 static const byte kIdentTextColor = 0xd3;
@@ -121,7 +123,6 @@ struct StartupRoomPlayerState {
 	int facing = -1;
 	bool hasMoveTarget = false;
 	int targetX = 0;
-	int targetY = 0;
 	float targetZ = 0.0f;
 	bool turnActive = false;
 	int turnTargetFacing = -1;
@@ -282,6 +283,29 @@ static float mapRoomScreenYToDepth(const StartupRoomSetupState &state, int scree
 		(state.roomMinZScreenY - state.roomMaxZScreenY));
 }
 
+static float clampRoomDepth(const StartupRoomSetupState &state, float z) {
+	if (!roomSupportsMovementBand(state))
+		return z;
+
+	return CLIP<float>(z,
+		(float)MIN(state.roomMinZ, state.roomMaxZ),
+		(float)MAX(state.roomMinZ, state.roomMaxZ));
+}
+
+static int mapRoomDepthToScreenY(const StartupRoomSetupState &state, float z) {
+	if (!roomSupportsMovementBand(state))
+		return state.playerSpawnY;
+
+	if (state.roomMaxZ == state.roomMinZ)
+		return clampRoomMovementY(state, state.playerSpawnY);
+
+	const float clampedZ = clampRoomDepth(state, z);
+	const float offset = ((float)state.roomMaxZ - clampedZ) *
+		(float)(state.roomMinZScreenY - state.roomMaxZScreenY) /
+		(float)(state.roomMaxZ - state.roomMinZ);
+	return clampRoomMovementY(state, state.roomMaxZScreenY + roundToInt(offset));
+}
+
 static float computeActorDepthScale(const StartupRoomSetupState &state, float z) {
 	float scale = 1.0f;
 	if (state.roomPerspectiveScale != 0.0f) {
@@ -338,6 +362,140 @@ static int resolveFacingFromMovementDelta(int dx, int dy) {
 	return 0;
 }
 
+static int resolveFacingFromRoomMovement(const StartupRoomSetupState &state,
+		int fromCenterX, float fromZ, int toCenterX, float toZ) {
+	return resolveFacingFromMovementDelta(
+		toCenterX - fromCenterX,
+		mapRoomDepthToScreenY(state, toZ) - mapRoomDepthToScreenY(state, fromZ));
+}
+
+static int computeRoomPlayerHorizontalStep(const StartupRoomSetupState &state, float z) {
+	return MAX<int>(1, roundToInt(computeActorDepthScale(state, z) * kRoomPlayerHorizontalMoveBase));
+}
+
+static float computeRoomPlayerDepthStep(const StartupRoomSetupState &state) {
+	return state.roomZVelocityStep > 0.0f ? state.roomZVelocityStep : 1.0f;
+}
+
+static int stepTowardsInt(int current, int target, int step) {
+	if (step <= 0 || current == target)
+		return current;
+	if (current < target)
+		return MIN(current + step, target);
+	return MAX(current - step, target);
+}
+
+static float stepTowardsFloat(float current, float target, float step) {
+	if (step <= 0.0f || fabsf(target - current) <= kRoomDepthCompareEpsilon)
+		return target;
+	if (current < target)
+		return MIN(current + step, target);
+	return MAX(current - step, target);
+}
+
+static bool doesPlayerOverlapRoomBlocker(HarvesterEngine &engine,
+		const Common::Array<StartupObjectRecord> &sceneObjects,
+		const Common::Array<StartupAnimRecord> &sceneAnimations, const RuntimeEntity &playerEntity) {
+	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
+	if (!runtimeEntities)
+		return false;
+
+	for (const StartupObjectRecord &object : sceneObjects) {
+		const RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(object.objectName);
+		if (entity && playerEntity.overlapsEntity(*entity))
+			return true;
+	}
+
+	for (const StartupAnimRecord &anim : sceneAnimations) {
+		const RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(anim.animName);
+		if (entity && playerEntity.overlapsEntity(*entity))
+			return true;
+	}
+
+	return false;
+}
+
+static bool isPlayerMovementBlocked(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		const Common::Array<StartupObjectRecord> &sceneObjects,
+		const Common::Array<StartupAnimRecord> &sceneAnimations,
+		StartupRoomPlayerState &playerState, int candidateCenterX, float candidateZ) {
+	if (!playerState.entity)
+		return true;
+
+	const int oldCenterX = playerState.centerX;
+	const int oldBottomY = playerState.bottomY;
+	const float oldZ = playerState.z;
+	const int candidateBottomY = mapRoomDepthToScreenY(state, candidateZ);
+	if (!applyRoomActorPlacement(state, *playerState.entity,
+			candidateCenterX, candidateBottomY, candidateZ)) {
+		(void)applyRoomActorPlacement(state, *playerState.entity, oldCenterX, oldBottomY, oldZ);
+		return true;
+	}
+
+	const bool blocked = doesPlayerOverlapRoomBlocker(engine, sceneObjects, sceneAnimations, *playerState.entity);
+	(void)applyRoomActorPlacement(state, *playerState.entity, oldCenterX, oldBottomY, oldZ);
+	return blocked;
+}
+
+static bool tryApplyPlayerMovement(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		const Common::Array<StartupObjectRecord> &sceneObjects,
+		const Common::Array<StartupAnimRecord> &sceneAnimations,
+		StartupRoomPlayerState &playerState, int candidateCenterX, float candidateZ) {
+	if (!playerState.entity)
+		return false;
+
+	candidateCenterX = CLIP<int>(candidateCenterX, 0, 639);
+	candidateZ = clampRoomDepth(state, candidateZ);
+	if (candidateCenterX == playerState.centerX &&
+			fabsf(candidateZ - playerState.z) <= kRoomDepthCompareEpsilon) {
+		return false;
+	}
+	if (isPlayerMovementBlocked(engine, state, sceneObjects, sceneAnimations,
+			playerState, candidateCenterX, candidateZ)) {
+		return false;
+	}
+
+	playerState.centerX = candidateCenterX;
+	playerState.z = candidateZ;
+	playerState.bottomY = mapRoomDepthToScreenY(state, candidateZ);
+	return applyRoomActorPlacement(state, *playerState.entity,
+		playerState.centerX, playerState.bottomY, playerState.z);
+}
+
+static void setPlayerMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
+		int targetX, float targetZ) {
+	playerState.hasMoveTarget = true;
+	playerState.targetX = CLIP<int>(targetX, 0, 639);
+	playerState.targetZ = clampRoomDepth(state, targetZ);
+	debugC(1, kDebugScene,
+		"Harvester: player move target room='%s' current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f)",
+		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
+		playerState.targetX, mapRoomDepthToScreenY(state, playerState.targetZ), (double)playerState.targetZ);
+}
+
+static void setPlayerMoveTargetFromScreenPoint(const StartupRoomSetupState &state,
+		StartupRoomPlayerState &playerState, int targetX, int targetBottomY) {
+	setPlayerMoveTarget(state, playerState, targetX,
+		mapRoomScreenYToDepth(state, clampRoomMovementY(state, targetBottomY)));
+}
+
+static int resolveRegionTargetX(const StartupRegionRecord &region, const StartupRoomPlayerState &playerState) {
+	const Common::Rect bounds(region.left, region.top, region.right + 1, region.bottom + 1);
+	int targetX = bounds.left + bounds.width() / 2;
+	if (playerState.entity && targetX + kRoomRegionTargetXBias < playerState.entity->getScreenRect().right)
+		targetX -= kRoomRegionTargetXBias;
+	return CLIP<int>(targetX, 0, 639);
+}
+
+static float resolveRegionTargetZ(const StartupRegionRecord &region) {
+	if (region.desiredFacing == 0)
+		return (float)region.maxZ;
+	if (region.desiredFacing == 3)
+		return (float)region.minZ;
+
+	return (float)(region.minZ + (region.maxZ - region.minZ) / 2);
+}
+
 static uint32 getRuntimeClockTicks() {
 	return g_system ? (g_system->getMillis() / kRuntimeClockDivisorMs) : 0;
 }
@@ -1033,80 +1191,132 @@ static bool updatePlayerTurnAnimationState(StartupRoomPlayerState &playerState)
 	return true;
 }
 
-static void setPlayerMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
-		int targetX, int targetBottomY) {
-	playerState.hasMoveTarget = true;
-	playerState.targetX = CLIP<int>(targetX, 0, 639);
-	playerState.targetY = clampRoomMovementY(state, targetBottomY);
-	playerState.targetZ = mapRoomScreenYToDepth(state, playerState.targetY);
-	debugC(1, kDebugScene,
-		"Harvester: player move target room='%s' current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f)",
-		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
-		playerState.targetX, playerState.targetY, (double)playerState.targetZ);
-}
-
-static bool stepPlayerMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState) {
+static bool stepPlayerMoveTarget(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		const Common::Array<StartupObjectRecord> &sceneObjects,
+		const Common::Array<StartupAnimRecord> &sceneAnimations,
+		StartupRoomPlayerState &playerState) {
 	if (!playerState.entity || !playerState.hasMoveTarget || playerState.turnActive)
 		return false;
 
-	const float dx = (float)(playerState.targetX - playerState.centerX);
-	const float dy = (float)(playerState.targetY - playerState.bottomY);
-	const float distance = sqrtf(dx * dx + dy * dy);
-	const int moveFacing = distance > 0.0f
-		? resolveFacingFromMovementDelta((int)dx, (int)dy)
-		: playerState.facing;
-	if (distance <= kRoomPlayerMoveStep) {
-		playerState.centerX = playerState.targetX;
-		playerState.bottomY = playerState.targetY;
+	if (playerState.centerX == playerState.targetX &&
+			fabsf(playerState.z - playerState.targetZ) <= kRoomDepthCompareEpsilon) {
+		playerState.hasMoveTarget = false;
+		return setPlayerIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
+	}
+
+	const int previousCenterX = playerState.centerX;
+	const float previousZ = playerState.z;
+	const int targetBottomY = mapRoomDepthToScreenY(state, playerState.targetZ);
+	const int moveFacing = resolveFacingFromMovementDelta(
+		playerState.targetX - playerState.centerX, targetBottomY - playerState.bottomY);
+	const int horizontalStep = computeRoomPlayerHorizontalStep(state, playerState.z);
+	const float depthStep = computeRoomPlayerDepthStep(state);
+	const int candidateCenterX = stepTowardsInt(playerState.centerX, playerState.targetX, horizontalStep);
+	const float candidateZ = stepTowardsFloat(playerState.z, playerState.targetZ, depthStep);
+
+	bool moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+		playerState, candidateCenterX, candidateZ);
+	if (!moved && candidateCenterX != previousCenterX) {
+		moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+			playerState, candidateCenterX, previousZ);
+	}
+	if (!moved && fabsf(candidateZ - previousZ) > kRoomDepthCompareEpsilon) {
+		moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+			playerState, previousCenterX, candidateZ);
+	}
+	if (!moved) {
+		playerState.hasMoveTarget = false;
+		return setPlayerIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : moveFacing);
+	}
+
+	const int actualFacing = resolveFacingFromRoomMovement(
+		state, previousCenterX, previousZ, playerState.centerX, playerState.z);
+	(void)setPlayerWalkAnimation(playerState, actualFacing);
+	if (playerState.centerX == playerState.targetX &&
+			fabsf(playerState.z - playerState.targetZ) <= kRoomDepthCompareEpsilon) {
+		playerState.bottomY = mapRoomDepthToScreenY(state, playerState.targetZ);
 		playerState.z = playerState.targetZ;
 		playerState.hasMoveTarget = false;
-		(void)setPlayerIdleAnimation(playerState, moveFacing);
-	} else {
-		(void)setPlayerWalkAnimation(playerState, moveFacing);
-		playerState.centerX += roundToInt((dx / distance) * kRoomPlayerMoveStep);
-		playerState.bottomY += roundToInt((dy / distance) * kRoomPlayerMoveStep);
-		playerState.bottomY = clampRoomMovementY(state, playerState.bottomY);
-		playerState.z = mapRoomScreenYToDepth(state, playerState.bottomY);
-	}
-	const bool placed = applyRoomActorPlacement(state, *playerState.entity,
-		playerState.centerX, playerState.bottomY, playerState.z);
+		(void)setPlayerIdleAnimation(playerState, actualFacing);
+	}
 	debugC(playerState.hasMoveTarget ? 2 : 1, kDebugScene,
-		"Harvester: player move step room='%s' pos=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f) facing=%d frame=%d active=%d placed=%d",
+		"Harvester: player move step room='%s' pos=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f) facing=%d frame=%d active=%d moved=%d",
 		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
-		playerState.targetX, playerState.targetY, (double)playerState.targetZ,
-		playerState.facing, playerState.entity->getCurrentFrame(), playerState.hasMoveTarget, placed);
-	return placed;
+		playerState.targetX, mapRoomDepthToScreenY(state, playerState.targetZ), (double)playerState.targetZ,
+		playerState.facing, playerState.entity->getCurrentFrame(), playerState.hasMoveTarget, moved);
+	return true;
 }
 
-static bool stepPlayerKeyboardMovement(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
-		bool moveLeft, bool moveRight, bool moveUp, bool moveDown) {
+static bool stepPlayerKeyboardMovement(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		const Common::Array<StartupObjectRecord> &sceneObjects,
+		const Common::Array<StartupAnimRecord> &sceneAnimations,
+		StartupRoomPlayerState &playerState, bool moveLeft, bool moveRight, bool moveUp, bool moveDown) {
 	if (!playerState.entity || !roomSupportsMovementBand(state))
 		return false;
 
-	const int dx = (moveRight ? 1 : 0) - (moveLeft ? 1 : 0);
-	const int dy = (moveDown ? 1 : 0) - (moveUp ? 1 : 0);
-	if (dx == 0 && dy == 0)
+	const int horizontalInput = (moveRight ? 1 : 0) - (moveLeft ? 1 : 0);
+	const int verticalInput = (moveDown ? 1 : 0) - (moveUp ? 1 : 0);
+	if (horizontalInput == 0 && verticalInput == 0)
 		return false;
 	if (playerState.turnActive)
 		return false;
 
-	const int desiredFacing = resolveFacingFromMovementDelta(dx, dy);
+	const int previousCenterX = playerState.centerX;
+	const float previousZ = playerState.z;
+	const int horizontalStep = computeRoomPlayerHorizontalStep(state, playerState.z);
+	const float depthStep = computeRoomPlayerDepthStep(state);
+	int candidateCenterX = CLIP<int>(playerState.centerX + horizontalInput * horizontalStep, 0, 639);
+	float candidateZ = playerState.z;
+	if (verticalInput != 0) {
+		const float positiveZ = clampRoomDepth(state, playerState.z + depthStep);
+		const float negativeZ = clampRoomDepth(state, playerState.z - depthStep);
+		const int positiveY = mapRoomDepthToScreenY(state, positiveZ);
+		const int negativeY = mapRoomDepthToScreenY(state, negativeZ);
+		if (verticalInput < 0) {
+			if (positiveY < playerState.bottomY)
+				candidateZ = positiveZ;
+			else if (negativeY < playerState.bottomY)
+				candidateZ = negativeZ;
+		} else {
+			if (positiveY > playerState.bottomY)
+				candidateZ = positiveZ;
+			else if (negativeY > playerState.bottomY)
+				candidateZ = negativeZ;
+		}
+	}
+	if (candidateCenterX == playerState.centerX &&
+			fabsf(candidateZ - playerState.z) <= kRoomDepthCompareEpsilon) {
+		return false;
+	}
+
+	const int desiredFacing = resolveFacingFromRoomMovement(
+		state, playerState.centerX, playerState.z, candidateCenterX, candidateZ);
 	if (desiredFacing != playerState.facing && startPlayerTurnAnimation(playerState, desiredFacing))
 		return true;
 
 	playerState.hasMoveTarget = false;
-	(void)setPlayerWalkAnimation(playerState, desiredFacing);
-	playerState.centerX = CLIP<int>(playerState.centerX + roundToInt(dx * kRoomPlayerMoveStep), 0, 639);
-	playerState.bottomY = clampRoomMovementY(state,
-		playerState.bottomY + roundToInt(dy * kRoomPlayerMoveStep));
-	playerState.z = mapRoomScreenYToDepth(state, playerState.bottomY);
-	const bool placed = applyRoomActorPlacement(state, *playerState.entity,
-		playerState.centerX, playerState.bottomY, playerState.z);
+	bool moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+		playerState, candidateCenterX, candidateZ);
+	if (!moved && candidateCenterX != previousCenterX) {
+		moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+			playerState, candidateCenterX, previousZ);
+	}
+	if (!moved && fabsf(candidateZ - previousZ) > kRoomDepthCompareEpsilon) {
+		moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+			playerState, previousCenterX, candidateZ);
+	}
+	if (!moved)
+		return false;
+
+	const int actualFacing = resolveFacingFromRoomMovement(
+		state, previousCenterX, previousZ, playerState.centerX, playerState.z);
+	(void)setPlayerWalkAnimation(playerState, actualFacing);
 	debugC(2, kDebugScene,
-		"Harvester: player keyboard move room='%s' delta=(%d,%d) pos=(%d,%d,z=%.2f) facing=%d frame=%d placed=%d",
-		state.roomName.c_str(), dx, dy, playerState.centerX, playerState.bottomY, (double)playerState.z,
-		playerState.facing, playerState.entity->getCurrentFrame(), placed);
-	return placed;
+		"Harvester: player keyboard move room='%s' input=(%d,%d) pos=(%d,%d,z=%.2f) facing=%d frame=%d moved=%d",
+		state.roomName.c_str(), horizontalInput, verticalInput,
+		playerState.centerX, playerState.bottomY, (double)playerState.z,
+		playerState.facing, playerState.entity->getCurrentFrame(), moved);
+	return true;
 }
 
 static void positionPlayerIdleAnimationEntity(const StartupRoomSetupState &state,
@@ -1384,9 +1594,9 @@ static void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneR
 				probePlayer.bottomY = scene.state.playerSpawnY;
 				probePlayer.z = (float)scene.state.playerSpawnZ;
 				probePlayer.facing = scene.state.playerFacing;
-				setPlayerMoveTarget(scene.state, probePlayer, floorProbe.x, floorProbe.y);
+				setPlayerMoveTargetFromScreenPoint(scene.state, probePlayer, floorProbe.x, floorProbe.y);
 				for (int i = 0; i < 32 && probePlayer.hasMoveTarget; ++i)
-					(void)stepPlayerMoveTarget(scene.state, probePlayer);
+					(void)stepPlayerMoveTarget(engine, scene.state, scene.sceneObjects, scene.sceneAnimations, probePlayer);
 
 				const Common::Rect movedRect = player->getScreenRect();
 				debugC(1, kDebugGeneral,
@@ -2055,8 +2265,8 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 		if (doesPlayerOverlapRegion(*playerState.entity, region))
 			return;
 
-		int targetBottomY = region.bottom;
-		setPlayerMoveTarget(scene.state, playerState, (region.left + region.right) / 2, targetBottomY);
+		setPlayerMoveTarget(scene.state, playerState,
+			resolveRegionTargetX(region, playerState), resolveRegionTargetZ(region));
 	};
 	auto runRegionInteraction = [&](const StartupRegionRecord &region) -> Common::Error {
 		StartupInteractionResult interaction;
@@ -2288,7 +2498,7 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 				if (!clickedObject) {
 					pendingRegionName.clear();
 					if (hoverState.cursorSequence == kCursorSequenceWalk && playerState.entity) {
-						setPlayerMoveTarget(scene.state, playerState, _mousePos.x, _mousePos.y);
+						setPlayerMoveTargetFromScreenPoint(scene.state, playerState, _mousePos.x, _mousePos.y);
 						needsRedraw = true;
 					}
 					break;
@@ -2434,10 +2644,12 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 			needsRedraw = true;
 
 		if (!idleState.active && !idleState.exiting) {
-			if (stepPlayerKeyboardMovement(scene.state, playerState, moveLeft, moveRight, moveUp, moveDown)) {
+			if (stepPlayerKeyboardMovement(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
+					playerState, moveLeft, moveRight, moveUp, moveDown)) {
 				notePlayerActivity(idleState);
 				needsRedraw = true;
-			} else if (stepPlayerMoveTarget(scene.state, playerState)) {
+			} else if (stepPlayerMoveTarget(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
+					playerState)) {
 				notePlayerActivity(idleState);
 				needsRedraw = true;
 			} else if (!moveLeft && !moveRight && !moveUp && !moveDown && !playerState.hasMoveTarget &&


Commit: f53ed83c50cf382e5f08527bcad3f400fa146fe5
    https://github.com/scummvm/scummvm/commit/f53ed83c50cf382e5f08527bcad3f400fa146fe5
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:45+02:00

Commit Message:
HARVESTER: Fix startup idle animation anchoring

Changed paths:
    TRACKER.md
    engines/harvester/startup_flow.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 642fd57589c..24df360a2fa 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,21 +15,20 @@
 
 ## Last Confirmed Action
 
-- Confirmed in Ghidra how native startup-room locomotion is driven.
-  - `run_harvester_main_loop()` does not walk to a screen-space `y`; floor clicks store player target `x = g_cursor_screen_x` and player target `z` derived from `g_cursor_screen_y` via the active room's `min_z`, `max_z`, `max_z_screen_y`, and `min_z_screen_y`.
-  - Region clicks also target `x/z`, not hotspot-bottom `y`. For class `0x19` regions the native path uses the region center, subtracts `10` pixels when that center is left of the player's current horizontal footprint, and chooses target `z` from the region by desired facing: `0 -> max_z`, `3 -> min_z`, otherwise midpoint.
-  - `update_actor_runtime_state()` uses depth-scaled horizontal movement (`round(depth_scale * 8.0f)`) and a separate room-depth step (`z +=/-= 1.0f`, matching the startup room state's `zVelocityStep` default) rather than a flat screen-space speed.
-  - `tick_entity_visual_state()` and the actor update path constrain movement through opaque overlap blockers plus z-range overlap; the native startup walk bounds are not script walk boxes.
-- Patched the startup stub to follow that recovered model.
-  - Startup movement targets now track `x/z`, with screen `y` derived back from room depth when placing the player sprite.
-  - Mouse floor clicks, keyboard movement, and pending region transitions now use depth-scaled horizontal stepping, room-depth stepping, and opaque overlap checks against spawned room objects and animations.
-  - Region transitions now use the recovered native target placement instead of walking to `region.bottom`, which keeps `PCX1`-style exits aligned with the bedroom/closeup handoff path.
+- Confirmed in Ghidra how the native player idle overlay is spawned and timed.
+  - `run_harvester_main_loop()` waits until `max(activity_timestamp, reset_timestamp) + 3000`, turns the player to facing `0`, then spawns `IDLE_ANIM` from `1:\GRAPHIC\ROOMANIM\PCLOUN02.ABM` at the player render entity's current screen `x`, current screen `y + 4`, and current `z`.
+  - The native idle overlay runs at animation rate `0x1e` (`30`), loops by jumping from frame `0xb2` back to `0x0f`, and exits by running from `0xb2` through `0xbf` before removing the overlay and showing the player entity again.
+  - These timings match the current startup constants; the remaining placement-sensitive part was how the overlay anchor was derived from the rendered player position.
+- Patched the startup stub to mirror that placement more closely.
+  - The startup idle overlay now anchors to the player entity's current rendered screen rect (`left/top + 4`) instead of the entity's pre-offset internal coordinates, which avoids the visible screen drift caused by ABM frame offsets moving under a fixed internal origin.
+  - The startup idle timing stays at the native `30` rate with the existing `0x0f`/`0xb2`/`0xbf` frame gates.
 
 ## Next Suggested Action
 
-1. Runtime-test `PCROOM`, `PCX1`, and `PCDRWR` against DOSBox with the new movement path, focusing on spawn placement, walk speed, blocker stops, and the restore point after returning from closeups.
-2. Recover more of `update_actor_runtime_state()` if obstacle navigation still differs.
-  - The startup stub now matches native target axes, step sizes, and opaque blocking, but it still stops/slide-tests directly instead of reproducing the native blocker-history detour slots at `+0x108c`, `+0x1090`, `+0x1094`, `+0x1098`, and `+0x109c`.
-3. Confirm whether startup room records ever override the default `zVelocityStep = 1.0f`; the current startup parser still relies on the struct default rather than a recovered script field.
-4. Flesh out the room-menu stub now that `Esc` reaches the correct native entry point, especially the per-item behaviors inside `run_main_menu()` such as resume/save/load/options/quit gating.
-5. Parse startup NPC and timer records from `HARVEST.SCR`, then add persistent runtime state for the remaining confirmed exit and interaction opcodes: `SET_NPC`, `SET_TIMER`, and `KILL_TIMER`.
+1. Recover the render/entity runtime structure around `spawn_scaled_abm_entity_from_resource()`, `hide_entity_visual()`, `update_render_entity_screen_position()`, `tick_entity_visual_state()`, and `update_actor_runtime_state()`.
+  - That struct is now the highest-leverage missing piece: it governs actor screen anchoring, frame offsets, hitmasks, z overlap, blocker-history detours, and the remaining startup/combat avatar behavior that still reads as raw offsets in Ghidra.
+2. Runtime-test `PCROOM`, `PCX1`, and `PCDRWR` against DOSBox with the new movement and idle-overlay path, focusing on spawn placement, walk speed, blocker stops, idle stability, and the restore point after returning from closeups.
+3. Recover more of `update_actor_runtime_state()` if obstacle navigation still differs.
+  - The startup stub now matches native target axes, step sizes, opaque blocking, and idle-overlay placement, but it still stops/slide-tests directly instead of reproducing the native blocker-history detour slots at `+0x108c`, `+0x1090`, `+0x1094`, `+0x1098`, and `+0x109c`.
+4. Confirm whether startup room records ever override the default `zVelocityStep = 1.0f`; the current startup parser still relies on the struct default rather than a recovered script field.
+5. Flesh out the room-menu stub now that `Esc` reaches the correct native entry point, especially the per-item behaviors inside `run_main_menu()` such as resume/save/load/options/quit gating.
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 6b5641fe7ad..051ec11ed63 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -1324,9 +1324,10 @@ static void positionPlayerIdleAnimationEntity(const StartupRoomSetupState &state
 	if (!playerState.entity)
 		return;
 
+	const Common::Rect playerRect = playerState.entity->getScreenRect();
 	idleEntity.setAnchorMode(kRuntimeEntityAnchorTopLeft);
 	idleEntity.setDepthScale(computeActorDepthScale(state, playerState.z));
-	idleEntity.setPosition(playerState.entity->getX(), playerState.entity->getY() + kRoomPlayerIdleYOffset, playerState.z);
+	idleEntity.setPosition(playerRect.left, playerRect.top + kRoomPlayerIdleYOffset, playerState.z);
 }
 
 static RuntimeEntity *ensurePlayerIdleAnimationEntity(HarvesterEngine &engine,
@@ -1341,7 +1342,8 @@ static RuntimeEntity *ensurePlayerIdleAnimationEntity(HarvesterEngine &engine,
 
 	idleState.entity = runtimeEntities->spawnSceneAnimationEntity(
 		kPlayerIdleAnimationEntityName, kPlayerIdleAnimationResourcePath,
-		Common::Point(playerState.entity->getX(), playerState.entity->getY() + kRoomPlayerIdleYOffset),
+		Common::Point(playerState.entity->getScreenRect().left,
+			playerState.entity->getScreenRect().top + kRoomPlayerIdleYOffset),
 		playerState.z, 0, false, false, false, false, false);
 	if (!idleState.entity)
 		return nullptr;


Commit: b3f49749c6c1e0b8b3d2c556b33bdf8fab433d2d
    https://github.com/scummvm/scummvm/commit/b3f49749c6c1e0b8b3d2c556b33bdf8fab433d2d
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:45+02:00

Commit Message:
HARVESTER: Document render entity runtime

Changed paths:
    ARCHITECTURE.md
    TRACKER.md


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index fe74158ba8d..c7fe94d6eb4 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -174,6 +174,42 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `blit_vesa_banked_rect` is the shared rectangle blitter used by the VESA mode records.
     - `select_vesa_bank_for_scanline` adjusts the active bank/window based on a scanline index.
 
+## Render Entity Runtime
+
+**Confidence:** High
+
+**Evidence**
+- `spawn_scaled_abm_entity_from_resource`, `attach_abm_resource_to_entity`, `show_entity_visual`, `hide_entity_visual`, `rescale_entity_sprite_for_depth`, `tick_entity_visual_state`, and `update_actor_runtime_state` all operate on the same `0x11bc` runtime record.
+- Recovered helper types now line up with the code paths that materialize and animate those entities:
+  - `BitmapBuffer` is the 12-byte `{ width, height, pixels }` bitmap header allocated by `attach_abm_resource_to_entity` and reused by `show_entity_visual` / `rescale_entity_sprite_for_depth`.
+  - `AbmFrameHeader` matches the packed per-frame ABM prefix consumed by `attach_abm_resource_to_entity` and `show_entity_visual`.
+  - `DirtyRectNode` is the 20-byte linked-list node used by the render-list dirty-rect merge path.
+  - `ActorWaypoint[10]` is the fixed 10-entry waypoint array starting at `RenderEntityRuntime + 0x10ac`.
+- Switching the simple entity helpers to Borland `__fastcall` makes the shared entity pointer recover cleanly in decompilation, which confirms that:
+  - `tick_entity_visual_state` writes the current opaque overlap blocker at `+0x109c`
+  - `update_actor_runtime_state` uses four directional blocker-history slots at `+0x108c`, `+0x1090`, `+0x1094`, and `+0x1098`
+  - `attach_abm_resource_to_entity` builds a `1024`-entry frame-header table at `+0x5c`
+
+**Key Functions**
+- `spawn_scaled_abm_entity_from_resource`
+- `attach_abm_resource_to_entity`
+- `show_entity_visual`
+- `hide_entity_visual`
+- `rescale_entity_sprite_for_depth`
+- `tick_entity_visual_state`
+- `update_actor_runtime_state`
+
+**Key Data**
+- `RenderEntityRuntime`
+- `BitmapBuffer`
+- `AbmFrameHeader`
+- `DirtyRectNode`
+- `ActorWaypoint[10]`
+- `g_render_entity_list`
+
+**Notes**
+- The current typed runtime record cleanly covers render placement, animation timing, waypoint storage, and blocker-history detours.
+- The actor-only tail from `+0x1134` through `+0x118e` is still only partially typed, but it now clearly contains combat/use-state sound slots and facing-bank flags rather than generic render state.
 ## Room And Event Flow
 
 - `room_setup` at `0x73540` is confirmed by in-binary debug strings:
diff --git a/TRACKER.md b/TRACKER.md
index 24df360a2fa..42d391dc0ce 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,7 +3,7 @@
 ## Current Focus
 
 - Ghidra-backed startup engine alignment from `run_harvester_main_loop`
-- Match the native startup room idle/movement behavior before expanding further subsystem coverage
+- Use the recovered render/entity runtime to remove raw actor offsets from the remaining startup/combat movement paths
 - Keep symbol recovery and engine changes tied to direct script data, call sites, and visible side effects
 
 ## Progress
@@ -15,20 +15,29 @@
 
 ## Last Confirmed Action
 
-- Confirmed in Ghidra how the native player idle overlay is spawned and timed.
-  - `run_harvester_main_loop()` waits until `max(activity_timestamp, reset_timestamp) + 3000`, turns the player to facing `0`, then spawns `IDLE_ANIM` from `1:\GRAPHIC\ROOMANIM\PCLOUN02.ABM` at the player render entity's current screen `x`, current screen `y + 4`, and current `z`.
-  - The native idle overlay runs at animation rate `0x1e` (`30`), loops by jumping from frame `0xb2` back to `0x0f`, and exits by running from `0xb2` through `0xbf` before removing the overlay and showing the player entity again.
-  - These timings match the current startup constants; the remaining placement-sensitive part was how the overlay anchor was derived from the rendered player position.
-- Patched the startup stub to mirror that placement more closely.
-  - The startup idle overlay now anchors to the player entity's current rendered screen rect (`left/top + 4`) instead of the entity's pre-offset internal coordinates, which avoids the visible screen drift caused by ABM frame offsets moving under a fixed internal origin.
-  - The startup idle timing stays at the native `30` rate with the existing `0x0f`/`0xb2`/`0xbf` frame gates.
+- Recovered the shared render/entity runtime layout in Ghidra around `spawn_scaled_abm_entity_from_resource()`, `attach_abm_resource_to_entity()`, `hide_entity_visual()`, `tick_entity_visual_state()`, and `update_actor_runtime_state()`.
+  - Confirmed helper types:
+    - `BitmapBuffer` is the 12-byte runtime bitmap header `{ width, height, pixels }`.
+    - `AbmFrameHeader` is the packed ABM frame header prefix `{ x_offset, y_offset, width, height, compressed_flag, encoded_size }`.
+    - `DirtyRectNode` is the 20-byte dirty-rect list node `{ left, top, right, bottom, next }`.
+    - `ActorWaypoint[10]` is the 10-entry actor waypoint array at `RenderEntityRuntime + 0x10ac`.
+  - Confirmed `RenderEntityRuntime` fields now typed in Ghidra include:
+    - bitmap pointers and render placement at `+0x00 .. +0x48`
+    - animation timing/state at `+0x105c .. +0x1084`
+    - directional blocker history slots at `+0x108c`, `+0x1090`, `+0x1094`, `+0x1098`
+    - the current opaque overlap blocker at `+0x109c`
+    - movement/waypoint state at `+0x10a0 .. +0x1124`
+  - Switching the simple entity helpers to Borland `__fastcall` made the entity pointer resolve correctly in decompilation, which in turn exposed the blocker-history detour logic directly inside `update_actor_runtime_state()`.
+- No engine code changed in this pass.
+  - This was a Ghidra-first recovery pass to establish the runtime structure before any further startup/combat movement changes.
 
 ## Next Suggested Action
 
-1. Recover the render/entity runtime structure around `spawn_scaled_abm_entity_from_resource()`, `hide_entity_visual()`, `update_render_entity_screen_position()`, `tick_entity_visual_state()`, and `update_actor_runtime_state()`.
-  - That struct is now the highest-leverage missing piece: it governs actor screen anchoring, frame offsets, hitmasks, z overlap, blocker-history detours, and the remaining startup/combat avatar behavior that still reads as raw offsets in Ghidra.
-2. Runtime-test `PCROOM`, `PCX1`, and `PCDRWR` against DOSBox with the new movement and idle-overlay path, focusing on spawn placement, walk speed, blocker stops, idle stability, and the restore point after returning from closeups.
-3. Recover more of `update_actor_runtime_state()` if obstacle navigation still differs.
-  - The startup stub now matches native target axes, step sizes, opaque blocking, and idle-overlay placement, but it still stops/slide-tests directly instead of reproducing the native blocker-history detour slots at `+0x108c`, `+0x1090`, `+0x1094`, `+0x1098`, and `+0x109c`.
+1. Recover `update_render_entity_screen_position()` and `set_entity_screen_position()` precisely enough to eliminate the remaining raw screen-anchor/dirty-rect offsets.
+  - The helper cluster is now on the right shared runtime struct, but those position-update helpers still have calling/storage oddities that prevent the decompiler from expressing all arguments cleanly.
+2. Continue typing the actor-only tail of `RenderEntityRuntime` from `+0x1134` through `+0x118e`.
+  - That range now clearly holds combat/use-state sample slots, attack timing, and facing-bank flags, and typing it would clean up both combat logic and the remaining locomotion detour states.
+3. Runtime-test `PCROOM`, `PCX1`, and `PCDRWR` against DOSBox with the recovered blocker-history model in mind.
+  - The startup stub can now be compared against confirmed native detour states instead of guessed movement constants.
 4. Confirm whether startup room records ever override the default `zVelocityStep = 1.0f`; the current startup parser still relies on the struct default rather than a recovered script field.
 5. Flesh out the room-menu stub now that `Esc` reaches the correct native entry point, especially the per-item behaviors inside `run_main_menu()` such as resume/save/load/options/quit gating.


Commit: 2fc1f1066b43cc740846f08278394d1329614bf5
    https://github.com/scummvm/scummvm/commit/2fc1f1066b43cc740846f08278394d1329614bf5
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:45+02:00

Commit Message:
HARVESTER: Use startup font for ESC room menu

Changed paths:
  A engines/harvester/cft_font.cpp
  A engines/harvester/cft_font.h
    engines/harvester/module.mk
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_flow.h


diff --git a/engines/harvester/cft_font.cpp b/engines/harvester/cft_font.cpp
new file mode 100644
index 00000000000..00aaf351e23
--- /dev/null
+++ b/engines/harvester/cft_font.cpp
@@ -0,0 +1,159 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/cft_font.h"
+
+#include "common/algorithm.h"
+#include "common/endian.h"
+#include "graphics/surface.h"
+
+namespace Harvester {
+
+namespace {
+
+static const uint32 kCftFontHeightOffset = 0x40;
+static const uint32 kCftStartTableOffset = 0x48;
+static const uint32 kCftWidthTableOffset = 0x248;
+static const uint32 kCftGlyphCount = 128;
+
+static uint32 normalizeGlyphChar(uint32 chr) {
+	if (chr >= 'a' && chr <= 'z')
+		return chr - ('a' - 'A');
+
+	return chr;
+}
+
+} // End of anonymous namespace
+
+HarvesterCftFont::HarvesterCftFont(const CftFontResource &resource, const char *glyphOrder, int spaceWidth)
+		: _resource(resource) {
+	Common::Array<GlyphSlice> atlasGlyphs;
+
+	if (_resource.header.size() >= kCftWidthTableOffset + kCftGlyphCount * 2) {
+		const byte *header = _resource.header.data();
+		const int headerFontHeight = READ_LE_UINT16(header + kCftFontHeightOffset);
+		if (headerFontHeight > 0)
+			_fontHeight = MIN<int>(headerFontHeight, _resource.atlasHeight);
+		if (_fontHeight <= 0)
+			_fontHeight = _resource.atlasHeight;
+
+		for (uint i = 0; i < kCftGlyphCount; ++i) {
+			GlyphSlice glyph;
+			glyph.x = READ_LE_UINT16(header + kCftStartTableOffset + i * 2);
+			glyph.width = READ_LE_UINT16(header + kCftWidthTableOffset + i * 2);
+			if (glyph.width <= 0 || glyph.x >= (int)_resource.atlasWidth)
+				continue;
+
+			glyph.width = MIN<int>(glyph.width, (int)_resource.atlasWidth - glyph.x);
+			glyph.valid = glyph.width > 0;
+			if (!glyph.valid)
+				continue;
+
+			bool duplicate = false;
+			for (const GlyphSlice &existing : atlasGlyphs) {
+				if (existing.x == glyph.x && existing.width == glyph.width) {
+					duplicate = true;
+					break;
+				}
+			}
+			if (!duplicate)
+				atlasGlyphs.push_back(glyph);
+		}
+	}
+
+	Common::sort(atlasGlyphs.begin(), atlasGlyphs.end(), [](const GlyphSlice &left, const GlyphSlice &right) {
+		return left.x < right.x;
+	});
+
+	const Common::String glyphOrderString(glyphOrder ? glyphOrder : "");
+	const uint glyphCount = MIN<uint>(glyphOrderString.size(), atlasGlyphs.size());
+	for (uint i = 0; i < glyphCount; ++i) {
+		const byte chr = glyphOrderString[i];
+		if (chr == ' ')
+			continue;
+
+		_glyphs[chr] = atlasGlyphs[i];
+		_maxCharWidth = MAX(_maxCharWidth, _glyphs[chr].width);
+	}
+
+	if (spaceWidth > 0) {
+		_spaceWidth = spaceWidth;
+	} else if (_glyphs['I'].valid) {
+		_spaceWidth = MAX<int>(1, _glyphs['I'].width / 2);
+	} else {
+		_spaceWidth = MAX<int>(1, _maxCharWidth / 2);
+	}
+}
+
+int HarvesterCftFont::getCharWidth(uint32 chr) const {
+	if (chr == ' ')
+		return _spaceWidth;
+
+	const GlyphSlice *glyph = findGlyph(chr);
+	return glyph ? glyph->width : _spaceWidth;
+}
+
+void HarvesterCftFont::drawChar(Graphics::Surface *dst, uint32 chr, int x, int y, uint32 color) const {
+	if (!dst || chr == ' ')
+		return;
+
+	const GlyphSlice *glyph = findGlyph(chr);
+	if (!glyph)
+		return;
+
+	const int drawHeight = MIN<int>(_fontHeight, _resource.atlasHeight);
+	for (int row = 0; row < drawHeight; ++row) {
+		const int dstY = y + row;
+		if (dstY < 0 || dstY >= dst->h)
+			continue;
+
+		const byte *srcRow = _resource.atlasPixels.data() + row * _resource.atlasWidth + glyph->x;
+		for (int col = 0; col < glyph->width; ++col) {
+			const int dstX = x + col;
+			if (dstX < 0 || dstX >= dst->w || srcRow[col] == 0)
+				continue;
+
+			switch (dst->format.bytesPerPixel) {
+			case 1:
+				*((byte *)dst->getBasePtr(dstX, dstY)) = color;
+				break;
+			case 2:
+				*((uint16 *)dst->getBasePtr(dstX, dstY)) = color;
+				break;
+			case 4:
+				*((uint32 *)dst->getBasePtr(dstX, dstY)) = color;
+				break;
+			default:
+				break;
+			}
+		}
+	}
+}
+
+const HarvesterCftFont::GlyphSlice *HarvesterCftFont::findGlyph(uint32 chr) const {
+	if (chr >= ARRAYSIZE(_glyphs))
+		return nullptr;
+
+	const GlyphSlice &glyph = _glyphs[normalizeGlyphChar(chr)];
+	return glyph.valid ? &glyph : nullptr;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/cft_font.h b/engines/harvester/cft_font.h
new file mode 100644
index 00000000000..ee3fdfb6b12
--- /dev/null
+++ b/engines/harvester/cft_font.h
@@ -0,0 +1,60 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_CFT_FONT_H
+#define HARVESTER_CFT_FONT_H
+
+#include "graphics/font.h"
+#include "harvester/startup_text.h"
+
+namespace Harvester {
+
+class HarvesterCftFont : public Graphics::Font {
+public:
+	HarvesterCftFont(const CftFontResource &resource, const char *glyphOrder, int spaceWidth = 0);
+
+	bool isValid() const { return _maxCharWidth > 0 && _fontHeight > 0; }
+
+	int getFontHeight() const override { return _fontHeight; }
+	Common::String getFontName() const override { return _resource.name; }
+	int getMaxCharWidth() const override { return _maxCharWidth; }
+	int getCharWidth(uint32 chr) const override;
+	void drawChar(Graphics::Surface *dst, uint32 chr, int x, int y, uint32 color) const override;
+
+private:
+	struct GlyphSlice {
+		int x = 0;
+		int width = 0;
+		bool valid = false;
+	};
+
+	const GlyphSlice *findGlyph(uint32 chr) const;
+
+	const CftFontResource &_resource;
+	GlyphSlice _glyphs[256];
+	int _fontHeight = 0;
+	int _spaceWidth = 0;
+	int _maxCharWidth = 0;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_CFT_FONT_H
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 1262cb1ea4e..6d20d63ac11 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -1,6 +1,7 @@
 MODULE := engines/harvester
 
 MODULE_OBJS = \
+	cft_font.o \
 	harvester.o \
 	console.o \
 	fst_player.o \
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 051ec11ed63..72e6c362c1a 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -33,12 +33,14 @@
 #include "graphics/font.h"
 #include "graphics/fontman.h"
 #include "graphics/framelimiter.h"
+#include "harvester/cft_font.h"
 #include "harvester/detection.h"
 #include "harvester/harvester.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
 #include "harvester/startup_art.h"
 #include "harvester/startup_script.h"
+#include "harvester/startup_text.h"
 
 namespace Harvester {
 
@@ -65,6 +67,10 @@ static const int kQuickTipTextWidth = 280;
 
 static const int kMenuStartY = 100;
 static const int kMenuLineSpacing = 28;
+static const int kNativeMenuInsetX = 24;
+static const int kNativeMenuInsetTop = 72;
+static const int kNativeMenuInsetBottom = 24;
+static const int kNativeMenuLineGap = 6;
 static const int kCursorSequence7 = 7;
 static const int kIdentTextboxX = 85;
 static const int kIdentTextboxY = 177;
@@ -88,6 +94,7 @@ static const byte kShadowColor = 0;
 static const byte kPanelFillColor = 1;
 static const byte kQuickTipActionColor = 0xc3;
 static const byte kRoomPromptColor = 0xce;
+static const char *const kHarvfontGlyphOrder = "ABCDEFGHIJKLMNOPQRSTUVWXYZ-1234567890";
 
 static const int kCursorSequenceWalk = 0;
 static const int kCursorSequenceExamine = 1;
@@ -765,6 +772,47 @@ static Common::Rect getInventoryPanelBounds(const StartupArt &art) {
 		kInventoryX + (int)bitmap.width, kInventoryY + (int)bitmap.height);
 }
 
+static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
+	const StartupText *startupText = engine.getStartupText();
+	if (!startupText || !fontName)
+		return nullptr;
+
+	for (const CftFontResource &font : startupText->getFonts()) {
+		if (font.name.equalsIgnoreCase(fontName))
+			return &font;
+	}
+
+	return nullptr;
+}
+
+static void drawStartupMenuBackdrop(Graphics::Screen &screen, const StartupArt &art) {
+	screen.setPalette(art.getWaitPalette());
+	screen.fillRect(screen.getBounds(), 0);
+	blitBitmap(screen, art.getInventoryBitmap(), kInventoryX, kInventoryY);
+	blitBitmap(screen, art.getLogoBitmap(), kLogoX, kLogoY);
+}
+
+static Common::Rect getNativeMenuTextBounds(const StartupArt &art) {
+	const Common::Rect inventoryBounds = getInventoryPanelBounds(art);
+	return Common::Rect(
+		inventoryBounds.left + kNativeMenuInsetX,
+		inventoryBounds.top + kNativeMenuInsetTop,
+		inventoryBounds.right - kNativeMenuInsetX,
+		inventoryBounds.bottom - kNativeMenuInsetBottom);
+}
+
+static int getNativeMenuFirstLineY(const Graphics::Font &font, const Common::Rect &bounds,
+		uint itemCount, bool hasStatusLine) {
+	if (itemCount == 0)
+		return bounds.top;
+
+	const int lineSpacing = font.getFontHeight() + kNativeMenuLineGap;
+	const int itemBlockHeight = font.getFontHeight() + ((int)itemCount - 1) * lineSpacing;
+	const int reservedStatusHeight = hasStatusLine ? font.getFontHeight() + kNativeMenuLineGap : 0;
+	const int availableHeight = MAX(0, bounds.height() - reservedStatusHeight);
+	return bounds.top + MAX(0, (availableHeight - itemBlockHeight) / 2);
+}
+
 static void drawInventoryOverlay(Graphics::Screen &screen, const StartupArt &art, StartupScript &startupScript,
 		const Graphics::Font &font, const Common::Array<StartupInventoryVisual> &items,
 		const Common::String &selectedItemName, const Common::String &promptText) {
@@ -1951,8 +1999,7 @@ Common::Error StartupFlow::runMainMenuStub() {
 
 Common::Error StartupFlow::runRoomMenuStub() {
 	Graphics::FrameLimiter limiter(g_system, 60);
-	Common::String statusMessage =
-		"Native Esc enters run_main_menu(); room-menu actions still need a dedicated implementation.";
+	Common::String statusMessage = "ESC RESUMES GAME";
 	int selectedItem = _menuItems.empty() ? -1 : 0;
 	bool needsRedraw = true;
 	resetCursorAnimationSequence();
@@ -1971,7 +2018,7 @@ Common::Error StartupFlow::runRoomMenuStub() {
 
 			switch (event.type) {
 			case Common::EVENT_MOUSEMOVE: {
-				const int hoveredItem = getMenuItemAt(_mousePos);
+				const int hoveredItem = getRoomMenuItemAt(_mousePos);
 				if (hoveredItem != -1 && hoveredItem != selectedItem) {
 					selectedItem = hoveredItem;
 					needsRedraw = true;
@@ -1993,9 +2040,7 @@ Common::Error StartupFlow::runRoomMenuStub() {
 					needsRedraw = true;
 				} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
 						event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
-					statusMessage = Common::String::format(
-						"%s is still stubbed here; Esc resumes gameplay.",
-						_menuItems[selectedItem].c_str());
+					statusMessage = "NOT READY";
 					needsRedraw = true;
 				}
 				break;
@@ -2003,13 +2048,11 @@ Common::Error StartupFlow::runRoomMenuStub() {
 				if (_menuItems.empty())
 					break;
 
-				selectedItem = getMenuItemAt(_mousePos);
+				selectedItem = getRoomMenuItemAt(_mousePos);
 				if (selectedItem == -1)
 					break;
 
-				statusMessage = Common::String::format(
-					"%s is still stubbed here; Esc resumes gameplay.",
-					_menuItems[selectedItem].c_str());
+				statusMessage = "NOT READY";
 				needsRedraw = true;
 				break;
 			default:
@@ -2918,35 +2961,33 @@ void StartupFlow::renderMainMenuStub(int selectedItem, const Common::String &sta
 void StartupFlow::renderRoomMenuStub(int selectedItem, const Common::String &statusMessage) const {
 	Graphics::Screen *screen = _engine.getScreen();
 	const StartupArt *art = _engine.getStartupArt();
-	const Graphics::Font *titleFont = FontMan.getFontByUsage(Graphics::FontManager::kBigGUIFont);
-	const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	if (!screen || !art || !titleFont || !bodyFont)
+	const CftFontResource *menuFontResource = findStartupFontByName(_engine, "HARVFONT");
+	if (!screen || !art || !menuFontResource)
 		return;
 
-	screen->setPalette(art->getWaitPalette());
-	screen->fillRect(screen->getBounds(), 0);
-	blitBitmap(*screen, art->getInventoryBitmap(), kInventoryX, kInventoryY);
-	blitBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
-
-	const Common::Rect panel(96, 96, 544, 432);
-	screen->fillRect(panel, kPanelFillColor);
+	HarvesterCftFont menuFont(*menuFontResource, kHarvfontGlyphOrder);
+	if (!menuFont.isValid())
+		return;
 
-	drawShadowedString(*screen, *titleFont, "Game Menu Stub", panel.left, 110, panel.width(),
-		kTextColorNormal, Graphics::kTextAlignCenter);
+	drawStartupMenuBackdrop(*screen, *art);
 
+	const Common::Rect menuBounds = getNativeMenuTextBounds(*art);
+	const int firstLineY = getNativeMenuFirstLineY(menuFont, menuBounds, _menuItems.size(), !statusMessage.empty());
+	const int lineSpacing = menuFont.getFontHeight() + kNativeMenuLineGap;
 	for (uint i = 0; i < _menuItems.size(); ++i) {
-		const int itemY = kMenuStartY + (int)i * kMenuLineSpacing;
+		const int itemY = firstLineY + (int)i * lineSpacing;
 		const byte color = ((int)i == selectedItem) ? kTextColorHover : kTextColorNormal;
-		drawShadowedString(*screen, *bodyFont, _menuItems[i], panel.left, itemY, panel.width(), color,
+		drawShadowedString(*screen, menuFont, _menuItems[i], menuBounds.left, itemY, menuBounds.width(), color,
 			Graphics::kTextAlignCenter);
 	}
 
-	drawShadowedString(*screen, *bodyFont, "Native Esc routes through run_main_menu().", panel.left, 330,
-		panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
-	drawWrappedShadowedText(*screen, *bodyFont, statusMessage, panel.left + 24, 360, panel.width() - 48,
-		kTextColorNormal);
-	drawShadowedString(*screen, *bodyFont, "Use mouse or arrow keys. Esc resumes gameplay.",
-		panel.left, 404, panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
+	if (!statusMessage.empty()) {
+		Common::String nativeStatus = statusMessage;
+		nativeStatus.toUppercase();
+		drawShadowedString(*screen, menuFont, nativeStatus,
+			menuBounds.left, menuBounds.bottom - menuFont.getFontHeight(),
+			menuBounds.width(), kTextColorDim, Graphics::kTextAlignCenter);
+	}
 
 	if (_engine.getRuntimeEntities())
 		_engine.getRuntimeEntities()->drawCursor(*screen);
@@ -2991,4 +3032,29 @@ int StartupFlow::getMenuItemAt(const Common::Point &mousePos) const {
 	return -1;
 }
 
+int StartupFlow::getRoomMenuItemAt(const Common::Point &mousePos) const {
+	const StartupArt *art = _engine.getStartupArt();
+	const CftFontResource *menuFontResource = findStartupFontByName(_engine, "HARVFONT");
+	if (!art || !menuFontResource)
+		return getMenuItemAt(mousePos);
+
+	HarvesterCftFont menuFont(*menuFontResource, kHarvfontGlyphOrder);
+	if (!menuFont.isValid())
+		return getMenuItemAt(mousePos);
+
+	const Common::Rect menuBounds = getNativeMenuTextBounds(*art);
+	const int firstLineY = getNativeMenuFirstLineY(menuFont, menuBounds, _menuItems.size(), true);
+	const int lineSpacing = menuFont.getFontHeight() + kNativeMenuLineGap;
+	for (uint i = 0; i < _menuItems.size(); ++i) {
+		const int width = menuFont.getStringWidth(_menuItems[i]);
+		const int x = menuBounds.left + (menuBounds.width() - width) / 2;
+		const int y = firstLineY + (int)i * lineSpacing;
+		const Common::Rect bounds(x - 8, y - 2, x + width + 8, y + menuFont.getFontHeight() + 2);
+		if (bounds.contains(mousePos))
+			return (int)i;
+	}
+
+	return -1;
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/startup_flow.h
index d85059307e0..eca54aed7ff 100644
--- a/engines/harvester/startup_flow.h
+++ b/engines/harvester/startup_flow.h
@@ -64,6 +64,7 @@ private:
 	void renderRoomMenuStub(int selectedItem, const Common::String &statusMessage) const;
 	bool handleSystemEvent(const Common::Event &event, Common::Error &result);
 	int getMenuItemAt(const Common::Point &mousePos) const;
+	int getRoomMenuItemAt(const Common::Point &mousePos) const;
 
 	HarvesterEngine &_engine;
 	Common::Array<Common::String> _quickTips;


Commit: a348c8b60425a2e8f256f09632f9310399771799
    https://github.com/scummvm/scummvm/commit/a348c8b60425a2e8f256f09632f9310399771799
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:46+02:00

Commit Message:
HARVESTER: Overlay ESC menu on the room scene

Changed paths:
    engines/harvester/cft_font.cpp
    engines/harvester/cft_font.h
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_flow.h


diff --git a/engines/harvester/cft_font.cpp b/engines/harvester/cft_font.cpp
index 00aaf351e23..41a04c08aad 100644
--- a/engines/harvester/cft_font.cpp
+++ b/engines/harvester/cft_font.cpp
@@ -21,7 +21,6 @@
 
 #include "harvester/cft_font.h"
 
-#include "common/algorithm.h"
 #include "common/endian.h"
 #include "graphics/surface.h"
 
@@ -30,81 +29,43 @@ namespace Harvester {
 namespace {
 
 static const uint32 kCftFontHeightOffset = 0x40;
-static const uint32 kCftStartTableOffset = 0x48;
-static const uint32 kCftWidthTableOffset = 0x248;
-static const uint32 kCftGlyphCount = 128;
-
-static uint32 normalizeGlyphChar(uint32 chr) {
-	if (chr >= 'a' && chr <= 'z')
-		return chr - ('a' - 'A');
-
-	return chr;
-}
+static const uint32 kCftStartTableOffset = 0x42;
+static const uint32 kCftWidthTableOffset = 0x242;
+static const uint32 kCftSpaceWidthOffset = 0x442;
+static const uint32 kCftGlyphCount = 256;
 
 } // End of anonymous namespace
 
-HarvesterCftFont::HarvesterCftFont(const CftFontResource &resource, const char *glyphOrder, int spaceWidth)
-		: _resource(resource) {
-	Common::Array<GlyphSlice> atlasGlyphs;
-
-	if (_resource.header.size() >= kCftWidthTableOffset + kCftGlyphCount * 2) {
-		const byte *header = _resource.header.data();
-		const int headerFontHeight = READ_LE_UINT16(header + kCftFontHeightOffset);
-		if (headerFontHeight > 0)
-			_fontHeight = MIN<int>(headerFontHeight, _resource.atlasHeight);
-		if (_fontHeight <= 0)
-			_fontHeight = _resource.atlasHeight;
-
-		for (uint i = 0; i < kCftGlyphCount; ++i) {
-			GlyphSlice glyph;
-			glyph.x = READ_LE_UINT16(header + kCftStartTableOffset + i * 2);
-			glyph.width = READ_LE_UINT16(header + kCftWidthTableOffset + i * 2);
-			if (glyph.width <= 0 || glyph.x >= (int)_resource.atlasWidth)
-				continue;
-
-			glyph.width = MIN<int>(glyph.width, (int)_resource.atlasWidth - glyph.x);
-			glyph.valid = glyph.width > 0;
-			if (!glyph.valid)
-				continue;
-
-			bool duplicate = false;
-			for (const GlyphSlice &existing : atlasGlyphs) {
-				if (existing.x == glyph.x && existing.width == glyph.width) {
-					duplicate = true;
-					break;
-				}
-			}
-			if (!duplicate)
-				atlasGlyphs.push_back(glyph);
-		}
-	}
-
-	Common::sort(atlasGlyphs.begin(), atlasGlyphs.end(), [](const GlyphSlice &left, const GlyphSlice &right) {
-		return left.x < right.x;
-	});
+HarvesterCftFont::HarvesterCftFont(const CftFontResource &resource) : _resource(resource) {
+	if (_resource.header.size() < kCftWidthTableOffset + kCftGlyphCount * 2 || _resource.atlasWidth == 0 || _resource.atlasHeight == 0)
+		return;
 
-	const Common::String glyphOrderString(glyphOrder ? glyphOrder : "");
-	const uint glyphCount = MIN<uint>(glyphOrderString.size(), atlasGlyphs.size());
-	for (uint i = 0; i < glyphCount; ++i) {
-		const byte chr = glyphOrderString[i];
-		if (chr == ' ')
+	const byte *header = _resource.header.data();
+	_fontHeight = READ_LE_UINT16(header + kCftFontHeightOffset);
+	_drawHeight = MAX<int>(0, (int)_resource.atlasHeight - 1);
+	if (_fontHeight <= 0)
+		_fontHeight = _drawHeight;
+	_spaceWidth = READ_LE_UINT16(header + kCftSpaceWidthOffset);
+
+	for (uint i = 0; i < kCftGlyphCount; ++i) {
+		GlyphSlice &glyph = _glyphs[i];
+		glyph.x = READ_LE_UINT16(header + kCftStartTableOffset + i * 2);
+		glyph.width = READ_LE_UINT16(header + kCftWidthTableOffset + i * 2);
+		if (glyph.width <= 0 || glyph.x >= (int)_resource.atlasWidth)
 			continue;
 
-		_glyphs[chr] = atlasGlyphs[i];
-		_maxCharWidth = MAX(_maxCharWidth, _glyphs[chr].width);
+		glyph.width = MIN<int>(glyph.width, (int)_resource.atlasWidth - glyph.x);
+		glyph.valid = glyph.width > 0;
+		if (glyph.valid)
+			_maxCharWidth = MAX(_maxCharWidth, glyph.width);
 	}
 
-	if (spaceWidth > 0) {
-		_spaceWidth = spaceWidth;
-	} else if (_glyphs['I'].valid) {
-		_spaceWidth = MAX<int>(1, _glyphs['I'].width / 2);
-	} else {
-		_spaceWidth = MAX<int>(1, _maxCharWidth / 2);
-	}
+	if (_spaceWidth <= 0)
+		_spaceWidth = MAX<int>(1, _maxCharWidth);
 }
 
 int HarvesterCftFont::getCharWidth(uint32 chr) const {
-	if (chr == ' ')
+	if (chr == ' ' || chr == '_')
 		return _spaceWidth;
 
 	const GlyphSlice *glyph = findGlyph(chr);
@@ -112,15 +73,15 @@ int HarvesterCftFont::getCharWidth(uint32 chr) const {
 }
 
 void HarvesterCftFont::drawChar(Graphics::Surface *dst, uint32 chr, int x, int y, uint32 color) const {
-	if (!dst || chr == ' ')
+	if (!dst || chr == ' ' || chr == '_')
 		return;
+	(void)color;
 
 	const GlyphSlice *glyph = findGlyph(chr);
 	if (!glyph)
 		return;
 
-	const int drawHeight = MIN<int>(_fontHeight, _resource.atlasHeight);
-	for (int row = 0; row < drawHeight; ++row) {
+	for (int row = 0; row < _drawHeight; ++row) {
 		const int dstY = y + row;
 		if (dstY < 0 || dstY >= dst->h)
 			continue;
@@ -128,18 +89,19 @@ void HarvesterCftFont::drawChar(Graphics::Surface *dst, uint32 chr, int x, int y
 		const byte *srcRow = _resource.atlasPixels.data() + row * _resource.atlasWidth + glyph->x;
 		for (int col = 0; col < glyph->width; ++col) {
 			const int dstX = x + col;
-			if (dstX < 0 || dstX >= dst->w || srcRow[col] == 0)
+			const byte srcColor = srcRow[col];
+			if (dstX < 0 || dstX >= dst->w || srcColor == 0)
 				continue;
 
 			switch (dst->format.bytesPerPixel) {
 			case 1:
-				*((byte *)dst->getBasePtr(dstX, dstY)) = color;
+				*((byte *)dst->getBasePtr(dstX, dstY)) = srcColor;
 				break;
 			case 2:
-				*((uint16 *)dst->getBasePtr(dstX, dstY)) = color;
+				*((uint16 *)dst->getBasePtr(dstX, dstY)) = srcColor;
 				break;
 			case 4:
-				*((uint32 *)dst->getBasePtr(dstX, dstY)) = color;
+				*((uint32 *)dst->getBasePtr(dstX, dstY)) = srcColor;
 				break;
 			default:
 				break;
@@ -152,7 +114,7 @@ const HarvesterCftFont::GlyphSlice *HarvesterCftFont::findGlyph(uint32 chr) cons
 	if (chr >= ARRAYSIZE(_glyphs))
 		return nullptr;
 
-	const GlyphSlice &glyph = _glyphs[normalizeGlyphChar(chr)];
+	const GlyphSlice &glyph = _glyphs[chr];
 	return glyph.valid ? &glyph : nullptr;
 }
 
diff --git a/engines/harvester/cft_font.h b/engines/harvester/cft_font.h
index ee3fdfb6b12..5bde9d347fa 100644
--- a/engines/harvester/cft_font.h
+++ b/engines/harvester/cft_font.h
@@ -29,9 +29,9 @@ namespace Harvester {
 
 class HarvesterCftFont : public Graphics::Font {
 public:
-	HarvesterCftFont(const CftFontResource &resource, const char *glyphOrder, int spaceWidth = 0);
+	explicit HarvesterCftFont(const CftFontResource &resource);
 
-	bool isValid() const { return _maxCharWidth > 0 && _fontHeight > 0; }
+	bool isValid() const { return _maxCharWidth > 0 && _fontHeight > 0 && _drawHeight > 0; }
 
 	int getFontHeight() const override { return _fontHeight; }
 	Common::String getFontName() const override { return _resource.name; }
@@ -51,6 +51,7 @@ private:
 	const CftFontResource &_resource;
 	GlyphSlice _glyphs[256];
 	int _fontHeight = 0;
+	int _drawHeight = 0;
 	int _spaceWidth = 0;
 	int _maxCharWidth = 0;
 };
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 72e6c362c1a..8da8cab3eb9 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -30,6 +30,7 @@
 #include "common/formats/ini-file.h"
 #include "common/memstream.h"
 #include "common/system.h"
+#include "graphics/blit.h"
 #include "graphics/font.h"
 #include "graphics/fontman.h"
 #include "graphics/framelimiter.h"
@@ -67,10 +68,6 @@ static const int kQuickTipTextWidth = 280;
 
 static const int kMenuStartY = 100;
 static const int kMenuLineSpacing = 28;
-static const int kNativeMenuInsetX = 24;
-static const int kNativeMenuInsetTop = 72;
-static const int kNativeMenuInsetBottom = 24;
-static const int kNativeMenuLineGap = 6;
 static const int kCursorSequence7 = 7;
 static const int kIdentTextboxX = 85;
 static const int kIdentTextboxY = 177;
@@ -94,7 +91,7 @@ static const byte kShadowColor = 0;
 static const byte kPanelFillColor = 1;
 static const byte kQuickTipActionColor = 0xc3;
 static const byte kRoomPromptColor = 0xce;
-static const char *const kHarvfontGlyphOrder = "ABCDEFGHIJKLMNOPQRSTUVWXYZ-1234567890";
+static const byte kTransparentPaletteIndex = 0;
 
 static const int kCursorSequenceWalk = 0;
 static const int kCursorSequenceExamine = 1;
@@ -596,6 +593,54 @@ static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, in
 	screen.copyRectToSurface(bitmap.pixels.data(), bitmap.width, x, y, bitmap.width, bitmap.height);
 }
 
+static void blitTransparentBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
+	if (!bitmap.isValid())
+		return;
+
+	int srcX = 0;
+	int srcY = 0;
+	int destX = x;
+	int destY = y;
+	int width = (int)bitmap.width;
+	int height = (int)bitmap.height;
+
+	if (destX < 0) {
+		srcX = -destX;
+		width += destX;
+		destX = 0;
+	}
+	if (destY < 0) {
+		srcY = -destY;
+		height += destY;
+		destY = 0;
+	}
+	if (destX >= screen.w || destY >= screen.h || width <= 0 || height <= 0)
+		return;
+
+	width = MIN<int>(width, screen.w - destX);
+	height = MIN<int>(height, screen.h - destY);
+	if (width <= 0 || height <= 0)
+		return;
+
+	const byte *src = bitmap.pixels.data() + srcY * bitmap.width + srcX;
+	byte *dst = (byte *)screen.getBasePtr(destX, destY);
+	Graphics::keyBlit(dst, src, screen.pitch, bitmap.width, width, height,
+		screen.format.bytesPerPixel, kTransparentPaletteIndex);
+}
+
+static bool captureScreenBackdrop(const Graphics::Screen &screen, IndexedBitmap &bitmap) {
+	if (screen.w <= 0 || screen.h <= 0 || screen.format.bytesPerPixel != 1)
+		return false;
+
+	bitmap.width = (uint32)screen.w;
+	bitmap.height = (uint32)screen.h;
+	bitmap.pixels.resize(bitmap.width * bitmap.height);
+	for (int y = 0; y < screen.h; ++y)
+		memcpy(bitmap.pixels.data() + y * bitmap.width, screen.getBasePtr(0, y), bitmap.width);
+
+	return true;
+}
+
 static void drawShadowedString(Graphics::Screen &screen, const Graphics::Font &font, const Common::String &text,
 		int x, int y, int width, byte color, Graphics::TextAlign align = Graphics::kTextAlignLeft) {
 	font.drawString(&screen, text, x + 1, y + 1, width, kShadowColor, align);
@@ -785,32 +830,21 @@ static const CftFontResource *findStartupFontByName(const HarvesterEngine &engin
 	return nullptr;
 }
 
-static void drawStartupMenuBackdrop(Graphics::Screen &screen, const StartupArt &art) {
-	screen.setPalette(art.getWaitPalette());
-	screen.fillRect(screen.getBounds(), 0);
-	blitBitmap(screen, art.getInventoryBitmap(), kInventoryX, kInventoryY);
-	blitBitmap(screen, art.getLogoBitmap(), kLogoX, kLogoY);
+static int getNativeRoomMenuLineHeight(const Graphics::Font &selectedFont) {
+	return selectedFont.getFontHeight() + 2;
 }
 
-static Common::Rect getNativeMenuTextBounds(const StartupArt &art) {
-	const Common::Rect inventoryBounds = getInventoryPanelBounds(art);
-	return Common::Rect(
-		inventoryBounds.left + kNativeMenuInsetX,
-		inventoryBounds.top + kNativeMenuInsetTop,
-		inventoryBounds.right - kNativeMenuInsetX,
-		inventoryBounds.bottom - kNativeMenuInsetBottom);
-}
-
-static int getNativeMenuFirstLineY(const Graphics::Font &font, const Common::Rect &bounds,
-		uint itemCount, bool hasStatusLine) {
+static int getNativeRoomMenuSelectionFromMouse(const Graphics::Font &selectedFont, uint itemCount,
+		const Common::Point &mousePos) {
 	if (itemCount == 0)
-		return bounds.top;
+		return -1;
 
-	const int lineSpacing = font.getFontHeight() + kNativeMenuLineGap;
-	const int itemBlockHeight = font.getFontHeight() + ((int)itemCount - 1) * lineSpacing;
-	const int reservedStatusHeight = hasStatusLine ? font.getFontHeight() + kNativeMenuLineGap : 0;
-	const int availableHeight = MAX(0, bounds.height() - reservedStatusHeight);
-	return bounds.top + MAX(0, (availableHeight - itemBlockHeight) / 2);
+	int selection = mousePos.y - kMenuStartY;
+	if (selection < 1)
+		selection = 1;
+
+	selection /= getNativeRoomMenuLineHeight(selectedFont);
+	return MIN<int>(selection, (int)itemCount - 1);
 }
 
 static void drawInventoryOverlay(Graphics::Screen &screen, const StartupArt &art, StartupScript &startupScript,
@@ -1997,16 +2031,15 @@ Common::Error StartupFlow::runMainMenuStub() {
 	return Common::kNoError;
 }
 
-Common::Error StartupFlow::runRoomMenuStub() {
+Common::Error StartupFlow::runRoomMenuStub(const IndexedBitmap &backdrop) {
 	Graphics::FrameLimiter limiter(g_system, 60);
-	Common::String statusMessage = "ESC RESUMES GAME";
 	int selectedItem = _menuItems.empty() ? -1 : 0;
 	bool needsRedraw = true;
 	resetCursorAnimationSequence();
 
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
-			renderRoomMenuStub(selectedItem, statusMessage);
+			renderRoomMenuStub(backdrop, selectedItem);
 			needsRedraw = false;
 		}
 
@@ -2040,8 +2073,8 @@ Common::Error StartupFlow::runRoomMenuStub() {
 					needsRedraw = true;
 				} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
 						event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
-					statusMessage = "NOT READY";
-					needsRedraw = true;
+					debug(1, "Harvester: room menu item '%s' selected but not implemented",
+						selectedItem >= 0 ? _menuItems[selectedItem].c_str() : "");
 				}
 				break;
 			case Common::EVENT_LBUTTONDOWN:
@@ -2052,8 +2085,8 @@ Common::Error StartupFlow::runRoomMenuStub() {
 				if (selectedItem == -1)
 					break;
 
-				statusMessage = "NOT READY";
-				needsRedraw = true;
+				debug(1, "Harvester: room menu item '%s' clicked but not implemented",
+					_menuItems[selectedItem].c_str());
 				break;
 			default:
 				break;
@@ -2654,7 +2687,11 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 					playerState.hasMoveTarget = false;
 					playerState.turnActive = false;
 					playerState.turnTargetFacing = -1;
-					Common::Error menuError = runRoomMenuStub();
+					IndexedBitmap roomMenuBackdrop;
+					drawRoomScene(_engine, *screen, scene, scene.targetPaletteBrightness);
+					if (!captureScreenBackdrop(*screen, roomMenuBackdrop))
+						return Common::kReadingFailed;
+					Common::Error menuError = runRoomMenuStub(roomMenuBackdrop);
 					if (menuError.getCode() != Common::kNoError)
 						return menuError;
 					resetCursorAnimationSequence();
@@ -2958,35 +2995,31 @@ void StartupFlow::renderMainMenuStub(int selectedItem, const Common::String &sta
 	screen->update();
 }
 
-void StartupFlow::renderRoomMenuStub(int selectedItem, const Common::String &statusMessage) const {
+void StartupFlow::renderRoomMenuStub(const IndexedBitmap &backdrop, int selectedItem) const {
 	Graphics::Screen *screen = _engine.getScreen();
 	const StartupArt *art = _engine.getStartupArt();
-	const CftFontResource *menuFontResource = findStartupFontByName(_engine, "HARVFONT");
-	if (!screen || !art || !menuFontResource)
+	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
+	const CftFontResource *unselectedFontResource = findStartupFontByName(_engine, "HARVFNT2");
+	if (!screen || !art || !selectedFontResource || !unselectedFontResource || !backdrop.isValid())
 		return;
 
-	HarvesterCftFont menuFont(*menuFontResource, kHarvfontGlyphOrder);
-	if (!menuFont.isValid())
+	HarvesterCftFont selectedFont(*selectedFontResource);
+	HarvesterCftFont unselectedFont(*unselectedFontResource);
+	if (!selectedFont.isValid() || !unselectedFont.isValid())
 		return;
 
-	drawStartupMenuBackdrop(*screen, *art);
+	blitBitmap(*screen, backdrop, 0, 0);
+	blitTransparentBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
 
-	const Common::Rect menuBounds = getNativeMenuTextBounds(*art);
-	const int firstLineY = getNativeMenuFirstLineY(menuFont, menuBounds, _menuItems.size(), !statusMessage.empty());
-	const int lineSpacing = menuFont.getFontHeight() + kNativeMenuLineGap;
+	const int lineSpacing = getNativeRoomMenuLineHeight(selectedFont);
 	for (uint i = 0; i < _menuItems.size(); ++i) {
-		const int itemY = firstLineY + (int)i * lineSpacing;
-		const byte color = ((int)i == selectedItem) ? kTextColorHover : kTextColorNormal;
-		drawShadowedString(*screen, menuFont, _menuItems[i], menuBounds.left, itemY, menuBounds.width(), color,
-			Graphics::kTextAlignCenter);
-	}
-
-	if (!statusMessage.empty()) {
-		Common::String nativeStatus = statusMessage;
-		nativeStatus.toUppercase();
-		drawShadowedString(*screen, menuFont, nativeStatus,
-			menuBounds.left, menuBounds.bottom - menuFont.getFontHeight(),
-			menuBounds.width(), kTextColorDim, Graphics::kTextAlignCenter);
+		const Graphics::Font *font = ((int)i == selectedItem)
+			? static_cast<const Graphics::Font *>(&selectedFont)
+			: static_cast<const Graphics::Font *>(&unselectedFont);
+		const int width = font->getStringWidth(_menuItems[i]);
+		const int x = (screen->w - width) / 2;
+		const int y = kMenuStartY + (int)i * lineSpacing;
+		font->drawString(screen, _menuItems[i], x, y, width, 0);
 	}
 
 	if (_engine.getRuntimeEntities())
@@ -3033,28 +3066,15 @@ int StartupFlow::getMenuItemAt(const Common::Point &mousePos) const {
 }
 
 int StartupFlow::getRoomMenuItemAt(const Common::Point &mousePos) const {
-	const StartupArt *art = _engine.getStartupArt();
-	const CftFontResource *menuFontResource = findStartupFontByName(_engine, "HARVFONT");
-	if (!art || !menuFontResource)
+	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
+	if (!selectedFontResource)
 		return getMenuItemAt(mousePos);
 
-	HarvesterCftFont menuFont(*menuFontResource, kHarvfontGlyphOrder);
-	if (!menuFont.isValid())
+	HarvesterCftFont selectedFont(*selectedFontResource);
+	if (!selectedFont.isValid())
 		return getMenuItemAt(mousePos);
 
-	const Common::Rect menuBounds = getNativeMenuTextBounds(*art);
-	const int firstLineY = getNativeMenuFirstLineY(menuFont, menuBounds, _menuItems.size(), true);
-	const int lineSpacing = menuFont.getFontHeight() + kNativeMenuLineGap;
-	for (uint i = 0; i < _menuItems.size(); ++i) {
-		const int width = menuFont.getStringWidth(_menuItems[i]);
-		const int x = menuBounds.left + (menuBounds.width() - width) / 2;
-		const int y = firstLineY + (int)i * lineSpacing;
-		const Common::Rect bounds(x - 8, y - 2, x + width + 8, y + menuFont.getFontHeight() + 2);
-		if (bounds.contains(mousePos))
-			return (int)i;
-	}
-
-	return -1;
+	return getNativeRoomMenuSelectionFromMouse(selectedFont, _menuItems.size(), mousePos);
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/startup_flow.h
index eca54aed7ff..e5a26bc4afa 100644
--- a/engines/harvester/startup_flow.h
+++ b/engines/harvester/startup_flow.h
@@ -35,6 +35,7 @@ struct Event;
 namespace Harvester {
 
 class HarvesterEngine;
+struct IndexedBitmap;
 
 class StartupFlow {
 public:
@@ -48,7 +49,7 @@ private:
 	bool loadMenuItems();
 	Common::Error runQuickTips();
 	Common::Error runMainMenuStub();
-	Common::Error runRoomMenuStub();
+	Common::Error runRoomMenuStub(const IndexedBitmap &backdrop);
 	Common::Error runRoomLoop(const Common::String &entranceName);
 	bool ensureCursorEntity();
 	bool populateRoomSceneEntities(const StartupRoomSetupState &state,
@@ -61,7 +62,7 @@ private:
 	void resetCursorAnimationSequence();
 	bool tickRuntimeEntities();
 	void renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const;
-	void renderRoomMenuStub(int selectedItem, const Common::String &statusMessage) const;
+	void renderRoomMenuStub(const IndexedBitmap &backdrop, int selectedItem) const;
 	bool handleSystemEvent(const Common::Event &event, Common::Error &result);
 	int getMenuItemAt(const Common::Point &mousePos) const;
 	int getRoomMenuItemAt(const Common::Point &mousePos) const;


Commit: cf7764bc22884487562f66f27abaf84995cdd178
    https://github.com/scummvm/scummvm/commit/cf7764bc22884487562f66f27abaf84995cdd178
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:46+02:00

Commit Message:
HARVESTER: Materialize startup room NPCs

Changed paths:
    engines/harvester/runtime_entity.h
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index ad71fe2f550..6dfb986ee01 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -40,6 +40,7 @@ enum RuntimeEntityClass {
 	kRuntimeEntityClassAnimation = 1,
 	kRuntimeEntityClassCursor = 2,
 	kRuntimeEntityClassBackground = 3,
+	kRuntimeEntityClassNpc = 4,
 	kRuntimeEntityClassPlayer = 5,
 	kRuntimeEntityClassRectHotspot = 0x15,
 	kRuntimeEntityClassDisabledHotspot = 0x16,
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 8da8cab3eb9..815c4ad4dc5 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -951,10 +951,11 @@ static bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceM
 	scene.sceneRegions = state.roomRegions;
 
 	debugC(1, kDebugGeneral,
-		"Harvester: loadRoomSceneResources room='%s' palette='%s' background='%s' brightness=%.2f objects=%u activeObjects=%u sceneObjects=%u anims=%u visibleAnims=%u regions=%u",
+		"Harvester: loadRoomSceneResources room='%s' palette='%s' background='%s' brightness=%.2f objects=%u activeObjects=%u sceneObjects=%u anims=%u visibleAnims=%u npcs=%u regions=%u",
 		state.roomName.c_str(), state.palettePath.c_str(), state.backgroundPath.c_str(), (double)state.paletteBrightness,
 		(uint)state.roomObjects.size(), (uint)state.activeObjects.size(), (uint)scene.sceneObjects.size(),
-		(uint)state.roomAnimations.size(), (uint)scene.sceneAnimations.size(), (uint)scene.sceneRegions.size());
+		(uint)state.roomAnimations.size(), (uint)scene.sceneAnimations.size(),
+		(uint)state.roomNpcs.size(), (uint)scene.sceneRegions.size());
 
 	return true;
 }
@@ -2856,6 +2857,30 @@ bool StartupFlow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 				anim.animName.c_str(), anim.resourcePath.c_str());
 		}
 	}
+	for (const StartupNpcRecord &npc : state.roomNpcs) {
+		RuntimeEntity *entity = runtimeEntities->spawnSceneActorEntity(npc.npcName,
+			npc.modelPath, Common::Point(npc.posX, npc.posY), (float)npc.posZ, 0);
+		if (!entity) {
+			debug(1, "Harvester: unable to spawn room npc entity '%s' from '%s'",
+				npc.npcName.c_str(), npc.modelPath.c_str());
+			continue;
+		}
+
+		entity->setClassId(kRuntimeEntityClassNpc);
+		entity->setHitTestMode(kRuntimeEntityHitTestNone);
+		entity->setLooping(true);
+		if (npc.frameDelay > 0)
+			entity->setAnimationRate(npc.frameDelay);
+		if (!applyRoomActorPlacement(state, *entity, npc.posX, npc.posY, (float)npc.posZ)) {
+			debug(1, "Harvester: unable to apply room npc placement for '%s'",
+				npc.npcName.c_str());
+		}
+		debugC(1, kDebugScene,
+			"Harvester: scene npc spawned room='%s' npc='%s' class=0x%x pos=(%d,%d,z=%.2f) frame_delay=%d model='%s' active=%d visible=%d",
+			state.roomName.c_str(), npc.npcName.c_str(), entity->getClassId(),
+			entity->getX(), entity->getY(), (double)entity->getZ(),
+			npc.frameDelay, npc.modelPath.c_str(), npc.active, npc.visible);
+	}
 	if (state.hasEntrance) {
 		const int playerFrame = resolvePlayerFacingFrame(state.playerFacing);
 		RuntimeEntity *player = runtimeEntities->spawnSceneActorEntity(kPlayerActorEntityName,
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 9ffd55e33c8..b2c4680badd 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -153,6 +153,7 @@ bool StartupScript::load(ResourceManager &resources) {
 	_rooms.clear();
 	_objects.clear();
 	_animations.clear();
+	_npcs.clear();
 	_regions.clear();
 	_flags.clear();
 	_commands.clear();
@@ -217,6 +218,7 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 	_rooms.clear();
 	_objects.clear();
 	_animations.clear();
+	_npcs.clear();
 	_regions.clear();
 	_flags.clear();
 	_commands.clear();
@@ -236,7 +238,7 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 		uint tagIndex = tokens.size();
 		for (uint i = 0; i < tokens.size(); ++i) {
 			if (tokens[i] == "ANIM" || tokens[i] == "ENTRANCE" || tokens[i] == "ROOM" || tokens[i] == "OBJECT" ||
-				tokens[i] == "REGION" ||
+				tokens[i] == "NPC" || tokens[i] == "REGION" ||
 				tokens[i] == "FLAG" || tokens[i] == "COMMAND" || tokens[i] == "TEXT" || tokens[i] == "USEITEM") {
 				tagIndex = i;
 				break;
@@ -365,6 +367,34 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 			return;
 		}
 
+		if (tag == "NPC") {
+			if (tokens.size() < tagIndex + 10)
+				return;
+
+			StartupNpcRecord npc;
+			if (tagIndex >= 4) {
+				npc.posX = atoi(tokens[0].c_str());
+				npc.posY = atoi(tokens[1].c_str());
+				npc.posZ = atoi(tokens[2].c_str());
+				npc.frameDelay = atoi(tokens[3].c_str());
+			}
+			npc.roomName = tokens[tagIndex + 1];
+			npc.modelPath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
+			npc.npcName = tokens[tagIndex + 3];
+			npc.monsterfyTargetName = tokens[tagIndex + 4];
+			npc.active = tokens[tagIndex + 5].equalsIgnoreCase("T");
+			npc.visible = tokens[tagIndex + 6].equalsIgnoreCase("T");
+			if (npc.active)
+				npc.visible = true;
+			npc.savedVisible = npc.visible;
+			npc.onDeathActionTag = tokens[tagIndex + 7];
+			npc.audioPath = resources.normalizeResourcePath(tokens[tagIndex + 8]);
+			npc.entityInitArg = tokens[tagIndex + 9];
+			if (!npc.roomName.empty() && !npc.modelPath.empty() && !npc.npcName.empty())
+				_npcs.push_back(npc);
+			return;
+		}
+
 		if (tag == "REGION") {
 			if (tokens.size() < tagIndex + 7)
 				return;
@@ -467,9 +497,9 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 
 	parseLine(line);
 
-	debug(1, "Harvester: parsed %u entrances, %u rooms, %u objects, %u anims, %u regions, %u flags, %u commands, %u texts, %u useitems from '%s'",
+	debug(1, "Harvester: parsed %u entrances, %u rooms, %u objects, %u anims, %u npcs, %u regions, %u flags, %u commands, %u texts, %u useitems from '%s'",
 		(uint)_entrances.size(), (uint)_rooms.size(), (uint)_objects.size(), (uint)_animations.size(),
-		(uint)_regions.size(),
+		(uint)_npcs.size(), (uint)_regions.size(),
 		(uint)_flags.size(), (uint)_commands.size(), (uint)_texts.size(), (uint)_useItems.size(), _path.c_str());
 }
 
@@ -498,12 +528,12 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 		state.musicPath = musicPath;
 
 	debugC(1, kDebugGeneral,
-		"Harvester: resolveRoomSetupState('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u roomRegions=%u mutated=%d",
+		"Harvester: resolveRoomSetupState('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u roomNpcs=%u roomRegions=%u mutated=%d",
 		entranceName.c_str(), state.roomName.c_str(), state.entranceName.c_str(),
 		state.playerSpawnX, state.playerSpawnY, state.playerSpawnZ, state.playerFacing,
 		state.palettePath.c_str(), state.backgroundPath.c_str(), state.musicPath.c_str(),
 		(double)state.paletteBrightness, (uint)state.roomObjects.size(),
-		(uint)state.activeObjects.size(), (uint)state.roomAnimations.size(),
+		(uint)state.activeObjects.size(), (uint)state.roomAnimations.size(), (uint)state.roomNpcs.size(),
 		(uint)state.roomRegions.size(), mutatedRuntimeState);
 
 	return true;
@@ -513,6 +543,7 @@ void StartupScript::resetRuntimeState() {
 	_runtimeFlags = _flags;
 	_runtimeObjects = _objects;
 	_runtimeAnimations = _animations;
+	_runtimeNpcs = _npcs;
 
 	for (StartupObjectRecord &object : _runtimeObjects) {
 		object.currentX = object.initialX;
@@ -528,6 +559,11 @@ void StartupScript::resetRuntimeState() {
 		anim.runtimeVisible = anim.visible;
 		anim.runtimeState = -1;
 	}
+
+	for (StartupNpcRecord &npc : _runtimeNpcs) {
+		npc.runtimeSpawned = false;
+		npc.savedVisible = npc.visible;
+	}
 }
 
 bool StartupScript::materializeRoomState(const Common::String &entranceName, const Common::String &roomName,
@@ -761,6 +797,18 @@ StartupAnimRecord *StartupScript::findRuntimeAnim(const Common::String &animName
 	return nullptr;
 }
 
+StartupNpcRecord *StartupScript::findRuntimeNpc(const Common::String &npcName) {
+	if (npcName.empty())
+		return nullptr;
+
+	for (StartupNpcRecord &npc : _runtimeNpcs) {
+		if (npc.npcName.equalsIgnoreCase(npcName))
+			return &npc;
+	}
+
+	return nullptr;
+}
+
 bool StartupScript::addRuntimeObjectToInventory(const Common::String &objectName) {
 	StartupObjectRecord *runtimeObject = findRuntimeObject(Common::String(), objectName);
 	if (!runtimeObject)
@@ -832,6 +880,10 @@ bool StartupScript::buildRuntimeRoomState(const StartupRoomRecord &room, const S
 		if (anim.roomName.equalsIgnoreCase(room.roomName))
 			state.roomAnimations.push_back(anim);
 	}
+	for (const StartupNpcRecord &npc : _runtimeNpcs) {
+		if (npc.roomName.equalsIgnoreCase(room.roomName) && npc.visible)
+			state.roomNpcs.push_back(npc);
+	}
 	for (const StartupRegionRecord &region : _regions) {
 		if (region.roomName.equalsIgnoreCase(room.roomName) && region.startEnabled)
 			state.roomRegions.push_back(region);
@@ -850,13 +902,21 @@ bool StartupScript::buildRuntimeRoomState(const StartupRoomRecord &room, const S
 			object.currentX, object.currentY, object.currentZ,
 			object.currentX, object.currentY, object.boundsX2, object.boundsY2, object.actionTag.c_str());
 	}
+	for (const StartupNpcRecord &npc : state.roomNpcs) {
+		debugC(1, kDebugScene,
+			"Harvester: materialized room npc room='%s' npc='%s' visible=%d active=%d pos=(%d,%d,%d) frame_delay=%d model='%s' on_death='%s' audio='%s'",
+			state.roomName.c_str(), npc.npcName.c_str(), npc.visible, npc.active,
+			npc.posX, npc.posY, npc.posZ, npc.frameDelay,
+			npc.modelPath.c_str(), npc.onDeathActionTag.c_str(), npc.audioPath.c_str());
+	}
 
 	debugC(1, kDebugGeneral,
-		"Harvester: materializeRoomState room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u roomAnims=%u roomRegions=%u",
+		"Harvester: materializeRoomState room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u roomAnims=%u roomNpcs=%u roomRegions=%u",
 		state.roomName.c_str(), state.entranceName.c_str(), state.playerSpawnX, state.playerSpawnY,
 		state.playerSpawnZ, state.playerFacing, state.palettePath.c_str(), state.backgroundPath.c_str(),
 		state.musicPath.c_str(), (double)state.paletteBrightness,
-		(uint)state.roomObjects.size(), (uint)state.roomAnimations.size(), (uint)state.roomRegions.size());
+		(uint)state.roomObjects.size(), (uint)state.roomAnimations.size(),
+		(uint)state.roomNpcs.size(), (uint)state.roomRegions.size());
 
 	return true;
 }
@@ -987,6 +1047,25 @@ void StartupScript::executeCommandChain(const Common::String &initialTag, const
 			continue;
 		}
 
+		if (command->opcodeName.equalsIgnoreCase("SET_NPC")) {
+			StartupNpcRecord *runtimeNpc = findRuntimeNpc(command->arg1);
+			if (!runtimeNpc) {
+				debug(1, "Harvester: unresolved npc for %s '%s' npc='%s'",
+					contextLabel, contextName.c_str(), command->arg1.c_str());
+				currentTag = command->arg4;
+				continue;
+			}
+
+			const bool active = isTruthy(command->arg2);
+			const bool visible = isTruthy(command->arg3);
+			const bool changed = runtimeNpc->active != active || runtimeNpc->visible != visible;
+			runtimeNpc->active = active;
+			runtimeNpc->visible = visible;
+			noteMutation(changed);
+			currentTag = command->arg4;
+			continue;
+		}
+
 		if (command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
 			if (allowTransitions && nextRoomName)
@@ -1024,6 +1103,7 @@ bool StartupScript::hasActionableCommandChain(const Common::String &initialTag)
 			command->opcodeName.equalsIgnoreCase("DELETE") ||
 			command->opcodeName.equalsIgnoreCase("ADD2INV") ||
 			command->opcodeName.equalsIgnoreCase("SET_ANIM") ||
+			command->opcodeName.equalsIgnoreCase("SET_NPC") ||
 			command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
 			return true;
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index c35d45bf537..f9537a35522 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -105,6 +105,24 @@ struct StartupAnimRecord {
 	int runtimeState = -1;
 };
 
+struct StartupNpcRecord {
+	int posX = 0;
+	int posY = 0;
+	int posZ = 0;
+	int frameDelay = 0;
+	Common::String onDeathActionTag;
+	Common::String modelPath;
+	Common::String npcName;
+	Common::String monsterfyTargetName;
+	Common::String roomName;
+	bool runtimeSpawned = false;
+	bool active = false;
+	bool visible = false;
+	bool savedVisible = false;
+	Common::String audioPath;
+	Common::String entityInitArg;
+};
+
 struct StartupRegionRecord {
 	int left = 0;
 	int top = 0;
@@ -185,6 +203,7 @@ struct StartupRoomSetupState {
 	Common::Array<StartupObjectRecord> activeObjects;
 	Common::Array<StartupObjectRecord> roomObjects;
 	Common::Array<StartupAnimRecord> roomAnimations;
+	Common::Array<StartupNpcRecord> roomNpcs;
 	Common::Array<StartupRegionRecord> roomRegions;
 	Common::Array<StartupAudioCommand> audioCommands;
 };
@@ -211,6 +230,7 @@ public:
 	const Common::Array<StartupRoomRecord> &getRooms() const { return _rooms; }
 	const Common::Array<StartupObjectRecord> &getObjects() const { return _objects; }
 	const Common::Array<StartupAnimRecord> &getAnimations() const { return _animations; }
+	const Common::Array<StartupNpcRecord> &getNpcs() const { return _npcs; }
 	const Common::Array<StartupRegionRecord> &getRegions() const { return _regions; }
 	const Common::Array<StartupFlagRecord> &getFlags() const { return _flags; }
 	const Common::Array<StartupCommandRecord> &getCommands() const { return _commands; }
@@ -250,6 +270,7 @@ private:
 	StartupFlagRecord *findRuntimeFlag(const Common::String &flagName);
 	StartupObjectRecord *findRuntimeObject(const Common::String &ownerOrRoom, const Common::String &objectName);
 	StartupAnimRecord *findRuntimeAnim(const Common::String &animName);
+	StartupNpcRecord *findRuntimeNpc(const Common::String &npcName);
 	bool addRuntimeObjectToInventory(const Common::String &objectName);
 	bool buildRuntimeRoomState(const StartupRoomRecord &room, const StartupEntranceRecord *entrance,
 		StartupRoomSetupState &state) const;
@@ -265,6 +286,7 @@ private:
 	Common::Array<StartupRoomRecord> _rooms;
 	Common::Array<StartupObjectRecord> _objects;
 	Common::Array<StartupAnimRecord> _animations;
+	Common::Array<StartupNpcRecord> _npcs;
 	Common::Array<StartupRegionRecord> _regions;
 	Common::Array<StartupFlagRecord> _flags;
 	Common::Array<StartupCommandRecord> _commands;
@@ -273,6 +295,7 @@ private:
 	Common::Array<StartupFlagRecord> _runtimeFlags;
 	Common::Array<StartupObjectRecord> _runtimeObjects;
 	Common::Array<StartupAnimRecord> _runtimeAnimations;
+	Common::Array<StartupNpcRecord> _runtimeNpcs;
 	bool _quickTipsEnabled = true;
 };
 


Commit: c5b89630ea93579f839529fefedea9f9ae10dbdc
    https://github.com/scummvm/scummvm/commit/c5b89630ea93579f839529fefedea9f9ae10dbdc
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:46+02:00

Commit Message:
HARVESTER: Draw wait overlay over the live screen

Changed paths:
    engines/harvester/harvester.cpp
    engines/harvester/startup_art.cpp
    engines/harvester/startup_art.h
    engines/harvester/startup_flow.cpp


diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index b6a2fb86e7c..b2040bacb1b 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -321,7 +321,8 @@ Common::Error HarvesterEngine::run() {
 	_startupArt = new StartupArt();
 	if (!_startupArt->load(*_resources))
 		return Common::kReadingFailed;
-	_startupArt->drawWaitFrame();
+	if (_screen)
+		_startupArt->drawWaitFrame(*_screen);
 
 	_startupText = new StartupText();
 	if (!_startupText->load(*_resources))
diff --git a/engines/harvester/startup_art.cpp b/engines/harvester/startup_art.cpp
index e8df76c1f9b..9776e96f2d7 100644
--- a/engines/harvester/startup_art.cpp
+++ b/engines/harvester/startup_art.cpp
@@ -23,8 +23,8 @@
 
 #include "common/debug.h"
 #include "common/endian.h"
-#include "common/system.h"
-#include "graphics/paletteman.h"
+#include "graphics/blit.h"
+#include "graphics/screen.h"
 #include "harvester/detection.h"
 #include "harvester/resources.h"
 
@@ -69,12 +69,9 @@ static void logPaletteSummary(const char *label, const Common::String &path, con
 		palette[255 * 3], palette[255 * 3 + 1], palette[255 * 3 + 2]);
 }
 
-static const int kInventoryX = 64;
-static const int kInventoryY = 48;
-static const int kLogoX = 160;
-static const int kLogoY = 0;
 static const int kWaitX = 250;
 static const int kWaitY = 160;
+static const byte kTransparentPaletteIndex = 0;
 
 static const char *const kTextboxPaths[] = {
 	"1:/GRAPHIC/OTHER/TEXTBOX1.BM",
@@ -122,17 +119,15 @@ bool StartupArt::loadQuickTipsResources(ResourceManager &resources) {
 	return loadBitmap(resources, "1:/GRAPHIC/OTHER/TIPS.BM", _tipsBitmap);
 }
 
-void StartupArt::drawWaitFrame() const {
+void StartupArt::drawWaitFrame(Graphics::Screen &screen) const {
 	if (_waitFrames.empty() || !_waitFrames[0].isValid())
 		return;
 
 	logPaletteSummary("applying wait palette", "1:/GRAPHIC/PAL/WAIT.PAL", _waitPalette);
-	g_system->getPaletteManager()->setPalette(_waitPalette, 0, 256);
-	g_system->fillScreen(0);
-	blitBitmap(_inventoryBitmap, kInventoryX, kInventoryY);
-	blitBitmap(_logoBitmap, kLogoX, kLogoY);
-	blitAnimationFrame(_waitFrames, 0, kWaitX, kWaitY);
-	g_system->updateScreen();
+	screen.setPalette(_waitPalette);
+	blitTransparentAnimationFrame(screen, _waitFrames, 0, kWaitX, kWaitY);
+	screen.makeAllDirty();
+	screen.update();
 }
 
 bool StartupArt::loadPalette(ResourceManager &resources, const Common::String &path, byte *dest) const {
@@ -243,19 +238,48 @@ bool StartupArt::decodeAnimationFrame(const byte *source, uint32 sourceSize, boo
 	return dstOffset == dest.size();
 }
 
-void StartupArt::blitBitmap(const IndexedBitmap &bitmap, int x, int y) const {
+void StartupArt::blitTransparentBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) const {
 	if (!bitmap.isValid())
 		return;
 
-	g_system->copyRectToScreen(bitmap.pixels.data(), bitmap.width, x, y, bitmap.width, bitmap.height);
+	int srcX = 0;
+	int srcY = 0;
+	int destX = x;
+	int destY = y;
+	int width = (int)bitmap.width;
+	int height = (int)bitmap.height;
+
+	if (destX < 0) {
+		srcX = -destX;
+		width += destX;
+		destX = 0;
+	}
+	if (destY < 0) {
+		srcY = -destY;
+		height += destY;
+		destY = 0;
+	}
+	if (destX >= screen.w || destY >= screen.h || width <= 0 || height <= 0)
+		return;
+
+	width = MIN<int>(width, screen.w - destX);
+	height = MIN<int>(height, screen.h - destY);
+	if (width <= 0 || height <= 0)
+		return;
+
+	const byte *src = bitmap.pixels.data() + srcY * bitmap.width + srcX;
+	byte *dst = (byte *)screen.getBasePtr(destX, destY);
+	Graphics::keyBlit(dst, src, screen.pitch, bitmap.width, width, height,
+		screen.format.bytesPerPixel, kTransparentPaletteIndex);
 }
 
-void StartupArt::blitAnimationFrame(const Common::Array<AbmFrame> &frames, uint frameIndex, int x, int y) const {
+void StartupArt::blitTransparentAnimationFrame(Graphics::Screen &screen, const Common::Array<AbmFrame> &frames,
+		uint frameIndex, int x, int y) const {
 	if (frameIndex >= frames.size() || !frames[frameIndex].isValid())
 		return;
 
 	const AbmFrame &frame = frames[frameIndex];
-	blitBitmap(frame, x + frame.xOffset, y + frame.yOffset);
+	blitTransparentBitmap(screen, frame, x + frame.xOffset, y + frame.yOffset);
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/startup_art.h b/engines/harvester/startup_art.h
index c4f3bda6cae..449894f930e 100644
--- a/engines/harvester/startup_art.h
+++ b/engines/harvester/startup_art.h
@@ -24,6 +24,10 @@
 
 #include "common/array.h"
 
+namespace Graphics {
+class Screen;
+}
+
 namespace Harvester {
 
 class ResourceManager;
@@ -47,7 +51,7 @@ class StartupArt {
 public:
 	bool load(ResourceManager &resources);
 	bool loadQuickTipsResources(ResourceManager &resources);
-	void drawWaitFrame() const;
+	void drawWaitFrame(Graphics::Screen &screen) const;
 
 	const byte *getWaitPalette() const { return _waitPalette; }
 	const Common::Array<AbmFrame> &getWaitFrames() const { return _waitFrames; }
@@ -63,8 +67,9 @@ private:
 	bool loadBitmap(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) const;
 	bool loadAnimation(ResourceManager &resources, const Common::String &path, Common::Array<AbmFrame> &frames) const;
 	bool decodeAnimationFrame(const byte *source, uint32 sourceSize, bool compressed, Common::Array<byte> &dest) const;
-	void blitBitmap(const IndexedBitmap &bitmap, int x, int y) const;
-	void blitAnimationFrame(const Common::Array<AbmFrame> &frames, uint frameIndex, int x, int y) const;
+	void blitTransparentBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) const;
+	void blitTransparentAnimationFrame(Graphics::Screen &screen, const Common::Array<AbmFrame> &frames,
+		uint frameIndex, int x, int y) const;
 
 	byte _waitPalette[256 * 3] = { 0 };
 	Common::Array<AbmFrame> _waitFrames;
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 815c4ad4dc5..f47d6ba337f 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -2911,8 +2911,9 @@ Common::Error StartupFlow::beginRoomSetupTransition() {
 		_engine.getRuntimeEntities()->hideCursor();
 
 	const StartupArt *art = _engine.getStartupArt();
-	if (art)
-		art->drawWaitFrame();
+	Graphics::Screen *screen = _engine.getScreen();
+	if (art && screen)
+		art->drawWaitFrame(*screen);
 
 	Common::Error result = Common::kNoError;
 	if (pumpTransitionEvents(result))


Commit: 50a84917bd34e732802f6ba7506d0338e743658c
    https://github.com/scummvm/scummvm/commit/50a84917bd34e732802f6ba7506d0338e743658c
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:47+02:00

Commit Message:
HARVESTER: Fix room actor placement and npc targeting

Changed paths:
    engines/harvester/runtime_entity.cpp
    engines/harvester/startup_flow.cpp


diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index e8f4ef99d54..89e2418792e 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -729,9 +729,9 @@ RuntimeEntity *RuntimeEntityManager::spawnSceneActorEntity(const Common::String
 	if (!entity)
 		return nullptr;
 
-	entity->setAnchorMode(kRuntimeEntityAnchorCentered);
+	entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
 	entity->setZExtent(3.0f);
-	entity->setHitTestMode(kRuntimeEntityHitTestNone);
+	entity->setHitTestMode(kRuntimeEntityHitTestOpaquePixels);
 	entity->setCurrentFrame(initialFrame);
 	debugC(1, kDebugScene,
 		"Harvester: spawned scene actor '%s' resource='%s' frames=0..%d initial_frame=%d animation_rate=%d sequence=%d pos=(%d,%d,z=%.2f)",
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index f47d6ba337f..2dc2c891c13 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -138,6 +138,7 @@ struct StartupRoomPlayerState {
 
 struct StartupRoomHoverState {
 	const StartupObjectRecord *object = nullptr;
+	const StartupNpcRecord *npc = nullptr;
 	const StartupRegionRecord *region = nullptr;
 	const RuntimeEntity *playerEntity = nullptr;
 	Common::String promptText;
@@ -993,6 +994,16 @@ static const StartupRegionRecord *findSceneRegionByName(const Common::Array<Star
 	return nullptr;
 }
 
+static const StartupNpcRecord *findSceneNpcByName(const Common::Array<StartupNpcRecord> &npcs,
+		const Common::String &npcName) {
+	for (const StartupNpcRecord &npc : npcs) {
+		if (npc.npcName.equalsIgnoreCase(npcName))
+			return &npc;
+	}
+
+	return nullptr;
+}
+
 static StartupObjectRecord *findSceneObjectByName(Common::Array<StartupObjectRecord> &objects,
 		const Common::String &objectName) {
 	for (StartupObjectRecord &object : objects) {
@@ -1065,6 +1076,19 @@ static const RuntimeEntity *findRoomPlayerAtPoint(HarvesterEngine &engine, const
 	return entity;
 }
 
+static const StartupNpcRecord *findRoomNpcAtPoint(HarvesterEngine &engine,
+		const Common::Array<StartupNpcRecord> &sceneNpcs, const Common::Point &point) {
+	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
+	if (!runtimeEntities)
+		return nullptr;
+
+	const RuntimeEntity *entity = runtimeEntities->findTopSceneEntityAt(point);
+	if (!entity || entity->getClassId() != kRuntimeEntityClassNpc)
+		return nullptr;
+
+	return findSceneNpcByName(sceneNpcs, entity->getName());
+}
+
 static const IndexedBitmap *resolveInspectTextboxBitmap(const StartupArt &art, const StartupResolvedText &text) {
 	if (text.boxName.equalsIgnoreCase("BOX1"))
 		return art.getTextboxBitmap(0);
@@ -1136,6 +1160,18 @@ static Common::String buildRoomObjectPrompt(const StartupObjectRecord &object, S
 	return Common::String::format("Examine %s", label.c_str());
 }
 
+static Common::String buildRoomNpcPrompt(const StartupNpcRecord &npc) {
+	Common::String label = !npc.entityInitArg.empty() ? npc.entityInitArg : npc.npcName;
+	for (uint i = 0; i < label.size(); ++i) {
+		if (label[i] == '_')
+			label.setChar(' ', i);
+	}
+
+	if (label.empty())
+		return Common::String();
+	return Common::String::format("Talk to %s", label.c_str());
+}
+
 static bool doesPlayerFacingMatchRegion(int playerFacing, const StartupRegionRecord &region) {
 	return region.desiredFacing < 0 || playerFacing == region.desiredFacing;
 }
@@ -1153,6 +1189,7 @@ static bool doesPlayerOverlapRegion(const RuntimeEntity &playerEntity, const Sta
 
 static StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const StartupRoomSetupState &state,
 		const Common::Array<StartupObjectRecord> &sceneObjects,
+		const Common::Array<StartupNpcRecord> &sceneNpcs,
 		const Common::Array<StartupRegionRecord> &sceneRegions, const Common::Point &mousePos) {
 	StartupRoomHoverState hoverState;
 	if (const RuntimeEntity *playerEntity = findRoomPlayerAtPoint(engine, mousePos)) {
@@ -1160,6 +1197,12 @@ static StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, cons
 		hoverState.cursorSequence = kCursorSequenceExamine;
 		return hoverState;
 	}
+	if (const StartupNpcRecord *npc = findRoomNpcAtPoint(engine, sceneNpcs, mousePos)) {
+		hoverState.npc = npc;
+		hoverState.cursorSequence = kCursorSequenceTalk;
+		hoverState.promptText = buildRoomNpcPrompt(*npc);
+		return hoverState;
+	}
 
 	StartupScript *startupScript = engine.getStartupScript();
 	if (!startupScript)
@@ -1638,7 +1681,7 @@ static void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneR
 		const Common::String objectLabel = startupScript->resolveObjectLabel(*hoveredObject);
 		StartupResolvedText inspectText;
 		const StartupRoomHoverState hoverState = resolveRoomHoverState(
-			engine, scene.state, scene.sceneObjects, scene.sceneRegions, probePoint);
+			engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions, probePoint);
 		const bool hasInteraction = startupScript->hasObjectInteraction(*hoveredObject);
 		const bool hasInspectText = startupScript->resolveObjectInspectText(*hoveredObject, inspectText);
 		debugC(1, kDebugGeneral,
@@ -1654,7 +1697,8 @@ static void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneR
 			for (int y = scene.state.roomMaxZScreenY; y <= scene.state.roomMinZScreenY && !foundFloorProbe; y += 12) {
 				for (int x = 48; x < 592; x += 16) {
 					const StartupRoomHoverState candidateHover = resolveRoomHoverState(
-						engine, scene.state, scene.sceneObjects, scene.sceneRegions, Common::Point(x, y));
+						engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions,
+						Common::Point(x, y));
 					if (candidateHover.cursorSequence == kCursorSequenceWalk) {
 						floorProbe = Common::Point(x, y);
 						foundFloorProbe = true;
@@ -1664,7 +1708,8 @@ static void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneR
 			}
 
 			const StartupRoomHoverState floorHover = foundFloorProbe
-				? resolveRoomHoverState(engine, scene.state, scene.sceneObjects, scene.sceneRegions, floorProbe)
+				? resolveRoomHoverState(engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
+					scene.sceneRegions, floorProbe)
 				: StartupRoomHoverState();
 			debugC(1, kDebugGeneral,
 				"Harvester: startup probe floor room='%s' point=(%d,%d) found=%d cursor_sequence=%d prompt='%s'",
@@ -2396,7 +2441,8 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 				(inventoryState.open && (inventoryPanelContainsMouse || inventoryState.selectedItemName.empty()));
 			StartupRoomHoverState hoverState = suppressHover
 				? StartupRoomHoverState()
-				: resolveRoomHoverState(_engine, scene.state, scene.sceneObjects, scene.sceneRegions, _mousePos);
+				: resolveRoomHoverState(_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
+					scene.sceneRegions, _mousePos);
 			Common::String promptText;
 			if (inventoryState.open) {
 				const StartupInventoryVisual *inventoryHover = findInventoryVisualAtPoint(inventoryState.items, _mousePos);
@@ -2520,7 +2566,8 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 
 					if (!inventoryState.selectedItemName.empty()) {
 						const StartupRoomHoverState useHoverState = resolveRoomHoverState(
-							_engine, scene.state, scene.sceneObjects, scene.sceneRegions, _mousePos);
+							_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
+							scene.sceneRegions, _mousePos);
 						const StartupObjectRecord *roomTarget = useHoverState.object
 							? findSceneObjectByName(scene.sceneObjects, useHoverState.object->objectName)
 							: nullptr;
@@ -2544,11 +2591,12 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 				}
 
 				const StartupRoomHoverState hoverState = resolveRoomHoverState(
-					_engine, scene.state, scene.sceneObjects, scene.sceneRegions, _mousePos);
+					_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions, _mousePos);
 				debugC(1, kDebugScene,
-					"Harvester: room click room='%s' mouse=(%d,%d) object='%s' region='%s' cursor_sequence=%d prompt='%s'",
+					"Harvester: room click room='%s' mouse=(%d,%d) object='%s' npc='%s' region='%s' cursor_sequence=%d prompt='%s'",
 					scene.state.roomName.c_str(), _mousePos.x, _mousePos.y,
 					hoverState.object ? hoverState.object->objectName.c_str() : "",
+					hoverState.npc ? hoverState.npc->npcName.c_str() : "",
 					hoverState.region ? hoverState.region->regionName.c_str() : "",
 					hoverState.cursorSequence, hoverState.promptText.c_str());
 				if (hoverState.playerEntity) {
@@ -2563,6 +2611,13 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 					needsRedraw = true;
 					break;
 				}
+				if (hoverState.npc) {
+					pendingRegionName.clear();
+					debug(1, "Harvester: native room NPC dialogue path for '%s' is not implemented",
+						hoverState.npc->npcName.c_str());
+					needsRedraw = true;
+					break;
+				}
 				if (hoverState.region && playerState.entity) {
 					queueRegionInteraction(*hoverState.region);
 					Common::Error interactionError = tryActivatePendingRegion();
@@ -2867,7 +2922,7 @@ bool StartupFlow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 		}
 
 		entity->setClassId(kRuntimeEntityClassNpc);
-		entity->setHitTestMode(kRuntimeEntityHitTestNone);
+		entity->setHitTestMode(kRuntimeEntityHitTestOpaquePixels);
 		entity->setLooping(true);
 		if (npc.frameDelay > 0)
 			entity->setAnimationRate(npc.frameDelay);


Commit: 1407c761a05f403ff723a0594edb8c1c3e8eb478
    https://github.com/scummvm/scummvm/commit/1407c761a05f403ff723a0594edb8c1c3e8eb478
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:47+02:00

Commit Message:
HARVESTER: Fix room entry actor offsets and pickups

Changed paths:
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_script.cpp


diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index 89e2418792e..e95ac72ba7f 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -78,8 +78,8 @@ static void blitAnimationFrame(Graphics::Screen &screen, const Common::Array<Abm
 		return;
 
 	const AbmFrame &frame = frames[frameIndex];
-	int destX = x + frame.xOffset;
-	int destY = y + frame.yOffset;
+	int destX = x;
+	int destY = y;
 	int srcX = 0;
 	int srcY = 0;
 	int width = (int)frame.width;
@@ -811,6 +811,26 @@ RuntimeEntity *RuntimeEntityManager::findSceneEntityByName(const Common::String
 	return nullptr;
 }
 
+RuntimeEntity *RuntimeEntityManager::detachSceneEntityByName(const Common::String &name) {
+	for (uint i = 0; i < _sceneEntities.size(); ++i) {
+		RuntimeEntity *entity = _sceneEntities[i];
+		if (!entity->getName().equalsIgnoreCase(name))
+			continue;
+
+		_sceneEntities.remove_at(i);
+		return entity;
+	}
+
+	return nullptr;
+}
+
+void RuntimeEntityManager::adoptSceneEntity(RuntimeEntity *entity) {
+	if (!entity)
+		return;
+
+	insertSceneEntity(entity);
+}
+
 void RuntimeEntityManager::insertSceneEntity(RuntimeEntity *entity) {
 	uint insertIndex = 0;
 	while (insertIndex < _sceneEntities.size() && _sceneEntities[insertIndex]->getZ() >= entity->getZ())
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index 6dfb986ee01..9f8d3d72f5a 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -176,6 +176,8 @@ public:
 	const RuntimeEntity *findTopSceneEntityAt(const Common::Point &point, int classIdFilter = -1) const;
 	const RuntimeEntity *findSceneEntityByName(const Common::String &name) const;
 	RuntimeEntity *findSceneEntityByName(const Common::String &name);
+	RuntimeEntity *detachSceneEntityByName(const Common::String &name);
+	void adoptSceneEntity(RuntimeEntity *entity);
 
 private:
 	void insertSceneEntity(RuntimeEntity *entity);
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 2dc2c891c13..61125ed4aaf 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -896,6 +896,8 @@ static bool isInteractiveSceneHotspot(const StartupObjectRecord &object, Startup
 		return true;
 	if (!startupScript)
 		return false;
+	if (startupScript->isPickupObject(object))
+		return true;
 
 	StartupResolvedText inspectText;
 	return startupScript->resolveObjectInspectText(object, inspectText);
@@ -2839,6 +2841,7 @@ bool StartupFlow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 	if (!runtimeEntities)
 		return false;
 
+	RuntimeEntity *preservedPlayer = runtimeEntities->detachSceneEntityByName(kPlayerActorEntityName);
 	runtimeEntities->clearSceneEntities();
 	for (const StartupRegionRecord &region : state.roomRegions) {
 		const Common::Rect regionBounds = getRegionBounds(region);
@@ -2938,24 +2941,38 @@ bool StartupFlow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 	}
 	if (state.hasEntrance) {
 		const int playerFrame = resolvePlayerFacingFrame(state.playerFacing);
-		RuntimeEntity *player = runtimeEntities->spawnSceneActorEntity(kPlayerActorEntityName,
-			kPlayerActorResourcePath, Common::Point(state.playerSpawnX, state.playerSpawnY),
-			(float)state.playerSpawnZ, playerFrame);
+		const bool reusedPlayer = preservedPlayer != nullptr;
+		RuntimeEntity *player = preservedPlayer;
+		if (!reusedPlayer) {
+			player = runtimeEntities->spawnSceneActorEntity(kPlayerActorEntityName,
+				kPlayerActorResourcePath, Common::Point(state.playerSpawnX, state.playerSpawnY),
+				(float)state.playerSpawnZ, playerFrame);
+		}
 		if (!player) {
 			debug(1, "Harvester: unable to spawn startup player actor from '%s'", kPlayerActorResourcePath);
 		} else {
 			player->setClassId(kRuntimeEntityClassPlayer);
+			player->setAnchorMode(kRuntimeEntityAnchorTopLeft);
 			player->setHitTestMode(kRuntimeEntityHitTestOpaquePixels);
+			player->setVisible(true);
+			player->setAnimationRate(0);
+			player->setAnimationFrameRange(playerFrame, playerFrame, false);
+			player->setCurrentFrame(playerFrame);
 			if (!applyStartupActorPlacement(state, *player)) {
 				debug(1, "Harvester: unable to apply startup player placement for entrance '%s'",
 					state.entranceName.c_str());
 			}
+			if (reusedPlayer)
+				runtimeEntities->adoptSceneEntity(player);
 			debugC(1, kDebugGeneral,
-				"Harvester: spawned startup player actor '%s' entrance='%s' pos=(%d,%d,%d) facing=%d frame=%d",
-				kPlayerActorResourcePath, state.entranceName.c_str(),
+				"Harvester: %s startup player actor '%s' entrance='%s' pos=(%d,%d,%d) facing=%d frame=%d",
+				reusedPlayer ? "reused" : "spawned", kPlayerActorResourcePath, state.entranceName.c_str(),
 				state.playerSpawnX, state.playerSpawnY, state.playerSpawnZ,
 				state.playerFacing, playerFrame);
 		}
+		preservedPlayer = nullptr;
+	} else if (preservedPlayer) {
+		delete preservedPlayer;
 	}
 
 	return true;
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index b2c4680badd..fd72e4d6636 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -646,8 +646,7 @@ bool StartupScript::resolveUseItemInteraction(const Common::String &itemName, co
 }
 
 bool StartupScript::isPickupObject(const StartupObjectRecord &object) const {
-	return object.operatable &&
-		!object.altSpritePath.empty() &&
+	return !object.altSpritePath.empty() &&
 		!object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName);
 }
 


Commit: cc2b0c8942989c9e12a26a67912d0988776269d8
    https://github.com/scummvm/scummvm/commit/cc2b0c8942989c9e12a26a67912d0988776269d8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:47+02:00

Commit Message:
HARVESTER: Add hank room dialogue

Changed paths:
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/startup_art.cpp
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_flow.h
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h
    engines/harvester/startup_text.cpp
    engines/harvester/startup_text.h


diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index b2040bacb1b..a69592eecbd 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -201,6 +201,11 @@ bool HarvesterEngine::playStartupSingleSound(const Common::String &path) {
 	return true;
 }
 
+bool HarvesterEngine::isStartupSingleSoundPlaying() const {
+	return g_system && g_system->getMixer() &&
+		g_system->getMixer()->isSoundHandleActive(_startupSingleSoundHandle);
+}
+
 bool HarvesterEngine::loadStartupSound(int slot, const Common::String &path) {
 	if (!validateStartupLoadedSoundSlot(slot) || path.empty() || !_resources)
 		return false;
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 6c1ef2552e4..1a3a0909173 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -85,6 +85,7 @@ public:
 	bool executeStartupAudioCommand(const StartupAudioCommand &command);
 	bool playStartupSound(const Common::String &path);
 	bool playStartupSingleSound(const Common::String &path);
+	bool isStartupSingleSoundPlaying() const;
 	bool loadStartupSound(int slot, const Common::String &path);
 	bool playStartupLoadedSound(int slot);
 	bool deleteStartupLoadedSound(int slot);
diff --git a/engines/harvester/startup_art.cpp b/engines/harvester/startup_art.cpp
index 9776e96f2d7..df3966a19ba 100644
--- a/engines/harvester/startup_art.cpp
+++ b/engines/harvester/startup_art.cpp
@@ -77,7 +77,11 @@ static const char *const kTextboxPaths[] = {
 	"1:/GRAPHIC/OTHER/TEXTBOX1.BM",
 	"1:/GRAPHIC/OTHER/TEXTBOX2.BM",
 	"1:/GRAPHIC/OTHER/TEXTBOX3.BM",
-	"1:/GRAPHIC/OTHER/TEXTBOX4.BM"
+	"1:/GRAPHIC/OTHER/TEXTBOX4.BM",
+	"1:/GRAPHIC/OTHER/TEXTBOX5.BM",
+	"1:/GRAPHIC/OTHER/TEXTBOX6.BM",
+	"1:/GRAPHIC/OTHER/TEXTBOX7.BM",
+	"1:/GRAPHIC/OTHER/TEXTBOX8.BM"
 };
 
 static const char *const kAmmoIconPaths[] = {
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 61125ed4aaf..7c207c73c8b 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -20,6 +20,7 @@
  */
 
 #include <math.h>
+#include <memory>
 
 #include "harvester/startup_flow.h"
 
@@ -36,6 +37,7 @@
 #include "graphics/framelimiter.h"
 #include "harvester/cft_font.h"
 #include "harvester/detection.h"
+#include "harvester/fst_player.h"
 #include "harvester/harvester.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
@@ -61,6 +63,29 @@ static const int kLogoX = 160;
 static const int kLogoY = 0;
 static const int kQuickTipsOverlayX = 167;
 static const int kQuickTipsOverlayY = 200;
+static const int kDialogueOverlayX = 167;
+static const int kDialogueOverlayY = 6;
+static const int kDialogueLeftHeadX = 6;
+static const int kDialogueRightHeadX = 474;
+static const int kDialogueHeadY = 6;
+static const int kDialogueSubtitleTextX = 178;
+static const int kDialogueSubtitleTextY = 12;
+static const int kDialogueSubtitleTextWidth = 316;
+static const int kDialogueKeywordTextX = 178;
+static const int kDialogueKeywordTextY = 34;
+static const int kDialogueKeywordTextWidth = 300;
+static const int kDialogueOtherX = 171;
+static const int kDialogueOtherY = 183;
+static const int kDialogueTextEntryX = 208;
+static const int kDialogueTextEntryY = 184;
+static const int kDialogueOtherWidth = 120;
+static const int kDialogueTopicStartX = 168;
+static const int kDialogueTopicEndX = 482;
+static const int kDialogueOtherStartY = 170;
+static const int kDialogueOtherEndY = 193;
+static const int kDialogueGenericByeResponseIndex = 13;
+static const char *const kDialogueKeywordBitmapPath = "1:/GRAPHIC/OTHER/KEYWORD.BM";
+static const char *const kDialogueRangshotFstPath = "GRAPHIC/FST/RANGSHOT.FST";
 
 static const int kQuickTipTextX = 180;
 static const int kQuickTipTextY = 228;
@@ -170,6 +195,34 @@ struct StartupRoomIdleAnimationState {
 	bool exiting = false;
 };
 
+struct HankDialogueTopicLine {
+	int responseLineIndex;
+	int wavId;
+	const char *speakerId;
+	bool setDiscussedLodgeTopic;
+};
+
+static const HankDialogueTopicLine kHankDialogueTopicLines[] = {
+	{ 0xd2, 0x725, "HANK", false },
+	{ 0xd6, 0x74f, "PC", false },
+	{ 0xd8, 0x76d, "PC", false },
+	{ 0xd9, 0x76d, "PC", false },
+	{ 0xdb, 0x77b, "PC", false },
+	{ 0xdc, 0x77b, "PC", false },
+	{ 0xe0, 0x7ad, "HANK", false },
+	{ 0xe1, 0x7b7, "PC", false },
+	{ 0xe3, 0x7d6, "PC", false },
+	{ 0xe5, 0x7f4, "HANK", false },
+	{ 0xe6, 0x7fc, "PC", false },
+	{ 0xe9, 0x82d, "PC", false },
+	{ 0xeb, 0x863, "PC", false },
+	{ 0xed, 0x881, "PC", false },
+	{ 0xee, 0x8a0, "HANK", false },
+	{ 0xef, 0x8a0, "HANK", false },
+	{ 0xf0, 0x8a0, "HANK", false },
+	{ 0xf1, 0x8bf, "HANK", true }
+};
+
 struct PlayerAnimationRange {
 	PlayerAnimationRange() {}
 	PlayerAnimationRange(int walkFirstFrame, int walkLastFrame, int idleFrame)
@@ -835,6 +888,85 @@ static int getNativeRoomMenuLineHeight(const Graphics::Font &selectedFont) {
 	return selectedFont.getFontHeight() + 2;
 }
 
+static bool isPrimaryMouseDown() {
+	return g_system && g_system->getEventManager() &&
+		((g_system->getEventManager()->getButtonState() & 1) != 0);
+}
+
+static bool isSecondaryMouseDown() {
+	return g_system && g_system->getEventManager() &&
+		((g_system->getEventManager()->getButtonState() & 2) != 0);
+}
+
+static int getDialogueTextLineHeight(const Graphics::Font &font) {
+	return font.getFontHeight() + 2;
+}
+
+static int resolveDialogueTextboxIndex(uint lineCount) {
+	if (lineCount <= 1)
+		return 0;
+	if (lineCount < 3)
+		return 1;
+	if (lineCount < 4)
+		return 2;
+	if (lineCount < 6)
+		return 3;
+	if (lineCount < 7)
+		return 4;
+	if (lineCount < 9)
+		return 5;
+	if (lineCount < 11)
+		return 6;
+	return 7;
+}
+
+static void drawDialogueTextLines(Graphics::Screen &screen, const Graphics::Font &font,
+		const Common::Array<Common::String> &lines, int x, int y, int width) {
+	const int lineHeight = getDialogueTextLineHeight(font);
+	for (uint i = 0; i < lines.size(); ++i)
+		font.drawString(&screen, lines[i], x, y + (int)i * lineHeight, width, 0);
+}
+
+static bool loadDialogueHeadBitmap(HarvesterEngine &engine, const Common::String &speakerId,
+		IndexedBitmap &bitmap) {
+	StartupScript *startupScript = engine.getStartupScript();
+	ResourceManager *resources = engine.getResources();
+	if (!startupScript || !resources || speakerId.empty())
+		return false;
+
+	const Common::String headId = Common::String::format("%s0", speakerId.c_str());
+	const StartupHeadRecord *head = startupScript->findHeadRecord(headId);
+	if (!head)
+		return false;
+
+	return loadBitmapResource(*resources, head->portraitPath + ".BM", bitmap);
+}
+
+static Common::String buildDialogueVoicePath(const StartupScript &startupScript, int wavId) {
+	if (wavId <= 0)
+		return Common::String();
+
+	return Common::String::format("%s%d.CMP", startupScript.getVoicePath().c_str(), wavId);
+}
+
+static int getDialogueMenuItemAt(const Graphics::Font &font, uint itemCount, const Common::Point &mousePos) {
+	if (itemCount == 0)
+		return -1;
+	if (mousePos.x < kDialogueTopicStartX || mousePos.x > kDialogueTopicEndX)
+		return -1;
+	if (mousePos.y < kDialogueKeywordTextY)
+		return -1;
+
+	const int lineHeight = getDialogueTextLineHeight(font);
+	const int selection = (mousePos.y - kDialogueKeywordTextY) / MAX<int>(1, lineHeight);
+	return selection >= 0 && selection < (int)itemCount ? selection : -1;
+}
+
+static bool isDialogueOtherHit(const Common::Point &mousePos) {
+	return mousePos.x >= kDialogueTopicStartX && mousePos.x <= kDialogueTopicEndX &&
+		mousePos.y >= kDialogueOtherStartY && mousePos.y <= kDialogueOtherEndY;
+}
+
 static int getNativeRoomMenuSelectionFromMouse(const Graphics::Font &selectedFont, uint itemCount,
 		const Common::Point &mousePos) {
 	if (itemCount == 0)
@@ -1813,6 +1945,7 @@ Common::Error StartupFlow::run() {
 	if (!ensureCursorEntity())
 		return Common::kReadingFailed;
 
+	resetRoomNpcDialogueState();
 	_engine.getStartupScript()->resetRuntimeState();
 	Common::Error error = runQuickTips();
 	if (error.getCode() != Common::kNoError)
@@ -2152,6 +2285,456 @@ Common::Error StartupFlow::runRoomMenuStub(const IndexedBitmap &backdrop) {
 	return Common::kNoError;
 }
 
+Common::Error StartupFlow::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, const StartupNpcRecord &npc, const Common::String &usedItemName) {
+	Graphics::Screen *screen = _engine.getScreen();
+	StartupScript *startupScript = _engine.getStartupScript();
+	StartupText *startupText = _engine.getStartupText();
+	StartupArt *startupArt = _engine.getStartupArt();
+	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+	const Graphics::Font *fallbackFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	if (!screen || !startupScript || !startupText || !startupArt || !fallbackFont || !backdrop.isValid())
+		return Common::kReadingFailed;
+
+	const CftFontResource *normalFontResource = findStartupFontByName(_engine, "TEXTFONT");
+	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "TEXTFNT2");
+	std::unique_ptr<HarvesterCftFont> normalCftFont;
+	std::unique_ptr<HarvesterCftFont> selectedCftFont;
+	if (normalFontResource) {
+		normalCftFont.reset(new HarvesterCftFont(*normalFontResource));
+		if (!normalCftFont->isValid())
+			normalCftFont.reset();
+	}
+	if (selectedFontResource) {
+		selectedCftFont.reset(new HarvesterCftFont(*selectedFontResource));
+		if (!selectedCftFont->isValid())
+			selectedCftFont.reset();
+	}
+
+	const Graphics::Font *normalFont = normalCftFont ? static_cast<const Graphics::Font *>(normalCftFont.get()) : fallbackFont;
+	const Graphics::Font *selectedFont = selectedCftFont ? static_cast<const Graphics::Font *>(selectedCftFont.get()) : normalFont;
+	const bool normalFontUsesCft = normalCftFont.get() != nullptr;
+	const bool selectedFontUsesCft = selectedCftFont.get() != nullptr;
+
+	IndexedBitmap keywordBitmap;
+	if (!loadBitmapResource(*_engine.getResources(), kDialogueKeywordBitmapPath, keywordBitmap))
+		return Common::kReadingFailed;
+
+	Common::String genericByeTopic = startupText->getDialogueResponseLine(kDialogueGenericByeResponseIndex);
+	if (genericByeTopic.empty())
+		genericByeTopic = "BYE";
+
+	IndexedBitmap leftHeadBitmap;
+	IndexedBitmap rightHeadBitmap;
+	Common::String leftHeadSpeakerId = npc.npcName;
+	Common::String rightHeadSpeakerId = "PC";
+	(void)loadDialogueHeadBitmap(_engine, leftHeadSpeakerId, leftHeadBitmap);
+	(void)loadDialogueHeadBitmap(_engine, rightHeadSpeakerId, rightHeadBitmap);
+
+	auto waitForPointerRelease = [&]() -> Common::Error {
+		Graphics::FrameLimiter limiter(g_system, 60);
+		while (!_engine.shouldQuit() && (isPrimaryMouseDown() || isSecondaryMouseDown())) {
+			Common::Event event;
+			while (g_system->getEventManager()->pollEvent(event)) {
+				Common::Error result = Common::kNoError;
+				if (handleSystemEvent(event, result))
+					return result;
+			}
+
+			if (runtimeEntities)
+				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+			limiter.delayBeforeSwap();
+			limiter.startFrame();
+		}
+
+		return Common::kNoError;
+	};
+
+	auto drawFontString = [&](const Graphics::Font &font, bool usesCft, const Common::String &text,
+			int x, int y, int width, byte color) {
+		if (usesCft)
+			font.drawString(screen, text, x, y, width, 0);
+		else
+			drawShadowedString(*screen, font, text, x, y, width, color);
+	};
+
+	auto ensureSpeakerPortrait = [&](const Common::String &speakerId) {
+		if (speakerId.empty())
+			return;
+
+		IndexedBitmap *targetBitmap = speakerId.equalsIgnoreCase("PC") ? &rightHeadBitmap : &leftHeadBitmap;
+		Common::String *targetSpeakerId = speakerId.equalsIgnoreCase("PC") ? &rightHeadSpeakerId : &leftHeadSpeakerId;
+		if (targetBitmap->isValid() && targetSpeakerId->equalsIgnoreCase(speakerId))
+			return;
+
+		IndexedBitmap updatedBitmap;
+		if (loadDialogueHeadBitmap(_engine, speakerId, updatedBitmap)) {
+			*targetBitmap = updatedBitmap;
+			*targetSpeakerId = speakerId;
+		} else {
+			warning("Harvester: unable to load dialogue head for '%s'", speakerId.c_str());
+		}
+	};
+
+	auto drawDialogueOverlay = [&](const IndexedBitmap *overlayBitmap,
+			const Common::Array<Common::String> *subtitleLines, const Common::Array<Common::String> *topics,
+			int hoveredTopicIndex, bool hoverOther, const Common::String *textEntryValue) {
+		setScaledPalette(*screen, palette, paletteBrightness);
+		blitBitmap(*screen, backdrop, 0, 0);
+		if (leftHeadBitmap.isValid())
+			blitTransparentBitmap(*screen, leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
+		if (rightHeadBitmap.isValid())
+			blitTransparentBitmap(*screen, rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
+		if (overlayBitmap && overlayBitmap->isValid())
+			blitTransparentBitmap(*screen, *overlayBitmap, kDialogueOverlayX, kDialogueOverlayY);
+
+		if (subtitleLines) {
+			if (normalFontUsesCft)
+				drawDialogueTextLines(*screen, *normalFont, *subtitleLines,
+					kDialogueSubtitleTextX, kDialogueSubtitleTextY, kDialogueSubtitleTextWidth);
+			else {
+				const int lineHeight = getDialogueTextLineHeight(*normalFont);
+				for (uint i = 0; i < subtitleLines->size(); ++i) {
+					drawShadowedString(*screen, *normalFont, (*subtitleLines)[i],
+						kDialogueSubtitleTextX,
+						kDialogueSubtitleTextY + (int)i * lineHeight,
+						kDialogueSubtitleTextWidth, kTextColorNormal);
+				}
+			}
+		}
+
+		if (topics) {
+			const int lineHeight = getDialogueTextLineHeight(*normalFont);
+			for (uint i = 0; i < topics->size(); ++i) {
+				const bool highlighted = (int)i == hoveredTopicIndex;
+				const Graphics::Font &font = highlighted ? *selectedFont : *normalFont;
+				const bool usesCft = highlighted ? selectedFontUsesCft : normalFontUsesCft;
+				drawFontString(font, usesCft, (*topics)[i], kDialogueKeywordTextX,
+					kDialogueKeywordTextY + (int)i * lineHeight, kDialogueKeywordTextWidth,
+					highlighted ? kTextColorHover : kTextColorNormal);
+			}
+
+			const Graphics::Font &otherFont = hoverOther ? *selectedFont : *normalFont;
+			const bool otherUsesCft = hoverOther ? selectedFontUsesCft : normalFontUsesCft;
+			drawFontString(otherFont, otherUsesCft, "Other", kDialogueOtherX, kDialogueOtherY,
+				kDialogueOtherWidth, hoverOther ? kTextColorHover : kTextColorNormal);
+		}
+
+		if (textEntryValue) {
+			drawFontString(*selectedFont, selectedFontUsesCft,
+				*textEntryValue + "_", kDialogueTextEntryX, kDialogueTextEntryY,
+				kDialogueKeywordTextWidth - 30, kTextColorHover);
+		}
+
+		if (runtimeEntities)
+			runtimeEntities->drawCursor(*screen);
+		screen->makeAllDirty();
+		screen->update();
+	};
+
+	auto playDialogueLine = [&](int wavId, const Common::String &speakerId) -> Common::Error {
+		ensureSpeakerPortrait(speakerId);
+
+		Common::String subtitleText;
+		const bool textEnabled = startupScript->getDialogueTextMode() != kStartupDialogueTextNone &&
+			startupText->resolveDialogueSubtitle(wavId, subtitleText);
+		Common::Array<Common::String> subtitleLines;
+		const IndexedBitmap *textboxBitmap = nullptr;
+		if (textEnabled) {
+			normalFont->wordWrapText(subtitleText, kDialogueSubtitleTextWidth, subtitleLines);
+			textboxBitmap = startupArt->getTextboxBitmap(resolveDialogueTextboxIndex(subtitleLines.size()));
+		}
+
+		const Common::String voicePath = buildDialogueVoicePath(*startupScript, wavId);
+		const bool voiceStarted = !voicePath.empty() && _engine.playStartupSingleSound(voicePath);
+		Common::Error releaseError = waitForPointerRelease();
+		if (releaseError.getCode() != Common::kNoError) {
+			_engine.stopStartupSound();
+			return releaseError;
+		}
+
+		bool interrupted = false;
+		Graphics::FrameLimiter limiter(g_system, 60);
+		for (;;) {
+			drawDialogueOverlay(textboxBitmap, textEnabled ? &subtitleLines : nullptr, nullptr, -1, false, nullptr);
+
+			Common::Event event;
+			while (g_system->getEventManager()->pollEvent(event)) {
+				Common::Error result = Common::kNoError;
+				if (handleSystemEvent(event, result)) {
+					_engine.stopStartupSound();
+					return result;
+				}
+
+				switch (event.type) {
+				case Common::EVENT_LBUTTONDOWN:
+				case Common::EVENT_RBUTTONDOWN:
+					interrupted = true;
+					break;
+				case Common::EVENT_KEYDOWN:
+					if (event.kbd.keycode == Common::KEYCODE_ESCAPE ||
+							event.kbd.keycode == Common::KEYCODE_RETURN ||
+							event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
+							event.kbd.keycode == Common::KEYCODE_SPACE) {
+						interrupted = true;
+					}
+					break;
+				default:
+					break;
+				}
+			}
+
+			if (runtimeEntities)
+				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+			if (interrupted || (!voiceStarted || !_engine.isStartupSingleSoundPlaying()))
+				break;
+
+			limiter.delayBeforeSwap();
+			limiter.startFrame();
+		}
+
+		_engine.stopStartupSound();
+		if (interrupted) {
+			Common::Error releaseResult = waitForPointerRelease();
+			if (releaseResult.getCode() != Common::kNoError)
+				return releaseResult;
+		} else if (textEnabled && startupScript->getDialogueTextMode() == kStartupDialogueTextClick) {
+			Graphics::FrameLimiter clickLimiter(g_system, 60);
+			for (;;) {
+				drawDialogueOverlay(textboxBitmap, &subtitleLines, nullptr, -1, false, nullptr);
+
+				bool continuePressed = false;
+				Common::Event event;
+				while (g_system->getEventManager()->pollEvent(event)) {
+					Common::Error result = Common::kNoError;
+					if (handleSystemEvent(event, result))
+						return result;
+
+					switch (event.type) {
+					case Common::EVENT_LBUTTONDOWN:
+					case Common::EVENT_RBUTTONDOWN:
+						continuePressed = true;
+						break;
+					case Common::EVENT_KEYDOWN:
+						if (event.kbd.keycode == Common::KEYCODE_ESCAPE ||
+								event.kbd.keycode == Common::KEYCODE_RETURN ||
+								event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
+								event.kbd.keycode == Common::KEYCODE_SPACE) {
+							continuePressed = true;
+						}
+						break;
+					default:
+						break;
+					}
+				}
+
+				if (runtimeEntities)
+					(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+				if (continuePressed)
+					break;
+
+				clickLimiter.delayBeforeSwap();
+				clickLimiter.startFrame();
+			}
+
+			Common::Error releaseResult = waitForPointerRelease();
+			if (releaseResult.getCode() != Common::kNoError)
+				return releaseResult;
+		}
+
+		return Common::kNoError;
+	};
+
+	auto runKeywordMenu = [&](Common::String &selectedTopic) -> Common::Error {
+		selectedTopic.clear();
+		Common::Array<Common::String> topics;
+		topics.push_back(genericByeTopic);
+		bool editingOther = false;
+		Common::String typedTopic;
+		Common::Error releaseError = waitForPointerRelease();
+		if (releaseError.getCode() != Common::kNoError)
+			return releaseError;
+
+		Graphics::FrameLimiter limiter(g_system, 60);
+		for (;;) {
+			const int hoveredTopicIndex = editingOther ? -1 : getDialogueMenuItemAt(*normalFont, topics.size(), _mousePos);
+			const bool hoverOther = !editingOther && isDialogueOtherHit(_mousePos);
+			drawDialogueOverlay(&keywordBitmap, nullptr, &topics, hoveredTopicIndex, hoverOther,
+				editingOther ? &typedTopic : nullptr);
+
+			Common::Event event;
+			while (g_system->getEventManager()->pollEvent(event)) {
+				Common::Error result = Common::kNoError;
+				if (handleSystemEvent(event, result))
+					return result;
+
+				if (editingOther) {
+					if (event.type == Common::EVENT_KEYDOWN) {
+						if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+							typedTopic.clear();
+							editingOther = false;
+							if (runtimeEntities)
+								runtimeEntities->showCursor();
+						} else if (event.kbd.keycode == Common::KEYCODE_BACKSPACE) {
+							if (!typedTopic.empty())
+								typedTopic.deleteLastChar();
+						} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+								event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
+							if (!typedTopic.empty()) {
+								selectedTopic = typedTopic;
+								if (runtimeEntities)
+									runtimeEntities->showCursor();
+								return Common::kNoError;
+							}
+						} else if (event.kbd.ascii >= 0x20 && event.kbd.ascii <= 0x7e &&
+								typedTopic.size() < 79) {
+							typedTopic += (char)event.kbd.ascii;
+						}
+					}
+					continue;
+				}
+
+				switch (event.type) {
+				case Common::EVENT_KEYDOWN:
+					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+						selectedTopic = genericByeTopic;
+						return Common::kNoError;
+					}
+					break;
+				case Common::EVENT_LBUTTONDOWN: {
+					const int clickedTopic = getDialogueMenuItemAt(*normalFont, topics.size(), _mousePos);
+					if (clickedTopic >= 0) {
+						selectedTopic = topics[(uint)clickedTopic];
+						return Common::kNoError;
+					}
+					if (isDialogueOtherHit(_mousePos)) {
+						typedTopic.clear();
+						editingOther = true;
+						if (runtimeEntities)
+							runtimeEntities->hideCursor();
+					}
+					break;
+				}
+				default:
+					break;
+				}
+			}
+
+			if (runtimeEntities)
+				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+			limiter.delayBeforeSwap();
+			limiter.startFrame();
+		}
+	};
+
+	if (!npc.npcName.equalsIgnoreCase("HANK")) {
+		debug(1, "Harvester: unsupported room NPC dialogue handler '%s'", npc.npcName.c_str());
+		return Common::kNoError;
+	}
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			_hankRoomDialogueState.discussedCasketPhotoEvidence = true;
+			return playDialogueLine(0xa3e, "HANK");
+		}
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			_hankRoomDialogueState.discussedWhaleyHerrillPhoto = true;
+			return playDialogueLine(0xa53, "HANK");
+		}
+		return playDialogueLine(0xa38, "HANK");
+	}
+
+	const int currentStoryDayIndex = startupScript->getCurrentStoryDayIndex();
+	if (_hankRoomDialogueState.pendingInitialConversation) {
+		_hankRoomDialogueState.trackedDayIndex = currentStoryDayIndex;
+		_hankRoomDialogueState.hasTrackedDayState = true;
+		_hankRoomDialogueState.pendingSameDayFollowup = true;
+		_hankRoomDialogueState.pendingInitialConversation = false;
+		return playDialogueLine(0x703, "HANK");
+	}
+
+	if (!_hankRoomDialogueState.hasTrackedDayState ||
+			currentStoryDayIndex != _hankRoomDialogueState.trackedDayIndex) {
+		if (_hankRoomDialogueState.pendingSameDayFollowup) {
+			_hankRoomDialogueState.trackedDayIndex = currentStoryDayIndex;
+			_hankRoomDialogueState.pendingSameDayFollowup = false;
+			_hankRoomDialogueState.pendingRangshotSequence = true;
+			return playDialogueLine(0x8f5, "HANK");
+		}
+
+		if (_hankRoomDialogueState.pendingRangshotSequence) {
+			if (currentStoryDayIndex > 5)
+				return playDialogueLine(0x8e2, "HANK");
+
+			FstPlayer fstPlayer(_engine);
+			if (!fstPlayer.play(kDialogueRangshotFstPath))
+				return Common::kReadingFailed;
+		}
+	} else {
+		_hankRoomDialogueState.pendingSameDayFollowup = true;
+		if (currentStoryDayIndex > 5)
+			return playDialogueLine(0x8e2, "HANK");
+
+		FstPlayer fstPlayer(_engine);
+		if (!fstPlayer.play(kDialogueRangshotFstPath))
+			return Common::kReadingFailed;
+	}
+
+	if (startupScript->getFlagValue("STEPH_MIDGAME_PLAYED") &&
+			!_hankRoomDialogueState.stephMidgamePlayedShown) {
+		_hankRoomDialogueState.stephMidgamePlayedShown = true;
+		return playDialogueLine(0x92c, "HANK");
+	}
+	if (startupScript->getFlagValue("BURNED_TV_STATION") &&
+			!_hankRoomDialogueState.burnedTvStationShown) {
+		_hankRoomDialogueState.burnedTvStationShown = true;
+		return playDialogueLine(0x987, "PC");
+	}
+	if (startupScript->getFlagValue("BUSTED_ONCE") &&
+			!_hankRoomDialogueState.bustedOnceShown) {
+		_hankRoomDialogueState.bustedOnceShown = true;
+		return playDialogueLine(0x9b1, "HANK");
+	}
+	if (startupScript->getFlagValue("KARIN_KIDNAPED") &&
+			!_hankRoomDialogueState.karinKidnapedShown) {
+		_hankRoomDialogueState.karinKidnapedShown = true;
+		return playDialogueLine(0x9d5, "HANK");
+	}
+	if (startupScript->getFlagValue("KARIN_FOUND_ALIVE") &&
+			!_hankRoomDialogueState.karinFoundAliveShown) {
+		_hankRoomDialogueState.karinFoundAliveShown = true;
+		return playDialogueLine(0xa0b, "HANK");
+	}
+	if (startupScript->getFlagValue("KARIN_FOUND_DEAD") &&
+			!_hankRoomDialogueState.karinFoundDeadShown) {
+		_hankRoomDialogueState.karinFoundDeadShown = true;
+		return playDialogueLine(0xa15, "HANK");
+	}
+
+	Common::String selectedTopic;
+	Common::Error menuError = runKeywordMenu(selectedTopic);
+	if (menuError.getCode() != Common::kNoError)
+		return menuError;
+
+	if (selectedTopic.empty())
+		return Common::kNoError;
+	if (selectedTopic.equalsIgnoreCase(genericByeTopic))
+		return playDialogueLine(0x8dc, "HANK");
+
+	for (const HankDialogueTopicLine &topic : kHankDialogueTopicLines) {
+		const Common::String topicText = startupText->getDialogueResponseLine(topic.responseLineIndex);
+		if (topicText.empty() || !selectedTopic.equalsIgnoreCase(topicText))
+			continue;
+
+		if (topic.setDiscussedLodgeTopic)
+			_hankRoomDialogueState.discussedLodgeTopic = true;
+		return playDialogueLine(topic.wavId, topic.speakerId);
+	}
+
+	return playDialogueLine(0xa32, "HANK");
+}
+
 Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 	StartupRoomSetupState state;
 	if (!_engine.getStartupScript()->resolveRoomSetupState(entranceName, state, *_engine.getResources()))
@@ -2314,6 +2897,10 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 		resetCursorAnimationSequence();
 		return refreshInventoryState();
 	};
+	auto captureDialogueBackdrop = [&](IndexedBitmap &dialogueBackdrop) {
+		drawRoomScene(_engine, *screen, scene, scene.targetPaletteBrightness);
+		return captureScreenBackdrop(*screen, dialogueBackdrop);
+	};
 	auto runRoomExitCommands = [&]() {
 		Common::Array<StartupAudioCommand> exitAudioCommands;
 		if (!_engine.getStartupScript()->executeRoomExitCommands(scene.state.roomName, exitAudioCommands))
@@ -2570,6 +3157,23 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 						const StartupRoomHoverState useHoverState = resolveRoomHoverState(
 							_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
 							scene.sceneRegions, _mousePos);
+						if (useHoverState.npc) {
+							IndexedBitmap dialogueBackdrop;
+							if (!captureDialogueBackdrop(dialogueBackdrop))
+								return Common::kReadingFailed;
+
+							Common::Error dialogueError = runRoomNpcDialogue(
+								dialogueBackdrop, scene.palette, scene.targetPaletteBrightness,
+								*useHoverState.npc, inventoryState.selectedItemName);
+							if (dialogueError.getCode() != Common::kNoError)
+								return dialogueError;
+							if (clearInventorySelection() || closeInventoryOverlay())
+								needsRedraw = true;
+							resetCursorAnimationSequence();
+							resetIdleState();
+							needsRedraw = true;
+							break;
+						}
 						const StartupObjectRecord *roomTarget = useHoverState.object
 							? findSceneObjectByName(scene.sceneObjects, useHoverState.object->objectName)
 							: nullptr;
@@ -2615,8 +3219,23 @@ Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 				}
 				if (hoverState.npc) {
 					pendingRegionName.clear();
-					debug(1, "Harvester: native room NPC dialogue path for '%s' is not implemented",
-						hoverState.npc->npcName.c_str());
+					moveLeft = false;
+					moveRight = false;
+					moveUp = false;
+					moveDown = false;
+					playerState.hasMoveTarget = false;
+					playerState.turnActive = false;
+					playerState.turnTargetFacing = -1;
+					IndexedBitmap dialogueBackdrop;
+					if (!captureDialogueBackdrop(dialogueBackdrop))
+						return Common::kReadingFailed;
+					Common::Error dialogueError = runRoomNpcDialogue(
+						dialogueBackdrop, scene.palette, scene.targetPaletteBrightness,
+						*hoverState.npc, Common::String());
+					if (dialogueError.getCode() != Common::kNoError)
+						return dialogueError;
+					resetCursorAnimationSequence();
+					resetIdleState();
 					needsRedraw = true;
 					break;
 				}
@@ -3034,6 +3653,10 @@ void StartupFlow::executeStartupAudioCommands(const Common::Array<StartupAudioCo
 		(void)_engine.executeStartupAudioCommand(command);
 }
 
+void StartupFlow::resetRoomNpcDialogueState() {
+	_hankRoomDialogueState = HankRoomDialogueState();
+}
+
 void StartupFlow::resetCursorAnimationSequence() {
 	if (!ensureCursorEntity())
 		return;
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/startup_flow.h
index e5a26bc4afa..377797d39f9 100644
--- a/engines/harvester/startup_flow.h
+++ b/engines/harvester/startup_flow.h
@@ -50,6 +50,8 @@ private:
 	Common::Error runQuickTips();
 	Common::Error runMainMenuStub();
 	Common::Error runRoomMenuStub(const IndexedBitmap &backdrop);
+	Common::Error runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, const StartupNpcRecord &npc, const Common::String &usedItemName);
 	Common::Error runRoomLoop(const Common::String &entranceName);
 	bool ensureCursorEntity();
 	bool populateRoomSceneEntities(const StartupRoomSetupState &state,
@@ -59,6 +61,7 @@ private:
 	Common::Error fadeInRoomScene(const byte *palette, float targetBrightness);
 	bool pumpTransitionEvents(Common::Error &result);
 	void executeStartupAudioCommands(const Common::Array<StartupAudioCommand> &commands);
+	void resetRoomNpcDialogueState();
 	void resetCursorAnimationSequence();
 	bool tickRuntimeEntities();
 	void renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const;
@@ -67,10 +70,28 @@ private:
 	int getMenuItemAt(const Common::Point &mousePos) const;
 	int getRoomMenuItemAt(const Common::Point &mousePos) const;
 
+	struct HankRoomDialogueState {
+		bool pendingInitialConversation = true;
+		bool hasTrackedDayState = false;
+		bool pendingRangshotSequence = false;
+		bool pendingSameDayFollowup = false;
+		int trackedDayIndex = 0;
+		bool stephMidgamePlayedShown = false;
+		bool burnedTvStationShown = false;
+		bool bustedOnceShown = false;
+		bool karinKidnapedShown = false;
+		bool karinFoundAliveShown = false;
+		bool karinFoundDeadShown = false;
+		bool discussedCasketPhotoEvidence = false;
+		bool discussedWhaleyHerrillPhoto = false;
+		bool discussedLodgeTopic = false;
+	};
+
 	HarvesterEngine &_engine;
 	Common::Array<Common::String> _quickTips;
 	Common::Array<Common::String> _menuItems;
 	Common::Point _mousePos;
+	HankRoomDialogueState _hankRoomDialogueState;
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index fd72e4d6636..406c1d4ace4 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -35,6 +35,7 @@ static const char *const kStartupConfigPath = "CONFIG.INI";
 static const char *const kConfigSectionName = "harvester";
 static const char *const kDefaultTownScript = "HARVEST.SCR";
 static const char *const kInventoryOwnerName = "INVENTORY";
+static const char *const kDefaultVoicePath = "SOUND/VOICE/";
 static const byte kTownScriptXorKey = 0xaa;
 static const float kDefaultPaletteBrightness = 1.0f;
 static const float kDimmedPaletteBrightness = 0.6f;
@@ -158,8 +159,11 @@ bool StartupScript::load(ResourceManager &resources) {
 	_flags.clear();
 	_commands.clear();
 	_texts.clear();
+	_heads.clear();
 	_useItems.clear();
 	_quickTipsEnabled = true;
+	_voicePath = kDefaultVoicePath;
+	_dialogueTextMode = kStartupDialogueTextYes;
 
 	loadConfig(resources);
 
@@ -201,6 +205,20 @@ bool StartupScript::loadConfig(ResourceManager &resources) {
 	if (config.getKey("QUICK_TIPS", kConfigSectionName, quickTipsValue))
 		_quickTipsEnabled = quickTipsValue.equalsIgnoreCase("ON");
 
+	Common::String voicePath;
+	if (config.getKey("VOICE", kConfigSectionName, voicePath))
+		_voicePath = resources.normalizeResourcePath(voicePath);
+
+	Common::String textMode;
+	if (config.getKey("TEXT", kConfigSectionName, textMode)) {
+		if (textMode.equalsIgnoreCase("NO"))
+			_dialogueTextMode = kStartupDialogueTextNone;
+		else if (textMode.equalsIgnoreCase("CLICK"))
+			_dialogueTextMode = kStartupDialogueTextClick;
+		else
+			_dialogueTextMode = kStartupDialogueTextYes;
+	}
+
 	return true;
 }
 
@@ -223,6 +241,7 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 	_flags.clear();
 	_commands.clear();
 	_texts.clear();
+	_heads.clear();
 	_useItems.clear();
 
 	auto parseLine = [&](const Common::String &rawLine) {
@@ -238,7 +257,7 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 		uint tagIndex = tokens.size();
 		for (uint i = 0; i < tokens.size(); ++i) {
 			if (tokens[i] == "ANIM" || tokens[i] == "ENTRANCE" || tokens[i] == "ROOM" || tokens[i] == "OBJECT" ||
-				tokens[i] == "NPC" || tokens[i] == "REGION" ||
+				tokens[i] == "NPC" || tokens[i] == "REGION" || tokens[i] == "HEAD" ||
 				tokens[i] == "FLAG" || tokens[i] == "COMMAND" || tokens[i] == "TEXT" || tokens[i] == "USEITEM") {
 				tagIndex = i;
 				break;
@@ -303,6 +322,18 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 			return;
 		}
 
+		if (tag == "HEAD") {
+			if (tokens.size() < tagIndex + 3)
+				return;
+
+			StartupHeadRecord head;
+			head.headId = tokens[tagIndex + 1];
+			head.portraitPath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
+			if (!head.headId.empty() && !head.portraitPath.empty())
+				_heads.push_back(head);
+			return;
+		}
+
 		if (tag == "USEITEM") {
 			if (tokens.size() < tagIndex + 5)
 				return;
@@ -497,10 +528,11 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 
 	parseLine(line);
 
-	debug(1, "Harvester: parsed %u entrances, %u rooms, %u objects, %u anims, %u npcs, %u regions, %u flags, %u commands, %u texts, %u useitems from '%s'",
+	debug(1, "Harvester: parsed %u entrances, %u rooms, %u objects, %u anims, %u npcs, %u regions, %u flags, %u commands, %u texts, %u heads, %u useitems from '%s'",
 		(uint)_entrances.size(), (uint)_rooms.size(), (uint)_objects.size(), (uint)_animations.size(),
 		(uint)_npcs.size(), (uint)_regions.size(),
-		(uint)_flags.size(), (uint)_commands.size(), (uint)_texts.size(), (uint)_useItems.size(), _path.c_str());
+		(uint)_flags.size(), (uint)_commands.size(), (uint)_texts.size(), (uint)_heads.size(),
+		(uint)_useItems.size(), _path.c_str());
 }
 
 bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
@@ -1173,4 +1205,38 @@ Common::String StartupScript::resolveTextValue(const Common::String &key) const
 	return normalizeInteractionLabel(key);
 }
 
+const StartupHeadRecord *StartupScript::findHeadRecord(const Common::String &headId) const {
+	if (headId.empty())
+		return nullptr;
+
+	for (const StartupHeadRecord &head : _heads) {
+		if (head.headId.equalsIgnoreCase(headId))
+			return &head;
+	}
+
+	return nullptr;
+}
+
+bool StartupScript::getFlagValue(const Common::String &flagName) const {
+	const StartupFlagRecord *flag = findRuntimeFlag(flagName);
+	return flag && flag->value;
+}
+
+int StartupScript::getCurrentStoryDayIndex() const {
+	if (getFlagValue("DAY_1"))
+		return 1;
+	if (getFlagValue("DAY_2") || getFlagValue("NIGHT_2"))
+		return 2;
+	if (getFlagValue("DAY_3") || getFlagValue("NIGHT_3"))
+		return 3;
+	if (getFlagValue("DAY_4") || getFlagValue("NIGHT_4"))
+		return 4;
+	if (getFlagValue("DAY_5") || getFlagValue("NIGHT_5"))
+		return 5;
+	if (getFlagValue("DAY_6"))
+		return 6;
+
+	return 0;
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index f9537a35522..70b45c04bd7 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -159,6 +159,11 @@ struct StartupTextRecord {
 	Common::String value;
 };
 
+struct StartupHeadRecord {
+	Common::String headId;
+	Common::String portraitPath;
+};
+
 struct StartupUseItemRecord {
 	Common::String itemName;
 	Common::String ownerOrRoom;
@@ -166,6 +171,12 @@ struct StartupUseItemRecord {
 	Common::String actionTag;
 };
 
+enum StartupDialogueTextMode {
+	kStartupDialogueTextNone = 0,
+	kStartupDialogueTextYes = 1,
+	kStartupDialogueTextClick = 2
+};
+
 enum StartupAudioCommandType {
 	kStartupAudioCommandStartWav,
 	kStartupAudioCommandStartSingleWav,
@@ -235,9 +246,12 @@ public:
 	const Common::Array<StartupFlagRecord> &getFlags() const { return _flags; }
 	const Common::Array<StartupCommandRecord> &getCommands() const { return _commands; }
 	const Common::Array<StartupTextRecord> &getTexts() const { return _texts; }
+	const Common::Array<StartupHeadRecord> &getHeads() const { return _heads; }
 	const Common::Array<StartupUseItemRecord> &getUseItems() const { return _useItems; }
 	bool isQuickTipsEnabled() const { return _quickTipsEnabled; }
 	void setQuickTipsEnabled(bool enabled) { _quickTipsEnabled = enabled; }
+	const Common::String &getVoicePath() const { return _voicePath; }
+	StartupDialogueTextMode getDialogueTextMode() const { return _dialogueTextMode; }
 	void resetRuntimeState();
 	bool resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
 		ResourceManager &resources);
@@ -256,6 +270,9 @@ public:
 	bool resolveObjectInspectText(const StartupObjectRecord &object, StartupResolvedText &text) const;
 	Common::String resolveObjectLabel(const StartupObjectRecord &object) const;
 	Common::String resolveTextValue(const Common::String &key) const;
+	const StartupHeadRecord *findHeadRecord(const Common::String &headId) const;
+	bool getFlagValue(const Common::String &flagName) const;
+	int getCurrentStoryDayIndex() const;
 
 private:
 	bool loadConfig(ResourceManager &resources);
@@ -291,12 +308,15 @@ private:
 	Common::Array<StartupFlagRecord> _flags;
 	Common::Array<StartupCommandRecord> _commands;
 	Common::Array<StartupTextRecord> _texts;
+	Common::Array<StartupHeadRecord> _heads;
 	Common::Array<StartupUseItemRecord> _useItems;
 	Common::Array<StartupFlagRecord> _runtimeFlags;
 	Common::Array<StartupObjectRecord> _runtimeObjects;
 	Common::Array<StartupAnimRecord> _runtimeAnimations;
 	Common::Array<StartupNpcRecord> _runtimeNpcs;
 	bool _quickTipsEnabled = true;
+	Common::String _voicePath = "SOUND/VOICE/";
+	StartupDialogueTextMode _dialogueTextMode = kStartupDialogueTextYes;
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/startup_text.cpp b/engines/harvester/startup_text.cpp
index ed7d8d2e525..0fd6081acd4 100644
--- a/engines/harvester/startup_text.cpp
+++ b/engines/harvester/startup_text.cpp
@@ -37,6 +37,7 @@ static const char *const kStartupFontPaths[] = {
 	"GRAPHIC/FONT/MEDFONT1.CFT",
 	"GRAPHIC/FONT/MEDFONT2.CFT"
 };
+static const char *const kDialogueResponsePath = "DIALOG.RSP";
 
 static const uint32 kCftHeaderSize = 0x448;
 static const uint32 kCftBitmapHeaderSize = kCftHeaderSize + 12;
@@ -50,9 +51,10 @@ static bool isDialogueDelimiter(byte value) {
 bool StartupText::load(ResourceManager &resources) {
 	_dialogueData.clear();
 	_dialogueEntries.clear();
+	_dialogueResponseLines.clear();
 	_fonts.clear();
 
-	if (!loadDialogueIndex(resources))
+	if (!loadDialogueIndex(resources) || !loadDialogueResponses(resources))
 		return false;
 
 	for (const char *path : kStartupFontPaths) {
@@ -106,6 +108,32 @@ bool StartupText::loadDialogueIndex(ResourceManager &resources) {
 	return !_dialogueEntries.empty();
 }
 
+bool StartupText::loadDialogueResponses(ResourceManager &resources) {
+	Common::Array<byte> responseData;
+	if (!resources.loadFile(kDialogueResponsePath, responseData) || responseData.empty()) {
+		warning("Harvester: unable to load %s", kDialogueResponsePath);
+		return false;
+	}
+
+	Common::String line;
+	for (byte value : responseData) {
+		if (value == '\r')
+			continue;
+		if (value == '\n') {
+			_dialogueResponseLines.push_back(line);
+			line.clear();
+			continue;
+		}
+
+		line += (char)value;
+	}
+
+	if (!line.empty())
+		_dialogueResponseLines.push_back(line);
+
+	return !_dialogueResponseLines.empty();
+}
+
 bool StartupText::loadFont(ResourceManager &resources, const Common::String &path) {
 	Common::Array<byte> data;
 	if (!resources.loadFile(path, data) || data.size() < kCftBitmapHeaderSize) {
@@ -147,4 +175,37 @@ void StartupText::decodeXorText(Common::Array<byte> &data) const {
 	}
 }
 
+bool StartupText::resolveDialogueSubtitle(int wavId, Common::String &text) const {
+	text.clear();
+	if (wavId <= 0 || _dialogueEntries.empty() || _dialogueData.empty())
+		return false;
+
+	int left = 0;
+	int right = (int)_dialogueEntries.size() - 1;
+	while (left <= right) {
+		const int middle = left + (right - left) / 2;
+		const DialogueIndexEntry &entry = _dialogueEntries[(uint)middle];
+		if (entry.wavId == wavId) {
+			if (entry.textOffset + entry.textLength > _dialogueData.size())
+				return false;
+
+			text = Common::String((const char *)_dialogueData.data() + entry.textOffset, entry.textLength);
+			return !text.empty();
+		}
+		if (entry.wavId < wavId)
+			left = middle + 1;
+		else
+			right = middle - 1;
+	}
+
+	return false;
+}
+
+Common::String StartupText::getDialogueResponseLine(int zeroBasedIndex) const {
+	if (zeroBasedIndex < 0 || (uint)zeroBasedIndex >= _dialogueResponseLines.size())
+		return Common::String();
+
+	return _dialogueResponseLines[(uint)zeroBasedIndex];
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/startup_text.h b/engines/harvester/startup_text.h
index a3d467c4da1..6d0c6e82067 100644
--- a/engines/harvester/startup_text.h
+++ b/engines/harvester/startup_text.h
@@ -50,15 +50,20 @@ public:
 
 	const Common::Array<byte> &getDialogueData() const { return _dialogueData; }
 	const Common::Array<DialogueIndexEntry> &getDialogueEntries() const { return _dialogueEntries; }
+	const Common::Array<Common::String> &getDialogueResponseLines() const { return _dialogueResponseLines; }
 	const Common::Array<CftFontResource> &getFonts() const { return _fonts; }
+	bool resolveDialogueSubtitle(int wavId, Common::String &text) const;
+	Common::String getDialogueResponseLine(int zeroBasedIndex) const;
 
 private:
 	bool loadDialogueIndex(ResourceManager &resources);
+	bool loadDialogueResponses(ResourceManager &resources);
 	bool loadFont(ResourceManager &resources, const Common::String &path);
 	void decodeXorText(Common::Array<byte> &data) const;
 
 	Common::Array<byte> _dialogueData;
 	Common::Array<DialogueIndexEntry> _dialogueEntries;
+	Common::Array<Common::String> _dialogueResponseLines;
 	Common::Array<CftFontResource> _fonts;
 };
 


Commit: f92ff568aeb5097add41e904a2c5398a88382319
    https://github.com/scummvm/scummvm/commit/f92ff568aeb5097add41e904a2c5398a88382319
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:48+02:00

Commit Message:
HARVESTER: Fix native hank dialogue menus

Changed paths:
    engines/harvester/startup_flow.cpp


diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 7c207c73c8b..ad7081f6f13 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -31,6 +31,7 @@
 #include "common/formats/ini-file.h"
 #include "common/memstream.h"
 #include "common/system.h"
+#include "common/tokenizer.h"
 #include "graphics/blit.h"
 #include "graphics/font.h"
 #include "graphics/fontman.h"
@@ -927,6 +928,18 @@ static void drawDialogueTextLines(Graphics::Screen &screen, const Graphics::Font
 		font.drawString(&screen, lines[i], x, y + (int)i * lineHeight, width, 0);
 }
 
+static void splitDialogueMenuLine(const Common::String &line, Common::Array<Common::String> &parts) {
+	parts.clear();
+
+	Common::StringTokenizer tokenizer(line, "/");
+	while (!tokenizer.empty()) {
+		Common::String token = tokenizer.nextToken();
+		token.trim();
+		if (!token.empty())
+			parts.push_back(token);
+	}
+}
+
 static bool loadDialogueHeadBitmap(HarvesterEngine &engine, const Common::String &speakerId,
 		IndexedBitmap &bitmap) {
 	StartupScript *startupScript = engine.getStartupScript();
@@ -2287,6 +2300,20 @@ Common::Error StartupFlow::runRoomMenuStub(const IndexedBitmap &backdrop) {
 
 Common::Error StartupFlow::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, const StartupNpcRecord &npc, const Common::String &usedItemName) {
+	struct DialogueResponseOptionLayout {
+		Common::String text;
+		Common::Array<Common::String> wrappedLines;
+		int rowStart = 0;
+		int rowCount = 0;
+	};
+
+	struct DialogueLineSpec {
+		DialogueLineSpec(int wavId, const char *speakerId) : wavId(wavId), speakerId(speakerId) {}
+
+		int wavId;
+		const char *speakerId;
+	};
+
 	Graphics::Screen *screen = _engine.getScreen();
 	StartupScript *startupScript = _engine.getStartupScript();
 	StartupText *startupText = _engine.getStartupText();
@@ -2328,8 +2355,12 @@ Common::Error StartupFlow::runRoomNpcDialogue(const IndexedBitmap &backdrop, con
 	IndexedBitmap rightHeadBitmap;
 	Common::String leftHeadSpeakerId = npc.npcName;
 	Common::String rightHeadSpeakerId = "PC";
+	bool leftHeadVisible = false;
+	bool rightHeadVisible = false;
 	(void)loadDialogueHeadBitmap(_engine, leftHeadSpeakerId, leftHeadBitmap);
 	(void)loadDialogueHeadBitmap(_engine, rightHeadSpeakerId, rightHeadBitmap);
+	leftHeadVisible = leftHeadBitmap.isValid();
+	rightHeadVisible = rightHeadBitmap.isValid();
 
 	auto waitForPointerRelease = [&]() -> Common::Error {
 		Graphics::FrameLimiter limiter(g_system, 60);
@@ -2376,14 +2407,28 @@ Common::Error StartupFlow::runRoomNpcDialogue(const IndexedBitmap &backdrop, con
 		}
 	};
 
+	auto setActiveSpeakerPortrait = [&](const Common::String &speakerId) {
+		if (speakerId.empty() || speakerId.equalsIgnoreCase("SPEECH"))
+			return;
+
+		ensureSpeakerPortrait(speakerId);
+		if (speakerId.equalsIgnoreCase("PC")) {
+			leftHeadVisible = false;
+			rightHeadVisible = rightHeadBitmap.isValid();
+		} else {
+			leftHeadVisible = leftHeadBitmap.isValid();
+			rightHeadVisible = false;
+		}
+	};
+
 	auto drawDialogueOverlay = [&](const IndexedBitmap *overlayBitmap,
 			const Common::Array<Common::String> *subtitleLines, const Common::Array<Common::String> *topics,
 			int hoveredTopicIndex, bool hoverOther, const Common::String *textEntryValue) {
 		setScaledPalette(*screen, palette, paletteBrightness);
 		blitBitmap(*screen, backdrop, 0, 0);
-		if (leftHeadBitmap.isValid())
+		if (leftHeadVisible && leftHeadBitmap.isValid())
 			blitTransparentBitmap(*screen, leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
-		if (rightHeadBitmap.isValid())
+		if (rightHeadVisible && rightHeadBitmap.isValid())
 			blitTransparentBitmap(*screen, rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
 		if (overlayBitmap && overlayBitmap->isValid())
 			blitTransparentBitmap(*screen, *overlayBitmap, kDialogueOverlayX, kDialogueOverlayY);
@@ -2433,7 +2478,7 @@ Common::Error StartupFlow::runRoomNpcDialogue(const IndexedBitmap &backdrop, con
 	};
 
 	auto playDialogueLine = [&](int wavId, const Common::String &speakerId) -> Common::Error {
-		ensureSpeakerPortrait(speakerId);
+		setActiveSpeakerPortrait(speakerId);
 
 		Common::String subtitleText;
 		const bool textEnabled = startupScript->getDialogueTextMode() != kStartupDialogueTextNone &&
@@ -2545,9 +2590,152 @@ Common::Error StartupFlow::runRoomNpcDialogue(const IndexedBitmap &backdrop, con
 		return Common::kNoError;
 	};
 
-	auto runKeywordMenu = [&](Common::String &selectedTopic) -> Common::Error {
+	auto playDialogueSequence = [&](const DialogueLineSpec *lines, uint count) -> Common::Error {
+		for (uint i = 0; i < count; ++i) {
+			Common::Error lineError = playDialogueLine(lines[i].wavId, lines[i].speakerId);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		return Common::kNoError;
+	};
+
+	auto buildResponseMenuLayout = [&](const Common::String &responseLine,
+			Common::Array<DialogueResponseOptionLayout> &options, uint &totalRows) {
+		options.clear();
+		totalRows = 0;
+
+		Common::Array<Common::String> rawOptions;
+		splitDialogueMenuLine(responseLine, rawOptions);
+		for (const Common::String &rawOption : rawOptions) {
+			DialogueResponseOptionLayout option;
+			option.text = rawOption;
+			normalFont->wordWrapText(rawOption, kDialogueSubtitleTextWidth, option.wrappedLines);
+			if (option.wrappedLines.empty())
+				option.wrappedLines.push_back(rawOption);
+			option.rowStart = (int)totalRows;
+			option.rowCount = MAX<int>(1, option.wrappedLines.size());
+			totalRows += option.rowCount;
+			options.push_back(option);
+		}
+	};
+
+	auto getResponseMenuItemAt = [&](const Common::Array<DialogueResponseOptionLayout> &options,
+			uint totalRows, const Common::Point &mousePos) -> int {
+		if (mousePos.x < kDialogueTopicStartX || mousePos.x > kDialogueTopicEndX)
+			return -1;
+		if (mousePos.y < kDialogueKeywordTextY)
+			return -1;
+
+		const int lineHeight = getDialogueTextLineHeight(*normalFont);
+		const int row = (mousePos.y - kDialogueKeywordTextY) / MAX<int>(1, lineHeight);
+		if (row < 0 || row >= (int)totalRows)
+			return -1;
+
+		for (uint i = 0; i < options.size(); ++i) {
+			const DialogueResponseOptionLayout &option = options[i];
+			if (row >= option.rowStart && row < option.rowStart + option.rowCount)
+				return (int)i;
+		}
+
+		return -1;
+	};
+
+	auto drawDialogueResponseMenu = [&](const IndexedBitmap *textboxBitmap,
+			const Common::Array<DialogueResponseOptionLayout> &options, int hoveredOptionIndex) {
+		setScaledPalette(*screen, palette, paletteBrightness);
+		blitBitmap(*screen, backdrop, 0, 0);
+		if (leftHeadVisible && leftHeadBitmap.isValid())
+			blitTransparentBitmap(*screen, leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
+		if (rightHeadVisible && rightHeadBitmap.isValid())
+			blitTransparentBitmap(*screen, rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
+		if (textboxBitmap && textboxBitmap->isValid())
+			blitTransparentBitmap(*screen, *textboxBitmap, kDialogueOverlayX, kDialogueOverlayY);
+
+		const Common::String title = "Responses";
+		const Graphics::Font &titleFont = *normalFont;
+		const bool titleUsesCft = normalFontUsesCft;
+		const int titleWidth = titleFont.getStringWidth(title);
+		const int titleX = kDialogueSubtitleTextX + MAX<int>(0, (kDialogueSubtitleTextWidth - titleWidth) / 2);
+		drawFontString(titleFont, titleUsesCft, title, titleX, 11, titleWidth, kTextColorNormal);
+
+		const int lineHeight = getDialogueTextLineHeight(*normalFont);
+		int drawY = kDialogueKeywordTextY;
+		for (uint i = 0; i < options.size(); ++i) {
+			const bool highlighted = (int)i == hoveredOptionIndex;
+			const Graphics::Font &font = highlighted ? *selectedFont : *normalFont;
+			const bool usesCft = highlighted ? selectedFontUsesCft : normalFontUsesCft;
+			const byte color = highlighted ? kTextColorHover : kTextColorNormal;
+
+			for (const Common::String &wrappedLine : options[i].wrappedLines) {
+				drawFontString(font, usesCft, wrappedLine, kDialogueSubtitleTextX, drawY,
+					kDialogueSubtitleTextWidth, color);
+				drawY += lineHeight;
+			}
+		}
+
+		if (runtimeEntities)
+			runtimeEntities->drawCursor(*screen);
+		screen->makeAllDirty();
+		screen->update();
+	};
+
+	auto runResponseMenu = [&](int responseLineIndex, int &selectedIndex) -> Common::Error {
+		selectedIndex = 0;
+
+		const Common::String responseLine = startupText->getDialogueResponseLine(responseLineIndex);
+		if (responseLine.empty())
+			return Common::kNoError;
+
+		Common::Array<DialogueResponseOptionLayout> options;
+		uint totalRows = 0;
+		buildResponseMenuLayout(responseLine, options, totalRows);
+		if (options.empty())
+			return Common::kNoError;
+
+		const IndexedBitmap *textboxBitmap = startupArt->getTextboxBitmap(resolveDialogueTextboxIndex(totalRows));
+		Common::Error releaseError = waitForPointerRelease();
+		if (releaseError.getCode() != Common::kNoError)
+			return releaseError;
+
+		Graphics::FrameLimiter limiter(g_system, 60);
+		for (;;) {
+			const int hoveredOptionIndex = getResponseMenuItemAt(options, totalRows, _mousePos);
+			drawDialogueResponseMenu(textboxBitmap, options, hoveredOptionIndex);
+
+			Common::Event event;
+			while (g_system->getEventManager()->pollEvent(event)) {
+				Common::Error result = Common::kNoError;
+				if (handleSystemEvent(event, result))
+					return result;
+
+				if (event.type == Common::EVENT_KEYDOWN) {
+					if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+						return Common::kNoError;
+					if (event.kbd.ascii >= '1' && event.kbd.ascii <= '9') {
+						const int menuIndex = event.kbd.ascii - '0';
+						if (menuIndex >= 1 && menuIndex <= (int)options.size()) {
+							selectedIndex = menuIndex;
+							return waitForPointerRelease();
+						}
+					}
+				} else if (event.type == Common::EVENT_LBUTTONDOWN && hoveredOptionIndex >= 0) {
+					selectedIndex = hoveredOptionIndex + 1;
+					return waitForPointerRelease();
+				}
+			}
+
+			if (runtimeEntities)
+				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+			limiter.delayBeforeSwap();
+			limiter.startFrame();
+		}
+	};
+
+	auto runKeywordMenu = [&](const Common::String &topicBuffer, Common::String &selectedTopic) -> Common::Error {
 		selectedTopic.clear();
 		Common::Array<Common::String> topics;
+		splitDialogueMenuLine(topicBuffer, topics);
 		topics.push_back(genericByeTopic);
 		bool editingOther = false;
 		Common::String typedTopic;
@@ -2645,13 +2833,47 @@ Common::Error StartupFlow::runRoomNpcDialogue(const IndexedBitmap &backdrop, con
 		return playDialogueLine(0xa38, "HANK");
 	}
 
+	Common::String hankTopicBuffer;
 	const int currentStoryDayIndex = startupScript->getCurrentStoryDayIndex();
 	if (_hankRoomDialogueState.pendingInitialConversation) {
 		_hankRoomDialogueState.trackedDayIndex = currentStoryDayIndex;
 		_hankRoomDialogueState.hasTrackedDayState = true;
 		_hankRoomDialogueState.pendingSameDayFollowup = true;
 		_hankRoomDialogueState.pendingInitialConversation = false;
-		return playDialogueLine(0x703, "HANK");
+
+		Common::Error lineError = playDialogueLine(0x703, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runResponseMenu(0xc7, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 0)
+			return Common::kNoError;
+
+		switch (responseIndex) {
+		case 1:
+			lineError = playDialogueLine(0x70f, "HANK");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			hankTopicBuffer = startupText->getDialogueResponseLine(0xc8);
+			break;
+		case 2:
+			lineError = playDialogueLine(0x715, "HANK");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			hankTopicBuffer = startupText->getDialogueResponseLine(0xc9);
+			break;
+		case 3:
+			lineError = playDialogueLine(0x71c, "HANK");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			hankTopicBuffer = startupText->getDialogueResponseLine(0xca);
+			break;
+		default:
+			break;
+		}
 	}
 
 	if (!_hankRoomDialogueState.hasTrackedDayState ||
@@ -2660,79 +2882,226 @@ Common::Error StartupFlow::runRoomNpcDialogue(const IndexedBitmap &backdrop, con
 			_hankRoomDialogueState.trackedDayIndex = currentStoryDayIndex;
 			_hankRoomDialogueState.pendingSameDayFollowup = false;
 			_hankRoomDialogueState.pendingRangshotSequence = true;
-			return playDialogueLine(0x8f5, "HANK");
+
+			Common::Error lineError = playDialogueLine(0x8f5, "HANK");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 		}
 
 		if (_hankRoomDialogueState.pendingRangshotSequence) {
-			if (currentStoryDayIndex > 5)
-				return playDialogueLine(0x8e2, "HANK");
-
+			if (currentStoryDayIndex > 5) {
+				Common::Error lineError = playDialogueLine(0x8e2, "HANK");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else {
+				FstPlayer fstPlayer(_engine);
+				if (!fstPlayer.play(kDialogueRangshotFstPath))
+					return Common::kReadingFailed;
+			}
+		}
+	} else {
+		_hankRoomDialogueState.pendingSameDayFollowup = true;
+		if (currentStoryDayIndex > 5) {
+			Common::Error lineError = playDialogueLine(0x8e2, "HANK");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else {
 			FstPlayer fstPlayer(_engine);
 			if (!fstPlayer.play(kDialogueRangshotFstPath))
 				return Common::kReadingFailed;
 		}
-	} else {
-		_hankRoomDialogueState.pendingSameDayFollowup = true;
-		if (currentStoryDayIndex > 5)
-			return playDialogueLine(0x8e2, "HANK");
-
-		FstPlayer fstPlayer(_engine);
-		if (!fstPlayer.play(kDialogueRangshotFstPath))
-			return Common::kReadingFailed;
 	}
 
 	if (startupScript->getFlagValue("STEPH_MIDGAME_PLAYED") &&
 			!_hankRoomDialogueState.stephMidgamePlayedShown) {
 		_hankRoomDialogueState.stephMidgamePlayedShown = true;
-		return playDialogueLine(0x92c, "HANK");
+
+		Common::Error lineError = playDialogueLine(0x92c, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runResponseMenu(0xcc, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 0)
+			return Common::kNoError;
+
+		switch (responseIndex) {
+		case 1: {
+			const DialogueLineSpec lines[] = {
+				{ 0x939, "HANK" },
+				{ 0x93f, "MOM" }
+			};
+			lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
+			break;
+		}
+		case 2:
+			lineError = playDialogueLine(0x944, "HANK");
+			break;
+		case 3:
+			lineError = playDialogueLine(0x948, "HANK");
+			break;
+		default:
+			break;
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = playDialogueLine(0x94f, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		responseIndex = 0;
+		responseError = runResponseMenu(0xcd, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 0)
+			return Common::kNoError;
+
+		switch (responseIndex) {
+		case 1: {
+			const DialogueLineSpec lines[] = {
+				{ 0x95a, "HANK" },
+				{ 0x961, "PC" }
+			};
+			lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
+			break;
+		}
+		case 2: {
+			const DialogueLineSpec lines[] = {
+				{ 0x965, "HANK" },
+				{ 0x96b, "PC" }
+			};
+			lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
+			break;
+		}
+		default:
+			break;
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = playDialogueLine(0x971, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 	if (startupScript->getFlagValue("BURNED_TV_STATION") &&
 			!_hankRoomDialogueState.burnedTvStationShown) {
 		_hankRoomDialogueState.burnedTvStationShown = true;
-		return playDialogueLine(0x987, "PC");
+		Common::Error lineError = playDialogueLine(0x987, "PC");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 	if (startupScript->getFlagValue("BUSTED_ONCE") &&
 			!_hankRoomDialogueState.bustedOnceShown) {
 		_hankRoomDialogueState.bustedOnceShown = true;
-		return playDialogueLine(0x9b1, "HANK");
+		Common::Error lineError = playDialogueLine(0x9b1, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 	if (startupScript->getFlagValue("KARIN_KIDNAPED") &&
 			!_hankRoomDialogueState.karinKidnapedShown) {
 		_hankRoomDialogueState.karinKidnapedShown = true;
-		return playDialogueLine(0x9d5, "HANK");
+		Common::Error lineError = playDialogueLine(0x9d5, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 	if (startupScript->getFlagValue("KARIN_FOUND_ALIVE") &&
 			!_hankRoomDialogueState.karinFoundAliveShown) {
 		_hankRoomDialogueState.karinFoundAliveShown = true;
-		return playDialogueLine(0xa0b, "HANK");
+		Common::Error lineError = playDialogueLine(0xa0b, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 	if (startupScript->getFlagValue("KARIN_FOUND_DEAD") &&
 			!_hankRoomDialogueState.karinFoundDeadShown) {
 		_hankRoomDialogueState.karinFoundDeadShown = true;
-		return playDialogueLine(0xa15, "HANK");
+		Common::Error lineError = playDialogueLine(0xa15, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runResponseMenu(0xd0, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 0)
+			return Common::kNoError;
+
+		switch (responseIndex) {
+		case 1:
+			lineError = playDialogueLine(0xa20, "HANK");
+			break;
+		case 2:
+			lineError = playDialogueLine(0xa26, "HANK");
+			break;
+		default:
+			break;
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = playDialogueLine(0xa2b, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 
-	Common::String selectedTopic;
-	Common::Error menuError = runKeywordMenu(selectedTopic);
-	if (menuError.getCode() != Common::kNoError)
-		return menuError;
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runKeywordMenu(hankTopicBuffer, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
 
-	if (selectedTopic.empty())
-		return Common::kNoError;
-	if (selectedTopic.equalsIgnoreCase(genericByeTopic))
-		return playDialogueLine(0x8dc, "HANK");
+		if (selectedTopic.empty())
+			return Common::kNoError;
+		if (selectedTopic.equalsIgnoreCase(genericByeTopic))
+			return playDialogueLine(0x8dc, "HANK");
+
+		const Common::String coolDaddioTopic = startupText->getDialogueResponseLine(0xd2);
+		if (!coolDaddioTopic.empty() && selectedTopic.equalsIgnoreCase(coolDaddioTopic)) {
+			const DialogueLineSpec lines[] = {
+				{ 0x725, "HANK" },
+				{ 0x729, "PC" },
+				{ 0x72d, "HANK" },
+				{ 0x733, "PC" },
+				{ 0x737, "HANK" },
+				{ 0x73b, "PC" },
+				{ 0x741, "HANK" },
+				{ 0x747, "MOM" }
+			};
+			Common::Error lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			hankTopicBuffer = startupText->getDialogueResponseLine(0xd5);
+			continue;
+		}
+
+		bool handledTopic = false;
+		for (const HankDialogueTopicLine &topic : kHankDialogueTopicLines) {
+			if (topic.responseLineIndex == 0xd2)
+				continue;
+
+			const Common::String topicText = startupText->getDialogueResponseLine(topic.responseLineIndex);
+			if (topicText.empty() || !selectedTopic.equalsIgnoreCase(topicText))
+				continue;
+
+			if (topic.setDiscussedLodgeTopic)
+				_hankRoomDialogueState.discussedLodgeTopic = true;
 
-	for (const HankDialogueTopicLine &topic : kHankDialogueTopicLines) {
-		const Common::String topicText = startupText->getDialogueResponseLine(topic.responseLineIndex);
-		if (topicText.empty() || !selectedTopic.equalsIgnoreCase(topicText))
+			Common::Error lineError = playDialogueLine(topic.wavId, topic.speakerId);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			handledTopic = true;
+			break;
+		}
+
+		if (handledTopic)
 			continue;
 
-		if (topic.setDiscussedLodgeTopic)
-			_hankRoomDialogueState.discussedLodgeTopic = true;
-		return playDialogueLine(topic.wavId, topic.speakerId);
+		Common::Error lineError = playDialogueLine(0xa32, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
-
-	return playDialogueLine(0xa32, "HANK");
 }
 
 Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {


Commit: a470aed4dc7d6703f55d396c3085a510864eb002
    https://github.com/scummvm/scummvm/commit/a470aed4dc7d6703f55d396c3085a510864eb002
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:48+02:00

Commit Message:
HARVESTER: Extract startup dialogue and menu systems

Changed paths:
  A engines/harvester/startup_dialogue.cpp
  A engines/harvester/startup_dialogue.h
  A engines/harvester/startup_menu.cpp
  A engines/harvester/startup_menu.h
    engines/harvester/module.mk
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_flow.h


diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 6d20d63ac11..dc4ca22500f 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -9,7 +9,9 @@ MODULE_OBJS = \
 	resources.o \
 	runtime_entity.o \
 	startup_art.o \
+	startup_dialogue.o \
 	startup_flow.o \
+	startup_menu.o \
 	startup_script.o \
 	startup_text.o \
 	xfile_archive.o
diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
new file mode 100644
index 00000000000..7ee3092a407
--- /dev/null
+++ b/engines/harvester/startup_dialogue.cpp
@@ -0,0 +1,1111 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <memory>
+
+#include "harvester/startup_dialogue.h"
+
+#include "common/debug.h"
+#include "common/endian.h"
+#include "common/events.h"
+#include "common/system.h"
+#include "common/tokenizer.h"
+#include "graphics/blit.h"
+#include "graphics/font.h"
+#include "graphics/fontman.h"
+#include "graphics/framelimiter.h"
+#include "harvester/cft_font.h"
+#include "harvester/fst_player.h"
+#include "harvester/harvester.h"
+#include "harvester/resources.h"
+#include "harvester/runtime_entity.h"
+#include "harvester/startup_art.h"
+#include "harvester/startup_flow.h"
+#include "harvester/startup_text.h"
+
+namespace Harvester {
+
+namespace {
+
+static const int kDialogueOverlayX = 167;
+static const int kDialogueOverlayY = 6;
+static const int kDialogueLeftHeadX = 6;
+static const int kDialogueRightHeadX = 474;
+static const int kDialogueHeadY = 6;
+static const int kDialogueSubtitleTextX = 178;
+static const int kDialogueSubtitleTextY = 12;
+static const int kDialogueSubtitleTextWidth = 316;
+static const int kDialogueKeywordTextX = 178;
+static const int kDialogueKeywordTextY = 34;
+static const int kDialogueKeywordTextWidth = 300;
+static const int kDialogueOtherX = 171;
+static const int kDialogueOtherY = 183;
+static const int kDialogueTextEntryX = 208;
+static const int kDialogueTextEntryY = 184;
+static const int kDialogueOtherWidth = 120;
+static const int kDialogueTopicStartX = 168;
+static const int kDialogueTopicEndX = 482;
+static const int kDialogueOtherStartY = 170;
+static const int kDialogueOtherEndY = 193;
+static const int kDialogueGenericByeResponseIndex = 13;
+static const char *const kDialogueKeywordBitmapPath = "1:/GRAPHIC/OTHER/KEYWORD.BM";
+static const char *const kDialogueRangshotFstPath = "GRAPHIC/FST/RANGSHOT.FST";
+
+static const byte kTextColorNormal = 255;
+static const byte kTextColorHover = 251;
+static const byte kShadowColor = 0;
+static const byte kTransparentPaletteIndex = 0;
+
+struct HankDialogueTopicLine {
+	int responseLineIndex;
+	int wavId;
+	const char *speakerId;
+	bool setDiscussedLodgeTopic;
+};
+
+static const HankDialogueTopicLine kHankDialogueTopicLines[] = {
+	{ 0xd2, 0x725, "HANK", false },
+	{ 0xd6, 0x74f, "PC", false },
+	{ 0xd8, 0x76d, "PC", false },
+	{ 0xd9, 0x76d, "PC", false },
+	{ 0xdb, 0x77b, "PC", false },
+	{ 0xdc, 0x77b, "PC", false },
+	{ 0xe0, 0x7ad, "HANK", false },
+	{ 0xe1, 0x7b7, "PC", false },
+	{ 0xe3, 0x7d6, "PC", false },
+	{ 0xe5, 0x7f4, "HANK", false },
+	{ 0xe6, 0x7fc, "PC", false },
+	{ 0xe9, 0x82d, "PC", false },
+	{ 0xeb, 0x863, "PC", false },
+	{ 0xed, 0x881, "PC", false },
+	{ 0xee, 0x8a0, "HANK", false },
+	{ 0xef, 0x8a0, "HANK", false },
+	{ 0xf0, 0x8a0, "HANK", false },
+	{ 0xf1, 0x8bf, "HANK", true }
+};
+
+static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
+	const StartupText *startupText = engine.getStartupText();
+	if (!startupText || !fontName)
+		return nullptr;
+
+	for (const CftFontResource &font : startupText->getFonts()) {
+		if (font.name.equalsIgnoreCase(fontName))
+			return &font;
+	}
+
+	return nullptr;
+}
+
+static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
+	if (!bitmap.isValid())
+		return;
+
+	screen.copyRectToSurface(bitmap.pixels.data(), bitmap.width, x, y, bitmap.width, bitmap.height);
+}
+
+static void blitTransparentBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
+	if (!bitmap.isValid())
+		return;
+
+	int srcX = 0;
+	int srcY = 0;
+	int destX = x;
+	int destY = y;
+	int width = (int)bitmap.width;
+	int height = (int)bitmap.height;
+
+	if (destX < 0) {
+		srcX = -destX;
+		width += destX;
+		destX = 0;
+	}
+	if (destY < 0) {
+		srcY = -destY;
+		height += destY;
+		destY = 0;
+	}
+	if (destX >= screen.w || destY >= screen.h || width <= 0 || height <= 0)
+		return;
+
+	width = MIN<int>(width, screen.w - destX);
+	height = MIN<int>(height, screen.h - destY);
+	if (width <= 0 || height <= 0)
+		return;
+
+	const byte *src = bitmap.pixels.data() + srcY * bitmap.width + srcX;
+	byte *dst = (byte *)screen.getBasePtr(destX, destY);
+	Graphics::keyBlit(dst, src, screen.pitch, bitmap.width, width, height,
+		screen.format.bytesPerPixel, kTransparentPaletteIndex);
+}
+
+static void drawShadowedString(Graphics::Screen &screen, const Graphics::Font &font, const Common::String &text,
+		int x, int y, int width, byte color, Graphics::TextAlign align = Graphics::kTextAlignLeft) {
+	font.drawString(&screen, text, x + 1, y + 1, width, kShadowColor, align);
+	font.drawString(&screen, text, x, y, width, color, align);
+}
+
+static void buildScaledPalette(const byte *source, float brightness, byte *dest) {
+	memset(dest, 0, 256 * 3);
+	if (!source)
+		return;
+
+	for (uint color = 1; color < 256; ++color) {
+		for (uint channel = 0; channel < 3; ++channel) {
+			const uint index = color * 3 + channel;
+			const int scaled = (int)(source[index] * brightness + 0.5f);
+			dest[index] = (byte)MIN<int>(scaled, 255);
+		}
+	}
+}
+
+static void setScaledPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
+	byte scaledPalette[256 * 3];
+	buildScaledPalette(palette, brightness, scaledPalette);
+	screen.setPalette(scaledPalette);
+}
+
+static bool loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 12)
+		return false;
+
+	bitmap = IndexedBitmap();
+	bitmap.width = READ_LE_UINT32(data.data());
+	bitmap.height = READ_LE_UINT32(data.data() + 4);
+	const uint32 pixelCount = bitmap.width * bitmap.height;
+	if (bitmap.width == 0 || bitmap.height == 0 || data.size() < 12 + pixelCount)
+		return false;
+
+	bitmap.pixels.resize(pixelCount);
+	memcpy(bitmap.pixels.data(), data.data() + 12, pixelCount);
+	return true;
+}
+
+static bool isPrimaryMouseDown() {
+	return g_system && g_system->getEventManager() &&
+		((g_system->getEventManager()->getButtonState() & 1) != 0);
+}
+
+static bool isSecondaryMouseDown() {
+	return g_system && g_system->getEventManager() &&
+		((g_system->getEventManager()->getButtonState() & 2) != 0);
+}
+
+static int getDialogueTextLineHeight(const Graphics::Font &font) {
+	return font.getFontHeight() + 2;
+}
+
+static int resolveDialogueTextboxIndex(uint lineCount) {
+	if (lineCount <= 1)
+		return 0;
+	if (lineCount < 3)
+		return 1;
+	if (lineCount < 4)
+		return 2;
+	if (lineCount < 6)
+		return 3;
+	if (lineCount < 7)
+		return 4;
+	if (lineCount < 9)
+		return 5;
+	if (lineCount < 11)
+		return 6;
+	return 7;
+}
+
+static void drawDialogueTextLines(Graphics::Screen &screen, const Graphics::Font &font,
+		const Common::Array<Common::String> &lines, int x, int y, int width) {
+	const int lineHeight = getDialogueTextLineHeight(font);
+	for (uint i = 0; i < lines.size(); ++i)
+		font.drawString(&screen, lines[i], x, y + (int)i * lineHeight, width, 0);
+}
+
+static void splitDialogueMenuLine(const Common::String &line, Common::Array<Common::String> &parts) {
+	parts.clear();
+
+	Common::StringTokenizer tokenizer(line, "/");
+	while (!tokenizer.empty()) {
+		Common::String token = tokenizer.nextToken();
+		token.trim();
+		if (!token.empty())
+			parts.push_back(token);
+	}
+}
+
+static bool loadDialogueHeadBitmap(HarvesterEngine &engine, const Common::String &speakerId,
+		IndexedBitmap &bitmap) {
+	StartupScript *startupScript = engine.getStartupScript();
+	ResourceManager *resources = engine.getResources();
+	if (!startupScript || !resources || speakerId.empty())
+		return false;
+
+	const Common::String headId = Common::String::format("%s0", speakerId.c_str());
+	const StartupHeadRecord *head = startupScript->findHeadRecord(headId);
+	if (!head)
+		return false;
+
+	return loadBitmapResource(*resources, head->portraitPath + ".BM", bitmap);
+}
+
+static Common::String buildDialogueVoicePath(const StartupScript &startupScript, int wavId) {
+	if (wavId <= 0)
+		return Common::String();
+
+	return Common::String::format("%s%d.CMP", startupScript.getVoicePath().c_str(), wavId);
+}
+
+static int getDialogueMenuItemAt(const Graphics::Font &font, uint itemCount, const Common::Point &mousePos) {
+	if (itemCount == 0)
+		return -1;
+	if (mousePos.x < kDialogueTopicStartX || mousePos.x > kDialogueTopicEndX)
+		return -1;
+	if (mousePos.y < kDialogueKeywordTextY)
+		return -1;
+
+	const int lineHeight = getDialogueTextLineHeight(font);
+	const int selection = (mousePos.y - kDialogueKeywordTextY) / MAX<int>(1, lineHeight);
+	return selection >= 0 && selection < (int)itemCount ? selection : -1;
+}
+
+static bool isDialogueOtherHit(const Common::Point &mousePos) {
+	return mousePos.x >= kDialogueTopicStartX && mousePos.x <= kDialogueTopicEndX &&
+		mousePos.y >= kDialogueOtherStartY && mousePos.y <= kDialogueOtherEndY;
+}
+
+} // End of anonymous namespace
+
+StartupDialogueSystem::StartupDialogueSystem(HarvesterEngine &engine, Common::Point &mousePos)
+	: _engine(engine), _mousePos(mousePos) {
+}
+
+void StartupDialogueSystem::resetRoomNpcDialogueState() {
+	_hankRoomDialogueState = HankRoomDialogueState();
+}
+
+Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, const StartupNpcRecord &npc, const Common::String &usedItemName,
+		StartupFlow &startupFlow) {
+	struct DialogueResponseOptionLayout {
+		Common::String text;
+		Common::Array<Common::String> wrappedLines;
+		int rowStart = 0;
+		int rowCount = 0;
+	};
+
+	struct DialogueLineSpec {
+		DialogueLineSpec(int wavId, const char *speakerId) : wavId(wavId), speakerId(speakerId) {}
+
+		int wavId;
+		const char *speakerId;
+	};
+
+	Graphics::Screen *screen = _engine.getScreen();
+	StartupScript *startupScript = _engine.getStartupScript();
+	StartupText *startupText = _engine.getStartupText();
+	StartupArt *startupArt = _engine.getStartupArt();
+	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+	const Graphics::Font *fallbackFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	if (!screen || !startupScript || !startupText || !startupArt || !fallbackFont || !backdrop.isValid())
+		return Common::kReadingFailed;
+
+	const CftFontResource *normalFontResource = findStartupFontByName(_engine, "TEXTFONT");
+	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "TEXTFNT2");
+	std::unique_ptr<HarvesterCftFont> normalCftFont;
+	std::unique_ptr<HarvesterCftFont> selectedCftFont;
+	if (normalFontResource) {
+		normalCftFont.reset(new HarvesterCftFont(*normalFontResource));
+		if (!normalCftFont->isValid())
+			normalCftFont.reset();
+	}
+	if (selectedFontResource) {
+		selectedCftFont.reset(new HarvesterCftFont(*selectedFontResource));
+		if (!selectedCftFont->isValid())
+			selectedCftFont.reset();
+	}
+
+	const Graphics::Font *normalFont = normalCftFont ? static_cast<const Graphics::Font *>(normalCftFont.get()) : fallbackFont;
+	const Graphics::Font *selectedFont = selectedCftFont ? static_cast<const Graphics::Font *>(selectedCftFont.get()) : normalFont;
+	const bool normalFontUsesCft = normalCftFont.get() != nullptr;
+	const bool selectedFontUsesCft = selectedCftFont.get() != nullptr;
+
+	IndexedBitmap keywordBitmap;
+	if (!loadBitmapResource(*_engine.getResources(), kDialogueKeywordBitmapPath, keywordBitmap))
+		return Common::kReadingFailed;
+
+	Common::String genericByeTopic = startupText->getDialogueResponseLine(kDialogueGenericByeResponseIndex);
+	if (genericByeTopic.empty())
+		genericByeTopic = "BYE";
+
+	IndexedBitmap leftHeadBitmap;
+	IndexedBitmap rightHeadBitmap;
+	Common::String leftHeadSpeakerId = npc.npcName;
+	Common::String rightHeadSpeakerId = "PC";
+	bool leftHeadVisible = false;
+	bool rightHeadVisible = false;
+	(void)loadDialogueHeadBitmap(_engine, leftHeadSpeakerId, leftHeadBitmap);
+	(void)loadDialogueHeadBitmap(_engine, rightHeadSpeakerId, rightHeadBitmap);
+	leftHeadVisible = leftHeadBitmap.isValid();
+	rightHeadVisible = rightHeadBitmap.isValid();
+
+	auto waitForPointerRelease = [&]() -> Common::Error {
+		Graphics::FrameLimiter limiter(g_system, 60);
+		while (!_engine.shouldQuit() && (isPrimaryMouseDown() || isSecondaryMouseDown())) {
+			Common::Event event;
+			while (g_system->getEventManager()->pollEvent(event)) {
+				Common::Error result = Common::kNoError;
+				if (startupFlow.handleSystemEvent(event, result))
+					return result;
+			}
+
+			if (runtimeEntities)
+				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+			limiter.delayBeforeSwap();
+			limiter.startFrame();
+		}
+
+		return Common::kNoError;
+	};
+
+	auto drawFontString = [&](const Graphics::Font &font, bool usesCft, const Common::String &text,
+			int x, int y, int width, byte color) {
+		if (usesCft)
+			font.drawString(screen, text, x, y, width, 0);
+		else
+			drawShadowedString(*screen, font, text, x, y, width, color);
+	};
+
+	auto ensureSpeakerPortrait = [&](const Common::String &speakerId) {
+		if (speakerId.empty())
+			return;
+
+		IndexedBitmap *targetBitmap = speakerId.equalsIgnoreCase("PC") ? &rightHeadBitmap : &leftHeadBitmap;
+		Common::String *targetSpeakerId = speakerId.equalsIgnoreCase("PC") ? &rightHeadSpeakerId : &leftHeadSpeakerId;
+		if (targetBitmap->isValid() && targetSpeakerId->equalsIgnoreCase(speakerId))
+			return;
+
+		IndexedBitmap updatedBitmap;
+		if (loadDialogueHeadBitmap(_engine, speakerId, updatedBitmap)) {
+			*targetBitmap = updatedBitmap;
+			*targetSpeakerId = speakerId;
+		} else {
+			warning("Harvester: unable to load dialogue head for '%s'", speakerId.c_str());
+		}
+	};
+
+	auto setActiveSpeakerPortrait = [&](const Common::String &speakerId) {
+		if (speakerId.empty() || speakerId.equalsIgnoreCase("SPEECH"))
+			return;
+
+		ensureSpeakerPortrait(speakerId);
+		if (speakerId.equalsIgnoreCase("PC")) {
+			leftHeadVisible = false;
+			rightHeadVisible = rightHeadBitmap.isValid();
+		} else {
+			leftHeadVisible = leftHeadBitmap.isValid();
+			rightHeadVisible = false;
+		}
+	};
+
+	auto drawDialogueOverlay = [&](const IndexedBitmap *overlayBitmap,
+			const Common::Array<Common::String> *subtitleLines, const Common::Array<Common::String> *topics,
+			int hoveredTopicIndex, bool hoverOther, const Common::String *textEntryValue) {
+		setScaledPalette(*screen, palette, paletteBrightness);
+		blitBitmap(*screen, backdrop, 0, 0);
+		if (leftHeadVisible && leftHeadBitmap.isValid())
+			blitTransparentBitmap(*screen, leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
+		if (rightHeadVisible && rightHeadBitmap.isValid())
+			blitTransparentBitmap(*screen, rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
+		if (overlayBitmap && overlayBitmap->isValid())
+			blitTransparentBitmap(*screen, *overlayBitmap, kDialogueOverlayX, kDialogueOverlayY);
+
+		if (subtitleLines) {
+			if (normalFontUsesCft)
+				drawDialogueTextLines(*screen, *normalFont, *subtitleLines,
+					kDialogueSubtitleTextX, kDialogueSubtitleTextY, kDialogueSubtitleTextWidth);
+			else {
+				const int lineHeight = getDialogueTextLineHeight(*normalFont);
+				for (uint i = 0; i < subtitleLines->size(); ++i) {
+					drawShadowedString(*screen, *normalFont, (*subtitleLines)[i],
+						kDialogueSubtitleTextX,
+						kDialogueSubtitleTextY + (int)i * lineHeight,
+						kDialogueSubtitleTextWidth, kTextColorNormal);
+				}
+			}
+		}
+
+		if (topics) {
+			const int lineHeight = getDialogueTextLineHeight(*normalFont);
+			for (uint i = 0; i < topics->size(); ++i) {
+				const bool highlighted = (int)i == hoveredTopicIndex;
+				const Graphics::Font &font = highlighted ? *selectedFont : *normalFont;
+				const bool usesCft = highlighted ? selectedFontUsesCft : normalFontUsesCft;
+				drawFontString(font, usesCft, (*topics)[i], kDialogueKeywordTextX,
+					kDialogueKeywordTextY + (int)i * lineHeight, kDialogueKeywordTextWidth,
+					highlighted ? kTextColorHover : kTextColorNormal);
+			}
+
+			const Graphics::Font &otherFont = hoverOther ? *selectedFont : *normalFont;
+			const bool otherUsesCft = hoverOther ? selectedFontUsesCft : normalFontUsesCft;
+			drawFontString(otherFont, otherUsesCft, "Other", kDialogueOtherX, kDialogueOtherY,
+				kDialogueOtherWidth, hoverOther ? kTextColorHover : kTextColorNormal);
+		}
+
+		if (textEntryValue) {
+			drawFontString(*selectedFont, selectedFontUsesCft,
+				*textEntryValue + "_", kDialogueTextEntryX, kDialogueTextEntryY,
+				kDialogueKeywordTextWidth - 30, kTextColorHover);
+		}
+
+		if (runtimeEntities)
+			runtimeEntities->drawCursor(*screen);
+		screen->makeAllDirty();
+		screen->update();
+	};
+
+	auto playDialogueLine = [&](int wavId, const Common::String &speakerId) -> Common::Error {
+		setActiveSpeakerPortrait(speakerId);
+
+		Common::String subtitleText;
+		const bool textEnabled = startupScript->getDialogueTextMode() != kStartupDialogueTextNone &&
+			startupText->resolveDialogueSubtitle(wavId, subtitleText);
+		Common::Array<Common::String> subtitleLines;
+		const IndexedBitmap *textboxBitmap = nullptr;
+		if (textEnabled) {
+			normalFont->wordWrapText(subtitleText, kDialogueSubtitleTextWidth, subtitleLines);
+			textboxBitmap = startupArt->getTextboxBitmap(resolveDialogueTextboxIndex(subtitleLines.size()));
+		}
+
+		const Common::String voicePath = buildDialogueVoicePath(*startupScript, wavId);
+		const bool voiceStarted = !voicePath.empty() && _engine.playStartupSingleSound(voicePath);
+		Common::Error releaseError = waitForPointerRelease();
+		if (releaseError.getCode() != Common::kNoError) {
+			_engine.stopStartupSound();
+			return releaseError;
+		}
+
+		bool interrupted = false;
+		Graphics::FrameLimiter limiter(g_system, 60);
+		for (;;) {
+			drawDialogueOverlay(textboxBitmap, textEnabled ? &subtitleLines : nullptr, nullptr, -1, false, nullptr);
+
+			Common::Event event;
+			while (g_system->getEventManager()->pollEvent(event)) {
+				Common::Error result = Common::kNoError;
+				if (startupFlow.handleSystemEvent(event, result)) {
+					_engine.stopStartupSound();
+					return result;
+				}
+
+				switch (event.type) {
+				case Common::EVENT_LBUTTONDOWN:
+				case Common::EVENT_RBUTTONDOWN:
+					interrupted = true;
+					break;
+				case Common::EVENT_KEYDOWN:
+					if (event.kbd.keycode == Common::KEYCODE_ESCAPE ||
+							event.kbd.keycode == Common::KEYCODE_RETURN ||
+							event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
+							event.kbd.keycode == Common::KEYCODE_SPACE) {
+						interrupted = true;
+					}
+					break;
+				default:
+					break;
+				}
+			}
+
+			if (runtimeEntities)
+				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+			if (interrupted || (!voiceStarted || !_engine.isStartupSingleSoundPlaying()))
+				break;
+
+			limiter.delayBeforeSwap();
+			limiter.startFrame();
+		}
+
+		_engine.stopStartupSound();
+		if (interrupted) {
+			Common::Error releaseResult = waitForPointerRelease();
+			if (releaseResult.getCode() != Common::kNoError)
+				return releaseResult;
+		} else if (textEnabled && startupScript->getDialogueTextMode() == kStartupDialogueTextClick) {
+			Graphics::FrameLimiter clickLimiter(g_system, 60);
+			for (;;) {
+				drawDialogueOverlay(textboxBitmap, &subtitleLines, nullptr, -1, false, nullptr);
+
+				bool continuePressed = false;
+				Common::Event event;
+				while (g_system->getEventManager()->pollEvent(event)) {
+					Common::Error result = Common::kNoError;
+					if (startupFlow.handleSystemEvent(event, result))
+						return result;
+
+					switch (event.type) {
+					case Common::EVENT_LBUTTONDOWN:
+					case Common::EVENT_RBUTTONDOWN:
+						continuePressed = true;
+						break;
+					case Common::EVENT_KEYDOWN:
+						if (event.kbd.keycode == Common::KEYCODE_ESCAPE ||
+								event.kbd.keycode == Common::KEYCODE_RETURN ||
+								event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
+								event.kbd.keycode == Common::KEYCODE_SPACE) {
+							continuePressed = true;
+						}
+						break;
+					default:
+						break;
+					}
+				}
+
+				if (runtimeEntities)
+					(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+				if (continuePressed)
+					break;
+
+				clickLimiter.delayBeforeSwap();
+				clickLimiter.startFrame();
+			}
+
+			Common::Error releaseResult = waitForPointerRelease();
+			if (releaseResult.getCode() != Common::kNoError)
+				return releaseResult;
+		}
+
+		return Common::kNoError;
+	};
+
+	auto playDialogueSequence = [&](const DialogueLineSpec *lines, uint count) -> Common::Error {
+		for (uint i = 0; i < count; ++i) {
+			Common::Error lineError = playDialogueLine(lines[i].wavId, lines[i].speakerId);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		return Common::kNoError;
+	};
+
+	auto buildResponseMenuLayout = [&](const Common::String &responseLine,
+			Common::Array<DialogueResponseOptionLayout> &options, uint &totalRows) {
+		options.clear();
+		totalRows = 0;
+
+		Common::Array<Common::String> rawOptions;
+		splitDialogueMenuLine(responseLine, rawOptions);
+		for (const Common::String &rawOption : rawOptions) {
+			DialogueResponseOptionLayout option;
+			option.text = rawOption;
+			normalFont->wordWrapText(rawOption, kDialogueSubtitleTextWidth, option.wrappedLines);
+			if (option.wrappedLines.empty())
+				option.wrappedLines.push_back(rawOption);
+			option.rowStart = (int)totalRows;
+			option.rowCount = MAX<int>(1, option.wrappedLines.size());
+			totalRows += option.rowCount;
+			options.push_back(option);
+		}
+	};
+
+	auto getResponseMenuItemAt = [&](const Common::Array<DialogueResponseOptionLayout> &options,
+			uint totalRows, const Common::Point &mousePos) -> int {
+		if (mousePos.x < kDialogueTopicStartX || mousePos.x > kDialogueTopicEndX)
+			return -1;
+		if (mousePos.y < kDialogueKeywordTextY)
+			return -1;
+
+		const int lineHeight = getDialogueTextLineHeight(*normalFont);
+		const int row = (mousePos.y - kDialogueKeywordTextY) / MAX<int>(1, lineHeight);
+		if (row < 0 || row >= (int)totalRows)
+			return -1;
+
+		for (uint i = 0; i < options.size(); ++i) {
+			const DialogueResponseOptionLayout &option = options[i];
+			if (row >= option.rowStart && row < option.rowStart + option.rowCount)
+				return (int)i;
+		}
+
+		return -1;
+	};
+
+	auto drawDialogueResponseMenu = [&](const IndexedBitmap *textboxBitmap,
+			const Common::Array<DialogueResponseOptionLayout> &options, int hoveredOptionIndex) {
+		setScaledPalette(*screen, palette, paletteBrightness);
+		blitBitmap(*screen, backdrop, 0, 0);
+		if (leftHeadVisible && leftHeadBitmap.isValid())
+			blitTransparentBitmap(*screen, leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
+		if (rightHeadVisible && rightHeadBitmap.isValid())
+			blitTransparentBitmap(*screen, rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
+		if (textboxBitmap && textboxBitmap->isValid())
+			blitTransparentBitmap(*screen, *textboxBitmap, kDialogueOverlayX, kDialogueOverlayY);
+
+		const Common::String title = "Responses";
+		const Graphics::Font &titleFont = *normalFont;
+		const bool titleUsesCft = normalFontUsesCft;
+		const int titleWidth = titleFont.getStringWidth(title);
+		const int titleX = kDialogueSubtitleTextX + MAX<int>(0, (kDialogueSubtitleTextWidth - titleWidth) / 2);
+		drawFontString(titleFont, titleUsesCft, title, titleX, 11, titleWidth, kTextColorNormal);
+
+		const int lineHeight = getDialogueTextLineHeight(*normalFont);
+		int drawY = kDialogueKeywordTextY;
+		for (uint i = 0; i < options.size(); ++i) {
+			const bool highlighted = (int)i == hoveredOptionIndex;
+			const Graphics::Font &font = highlighted ? *selectedFont : *normalFont;
+			const bool usesCft = highlighted ? selectedFontUsesCft : normalFontUsesCft;
+			const byte color = highlighted ? kTextColorHover : kTextColorNormal;
+
+			for (const Common::String &wrappedLine : options[i].wrappedLines) {
+				drawFontString(font, usesCft, wrappedLine, kDialogueSubtitleTextX, drawY,
+					kDialogueSubtitleTextWidth, color);
+				drawY += lineHeight;
+			}
+		}
+
+		if (runtimeEntities)
+			runtimeEntities->drawCursor(*screen);
+		screen->makeAllDirty();
+		screen->update();
+	};
+
+	auto runResponseMenu = [&](int responseLineIndex, int &selectedIndex) -> Common::Error {
+		selectedIndex = 0;
+
+		const Common::String responseLine = startupText->getDialogueResponseLine(responseLineIndex);
+		if (responseLine.empty())
+			return Common::kNoError;
+
+		Common::Array<DialogueResponseOptionLayout> options;
+		uint totalRows = 0;
+		buildResponseMenuLayout(responseLine, options, totalRows);
+		if (options.empty())
+			return Common::kNoError;
+
+		const IndexedBitmap *textboxBitmap = startupArt->getTextboxBitmap(resolveDialogueTextboxIndex(totalRows));
+		Common::Error releaseError = waitForPointerRelease();
+		if (releaseError.getCode() != Common::kNoError)
+			return releaseError;
+
+		Graphics::FrameLimiter limiter(g_system, 60);
+		for (;;) {
+			const int hoveredOptionIndex = getResponseMenuItemAt(options, totalRows, _mousePos);
+			drawDialogueResponseMenu(textboxBitmap, options, hoveredOptionIndex);
+
+			Common::Event event;
+			while (g_system->getEventManager()->pollEvent(event)) {
+				Common::Error result = Common::kNoError;
+				if (startupFlow.handleSystemEvent(event, result))
+					return result;
+
+				if (event.type == Common::EVENT_KEYDOWN) {
+					if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+						return Common::kNoError;
+					if (event.kbd.ascii >= '1' && event.kbd.ascii <= '9') {
+						const int menuIndex = event.kbd.ascii - '0';
+						if (menuIndex >= 1 && menuIndex <= (int)options.size()) {
+							selectedIndex = menuIndex;
+							return waitForPointerRelease();
+						}
+					}
+				} else if (event.type == Common::EVENT_LBUTTONDOWN && hoveredOptionIndex >= 0) {
+					selectedIndex = hoveredOptionIndex + 1;
+					return waitForPointerRelease();
+				}
+			}
+
+			if (runtimeEntities)
+				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+			limiter.delayBeforeSwap();
+			limiter.startFrame();
+		}
+	};
+
+	auto runKeywordMenu = [&](const Common::String &topicBuffer, Common::String &selectedTopic) -> Common::Error {
+		selectedTopic.clear();
+		Common::Array<Common::String> topics;
+		splitDialogueMenuLine(topicBuffer, topics);
+		topics.push_back(genericByeTopic);
+		bool editingOther = false;
+		Common::String typedTopic;
+		Common::Error releaseError = waitForPointerRelease();
+		if (releaseError.getCode() != Common::kNoError)
+			return releaseError;
+
+		Graphics::FrameLimiter limiter(g_system, 60);
+		for (;;) {
+			const int hoveredTopicIndex = editingOther ? -1 : getDialogueMenuItemAt(*normalFont, topics.size(), _mousePos);
+			const bool hoverOther = !editingOther && isDialogueOtherHit(_mousePos);
+			drawDialogueOverlay(&keywordBitmap, nullptr, &topics, hoveredTopicIndex, hoverOther,
+				editingOther ? &typedTopic : nullptr);
+
+			Common::Event event;
+			while (g_system->getEventManager()->pollEvent(event)) {
+				Common::Error result = Common::kNoError;
+				if (startupFlow.handleSystemEvent(event, result))
+					return result;
+
+				if (editingOther) {
+					if (event.type == Common::EVENT_KEYDOWN) {
+						if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+							typedTopic.clear();
+							editingOther = false;
+							if (runtimeEntities)
+								runtimeEntities->showCursor();
+						} else if (event.kbd.keycode == Common::KEYCODE_BACKSPACE) {
+							if (!typedTopic.empty())
+								typedTopic.deleteLastChar();
+						} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+								event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
+							if (!typedTopic.empty()) {
+								selectedTopic = typedTopic;
+								if (runtimeEntities)
+									runtimeEntities->showCursor();
+								return Common::kNoError;
+							}
+						} else if (event.kbd.ascii >= 0x20 && event.kbd.ascii <= 0x7e &&
+								typedTopic.size() < 79) {
+							typedTopic += (char)event.kbd.ascii;
+						}
+					}
+					continue;
+				}
+
+				switch (event.type) {
+				case Common::EVENT_KEYDOWN:
+					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+						selectedTopic = genericByeTopic;
+						return Common::kNoError;
+					}
+					break;
+				case Common::EVENT_LBUTTONDOWN: {
+					const int clickedTopic = getDialogueMenuItemAt(*normalFont, topics.size(), _mousePos);
+					if (clickedTopic >= 0) {
+						selectedTopic = topics[(uint)clickedTopic];
+						return Common::kNoError;
+					}
+					if (isDialogueOtherHit(_mousePos)) {
+						typedTopic.clear();
+						editingOther = true;
+						if (runtimeEntities)
+							runtimeEntities->hideCursor();
+					}
+					break;
+				}
+				default:
+					break;
+				}
+			}
+
+			if (runtimeEntities)
+				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+			limiter.delayBeforeSwap();
+			limiter.startFrame();
+		}
+	};
+
+	if (!npc.npcName.equalsIgnoreCase("HANK")) {
+		debug(1, "Harvester: unsupported room NPC dialogue handler '%s'", npc.npcName.c_str());
+		return Common::kNoError;
+	}
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			_hankRoomDialogueState.discussedCasketPhotoEvidence = true;
+			return playDialogueLine(0xa3e, "HANK");
+		}
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			_hankRoomDialogueState.discussedWhaleyHerrillPhoto = true;
+			return playDialogueLine(0xa53, "HANK");
+		}
+		return playDialogueLine(0xa38, "HANK");
+	}
+
+	Common::String hankTopicBuffer;
+	const int currentStoryDayIndex = startupScript->getCurrentStoryDayIndex();
+	if (_hankRoomDialogueState.pendingInitialConversation) {
+		_hankRoomDialogueState.trackedDayIndex = currentStoryDayIndex;
+		_hankRoomDialogueState.hasTrackedDayState = true;
+		_hankRoomDialogueState.pendingSameDayFollowup = true;
+		_hankRoomDialogueState.pendingInitialConversation = false;
+
+		Common::Error lineError = playDialogueLine(0x703, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runResponseMenu(0xc7, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 0)
+			return Common::kNoError;
+
+		switch (responseIndex) {
+		case 1:
+			lineError = playDialogueLine(0x70f, "HANK");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			hankTopicBuffer = startupText->getDialogueResponseLine(0xc8);
+			break;
+		case 2:
+			lineError = playDialogueLine(0x715, "HANK");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			hankTopicBuffer = startupText->getDialogueResponseLine(0xc9);
+			break;
+		case 3:
+			lineError = playDialogueLine(0x71c, "HANK");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			hankTopicBuffer = startupText->getDialogueResponseLine(0xca);
+			break;
+		default:
+			break;
+		}
+	}
+
+	if (!_hankRoomDialogueState.hasTrackedDayState ||
+			currentStoryDayIndex != _hankRoomDialogueState.trackedDayIndex) {
+		if (_hankRoomDialogueState.pendingSameDayFollowup) {
+			_hankRoomDialogueState.trackedDayIndex = currentStoryDayIndex;
+			_hankRoomDialogueState.pendingSameDayFollowup = false;
+			_hankRoomDialogueState.pendingRangshotSequence = true;
+
+			Common::Error lineError = playDialogueLine(0x8f5, "HANK");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		if (_hankRoomDialogueState.pendingRangshotSequence) {
+			if (currentStoryDayIndex > 5) {
+				Common::Error lineError = playDialogueLine(0x8e2, "HANK");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else {
+				FstPlayer fstPlayer(_engine);
+				if (!fstPlayer.play(kDialogueRangshotFstPath))
+					return Common::kReadingFailed;
+			}
+		}
+	} else {
+		_hankRoomDialogueState.pendingSameDayFollowup = true;
+		if (currentStoryDayIndex > 5) {
+			Common::Error lineError = playDialogueLine(0x8e2, "HANK");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else {
+			FstPlayer fstPlayer(_engine);
+			if (!fstPlayer.play(kDialogueRangshotFstPath))
+				return Common::kReadingFailed;
+		}
+	}
+
+	if (startupScript->getFlagValue("STEPH_MIDGAME_PLAYED") &&
+			!_hankRoomDialogueState.stephMidgamePlayedShown) {
+		_hankRoomDialogueState.stephMidgamePlayedShown = true;
+
+		Common::Error lineError = playDialogueLine(0x92c, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runResponseMenu(0xcc, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 0)
+			return Common::kNoError;
+
+		switch (responseIndex) {
+		case 1: {
+			const DialogueLineSpec lines[] = {
+				DialogueLineSpec(0x939, "HANK"),
+				DialogueLineSpec(0x93f, "MOM")
+			};
+			lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
+			break;
+		}
+		case 2:
+			lineError = playDialogueLine(0x944, "HANK");
+			break;
+		case 3:
+			lineError = playDialogueLine(0x948, "HANK");
+			break;
+		default:
+			break;
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = playDialogueLine(0x94f, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		responseIndex = 0;
+		responseError = runResponseMenu(0xcd, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 0)
+			return Common::kNoError;
+
+		switch (responseIndex) {
+		case 1: {
+			const DialogueLineSpec lines[] = {
+				DialogueLineSpec(0x95a, "HANK"),
+				DialogueLineSpec(0x961, "PC")
+			};
+			lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
+			break;
+		}
+		case 2: {
+			const DialogueLineSpec lines[] = {
+				DialogueLineSpec(0x965, "HANK"),
+				DialogueLineSpec(0x96b, "PC")
+			};
+			lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
+			break;
+		}
+		default:
+			break;
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = playDialogueLine(0x971, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (startupScript->getFlagValue("BURNED_TV_STATION") &&
+			!_hankRoomDialogueState.burnedTvStationShown) {
+		_hankRoomDialogueState.burnedTvStationShown = true;
+		Common::Error lineError = playDialogueLine(0x987, "PC");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (startupScript->getFlagValue("BUSTED_ONCE") &&
+			!_hankRoomDialogueState.bustedOnceShown) {
+		_hankRoomDialogueState.bustedOnceShown = true;
+		Common::Error lineError = playDialogueLine(0x9b1, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (startupScript->getFlagValue("KARIN_KIDNAPED") &&
+			!_hankRoomDialogueState.karinKidnapedShown) {
+		_hankRoomDialogueState.karinKidnapedShown = true;
+		Common::Error lineError = playDialogueLine(0x9d5, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (startupScript->getFlagValue("KARIN_FOUND_ALIVE") &&
+			!_hankRoomDialogueState.karinFoundAliveShown) {
+		_hankRoomDialogueState.karinFoundAliveShown = true;
+		Common::Error lineError = playDialogueLine(0xa0b, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (startupScript->getFlagValue("KARIN_FOUND_DEAD") &&
+			!_hankRoomDialogueState.karinFoundDeadShown) {
+		_hankRoomDialogueState.karinFoundDeadShown = true;
+		Common::Error lineError = playDialogueLine(0xa15, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runResponseMenu(0xd0, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 0)
+			return Common::kNoError;
+
+		switch (responseIndex) {
+		case 1:
+			lineError = playDialogueLine(0xa20, "HANK");
+			break;
+		case 2:
+			lineError = playDialogueLine(0xa26, "HANK");
+			break;
+		default:
+			break;
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = playDialogueLine(0xa2b, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runKeywordMenu(hankTopicBuffer, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+
+		if (selectedTopic.empty())
+			return Common::kNoError;
+		if (selectedTopic.equalsIgnoreCase(genericByeTopic))
+			return playDialogueLine(0x8dc, "HANK");
+
+		const Common::String coolDaddioTopic = startupText->getDialogueResponseLine(0xd2);
+		if (!coolDaddioTopic.empty() && selectedTopic.equalsIgnoreCase(coolDaddioTopic)) {
+			const DialogueLineSpec lines[] = {
+				DialogueLineSpec(0x725, "HANK"),
+				DialogueLineSpec(0x729, "PC"),
+				DialogueLineSpec(0x72d, "HANK"),
+				DialogueLineSpec(0x733, "PC"),
+				DialogueLineSpec(0x737, "HANK"),
+				DialogueLineSpec(0x73b, "PC"),
+				DialogueLineSpec(0x741, "HANK"),
+				DialogueLineSpec(0x747, "MOM")
+			};
+			Common::Error lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			hankTopicBuffer = startupText->getDialogueResponseLine(0xd5);
+			continue;
+		}
+
+		bool handledTopic = false;
+		for (const HankDialogueTopicLine &topic : kHankDialogueTopicLines) {
+			if (topic.responseLineIndex == 0xd2)
+				continue;
+
+			const Common::String topicText = startupText->getDialogueResponseLine(topic.responseLineIndex);
+			if (topicText.empty() || !selectedTopic.equalsIgnoreCase(topicText))
+				continue;
+
+			if (topic.setDiscussedLodgeTopic)
+				_hankRoomDialogueState.discussedLodgeTopic = true;
+
+			Common::Error lineError = playDialogueLine(topic.wavId, topic.speakerId);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			handledTopic = true;
+			break;
+		}
+
+		if (handledTopic)
+			continue;
+
+		Common::Error lineError = playDialogueLine(0xa32, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/startup_dialogue.h b/engines/harvester/startup_dialogue.h
new file mode 100644
index 00000000000..32517d7fbcd
--- /dev/null
+++ b/engines/harvester/startup_dialogue.h
@@ -0,0 +1,74 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_STARTUP_DIALOGUE_H
+#define HARVESTER_STARTUP_DIALOGUE_H
+
+#include "common/error.h"
+#include "common/rect.h"
+#include "common/str.h"
+#include "harvester/startup_script.h"
+
+namespace Common {
+struct Event;
+}
+
+namespace Harvester {
+
+class HarvesterEngine;
+class StartupFlow;
+struct IndexedBitmap;
+
+class StartupDialogueSystem {
+public:
+	struct HankRoomDialogueState {
+		bool pendingInitialConversation = true;
+		bool hasTrackedDayState = false;
+		bool pendingRangshotSequence = false;
+		bool pendingSameDayFollowup = false;
+		int trackedDayIndex = 0;
+		bool stephMidgamePlayedShown = false;
+		bool burnedTvStationShown = false;
+		bool bustedOnceShown = false;
+		bool karinKidnapedShown = false;
+		bool karinFoundAliveShown = false;
+		bool karinFoundDeadShown = false;
+		bool discussedCasketPhotoEvidence = false;
+		bool discussedWhaleyHerrillPhoto = false;
+		bool discussedLodgeTopic = false;
+	};
+
+	StartupDialogueSystem(HarvesterEngine &engine, Common::Point &mousePos);
+
+	void resetRoomNpcDialogueState();
+	Common::Error runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, const StartupNpcRecord &npc, const Common::String &usedItemName,
+		StartupFlow &startupFlow);
+
+private:
+	HarvesterEngine &_engine;
+	Common::Point &_mousePos;
+	HankRoomDialogueState _hankRoomDialogueState;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_STARTUP_DIALOGUE_H
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index ad7081f6f13..e959d204304 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -20,7 +20,6 @@
  */
 
 #include <math.h>
-#include <memory>
 
 #include "harvester/startup_flow.h"
 
@@ -31,20 +30,16 @@
 #include "common/formats/ini-file.h"
 #include "common/memstream.h"
 #include "common/system.h"
-#include "common/tokenizer.h"
 #include "graphics/blit.h"
 #include "graphics/font.h"
 #include "graphics/fontman.h"
 #include "graphics/framelimiter.h"
-#include "harvester/cft_font.h"
 #include "harvester/detection.h"
-#include "harvester/fst_player.h"
 #include "harvester/harvester.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
 #include "harvester/startup_art.h"
 #include "harvester/startup_script.h"
-#include "harvester/startup_text.h"
 
 namespace Harvester {
 
@@ -60,40 +55,13 @@ static const int kInventoryItemStartX = 73;
 static const int kInventoryItemStartY = 115;
 static const int kInventoryItemMaxRight = 564;
 static const int kInventoryItemSpacing = 5;
-static const int kLogoX = 160;
-static const int kLogoY = 0;
 static const int kQuickTipsOverlayX = 167;
 static const int kQuickTipsOverlayY = 200;
-static const int kDialogueOverlayX = 167;
-static const int kDialogueOverlayY = 6;
-static const int kDialogueLeftHeadX = 6;
-static const int kDialogueRightHeadX = 474;
-static const int kDialogueHeadY = 6;
-static const int kDialogueSubtitleTextX = 178;
-static const int kDialogueSubtitleTextY = 12;
-static const int kDialogueSubtitleTextWidth = 316;
-static const int kDialogueKeywordTextX = 178;
-static const int kDialogueKeywordTextY = 34;
-static const int kDialogueKeywordTextWidth = 300;
-static const int kDialogueOtherX = 171;
-static const int kDialogueOtherY = 183;
-static const int kDialogueTextEntryX = 208;
-static const int kDialogueTextEntryY = 184;
-static const int kDialogueOtherWidth = 120;
-static const int kDialogueTopicStartX = 168;
-static const int kDialogueTopicEndX = 482;
-static const int kDialogueOtherStartY = 170;
-static const int kDialogueOtherEndY = 193;
-static const int kDialogueGenericByeResponseIndex = 13;
-static const char *const kDialogueKeywordBitmapPath = "1:/GRAPHIC/OTHER/KEYWORD.BM";
-static const char *const kDialogueRangshotFstPath = "GRAPHIC/FST/RANGSHOT.FST";
 
 static const int kQuickTipTextX = 180;
 static const int kQuickTipTextY = 228;
 static const int kQuickTipTextWidth = 280;
 
-static const int kMenuStartY = 100;
-static const int kMenuLineSpacing = 28;
 static const int kCursorSequence7 = 7;
 static const int kIdentTextboxX = 85;
 static const int kIdentTextboxY = 177;
@@ -111,13 +79,9 @@ static const int kRoomPlayerWalkAnimationRate = 17;
 
 static const byte kIdentTextColor = 0xd3;
 static const byte kTextColorNormal = 255;
-static const byte kTextColorHover = 251;
-static const byte kTextColorDim = 248;
 static const byte kShadowColor = 0;
-static const byte kPanelFillColor = 1;
 static const byte kQuickTipActionColor = 0xc3;
 static const byte kRoomPromptColor = 0xce;
-static const byte kTransparentPaletteIndex = 0;
 
 static const int kCursorSequenceWalk = 0;
 static const int kCursorSequenceExamine = 1;
@@ -196,34 +160,6 @@ struct StartupRoomIdleAnimationState {
 	bool exiting = false;
 };
 
-struct HankDialogueTopicLine {
-	int responseLineIndex;
-	int wavId;
-	const char *speakerId;
-	bool setDiscussedLodgeTopic;
-};
-
-static const HankDialogueTopicLine kHankDialogueTopicLines[] = {
-	{ 0xd2, 0x725, "HANK", false },
-	{ 0xd6, 0x74f, "PC", false },
-	{ 0xd8, 0x76d, "PC", false },
-	{ 0xd9, 0x76d, "PC", false },
-	{ 0xdb, 0x77b, "PC", false },
-	{ 0xdc, 0x77b, "PC", false },
-	{ 0xe0, 0x7ad, "HANK", false },
-	{ 0xe1, 0x7b7, "PC", false },
-	{ 0xe3, 0x7d6, "PC", false },
-	{ 0xe5, 0x7f4, "HANK", false },
-	{ 0xe6, 0x7fc, "PC", false },
-	{ 0xe9, 0x82d, "PC", false },
-	{ 0xeb, 0x863, "PC", false },
-	{ 0xed, 0x881, "PC", false },
-	{ 0xee, 0x8a0, "HANK", false },
-	{ 0xef, 0x8a0, "HANK", false },
-	{ 0xf0, 0x8a0, "HANK", false },
-	{ 0xf1, 0x8bf, "HANK", true }
-};
-
 struct PlayerAnimationRange {
 	PlayerAnimationRange() {}
 	PlayerAnimationRange(int walkFirstFrame, int walkLastFrame, int idleFrame)
@@ -648,41 +584,6 @@ static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, in
 	screen.copyRectToSurface(bitmap.pixels.data(), bitmap.width, x, y, bitmap.width, bitmap.height);
 }
 
-static void blitTransparentBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
-	if (!bitmap.isValid())
-		return;
-
-	int srcX = 0;
-	int srcY = 0;
-	int destX = x;
-	int destY = y;
-	int width = (int)bitmap.width;
-	int height = (int)bitmap.height;
-
-	if (destX < 0) {
-		srcX = -destX;
-		width += destX;
-		destX = 0;
-	}
-	if (destY < 0) {
-		srcY = -destY;
-		height += destY;
-		destY = 0;
-	}
-	if (destX >= screen.w || destY >= screen.h || width <= 0 || height <= 0)
-		return;
-
-	width = MIN<int>(width, screen.w - destX);
-	height = MIN<int>(height, screen.h - destY);
-	if (width <= 0 || height <= 0)
-		return;
-
-	const byte *src = bitmap.pixels.data() + srcY * bitmap.width + srcX;
-	byte *dst = (byte *)screen.getBasePtr(destX, destY);
-	Graphics::keyBlit(dst, src, screen.pitch, bitmap.width, width, height,
-		screen.format.bytesPerPixel, kTransparentPaletteIndex);
-}
-
 static bool captureScreenBackdrop(const Graphics::Screen &screen, IndexedBitmap &bitmap) {
 	if (screen.w <= 0 || screen.h <= 0 || screen.format.bytesPerPixel != 1)
 		return false;
@@ -872,127 +773,6 @@ static Common::Rect getInventoryPanelBounds(const StartupArt &art) {
 		kInventoryX + (int)bitmap.width, kInventoryY + (int)bitmap.height);
 }
 
-static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
-	const StartupText *startupText = engine.getStartupText();
-	if (!startupText || !fontName)
-		return nullptr;
-
-	for (const CftFontResource &font : startupText->getFonts()) {
-		if (font.name.equalsIgnoreCase(fontName))
-			return &font;
-	}
-
-	return nullptr;
-}
-
-static int getNativeRoomMenuLineHeight(const Graphics::Font &selectedFont) {
-	return selectedFont.getFontHeight() + 2;
-}
-
-static bool isPrimaryMouseDown() {
-	return g_system && g_system->getEventManager() &&
-		((g_system->getEventManager()->getButtonState() & 1) != 0);
-}
-
-static bool isSecondaryMouseDown() {
-	return g_system && g_system->getEventManager() &&
-		((g_system->getEventManager()->getButtonState() & 2) != 0);
-}
-
-static int getDialogueTextLineHeight(const Graphics::Font &font) {
-	return font.getFontHeight() + 2;
-}
-
-static int resolveDialogueTextboxIndex(uint lineCount) {
-	if (lineCount <= 1)
-		return 0;
-	if (lineCount < 3)
-		return 1;
-	if (lineCount < 4)
-		return 2;
-	if (lineCount < 6)
-		return 3;
-	if (lineCount < 7)
-		return 4;
-	if (lineCount < 9)
-		return 5;
-	if (lineCount < 11)
-		return 6;
-	return 7;
-}
-
-static void drawDialogueTextLines(Graphics::Screen &screen, const Graphics::Font &font,
-		const Common::Array<Common::String> &lines, int x, int y, int width) {
-	const int lineHeight = getDialogueTextLineHeight(font);
-	for (uint i = 0; i < lines.size(); ++i)
-		font.drawString(&screen, lines[i], x, y + (int)i * lineHeight, width, 0);
-}
-
-static void splitDialogueMenuLine(const Common::String &line, Common::Array<Common::String> &parts) {
-	parts.clear();
-
-	Common::StringTokenizer tokenizer(line, "/");
-	while (!tokenizer.empty()) {
-		Common::String token = tokenizer.nextToken();
-		token.trim();
-		if (!token.empty())
-			parts.push_back(token);
-	}
-}
-
-static bool loadDialogueHeadBitmap(HarvesterEngine &engine, const Common::String &speakerId,
-		IndexedBitmap &bitmap) {
-	StartupScript *startupScript = engine.getStartupScript();
-	ResourceManager *resources = engine.getResources();
-	if (!startupScript || !resources || speakerId.empty())
-		return false;
-
-	const Common::String headId = Common::String::format("%s0", speakerId.c_str());
-	const StartupHeadRecord *head = startupScript->findHeadRecord(headId);
-	if (!head)
-		return false;
-
-	return loadBitmapResource(*resources, head->portraitPath + ".BM", bitmap);
-}
-
-static Common::String buildDialogueVoicePath(const StartupScript &startupScript, int wavId) {
-	if (wavId <= 0)
-		return Common::String();
-
-	return Common::String::format("%s%d.CMP", startupScript.getVoicePath().c_str(), wavId);
-}
-
-static int getDialogueMenuItemAt(const Graphics::Font &font, uint itemCount, const Common::Point &mousePos) {
-	if (itemCount == 0)
-		return -1;
-	if (mousePos.x < kDialogueTopicStartX || mousePos.x > kDialogueTopicEndX)
-		return -1;
-	if (mousePos.y < kDialogueKeywordTextY)
-		return -1;
-
-	const int lineHeight = getDialogueTextLineHeight(font);
-	const int selection = (mousePos.y - kDialogueKeywordTextY) / MAX<int>(1, lineHeight);
-	return selection >= 0 && selection < (int)itemCount ? selection : -1;
-}
-
-static bool isDialogueOtherHit(const Common::Point &mousePos) {
-	return mousePos.x >= kDialogueTopicStartX && mousePos.x <= kDialogueTopicEndX &&
-		mousePos.y >= kDialogueOtherStartY && mousePos.y <= kDialogueOtherEndY;
-}
-
-static int getNativeRoomMenuSelectionFromMouse(const Graphics::Font &selectedFont, uint itemCount,
-		const Common::Point &mousePos) {
-	if (itemCount == 0)
-		return -1;
-
-	int selection = mousePos.y - kMenuStartY;
-	if (selection < 1)
-		selection = 1;
-
-	selection /= getNativeRoomMenuLineHeight(selectedFont);
-	return MIN<int>(selection, (int)itemCount - 1);
-}
-
 static void drawInventoryOverlay(Graphics::Screen &screen, const StartupArt &art, StartupScript &startupScript,
 		const Graphics::Font &font, const Common::Array<StartupInventoryVisual> &items,
 		const Common::String &selectedItemName, const Common::String &promptText) {
@@ -1947,7 +1727,8 @@ static void renderQuickTipsScreen(HarvesterEngine &engine, const StartupRoomScen
 
 } // End of anonymous namespace
 
-StartupFlow::StartupFlow(HarvesterEngine &engine) : _engine(engine), _mousePos(320, 200) {
+StartupFlow::StartupFlow(HarvesterEngine &engine)
+	: _engine(engine), _mousePos(320, 200), _dialogue(engine, _mousePos), _menu(engine, _mousePos, _menuItems) {
 }
 
 bool StartupFlow::load() {
@@ -2114,994 +1895,16 @@ Common::Error StartupFlow::runQuickTips() {
 }
 
 Common::Error StartupFlow::runMainMenuStub() {
-	Graphics::FrameLimiter limiter(g_system, 60);
-	Common::String statusMessage = "Stub handoff after room_setup(\"QUICK_TIPS\") and before the main menu loop.";
-	int selectedItem = _menuItems.empty() ? -1 : 0;
-	bool needsRedraw = true;
-	if (_engine.getRuntimeEntities())
-		_engine.getRuntimeEntities()->clearSceneEntities();
-	resetCursorAnimationSequence();
-
-	while (!_engine.shouldQuit()) {
-		if (needsRedraw) {
-			renderMainMenuStub(selectedItem, statusMessage);
-			needsRedraw = false;
-		}
-
-		Common::Event event;
-		while (g_system->getEventManager()->pollEvent(event)) {
-			Common::Error result = Common::kNoError;
-			if (handleSystemEvent(event, result))
-				return result;
-
-			switch (event.type) {
-			case Common::EVENT_MOUSEMOVE: {
-				const int hoveredItem = getMenuItemAt(_mousePos);
-				if (hoveredItem != -1 && hoveredItem != selectedItem) {
-					selectedItem = hoveredItem;
-					needsRedraw = true;
-				}
-				break;
-			}
-			case Common::EVENT_KEYDOWN:
-				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
-					return Common::kNoError;
-
-				if (_menuItems.empty())
-					break;
-
-				if (event.kbd.keycode == Common::KEYCODE_UP) {
-					selectedItem = (selectedItem + _menuItems.size() - 1) % _menuItems.size();
-					needsRedraw = true;
-				} else if (event.kbd.keycode == Common::KEYCODE_DOWN) {
-					selectedItem = (selectedItem + 1) % _menuItems.size();
-					needsRedraw = true;
-				} else if (event.kbd.keycode == Common::KEYCODE_RETURN || event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
-					if (_menuItems[selectedItem].equalsIgnoreCase("QUIT GAME"))
-						return Common::kNoError;
-
-					if (_menuItems[selectedItem].equalsIgnoreCase("NEW GAME")) {
-						_engine.getStartupScript()->resetRuntimeState();
-						Common::Error roomError = runRoomLoop("START");
-						if (roomError.getCode() == Common::kReadingFailed) {
-							statusMessage = "Unable to resolve START room setup from HARVEST.SCR.";
-							needsRedraw = true;
-						} else if (roomError.getCode() != Common::kNoError) {
-							return roomError;
-						} else {
-							statusMessage = "Resolved START room handoff from HARVEST.SCR.";
-							needsRedraw = true;
-						}
-					} else {
-						statusMessage = Common::String::format("%s is stubbed; room setup and gameplay loop are next.",
-							_menuItems[selectedItem].c_str());
-						needsRedraw = true;
-					}
-				}
-				break;
-			case Common::EVENT_LBUTTONDOWN:
-				if (_menuItems.empty())
-					break;
-
-				selectedItem = getMenuItemAt(_mousePos);
-				if (selectedItem == -1)
-					break;
-
-				if (_menuItems[selectedItem].equalsIgnoreCase("QUIT GAME"))
-					return Common::kNoError;
-
-				if (_menuItems[selectedItem].equalsIgnoreCase("NEW GAME")) {
-					_engine.getStartupScript()->resetRuntimeState();
-					Common::Error roomError = runRoomLoop("START");
-					_engine.stopStartupMusic();
-					_engine.stopStartupSound();
-					if (roomError.getCode() == Common::kReadingFailed) {
-						statusMessage = "Unable to resolve START room setup from HARVEST.SCR.";
-						needsRedraw = true;
-					} else if (roomError.getCode() != Common::kNoError) {
-						return roomError;
-					} else {
-						statusMessage = "Resolved START room handoff from HARVEST.SCR.";
-						needsRedraw = true;
-					}
-				} else {
-					statusMessage = Common::String::format("%s is stubbed; room setup and gameplay loop are next.",
-						_menuItems[selectedItem].c_str());
-					needsRedraw = true;
-				}
-				break;
-			default:
-				break;
-			}
-		}
-
-		if (tickRuntimeEntities())
-			needsRedraw = true;
-
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
-
-	return Common::kNoError;
+	return _menu.runMainMenuStub(*this);
 }
 
 Common::Error StartupFlow::runRoomMenuStub(const IndexedBitmap &backdrop) {
-	Graphics::FrameLimiter limiter(g_system, 60);
-	int selectedItem = _menuItems.empty() ? -1 : 0;
-	bool needsRedraw = true;
-	resetCursorAnimationSequence();
-
-	while (!_engine.shouldQuit()) {
-		if (needsRedraw) {
-			renderRoomMenuStub(backdrop, selectedItem);
-			needsRedraw = false;
-		}
-
-		Common::Event event;
-		while (g_system->getEventManager()->pollEvent(event)) {
-			Common::Error result = Common::kNoError;
-			if (handleSystemEvent(event, result))
-				return result;
-
-			switch (event.type) {
-			case Common::EVENT_MOUSEMOVE: {
-				const int hoveredItem = getRoomMenuItemAt(_mousePos);
-				if (hoveredItem != -1 && hoveredItem != selectedItem) {
-					selectedItem = hoveredItem;
-					needsRedraw = true;
-				}
-				break;
-			}
-			case Common::EVENT_KEYDOWN:
-				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
-					return Common::kNoError;
-
-				if (_menuItems.empty())
-					break;
-
-				if (event.kbd.keycode == Common::KEYCODE_UP) {
-					selectedItem = (selectedItem + _menuItems.size() - 1) % _menuItems.size();
-					needsRedraw = true;
-				} else if (event.kbd.keycode == Common::KEYCODE_DOWN) {
-					selectedItem = (selectedItem + 1) % _menuItems.size();
-					needsRedraw = true;
-				} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
-						event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
-					debug(1, "Harvester: room menu item '%s' selected but not implemented",
-						selectedItem >= 0 ? _menuItems[selectedItem].c_str() : "");
-				}
-				break;
-			case Common::EVENT_LBUTTONDOWN:
-				if (_menuItems.empty())
-					break;
-
-				selectedItem = getRoomMenuItemAt(_mousePos);
-				if (selectedItem == -1)
-					break;
-
-				debug(1, "Harvester: room menu item '%s' clicked but not implemented",
-					_menuItems[selectedItem].c_str());
-				break;
-			default:
-				break;
-			}
-		}
-
-		RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
-		if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
-			needsRedraw = true;
-
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
-
-	return Common::kNoError;
+	return _menu.runRoomMenuStub(backdrop, *this);
 }
 
 Common::Error StartupFlow::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, const StartupNpcRecord &npc, const Common::String &usedItemName) {
-	struct DialogueResponseOptionLayout {
-		Common::String text;
-		Common::Array<Common::String> wrappedLines;
-		int rowStart = 0;
-		int rowCount = 0;
-	};
-
-	struct DialogueLineSpec {
-		DialogueLineSpec(int wavId, const char *speakerId) : wavId(wavId), speakerId(speakerId) {}
-
-		int wavId;
-		const char *speakerId;
-	};
-
-	Graphics::Screen *screen = _engine.getScreen();
-	StartupScript *startupScript = _engine.getStartupScript();
-	StartupText *startupText = _engine.getStartupText();
-	StartupArt *startupArt = _engine.getStartupArt();
-	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
-	const Graphics::Font *fallbackFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	if (!screen || !startupScript || !startupText || !startupArt || !fallbackFont || !backdrop.isValid())
-		return Common::kReadingFailed;
-
-	const CftFontResource *normalFontResource = findStartupFontByName(_engine, "TEXTFONT");
-	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "TEXTFNT2");
-	std::unique_ptr<HarvesterCftFont> normalCftFont;
-	std::unique_ptr<HarvesterCftFont> selectedCftFont;
-	if (normalFontResource) {
-		normalCftFont.reset(new HarvesterCftFont(*normalFontResource));
-		if (!normalCftFont->isValid())
-			normalCftFont.reset();
-	}
-	if (selectedFontResource) {
-		selectedCftFont.reset(new HarvesterCftFont(*selectedFontResource));
-		if (!selectedCftFont->isValid())
-			selectedCftFont.reset();
-	}
-
-	const Graphics::Font *normalFont = normalCftFont ? static_cast<const Graphics::Font *>(normalCftFont.get()) : fallbackFont;
-	const Graphics::Font *selectedFont = selectedCftFont ? static_cast<const Graphics::Font *>(selectedCftFont.get()) : normalFont;
-	const bool normalFontUsesCft = normalCftFont.get() != nullptr;
-	const bool selectedFontUsesCft = selectedCftFont.get() != nullptr;
-
-	IndexedBitmap keywordBitmap;
-	if (!loadBitmapResource(*_engine.getResources(), kDialogueKeywordBitmapPath, keywordBitmap))
-		return Common::kReadingFailed;
-
-	Common::String genericByeTopic = startupText->getDialogueResponseLine(kDialogueGenericByeResponseIndex);
-	if (genericByeTopic.empty())
-		genericByeTopic = "BYE";
-
-	IndexedBitmap leftHeadBitmap;
-	IndexedBitmap rightHeadBitmap;
-	Common::String leftHeadSpeakerId = npc.npcName;
-	Common::String rightHeadSpeakerId = "PC";
-	bool leftHeadVisible = false;
-	bool rightHeadVisible = false;
-	(void)loadDialogueHeadBitmap(_engine, leftHeadSpeakerId, leftHeadBitmap);
-	(void)loadDialogueHeadBitmap(_engine, rightHeadSpeakerId, rightHeadBitmap);
-	leftHeadVisible = leftHeadBitmap.isValid();
-	rightHeadVisible = rightHeadBitmap.isValid();
-
-	auto waitForPointerRelease = [&]() -> Common::Error {
-		Graphics::FrameLimiter limiter(g_system, 60);
-		while (!_engine.shouldQuit() && (isPrimaryMouseDown() || isSecondaryMouseDown())) {
-			Common::Event event;
-			while (g_system->getEventManager()->pollEvent(event)) {
-				Common::Error result = Common::kNoError;
-				if (handleSystemEvent(event, result))
-					return result;
-			}
-
-			if (runtimeEntities)
-				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
-			limiter.delayBeforeSwap();
-			limiter.startFrame();
-		}
-
-		return Common::kNoError;
-	};
-
-	auto drawFontString = [&](const Graphics::Font &font, bool usesCft, const Common::String &text,
-			int x, int y, int width, byte color) {
-		if (usesCft)
-			font.drawString(screen, text, x, y, width, 0);
-		else
-			drawShadowedString(*screen, font, text, x, y, width, color);
-	};
-
-	auto ensureSpeakerPortrait = [&](const Common::String &speakerId) {
-		if (speakerId.empty())
-			return;
-
-		IndexedBitmap *targetBitmap = speakerId.equalsIgnoreCase("PC") ? &rightHeadBitmap : &leftHeadBitmap;
-		Common::String *targetSpeakerId = speakerId.equalsIgnoreCase("PC") ? &rightHeadSpeakerId : &leftHeadSpeakerId;
-		if (targetBitmap->isValid() && targetSpeakerId->equalsIgnoreCase(speakerId))
-			return;
-
-		IndexedBitmap updatedBitmap;
-		if (loadDialogueHeadBitmap(_engine, speakerId, updatedBitmap)) {
-			*targetBitmap = updatedBitmap;
-			*targetSpeakerId = speakerId;
-		} else {
-			warning("Harvester: unable to load dialogue head for '%s'", speakerId.c_str());
-		}
-	};
-
-	auto setActiveSpeakerPortrait = [&](const Common::String &speakerId) {
-		if (speakerId.empty() || speakerId.equalsIgnoreCase("SPEECH"))
-			return;
-
-		ensureSpeakerPortrait(speakerId);
-		if (speakerId.equalsIgnoreCase("PC")) {
-			leftHeadVisible = false;
-			rightHeadVisible = rightHeadBitmap.isValid();
-		} else {
-			leftHeadVisible = leftHeadBitmap.isValid();
-			rightHeadVisible = false;
-		}
-	};
-
-	auto drawDialogueOverlay = [&](const IndexedBitmap *overlayBitmap,
-			const Common::Array<Common::String> *subtitleLines, const Common::Array<Common::String> *topics,
-			int hoveredTopicIndex, bool hoverOther, const Common::String *textEntryValue) {
-		setScaledPalette(*screen, palette, paletteBrightness);
-		blitBitmap(*screen, backdrop, 0, 0);
-		if (leftHeadVisible && leftHeadBitmap.isValid())
-			blitTransparentBitmap(*screen, leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
-		if (rightHeadVisible && rightHeadBitmap.isValid())
-			blitTransparentBitmap(*screen, rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
-		if (overlayBitmap && overlayBitmap->isValid())
-			blitTransparentBitmap(*screen, *overlayBitmap, kDialogueOverlayX, kDialogueOverlayY);
-
-		if (subtitleLines) {
-			if (normalFontUsesCft)
-				drawDialogueTextLines(*screen, *normalFont, *subtitleLines,
-					kDialogueSubtitleTextX, kDialogueSubtitleTextY, kDialogueSubtitleTextWidth);
-			else {
-				const int lineHeight = getDialogueTextLineHeight(*normalFont);
-				for (uint i = 0; i < subtitleLines->size(); ++i) {
-					drawShadowedString(*screen, *normalFont, (*subtitleLines)[i],
-						kDialogueSubtitleTextX,
-						kDialogueSubtitleTextY + (int)i * lineHeight,
-						kDialogueSubtitleTextWidth, kTextColorNormal);
-				}
-			}
-		}
-
-		if (topics) {
-			const int lineHeight = getDialogueTextLineHeight(*normalFont);
-			for (uint i = 0; i < topics->size(); ++i) {
-				const bool highlighted = (int)i == hoveredTopicIndex;
-				const Graphics::Font &font = highlighted ? *selectedFont : *normalFont;
-				const bool usesCft = highlighted ? selectedFontUsesCft : normalFontUsesCft;
-				drawFontString(font, usesCft, (*topics)[i], kDialogueKeywordTextX,
-					kDialogueKeywordTextY + (int)i * lineHeight, kDialogueKeywordTextWidth,
-					highlighted ? kTextColorHover : kTextColorNormal);
-			}
-
-			const Graphics::Font &otherFont = hoverOther ? *selectedFont : *normalFont;
-			const bool otherUsesCft = hoverOther ? selectedFontUsesCft : normalFontUsesCft;
-			drawFontString(otherFont, otherUsesCft, "Other", kDialogueOtherX, kDialogueOtherY,
-				kDialogueOtherWidth, hoverOther ? kTextColorHover : kTextColorNormal);
-		}
-
-		if (textEntryValue) {
-			drawFontString(*selectedFont, selectedFontUsesCft,
-				*textEntryValue + "_", kDialogueTextEntryX, kDialogueTextEntryY,
-				kDialogueKeywordTextWidth - 30, kTextColorHover);
-		}
-
-		if (runtimeEntities)
-			runtimeEntities->drawCursor(*screen);
-		screen->makeAllDirty();
-		screen->update();
-	};
-
-	auto playDialogueLine = [&](int wavId, const Common::String &speakerId) -> Common::Error {
-		setActiveSpeakerPortrait(speakerId);
-
-		Common::String subtitleText;
-		const bool textEnabled = startupScript->getDialogueTextMode() != kStartupDialogueTextNone &&
-			startupText->resolveDialogueSubtitle(wavId, subtitleText);
-		Common::Array<Common::String> subtitleLines;
-		const IndexedBitmap *textboxBitmap = nullptr;
-		if (textEnabled) {
-			normalFont->wordWrapText(subtitleText, kDialogueSubtitleTextWidth, subtitleLines);
-			textboxBitmap = startupArt->getTextboxBitmap(resolveDialogueTextboxIndex(subtitleLines.size()));
-		}
-
-		const Common::String voicePath = buildDialogueVoicePath(*startupScript, wavId);
-		const bool voiceStarted = !voicePath.empty() && _engine.playStartupSingleSound(voicePath);
-		Common::Error releaseError = waitForPointerRelease();
-		if (releaseError.getCode() != Common::kNoError) {
-			_engine.stopStartupSound();
-			return releaseError;
-		}
-
-		bool interrupted = false;
-		Graphics::FrameLimiter limiter(g_system, 60);
-		for (;;) {
-			drawDialogueOverlay(textboxBitmap, textEnabled ? &subtitleLines : nullptr, nullptr, -1, false, nullptr);
-
-			Common::Event event;
-			while (g_system->getEventManager()->pollEvent(event)) {
-				Common::Error result = Common::kNoError;
-				if (handleSystemEvent(event, result)) {
-					_engine.stopStartupSound();
-					return result;
-				}
-
-				switch (event.type) {
-				case Common::EVENT_LBUTTONDOWN:
-				case Common::EVENT_RBUTTONDOWN:
-					interrupted = true;
-					break;
-				case Common::EVENT_KEYDOWN:
-					if (event.kbd.keycode == Common::KEYCODE_ESCAPE ||
-							event.kbd.keycode == Common::KEYCODE_RETURN ||
-							event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
-							event.kbd.keycode == Common::KEYCODE_SPACE) {
-						interrupted = true;
-					}
-					break;
-				default:
-					break;
-				}
-			}
-
-			if (runtimeEntities)
-				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
-			if (interrupted || (!voiceStarted || !_engine.isStartupSingleSoundPlaying()))
-				break;
-
-			limiter.delayBeforeSwap();
-			limiter.startFrame();
-		}
-
-		_engine.stopStartupSound();
-		if (interrupted) {
-			Common::Error releaseResult = waitForPointerRelease();
-			if (releaseResult.getCode() != Common::kNoError)
-				return releaseResult;
-		} else if (textEnabled && startupScript->getDialogueTextMode() == kStartupDialogueTextClick) {
-			Graphics::FrameLimiter clickLimiter(g_system, 60);
-			for (;;) {
-				drawDialogueOverlay(textboxBitmap, &subtitleLines, nullptr, -1, false, nullptr);
-
-				bool continuePressed = false;
-				Common::Event event;
-				while (g_system->getEventManager()->pollEvent(event)) {
-					Common::Error result = Common::kNoError;
-					if (handleSystemEvent(event, result))
-						return result;
-
-					switch (event.type) {
-					case Common::EVENT_LBUTTONDOWN:
-					case Common::EVENT_RBUTTONDOWN:
-						continuePressed = true;
-						break;
-					case Common::EVENT_KEYDOWN:
-						if (event.kbd.keycode == Common::KEYCODE_ESCAPE ||
-								event.kbd.keycode == Common::KEYCODE_RETURN ||
-								event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
-								event.kbd.keycode == Common::KEYCODE_SPACE) {
-							continuePressed = true;
-						}
-						break;
-					default:
-						break;
-					}
-				}
-
-				if (runtimeEntities)
-					(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
-				if (continuePressed)
-					break;
-
-				clickLimiter.delayBeforeSwap();
-				clickLimiter.startFrame();
-			}
-
-			Common::Error releaseResult = waitForPointerRelease();
-			if (releaseResult.getCode() != Common::kNoError)
-				return releaseResult;
-		}
-
-		return Common::kNoError;
-	};
-
-	auto playDialogueSequence = [&](const DialogueLineSpec *lines, uint count) -> Common::Error {
-		for (uint i = 0; i < count; ++i) {
-			Common::Error lineError = playDialogueLine(lines[i].wavId, lines[i].speakerId);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-
-		return Common::kNoError;
-	};
-
-	auto buildResponseMenuLayout = [&](const Common::String &responseLine,
-			Common::Array<DialogueResponseOptionLayout> &options, uint &totalRows) {
-		options.clear();
-		totalRows = 0;
-
-		Common::Array<Common::String> rawOptions;
-		splitDialogueMenuLine(responseLine, rawOptions);
-		for (const Common::String &rawOption : rawOptions) {
-			DialogueResponseOptionLayout option;
-			option.text = rawOption;
-			normalFont->wordWrapText(rawOption, kDialogueSubtitleTextWidth, option.wrappedLines);
-			if (option.wrappedLines.empty())
-				option.wrappedLines.push_back(rawOption);
-			option.rowStart = (int)totalRows;
-			option.rowCount = MAX<int>(1, option.wrappedLines.size());
-			totalRows += option.rowCount;
-			options.push_back(option);
-		}
-	};
-
-	auto getResponseMenuItemAt = [&](const Common::Array<DialogueResponseOptionLayout> &options,
-			uint totalRows, const Common::Point &mousePos) -> int {
-		if (mousePos.x < kDialogueTopicStartX || mousePos.x > kDialogueTopicEndX)
-			return -1;
-		if (mousePos.y < kDialogueKeywordTextY)
-			return -1;
-
-		const int lineHeight = getDialogueTextLineHeight(*normalFont);
-		const int row = (mousePos.y - kDialogueKeywordTextY) / MAX<int>(1, lineHeight);
-		if (row < 0 || row >= (int)totalRows)
-			return -1;
-
-		for (uint i = 0; i < options.size(); ++i) {
-			const DialogueResponseOptionLayout &option = options[i];
-			if (row >= option.rowStart && row < option.rowStart + option.rowCount)
-				return (int)i;
-		}
-
-		return -1;
-	};
-
-	auto drawDialogueResponseMenu = [&](const IndexedBitmap *textboxBitmap,
-			const Common::Array<DialogueResponseOptionLayout> &options, int hoveredOptionIndex) {
-		setScaledPalette(*screen, palette, paletteBrightness);
-		blitBitmap(*screen, backdrop, 0, 0);
-		if (leftHeadVisible && leftHeadBitmap.isValid())
-			blitTransparentBitmap(*screen, leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
-		if (rightHeadVisible && rightHeadBitmap.isValid())
-			blitTransparentBitmap(*screen, rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
-		if (textboxBitmap && textboxBitmap->isValid())
-			blitTransparentBitmap(*screen, *textboxBitmap, kDialogueOverlayX, kDialogueOverlayY);
-
-		const Common::String title = "Responses";
-		const Graphics::Font &titleFont = *normalFont;
-		const bool titleUsesCft = normalFontUsesCft;
-		const int titleWidth = titleFont.getStringWidth(title);
-		const int titleX = kDialogueSubtitleTextX + MAX<int>(0, (kDialogueSubtitleTextWidth - titleWidth) / 2);
-		drawFontString(titleFont, titleUsesCft, title, titleX, 11, titleWidth, kTextColorNormal);
-
-		const int lineHeight = getDialogueTextLineHeight(*normalFont);
-		int drawY = kDialogueKeywordTextY;
-		for (uint i = 0; i < options.size(); ++i) {
-			const bool highlighted = (int)i == hoveredOptionIndex;
-			const Graphics::Font &font = highlighted ? *selectedFont : *normalFont;
-			const bool usesCft = highlighted ? selectedFontUsesCft : normalFontUsesCft;
-			const byte color = highlighted ? kTextColorHover : kTextColorNormal;
-
-			for (const Common::String &wrappedLine : options[i].wrappedLines) {
-				drawFontString(font, usesCft, wrappedLine, kDialogueSubtitleTextX, drawY,
-					kDialogueSubtitleTextWidth, color);
-				drawY += lineHeight;
-			}
-		}
-
-		if (runtimeEntities)
-			runtimeEntities->drawCursor(*screen);
-		screen->makeAllDirty();
-		screen->update();
-	};
-
-	auto runResponseMenu = [&](int responseLineIndex, int &selectedIndex) -> Common::Error {
-		selectedIndex = 0;
-
-		const Common::String responseLine = startupText->getDialogueResponseLine(responseLineIndex);
-		if (responseLine.empty())
-			return Common::kNoError;
-
-		Common::Array<DialogueResponseOptionLayout> options;
-		uint totalRows = 0;
-		buildResponseMenuLayout(responseLine, options, totalRows);
-		if (options.empty())
-			return Common::kNoError;
-
-		const IndexedBitmap *textboxBitmap = startupArt->getTextboxBitmap(resolveDialogueTextboxIndex(totalRows));
-		Common::Error releaseError = waitForPointerRelease();
-		if (releaseError.getCode() != Common::kNoError)
-			return releaseError;
-
-		Graphics::FrameLimiter limiter(g_system, 60);
-		for (;;) {
-			const int hoveredOptionIndex = getResponseMenuItemAt(options, totalRows, _mousePos);
-			drawDialogueResponseMenu(textboxBitmap, options, hoveredOptionIndex);
-
-			Common::Event event;
-			while (g_system->getEventManager()->pollEvent(event)) {
-				Common::Error result = Common::kNoError;
-				if (handleSystemEvent(event, result))
-					return result;
-
-				if (event.type == Common::EVENT_KEYDOWN) {
-					if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
-						return Common::kNoError;
-					if (event.kbd.ascii >= '1' && event.kbd.ascii <= '9') {
-						const int menuIndex = event.kbd.ascii - '0';
-						if (menuIndex >= 1 && menuIndex <= (int)options.size()) {
-							selectedIndex = menuIndex;
-							return waitForPointerRelease();
-						}
-					}
-				} else if (event.type == Common::EVENT_LBUTTONDOWN && hoveredOptionIndex >= 0) {
-					selectedIndex = hoveredOptionIndex + 1;
-					return waitForPointerRelease();
-				}
-			}
-
-			if (runtimeEntities)
-				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
-			limiter.delayBeforeSwap();
-			limiter.startFrame();
-		}
-	};
-
-	auto runKeywordMenu = [&](const Common::String &topicBuffer, Common::String &selectedTopic) -> Common::Error {
-		selectedTopic.clear();
-		Common::Array<Common::String> topics;
-		splitDialogueMenuLine(topicBuffer, topics);
-		topics.push_back(genericByeTopic);
-		bool editingOther = false;
-		Common::String typedTopic;
-		Common::Error releaseError = waitForPointerRelease();
-		if (releaseError.getCode() != Common::kNoError)
-			return releaseError;
-
-		Graphics::FrameLimiter limiter(g_system, 60);
-		for (;;) {
-			const int hoveredTopicIndex = editingOther ? -1 : getDialogueMenuItemAt(*normalFont, topics.size(), _mousePos);
-			const bool hoverOther = !editingOther && isDialogueOtherHit(_mousePos);
-			drawDialogueOverlay(&keywordBitmap, nullptr, &topics, hoveredTopicIndex, hoverOther,
-				editingOther ? &typedTopic : nullptr);
-
-			Common::Event event;
-			while (g_system->getEventManager()->pollEvent(event)) {
-				Common::Error result = Common::kNoError;
-				if (handleSystemEvent(event, result))
-					return result;
-
-				if (editingOther) {
-					if (event.type == Common::EVENT_KEYDOWN) {
-						if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-							typedTopic.clear();
-							editingOther = false;
-							if (runtimeEntities)
-								runtimeEntities->showCursor();
-						} else if (event.kbd.keycode == Common::KEYCODE_BACKSPACE) {
-							if (!typedTopic.empty())
-								typedTopic.deleteLastChar();
-						} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
-								event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
-							if (!typedTopic.empty()) {
-								selectedTopic = typedTopic;
-								if (runtimeEntities)
-									runtimeEntities->showCursor();
-								return Common::kNoError;
-							}
-						} else if (event.kbd.ascii >= 0x20 && event.kbd.ascii <= 0x7e &&
-								typedTopic.size() < 79) {
-							typedTopic += (char)event.kbd.ascii;
-						}
-					}
-					continue;
-				}
-
-				switch (event.type) {
-				case Common::EVENT_KEYDOWN:
-					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-						selectedTopic = genericByeTopic;
-						return Common::kNoError;
-					}
-					break;
-				case Common::EVENT_LBUTTONDOWN: {
-					const int clickedTopic = getDialogueMenuItemAt(*normalFont, topics.size(), _mousePos);
-					if (clickedTopic >= 0) {
-						selectedTopic = topics[(uint)clickedTopic];
-						return Common::kNoError;
-					}
-					if (isDialogueOtherHit(_mousePos)) {
-						typedTopic.clear();
-						editingOther = true;
-						if (runtimeEntities)
-							runtimeEntities->hideCursor();
-					}
-					break;
-				}
-				default:
-					break;
-				}
-			}
-
-			if (runtimeEntities)
-				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
-			limiter.delayBeforeSwap();
-			limiter.startFrame();
-		}
-	};
-
-	if (!npc.npcName.equalsIgnoreCase("HANK")) {
-		debug(1, "Harvester: unsupported room NPC dialogue handler '%s'", npc.npcName.c_str());
-		return Common::kNoError;
-	}
-
-	if (!usedItemName.empty()) {
-		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-			_hankRoomDialogueState.discussedCasketPhotoEvidence = true;
-			return playDialogueLine(0xa3e, "HANK");
-		}
-		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-			_hankRoomDialogueState.discussedWhaleyHerrillPhoto = true;
-			return playDialogueLine(0xa53, "HANK");
-		}
-		return playDialogueLine(0xa38, "HANK");
-	}
-
-	Common::String hankTopicBuffer;
-	const int currentStoryDayIndex = startupScript->getCurrentStoryDayIndex();
-	if (_hankRoomDialogueState.pendingInitialConversation) {
-		_hankRoomDialogueState.trackedDayIndex = currentStoryDayIndex;
-		_hankRoomDialogueState.hasTrackedDayState = true;
-		_hankRoomDialogueState.pendingSameDayFollowup = true;
-		_hankRoomDialogueState.pendingInitialConversation = false;
-
-		Common::Error lineError = playDialogueLine(0x703, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		int responseIndex = 0;
-		Common::Error responseError = runResponseMenu(0xc7, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
-		if (responseIndex == 0)
-			return Common::kNoError;
-
-		switch (responseIndex) {
-		case 1:
-			lineError = playDialogueLine(0x70f, "HANK");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			hankTopicBuffer = startupText->getDialogueResponseLine(0xc8);
-			break;
-		case 2:
-			lineError = playDialogueLine(0x715, "HANK");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			hankTopicBuffer = startupText->getDialogueResponseLine(0xc9);
-			break;
-		case 3:
-			lineError = playDialogueLine(0x71c, "HANK");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			hankTopicBuffer = startupText->getDialogueResponseLine(0xca);
-			break;
-		default:
-			break;
-		}
-	}
-
-	if (!_hankRoomDialogueState.hasTrackedDayState ||
-			currentStoryDayIndex != _hankRoomDialogueState.trackedDayIndex) {
-		if (_hankRoomDialogueState.pendingSameDayFollowup) {
-			_hankRoomDialogueState.trackedDayIndex = currentStoryDayIndex;
-			_hankRoomDialogueState.pendingSameDayFollowup = false;
-			_hankRoomDialogueState.pendingRangshotSequence = true;
-
-			Common::Error lineError = playDialogueLine(0x8f5, "HANK");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-
-		if (_hankRoomDialogueState.pendingRangshotSequence) {
-			if (currentStoryDayIndex > 5) {
-				Common::Error lineError = playDialogueLine(0x8e2, "HANK");
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-			} else {
-				FstPlayer fstPlayer(_engine);
-				if (!fstPlayer.play(kDialogueRangshotFstPath))
-					return Common::kReadingFailed;
-			}
-		}
-	} else {
-		_hankRoomDialogueState.pendingSameDayFollowup = true;
-		if (currentStoryDayIndex > 5) {
-			Common::Error lineError = playDialogueLine(0x8e2, "HANK");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		} else {
-			FstPlayer fstPlayer(_engine);
-			if (!fstPlayer.play(kDialogueRangshotFstPath))
-				return Common::kReadingFailed;
-		}
-	}
-
-	if (startupScript->getFlagValue("STEPH_MIDGAME_PLAYED") &&
-			!_hankRoomDialogueState.stephMidgamePlayedShown) {
-		_hankRoomDialogueState.stephMidgamePlayedShown = true;
-
-		Common::Error lineError = playDialogueLine(0x92c, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		int responseIndex = 0;
-		Common::Error responseError = runResponseMenu(0xcc, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
-		if (responseIndex == 0)
-			return Common::kNoError;
-
-		switch (responseIndex) {
-		case 1: {
-			const DialogueLineSpec lines[] = {
-				{ 0x939, "HANK" },
-				{ 0x93f, "MOM" }
-			};
-			lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
-			break;
-		}
-		case 2:
-			lineError = playDialogueLine(0x944, "HANK");
-			break;
-		case 3:
-			lineError = playDialogueLine(0x948, "HANK");
-			break;
-		default:
-			break;
-		}
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		lineError = playDialogueLine(0x94f, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		responseIndex = 0;
-		responseError = runResponseMenu(0xcd, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
-		if (responseIndex == 0)
-			return Common::kNoError;
-
-		switch (responseIndex) {
-		case 1: {
-			const DialogueLineSpec lines[] = {
-				{ 0x95a, "HANK" },
-				{ 0x961, "PC" }
-			};
-			lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
-			break;
-		}
-		case 2: {
-			const DialogueLineSpec lines[] = {
-				{ 0x965, "HANK" },
-				{ 0x96b, "PC" }
-			};
-			lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
-			break;
-		}
-		default:
-			break;
-		}
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		lineError = playDialogueLine(0x971, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (startupScript->getFlagValue("BURNED_TV_STATION") &&
-			!_hankRoomDialogueState.burnedTvStationShown) {
-		_hankRoomDialogueState.burnedTvStationShown = true;
-		Common::Error lineError = playDialogueLine(0x987, "PC");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (startupScript->getFlagValue("BUSTED_ONCE") &&
-			!_hankRoomDialogueState.bustedOnceShown) {
-		_hankRoomDialogueState.bustedOnceShown = true;
-		Common::Error lineError = playDialogueLine(0x9b1, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (startupScript->getFlagValue("KARIN_KIDNAPED") &&
-			!_hankRoomDialogueState.karinKidnapedShown) {
-		_hankRoomDialogueState.karinKidnapedShown = true;
-		Common::Error lineError = playDialogueLine(0x9d5, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (startupScript->getFlagValue("KARIN_FOUND_ALIVE") &&
-			!_hankRoomDialogueState.karinFoundAliveShown) {
-		_hankRoomDialogueState.karinFoundAliveShown = true;
-		Common::Error lineError = playDialogueLine(0xa0b, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (startupScript->getFlagValue("KARIN_FOUND_DEAD") &&
-			!_hankRoomDialogueState.karinFoundDeadShown) {
-		_hankRoomDialogueState.karinFoundDeadShown = true;
-		Common::Error lineError = playDialogueLine(0xa15, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		int responseIndex = 0;
-		Common::Error responseError = runResponseMenu(0xd0, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
-		if (responseIndex == 0)
-			return Common::kNoError;
-
-		switch (responseIndex) {
-		case 1:
-			lineError = playDialogueLine(0xa20, "HANK");
-			break;
-		case 2:
-			lineError = playDialogueLine(0xa26, "HANK");
-			break;
-		default:
-			break;
-		}
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		lineError = playDialogueLine(0xa2b, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-
-	for (;;) {
-		Common::String selectedTopic;
-		Common::Error menuError = runKeywordMenu(hankTopicBuffer, selectedTopic);
-		if (menuError.getCode() != Common::kNoError)
-			return menuError;
-
-		if (selectedTopic.empty())
-			return Common::kNoError;
-		if (selectedTopic.equalsIgnoreCase(genericByeTopic))
-			return playDialogueLine(0x8dc, "HANK");
-
-		const Common::String coolDaddioTopic = startupText->getDialogueResponseLine(0xd2);
-		if (!coolDaddioTopic.empty() && selectedTopic.equalsIgnoreCase(coolDaddioTopic)) {
-			const DialogueLineSpec lines[] = {
-				{ 0x725, "HANK" },
-				{ 0x729, "PC" },
-				{ 0x72d, "HANK" },
-				{ 0x733, "PC" },
-				{ 0x737, "HANK" },
-				{ 0x73b, "PC" },
-				{ 0x741, "HANK" },
-				{ 0x747, "MOM" }
-			};
-			Common::Error lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			hankTopicBuffer = startupText->getDialogueResponseLine(0xd5);
-			continue;
-		}
-
-		bool handledTopic = false;
-		for (const HankDialogueTopicLine &topic : kHankDialogueTopicLines) {
-			if (topic.responseLineIndex == 0xd2)
-				continue;
-
-			const Common::String topicText = startupText->getDialogueResponseLine(topic.responseLineIndex);
-			if (topicText.empty() || !selectedTopic.equalsIgnoreCase(topicText))
-				continue;
-
-			if (topic.setDiscussedLodgeTopic)
-				_hankRoomDialogueState.discussedLodgeTopic = true;
-
-			Common::Error lineError = playDialogueLine(topic.wavId, topic.speakerId);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			handledTopic = true;
-			break;
-		}
-
-		if (handledTopic)
-			continue;
-
-		Common::Error lineError = playDialogueLine(0xa32, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
+	return _dialogue.runRoomNpcDialogue(backdrop, palette, paletteBrightness, npc, usedItemName, *this);
 }
 
 Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
@@ -4023,7 +2826,7 @@ void StartupFlow::executeStartupAudioCommands(const Common::Array<StartupAudioCo
 }
 
 void StartupFlow::resetRoomNpcDialogueState() {
-	_hankRoomDialogueState = HankRoomDialogueState();
+	_dialogue.resetRoomNpcDialogueState();
 }
 
 void StartupFlow::resetCursorAnimationSequence() {
@@ -4046,78 +2849,6 @@ bool StartupFlow::tickRuntimeEntities() {
 	return runtimeEntities->tickSceneEntities() || runtimeEntities->syncCursorEntityPosition(_mousePos);
 }
 
-void StartupFlow::renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const {
-	Graphics::Screen *screen = _engine.getScreen();
-	const StartupArt *art = _engine.getStartupArt();
-	const Graphics::Font *titleFont = FontMan.getFontByUsage(Graphics::FontManager::kBigGUIFont);
-	const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	if (!screen || !art || !titleFont || !bodyFont)
-		return;
-
-	screen->setPalette(art->getWaitPalette());
-	screen->fillRect(screen->getBounds(), 0);
-	blitBitmap(*screen, art->getInventoryBitmap(), kInventoryX, kInventoryY);
-	blitBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
-
-	const Common::Rect panel(96, 96, 544, 432);
-	screen->fillRect(panel, kPanelFillColor);
-
-	drawShadowedString(*screen, *titleFont, "Startup Main Menu Stub", panel.left, 110, panel.width(),
-		kTextColorNormal, Graphics::kTextAlignCenter);
-
-	for (uint i = 0; i < _menuItems.size(); ++i) {
-		const int itemY = kMenuStartY + (int)i * kMenuLineSpacing;
-		const byte color = ((int)i == selectedItem) ? kTextColorHover : kTextColorNormal;
-		drawShadowedString(*screen, *bodyFont, _menuItems[i], panel.left, itemY, panel.width(), color,
-			Graphics::kTextAlignCenter);
-	}
-
-	drawShadowedString(*screen, *bodyFont, "This is the next startup handoff after quick tips.", panel.left, 330,
-		panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
-	drawWrappedShadowedText(*screen, *bodyFont, statusMessage, panel.left + 24, 360, panel.width() - 48,
-		kTextColorNormal);
-	drawShadowedString(*screen, *bodyFont, "Use mouse or arrow keys. Enter activates. Esc returns to launcher.",
-		panel.left, 404, panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
-
-	if (_engine.getRuntimeEntities())
-		_engine.getRuntimeEntities()->drawCursor(*screen);
-	screen->makeAllDirty();
-	screen->update();
-}
-
-void StartupFlow::renderRoomMenuStub(const IndexedBitmap &backdrop, int selectedItem) const {
-	Graphics::Screen *screen = _engine.getScreen();
-	const StartupArt *art = _engine.getStartupArt();
-	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
-	const CftFontResource *unselectedFontResource = findStartupFontByName(_engine, "HARVFNT2");
-	if (!screen || !art || !selectedFontResource || !unselectedFontResource || !backdrop.isValid())
-		return;
-
-	HarvesterCftFont selectedFont(*selectedFontResource);
-	HarvesterCftFont unselectedFont(*unselectedFontResource);
-	if (!selectedFont.isValid() || !unselectedFont.isValid())
-		return;
-
-	blitBitmap(*screen, backdrop, 0, 0);
-	blitTransparentBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
-
-	const int lineSpacing = getNativeRoomMenuLineHeight(selectedFont);
-	for (uint i = 0; i < _menuItems.size(); ++i) {
-		const Graphics::Font *font = ((int)i == selectedItem)
-			? static_cast<const Graphics::Font *>(&selectedFont)
-			: static_cast<const Graphics::Font *>(&unselectedFont);
-		const int width = font->getStringWidth(_menuItems[i]);
-		const int x = (screen->w - width) / 2;
-		const int y = kMenuStartY + (int)i * lineSpacing;
-		font->drawString(screen, _menuItems[i], x, y, width, 0);
-	}
-
-	if (_engine.getRuntimeEntities())
-		_engine.getRuntimeEntities()->drawCursor(*screen);
-	screen->makeAllDirty();
-	screen->update();
-}
-
 bool StartupFlow::handleSystemEvent(const Common::Event &event, Common::Error &result) {
 	switch (event.type) {
 	case Common::EVENT_QUIT:
@@ -4138,33 +2869,4 @@ bool StartupFlow::handleSystemEvent(const Common::Event &event, Common::Error &r
 	return false;
 }
 
-int StartupFlow::getMenuItemAt(const Common::Point &mousePos) const {
-	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	if (!font)
-		return -1;
-
-	for (uint i = 0; i < _menuItems.size(); ++i) {
-		const int width = font->getStringWidth(_menuItems[i]);
-		const int x = (640 - width) / 2;
-		const int y = kMenuStartY + (int)i * kMenuLineSpacing;
-		const Common::Rect bounds(x - 12, y - 2, x + width + 12, y + font->getFontHeight() + 2);
-		if (bounds.contains(mousePos))
-			return (int)i;
-	}
-
-	return -1;
-}
-
-int StartupFlow::getRoomMenuItemAt(const Common::Point &mousePos) const {
-	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
-	if (!selectedFontResource)
-		return getMenuItemAt(mousePos);
-
-	HarvesterCftFont selectedFont(*selectedFontResource);
-	if (!selectedFont.isValid())
-		return getMenuItemAt(mousePos);
-
-	return getNativeRoomMenuSelectionFromMouse(selectedFont, _menuItems.size(), mousePos);
-}
-
 } // End of namespace Harvester
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/startup_flow.h
index 377797d39f9..6fbf44d6732 100644
--- a/engines/harvester/startup_flow.h
+++ b/engines/harvester/startup_flow.h
@@ -26,6 +26,8 @@
 #include "common/error.h"
 #include "common/rect.h"
 #include "common/str.h"
+#include "harvester/startup_dialogue.h"
+#include "harvester/startup_menu.h"
 #include "harvester/startup_script.h"
 
 namespace Common {
@@ -45,6 +47,9 @@ public:
 	Common::Error run();
 
 private:
+	friend class StartupDialogueSystem;
+	friend class StartupMenuSystem;
+
 	bool loadQuickTips();
 	bool loadMenuItems();
 	Common::Error runQuickTips();
@@ -64,34 +69,14 @@ private:
 	void resetRoomNpcDialogueState();
 	void resetCursorAnimationSequence();
 	bool tickRuntimeEntities();
-	void renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const;
-	void renderRoomMenuStub(const IndexedBitmap &backdrop, int selectedItem) const;
 	bool handleSystemEvent(const Common::Event &event, Common::Error &result);
-	int getMenuItemAt(const Common::Point &mousePos) const;
-	int getRoomMenuItemAt(const Common::Point &mousePos) const;
-
-	struct HankRoomDialogueState {
-		bool pendingInitialConversation = true;
-		bool hasTrackedDayState = false;
-		bool pendingRangshotSequence = false;
-		bool pendingSameDayFollowup = false;
-		int trackedDayIndex = 0;
-		bool stephMidgamePlayedShown = false;
-		bool burnedTvStationShown = false;
-		bool bustedOnceShown = false;
-		bool karinKidnapedShown = false;
-		bool karinFoundAliveShown = false;
-		bool karinFoundDeadShown = false;
-		bool discussedCasketPhotoEvidence = false;
-		bool discussedWhaleyHerrillPhoto = false;
-		bool discussedLodgeTopic = false;
-	};
 
 	HarvesterEngine &_engine;
 	Common::Array<Common::String> _quickTips;
 	Common::Array<Common::String> _menuItems;
 	Common::Point _mousePos;
-	HankRoomDialogueState _hankRoomDialogueState;
+	StartupDialogueSystem _dialogue;
+	StartupMenuSystem _menu;
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/startup_menu.cpp b/engines/harvester/startup_menu.cpp
new file mode 100644
index 00000000000..6677f1ae120
--- /dev/null
+++ b/engines/harvester/startup_menu.cpp
@@ -0,0 +1,432 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/startup_menu.h"
+
+#include "common/events.h"
+#include "common/system.h"
+#include "graphics/blit.h"
+#include "graphics/font.h"
+#include "graphics/fontman.h"
+#include "graphics/framelimiter.h"
+#include "harvester/cft_font.h"
+#include "harvester/harvester.h"
+#include "harvester/runtime_entity.h"
+#include "harvester/startup_art.h"
+#include "harvester/startup_flow.h"
+#include "harvester/startup_script.h"
+#include "harvester/startup_text.h"
+
+namespace Harvester {
+
+namespace {
+
+static const int kInventoryX = 64;
+static const int kInventoryY = 48;
+static const int kLogoX = 160;
+static const int kLogoY = 0;
+static const int kMenuStartY = 100;
+static const int kMenuLineSpacing = 28;
+
+static const byte kTextColorNormal = 255;
+static const byte kTextColorHover = 251;
+static const byte kTextColorDim = 248;
+static const byte kShadowColor = 0;
+static const byte kPanelFillColor = 1;
+static const byte kTransparentPaletteIndex = 0;
+
+static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
+	const StartupText *startupText = engine.getStartupText();
+	if (!startupText || !fontName)
+		return nullptr;
+
+	for (const CftFontResource &font : startupText->getFonts()) {
+		if (font.name.equalsIgnoreCase(fontName))
+			return &font;
+	}
+
+	return nullptr;
+}
+
+static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
+	if (!bitmap.isValid())
+		return;
+
+	screen.copyRectToSurface(bitmap.pixels.data(), bitmap.width, x, y, bitmap.width, bitmap.height);
+}
+
+static void blitTransparentBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
+	if (!bitmap.isValid())
+		return;
+
+	int srcX = 0;
+	int srcY = 0;
+	int destX = x;
+	int destY = y;
+	int width = (int)bitmap.width;
+	int height = (int)bitmap.height;
+
+	if (destX < 0) {
+		srcX = -destX;
+		width += destX;
+		destX = 0;
+	}
+	if (destY < 0) {
+		srcY = -destY;
+		height += destY;
+		destY = 0;
+	}
+	if (destX >= screen.w || destY >= screen.h || width <= 0 || height <= 0)
+		return;
+
+	width = MIN<int>(width, screen.w - destX);
+	height = MIN<int>(height, screen.h - destY);
+	if (width <= 0 || height <= 0)
+		return;
+
+	const byte *src = bitmap.pixels.data() + srcY * bitmap.width + srcX;
+	byte *dst = (byte *)screen.getBasePtr(destX, destY);
+	Graphics::keyBlit(dst, src, screen.pitch, bitmap.width, width, height,
+		screen.format.bytesPerPixel, kTransparentPaletteIndex);
+}
+
+static void drawShadowedString(Graphics::Screen &screen, const Graphics::Font &font, const Common::String &text,
+		int x, int y, int width, byte color, Graphics::TextAlign align = Graphics::kTextAlignLeft) {
+	font.drawString(&screen, text, x + 1, y + 1, width, kShadowColor, align);
+	font.drawString(&screen, text, x, y, width, color, align);
+}
+
+static void drawWrappedShadowedText(Graphics::Screen &screen, const Graphics::Font &font,
+		const Common::String &text, int x, int y, int width, byte color) {
+	Common::Array<Common::String> lines;
+	font.wordWrapText(text, width, lines);
+	for (uint i = 0; i < lines.size(); ++i)
+		drawShadowedString(screen, font, lines[i], x, y + (int)i * (font.getFontHeight() + 2), width, color);
+}
+
+static int getNativeRoomMenuLineHeight(const Graphics::Font &selectedFont) {
+	return selectedFont.getFontHeight() + 2;
+}
+
+static int getNativeRoomMenuSelectionFromMouse(const Graphics::Font &selectedFont, uint itemCount,
+		const Common::Point &mousePos) {
+	if (itemCount == 0)
+		return -1;
+
+	int selection = mousePos.y - kMenuStartY;
+	if (selection < 1)
+		selection = 1;
+
+	return (selection - 1) / MAX<int>(1, getNativeRoomMenuLineHeight(selectedFont));
+}
+
+} // End of anonymous namespace
+
+StartupMenuSystem::StartupMenuSystem(HarvesterEngine &engine, Common::Point &mousePos,
+		const Common::Array<Common::String> &menuItems)
+	: _engine(engine), _mousePos(mousePos), _menuItems(menuItems) {
+}
+
+Common::Error StartupMenuSystem::runMainMenuStub(StartupFlow &startupFlow) {
+	Graphics::FrameLimiter limiter(g_system, 60);
+	int selectedItem = _menuItems.empty() ? -1 : 0;
+	Common::String statusMessage = "Main menu stub active.";
+	bool needsRedraw = true;
+
+	while (!_engine.shouldQuit()) {
+		if (needsRedraw) {
+			renderMainMenuStub(selectedItem, statusMessage);
+			needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (startupFlow.handleSystemEvent(event, result))
+				return result;
+
+			switch (event.type) {
+			case Common::EVENT_MOUSEMOVE: {
+				const int hoveredItem = getMenuItemAt(_mousePos);
+				if (hoveredItem != -1 && hoveredItem != selectedItem) {
+					selectedItem = hoveredItem;
+					needsRedraw = true;
+				}
+				break;
+			}
+			case Common::EVENT_KEYDOWN:
+				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+					return Common::kNoError;
+
+				if (_menuItems.empty())
+					break;
+
+				if (event.kbd.keycode == Common::KEYCODE_UP) {
+					selectedItem = (selectedItem + _menuItems.size() - 1) % _menuItems.size();
+					needsRedraw = true;
+				} else if (event.kbd.keycode == Common::KEYCODE_DOWN) {
+					selectedItem = (selectedItem + 1) % _menuItems.size();
+					needsRedraw = true;
+				} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+						event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
+					if (_menuItems[selectedItem].equalsIgnoreCase("QUIT GAME"))
+						return Common::kNoError;
+
+					if (_menuItems[selectedItem].equalsIgnoreCase("NEW GAME")) {
+						_engine.getStartupScript()->resetRuntimeState();
+						Common::Error roomError = startupFlow.runRoomLoop("START");
+						if (roomError.getCode() == Common::kReadingFailed) {
+							statusMessage = "Unable to resolve START room setup from HARVEST.SCR.";
+							needsRedraw = true;
+						} else if (roomError.getCode() != Common::kNoError) {
+							return roomError;
+						} else {
+							statusMessage = "Resolved START room handoff from HARVEST.SCR.";
+							needsRedraw = true;
+						}
+					} else {
+						statusMessage = Common::String::format("%s is stubbed; room setup and gameplay loop are next.",
+							_menuItems[selectedItem].c_str());
+						needsRedraw = true;
+					}
+				}
+				break;
+			case Common::EVENT_LBUTTONDOWN:
+				if (_menuItems.empty())
+					break;
+
+				selectedItem = getMenuItemAt(_mousePos);
+				if (selectedItem == -1)
+					break;
+
+				if (_menuItems[selectedItem].equalsIgnoreCase("QUIT GAME"))
+					return Common::kNoError;
+
+				if (_menuItems[selectedItem].equalsIgnoreCase("NEW GAME")) {
+					_engine.getStartupScript()->resetRuntimeState();
+					Common::Error roomError = startupFlow.runRoomLoop("START");
+					_engine.stopStartupMusic();
+					_engine.stopStartupSound();
+					if (roomError.getCode() == Common::kReadingFailed) {
+						statusMessage = "Unable to resolve START room setup from HARVEST.SCR.";
+						needsRedraw = true;
+					} else if (roomError.getCode() != Common::kNoError) {
+						return roomError;
+					} else {
+						statusMessage = "Resolved START room handoff from HARVEST.SCR.";
+						needsRedraw = true;
+					}
+				} else {
+					statusMessage = Common::String::format("%s is stubbed; room setup and gameplay loop are next.",
+						_menuItems[selectedItem].c_str());
+					needsRedraw = true;
+				}
+				break;
+			default:
+				break;
+			}
+		}
+
+		if (startupFlow.tickRuntimeEntities())
+			needsRedraw = true;
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error StartupMenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, StartupFlow &startupFlow) {
+	Graphics::FrameLimiter limiter(g_system, 60);
+	int selectedItem = _menuItems.empty() ? -1 : 0;
+	bool needsRedraw = true;
+	startupFlow.resetCursorAnimationSequence();
+
+	while (!_engine.shouldQuit()) {
+		if (needsRedraw) {
+			renderRoomMenuStub(backdrop, selectedItem);
+			needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (startupFlow.handleSystemEvent(event, result))
+				return result;
+
+			switch (event.type) {
+			case Common::EVENT_MOUSEMOVE: {
+				const int hoveredItem = getRoomMenuItemAt(_mousePos);
+				if (hoveredItem != -1 && hoveredItem != selectedItem) {
+					selectedItem = hoveredItem;
+					needsRedraw = true;
+				}
+				break;
+			}
+			case Common::EVENT_KEYDOWN:
+				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+					return Common::kNoError;
+
+				if (_menuItems.empty())
+					break;
+
+				if (event.kbd.keycode == Common::KEYCODE_UP) {
+					selectedItem = (selectedItem + _menuItems.size() - 1) % _menuItems.size();
+					needsRedraw = true;
+				} else if (event.kbd.keycode == Common::KEYCODE_DOWN) {
+					selectedItem = (selectedItem + 1) % _menuItems.size();
+					needsRedraw = true;
+				} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+						event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
+					debug(1, "Harvester: room menu item '%s' selected but not implemented",
+						selectedItem >= 0 ? _menuItems[selectedItem].c_str() : "");
+				}
+				break;
+			case Common::EVENT_LBUTTONDOWN:
+				if (_menuItems.empty())
+					break;
+
+				selectedItem = getRoomMenuItemAt(_mousePos);
+				if (selectedItem == -1)
+					break;
+
+				debug(1, "Harvester: room menu item '%s' clicked but not implemented",
+					_menuItems[selectedItem].c_str());
+				break;
+			default:
+				break;
+			}
+		}
+
+		RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+		if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
+			needsRedraw = true;
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+void StartupMenuSystem::renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const {
+	Graphics::Screen *screen = _engine.getScreen();
+	const StartupArt *art = _engine.getStartupArt();
+	const Graphics::Font *titleFont = FontMan.getFontByUsage(Graphics::FontManager::kBigGUIFont);
+	const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	if (!screen || !art || !titleFont || !bodyFont)
+		return;
+
+	screen->setPalette(art->getWaitPalette());
+	screen->fillRect(screen->getBounds(), 0);
+	blitBitmap(*screen, art->getInventoryBitmap(), kInventoryX, kInventoryY);
+	blitBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
+
+	const Common::Rect panel(96, 96, 544, 432);
+	screen->fillRect(panel, kPanelFillColor);
+
+	drawShadowedString(*screen, *titleFont, "Startup Main Menu Stub", panel.left, 110, panel.width(),
+		kTextColorNormal, Graphics::kTextAlignCenter);
+
+	for (uint i = 0; i < _menuItems.size(); ++i) {
+		const int itemY = kMenuStartY + (int)i * kMenuLineSpacing;
+		const byte color = ((int)i == selectedItem) ? kTextColorHover : kTextColorNormal;
+		drawShadowedString(*screen, *bodyFont, _menuItems[i], panel.left, itemY, panel.width(), color,
+			Graphics::kTextAlignCenter);
+	}
+
+	drawShadowedString(*screen, *bodyFont, "This is the next startup handoff after quick tips.", panel.left, 330,
+		panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
+	drawWrappedShadowedText(*screen, *bodyFont, statusMessage, panel.left + 24, 360, panel.width() - 48,
+		kTextColorNormal);
+	drawShadowedString(*screen, *bodyFont, "Use mouse or arrow keys. Enter activates. Esc returns to launcher.",
+		panel.left, 404, panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
+
+	if (_engine.getRuntimeEntities())
+		_engine.getRuntimeEntities()->drawCursor(*screen);
+	screen->makeAllDirty();
+	screen->update();
+}
+
+void StartupMenuSystem::renderRoomMenuStub(const IndexedBitmap &backdrop, int selectedItem) const {
+	Graphics::Screen *screen = _engine.getScreen();
+	const StartupArt *art = _engine.getStartupArt();
+	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
+	const CftFontResource *unselectedFontResource = findStartupFontByName(_engine, "HARVFNT2");
+	if (!screen || !art || !selectedFontResource || !unselectedFontResource || !backdrop.isValid())
+		return;
+
+	HarvesterCftFont selectedFont(*selectedFontResource);
+	HarvesterCftFont unselectedFont(*unselectedFontResource);
+	if (!selectedFont.isValid() || !unselectedFont.isValid())
+		return;
+
+	blitBitmap(*screen, backdrop, 0, 0);
+	blitTransparentBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
+
+	const int lineSpacing = getNativeRoomMenuLineHeight(selectedFont);
+	for (uint i = 0; i < _menuItems.size(); ++i) {
+		const Graphics::Font *font = ((int)i == selectedItem)
+			? static_cast<const Graphics::Font *>(&selectedFont)
+			: static_cast<const Graphics::Font *>(&unselectedFont);
+		const int width = font->getStringWidth(_menuItems[i]);
+		const int x = (screen->w - width) / 2;
+		const int y = kMenuStartY + (int)i * lineSpacing;
+		font->drawString(screen, _menuItems[i], x, y, width, 0);
+	}
+
+	if (_engine.getRuntimeEntities())
+		_engine.getRuntimeEntities()->drawCursor(*screen);
+	screen->makeAllDirty();
+	screen->update();
+}
+
+int StartupMenuSystem::getMenuItemAt(const Common::Point &mousePos) const {
+	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	if (!font)
+		return -1;
+
+	for (uint i = 0; i < _menuItems.size(); ++i) {
+		const int width = font->getStringWidth(_menuItems[i]);
+		const int x = (640 - width) / 2;
+		const int y = kMenuStartY + (int)i * kMenuLineSpacing;
+		const Common::Rect bounds(x - 12, y - 2, x + width + 12, y + font->getFontHeight() + 2);
+		if (bounds.contains(mousePos))
+			return (int)i;
+	}
+
+	return -1;
+}
+
+int StartupMenuSystem::getRoomMenuItemAt(const Common::Point &mousePos) const {
+	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
+	if (!selectedFontResource)
+		return getMenuItemAt(mousePos);
+
+	HarvesterCftFont selectedFont(*selectedFontResource);
+	if (!selectedFont.isValid())
+		return getMenuItemAt(mousePos);
+
+	return getNativeRoomMenuSelectionFromMouse(selectedFont, _menuItems.size(), mousePos);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/startup_menu.h b/engines/harvester/startup_menu.h
new file mode 100644
index 00000000000..0a24aab45b1
--- /dev/null
+++ b/engines/harvester/startup_menu.h
@@ -0,0 +1,57 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_STARTUP_MENU_H
+#define HARVESTER_STARTUP_MENU_H
+
+#include "common/array.h"
+#include "common/error.h"
+#include "common/rect.h"
+#include "common/str.h"
+
+namespace Harvester {
+
+class HarvesterEngine;
+class StartupFlow;
+struct IndexedBitmap;
+
+class StartupMenuSystem {
+public:
+	StartupMenuSystem(HarvesterEngine &engine, Common::Point &mousePos,
+		const Common::Array<Common::String> &menuItems);
+
+	Common::Error runMainMenuStub(StartupFlow &startupFlow);
+	Common::Error runRoomMenuStub(const IndexedBitmap &backdrop, StartupFlow &startupFlow);
+
+private:
+	void renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const;
+	void renderRoomMenuStub(const IndexedBitmap &backdrop, int selectedItem) const;
+	int getMenuItemAt(const Common::Point &mousePos) const;
+	int getRoomMenuItemAt(const Common::Point &mousePos) const;
+
+	HarvesterEngine &_engine;
+	Common::Point &_mousePos;
+	const Common::Array<Common::String> &_menuItems;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_STARTUP_MENU_H


Commit: a5ec55becd73c29cde3c726db731edbbb446b2a5
    https://github.com/scummvm/scummvm/commit/a5ec55becd73c29cde3c726db731edbbb446b2a5
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:48+02:00

Commit Message:
HARVESTER: Extract startup inventory and room systems

Changed paths:
  A engines/harvester/startup_inventory.cpp
  A engines/harvester/startup_inventory.h
  A engines/harvester/startup_room.cpp
  A engines/harvester/startup_room.h
  A engines/harvester/startup_room_support.h
    engines/harvester/module.mk
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_flow.h


diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index dc4ca22500f..a65caf8a921 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -11,7 +11,9 @@ MODULE_OBJS = \
 	startup_art.o \
 	startup_dialogue.o \
 	startup_flow.o \
+	startup_inventory.o \
 	startup_menu.o \
+	startup_room.o \
 	startup_script.o \
 	startup_text.o \
 	xfile_archive.o
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index e959d204304..e8256356cbb 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -49,12 +49,6 @@ static const char *const kQuickTipsPath = "ADJHEAD.RCS";
 static const char *const kMenuPath = "MENU.INI";
 static const char *const kMenuSectionName = "menu";
 
-static const int kInventoryX = 64;
-static const int kInventoryY = 48;
-static const int kInventoryItemStartX = 73;
-static const int kInventoryItemStartY = 115;
-static const int kInventoryItemMaxRight = 564;
-static const int kInventoryItemSpacing = 5;
 static const int kQuickTipsOverlayX = 167;
 static const int kQuickTipsOverlayY = 200;
 
@@ -81,7 +75,6 @@ static const byte kIdentTextColor = 0xd3;
 static const byte kTextColorNormal = 255;
 static const byte kShadowColor = 0;
 static const byte kQuickTipActionColor = 0xc3;
-static const byte kRoomPromptColor = 0xce;
 
 static const int kCursorSequenceWalk = 0;
 static const int kCursorSequenceExamine = 1;
@@ -100,66 +93,6 @@ static const int kRoomPlayerIdleLoopLastFrame = 0xb2;
 static const int kRoomPlayerIdleExitLastFrame = 0xbf;
 static const int kRoomPlayerIdleYOffset = 4;
 
-struct StartupRoomSceneResources {
-	StartupRoomSetupState state;
-	byte palette[256 * 3] = { 0 };
-	Common::Array<StartupObjectRecord> sceneObjects;
-	Common::Array<StartupAnimRecord> sceneAnimations;
-	Common::Array<StartupRegionRecord> sceneRegions;
-	float targetPaletteBrightness = 1.0f;
-};
-
-struct StartupRoomPlayerState {
-	RuntimeEntity *entity = nullptr;
-	int centerX = 0;
-	int bottomY = 0;
-	float z = 0.0f;
-	int facing = -1;
-	bool hasMoveTarget = false;
-	int targetX = 0;
-	float targetZ = 0.0f;
-	bool turnActive = false;
-	int turnTargetFacing = -1;
-	int turnFirstFrame = -1;
-	int turnLastFrame = -1;
-	int turnEndFrame = -1;
-	bool turnPlayBackwards = false;
-};
-
-struct StartupRoomHoverState {
-	const StartupObjectRecord *object = nullptr;
-	const StartupNpcRecord *npc = nullptr;
-	const StartupRegionRecord *region = nullptr;
-	const RuntimeEntity *playerEntity = nullptr;
-	Common::String promptText;
-	int cursorSequence = kCursorSequenceNeutral;
-};
-
-struct StartupInventoryVisual {
-	StartupObjectRecord object;
-	IndexedBitmap bitmap;
-	Common::Rect bounds;
-	bool hasBitmap = false;
-};
-
-struct StartupInventoryOverlayState {
-	Common::Array<StartupInventoryVisual> items;
-	bool open = false;
-	Common::String selectedItemName;
-	Common::String promptText;
-};
-
-struct StartupRoomIdleAnimationState {
-	RuntimeEntity *entity = nullptr;
-	int restoreFacing = -1;
-	uint32 activityTick = 0;
-	uint32 resetTick = 0;
-	uint32 triggerTick = 0;
-	bool active = false;
-	bool loopStarted = false;
-	bool exiting = false;
-};
-
 struct PlayerAnimationRange {
 	PlayerAnimationRange() {}
 	PlayerAnimationRange(int walkFirstFrame, int walkLastFrame, int idleFrame)
@@ -180,6 +113,8 @@ struct PlayerTurnAnimationRange {
 	bool playBackwards = false;
 };
 
+} // End of anonymous namespace
+
 static int roundToInt(float value) {
 	return value >= 0.0f ? (int)floorf(value + 0.5f) : (int)ceilf(value - 0.5f);
 }
@@ -317,8 +252,8 @@ static void setRoomActorScreenPosition(RuntimeEntity &entity, int centerX, int b
 	entity.setPosition(centerX - xOffset - width / 2, bottomY - height - yOffset, z);
 }
 
-static bool applyRoomActorPlacement(const StartupRoomSetupState &state, RuntimeEntity &entity,
-		int centerX, int bottomY, float z, const Common::String *entranceName = nullptr) {
+bool applyRoomActorPlacement(const StartupRoomSetupState &state, RuntimeEntity &entity,
+		int centerX, int bottomY, float z, const Common::String *entranceName) {
 	int width = 0;
 	int height = 0;
 	int xOffset = 0;
@@ -457,7 +392,7 @@ static bool tryApplyPlayerMovement(HarvesterEngine &engine, const StartupRoomSet
 		playerState.centerX, playerState.bottomY, playerState.z);
 }
 
-static void setPlayerMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
+void setPlayerMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
 		int targetX, float targetZ) {
 	playerState.hasMoveTarget = true;
 	playerState.targetX = CLIP<int>(targetX, 0, 639);
@@ -468,13 +403,13 @@ static void setPlayerMoveTarget(const StartupRoomSetupState &state, StartupRoomP
 		playerState.targetX, mapRoomDepthToScreenY(state, playerState.targetZ), (double)playerState.targetZ);
 }
 
-static void setPlayerMoveTargetFromScreenPoint(const StartupRoomSetupState &state,
+void setPlayerMoveTargetFromScreenPoint(const StartupRoomSetupState &state,
 		StartupRoomPlayerState &playerState, int targetX, int targetBottomY) {
 	setPlayerMoveTarget(state, playerState, targetX,
 		mapRoomScreenYToDepth(state, clampRoomMovementY(state, targetBottomY)));
 }
 
-static int resolveRegionTargetX(const StartupRegionRecord &region, const StartupRoomPlayerState &playerState) {
+int resolveRegionTargetX(const StartupRegionRecord &region, const StartupRoomPlayerState &playerState) {
 	const Common::Rect bounds(region.left, region.top, region.right + 1, region.bottom + 1);
 	int targetX = bounds.left + bounds.width() / 2;
 	if (playerState.entity && targetX + kRoomRegionTargetXBias < playerState.entity->getScreenRect().right)
@@ -482,7 +417,7 @@ static int resolveRegionTargetX(const StartupRegionRecord &region, const Startup
 	return CLIP<int>(targetX, 0, 639);
 }
 
-static float resolveRegionTargetZ(const StartupRegionRecord &region) {
+float resolveRegionTargetZ(const StartupRegionRecord &region) {
 	if (region.desiredFacing == 0)
 		return (float)region.maxZ;
 	if (region.desiredFacing == 3)
@@ -491,11 +426,11 @@ static float resolveRegionTargetZ(const StartupRegionRecord &region) {
 	return (float)(region.minZ + (region.maxZ - region.minZ) / 2);
 }
 
-static uint32 getRuntimeClockTicks() {
+uint32 getRuntimeClockTicks() {
 	return g_system ? (g_system->getMillis() / kRuntimeClockDivisorMs) : 0;
 }
 
-static bool isIdleAnimationExcludedRoom(const Common::String &roomName) {
+bool isIdleAnimationExcludedRoom(const Common::String &roomName) {
 	static const char *const kExcludedRooms[] = {
 		"JAWS",
 		"SUPPLY1",
@@ -515,15 +450,10 @@ static bool isIdleAnimationExcludedRoom(const Common::String &roomName) {
 	return false;
 }
 
-static void updatePlayerIdleTrigger(StartupRoomIdleAnimationState &idleState) {
+void updatePlayerIdleTrigger(StartupRoomIdleAnimationState &idleState) {
 	idleState.triggerTick = MAX(idleState.activityTick, idleState.resetTick) + kRoomPlayerIdleDelayTicks;
 }
 
-static void notePlayerActivity(StartupRoomIdleAnimationState &idleState) {
-	idleState.activityTick = getRuntimeClockTicks();
-	updatePlayerIdleTrigger(idleState);
-}
-
 static void notePlayerIdleReset(StartupRoomIdleAnimationState &idleState) {
 	idleState.resetTick = getRuntimeClockTicks();
 	updatePlayerIdleTrigger(idleState);
@@ -549,7 +479,7 @@ static uint32 hashPalette(const byte *palette) {
 	return hash;
 }
 
-static void logScenePaletteSummary(const char *label, const StartupRoomSceneResources &scene, float brightness) {
+void logScenePaletteSummary(const char *label, const StartupRoomSceneResources &scene, float brightness) {
 	const byte *palette = scene.palette;
 	byte minValue = 0;
 	byte maxValue = 0;
@@ -584,7 +514,7 @@ static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, in
 	screen.copyRectToSurface(bitmap.pixels.data(), bitmap.width, x, y, bitmap.width, bitmap.height);
 }
 
-static bool captureScreenBackdrop(const Graphics::Screen &screen, IndexedBitmap &bitmap) {
+bool captureScreenBackdrop(const Graphics::Screen &screen, IndexedBitmap &bitmap) {
 	if (screen.w <= 0 || screen.h <= 0 || screen.format.bytesPerPixel != 1)
 		return false;
 
@@ -652,7 +582,7 @@ static bool loadPaletteResource(ResourceManager &resources, const Common::String
 	return true;
 }
 
-static Common::Rect getHotspotBounds(const StartupObjectRecord &object) {
+Common::Rect getHotspotBounds(const StartupObjectRecord &object) {
 	if (object.boundsX2 > object.currentX && object.boundsY2 > object.currentY)
 		return Common::Rect(object.currentX, object.currentY, object.boundsX2 + 1, object.boundsY2 + 1);
 
@@ -666,7 +596,7 @@ static Common::Rect getRegionBounds(const StartupRegionRecord &region) {
 	return Common::Rect();
 }
 
-static Common::String resolveSceneObjectSpritePath(const StartupObjectRecord &object) {
+Common::String resolveSceneObjectSpritePath(const StartupObjectRecord &object) {
 	const bool atInitialPlacement = object.currentX == object.initialX &&
 		object.currentY == object.initialY &&
 		object.currentOwnerOrRoom.equalsIgnoreCase(object.initialOwnerOrRoom);
@@ -677,7 +607,7 @@ static Common::String resolveSceneObjectSpritePath(const StartupObjectRecord &ob
 	return object.spritePath;
 }
 
-static bool loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) {
+bool loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) {
 	Common::Array<byte> data;
 	if (!resources.loadFile(path, data) || data.size() < 12)
 		return false;
@@ -694,109 +624,6 @@ static bool loadBitmapResource(ResourceManager &resources, const Common::String
 	return true;
 }
 
-static bool isInventoryExitObject(const StartupObjectRecord &object) {
-	return object.objectName.equalsIgnoreCase("INV_EXIT");
-}
-
-static bool isInventoryStatusObject(const StartupObjectRecord &object) {
-	return object.objectName.hasPrefixIgnoreCase("INV_STAT");
-}
-
-static bool buildInventoryVisuals(StartupScript &startupScript, ResourceManager &resources,
-		Common::Array<StartupInventoryVisual> &items) {
-	items.clear();
-
-	Common::Array<StartupObjectRecord> inventoryObjects;
-	startupScript.getVisibleInventoryObjects(inventoryObjects);
-	int nextX = kInventoryItemStartX;
-	int nextY = kInventoryItemStartY;
-	int rowHeight = 0;
-
-	for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
-		StartupInventoryVisual visual;
-		visual.object = inventoryObject;
-
-		if (isInventoryExitObject(inventoryObject)) {
-			visual.bounds = getHotspotBounds(inventoryObject);
-			items.push_back(visual);
-			continue;
-		}
-		if (isInventoryStatusObject(inventoryObject))
-			continue;
-
-		const Common::String spritePath = resolveSceneObjectSpritePath(inventoryObject);
-		if (!spritePath.empty() && loadBitmapResource(resources, spritePath, visual.bitmap)) {
-			visual.hasBitmap = true;
-			if (nextX + (int)visual.bitmap.width > kInventoryItemMaxRight) {
-				nextX = kInventoryItemStartX;
-				nextY += rowHeight + kInventoryItemSpacing;
-				rowHeight = 0;
-			}
-
-			visual.object.currentX = nextX;
-			visual.object.currentY = nextY;
-			visual.bounds = Common::Rect(nextX, nextY, nextX + visual.bitmap.width, nextY + visual.bitmap.height);
-			nextX += visual.bitmap.width + kInventoryItemSpacing;
-			rowHeight = MAX<int>(rowHeight, visual.bitmap.height);
-		} else {
-			visual.bounds = getHotspotBounds(inventoryObject);
-		}
-
-		items.push_back(visual);
-	}
-
-	return true;
-}
-
-static const StartupInventoryVisual *findInventoryVisualAtPoint(
-		const Common::Array<StartupInventoryVisual> &items, const Common::Point &point) {
-	for (int i = (int)items.size() - 1; i >= 0; --i) {
-		if (items[i].bounds.contains(point))
-			return &items[i];
-	}
-
-	return nullptr;
-}
-
-static Common::String buildUseItemPrompt(const Common::String &itemLabel, const Common::String &targetLabel) {
-	if (itemLabel.empty())
-		return Common::String();
-	if (targetLabel.empty())
-		return Common::String::format("Use %s on ...", itemLabel.c_str());
-
-	return Common::String::format("Use %s on %s", itemLabel.c_str(), targetLabel.c_str());
-}
-
-static Common::Rect getInventoryPanelBounds(const StartupArt &art) {
-	const IndexedBitmap &bitmap = art.getInventoryBitmap();
-	return Common::Rect(kInventoryX, kInventoryY,
-		kInventoryX + (int)bitmap.width, kInventoryY + (int)bitmap.height);
-}
-
-static void drawInventoryOverlay(Graphics::Screen &screen, const StartupArt &art, StartupScript &startupScript,
-		const Graphics::Font &font, const Common::Array<StartupInventoryVisual> &items,
-		const Common::String &selectedItemName, const Common::String &promptText) {
-	blitBitmap(screen, art.getInventoryBitmap(), kInventoryX, kInventoryY);
-
-	for (const StartupInventoryVisual &item : items) {
-		if (item.hasBitmap && item.bitmap.isValid())
-			blitBitmap(screen, item.bitmap, item.object.currentX, item.object.currentY);
-	}
-
-	Common::String overlayPrompt = promptText;
-	if (overlayPrompt.empty() && !selectedItemName.empty()) {
-		for (const StartupInventoryVisual &item : items) {
-			if (item.object.objectName.equalsIgnoreCase(selectedItemName)) {
-				overlayPrompt = buildUseItemPrompt(startupScript.resolveObjectLabel(item.object), Common::String());
-				break;
-			}
-		}
-	}
-
-	if (!overlayPrompt.empty())
-		drawShadowedString(screen, font, overlayPrompt, 0, 462, 640, kRoomPromptColor, Graphics::kTextAlignCenter);
-}
-
 static void logSceneObjectSelection(const char *decision, const char *source, const StartupObjectRecord &object,
 		const Common::String &detail = Common::String()) {
 	const Common::Rect hotspotBounds = getHotspotBounds(object);
@@ -857,7 +684,7 @@ static void queueVisibleSceneObject(const char *source, const StartupObjectRecor
 	logSceneObjectSelection("queued", source, object);
 }
 
-static bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceManager &resources, StartupRoomSceneResources &scene) {
+bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceManager &resources, StartupRoomSceneResources &scene) {
 	scene = StartupRoomSceneResources();
 	scene.state = state;
 	scene.targetPaletteBrightness = state.paletteBrightness;
@@ -888,12 +715,12 @@ static bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceM
 	return true;
 }
 
-static bool shouldRunStartupRoomProbe() {
+bool shouldRunStartupRoomProbe() {
 	return ConfMan.hasKey("harvester_debug_probe_startup_room") &&
 		ConfMan.getBool("harvester_debug_probe_startup_room");
 }
 
-static void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const StartupRoomSceneResources &scene,
+void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const StartupRoomSceneResources &scene,
 		float brightness) {
 	setScaledPalette(screen, scene.palette, brightness);
 	screen.fillRect(screen.getBounds(), 0);
@@ -901,7 +728,7 @@ static void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, con
 		engine.getRuntimeEntities()->drawSceneEntities(screen);
 }
 
-static const StartupObjectRecord *findSceneObjectByName(const Common::Array<StartupObjectRecord> &objects,
+const StartupObjectRecord *findSceneObjectByName(const Common::Array<StartupObjectRecord> &objects,
 		const Common::String &objectName) {
 	for (const StartupObjectRecord &object : objects) {
 		if (object.objectName.equalsIgnoreCase(objectName))
@@ -911,7 +738,7 @@ static const StartupObjectRecord *findSceneObjectByName(const Common::Array<Star
 	return nullptr;
 }
 
-static const StartupRegionRecord *findSceneRegionByName(const Common::Array<StartupRegionRecord> &regions,
+const StartupRegionRecord *findSceneRegionByName(const Common::Array<StartupRegionRecord> &regions,
 		const Common::String &regionName) {
 	for (const StartupRegionRecord &region : regions) {
 		if (region.regionName.equalsIgnoreCase(regionName))
@@ -931,7 +758,7 @@ static const StartupNpcRecord *findSceneNpcByName(const Common::Array<StartupNpc
 	return nullptr;
 }
 
-static StartupObjectRecord *findSceneObjectByName(Common::Array<StartupObjectRecord> &objects,
+StartupObjectRecord *findSceneObjectByName(Common::Array<StartupObjectRecord> &objects,
 		const Common::String &objectName) {
 	for (StartupObjectRecord &object : objects) {
 		if (object.objectName.equalsIgnoreCase(objectName))
@@ -1016,7 +843,7 @@ static const StartupNpcRecord *findRoomNpcAtPoint(HarvesterEngine &engine,
 	return findSceneNpcByName(sceneNpcs, entity->getName());
 }
 
-static const IndexedBitmap *resolveInspectTextboxBitmap(const StartupArt &art, const StartupResolvedText &text) {
+const IndexedBitmap *resolveInspectTextboxBitmap(const StartupArt &art, const StartupResolvedText &text) {
 	if (text.boxName.equalsIgnoreCase("BOX1"))
 		return art.getTextboxBitmap(0);
 	if (text.boxName.equalsIgnoreCase("BOX2"))
@@ -1029,7 +856,7 @@ static const IndexedBitmap *resolveInspectTextboxBitmap(const StartupArt &art, c
 	return nullptr;
 }
 
-static void drawRoomInspectText(Graphics::Screen &screen, const StartupArt &art, const Graphics::Font &font,
+void drawRoomInspectText(Graphics::Screen &screen, const StartupArt &art, const Graphics::Font &font,
 		const StartupResolvedText &inspectText) {
 	const IndexedBitmap *textbox = resolveInspectTextboxBitmap(art, inspectText);
 	if (!textbox || !textbox->isValid())
@@ -1043,7 +870,7 @@ static void drawRoomInspectText(Graphics::Screen &screen, const StartupArt &art,
 		kIdentTextColor);
 }
 
-static bool unlocksRoomObjectInteractionAfterInitialExamine(const StartupObjectRecord &object,
+bool unlocksRoomObjectInteractionAfterInitialExamine(const StartupObjectRecord &object,
 		StartupScript &startupScript) {
 	return object.operatable || startupScript.isPickupObject(object);
 }
@@ -1099,11 +926,11 @@ static Common::String buildRoomNpcPrompt(const StartupNpcRecord &npc) {
 	return Common::String::format("Talk to %s", label.c_str());
 }
 
-static bool doesPlayerFacingMatchRegion(int playerFacing, const StartupRegionRecord &region) {
+bool doesPlayerFacingMatchRegion(int playerFacing, const StartupRegionRecord &region) {
 	return region.desiredFacing < 0 || playerFacing == region.desiredFacing;
 }
 
-static bool doesPlayerOverlapRegion(const RuntimeEntity &playerEntity, const StartupRegionRecord &region) {
+bool doesPlayerOverlapRegion(const RuntimeEntity &playerEntity, const StartupRegionRecord &region) {
 	const Common::Rect regionBounds = getRegionBounds(region);
 	if (regionBounds.isEmpty())
 		return false;
@@ -1114,7 +941,7 @@ static bool doesPlayerOverlapRegion(const RuntimeEntity &playerEntity, const Sta
 	return playerMaxZ >= (float)region.minZ && (float)region.maxZ >= playerEntity.getZ();
 }
 
-static StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const StartupRoomSetupState &state,
+StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const StartupRoomSetupState &state,
 		const Common::Array<StartupObjectRecord> &sceneObjects,
 		const Common::Array<StartupNpcRecord> &sceneNpcs,
 		const Common::Array<StartupRegionRecord> &sceneRegions, const Common::Point &mousePos) {
@@ -1158,7 +985,7 @@ static StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, cons
 	return hoverState;
 }
 
-static bool setPlayerIdleAnimation(StartupRoomPlayerState &playerState, int facing) {
+bool setPlayerIdleAnimation(StartupRoomPlayerState &playerState, int facing) {
 	if (!playerState.entity)
 		return false;
 
@@ -1199,7 +1026,7 @@ static bool setPlayerWalkAnimation(StartupRoomPlayerState &playerState, int faci
 	return true;
 }
 
-static bool startPlayerTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing) {
+bool startPlayerTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing) {
 	if (!playerState.entity || playerState.facing < 0 || playerState.facing == targetFacing)
 		return false;
 
@@ -1224,7 +1051,7 @@ static bool startPlayerTurnAnimation(StartupRoomPlayerState &playerState, int ta
 	return true;
 }
 
-static bool updatePlayerTurnAnimationState(StartupRoomPlayerState &playerState) {
+bool updatePlayerTurnAnimationState(StartupRoomPlayerState &playerState) {
 	if (!playerState.turnActive || !playerState.entity)
 		return false;
 	if (playerState.entity->getCurrentFrame() != playerState.turnEndFrame)
@@ -1244,7 +1071,7 @@ static bool updatePlayerTurnAnimationState(StartupRoomPlayerState &playerState)
 	return true;
 }
 
-static bool stepPlayerMoveTarget(HarvesterEngine &engine, const StartupRoomSetupState &state,
+bool stepPlayerMoveTarget(HarvesterEngine &engine, const StartupRoomSetupState &state,
 		const Common::Array<StartupObjectRecord> &sceneObjects,
 		const Common::Array<StartupAnimRecord> &sceneAnimations,
 		StartupRoomPlayerState &playerState) {
@@ -1300,7 +1127,7 @@ static bool stepPlayerMoveTarget(HarvesterEngine &engine, const StartupRoomSetup
 	return true;
 }
 
-static bool stepPlayerKeyboardMovement(HarvesterEngine &engine, const StartupRoomSetupState &state,
+bool stepPlayerKeyboardMovement(HarvesterEngine &engine, const StartupRoomSetupState &state,
 		const Common::Array<StartupObjectRecord> &sceneObjects,
 		const Common::Array<StartupAnimRecord> &sceneAnimations,
 		StartupRoomPlayerState &playerState, bool moveLeft, bool moveRight, bool moveUp, bool moveDown) {
@@ -1437,7 +1264,7 @@ static bool finishPlayerIdleAnimation(const StartupRoomSetupState &state, Startu
 	return true;
 }
 
-static bool requestPlayerIdleAnimationExit(const StartupRoomSetupState &state,
+bool requestPlayerIdleAnimationExit(const StartupRoomSetupState &state,
 		StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState) {
 	if (!idleState.active || idleState.exiting || !idleState.entity)
 		return false;
@@ -1459,7 +1286,7 @@ static bool requestPlayerIdleAnimationExit(const StartupRoomSetupState &state,
 	return true;
 }
 
-static bool startPlayerIdleAnimation(HarvesterEngine &engine, const StartupRoomSetupState &state,
+bool startPlayerIdleAnimation(HarvesterEngine &engine, const StartupRoomSetupState &state,
 		StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState) {
 	if (!playerState.entity || idleState.active || isIdleAnimationExcludedRoom(state.roomName))
 		return false;
@@ -1490,7 +1317,7 @@ static bool startPlayerIdleAnimation(HarvesterEngine &engine, const StartupRoomS
 	return true;
 }
 
-static bool updatePlayerIdleAnimation(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
+bool updatePlayerIdleAnimation(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
 		StartupRoomIdleAnimationState &idleState) {
 	if (!idleState.active || !idleState.entity)
 		return false;
@@ -1560,7 +1387,7 @@ static bool findRoomObjectProbePoint(HarvesterEngine &engine, const Common::Arra
 	return false;
 }
 
-static void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResources &scene,
+void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResources &scene,
 		const Common::String &entranceName, Common::Point &mousePos) {
 	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
 	StartupScript *startupScript = engine.getStartupScript();
@@ -1725,10 +1552,9 @@ static void renderQuickTipsScreen(HarvesterEngine &engine, const StartupRoomScen
 	screen->update();
 }
 
-} // End of anonymous namespace
-
 StartupFlow::StartupFlow(HarvesterEngine &engine)
-	: _engine(engine), _mousePos(320, 200), _dialogue(engine, _mousePos), _menu(engine, _mousePos, _menuItems) {
+	: _engine(engine), _mousePos(320, 200), _dialogue(engine, _mousePos), _inventory(engine),
+	  _menu(engine, _mousePos, _menuItems), _room(engine, _mousePos, _inventory) {
 }
 
 bool StartupFlow::load() {
@@ -1908,711 +1734,7 @@ Common::Error StartupFlow::runRoomNpcDialogue(const IndexedBitmap &backdrop, con
 }
 
 Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
-	StartupRoomSetupState state;
-	if (!_engine.getStartupScript()->resolveRoomSetupState(entranceName, state, *_engine.getResources()))
-		return Common::kReadingFailed;
-
-	Common::Error transitionError = beginRoomSetupTransition();
-	if (transitionError.getCode() != Common::kNoError)
-		return transitionError;
-
-	StartupRoomSceneResources scene;
-	if (!loadRoomSceneResources(state, *_engine.getResources(), scene))
-		return Common::kReadingFailed;
-
-	Graphics::Screen *screen = _engine.getScreen();
-	const StartupArt *art = _engine.getStartupArt();
-	const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	if (!screen || !art || !bodyFont)
-		return Common::kNoError;
-
-	if (!populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
-		return Common::kReadingFailed;
-
-	logScenePaletteSummary("room setup stub palette", scene, kPaletteBrightnessBlack);
-	drawRoomScene(_engine, *screen, scene, kPaletteBrightnessBlack);
-	screen->makeAllDirty();
-	screen->update();
-
-	logScenePaletteSummary("room setup fade target", scene, scene.targetPaletteBrightness);
-	transitionError = fadeInRoomScene(scene.palette, scene.targetPaletteBrightness);
-	if (transitionError.getCode() != Common::kNoError)
-		return transitionError;
-
-	resetCursorAnimationSequence();
-	executeStartupAudioCommands(scene.state.audioCommands);
-	if (!scene.state.musicPath.empty())
-		(void)_engine.playStartupMusic(scene.state.musicPath);
-	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
-	StartupRoomPlayerState playerState;
-	playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName(kPlayerActorEntityName) : nullptr;
-	playerState.centerX = state.playerSpawnX;
-	playerState.bottomY = state.playerSpawnY;
-	playerState.z = (float)state.playerSpawnZ;
-	playerState.facing = state.playerFacing;
-	playerState.turnActive = false;
-	playerState.turnTargetFacing = -1;
-	StartupResolvedText inspectText;
-	bool showingInspectText = false;
-	bool inspectCanDismiss = false;
-	bool moveLeft = false;
-	bool moveRight = false;
-	bool moveUp = false;
-	bool moveDown = false;
-	Common::String pendingRegionName;
-	StartupInventoryOverlayState inventoryState;
-	StartupRoomIdleAnimationState idleState;
-	bool needsRedraw = true;
-	auto resetIdleState = [&]() {
-		idleState = StartupRoomIdleAnimationState();
-		idleState.activityTick = getRuntimeClockTicks();
-		idleState.resetTick = idleState.activityTick;
-		updatePlayerIdleTrigger(idleState);
-	};
-	resetIdleState();
-	auto refreshInventoryState = [&]() {
-		if (!buildInventoryVisuals(*_engine.getStartupScript(), *_engine.getResources(), inventoryState.items))
-			return false;
-
-		if (inventoryState.selectedItemName.empty())
-			return true;
-
-		for (const StartupInventoryVisual &item : inventoryState.items) {
-			if (item.object.objectName.equalsIgnoreCase(inventoryState.selectedItemName))
-				return true;
-		}
-
-		inventoryState.selectedItemName.clear();
-		inventoryState.promptText.clear();
-		return true;
-	};
-	auto resolveSelectedInventoryLabel = [&]() {
-		if (inventoryState.selectedItemName.empty())
-			return Common::String();
-
-		for (const StartupInventoryVisual &item : inventoryState.items) {
-			if (item.object.objectName.equalsIgnoreCase(inventoryState.selectedItemName))
-				return _engine.getStartupScript()->resolveObjectLabel(item.object);
-		}
-
-		Common::Array<StartupObjectRecord> inventoryObjects;
-		_engine.getStartupScript()->getVisibleInventoryObjects(inventoryObjects);
-		for (const StartupObjectRecord &item : inventoryObjects) {
-			if (item.objectName.equalsIgnoreCase(inventoryState.selectedItemName))
-				return _engine.getStartupScript()->resolveObjectLabel(item);
-		}
-
-		return normalizeHarvesterResourcePath(inventoryState.selectedItemName);
-	};
-	auto clearInventorySelection = [&]() {
-		if (inventoryState.selectedItemName.empty() && inventoryState.promptText.empty())
-			return false;
-
-		inventoryState.selectedItemName.clear();
-		inventoryState.promptText.clear();
-		return true;
-	};
-	auto closeInventoryOverlay = [&]() {
-		bool changed = clearInventorySelection();
-		if (inventoryState.open)
-			changed = true;
-		inventoryState.open = false;
-		return changed;
-	};
-	auto openInventoryOverlay = [&]() {
-		moveLeft = false;
-		moveRight = false;
-		moveUp = false;
-		moveDown = false;
-		pendingRegionName.clear();
-		playerState.hasMoveTarget = false;
-		playerState.turnActive = false;
-		playerState.turnTargetFacing = -1;
-		inventoryState.open = true;
-		inventoryState.promptText.clear();
-		return refreshInventoryState();
-	};
-	auto refreshCurrentScene = [&](bool preservePlayerPlacement) {
-		const Common::Array<StartupAudioCommand> entryAudioCommands = scene.state.audioCommands;
-		StartupRoomSetupState refreshedState;
-		if (!_engine.getStartupScript()->materializeRoomState(
-				scene.state.entranceName, scene.state.roomName, refreshedState)) {
-			return false;
-		}
-
-		refreshedState.audioCommands = entryAudioCommands;
-		if (!loadRoomSceneResources(refreshedState, *_engine.getResources(), scene))
-			return false;
-		if (!populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
-			return false;
-
-		playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName(kPlayerActorEntityName) : nullptr;
-		if (playerState.entity) {
-			if (!preservePlayerPlacement || playerState.facing < 0) {
-				playerState.centerX = scene.state.playerSpawnX;
-				playerState.bottomY = scene.state.playerSpawnY;
-				playerState.z = (float)scene.state.playerSpawnZ;
-				playerState.facing = scene.state.playerFacing;
-			}
-
-			const int facing = playerState.facing >= 0 ? playerState.facing : scene.state.playerFacing;
-			(void)setPlayerIdleAnimation(playerState, facing);
-			(void)applyRoomActorPlacement(scene.state, *playerState.entity,
-				playerState.centerX, playerState.bottomY, playerState.z);
-		}
-
-		playerState.hasMoveTarget = false;
-		playerState.turnActive = false;
-		playerState.turnTargetFacing = -1;
-		pendingRegionName.clear();
-		resetIdleState();
-		resetCursorAnimationSequence();
-		return refreshInventoryState();
-	};
-	auto captureDialogueBackdrop = [&](IndexedBitmap &dialogueBackdrop) {
-		drawRoomScene(_engine, *screen, scene, scene.targetPaletteBrightness);
-		return captureScreenBackdrop(*screen, dialogueBackdrop);
-	};
-	auto runRoomExitCommands = [&]() {
-		Common::Array<StartupAudioCommand> exitAudioCommands;
-		if (!_engine.getStartupScript()->executeRoomExitCommands(scene.state.roomName, exitAudioCommands))
-			return false;
-		executeStartupAudioCommands(exitAudioCommands);
-		return true;
-	};
-	auto handleInteractionResult = [&](const StartupInteractionResult &interaction) -> Common::Error {
-		playerState.hasMoveTarget = false;
-		playerState.turnActive = false;
-		playerState.turnTargetFacing = -1;
-		pendingRegionName.clear();
-
-		Common::String restoreMusicPath = _engine.getStartupMusicPath();
-		if (!interaction.musicPath.empty()) {
-			(void)_engine.playStartupMusic(interaction.musicPath);
-			restoreMusicPath = _engine.getStartupMusicPath();
-		}
-		executeStartupAudioCommands(interaction.audioCommands);
-
-		if (!interaction.nextRoomName.empty()) {
-			if (!runRoomExitCommands())
-				return Common::kReadingFailed;
-
-			Common::Error roomError = runRoomLoop(interaction.nextRoomName);
-			if (roomError.getCode() != Common::kReadingFailed &&
-				roomError.getCode() != Common::kNoError) {
-				return roomError;
-			}
-
-			if (!refreshCurrentScene(true))
-				return Common::kReadingFailed;
-
-			executeStartupAudioCommands(scene.state.audioCommands);
-			if (!restoreMusicPath.empty())
-				(void)_engine.playStartupMusic(restoreMusicPath);
-			else
-				_engine.stopStartupMusic();
-		} else if (interaction.mutatedRuntimeState) {
-			if (!refreshCurrentScene(true))
-				return Common::kReadingFailed;
-		}
-
-		return Common::kNoError;
-	};
-	auto handleInventoryTargetInteraction = [&](const StartupObjectRecord &target, bool preferPickup) -> Common::Error {
-		if (inventoryState.selectedItemName.empty())
-			return Common::kNoError;
-
-		StartupInteractionResult interaction;
-		bool handled = false;
-		if (preferPickup && _engine.getStartupScript()->isPickupObject(target))
-			handled = _engine.getStartupScript()->resolveObjectInteraction(target, interaction);
-		else
-			handled = _engine.getStartupScript()->resolveUseItemInteraction(
-				inventoryState.selectedItemName, target, interaction);
-		if (!handled)
-			return Common::kNoError;
-
-		clearInventorySelection();
-		Common::Error interactionError = handleInteractionResult(interaction);
-		if (interactionError.getCode() != Common::kNoError)
-			return interactionError;
-		if (!refreshInventoryState())
-			return Common::kReadingFailed;
-
-		needsRedraw = true;
-		return Common::kNoError;
-	};
-	auto queueRegionInteraction = [&](const StartupRegionRecord &region) {
-		pendingRegionName = region.regionName;
-		if (!playerState.entity)
-			return;
-
-		if (doesPlayerOverlapRegion(*playerState.entity, region))
-			return;
-
-		setPlayerMoveTarget(scene.state, playerState,
-			resolveRegionTargetX(region, playerState), resolveRegionTargetZ(region));
-	};
-	auto runRegionInteraction = [&](const StartupRegionRecord &region) -> Common::Error {
-		StartupInteractionResult interaction;
-		if (!_engine.getStartupScript()->resolveRegionInteraction(region, interaction))
-			return Common::kNoError;
-
-		Common::Error interactionError = handleInteractionResult(interaction);
-		if (interactionError.getCode() != Common::kNoError)
-			return interactionError;
-
-		if (!refreshInventoryState())
-			return Common::kReadingFailed;
-		needsRedraw = true;
-		return Common::kNoError;
-	};
-	auto tryActivatePendingRegion = [&]() -> Common::Error {
-		if (pendingRegionName.empty() || !playerState.entity)
-			return Common::kNoError;
-
-		const StartupRegionRecord *region = findSceneRegionByName(scene.sceneRegions, pendingRegionName);
-		if (!region || !region->startEnabled) {
-			pendingRegionName.clear();
-			return Common::kNoError;
-		}
-		if (!doesPlayerOverlapRegion(*playerState.entity, *region))
-			return Common::kNoError;
-		if (!doesPlayerFacingMatchRegion(playerState.facing, *region)) {
-			if (!playerState.hasMoveTarget && !playerState.turnActive && region->desiredFacing >= 0)
-				(void)startPlayerTurnAnimation(playerState, region->desiredFacing);
-			return Common::kNoError;
-		}
-
-		pendingRegionName.clear();
-		return runRegionInteraction(*region);
-	};
-	if (!refreshInventoryState())
-		return Common::kReadingFailed;
-	Graphics::FrameLimiter limiter(g_system, 60);
-
-	if (shouldRunStartupRoomProbe())
-		logStartupRoomProbe(_engine, scene, entranceName, _mousePos);
-
-	while (!_engine.shouldQuit()) {
-		if (needsRedraw) {
-			const Common::Rect inventoryPanelBounds = getInventoryPanelBounds(*art);
-			const bool inventoryPanelContainsMouse = inventoryState.open && inventoryPanelBounds.contains(_mousePos);
-			const bool suppressHover = showingInspectText || idleState.active || idleState.exiting ||
-				(inventoryState.open && (inventoryPanelContainsMouse || inventoryState.selectedItemName.empty()));
-			StartupRoomHoverState hoverState = suppressHover
-				? StartupRoomHoverState()
-				: resolveRoomHoverState(_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
-					scene.sceneRegions, _mousePos);
-			Common::String promptText;
-			if (inventoryState.open) {
-				const StartupInventoryVisual *inventoryHover = findInventoryVisualAtPoint(inventoryState.items, _mousePos);
-				if (!inventoryState.selectedItemName.empty()) {
-					Common::String targetLabel;
-					if (inventoryHover && !isInventoryExitObject(inventoryHover->object) &&
-						!isInventoryStatusObject(inventoryHover->object)) {
-						targetLabel = _engine.getStartupScript()->resolveObjectLabel(inventoryHover->object);
-					} else if (!inventoryPanelContainsMouse && hoverState.object) {
-						targetLabel = _engine.getStartupScript()->resolveObjectLabel(*hoverState.object);
-					}
-					promptText = buildUseItemPrompt(resolveSelectedInventoryLabel(), targetLabel);
-				} else if (inventoryHover) {
-					promptText = _engine.getStartupScript()->resolveObjectLabel(inventoryHover->object);
-				}
-				inventoryState.promptText = promptText;
-				hoverState.cursorSequence = kCursorSequenceNeutral;
-			} else {
-				promptText = hoverState.promptText;
-			}
-			if (RuntimeEntity *cursor = runtimeEntities ? runtimeEntities->getCursorEntity() : nullptr) {
-				cursor->setAnimationSequence(
-					(showingInspectText || idleState.active || idleState.exiting || inventoryState.open)
-						? kCursorSequenceNeutral
-						: hoverState.cursorSequence);
-			}
-
-			drawRoomScene(_engine, *screen, scene, scene.targetPaletteBrightness);
-			if (inventoryState.open)
-				drawInventoryOverlay(*screen, *art, *_engine.getStartupScript(), *bodyFont,
-					inventoryState.items, inventoryState.selectedItemName, inventoryState.promptText);
-
-			if (showingInspectText) {
-				drawRoomInspectText(*screen, *art, *bodyFont, inspectText);
-			} else if (!promptText.empty()) {
-				drawShadowedString(*screen, *bodyFont, promptText,
-					0, 462, 640, kRoomPromptColor, Graphics::kTextAlignCenter);
-			}
-
-			if (runtimeEntities)
-				runtimeEntities->drawCursor(*screen);
-			screen->makeAllDirty();
-			screen->update();
-			needsRedraw = false;
-		}
-
-		Common::Event event;
-		while (g_system->getEventManager()->pollEvent(event)) {
-			Common::Error result = Common::kNoError;
-			if (handleSystemEvent(event, result))
-				return result;
-
-			switch (event.type) {
-			case Common::EVENT_MOUSEMOVE:
-				needsRedraw = true;
-				break;
-			case Common::EVENT_RBUTTONDOWN:
-				notePlayerActivity(idleState);
-				if (idleState.active || idleState.exiting) {
-					if (requestPlayerIdleAnimationExit(scene.state, playerState, idleState))
-						needsRedraw = true;
-					break;
-				}
-				pendingRegionName.clear();
-				if (inventoryState.open) {
-					if (clearInventorySelection() || closeInventoryOverlay())
-						needsRedraw = true;
-					break;
-				}
-				break;
-			case Common::EVENT_LBUTTONUP:
-				if (showingInspectText)
-					inspectCanDismiss = true;
-				break;
-			case Common::EVENT_LBUTTONDOWN: {
-				notePlayerActivity(idleState);
-				if (idleState.active || idleState.exiting) {
-					if (requestPlayerIdleAnimationExit(scene.state, playerState, idleState))
-						needsRedraw = true;
-					break;
-				}
-
-				if (showingInspectText) {
-					if (inspectCanDismiss) {
-						showingInspectText = false;
-						inspectCanDismiss = false;
-						inspectText = StartupResolvedText();
-						needsRedraw = true;
-					}
-					break;
-				}
-
-				const Common::Rect inventoryPanelBounds = getInventoryPanelBounds(*art);
-				if (inventoryState.open) {
-					const StartupInventoryVisual *inventoryHover =
-						findInventoryVisualAtPoint(inventoryState.items, _mousePos);
-					if (inventoryHover) {
-						if (isInventoryExitObject(inventoryHover->object)) {
-							if (closeInventoryOverlay())
-								needsRedraw = true;
-							break;
-						}
-
-						if (inventoryState.selectedItemName.empty()) {
-							inventoryState.selectedItemName = inventoryHover->object.objectName;
-							inventoryState.promptText = buildUseItemPrompt(
-								resolveSelectedInventoryLabel(), Common::String());
-							needsRedraw = true;
-							break;
-						}
-
-						if (!inventoryHover->object.objectName.equalsIgnoreCase(inventoryState.selectedItemName)) {
-							Common::Error interactionError =
-								handleInventoryTargetInteraction(inventoryHover->object, false);
-							if (interactionError.getCode() != Common::kNoError)
-								return interactionError;
-						}
-						needsRedraw = true;
-						break;
-					}
-
-					if (!inventoryState.selectedItemName.empty()) {
-						const StartupRoomHoverState useHoverState = resolveRoomHoverState(
-							_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
-							scene.sceneRegions, _mousePos);
-						if (useHoverState.npc) {
-							IndexedBitmap dialogueBackdrop;
-							if (!captureDialogueBackdrop(dialogueBackdrop))
-								return Common::kReadingFailed;
-
-							Common::Error dialogueError = runRoomNpcDialogue(
-								dialogueBackdrop, scene.palette, scene.targetPaletteBrightness,
-								*useHoverState.npc, inventoryState.selectedItemName);
-							if (dialogueError.getCode() != Common::kNoError)
-								return dialogueError;
-							if (clearInventorySelection() || closeInventoryOverlay())
-								needsRedraw = true;
-							resetCursorAnimationSequence();
-							resetIdleState();
-							needsRedraw = true;
-							break;
-						}
-						const StartupObjectRecord *roomTarget = useHoverState.object
-							? findSceneObjectByName(scene.sceneObjects, useHoverState.object->objectName)
-							: nullptr;
-						if (roomTarget) {
-							Common::Error interactionError =
-								handleInventoryTargetInteraction(*roomTarget, true);
-							if (interactionError.getCode() != Common::kNoError)
-								return interactionError;
-							needsRedraw = true;
-							break;
-						}
-					}
-
-					if (!inventoryPanelBounds.contains(_mousePos) && !inventoryState.selectedItemName.empty()) {
-						needsRedraw = true;
-						break;
-					}
-					if (!inventoryPanelBounds.contains(_mousePos) && closeInventoryOverlay())
-						needsRedraw = true;
-					break;
-				}
-
-				const StartupRoomHoverState hoverState = resolveRoomHoverState(
-					_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions, _mousePos);
-				debugC(1, kDebugScene,
-					"Harvester: room click room='%s' mouse=(%d,%d) object='%s' npc='%s' region='%s' cursor_sequence=%d prompt='%s'",
-					scene.state.roomName.c_str(), _mousePos.x, _mousePos.y,
-					hoverState.object ? hoverState.object->objectName.c_str() : "",
-					hoverState.npc ? hoverState.npc->npcName.c_str() : "",
-					hoverState.region ? hoverState.region->regionName.c_str() : "",
-					hoverState.cursorSequence, hoverState.promptText.c_str());
-				if (hoverState.playerEntity) {
-					if (!playerState.entity || hoverState.playerEntity != playerState.entity ||
-						idleState.active || idleState.exiting ||
-						playerState.hasMoveTarget || playerState.turnActive ||
-						playerState.entity->getAnimationRate() != 0) {
-						break;
-					}
-					if (!openInventoryOverlay())
-						return Common::kReadingFailed;
-					needsRedraw = true;
-					break;
-				}
-				if (hoverState.npc) {
-					pendingRegionName.clear();
-					moveLeft = false;
-					moveRight = false;
-					moveUp = false;
-					moveDown = false;
-					playerState.hasMoveTarget = false;
-					playerState.turnActive = false;
-					playerState.turnTargetFacing = -1;
-					IndexedBitmap dialogueBackdrop;
-					if (!captureDialogueBackdrop(dialogueBackdrop))
-						return Common::kReadingFailed;
-					Common::Error dialogueError = runRoomNpcDialogue(
-						dialogueBackdrop, scene.palette, scene.targetPaletteBrightness,
-						*hoverState.npc, Common::String());
-					if (dialogueError.getCode() != Common::kNoError)
-						return dialogueError;
-					resetCursorAnimationSequence();
-					resetIdleState();
-					needsRedraw = true;
-					break;
-				}
-				if (hoverState.region && playerState.entity) {
-					queueRegionInteraction(*hoverState.region);
-					Common::Error interactionError = tryActivatePendingRegion();
-					if (interactionError.getCode() != Common::kNoError)
-						return interactionError;
-					needsRedraw = true;
-					break;
-				}
-				StartupObjectRecord *clickedObject = hoverState.object
-					? findSceneObjectByName(scene.sceneObjects, hoverState.object->objectName)
-					: nullptr;
-				if (!clickedObject) {
-					pendingRegionName.clear();
-					if (hoverState.cursorSequence == kCursorSequenceWalk && playerState.entity) {
-						setPlayerMoveTargetFromScreenPoint(scene.state, playerState, _mousePos.x, _mousePos.y);
-						needsRedraw = true;
-					}
-					break;
-				}
-				pendingRegionName.clear();
-				if (clickedObject->objectName.equalsIgnoreCase("EXIT_BM") ||
-					clickedObject->objectName.equalsIgnoreCase("EXIT_HS")) {
-					if (!runRoomExitCommands())
-						return Common::kReadingFailed;
-					return Common::kNoError;
-				}
-
-				StartupResolvedText resolvedInspectText;
-				const bool hasInspectText =
-					_engine.getStartupScript()->resolveObjectInspectText(*clickedObject, resolvedInspectText);
-				const bool unlocksAfterInitialExamine =
-					unlocksRoomObjectInteractionAfterInitialExamine(*clickedObject, *_engine.getStartupScript());
-				const bool canShowInspectText = hasInspectText &&
-					resolveInspectTextboxBitmap(*art, resolvedInspectText);
-				if (!clickedObject->identShown && unlocksAfterInitialExamine) {
-					clickedObject->identShown = true;
-					_engine.getStartupScript()->markObjectIdentShown(*clickedObject);
-					if (canShowInspectText) {
-						inspectText = resolvedInspectText;
-						showingInspectText = true;
-						inspectCanDismiss = false;
-					} else if (hasInspectText) {
-						debug(1, "Harvester: unsupported IDENT textbox '%s' for object '%s'",
-							resolvedInspectText.boxName.c_str(), clickedObject->objectName.c_str());
-					}
-					playerState.hasMoveTarget = false;
-					playerState.turnActive = false;
-					playerState.turnTargetFacing = -1;
-					needsRedraw = true;
-					break;
-				}
-
-				StartupInteractionResult interaction;
-				if (!_engine.getStartupScript()->resolveObjectInteraction(*clickedObject, interaction)) {
-					if (canShowInspectText) {
-						inspectText = resolvedInspectText;
-						showingInspectText = true;
-						inspectCanDismiss = false;
-					} else if (hasInspectText) {
-						debug(1, "Harvester: unsupported IDENT textbox '%s' for object '%s'",
-							resolvedInspectText.boxName.c_str(), clickedObject->objectName.c_str());
-					}
-					needsRedraw = true;
-					break;
-				}
-
-				Common::Error interactionError = handleInteractionResult(interaction);
-				if (interactionError.getCode() != Common::kNoError)
-					return interactionError;
-				needsRedraw = true;
-				break;
-			}
-			case Common::EVENT_KEYDOWN:
-				if (showingInspectText) {
-					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-						showingInspectText = false;
-						inspectCanDismiss = false;
-						inspectText = StartupResolvedText();
-						needsRedraw = true;
-					}
-					break;
-				}
-
-				notePlayerActivity(idleState);
-				if (idleState.active || idleState.exiting) {
-					if (requestPlayerIdleAnimationExit(scene.state, playerState, idleState))
-						needsRedraw = true;
-					break;
-				}
-
-				if (inventoryState.open) {
-					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-						if (clearInventorySelection() || closeInventoryOverlay())
-							needsRedraw = true;
-					} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
-							event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
-							event.kbd.keycode == Common::KEYCODE_i) {
-						if (closeInventoryOverlay())
-							needsRedraw = true;
-					}
-					break;
-				}
-
-				if (event.kbd.keycode == Common::KEYCODE_LEFT)
-					moveLeft = true;
-				else if (event.kbd.keycode == Common::KEYCODE_RIGHT)
-					moveRight = true;
-				else if (event.kbd.keycode == Common::KEYCODE_UP)
-					moveUp = true;
-				else if (event.kbd.keycode == Common::KEYCODE_DOWN)
-					moveDown = true;
-				else if (event.kbd.keycode == Common::KEYCODE_i) {
-					if (!openInventoryOverlay())
-						return Common::kReadingFailed;
-					needsRedraw = true;
-					break;
-				} else if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-					moveLeft = false;
-					moveRight = false;
-					moveUp = false;
-					moveDown = false;
-					pendingRegionName.clear();
-					playerState.hasMoveTarget = false;
-					playerState.turnActive = false;
-					playerState.turnTargetFacing = -1;
-					IndexedBitmap roomMenuBackdrop;
-					drawRoomScene(_engine, *screen, scene, scene.targetPaletteBrightness);
-					if (!captureScreenBackdrop(*screen, roomMenuBackdrop))
-						return Common::kReadingFailed;
-					Common::Error menuError = runRoomMenuStub(roomMenuBackdrop);
-					if (menuError.getCode() != Common::kNoError)
-						return menuError;
-					resetCursorAnimationSequence();
-					resetIdleState();
-					needsRedraw = true;
-					break;
-				}
-
-				if (event.kbd.keycode == Common::KEYCODE_RETURN ||
-					event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
-					if (!runRoomExitCommands())
-						return Common::kReadingFailed;
-					return Common::kNoError;
-				}
-				break;
-			case Common::EVENT_KEYUP:
-				if (event.kbd.keycode == Common::KEYCODE_LEFT)
-					moveLeft = false;
-				else if (event.kbd.keycode == Common::KEYCODE_RIGHT)
-					moveRight = false;
-				else if (event.kbd.keycode == Common::KEYCODE_UP)
-					moveUp = false;
-				else if (event.kbd.keycode == Common::KEYCODE_DOWN)
-					moveDown = false;
-				break;
-			default:
-				break;
-			}
-		}
-
-		if (updatePlayerTurnAnimationState(playerState))
-			needsRedraw = true;
-
-		if (!idleState.active && !idleState.exiting) {
-			if (stepPlayerKeyboardMovement(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
-					playerState, moveLeft, moveRight, moveUp, moveDown)) {
-				notePlayerActivity(idleState);
-				needsRedraw = true;
-			} else if (stepPlayerMoveTarget(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
-					playerState)) {
-				notePlayerActivity(idleState);
-				needsRedraw = true;
-			} else if (!moveLeft && !moveRight && !moveUp && !moveDown && !playerState.hasMoveTarget &&
-					!playerState.turnActive &&
-					playerState.entity && playerState.facing >= 0 &&
-					setPlayerIdleAnimation(playerState, playerState.facing)) {
-				needsRedraw = true;
-			}
-
-			if (!showingInspectText && !moveLeft && !moveRight && !moveUp && !moveDown &&
-					!playerState.hasMoveTarget && !playerState.turnActive &&
-					playerState.entity && playerState.facing >= 0 &&
-					!isIdleAnimationExcludedRoom(scene.state.roomName) &&
-					getRuntimeClockTicks() > idleState.triggerTick &&
-					startPlayerIdleAnimation(_engine, scene.state, playerState, idleState)) {
-				needsRedraw = true;
-			}
-		}
-		Common::Error pendingRegionError = tryActivatePendingRegion();
-		if (pendingRegionError.getCode() != Common::kNoError)
-			return pendingRegionError;
-
-		if (tickRuntimeEntities())
-			needsRedraw = true;
-		if (updatePlayerIdleAnimation(scene.state, playerState, idleState))
-			needsRedraw = true;
-
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
-
-	return Common::kNoError;
+	return _room.runRoomLoop(*this, entranceName);
 }
 
 bool StartupFlow::ensureCursorEntity() {
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/startup_flow.h
index 6fbf44d6732..885e0916f48 100644
--- a/engines/harvester/startup_flow.h
+++ b/engines/harvester/startup_flow.h
@@ -27,7 +27,9 @@
 #include "common/rect.h"
 #include "common/str.h"
 #include "harvester/startup_dialogue.h"
+#include "harvester/startup_inventory.h"
 #include "harvester/startup_menu.h"
+#include "harvester/startup_room.h"
 #include "harvester/startup_script.h"
 
 namespace Common {
@@ -48,7 +50,9 @@ public:
 
 private:
 	friend class StartupDialogueSystem;
+	friend class StartupInventorySystem;
 	friend class StartupMenuSystem;
+	friend class StartupRoomSystem;
 
 	bool loadQuickTips();
 	bool loadMenuItems();
@@ -76,7 +80,9 @@ private:
 	Common::Array<Common::String> _menuItems;
 	Common::Point _mousePos;
 	StartupDialogueSystem _dialogue;
+	StartupInventorySystem _inventory;
 	StartupMenuSystem _menu;
+	StartupRoomSystem _room;
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/startup_inventory.cpp b/engines/harvester/startup_inventory.cpp
new file mode 100644
index 00000000000..cddc151c0b1
--- /dev/null
+++ b/engines/harvester/startup_inventory.cpp
@@ -0,0 +1,291 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/startup_inventory.h"
+
+#include "common/endian.h"
+#include "graphics/font.h"
+#include "harvester/harvester.h"
+#include "harvester/resources.h"
+#include "harvester/startup_art.h"
+
+namespace Harvester {
+
+namespace {
+
+static const int kInventoryX = 64;
+static const int kInventoryY = 48;
+static const int kInventoryItemStartX = 73;
+static const int kInventoryItemStartY = 115;
+static const int kInventoryItemMaxRight = 564;
+static const int kInventoryItemSpacing = 5;
+static const byte kShadowColor = 0;
+static const byte kRoomPromptColor = 0xce;
+
+static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
+	if (!bitmap.isValid())
+		return;
+
+	screen.copyRectToSurface(bitmap.pixels.data(), bitmap.width, x, y, bitmap.width, bitmap.height);
+}
+
+static void drawShadowedString(Graphics::Screen &screen, const Graphics::Font &font, const Common::String &text,
+		int x, int y, int width, byte color, Graphics::TextAlign align = Graphics::kTextAlignLeft) {
+	font.drawString(&screen, text, x + 1, y + 1, width, kShadowColor, align);
+	font.drawString(&screen, text, x, y, width, color, align);
+}
+
+static Common::String buildUseItemPrompt(const Common::String &itemLabel, const Common::String &targetLabel) {
+	if (itemLabel.empty())
+		return Common::String();
+	if (targetLabel.empty())
+		return Common::String::format("Use %s on ...", itemLabel.c_str());
+
+	return Common::String::format("Use %s on %s", itemLabel.c_str(), targetLabel.c_str());
+}
+
+static Common::Rect getHotspotBounds(const StartupObjectRecord &object) {
+	if (object.boundsX2 > object.currentX && object.boundsY2 > object.currentY)
+		return Common::Rect(object.currentX, object.currentY, object.boundsX2 + 1, object.boundsY2 + 1);
+
+	return Common::Rect();
+}
+
+static Common::String resolveSceneObjectSpritePath(const StartupObjectRecord &object) {
+	const bool atInitialPlacement = object.currentX == object.initialX &&
+		object.currentY == object.initialY &&
+		object.currentOwnerOrRoom.equalsIgnoreCase(object.initialOwnerOrRoom);
+	if (!object.altSpritePath.empty() &&
+		(!atInitialPlacement || object.currentOwnerOrRoom.equalsIgnoreCase("INVENTORY")))
+		return object.altSpritePath;
+
+	return object.spritePath;
+}
+
+static bool loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 12)
+		return false;
+
+	bitmap = IndexedBitmap();
+	bitmap.width = READ_LE_UINT32(data.data());
+	bitmap.height = READ_LE_UINT32(data.data() + 4);
+	const uint32 pixelCount = bitmap.width * bitmap.height;
+	if (bitmap.width == 0 || bitmap.height == 0 || data.size() < 12 + pixelCount)
+		return false;
+
+	bitmap.pixels.resize(pixelCount);
+	memcpy(bitmap.pixels.data(), data.data() + 12, pixelCount);
+	return true;
+}
+
+} // End of anonymous namespace
+
+StartupInventorySystem::StartupInventorySystem(HarvesterEngine &engine) : _engine(engine) {
+}
+
+bool StartupInventorySystem::refresh() {
+	_items.clear();
+
+	StartupScript *startupScript = _engine.getStartupScript();
+	ResourceManager *resources = _engine.getResources();
+	if (!startupScript || !resources)
+		return false;
+
+	Common::Array<StartupObjectRecord> inventoryObjects;
+	startupScript->getVisibleInventoryObjects(inventoryObjects);
+	int nextX = kInventoryItemStartX;
+	int nextY = kInventoryItemStartY;
+	int rowHeight = 0;
+
+	for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+		StartupInventoryVisual visual;
+		visual.object = inventoryObject;
+
+		if (isExitObject(inventoryObject)) {
+			visual.bounds = getHotspotBounds(inventoryObject);
+			_items.push_back(visual);
+			continue;
+		}
+		if (isStatusObject(inventoryObject))
+			continue;
+
+		const Common::String spritePath = resolveSceneObjectSpritePath(inventoryObject);
+		if (!spritePath.empty() && loadBitmapResource(*resources, spritePath, visual.bitmap)) {
+			visual.hasBitmap = true;
+			if (nextX + (int)visual.bitmap.width > kInventoryItemMaxRight) {
+				nextX = kInventoryItemStartX;
+				nextY += rowHeight + kInventoryItemSpacing;
+				rowHeight = 0;
+			}
+
+			visual.object.currentX = nextX;
+			visual.object.currentY = nextY;
+			visual.bounds = Common::Rect(nextX, nextY, nextX + visual.bitmap.width, nextY + visual.bitmap.height);
+			nextX += visual.bitmap.width + kInventoryItemSpacing;
+			rowHeight = MAX<int>(rowHeight, visual.bitmap.height);
+		} else {
+			visual.bounds = getHotspotBounds(inventoryObject);
+		}
+
+		_items.push_back(visual);
+	}
+
+	if (_selectedItemName.empty())
+		return true;
+
+	for (const StartupInventoryVisual &item : _items) {
+		if (item.object.objectName.equalsIgnoreCase(_selectedItemName))
+			return true;
+	}
+
+	_selectedItemName.clear();
+	_promptText.clear();
+	return true;
+}
+
+bool StartupInventorySystem::open() {
+	const bool wasOpen = _open;
+	_open = true;
+	_promptText.clear();
+	return refresh() || !wasOpen;
+}
+
+bool StartupInventorySystem::close() {
+	if (!_open)
+		return false;
+
+	_open = false;
+	return true;
+}
+
+bool StartupInventorySystem::clearSelection() {
+	if (_selectedItemName.empty() && _promptText.empty())
+		return false;
+
+	_selectedItemName.clear();
+	_promptText.clear();
+	return true;
+}
+
+bool StartupInventorySystem::isOpen() const {
+	return _open;
+}
+
+bool StartupInventorySystem::hasSelection() const {
+	return !_selectedItemName.empty();
+}
+
+const Common::String &StartupInventorySystem::getSelectedItemName() const {
+	return _selectedItemName;
+}
+
+Common::String StartupInventorySystem::resolveSelectedLabel() const {
+	StartupScript *startupScript = _engine.getStartupScript();
+	if (!startupScript || _selectedItemName.empty())
+		return Common::String();
+
+	for (const StartupInventoryVisual &item : _items) {
+		if (item.object.objectName.equalsIgnoreCase(_selectedItemName))
+			return startupScript->resolveObjectLabel(item.object);
+	}
+
+	Common::Array<StartupObjectRecord> inventoryObjects;
+	startupScript->getVisibleInventoryObjects(inventoryObjects);
+	for (const StartupObjectRecord &item : inventoryObjects) {
+		if (item.objectName.equalsIgnoreCase(_selectedItemName))
+			return startupScript->resolveObjectLabel(item);
+	}
+
+	return normalizeHarvesterResourcePath(_selectedItemName);
+}
+
+Common::String StartupInventorySystem::buildSelectedPrompt(const Common::String &targetLabel) const {
+	return buildUseItemPrompt(resolveSelectedLabel(), targetLabel);
+}
+
+void StartupInventorySystem::selectItem(const Common::String &objectName) {
+	_selectedItemName = objectName;
+}
+
+void StartupInventorySystem::setPromptText(const Common::String &promptText) {
+	_promptText = promptText;
+}
+
+const Common::String &StartupInventorySystem::getPromptText() const {
+	return _promptText;
+}
+
+const StartupInventoryVisual *StartupInventorySystem::findItemAtPoint(const Common::Point &point) const {
+	for (int i = (int)_items.size() - 1; i >= 0; --i) {
+		if (_items[i].bounds.contains(point))
+			return &_items[i];
+	}
+
+	return nullptr;
+}
+
+Common::Rect StartupInventorySystem::getPanelBounds() const {
+	const StartupArt *art = _engine.getStartupArt();
+	if (!art)
+		return Common::Rect();
+
+	const IndexedBitmap &bitmap = art->getInventoryBitmap();
+	return Common::Rect(kInventoryX, kInventoryY,
+		kInventoryX + (int)bitmap.width, kInventoryY + (int)bitmap.height);
+}
+
+void StartupInventorySystem::drawOverlay(Graphics::Screen &screen, const Graphics::Font &font) const {
+	const StartupArt *art = _engine.getStartupArt();
+	StartupScript *startupScript = _engine.getStartupScript();
+	if (!art || !startupScript)
+		return;
+
+	blitBitmap(screen, art->getInventoryBitmap(), kInventoryX, kInventoryY);
+
+	for (const StartupInventoryVisual &item : _items) {
+		if (item.hasBitmap && item.bitmap.isValid())
+			blitBitmap(screen, item.bitmap, item.object.currentX, item.object.currentY);
+	}
+
+	Common::String overlayPrompt = _promptText;
+	if (overlayPrompt.empty() && !_selectedItemName.empty()) {
+		for (const StartupInventoryVisual &item : _items) {
+			if (item.object.objectName.equalsIgnoreCase(_selectedItemName)) {
+				overlayPrompt = buildUseItemPrompt(startupScript->resolveObjectLabel(item.object), Common::String());
+				break;
+			}
+		}
+	}
+
+	if (!overlayPrompt.empty())
+		drawShadowedString(screen, font, overlayPrompt, 0, 462, 640, kRoomPromptColor, Graphics::kTextAlignCenter);
+}
+
+bool StartupInventorySystem::isExitObject(const StartupObjectRecord &object) {
+	return object.objectName.equalsIgnoreCase("INV_EXIT");
+}
+
+bool StartupInventorySystem::isStatusObject(const StartupObjectRecord &object) {
+	return object.objectName.hasPrefixIgnoreCase("INV_STAT");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/startup_inventory.h b/engines/harvester/startup_inventory.h
new file mode 100644
index 00000000000..4f0aa0dec2f
--- /dev/null
+++ b/engines/harvester/startup_inventory.h
@@ -0,0 +1,80 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_STARTUP_INVENTORY_H
+#define HARVESTER_STARTUP_INVENTORY_H
+
+#include "common/array.h"
+#include "common/rect.h"
+#include "common/str.h"
+#include "harvester/startup_art.h"
+#include "harvester/startup_room_support.h"
+
+namespace Graphics {
+class Font;
+class Screen;
+}
+
+namespace Harvester {
+
+class HarvesterEngine;
+
+struct StartupInventoryVisual {
+	StartupObjectRecord object;
+	IndexedBitmap bitmap;
+	Common::Rect bounds;
+	bool hasBitmap = false;
+};
+
+class StartupInventorySystem {
+public:
+	explicit StartupInventorySystem(HarvesterEngine &engine);
+
+	bool refresh();
+	bool open();
+	bool close();
+	bool clearSelection();
+	bool isOpen() const;
+	bool hasSelection() const;
+	const Common::String &getSelectedItemName() const;
+	Common::String resolveSelectedLabel() const;
+	Common::String buildSelectedPrompt(const Common::String &targetLabel) const;
+	void selectItem(const Common::String &objectName);
+	void setPromptText(const Common::String &promptText);
+	const Common::String &getPromptText() const;
+	const StartupInventoryVisual *findItemAtPoint(const Common::Point &point) const;
+	Common::Rect getPanelBounds() const;
+	void drawOverlay(Graphics::Screen &screen, const Graphics::Font &font) const;
+
+	static bool isExitObject(const StartupObjectRecord &object);
+	static bool isStatusObject(const StartupObjectRecord &object);
+
+private:
+	Common::Array<StartupInventoryVisual> _items;
+	bool _open = false;
+	Common::String _selectedItemName;
+	Common::String _promptText;
+	HarvesterEngine &_engine;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_STARTUP_INVENTORY_H
diff --git a/engines/harvester/startup_room.cpp b/engines/harvester/startup_room.cpp
new file mode 100644
index 00000000000..72d7abcd881
--- /dev/null
+++ b/engines/harvester/startup_room.cpp
@@ -0,0 +1,707 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/startup_room.h"
+
+#include "common/events.h"
+#include "common/system.h"
+#include "graphics/font.h"
+#include "graphics/fontman.h"
+#include "graphics/framelimiter.h"
+#include "harvester/detection.h"
+#include "harvester/harvester.h"
+#include "harvester/runtime_entity.h"
+#include "harvester/startup_art.h"
+#include "harvester/startup_flow.h"
+#include "harvester/startup_inventory.h"
+#include "harvester/startup_room_support.h"
+
+namespace Harvester {
+
+StartupRoomSystem::StartupRoomSystem(HarvesterEngine &engine, Common::Point &mousePos,
+		StartupInventorySystem &inventory)
+	: _engine(engine), _mousePos(mousePos), _inventory(inventory) {
+}
+
+Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Common::String &entranceName) {
+	StartupRoomSetupState state;
+	if (!_engine.getStartupScript()->resolveRoomSetupState(entranceName, state, *_engine.getResources()))
+		return Common::kReadingFailed;
+
+	Common::Error transitionError = startupFlow.beginRoomSetupTransition();
+	if (transitionError.getCode() != Common::kNoError)
+		return transitionError;
+
+	StartupRoomSceneResources scene;
+	if (!loadRoomSceneResources(state, *_engine.getResources(), scene))
+		return Common::kReadingFailed;
+
+	Graphics::Screen *screen = _engine.getScreen();
+	const StartupArt *art = _engine.getStartupArt();
+	const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	if (!screen || !art || !bodyFont)
+		return Common::kNoError;
+
+	if (!startupFlow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
+		return Common::kReadingFailed;
+
+	logScenePaletteSummary("room setup stub palette", scene, 0.0f);
+	drawRoomScene(_engine, *screen, scene, 0.0f);
+	screen->makeAllDirty();
+	screen->update();
+
+	logScenePaletteSummary("room setup fade target", scene, scene.targetPaletteBrightness);
+	transitionError = startupFlow.fadeInRoomScene(scene.palette, scene.targetPaletteBrightness);
+	if (transitionError.getCode() != Common::kNoError)
+		return transitionError;
+
+	startupFlow.resetCursorAnimationSequence();
+	startupFlow.executeStartupAudioCommands(scene.state.audioCommands);
+	if (!scene.state.musicPath.empty())
+		(void)_engine.playStartupMusic(scene.state.musicPath);
+	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+	StartupRoomPlayerState playerState;
+	playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName("PLAYER") : nullptr;
+	playerState.centerX = state.playerSpawnX;
+	playerState.bottomY = state.playerSpawnY;
+	playerState.z = (float)state.playerSpawnZ;
+	playerState.facing = state.playerFacing;
+	playerState.turnActive = false;
+	playerState.turnTargetFacing = -1;
+	StartupResolvedText inspectText;
+	bool showingInspectText = false;
+	bool inspectCanDismiss = false;
+	bool moveLeft = false;
+	bool moveRight = false;
+	bool moveUp = false;
+	bool moveDown = false;
+	Common::String pendingRegionName;
+	StartupRoomIdleAnimationState idleState;
+	bool needsRedraw = true;
+	auto resetIdleState = [&]() {
+		idleState = StartupRoomIdleAnimationState();
+		idleState.activityTick = getRuntimeClockTicks();
+		idleState.resetTick = idleState.activityTick;
+		updatePlayerIdleTrigger(idleState);
+	};
+	auto notePlayerActivity = [&]() {
+		idleState.activityTick = getRuntimeClockTicks();
+		updatePlayerIdleTrigger(idleState);
+	};
+	resetIdleState();
+
+	auto openInventoryOverlay = [&]() {
+		moveLeft = false;
+		moveRight = false;
+		moveUp = false;
+		moveDown = false;
+		pendingRegionName.clear();
+		playerState.hasMoveTarget = false;
+		playerState.turnActive = false;
+		playerState.turnTargetFacing = -1;
+		return _inventory.open();
+	};
+	auto refreshCurrentScene = [&](bool preservePlayerPlacement) {
+		const Common::Array<StartupAudioCommand> entryAudioCommands = scene.state.audioCommands;
+		StartupRoomSetupState refreshedState;
+		if (!_engine.getStartupScript()->materializeRoomState(
+				scene.state.entranceName, scene.state.roomName, refreshedState)) {
+			return false;
+		}
+
+		refreshedState.audioCommands = entryAudioCommands;
+		if (!loadRoomSceneResources(refreshedState, *_engine.getResources(), scene))
+			return false;
+		if (!startupFlow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
+			return false;
+
+		playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName("PLAYER") : nullptr;
+		if (playerState.entity) {
+			if (!preservePlayerPlacement || playerState.facing < 0) {
+				playerState.centerX = scene.state.playerSpawnX;
+				playerState.bottomY = scene.state.playerSpawnY;
+				playerState.z = (float)scene.state.playerSpawnZ;
+				playerState.facing = scene.state.playerFacing;
+			}
+
+			const int facing = playerState.facing >= 0 ? playerState.facing : scene.state.playerFacing;
+			(void)setPlayerIdleAnimation(playerState, facing);
+			(void)applyRoomActorPlacement(scene.state, *playerState.entity,
+				playerState.centerX, playerState.bottomY, playerState.z);
+		}
+
+		playerState.hasMoveTarget = false;
+		playerState.turnActive = false;
+		playerState.turnTargetFacing = -1;
+		pendingRegionName.clear();
+		resetIdleState();
+		startupFlow.resetCursorAnimationSequence();
+		return _inventory.refresh();
+	};
+	auto captureDialogueBackdrop = [&](IndexedBitmap &dialogueBackdrop) {
+		drawRoomScene(_engine, *screen, scene, scene.targetPaletteBrightness);
+		return captureScreenBackdrop(*screen, dialogueBackdrop);
+	};
+	auto runRoomExitCommands = [&]() {
+		Common::Array<StartupAudioCommand> exitAudioCommands;
+		if (!_engine.getStartupScript()->executeRoomExitCommands(scene.state.roomName, exitAudioCommands))
+			return false;
+		startupFlow.executeStartupAudioCommands(exitAudioCommands);
+		return true;
+	};
+	auto handleInteractionResult = [&](const StartupInteractionResult &interaction) -> Common::Error {
+		playerState.hasMoveTarget = false;
+		playerState.turnActive = false;
+		playerState.turnTargetFacing = -1;
+		pendingRegionName.clear();
+
+		Common::String restoreMusicPath = _engine.getStartupMusicPath();
+		if (!interaction.musicPath.empty()) {
+			(void)_engine.playStartupMusic(interaction.musicPath);
+			restoreMusicPath = _engine.getStartupMusicPath();
+		}
+		startupFlow.executeStartupAudioCommands(interaction.audioCommands);
+
+		if (!interaction.nextRoomName.empty()) {
+			if (!runRoomExitCommands())
+				return Common::kReadingFailed;
+
+			Common::Error roomError = startupFlow.runRoomLoop(interaction.nextRoomName);
+			if (roomError.getCode() != Common::kReadingFailed &&
+				roomError.getCode() != Common::kNoError) {
+				return roomError;
+			}
+
+			if (!refreshCurrentScene(true))
+				return Common::kReadingFailed;
+
+			startupFlow.executeStartupAudioCommands(scene.state.audioCommands);
+			if (!restoreMusicPath.empty())
+				(void)_engine.playStartupMusic(restoreMusicPath);
+			else
+				_engine.stopStartupMusic();
+		} else if (interaction.mutatedRuntimeState) {
+			if (!refreshCurrentScene(true))
+				return Common::kReadingFailed;
+		}
+
+		return Common::kNoError;
+	};
+	auto handleInventoryTargetInteraction = [&](const StartupObjectRecord &target, bool preferPickup) -> Common::Error {
+		if (!_inventory.hasSelection())
+			return Common::kNoError;
+
+		StartupInteractionResult interaction;
+		bool handled = false;
+		if (preferPickup && _engine.getStartupScript()->isPickupObject(target))
+			handled = _engine.getStartupScript()->resolveObjectInteraction(target, interaction);
+		else
+			handled = _engine.getStartupScript()->resolveUseItemInteraction(
+				_inventory.getSelectedItemName(), target, interaction);
+		if (!handled)
+			return Common::kNoError;
+
+		_inventory.clearSelection();
+		Common::Error interactionError = handleInteractionResult(interaction);
+		if (interactionError.getCode() != Common::kNoError)
+			return interactionError;
+		if (!_inventory.refresh())
+			return Common::kReadingFailed;
+
+		needsRedraw = true;
+		return Common::kNoError;
+	};
+	auto queueRegionInteraction = [&](const StartupRegionRecord &region) {
+		pendingRegionName = region.regionName;
+		if (!playerState.entity)
+			return;
+
+		if (doesPlayerOverlapRegion(*playerState.entity, region))
+			return;
+
+		setPlayerMoveTarget(scene.state, playerState,
+			resolveRegionTargetX(region, playerState), resolveRegionTargetZ(region));
+	};
+	auto runRegionInteraction = [&](const StartupRegionRecord &region) -> Common::Error {
+		StartupInteractionResult interaction;
+		if (!_engine.getStartupScript()->resolveRegionInteraction(region, interaction))
+			return Common::kNoError;
+
+		Common::Error interactionError = handleInteractionResult(interaction);
+		if (interactionError.getCode() != Common::kNoError)
+			return interactionError;
+
+		if (!_inventory.refresh())
+			return Common::kReadingFailed;
+		needsRedraw = true;
+		return Common::kNoError;
+	};
+	auto tryActivatePendingRegion = [&]() -> Common::Error {
+		if (pendingRegionName.empty() || !playerState.entity)
+			return Common::kNoError;
+
+		const StartupRegionRecord *region = findSceneRegionByName(scene.sceneRegions, pendingRegionName);
+		if (!region || !region->startEnabled) {
+			pendingRegionName.clear();
+			return Common::kNoError;
+		}
+		if (!doesPlayerOverlapRegion(*playerState.entity, *region))
+			return Common::kNoError;
+		if (!doesPlayerFacingMatchRegion(playerState.facing, *region)) {
+			if (!playerState.hasMoveTarget && !playerState.turnActive && region->desiredFacing >= 0)
+				(void)startPlayerTurnAnimation(playerState, region->desiredFacing);
+			return Common::kNoError;
+		}
+
+		pendingRegionName.clear();
+		return runRegionInteraction(*region);
+	};
+	if (!_inventory.refresh())
+		return Common::kReadingFailed;
+	Graphics::FrameLimiter limiter(g_system, 60);
+
+	if (shouldRunStartupRoomProbe())
+		logStartupRoomProbe(_engine, scene, entranceName, _mousePos);
+
+	while (!_engine.shouldQuit()) {
+		if (needsRedraw) {
+			const Common::Rect inventoryPanelBounds = _inventory.getPanelBounds();
+			const bool inventoryPanelContainsMouse = _inventory.isOpen() && inventoryPanelBounds.contains(_mousePos);
+			const bool suppressHover = showingInspectText || idleState.active || idleState.exiting ||
+				(_inventory.isOpen() && (inventoryPanelContainsMouse || !_inventory.hasSelection()));
+			StartupRoomHoverState hoverState = suppressHover
+				? StartupRoomHoverState()
+				: resolveRoomHoverState(_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
+					scene.sceneRegions, _mousePos);
+			Common::String promptText;
+			if (_inventory.isOpen()) {
+				const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
+				if (_inventory.hasSelection()) {
+					Common::String targetLabel;
+					if (inventoryHover && !StartupInventorySystem::isExitObject(inventoryHover->object) &&
+						!StartupInventorySystem::isStatusObject(inventoryHover->object)) {
+						targetLabel = _engine.getStartupScript()->resolveObjectLabel(inventoryHover->object);
+					} else if (!inventoryPanelContainsMouse && hoverState.object) {
+						targetLabel = _engine.getStartupScript()->resolveObjectLabel(*hoverState.object);
+					}
+					promptText = _inventory.buildSelectedPrompt(targetLabel);
+				} else if (inventoryHover) {
+					promptText = _engine.getStartupScript()->resolveObjectLabel(inventoryHover->object);
+				}
+				_inventory.setPromptText(promptText);
+				hoverState.cursorSequence = 7;
+			} else {
+				promptText = hoverState.promptText;
+			}
+			if (RuntimeEntity *cursor = runtimeEntities ? runtimeEntities->getCursorEntity() : nullptr) {
+				cursor->setAnimationSequence(
+					(showingInspectText || idleState.active || idleState.exiting || _inventory.isOpen())
+						? 7
+						: hoverState.cursorSequence);
+			}
+
+			drawRoomScene(_engine, *screen, scene, scene.targetPaletteBrightness);
+			if (_inventory.isOpen())
+				_inventory.drawOverlay(*screen, *bodyFont);
+
+			if (showingInspectText) {
+				drawRoomInspectText(*screen, *art, *bodyFont, inspectText);
+			} else if (!promptText.empty()) {
+				_engine.getScreen();
+				// Shared room prompt rendering remains in the orchestration layer.
+				{
+					const byte shadowColor = 0;
+					bodyFont->drawString(screen, promptText, 1, 463, 640, shadowColor, Graphics::kTextAlignCenter);
+					bodyFont->drawString(screen, promptText, 0, 462, 640, 0xce, Graphics::kTextAlignCenter);
+				}
+			}
+
+			if (runtimeEntities)
+				runtimeEntities->drawCursor(*screen);
+			screen->makeAllDirty();
+			screen->update();
+			needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (startupFlow.handleSystemEvent(event, result))
+				return result;
+
+			switch (event.type) {
+			case Common::EVENT_MOUSEMOVE:
+				needsRedraw = true;
+				break;
+			case Common::EVENT_RBUTTONDOWN:
+				notePlayerActivity();
+				if (idleState.active || idleState.exiting) {
+					if (requestPlayerIdleAnimationExit(scene.state, playerState, idleState))
+						needsRedraw = true;
+					break;
+				}
+				pendingRegionName.clear();
+				if (_inventory.isOpen()) {
+					if (_inventory.clearSelection() || _inventory.close())
+						needsRedraw = true;
+					break;
+				}
+				break;
+			case Common::EVENT_LBUTTONUP:
+				if (showingInspectText)
+					inspectCanDismiss = true;
+				break;
+			case Common::EVENT_LBUTTONDOWN: {
+				notePlayerActivity();
+				if (idleState.active || idleState.exiting) {
+					if (requestPlayerIdleAnimationExit(scene.state, playerState, idleState))
+						needsRedraw = true;
+					break;
+				}
+
+				if (showingInspectText) {
+					if (inspectCanDismiss) {
+						showingInspectText = false;
+						inspectCanDismiss = false;
+						inspectText = StartupResolvedText();
+						needsRedraw = true;
+					}
+					break;
+				}
+
+				const Common::Rect inventoryPanelBounds = _inventory.getPanelBounds();
+				if (_inventory.isOpen()) {
+					const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
+					if (inventoryHover) {
+						if (StartupInventorySystem::isExitObject(inventoryHover->object)) {
+							if (_inventory.close())
+								needsRedraw = true;
+							break;
+						}
+
+						if (!_inventory.hasSelection()) {
+							_inventory.selectItem(inventoryHover->object.objectName);
+							_inventory.setPromptText(_inventory.buildSelectedPrompt(Common::String()));
+							needsRedraw = true;
+							break;
+						}
+
+						if (!inventoryHover->object.objectName.equalsIgnoreCase(_inventory.getSelectedItemName())) {
+							Common::Error interactionError =
+								handleInventoryTargetInteraction(inventoryHover->object, false);
+							if (interactionError.getCode() != Common::kNoError)
+								return interactionError;
+						}
+						needsRedraw = true;
+						break;
+					}
+
+					if (_inventory.hasSelection()) {
+						const StartupRoomHoverState useHoverState = resolveRoomHoverState(
+							_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
+							scene.sceneRegions, _mousePos);
+						if (useHoverState.npc) {
+							IndexedBitmap dialogueBackdrop;
+							if (!captureDialogueBackdrop(dialogueBackdrop))
+								return Common::kReadingFailed;
+
+							Common::Error dialogueError = startupFlow.runRoomNpcDialogue(
+								dialogueBackdrop, scene.palette, scene.targetPaletteBrightness,
+								*useHoverState.npc, _inventory.getSelectedItemName());
+							if (dialogueError.getCode() != Common::kNoError)
+								return dialogueError;
+							if (_inventory.clearSelection() || _inventory.close())
+								needsRedraw = true;
+							startupFlow.resetCursorAnimationSequence();
+							resetIdleState();
+							needsRedraw = true;
+							break;
+						}
+						const StartupObjectRecord *roomTarget = useHoverState.object
+							? findSceneObjectByName(scene.sceneObjects, useHoverState.object->objectName)
+							: nullptr;
+						if (roomTarget) {
+							Common::Error interactionError =
+								handleInventoryTargetInteraction(*roomTarget, true);
+							if (interactionError.getCode() != Common::kNoError)
+								return interactionError;
+							needsRedraw = true;
+							break;
+						}
+					}
+
+					if (!inventoryPanelBounds.contains(_mousePos) && _inventory.hasSelection()) {
+						needsRedraw = true;
+						break;
+					}
+					if (!inventoryPanelBounds.contains(_mousePos) && _inventory.close())
+						needsRedraw = true;
+					break;
+				}
+
+				const StartupRoomHoverState hoverState = resolveRoomHoverState(
+					_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions, _mousePos);
+				debugC(1, kDebugScene,
+					"Harvester: room click room='%s' mouse=(%d,%d) object='%s' npc='%s' region='%s' cursor_sequence=%d prompt='%s'",
+					scene.state.roomName.c_str(), _mousePos.x, _mousePos.y,
+					hoverState.object ? hoverState.object->objectName.c_str() : "",
+					hoverState.npc ? hoverState.npc->npcName.c_str() : "",
+					hoverState.region ? hoverState.region->regionName.c_str() : "",
+					hoverState.cursorSequence, hoverState.promptText.c_str());
+				if (hoverState.playerEntity) {
+					if (!playerState.entity || hoverState.playerEntity != playerState.entity ||
+						idleState.active || idleState.exiting ||
+						playerState.hasMoveTarget || playerState.turnActive ||
+						playerState.entity->getAnimationRate() != 0) {
+						break;
+					}
+					if (!openInventoryOverlay())
+						return Common::kReadingFailed;
+					needsRedraw = true;
+					break;
+				}
+				if (hoverState.npc) {
+					pendingRegionName.clear();
+					moveLeft = false;
+					moveRight = false;
+					moveUp = false;
+					moveDown = false;
+					playerState.hasMoveTarget = false;
+					playerState.turnActive = false;
+					playerState.turnTargetFacing = -1;
+					IndexedBitmap dialogueBackdrop;
+					if (!captureDialogueBackdrop(dialogueBackdrop))
+						return Common::kReadingFailed;
+					Common::Error dialogueError = startupFlow.runRoomNpcDialogue(
+						dialogueBackdrop, scene.palette, scene.targetPaletteBrightness,
+						*hoverState.npc, Common::String());
+					if (dialogueError.getCode() != Common::kNoError)
+						return dialogueError;
+					startupFlow.resetCursorAnimationSequence();
+					resetIdleState();
+					needsRedraw = true;
+					break;
+				}
+				if (hoverState.region && playerState.entity) {
+					queueRegionInteraction(*hoverState.region);
+					Common::Error interactionError = tryActivatePendingRegion();
+					if (interactionError.getCode() != Common::kNoError)
+						return interactionError;
+					needsRedraw = true;
+					break;
+				}
+				StartupObjectRecord *clickedObject = hoverState.object
+					? findSceneObjectByName(scene.sceneObjects, hoverState.object->objectName)
+					: nullptr;
+				if (!clickedObject) {
+					pendingRegionName.clear();
+					if (hoverState.cursorSequence == 0 && playerState.entity) {
+						setPlayerMoveTargetFromScreenPoint(scene.state, playerState, _mousePos.x, _mousePos.y);
+						needsRedraw = true;
+					}
+					break;
+				}
+				pendingRegionName.clear();
+				if (clickedObject->objectName.equalsIgnoreCase("EXIT_BM") ||
+					clickedObject->objectName.equalsIgnoreCase("EXIT_HS")) {
+					if (!runRoomExitCommands())
+						return Common::kReadingFailed;
+					return Common::kNoError;
+				}
+
+				StartupResolvedText resolvedInspectText;
+				const bool hasInspectText =
+					_engine.getStartupScript()->resolveObjectInspectText(*clickedObject, resolvedInspectText);
+				const bool unlocksAfterInitialExamine =
+					unlocksRoomObjectInteractionAfterInitialExamine(*clickedObject, *_engine.getStartupScript());
+				const bool canShowInspectText =
+					hasInspectText && resolveInspectTextboxBitmap(*art, resolvedInspectText);
+				if (!clickedObject->identShown && unlocksAfterInitialExamine) {
+					clickedObject->identShown = true;
+					_engine.getStartupScript()->markObjectIdentShown(*clickedObject);
+					if (canShowInspectText) {
+						inspectText = resolvedInspectText;
+						showingInspectText = true;
+						inspectCanDismiss = false;
+					} else if (hasInspectText) {
+						debug(1, "Harvester: unsupported IDENT textbox '%s' for object '%s'",
+							resolvedInspectText.boxName.c_str(), clickedObject->objectName.c_str());
+					}
+					playerState.hasMoveTarget = false;
+					playerState.turnActive = false;
+					playerState.turnTargetFacing = -1;
+					needsRedraw = true;
+					break;
+				}
+
+				StartupInteractionResult interaction;
+				if (!_engine.getStartupScript()->resolveObjectInteraction(*clickedObject, interaction)) {
+					if (canShowInspectText) {
+						inspectText = resolvedInspectText;
+						showingInspectText = true;
+						inspectCanDismiss = false;
+					} else if (hasInspectText) {
+						debug(1, "Harvester: unsupported IDENT textbox '%s' for object '%s'",
+							resolvedInspectText.boxName.c_str(), clickedObject->objectName.c_str());
+					}
+					needsRedraw = true;
+					break;
+				}
+
+				Common::Error interactionError = handleInteractionResult(interaction);
+				if (interactionError.getCode() != Common::kNoError)
+					return interactionError;
+				needsRedraw = true;
+				break;
+			}
+			case Common::EVENT_KEYDOWN:
+				if (showingInspectText) {
+					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+						showingInspectText = false;
+						inspectCanDismiss = false;
+						inspectText = StartupResolvedText();
+						needsRedraw = true;
+					}
+					break;
+				}
+
+				notePlayerActivity();
+				if (idleState.active || idleState.exiting) {
+					if (requestPlayerIdleAnimationExit(scene.state, playerState, idleState))
+						needsRedraw = true;
+					break;
+				}
+
+				if (_inventory.isOpen()) {
+					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+						if (_inventory.clearSelection() || _inventory.close())
+							needsRedraw = true;
+					} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+							event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
+							event.kbd.keycode == Common::KEYCODE_i) {
+						if (_inventory.close())
+							needsRedraw = true;
+					}
+					break;
+				}
+
+				if (event.kbd.keycode == Common::KEYCODE_LEFT)
+					moveLeft = true;
+				else if (event.kbd.keycode == Common::KEYCODE_RIGHT)
+					moveRight = true;
+				else if (event.kbd.keycode == Common::KEYCODE_UP)
+					moveUp = true;
+				else if (event.kbd.keycode == Common::KEYCODE_DOWN)
+					moveDown = true;
+				else if (event.kbd.keycode == Common::KEYCODE_i) {
+					if (!openInventoryOverlay())
+						return Common::kReadingFailed;
+					needsRedraw = true;
+					break;
+				} else if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					moveLeft = false;
+					moveRight = false;
+					moveUp = false;
+					moveDown = false;
+					pendingRegionName.clear();
+					playerState.hasMoveTarget = false;
+					playerState.turnActive = false;
+					playerState.turnTargetFacing = -1;
+					IndexedBitmap roomMenuBackdrop;
+					drawRoomScene(_engine, *screen, scene, scene.targetPaletteBrightness);
+					if (!captureScreenBackdrop(*screen, roomMenuBackdrop))
+						return Common::kReadingFailed;
+					Common::Error menuError = startupFlow.runRoomMenuStub(roomMenuBackdrop);
+					if (menuError.getCode() != Common::kNoError)
+						return menuError;
+					startupFlow.resetCursorAnimationSequence();
+					resetIdleState();
+					needsRedraw = true;
+					break;
+				}
+
+				if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+					event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
+					if (!runRoomExitCommands())
+						return Common::kReadingFailed;
+					return Common::kNoError;
+				}
+				break;
+			case Common::EVENT_KEYUP:
+				if (event.kbd.keycode == Common::KEYCODE_LEFT)
+					moveLeft = false;
+				else if (event.kbd.keycode == Common::KEYCODE_RIGHT)
+					moveRight = false;
+				else if (event.kbd.keycode == Common::KEYCODE_UP)
+					moveUp = false;
+				else if (event.kbd.keycode == Common::KEYCODE_DOWN)
+					moveDown = false;
+				break;
+			default:
+				break;
+			}
+		}
+
+		if (updatePlayerTurnAnimationState(playerState))
+			needsRedraw = true;
+
+		if (!idleState.active && !idleState.exiting) {
+			if (stepPlayerKeyboardMovement(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
+					playerState, moveLeft, moveRight, moveUp, moveDown)) {
+				notePlayerActivity();
+				needsRedraw = true;
+			} else if (stepPlayerMoveTarget(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
+					playerState)) {
+				notePlayerActivity();
+				needsRedraw = true;
+			} else if (!moveLeft && !moveRight && !moveUp && !moveDown && !playerState.hasMoveTarget &&
+					!playerState.turnActive &&
+					playerState.entity && playerState.facing >= 0 &&
+					setPlayerIdleAnimation(playerState, playerState.facing)) {
+				needsRedraw = true;
+			}
+
+			if (!showingInspectText && !moveLeft && !moveRight && !moveUp && !moveDown &&
+					!playerState.hasMoveTarget && !playerState.turnActive &&
+					playerState.entity && playerState.facing >= 0 &&
+					!isIdleAnimationExcludedRoom(scene.state.roomName) &&
+					getRuntimeClockTicks() > idleState.triggerTick &&
+					startPlayerIdleAnimation(_engine, scene.state, playerState, idleState)) {
+				needsRedraw = true;
+			}
+		}
+		Common::Error pendingRegionError = tryActivatePendingRegion();
+		if (pendingRegionError.getCode() != Common::kNoError)
+			return pendingRegionError;
+
+		if (startupFlow.tickRuntimeEntities())
+			needsRedraw = true;
+		if (updatePlayerIdleAnimation(scene.state, playerState, idleState))
+			needsRedraw = true;
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/startup_room.h b/engines/harvester/startup_room.h
new file mode 100644
index 00000000000..4005965a44e
--- /dev/null
+++ b/engines/harvester/startup_room.h
@@ -0,0 +1,49 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_STARTUP_ROOM_H
+#define HARVESTER_STARTUP_ROOM_H
+
+#include "common/error.h"
+#include "common/rect.h"
+#include "common/str.h"
+
+namespace Harvester {
+
+class HarvesterEngine;
+class StartupFlow;
+class StartupInventorySystem;
+
+class StartupRoomSystem {
+public:
+	StartupRoomSystem(HarvesterEngine &engine, Common::Point &mousePos, StartupInventorySystem &inventory);
+
+	Common::Error runRoomLoop(StartupFlow &startupFlow, const Common::String &entranceName);
+
+private:
+	HarvesterEngine &_engine;
+	Common::Point &_mousePos;
+	StartupInventorySystem &_inventory;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_STARTUP_ROOM_H
diff --git a/engines/harvester/startup_room_support.h b/engines/harvester/startup_room_support.h
new file mode 100644
index 00000000000..d6e53783dd6
--- /dev/null
+++ b/engines/harvester/startup_room_support.h
@@ -0,0 +1,147 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_STARTUP_ROOM_SUPPORT_H
+#define HARVESTER_STARTUP_ROOM_SUPPORT_H
+
+#include "common/array.h"
+#include "common/rect.h"
+#include "common/scummsys.h"
+#include "common/str.h"
+#include "harvester/startup_script.h"
+
+namespace Graphics {
+class Font;
+class Screen;
+}
+
+namespace Harvester {
+
+class HarvesterEngine;
+class ResourceManager;
+class RuntimeEntity;
+class StartupArt;
+struct IndexedBitmap;
+
+struct StartupRoomSceneResources {
+	StartupRoomSetupState state;
+	byte palette[256 * 3] = { 0 };
+	Common::Array<StartupObjectRecord> sceneObjects;
+	Common::Array<StartupAnimRecord> sceneAnimations;
+	Common::Array<StartupRegionRecord> sceneRegions;
+	float targetPaletteBrightness = 1.0f;
+};
+
+struct StartupRoomPlayerState {
+	RuntimeEntity *entity = nullptr;
+	int centerX = 0;
+	int bottomY = 0;
+	float z = 0.0f;
+	int facing = -1;
+	bool hasMoveTarget = false;
+	int targetX = 0;
+	float targetZ = 0.0f;
+	bool turnActive = false;
+	int turnTargetFacing = -1;
+	int turnFirstFrame = -1;
+	int turnLastFrame = -1;
+	int turnEndFrame = -1;
+	bool turnPlayBackwards = false;
+};
+
+struct StartupRoomHoverState {
+	const StartupObjectRecord *object = nullptr;
+	const StartupNpcRecord *npc = nullptr;
+	const StartupRegionRecord *region = nullptr;
+	const RuntimeEntity *playerEntity = nullptr;
+	Common::String promptText;
+	int cursorSequence = 7;
+};
+
+struct StartupRoomIdleAnimationState {
+	RuntimeEntity *entity = nullptr;
+	int restoreFacing = -1;
+	uint32 activityTick = 0;
+	uint32 resetTick = 0;
+	uint32 triggerTick = 0;
+	bool active = false;
+	bool loopStarted = false;
+	bool exiting = false;
+};
+
+bool applyRoomActorPlacement(const StartupRoomSetupState &state, RuntimeEntity &entity,
+	int centerX, int bottomY, float z, const Common::String *entranceName = nullptr);
+void setPlayerMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
+	int targetX, float targetZ);
+void setPlayerMoveTargetFromScreenPoint(const StartupRoomSetupState &state,
+	StartupRoomPlayerState &playerState, int targetX, int targetBottomY);
+int resolveRegionTargetX(const StartupRegionRecord &region, const StartupRoomPlayerState &playerState);
+float resolveRegionTargetZ(const StartupRegionRecord &region);
+uint32 getRuntimeClockTicks();
+bool isIdleAnimationExcludedRoom(const Common::String &roomName);
+void updatePlayerIdleTrigger(StartupRoomIdleAnimationState &idleState);
+void logScenePaletteSummary(const char *label, const StartupRoomSceneResources &scene, float brightness);
+bool captureScreenBackdrop(const Graphics::Screen &screen, IndexedBitmap &bitmap);
+bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceManager &resources, StartupRoomSceneResources &scene);
+bool shouldRunStartupRoomProbe();
+void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const StartupRoomSceneResources &scene,
+	float brightness);
+const StartupObjectRecord *findSceneObjectByName(const Common::Array<StartupObjectRecord> &objects,
+	const Common::String &objectName);
+const StartupRegionRecord *findSceneRegionByName(const Common::Array<StartupRegionRecord> &regions,
+	const Common::String &regionName);
+StartupObjectRecord *findSceneObjectByName(Common::Array<StartupObjectRecord> &objects,
+	const Common::String &objectName);
+const IndexedBitmap *resolveInspectTextboxBitmap(const StartupArt &art, const StartupResolvedText &text);
+void drawRoomInspectText(Graphics::Screen &screen, const StartupArt &art, const Graphics::Font &font,
+	const StartupResolvedText &inspectText);
+bool unlocksRoomObjectInteractionAfterInitialExamine(const StartupObjectRecord &object,
+	StartupScript &startupScript);
+bool doesPlayerFacingMatchRegion(int playerFacing, const StartupRegionRecord &region);
+bool doesPlayerOverlapRegion(const RuntimeEntity &playerEntity, const StartupRegionRecord &region);
+StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const StartupRoomSetupState &state,
+	const Common::Array<StartupObjectRecord> &sceneObjects,
+	const Common::Array<StartupNpcRecord> &npcs,
+	const Common::Array<StartupRegionRecord> &regions,
+	const Common::Point &mousePos);
+bool setPlayerIdleAnimation(StartupRoomPlayerState &playerState, int facing);
+bool startPlayerTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing);
+bool updatePlayerTurnAnimationState(StartupRoomPlayerState &playerState);
+bool stepPlayerMoveTarget(HarvesterEngine &engine, const StartupRoomSetupState &state,
+	const Common::Array<StartupObjectRecord> &sceneObjects,
+	const Common::Array<StartupAnimRecord> &sceneAnimations,
+	StartupRoomPlayerState &playerState);
+bool stepPlayerKeyboardMovement(HarvesterEngine &engine, const StartupRoomSetupState &state,
+	const Common::Array<StartupObjectRecord> &sceneObjects,
+	const Common::Array<StartupAnimRecord> &sceneAnimations,
+	StartupRoomPlayerState &playerState, bool moveLeft, bool moveRight, bool moveUp, bool moveDown);
+bool requestPlayerIdleAnimationExit(const StartupRoomSetupState &state,
+	StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState);
+bool startPlayerIdleAnimation(HarvesterEngine &engine, const StartupRoomSetupState &state,
+	StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState);
+bool updatePlayerIdleAnimation(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
+	StartupRoomIdleAnimationState &idleState);
+void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResources &scene,
+	const Common::String &entranceName, Common::Point &mousePos);
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_STARTUP_ROOM_SUPPORT_H


Commit: fde126c3a3adb6222db9c54b8853a9600823d617
    https://github.com/scummvm/scummvm/commit/fde126c3a3adb6222db9c54b8853a9600823d617
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:49+02:00

Commit Message:
HARVESTER: Honor room animation active state

Changed paths:
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h


diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index e95ac72ba7f..0eca5c7a3dd 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -253,6 +253,10 @@ void RuntimeEntity::setAnimationRate(int rate) {
 	}
 }
 
+void RuntimeEntity::setAnimationEnabled(bool enabled) {
+	_animationEnabled = enabled && !_frames.empty() && _currentFrame >= 0;
+}
+
 void RuntimeEntity::setCurrentFrame(int frame) {
 	if (_frames.empty())
 		return;
@@ -714,6 +718,7 @@ RuntimeEntity *RuntimeEntityManager::spawnSceneAnimationEntity(const Common::Str
 		entity->setCurrentFrame(entity->getLastFrame());
 	else
 		entity->setCurrentFrame(0);
+	entity->setAnimationEnabled(active);
 
 	if (!active && !visible)
 		entity->setVisible(false);
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index 9f8d3d72f5a..8768cc05e78 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -86,6 +86,7 @@ public:
 
 	void setAnimationRate(int rate);
 	int getAnimationRate() const { return _animationRate; }
+	void setAnimationEnabled(bool enabled);
 	void setCurrentFrame(int frame);
 	int getCurrentFrame() const { return _currentFrame; }
 	int getLastFrame() const { return _lastFrame; }


Commit: d790c5f6d9c709bf01df4776e193d9a9866242ed
    https://github.com/scummvm/scummvm/commit/d790c5f6d9c709bf01df4776e193d9a9866242ed
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:49+02:00

Commit Message:
HARVESTER: Streamline animation ticks

Simplify animation tick handling and player movement logic.

Changed paths:
    engines/harvester/runtime_entity.cpp
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_room_support.h


diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index 0eca5c7a3dd..caec66eb121 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -351,14 +351,9 @@ bool RuntimeEntity::tickVisualState(uint32 now) {
 	if (now < _nextAnimationTick)
 		return false;
 
-	bool advanced = false;
-	do {
-		advanceAnimationFrame(_playBackwards ? -1 : -2);
-		_nextAnimationTick = now + _animationTickInterval;
-		advanced = true;
-	} while (_animationTickInterval != 0 && now >= _nextAnimationTick);
-
-	return advanced;
+	advanceAnimationFrame(_playBackwards ? -1 : -2);
+	_nextAnimationTick = now + _animationTickInterval;
+	return true;
 }
 
 Common::Point RuntimeEntity::getDrawOrigin() const {
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index e8256356cbb..3de90af74cb 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -307,6 +307,29 @@ static float computeRoomPlayerDepthStep(const StartupRoomSetupState &state) {
 	return state.roomZVelocityStep > 0.0f ? state.roomZVelocityStep : 1.0f;
 }
 
+uint32 getRuntimeClockTicks();
+
+static uint32 computeAnimationTickInterval(int rate) {
+	return rate > 0 ? MAX<uint32>(1, 100U / (uint32)rate) : 0;
+}
+
+static bool consumePlayerMovementTick(StartupRoomPlayerState &playerState) {
+	const uint32 interval = computeAnimationTickInterval(kRoomPlayerWalkAnimationRate);
+	if (interval == 0) {
+		playerState.nextMovementTick = 0;
+		return true;
+	}
+
+	const uint32 now = getRuntimeClockTicks();
+	if (playerState.nextMovementTick != 0 &&
+			(int32)(now - playerState.nextMovementTick) < 0) {
+		return false;
+	}
+
+	playerState.nextMovementTick = now + interval;
+	return true;
+}
+
 static int stepTowardsInt(int current, int target, int step) {
 	if (step <= 0 || current == target)
 		return current;
@@ -395,6 +418,7 @@ static bool tryApplyPlayerMovement(HarvesterEngine &engine, const StartupRoomSet
 void setPlayerMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
 		int targetX, float targetZ) {
 	playerState.hasMoveTarget = true;
+	playerState.nextMovementTick = 0;
 	playerState.targetX = CLIP<int>(targetX, 0, 639);
 	playerState.targetZ = clampRoomDepth(state, targetZ);
 	debugC(1, kDebugScene,
@@ -994,6 +1018,7 @@ bool setPlayerIdleAnimation(StartupRoomPlayerState &playerState, int facing) {
 		playerState.entity->getCurrentFrame() != range.idleFrame ||
 		playerState.entity->getAnimationRate() != 0;
 	playerState.facing = facing;
+	playerState.nextMovementTick = 0;
 	playerState.entity->setAnimationRate(0);
 	playerState.entity->setAnimationFrameRange(range.idleFrame, range.idleFrame, false);
 	playerState.entity->setCurrentFrame(range.idleFrame);
@@ -1040,6 +1065,7 @@ bool startPlayerTurnAnimation(StartupRoomPlayerState &playerState, int targetFac
 	playerState.turnLastFrame = range.lastFrame;
 	playerState.turnEndFrame = range.playBackwards ? range.firstFrame : range.lastFrame;
 	playerState.turnPlayBackwards = range.playBackwards;
+	playerState.nextMovementTick = 0;
 	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
 	playerState.entity->setPlayBackwards(range.playBackwards);
 	playerState.entity->setAnimationRate(kRoomPlayerWalkAnimationRate);
@@ -1089,6 +1115,8 @@ bool stepPlayerMoveTarget(HarvesterEngine &engine, const StartupRoomSetupState &
 	const int targetBottomY = mapRoomDepthToScreenY(state, playerState.targetZ);
 	const int moveFacing = resolveFacingFromMovementDelta(
 		playerState.targetX - playerState.centerX, targetBottomY - playerState.bottomY);
+	if (!consumePlayerMovementTick(playerState))
+		return false;
 	const int horizontalStep = computeRoomPlayerHorizontalStep(state, playerState.z);
 	const float depthStep = computeRoomPlayerDepthStep(state);
 	const int candidateCenterX = stepTowardsInt(playerState.centerX, playerState.targetX, horizontalStep);
@@ -1174,6 +1202,9 @@ bool stepPlayerKeyboardMovement(HarvesterEngine &engine, const StartupRoomSetupS
 	if (desiredFacing != playerState.facing && startPlayerTurnAnimation(playerState, desiredFacing))
 		return true;
 
+	if (!consumePlayerMovementTick(playerState))
+		return false;
+
 	playerState.hasMoveTarget = false;
 	bool moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
 		playerState, candidateCenterX, candidateZ);
@@ -1204,10 +1235,10 @@ static void positionPlayerIdleAnimationEntity(const StartupRoomSetupState &state
 	if (!playerState.entity)
 		return;
 
-	const Common::Rect playerRect = playerState.entity->getScreenRect();
 	idleEntity.setAnchorMode(kRuntimeEntityAnchorTopLeft);
 	idleEntity.setDepthScale(computeActorDepthScale(state, playerState.z));
-	idleEntity.setPosition(playerRect.left, playerRect.top + kRoomPlayerIdleYOffset, playerState.z);
+	idleEntity.setPosition(playerState.entity->getX(),
+		playerState.entity->getY() + kRoomPlayerIdleYOffset, playerState.z);
 }
 
 static RuntimeEntity *ensurePlayerIdleAnimationEntity(HarvesterEngine &engine,
@@ -1222,8 +1253,8 @@ static RuntimeEntity *ensurePlayerIdleAnimationEntity(HarvesterEngine &engine,
 
 	idleState.entity = runtimeEntities->spawnSceneAnimationEntity(
 		kPlayerIdleAnimationEntityName, kPlayerIdleAnimationResourcePath,
-		Common::Point(playerState.entity->getScreenRect().left,
-			playerState.entity->getScreenRect().top + kRoomPlayerIdleYOffset),
+		Common::Point(playerState.entity->getX(),
+			playerState.entity->getY() + kRoomPlayerIdleYOffset),
 		playerState.z, 0, false, false, false, false, false);
 	if (!idleState.entity)
 		return nullptr;
diff --git a/engines/harvester/startup_room_support.h b/engines/harvester/startup_room_support.h
index d6e53783dd6..d69206f5664 100644
--- a/engines/harvester/startup_room_support.h
+++ b/engines/harvester/startup_room_support.h
@@ -56,6 +56,7 @@ struct StartupRoomPlayerState {
 	int bottomY = 0;
 	float z = 0.0f;
 	int facing = -1;
+	uint32 nextMovementTick = 0;
 	bool hasMoveTarget = false;
 	int targetX = 0;
 	float targetZ = 0.0f;


Commit: a6a3a3b7d14abbda3c5a336d367ea4806b671f76
    https://github.com/scummvm/scummvm/commit/a6a3a3b7d14abbda3c5a336d367ea4806b671f76
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:49+02:00

Commit Message:
HARVESTER: Improve dialogue font handling

Resolve response textbox indices while refining dialogue font use.

Changed paths:
    engines/harvester/startup_dialogue.cpp


diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
index 7ee3092a407..30c3defc727 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/startup_dialogue.cpp
@@ -232,6 +232,18 @@ static int resolveDialogueTextboxIndex(uint lineCount) {
 	return 7;
 }
 
+static int resolveDialogueResponseTextboxIndex(uint responseRowCount) {
+	if (responseRowCount == 0)
+		return 0;
+	if (responseRowCount < 3)
+		return 3;
+	if (responseRowCount < 4)
+		return 4;
+	if (responseRowCount < 6)
+		return 5;
+	return 6;
+}
+
 static void drawDialogueTextLines(Graphics::Screen &screen, const Graphics::Font &font,
 		const Common::Array<Common::String> &lines, int x, int y, int width) {
 	const int lineHeight = getDialogueTextLineHeight(font);
@@ -327,25 +339,27 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 	if (!screen || !startupScript || !startupText || !startupArt || !fallbackFont || !backdrop.isValid())
 		return Common::kReadingFailed;
 
-	const CftFontResource *normalFontResource = findStartupFontByName(_engine, "TEXTFONT");
-	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "TEXTFNT2");
-	std::unique_ptr<HarvesterCftFont> normalCftFont;
-	std::unique_ptr<HarvesterCftFont> selectedCftFont;
-	if (normalFontResource) {
-		normalCftFont.reset(new HarvesterCftFont(*normalFontResource));
-		if (!normalCftFont->isValid())
-			normalCftFont.reset();
+	const CftFontResource *subtitleFontResource = findStartupFontByName(_engine, "TEXTFONT");
+	const CftFontResource *menuFontResource = findStartupFontByName(_engine, "TEXTFNT2");
+	std::unique_ptr<HarvesterCftFont> subtitleCftFont;
+	std::unique_ptr<HarvesterCftFont> menuCftFont;
+	if (subtitleFontResource) {
+		subtitleCftFont.reset(new HarvesterCftFont(*subtitleFontResource));
+		if (!subtitleCftFont->isValid())
+			subtitleCftFont.reset();
 	}
-	if (selectedFontResource) {
-		selectedCftFont.reset(new HarvesterCftFont(*selectedFontResource));
-		if (!selectedCftFont->isValid())
-			selectedCftFont.reset();
+	if (menuFontResource) {
+		menuCftFont.reset(new HarvesterCftFont(*menuFontResource));
+		if (!menuCftFont->isValid())
+			menuCftFont.reset();
 	}
 
-	const Graphics::Font *normalFont = normalCftFont ? static_cast<const Graphics::Font *>(normalCftFont.get()) : fallbackFont;
-	const Graphics::Font *selectedFont = selectedCftFont ? static_cast<const Graphics::Font *>(selectedCftFont.get()) : normalFont;
-	const bool normalFontUsesCft = normalCftFont.get() != nullptr;
-	const bool selectedFontUsesCft = selectedCftFont.get() != nullptr;
+	const Graphics::Font *subtitleFont = subtitleCftFont ? static_cast<const Graphics::Font *>(subtitleCftFont.get()) : fallbackFont;
+	const Graphics::Font *menuFont = menuCftFont ? static_cast<const Graphics::Font *>(menuCftFont.get()) : subtitleFont;
+	const Graphics::Font *highlightFont = subtitleFont;
+	const bool subtitleFontUsesCft = subtitleCftFont.get() != nullptr;
+	const bool menuFontUsesCft = menuCftFont.get() != nullptr;
+	const bool highlightFontUsesCft = subtitleFontUsesCft;
 
 	IndexedBitmap keywordBitmap;
 	if (!loadBitmapResource(*_engine.getResources(), kDialogueKeywordBitmapPath, keywordBitmap))
@@ -438,13 +452,13 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			blitTransparentBitmap(*screen, *overlayBitmap, kDialogueOverlayX, kDialogueOverlayY);
 
 		if (subtitleLines) {
-			if (normalFontUsesCft)
-				drawDialogueTextLines(*screen, *normalFont, *subtitleLines,
+			if (subtitleFontUsesCft)
+				drawDialogueTextLines(*screen, *subtitleFont, *subtitleLines,
 					kDialogueSubtitleTextX, kDialogueSubtitleTextY, kDialogueSubtitleTextWidth);
 			else {
-				const int lineHeight = getDialogueTextLineHeight(*normalFont);
+				const int lineHeight = getDialogueTextLineHeight(*subtitleFont);
 				for (uint i = 0; i < subtitleLines->size(); ++i) {
-					drawShadowedString(*screen, *normalFont, (*subtitleLines)[i],
+					drawShadowedString(*screen, *subtitleFont, (*subtitleLines)[i],
 						kDialogueSubtitleTextX,
 						kDialogueSubtitleTextY + (int)i * lineHeight,
 						kDialogueSubtitleTextWidth, kTextColorNormal);
@@ -453,26 +467,26 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		}
 
 		if (topics) {
-			const int lineHeight = getDialogueTextLineHeight(*normalFont);
+			const int lineHeight = getDialogueTextLineHeight(*menuFont);
 			for (uint i = 0; i < topics->size(); ++i) {
 				const bool highlighted = (int)i == hoveredTopicIndex;
-				const Graphics::Font &font = highlighted ? *selectedFont : *normalFont;
-				const bool usesCft = highlighted ? selectedFontUsesCft : normalFontUsesCft;
+				const Graphics::Font &font = highlighted ? *highlightFont : *menuFont;
+				const bool usesCft = highlighted ? highlightFontUsesCft : menuFontUsesCft;
 				drawFontString(font, usesCft, (*topics)[i], kDialogueKeywordTextX,
 					kDialogueKeywordTextY + (int)i * lineHeight, kDialogueKeywordTextWidth,
-					highlighted ? kTextColorHover : kTextColorNormal);
+					highlighted ? kTextColorNormal : kTextColorHover);
 			}
 
-			const Graphics::Font &otherFont = hoverOther ? *selectedFont : *normalFont;
-			const bool otherUsesCft = hoverOther ? selectedFontUsesCft : normalFontUsesCft;
+			const Graphics::Font &otherFont = hoverOther ? *highlightFont : *menuFont;
+			const bool otherUsesCft = hoverOther ? highlightFontUsesCft : menuFontUsesCft;
 			drawFontString(otherFont, otherUsesCft, "Other", kDialogueOtherX, kDialogueOtherY,
-				kDialogueOtherWidth, hoverOther ? kTextColorHover : kTextColorNormal);
+				kDialogueOtherWidth, hoverOther ? kTextColorNormal : kTextColorHover);
 		}
 
 		if (textEntryValue) {
-			drawFontString(*selectedFont, selectedFontUsesCft,
+			drawFontString(*highlightFont, highlightFontUsesCft,
 				*textEntryValue + "_", kDialogueTextEntryX, kDialogueTextEntryY,
-				kDialogueKeywordTextWidth - 30, kTextColorHover);
+				kDialogueKeywordTextWidth - 30, kTextColorNormal);
 		}
 
 		if (runtimeEntities)
@@ -490,7 +504,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		Common::Array<Common::String> subtitleLines;
 		const IndexedBitmap *textboxBitmap = nullptr;
 		if (textEnabled) {
-			normalFont->wordWrapText(subtitleText, kDialogueSubtitleTextWidth, subtitleLines);
+			subtitleFont->wordWrapText(subtitleText, kDialogueSubtitleTextWidth, subtitleLines);
 			textboxBitmap = startupArt->getTextboxBitmap(resolveDialogueTextboxIndex(subtitleLines.size()));
 		}
 
@@ -614,7 +628,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		for (const Common::String &rawOption : rawOptions) {
 			DialogueResponseOptionLayout option;
 			option.text = rawOption;
-			normalFont->wordWrapText(rawOption, kDialogueSubtitleTextWidth, option.wrappedLines);
+			menuFont->wordWrapText(rawOption, kDialogueSubtitleTextWidth, option.wrappedLines);
 			if (option.wrappedLines.empty())
 				option.wrappedLines.push_back(rawOption);
 			option.rowStart = (int)totalRows;
@@ -631,7 +645,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		if (mousePos.y < kDialogueKeywordTextY)
 			return -1;
 
-		const int lineHeight = getDialogueTextLineHeight(*normalFont);
+		const int lineHeight = getDialogueTextLineHeight(*menuFont);
 		const int row = (mousePos.y - kDialogueKeywordTextY) / MAX<int>(1, lineHeight);
 		if (row < 0 || row >= (int)totalRows)
 			return -1;
@@ -657,19 +671,19 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			blitTransparentBitmap(*screen, *textboxBitmap, kDialogueOverlayX, kDialogueOverlayY);
 
 		const Common::String title = "Responses";
-		const Graphics::Font &titleFont = *normalFont;
-		const bool titleUsesCft = normalFontUsesCft;
+		const Graphics::Font &titleFont = *highlightFont;
+		const bool titleUsesCft = highlightFontUsesCft;
 		const int titleWidth = titleFont.getStringWidth(title);
 		const int titleX = kDialogueSubtitleTextX + MAX<int>(0, (kDialogueSubtitleTextWidth - titleWidth) / 2);
 		drawFontString(titleFont, titleUsesCft, title, titleX, 11, titleWidth, kTextColorNormal);
 
-		const int lineHeight = getDialogueTextLineHeight(*normalFont);
+		const int lineHeight = getDialogueTextLineHeight(*menuFont);
 		int drawY = kDialogueKeywordTextY;
 		for (uint i = 0; i < options.size(); ++i) {
 			const bool highlighted = (int)i == hoveredOptionIndex;
-			const Graphics::Font &font = highlighted ? *selectedFont : *normalFont;
-			const bool usesCft = highlighted ? selectedFontUsesCft : normalFontUsesCft;
-			const byte color = highlighted ? kTextColorHover : kTextColorNormal;
+			const Graphics::Font &font = highlighted ? *highlightFont : *menuFont;
+			const bool usesCft = highlighted ? highlightFontUsesCft : menuFontUsesCft;
+			const byte color = highlighted ? kTextColorNormal : kTextColorHover;
 
 			for (const Common::String &wrappedLine : options[i].wrappedLines) {
 				drawFontString(font, usesCft, wrappedLine, kDialogueSubtitleTextX, drawY,
@@ -697,7 +711,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		if (options.empty())
 			return Common::kNoError;
 
-		const IndexedBitmap *textboxBitmap = startupArt->getTextboxBitmap(resolveDialogueTextboxIndex(totalRows));
+		const IndexedBitmap *textboxBitmap = startupArt->getTextboxBitmap(resolveDialogueResponseTextboxIndex(totalRows));
 		Common::Error releaseError = waitForPointerRelease();
 		if (releaseError.getCode() != Common::kNoError)
 			return releaseError;
@@ -749,7 +763,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 
 		Graphics::FrameLimiter limiter(g_system, 60);
 		for (;;) {
-			const int hoveredTopicIndex = editingOther ? -1 : getDialogueMenuItemAt(*normalFont, topics.size(), _mousePos);
+			const int hoveredTopicIndex = editingOther ? -1 : getDialogueMenuItemAt(*menuFont, topics.size(), _mousePos);
 			const bool hoverOther = !editingOther && isDialogueOtherHit(_mousePos);
 			drawDialogueOverlay(&keywordBitmap, nullptr, &topics, hoveredTopicIndex, hoverOther,
 				editingOther ? &typedTopic : nullptr);
@@ -794,7 +808,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 					}
 					break;
 				case Common::EVENT_LBUTTONDOWN: {
-					const int clickedTopic = getDialogueMenuItemAt(*normalFont, topics.size(), _mousePos);
+					const int clickedTopic = getDialogueMenuItemAt(*menuFont, topics.size(), _mousePos);
 					if (clickedTopic >= 0) {
 						selectedTopic = topics[(uint)clickedTopic];
 						return Common::kNoError;


Commit: 77b593c031868c0c0488647e471d7c0b852f3121
    https://github.com/scummvm/scummvm/commit/77b593c031868c0c0488647e471d7c0b852f3121
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:50+02:00

Commit Message:
HARVESTER: Support dialogue head variants

Handle head variant selection in startup dialogue.

Changed paths:
    engines/harvester/startup_dialogue.cpp
    engines/harvester/startup_dialogue.h


diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
index 30c3defc727..e6501f7e517 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/startup_dialogue.cpp
@@ -81,6 +81,15 @@ struct HankDialogueTopicLine {
 	bool setDiscussedLodgeTopic;
 };
 
+struct DialogueLineSpec {
+	DialogueLineSpec(int wavId, const char *speakerId, int headVariant = 0)
+		: wavId(wavId), speakerId(speakerId), headVariant(headVariant) {}
+
+	int wavId;
+	const char *speakerId;
+	int headVariant;
+};
+
 static const HankDialogueTopicLine kHankDialogueTopicLines[] = {
 	{ 0xd2, 0x725, "HANK", false },
 	{ 0xd6, 0x74f, "PC", false },
@@ -263,14 +272,18 @@ static void splitDialogueMenuLine(const Common::String &line, Common::Array<Comm
 	}
 }
 
-static bool loadDialogueHeadBitmap(HarvesterEngine &engine, const Common::String &speakerId,
+static Common::String buildDialogueHeadId(const Common::String &speakerId, int headVariant) {
+	return Common::String::format("%s%d", speakerId.c_str(), headVariant);
+}
+
+static bool loadDialogueHeadBitmap(HarvesterEngine &engine, const Common::String &speakerId, int headVariant,
 		IndexedBitmap &bitmap) {
 	StartupScript *startupScript = engine.getStartupScript();
 	ResourceManager *resources = engine.getResources();
 	if (!startupScript || !resources || speakerId.empty())
 		return false;
 
-	const Common::String headId = Common::String::format("%s0", speakerId.c_str());
+	const Common::String headId = buildDialogueHeadId(speakerId, headVariant);
 	const StartupHeadRecord *head = startupScript->findHeadRecord(headId);
 	if (!head)
 		return false;
@@ -311,6 +324,7 @@ StartupDialogueSystem::StartupDialogueSystem(HarvesterEngine &engine, Common::Po
 
 void StartupDialogueSystem::resetRoomNpcDialogueState() {
 	_hankRoomDialogueState = HankRoomDialogueState();
+	_sharedDialogueStateD2ecc = false;
 }
 
 Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
@@ -323,13 +337,6 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		int rowCount = 0;
 	};
 
-	struct DialogueLineSpec {
-		DialogueLineSpec(int wavId, const char *speakerId) : wavId(wavId), speakerId(speakerId) {}
-
-		int wavId;
-		const char *speakerId;
-	};
-
 	Graphics::Screen *screen = _engine.getScreen();
 	StartupScript *startupScript = _engine.getStartupScript();
 	StartupText *startupText = _engine.getStartupText();
@@ -371,12 +378,12 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 
 	IndexedBitmap leftHeadBitmap;
 	IndexedBitmap rightHeadBitmap;
-	Common::String leftHeadSpeakerId = npc.npcName;
-	Common::String rightHeadSpeakerId = "PC";
+	Common::String leftHeadSpeakerId = buildDialogueHeadId(npc.npcName, 0);
+	Common::String rightHeadSpeakerId = buildDialogueHeadId("PC", 0);
 	bool leftHeadVisible = false;
 	bool rightHeadVisible = false;
-	(void)loadDialogueHeadBitmap(_engine, leftHeadSpeakerId, leftHeadBitmap);
-	(void)loadDialogueHeadBitmap(_engine, rightHeadSpeakerId, rightHeadBitmap);
+	(void)loadDialogueHeadBitmap(_engine, npc.npcName, 0, leftHeadBitmap);
+	(void)loadDialogueHeadBitmap(_engine, "PC", 0, rightHeadBitmap);
 	leftHeadVisible = leftHeadBitmap.isValid();
 	rightHeadVisible = rightHeadBitmap.isValid();
 
@@ -407,29 +414,30 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			drawShadowedString(*screen, font, text, x, y, width, color);
 	};
 
-	auto ensureSpeakerPortrait = [&](const Common::String &speakerId) {
+	auto ensureSpeakerPortrait = [&](const Common::String &speakerId, int headVariant) {
 		if (speakerId.empty())
 			return;
 
+		const Common::String headId = buildDialogueHeadId(speakerId, headVariant);
 		IndexedBitmap *targetBitmap = speakerId.equalsIgnoreCase("PC") ? &rightHeadBitmap : &leftHeadBitmap;
 		Common::String *targetSpeakerId = speakerId.equalsIgnoreCase("PC") ? &rightHeadSpeakerId : &leftHeadSpeakerId;
-		if (targetBitmap->isValid() && targetSpeakerId->equalsIgnoreCase(speakerId))
+		if (targetBitmap->isValid() && targetSpeakerId->equalsIgnoreCase(headId))
 			return;
 
 		IndexedBitmap updatedBitmap;
-		if (loadDialogueHeadBitmap(_engine, speakerId, updatedBitmap)) {
+		if (loadDialogueHeadBitmap(_engine, speakerId, headVariant, updatedBitmap)) {
 			*targetBitmap = updatedBitmap;
-			*targetSpeakerId = speakerId;
+			*targetSpeakerId = headId;
 		} else {
-			warning("Harvester: unable to load dialogue head for '%s'", speakerId.c_str());
+			warning("Harvester: unable to load dialogue head for '%s'", headId.c_str());
 		}
 	};
 
-	auto setActiveSpeakerPortrait = [&](const Common::String &speakerId) {
+	auto setActiveSpeakerPortrait = [&](const Common::String &speakerId, int headVariant) {
 		if (speakerId.empty() || speakerId.equalsIgnoreCase("SPEECH"))
 			return;
 
-		ensureSpeakerPortrait(speakerId);
+		ensureSpeakerPortrait(speakerId, headVariant);
 		if (speakerId.equalsIgnoreCase("PC")) {
 			leftHeadVisible = false;
 			rightHeadVisible = rightHeadBitmap.isValid();
@@ -495,8 +503,8 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		screen->update();
 	};
 
-	auto playDialogueLine = [&](int wavId, const Common::String &speakerId) -> Common::Error {
-		setActiveSpeakerPortrait(speakerId);
+	auto playDialogueLineWithVariant = [&](int wavId, const Common::String &speakerId, int headVariant) -> Common::Error {
+		setActiveSpeakerPortrait(speakerId, headVariant);
 
 		Common::String subtitleText;
 		const bool textEnabled = startupScript->getDialogueTextMode() != kStartupDialogueTextNone &&
@@ -608,9 +616,14 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		return Common::kNoError;
 	};
 
+	auto playDialogueLine = [&](int wavId, const Common::String &speakerId) -> Common::Error {
+		return playDialogueLineWithVariant(wavId, speakerId, 0);
+	};
+
 	auto playDialogueSequence = [&](const DialogueLineSpec *lines, uint count) -> Common::Error {
 		for (uint i = 0; i < count; ++i) {
-			Common::Error lineError = playDialogueLine(lines[i].wavId, lines[i].speakerId);
+			Common::Error lineError = playDialogueLineWithVariant(
+				lines[i].wavId, lines[i].speakerId, lines[i].headVariant);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
@@ -867,8 +880,6 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		Common::Error responseError = runResponseMenu(0xc7, responseIndex);
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
-		if (responseIndex == 0)
-			return Common::kNoError;
 
 		switch (responseIndex) {
 		case 1:
@@ -942,14 +953,12 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		Common::Error responseError = runResponseMenu(0xcc, responseIndex);
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
-		if (responseIndex == 0)
-			return Common::kNoError;
 
 		switch (responseIndex) {
 		case 1: {
 			const DialogueLineSpec lines[] = {
 				DialogueLineSpec(0x939, "HANK"),
-				DialogueLineSpec(0x93f, "MOM")
+				DialogueLineSpec(0x93f, "MOM", 2)
 			};
 			lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
 			break;
@@ -974,14 +983,12 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		responseError = runResponseMenu(0xcd, responseIndex);
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
-		if (responseIndex == 0)
-			return Common::kNoError;
 
 		switch (responseIndex) {
 		case 1: {
 			const DialogueLineSpec lines[] = {
 				DialogueLineSpec(0x95a, "HANK"),
-				DialogueLineSpec(0x961, "PC")
+				DialogueLineSpec(0x961, "PC", 2)
 			};
 			lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
 			break;
@@ -1017,6 +1024,35 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		Common::Error lineError = playDialogueLine(0x9b1, "HANK");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runResponseMenu(0xce, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		switch (responseIndex) {
+		case 1: {
+			const DialogueLineSpec lines[] = {
+				DialogueLineSpec(0x9bc, "HANK"),
+				DialogueLineSpec(0x9c9, "HANK")
+			};
+			lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
+			break;
+		}
+		case 2: {
+			const DialogueLineSpec lines[] = {
+				DialogueLineSpec(0x9c1, "HANK"),
+				DialogueLineSpec(0x9c5, "PC"),
+				DialogueLineSpec(0x9c9, "HANK")
+			};
+			lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
+			break;
+		}
+		default:
+			break;
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 	if (startupScript->getFlagValue("KARIN_KIDNAPED") &&
 			!_hankRoomDialogueState.karinKidnapedShown) {
@@ -1024,6 +1060,46 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		Common::Error lineError = playDialogueLine(0x9d5, "HANK");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+
+		if (_sharedDialogueStateD2ecc) {
+			lineError = playDialogueLineWithVariant(0x9da, "PC", 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		const DialogueLineSpec lines[] = {
+			DialogueLineSpec(0x9de, "HANK"),
+			DialogueLineSpec(0x9df, "HANK"),
+			DialogueLineSpec(0x9e0, "HANK"),
+			DialogueLineSpec(0x9e1, "HANK"),
+			DialogueLineSpec(0x9e2, "HANK")
+		};
+		lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runResponseMenu(0xcf, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		switch (responseIndex) {
+		case 1: {
+			const DialogueLineSpec responseLines[] = {
+				DialogueLineSpec(0x9f4, "HANK"),
+				DialogueLineSpec(0x9fc, "HANK")
+			};
+			lineError = playDialogueSequence(responseLines, ARRAYSIZE(responseLines));
+			break;
+		}
+		case 2:
+			lineError = playDialogueLine(0xa01, "HANK");
+			break;
+		default:
+			break;
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 	if (startupScript->getFlagValue("KARIN_FOUND_ALIVE") &&
 			!_hankRoomDialogueState.karinFoundAliveShown) {
@@ -1043,8 +1119,6 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		Common::Error responseError = runResponseMenu(0xd0, responseIndex);
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
-		if (responseIndex == 0)
-			return Common::kNoError;
 
 		switch (responseIndex) {
 		case 1:
@@ -1085,7 +1159,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 				DialogueLineSpec(0x737, "HANK"),
 				DialogueLineSpec(0x73b, "PC"),
 				DialogueLineSpec(0x741, "HANK"),
-				DialogueLineSpec(0x747, "MOM")
+				DialogueLineSpec(0x747, "MOM", 2)
 			};
 			Common::Error lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
 			if (lineError.getCode() != Common::kNoError)
diff --git a/engines/harvester/startup_dialogue.h b/engines/harvester/startup_dialogue.h
index 32517d7fbcd..1b675b574a6 100644
--- a/engines/harvester/startup_dialogue.h
+++ b/engines/harvester/startup_dialogue.h
@@ -67,6 +67,7 @@ private:
 	HarvesterEngine &_engine;
 	Common::Point &_mousePos;
 	HankRoomDialogueState _hankRoomDialogueState;
+	bool _sharedDialogueStateD2ecc = false;
 };
 
 } // End of namespace Harvester


Commit: c59cf546447f8ca932588ac704245af116e33661
    https://github.com/scummvm/scummvm/commit/c59cf546447f8ca932588ac704245af116e33661
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:50+02:00

Commit Message:
HARVESTER: Refactor startup sound handling

Clean up startup sound handling and dialogue state management.

Changed paths:
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/startup_dialogue.cpp
    engines/harvester/startup_dialogue.h


diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index a69592eecbd..1a51768fb3b 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -45,6 +45,9 @@ namespace Harvester {
 
 namespace {
 
+static const uint32 kStartupExclusiveFadeMs = 16;
+static const uint32 kStartupExclusiveFadeSteps = 4;
+
 static bool shouldSkipStartupMoviesForDebug() {
 	return ConfMan.hasKey("harvester_debug_skip_startup_movies") &&
 		ConfMan.getBool("harvester_debug_skip_startup_movies");
@@ -138,7 +141,7 @@ bool HarvesterEngine::playStartupMusic(const Common::String &path) {
 
 void HarvesterEngine::stopStartupMusic() {
 	if (g_system && g_system->getMixer())
-		g_system->getMixer()->stopHandle(_startupMusicHandle);
+		stopStartupSoundHandle(_startupMusicHandle, true);
 	_startupMusicPath.clear();
 }
 
@@ -187,10 +190,10 @@ bool HarvesterEngine::playStartupSound(const Common::String &path) {
 }
 
 bool HarvesterEngine::playStartupSingleSound(const Common::String &path) {
-	stopStartupSoundHandle(_startupSingleSoundHandle);
 	if (path.empty() || !_resources || !g_system || !g_system->getMixer())
 		return false;
 
+	stopStartupSoundHandle(_startupSingleSoundHandle, true);
 	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(*_resources, path);
 	if (!audioStream) {
 		warning("Harvester: unable to decode startup sound '%s'", path.c_str());
@@ -201,11 +204,39 @@ bool HarvesterEngine::playStartupSingleSound(const Common::String &path) {
 	return true;
 }
 
+void HarvesterEngine::stopStartupSingleSound() {
+	stopStartupSoundHandle(_startupSingleSoundHandle, true);
+}
+
 bool HarvesterEngine::isStartupSingleSoundPlaying() const {
 	return g_system && g_system->getMixer() &&
 		g_system->getMixer()->isSoundHandleActive(_startupSingleSoundHandle);
 }
 
+bool HarvesterEngine::playStartupSpeech(const Common::String &path) {
+	if (path.empty() || !_resources || !g_system || !g_system->getMixer())
+		return false;
+
+	stopStartupSoundHandle(_startupSpeechHandle, true);
+	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(*_resources, path);
+	if (!audioStream) {
+		warning("Harvester: unable to decode startup speech '%s'", path.c_str());
+		return false;
+	}
+
+	g_system->getMixer()->playStream(Audio::Mixer::kSpeechSoundType, &_startupSpeechHandle, audioStream);
+	return true;
+}
+
+void HarvesterEngine::stopStartupSpeech() {
+	stopStartupSoundHandle(_startupSpeechHandle, true);
+}
+
+bool HarvesterEngine::isStartupSpeechPlaying() const {
+	return g_system && g_system->getMixer() &&
+		g_system->getMixer()->isSoundHandleActive(_startupSpeechHandle);
+}
+
 bool HarvesterEngine::loadStartupSound(int slot, const Common::String &path) {
 	if (!validateStartupLoadedSoundSlot(slot) || path.empty() || !_resources)
 		return false;
@@ -255,7 +286,8 @@ bool HarvesterEngine::deleteStartupLoadedSound(int slot) {
 }
 
 void HarvesterEngine::stopStartupSound() {
-	stopStartupSoundHandle(_startupSingleSoundHandle);
+	stopStartupSingleSound();
+	stopStartupSpeech();
 	for (int i = 0; i < ARRAYSIZE(_startupSoundHandles); ++i) {
 		stopStartupSoundHandle(_startupSoundHandles[i]);
 		_startupSoundPaths[i].clear();
@@ -268,9 +300,28 @@ void HarvesterEngine::stopStartupSound() {
 	_startupSoundSlotIndex = -1;
 }
 
-void HarvesterEngine::stopStartupSoundHandle(Audio::SoundHandle &handle) {
-	if (g_system && g_system->getMixer())
-		g_system->getMixer()->stopHandle(handle);
+void HarvesterEngine::stopStartupSoundHandle(Audio::SoundHandle &handle, bool fadeOut) {
+	if (!g_system || !g_system->getMixer())
+		return;
+
+	Audio::Mixer *mixer = g_system->getMixer();
+	if (!mixer->isSoundHandleActive(handle)) {
+		mixer->stopHandle(handle);
+		return;
+	}
+
+	if (fadeOut) {
+		const byte startVolume = mixer->getChannelVolume(handle);
+		for (uint32 step = kStartupExclusiveFadeSteps; step != 0; --step) {
+			if (!mixer->isSoundHandleActive(handle))
+				return;
+
+			mixer->setChannelVolume(handle, (byte)((startVolume * (step - 1)) / kStartupExclusiveFadeSteps));
+			g_system->delayMillis(kStartupExclusiveFadeMs / kStartupExclusiveFadeSteps);
+		}
+	}
+
+	mixer->stopHandle(handle);
 }
 
 bool HarvesterEngine::validateStartupLoadedSoundSlot(int slot) const {
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 1a3a0909173..1e4486cc787 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -85,7 +85,11 @@ public:
 	bool executeStartupAudioCommand(const StartupAudioCommand &command);
 	bool playStartupSound(const Common::String &path);
 	bool playStartupSingleSound(const Common::String &path);
+	void stopStartupSingleSound();
 	bool isStartupSingleSoundPlaying() const;
+	bool playStartupSpeech(const Common::String &path);
+	void stopStartupSpeech();
+	bool isStartupSpeechPlaying() const;
 	bool loadStartupSound(int slot, const Common::String &path);
 	bool playStartupLoadedSound(int slot);
 	bool deleteStartupLoadedSound(int slot);
@@ -93,13 +97,14 @@ public:
 
 private:
 	void setDisplayMode(int width, int height);
-	void stopStartupSoundHandle(Audio::SoundHandle &handle);
+	void stopStartupSoundHandle(Audio::SoundHandle &handle, bool fadeOut = false);
 	bool validateStartupLoadedSoundSlot(int slot) const;
 
 	const ADGameDescription *const _gameDescription;
 	Common::RandomSource _randomSource;
 	Audio::SoundHandle _startupMusicHandle;
 	Audio::SoundHandle _startupSingleSoundHandle;
+	Audio::SoundHandle _startupSpeechHandle;
 	Common::String _startupMusicPath;
 	int _startupSoundSlotIndex = -1;
 	Common::String _startupSoundPaths[8];
diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
index e6501f7e517..db141b80489 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/startup_dialogue.cpp
@@ -324,7 +324,7 @@ StartupDialogueSystem::StartupDialogueSystem(HarvesterEngine &engine, Common::Po
 
 void StartupDialogueSystem::resetRoomNpcDialogueState() {
 	_hankRoomDialogueState = HankRoomDialogueState();
-	_sharedDialogueStateD2ecc = false;
+	_sharedKarinKidnapedDialogueState = false;
 }
 
 Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
@@ -517,10 +517,10 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		}
 
 		const Common::String voicePath = buildDialogueVoicePath(*startupScript, wavId);
-		const bool voiceStarted = !voicePath.empty() && _engine.playStartupSingleSound(voicePath);
+		const bool voiceStarted = !voicePath.empty() && _engine.playStartupSpeech(voicePath);
 		Common::Error releaseError = waitForPointerRelease();
 		if (releaseError.getCode() != Common::kNoError) {
-			_engine.stopStartupSound();
+			_engine.stopStartupSpeech();
 			return releaseError;
 		}
 
@@ -533,7 +533,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			while (g_system->getEventManager()->pollEvent(event)) {
 				Common::Error result = Common::kNoError;
 				if (startupFlow.handleSystemEvent(event, result)) {
-					_engine.stopStartupSound();
+					_engine.stopStartupSpeech();
 					return result;
 				}
 
@@ -557,14 +557,14 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 
 			if (runtimeEntities)
 				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
-			if (interrupted || (!voiceStarted || !_engine.isStartupSingleSoundPlaying()))
+			if (interrupted || (!voiceStarted || !_engine.isStartupSpeechPlaying()))
 				break;
 
 			limiter.delayBeforeSwap();
 			limiter.startFrame();
 		}
 
-		_engine.stopStartupSound();
+		_engine.stopStartupSpeech();
 		if (interrupted) {
 			Common::Error releaseResult = waitForPointerRelease();
 			if (releaseResult.getCode() != Common::kNoError)
@@ -1061,7 +1061,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
-		if (_sharedDialogueStateD2ecc) {
+		if (_sharedKarinKidnapedDialogueState) {
 			lineError = playDialogueLineWithVariant(0x9da, "PC", 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
diff --git a/engines/harvester/startup_dialogue.h b/engines/harvester/startup_dialogue.h
index 1b675b574a6..6def7a8dacf 100644
--- a/engines/harvester/startup_dialogue.h
+++ b/engines/harvester/startup_dialogue.h
@@ -67,7 +67,7 @@ private:
 	HarvesterEngine &_engine;
 	Common::Point &_mousePos;
 	HankRoomDialogueState _hankRoomDialogueState;
-	bool _sharedDialogueStateD2ecc = false;
+	bool _sharedKarinKidnapedDialogueState = false;
 };
 
 } // End of namespace Harvester


Commit: c96c7b6b8d156fae164cc66b5f046cc47f388c44
    https://github.com/scummvm/scummvm/commit/c96c7b6b8d156fae164cc66b5f046cc47f388c44
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:50+02:00

Commit Message:
HARVESTER: Refactor startup music handling

Improve startup music handling and audio decoding logic.

Changed paths:
    engines/harvester/fst_player.cpp
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h


diff --git a/engines/harvester/fst_player.cpp b/engines/harvester/fst_player.cpp
index 86f4b910f6e..476c07c958b 100644
--- a/engines/harvester/fst_player.cpp
+++ b/engines/harvester/fst_player.cpp
@@ -352,6 +352,10 @@ bool FstPlayer::play(const Common::String &path) {
 	byte palette[256 * 3] = { 0 };
 	g_system->getPaletteManager()->setPalette(palette, 0, 256);
 
+	const bool resumeMusicAfterPlayback = _vm.isStartupMusicPlaying();
+	if (resumeMusicAfterPlayback)
+		_vm.pauseStartupMusic(true);
+
 	const bool censorshipEnabled = !_vm.isGoreEnabled();
 	const FstCensorshipToggleEntry *censorshipEntry = censorshipEnabled ? findCensorshipToggleEntry(path) : nullptr;
 	FstOverlayFrame censorshipOverlay;
@@ -481,6 +485,8 @@ bool FstPlayer::play(const Common::String &path) {
 	if (audioStarted)
 		mixer->stopHandle(audioHandle);
 	delete audioStream;
+	if (resumeMusicAfterPlayback)
+		_vm.pauseStartupMusic(false);
 
 	return !_vm.shouldQuit();
 }
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 1a51768fb3b..e79c8219678 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -24,7 +24,7 @@
 #include "harvester/harvester.h"
 
 #include "audio/audiostream.h"
-#include "audio/decoders/adpcm.h"
+#include "audio/decoders/raw.h"
 #include "audio/decoders/wave.h"
 #include "common/config-manager.h"
 #include "common/memstream.h"
@@ -45,14 +45,76 @@ namespace Harvester {
 
 namespace {
 
-static const uint32 kStartupExclusiveFadeMs = 16;
-static const uint32 kStartupExclusiveFadeSteps = 4;
+static const int8 kHarvesterImaIndexAdjustTable[16] = {
+	-1, -1, -1, -1, 2, 4, 6, 8,
+	-1, -1, -1, -1, 2, 4, 6, 8
+};
+
+static const uint16 kHarvesterImaStepTable[89] = {
+	7, 8, 9, 10, 11, 12, 13, 14, 16, 17,
+	19, 21, 23, 25, 28, 31, 34, 37, 41, 45,
+	50, 55, 60, 66, 73, 80, 88, 97, 107, 118,
+	130, 143, 157, 173, 190, 209, 230, 253, 279, 307,
+	337, 371, 408, 449, 494, 544, 598, 658, 724, 796,
+	876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066,
+	2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358,
+	5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899,
+	15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767
+};
 
 static bool shouldSkipStartupMoviesForDebug() {
 	return ConfMan.hasKey("harvester_debug_skip_startup_movies") &&
 		ConfMan.getBool("harvester_debug_skip_startup_movies");
 }
 
+static uint32 decodeHarvesterFcmp(byte *dest, const byte *src, uint32 srcSize, uint16 bitsPerSample) {
+	const uint32 decodedBytesPerInputByte = bitsPerSample >> 2;
+	const uint32 decodedSize = srcSize * decodedBytesPerInputByte;
+	const uint32 sampleCount = (bitsPerSample == 16) ? (decodedSize >> 1) : decodedSize;
+	int predictor = 0;
+	int stepIndex = 0;
+	int stepSize = 7;
+	byte cachedByte = 0;
+	uint32 srcPos = 0;
+
+	for (uint32 sample = 0; sample < sampleCount; ++sample) {
+		const byte nibble = ((sample & 1) == 0)
+			? ((cachedByte = src[srcPos++]) & 0x0f)
+			: ((cachedByte >> 4) & 0x0f);
+		int delta = stepSize >> 3;
+		if (nibble & 4)
+			delta += stepSize;
+		if (nibble & 2)
+			delta += stepSize >> 1;
+		if (nibble & 1)
+			delta += stepSize >> 2;
+		if (nibble & 8)
+			delta = -delta;
+
+		predictor += delta;
+		if (predictor > 0x7fff)
+			predictor = 0x7fff;
+		else if (predictor < -0x8000)
+			predictor = -0x8000;
+
+		if (bitsPerSample == 16) {
+			WRITE_LE_INT16(dest, predictor);
+			dest += 2;
+		} else {
+			*dest++ = (byte)(((predictor >> 8) & 0xff) ^ 0x80);
+		}
+
+		stepIndex += kHarvesterImaIndexAdjustTable[nibble];
+		if (stepIndex < 0)
+			stepIndex = 0;
+		else if (stepIndex > 88)
+			stepIndex = 88;
+		stepSize = kHarvesterImaStepTable[stepIndex];
+	}
+
+	return decodedSize;
+}
+
 static Audio::SeekableAudioStream *decodeStartupAudioStream(Common::SeekableReadStream *stream) {
 	if (!stream)
 		return nullptr;
@@ -69,13 +131,41 @@ static Audio::SeekableAudioStream *decodeStartupAudioStream(Common::SeekableRead
 				? MIN<uint32>(payloadSizeFromHeader, availablePayloadSize)
 				: availablePayloadSize;
 
-			if (sampleRate == 0 || bitsPerSample != 16 || payloadSize == 0) {
+			if (sampleRate == 0 || (bitsPerSample != 8 && bitsPerSample != 16) || payloadSize == 0) {
+				delete stream;
+				return nullptr;
+			}
+
+			if (!stream->seek(14)) {
 				delete stream;
 				return nullptr;
 			}
 
-			return Audio::makeADPCMStream(stream, DisposeAfterUse::YES, payloadSize,
-				Audio::kADPCMDVI, sampleRate, 1);
+			byte *compressedPayload = (byte *)malloc(payloadSize);
+			if (!compressedPayload) {
+				delete stream;
+				return nullptr;
+			}
+			if (stream->read(compressedPayload, payloadSize) != payloadSize) {
+				free(compressedPayload);
+				delete stream;
+				return nullptr;
+			}
+
+			const uint32 decodedSize = payloadSize * (bitsPerSample >> 2);
+			byte *decodedPcm = (byte *)malloc(decodedSize);
+			if (!decodedPcm) {
+				free(compressedPayload);
+				delete stream;
+				return nullptr;
+			}
+
+			decodeHarvesterFcmp(decodedPcm, compressedPayload, payloadSize, bitsPerSample);
+			free(compressedPayload);
+			delete stream;
+			return Audio::makeRawStream(decodedPcm, decodedSize, sampleRate,
+				Audio::FLAG_LITTLE_ENDIAN | ((bitsPerSample == 16) ? Audio::FLAG_16BITS : 0),
+				DisposeAfterUse::YES);
 		}
 	}
 
@@ -116,6 +206,11 @@ bool HarvesterEngine::isGoreEnabled() const {
 	return !ConfMan.hasKey("gore") || ConfMan.getBool("gore");
 }
 
+bool HarvesterEngine::isStartupMusicPlaying() const {
+	return g_system && g_system->getMixer() &&
+		g_system->getMixer()->isSoundHandleActive(_startupMusicHandle);
+}
+
 bool HarvesterEngine::playStartupMusic(const Common::String &path) {
 	if (path.empty() || !_resources || !g_system || !g_system->getMixer())
 		return false;
@@ -139,9 +234,14 @@ bool HarvesterEngine::playStartupMusic(const Common::String &path) {
 	return true;
 }
 
+void HarvesterEngine::pauseStartupMusic(bool paused) {
+	if (g_system && g_system->getMixer())
+		g_system->getMixer()->pauseHandle(_startupMusicHandle, paused);
+}
+
 void HarvesterEngine::stopStartupMusic() {
 	if (g_system && g_system->getMixer())
-		stopStartupSoundHandle(_startupMusicHandle, true);
+		stopStartupSoundHandle(_startupMusicHandle);
 	_startupMusicPath.clear();
 }
 
@@ -193,7 +293,7 @@ bool HarvesterEngine::playStartupSingleSound(const Common::String &path) {
 	if (path.empty() || !_resources || !g_system || !g_system->getMixer())
 		return false;
 
-	stopStartupSoundHandle(_startupSingleSoundHandle, true);
+	stopStartupSoundHandle(_startupSingleSoundHandle);
 	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(*_resources, path);
 	if (!audioStream) {
 		warning("Harvester: unable to decode startup sound '%s'", path.c_str());
@@ -205,7 +305,7 @@ bool HarvesterEngine::playStartupSingleSound(const Common::String &path) {
 }
 
 void HarvesterEngine::stopStartupSingleSound() {
-	stopStartupSoundHandle(_startupSingleSoundHandle, true);
+	stopStartupSoundHandle(_startupSingleSoundHandle);
 }
 
 bool HarvesterEngine::isStartupSingleSoundPlaying() const {
@@ -217,7 +317,7 @@ bool HarvesterEngine::playStartupSpeech(const Common::String &path) {
 	if (path.empty() || !_resources || !g_system || !g_system->getMixer())
 		return false;
 
-	stopStartupSoundHandle(_startupSpeechHandle, true);
+	stopStartupSoundHandle(_startupSpeechHandle);
 	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(*_resources, path);
 	if (!audioStream) {
 		warning("Harvester: unable to decode startup speech '%s'", path.c_str());
@@ -229,7 +329,7 @@ bool HarvesterEngine::playStartupSpeech(const Common::String &path) {
 }
 
 void HarvesterEngine::stopStartupSpeech() {
-	stopStartupSoundHandle(_startupSpeechHandle, true);
+	stopStartupSoundHandle(_startupSpeechHandle);
 }
 
 bool HarvesterEngine::isStartupSpeechPlaying() const {
@@ -300,28 +400,9 @@ void HarvesterEngine::stopStartupSound() {
 	_startupSoundSlotIndex = -1;
 }
 
-void HarvesterEngine::stopStartupSoundHandle(Audio::SoundHandle &handle, bool fadeOut) {
-	if (!g_system || !g_system->getMixer())
-		return;
-
-	Audio::Mixer *mixer = g_system->getMixer();
-	if (!mixer->isSoundHandleActive(handle)) {
-		mixer->stopHandle(handle);
-		return;
-	}
-
-	if (fadeOut) {
-		const byte startVolume = mixer->getChannelVolume(handle);
-		for (uint32 step = kStartupExclusiveFadeSteps; step != 0; --step) {
-			if (!mixer->isSoundHandleActive(handle))
-				return;
-
-			mixer->setChannelVolume(handle, (byte)((startVolume * (step - 1)) / kStartupExclusiveFadeSteps));
-			g_system->delayMillis(kStartupExclusiveFadeMs / kStartupExclusiveFadeSteps);
-		}
-	}
-
-	mixer->stopHandle(handle);
+void HarvesterEngine::stopStartupSoundHandle(Audio::SoundHandle &handle) {
+	if (g_system && g_system->getMixer())
+		g_system->getMixer()->stopHandle(handle);
 }
 
 bool HarvesterEngine::validateStartupLoadedSoundSlot(int slot) const {
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 1e4486cc787..2a4c4dd7c95 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -80,7 +80,9 @@ public:
 	StartupText *getStartupText() const { return _startupText; }
 	bool isGoreEnabled() const;
 	const Common::String &getStartupMusicPath() const { return _startupMusicPath; }
+	bool isStartupMusicPlaying() const;
 	bool playStartupMusic(const Common::String &path);
+	void pauseStartupMusic(bool paused);
 	void stopStartupMusic();
 	bool executeStartupAudioCommand(const StartupAudioCommand &command);
 	bool playStartupSound(const Common::String &path);
@@ -97,7 +99,7 @@ public:
 
 private:
 	void setDisplayMode(int width, int height);
-	void stopStartupSoundHandle(Audio::SoundHandle &handle, bool fadeOut = false);
+	void stopStartupSoundHandle(Audio::SoundHandle &handle);
 	bool validateStartupLoadedSoundSlot(int slot) const;
 
 	const ADGameDescription *const _gameDescription;


Commit: 513a92b39932555aa74de781cfb14196717daca1
    https://github.com/scummvm/scummvm/commit/513a92b39932555aa74de781cfb14196717daca1
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:50+02:00

Commit Message:
HARVESTER: Add dialogue debug support

Enhance startup dialogue topic handling with debug support.

Changed paths:
    engines/harvester/detection.cpp
    engines/harvester/detection.h
    engines/harvester/startup_dialogue.cpp
    engines/harvester/startup_dialogue.h


diff --git a/engines/harvester/detection.cpp b/engines/harvester/detection.cpp
index 9429eeb8c66..d2255193960 100644
--- a/engines/harvester/detection.cpp
+++ b/engines/harvester/detection.cpp
@@ -28,6 +28,7 @@ const DebugChannelDef HarvesterMetaEngineDetection::debugFlagList[] = {
 	{ Harvester::kDebugGeneral, "general", "General debug level" },
 	{ Harvester::kDebugResources, "resources", "Resource loading" },
 	{ Harvester::kDebugScene, "scene", "Startup room, hotspot, and actor debug" },
+	{ Harvester::kDebugDialogue, "dialogue", "Startup dialogue and NPC interaction debug" },
 	DEBUG_CHANNEL_END
 };
 
diff --git a/engines/harvester/detection.h b/engines/harvester/detection.h
index 15039ef9456..8d248d3bac1 100644
--- a/engines/harvester/detection.h
+++ b/engines/harvester/detection.h
@@ -31,7 +31,8 @@ namespace Harvester {
 enum HarvesterDebugChannels {
 	kDebugGeneral   = 1 << 0,
 	kDebugResources = 1 << 1,
-	kDebugScene     = 1 << 2
+	kDebugScene     = 1 << 2,
+	kDebugDialogue  = 1 << 3
 };
 
 extern const PlainGameDescriptor harvesterGames[];
diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
index db141b80489..385a70e4496 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/startup_dialogue.cpp
@@ -27,12 +27,12 @@
 #include "common/endian.h"
 #include "common/events.h"
 #include "common/system.h"
-#include "common/tokenizer.h"
 #include "graphics/blit.h"
 #include "graphics/font.h"
 #include "graphics/fontman.h"
 #include "graphics/framelimiter.h"
 #include "harvester/cft_font.h"
+#include "harvester/detection.h"
 #include "harvester/fst_player.h"
 #include "harvester/harvester.h"
 #include "harvester/resources.h"
@@ -262,14 +262,39 @@ static void drawDialogueTextLines(Graphics::Screen &screen, const Graphics::Font
 
 static void splitDialogueMenuLine(const Common::String &line, Common::Array<Common::String> &parts) {
 	parts.clear();
+	if (line.empty())
+		return;
 
-	Common::StringTokenizer tokenizer(line, "/");
-	while (!tokenizer.empty()) {
-		Common::String token = tokenizer.nextToken();
-		token.trim();
-		if (!token.empty())
+	Common::String token;
+	for (uint i = 0; i < line.size(); ++i) {
+		if (line[i] == '/') {
 			parts.push_back(token);
+			token.clear();
+			continue;
+		}
+
+		token += line[i];
 	}
+
+	parts.push_back(token);
+}
+
+static void logDialogueMenuItems(const char *label, int sourceLineIndex,
+		const Common::String &rawLine, const Common::Array<Common::String> &items) {
+	if (sourceLineIndex >= 0) {
+		debugC(1, kDebugDialogue, "Harvester: %s source line 0x%x (%d) raw='%s'",
+			label, sourceLineIndex, sourceLineIndex + 1, rawLine.c_str());
+	} else {
+		debugC(1, kDebugDialogue, "Harvester: %s source raw='%s'", label, rawLine.c_str());
+	}
+
+	if (items.empty()) {
+		debugC(1, kDebugDialogue, "Harvester: %s has no explicit items", label);
+		return;
+	}
+
+	for (uint i = 0; i < items.size(); ++i)
+		debugC(1, kDebugDialogue, "Harvester: %s item[%u]='%s'", label, i + 1, items[i].c_str());
 }
 
 static Common::String buildDialogueHeadId(const Common::String &speakerId, int headVariant) {
@@ -518,6 +543,10 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 
 		const Common::String voicePath = buildDialogueVoicePath(*startupScript, wavId);
 		const bool voiceStarted = !voicePath.empty() && _engine.playStartupSpeech(voicePath);
+		debugC(2, kDebugDialogue,
+			"Harvester: dialogue line wav=0x%x speaker='%s' headVariant=%d voice='%s' subtitle='%s'",
+			wavId, speakerId.c_str(), headVariant, voicePath.c_str(),
+			textEnabled ? subtitleText.c_str() : "");
 		Common::Error releaseError = waitForPointerRelease();
 		if (releaseError.getCode() != Common::kNoError) {
 			_engine.stopStartupSpeech();
@@ -715,14 +744,24 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		selectedIndex = 0;
 
 		const Common::String responseLine = startupText->getDialogueResponseLine(responseLineIndex);
-		if (responseLine.empty())
+		if (responseLine.empty()) {
+			debugC(1, kDebugDialogue, "Harvester: response menu line 0x%x (%d) is empty",
+				responseLineIndex, responseLineIndex + 1);
 			return Common::kNoError;
+		}
 
 		Common::Array<DialogueResponseOptionLayout> options;
 		uint totalRows = 0;
 		buildResponseMenuLayout(responseLine, options, totalRows);
-		if (options.empty())
+		if (options.empty()) {
+			debugC(1, kDebugDialogue, "Harvester: response menu line 0x%x (%d) produced no options",
+				responseLineIndex, responseLineIndex + 1);
 			return Common::kNoError;
+		}
+
+		Common::Array<Common::String> rawOptions;
+		splitDialogueMenuLine(responseLine, rawOptions);
+		logDialogueMenuItems("Response menu", responseLineIndex, responseLine, rawOptions);
 
 		const IndexedBitmap *textboxBitmap = startupArt->getTextboxBitmap(resolveDialogueResponseTextboxIndex(totalRows));
 		Common::Error releaseError = waitForPointerRelease();
@@ -741,17 +780,28 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 					return result;
 
 				if (event.type == Common::EVENT_KEYDOWN) {
-					if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+						debugC(1, kDebugDialogue, "Harvester: response menu line 0x%x (%d) cancelled",
+							responseLineIndex, responseLineIndex + 1);
 						return Common::kNoError;
+					}
 					if (event.kbd.ascii >= '1' && event.kbd.ascii <= '9') {
 						const int menuIndex = event.kbd.ascii - '0';
 						if (menuIndex >= 1 && menuIndex <= (int)options.size()) {
 							selectedIndex = menuIndex;
+							debugC(1, kDebugDialogue,
+								"Harvester: response menu line 0x%x (%d) selected option[%d]='%s' via hotkey",
+								responseLineIndex, responseLineIndex + 1,
+								selectedIndex, options[(uint)(selectedIndex - 1)].text.c_str());
 							return waitForPointerRelease();
 						}
 					}
 				} else if (event.type == Common::EVENT_LBUTTONDOWN && hoveredOptionIndex >= 0) {
 					selectedIndex = hoveredOptionIndex + 1;
+					debugC(1, kDebugDialogue,
+						"Harvester: response menu line 0x%x (%d) selected option[%d]='%s' via click",
+						responseLineIndex, responseLineIndex + 1,
+						selectedIndex, options[(uint)hoveredOptionIndex].text.c_str());
 					return waitForPointerRelease();
 				}
 			}
@@ -763,11 +813,14 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		}
 	};
 
-	auto runKeywordMenu = [&](const Common::String &topicBuffer, Common::String &selectedTopic) -> Common::Error {
+	auto runKeywordMenu = [&](const Common::String &topicBuffer, int topicBufferLineIndex,
+			Common::String &selectedTopic) -> Common::Error {
 		selectedTopic.clear();
 		Common::Array<Common::String> topics;
 		splitDialogueMenuLine(topicBuffer, topics);
+		logDialogueMenuItems("Keyword menu", topicBufferLineIndex, topicBuffer, topics);
 		topics.push_back(genericByeTopic);
+		debugC(1, kDebugDialogue, "Harvester: keyword menu default topic='%s'", genericByeTopic.c_str());
 		bool editingOther = false;
 		Common::String typedTopic;
 		Common::Error releaseError = waitForPointerRelease();
@@ -792,6 +845,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 						if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
 							typedTopic.clear();
 							editingOther = false;
+							debugC(1, kDebugDialogue, "Harvester: keyword menu cancelled Other input");
 							if (runtimeEntities)
 								runtimeEntities->showCursor();
 						} else if (event.kbd.keycode == Common::KEYCODE_BACKSPACE) {
@@ -801,6 +855,8 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 								event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
 							if (!typedTopic.empty()) {
 								selectedTopic = typedTopic;
+								debugC(1, kDebugDialogue, "Harvester: keyword menu typed topic='%s'",
+									selectedTopic.c_str());
 								if (runtimeEntities)
 									runtimeEntities->showCursor();
 								return Common::kNoError;
@@ -817,6 +873,9 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 				case Common::EVENT_KEYDOWN:
 					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
 						selectedTopic = genericByeTopic;
+						debugC(1, kDebugDialogue,
+							"Harvester: keyword menu selected default topic via escape '%s'",
+							selectedTopic.c_str());
 						return Common::kNoError;
 					}
 					break;
@@ -824,11 +883,14 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 					const int clickedTopic = getDialogueMenuItemAt(*menuFont, topics.size(), _mousePos);
 					if (clickedTopic >= 0) {
 						selectedTopic = topics[(uint)clickedTopic];
+						debugC(1, kDebugDialogue, "Harvester: keyword menu selected topic[%d]='%s'",
+							clickedTopic + 1, selectedTopic.c_str());
 						return Common::kNoError;
 					}
 					if (isDialogueOtherHit(_mousePos)) {
 						typedTopic.clear();
 						editingOther = true;
+						debugC(1, kDebugDialogue, "Harvester: keyword menu entered Other input");
 						if (runtimeEntities)
 							runtimeEntities->hideCursor();
 					}
@@ -851,6 +913,27 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		return Common::kNoError;
 	}
 
+	const int currentStoryDayIndex = startupScript->getCurrentStoryDayIndex();
+	Common::String &hankTopicBuffer = _hankRoomDialogueState.currentTopicBuffer;
+	int &hankTopicBufferLineIndex = _hankRoomDialogueState.currentTopicBufferLineIndex;
+	auto assignHankTopicBuffer = [&](int responseLineIndex) {
+		hankTopicBuffer = startupText->getDialogueResponseLine(responseLineIndex);
+		hankTopicBufferLineIndex = responseLineIndex;
+
+		Common::Array<Common::String> topics;
+		splitDialogueMenuLine(hankTopicBuffer, topics);
+		logDialogueMenuItems("Hank topic buffer", responseLineIndex, hankTopicBuffer, topics);
+	};
+
+	debugC(1, kDebugDialogue,
+		"Harvester: Hank dialogue start day=%d item='%s' initial=%d trackedDayValid=%d trackedDay=%d sameDayPending=%d rangshotPending=%d topicLine=%d topicBuffer='%s'",
+		currentStoryDayIndex, usedItemName.empty() ? "<none>" : usedItemName.c_str(),
+		(int)_hankRoomDialogueState.pendingInitialConversation,
+		(int)_hankRoomDialogueState.hasTrackedDayState, _hankRoomDialogueState.trackedDayIndex,
+		(int)_hankRoomDialogueState.pendingSameDayFollowup,
+		(int)_hankRoomDialogueState.pendingRangshotSequence,
+		hankTopicBufferLineIndex, hankTopicBuffer.c_str());
+
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
@@ -863,14 +946,15 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		}
 		return playDialogueLine(0xa38, "HANK");
 	}
-
-	Common::String hankTopicBuffer;
-	const int currentStoryDayIndex = startupScript->getCurrentStoryDayIndex();
+	bool skipHankFollowupBranches = false;
 	if (_hankRoomDialogueState.pendingInitialConversation) {
 		_hankRoomDialogueState.trackedDayIndex = currentStoryDayIndex;
 		_hankRoomDialogueState.hasTrackedDayState = true;
 		_hankRoomDialogueState.pendingSameDayFollowup = true;
 		_hankRoomDialogueState.pendingInitialConversation = false;
+		hankTopicBuffer.clear();
+		hankTopicBufferLineIndex = -1;
+		skipHankFollowupBranches = true;
 
 		Common::Error lineError = playDialogueLine(0x703, "HANK");
 		if (lineError.getCode() != Common::kNoError)
@@ -886,27 +970,27 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			lineError = playDialogueLine(0x70f, "HANK");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			hankTopicBuffer = startupText->getDialogueResponseLine(0xc8);
+			assignHankTopicBuffer(0xc8);
 			break;
 		case 2:
 			lineError = playDialogueLine(0x715, "HANK");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			hankTopicBuffer = startupText->getDialogueResponseLine(0xc9);
+			assignHankTopicBuffer(0xc9);
 			break;
 		case 3:
 			lineError = playDialogueLine(0x71c, "HANK");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			hankTopicBuffer = startupText->getDialogueResponseLine(0xca);
+			assignHankTopicBuffer(0xca);
 			break;
 		default:
 			break;
 		}
 	}
 
-	if (!_hankRoomDialogueState.hasTrackedDayState ||
-			currentStoryDayIndex != _hankRoomDialogueState.trackedDayIndex) {
+	if (!skipHankFollowupBranches && (!_hankRoomDialogueState.hasTrackedDayState ||
+			currentStoryDayIndex != _hankRoomDialogueState.trackedDayIndex)) {
 		if (_hankRoomDialogueState.pendingSameDayFollowup) {
 			_hankRoomDialogueState.trackedDayIndex = currentStoryDayIndex;
 			_hankRoomDialogueState.pendingSameDayFollowup = false;
@@ -928,7 +1012,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 					return Common::kReadingFailed;
 			}
 		}
-	} else {
+	} else if (!skipHankFollowupBranches) {
 		_hankRoomDialogueState.pendingSameDayFollowup = true;
 		if (currentStoryDayIndex > 5) {
 			Common::Error lineError = playDialogueLine(0x8e2, "HANK");
@@ -941,7 +1025,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		}
 	}
 
-	if (startupScript->getFlagValue("STEPH_MIDGAME_PLAYED") &&
+	if (!skipHankFollowupBranches && startupScript->getFlagValue("STEPH_MIDGAME_PLAYED") &&
 			!_hankRoomDialogueState.stephMidgamePlayedShown) {
 		_hankRoomDialogueState.stephMidgamePlayedShown = true;
 
@@ -1011,14 +1095,14 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
-	if (startupScript->getFlagValue("BURNED_TV_STATION") &&
+	if (!skipHankFollowupBranches && startupScript->getFlagValue("BURNED_TV_STATION") &&
 			!_hankRoomDialogueState.burnedTvStationShown) {
 		_hankRoomDialogueState.burnedTvStationShown = true;
 		Common::Error lineError = playDialogueLine(0x987, "PC");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
-	if (startupScript->getFlagValue("BUSTED_ONCE") &&
+	if (!skipHankFollowupBranches && startupScript->getFlagValue("BUSTED_ONCE") &&
 			!_hankRoomDialogueState.bustedOnceShown) {
 		_hankRoomDialogueState.bustedOnceShown = true;
 		Common::Error lineError = playDialogueLine(0x9b1, "HANK");
@@ -1054,7 +1138,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
-	if (startupScript->getFlagValue("KARIN_KIDNAPED") &&
+	if (!skipHankFollowupBranches && startupScript->getFlagValue("KARIN_KIDNAPED") &&
 			!_hankRoomDialogueState.karinKidnapedShown) {
 		_hankRoomDialogueState.karinKidnapedShown = true;
 		Common::Error lineError = playDialogueLine(0x9d5, "HANK");
@@ -1101,14 +1185,14 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
-	if (startupScript->getFlagValue("KARIN_FOUND_ALIVE") &&
+	if (!skipHankFollowupBranches && startupScript->getFlagValue("KARIN_FOUND_ALIVE") &&
 			!_hankRoomDialogueState.karinFoundAliveShown) {
 		_hankRoomDialogueState.karinFoundAliveShown = true;
 		Common::Error lineError = playDialogueLine(0xa0b, "HANK");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
-	if (startupScript->getFlagValue("KARIN_FOUND_DEAD") &&
+	if (!skipHankFollowupBranches && startupScript->getFlagValue("KARIN_FOUND_DEAD") &&
 			!_hankRoomDialogueState.karinFoundDeadShown) {
 		_hankRoomDialogueState.karinFoundDeadShown = true;
 		Common::Error lineError = playDialogueLine(0xa15, "HANK");
@@ -1140,12 +1224,13 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 
 	for (;;) {
 		Common::String selectedTopic;
-		Common::Error menuError = runKeywordMenu(hankTopicBuffer, selectedTopic);
+		Common::Error menuError = runKeywordMenu(hankTopicBuffer, hankTopicBufferLineIndex, selectedTopic);
 		if (menuError.getCode() != Common::kNoError)
 			return menuError;
 
 		if (selectedTopic.empty())
 			return Common::kNoError;
+		debugC(1, kDebugDialogue, "Harvester: Hank selected topic='%s'", selectedTopic.c_str());
 		if (selectedTopic.equalsIgnoreCase(genericByeTopic))
 			return playDialogueLine(0x8dc, "HANK");
 
@@ -1164,7 +1249,8 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			Common::Error lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			hankTopicBuffer = startupText->getDialogueResponseLine(0xd5);
+			setActiveSpeakerPortrait("HANK", 0);
+			assignHankTopicBuffer(0xd5);
 			continue;
 		}
 
@@ -1180,6 +1266,10 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			if (topic.setDiscussedLodgeTopic)
 				_hankRoomDialogueState.discussedLodgeTopic = true;
 
+			debugC(1, kDebugDialogue,
+				"Harvester: Hank matched topic '%s' to response line 0x%x (%d) -> wav=0x%x speaker='%s'",
+				selectedTopic.c_str(), topic.responseLineIndex, topic.responseLineIndex + 1,
+				topic.wavId, topic.speakerId);
 			Common::Error lineError = playDialogueLine(topic.wavId, topic.speakerId);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
@@ -1190,9 +1280,13 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		if (handledTopic)
 			continue;
 
+		debugC(1, kDebugDialogue,
+			"Harvester: Hank topic '%s' fell back to generic response and exits dialogue",
+			selectedTopic.c_str());
 		Common::Error lineError = playDialogueLine(0xa32, "HANK");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+		return playDialogueLine(0x8dc, "HANK");
 	}
 }
 
diff --git a/engines/harvester/startup_dialogue.h b/engines/harvester/startup_dialogue.h
index 6def7a8dacf..44400086a4c 100644
--- a/engines/harvester/startup_dialogue.h
+++ b/engines/harvester/startup_dialogue.h
@@ -54,6 +54,8 @@ public:
 		bool discussedCasketPhotoEvidence = false;
 		bool discussedWhaleyHerrillPhoto = false;
 		bool discussedLodgeTopic = false;
+		Common::String currentTopicBuffer;
+		int currentTopicBufferLineIndex = -1;
 	};
 
 	StartupDialogueSystem(HarvesterEngine &engine, Common::Point &mousePos);


Commit: c4ea1c1fd48fa372643dde3d2b3f00d2e7a51424
    https://github.com/scummvm/scummvm/commit/c4ea1c1fd48fa372643dde3d2b3f00d2e7a51424
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:51+02:00

Commit Message:
HARVESTER: Improve dialogue topic handling

Clarify dialogue topic handling and add debug logging.

Changed paths:
    engines/harvester/startup_dialogue.cpp


diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
index 385a70e4496..5fb459f36a1 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/startup_dialogue.cpp
@@ -1234,8 +1234,8 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		if (selectedTopic.equalsIgnoreCase(genericByeTopic))
 			return playDialogueLine(0x8dc, "HANK");
 
-		const Common::String coolDaddioTopic = startupText->getDialogueResponseLine(0xd2);
-		if (!coolDaddioTopic.empty() && selectedTopic.equalsIgnoreCase(coolDaddioTopic)) {
+		const Common::String momTopic = startupText->getDialogueResponseLine(0xd2);
+		if (!momTopic.empty() && selectedTopic.equalsIgnoreCase(momTopic)) {
 			const DialogueLineSpec lines[] = {
 				DialogueLineSpec(0x725, "HANK"),
 				DialogueLineSpec(0x729, "PC"),
@@ -1277,8 +1277,15 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			break;
 		}
 
-		if (handledTopic)
-			continue;
+		if (handledTopic) {
+			debugC(1, kDebugDialogue,
+				"Harvester: Hank handled topic '%s' and now falls through to generic response/exit",
+				selectedTopic.c_str());
+			Common::Error lineError = playDialogueLine(0xa32, "HANK");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			return playDialogueLine(0x8dc, "HANK");
+		}
 
 		debugC(1, kDebugDialogue,
 			"Harvester: Hank topic '%s' fell back to generic response and exits dialogue",


Commit: 35f656c6e795e28d29a1c6bc70882df5ff8329e2
    https://github.com/scummvm/scummvm/commit/35f656c6e795e28d29a1c6bc70882df5ff8329e2
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:51+02:00

Commit Message:
HARVESTER: Improve XFileArchive handling

Improve archive file handling and add mutex support.

Changed paths:
    engines/harvester/startup_dialogue.cpp
    engines/harvester/xfile_archive.cpp
    engines/harvester/xfile_archive.h


diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
index 5fb459f36a1..259653ef955 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/startup_dialogue.cpp
@@ -649,6 +649,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		return playDialogueLineWithVariant(wavId, speakerId, 0);
 	};
 
+	// Multi-line exchanges in the original binary are emitted as consecutive play_dialogue_line calls.
 	auto playDialogueSequence = [&](const DialogueLineSpec *lines, uint count) -> Common::Error {
 		for (uint i = 0; i < count; ++i) {
 			Common::Error lineError = playDialogueLineWithVariant(
@@ -1235,7 +1236,15 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			return playDialogueLine(0x8dc, "HANK");
 
 		const Common::String momTopic = startupText->getDialogueResponseLine(0xd2);
-		if (!momTopic.empty() && selectedTopic.equalsIgnoreCase(momTopic)) {
+		Common::Array<Common::String> currentTopics;
+		splitDialogueMenuLine(hankTopicBuffer, currentTopics);
+		const bool matchesMomTopicByLine = !momTopic.empty() && selectedTopic.equalsIgnoreCase(momTopic);
+		const bool matchesMomTopicFromIntroBuffer = hankTopicBufferLineIndex == 0xc8 &&
+			!currentTopics.empty() && selectedTopic.equalsIgnoreCase(currentTopics[0]);
+		if (matchesMomTopicByLine || matchesMomTopicFromIntroBuffer) {
+			debugC(1, kDebugDialogue,
+				"Harvester: Hank matched special Mom branch '%s' (lineMatch=%d bufferMatch=%d)",
+				selectedTopic.c_str(), (int)matchesMomTopicByLine, (int)matchesMomTopicFromIntroBuffer);
 			const DialogueLineSpec lines[] = {
 				DialogueLineSpec(0x725, "HANK"),
 				DialogueLineSpec(0x729, "PC"),
diff --git a/engines/harvester/xfile_archive.cpp b/engines/harvester/xfile_archive.cpp
index d2cf5a99f4b..13600e009fa 100644
--- a/engines/harvester/xfile_archive.cpp
+++ b/engines/harvester/xfile_archive.cpp
@@ -62,6 +62,13 @@ bool XFileArchive::open(const Common::String &indexPath, const Common::String &d
 		return false;
 	}
 
+	_dataFile.reset(new Common::File());
+	if (!_dataFile->open(_dataPath)) {
+		debugC(1, kDebugResources, "Harvester: unable to open archive data %s", dataPath.c_str());
+		close();
+		return false;
+	}
+
 	const uint32 fileCount = indexStream->size() / kEntrySize;
 	for (uint32 i = 0; i < fileCount; ++i) {
 		if (indexStream->readUint32BE() != kSignatureXfle) {
@@ -97,6 +104,7 @@ bool XFileArchive::open(const Common::String &indexPath, const Common::String &d
 
 void XFileArchive::close() {
 	_dataPath.clear();
+	_dataFile.reset();
 	_entries.clear();
 }
 
@@ -130,26 +138,26 @@ Common::SeekableReadStream *XFileArchive::createReadStreamForMember(const Common
 }
 
 Common::SeekableReadStream *XFileArchive::openStoredEntry(const Entry &entry) const {
-	Common::File *dataFile = new Common::File();
-	if (!dataFile->open(_dataPath)) {
-		delete dataFile;
+	if (!_dataFile)
 		return nullptr;
-	}
 
-	return new Common::SeekableSubReadStream(dataFile, entry._archiveOffset, entry._archiveOffset + entry._storedSize, DisposeAfterUse::YES);
+	return new Common::SafeMutexedSeekableSubReadStream(
+		_dataFile.get(), entry._archiveOffset, entry._archiveOffset + entry._storedSize,
+		DisposeAfterUse::NO, _dataFileMutex);
 }
 
 Common::SeekableReadStream *XFileArchive::openPackedEntry(const Entry &entry) const {
-	Common::File dataFile;
-	if (!dataFile.open(_dataPath))
+	if (!_dataFile)
 		return nullptr;
 
-	dataFile.seek(entry._archiveOffset);
+	Common::ScopedPtr<Common::SeekableReadStream> dataStream(new Common::SafeMutexedSeekableSubReadStream(
+		_dataFile.get(), entry._archiveOffset, entry._archiveOffset + entry._storedSize,
+		DisposeAfterUse::NO, _dataFileMutex));
 
 	Common::Array<byte> compressed;
 	compressed.resize(entry._storedSize);
 	if (!compressed.empty())
-		dataFile.read(compressed.data(), compressed.size());
+		dataStream->read(compressed.data(), compressed.size());
 
 	byte *output = (byte *)malloc(entry._unpackedSize);
 	if (!output)
diff --git a/engines/harvester/xfile_archive.h b/engines/harvester/xfile_archive.h
index 4c231586288..f82486a251b 100644
--- a/engines/harvester/xfile_archive.h
+++ b/engines/harvester/xfile_archive.h
@@ -23,7 +23,10 @@
 #define HARVESTER_XFILE_ARCHIVE_H
 
 #include "common/archive.h"
+#include "common/file.h"
 #include "common/hashmap.h"
+#include "common/mutex.h"
+#include "common/ptr.h"
 
 namespace Harvester {
 
@@ -52,6 +55,8 @@ private:
 	Common::SeekableReadStream *openPackedEntry(const Entry &entry) const;
 
 	Common::Path _dataPath;
+	Common::ScopedPtr<Common::File> _dataFile;
+	mutable Common::Mutex _dataFileMutex;
 	typedef Common::HashMap<Common::Path, Entry, Common::Path::IgnoreCase_Hash, Common::Path::IgnoreCase_EqualTo> EntryMap;
 	EntryMap _entries;
 };


Commit: 21e7f3d249e13fb0cba5c9167d6ba871a2394221
    https://github.com/scummvm/scummvm/commit/21e7f3d249e13fb0cba5c9167d6ba871a2394221
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:51+02:00

Commit Message:
HARVESTER: Add Mom dialogue state

Add MomRoomDialogueState for Mom interaction state.

Changed paths:
    engines/harvester/startup_dialogue.cpp
    engines/harvester/startup_dialogue.h


diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
index 259653ef955..c50e6843fb7 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/startup_dialogue.cpp
@@ -90,6 +90,12 @@ struct DialogueLineSpec {
 	int headVariant;
 };
 
+struct DialogueLineEntry {
+	int wavId;
+	const char *speakerId;
+	int headVariant;
+};
+
 static const HankDialogueTopicLine kHankDialogueTopicLines[] = {
 	{ 0xd2, 0x725, "HANK", false },
 	{ 0xd6, 0x74f, "PC", false },
@@ -111,6 +117,52 @@ static const HankDialogueTopicLine kHankDialogueTopicLines[] = {
 	{ 0xf1, 0x8bf, "HANK", true }
 };
 
+static const DialogueLineEntry kMomIntroLines[] = {
+	{ 0x1dd7, "MOM", 0 },
+	{ 0x1ddc, "PC", 0 },
+	{ 0x1de0, "MOM", 0 },
+	{ 0x1de4, "PC", 0 }
+};
+
+static const DialogueLineEntry kMomCookiesLines[] = {
+	{ 0x1e18, "PC", 0 },
+	{ 0x1e1d, "MOM", 0 },
+	{ 0x1e21, "PC", 0 }
+};
+
+static const DialogueLineEntry kMomSparkyLines[] = {
+	{ 0x1ed7, "PC", 0 },
+	{ 0x1edb, "MOM", 0 },
+	{ 0x1ee1, "PC", 0 },
+	{ 0x1ee5, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomFatherLines[] = {
+	{ 0x1fc8, "PC", 0 },
+	{ 0x1fcc, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomFatherHeardDadMoanLines[] = {
+	{ 0x1fd3, "PC", 0 },
+	{ 0x1fd8, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomGoodCauseDay5Lines[] = {
+	{ 0x21a3, "PC", 0 },
+	{ 0x21a8, "MOM", 0 },
+	{ 0x21ad, "PC", 0 },
+	{ 0x21b2, "MOM", 0 },
+	{ 0x21b7, "PC", 0 }
+};
+
+static const int kMomPtaTopicResponseLines[] = { 0x125, 0x126, 0x127, 0x128 };
+static const int kMomCookingTopicResponseLines[] = { 0x131, 0x132, 0x133 };
+static const int kMomBakeSaleTopicResponseLines[] = { 0x145, 0x146, 0x147 };
+static const int kMomPottsdamTopicResponseLines[] = { 0x14b, 0x14c };
+static const int kMomMeatPlantTopicResponseLines[] = { 0x151, 0x152 };
+static const int kMomMoynahanTopicResponseLines[] = { 0x160, 0x161 };
+static const int kMomNewspaperFireTopicResponseLines[] = { 0x162, 0x163 };
+
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
 	const StartupText *startupText = engine.getStartupText();
 	if (!startupText || !fontName)
@@ -349,6 +401,7 @@ StartupDialogueSystem::StartupDialogueSystem(HarvesterEngine &engine, Common::Po
 
 void StartupDialogueSystem::resetRoomNpcDialogueState() {
 	_hankRoomDialogueState = HankRoomDialogueState();
+	_momRoomDialogueState = MomRoomDialogueState();
 	_sharedKarinKidnapedDialogueState = false;
 }
 
@@ -661,6 +714,17 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		return Common::kNoError;
 	};
 
+	auto playDialogueEntrySequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
+		for (uint i = 0; i < count; ++i) {
+			Common::Error lineError = playDialogueLineWithVariant(
+				lines[i].wavId, lines[i].speakerId, lines[i].headVariant);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		return Common::kNoError;
+	};
+
 	auto buildResponseMenuLayout = [&](const Common::String &responseLine,
 			Common::Array<DialogueResponseOptionLayout> &options, uint &totalRows) {
 		options.clear();
@@ -909,6 +973,286 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		}
 	};
 
+	auto matchesResponseLine = [&](const Common::String &selectedTopic, int responseLineIndex) {
+		const Common::String topicText = startupText->getDialogueResponseLine(responseLineIndex);
+		return !topicText.empty() && selectedTopic.equalsIgnoreCase(topicText);
+	};
+
+	auto matchesAnyResponseLine = [&](const Common::String &selectedTopic, const int *responseLineIndices,
+			uint responseLineCount) {
+		for (uint i = 0; i < responseLineCount; ++i) {
+			if (matchesResponseLine(selectedTopic, responseLineIndices[i]))
+				return true;
+		}
+
+		return false;
+	};
+
+	auto assignTopicBuffer = [&](Common::String &topicBuffer, int &topicBufferLineIndex,
+			int responseLineIndex, const char *label) {
+		topicBuffer = startupText->getDialogueResponseLine(responseLineIndex);
+		topicBufferLineIndex = responseLineIndex;
+
+		Common::Array<Common::String> topics;
+		splitDialogueMenuLine(topicBuffer, topics);
+		logDialogueMenuItems(label, responseLineIndex, topicBuffer, topics);
+	};
+
+	if (npc.npcName.equalsIgnoreCase("MOM")) {
+		const int currentStoryDayIndex = startupScript->getCurrentStoryDayIndex();
+		Common::String &momTopicBuffer = _momRoomDialogueState.currentTopicBuffer;
+		int &momTopicBufferLineIndex = _momRoomDialogueState.currentTopicBufferLineIndex;
+		auto runMomGoodbye = [&]() -> Common::Error {
+			if (!startupScript->getFlagValue("DAY_FLAG")) {
+				return playDialogueLine(0x256e, "MOM");
+			}
+
+			return playDialogueLine(0x2051, "MOM");
+		};
+
+		if (!usedItemName.empty()) {
+			if (usedItemName.equalsIgnoreCase("NOTE") ||
+					usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+					usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+					usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+				return playDialogueLine(0x2317, "MOM");
+			}
+			if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+					usedItemName.equalsIgnoreCase("LEDGER2")) &&
+					startupScript->getFlagValue("HAVE_BOTH_LEDGERS")) {
+				return playDialogueLine(0x2320, "MOM");
+			}
+			if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+					usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+				return playDialogueLine(0x233b, "PC");
+			}
+			if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL"))
+				return playDialogueLine(0x239c, "MOM");
+			if (usedItemName.equalsIgnoreCase("TV_DEED") ||
+					usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
+				return playDialogueLine(0x2382, "MOM");
+			}
+
+			return playDialogueLine(0x26cc, "MOM");
+		}
+
+		if (_momRoomDialogueState.introPending) {
+			_momRoomDialogueState.introPending = false;
+			_momRoomDialogueState.sameDayIntroLineEnabled = true;
+			_momRoomDialogueState.postIntroDefaultLineEnabled = true;
+			_momRoomDialogueState.introDayIndex = currentStoryDayIndex;
+			Common::Error lineError = playDialogueEntrySequence(kMomIntroLines, ARRAYSIZE(kMomIntroLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (_momRoomDialogueState.sameDayIntroLineEnabled &&
+				currentStoryDayIndex == _momRoomDialogueState.introDayIndex) {
+			Common::Error lineError = playDialogueLine(0x2047, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (_momRoomDialogueState.postIntroDefaultLineEnabled) {
+			Common::Error lineError = playDialogueLine(0x2311, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (startupScript->getFlagValue("STEPH_MIDGAME_PLAYED") &&
+				!_momRoomDialogueState.stephMidgameShown) {
+			_momRoomDialogueState.stephMidgameShown = true;
+			Common::Error lineError = playDialogueLine(0x205a, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (startupScript->getFlagValue("DINER_BURNED") &&
+				(startupScript->getFlagValue("KARIN_KIDNAPED") ||
+					startupScript->getFlagValue("KARIN_FOUND_DEAD")) &&
+				!_momRoomDialogueState.dinerBurnedKarinMissingOrDeadShown) {
+			_momRoomDialogueState.dinerBurnedKarinMissingOrDeadShown = true;
+			Common::Error lineError = playDialogueLine(0x2456, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (startupScript->getFlagValue("BURNED_TV_STATION") &&
+				!_momRoomDialogueState.burnedTvStationShown) {
+			_momRoomDialogueState.burnedTvStationShown = true;
+			Common::Error lineError = playDialogueLine(0x2220, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (startupScript->getFlagValue("SCRATCHED_TUCKER") &&
+				!_momRoomDialogueState.scratchedTuckerShown) {
+			_momRoomDialogueState.scratchedTuckerShown = true;
+			Common::Error lineError = playDialogueLine(0x23f3, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		// Native Mom dialogue also has death-damage-gated barks for the firemen and several dead NPCs.
+		// The startup runtime does not expose that persisted state yet, so those branches remain pending.
+		if (startupScript->getFlagValue("DINER_BURNED") &&
+				startupScript->getFlagValue("KARIN_FOUND_ALIVE") &&
+				!_momRoomDialogueState.dinerBurnedKarinAliveShown) {
+			_momRoomDialogueState.dinerBurnedKarinAliveShown = true;
+			Common::Error lineError = playDialogueLine(0x2434, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (startupScript->getFlagValue("PC_ESCAPED_JAIL") &&
+				!_momRoomDialogueState.escapedJailShown) {
+			_momRoomDialogueState.escapedJailShown = true;
+			Common::Error lineError = playDialogueLine(0x2495, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (startupScript->getFlagValue("KARIN_KIDNAPED") &&
+				!startupScript->getFlagValue("KARIN_FOUND_DEAD") &&
+				!startupScript->getFlagValue("KARIN_FOUND_ALIVE") &&
+				!_momRoomDialogueState.karinKidnapedUnresolvedShown) {
+			_momRoomDialogueState.karinKidnapedUnresolvedShown = true;
+			Common::Error lineError = playDialogueLine(0x24d7, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (startupScript->getFlagValue("KARIN_FOUND_ALIVE") &&
+				!_momRoomDialogueState.karinFoundAliveShown) {
+			_momRoomDialogueState.karinFoundAliveShown = true;
+			Common::Error lineError = playDialogueLine(0x2505, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (startupScript->getFlagValue("KARIN_FOUND_DEAD") &&
+				!_momRoomDialogueState.karinFoundDeadShown) {
+			_momRoomDialogueState.karinFoundDeadShown = true;
+			Common::Error lineError = playDialogueLine(0x2576, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (startupScript->getFlagValue("STEPHANIE_IS_DEAD") &&
+				!startupScript->getFlagValue("STEPH_MIDGAME_PLAYED") &&
+				!_momRoomDialogueState.stephanieDeadPreMidgameShown) {
+			_momRoomDialogueState.stephanieDeadPreMidgameShown = true;
+			Common::Error lineError = playDialogueLine(0x217c, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (startupScript->getFlagValue("DAY_5") && !_momRoomDialogueState.day5Shown) {
+			_momRoomDialogueState.day5Shown = true;
+			Common::Error lineError = playDialogueLine(0x218d, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (startupScript->getFlagValue("DAY_6") && !_momRoomDialogueState.day6Shown) {
+			_momRoomDialogueState.day6Shown = true;
+			Common::Error lineError = playDialogueLine(0x22a8, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		for (;;) {
+			Common::String selectedTopic;
+			Common::Error menuError = runKeywordMenu(momTopicBuffer, momTopicBufferLineIndex, selectedTopic);
+			if (menuError.getCode() != Common::kNoError)
+				return menuError;
+			if (selectedTopic.empty())
+				return Common::kNoError;
+			if (selectedTopic.equalsIgnoreCase(genericByeTopic))
+				return runMomGoodbye();
+
+			Common::Error lineError = Common::kNoError;
+			if (matchesResponseLine(selectedTopic, 0x116)) {
+				lineError = playDialogueEntrySequence(kMomCookiesLines, ARRAYSIZE(kMomCookiesLines));
+			} else if (matchesResponseLine(selectedTopic, 0x119)) {
+				lineError = playDialogueLine(0x1e47, "PC");
+			} else if (matchesResponseLine(selectedTopic, 0x11d)) {
+				lineError = playDialogueLine(0x1e7c, "PC");
+			} else if (matchesResponseLine(selectedTopic, 0x121)) {
+				lineError = playDialogueLine(0x1ec0, "PC");
+			} else if (matchesResponseLine(selectedTopic, 0x123)) {
+				lineError = playDialogueEntrySequence(kMomSparkyLines, ARRAYSIZE(kMomSparkyLines));
+			} else if (matchesAnyResponseLine(selectedTopic, kMomPtaTopicResponseLines,
+					ARRAYSIZE(kMomPtaTopicResponseLines))) {
+				lineError = playDialogueLine(0x1eee, "PC");
+			} else if (matchesResponseLine(selectedTopic, 0x12b)) {
+				lineError = playDialogueLine(0x1efd, "PC");
+			} else if (matchesResponseLine(selectedTopic, 0x12d)) {
+				lineError = playDialogueLine(0x1f14, "PC");
+			} else if (matchesResponseLine(selectedTopic, 0x12f)) {
+				lineError = playDialogueLine(0x1f41, "PC");
+			} else if (matchesAnyResponseLine(selectedTopic, kMomCookingTopicResponseLines,
+					ARRAYSIZE(kMomCookingTopicResponseLines))) {
+				lineError = playDialogueLine(0x1f58, "PC");
+			} else if (matchesResponseLine(selectedTopic, 0x135)) {
+				lineError = playDialogueLine(0x1f71, "PC");
+			} else if (matchesResponseLine(selectedTopic, 0x137)) {
+				lineError = playDialogueLine(0x1f7f, "PC");
+			} else if (matchesResponseLine(selectedTopic, 0x13a)) {
+				lineError = playDialogueLine(0x1fa3, "MOM");
+			} else if (matchesResponseLine(selectedTopic, 0x13c)) {
+				lineError = playDialogueLine(0x1faf, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			} else if (matchesResponseLine(selectedTopic, 0x13f)) {
+				_momRoomDialogueState.fatherTopicState = true;
+				lineError = playDialogueEntrySequence(kMomFatherLines, ARRAYSIZE(kMomFatherLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				if (startupScript->getFlagValue("HEARD_DAD_MOAN")) {
+					lineError = playDialogueEntrySequence(kMomFatherHeardDadMoanLines,
+						ARRAYSIZE(kMomFatherHeardDadMoanLines));
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				}
+				assignTopicBuffer(momTopicBuffer, momTopicBufferLineIndex, 0x140, "Mom topic buffer");
+				continue;
+			} else if (matchesResponseLine(selectedTopic, 0x141)) {
+				if (startupScript->getFlagValue("STEPH_MIDGAME_PLAYED"))
+					lineError = playDialogueLine(0x2154, "PC");
+				else
+					lineError = playDialogueLine(0x1fe1, "PC");
+			} else if (matchesAnyResponseLine(selectedTopic, kMomBakeSaleTopicResponseLines,
+					ARRAYSIZE(kMomBakeSaleTopicResponseLines))) {
+				lineError = playDialogueLine(0x2017, "PC");
+			} else if (matchesResponseLine(selectedTopic, 0x14a)) {
+				lineError = playDialogueLine(0x2036, "MOM");
+			} else if (matchesAnyResponseLine(selectedTopic, kMomPottsdamTopicResponseLines,
+					ARRAYSIZE(kMomPottsdamTopicResponseLines))) {
+				if (startupScript->getFlagValue("STEPH_MIDGAME_PLAYED"))
+					lineError = playDialogueLine(0x2086, "MOM");
+			} else if (matchesAnyResponseLine(selectedTopic, kMomMeatPlantTopicResponseLines,
+					ARRAYSIZE(kMomMeatPlantTopicResponseLines))) {
+				lineError = playDialogueLine(0x20f7, "PC");
+			} else if (matchesResponseLine(selectedTopic, 0x155)) {
+				lineError = playDialogueLine(0x2125, "MOM");
+			} else if (matchesResponseLine(selectedTopic, 0x156)) {
+				lineError = playDialogueLine(0x2142, "MOM");
+			} else if (matchesResponseLine(selectedTopic, 0x15b) && currentStoryDayIndex == 5) {
+				if (_momRoomDialogueState.goodCauseDay5State)
+					lineError = playDialogueEntrySequence(kMomGoodCauseDay5Lines, ARRAYSIZE(kMomGoodCauseDay5Lines));
+				else
+					lineError = playDialogueLine(0x21dd, "PC");
+			} else if (matchesAnyResponseLine(selectedTopic, kMomMoynahanTopicResponseLines,
+					ARRAYSIZE(kMomMoynahanTopicResponseLines))) {
+				lineError = playDialogueLine(0x220a, "PC");
+			} else if (matchesAnyResponseLine(selectedTopic, kMomNewspaperFireTopicResponseLines,
+					ARRAYSIZE(kMomNewspaperFireTopicResponseLines))) {
+				lineError = playDialogueLine(0x229e, "MOM");
+			} else if (matchesResponseLine(selectedTopic, 0x16d)) {
+				lineError = playDialogueLine(0x2696, "MOM");
+			} else if (matchesResponseLine(selectedTopic, 0x16e)) {
+				lineError = playDialogueLine(0x26a3, "MOM");
+			} else if (matchesResponseLine(selectedTopic, 0x170)) {
+				lineError = playDialogueLine(0x26bc, "MOM");
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			lineError = playDialogueLine(0x26c6, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			return runMomGoodbye();
+		}
+	}
+
 	if (!npc.npcName.equalsIgnoreCase("HANK")) {
 		debug(1, "Harvester: unsupported room NPC dialogue handler '%s'", npc.npcName.c_str());
 		return Common::kNoError;
@@ -918,12 +1262,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 	Common::String &hankTopicBuffer = _hankRoomDialogueState.currentTopicBuffer;
 	int &hankTopicBufferLineIndex = _hankRoomDialogueState.currentTopicBufferLineIndex;
 	auto assignHankTopicBuffer = [&](int responseLineIndex) {
-		hankTopicBuffer = startupText->getDialogueResponseLine(responseLineIndex);
-		hankTopicBufferLineIndex = responseLineIndex;
-
-		Common::Array<Common::String> topics;
-		splitDialogueMenuLine(hankTopicBuffer, topics);
-		logDialogueMenuItems("Hank topic buffer", responseLineIndex, hankTopicBuffer, topics);
+		assignTopicBuffer(hankTopicBuffer, hankTopicBufferLineIndex, responseLineIndex, "Hank topic buffer");
 	};
 
 	debugC(1, kDebugDialogue,
diff --git a/engines/harvester/startup_dialogue.h b/engines/harvester/startup_dialogue.h
index 44400086a4c..d6a3a90b1c8 100644
--- a/engines/harvester/startup_dialogue.h
+++ b/engines/harvester/startup_dialogue.h
@@ -58,6 +58,29 @@ public:
 		int currentTopicBufferLineIndex = -1;
 	};
 
+	struct MomRoomDialogueState {
+		bool introPending = true;
+		bool sameDayIntroLineEnabled = false;
+		bool postIntroDefaultLineEnabled = false;
+		int introDayIndex = 0;
+		bool stephMidgameShown = false;
+		bool dinerBurnedKarinMissingOrDeadShown = false;
+		bool burnedTvStationShown = false;
+		bool scratchedTuckerShown = false;
+		bool dinerBurnedKarinAliveShown = false;
+		bool escapedJailShown = false;
+		bool karinKidnapedUnresolvedShown = false;
+		bool karinFoundAliveShown = false;
+		bool karinFoundDeadShown = false;
+		bool stephanieDeadPreMidgameShown = false;
+		bool day5Shown = false;
+		bool day6Shown = false;
+		bool fatherTopicState = false;
+		bool goodCauseDay5State = false;
+		Common::String currentTopicBuffer;
+		int currentTopicBufferLineIndex = -1;
+	};
+
 	StartupDialogueSystem(HarvesterEngine &engine, Common::Point &mousePos);
 
 	void resetRoomNpcDialogueState();
@@ -69,6 +92,7 @@ private:
 	HarvesterEngine &_engine;
 	Common::Point &_mousePos;
 	HankRoomDialogueState _hankRoomDialogueState;
+	MomRoomDialogueState _momRoomDialogueState;
 	bool _sharedKarinKidnapedDialogueState = false;
 };
 


Commit: 7a439574e10989a73da9ff46ccf07525d27f65f8
    https://github.com/scummvm/scummvm/commit/7a439574e10989a73da9ff46ccf07525d27f65f8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:52+02:00

Commit Message:
HARVESTER: Mirror npc death state for mom dialogue

Changed paths:
    engines/harvester/startup_dialogue.cpp
    engines/harvester/startup_dialogue.h
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
index c50e6843fb7..c6d8023a7b7 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/startup_dialogue.cpp
@@ -1006,6 +1006,8 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			if (!startupScript->getFlagValue("DAY_FLAG")) {
 				return playDialogueLine(0x256e, "MOM");
 			}
+			if (!startupScript->isNamedNpcDeathTypeClear("JIMMY"))
+				return Common::kNoError;
 
 			return playDialogueLine(0x2051, "MOM");
 		};
@@ -1086,8 +1088,26 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
-		// Native Mom dialogue also has death-damage-gated barks for the firemen and several dead NPCs.
-		// The startup runtime does not expose that persisted state yet, so those branches remain pending.
+		if (startupScript->getFlagValue("BARBER_POLE_STOLEN") &&
+				!_momRoomDialogueState.barberPoleStolenShown) {
+			_momRoomDialogueState.barberPoleStolenShown = true;
+			Common::Error lineError = playDialogueLine(0x23fb, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (startupScript->getFlagValue("BOLT_OF_CLOTH_TAKEN") &&
+				!_momRoomDialogueState.boltOfClothTakenShown) {
+			_momRoomDialogueState.boltOfClothTakenShown = true;
+			if (startupScript->isNamedNpcDeathTypeClear("SPARKY") &&
+					startupScript->isNamedNpcDeathTypeClear("FIREMAN2")) {
+				Common::Error lineError = playDialogueLine(0x2416, "MOM");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			Common::Error lineError = playDialogueLine(0x2420, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
 		if (startupScript->getFlagValue("DINER_BURNED") &&
 				startupScript->getFlagValue("KARIN_FOUND_ALIVE") &&
 				!_momRoomDialogueState.dinerBurnedKarinAliveShown) {
@@ -1126,6 +1146,39 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
+		if (!startupScript->isNamedNpcDeathTypeClear("BUTCHER") &&
+				!_momRoomDialogueState.butcherAbsentShown) {
+			_momRoomDialogueState.butcherAbsentShown = true;
+			Common::Error lineError = playDialogueLine(0x2633, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (!startupScript->isNamedNpcDeathTypeClear("MOYNAHAN") &&
+				!_momRoomDialogueState.moynahanAbsentShown) {
+			_momRoomDialogueState.moynahanAbsentShown = true;
+			Common::Error lineError = playDialogueLine(0x2647, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (!startupScript->isNamedNpcDeathTypeClear("JIMMY") &&
+				!_momRoomDialogueState.jimmyAbsentShown) {
+			_momRoomDialogueState.jimmyAbsentShown = true;
+			if (startupScript->getFlagValue("JIMMY_ATTACKED")) {
+				Common::Error lineError = playDialogueLine(0x2659, "MOM");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			Common::Error lineError = playDialogueLine(0x266c, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (!startupScript->isNamedNpcDeathTypeClear("WASP_WOMAN") &&
+				!_momRoomDialogueState.waspWomanAbsentShown) {
+			_momRoomDialogueState.waspWomanAbsentShown = true;
+			Common::Error lineError = playDialogueLine(0x2689, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
 		if (startupScript->getFlagValue("STEPHANIE_IS_DEAD") &&
 				!startupScript->getFlagValue("STEPH_MIDGAME_PLAYED") &&
 				!_momRoomDialogueState.stephanieDeadPreMidgameShown) {
diff --git a/engines/harvester/startup_dialogue.h b/engines/harvester/startup_dialogue.h
index d6a3a90b1c8..243e6b7fc8d 100644
--- a/engines/harvester/startup_dialogue.h
+++ b/engines/harvester/startup_dialogue.h
@@ -67,11 +67,17 @@ public:
 		bool dinerBurnedKarinMissingOrDeadShown = false;
 		bool burnedTvStationShown = false;
 		bool scratchedTuckerShown = false;
+		bool barberPoleStolenShown = false;
+		bool boltOfClothTakenShown = false;
 		bool dinerBurnedKarinAliveShown = false;
 		bool escapedJailShown = false;
 		bool karinKidnapedUnresolvedShown = false;
 		bool karinFoundAliveShown = false;
 		bool karinFoundDeadShown = false;
+		bool butcherAbsentShown = false;
+		bool moynahanAbsentShown = false;
+		bool jimmyAbsentShown = false;
+		bool waspWomanAbsentShown = false;
 		bool stephanieDeadPreMidgameShown = false;
 		bool day5Shown = false;
 		bool day6Shown = false;
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 406c1d4ace4..84627e986fd 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -75,6 +75,17 @@ static int parseEntranceFacing(const Common::String &direction) {
 	return -1;
 }
 
+static int parseNpcDeathDamageType(const Common::String &value) {
+	if (value.equalsIgnoreCase("BLUDGE"))
+		return 1;
+	if (value.equalsIgnoreCase("SLASH"))
+		return 2;
+	if (value.equalsIgnoreCase("PROJ"))
+		return 4;
+
+	return 0;
+}
+
 static bool appendStartupAudioCommand(const StartupCommandRecord &command, Common::Array<StartupAudioCommand> &commands) {
 	if (command.opcodeName.equalsIgnoreCase("START_WAV")) {
 		StartupAudioCommand audioCommand;
@@ -840,6 +851,18 @@ StartupNpcRecord *StartupScript::findRuntimeNpc(const Common::String &npcName) {
 	return nullptr;
 }
 
+const StartupNpcRecord *StartupScript::findRuntimeNpc(const Common::String &npcName) const {
+	if (npcName.empty())
+		return nullptr;
+
+	for (const StartupNpcRecord &npc : _runtimeNpcs) {
+		if (npc.npcName.equalsIgnoreCase(npcName))
+			return &npc;
+	}
+
+	return nullptr;
+}
+
 bool StartupScript::addRuntimeObjectToInventory(const Common::String &objectName) {
 	StartupObjectRecord *runtimeObject = findRuntimeObject(Common::String(), objectName);
 	if (!runtimeObject)
@@ -1097,6 +1120,27 @@ void StartupScript::executeCommandChain(const Common::String &initialTag, const
 			continue;
 		}
 
+		if (command->opcodeName.equalsIgnoreCase("KILL_NPC") ||
+				command->opcodeName.equalsIgnoreCase("MONSTERFY")) {
+			StartupNpcRecord *runtimeNpc = findRuntimeNpc(command->arg1);
+			if (!runtimeNpc) {
+				debug(1, "Harvester: unresolved npc for %s '%s' npc='%s'",
+					contextLabel, contextName.c_str(), command->arg1.c_str());
+				currentTag = command->arg4;
+				continue;
+			}
+
+			const int deathDamageType = parseNpcDeathDamageType(command->arg2);
+			const bool changed = !runtimeNpc->deathOrMonsterfyFlag ||
+				(deathDamageType != 0 && runtimeNpc->deathDamageType != deathDamageType);
+			runtimeNpc->deathOrMonsterfyFlag = true;
+			if (deathDamageType != 0)
+				runtimeNpc->deathDamageType = deathDamageType;
+			noteMutation(changed);
+			currentTag = command->arg4;
+			continue;
+		}
+
 		if (command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
 			if (allowTransitions && nextRoomName)
@@ -1222,6 +1266,11 @@ bool StartupScript::getFlagValue(const Common::String &flagName) const {
 	return flag && flag->value;
 }
 
+bool StartupScript::isNamedNpcDeathTypeClear(const Common::String &npcName) const {
+	const StartupNpcRecord *npc = findRuntimeNpc(npcName);
+	return npc && npc->deathDamageType == 0;
+}
+
 int StartupScript::getCurrentStoryDayIndex() const {
 	if (getFlagValue("DAY_1"))
 		return 1;
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 70b45c04bd7..a78aa45e61d 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -115,10 +115,12 @@ struct StartupNpcRecord {
 	Common::String npcName;
 	Common::String monsterfyTargetName;
 	Common::String roomName;
+	bool deathOrMonsterfyFlag = false;
 	bool runtimeSpawned = false;
 	bool active = false;
 	bool visible = false;
 	bool savedVisible = false;
+	int deathDamageType = 0;
 	Common::String audioPath;
 	Common::String entityInitArg;
 };
@@ -272,6 +274,7 @@ public:
 	Common::String resolveTextValue(const Common::String &key) const;
 	const StartupHeadRecord *findHeadRecord(const Common::String &headId) const;
 	bool getFlagValue(const Common::String &flagName) const;
+	bool isNamedNpcDeathTypeClear(const Common::String &npcName) const;
 	int getCurrentStoryDayIndex() const;
 
 private:
@@ -288,6 +291,7 @@ private:
 	StartupObjectRecord *findRuntimeObject(const Common::String &ownerOrRoom, const Common::String &objectName);
 	StartupAnimRecord *findRuntimeAnim(const Common::String &animName);
 	StartupNpcRecord *findRuntimeNpc(const Common::String &npcName);
+	const StartupNpcRecord *findRuntimeNpc(const Common::String &npcName) const;
 	bool addRuntimeObjectToInventory(const Common::String &objectName);
 	bool buildRuntimeRoomState(const StartupRoomRecord &room, const StartupEntranceRecord *entrance,
 		StartupRoomSetupState &state) const;


Commit: 708005505fdcb8fa5b053772ecbdd1ce8d47f4b3
    https://github.com/scummvm/scummvm/commit/708005505fdcb8fa5b053772ecbdd1ce8d47f4b3
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:52+02:00

Commit Message:
HARVESTER: Add Jimmy dialogue state

Add JimmyRoomDialogueState for Jimmy NPC interactions.

Changed paths:
    engines/harvester/startup_dialogue.cpp
    engines/harvester/startup_dialogue.h
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_flow.h
    engines/harvester/startup_room.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
index c6d8023a7b7..556941ab72a 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/startup_dialogue.cpp
@@ -402,6 +402,7 @@ StartupDialogueSystem::StartupDialogueSystem(HarvesterEngine &engine, Common::Po
 void StartupDialogueSystem::resetRoomNpcDialogueState() {
 	_hankRoomDialogueState = HankRoomDialogueState();
 	_momRoomDialogueState = MomRoomDialogueState();
+	_jimmyRoomDialogueState = JimmyRoomDialogueState();
 	_sharedKarinKidnapedDialogueState = false;
 }
 
@@ -998,6 +999,117 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		logDialogueMenuItems(label, responseLineIndex, topicBuffer, topics);
 	};
 
+	if (npc.npcName.equalsIgnoreCase("JIMMY")) {
+		auto playJimmyLine = [&](int wavId, int headVariant) -> Common::Error {
+			return playDialogueLineWithVariant(wavId, "JIMMY", headVariant);
+		};
+		auto hasInventoryItem = [&](const char *objectName) {
+			Common::Array<StartupObjectRecord> inventoryObjects;
+			startupScript->getVisibleInventoryObjects(inventoryObjects);
+			for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+				if (inventoryObject.objectName.equalsIgnoreCase(objectName))
+					return true;
+			}
+
+			return false;
+		};
+
+		if (!usedItemName.empty()) {
+			if (usedItemName.equalsIgnoreCase("NEWSPAPER")) {
+				StartupInteractionResult jimmyInteraction;
+				const bool changedGivenPaperToday =
+					startupScript->setRuntimeFlagValue("GIVEN_PAPER_TODAY", true);
+				const bool changedNewspaperState =
+					startupScript->resetRuntimeObjectToInitialState("NEWSPAPER");
+				jimmyInteraction.mutatedRuntimeState = changedGivenPaperToday || changedNewspaperState;
+
+				StartupInteractionResult actionInteraction;
+				if (startupScript->executeActionTag("ACTV_HOUSE_EXIT", actionInteraction)) {
+					jimmyInteraction.mutatedRuntimeState =
+						jimmyInteraction.mutatedRuntimeState || actionInteraction.mutatedRuntimeState;
+					if (!actionInteraction.musicPath.empty())
+						jimmyInteraction.musicPath = actionInteraction.musicPath;
+					if (!actionInteraction.nextRoomName.empty())
+						jimmyInteraction.nextRoomName = actionInteraction.nextRoomName;
+					for (const StartupAudioCommand &command : actionInteraction.audioCommands)
+						jimmyInteraction.audioCommands.push_back(command);
+				}
+				if (jimmyInteraction.mutatedRuntimeState || !jimmyInteraction.musicPath.empty() ||
+						!jimmyInteraction.nextRoomName.empty() || !jimmyInteraction.audioCommands.empty()) {
+					startupFlow.queueDialogueInteraction(jimmyInteraction);
+				}
+
+				if (!_jimmyRoomDialogueState.paperHandoffStateSet) {
+					_jimmyRoomDialogueState.paperHandoffStateSet = true;
+					Common::Error lineError = playJimmyLine(0x4a4c, 1);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				}
+
+				return _engine.getRandomNumber(1) == 0
+					? playJimmyLine(0x4acc, 0)
+					: playJimmyLine(0x4a4b, 1);
+			}
+			if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL"))
+				return playJimmyLine(0x4af8, 1);
+			if (((usedItemName.equalsIgnoreCase("LEDGER") ||
+						usedItemName.equalsIgnoreCase("LEDGER2")) &&
+					startupScript->getFlagValue("HAVE_BOTH_LEDGERS")) ||
+					usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+					usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+				return playJimmyLine(0x4b00, 0);
+			}
+			if (usedItemName.equalsIgnoreCase("NOTE") ||
+					usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+					usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+					usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+				return playJimmyLine(0x4b21, 0);
+			}
+			if (usedItemName.equalsIgnoreCase("SNEAKERS")) {
+				Common::Error lineError = playJimmyLine(0x4a9e, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			return playJimmyLine(0x4af2, 0);
+		}
+
+		if (_jimmyRoomDialogueState.firstNoItemLinePending) {
+			_jimmyRoomDialogueState.firstNoItemLinePending = false;
+			if (!_jimmyRoomDialogueState.paperHandoffStateSet) {
+				Common::Error lineError = playJimmyLine(0x4a4c, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			Common::Error lineError = playJimmyLine(0x4a58, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (hasInventoryItem("SNEAKERS") && !hasInventoryItem("BROOMKEY")) {
+			Common::Error lineError = playJimmyLine(0x4ac3, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (startupScript->getFlagValue("PAPER_CHK_4")) {
+			Common::Error lineError = playJimmyLine(0x4ae2, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (startupScript->getFlagValue("PAPER_CHK_3")) {
+			Common::Error lineError = playJimmyLine(0x4adb, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (startupScript->getFlagValue("PAPER_CHK_2")) {
+			Common::Error lineError = playJimmyLine(0x4ad4, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		return playJimmyLine(0x4b38, 0);
+	}
+
 	if (npc.npcName.equalsIgnoreCase("MOM")) {
 		const int currentStoryDayIndex = startupScript->getCurrentStoryDayIndex();
 		Common::String &momTopicBuffer = _momRoomDialogueState.currentTopicBuffer;
diff --git a/engines/harvester/startup_dialogue.h b/engines/harvester/startup_dialogue.h
index 243e6b7fc8d..cf851da3e8b 100644
--- a/engines/harvester/startup_dialogue.h
+++ b/engines/harvester/startup_dialogue.h
@@ -58,7 +58,7 @@ public:
 		int currentTopicBufferLineIndex = -1;
 	};
 
-	struct MomRoomDialogueState {
+		struct MomRoomDialogueState {
 		bool introPending = true;
 		bool sameDayIntroLineEnabled = false;
 		bool postIntroDefaultLineEnabled = false;
@@ -83,9 +83,14 @@ public:
 		bool day6Shown = false;
 		bool fatherTopicState = false;
 		bool goodCauseDay5State = false;
-		Common::String currentTopicBuffer;
-		int currentTopicBufferLineIndex = -1;
-	};
+			Common::String currentTopicBuffer;
+			int currentTopicBufferLineIndex = -1;
+		};
+
+		struct JimmyRoomDialogueState {
+			bool firstNoItemLinePending = true;
+			bool paperHandoffStateSet = false;
+		};
 
 	StartupDialogueSystem(HarvesterEngine &engine, Common::Point &mousePos);
 
@@ -96,11 +101,12 @@ public:
 
 private:
 	HarvesterEngine &_engine;
-	Common::Point &_mousePos;
-	HankRoomDialogueState _hankRoomDialogueState;
-	MomRoomDialogueState _momRoomDialogueState;
-	bool _sharedKarinKidnapedDialogueState = false;
-};
+		Common::Point &_mousePos;
+		HankRoomDialogueState _hankRoomDialogueState;
+		MomRoomDialogueState _momRoomDialogueState;
+		JimmyRoomDialogueState _jimmyRoomDialogueState;
+		bool _sharedKarinKidnapedDialogueState = false;
+	};
 
 } // End of namespace Harvester
 
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 3de90af74cb..fe2082dd4a8 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -1761,9 +1761,26 @@ Common::Error StartupFlow::runRoomMenuStub(const IndexedBitmap &backdrop) {
 
 Common::Error StartupFlow::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, const StartupNpcRecord &npc, const Common::String &usedItemName) {
+	_queuedDialogueInteraction = StartupInteractionResult();
+	_hasQueuedDialogueInteraction = false;
 	return _dialogue.runRoomNpcDialogue(backdrop, palette, paletteBrightness, npc, usedItemName, *this);
 }
 
+void StartupFlow::queueDialogueInteraction(const StartupInteractionResult &interaction) {
+	_queuedDialogueInteraction = interaction;
+	_hasQueuedDialogueInteraction = true;
+}
+
+bool StartupFlow::takeQueuedDialogueInteraction(StartupInteractionResult &interaction) {
+	if (!_hasQueuedDialogueInteraction)
+		return false;
+
+	interaction = _queuedDialogueInteraction;
+	_queuedDialogueInteraction = StartupInteractionResult();
+	_hasQueuedDialogueInteraction = false;
+	return true;
+}
+
 Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 	return _room.runRoomLoop(*this, entranceName);
 }
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/startup_flow.h
index 885e0916f48..5a29a20b321 100644
--- a/engines/harvester/startup_flow.h
+++ b/engines/harvester/startup_flow.h
@@ -67,23 +67,27 @@ private:
 		const Common::Array<StartupObjectRecord> &drawableObjects,
 		const Common::Array<StartupAnimRecord> &drawableAnimations);
 	Common::Error beginRoomSetupTransition();
-	Common::Error fadeInRoomScene(const byte *palette, float targetBrightness);
-	bool pumpTransitionEvents(Common::Error &result);
-	void executeStartupAudioCommands(const Common::Array<StartupAudioCommand> &commands);
-	void resetRoomNpcDialogueState();
-	void resetCursorAnimationSequence();
-	bool tickRuntimeEntities();
-	bool handleSystemEvent(const Common::Event &event, Common::Error &result);
+		Common::Error fadeInRoomScene(const byte *palette, float targetBrightness);
+		bool pumpTransitionEvents(Common::Error &result);
+		void executeStartupAudioCommands(const Common::Array<StartupAudioCommand> &commands);
+		void queueDialogueInteraction(const StartupInteractionResult &interaction);
+		bool takeQueuedDialogueInteraction(StartupInteractionResult &interaction);
+		void resetRoomNpcDialogueState();
+		void resetCursorAnimationSequence();
+		bool tickRuntimeEntities();
+		bool handleSystemEvent(const Common::Event &event, Common::Error &result);
 
 	HarvesterEngine &_engine;
 	Common::Array<Common::String> _quickTips;
 	Common::Array<Common::String> _menuItems;
 	Common::Point _mousePos;
-	StartupDialogueSystem _dialogue;
-	StartupInventorySystem _inventory;
-	StartupMenuSystem _menu;
-	StartupRoomSystem _room;
-};
+		StartupDialogueSystem _dialogue;
+		StartupInventorySystem _inventory;
+		StartupMenuSystem _menu;
+		StartupRoomSystem _room;
+		StartupInteractionResult _queuedDialogueInteraction;
+		bool _hasQueuedDialogueInteraction = false;
+	};
 
 } // End of namespace Harvester
 
diff --git a/engines/harvester/startup_room.cpp b/engines/harvester/startup_room.cpp
index 72d7abcd881..7cca7dfcb7d 100644
--- a/engines/harvester/startup_room.cpp
+++ b/engines/harvester/startup_room.cpp
@@ -428,11 +428,19 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 								*useHoverState.npc, _inventory.getSelectedItemName());
 							if (dialogueError.getCode() != Common::kNoError)
 								return dialogueError;
+							StartupInteractionResult dialogueInteraction;
+							const bool hasDialogueInteraction =
+								startupFlow.takeQueuedDialogueInteraction(dialogueInteraction);
 							if (_inventory.clearSelection() || _inventory.close())
 								needsRedraw = true;
+							if (hasDialogueInteraction) {
+								Common::Error interactionError = handleInteractionResult(dialogueInteraction);
+								if (interactionError.getCode() != Common::kNoError)
+									return interactionError;
+							}
 							startupFlow.resetCursorAnimationSequence();
 							resetIdleState();
-							needsRedraw = true;
+								needsRedraw = true;
 							break;
 						}
 						const StartupObjectRecord *roomTarget = useHoverState.object
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 84627e986fd..2a2e64e6eb4 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -688,6 +688,19 @@ bool StartupScript::resolveUseItemInteraction(const Common::String &itemName, co
 	return true;
 }
 
+bool StartupScript::executeActionTag(const Common::String &tag, StartupInteractionResult &result,
+		bool allowTransitions) {
+	result = StartupInteractionResult();
+	if (tag.empty())
+		return false;
+
+	executeCommandChain(tag, "action tag", tag, allowTransitions,
+		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.mutatedRuntimeState);
+
+	return !result.nextRoomName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
+		result.mutatedRuntimeState || hasActionableCommandChain(tag);
+}
+
 bool StartupScript::isPickupObject(const StartupObjectRecord &object) const {
 	return !object.altSpritePath.empty() &&
 		!object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName);
@@ -1266,6 +1279,60 @@ bool StartupScript::getFlagValue(const Common::String &flagName) const {
 	return flag && flag->value;
 }
 
+bool StartupScript::setRuntimeFlagValue(const Common::String &flagName, bool value) {
+	if (flagName.empty())
+		return false;
+
+	StartupFlagRecord *flag = findRuntimeFlag(flagName);
+	if (flag) {
+		const bool changed = flag->value != value;
+		flag->value = value;
+		return changed;
+	}
+
+	StartupFlagRecord newFlag;
+	newFlag.name = flagName;
+	newFlag.value = value;
+	_runtimeFlags.push_back(newFlag);
+	return true;
+}
+
+bool StartupScript::resetRuntimeObjectToInitialState(const Common::String &objectName) {
+	if (objectName.empty())
+		return false;
+
+	StartupObjectRecord *runtimeObject = findRuntimeObject(Common::String(), objectName);
+	if (!runtimeObject)
+		return false;
+
+	const StartupObjectRecord *baseObject = nullptr;
+	for (const StartupObjectRecord &object : _objects) {
+		if (object.objectName.equalsIgnoreCase(objectName)) {
+			baseObject = &object;
+			break;
+		}
+	}
+	if (!baseObject)
+		return false;
+
+	const bool changed = runtimeObject->currentX != runtimeObject->initialX ||
+		runtimeObject->currentY != runtimeObject->initialY ||
+		runtimeObject->currentZ != runtimeObject->initialZ ||
+		!runtimeObject->currentOwnerOrRoom.equalsIgnoreCase(runtimeObject->initialOwnerOrRoom) ||
+		runtimeObject->visible != baseObject->visible ||
+		runtimeObject->runtimeVisible != baseObject->visible ||
+		runtimeObject->identShown != baseObject->identShown;
+
+	runtimeObject->currentX = runtimeObject->initialX;
+	runtimeObject->currentY = runtimeObject->initialY;
+	runtimeObject->currentZ = runtimeObject->initialZ;
+	runtimeObject->currentOwnerOrRoom = runtimeObject->initialOwnerOrRoom;
+	runtimeObject->visible = baseObject->visible;
+	runtimeObject->runtimeVisible = baseObject->visible;
+	runtimeObject->identShown = baseObject->identShown;
+	return changed;
+}
+
 bool StartupScript::isNamedNpcDeathTypeClear(const Common::String &npcName) const {
 	const StartupNpcRecord *npc = findRuntimeNpc(npcName);
 	return npc && npc->deathDamageType == 0;
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index a78aa45e61d..b039c937ad2 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -259,23 +259,27 @@ public:
 		ResourceManager &resources);
 	bool materializeRoomState(const Common::String &entranceName, const Common::String &roomName,
 		StartupRoomSetupState &state) const;
-	bool executeRoomExitCommands(const Common::String &roomName, Common::Array<StartupAudioCommand> &audioCommands);
-	bool resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result);
-	bool resolveRegionInteraction(const StartupRegionRecord &region, StartupInteractionResult &result);
-	bool resolveUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target,
-		StartupInteractionResult &result);
-	bool isPickupObject(const StartupObjectRecord &object) const;
-	bool hasObjectInteraction(const StartupObjectRecord &object) const;
-	bool hasUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target) const;
-	void getVisibleInventoryObjects(Common::Array<StartupObjectRecord> &objects) const;
-	void markObjectIdentShown(const StartupObjectRecord &object);
-	bool resolveObjectInspectText(const StartupObjectRecord &object, StartupResolvedText &text) const;
-	Common::String resolveObjectLabel(const StartupObjectRecord &object) const;
-	Common::String resolveTextValue(const Common::String &key) const;
-	const StartupHeadRecord *findHeadRecord(const Common::String &headId) const;
-	bool getFlagValue(const Common::String &flagName) const;
-	bool isNamedNpcDeathTypeClear(const Common::String &npcName) const;
-	int getCurrentStoryDayIndex() const;
+		bool executeRoomExitCommands(const Common::String &roomName, Common::Array<StartupAudioCommand> &audioCommands);
+		bool resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result);
+		bool resolveRegionInteraction(const StartupRegionRecord &region, StartupInteractionResult &result);
+		bool resolveUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target,
+			StartupInteractionResult &result);
+		bool executeActionTag(const Common::String &tag, StartupInteractionResult &result,
+			bool allowTransitions = true);
+		bool isPickupObject(const StartupObjectRecord &object) const;
+		bool hasObjectInteraction(const StartupObjectRecord &object) const;
+		bool hasUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target) const;
+		void getVisibleInventoryObjects(Common::Array<StartupObjectRecord> &objects) const;
+		void markObjectIdentShown(const StartupObjectRecord &object);
+		bool resolveObjectInspectText(const StartupObjectRecord &object, StartupResolvedText &text) const;
+		Common::String resolveObjectLabel(const StartupObjectRecord &object) const;
+		Common::String resolveTextValue(const Common::String &key) const;
+		const StartupHeadRecord *findHeadRecord(const Common::String &headId) const;
+		bool getFlagValue(const Common::String &flagName) const;
+		bool setRuntimeFlagValue(const Common::String &flagName, bool value);
+		bool resetRuntimeObjectToInitialState(const Common::String &objectName);
+		bool isNamedNpcDeathTypeClear(const Common::String &npcName) const;
+		int getCurrentStoryDayIndex() const;
 
 private:
 	bool loadConfig(ResourceManager &resources);


Commit: b5a13ae9476f5a8b9b29149e75b222d112c887a7
    https://github.com/scummvm/scummvm/commit/b5a13ae9476f5a8b9b29149e75b222d112c887a7
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:52+02:00

Commit Message:
HARVESTER: Implement startup SET_REGION and START_DIALOG

Changed paths:
    TRACKER.md
    engines/harvester/startup_dialogue.cpp
    engines/harvester/startup_room.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/TRACKER.md b/TRACKER.md
index 42d391dc0ce..c3092429b77 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,42 +2,28 @@
 
 ## Current Focus
 
-- Ghidra-backed startup engine alignment from `run_harvester_main_loop`
-- Use the recovered render/entity runtime to remove raw actor offsets from the remaining startup/combat movement paths
-- Keep symbol recovery and engine changes tied to direct script data, call sites, and visible side effects
+- Ghidra-only recovery in `HARVEST.LE`
+- Continue scanning high-confidence `FUN_*` clusters from proven caller/callee paths
+- Prefer dialogue/text/NPC-state helpers tied to already-confirmed record layouts
 
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `774`
-- Named/documented: `468`
-- Still `FUN_*` / undocumented: `306`
+- Total functions: `887`
+- Named/documented: `565`
+- Still `FUN_*` / undocumented: `322`
 
 ## Last Confirmed Action
 
-- Recovered the shared render/entity runtime layout in Ghidra around `spawn_scaled_abm_entity_from_resource()`, `attach_abm_resource_to_entity()`, `hide_entity_visual()`, `tick_entity_visual_state()`, and `update_actor_runtime_state()`.
-  - Confirmed helper types:
-    - `BitmapBuffer` is the 12-byte runtime bitmap header `{ width, height, pixels }`.
-    - `AbmFrameHeader` is the packed ABM frame header prefix `{ x_offset, y_offset, width, height, compressed_flag, encoded_size }`.
-    - `DirtyRectNode` is the 20-byte dirty-rect list node `{ left, top, right, bottom, next }`.
-    - `ActorWaypoint[10]` is the 10-entry actor waypoint array at `RenderEntityRuntime + 0x10ac`.
-  - Confirmed `RenderEntityRuntime` fields now typed in Ghidra include:
-    - bitmap pointers and render placement at `+0x00 .. +0x48`
-    - animation timing/state at `+0x105c .. +0x1084`
-    - directional blocker history slots at `+0x108c`, `+0x1090`, `+0x1094`, `+0x1098`
-    - the current opaque overlap blocker at `+0x109c`
-    - movement/waypoint state at `+0x10a0 .. +0x1124`
-  - Switching the simple entity helpers to Borland `__fastcall` made the entity pointer resolve correctly in decompilation, which in turn exposed the blocker-history detour logic directly inside `update_actor_runtime_state()`.
-- No engine code changed in this pass.
-  - This was a Ghidra-first recovery pass to establish the runtime structure before any further startup/combat movement changes.
+- Recovered `SET_REGION` and `START_DIALOG` from `dispatch_room_event_actions @ 0x60ee0`, then aligned the startup script/runtime flow with the native continuation behavior.
+  - Confirmed `case 0x16` finds a `RegionRecord` by name, treats `arg2 == "F"` as the disable sentinel, updates `start_enabled`, and toggles the live room-region entity from that runtime state; ScummVM now keeps runtime region records and materializes room regions from them instead of the immutable parse-time table.
+  - Confirmed `case 0x1a` refreshes/fades the room palette if needed, calls `run_npc_dialogue(arg1, 0)`, then resumes at `arg4` unless the native post-dialogue abort flag trips; ScummVM now suspends `START_DIALOG`, runs the named NPC dialogue with no presented item, drains queued dialogue-side effects, and resumes the continuation tag afterward.
+  - Unified direct room-NPC clicks and scripted `START_DIALOG` through the same post-dialogue interaction path, so no-item dialogue handlers can now mutate startup state consistently.
+  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the dispatcher/room-loop changes.
 
 ## Next Suggested Action
 
-1. Recover `update_render_entity_screen_position()` and `set_entity_screen_position()` precisely enough to eliminate the remaining raw screen-anchor/dirty-rect offsets.
-  - The helper cluster is now on the right shared runtime struct, but those position-update helpers still have calling/storage oddities that prevent the decompiler from expressing all arguments cleanly.
-2. Continue typing the actor-only tail of `RenderEntityRuntime` from `+0x1134` through `+0x118e`.
-  - That range now clearly holds combat/use-state sample slots, attack timing, and facing-bank flags, and typing it would clean up both combat logic and the remaining locomotion detour states.
-3. Runtime-test `PCROOM`, `PCX1`, and `PCDRWR` against DOSBox with the recovered blocker-history model in mind.
-  - The startup stub can now be compared against confirmed native detour states instead of guessed movement constants.
-4. Confirm whether startup room records ever override the default `zVelocityStep = 1.0f`; the current startup parser still relies on the struct default rather than a recovered script field.
-5. Flesh out the room-menu stub now that `Esc` reaches the correct native entry point, especially the per-item behaviors inside `run_main_menu()` such as resume/save/load/options/quit gating.
+1. Recover the native post-dialogue abort flag checked immediately after `run_npc_dialogue` in `dispatch_room_event_actions @ 0x60ee0`.
+   - ScummVM now skips `START_DIALOG` continuations when the dialogue result already transitions rooms, but the DOS handler also has a separate `DAT_000d60bc` early-return path that still needs a confirmed engine-side mapping.
+2. Recover the monster-record replacement half of `MONSTERFY`.
+   - `DIAL_JIM_4_D -> MONST_JIM` is now reachable through the restored `START_DIALOG` continuation, but ScummVM still only sets the NPC death/monsterfy flag and suppresses the original NPC; the native monster-record spawn/replacement path is still missing.
diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
index 556941ab72a..212f325c177 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/startup_dialogue.cpp
@@ -1031,11 +1031,19 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 						jimmyInteraction.musicPath = actionInteraction.musicPath;
 					if (!actionInteraction.nextRoomName.empty())
 						jimmyInteraction.nextRoomName = actionInteraction.nextRoomName;
+					if (!actionInteraction.dialogueNpcName.empty())
+						jimmyInteraction.dialogueNpcName = actionInteraction.dialogueNpcName;
+					if (!actionInteraction.dialogueContinuationTag.empty()) {
+						jimmyInteraction.dialogueContinuationTag =
+							actionInteraction.dialogueContinuationTag;
+					}
 					for (const StartupAudioCommand &command : actionInteraction.audioCommands)
 						jimmyInteraction.audioCommands.push_back(command);
 				}
 				if (jimmyInteraction.mutatedRuntimeState || !jimmyInteraction.musicPath.empty() ||
-						!jimmyInteraction.nextRoomName.empty() || !jimmyInteraction.audioCommands.empty()) {
+						!jimmyInteraction.nextRoomName.empty() || !jimmyInteraction.dialogueNpcName.empty() ||
+						!jimmyInteraction.dialogueContinuationTag.empty() ||
+						!jimmyInteraction.audioCommands.empty()) {
 					startupFlow.queueDialogueInteraction(jimmyInteraction);
 				}
 
diff --git a/engines/harvester/startup_room.cpp b/engines/harvester/startup_room.cpp
index 7cca7dfcb7d..62dbaa39589 100644
--- a/engines/harvester/startup_room.cpp
+++ b/engines/harvester/startup_room.cpp
@@ -167,43 +167,114 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		startupFlow.executeStartupAudioCommands(exitAudioCommands);
 		return true;
 	};
-	auto handleInteractionResult = [&](const StartupInteractionResult &interaction) -> Common::Error {
-		playerState.hasMoveTarget = false;
-		playerState.turnActive = false;
-		playerState.turnTargetFacing = -1;
-		pendingRegionName.clear();
+	struct InteractionProcessor {
+		HarvesterEngine &engine;
+		StartupFlow &startupFlow;
+		StartupRoomSceneResources &scene;
+		StartupRoomPlayerState &playerState;
+		Common::String &pendingRegionName;
+		decltype(refreshCurrentScene) &refreshCurrentSceneFn;
+		decltype(captureDialogueBackdrop) &captureDialogueBackdropFn;
+		decltype(runRoomExitCommands) &runRoomExitCommandsFn;
+		decltype(resetIdleState) &resetIdleStateFn;
+
+		Common::Error handleInteractionResult(const StartupInteractionResult &interaction,
+				bool &didTransition) {
+			didTransition = false;
+			playerState.hasMoveTarget = false;
+			playerState.turnActive = false;
+			playerState.turnTargetFacing = -1;
+			pendingRegionName.clear();
 
-		Common::String restoreMusicPath = _engine.getStartupMusicPath();
-		if (!interaction.musicPath.empty()) {
-			(void)_engine.playStartupMusic(interaction.musicPath);
-			restoreMusicPath = _engine.getStartupMusicPath();
-		}
-		startupFlow.executeStartupAudioCommands(interaction.audioCommands);
+			Common::String restoreMusicPath = engine.getStartupMusicPath();
+			if (!interaction.musicPath.empty()) {
+				(void)engine.playStartupMusic(interaction.musicPath);
+				restoreMusicPath = engine.getStartupMusicPath();
+			}
+			startupFlow.executeStartupAudioCommands(interaction.audioCommands);
 
-		if (!interaction.nextRoomName.empty()) {
-			if (!runRoomExitCommands())
-				return Common::kReadingFailed;
+			if (!interaction.nextRoomName.empty()) {
+				if (!runRoomExitCommandsFn())
+					return Common::kReadingFailed;
+
+				Common::Error roomError = startupFlow.runRoomLoop(interaction.nextRoomName);
+				if (roomError.getCode() != Common::kReadingFailed &&
+					roomError.getCode() != Common::kNoError) {
+					return roomError;
+				}
 
-			Common::Error roomError = startupFlow.runRoomLoop(interaction.nextRoomName);
-			if (roomError.getCode() != Common::kReadingFailed &&
-				roomError.getCode() != Common::kNoError) {
-				return roomError;
+				if (!refreshCurrentSceneFn(true))
+					return Common::kReadingFailed;
+
+				startupFlow.executeStartupAudioCommands(scene.state.audioCommands);
+				if (!restoreMusicPath.empty())
+					(void)engine.playStartupMusic(restoreMusicPath);
+				else
+					engine.stopStartupMusic();
+				didTransition = true;
+			} else if (interaction.mutatedRuntimeState) {
+				if (!refreshCurrentSceneFn(true))
+					return Common::kReadingFailed;
 			}
 
-			if (!refreshCurrentScene(true))
-				return Common::kReadingFailed;
-
-			startupFlow.executeStartupAudioCommands(scene.state.audioCommands);
-			if (!restoreMusicPath.empty())
-				(void)_engine.playStartupMusic(restoreMusicPath);
-			else
-				_engine.stopStartupMusic();
-		} else if (interaction.mutatedRuntimeState) {
-			if (!refreshCurrentScene(true))
-				return Common::kReadingFailed;
+			if (didTransition)
+				return Common::kNoError;
+
+			if (!interaction.dialogueNpcName.empty()) {
+				Common::Error dialogueError = runScriptedDialogue(
+					interaction.dialogueNpcName, Common::String(), interaction.dialogueContinuationTag,
+					didTransition);
+				if (dialogueError.getCode() != Common::kNoError)
+					return dialogueError;
+			}
+
+			return Common::kNoError;
 		}
 
-		return Common::kNoError;
+		Common::Error runScriptedDialogue(const Common::String &npcName, const Common::String &usedItemName,
+				const Common::String &continuationTag, bool &didTransition) {
+			didTransition = false;
+
+			const StartupNpcRecord *dialogueNpc = engine.getStartupScript()->findRuntimeNpcRecord(npcName);
+			if (dialogueNpc) {
+				IndexedBitmap dialogueBackdrop;
+				if (!captureDialogueBackdropFn(dialogueBackdrop))
+					return Common::kReadingFailed;
+
+				Common::Error dialogueError = startupFlow.runRoomNpcDialogue(
+					dialogueBackdrop, scene.palette, scene.targetPaletteBrightness, *dialogueNpc,
+					usedItemName);
+				if (dialogueError.getCode() != Common::kNoError)
+					return dialogueError;
+			} else {
+				warning("Harvester: unresolved startup dialogue npc '%s' while processing room dialogue",
+					npcName.c_str());
+			}
+
+			StartupInteractionResult dialogueInteraction;
+			if (startupFlow.takeQueuedDialogueInteraction(dialogueInteraction)) {
+				Common::Error interactionError = handleInteractionResult(dialogueInteraction, didTransition);
+				if (interactionError.getCode() != Common::kNoError)
+					return interactionError;
+			}
+			if (!didTransition && !continuationTag.empty()) {
+				StartupInteractionResult continuationInteraction;
+				if (engine.getStartupScript()->executeActionTag(continuationTag, continuationInteraction)) {
+					Common::Error interactionError =
+						handleInteractionResult(continuationInteraction, didTransition);
+					if (interactionError.getCode() != Common::kNoError)
+						return interactionError;
+				}
+			}
+
+			startupFlow.resetCursorAnimationSequence();
+			resetIdleStateFn();
+			return Common::kNoError;
+		}
+	};
+	InteractionProcessor interactionProcessor = {
+		_engine, startupFlow, scene, playerState, pendingRegionName, refreshCurrentScene,
+		captureDialogueBackdrop, runRoomExitCommands, resetIdleState
 	};
 	auto handleInventoryTargetInteraction = [&](const StartupObjectRecord &target, bool preferPickup) -> Common::Error {
 		if (!_inventory.hasSelection())
@@ -220,7 +291,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			return Common::kNoError;
 
 		_inventory.clearSelection();
-		Common::Error interactionError = handleInteractionResult(interaction);
+		bool didTransition = false;
+		Common::Error interactionError = interactionProcessor.handleInteractionResult(interaction, didTransition);
 		if (interactionError.getCode() != Common::kNoError)
 			return interactionError;
 		if (!_inventory.refresh())
@@ -245,7 +317,9 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		if (!_engine.getStartupScript()->resolveRegionInteraction(region, interaction))
 			return Common::kNoError;
 
-		Common::Error interactionError = handleInteractionResult(interaction);
+		bool didTransition = false;
+		Common::Error interactionError =
+			interactionProcessor.handleInteractionResult(interaction, didTransition);
 		if (interactionError.getCode() != Common::kNoError)
 			return interactionError;
 
@@ -419,28 +493,15 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 							_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
 							scene.sceneRegions, _mousePos);
 						if (useHoverState.npc) {
-							IndexedBitmap dialogueBackdrop;
-							if (!captureDialogueBackdrop(dialogueBackdrop))
-								return Common::kReadingFailed;
-
-							Common::Error dialogueError = startupFlow.runRoomNpcDialogue(
-								dialogueBackdrop, scene.palette, scene.targetPaletteBrightness,
-								*useHoverState.npc, _inventory.getSelectedItemName());
+							bool didTransition = false;
+							Common::Error dialogueError = interactionProcessor.runScriptedDialogue(
+								useHoverState.npc->npcName, _inventory.getSelectedItemName(),
+								Common::String(), didTransition);
 							if (dialogueError.getCode() != Common::kNoError)
 								return dialogueError;
-							StartupInteractionResult dialogueInteraction;
-							const bool hasDialogueInteraction =
-								startupFlow.takeQueuedDialogueInteraction(dialogueInteraction);
 							if (_inventory.clearSelection() || _inventory.close())
 								needsRedraw = true;
-							if (hasDialogueInteraction) {
-								Common::Error interactionError = handleInteractionResult(dialogueInteraction);
-								if (interactionError.getCode() != Common::kNoError)
-									return interactionError;
-							}
-							startupFlow.resetCursorAnimationSequence();
-							resetIdleState();
-								needsRedraw = true;
+							needsRedraw = true;
 							break;
 						}
 						const StartupObjectRecord *roomTarget = useHoverState.object
@@ -495,16 +556,11 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 					playerState.hasMoveTarget = false;
 					playerState.turnActive = false;
 					playerState.turnTargetFacing = -1;
-					IndexedBitmap dialogueBackdrop;
-					if (!captureDialogueBackdrop(dialogueBackdrop))
-						return Common::kReadingFailed;
-					Common::Error dialogueError = startupFlow.runRoomNpcDialogue(
-						dialogueBackdrop, scene.palette, scene.targetPaletteBrightness,
-						*hoverState.npc, Common::String());
+					bool didTransition = false;
+					Common::Error dialogueError = interactionProcessor.runScriptedDialogue(
+						hoverState.npc->npcName, Common::String(), Common::String(), didTransition);
 					if (dialogueError.getCode() != Common::kNoError)
 						return dialogueError;
-					startupFlow.resetCursorAnimationSequence();
-					resetIdleState();
 					needsRedraw = true;
 					break;
 				}
@@ -574,7 +630,9 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 					break;
 				}
 
-				Common::Error interactionError = handleInteractionResult(interaction);
+				bool didTransition = false;
+				Common::Error interactionError =
+					interactionProcessor.handleInteractionResult(interaction, didTransition);
 				if (interactionError.getCode() != Common::kNoError)
 					return interactionError;
 				needsRedraw = true;
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 2a2e64e6eb4..0925a61769a 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -172,6 +172,7 @@ bool StartupScript::load(ResourceManager &resources) {
 	_texts.clear();
 	_heads.clear();
 	_useItems.clear();
+	_runtimeRegions.clear();
 	_quickTipsEnabled = true;
 	_voicePath = kDefaultVoicePath;
 	_dialogueTextMode = kStartupDialogueTextYes;
@@ -561,7 +562,7 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 	Common::Array<StartupAudioCommand> audioCommands;
 	bool mutatedRuntimeState = false;
 	executeCommandChain(room->onEnterCommand, "room setup command", room->roomName, false,
-		&musicPath, &audioCommands, nullptr, &mutatedRuntimeState);
+		&musicPath, &audioCommands, nullptr, nullptr, nullptr, &mutatedRuntimeState);
 
 	if (!buildRuntimeRoomState(*room, entrance, state))
 		return false;
@@ -586,6 +587,7 @@ void StartupScript::resetRuntimeState() {
 	_runtimeFlags = _flags;
 	_runtimeObjects = _objects;
 	_runtimeAnimations = _animations;
+	_runtimeRegions = _regions;
 	_runtimeNpcs = _npcs;
 
 	for (StartupObjectRecord &object : _runtimeObjects) {
@@ -637,7 +639,7 @@ bool StartupScript::executeRoomExitCommands(const Common::String &roomName,
 
 	bool mutatedRuntimeState = false;
 	executeCommandChain(room->onExitCommand, "room exit command", room->roomName, false,
-		nullptr, &audioCommands, nullptr, &mutatedRuntimeState);
+		nullptr, &audioCommands, nullptr, nullptr, nullptr, &mutatedRuntimeState);
 	return true;
 }
 
@@ -657,10 +659,11 @@ bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object,
 		return result.mutatedRuntimeState;
 
 	executeCommandChain(object.actionTag, "interaction command", object.objectName, true,
-		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.mutatedRuntimeState);
+		&result.musicPath, &result.audioCommands, &result.nextRoomName,
+		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.mutatedRuntimeState);
 
-	return !result.nextRoomName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
-		result.mutatedRuntimeState || hasActionableCommandChain(object.actionTag);
+	return !result.nextRoomName.empty() || !result.dialogueNpcName.empty() || !result.musicPath.empty() ||
+		!result.audioCommands.empty() || result.mutatedRuntimeState || hasActionableCommandChain(object.actionTag);
 }
 
 bool StartupScript::resolveRegionInteraction(const StartupRegionRecord &region, StartupInteractionResult &result) {
@@ -669,9 +672,10 @@ bool StartupScript::resolveRegionInteraction(const StartupRegionRecord &region,
 		return false;
 
 	executeCommandChain(region.actionTag, "region command", region.regionName, true,
-		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.mutatedRuntimeState);
-	return !result.nextRoomName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
-		result.mutatedRuntimeState || hasActionableCommandChain(region.actionTag);
+		&result.musicPath, &result.audioCommands, &result.nextRoomName,
+		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.mutatedRuntimeState);
+	return !result.nextRoomName.empty() || !result.dialogueNpcName.empty() || !result.musicPath.empty() ||
+		!result.audioCommands.empty() || result.mutatedRuntimeState || hasActionableCommandChain(region.actionTag);
 }
 
 bool StartupScript::resolveUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target,
@@ -684,7 +688,8 @@ bool StartupScript::resolveUseItemInteraction(const Common::String &itemName, co
 
 	executeCommandChain(useItem->actionTag, "useitem command",
 		Common::String::format("%s -> %s", itemName.c_str(), target.objectName.c_str()), true,
-		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.mutatedRuntimeState);
+		&result.musicPath, &result.audioCommands, &result.nextRoomName,
+		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.mutatedRuntimeState);
 	return true;
 }
 
@@ -695,10 +700,11 @@ bool StartupScript::executeActionTag(const Common::String &tag, StartupInteracti
 		return false;
 
 	executeCommandChain(tag, "action tag", tag, allowTransitions,
-		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.mutatedRuntimeState);
+		&result.musicPath, &result.audioCommands, &result.nextRoomName,
+		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.mutatedRuntimeState);
 
-	return !result.nextRoomName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
-		result.mutatedRuntimeState || hasActionableCommandChain(tag);
+	return !result.nextRoomName.empty() || !result.dialogueNpcName.empty() || !result.musicPath.empty() ||
+		!result.audioCommands.empty() || result.mutatedRuntimeState || hasActionableCommandChain(tag);
 }
 
 bool StartupScript::isPickupObject(const StartupObjectRecord &object) const {
@@ -852,6 +858,18 @@ StartupAnimRecord *StartupScript::findRuntimeAnim(const Common::String &animName
 	return nullptr;
 }
 
+StartupRegionRecord *StartupScript::findRuntimeRegion(const Common::String &regionName) {
+	if (regionName.empty())
+		return nullptr;
+
+	for (StartupRegionRecord &region : _runtimeRegions) {
+		if (region.regionName.equalsIgnoreCase(regionName))
+			return ®ion;
+	}
+
+	return nullptr;
+}
+
 StartupNpcRecord *StartupScript::findRuntimeNpc(const Common::String &npcName) {
 	if (npcName.empty())
 		return nullptr;
@@ -876,6 +894,10 @@ const StartupNpcRecord *StartupScript::findRuntimeNpc(const Common::String &npcN
 	return nullptr;
 }
 
+const StartupNpcRecord *StartupScript::findRuntimeNpcRecord(const Common::String &npcName) const {
+	return findRuntimeNpc(npcName);
+}
+
 bool StartupScript::addRuntimeObjectToInventory(const Common::String &objectName) {
 	StartupObjectRecord *runtimeObject = findRuntimeObject(Common::String(), objectName);
 	if (!runtimeObject)
@@ -948,10 +970,19 @@ bool StartupScript::buildRuntimeRoomState(const StartupRoomRecord &room, const S
 			state.roomAnimations.push_back(anim);
 	}
 	for (const StartupNpcRecord &npc : _runtimeNpcs) {
-		if (npc.roomName.equalsIgnoreCase(room.roomName) && npc.visible)
-			state.roomNpcs.push_back(npc);
+		if (!npc.roomName.equalsIgnoreCase(room.roomName) || !npc.visible)
+			continue;
+		if (npc.deathOrMonsterfyFlag) {
+			debugC(1, kDebugScene,
+				"Harvester: suppressed room npc room='%s' npc='%s' death_or_monsterfy=%d damage_type=%d model='%s' monsterfy_target='%s'",
+				room.roomName.c_str(), npc.npcName.c_str(), npc.deathOrMonsterfyFlag, npc.deathDamageType,
+				npc.modelPath.c_str(), npc.monsterfyTargetName.c_str());
+			continue;
+		}
+
+		state.roomNpcs.push_back(npc);
 	}
-	for (const StartupRegionRecord &region : _regions) {
+	for (const StartupRegionRecord &region : _runtimeRegions) {
 		if (region.roomName.equalsIgnoreCase(room.roomName) && region.startEnabled)
 			state.roomRegions.push_back(region);
 	}
@@ -991,6 +1022,7 @@ bool StartupScript::buildRuntimeRoomState(const StartupRoomRecord &room, const S
 void StartupScript::executeCommandChain(const Common::String &initialTag, const char *contextLabel,
 		const Common::String &contextName, bool allowTransitions, Common::String *musicPath,
 		Common::Array<StartupAudioCommand> *audioCommands, Common::String *nextRoomName,
+		Common::String *dialogueNpcName, Common::String *dialogueContinuationTag,
 		bool *mutatedRuntimeState) {
 	auto isTruthy = [](const Common::String &value) {
 		return value.equalsIgnoreCase("T") || value.equalsIgnoreCase("ON") || value.equalsIgnoreCase("TRUE");
@@ -1114,6 +1146,23 @@ void StartupScript::executeCommandChain(const Common::String &initialTag, const
 			continue;
 		}
 
+		if (command->opcodeName.equalsIgnoreCase("SET_REGION")) {
+			StartupRegionRecord *runtimeRegion = findRuntimeRegion(command->arg1);
+			if (!runtimeRegion) {
+				debug(1, "Harvester: unresolved region for %s '%s' region='%s'",
+					contextLabel, contextName.c_str(), command->arg1.c_str());
+				currentTag = command->arg4;
+				continue;
+			}
+
+			const bool enabled = !command->arg2.equalsIgnoreCase("F");
+			const bool changed = runtimeRegion->startEnabled != enabled;
+			runtimeRegion->startEnabled = enabled;
+			noteMutation(changed);
+			currentTag = command->arg4;
+			continue;
+		}
+
 		if (command->opcodeName.equalsIgnoreCase("SET_NPC")) {
 			StartupNpcRecord *runtimeNpc = findRuntimeNpc(command->arg1);
 			if (!runtimeNpc) {
@@ -1133,6 +1182,22 @@ void StartupScript::executeCommandChain(const Common::String &initialTag, const
 			continue;
 		}
 
+		if (command->opcodeName.equalsIgnoreCase("START_DIALOG")) {
+			if (!dialogueNpcName || !dialogueContinuationTag) {
+				debug(1, "Harvester: unsupported startup command '%s' for %s '%s' without dialogue context",
+					command->opcodeName.c_str(), contextLabel, contextName.c_str());
+				return;
+			}
+			if (command->arg1.empty()) {
+				currentTag = command->arg4;
+				continue;
+			}
+
+			*dialogueNpcName = command->arg1;
+			*dialogueContinuationTag = command->arg4;
+			return;
+		}
+
 		if (command->opcodeName.equalsIgnoreCase("KILL_NPC") ||
 				command->opcodeName.equalsIgnoreCase("MONSTERFY")) {
 			StartupNpcRecord *runtimeNpc = findRuntimeNpc(command->arg1);
@@ -1191,7 +1256,9 @@ bool StartupScript::hasActionableCommandChain(const Common::String &initialTag)
 			command->opcodeName.equalsIgnoreCase("DELETE") ||
 			command->opcodeName.equalsIgnoreCase("ADD2INV") ||
 			command->opcodeName.equalsIgnoreCase("SET_ANIM") ||
+			command->opcodeName.equalsIgnoreCase("SET_REGION") ||
 			command->opcodeName.equalsIgnoreCase("SET_NPC") ||
+			command->opcodeName.equalsIgnoreCase("START_DIALOG") ||
 			command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
 			return true;
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index b039c937ad2..1231216db77 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -224,6 +224,8 @@ struct StartupRoomSetupState {
 struct StartupInteractionResult {
 	Common::String musicPath;
 	Common::String nextRoomName;
+	Common::String dialogueNpcName;
+	Common::String dialogueContinuationTag;
 	Common::Array<StartupAudioCommand> audioCommands;
 	bool mutatedRuntimeState = false;
 };
@@ -276,6 +278,7 @@ public:
 		Common::String resolveTextValue(const Common::String &key) const;
 		const StartupHeadRecord *findHeadRecord(const Common::String &headId) const;
 		bool getFlagValue(const Common::String &flagName) const;
+		const StartupNpcRecord *findRuntimeNpcRecord(const Common::String &npcName) const;
 		bool setRuntimeFlagValue(const Common::String &flagName, bool value);
 		bool resetRuntimeObjectToInitialState(const Common::String &objectName);
 		bool isNamedNpcDeathTypeClear(const Common::String &npcName) const;
@@ -294,6 +297,7 @@ private:
 	StartupFlagRecord *findRuntimeFlag(const Common::String &flagName);
 	StartupObjectRecord *findRuntimeObject(const Common::String &ownerOrRoom, const Common::String &objectName);
 	StartupAnimRecord *findRuntimeAnim(const Common::String &animName);
+	StartupRegionRecord *findRuntimeRegion(const Common::String &regionName);
 	StartupNpcRecord *findRuntimeNpc(const Common::String &npcName);
 	const StartupNpcRecord *findRuntimeNpc(const Common::String &npcName) const;
 	bool addRuntimeObjectToInventory(const Common::String &objectName);
@@ -302,6 +306,7 @@ private:
 	void executeCommandChain(const Common::String &initialTag, const char *contextLabel,
 		const Common::String &contextName, bool allowTransitions, Common::String *musicPath,
 		Common::Array<StartupAudioCommand> *audioCommands, Common::String *nextRoomName,
+		Common::String *dialogueNpcName, Common::String *dialogueContinuationTag,
 		bool *mutatedRuntimeState);
 	bool hasActionableCommandChain(const Common::String &initialTag) const;
 
@@ -321,6 +326,7 @@ private:
 	Common::Array<StartupFlagRecord> _runtimeFlags;
 	Common::Array<StartupObjectRecord> _runtimeObjects;
 	Common::Array<StartupAnimRecord> _runtimeAnimations;
+	Common::Array<StartupRegionRecord> _runtimeRegions;
 	Common::Array<StartupNpcRecord> _runtimeNpcs;
 	bool _quickTipsEnabled = true;
 	Common::String _voicePath = "SOUND/VOICE/";


Commit: 50524a18855df8890a832789438f7772e8b0dd3a
    https://github.com/scummvm/scummvm/commit/50524a18855df8890a832789438f7772e8b0dd3a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:53+02:00

Commit Message:
HARVESTER: Honor startup dialogue abort flow

Changed paths:
    TRACKER.md
    engines/harvester/startup_dialogue.cpp
    engines/harvester/startup_room.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/TRACKER.md b/TRACKER.md
index c3092429b77..146ec9dcaa3 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,15 +15,16 @@
 
 ## Last Confirmed Action
 
-- Recovered `SET_REGION` and `START_DIALOG` from `dispatch_room_event_actions @ 0x60ee0`, then aligned the startup script/runtime flow with the native continuation behavior.
-  - Confirmed `case 0x16` finds a `RegionRecord` by name, treats `arg2 == "F"` as the disable sentinel, updates `start_enabled`, and toggles the live room-region entity from that runtime state; ScummVM now keeps runtime region records and materializes room regions from them instead of the immutable parse-time table.
-  - Confirmed `case 0x1a` refreshes/fades the room palette if needed, calls `run_npc_dialogue(arg1, 0)`, then resumes at `arg4` unless the native post-dialogue abort flag trips; ScummVM now suspends `START_DIALOG`, runs the named NPC dialogue with no presented item, drains queued dialogue-side effects, and resumes the continuation tag afterward.
-  - Unified direct room-NPC clicks and scripted `START_DIALOG` through the same post-dialogue interaction path, so no-item dialogue handlers can now mutate startup state consistently.
-  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the dispatcher/room-loop changes.
+- Recovered the native post-dialogue abort flag `DAT_000d60bc` around `dispatch_room_event_actions @ 0x60ee0`, then aligned the startup dialogue continuation flow with the confirmed nested-dispatch behavior.
+  - Confirmed `dispatch_room_event_actions` `case 0x1a` calls `run_npc_dialogue(arg1, 0)`, waits for mouse release, then returns early when `DAT_000d60bc != 0` instead of always resuming `arg4`.
+  - Confirmed `run_inventory_screen @ 0x7ec02`, `handle_target_interaction @ 0x801cc`, and three `update_actor_runtime_state` paths (`0x50a50`, `0x514fb`, `0x5154e`) all set `DAT_000d60bc = 1` immediately after nested `dispatch_room_event_actions` calls, establishing it as the native “abort the caller’s remaining command chain” signal.
+  - Confirmed `run_game_over_screen @ 0x7c749` stores `1` or `2`, and `run_harvester_main_loop` clears `1` to restart the loop but treats `2` as the outer exit path; ScummVM only needed the value-`1` nested-abort behavior for current startup dialogue parity.
+  - ScummVM now propagates a dedicated nested action-tag abort bit through queued dialogue interactions, so `START_DIALOG` continuations are skipped not only on room transitions but also when the dialogue triggers a nested startup action-tag analogue such as Jimmy’s `ACTV_HOUSE_EXIT`.
+  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the abort-path runtime changes.
 
 ## Next Suggested Action
 
-1. Recover the native post-dialogue abort flag checked immediately after `run_npc_dialogue` in `dispatch_room_event_actions @ 0x60ee0`.
-   - ScummVM now skips `START_DIALOG` continuations when the dialogue result already transitions rooms, but the DOS handler also has a separate `DAT_000d60bc` early-return path that still needs a confirmed engine-side mapping.
-2. Recover the monster-record replacement half of `MONSTERFY`.
+1. Recover the monster-record replacement half of `MONSTERFY`.
    - `DIAL_JIM_4_D -> MONST_JIM` is now reachable through the restored `START_DIALOG` continuation, but ScummVM still only sets the NPC death/monsterfy flag and suppresses the original NPC; the native monster-record spawn/replacement path is still missing.
+2. Confirm whether any startup dialogue/action-tag path needs a value-`2` / outer-exit analogue of `DAT_000d60bc`.
+   - Current startup parity only needs the confirmed value-`1` nested-abort case; the DOS main loop also treats `2` as a high-level exit request, but no startup-script path has been tied to that behavior yet.
diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
index 212f325c177..6f01b332ba3 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/startup_dialogue.cpp
@@ -1024,7 +1024,10 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 				jimmyInteraction.mutatedRuntimeState = changedGivenPaperToday || changedNewspaperState;
 
 				StartupInteractionResult actionInteraction;
-				if (startupScript->executeActionTag("ACTV_HOUSE_EXIT", actionInteraction)) {
+				if (startupScript->executeNestedActionTag("ACTV_HOUSE_EXIT", actionInteraction)) {
+					jimmyInteraction.abortRemainingCommandChain =
+						jimmyInteraction.abortRemainingCommandChain ||
+						actionInteraction.abortRemainingCommandChain;
 					jimmyInteraction.mutatedRuntimeState =
 						jimmyInteraction.mutatedRuntimeState || actionInteraction.mutatedRuntimeState;
 					if (!actionInteraction.musicPath.empty())
@@ -1040,7 +1043,8 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 					for (const StartupAudioCommand &command : actionInteraction.audioCommands)
 						jimmyInteraction.audioCommands.push_back(command);
 				}
-				if (jimmyInteraction.mutatedRuntimeState || !jimmyInteraction.musicPath.empty() ||
+				if (jimmyInteraction.abortRemainingCommandChain || jimmyInteraction.mutatedRuntimeState ||
+						!jimmyInteraction.musicPath.empty() ||
 						!jimmyInteraction.nextRoomName.empty() || !jimmyInteraction.dialogueNpcName.empty() ||
 						!jimmyInteraction.dialogueContinuationTag.empty() ||
 						!jimmyInteraction.audioCommands.empty()) {
diff --git a/engines/harvester/startup_room.cpp b/engines/harvester/startup_room.cpp
index 62dbaa39589..ac72dc1296f 100644
--- a/engines/harvester/startup_room.cpp
+++ b/engines/harvester/startup_room.cpp
@@ -252,12 +252,14 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			}
 
 			StartupInteractionResult dialogueInteraction;
+			bool abortRemainingCommandChain = false;
 			if (startupFlow.takeQueuedDialogueInteraction(dialogueInteraction)) {
+				abortRemainingCommandChain = dialogueInteraction.abortRemainingCommandChain;
 				Common::Error interactionError = handleInteractionResult(dialogueInteraction, didTransition);
 				if (interactionError.getCode() != Common::kNoError)
 					return interactionError;
 			}
-			if (!didTransition && !continuationTag.empty()) {
+			if (!didTransition && !abortRemainingCommandChain && !continuationTag.empty()) {
 				StartupInteractionResult continuationInteraction;
 				if (engine.getStartupScript()->executeActionTag(continuationTag, continuationInteraction)) {
 					Common::Error interactionError =
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 0925a61769a..7857342297f 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -707,6 +707,15 @@ bool StartupScript::executeActionTag(const Common::String &tag, StartupInteracti
 		!result.audioCommands.empty() || result.mutatedRuntimeState || hasActionableCommandChain(tag);
 }
 
+bool StartupScript::executeNestedActionTag(const Common::String &tag, StartupInteractionResult &result,
+		bool allowTransitions) {
+	const bool handled = executeActionTag(tag, result, allowTransitions);
+	if (handled)
+		result.abortRemainingCommandChain = true;
+
+	return handled;
+}
+
 bool StartupScript::isPickupObject(const StartupObjectRecord &object) const {
 	return !object.altSpritePath.empty() &&
 		!object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName);
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 1231216db77..1ee5876e130 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -227,6 +227,7 @@ struct StartupInteractionResult {
 	Common::String dialogueNpcName;
 	Common::String dialogueContinuationTag;
 	Common::Array<StartupAudioCommand> audioCommands;
+	bool abortRemainingCommandChain = false;
 	bool mutatedRuntimeState = false;
 };
 
@@ -268,6 +269,8 @@ public:
 			StartupInteractionResult &result);
 		bool executeActionTag(const Common::String &tag, StartupInteractionResult &result,
 			bool allowTransitions = true);
+		bool executeNestedActionTag(const Common::String &tag, StartupInteractionResult &result,
+			bool allowTransitions = true);
 		bool isPickupObject(const StartupObjectRecord &object) const;
 		bool hasObjectInteraction(const StartupObjectRecord &object) const;
 		bool hasUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target) const;


Commit: f46bb5fb7d39367b0367e559e2333c1cfa4f92b2
    https://github.com/scummvm/scummvm/commit/f46bb5fb7d39367b0367e559e2333c1cfa4f92b2
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:53+02:00

Commit Message:
HARVESTER: Restore startup monsterfy replacement

Changed paths:
    TRACKER.md
    engines/harvester/runtime_entity.h
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/TRACKER.md b/TRACKER.md
index 146ec9dcaa3..0b20907d868 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,16 +15,16 @@
 
 ## Last Confirmed Action
 
-- Recovered the native post-dialogue abort flag `DAT_000d60bc` around `dispatch_room_event_actions @ 0x60ee0`, then aligned the startup dialogue continuation flow with the confirmed nested-dispatch behavior.
-  - Confirmed `dispatch_room_event_actions` `case 0x1a` calls `run_npc_dialogue(arg1, 0)`, waits for mouse release, then returns early when `DAT_000d60bc != 0` instead of always resuming `arg4`.
-  - Confirmed `run_inventory_screen @ 0x7ec02`, `handle_target_interaction @ 0x801cc`, and three `update_actor_runtime_state` paths (`0x50a50`, `0x514fb`, `0x5154e`) all set `DAT_000d60bc = 1` immediately after nested `dispatch_room_event_actions` calls, establishing it as the native “abort the caller’s remaining command chain” signal.
-  - Confirmed `run_game_over_screen @ 0x7c749` stores `1` or `2`, and `run_harvester_main_loop` clears `1` to restart the loop but treats `2` as the outer exit path; ScummVM only needed the value-`1` nested-abort behavior for current startup dialogue parity.
-  - ScummVM now propagates a dedicated nested action-tag abort bit through queued dialogue interactions, so `START_DIALOG` continuations are skipped not only on room transitions but also when the dialogue triggers a nested startup action-tag analogue such as Jimmy’s `ACTV_HOUSE_EXIT`.
-  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the abort-path runtime changes.
+- Recovered the startup monster-record half of `MONSTERFY` from `HARVEST.SCR` and the matching native room/action logic, then aligned the startup runtime with that confirmed replacement path.
+  - Confirmed `CONFIG.INI` routes the town script to `HARVEST.SCR`, and decoding that file with the native `0xaa` XOR shows Jimmy’s restored dialogue chain as `DIAL_JIM_4_D -> START_DIALOG "JIMMY" -> MONST_JIM -> MONSTERFY "JIMMY"`.
+  - Confirmed the same decoded script contains both the `NPC "PCHOUSE" ... "JIMMY" "JIMMY_M"` record and a live `MONSTER "PCHOUSE" "JIMMY_M" ... "JIMMY_DEATH_COM"` record, which provides the replacement model path, spawn state, facing, and on-death command tag.
+  - Confirmed `dispatch_room_event_actions @ 0x60ee0` recognizes `MONSTERFY` (`case 0x0e`) and `SET_MONSTER` (`case 0x14`), while `room_setup @ 0x73540` separately materializes matching monster records when their room matches and their active/visible state is enabled.
+  - ScummVM now parses startup `MONSTER` records, keeps runtime monster state alongside objects/anims/NPCs, materializes active-or-visible room monsters as non-interactive scene actors, supports `SET_MONSTER`, and makes `MONSTERFY` both suppress the original NPC and activate/show the linked `monsterfyTargetName` monster record.
+  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the monster-record runtime changes.
 
 ## Next Suggested Action
 
-1. Recover the monster-record replacement half of `MONSTERFY`.
-   - `DIAL_JIM_4_D -> MONST_JIM` is now reachable through the restored `START_DIALOG` continuation, but ScummVM still only sets the NPC death/monsterfy flag and suppresses the original NPC; the native monster-record spawn/replacement path is still missing.
+1. Recover startup monster death / on-death command execution.
+   - The decoded `MONSTER` records carry native death action tags such as `JIMMY_DEATH_COM`, and `room_setup`/`MONSTERFY` parity now reaches the replacement spawn, but the startup runtime still has no monster combat/death path to fire those follow-up commands.
 2. Confirm whether any startup dialogue/action-tag path needs a value-`2` / outer-exit analogue of `DAT_000d60bc`.
-   - Current startup parity only needs the confirmed value-`1` nested-abort case; the DOS main loop also treats `2` as a high-level exit request, but no startup-script path has been tied to that behavior yet.
+   - Current startup parity still only needs the confirmed value-`1` nested-abort case; the DOS main loop also treats `2` as a high-level exit request, but no startup-script path has been tied to that behavior yet.
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index 8768cc05e78..47a51be1983 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -42,6 +42,7 @@ enum RuntimeEntityClass {
 	kRuntimeEntityClassBackground = 3,
 	kRuntimeEntityClassNpc = 4,
 	kRuntimeEntityClassPlayer = 5,
+	kRuntimeEntityClassMonster = 6,
 	kRuntimeEntityClassRectHotspot = 0x15,
 	kRuntimeEntityClassDisabledHotspot = 0x16,
 	kRuntimeEntityClassRectHotspot18 = 0x18,
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index fe2082dd4a8..9ebc6f9d84c 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -134,6 +134,19 @@ static int resolvePlayerFacingFrame(int facing) {
 	}
 }
 
+static int resolveMonsterFacingFrame(int facing) {
+	switch (facing) {
+	case 1:
+		return 0x0e;
+	case 2:
+		return 0x2c;
+	case 3:
+		return 0x28;
+	default:
+		return 0;
+	}
+}
+
 static PlayerAnimationRange resolvePlayerAnimationRange(int facing) {
 	switch (facing) {
 	case 0:
@@ -730,11 +743,11 @@ bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceManager
 	scene.sceneRegions = state.roomRegions;
 
 	debugC(1, kDebugGeneral,
-		"Harvester: loadRoomSceneResources room='%s' palette='%s' background='%s' brightness=%.2f objects=%u activeObjects=%u sceneObjects=%u anims=%u visibleAnims=%u npcs=%u regions=%u",
+		"Harvester: loadRoomSceneResources room='%s' palette='%s' background='%s' brightness=%.2f objects=%u activeObjects=%u sceneObjects=%u anims=%u visibleAnims=%u npcs=%u monsters=%u regions=%u",
 		state.roomName.c_str(), state.palettePath.c_str(), state.backgroundPath.c_str(), (double)state.paletteBrightness,
 		(uint)state.roomObjects.size(), (uint)state.activeObjects.size(), (uint)scene.sceneObjects.size(),
 		(uint)state.roomAnimations.size(), (uint)scene.sceneAnimations.size(),
-		(uint)state.roomNpcs.size(), (uint)scene.sceneRegions.size());
+		(uint)state.roomNpcs.size(), (uint)state.roomMonsters.size(), (uint)scene.sceneRegions.size());
 
 	return true;
 }
@@ -1900,6 +1913,29 @@ bool StartupFlow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 			entity->getX(), entity->getY(), (double)entity->getZ(),
 			npc.frameDelay, npc.modelPath.c_str(), npc.active, npc.visible);
 	}
+	for (const StartupMonsterRecord &monster : state.roomMonsters) {
+		RuntimeEntity *entity = runtimeEntities->spawnSceneActorEntity(monster.monsterName,
+			monster.modelPath, Common::Point(monster.posX, monster.posY), (float)monster.posZ,
+			resolveMonsterFacingFrame(monster.initialFacing));
+		if (!entity) {
+			debug(1, "Harvester: unable to spawn room monster entity '%s' from '%s'",
+				monster.monsterName.c_str(), monster.modelPath.c_str());
+			continue;
+		}
+
+		entity->setClassId(kRuntimeEntityClassMonster);
+		entity->setHitTestMode(kRuntimeEntityHitTestNone);
+		entity->setVisible(monster.visible);
+		if (!applyRoomActorPlacement(state, *entity, monster.posX, monster.posY, (float)monster.posZ)) {
+			debug(1, "Harvester: unable to apply room monster placement for '%s'",
+				monster.monsterName.c_str());
+		}
+		debugC(1, kDebugScene,
+			"Harvester: scene monster spawned room='%s' monster='%s' class=0x%x pos=(%d,%d,z=%.2f) facing=%d model='%s' active=%d visible=%d",
+			state.roomName.c_str(), monster.monsterName.c_str(), entity->getClassId(),
+			entity->getX(), entity->getY(), (double)entity->getZ(), monster.initialFacing,
+			monster.modelPath.c_str(), monster.active, monster.visible);
+	}
 	if (state.hasEntrance) {
 		const int playerFrame = resolvePlayerFacingFrame(state.playerFacing);
 		const bool reusedPlayer = preservedPlayer != nullptr;
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 7857342297f..5be8c8c7de6 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -438,6 +438,28 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 			return;
 		}
 
+		if (tag == "MONSTER") {
+			if (tokens.size() < tagIndex + 24)
+				return;
+
+			StartupMonsterRecord monster;
+			if (tagIndex >= 3) {
+				monster.posX = atoi(tokens[0].c_str());
+				monster.posY = atoi(tokens[1].c_str());
+				monster.posZ = atoi(tokens[2].c_str());
+			}
+			monster.roomName = tokens[tagIndex + 1];
+			monster.monsterName = tokens[tagIndex + 2];
+			monster.modelPath = resources.normalizeResourcePath(tokens[tagIndex + 3]);
+			monster.initialFacing = parseEntranceFacing(tokens[tagIndex + 8]);
+			monster.active = tokens[tagIndex + 19].equalsIgnoreCase("T");
+			monster.visible = tokens[tagIndex + 20].equalsIgnoreCase("T");
+			monster.onDeathActionTag = tokens[tagIndex + 21];
+			if (!monster.roomName.empty() && !monster.monsterName.empty() && !monster.modelPath.empty())
+				_monsters.push_back(monster);
+			return;
+		}
+
 		if (tag == "REGION") {
 			if (tokens.size() < tagIndex + 7)
 				return;
@@ -572,13 +594,13 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 		state.musicPath = musicPath;
 
 	debugC(1, kDebugGeneral,
-		"Harvester: resolveRoomSetupState('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u roomNpcs=%u roomRegions=%u mutated=%d",
+		"Harvester: resolveRoomSetupState('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u roomNpcs=%u roomMonsters=%u roomRegions=%u mutated=%d",
 		entranceName.c_str(), state.roomName.c_str(), state.entranceName.c_str(),
 		state.playerSpawnX, state.playerSpawnY, state.playerSpawnZ, state.playerFacing,
 		state.palettePath.c_str(), state.backgroundPath.c_str(), state.musicPath.c_str(),
 		(double)state.paletteBrightness, (uint)state.roomObjects.size(),
 		(uint)state.activeObjects.size(), (uint)state.roomAnimations.size(), (uint)state.roomNpcs.size(),
-		(uint)state.roomRegions.size(), mutatedRuntimeState);
+		(uint)state.roomMonsters.size(), (uint)state.roomRegions.size(), mutatedRuntimeState);
 
 	return true;
 }
@@ -589,6 +611,7 @@ void StartupScript::resetRuntimeState() {
 	_runtimeAnimations = _animations;
 	_runtimeRegions = _regions;
 	_runtimeNpcs = _npcs;
+	_runtimeMonsters = _monsters;
 
 	for (StartupObjectRecord &object : _runtimeObjects) {
 		object.currentX = object.initialX;
@@ -891,6 +914,18 @@ StartupNpcRecord *StartupScript::findRuntimeNpc(const Common::String &npcName) {
 	return nullptr;
 }
 
+StartupMonsterRecord *StartupScript::findRuntimeMonster(const Common::String &monsterName) {
+	if (monsterName.empty())
+		return nullptr;
+
+	for (StartupMonsterRecord &monster : _runtimeMonsters) {
+		if (monster.monsterName.equalsIgnoreCase(monsterName))
+			return &monster;
+	}
+
+	return nullptr;
+}
+
 const StartupNpcRecord *StartupScript::findRuntimeNpc(const Common::String &npcName) const {
 	if (npcName.empty())
 		return nullptr;
@@ -903,6 +938,18 @@ const StartupNpcRecord *StartupScript::findRuntimeNpc(const Common::String &npcN
 	return nullptr;
 }
 
+const StartupMonsterRecord *StartupScript::findRuntimeMonster(const Common::String &monsterName) const {
+	if (monsterName.empty())
+		return nullptr;
+
+	for (const StartupMonsterRecord &monster : _runtimeMonsters) {
+		if (monster.monsterName.equalsIgnoreCase(monsterName))
+			return &monster;
+	}
+
+	return nullptr;
+}
+
 const StartupNpcRecord *StartupScript::findRuntimeNpcRecord(const Common::String &npcName) const {
 	return findRuntimeNpc(npcName);
 }
@@ -991,6 +1038,14 @@ bool StartupScript::buildRuntimeRoomState(const StartupRoomRecord &room, const S
 
 		state.roomNpcs.push_back(npc);
 	}
+	for (const StartupMonsterRecord &monster : _runtimeMonsters) {
+		if (!monster.roomName.equalsIgnoreCase(room.roomName))
+			continue;
+		if (!monster.active && !monster.visible)
+			continue;
+
+		state.roomMonsters.push_back(monster);
+	}
 	for (const StartupRegionRecord &region : _runtimeRegions) {
 		if (region.roomName.equalsIgnoreCase(room.roomName) && region.startEnabled)
 			state.roomRegions.push_back(region);
@@ -1016,14 +1071,21 @@ bool StartupScript::buildRuntimeRoomState(const StartupRoomRecord &room, const S
 			npc.posX, npc.posY, npc.posZ, npc.frameDelay,
 			npc.modelPath.c_str(), npc.onDeathActionTag.c_str(), npc.audioPath.c_str());
 	}
+	for (const StartupMonsterRecord &monster : state.roomMonsters) {
+		debugC(1, kDebugScene,
+			"Harvester: materialized room monster room='%s' monster='%s' visible=%d active=%d pos=(%d,%d,%d) facing=%d model='%s' on_death='%s'",
+			state.roomName.c_str(), monster.monsterName.c_str(), monster.visible, monster.active,
+			monster.posX, monster.posY, monster.posZ, monster.initialFacing,
+			monster.modelPath.c_str(), monster.onDeathActionTag.c_str());
+	}
 
 	debugC(1, kDebugGeneral,
-		"Harvester: materializeRoomState room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u roomAnims=%u roomNpcs=%u roomRegions=%u",
+		"Harvester: materializeRoomState room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u roomAnims=%u roomNpcs=%u roomMonsters=%u roomRegions=%u",
 		state.roomName.c_str(), state.entranceName.c_str(), state.playerSpawnX, state.playerSpawnY,
 		state.playerSpawnZ, state.playerFacing, state.palettePath.c_str(), state.backgroundPath.c_str(),
 		state.musicPath.c_str(), (double)state.paletteBrightness,
 		(uint)state.roomObjects.size(), (uint)state.roomAnimations.size(),
-		(uint)state.roomNpcs.size(), (uint)state.roomRegions.size());
+		(uint)state.roomNpcs.size(), (uint)state.roomMonsters.size(), (uint)state.roomRegions.size());
 
 	return true;
 }
@@ -1191,6 +1253,25 @@ void StartupScript::executeCommandChain(const Common::String &initialTag, const
 			continue;
 		}
 
+		if (command->opcodeName.equalsIgnoreCase("SET_MONSTER")) {
+			StartupMonsterRecord *runtimeMonster = findRuntimeMonster(command->arg1);
+			if (!runtimeMonster) {
+				debug(1, "Harvester: unresolved monster for %s '%s' monster='%s'",
+					contextLabel, contextName.c_str(), command->arg1.c_str());
+				currentTag = command->arg4;
+				continue;
+			}
+
+			const bool active = isTruthy(command->arg2);
+			const bool visible = isTruthy(command->arg3);
+			const bool changed = runtimeMonster->active != active || runtimeMonster->visible != visible;
+			runtimeMonster->active = active;
+			runtimeMonster->visible = visible;
+			noteMutation(changed);
+			currentTag = command->arg4;
+			continue;
+		}
+
 		if (command->opcodeName.equalsIgnoreCase("START_DIALOG")) {
 			if (!dialogueNpcName || !dialogueContinuationTag) {
 				debug(1, "Harvester: unsupported startup command '%s' for %s '%s' without dialogue context",
@@ -1218,12 +1299,26 @@ void StartupScript::executeCommandChain(const Common::String &initialTag, const
 			}
 
 			const int deathDamageType = parseNpcDeathDamageType(command->arg2);
+			bool monsterChanged = false;
+			if (command->opcodeName.equalsIgnoreCase("MONSTERFY") && !runtimeNpc->monsterfyTargetName.empty()) {
+				StartupMonsterRecord *runtimeMonster = findRuntimeMonster(runtimeNpc->monsterfyTargetName);
+				if (runtimeMonster) {
+					monsterChanged = !runtimeMonster->active || !runtimeMonster->visible;
+					runtimeMonster->active = true;
+					runtimeMonster->visible = true;
+				} else {
+					debug(1, "Harvester: unresolved monsterfy target for %s '%s' npc='%s' target='%s'",
+						contextLabel, contextName.c_str(), command->arg1.c_str(),
+						runtimeNpc->monsterfyTargetName.c_str());
+				}
+			}
+
 			const bool changed = !runtimeNpc->deathOrMonsterfyFlag ||
 				(deathDamageType != 0 && runtimeNpc->deathDamageType != deathDamageType);
 			runtimeNpc->deathOrMonsterfyFlag = true;
 			if (deathDamageType != 0)
 				runtimeNpc->deathDamageType = deathDamageType;
-			noteMutation(changed);
+			noteMutation(changed || monsterChanged);
 			currentTag = command->arg4;
 			continue;
 		}
@@ -1267,7 +1362,10 @@ bool StartupScript::hasActionableCommandChain(const Common::String &initialTag)
 			command->opcodeName.equalsIgnoreCase("SET_ANIM") ||
 			command->opcodeName.equalsIgnoreCase("SET_REGION") ||
 			command->opcodeName.equalsIgnoreCase("SET_NPC") ||
+			command->opcodeName.equalsIgnoreCase("SET_MONSTER") ||
 			command->opcodeName.equalsIgnoreCase("START_DIALOG") ||
+			command->opcodeName.equalsIgnoreCase("KILL_NPC") ||
+			command->opcodeName.equalsIgnoreCase("MONSTERFY") ||
 			command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
 			return true;
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 1ee5876e130..07fdd9cf6c6 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -125,6 +125,19 @@ struct StartupNpcRecord {
 	Common::String entityInitArg;
 };
 
+struct StartupMonsterRecord {
+	int posX = 0;
+	int posY = 0;
+	int posZ = 0;
+	int initialFacing = -1;
+	Common::String roomName;
+	Common::String monsterName;
+	Common::String modelPath;
+	Common::String onDeathActionTag;
+	bool active = false;
+	bool visible = false;
+};
+
 struct StartupRegionRecord {
 	int left = 0;
 	int top = 0;
@@ -217,6 +230,7 @@ struct StartupRoomSetupState {
 	Common::Array<StartupObjectRecord> roomObjects;
 	Common::Array<StartupAnimRecord> roomAnimations;
 	Common::Array<StartupNpcRecord> roomNpcs;
+	Common::Array<StartupMonsterRecord> roomMonsters;
 	Common::Array<StartupRegionRecord> roomRegions;
 	Common::Array<StartupAudioCommand> audioCommands;
 };
@@ -247,6 +261,7 @@ public:
 	const Common::Array<StartupObjectRecord> &getObjects() const { return _objects; }
 	const Common::Array<StartupAnimRecord> &getAnimations() const { return _animations; }
 	const Common::Array<StartupNpcRecord> &getNpcs() const { return _npcs; }
+	const Common::Array<StartupMonsterRecord> &getMonsters() const { return _monsters; }
 	const Common::Array<StartupRegionRecord> &getRegions() const { return _regions; }
 	const Common::Array<StartupFlagRecord> &getFlags() const { return _flags; }
 	const Common::Array<StartupCommandRecord> &getCommands() const { return _commands; }
@@ -303,6 +318,8 @@ private:
 	StartupRegionRecord *findRuntimeRegion(const Common::String &regionName);
 	StartupNpcRecord *findRuntimeNpc(const Common::String &npcName);
 	const StartupNpcRecord *findRuntimeNpc(const Common::String &npcName) const;
+	StartupMonsterRecord *findRuntimeMonster(const Common::String &monsterName);
+	const StartupMonsterRecord *findRuntimeMonster(const Common::String &monsterName) const;
 	bool addRuntimeObjectToInventory(const Common::String &objectName);
 	bool buildRuntimeRoomState(const StartupRoomRecord &room, const StartupEntranceRecord *entrance,
 		StartupRoomSetupState &state) const;
@@ -320,6 +337,7 @@ private:
 	Common::Array<StartupObjectRecord> _objects;
 	Common::Array<StartupAnimRecord> _animations;
 	Common::Array<StartupNpcRecord> _npcs;
+	Common::Array<StartupMonsterRecord> _monsters;
 	Common::Array<StartupRegionRecord> _regions;
 	Common::Array<StartupFlagRecord> _flags;
 	Common::Array<StartupCommandRecord> _commands;
@@ -331,6 +349,7 @@ private:
 	Common::Array<StartupAnimRecord> _runtimeAnimations;
 	Common::Array<StartupRegionRecord> _runtimeRegions;
 	Common::Array<StartupNpcRecord> _runtimeNpcs;
+	Common::Array<StartupMonsterRecord> _runtimeMonsters;
 	bool _quickTipsEnabled = true;
 	Common::String _voicePath = "SOUND/VOICE/";
 	StartupDialogueTextMode _dialogueTextMode = kStartupDialogueTextYes;


Commit: 6e5da3a3309539006baff6fa21d0eeb89fcd88d3
    https://github.com/scummvm/scummvm/commit/6e5da3a3309539006baff6fa21d0eeb89fcd88d3
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:53+02:00

Commit Message:
HARVESTER: Handle startup GODEATHFLIC menu return

Changed paths:
    TRACKER.md
    engines/harvester/startup_dialogue.cpp
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_flow.h
    engines/harvester/startup_menu.cpp
    engines/harvester/startup_room.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/TRACKER.md b/TRACKER.md
index 0b20907d868..e979fd9d8e7 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,16 +15,16 @@
 
 ## Last Confirmed Action
 
-- Recovered the startup monster-record half of `MONSTERFY` from `HARVEST.SCR` and the matching native room/action logic, then aligned the startup runtime with that confirmed replacement path.
-  - Confirmed `CONFIG.INI` routes the town script to `HARVEST.SCR`, and decoding that file with the native `0xaa` XOR shows Jimmy’s restored dialogue chain as `DIAL_JIM_4_D -> START_DIALOG "JIMMY" -> MONST_JIM -> MONSTERFY "JIMMY"`.
-  - Confirmed the same decoded script contains both the `NPC "PCHOUSE" ... "JIMMY" "JIMMY_M"` record and a live `MONSTER "PCHOUSE" "JIMMY_M" ... "JIMMY_DEATH_COM"` record, which provides the replacement model path, spawn state, facing, and on-death command tag.
-  - Confirmed `dispatch_room_event_actions @ 0x60ee0` recognizes `MONSTERFY` (`case 0x0e`) and `SET_MONSTER` (`case 0x14`), while `room_setup @ 0x73540` separately materializes matching monster records when their room matches and their active/visible state is enabled.
-  - ScummVM now parses startup `MONSTER` records, keeps runtime monster state alongside objects/anims/NPCs, materializes active-or-visible room monsters as non-interactive scene actors, supports `SET_MONSTER`, and makes `MONSTERFY` both suppress the original NPC and activate/show the linked `monsterfyTargetName` monster record.
-  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the monster-record runtime changes.
+- Confirmed the startup value-`2` / outer-exit analogue is reached through `GODEATHFLIC`, then aligned the startup runtime with that native game-over return-to-menu path.
+  - Confirmed `parse_command_record @ 0x5e410` maps `GODEATHFLIC` to opcode `0x1c`, and `dispatch_room_event_actions @ 0x60ee0` handles that case by clearing the current palette, playing the supplied FST path, calling `run_game_over_screen()`, restoring music volume, and returning immediately.
+  - Confirmed `run_game_over_screen @ 0x7c540` writes `DAT_000d60bc = (run_main_menu() == -1) + 1`, so the same native path carries both the already-implemented value-`1` nested-abort case and the value-`2` high-level exit-to-menu case.
+  - Confirmed the decoded `HARVEST.SCR` actually uses that route in the blood-drive chain: `BLOOD_DRIVE_A -> CHANGE_LIGHTING NONE -> BLOOD_DRIVE_B -> GOFLIC GRAPHIC\\FST\\EVENING.FST -> BLOOD_DRIVE_C -> GODEATHFLIC GRAPHIC\\FST\\C006.FST`.
+  - ScummVM now treats `GODEATHFLIC` as a terminal startup interaction result, plays the death FST, marks a pending main-menu return, and unwinds recursive room/dialogue continuations without resuming the abandoned room chain or spawning nested menu loops.
+  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the `GODEATHFLIC` / outer-exit work.
 
 ## Next Suggested Action
 
-1. Recover startup monster death / on-death command execution.
-   - The decoded `MONSTER` records carry native death action tags such as `JIMMY_DEATH_COM`, and `room_setup`/`MONSTERFY` parity now reaches the replacement spawn, but the startup runtime still has no monster combat/death path to fire those follow-up commands.
-2. Confirm whether any startup dialogue/action-tag path needs a value-`2` / outer-exit analogue of `DAT_000d60bc`.
-   - Current startup parity still only needs the confirmed value-`1` nested-abort case; the DOS main loop also treats `2` as a high-level exit request, but no startup-script path has been tied to that behavior yet.
+1. Recover startup `GOFLIC` and `CHANGE_LIGHTING` handling for the same confirmed blood-drive sequence.
+   - The native `GODEATHFLIC` / value-`2` path is now covered, but the decoded `BLOOD_DRIVE_*` chain still depends on the preceding `CHANGE_LIGHTING NONE` and `GOFLIC GRAPHIC\\FST\\EVENING.FST` opcodes before it reaches `C006.FST`.
+2. Revisit startup monster death / on-death command execution once there is a safe trigger path.
+   - The decoded `MONSTER` records still carry native death action tags such as `JIMMY_DEATH_COM`, but startup parity still lacks a non-speculative monster combat/death state transition to fire them.
diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
index 6f01b332ba3..c2383e2bc6d 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/startup_dialogue.cpp
@@ -1034,18 +1034,23 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 						jimmyInteraction.musicPath = actionInteraction.musicPath;
 					if (!actionInteraction.nextRoomName.empty())
 						jimmyInteraction.nextRoomName = actionInteraction.nextRoomName;
+					if (!actionInteraction.deathFlicPath.empty())
+						jimmyInteraction.deathFlicPath = actionInteraction.deathFlicPath;
 					if (!actionInteraction.dialogueNpcName.empty())
 						jimmyInteraction.dialogueNpcName = actionInteraction.dialogueNpcName;
 					if (!actionInteraction.dialogueContinuationTag.empty()) {
 						jimmyInteraction.dialogueContinuationTag =
 							actionInteraction.dialogueContinuationTag;
 					}
+					jimmyInteraction.requestMainMenu =
+						jimmyInteraction.requestMainMenu || actionInteraction.requestMainMenu;
 					for (const StartupAudioCommand &command : actionInteraction.audioCommands)
 						jimmyInteraction.audioCommands.push_back(command);
 				}
 				if (jimmyInteraction.abortRemainingCommandChain || jimmyInteraction.mutatedRuntimeState ||
 						!jimmyInteraction.musicPath.empty() ||
-						!jimmyInteraction.nextRoomName.empty() || !jimmyInteraction.dialogueNpcName.empty() ||
+						!jimmyInteraction.nextRoomName.empty() || !jimmyInteraction.deathFlicPath.empty() ||
+						jimmyInteraction.requestMainMenu || !jimmyInteraction.dialogueNpcName.empty() ||
 						!jimmyInteraction.dialogueContinuationTag.empty() ||
 						!jimmyInteraction.audioCommands.empty()) {
 					startupFlow.queueDialogueInteraction(jimmyInteraction);
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 9ebc6f9d84c..be059abead6 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -1609,14 +1609,24 @@ Common::Error StartupFlow::run() {
 	if (!ensureCursorEntity())
 		return Common::kReadingFailed;
 
+	clearPendingMainMenuReturn();
 	resetRoomNpcDialogueState();
 	_engine.getStartupScript()->resetRuntimeState();
 	Common::Error error = runQuickTips();
 	if (error.getCode() != Common::kNoError)
 		return error;
 
+	clearPendingMainMenuReturn();
 	_engine.getStartupScript()->resetRuntimeState();
-	return runRoomLoop("START");
+	error = runRoomLoop("START");
+	if (error.getCode() != Common::kNoError)
+		return error;
+	if (!takePendingMainMenuReturn())
+		return Common::kNoError;
+
+	_engine.stopStartupMusic();
+	_engine.stopStartupSound();
+	return runMainMenuStub();
 }
 
 bool StartupFlow::loadQuickTips() {
@@ -1794,6 +1804,24 @@ bool StartupFlow::takeQueuedDialogueInteraction(StartupInteractionResult &intera
 	return true;
 }
 
+void StartupFlow::requestMainMenuReturn() {
+	_pendingMainMenuReturn = true;
+}
+
+bool StartupFlow::hasPendingMainMenuReturn() const {
+	return _pendingMainMenuReturn;
+}
+
+bool StartupFlow::takePendingMainMenuReturn() {
+	const bool requested = _pendingMainMenuReturn;
+	_pendingMainMenuReturn = false;
+	return requested;
+}
+
+void StartupFlow::clearPendingMainMenuReturn() {
+	_pendingMainMenuReturn = false;
+}
+
 Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
 	return _room.runRoomLoop(*this, entranceName);
 }
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/startup_flow.h
index 5a29a20b321..4a9c34ac956 100644
--- a/engines/harvester/startup_flow.h
+++ b/engines/harvester/startup_flow.h
@@ -72,6 +72,10 @@ private:
 		void executeStartupAudioCommands(const Common::Array<StartupAudioCommand> &commands);
 		void queueDialogueInteraction(const StartupInteractionResult &interaction);
 		bool takeQueuedDialogueInteraction(StartupInteractionResult &interaction);
+		void requestMainMenuReturn();
+		bool hasPendingMainMenuReturn() const;
+		bool takePendingMainMenuReturn();
+		void clearPendingMainMenuReturn();
 		void resetRoomNpcDialogueState();
 		void resetCursorAnimationSequence();
 		bool tickRuntimeEntities();
@@ -87,6 +91,7 @@ private:
 		StartupRoomSystem _room;
 		StartupInteractionResult _queuedDialogueInteraction;
 		bool _hasQueuedDialogueInteraction = false;
+		bool _pendingMainMenuReturn = false;
 	};
 
 } // End of namespace Harvester
diff --git a/engines/harvester/startup_menu.cpp b/engines/harvester/startup_menu.cpp
index 6677f1ae120..61afadd23a0 100644
--- a/engines/harvester/startup_menu.cpp
+++ b/engines/harvester/startup_menu.cpp
@@ -191,13 +191,19 @@ Common::Error StartupMenuSystem::runMainMenuStub(StartupFlow &startupFlow) {
 						return Common::kNoError;
 
 					if (_menuItems[selectedItem].equalsIgnoreCase("NEW GAME")) {
+						startupFlow.clearPendingMainMenuReturn();
 						_engine.getStartupScript()->resetRuntimeState();
 						Common::Error roomError = startupFlow.runRoomLoop("START");
+						_engine.stopStartupMusic();
+						_engine.stopStartupSound();
 						if (roomError.getCode() == Common::kReadingFailed) {
 							statusMessage = "Unable to resolve START room setup from HARVEST.SCR.";
 							needsRedraw = true;
 						} else if (roomError.getCode() != Common::kNoError) {
 							return roomError;
+						} else if (startupFlow.takePendingMainMenuReturn()) {
+							statusMessage = "Returned to main menu after startup death sequence.";
+							needsRedraw = true;
 						} else {
 							statusMessage = "Resolved START room handoff from HARVEST.SCR.";
 							needsRedraw = true;
@@ -221,6 +227,7 @@ Common::Error StartupMenuSystem::runMainMenuStub(StartupFlow &startupFlow) {
 					return Common::kNoError;
 
 				if (_menuItems[selectedItem].equalsIgnoreCase("NEW GAME")) {
+					startupFlow.clearPendingMainMenuReturn();
 					_engine.getStartupScript()->resetRuntimeState();
 					Common::Error roomError = startupFlow.runRoomLoop("START");
 					_engine.stopStartupMusic();
@@ -230,6 +237,9 @@ Common::Error StartupMenuSystem::runMainMenuStub(StartupFlow &startupFlow) {
 						needsRedraw = true;
 					} else if (roomError.getCode() != Common::kNoError) {
 						return roomError;
+					} else if (startupFlow.takePendingMainMenuReturn()) {
+						statusMessage = "Returned to main menu after startup death sequence.";
+						needsRedraw = true;
 					} else {
 						statusMessage = "Resolved START room handoff from HARVEST.SCR.";
 						needsRedraw = true;
diff --git a/engines/harvester/startup_room.cpp b/engines/harvester/startup_room.cpp
index ac72dc1296f..136db2517e7 100644
--- a/engines/harvester/startup_room.cpp
+++ b/engines/harvester/startup_room.cpp
@@ -27,6 +27,7 @@
 #include "graphics/fontman.h"
 #include "graphics/framelimiter.h"
 #include "harvester/detection.h"
+#include "harvester/fst_player.h"
 #include "harvester/harvester.h"
 #include "harvester/runtime_entity.h"
 #include "harvester/startup_art.h"
@@ -42,6 +43,9 @@ StartupRoomSystem::StartupRoomSystem(HarvesterEngine &engine, Common::Point &mou
 }
 
 Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Common::String &entranceName) {
+	if (startupFlow.hasPendingMainMenuReturn())
+		return Common::kNoError;
+
 	StartupRoomSetupState state;
 	if (!_engine.getStartupScript()->resolveRoomSetupState(entranceName, state, *_engine.getResources()))
 		return Common::kReadingFailed;
@@ -186,6 +190,19 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			playerState.turnTargetFacing = -1;
 			pendingRegionName.clear();
 
+			if (interaction.requestMainMenu) {
+				engine.stopStartupMusic();
+				engine.stopStartupSound();
+				if (!interaction.deathFlicPath.empty()) {
+					FstPlayer fstPlayer(engine);
+					if (!fstPlayer.play(interaction.deathFlicPath))
+						return Common::kReadingFailed;
+				}
+
+				startupFlow.requestMainMenuReturn();
+				return Common::kNoError;
+			}
+
 			Common::String restoreMusicPath = engine.getStartupMusicPath();
 			if (!interaction.musicPath.empty()) {
 				(void)engine.playStartupMusic(interaction.musicPath);
@@ -198,6 +215,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 					return Common::kReadingFailed;
 
 				Common::Error roomError = startupFlow.runRoomLoop(interaction.nextRoomName);
+				if (startupFlow.hasPendingMainMenuReturn())
+					return Common::kNoError;
 				if (roomError.getCode() != Common::kReadingFailed &&
 					roomError.getCode() != Common::kNoError) {
 					return roomError;
@@ -258,6 +277,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 				Common::Error interactionError = handleInteractionResult(dialogueInteraction, didTransition);
 				if (interactionError.getCode() != Common::kNoError)
 					return interactionError;
+				if (startupFlow.hasPendingMainMenuReturn())
+					return Common::kNoError;
 			}
 			if (!didTransition && !abortRemainingCommandChain && !continuationTag.empty()) {
 				StartupInteractionResult continuationInteraction;
@@ -266,6 +287,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 						handleInteractionResult(continuationInteraction, didTransition);
 					if (interactionError.getCode() != Common::kNoError)
 						return interactionError;
+					if (startupFlow.hasPendingMainMenuReturn())
+						return Common::kNoError;
 				}
 			}
 
@@ -297,6 +320,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		Common::Error interactionError = interactionProcessor.handleInteractionResult(interaction, didTransition);
 		if (interactionError.getCode() != Common::kNoError)
 			return interactionError;
+		if (startupFlow.hasPendingMainMenuReturn())
+			return Common::kNoError;
 		if (!_inventory.refresh())
 			return Common::kReadingFailed;
 
@@ -324,6 +349,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			interactionProcessor.handleInteractionResult(interaction, didTransition);
 		if (interactionError.getCode() != Common::kNoError)
 			return interactionError;
+		if (startupFlow.hasPendingMainMenuReturn())
+			return Common::kNoError;
 
 		if (!_inventory.refresh())
 			return Common::kReadingFailed;
@@ -358,6 +385,9 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		logStartupRoomProbe(_engine, scene, entranceName, _mousePos);
 
 	while (!_engine.shouldQuit()) {
+		if (startupFlow.hasPendingMainMenuReturn())
+			return Common::kNoError;
+
 		if (needsRedraw) {
 			const Common::Rect inventoryPanelBounds = _inventory.getPanelBounds();
 			const bool inventoryPanelContainsMouse = _inventory.isOpen() && inventoryPanelBounds.contains(_mousePos);
@@ -501,6 +531,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 								Common::String(), didTransition);
 							if (dialogueError.getCode() != Common::kNoError)
 								return dialogueError;
+							if (startupFlow.hasPendingMainMenuReturn())
+								return Common::kNoError;
 							if (_inventory.clearSelection() || _inventory.close())
 								needsRedraw = true;
 							needsRedraw = true;
@@ -563,6 +595,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 						hoverState.npc->npcName, Common::String(), Common::String(), didTransition);
 					if (dialogueError.getCode() != Common::kNoError)
 						return dialogueError;
+					if (startupFlow.hasPendingMainMenuReturn())
+						return Common::kNoError;
 					needsRedraw = true;
 					break;
 				}
@@ -637,6 +671,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 					interactionProcessor.handleInteractionResult(interaction, didTransition);
 				if (interactionError.getCode() != Common::kNoError)
 					return interactionError;
+				if (startupFlow.hasPendingMainMenuReturn())
+					return Common::kNoError;
 				needsRedraw = true;
 				break;
 			}
@@ -759,6 +795,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		Common::Error pendingRegionError = tryActivatePendingRegion();
 		if (pendingRegionError.getCode() != Common::kNoError)
 			return pendingRegionError;
+		if (startupFlow.hasPendingMainMenuReturn())
+			return Common::kNoError;
 
 		if (startupFlow.tickRuntimeEntities())
 			needsRedraw = true;
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 5be8c8c7de6..91a391fe1da 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -584,7 +584,7 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 	Common::Array<StartupAudioCommand> audioCommands;
 	bool mutatedRuntimeState = false;
 	executeCommandChain(room->onEnterCommand, "room setup command", room->roomName, false,
-		&musicPath, &audioCommands, nullptr, nullptr, nullptr, &mutatedRuntimeState);
+		&musicPath, &audioCommands, nullptr, nullptr, nullptr, nullptr, nullptr, &mutatedRuntimeState);
 
 	if (!buildRuntimeRoomState(*room, entrance, state))
 		return false;
@@ -662,7 +662,7 @@ bool StartupScript::executeRoomExitCommands(const Common::String &roomName,
 
 	bool mutatedRuntimeState = false;
 	executeCommandChain(room->onExitCommand, "room exit command", room->roomName, false,
-		nullptr, &audioCommands, nullptr, nullptr, nullptr, &mutatedRuntimeState);
+		nullptr, &audioCommands, nullptr, nullptr, nullptr, nullptr, nullptr, &mutatedRuntimeState);
 	return true;
 }
 
@@ -683,10 +683,12 @@ bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object,
 
 	executeCommandChain(object.actionTag, "interaction command", object.objectName, true,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName,
+		&result.deathFlicPath, &result.requestMainMenu,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.mutatedRuntimeState);
 
-	return !result.nextRoomName.empty() || !result.dialogueNpcName.empty() || !result.musicPath.empty() ||
-		!result.audioCommands.empty() || result.mutatedRuntimeState || hasActionableCommandChain(object.actionTag);
+	return !result.nextRoomName.empty() || !result.deathFlicPath.empty() || result.requestMainMenu ||
+		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
+		result.mutatedRuntimeState || hasActionableCommandChain(object.actionTag);
 }
 
 bool StartupScript::resolveRegionInteraction(const StartupRegionRecord &region, StartupInteractionResult &result) {
@@ -696,9 +698,11 @@ bool StartupScript::resolveRegionInteraction(const StartupRegionRecord &region,
 
 	executeCommandChain(region.actionTag, "region command", region.regionName, true,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName,
+		&result.deathFlicPath, &result.requestMainMenu,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.mutatedRuntimeState);
-	return !result.nextRoomName.empty() || !result.dialogueNpcName.empty() || !result.musicPath.empty() ||
-		!result.audioCommands.empty() || result.mutatedRuntimeState || hasActionableCommandChain(region.actionTag);
+	return !result.nextRoomName.empty() || !result.deathFlicPath.empty() || result.requestMainMenu ||
+		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
+		result.mutatedRuntimeState || hasActionableCommandChain(region.actionTag);
 }
 
 bool StartupScript::resolveUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target,
@@ -712,6 +716,7 @@ bool StartupScript::resolveUseItemInteraction(const Common::String &itemName, co
 	executeCommandChain(useItem->actionTag, "useitem command",
 		Common::String::format("%s -> %s", itemName.c_str(), target.objectName.c_str()), true,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName,
+		&result.deathFlicPath, &result.requestMainMenu,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.mutatedRuntimeState);
 	return true;
 }
@@ -724,10 +729,12 @@ bool StartupScript::executeActionTag(const Common::String &tag, StartupInteracti
 
 	executeCommandChain(tag, "action tag", tag, allowTransitions,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName,
+		&result.deathFlicPath, &result.requestMainMenu,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.mutatedRuntimeState);
 
-	return !result.nextRoomName.empty() || !result.dialogueNpcName.empty() || !result.musicPath.empty() ||
-		!result.audioCommands.empty() || result.mutatedRuntimeState || hasActionableCommandChain(tag);
+	return !result.nextRoomName.empty() || !result.deathFlicPath.empty() || result.requestMainMenu ||
+		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
+		result.mutatedRuntimeState || hasActionableCommandChain(tag);
 }
 
 bool StartupScript::executeNestedActionTag(const Common::String &tag, StartupInteractionResult &result,
@@ -1093,7 +1100,8 @@ bool StartupScript::buildRuntimeRoomState(const StartupRoomRecord &room, const S
 void StartupScript::executeCommandChain(const Common::String &initialTag, const char *contextLabel,
 		const Common::String &contextName, bool allowTransitions, Common::String *musicPath,
 		Common::Array<StartupAudioCommand> *audioCommands, Common::String *nextRoomName,
-		Common::String *dialogueNpcName, Common::String *dialogueContinuationTag,
+		Common::String *deathFlicPath, bool *requestMainMenu, Common::String *dialogueNpcName,
+		Common::String *dialogueContinuationTag,
 		bool *mutatedRuntimeState) {
 	auto isTruthy = [](const Common::String &value) {
 		return value.equalsIgnoreCase("T") || value.equalsIgnoreCase("ON") || value.equalsIgnoreCase("TRUE");
@@ -1288,6 +1296,23 @@ void StartupScript::executeCommandChain(const Common::String &initialTag, const
 			return;
 		}
 
+		if (command->opcodeName.equalsIgnoreCase("GODEATHFLIC")) {
+			if (!deathFlicPath || !requestMainMenu) {
+				debug(1, "Harvester: unsupported startup command '%s' for %s '%s' without menu-exit context",
+					command->opcodeName.c_str(), contextLabel, contextName.c_str());
+				return;
+			}
+
+			if (allowTransitions) {
+				*deathFlicPath = command->arg1;
+				*requestMainMenu = true;
+			} else {
+				debugC(1, kDebugScene, "Harvester: skipped transition opcode '%s' while processing %s '%s'",
+					command->opcodeName.c_str(), contextLabel, contextName.c_str());
+			}
+			return;
+		}
+
 		if (command->opcodeName.equalsIgnoreCase("KILL_NPC") ||
 				command->opcodeName.equalsIgnoreCase("MONSTERFY")) {
 			StartupNpcRecord *runtimeNpc = findRuntimeNpc(command->arg1);
@@ -1364,6 +1389,7 @@ bool StartupScript::hasActionableCommandChain(const Common::String &initialTag)
 			command->opcodeName.equalsIgnoreCase("SET_NPC") ||
 			command->opcodeName.equalsIgnoreCase("SET_MONSTER") ||
 			command->opcodeName.equalsIgnoreCase("START_DIALOG") ||
+			command->opcodeName.equalsIgnoreCase("GODEATHFLIC") ||
 			command->opcodeName.equalsIgnoreCase("KILL_NPC") ||
 			command->opcodeName.equalsIgnoreCase("MONSTERFY") ||
 			command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 07fdd9cf6c6..8447d733702 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -238,9 +238,11 @@ struct StartupRoomSetupState {
 struct StartupInteractionResult {
 	Common::String musicPath;
 	Common::String nextRoomName;
+	Common::String deathFlicPath;
 	Common::String dialogueNpcName;
 	Common::String dialogueContinuationTag;
 	Common::Array<StartupAudioCommand> audioCommands;
+	bool requestMainMenu = false;
 	bool abortRemainingCommandChain = false;
 	bool mutatedRuntimeState = false;
 };
@@ -326,7 +328,8 @@ private:
 	void executeCommandChain(const Common::String &initialTag, const char *contextLabel,
 		const Common::String &contextName, bool allowTransitions, Common::String *musicPath,
 		Common::Array<StartupAudioCommand> *audioCommands, Common::String *nextRoomName,
-		Common::String *dialogueNpcName, Common::String *dialogueContinuationTag,
+		Common::String *deathFlicPath, bool *requestMainMenu, Common::String *dialogueNpcName,
+		Common::String *dialogueContinuationTag,
 		bool *mutatedRuntimeState);
 	bool hasActionableCommandChain(const Common::String &initialTag) const;
 


Commit: be563883feaf92ba500d99da2c89073f0cbee9df
    https://github.com/scummvm/scummvm/commit/be563883feaf92ba500d99da2c89073f0cbee9df
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:54+02:00

Commit Message:
HARVESTER: Align mom dialogue resources with native flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/startup_dialogue.cpp
    engines/harvester/startup_dialogue.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index c7fe94d6eb4..d22fd915565 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -9,8 +9,15 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `_entry` at `0x810e8` is only an LE loader thunk.
 - `watcom_le_crt_startup` at `0x81160` is Watcom DOS-extender CRT setup. It captures the command tail and environment block, counts `NO87=` overrides, clears startup/BSS state, runs init callbacks, and hands off to C startup.
 - `run_init_callbacks_by_priority` at `0x888a6` walks a 6-byte callback table and executes pending init callbacks in priority order.
+- The callback table format is now typed as `RuntimePriorityCallbackRecord { state, priority, callback }`.
+- `g_startup_priority_callback_table` at `0xc6d92` contains 21 startup callbacks that initialize the mixed CRT/game runtime, including music stream state, video surface context, keyboard/mouse input, render-entity lists, menu/config environments, startup `argc/argv`, floating-point text conversion callbacks, timing bases, environment strings, and the buffered FILE-stream node pool.
+- `run_runtime_termination_callbacks_by_priority_range` at `0x888f1` walks the 3-record `g_runtime_termination_callback_table` at `0xc6e10` on exit paths, including `write_console_message_and_exit`. Its callbacks shut down DOS-runtime mode state, drain the intrusive runtime-module shutdown list, and tear down the buffered FILE-stream layer.
+- Several startup callbacks register per-module runtime records through `push_runtime_module_record`. The termination helper at `0x82084` moves `g_runtime_module_record_list_head` to `g_runtime_module_shutdown_list_head` and drains that list through the common runtime shutdown state machine.
 - `crt_initialize_and_run_program` at `0x88856` finishes CRT setup and transfers control into the program startup path.
-- `startup_main` at `0x10010` is the first game-specific startup routine. It stores `argc/argv`, resolves `TEMP` / `TMP` / `.\` working paths, loads options such as `FX_VOLUME`, `MUSIC_VOLUME`, `GAMMA`, `CLICK`, and `QUICK_TIPS`, handles the `Z.EXE` CLI/help path, and then enters the normal game startup chain.
+- `startup_main` at `0x10010` is the first game-specific startup routine. It stores `argc/argv`, verifies MSCDEX, resolves a writable temp directory from `TEMP` / `TMP` / `.\`, loads persisted options such as `FX_VOLUME`, `MUSIC_VOLUME`, `GAMMA`, `TEXT`, `GORE`, and `QUICK_TIPS`, handles the `Z.EXE` CLI/help path, and then enters the normal game startup chain.
+  - The temp-directory probe copies each candidate path, trims a trailing `\`, formats `"%s\\temp.fil"`, and only commits `g_temp_directory` after the test file opens and can be deleted.
+  - `TEXT` is a tri-state startup/menu option rather than a boolean: `NO` maps to `g_text_option_mode = 0`, `YES` maps to `1`, and `CLICK` maps to `2`.
+  - `g_quick_tips_enabled` defaults to `1` and is only cleared when `QUICK_TIPS` contains `OFF`.
 - `run_quick_tips_screen` at `0x6c890` is the startup quick-tips overlay shown on top of the already loaded `START -> PCROOM` room state.
   - It spawns the bottom action labels through `spawn_text_entity`, renders all three actions in color `0xc3`, and resolves the toggle through the `TEXT` records so `Show_Tips_ON` / `Show_Tips_OFF` display their text values rather than the raw keys.
 
@@ -102,6 +109,19 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - It loads the town script blob through XFILE and deobfuscates it by XORing bytes with `0xAA` except CR/LF.
 - The function then registers a large vocabulary of words/primitives, including `create`, `lit`, `var`, `const`, `if`, `else`, `branch`, `quote`, `.quote`, `do_do`, `do_loop`, and related operators.
 - This strongly suggests a compact Forth-like or stack-based scripting runtime rather than a simple linear bytecode interpreter.
+- The runtime dictionary is now structurally bounded:
+  - `g_town_script_dictionary_head` is a linked list of 16-byte word entries with fields `{ handler, value, name_record, next }`.
+  - Each entry owns a separate 16-byte name record with `{ text_length, reserved_04, registration_state, text }`.
+  - `find_town_script_dictionary_entry` searches that list from the counted token in `g_town_script_token_buffer` and leaves `g_town_script_dictionary_lookup_cursor` pointing at either the matched entry or the tail insertion point.
+- Token input is split cleanly between two confirmed source paths:
+  - `g_town_script_token_source` is a callback slot used by `read_next_town_script_word`.
+  - When the encrypted script blob is present, the runtime reads from `g_town_script_blob` / `g_town_script_blob_offset`.
+  - When no blob is active, or when nested script input unwinds back to zero depth, the callback falls back to `read_town_script_console_line`, which reads and echoes a console line into `g_town_script_input_line_buffer`, expands tabs, and then lets `read_next_town_script_word` tokenize it.
+- The interpreter and record parsers now have an explicit shared cell stack:
+  - `g_town_script_cell_stack_count` tracks the live depth.
+  - `g_town_script_cell_stack` stores mixed 32-bit cells, including numeric values, delimiter bytes, counted-string token buffers, and dictionary entry pointers.
+  - `pop_town_script_cell_or_zero` is the non-fatal pop helper used by record parsers when they consume numeric fields from that stack.
+  - `read_next_town_script_quoted_string` pushes delimiter `0x20`, routes through `read_next_town_script_word`, then strips the surrounding quote bytes and returns a heap `char *` or null for an empty quoted field.
 - `g_town_script_runtime_state` at `0xd3050` is the 0x20-byte base state block zeroed by `reset_town_script_runtime_state` at `0x48000`.
   - The mixed Borland runtime descriptor block still binds a no-op paired callback above it, but the owning state object itself is now explicit from the `initialize_town_script_runtime` and `run_town_script_interpreter` xrefs.
 
@@ -138,6 +158,19 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `run_harvester_main_loop` at `0x6dc70` is the primary Harvester boot and gameplay loop.
 - It prints the `Running Harvester v1.0` banner.
 - It loads startup art and UI resources including logo FSTs, palettes, fonts, textbox art, cursor resources, and room-object art.
+- The startup loading overlay is now explicit:
+  - `WAIT_BM` from `1:\graphic\other\wait.abm` stays on the render list while the dialogue index, fonts, textbox panels, and combat HUD icon strips are loaded, then is removed before the first live `START` room setup.
+  - `g_inventory_background_entity` and `g_harvester_logo_entity` are allocated only after that `WAIT_BM` setup, so the native wait overlay is not composed on top of the inventory/logo art.
+- Several startup UI globals seeded there are now named:
+  - `g_inventory_background_entity` at `0xe2498` is the preloaded `1:\graphic\other\inventry.bm` panel entity used by `run_inventory_screen`.
+  - `g_harvester_logo_entity` at `0xd60b4` is the preloaded `1:\graphic\other\harvlogo.bm` logo entity reused by the main-menu path.
+  - `g_box1_panel_entity` .. `g_box4_panel_entity` at `0xe249c` .. `0xe24a8` are the four preloaded textbox panel entities from `textbox1.bm` .. `textbox4.bm`.
+- The in-room ESC menu path is now bounded directly from `run_main_menu`:
+  - it pauses timer countdown entities, keeps the current room render list on screen, re-adds `g_harvester_logo_entity`, and does not replace the background with `g_inventory_background_entity`
+  - the six base menu rows are centered at screen Y `100 + n * (get_font_line_height(g_harvfont_cft) + 2)`
+  - unselected rows are spawned with `g_harvfnt2_cft`, while the currently selected row is respawned with `g_harvfont_cft`
+  - mouse row selection is derived only from `(g_cursor_screen_y - 100) / line_height`, so the base ESC menu does not perform X-bounded text hit testing
+  - `TEXTBOX4.BM` is only introduced by the `NEW GAME` / `QUIT GAME` confirmation branch and is not part of the base ESC overlay
 - On the cold-start path it only seeds the first town transition: after the world-record parsers and script runtime have populated the live record lists, it preloads `POINTERS.ABM`, stores the spawned cursor entity, preloads the initial `START -> PCROOM` room palette into `g_current_palette_buffer`, and then hands off to `room_setup("START")`; the quick-tips screen is a later overlay on top of that live room scene rather than a standalone room handoff.
 - That same cold-start path also stores the live player actor before the first room transition; `room_setup` later re-adds that existing actor to the room render list instead of constructing a fresh PCROOM-local record.
 - It seeds the initial `START` state, enters the main loop, processes state-driven transitions and interactions, and performs cleanup on shutdown.
@@ -170,9 +203,9 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `restore_text_mode_3` restores BIOS text mode 3 for the current backend.
     - `noop_vesa_mode_shutdown` is the no-op teardown callback used by the 640x400, 640x480, and 800x600 descriptor records.
     - The per-mode callback sets are now explicit for the four descriptor records: each record carries a probe helper, an activation helper, a display-start preset helper, and single-pixel read/write helpers for its specific geometry.
-    - `fill_vesa_banked_rect` fills a rectangular byte region in banked VRAM using the per-scanline bank split tables.
-    - `blit_vesa_banked_rect` is the shared rectangle blitter used by the VESA mode records.
-    - `select_vesa_bank_for_scanline` adjusts the active bank/window based on a scanline index.
+  - `fill_vesa_banked_rect` fills a rectangular byte region in banked VRAM using the per-scanline bank split tables.
+  - `blit_vesa_banked_rect` is the shared rectangle blitter used by the VESA mode records.
+  - `select_vesa_bank_for_scanline` adjusts the active bank/window based on a scanline index.
 
 ## Render Entity Runtime
 
@@ -210,6 +243,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 **Notes**
 - The current typed runtime record cleanly covers render placement, animation timing, waypoint storage, and blocker-history detours.
 - The actor-only tail from `+0x1134` through `+0x118e` is still only partially typed, but it now clearly contains combat/use-state sound slots and facing-bank flags rather than generic render state.
+
 ## Room And Event Flow
 
 - `room_setup` at `0x73540` is confirmed by in-binary debug strings:
@@ -316,6 +350,10 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `g_timer_records` at `0xd5ad0` is the `TIMER` list.
 - `g_useitem_records` at `0xd5ad4` is the `USEITEM` list.
 - `g_pending_room_name` at `0xd60a4` is the pending next-room string consumed by the main loop.
+- `g_game_session_active` at `0xc3f08` is the live gameplay-session gate shared by room setup, the save/load menus, and the game-over flow.
+  - `run_save_game_menu` returns immediately when it is clear.
+  - `run_load_game_menu` sets it before applying a slot.
+  - `run_game_over_screen` clears it until the main menu starts or loads a new session again.
 
 ## Menus And UI States
 
@@ -325,10 +363,18 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `run_load_game_menu` at `0x64910` is the load-game UI.
   - It loads `loadgame.pal` and `loadgame.bm`.
   - It enumerates `GAME_01` through `GAME_25` slots and reads slot names from the save file.
+  - When a slot is accepted it restores the save header fields that drive room handoff: `g_player_present_in_room`, `g_pending_room_name`, `g_player_room_name`, and the saved `SAVE_GAME` entrance position/facing.
 - `run_save_game_menu` at `0x632c0` is the matching save-game UI.
   - It loads `savegame.pal` and `savegame.bm`.
   - It enumerates the same 25 save slots and supports text entry for save names.
+  - It first calls `sync_current_room_runtime_state_before_save_load`, which copies current-room monster runtime positions/facing back into `MonsterRecord` fields and refreshes the `SAVE_GAME` entrance from `g_player_combat_avatar`.
+  - The save header is now explicit: current disc id, `g_player_present_in_room`, `g_game_clock_tick_remainder_ms`, `g_current_room_def->room_name`, and `g_player_room_name`.
+  - The remainder of the save blob persists the player combat-avatar state, record lists (`ANIM`, `FLAG`, `MONSTER`, `NPC`, `OBJECT`, `REGION`, `TIMER`), the talk-state blocks, ammo counters, and `g_current_music_path`.
   - The object-state portion of the slot format persists only `object_name`, `current_x`, `current_y`, `current_z`, `current_owner_or_room`, `visible`, and `ident_shown`; the other object heap strings are reloaded from world data rather than restored from the save blob.
+  - `g_player_room_name` at `0xd60a8` is serialized separately from `g_current_room_def->room_name` and tracks the room string currently assigned to the live player actor.
+- `run_text_entry_dialog` at `0x59fe0` is the shared modal text-entry widget used by the save menu, password prompt, and dialogue input.
+  - Callers pass the target text-entry entity and CFT font explicitly; the initial string and maximum length arrive through hidden register arguments in this build.
+  - Input polling uses `g_buffered_console_char` plus DOS console-status queries through `is_console_char_available`.
 
 ## Map And Dialogue Helpers
 
@@ -344,6 +390,14 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - It validates the NPC through `g_npc_records`.
   - It uses `g_head_records` to resolve portrait variants and keyword/head assets.
   - It drives the textbox / keyword / voice interaction loop until the exchange completes or is interrupted.
+  - Before dispatching the per-NPC talk handler, it clears `g_dialogue_keyword_list_spec` and `g_dialogue_selected_topic_text`, spawns `g_dialogue_keyword_panel_entity` plus `g_dialogue_textbox1_entity` through `g_dialogue_textbox8_entity`, preloads the right PC portrait from the first matching `PC0`..`PC5` `HEAD` record, and preloads the left NPC portrait from `<NPC>0`.
+  - `set_dialogue_head_left_portrait` at `0x79d20` is the left-side portrait refresh helper on that path.
+    - It rebuilds the shared `g_dialogue_head_bitmap_path`, tears down any prior `g_dialogue_head_left_entity`, resolves the requested `HeadRecord.head_id`, and respawns the left portrait bitmap entity at `(6, 6)` before re-adding it to `g_render_entity_list`.
+  - The shared conversation globals on this path are now explicit:
+    - `g_dialogue_presented_object_name` is the optional inventory/evidence object name passed in from the click/use path.
+    - `g_dialogue_keyword_menu_default_topic` is the default keyword-menu topic loaded from zero-based `dialog.rsp` line `13`.
+    - `g_dialogue_skip_full_ui_flag` is the small `play_dialogue_line` bypass flag that the menu paths clear before standard portrait/textbox presentation.
+    - `g_dialogue_head_id_slots` at `0xd96c0` is the five-slot `0x1f`-byte head-ID cache used to reuse portrait resources during a conversation.
   - `g_talk_to_handler_entries` at `0xc2e88` is the fixed pre-dialogue talk-to dispatch table used by this path.
     - Each record is `0x24` bytes: a 32-byte NPC/object name token followed by a handler function pointer at `+0x20`.
     - `has_talk_to_handler_for_name` at `0x3a160` linearly scans that table until the empty-name terminator and is used by `run_harvester_main_loop`, `run_inventory_screen`, and `handle_target_interaction` to decide whether a target can enter NPC dialogue.
@@ -353,11 +407,21 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
       - `handle_talk_to_mr_potts` at `0x26000` is the shared target for `MR_POTTS`, `MR_POTTS_CEM10`, and `MR_POTTS_HALL`.
       - `handle_talk_to_mrs_potts` at `0x2ee70` is the shared target for `MRS_POTTS` and `MRS_POTTS_ST_BEDRM`.
       - Additional table targets that were still undefined are now explicit handlers: `handle_talk_to_whaley`, `handle_talk_to_moynahan`, `handle_talk_to_swell`, `handle_talk_to_priest`, `handle_talk_to_phelps`, `handle_talk_to_nude_man`, `handle_talk_to_valet`, `handle_talk_to_vet`, `handle_talk_to_wasp_woman`, `handle_talk_to_sergeant`, `handle_talk_to_pta_mom`, `handle_talk_to_stephanie`, `handle_talk_to_pastorelli`, `handle_talk_to_ryder`, `handle_talk_to_sparky`, and `handle_talk_to_parsons`.
-      - Shared-address alias groups now confirmed from the table include `MOYNAHAN` / `MOYNAHAN_MBLM`, `PTA_MOM1` through `PTA_MOM5`, and `RYDER` / `RYDER_TV_OUT`.
-      - The apparent `FUN_0002f7fd` helper is only an overlapping inner keyword-loop region inside `handle_talk_to_mrs_potts`, not the dispatch-table target itself.
+    - Shared-address alias groups now confirmed from the table include `MOYNAHAN` / `MOYNAHAN_MBLM`, `PTA_MOM1` through `PTA_MOM5`, and `RYDER` / `RYDER_TV_OUT`.
+    - The apparent `FUN_0002f7fd` helper is only an overlapping inner keyword-loop region inside `handle_talk_to_mrs_potts`, not the dispatch-table target itself.
+- `run_harvester_main_loop` only exposes the talk cursor / click path for runtime class-4 NPC entities when `has_talk_to_handler_for_name` succeeds and the NPC state byte at `+0x11a0` is not the dead-state value `'8'`.
+- `load_dialogue_index` at `0x79a20` is the dialogue subtitle/keyword bootstrap.
+  - It copies zero-based `dialog.rsp` line `0x0d` into `g_dialogue_keyword_menu_default_topic`, then opens `dialogue.idx` through the XFILE layer, XOR-decodes it, and builds the sorted `g_dialogue_index_entries` table of `{ wav_id, text_offset, text_length }` records.
+  - Because `dialogue.idx` is opened without a `1:\` / `2:\` set prefix, `open_xfile_stream` routes it through the direct-file path rather than the DAT archives.
+- `play_dialogue_line` at `0x7a690` is the single-line voice/subtitle presenter used by the talk handlers.
+  - It formats the voice sample path as `<VOICE config><wavId>.CMP`, loads that direct `CMP` file if present, seeks `g_dialogue_index_stream` to the matching `dialogue.idx` text span, XOR-decodes the subtitle text, selects the textbox art by wrapped-line count, refreshes the active portrait/head slot, and blocks until the line completes or is interrupted.
 - `load_dialogue_response_line` at `0x3a1a0` is the indexed `dialog.rsp` line loader used by the talk-to handlers.
   - It opens the ASCII `dialog.rsp` file, reads forward to the requested zero-based line index, strips the trailing CR/LF, and returns the shared `g_dialogue_response_line_buffer`.
   - Handlers such as `handle_talk_to_dwayne`, `handle_talk_to_hank`, and `handle_talk_to_mom` use it to map keyword-menu selections back to concrete topic strings before branching.
+  - `handle_talk_to_hank` has hidden post-`play_dialogue_line` blocks that immediately use this loader with `run_dialogue_response_menu`: after Hank's opening `0x703` line it shows zero-based `dialog.rsp` line `0xc7` (`1. Who are you?/2. Who am I?/3. Where am I?`) and seeds `g_dialogue_keyword_list_spec` from lines `0xc8`, `0xc9`, or `0xca` based on the selected slot.
+  - The same Hank handler also rewrites `g_dialogue_keyword_list_spec` from zero-based `dialog.rsp` line `0xd5` after the hidden multi-line `CoolDaddio` branch (`0x725` through `0x747`), which confirms the keyword buffer is live dialogue state rather than a static per-NPC list.
+  - `handle_talk_to_mom` compares the selected topic against uppercase `dialog.rsp` lines `0x116` through `0x171`; the hidden follow-up blocks rewrite `g_dialogue_keyword_list_spec` from zero-based line `0x13e` after the `0x1faf` / `0x1fb4` / `0x1fb9` / `0x1fbd` Slaughterhouse exchange and from line `0x140` after the `FATHER` branch.
+  - The observed early-game DOSBox Mom keyword panel in the kitchen matches zero-based `dialog.rsp` line `0x102` exactly: `Graduation/Sister/Hank/Cookies/Charity Bake Sale`.
 - The shared dialogue evidence/topic state shims behind those handlers are now partially explicit:
   - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
   - `get_set_discussed_note_checkbook_evidence` at `0x38330` gates the shared `NOTE` / `NOTE_PHOTOCOPY` / `CHECKBOOK` / `CHECKBOOK_PHOTOCOPY` evidence path used by Dwayne, Edna, Herrill, Johnson, Mom, and related handlers.
@@ -367,6 +431,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `get_set_discussed_whaley_herrill_photo` at `0x38370` gates the shared `PHOTO_OF_WHALEY_HERRILL` evidence path.
   - `get_set_discussed_karin_purse` at `0x38390` gates Dwayne's `K_PURSE` evidence state and the later `KARIN_FOUND_DEAD` follow-up that depends on whether he has seen the purse yet.
   - `get_set_dwayne_pending_karin_alive_followup` at `0x38260` is a Dwayne-local state bit set by the `BRING_KARIN_TO_SHERIFF` / `KARIN_FOUND_ALIVE` path, then read and cleared when the delayed next-day follow-up bark triggers.
+  - `get_set_dwayne_completed_karin_alive_followup` at `0x38480` is the shared completion bit written only after that delayed Dwayne follow-up fires; the hidden Dwayne branch plays `0x3780` / `0x378a`, clears `get_set_dwayne_pending_karin_alive_followup`, then sets this helper, and Stephanie later reads it on her hidden `KARIN_FOUND_ALIVE` path to decide whether `0x49c1` and the `0x49ea` / `0x49ef` exit lines should run.
   - `get_set_dwayne_discussed_boyles_button` at `0x38320` is the Dwayne-local `BOYLES_BUTTON` evidence bit; `handle_talk_to_dwayne` sets it when shown the button, and nearby hidden dialogue logic reads it at `0x2d262`.
   - `get_set_mom_father_topic_state` at `0x383b0` is a Mom-local state bit set from the `FATHER` keyword branch.
   - `get_set_mom_good_cause_day5_state` at `0x383c0` is a Mom-local day-5 `GOOD CAUSE` state bit; the visible caller currently reads it, but the concrete setter is still outside the recovered call graph.
@@ -389,19 +454,55 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - The wrapper mechanics are confirmed, but most of the remaining local quest-state semantics in that run are still left unnamed pending stronger caller/readback evidence from the hidden no-function regions.
 - `reset_all_talk_to_handler_state` at `0x3a0b0` is the startup/menu bulk reset for the talk-to subsystem.
   - It calls the per-NPC talk-to reset helpers plus `reset_shared_talk_to_discussion_state` before the handlers are reused.
+  - The reset fan-out is now mostly resolved into per-handler persisted state blocks that save/load serializes by address:
+    - `reset_talk_to_moynahan_state`, `reset_talk_to_johnson_state`, `reset_talk_to_jimmy_state`, `reset_talk_to_phelps_state`, `reset_talk_to_butcher_state`, `reset_talk_to_inquisitor_state`, `reset_talk_to_dad_state`, `reset_talk_to_mother_state`, `reset_talk_to_buster_state`, `reset_talk_to_mcknight_state`, `reset_talk_to_boyle_state`, `reset_talk_to_nude_man_state`, `reset_talk_to_herrill_state`, `reset_talk_to_mom_state`, `reset_talk_to_dark_woman_state`, `reset_talk_to_fireman1_state`, `reset_talk_to_fireman2_state`, `reset_talk_to_hank_state`, `reset_talk_to_loomis_state`, `reset_talk_to_edna_state`, `reset_talk_to_stephanie_state`, `reset_talk_to_ryder_state`, `reset_talk_to_sparky_state`, `reset_talk_to_memb_dir_state`, `reset_talk_to_parsons_state`, `reset_talk_to_wasp_woman_state`, `reset_talk_to_sergeant_state`, and `reset_talk_to_dwayne_state` each map to a same-named `g_*_talk_state_block`.
+    - `reset_talk_to_mr_potts_state` at `0x25fa0` resets the primary `g_mr_potts_talk_state_block` at `0xd2ab8`, while `reset_talk_to_mr_potts_aux_state` at `0x27d50` clears the adjacent `g_mr_potts_talk_aux_state_block` at `0xd2af4`.
+    - `reset_talk_to_mrs_potts_state` at `0x2ee60` seeds `g_mrs_potts_talk_state_block` at `0xd2d20`, while `reset_talk_to_mrs_potts_aux_state` at `0x2fce0` clears the adjacent `g_mrs_potts_talk_aux_state_block` at `0xd2d24`.
+  - A small remainder is still only mechanically identified:
+    - `reset_serialized_talk_state_block_d2c30` at `0x29700` resets `g_serialized_talk_state_block_d2c30`, and `reset_serialized_talk_state_flag_d2cd8` at `0x2dbf0` resets `g_serialized_talk_state_flag_d2cd8`.
+    - Both participate in `reset_all_talk_to_handler_state` and save/load serialization, but this pass did not find a visible talk handler that reads them directly.
+  - Two handler-local block roles are now more explicit from disassembly:
+    - `g_sergeant_talk_state_block` at `0xd2d4c` is the quest-delivery state cluster toggled across the `REMAINS`, `INVITE`, `BARBER_POLE`, `BOLTCLTH`, `SCRATCHED_TUCKER`, and `COMPLETED_LODGE_APPLICATION` branches.
+    - `g_stephanie_talk_state_block` at `0xd2e4c` begins with three one-shot intro flags and stores the corresponding story-day indices in later slots so the repeat lines can be suppressed until the day changes; later confirmed fields now include `g_stephanie_played_spyhole_branch` at `+0x10`, which is set by the hidden `0x2f9..0x2fc` spyhole keyword branch and later prepends `0x4970` before `0x4975`, and `g_stephanie_played_karin_outcome_branch` at `+0x48`, the one-shot gate for the `KARIN_FOUND_ALIVE` / `KARIN_FOUND_DEAD` payoff block.
+    - `g_hank_talk_state` at `0xd2de8` is a serialized `0x2c` Hank-local record: first-visit pending at `+0x0`, last-story-day gate at `+0x4`, `rangshot` branch active/pending flags at `+0x8` / `+0xc`, the last story day at `+0x10`, and six one-shot event-response flags at `+0x14..+0x28` for `STEPH_MIDGAME_PLAYED`, `BURNED_TV_STATION`, `BUSTED_ONCE`, `KARIN_KIDNAPED`, `KARIN_FOUND_ALIVE`, and `KARIN_FOUND_DEAD`.
+    - `g_moynahan_talk_state_block` at `0xd2ad0` is the simple no-item intro gate for Moynahan, and the adjacent `g_moynahan_ledger_evidence_line_played` at `0xd2ad4` keeps the special `HAVE_BOTH_LEDGERS` follow-up one-shot once the generic ledger topic has already been discussed.
+    - `g_mr_potts_talk_aux_state_block` at `0xd2af4` includes at least two proven live fields: `g_mr_potts_followup_day_index` for the same-day vs later-day branch after the auxiliary intro, and `g_mr_potts_pottsdam_digging_line_played` for the one-shot `POTTSDAM_DIGGING` reaction.
+    - `g_herrill_talk_state_block` carries a small gate tail: `g_herrill_dialogue_suppressed_flag` blocks the standard no-item branch, `g_herrill_requires_evidence_for_dialogue_flag` forces the inventory-evidence prerequisite, and `g_herrill_gascan_presented_flag` records the `GASCAN` presentation path.
+    - `g_mrs_potts_talk_aux_state_block` at `0xd2d24` is a compact return-visit/event tail; `g_mrs_potts_return_visit_followup_pending` drives the generic revisit line, while the remaining named fields are one-shot reactions for `DAY_5`, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, `DINER_BURNED`, `GOT_REMAINS_FOR_LODGE`, and `TAKEN_INVITE_TO_SERGEANT`.
+    - `g_mom_talk_state_block` carries a much larger persisted tail immediately after its primary intro flag: it stores `g_mom_intro_day_index`, the two post-intro gates, and one-shot reactions for `STEPH_MIDGAME_PLAYED`, `STEPHANIE_IS_DEAD`, `BURNED_TV_STATION`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `BOLT_OF_CLOTH_TAKEN`, the Karin outcome flags, `PC_ESCAPED_JAIL`, and several absent-NPC checks.
+    - `g_loomis_talk_state_block` adds a single extra revisit flag, `g_loomis_return_visit_followup_pending`, and `g_edna_talk_state_block` has at least one proven auxiliary one-shot flag, `g_edna_karin_purse_line_played`, for the `K_PURSE` evidence branch.
+    - `g_dwayne_talk_state_block` extends its intro flag with a serialized tail that includes the sheriff-in-diner introduction gates, `g_dwayne_pending_karin_alive_followup_day_index`, two externally driven reply-override flags, and a long one-shot event sequence for the later plot-state reactions.
+    - `g_memb_dir_talk_state_block` only adds one proven auxiliary field, `g_memb_dir_kewpie_kill_line_played`, which makes the `PC_KILLED_KEWPIE` reaction one-shot.
 - `reset_shared_talk_to_discussion_state` at `0x38530` zeroes the contiguous shared dialogue-state block behind the `get_set_discussed_*` wrappers and related talk-to follow-up flags.
 - `get_current_story_day_index` at `0x38bc0` collapses the `DAY_n` / `NIGHT_n` flags into a single numeric day index and is used by the dialogue handlers to suppress repeat barks or unlock later-day follow-ups.
 - `load_dialogue_index` at `0x79a20` parses `dialogue.idx` into `g_dialogue_index_entries`.
   - The table is now typed as `DialogueIndexEntry[3000]`, with each entry storing `wav_id`, `text_offset`, and `text_length`.
-  - `play_dialogue_line` binary-searches that table by WAV id, seeks through `g_dialogue_idx_handle`, and reads the subtitle text by offset/length.
+  - `play_dialogue_line` binary-searches that table by WAV id, seeks through `g_dialogue_index_stream`, and reads the subtitle text by offset/length.
 - `play_dialogue_line` at `0x7a690` is the inner dialogue-line player.
   - It resolves a `VOICE` asset, finds subtitle text for the WAV id, updates current head/textbox visuals, and waits for playback or interruption to finish.
+  - The voice path is formatted from the `VOICE` config value plus the decimal WAV id (`<VOICE><wavId>.CMP`), while portrait loads use the `HEAD` `portrait_path` base plus `.bm`.
+  - Subtitle text is rendered at `(178, 12)` with width `316`, and the textbox art is selected strictly from wrapped line count:
+    - 1 line -> `TEXTBOX1`
+    - 2 lines -> `TEXTBOX2`
+    - 3 lines -> `TEXTBOX3`
+    - 4-5 lines -> `TEXTBOX4`
+    - 6 lines -> `TEXTBOX5`
+    - 7-8 lines -> `TEXTBOX6`
+    - 9-10 lines -> `TEXTBOX7`
+    - 11+ lines -> `TEXTBOX8`
   - `decode_xor_aa_text_buffer` at `0x42f80` is the simple subtitle/text deobfuscator used by that path; it XORs each byte with `0xaa` while leaving CR/LF untouched.
   - The live portrait globals used by that path are now explicit:
     - `g_dialogue_head_left_entity` is the left-side spawned head bitmap entity created by `run_npc_dialogue`.
     - `g_dialogue_head_right_entity` is the matching right-side head bitmap entity.
     - `g_dialogue_head_bitmap_path` is the shared path buffer used by `run_npc_dialogue` and `play_dialogue_line` when selecting the current head bitmap resource.
+    - `g_dialogue_head_id_slots` is the five-slot cached head-ID table used to avoid unnecessary portrait reloads within the same conversation.
+    - `g_dialogue_skip_full_ui_flag` is checked at the top of `play_dialogue_line`; when nonzero, the function skips the full portrait/textbox presentation path.
+  - After picking a non-`PC` speaker, it removes the right PC head entity from the render list, refreshes the left portrait if needed, and re-adds only the speaking-side portrait before the line finishes; `run_dialogue_response_menu` and `run_dialogue_keyword_menu` inherit that render-list state, which is why native conversation menus show only the last speaker's portrait.
   - `reload_bitmap_entity_pixels_from_resource` at `0x4b7a0` refreshes the pixel payload of an already spawned bitmap entity from a same-sized XFILE bitmap resource; `play_dialogue_line` uses it to swap the currently visible left or right head portrait without respawning the entity.
+- The dialogue/text bitmap raster helpers on the same UI path are now explicit:
+  - `allocate_bitmap_buffer` at `0x229a0` allocates the 12-byte `BitmapBuffer` header plus its `width * height` pixel payload and can zero-initialize the pixel plane for dialogue/text-entry surfaces.
+  - `write_bitmap_buffer_pixel` at `0x237b0` writes a single pixel into that bitmap payload using `(x, y)` coordinates.
+  - `get_cft_glyph_width` at `0x58900` returns the width of a single CFT glyph, using the font's shared space/underscore width fallback for `' '` and `'_'`.
 - `handle_target_interaction` at `0x7ff50` is the high-level click/use dispatcher for world targets.
   - It can branch into NPC dialogue, inventory transfer, `USEITEM` actions, or generic fallback responses depending on the clicked entity and current item state.
 - `show_target_ident_text` at `0x80270` renders `IDENT` text for the current target by resolving the matching `TEXT` record and showing it until dismissal.
@@ -452,6 +553,20 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - For the normal sprite classes, palette index `0` fails that hitmask test and therefore behaves as transparent.
   - Class `3` bypasses the cursor bounds/mask checks and acts as the room-background fallback entity when nothing higher in the render list matches.
 
+## Joystick Input
+
+- `initialize_joystick_input_state` at `0x14490` is the startup callback for the joystick/game-port path.
+  - It registers a runtime-module record, then probes BIOS joystick support through `probe_joystick_presence_via_bios`.
+- `probe_joystick_presence_via_bios` at `0x14540` clears `g_joystick_state` and issues `int 15h AH=84h, DX=1`; the path treats an all-zero `AX` / `BX` return pair as joystick-not-present.
+- `poll_joystick_raw_state` at `0x144c0` talks directly to game-port I/O `0x201`.
+  - It strobes the port, counts how long bits `0` / `1` stay asserted to obtain raw X/Y readings, and maps bits `4` / `5` into the two joystick button flags.
+- `poll_calibrated_joystick_state` at `0x145c0` converts those raw counts into a `0..10` range using the stored calibration bounds.
+- `run_joystick_calibration_loop` at `0x14663` captures raw X/Y minima and maxima from button-driven calibration steps, then keeps polling and formatting normalized live values until keyboard input aborts the loop.
+- `g_joystick_state` at `0xc7e74` is now typed as `JoystickState`:
+  - `present`, `button1_pressed`, `button2_pressed`
+  - `current_x`, `current_y`
+  - `x_min_raw`, `x_max_raw`, `y_min_raw`, `y_max_raw`
+
 ## Idle Animation Timing
 
 - `g_idle_animation_activity_timestamp` at `0xd5968` is one side of the main-loop idle timer input.
@@ -463,6 +578,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `run_harvester_main_loop` recomputes it as `max(g_idle_animation_activity_timestamp, g_idle_animation_reset_timestamp) + 3000`, skips the branch in excluded rooms, and otherwise spawns `IDLE_ANIM` from `graphic\\roomanim\\pcloun02.abm`.
   - After the idle animation exits, the main loop re-arms the threshold by another 3000 ticks.
 - `show_pause_message` at `0x797c0` renders the `HARVESTER IS PAUSED` overlay and backdates both idle timestamps by `0xc1c`, allowing the idle-animation gate to become eligible again immediately after pause dismissal.
+  - It reuses `g_box3_panel_entity`, while `show_target_ident_text` selects among `g_box1_panel_entity` .. `g_box4_panel_entity` by matching `TextRecord.panel_id` against the `BOX1` / `BOX2` / `BOX3` / `BOX4` strings.
 
 ## CD-ROM Media Check
 
@@ -476,6 +592,11 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 
 ## Struct Recovery
 
+- The startup/menu configuration state is now bounded:
+  - `g_config_environment` at `0xe2438` is the global linked-list store used by `startup_main`, the main menu toggles, and the quick-tips path.
+  - `ConfigEnvList` is an 8-byte list header with `tail` at offset `+0` and `head` at offset `+4`.
+  - `ConfigEnvEntry` is a 12-byte node `{ char *key, char *value, ConfigEnvEntry *next }`.
+  - `find_config_value_by_key`, `get_config_integer_value`, and `set_environment_string_value` are the case-insensitive lookup/update helpers over that list, while `get_environment_value` is the separate process-environment `NAME=VALUE` probe used for `TEMP` / `TMP`.
 - Shared runtime constructor layer now identified:
   - `initialize_entity_common_fields` at `0x4ab70` seeds the common runtime entity header from register/stack arguments for `name`, `x`, `y`, `z`, a class/type argument, and one extra init argument.
   - `spawn_abm_entity_base` at `0x4b890` calls `initialize_entity_common_fields`, loads an ABM/bitmap resource through XFILE, decodes its frames, and seeds common animation state.
@@ -487,9 +608,12 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `spawn_anim_entity_from_record` at `0x4bbe0`
   - `spawn_npc_entity_from_record` at `0x4cd00`
   - `spawn_monster_entity_from_record` at `0x53a10`
+  - `spawn_timer_entity_from_record` at `0x59390`
   - `spawn_object_entity_from_record` promotes sprite-backed objects whose initial coordinates are `(0, 0)` and whose loaded bitmap is `640x480` to runtime class `3`, the room-background object class.
   - Hotspot-only objects with no action tag become class `0x16`; the other hotspot-only objects become class `0x15`.
+  - `spawn_region_entity_from_record` builds runtime class `0x19` rectangular hotspots from the record bounds and Z span, copies `desired_facing`, and maps `cursor_enabled` onto the runtime hotspot interaction flag.
   - `spawn_anim_entity_from_record` immediately drives `show_entity_visual`, so room `ANIM` records use the centered anchor path during room setup, while room bitmap objects are inserted at their record `x` / `y` without that initial recentering step.
+  - `spawn_timer_entity_from_record` builds runtime class `0x17` timer entities keyed by `TimerRecord.timer_name`; when the timer starts enabled it seeds the start tick from `DAT_000c7e44` and computes the first expiration as `start_tick + initial_value * 100`.
 - `CommandRecord` is now stable enough to use directly in the event interpreter:
   - `opcode`, `trigger_tag`, `arg1`..`arg4`, `runtime_flag`, `next`
 - `EntranceRecord` is stable and drives room transitions and save/load handoff:
@@ -509,6 +633,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `inventory_text_key` is the `TEXT` lookup used by the inventory screen tooltip/description panel.
   - `ident_shown` defaults true when `ident_text_key` is missing, is copied into the runtime object entity, and is set after the IDENT text has been shown or when an object is moved into inventory.
   - `action_tag` was corrected from the older `pickup_action_tag` label: the main loop dispatches it on initial object activation, on successful carry/pickup into inventory, and on later carried-object activation, so the field is broader than pickup-only handling.
+  - `alt_sprite_path` is also the native pickup-capability flag: `run_harvester_main_loop` promotes room objects with a non-null alternate sprite from IDENT-first cursor sequence `1` to pickup sequence `5`, and the post-IDENT click path moves those objects into `INVENTORY` even when `operatable` is clear.
   - Save/load only persists the mutable object state: `current_x`, `current_y`, `current_z`, `current_owner_or_room`, `visible`, and `ident_shown`, keyed by `object_name`.
   - `interaction_label` is copied into the runtime object entity and drives the main-loop hover / use prompt text; a `NULL_ID` label suppresses prompt generation.
   - `z_extent` is copied into the runtime object entity at offset `+0x20`; `FUN_0004cae0` uses the live interval `[entity + 0x1c, entity + 0x1c + 0x20]` before the 2D overlap test, which confirms that the field participates in third-axis collision gating.
@@ -516,16 +641,31 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `spawn_object_entity_from_record` uses `sprite_path` while the object remains at its initial position / owner outside `INVENTORY`, otherwise it switches to `alt_sprite_path`.
   - When `sprite_path` is null, the same helper allocates a rectangle-only hotspot from `current_x`, `current_y`, `bounds_x2`, and `bounds_y2` instead of a bitmap-backed render entity.
 - `RegionRecord` is now structurally clear:
-  - `min_x`, `min_y`, `max_x`, `max_y`, `floor_z`, `ceiling_z`
-  - `facing`, `region_name`, `room_name`, `action_tag`
-  - `start_enabled`, `runtime_enabled`, `flag_2a`, `next`
-- `NpcRecord` now has stable placement/runtime fields:
+  - `min_x`, `min_y`, `max_x`, `max_y`, `min_z`, `max_z`
+  - `desired_facing`, `region_name`, `room_name`, `action_tag`
+  - `start_enabled`, `runtime_enabled`, `cursor_enabled`, `next`
+  - `parse_region_record` maps `FRONT`, `LEFT`, `RIGHT`, and `BACK` into `desired_facing` values `0`, `1`, `2`, and `3`.
+  - `room_setup` only instantiates region hotspots when `room_name` matches the resolved room and `start_enabled` is set; the live room-state mirror is `runtime_enabled`.
+- `NpcRecord` now has stable placement/persisted-state fields:
   - `pos_x`, `pos_y`, `pos_z`, `frame_delay`
   - `model_path`, `npc_name`, `room_name`, `audio_path`, `entity_init_arg`
-  - `runtime_spawned`, `runtime_actor`
-  - parser booleans are `active` and `visible`; a third byte mirrors `visible` at load time and appears to be the persisted visibility state
+  - `death_or_monsterfy_flag`, `death_damage_type`
+  - parser booleans are `active` and `visible`; `saved_visible` mirrors `visible` at load time, but its exact save/load role is still unresolved
+  - `HARVEST.SCR` `NPC` lines match that layout directly after the four numeric fields:
+    `room_name`, `model_path`, `npc_name`, `monsterfy_target_name`, `active`, `visible`,
+    `on_death_action_tag`, `audio_path`, `entity_init_arg`
   - `monsterfy_target_name` is consumed by the `change2monster` / monsterfy path in `FUN_0004d750`
   - `on_death_action_tag` is dispatched when the NPC dies without transforming
+  - `death_or_monsterfy_flag` is latched both by the no-live-entity `KILL_NPC` / `MONSTERFY` path and by the live actor completion path in `update_actor_runtime_state`, which is why later room setup no longer respawns the normal NPC actor.
+  - `death_damage_type` preserves the pending `BLUDGE` / `SLASH` / `PROJ` selector as values `1` / `2` / `4`; `spawn_npc_entity_from_record` reads that persisted value when `death_or_monsterfy_flag` is already set so it can choose the matching dead / removed-state branch.
+  - `SET_NPC` updates the live `active` / `visible` bytes on the matching record; when the target NPC belongs to the current room, the dispatcher removes its render entity when `visible` clears and spawns one through `spawn_npc_entity_from_record` when `visible` is set.
+  - `set_npc_active_visible_state` at `0x7cab0` is the concrete `SET_NPC` helper.
+    - It writes the matching record's `active` / `visible` bytes, removes the current-room render entity when visibility clears, respawns it through `spawn_npc_entity_from_record` when visibility becomes set, and mirrors the `active` byte onto the live entity state at `+0x52`.
+  - `queue_named_npc_death_or_monsterfy_transition` at `0x7c940` is the name-based `KILL_NPC` / `MONSTERFY` helper.
+    - It resolves the live NPC render entity when present, translates `BLUDGE` / `SLASH` / `PROJ` into the pending damage-type values `1` / `2` / `4`, queues `queue_npc_death_or_monsterfy_transition`, and preserves the pending damage-type selection on the record when no live entity exists yet.
+  - `is_named_npc_death_type_clear` at `0x7c840` is the talk-handler predicate shared by `handle_talk_to_mom` and `handle_talk_to_dwayne`.
+    - It resolves the named `NpcRecord` and returns true only while `death_damage_type` is still zero, so dialogue branches stop treating that NPC as available after a persisted kill / monsterfy transition has been latched.
+  - `room_setup` walks the room-matching NPC list after room animations and before the player handoff, which is why `PCLIVRM` needs the `SET_PC_LIV_DAY -> SET_NPC "HANK" "T" "T"` startup chain to materialize Hank in the living room.
 - `MonsterRecord` now has stable placement/spawn state and resource fields:
   - `pos_x`, `pos_y`, `pos_z`, `engage_distance`
   - `damage_amount`, `initial_hit_points`, `current_hit_points`
@@ -571,6 +711,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `room_name`, `anim_path`, `anim_name`
   - script booleans: `active`, `visible`, `loop`, `backward`, `ping_pong`, `remove`
   - `room_setup` and room teardown reuse the same live bytes as runtime state: `runtime_active`, `runtime_visible`, and `runtime_state`
+  - `room_setup` materializes a room animation when either `active` or `visible` is set, but `spawn_anim_entity_from_record` copies only `active` into the live animation-enable flag.
+  - The `SET_ANIM` dispatcher branch updates both record bytes, respawns or removes the live entity from `visible`, and toggles ticking on the already-spawned entity from `active`, so a visible-but-inactive room anim remains on its initial frame instead of advancing.
 - `TextRecord` now has stable string identity:
   - `text_id`, `panel_id`, `text_value`, `next`
   - The parser normalizes `_` to space characters in `text_value`
@@ -583,6 +725,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `TimerRecord` now has stable timing/key fields:
   - `initial_value`, `current_value`, `timer_name`, `arg1`, `arg2`, `enabled`, `next`
   - parser booleans are `loop` and `global`
+  - `room_setup` treats `arg1` as the room/scope key for room-local timers and only materializes timer entities when it matches the resolved room name.
+  - `initialize_timer_entity_common_fields` at `0x4ab10` zeroes the lightweight timer-entity header before `spawn_timer_entity_from_record` installs the countdown fields and adds the entity to `g_render_entity_list`.
 - `UseItemRecord` is stable:
   - `item_name`, `target_name`, `action_tag`, `next`
 - `RoomRecord` is partly recovered:
@@ -599,8 +743,11 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 
 - `strcmp_ascii` at `0x86430` is a fast `strcmp`-style helper that returns `0` on equality.
 - `atoi_decimal` at `0x869d4` is a small decimal parser used heavily by the room event system.
+- `hex_nibble_to_ascii` at `0x80e82` is the small `0..15 -> 0..9/a..f` helper used while building temporary file-stream names during `shutdown_file_stream`.
 - `rescale_entity_sprite_for_depth` at `0x4b440` rescales an entity sprite from the current room depth model.
   - It applies the room-wide depth slope derived from `full_scale_z`, `max_z_scale_percent`, and `max_z`, rescales the backing bitmap, and updates the runtime width / height / anchor offsets.
+- `scale_raw_bitmap_nearest` at `0x237c0` is the `RawBitmap` scaler under `rescale_entity_sprite_for_depth`.
+  - It grows or reallocates the destination bitmap as needed and uses nearest-neighbor duplication/sampling depending on whether the target size expands or shrinks.
 - `set_entity_screen_position` at `0x4b6f0` writes the live entity screen-space `x`, `y`, and depth/z anchor used by overlap and render ordering.
 - `update_visible_entity_screen_position` at `0x4c860` is the visible-entity wrapper used by `sync_cursor_entity_position`.
   - It only runs while the entity's visibility nesting count is nonnegative.
@@ -631,12 +778,21 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `show_entity_visual` increments the nesting and, when the entity becomes visible again, refreshes the current frame bitmap and re-adds it to `g_render_entity_list`.
   - The recentering path only runs when the internal anchor flag at `+0x4f` is clear.
   - That path subtracts half the current bitmap width and height from the stored screen position and subtracts half `z_extent` from the live depth before the render-list update.
+  - For the normal actor path with that anchor flag left set, the stored `screen_x` / `screen_y` remain pre-offset bases and the live frame `x_offset` / `y_offset` are applied later by the visual refresh path, so `room_setup` writes actor screen bases rather than final top-left pixel coordinates.
 - `is_cursor_within_entity_bounds` at `0x4b670` is the rectangle gate for runtime entities; class `0x16` skips hit-testing entirely.
 - `is_cursor_over_entity_hitmask` at `0x4b5b0` adds the transparent-pixel test on top of that rectangle gate.
   - Runtime classes `0x15`, `0x18`, and `0x19` bypass the per-pixel transparency check and behave as rectangle-only hotspots.
 - `spawn_scaled_abm_entity_from_resource` at `0x4bda0` is the scale-managed variant of `spawn_abm_entity_from_resource`.
   - It loads the same ABM resource format, but additionally marks the runtime entity for depth scaling and seeds the live sprite-scale field before the first `show_entity_visual` / render-list add.
   - `run_harvester_main_loop` uses it for `IDLE_ANIM` with `graphic\\roomanim\\pcloun02.abm`, copying the live player avatar's current scale so the temporary idle actor inherits the same room-depth sizing.
+- `spawn_bitmap_entity_from_resource` at `0x4ace0` is the raw-bitmap / UI-panel spawner used by menus, dialogue heads, startup overlays, and other non-ABM visuals.
+  - It does not build the full `RenderEntityRuntime`; instead it allocates the smaller 0x4c render-list wrapper also used by `spawn_text_entity`.
+  - Offset `+0x0` points to the primary 12-byte `BitmapBuffer { width, height, pixels }`, while `+0x4` mirrors that same pointer as the current-bitmap slot.
+  - The shared low-offset header still matches `initialize_entity_common_fields`: name at `+0x10`, depth data at `+0x1c` / `+0x20`, screen position at `+0x3c` / `+0x40`, cached width/height at `+0x2c` / `+0x30`, and render-list links at `+0x44` / `+0x48`.
+  - When the optional external pixel buffer pointer is null it allocates pixel storage itself; otherwise it reuses the caller-provided buffer and only refreshes the copied pixel payload.
+- `reload_bitmap_entity_pixels_from_resource` at `0x4b7a0` only refreshes the pixel payload of that same wrapper and aborts if the resource width or height changes, so dialogue portrait swaps and similar callers must keep the same bitmap dimensions.
+- `free_entity_bitmap_buffers` at `0x4b3a0` tears down the primary and optional secondary `BitmapBuffer` slots for the small wrapper.
+  - It intentionally skips freeing pixel storage when the entity name is `STAT` or `LOGO`, which indicates those two bitmap entities can point at shared externally owned pixel buffers.
 - `attach_abm_resource_to_entity` at `0x4c0f0` is the shared decoded-ABM binding layer under the runtime-entity spawners and combat-avatar reloads.
   - It stores the decoded ABM blob on the live entity, builds the per-frame pointer table, allocates the current frame bitmap storage, and seeds animation frame `0`.
   - Callers now confirmed from the binary are `spawn_abm_entity_base`, `spawn_abm_entity_from_resource`, `spawn_scaled_abm_entity_from_resource`, `spawn_anim_entity_from_record`, `set_player_combat_loadout`, and `reset_player_combat_avatar`.
@@ -865,7 +1021,10 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `spawn_text_entity` at `0x59c00` and `spawn_text_entry_entity` at `0x59f40` are the shared rendered-text constructors for menus, prompts, quick tips, and dialogue UI.
   - They consume the `load_font_resource` font blobs, attach their results to `g_render_entity_list`, and rely on `g_dirty_rect_list` plus `flush_dirty_rects_to_screen` for incremental redraw.
 - `run_dialogue_response_menu` at `0x7af10` is the slash-delimited response list UI used by the NPC dialogue system.
+  - It parses one numbered slash-delimited `dialog.rsp` line into wrapped response rows, chooses `TEXTBOX1`..`TEXTBOX8` from the total wrapped line count, titles the panel with the literal `Responses`, and returns a 1-based selection from mouse row hit-testing or digit keys `1` through `9`.
 - `run_dialogue_keyword_menu` at `0x7ba40` is the slash-delimited keyword menu plus free-text `Other` path used by the NPC dialogue system.
+  - It always appends `g_dialogue_keyword_menu_default_topic`, loaded by `load_dialogue_index` from zero-based `dialog.rsp` line `13`, plus a literal `Other` entry that calls `run_text_entry_dialog`.
+  - ESC does not silently cancel the keyword menu; it writes that default topic into `g_dialogue_selected_topic_text` and returns through the normal handler path.
 
 ## Current Blockers
 
diff --git a/TRACKER.md b/TRACKER.md
index e979fd9d8e7..01a44897df9 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,16 +15,16 @@
 
 ## Last Confirmed Action
 
-- Confirmed the startup value-`2` / outer-exit analogue is reached through `GODEATHFLIC`, then aligned the startup runtime with that native game-over return-to-menu path.
-  - Confirmed `parse_command_record @ 0x5e410` maps `GODEATHFLIC` to opcode `0x1c`, and `dispatch_room_event_actions @ 0x60ee0` handles that case by clearing the current palette, playing the supplied FST path, calling `run_game_over_screen()`, restoring music volume, and returning immediately.
-  - Confirmed `run_game_over_screen @ 0x7c540` writes `DAT_000d60bc = (run_main_menu() == -1) + 1`, so the same native path carries both the already-implemented value-`1` nested-abort case and the value-`2` high-level exit-to-menu case.
-  - Confirmed the decoded `HARVEST.SCR` actually uses that route in the blood-drive chain: `BLOOD_DRIVE_A -> CHANGE_LIGHTING NONE -> BLOOD_DRIVE_B -> GOFLIC GRAPHIC\\FST\\EVENING.FST -> BLOOD_DRIVE_C -> GODEATHFLIC GRAPHIC\\FST\\C006.FST`.
-  - ScummVM now treats `GODEATHFLIC` as a terminal startup interaction result, plays the death FST, marks a pending main-menu return, and unwinds recursive room/dialogue continuations without resuming the abandoned room chain or spawning nested menu loops.
-  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the `GODEATHFLIC` / outer-exit work.
+- Recovered the native dialogue resource split and the Mom/Hank keyword-buffer state transitions, then aligned the startup runtime with the confirmed Mom menu path.
+  - Confirmed `load_dialogue_response_line @ 0x3a1a0` reads keyword/menu text from direct file `dialog.rsp`, while `load_dialogue_index @ 0x79a20` and `play_dialogue_line @ 0x7a690` map wav ids through direct `dialogue.idx` subtitle records plus `VOICE/*.CMP` audio, not DAT-backed archive paths.
+  - Confirmed `handle_talk_to_hank @ 0x33a30` seeds `g_dialogue_keyword_list_spec` from `dialog.rsp` lines `0xc8`, `0xc9`, or `0xca` after the opening response menu and rewrites it to line `0xd5` after the hidden `0x725` / `0x729` / `0x72d` / `0x733` / `0x737` / `0x73b` / `0x741` / `0x747` branch.
+  - Confirmed `handle_talk_to_mom @ 0x31140` compares keyword selections against `dialog.rsp` lines `0x116` through `0x171`, rewrites the keyword buffer to line `0x13e` after the `0x1faf` / `0x1fb4` / `0x1fb9` / `0x1fbd` Slaughterhouse exchange, and rewrites it to line `0x140` after the `FATHER` branch; the observed early-game DOSBox kitchen menu matches zero-based `dialog.rsp` line `0x102` exactly.
+  - ScummVM now seeds Mom's keyword menu from that confirmed `dialog.rsp` line, restores the hidden Slaughterhouse follow-up sequence and menu rewrite, and updates the confirmed Mom portrait-variant calls on the intro, Cookies, and Sparky exchanges.
+  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the dialogue/resource alignment work.
 
 ## Next Suggested Action
 
-1. Recover startup `GOFLIC` and `CHANGE_LIGHTING` handling for the same confirmed blood-drive sequence.
-   - The native `GODEATHFLIC` / value-`2` path is now covered, but the decoded `BLOOD_DRIVE_*` chain still depends on the preceding `CHANGE_LIGHTING NONE` and `GOFLIC GRAPHIC\\FST\\EVENING.FST` opcodes before it reaches `C006.FST`.
-2. Revisit startup monster death / on-death command execution once there is a safe trigger path.
-   - The decoded `MONSTER` records still carry native death action tags such as `JIMMY_DEATH_COM`, but startup parity still lacks a non-speculative monster combat/death state transition to fire them.
+1. Isolate Mom's initial `g_dialogue_keyword_list_spec` seed in Ghidra instead of relying on the observed DOSBox `dialog.rsp` line match.
+   - Static disassembly already confirms the later `0x13e` and `0x140` rewrites plus all keyword comparisons, but the first native buffer write that produces the observed day-1 line `0x102` has not been recovered cleanly yet.
+2. Audit the remaining startup NPC talk handlers for the same direct-file `dialog.rsp` / `dialogue.idx` / `VOICE/*.CMP` split.
+   - Hank is now bounded (`0xc8` / `0xc9` / `0xca` -> `0xd5`) and Mom is partially bounded, but the other keyword-driven handlers still need the same parity pass before broader dialogue cleanup.
diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
index c2383e2bc6d..0409140dc0e 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/startup_dialogue.cpp
@@ -118,23 +118,23 @@ static const HankDialogueTopicLine kHankDialogueTopicLines[] = {
 };
 
 static const DialogueLineEntry kMomIntroLines[] = {
-	{ 0x1dd7, "MOM", 0 },
+	{ 0x1dd7, "MOM", 1 },
 	{ 0x1ddc, "PC", 0 },
-	{ 0x1de0, "MOM", 0 },
-	{ 0x1de4, "PC", 0 }
+	{ 0x1de0, "MOM", 2 },
+	{ 0x1de4, "PC", 2 }
 };
 
 static const DialogueLineEntry kMomCookiesLines[] = {
 	{ 0x1e18, "PC", 0 },
-	{ 0x1e1d, "MOM", 0 },
+	{ 0x1e1d, "MOM", 1 },
 	{ 0x1e21, "PC", 0 }
 };
 
 static const DialogueLineEntry kMomSparkyLines[] = {
 	{ 0x1ed7, "PC", 0 },
-	{ 0x1edb, "MOM", 0 },
+	{ 0x1edb, "MOM", 2 },
 	{ 0x1ee1, "PC", 0 },
-	{ 0x1ee5, "MOM", 0 }
+	{ 0x1ee5, "MOM", 2 }
 };
 
 static const DialogueLineEntry kMomFatherLines[] = {
@@ -155,6 +155,13 @@ static const DialogueLineEntry kMomGoodCauseDay5Lines[] = {
 	{ 0x21b7, "PC", 0 }
 };
 
+static const DialogueLineEntry kMomSlaughterhouseLines[] = {
+	{ 0x1faf, "PC", 4 },
+	{ 0x1fb4, "MOM", 0 },
+	{ 0x1fb9, "PC", 2 },
+	{ 0x1fbd, "MOM", 0 }
+};
+
 static const int kMomPtaTopicResponseLines[] = { 0x125, 0x126, 0x127, 0x128 };
 static const int kMomCookingTopicResponseLines[] = { 0x131, 0x132, 0x133 };
 static const int kMomBakeSaleTopicResponseLines[] = { 0x145, 0x146, 0x147 };
@@ -162,6 +169,9 @@ static const int kMomPottsdamTopicResponseLines[] = { 0x14b, 0x14c };
 static const int kMomMeatPlantTopicResponseLines[] = { 0x151, 0x152 };
 static const int kMomMoynahanTopicResponseLines[] = { 0x160, 0x161 };
 static const int kMomNewspaperFireTopicResponseLines[] = { 0x162, 0x163 };
+static const int kMomInitialTopicBufferResponseLine = 0x102;
+static const int kMomSlaughterhouseFollowupTopicBufferResponseLine = 0x13e;
+static const int kMomFatherFollowupTopicBufferResponseLine = 0x140;
 
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
 	const StartupText *startupText = engine.getStartupText();
@@ -1129,8 +1139,11 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 
 	if (npc.npcName.equalsIgnoreCase("MOM")) {
 		const int currentStoryDayIndex = startupScript->getCurrentStoryDayIndex();
-		Common::String &momTopicBuffer = _momRoomDialogueState.currentTopicBuffer;
-		int &momTopicBufferLineIndex = _momRoomDialogueState.currentTopicBufferLineIndex;
+		Common::String momTopicBuffer;
+		int momTopicBufferLineIndex = -1;
+		auto assignMomTopicBuffer = [&](int responseLineIndex) {
+			assignTopicBuffer(momTopicBuffer, momTopicBufferLineIndex, responseLineIndex, "Mom topic buffer");
+		};
 		auto runMomGoodbye = [&]() -> Common::Error {
 			if (!startupScript->getFlagValue("DAY_FLAG")) {
 				return playDialogueLine(0x256e, "MOM");
@@ -1140,6 +1153,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 
 			return playDialogueLine(0x2051, "MOM");
 		};
+		assignMomTopicBuffer(kMomInitialTopicBufferResponseLine);
 
 		if (!usedItemName.empty()) {
 			if (usedItemName.equalsIgnoreCase("NOTE") ||
@@ -1369,9 +1383,10 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			} else if (matchesResponseLine(selectedTopic, 0x13a)) {
 				lineError = playDialogueLine(0x1fa3, "MOM");
 			} else if (matchesResponseLine(selectedTopic, 0x13c)) {
-				lineError = playDialogueLine(0x1faf, "PC");
+				lineError = playDialogueEntrySequence(kMomSlaughterhouseLines, ARRAYSIZE(kMomSlaughterhouseLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
+				assignMomTopicBuffer(kMomSlaughterhouseFollowupTopicBufferResponseLine);
 				continue;
 			} else if (matchesResponseLine(selectedTopic, 0x13f)) {
 				_momRoomDialogueState.fatherTopicState = true;
@@ -1384,7 +1399,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 					if (lineError.getCode() != Common::kNoError)
 						return lineError;
 				}
-				assignTopicBuffer(momTopicBuffer, momTopicBufferLineIndex, 0x140, "Mom topic buffer");
+				assignMomTopicBuffer(kMomFatherFollowupTopicBufferResponseLine);
 				continue;
 			} else if (matchesResponseLine(selectedTopic, 0x141)) {
 				if (startupScript->getFlagValue("STEPH_MIDGAME_PLAYED"))
diff --git a/engines/harvester/startup_dialogue.h b/engines/harvester/startup_dialogue.h
index cf851da3e8b..ae6f8ba33d6 100644
--- a/engines/harvester/startup_dialogue.h
+++ b/engines/harvester/startup_dialogue.h
@@ -58,7 +58,7 @@ public:
 		int currentTopicBufferLineIndex = -1;
 	};
 
-		struct MomRoomDialogueState {
+	struct MomRoomDialogueState {
 		bool introPending = true;
 		bool sameDayIntroLineEnabled = false;
 		bool postIntroDefaultLineEnabled = false;
@@ -83,14 +83,12 @@ public:
 		bool day6Shown = false;
 		bool fatherTopicState = false;
 		bool goodCauseDay5State = false;
-			Common::String currentTopicBuffer;
-			int currentTopicBufferLineIndex = -1;
-		};
+	};
 
-		struct JimmyRoomDialogueState {
-			bool firstNoItemLinePending = true;
-			bool paperHandoffStateSet = false;
-		};
+	struct JimmyRoomDialogueState {
+		bool firstNoItemLinePending = true;
+		bool paperHandoffStateSet = false;
+	};
 
 	StartupDialogueSystem(HarvesterEngine &engine, Common::Point &mousePos);
 
@@ -101,12 +99,12 @@ public:
 
 private:
 	HarvesterEngine &_engine;
-		Common::Point &_mousePos;
-		HankRoomDialogueState _hankRoomDialogueState;
-		MomRoomDialogueState _momRoomDialogueState;
-		JimmyRoomDialogueState _jimmyRoomDialogueState;
-		bool _sharedKarinKidnapedDialogueState = false;
-	};
+	Common::Point &_mousePos;
+	HankRoomDialogueState _hankRoomDialogueState;
+	MomRoomDialogueState _momRoomDialogueState;
+	JimmyRoomDialogueState _jimmyRoomDialogueState;
+	bool _sharedKarinKidnapedDialogueState = false;
+};
 
 } // End of namespace Harvester
 


Commit: 643b57b534d3ba10b286d0138303ef830da4f709
    https://github.com/scummvm/scummvm/commit/643b57b534d3ba10b286d0138303ef830da4f709
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:54+02:00

Commit Message:
HARVESTER: Restore mom dialogue menu loop

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/startup_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index d22fd915565..7e0a9cb831d 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -420,8 +420,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - Handlers such as `handle_talk_to_dwayne`, `handle_talk_to_hank`, and `handle_talk_to_mom` use it to map keyword-menu selections back to concrete topic strings before branching.
   - `handle_talk_to_hank` has hidden post-`play_dialogue_line` blocks that immediately use this loader with `run_dialogue_response_menu`: after Hank's opening `0x703` line it shows zero-based `dialog.rsp` line `0xc7` (`1. Who are you?/2. Who am I?/3. Where am I?`) and seeds `g_dialogue_keyword_list_spec` from lines `0xc8`, `0xc9`, or `0xca` based on the selected slot.
   - The same Hank handler also rewrites `g_dialogue_keyword_list_spec` from zero-based `dialog.rsp` line `0xd5` after the hidden multi-line `CoolDaddio` branch (`0x725` through `0x747`), which confirms the keyword buffer is live dialogue state rather than a static per-NPC list.
-  - `handle_talk_to_mom` compares the selected topic against uppercase `dialog.rsp` lines `0x116` through `0x171`; the hidden follow-up blocks rewrite `g_dialogue_keyword_list_spec` from zero-based line `0x13e` after the `0x1faf` / `0x1fb4` / `0x1fb9` / `0x1fbd` Slaughterhouse exchange and from line `0x140` after the `FATHER` branch.
-  - The observed early-game DOSBox Mom keyword panel in the kitchen matches zero-based `dialog.rsp` line `0x102` exactly: `Graduation/Sister/Hank/Cookies/Charity Bake Sale`.
+  - Raw disassembly inside `handle_talk_to_mom` shows additional inline `load_dialogue_response_line` -> copy-to-`g_dialogue_keyword_list_spec` blocks that the initial decompiler pass did not surface cleanly. The pre-menu seeds are now confirmed directly in code: intro `0x102`, same-day follow-up `0x104`, later default `0x105`, day-5 override `0x113`, and day-6 override `0x114`.
+  - The same hidden Mom blocks also jump back to `run_dialogue_keyword_menu`, so her keyword flow is a looping dialogue state machine rather than a single-topic exchange. Confirmed rewrites are `0x116->0x118`, `0x119->0x11a`, `0x11d->0x120`, `0x121->0x122`, `0x123->0x124`, `0x125..0x128->0x12a`, `0x12b->0x12c`, `0x12d->0x12e`, `0x12f->0x130`, `0x131..0x133->0x134`, `0x135->0x136`, `0x137->0x13b`, `0x13c->0x13e`, `0x13f->0x140`, `0x141->0x144`, `0x145..0x147->0x148`, `0x14b..0x14c->0x150`, `0x151..0x152->0x154`, and `0x15b->0x15c`.
 - The shared dialogue evidence/topic state shims behind those handlers are now partially explicit:
   - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
   - `get_set_discussed_note_checkbook_evidence` at `0x38330` gates the shared `NOTE` / `NOTE_PHOTOCOPY` / `CHECKBOOK` / `CHECKBOOK_PHOTOCOPY` evidence path used by Dwayne, Edna, Herrill, Johnson, Mom, and related handlers.
diff --git a/TRACKER.md b/TRACKER.md
index 01a44897df9..b48f5deccfd 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,16 +15,15 @@
 
 ## Last Confirmed Action
 
-- Recovered the native dialogue resource split and the Mom/Hank keyword-buffer state transitions, then aligned the startup runtime with the confirmed Mom menu path.
-  - Confirmed `load_dialogue_response_line @ 0x3a1a0` reads keyword/menu text from direct file `dialog.rsp`, while `load_dialogue_index @ 0x79a20` and `play_dialogue_line @ 0x7a690` map wav ids through direct `dialogue.idx` subtitle records plus `VOICE/*.CMP` audio, not DAT-backed archive paths.
-  - Confirmed `handle_talk_to_hank @ 0x33a30` seeds `g_dialogue_keyword_list_spec` from `dialog.rsp` lines `0xc8`, `0xc9`, or `0xca` after the opening response menu and rewrites it to line `0xd5` after the hidden `0x725` / `0x729` / `0x72d` / `0x733` / `0x737` / `0x73b` / `0x741` / `0x747` branch.
-  - Confirmed `handle_talk_to_mom @ 0x31140` compares keyword selections against `dialog.rsp` lines `0x116` through `0x171`, rewrites the keyword buffer to line `0x13e` after the `0x1faf` / `0x1fb4` / `0x1fb9` / `0x1fbd` Slaughterhouse exchange, and rewrites it to line `0x140` after the `FATHER` branch; the observed early-game DOSBox kitchen menu matches zero-based `dialog.rsp` line `0x102` exactly.
-  - ScummVM now seeds Mom's keyword menu from that confirmed `dialog.rsp` line, restores the hidden Slaughterhouse follow-up sequence and menu rewrite, and updates the confirmed Mom portrait-variant calls on the intro, Cookies, and Sparky exchanges.
-  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the dialogue/resource alignment work.
+- Recovered Mom's hidden keyword-buffer loop directly from raw Ghidra disassembly and aligned the startup runtime with the confirmed native menu state machine.
+  - Confirmed `handle_talk_to_mom @ 0x31140` really does seed `g_dialogue_keyword_list_spec` from zero-based `dialog.rsp` line `0x102` in code, not just from the observed DOSBox kitchen screenshot; the hidden pre-menu seeds are now bounded to `0x102` (intro), `0x104` (same-day follow-up), `0x105` (later default), `0x113` (day 5), and `0x114` (day 6).
+  - Confirmed the earlier Ghidra decompiler view understated Mom's topic-state machine: hidden inline copy/jump blocks loop back to `run_dialogue_keyword_menu` and rewrite the keyword buffer through `0x116->0x118`, `0x119->0x11a`, `0x11d->0x120`, `0x121->0x122`, `0x123->0x124`, `0x125..0x128->0x12a`, `0x12b->0x12c`, `0x12d->0x12e`, `0x12f->0x130`, `0x131..0x133->0x134`, `0x135->0x136`, `0x137->0x13b`, `0x13c->0x13e`, `0x13f->0x140`, `0x141->0x144`, `0x145..0x147->0x148`, `0x14b..0x14c->0x150`, `0x151..0x152->0x154`, and `0x15b->0x15c`.
+  - ScummVM now follows those confirmed Mom menu seeds/transitions instead of unconditionally seeding `0x102` and falling through to `0x26c6` after every recognized topic.
+  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Mom menu-loop update.
 
 ## Next Suggested Action
 
-1. Isolate Mom's initial `g_dialogue_keyword_list_spec` seed in Ghidra instead of relying on the observed DOSBox `dialog.rsp` line match.
-   - Static disassembly already confirms the later `0x13e` and `0x140` rewrites plus all keyword comparisons, but the first native buffer write that produces the observed day-1 line `0x102` has not been recovered cleanly yet.
-2. Audit the remaining startup NPC talk handlers for the same direct-file `dialog.rsp` / `dialogue.idx` / `VOICE/*.CMP` split.
-   - Hank is now bounded (`0xc8` / `0xc9` / `0xca` -> `0xd5`) and Mom is partially bounded, but the other keyword-driven handlers still need the same parity pass before broader dialogue cleanup.
+1. Restore the remaining hidden Mom multi-line exchanges that sit next to the confirmed keyword-buffer rewrites.
+   - Raw disassembly shows the menu/state transitions clearly, but several Mom branches still have extra `play_dialogue_line` calls in those hidden blocks that the current engine only partially mirrors.
+2. Audit the remaining startup NPC talk handlers for the same direct-file `dialog.rsp` / `dialogue.idx` / `VOICE/*.CMP` split and hidden keyword-loop blocks.
+   - Hank and Mom now show the pattern explicitly; the other keyword-driven handlers still need the same parity pass before broader dialogue cleanup.
diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
index 0409140dc0e..4188976b445 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/startup_dialogue.cpp
@@ -170,8 +170,18 @@ static const int kMomMeatPlantTopicResponseLines[] = { 0x151, 0x152 };
 static const int kMomMoynahanTopicResponseLines[] = { 0x160, 0x161 };
 static const int kMomNewspaperFireTopicResponseLines[] = { 0x162, 0x163 };
 static const int kMomInitialTopicBufferResponseLine = 0x102;
+static const int kMomSameDayTopicBufferResponseLine = 0x104;
+static const int kMomPostIntroTopicBufferResponseLine = 0x105;
+static const int kMomDay5TopicBufferResponseLine = 0x113;
+static const int kMomDay6TopicBufferResponseLine = 0x114;
 static const int kMomSlaughterhouseFollowupTopicBufferResponseLine = 0x13e;
 static const int kMomFatherFollowupTopicBufferResponseLine = 0x140;
+static const int kMomPtaFollowupTopicBufferResponseLine = 0x12a;
+static const int kMomCookingFollowupTopicBufferResponseLine = 0x134;
+static const int kMomBakeSaleFollowupTopicBufferResponseLine = 0x148;
+static const int kMomPottsdamFollowupTopicBufferResponseLine = 0x150;
+static const int kMomMeatPlantFollowupTopicBufferResponseLine = 0x154;
+static const int kMomGoodCauseDay5FollowupTopicBufferResponseLine = 0x15c;
 
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
 	const StartupText *startupText = engine.getStartupText();
@@ -1153,7 +1163,8 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 
 			return playDialogueLine(0x2051, "MOM");
 		};
-		assignMomTopicBuffer(kMomInitialTopicBufferResponseLine);
+		momTopicBuffer.clear();
+		momTopicBufferLineIndex = -1;
 
 		if (!usedItemName.empty()) {
 			if (usedItemName.equalsIgnoreCase("NOTE") ||
@@ -1181,6 +1192,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			return playDialogueLine(0x26cc, "MOM");
 		}
 
+		bool skipMomDefaultKeywordSeed = false;
 		if (_momRoomDialogueState.introPending) {
 			_momRoomDialogueState.introPending = false;
 			_momRoomDialogueState.sameDayIntroLineEnabled = true;
@@ -1189,17 +1201,22 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			Common::Error lineError = playDialogueEntrySequence(kMomIntroLines, ARRAYSIZE(kMomIntroLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			assignMomTopicBuffer(kMomInitialTopicBufferResponseLine);
+			skipMomDefaultKeywordSeed = true;
 		}
-		if (_momRoomDialogueState.sameDayIntroLineEnabled &&
+		if (!skipMomDefaultKeywordSeed && _momRoomDialogueState.sameDayIntroLineEnabled &&
 				currentStoryDayIndex == _momRoomDialogueState.introDayIndex) {
 			Common::Error lineError = playDialogueLine(0x2047, "MOM");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			assignMomTopicBuffer(kMomSameDayTopicBufferResponseLine);
+			skipMomDefaultKeywordSeed = true;
 		}
-		if (_momRoomDialogueState.postIntroDefaultLineEnabled) {
+		if (!skipMomDefaultKeywordSeed && _momRoomDialogueState.postIntroDefaultLineEnabled) {
 			Common::Error lineError = playDialogueLine(0x2311, "MOM");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			assignMomTopicBuffer(kMomPostIntroTopicBufferResponseLine);
 		}
 		if (startupScript->getFlagValue("STEPH_MIDGAME_PLAYED") &&
 				!_momRoomDialogueState.stephMidgameShown) {
@@ -1335,12 +1352,14 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			Common::Error lineError = playDialogueLine(0x218d, "MOM");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			assignMomTopicBuffer(kMomDay5TopicBufferResponseLine);
 		}
 		if (startupScript->getFlagValue("DAY_6") && !_momRoomDialogueState.day6Shown) {
 			_momRoomDialogueState.day6Shown = true;
 			Common::Error lineError = playDialogueLine(0x22a8, "MOM");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			assignMomTopicBuffer(kMomDay6TopicBufferResponseLine);
 		}
 
 		for (;;) {
@@ -1353,44 +1372,108 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			if (selectedTopic.equalsIgnoreCase(genericByeTopic))
 				return runMomGoodbye();
 
-			Common::Error lineError = Common::kNoError;
 			if (matchesResponseLine(selectedTopic, 0x116)) {
-				lineError = playDialogueEntrySequence(kMomCookiesLines, ARRAYSIZE(kMomCookiesLines));
-			} else if (matchesResponseLine(selectedTopic, 0x119)) {
-				lineError = playDialogueLine(0x1e47, "PC");
-			} else if (matchesResponseLine(selectedTopic, 0x11d)) {
-				lineError = playDialogueLine(0x1e7c, "PC");
-			} else if (matchesResponseLine(selectedTopic, 0x121)) {
-				lineError = playDialogueLine(0x1ec0, "PC");
-			} else if (matchesResponseLine(selectedTopic, 0x123)) {
-				lineError = playDialogueEntrySequence(kMomSparkyLines, ARRAYSIZE(kMomSparkyLines));
-			} else if (matchesAnyResponseLine(selectedTopic, kMomPtaTopicResponseLines,
+				Common::Error lineError = playDialogueEntrySequence(kMomCookiesLines, ARRAYSIZE(kMomCookiesLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignMomTopicBuffer(0x118);
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x119)) {
+				Common::Error lineError = playDialogueLine(0x1e47, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignMomTopicBuffer(0x11a);
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x11d)) {
+				Common::Error lineError = playDialogueLine(0x1e7c, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignMomTopicBuffer(0x120);
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x121)) {
+				Common::Error lineError = playDialogueLine(0x1ec0, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignMomTopicBuffer(0x122);
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x123)) {
+				Common::Error lineError = playDialogueEntrySequence(kMomSparkyLines, ARRAYSIZE(kMomSparkyLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignMomTopicBuffer(0x124);
+				continue;
+			}
+			if (matchesAnyResponseLine(selectedTopic, kMomPtaTopicResponseLines,
 					ARRAYSIZE(kMomPtaTopicResponseLines))) {
-				lineError = playDialogueLine(0x1eee, "PC");
-			} else if (matchesResponseLine(selectedTopic, 0x12b)) {
-				lineError = playDialogueLine(0x1efd, "PC");
-			} else if (matchesResponseLine(selectedTopic, 0x12d)) {
-				lineError = playDialogueLine(0x1f14, "PC");
-			} else if (matchesResponseLine(selectedTopic, 0x12f)) {
-				lineError = playDialogueLine(0x1f41, "PC");
-			} else if (matchesAnyResponseLine(selectedTopic, kMomCookingTopicResponseLines,
+				Common::Error lineError = playDialogueLine(0x1eee, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignMomTopicBuffer(kMomPtaFollowupTopicBufferResponseLine);
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x12b)) {
+				Common::Error lineError = playDialogueLine(0x1efd, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignMomTopicBuffer(0x12c);
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x12d)) {
+				Common::Error lineError = playDialogueLine(0x1f14, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignMomTopicBuffer(0x12e);
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x12f)) {
+				Common::Error lineError = playDialogueLine(0x1f41, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignMomTopicBuffer(0x130);
+				continue;
+			}
+			if (matchesAnyResponseLine(selectedTopic, kMomCookingTopicResponseLines,
 					ARRAYSIZE(kMomCookingTopicResponseLines))) {
-				lineError = playDialogueLine(0x1f58, "PC");
-			} else if (matchesResponseLine(selectedTopic, 0x135)) {
-				lineError = playDialogueLine(0x1f71, "PC");
-			} else if (matchesResponseLine(selectedTopic, 0x137)) {
-				lineError = playDialogueLine(0x1f7f, "PC");
-			} else if (matchesResponseLine(selectedTopic, 0x13a)) {
-				lineError = playDialogueLine(0x1fa3, "MOM");
-			} else if (matchesResponseLine(selectedTopic, 0x13c)) {
-				lineError = playDialogueEntrySequence(kMomSlaughterhouseLines, ARRAYSIZE(kMomSlaughterhouseLines));
+				Common::Error lineError = playDialogueLine(0x1f58, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignMomTopicBuffer(kMomCookingFollowupTopicBufferResponseLine);
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x135)) {
+				Common::Error lineError = playDialogueLine(0x1f71, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignMomTopicBuffer(0x136);
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x137)) {
+				Common::Error lineError = playDialogueLine(0x1f7f, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignMomTopicBuffer(0x13b);
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x13a)) {
+				Common::Error lineError = playDialogueLine(0x1fa3, "MOM");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x13c)) {
+				Common::Error lineError = playDialogueEntrySequence(kMomSlaughterhouseLines, ARRAYSIZE(kMomSlaughterhouseLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				assignMomTopicBuffer(kMomSlaughterhouseFollowupTopicBufferResponseLine);
 				continue;
-			} else if (matchesResponseLine(selectedTopic, 0x13f)) {
+			}
+			if (matchesResponseLine(selectedTopic, 0x13f)) {
 				_momRoomDialogueState.fatherTopicState = true;
-				lineError = playDialogueEntrySequence(kMomFatherLines, ARRAYSIZE(kMomFatherLines));
+				Common::Error lineError = playDialogueEntrySequence(kMomFatherLines, ARRAYSIZE(kMomFatherLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				if (startupScript->getFlagValue("HEARD_DAD_MOAN")) {
@@ -1401,49 +1484,107 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 				}
 				assignMomTopicBuffer(kMomFatherFollowupTopicBufferResponseLine);
 				continue;
-			} else if (matchesResponseLine(selectedTopic, 0x141)) {
+			}
+			if (matchesResponseLine(selectedTopic, 0x141)) {
+				Common::Error lineError = Common::kNoError;
 				if (startupScript->getFlagValue("STEPH_MIDGAME_PLAYED"))
 					lineError = playDialogueLine(0x2154, "PC");
 				else
 					lineError = playDialogueLine(0x1fe1, "PC");
-			} else if (matchesAnyResponseLine(selectedTopic, kMomBakeSaleTopicResponseLines,
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignMomTopicBuffer(0x144);
+				continue;
+			}
+			if (matchesAnyResponseLine(selectedTopic, kMomBakeSaleTopicResponseLines,
 					ARRAYSIZE(kMomBakeSaleTopicResponseLines))) {
-				lineError = playDialogueLine(0x2017, "PC");
-			} else if (matchesResponseLine(selectedTopic, 0x14a)) {
-				lineError = playDialogueLine(0x2036, "MOM");
-			} else if (matchesAnyResponseLine(selectedTopic, kMomPottsdamTopicResponseLines,
+				Common::Error lineError = playDialogueLine(0x2017, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignMomTopicBuffer(kMomBakeSaleFollowupTopicBufferResponseLine);
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x14a)) {
+				Common::Error lineError = playDialogueLine(0x2036, "MOM");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			}
+			if (matchesAnyResponseLine(selectedTopic, kMomPottsdamTopicResponseLines,
 					ARRAYSIZE(kMomPottsdamTopicResponseLines))) {
+				Common::Error lineError = Common::kNoError;
 				if (startupScript->getFlagValue("STEPH_MIDGAME_PLAYED"))
 					lineError = playDialogueLine(0x2086, "MOM");
-			} else if (matchesAnyResponseLine(selectedTopic, kMomMeatPlantTopicResponseLines,
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignMomTopicBuffer(kMomPottsdamFollowupTopicBufferResponseLine);
+				continue;
+			}
+			if (matchesAnyResponseLine(selectedTopic, kMomMeatPlantTopicResponseLines,
 					ARRAYSIZE(kMomMeatPlantTopicResponseLines))) {
-				lineError = playDialogueLine(0x20f7, "PC");
-			} else if (matchesResponseLine(selectedTopic, 0x155)) {
-				lineError = playDialogueLine(0x2125, "MOM");
-			} else if (matchesResponseLine(selectedTopic, 0x156)) {
-				lineError = playDialogueLine(0x2142, "MOM");
-			} else if (matchesResponseLine(selectedTopic, 0x15b) && currentStoryDayIndex == 5) {
+				Common::Error lineError = playDialogueLine(0x20f7, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignMomTopicBuffer(kMomMeatPlantFollowupTopicBufferResponseLine);
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x155)) {
+				Common::Error lineError = playDialogueLine(0x2125, "MOM");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x156)) {
+				Common::Error lineError = playDialogueLine(0x2142, "MOM");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x15b) && currentStoryDayIndex == 5) {
+				Common::Error lineError = Common::kNoError;
 				if (_momRoomDialogueState.goodCauseDay5State)
 					lineError = playDialogueEntrySequence(kMomGoodCauseDay5Lines, ARRAYSIZE(kMomGoodCauseDay5Lines));
 				else
 					lineError = playDialogueLine(0x21dd, "PC");
-			} else if (matchesAnyResponseLine(selectedTopic, kMomMoynahanTopicResponseLines,
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignMomTopicBuffer(kMomGoodCauseDay5FollowupTopicBufferResponseLine);
+				continue;
+			}
+			if (matchesAnyResponseLine(selectedTopic, kMomMoynahanTopicResponseLines,
 					ARRAYSIZE(kMomMoynahanTopicResponseLines))) {
-				lineError = playDialogueLine(0x220a, "PC");
-			} else if (matchesAnyResponseLine(selectedTopic, kMomNewspaperFireTopicResponseLines,
+				Common::Error lineError = playDialogueLine(0x220a, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			}
+			if (matchesAnyResponseLine(selectedTopic, kMomNewspaperFireTopicResponseLines,
 					ARRAYSIZE(kMomNewspaperFireTopicResponseLines))) {
-				lineError = playDialogueLine(0x229e, "MOM");
-			} else if (matchesResponseLine(selectedTopic, 0x16d)) {
-				lineError = playDialogueLine(0x2696, "MOM");
-			} else if (matchesResponseLine(selectedTopic, 0x16e)) {
-				lineError = playDialogueLine(0x26a3, "MOM");
-			} else if (matchesResponseLine(selectedTopic, 0x170)) {
-				lineError = playDialogueLine(0x26bc, "MOM");
+				Common::Error lineError = playDialogueLine(0x229e, "MOM");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x16d)) {
+				Common::Error lineError = playDialogueLine(0x2696, "MOM");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x16e)) {
+				Common::Error lineError = playDialogueLine(0x26a3, "MOM");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x170)) {
+				Common::Error lineError = playDialogueLine(0x26bc, "MOM");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
 			}
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
 
-			lineError = playDialogueLine(0x26c6, "MOM");
+			Common::Error lineError = playDialogueLine(0x26c6, "MOM");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			return runMomGoodbye();


Commit: d9243c6062e4faaebbf59c1963f01b52d402c6f0
    https://github.com/scummvm/scummvm/commit/d9243c6062e4faaebbf59c1963f01b52d402c6f0
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:54+02:00

Commit Message:
HARVESTER: Restore hidden mom dialogue branches

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/startup_dialogue.cpp
    engines/harvester/startup_dialogue.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 7e0a9cb831d..2db15248a61 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -421,7 +421,12 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `handle_talk_to_hank` has hidden post-`play_dialogue_line` blocks that immediately use this loader with `run_dialogue_response_menu`: after Hank's opening `0x703` line it shows zero-based `dialog.rsp` line `0xc7` (`1. Who are you?/2. Who am I?/3. Where am I?`) and seeds `g_dialogue_keyword_list_spec` from lines `0xc8`, `0xc9`, or `0xca` based on the selected slot.
   - The same Hank handler also rewrites `g_dialogue_keyword_list_spec` from zero-based `dialog.rsp` line `0xd5` after the hidden multi-line `CoolDaddio` branch (`0x725` through `0x747`), which confirms the keyword buffer is live dialogue state rather than a static per-NPC list.
   - Raw disassembly inside `handle_talk_to_mom` shows additional inline `load_dialogue_response_line` -> copy-to-`g_dialogue_keyword_list_spec` blocks that the initial decompiler pass did not surface cleanly. The pre-menu seeds are now confirmed directly in code: intro `0x102`, same-day follow-up `0x104`, later default `0x105`, day-5 override `0x113`, and day-6 override `0x114`.
-  - The same hidden Mom blocks also jump back to `run_dialogue_keyword_menu`, so her keyword flow is a looping dialogue state machine rather than a single-topic exchange. Confirmed rewrites are `0x116->0x118`, `0x119->0x11a`, `0x11d->0x120`, `0x121->0x122`, `0x123->0x124`, `0x125..0x128->0x12a`, `0x12b->0x12c`, `0x12d->0x12e`, `0x12f->0x130`, `0x131..0x133->0x134`, `0x135->0x136`, `0x137->0x13b`, `0x13c->0x13e`, `0x13f->0x140`, `0x141->0x144`, `0x145..0x147->0x148`, `0x14b..0x14c->0x150`, `0x151..0x152->0x154`, and `0x15b->0x15c`.
+  - The same hidden Mom blocks also jump back to `run_dialogue_keyword_menu`, so her keyword flow is a looping dialogue state machine rather than a single-topic exchange. Confirmed keyword-buffer rewrites are `0x116->0x118`, `0x119->0x11a`, `0x11d->0x120`, `0x121->0x122`, `0x123->0x124`, `0x125..0x128->0x12a`, `0x12b->0x12c`, `0x12d->0x12e`, `0x12f->0x130`, `0x131..0x133->0x134`, `0x135->0x136`, `0x13a->0x13b`, `0x13c->0x13e`, `0x13f->0x140`, the non-`STEPH_MIDGAME_PLAYED` `0x141` branch -> `0x144`, `0x145..0x147->0x148`, `0x14b..0x14c->0x150`, `0x151..0x152` response-menu choice `2` -> `0x154`, and `0x15b->0x15c`.
+  - Raw byte disassembly also recovered the hidden straight-line `play_dialogue_line` sequences that the earlier function listing truncated after the first visible call. Confirmed multi-line Mom exchanges now include `0x116`, `0x119`, `0x125..0x128`, `0x12b`, `0x12d`, `0x12f`, `0x131..0x133`, `0x135`, `0x137`, `0x145..0x147`, `0x14a`, `0x155`, and `0x156`.
+  - Two hidden Mom branches use `run_dialogue_response_menu` rather than a pure keyword-buffer rewrite:
+    - `0x141` first calls `get_set_discussed_lodge_topic(0, 1)`, plays `0x1fe1` / `0x1fe5` / `0x1feb` / `0x1fef`, then shows zero-based `dialog.rsp` line `0x142`; response `1` plays `0x1ffe`, response `2` plays `0x2005` / `0x200a` / `0x200e`, and the branch then rewrites the keyword buffer to `0x144`.
+    - When `STEPH_MIDGAME_PLAYED` is already set, that same `0x141` topic instead plays `0x2154` / `0x215a` and loops on the current keyword buffer without rewriting it.
+    - `0x151..0x152` plays `0x20f7` / `0x20fb`, then shows zero-based `dialog.rsp` line `0x153`; response `1` plays `0x2106` / `0x210a` / `0x210e` and loops on the current keyword buffer, while response `2` plays `0x2114` / `0x2119` / `0x211d` and rewrites the buffer to `0x154`.
 - The shared dialogue evidence/topic state shims behind those handlers are now partially explicit:
   - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
   - `get_set_discussed_note_checkbook_evidence` at `0x38330` gates the shared `NOTE` / `NOTE_PHOTOCOPY` / `CHECKBOOK` / `CHECKBOOK_PHOTOCOPY` evidence path used by Dwayne, Edna, Herrill, Johnson, Mom, and related handlers.
diff --git a/TRACKER.md b/TRACKER.md
index b48f5deccfd..e0ba2dacbbf 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,15 +15,18 @@
 
 ## Last Confirmed Action
 
-- Recovered Mom's hidden keyword-buffer loop directly from raw Ghidra disassembly and aligned the startup runtime with the confirmed native menu state machine.
-  - Confirmed `handle_talk_to_mom @ 0x31140` really does seed `g_dialogue_keyword_list_spec` from zero-based `dialog.rsp` line `0x102` in code, not just from the observed DOSBox kitchen screenshot; the hidden pre-menu seeds are now bounded to `0x102` (intro), `0x104` (same-day follow-up), `0x105` (later default), `0x113` (day 5), and `0x114` (day 6).
-  - Confirmed the earlier Ghidra decompiler view understated Mom's topic-state machine: hidden inline copy/jump blocks loop back to `run_dialogue_keyword_menu` and rewrite the keyword buffer through `0x116->0x118`, `0x119->0x11a`, `0x11d->0x120`, `0x121->0x122`, `0x123->0x124`, `0x125..0x128->0x12a`, `0x12b->0x12c`, `0x12d->0x12e`, `0x12f->0x130`, `0x131..0x133->0x134`, `0x135->0x136`, `0x137->0x13b`, `0x13c->0x13e`, `0x13f->0x140`, `0x141->0x144`, `0x145..0x147->0x148`, `0x14b..0x14c->0x150`, `0x151..0x152->0x154`, and `0x15b->0x15c`.
-  - ScummVM now follows those confirmed Mom menu seeds/transitions instead of unconditionally seeding `0x102` and falling through to `0x26c6` after every recognized topic.
-  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Mom menu-loop update.
+- Recovered the hidden Mom multi-line/menu branches directly from raw byte disassembly and aligned the startup runtime with the confirmed native call order instead of the earlier truncated Ghidra listing.
+  - Confirmed the previously recorded `0x137->0x13b` rewrite was wrong: `0x137` loops on the same keyword buffer after `0x1f7f` / `0x1f84` / `0x1f8a` / `0x1f8e`, while `0x13a` is the branch that really rewrites the buffer to `0x13b`.
+  - Confirmed hidden multi-line Mom exchanges on `0x116`, `0x119`, `0x125..0x128`, `0x12b`, `0x12d`, `0x12f`, `0x131..0x133`, `0x135`, `0x137`, `0x145..0x147`, `0x14a`, `0x155`, and `0x156`; those branches are now mirrored in ScummVM with the directly observed speaker strings and portrait variants.
+  - Confirmed `0x141` is a Lodge-topic response-menu branch: it calls the shared `get_set_discussed_lodge_topic(0, 1)` wrapper at `0x384c0`, plays `0x1fe1` / `0x1fe5` / `0x1feb` / `0x1fef`, shows zero-based `dialog.rsp` line `0x142`, then rewrites to `0x144`; the `STEPH_MIDGAME_PLAYED` path instead plays `0x2154` / `0x215a` and loops without rewriting.
+  - Confirmed `0x151..0x152` is another hidden response-menu branch: it plays `0x20f7` / `0x20fb`, shows zero-based `dialog.rsp` line `0x153`, keeps the same keyword buffer after response `1`, and only rewrites to `0x154` after response `2`.
+  - The engine-side Lodge-topic state assumption is now cleaned up to match native scope: the prior dead Hank-local flag was replaced with a shared runtime flag, and the day-5 `GOOD CAUSE` sequence now uses the directly confirmed Mom portrait variant for `0x21a8`.
+  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Mom hidden-branch update.
 
 ## Next Suggested Action
 
-1. Restore the remaining hidden Mom multi-line exchanges that sit next to the confirmed keyword-buffer rewrites.
-   - Raw disassembly shows the menu/state transitions clearly, but several Mom branches still have extra `play_dialogue_line` calls in those hidden blocks that the current engine only partially mirrors.
+1. Resolve the still-partial helper-heavy Mom branches before widening the audit.
+   - `0x11d` and `0x121` still include hidden helper calls (`0x388d0`, `0x388c0`, `0x38420`) and the `BABY_GURGLE` speaker path that the engine still only mirrors partially.
+   - `get_set_mom_good_cause_day5_state @ 0x383c0` is confirmed as a real wrapper over a native global, but the concrete setter path is still outside the recovered call graph; the engine still uses a local placeholder bool there.
 2. Audit the remaining startup NPC talk handlers for the same direct-file `dialog.rsp` / `dialogue.idx` / `VOICE/*.CMP` split and hidden keyword-loop blocks.
-   - Hank and Mom now show the pattern explicitly; the other keyword-driven handlers still need the same parity pass before broader dialogue cleanup.
+   - Hank and Mom now expose the pattern clearly; Dwayne, Potts, Stephanie, and the other keyword-driven handlers still need the same parity pass before broader dialogue cleanup.
diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
index 4188976b445..3a5a8f3e267 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/startup_dialogue.cpp
@@ -127,7 +127,68 @@ static const DialogueLineEntry kMomIntroLines[] = {
 static const DialogueLineEntry kMomCookiesLines[] = {
 	{ 0x1e18, "PC", 0 },
 	{ 0x1e1d, "MOM", 1 },
-	{ 0x1e21, "PC", 0 }
+	{ 0x1e21, "PC", 0 },
+	{ 0x1e25, "MOM", 0 },
+	{ 0x1e29, "PC", 0 }
+};
+
+static const DialogueLineEntry kMomTopic119Lines[] = {
+	{ 0x1e47, "PC", 0 },
+	{ 0x1e4b, "MOM", 0 },
+	{ 0x1e4f, "PC", 0 },
+	{ 0x1e53, "MOM", 0 },
+	{ 0x1f99, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomPtaLines[] = {
+	{ 0x1eee, "PC", 2 },
+	{ 0x1ef2, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomTopic12bLines[] = {
+	{ 0x1efd, "PC", 0 },
+	{ 0x1f01, "MOM", 0 },
+	{ 0x1f07, "PC", 0 },
+	{ 0x1f0b, "MOM", 0 },
+	{ 0x22cd, "PC", 2 },
+	{ 0x22d1, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomTopic12dLines[] = {
+	{ 0x1f14, "PC", 0 },
+	{ 0x1f18, "MOM", 0 },
+	{ 0x1f1d, "PC", 0 },
+	{ 0x1f21, "MOM", 0 },
+	{ 0x1f28, "PC", 0 },
+	{ 0x1f2c, "MOM", 2 },
+	{ 0x1f31, "PC", 2 },
+	{ 0x1f35, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomTopic12fLines[] = {
+	{ 0x1f41, "PC", 4 },
+	{ 0x1f45, "MOM", 3 },
+	{ 0x1f4b, "PC", 0 },
+	{ 0x1f4f, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomCookingLines[] = {
+	{ 0x1f58, "PC", 0 },
+	{ 0x1f5c, "MOM", 0 },
+	{ 0x1f63, "PC", 0 },
+	{ 0x1f68, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomTopic135Lines[] = {
+	{ 0x1f71, "PC", 0 },
+	{ 0x1f75, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomTopic137Lines[] = {
+	{ 0x1f7f, "PC", 2 },
+	{ 0x1f84, "MOM", 3 },
+	{ 0x1f8a, "PC", 2 },
+	{ 0x1f8e, "MOM", 2 }
 };
 
 static const DialogueLineEntry kMomSparkyLines[] = {
@@ -147,9 +208,81 @@ static const DialogueLineEntry kMomFatherHeardDadMoanLines[] = {
 	{ 0x1fd8, "MOM", 0 }
 };
 
+static const DialogueLineEntry kMomLodgeLines[] = {
+	{ 0x1fe1, "PC", 0 },
+	{ 0x1fe5, "MOM", 1 },
+	{ 0x1feb, "PC", 0 },
+	{ 0x1fef, "MOM", 1 }
+};
+
+static const DialogueLineEntry kMomLodgeResponse1Lines[] = {
+	{ 0x1ffe, "MOM", 1 }
+};
+
+static const DialogueLineEntry kMomLodgeResponse2Lines[] = {
+	{ 0x2005, "MOM", 2 },
+	{ 0x200a, "PC", 0 },
+	{ 0x200e, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomStephMidgameLodgeLines[] = {
+	{ 0x2154, "PC", 0 },
+	{ 0x215a, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomBakeSaleLines[] = {
+	{ 0x2017, "PC", 0 },
+	{ 0x201b, "MOM", 1 },
+	{ 0x2020, "PC", 0 },
+	{ 0x2024, "MOM", 0 },
+	{ 0x2029, "PC", 0 },
+	{ 0x202d, "MOM", 0 },
+	{ 0x2164, "PC", 0 },
+	{ 0x2168, "MOM", 1 },
+	{ 0x216d, "PC", 0 },
+	{ 0x2171, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomTopic14aLines[] = {
+	{ 0x2036, "MOM", 0 },
+	{ 0x203a, "PC", 0 },
+	{ 0x203e, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomMeatPlantIntroLines[] = {
+	{ 0x20f7, "PC", 0 },
+	{ 0x20fb, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomMeatPlantResponse1Lines[] = {
+	{ 0x2106, "PC", 0 },
+	{ 0x210a, "MOM", 0 },
+	{ 0x210e, "PC", 0 }
+};
+
+static const DialogueLineEntry kMomMeatPlantResponse2Lines[] = {
+	{ 0x2114, "MOM", 1 },
+	{ 0x2119, "PC", 0 },
+	{ 0x211d, "MOM", 1 }
+};
+
+static const DialogueLineEntry kMomTopic155Lines[] = {
+	{ 0x2125, "MOM", 1 },
+	{ 0x212a, "PC", 0 },
+	{ 0x212e, "MOM", 1 },
+	{ 0x2134, "PC", 0 },
+	{ 0x2138, "MOM", 1 }
+};
+
+static const DialogueLineEntry kMomTopic156Lines[] = {
+	{ 0x2142, "MOM", 0 },
+	{ 0x2147, "PC", 0 },
+	{ 0x214b, "MOM", 0 }
+};
+
 static const DialogueLineEntry kMomGoodCauseDay5Lines[] = {
 	{ 0x21a3, "PC", 0 },
-	{ 0x21a8, "MOM", 0 },
+	{ 0x21a8, "MOM", 1 },
 	{ 0x21ad, "PC", 0 },
 	{ 0x21b2, "MOM", 0 },
 	{ 0x21b7, "PC", 0 }
@@ -176,6 +309,7 @@ static const int kMomDay5TopicBufferResponseLine = 0x113;
 static const int kMomDay6TopicBufferResponseLine = 0x114;
 static const int kMomSlaughterhouseFollowupTopicBufferResponseLine = 0x13e;
 static const int kMomFatherFollowupTopicBufferResponseLine = 0x140;
+static const int kMomLodgeFollowupTopicBufferResponseLine = 0x144;
 static const int kMomPtaFollowupTopicBufferResponseLine = 0x12a;
 static const int kMomCookingFollowupTopicBufferResponseLine = 0x134;
 static const int kMomBakeSaleFollowupTopicBufferResponseLine = 0x148;
@@ -424,6 +558,7 @@ void StartupDialogueSystem::resetRoomNpcDialogueState() {
 	_momRoomDialogueState = MomRoomDialogueState();
 	_jimmyRoomDialogueState = JimmyRoomDialogueState();
 	_sharedKarinKidnapedDialogueState = false;
+	_sharedDiscussedLodgeTopic = false;
 }
 
 Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
@@ -1380,7 +1515,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 				continue;
 			}
 			if (matchesResponseLine(selectedTopic, 0x119)) {
-				Common::Error lineError = playDialogueLine(0x1e47, "PC");
+				Common::Error lineError = playDialogueEntrySequence(kMomTopic119Lines, ARRAYSIZE(kMomTopic119Lines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				assignMomTopicBuffer(0x11a);
@@ -1409,28 +1544,28 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			}
 			if (matchesAnyResponseLine(selectedTopic, kMomPtaTopicResponseLines,
 					ARRAYSIZE(kMomPtaTopicResponseLines))) {
-				Common::Error lineError = playDialogueLine(0x1eee, "PC");
+				Common::Error lineError = playDialogueEntrySequence(kMomPtaLines, ARRAYSIZE(kMomPtaLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				assignMomTopicBuffer(kMomPtaFollowupTopicBufferResponseLine);
 				continue;
 			}
 			if (matchesResponseLine(selectedTopic, 0x12b)) {
-				Common::Error lineError = playDialogueLine(0x1efd, "PC");
+				Common::Error lineError = playDialogueEntrySequence(kMomTopic12bLines, ARRAYSIZE(kMomTopic12bLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				assignMomTopicBuffer(0x12c);
 				continue;
 			}
 			if (matchesResponseLine(selectedTopic, 0x12d)) {
-				Common::Error lineError = playDialogueLine(0x1f14, "PC");
+				Common::Error lineError = playDialogueEntrySequence(kMomTopic12dLines, ARRAYSIZE(kMomTopic12dLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				assignMomTopicBuffer(0x12e);
 				continue;
 			}
 			if (matchesResponseLine(selectedTopic, 0x12f)) {
-				Common::Error lineError = playDialogueLine(0x1f41, "PC");
+				Common::Error lineError = playDialogueEntrySequence(kMomTopic12fLines, ARRAYSIZE(kMomTopic12fLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				assignMomTopicBuffer(0x130);
@@ -1438,30 +1573,30 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			}
 			if (matchesAnyResponseLine(selectedTopic, kMomCookingTopicResponseLines,
 					ARRAYSIZE(kMomCookingTopicResponseLines))) {
-				Common::Error lineError = playDialogueLine(0x1f58, "PC");
+				Common::Error lineError = playDialogueEntrySequence(kMomCookingLines, ARRAYSIZE(kMomCookingLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				assignMomTopicBuffer(kMomCookingFollowupTopicBufferResponseLine);
 				continue;
 			}
 			if (matchesResponseLine(selectedTopic, 0x135)) {
-				Common::Error lineError = playDialogueLine(0x1f71, "PC");
+				Common::Error lineError = playDialogueEntrySequence(kMomTopic135Lines, ARRAYSIZE(kMomTopic135Lines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				assignMomTopicBuffer(0x136);
 				continue;
 			}
 			if (matchesResponseLine(selectedTopic, 0x137)) {
-				Common::Error lineError = playDialogueLine(0x1f7f, "PC");
+				Common::Error lineError = playDialogueEntrySequence(kMomTopic137Lines, ARRAYSIZE(kMomTopic137Lines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
-				assignMomTopicBuffer(0x13b);
 				continue;
 			}
 			if (matchesResponseLine(selectedTopic, 0x13a)) {
 				Common::Error lineError = playDialogueLine(0x1fa3, "MOM");
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
+				assignMomTopicBuffer(0x13b);
 				continue;
 			}
 			if (matchesResponseLine(selectedTopic, 0x13c)) {
@@ -1487,25 +1622,50 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			}
 			if (matchesResponseLine(selectedTopic, 0x141)) {
 				Common::Error lineError = Common::kNoError;
-				if (startupScript->getFlagValue("STEPH_MIDGAME_PLAYED"))
-					lineError = playDialogueLine(0x2154, "PC");
-				else
-					lineError = playDialogueLine(0x1fe1, "PC");
+				if (startupScript->getFlagValue("STEPH_MIDGAME_PLAYED")) {
+					lineError = playDialogueEntrySequence(kMomStephMidgameLodgeLines,
+						ARRAYSIZE(kMomStephMidgameLodgeLines));
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+					continue;
+				}
+				_sharedDiscussedLodgeTopic = true;
+				lineError = playDialogueEntrySequence(kMomLodgeLines, ARRAYSIZE(kMomLodgeLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
-				assignMomTopicBuffer(0x144);
+
+				int responseIndex = 0;
+				Common::Error responseError = runResponseMenu(0x142, responseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+
+				switch (responseIndex) {
+				case 1:
+					lineError = playDialogueEntrySequence(kMomLodgeResponse1Lines,
+						ARRAYSIZE(kMomLodgeResponse1Lines));
+					break;
+				case 2:
+					lineError = playDialogueEntrySequence(kMomLodgeResponse2Lines,
+						ARRAYSIZE(kMomLodgeResponse2Lines));
+					break;
+				default:
+					break;
+				}
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignMomTopicBuffer(kMomLodgeFollowupTopicBufferResponseLine);
 				continue;
 			}
 			if (matchesAnyResponseLine(selectedTopic, kMomBakeSaleTopicResponseLines,
 					ARRAYSIZE(kMomBakeSaleTopicResponseLines))) {
-				Common::Error lineError = playDialogueLine(0x2017, "PC");
+				Common::Error lineError = playDialogueEntrySequence(kMomBakeSaleLines, ARRAYSIZE(kMomBakeSaleLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				assignMomTopicBuffer(kMomBakeSaleFollowupTopicBufferResponseLine);
 				continue;
 			}
 			if (matchesResponseLine(selectedTopic, 0x14a)) {
-				Common::Error lineError = playDialogueLine(0x2036, "MOM");
+				Common::Error lineError = playDialogueEntrySequence(kMomTopic14aLines, ARRAYSIZE(kMomTopic14aLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				continue;
@@ -1522,20 +1682,42 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			}
 			if (matchesAnyResponseLine(selectedTopic, kMomMeatPlantTopicResponseLines,
 					ARRAYSIZE(kMomMeatPlantTopicResponseLines))) {
-				Common::Error lineError = playDialogueLine(0x20f7, "PC");
+				Common::Error lineError = playDialogueEntrySequence(kMomMeatPlantIntroLines,
+					ARRAYSIZE(kMomMeatPlantIntroLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
-				assignMomTopicBuffer(kMomMeatPlantFollowupTopicBufferResponseLine);
-				continue;
+
+				int responseIndex = 0;
+				Common::Error responseError = runResponseMenu(0x153, responseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+
+				switch (responseIndex) {
+				case 1:
+					lineError = playDialogueEntrySequence(kMomMeatPlantResponse1Lines,
+						ARRAYSIZE(kMomMeatPlantResponse1Lines));
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+					continue;
+				case 2:
+					lineError = playDialogueEntrySequence(kMomMeatPlantResponse2Lines,
+						ARRAYSIZE(kMomMeatPlantResponse2Lines));
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+					assignMomTopicBuffer(kMomMeatPlantFollowupTopicBufferResponseLine);
+					continue;
+				default:
+					continue;
+				}
 			}
 			if (matchesResponseLine(selectedTopic, 0x155)) {
-				Common::Error lineError = playDialogueLine(0x2125, "MOM");
+				Common::Error lineError = playDialogueEntrySequence(kMomTopic155Lines, ARRAYSIZE(kMomTopic155Lines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				continue;
 			}
 			if (matchesResponseLine(selectedTopic, 0x156)) {
-				Common::Error lineError = playDialogueLine(0x2142, "MOM");
+				Common::Error lineError = playDialogueEntrySequence(kMomTopic156Lines, ARRAYSIZE(kMomTopic156Lines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				continue;
@@ -1950,7 +2132,7 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 				continue;
 
 			if (topic.setDiscussedLodgeTopic)
-				_hankRoomDialogueState.discussedLodgeTopic = true;
+				_sharedDiscussedLodgeTopic = true;
 
 			debugC(1, kDebugDialogue,
 				"Harvester: Hank matched topic '%s' to response line 0x%x (%d) -> wav=0x%x speaker='%s'",
diff --git a/engines/harvester/startup_dialogue.h b/engines/harvester/startup_dialogue.h
index ae6f8ba33d6..c0a378aa103 100644
--- a/engines/harvester/startup_dialogue.h
+++ b/engines/harvester/startup_dialogue.h
@@ -53,7 +53,6 @@ public:
 		bool karinFoundDeadShown = false;
 		bool discussedCasketPhotoEvidence = false;
 		bool discussedWhaleyHerrillPhoto = false;
-		bool discussedLodgeTopic = false;
 		Common::String currentTopicBuffer;
 		int currentTopicBufferLineIndex = -1;
 	};
@@ -104,6 +103,7 @@ private:
 	MomRoomDialogueState _momRoomDialogueState;
 	JimmyRoomDialogueState _jimmyRoomDialogueState;
 	bool _sharedKarinKidnapedDialogueState = false;
+	bool _sharedDiscussedLodgeTopic = false;
 };
 
 } // End of namespace Harvester


Commit: fa0d637abf31f2a10290771e392c2ad459905262
    https://github.com/scummvm/scummvm/commit/fa0d637abf31f2a10290771e392c2ad459905262
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:55+02:00

Commit Message:
HARVESTER: Document DAT and dialogue evidence

Sync DAT archive notes with the current dialogue evidence state.

Changed paths:
  A FILE_FORMATS.md
    TRACKER.md
    engines/harvester/startup_dialogue.cpp
    engines/harvester/startup_dialogue.h


diff --git a/FILE_FORMATS.md b/FILE_FORMATS.md
new file mode 100644
index 00000000000..85f43070930
--- /dev/null
+++ b/FILE_FORMATS.md
@@ -0,0 +1,79 @@
+# File Formats
+
+This document tracks file-format facts that are confirmed by the current Harvester reverse-engineering work. It is intentionally conservative: if a field or behavior is not supported by code or verified runtime notes, it is left out.
+
+## DAT (XFILE archive payload)
+
+Harvester's `.DAT` files act as payload containers for the game's numbered XFILE resource sets. The important wrinkle is that the `.DAT` file is not the whole format by itself: the loader depends on a sidecar `INDEX.00N` file for the directory. In other words, the `.DAT` holds the bytes, while the `INDEX.00N` records tell the engine where each member starts, how large it is, and whether it must be unpacked after reading.
+
+At confirmed cold start, the original game mounts these pairs in this order:
+
+| Set | Index file | Data file | Notes |
+| --- | --- | --- | --- |
+| 1 | `INDEX.001` | `HARVEST.DAT` | First archive-backed set mounted during startup. Confirmed startup art and UI assets are loaded through this set. |
+| 2 | `INDEX.002` | `SOUND.DAT` | Second numbered set mounted during startup. |
+| 3 | `INDEX.003` | `HARVEST2.DAT` | Third numbered set mounted during startup. |
+
+### Summary
+
+| Property | Value |
+| --- | --- |
+| Format role | Archive payload file used by the XFILE resource layer |
+| Companion metadata | Required sidecar `INDEX.00N` directory file |
+| Archive-wide header in `.DAT` | No confirmed archive-wide header or in-file directory has been required by the current loader |
+| Addressing model | Members are looked up by path in `INDEX.00N`, then read from absolute offsets in the `.DAT` |
+| Path syntax in game code | Archive-backed lookups use `<set-number>:\path\to\file.ext` |
+| Endianness | Mixed in the sidecar index: signature is checked as big-endian `XFLE`; numeric fields are little-endian 32-bit values |
+| Compression | Optional per-entry packing; unpacked entries are read directly, packed entries are expanded after read |
+
+### `.DAT` payload layout
+
+| Offset | Size | Description |
+| --- | --- | --- |
+| `0x00` | variable | Raw member data addressed by the sidecar index. The current loader treats the file as a byte reservoir and does not require a confirmed global header before opening entries. |
+
+This is sparse compared with many archive formats, but that sparseness is itself useful for a blog post: the format's structure is directory-driven rather than self-describing. All meaningful per-file metadata currently comes from the matching `INDEX.00N` file.
+
+### `INDEX.00N` directory record layout
+
+Each confirmed sidecar index is read as a flat array of `0x94`-byte records:
+
+| Offset | Size | Type | Name | Description |
+| --- | --- | --- | --- | --- |
+| `0x00` | `0x04` | ASCII | `signature` | `XFLE` magic. The loader rejects records whose first four bytes do not match this tag. |
+| `0x04` | `0x80` | char[128] | `path` | NUL-terminated resource path string. This is the logical member name used for later lookups. |
+| `0x84` | `0x04` | uint32le | `archive_offset` | Absolute offset of the member data inside the companion `.DAT` file. |
+| `0x88` | `0x04` | uint32le | `stored_size` | Number of bytes stored in the `.DAT` for this member. |
+| `0x8c` | `0x04` | uint32le | `packed_flag` | `0` means the member is stored verbatim. Any nonzero value takes the packed-entry decode path. The current loader does not distinguish between different nonzero flag values. |
+| `0x90` | `0x04` | uint32le | `unpacked_size` | Expected output size after decode for packed entries. |
+
+### Packed entry stream
+
+When `packed_flag != 0`, the loader reads `stored_size` bytes from the `.DAT` and expands them into an `unpacked_size` output buffer using a simple control-byte stream:
+
+| Control byte range | Meaning |
+| --- | --- |
+| `0x00` to `0x80` | Copy the next `control` bytes literally into the output stream. |
+| `0x81` to `0xff` | Read one byte and repeat it `control - 0x80` times. |
+
+The decode loop stops once either the compressed input is exhausted or the output buffer reaches `unpacked_size`. Entries that fail to produce exactly `unpacked_size` bytes are treated as invalid.
+
+### Path handling notes
+
+| Behavior | Detail |
+| --- | --- |
+| Separators | Resource paths are normalized from DOS-style backslashes to forward slashes during lookup. |
+| Prefix stripping | Leading `./`, leading `/`, and `<digit>:/` prefixes are stripped during normalization. |
+| Case handling | Archive member lookups are case-insensitive in the current implementation. |
+| Loose files vs archive paths | Bare relative paths are handled separately by the direct-file path builder; `<digit>:\...` paths select an archive set instead. |
+
+### Practical interpretation
+
+For extraction or tooling work, the current evidence supports treating Harvester's `.DAT` archives as a two-file format:
+
+1. Read the matching `INDEX.00N` file as a sequence of `0x94`-byte `XFLE` records.
+2. Use each record's `archive_offset` and `stored_size` to slice bytes from the companion `.DAT`.
+3. If `packed_flag` is zero, the slice is the final file.
+4. If `packed_flag` is nonzero, expand the slice with the literal/repeat decoder above until `unpacked_size` bytes are produced.
+
+That model is enough to explain the startup resource mounts already confirmed in the reverse-engineering notes, and it matches the current ScummVM-side archive loader.
diff --git a/TRACKER.md b/TRACKER.md
index e0ba2dacbbf..71dbcf7925b 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,18 +15,16 @@
 
 ## Last Confirmed Action
 
-- Recovered the hidden Mom multi-line/menu branches directly from raw byte disassembly and aligned the startup runtime with the confirmed native call order instead of the earlier truncated Ghidra listing.
-  - Confirmed the previously recorded `0x137->0x13b` rewrite was wrong: `0x137` loops on the same keyword buffer after `0x1f7f` / `0x1f84` / `0x1f8a` / `0x1f8e`, while `0x13a` is the branch that really rewrites the buffer to `0x13b`.
-  - Confirmed hidden multi-line Mom exchanges on `0x116`, `0x119`, `0x125..0x128`, `0x12b`, `0x12d`, `0x12f`, `0x131..0x133`, `0x135`, `0x137`, `0x145..0x147`, `0x14a`, `0x155`, and `0x156`; those branches are now mirrored in ScummVM with the directly observed speaker strings and portrait variants.
-  - Confirmed `0x141` is a Lodge-topic response-menu branch: it calls the shared `get_set_discussed_lodge_topic(0, 1)` wrapper at `0x384c0`, plays `0x1fe1` / `0x1fe5` / `0x1feb` / `0x1fef`, shows zero-based `dialog.rsp` line `0x142`, then rewrites to `0x144`; the `STEPH_MIDGAME_PLAYED` path instead plays `0x2154` / `0x215a` and loops without rewriting.
-  - Confirmed `0x151..0x152` is another hidden response-menu branch: it plays `0x20f7` / `0x20fb`, shows zero-based `dialog.rsp` line `0x153`, keeps the same keyword buffer after response `1`, and only rewrites to `0x154` after response `2`.
-  - The engine-side Lodge-topic state assumption is now cleaned up to match native scope: the prior dead Hank-local flag was replaced with a shared runtime flag, and the day-5 `GOOD CAUSE` sequence now uses the directly confirmed Mom portrait variant for `0x21a8`.
-  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Mom hidden-branch update.
+- Audited the native `handle_talk_to_hank` and `handle_talk_to_jimmy` item branches against the current engine code and fixed the confirmed shared-evidence state mismatch.
+  - Native `handle_talk_to_hank @ 0x33a30` sets the shared casket-photo and Whaley/Herrill evidence wrappers before its `0xa3e` / `0xa53` lines; the engine now mirrors that by setting the matching `SHOWN_PHOTO_OF_CORPSE_AROUN` and `SHOWN_PHOTO_OF_WHALEY_HERRI` runtime flags instead of dead Hank-local placeholder bools.
+  - Native `handle_talk_to_jimmy @ 0x28880` sets the shared ledger, casket-photo, note/checkbook, and Whaley/Herrill evidence wrappers on the corresponding item branches; the engine now mirrors those writes through the matching `SHOWN_*` runtime flags before `0x4b00`, `0x4b21`, and `0x4af8`.
+  - Confirmed the tempting Jimmy intro tweak was wrong: the native handler does **not** set the newspaper handoff state on the first no-item `0x4a4c` path, so that engine-side gate was intentionally left unchanged.
+  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Hank/Jimmy parity update.
 
 ## Next Suggested Action
 
-1. Resolve the still-partial helper-heavy Mom branches before widening the audit.
+1. Carry the same shared-evidence/runtime-flag audit into the remaining item-heavy talk handlers.
+   - Native notes already tie Dwayne, Edna, Herrill, Johnson, Mom, and related handlers to the same note/checkbook, ledger, casket-photo, and Whaley/Herrill evidence wrappers.
+   - The engine-side branches for those NPCs should be checked for missing `SHOWN_*` runtime flag writes before more dialogue parity work builds on them.
+2. Resume the unresolved raw-byte audit on Mom's helper-heavy hidden branches.
    - `0x11d` and `0x121` still include hidden helper calls (`0x388d0`, `0x388c0`, `0x38420`) and the `BABY_GURGLE` speaker path that the engine still only mirrors partially.
-   - `get_set_mom_good_cause_day5_state @ 0x383c0` is confirmed as a real wrapper over a native global, but the concrete setter path is still outside the recovered call graph; the engine still uses a local placeholder bool there.
-2. Audit the remaining startup NPC talk handlers for the same direct-file `dialog.rsp` / `dialogue.idx` / `VOICE/*.CMP` split and hidden keyword-loop blocks.
-   - Hank and Mom now expose the pattern clearly; Dwayne, Potts, Stephanie, and the other keyword-driven handlers still need the same parity pass before broader dialogue cleanup.
diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
index 3a5a8f3e267..110084258d6 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/startup_dialogue.cpp
@@ -68,6 +68,10 @@ static const int kDialogueOtherEndY = 193;
 static const int kDialogueGenericByeResponseIndex = 13;
 static const char *const kDialogueKeywordBitmapPath = "1:/GRAPHIC/OTHER/KEYWORD.BM";
 static const char *const kDialogueRangshotFstPath = "GRAPHIC/FST/RANGSHOT.FST";
+static const char *const kShownEvidenceOfBlackmailFlag = "SHOWN_EVIDENCE_OF_BLACKMAIL";
+static const char *const kShownLedgersToAnyoneFlag = "SHOWN_LEDGERS_TO_ANYONE_OTH";
+static const char *const kShownPhotoOfCorpseFlag = "SHOWN_PHOTO_OF_CORPSE_AROUN";
+static const char *const kShownPhotoOfWhaleyHerrillFlag = "SHOWN_PHOTO_OF_WHALEY_HERRI";
 
 static const byte kTextColorNormal = 255;
 static const byte kTextColorHover = 251;
@@ -1222,19 +1226,28 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 					? playJimmyLine(0x4acc, 0)
 					: playJimmyLine(0x4a4b, 1);
 			}
-			if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL"))
+			if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+				(void)startupScript->setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
 				return playJimmyLine(0x4af8, 1);
+			}
 			if (((usedItemName.equalsIgnoreCase("LEDGER") ||
 						usedItemName.equalsIgnoreCase("LEDGER2")) &&
 					startupScript->getFlagValue("HAVE_BOTH_LEDGERS")) ||
 					usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 					usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+				if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+						usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+					(void)startupScript->setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
+				} else {
+					(void)startupScript->setRuntimeFlagValue(kShownLedgersToAnyoneFlag, true);
+				}
 				return playJimmyLine(0x4b00, 0);
 			}
 			if (usedItemName.equalsIgnoreCase("NOTE") ||
 					usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 					usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 					usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+				(void)startupScript->setRuntimeFlagValue(kShownEvidenceOfBlackmailFlag, true);
 				return playJimmyLine(0x4b21, 0);
 			}
 			if (usedItemName.equalsIgnoreCase("SNEAKERS")) {
@@ -1797,11 +1810,11 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-			_hankRoomDialogueState.discussedCasketPhotoEvidence = true;
+			(void)startupScript->setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
 			return playDialogueLine(0xa3e, "HANK");
 		}
 		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-			_hankRoomDialogueState.discussedWhaleyHerrillPhoto = true;
+			(void)startupScript->setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
 			return playDialogueLine(0xa53, "HANK");
 		}
 		return playDialogueLine(0xa38, "HANK");
diff --git a/engines/harvester/startup_dialogue.h b/engines/harvester/startup_dialogue.h
index c0a378aa103..4fbcf8b27ac 100644
--- a/engines/harvester/startup_dialogue.h
+++ b/engines/harvester/startup_dialogue.h
@@ -51,8 +51,6 @@ public:
 		bool karinKidnapedShown = false;
 		bool karinFoundAliveShown = false;
 		bool karinFoundDeadShown = false;
-		bool discussedCasketPhotoEvidence = false;
-		bool discussedWhaleyHerrillPhoto = false;
 		Common::String currentTopicBuffer;
 		int currentTopicBufferLineIndex = -1;
 	};


Commit: 7e53df9f9ee0309e7889b833de33f0c65dca539f
    https://github.com/scummvm/scummvm/commit/7e53df9f9ee0309e7889b833de33f0c65dca539f
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:55+02:00

Commit Message:
HARVESTER: Mirror mom evidence state

Changed paths:
    TRACKER.md
    engines/harvester/startup_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 71dbcf7925b..eb56092c03d 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,16 +15,15 @@
 
 ## Last Confirmed Action
 
-- Audited the native `handle_talk_to_hank` and `handle_talk_to_jimmy` item branches against the current engine code and fixed the confirmed shared-evidence state mismatch.
-  - Native `handle_talk_to_hank @ 0x33a30` sets the shared casket-photo and Whaley/Herrill evidence wrappers before its `0xa3e` / `0xa53` lines; the engine now mirrors that by setting the matching `SHOWN_PHOTO_OF_CORPSE_AROUN` and `SHOWN_PHOTO_OF_WHALEY_HERRI` runtime flags instead of dead Hank-local placeholder bools.
-  - Native `handle_talk_to_jimmy @ 0x28880` sets the shared ledger, casket-photo, note/checkbook, and Whaley/Herrill evidence wrappers on the corresponding item branches; the engine now mirrors those writes through the matching `SHOWN_*` runtime flags before `0x4b00`, `0x4b21`, and `0x4af8`.
-  - Confirmed the tempting Jimmy intro tweak was wrong: the native handler does **not** set the newspaper handoff state on the first no-item `0x4a4c` path, so that engine-side gate was intentionally left unchanged.
-  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Hank/Jimmy parity update.
+- Completed the shared-evidence/runtime-flag audit for the currently implemented startup-room item handlers and fixed the remaining Mom mismatch.
+  - Native `handle_talk_to_mom @ 0x31140` sets the shared note/checkbook, ledger, casket-photo, Whaley/Herrill, and TV-deed evidence wrappers before `0x2317`, `0x2320`, `0x233b`, `0x239c`, and `0x2382`; the engine now mirrors those writes through `SHOWN_EVIDENCE_OF_BLACKMAIL`, `SHOWN_LEDGERS_TO_ANYONE_OTH`, `SHOWN_PHOTO_OF_CORPSE_AROUN`, `SHOWN_PHOTO_OF_WHALEY_HERRI`, and `SHOWN_EVIDENCE_SHERIFF_OWNS`.
+  - The earlier Hank/Jimmy parity pass remains correct: Hank now mirrors the shared casket-photo / Whaley-Herrill evidence writes, Jimmy now mirrors the shared ledger / casket-photo / note-checkbook / Whaley-Herrill evidence writes, and Jimmy's first no-item `0x4a4c` path still intentionally leaves the newspaper handoff state clear because the native handler does too.
+  - Native audits of Dwayne, Edna, Herrill, and Johnson confirm the same shared wrapper pattern, but those NPC-specific startup handlers are not yet implemented in `startup_dialogue.cpp`, so there was no additional engine-side evidence write to patch in this pass.
+  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Mom evidence-state update.
 
 ## Next Suggested Action
 
-1. Carry the same shared-evidence/runtime-flag audit into the remaining item-heavy talk handlers.
-   - Native notes already tie Dwayne, Edna, Herrill, Johnson, Mom, and related handlers to the same note/checkbook, ledger, casket-photo, and Whaley/Herrill evidence wrappers.
-   - The engine-side branches for those NPCs should be checked for missing `SHOWN_*` runtime flag writes before more dialogue parity work builds on them.
-2. Resume the unresolved raw-byte audit on Mom's helper-heavy hidden branches.
+1. Resume the unresolved raw-byte audit on Mom's helper-heavy hidden branches.
    - `0x11d` and `0x121` still include hidden helper calls (`0x388d0`, `0x388c0`, `0x38420`) and the `BABY_GURGLE` speaker path that the engine still only mirrors partially.
+2. If wider evidence parity work is needed beyond Mom/Hank/Jimmy, port the next native talk handler cluster into `startup_dialogue.cpp` before auditing its shared `SHOWN_*` writes.
+   - Dwayne, Edna, Herrill, and Johnson already have confirmed native evidence-wrapper behavior, but there is no explicit engine-side room handler for them yet.
diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
index 110084258d6..1451e9824a7 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/startup_dialogue.cpp
@@ -69,6 +69,7 @@ static const int kDialogueGenericByeResponseIndex = 13;
 static const char *const kDialogueKeywordBitmapPath = "1:/GRAPHIC/OTHER/KEYWORD.BM";
 static const char *const kDialogueRangshotFstPath = "GRAPHIC/FST/RANGSHOT.FST";
 static const char *const kShownEvidenceOfBlackmailFlag = "SHOWN_EVIDENCE_OF_BLACKMAIL";
+static const char *const kShownEvidenceSheriffOwnsFlag = "SHOWN_EVIDENCE_SHERIFF_OWNS";
 static const char *const kShownLedgersToAnyoneFlag = "SHOWN_LEDGERS_TO_ANYONE_OTH";
 static const char *const kShownPhotoOfCorpseFlag = "SHOWN_PHOTO_OF_CORPSE_AROUN";
 static const char *const kShownPhotoOfWhaleyHerrillFlag = "SHOWN_PHOTO_OF_WHALEY_HERRI";
@@ -1319,21 +1320,27 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 					usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 					usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 					usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+				(void)startupScript->setRuntimeFlagValue(kShownEvidenceOfBlackmailFlag, true);
 				return playDialogueLine(0x2317, "MOM");
 			}
 			if ((usedItemName.equalsIgnoreCase("LEDGER") ||
 					usedItemName.equalsIgnoreCase("LEDGER2")) &&
 					startupScript->getFlagValue("HAVE_BOTH_LEDGERS")) {
+				(void)startupScript->setRuntimeFlagValue(kShownLedgersToAnyoneFlag, true);
 				return playDialogueLine(0x2320, "MOM");
 			}
 			if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 					usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+				(void)startupScript->setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
 				return playDialogueLine(0x233b, "PC");
 			}
-			if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL"))
+			if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+				(void)startupScript->setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
 				return playDialogueLine(0x239c, "MOM");
+			}
 			if (usedItemName.equalsIgnoreCase("TV_DEED") ||
 					usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
+				(void)startupScript->setRuntimeFlagValue(kShownEvidenceSheriffOwnsFlag, true);
 				return playDialogueLine(0x2382, "MOM");
 			}
 


Commit: a2924eb25eab47f5fef610ffe84d3d24b902cd0a
    https://github.com/scummvm/scummvm/commit/a2924eb25eab47f5fef610ffe84d3d24b902cd0a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:55+02:00

Commit Message:
HARVESTER: Recover mom hidden dialogue branches

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/startup_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 2db15248a61..5353255e7c8 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -422,7 +422,9 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - The same Hank handler also rewrites `g_dialogue_keyword_list_spec` from zero-based `dialog.rsp` line `0xd5` after the hidden multi-line `CoolDaddio` branch (`0x725` through `0x747`), which confirms the keyword buffer is live dialogue state rather than a static per-NPC list.
   - Raw disassembly inside `handle_talk_to_mom` shows additional inline `load_dialogue_response_line` -> copy-to-`g_dialogue_keyword_list_spec` blocks that the initial decompiler pass did not surface cleanly. The pre-menu seeds are now confirmed directly in code: intro `0x102`, same-day follow-up `0x104`, later default `0x105`, day-5 override `0x113`, and day-6 override `0x114`.
   - The same hidden Mom blocks also jump back to `run_dialogue_keyword_menu`, so her keyword flow is a looping dialogue state machine rather than a single-topic exchange. Confirmed keyword-buffer rewrites are `0x116->0x118`, `0x119->0x11a`, `0x11d->0x120`, `0x121->0x122`, `0x123->0x124`, `0x125..0x128->0x12a`, `0x12b->0x12c`, `0x12d->0x12e`, `0x12f->0x130`, `0x131..0x133->0x134`, `0x135->0x136`, `0x13a->0x13b`, `0x13c->0x13e`, `0x13f->0x140`, the non-`STEPH_MIDGAME_PLAYED` `0x141` branch -> `0x144`, `0x145..0x147->0x148`, `0x14b..0x14c->0x150`, `0x151..0x152` response-menu choice `2` -> `0x154`, and `0x15b->0x15c`.
-  - Raw byte disassembly also recovered the hidden straight-line `play_dialogue_line` sequences that the earlier function listing truncated after the first visible call. Confirmed multi-line Mom exchanges now include `0x116`, `0x119`, `0x125..0x128`, `0x12b`, `0x12d`, `0x12f`, `0x131..0x133`, `0x135`, `0x137`, `0x145..0x147`, `0x14a`, `0x155`, and `0x156`.
+  - Raw byte disassembly also recovered the hidden straight-line `play_dialogue_line` sequences that the earlier function listing truncated after the first visible call. Confirmed multi-line Mom exchanges now include `0x116`, `0x119`, `0x11d`, `0x121`, `0x125..0x128`, `0x12b`, `0x12d`, `0x12f`, `0x131..0x133`, `0x135`, `0x137`, `0x145..0x147`, `0x14a`, `0x155`, and `0x156`.
+  - Mom `0x11d` is the hidden `PC`/`MOM` exchange `0x1e7c` / `0x1e80`, `C008A.FST`, `0x1e8e` / `0x1e94`, `dispatch_action_tag_if_set("BABY_GURGLE")`, `0x1e9d` / `0x1ea3`, `C008B.FST`, then keyword-buffer rewrite `0x120`.
+  - Mom `0x121` is the four-line `PC` / `MOM` / `PC` / `MOM` exchange `0x1ec0` / `0x1ec4` / `0x1ec9` / `0x1ecd`, then keyword-buffer rewrite `0x122`.
   - Two hidden Mom branches use `run_dialogue_response_menu` rather than a pure keyword-buffer rewrite:
     - `0x141` first calls `get_set_discussed_lodge_topic(0, 1)`, plays `0x1fe1` / `0x1fe5` / `0x1feb` / `0x1fef`, then shows zero-based `dialog.rsp` line `0x142`; response `1` plays `0x1ffe`, response `2` plays `0x2005` / `0x200a` / `0x200e`, and the branch then rewrites the keyword buffer to `0x144`.
     - When `STEPH_MIDGAME_PLAYED` is already set, that same `0x141` topic instead plays `0x2154` / `0x215a` and loops on the current keyword buffer without rewriting it.
@@ -442,7 +444,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `get_set_mom_good_cause_day5_state` at `0x383c0` is a Mom-local day-5 `GOOD CAUSE` state bit; the visible caller currently reads it, but the concrete setter is still outside the recovered call graph.
   - `get_set_dad_meat_permission_state` at `0x38400` is the Dad-local `MEAT_PERMISSION0` state bit read on the related item branch.
   - `get_set_shared_dialogue_state_d2f04` at `0x383e0` is a neutral shared wrapper over `g_shared_dialogue_state_d2f04`; raw call tracing shows it is used from hidden regions in Moynahan, Mr. Potts, Johnson, Phelps, Butcher, Dad, McKnight, Loomis, Edna, Sparky, and Dwayne, but the surrounding subtitle lines still span too many unrelated topics for a stronger semantic rename.
-  - `get_set_wasp_woman_mom_dialogue_state_d2f10` at `0x38420` is a neutral two-handler wrapper over `g_wasp_woman_mom_dialogue_state_d2f10`; raw calls only appear in the hidden Wasp Woman and Mom regions, with nearby `dialog.rsp` anchors in the `Sparky/Wasp Woman` and `SPARKY` / `P.T.A.` keyword cluster, but the exact shared topic is still unresolved.
+  - `get_set_wasp_woman_mom_dialogue_state_d2f10` at `0x38420` is a neutral two-handler wrapper over `g_wasp_woman_mom_dialogue_state_d2f10`; raw calls only appear in the hidden Wasp Woman and Mom regions, with nearby `dialog.rsp` anchors in the `Sparky/Wasp Woman` and `SPARKY` / `P.T.A.` keyword cluster, but the exact shared topic is still unresolved. The later raw-byte recovery of Mom `0x11d` / `0x121` showed that helper is not part of those two branches.
   - `get_set_discussed_mr_potts_tuesday_night_alibi` at `0x38440` is set on the hidden `handle_talk_to_mr_potts` Tuesday-night branch and later read by Johnson and Stephanie; Stephanie's hidden `0x4980` line, "Pottsdam lied. He said he stayed home Tuesday night.", makes the semantics explicit.
   - `get_set_discussed_mrs_potts_tuesday_night_alibi` at `0x38460` is set on the hidden `handle_talk_to_mrs_potts` Tuesday-night branch and later read by Stephanie; the hidden `0x4984` / `0x4989` / `0x498f` exchange anchors it to Mrs. Potts's version of Pottsdam's Tuesday-night alibi.
   - `get_set_confronted_mr_potts_about_spyhole` at `0x384a0` is set on the hidden `handle_talk_to_mr_potts` spyhole confrontation and later read by Stephanie; the paired `0x2d35` / `0x2d39` / `0x2d3e` and `0x4a12` / `0x4a19` / `0x4a1e` / `0x4a23` subtitle lines make the semantics explicit.
diff --git a/TRACKER.md b/TRACKER.md
index eb56092c03d..5b4cbdfb305 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -9,21 +9,21 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `887`
-- Named/documented: `565`
-- Still `FUN_*` / undocumented: `322`
+- Total functions: `890`
+- Named/documented: `566`
+- Still `FUN_*` / undocumented: `324`
 
 ## Last Confirmed Action
 
-- Completed the shared-evidence/runtime-flag audit for the currently implemented startup-room item handlers and fixed the remaining Mom mismatch.
-  - Native `handle_talk_to_mom @ 0x31140` sets the shared note/checkbook, ledger, casket-photo, Whaley/Herrill, and TV-deed evidence wrappers before `0x2317`, `0x2320`, `0x233b`, `0x239c`, and `0x2382`; the engine now mirrors those writes through `SHOWN_EVIDENCE_OF_BLACKMAIL`, `SHOWN_LEDGERS_TO_ANYONE_OTH`, `SHOWN_PHOTO_OF_CORPSE_AROUN`, `SHOWN_PHOTO_OF_WHALEY_HERRI`, and `SHOWN_EVIDENCE_SHERIFF_OWNS`.
-  - The earlier Hank/Jimmy parity pass remains correct: Hank now mirrors the shared casket-photo / Whaley-Herrill evidence writes, Jimmy now mirrors the shared ledger / casket-photo / note-checkbook / Whaley-Herrill evidence writes, and Jimmy's first no-item `0x4a4c` path still intentionally leaves the newspaper handoff state clear because the native handler does too.
-  - Native audits of Dwayne, Edna, Herrill, and Johnson confirm the same shared wrapper pattern, but those NPC-specific startup handlers are not yet implemented in `startup_dialogue.cpp`, so there was no additional engine-side evidence write to patch in this pass.
-  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Mom evidence-state update.
+- Recovered and ported Mom's hidden `0x11d` / `0x121` branches instead of the old one-line stubs.
+  - `0x11d` now mirrors the native `0x1e7c` / `0x1e80` exchange, `C008A.FST`, `0x1e8e` / `0x1e94`, the `BABY_GURGLE` action-tag dispatch, `0x1e9d` / `0x1ea3`, `C008B.FST`, and the keyword-buffer rewrite to `0x120`.
+  - `0x121` now mirrors the native `0x1ec0` / `0x1ec4` / `0x1ec9` / `0x1ecd` exchange before rewriting the keyword buffer to `0x122`.
+  - The raw-byte audit corrected the earlier helper guess: `0x388d0` and `0x388e0` belong to Mom `0x11d`, while `0x388c0` and `0x38420` are real nearby helpers but not part of Mom `0x11d` / `0x121`.
+  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Mom hidden-branch recovery.
 
 ## Next Suggested Action
 
-1. Resume the unresolved raw-byte audit on Mom's helper-heavy hidden branches.
-   - `0x11d` and `0x121` still include hidden helper calls (`0x388d0`, `0x388c0`, `0x38420`) and the `BABY_GURGLE` speaker path that the engine still only mirrors partially.
+1. Audit the remaining hidden Mom/Wasp Woman helper cluster that is still unresolved after closing Mom `0x11d` / `0x121`.
+   - `play_c008_prelude_fst @ 0x388c0` and `get_set_wasp_woman_mom_dialogue_state_d2f10 @ 0x38420` are confirmed helpers, but the raw-byte recovery showed they are not part of Mom `0x11d` / `0x121`; identify which hidden Mom/Wasp Woman topics actually call them.
 2. If wider evidence parity work is needed beyond Mom/Hank/Jimmy, port the next native talk handler cluster into `startup_dialogue.cpp` before auditing its shared `SHOWN_*` writes.
    - Dwayne, Edna, Herrill, and Johnson already have confirmed native evidence-wrapper behavior, but there is no explicit engine-side room handler for them yet.
diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
index 1451e9824a7..20dcd833cfc 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/startup_dialogue.cpp
@@ -68,6 +68,9 @@ static const int kDialogueOtherEndY = 193;
 static const int kDialogueGenericByeResponseIndex = 13;
 static const char *const kDialogueKeywordBitmapPath = "1:/GRAPHIC/OTHER/KEYWORD.BM";
 static const char *const kDialogueRangshotFstPath = "GRAPHIC/FST/RANGSHOT.FST";
+static const char *const kDialogueC008AFstPath = "GRAPHIC/FST/C008A.FST";
+static const char *const kDialogueC008BFstPath = "GRAPHIC/FST/C008B.FST";
+static const char *const kBabyGurgleActionTag = "BABY_GURGLE";
 static const char *const kShownEvidenceOfBlackmailFlag = "SHOWN_EVIDENCE_OF_BLACKMAIL";
 static const char *const kShownEvidenceSheriffOwnsFlag = "SHOWN_EVIDENCE_SHERIFF_OWNS";
 static const char *const kShownLedgersToAnyoneFlag = "SHOWN_LEDGERS_TO_ANYONE_OTH";
@@ -145,6 +148,28 @@ static const DialogueLineEntry kMomTopic119Lines[] = {
 	{ 0x1f99, "MOM", 2 }
 };
 
+static const DialogueLineEntry kMomTopic11dOpeningLines[] = {
+	{ 0x1e7c, "PC", 4 },
+	{ 0x1e80, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomTopic11dPostC008ALines[] = {
+	{ 0x1e8e, "MOM", 2 },
+	{ 0x1e94, "PC", 2 }
+};
+
+static const DialogueLineEntry kMomTopic11dClosingLines[] = {
+	{ 0x1e9d, "MOM", 3 },
+	{ 0x1ea3, "MOM", 3 }
+};
+
+static const DialogueLineEntry kMomTopic121Lines[] = {
+	{ 0x1ec0, "PC", 0 },
+	{ 0x1ec4, "MOM", 2 },
+	{ 0x1ec9, "PC", 0 },
+	{ 0x1ecd, "MOM", 2 }
+};
+
 static const DialogueLineEntry kMomPtaLines[] = {
 	{ 0x1eee, "PC", 2 },
 	{ 0x1ef2, "MOM", 0 }
@@ -886,6 +911,25 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		return Common::kNoError;
 	};
 
+	auto playDialogueFst = [&](const Common::String &path) -> Common::Error {
+		FstPlayer fstPlayer(_engine);
+		if (!fstPlayer.play(path))
+			return Common::kReadingFailed;
+
+		return Common::kNoError;
+	};
+
+	auto queueDialogueInteractionIfNeeded = [&](const StartupInteractionResult &interaction) {
+		if (interaction.abortRemainingCommandChain || interaction.mutatedRuntimeState ||
+				!interaction.musicPath.empty() || !interaction.nextRoomName.empty() ||
+				!interaction.deathFlicPath.empty() || interaction.requestMainMenu ||
+				!interaction.dialogueNpcName.empty() ||
+				!interaction.dialogueContinuationTag.empty() ||
+				!interaction.audioCommands.empty()) {
+			startupFlow.queueDialogueInteraction(interaction);
+		}
+	};
+
 	auto buildResponseMenuLayout = [&](const Common::String &responseLine,
 			Common::Array<DialogueResponseOptionLayout> &options, uint &totalRows) {
 		options.clear();
@@ -1542,14 +1586,42 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 				continue;
 			}
 			if (matchesResponseLine(selectedTopic, 0x11d)) {
-				Common::Error lineError = playDialogueLine(0x1e7c, "PC");
+				Common::Error lineError = playDialogueEntrySequence(
+					kMomTopic11dOpeningLines, ARRAYSIZE(kMomTopic11dOpeningLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playDialogueFst(kDialogueC008AFstPath);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playDialogueEntrySequence(
+					kMomTopic11dPostC008ALines, ARRAYSIZE(kMomTopic11dPostC008ALines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				StartupInteractionResult babyGurgleInteraction;
+				if (startupScript->executeActionTag(kBabyGurgleActionTag, babyGurgleInteraction)) {
+					if (!babyGurgleInteraction.musicPath.empty())
+						(void)_engine.playStartupMusic(babyGurgleInteraction.musicPath);
+					if (!babyGurgleInteraction.audioCommands.empty())
+						startupFlow.executeStartupAudioCommands(babyGurgleInteraction.audioCommands);
+
+					babyGurgleInteraction.musicPath.clear();
+					babyGurgleInteraction.audioCommands.clear();
+					queueDialogueInteractionIfNeeded(babyGurgleInteraction);
+				}
+
+				lineError = playDialogueEntrySequence(
+					kMomTopic11dClosingLines, ARRAYSIZE(kMomTopic11dClosingLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playDialogueFst(kDialogueC008BFstPath);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				assignMomTopicBuffer(0x120);
 				continue;
 			}
 			if (matchesResponseLine(selectedTopic, 0x121)) {
-				Common::Error lineError = playDialogueLine(0x1ec0, "PC");
+				Common::Error lineError = playDialogueEntrySequence(kMomTopic121Lines, ARRAYSIZE(kMomTopic121Lines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				assignMomTopicBuffer(0x122);


Commit: 3712f33422993a212b18aab21337df7365c428d1
    https://github.com/scummvm/scummvm/commit/3712f33422993a212b18aab21337df7365c428d1
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:56+02:00

Commit Message:
HARVESTER: Audit wasp woman dialogue helpers

Changed paths:
    ARCHITECTURE.md
    TRACKER.md


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 5353255e7c8..2ece1726190 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -444,7 +444,10 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `get_set_mom_good_cause_day5_state` at `0x383c0` is a Mom-local day-5 `GOOD CAUSE` state bit; the visible caller currently reads it, but the concrete setter is still outside the recovered call graph.
   - `get_set_dad_meat_permission_state` at `0x38400` is the Dad-local `MEAT_PERMISSION0` state bit read on the related item branch.
   - `get_set_shared_dialogue_state_d2f04` at `0x383e0` is a neutral shared wrapper over `g_shared_dialogue_state_d2f04`; raw call tracing shows it is used from hidden regions in Moynahan, Mr. Potts, Johnson, Phelps, Butcher, Dad, McKnight, Loomis, Edna, Sparky, and Dwayne, but the surrounding subtitle lines still span too many unrelated topics for a stronger semantic rename.
-  - `get_set_wasp_woman_mom_dialogue_state_d2f10` at `0x38420` is a neutral two-handler wrapper over `g_wasp_woman_mom_dialogue_state_d2f10`; raw calls only appear in the hidden Wasp Woman and Mom regions, with nearby `dialog.rsp` anchors in the `Sparky/Wasp Woman` and `SPARKY` / `P.T.A.` keyword cluster, but the exact shared topic is still unresolved. The later raw-byte recovery of Mom `0x11d` / `0x121` showed that helper is not part of those two branches.
+  - `get_set_wasp_woman_dialogue_state_d2f10` at `0x38420` is a neutral wrapper over `g_wasp_woman_mom_dialogue_state_d2f10`; a program-wide linear call scan currently only finds two call sites, both inside hidden `handle_talk_to_wasp_woman` code at `0x2ff4e` and `0x3010f`.
+  - On Wasp Woman's no-item intro path, that helper is called between `0x4bee` and the optional `0x4bf2` PC reply before the fixed `0x4bf6` / `0x4bfc` exchange.
+  - On Wasp Woman's hidden `0x305` keyword branch, the same helper is called after `0x4c31`; if it returns nonzero, the branch opens response line `0x307`, where choice `1` plays `0x4c4d` / `0x4c53` and choice `2` plays `0x4c5e`.
+  - `play_c008_prelude_fst` at `0x388c0` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C123.FST", 0)`; both normal xrefs and a full linear call scan currently show no direct callers, so it remains an orphan FST helper for now.
   - `get_set_discussed_mr_potts_tuesday_night_alibi` at `0x38440` is set on the hidden `handle_talk_to_mr_potts` Tuesday-night branch and later read by Johnson and Stephanie; Stephanie's hidden `0x4980` line, "Pottsdam lied. He said he stayed home Tuesday night.", makes the semantics explicit.
   - `get_set_discussed_mrs_potts_tuesday_night_alibi` at `0x38460` is set on the hidden `handle_talk_to_mrs_potts` Tuesday-night branch and later read by Stephanie; the hidden `0x4984` / `0x4989` / `0x498f` exchange anchors it to Mrs. Potts's version of Pottsdam's Tuesday-night alibi.
   - `get_set_confronted_mr_potts_about_spyhole` at `0x384a0` is set on the hidden `handle_talk_to_mr_potts` spyhole confrontation and later read by Stephanie; the paired `0x2d35` / `0x2d39` / `0x2d3e` and `0x4a12` / `0x4a19` / `0x4a1e` / `0x4a23` subtitle lines make the semantics explicit.
diff --git a/TRACKER.md b/TRACKER.md
index 5b4cbdfb305..0bf3a6c6293 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,15 +15,15 @@
 
 ## Last Confirmed Action
 
-- Recovered and ported Mom's hidden `0x11d` / `0x121` branches instead of the old one-line stubs.
-  - `0x11d` now mirrors the native `0x1e7c` / `0x1e80` exchange, `C008A.FST`, `0x1e8e` / `0x1e94`, the `BABY_GURGLE` action-tag dispatch, `0x1e9d` / `0x1ea3`, `C008B.FST`, and the keyword-buffer rewrite to `0x120`.
-  - `0x121` now mirrors the native `0x1ec0` / `0x1ec4` / `0x1ec9` / `0x1ecd` exchange before rewriting the keyword buffer to `0x122`.
-  - The raw-byte audit corrected the earlier helper guess: `0x388d0` and `0x388e0` belong to Mom `0x11d`, while `0x388c0` and `0x38420` are real nearby helpers but not part of Mom `0x11d` / `0x121`.
-  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Mom hidden-branch recovery.
+- Completed the follow-up audit on the remaining Mom/Wasp Woman helper cluster after the Mom `0x11d` / `0x121` recovery.
+  - `get_set_wasp_woman_dialogue_state_d2f10 @ 0x38420` is no longer treated as a Mom/Wasp shared helper: a linear call scan only finds `CALL 0x38420` at `0x2ff4e` and `0x3010f`, both inside hidden `handle_talk_to_wasp_woman @ 0x2fde0`, so the Ghidra name was corrected accordingly.
+  - Those two Wasp Woman call sites gate the no-item intro exchange (`0x4bee`, optional `0x4bf2`, then `0x4bf6` / `0x4bfc`) and the hidden `0x305` keyword branch (`0x4c31` followed by the `0x307` response-menu lines `0x4c4d` / `0x4c53` or `0x4c5e`).
+  - `play_c008_prelude_fst @ 0x388c0` is confirmed as a `GRAPHIC/FST/C123.FST` wrapper, but both normal xrefs and a whole-program call scan still show no direct callers in the current database.
+  - This pass was Ghidra/documentation-only; no engine build was needed after the audit.
 
 ## Next Suggested Action
 
-1. Audit the remaining hidden Mom/Wasp Woman helper cluster that is still unresolved after closing Mom `0x11d` / `0x121`.
-   - `play_c008_prelude_fst @ 0x388c0` and `get_set_wasp_woman_mom_dialogue_state_d2f10 @ 0x38420` are confirmed helpers, but the raw-byte recovery showed they are not part of Mom `0x11d` / `0x121`; identify which hidden Mom/Wasp Woman topics actually call them.
-2. If wider evidence parity work is needed beyond Mom/Hank/Jimmy, port the next native talk handler cluster into `startup_dialogue.cpp` before auditing its shared `SHOWN_*` writes.
-   - Dwayne, Edna, Herrill, and Johnson already have confirmed native evidence-wrapper behavior, but there is no explicit engine-side room handler for them yet.
+1. Port `handle_talk_to_wasp_woman @ 0x2fde0` into `startup_dialogue.cpp`.
+   - The engine only has explicit room dialogue handlers for Jimmy, Mom, and Hank today; Wasp Woman is now the best-bounded next target, including the `0x38420`-gated intro path and the hidden `0x305` -> `0x307` response-menu branch.
+2. After Wasp Woman, port the next bounded startup-room handler cluster rather than jumping back to broad helper scans.
+   - `handle_talk_to_pta_mom @ 0x34e30` is already identified, while Dwayne, Edna, Herrill, and Johnson still need explicit engine-side room handlers before their shared `SHOWN_*` evidence writes can be mirrored.


Commit: b16463f0a78055fd9d69fcb8b5e89ba12aa0c4fe
    https://github.com/scummvm/scummvm/commit/b16463f0a78055fd9d69fcb8b5e89ba12aa0c4fe
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:56+02:00

Commit Message:
HARVESTER: Port wasp woman startup dialogue

Changed paths:
    TRACKER.md
    engines/harvester/startup_dialogue.cpp
    engines/harvester/startup_dialogue.h


diff --git a/TRACKER.md b/TRACKER.md
index 0bf3a6c6293..38201dce0e7 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,15 +15,15 @@
 
 ## Last Confirmed Action
 
-- Completed the follow-up audit on the remaining Mom/Wasp Woman helper cluster after the Mom `0x11d` / `0x121` recovery.
-  - `get_set_wasp_woman_dialogue_state_d2f10 @ 0x38420` is no longer treated as a Mom/Wasp shared helper: a linear call scan only finds `CALL 0x38420` at `0x2ff4e` and `0x3010f`, both inside hidden `handle_talk_to_wasp_woman @ 0x2fde0`, so the Ghidra name was corrected accordingly.
-  - Those two Wasp Woman call sites gate the no-item intro exchange (`0x4bee`, optional `0x4bf2`, then `0x4bf6` / `0x4bfc`) and the hidden `0x305` keyword branch (`0x4c31` followed by the `0x307` response-menu lines `0x4c4d` / `0x4c53` or `0x4c5e`).
-  - `play_c008_prelude_fst @ 0x388c0` is confirmed as a `GRAPHIC/FST/C123.FST` wrapper, but both normal xrefs and a whole-program call scan still show no direct callers in the current database.
-  - This pass was Ghidra/documentation-only; no engine build was needed after the audit.
+- Ported `handle_talk_to_wasp_woman @ 0x2fde0` into `startup_dialogue.cpp`.
+  - The engine now mirrors Wasp Woman's item branches for `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, and the note/checkbook evidence group, including the shared `SHOWN_*` runtime-flag writes before `0x4ca6` / `0x4cae` / `0x4cb2` / `0x4cb6`, `0x4cbd`, and `0x4cc3`.
+  - The no-item path now mirrors the native one-time intro sequence (`0x4bee`, optional `0x4bf2`, `0x4bf6`, `0x4bfc`), the fixed `0x301` keyword menu seed, and the `0x302`, `0x305`, `0x308`, `0x30a`, `0x30c`, and `0x30e` menu branches.
+  - The query-only `get_set_wasp_woman_dialogue_state_d2f10 @ 0x38420` gate is represented as shared engine dialogue state; it currently stays false because the native audit has not recovered any startup-side writer beyond the global reset path, so the optional `0x4bf2` line and the `0x307` response-menu follow-up remain correctly locked behind that unresolved state.
+  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Wasp Woman dialogue port.
 
 ## Next Suggested Action
 
-1. Port `handle_talk_to_wasp_woman @ 0x2fde0` into `startup_dialogue.cpp`.
-   - The engine only has explicit room dialogue handlers for Jimmy, Mom, and Hank today; Wasp Woman is now the best-bounded next target, including the `0x38420`-gated intro path and the hidden `0x305` -> `0x307` response-menu branch.
-2. After Wasp Woman, port the next bounded startup-room handler cluster rather than jumping back to broad helper scans.
-   - `handle_talk_to_pta_mom @ 0x34e30` is already identified, while Dwayne, Edna, Herrill, and Johnson still need explicit engine-side room handlers before their shared `SHOWN_*` evidence writes can be mirrored.
+1. Port `handle_talk_to_pta_mom @ 0x34e30` into `startup_dialogue.cpp`.
+   - The handler is already bounded to `PTA_MOM1` through `PTA_MOM5`, uses the fixed `0x297` response menu, and is the next explicit startup-room talk cluster after Wasp Woman.
+2. After PTA Mom, continue porting bounded startup-room handlers before returning to wider helper scans.
+   - Dwayne, Edna, Herrill, and Johnson still need explicit engine-side room handlers before their confirmed shared `SHOWN_*` evidence writes can be mirrored.
diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/startup_dialogue.cpp
index 20dcd833cfc..a8c77c5ea5f 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/startup_dialogue.cpp
@@ -325,6 +325,23 @@ static const DialogueLineEntry kMomSlaughterhouseLines[] = {
 	{ 0x1fbd, "MOM", 0 }
 };
 
+static const DialogueLineEntry kWaspWomanWhaleyLines[] = {
+	{ 0x4ca6, "WASP_WOMAN", 0 },
+	{ 0x4cae, "WASP_WOMAN", 1 },
+	{ 0x4cb2, "PC", 0 },
+	{ 0x4cb6, "WASP_WOMAN", 0 }
+};
+
+static const DialogueLineEntry kWaspWomanIntroTailLines[] = {
+	{ 0x4bf6, "WASP_WOMAN", 0 },
+	{ 0x4bfc, "PC", 0 }
+};
+
+static const DialogueLineEntry kWaspWomanTopic305Response1Lines[] = {
+	{ 0x4c4d, "WASP_WOMAN", 1 },
+	{ 0x4c53, "WASP_WOMAN", 1 }
+};
+
 static const int kMomPtaTopicResponseLines[] = { 0x125, 0x126, 0x127, 0x128 };
 static const int kMomCookingTopicResponseLines[] = { 0x131, 0x132, 0x133 };
 static const int kMomBakeSaleTopicResponseLines[] = { 0x145, 0x146, 0x147 };
@@ -346,6 +363,7 @@ static const int kMomBakeSaleFollowupTopicBufferResponseLine = 0x148;
 static const int kMomPottsdamFollowupTopicBufferResponseLine = 0x150;
 static const int kMomMeatPlantFollowupTopicBufferResponseLine = 0x154;
 static const int kMomGoodCauseDay5FollowupTopicBufferResponseLine = 0x15c;
+static const int kWaspWomanTopicBufferResponseLine = 0x301;
 
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
 	const StartupText *startupText = engine.getStartupText();
@@ -587,8 +605,10 @@ void StartupDialogueSystem::resetRoomNpcDialogueState() {
 	_hankRoomDialogueState = HankRoomDialogueState();
 	_momRoomDialogueState = MomRoomDialogueState();
 	_jimmyRoomDialogueState = JimmyRoomDialogueState();
+	_waspWomanRoomDialogueState = WaspWomanRoomDialogueState();
 	_sharedKarinKidnapedDialogueState = false;
 	_sharedDiscussedLodgeTopic = false;
+	_sharedWaspWomanDialogueState = false;
 }
 
 Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
@@ -1340,6 +1360,129 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		return playJimmyLine(0x4b38, 0);
 	}
 
+	if (npc.npcName.equalsIgnoreCase("WASP_WOMAN")) {
+		Common::String waspWomanTopicBuffer;
+		int waspWomanTopicBufferLineIndex = -1;
+		auto assignWaspWomanTopicBuffer = [&](int responseLineIndex) {
+			assignTopicBuffer(waspWomanTopicBuffer, waspWomanTopicBufferLineIndex, responseLineIndex,
+				"Wasp Woman topic buffer");
+		};
+		auto playWaspWomanLine = [&](int wavId, int headVariant) -> Common::Error {
+			return playDialogueLineWithVariant(wavId, "WASP_WOMAN", headVariant);
+		};
+
+		if (!usedItemName.empty()) {
+			if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+				(void)startupScript->setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
+				return playDialogueEntrySequence(kWaspWomanWhaleyLines, ARRAYSIZE(kWaspWomanWhaleyLines));
+			}
+			if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+					usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+				(void)startupScript->setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
+				return playWaspWomanLine(0x4cbd, 0);
+			}
+			if (usedItemName.equalsIgnoreCase("NOTE") ||
+					usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+					usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+					usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+				(void)startupScript->setRuntimeFlagValue(kShownEvidenceOfBlackmailFlag, true);
+				return playWaspWomanLine(0x4cc3, 0);
+			}
+
+			return playWaspWomanLine(0x4ca0, 0);
+		}
+
+		if (_waspWomanRoomDialogueState.introPending) {
+			_waspWomanRoomDialogueState.introPending = false;
+
+			Common::Error lineError = playWaspWomanLine(0x4bee, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			if (_sharedWaspWomanDialogueState) {
+				lineError = playDialogueLine(0x4bf2, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			lineError = playDialogueEntrySequence(kWaspWomanIntroTailLines, ARRAYSIZE(kWaspWomanIntroTailLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		Common::Error lineError = playWaspWomanLine(0x4bee, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignWaspWomanTopicBuffer(kWaspWomanTopicBufferResponseLine);
+
+		for (;;) {
+			Common::String selectedTopic;
+			Common::Error menuError = runKeywordMenu(
+				waspWomanTopicBuffer, waspWomanTopicBufferLineIndex, selectedTopic);
+			if (menuError.getCode() != Common::kNoError)
+				return menuError;
+			if (selectedTopic.empty() || selectedTopic.equalsIgnoreCase(genericByeTopic))
+				return Common::kNoError;
+
+			if (matchesResponseLine(selectedTopic, 0x302)) {
+				lineError = playWaspWomanLine(0x4c0a, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x305)) {
+				lineError = playWaspWomanLine(0x4c31, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				if (!_sharedWaspWomanDialogueState)
+					continue;
+
+				int responseIndex = 0;
+				Common::Error responseError = runResponseMenu(0x307, responseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+
+				switch (responseIndex) {
+				case 1:
+					lineError = playDialogueEntrySequence(kWaspWomanTopic305Response1Lines,
+						ARRAYSIZE(kWaspWomanTopic305Response1Lines));
+					break;
+				case 2:
+					lineError = playWaspWomanLine(0x4c5e, 2);
+					break;
+				default:
+					lineError = Common::kNoError;
+					break;
+				}
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x308)) {
+				lineError = playWaspWomanLine(0x4c67, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x30a)) {
+				lineError = playWaspWomanLine(0x4c74, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x30c)) {
+				lineError = playWaspWomanLine(0x4c85, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			}
+			if (matchesResponseLine(selectedTopic, 0x30e)) {
+				lineError = playWaspWomanLine(0x4c9a, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				return Common::kNoError;
+			}
+		}
+	}
+
 	if (npc.npcName.equalsIgnoreCase("MOM")) {
 		const int currentStoryDayIndex = startupScript->getCurrentStoryDayIndex();
 		Common::String momTopicBuffer;
diff --git a/engines/harvester/startup_dialogue.h b/engines/harvester/startup_dialogue.h
index 4fbcf8b27ac..38cdd75d6ff 100644
--- a/engines/harvester/startup_dialogue.h
+++ b/engines/harvester/startup_dialogue.h
@@ -87,6 +87,10 @@ public:
 		bool paperHandoffStateSet = false;
 	};
 
+	struct WaspWomanRoomDialogueState {
+		bool introPending = true;
+	};
+
 	StartupDialogueSystem(HarvesterEngine &engine, Common::Point &mousePos);
 
 	void resetRoomNpcDialogueState();
@@ -100,8 +104,10 @@ private:
 	HankRoomDialogueState _hankRoomDialogueState;
 	MomRoomDialogueState _momRoomDialogueState;
 	JimmyRoomDialogueState _jimmyRoomDialogueState;
+	WaspWomanRoomDialogueState _waspWomanRoomDialogueState;
 	bool _sharedKarinKidnapedDialogueState = false;
 	bool _sharedDiscussedLodgeTopic = false;
+	bool _sharedWaspWomanDialogueState = false;
 };
 
 } // End of namespace Harvester


Commit: ce94b5d6b24a7a462ee3bfdb38ebdf1570578cf6
    https://github.com/scummvm/scummvm/commit/ce94b5d6b24a7a462ee3bfdb38ebdf1570578cf6
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:56+02:00

Commit Message:
HARVESTER: Split dialogue system into npc handlers

Changed paths:
  A engines/harvester/dialogue.cpp
  A engines/harvester/dialogue.h
  A engines/harvester/npc/dialogue_handler.h
  A engines/harvester/npc/hank_dialogue.cpp
  A engines/harvester/npc/hank_dialogue.h
  A engines/harvester/npc/jimmy_dialogue.cpp
  A engines/harvester/npc/jimmy_dialogue.h
  A engines/harvester/npc/mom_dialogue.cpp
  A engines/harvester/npc/mom_dialogue.h
  A engines/harvester/npc/stub_dialogue.cpp
  A engines/harvester/npc/stub_dialogue.h
  A engines/harvester/npc/wasp_woman_dialogue.cpp
  A engines/harvester/npc/wasp_woman_dialogue.h
  R engines/harvester/startup_dialogue.cpp
  R engines/harvester/startup_dialogue.h
    TRACKER.md
    engines/harvester/module.mk
    engines/harvester/startup_flow.h


diff --git a/TRACKER.md b/TRACKER.md
index 38201dce0e7..b0f655cfdec 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,15 +15,15 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_wasp_woman @ 0x2fde0` into `startup_dialogue.cpp`.
-  - The engine now mirrors Wasp Woman's item branches for `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, and the note/checkbook evidence group, including the shared `SHOWN_*` runtime-flag writes before `0x4ca6` / `0x4cae` / `0x4cb2` / `0x4cb6`, `0x4cbd`, and `0x4cc3`.
-  - The no-item path now mirrors the native one-time intro sequence (`0x4bee`, optional `0x4bf2`, `0x4bf6`, `0x4bfc`), the fixed `0x301` keyword menu seed, and the `0x302`, `0x305`, `0x308`, `0x30a`, `0x30c`, and `0x30e` menu branches.
-  - The query-only `get_set_wasp_woman_dialogue_state_d2f10 @ 0x38420` gate is represented as shared engine dialogue state; it currently stays false because the native audit has not recovered any startup-side writer beyond the global reset path, so the optional `0x4bf2` line and the `0x307` response-menu follow-up remain correctly locked behind that unresolved state.
-  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Wasp Woman dialogue port.
+- Refactored `StartupDialogueSystem` into `DialogueSystem` and split room-NPC handlers into `engines/harvester/npc/`.
+  - `startup_dialogue.cpp` / `startup_dialogue.h` were renamed to `dialogue.cpp` / `dialogue.h`, and `StartupFlow` now owns `DialogueSystem _dialogue`.
+  - Jimmy, Mom, Hank, and Wasp Woman now dispatch through dedicated handler classes in `engines/harvester/npc/`, while `PTA_MOM`, `PTA_MOM1`-`PTA_MOM5`, `DWAYNE`, `EDNA`, `HERRILL`, and `JOHNSON` are stubbed through the same registry.
+  - The runtime split preserves the existing recovered dialogue behavior while making the next startup-room ports land as isolated NPC handlers instead of growing a single monolithic dispatcher again.
+  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the refactor.
 
 ## Next Suggested Action
 
-1. Port `handle_talk_to_pta_mom @ 0x34e30` into `startup_dialogue.cpp`.
-   - The handler is already bounded to `PTA_MOM1` through `PTA_MOM5`, uses the fixed `0x297` response menu, and is the next explicit startup-room talk cluster after Wasp Woman.
+1. Port `handle_talk_to_pta_mom @ 0x34e30` into `DialogueSystem` as `engines/harvester/npc/pta_mom_dialogue.*`.
+   - The handler is already bounded to `PTA_MOM1` through `PTA_MOM5`, uses the fixed `0x297` response menu, and now has an explicit stub slot in the NPC registry to replace.
 2. After PTA Mom, continue porting bounded startup-room handlers before returning to wider helper scans.
    - Dwayne, Edna, Herrill, and Johnson still need explicit engine-side room handlers before their confirmed shared `SHOWN_*` evidence writes can be mirrored.
diff --git a/engines/harvester/startup_dialogue.cpp b/engines/harvester/dialogue.cpp
similarity index 55%
rename from engines/harvester/startup_dialogue.cpp
rename to engines/harvester/dialogue.cpp
index a8c77c5ea5f..9d705a679a1 100644
--- a/engines/harvester/startup_dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -19,9 +19,10 @@
  *
  */
 
+#include <functional>
 #include <memory>
 
-#include "harvester/startup_dialogue.h"
+#include "harvester/dialogue.h"
 
 #include "common/debug.h"
 #include "common/endian.h"
@@ -35,6 +36,11 @@
 #include "harvester/detection.h"
 #include "harvester/fst_player.h"
 #include "harvester/harvester.h"
+#include "harvester/npc/hank_dialogue.h"
+#include "harvester/npc/jimmy_dialogue.h"
+#include "harvester/npc/mom_dialogue.h"
+#include "harvester/npc/stub_dialogue.h"
+#include "harvester/npc/wasp_woman_dialogue.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
 #include "harvester/startup_art.h"
@@ -43,6 +49,117 @@
 
 namespace Harvester {
 
+struct DialogueLineEntry {
+	int wavId;
+	const char *speakerId;
+	int headVariant;
+};
+
+class DialogueRuntime {
+public:
+	typedef std::function<Common::Error(int, const Common::String &, int)> PlayDialogueLineWithVariantFn;
+	typedef std::function<Common::Error(int, const Common::String &)> PlayDialogueLineFn;
+	typedef std::function<Common::Error(const DialogueLineEntry *, uint)> PlayDialogueEntrySequenceFn;
+	typedef std::function<Common::Error(const Common::String &)> PlayDialogueFstFn;
+	typedef std::function<Common::Error(const Common::String &, int, Common::String &)> RunKeywordMenuFn;
+	typedef std::function<Common::Error(int, int &)> RunResponseMenuFn;
+	typedef std::function<void(Common::String &, int &, int, const char *)> AssignTopicBufferFn;
+	typedef std::function<bool(const Common::String &, int)> MatchesResponseLineFn;
+	typedef std::function<bool(const Common::String &, const int *, uint)> MatchesAnyResponseLineFn;
+	typedef std::function<void(const StartupInteractionResult &)> QueueDialogueInteractionIfNeededFn;
+	typedef std::function<int(int)> GetRandomNumberFn;
+	typedef std::function<void(const Common::String &, int)> SetActiveSpeakerPortraitFn;
+
+	DialogueRuntime(HarvesterEngine &engine, StartupScript &startupScript, StartupText &startupText,
+			StartupFlow &startupFlow, const Common::String &genericByeTopic,
+			const PlayDialogueLineWithVariantFn &playDialogueLineWithVariant,
+			const PlayDialogueLineFn &playDialogueLine,
+			const PlayDialogueEntrySequenceFn &playDialogueEntrySequence,
+			const PlayDialogueFstFn &playDialogueFst,
+			const RunKeywordMenuFn &runKeywordMenu,
+			const RunResponseMenuFn &runResponseMenu,
+			const AssignTopicBufferFn &assignTopicBuffer,
+			const MatchesResponseLineFn &matchesResponseLine,
+			const MatchesAnyResponseLineFn &matchesAnyResponseLine,
+			const QueueDialogueInteractionIfNeededFn &queueDialogueInteractionIfNeeded,
+			const GetRandomNumberFn &getRandomNumber,
+			const SetActiveSpeakerPortraitFn &setActiveSpeakerPortrait)
+		: _engine(engine), _startupScript(startupScript), _startupText(startupText),
+		  _startupFlow(startupFlow), _genericByeTopic(genericByeTopic),
+		  _playDialogueLineWithVariant(playDialogueLineWithVariant),
+		  _playDialogueLine(playDialogueLine), _playDialogueEntrySequence(playDialogueEntrySequence),
+		  _playDialogueFst(playDialogueFst), _runKeywordMenu(runKeywordMenu),
+		  _runResponseMenu(runResponseMenu), _assignTopicBuffer(assignTopicBuffer),
+		  _matchesResponseLine(matchesResponseLine),
+		  _matchesAnyResponseLine(matchesAnyResponseLine),
+		  _queueDialogueInteractionIfNeeded(queueDialogueInteractionIfNeeded),
+		  _getRandomNumber(getRandomNumber),
+		  _setActiveSpeakerPortrait(setActiveSpeakerPortrait) {
+	}
+
+	HarvesterEngine &engine() const { return _engine; }
+	StartupScript &startupScript() const { return _startupScript; }
+	StartupText &startupText() const { return _startupText; }
+	StartupFlow &startupFlow() const { return _startupFlow; }
+	const Common::String &genericByeTopic() const { return _genericByeTopic; }
+
+	Common::Error playDialogueLineWithVariant(int wavId, const Common::String &speakerId,
+			int headVariant) const {
+		return _playDialogueLineWithVariant(wavId, speakerId, headVariant);
+	}
+	Common::Error playDialogueLine(int wavId, const Common::String &speakerId) const {
+		return _playDialogueLine(wavId, speakerId);
+	}
+	Common::Error playDialogueEntrySequence(const DialogueLineEntry *lines, uint count) const {
+		return _playDialogueEntrySequence(lines, count);
+	}
+	Common::Error playDialogueFst(const Common::String &path) const { return _playDialogueFst(path); }
+	Common::Error runKeywordMenu(const Common::String &topicBuffer, int topicBufferLineIndex,
+			Common::String &selectedTopic) const {
+		return _runKeywordMenu(topicBuffer, topicBufferLineIndex, selectedTopic);
+	}
+	Common::Error runResponseMenu(int responseLineIndex, int &responseIndex) const {
+		return _runResponseMenu(responseLineIndex, responseIndex);
+	}
+	void assignTopicBuffer(Common::String &topicBuffer, int &topicBufferLineIndex,
+			int responseLineIndex, const char *label) const {
+		_assignTopicBuffer(topicBuffer, topicBufferLineIndex, responseLineIndex, label);
+	}
+	bool matchesResponseLine(const Common::String &selectedTopic, int responseLineIndex) const {
+		return _matchesResponseLine(selectedTopic, responseLineIndex);
+	}
+	bool matchesAnyResponseLine(const Common::String &selectedTopic,
+			const int *responseLineIndices, uint responseLineCount) const {
+		return _matchesAnyResponseLine(selectedTopic, responseLineIndices, responseLineCount);
+	}
+	void queueDialogueInteractionIfNeeded(const StartupInteractionResult &interaction) const {
+		_queueDialogueInteractionIfNeeded(interaction);
+	}
+	int getRandomNumber(int maxValue) const { return _getRandomNumber(maxValue); }
+	void setActiveSpeakerPortrait(const Common::String &speakerId, int headVariant) const {
+		_setActiveSpeakerPortrait(speakerId, headVariant);
+	}
+
+private:
+	HarvesterEngine &_engine;
+	StartupScript &_startupScript;
+	StartupText &_startupText;
+	StartupFlow &_startupFlow;
+	const Common::String &_genericByeTopic;
+	PlayDialogueLineWithVariantFn _playDialogueLineWithVariant;
+	PlayDialogueLineFn _playDialogueLine;
+	PlayDialogueEntrySequenceFn _playDialogueEntrySequence;
+	PlayDialogueFstFn _playDialogueFst;
+	RunKeywordMenuFn _runKeywordMenu;
+	RunResponseMenuFn _runResponseMenu;
+	AssignTopicBufferFn _assignTopicBuffer;
+	MatchesResponseLineFn _matchesResponseLine;
+	MatchesAnyResponseLineFn _matchesAnyResponseLine;
+	QueueDialogueInteractionIfNeededFn _queueDialogueInteractionIfNeeded;
+	GetRandomNumberFn _getRandomNumber;
+	SetActiveSpeakerPortraitFn _setActiveSpeakerPortrait;
+};
+
 namespace {
 
 static const int kDialogueOverlayX = 167;
@@ -89,21 +206,6 @@ struct HankDialogueTopicLine {
 	bool setDiscussedLodgeTopic;
 };
 
-struct DialogueLineSpec {
-	DialogueLineSpec(int wavId, const char *speakerId, int headVariant = 0)
-		: wavId(wavId), speakerId(speakerId), headVariant(headVariant) {}
-
-	int wavId;
-	const char *speakerId;
-	int headVariant;
-};
-
-struct DialogueLineEntry {
-	int wavId;
-	const char *speakerId;
-	int headVariant;
-};
-
 static const HankDialogueTopicLine kHankDialogueTopicLines[] = {
 	{ 0xd2, 0x725, "HANK", false },
 	{ 0xd6, 0x74f, "PC", false },
@@ -597,11 +699,37 @@ static bool isDialogueOtherHit(const Common::Point &mousePos) {
 
 } // End of anonymous namespace
 
-StartupDialogueSystem::StartupDialogueSystem(HarvesterEngine &engine, Common::Point &mousePos)
+DialogueSystem::DialogueSystem(HarvesterEngine &engine, Common::Point &mousePos)
 	: _engine(engine), _mousePos(mousePos) {
+	registerNpcHandlers();
+}
+
+DialogueSystem::~DialogueSystem() {
+	for (uint i = 0; i < _npcHandlers.size(); ++i)
+		delete _npcHandlers[i];
 }
 
-void StartupDialogueSystem::resetRoomNpcDialogueState() {
+void DialogueSystem::registerNpcHandlers() {
+	_npcHandlers.push_back(new JimmyDialogueHandler());
+	_npcHandlers.push_back(new WaspWomanDialogueHandler());
+	_npcHandlers.push_back(new MomDialogueHandler());
+	_npcHandlers.push_back(new HankDialogueHandler());
+
+	Common::Array<Common::String> ptaMomNames;
+	ptaMomNames.push_back(Common::String("PTA_MOM"));
+	ptaMomNames.push_back(Common::String("PTA_MOM1"));
+	ptaMomNames.push_back(Common::String("PTA_MOM2"));
+	ptaMomNames.push_back(Common::String("PTA_MOM3"));
+	ptaMomNames.push_back(Common::String("PTA_MOM4"));
+	ptaMomNames.push_back(Common::String("PTA_MOM5"));
+	_npcHandlers.push_back(new StubNpcDialogueHandler(ptaMomNames));
+	_npcHandlers.push_back(new StubNpcDialogueHandler("DWAYNE"));
+	_npcHandlers.push_back(new StubNpcDialogueHandler("EDNA"));
+	_npcHandlers.push_back(new StubNpcDialogueHandler("HERRILL"));
+	_npcHandlers.push_back(new StubNpcDialogueHandler("JOHNSON"));
+}
+
+void DialogueSystem::resetRoomNpcDialogueState() {
 	_hankRoomDialogueState = HankRoomDialogueState();
 	_momRoomDialogueState = MomRoomDialogueState();
 	_jimmyRoomDialogueState = JimmyRoomDialogueState();
@@ -611,7 +739,7 @@ void StartupDialogueSystem::resetRoomNpcDialogueState() {
 	_sharedWaspWomanDialogueState = false;
 }
 
-Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
+Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, const StartupNpcRecord &npc, const Common::String &usedItemName,
 		StartupFlow &startupFlow) {
 	struct DialogueResponseOptionLayout {
@@ -908,18 +1036,6 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		return playDialogueLineWithVariant(wavId, speakerId, 0);
 	};
 
-	// Multi-line exchanges in the original binary are emitted as consecutive play_dialogue_line calls.
-	auto playDialogueSequence = [&](const DialogueLineSpec *lines, uint count) -> Common::Error {
-		for (uint i = 0; i < count; ++i) {
-			Common::Error lineError = playDialogueLineWithVariant(
-				lines[i].wavId, lines[i].speakerId, lines[i].headVariant);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-
-		return Common::kNoError;
-	};
-
 	auto playDialogueEntrySequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
 		for (uint i = 0; i < count; ++i) {
 			Common::Error lineError = playDialogueLineWithVariant(
@@ -1223,801 +1339,829 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		logDialogueMenuItems(label, responseLineIndex, topicBuffer, topics);
 	};
 
-	if (npc.npcName.equalsIgnoreCase("JIMMY")) {
-		auto playJimmyLine = [&](int wavId, int headVariant) -> Common::Error {
-			return playDialogueLineWithVariant(wavId, "JIMMY", headVariant);
-		};
-		auto hasInventoryItem = [&](const char *objectName) {
-			Common::Array<StartupObjectRecord> inventoryObjects;
-			startupScript->getVisibleInventoryObjects(inventoryObjects);
-			for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
-				if (inventoryObject.objectName.equalsIgnoreCase(objectName))
-					return true;
-			}
+	DialogueRuntime runtime(
+		_engine, *startupScript, *startupText, startupFlow, genericByeTopic,
+		playDialogueLineWithVariant, playDialogueLine, playDialogueEntrySequence,
+		playDialogueFst, runKeywordMenu, runResponseMenu, assignTopicBuffer,
+		matchesResponseLine, matchesAnyResponseLine, queueDialogueInteractionIfNeeded,
+		[&](int maxValue) { return _engine.getRandomNumber(maxValue); },
+		setActiveSpeakerPortrait);
+
+	for (uint i = 0; i < _npcHandlers.size(); ++i) {
+		if (_npcHandlers[i]->matchesNpc(npc.npcName))
+			return _npcHandlers[i]->handle(*this, runtime, usedItemName);
+	}
 
-			return false;
-		};
+	debug(1, "Harvester: unsupported room NPC dialogue handler '%s'", npc.npcName.c_str());
+	return Common::kNoError;
+}
 
-		if (!usedItemName.empty()) {
-			if (usedItemName.equalsIgnoreCase("NEWSPAPER")) {
-				StartupInteractionResult jimmyInteraction;
-				const bool changedGivenPaperToday =
-					startupScript->setRuntimeFlagValue("GIVEN_PAPER_TODAY", true);
-				const bool changedNewspaperState =
-					startupScript->resetRuntimeObjectToInitialState("NEWSPAPER");
-				jimmyInteraction.mutatedRuntimeState = changedGivenPaperToday || changedNewspaperState;
-
-				StartupInteractionResult actionInteraction;
-				if (startupScript->executeNestedActionTag("ACTV_HOUSE_EXIT", actionInteraction)) {
-					jimmyInteraction.abortRemainingCommandChain =
-						jimmyInteraction.abortRemainingCommandChain ||
-						actionInteraction.abortRemainingCommandChain;
-					jimmyInteraction.mutatedRuntimeState =
-						jimmyInteraction.mutatedRuntimeState || actionInteraction.mutatedRuntimeState;
-					if (!actionInteraction.musicPath.empty())
-						jimmyInteraction.musicPath = actionInteraction.musicPath;
-					if (!actionInteraction.nextRoomName.empty())
-						jimmyInteraction.nextRoomName = actionInteraction.nextRoomName;
-					if (!actionInteraction.deathFlicPath.empty())
-						jimmyInteraction.deathFlicPath = actionInteraction.deathFlicPath;
-					if (!actionInteraction.dialogueNpcName.empty())
-						jimmyInteraction.dialogueNpcName = actionInteraction.dialogueNpcName;
-					if (!actionInteraction.dialogueContinuationTag.empty()) {
-						jimmyInteraction.dialogueContinuationTag =
-							actionInteraction.dialogueContinuationTag;
-					}
-					jimmyInteraction.requestMainMenu =
-						jimmyInteraction.requestMainMenu || actionInteraction.requestMainMenu;
-					for (const StartupAudioCommand &command : actionInteraction.audioCommands)
-						jimmyInteraction.audioCommands.push_back(command);
-				}
-				if (jimmyInteraction.abortRemainingCommandChain || jimmyInteraction.mutatedRuntimeState ||
-						!jimmyInteraction.musicPath.empty() ||
-						!jimmyInteraction.nextRoomName.empty() || !jimmyInteraction.deathFlicPath.empty() ||
-						jimmyInteraction.requestMainMenu || !jimmyInteraction.dialogueNpcName.empty() ||
-						!jimmyInteraction.dialogueContinuationTag.empty() ||
-						!jimmyInteraction.audioCommands.empty()) {
-					startupFlow.queueDialogueInteraction(jimmyInteraction);
-				}
+Common::Error DialogueSystem::handleJimmyDialogue(DialogueRuntime &runtime, const Common::String &usedItemName) {
+	auto playJimmyLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "JIMMY", headVariant);
+	};
+	auto hasInventoryItem = [&](const char *objectName) {
+		Common::Array<StartupObjectRecord> inventoryObjects;
+		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
+		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
+				return true;
+		}
 
-				if (!_jimmyRoomDialogueState.paperHandoffStateSet) {
-					_jimmyRoomDialogueState.paperHandoffStateSet = true;
-					Common::Error lineError = playJimmyLine(0x4a4c, 1);
-					if (lineError.getCode() != Common::kNoError)
-						return lineError;
-				}
+		return false;
+	};
 
-				return _engine.getRandomNumber(1) == 0
-					? playJimmyLine(0x4acc, 0)
-					: playJimmyLine(0x4a4b, 1);
-			}
-			if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-				(void)startupScript->setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
-				return playJimmyLine(0x4af8, 1);
-			}
-			if (((usedItemName.equalsIgnoreCase("LEDGER") ||
-						usedItemName.equalsIgnoreCase("LEDGER2")) &&
-					startupScript->getFlagValue("HAVE_BOTH_LEDGERS")) ||
-					usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-					usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-				if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-						usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-					(void)startupScript->setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
-				} else {
-					(void)startupScript->setRuntimeFlagValue(kShownLedgersToAnyoneFlag, true);
-				}
-				return playJimmyLine(0x4b00, 0);
-			}
-			if (usedItemName.equalsIgnoreCase("NOTE") ||
-					usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
-					usedItemName.equalsIgnoreCase("CHECKBOOK") ||
-					usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
-				(void)startupScript->setRuntimeFlagValue(kShownEvidenceOfBlackmailFlag, true);
-				return playJimmyLine(0x4b21, 0);
-			}
-			if (usedItemName.equalsIgnoreCase("SNEAKERS")) {
-				Common::Error lineError = playJimmyLine(0x4a9e, 1);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("NEWSPAPER")) {
+			StartupInteractionResult jimmyInteraction;
+			const bool changedGivenPaperToday =
+				runtime.startupScript().setRuntimeFlagValue("GIVEN_PAPER_TODAY", true);
+			const bool changedNewspaperState =
+				runtime.startupScript().resetRuntimeObjectToInitialState("NEWSPAPER");
+			jimmyInteraction.mutatedRuntimeState = changedGivenPaperToday || changedNewspaperState;
+
+			StartupInteractionResult actionInteraction;
+			if (runtime.startupScript().executeNestedActionTag("ACTV_HOUSE_EXIT", actionInteraction)) {
+				jimmyInteraction.abortRemainingCommandChain =
+					jimmyInteraction.abortRemainingCommandChain ||
+					actionInteraction.abortRemainingCommandChain;
+				jimmyInteraction.mutatedRuntimeState =
+					jimmyInteraction.mutatedRuntimeState || actionInteraction.mutatedRuntimeState;
+				if (!actionInteraction.musicPath.empty())
+					jimmyInteraction.musicPath = actionInteraction.musicPath;
+				if (!actionInteraction.nextRoomName.empty())
+					jimmyInteraction.nextRoomName = actionInteraction.nextRoomName;
+				if (!actionInteraction.deathFlicPath.empty())
+					jimmyInteraction.deathFlicPath = actionInteraction.deathFlicPath;
+				if (!actionInteraction.dialogueNpcName.empty())
+					jimmyInteraction.dialogueNpcName = actionInteraction.dialogueNpcName;
+				if (!actionInteraction.dialogueContinuationTag.empty())
+					jimmyInteraction.dialogueContinuationTag = actionInteraction.dialogueContinuationTag;
+				jimmyInteraction.requestMainMenu =
+					jimmyInteraction.requestMainMenu || actionInteraction.requestMainMenu;
+				for (const StartupAudioCommand &command : actionInteraction.audioCommands)
+					jimmyInteraction.audioCommands.push_back(command);
 			}
+			runtime.queueDialogueInteractionIfNeeded(jimmyInteraction);
 
-			return playJimmyLine(0x4af2, 0);
-		}
-
-		if (_jimmyRoomDialogueState.firstNoItemLinePending) {
-			_jimmyRoomDialogueState.firstNoItemLinePending = false;
 			if (!_jimmyRoomDialogueState.paperHandoffStateSet) {
+				_jimmyRoomDialogueState.paperHandoffStateSet = true;
 				Common::Error lineError = playJimmyLine(0x4a4c, 1);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 			}
 
-			Common::Error lineError = playJimmyLine(0x4a58, 0);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
+			return runtime.getRandomNumber(1) == 0
+				? playJimmyLine(0x4acc, 0)
+				: playJimmyLine(0x4a4b, 1);
 		}
-		if (hasInventoryItem("SNEAKERS") && !hasInventoryItem("BROOMKEY")) {
-			Common::Error lineError = playJimmyLine(0x4ac3, 0);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
+			return playJimmyLine(0x4af8, 1);
 		}
-		if (startupScript->getFlagValue("PAPER_CHK_4")) {
-			Common::Error lineError = playJimmyLine(0x4ae2, 2);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
+		if (((usedItemName.equalsIgnoreCase("LEDGER") ||
+					usedItemName.equalsIgnoreCase("LEDGER2")) &&
+				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+					usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+				(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
+			} else {
+				(void)runtime.startupScript().setRuntimeFlagValue(kShownLedgersToAnyoneFlag, true);
+			}
+			return playJimmyLine(0x4b00, 0);
 		}
-		if (startupScript->getFlagValue("PAPER_CHK_3")) {
-			Common::Error lineError = playJimmyLine(0x4adb, 2);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
+		if (usedItemName.equalsIgnoreCase("NOTE") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownEvidenceOfBlackmailFlag, true);
+			return playJimmyLine(0x4b21, 0);
 		}
-		if (startupScript->getFlagValue("PAPER_CHK_2")) {
-			Common::Error lineError = playJimmyLine(0x4ad4, 2);
+		if (usedItemName.equalsIgnoreCase("SNEAKERS")) {
+			Common::Error lineError = playJimmyLine(0x4a9e, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 
-		return playJimmyLine(0x4b38, 0);
+		return playJimmyLine(0x4af2, 0);
 	}
 
-	if (npc.npcName.equalsIgnoreCase("WASP_WOMAN")) {
-		Common::String waspWomanTopicBuffer;
-		int waspWomanTopicBufferLineIndex = -1;
-		auto assignWaspWomanTopicBuffer = [&](int responseLineIndex) {
-			assignTopicBuffer(waspWomanTopicBuffer, waspWomanTopicBufferLineIndex, responseLineIndex,
-				"Wasp Woman topic buffer");
-		};
-		auto playWaspWomanLine = [&](int wavId, int headVariant) -> Common::Error {
-			return playDialogueLineWithVariant(wavId, "WASP_WOMAN", headVariant);
-		};
-
-		if (!usedItemName.empty()) {
-			if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-				(void)startupScript->setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
-				return playDialogueEntrySequence(kWaspWomanWhaleyLines, ARRAYSIZE(kWaspWomanWhaleyLines));
-			}
-			if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-					usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-				(void)startupScript->setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
-				return playWaspWomanLine(0x4cbd, 0);
-			}
-			if (usedItemName.equalsIgnoreCase("NOTE") ||
-					usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
-					usedItemName.equalsIgnoreCase("CHECKBOOK") ||
-					usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
-				(void)startupScript->setRuntimeFlagValue(kShownEvidenceOfBlackmailFlag, true);
-				return playWaspWomanLine(0x4cc3, 0);
-			}
-
-			return playWaspWomanLine(0x4ca0, 0);
-		}
-
-		if (_waspWomanRoomDialogueState.introPending) {
-			_waspWomanRoomDialogueState.introPending = false;
-
-			Common::Error lineError = playWaspWomanLine(0x4bee, 1);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			if (_sharedWaspWomanDialogueState) {
-				lineError = playDialogueLine(0x4bf2, "PC");
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-			}
-			lineError = playDialogueEntrySequence(kWaspWomanIntroTailLines, ARRAYSIZE(kWaspWomanIntroTailLines));
+	if (_jimmyRoomDialogueState.firstNoItemLinePending) {
+		_jimmyRoomDialogueState.firstNoItemLinePending = false;
+		if (!_jimmyRoomDialogueState.paperHandoffStateSet) {
+			Common::Error lineError = playJimmyLine(0x4a4c, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 
-		Common::Error lineError = playWaspWomanLine(0x4bee, 0);
+		Common::Error lineError = playJimmyLine(0x4a58, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (hasInventoryItem("SNEAKERS") && !hasInventoryItem("BROOMKEY")) {
+		Common::Error lineError = playJimmyLine(0x4ac3, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("PAPER_CHK_4")) {
+		Common::Error lineError = playJimmyLine(0x4ae2, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("PAPER_CHK_3")) {
+		Common::Error lineError = playJimmyLine(0x4adb, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("PAPER_CHK_2")) {
+		Common::Error lineError = playJimmyLine(0x4ad4, 2);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		assignWaspWomanTopicBuffer(kWaspWomanTopicBufferResponseLine);
+	}
 
-		for (;;) {
-			Common::String selectedTopic;
-			Common::Error menuError = runKeywordMenu(
-				waspWomanTopicBuffer, waspWomanTopicBufferLineIndex, selectedTopic);
-			if (menuError.getCode() != Common::kNoError)
-				return menuError;
-			if (selectedTopic.empty() || selectedTopic.equalsIgnoreCase(genericByeTopic))
-				return Common::kNoError;
-
-			if (matchesResponseLine(selectedTopic, 0x302)) {
-				lineError = playWaspWomanLine(0x4c0a, 0);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x305)) {
-				lineError = playWaspWomanLine(0x4c31, 0);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				if (!_sharedWaspWomanDialogueState)
-					continue;
+	return playJimmyLine(0x4b38, 0);
+}
 
-				int responseIndex = 0;
-				Common::Error responseError = runResponseMenu(0x307, responseIndex);
-				if (responseError.getCode() != Common::kNoError)
-					return responseError;
+Common::Error DialogueSystem::handleWaspWomanDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName) {
+	Common::String waspWomanTopicBuffer;
+	int waspWomanTopicBufferLineIndex = -1;
+	auto assignWaspWomanTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(waspWomanTopicBuffer, waspWomanTopicBufferLineIndex,
+			responseLineIndex, "Wasp Woman topic buffer");
+	};
+	auto playWaspWomanLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "WASP_WOMAN", headVariant);
+	};
 
-				switch (responseIndex) {
-				case 1:
-					lineError = playDialogueEntrySequence(kWaspWomanTopic305Response1Lines,
-						ARRAYSIZE(kWaspWomanTopic305Response1Lines));
-					break;
-				case 2:
-					lineError = playWaspWomanLine(0x4c5e, 2);
-					break;
-				default:
-					lineError = Common::kNoError;
-					break;
-				}
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x308)) {
-				lineError = playWaspWomanLine(0x4c67, 0);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x30a)) {
-				lineError = playWaspWomanLine(0x4c74, 1);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x30c)) {
-				lineError = playWaspWomanLine(0x4c85, 0);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x30e)) {
-				lineError = playWaspWomanLine(0x4c9a, 0);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				return Common::kNoError;
-			}
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
+			return runtime.playDialogueEntrySequence(kWaspWomanWhaleyLines,
+				ARRAYSIZE(kWaspWomanWhaleyLines));
+		}
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
+			return playWaspWomanLine(0x4cbd, 0);
+		}
+		if (usedItemName.equalsIgnoreCase("NOTE") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownEvidenceOfBlackmailFlag, true);
+			return playWaspWomanLine(0x4cc3, 0);
 		}
-	}
-
-	if (npc.npcName.equalsIgnoreCase("MOM")) {
-		const int currentStoryDayIndex = startupScript->getCurrentStoryDayIndex();
-		Common::String momTopicBuffer;
-		int momTopicBufferLineIndex = -1;
-		auto assignMomTopicBuffer = [&](int responseLineIndex) {
-			assignTopicBuffer(momTopicBuffer, momTopicBufferLineIndex, responseLineIndex, "Mom topic buffer");
-		};
-		auto runMomGoodbye = [&]() -> Common::Error {
-			if (!startupScript->getFlagValue("DAY_FLAG")) {
-				return playDialogueLine(0x256e, "MOM");
-			}
-			if (!startupScript->isNamedNpcDeathTypeClear("JIMMY"))
-				return Common::kNoError;
 
-			return playDialogueLine(0x2051, "MOM");
-		};
-		momTopicBuffer.clear();
-		momTopicBufferLineIndex = -1;
-
-		if (!usedItemName.empty()) {
-			if (usedItemName.equalsIgnoreCase("NOTE") ||
-					usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
-					usedItemName.equalsIgnoreCase("CHECKBOOK") ||
-					usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
-				(void)startupScript->setRuntimeFlagValue(kShownEvidenceOfBlackmailFlag, true);
-				return playDialogueLine(0x2317, "MOM");
-			}
-			if ((usedItemName.equalsIgnoreCase("LEDGER") ||
-					usedItemName.equalsIgnoreCase("LEDGER2")) &&
-					startupScript->getFlagValue("HAVE_BOTH_LEDGERS")) {
-				(void)startupScript->setRuntimeFlagValue(kShownLedgersToAnyoneFlag, true);
-				return playDialogueLine(0x2320, "MOM");
-			}
-			if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-					usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-				(void)startupScript->setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
-				return playDialogueLine(0x233b, "PC");
-			}
-			if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-				(void)startupScript->setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
-				return playDialogueLine(0x239c, "MOM");
-			}
-			if (usedItemName.equalsIgnoreCase("TV_DEED") ||
-					usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
-				(void)startupScript->setRuntimeFlagValue(kShownEvidenceSheriffOwnsFlag, true);
-				return playDialogueLine(0x2382, "MOM");
-			}
+		return playWaspWomanLine(0x4ca0, 0);
+	}
 
-			return playDialogueLine(0x26cc, "MOM");
-		}
+	if (_waspWomanRoomDialogueState.introPending) {
+		_waspWomanRoomDialogueState.introPending = false;
 
-		bool skipMomDefaultKeywordSeed = false;
-		if (_momRoomDialogueState.introPending) {
-			_momRoomDialogueState.introPending = false;
-			_momRoomDialogueState.sameDayIntroLineEnabled = true;
-			_momRoomDialogueState.postIntroDefaultLineEnabled = true;
-			_momRoomDialogueState.introDayIndex = currentStoryDayIndex;
-			Common::Error lineError = playDialogueEntrySequence(kMomIntroLines, ARRAYSIZE(kMomIntroLines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignMomTopicBuffer(kMomInitialTopicBufferResponseLine);
-			skipMomDefaultKeywordSeed = true;
-		}
-		if (!skipMomDefaultKeywordSeed && _momRoomDialogueState.sameDayIntroLineEnabled &&
-				currentStoryDayIndex == _momRoomDialogueState.introDayIndex) {
-			Common::Error lineError = playDialogueLine(0x2047, "MOM");
+		Common::Error lineError = playWaspWomanLine(0x4bee, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (_sharedWaspWomanDialogueState) {
+			lineError = runtime.playDialogueLine(0x4bf2, "PC");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			assignMomTopicBuffer(kMomSameDayTopicBufferResponseLine);
-			skipMomDefaultKeywordSeed = true;
 		}
-		if (!skipMomDefaultKeywordSeed && _momRoomDialogueState.postIntroDefaultLineEnabled) {
-			Common::Error lineError = playDialogueLine(0x2311, "MOM");
+		lineError = runtime.playDialogueEntrySequence(kWaspWomanIntroTailLines,
+			ARRAYSIZE(kWaspWomanIntroTailLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	Common::Error lineError = playWaspWomanLine(0x4bee, 0);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+	assignWaspWomanTopicBuffer(kWaspWomanTopicBufferResponseLine);
+
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runtime.runKeywordMenu(
+			waspWomanTopicBuffer, waspWomanTopicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.empty() || selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()))
+			return Common::kNoError;
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x302)) {
+			lineError = playWaspWomanLine(0x4c0a, 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			assignMomTopicBuffer(kMomPostIntroTopicBufferResponseLine);
+			continue;
 		}
-		if (startupScript->getFlagValue("STEPH_MIDGAME_PLAYED") &&
-				!_momRoomDialogueState.stephMidgameShown) {
-			_momRoomDialogueState.stephMidgameShown = true;
-			Common::Error lineError = playDialogueLine(0x205a, "MOM");
+		if (runtime.matchesResponseLine(selectedTopic, 0x305)) {
+			lineError = playWaspWomanLine(0x4c31, 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-		}
-		if (startupScript->getFlagValue("DINER_BURNED") &&
-				(startupScript->getFlagValue("KARIN_KIDNAPED") ||
-					startupScript->getFlagValue("KARIN_FOUND_DEAD")) &&
-				!_momRoomDialogueState.dinerBurnedKarinMissingOrDeadShown) {
-			_momRoomDialogueState.dinerBurnedKarinMissingOrDeadShown = true;
-			Common::Error lineError = playDialogueLine(0x2456, "MOM");
+			if (!_sharedWaspWomanDialogueState)
+				continue;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x307, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			switch (responseIndex) {
+			case 1:
+				lineError = runtime.playDialogueEntrySequence(kWaspWomanTopic305Response1Lines,
+					ARRAYSIZE(kWaspWomanTopic305Response1Lines));
+				break;
+			case 2:
+				lineError = playWaspWomanLine(0x4c5e, 2);
+				break;
+			default:
+				lineError = Common::kNoError;
+				break;
+			}
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			continue;
 		}
-		if (startupScript->getFlagValue("BURNED_TV_STATION") &&
-				!_momRoomDialogueState.burnedTvStationShown) {
-			_momRoomDialogueState.burnedTvStationShown = true;
-			Common::Error lineError = playDialogueLine(0x2220, "MOM");
+		if (runtime.matchesResponseLine(selectedTopic, 0x308)) {
+			lineError = playWaspWomanLine(0x4c67, 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			continue;
 		}
-		if (startupScript->getFlagValue("SCRATCHED_TUCKER") &&
-				!_momRoomDialogueState.scratchedTuckerShown) {
-			_momRoomDialogueState.scratchedTuckerShown = true;
-			Common::Error lineError = playDialogueLine(0x23f3, "MOM");
+		if (runtime.matchesResponseLine(selectedTopic, 0x30a)) {
+			lineError = playWaspWomanLine(0x4c74, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			continue;
 		}
-		if (startupScript->getFlagValue("BARBER_POLE_STOLEN") &&
-				!_momRoomDialogueState.barberPoleStolenShown) {
-			_momRoomDialogueState.barberPoleStolenShown = true;
-			Common::Error lineError = playDialogueLine(0x23fb, "MOM");
+		if (runtime.matchesResponseLine(selectedTopic, 0x30c)) {
+			lineError = playWaspWomanLine(0x4c85, 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			continue;
 		}
-		if (startupScript->getFlagValue("BOLT_OF_CLOTH_TAKEN") &&
-				!_momRoomDialogueState.boltOfClothTakenShown) {
-			_momRoomDialogueState.boltOfClothTakenShown = true;
-			if (startupScript->isNamedNpcDeathTypeClear("SPARKY") &&
-					startupScript->isNamedNpcDeathTypeClear("FIREMAN2")) {
-				Common::Error lineError = playDialogueLine(0x2416, "MOM");
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-			}
-			Common::Error lineError = playDialogueLine(0x2420, "MOM");
+		if (runtime.matchesResponseLine(selectedTopic, 0x30e)) {
+			lineError = playWaspWomanLine(0x4c9a, 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			return Common::kNoError;
 		}
-		if (startupScript->getFlagValue("DINER_BURNED") &&
-				startupScript->getFlagValue("KARIN_FOUND_ALIVE") &&
-				!_momRoomDialogueState.dinerBurnedKarinAliveShown) {
-			_momRoomDialogueState.dinerBurnedKarinAliveShown = true;
-			Common::Error lineError = playDialogueLine(0x2434, "MOM");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
+	}
+}
+
+Common::Error DialogueSystem::handleMomDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName) {
+	const int currentStoryDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
+	Common::String momTopicBuffer;
+	int momTopicBufferLineIndex = -1;
+	auto assignMomTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(momTopicBuffer, momTopicBufferLineIndex,
+			responseLineIndex, "Mom topic buffer");
+	};
+	auto runMomGoodbye = [&]() -> Common::Error {
+		if (!runtime.startupScript().getFlagValue("DAY_FLAG"))
+			return runtime.playDialogueLine(0x256e, "MOM");
+		if (!runtime.startupScript().isNamedNpcDeathTypeClear("JIMMY"))
+			return Common::kNoError;
+
+		return runtime.playDialogueLine(0x2051, "MOM");
+	};
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("NOTE") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownEvidenceOfBlackmailFlag, true);
+			return runtime.playDialogueLine(0x2317, "MOM");
+		}
+		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+				usedItemName.equalsIgnoreCase("LEDGER2")) &&
+				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownLedgersToAnyoneFlag, true);
+			return runtime.playDialogueLine(0x2320, "MOM");
 		}
-		if (startupScript->getFlagValue("PC_ESCAPED_JAIL") &&
-				!_momRoomDialogueState.escapedJailShown) {
-			_momRoomDialogueState.escapedJailShown = true;
-			Common::Error lineError = playDialogueLine(0x2495, "MOM");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
+			return runtime.playDialogueLine(0x233b, "PC");
 		}
-		if (startupScript->getFlagValue("KARIN_KIDNAPED") &&
-				!startupScript->getFlagValue("KARIN_FOUND_DEAD") &&
-				!startupScript->getFlagValue("KARIN_FOUND_ALIVE") &&
-				!_momRoomDialogueState.karinKidnapedUnresolvedShown) {
-			_momRoomDialogueState.karinKidnapedUnresolvedShown = true;
-			Common::Error lineError = playDialogueLine(0x24d7, "MOM");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
+			return runtime.playDialogueLine(0x239c, "MOM");
 		}
-		if (startupScript->getFlagValue("KARIN_FOUND_ALIVE") &&
-				!_momRoomDialogueState.karinFoundAliveShown) {
-			_momRoomDialogueState.karinFoundAliveShown = true;
-			Common::Error lineError = playDialogueLine(0x2505, "MOM");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
+		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
+				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownEvidenceSheriffOwnsFlag, true);
+			return runtime.playDialogueLine(0x2382, "MOM");
 		}
-		if (startupScript->getFlagValue("KARIN_FOUND_DEAD") &&
-				!_momRoomDialogueState.karinFoundDeadShown) {
-			_momRoomDialogueState.karinFoundDeadShown = true;
-			Common::Error lineError = playDialogueLine(0x2576, "MOM");
+
+		return runtime.playDialogueLine(0x26cc, "MOM");
+	}
+
+	bool skipMomDefaultKeywordSeed = false;
+	if (_momRoomDialogueState.introPending) {
+		_momRoomDialogueState.introPending = false;
+		_momRoomDialogueState.sameDayIntroLineEnabled = true;
+		_momRoomDialogueState.postIntroDefaultLineEnabled = true;
+		_momRoomDialogueState.introDayIndex = currentStoryDayIndex;
+		Common::Error lineError = runtime.playDialogueEntrySequence(kMomIntroLines, ARRAYSIZE(kMomIntroLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignMomTopicBuffer(kMomInitialTopicBufferResponseLine);
+		skipMomDefaultKeywordSeed = true;
+	}
+	if (!skipMomDefaultKeywordSeed && _momRoomDialogueState.sameDayIntroLineEnabled &&
+			currentStoryDayIndex == _momRoomDialogueState.introDayIndex) {
+		Common::Error lineError = runtime.playDialogueLine(0x2047, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignMomTopicBuffer(kMomSameDayTopicBufferResponseLine);
+		skipMomDefaultKeywordSeed = true;
+	}
+	if (!skipMomDefaultKeywordSeed && _momRoomDialogueState.postIntroDefaultLineEnabled) {
+		Common::Error lineError = runtime.playDialogueLine(0x2311, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignMomTopicBuffer(kMomPostIntroTopicBufferResponseLine);
+	}
+	if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
+			!_momRoomDialogueState.stephMidgameShown) {
+		_momRoomDialogueState.stephMidgameShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x205a, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
+			(runtime.startupScript().getFlagValue("KARIN_KIDNAPED") ||
+				runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD")) &&
+			!_momRoomDialogueState.dinerBurnedKarinMissingOrDeadShown) {
+		_momRoomDialogueState.dinerBurnedKarinMissingOrDeadShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x2456, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
+			!_momRoomDialogueState.burnedTvStationShown) {
+		_momRoomDialogueState.burnedTvStationShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x2220, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER") &&
+			!_momRoomDialogueState.scratchedTuckerShown) {
+		_momRoomDialogueState.scratchedTuckerShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x23f3, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") &&
+			!_momRoomDialogueState.barberPoleStolenShown) {
+		_momRoomDialogueState.barberPoleStolenShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x23fb, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("BOLT_OF_CLOTH_TAKEN") &&
+			!_momRoomDialogueState.boltOfClothTakenShown) {
+		_momRoomDialogueState.boltOfClothTakenShown = true;
+		if (runtime.startupScript().isNamedNpcDeathTypeClear("SPARKY") &&
+				runtime.startupScript().isNamedNpcDeathTypeClear("FIREMAN2")) {
+			Common::Error lineError = runtime.playDialogueLine(0x2416, "MOM");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
-		if (!startupScript->isNamedNpcDeathTypeClear("BUTCHER") &&
-				!_momRoomDialogueState.butcherAbsentShown) {
-			_momRoomDialogueState.butcherAbsentShown = true;
-			Common::Error lineError = playDialogueLine(0x2633, "MOM");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
+		Common::Error lineError = runtime.playDialogueLine(0x2420, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
+			runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
+			!_momRoomDialogueState.dinerBurnedKarinAliveShown) {
+		_momRoomDialogueState.dinerBurnedKarinAliveShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x2434, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("PC_ESCAPED_JAIL") &&
+			!_momRoomDialogueState.escapedJailShown) {
+		_momRoomDialogueState.escapedJailShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x2495, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
+			!runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
+			!runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
+			!_momRoomDialogueState.karinKidnapedUnresolvedShown) {
+		_momRoomDialogueState.karinKidnapedUnresolvedShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x24d7, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
+			!_momRoomDialogueState.karinFoundAliveShown) {
+		_momRoomDialogueState.karinFoundAliveShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x2505, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
+			!_momRoomDialogueState.karinFoundDeadShown) {
+		_momRoomDialogueState.karinFoundDeadShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x2576, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (!runtime.startupScript().isNamedNpcDeathTypeClear("BUTCHER") &&
+			!_momRoomDialogueState.butcherAbsentShown) {
+		_momRoomDialogueState.butcherAbsentShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x2633, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (!runtime.startupScript().isNamedNpcDeathTypeClear("MOYNAHAN") &&
+			!_momRoomDialogueState.moynahanAbsentShown) {
+		_momRoomDialogueState.moynahanAbsentShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x2647, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (!runtime.startupScript().isNamedNpcDeathTypeClear("JIMMY") &&
+			!_momRoomDialogueState.jimmyAbsentShown) {
+		_momRoomDialogueState.jimmyAbsentShown = true;
+		if (runtime.startupScript().getFlagValue("JIMMY_ATTACKED")) {
+			Common::Error lineError = runtime.playDialogueLine(0x2659, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		Common::Error lineError = runtime.playDialogueLine(0x266c, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (!runtime.startupScript().isNamedNpcDeathTypeClear("WASP_WOMAN") &&
+			!_momRoomDialogueState.waspWomanAbsentShown) {
+		_momRoomDialogueState.waspWomanAbsentShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x2689, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD") &&
+			!runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
+			!_momRoomDialogueState.stephanieDeadPreMidgameShown) {
+		_momRoomDialogueState.stephanieDeadPreMidgameShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x217c, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("DAY_5") && !_momRoomDialogueState.day5Shown) {
+		_momRoomDialogueState.day5Shown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x218d, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignMomTopicBuffer(kMomDay5TopicBufferResponseLine);
+	}
+	if (runtime.startupScript().getFlagValue("DAY_6") && !_momRoomDialogueState.day6Shown) {
+		_momRoomDialogueState.day6Shown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x22a8, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignMomTopicBuffer(kMomDay6TopicBufferResponseLine);
+	}
+
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runtime.runKeywordMenu(
+			momTopicBuffer, momTopicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.empty())
+			return Common::kNoError;
+		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()))
+			return runMomGoodbye();
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x116)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomCookiesLines, ARRAYSIZE(kMomCookiesLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(0x118);
+			continue;
 		}
-		if (!startupScript->isNamedNpcDeathTypeClear("MOYNAHAN") &&
-				!_momRoomDialogueState.moynahanAbsentShown) {
-			_momRoomDialogueState.moynahanAbsentShown = true;
-			Common::Error lineError = playDialogueLine(0x2647, "MOM");
+		if (runtime.matchesResponseLine(selectedTopic, 0x119)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic119Lines, ARRAYSIZE(kMomTopic119Lines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			assignMomTopicBuffer(0x11a);
+			continue;
 		}
-		if (!startupScript->isNamedNpcDeathTypeClear("JIMMY") &&
-				!_momRoomDialogueState.jimmyAbsentShown) {
-			_momRoomDialogueState.jimmyAbsentShown = true;
-			if (startupScript->getFlagValue("JIMMY_ATTACKED")) {
-				Common::Error lineError = playDialogueLine(0x2659, "MOM");
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
+		if (runtime.matchesResponseLine(selectedTopic, 0x11d)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic11dOpeningLines, ARRAYSIZE(kMomTopic11dOpeningLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC008AFstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueEntrySequence(
+				kMomTopic11dPostC008ALines, ARRAYSIZE(kMomTopic11dPostC008ALines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			StartupInteractionResult babyGurgleInteraction;
+			if (runtime.startupScript().executeActionTag(kBabyGurgleActionTag, babyGurgleInteraction)) {
+				if (!babyGurgleInteraction.musicPath.empty())
+					(void)runtime.engine().playStartupMusic(babyGurgleInteraction.musicPath);
+				if (!babyGurgleInteraction.audioCommands.empty())
+					runtime.startupFlow().executeStartupAudioCommands(babyGurgleInteraction.audioCommands);
+
+				babyGurgleInteraction.musicPath.clear();
+				babyGurgleInteraction.audioCommands.clear();
+				runtime.queueDialogueInteractionIfNeeded(babyGurgleInteraction);
 			}
-			Common::Error lineError = playDialogueLine(0x266c, "MOM");
+
+			lineError = runtime.playDialogueEntrySequence(
+				kMomTopic11dClosingLines, ARRAYSIZE(kMomTopic11dClosingLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC008BFstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(0x120);
+			continue;
 		}
-		if (!startupScript->isNamedNpcDeathTypeClear("WASP_WOMAN") &&
-				!_momRoomDialogueState.waspWomanAbsentShown) {
-			_momRoomDialogueState.waspWomanAbsentShown = true;
-			Common::Error lineError = playDialogueLine(0x2689, "MOM");
+		if (runtime.matchesResponseLine(selectedTopic, 0x121)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic121Lines, ARRAYSIZE(kMomTopic121Lines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			assignMomTopicBuffer(0x122);
+			continue;
 		}
-		if (startupScript->getFlagValue("STEPHANIE_IS_DEAD") &&
-				!startupScript->getFlagValue("STEPH_MIDGAME_PLAYED") &&
-				!_momRoomDialogueState.stephanieDeadPreMidgameShown) {
-			_momRoomDialogueState.stephanieDeadPreMidgameShown = true;
-			Common::Error lineError = playDialogueLine(0x217c, "MOM");
+		if (runtime.matchesResponseLine(selectedTopic, 0x123)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomSparkyLines, ARRAYSIZE(kMomSparkyLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			assignMomTopicBuffer(0x124);
+			continue;
 		}
-		if (startupScript->getFlagValue("DAY_5") && !_momRoomDialogueState.day5Shown) {
-			_momRoomDialogueState.day5Shown = true;
-			Common::Error lineError = playDialogueLine(0x218d, "MOM");
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMomPtaTopicResponseLines,
+				ARRAYSIZE(kMomPtaTopicResponseLines))) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomPtaLines, ARRAYSIZE(kMomPtaLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			assignMomTopicBuffer(kMomDay5TopicBufferResponseLine);
+			assignMomTopicBuffer(kMomPtaFollowupTopicBufferResponseLine);
+			continue;
 		}
-		if (startupScript->getFlagValue("DAY_6") && !_momRoomDialogueState.day6Shown) {
-			_momRoomDialogueState.day6Shown = true;
-			Common::Error lineError = playDialogueLine(0x22a8, "MOM");
+		if (runtime.matchesResponseLine(selectedTopic, 0x12b)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic12bLines, ARRAYSIZE(kMomTopic12bLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			assignMomTopicBuffer(kMomDay6TopicBufferResponseLine);
+			assignMomTopicBuffer(0x12c);
+			continue;
 		}
-
-		for (;;) {
-			Common::String selectedTopic;
-			Common::Error menuError = runKeywordMenu(momTopicBuffer, momTopicBufferLineIndex, selectedTopic);
-			if (menuError.getCode() != Common::kNoError)
-				return menuError;
-			if (selectedTopic.empty())
-				return Common::kNoError;
-			if (selectedTopic.equalsIgnoreCase(genericByeTopic))
-				return runMomGoodbye();
-
-			if (matchesResponseLine(selectedTopic, 0x116)) {
-				Common::Error lineError = playDialogueEntrySequence(kMomCookiesLines, ARRAYSIZE(kMomCookiesLines));
+		if (runtime.matchesResponseLine(selectedTopic, 0x12d)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic12dLines, ARRAYSIZE(kMomTopic12dLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(0x12e);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x12f)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic12fLines, ARRAYSIZE(kMomTopic12fLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(0x130);
+			continue;
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMomCookingTopicResponseLines,
+				ARRAYSIZE(kMomCookingTopicResponseLines))) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomCookingLines, ARRAYSIZE(kMomCookingLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(kMomCookingFollowupTopicBufferResponseLine);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x135)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic135Lines, ARRAYSIZE(kMomTopic135Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(0x136);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x137)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic137Lines, ARRAYSIZE(kMomTopic137Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x13a)) {
+			Common::Error lineError = runtime.playDialogueLine(0x1fa3, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(0x13b);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x13c)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomSlaughterhouseLines, ARRAYSIZE(kMomSlaughterhouseLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(kMomSlaughterhouseFollowupTopicBufferResponseLine);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x13f)) {
+			_momRoomDialogueState.fatherTopicState = true;
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomFatherLines, ARRAYSIZE(kMomFatherLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			if (runtime.startupScript().getFlagValue("HEARD_DAD_MOAN")) {
+				lineError = runtime.playDialogueEntrySequence(
+					kMomFatherHeardDadMoanLines, ARRAYSIZE(kMomFatherHeardDadMoanLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
-				assignMomTopicBuffer(0x118);
-				continue;
 			}
-			if (matchesResponseLine(selectedTopic, 0x119)) {
-				Common::Error lineError = playDialogueEntrySequence(kMomTopic119Lines, ARRAYSIZE(kMomTopic119Lines));
+			assignMomTopicBuffer(kMomFatherFollowupTopicBufferResponseLine);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x141)) {
+			Common::Error lineError = Common::kNoError;
+			if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED")) {
+				lineError = runtime.playDialogueEntrySequence(
+					kMomStephMidgameLodgeLines, ARRAYSIZE(kMomStephMidgameLodgeLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
-				assignMomTopicBuffer(0x11a);
 				continue;
 			}
-			if (matchesResponseLine(selectedTopic, 0x11d)) {
-				Common::Error lineError = playDialogueEntrySequence(
-					kMomTopic11dOpeningLines, ARRAYSIZE(kMomTopic11dOpeningLines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				lineError = playDialogueFst(kDialogueC008AFstPath);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				lineError = playDialogueEntrySequence(
-					kMomTopic11dPostC008ALines, ARRAYSIZE(kMomTopic11dPostC008ALines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-
-				StartupInteractionResult babyGurgleInteraction;
-				if (startupScript->executeActionTag(kBabyGurgleActionTag, babyGurgleInteraction)) {
-					if (!babyGurgleInteraction.musicPath.empty())
-						(void)_engine.playStartupMusic(babyGurgleInteraction.musicPath);
-					if (!babyGurgleInteraction.audioCommands.empty())
-						startupFlow.executeStartupAudioCommands(babyGurgleInteraction.audioCommands);
+			_sharedDiscussedLodgeTopic = true;
+			lineError = runtime.playDialogueEntrySequence(kMomLodgeLines, ARRAYSIZE(kMomLodgeLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 
-					babyGurgleInteraction.musicPath.clear();
-					babyGurgleInteraction.audioCommands.clear();
-					queueDialogueInteractionIfNeeded(babyGurgleInteraction);
-				}
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x142, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
 
-				lineError = playDialogueEntrySequence(
-					kMomTopic11dClosingLines, ARRAYSIZE(kMomTopic11dClosingLines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				lineError = playDialogueFst(kDialogueC008BFstPath);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				assignMomTopicBuffer(0x120);
-				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x121)) {
-				Common::Error lineError = playDialogueEntrySequence(kMomTopic121Lines, ARRAYSIZE(kMomTopic121Lines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				assignMomTopicBuffer(0x122);
-				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x123)) {
-				Common::Error lineError = playDialogueEntrySequence(kMomSparkyLines, ARRAYSIZE(kMomSparkyLines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				assignMomTopicBuffer(0x124);
-				continue;
-			}
-			if (matchesAnyResponseLine(selectedTopic, kMomPtaTopicResponseLines,
-					ARRAYSIZE(kMomPtaTopicResponseLines))) {
-				Common::Error lineError = playDialogueEntrySequence(kMomPtaLines, ARRAYSIZE(kMomPtaLines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				assignMomTopicBuffer(kMomPtaFollowupTopicBufferResponseLine);
-				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x12b)) {
-				Common::Error lineError = playDialogueEntrySequence(kMomTopic12bLines, ARRAYSIZE(kMomTopic12bLines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				assignMomTopicBuffer(0x12c);
-				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x12d)) {
-				Common::Error lineError = playDialogueEntrySequence(kMomTopic12dLines, ARRAYSIZE(kMomTopic12dLines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				assignMomTopicBuffer(0x12e);
-				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x12f)) {
-				Common::Error lineError = playDialogueEntrySequence(kMomTopic12fLines, ARRAYSIZE(kMomTopic12fLines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				assignMomTopicBuffer(0x130);
-				continue;
-			}
-			if (matchesAnyResponseLine(selectedTopic, kMomCookingTopicResponseLines,
-					ARRAYSIZE(kMomCookingTopicResponseLines))) {
-				Common::Error lineError = playDialogueEntrySequence(kMomCookingLines, ARRAYSIZE(kMomCookingLines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				assignMomTopicBuffer(kMomCookingFollowupTopicBufferResponseLine);
-				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x135)) {
-				Common::Error lineError = playDialogueEntrySequence(kMomTopic135Lines, ARRAYSIZE(kMomTopic135Lines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				assignMomTopicBuffer(0x136);
-				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x137)) {
-				Common::Error lineError = playDialogueEntrySequence(kMomTopic137Lines, ARRAYSIZE(kMomTopic137Lines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x13a)) {
-				Common::Error lineError = playDialogueLine(0x1fa3, "MOM");
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				assignMomTopicBuffer(0x13b);
-				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x13c)) {
-				Common::Error lineError = playDialogueEntrySequence(kMomSlaughterhouseLines, ARRAYSIZE(kMomSlaughterhouseLines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				assignMomTopicBuffer(kMomSlaughterhouseFollowupTopicBufferResponseLine);
-				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x13f)) {
-				_momRoomDialogueState.fatherTopicState = true;
-				Common::Error lineError = playDialogueEntrySequence(kMomFatherLines, ARRAYSIZE(kMomFatherLines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				if (startupScript->getFlagValue("HEARD_DAD_MOAN")) {
-					lineError = playDialogueEntrySequence(kMomFatherHeardDadMoanLines,
-						ARRAYSIZE(kMomFatherHeardDadMoanLines));
-					if (lineError.getCode() != Common::kNoError)
-						return lineError;
-				}
-				assignMomTopicBuffer(kMomFatherFollowupTopicBufferResponseLine);
-				continue;
+			switch (responseIndex) {
+			case 1:
+				lineError = runtime.playDialogueEntrySequence(
+					kMomLodgeResponse1Lines, ARRAYSIZE(kMomLodgeResponse1Lines));
+				break;
+			case 2:
+				lineError = runtime.playDialogueEntrySequence(
+					kMomLodgeResponse2Lines, ARRAYSIZE(kMomLodgeResponse2Lines));
+				break;
+			default:
+				break;
 			}
-			if (matchesResponseLine(selectedTopic, 0x141)) {
-				Common::Error lineError = Common::kNoError;
-				if (startupScript->getFlagValue("STEPH_MIDGAME_PLAYED")) {
-					lineError = playDialogueEntrySequence(kMomStephMidgameLodgeLines,
-						ARRAYSIZE(kMomStephMidgameLodgeLines));
-					if (lineError.getCode() != Common::kNoError)
-						return lineError;
-					continue;
-				}
-				_sharedDiscussedLodgeTopic = true;
-				lineError = playDialogueEntrySequence(kMomLodgeLines, ARRAYSIZE(kMomLodgeLines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-
-				int responseIndex = 0;
-				Common::Error responseError = runResponseMenu(0x142, responseIndex);
-				if (responseError.getCode() != Common::kNoError)
-					return responseError;
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(kMomLodgeFollowupTopicBufferResponseLine);
+			continue;
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMomBakeSaleTopicResponseLines,
+				ARRAYSIZE(kMomBakeSaleTopicResponseLines))) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomBakeSaleLines, ARRAYSIZE(kMomBakeSaleLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(kMomBakeSaleFollowupTopicBufferResponseLine);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x14a)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic14aLines, ARRAYSIZE(kMomTopic14aLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMomPottsdamTopicResponseLines,
+				ARRAYSIZE(kMomPottsdamTopicResponseLines))) {
+			Common::Error lineError = Common::kNoError;
+			if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED"))
+				lineError = runtime.playDialogueLine(0x2086, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(kMomPottsdamFollowupTopicBufferResponseLine);
+			continue;
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMomMeatPlantTopicResponseLines,
+				ARRAYSIZE(kMomMeatPlantTopicResponseLines))) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomMeatPlantIntroLines, ARRAYSIZE(kMomMeatPlantIntroLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 
-				switch (responseIndex) {
-				case 1:
-					lineError = playDialogueEntrySequence(kMomLodgeResponse1Lines,
-						ARRAYSIZE(kMomLodgeResponse1Lines));
-					break;
-				case 2:
-					lineError = playDialogueEntrySequence(kMomLodgeResponse2Lines,
-						ARRAYSIZE(kMomLodgeResponse2Lines));
-					break;
-				default:
-					break;
-				}
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				assignMomTopicBuffer(kMomLodgeFollowupTopicBufferResponseLine);
-				continue;
-			}
-			if (matchesAnyResponseLine(selectedTopic, kMomBakeSaleTopicResponseLines,
-					ARRAYSIZE(kMomBakeSaleTopicResponseLines))) {
-				Common::Error lineError = playDialogueEntrySequence(kMomBakeSaleLines, ARRAYSIZE(kMomBakeSaleLines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				assignMomTopicBuffer(kMomBakeSaleFollowupTopicBufferResponseLine);
-				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x14a)) {
-				Common::Error lineError = playDialogueEntrySequence(kMomTopic14aLines, ARRAYSIZE(kMomTopic14aLines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				continue;
-			}
-			if (matchesAnyResponseLine(selectedTopic, kMomPottsdamTopicResponseLines,
-					ARRAYSIZE(kMomPottsdamTopicResponseLines))) {
-				Common::Error lineError = Common::kNoError;
-				if (startupScript->getFlagValue("STEPH_MIDGAME_PLAYED"))
-					lineError = playDialogueLine(0x2086, "MOM");
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				assignMomTopicBuffer(kMomPottsdamFollowupTopicBufferResponseLine);
-				continue;
-			}
-			if (matchesAnyResponseLine(selectedTopic, kMomMeatPlantTopicResponseLines,
-					ARRAYSIZE(kMomMeatPlantTopicResponseLines))) {
-				Common::Error lineError = playDialogueEntrySequence(kMomMeatPlantIntroLines,
-					ARRAYSIZE(kMomMeatPlantIntroLines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x153, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
 
-				int responseIndex = 0;
-				Common::Error responseError = runResponseMenu(0x153, responseIndex);
-				if (responseError.getCode() != Common::kNoError)
-					return responseError;
-
-				switch (responseIndex) {
-				case 1:
-					lineError = playDialogueEntrySequence(kMomMeatPlantResponse1Lines,
-						ARRAYSIZE(kMomMeatPlantResponse1Lines));
-					if (lineError.getCode() != Common::kNoError)
-						return lineError;
-					continue;
-				case 2:
-					lineError = playDialogueEntrySequence(kMomMeatPlantResponse2Lines,
-						ARRAYSIZE(kMomMeatPlantResponse2Lines));
-					if (lineError.getCode() != Common::kNoError)
-						return lineError;
-					assignMomTopicBuffer(kMomMeatPlantFollowupTopicBufferResponseLine);
-					continue;
-				default:
-					continue;
-				}
-			}
-			if (matchesResponseLine(selectedTopic, 0x155)) {
-				Common::Error lineError = playDialogueEntrySequence(kMomTopic155Lines, ARRAYSIZE(kMomTopic155Lines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x156)) {
-				Common::Error lineError = playDialogueEntrySequence(kMomTopic156Lines, ARRAYSIZE(kMomTopic156Lines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x15b) && currentStoryDayIndex == 5) {
-				Common::Error lineError = Common::kNoError;
-				if (_momRoomDialogueState.goodCauseDay5State)
-					lineError = playDialogueEntrySequence(kMomGoodCauseDay5Lines, ARRAYSIZE(kMomGoodCauseDay5Lines));
-				else
-					lineError = playDialogueLine(0x21dd, "PC");
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				assignMomTopicBuffer(kMomGoodCauseDay5FollowupTopicBufferResponseLine);
-				continue;
-			}
-			if (matchesAnyResponseLine(selectedTopic, kMomMoynahanTopicResponseLines,
-					ARRAYSIZE(kMomMoynahanTopicResponseLines))) {
-				Common::Error lineError = playDialogueLine(0x220a, "PC");
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				continue;
-			}
-			if (matchesAnyResponseLine(selectedTopic, kMomNewspaperFireTopicResponseLines,
-					ARRAYSIZE(kMomNewspaperFireTopicResponseLines))) {
-				Common::Error lineError = playDialogueLine(0x229e, "MOM");
+			switch (responseIndex) {
+			case 1:
+				lineError = runtime.playDialogueEntrySequence(
+					kMomMeatPlantResponse1Lines, ARRAYSIZE(kMomMeatPlantResponse1Lines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x16d)) {
-				Common::Error lineError = playDialogueLine(0x2696, "MOM");
+			case 2:
+				lineError = runtime.playDialogueEntrySequence(
+					kMomMeatPlantResponse2Lines, ARRAYSIZE(kMomMeatPlantResponse2Lines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
+				assignMomTopicBuffer(kMomMeatPlantFollowupTopicBufferResponseLine);
 				continue;
-			}
-			if (matchesResponseLine(selectedTopic, 0x16e)) {
-				Common::Error lineError = playDialogueLine(0x26a3, "MOM");
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
+			default:
 				continue;
 			}
-			if (matchesResponseLine(selectedTopic, 0x170)) {
-				Common::Error lineError = playDialogueLine(0x26bc, "MOM");
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x155)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic155Lines, ARRAYSIZE(kMomTopic155Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x156)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic156Lines, ARRAYSIZE(kMomTopic156Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x15b) && currentStoryDayIndex == 5) {
+			Common::Error lineError = Common::kNoError;
+			if (_momRoomDialogueState.goodCauseDay5State) {
+				lineError = runtime.playDialogueEntrySequence(
+					kMomGoodCauseDay5Lines, ARRAYSIZE(kMomGoodCauseDay5Lines));
+			} else {
+				lineError = runtime.playDialogueLine(0x21dd, "PC");
 			}
-
-			Common::Error lineError = playDialogueLine(0x26c6, "MOM");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			return runMomGoodbye();
+			assignMomTopicBuffer(kMomGoodCauseDay5FollowupTopicBufferResponseLine);
+			continue;
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMomMoynahanTopicResponseLines,
+				ARRAYSIZE(kMomMoynahanTopicResponseLines))) {
+			Common::Error lineError = runtime.playDialogueLine(0x220a, "PC");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMomNewspaperFireTopicResponseLines,
+				ARRAYSIZE(kMomNewspaperFireTopicResponseLines))) {
+			Common::Error lineError = runtime.playDialogueLine(0x229e, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x16d)) {
+			Common::Error lineError = runtime.playDialogueLine(0x2696, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x16e)) {
+			Common::Error lineError = runtime.playDialogueLine(0x26a3, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x170)) {
+			Common::Error lineError = runtime.playDialogueLine(0x26bc, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
 		}
-	}
 
-	if (!npc.npcName.equalsIgnoreCase("HANK")) {
-		debug(1, "Harvester: unsupported room NPC dialogue handler '%s'", npc.npcName.c_str());
-		return Common::kNoError;
+		Common::Error lineError = runtime.playDialogueLine(0x26c6, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		return runMomGoodbye();
 	}
+}
 
-	const int currentStoryDayIndex = startupScript->getCurrentStoryDayIndex();
+Common::Error DialogueSystem::handleHankDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName) {
+	const int currentStoryDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
 	Common::String &hankTopicBuffer = _hankRoomDialogueState.currentTopicBuffer;
 	int &hankTopicBufferLineIndex = _hankRoomDialogueState.currentTopicBufferLineIndex;
 	auto assignHankTopicBuffer = [&](int responseLineIndex) {
-		assignTopicBuffer(hankTopicBuffer, hankTopicBufferLineIndex, responseLineIndex, "Hank topic buffer");
+		runtime.assignTopicBuffer(hankTopicBuffer, hankTopicBufferLineIndex,
+			responseLineIndex, "Hank topic buffer");
 	};
 
 	debugC(1, kDebugDialogue,
@@ -2032,15 +2176,16 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-			(void)startupScript->setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
-			return playDialogueLine(0xa3e, "HANK");
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
+			return runtime.playDialogueLine(0xa3e, "HANK");
 		}
 		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-			(void)startupScript->setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
-			return playDialogueLine(0xa53, "HANK");
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
+			return runtime.playDialogueLine(0xa53, "HANK");
 		}
-		return playDialogueLine(0xa38, "HANK");
+		return runtime.playDialogueLine(0xa38, "HANK");
 	}
+
 	bool skipHankFollowupBranches = false;
 	if (_hankRoomDialogueState.pendingInitialConversation) {
 		_hankRoomDialogueState.trackedDayIndex = currentStoryDayIndex;
@@ -2051,30 +2196,30 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		hankTopicBufferLineIndex = -1;
 		skipHankFollowupBranches = true;
 
-		Common::Error lineError = playDialogueLine(0x703, "HANK");
+		Common::Error lineError = runtime.playDialogueLine(0x703, "HANK");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
 		int responseIndex = 0;
-		Common::Error responseError = runResponseMenu(0xc7, responseIndex);
+		Common::Error responseError = runtime.runResponseMenu(0xc7, responseIndex);
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 
 		switch (responseIndex) {
 		case 1:
-			lineError = playDialogueLine(0x70f, "HANK");
+			lineError = runtime.playDialogueLine(0x70f, "HANK");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignHankTopicBuffer(0xc8);
 			break;
 		case 2:
-			lineError = playDialogueLine(0x715, "HANK");
+			lineError = runtime.playDialogueLine(0x715, "HANK");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignHankTopicBuffer(0xc9);
 			break;
 		case 3:
-			lineError = playDialogueLine(0x71c, "HANK");
+			lineError = runtime.playDialogueLine(0x71c, "HANK");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignHankTopicBuffer(0xca);
@@ -2091,62 +2236,54 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			_hankRoomDialogueState.pendingSameDayFollowup = false;
 			_hankRoomDialogueState.pendingRangshotSequence = true;
 
-			Common::Error lineError = playDialogueLine(0x8f5, "HANK");
+			Common::Error lineError = runtime.playDialogueLine(0x8f5, "HANK");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 
 		if (_hankRoomDialogueState.pendingRangshotSequence) {
-			if (currentStoryDayIndex > 5) {
-				Common::Error lineError = playDialogueLine(0x8e2, "HANK");
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-			} else {
-				FstPlayer fstPlayer(_engine);
-				if (!fstPlayer.play(kDialogueRangshotFstPath))
-					return Common::kReadingFailed;
-			}
-		}
-	} else if (!skipHankFollowupBranches) {
-		_hankRoomDialogueState.pendingSameDayFollowup = true;
-		if (currentStoryDayIndex > 5) {
-			Common::Error lineError = playDialogueLine(0x8e2, "HANK");
+			Common::Error lineError = currentStoryDayIndex > 5
+				? runtime.playDialogueLine(0x8e2, "HANK")
+				: runtime.playDialogueFst(kDialogueRangshotFstPath);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-		} else {
-			FstPlayer fstPlayer(_engine);
-			if (!fstPlayer.play(kDialogueRangshotFstPath))
-				return Common::kReadingFailed;
 		}
+	} else if (!skipHankFollowupBranches) {
+		_hankRoomDialogueState.pendingSameDayFollowup = true;
+		Common::Error lineError = currentStoryDayIndex > 5
+			? runtime.playDialogueLine(0x8e2, "HANK")
+			: runtime.playDialogueFst(kDialogueRangshotFstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 
-	if (!skipHankFollowupBranches && startupScript->getFlagValue("STEPH_MIDGAME_PLAYED") &&
+	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
 			!_hankRoomDialogueState.stephMidgamePlayedShown) {
 		_hankRoomDialogueState.stephMidgamePlayedShown = true;
 
-		Common::Error lineError = playDialogueLine(0x92c, "HANK");
+		Common::Error lineError = runtime.playDialogueLine(0x92c, "HANK");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
 		int responseIndex = 0;
-		Common::Error responseError = runResponseMenu(0xcc, responseIndex);
+		Common::Error responseError = runtime.runResponseMenu(0xcc, responseIndex);
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 
 		switch (responseIndex) {
 		case 1: {
-			const DialogueLineSpec lines[] = {
-				DialogueLineSpec(0x939, "HANK"),
-				DialogueLineSpec(0x93f, "MOM", 2)
+			const DialogueLineEntry lines[] = {
+				{ 0x939, "HANK", 0 },
+				{ 0x93f, "MOM", 2 }
 			};
-			lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
+			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
 			break;
 		}
 		case 2:
-			lineError = playDialogueLine(0x944, "HANK");
+			lineError = runtime.playDialogueLine(0x944, "HANK");
 			break;
 		case 3:
-			lineError = playDialogueLine(0x948, "HANK");
+			lineError = runtime.playDialogueLine(0x948, "HANK");
 			break;
 		default:
 			break;
@@ -2154,30 +2291,30 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
-		lineError = playDialogueLine(0x94f, "HANK");
+		lineError = runtime.playDialogueLine(0x94f, "HANK");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
 		responseIndex = 0;
-		responseError = runResponseMenu(0xcd, responseIndex);
+		responseError = runtime.runResponseMenu(0xcd, responseIndex);
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 
 		switch (responseIndex) {
 		case 1: {
-			const DialogueLineSpec lines[] = {
-				DialogueLineSpec(0x95a, "HANK"),
-				DialogueLineSpec(0x961, "PC", 2)
+			const DialogueLineEntry lines[] = {
+				{ 0x95a, "HANK", 0 },
+				{ 0x961, "PC", 2 }
 			};
-			lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
+			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
 			break;
 		}
 		case 2: {
-			const DialogueLineSpec lines[] = {
-				DialogueLineSpec(0x965, "HANK"),
-				DialogueLineSpec(0x96b, "PC")
+			const DialogueLineEntry lines[] = {
+				{ 0x965, "HANK", 0 },
+				{ 0x96b, "PC", 0 }
 			};
-			lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
+			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
 			break;
 		}
 		default:
@@ -2186,45 +2323,45 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
-		lineError = playDialogueLine(0x971, "HANK");
+		lineError = runtime.playDialogueLine(0x971, "HANK");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
-	if (!skipHankFollowupBranches && startupScript->getFlagValue("BURNED_TV_STATION") &&
+	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
 			!_hankRoomDialogueState.burnedTvStationShown) {
 		_hankRoomDialogueState.burnedTvStationShown = true;
-		Common::Error lineError = playDialogueLine(0x987, "PC");
+		Common::Error lineError = runtime.playDialogueLine(0x987, "PC");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
-	if (!skipHankFollowupBranches && startupScript->getFlagValue("BUSTED_ONCE") &&
+	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("BUSTED_ONCE") &&
 			!_hankRoomDialogueState.bustedOnceShown) {
 		_hankRoomDialogueState.bustedOnceShown = true;
-		Common::Error lineError = playDialogueLine(0x9b1, "HANK");
+		Common::Error lineError = runtime.playDialogueLine(0x9b1, "HANK");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
 		int responseIndex = 0;
-		Common::Error responseError = runResponseMenu(0xce, responseIndex);
+		Common::Error responseError = runtime.runResponseMenu(0xce, responseIndex);
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 
 		switch (responseIndex) {
 		case 1: {
-			const DialogueLineSpec lines[] = {
-				DialogueLineSpec(0x9bc, "HANK"),
-				DialogueLineSpec(0x9c9, "HANK")
+			const DialogueLineEntry lines[] = {
+				{ 0x9bc, "HANK", 0 },
+				{ 0x9c9, "HANK", 0 }
 			};
-			lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
+			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
 			break;
 		}
 		case 2: {
-			const DialogueLineSpec lines[] = {
-				DialogueLineSpec(0x9c1, "HANK"),
-				DialogueLineSpec(0x9c5, "PC"),
-				DialogueLineSpec(0x9c9, "HANK")
+			const DialogueLineEntry lines[] = {
+				{ 0x9c1, "HANK", 0 },
+				{ 0x9c5, "PC", 0 },
+				{ 0x9c9, "HANK", 0 }
 			};
-			lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
+			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
 			break;
 		}
 		default:
@@ -2233,46 +2370,46 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
-	if (!skipHankFollowupBranches && startupScript->getFlagValue("KARIN_KIDNAPED") &&
+	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
 			!_hankRoomDialogueState.karinKidnapedShown) {
 		_hankRoomDialogueState.karinKidnapedShown = true;
-		Common::Error lineError = playDialogueLine(0x9d5, "HANK");
+		Common::Error lineError = runtime.playDialogueLine(0x9d5, "HANK");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
 		if (_sharedKarinKidnapedDialogueState) {
-			lineError = playDialogueLineWithVariant(0x9da, "PC", 3);
+			lineError = runtime.playDialogueLineWithVariant(0x9da, "PC", 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 
-		const DialogueLineSpec lines[] = {
-			DialogueLineSpec(0x9de, "HANK"),
-			DialogueLineSpec(0x9df, "HANK"),
-			DialogueLineSpec(0x9e0, "HANK"),
-			DialogueLineSpec(0x9e1, "HANK"),
-			DialogueLineSpec(0x9e2, "HANK")
+		const DialogueLineEntry lines[] = {
+			{ 0x9de, "HANK", 0 },
+			{ 0x9df, "HANK", 0 },
+			{ 0x9e0, "HANK", 0 },
+			{ 0x9e1, "HANK", 0 },
+			{ 0x9e2, "HANK", 0 }
 		};
-		lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
+		lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
 		int responseIndex = 0;
-		Common::Error responseError = runResponseMenu(0xcf, responseIndex);
+		Common::Error responseError = runtime.runResponseMenu(0xcf, responseIndex);
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 
 		switch (responseIndex) {
 		case 1: {
-			const DialogueLineSpec responseLines[] = {
-				DialogueLineSpec(0x9f4, "HANK"),
-				DialogueLineSpec(0x9fc, "HANK")
+			const DialogueLineEntry responseLines[] = {
+				{ 0x9f4, "HANK", 0 },
+				{ 0x9fc, "HANK", 0 }
 			};
-			lineError = playDialogueSequence(responseLines, ARRAYSIZE(responseLines));
+			lineError = runtime.playDialogueEntrySequence(responseLines, ARRAYSIZE(responseLines));
 			break;
 		}
 		case 2:
-			lineError = playDialogueLine(0xa01, "HANK");
+			lineError = runtime.playDialogueLine(0xa01, "HANK");
 			break;
 		default:
 			break;
@@ -2280,31 +2417,31 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
-	if (!skipHankFollowupBranches && startupScript->getFlagValue("KARIN_FOUND_ALIVE") &&
+	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
 			!_hankRoomDialogueState.karinFoundAliveShown) {
 		_hankRoomDialogueState.karinFoundAliveShown = true;
-		Common::Error lineError = playDialogueLine(0xa0b, "HANK");
+		Common::Error lineError = runtime.playDialogueLine(0xa0b, "HANK");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
-	if (!skipHankFollowupBranches && startupScript->getFlagValue("KARIN_FOUND_DEAD") &&
+	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
 			!_hankRoomDialogueState.karinFoundDeadShown) {
 		_hankRoomDialogueState.karinFoundDeadShown = true;
-		Common::Error lineError = playDialogueLine(0xa15, "HANK");
+		Common::Error lineError = runtime.playDialogueLine(0xa15, "HANK");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
 		int responseIndex = 0;
-		Common::Error responseError = runResponseMenu(0xd0, responseIndex);
+		Common::Error responseError = runtime.runResponseMenu(0xd0, responseIndex);
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 
 		switch (responseIndex) {
 		case 1:
-			lineError = playDialogueLine(0xa20, "HANK");
+			lineError = runtime.playDialogueLine(0xa20, "HANK");
 			break;
 		case 2:
-			lineError = playDialogueLine(0xa26, "HANK");
+			lineError = runtime.playDialogueLine(0xa26, "HANK");
 			break;
 		default:
 			break;
@@ -2312,24 +2449,25 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
-		lineError = playDialogueLine(0xa2b, "HANK");
+		lineError = runtime.playDialogueLine(0xa2b, "HANK");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 
 	for (;;) {
 		Common::String selectedTopic;
-		Common::Error menuError = runKeywordMenu(hankTopicBuffer, hankTopicBufferLineIndex, selectedTopic);
+		Common::Error menuError = runtime.runKeywordMenu(
+			hankTopicBuffer, hankTopicBufferLineIndex, selectedTopic);
 		if (menuError.getCode() != Common::kNoError)
 			return menuError;
 
 		if (selectedTopic.empty())
 			return Common::kNoError;
 		debugC(1, kDebugDialogue, "Harvester: Hank selected topic='%s'", selectedTopic.c_str());
-		if (selectedTopic.equalsIgnoreCase(genericByeTopic))
-			return playDialogueLine(0x8dc, "HANK");
+		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()))
+			return runtime.playDialogueLine(0x8dc, "HANK");
 
-		const Common::String momTopic = startupText->getDialogueResponseLine(0xd2);
+		const Common::String momTopic = runtime.startupText().getDialogueResponseLine(0xd2);
 		Common::Array<Common::String> currentTopics;
 		splitDialogueMenuLine(hankTopicBuffer, currentTopics);
 		const bool matchesMomTopicByLine = !momTopic.empty() && selectedTopic.equalsIgnoreCase(momTopic);
@@ -2339,20 +2477,20 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			debugC(1, kDebugDialogue,
 				"Harvester: Hank matched special Mom branch '%s' (lineMatch=%d bufferMatch=%d)",
 				selectedTopic.c_str(), (int)matchesMomTopicByLine, (int)matchesMomTopicFromIntroBuffer);
-			const DialogueLineSpec lines[] = {
-				DialogueLineSpec(0x725, "HANK"),
-				DialogueLineSpec(0x729, "PC"),
-				DialogueLineSpec(0x72d, "HANK"),
-				DialogueLineSpec(0x733, "PC"),
-				DialogueLineSpec(0x737, "HANK"),
-				DialogueLineSpec(0x73b, "PC"),
-				DialogueLineSpec(0x741, "HANK"),
-				DialogueLineSpec(0x747, "MOM", 2)
+			const DialogueLineEntry lines[] = {
+				{ 0x725, "HANK", 0 },
+				{ 0x729, "PC", 0 },
+				{ 0x72d, "HANK", 0 },
+				{ 0x733, "PC", 0 },
+				{ 0x737, "HANK", 0 },
+				{ 0x73b, "PC", 0 },
+				{ 0x741, "HANK", 0 },
+				{ 0x747, "MOM", 2 }
 			};
-			Common::Error lineError = playDialogueSequence(lines, ARRAYSIZE(lines));
+			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			setActiveSpeakerPortrait("HANK", 0);
+			runtime.setActiveSpeakerPortrait("HANK", 0);
 			assignHankTopicBuffer(0xd5);
 			continue;
 		}
@@ -2362,7 +2500,8 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 			if (topic.responseLineIndex == 0xd2)
 				continue;
 
-			const Common::String topicText = startupText->getDialogueResponseLine(topic.responseLineIndex);
+			const Common::String topicText =
+				runtime.startupText().getDialogueResponseLine(topic.responseLineIndex);
 			if (topicText.empty() || !selectedTopic.equalsIgnoreCase(topicText))
 				continue;
 
@@ -2373,30 +2512,28 @@ Common::Error StartupDialogueSystem::runRoomNpcDialogue(const IndexedBitmap &bac
 				"Harvester: Hank matched topic '%s' to response line 0x%x (%d) -> wav=0x%x speaker='%s'",
 				selectedTopic.c_str(), topic.responseLineIndex, topic.responseLineIndex + 1,
 				topic.wavId, topic.speakerId);
-			Common::Error lineError = playDialogueLine(topic.wavId, topic.speakerId);
+			Common::Error lineError = runtime.playDialogueLine(topic.wavId, topic.speakerId);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			handledTopic = true;
 			break;
 		}
 
+		Common::Error lineError = runtime.playDialogueLine(0xa32, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
 		if (handledTopic) {
 			debugC(1, kDebugDialogue,
 				"Harvester: Hank handled topic '%s' and now falls through to generic response/exit",
 				selectedTopic.c_str());
-			Common::Error lineError = playDialogueLine(0xa32, "HANK");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			return playDialogueLine(0x8dc, "HANK");
+		} else {
+			debugC(1, kDebugDialogue,
+				"Harvester: Hank topic '%s' fell back to generic response and exits dialogue",
+				selectedTopic.c_str());
 		}
 
-		debugC(1, kDebugDialogue,
-			"Harvester: Hank topic '%s' fell back to generic response and exits dialogue",
-			selectedTopic.c_str());
-		Common::Error lineError = playDialogueLine(0xa32, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		return playDialogueLine(0x8dc, "HANK");
+		return runtime.playDialogueLine(0x8dc, "HANK");
 	}
 }
 
diff --git a/engines/harvester/startup_dialogue.h b/engines/harvester/dialogue.h
similarity index 76%
rename from engines/harvester/startup_dialogue.h
rename to engines/harvester/dialogue.h
index 38cdd75d6ff..63e76c1f184 100644
--- a/engines/harvester/startup_dialogue.h
+++ b/engines/harvester/dialogue.h
@@ -19,9 +19,10 @@
  *
  */
 
-#ifndef HARVESTER_STARTUP_DIALOGUE_H
-#define HARVESTER_STARTUP_DIALOGUE_H
+#ifndef HARVESTER_DIALOGUE_H
+#define HARVESTER_DIALOGUE_H
 
+#include "common/array.h"
 #include "common/error.h"
 #include "common/rect.h"
 #include "common/str.h"
@@ -33,11 +34,18 @@ struct Event;
 
 namespace Harvester {
 
+class HankDialogueHandler;
 class HarvesterEngine;
+class JimmyDialogueHandler;
+class MomDialogueHandler;
+class NpcDialogueHandler;
+class DialogueRuntime;
 class StartupFlow;
+class StartupText;
+class WaspWomanDialogueHandler;
 struct IndexedBitmap;
 
-class StartupDialogueSystem {
+class DialogueSystem {
 public:
 	struct HankRoomDialogueState {
 		bool pendingInitialConversation = true;
@@ -91,7 +99,8 @@ public:
 		bool introPending = true;
 	};
 
-	StartupDialogueSystem(HarvesterEngine &engine, Common::Point &mousePos);
+	DialogueSystem(HarvesterEngine &engine, Common::Point &mousePos);
+	~DialogueSystem();
 
 	void resetRoomNpcDialogueState();
 	Common::Error runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
@@ -99,6 +108,18 @@ public:
 		StartupFlow &startupFlow);
 
 private:
+	friend class HankDialogueHandler;
+	friend class JimmyDialogueHandler;
+	friend class MomDialogueHandler;
+	friend class WaspWomanDialogueHandler;
+
+	void registerNpcHandlers();
+
+	Common::Error handleJimmyDialogue(DialogueRuntime &runtime, const Common::String &usedItemName);
+	Common::Error handleWaspWomanDialogue(DialogueRuntime &runtime, const Common::String &usedItemName);
+	Common::Error handleMomDialogue(DialogueRuntime &runtime, const Common::String &usedItemName);
+	Common::Error handleHankDialogue(DialogueRuntime &runtime, const Common::String &usedItemName);
+
 	HarvesterEngine &_engine;
 	Common::Point &_mousePos;
 	HankRoomDialogueState _hankRoomDialogueState;
@@ -108,8 +129,9 @@ private:
 	bool _sharedKarinKidnapedDialogueState = false;
 	bool _sharedDiscussedLodgeTopic = false;
 	bool _sharedWaspWomanDialogueState = false;
+	Common::Array<NpcDialogueHandler *> _npcHandlers;
 };
 
 } // End of namespace Harvester
 
-#endif // HARVESTER_STARTUP_DIALOGUE_H
+#endif // HARVESTER_DIALOGUE_H
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index a65caf8a921..cd226c9100e 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -4,12 +4,17 @@ MODULE_OBJS = \
 	cft_font.o \
 	harvester.o \
 	console.o \
+	dialogue.o \
 	fst_player.o \
 	metaengine.o \
+	npc/hank_dialogue.o \
+	npc/jimmy_dialogue.o \
+	npc/mom_dialogue.o \
+	npc/stub_dialogue.o \
+	npc/wasp_woman_dialogue.o \
 	resources.o \
 	runtime_entity.o \
 	startup_art.o \
-	startup_dialogue.o \
 	startup_flow.o \
 	startup_inventory.o \
 	startup_menu.o \
diff --git a/engines/harvester/npc/dialogue_handler.h b/engines/harvester/npc/dialogue_handler.h
new file mode 100644
index 00000000000..8107ac78fc8
--- /dev/null
+++ b/engines/harvester/npc/dialogue_handler.h
@@ -0,0 +1,44 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_DIALOGUE_HANDLER_H
+#define HARVESTER_NPC_DIALOGUE_HANDLER_H
+
+#include "common/error.h"
+#include "common/str.h"
+
+namespace Harvester {
+
+class DialogueRuntime;
+class DialogueSystem;
+
+class NpcDialogueHandler {
+public:
+	virtual ~NpcDialogueHandler() {}
+
+	virtual bool matchesNpc(const Common::String &npcName) const = 0;
+	virtual Common::Error handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
+		const Common::String &usedItemName) = 0;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_DIALOGUE_HANDLER_H
diff --git a/engines/harvester/npc/hank_dialogue.cpp b/engines/harvester/npc/hank_dialogue.cpp
new file mode 100644
index 00000000000..838c18c5c6d
--- /dev/null
+++ b/engines/harvester/npc/hank_dialogue.cpp
@@ -0,0 +1,36 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/dialogue.h"
+#include "harvester/npc/hank_dialogue.h"
+
+namespace Harvester {
+
+bool HankDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("HANK");
+}
+
+Common::Error HankDialogueHandler::handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
+		const Common::String &usedItemName) {
+	return dialogue.handleHankDialogue(runtime, usedItemName);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/hank_dialogue.h b/engines/harvester/npc/hank_dialogue.h
new file mode 100644
index 00000000000..baf9bb16453
--- /dev/null
+++ b/engines/harvester/npc/hank_dialogue.h
@@ -0,0 +1,38 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_HANK_DIALOGUE_H
+#define HARVESTER_NPC_HANK_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class HankDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	Common::Error handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
+		const Common::String &usedItemName) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_HANK_DIALOGUE_H
diff --git a/engines/harvester/npc/jimmy_dialogue.cpp b/engines/harvester/npc/jimmy_dialogue.cpp
new file mode 100644
index 00000000000..0f473e3d94d
--- /dev/null
+++ b/engines/harvester/npc/jimmy_dialogue.cpp
@@ -0,0 +1,36 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/dialogue.h"
+#include "harvester/npc/jimmy_dialogue.h"
+
+namespace Harvester {
+
+bool JimmyDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("JIMMY");
+}
+
+Common::Error JimmyDialogueHandler::handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
+		const Common::String &usedItemName) {
+	return dialogue.handleJimmyDialogue(runtime, usedItemName);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/jimmy_dialogue.h b/engines/harvester/npc/jimmy_dialogue.h
new file mode 100644
index 00000000000..465a111de23
--- /dev/null
+++ b/engines/harvester/npc/jimmy_dialogue.h
@@ -0,0 +1,38 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_JIMMY_DIALOGUE_H
+#define HARVESTER_NPC_JIMMY_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class JimmyDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	Common::Error handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
+		const Common::String &usedItemName) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_JIMMY_DIALOGUE_H
diff --git a/engines/harvester/npc/mom_dialogue.cpp b/engines/harvester/npc/mom_dialogue.cpp
new file mode 100644
index 00000000000..5c7b9b6cbbd
--- /dev/null
+++ b/engines/harvester/npc/mom_dialogue.cpp
@@ -0,0 +1,36 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/dialogue.h"
+#include "harvester/npc/mom_dialogue.h"
+
+namespace Harvester {
+
+bool MomDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("MOM");
+}
+
+Common::Error MomDialogueHandler::handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
+		const Common::String &usedItemName) {
+	return dialogue.handleMomDialogue(runtime, usedItemName);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/mom_dialogue.h b/engines/harvester/npc/mom_dialogue.h
new file mode 100644
index 00000000000..83b1d30126c
--- /dev/null
+++ b/engines/harvester/npc/mom_dialogue.h
@@ -0,0 +1,38 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_MOM_DIALOGUE_H
+#define HARVESTER_NPC_MOM_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class MomDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	Common::Error handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
+		const Common::String &usedItemName) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_MOM_DIALOGUE_H
diff --git a/engines/harvester/npc/stub_dialogue.cpp b/engines/harvester/npc/stub_dialogue.cpp
new file mode 100644
index 00000000000..68f6510398c
--- /dev/null
+++ b/engines/harvester/npc/stub_dialogue.cpp
@@ -0,0 +1,54 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/npc/stub_dialogue.h"
+
+#include "common/debug.h"
+#include "harvester/dialogue.h"
+
+namespace Harvester {
+
+StubNpcDialogueHandler::StubNpcDialogueHandler(const char *npcName) {
+	_npcNames.push_back(npcName);
+}
+
+StubNpcDialogueHandler::StubNpcDialogueHandler(const Common::Array<Common::String> &npcNames)
+	: _npcNames(npcNames) {
+}
+
+bool StubNpcDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	for (uint i = 0; i < _npcNames.size(); ++i) {
+		if (npcName.equalsIgnoreCase(_npcNames[i]))
+			return true;
+	}
+
+	return false;
+}
+
+Common::Error StubNpcDialogueHandler::handle(DialogueSystem &, DialogueRuntime &,
+		const Common::String &usedItemName) {
+	debug(1, "Harvester: stub NPC dialogue handler '%s' item='%s'",
+		_npcNames.empty() ? "<unknown>" : _npcNames.front().c_str(),
+		usedItemName.empty() ? "<none>" : usedItemName.c_str());
+	return Common::kNoError;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/stub_dialogue.h b/engines/harvester/npc/stub_dialogue.h
new file mode 100644
index 00000000000..5b3f539ea4a
--- /dev/null
+++ b/engines/harvester/npc/stub_dialogue.h
@@ -0,0 +1,45 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_STUB_DIALOGUE_H
+#define HARVESTER_NPC_STUB_DIALOGUE_H
+
+#include "common/array.h"
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class StubNpcDialogueHandler : public NpcDialogueHandler {
+public:
+	explicit StubNpcDialogueHandler(const char *npcName);
+	explicit StubNpcDialogueHandler(const Common::Array<Common::String> &npcNames);
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	Common::Error handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
+		const Common::String &usedItemName) override;
+
+private:
+	Common::Array<Common::String> _npcNames;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_STUB_DIALOGUE_H
diff --git a/engines/harvester/npc/wasp_woman_dialogue.cpp b/engines/harvester/npc/wasp_woman_dialogue.cpp
new file mode 100644
index 00000000000..a3d6307db10
--- /dev/null
+++ b/engines/harvester/npc/wasp_woman_dialogue.cpp
@@ -0,0 +1,36 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/dialogue.h"
+#include "harvester/npc/wasp_woman_dialogue.h"
+
+namespace Harvester {
+
+bool WaspWomanDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("WASP_WOMAN");
+}
+
+Common::Error WaspWomanDialogueHandler::handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
+		const Common::String &usedItemName) {
+	return dialogue.handleWaspWomanDialogue(runtime, usedItemName);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/wasp_woman_dialogue.h b/engines/harvester/npc/wasp_woman_dialogue.h
new file mode 100644
index 00000000000..1691dab6250
--- /dev/null
+++ b/engines/harvester/npc/wasp_woman_dialogue.h
@@ -0,0 +1,38 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_WASP_WOMAN_DIALOGUE_H
+#define HARVESTER_NPC_WASP_WOMAN_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class WaspWomanDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	Common::Error handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
+		const Common::String &usedItemName) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_WASP_WOMAN_DIALOGUE_H
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/startup_flow.h
index 4a9c34ac956..f91b2ca7f97 100644
--- a/engines/harvester/startup_flow.h
+++ b/engines/harvester/startup_flow.h
@@ -26,7 +26,7 @@
 #include "common/error.h"
 #include "common/rect.h"
 #include "common/str.h"
-#include "harvester/startup_dialogue.h"
+#include "harvester/dialogue.h"
 #include "harvester/startup_inventory.h"
 #include "harvester/startup_menu.h"
 #include "harvester/startup_room.h"
@@ -49,7 +49,7 @@ public:
 	Common::Error run();
 
 private:
-	friend class StartupDialogueSystem;
+	friend class DialogueSystem;
 	friend class StartupInventorySystem;
 	friend class StartupMenuSystem;
 	friend class StartupRoomSystem;
@@ -85,14 +85,14 @@ private:
 	Common::Array<Common::String> _quickTips;
 	Common::Array<Common::String> _menuItems;
 	Common::Point _mousePos;
-		StartupDialogueSystem _dialogue;
-		StartupInventorySystem _inventory;
-		StartupMenuSystem _menu;
-		StartupRoomSystem _room;
-		StartupInteractionResult _queuedDialogueInteraction;
-		bool _hasQueuedDialogueInteraction = false;
-		bool _pendingMainMenuReturn = false;
-	};
+	DialogueSystem _dialogue;
+	StartupInventorySystem _inventory;
+	StartupMenuSystem _menu;
+	StartupRoomSystem _room;
+	StartupInteractionResult _queuedDialogueInteraction;
+	bool _hasQueuedDialogueInteraction = false;
+	bool _pendingMainMenuReturn = false;
+};
 
 } // End of namespace Harvester
 


Commit: 05dfb6bab4330963ab973c7bd8ca87fa6358e3d8
    https://github.com/scummvm/scummvm/commit/05dfb6bab4330963ab973c7bd8ca87fa6358e3d8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:57+02:00

Commit Message:
HARVESTER: Port pta mom startup dialogue

Changed paths:
  A engines/harvester/npc/pta_mom_dialogue.cpp
  A engines/harvester/npc/pta_mom_dialogue.h
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/dialogue.h
    engines/harvester/module.mk


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 2ece1726190..51f0106db11 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -429,6 +429,12 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `0x141` first calls `get_set_discussed_lodge_topic(0, 1)`, plays `0x1fe1` / `0x1fe5` / `0x1feb` / `0x1fef`, then shows zero-based `dialog.rsp` line `0x142`; response `1` plays `0x1ffe`, response `2` plays `0x2005` / `0x200a` / `0x200e`, and the branch then rewrites the keyword buffer to `0x144`.
     - When `STEPH_MIDGAME_PLAYED` is already set, that same `0x141` topic instead plays `0x2154` / `0x215a` and loops on the current keyword buffer without rewriting it.
     - `0x151..0x152` plays `0x20f7` / `0x20fb`, then shows zero-based `dialog.rsp` line `0x153`; response `1` plays `0x2106` / `0x210a` / `0x210e` and loops on the current keyword buffer, while response `2` plays `0x2114` / `0x2119` / `0x211d` and rewrites the buffer to `0x154`.
+  - `handle_talk_to_pta_mom @ 0x34e30` is now constrained to a compact one-screen exchange.
+    - If `PTA_RESPOND_TO_TV` is set, it plays `0x3233` with `PTA_MOM1` head variant `2`, clears that flag, and returns without opening the response menu.
+    - Otherwise it chooses one of three random opener lines, `0x31ee`, `0x31f2`, or `0x31f6`, all spoken as `PTA_MOM1` with head variant `1`, then opens zero-based `dialog.rsp` line `0x297`.
+    - Response `1` plays `0x3204` then `0x320b`, both as `PTA_MOM1` with head variant `2`.
+    - Response `2` plays `0x320f` then `0x3213`, both as `PTA_MOM1` with head variant `0`.
+    - Response `3` plays `0x3217`, `0x321c`, `0x3221`, and `0x3226`, where `0x3221` is the only `PC` line and the others stay on `PTA_MOM1` head variant `0`.
 - The shared dialogue evidence/topic state shims behind those handlers are now partially explicit:
   - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
   - `get_set_discussed_note_checkbook_evidence` at `0x38330` gates the shared `NOTE` / `NOTE_PHOTOCOPY` / `CHECKBOOK` / `CHECKBOOK_PHOTOCOPY` evidence path used by Dwayne, Edna, Herrill, Johnson, Mom, and related handlers.
diff --git a/TRACKER.md b/TRACKER.md
index b0f655cfdec..bcb5770069b 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,15 +15,15 @@
 
 ## Last Confirmed Action
 
-- Refactored `StartupDialogueSystem` into `DialogueSystem` and split room-NPC handlers into `engines/harvester/npc/`.
-  - `startup_dialogue.cpp` / `startup_dialogue.h` were renamed to `dialogue.cpp` / `dialogue.h`, and `StartupFlow` now owns `DialogueSystem _dialogue`.
-  - Jimmy, Mom, Hank, and Wasp Woman now dispatch through dedicated handler classes in `engines/harvester/npc/`, while `PTA_MOM`, `PTA_MOM1`-`PTA_MOM5`, `DWAYNE`, `EDNA`, `HERRILL`, and `JOHNSON` are stubbed through the same registry.
-  - The runtime split preserves the existing recovered dialogue behavior while making the next startup-room ports land as isolated NPC handlers instead of growing a single monolithic dispatcher again.
-  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the refactor.
+- Ported `handle_talk_to_pta_mom @ 0x34e30` into `DialogueSystem` as `engines/harvester/npc/pta_mom_dialogue.*`.
+  - `PTA_MOM1` through `PTA_MOM5` now route through a dedicated handler instead of the generic stub registry entry; the plain `PTA_MOM` token remains stubbed because this pass did not confirm it as a native talk-table alias.
+  - The engine now mirrors the native `PTA_RESPOND_TO_TV` one-shot override: `0x3233` with `PTA_MOM1` head variant `2`, then the flag is cleared and the handler returns without opening the response menu.
+  - The normal path now mirrors the compact native exchange: one random opener from `0x31ee`, `0x31f2`, or `0x31f6`, then zero-based `dialog.rsp` line `0x297`, with response `1` -> `0x3204` / `0x320b`, response `2` -> `0x320f` / `0x3213`, and response `3` -> `0x3217` / `0x321c` / `0x3221` / `0x3226`.
+  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the PTA Mom handler port.
 
 ## Next Suggested Action
 
-1. Port `handle_talk_to_pta_mom @ 0x34e30` into `DialogueSystem` as `engines/harvester/npc/pta_mom_dialogue.*`.
-   - The handler is already bounded to `PTA_MOM1` through `PTA_MOM5`, uses the fixed `0x297` response menu, and now has an explicit stub slot in the NPC registry to replace.
-2. After PTA Mom, continue porting bounded startup-room handlers before returning to wider helper scans.
-   - Dwayne, Edna, Herrill, and Johnson still need explicit engine-side room handlers before their confirmed shared `SHOWN_*` evidence writes can be mirrored.
+1. Port `handle_talk_to_dwayne @ 0x3a310` into `DialogueSystem` as `engines/harvester/npc/dwayne_dialogue.*`.
+   - Dwayne is the next explicit startup-room handler still behind a stub, and the native audit already ties him to confirmed shared evidence helpers such as `get_set_discussed_note_checkbook_evidence`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_whaley_herrill_photo`, and the Dwayne-local `K_PURSE` / Karin-follow-up state wrappers.
+2. After Dwayne, continue replacing the remaining bounded startup-room stubs before returning to wider helper scans.
+   - Edna, Herrill, and Johnson still need explicit engine-side room handlers before their confirmed shared `SHOWN_*` evidence writes can be mirrored.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 9d705a679a1..11a30571064 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -39,6 +39,7 @@
 #include "harvester/npc/hank_dialogue.h"
 #include "harvester/npc/jimmy_dialogue.h"
 #include "harvester/npc/mom_dialogue.h"
+#include "harvester/npc/pta_mom_dialogue.h"
 #include "harvester/npc/stub_dialogue.h"
 #include "harvester/npc/wasp_woman_dialogue.h"
 #include "harvester/resources.h"
@@ -193,6 +194,8 @@ static const char *const kShownEvidenceSheriffOwnsFlag = "SHOWN_EVIDENCE_SHERIFF
 static const char *const kShownLedgersToAnyoneFlag = "SHOWN_LEDGERS_TO_ANYONE_OTH";
 static const char *const kShownPhotoOfCorpseFlag = "SHOWN_PHOTO_OF_CORPSE_AROUN";
 static const char *const kShownPhotoOfWhaleyHerrillFlag = "SHOWN_PHOTO_OF_WHALEY_HERRI";
+static const char *const kPtaRespondToTvFlag = "PTA_RESPOND_TO_TV";
+static const char *const kPtaMomSpeakerId = "PTA_MOM1";
 
 static const byte kTextColorNormal = 255;
 static const byte kTextColorHover = 251;
@@ -444,6 +447,23 @@ static const DialogueLineEntry kWaspWomanTopic305Response1Lines[] = {
 	{ 0x4c53, "WASP_WOMAN", 1 }
 };
 
+static const DialogueLineEntry kPtaMomResponse1Lines[] = {
+	{ 0x3204, kPtaMomSpeakerId, 2 },
+	{ 0x320b, kPtaMomSpeakerId, 2 }
+};
+
+static const DialogueLineEntry kPtaMomResponse2Lines[] = {
+	{ 0x320f, kPtaMomSpeakerId, 0 },
+	{ 0x3213, kPtaMomSpeakerId, 0 }
+};
+
+static const DialogueLineEntry kPtaMomResponse3Lines[] = {
+	{ 0x3217, kPtaMomSpeakerId, 0 },
+	{ 0x321c, kPtaMomSpeakerId, 0 },
+	{ 0x3221, "PC", 0 },
+	{ 0x3226, kPtaMomSpeakerId, 0 }
+};
+
 static const int kMomPtaTopicResponseLines[] = { 0x125, 0x126, 0x127, 0x128 };
 static const int kMomCookingTopicResponseLines[] = { 0x131, 0x132, 0x133 };
 static const int kMomBakeSaleTopicResponseLines[] = { 0x145, 0x146, 0x147 };
@@ -714,15 +734,9 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new WaspWomanDialogueHandler());
 	_npcHandlers.push_back(new MomDialogueHandler());
 	_npcHandlers.push_back(new HankDialogueHandler());
+	_npcHandlers.push_back(new PtaMomDialogueHandler());
 
-	Common::Array<Common::String> ptaMomNames;
-	ptaMomNames.push_back(Common::String("PTA_MOM"));
-	ptaMomNames.push_back(Common::String("PTA_MOM1"));
-	ptaMomNames.push_back(Common::String("PTA_MOM2"));
-	ptaMomNames.push_back(Common::String("PTA_MOM3"));
-	ptaMomNames.push_back(Common::String("PTA_MOM4"));
-	ptaMomNames.push_back(Common::String("PTA_MOM5"));
-	_npcHandlers.push_back(new StubNpcDialogueHandler(ptaMomNames));
+	_npcHandlers.push_back(new StubNpcDialogueHandler("PTA_MOM"));
 	_npcHandlers.push_back(new StubNpcDialogueHandler("DWAYNE"));
 	_npcHandlers.push_back(new StubNpcDialogueHandler("EDNA"));
 	_npcHandlers.push_back(new StubNpcDialogueHandler("HERRILL"));
@@ -1484,6 +1498,55 @@ Common::Error DialogueSystem::handleJimmyDialogue(DialogueRuntime &runtime, cons
 	return playJimmyLine(0x4b38, 0);
 }
 
+Common::Error DialogueSystem::handlePtaMomDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName) {
+	(void)usedItemName;
+
+	auto playPtaMomLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kPtaMomSpeakerId, headVariant);
+	};
+
+	if (runtime.startupScript().getFlagValue(kPtaRespondToTvFlag)) {
+		Common::Error lineError = playPtaMomLine(0x3233, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		(void)runtime.startupScript().setRuntimeFlagValue(kPtaRespondToTvFlag, false);
+		return Common::kNoError;
+	}
+
+	Common::Error lineError = Common::kNoError;
+	switch (runtime.getRandomNumber(2)) {
+	case 0:
+		lineError = playPtaMomLine(0x31ee, 1);
+		break;
+	case 1:
+		lineError = playPtaMomLine(0x31f2, 1);
+		break;
+	default:
+		lineError = playPtaMomLine(0x31f6, 1);
+		break;
+	}
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(0x297, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	switch (responseIndex) {
+	case 1:
+		return runtime.playDialogueEntrySequence(kPtaMomResponse1Lines, ARRAYSIZE(kPtaMomResponse1Lines));
+	case 2:
+		return runtime.playDialogueEntrySequence(kPtaMomResponse2Lines, ARRAYSIZE(kPtaMomResponse2Lines));
+	case 3:
+		return runtime.playDialogueEntrySequence(kPtaMomResponse3Lines, ARRAYSIZE(kPtaMomResponse3Lines));
+	default:
+		return Common::kNoError;
+	}
+}
+
 Common::Error DialogueSystem::handleWaspWomanDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName) {
 	Common::String waspWomanTopicBuffer;
diff --git a/engines/harvester/dialogue.h b/engines/harvester/dialogue.h
index 63e76c1f184..5bfaf8358fd 100644
--- a/engines/harvester/dialogue.h
+++ b/engines/harvester/dialogue.h
@@ -40,6 +40,7 @@ class JimmyDialogueHandler;
 class MomDialogueHandler;
 class NpcDialogueHandler;
 class DialogueRuntime;
+class PtaMomDialogueHandler;
 class StartupFlow;
 class StartupText;
 class WaspWomanDialogueHandler;
@@ -111,6 +112,7 @@ private:
 	friend class HankDialogueHandler;
 	friend class JimmyDialogueHandler;
 	friend class MomDialogueHandler;
+	friend class PtaMomDialogueHandler;
 	friend class WaspWomanDialogueHandler;
 
 	void registerNpcHandlers();
@@ -119,6 +121,7 @@ private:
 	Common::Error handleWaspWomanDialogue(DialogueRuntime &runtime, const Common::String &usedItemName);
 	Common::Error handleMomDialogue(DialogueRuntime &runtime, const Common::String &usedItemName);
 	Common::Error handleHankDialogue(DialogueRuntime &runtime, const Common::String &usedItemName);
+	Common::Error handlePtaMomDialogue(DialogueRuntime &runtime, const Common::String &usedItemName);
 
 	HarvesterEngine &_engine;
 	Common::Point &_mousePos;
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index cd226c9100e..b20705db318 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -10,6 +10,7 @@ MODULE_OBJS = \
 	npc/hank_dialogue.o \
 	npc/jimmy_dialogue.o \
 	npc/mom_dialogue.o \
+	npc/pta_mom_dialogue.o \
 	npc/stub_dialogue.o \
 	npc/wasp_woman_dialogue.o \
 	resources.o \
diff --git a/engines/harvester/npc/pta_mom_dialogue.cpp b/engines/harvester/npc/pta_mom_dialogue.cpp
new file mode 100644
index 00000000000..c3a98a603cf
--- /dev/null
+++ b/engines/harvester/npc/pta_mom_dialogue.cpp
@@ -0,0 +1,40 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/dialogue.h"
+#include "harvester/npc/pta_mom_dialogue.h"
+
+namespace Harvester {
+
+bool PtaMomDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("PTA_MOM1") ||
+		npcName.equalsIgnoreCase("PTA_MOM2") ||
+		npcName.equalsIgnoreCase("PTA_MOM3") ||
+		npcName.equalsIgnoreCase("PTA_MOM4") ||
+		npcName.equalsIgnoreCase("PTA_MOM5");
+}
+
+Common::Error PtaMomDialogueHandler::handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
+		const Common::String &usedItemName) {
+	return dialogue.handlePtaMomDialogue(runtime, usedItemName);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/pta_mom_dialogue.h b/engines/harvester/npc/pta_mom_dialogue.h
new file mode 100644
index 00000000000..04b97967fe5
--- /dev/null
+++ b/engines/harvester/npc/pta_mom_dialogue.h
@@ -0,0 +1,38 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_PTA_MOM_DIALOGUE_H
+#define HARVESTER_NPC_PTA_MOM_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class PtaMomDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	Common::Error handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
+		const Common::String &usedItemName) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_PTA_MOM_DIALOGUE_H


Commit: c26cc8ff16ecf343e509abb864c85c05e9daccb1
    https://github.com/scummvm/scummvm/commit/c26cc8ff16ecf343e509abb864c85c05e9daccb1
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:57+02:00

Commit Message:
HARVESTER: Port dwayne startup dialogue

Changed paths:
  A engines/harvester/npc/dwayne_dialogue.cpp
  A engines/harvester/npc/dwayne_dialogue.h
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/dialogue.h
    engines/harvester/module.mk


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 51f0106db11..319d49a3feb 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -435,6 +435,13 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - Response `1` plays `0x3204` then `0x320b`, both as `PTA_MOM1` with head variant `2`.
     - Response `2` plays `0x320f` then `0x3213`, both as `PTA_MOM1` with head variant `0`.
     - Response `3` plays `0x3217`, `0x321c`, `0x3221`, and `0x3226`, where `0x3221` is the only `PC` line and the others stay on `PTA_MOM1` head variant `0`.
+  - `handle_talk_to_dwayne @ 0x3a310` is now bounded enough to mirror directly in the engine-side room dispatcher.
+    - The top-level no-item flow splits between the sheriff-in-diner intro (`0x3482` / `0x3486` / `0x348b` / `0x3490`, optional `EDNA 0x3496` when `KARIN_KIDNAPED`, then `0x349b` / `0x34ac`) and the outside-sheriff opener (`0x3245` / `0x324a` / `0x324e` / `0x3252`), which seeds the first keyword buffer from zero-based `dialog.rsp` line `0x78`.
+    - The arrest path clears `ARREST_FLAG` and then dispatches a strict priority bark chain over the `PC_BUSTED_KILLED_*` flags, `IF_KILL_POTTSDAM_AT_GRAVE`, `GENERIC_BUST`, `DNALFT_PERVERT`, the `PC_HAS_GOOJF_CARD` special case, and the escalating `BUSTED_ONCE` / `BUSTED_TWICE` / `BUSTED_THIRD` lines.
+    - The item path now separates Dwayne-local evidence state from shared evidence state: `BOYLES_BUTTON` and `K_PURSE` set Dwayne-local follow-up bits, while `LEDGER` / `LEDGER2`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, and `PHOTO_OF_WHALEY_HERRILL` write the same shared `SHOWN_*` evidence flags used by the other startup-room handlers.
+    - `CHECKBOOK` / `NOTE` is a two-step response branch: `0x33f4`, zero-based `dialog.rsp` line `0x70`, then `0x3407` / `0x340d`, followed by zero-based `dialog.rsp` line `0x71`, with the direct override exit at `0x342c`.
+    - The delayed Karin-alive follow-up is also confirmed: `BRING_KARIN_TO_SHERIFF` or `KARIN_FOUND_ALIVE` sets the pending Dwayne-local day gate, and when the story day changes the handler plays `0x3780` / `0x3786` / `0x378a`, clears the pending bit, and sets the shared completion helper that Stephanie later reads.
+    - The looping keyword branch rewrites the live topic buffer through confirmed `dialog.rsp` transitions `0x83->0x84`, `0x85..0x87->0x89`, `0x8c..0x8d->0x8e`, `0x8f->0x90`, `0x91->0x92`, `0x93->0x94`, `0x95->0x97`, `0x98->0x99`, `0x9a->0x9b`, and `0x9c->0x9d`; `0x82` exits with `0x3395`, while `0x9e` loops back to the menu without changing the buffer.
 - The shared dialogue evidence/topic state shims behind those handlers are now partially explicit:
   - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
   - `get_set_discussed_note_checkbook_evidence` at `0x38330` gates the shared `NOTE` / `NOTE_PHOTOCOPY` / `CHECKBOOK` / `CHECKBOOK_PHOTOCOPY` evidence path used by Dwayne, Edna, Herrill, Johnson, Mom, and related handlers.
diff --git a/TRACKER.md b/TRACKER.md
index bcb5770069b..a5f4c0af239 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,15 +15,16 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_pta_mom @ 0x34e30` into `DialogueSystem` as `engines/harvester/npc/pta_mom_dialogue.*`.
-  - `PTA_MOM1` through `PTA_MOM5` now route through a dedicated handler instead of the generic stub registry entry; the plain `PTA_MOM` token remains stubbed because this pass did not confirm it as a native talk-table alias.
-  - The engine now mirrors the native `PTA_RESPOND_TO_TV` one-shot override: `0x3233` with `PTA_MOM1` head variant `2`, then the flag is cleared and the handler returns without opening the response menu.
-  - The normal path now mirrors the compact native exchange: one random opener from `0x31ee`, `0x31f2`, or `0x31f6`, then zero-based `dialog.rsp` line `0x297`, with response `1` -> `0x3204` / `0x320b`, response `2` -> `0x320f` / `0x3213`, and response `3` -> `0x3217` / `0x321c` / `0x3221` / `0x3226`.
-  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the PTA Mom handler port.
+- Ported `handle_talk_to_dwayne @ 0x3a310` into `DialogueSystem` as `engines/harvester/npc/dwayne_dialogue.*`.
+  - `DWAYNE` now routes through a dedicated handler instead of the generic stub registry entry, and `DialogueRuntime` now carries the active room name so the native `ST_BEDRM` special case can be mirrored without reintroducing startup-only branching elsewhere.
+  - The engine now mirrors the native arrest-priority bark chain, the sheriff-in-diner intro vs outside-sheriff opener split, the delayed Karin-alive next-day follow-up, and the bounded no-sheriff event barks that native gates through the Dwayne-local persisted state block.
+  - The item path now mirrors the confirmed shared evidence writes for `LEDGER` / `LEDGER2`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, and `PHOTO_OF_WHALEY_HERRILL`, while keeping Dwayne-local state for `BOYLES_BUTTON`, `K_PURSE`, and the unresolved direct reply overrides.
+  - The looping keyword/menu path is now explicit in the engine: initial buffer `0x78`, exit topic `0x82`, Whaley/Loomis topic clusters, the Lodge `0x95` response menu on zero-based `dialog.rsp` line `0x96`, and the later `0x98`, `0x9a`, `0x9c`, and `0x9e` rewrites all follow the confirmed native `dialog.rsp` transitions.
+  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Dwayne handler port.
 
 ## Next Suggested Action
 
-1. Port `handle_talk_to_dwayne @ 0x3a310` into `DialogueSystem` as `engines/harvester/npc/dwayne_dialogue.*`.
-   - Dwayne is the next explicit startup-room handler still behind a stub, and the native audit already ties him to confirmed shared evidence helpers such as `get_set_discussed_note_checkbook_evidence`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_whaley_herrill_photo`, and the Dwayne-local `K_PURSE` / Karin-follow-up state wrappers.
-2. After Dwayne, continue replacing the remaining bounded startup-room stubs before returning to wider helper scans.
-   - Edna, Herrill, and Johnson still need explicit engine-side room handlers before their confirmed shared `SHOWN_*` evidence writes can be mirrored.
+1. Port `handle_talk_to_edna @ 0x358a0` into `DialogueSystem` as `engines/harvester/npc/edna_dialogue.*`.
+   - Edna is the next bounded startup-room handler still behind a stub, and her native item/no-item branches already overlap the confirmed shared evidence helpers used by Dwayne, Mom, Hank, Herrill, and Johnson.
+2. After Edna, continue replacing the remaining bounded startup-room stubs before returning to wider helper scans.
+   - `handle_talk_to_herrill @ 0x2dc00` and `handle_talk_to_johnson @ 0x27dd0` still need explicit engine-side handlers before their confirmed shared `SHOWN_*` evidence writes can be mirrored.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 11a30571064..137ba92b312 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -36,6 +36,7 @@
 #include "harvester/detection.h"
 #include "harvester/fst_player.h"
 #include "harvester/harvester.h"
+#include "harvester/npc/dwayne_dialogue.h"
 #include "harvester/npc/hank_dialogue.h"
 #include "harvester/npc/jimmy_dialogue.h"
 #include "harvester/npc/mom_dialogue.h"
@@ -72,7 +73,8 @@ public:
 	typedef std::function<void(const Common::String &, int)> SetActiveSpeakerPortraitFn;
 
 	DialogueRuntime(HarvesterEngine &engine, StartupScript &startupScript, StartupText &startupText,
-			StartupFlow &startupFlow, const Common::String &genericByeTopic,
+			StartupFlow &startupFlow, const Common::String &currentRoomName,
+			const Common::String &genericByeTopic,
 			const PlayDialogueLineWithVariantFn &playDialogueLineWithVariant,
 			const PlayDialogueLineFn &playDialogueLine,
 			const PlayDialogueEntrySequenceFn &playDialogueEntrySequence,
@@ -86,7 +88,8 @@ public:
 			const GetRandomNumberFn &getRandomNumber,
 			const SetActiveSpeakerPortraitFn &setActiveSpeakerPortrait)
 		: _engine(engine), _startupScript(startupScript), _startupText(startupText),
-		  _startupFlow(startupFlow), _genericByeTopic(genericByeTopic),
+		  _startupFlow(startupFlow), _currentRoomName(currentRoomName),
+		  _genericByeTopic(genericByeTopic),
 		  _playDialogueLineWithVariant(playDialogueLineWithVariant),
 		  _playDialogueLine(playDialogueLine), _playDialogueEntrySequence(playDialogueEntrySequence),
 		  _playDialogueFst(playDialogueFst), _runKeywordMenu(runKeywordMenu),
@@ -102,6 +105,7 @@ public:
 	StartupScript &startupScript() const { return _startupScript; }
 	StartupText &startupText() const { return _startupText; }
 	StartupFlow &startupFlow() const { return _startupFlow; }
+	const Common::String &currentRoomName() const { return _currentRoomName; }
 	const Common::String &genericByeTopic() const { return _genericByeTopic; }
 
 	Common::Error playDialogueLineWithVariant(int wavId, const Common::String &speakerId,
@@ -146,6 +150,7 @@ private:
 	StartupScript &_startupScript;
 	StartupText &_startupText;
 	StartupFlow &_startupFlow;
+	const Common::String &_currentRoomName;
 	const Common::String &_genericByeTopic;
 	PlayDialogueLineWithVariantFn _playDialogueLineWithVariant;
 	PlayDialogueLineFn _playDialogueLine;
@@ -730,6 +735,7 @@ DialogueSystem::~DialogueSystem() {
 }
 
 void DialogueSystem::registerNpcHandlers() {
+	_npcHandlers.push_back(new DwayneDialogueHandler());
 	_npcHandlers.push_back(new JimmyDialogueHandler());
 	_npcHandlers.push_back(new WaspWomanDialogueHandler());
 	_npcHandlers.push_back(new MomDialogueHandler());
@@ -737,17 +743,19 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new PtaMomDialogueHandler());
 
 	_npcHandlers.push_back(new StubNpcDialogueHandler("PTA_MOM"));
-	_npcHandlers.push_back(new StubNpcDialogueHandler("DWAYNE"));
 	_npcHandlers.push_back(new StubNpcDialogueHandler("EDNA"));
 	_npcHandlers.push_back(new StubNpcDialogueHandler("HERRILL"));
 	_npcHandlers.push_back(new StubNpcDialogueHandler("JOHNSON"));
 }
 
 void DialogueSystem::resetRoomNpcDialogueState() {
+	_dwayneRoomDialogueState = DwayneRoomDialogueState();
 	_hankRoomDialogueState = HankRoomDialogueState();
 	_momRoomDialogueState = MomRoomDialogueState();
 	_jimmyRoomDialogueState = JimmyRoomDialogueState();
 	_waspWomanRoomDialogueState = WaspWomanRoomDialogueState();
+	_sharedBoyleGascanApplicationState = false;
+	_sharedDialogueStateD2f04 = false;
 	_sharedKarinKidnapedDialogueState = false;
 	_sharedDiscussedLodgeTopic = false;
 	_sharedWaspWomanDialogueState = false;
@@ -1354,7 +1362,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 	};
 
 	DialogueRuntime runtime(
-		_engine, *startupScript, *startupText, startupFlow, genericByeTopic,
+		_engine, *startupScript, *startupText, startupFlow, npc.roomName, genericByeTopic,
 		playDialogueLineWithVariant, playDialogueLine, playDialogueEntrySequence,
 		playDialogueFst, runKeywordMenu, runResponseMenu, assignTopicBuffer,
 		matchesResponseLine, matchesAnyResponseLine, queueDialogueInteractionIfNeeded,
@@ -1370,6 +1378,591 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 	return Common::kNoError;
 }
 
+Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName) {
+	static const int kDwayneWhaleyTopicResponseLines[] = { 0x85, 0x86, 0x87 };
+	static const int kDwayneLoomisTopicResponseLines[] = { 0x8c, 0x8d };
+
+	Common::String &dwayneTopicBuffer = _dwayneRoomDialogueState.currentTopicBuffer;
+	int &dwayneTopicBufferLineIndex = _dwayneRoomDialogueState.currentTopicBufferLineIndex;
+	auto assignDwayneTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(dwayneTopicBuffer, dwayneTopicBufferLineIndex,
+			responseLineIndex, "Dwayne topic buffer");
+	};
+	auto playDwayneLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "DWAYNE", headVariant);
+	};
+	auto playEdnaLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "EDNA", headVariant);
+	};
+	auto hasInventoryItem = [&](const char *objectName) {
+		Common::Array<StartupObjectRecord> inventoryObjects;
+		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
+		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
+				return true;
+		}
+
+		return false;
+	};
+	auto setDwayneIntroduced = [&]() {
+		(void)runtime.startupScript().setRuntimeFlagValue("DWAYNE_INTRODUCED", true);
+	};
+	const bool sheriffInDiner = runtime.startupScript().getFlagValue("SHERIFF_IN_DINER");
+
+	if (runtime.startupScript().getFlagValue("ARREST_FLAG")) {
+		(void)runtime.startupScript().setRuntimeFlagValue("ARREST_FLAG", false);
+		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_MOYNAHAN"))
+			return playDwayneLine(0x38d6, 2);
+		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_JIMMY"))
+			return playDwayneLine(0x384d, 2);
+		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_JOHNSON"))
+			return playDwayneLine(0x386d, 2);
+		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_PHELPS"))
+			return playDwayneLine(0x38bc, 2);
+		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_OREILLY"))
+			return playDwayneLine(0x38b5, 2);
+		if (runtime.startupScript().getFlagValue("IF_KILL_POTTSDAM_AT_GRAVE"))
+			return playDwayneLine(0x37ed, 2);
+		if (runtime.startupScript().getFlagValue("GENERIC_BUST"))
+			return playDwayneLine(0x38fc, 2);
+		if (runtime.startupScript().getFlagValue("DNALFT_PERVERT"))
+			return playDwayneLine(0x3580, 2);
+		if (runtime.startupScript().getFlagValue("PC_HAS_GOOJF_CARD") &&
+				!runtime.startupScript().getFlagValue("PC_FRY_IN_CHAIR")) {
+			return playDwayneLine(0x3814);
+		}
+		if (!runtime.startupScript().getFlagValue("PC_FRY_IN_CHAIR")) {
+			if (runtime.startupScript().getFlagValue("BUSTED_THIRD"))
+				return playDwayneLine(0x3928, 2);
+			if (runtime.startupScript().getFlagValue("BUSTED_TWICE"))
+				return playDwayneLine(0x3921, 2);
+			if (runtime.startupScript().getFlagValue("BUSTED_ONCE"))
+				return playDwayneLine(0x3918);
+		}
+		return Common::kNoError;
+	}
+
+	if (runtime.startupScript().getFlagValue("CHECK_EVIDENCE_DOOR"))
+		return playDwayneLine(0x3386);
+	if (runtime.startupScript().getFlagValue("PC_TRIES_TO_TAKE_STEPHANIES_REMAINS"))
+		return playDwayneLine(0x34e3, 2);
+	if (runtime.currentRoomName().equalsIgnoreCase("ST_BEDRM")) {
+		if (!runtime.startupScript().getFlagValue("SD_TALKED_OF_CARD")) {
+			Common::Error lineError = playDwayneLine(0x34da);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		return playDwayneLine(0x3395);
+	}
+
+	if (runtime.startupScript().getFlagValue("BRING_KARIN_TO_SHERIFF") &&
+			!sheriffInDiner &&
+			!_dwayneRoomDialogueState.bringKarinToSheriffLinePlayed) {
+		_dwayneRoomDialogueState.pendingKarinAliveFollowup = true;
+		_dwayneRoomDialogueState.pendingKarinAliveFollowupDayIndex =
+			runtime.startupScript().getCurrentStoryDayIndex();
+		_dwayneRoomDialogueState.bringKarinToSheriffLinePlayed = true;
+		return playDwayneLine(0x3750);
+	}
+	if (runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE")) {
+		_dwayneRoomDialogueState.pendingKarinAliveFollowup = true;
+		_dwayneRoomDialogueState.pendingKarinAliveFollowupDayIndex =
+			runtime.startupScript().getCurrentStoryDayIndex();
+	}
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("BOYLES_BUTTON")) {
+			_dwayneRoomDialogueState.discussedBoylesButton = true;
+			return playDwayneLine(0x339b);
+		}
+		if (usedItemName.equalsIgnoreCase("K_PURSE")) {
+			_dwayneRoomDialogueState.discussedKarinPurse = true;
+			return playDwayneLine(0x37cb);
+		}
+		if (usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY")) {
+			return playDwayneLine(0x33b9, 2);
+		}
+		if (usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("NOTE")) {
+			if (_dwayneRoomDialogueState.presentedEvidenceReplyOverride)
+				return playDwayneLine(0x342c);
+
+			Common::Error lineError = playDwayneLine(0x33f4, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x70, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			lineError = Common::kNoError;
+			if (responseIndex == 1) {
+				lineError = playDwayneLine(0x33c3, 2);
+			} else if (responseIndex == 2) {
+				lineError = playDwayneLine(0x3403, 2);
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			lineError = playDwayneLine(0x3407, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playDwayneLine(0x340d);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			responseIndex = 0;
+			responseError = runtime.runResponseMenu(0x71, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 0)
+				return Common::kNoError;
+			if (responseIndex == 1)
+				return playDwayneLine(0x341a, 2);
+			if (responseIndex == 2)
+				return playDwayneLine(0x3422, 2);
+			return Common::kNoError;
+		}
+		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+					usedItemName.equalsIgnoreCase("LEDGER2")) &&
+				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownLedgersToAnyoneFlag, true);
+			return playDwayneLine(0x3452);
+		}
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
+			return playDwayneLine(0x3975, 3);
+		}
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
+			return playDwayneLine(0x3942, 2);
+		}
+		if (_dwayneRoomDialogueState.tvDeedReplyOverride &&
+				usedItemName.equalsIgnoreCase("TV_DEED")) {
+			return playDwayneLine(0x3686);
+		}
+		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
+				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
+			return playDwayneLine(0x3935);
+		}
+		return playDwayneLine(0x3608);
+	}
+
+	if (_dwayneRoomDialogueState.sheriffInDinerIntroPending && sheriffInDiner) {
+		_dwayneRoomDialogueState.sheriffInDinerIntroPending = false;
+		_dwayneRoomDialogueState.sheriffInDinerIntroPlayed = true;
+		setDwayneIntroduced();
+
+		const DialogueLineEntry introLines[] = {
+			{ 0x3482, "DWAYNE", 0 },
+			{ 0x3486, "PC", 0 },
+			{ 0x348b, "DWAYNE", 1 },
+			{ 0x3490, "PC", 0 }
+		};
+		Common::Error lineError = runtime.playDialogueEntrySequence(introLines, ARRAYSIZE(introLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED")) {
+			_sharedKarinKidnapedDialogueState = true;
+			lineError = playEdnaLine(0x3496, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		lineError = playDwayneLine(0x349b, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		return playDwayneLine(0x34ac);
+	}
+
+	if (_dwayneRoomDialogueState.sheriffInDinerIntroPlayed && sheriffInDiner)
+		return playDwayneLine(0x34ac);
+
+	if (_dwayneRoomDialogueState.pendingInitialConversation && !sheriffInDiner) {
+		_dwayneRoomDialogueState.pendingInitialConversation = false;
+		_dwayneRoomDialogueState.eventFollowupGate = true;
+		setDwayneIntroduced();
+
+		const DialogueLineEntry initialLines[] = {
+			{ 0x3245, "DWAYNE", 1 },
+			{ 0x324a, "LOOMIS", 1 },
+			{ 0x324e, "PC", 0 },
+			{ 0x3252, "DWAYNE", 0 }
+		};
+		Common::Error lineError = runtime.playDialogueEntrySequence(initialLines, ARRAYSIZE(initialLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignDwayneTopicBuffer(0x78);
+		if (_sharedBoyleGascanApplicationState)
+			(void)runtime.startupScript().setRuntimeFlagValue("MOVE_SHERIFF", true);
+	}
+
+	if (_dwayneRoomDialogueState.eventFollowupGate && !_sharedBoyleGascanApplicationState) {
+		Common::Error lineError = playDwayneLine(0x34d3, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (_dwayneRoomDialogueState.whaleyDisciplineFollowupState &&
+			!_dwayneRoomDialogueState.whaleyDisciplineFollowupShown) {
+		_dwayneRoomDialogueState.whaleyDisciplineFollowupShown = true;
+		Common::Error lineError = playDwayneLine(0x34b3, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x79, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		lineError = Common::kNoError;
+		if (responseIndex == 1) {
+			lineError = playDwayneLine(0x34bf, 2);
+		} else if (responseIndex == 2) {
+			lineError = playDwayneLine(0x34c5, 1);
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playDwayneLine(0x34cb, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue(kShownEvidenceOfBlackmailFlag) &&
+			!_dwayneRoomDialogueState.noteCheckbookFollowupShown) {
+		_dwayneRoomDialogueState.noteCheckbookFollowupShown = true;
+		Common::Error lineError = playDwayneLine(0x355f, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (!sheriffInDiner) {
+		if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER") &&
+				!_dwayneRoomDialogueState.scratchedTuckerShown) {
+			_dwayneRoomDialogueState.scratchedTuckerShown = true;
+			Common::Error lineError = playDwayneLine(0x358b, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("BOLT_OF_CLOTH_TAKEN") &&
+				!_dwayneRoomDialogueState.boltOfClothTakenShown) {
+			_dwayneRoomDialogueState.boltOfClothTakenShown = true;
+			Common::Error lineError = playDwayneLine(0x3594, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") &&
+				!_dwayneRoomDialogueState.barberPoleStolenShown) {
+			_dwayneRoomDialogueState.barberPoleStolenShown = true;
+			Common::Error lineError = playDwayneLine(0x359d, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
+				!_dwayneRoomDialogueState.dinerBurnedShown) {
+			_dwayneRoomDialogueState.dinerBurnedShown = true;
+			Common::Error lineError = playDwayneLine(0x35bc, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("PC_ESCAPED_JAIL") &&
+				!_dwayneRoomDialogueState.escapedJailShown) {
+			_dwayneRoomDialogueState.escapedJailShown = true;
+			Common::Error lineError = playDwayneLine(0x35cd);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("GOT_REMAINS_FOR_LODGE") &&
+				!_dwayneRoomDialogueState.gotRemainsForLodgeShown) {
+			_dwayneRoomDialogueState.gotRemainsForLodgeShown = true;
+			Common::Error lineError = playDwayneLine(0x35d7, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
+				!_dwayneRoomDialogueState.burnedTvStationShown) {
+			_dwayneRoomDialogueState.burnedTvStationShown = true;
+			Common::Error lineError = playDwayneLine(0x35f6, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
+				!_dwayneRoomDialogueState.karinKidnapedShown) {
+			_dwayneRoomDialogueState.karinKidnapedShown = true;
+			Common::Error lineError = playDwayneLine(0x36b2, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+	}
+
+	const int currentStoryDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
+	if (_dwayneRoomDialogueState.pendingKarinAliveFollowup &&
+			currentStoryDayIndex != _dwayneRoomDialogueState.pendingKarinAliveFollowupDayIndex &&
+			!_dwayneRoomDialogueState.pendingKarinAliveFollowupLinePlayed) {
+		_dwayneRoomDialogueState.pendingKarinAliveFollowupLinePlayed = true;
+		_dwayneRoomDialogueState.pendingKarinAliveFollowup = false;
+		_dwayneRoomDialogueState.completedKarinAliveFollowup = true;
+
+		const DialogueLineEntry lines[] = {
+			{ 0x3780, "DWAYNE", 0 },
+			{ 0x3786, "PC", 4 },
+			{ 0x378a, "DWAYNE", 2 }
+		};
+		Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
+			!_dwayneRoomDialogueState.discussedKarinPurse &&
+			!sheriffInDiner &&
+			!_dwayneRoomDialogueState.karinFoundDeadWithoutPurseShown) {
+		_dwayneRoomDialogueState.karinFoundDeadWithoutPurseShown = true;
+		Common::Error lineError = playDwayneLine(0x3793);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (!runtime.startupScript().isNamedNpcDeathTypeClear("JIMMY") &&
+			!sheriffInDiner &&
+			!_dwayneRoomDialogueState.jimmyAbsentShown) {
+		_dwayneRoomDialogueState.jimmyAbsentShown = true;
+		Common::Error lineError = playDwayneLine(0x3855, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (runtime.startupScript().getFlagValue("JIMMY_ATTACKED")) {
+			lineError = playDwayneLine(0x37f8);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x81, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			lineError = Common::kNoError;
+			if (responseIndex == 1) {
+				lineError = playDwayneLine(0x3805, 1);
+			} else if (responseIndex == 2) {
+				lineError = playDwayneLine(0x380c);
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+	}
+
+	if (!runtime.startupScript().isNamedNpcDeathTypeClear("MOYNAHAN") &&
+			!sheriffInDiner &&
+			!_dwayneRoomDialogueState.moynahanAbsentShown) {
+		_dwayneRoomDialogueState.moynahanAbsentShown = true;
+		Common::Error lineError = playDwayneLine(0x38ce);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue("EDNA_HUNG") &&
+			!_dwayneRoomDialogueState.ednaHungShown) {
+		_dwayneRoomDialogueState.ednaHungShown = true;
+		Common::Error lineError = playDwayneLine(0x38f1, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (!runtime.startupScript().isNamedNpcDeathTypeClear("MCKNIGHT") &&
+			!sheriffInDiner &&
+			!_dwayneRoomDialogueState.mcknightAbsentShown) {
+		_dwayneRoomDialogueState.mcknightAbsentShown = true;
+		Common::Error lineError = hasInventoryItem("TV_DEED")
+			? playDwayneLine(0x3904, 3)
+			: playDwayneLine(0x390f, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	Common::String selectedTopic;
+	for (;;) {
+		if (runtime.matchesResponseLine(selectedTopic, 0x82))
+			return playDwayneLine(0x3395);
+
+		Common::Error menuError = runtime.runKeywordMenu(
+			dwayneTopicBuffer, dwayneTopicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.empty())
+			return Common::kNoError;
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x83)) {
+			Common::Error lineError = playDwayneLine(0x326e);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignDwayneTopicBuffer(0x84);
+			continue;
+		}
+
+		if (runtime.matchesAnyResponseLine(selectedTopic, kDwayneWhaleyTopicResponseLines,
+				ARRAYSIZE(kDwayneWhaleyTopicResponseLines))) {
+			if (!sheriffInDiner) {
+				const DialogueLineEntry lines[] = {
+					{ 0x327b, "DWAYNE", 0 },
+					{ 0x3280, "PC", 2 },
+					{ 0x3285, "DWAYNE", 0 },
+					{ 0x328b, "LOOMIS", 1 },
+					{ 0x3290, "DWAYNE", 2 },
+					{ 0x3294, "LOOMIS", 3 },
+					{ 0x3299, "PC", 4 },
+					{ 0x329e, "DWAYNE", 1 }
+				};
+				Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				_sharedDialogueStateD2f04 = true;
+				assignDwayneTopicBuffer(0x89);
+			}
+			continue;
+		}
+
+		if (runtime.matchesAnyResponseLine(selectedTopic, kDwayneLoomisTopicResponseLines,
+				ARRAYSIZE(kDwayneLoomisTopicResponseLines))) {
+			if (!sheriffInDiner) {
+				const DialogueLineEntry lines[] = {
+					{ 0x32bf, "DWAYNE", 1 },
+					{ 0x32c1, "DWAYNE", 0 },
+					{ 0x32c7, "LOOMIS", 3 },
+					{ 0x32cb, "DWAYNE", 0 },
+					{ 0x32d1, "DWAYNE", 1 }
+				};
+				Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignDwayneTopicBuffer(0x8e);
+			}
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x8f)) {
+			const DialogueLineEntry lines[] = {
+				{ 0x32db, "DWAYNE", 0 },
+				{ 0x32e2, "PC", 4 },
+				{ 0x32e6, "DWAYNE", 0 }
+			};
+			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignDwayneTopicBuffer(0x90);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x91)) {
+			const DialogueLineEntry lines[] = {
+				{ 0x32f2, "DWAYNE", 0 },
+				{ 0x32f9, "PC", 0 },
+				{ 0x32fd, "DWAYNE", 0 },
+				{ 0x32fe, "DWAYNE", 0 },
+				{ 0x32ff, "DWAYNE", 0 }
+			};
+			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignDwayneTopicBuffer(0x92);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x93)) {
+			if (!runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED")) {
+				Common::Error lineError = playDwayneLine(0x330d, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			const DialogueLineEntry lines[] = {
+				{ 0x331f, "DWAYNE", 3 },
+				{ 0x3320, "DWAYNE", 0 },
+				{ 0x3321, "DWAYNE", 0 },
+				{ 0x332c, "DWAYNE", 0 }
+			};
+			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignDwayneTopicBuffer(0x94);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x95)) {
+			_sharedDiscussedLodgeTopic = true;
+			Common::Error lineError = playDwayneLine(0x3339);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x96, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			lineError = Common::kNoError;
+			if (responseIndex == 1) {
+				const DialogueLineEntry responseLines[] = {
+					{ 0x3345, "DWAYNE", 2 },
+					{ 0x3346, "DWAYNE", 2 },
+					{ 0x3347, "DWAYNE", 2 }
+				};
+				lineError = runtime.playDialogueEntrySequence(responseLines, ARRAYSIZE(responseLines));
+			} else if (responseIndex == 2) {
+				lineError = playDwayneLine(0x3350);
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignDwayneTopicBuffer(0x97);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x98)) {
+			const DialogueLineEntry lines[] = {
+				{ 0x335b, "DWAYNE", 2 },
+				{ 0x3360, "PC", 2 },
+				{ 0x3364, "DWAYNE", 3 }
+			};
+			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = _sharedDialogueStateD2f04
+				? playDwayneLine(0x3368, 2)
+				: playDwayneLine(0x336c, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playDwayneLine(0x3370, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignDwayneTopicBuffer(0x99);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x9a)) {
+			Common::Error lineError = playDwayneLine(0x337a, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignDwayneTopicBuffer(0x9b);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x9c)) {
+			const DialogueLineEntry lines[] = {
+				{ 0x3386, "DWAYNE", 0 },
+				{ 0x3387, "DWAYNE", 0 }
+			};
+			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignDwayneTopicBuffer(0x9d);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x9e))
+			continue;
+
+		Common::Error lineError = playDwayneLine(0x393c);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+}
+
 Common::Error DialogueSystem::handleJimmyDialogue(DialogueRuntime &runtime, const Common::String &usedItemName) {
 	auto playJimmyLine = [&](int wavId, int headVariant) -> Common::Error {
 		return runtime.playDialogueLineWithVariant(wavId, "JIMMY", headVariant);
diff --git a/engines/harvester/dialogue.h b/engines/harvester/dialogue.h
index 5bfaf8358fd..ee3e7aac7fb 100644
--- a/engines/harvester/dialogue.h
+++ b/engines/harvester/dialogue.h
@@ -34,6 +34,7 @@ struct Event;
 
 namespace Harvester {
 
+class DwayneDialogueHandler;
 class HankDialogueHandler;
 class HarvesterEngine;
 class JimmyDialogueHandler;
@@ -48,6 +49,40 @@ struct IndexedBitmap;
 
 class DialogueSystem {
 public:
+	struct DwayneRoomDialogueState {
+		bool pendingInitialConversation = true;
+		bool sheriffInDinerIntroPending = true;
+		bool sheriffInDinerIntroPlayed = false;
+		bool eventFollowupGate = false;
+		bool tvDeedReplyOverride = false;
+		bool presentedEvidenceReplyOverride = false;
+		bool discussedBoylesButton = false;
+		bool discussedKarinPurse = false;
+		bool pendingKarinAliveFollowup = false;
+		bool completedKarinAliveFollowup = false;
+		int pendingKarinAliveFollowupDayIndex = 0;
+		bool bringKarinToSheriffLinePlayed = false;
+		bool whaleyDisciplineFollowupState = false;
+		bool whaleyDisciplineFollowupShown = false;
+		bool noteCheckbookFollowupShown = false;
+		bool scratchedTuckerShown = false;
+		bool boltOfClothTakenShown = false;
+		bool barberPoleStolenShown = false;
+		bool dinerBurnedShown = false;
+		bool escapedJailShown = false;
+		bool gotRemainsForLodgeShown = false;
+		bool burnedTvStationShown = false;
+		bool karinKidnapedShown = false;
+		bool pendingKarinAliveFollowupLinePlayed = false;
+		bool karinFoundDeadWithoutPurseShown = false;
+		bool jimmyAbsentShown = false;
+		bool moynahanAbsentShown = false;
+		bool ednaHungShown = false;
+		bool mcknightAbsentShown = false;
+		Common::String currentTopicBuffer;
+		int currentTopicBufferLineIndex = -1;
+	};
+
 	struct HankRoomDialogueState {
 		bool pendingInitialConversation = true;
 		bool hasTrackedDayState = false;
@@ -109,6 +144,7 @@ public:
 		StartupFlow &startupFlow);
 
 private:
+	friend class DwayneDialogueHandler;
 	friend class HankDialogueHandler;
 	friend class JimmyDialogueHandler;
 	friend class MomDialogueHandler;
@@ -117,6 +153,7 @@ private:
 
 	void registerNpcHandlers();
 
+	Common::Error handleDwayneDialogue(DialogueRuntime &runtime, const Common::String &usedItemName);
 	Common::Error handleJimmyDialogue(DialogueRuntime &runtime, const Common::String &usedItemName);
 	Common::Error handleWaspWomanDialogue(DialogueRuntime &runtime, const Common::String &usedItemName);
 	Common::Error handleMomDialogue(DialogueRuntime &runtime, const Common::String &usedItemName);
@@ -125,10 +162,13 @@ private:
 
 	HarvesterEngine &_engine;
 	Common::Point &_mousePos;
+	DwayneRoomDialogueState _dwayneRoomDialogueState;
 	HankRoomDialogueState _hankRoomDialogueState;
 	MomRoomDialogueState _momRoomDialogueState;
 	JimmyRoomDialogueState _jimmyRoomDialogueState;
 	WaspWomanRoomDialogueState _waspWomanRoomDialogueState;
+	bool _sharedBoyleGascanApplicationState = false;
+	bool _sharedDialogueStateD2f04 = false;
 	bool _sharedKarinKidnapedDialogueState = false;
 	bool _sharedDiscussedLodgeTopic = false;
 	bool _sharedWaspWomanDialogueState = false;
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index b20705db318..562e7088757 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -7,6 +7,7 @@ MODULE_OBJS = \
 	dialogue.o \
 	fst_player.o \
 	metaengine.o \
+	npc/dwayne_dialogue.o \
 	npc/hank_dialogue.o \
 	npc/jimmy_dialogue.o \
 	npc/mom_dialogue.o \
diff --git a/engines/harvester/npc/dwayne_dialogue.cpp b/engines/harvester/npc/dwayne_dialogue.cpp
new file mode 100644
index 00000000000..afc0655ae00
--- /dev/null
+++ b/engines/harvester/npc/dwayne_dialogue.cpp
@@ -0,0 +1,36 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/dialogue.h"
+#include "harvester/npc/dwayne_dialogue.h"
+
+namespace Harvester {
+
+bool DwayneDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("DWAYNE");
+}
+
+Common::Error DwayneDialogueHandler::handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
+		const Common::String &usedItemName) {
+	return dialogue.handleDwayneDialogue(runtime, usedItemName);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/dwayne_dialogue.h b/engines/harvester/npc/dwayne_dialogue.h
new file mode 100644
index 00000000000..c34d820423b
--- /dev/null
+++ b/engines/harvester/npc/dwayne_dialogue.h
@@ -0,0 +1,38 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_DWAYNE_DIALOGUE_H
+#define HARVESTER_NPC_DWAYNE_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class DwayneDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	Common::Error handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
+		const Common::String &usedItemName) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_DWAYNE_DIALOGUE_H


Commit: cfb0deb03906693501b36bcbf2c4929764b55c52
    https://github.com/scummvm/scummvm/commit/cfb0deb03906693501b36bcbf2c4929764b55c52
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:57+02:00

Commit Message:
HARVESTER: Move npc dialogue state into handlers

Changed paths:
    engines/harvester/dialogue.cpp
    engines/harvester/dialogue.h
    engines/harvester/npc/dialogue_handler.h
    engines/harvester/npc/dwayne_dialogue.cpp
    engines/harvester/npc/dwayne_dialogue.h
    engines/harvester/npc/hank_dialogue.cpp
    engines/harvester/npc/hank_dialogue.h
    engines/harvester/npc/jimmy_dialogue.cpp
    engines/harvester/npc/jimmy_dialogue.h
    engines/harvester/npc/mom_dialogue.cpp
    engines/harvester/npc/mom_dialogue.h
    engines/harvester/npc/pta_mom_dialogue.cpp
    engines/harvester/npc/pta_mom_dialogue.h
    engines/harvester/npc/stub_dialogue.cpp
    engines/harvester/npc/stub_dialogue.h
    engines/harvester/npc/wasp_woman_dialogue.cpp
    engines/harvester/npc/wasp_woman_dialogue.h


diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 137ba92b312..c89b0f8732c 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -69,6 +69,7 @@ public:
 	typedef std::function<bool(const Common::String &, int)> MatchesResponseLineFn;
 	typedef std::function<bool(const Common::String &, const int *, uint)> MatchesAnyResponseLineFn;
 	typedef std::function<void(const StartupInteractionResult &)> QueueDialogueInteractionIfNeededFn;
+	typedef std::function<void(StartupInteractionResult &)> ApplyImmediateDialogueInteractionEffectsFn;
 	typedef std::function<int(int)> GetRandomNumberFn;
 	typedef std::function<void(const Common::String &, int)> SetActiveSpeakerPortraitFn;
 
@@ -85,6 +86,7 @@ public:
 			const MatchesResponseLineFn &matchesResponseLine,
 			const MatchesAnyResponseLineFn &matchesAnyResponseLine,
 			const QueueDialogueInteractionIfNeededFn &queueDialogueInteractionIfNeeded,
+			const ApplyImmediateDialogueInteractionEffectsFn &applyImmediateDialogueInteractionEffects,
 			const GetRandomNumberFn &getRandomNumber,
 			const SetActiveSpeakerPortraitFn &setActiveSpeakerPortrait)
 		: _engine(engine), _startupScript(startupScript), _startupText(startupText),
@@ -97,6 +99,7 @@ public:
 		  _matchesResponseLine(matchesResponseLine),
 		  _matchesAnyResponseLine(matchesAnyResponseLine),
 		  _queueDialogueInteractionIfNeeded(queueDialogueInteractionIfNeeded),
+		  _applyImmediateDialogueInteractionEffects(applyImmediateDialogueInteractionEffects),
 		  _getRandomNumber(getRandomNumber),
 		  _setActiveSpeakerPortrait(setActiveSpeakerPortrait) {
 	}
@@ -140,6 +143,9 @@ public:
 	void queueDialogueInteractionIfNeeded(const StartupInteractionResult &interaction) const {
 		_queueDialogueInteractionIfNeeded(interaction);
 	}
+	void applyImmediateDialogueInteractionEffects(StartupInteractionResult &interaction) const {
+		_applyImmediateDialogueInteractionEffects(interaction);
+	}
 	int getRandomNumber(int maxValue) const { return _getRandomNumber(maxValue); }
 	void setActiveSpeakerPortrait(const Common::String &speakerId, int headVariant) const {
 		_setActiveSpeakerPortrait(speakerId, headVariant);
@@ -162,6 +168,7 @@ private:
 	MatchesResponseLineFn _matchesResponseLine;
 	MatchesAnyResponseLineFn _matchesAnyResponseLine;
 	QueueDialogueInteractionIfNeededFn _queueDialogueInteractionIfNeeded;
+	ApplyImmediateDialogueInteractionEffectsFn _applyImmediateDialogueInteractionEffects;
 	GetRandomNumberFn _getRandomNumber;
 	SetActiveSpeakerPortraitFn _setActiveSpeakerPortrait;
 };
@@ -749,16 +756,9 @@ void DialogueSystem::registerNpcHandlers() {
 }
 
 void DialogueSystem::resetRoomNpcDialogueState() {
-	_dwayneRoomDialogueState = DwayneRoomDialogueState();
-	_hankRoomDialogueState = HankRoomDialogueState();
-	_momRoomDialogueState = MomRoomDialogueState();
-	_jimmyRoomDialogueState = JimmyRoomDialogueState();
-	_waspWomanRoomDialogueState = WaspWomanRoomDialogueState();
-	_sharedBoyleGascanApplicationState = false;
-	_sharedDialogueStateD2f04 = false;
-	_sharedKarinKidnapedDialogueState = false;
-	_sharedDiscussedLodgeTopic = false;
-	_sharedWaspWomanDialogueState = false;
+	_sharedDialogueState = DialogueSharedState();
+	for (uint i = 0; i < _npcHandlers.size(); ++i)
+		_npcHandlers[i]->resetState();
 }
 
 Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
@@ -1087,6 +1087,14 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			startupFlow.queueDialogueInteraction(interaction);
 		}
 	};
+	auto applyImmediateDialogueInteractionEffects = [&](StartupInteractionResult &interaction) {
+		if (!interaction.musicPath.empty())
+			(void)_engine.playStartupMusic(interaction.musicPath);
+		if (!interaction.audioCommands.empty())
+			startupFlow.executeStartupAudioCommands(interaction.audioCommands);
+		interaction.musicPath.clear();
+		interaction.audioCommands.clear();
+	};
 
 	auto buildResponseMenuLayout = [&](const Common::String &responseLine,
 			Common::Array<DialogueResponseOptionLayout> &options, uint &totalRows) {
@@ -1366,25 +1374,27 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		playDialogueLineWithVariant, playDialogueLine, playDialogueEntrySequence,
 		playDialogueFst, runKeywordMenu, runResponseMenu, assignTopicBuffer,
 		matchesResponseLine, matchesAnyResponseLine, queueDialogueInteractionIfNeeded,
+		applyImmediateDialogueInteractionEffects,
 		[&](int maxValue) { return _engine.getRandomNumber(maxValue); },
 		setActiveSpeakerPortrait);
 
 	for (uint i = 0; i < _npcHandlers.size(); ++i) {
 		if (_npcHandlers[i]->matchesNpc(npc.npcName))
-			return _npcHandlers[i]->handle(*this, runtime, usedItemName);
+			return _npcHandlers[i]->handleDialogue(runtime, usedItemName, _sharedDialogueState);
 	}
 
 	debug(1, "Harvester: unsupported room NPC dialogue handler '%s'", npc.npcName.c_str());
 	return Common::kNoError;
 }
 
-Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName) {
+Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	static const int kDwayneWhaleyTopicResponseLines[] = { 0x85, 0x86, 0x87 };
 	static const int kDwayneLoomisTopicResponseLines[] = { 0x8c, 0x8d };
 
-	Common::String &dwayneTopicBuffer = _dwayneRoomDialogueState.currentTopicBuffer;
-	int &dwayneTopicBufferLineIndex = _dwayneRoomDialogueState.currentTopicBufferLineIndex;
+	DwayneRoomDialogueState &state = _state;
+	Common::String &dwayneTopicBuffer = state.currentTopicBuffer;
+	int &dwayneTopicBufferLineIndex = state.currentTopicBufferLineIndex;
 	auto assignDwayneTopicBuffer = [&](int responseLineIndex) {
 		runtime.assignTopicBuffer(dwayneTopicBuffer, dwayneTopicBufferLineIndex,
 			responseLineIndex, "Dwayne topic buffer");
@@ -1458,26 +1468,26 @@ Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
 
 	if (runtime.startupScript().getFlagValue("BRING_KARIN_TO_SHERIFF") &&
 			!sheriffInDiner &&
-			!_dwayneRoomDialogueState.bringKarinToSheriffLinePlayed) {
-		_dwayneRoomDialogueState.pendingKarinAliveFollowup = true;
-		_dwayneRoomDialogueState.pendingKarinAliveFollowupDayIndex =
+			!state.bringKarinToSheriffLinePlayed) {
+		state.pendingKarinAliveFollowup = true;
+		state.pendingKarinAliveFollowupDayIndex =
 			runtime.startupScript().getCurrentStoryDayIndex();
-		_dwayneRoomDialogueState.bringKarinToSheriffLinePlayed = true;
+		state.bringKarinToSheriffLinePlayed = true;
 		return playDwayneLine(0x3750);
 	}
 	if (runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE")) {
-		_dwayneRoomDialogueState.pendingKarinAliveFollowup = true;
-		_dwayneRoomDialogueState.pendingKarinAliveFollowupDayIndex =
+		state.pendingKarinAliveFollowup = true;
+		state.pendingKarinAliveFollowupDayIndex =
 			runtime.startupScript().getCurrentStoryDayIndex();
 	}
 
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("BOYLES_BUTTON")) {
-			_dwayneRoomDialogueState.discussedBoylesButton = true;
+			state.discussedBoylesButton = true;
 			return playDwayneLine(0x339b);
 		}
 		if (usedItemName.equalsIgnoreCase("K_PURSE")) {
-			_dwayneRoomDialogueState.discussedKarinPurse = true;
+			state.discussedKarinPurse = true;
 			return playDwayneLine(0x37cb);
 		}
 		if (usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY") ||
@@ -1486,7 +1496,7 @@ Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
 		}
 		if (usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 				usedItemName.equalsIgnoreCase("NOTE")) {
-			if (_dwayneRoomDialogueState.presentedEvidenceReplyOverride)
+			if (state.presentedEvidenceReplyOverride)
 				return playDwayneLine(0x342c);
 
 			Common::Error lineError = playDwayneLine(0x33f4, 2);
@@ -1540,7 +1550,7 @@ Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
 			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
 			return playDwayneLine(0x3942, 2);
 		}
-		if (_dwayneRoomDialogueState.tvDeedReplyOverride &&
+		if (state.tvDeedReplyOverride &&
 				usedItemName.equalsIgnoreCase("TV_DEED")) {
 			return playDwayneLine(0x3686);
 		}
@@ -1551,9 +1561,9 @@ Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
 		return playDwayneLine(0x3608);
 	}
 
-	if (_dwayneRoomDialogueState.sheriffInDinerIntroPending && sheriffInDiner) {
-		_dwayneRoomDialogueState.sheriffInDinerIntroPending = false;
-		_dwayneRoomDialogueState.sheriffInDinerIntroPlayed = true;
+	if (state.sheriffInDinerIntroPending && sheriffInDiner) {
+		state.sheriffInDinerIntroPending = false;
+		state.sheriffInDinerIntroPlayed = true;
 		setDwayneIntroduced();
 
 		const DialogueLineEntry introLines[] = {
@@ -1566,7 +1576,7 @@ Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 		if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED")) {
-			_sharedKarinKidnapedDialogueState = true;
+			sharedState.karinKidnapedDialogueState = true;
 			lineError = playEdnaLine(0x3496, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
@@ -1577,12 +1587,12 @@ Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
 		return playDwayneLine(0x34ac);
 	}
 
-	if (_dwayneRoomDialogueState.sheriffInDinerIntroPlayed && sheriffInDiner)
+	if (state.sheriffInDinerIntroPlayed && sheriffInDiner)
 		return playDwayneLine(0x34ac);
 
-	if (_dwayneRoomDialogueState.pendingInitialConversation && !sheriffInDiner) {
-		_dwayneRoomDialogueState.pendingInitialConversation = false;
-		_dwayneRoomDialogueState.eventFollowupGate = true;
+	if (state.pendingInitialConversation && !sheriffInDiner) {
+		state.pendingInitialConversation = false;
+		state.eventFollowupGate = true;
 		setDwayneIntroduced();
 
 		const DialogueLineEntry initialLines[] = {
@@ -1595,19 +1605,19 @@ Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 		assignDwayneTopicBuffer(0x78);
-		if (_sharedBoyleGascanApplicationState)
+		if (sharedState.boyleGascanApplicationState)
 			(void)runtime.startupScript().setRuntimeFlagValue("MOVE_SHERIFF", true);
 	}
 
-	if (_dwayneRoomDialogueState.eventFollowupGate && !_sharedBoyleGascanApplicationState) {
+	if (state.eventFollowupGate && !sharedState.boyleGascanApplicationState) {
 		Common::Error lineError = playDwayneLine(0x34d3, 2);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 
-	if (_dwayneRoomDialogueState.whaleyDisciplineFollowupState &&
-			!_dwayneRoomDialogueState.whaleyDisciplineFollowupShown) {
-		_dwayneRoomDialogueState.whaleyDisciplineFollowupShown = true;
+	if (state.whaleyDisciplineFollowupState &&
+			!state.whaleyDisciplineFollowupShown) {
+		state.whaleyDisciplineFollowupShown = true;
 		Common::Error lineError = playDwayneLine(0x34b3, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
@@ -1630,8 +1640,8 @@ Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
 	}
 
 	if (runtime.startupScript().getFlagValue(kShownEvidenceOfBlackmailFlag) &&
-			!_dwayneRoomDialogueState.noteCheckbookFollowupShown) {
-		_dwayneRoomDialogueState.noteCheckbookFollowupShown = true;
+			!state.noteCheckbookFollowupShown) {
+		state.noteCheckbookFollowupShown = true;
 		Common::Error lineError = playDwayneLine(0x355f, 2);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
@@ -1639,57 +1649,57 @@ Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
 
 	if (!sheriffInDiner) {
 		if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER") &&
-				!_dwayneRoomDialogueState.scratchedTuckerShown) {
-			_dwayneRoomDialogueState.scratchedTuckerShown = true;
+				!state.scratchedTuckerShown) {
+			state.scratchedTuckerShown = true;
 			Common::Error lineError = playDwayneLine(0x358b, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 		if (runtime.startupScript().getFlagValue("BOLT_OF_CLOTH_TAKEN") &&
-				!_dwayneRoomDialogueState.boltOfClothTakenShown) {
-			_dwayneRoomDialogueState.boltOfClothTakenShown = true;
+				!state.boltOfClothTakenShown) {
+			state.boltOfClothTakenShown = true;
 			Common::Error lineError = playDwayneLine(0x3594, 2);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 		if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") &&
-				!_dwayneRoomDialogueState.barberPoleStolenShown) {
-			_dwayneRoomDialogueState.barberPoleStolenShown = true;
+				!state.barberPoleStolenShown) {
+			state.barberPoleStolenShown = true;
 			Common::Error lineError = playDwayneLine(0x359d, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 		if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
-				!_dwayneRoomDialogueState.dinerBurnedShown) {
-			_dwayneRoomDialogueState.dinerBurnedShown = true;
+				!state.dinerBurnedShown) {
+			state.dinerBurnedShown = true;
 			Common::Error lineError = playDwayneLine(0x35bc, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 		if (runtime.startupScript().getFlagValue("PC_ESCAPED_JAIL") &&
-				!_dwayneRoomDialogueState.escapedJailShown) {
-			_dwayneRoomDialogueState.escapedJailShown = true;
+				!state.escapedJailShown) {
+			state.escapedJailShown = true;
 			Common::Error lineError = playDwayneLine(0x35cd);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 		if (runtime.startupScript().getFlagValue("GOT_REMAINS_FOR_LODGE") &&
-				!_dwayneRoomDialogueState.gotRemainsForLodgeShown) {
-			_dwayneRoomDialogueState.gotRemainsForLodgeShown = true;
+				!state.gotRemainsForLodgeShown) {
+			state.gotRemainsForLodgeShown = true;
 			Common::Error lineError = playDwayneLine(0x35d7, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 		if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
-				!_dwayneRoomDialogueState.burnedTvStationShown) {
-			_dwayneRoomDialogueState.burnedTvStationShown = true;
+				!state.burnedTvStationShown) {
+			state.burnedTvStationShown = true;
 			Common::Error lineError = playDwayneLine(0x35f6, 2);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 		if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
-				!_dwayneRoomDialogueState.karinKidnapedShown) {
-			_dwayneRoomDialogueState.karinKidnapedShown = true;
+				!state.karinKidnapedShown) {
+			state.karinKidnapedShown = true;
 			Common::Error lineError = playDwayneLine(0x36b2, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
@@ -1697,12 +1707,12 @@ Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
 	}
 
 	const int currentStoryDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
-	if (_dwayneRoomDialogueState.pendingKarinAliveFollowup &&
-			currentStoryDayIndex != _dwayneRoomDialogueState.pendingKarinAliveFollowupDayIndex &&
-			!_dwayneRoomDialogueState.pendingKarinAliveFollowupLinePlayed) {
-		_dwayneRoomDialogueState.pendingKarinAliveFollowupLinePlayed = true;
-		_dwayneRoomDialogueState.pendingKarinAliveFollowup = false;
-		_dwayneRoomDialogueState.completedKarinAliveFollowup = true;
+	if (state.pendingKarinAliveFollowup &&
+			currentStoryDayIndex != state.pendingKarinAliveFollowupDayIndex &&
+			!state.pendingKarinAliveFollowupLinePlayed) {
+		state.pendingKarinAliveFollowupLinePlayed = true;
+		state.pendingKarinAliveFollowup = false;
+		state.completedKarinAliveFollowup = true;
 
 		const DialogueLineEntry lines[] = {
 			{ 0x3780, "DWAYNE", 0 },
@@ -1715,10 +1725,10 @@ Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
 	}
 
 	if (runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
-			!_dwayneRoomDialogueState.discussedKarinPurse &&
+			!state.discussedKarinPurse &&
 			!sheriffInDiner &&
-			!_dwayneRoomDialogueState.karinFoundDeadWithoutPurseShown) {
-		_dwayneRoomDialogueState.karinFoundDeadWithoutPurseShown = true;
+			!state.karinFoundDeadWithoutPurseShown) {
+		state.karinFoundDeadWithoutPurseShown = true;
 		Common::Error lineError = playDwayneLine(0x3793);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
@@ -1726,8 +1736,8 @@ Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
 
 	if (!runtime.startupScript().isNamedNpcDeathTypeClear("JIMMY") &&
 			!sheriffInDiner &&
-			!_dwayneRoomDialogueState.jimmyAbsentShown) {
-		_dwayneRoomDialogueState.jimmyAbsentShown = true;
+			!state.jimmyAbsentShown) {
+		state.jimmyAbsentShown = true;
 		Common::Error lineError = playDwayneLine(0x3855, 3);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
@@ -1753,16 +1763,16 @@ Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
 
 	if (!runtime.startupScript().isNamedNpcDeathTypeClear("MOYNAHAN") &&
 			!sheriffInDiner &&
-			!_dwayneRoomDialogueState.moynahanAbsentShown) {
-		_dwayneRoomDialogueState.moynahanAbsentShown = true;
+			!state.moynahanAbsentShown) {
+		state.moynahanAbsentShown = true;
 		Common::Error lineError = playDwayneLine(0x38ce);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 
 	if (runtime.startupScript().getFlagValue("EDNA_HUNG") &&
-			!_dwayneRoomDialogueState.ednaHungShown) {
-		_dwayneRoomDialogueState.ednaHungShown = true;
+			!state.ednaHungShown) {
+		state.ednaHungShown = true;
 		Common::Error lineError = playDwayneLine(0x38f1, 3);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
@@ -1770,8 +1780,8 @@ Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
 
 	if (!runtime.startupScript().isNamedNpcDeathTypeClear("MCKNIGHT") &&
 			!sheriffInDiner &&
-			!_dwayneRoomDialogueState.mcknightAbsentShown) {
-		_dwayneRoomDialogueState.mcknightAbsentShown = true;
+			!state.mcknightAbsentShown) {
+		state.mcknightAbsentShown = true;
 		Common::Error lineError = hasInventoryItem("TV_DEED")
 			? playDwayneLine(0x3904, 3)
 			: playDwayneLine(0x390f, 1);
@@ -1815,7 +1825,7 @@ Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
 				Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
-				_sharedDialogueStateD2f04 = true;
+				sharedState.dialogueStateD2f04 = true;
 				assignDwayneTopicBuffer(0x89);
 			}
 			continue;
@@ -1887,7 +1897,7 @@ Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
 		}
 
 		if (runtime.matchesResponseLine(selectedTopic, 0x95)) {
-			_sharedDiscussedLodgeTopic = true;
+			sharedState.discussedLodgeTopic = true;
 			Common::Error lineError = playDwayneLine(0x3339);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
@@ -1922,7 +1932,7 @@ Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
 			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			lineError = _sharedDialogueStateD2f04
+			lineError = sharedState.dialogueStateD2f04
 				? playDwayneLine(0x3368, 2)
 				: playDwayneLine(0x336c, 2);
 			if (lineError.getCode() != Common::kNoError)
@@ -1963,7 +1973,10 @@ Common::Error DialogueSystem::handleDwayneDialogue(DialogueRuntime &runtime,
 	}
 }
 
-Common::Error DialogueSystem::handleJimmyDialogue(DialogueRuntime &runtime, const Common::String &usedItemName) {
+Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
+	JimmyRoomDialogueState &state = _state;
+
 	auto playJimmyLine = [&](int wavId, int headVariant) -> Common::Error {
 		return runtime.playDialogueLineWithVariant(wavId, "JIMMY", headVariant);
 	};
@@ -2011,8 +2024,8 @@ Common::Error DialogueSystem::handleJimmyDialogue(DialogueRuntime &runtime, cons
 			}
 			runtime.queueDialogueInteractionIfNeeded(jimmyInteraction);
 
-			if (!_jimmyRoomDialogueState.paperHandoffStateSet) {
-				_jimmyRoomDialogueState.paperHandoffStateSet = true;
+			if (!state.paperHandoffStateSet) {
+				state.paperHandoffStateSet = true;
 				Common::Error lineError = playJimmyLine(0x4a4c, 1);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
@@ -2055,9 +2068,9 @@ Common::Error DialogueSystem::handleJimmyDialogue(DialogueRuntime &runtime, cons
 		return playJimmyLine(0x4af2, 0);
 	}
 
-	if (_jimmyRoomDialogueState.firstNoItemLinePending) {
-		_jimmyRoomDialogueState.firstNoItemLinePending = false;
-		if (!_jimmyRoomDialogueState.paperHandoffStateSet) {
+	if (state.firstNoItemLinePending) {
+		state.firstNoItemLinePending = false;
+		if (!state.paperHandoffStateSet) {
 			Common::Error lineError = playJimmyLine(0x4a4c, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
@@ -2091,8 +2104,8 @@ Common::Error DialogueSystem::handleJimmyDialogue(DialogueRuntime &runtime, cons
 	return playJimmyLine(0x4b38, 0);
 }
 
-Common::Error DialogueSystem::handlePtaMomDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName) {
+Common::Error PtaMomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
 	(void)usedItemName;
 
 	auto playPtaMomLine = [&](int wavId, int headVariant) -> Common::Error {
@@ -2140,8 +2153,9 @@ Common::Error DialogueSystem::handlePtaMomDialogue(DialogueRuntime &runtime,
 	}
 }
 
-Common::Error DialogueSystem::handleWaspWomanDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName) {
+Common::Error WaspWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	WaspWomanRoomDialogueState &state = _state;
 	Common::String waspWomanTopicBuffer;
 	int waspWomanTopicBufferLineIndex = -1;
 	auto assignWaspWomanTopicBuffer = [&](int responseLineIndex) {
@@ -2174,13 +2188,13 @@ Common::Error DialogueSystem::handleWaspWomanDialogue(DialogueRuntime &runtime,
 		return playWaspWomanLine(0x4ca0, 0);
 	}
 
-	if (_waspWomanRoomDialogueState.introPending) {
-		_waspWomanRoomDialogueState.introPending = false;
+	if (state.introPending) {
+		state.introPending = false;
 
 		Common::Error lineError = playWaspWomanLine(0x4bee, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		if (_sharedWaspWomanDialogueState) {
+		if (sharedState.waspWomanDialogueState) {
 			lineError = runtime.playDialogueLine(0x4bf2, "PC");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
@@ -2215,7 +2229,7 @@ Common::Error DialogueSystem::handleWaspWomanDialogue(DialogueRuntime &runtime,
 			lineError = playWaspWomanLine(0x4c31, 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			if (!_sharedWaspWomanDialogueState)
+			if (!sharedState.waspWomanDialogueState)
 				continue;
 
 			int responseIndex = 0;
@@ -2266,9 +2280,10 @@ Common::Error DialogueSystem::handleWaspWomanDialogue(DialogueRuntime &runtime,
 	}
 }
 
-Common::Error DialogueSystem::handleMomDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName) {
+Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	const int currentStoryDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
+	MomRoomDialogueState &state = _state;
 	Common::String momTopicBuffer;
 	int momTopicBufferLineIndex = -1;
 	auto assignMomTopicBuffer = [&](int responseLineIndex) {
@@ -2317,34 +2332,34 @@ Common::Error DialogueSystem::handleMomDialogue(DialogueRuntime &runtime,
 	}
 
 	bool skipMomDefaultKeywordSeed = false;
-	if (_momRoomDialogueState.introPending) {
-		_momRoomDialogueState.introPending = false;
-		_momRoomDialogueState.sameDayIntroLineEnabled = true;
-		_momRoomDialogueState.postIntroDefaultLineEnabled = true;
-		_momRoomDialogueState.introDayIndex = currentStoryDayIndex;
+	if (state.introPending) {
+		state.introPending = false;
+		state.sameDayIntroLineEnabled = true;
+		state.postIntroDefaultLineEnabled = true;
+		state.introDayIndex = currentStoryDayIndex;
 		Common::Error lineError = runtime.playDialogueEntrySequence(kMomIntroLines, ARRAYSIZE(kMomIntroLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 		assignMomTopicBuffer(kMomInitialTopicBufferResponseLine);
 		skipMomDefaultKeywordSeed = true;
 	}
-	if (!skipMomDefaultKeywordSeed && _momRoomDialogueState.sameDayIntroLineEnabled &&
-			currentStoryDayIndex == _momRoomDialogueState.introDayIndex) {
+	if (!skipMomDefaultKeywordSeed && state.sameDayIntroLineEnabled &&
+			currentStoryDayIndex == state.introDayIndex) {
 		Common::Error lineError = runtime.playDialogueLine(0x2047, "MOM");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 		assignMomTopicBuffer(kMomSameDayTopicBufferResponseLine);
 		skipMomDefaultKeywordSeed = true;
 	}
-	if (!skipMomDefaultKeywordSeed && _momRoomDialogueState.postIntroDefaultLineEnabled) {
+	if (!skipMomDefaultKeywordSeed && state.postIntroDefaultLineEnabled) {
 		Common::Error lineError = runtime.playDialogueLine(0x2311, "MOM");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 		assignMomTopicBuffer(kMomPostIntroTopicBufferResponseLine);
 	}
 	if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
-			!_momRoomDialogueState.stephMidgameShown) {
-		_momRoomDialogueState.stephMidgameShown = true;
+			!state.stephMidgameShown) {
+		state.stephMidgameShown = true;
 		Common::Error lineError = runtime.playDialogueLine(0x205a, "MOM");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
@@ -2352,36 +2367,36 @@ Common::Error DialogueSystem::handleMomDialogue(DialogueRuntime &runtime,
 	if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
 			(runtime.startupScript().getFlagValue("KARIN_KIDNAPED") ||
 				runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD")) &&
-			!_momRoomDialogueState.dinerBurnedKarinMissingOrDeadShown) {
-		_momRoomDialogueState.dinerBurnedKarinMissingOrDeadShown = true;
+			!state.dinerBurnedKarinMissingOrDeadShown) {
+		state.dinerBurnedKarinMissingOrDeadShown = true;
 		Common::Error lineError = runtime.playDialogueLine(0x2456, "MOM");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 	if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
-			!_momRoomDialogueState.burnedTvStationShown) {
-		_momRoomDialogueState.burnedTvStationShown = true;
+			!state.burnedTvStationShown) {
+		state.burnedTvStationShown = true;
 		Common::Error lineError = runtime.playDialogueLine(0x2220, "MOM");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 	if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER") &&
-			!_momRoomDialogueState.scratchedTuckerShown) {
-		_momRoomDialogueState.scratchedTuckerShown = true;
+			!state.scratchedTuckerShown) {
+		state.scratchedTuckerShown = true;
 		Common::Error lineError = runtime.playDialogueLine(0x23f3, "MOM");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 	if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") &&
-			!_momRoomDialogueState.barberPoleStolenShown) {
-		_momRoomDialogueState.barberPoleStolenShown = true;
+			!state.barberPoleStolenShown) {
+		state.barberPoleStolenShown = true;
 		Common::Error lineError = runtime.playDialogueLine(0x23fb, "MOM");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 	if (runtime.startupScript().getFlagValue("BOLT_OF_CLOTH_TAKEN") &&
-			!_momRoomDialogueState.boltOfClothTakenShown) {
-		_momRoomDialogueState.boltOfClothTakenShown = true;
+			!state.boltOfClothTakenShown) {
+		state.boltOfClothTakenShown = true;
 		if (runtime.startupScript().isNamedNpcDeathTypeClear("SPARKY") &&
 				runtime.startupScript().isNamedNpcDeathTypeClear("FIREMAN2")) {
 			Common::Error lineError = runtime.playDialogueLine(0x2416, "MOM");
@@ -2394,15 +2409,15 @@ Common::Error DialogueSystem::handleMomDialogue(DialogueRuntime &runtime,
 	}
 	if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
 			runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
-			!_momRoomDialogueState.dinerBurnedKarinAliveShown) {
-		_momRoomDialogueState.dinerBurnedKarinAliveShown = true;
+			!state.dinerBurnedKarinAliveShown) {
+		state.dinerBurnedKarinAliveShown = true;
 		Common::Error lineError = runtime.playDialogueLine(0x2434, "MOM");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 	if (runtime.startupScript().getFlagValue("PC_ESCAPED_JAIL") &&
-			!_momRoomDialogueState.escapedJailShown) {
-		_momRoomDialogueState.escapedJailShown = true;
+			!state.escapedJailShown) {
+		state.escapedJailShown = true;
 		Common::Error lineError = runtime.playDialogueLine(0x2495, "MOM");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
@@ -2410,43 +2425,43 @@ Common::Error DialogueSystem::handleMomDialogue(DialogueRuntime &runtime,
 	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
 			!runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
 			!runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
-			!_momRoomDialogueState.karinKidnapedUnresolvedShown) {
-		_momRoomDialogueState.karinKidnapedUnresolvedShown = true;
+			!state.karinKidnapedUnresolvedShown) {
+		state.karinKidnapedUnresolvedShown = true;
 		Common::Error lineError = runtime.playDialogueLine(0x24d7, "MOM");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 	if (runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
-			!_momRoomDialogueState.karinFoundAliveShown) {
-		_momRoomDialogueState.karinFoundAliveShown = true;
+			!state.karinFoundAliveShown) {
+		state.karinFoundAliveShown = true;
 		Common::Error lineError = runtime.playDialogueLine(0x2505, "MOM");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 	if (runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
-			!_momRoomDialogueState.karinFoundDeadShown) {
-		_momRoomDialogueState.karinFoundDeadShown = true;
+			!state.karinFoundDeadShown) {
+		state.karinFoundDeadShown = true;
 		Common::Error lineError = runtime.playDialogueLine(0x2576, "MOM");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 	if (!runtime.startupScript().isNamedNpcDeathTypeClear("BUTCHER") &&
-			!_momRoomDialogueState.butcherAbsentShown) {
-		_momRoomDialogueState.butcherAbsentShown = true;
+			!state.butcherAbsentShown) {
+		state.butcherAbsentShown = true;
 		Common::Error lineError = runtime.playDialogueLine(0x2633, "MOM");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 	if (!runtime.startupScript().isNamedNpcDeathTypeClear("MOYNAHAN") &&
-			!_momRoomDialogueState.moynahanAbsentShown) {
-		_momRoomDialogueState.moynahanAbsentShown = true;
+			!state.moynahanAbsentShown) {
+		state.moynahanAbsentShown = true;
 		Common::Error lineError = runtime.playDialogueLine(0x2647, "MOM");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 	if (!runtime.startupScript().isNamedNpcDeathTypeClear("JIMMY") &&
-			!_momRoomDialogueState.jimmyAbsentShown) {
-		_momRoomDialogueState.jimmyAbsentShown = true;
+			!state.jimmyAbsentShown) {
+		state.jimmyAbsentShown = true;
 		if (runtime.startupScript().getFlagValue("JIMMY_ATTACKED")) {
 			Common::Error lineError = runtime.playDialogueLine(0x2659, "MOM");
 			if (lineError.getCode() != Common::kNoError)
@@ -2457,29 +2472,29 @@ Common::Error DialogueSystem::handleMomDialogue(DialogueRuntime &runtime,
 			return lineError;
 	}
 	if (!runtime.startupScript().isNamedNpcDeathTypeClear("WASP_WOMAN") &&
-			!_momRoomDialogueState.waspWomanAbsentShown) {
-		_momRoomDialogueState.waspWomanAbsentShown = true;
+			!state.waspWomanAbsentShown) {
+		state.waspWomanAbsentShown = true;
 		Common::Error lineError = runtime.playDialogueLine(0x2689, "MOM");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 	if (runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD") &&
 			!runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
-			!_momRoomDialogueState.stephanieDeadPreMidgameShown) {
-		_momRoomDialogueState.stephanieDeadPreMidgameShown = true;
+			!state.stephanieDeadPreMidgameShown) {
+		state.stephanieDeadPreMidgameShown = true;
 		Common::Error lineError = runtime.playDialogueLine(0x217c, "MOM");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
-	if (runtime.startupScript().getFlagValue("DAY_5") && !_momRoomDialogueState.day5Shown) {
-		_momRoomDialogueState.day5Shown = true;
+	if (runtime.startupScript().getFlagValue("DAY_5") && !state.day5Shown) {
+		state.day5Shown = true;
 		Common::Error lineError = runtime.playDialogueLine(0x218d, "MOM");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 		assignMomTopicBuffer(kMomDay5TopicBufferResponseLine);
 	}
-	if (runtime.startupScript().getFlagValue("DAY_6") && !_momRoomDialogueState.day6Shown) {
-		_momRoomDialogueState.day6Shown = true;
+	if (runtime.startupScript().getFlagValue("DAY_6") && !state.day6Shown) {
+		state.day6Shown = true;
 		Common::Error lineError = runtime.playDialogueLine(0x22a8, "MOM");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
@@ -2528,13 +2543,7 @@ Common::Error DialogueSystem::handleMomDialogue(DialogueRuntime &runtime,
 
 			StartupInteractionResult babyGurgleInteraction;
 			if (runtime.startupScript().executeActionTag(kBabyGurgleActionTag, babyGurgleInteraction)) {
-				if (!babyGurgleInteraction.musicPath.empty())
-					(void)runtime.engine().playStartupMusic(babyGurgleInteraction.musicPath);
-				if (!babyGurgleInteraction.audioCommands.empty())
-					runtime.startupFlow().executeStartupAudioCommands(babyGurgleInteraction.audioCommands);
-
-				babyGurgleInteraction.musicPath.clear();
-				babyGurgleInteraction.audioCommands.clear();
+				runtime.applyImmediateDialogueInteractionEffects(babyGurgleInteraction);
 				runtime.queueDialogueInteractionIfNeeded(babyGurgleInteraction);
 			}
 
@@ -2637,7 +2646,7 @@ Common::Error DialogueSystem::handleMomDialogue(DialogueRuntime &runtime,
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x13f)) {
-			_momRoomDialogueState.fatherTopicState = true;
+			state.fatherTopicState = true;
 			Common::Error lineError = runtime.playDialogueEntrySequence(
 				kMomFatherLines, ARRAYSIZE(kMomFatherLines));
 			if (lineError.getCode() != Common::kNoError)
@@ -2660,7 +2669,7 @@ Common::Error DialogueSystem::handleMomDialogue(DialogueRuntime &runtime,
 					return lineError;
 				continue;
 			}
-			_sharedDiscussedLodgeTopic = true;
+			sharedState.discussedLodgeTopic = true;
 			lineError = runtime.playDialogueEntrySequence(kMomLodgeLines, ARRAYSIZE(kMomLodgeLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
@@ -2759,7 +2768,7 @@ Common::Error DialogueSystem::handleMomDialogue(DialogueRuntime &runtime,
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x15b) && currentStoryDayIndex == 5) {
 			Common::Error lineError = Common::kNoError;
-			if (_momRoomDialogueState.goodCauseDay5State) {
+			if (state.goodCauseDay5State) {
 				lineError = runtime.playDialogueEntrySequence(
 					kMomGoodCauseDay5Lines, ARRAYSIZE(kMomGoodCauseDay5Lines));
 			} else {
@@ -2810,11 +2819,12 @@ Common::Error DialogueSystem::handleMomDialogue(DialogueRuntime &runtime,
 	}
 }
 
-Common::Error DialogueSystem::handleHankDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName) {
+Common::Error HankDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	const int currentStoryDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
-	Common::String &hankTopicBuffer = _hankRoomDialogueState.currentTopicBuffer;
-	int &hankTopicBufferLineIndex = _hankRoomDialogueState.currentTopicBufferLineIndex;
+	HankRoomDialogueState &state = _state;
+	Common::String &hankTopicBuffer = state.currentTopicBuffer;
+	int &hankTopicBufferLineIndex = state.currentTopicBufferLineIndex;
 	auto assignHankTopicBuffer = [&](int responseLineIndex) {
 		runtime.assignTopicBuffer(hankTopicBuffer, hankTopicBufferLineIndex,
 			responseLineIndex, "Hank topic buffer");
@@ -2823,10 +2833,10 @@ Common::Error DialogueSystem::handleHankDialogue(DialogueRuntime &runtime,
 	debugC(1, kDebugDialogue,
 		"Harvester: Hank dialogue start day=%d item='%s' initial=%d trackedDayValid=%d trackedDay=%d sameDayPending=%d rangshotPending=%d topicLine=%d topicBuffer='%s'",
 		currentStoryDayIndex, usedItemName.empty() ? "<none>" : usedItemName.c_str(),
-		(int)_hankRoomDialogueState.pendingInitialConversation,
-		(int)_hankRoomDialogueState.hasTrackedDayState, _hankRoomDialogueState.trackedDayIndex,
-		(int)_hankRoomDialogueState.pendingSameDayFollowup,
-		(int)_hankRoomDialogueState.pendingRangshotSequence,
+		(int)state.pendingInitialConversation,
+		(int)state.hasTrackedDayState, state.trackedDayIndex,
+		(int)state.pendingSameDayFollowup,
+		(int)state.pendingRangshotSequence,
 		hankTopicBufferLineIndex, hankTopicBuffer.c_str());
 
 	if (!usedItemName.empty()) {
@@ -2843,11 +2853,11 @@ Common::Error DialogueSystem::handleHankDialogue(DialogueRuntime &runtime,
 	}
 
 	bool skipHankFollowupBranches = false;
-	if (_hankRoomDialogueState.pendingInitialConversation) {
-		_hankRoomDialogueState.trackedDayIndex = currentStoryDayIndex;
-		_hankRoomDialogueState.hasTrackedDayState = true;
-		_hankRoomDialogueState.pendingSameDayFollowup = true;
-		_hankRoomDialogueState.pendingInitialConversation = false;
+	if (state.pendingInitialConversation) {
+		state.trackedDayIndex = currentStoryDayIndex;
+		state.hasTrackedDayState = true;
+		state.pendingSameDayFollowup = true;
+		state.pendingInitialConversation = false;
 		hankTopicBuffer.clear();
 		hankTopicBufferLineIndex = -1;
 		skipHankFollowupBranches = true;
@@ -2885,19 +2895,19 @@ Common::Error DialogueSystem::handleHankDialogue(DialogueRuntime &runtime,
 		}
 	}
 
-	if (!skipHankFollowupBranches && (!_hankRoomDialogueState.hasTrackedDayState ||
-			currentStoryDayIndex != _hankRoomDialogueState.trackedDayIndex)) {
-		if (_hankRoomDialogueState.pendingSameDayFollowup) {
-			_hankRoomDialogueState.trackedDayIndex = currentStoryDayIndex;
-			_hankRoomDialogueState.pendingSameDayFollowup = false;
-			_hankRoomDialogueState.pendingRangshotSequence = true;
+	if (!skipHankFollowupBranches && (!state.hasTrackedDayState ||
+			currentStoryDayIndex != state.trackedDayIndex)) {
+		if (state.pendingSameDayFollowup) {
+			state.trackedDayIndex = currentStoryDayIndex;
+			state.pendingSameDayFollowup = false;
+			state.pendingRangshotSequence = true;
 
 			Common::Error lineError = runtime.playDialogueLine(0x8f5, "HANK");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 
-		if (_hankRoomDialogueState.pendingRangshotSequence) {
+		if (state.pendingRangshotSequence) {
 			Common::Error lineError = currentStoryDayIndex > 5
 				? runtime.playDialogueLine(0x8e2, "HANK")
 				: runtime.playDialogueFst(kDialogueRangshotFstPath);
@@ -2905,7 +2915,7 @@ Common::Error DialogueSystem::handleHankDialogue(DialogueRuntime &runtime,
 				return lineError;
 		}
 	} else if (!skipHankFollowupBranches) {
-		_hankRoomDialogueState.pendingSameDayFollowup = true;
+		state.pendingSameDayFollowup = true;
 		Common::Error lineError = currentStoryDayIndex > 5
 			? runtime.playDialogueLine(0x8e2, "HANK")
 			: runtime.playDialogueFst(kDialogueRangshotFstPath);
@@ -2914,8 +2924,8 @@ Common::Error DialogueSystem::handleHankDialogue(DialogueRuntime &runtime,
 	}
 
 	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
-			!_hankRoomDialogueState.stephMidgamePlayedShown) {
-		_hankRoomDialogueState.stephMidgamePlayedShown = true;
+			!state.stephMidgamePlayedShown) {
+		state.stephMidgamePlayedShown = true;
 
 		Common::Error lineError = runtime.playDialogueLine(0x92c, "HANK");
 		if (lineError.getCode() != Common::kNoError)
@@ -2984,15 +2994,15 @@ Common::Error DialogueSystem::handleHankDialogue(DialogueRuntime &runtime,
 			return lineError;
 	}
 	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
-			!_hankRoomDialogueState.burnedTvStationShown) {
-		_hankRoomDialogueState.burnedTvStationShown = true;
+			!state.burnedTvStationShown) {
+		state.burnedTvStationShown = true;
 		Common::Error lineError = runtime.playDialogueLine(0x987, "PC");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("BUSTED_ONCE") &&
-			!_hankRoomDialogueState.bustedOnceShown) {
-		_hankRoomDialogueState.bustedOnceShown = true;
+			!state.bustedOnceShown) {
+		state.bustedOnceShown = true;
 		Common::Error lineError = runtime.playDialogueLine(0x9b1, "HANK");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
@@ -3027,13 +3037,13 @@ Common::Error DialogueSystem::handleHankDialogue(DialogueRuntime &runtime,
 			return lineError;
 	}
 	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
-			!_hankRoomDialogueState.karinKidnapedShown) {
-		_hankRoomDialogueState.karinKidnapedShown = true;
+			!state.karinKidnapedShown) {
+		state.karinKidnapedShown = true;
 		Common::Error lineError = runtime.playDialogueLine(0x9d5, "HANK");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
-		if (_sharedKarinKidnapedDialogueState) {
+		if (sharedState.karinKidnapedDialogueState) {
 			lineError = runtime.playDialogueLineWithVariant(0x9da, "PC", 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
@@ -3074,15 +3084,15 @@ Common::Error DialogueSystem::handleHankDialogue(DialogueRuntime &runtime,
 			return lineError;
 	}
 	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
-			!_hankRoomDialogueState.karinFoundAliveShown) {
-		_hankRoomDialogueState.karinFoundAliveShown = true;
+			!state.karinFoundAliveShown) {
+		state.karinFoundAliveShown = true;
 		Common::Error lineError = runtime.playDialogueLine(0xa0b, "HANK");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
-			!_hankRoomDialogueState.karinFoundDeadShown) {
-		_hankRoomDialogueState.karinFoundDeadShown = true;
+			!state.karinFoundDeadShown) {
+		state.karinFoundDeadShown = true;
 		Common::Error lineError = runtime.playDialogueLine(0xa15, "HANK");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
@@ -3162,7 +3172,7 @@ Common::Error DialogueSystem::handleHankDialogue(DialogueRuntime &runtime,
 				continue;
 
 			if (topic.setDiscussedLodgeTopic)
-				_sharedDiscussedLodgeTopic = true;
+				sharedState.discussedLodgeTopic = true;
 
 			debugC(1, kDebugDialogue,
 				"Harvester: Hank matched topic '%s' to response line 0x%x (%d) -> wav=0x%x speaker='%s'",
diff --git a/engines/harvester/dialogue.h b/engines/harvester/dialogue.h
index ee3e7aac7fb..ba0d3710da7 100644
--- a/engines/harvester/dialogue.h
+++ b/engines/harvester/dialogue.h
@@ -26,6 +26,7 @@
 #include "common/error.h"
 #include "common/rect.h"
 #include "common/str.h"
+#include "harvester/npc/dialogue_handler.h"
 #include "harvester/startup_script.h"
 
 namespace Common {
@@ -34,107 +35,14 @@ struct Event;
 
 namespace Harvester {
 
-class DwayneDialogueHandler;
-class HankDialogueHandler;
 class HarvesterEngine;
-class JimmyDialogueHandler;
-class MomDialogueHandler;
 class NpcDialogueHandler;
-class DialogueRuntime;
-class PtaMomDialogueHandler;
 class StartupFlow;
 class StartupText;
-class WaspWomanDialogueHandler;
 struct IndexedBitmap;
 
 class DialogueSystem {
 public:
-	struct DwayneRoomDialogueState {
-		bool pendingInitialConversation = true;
-		bool sheriffInDinerIntroPending = true;
-		bool sheriffInDinerIntroPlayed = false;
-		bool eventFollowupGate = false;
-		bool tvDeedReplyOverride = false;
-		bool presentedEvidenceReplyOverride = false;
-		bool discussedBoylesButton = false;
-		bool discussedKarinPurse = false;
-		bool pendingKarinAliveFollowup = false;
-		bool completedKarinAliveFollowup = false;
-		int pendingKarinAliveFollowupDayIndex = 0;
-		bool bringKarinToSheriffLinePlayed = false;
-		bool whaleyDisciplineFollowupState = false;
-		bool whaleyDisciplineFollowupShown = false;
-		bool noteCheckbookFollowupShown = false;
-		bool scratchedTuckerShown = false;
-		bool boltOfClothTakenShown = false;
-		bool barberPoleStolenShown = false;
-		bool dinerBurnedShown = false;
-		bool escapedJailShown = false;
-		bool gotRemainsForLodgeShown = false;
-		bool burnedTvStationShown = false;
-		bool karinKidnapedShown = false;
-		bool pendingKarinAliveFollowupLinePlayed = false;
-		bool karinFoundDeadWithoutPurseShown = false;
-		bool jimmyAbsentShown = false;
-		bool moynahanAbsentShown = false;
-		bool ednaHungShown = false;
-		bool mcknightAbsentShown = false;
-		Common::String currentTopicBuffer;
-		int currentTopicBufferLineIndex = -1;
-	};
-
-	struct HankRoomDialogueState {
-		bool pendingInitialConversation = true;
-		bool hasTrackedDayState = false;
-		bool pendingRangshotSequence = false;
-		bool pendingSameDayFollowup = false;
-		int trackedDayIndex = 0;
-		bool stephMidgamePlayedShown = false;
-		bool burnedTvStationShown = false;
-		bool bustedOnceShown = false;
-		bool karinKidnapedShown = false;
-		bool karinFoundAliveShown = false;
-		bool karinFoundDeadShown = false;
-		Common::String currentTopicBuffer;
-		int currentTopicBufferLineIndex = -1;
-	};
-
-	struct MomRoomDialogueState {
-		bool introPending = true;
-		bool sameDayIntroLineEnabled = false;
-		bool postIntroDefaultLineEnabled = false;
-		int introDayIndex = 0;
-		bool stephMidgameShown = false;
-		bool dinerBurnedKarinMissingOrDeadShown = false;
-		bool burnedTvStationShown = false;
-		bool scratchedTuckerShown = false;
-		bool barberPoleStolenShown = false;
-		bool boltOfClothTakenShown = false;
-		bool dinerBurnedKarinAliveShown = false;
-		bool escapedJailShown = false;
-		bool karinKidnapedUnresolvedShown = false;
-		bool karinFoundAliveShown = false;
-		bool karinFoundDeadShown = false;
-		bool butcherAbsentShown = false;
-		bool moynahanAbsentShown = false;
-		bool jimmyAbsentShown = false;
-		bool waspWomanAbsentShown = false;
-		bool stephanieDeadPreMidgameShown = false;
-		bool day5Shown = false;
-		bool day6Shown = false;
-		bool fatherTopicState = false;
-		bool goodCauseDay5State = false;
-	};
-
-	struct JimmyRoomDialogueState {
-		bool firstNoItemLinePending = true;
-		bool paperHandoffStateSet = false;
-	};
-
-	struct WaspWomanRoomDialogueState {
-		bool introPending = true;
-	};
-
 	DialogueSystem(HarvesterEngine &engine, Common::Point &mousePos);
 	~DialogueSystem();
 
@@ -144,34 +52,11 @@ public:
 		StartupFlow &startupFlow);
 
 private:
-	friend class DwayneDialogueHandler;
-	friend class HankDialogueHandler;
-	friend class JimmyDialogueHandler;
-	friend class MomDialogueHandler;
-	friend class PtaMomDialogueHandler;
-	friend class WaspWomanDialogueHandler;
-
 	void registerNpcHandlers();
 
-	Common::Error handleDwayneDialogue(DialogueRuntime &runtime, const Common::String &usedItemName);
-	Common::Error handleJimmyDialogue(DialogueRuntime &runtime, const Common::String &usedItemName);
-	Common::Error handleWaspWomanDialogue(DialogueRuntime &runtime, const Common::String &usedItemName);
-	Common::Error handleMomDialogue(DialogueRuntime &runtime, const Common::String &usedItemName);
-	Common::Error handleHankDialogue(DialogueRuntime &runtime, const Common::String &usedItemName);
-	Common::Error handlePtaMomDialogue(DialogueRuntime &runtime, const Common::String &usedItemName);
-
 	HarvesterEngine &_engine;
 	Common::Point &_mousePos;
-	DwayneRoomDialogueState _dwayneRoomDialogueState;
-	HankRoomDialogueState _hankRoomDialogueState;
-	MomRoomDialogueState _momRoomDialogueState;
-	JimmyRoomDialogueState _jimmyRoomDialogueState;
-	WaspWomanRoomDialogueState _waspWomanRoomDialogueState;
-	bool _sharedBoyleGascanApplicationState = false;
-	bool _sharedDialogueStateD2f04 = false;
-	bool _sharedKarinKidnapedDialogueState = false;
-	bool _sharedDiscussedLodgeTopic = false;
-	bool _sharedWaspWomanDialogueState = false;
+	DialogueSharedState _sharedDialogueState;
 	Common::Array<NpcDialogueHandler *> _npcHandlers;
 };
 
diff --git a/engines/harvester/npc/dialogue_handler.h b/engines/harvester/npc/dialogue_handler.h
index 8107ac78fc8..7fe3734a054 100644
--- a/engines/harvester/npc/dialogue_handler.h
+++ b/engines/harvester/npc/dialogue_handler.h
@@ -28,15 +28,23 @@
 namespace Harvester {
 
 class DialogueRuntime;
-class DialogueSystem;
+
+struct DialogueSharedState {
+	bool boyleGascanApplicationState = false;
+	bool dialogueStateD2f04 = false;
+	bool karinKidnapedDialogueState = false;
+	bool discussedLodgeTopic = false;
+	bool waspWomanDialogueState = false;
+};
 
 class NpcDialogueHandler {
 public:
 	virtual ~NpcDialogueHandler() {}
 
 	virtual bool matchesNpc(const Common::String &npcName) const = 0;
-	virtual Common::Error handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
-		const Common::String &usedItemName) = 0;
+	virtual void resetState() {}
+	virtual Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) = 0;
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/dwayne_dialogue.cpp b/engines/harvester/npc/dwayne_dialogue.cpp
index afc0655ae00..82db5b7dcf4 100644
--- a/engines/harvester/npc/dwayne_dialogue.cpp
+++ b/engines/harvester/npc/dwayne_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include "harvester/dialogue.h"
 #include "harvester/npc/dwayne_dialogue.h"
 
 namespace Harvester {
@@ -28,9 +27,4 @@ bool DwayneDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("DWAYNE");
 }
 
-Common::Error DwayneDialogueHandler::handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
-		const Common::String &usedItemName) {
-	return dialogue.handleDwayneDialogue(runtime, usedItemName);
-}
-
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/dwayne_dialogue.h b/engines/harvester/npc/dwayne_dialogue.h
index c34d820423b..b7778c24700 100644
--- a/engines/harvester/npc/dwayne_dialogue.h
+++ b/engines/harvester/npc/dwayne_dialogue.h
@@ -28,9 +28,47 @@ namespace Harvester {
 
 class DwayneDialogueHandler : public NpcDialogueHandler {
 public:
+	struct DwayneRoomDialogueState {
+		bool pendingInitialConversation = true;
+		bool sheriffInDinerIntroPending = true;
+		bool sheriffInDinerIntroPlayed = false;
+		bool eventFollowupGate = false;
+		bool tvDeedReplyOverride = false;
+		bool presentedEvidenceReplyOverride = false;
+		bool discussedBoylesButton = false;
+		bool discussedKarinPurse = false;
+		bool pendingKarinAliveFollowup = false;
+		bool completedKarinAliveFollowup = false;
+		int pendingKarinAliveFollowupDayIndex = 0;
+		bool bringKarinToSheriffLinePlayed = false;
+		bool whaleyDisciplineFollowupState = false;
+		bool whaleyDisciplineFollowupShown = false;
+		bool noteCheckbookFollowupShown = false;
+		bool scratchedTuckerShown = false;
+		bool boltOfClothTakenShown = false;
+		bool barberPoleStolenShown = false;
+		bool dinerBurnedShown = false;
+		bool escapedJailShown = false;
+		bool gotRemainsForLodgeShown = false;
+		bool burnedTvStationShown = false;
+		bool karinKidnapedShown = false;
+		bool pendingKarinAliveFollowupLinePlayed = false;
+		bool karinFoundDeadWithoutPurseShown = false;
+		bool jimmyAbsentShown = false;
+		bool moynahanAbsentShown = false;
+		bool ednaHungShown = false;
+		bool mcknightAbsentShown = false;
+		Common::String currentTopicBuffer;
+		int currentTopicBufferLineIndex = -1;
+	};
+
 	bool matchesNpc(const Common::String &npcName) const override;
-	Common::Error handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
-		const Common::String &usedItemName) override;
+	void resetState() override { _state = DwayneRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	DwayneRoomDialogueState _state;
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/hank_dialogue.cpp b/engines/harvester/npc/hank_dialogue.cpp
index 838c18c5c6d..ed38a5713e6 100644
--- a/engines/harvester/npc/hank_dialogue.cpp
+++ b/engines/harvester/npc/hank_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include "harvester/dialogue.h"
 #include "harvester/npc/hank_dialogue.h"
 
 namespace Harvester {
@@ -28,9 +27,4 @@ bool HankDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("HANK");
 }
 
-Common::Error HankDialogueHandler::handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
-		const Common::String &usedItemName) {
-	return dialogue.handleHankDialogue(runtime, usedItemName);
-}
-
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/hank_dialogue.h b/engines/harvester/npc/hank_dialogue.h
index baf9bb16453..4a08c3609ae 100644
--- a/engines/harvester/npc/hank_dialogue.h
+++ b/engines/harvester/npc/hank_dialogue.h
@@ -28,9 +28,29 @@ namespace Harvester {
 
 class HankDialogueHandler : public NpcDialogueHandler {
 public:
+	struct HankRoomDialogueState {
+		bool pendingInitialConversation = true;
+		bool hasTrackedDayState = false;
+		bool pendingRangshotSequence = false;
+		bool pendingSameDayFollowup = false;
+		int trackedDayIndex = 0;
+		bool stephMidgamePlayedShown = false;
+		bool burnedTvStationShown = false;
+		bool bustedOnceShown = false;
+		bool karinKidnapedShown = false;
+		bool karinFoundAliveShown = false;
+		bool karinFoundDeadShown = false;
+		Common::String currentTopicBuffer;
+		int currentTopicBufferLineIndex = -1;
+	};
+
 	bool matchesNpc(const Common::String &npcName) const override;
-	Common::Error handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
-		const Common::String &usedItemName) override;
+	void resetState() override { _state = HankRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	HankRoomDialogueState _state;
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/jimmy_dialogue.cpp b/engines/harvester/npc/jimmy_dialogue.cpp
index 0f473e3d94d..3f5cd6142f2 100644
--- a/engines/harvester/npc/jimmy_dialogue.cpp
+++ b/engines/harvester/npc/jimmy_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include "harvester/dialogue.h"
 #include "harvester/npc/jimmy_dialogue.h"
 
 namespace Harvester {
@@ -28,9 +27,4 @@ bool JimmyDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("JIMMY");
 }
 
-Common::Error JimmyDialogueHandler::handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
-		const Common::String &usedItemName) {
-	return dialogue.handleJimmyDialogue(runtime, usedItemName);
-}
-
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/jimmy_dialogue.h b/engines/harvester/npc/jimmy_dialogue.h
index 465a111de23..1fa273b5406 100644
--- a/engines/harvester/npc/jimmy_dialogue.h
+++ b/engines/harvester/npc/jimmy_dialogue.h
@@ -28,9 +28,18 @@ namespace Harvester {
 
 class JimmyDialogueHandler : public NpcDialogueHandler {
 public:
+	struct JimmyRoomDialogueState {
+		bool firstNoItemLinePending = true;
+		bool paperHandoffStateSet = false;
+	};
+
 	bool matchesNpc(const Common::String &npcName) const override;
-	Common::Error handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
-		const Common::String &usedItemName) override;
+	void resetState() override { _state = JimmyRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	JimmyRoomDialogueState _state;
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/mom_dialogue.cpp b/engines/harvester/npc/mom_dialogue.cpp
index 5c7b9b6cbbd..73e2b1bdabe 100644
--- a/engines/harvester/npc/mom_dialogue.cpp
+++ b/engines/harvester/npc/mom_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include "harvester/dialogue.h"
 #include "harvester/npc/mom_dialogue.h"
 
 namespace Harvester {
@@ -28,9 +27,4 @@ bool MomDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("MOM");
 }
 
-Common::Error MomDialogueHandler::handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
-		const Common::String &usedItemName) {
-	return dialogue.handleMomDialogue(runtime, usedItemName);
-}
-
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/mom_dialogue.h b/engines/harvester/npc/mom_dialogue.h
index 83b1d30126c..0f18b7677e0 100644
--- a/engines/harvester/npc/mom_dialogue.h
+++ b/engines/harvester/npc/mom_dialogue.h
@@ -28,9 +28,40 @@ namespace Harvester {
 
 class MomDialogueHandler : public NpcDialogueHandler {
 public:
+	struct MomRoomDialogueState {
+		bool introPending = true;
+		bool sameDayIntroLineEnabled = false;
+		bool postIntroDefaultLineEnabled = false;
+		int introDayIndex = 0;
+		bool stephMidgameShown = false;
+		bool dinerBurnedKarinMissingOrDeadShown = false;
+		bool burnedTvStationShown = false;
+		bool scratchedTuckerShown = false;
+		bool barberPoleStolenShown = false;
+		bool boltOfClothTakenShown = false;
+		bool dinerBurnedKarinAliveShown = false;
+		bool escapedJailShown = false;
+		bool karinKidnapedUnresolvedShown = false;
+		bool karinFoundAliveShown = false;
+		bool karinFoundDeadShown = false;
+		bool butcherAbsentShown = false;
+		bool moynahanAbsentShown = false;
+		bool jimmyAbsentShown = false;
+		bool waspWomanAbsentShown = false;
+		bool stephanieDeadPreMidgameShown = false;
+		bool day5Shown = false;
+		bool day6Shown = false;
+		bool fatherTopicState = false;
+		bool goodCauseDay5State = false;
+	};
+
 	bool matchesNpc(const Common::String &npcName) const override;
-	Common::Error handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
-		const Common::String &usedItemName) override;
+	void resetState() override { _state = MomRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	MomRoomDialogueState _state;
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/pta_mom_dialogue.cpp b/engines/harvester/npc/pta_mom_dialogue.cpp
index c3a98a603cf..073518f3f1c 100644
--- a/engines/harvester/npc/pta_mom_dialogue.cpp
+++ b/engines/harvester/npc/pta_mom_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include "harvester/dialogue.h"
 #include "harvester/npc/pta_mom_dialogue.h"
 
 namespace Harvester {
@@ -32,9 +31,4 @@ bool PtaMomDialogueHandler::matchesNpc(const Common::String &npcName) const {
 		npcName.equalsIgnoreCase("PTA_MOM5");
 }
 
-Common::Error PtaMomDialogueHandler::handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
-		const Common::String &usedItemName) {
-	return dialogue.handlePtaMomDialogue(runtime, usedItemName);
-}
-
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/pta_mom_dialogue.h b/engines/harvester/npc/pta_mom_dialogue.h
index 04b97967fe5..73883315b55 100644
--- a/engines/harvester/npc/pta_mom_dialogue.h
+++ b/engines/harvester/npc/pta_mom_dialogue.h
@@ -29,8 +29,8 @@ namespace Harvester {
 class PtaMomDialogueHandler : public NpcDialogueHandler {
 public:
 	bool matchesNpc(const Common::String &npcName) const override;
-	Common::Error handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
-		const Common::String &usedItemName) override;
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/stub_dialogue.cpp b/engines/harvester/npc/stub_dialogue.cpp
index 68f6510398c..f997065da96 100644
--- a/engines/harvester/npc/stub_dialogue.cpp
+++ b/engines/harvester/npc/stub_dialogue.cpp
@@ -22,8 +22,6 @@
 #include "harvester/npc/stub_dialogue.h"
 
 #include "common/debug.h"
-#include "harvester/dialogue.h"
-
 namespace Harvester {
 
 StubNpcDialogueHandler::StubNpcDialogueHandler(const char *npcName) {
@@ -43,8 +41,8 @@ bool StubNpcDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return false;
 }
 
-Common::Error StubNpcDialogueHandler::handle(DialogueSystem &, DialogueRuntime &,
-		const Common::String &usedItemName) {
+Common::Error StubNpcDialogueHandler::handleDialogue(DialogueRuntime &,
+		const Common::String &usedItemName, DialogueSharedState &) {
 	debug(1, "Harvester: stub NPC dialogue handler '%s' item='%s'",
 		_npcNames.empty() ? "<unknown>" : _npcNames.front().c_str(),
 		usedItemName.empty() ? "<none>" : usedItemName.c_str());
diff --git a/engines/harvester/npc/stub_dialogue.h b/engines/harvester/npc/stub_dialogue.h
index 5b3f539ea4a..00844d97f66 100644
--- a/engines/harvester/npc/stub_dialogue.h
+++ b/engines/harvester/npc/stub_dialogue.h
@@ -33,8 +33,8 @@ public:
 	explicit StubNpcDialogueHandler(const Common::Array<Common::String> &npcNames);
 
 	bool matchesNpc(const Common::String &npcName) const override;
-	Common::Error handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
-		const Common::String &usedItemName) override;
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
 private:
 	Common::Array<Common::String> _npcNames;
diff --git a/engines/harvester/npc/wasp_woman_dialogue.cpp b/engines/harvester/npc/wasp_woman_dialogue.cpp
index a3d6307db10..801ee4198df 100644
--- a/engines/harvester/npc/wasp_woman_dialogue.cpp
+++ b/engines/harvester/npc/wasp_woman_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include "harvester/dialogue.h"
 #include "harvester/npc/wasp_woman_dialogue.h"
 
 namespace Harvester {
@@ -28,9 +27,4 @@ bool WaspWomanDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("WASP_WOMAN");
 }
 
-Common::Error WaspWomanDialogueHandler::handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
-		const Common::String &usedItemName) {
-	return dialogue.handleWaspWomanDialogue(runtime, usedItemName);
-}
-
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/wasp_woman_dialogue.h b/engines/harvester/npc/wasp_woman_dialogue.h
index 1691dab6250..a8a3d85df48 100644
--- a/engines/harvester/npc/wasp_woman_dialogue.h
+++ b/engines/harvester/npc/wasp_woman_dialogue.h
@@ -28,9 +28,17 @@ namespace Harvester {
 
 class WaspWomanDialogueHandler : public NpcDialogueHandler {
 public:
+	struct WaspWomanRoomDialogueState {
+		bool introPending = true;
+	};
+
 	bool matchesNpc(const Common::String &npcName) const override;
-	Common::Error handle(DialogueSystem &dialogue, DialogueRuntime &runtime,
-		const Common::String &usedItemName) override;
+	void resetState() override { _state = WaspWomanRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	WaspWomanRoomDialogueState _state;
 };
 
 } // End of namespace Harvester


Commit: 18404318eff026950cd2ae8653f93c7a5a8bf8cd
    https://github.com/scummvm/scummvm/commit/18404318eff026950cd2ae8653f93c7a5a8bf8cd
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:58+02:00

Commit Message:
HARVESTER: Port edna startup dialogue

Changed paths:
  A engines/harvester/npc/edna_dialogue.cpp
  A engines/harvester/npc/edna_dialogue.h
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk
    engines/harvester/npc/dialogue_handler.h
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 319d49a3feb..8aece0443b1 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -442,6 +442,12 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `CHECKBOOK` / `NOTE` is a two-step response branch: `0x33f4`, zero-based `dialog.rsp` line `0x70`, then `0x3407` / `0x340d`, followed by zero-based `dialog.rsp` line `0x71`, with the direct override exit at `0x342c`.
     - The delayed Karin-alive follow-up is also confirmed: `BRING_KARIN_TO_SHERIFF` or `KARIN_FOUND_ALIVE` sets the pending Dwayne-local day gate, and when the story day changes the handler plays `0x3780` / `0x3786` / `0x378a`, clears the pending bit, and sets the shared completion helper that Stephanie later reads.
     - The looping keyword branch rewrites the live topic buffer through confirmed `dialog.rsp` transitions `0x83->0x84`, `0x85..0x87->0x89`, `0x8c..0x8d->0x8e`, `0x8f->0x90`, `0x91->0x92`, `0x93->0x94`, `0x95->0x97`, `0x98->0x99`, `0x9a->0x9b`, and `0x9c->0x9d`; `0x82` exits with `0x3395`, while `0x9e` loops back to the menu without changing the buffer.
+  - `handle_talk_to_edna @ 0x358a0` is now bounded enough for a direct startup-room port.
+    - `DNA_S_SUICIDE_NOTE` is an early no-dialogue branch: it clears the flag, plays `C043K.FST` when `KARIN_FOUND_ALIVE` is already set or `C043.FST` otherwise, writes two still-unresolved shared talk-state bits at `0xd2ea4` / `0xd2ea8`, and reveals `SIGNOUT` in `DNAEXT`.
+    - `BRING_KARIN_TO_SHERIFF` is a one-shot no-item reward branch. It plays `0x3cce` / `0x3cd2` / `0x3cd7`, adds `REWARD_MONEY` to inventory, and clears `BRING_KARIN_TO_SHERIFF` before the normal Edna flow resumes on later visits.
+    - The primary intro gate is `g_edna_talk_state_block`: on the first normal no-item visit Edna plays `0x3a95`, opens zero-based `dialog.rsp` line `0x9f`, then response `1` plays `0x3aa1` / `0x3aa6` / `0x3aaa`, sets `get_set_shared_dialogue_state_d2f04`, and continues with `0x3aae` / `0x3ac6`, while response `2` plays `0x3ab2` / `0x3ab7` / `0x3abb` / `0x3ac2` / `0x3ac6`.
+    - The item path is linear and evidence-driven: `K_PURSE` plays `0x3d05` and sets the one-shot purse bit, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` and the dual-ledger case share `0x3bd6`, `PHOTO_OF_WHALEY_HERRILL` plays `0x3bde`, note/checkbook evidence plays `0x3be5`, TV deed evidence plays `0x3bee`, and all other presented objects fall back to `0x3bcf`.
+    - The steady-state no-item tail is also simple: if `KARIN_KIDNAPED` is still clear Edna plays `0x3bf8`, then always ends the branch on `0x3bff`.
 - The shared dialogue evidence/topic state shims behind those handlers are now partially explicit:
   - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
   - `get_set_discussed_note_checkbook_evidence` at `0x38330` gates the shared `NOTE` / `NOTE_PHOTOCOPY` / `CHECKBOOK` / `CHECKBOOK_PHOTOCOPY` evidence path used by Dwayne, Edna, Herrill, Johnson, Mom, and related handlers.
@@ -493,7 +499,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `g_herrill_talk_state_block` carries a small gate tail: `g_herrill_dialogue_suppressed_flag` blocks the standard no-item branch, `g_herrill_requires_evidence_for_dialogue_flag` forces the inventory-evidence prerequisite, and `g_herrill_gascan_presented_flag` records the `GASCAN` presentation path.
     - `g_mrs_potts_talk_aux_state_block` at `0xd2d24` is a compact return-visit/event tail; `g_mrs_potts_return_visit_followup_pending` drives the generic revisit line, while the remaining named fields are one-shot reactions for `DAY_5`, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, `DINER_BURNED`, `GOT_REMAINS_FOR_LODGE`, and `TAKEN_INVITE_TO_SERGEANT`.
     - `g_mom_talk_state_block` carries a much larger persisted tail immediately after its primary intro flag: it stores `g_mom_intro_day_index`, the two post-intro gates, and one-shot reactions for `STEPH_MIDGAME_PLAYED`, `STEPHANIE_IS_DEAD`, `BURNED_TV_STATION`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `BOLT_OF_CLOTH_TAKEN`, the Karin outcome flags, `PC_ESCAPED_JAIL`, and several absent-NPC checks.
-    - `g_loomis_talk_state_block` adds a single extra revisit flag, `g_loomis_return_visit_followup_pending`, and `g_edna_talk_state_block` has at least one proven auxiliary one-shot flag, `g_edna_karin_purse_line_played`, for the `K_PURSE` evidence branch.
+    - `g_loomis_talk_state_block` adds a single extra revisit flag, `g_loomis_return_visit_followup_pending`, and `g_edna_talk_state_block` now has two proven live fields: the primary intro gate at `+0x0`, which protects Edna's first `0x3a95` + `dialog.rsp 0x9f` exchange, and `g_edna_karin_purse_line_played` at `+0x4` for the `K_PURSE` evidence branch.
     - `g_dwayne_talk_state_block` extends its intro flag with a serialized tail that includes the sheriff-in-diner introduction gates, `g_dwayne_pending_karin_alive_followup_day_index`, two externally driven reply-override flags, and a long one-shot event sequence for the later plot-state reactions.
     - `g_memb_dir_talk_state_block` only adds one proven auxiliary field, `g_memb_dir_kewpie_kill_line_played`, which makes the `PC_KILLED_KEWPIE` reaction one-shot.
 - `reset_shared_talk_to_discussion_state` at `0x38530` zeroes the contiguous shared dialogue-state block behind the `get_set_discussed_*` wrappers and related talk-to follow-up flags.
diff --git a/TRACKER.md b/TRACKER.md
index a5f4c0af239..f83b69345cd 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,16 +15,16 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_dwayne @ 0x3a310` into `DialogueSystem` as `engines/harvester/npc/dwayne_dialogue.*`.
-  - `DWAYNE` now routes through a dedicated handler instead of the generic stub registry entry, and `DialogueRuntime` now carries the active room name so the native `ST_BEDRM` special case can be mirrored without reintroducing startup-only branching elsewhere.
-  - The engine now mirrors the native arrest-priority bark chain, the sheriff-in-diner intro vs outside-sheriff opener split, the delayed Karin-alive next-day follow-up, and the bounded no-sheriff event barks that native gates through the Dwayne-local persisted state block.
-  - The item path now mirrors the confirmed shared evidence writes for `LEDGER` / `LEDGER2`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, and `PHOTO_OF_WHALEY_HERRILL`, while keeping Dwayne-local state for `BOYLES_BUTTON`, `K_PURSE`, and the unresolved direct reply overrides.
-  - The looping keyword/menu path is now explicit in the engine: initial buffer `0x78`, exit topic `0x82`, Whaley/Loomis topic clusters, the Lodge `0x95` response menu on zero-based `dialog.rsp` line `0x96`, and the later `0x98`, `0x9a`, `0x9c`, and `0x9e` rewrites all follow the confirmed native `dialog.rsp` transitions.
-  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Dwayne handler port.
+- Ported `handle_talk_to_edna @ 0x358a0` into `DialogueSystem` as `engines/harvester/npc/edna_dialogue.*`.
+  - `EDNA` now routes through a dedicated handler instead of the generic stub registry entry, and the runtime script API now exposes the two minimal object-state helpers this native handler actually needs: add `REWARD_MONEY` to inventory and reveal `DNAEXT/SIGNOUT`.
+  - The engine now mirrors the native early special branches: `KILLED_KARIN1` -> `0x3cdf`, `DNA_S_SUICIDE_NOTE` -> `C043.FST` / `C043K.FST` plus `SIGNOUT` visibility, and `BRING_KARIN_TO_SHERIFF` -> `0x3cce` / `0x3cd2` / `0x3cd7` plus the `REWARD_MONEY` inventory award and flag clear.
+  - The first normal no-item visit now mirrors the native intro gate on `g_edna_talk_state_block`: `0x3a95`, zero-based `dialog.rsp` line `0x9f`, then response `1` -> `0x3aa1` / `0x3aa6` / `0x3aaa` / `0x3aae` / `0x3ac6` with shared `d2f04` set, response `2` -> `0x3ab2` / `0x3ab7` / `0x3abb` / `0x3ac2` / `0x3ac6`.
+  - The item path now mirrors the confirmed shared evidence writes for casket photo, dual-ledger, Whaley/Herrill, note/checkbook, and TV deed evidence, while keeping Edna-local state for the `K_PURSE` one-shot line.
+  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Edna handler port.
 
 ## Next Suggested Action
 
-1. Port `handle_talk_to_edna @ 0x358a0` into `DialogueSystem` as `engines/harvester/npc/edna_dialogue.*`.
-   - Edna is the next bounded startup-room handler still behind a stub, and her native item/no-item branches already overlap the confirmed shared evidence helpers used by Dwayne, Mom, Hank, Herrill, and Johnson.
-2. After Edna, continue replacing the remaining bounded startup-room stubs before returning to wider helper scans.
-   - `handle_talk_to_herrill @ 0x2dc00` and `handle_talk_to_johnson @ 0x27dd0` still need explicit engine-side handlers before their confirmed shared `SHOWN_*` evidence writes can be mirrored.
+1. Port `handle_talk_to_herrill @ 0x2dc00` into `DialogueSystem` as `engines/harvester/npc/herrill_dialogue.*`.
+   - Herrill is now the next bounded startup-room handler still behind a stub, and his visible evidence branches overlap the same shared `SHOWN_*` note/checkbook, TV deed, casket photo, and Whaley/Herrill helpers Edna just confirmed in-engine.
+2. After Herrill, continue with `handle_talk_to_johnson @ 0x27dd0`.
+   - Johnson is the last remaining bounded startup-room stub in this evidence-sharing cluster before the tracker can return to wider helper scans.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index c89b0f8732c..e2830672ffb 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -37,6 +37,7 @@
 #include "harvester/fst_player.h"
 #include "harvester/harvester.h"
 #include "harvester/npc/dwayne_dialogue.h"
+#include "harvester/npc/edna_dialogue.h"
 #include "harvester/npc/hank_dialogue.h"
 #include "harvester/npc/jimmy_dialogue.h"
 #include "harvester/npc/mom_dialogue.h"
@@ -200,6 +201,8 @@ static const char *const kDialogueKeywordBitmapPath = "1:/GRAPHIC/OTHER/KEYWORD.
 static const char *const kDialogueRangshotFstPath = "GRAPHIC/FST/RANGSHOT.FST";
 static const char *const kDialogueC008AFstPath = "GRAPHIC/FST/C008A.FST";
 static const char *const kDialogueC008BFstPath = "GRAPHIC/FST/C008B.FST";
+static const char *const kDialogueC043FstPath = "GRAPHIC/FST/C043.FST";
+static const char *const kDialogueC043KFstPath = "GRAPHIC/FST/C043K.FST";
 static const char *const kBabyGurgleActionTag = "BABY_GURGLE";
 static const char *const kShownEvidenceOfBlackmailFlag = "SHOWN_EVIDENCE_OF_BLACKMAIL";
 static const char *const kShownEvidenceSheriffOwnsFlag = "SHOWN_EVIDENCE_SHERIFF_OWNS";
@@ -743,6 +746,7 @@ DialogueSystem::~DialogueSystem() {
 
 void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new DwayneDialogueHandler());
+	_npcHandlers.push_back(new EdnaDialogueHandler());
 	_npcHandlers.push_back(new JimmyDialogueHandler());
 	_npcHandlers.push_back(new WaspWomanDialogueHandler());
 	_npcHandlers.push_back(new MomDialogueHandler());
@@ -750,7 +754,6 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new PtaMomDialogueHandler());
 
 	_npcHandlers.push_back(new StubNpcDialogueHandler("PTA_MOM"));
-	_npcHandlers.push_back(new StubNpcDialogueHandler("EDNA"));
 	_npcHandlers.push_back(new StubNpcDialogueHandler("HERRILL"));
 	_npcHandlers.push_back(new StubNpcDialogueHandler("JOHNSON"));
 }
@@ -1387,6 +1390,134 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 	return Common::kNoError;
 }
 
+Common::Error EdnaDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	EdnaRoomDialogueState &state = _state;
+	auto playEdnaLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "EDNA", headVariant);
+	};
+
+	if (runtime.startupScript().getFlagValue("KILLED_KARIN1"))
+		return playEdnaLine(0x3cdf, 2);
+
+	if (runtime.startupScript().getFlagValue("DNA_S_SUICIDE_NOTE")) {
+		(void)runtime.startupScript().setRuntimeFlagValue("DNA_S_SUICIDE_NOTE", false);
+		Common::Error lineError = runtime.playDialogueFst(
+			runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE")
+				? kDialogueC043KFstPath
+				: kDialogueC043FstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		sharedState.dialogueStateD2ea4 = true;
+		sharedState.dialogueStateD2ea8 = true;
+		(void)runtime.startupScript().setRuntimeObjectVisible("DNAEXT", "SIGNOUT", true);
+		return Common::kNoError;
+	}
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("K_PURSE")) {
+			state.karinPurseLinePlayed = true;
+			return playEdnaLine(0x3d05, 3);
+		}
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
+			return playEdnaLine(0x3bd6);
+		}
+		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+				usedItemName.equalsIgnoreCase("LEDGER2")) &&
+				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownLedgersToAnyoneFlag, true);
+			return playEdnaLine(0x3bd6);
+		}
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
+			return playEdnaLine(0x3bde, 2);
+		}
+		if (usedItemName.equalsIgnoreCase("NOTE") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownEvidenceOfBlackmailFlag, true);
+			return playEdnaLine(0x3be5, 2);
+		}
+		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
+				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(kShownEvidenceSheriffOwnsFlag, true);
+			return playEdnaLine(0x3bee);
+		}
+
+		return playEdnaLine(0x3bcf);
+	}
+
+	if (runtime.startupScript().getFlagValue("BRING_KARIN_TO_SHERIFF")) {
+		Common::Error lineError = playEdnaLine(0x3cce, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = runtime.playDialogueLine(0x3cd2, "PC");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playEdnaLine(0x3cd7, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		(void)runtime.startupScript().addRuntimeObjectToInventory("REWARD_MONEY");
+		(void)runtime.startupScript().setRuntimeFlagValue("BRING_KARIN_TO_SHERIFF", false);
+		return Common::kNoError;
+	}
+
+	if (state.introPending) {
+		state.introPending = false;
+		Common::Error lineError = playEdnaLine(0x3a95);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x9f, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playEdnaLine(0x3aa1, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueLine(0x3aa6, "PC");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playEdnaLine(0x3aaa);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			sharedState.dialogueStateD2f04 = true;
+			lineError = runtime.playDialogueLine(0x3aae, "PC");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			const DialogueLineEntry responseLines[] = {
+				{ 0x3ab2, "EDNA", 0 },
+				{ 0x3ab7, "PC", 0 },
+				{ 0x3abb, "EDNA", 0 },
+				{ 0x3ac2, "EDNA", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(responseLines, ARRAYSIZE(responseLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		lineError = playEdnaLine(0x3ac6, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (!runtime.startupScript().getFlagValue("KARIN_KIDNAPED")) {
+		Common::Error lineError = playEdnaLine(0x3bf8);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	return playEdnaLine(0x3bff);
+}
+
 Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	static const int kDwayneWhaleyTopicResponseLines[] = { 0x85, 0x86, 0x87 };
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 562e7088757..3697845f88a 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -8,6 +8,7 @@ MODULE_OBJS = \
 	fst_player.o \
 	metaengine.o \
 	npc/dwayne_dialogue.o \
+	npc/edna_dialogue.o \
 	npc/hank_dialogue.o \
 	npc/jimmy_dialogue.o \
 	npc/mom_dialogue.o \
diff --git a/engines/harvester/npc/dialogue_handler.h b/engines/harvester/npc/dialogue_handler.h
index 7fe3734a054..788f8986f34 100644
--- a/engines/harvester/npc/dialogue_handler.h
+++ b/engines/harvester/npc/dialogue_handler.h
@@ -32,6 +32,8 @@ class DialogueRuntime;
 struct DialogueSharedState {
 	bool boyleGascanApplicationState = false;
 	bool dialogueStateD2f04 = false;
+	bool dialogueStateD2ea4 = false;
+	bool dialogueStateD2ea8 = false;
 	bool karinKidnapedDialogueState = false;
 	bool discussedLodgeTopic = false;
 	bool waspWomanDialogueState = false;
diff --git a/engines/harvester/npc/edna_dialogue.cpp b/engines/harvester/npc/edna_dialogue.cpp
new file mode 100644
index 00000000000..3ae1b345761
--- /dev/null
+++ b/engines/harvester/npc/edna_dialogue.cpp
@@ -0,0 +1,30 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/npc/edna_dialogue.h"
+
+namespace Harvester {
+
+bool EdnaDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("EDNA");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/edna_dialogue.h b/engines/harvester/npc/edna_dialogue.h
new file mode 100644
index 00000000000..4961b9e662a
--- /dev/null
+++ b/engines/harvester/npc/edna_dialogue.h
@@ -0,0 +1,47 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_EDNA_DIALOGUE_H
+#define HARVESTER_NPC_EDNA_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class EdnaDialogueHandler : public NpcDialogueHandler {
+public:
+	struct EdnaRoomDialogueState {
+		bool introPending = true;
+		bool karinPurseLinePlayed = false;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = EdnaRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	EdnaRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_EDNA_DIALOGUE_H
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 91a391fe1da..fa9ee62d99b 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -975,6 +975,25 @@ bool StartupScript::addRuntimeObjectToInventory(const Common::String &objectName
 	return changed;
 }
 
+bool StartupScript::setRuntimeObjectVisible(const Common::String &ownerOrRoom,
+		const Common::String &objectName, bool visible) {
+	if (objectName.empty())
+		return false;
+
+	StartupObjectRecord *runtimeObject = findRuntimeObject(ownerOrRoom, objectName);
+	if (!runtimeObject)
+		return false;
+
+	const bool changed = runtimeObject->visible != visible ||
+		runtimeObject->runtimeVisible != visible;
+	runtimeObject->visible = visible;
+	runtimeObject->runtimeVisible = visible;
+	if (visible && runtimeObject->currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName))
+		runtimeObject->identShown = true;
+
+	return changed;
+}
+
 bool StartupScript::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupEntranceRecord *entrance,
 		StartupRoomSetupState &state) const {
 	state = StartupRoomSetupState();
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 8447d733702..09e1d95955a 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -301,6 +301,9 @@ public:
 		const StartupNpcRecord *findRuntimeNpcRecord(const Common::String &npcName) const;
 		bool setRuntimeFlagValue(const Common::String &flagName, bool value);
 		bool resetRuntimeObjectToInitialState(const Common::String &objectName);
+		bool addRuntimeObjectToInventory(const Common::String &objectName);
+		bool setRuntimeObjectVisible(const Common::String &ownerOrRoom, const Common::String &objectName,
+			bool visible);
 		bool isNamedNpcDeathTypeClear(const Common::String &npcName) const;
 		int getCurrentStoryDayIndex() const;
 
@@ -322,7 +325,6 @@ private:
 	const StartupNpcRecord *findRuntimeNpc(const Common::String &npcName) const;
 	StartupMonsterRecord *findRuntimeMonster(const Common::String &monsterName);
 	const StartupMonsterRecord *findRuntimeMonster(const Common::String &monsterName) const;
-	bool addRuntimeObjectToInventory(const Common::String &objectName);
 	bool buildRuntimeRoomState(const StartupRoomRecord &room, const StartupEntranceRecord *entrance,
 		StartupRoomSetupState &state) const;
 	void executeCommandChain(const Common::String &initialTag, const char *contextLabel,


Commit: 3718f83e6eabb13bec0e799c0ea769e5c220a781
    https://github.com/scummvm/scummvm/commit/3718f83e6eabb13bec0e799c0ea769e5c220a781
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:58+02:00

Commit Message:
HARVESTER: Move npc dialogue logic into handlers

Changed paths:
  A engines/harvester/npc/dialogue_flags.h
  A engines/harvester/npc/dialogue_runtime.h
    engines/harvester/dialogue.cpp
    engines/harvester/npc/dwayne_dialogue.cpp
    engines/harvester/npc/edna_dialogue.cpp
    engines/harvester/npc/hank_dialogue.cpp
    engines/harvester/npc/jimmy_dialogue.cpp
    engines/harvester/npc/mom_dialogue.cpp
    engines/harvester/npc/pta_mom_dialogue.cpp
    engines/harvester/npc/wasp_woman_dialogue.cpp


diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index e2830672ffb..92f1ced802b 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -37,6 +37,7 @@
 #include "harvester/fst_player.h"
 #include "harvester/harvester.h"
 #include "harvester/npc/dwayne_dialogue.h"
+#include "harvester/npc/dialogue_runtime.h"
 #include "harvester/npc/edna_dialogue.h"
 #include "harvester/npc/hank_dialogue.h"
 #include "harvester/npc/jimmy_dialogue.h"
@@ -52,128 +53,6 @@
 
 namespace Harvester {
 
-struct DialogueLineEntry {
-	int wavId;
-	const char *speakerId;
-	int headVariant;
-};
-
-class DialogueRuntime {
-public:
-	typedef std::function<Common::Error(int, const Common::String &, int)> PlayDialogueLineWithVariantFn;
-	typedef std::function<Common::Error(int, const Common::String &)> PlayDialogueLineFn;
-	typedef std::function<Common::Error(const DialogueLineEntry *, uint)> PlayDialogueEntrySequenceFn;
-	typedef std::function<Common::Error(const Common::String &)> PlayDialogueFstFn;
-	typedef std::function<Common::Error(const Common::String &, int, Common::String &)> RunKeywordMenuFn;
-	typedef std::function<Common::Error(int, int &)> RunResponseMenuFn;
-	typedef std::function<void(Common::String &, int &, int, const char *)> AssignTopicBufferFn;
-	typedef std::function<bool(const Common::String &, int)> MatchesResponseLineFn;
-	typedef std::function<bool(const Common::String &, const int *, uint)> MatchesAnyResponseLineFn;
-	typedef std::function<void(const StartupInteractionResult &)> QueueDialogueInteractionIfNeededFn;
-	typedef std::function<void(StartupInteractionResult &)> ApplyImmediateDialogueInteractionEffectsFn;
-	typedef std::function<int(int)> GetRandomNumberFn;
-	typedef std::function<void(const Common::String &, int)> SetActiveSpeakerPortraitFn;
-
-	DialogueRuntime(HarvesterEngine &engine, StartupScript &startupScript, StartupText &startupText,
-			StartupFlow &startupFlow, const Common::String &currentRoomName,
-			const Common::String &genericByeTopic,
-			const PlayDialogueLineWithVariantFn &playDialogueLineWithVariant,
-			const PlayDialogueLineFn &playDialogueLine,
-			const PlayDialogueEntrySequenceFn &playDialogueEntrySequence,
-			const PlayDialogueFstFn &playDialogueFst,
-			const RunKeywordMenuFn &runKeywordMenu,
-			const RunResponseMenuFn &runResponseMenu,
-			const AssignTopicBufferFn &assignTopicBuffer,
-			const MatchesResponseLineFn &matchesResponseLine,
-			const MatchesAnyResponseLineFn &matchesAnyResponseLine,
-			const QueueDialogueInteractionIfNeededFn &queueDialogueInteractionIfNeeded,
-			const ApplyImmediateDialogueInteractionEffectsFn &applyImmediateDialogueInteractionEffects,
-			const GetRandomNumberFn &getRandomNumber,
-			const SetActiveSpeakerPortraitFn &setActiveSpeakerPortrait)
-		: _engine(engine), _startupScript(startupScript), _startupText(startupText),
-		  _startupFlow(startupFlow), _currentRoomName(currentRoomName),
-		  _genericByeTopic(genericByeTopic),
-		  _playDialogueLineWithVariant(playDialogueLineWithVariant),
-		  _playDialogueLine(playDialogueLine), _playDialogueEntrySequence(playDialogueEntrySequence),
-		  _playDialogueFst(playDialogueFst), _runKeywordMenu(runKeywordMenu),
-		  _runResponseMenu(runResponseMenu), _assignTopicBuffer(assignTopicBuffer),
-		  _matchesResponseLine(matchesResponseLine),
-		  _matchesAnyResponseLine(matchesAnyResponseLine),
-		  _queueDialogueInteractionIfNeeded(queueDialogueInteractionIfNeeded),
-		  _applyImmediateDialogueInteractionEffects(applyImmediateDialogueInteractionEffects),
-		  _getRandomNumber(getRandomNumber),
-		  _setActiveSpeakerPortrait(setActiveSpeakerPortrait) {
-	}
-
-	HarvesterEngine &engine() const { return _engine; }
-	StartupScript &startupScript() const { return _startupScript; }
-	StartupText &startupText() const { return _startupText; }
-	StartupFlow &startupFlow() const { return _startupFlow; }
-	const Common::String &currentRoomName() const { return _currentRoomName; }
-	const Common::String &genericByeTopic() const { return _genericByeTopic; }
-
-	Common::Error playDialogueLineWithVariant(int wavId, const Common::String &speakerId,
-			int headVariant) const {
-		return _playDialogueLineWithVariant(wavId, speakerId, headVariant);
-	}
-	Common::Error playDialogueLine(int wavId, const Common::String &speakerId) const {
-		return _playDialogueLine(wavId, speakerId);
-	}
-	Common::Error playDialogueEntrySequence(const DialogueLineEntry *lines, uint count) const {
-		return _playDialogueEntrySequence(lines, count);
-	}
-	Common::Error playDialogueFst(const Common::String &path) const { return _playDialogueFst(path); }
-	Common::Error runKeywordMenu(const Common::String &topicBuffer, int topicBufferLineIndex,
-			Common::String &selectedTopic) const {
-		return _runKeywordMenu(topicBuffer, topicBufferLineIndex, selectedTopic);
-	}
-	Common::Error runResponseMenu(int responseLineIndex, int &responseIndex) const {
-		return _runResponseMenu(responseLineIndex, responseIndex);
-	}
-	void assignTopicBuffer(Common::String &topicBuffer, int &topicBufferLineIndex,
-			int responseLineIndex, const char *label) const {
-		_assignTopicBuffer(topicBuffer, topicBufferLineIndex, responseLineIndex, label);
-	}
-	bool matchesResponseLine(const Common::String &selectedTopic, int responseLineIndex) const {
-		return _matchesResponseLine(selectedTopic, responseLineIndex);
-	}
-	bool matchesAnyResponseLine(const Common::String &selectedTopic,
-			const int *responseLineIndices, uint responseLineCount) const {
-		return _matchesAnyResponseLine(selectedTopic, responseLineIndices, responseLineCount);
-	}
-	void queueDialogueInteractionIfNeeded(const StartupInteractionResult &interaction) const {
-		_queueDialogueInteractionIfNeeded(interaction);
-	}
-	void applyImmediateDialogueInteractionEffects(StartupInteractionResult &interaction) const {
-		_applyImmediateDialogueInteractionEffects(interaction);
-	}
-	int getRandomNumber(int maxValue) const { return _getRandomNumber(maxValue); }
-	void setActiveSpeakerPortrait(const Common::String &speakerId, int headVariant) const {
-		_setActiveSpeakerPortrait(speakerId, headVariant);
-	}
-
-private:
-	HarvesterEngine &_engine;
-	StartupScript &_startupScript;
-	StartupText &_startupText;
-	StartupFlow &_startupFlow;
-	const Common::String &_currentRoomName;
-	const Common::String &_genericByeTopic;
-	PlayDialogueLineWithVariantFn _playDialogueLineWithVariant;
-	PlayDialogueLineFn _playDialogueLine;
-	PlayDialogueEntrySequenceFn _playDialogueEntrySequence;
-	PlayDialogueFstFn _playDialogueFst;
-	RunKeywordMenuFn _runKeywordMenu;
-	RunResponseMenuFn _runResponseMenu;
-	AssignTopicBufferFn _assignTopicBuffer;
-	MatchesResponseLineFn _matchesResponseLine;
-	MatchesAnyResponseLineFn _matchesAnyResponseLine;
-	QueueDialogueInteractionIfNeededFn _queueDialogueInteractionIfNeeded;
-	ApplyImmediateDialogueInteractionEffectsFn _applyImmediateDialogueInteractionEffects;
-	GetRandomNumberFn _getRandomNumber;
-	SetActiveSpeakerPortraitFn _setActiveSpeakerPortrait;
-};
-
 namespace {
 
 static const int kDialogueOverlayX = 167;
@@ -198,310 +77,12 @@ static const int kDialogueOtherStartY = 170;
 static const int kDialogueOtherEndY = 193;
 static const int kDialogueGenericByeResponseIndex = 13;
 static const char *const kDialogueKeywordBitmapPath = "1:/GRAPHIC/OTHER/KEYWORD.BM";
-static const char *const kDialogueRangshotFstPath = "GRAPHIC/FST/RANGSHOT.FST";
-static const char *const kDialogueC008AFstPath = "GRAPHIC/FST/C008A.FST";
-static const char *const kDialogueC008BFstPath = "GRAPHIC/FST/C008B.FST";
-static const char *const kDialogueC043FstPath = "GRAPHIC/FST/C043.FST";
-static const char *const kDialogueC043KFstPath = "GRAPHIC/FST/C043K.FST";
-static const char *const kBabyGurgleActionTag = "BABY_GURGLE";
-static const char *const kShownEvidenceOfBlackmailFlag = "SHOWN_EVIDENCE_OF_BLACKMAIL";
-static const char *const kShownEvidenceSheriffOwnsFlag = "SHOWN_EVIDENCE_SHERIFF_OWNS";
-static const char *const kShownLedgersToAnyoneFlag = "SHOWN_LEDGERS_TO_ANYONE_OTH";
-static const char *const kShownPhotoOfCorpseFlag = "SHOWN_PHOTO_OF_CORPSE_AROUN";
-static const char *const kShownPhotoOfWhaleyHerrillFlag = "SHOWN_PHOTO_OF_WHALEY_HERRI";
-static const char *const kPtaRespondToTvFlag = "PTA_RESPOND_TO_TV";
-static const char *const kPtaMomSpeakerId = "PTA_MOM1";
 
 static const byte kTextColorNormal = 255;
 static const byte kTextColorHover = 251;
 static const byte kShadowColor = 0;
 static const byte kTransparentPaletteIndex = 0;
 
-struct HankDialogueTopicLine {
-	int responseLineIndex;
-	int wavId;
-	const char *speakerId;
-	bool setDiscussedLodgeTopic;
-};
-
-static const HankDialogueTopicLine kHankDialogueTopicLines[] = {
-	{ 0xd2, 0x725, "HANK", false },
-	{ 0xd6, 0x74f, "PC", false },
-	{ 0xd8, 0x76d, "PC", false },
-	{ 0xd9, 0x76d, "PC", false },
-	{ 0xdb, 0x77b, "PC", false },
-	{ 0xdc, 0x77b, "PC", false },
-	{ 0xe0, 0x7ad, "HANK", false },
-	{ 0xe1, 0x7b7, "PC", false },
-	{ 0xe3, 0x7d6, "PC", false },
-	{ 0xe5, 0x7f4, "HANK", false },
-	{ 0xe6, 0x7fc, "PC", false },
-	{ 0xe9, 0x82d, "PC", false },
-	{ 0xeb, 0x863, "PC", false },
-	{ 0xed, 0x881, "PC", false },
-	{ 0xee, 0x8a0, "HANK", false },
-	{ 0xef, 0x8a0, "HANK", false },
-	{ 0xf0, 0x8a0, "HANK", false },
-	{ 0xf1, 0x8bf, "HANK", true }
-};
-
-static const DialogueLineEntry kMomIntroLines[] = {
-	{ 0x1dd7, "MOM", 1 },
-	{ 0x1ddc, "PC", 0 },
-	{ 0x1de0, "MOM", 2 },
-	{ 0x1de4, "PC", 2 }
-};
-
-static const DialogueLineEntry kMomCookiesLines[] = {
-	{ 0x1e18, "PC", 0 },
-	{ 0x1e1d, "MOM", 1 },
-	{ 0x1e21, "PC", 0 },
-	{ 0x1e25, "MOM", 0 },
-	{ 0x1e29, "PC", 0 }
-};
-
-static const DialogueLineEntry kMomTopic119Lines[] = {
-	{ 0x1e47, "PC", 0 },
-	{ 0x1e4b, "MOM", 0 },
-	{ 0x1e4f, "PC", 0 },
-	{ 0x1e53, "MOM", 0 },
-	{ 0x1f99, "MOM", 2 }
-};
-
-static const DialogueLineEntry kMomTopic11dOpeningLines[] = {
-	{ 0x1e7c, "PC", 4 },
-	{ 0x1e80, "MOM", 2 }
-};
-
-static const DialogueLineEntry kMomTopic11dPostC008ALines[] = {
-	{ 0x1e8e, "MOM", 2 },
-	{ 0x1e94, "PC", 2 }
-};
-
-static const DialogueLineEntry kMomTopic11dClosingLines[] = {
-	{ 0x1e9d, "MOM", 3 },
-	{ 0x1ea3, "MOM", 3 }
-};
-
-static const DialogueLineEntry kMomTopic121Lines[] = {
-	{ 0x1ec0, "PC", 0 },
-	{ 0x1ec4, "MOM", 2 },
-	{ 0x1ec9, "PC", 0 },
-	{ 0x1ecd, "MOM", 2 }
-};
-
-static const DialogueLineEntry kMomPtaLines[] = {
-	{ 0x1eee, "PC", 2 },
-	{ 0x1ef2, "MOM", 0 }
-};
-
-static const DialogueLineEntry kMomTopic12bLines[] = {
-	{ 0x1efd, "PC", 0 },
-	{ 0x1f01, "MOM", 0 },
-	{ 0x1f07, "PC", 0 },
-	{ 0x1f0b, "MOM", 0 },
-	{ 0x22cd, "PC", 2 },
-	{ 0x22d1, "MOM", 0 }
-};
-
-static const DialogueLineEntry kMomTopic12dLines[] = {
-	{ 0x1f14, "PC", 0 },
-	{ 0x1f18, "MOM", 0 },
-	{ 0x1f1d, "PC", 0 },
-	{ 0x1f21, "MOM", 0 },
-	{ 0x1f28, "PC", 0 },
-	{ 0x1f2c, "MOM", 2 },
-	{ 0x1f31, "PC", 2 },
-	{ 0x1f35, "MOM", 2 }
-};
-
-static const DialogueLineEntry kMomTopic12fLines[] = {
-	{ 0x1f41, "PC", 4 },
-	{ 0x1f45, "MOM", 3 },
-	{ 0x1f4b, "PC", 0 },
-	{ 0x1f4f, "MOM", 2 }
-};
-
-static const DialogueLineEntry kMomCookingLines[] = {
-	{ 0x1f58, "PC", 0 },
-	{ 0x1f5c, "MOM", 0 },
-	{ 0x1f63, "PC", 0 },
-	{ 0x1f68, "MOM", 0 }
-};
-
-static const DialogueLineEntry kMomTopic135Lines[] = {
-	{ 0x1f71, "PC", 0 },
-	{ 0x1f75, "MOM", 2 }
-};
-
-static const DialogueLineEntry kMomTopic137Lines[] = {
-	{ 0x1f7f, "PC", 2 },
-	{ 0x1f84, "MOM", 3 },
-	{ 0x1f8a, "PC", 2 },
-	{ 0x1f8e, "MOM", 2 }
-};
-
-static const DialogueLineEntry kMomSparkyLines[] = {
-	{ 0x1ed7, "PC", 0 },
-	{ 0x1edb, "MOM", 2 },
-	{ 0x1ee1, "PC", 0 },
-	{ 0x1ee5, "MOM", 2 }
-};
-
-static const DialogueLineEntry kMomFatherLines[] = {
-	{ 0x1fc8, "PC", 0 },
-	{ 0x1fcc, "MOM", 0 }
-};
-
-static const DialogueLineEntry kMomFatherHeardDadMoanLines[] = {
-	{ 0x1fd3, "PC", 0 },
-	{ 0x1fd8, "MOM", 0 }
-};
-
-static const DialogueLineEntry kMomLodgeLines[] = {
-	{ 0x1fe1, "PC", 0 },
-	{ 0x1fe5, "MOM", 1 },
-	{ 0x1feb, "PC", 0 },
-	{ 0x1fef, "MOM", 1 }
-};
-
-static const DialogueLineEntry kMomLodgeResponse1Lines[] = {
-	{ 0x1ffe, "MOM", 1 }
-};
-
-static const DialogueLineEntry kMomLodgeResponse2Lines[] = {
-	{ 0x2005, "MOM", 2 },
-	{ 0x200a, "PC", 0 },
-	{ 0x200e, "MOM", 0 }
-};
-
-static const DialogueLineEntry kMomStephMidgameLodgeLines[] = {
-	{ 0x2154, "PC", 0 },
-	{ 0x215a, "MOM", 0 }
-};
-
-static const DialogueLineEntry kMomBakeSaleLines[] = {
-	{ 0x2017, "PC", 0 },
-	{ 0x201b, "MOM", 1 },
-	{ 0x2020, "PC", 0 },
-	{ 0x2024, "MOM", 0 },
-	{ 0x2029, "PC", 0 },
-	{ 0x202d, "MOM", 0 },
-	{ 0x2164, "PC", 0 },
-	{ 0x2168, "MOM", 1 },
-	{ 0x216d, "PC", 0 },
-	{ 0x2171, "MOM", 0 }
-};
-
-static const DialogueLineEntry kMomTopic14aLines[] = {
-	{ 0x2036, "MOM", 0 },
-	{ 0x203a, "PC", 0 },
-	{ 0x203e, "MOM", 2 }
-};
-
-static const DialogueLineEntry kMomMeatPlantIntroLines[] = {
-	{ 0x20f7, "PC", 0 },
-	{ 0x20fb, "MOM", 0 }
-};
-
-static const DialogueLineEntry kMomMeatPlantResponse1Lines[] = {
-	{ 0x2106, "PC", 0 },
-	{ 0x210a, "MOM", 0 },
-	{ 0x210e, "PC", 0 }
-};
-
-static const DialogueLineEntry kMomMeatPlantResponse2Lines[] = {
-	{ 0x2114, "MOM", 1 },
-	{ 0x2119, "PC", 0 },
-	{ 0x211d, "MOM", 1 }
-};
-
-static const DialogueLineEntry kMomTopic155Lines[] = {
-	{ 0x2125, "MOM", 1 },
-	{ 0x212a, "PC", 0 },
-	{ 0x212e, "MOM", 1 },
-	{ 0x2134, "PC", 0 },
-	{ 0x2138, "MOM", 1 }
-};
-
-static const DialogueLineEntry kMomTopic156Lines[] = {
-	{ 0x2142, "MOM", 0 },
-	{ 0x2147, "PC", 0 },
-	{ 0x214b, "MOM", 0 }
-};
-
-static const DialogueLineEntry kMomGoodCauseDay5Lines[] = {
-	{ 0x21a3, "PC", 0 },
-	{ 0x21a8, "MOM", 1 },
-	{ 0x21ad, "PC", 0 },
-	{ 0x21b2, "MOM", 0 },
-	{ 0x21b7, "PC", 0 }
-};
-
-static const DialogueLineEntry kMomSlaughterhouseLines[] = {
-	{ 0x1faf, "PC", 4 },
-	{ 0x1fb4, "MOM", 0 },
-	{ 0x1fb9, "PC", 2 },
-	{ 0x1fbd, "MOM", 0 }
-};
-
-static const DialogueLineEntry kWaspWomanWhaleyLines[] = {
-	{ 0x4ca6, "WASP_WOMAN", 0 },
-	{ 0x4cae, "WASP_WOMAN", 1 },
-	{ 0x4cb2, "PC", 0 },
-	{ 0x4cb6, "WASP_WOMAN", 0 }
-};
-
-static const DialogueLineEntry kWaspWomanIntroTailLines[] = {
-	{ 0x4bf6, "WASP_WOMAN", 0 },
-	{ 0x4bfc, "PC", 0 }
-};
-
-static const DialogueLineEntry kWaspWomanTopic305Response1Lines[] = {
-	{ 0x4c4d, "WASP_WOMAN", 1 },
-	{ 0x4c53, "WASP_WOMAN", 1 }
-};
-
-static const DialogueLineEntry kPtaMomResponse1Lines[] = {
-	{ 0x3204, kPtaMomSpeakerId, 2 },
-	{ 0x320b, kPtaMomSpeakerId, 2 }
-};
-
-static const DialogueLineEntry kPtaMomResponse2Lines[] = {
-	{ 0x320f, kPtaMomSpeakerId, 0 },
-	{ 0x3213, kPtaMomSpeakerId, 0 }
-};
-
-static const DialogueLineEntry kPtaMomResponse3Lines[] = {
-	{ 0x3217, kPtaMomSpeakerId, 0 },
-	{ 0x321c, kPtaMomSpeakerId, 0 },
-	{ 0x3221, "PC", 0 },
-	{ 0x3226, kPtaMomSpeakerId, 0 }
-};
-
-static const int kMomPtaTopicResponseLines[] = { 0x125, 0x126, 0x127, 0x128 };
-static const int kMomCookingTopicResponseLines[] = { 0x131, 0x132, 0x133 };
-static const int kMomBakeSaleTopicResponseLines[] = { 0x145, 0x146, 0x147 };
-static const int kMomPottsdamTopicResponseLines[] = { 0x14b, 0x14c };
-static const int kMomMeatPlantTopicResponseLines[] = { 0x151, 0x152 };
-static const int kMomMoynahanTopicResponseLines[] = { 0x160, 0x161 };
-static const int kMomNewspaperFireTopicResponseLines[] = { 0x162, 0x163 };
-static const int kMomInitialTopicBufferResponseLine = 0x102;
-static const int kMomSameDayTopicBufferResponseLine = 0x104;
-static const int kMomPostIntroTopicBufferResponseLine = 0x105;
-static const int kMomDay5TopicBufferResponseLine = 0x113;
-static const int kMomDay6TopicBufferResponseLine = 0x114;
-static const int kMomSlaughterhouseFollowupTopicBufferResponseLine = 0x13e;
-static const int kMomFatherFollowupTopicBufferResponseLine = 0x140;
-static const int kMomLodgeFollowupTopicBufferResponseLine = 0x144;
-static const int kMomPtaFollowupTopicBufferResponseLine = 0x12a;
-static const int kMomCookingFollowupTopicBufferResponseLine = 0x134;
-static const int kMomBakeSaleFollowupTopicBufferResponseLine = 0x148;
-static const int kMomPottsdamFollowupTopicBufferResponseLine = 0x150;
-static const int kMomMeatPlantFollowupTopicBufferResponseLine = 0x154;
-static const int kMomGoodCauseDay5FollowupTopicBufferResponseLine = 0x15c;
-static const int kWaspWomanTopicBufferResponseLine = 0x301;
-
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
 	const StartupText *startupText = engine.getStartupText();
 	if (!startupText || !fontName)
@@ -1390,1948 +971,4 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 	return Common::kNoError;
 }
 
-Common::Error EdnaDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &sharedState) {
-	EdnaRoomDialogueState &state = _state;
-	auto playEdnaLine = [&](int wavId, int headVariant = 0) -> Common::Error {
-		return runtime.playDialogueLineWithVariant(wavId, "EDNA", headVariant);
-	};
-
-	if (runtime.startupScript().getFlagValue("KILLED_KARIN1"))
-		return playEdnaLine(0x3cdf, 2);
-
-	if (runtime.startupScript().getFlagValue("DNA_S_SUICIDE_NOTE")) {
-		(void)runtime.startupScript().setRuntimeFlagValue("DNA_S_SUICIDE_NOTE", false);
-		Common::Error lineError = runtime.playDialogueFst(
-			runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE")
-				? kDialogueC043KFstPath
-				: kDialogueC043FstPath);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		sharedState.dialogueStateD2ea4 = true;
-		sharedState.dialogueStateD2ea8 = true;
-		(void)runtime.startupScript().setRuntimeObjectVisible("DNAEXT", "SIGNOUT", true);
-		return Common::kNoError;
-	}
-
-	if (!usedItemName.empty()) {
-		if (usedItemName.equalsIgnoreCase("K_PURSE")) {
-			state.karinPurseLinePlayed = true;
-			return playEdnaLine(0x3d05, 3);
-		}
-		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
-			return playEdnaLine(0x3bd6);
-		}
-		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
-				usedItemName.equalsIgnoreCase("LEDGER2")) &&
-				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownLedgersToAnyoneFlag, true);
-			return playEdnaLine(0x3bd6);
-		}
-		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
-			return playEdnaLine(0x3bde, 2);
-		}
-		if (usedItemName.equalsIgnoreCase("NOTE") ||
-				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
-				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
-				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownEvidenceOfBlackmailFlag, true);
-			return playEdnaLine(0x3be5, 2);
-		}
-		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
-				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownEvidenceSheriffOwnsFlag, true);
-			return playEdnaLine(0x3bee);
-		}
-
-		return playEdnaLine(0x3bcf);
-	}
-
-	if (runtime.startupScript().getFlagValue("BRING_KARIN_TO_SHERIFF")) {
-		Common::Error lineError = playEdnaLine(0x3cce, 2);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		lineError = runtime.playDialogueLine(0x3cd2, "PC");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		lineError = playEdnaLine(0x3cd7, 1);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		(void)runtime.startupScript().addRuntimeObjectToInventory("REWARD_MONEY");
-		(void)runtime.startupScript().setRuntimeFlagValue("BRING_KARIN_TO_SHERIFF", false);
-		return Common::kNoError;
-	}
-
-	if (state.introPending) {
-		state.introPending = false;
-		Common::Error lineError = playEdnaLine(0x3a95);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		int responseIndex = 0;
-		Common::Error responseError = runtime.runResponseMenu(0x9f, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
-
-		if (responseIndex == 1) {
-			lineError = playEdnaLine(0x3aa1, 1);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			lineError = runtime.playDialogueLine(0x3aa6, "PC");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			lineError = playEdnaLine(0x3aaa);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			sharedState.dialogueStateD2f04 = true;
-			lineError = runtime.playDialogueLine(0x3aae, "PC");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		} else if (responseIndex == 2) {
-			const DialogueLineEntry responseLines[] = {
-				{ 0x3ab2, "EDNA", 0 },
-				{ 0x3ab7, "PC", 0 },
-				{ 0x3abb, "EDNA", 0 },
-				{ 0x3ac2, "EDNA", 0 }
-			};
-			lineError = runtime.playDialogueEntrySequence(responseLines, ARRAYSIZE(responseLines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-
-		lineError = playEdnaLine(0x3ac6, 2);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-
-	if (!runtime.startupScript().getFlagValue("KARIN_KIDNAPED")) {
-		Common::Error lineError = playEdnaLine(0x3bf8);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-
-	return playEdnaLine(0x3bff);
-}
-
-Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &sharedState) {
-	static const int kDwayneWhaleyTopicResponseLines[] = { 0x85, 0x86, 0x87 };
-	static const int kDwayneLoomisTopicResponseLines[] = { 0x8c, 0x8d };
-
-	DwayneRoomDialogueState &state = _state;
-	Common::String &dwayneTopicBuffer = state.currentTopicBuffer;
-	int &dwayneTopicBufferLineIndex = state.currentTopicBufferLineIndex;
-	auto assignDwayneTopicBuffer = [&](int responseLineIndex) {
-		runtime.assignTopicBuffer(dwayneTopicBuffer, dwayneTopicBufferLineIndex,
-			responseLineIndex, "Dwayne topic buffer");
-	};
-	auto playDwayneLine = [&](int wavId, int headVariant = 0) -> Common::Error {
-		return runtime.playDialogueLineWithVariant(wavId, "DWAYNE", headVariant);
-	};
-	auto playEdnaLine = [&](int wavId, int headVariant = 0) -> Common::Error {
-		return runtime.playDialogueLineWithVariant(wavId, "EDNA", headVariant);
-	};
-	auto hasInventoryItem = [&](const char *objectName) {
-		Common::Array<StartupObjectRecord> inventoryObjects;
-		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
-		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
-			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
-				return true;
-		}
-
-		return false;
-	};
-	auto setDwayneIntroduced = [&]() {
-		(void)runtime.startupScript().setRuntimeFlagValue("DWAYNE_INTRODUCED", true);
-	};
-	const bool sheriffInDiner = runtime.startupScript().getFlagValue("SHERIFF_IN_DINER");
-
-	if (runtime.startupScript().getFlagValue("ARREST_FLAG")) {
-		(void)runtime.startupScript().setRuntimeFlagValue("ARREST_FLAG", false);
-		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_MOYNAHAN"))
-			return playDwayneLine(0x38d6, 2);
-		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_JIMMY"))
-			return playDwayneLine(0x384d, 2);
-		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_JOHNSON"))
-			return playDwayneLine(0x386d, 2);
-		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_PHELPS"))
-			return playDwayneLine(0x38bc, 2);
-		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_OREILLY"))
-			return playDwayneLine(0x38b5, 2);
-		if (runtime.startupScript().getFlagValue("IF_KILL_POTTSDAM_AT_GRAVE"))
-			return playDwayneLine(0x37ed, 2);
-		if (runtime.startupScript().getFlagValue("GENERIC_BUST"))
-			return playDwayneLine(0x38fc, 2);
-		if (runtime.startupScript().getFlagValue("DNALFT_PERVERT"))
-			return playDwayneLine(0x3580, 2);
-		if (runtime.startupScript().getFlagValue("PC_HAS_GOOJF_CARD") &&
-				!runtime.startupScript().getFlagValue("PC_FRY_IN_CHAIR")) {
-			return playDwayneLine(0x3814);
-		}
-		if (!runtime.startupScript().getFlagValue("PC_FRY_IN_CHAIR")) {
-			if (runtime.startupScript().getFlagValue("BUSTED_THIRD"))
-				return playDwayneLine(0x3928, 2);
-			if (runtime.startupScript().getFlagValue("BUSTED_TWICE"))
-				return playDwayneLine(0x3921, 2);
-			if (runtime.startupScript().getFlagValue("BUSTED_ONCE"))
-				return playDwayneLine(0x3918);
-		}
-		return Common::kNoError;
-	}
-
-	if (runtime.startupScript().getFlagValue("CHECK_EVIDENCE_DOOR"))
-		return playDwayneLine(0x3386);
-	if (runtime.startupScript().getFlagValue("PC_TRIES_TO_TAKE_STEPHANIES_REMAINS"))
-		return playDwayneLine(0x34e3, 2);
-	if (runtime.currentRoomName().equalsIgnoreCase("ST_BEDRM")) {
-		if (!runtime.startupScript().getFlagValue("SD_TALKED_OF_CARD")) {
-			Common::Error lineError = playDwayneLine(0x34da);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-		return playDwayneLine(0x3395);
-	}
-
-	if (runtime.startupScript().getFlagValue("BRING_KARIN_TO_SHERIFF") &&
-			!sheriffInDiner &&
-			!state.bringKarinToSheriffLinePlayed) {
-		state.pendingKarinAliveFollowup = true;
-		state.pendingKarinAliveFollowupDayIndex =
-			runtime.startupScript().getCurrentStoryDayIndex();
-		state.bringKarinToSheriffLinePlayed = true;
-		return playDwayneLine(0x3750);
-	}
-	if (runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE")) {
-		state.pendingKarinAliveFollowup = true;
-		state.pendingKarinAliveFollowupDayIndex =
-			runtime.startupScript().getCurrentStoryDayIndex();
-	}
-
-	if (!usedItemName.empty()) {
-		if (usedItemName.equalsIgnoreCase("BOYLES_BUTTON")) {
-			state.discussedBoylesButton = true;
-			return playDwayneLine(0x339b);
-		}
-		if (usedItemName.equalsIgnoreCase("K_PURSE")) {
-			state.discussedKarinPurse = true;
-			return playDwayneLine(0x37cb);
-		}
-		if (usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY") ||
-				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY")) {
-			return playDwayneLine(0x33b9, 2);
-		}
-		if (usedItemName.equalsIgnoreCase("CHECKBOOK") ||
-				usedItemName.equalsIgnoreCase("NOTE")) {
-			if (state.presentedEvidenceReplyOverride)
-				return playDwayneLine(0x342c);
-
-			Common::Error lineError = playDwayneLine(0x33f4, 2);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-
-			int responseIndex = 0;
-			Common::Error responseError = runtime.runResponseMenu(0x70, responseIndex);
-			if (responseError.getCode() != Common::kNoError)
-				return responseError;
-			lineError = Common::kNoError;
-			if (responseIndex == 1) {
-				lineError = playDwayneLine(0x33c3, 2);
-			} else if (responseIndex == 2) {
-				lineError = playDwayneLine(0x3403, 2);
-			}
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-
-			lineError = playDwayneLine(0x3407, 2);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			lineError = playDwayneLine(0x340d);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-
-			responseIndex = 0;
-			responseError = runtime.runResponseMenu(0x71, responseIndex);
-			if (responseError.getCode() != Common::kNoError)
-				return responseError;
-			if (responseIndex == 0)
-				return Common::kNoError;
-			if (responseIndex == 1)
-				return playDwayneLine(0x341a, 2);
-			if (responseIndex == 2)
-				return playDwayneLine(0x3422, 2);
-			return Common::kNoError;
-		}
-		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
-					usedItemName.equalsIgnoreCase("LEDGER2")) &&
-				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownLedgersToAnyoneFlag, true);
-			return playDwayneLine(0x3452);
-		}
-		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
-			return playDwayneLine(0x3975, 3);
-		}
-		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
-			return playDwayneLine(0x3942, 2);
-		}
-		if (state.tvDeedReplyOverride &&
-				usedItemName.equalsIgnoreCase("TV_DEED")) {
-			return playDwayneLine(0x3686);
-		}
-		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
-				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
-			return playDwayneLine(0x3935);
-		}
-		return playDwayneLine(0x3608);
-	}
-
-	if (state.sheriffInDinerIntroPending && sheriffInDiner) {
-		state.sheriffInDinerIntroPending = false;
-		state.sheriffInDinerIntroPlayed = true;
-		setDwayneIntroduced();
-
-		const DialogueLineEntry introLines[] = {
-			{ 0x3482, "DWAYNE", 0 },
-			{ 0x3486, "PC", 0 },
-			{ 0x348b, "DWAYNE", 1 },
-			{ 0x3490, "PC", 0 }
-		};
-		Common::Error lineError = runtime.playDialogueEntrySequence(introLines, ARRAYSIZE(introLines));
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED")) {
-			sharedState.karinKidnapedDialogueState = true;
-			lineError = playEdnaLine(0x3496, 3);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-		lineError = playDwayneLine(0x349b, 1);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		return playDwayneLine(0x34ac);
-	}
-
-	if (state.sheriffInDinerIntroPlayed && sheriffInDiner)
-		return playDwayneLine(0x34ac);
-
-	if (state.pendingInitialConversation && !sheriffInDiner) {
-		state.pendingInitialConversation = false;
-		state.eventFollowupGate = true;
-		setDwayneIntroduced();
-
-		const DialogueLineEntry initialLines[] = {
-			{ 0x3245, "DWAYNE", 1 },
-			{ 0x324a, "LOOMIS", 1 },
-			{ 0x324e, "PC", 0 },
-			{ 0x3252, "DWAYNE", 0 }
-		};
-		Common::Error lineError = runtime.playDialogueEntrySequence(initialLines, ARRAYSIZE(initialLines));
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		assignDwayneTopicBuffer(0x78);
-		if (sharedState.boyleGascanApplicationState)
-			(void)runtime.startupScript().setRuntimeFlagValue("MOVE_SHERIFF", true);
-	}
-
-	if (state.eventFollowupGate && !sharedState.boyleGascanApplicationState) {
-		Common::Error lineError = playDwayneLine(0x34d3, 2);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-
-	if (state.whaleyDisciplineFollowupState &&
-			!state.whaleyDisciplineFollowupShown) {
-		state.whaleyDisciplineFollowupShown = true;
-		Common::Error lineError = playDwayneLine(0x34b3, 1);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		int responseIndex = 0;
-		Common::Error responseError = runtime.runResponseMenu(0x79, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
-		lineError = Common::kNoError;
-		if (responseIndex == 1) {
-			lineError = playDwayneLine(0x34bf, 2);
-		} else if (responseIndex == 2) {
-			lineError = playDwayneLine(0x34c5, 1);
-		}
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		lineError = playDwayneLine(0x34cb, 1);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-
-	if (runtime.startupScript().getFlagValue(kShownEvidenceOfBlackmailFlag) &&
-			!state.noteCheckbookFollowupShown) {
-		state.noteCheckbookFollowupShown = true;
-		Common::Error lineError = playDwayneLine(0x355f, 2);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-
-	if (!sheriffInDiner) {
-		if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER") &&
-				!state.scratchedTuckerShown) {
-			state.scratchedTuckerShown = true;
-			Common::Error lineError = playDwayneLine(0x358b, 1);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-		if (runtime.startupScript().getFlagValue("BOLT_OF_CLOTH_TAKEN") &&
-				!state.boltOfClothTakenShown) {
-			state.boltOfClothTakenShown = true;
-			Common::Error lineError = playDwayneLine(0x3594, 2);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-		if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") &&
-				!state.barberPoleStolenShown) {
-			state.barberPoleStolenShown = true;
-			Common::Error lineError = playDwayneLine(0x359d, 1);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-		if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
-				!state.dinerBurnedShown) {
-			state.dinerBurnedShown = true;
-			Common::Error lineError = playDwayneLine(0x35bc, 3);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-		if (runtime.startupScript().getFlagValue("PC_ESCAPED_JAIL") &&
-				!state.escapedJailShown) {
-			state.escapedJailShown = true;
-			Common::Error lineError = playDwayneLine(0x35cd);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-		if (runtime.startupScript().getFlagValue("GOT_REMAINS_FOR_LODGE") &&
-				!state.gotRemainsForLodgeShown) {
-			state.gotRemainsForLodgeShown = true;
-			Common::Error lineError = playDwayneLine(0x35d7, 1);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-		if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
-				!state.burnedTvStationShown) {
-			state.burnedTvStationShown = true;
-			Common::Error lineError = playDwayneLine(0x35f6, 2);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-		if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
-				!state.karinKidnapedShown) {
-			state.karinKidnapedShown = true;
-			Common::Error lineError = playDwayneLine(0x36b2, 3);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-	}
-
-	const int currentStoryDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
-	if (state.pendingKarinAliveFollowup &&
-			currentStoryDayIndex != state.pendingKarinAliveFollowupDayIndex &&
-			!state.pendingKarinAliveFollowupLinePlayed) {
-		state.pendingKarinAliveFollowupLinePlayed = true;
-		state.pendingKarinAliveFollowup = false;
-		state.completedKarinAliveFollowup = true;
-
-		const DialogueLineEntry lines[] = {
-			{ 0x3780, "DWAYNE", 0 },
-			{ 0x3786, "PC", 4 },
-			{ 0x378a, "DWAYNE", 2 }
-		};
-		Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-
-	if (runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
-			!state.discussedKarinPurse &&
-			!sheriffInDiner &&
-			!state.karinFoundDeadWithoutPurseShown) {
-		state.karinFoundDeadWithoutPurseShown = true;
-		Common::Error lineError = playDwayneLine(0x3793);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-
-	if (!runtime.startupScript().isNamedNpcDeathTypeClear("JIMMY") &&
-			!sheriffInDiner &&
-			!state.jimmyAbsentShown) {
-		state.jimmyAbsentShown = true;
-		Common::Error lineError = playDwayneLine(0x3855, 3);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		if (runtime.startupScript().getFlagValue("JIMMY_ATTACKED")) {
-			lineError = playDwayneLine(0x37f8);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-
-			int responseIndex = 0;
-			Common::Error responseError = runtime.runResponseMenu(0x81, responseIndex);
-			if (responseError.getCode() != Common::kNoError)
-				return responseError;
-			lineError = Common::kNoError;
-			if (responseIndex == 1) {
-				lineError = playDwayneLine(0x3805, 1);
-			} else if (responseIndex == 2) {
-				lineError = playDwayneLine(0x380c);
-			}
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-	}
-
-	if (!runtime.startupScript().isNamedNpcDeathTypeClear("MOYNAHAN") &&
-			!sheriffInDiner &&
-			!state.moynahanAbsentShown) {
-		state.moynahanAbsentShown = true;
-		Common::Error lineError = playDwayneLine(0x38ce);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-
-	if (runtime.startupScript().getFlagValue("EDNA_HUNG") &&
-			!state.ednaHungShown) {
-		state.ednaHungShown = true;
-		Common::Error lineError = playDwayneLine(0x38f1, 3);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-
-	if (!runtime.startupScript().isNamedNpcDeathTypeClear("MCKNIGHT") &&
-			!sheriffInDiner &&
-			!state.mcknightAbsentShown) {
-		state.mcknightAbsentShown = true;
-		Common::Error lineError = hasInventoryItem("TV_DEED")
-			? playDwayneLine(0x3904, 3)
-			: playDwayneLine(0x390f, 1);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-
-	Common::String selectedTopic;
-	for (;;) {
-		if (runtime.matchesResponseLine(selectedTopic, 0x82))
-			return playDwayneLine(0x3395);
-
-		Common::Error menuError = runtime.runKeywordMenu(
-			dwayneTopicBuffer, dwayneTopicBufferLineIndex, selectedTopic);
-		if (menuError.getCode() != Common::kNoError)
-			return menuError;
-		if (selectedTopic.empty())
-			return Common::kNoError;
-
-		if (runtime.matchesResponseLine(selectedTopic, 0x83)) {
-			Common::Error lineError = playDwayneLine(0x326e);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignDwayneTopicBuffer(0x84);
-			continue;
-		}
-
-		if (runtime.matchesAnyResponseLine(selectedTopic, kDwayneWhaleyTopicResponseLines,
-				ARRAYSIZE(kDwayneWhaleyTopicResponseLines))) {
-			if (!sheriffInDiner) {
-				const DialogueLineEntry lines[] = {
-					{ 0x327b, "DWAYNE", 0 },
-					{ 0x3280, "PC", 2 },
-					{ 0x3285, "DWAYNE", 0 },
-					{ 0x328b, "LOOMIS", 1 },
-					{ 0x3290, "DWAYNE", 2 },
-					{ 0x3294, "LOOMIS", 3 },
-					{ 0x3299, "PC", 4 },
-					{ 0x329e, "DWAYNE", 1 }
-				};
-				Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				sharedState.dialogueStateD2f04 = true;
-				assignDwayneTopicBuffer(0x89);
-			}
-			continue;
-		}
-
-		if (runtime.matchesAnyResponseLine(selectedTopic, kDwayneLoomisTopicResponseLines,
-				ARRAYSIZE(kDwayneLoomisTopicResponseLines))) {
-			if (!sheriffInDiner) {
-				const DialogueLineEntry lines[] = {
-					{ 0x32bf, "DWAYNE", 1 },
-					{ 0x32c1, "DWAYNE", 0 },
-					{ 0x32c7, "LOOMIS", 3 },
-					{ 0x32cb, "DWAYNE", 0 },
-					{ 0x32d1, "DWAYNE", 1 }
-				};
-				Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				assignDwayneTopicBuffer(0x8e);
-			}
-			continue;
-		}
-
-		if (runtime.matchesResponseLine(selectedTopic, 0x8f)) {
-			const DialogueLineEntry lines[] = {
-				{ 0x32db, "DWAYNE", 0 },
-				{ 0x32e2, "PC", 4 },
-				{ 0x32e6, "DWAYNE", 0 }
-			};
-			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignDwayneTopicBuffer(0x90);
-			continue;
-		}
-
-		if (runtime.matchesResponseLine(selectedTopic, 0x91)) {
-			const DialogueLineEntry lines[] = {
-				{ 0x32f2, "DWAYNE", 0 },
-				{ 0x32f9, "PC", 0 },
-				{ 0x32fd, "DWAYNE", 0 },
-				{ 0x32fe, "DWAYNE", 0 },
-				{ 0x32ff, "DWAYNE", 0 }
-			};
-			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignDwayneTopicBuffer(0x92);
-			continue;
-		}
-
-		if (runtime.matchesResponseLine(selectedTopic, 0x93)) {
-			if (!runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED")) {
-				Common::Error lineError = playDwayneLine(0x330d, 1);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-			}
-			const DialogueLineEntry lines[] = {
-				{ 0x331f, "DWAYNE", 3 },
-				{ 0x3320, "DWAYNE", 0 },
-				{ 0x3321, "DWAYNE", 0 },
-				{ 0x332c, "DWAYNE", 0 }
-			};
-			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignDwayneTopicBuffer(0x94);
-			continue;
-		}
-
-		if (runtime.matchesResponseLine(selectedTopic, 0x95)) {
-			sharedState.discussedLodgeTopic = true;
-			Common::Error lineError = playDwayneLine(0x3339);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-
-			int responseIndex = 0;
-			Common::Error responseError = runtime.runResponseMenu(0x96, responseIndex);
-			if (responseError.getCode() != Common::kNoError)
-				return responseError;
-			lineError = Common::kNoError;
-			if (responseIndex == 1) {
-				const DialogueLineEntry responseLines[] = {
-					{ 0x3345, "DWAYNE", 2 },
-					{ 0x3346, "DWAYNE", 2 },
-					{ 0x3347, "DWAYNE", 2 }
-				};
-				lineError = runtime.playDialogueEntrySequence(responseLines, ARRAYSIZE(responseLines));
-			} else if (responseIndex == 2) {
-				lineError = playDwayneLine(0x3350);
-			}
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignDwayneTopicBuffer(0x97);
-			continue;
-		}
-
-		if (runtime.matchesResponseLine(selectedTopic, 0x98)) {
-			const DialogueLineEntry lines[] = {
-				{ 0x335b, "DWAYNE", 2 },
-				{ 0x3360, "PC", 2 },
-				{ 0x3364, "DWAYNE", 3 }
-			};
-			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			lineError = sharedState.dialogueStateD2f04
-				? playDwayneLine(0x3368, 2)
-				: playDwayneLine(0x336c, 2);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			lineError = playDwayneLine(0x3370, 2);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignDwayneTopicBuffer(0x99);
-			continue;
-		}
-
-		if (runtime.matchesResponseLine(selectedTopic, 0x9a)) {
-			Common::Error lineError = playDwayneLine(0x337a, 2);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignDwayneTopicBuffer(0x9b);
-			continue;
-		}
-
-		if (runtime.matchesResponseLine(selectedTopic, 0x9c)) {
-			const DialogueLineEntry lines[] = {
-				{ 0x3386, "DWAYNE", 0 },
-				{ 0x3387, "DWAYNE", 0 }
-			};
-			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignDwayneTopicBuffer(0x9d);
-			continue;
-		}
-
-		if (runtime.matchesResponseLine(selectedTopic, 0x9e))
-			continue;
-
-		Common::Error lineError = playDwayneLine(0x393c);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-}
-
-Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &) {
-	JimmyRoomDialogueState &state = _state;
-
-	auto playJimmyLine = [&](int wavId, int headVariant) -> Common::Error {
-		return runtime.playDialogueLineWithVariant(wavId, "JIMMY", headVariant);
-	};
-	auto hasInventoryItem = [&](const char *objectName) {
-		Common::Array<StartupObjectRecord> inventoryObjects;
-		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
-		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
-			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
-				return true;
-		}
-
-		return false;
-	};
-
-	if (!usedItemName.empty()) {
-		if (usedItemName.equalsIgnoreCase("NEWSPAPER")) {
-			StartupInteractionResult jimmyInteraction;
-			const bool changedGivenPaperToday =
-				runtime.startupScript().setRuntimeFlagValue("GIVEN_PAPER_TODAY", true);
-			const bool changedNewspaperState =
-				runtime.startupScript().resetRuntimeObjectToInitialState("NEWSPAPER");
-			jimmyInteraction.mutatedRuntimeState = changedGivenPaperToday || changedNewspaperState;
-
-			StartupInteractionResult actionInteraction;
-			if (runtime.startupScript().executeNestedActionTag("ACTV_HOUSE_EXIT", actionInteraction)) {
-				jimmyInteraction.abortRemainingCommandChain =
-					jimmyInteraction.abortRemainingCommandChain ||
-					actionInteraction.abortRemainingCommandChain;
-				jimmyInteraction.mutatedRuntimeState =
-					jimmyInteraction.mutatedRuntimeState || actionInteraction.mutatedRuntimeState;
-				if (!actionInteraction.musicPath.empty())
-					jimmyInteraction.musicPath = actionInteraction.musicPath;
-				if (!actionInteraction.nextRoomName.empty())
-					jimmyInteraction.nextRoomName = actionInteraction.nextRoomName;
-				if (!actionInteraction.deathFlicPath.empty())
-					jimmyInteraction.deathFlicPath = actionInteraction.deathFlicPath;
-				if (!actionInteraction.dialogueNpcName.empty())
-					jimmyInteraction.dialogueNpcName = actionInteraction.dialogueNpcName;
-				if (!actionInteraction.dialogueContinuationTag.empty())
-					jimmyInteraction.dialogueContinuationTag = actionInteraction.dialogueContinuationTag;
-				jimmyInteraction.requestMainMenu =
-					jimmyInteraction.requestMainMenu || actionInteraction.requestMainMenu;
-				for (const StartupAudioCommand &command : actionInteraction.audioCommands)
-					jimmyInteraction.audioCommands.push_back(command);
-			}
-			runtime.queueDialogueInteractionIfNeeded(jimmyInteraction);
-
-			if (!state.paperHandoffStateSet) {
-				state.paperHandoffStateSet = true;
-				Common::Error lineError = playJimmyLine(0x4a4c, 1);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-			}
-
-			return runtime.getRandomNumber(1) == 0
-				? playJimmyLine(0x4acc, 0)
-				: playJimmyLine(0x4a4b, 1);
-		}
-		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
-			return playJimmyLine(0x4af8, 1);
-		}
-		if (((usedItemName.equalsIgnoreCase("LEDGER") ||
-					usedItemName.equalsIgnoreCase("LEDGER2")) &&
-				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) ||
-				usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-			if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-					usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-				(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
-			} else {
-				(void)runtime.startupScript().setRuntimeFlagValue(kShownLedgersToAnyoneFlag, true);
-			}
-			return playJimmyLine(0x4b00, 0);
-		}
-		if (usedItemName.equalsIgnoreCase("NOTE") ||
-				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
-				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
-				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownEvidenceOfBlackmailFlag, true);
-			return playJimmyLine(0x4b21, 0);
-		}
-		if (usedItemName.equalsIgnoreCase("SNEAKERS")) {
-			Common::Error lineError = playJimmyLine(0x4a9e, 1);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-
-		return playJimmyLine(0x4af2, 0);
-	}
-
-	if (state.firstNoItemLinePending) {
-		state.firstNoItemLinePending = false;
-		if (!state.paperHandoffStateSet) {
-			Common::Error lineError = playJimmyLine(0x4a4c, 1);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-
-		Common::Error lineError = playJimmyLine(0x4a58, 0);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (hasInventoryItem("SNEAKERS") && !hasInventoryItem("BROOMKEY")) {
-		Common::Error lineError = playJimmyLine(0x4ac3, 0);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (runtime.startupScript().getFlagValue("PAPER_CHK_4")) {
-		Common::Error lineError = playJimmyLine(0x4ae2, 2);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (runtime.startupScript().getFlagValue("PAPER_CHK_3")) {
-		Common::Error lineError = playJimmyLine(0x4adb, 2);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (runtime.startupScript().getFlagValue("PAPER_CHK_2")) {
-		Common::Error lineError = playJimmyLine(0x4ad4, 2);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-
-	return playJimmyLine(0x4b38, 0);
-}
-
-Common::Error PtaMomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &) {
-	(void)usedItemName;
-
-	auto playPtaMomLine = [&](int wavId, int headVariant) -> Common::Error {
-		return runtime.playDialogueLineWithVariant(wavId, kPtaMomSpeakerId, headVariant);
-	};
-
-	if (runtime.startupScript().getFlagValue(kPtaRespondToTvFlag)) {
-		Common::Error lineError = playPtaMomLine(0x3233, 2);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		(void)runtime.startupScript().setRuntimeFlagValue(kPtaRespondToTvFlag, false);
-		return Common::kNoError;
-	}
-
-	Common::Error lineError = Common::kNoError;
-	switch (runtime.getRandomNumber(2)) {
-	case 0:
-		lineError = playPtaMomLine(0x31ee, 1);
-		break;
-	case 1:
-		lineError = playPtaMomLine(0x31f2, 1);
-		break;
-	default:
-		lineError = playPtaMomLine(0x31f6, 1);
-		break;
-	}
-	if (lineError.getCode() != Common::kNoError)
-		return lineError;
-
-	int responseIndex = 0;
-	Common::Error responseError = runtime.runResponseMenu(0x297, responseIndex);
-	if (responseError.getCode() != Common::kNoError)
-		return responseError;
-
-	switch (responseIndex) {
-	case 1:
-		return runtime.playDialogueEntrySequence(kPtaMomResponse1Lines, ARRAYSIZE(kPtaMomResponse1Lines));
-	case 2:
-		return runtime.playDialogueEntrySequence(kPtaMomResponse2Lines, ARRAYSIZE(kPtaMomResponse2Lines));
-	case 3:
-		return runtime.playDialogueEntrySequence(kPtaMomResponse3Lines, ARRAYSIZE(kPtaMomResponse3Lines));
-	default:
-		return Common::kNoError;
-	}
-}
-
-Common::Error WaspWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &sharedState) {
-	WaspWomanRoomDialogueState &state = _state;
-	Common::String waspWomanTopicBuffer;
-	int waspWomanTopicBufferLineIndex = -1;
-	auto assignWaspWomanTopicBuffer = [&](int responseLineIndex) {
-		runtime.assignTopicBuffer(waspWomanTopicBuffer, waspWomanTopicBufferLineIndex,
-			responseLineIndex, "Wasp Woman topic buffer");
-	};
-	auto playWaspWomanLine = [&](int wavId, int headVariant) -> Common::Error {
-		return runtime.playDialogueLineWithVariant(wavId, "WASP_WOMAN", headVariant);
-	};
-
-	if (!usedItemName.empty()) {
-		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
-			return runtime.playDialogueEntrySequence(kWaspWomanWhaleyLines,
-				ARRAYSIZE(kWaspWomanWhaleyLines));
-		}
-		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
-			return playWaspWomanLine(0x4cbd, 0);
-		}
-		if (usedItemName.equalsIgnoreCase("NOTE") ||
-				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
-				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
-				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownEvidenceOfBlackmailFlag, true);
-			return playWaspWomanLine(0x4cc3, 0);
-		}
-
-		return playWaspWomanLine(0x4ca0, 0);
-	}
-
-	if (state.introPending) {
-		state.introPending = false;
-
-		Common::Error lineError = playWaspWomanLine(0x4bee, 1);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		if (sharedState.waspWomanDialogueState) {
-			lineError = runtime.playDialogueLine(0x4bf2, "PC");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-		lineError = runtime.playDialogueEntrySequence(kWaspWomanIntroTailLines,
-			ARRAYSIZE(kWaspWomanIntroTailLines));
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-
-	Common::Error lineError = playWaspWomanLine(0x4bee, 0);
-	if (lineError.getCode() != Common::kNoError)
-		return lineError;
-	assignWaspWomanTopicBuffer(kWaspWomanTopicBufferResponseLine);
-
-	for (;;) {
-		Common::String selectedTopic;
-		Common::Error menuError = runtime.runKeywordMenu(
-			waspWomanTopicBuffer, waspWomanTopicBufferLineIndex, selectedTopic);
-		if (menuError.getCode() != Common::kNoError)
-			return menuError;
-		if (selectedTopic.empty() || selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()))
-			return Common::kNoError;
-
-		if (runtime.matchesResponseLine(selectedTopic, 0x302)) {
-			lineError = playWaspWomanLine(0x4c0a, 0);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x305)) {
-			lineError = playWaspWomanLine(0x4c31, 0);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			if (!sharedState.waspWomanDialogueState)
-				continue;
-
-			int responseIndex = 0;
-			Common::Error responseError = runtime.runResponseMenu(0x307, responseIndex);
-			if (responseError.getCode() != Common::kNoError)
-				return responseError;
-
-			switch (responseIndex) {
-			case 1:
-				lineError = runtime.playDialogueEntrySequence(kWaspWomanTopic305Response1Lines,
-					ARRAYSIZE(kWaspWomanTopic305Response1Lines));
-				break;
-			case 2:
-				lineError = playWaspWomanLine(0x4c5e, 2);
-				break;
-			default:
-				lineError = Common::kNoError;
-				break;
-			}
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x308)) {
-			lineError = playWaspWomanLine(0x4c67, 0);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x30a)) {
-			lineError = playWaspWomanLine(0x4c74, 1);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x30c)) {
-			lineError = playWaspWomanLine(0x4c85, 0);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x30e)) {
-			lineError = playWaspWomanLine(0x4c9a, 0);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			return Common::kNoError;
-		}
-	}
-}
-
-Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &sharedState) {
-	const int currentStoryDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
-	MomRoomDialogueState &state = _state;
-	Common::String momTopicBuffer;
-	int momTopicBufferLineIndex = -1;
-	auto assignMomTopicBuffer = [&](int responseLineIndex) {
-		runtime.assignTopicBuffer(momTopicBuffer, momTopicBufferLineIndex,
-			responseLineIndex, "Mom topic buffer");
-	};
-	auto runMomGoodbye = [&]() -> Common::Error {
-		if (!runtime.startupScript().getFlagValue("DAY_FLAG"))
-			return runtime.playDialogueLine(0x256e, "MOM");
-		if (!runtime.startupScript().isNamedNpcDeathTypeClear("JIMMY"))
-			return Common::kNoError;
-
-		return runtime.playDialogueLine(0x2051, "MOM");
-	};
-
-	if (!usedItemName.empty()) {
-		if (usedItemName.equalsIgnoreCase("NOTE") ||
-				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
-				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
-				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownEvidenceOfBlackmailFlag, true);
-			return runtime.playDialogueLine(0x2317, "MOM");
-		}
-		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
-				usedItemName.equalsIgnoreCase("LEDGER2")) &&
-				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownLedgersToAnyoneFlag, true);
-			return runtime.playDialogueLine(0x2320, "MOM");
-		}
-		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
-			return runtime.playDialogueLine(0x233b, "PC");
-		}
-		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
-			return runtime.playDialogueLine(0x239c, "MOM");
-		}
-		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
-				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownEvidenceSheriffOwnsFlag, true);
-			return runtime.playDialogueLine(0x2382, "MOM");
-		}
-
-		return runtime.playDialogueLine(0x26cc, "MOM");
-	}
-
-	bool skipMomDefaultKeywordSeed = false;
-	if (state.introPending) {
-		state.introPending = false;
-		state.sameDayIntroLineEnabled = true;
-		state.postIntroDefaultLineEnabled = true;
-		state.introDayIndex = currentStoryDayIndex;
-		Common::Error lineError = runtime.playDialogueEntrySequence(kMomIntroLines, ARRAYSIZE(kMomIntroLines));
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		assignMomTopicBuffer(kMomInitialTopicBufferResponseLine);
-		skipMomDefaultKeywordSeed = true;
-	}
-	if (!skipMomDefaultKeywordSeed && state.sameDayIntroLineEnabled &&
-			currentStoryDayIndex == state.introDayIndex) {
-		Common::Error lineError = runtime.playDialogueLine(0x2047, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		assignMomTopicBuffer(kMomSameDayTopicBufferResponseLine);
-		skipMomDefaultKeywordSeed = true;
-	}
-	if (!skipMomDefaultKeywordSeed && state.postIntroDefaultLineEnabled) {
-		Common::Error lineError = runtime.playDialogueLine(0x2311, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		assignMomTopicBuffer(kMomPostIntroTopicBufferResponseLine);
-	}
-	if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
-			!state.stephMidgameShown) {
-		state.stephMidgameShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x205a, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
-			(runtime.startupScript().getFlagValue("KARIN_KIDNAPED") ||
-				runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD")) &&
-			!state.dinerBurnedKarinMissingOrDeadShown) {
-		state.dinerBurnedKarinMissingOrDeadShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x2456, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
-			!state.burnedTvStationShown) {
-		state.burnedTvStationShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x2220, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER") &&
-			!state.scratchedTuckerShown) {
-		state.scratchedTuckerShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x23f3, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") &&
-			!state.barberPoleStolenShown) {
-		state.barberPoleStolenShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x23fb, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (runtime.startupScript().getFlagValue("BOLT_OF_CLOTH_TAKEN") &&
-			!state.boltOfClothTakenShown) {
-		state.boltOfClothTakenShown = true;
-		if (runtime.startupScript().isNamedNpcDeathTypeClear("SPARKY") &&
-				runtime.startupScript().isNamedNpcDeathTypeClear("FIREMAN2")) {
-			Common::Error lineError = runtime.playDialogueLine(0x2416, "MOM");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-		Common::Error lineError = runtime.playDialogueLine(0x2420, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
-			runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
-			!state.dinerBurnedKarinAliveShown) {
-		state.dinerBurnedKarinAliveShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x2434, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (runtime.startupScript().getFlagValue("PC_ESCAPED_JAIL") &&
-			!state.escapedJailShown) {
-		state.escapedJailShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x2495, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
-			!runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
-			!runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
-			!state.karinKidnapedUnresolvedShown) {
-		state.karinKidnapedUnresolvedShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x24d7, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
-			!state.karinFoundAliveShown) {
-		state.karinFoundAliveShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x2505, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
-			!state.karinFoundDeadShown) {
-		state.karinFoundDeadShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x2576, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (!runtime.startupScript().isNamedNpcDeathTypeClear("BUTCHER") &&
-			!state.butcherAbsentShown) {
-		state.butcherAbsentShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x2633, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (!runtime.startupScript().isNamedNpcDeathTypeClear("MOYNAHAN") &&
-			!state.moynahanAbsentShown) {
-		state.moynahanAbsentShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x2647, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (!runtime.startupScript().isNamedNpcDeathTypeClear("JIMMY") &&
-			!state.jimmyAbsentShown) {
-		state.jimmyAbsentShown = true;
-		if (runtime.startupScript().getFlagValue("JIMMY_ATTACKED")) {
-			Common::Error lineError = runtime.playDialogueLine(0x2659, "MOM");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-		Common::Error lineError = runtime.playDialogueLine(0x266c, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (!runtime.startupScript().isNamedNpcDeathTypeClear("WASP_WOMAN") &&
-			!state.waspWomanAbsentShown) {
-		state.waspWomanAbsentShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x2689, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD") &&
-			!runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
-			!state.stephanieDeadPreMidgameShown) {
-		state.stephanieDeadPreMidgameShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x217c, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (runtime.startupScript().getFlagValue("DAY_5") && !state.day5Shown) {
-		state.day5Shown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x218d, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		assignMomTopicBuffer(kMomDay5TopicBufferResponseLine);
-	}
-	if (runtime.startupScript().getFlagValue("DAY_6") && !state.day6Shown) {
-		state.day6Shown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x22a8, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		assignMomTopicBuffer(kMomDay6TopicBufferResponseLine);
-	}
-
-	for (;;) {
-		Common::String selectedTopic;
-		Common::Error menuError = runtime.runKeywordMenu(
-			momTopicBuffer, momTopicBufferLineIndex, selectedTopic);
-		if (menuError.getCode() != Common::kNoError)
-			return menuError;
-		if (selectedTopic.empty())
-			return Common::kNoError;
-		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()))
-			return runMomGoodbye();
-
-		if (runtime.matchesResponseLine(selectedTopic, 0x116)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomCookiesLines, ARRAYSIZE(kMomCookiesLines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignMomTopicBuffer(0x118);
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x119)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic119Lines, ARRAYSIZE(kMomTopic119Lines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignMomTopicBuffer(0x11a);
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x11d)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic11dOpeningLines, ARRAYSIZE(kMomTopic11dOpeningLines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			lineError = runtime.playDialogueFst(kDialogueC008AFstPath);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			lineError = runtime.playDialogueEntrySequence(
-				kMomTopic11dPostC008ALines, ARRAYSIZE(kMomTopic11dPostC008ALines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-
-			StartupInteractionResult babyGurgleInteraction;
-			if (runtime.startupScript().executeActionTag(kBabyGurgleActionTag, babyGurgleInteraction)) {
-				runtime.applyImmediateDialogueInteractionEffects(babyGurgleInteraction);
-				runtime.queueDialogueInteractionIfNeeded(babyGurgleInteraction);
-			}
-
-			lineError = runtime.playDialogueEntrySequence(
-				kMomTopic11dClosingLines, ARRAYSIZE(kMomTopic11dClosingLines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			lineError = runtime.playDialogueFst(kDialogueC008BFstPath);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignMomTopicBuffer(0x120);
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x121)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic121Lines, ARRAYSIZE(kMomTopic121Lines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignMomTopicBuffer(0x122);
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x123)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomSparkyLines, ARRAYSIZE(kMomSparkyLines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignMomTopicBuffer(0x124);
-			continue;
-		}
-		if (runtime.matchesAnyResponseLine(selectedTopic, kMomPtaTopicResponseLines,
-				ARRAYSIZE(kMomPtaTopicResponseLines))) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomPtaLines, ARRAYSIZE(kMomPtaLines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignMomTopicBuffer(kMomPtaFollowupTopicBufferResponseLine);
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x12b)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic12bLines, ARRAYSIZE(kMomTopic12bLines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignMomTopicBuffer(0x12c);
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x12d)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic12dLines, ARRAYSIZE(kMomTopic12dLines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignMomTopicBuffer(0x12e);
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x12f)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic12fLines, ARRAYSIZE(kMomTopic12fLines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignMomTopicBuffer(0x130);
-			continue;
-		}
-		if (runtime.matchesAnyResponseLine(selectedTopic, kMomCookingTopicResponseLines,
-				ARRAYSIZE(kMomCookingTopicResponseLines))) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomCookingLines, ARRAYSIZE(kMomCookingLines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignMomTopicBuffer(kMomCookingFollowupTopicBufferResponseLine);
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x135)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic135Lines, ARRAYSIZE(kMomTopic135Lines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignMomTopicBuffer(0x136);
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x137)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic137Lines, ARRAYSIZE(kMomTopic137Lines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x13a)) {
-			Common::Error lineError = runtime.playDialogueLine(0x1fa3, "MOM");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignMomTopicBuffer(0x13b);
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x13c)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomSlaughterhouseLines, ARRAYSIZE(kMomSlaughterhouseLines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignMomTopicBuffer(kMomSlaughterhouseFollowupTopicBufferResponseLine);
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x13f)) {
-			state.fatherTopicState = true;
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomFatherLines, ARRAYSIZE(kMomFatherLines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			if (runtime.startupScript().getFlagValue("HEARD_DAD_MOAN")) {
-				lineError = runtime.playDialogueEntrySequence(
-					kMomFatherHeardDadMoanLines, ARRAYSIZE(kMomFatherHeardDadMoanLines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-			}
-			assignMomTopicBuffer(kMomFatherFollowupTopicBufferResponseLine);
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x141)) {
-			Common::Error lineError = Common::kNoError;
-			if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED")) {
-				lineError = runtime.playDialogueEntrySequence(
-					kMomStephMidgameLodgeLines, ARRAYSIZE(kMomStephMidgameLodgeLines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				continue;
-			}
-			sharedState.discussedLodgeTopic = true;
-			lineError = runtime.playDialogueEntrySequence(kMomLodgeLines, ARRAYSIZE(kMomLodgeLines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-
-			int responseIndex = 0;
-			Common::Error responseError = runtime.runResponseMenu(0x142, responseIndex);
-			if (responseError.getCode() != Common::kNoError)
-				return responseError;
-
-			switch (responseIndex) {
-			case 1:
-				lineError = runtime.playDialogueEntrySequence(
-					kMomLodgeResponse1Lines, ARRAYSIZE(kMomLodgeResponse1Lines));
-				break;
-			case 2:
-				lineError = runtime.playDialogueEntrySequence(
-					kMomLodgeResponse2Lines, ARRAYSIZE(kMomLodgeResponse2Lines));
-				break;
-			default:
-				break;
-			}
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignMomTopicBuffer(kMomLodgeFollowupTopicBufferResponseLine);
-			continue;
-		}
-		if (runtime.matchesAnyResponseLine(selectedTopic, kMomBakeSaleTopicResponseLines,
-				ARRAYSIZE(kMomBakeSaleTopicResponseLines))) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomBakeSaleLines, ARRAYSIZE(kMomBakeSaleLines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignMomTopicBuffer(kMomBakeSaleFollowupTopicBufferResponseLine);
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x14a)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic14aLines, ARRAYSIZE(kMomTopic14aLines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			continue;
-		}
-		if (runtime.matchesAnyResponseLine(selectedTopic, kMomPottsdamTopicResponseLines,
-				ARRAYSIZE(kMomPottsdamTopicResponseLines))) {
-			Common::Error lineError = Common::kNoError;
-			if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED"))
-				lineError = runtime.playDialogueLine(0x2086, "MOM");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignMomTopicBuffer(kMomPottsdamFollowupTopicBufferResponseLine);
-			continue;
-		}
-		if (runtime.matchesAnyResponseLine(selectedTopic, kMomMeatPlantTopicResponseLines,
-				ARRAYSIZE(kMomMeatPlantTopicResponseLines))) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomMeatPlantIntroLines, ARRAYSIZE(kMomMeatPlantIntroLines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-
-			int responseIndex = 0;
-			Common::Error responseError = runtime.runResponseMenu(0x153, responseIndex);
-			if (responseError.getCode() != Common::kNoError)
-				return responseError;
-
-			switch (responseIndex) {
-			case 1:
-				lineError = runtime.playDialogueEntrySequence(
-					kMomMeatPlantResponse1Lines, ARRAYSIZE(kMomMeatPlantResponse1Lines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				continue;
-			case 2:
-				lineError = runtime.playDialogueEntrySequence(
-					kMomMeatPlantResponse2Lines, ARRAYSIZE(kMomMeatPlantResponse2Lines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				assignMomTopicBuffer(kMomMeatPlantFollowupTopicBufferResponseLine);
-				continue;
-			default:
-				continue;
-			}
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x155)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic155Lines, ARRAYSIZE(kMomTopic155Lines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x156)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic156Lines, ARRAYSIZE(kMomTopic156Lines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x15b) && currentStoryDayIndex == 5) {
-			Common::Error lineError = Common::kNoError;
-			if (state.goodCauseDay5State) {
-				lineError = runtime.playDialogueEntrySequence(
-					kMomGoodCauseDay5Lines, ARRAYSIZE(kMomGoodCauseDay5Lines));
-			} else {
-				lineError = runtime.playDialogueLine(0x21dd, "PC");
-			}
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignMomTopicBuffer(kMomGoodCauseDay5FollowupTopicBufferResponseLine);
-			continue;
-		}
-		if (runtime.matchesAnyResponseLine(selectedTopic, kMomMoynahanTopicResponseLines,
-				ARRAYSIZE(kMomMoynahanTopicResponseLines))) {
-			Common::Error lineError = runtime.playDialogueLine(0x220a, "PC");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			continue;
-		}
-		if (runtime.matchesAnyResponseLine(selectedTopic, kMomNewspaperFireTopicResponseLines,
-				ARRAYSIZE(kMomNewspaperFireTopicResponseLines))) {
-			Common::Error lineError = runtime.playDialogueLine(0x229e, "MOM");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x16d)) {
-			Common::Error lineError = runtime.playDialogueLine(0x2696, "MOM");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x16e)) {
-			Common::Error lineError = runtime.playDialogueLine(0x26a3, "MOM");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			continue;
-		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x170)) {
-			Common::Error lineError = runtime.playDialogueLine(0x26bc, "MOM");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			continue;
-		}
-
-		Common::Error lineError = runtime.playDialogueLine(0x26c6, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		return runMomGoodbye();
-	}
-}
-
-Common::Error HankDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &sharedState) {
-	const int currentStoryDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
-	HankRoomDialogueState &state = _state;
-	Common::String &hankTopicBuffer = state.currentTopicBuffer;
-	int &hankTopicBufferLineIndex = state.currentTopicBufferLineIndex;
-	auto assignHankTopicBuffer = [&](int responseLineIndex) {
-		runtime.assignTopicBuffer(hankTopicBuffer, hankTopicBufferLineIndex,
-			responseLineIndex, "Hank topic buffer");
-	};
-
-	debugC(1, kDebugDialogue,
-		"Harvester: Hank dialogue start day=%d item='%s' initial=%d trackedDayValid=%d trackedDay=%d sameDayPending=%d rangshotPending=%d topicLine=%d topicBuffer='%s'",
-		currentStoryDayIndex, usedItemName.empty() ? "<none>" : usedItemName.c_str(),
-		(int)state.pendingInitialConversation,
-		(int)state.hasTrackedDayState, state.trackedDayIndex,
-		(int)state.pendingSameDayFollowup,
-		(int)state.pendingRangshotSequence,
-		hankTopicBufferLineIndex, hankTopicBuffer.c_str());
-
-	if (!usedItemName.empty()) {
-		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfCorpseFlag, true);
-			return runtime.playDialogueLine(0xa3e, "HANK");
-		}
-		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(kShownPhotoOfWhaleyHerrillFlag, true);
-			return runtime.playDialogueLine(0xa53, "HANK");
-		}
-		return runtime.playDialogueLine(0xa38, "HANK");
-	}
-
-	bool skipHankFollowupBranches = false;
-	if (state.pendingInitialConversation) {
-		state.trackedDayIndex = currentStoryDayIndex;
-		state.hasTrackedDayState = true;
-		state.pendingSameDayFollowup = true;
-		state.pendingInitialConversation = false;
-		hankTopicBuffer.clear();
-		hankTopicBufferLineIndex = -1;
-		skipHankFollowupBranches = true;
-
-		Common::Error lineError = runtime.playDialogueLine(0x703, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		int responseIndex = 0;
-		Common::Error responseError = runtime.runResponseMenu(0xc7, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
-
-		switch (responseIndex) {
-		case 1:
-			lineError = runtime.playDialogueLine(0x70f, "HANK");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignHankTopicBuffer(0xc8);
-			break;
-		case 2:
-			lineError = runtime.playDialogueLine(0x715, "HANK");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignHankTopicBuffer(0xc9);
-			break;
-		case 3:
-			lineError = runtime.playDialogueLine(0x71c, "HANK");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			assignHankTopicBuffer(0xca);
-			break;
-		default:
-			break;
-		}
-	}
-
-	if (!skipHankFollowupBranches && (!state.hasTrackedDayState ||
-			currentStoryDayIndex != state.trackedDayIndex)) {
-		if (state.pendingSameDayFollowup) {
-			state.trackedDayIndex = currentStoryDayIndex;
-			state.pendingSameDayFollowup = false;
-			state.pendingRangshotSequence = true;
-
-			Common::Error lineError = runtime.playDialogueLine(0x8f5, "HANK");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-
-		if (state.pendingRangshotSequence) {
-			Common::Error lineError = currentStoryDayIndex > 5
-				? runtime.playDialogueLine(0x8e2, "HANK")
-				: runtime.playDialogueFst(kDialogueRangshotFstPath);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-	} else if (!skipHankFollowupBranches) {
-		state.pendingSameDayFollowup = true;
-		Common::Error lineError = currentStoryDayIndex > 5
-			? runtime.playDialogueLine(0x8e2, "HANK")
-			: runtime.playDialogueFst(kDialogueRangshotFstPath);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-
-	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
-			!state.stephMidgamePlayedShown) {
-		state.stephMidgamePlayedShown = true;
-
-		Common::Error lineError = runtime.playDialogueLine(0x92c, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		int responseIndex = 0;
-		Common::Error responseError = runtime.runResponseMenu(0xcc, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
-
-		switch (responseIndex) {
-		case 1: {
-			const DialogueLineEntry lines[] = {
-				{ 0x939, "HANK", 0 },
-				{ 0x93f, "MOM", 2 }
-			};
-			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
-			break;
-		}
-		case 2:
-			lineError = runtime.playDialogueLine(0x944, "HANK");
-			break;
-		case 3:
-			lineError = runtime.playDialogueLine(0x948, "HANK");
-			break;
-		default:
-			break;
-		}
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		lineError = runtime.playDialogueLine(0x94f, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		responseIndex = 0;
-		responseError = runtime.runResponseMenu(0xcd, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
-
-		switch (responseIndex) {
-		case 1: {
-			const DialogueLineEntry lines[] = {
-				{ 0x95a, "HANK", 0 },
-				{ 0x961, "PC", 2 }
-			};
-			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
-			break;
-		}
-		case 2: {
-			const DialogueLineEntry lines[] = {
-				{ 0x965, "HANK", 0 },
-				{ 0x96b, "PC", 0 }
-			};
-			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
-			break;
-		}
-		default:
-			break;
-		}
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		lineError = runtime.playDialogueLine(0x971, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
-			!state.burnedTvStationShown) {
-		state.burnedTvStationShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x987, "PC");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("BUSTED_ONCE") &&
-			!state.bustedOnceShown) {
-		state.bustedOnceShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x9b1, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		int responseIndex = 0;
-		Common::Error responseError = runtime.runResponseMenu(0xce, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
-
-		switch (responseIndex) {
-		case 1: {
-			const DialogueLineEntry lines[] = {
-				{ 0x9bc, "HANK", 0 },
-				{ 0x9c9, "HANK", 0 }
-			};
-			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
-			break;
-		}
-		case 2: {
-			const DialogueLineEntry lines[] = {
-				{ 0x9c1, "HANK", 0 },
-				{ 0x9c5, "PC", 0 },
-				{ 0x9c9, "HANK", 0 }
-			};
-			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
-			break;
-		}
-		default:
-			break;
-		}
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
-			!state.karinKidnapedShown) {
-		state.karinKidnapedShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x9d5, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		if (sharedState.karinKidnapedDialogueState) {
-			lineError = runtime.playDialogueLineWithVariant(0x9da, "PC", 3);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-
-		const DialogueLineEntry lines[] = {
-			{ 0x9de, "HANK", 0 },
-			{ 0x9df, "HANK", 0 },
-			{ 0x9e0, "HANK", 0 },
-			{ 0x9e1, "HANK", 0 },
-			{ 0x9e2, "HANK", 0 }
-		};
-		lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		int responseIndex = 0;
-		Common::Error responseError = runtime.runResponseMenu(0xcf, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
-
-		switch (responseIndex) {
-		case 1: {
-			const DialogueLineEntry responseLines[] = {
-				{ 0x9f4, "HANK", 0 },
-				{ 0x9fc, "HANK", 0 }
-			};
-			lineError = runtime.playDialogueEntrySequence(responseLines, ARRAYSIZE(responseLines));
-			break;
-		}
-		case 2:
-			lineError = runtime.playDialogueLine(0xa01, "HANK");
-			break;
-		default:
-			break;
-		}
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
-			!state.karinFoundAliveShown) {
-		state.karinFoundAliveShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0xa0b, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
-			!state.karinFoundDeadShown) {
-		state.karinFoundDeadShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0xa15, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		int responseIndex = 0;
-		Common::Error responseError = runtime.runResponseMenu(0xd0, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
-
-		switch (responseIndex) {
-		case 1:
-			lineError = runtime.playDialogueLine(0xa20, "HANK");
-			break;
-		case 2:
-			lineError = runtime.playDialogueLine(0xa26, "HANK");
-			break;
-		default:
-			break;
-		}
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		lineError = runtime.playDialogueLine(0xa2b, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-
-	for (;;) {
-		Common::String selectedTopic;
-		Common::Error menuError = runtime.runKeywordMenu(
-			hankTopicBuffer, hankTopicBufferLineIndex, selectedTopic);
-		if (menuError.getCode() != Common::kNoError)
-			return menuError;
-
-		if (selectedTopic.empty())
-			return Common::kNoError;
-		debugC(1, kDebugDialogue, "Harvester: Hank selected topic='%s'", selectedTopic.c_str());
-		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()))
-			return runtime.playDialogueLine(0x8dc, "HANK");
-
-		const Common::String momTopic = runtime.startupText().getDialogueResponseLine(0xd2);
-		Common::Array<Common::String> currentTopics;
-		splitDialogueMenuLine(hankTopicBuffer, currentTopics);
-		const bool matchesMomTopicByLine = !momTopic.empty() && selectedTopic.equalsIgnoreCase(momTopic);
-		const bool matchesMomTopicFromIntroBuffer = hankTopicBufferLineIndex == 0xc8 &&
-			!currentTopics.empty() && selectedTopic.equalsIgnoreCase(currentTopics[0]);
-		if (matchesMomTopicByLine || matchesMomTopicFromIntroBuffer) {
-			debugC(1, kDebugDialogue,
-				"Harvester: Hank matched special Mom branch '%s' (lineMatch=%d bufferMatch=%d)",
-				selectedTopic.c_str(), (int)matchesMomTopicByLine, (int)matchesMomTopicFromIntroBuffer);
-			const DialogueLineEntry lines[] = {
-				{ 0x725, "HANK", 0 },
-				{ 0x729, "PC", 0 },
-				{ 0x72d, "HANK", 0 },
-				{ 0x733, "PC", 0 },
-				{ 0x737, "HANK", 0 },
-				{ 0x73b, "PC", 0 },
-				{ 0x741, "HANK", 0 },
-				{ 0x747, "MOM", 2 }
-			};
-			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			runtime.setActiveSpeakerPortrait("HANK", 0);
-			assignHankTopicBuffer(0xd5);
-			continue;
-		}
-
-		bool handledTopic = false;
-		for (const HankDialogueTopicLine &topic : kHankDialogueTopicLines) {
-			if (topic.responseLineIndex == 0xd2)
-				continue;
-
-			const Common::String topicText =
-				runtime.startupText().getDialogueResponseLine(topic.responseLineIndex);
-			if (topicText.empty() || !selectedTopic.equalsIgnoreCase(topicText))
-				continue;
-
-			if (topic.setDiscussedLodgeTopic)
-				sharedState.discussedLodgeTopic = true;
-
-			debugC(1, kDebugDialogue,
-				"Harvester: Hank matched topic '%s' to response line 0x%x (%d) -> wav=0x%x speaker='%s'",
-				selectedTopic.c_str(), topic.responseLineIndex, topic.responseLineIndex + 1,
-				topic.wavId, topic.speakerId);
-			Common::Error lineError = runtime.playDialogueLine(topic.wavId, topic.speakerId);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			handledTopic = true;
-			break;
-		}
-
-		Common::Error lineError = runtime.playDialogueLine(0xa32, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		if (handledTopic) {
-			debugC(1, kDebugDialogue,
-				"Harvester: Hank handled topic '%s' and now falls through to generic response/exit",
-				selectedTopic.c_str());
-		} else {
-			debugC(1, kDebugDialogue,
-				"Harvester: Hank topic '%s' fell back to generic response and exits dialogue",
-				selectedTopic.c_str());
-		}
-
-		return runtime.playDialogueLine(0x8dc, "HANK");
-	}
-}
-
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/dialogue_flags.h b/engines/harvester/npc/dialogue_flags.h
new file mode 100644
index 00000000000..320851a3bc3
--- /dev/null
+++ b/engines/harvester/npc/dialogue_flags.h
@@ -0,0 +1,16 @@
+#ifndef HARVESTER_NPC_DIALOGUE_FLAGS_H
+#define HARVESTER_NPC_DIALOGUE_FLAGS_H
+
+namespace Harvester {
+namespace DialogueFlags {
+
+static const char *const kShownEvidenceOfBlackmail = "SHOWN_EVIDENCE_OF_BLACKMAIL";
+static const char *const kShownEvidenceSheriffOwns = "SHOWN_EVIDENCE_SHERIFF_OWNS";
+static const char *const kShownLedgersToAnyone = "SHOWN_LEDGERS_TO_ANYONE_OTH";
+static const char *const kShownPhotoOfCorpse = "SHOWN_PHOTO_OF_CORPSE_AROUN";
+static const char *const kShownPhotoOfWhaleyHerrill = "SHOWN_PHOTO_OF_WHALEY_HERRI";
+
+} // End of namespace DialogueFlags
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_DIALOGUE_FLAGS_H
diff --git a/engines/harvester/npc/dialogue_runtime.h b/engines/harvester/npc/dialogue_runtime.h
new file mode 100644
index 00000000000..80b4f88d071
--- /dev/null
+++ b/engines/harvester/npc/dialogue_runtime.h
@@ -0,0 +1,140 @@
+#ifndef HARVESTER_NPC_DIALOGUE_RUNTIME_H
+#define HARVESTER_NPC_DIALOGUE_RUNTIME_H
+
+#include <functional>
+
+#include "common/error.h"
+#include "common/str.h"
+#include "harvester/startup_script.h"
+
+namespace Harvester {
+
+class HarvesterEngine;
+class StartupFlow;
+class StartupText;
+
+struct DialogueLineEntry {
+	int wavId;
+	const char *speakerId;
+	int headVariant;
+};
+
+class DialogueRuntime {
+public:
+	typedef std::function<Common::Error(int, const Common::String &, int)> PlayDialogueLineWithVariantFn;
+	typedef std::function<Common::Error(int, const Common::String &)> PlayDialogueLineFn;
+	typedef std::function<Common::Error(const DialogueLineEntry *, uint)> PlayDialogueEntrySequenceFn;
+	typedef std::function<Common::Error(const Common::String &)> PlayDialogueFstFn;
+	typedef std::function<Common::Error(const Common::String &, int, Common::String &)> RunKeywordMenuFn;
+	typedef std::function<Common::Error(int, int &)> RunResponseMenuFn;
+	typedef std::function<void(Common::String &, int &, int, const char *)> AssignTopicBufferFn;
+	typedef std::function<bool(const Common::String &, int)> MatchesResponseLineFn;
+	typedef std::function<bool(const Common::String &, const int *, uint)> MatchesAnyResponseLineFn;
+	typedef std::function<void(const StartupInteractionResult &)> QueueDialogueInteractionIfNeededFn;
+	typedef std::function<void(StartupInteractionResult &)> ApplyImmediateDialogueInteractionEffectsFn;
+	typedef std::function<int(int)> GetRandomNumberFn;
+	typedef std::function<void(const Common::String &, int)> SetActiveSpeakerPortraitFn;
+
+	DialogueRuntime(HarvesterEngine &engine, StartupScript &startupScript, StartupText &startupText,
+			StartupFlow &startupFlow, const Common::String &currentRoomName,
+			const Common::String &genericByeTopic,
+			const PlayDialogueLineWithVariantFn &playDialogueLineWithVariant,
+			const PlayDialogueLineFn &playDialogueLine,
+			const PlayDialogueEntrySequenceFn &playDialogueEntrySequence,
+			const PlayDialogueFstFn &playDialogueFst,
+			const RunKeywordMenuFn &runKeywordMenu,
+			const RunResponseMenuFn &runResponseMenu,
+			const AssignTopicBufferFn &assignTopicBuffer,
+			const MatchesResponseLineFn &matchesResponseLine,
+			const MatchesAnyResponseLineFn &matchesAnyResponseLine,
+			const QueueDialogueInteractionIfNeededFn &queueDialogueInteractionIfNeeded,
+			const ApplyImmediateDialogueInteractionEffectsFn &applyImmediateDialogueInteractionEffects,
+			const GetRandomNumberFn &getRandomNumber,
+			const SetActiveSpeakerPortraitFn &setActiveSpeakerPortrait)
+		: _engine(engine), _startupScript(startupScript), _startupText(startupText),
+		  _startupFlow(startupFlow), _currentRoomName(currentRoomName),
+		  _genericByeTopic(genericByeTopic),
+		  _playDialogueLineWithVariant(playDialogueLineWithVariant),
+		  _playDialogueLine(playDialogueLine), _playDialogueEntrySequence(playDialogueEntrySequence),
+		  _playDialogueFst(playDialogueFst), _runKeywordMenu(runKeywordMenu),
+		  _runResponseMenu(runResponseMenu), _assignTopicBuffer(assignTopicBuffer),
+		  _matchesResponseLine(matchesResponseLine),
+		  _matchesAnyResponseLine(matchesAnyResponseLine),
+		  _queueDialogueInteractionIfNeeded(queueDialogueInteractionIfNeeded),
+		  _applyImmediateDialogueInteractionEffects(applyImmediateDialogueInteractionEffects),
+		  _getRandomNumber(getRandomNumber),
+		  _setActiveSpeakerPortrait(setActiveSpeakerPortrait) {
+	}
+
+	HarvesterEngine &engine() const { return _engine; }
+	StartupScript &startupScript() const { return _startupScript; }
+	StartupText &startupText() const { return _startupText; }
+	StartupFlow &startupFlow() const { return _startupFlow; }
+	const Common::String &currentRoomName() const { return _currentRoomName; }
+	const Common::String &genericByeTopic() const { return _genericByeTopic; }
+
+	Common::Error playDialogueLineWithVariant(int wavId, const Common::String &speakerId,
+			int headVariant) const {
+		return _playDialogueLineWithVariant(wavId, speakerId, headVariant);
+	}
+	Common::Error playDialogueLine(int wavId, const Common::String &speakerId) const {
+		return _playDialogueLine(wavId, speakerId);
+	}
+	Common::Error playDialogueEntrySequence(const DialogueLineEntry *lines, uint count) const {
+		return _playDialogueEntrySequence(lines, count);
+	}
+	Common::Error playDialogueFst(const Common::String &path) const { return _playDialogueFst(path); }
+	Common::Error runKeywordMenu(const Common::String &topicBuffer, int topicBufferLineIndex,
+			Common::String &selectedTopic) const {
+		return _runKeywordMenu(topicBuffer, topicBufferLineIndex, selectedTopic);
+	}
+	Common::Error runResponseMenu(int responseLineIndex, int &responseIndex) const {
+		return _runResponseMenu(responseLineIndex, responseIndex);
+	}
+	void assignTopicBuffer(Common::String &topicBuffer, int &topicBufferLineIndex,
+			int responseLineIndex, const char *label) const {
+		_assignTopicBuffer(topicBuffer, topicBufferLineIndex, responseLineIndex, label);
+	}
+	bool matchesResponseLine(const Common::String &selectedTopic, int responseLineIndex) const {
+		return _matchesResponseLine(selectedTopic, responseLineIndex);
+	}
+	bool matchesAnyResponseLine(const Common::String &selectedTopic,
+			const int *responseLineIndices, uint responseLineCount) const {
+		return _matchesAnyResponseLine(selectedTopic, responseLineIndices, responseLineCount);
+	}
+	void queueDialogueInteractionIfNeeded(const StartupInteractionResult &interaction) const {
+		_queueDialogueInteractionIfNeeded(interaction);
+	}
+	void applyImmediateDialogueInteractionEffects(StartupInteractionResult &interaction) const {
+		_applyImmediateDialogueInteractionEffects(interaction);
+	}
+	int getRandomNumber(int maxValue) const { return _getRandomNumber(maxValue); }
+	void setActiveSpeakerPortrait(const Common::String &speakerId, int headVariant) const {
+		_setActiveSpeakerPortrait(speakerId, headVariant);
+	}
+
+private:
+	HarvesterEngine &_engine;
+	StartupScript &_startupScript;
+	StartupText &_startupText;
+	StartupFlow &_startupFlow;
+	const Common::String &_currentRoomName;
+	const Common::String &_genericByeTopic;
+	PlayDialogueLineWithVariantFn _playDialogueLineWithVariant;
+	PlayDialogueLineFn _playDialogueLine;
+	PlayDialogueEntrySequenceFn _playDialogueEntrySequence;
+	PlayDialogueFstFn _playDialogueFst;
+	RunKeywordMenuFn _runKeywordMenu;
+	RunResponseMenuFn _runResponseMenu;
+	AssignTopicBufferFn _assignTopicBuffer;
+	MatchesResponseLineFn _matchesResponseLine;
+	MatchesAnyResponseLineFn _matchesAnyResponseLine;
+	QueueDialogueInteractionIfNeededFn _queueDialogueInteractionIfNeeded;
+	ApplyImmediateDialogueInteractionEffectsFn _applyImmediateDialogueInteractionEffects;
+	GetRandomNumberFn _getRandomNumber;
+	SetActiveSpeakerPortraitFn _setActiveSpeakerPortrait;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_DIALOGUE_RUNTIME_H
diff --git a/engines/harvester/npc/dwayne_dialogue.cpp b/engines/harvester/npc/dwayne_dialogue.cpp
index 82db5b7dcf4..6f45cdd2a53 100644
--- a/engines/harvester/npc/dwayne_dialogue.cpp
+++ b/engines/harvester/npc/dwayne_dialogue.cpp
@@ -19,12 +19,608 @@
  *
  */
 
+#include <functional>
+
 #include "harvester/npc/dwayne_dialogue.h"
 
+#include "common/array.h"
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
 namespace Harvester {
 
+namespace {
+
+static const int kDwayneWhaleyTopicResponseLines[] = { 0x85, 0x86, 0x87 };
+static const int kDwayneLoomisTopicResponseLines[] = { 0x8c, 0x8d };
+
+} // End of namespace
+
 bool DwayneDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("DWAYNE");
 }
 
+Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	DwayneRoomDialogueState &state = _state;
+	Common::String &dwayneTopicBuffer = state.currentTopicBuffer;
+	int &dwayneTopicBufferLineIndex = state.currentTopicBufferLineIndex;
+	auto assignDwayneTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(dwayneTopicBuffer, dwayneTopicBufferLineIndex,
+			responseLineIndex, "Dwayne topic buffer");
+	};
+	auto playDwayneLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "DWAYNE", headVariant);
+	};
+	auto playEdnaLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "EDNA", headVariant);
+	};
+	auto hasInventoryItem = [&](const char *objectName) {
+		Common::Array<StartupObjectRecord> inventoryObjects;
+		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
+		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
+				return true;
+		}
+
+		return false;
+	};
+	auto setDwayneIntroduced = [&]() {
+		(void)runtime.startupScript().setRuntimeFlagValue("DWAYNE_INTRODUCED", true);
+	};
+	const bool sheriffInDiner = runtime.startupScript().getFlagValue("SHERIFF_IN_DINER");
+
+	if (runtime.startupScript().getFlagValue("ARREST_FLAG")) {
+		(void)runtime.startupScript().setRuntimeFlagValue("ARREST_FLAG", false);
+		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_MOYNAHAN"))
+			return playDwayneLine(0x38d6, 2);
+		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_JIMMY"))
+			return playDwayneLine(0x384d, 2);
+		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_JOHNSON"))
+			return playDwayneLine(0x386d, 2);
+		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_PHELPS"))
+			return playDwayneLine(0x38bc, 2);
+		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_OREILLY"))
+			return playDwayneLine(0x38b5, 2);
+		if (runtime.startupScript().getFlagValue("IF_KILL_POTTSDAM_AT_GRAVE"))
+			return playDwayneLine(0x37ed, 2);
+		if (runtime.startupScript().getFlagValue("GENERIC_BUST"))
+			return playDwayneLine(0x38fc, 2);
+		if (runtime.startupScript().getFlagValue("DNALFT_PERVERT"))
+			return playDwayneLine(0x3580, 2);
+		if (runtime.startupScript().getFlagValue("PC_HAS_GOOJF_CARD") &&
+				!runtime.startupScript().getFlagValue("PC_FRY_IN_CHAIR")) {
+			return playDwayneLine(0x3814);
+		}
+		if (!runtime.startupScript().getFlagValue("PC_FRY_IN_CHAIR")) {
+			if (runtime.startupScript().getFlagValue("BUSTED_THIRD"))
+				return playDwayneLine(0x3928, 2);
+			if (runtime.startupScript().getFlagValue("BUSTED_TWICE"))
+				return playDwayneLine(0x3921, 2);
+			if (runtime.startupScript().getFlagValue("BUSTED_ONCE"))
+				return playDwayneLine(0x3918);
+		}
+		return Common::kNoError;
+	}
+
+	if (runtime.startupScript().getFlagValue("CHECK_EVIDENCE_DOOR"))
+		return playDwayneLine(0x3386);
+	if (runtime.startupScript().getFlagValue("PC_TRIES_TO_TAKE_STEPHANIES_REMAINS"))
+		return playDwayneLine(0x34e3, 2);
+	if (runtime.currentRoomName().equalsIgnoreCase("ST_BEDRM")) {
+		if (!runtime.startupScript().getFlagValue("SD_TALKED_OF_CARD")) {
+			Common::Error lineError = playDwayneLine(0x34da);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		return playDwayneLine(0x3395);
+	}
+
+	if (runtime.startupScript().getFlagValue("BRING_KARIN_TO_SHERIFF") &&
+			!sheriffInDiner &&
+			!state.bringKarinToSheriffLinePlayed) {
+		state.pendingKarinAliveFollowup = true;
+		state.pendingKarinAliveFollowupDayIndex =
+			runtime.startupScript().getCurrentStoryDayIndex();
+		state.bringKarinToSheriffLinePlayed = true;
+		return playDwayneLine(0x3750);
+	}
+	if (runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE")) {
+		state.pendingKarinAliveFollowup = true;
+		state.pendingKarinAliveFollowupDayIndex =
+			runtime.startupScript().getCurrentStoryDayIndex();
+	}
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("BOYLES_BUTTON")) {
+			state.discussedBoylesButton = true;
+			return playDwayneLine(0x339b);
+		}
+		if (usedItemName.equalsIgnoreCase("K_PURSE")) {
+			state.discussedKarinPurse = true;
+			return playDwayneLine(0x37cb);
+		}
+		if (usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY")) {
+			return playDwayneLine(0x33b9, 2);
+		}
+		if (usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("NOTE")) {
+			if (state.presentedEvidenceReplyOverride)
+				return playDwayneLine(0x342c);
+
+			Common::Error lineError = playDwayneLine(0x33f4, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x70, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			lineError = Common::kNoError;
+			if (responseIndex == 1) {
+				lineError = playDwayneLine(0x33c3, 2);
+			} else if (responseIndex == 2) {
+				lineError = playDwayneLine(0x3403, 2);
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			lineError = playDwayneLine(0x3407, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playDwayneLine(0x340d);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			responseIndex = 0;
+			responseError = runtime.runResponseMenu(0x71, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 0)
+				return Common::kNoError;
+			if (responseIndex == 1)
+				return playDwayneLine(0x341a, 2);
+			if (responseIndex == 2)
+				return playDwayneLine(0x3422, 2);
+			return Common::kNoError;
+		}
+		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+					usedItemName.equalsIgnoreCase("LEDGER2")) &&
+				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+			return playDwayneLine(0x3452);
+		}
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			return playDwayneLine(0x3975, 3);
+		}
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+			return playDwayneLine(0x3942, 2);
+		}
+		if (state.tvDeedReplyOverride &&
+				usedItemName.equalsIgnoreCase("TV_DEED")) {
+			return playDwayneLine(0x3686);
+		}
+		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
+				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
+			return playDwayneLine(0x3935);
+		}
+		return playDwayneLine(0x3608);
+	}
+
+	if (state.sheriffInDinerIntroPending && sheriffInDiner) {
+		state.sheriffInDinerIntroPending = false;
+		state.sheriffInDinerIntroPlayed = true;
+		setDwayneIntroduced();
+
+		const DialogueLineEntry introLines[] = {
+			{ 0x3482, "DWAYNE", 0 },
+			{ 0x3486, "PC", 0 },
+			{ 0x348b, "DWAYNE", 1 },
+			{ 0x3490, "PC", 0 }
+		};
+		Common::Error lineError = runtime.playDialogueEntrySequence(introLines, ARRAYSIZE(introLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED")) {
+			sharedState.karinKidnapedDialogueState = true;
+			lineError = playEdnaLine(0x3496, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		lineError = playDwayneLine(0x349b, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		return playDwayneLine(0x34ac);
+	}
+
+	if (state.sheriffInDinerIntroPlayed && sheriffInDiner)
+		return playDwayneLine(0x34ac);
+
+	if (state.pendingInitialConversation && !sheriffInDiner) {
+		state.pendingInitialConversation = false;
+		state.eventFollowupGate = true;
+		setDwayneIntroduced();
+
+		const DialogueLineEntry initialLines[] = {
+			{ 0x3245, "DWAYNE", 1 },
+			{ 0x324a, "LOOMIS", 1 },
+			{ 0x324e, "PC", 0 },
+			{ 0x3252, "DWAYNE", 0 }
+		};
+		Common::Error lineError = runtime.playDialogueEntrySequence(initialLines, ARRAYSIZE(initialLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignDwayneTopicBuffer(0x78);
+		if (sharedState.boyleGascanApplicationState)
+			(void)runtime.startupScript().setRuntimeFlagValue("MOVE_SHERIFF", true);
+	}
+
+	if (state.eventFollowupGate && !sharedState.boyleGascanApplicationState) {
+		Common::Error lineError = playDwayneLine(0x34d3, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (state.whaleyDisciplineFollowupState &&
+			!state.whaleyDisciplineFollowupShown) {
+		state.whaleyDisciplineFollowupShown = true;
+		Common::Error lineError = playDwayneLine(0x34b3, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x79, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		lineError = Common::kNoError;
+		if (responseIndex == 1) {
+			lineError = playDwayneLine(0x34bf, 2);
+		} else if (responseIndex == 2) {
+			lineError = playDwayneLine(0x34c5, 1);
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playDwayneLine(0x34cb, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue(DialogueFlags::kShownEvidenceOfBlackmail) &&
+			!state.noteCheckbookFollowupShown) {
+		state.noteCheckbookFollowupShown = true;
+		Common::Error lineError = playDwayneLine(0x355f, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (!sheriffInDiner) {
+		if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER") &&
+				!state.scratchedTuckerShown) {
+			state.scratchedTuckerShown = true;
+			Common::Error lineError = playDwayneLine(0x358b, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("BOLT_OF_CLOTH_TAKEN") &&
+				!state.boltOfClothTakenShown) {
+			state.boltOfClothTakenShown = true;
+			Common::Error lineError = playDwayneLine(0x3594, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") &&
+				!state.barberPoleStolenShown) {
+			state.barberPoleStolenShown = true;
+			Common::Error lineError = playDwayneLine(0x359d, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
+				!state.dinerBurnedShown) {
+			state.dinerBurnedShown = true;
+			Common::Error lineError = playDwayneLine(0x35bc, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("PC_ESCAPED_JAIL") &&
+				!state.escapedJailShown) {
+			state.escapedJailShown = true;
+			Common::Error lineError = playDwayneLine(0x35cd);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("GOT_REMAINS_FOR_LODGE") &&
+				!state.gotRemainsForLodgeShown) {
+			state.gotRemainsForLodgeShown = true;
+			Common::Error lineError = playDwayneLine(0x35d7, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
+				!state.burnedTvStationShown) {
+			state.burnedTvStationShown = true;
+			Common::Error lineError = playDwayneLine(0x35f6, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
+				!state.karinKidnapedShown) {
+			state.karinKidnapedShown = true;
+			Common::Error lineError = playDwayneLine(0x36b2, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+	}
+
+	const int currentStoryDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
+	if (state.pendingKarinAliveFollowup &&
+			currentStoryDayIndex != state.pendingKarinAliveFollowupDayIndex &&
+			!state.pendingKarinAliveFollowupLinePlayed) {
+		state.pendingKarinAliveFollowupLinePlayed = true;
+		state.pendingKarinAliveFollowup = false;
+		state.completedKarinAliveFollowup = true;
+
+		const DialogueLineEntry lines[] = {
+			{ 0x3780, "DWAYNE", 0 },
+			{ 0x3786, "PC", 4 },
+			{ 0x378a, "DWAYNE", 2 }
+		};
+		Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
+			!state.discussedKarinPurse &&
+			!sheriffInDiner &&
+			!state.karinFoundDeadWithoutPurseShown) {
+		state.karinFoundDeadWithoutPurseShown = true;
+		Common::Error lineError = playDwayneLine(0x3793);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (!runtime.startupScript().isNamedNpcDeathTypeClear("JIMMY") &&
+			!sheriffInDiner &&
+			!state.jimmyAbsentShown) {
+		state.jimmyAbsentShown = true;
+		Common::Error lineError = playDwayneLine(0x3855, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (runtime.startupScript().getFlagValue("JIMMY_ATTACKED")) {
+			lineError = playDwayneLine(0x37f8);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x81, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			lineError = Common::kNoError;
+			if (responseIndex == 1) {
+				lineError = playDwayneLine(0x3805, 1);
+			} else if (responseIndex == 2) {
+				lineError = playDwayneLine(0x380c);
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+	}
+
+	if (!runtime.startupScript().isNamedNpcDeathTypeClear("MOYNAHAN") &&
+			!sheriffInDiner &&
+			!state.moynahanAbsentShown) {
+		state.moynahanAbsentShown = true;
+		Common::Error lineError = playDwayneLine(0x38ce);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue("EDNA_HUNG") &&
+			!state.ednaHungShown) {
+		state.ednaHungShown = true;
+		Common::Error lineError = playDwayneLine(0x38f1, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (!runtime.startupScript().isNamedNpcDeathTypeClear("MCKNIGHT") &&
+			!sheriffInDiner &&
+			!state.mcknightAbsentShown) {
+		state.mcknightAbsentShown = true;
+		Common::Error lineError = hasInventoryItem("TV_DEED")
+			? playDwayneLine(0x3904, 3)
+			: playDwayneLine(0x390f, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	Common::String selectedTopic;
+	for (;;) {
+		if (runtime.matchesResponseLine(selectedTopic, 0x82))
+			return playDwayneLine(0x3395);
+
+		Common::Error menuError = runtime.runKeywordMenu(
+			dwayneTopicBuffer, dwayneTopicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.empty())
+			return Common::kNoError;
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x83)) {
+			Common::Error lineError = playDwayneLine(0x326e);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignDwayneTopicBuffer(0x84);
+			continue;
+		}
+
+		if (runtime.matchesAnyResponseLine(selectedTopic, kDwayneWhaleyTopicResponseLines,
+				ARRAYSIZE(kDwayneWhaleyTopicResponseLines))) {
+			if (!sheriffInDiner) {
+				const DialogueLineEntry lines[] = {
+					{ 0x327b, "DWAYNE", 0 },
+					{ 0x3280, "PC", 2 },
+					{ 0x3285, "DWAYNE", 0 },
+					{ 0x328b, "LOOMIS", 1 },
+					{ 0x3290, "DWAYNE", 2 },
+					{ 0x3294, "LOOMIS", 3 },
+					{ 0x3299, "PC", 4 },
+					{ 0x329e, "DWAYNE", 1 }
+				};
+				Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				sharedState.dialogueStateD2f04 = true;
+				assignDwayneTopicBuffer(0x89);
+			}
+			continue;
+		}
+
+		if (runtime.matchesAnyResponseLine(selectedTopic, kDwayneLoomisTopicResponseLines,
+				ARRAYSIZE(kDwayneLoomisTopicResponseLines))) {
+			if (!sheriffInDiner) {
+				const DialogueLineEntry lines[] = {
+					{ 0x32bf, "DWAYNE", 1 },
+					{ 0x32c1, "DWAYNE", 0 },
+					{ 0x32c7, "LOOMIS", 3 },
+					{ 0x32cb, "DWAYNE", 0 },
+					{ 0x32d1, "DWAYNE", 1 }
+				};
+				Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignDwayneTopicBuffer(0x8e);
+			}
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x8f)) {
+			const DialogueLineEntry lines[] = {
+				{ 0x32db, "DWAYNE", 0 },
+				{ 0x32e2, "PC", 4 },
+				{ 0x32e6, "DWAYNE", 0 }
+			};
+			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignDwayneTopicBuffer(0x90);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x91)) {
+			const DialogueLineEntry lines[] = {
+				{ 0x32f2, "DWAYNE", 0 },
+				{ 0x32f9, "PC", 0 },
+				{ 0x32fd, "DWAYNE", 0 },
+				{ 0x32fe, "DWAYNE", 0 },
+				{ 0x32ff, "DWAYNE", 0 }
+			};
+			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignDwayneTopicBuffer(0x92);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x93)) {
+			if (!runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED")) {
+				Common::Error lineError = playDwayneLine(0x330d, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			const DialogueLineEntry lines[] = {
+				{ 0x331f, "DWAYNE", 3 },
+				{ 0x3320, "DWAYNE", 0 },
+				{ 0x3321, "DWAYNE", 0 },
+				{ 0x332c, "DWAYNE", 0 }
+			};
+			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignDwayneTopicBuffer(0x94);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x95)) {
+			sharedState.discussedLodgeTopic = true;
+			Common::Error lineError = playDwayneLine(0x3339);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x96, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			lineError = Common::kNoError;
+			if (responseIndex == 1) {
+				const DialogueLineEntry responseLines[] = {
+					{ 0x3345, "DWAYNE", 2 },
+					{ 0x3346, "DWAYNE", 2 },
+					{ 0x3347, "DWAYNE", 2 }
+				};
+				lineError = runtime.playDialogueEntrySequence(responseLines, ARRAYSIZE(responseLines));
+			} else if (responseIndex == 2) {
+				lineError = playDwayneLine(0x3350);
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignDwayneTopicBuffer(0x97);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x98)) {
+			const DialogueLineEntry lines[] = {
+				{ 0x335b, "DWAYNE", 2 },
+				{ 0x3360, "PC", 2 },
+				{ 0x3364, "DWAYNE", 3 }
+			};
+			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = sharedState.dialogueStateD2f04
+				? playDwayneLine(0x3368, 2)
+				: playDwayneLine(0x336c, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playDwayneLine(0x3370, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignDwayneTopicBuffer(0x99);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x9a)) {
+			Common::Error lineError = playDwayneLine(0x337a, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignDwayneTopicBuffer(0x9b);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x9c)) {
+			const DialogueLineEntry lines[] = {
+				{ 0x3386, "DWAYNE", 0 },
+				{ 0x3387, "DWAYNE", 0 }
+			};
+			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignDwayneTopicBuffer(0x9d);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x9e))
+			continue;
+
+		Common::Error lineError = playDwayneLine(0x393c);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/edna_dialogue.cpp b/engines/harvester/npc/edna_dialogue.cpp
index 3ae1b345761..725beb5f642 100644
--- a/engines/harvester/npc/edna_dialogue.cpp
+++ b/engines/harvester/npc/edna_dialogue.cpp
@@ -19,12 +19,152 @@
  *
  */
 
+#include <functional>
+
 #include "harvester/npc/edna_dialogue.h"
 
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
 namespace Harvester {
 
+namespace {
+
+static const char *const kDialogueC043FstPath = "GRAPHIC/FST/C043.FST";
+static const char *const kDialogueC043KFstPath = "GRAPHIC/FST/C043K.FST";
+
+} // End of namespace
+
 bool EdnaDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("EDNA");
 }
 
+Common::Error EdnaDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	EdnaRoomDialogueState &state = _state;
+	auto playEdnaLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "EDNA", headVariant);
+	};
+
+	if (runtime.startupScript().getFlagValue("KILLED_KARIN1"))
+		return playEdnaLine(0x3cdf, 2);
+
+	if (runtime.startupScript().getFlagValue("DNA_S_SUICIDE_NOTE")) {
+		(void)runtime.startupScript().setRuntimeFlagValue("DNA_S_SUICIDE_NOTE", false);
+		Common::Error lineError = runtime.playDialogueFst(
+			runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE")
+				? kDialogueC043KFstPath
+				: kDialogueC043FstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		sharedState.dialogueStateD2ea4 = true;
+		sharedState.dialogueStateD2ea8 = true;
+		(void)runtime.startupScript().setRuntimeObjectVisible("DNAEXT", "SIGNOUT", true);
+		return Common::kNoError;
+	}
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("K_PURSE")) {
+			state.karinPurseLinePlayed = true;
+			return playEdnaLine(0x3d05, 3);
+		}
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+			return playEdnaLine(0x3bd6);
+		}
+		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+				usedItemName.equalsIgnoreCase("LEDGER2")) &&
+				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+			return playEdnaLine(0x3bd6);
+		}
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			return playEdnaLine(0x3bde, 2);
+		}
+		if (usedItemName.equalsIgnoreCase("NOTE") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+			return playEdnaLine(0x3be5, 2);
+		}
+		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
+				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceSheriffOwns, true);
+			return playEdnaLine(0x3bee);
+		}
+
+		return playEdnaLine(0x3bcf);
+	}
+
+	if (runtime.startupScript().getFlagValue("BRING_KARIN_TO_SHERIFF")) {
+		Common::Error lineError = playEdnaLine(0x3cce, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = runtime.playDialogueLine(0x3cd2, "PC");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playEdnaLine(0x3cd7, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		(void)runtime.startupScript().addRuntimeObjectToInventory("REWARD_MONEY");
+		(void)runtime.startupScript().setRuntimeFlagValue("BRING_KARIN_TO_SHERIFF", false);
+		return Common::kNoError;
+	}
+
+	if (state.introPending) {
+		state.introPending = false;
+		Common::Error lineError = playEdnaLine(0x3a95);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x9f, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playEdnaLine(0x3aa1, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueLine(0x3aa6, "PC");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playEdnaLine(0x3aaa);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			sharedState.dialogueStateD2f04 = true;
+			lineError = runtime.playDialogueLine(0x3aae, "PC");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			const DialogueLineEntry responseLines[] = {
+				{ 0x3ab2, "EDNA", 0 },
+				{ 0x3ab7, "PC", 0 },
+				{ 0x3abb, "EDNA", 0 },
+				{ 0x3ac2, "EDNA", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(responseLines, ARRAYSIZE(responseLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		lineError = playEdnaLine(0x3ac6, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (!runtime.startupScript().getFlagValue("KARIN_KIDNAPED")) {
+		Common::Error lineError = playEdnaLine(0x3bf8);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	return playEdnaLine(0x3bff);
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/hank_dialogue.cpp b/engines/harvester/npc/hank_dialogue.cpp
index ed38a5713e6..9a35a6789c8 100644
--- a/engines/harvester/npc/hank_dialogue.cpp
+++ b/engines/harvester/npc/hank_dialogue.cpp
@@ -19,12 +19,458 @@
  *
  */
 
+#include <functional>
+
 #include "harvester/npc/hank_dialogue.h"
 
+#include "common/array.h"
+#include "common/debug.h"
+#include "harvester/detection.h"
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+#include "harvester/startup_text.h"
+
 namespace Harvester {
 
+namespace {
+
+static const char *const kDialogueRangshotFstPath = "GRAPHIC/FST/RANGSHOT.FST";
+
+struct HankDialogueTopicLine {
+	int responseLineIndex;
+	int wavId;
+	const char *speakerId;
+	bool setDiscussedLodgeTopic;
+};
+
+static const HankDialogueTopicLine kHankDialogueTopicLines[] = {
+	{ 0xd2, 0x725, "HANK", false },
+	{ 0xd6, 0x74f, "PC", false },
+	{ 0xd8, 0x76d, "PC", false },
+	{ 0xd9, 0x76d, "PC", false },
+	{ 0xdb, 0x77b, "PC", false },
+	{ 0xdc, 0x77b, "PC", false },
+	{ 0xe0, 0x7ad, "HANK", false },
+	{ 0xe1, 0x7b7, "PC", false },
+	{ 0xe3, 0x7d6, "PC", false },
+	{ 0xe5, 0x7f4, "HANK", false },
+	{ 0xe6, 0x7fc, "PC", false },
+	{ 0xe9, 0x82d, "PC", false },
+	{ 0xeb, 0x863, "PC", false },
+	{ 0xed, 0x881, "PC", false },
+	{ 0xee, 0x8a0, "HANK", false },
+	{ 0xef, 0x8a0, "HANK", false },
+	{ 0xf0, 0x8a0, "HANK", false },
+	{ 0xf1, 0x8bf, "HANK", true }
+};
+
+static void splitDialogueMenuLine(const Common::String &line, Common::Array<Common::String> &parts) {
+	parts.clear();
+	if (line.empty())
+		return;
+
+	Common::String token;
+	for (uint i = 0; i < line.size(); ++i) {
+		if (line[i] == '/') {
+			parts.push_back(token);
+			token.clear();
+			continue;
+		}
+
+		token += line[i];
+	}
+
+	parts.push_back(token);
+}
+
+} // End of namespace
+
 bool HankDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("HANK");
 }
 
+Common::Error HankDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	const int currentStoryDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
+	HankRoomDialogueState &state = _state;
+	Common::String &hankTopicBuffer = state.currentTopicBuffer;
+	int &hankTopicBufferLineIndex = state.currentTopicBufferLineIndex;
+	auto assignHankTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(hankTopicBuffer, hankTopicBufferLineIndex,
+			responseLineIndex, "Hank topic buffer");
+	};
+
+	debugC(1, kDebugDialogue,
+		"Harvester: Hank dialogue start day=%d item='%s' initial=%d trackedDayValid=%d trackedDay=%d sameDayPending=%d rangshotPending=%d topicLine=%d topicBuffer='%s'",
+		currentStoryDayIndex, usedItemName.empty() ? "<none>" : usedItemName.c_str(),
+		(int)state.pendingInitialConversation,
+		(int)state.hasTrackedDayState, state.trackedDayIndex,
+		(int)state.pendingSameDayFollowup,
+		(int)state.pendingRangshotSequence,
+		hankTopicBufferLineIndex, hankTopicBuffer.c_str());
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+			return runtime.playDialogueLine(0xa3e, "HANK");
+		}
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			return runtime.playDialogueLine(0xa53, "HANK");
+		}
+		return runtime.playDialogueLine(0xa38, "HANK");
+	}
+
+	bool skipHankFollowupBranches = false;
+	if (state.pendingInitialConversation) {
+		state.trackedDayIndex = currentStoryDayIndex;
+		state.hasTrackedDayState = true;
+		state.pendingSameDayFollowup = true;
+		state.pendingInitialConversation = false;
+		hankTopicBuffer.clear();
+		hankTopicBufferLineIndex = -1;
+		skipHankFollowupBranches = true;
+
+		Common::Error lineError = runtime.playDialogueLine(0x703, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0xc7, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		switch (responseIndex) {
+		case 1:
+			lineError = runtime.playDialogueLine(0x70f, "HANK");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignHankTopicBuffer(0xc8);
+			break;
+		case 2:
+			lineError = runtime.playDialogueLine(0x715, "HANK");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignHankTopicBuffer(0xc9);
+			break;
+		case 3:
+			lineError = runtime.playDialogueLine(0x71c, "HANK");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignHankTopicBuffer(0xca);
+			break;
+		default:
+			break;
+		}
+	}
+
+	if (!skipHankFollowupBranches && (!state.hasTrackedDayState ||
+			currentStoryDayIndex != state.trackedDayIndex)) {
+		if (state.pendingSameDayFollowup) {
+			state.trackedDayIndex = currentStoryDayIndex;
+			state.pendingSameDayFollowup = false;
+			state.pendingRangshotSequence = true;
+
+			Common::Error lineError = runtime.playDialogueLine(0x8f5, "HANK");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		if (state.pendingRangshotSequence) {
+			Common::Error lineError = currentStoryDayIndex > 5
+				? runtime.playDialogueLine(0x8e2, "HANK")
+				: runtime.playDialogueFst(kDialogueRangshotFstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+	} else if (!skipHankFollowupBranches) {
+		state.pendingSameDayFollowup = true;
+		Common::Error lineError = currentStoryDayIndex > 5
+			? runtime.playDialogueLine(0x8e2, "HANK")
+			: runtime.playDialogueFst(kDialogueRangshotFstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
+			!state.stephMidgamePlayedShown) {
+		state.stephMidgamePlayedShown = true;
+
+		Common::Error lineError = runtime.playDialogueLine(0x92c, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0xcc, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		switch (responseIndex) {
+		case 1: {
+			const DialogueLineEntry lines[] = {
+				{ 0x939, "HANK", 0 },
+				{ 0x93f, "MOM", 2 }
+			};
+			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			break;
+		}
+		case 2:
+			lineError = runtime.playDialogueLine(0x944, "HANK");
+			break;
+		case 3:
+			lineError = runtime.playDialogueLine(0x948, "HANK");
+			break;
+		default:
+			break;
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = runtime.playDialogueLine(0x94f, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		responseIndex = 0;
+		responseError = runtime.runResponseMenu(0xcd, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		switch (responseIndex) {
+		case 1: {
+			const DialogueLineEntry lines[] = {
+				{ 0x95a, "HANK", 0 },
+				{ 0x961, "PC", 2 }
+			};
+			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			break;
+		}
+		case 2: {
+			const DialogueLineEntry lines[] = {
+				{ 0x965, "HANK", 0 },
+				{ 0x96b, "PC", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			break;
+		}
+		default:
+			break;
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = runtime.playDialogueLine(0x971, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
+			!state.burnedTvStationShown) {
+		state.burnedTvStationShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x987, "PC");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("BUSTED_ONCE") &&
+			!state.bustedOnceShown) {
+		state.bustedOnceShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x9b1, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0xce, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		switch (responseIndex) {
+		case 1: {
+			const DialogueLineEntry lines[] = {
+				{ 0x9bc, "HANK", 0 },
+				{ 0x9c9, "HANK", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			break;
+		}
+		case 2: {
+			const DialogueLineEntry lines[] = {
+				{ 0x9c1, "HANK", 0 },
+				{ 0x9c5, "PC", 0 },
+				{ 0x9c9, "HANK", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			break;
+		}
+		default:
+			break;
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
+			!state.karinKidnapedShown) {
+		state.karinKidnapedShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x9d5, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		if (sharedState.karinKidnapedDialogueState) {
+			lineError = runtime.playDialogueLineWithVariant(0x9da, "PC", 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		const DialogueLineEntry lines[] = {
+			{ 0x9de, "HANK", 0 },
+			{ 0x9df, "HANK", 0 },
+			{ 0x9e0, "HANK", 0 },
+			{ 0x9e1, "HANK", 0 },
+			{ 0x9e2, "HANK", 0 }
+		};
+		lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0xcf, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		switch (responseIndex) {
+		case 1: {
+			const DialogueLineEntry responseLines[] = {
+				{ 0x9f4, "HANK", 0 },
+				{ 0x9fc, "HANK", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(responseLines, ARRAYSIZE(responseLines));
+			break;
+		}
+		case 2:
+			lineError = runtime.playDialogueLine(0xa01, "HANK");
+			break;
+		default:
+			break;
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
+			!state.karinFoundAliveShown) {
+		state.karinFoundAliveShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0xa0b, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
+			!state.karinFoundDeadShown) {
+		state.karinFoundDeadShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0xa15, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0xd0, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		switch (responseIndex) {
+		case 1:
+			lineError = runtime.playDialogueLine(0xa20, "HANK");
+			break;
+		case 2:
+			lineError = runtime.playDialogueLine(0xa26, "HANK");
+			break;
+		default:
+			break;
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = runtime.playDialogueLine(0xa2b, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runtime.runKeywordMenu(
+			hankTopicBuffer, hankTopicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+
+		if (selectedTopic.empty())
+			return Common::kNoError;
+		debugC(1, kDebugDialogue, "Harvester: Hank selected topic='%s'", selectedTopic.c_str());
+		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()))
+			return runtime.playDialogueLine(0x8dc, "HANK");
+
+		const Common::String momTopic = runtime.startupText().getDialogueResponseLine(0xd2);
+		Common::Array<Common::String> currentTopics;
+		splitDialogueMenuLine(hankTopicBuffer, currentTopics);
+		const bool matchesMomTopicByLine = !momTopic.empty() && selectedTopic.equalsIgnoreCase(momTopic);
+		const bool matchesMomTopicFromIntroBuffer = hankTopicBufferLineIndex == 0xc8 &&
+			!currentTopics.empty() && selectedTopic.equalsIgnoreCase(currentTopics[0]);
+		if (matchesMomTopicByLine || matchesMomTopicFromIntroBuffer) {
+			debugC(1, kDebugDialogue,
+				"Harvester: Hank matched special Mom branch '%s' (lineMatch=%d bufferMatch=%d)",
+				selectedTopic.c_str(), (int)matchesMomTopicByLine, (int)matchesMomTopicFromIntroBuffer);
+			const DialogueLineEntry lines[] = {
+				{ 0x725, "HANK", 0 },
+				{ 0x729, "PC", 0 },
+				{ 0x72d, "HANK", 0 },
+				{ 0x733, "PC", 0 },
+				{ 0x737, "HANK", 0 },
+				{ 0x73b, "PC", 0 },
+				{ 0x741, "HANK", 0 },
+				{ 0x747, "MOM", 2 }
+			};
+			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			runtime.setActiveSpeakerPortrait("HANK", 0);
+			assignHankTopicBuffer(0xd5);
+			continue;
+		}
+
+		bool handledTopic = false;
+		for (const HankDialogueTopicLine &topic : kHankDialogueTopicLines) {
+			if (topic.responseLineIndex == 0xd2)
+				continue;
+
+			const Common::String topicText =
+				runtime.startupText().getDialogueResponseLine(topic.responseLineIndex);
+			if (topicText.empty() || !selectedTopic.equalsIgnoreCase(topicText))
+				continue;
+
+			if (topic.setDiscussedLodgeTopic)
+				sharedState.discussedLodgeTopic = true;
+
+			debugC(1, kDebugDialogue,
+				"Harvester: Hank matched topic '%s' to response line 0x%x (%d) -> wav=0x%x speaker='%s'",
+				selectedTopic.c_str(), topic.responseLineIndex, topic.responseLineIndex + 1,
+				topic.wavId, topic.speakerId);
+			Common::Error lineError = runtime.playDialogueLine(topic.wavId, topic.speakerId);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			handledTopic = true;
+			break;
+		}
+
+		Common::Error lineError = runtime.playDialogueLine(0xa32, "HANK");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		if (handledTopic) {
+			debugC(1, kDebugDialogue,
+				"Harvester: Hank handled topic '%s' and now falls through to generic response/exit",
+				selectedTopic.c_str());
+		} else {
+			debugC(1, kDebugDialogue,
+				"Harvester: Hank topic '%s' fell back to generic response and exits dialogue",
+				selectedTopic.c_str());
+		}
+
+		return runtime.playDialogueLine(0x8dc, "HANK");
+	}
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/jimmy_dialogue.cpp b/engines/harvester/npc/jimmy_dialogue.cpp
index 3f5cd6142f2..d1e688995a3 100644
--- a/engines/harvester/npc/jimmy_dialogue.cpp
+++ b/engines/harvester/npc/jimmy_dialogue.cpp
@@ -19,12 +19,149 @@
  *
  */
 
+#include <functional>
+
 #include "harvester/npc/jimmy_dialogue.h"
 
+#include "common/array.h"
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
 namespace Harvester {
 
 bool JimmyDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("JIMMY");
 }
 
+Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
+	JimmyRoomDialogueState &state = _state;
+
+	auto playJimmyLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "JIMMY", headVariant);
+	};
+	auto hasInventoryItem = [&](const char *objectName) {
+		Common::Array<StartupObjectRecord> inventoryObjects;
+		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
+		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
+				return true;
+		}
+
+		return false;
+	};
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("NEWSPAPER")) {
+			StartupInteractionResult jimmyInteraction;
+			const bool changedGivenPaperToday =
+				runtime.startupScript().setRuntimeFlagValue("GIVEN_PAPER_TODAY", true);
+			const bool changedNewspaperState =
+				runtime.startupScript().resetRuntimeObjectToInitialState("NEWSPAPER");
+			jimmyInteraction.mutatedRuntimeState = changedGivenPaperToday || changedNewspaperState;
+
+			StartupInteractionResult actionInteraction;
+			if (runtime.startupScript().executeNestedActionTag("ACTV_HOUSE_EXIT", actionInteraction)) {
+				jimmyInteraction.abortRemainingCommandChain =
+					jimmyInteraction.abortRemainingCommandChain ||
+					actionInteraction.abortRemainingCommandChain;
+				jimmyInteraction.mutatedRuntimeState =
+					jimmyInteraction.mutatedRuntimeState || actionInteraction.mutatedRuntimeState;
+				if (!actionInteraction.musicPath.empty())
+					jimmyInteraction.musicPath = actionInteraction.musicPath;
+				if (!actionInteraction.nextRoomName.empty())
+					jimmyInteraction.nextRoomName = actionInteraction.nextRoomName;
+				if (!actionInteraction.deathFlicPath.empty())
+					jimmyInteraction.deathFlicPath = actionInteraction.deathFlicPath;
+				if (!actionInteraction.dialogueNpcName.empty())
+					jimmyInteraction.dialogueNpcName = actionInteraction.dialogueNpcName;
+				if (!actionInteraction.dialogueContinuationTag.empty())
+					jimmyInteraction.dialogueContinuationTag = actionInteraction.dialogueContinuationTag;
+				jimmyInteraction.requestMainMenu =
+					jimmyInteraction.requestMainMenu || actionInteraction.requestMainMenu;
+				for (const StartupAudioCommand &command : actionInteraction.audioCommands)
+					jimmyInteraction.audioCommands.push_back(command);
+			}
+			runtime.queueDialogueInteractionIfNeeded(jimmyInteraction);
+
+			if (!state.paperHandoffStateSet) {
+				state.paperHandoffStateSet = true;
+				Common::Error lineError = playJimmyLine(0x4a4c, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			return runtime.getRandomNumber(1) == 0
+				? playJimmyLine(0x4acc, 0)
+				: playJimmyLine(0x4a4b, 1);
+		}
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			return playJimmyLine(0x4af8, 1);
+		}
+		if (((usedItemName.equalsIgnoreCase("LEDGER") ||
+					usedItemName.equalsIgnoreCase("LEDGER2")) &&
+				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+					usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+				(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+			} else {
+				(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+			}
+			return playJimmyLine(0x4b00, 0);
+		}
+		if (usedItemName.equalsIgnoreCase("NOTE") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+			return playJimmyLine(0x4b21, 0);
+		}
+		if (usedItemName.equalsIgnoreCase("SNEAKERS")) {
+			Common::Error lineError = playJimmyLine(0x4a9e, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		return playJimmyLine(0x4af2, 0);
+	}
+
+	if (state.firstNoItemLinePending) {
+		state.firstNoItemLinePending = false;
+		if (!state.paperHandoffStateSet) {
+			Common::Error lineError = playJimmyLine(0x4a4c, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		Common::Error lineError = playJimmyLine(0x4a58, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (hasInventoryItem("SNEAKERS") && !hasInventoryItem("BROOMKEY")) {
+		Common::Error lineError = playJimmyLine(0x4ac3, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("PAPER_CHK_4")) {
+		Common::Error lineError = playJimmyLine(0x4ae2, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("PAPER_CHK_3")) {
+		Common::Error lineError = playJimmyLine(0x4adb, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("PAPER_CHK_2")) {
+		Common::Error lineError = playJimmyLine(0x4ad4, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	return playJimmyLine(0x4b38, 0);
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/mom_dialogue.cpp b/engines/harvester/npc/mom_dialogue.cpp
index 73e2b1bdabe..a7acd8d829d 100644
--- a/engines/harvester/npc/mom_dialogue.cpp
+++ b/engines/harvester/npc/mom_dialogue.cpp
@@ -19,12 +19,786 @@
  *
  */
 
+#include <functional>
+
 #include "harvester/npc/mom_dialogue.h"
 
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
 namespace Harvester {
 
+namespace {
+
+static const char *const kDialogueC008AFstPath = "GRAPHIC/FST/C008A.FST";
+static const char *const kDialogueC008BFstPath = "GRAPHIC/FST/C008B.FST";
+static const char *const kBabyGurgleActionTag = "BABY_GURGLE";
+
+static const DialogueLineEntry kMomIntroLines[] = {
+	{ 0x1dd7, "MOM", 1 },
+	{ 0x1ddc, "PC", 0 },
+	{ 0x1de0, "MOM", 2 },
+	{ 0x1de4, "PC", 2 }
+};
+
+static const DialogueLineEntry kMomCookiesLines[] = {
+	{ 0x1e18, "PC", 0 },
+	{ 0x1e1d, "MOM", 1 },
+	{ 0x1e21, "PC", 0 },
+	{ 0x1e25, "MOM", 0 },
+	{ 0x1e29, "PC", 0 }
+};
+
+static const DialogueLineEntry kMomTopic119Lines[] = {
+	{ 0x1e47, "PC", 0 },
+	{ 0x1e4b, "MOM", 0 },
+	{ 0x1e4f, "PC", 0 },
+	{ 0x1e53, "MOM", 0 },
+	{ 0x1f99, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomTopic11dOpeningLines[] = {
+	{ 0x1e7c, "PC", 4 },
+	{ 0x1e80, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomTopic11dPostC008ALines[] = {
+	{ 0x1e8e, "MOM", 2 },
+	{ 0x1e94, "PC", 2 }
+};
+
+static const DialogueLineEntry kMomTopic11dClosingLines[] = {
+	{ 0x1e9d, "MOM", 3 },
+	{ 0x1ea3, "MOM", 3 }
+};
+
+static const DialogueLineEntry kMomTopic121Lines[] = {
+	{ 0x1ec0, "PC", 0 },
+	{ 0x1ec4, "MOM", 2 },
+	{ 0x1ec9, "PC", 0 },
+	{ 0x1ecd, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomPtaLines[] = {
+	{ 0x1eee, "PC", 2 },
+	{ 0x1ef2, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomTopic12bLines[] = {
+	{ 0x1efd, "PC", 0 },
+	{ 0x1f01, "MOM", 0 },
+	{ 0x1f07, "PC", 0 },
+	{ 0x1f0b, "MOM", 0 },
+	{ 0x22cd, "PC", 2 },
+	{ 0x22d1, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomTopic12dLines[] = {
+	{ 0x1f14, "PC", 0 },
+	{ 0x1f18, "MOM", 0 },
+	{ 0x1f1d, "PC", 0 },
+	{ 0x1f21, "MOM", 0 },
+	{ 0x1f28, "PC", 0 },
+	{ 0x1f2c, "MOM", 2 },
+	{ 0x1f31, "PC", 2 },
+	{ 0x1f35, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomTopic12fLines[] = {
+	{ 0x1f41, "PC", 4 },
+	{ 0x1f45, "MOM", 3 },
+	{ 0x1f4b, "PC", 0 },
+	{ 0x1f4f, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomCookingLines[] = {
+	{ 0x1f58, "PC", 0 },
+	{ 0x1f5c, "MOM", 0 },
+	{ 0x1f63, "PC", 0 },
+	{ 0x1f68, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomTopic135Lines[] = {
+	{ 0x1f71, "PC", 0 },
+	{ 0x1f75, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomTopic137Lines[] = {
+	{ 0x1f7f, "PC", 2 },
+	{ 0x1f84, "MOM", 3 },
+	{ 0x1f8a, "PC", 2 },
+	{ 0x1f8e, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomSparkyLines[] = {
+	{ 0x1ed7, "PC", 0 },
+	{ 0x1edb, "MOM", 2 },
+	{ 0x1ee1, "PC", 0 },
+	{ 0x1ee5, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomFatherLines[] = {
+	{ 0x1fc8, "PC", 0 },
+	{ 0x1fcc, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomFatherHeardDadMoanLines[] = {
+	{ 0x1fd3, "PC", 0 },
+	{ 0x1fd8, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomLodgeLines[] = {
+	{ 0x1fe1, "PC", 0 },
+	{ 0x1fe5, "MOM", 1 },
+	{ 0x1feb, "PC", 0 },
+	{ 0x1fef, "MOM", 1 }
+};
+
+static const DialogueLineEntry kMomLodgeResponse1Lines[] = {
+	{ 0x1ffe, "MOM", 1 }
+};
+
+static const DialogueLineEntry kMomLodgeResponse2Lines[] = {
+	{ 0x2005, "MOM", 2 },
+	{ 0x200a, "PC", 0 },
+	{ 0x200e, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomStephMidgameLodgeLines[] = {
+	{ 0x2154, "PC", 0 },
+	{ 0x215a, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomBakeSaleLines[] = {
+	{ 0x2017, "PC", 0 },
+	{ 0x201b, "MOM", 1 },
+	{ 0x2020, "PC", 0 },
+	{ 0x2024, "MOM", 0 },
+	{ 0x2029, "PC", 0 },
+	{ 0x202d, "MOM", 0 },
+	{ 0x2164, "PC", 0 },
+	{ 0x2168, "MOM", 1 },
+	{ 0x216d, "PC", 0 },
+	{ 0x2171, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomTopic14aLines[] = {
+	{ 0x2036, "MOM", 0 },
+	{ 0x203a, "PC", 0 },
+	{ 0x203e, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomMeatPlantIntroLines[] = {
+	{ 0x20f7, "PC", 0 },
+	{ 0x20fb, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomMeatPlantResponse1Lines[] = {
+	{ 0x2106, "PC", 0 },
+	{ 0x210a, "MOM", 0 },
+	{ 0x210e, "PC", 0 }
+};
+
+static const DialogueLineEntry kMomMeatPlantResponse2Lines[] = {
+	{ 0x2114, "MOM", 1 },
+	{ 0x2119, "PC", 0 },
+	{ 0x211d, "MOM", 1 }
+};
+
+static const DialogueLineEntry kMomTopic155Lines[] = {
+	{ 0x2125, "MOM", 1 },
+	{ 0x212a, "PC", 0 },
+	{ 0x212e, "MOM", 1 },
+	{ 0x2134, "PC", 0 },
+	{ 0x2138, "MOM", 1 }
+};
+
+static const DialogueLineEntry kMomTopic156Lines[] = {
+	{ 0x2142, "MOM", 0 },
+	{ 0x2147, "PC", 0 },
+	{ 0x214b, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomGoodCauseDay5Lines[] = {
+	{ 0x21a3, "PC", 0 },
+	{ 0x21a8, "MOM", 1 },
+	{ 0x21ad, "PC", 0 },
+	{ 0x21b2, "MOM", 0 },
+	{ 0x21b7, "PC", 0 }
+};
+
+static const DialogueLineEntry kMomSlaughterhouseLines[] = {
+	{ 0x1faf, "PC", 4 },
+	{ 0x1fb4, "MOM", 0 },
+	{ 0x1fb9, "PC", 2 },
+	{ 0x1fbd, "MOM", 0 }
+};
+
+static const int kMomPtaTopicResponseLines[] = { 0x125, 0x126, 0x127, 0x128 };
+static const int kMomCookingTopicResponseLines[] = { 0x131, 0x132, 0x133 };
+static const int kMomBakeSaleTopicResponseLines[] = { 0x145, 0x146, 0x147 };
+static const int kMomPottsdamTopicResponseLines[] = { 0x14b, 0x14c };
+static const int kMomMeatPlantTopicResponseLines[] = { 0x151, 0x152 };
+static const int kMomMoynahanTopicResponseLines[] = { 0x160, 0x161 };
+static const int kMomNewspaperFireTopicResponseLines[] = { 0x162, 0x163 };
+static const int kMomInitialTopicBufferResponseLine = 0x102;
+static const int kMomSameDayTopicBufferResponseLine = 0x104;
+static const int kMomPostIntroTopicBufferResponseLine = 0x105;
+static const int kMomDay5TopicBufferResponseLine = 0x113;
+static const int kMomDay6TopicBufferResponseLine = 0x114;
+static const int kMomSlaughterhouseFollowupTopicBufferResponseLine = 0x13e;
+static const int kMomFatherFollowupTopicBufferResponseLine = 0x140;
+static const int kMomLodgeFollowupTopicBufferResponseLine = 0x144;
+static const int kMomPtaFollowupTopicBufferResponseLine = 0x12a;
+static const int kMomCookingFollowupTopicBufferResponseLine = 0x134;
+static const int kMomBakeSaleFollowupTopicBufferResponseLine = 0x148;
+static const int kMomPottsdamFollowupTopicBufferResponseLine = 0x150;
+static const int kMomMeatPlantFollowupTopicBufferResponseLine = 0x154;
+static const int kMomGoodCauseDay5FollowupTopicBufferResponseLine = 0x15c;
+
+} // End of namespace
+
 bool MomDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("MOM");
 }
 
+Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	const int currentStoryDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
+	MomRoomDialogueState &state = _state;
+	Common::String momTopicBuffer;
+	int momTopicBufferLineIndex = -1;
+	auto assignMomTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(momTopicBuffer, momTopicBufferLineIndex,
+			responseLineIndex, "Mom topic buffer");
+	};
+	auto runMomGoodbye = [&]() -> Common::Error {
+		if (!runtime.startupScript().getFlagValue("DAY_FLAG"))
+			return runtime.playDialogueLine(0x256e, "MOM");
+		if (!runtime.startupScript().isNamedNpcDeathTypeClear("JIMMY"))
+			return Common::kNoError;
+
+		return runtime.playDialogueLine(0x2051, "MOM");
+	};
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("NOTE") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+			return runtime.playDialogueLine(0x2317, "MOM");
+		}
+		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+				usedItemName.equalsIgnoreCase("LEDGER2")) &&
+				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+			return runtime.playDialogueLine(0x2320, "MOM");
+		}
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+			return runtime.playDialogueLine(0x233b, "PC");
+		}
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			return runtime.playDialogueLine(0x239c, "MOM");
+		}
+		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
+				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceSheriffOwns, true);
+			return runtime.playDialogueLine(0x2382, "MOM");
+		}
+
+		return runtime.playDialogueLine(0x26cc, "MOM");
+	}
+
+	bool skipMomDefaultKeywordSeed = false;
+	if (state.introPending) {
+		state.introPending = false;
+		state.sameDayIntroLineEnabled = true;
+		state.postIntroDefaultLineEnabled = true;
+		state.introDayIndex = currentStoryDayIndex;
+		Common::Error lineError = runtime.playDialogueEntrySequence(kMomIntroLines, ARRAYSIZE(kMomIntroLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignMomTopicBuffer(kMomInitialTopicBufferResponseLine);
+		skipMomDefaultKeywordSeed = true;
+	}
+	if (!skipMomDefaultKeywordSeed && state.sameDayIntroLineEnabled &&
+			currentStoryDayIndex == state.introDayIndex) {
+		Common::Error lineError = runtime.playDialogueLine(0x2047, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignMomTopicBuffer(kMomSameDayTopicBufferResponseLine);
+		skipMomDefaultKeywordSeed = true;
+	}
+	if (!skipMomDefaultKeywordSeed && state.postIntroDefaultLineEnabled) {
+		Common::Error lineError = runtime.playDialogueLine(0x2311, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignMomTopicBuffer(kMomPostIntroTopicBufferResponseLine);
+	}
+	if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
+			!state.stephMidgameShown) {
+		state.stephMidgameShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x205a, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
+			(runtime.startupScript().getFlagValue("KARIN_KIDNAPED") ||
+				runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD")) &&
+			!state.dinerBurnedKarinMissingOrDeadShown) {
+		state.dinerBurnedKarinMissingOrDeadShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x2456, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
+			!state.burnedTvStationShown) {
+		state.burnedTvStationShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x2220, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER") &&
+			!state.scratchedTuckerShown) {
+		state.scratchedTuckerShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x23f3, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") &&
+			!state.barberPoleStolenShown) {
+		state.barberPoleStolenShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x23fb, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("BOLT_OF_CLOTH_TAKEN") &&
+			!state.boltOfClothTakenShown) {
+		state.boltOfClothTakenShown = true;
+		if (runtime.startupScript().isNamedNpcDeathTypeClear("SPARKY") &&
+				runtime.startupScript().isNamedNpcDeathTypeClear("FIREMAN2")) {
+			Common::Error lineError = runtime.playDialogueLine(0x2416, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		Common::Error lineError = runtime.playDialogueLine(0x2420, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
+			runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
+			!state.dinerBurnedKarinAliveShown) {
+		state.dinerBurnedKarinAliveShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x2434, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("PC_ESCAPED_JAIL") &&
+			!state.escapedJailShown) {
+		state.escapedJailShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x2495, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
+			!runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
+			!runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
+			!state.karinKidnapedUnresolvedShown) {
+		state.karinKidnapedUnresolvedShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x24d7, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
+			!state.karinFoundAliveShown) {
+		state.karinFoundAliveShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x2505, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
+			!state.karinFoundDeadShown) {
+		state.karinFoundDeadShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x2576, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (!runtime.startupScript().isNamedNpcDeathTypeClear("BUTCHER") &&
+			!state.butcherAbsentShown) {
+		state.butcherAbsentShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x2633, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (!runtime.startupScript().isNamedNpcDeathTypeClear("MOYNAHAN") &&
+			!state.moynahanAbsentShown) {
+		state.moynahanAbsentShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x2647, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (!runtime.startupScript().isNamedNpcDeathTypeClear("JIMMY") &&
+			!state.jimmyAbsentShown) {
+		state.jimmyAbsentShown = true;
+		if (runtime.startupScript().getFlagValue("JIMMY_ATTACKED")) {
+			Common::Error lineError = runtime.playDialogueLine(0x2659, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		Common::Error lineError = runtime.playDialogueLine(0x266c, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (!runtime.startupScript().isNamedNpcDeathTypeClear("WASP_WOMAN") &&
+			!state.waspWomanAbsentShown) {
+		state.waspWomanAbsentShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x2689, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD") &&
+			!runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
+			!state.stephanieDeadPreMidgameShown) {
+		state.stephanieDeadPreMidgameShown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x217c, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("DAY_5") && !state.day5Shown) {
+		state.day5Shown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x218d, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignMomTopicBuffer(kMomDay5TopicBufferResponseLine);
+	}
+	if (runtime.startupScript().getFlagValue("DAY_6") && !state.day6Shown) {
+		state.day6Shown = true;
+		Common::Error lineError = runtime.playDialogueLine(0x22a8, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignMomTopicBuffer(kMomDay6TopicBufferResponseLine);
+	}
+
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runtime.runKeywordMenu(
+			momTopicBuffer, momTopicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.empty())
+			return Common::kNoError;
+		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()))
+			return runMomGoodbye();
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x116)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomCookiesLines, ARRAYSIZE(kMomCookiesLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(0x118);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x119)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic119Lines, ARRAYSIZE(kMomTopic119Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(0x11a);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x11d)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic11dOpeningLines, ARRAYSIZE(kMomTopic11dOpeningLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC008AFstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueEntrySequence(
+				kMomTopic11dPostC008ALines, ARRAYSIZE(kMomTopic11dPostC008ALines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			StartupInteractionResult babyGurgleInteraction;
+			if (runtime.startupScript().executeActionTag(kBabyGurgleActionTag, babyGurgleInteraction)) {
+				runtime.applyImmediateDialogueInteractionEffects(babyGurgleInteraction);
+				runtime.queueDialogueInteractionIfNeeded(babyGurgleInteraction);
+			}
+
+			lineError = runtime.playDialogueEntrySequence(
+				kMomTopic11dClosingLines, ARRAYSIZE(kMomTopic11dClosingLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC008BFstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(0x120);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x121)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic121Lines, ARRAYSIZE(kMomTopic121Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(0x122);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x123)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomSparkyLines, ARRAYSIZE(kMomSparkyLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(0x124);
+			continue;
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMomPtaTopicResponseLines,
+				ARRAYSIZE(kMomPtaTopicResponseLines))) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomPtaLines, ARRAYSIZE(kMomPtaLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(kMomPtaFollowupTopicBufferResponseLine);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x12b)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic12bLines, ARRAYSIZE(kMomTopic12bLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(0x12c);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x12d)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic12dLines, ARRAYSIZE(kMomTopic12dLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(0x12e);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x12f)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic12fLines, ARRAYSIZE(kMomTopic12fLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(0x130);
+			continue;
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMomCookingTopicResponseLines,
+				ARRAYSIZE(kMomCookingTopicResponseLines))) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomCookingLines, ARRAYSIZE(kMomCookingLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(kMomCookingFollowupTopicBufferResponseLine);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x135)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic135Lines, ARRAYSIZE(kMomTopic135Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(0x136);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x137)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic137Lines, ARRAYSIZE(kMomTopic137Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x13a)) {
+			Common::Error lineError = runtime.playDialogueLine(0x1fa3, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(0x13b);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x13c)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomSlaughterhouseLines, ARRAYSIZE(kMomSlaughterhouseLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(kMomSlaughterhouseFollowupTopicBufferResponseLine);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x13f)) {
+			state.fatherTopicState = true;
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomFatherLines, ARRAYSIZE(kMomFatherLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			if (runtime.startupScript().getFlagValue("HEARD_DAD_MOAN")) {
+				lineError = runtime.playDialogueEntrySequence(
+					kMomFatherHeardDadMoanLines, ARRAYSIZE(kMomFatherHeardDadMoanLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			assignMomTopicBuffer(kMomFatherFollowupTopicBufferResponseLine);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x141)) {
+			Common::Error lineError = Common::kNoError;
+			if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED")) {
+				lineError = runtime.playDialogueEntrySequence(
+					kMomStephMidgameLodgeLines, ARRAYSIZE(kMomStephMidgameLodgeLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			}
+			sharedState.discussedLodgeTopic = true;
+			lineError = runtime.playDialogueEntrySequence(kMomLodgeLines, ARRAYSIZE(kMomLodgeLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x142, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			switch (responseIndex) {
+			case 1:
+				lineError = runtime.playDialogueEntrySequence(
+					kMomLodgeResponse1Lines, ARRAYSIZE(kMomLodgeResponse1Lines));
+				break;
+			case 2:
+				lineError = runtime.playDialogueEntrySequence(
+					kMomLodgeResponse2Lines, ARRAYSIZE(kMomLodgeResponse2Lines));
+				break;
+			default:
+				break;
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(kMomLodgeFollowupTopicBufferResponseLine);
+			continue;
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMomBakeSaleTopicResponseLines,
+				ARRAYSIZE(kMomBakeSaleTopicResponseLines))) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomBakeSaleLines, ARRAYSIZE(kMomBakeSaleLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(kMomBakeSaleFollowupTopicBufferResponseLine);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x14a)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic14aLines, ARRAYSIZE(kMomTopic14aLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMomPottsdamTopicResponseLines,
+				ARRAYSIZE(kMomPottsdamTopicResponseLines))) {
+			Common::Error lineError = Common::kNoError;
+			if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED"))
+				lineError = runtime.playDialogueLine(0x2086, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(kMomPottsdamFollowupTopicBufferResponseLine);
+			continue;
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMomMeatPlantTopicResponseLines,
+				ARRAYSIZE(kMomMeatPlantTopicResponseLines))) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomMeatPlantIntroLines, ARRAYSIZE(kMomMeatPlantIntroLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x153, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			switch (responseIndex) {
+			case 1:
+				lineError = runtime.playDialogueEntrySequence(
+					kMomMeatPlantResponse1Lines, ARRAYSIZE(kMomMeatPlantResponse1Lines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			case 2:
+				lineError = runtime.playDialogueEntrySequence(
+					kMomMeatPlantResponse2Lines, ARRAYSIZE(kMomMeatPlantResponse2Lines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignMomTopicBuffer(kMomMeatPlantFollowupTopicBufferResponseLine);
+				continue;
+			default:
+				continue;
+			}
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x155)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic155Lines, ARRAYSIZE(kMomTopic155Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x156)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kMomTopic156Lines, ARRAYSIZE(kMomTopic156Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x15b) && currentStoryDayIndex == 5) {
+			Common::Error lineError = Common::kNoError;
+			if (state.goodCauseDay5State) {
+				lineError = runtime.playDialogueEntrySequence(
+					kMomGoodCauseDay5Lines, ARRAYSIZE(kMomGoodCauseDay5Lines));
+			} else {
+				lineError = runtime.playDialogueLine(0x21dd, "PC");
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMomTopicBuffer(kMomGoodCauseDay5FollowupTopicBufferResponseLine);
+			continue;
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMomMoynahanTopicResponseLines,
+				ARRAYSIZE(kMomMoynahanTopicResponseLines))) {
+			Common::Error lineError = runtime.playDialogueLine(0x220a, "PC");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMomNewspaperFireTopicResponseLines,
+				ARRAYSIZE(kMomNewspaperFireTopicResponseLines))) {
+			Common::Error lineError = runtime.playDialogueLine(0x229e, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x16d)) {
+			Common::Error lineError = runtime.playDialogueLine(0x2696, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x16e)) {
+			Common::Error lineError = runtime.playDialogueLine(0x26a3, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x170)) {
+			Common::Error lineError = runtime.playDialogueLine(0x26bc, "MOM");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+
+		Common::Error lineError = runtime.playDialogueLine(0x26c6, "MOM");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		return runMomGoodbye();
+	}
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/pta_mom_dialogue.cpp b/engines/harvester/npc/pta_mom_dialogue.cpp
index 073518f3f1c..ccfb5458281 100644
--- a/engines/harvester/npc/pta_mom_dialogue.cpp
+++ b/engines/harvester/npc/pta_mom_dialogue.cpp
@@ -19,10 +19,38 @@
  *
  */
 
+#include <functional>
+
 #include "harvester/npc/pta_mom_dialogue.h"
 
+#include "harvester/npc/dialogue_runtime.h"
+
 namespace Harvester {
 
+namespace {
+
+static const char *const kPtaRespondToTvFlag = "PTA_RESPOND_TO_TV";
+static const char *const kPtaMomSpeakerId = "PTA_MOM1";
+
+static const DialogueLineEntry kPtaMomResponse1Lines[] = {
+	{ 0x3204, kPtaMomSpeakerId, 2 },
+	{ 0x320b, kPtaMomSpeakerId, 2 }
+};
+
+static const DialogueLineEntry kPtaMomResponse2Lines[] = {
+	{ 0x320f, kPtaMomSpeakerId, 0 },
+	{ 0x3213, kPtaMomSpeakerId, 0 }
+};
+
+static const DialogueLineEntry kPtaMomResponse3Lines[] = {
+	{ 0x3217, kPtaMomSpeakerId, 0 },
+	{ 0x321c, kPtaMomSpeakerId, 0 },
+	{ 0x3221, "PC", 0 },
+	{ 0x3226, kPtaMomSpeakerId, 0 }
+};
+
+} // End of namespace
+
 bool PtaMomDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("PTA_MOM1") ||
 		npcName.equalsIgnoreCase("PTA_MOM2") ||
@@ -31,4 +59,53 @@ bool PtaMomDialogueHandler::matchesNpc(const Common::String &npcName) const {
 		npcName.equalsIgnoreCase("PTA_MOM5");
 }
 
+Common::Error PtaMomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
+	(void)usedItemName;
+
+	auto playPtaMomLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kPtaMomSpeakerId, headVariant);
+	};
+
+	if (runtime.startupScript().getFlagValue(kPtaRespondToTvFlag)) {
+		Common::Error lineError = playPtaMomLine(0x3233, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		(void)runtime.startupScript().setRuntimeFlagValue(kPtaRespondToTvFlag, false);
+		return Common::kNoError;
+	}
+
+	Common::Error lineError = Common::kNoError;
+	switch (runtime.getRandomNumber(2)) {
+	case 0:
+		lineError = playPtaMomLine(0x31ee, 1);
+		break;
+	case 1:
+		lineError = playPtaMomLine(0x31f2, 1);
+		break;
+	default:
+		lineError = playPtaMomLine(0x31f6, 1);
+		break;
+	}
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(0x297, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	switch (responseIndex) {
+	case 1:
+		return runtime.playDialogueEntrySequence(kPtaMomResponse1Lines, ARRAYSIZE(kPtaMomResponse1Lines));
+	case 2:
+		return runtime.playDialogueEntrySequence(kPtaMomResponse2Lines, ARRAYSIZE(kPtaMomResponse2Lines));
+	case 3:
+		return runtime.playDialogueEntrySequence(kPtaMomResponse3Lines, ARRAYSIZE(kPtaMomResponse3Lines));
+	default:
+		return Common::kNoError;
+	}
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/wasp_woman_dialogue.cpp b/engines/harvester/npc/wasp_woman_dialogue.cpp
index 801ee4198df..14c195681a8 100644
--- a/engines/harvester/npc/wasp_woman_dialogue.cpp
+++ b/engines/harvester/npc/wasp_woman_dialogue.cpp
@@ -19,12 +19,167 @@
  *
  */
 
+#include <functional>
+
 #include "harvester/npc/wasp_woman_dialogue.h"
 
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
 namespace Harvester {
 
+namespace {
+
+static const DialogueLineEntry kWaspWomanWhaleyLines[] = {
+	{ 0x4ca6, "WASP_WOMAN", 0 },
+	{ 0x4cae, "WASP_WOMAN", 1 },
+	{ 0x4cb2, "PC", 0 },
+	{ 0x4cb6, "WASP_WOMAN", 0 }
+};
+
+static const DialogueLineEntry kWaspWomanIntroTailLines[] = {
+	{ 0x4bf6, "WASP_WOMAN", 0 },
+	{ 0x4bfc, "PC", 0 }
+};
+
+static const DialogueLineEntry kWaspWomanTopic305Response1Lines[] = {
+	{ 0x4c4d, "WASP_WOMAN", 1 },
+	{ 0x4c53, "WASP_WOMAN", 1 }
+};
+
+static const int kWaspWomanTopicBufferResponseLine = 0x301;
+
+} // End of namespace
+
 bool WaspWomanDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("WASP_WOMAN");
 }
 
+Common::Error WaspWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	WaspWomanRoomDialogueState &state = _state;
+	Common::String waspWomanTopicBuffer;
+	int waspWomanTopicBufferLineIndex = -1;
+	auto assignWaspWomanTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(waspWomanTopicBuffer, waspWomanTopicBufferLineIndex,
+			responseLineIndex, "Wasp Woman topic buffer");
+	};
+	auto playWaspWomanLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "WASP_WOMAN", headVariant);
+	};
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			return runtime.playDialogueEntrySequence(kWaspWomanWhaleyLines,
+				ARRAYSIZE(kWaspWomanWhaleyLines));
+		}
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+			return playWaspWomanLine(0x4cbd, 0);
+		}
+		if (usedItemName.equalsIgnoreCase("NOTE") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+			return playWaspWomanLine(0x4cc3, 0);
+		}
+
+		return playWaspWomanLine(0x4ca0, 0);
+	}
+
+	if (state.introPending) {
+		state.introPending = false;
+
+		Common::Error lineError = playWaspWomanLine(0x4bee, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (sharedState.waspWomanDialogueState) {
+			lineError = runtime.playDialogueLine(0x4bf2, "PC");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		lineError = runtime.playDialogueEntrySequence(kWaspWomanIntroTailLines,
+			ARRAYSIZE(kWaspWomanIntroTailLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	Common::Error lineError = playWaspWomanLine(0x4bee, 0);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+	assignWaspWomanTopicBuffer(kWaspWomanTopicBufferResponseLine);
+
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runtime.runKeywordMenu(
+			waspWomanTopicBuffer, waspWomanTopicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.empty() || selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()))
+			return Common::kNoError;
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x302)) {
+			lineError = playWaspWomanLine(0x4c0a, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x305)) {
+			lineError = playWaspWomanLine(0x4c31, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			if (!sharedState.waspWomanDialogueState)
+				continue;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x307, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			switch (responseIndex) {
+			case 1:
+				lineError = runtime.playDialogueEntrySequence(kWaspWomanTopic305Response1Lines,
+					ARRAYSIZE(kWaspWomanTopic305Response1Lines));
+				break;
+			case 2:
+				lineError = playWaspWomanLine(0x4c5e, 2);
+				break;
+			default:
+				lineError = Common::kNoError;
+				break;
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x308)) {
+			lineError = playWaspWomanLine(0x4c67, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x30a)) {
+			lineError = playWaspWomanLine(0x4c74, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x30c)) {
+			lineError = playWaspWomanLine(0x4c85, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x30e)) {
+			lineError = playWaspWomanLine(0x4c9a, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			return Common::kNoError;
+		}
+	}
+}
+
 } // End of namespace Harvester


Commit: ed1f0d3c3dc33fb83bdbe10f49cde1252637f2f4
    https://github.com/scummvm/scummvm/commit/ed1f0d3c3dc33fb83bdbe10f49cde1252637f2f4
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:58+02:00

Commit Message:
HARVESTER: Port herrill startup dialogue

Changed paths:
  A engines/harvester/npc/herrill_dialogue.cpp
  A engines/harvester/npc/herrill_dialogue.h
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk
    engines/harvester/npc/dialogue_handler.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 8aece0443b1..b45bf1c8068 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -448,13 +448,23 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - The primary intro gate is `g_edna_talk_state_block`: on the first normal no-item visit Edna plays `0x3a95`, opens zero-based `dialog.rsp` line `0x9f`, then response `1` plays `0x3aa1` / `0x3aa6` / `0x3aaa`, sets `get_set_shared_dialogue_state_d2f04`, and continues with `0x3aae` / `0x3ac6`, while response `2` plays `0x3ab2` / `0x3ab7` / `0x3abb` / `0x3ac2` / `0x3ac6`.
     - The item path is linear and evidence-driven: `K_PURSE` plays `0x3d05` and sets the one-shot purse bit, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` and the dual-ledger case share `0x3bd6`, `PHOTO_OF_WHALEY_HERRILL` plays `0x3bde`, note/checkbook evidence plays `0x3be5`, TV deed evidence plays `0x3bee`, and all other presented objects fall back to `0x3bcf`.
     - The steady-state no-item tail is also simple: if `KARIN_KIDNAPED` is still clear Edna plays `0x3bf8`, then always ends the branch on `0x3bff`.
+  - `handle_talk_to_herrill @ 0x2dc00` is now bounded enough for a direct startup-room port.
+    - The early lodge branch is a strict short-circuit: `HERRILL_IN_LODGE` plus a clear `PC_TALKED_TO_HERRILL` flag plays `HERRILL_LOG 0x31ca` and returns, while the normal room path first checks the Herrill-local suppression and evidence-required bits.
+    - The item path reuses the shared evidence cluster: `PHOTO_OF_WHALEY_HERRILL` plays `0x309f`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` and the dual-ledger case share `0x30a6`, note/checkbook evidence plays `0x30bf`, TV deed evidence plays `0x3188`, and all other presented objects fall back to `0x3099`.
+    - `GASCAN` is the one special item branch. It first plays the fixed `0x30cf` / `0x30d3` / `0x30d8` exchange, then reads neutral shared talk-state bits at `0xd2e98`, `0xd2eb0`, and `0xd2eec` plus the shared Boyle gas-can application bit. When `0xd2e98` is clear the branch collapses to `PC 0x3123`; otherwise it opens zero-based `dialog.rsp` line `0x280`, where response `1` continues through `0x30e7` and the optional `0x30eb` / `0x30f4` / `0x3104` inserts before the `HAVE_LODGE_APP` split, while response `2` exits on `0x311b`.
+    - The first no-item visit is gated by `g_herrill_talk_state_block`: it plays `0x2f59` / `0x2f5e` / `0x2f62` / `0x1654` / `0x2f6e` and seeds the keyword buffer from zero-based `dialog.rsp` line `0x281`. Later no-item visits start on `0x2ffb`.
+    - The steady-state no-item followups are a linear one-shot bark chain over `STEPH_MIDGAME_PLAYED`, story day `5`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `KARIN_KIDNAPED`, `KARIN_FOUND_ALIVE`, `KARIN_FOUND_DEAD`, `DINER_BURNED`, and the shared Whaley/Herrill-photo evidence flag.
+    - Herrill's keyword loop now has a clean branch map: response line `0x283` exits on `0x2ff4`, `0x284` / `0x286` / `0x288` / `0x28a` / `0x28c` / `0x291` each play a single line and loop, `0x28e` sets `get_set_discussed_lodge_topic` before `0x2fdd`, `0x28f` loops silently, and `0x293` exits through `0x3099` then `0x2ff4`.
 - The shared dialogue evidence/topic state shims behind those handlers are now partially explicit:
+  - `get_set_shared_dialogue_state_d2e98` at `0x38230` is a neutral wrapper over `g_shared_dialogue_state_d2e98`; the only currently recovered gameplay reader is Herrill's `GASCAN` branch, where a zero value collapses the post-`0x30d8` continuation to `PC 0x3123`.
   - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
+  - `get_set_shared_dialogue_state_d2eb0` at `0x382a0` is a neutral wrapper over `g_shared_dialogue_state_d2eb0`; Herrill's `GASCAN` response-1 branch uses it to decide whether `0x30eb` is inserted after `0x30e7`.
   - `get_set_discussed_note_checkbook_evidence` at `0x38330` gates the shared `NOTE` / `NOTE_PHOTOCOPY` / `CHECKBOOK` / `CHECKBOOK_PHOTOCOPY` evidence path used by Dwayne, Edna, Herrill, Johnson, Mom, and related handlers.
   - `get_set_discussed_tv_deed_evidence` at `0x38340` gates the `TV_DEED` / `TV_DEED_PHOTOCOPY` evidence path used by Edna, Herrill, Johnson, and Mom.
   - `get_set_discussed_ledger_evidence` at `0x38350` gates the shared dual-ledger evidence path reached when `HAVE_BOTH_LEDGERS` is set.
   - `get_set_discussed_casket_photo_evidence` at `0x38360` gates the `CASKET_PHOTO` / `CASKET_PHOTOCOPY` evidence path shared across Boyle, Dwayne, Edna, Hank, Herrill, Johnson, Mom, and others.
   - `get_set_discussed_whaley_herrill_photo` at `0x38370` gates the shared `PHOTO_OF_WHALEY_HERRILL` evidence path.
+  - `get_set_shared_dialogue_state_d2eec` at `0x38380` is a neutral wrapper over `g_shared_dialogue_state_d2eec`; Herrill's `HAVE_LODGE_APP` continuation uses it to decide whether `0x3104` is inserted before `0x3109`.
   - `get_set_discussed_karin_purse` at `0x38390` gates Dwayne's `K_PURSE` evidence state and the later `KARIN_FOUND_DEAD` follow-up that depends on whether he has seen the purse yet.
   - `get_set_dwayne_pending_karin_alive_followup` at `0x38260` is a Dwayne-local state bit set by the `BRING_KARIN_TO_SHERIFF` / `KARIN_FOUND_ALIVE` path, then read and cleared when the delayed next-day follow-up bark triggers.
   - `get_set_dwayne_completed_karin_alive_followup` at `0x38480` is the shared completion bit written only after that delayed Dwayne follow-up fires; the hidden Dwayne branch plays `0x3780` / `0x378a`, clears `get_set_dwayne_pending_karin_alive_followup`, then sets this helper, and Stephanie later reads it on her hidden `KARIN_FOUND_ALIVE` path to decide whether `0x49c1` and the `0x49ea` / `0x49ef` exit lines should run.
diff --git a/TRACKER.md b/TRACKER.md
index f83b69345cd..c04f467483b 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,16 +15,15 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_edna @ 0x358a0` into `DialogueSystem` as `engines/harvester/npc/edna_dialogue.*`.
-  - `EDNA` now routes through a dedicated handler instead of the generic stub registry entry, and the runtime script API now exposes the two minimal object-state helpers this native handler actually needs: add `REWARD_MONEY` to inventory and reveal `DNAEXT/SIGNOUT`.
-  - The engine now mirrors the native early special branches: `KILLED_KARIN1` -> `0x3cdf`, `DNA_S_SUICIDE_NOTE` -> `C043.FST` / `C043K.FST` plus `SIGNOUT` visibility, and `BRING_KARIN_TO_SHERIFF` -> `0x3cce` / `0x3cd2` / `0x3cd7` plus the `REWARD_MONEY` inventory award and flag clear.
-  - The first normal no-item visit now mirrors the native intro gate on `g_edna_talk_state_block`: `0x3a95`, zero-based `dialog.rsp` line `0x9f`, then response `1` -> `0x3aa1` / `0x3aa6` / `0x3aaa` / `0x3aae` / `0x3ac6` with shared `d2f04` set, response `2` -> `0x3ab2` / `0x3ab7` / `0x3abb` / `0x3ac2` / `0x3ac6`.
-  - The item path now mirrors the confirmed shared evidence writes for casket photo, dual-ledger, Whaley/Herrill, note/checkbook, and TV deed evidence, while keeping Edna-local state for the `K_PURSE` one-shot line.
-  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Edna handler port.
+- Ported `handle_talk_to_herrill @ 0x2dc00` into `DialogueSystem` as `engines/harvester/npc/herrill_dialogue.*`.
+  - `HERRILL` now routes through a dedicated handler instead of the generic stub registry entry, and the engine mirrors the bounded native lodge shortcut, the shared evidence item replies, the one-shot no-item bark chain, and the looping keyword menu seeded from zero-based `dialog.rsp` line `0x281`.
+  - The previously obscured mid-function branch is now confirmed as `KARIN_KIDNAPED`, not another helper-only condition. Herrill's no-item followups are now implemented directly from raw control flow for `STEPH_MIDGAME_PLAYED`, story day `5`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `KARIN_KIDNAPED`, `KARIN_FOUND_ALIVE`, `KARIN_FOUND_DEAD`, `DINER_BURNED`, and the Whaley/Herrill-photo follow-up.
+  - Herrill's `GASCAN` path is now mirrored in-engine as its own response tree: `0x30cf` / `0x30d3` / `0x30d8`, zero-based `dialog.rsp` line `0x280`, the `HAVE_LODGE_APP` split, and the neutral shared talk-state reads at `0xd2e98`, `0xd2eb0`, and `0xd2eec`.
+  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Herrill handler port.
 
 ## Next Suggested Action
 
-1. Port `handle_talk_to_herrill @ 0x2dc00` into `DialogueSystem` as `engines/harvester/npc/herrill_dialogue.*`.
-   - Herrill is now the next bounded startup-room handler still behind a stub, and his visible evidence branches overlap the same shared `SHOWN_*` note/checkbook, TV deed, casket photo, and Whaley/Herrill helpers Edna just confirmed in-engine.
-2. After Herrill, continue with `handle_talk_to_johnson @ 0x27dd0`.
-   - Johnson is the last remaining bounded startup-room stub in this evidence-sharing cluster before the tracker can return to wider helper scans.
+1. Port `handle_talk_to_johnson @ 0x27dd0` into `DialogueSystem` as `engines/harvester/npc/johnson_dialogue.*`.
+   - Johnson is now the last bounded startup-room stub in this shared-evidence cluster, and Herrill's port confirmed the remaining shared talk-state reads around the sheriff/lodge path without changing the broader handler interface.
+2. After Johnson, return to the unresolved setters for the neutral shared talk-state bits at `0xd2e98`, `0xd2eb0`, and `0xd2eec`.
+   - Herrill now reads those bits in-engine on the `GASCAN` path, but their gameplay-side writers are still outside the currently ported handler set.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 92f1ced802b..6c137e09605 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -40,6 +40,7 @@
 #include "harvester/npc/dialogue_runtime.h"
 #include "harvester/npc/edna_dialogue.h"
 #include "harvester/npc/hank_dialogue.h"
+#include "harvester/npc/herrill_dialogue.h"
 #include "harvester/npc/jimmy_dialogue.h"
 #include "harvester/npc/mom_dialogue.h"
 #include "harvester/npc/pta_mom_dialogue.h"
@@ -332,10 +333,10 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new WaspWomanDialogueHandler());
 	_npcHandlers.push_back(new MomDialogueHandler());
 	_npcHandlers.push_back(new HankDialogueHandler());
+	_npcHandlers.push_back(new HerrillDialogueHandler());
 	_npcHandlers.push_back(new PtaMomDialogueHandler());
 
 	_npcHandlers.push_back(new StubNpcDialogueHandler("PTA_MOM"));
-	_npcHandlers.push_back(new StubNpcDialogueHandler("HERRILL"));
 	_npcHandlers.push_back(new StubNpcDialogueHandler("JOHNSON"));
 }
 
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 3697845f88a..8f24a7054b1 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -10,6 +10,7 @@ MODULE_OBJS = \
 	npc/dwayne_dialogue.o \
 	npc/edna_dialogue.o \
 	npc/hank_dialogue.o \
+	npc/herrill_dialogue.o \
 	npc/jimmy_dialogue.o \
 	npc/mom_dialogue.o \
 	npc/pta_mom_dialogue.o \
diff --git a/engines/harvester/npc/dialogue_handler.h b/engines/harvester/npc/dialogue_handler.h
index 788f8986f34..0a6d1392ca4 100644
--- a/engines/harvester/npc/dialogue_handler.h
+++ b/engines/harvester/npc/dialogue_handler.h
@@ -31,6 +31,9 @@ class DialogueRuntime;
 
 struct DialogueSharedState {
 	bool boyleGascanApplicationState = false;
+	bool dialogueStateD2e98 = false;
+	bool dialogueStateD2eb0 = false;
+	bool dialogueStateD2eec = false;
 	bool dialogueStateD2f04 = false;
 	bool dialogueStateD2ea4 = false;
 	bool dialogueStateD2ea8 = false;
diff --git a/engines/harvester/npc/herrill_dialogue.cpp b/engines/harvester/npc/herrill_dialogue.cpp
new file mode 100644
index 00000000000..a6d77149468
--- /dev/null
+++ b/engines/harvester/npc/herrill_dialogue.cpp
@@ -0,0 +1,374 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/herrill_dialogue.h"
+
+#include "common/array.h"
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+namespace {
+
+static const int kHerrillGascanResponseLine = 0x280;
+static const int kHerrillTopicBufferResponseLine = 0x281;
+
+static const DialogueLineEntry kHerrillIntroLines[] = {
+	{ 0x2f59, "HERRILL", 1 },
+	{ 0x2f5e, "PC", 0 },
+	{ 0x2f62, "HERRILL", 1 },
+	{ 0x1654, "PC", 0 },
+	{ 0x2f6e, "HERRILL", 0 }
+};
+
+static const DialogueLineEntry kHerrillGascanIntroLines[] = {
+	{ 0x30cf, "HERRILL", 1 },
+	{ 0x30d3, "PC", 0 },
+	{ 0x30d8, "HERRILL", 0 }
+};
+
+} // End of namespace
+
+bool HerrillDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("HERRILL");
+}
+
+Common::Error HerrillDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	HerrillRoomDialogueState &state = _state;
+	Common::String &herrillTopicBuffer = state.currentTopicBuffer;
+	int &herrillTopicBufferLineIndex = state.currentTopicBufferLineIndex;
+
+	auto assignHerrillTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(herrillTopicBuffer, herrillTopicBufferLineIndex,
+			responseLineIndex, "Herrill topic buffer");
+	};
+	auto playHerrillLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "HERRILL", headVariant);
+	};
+	auto playHerrillLogLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "HERRILL_LOG", headVariant);
+	};
+	auto playPcLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "PC", headVariant);
+	};
+	auto hasInventoryItem = [&](const char *objectName) {
+		Common::Array<StartupObjectRecord> inventoryObjects;
+		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
+		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
+				return true;
+		}
+
+		return false;
+	};
+	auto hasRequiredEvidence = [&]() {
+		return hasInventoryItem("CHECKBOOK") ||
+			hasInventoryItem("CHECKBOOK_PHOTOCOPY") ||
+			hasInventoryItem("NOTE") ||
+			hasInventoryItem("NOTE_PHOTOCOPY") ||
+			hasInventoryItem("TV_DEED") ||
+			hasInventoryItem("TV_DEED_PHOTOCOPY");
+	};
+	auto shownWhaleyHerrillPhoto = [&]() {
+		return runtime.startupScript().getFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill);
+	};
+
+	if (runtime.startupScript().getFlagValue("HERRILL_IN_LODGE")) {
+		if (!runtime.startupScript().getFlagValue("PC_TALKED_TO_HERRILL"))
+			return playHerrillLogLine(0x31ca, 1);
+		return Common::kNoError;
+	}
+
+	if (state.dialogueSuppressed)
+		return Common::kNoError;
+	if (state.requiresEvidenceForDialogue && !hasRequiredEvidence())
+		return Common::kNoError;
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			return playHerrillLine(0x309f, 2);
+		}
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+			return playHerrillLine(0x30a6);
+		}
+		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+					usedItemName.equalsIgnoreCase("LEDGER2")) &&
+				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+			return playHerrillLine(0x30a6);
+		}
+		if (usedItemName.equalsIgnoreCase("GASCAN")) {
+			state.gascanPresented = true;
+		} else if (usedItemName.equalsIgnoreCase("TV_DEED") ||
+				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceSheriffOwns, true);
+			return playHerrillLine(0x3188);
+		} else if (usedItemName.equalsIgnoreCase("NOTE") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+			return playHerrillLine(0x30bf);
+		} else {
+			return playHerrillLine(0x3099);
+		}
+	}
+
+	if (state.gascanPresented) {
+		state.gascanPresented = false;
+
+		Common::Error lineError = runtime.playDialogueEntrySequence(
+			kHerrillGascanIntroLines, ARRAYSIZE(kHerrillGascanIntroLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (!sharedState.dialogueStateD2e98)
+			return playPcLine(0x3123);
+
+		state.dialogueStateD2ce4 = true;
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(kHerrillGascanResponseLine, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playHerrillLine(0x30e7);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			if (sharedState.dialogueStateD2eb0) {
+				lineError = playHerrillLine(0x30eb);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			if (sharedState.boyleGascanApplicationState) {
+				lineError = playHerrillLine(0x30f4);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			if (runtime.startupScript().getFlagValue("HAVE_LODGE_APP")) {
+				lineError = playPcLine(0x30fa);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playHerrillLine(0x30ff, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				if (sharedState.dialogueStateD2eec) {
+					lineError = playHerrillLine(0x3104);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				}
+				return playHerrillLine(0x3109, 2);
+			}
+
+			lineError = playHerrillLine(0x30f0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playPcLine(0x3110);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			return playHerrillLine(0x3114, 3);
+		}
+		if (responseIndex == 2)
+			return playHerrillLine(0x311b, 1);
+
+		return Common::kNoError;
+	}
+
+	if (state.introPending) {
+		state.introPending = false;
+		Common::Error lineError = runtime.playDialogueEntrySequence(
+			kHerrillIntroLines, ARRAYSIZE(kHerrillIntroLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignHerrillTopicBuffer(kHerrillTopicBufferResponseLine);
+	} else {
+		Common::Error lineError = playHerrillLine(0x2ffb);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
+			!state.stephMidgamePlayedShown) {
+		state.stephMidgamePlayedShown = true;
+		Common::Error lineError = playHerrillLine(0x3004, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getCurrentStoryDayIndex() == 5 &&
+			!state.dayFiveShown) {
+		state.dayFiveShown = true;
+		Common::Error lineError = playHerrillLine(0x300d, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER") &&
+			!state.scratchedTuckerShown) {
+		state.scratchedTuckerShown = true;
+		Common::Error lineError = playHerrillLine(0x3019, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playHerrillLine(0x3022, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") &&
+			!state.barberPoleStolenShown) {
+		state.barberPoleStolenShown = true;
+		Common::Error lineError = playHerrillLine(0x3029);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playHerrillLine(0x302f, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
+			!state.karinKidnapedShown) {
+		state.karinKidnapedShown = true;
+		sharedState.karinKidnapedDialogueState = true;
+		Common::Error lineError = playHerrillLine(0x3041);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
+			!state.karinFoundAliveShown) {
+		state.karinFoundAliveShown = true;
+		Common::Error lineError = playHerrillLine(0x304c, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = shownWhaleyHerrillPhoto()
+			? playHerrillLine(0x3050, 2)
+			: playHerrillLine(0x3057, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
+			!state.karinFoundDeadShown) {
+		state.karinFoundDeadShown = true;
+		Common::Error lineError = playHerrillLine(0x305e);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playPcLine(0x461b, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED")) {
+			lineError = playHerrillLine(0x3067, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		lineError = shownWhaleyHerrillPhoto()
+			? playHerrillLine(0x306b, 2)
+			: playHerrillLine(0x3073, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
+			!state.dinerBurnedShown) {
+		state.dinerBurnedShown = true;
+		Common::Error lineError = playHerrillLine(0x307d, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (shownWhaleyHerrillPhoto() && !state.whaleyHerrillFollowupShown) {
+		state.whaleyHerrillFollowupShown = true;
+		Common::Error lineError = playHerrillLine(0x31a6, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (herrillTopicBufferLineIndex < 0)
+		assignHerrillTopicBuffer(kHerrillTopicBufferResponseLine);
+
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runtime.runKeywordMenu(
+			herrillTopicBuffer, herrillTopicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.empty())
+			return Common::kNoError;
+		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
+				runtime.matchesResponseLine(selectedTopic, 0x283)) {
+			return playHerrillLine(0x2ff4);
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x284)) {
+			Common::Error lineError = playPcLine(0x2f7b, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x286)) {
+			Common::Error lineError = playHerrillLine(0x2f8f);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x288)) {
+			Common::Error lineError = playHerrillLine(0x2f9e, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x28a)) {
+			Common::Error lineError = playPcLine(0x2fb2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x28c)) {
+			Common::Error lineError = playPcLine(0x2fcd);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x28e)) {
+			sharedState.discussedLodgeTopic = true;
+			Common::Error lineError = playHerrillLine(0x2fdd);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x28f))
+			continue;
+		if (runtime.matchesResponseLine(selectedTopic, 0x291)) {
+			Common::Error lineError = playHerrillLine(0x2fe9, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x293)) {
+			Common::Error lineError = playHerrillLine(0x3099);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			return playHerrillLine(0x2ff4);
+		}
+
+		return playHerrillLine(0x2ff4);
+	}
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/herrill_dialogue.h b/engines/harvester/npc/herrill_dialogue.h
new file mode 100644
index 00000000000..d1d34a781fb
--- /dev/null
+++ b/engines/harvester/npc/herrill_dialogue.h
@@ -0,0 +1,61 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_HERRILL_DIALOGUE_H
+#define HARVESTER_NPC_HERRILL_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class HerrillDialogueHandler : public NpcDialogueHandler {
+public:
+	struct HerrillRoomDialogueState {
+		bool introPending = true;
+		bool gascanPresented = false;
+		bool dialogueStateD2ce4 = false;
+		bool dialogueSuppressed = false;
+		bool requiresEvidenceForDialogue = false;
+		bool stephMidgamePlayedShown = false;
+		bool dayFiveShown = false;
+		bool scratchedTuckerShown = false;
+		bool barberPoleStolenShown = false;
+		bool karinKidnapedShown = false;
+		bool karinFoundAliveShown = false;
+		bool karinFoundDeadShown = false;
+		bool dinerBurnedShown = false;
+		bool whaleyHerrillFollowupShown = false;
+		Common::String currentTopicBuffer;
+		int currentTopicBufferLineIndex = -1;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = HerrillRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	HerrillRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_HERRILL_DIALOGUE_H


Commit: b98e81be1b04cffd274e58c4f00f1042437a9793
    https://github.com/scummvm/scummvm/commit/b98e81be1b04cffd274e58c4f00f1042437a9793
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:59+02:00

Commit Message:
HARVESTER: Port johnson startup dialogue

Changed paths:
  A engines/harvester/npc/johnson_dialogue.cpp
  A engines/harvester/npc/johnson_dialogue.h
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index b45bf1c8068..8dd81caa0b5 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -455,6 +455,13 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - The first no-item visit is gated by `g_herrill_talk_state_block`: it plays `0x2f59` / `0x2f5e` / `0x2f62` / `0x1654` / `0x2f6e` and seeds the keyword buffer from zero-based `dialog.rsp` line `0x281`. Later no-item visits start on `0x2ffb`.
     - The steady-state no-item followups are a linear one-shot bark chain over `STEPH_MIDGAME_PLAYED`, story day `5`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `KARIN_KIDNAPED`, `KARIN_FOUND_ALIVE`, `KARIN_FOUND_DEAD`, `DINER_BURNED`, and the shared Whaley/Herrill-photo evidence flag.
     - Herrill's keyword loop now has a clean branch map: response line `0x283` exits on `0x2ff4`, `0x284` / `0x286` / `0x288` / `0x28a` / `0x28c` / `0x291` each play a single line and loop, `0x28e` sets `get_set_discussed_lodge_topic` before `0x2fdd`, `0x28f` loops silently, and `0x293` exits through `0x3099` then `0x2ff4`.
+  - `handle_talk_to_johnson @ 0x27dd0` is now bounded enough for a direct startup-room port.
+    - Johnson has three early short-circuit branches before the normal room flow: `2ND_SCRATCH_TUCKER` returns on `0xbe6`, `TRY_TO_SCRATCH_TUCKER` is a one-shot `0xbe0` bark keyed by the local flag at `0xd2bf8`, and `OPENING_MANHOLE_DAYTIME` also returns on `0xbe0`.
+    - The item path is another straight shared-evidence cluster: `PHOTO_OF_WHALEY_HERRILL` plays `0xb75`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` plays `0xb89`, the dual-ledger case plays `0xba0`, note/checkbook evidence plays `0xbb1`, TV deed evidence plays `0xbce`, and all other presented objects fall back to `0xb6f`.
+    - The main no-item gate is the Johnson-local intro bit at `0xd2bf4`. When set, Johnson plays `0xa86` / `0xa8a` / `0xa8e` / `0xa97` / `0xa9b`, sets `get_set_shared_dialogue_state_d2f04`, and seeds the keyword buffer from zero-based `dialog.rsp` line `0x176`; otherwise the steady revisit entry is `0xb62`.
+    - `SCRATCHED_TUCKER` has its own one-shot re-entry hook: the first time that flag is seen, Johnson plays `0xc25`, re-arms the intro gate, and then immediately falls back into the normal intro path.
+    - The steady-state no-item bark chain is fully linear and one-shot gated: `STEPH_MIDGAME_PLAYED` -> `0xbf0` / `0xbf1` / `0xbf2`, `KARIN_KIDNAPED` -> `0xbff` / `0xc06` plus another `d2f04` write, `KARIN_FOUND_ALIVE` -> `0xc13` / `0xc17`, `BARBER_POLE_STOLEN` -> `0xc46` / `0xc4d` / `0xc59`, `DINER_BURNED` -> `0xc6f`, `PC_ESCAPED_JAIL` -> `0xc77`, `GOT_REMAINS_FOR_LODGE` -> `0xc7f`, and `BURNED_TV_STATION` -> `0xc8f`.
+    - Johnson's keyword loop now has a stable branch map: `0x177` exits on `0xbda`, `0x178` / `0x179` play `0xaa6`, open zero-based `dialog.rsp` line `0x17a`, then continue through `0xabd` / `0xac3` / `0xac7` and rewrite the keyword buffer to `0x17b`; `0x17c` plays `0xb17` / `0xb1c` / `0xb20` and rewrites to `0x17d`; `0x17e` plays `0xb2d` and rewrites to `0x17f`; `0x180` / `0x181` / `0x182`, `0x184` / `0x185`, and `0x187` each play one topic line, then fall through the generic `0xb69` / `0xbda` exit; `0x189` loops silently on the current buffer.
 - The shared dialogue evidence/topic state shims behind those handlers are now partially explicit:
   - `get_set_shared_dialogue_state_d2e98` at `0x38230` is a neutral wrapper over `g_shared_dialogue_state_d2e98`; the only currently recovered gameplay reader is Herrill's `GASCAN` branch, where a zero value collapses the post-`0x30d8` continuation to `PC 0x3123`.
   - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
@@ -473,6 +480,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `get_set_mom_good_cause_day5_state` at `0x383c0` is a Mom-local day-5 `GOOD CAUSE` state bit; the visible caller currently reads it, but the concrete setter is still outside the recovered call graph.
   - `get_set_dad_meat_permission_state` at `0x38400` is the Dad-local `MEAT_PERMISSION0` state bit read on the related item branch.
   - `get_set_shared_dialogue_state_d2f04` at `0x383e0` is a neutral shared wrapper over `g_shared_dialogue_state_d2f04`; raw call tracing shows it is used from hidden regions in Moynahan, Mr. Potts, Johnson, Phelps, Butcher, Dad, McKnight, Loomis, Edna, Sparky, and Dwayne, but the surrounding subtitle lines still span too many unrelated topics for a stronger semantic rename.
+    - Johnson writes that shared bit on both the primary intro branch (`0xa86` through `0xa9b`) and the `KARIN_KIDNAPED` follow-up (`0xbff` / `0xc06`).
   - `get_set_wasp_woman_dialogue_state_d2f10` at `0x38420` is a neutral wrapper over `g_wasp_woman_mom_dialogue_state_d2f10`; a program-wide linear call scan currently only finds two call sites, both inside hidden `handle_talk_to_wasp_woman` code at `0x2ff4e` and `0x3010f`.
   - On Wasp Woman's no-item intro path, that helper is called between `0x4bee` and the optional `0x4bf2` PC reply before the fixed `0x4bf6` / `0x4bfc` exchange.
   - On Wasp Woman's hidden `0x305` keyword branch, the same helper is called after `0x4c31`; if it returns nonzero, the branch opens response line `0x307`, where choice `1` plays `0x4c4d` / `0x4c53` and choice `2` plays `0x4c5e`.
diff --git a/TRACKER.md b/TRACKER.md
index c04f467483b..3a9765788b3 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -9,21 +9,21 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `890`
-- Named/documented: `566`
-- Still `FUN_*` / undocumented: `324`
+- Total functions: `885`
+- Named/documented: `565`
+- Still `FUN_*` / undocumented: `320`
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_herrill @ 0x2dc00` into `DialogueSystem` as `engines/harvester/npc/herrill_dialogue.*`.
-  - `HERRILL` now routes through a dedicated handler instead of the generic stub registry entry, and the engine mirrors the bounded native lodge shortcut, the shared evidence item replies, the one-shot no-item bark chain, and the looping keyword menu seeded from zero-based `dialog.rsp` line `0x281`.
-  - The previously obscured mid-function branch is now confirmed as `KARIN_KIDNAPED`, not another helper-only condition. Herrill's no-item followups are now implemented directly from raw control flow for `STEPH_MIDGAME_PLAYED`, story day `5`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `KARIN_KIDNAPED`, `KARIN_FOUND_ALIVE`, `KARIN_FOUND_DEAD`, `DINER_BURNED`, and the Whaley/Herrill-photo follow-up.
-  - Herrill's `GASCAN` path is now mirrored in-engine as its own response tree: `0x30cf` / `0x30d3` / `0x30d8`, zero-based `dialog.rsp` line `0x280`, the `HAVE_LODGE_APP` split, and the neutral shared talk-state reads at `0xd2e98`, `0xd2eb0`, and `0xd2eec`.
-  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Herrill handler port.
+- Ported `handle_talk_to_johnson @ 0x27dd0` into `DialogueSystem` as `engines/harvester/npc/johnson_dialogue.*`.
+  - `JOHNSON` now routes through a dedicated handler instead of the generic stub registry entry, and the engine mirrors the bounded native early short-circuit flags (`2ND_SCRATCH_TUCKER`, `TRY_TO_SCRATCH_TUCKER`, `OPENING_MANHOLE_DAYTIME`), the shared-evidence item replies, the intro gate at `0xd2bf4`, the one-shot no-item bark chain, and the looping keyword menu seeded from zero-based `dialog.rsp` line `0x176`.
+  - The previously truncated mid-function block is now recovered from raw bytes: `SCRATCHED_TUCKER` re-arms the intro after `0xc25`, the intro itself is `0xa86` / `0xa8a` / `0xa8e` / `0xa97` / `0xa9b`, and the event bark chain covers `STEPH_MIDGAME_PLAYED`, `KARIN_KIDNAPED`, `KARIN_FOUND_ALIVE`, `BARBER_POLE_STOLEN`, `DINER_BURNED`, `PC_ESCAPED_JAIL`, `GOT_REMAINS_FOR_LODGE`, and `BURNED_TV_STATION`.
+  - Johnson's keyword loop is now mirrored in-engine with the native branch grouping: `0x178` / `0x179` opens response line `0x17a` and rewrites to `0x17b`, `0x17c` rewrites to `0x17d`, `0x17e` rewrites to `0x17f`, `0x180` / `0x181` / `0x182`, `0x184` / `0x185`, and `0x187` fall through the generic `0xb69` / `0xbda` exit, and `0x189` loops silently.
+  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Johnson handler port.
 
 ## Next Suggested Action
 
-1. Port `handle_talk_to_johnson @ 0x27dd0` into `DialogueSystem` as `engines/harvester/npc/johnson_dialogue.*`.
-   - Johnson is now the last bounded startup-room stub in this shared-evidence cluster, and Herrill's port confirmed the remaining shared talk-state reads around the sheriff/lodge path without changing the broader handler interface.
-2. After Johnson, return to the unresolved setters for the neutral shared talk-state bits at `0xd2e98`, `0xd2eb0`, and `0xd2eec`.
-   - Herrill now reads those bits in-engine on the `GASCAN` path, but their gameplay-side writers are still outside the currently ported handler set.
+1. Trace the gameplay-side setters for the neutral shared talk-state bits at `0xd2e98`, `0xd2eb0`, and `0xd2eec`.
+   - Herrill now reads those bits in-engine on the `GASCAN` path, and Johnson was the last bounded startup-room stub in the same shared-evidence cluster, so the next useful pass is to recover who actually writes those values.
+2. After those setters are bounded, continue the wider helper scan around the `0x38230`-`0x38380` dialogue-state wrapper cluster.
+   - That range still contains the highest-signal unresolved shared-state shims tied directly to already-confirmed talk-to handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 6c137e09605..8451c26b06f 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -42,6 +42,7 @@
 #include "harvester/npc/hank_dialogue.h"
 #include "harvester/npc/herrill_dialogue.h"
 #include "harvester/npc/jimmy_dialogue.h"
+#include "harvester/npc/johnson_dialogue.h"
 #include "harvester/npc/mom_dialogue.h"
 #include "harvester/npc/pta_mom_dialogue.h"
 #include "harvester/npc/stub_dialogue.h"
@@ -334,10 +335,10 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new MomDialogueHandler());
 	_npcHandlers.push_back(new HankDialogueHandler());
 	_npcHandlers.push_back(new HerrillDialogueHandler());
+	_npcHandlers.push_back(new JohnsonDialogueHandler());
 	_npcHandlers.push_back(new PtaMomDialogueHandler());
 
 	_npcHandlers.push_back(new StubNpcDialogueHandler("PTA_MOM"));
-	_npcHandlers.push_back(new StubNpcDialogueHandler("JOHNSON"));
 }
 
 void DialogueSystem::resetRoomNpcDialogueState() {
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 8f24a7054b1..fb582f5d9a6 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -12,6 +12,7 @@ MODULE_OBJS = \
 	npc/hank_dialogue.o \
 	npc/herrill_dialogue.o \
 	npc/jimmy_dialogue.o \
+	npc/johnson_dialogue.o \
 	npc/mom_dialogue.o \
 	npc/pta_mom_dialogue.o \
 	npc/stub_dialogue.o \
diff --git a/engines/harvester/npc/johnson_dialogue.cpp b/engines/harvester/npc/johnson_dialogue.cpp
new file mode 100644
index 00000000000..9b35d0e56a4
--- /dev/null
+++ b/engines/harvester/npc/johnson_dialogue.cpp
@@ -0,0 +1,325 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/johnson_dialogue.h"
+
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+namespace {
+
+static const int kJohnsonInitialTopicBufferLine = 0x176;
+static const int kJohnsonResponseTopicBufferLine = 0x17b;
+static const int kJohnsonStephTopicBufferLine = 0x17d;
+static const int kJohnsonKarinAliveTopicBufferLine = 0x17f;
+static const int kJohnsonResponseMenuLine = 0x17a;
+static const int kJohnsonDirectExitTopicLines[] = { 0x177 };
+static const int kJohnsonIntroductionTopicLines[] = { 0x178, 0x179 };
+static const int kJohnsonGenericReplyTopicLines[] = { 0x180, 0x181, 0x182 };
+static const int kJohnsonSecondGenericReplyTopicLines[] = { 0x184, 0x185 };
+
+static const DialogueLineEntry kJohnsonIntroLines[] = {
+	{ 0xa86, "JOHNSON", 1 },
+	{ 0xa8a, "PC", 0 },
+	{ 0xa8e, "JOHNSON", 1 },
+	{ 0xa97, "PC", 0 },
+	{ 0xa9b, "JOHNSON", 0 }
+};
+
+static const DialogueLineEntry kJohnsonStephMidgameLines[] = {
+	{ 0xbf0, "JOHNSON", 3 },
+	{ 0xbf1, "JOHNSON", 3 },
+	{ 0xbf2, "JOHNSON", 3 }
+};
+
+static const DialogueLineEntry kJohnsonKarinKidnapedLines[] = {
+	{ 0xbff, "JOHNSON", 0 },
+	{ 0xc06, "JOHNSON", 0 }
+};
+
+static const DialogueLineEntry kJohnsonBarberPoleStolenLines[] = {
+	{ 0xc46, "JOHNSON", 3 },
+	{ 0xc4d, "JOHNSON", 3 },
+	{ 0xc59, "JOHNSON", 3 }
+};
+
+} // End of namespace
+
+bool JohnsonDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("JOHNSON");
+}
+
+Common::Error JohnsonDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	JohnsonRoomDialogueState &state = _state;
+	Common::String &johnsonTopicBuffer = state.currentTopicBuffer;
+	int &johnsonTopicBufferLineIndex = state.currentTopicBufferLineIndex;
+
+	auto assignJohnsonTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(johnsonTopicBuffer, johnsonTopicBufferLineIndex,
+			responseLineIndex, "Johnson topic buffer");
+	};
+	auto playJohnsonLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "JOHNSON", headVariant);
+	};
+
+	if (runtime.startupScript().getFlagValue("2ND_SCRATCH_TUCKER"))
+		return playJohnsonLine(0xbe6, 2);
+	if (runtime.startupScript().getFlagValue("TRY_TO_SCRATCH_TUCKER") &&
+			!state.tryToScratchTuckerShown) {
+		state.tryToScratchTuckerShown = true;
+		return playJohnsonLine(0xbe0, 2);
+	}
+	if (runtime.startupScript().getFlagValue("OPENING_MANHOLE_DAYTIME"))
+		return playJohnsonLine(0xbe0, 2);
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			return playJohnsonLine(0xb75, 1);
+		}
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+			return playJohnsonLine(0xb89, 1);
+		}
+		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+					usedItemName.equalsIgnoreCase("LEDGER2")) &&
+				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+			return playJohnsonLine(0xba0, 1);
+		}
+		if (usedItemName.equalsIgnoreCase("NOTE") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+			return playJohnsonLine(0xbb1, 1);
+		}
+		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
+				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceSheriffOwns, true);
+			return playJohnsonLine(0xbce, 1);
+		}
+
+		return playJohnsonLine(0xb6f);
+	}
+
+	if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER") &&
+			!state.scratchedTuckerReintroShown) {
+		state.scratchedTuckerReintroShown = true;
+		state.introPending = true;
+		Common::Error lineError = playJohnsonLine(0xc25, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (state.introPending) {
+		state.introPending = false;
+		Common::Error lineError = runtime.playDialogueEntrySequence(
+			kJohnsonIntroLines, ARRAYSIZE(kJohnsonIntroLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		sharedState.dialogueStateD2f04 = true;
+		assignJohnsonTopicBuffer(kJohnsonInitialTopicBufferLine);
+	} else {
+		Common::Error lineError = playJohnsonLine(0xb62);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
+			!state.stephMidgamePlayedShown) {
+		state.stephMidgamePlayedShown = true;
+		Common::Error lineError = runtime.playDialogueEntrySequence(
+			kJohnsonStephMidgameLines, ARRAYSIZE(kJohnsonStephMidgameLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
+			!state.karinKidnapedShown) {
+		state.karinKidnapedShown = true;
+		sharedState.dialogueStateD2f04 = true;
+		Common::Error lineError = runtime.playDialogueEntrySequence(
+			kJohnsonKarinKidnapedLines, ARRAYSIZE(kJohnsonKarinKidnapedLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
+			!state.karinFoundAliveShown) {
+		state.karinFoundAliveShown = true;
+		Common::Error lineError = playJohnsonLine(0xc13, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playJohnsonLine(0xc17);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") &&
+			!state.barberPoleStolenShown) {
+		state.barberPoleStolenShown = true;
+		Common::Error lineError = runtime.playDialogueEntrySequence(
+			kJohnsonBarberPoleStolenLines, ARRAYSIZE(kJohnsonBarberPoleStolenLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
+			!state.dinerBurnedShown) {
+		state.dinerBurnedShown = true;
+		Common::Error lineError = playJohnsonLine(0xc6f, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("PC_ESCAPED_JAIL") &&
+			!state.escapedJailShown) {
+		state.escapedJailShown = true;
+		Common::Error lineError = playJohnsonLine(0xc77, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("GOT_REMAINS_FOR_LODGE") &&
+			!state.gotRemainsForLodgeShown) {
+		state.gotRemainsForLodgeShown = true;
+		Common::Error lineError = playJohnsonLine(0xc7f);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
+			!state.burnedTvStationShown) {
+		state.burnedTvStationShown = true;
+		Common::Error lineError = playJohnsonLine(0xc8f);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (johnsonTopicBufferLineIndex < 0)
+		assignJohnsonTopicBuffer(kJohnsonInitialTopicBufferLine);
+
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runtime.runKeywordMenu(
+			johnsonTopicBuffer, johnsonTopicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.empty())
+			return Common::kNoError;
+		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
+				runtime.matchesAnyResponseLine(selectedTopic, kJohnsonDirectExitTopicLines,
+					ARRAYSIZE(kJohnsonDirectExitTopicLines))) {
+			return playJohnsonLine(0xbda, 1);
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kJohnsonIntroductionTopicLines,
+				ARRAYSIZE(kJohnsonIntroductionTopicLines))) {
+			Common::Error lineError = playJohnsonLine(0xaa6);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(
+				kJohnsonResponseMenuLine, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = playJohnsonLine(0xab4, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else if (responseIndex == 2) {
+				lineError = playJohnsonLine(0xab9);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			const DialogueLineEntry followupLines[] = {
+				{ 0xabd, "JOHNSON", 0 },
+				{ 0xac3, "JOHNSON", 0 },
+				{ 0xac7, "JOHNSON", 2 }
+			};
+			lineError = runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			assignJohnsonTopicBuffer(kJohnsonResponseTopicBufferLine);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x17c)) {
+			const DialogueLineEntry lines[] = {
+				{ 0xb17, "JOHNSON", 0 },
+				{ 0xb1c, "JOHNSON", 0 },
+				{ 0xb20, "JOHNSON", 1 }
+			};
+			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignJohnsonTopicBuffer(kJohnsonStephTopicBufferLine);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x17e)) {
+			Common::Error lineError = playJohnsonLine(0xb2d, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignJohnsonTopicBuffer(kJohnsonKarinAliveTopicBufferLine);
+			continue;
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kJohnsonGenericReplyTopicLines,
+				ARRAYSIZE(kJohnsonGenericReplyTopicLines))) {
+			Common::Error lineError = playJohnsonLine(0xb3a);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playJohnsonLine(0xb69);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			return playJohnsonLine(0xbda, 1);
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kJohnsonSecondGenericReplyTopicLines,
+				ARRAYSIZE(kJohnsonSecondGenericReplyTopicLines))) {
+			Common::Error lineError = playJohnsonLine(0xb46);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playJohnsonLine(0xb69);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			return playJohnsonLine(0xbda, 1);
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x187)) {
+			Common::Error lineError = playJohnsonLine(0xb52);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playJohnsonLine(0xb69);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			return playJohnsonLine(0xbda, 1);
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x189))
+			continue;
+
+		Common::Error lineError = playJohnsonLine(0xb69);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		return playJohnsonLine(0xbda, 1);
+	}
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/johnson_dialogue.h b/engines/harvester/npc/johnson_dialogue.h
new file mode 100644
index 00000000000..6f0a35e0e2f
--- /dev/null
+++ b/engines/harvester/npc/johnson_dialogue.h
@@ -0,0 +1,58 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_JOHNSON_DIALOGUE_H
+#define HARVESTER_NPC_JOHNSON_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class JohnsonDialogueHandler : public NpcDialogueHandler {
+public:
+	struct JohnsonRoomDialogueState {
+		bool introPending = true;
+		bool tryToScratchTuckerShown = false;
+		bool scratchedTuckerReintroShown = false;
+		bool stephMidgamePlayedShown = false;
+		bool karinKidnapedShown = false;
+		bool karinFoundAliveShown = false;
+		bool barberPoleStolenShown = false;
+		bool burnedTvStationShown = false;
+		bool dinerBurnedShown = false;
+		bool escapedJailShown = false;
+		bool gotRemainsForLodgeShown = false;
+		Common::String currentTopicBuffer;
+		int currentTopicBufferLineIndex = -1;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = JohnsonRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	JohnsonRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_JOHNSON_DIALOGUE_H


Commit: 209fc0a2ebc556895c3be45faf6e73ecf881f4f8
    https://github.com/scummvm/scummvm/commit/209fc0a2ebc556895c3be45faf6e73ecf881f4f8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:59+02:00

Commit Message:
HARVESTER: Audit neutral dialogue state wrappers

Changed paths:
    ARCHITECTURE.md
    TRACKER.md


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 8dd81caa0b5..eba8c837c03 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -463,15 +463,15 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - The steady-state no-item bark chain is fully linear and one-shot gated: `STEPH_MIDGAME_PLAYED` -> `0xbf0` / `0xbf1` / `0xbf2`, `KARIN_KIDNAPED` -> `0xbff` / `0xc06` plus another `d2f04` write, `KARIN_FOUND_ALIVE` -> `0xc13` / `0xc17`, `BARBER_POLE_STOLEN` -> `0xc46` / `0xc4d` / `0xc59`, `DINER_BURNED` -> `0xc6f`, `PC_ESCAPED_JAIL` -> `0xc77`, `GOT_REMAINS_FOR_LODGE` -> `0xc7f`, and `BURNED_TV_STATION` -> `0xc8f`.
     - Johnson's keyword loop now has a stable branch map: `0x177` exits on `0xbda`, `0x178` / `0x179` play `0xaa6`, open zero-based `dialog.rsp` line `0x17a`, then continue through `0xabd` / `0xac3` / `0xac7` and rewrite the keyword buffer to `0x17b`; `0x17c` plays `0xb17` / `0xb1c` / `0xb20` and rewrites to `0x17d`; `0x17e` plays `0xb2d` and rewrites to `0x17f`; `0x180` / `0x181` / `0x182`, `0x184` / `0x185`, and `0x187` each play one topic line, then fall through the generic `0xb69` / `0xbda` exit; `0x189` loops silently on the current buffer.
 - The shared dialogue evidence/topic state shims behind those handlers are now partially explicit:
-  - `get_set_shared_dialogue_state_d2e98` at `0x38230` is a neutral wrapper over `g_shared_dialogue_state_d2e98`; the only currently recovered gameplay reader is Herrill's `GASCAN` branch, where a zero value collapses the post-`0x30d8` continuation to `PC 0x3123`.
+  - `get_set_shared_dialogue_state_d2e98` at `0x38230` is a neutral wrapper over `g_shared_dialogue_state_d2e98`; the only currently recovered gameplay caller is Herrill's `GASCAN` branch at `0x2e056`, where `eax = 1` / `edx = 0` makes it a read-only gate and a zero value collapses the post-`0x30d8` continuation to `PC 0x3123`. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path and save/load serialization.
   - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
-  - `get_set_shared_dialogue_state_d2eb0` at `0x382a0` is a neutral wrapper over `g_shared_dialogue_state_d2eb0`; Herrill's `GASCAN` response-1 branch uses it to decide whether `0x30eb` is inserted after `0x30e7`.
+  - `get_set_shared_dialogue_state_d2eb0` at `0x382a0` is a neutral wrapper over `g_shared_dialogue_state_d2eb0`; the only currently recovered gameplay caller is a hidden Boyle dialogue branch at `0x2d6e3`, where `eax = 1` / `edx = 0` makes it a read-only gate between `BOYLE 0x10e` and `BOYLE 0x115` after the local item checks. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path.
   - `get_set_discussed_note_checkbook_evidence` at `0x38330` gates the shared `NOTE` / `NOTE_PHOTOCOPY` / `CHECKBOOK` / `CHECKBOOK_PHOTOCOPY` evidence path used by Dwayne, Edna, Herrill, Johnson, Mom, and related handlers.
   - `get_set_discussed_tv_deed_evidence` at `0x38340` gates the `TV_DEED` / `TV_DEED_PHOTOCOPY` evidence path used by Edna, Herrill, Johnson, and Mom.
   - `get_set_discussed_ledger_evidence` at `0x38350` gates the shared dual-ledger evidence path reached when `HAVE_BOTH_LEDGERS` is set.
   - `get_set_discussed_casket_photo_evidence` at `0x38360` gates the `CASKET_PHOTO` / `CASKET_PHOTOCOPY` evidence path shared across Boyle, Dwayne, Edna, Hank, Herrill, Johnson, Mom, and others.
   - `get_set_discussed_whaley_herrill_photo` at `0x38370` gates the shared `PHOTO_OF_WHALEY_HERRILL` evidence path.
-  - `get_set_shared_dialogue_state_d2eec` at `0x38380` is a neutral wrapper over `g_shared_dialogue_state_d2eec`; Herrill's `HAVE_LODGE_APP` continuation uses it to decide whether `0x3104` is inserted before `0x3109`.
+  - `get_set_shared_dialogue_state_d2eec` at `0x38380` is a neutral wrapper over `g_shared_dialogue_state_d2eec`; Herrill's `HAVE_LODGE_APP` continuation calls it at `0x2e10f` with `eax = 1` / `edx = 0`, so the currently recovered gameplay use is read-only and decides whether `0x3104` is inserted before `0x3109`. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path.
   - `get_set_discussed_karin_purse` at `0x38390` gates Dwayne's `K_PURSE` evidence state and the later `KARIN_FOUND_DEAD` follow-up that depends on whether he has seen the purse yet.
   - `get_set_dwayne_pending_karin_alive_followup` at `0x38260` is a Dwayne-local state bit set by the `BRING_KARIN_TO_SHERIFF` / `KARIN_FOUND_ALIVE` path, then read and cleared when the delayed next-day follow-up bark triggers.
   - `get_set_dwayne_completed_karin_alive_followup` at `0x38480` is the shared completion bit written only after that delayed Dwayne follow-up fires; the hidden Dwayne branch plays `0x3780` / `0x378a`, clears `get_set_dwayne_pending_karin_alive_followup`, then sets this helper, and Stephanie later reads it on her hidden `KARIN_FOUND_ALIVE` path to decide whether `0x49c1` and the `0x49ea` / `0x49ef` exit lines should run.
diff --git a/TRACKER.md b/TRACKER.md
index 3a9765788b3..632d7dd7180 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -9,21 +9,19 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `885`
-- Named/documented: `565`
-- Still `FUN_*` / undocumented: `320`
+- Total functions: `886`
+- Named/documented: `568`
+- Still `FUN_*` / undocumented: `318`
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_johnson @ 0x27dd0` into `DialogueSystem` as `engines/harvester/npc/johnson_dialogue.*`.
-  - `JOHNSON` now routes through a dedicated handler instead of the generic stub registry entry, and the engine mirrors the bounded native early short-circuit flags (`2ND_SCRATCH_TUCKER`, `TRY_TO_SCRATCH_TUCKER`, `OPENING_MANHOLE_DAYTIME`), the shared-evidence item replies, the intro gate at `0xd2bf4`, the one-shot no-item bark chain, and the looping keyword menu seeded from zero-based `dialog.rsp` line `0x176`.
-  - The previously truncated mid-function block is now recovered from raw bytes: `SCRATCHED_TUCKER` re-arms the intro after `0xc25`, the intro itself is `0xa86` / `0xa8a` / `0xa8e` / `0xa97` / `0xa9b`, and the event bark chain covers `STEPH_MIDGAME_PLAYED`, `KARIN_KIDNAPED`, `KARIN_FOUND_ALIVE`, `BARBER_POLE_STOLEN`, `DINER_BURNED`, `PC_ESCAPED_JAIL`, `GOT_REMAINS_FOR_LODGE`, and `BURNED_TV_STATION`.
-  - Johnson's keyword loop is now mirrored in-engine with the native branch grouping: `0x178` / `0x179` opens response line `0x17a` and rewrites to `0x17b`, `0x17c` rewrites to `0x17d`, `0x17e` rewrites to `0x17f`, `0x180` / `0x181` / `0x182`, `0x184` / `0x185`, and `0x187` fall through the generic `0xb69` / `0xbda` exit, and `0x189` loops silently.
-  - `rtk make -C /Users/alex/Workspace/scummvm/build-vscode-harvester-debug -j4` succeeded after the Johnson handler port.
+- Traced the neutral shared talk-state wrappers at `0x38230`, `0x382a0`, and `0x38380`, then renamed them in Ghidra as `get_set_shared_dialogue_state_d2e98`, `get_set_shared_dialogue_state_d2eb0`, and `get_set_shared_dialogue_state_d2eec`.
+  - The recovered gameplay callers are all read-only: Herrill's `GASCAN` path calls `0x38230` at `0x2e056` with `eax = 1` / `edx = 0`, Herrill's `HAVE_LODGE_APP` continuation calls `0x38380` at `0x2e10f` with the same read-only convention, and the only current caller of `0x382a0` is a hidden Boyle dialogue branch at `0x2d6e3`, which also passes `eax = 1` / `edx = 0`.
+  - No gameplay-side setter call has been recovered yet for `0xd2e98`, `0xd2eb0`, or `0xd2eec`. In the current database, those globals are otherwise only touched by the shared reset helper and, for `0xd2e98`, save/load serialization.
 
 ## Next Suggested Action
 
-1. Trace the gameplay-side setters for the neutral shared talk-state bits at `0xd2e98`, `0xd2eb0`, and `0xd2eec`.
-   - Herrill now reads those bits in-engine on the `GASCAN` path, and Johnson was the last bounded startup-room stub in the same shared-evidence cluster, so the next useful pass is to recover who actually writes those values.
-2. After those setters are bounded, continue the wider helper scan around the `0x38230`-`0x38380` dialogue-state wrapper cluster.
-   - That range still contains the highest-signal unresolved shared-state shims tied directly to already-confirmed talk-to handlers.
+1. Continue the wider helper scan around the `0x38230`-`0x38380` dialogue-state wrapper cluster, starting with the hidden Boyle block around `0x2d620`.
+   - That block now owns the only recovered gameplay read of `get_set_shared_dialogue_state_d2eb0`, and it is adjacent to the already-bounded `get_set_boyle_gascan_application_state @ 0x38240` plus the still-unresolved helper at `0x38620`.
+2. After the Boyle-side helper cluster is bounded, trace the adjacent unresolved shared-state writers at `0xd2ea4` / `0xd2ea8`.
+   - Edna's `DNA_S_SUICIDE_NOTE` path still writes those two globals directly, and they sit in the same neutral-wrapper neighborhood as the newly bounded `d2e98` / `d2eb0` / `d2eec` gates.


Commit: 7eb83fe073b7eb45d1cb1d6f2039d7be9265f539
    https://github.com/scummvm/scummvm/commit/7eb83fe073b7eb45d1cb1d6f2039d7be9265f539
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:06:59+02:00

Commit Message:
HARVESTER: Bound hidden boyle keyword block

Changed paths:
    ARCHITECTURE.md
    TRACKER.md


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index eba8c837c03..882b8544642 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -493,6 +493,12 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - Each helper is a tiny read/write shim over a dedicated global (`g_discussed_note_checkbook_evidence`, `g_discussed_tv_deed_evidence`, `g_discussed_ledger_evidence`, `g_discussed_casket_photo_evidence`, `g_discussed_whaley_herrill_photo`, `g_discussed_karin_purse`, `g_discussed_lodge_topic`).
 - Boyle's hidden `GASCAN` return branch is now explicit through `get_set_boyle_gascan_application_state` at `0x38240`.
   - When that state is nonzero, `handle_talk_to_boyle` plays the `0x1f2` / `0x1f6` / `0x1fa` / `0x206` exchange, removes `GASCAN` from inventory, awards `LODGE_APPLICATION`, and sets `HAVE_LODGE_APP`.
+  - Boyle's hidden keyword block around `0x2d620` is now bounded enough to map its major topic groups.
+    - `Amnesia` (zero-based `dialog.rsp` line `0x17`) plays the fixed `0xee` / `0xf2` / `0xf6` / `0xfa` exchange.
+    - `Button`, `Epaulet`, `Shirt`, and `Postal Button` (`0x18` through `0x1b`) all converge on the neutral `get_set_shared_dialogue_state_d2eb0` read. When that shared bit is clear, Boyle first calls `play_c019b_fst_sequence` and then plays `0x10e`; when the bit is set, the same topic group jumps straight to `0x115`.
+    - `Arson` (`0x1d`) and the literal keyword string `Arsonist` share one branch: Boyle plays `0x11c`, opens the zero-based `dialog.rsp` response menu at `0x1e`, then takes either the `0x126` / `0x12c` / `0x130` continuation for choice `1` or the `0x135` continuation for choice `2`.
+    - `Blackmail` (`0x1f`) plays `0x13d` / `0x141` / `0x145` / `0x14a` / `0x14e`, `BYE` (`0x20`) falls through the common exit path, and unmatched keywords drop to Boyle's `0x243` fallback line.
+  - `play_c019b_fst_sequence` at `0x38620` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C019B.FST", 0)` and is currently only called from Boyle's `d2eb0 == 0` evidence-topic branch.
 - The adjacent `0x383d0`-`0x384e0` range is now confirmed to be another overlapping run of tiny read/write dialogue-state wrappers over globals in the same contiguous block (`0xd2f00` through `0xd2f28`).
   - `get_set_sergeant_completed_first_task_state` at `0x384e0` is the Sergeant-local bit set on the `SCRATCHED_TUCKER` branch immediately before dialogue `0x420d` ("So, you have completed your first task.").
   - The same block also contains the recovered Pottsdam alibi bits at `0x38440` and `0x38460`, so it is no longer Sergeant-only even though several remaining wrappers in that span are still only identified by locality.
diff --git a/TRACKER.md b/TRACKER.md
index 632d7dd7180..6a967ffde27 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,18 +10,18 @@
 
 - Program: `HARVEST.LE`
 - Total functions: `886`
-- Named/documented: `568`
-- Still `FUN_*` / undocumented: `318`
+- Named/documented: `569`
+- Still `FUN_*` / undocumented: `317`
 
 ## Last Confirmed Action
 
-- Traced the neutral shared talk-state wrappers at `0x38230`, `0x382a0`, and `0x38380`, then renamed them in Ghidra as `get_set_shared_dialogue_state_d2e98`, `get_set_shared_dialogue_state_d2eb0`, and `get_set_shared_dialogue_state_d2eec`.
-  - The recovered gameplay callers are all read-only: Herrill's `GASCAN` path calls `0x38230` at `0x2e056` with `eax = 1` / `edx = 0`, Herrill's `HAVE_LODGE_APP` continuation calls `0x38380` at `0x2e10f` with the same read-only convention, and the only current caller of `0x382a0` is a hidden Boyle dialogue branch at `0x2d6e3`, which also passes `eax = 1` / `edx = 0`.
-  - No gameplay-side setter call has been recovered yet for `0xd2e98`, `0xd2eb0`, or `0xd2eec`. In the current database, those globals are otherwise only touched by the shared reset helper and, for `0xd2e98`, save/load serialization.
+- Bounded the hidden Boyle keyword block around `0x2d620` and renamed its fixed FST helper as `play_c019b_fst_sequence @ 0x38620`.
+  - The recovered branch map is now stable: `Amnesia` (`dialog.rsp 0x17`) plays `0xee` / `0xf2` / `0xf6` / `0xfa`; `Button`, `Epaulet`, `Shirt`, and `Postal Button` (`0x18` through `0x1b`) all funnel through the read-only `get_set_shared_dialogue_state_d2eb0` gate; `Arson` (`0x1d`) and the literal keyword `Arsonist` share the `0x11c` prompt plus the response menu at `dialog.rsp 0x1e`; `Blackmail` (`0x1f`) plays the `0x13d` through `0x14e` sequence; `BYE` (`0x20`) falls through the common exit path; and unknown keywords drop to `0x243`.
+  - `play_c019b_fst_sequence` is only reached from the `d2eb0 == 0` evidence-topic branch, where Boyle plays `GRAPHIC/FST/C019B.FST` immediately before dialogue `0x10e`; the `d2eb0 != 0` side skips the FST and jumps straight to `0x115`.
 
 ## Next Suggested Action
 
-1. Continue the wider helper scan around the `0x38230`-`0x38380` dialogue-state wrapper cluster, starting with the hidden Boyle block around `0x2d620`.
-   - That block now owns the only recovered gameplay read of `get_set_shared_dialogue_state_d2eb0`, and it is adjacent to the already-bounded `get_set_boyle_gascan_application_state @ 0x38240` plus the still-unresolved helper at `0x38620`.
-2. After the Boyle-side helper cluster is bounded, trace the adjacent unresolved shared-state writers at `0xd2ea4` / `0xd2ea8`.
-   - Edna's `DNA_S_SUICIDE_NOTE` path still writes those two globals directly, and they sit in the same neutral-wrapper neighborhood as the newly bounded `d2e98` / `d2eb0` / `d2eec` gates.
+1. Trace the adjacent unresolved shared-state writers at `0xd2ea4` / `0xd2ea8`.
+   - Edna's `DNA_S_SUICIDE_NOTE` path still writes those two globals directly, and they sit in the same neutral-wrapper neighborhood as the now-bounded `d2e98` / `d2eb0` / `d2eec` Boyle/Herrill gates.
+2. After the `0xd2ea4` / `0xd2ea8` writers are bounded, continue the wider wrapper scan outward from the same `0x38230`-`0x38380` cluster.
+   - The surrounding dialogue-state helpers still offer the highest-signal path for recovering hidden startup-room branches without speculative renames.


Commit: 650c100900218c91e6e15958a6e50a163da5496d
    https://github.com/scummvm/scummvm/commit/650c100900218c91e6e15958a6e50a163da5496d
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:00+02:00

Commit Message:
HARVESTER: Port curator dialogue

Changed paths:
  A engines/harvester/npc/curator_dialogue.cpp
  A engines/harvester/npc/curator_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 6a967ffde27..5137c3317be 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,9 +2,9 @@
 
 ## Current Focus
 
-- Ghidra-only recovery in `HARVEST.LE`
-- Continue scanning high-confidence `FUN_*` clusters from proven caller/callee paths
-- Prefer dialogue/text/NPC-state helpers tied to already-confirmed record layouts
+- Implement the remaining Ghidra-identified NPC dialogue handlers in the engine
+- Keep each NPC port scoped to confirmed native behavior only
+- Update `TRACKER.md` and commit after each completed NPC
 
 ## Progress
 
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Bounded the hidden Boyle keyword block around `0x2d620` and renamed its fixed FST helper as `play_c019b_fst_sequence @ 0x38620`.
-  - The recovered branch map is now stable: `Amnesia` (`dialog.rsp 0x17`) plays `0xee` / `0xf2` / `0xf6` / `0xfa`; `Button`, `Epaulet`, `Shirt`, and `Postal Button` (`0x18` through `0x1b`) all funnel through the read-only `get_set_shared_dialogue_state_d2eb0` gate; `Arson` (`0x1d`) and the literal keyword `Arsonist` share the `0x11c` prompt plus the response menu at `dialog.rsp 0x1e`; `Blackmail` (`0x1f`) plays the `0x13d` through `0x14e` sequence; `BYE` (`0x20`) falls through the common exit path; and unknown keywords drop to `0x243`.
-  - `play_c019b_fst_sequence` is only reached from the `d2eb0 == 0` evidence-topic branch, where Boyle plays `GRAPHIC/FST/C019B.FST` immediately before dialogue `0x10e`; the `d2eb0 != 0` side skips the FST and jumps straight to `0x115`.
+- Ported `handle_talk_to_curator @ 0x34ca0` into a dedicated `engines/harvester/npc/curator_dialogue.*` handler.
+  - Native Curator dialogue is a fixed single-line bark: `play_dialogue_line(0x425, "CURATOR")`, with no item gating, no room-local state, and no shared dialogue-state writes.
+  - The engine now routes `CURATOR` through a concrete handler instead of falling back to the unsupported NPC path.
 
 ## Next Suggested Action
 
-1. Trace the adjacent unresolved shared-state writers at `0xd2ea4` / `0xd2ea8`.
-   - Edna's `DNA_S_SUICIDE_NOTE` path still writes those two globals directly, and they sit in the same neutral-wrapper neighborhood as the now-bounded `d2e98` / `d2eb0` / `d2eec` Boyle/Herrill gates.
-2. After the `0xd2ea4` / `0xd2ea8` writers are bounded, continue the wider wrapper scan outward from the same `0x38230`-`0x38380` cluster.
-   - The surrounding dialogue-state helpers still offer the highest-signal path for recovering hidden startup-room branches without speculative renames.
+1. Port `handle_talk_to_lodge_chef @ 0x38170` as the next one-line NPC handler.
+   - Ghidra decompilation currently shows a fixed `play_dialogue_line(0x1b10, "LODGE_CHEF")` body with no visible state, making it the next lowest-risk completion.
+2. After the remaining one-line handlers are covered, resume the larger hidden-topic ports starting with Boyle.
+   - Boyle already has bounded hidden keyword and gas-can branches, so he remains the highest-signal multi-branch NPC once the trivial handlers are out of the way.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 8451c26b06f..aeb9ab8993b 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -36,6 +36,7 @@
 #include "harvester/detection.h"
 #include "harvester/fst_player.h"
 #include "harvester/harvester.h"
+#include "harvester/npc/curator_dialogue.h"
 #include "harvester/npc/dwayne_dialogue.h"
 #include "harvester/npc/dialogue_runtime.h"
 #include "harvester/npc/edna_dialogue.h"
@@ -330,6 +331,7 @@ DialogueSystem::~DialogueSystem() {
 void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new DwayneDialogueHandler());
 	_npcHandlers.push_back(new EdnaDialogueHandler());
+	_npcHandlers.push_back(new CuratorDialogueHandler());
 	_npcHandlers.push_back(new JimmyDialogueHandler());
 	_npcHandlers.push_back(new WaspWomanDialogueHandler());
 	_npcHandlers.push_back(new MomDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index fb582f5d9a6..ddd0850530a 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -7,6 +7,7 @@ MODULE_OBJS = \
 	dialogue.o \
 	fst_player.o \
 	metaengine.o \
+	npc/curator_dialogue.o \
 	npc/dwayne_dialogue.o \
 	npc/edna_dialogue.o \
 	npc/hank_dialogue.o \
diff --git a/engines/harvester/npc/curator_dialogue.cpp b/engines/harvester/npc/curator_dialogue.cpp
new file mode 100644
index 00000000000..efa7dbfaf48
--- /dev/null
+++ b/engines/harvester/npc/curator_dialogue.cpp
@@ -0,0 +1,39 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/curator_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool CuratorDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("CURATOR");
+}
+
+Common::Error CuratorDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &, DialogueSharedState &) {
+	return runtime.playDialogueLine(0x425, "CURATOR");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/curator_dialogue.h b/engines/harvester/npc/curator_dialogue.h
new file mode 100644
index 00000000000..89ad1b0f654
--- /dev/null
+++ b/engines/harvester/npc/curator_dialogue.h
@@ -0,0 +1,39 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_CURATOR_DIALOGUE_H
+#define HARVESTER_NPC_CURATOR_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class CuratorDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override {}
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_CURATOR_DIALOGUE_H


Commit: 191f7b541bc18d403d1530148a0ef6c7c656490b
    https://github.com/scummvm/scummvm/commit/191f7b541bc18d403d1530148a0ef6c7c656490b
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:00+02:00

Commit Message:
HARVESTER: Port lodge chef dialogue

Changed paths:
  A engines/harvester/npc/lodge_chef_dialogue.cpp
  A engines/harvester/npc/lodge_chef_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 5137c3317be..2fc83b4fcce 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_curator @ 0x34ca0` into a dedicated `engines/harvester/npc/curator_dialogue.*` handler.
-  - Native Curator dialogue is a fixed single-line bark: `play_dialogue_line(0x425, "CURATOR")`, with no item gating, no room-local state, and no shared dialogue-state writes.
-  - The engine now routes `CURATOR` through a concrete handler instead of falling back to the unsupported NPC path.
+- Ported `handle_talk_to_lodge_chef @ 0x38170` into a dedicated `engines/harvester/npc/lodge_chef_dialogue.*` handler.
+  - Native Lodge Chef dialogue is a fixed single-line bark: `play_dialogue_line(0x1b10, "LODGE_CHEF")`, with no item gating, no room-local state, and no shared dialogue-state writes.
+  - The engine now routes `LODGE_CHEF` through a concrete handler instead of falling back to the unsupported NPC path.
 
 ## Next Suggested Action
 
-1. Port `handle_talk_to_lodge_chef @ 0x38170` as the next one-line NPC handler.
-   - Ghidra decompilation currently shows a fixed `play_dialogue_line(0x1b10, "LODGE_CHEF")` body with no visible state, making it the next lowest-risk completion.
+1. Port `handle_talk_to_chessmaster @ 0x2d900` as the next one-line NPC handler.
+   - Ghidra decompilation currently shows a fixed `play_dialogue_line(0x4115, "CHESSMASTER")` body with no visible state, making it the next lowest-risk completion.
 2. After the remaining one-line handlers are covered, resume the larger hidden-topic ports starting with Boyle.
    - Boyle already has bounded hidden keyword and gas-can branches, so he remains the highest-signal multi-branch NPC once the trivial handlers are out of the way.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index aeb9ab8993b..b37820c94de 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -44,6 +44,7 @@
 #include "harvester/npc/herrill_dialogue.h"
 #include "harvester/npc/jimmy_dialogue.h"
 #include "harvester/npc/johnson_dialogue.h"
+#include "harvester/npc/lodge_chef_dialogue.h"
 #include "harvester/npc/mom_dialogue.h"
 #include "harvester/npc/pta_mom_dialogue.h"
 #include "harvester/npc/stub_dialogue.h"
@@ -335,6 +336,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new JimmyDialogueHandler());
 	_npcHandlers.push_back(new WaspWomanDialogueHandler());
 	_npcHandlers.push_back(new MomDialogueHandler());
+	_npcHandlers.push_back(new LodgeChefDialogueHandler());
 	_npcHandlers.push_back(new HankDialogueHandler());
 	_npcHandlers.push_back(new HerrillDialogueHandler());
 	_npcHandlers.push_back(new JohnsonDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index ddd0850530a..66e26b050d0 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -14,6 +14,7 @@ MODULE_OBJS = \
 	npc/herrill_dialogue.o \
 	npc/jimmy_dialogue.o \
 	npc/johnson_dialogue.o \
+	npc/lodge_chef_dialogue.o \
 	npc/mom_dialogue.o \
 	npc/pta_mom_dialogue.o \
 	npc/stub_dialogue.o \
diff --git a/engines/harvester/npc/lodge_chef_dialogue.cpp b/engines/harvester/npc/lodge_chef_dialogue.cpp
new file mode 100644
index 00000000000..a92495a9bcf
--- /dev/null
+++ b/engines/harvester/npc/lodge_chef_dialogue.cpp
@@ -0,0 +1,39 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/lodge_chef_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool LodgeChefDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("LODGE_CHEF");
+}
+
+Common::Error LodgeChefDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &, DialogueSharedState &) {
+	return runtime.playDialogueLine(0x1b10, "LODGE_CHEF");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/lodge_chef_dialogue.h b/engines/harvester/npc/lodge_chef_dialogue.h
new file mode 100644
index 00000000000..b0d3a563571
--- /dev/null
+++ b/engines/harvester/npc/lodge_chef_dialogue.h
@@ -0,0 +1,39 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_LODGE_CHEF_DIALOGUE_H
+#define HARVESTER_NPC_LODGE_CHEF_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class LodgeChefDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override {}
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_LODGE_CHEF_DIALOGUE_H


Commit: aac5e18ef3759d45fd1e2b9c8178f58d47438071
    https://github.com/scummvm/scummvm/commit/aac5e18ef3759d45fd1e2b9c8178f58d47438071
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:00+02:00

Commit Message:
HARVESTER: Port chessmaster dialogue

Changed paths:
  A engines/harvester/npc/chessmaster_dialogue.cpp
  A engines/harvester/npc/chessmaster_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 2fc83b4fcce..b82a1123972 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_lodge_chef @ 0x38170` into a dedicated `engines/harvester/npc/lodge_chef_dialogue.*` handler.
-  - Native Lodge Chef dialogue is a fixed single-line bark: `play_dialogue_line(0x1b10, "LODGE_CHEF")`, with no item gating, no room-local state, and no shared dialogue-state writes.
-  - The engine now routes `LODGE_CHEF` through a concrete handler instead of falling back to the unsupported NPC path.
+- Ported `handle_talk_to_chessmaster @ 0x2d900` into a dedicated `engines/harvester/npc/chessmaster_dialogue.*` handler.
+  - Native Chessmaster dialogue is a fixed single-line bark: `play_dialogue_line(0x4115, "CHESSMASTER")`, with no item gating, no room-local state, and no shared dialogue-state writes.
+  - The engine now routes `CHESSMASTER` through a concrete handler instead of falling back to the unsupported NPC path.
 
 ## Next Suggested Action
 
-1. Port `handle_talk_to_chessmaster @ 0x2d900` as the next one-line NPC handler.
-   - Ghidra decompilation currently shows a fixed `play_dialogue_line(0x4115, "CHESSMASTER")` body with no visible state, making it the next lowest-risk completion.
-2. After the remaining one-line handlers are covered, resume the larger hidden-topic ports starting with Boyle.
+1. Port `handle_talk_to_mother @ 0x2b5b0` as the next small state-gated NPC handler.
+   - Ghidra decompilation currently shows a single local talk-state bit gate: when `g_mother_talk_state_block` is set, native clears it and plays `0x26d6` for `MOTHER`.
+2. After the remaining single-branch handlers are covered, resume the larger hidden-topic ports starting with Boyle.
    - Boyle already has bounded hidden keyword and gas-can branches, so he remains the highest-signal multi-branch NPC once the trivial handlers are out of the way.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index b37820c94de..4bba41b6d70 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -36,6 +36,7 @@
 #include "harvester/detection.h"
 #include "harvester/fst_player.h"
 #include "harvester/harvester.h"
+#include "harvester/npc/chessmaster_dialogue.h"
 #include "harvester/npc/curator_dialogue.h"
 #include "harvester/npc/dwayne_dialogue.h"
 #include "harvester/npc/dialogue_runtime.h"
@@ -332,6 +333,7 @@ DialogueSystem::~DialogueSystem() {
 void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new DwayneDialogueHandler());
 	_npcHandlers.push_back(new EdnaDialogueHandler());
+	_npcHandlers.push_back(new ChessmasterDialogueHandler());
 	_npcHandlers.push_back(new CuratorDialogueHandler());
 	_npcHandlers.push_back(new JimmyDialogueHandler());
 	_npcHandlers.push_back(new WaspWomanDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 66e26b050d0..ff8844c161e 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -3,6 +3,7 @@ MODULE := engines/harvester
 MODULE_OBJS = \
 	cft_font.o \
 	harvester.o \
+	npc/chessmaster_dialogue.o \
 	console.o \
 	dialogue.o \
 	fst_player.o \
diff --git a/engines/harvester/npc/chessmaster_dialogue.cpp b/engines/harvester/npc/chessmaster_dialogue.cpp
new file mode 100644
index 00000000000..9c87a7b9345
--- /dev/null
+++ b/engines/harvester/npc/chessmaster_dialogue.cpp
@@ -0,0 +1,39 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/chessmaster_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool ChessmasterDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("CHESSMASTER");
+}
+
+Common::Error ChessmasterDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &, DialogueSharedState &) {
+	return runtime.playDialogueLine(0x4115, "CHESSMASTER");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/chessmaster_dialogue.h b/engines/harvester/npc/chessmaster_dialogue.h
new file mode 100644
index 00000000000..867e94d7453
--- /dev/null
+++ b/engines/harvester/npc/chessmaster_dialogue.h
@@ -0,0 +1,39 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_CHESSMASTER_DIALOGUE_H
+#define HARVESTER_NPC_CHESSMASTER_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class ChessmasterDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override {}
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_CHESSMASTER_DIALOGUE_H


Commit: ed9a6f9076bfcb2e6cbf7b93a21fe3ea7c10ca00
    https://github.com/scummvm/scummvm/commit/ed9a6f9076bfcb2e6cbf7b93a21fe3ea7c10ca00
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:01+02:00

Commit Message:
HARVESTER: Port mother dialogue

Changed paths:
  A engines/harvester/npc/mother_dialogue.cpp
  A engines/harvester/npc/mother_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index b82a1123972..e6f5d2d216e 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_chessmaster @ 0x2d900` into a dedicated `engines/harvester/npc/chessmaster_dialogue.*` handler.
-  - Native Chessmaster dialogue is a fixed single-line bark: `play_dialogue_line(0x4115, "CHESSMASTER")`, with no item gating, no room-local state, and no shared dialogue-state writes.
-  - The engine now routes `CHESSMASTER` through a concrete handler instead of falling back to the unsupported NPC path.
+- Ported `handle_talk_to_mother @ 0x2b5b0` into a dedicated `engines/harvester/npc/mother_dialogue.*` handler.
+  - Native Mother dialogue is a one-shot local talk-state branch: when `g_mother_talk_state_block` is set, native clears it and plays `0x26d6` for `MOTHER`; otherwise the handler returns without dialogue.
+  - The engine now routes `MOTHER` through a concrete handler with matching per-NPC state instead of falling back to the unsupported NPC path.
 
 ## Next Suggested Action
 
-1. Port `handle_talk_to_mother @ 0x2b5b0` as the next small state-gated NPC handler.
-   - Ghidra decompilation currently shows a single local talk-state bit gate: when `g_mother_talk_state_block` is set, native clears it and plays `0x26d6` for `MOTHER`.
+1. Port `handle_talk_to_inquisitor @ 0x2af70` as the next small state-gated NPC handler.
+   - Ghidra decompilation currently shows a single local talk-state gate plus the `PAIN_SOLVED` flag: when the puzzle is not solved and `g_inquisitor_talk_state_block` is set, native clears it and plays `0x18e7` for `INQUISITOR`.
 2. After the remaining single-branch handlers are covered, resume the larger hidden-topic ports starting with Boyle.
    - Boyle already has bounded hidden keyword and gas-can branches, so he remains the highest-signal multi-branch NPC once the trivial handlers are out of the way.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 4bba41b6d70..d08663a5acf 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -47,6 +47,7 @@
 #include "harvester/npc/johnson_dialogue.h"
 #include "harvester/npc/lodge_chef_dialogue.h"
 #include "harvester/npc/mom_dialogue.h"
+#include "harvester/npc/mother_dialogue.h"
 #include "harvester/npc/pta_mom_dialogue.h"
 #include "harvester/npc/stub_dialogue.h"
 #include "harvester/npc/wasp_woman_dialogue.h"
@@ -338,6 +339,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new JimmyDialogueHandler());
 	_npcHandlers.push_back(new WaspWomanDialogueHandler());
 	_npcHandlers.push_back(new MomDialogueHandler());
+	_npcHandlers.push_back(new MotherDialogueHandler());
 	_npcHandlers.push_back(new LodgeChefDialogueHandler());
 	_npcHandlers.push_back(new HankDialogueHandler());
 	_npcHandlers.push_back(new HerrillDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index ff8844c161e..b944a124232 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -17,6 +17,7 @@ MODULE_OBJS = \
 	npc/johnson_dialogue.o \
 	npc/lodge_chef_dialogue.o \
 	npc/mom_dialogue.o \
+	npc/mother_dialogue.o \
 	npc/pta_mom_dialogue.o \
 	npc/stub_dialogue.o \
 	npc/wasp_woman_dialogue.o \
diff --git a/engines/harvester/npc/mother_dialogue.cpp b/engines/harvester/npc/mother_dialogue.cpp
new file mode 100644
index 00000000000..5f00760dcac
--- /dev/null
+++ b/engines/harvester/npc/mother_dialogue.cpp
@@ -0,0 +1,43 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/mother_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool MotherDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("MOTHER");
+}
+
+Common::Error MotherDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &, DialogueSharedState &) {
+	if (!_state.talkStatePending)
+		return Common::kNoError;
+
+	_state.talkStatePending = false;
+	return runtime.playDialogueLine(0x26d6, "MOTHER");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/mother_dialogue.h b/engines/harvester/npc/mother_dialogue.h
new file mode 100644
index 00000000000..444dddc5f28
--- /dev/null
+++ b/engines/harvester/npc/mother_dialogue.h
@@ -0,0 +1,46 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_MOTHER_DIALOGUE_H
+#define HARVESTER_NPC_MOTHER_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class MotherDialogueHandler : public NpcDialogueHandler {
+public:
+	struct MotherRoomDialogueState {
+		bool talkStatePending = true;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = MotherRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	MotherRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_MOTHER_DIALOGUE_H


Commit: b11a428b10db1db11678e67c07b44a861d68b930
    https://github.com/scummvm/scummvm/commit/b11a428b10db1db11678e67c07b44a861d68b930
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:01+02:00

Commit Message:
HARVESTER: Port inquisitor dialogue

Changed paths:
  A engines/harvester/npc/inquisitor_dialogue.cpp
  A engines/harvester/npc/inquisitor_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index e6f5d2d216e..244bf40b92c 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_mother @ 0x2b5b0` into a dedicated `engines/harvester/npc/mother_dialogue.*` handler.
-  - Native Mother dialogue is a one-shot local talk-state branch: when `g_mother_talk_state_block` is set, native clears it and plays `0x26d6` for `MOTHER`; otherwise the handler returns without dialogue.
-  - The engine now routes `MOTHER` through a concrete handler with matching per-NPC state instead of falling back to the unsupported NPC path.
+- Ported `handle_talk_to_inquisitor @ 0x2af70` into a dedicated `engines/harvester/npc/inquisitor_dialogue.*` handler.
+  - Native Inquisitor dialogue is a one-shot local talk-state branch gated by the global `PAIN_SOLVED` flag: when the puzzle is still unsolved and `g_inquisitor_talk_state_block` is set, native clears it and plays `0x18e7` for `INQUISITOR`.
+  - The engine now routes `INQUISITOR` through a concrete handler with matching per-NPC state instead of falling back to the unsupported NPC path.
 
 ## Next Suggested Action
 
-1. Port `handle_talk_to_inquisitor @ 0x2af70` as the next small state-gated NPC handler.
-   - Ghidra decompilation currently shows a single local talk-state gate plus the `PAIN_SOLVED` flag: when the puzzle is not solved and `g_inquisitor_talk_state_block` is set, native clears it and plays `0x18e7` for `INQUISITOR`.
+1. Port `handle_talk_to_authority @ 0x2b730` as the next small flag-driven NPC handler.
+   - Ghidra decompilation currently shows three flag-controlled outcomes: `STOP_AUTHOR_TALK` suppresses dialogue entirely, `IF_DON_T_EAT_THE_FOOD` forces `0x3992`, and otherwise the handler picks `0x398a` or `0x399b` based on `IF_YOU_EAT_THE_FOOD_AND_DEFEAT_THE_ENEMIES`.
 2. After the remaining single-branch handlers are covered, resume the larger hidden-topic ports starting with Boyle.
    - Boyle already has bounded hidden keyword and gas-can branches, so he remains the highest-signal multi-branch NPC once the trivial handlers are out of the way.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index d08663a5acf..59f0a63ccec 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -43,6 +43,7 @@
 #include "harvester/npc/edna_dialogue.h"
 #include "harvester/npc/hank_dialogue.h"
 #include "harvester/npc/herrill_dialogue.h"
+#include "harvester/npc/inquisitor_dialogue.h"
 #include "harvester/npc/jimmy_dialogue.h"
 #include "harvester/npc/johnson_dialogue.h"
 #include "harvester/npc/lodge_chef_dialogue.h"
@@ -337,6 +338,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new ChessmasterDialogueHandler());
 	_npcHandlers.push_back(new CuratorDialogueHandler());
 	_npcHandlers.push_back(new JimmyDialogueHandler());
+	_npcHandlers.push_back(new InquisitorDialogueHandler());
 	_npcHandlers.push_back(new WaspWomanDialogueHandler());
 	_npcHandlers.push_back(new MomDialogueHandler());
 	_npcHandlers.push_back(new MotherDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index b944a124232..9518f77c4a5 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -13,6 +13,7 @@ MODULE_OBJS = \
 	npc/edna_dialogue.o \
 	npc/hank_dialogue.o \
 	npc/herrill_dialogue.o \
+	npc/inquisitor_dialogue.o \
 	npc/jimmy_dialogue.o \
 	npc/johnson_dialogue.o \
 	npc/lodge_chef_dialogue.o \
diff --git a/engines/harvester/npc/inquisitor_dialogue.cpp b/engines/harvester/npc/inquisitor_dialogue.cpp
new file mode 100644
index 00000000000..11d01fd172d
--- /dev/null
+++ b/engines/harvester/npc/inquisitor_dialogue.cpp
@@ -0,0 +1,43 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/inquisitor_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool InquisitorDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("INQUISITOR");
+}
+
+Common::Error InquisitorDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &, DialogueSharedState &) {
+	if (runtime.startupScript().getFlagValue("PAIN_SOLVED") || !_state.talkStatePending)
+		return Common::kNoError;
+
+	_state.talkStatePending = false;
+	return runtime.playDialogueLine(0x18e7, "INQUISITOR");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/inquisitor_dialogue.h b/engines/harvester/npc/inquisitor_dialogue.h
new file mode 100644
index 00000000000..1dcee7e9e23
--- /dev/null
+++ b/engines/harvester/npc/inquisitor_dialogue.h
@@ -0,0 +1,46 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_INQUISITOR_DIALOGUE_H
+#define HARVESTER_NPC_INQUISITOR_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class InquisitorDialogueHandler : public NpcDialogueHandler {
+public:
+	struct InquisitorRoomDialogueState {
+		bool talkStatePending = true;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = InquisitorRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	InquisitorRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_INQUISITOR_DIALOGUE_H


Commit: 25e39985ea6527ced69a06ec7995f28a828945e8
    https://github.com/scummvm/scummvm/commit/25e39985ea6527ced69a06ec7995f28a828945e8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:01+02:00

Commit Message:
HARVESTER: Port authority dialogue

Changed paths:
  A engines/harvester/npc/authority_dialogue.cpp
  A engines/harvester/npc/authority_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 244bf40b92c..66241fd8249 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_inquisitor @ 0x2af70` into a dedicated `engines/harvester/npc/inquisitor_dialogue.*` handler.
-  - Native Inquisitor dialogue is a one-shot local talk-state branch gated by the global `PAIN_SOLVED` flag: when the puzzle is still unsolved and `g_inquisitor_talk_state_block` is set, native clears it and plays `0x18e7` for `INQUISITOR`.
-  - The engine now routes `INQUISITOR` through a concrete handler with matching per-NPC state instead of falling back to the unsupported NPC path.
+- Ported `handle_talk_to_authority @ 0x2b730` into a dedicated `engines/harvester/npc/authority_dialogue.*` handler.
+  - Native Authority dialogue is a small flag-driven selector with no local room state: `STOP_AUTHOR_TALK` suppresses dialogue entirely, `IF_DON_T_EAT_THE_FOOD` forces `0x3992`, and otherwise the handler picks `0x398a` or `0x399b` based on `IF_YOU_EAT_THE_FOOD_AND_DEFEAT_THE_ENEMIES`.
+  - The engine now routes `AUTHORITY` through a concrete handler instead of falling back to the unsupported NPC path.
 
 ## Next Suggested Action
 
-1. Port `handle_talk_to_authority @ 0x2b730` as the next small flag-driven NPC handler.
-   - Ghidra decompilation currently shows three flag-controlled outcomes: `STOP_AUTHOR_TALK` suppresses dialogue entirely, `IF_DON_T_EAT_THE_FOOD` forces `0x3992`, and otherwise the handler picks `0x398a` or `0x399b` based on `IF_YOU_EAT_THE_FOOD_AND_DEFEAT_THE_ENEMIES`.
+1. Port `handle_talk_to_maint_man @ 0x2b6a0` as the next small flag-driven NPC handler.
+   - Ghidra decompilation currently shows a compact four-way branch on the maintenance-man conversation flags, with `0xdaf`, `0xdb6`, `0xdbc`, and `0xdc4` as the only native outcomes.
 2. After the remaining single-branch handlers are covered, resume the larger hidden-topic ports starting with Boyle.
    - Boyle already has bounded hidden keyword and gas-can branches, so he remains the highest-signal multi-branch NPC once the trivial handlers are out of the way.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 59f0a63ccec..1f9c6ffd6b3 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -32,6 +32,7 @@
 #include "graphics/font.h"
 #include "graphics/fontman.h"
 #include "graphics/framelimiter.h"
+#include "harvester/npc/authority_dialogue.h"
 #include "harvester/cft_font.h"
 #include "harvester/detection.h"
 #include "harvester/fst_player.h"
@@ -333,6 +334,7 @@ DialogueSystem::~DialogueSystem() {
 }
 
 void DialogueSystem::registerNpcHandlers() {
+	_npcHandlers.push_back(new AuthorityDialogueHandler());
 	_npcHandlers.push_back(new DwayneDialogueHandler());
 	_npcHandlers.push_back(new EdnaDialogueHandler());
 	_npcHandlers.push_back(new ChessmasterDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 9518f77c4a5..7fd72282de2 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -1,6 +1,7 @@
 MODULE := engines/harvester
 
 MODULE_OBJS = \
+	npc/authority_dialogue.o \
 	cft_font.o \
 	harvester.o \
 	npc/chessmaster_dialogue.o \
diff --git a/engines/harvester/npc/authority_dialogue.cpp b/engines/harvester/npc/authority_dialogue.cpp
new file mode 100644
index 00000000000..2de5a6c005f
--- /dev/null
+++ b/engines/harvester/npc/authority_dialogue.cpp
@@ -0,0 +1,49 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/authority_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool AuthorityDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("AUTHORITY");
+}
+
+Common::Error AuthorityDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &, DialogueSharedState &) {
+	if (runtime.startupScript().getFlagValue("STOP_AUTHOR_TALK"))
+		return Common::kNoError;
+
+	if (runtime.startupScript().getFlagValue("IF_DON_T_EAT_THE_FOOD"))
+		return runtime.playDialogueLine(0x3992, "AUTHORITY");
+
+	return runtime.playDialogueLine(
+		runtime.startupScript().getFlagValue("IF_YOU_EAT_THE_FOOD_AND_DEFEAT_THE_ENEMIES")
+			? 0x399b
+			: 0x398a,
+		"AUTHORITY");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/authority_dialogue.h b/engines/harvester/npc/authority_dialogue.h
new file mode 100644
index 00000000000..771441ef3f1
--- /dev/null
+++ b/engines/harvester/npc/authority_dialogue.h
@@ -0,0 +1,39 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_AUTHORITY_DIALOGUE_H
+#define HARVESTER_NPC_AUTHORITY_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class AuthorityDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override {}
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_AUTHORITY_DIALOGUE_H


Commit: acbb5ff3af1929c99b4ba75a72ed898883818e69
    https://github.com/scummvm/scummvm/commit/acbb5ff3af1929c99b4ba75a72ed898883818e69
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:02+02:00

Commit Message:
HARVESTER: Port maintenance man dialogue

Changed paths:
  A engines/harvester/npc/maint_man_dialogue.cpp
  A engines/harvester/npc/maint_man_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 66241fd8249..795a6642e56 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_authority @ 0x2b730` into a dedicated `engines/harvester/npc/authority_dialogue.*` handler.
-  - Native Authority dialogue is a small flag-driven selector with no local room state: `STOP_AUTHOR_TALK` suppresses dialogue entirely, `IF_DON_T_EAT_THE_FOOD` forces `0x3992`, and otherwise the handler picks `0x398a` or `0x399b` based on `IF_YOU_EAT_THE_FOOD_AND_DEFEAT_THE_ENEMIES`.
-  - The engine now routes `AUTHORITY` through a concrete handler instead of falling back to the unsupported NPC path.
+- Ported `handle_talk_to_maint_man @ 0x2b6a0` into a dedicated `engines/harvester/npc/maint_man_dialogue.*` handler.
+  - Native Maintenance Man dialogue is a compact flag-driven selector with no local room state: `MAINTENANCE_MAN_FIRST_CONVERSATION` forces `0xdaf`, `MAINTENANCE_MAN_THIRD_CONVERSATION` forces `0xdbc`, `MAINTENANCE_MAN_FOURTH_CONVERSATION` forces `0xdc4`, and the default path falls through to `0xdb6`.
+  - The engine now routes `MAINT_MAN` through a concrete handler instead of falling back to the unsupported NPC path.
 
 ## Next Suggested Action
 
-1. Port `handle_talk_to_maint_man @ 0x2b6a0` as the next small flag-driven NPC handler.
-   - Ghidra decompilation currently shows a compact four-way branch on the maintenance-man conversation flags, with `0xdaf`, `0xdb6`, `0xdbc`, and `0xdc4` as the only native outcomes.
-2. After the remaining single-branch handlers are covered, resume the larger hidden-topic ports starting with Boyle.
-   - Boyle already has bounded hidden keyword and gas-can branches, so he remains the highest-signal multi-branch NPC once the trivial handlers are out of the way.
+1. Port `handle_talk_to_beggar @ 0x34d40` as the next small interrupt-driven NPC handler.
+   - Ghidra decompilation currently shows one flag gate plus one action tag: `BEGGAR_INTERRUPT_CONVERSATION` triggers `dispatch_action_tag_if_set("BEGGAR_DIALOG_3", "BEGGAR")` and then plays `0x1276`; otherwise the native handler returns without dialogue.
+2. Add the minimal runtime callback needed for the interrupt/action-tag handlers, then continue through Beggar, Madam, and Gladiator.
+   - Those three handlers are the next confirmed low-complexity group after the visible flag-driven ports and they share the same native shape.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 1f9c6ffd6b3..52b1b9b937c 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -48,6 +48,7 @@
 #include "harvester/npc/jimmy_dialogue.h"
 #include "harvester/npc/johnson_dialogue.h"
 #include "harvester/npc/lodge_chef_dialogue.h"
+#include "harvester/npc/maint_man_dialogue.h"
 #include "harvester/npc/mom_dialogue.h"
 #include "harvester/npc/mother_dialogue.h"
 #include "harvester/npc/pta_mom_dialogue.h"
@@ -342,6 +343,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new JimmyDialogueHandler());
 	_npcHandlers.push_back(new InquisitorDialogueHandler());
 	_npcHandlers.push_back(new WaspWomanDialogueHandler());
+	_npcHandlers.push_back(new MaintManDialogueHandler());
 	_npcHandlers.push_back(new MomDialogueHandler());
 	_npcHandlers.push_back(new MotherDialogueHandler());
 	_npcHandlers.push_back(new LodgeChefDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 7fd72282de2..231c2f7e8f4 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -18,6 +18,7 @@ MODULE_OBJS = \
 	npc/jimmy_dialogue.o \
 	npc/johnson_dialogue.o \
 	npc/lodge_chef_dialogue.o \
+	npc/maint_man_dialogue.o \
 	npc/mom_dialogue.o \
 	npc/mother_dialogue.o \
 	npc/pta_mom_dialogue.o \
diff --git a/engines/harvester/npc/maint_man_dialogue.cpp b/engines/harvester/npc/maint_man_dialogue.cpp
new file mode 100644
index 00000000000..170b20e1138
--- /dev/null
+++ b/engines/harvester/npc/maint_man_dialogue.cpp
@@ -0,0 +1,48 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/maint_man_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool MaintManDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("MAINT_MAN");
+}
+
+Common::Error MaintManDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &, DialogueSharedState &) {
+	if (runtime.startupScript().getFlagValue("MAINTENANCE_MAN_FIRST_CONVERSATION"))
+		return runtime.playDialogueLine(0xdaf, "MAINT_MAN");
+
+	if (runtime.startupScript().getFlagValue("MAINTENANCE_MAN_THIRD_CONVERSATION"))
+		return runtime.playDialogueLine(0xdbc, "MAINT_MAN");
+
+	if (runtime.startupScript().getFlagValue("MAINTENANCE_MAN_FOURTH_CONVERSATION"))
+		return runtime.playDialogueLine(0xdc4, "MAINT_MAN");
+
+	return runtime.playDialogueLine(0xdb6, "MAINT_MAN");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/maint_man_dialogue.h b/engines/harvester/npc/maint_man_dialogue.h
new file mode 100644
index 00000000000..0b08e377c7d
--- /dev/null
+++ b/engines/harvester/npc/maint_man_dialogue.h
@@ -0,0 +1,39 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_MAINT_MAN_DIALOGUE_H
+#define HARVESTER_NPC_MAINT_MAN_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class MaintManDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override {}
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_MAINT_MAN_DIALOGUE_H


Commit: 34c49227308c524286bc9cc115c88a58bf9c21ca
    https://github.com/scummvm/scummvm/commit/34c49227308c524286bc9cc115c88a58bf9c21ca
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:02+02:00

Commit Message:
HARVESTER: Port beggar dialogue

Changed paths:
  A engines/harvester/npc/beggar_dialogue.cpp
  A engines/harvester/npc/beggar_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 795a6642e56..cc7dc2db029 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_maint_man @ 0x2b6a0` into a dedicated `engines/harvester/npc/maint_man_dialogue.*` handler.
-  - Native Maintenance Man dialogue is a compact flag-driven selector with no local room state: `MAINTENANCE_MAN_FIRST_CONVERSATION` forces `0xdaf`, `MAINTENANCE_MAN_THIRD_CONVERSATION` forces `0xdbc`, `MAINTENANCE_MAN_FOURTH_CONVERSATION` forces `0xdc4`, and the default path falls through to `0xdb6`.
-  - The engine now routes `MAINT_MAN` through a concrete handler instead of falling back to the unsupported NPC path.
+- Ported `handle_talk_to_beggar @ 0x34d40` into a dedicated `engines/harvester/npc/beggar_dialogue.*` handler.
+  - Native Beggar dialogue is a single interrupt branch: when `BEGGAR_INTERRUPT_CONVERSATION` is set, native dispatches the `BEGGAR_DIALOG_3` action tag, then plays `0x1276` for `BEGGAR` with head variant `1`; otherwise the handler returns without dialogue.
+  - The engine now routes `BEGGAR` through a concrete handler instead of falling back to the unsupported NPC path.
 
 ## Next Suggested Action
 
-1. Port `handle_talk_to_beggar @ 0x34d40` as the next small interrupt-driven NPC handler.
-   - Ghidra decompilation currently shows one flag gate plus one action tag: `BEGGAR_INTERRUPT_CONVERSATION` triggers `dispatch_action_tag_if_set("BEGGAR_DIALOG_3", "BEGGAR")` and then plays `0x1276`; otherwise the native handler returns without dialogue.
-2. Add the minimal runtime callback needed for the interrupt/action-tag handlers, then continue through Beggar, Madam, and Gladiator.
-   - Those three handlers are the next confirmed low-complexity group after the visible flag-driven ports and they share the same native shape.
+1. Port `handle_talk_to_madam @ 0x2adc0` as the next small interrupt-driven NPC handler.
+   - Ghidra decompilation currently shows a single interrupt flag gate: when `MADAM_INTERRUPT_CONVERSATION` is set, native clears it and plays `0x1537` for `MADAM` with head variant `1`; otherwise the handler returns without dialogue.
+2. After Madam, port `handle_talk_to_gladiator @ 0x2c630`, which shares the same interrupt-flag shape.
+   - Gladiator is the same pattern with `GLADIATOR_INTERRUPT_CONVERSATION`, a clear-to-zero write, and a single `0x69c` `GLADIATOR` line.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 52b1b9b937c..adf4499a95a 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -33,6 +33,7 @@
 #include "graphics/fontman.h"
 #include "graphics/framelimiter.h"
 #include "harvester/npc/authority_dialogue.h"
+#include "harvester/npc/beggar_dialogue.h"
 #include "harvester/cft_font.h"
 #include "harvester/detection.h"
 #include "harvester/fst_player.h"
@@ -336,6 +337,7 @@ DialogueSystem::~DialogueSystem() {
 
 void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new AuthorityDialogueHandler());
+	_npcHandlers.push_back(new BeggarDialogueHandler());
 	_npcHandlers.push_back(new DwayneDialogueHandler());
 	_npcHandlers.push_back(new EdnaDialogueHandler());
 	_npcHandlers.push_back(new ChessmasterDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 231c2f7e8f4..c4cd10b48e8 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -2,6 +2,7 @@ MODULE := engines/harvester
 
 MODULE_OBJS = \
 	npc/authority_dialogue.o \
+	npc/beggar_dialogue.o \
 	cft_font.o \
 	harvester.o \
 	npc/chessmaster_dialogue.o \
diff --git a/engines/harvester/npc/beggar_dialogue.cpp b/engines/harvester/npc/beggar_dialogue.cpp
new file mode 100644
index 00000000000..b067da6508f
--- /dev/null
+++ b/engines/harvester/npc/beggar_dialogue.cpp
@@ -0,0 +1,55 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/beggar_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kBeggarInterruptFlag = "BEGGAR_INTERRUPT_CONVERSATION";
+static const char *const kBeggarDialog3ActionTag = "BEGGAR_DIALOG_3";
+
+} // End of namespace
+
+bool BeggarDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("BEGGAR");
+}
+
+Common::Error BeggarDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &, DialogueSharedState &) {
+	if (!runtime.startupScript().getFlagValue(kBeggarInterruptFlag))
+		return Common::kNoError;
+
+	StartupInteractionResult interaction;
+	if (runtime.startupScript().executeActionTag(kBeggarDialog3ActionTag, interaction)) {
+		runtime.applyImmediateDialogueInteractionEffects(interaction);
+		runtime.queueDialogueInteractionIfNeeded(interaction);
+	}
+
+	return runtime.playDialogueLineWithVariant(0x1276, "BEGGAR", 1);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/beggar_dialogue.h b/engines/harvester/npc/beggar_dialogue.h
new file mode 100644
index 00000000000..d16aad1e47b
--- /dev/null
+++ b/engines/harvester/npc/beggar_dialogue.h
@@ -0,0 +1,39 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_BEGGAR_DIALOGUE_H
+#define HARVESTER_NPC_BEGGAR_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class BeggarDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override {}
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_BEGGAR_DIALOGUE_H


Commit: 885707f6e00f16544799c6122fa68dfc4696b7cf
    https://github.com/scummvm/scummvm/commit/885707f6e00f16544799c6122fa68dfc4696b7cf
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:02+02:00

Commit Message:
HARVESTER: Port madam dialogue

Changed paths:
  A engines/harvester/npc/madam_dialogue.cpp
  A engines/harvester/npc/madam_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index cc7dc2db029..12fc974e209 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_beggar @ 0x34d40` into a dedicated `engines/harvester/npc/beggar_dialogue.*` handler.
-  - Native Beggar dialogue is a single interrupt branch: when `BEGGAR_INTERRUPT_CONVERSATION` is set, native dispatches the `BEGGAR_DIALOG_3` action tag, then plays `0x1276` for `BEGGAR` with head variant `1`; otherwise the handler returns without dialogue.
-  - The engine now routes `BEGGAR` through a concrete handler instead of falling back to the unsupported NPC path.
+- Ported `handle_talk_to_madam @ 0x2adc0` into a dedicated `engines/harvester/npc/madam_dialogue.*` handler.
+  - Native Madam dialogue is a single interrupt branch: when `MADAM_INTERRUPT_CONVERSATION` is set, native clears the flag and plays `0x1537` for `MADAM` with head variant `1`; otherwise the handler returns without dialogue.
+  - The engine now routes `MADAM` through a concrete handler instead of falling back to the unsupported NPC path.
 
 ## Next Suggested Action
 
-1. Port `handle_talk_to_madam @ 0x2adc0` as the next small interrupt-driven NPC handler.
-   - Ghidra decompilation currently shows a single interrupt flag gate: when `MADAM_INTERRUPT_CONVERSATION` is set, native clears it and plays `0x1537` for `MADAM` with head variant `1`; otherwise the handler returns without dialogue.
-2. After Madam, port `handle_talk_to_gladiator @ 0x2c630`, which shares the same interrupt-flag shape.
-   - Gladiator is the same pattern with `GLADIATOR_INTERRUPT_CONVERSATION`, a clear-to-zero write, and a single `0x69c` `GLADIATOR` line.
+1. Port `handle_talk_to_gladiator @ 0x2c630`, which shares the same interrupt-flag shape.
+   - Ghidra decompilation shows `GLADIATOR_INTERRUPT_CONVERSATION`, a clear-to-zero write, and a single `0x69c` `GLADIATOR` line with head variant `1`.
+2. After Gladiator, resume the remaining low-complexity visible handlers before returning to the larger hidden-topic ports.
+   - The next best candidates are the short non-hidden handlers that do not require new shared state reconstruction.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index adf4499a95a..bb6c7fbd5f9 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -50,6 +50,7 @@
 #include "harvester/npc/johnson_dialogue.h"
 #include "harvester/npc/lodge_chef_dialogue.h"
 #include "harvester/npc/maint_man_dialogue.h"
+#include "harvester/npc/madam_dialogue.h"
 #include "harvester/npc/mom_dialogue.h"
 #include "harvester/npc/mother_dialogue.h"
 #include "harvester/npc/pta_mom_dialogue.h"
@@ -346,6 +347,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new InquisitorDialogueHandler());
 	_npcHandlers.push_back(new WaspWomanDialogueHandler());
 	_npcHandlers.push_back(new MaintManDialogueHandler());
+	_npcHandlers.push_back(new MadamDialogueHandler());
 	_npcHandlers.push_back(new MomDialogueHandler());
 	_npcHandlers.push_back(new MotherDialogueHandler());
 	_npcHandlers.push_back(new LodgeChefDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index c4cd10b48e8..172cfe920c8 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -20,6 +20,7 @@ MODULE_OBJS = \
 	npc/johnson_dialogue.o \
 	npc/lodge_chef_dialogue.o \
 	npc/maint_man_dialogue.o \
+	npc/madam_dialogue.o \
 	npc/mom_dialogue.o \
 	npc/mother_dialogue.o \
 	npc/pta_mom_dialogue.o \
diff --git a/engines/harvester/npc/madam_dialogue.cpp b/engines/harvester/npc/madam_dialogue.cpp
new file mode 100644
index 00000000000..1b608d3a15f
--- /dev/null
+++ b/engines/harvester/npc/madam_dialogue.cpp
@@ -0,0 +1,49 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/madam_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kMadamInterruptFlag = "MADAM_INTERRUPT_CONVERSATION";
+
+} // End of namespace
+
+bool MadamDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("MADAM");
+}
+
+Common::Error MadamDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &, DialogueSharedState &) {
+	if (!runtime.startupScript().getFlagValue(kMadamInterruptFlag))
+		return Common::kNoError;
+
+	(void)runtime.startupScript().setRuntimeFlagValue(kMadamInterruptFlag, false);
+	return runtime.playDialogueLineWithVariant(0x1537, "MADAM", 1);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/madam_dialogue.h b/engines/harvester/npc/madam_dialogue.h
new file mode 100644
index 00000000000..3dc37f955c8
--- /dev/null
+++ b/engines/harvester/npc/madam_dialogue.h
@@ -0,0 +1,39 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_MADAM_DIALOGUE_H
+#define HARVESTER_NPC_MADAM_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class MadamDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override {}
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_MADAM_DIALOGUE_H


Commit: 5aab4e9b36e983f7e235ce6ee75b1170e7346f96
    https://github.com/scummvm/scummvm/commit/5aab4e9b36e983f7e235ce6ee75b1170e7346f96
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:03+02:00

Commit Message:
HARVESTER: Port gladiator dialogue

Changed paths:
  A engines/harvester/npc/gladiator_dialogue.cpp
  A engines/harvester/npc/gladiator_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 12fc974e209..e71e9cc9e19 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_madam @ 0x2adc0` into a dedicated `engines/harvester/npc/madam_dialogue.*` handler.
-  - Native Madam dialogue is a single interrupt branch: when `MADAM_INTERRUPT_CONVERSATION` is set, native clears the flag and plays `0x1537` for `MADAM` with head variant `1`; otherwise the handler returns without dialogue.
-  - The engine now routes `MADAM` through a concrete handler instead of falling back to the unsupported NPC path.
+- Ported `handle_talk_to_gladiator @ 0x2c630` into a dedicated `engines/harvester/npc/gladiator_dialogue.*` handler.
+  - Native Gladiator dialogue is a single interrupt branch: when `GLADIATOR_INTERRUPT_CONVERSATION` is set, native clears the flag and plays `0x69c` for `GLADIATOR` with head variant `1`; otherwise the handler returns without dialogue.
+  - The engine now routes `GLADIATOR` through a concrete handler instead of falling back to the unsupported NPC path.
 
 ## Next Suggested Action
 
-1. Port `handle_talk_to_gladiator @ 0x2c630`, which shares the same interrupt-flag shape.
-   - Ghidra decompilation shows `GLADIATOR_INTERRUPT_CONVERSATION`, a clear-to-zero write, and a single `0x69c` `GLADIATOR` line with head variant `1`.
-2. After Gladiator, resume the remaining low-complexity visible handlers before returning to the larger hidden-topic ports.
-   - The next best candidates are the short non-hidden handlers that do not require new shared state reconstruction.
+1. Resume the remaining low-complexity visible handlers before returning to the larger hidden-topic ports.
+   - The next best candidates are the short non-hidden handlers that do not require new shared state reconstruction, starting with the still-unported simple bark/flag handlers such as `FIREMAN1`, `FIREMAN2`, and comparable short natives.
+2. After that short-handler pass, return to the bounded multi-branch ports starting with Boyle.
+   - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index bb6c7fbd5f9..becf069b4cf 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -43,6 +43,7 @@
 #include "harvester/npc/dwayne_dialogue.h"
 #include "harvester/npc/dialogue_runtime.h"
 #include "harvester/npc/edna_dialogue.h"
+#include "harvester/npc/gladiator_dialogue.h"
 #include "harvester/npc/hank_dialogue.h"
 #include "harvester/npc/herrill_dialogue.h"
 #include "harvester/npc/inquisitor_dialogue.h"
@@ -346,6 +347,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new JimmyDialogueHandler());
 	_npcHandlers.push_back(new InquisitorDialogueHandler());
 	_npcHandlers.push_back(new WaspWomanDialogueHandler());
+	_npcHandlers.push_back(new GladiatorDialogueHandler());
 	_npcHandlers.push_back(new MaintManDialogueHandler());
 	_npcHandlers.push_back(new MadamDialogueHandler());
 	_npcHandlers.push_back(new MomDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 172cfe920c8..c27e7729038 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -13,6 +13,7 @@ MODULE_OBJS = \
 	npc/curator_dialogue.o \
 	npc/dwayne_dialogue.o \
 	npc/edna_dialogue.o \
+	npc/gladiator_dialogue.o \
 	npc/hank_dialogue.o \
 	npc/herrill_dialogue.o \
 	npc/inquisitor_dialogue.o \
diff --git a/engines/harvester/npc/gladiator_dialogue.cpp b/engines/harvester/npc/gladiator_dialogue.cpp
new file mode 100644
index 00000000000..044f5898a54
--- /dev/null
+++ b/engines/harvester/npc/gladiator_dialogue.cpp
@@ -0,0 +1,49 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/gladiator_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kGladiatorInterruptFlag = "GLADIATOR_INTERRUPT_CONVERSATION";
+
+} // End of namespace
+
+bool GladiatorDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("GLADIATOR");
+}
+
+Common::Error GladiatorDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &, DialogueSharedState &) {
+	if (!runtime.startupScript().getFlagValue(kGladiatorInterruptFlag))
+		return Common::kNoError;
+
+	(void)runtime.startupScript().setRuntimeFlagValue(kGladiatorInterruptFlag, false);
+	return runtime.playDialogueLineWithVariant(0x69c, "GLADIATOR", 1);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/gladiator_dialogue.h b/engines/harvester/npc/gladiator_dialogue.h
new file mode 100644
index 00000000000..bc6026d86eb
--- /dev/null
+++ b/engines/harvester/npc/gladiator_dialogue.h
@@ -0,0 +1,39 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_GLADIATOR_DIALOGUE_H
+#define HARVESTER_NPC_GLADIATOR_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class GladiatorDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override {}
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_GLADIATOR_DIALOGUE_H


Commit: 2ae20e906d8bf9e25fb6a9d80bef8566f1e81cb3
    https://github.com/scummvm/scummvm/commit/2ae20e906d8bf9e25fb6a9d80bef8566f1e81cb3
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:03+02:00

Commit Message:
HARVESTER: Port fireman1 dialogue

Changed paths:
  A engines/harvester/npc/fireman1_dialogue.cpp
  A engines/harvester/npc/fireman1_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index e71e9cc9e19..2cd9d7bff2f 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_gladiator @ 0x2c630` into a dedicated `engines/harvester/npc/gladiator_dialogue.*` handler.
-  - Native Gladiator dialogue is a single interrupt branch: when `GLADIATOR_INTERRUPT_CONVERSATION` is set, native clears the flag and plays `0x69c` for `GLADIATOR` with head variant `1`; otherwise the handler returns without dialogue.
-  - The engine now routes `GLADIATOR` through a concrete handler instead of falling back to the unsupported NPC path.
+- Ported `handle_talk_to_fireman1 @ 0x33850` into a dedicated `engines/harvester/npc/fireman1_dialogue.*` handler.
+  - Native Fireman 1 dialogue is a two-outcome local talk-state branch: the first talk clears `g_fireman1_talk_state_block` and plays `0x453` for `FIREMAN1`, while subsequent talks fall through to `0x463`.
+  - The engine now routes `FIREMAN1` through a concrete handler with matching per-NPC state instead of falling back to the unsupported NPC path.
 
 ## Next Suggested Action
 
-1. Resume the remaining low-complexity visible handlers before returning to the larger hidden-topic ports.
-   - The next best candidates are the short non-hidden handlers that do not require new shared state reconstruction, starting with the still-unported simple bark/flag handlers such as `FIREMAN1`, `FIREMAN2`, and comparable short natives.
-2. After that short-handler pass, return to the bounded multi-branch ports starting with Boyle.
+1. Port `handle_talk_to_fireman2 @ 0x33940` as the next short local-state handler.
+   - Ghidra decompilation currently shows the same two-outcome shape as Fireman 1: the first talk clears `g_fireman2_talk_state_block` and plays `0x5b9`, while the steady path plays `0x5c7`.
+2. After the short visible handlers are exhausted, return to the bounded multi-branch ports starting with Boyle.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index becf069b4cf..befd7035740 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -44,6 +44,7 @@
 #include "harvester/npc/dialogue_runtime.h"
 #include "harvester/npc/edna_dialogue.h"
 #include "harvester/npc/gladiator_dialogue.h"
+#include "harvester/npc/fireman1_dialogue.h"
 #include "harvester/npc/hank_dialogue.h"
 #include "harvester/npc/herrill_dialogue.h"
 #include "harvester/npc/inquisitor_dialogue.h"
@@ -342,6 +343,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new BeggarDialogueHandler());
 	_npcHandlers.push_back(new DwayneDialogueHandler());
 	_npcHandlers.push_back(new EdnaDialogueHandler());
+	_npcHandlers.push_back(new Fireman1DialogueHandler());
 	_npcHandlers.push_back(new ChessmasterDialogueHandler());
 	_npcHandlers.push_back(new CuratorDialogueHandler());
 	_npcHandlers.push_back(new JimmyDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index c27e7729038..45f944fa26f 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -13,6 +13,7 @@ MODULE_OBJS = \
 	npc/curator_dialogue.o \
 	npc/dwayne_dialogue.o \
 	npc/edna_dialogue.o \
+	npc/fireman1_dialogue.o \
 	npc/gladiator_dialogue.o \
 	npc/hank_dialogue.o \
 	npc/herrill_dialogue.o \
diff --git a/engines/harvester/npc/fireman1_dialogue.cpp b/engines/harvester/npc/fireman1_dialogue.cpp
new file mode 100644
index 00000000000..a4bc97c13dd
--- /dev/null
+++ b/engines/harvester/npc/fireman1_dialogue.cpp
@@ -0,0 +1,44 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/fireman1_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool Fireman1DialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("FIREMAN1");
+}
+
+Common::Error Fireman1DialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &, DialogueSharedState &) {
+	if (_state.talkStatePending) {
+		_state.talkStatePending = false;
+		return runtime.playDialogueLine(0x453, "FIREMAN1");
+	}
+
+	return runtime.playDialogueLine(0x463, "FIREMAN1");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/fireman1_dialogue.h b/engines/harvester/npc/fireman1_dialogue.h
new file mode 100644
index 00000000000..93e911b139f
--- /dev/null
+++ b/engines/harvester/npc/fireman1_dialogue.h
@@ -0,0 +1,46 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_FIREMAN1_DIALOGUE_H
+#define HARVESTER_NPC_FIREMAN1_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class Fireman1DialogueHandler : public NpcDialogueHandler {
+public:
+	struct Fireman1RoomDialogueState {
+		bool talkStatePending = true;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = Fireman1RoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	Fireman1RoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_FIREMAN1_DIALOGUE_H


Commit: 891b4fbab8a1c59df2d73f072db8e7dd8d795707
    https://github.com/scummvm/scummvm/commit/891b4fbab8a1c59df2d73f072db8e7dd8d795707
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:03+02:00

Commit Message:
HARVESTER: Port fireman2 dialogue

Changed paths:
  A engines/harvester/npc/fireman2_dialogue.cpp
  A engines/harvester/npc/fireman2_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 2cd9d7bff2f..6277cd3eec9 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_fireman1 @ 0x33850` into a dedicated `engines/harvester/npc/fireman1_dialogue.*` handler.
-  - Native Fireman 1 dialogue is a two-outcome local talk-state branch: the first talk clears `g_fireman1_talk_state_block` and plays `0x453` for `FIREMAN1`, while subsequent talks fall through to `0x463`.
-  - The engine now routes `FIREMAN1` through a concrete handler with matching per-NPC state instead of falling back to the unsupported NPC path.
+- Ported `handle_talk_to_fireman2 @ 0x33940` into a dedicated `engines/harvester/npc/fireman2_dialogue.*` handler.
+  - Native Fireman 2 dialogue is a two-outcome local talk-state branch: the first talk clears `g_fireman2_talk_state_block` and plays `0x5b9` for `FIREMAN2`, while subsequent talks fall through to `0x5c7`.
+  - The engine now routes `FIREMAN2` through a concrete handler with matching per-NPC state instead of falling back to the unsupported NPC path.
 
 ## Next Suggested Action
 
-1. Port `handle_talk_to_fireman2 @ 0x33940` as the next short local-state handler.
-   - Ghidra decompilation currently shows the same two-outcome shape as Fireman 1: the first talk clears `g_fireman2_talk_state_block` and plays `0x5b9`, while the steady path plays `0x5c7`.
-2. After the short visible handlers are exhausted, return to the bounded multi-branch ports starting with Boyle.
+1. Resume the remaining low-complexity visible handlers before returning to the bounded multi-branch ports.
+   - The next best candidates are the short non-hidden handlers that do not require new shared state reconstruction, followed by the already-bounded multi-branch Boyle port.
+2. After that short-handler pass, return to Boyle and the other larger hidden-topic handlers.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index befd7035740..ad98533853f 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -45,6 +45,7 @@
 #include "harvester/npc/edna_dialogue.h"
 #include "harvester/npc/gladiator_dialogue.h"
 #include "harvester/npc/fireman1_dialogue.h"
+#include "harvester/npc/fireman2_dialogue.h"
 #include "harvester/npc/hank_dialogue.h"
 #include "harvester/npc/herrill_dialogue.h"
 #include "harvester/npc/inquisitor_dialogue.h"
@@ -344,6 +345,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new DwayneDialogueHandler());
 	_npcHandlers.push_back(new EdnaDialogueHandler());
 	_npcHandlers.push_back(new Fireman1DialogueHandler());
+	_npcHandlers.push_back(new Fireman2DialogueHandler());
 	_npcHandlers.push_back(new ChessmasterDialogueHandler());
 	_npcHandlers.push_back(new CuratorDialogueHandler());
 	_npcHandlers.push_back(new JimmyDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 45f944fa26f..bc68fd0f752 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -14,6 +14,7 @@ MODULE_OBJS = \
 	npc/dwayne_dialogue.o \
 	npc/edna_dialogue.o \
 	npc/fireman1_dialogue.o \
+	npc/fireman2_dialogue.o \
 	npc/gladiator_dialogue.o \
 	npc/hank_dialogue.o \
 	npc/herrill_dialogue.o \
diff --git a/engines/harvester/npc/fireman2_dialogue.cpp b/engines/harvester/npc/fireman2_dialogue.cpp
new file mode 100644
index 00000000000..7290a8efb8e
--- /dev/null
+++ b/engines/harvester/npc/fireman2_dialogue.cpp
@@ -0,0 +1,44 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/fireman2_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool Fireman2DialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("FIREMAN2");
+}
+
+Common::Error Fireman2DialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &, DialogueSharedState &) {
+	if (_state.talkStatePending) {
+		_state.talkStatePending = false;
+		return runtime.playDialogueLine(0x5b9, "FIREMAN2");
+	}
+
+	return runtime.playDialogueLine(0x5c7, "FIREMAN2");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/fireman2_dialogue.h b/engines/harvester/npc/fireman2_dialogue.h
new file mode 100644
index 00000000000..721f9942a94
--- /dev/null
+++ b/engines/harvester/npc/fireman2_dialogue.h
@@ -0,0 +1,46 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_FIREMAN2_DIALOGUE_H
+#define HARVESTER_NPC_FIREMAN2_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class Fireman2DialogueHandler : public NpcDialogueHandler {
+public:
+	struct Fireman2RoomDialogueState {
+		bool talkStatePending = true;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = Fireman2RoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	Fireman2RoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_FIREMAN2_DIALOGUE_H


Commit: bc28c3f9c240c0007c02e3284811e7d6118c70f4
    https://github.com/scummvm/scummvm/commit/bc28c3f9c240c0007c02e3284811e7d6118c70f4
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:04+02:00

Commit Message:
HARVESTER: Port pastorelli dialogue

Changed paths:
  A engines/harvester/npc/pastorelli_dialogue.cpp
  A engines/harvester/npc/pastorelli_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 6277cd3eec9..450853854dc 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_fireman2 @ 0x33940` into a dedicated `engines/harvester/npc/fireman2_dialogue.*` handler.
-  - Native Fireman 2 dialogue is a two-outcome local talk-state branch: the first talk clears `g_fireman2_talk_state_block` and plays `0x5b9` for `FIREMAN2`, while subsequent talks fall through to `0x5c7`.
-  - The engine now routes `FIREMAN2` through a concrete handler with matching per-NPC state instead of falling back to the unsupported NPC path.
+- Ported `handle_talk_to_pastorelli @ 0x38160` into a dedicated `engines/harvester/npc/pastorelli_dialogue.*` handler.
+  - Native Pastorelli dialogue is just an action-tag dispatch wrapper around `PASTORELLI_TALK`; there is no topic tree or local talk-state block in the recovered function.
+  - The engine now routes `PASTORELLI` through a concrete handler that executes the same tag and applies any immediate or queued interaction side effects.
 
 ## Next Suggested Action
 
 1. Resume the remaining low-complexity visible handlers before returning to the bounded multi-branch ports.
-   - The next best candidates are the short non-hidden handlers that do not require new shared state reconstruction, followed by the already-bounded multi-branch Boyle port.
+   - The next best candidates are the remaining short non-hidden handlers that do not require new shared state reconstruction: `PRIEST`, `VALET`, `VET`, `CLOAK_ATND`, and `DARK_WOMAN`.
 2. After that short-handler pass, return to Boyle and the other larger hidden-topic handlers.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index ad98533853f..05e886ef1f0 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -56,6 +56,7 @@
 #include "harvester/npc/madam_dialogue.h"
 #include "harvester/npc/mom_dialogue.h"
 #include "harvester/npc/mother_dialogue.h"
+#include "harvester/npc/pastorelli_dialogue.h"
 #include "harvester/npc/pta_mom_dialogue.h"
 #include "harvester/npc/stub_dialogue.h"
 #include "harvester/npc/wasp_woman_dialogue.h"
@@ -357,6 +358,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new MomDialogueHandler());
 	_npcHandlers.push_back(new MotherDialogueHandler());
 	_npcHandlers.push_back(new LodgeChefDialogueHandler());
+	_npcHandlers.push_back(new PastorelliDialogueHandler());
 	_npcHandlers.push_back(new HankDialogueHandler());
 	_npcHandlers.push_back(new HerrillDialogueHandler());
 	_npcHandlers.push_back(new JohnsonDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index bc68fd0f752..beb63e615c9 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -26,6 +26,7 @@ MODULE_OBJS = \
 	npc/madam_dialogue.o \
 	npc/mom_dialogue.o \
 	npc/mother_dialogue.o \
+	npc/pastorelli_dialogue.o \
 	npc/pta_mom_dialogue.o \
 	npc/stub_dialogue.o \
 	npc/wasp_woman_dialogue.o \
diff --git a/engines/harvester/npc/pastorelli_dialogue.cpp b/engines/harvester/npc/pastorelli_dialogue.cpp
new file mode 100644
index 00000000000..b57dbe5f088
--- /dev/null
+++ b/engines/harvester/npc/pastorelli_dialogue.cpp
@@ -0,0 +1,51 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/pastorelli_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kPastorelliTalkActionTag = "PASTORELLI_TALK";
+
+} // End of namespace
+
+bool PastorelliDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("PASTORELLI");
+}
+
+Common::Error PastorelliDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &, DialogueSharedState &) {
+	StartupInteractionResult interaction;
+	if (runtime.startupScript().executeActionTag(kPastorelliTalkActionTag, interaction)) {
+		runtime.applyImmediateDialogueInteractionEffects(interaction);
+		runtime.queueDialogueInteractionIfNeeded(interaction);
+	}
+
+	return Common::kNoError;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/pastorelli_dialogue.h b/engines/harvester/npc/pastorelli_dialogue.h
new file mode 100644
index 00000000000..db165f8f4de
--- /dev/null
+++ b/engines/harvester/npc/pastorelli_dialogue.h
@@ -0,0 +1,39 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_PASTORELLI_DIALOGUE_H
+#define HARVESTER_NPC_PASTORELLI_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class PastorelliDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override {}
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_PASTORELLI_DIALOGUE_H


Commit: 0236207f00211368338757b1fa7bf9f19185abad
    https://github.com/scummvm/scummvm/commit/0236207f00211368338757b1fa7bf9f19185abad
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:04+02:00

Commit Message:
HARVESTER: Port priest dialogue

Changed paths:
  A engines/harvester/npc/priest_dialogue.cpp
  A engines/harvester/npc/priest_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 450853854dc..631096153e2 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_pastorelli @ 0x38160` into a dedicated `engines/harvester/npc/pastorelli_dialogue.*` handler.
-  - Native Pastorelli dialogue is just an action-tag dispatch wrapper around `PASTORELLI_TALK`; there is no topic tree or local talk-state block in the recovered function.
-  - The engine now routes `PASTORELLI` through a concrete handler that executes the same tag and applies any immediate or queued interaction side effects.
+- Ported `handle_talk_to_priest @ 0x29730` into a dedicated `engines/harvester/npc/priest_dialogue.*` handler.
+  - Native Priest dialogue is a one-shot gate on `PC_TALKED_TO_PRIEST`: the first conversation sets the flag and plays `0xcb2` for `PRIEST`, and later talks do nothing.
+  - The engine now routes `PRIEST` through a concrete handler that mirrors the same flag write and single-line behavior.
 
 ## Next Suggested Action
 
 1. Resume the remaining low-complexity visible handlers before returning to the bounded multi-branch ports.
-   - The next best candidates are the remaining short non-hidden handlers that do not require new shared state reconstruction: `PRIEST`, `VALET`, `VET`, `CLOAK_ATND`, and `DARK_WOMAN`.
+   - The next best candidates are the remaining short non-hidden handlers that do not require new shared state reconstruction: `VALET`, `VET`, `CLOAK_ATND`, and `DARK_WOMAN`.
 2. After that short-handler pass, return to Boyle and the other larger hidden-topic handlers.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 05e886ef1f0..8fa1577011c 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -57,6 +57,7 @@
 #include "harvester/npc/mom_dialogue.h"
 #include "harvester/npc/mother_dialogue.h"
 #include "harvester/npc/pastorelli_dialogue.h"
+#include "harvester/npc/priest_dialogue.h"
 #include "harvester/npc/pta_mom_dialogue.h"
 #include "harvester/npc/stub_dialogue.h"
 #include "harvester/npc/wasp_woman_dialogue.h"
@@ -359,6 +360,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new MotherDialogueHandler());
 	_npcHandlers.push_back(new LodgeChefDialogueHandler());
 	_npcHandlers.push_back(new PastorelliDialogueHandler());
+	_npcHandlers.push_back(new PriestDialogueHandler());
 	_npcHandlers.push_back(new HankDialogueHandler());
 	_npcHandlers.push_back(new HerrillDialogueHandler());
 	_npcHandlers.push_back(new JohnsonDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index beb63e615c9..ae0dcad94e5 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -27,6 +27,7 @@ MODULE_OBJS = \
 	npc/mom_dialogue.o \
 	npc/mother_dialogue.o \
 	npc/pastorelli_dialogue.o \
+	npc/priest_dialogue.o \
 	npc/pta_mom_dialogue.o \
 	npc/stub_dialogue.o \
 	npc/wasp_woman_dialogue.o \
diff --git a/engines/harvester/npc/priest_dialogue.cpp b/engines/harvester/npc/priest_dialogue.cpp
new file mode 100644
index 00000000000..6ce983e09a5
--- /dev/null
+++ b/engines/harvester/npc/priest_dialogue.cpp
@@ -0,0 +1,49 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/priest_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kPcTalkedToPriestFlag = "PC_TALKED_TO_PRIEST";
+
+} // End of namespace
+
+bool PriestDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("PRIEST");
+}
+
+Common::Error PriestDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &, DialogueSharedState &) {
+	if (runtime.startupScript().getFlagValue(kPcTalkedToPriestFlag))
+		return Common::kNoError;
+
+	(void)runtime.startupScript().setRuntimeFlagValue(kPcTalkedToPriestFlag, true);
+	return runtime.playDialogueLine(0xcb2, "PRIEST");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/priest_dialogue.h b/engines/harvester/npc/priest_dialogue.h
new file mode 100644
index 00000000000..9bc6ad6b689
--- /dev/null
+++ b/engines/harvester/npc/priest_dialogue.h
@@ -0,0 +1,39 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_PRIEST_DIALOGUE_H
+#define HARVESTER_NPC_PRIEST_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class PriestDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override {}
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_PRIEST_DIALOGUE_H


Commit: 0b44eab9cd479b7d5f57357df3d952088c282004
    https://github.com/scummvm/scummvm/commit/0b44eab9cd479b7d5f57357df3d952088c282004
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:04+02:00

Commit Message:
HARVESTER: Port valet dialogue

Changed paths:
  A engines/harvester/npc/valet_dialogue.cpp
  A engines/harvester/npc/valet_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 631096153e2..36b25abbf86 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_priest @ 0x29730` into a dedicated `engines/harvester/npc/priest_dialogue.*` handler.
-  - Native Priest dialogue is a one-shot gate on `PC_TALKED_TO_PRIEST`: the first conversation sets the flag and plays `0xcb2` for `PRIEST`, and later talks do nothing.
-  - The engine now routes `PRIEST` through a concrete handler that mirrors the same flag write and single-line behavior.
+- Ported `handle_talk_to_valet @ 0x2eda0` into a dedicated `engines/harvester/npc/valet_dialogue.*` handler.
+  - Native Valet dialogue is a fixed single-line handler that always plays `0xf2c` for `VALET`; there is no topic tree, item branch, or local state in the recovered function.
+  - The engine now routes `VALET` through a concrete handler that mirrors the same unconditional line playback.
 
 ## Next Suggested Action
 
 1. Resume the remaining low-complexity visible handlers before returning to the bounded multi-branch ports.
-   - The next best candidates are the remaining short non-hidden handlers that do not require new shared state reconstruction: `VALET`, `VET`, `CLOAK_ATND`, and `DARK_WOMAN`.
+   - The next best candidates are the remaining short non-hidden handlers that do not require new shared state reconstruction: `VET`, `CLOAK_ATND`, and `DARK_WOMAN`.
 2. After that short-handler pass, return to Boyle and the other larger hidden-topic handlers.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 8fa1577011c..3403233bf15 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -60,6 +60,7 @@
 #include "harvester/npc/priest_dialogue.h"
 #include "harvester/npc/pta_mom_dialogue.h"
 #include "harvester/npc/stub_dialogue.h"
+#include "harvester/npc/valet_dialogue.h"
 #include "harvester/npc/wasp_woman_dialogue.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
@@ -361,6 +362,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new LodgeChefDialogueHandler());
 	_npcHandlers.push_back(new PastorelliDialogueHandler());
 	_npcHandlers.push_back(new PriestDialogueHandler());
+	_npcHandlers.push_back(new ValetDialogueHandler());
 	_npcHandlers.push_back(new HankDialogueHandler());
 	_npcHandlers.push_back(new HerrillDialogueHandler());
 	_npcHandlers.push_back(new JohnsonDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index ae0dcad94e5..76bfb0735cb 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -30,6 +30,7 @@ MODULE_OBJS = \
 	npc/priest_dialogue.o \
 	npc/pta_mom_dialogue.o \
 	npc/stub_dialogue.o \
+	npc/valet_dialogue.o \
 	npc/wasp_woman_dialogue.o \
 	resources.o \
 	runtime_entity.o \
diff --git a/engines/harvester/npc/valet_dialogue.cpp b/engines/harvester/npc/valet_dialogue.cpp
new file mode 100644
index 00000000000..4bc8e6e33eb
--- /dev/null
+++ b/engines/harvester/npc/valet_dialogue.cpp
@@ -0,0 +1,39 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/valet_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool ValetDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("VALET");
+}
+
+Common::Error ValetDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &, DialogueSharedState &) {
+	return runtime.playDialogueLine(0xf2c, "VALET");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/valet_dialogue.h b/engines/harvester/npc/valet_dialogue.h
new file mode 100644
index 00000000000..18687b9f379
--- /dev/null
+++ b/engines/harvester/npc/valet_dialogue.h
@@ -0,0 +1,39 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_VALET_DIALOGUE_H
+#define HARVESTER_NPC_VALET_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class ValetDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override {}
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_VALET_DIALOGUE_H


Commit: 78c857d7d16fbef5f2461ab03b9c15323d150a11
    https://github.com/scummvm/scummvm/commit/78c857d7d16fbef5f2461ab03b9c15323d150a11
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:05+02:00

Commit Message:
HARVESTER: Port vet dialogue

Changed paths:
  A engines/harvester/npc/vet_dialogue.cpp
  A engines/harvester/npc/vet_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 36b25abbf86..53325804f4a 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_valet @ 0x2eda0` into a dedicated `engines/harvester/npc/valet_dialogue.*` handler.
-  - Native Valet dialogue is a fixed single-line handler that always plays `0xf2c` for `VALET`; there is no topic tree, item branch, or local state in the recovered function.
-  - The engine now routes `VALET` through a concrete handler that mirrors the same unconditional line playback.
+- Ported `handle_talk_to_vet @ 0x2fd20` into a dedicated `engines/harvester/npc/vet_dialogue.*` handler.
+  - Native Vet dialogue is a one-shot interrupt branch on `VET_INTERRUPT`: when the flag is set it is cleared and `0x6d9` plays for `VET`, otherwise the handler falls through.
+  - The engine now routes `VET` through a concrete handler that mirrors the same flag clear and conditional line playback.
 
 ## Next Suggested Action
 
 1. Resume the remaining low-complexity visible handlers before returning to the bounded multi-branch ports.
-   - The next best candidates are the remaining short non-hidden handlers that do not require new shared state reconstruction: `VET`, `CLOAK_ATND`, and `DARK_WOMAN`.
+   - The next best candidates are the remaining short non-hidden handlers that do not require new shared state reconstruction: `CLOAK_ATND` and `DARK_WOMAN`.
 2. After that short-handler pass, return to Boyle and the other larger hidden-topic handlers.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 3403233bf15..1489f770486 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -61,6 +61,7 @@
 #include "harvester/npc/pta_mom_dialogue.h"
 #include "harvester/npc/stub_dialogue.h"
 #include "harvester/npc/valet_dialogue.h"
+#include "harvester/npc/vet_dialogue.h"
 #include "harvester/npc/wasp_woman_dialogue.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
@@ -363,6 +364,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new PastorelliDialogueHandler());
 	_npcHandlers.push_back(new PriestDialogueHandler());
 	_npcHandlers.push_back(new ValetDialogueHandler());
+	_npcHandlers.push_back(new VetDialogueHandler());
 	_npcHandlers.push_back(new HankDialogueHandler());
 	_npcHandlers.push_back(new HerrillDialogueHandler());
 	_npcHandlers.push_back(new JohnsonDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 76bfb0735cb..02aaaea092e 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -31,6 +31,7 @@ MODULE_OBJS = \
 	npc/pta_mom_dialogue.o \
 	npc/stub_dialogue.o \
 	npc/valet_dialogue.o \
+	npc/vet_dialogue.o \
 	npc/wasp_woman_dialogue.o \
 	resources.o \
 	runtime_entity.o \
diff --git a/engines/harvester/npc/vet_dialogue.cpp b/engines/harvester/npc/vet_dialogue.cpp
new file mode 100644
index 00000000000..44e06b5563d
--- /dev/null
+++ b/engines/harvester/npc/vet_dialogue.cpp
@@ -0,0 +1,49 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/vet_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kVetInterruptFlag = "VET_INTERRUPT";
+
+} // End of namespace
+
+bool VetDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("VET");
+}
+
+Common::Error VetDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &, DialogueSharedState &) {
+	if (!runtime.startupScript().getFlagValue(kVetInterruptFlag))
+		return Common::kNoError;
+
+	(void)runtime.startupScript().setRuntimeFlagValue(kVetInterruptFlag, false);
+	return runtime.playDialogueLine(0x6d9, "VET");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/vet_dialogue.h b/engines/harvester/npc/vet_dialogue.h
new file mode 100644
index 00000000000..7a40df4501c
--- /dev/null
+++ b/engines/harvester/npc/vet_dialogue.h
@@ -0,0 +1,39 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_VET_DIALOGUE_H
+#define HARVESTER_NPC_VET_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class VetDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override {}
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_VET_DIALOGUE_H


Commit: a53268a5d983bd4922de5fb7fbb70397b73cfe8e
    https://github.com/scummvm/scummvm/commit/a53268a5d983bd4922de5fb7fbb70397b73cfe8e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:05+02:00

Commit Message:
HARVESTER: Port cloak attendant dialogue

Changed paths:
  A engines/harvester/npc/cloak_atnd_dialogue.cpp
  A engines/harvester/npc/cloak_atnd_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 53325804f4a..5e3ee542b8c 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_vet @ 0x2fd20` into a dedicated `engines/harvester/npc/vet_dialogue.*` handler.
-  - Native Vet dialogue is a one-shot interrupt branch on `VET_INTERRUPT`: when the flag is set it is cleared and `0x6d9` plays for `VET`, otherwise the handler falls through.
-  - The engine now routes `VET` through a concrete handler that mirrors the same flag clear and conditional line playback.
+- Ported `handle_talk_to_cloak_atnd @ 0x2db80` into a dedicated `engines/harvester/npc/cloak_atnd_dialogue.*` handler.
+  - Native Cloak Attendant dialogue is a small no-item/item split: if no item is presented and `CLEANED_CLOTHES` is still clear, it plays `0x1066` for `CLOAK_ATND`; if `BARCASHFIVE` is presented, it dispatches `CLEAN_CLOTHES`.
+  - The engine now routes `CLOAK_ATND` through a concrete handler that mirrors the same flag check and action-tag dispatch, including queued interaction side effects.
 
 ## Next Suggested Action
 
 1. Resume the remaining low-complexity visible handlers before returning to the bounded multi-branch ports.
-   - The next best candidates are the remaining short non-hidden handlers that do not require new shared state reconstruction: `CLOAK_ATND` and `DARK_WOMAN`.
+   - The remaining short visible handler before the evidence-heavy pass is `DARK_WOMAN`.
 2. After that short-handler pass, return to Boyle and the other larger hidden-topic handlers.
-   - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
+   - After `DARK_WOMAN`, the next best bounded visible ports are the evidence-heavy but still linear handlers such as `BUSTER`, `BUTCHER`, `PHELPS`, `SWELL`, and `PARSONS`, before returning to Boyle.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 1489f770486..6fff6708ed6 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -39,6 +39,7 @@
 #include "harvester/fst_player.h"
 #include "harvester/harvester.h"
 #include "harvester/npc/chessmaster_dialogue.h"
+#include "harvester/npc/cloak_atnd_dialogue.h"
 #include "harvester/npc/curator_dialogue.h"
 #include "harvester/npc/dwayne_dialogue.h"
 #include "harvester/npc/dialogue_runtime.h"
@@ -351,6 +352,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new Fireman1DialogueHandler());
 	_npcHandlers.push_back(new Fireman2DialogueHandler());
 	_npcHandlers.push_back(new ChessmasterDialogueHandler());
+	_npcHandlers.push_back(new CloakAtndDialogueHandler());
 	_npcHandlers.push_back(new CuratorDialogueHandler());
 	_npcHandlers.push_back(new JimmyDialogueHandler());
 	_npcHandlers.push_back(new InquisitorDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 02aaaea092e..e5e67347fb8 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -6,6 +6,7 @@ MODULE_OBJS = \
 	cft_font.o \
 	harvester.o \
 	npc/chessmaster_dialogue.o \
+	npc/cloak_atnd_dialogue.o \
 	console.o \
 	dialogue.o \
 	fst_player.o \
diff --git a/engines/harvester/npc/cloak_atnd_dialogue.cpp b/engines/harvester/npc/cloak_atnd_dialogue.cpp
new file mode 100644
index 00000000000..43ce25724f3
--- /dev/null
+++ b/engines/harvester/npc/cloak_atnd_dialogue.cpp
@@ -0,0 +1,63 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/cloak_atnd_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kCleanedClothesFlag = "CLEANED_CLOTHES";
+static const char *const kCleanClothesActionTag = "CLEAN_CLOTHES";
+static const char *const kPaymentItemName = "BARCASHFIVE";
+
+} // End of namespace
+
+bool CloakAtndDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("CLOAK_ATND");
+}
+
+Common::Error CloakAtndDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
+	if (usedItemName.empty()) {
+		if (runtime.startupScript().getFlagValue(kCleanedClothesFlag))
+			return Common::kNoError;
+
+		return runtime.playDialogueLine(0x1066, "CLOAK_ATND");
+	}
+
+	if (!usedItemName.equalsIgnoreCase(kPaymentItemName))
+		return Common::kNoError;
+
+	StartupInteractionResult interaction;
+	if (runtime.startupScript().executeActionTag(kCleanClothesActionTag, interaction)) {
+		runtime.applyImmediateDialogueInteractionEffects(interaction);
+		runtime.queueDialogueInteractionIfNeeded(interaction);
+	}
+
+	return Common::kNoError;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/cloak_atnd_dialogue.h b/engines/harvester/npc/cloak_atnd_dialogue.h
new file mode 100644
index 00000000000..44a749eef07
--- /dev/null
+++ b/engines/harvester/npc/cloak_atnd_dialogue.h
@@ -0,0 +1,39 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_CLOAK_ATND_DIALOGUE_H
+#define HARVESTER_NPC_CLOAK_ATND_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class CloakAtndDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override {}
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_CLOAK_ATND_DIALOGUE_H


Commit: 9b2dc3c6922229aa8b0c8decb1bafc8a733d8877
    https://github.com/scummvm/scummvm/commit/9b2dc3c6922229aa8b0c8decb1bafc8a733d8877
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:05+02:00

Commit Message:
HARVESTER: Port dark woman dialogue

Changed paths:
  A engines/harvester/npc/dark_woman_dialogue.cpp
  A engines/harvester/npc/dark_woman_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 5e3ee542b8c..3ba59c315c8 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_cloak_atnd @ 0x2db80` into a dedicated `engines/harvester/npc/cloak_atnd_dialogue.*` handler.
-  - Native Cloak Attendant dialogue is a small no-item/item split: if no item is presented and `CLEANED_CLOTHES` is still clear, it plays `0x1066` for `CLOAK_ATND`; if `BARCASHFIVE` is presented, it dispatches `CLEAN_CLOTHES`.
-  - The engine now routes `CLOAK_ATND` through a concrete handler that mirrors the same flag check and action-tag dispatch, including queued interaction side effects.
+- Ported `handle_talk_to_dark_woman @ 0x33610` into a dedicated `engines/harvester/npc/dark_woman_dialogue.*` handler.
+  - Native Dark Woman dialogue is a short two-branch handler: presenting `HANDMIRROR` plays `0x4d03` for `DARK_WOMAN`, otherwise the one-shot `g_dark_woman_talk_state_block` branch clears itself and plays `0x4cd5`.
+  - The engine now routes `DARK_WOMAN` through a concrete handler with matching item handling and a local one-shot talk-state bit.
 
 ## Next Suggested Action
 
-1. Resume the remaining low-complexity visible handlers before returning to the bounded multi-branch ports.
-   - The remaining short visible handler before the evidence-heavy pass is `DARK_WOMAN`.
-2. After that short-handler pass, return to Boyle and the other larger hidden-topic handlers.
-   - After `DARK_WOMAN`, the next best bounded visible ports are the evidence-heavy but still linear handlers such as `BUSTER`, `BUTCHER`, `PHELPS`, `SWELL`, and `PARSONS`, before returning to Boyle.
+1. Continue the evidence-heavy but still linear visible handlers before returning to the bounded multi-branch ports.
+   - The next best candidates are `BUSTER`, `BUTCHER`, `PHELPS`, `SWELL`, and `PARSONS`, which mostly reuse the already-confirmed shared evidence state writes and straight no-item talk-state branches.
+2. After that visible evidence-handler pass, return to Boyle and the other larger hidden-topic handlers.
+   - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 6fff6708ed6..b770c7bc08d 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -41,6 +41,7 @@
 #include "harvester/npc/chessmaster_dialogue.h"
 #include "harvester/npc/cloak_atnd_dialogue.h"
 #include "harvester/npc/curator_dialogue.h"
+#include "harvester/npc/dark_woman_dialogue.h"
 #include "harvester/npc/dwayne_dialogue.h"
 #include "harvester/npc/dialogue_runtime.h"
 #include "harvester/npc/edna_dialogue.h"
@@ -354,6 +355,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new ChessmasterDialogueHandler());
 	_npcHandlers.push_back(new CloakAtndDialogueHandler());
 	_npcHandlers.push_back(new CuratorDialogueHandler());
+	_npcHandlers.push_back(new DarkWomanDialogueHandler());
 	_npcHandlers.push_back(new JimmyDialogueHandler());
 	_npcHandlers.push_back(new InquisitorDialogueHandler());
 	_npcHandlers.push_back(new WaspWomanDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index e5e67347fb8..8354c9fb0cd 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -12,6 +12,7 @@ MODULE_OBJS = \
 	fst_player.o \
 	metaengine.o \
 	npc/curator_dialogue.o \
+	npc/dark_woman_dialogue.o \
 	npc/dwayne_dialogue.o \
 	npc/edna_dialogue.o \
 	npc/fireman1_dialogue.o \
diff --git a/engines/harvester/npc/dark_woman_dialogue.cpp b/engines/harvester/npc/dark_woman_dialogue.cpp
new file mode 100644
index 00000000000..fd7fb5f4139
--- /dev/null
+++ b/engines/harvester/npc/dark_woman_dialogue.cpp
@@ -0,0 +1,52 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/dark_woman_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kHandMirrorItemName = "HANDMIRROR";
+
+} // End of namespace
+
+bool DarkWomanDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("DARK_WOMAN");
+}
+
+Common::Error DarkWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
+	if (usedItemName.equalsIgnoreCase(kHandMirrorItemName))
+		return runtime.playDialogueLine(0x4d03, "DARK_WOMAN");
+
+	if (!_state.talkStatePending)
+		return Common::kNoError;
+
+	_state.talkStatePending = false;
+	return runtime.playDialogueLine(0x4cd5, "DARK_WOMAN");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/dark_woman_dialogue.h b/engines/harvester/npc/dark_woman_dialogue.h
new file mode 100644
index 00000000000..bed729fd495
--- /dev/null
+++ b/engines/harvester/npc/dark_woman_dialogue.h
@@ -0,0 +1,46 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_DARK_WOMAN_DIALOGUE_H
+#define HARVESTER_NPC_DARK_WOMAN_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class DarkWomanDialogueHandler : public NpcDialogueHandler {
+public:
+	struct DarkWomanRoomDialogueState {
+		bool talkStatePending = true;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = DarkWomanRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	DarkWomanRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_DARK_WOMAN_DIALOGUE_H


Commit: a9549b8a15cc6b1f6bacdd344356475d3caa8967
    https://github.com/scummvm/scummvm/commit/a9549b8a15cc6b1f6bacdd344356475d3caa8967
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:06+02:00

Commit Message:
HARVESTER: Port buster dialogue

Changed paths:
  A engines/harvester/npc/buster_dialogue.cpp
  A engines/harvester/npc/buster_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 3ba59c315c8..51af52f7096 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_dark_woman @ 0x33610` into a dedicated `engines/harvester/npc/dark_woman_dialogue.*` handler.
-  - Native Dark Woman dialogue is a short two-branch handler: presenting `HANDMIRROR` plays `0x4d03` for `DARK_WOMAN`, otherwise the one-shot `g_dark_woman_talk_state_block` branch clears itself and plays `0x4cd5`.
-  - The engine now routes `DARK_WOMAN` through a concrete handler with matching item handling and a local one-shot talk-state bit.
+- Ported `handle_talk_to_buster @ 0x2b7c0` into a dedicated `engines/harvester/npc/buster_dialogue.*` handler.
+  - Native Buster dialogue is a straight evidence/no-item handler: the no-item path walks three one-shot intro lines (`0x163c`, `0x1828`, `0x18c5`) before settling on `0x1868`, while the item path handles Whaley/Herrill photo, casket-photo evidence, and note/checkbook evidence with the same shared `SHOWN_*` writes used elsewhere.
+  - The engine now routes `BUSTER` through a concrete handler with matching local talk-state and shared evidence-flag writes.
 
 ## Next Suggested Action
 
 1. Continue the evidence-heavy but still linear visible handlers before returning to the bounded multi-branch ports.
-   - The next best candidates are `BUSTER`, `BUTCHER`, `PHELPS`, `SWELL`, and `PARSONS`, which mostly reuse the already-confirmed shared evidence state writes and straight no-item talk-state branches.
+   - The next best candidates are `BUTCHER`, `PHELPS`, `SWELL`, `PARSONS`, and `WHALEY`, which mostly reuse the already-confirmed shared evidence state writes and straight no-item talk-state branches.
 2. After that visible evidence-handler pass, return to Boyle and the other larger hidden-topic handlers.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index b770c7bc08d..871363a6ab9 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -34,6 +34,7 @@
 #include "graphics/framelimiter.h"
 #include "harvester/npc/authority_dialogue.h"
 #include "harvester/npc/beggar_dialogue.h"
+#include "harvester/npc/buster_dialogue.h"
 #include "harvester/cft_font.h"
 #include "harvester/detection.h"
 #include "harvester/fst_player.h"
@@ -348,6 +349,7 @@ DialogueSystem::~DialogueSystem() {
 void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new AuthorityDialogueHandler());
 	_npcHandlers.push_back(new BeggarDialogueHandler());
+	_npcHandlers.push_back(new BusterDialogueHandler());
 	_npcHandlers.push_back(new DwayneDialogueHandler());
 	_npcHandlers.push_back(new EdnaDialogueHandler());
 	_npcHandlers.push_back(new Fireman1DialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 8354c9fb0cd..b9d2536915a 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -1,9 +1,10 @@
 MODULE := engines/harvester
 
-MODULE_OBJS = \
-	npc/authority_dialogue.o \
-	npc/beggar_dialogue.o \
-	cft_font.o \
+	MODULE_OBJS = \
+		npc/authority_dialogue.o \
+		npc/beggar_dialogue.o \
+		npc/buster_dialogue.o \
+		cft_font.o \
 	harvester.o \
 	npc/chessmaster_dialogue.o \
 	npc/cloak_atnd_dialogue.o \
diff --git a/engines/harvester/npc/buster_dialogue.cpp b/engines/harvester/npc/buster_dialogue.cpp
new file mode 100644
index 00000000000..67405a1adf8
--- /dev/null
+++ b/engines/harvester/npc/buster_dialogue.cpp
@@ -0,0 +1,75 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/buster_dialogue.h"
+
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool BusterDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("BUSTER");
+}
+
+Common::Error BusterDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			return runtime.playDialogueLine(0x187b, "BUSTER");
+		}
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+			return runtime.playDialogueLine(0x1884, "BUSTER");
+		}
+		if (usedItemName.equalsIgnoreCase("NOTE_CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+			return runtime.playDialogueLine(0x189b, "BUSTER");
+		}
+		return runtime.playDialogueLine(0x1875, "BUSTER");
+	}
+
+	if (_state.introPending) {
+		_state.introPending = false;
+		_state.secondIntroPending = true;
+		return runtime.playDialogueLine(0x163c, "BUSTER");
+	}
+	if (_state.secondIntroPending) {
+		_state.secondIntroPending = false;
+		_state.thirdIntroPending = true;
+		return runtime.playDialogueLine(0x1828, "BUSTER");
+	}
+	if (_state.thirdIntroPending) {
+		_state.thirdIntroPending = false;
+		return runtime.playDialogueLine(0x18c5, "BUSTER");
+	}
+
+	return runtime.playDialogueLine(0x1868, "BUSTER");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/buster_dialogue.h b/engines/harvester/npc/buster_dialogue.h
new file mode 100644
index 00000000000..c39b09c4eac
--- /dev/null
+++ b/engines/harvester/npc/buster_dialogue.h
@@ -0,0 +1,48 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_BUSTER_DIALOGUE_H
+#define HARVESTER_NPC_BUSTER_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class BusterDialogueHandler : public NpcDialogueHandler {
+public:
+	struct BusterRoomDialogueState {
+		bool introPending = true;
+		bool secondIntroPending = false;
+		bool thirdIntroPending = false;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = BusterRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	BusterRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_BUSTER_DIALOGUE_H


Commit: 7483ab72bc3e512a8d0f916553248c36d7fdcf13
    https://github.com/scummvm/scummvm/commit/7483ab72bc3e512a8d0f916553248c36d7fdcf13
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:06+02:00

Commit Message:
HARVESTER: Port butcher dialogue

Changed paths:
  A engines/harvester/npc/butcher_dialogue.cpp
  A engines/harvester/npc/butcher_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 51af52f7096..d43403ea0f0 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_buster @ 0x2b7c0` into a dedicated `engines/harvester/npc/buster_dialogue.*` handler.
-  - Native Buster dialogue is a straight evidence/no-item handler: the no-item path walks three one-shot intro lines (`0x163c`, `0x1828`, `0x18c5`) before settling on `0x1868`, while the item path handles Whaley/Herrill photo, casket-photo evidence, and note/checkbook evidence with the same shared `SHOWN_*` writes used elsewhere.
-  - The engine now routes `BUSTER` through a concrete handler with matching local talk-state and shared evidence-flag writes.
+- Ported `handle_talk_to_butcher @ 0x2a180` into a dedicated `engines/harvester/npc/butcher_dialogue.*` handler.
+  - Native Butcher dialogue is another linear evidence/no-item handler: the no-item path is a one-shot intro (`0x1902`) that falls through to `0x1a47`, while the item path handles Whaley/Herrill photo, casket-photo evidence, the dual-ledger case, note/checkbook evidence, and the special `MEAT_PERMISSION` reply.
+  - The engine now routes `BUTCHER` through a concrete handler with matching local talk-state and shared evidence-flag writes.
 
 ## Next Suggested Action
 
 1. Continue the evidence-heavy but still linear visible handlers before returning to the bounded multi-branch ports.
-   - The next best candidates are `BUTCHER`, `PHELPS`, `SWELL`, `PARSONS`, and `WHALEY`, which mostly reuse the already-confirmed shared evidence state writes and straight no-item talk-state branches.
+   - The next best candidates are `PHELPS`, `SWELL`, `PARSONS`, `WHALEY`, and `MCKNIGHT`, which mostly reuse the already-confirmed shared evidence state writes and straight no-item talk-state branches.
 2. After that visible evidence-handler pass, return to Boyle and the other larger hidden-topic handlers.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 871363a6ab9..fdd759ea653 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -35,6 +35,7 @@
 #include "harvester/npc/authority_dialogue.h"
 #include "harvester/npc/beggar_dialogue.h"
 #include "harvester/npc/buster_dialogue.h"
+#include "harvester/npc/butcher_dialogue.h"
 #include "harvester/cft_font.h"
 #include "harvester/detection.h"
 #include "harvester/fst_player.h"
@@ -350,6 +351,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new AuthorityDialogueHandler());
 	_npcHandlers.push_back(new BeggarDialogueHandler());
 	_npcHandlers.push_back(new BusterDialogueHandler());
+	_npcHandlers.push_back(new ButcherDialogueHandler());
 	_npcHandlers.push_back(new DwayneDialogueHandler());
 	_npcHandlers.push_back(new EdnaDialogueHandler());
 	_npcHandlers.push_back(new Fireman1DialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index b9d2536915a..cee1c2e1a93 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -1,10 +1,11 @@
 MODULE := engines/harvester
 
 	MODULE_OBJS = \
-		npc/authority_dialogue.o \
-		npc/beggar_dialogue.o \
-		npc/buster_dialogue.o \
-		cft_font.o \
+	npc/authority_dialogue.o \
+	npc/beggar_dialogue.o \
+	npc/buster_dialogue.o \
+	npc/butcher_dialogue.o \
+	cft_font.o \
 	harvester.o \
 	npc/chessmaster_dialogue.o \
 	npc/cloak_atnd_dialogue.o \
diff --git a/engines/harvester/npc/butcher_dialogue.cpp b/engines/harvester/npc/butcher_dialogue.cpp
new file mode 100644
index 00000000000..154e3d8b733
--- /dev/null
+++ b/engines/harvester/npc/butcher_dialogue.cpp
@@ -0,0 +1,73 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/butcher_dialogue.h"
+
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool ButcherDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("BUTCHER");
+}
+
+Common::Error ButcherDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
+	if (usedItemName.empty()) {
+		if (_state.talkStatePending) {
+			_state.talkStatePending = false;
+			return runtime.playDialogueLine(0x1902, "BUTCHER");
+		}
+		return runtime.playDialogueLine(0x1a47, "BUTCHER");
+	}
+
+	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+		return runtime.playDialogueLine(0x1a5a, "BUTCHER");
+	}
+	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+		return runtime.playDialogueLine(0x1a69, "BUTCHER");
+	}
+	if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+				usedItemName.equalsIgnoreCase("LEDGER2")) &&
+			runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+		return runtime.playDialogueLine(0x1a80, "BUTCHER");
+	}
+	if (usedItemName.equalsIgnoreCase("NOTE") ||
+			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+		return runtime.playDialogueLine(0x1a9e, "BUTCHER");
+	}
+	if (usedItemName.equalsIgnoreCase("MEAT_PERMISSION"))
+		return runtime.playDialogueLine(0x1ac4, "BUTCHER");
+
+	return runtime.playDialogueLine(0x1a54, "BUTCHER");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/butcher_dialogue.h b/engines/harvester/npc/butcher_dialogue.h
new file mode 100644
index 00000000000..ad7447d6445
--- /dev/null
+++ b/engines/harvester/npc/butcher_dialogue.h
@@ -0,0 +1,46 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_BUTCHER_DIALOGUE_H
+#define HARVESTER_NPC_BUTCHER_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class ButcherDialogueHandler : public NpcDialogueHandler {
+public:
+	struct ButcherRoomDialogueState {
+		bool talkStatePending = true;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = ButcherRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	ButcherRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_BUTCHER_DIALOGUE_H


Commit: aa5fe9f59dab1e290560b5b92c17e7fe23d8cec9
    https://github.com/scummvm/scummvm/commit/aa5fe9f59dab1e290560b5b92c17e7fe23d8cec9
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:06+02:00

Commit Message:
HARVESTER: Port phelps dialogue

Changed paths:
  A engines/harvester/npc/phelps_dialogue.cpp
  A engines/harvester/npc/phelps_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index d43403ea0f0..4b22ab35e66 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_butcher @ 0x2a180` into a dedicated `engines/harvester/npc/butcher_dialogue.*` handler.
-  - Native Butcher dialogue is another linear evidence/no-item handler: the no-item path is a one-shot intro (`0x1902`) that falls through to `0x1a47`, while the item path handles Whaley/Herrill photo, casket-photo evidence, the dual-ledger case, note/checkbook evidence, and the special `MEAT_PERMISSION` reply.
-  - The engine now routes `BUTCHER` through a concrete handler with matching local talk-state and shared evidence-flag writes.
+- Ported `handle_talk_to_phelps @ 0x29920` into a dedicated `engines/harvester/npc/phelps_dialogue.*` handler.
+  - Native Phelps dialogue is another linear evidence/no-item handler: the no-item path is a one-shot intro (`0x1b44`) that falls through to `0x1bf4`, while the item path handles the shared Whaley/casket/ledger/note evidence cluster plus the special `QUARTER` and `REWARD_MONEY` action-tag branches.
+  - The engine now routes `PHELPS` through a concrete handler with matching local talk-state, shared evidence-flag writes, and action-tag side effects.
 
 ## Next Suggested Action
 
 1. Continue the evidence-heavy but still linear visible handlers before returning to the bounded multi-branch ports.
-   - The next best candidates are `PHELPS`, `SWELL`, `PARSONS`, `WHALEY`, and `MCKNIGHT`, which mostly reuse the already-confirmed shared evidence state writes and straight no-item talk-state branches.
+   - The next best candidates are `SWELL`, `PARSONS`, `WHALEY`, and `MCKNIGHT`, which mostly reuse the already-confirmed shared evidence state writes and straight no-item talk-state branches.
 2. After that visible evidence-handler pass, return to Boyle and the other larger hidden-topic handlers.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index fdd759ea653..88833cbc595 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -61,6 +61,7 @@
 #include "harvester/npc/mom_dialogue.h"
 #include "harvester/npc/mother_dialogue.h"
 #include "harvester/npc/pastorelli_dialogue.h"
+#include "harvester/npc/phelps_dialogue.h"
 #include "harvester/npc/priest_dialogue.h"
 #include "harvester/npc/pta_mom_dialogue.h"
 #include "harvester/npc/stub_dialogue.h"
@@ -370,6 +371,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new MotherDialogueHandler());
 	_npcHandlers.push_back(new LodgeChefDialogueHandler());
 	_npcHandlers.push_back(new PastorelliDialogueHandler());
+	_npcHandlers.push_back(new PhelpsDialogueHandler());
 	_npcHandlers.push_back(new PriestDialogueHandler());
 	_npcHandlers.push_back(new ValetDialogueHandler());
 	_npcHandlers.push_back(new VetDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index cee1c2e1a93..ef2e19007c3 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -31,6 +31,7 @@ MODULE := engines/harvester
 	npc/mom_dialogue.o \
 	npc/mother_dialogue.o \
 	npc/pastorelli_dialogue.o \
+	npc/phelps_dialogue.o \
 	npc/priest_dialogue.o \
 	npc/pta_mom_dialogue.o \
 	npc/stub_dialogue.o \
diff --git a/engines/harvester/npc/phelps_dialogue.cpp b/engines/harvester/npc/phelps_dialogue.cpp
new file mode 100644
index 00000000000..e5344ab93f1
--- /dev/null
+++ b/engines/harvester/npc/phelps_dialogue.cpp
@@ -0,0 +1,96 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/phelps_dialogue.h"
+
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kGetPrnMagActionTag = "GET_PRN_MAG";
+static const char *const kUseRewardAtStoreActionTag = "USE_REWARD_AT_STORE";
+static const char *const kSetGenCuStuffActionTag = "SET_GEN_CU_STUFF";
+
+} // End of namespace
+
+bool PhelpsDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("PHELPS");
+}
+
+Common::Error PhelpsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
+	auto executeDialogueActionTag = [&](const char *tag) {
+		StartupInteractionResult interaction;
+		if (runtime.startupScript().executeActionTag(tag, interaction)) {
+			runtime.applyImmediateDialogueInteractionEffects(interaction);
+			runtime.queueDialogueInteractionIfNeeded(interaction);
+		}
+	};
+
+	if (usedItemName.empty()) {
+		if (_state.talkStatePending) {
+			_state.talkStatePending = false;
+			return runtime.playDialogueLine(0x1b44, "PHELPS");
+		}
+		return runtime.playDialogueLine(0x1bf4, "PHELPS");
+	}
+
+	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+		return runtime.playDialogueLine(0x1c0c, "PHELPS");
+	}
+	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+		return runtime.playDialogueLine(0x1c1e, "PHELPS");
+	}
+	if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+				usedItemName.equalsIgnoreCase("LEDGER2")) &&
+			runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+		return runtime.playDialogueLine(0x1c36, "PHELPS");
+	}
+	if (usedItemName.equalsIgnoreCase("NOTE") ||
+			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+		return runtime.playDialogueLine(0x1c3d, "PHELPS");
+	}
+	if (usedItemName.equalsIgnoreCase("QUARTER")) {
+		executeDialogueActionTag(kGetPrnMagActionTag);
+		return runtime.playDialogueLine(0x1ba4, "PHELPS");
+	}
+	if (usedItemName.equalsIgnoreCase("REWARD_MONEY")) {
+		executeDialogueActionTag(kUseRewardAtStoreActionTag);
+		executeDialogueActionTag(kSetGenCuStuffActionTag);
+		return Common::kNoError;
+	}
+
+	return runtime.playDialogueLine(0x1c06, "PHELPS");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/phelps_dialogue.h b/engines/harvester/npc/phelps_dialogue.h
new file mode 100644
index 00000000000..31a88b35d13
--- /dev/null
+++ b/engines/harvester/npc/phelps_dialogue.h
@@ -0,0 +1,46 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_PHELPS_DIALOGUE_H
+#define HARVESTER_NPC_PHELPS_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class PhelpsDialogueHandler : public NpcDialogueHandler {
+public:
+	struct PhelpsRoomDialogueState {
+		bool talkStatePending = true;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = PhelpsRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	PhelpsRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_PHELPS_DIALOGUE_H


Commit: f333587cc6735249e70c1d0ce4fcecc834221523
    https://github.com/scummvm/scummvm/commit/f333587cc6735249e70c1d0ce4fcecc834221523
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:07+02:00

Commit Message:
HARVESTER: Port swell dialogue

Changed paths:
  A engines/harvester/npc/swell_dialogue.cpp
  A engines/harvester/npc/swell_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 4b22ab35e66..86a4d21ec65 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_phelps @ 0x29920` into a dedicated `engines/harvester/npc/phelps_dialogue.*` handler.
-  - Native Phelps dialogue is another linear evidence/no-item handler: the no-item path is a one-shot intro (`0x1b44`) that falls through to `0x1bf4`, while the item path handles the shared Whaley/casket/ledger/note evidence cluster plus the special `QUARTER` and `REWARD_MONEY` action-tag branches.
-  - The engine now routes `PHELPS` through a concrete handler with matching local talk-state, shared evidence-flag writes, and action-tag side effects.
+- Ported `handle_talk_to_swell @ 0x29110` into a dedicated `engines/harvester/npc/swell_dialogue.*` handler.
+  - Native Swell dialogue is another linear evidence/no-item handler: the no-item path is a one-shot intro (`0xf61`) that falls through to `0xfb3`, while the item path handles the shared Whaley/casket/ledger/note evidence cluster.
+  - The engine now routes `SWELL` through a concrete handler with matching local talk-state and shared evidence-flag writes.
 
 ## Next Suggested Action
 
 1. Continue the evidence-heavy but still linear visible handlers before returning to the bounded multi-branch ports.
-   - The next best candidates are `SWELL`, `PARSONS`, `WHALEY`, and `MCKNIGHT`, which mostly reuse the already-confirmed shared evidence state writes and straight no-item talk-state branches.
+   - The next best candidates are `PARSONS`, `WHALEY`, and `MCKNIGHT`, which mostly reuse the already-confirmed shared evidence state writes and straight no-item talk-state branches.
 2. After that visible evidence-handler pass, return to Boyle and the other larger hidden-topic handlers.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 88833cbc595..ed953fa097c 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -65,6 +65,7 @@
 #include "harvester/npc/priest_dialogue.h"
 #include "harvester/npc/pta_mom_dialogue.h"
 #include "harvester/npc/stub_dialogue.h"
+#include "harvester/npc/swell_dialogue.h"
 #include "harvester/npc/valet_dialogue.h"
 #include "harvester/npc/vet_dialogue.h"
 #include "harvester/npc/wasp_woman_dialogue.h"
@@ -375,6 +376,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new PriestDialogueHandler());
 	_npcHandlers.push_back(new ValetDialogueHandler());
 	_npcHandlers.push_back(new VetDialogueHandler());
+	_npcHandlers.push_back(new SwellDialogueHandler());
 	_npcHandlers.push_back(new HankDialogueHandler());
 	_npcHandlers.push_back(new HerrillDialogueHandler());
 	_npcHandlers.push_back(new JohnsonDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index ef2e19007c3..427fc25d053 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -35,6 +35,7 @@ MODULE := engines/harvester
 	npc/priest_dialogue.o \
 	npc/pta_mom_dialogue.o \
 	npc/stub_dialogue.o \
+	npc/swell_dialogue.o \
 	npc/valet_dialogue.o \
 	npc/vet_dialogue.o \
 	npc/wasp_woman_dialogue.o \
diff --git a/engines/harvester/npc/swell_dialogue.cpp b/engines/harvester/npc/swell_dialogue.cpp
new file mode 100644
index 00000000000..415fc058d97
--- /dev/null
+++ b/engines/harvester/npc/swell_dialogue.cpp
@@ -0,0 +1,71 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/swell_dialogue.h"
+
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool SwellDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("SWELL");
+}
+
+Common::Error SwellDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
+	if (usedItemName.empty()) {
+		if (_state.talkStatePending) {
+			_state.talkStatePending = false;
+			return runtime.playDialogueLine(0xf61, "SWELL");
+		}
+		return runtime.playDialogueLine(0xfb3, "SWELL");
+	}
+
+	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+		return runtime.playDialogueLine(0xfc6, "SWELL");
+	}
+	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+		return runtime.playDialogueLine(0xfcd, "SWELL");
+	}
+	if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+				usedItemName.equalsIgnoreCase("LEDGER2")) &&
+			runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+		return runtime.playDialogueLine(0xfd4, "SWELL");
+	}
+	if (usedItemName.equalsIgnoreCase("NOTE") ||
+			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+		return runtime.playDialogueLine(0xfe6, "SWELL");
+	}
+
+	return runtime.playDialogueLine(0xfc0, "SWELL");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/swell_dialogue.h b/engines/harvester/npc/swell_dialogue.h
new file mode 100644
index 00000000000..419b8996396
--- /dev/null
+++ b/engines/harvester/npc/swell_dialogue.h
@@ -0,0 +1,46 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_SWELL_DIALOGUE_H
+#define HARVESTER_NPC_SWELL_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class SwellDialogueHandler : public NpcDialogueHandler {
+public:
+	struct SwellRoomDialogueState {
+		bool talkStatePending = true;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = SwellRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	SwellRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_SWELL_DIALOGUE_H


Commit: c17925e8702658e731913205ea613c4e228ca55a
    https://github.com/scummvm/scummvm/commit/c17925e8702658e731913205ea613c4e228ca55a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:07+02:00

Commit Message:
HARVESTER: Port parsons dialogue

Changed paths:
  A engines/harvester/npc/parsons_dialogue.cpp
  A engines/harvester/npc/parsons_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 86a4d21ec65..6fd1032cb12 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_swell @ 0x29110` into a dedicated `engines/harvester/npc/swell_dialogue.*` handler.
-  - Native Swell dialogue is another linear evidence/no-item handler: the no-item path is a one-shot intro (`0xf61`) that falls through to `0xfb3`, while the item path handles the shared Whaley/casket/ledger/note evidence cluster.
-  - The engine now routes `SWELL` through a concrete handler with matching local talk-state and shared evidence-flag writes.
+- Ported `handle_talk_to_parsons @ 0x397c0` into a dedicated `engines/harvester/npc/parsons_dialogue.*` handler.
+  - Native Parsons dialogue is another linear evidence/no-item handler: the no-item path is a one-shot intro (`0xdcc`) that falls through to `0xe65`, while the item path handles the shared Whaley/casket/ledger/note evidence cluster plus the TV deed branch.
+  - The engine now routes `PARSONS` through a concrete handler with matching local talk-state and shared evidence-flag writes.
 
 ## Next Suggested Action
 
 1. Continue the evidence-heavy but still linear visible handlers before returning to the bounded multi-branch ports.
-   - The next best candidates are `PARSONS`, `WHALEY`, and `MCKNIGHT`, which mostly reuse the already-confirmed shared evidence state writes and straight no-item talk-state branches.
+   - The next best candidates are `WHALEY` and `MCKNIGHT`, which mostly reuse the already-confirmed shared evidence state writes and straight no-item talk-state branches.
 2. After that visible evidence-handler pass, return to Boyle and the other larger hidden-topic handlers.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index ed953fa097c..feb3aa35879 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -60,6 +60,7 @@
 #include "harvester/npc/madam_dialogue.h"
 #include "harvester/npc/mom_dialogue.h"
 #include "harvester/npc/mother_dialogue.h"
+#include "harvester/npc/parsons_dialogue.h"
 #include "harvester/npc/pastorelli_dialogue.h"
 #include "harvester/npc/phelps_dialogue.h"
 #include "harvester/npc/priest_dialogue.h"
@@ -371,6 +372,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new MomDialogueHandler());
 	_npcHandlers.push_back(new MotherDialogueHandler());
 	_npcHandlers.push_back(new LodgeChefDialogueHandler());
+	_npcHandlers.push_back(new ParsonsDialogueHandler());
 	_npcHandlers.push_back(new PastorelliDialogueHandler());
 	_npcHandlers.push_back(new PhelpsDialogueHandler());
 	_npcHandlers.push_back(new PriestDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 427fc25d053..0200492833c 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -30,6 +30,7 @@ MODULE := engines/harvester
 	npc/madam_dialogue.o \
 	npc/mom_dialogue.o \
 	npc/mother_dialogue.o \
+	npc/parsons_dialogue.o \
 	npc/pastorelli_dialogue.o \
 	npc/phelps_dialogue.o \
 	npc/priest_dialogue.o \
diff --git a/engines/harvester/npc/parsons_dialogue.cpp b/engines/harvester/npc/parsons_dialogue.cpp
new file mode 100644
index 00000000000..4f8f899fb71
--- /dev/null
+++ b/engines/harvester/npc/parsons_dialogue.cpp
@@ -0,0 +1,76 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/parsons_dialogue.h"
+
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool ParsonsDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("PARSONS");
+}
+
+Common::Error ParsonsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
+	if (usedItemName.empty()) {
+		if (_state.talkStatePending) {
+			_state.talkStatePending = false;
+			return runtime.playDialogueLine(0xdcc, "PARSONS");
+		}
+		return runtime.playDialogueLine(0xe65, "PARSONS");
+	}
+
+	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+		return runtime.playDialogueLine(0xe77, "PARSONS");
+	}
+	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+		return runtime.playDialogueLine(0xe7e, "PARSONS");
+	}
+	if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+				usedItemName.equalsIgnoreCase("LEDGER2")) &&
+			runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+		return runtime.playDialogueLine(0xe8e, "PARSONS");
+	}
+	if (usedItemName.equalsIgnoreCase("NOTE") ||
+			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+		return runtime.playDialogueLine(0xe95, "PARSONS");
+	}
+	if (usedItemName.equalsIgnoreCase("TV_DEED") ||
+			usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceSheriffOwns, true);
+		return runtime.playDialogueLine(0xea4, "PARSONS");
+	}
+
+	return runtime.playDialogueLine(0xe71, "PARSONS");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/parsons_dialogue.h b/engines/harvester/npc/parsons_dialogue.h
new file mode 100644
index 00000000000..488112718cb
--- /dev/null
+++ b/engines/harvester/npc/parsons_dialogue.h
@@ -0,0 +1,46 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_PARSONS_DIALOGUE_H
+#define HARVESTER_NPC_PARSONS_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class ParsonsDialogueHandler : public NpcDialogueHandler {
+public:
+	struct ParsonsRoomDialogueState {
+		bool talkStatePending = true;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = ParsonsRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	ParsonsRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_PARSONS_DIALOGUE_H


Commit: 3472b767000c4a586b8839d6510bd2d64962d2b4
    https://github.com/scummvm/scummvm/commit/3472b767000c4a586b8839d6510bd2d64962d2b4
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:07+02:00

Commit Message:
HARVESTER: Port whaley dialogue

Changed paths:
  A engines/harvester/npc/whaley_dialogue.cpp
  A engines/harvester/npc/whaley_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 6fd1032cb12..02c94d0c349 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_parsons @ 0x397c0` into a dedicated `engines/harvester/npc/parsons_dialogue.*` handler.
-  - Native Parsons dialogue is another linear evidence/no-item handler: the no-item path is a one-shot intro (`0xdcc`) that falls through to `0xe65`, while the item path handles the shared Whaley/casket/ledger/note evidence cluster plus the TV deed branch.
-  - The engine now routes `PARSONS` through a concrete handler with matching local talk-state and shared evidence-flag writes.
+- Ported `handle_talk_to_whaley @ 0x23ec0` into a dedicated `engines/harvester/npc/whaley_dialogue.*` handler.
+  - Native Whaley dialogue is another linear evidence/no-item handler with one pre-empting plot bark: `BUSTED_SCREWING_MIDGAME` forces `0x143b`, the no-item path is a one-shot intro (`0x12b6`) that falls through to `0x1385`, and the item path handles the shared casket/ledger/note evidence cluster while leaving Whaley’s own photo branch as a plain `0x1411` reply.
+  - The engine now routes `WHALEY` through a concrete handler with matching local talk-state and shared evidence-flag writes only where the native handler actually performs them.
 
 ## Next Suggested Action
 
 1. Continue the evidence-heavy but still linear visible handlers before returning to the bounded multi-branch ports.
-   - The next best candidates are `WHALEY` and `MCKNIGHT`, which mostly reuse the already-confirmed shared evidence state writes and straight no-item talk-state branches.
+   - The next best candidate in the visible linear bucket is `MCKNIGHT`; after that, the remaining work shifts back toward more stateful or hidden-topic handlers.
 2. After that visible evidence-handler pass, return to Boyle and the other larger hidden-topic handlers.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index feb3aa35879..8a43a0ba896 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -69,6 +69,7 @@
 #include "harvester/npc/swell_dialogue.h"
 #include "harvester/npc/valet_dialogue.h"
 #include "harvester/npc/vet_dialogue.h"
+#include "harvester/npc/whaley_dialogue.h"
 #include "harvester/npc/wasp_woman_dialogue.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
@@ -379,6 +380,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new ValetDialogueHandler());
 	_npcHandlers.push_back(new VetDialogueHandler());
 	_npcHandlers.push_back(new SwellDialogueHandler());
+	_npcHandlers.push_back(new WhaleyDialogueHandler());
 	_npcHandlers.push_back(new HankDialogueHandler());
 	_npcHandlers.push_back(new HerrillDialogueHandler());
 	_npcHandlers.push_back(new JohnsonDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 0200492833c..5c081e0e36c 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -39,6 +39,7 @@ MODULE := engines/harvester
 	npc/swell_dialogue.o \
 	npc/valet_dialogue.o \
 	npc/vet_dialogue.o \
+	npc/whaley_dialogue.o \
 	npc/wasp_woman_dialogue.o \
 	resources.o \
 	runtime_entity.o \
diff --git a/engines/harvester/npc/whaley_dialogue.cpp b/engines/harvester/npc/whaley_dialogue.cpp
new file mode 100644
index 00000000000..ecd19f85325
--- /dev/null
+++ b/engines/harvester/npc/whaley_dialogue.cpp
@@ -0,0 +1,72 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/whaley_dialogue.h"
+
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool WhaleyDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("WHALEY");
+}
+
+Common::Error WhaleyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
+	if (runtime.startupScript().getFlagValue("BUSTED_SCREWING_MIDGAME"))
+		return runtime.playDialogueLine(0x143b, "WHALEY");
+
+	if (usedItemName.empty()) {
+		if (_state.talkStatePending) {
+			_state.talkStatePending = false;
+			return runtime.playDialogueLine(0x12b6, "WHALEY");
+		}
+		return runtime.playDialogueLine(0x1385, "WHALEY");
+	}
+
+	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL"))
+		return runtime.playDialogueLine(0x1411, "WHALEY");
+	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+		return runtime.playDialogueLine(0x1417, "WHALEY");
+	}
+	if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+				usedItemName.equalsIgnoreCase("LEDGER2")) &&
+			runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+		return runtime.playDialogueLine(0x14a4, "WHALEY");
+	}
+	if (usedItemName.equalsIgnoreCase("NOTE") ||
+			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+		return runtime.playDialogueLine(0x14c7, "WHALEY");
+	}
+
+	return runtime.playDialogueLine(0x140b, "WHALEY");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/whaley_dialogue.h b/engines/harvester/npc/whaley_dialogue.h
new file mode 100644
index 00000000000..a5d15c98de5
--- /dev/null
+++ b/engines/harvester/npc/whaley_dialogue.h
@@ -0,0 +1,46 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_WHALEY_DIALOGUE_H
+#define HARVESTER_NPC_WHALEY_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class WhaleyDialogueHandler : public NpcDialogueHandler {
+public:
+	struct WhaleyRoomDialogueState {
+		bool talkStatePending = true;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = WhaleyRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	WhaleyRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_WHALEY_DIALOGUE_H


Commit: 9ac15b4a4bc079c68b084f7282b7d2c774bc1032
    https://github.com/scummvm/scummvm/commit/9ac15b4a4bc079c68b084f7282b7d2c774bc1032
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:08+02:00

Commit Message:
HARVESTER: Port ryder dialogue

Changed paths:
  A engines/harvester/npc/ryder_dialogue.cpp
  A engines/harvester/npc/ryder_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 02c94d0c349..12d2be4d482 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_whaley @ 0x23ec0` into a dedicated `engines/harvester/npc/whaley_dialogue.*` handler.
-  - Native Whaley dialogue is another linear evidence/no-item handler with one pre-empting plot bark: `BUSTED_SCREWING_MIDGAME` forces `0x143b`, the no-item path is a one-shot intro (`0x12b6`) that falls through to `0x1385`, and the item path handles the shared casket/ledger/note evidence cluster while leaving Whaley’s own photo branch as a plain `0x1411` reply.
-  - The engine now routes `WHALEY` through a concrete handler with matching local talk-state and shared evidence-flag writes only where the native handler actually performs them.
+- Ported `handle_talk_to_ryder @ 0x38c80` into a dedicated `engines/harvester/npc/ryder_dialogue.*` handler.
+  - Native Ryder dialogue is a compact stateful visible handler: `BURNED_TV_STATION` switches him outright to the `RYDER_TV_OUT` bark at `0x5a6`, otherwise the no-item path is a one-shot intro (`0x477`) that falls through to `0x4cf`, and the item path handles Whaley/casket/note evidence only.
+  - The engine now routes both `RYDER` and `RYDER_TV_OUT` through a concrete handler with matching speaker selection, local talk-state, and shared evidence-flag writes.
 
 ## Next Suggested Action
 
 1. Continue the evidence-heavy but still linear visible handlers before returning to the bounded multi-branch ports.
-   - The next best candidate in the visible linear bucket is `MCKNIGHT`; after that, the remaining work shifts back toward more stateful or hidden-topic handlers.
+   - The next best candidate in the visible linear bucket is `MCKNIGHT`; after that, the remaining work shifts back toward more stateful or hidden-topic handlers like `LOOMIS`, `MOYNAHAN`, `MR_POTTS`, and Boyle.
 2. After that visible evidence-handler pass, return to Boyle and the other larger hidden-topic handlers.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 8a43a0ba896..f09baaa6549 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -65,6 +65,7 @@
 #include "harvester/npc/phelps_dialogue.h"
 #include "harvester/npc/priest_dialogue.h"
 #include "harvester/npc/pta_mom_dialogue.h"
+#include "harvester/npc/ryder_dialogue.h"
 #include "harvester/npc/stub_dialogue.h"
 #include "harvester/npc/swell_dialogue.h"
 #include "harvester/npc/valet_dialogue.h"
@@ -377,6 +378,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new PastorelliDialogueHandler());
 	_npcHandlers.push_back(new PhelpsDialogueHandler());
 	_npcHandlers.push_back(new PriestDialogueHandler());
+	_npcHandlers.push_back(new RyderDialogueHandler());
 	_npcHandlers.push_back(new ValetDialogueHandler());
 	_npcHandlers.push_back(new VetDialogueHandler());
 	_npcHandlers.push_back(new SwellDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 5c081e0e36c..ea8f89404c9 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -35,6 +35,7 @@ MODULE := engines/harvester
 	npc/phelps_dialogue.o \
 	npc/priest_dialogue.o \
 	npc/pta_mom_dialogue.o \
+	npc/ryder_dialogue.o \
 	npc/stub_dialogue.o \
 	npc/swell_dialogue.o \
 	npc/valet_dialogue.o \
diff --git a/engines/harvester/npc/ryder_dialogue.cpp b/engines/harvester/npc/ryder_dialogue.cpp
new file mode 100644
index 00000000000..d7d6ed87f4b
--- /dev/null
+++ b/engines/harvester/npc/ryder_dialogue.cpp
@@ -0,0 +1,71 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/ryder_dialogue.h"
+
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool RyderDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("RYDER") || npcName.equalsIgnoreCase("RYDER_TV_OUT");
+}
+
+Common::Error RyderDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
+	const bool burnedTvStation = runtime.startupScript().getFlagValue("BURNED_TV_STATION");
+	const char *speakerId = burnedTvStation ? "RYDER_TV_OUT" : "RYDER";
+
+	if (burnedTvStation)
+		return runtime.playDialogueLine(0x5a6, speakerId);
+
+	if (usedItemName.empty()) {
+		if (_state.talkStatePending) {
+			_state.talkStatePending = false;
+			return runtime.playDialogueLine(0x477, speakerId);
+		}
+		return runtime.playDialogueLine(0x4cf, speakerId);
+	}
+
+	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+		return runtime.playDialogueLine(0x52d, speakerId);
+	}
+	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+		return runtime.playDialogueLine(0x54f, speakerId);
+	}
+	if (usedItemName.equalsIgnoreCase("NOTE") ||
+			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+		return runtime.playDialogueLine(0x556, speakerId);
+	}
+
+	return runtime.playDialogueLine(0x4db, speakerId);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/ryder_dialogue.h b/engines/harvester/npc/ryder_dialogue.h
new file mode 100644
index 00000000000..52c3b24d1c6
--- /dev/null
+++ b/engines/harvester/npc/ryder_dialogue.h
@@ -0,0 +1,46 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_RYDER_DIALOGUE_H
+#define HARVESTER_NPC_RYDER_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class RyderDialogueHandler : public NpcDialogueHandler {
+public:
+	struct RyderRoomDialogueState {
+		bool talkStatePending = true;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = RyderRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	RyderRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_RYDER_DIALOGUE_H


Commit: a2563badd9797118caa21230474dd26748aca524
    https://github.com/scummvm/scummvm/commit/a2563badd9797118caa21230474dd26748aca524
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:08+02:00

Commit Message:
HARVESTER: Port mcknight dialogue

Changed paths:
  A engines/harvester/npc/mcknight_dialogue.cpp
  A engines/harvester/npc/mcknight_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 12d2be4d482..7409e2cca36 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_ryder @ 0x38c80` into a dedicated `engines/harvester/npc/ryder_dialogue.*` handler.
-  - Native Ryder dialogue is a compact stateful visible handler: `BURNED_TV_STATION` switches him outright to the `RYDER_TV_OUT` bark at `0x5a6`, otherwise the no-item path is a one-shot intro (`0x477`) that falls through to `0x4cf`, and the item path handles Whaley/casket/note evidence only.
-  - The engine now routes both `RYDER` and `RYDER_TV_OUT` through a concrete handler with matching speaker selection, local talk-state, and shared evidence-flag writes.
+- Ported `handle_talk_to_mcknight @ 0x2c770` into a dedicated `engines/harvester/npc/mcknight_dialogue.*` handler.
+  - Native McKnight dialogue is another compact visible handler: `LOOK_SAFE_2ND` forces `0x15d0`, `LOOK_SAFE_1ST` reads an additional unresolved gate before `0x15c9`, the no-item path is a one-shot intro (`0x1587`) that falls through to `0x15e4`, and the item path handles the shared Whaley/casket/ledger/note evidence cluster.
+  - The engine now routes `MCKNIGHT` through a concrete handler with matching talk-state and evidence writes; the extra `LOOK_SAFE_1ST` gate remains locally represented until its gameplay-side setter is recovered.
 
 ## Next Suggested Action
 
-1. Continue the evidence-heavy but still linear visible handlers before returning to the bounded multi-branch ports.
-   - The next best candidate in the visible linear bucket is `MCKNIGHT`; after that, the remaining work shifts back toward more stateful or hidden-topic handlers like `LOOMIS`, `MOYNAHAN`, `MR_POTTS`, and Boyle.
+1. Move back to the more stateful visible handlers now that the compact visible bucket is mostly cleared.
+   - The next best candidates are `LOOMIS`, `MOYNAHAN`, `MR_POTTS`, and `MRS_POTTS`, followed by the larger hidden-topic handlers such as Boyle.
 2. After that visible evidence-handler pass, return to Boyle and the other larger hidden-topic handlers.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index f09baaa6549..57bf7ece4eb 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -56,6 +56,7 @@
 #include "harvester/npc/jimmy_dialogue.h"
 #include "harvester/npc/johnson_dialogue.h"
 #include "harvester/npc/lodge_chef_dialogue.h"
+#include "harvester/npc/mcknight_dialogue.h"
 #include "harvester/npc/maint_man_dialogue.h"
 #include "harvester/npc/madam_dialogue.h"
 #include "harvester/npc/mom_dialogue.h"
@@ -369,6 +370,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new InquisitorDialogueHandler());
 	_npcHandlers.push_back(new WaspWomanDialogueHandler());
 	_npcHandlers.push_back(new GladiatorDialogueHandler());
+	_npcHandlers.push_back(new McknightDialogueHandler());
 	_npcHandlers.push_back(new MaintManDialogueHandler());
 	_npcHandlers.push_back(new MadamDialogueHandler());
 	_npcHandlers.push_back(new MomDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index ea8f89404c9..43903b03d82 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -26,6 +26,7 @@ MODULE := engines/harvester
 	npc/jimmy_dialogue.o \
 	npc/johnson_dialogue.o \
 	npc/lodge_chef_dialogue.o \
+	npc/mcknight_dialogue.o \
 	npc/maint_man_dialogue.o \
 	npc/madam_dialogue.o \
 	npc/mom_dialogue.o \
diff --git a/engines/harvester/npc/mcknight_dialogue.cpp b/engines/harvester/npc/mcknight_dialogue.cpp
new file mode 100644
index 00000000000..e50b439e643
--- /dev/null
+++ b/engines/harvester/npc/mcknight_dialogue.cpp
@@ -0,0 +1,77 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/mcknight_dialogue.h"
+
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool McknightDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("MCKNIGHT");
+}
+
+Common::Error McknightDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
+	if (runtime.startupScript().getFlagValue("LOOK_SAFE_2ND"))
+		return runtime.playDialogueLine(0x15d0, "MCKNIGHT");
+
+	if (runtime.startupScript().getFlagValue("LOOK_SAFE_1ST") && !_state.lookSafeFirstLineGate)
+		return runtime.playDialogueLine(0x15c9, "MCKNIGHT");
+
+	if (usedItemName.empty()) {
+		if (_state.talkStatePending) {
+			_state.talkStatePending = false;
+			return runtime.playDialogueLine(0x1587, "MCKNIGHT");
+		}
+		return runtime.playDialogueLine(0x15e4, "MCKNIGHT");
+	}
+
+	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+		return runtime.playDialogueLine(0x15ea, "MCKNIGHT");
+	}
+	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+		return runtime.playDialogueLine(0x15f2, "MCKNIGHT");
+	}
+	if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+				usedItemName.equalsIgnoreCase("LEDGER2")) &&
+			runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+		return runtime.playDialogueLine(0x1611, "MCKNIGHT");
+	}
+	if (usedItemName.equalsIgnoreCase("NOTE") ||
+			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+		return runtime.playDialogueLine(0x161b, "MCKNIGHT");
+	}
+
+	return runtime.playDialogueLine(0x15de, "MCKNIGHT");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/mcknight_dialogue.h b/engines/harvester/npc/mcknight_dialogue.h
new file mode 100644
index 00000000000..0d3503cd82f
--- /dev/null
+++ b/engines/harvester/npc/mcknight_dialogue.h
@@ -0,0 +1,47 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_MCKNIGHT_DIALOGUE_H
+#define HARVESTER_NPC_MCKNIGHT_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class McknightDialogueHandler : public NpcDialogueHandler {
+public:
+	struct McknightRoomDialogueState {
+		bool talkStatePending = true;
+		bool lookSafeFirstLineGate = false;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = McknightRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	McknightRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_MCKNIGHT_DIALOGUE_H


Commit: 350e3fd48b9f3a294f878f2dee5bf6438e162b90
    https://github.com/scummvm/scummvm/commit/350e3fd48b9f3a294f878f2dee5bf6438e162b90
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:08+02:00

Commit Message:
HARVESTER: Port librarian dialogue

Changed paths:
  A engines/harvester/npc/librarian_dialogue.cpp
  A engines/harvester/npc/librarian_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 7409e2cca36..caa8dd7ce9a 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_mcknight @ 0x2c770` into a dedicated `engines/harvester/npc/mcknight_dialogue.*` handler.
-  - Native McKnight dialogue is another compact visible handler: `LOOK_SAFE_2ND` forces `0x15d0`, `LOOK_SAFE_1ST` reads an additional unresolved gate before `0x15c9`, the no-item path is a one-shot intro (`0x1587`) that falls through to `0x15e4`, and the item path handles the shared Whaley/casket/ledger/note evidence cluster.
-  - The engine now routes `MCKNIGHT` through a concrete handler with matching talk-state and evidence writes; the extra `LOOK_SAFE_1ST` gate remains locally represented until its gameplay-side setter is recovered.
+- Ported `handle_talk_to_librarian @ 0x28f40` into a dedicated `engines/harvester/npc/librarian_dialogue.*` handler.
+  - Native Librarian dialogue is a small inventory gate: if the player already has `CLUE` or `CAINBOOK`, or explicitly presents `CAINBOOK`, she grants `CLUE` to inventory and plays `0x1db1`; otherwise she falls through to the default `0x1d5b` response.
+  - The engine now routes `LIBRARIAN` through a concrete handler with the same inventory check and `CLUE` handoff behavior.
 
 ## Next Suggested Action
 
 1. Move back to the more stateful visible handlers now that the compact visible bucket is mostly cleared.
-   - The next best candidates are `LOOMIS`, `MOYNAHAN`, `MR_POTTS`, and `MRS_POTTS`, followed by the larger hidden-topic handlers such as Boyle.
+   - The next bounded cleanup candidates before the largest trees are `NUDE_MAN`, `MEMB_DIR`, and `KARIN`, followed by `LOOMIS`, `MOYNAHAN`, `MR_POTTS`, and `MRS_POTTS`.
 2. After that visible evidence-handler pass, return to Boyle and the other larger hidden-topic handlers.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 57bf7ece4eb..f83589fcb6a 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -55,6 +55,7 @@
 #include "harvester/npc/inquisitor_dialogue.h"
 #include "harvester/npc/jimmy_dialogue.h"
 #include "harvester/npc/johnson_dialogue.h"
+#include "harvester/npc/librarian_dialogue.h"
 #include "harvester/npc/lodge_chef_dialogue.h"
 #include "harvester/npc/mcknight_dialogue.h"
 #include "harvester/npc/maint_man_dialogue.h"
@@ -370,6 +371,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new InquisitorDialogueHandler());
 	_npcHandlers.push_back(new WaspWomanDialogueHandler());
 	_npcHandlers.push_back(new GladiatorDialogueHandler());
+	_npcHandlers.push_back(new LibrarianDialogueHandler());
 	_npcHandlers.push_back(new McknightDialogueHandler());
 	_npcHandlers.push_back(new MaintManDialogueHandler());
 	_npcHandlers.push_back(new MadamDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 43903b03d82..d35e1171917 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -25,6 +25,7 @@ MODULE := engines/harvester
 	npc/inquisitor_dialogue.o \
 	npc/jimmy_dialogue.o \
 	npc/johnson_dialogue.o \
+	npc/librarian_dialogue.o \
 	npc/lodge_chef_dialogue.o \
 	npc/mcknight_dialogue.o \
 	npc/maint_man_dialogue.o \
diff --git a/engines/harvester/npc/librarian_dialogue.cpp b/engines/harvester/npc/librarian_dialogue.cpp
new file mode 100644
index 00000000000..53063b721f4
--- /dev/null
+++ b/engines/harvester/npc/librarian_dialogue.cpp
@@ -0,0 +1,57 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/librarian_dialogue.h"
+
+#include "common/array.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool LibrarianDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("LIBRARIAN");
+}
+
+Common::Error LibrarianDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
+	auto hasInventoryItem = [&](const char *objectName) {
+		Common::Array<StartupObjectRecord> inventoryObjects;
+		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
+		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
+				return true;
+		}
+
+		return false;
+	};
+
+	if (!hasInventoryItem("CLUE") && !hasInventoryItem("CAINBOOK")) {
+		if (usedItemName.empty() || !usedItemName.equalsIgnoreCase("CAINBOOK"))
+			return runtime.playDialogueLine(0x1d5b, "LIBRARIAN");
+	}
+
+	(void)runtime.startupScript().addRuntimeObjectToInventory("CLUE");
+	return runtime.playDialogueLine(0x1db1, "LIBRARIAN");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/librarian_dialogue.h b/engines/harvester/npc/librarian_dialogue.h
new file mode 100644
index 00000000000..fe4a0d4ec14
--- /dev/null
+++ b/engines/harvester/npc/librarian_dialogue.h
@@ -0,0 +1,39 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_LIBRARIAN_DIALOGUE_H
+#define HARVESTER_NPC_LIBRARIAN_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class LibrarianDialogueHandler : public NpcDialogueHandler {
+public:
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override {}
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_LIBRARIAN_DIALOGUE_H


Commit: ccbddbc0a89165a550f67d2794213384a58cf116
    https://github.com/scummvm/scummvm/commit/ccbddbc0a89165a550f67d2794213384a58cf116
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:09+02:00

Commit Message:
HARVESTER: Port nude man dialogue

Changed paths:
  A engines/harvester/npc/nude_man_dialogue.cpp
  A engines/harvester/npc/nude_man_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index caa8dd7ce9a..98bf6629721 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_librarian @ 0x28f40` into a dedicated `engines/harvester/npc/librarian_dialogue.*` handler.
-  - Native Librarian dialogue is a small inventory gate: if the player already has `CLUE` or `CAINBOOK`, or explicitly presents `CAINBOOK`, she grants `CLUE` to inventory and plays `0x1db1`; otherwise she falls through to the default `0x1d5b` response.
-  - The engine now routes `LIBRARIAN` through a concrete handler with the same inventory check and `CLUE` handoff behavior.
+- Ported `handle_talk_to_nude_man @ 0x2d9b0` into a dedicated `engines/harvester/npc/nude_man_dialogue.*` handler.
+  - Native Nude Man dialogue is a compact three-way branch: `DAY_FLAG` forces `0x65c` for `NUDE_MAN`, the first non-day conversation clears the local talk-state block and plays `0x606` for `PC`, and later non-day conversations fall through to `0x651` for `PC`.
+  - The engine now routes `NUDE_MAN` through a concrete handler with matching day-gate behavior, local talk-state, and PC-spoken follow-up lines.
 
 ## Next Suggested Action
 
 1. Move back to the more stateful visible handlers now that the compact visible bucket is mostly cleared.
-   - The next bounded cleanup candidates before the largest trees are `NUDE_MAN`, `MEMB_DIR`, and `KARIN`, followed by `LOOMIS`, `MOYNAHAN`, `MR_POTTS`, and `MRS_POTTS`.
+   - The next bounded cleanup candidates before the largest trees are `MEMB_DIR` and `KARIN`, followed by `LOOMIS`, `MOYNAHAN`, `MR_POTTS`, and `MRS_POTTS`.
 2. After that visible evidence-handler pass, return to Boyle and the other larger hidden-topic handlers.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index f83589fcb6a..0f6b9291d75 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -68,6 +68,7 @@
 #include "harvester/npc/priest_dialogue.h"
 #include "harvester/npc/pta_mom_dialogue.h"
 #include "harvester/npc/ryder_dialogue.h"
+#include "harvester/npc/nude_man_dialogue.h"
 #include "harvester/npc/stub_dialogue.h"
 #include "harvester/npc/swell_dialogue.h"
 #include "harvester/npc/valet_dialogue.h"
@@ -378,6 +379,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new MomDialogueHandler());
 	_npcHandlers.push_back(new MotherDialogueHandler());
 	_npcHandlers.push_back(new LodgeChefDialogueHandler());
+	_npcHandlers.push_back(new NudeManDialogueHandler());
 	_npcHandlers.push_back(new ParsonsDialogueHandler());
 	_npcHandlers.push_back(new PastorelliDialogueHandler());
 	_npcHandlers.push_back(new PhelpsDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index d35e1171917..63abce10907 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -32,6 +32,7 @@ MODULE := engines/harvester
 	npc/madam_dialogue.o \
 	npc/mom_dialogue.o \
 	npc/mother_dialogue.o \
+	npc/nude_man_dialogue.o \
 	npc/parsons_dialogue.o \
 	npc/pastorelli_dialogue.o \
 	npc/phelps_dialogue.o \
diff --git a/engines/harvester/npc/nude_man_dialogue.cpp b/engines/harvester/npc/nude_man_dialogue.cpp
new file mode 100644
index 00000000000..6fe8148665b
--- /dev/null
+++ b/engines/harvester/npc/nude_man_dialogue.cpp
@@ -0,0 +1,47 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/nude_man_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool NudeManDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("NUDE_MAN");
+}
+
+Common::Error NudeManDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &, DialogueSharedState &) {
+	if (runtime.startupScript().getFlagValue("DAY_FLAG"))
+		return runtime.playDialogueLine(0x65c, "NUDE_MAN");
+
+	if (_state.talkStatePending) {
+		_state.talkStatePending = false;
+		return runtime.playDialogueLine(0x606, "PC");
+	}
+
+	return runtime.playDialogueLine(0x651, "PC");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/nude_man_dialogue.h b/engines/harvester/npc/nude_man_dialogue.h
new file mode 100644
index 00000000000..70ed10ec535
--- /dev/null
+++ b/engines/harvester/npc/nude_man_dialogue.h
@@ -0,0 +1,46 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_NUDE_MAN_DIALOGUE_H
+#define HARVESTER_NPC_NUDE_MAN_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class NudeManDialogueHandler : public NpcDialogueHandler {
+public:
+	struct NudeManRoomDialogueState {
+		bool talkStatePending = true;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = NudeManRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	NudeManRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_NUDE_MAN_DIALOGUE_H


Commit: 64e701260c2fd1d7b3d69370871cfbeb9e5e10f0
    https://github.com/scummvm/scummvm/commit/64e701260c2fd1d7b3d69370871cfbeb9e5e10f0
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:09+02:00

Commit Message:
HARVESTER: Port membership director dialogue

Changed paths:
  A engines/harvester/npc/memb_dir_dialogue.cpp
  A engines/harvester/npc/memb_dir_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 98bf6629721..98e8496f955 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_nude_man @ 0x2d9b0` into a dedicated `engines/harvester/npc/nude_man_dialogue.*` handler.
-  - Native Nude Man dialogue is a compact three-way branch: `DAY_FLAG` forces `0x65c` for `NUDE_MAN`, the first non-day conversation clears the local talk-state block and plays `0x606` for `PC`, and later non-day conversations fall through to `0x651` for `PC`.
-  - The engine now routes `NUDE_MAN` through a concrete handler with matching day-gate behavior, local talk-state, and PC-spoken follow-up lines.
+- Ported `handle_talk_to_memb_dir @ 0x396d0` into a dedicated `engines/harvester/npc/memb_dir_dialogue.*` handler.
+  - Native Membership Director dialogue is a two-case aftermath handler: before `PC_KILLED_KEWPIE` it always plays `0x2b7` for `MEMB_DIR`, and after that flag it plays the one-shot `0x2d5` line once, then falls silent.
+  - The engine now routes `MEMB_DIR` through a concrete handler with the same flag gate and one-shot local state.
 
 ## Next Suggested Action
 
 1. Move back to the more stateful visible handlers now that the compact visible bucket is mostly cleared.
-   - The next bounded cleanup candidates before the largest trees are `MEMB_DIR` and `KARIN`, followed by `LOOMIS`, `MOYNAHAN`, `MR_POTTS`, and `MRS_POTTS`.
+   - The next bounded cleanup candidate before the largest trees is `KARIN`, followed by `LOOMIS`, `MOYNAHAN`, `MR_POTTS`, and `MRS_POTTS`.
 2. After that visible evidence-handler pass, return to Boyle and the other larger hidden-topic handlers.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 0f6b9291d75..85c2db7cbec 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -58,6 +58,7 @@
 #include "harvester/npc/librarian_dialogue.h"
 #include "harvester/npc/lodge_chef_dialogue.h"
 #include "harvester/npc/mcknight_dialogue.h"
+#include "harvester/npc/memb_dir_dialogue.h"
 #include "harvester/npc/maint_man_dialogue.h"
 #include "harvester/npc/madam_dialogue.h"
 #include "harvester/npc/mom_dialogue.h"
@@ -374,6 +375,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new GladiatorDialogueHandler());
 	_npcHandlers.push_back(new LibrarianDialogueHandler());
 	_npcHandlers.push_back(new McknightDialogueHandler());
+	_npcHandlers.push_back(new MembDirDialogueHandler());
 	_npcHandlers.push_back(new MaintManDialogueHandler());
 	_npcHandlers.push_back(new MadamDialogueHandler());
 	_npcHandlers.push_back(new MomDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 63abce10907..0a272ee8114 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -28,6 +28,7 @@ MODULE := engines/harvester
 	npc/librarian_dialogue.o \
 	npc/lodge_chef_dialogue.o \
 	npc/mcknight_dialogue.o \
+	npc/memb_dir_dialogue.o \
 	npc/maint_man_dialogue.o \
 	npc/madam_dialogue.o \
 	npc/mom_dialogue.o \
diff --git a/engines/harvester/npc/memb_dir_dialogue.cpp b/engines/harvester/npc/memb_dir_dialogue.cpp
new file mode 100644
index 00000000000..496ec323da7
--- /dev/null
+++ b/engines/harvester/npc/memb_dir_dialogue.cpp
@@ -0,0 +1,46 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/memb_dir_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool MembDirDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("MEMB_DIR");
+}
+
+Common::Error MembDirDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &, DialogueSharedState &) {
+	if (!runtime.startupScript().getFlagValue("PC_KILLED_KEWPIE"))
+		return runtime.playDialogueLine(0x2b7, "MEMB_DIR");
+
+	if (_state.kewpieKillLinePlayed)
+		return Common::kNoError;
+
+	_state.kewpieKillLinePlayed = true;
+	return runtime.playDialogueLine(0x2d5, "MEMB_DIR");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/memb_dir_dialogue.h b/engines/harvester/npc/memb_dir_dialogue.h
new file mode 100644
index 00000000000..c089f3c7b64
--- /dev/null
+++ b/engines/harvester/npc/memb_dir_dialogue.h
@@ -0,0 +1,46 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_MEMB_DIR_DIALOGUE_H
+#define HARVESTER_NPC_MEMB_DIR_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class MembDirDialogueHandler : public NpcDialogueHandler {
+public:
+	struct MembDirRoomDialogueState {
+		bool kewpieKillLinePlayed = false;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = MembDirRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	MembDirRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_MEMB_DIR_DIALOGUE_H


Commit: 2f2d03de9291b3070db049f538a37deed5f5da38
    https://github.com/scummvm/scummvm/commit/2f2d03de9291b3070db049f538a37deed5f5da38
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:09+02:00

Commit Message:
HARVESTER: Port karin dialogue

Changed paths:
  A engines/harvester/npc/karin_dialogue.cpp
  A engines/harvester/npc/karin_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 98e8496f955..92722ffe0b1 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_memb_dir @ 0x396d0` into a dedicated `engines/harvester/npc/memb_dir_dialogue.*` handler.
-  - Native Membership Director dialogue is a two-case aftermath handler: before `PC_KILLED_KEWPIE` it always plays `0x2b7` for `MEMB_DIR`, and after that flag it plays the one-shot `0x2d5` line once, then falls silent.
-  - The engine now routes `MEMB_DIR` through a concrete handler with the same flag gate and one-shot local state.
+- Ported `handle_talk_to_karin @ 0x2ea90` into a dedicated `engines/harvester/npc/karin_dialogue.*` handler.
+  - Native Karin dialogue is a compact stateful handler: the CEM10 alive line (`0x10a5`) and the pre-rescue default line (`0x1076`) are both one-shot PC-spoken branches, the item path handles casket/photo evidence and `INV_MAG`, and an extra photo-reply override bit suppresses native evidence writes when set.
+  - The engine now routes `KARIN` through a concrete handler with matching one-shot state and evidence replies; the photo-reply override remains explicit local state until its gameplay-side setter is recovered.
 
 ## Next Suggested Action
 
 1. Move back to the more stateful visible handlers now that the compact visible bucket is mostly cleared.
-   - The next bounded cleanup candidate before the largest trees is `KARIN`, followed by `LOOMIS`, `MOYNAHAN`, `MR_POTTS`, and `MRS_POTTS`.
+   - The next best candidates are the larger visible stateful handlers: `LOOMIS`, `MOYNAHAN`, `MR_POTTS`, and `MRS_POTTS`, followed by the heavier hidden-topic handlers.
 2. After that visible evidence-handler pass, return to Boyle and the other larger hidden-topic handlers.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 85c2db7cbec..d685ca8a7a5 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -55,6 +55,7 @@
 #include "harvester/npc/inquisitor_dialogue.h"
 #include "harvester/npc/jimmy_dialogue.h"
 #include "harvester/npc/johnson_dialogue.h"
+#include "harvester/npc/karin_dialogue.h"
 #include "harvester/npc/librarian_dialogue.h"
 #include "harvester/npc/lodge_chef_dialogue.h"
 #include "harvester/npc/mcknight_dialogue.h"
@@ -371,6 +372,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new DarkWomanDialogueHandler());
 	_npcHandlers.push_back(new JimmyDialogueHandler());
 	_npcHandlers.push_back(new InquisitorDialogueHandler());
+	_npcHandlers.push_back(new KarinDialogueHandler());
 	_npcHandlers.push_back(new WaspWomanDialogueHandler());
 	_npcHandlers.push_back(new GladiatorDialogueHandler());
 	_npcHandlers.push_back(new LibrarianDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 0a272ee8114..f457e11de6f 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -25,6 +25,7 @@ MODULE := engines/harvester
 	npc/inquisitor_dialogue.o \
 	npc/jimmy_dialogue.o \
 	npc/johnson_dialogue.o \
+	npc/karin_dialogue.o \
 	npc/librarian_dialogue.o \
 	npc/lodge_chef_dialogue.o \
 	npc/mcknight_dialogue.o \
diff --git a/engines/harvester/npc/karin_dialogue.cpp b/engines/harvester/npc/karin_dialogue.cpp
new file mode 100644
index 00000000000..424b4a2a2a1
--- /dev/null
+++ b/engines/harvester/npc/karin_dialogue.cpp
@@ -0,0 +1,78 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/karin_dialogue.h"
+
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool KarinDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("KARIN");
+}
+
+Common::Error KarinDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
+	KarinRoomDialogueState &state = _state;
+
+	const bool karinFoundAlive = runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE");
+	if (karinFoundAlive && runtime.startupScript().getFlagValue("IN_CEM10") &&
+			!state.cem10AliveLinePlayed) {
+		state.cem10AliveLinePlayed = true;
+		Common::Error lineError = runtime.playDialogueLine(0x10a5, "PC");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			if (!state.photoReplyOverrideFlag) {
+				if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+					(void)runtime.startupScript().setRuntimeFlagValue(
+						DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+				} else {
+					(void)runtime.startupScript().setRuntimeFlagValue(
+						DialogueFlags::kShownPhotoOfCorpse, true);
+				}
+			}
+			return runtime.playDialogueLine(0x10e3, "KARIN");
+		}
+		if (usedItemName.equalsIgnoreCase("INV_MAG"))
+			return runtime.playDialogueLine(0x10ee, "KARIN");
+		return runtime.playDialogueLine(0x10d6, "KARIN");
+	}
+
+	if (!karinFoundAlive && !state.notFoundAliveLinePlayed) {
+		state.notFoundAliveLinePlayed = true;
+		Common::Error lineError = runtime.playDialogueLine(0x1076, "PC");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	return runtime.playDialogueLine(0x10dc, "KARIN");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/karin_dialogue.h b/engines/harvester/npc/karin_dialogue.h
new file mode 100644
index 00000000000..fb704dd3650
--- /dev/null
+++ b/engines/harvester/npc/karin_dialogue.h
@@ -0,0 +1,48 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_KARIN_DIALOGUE_H
+#define HARVESTER_NPC_KARIN_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class KarinDialogueHandler : public NpcDialogueHandler {
+public:
+	struct KarinRoomDialogueState {
+		bool photoReplyOverrideFlag = false;
+		bool cem10AliveLinePlayed = false;
+		bool notFoundAliveLinePlayed = false;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = KarinRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	KarinRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_KARIN_DIALOGUE_H


Commit: a054e52003aef8489f6bd068077b7a8f7851adf3
    https://github.com/scummvm/scummvm/commit/a054e52003aef8489f6bd068077b7a8f7851adf3
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:10+02:00

Commit Message:
HARVESTER: Port loomis dialogue

Changed paths:
  A engines/harvester/npc/loomis_dialogue.cpp
  A engines/harvester/npc/loomis_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 92722ffe0b1..0f300af2fe0 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_karin @ 0x2ea90` into a dedicated `engines/harvester/npc/karin_dialogue.*` handler.
-  - Native Karin dialogue is a compact stateful handler: the CEM10 alive line (`0x10a5`) and the pre-rescue default line (`0x1076`) are both one-shot PC-spoken branches, the item path handles casket/photo evidence and `INV_MAG`, and an extra photo-reply override bit suppresses native evidence writes when set.
-  - The engine now routes `KARIN` through a concrete handler with matching one-shot state and evidence replies; the photo-reply override remains explicit local state until its gameplay-side setter is recovered.
+- Ported `handle_talk_to_loomis @ 0x34f80` into a dedicated `engines/harvester/npc/loomis_dialogue.*` handler.
+  - Native Loomis dialogue uses a small two-step no-item state machine: the intro clears the local talk-state block, sets a return-visit followup bit, optionally plays the pre-Dwayne line `0x10fd`, then falls through to `0x1121`; the next no-item talk plays `0x11a8`, and later talks settle on `0x11f6`.
+  - The item path is mostly linear but includes one native quirk: `TV_DEED` and `TV_DEED_PHOTOCOPY` collapse into the same note/checkbook helper and `0x1209` line instead of using the usual TV-deed shared flag, and `PHOTO_OF_WHALEY_HERRILL` plays `0x1143` without writing the shared Whaley/Herrill evidence bit.
 
 ## Next Suggested Action
 
 1. Move back to the more stateful visible handlers now that the compact visible bucket is mostly cleared.
-   - The next best candidates are the larger visible stateful handlers: `LOOMIS`, `MOYNAHAN`, `MR_POTTS`, and `MRS_POTTS`, followed by the heavier hidden-topic handlers.
+   - The next best candidates are the larger visible stateful handlers: `MOYNAHAN`, `MR_POTTS`, and `MRS_POTTS`, followed by the heavier hidden-topic handlers.
 2. After that visible evidence-handler pass, return to Boyle and the other larger hidden-topic handlers.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index d685ca8a7a5..3bd36ee527b 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -57,6 +57,7 @@
 #include "harvester/npc/johnson_dialogue.h"
 #include "harvester/npc/karin_dialogue.h"
 #include "harvester/npc/librarian_dialogue.h"
+#include "harvester/npc/loomis_dialogue.h"
 #include "harvester/npc/lodge_chef_dialogue.h"
 #include "harvester/npc/mcknight_dialogue.h"
 #include "harvester/npc/memb_dir_dialogue.h"
@@ -376,6 +377,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new WaspWomanDialogueHandler());
 	_npcHandlers.push_back(new GladiatorDialogueHandler());
 	_npcHandlers.push_back(new LibrarianDialogueHandler());
+	_npcHandlers.push_back(new LoomisDialogueHandler());
 	_npcHandlers.push_back(new McknightDialogueHandler());
 	_npcHandlers.push_back(new MembDirDialogueHandler());
 	_npcHandlers.push_back(new MaintManDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index f457e11de6f..5c814a130f3 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -27,6 +27,7 @@ MODULE := engines/harvester
 	npc/johnson_dialogue.o \
 	npc/karin_dialogue.o \
 	npc/librarian_dialogue.o \
+	npc/loomis_dialogue.o \
 	npc/lodge_chef_dialogue.o \
 	npc/mcknight_dialogue.o \
 	npc/memb_dir_dialogue.o \
diff --git a/engines/harvester/npc/loomis_dialogue.cpp b/engines/harvester/npc/loomis_dialogue.cpp
new file mode 100644
index 00000000000..931f66c537e
--- /dev/null
+++ b/engines/harvester/npc/loomis_dialogue.cpp
@@ -0,0 +1,86 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/loomis_dialogue.h"
+
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool LoomisDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("LOOMIS");
+}
+
+Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
+	LoomisRoomDialogueState &state = _state;
+
+	if (usedItemName.empty()) {
+		if (!state.talkStatePending) {
+			if (state.returnVisitFollowupPending) {
+				state.returnVisitFollowupPending = false;
+				return runtime.playDialogueLine(0x11a8, "LOOMIS");
+			}
+			return runtime.playDialogueLine(0x11f6, "LOOMIS");
+		}
+
+		state.talkStatePending = false;
+		state.returnVisitFollowupPending = true;
+		if (!runtime.startupScript().getFlagValue("DWAYNE_INTRODUCED")) {
+			Common::Error lineError = runtime.playDialogueLine(0x10fd, "LOOMIS");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		return runtime.playDialogueLine(0x1121, "LOOMIS");
+	}
+
+	if (usedItemName.equalsIgnoreCase("NOTE") ||
+			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY") ||
+			usedItemName.equalsIgnoreCase("TV_DEED") ||
+			usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+		return runtime.playDialogueLine(0x1209, "LOOMIS");
+	}
+	if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+				usedItemName.equalsIgnoreCase("LEDGER2")) &&
+			runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+		return runtime.playDialogueLine(0x1229, "LOOMIS");
+	}
+	if (usedItemName.equalsIgnoreCase("INV_MAG")) {
+		if (runtime.startupScript().getFlagValue("SHERIFF_IN_DINER"))
+			return runtime.playDialogueLine(0x11bb, "LOOMIS");
+
+		(void)runtime.startupScript().addRuntimeObjectToInventory("INV_MAG");
+		return runtime.playDialogueLine(0x11db, "LOOMIS");
+	}
+	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL"))
+		return runtime.playDialogueLine(0x1143, "LOOMIS");
+
+	return runtime.playDialogueLine(0x1202, "LOOMIS");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/loomis_dialogue.h b/engines/harvester/npc/loomis_dialogue.h
new file mode 100644
index 00000000000..ee327218250
--- /dev/null
+++ b/engines/harvester/npc/loomis_dialogue.h
@@ -0,0 +1,47 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_LOOMIS_DIALOGUE_H
+#define HARVESTER_NPC_LOOMIS_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class LoomisDialogueHandler : public NpcDialogueHandler {
+public:
+	struct LoomisRoomDialogueState {
+		bool talkStatePending = true;
+		bool returnVisitFollowupPending = false;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = LoomisRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	LoomisRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_LOOMIS_DIALOGUE_H


Commit: 1384493025b5f489cf44cf1d0cc461025d03ec55
    https://github.com/scummvm/scummvm/commit/1384493025b5f489cf44cf1d0cc461025d03ec55
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:10+02:00

Commit Message:
HARVESTER: Port moynahan dialogue

Changed paths:
  A engines/harvester/npc/moynahan_dialogue.cpp
  A engines/harvester/npc/moynahan_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 0f300af2fe0..944ebcb23c5 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_loomis @ 0x34f80` into a dedicated `engines/harvester/npc/loomis_dialogue.*` handler.
-  - Native Loomis dialogue uses a small two-step no-item state machine: the intro clears the local talk-state block, sets a return-visit followup bit, optionally plays the pre-Dwayne line `0x10fd`, then falls through to `0x1121`; the next no-item talk plays `0x11a8`, and later talks settle on `0x11f6`.
-  - The item path is mostly linear but includes one native quirk: `TV_DEED` and `TV_DEED_PHOTOCOPY` collapse into the same note/checkbook helper and `0x1209` line instead of using the usual TV-deed shared flag, and `PHOTO_OF_WHALEY_HERRILL` plays `0x1143` without writing the shared Whaley/Herrill evidence bit.
+- Ported `handle_talk_to_moynahan @ 0x24a20` into a dedicated `engines/harvester/npc/moynahan_dialogue.*` handler.
+  - Native Moynahan dialogue is a stateful visible handler with one early plot bark (`IF_TRY_TO_TAKE_THE_GLUE`), a one-shot no-item intro (`0x3d33`) that falls through to `0x3fd1`, and a mostly linear evidence-item path.
+  - The main native quirk is the ledger branch: it reads the shared shown-ledgers state and only plays the extra `0x3f87` line when that shared flag is already set, instead of writing the ledger evidence flag itself; the engine now preserves that behavior explicitly.
 
 ## Next Suggested Action
 
 1. Move back to the more stateful visible handlers now that the compact visible bucket is mostly cleared.
-   - The next best candidates are the larger visible stateful handlers: `MOYNAHAN`, `MR_POTTS`, and `MRS_POTTS`, followed by the heavier hidden-topic handlers.
+   - The next best candidates are the larger visible stateful handlers: `MR_POTTS` and `MRS_POTTS`, followed by the heavier hidden-topic handlers.
 2. After that visible evidence-handler pass, return to Boyle and the other larger hidden-topic handlers.
    - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 3bd36ee527b..86a8386c0c4 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -65,6 +65,7 @@
 #include "harvester/npc/madam_dialogue.h"
 #include "harvester/npc/mom_dialogue.h"
 #include "harvester/npc/mother_dialogue.h"
+#include "harvester/npc/moynahan_dialogue.h"
 #include "harvester/npc/parsons_dialogue.h"
 #include "harvester/npc/pastorelli_dialogue.h"
 #include "harvester/npc/phelps_dialogue.h"
@@ -384,6 +385,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new MadamDialogueHandler());
 	_npcHandlers.push_back(new MomDialogueHandler());
 	_npcHandlers.push_back(new MotherDialogueHandler());
+	_npcHandlers.push_back(new MoynahanDialogueHandler());
 	_npcHandlers.push_back(new LodgeChefDialogueHandler());
 	_npcHandlers.push_back(new NudeManDialogueHandler());
 	_npcHandlers.push_back(new ParsonsDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 5c814a130f3..583472c170a 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -35,6 +35,7 @@ MODULE := engines/harvester
 	npc/madam_dialogue.o \
 	npc/mom_dialogue.o \
 	npc/mother_dialogue.o \
+	npc/moynahan_dialogue.o \
 	npc/nude_man_dialogue.o \
 	npc/parsons_dialogue.o \
 	npc/pastorelli_dialogue.o \
diff --git a/engines/harvester/npc/moynahan_dialogue.cpp b/engines/harvester/npc/moynahan_dialogue.cpp
new file mode 100644
index 00000000000..2735341cf96
--- /dev/null
+++ b/engines/harvester/npc/moynahan_dialogue.cpp
@@ -0,0 +1,82 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/moynahan_dialogue.h"
+
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool MoynahanDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("MOYNAHAN");
+}
+
+Common::Error MoynahanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
+	MoynahanRoomDialogueState &state = _state;
+
+	if (runtime.startupScript().getFlagValue("IF_TRY_TO_TAKE_THE_GLUE"))
+		return runtime.playDialogueLine(0x3e4d, "MOYNAHAN");
+
+	if (usedItemName.empty()) {
+		if (state.talkStatePending) {
+			state.talkStatePending = false;
+			return runtime.playDialogueLine(0x3d33, "MOYNAHAN");
+		}
+		return runtime.playDialogueLine(0x3fd1, "MOYNAHAN");
+	}
+
+	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+		return runtime.playDialogueLine(0x3e45, "MOYNAHAN");
+	}
+	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY"))
+		return runtime.playDialogueLine(0x3e84, "MOYNAHAN");
+	if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+				usedItemName.equalsIgnoreCase("LEDGER2")) &&
+			runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+		if (runtime.startupScript().getFlagValue(DialogueFlags::kShownLedgersToAnyone) &&
+				!state.ledgerEvidenceLinePlayed) {
+			state.ledgerEvidenceLinePlayed = true;
+			Common::Error lineError = runtime.playDialogueLine(0x3f87, "MOYNAHAN");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		return runtime.playDialogueLine(0x3ede, "MOYNAHAN");
+	}
+	if (usedItemName.equalsIgnoreCase("NOTE") ||
+			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+		return runtime.playDialogueLine(0x3f3e, "MOYNAHAN");
+	}
+	if (usedItemName.equalsIgnoreCase("WED_MATCHES"))
+		return runtime.playDialogueLine(0x4049, "MOYNAHAN");
+
+	return runtime.playDialogueLine(0x3e3f, "MOYNAHAN");
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/moynahan_dialogue.h b/engines/harvester/npc/moynahan_dialogue.h
new file mode 100644
index 00000000000..f756cead198
--- /dev/null
+++ b/engines/harvester/npc/moynahan_dialogue.h
@@ -0,0 +1,47 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_MOYNAHAN_DIALOGUE_H
+#define HARVESTER_NPC_MOYNAHAN_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class MoynahanDialogueHandler : public NpcDialogueHandler {
+public:
+	struct MoynahanRoomDialogueState {
+		bool talkStatePending = true;
+		bool ledgerEvidenceLinePlayed = false;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = MoynahanRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	MoynahanRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_MOYNAHAN_DIALOGUE_H


Commit: 246f6457cbe703e176ffd4de7aa5202daf5f5150
    https://github.com/scummvm/scummvm/commit/246f6457cbe703e176ffd4de7aa5202daf5f5150
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:10+02:00

Commit Message:
HARVESTER: Port mr potts dialogue

Changed paths:
  A engines/harvester/npc/mr_potts_dialogue.cpp
  A engines/harvester/npc/mr_potts_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 944ebcb23c5..277a4d3d4f9 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_moynahan @ 0x24a20` into a dedicated `engines/harvester/npc/moynahan_dialogue.*` handler.
-  - Native Moynahan dialogue is a stateful visible handler with one early plot bark (`IF_TRY_TO_TAKE_THE_GLUE`), a one-shot no-item intro (`0x3d33`) that falls through to `0x3fd1`, and a mostly linear evidence-item path.
-  - The main native quirk is the ledger branch: it reads the shared shown-ledgers state and only plays the extra `0x3f87` line when that shared flag is already set, instead of writing the ledger evidence flag itself; the engine now preserves that behavior explicitly.
+- Ported `handle_talk_to_mr_potts @ 0x26000` into a dedicated `engines/harvester/npc/mr_potts_dialogue.*` handler.
+  - The confirmed visible handler is compact but stateful: it short-circuits on the blade/Stephanie/digging interruptions, mirrors the evidence-item replies, and preserves the same-day vs later-day followup split keyed by the auxiliary talk-state tail.
+  - The known hidden-topic setters for Mr. Potts still live deeper in the overlapping Potts code region, but the engine now covers the top-level dispatch target and its visible branches without guessing at the unrecovered topic loop.
 
 ## Next Suggested Action
 
 1. Move back to the more stateful visible handlers now that the compact visible bucket is mostly cleared.
-   - The next best candidates are the larger visible stateful handlers: `MR_POTTS` and `MRS_POTTS`, followed by the heavier hidden-topic handlers.
+   - The next best candidate is `MRS_POTTS`, which shares the Potts evidence-item shape but also has a recovered overlapping keyword loop worth porting directly.
 2. After that visible evidence-handler pass, return to Boyle and the other larger hidden-topic handlers.
-   - Boyle still has the best confirmed hidden-topic coverage among the larger remaining NPC handlers.
+   - After `MRS_POTTS`, the remaining Ghidra-backed handlers are `STEPHANIE`, `SERGEANT`, `SPARKY`, `DAD`, and `BOYLE`.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 86a8386c0c4..6d44cdc7873 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -66,6 +66,7 @@
 #include "harvester/npc/mom_dialogue.h"
 #include "harvester/npc/mother_dialogue.h"
 #include "harvester/npc/moynahan_dialogue.h"
+#include "harvester/npc/mr_potts_dialogue.h"
 #include "harvester/npc/parsons_dialogue.h"
 #include "harvester/npc/pastorelli_dialogue.h"
 #include "harvester/npc/phelps_dialogue.h"
@@ -386,6 +387,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new MomDialogueHandler());
 	_npcHandlers.push_back(new MotherDialogueHandler());
 	_npcHandlers.push_back(new MoynahanDialogueHandler());
+	_npcHandlers.push_back(new MrPottsDialogueHandler());
 	_npcHandlers.push_back(new LodgeChefDialogueHandler());
 	_npcHandlers.push_back(new NudeManDialogueHandler());
 	_npcHandlers.push_back(new ParsonsDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 583472c170a..70e4b127266 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -36,6 +36,7 @@ MODULE := engines/harvester
 	npc/mom_dialogue.o \
 	npc/mother_dialogue.o \
 	npc/moynahan_dialogue.o \
+	npc/mr_potts_dialogue.o \
 	npc/nude_man_dialogue.o \
 	npc/parsons_dialogue.o \
 	npc/pastorelli_dialogue.o \
diff --git a/engines/harvester/npc/mr_potts_dialogue.cpp b/engines/harvester/npc/mr_potts_dialogue.cpp
new file mode 100644
index 00000000000..84745a4a46e
--- /dev/null
+++ b/engines/harvester/npc/mr_potts_dialogue.cpp
@@ -0,0 +1,133 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/mr_potts_dialogue.h"
+
+#include "common/array.h"
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool MrPottsDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("MR_POTTS") ||
+		npcName.equalsIgnoreCase("MR_POTTS_CEM10") ||
+		npcName.equalsIgnoreCase("MR_POTTS_HALL");
+}
+
+Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
+	MrPottsRoomDialogueState &state = _state;
+	auto playMrPottsLine = [&](int wavId, const char *speakerId = "MR_POTTS") -> Common::Error {
+		return runtime.playDialogueLine(wavId, speakerId);
+	};
+	auto hasInventoryItem = [&](const char *objectName) {
+		Common::Array<StartupObjectRecord> inventoryObjects;
+		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
+		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
+				return true;
+		}
+
+		return false;
+	};
+
+	if (hasInventoryItem("HARVEST_BLADE")) {
+		(void)runtime.startupScript().setRuntimeFlagValue("PC_TALKED_TO_POTTS", true);
+		return playMrPottsLine(0x2f1d, "POTTS_FLESH");
+	}
+
+	if (runtime.startupScript().getFlagValue("STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(
+			"STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE", false);
+		return playMrPottsLine(0x2afc);
+	}
+
+	if (runtime.startupScript().getFlagValue("POTTSDAM_DIGGING")) {
+		if (!state.pottsdamDiggingLinePlayed) {
+			state.pottsdamDiggingLinePlayed = true;
+			Common::Error lineError = playMrPottsLine(0x2c63);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		return playMrPottsLine(0x2cd9);
+	}
+
+	if (runtime.startupScript().getFlagValue("POTTSDAM_DIGGING_KARIN"))
+		return playMrPottsLine(0x2c33);
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("MEAT"))
+			return playMrPottsLine(0x2c7f);
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+			return playMrPottsLine(0x2c97);
+		}
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			return playMrPottsLine(0x2cb3);
+		}
+		if (usedItemName.equalsIgnoreCase("NOTE") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				DialogueFlags::kShownEvidenceOfBlackmail, true);
+			return playMrPottsLine(0x2ce0);
+		}
+		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
+				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				DialogueFlags::kShownEvidenceSheriffOwns, true);
+			return playMrPottsLine(0x2ce0);
+		}
+
+		return playMrPottsLine(0x2c79);
+	}
+
+	if (runtime.startupScript().getFlagValue("IF_TRY_TO_SEE_STEPHANIE"))
+		return playMrPottsLine(0x2b18);
+
+	if (state.auxIntroPending &&
+			!runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD")) {
+		state.auxIntroPending = false;
+		state.followupDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
+		return playMrPottsLine(0x2988);
+	}
+
+	if (state.followupDayIndex == runtime.startupScript().getCurrentStoryDayIndex()) {
+		if (!runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD"))
+			return playMrPottsLine(0x2ac7);
+		return playMrPottsLine(0x2aed);
+	}
+
+	if (!runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD"))
+		return playMrPottsLine(0x2ace);
+
+	return playMrPottsLine(0x2bdf);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/mr_potts_dialogue.h b/engines/harvester/npc/mr_potts_dialogue.h
new file mode 100644
index 00000000000..867f6c7154b
--- /dev/null
+++ b/engines/harvester/npc/mr_potts_dialogue.h
@@ -0,0 +1,48 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_MR_POTTS_DIALOGUE_H
+#define HARVESTER_NPC_MR_POTTS_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class MrPottsDialogueHandler : public NpcDialogueHandler {
+public:
+	struct MrPottsRoomDialogueState {
+		bool auxIntroPending = false;
+		int followupDayIndex = -1;
+		bool pottsdamDiggingLinePlayed = false;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = MrPottsRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	MrPottsRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_MR_POTTS_DIALOGUE_H


Commit: d3051f89d70565f06514358850c0a37dbaf4b2dc
    https://github.com/scummvm/scummvm/commit/d3051f89d70565f06514358850c0a37dbaf4b2dc
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:11+02:00

Commit Message:
HARVESTER: Port mrs potts dialogue

Changed paths:
  A engines/harvester/npc/mrs_potts_dialogue.cpp
  A engines/harvester/npc/mrs_potts_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 277a4d3d4f9..55ea3d362f0 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_mr_potts @ 0x26000` into a dedicated `engines/harvester/npc/mr_potts_dialogue.*` handler.
-  - The confirmed visible handler is compact but stateful: it short-circuits on the blade/Stephanie/digging interruptions, mirrors the evidence-item replies, and preserves the same-day vs later-day followup split keyed by the auxiliary talk-state tail.
-  - The known hidden-topic setters for Mr. Potts still live deeper in the overlapping Potts code region, but the engine now covers the top-level dispatch target and its visible branches without guessing at the unrecovered topic loop.
+- Ported `handle_talk_to_mrs_potts @ 0x2ee70` into a dedicated `engines/harvester/npc/mrs_potts_dialogue.*` handler.
+  - The confirmed top-level handler now mirrors Mrs. Potts's shared evidence-item replies, her Stephanie-dead room split, and the auxiliary return-visit / event bark tail.
+  - Ghidra still exposes the `FUN_0002f7fd` overlapping keyword-loop region separately from the top-level visible flow, so that menu block remains documented as an overlapping hidden branch until a trustworthy engine entry path is recovered.
 
 ## Next Suggested Action
 
 1. Move back to the more stateful visible handlers now that the compact visible bucket is mostly cleared.
-   - The next best candidate is `MRS_POTTS`, which shares the Potts evidence-item shape but also has a recovered overlapping keyword loop worth porting directly.
+   - The next best candidates are the remaining compact named handlers: `SPARKY` and `SERGEANT`, before the larger hidden-topic-heavy ports.
 2. After that visible evidence-handler pass, return to Boyle and the other larger hidden-topic handlers.
-   - After `MRS_POTTS`, the remaining Ghidra-backed handlers are `STEPHANIE`, `SERGEANT`, `SPARKY`, `DAD`, and `BOYLE`.
+   - After `SPARKY` and `SERGEANT`, the remaining Ghidra-backed handlers are `STEPHANIE`, `DAD`, and `BOYLE`.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 6d44cdc7873..256c919e5b3 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -67,6 +67,7 @@
 #include "harvester/npc/mother_dialogue.h"
 #include "harvester/npc/moynahan_dialogue.h"
 #include "harvester/npc/mr_potts_dialogue.h"
+#include "harvester/npc/mrs_potts_dialogue.h"
 #include "harvester/npc/parsons_dialogue.h"
 #include "harvester/npc/pastorelli_dialogue.h"
 #include "harvester/npc/phelps_dialogue.h"
@@ -388,6 +389,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new MotherDialogueHandler());
 	_npcHandlers.push_back(new MoynahanDialogueHandler());
 	_npcHandlers.push_back(new MrPottsDialogueHandler());
+	_npcHandlers.push_back(new MrsPottsDialogueHandler());
 	_npcHandlers.push_back(new LodgeChefDialogueHandler());
 	_npcHandlers.push_back(new NudeManDialogueHandler());
 	_npcHandlers.push_back(new ParsonsDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 70e4b127266..366697d4bea 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -37,6 +37,7 @@ MODULE := engines/harvester
 	npc/mother_dialogue.o \
 	npc/moynahan_dialogue.o \
 	npc/mr_potts_dialogue.o \
+	npc/mrs_potts_dialogue.o \
 	npc/nude_man_dialogue.o \
 	npc/parsons_dialogue.o \
 	npc/pastorelli_dialogue.o \
diff --git a/engines/harvester/npc/mrs_potts_dialogue.cpp b/engines/harvester/npc/mrs_potts_dialogue.cpp
new file mode 100644
index 00000000000..56e47bd28da
--- /dev/null
+++ b/engines/harvester/npc/mrs_potts_dialogue.cpp
@@ -0,0 +1,156 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/mrs_potts_dialogue.h"
+
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool MrsPottsDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("MRS_POTTS") ||
+		npcName.equalsIgnoreCase("MRS_POTTS_ST_BEDRM");
+}
+
+Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	MrsPottsRoomDialogueState &state = _state;
+	auto playMrsPottsLine = [&](int wavId, const char *speakerId = "MRS_POTTS") -> Common::Error {
+		return runtime.playDialogueLine(wavId, speakerId);
+	};
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			return playMrsPottsLine(0x293c);
+		}
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+			return playMrsPottsLine(0x294d);
+		}
+		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+				usedItemName.equalsIgnoreCase("LEDGER2")) &&
+				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+			return playMrsPottsLine(0x294d);
+		}
+		if (usedItemName.equalsIgnoreCase("NOTE") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				DialogueFlags::kShownEvidenceOfBlackmail, true);
+			return playMrsPottsLine(0x2954);
+		}
+		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
+				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				DialogueFlags::kShownEvidenceSheriffOwns, true);
+			return playMrsPottsLine(0x296e);
+		}
+		if (usedItemName.equalsIgnoreCase("REMAINS"))
+			return playMrsPottsLine(0x28b7);
+
+		return playMrsPottsLine(0x2936);
+	}
+
+	if (runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD")) {
+		Common::Error lineError = Common::kNoError;
+		if (!sharedState.dialogueStateD2ea4) {
+			sharedState.dialogueStateD2ea4 = true;
+			lineError = playMrsPottsLine(0x283b);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (!runtime.currentRoomName().equalsIgnoreCase("STKITCHN")) {
+			lineError = playMrsPottsLine(0x2854);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("GOT_REMAINS_FOR_LODGE") &&
+				!state.gotRemainsForLodgeLinePlayed) {
+			state.gotRemainsForLodgeLinePlayed = true;
+			lineError = playMrsPottsLine(0x289e);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("TAKEN_INVITE_TO_SERGEANT") &&
+				!state.takenInviteToSergeantLinePlayed) {
+			state.takenInviteToSergeantLinePlayed = true;
+			lineError = playMrsPottsLine(0x286b);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		return playMrsPottsLine(0x2835);
+	}
+
+	if (state.auxIntroPending) {
+		state.auxIntroPending = false;
+		state.returnVisitFollowupPending = true;
+		return playMrsPottsLine(0x2726);
+	}
+
+	if (state.returnVisitFollowupPending) {
+		state.returnVisitFollowupPending = false;
+		return playMrsPottsLine(0x2800);
+	}
+
+	const int storyDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
+	if (storyDayIndex > 1 && storyDayIndex < 6) {
+		state.returnVisitFollowupPending = true;
+		return playMrsPottsLine(0x281b);
+	}
+
+	if (!runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD")) {
+		state.returnVisitFollowupPending = true;
+		return playMrsPottsLine(0x282d);
+	}
+
+	if (storyDayIndex == 5 && !state.day5LinePlayed) {
+		state.day5LinePlayed = true;
+		return playMrsPottsLine(0x2889);
+	}
+	if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
+			!state.burnedTvStationLinePlayed) {
+		state.burnedTvStationLinePlayed = true;
+		return playMrsPottsLine(0x2891);
+	}
+	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
+			!state.karinKidnappedLinePlayed) {
+		state.karinKidnappedLinePlayed = true;
+		return playMrsPottsLine(0x28dc);
+	}
+	if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
+			!state.dinerBurnedLinePlayed) {
+		state.dinerBurnedLinePlayed = true;
+		return playMrsPottsLine(0x290e);
+	}
+
+	return playMrsPottsLine(0x2930);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/mrs_potts_dialogue.h b/engines/harvester/npc/mrs_potts_dialogue.h
new file mode 100644
index 00000000000..12df8cde89e
--- /dev/null
+++ b/engines/harvester/npc/mrs_potts_dialogue.h
@@ -0,0 +1,53 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_MRS_POTTS_DIALOGUE_H
+#define HARVESTER_NPC_MRS_POTTS_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class MrsPottsDialogueHandler : public NpcDialogueHandler {
+public:
+	struct MrsPottsRoomDialogueState {
+		bool auxIntroPending = false;
+		bool returnVisitFollowupPending = false;
+		bool gotRemainsForLodgeLinePlayed = false;
+		bool takenInviteToSergeantLinePlayed = false;
+		bool day5LinePlayed = false;
+		bool burnedTvStationLinePlayed = false;
+		bool karinKidnappedLinePlayed = false;
+		bool dinerBurnedLinePlayed = false;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = MrsPottsRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	MrsPottsRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_MRS_POTTS_DIALOGUE_H


Commit: ed79bd58c05a74062797ed07ca1f3187126ffe27
    https://github.com/scummvm/scummvm/commit/ed79bd58c05a74062797ed07ca1f3187126ffe27
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:11+02:00

Commit Message:
HARVESTER: Port sparky dialogue

Changed paths:
  A engines/harvester/npc/sparky_dialogue.cpp
  A engines/harvester/npc/sparky_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 55ea3d362f0..d6e309518a7 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_mrs_potts @ 0x2ee70` into a dedicated `engines/harvester/npc/mrs_potts_dialogue.*` handler.
-  - The confirmed top-level handler now mirrors Mrs. Potts's shared evidence-item replies, her Stephanie-dead room split, and the auxiliary return-visit / event bark tail.
-  - Ghidra still exposes the `FUN_0002f7fd` overlapping keyword-loop region separately from the top-level visible flow, so that menu block remains documented as an overlapping hidden branch until a trustworthy engine entry path is recovered.
+- Ported `handle_talk_to_sparky @ 0x39240` into a dedicated `engines/harvester/npc/sparky_dialogue.*` handler.
+  - Native Sparky dialogue is a compact stateful handler: one interrupt bark for the light switch, a first-visit `SPARKY_SPOTS_MEAT` sequence bracketed by `C135.FST` / `C098.FST`, and then a revisit keyword loop with a `0x2c9` submenu rewrite and `NUDETATU.FST` branch.
+  - This pass also fixed a Ghidra blind spot by promoting and renaming the tiny FST wrappers at `0x38630`, `0x38650`, `0x38660`, `0x38670`, `0x38690`, and `0x38b80` to `play_c135_fst`, `play_c096_fst`, `play_c096a_fst`, `play_nudetatu_fst`, `play_c098_fst`, and `play_c149_fst`.
 
 ## Next Suggested Action
 
 1. Move back to the more stateful visible handlers now that the compact visible bucket is mostly cleared.
-   - The next best candidates are the remaining compact named handlers: `SPARKY` and `SERGEANT`, before the larger hidden-topic-heavy ports.
+   - The next best candidate is `SERGEANT`, which is still compact enough to port directly but needs a couple of runtime NPC/object state helpers.
 2. After that visible evidence-handler pass, return to Boyle and the other larger hidden-topic handlers.
-   - After `SPARKY` and `SERGEANT`, the remaining Ghidra-backed handlers are `STEPHANIE`, `DAD`, and `BOYLE`.
+   - After `SERGEANT`, the remaining Ghidra-backed handlers are `STEPHANIE`, `DAD`, and `BOYLE`.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 256c919e5b3..6f495247416 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -76,6 +76,7 @@
 #include "harvester/npc/ryder_dialogue.h"
 #include "harvester/npc/nude_man_dialogue.h"
 #include "harvester/npc/stub_dialogue.h"
+#include "harvester/npc/sparky_dialogue.h"
 #include "harvester/npc/swell_dialogue.h"
 #include "harvester/npc/valet_dialogue.h"
 #include "harvester/npc/vet_dialogue.h"
@@ -399,6 +400,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new RyderDialogueHandler());
 	_npcHandlers.push_back(new ValetDialogueHandler());
 	_npcHandlers.push_back(new VetDialogueHandler());
+	_npcHandlers.push_back(new SparkyDialogueHandler());
 	_npcHandlers.push_back(new SwellDialogueHandler());
 	_npcHandlers.push_back(new WhaleyDialogueHandler());
 	_npcHandlers.push_back(new HankDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 366697d4bea..75ca004bbd6 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -45,6 +45,7 @@ MODULE := engines/harvester
 	npc/priest_dialogue.o \
 	npc/pta_mom_dialogue.o \
 	npc/ryder_dialogue.o \
+	npc/sparky_dialogue.o \
 	npc/stub_dialogue.o \
 	npc/swell_dialogue.o \
 	npc/valet_dialogue.o \
diff --git a/engines/harvester/npc/sparky_dialogue.cpp b/engines/harvester/npc/sparky_dialogue.cpp
new file mode 100644
index 00000000000..5db957f21ad
--- /dev/null
+++ b/engines/harvester/npc/sparky_dialogue.cpp
@@ -0,0 +1,209 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/sparky_dialogue.h"
+
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kDialogueC135FstPath = "GRAPHIC/FST/C135.FST";
+static const char *const kDialogueC096FstPath = "GRAPHIC/FST/C096.FST";
+static const char *const kDialogueC096AFstPath = "GRAPHIC/FST/C096A.FST";
+static const char *const kDialogueC098FstPath = "GRAPHIC/FST/C098.FST";
+static const char *const kDialogueNudetatuFstPath = "GRAPHIC/FST/NUDETATU.FST";
+
+} // End of namespace
+
+bool SparkyDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("SPARKY");
+}
+
+Common::Error SparkyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	SparkyRoomDialogueState &state = _state;
+	Common::String &sparkyTopicBuffer = state.currentTopicBuffer;
+	int &sparkyTopicBufferLineIndex = state.currentTopicBufferLineIndex;
+
+	auto assignSparkyTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(sparkyTopicBuffer, sparkyTopicBufferLineIndex,
+			responseLineIndex, "Sparky topic buffer");
+	};
+	auto playSparkyLine = [&](int wavId, const char *speakerId = "SPARKY") -> Common::Error {
+		return runtime.playDialogueLine(wavId, speakerId);
+	};
+	auto executeDialogueActionTag = [&](const char *tag) {
+		StartupInteractionResult interaction;
+		if (runtime.startupScript().executeActionTag(tag, interaction)) {
+			runtime.applyImmediateDialogueInteractionEffects(interaction);
+			runtime.queueDialogueInteractionIfNeeded(interaction);
+		}
+	};
+
+	if (runtime.startupScript().getFlagValue("PC_TRIES_TO_TURN_ON_LIGHT")) {
+		(void)runtime.startupScript().setRuntimeFlagValue("PC_TRIES_TO_TURN_ON_LIGHT", false);
+		return playSparkyLine(0x3a4);
+	}
+
+	if (!usedItemName.empty())
+		return playSparkyLine(0x3c7);
+
+	if (state.introPending) {
+		state.introPending = false;
+		state.returnVisitPending = true;
+		state.returnVisitDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
+
+		const DialogueLineEntry introLines[] = {
+			{ 0x2f7, "PC", 0 },
+			{ 0x2fe, "SPARKY", 0 },
+			{ 0x302, "SPARKY", 0 }
+		};
+		Common::Error lineError = runtime.playDialogueEntrySequence(introLines, ARRAYSIZE(introLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = runtime.playDialogueFst(kDialogueC135FstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		executeDialogueActionTag("SPARKY_SPOTS_MEAT");
+		lineError = runtime.playDialogueFst(kDialogueC098FstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = runtime.playDialogueLine(0x30d, "PC");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x2be, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			sharedState.dialogueStateD2f04 = true;
+			return playSparkyLine(0x319);
+		}
+		if (responseIndex == 2)
+			return playSparkyLine(0x334);
+
+		lineError = runtime.playDialogueFst(kDialogueC096FstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		return playSparkyLine(0x33f);
+	}
+
+	if (state.returnVisitPending) {
+		Common::Error lineError = Common::kNoError;
+		if (runtime.startupScript().getCurrentStoryDayIndex() == state.returnVisitDayIndex) {
+			lineError = playSparkyLine(0x3bb);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else {
+			const DialogueLineEntry returnVisitLines[] = {
+				{ 0x3ac, "SPARKY", 0 },
+				{ 0x3b0, "PC", 0 },
+				{ 0x3b4, "SPARKY", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(returnVisitLines, ARRAYSIZE(returnVisitLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			state.returnVisitDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
+		}
+	}
+
+	if (sparkyTopicBufferLineIndex < 0)
+		assignSparkyTopicBuffer(0x2c3);
+
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runtime.runKeywordMenu(
+			sparkyTopicBuffer, sparkyTopicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.empty())
+			return Common::kNoError;
+		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
+				runtime.matchesResponseLine(selectedTopic, 0x2c3)) {
+			return playSparkyLine(0x39e);
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x2c4) ||
+				runtime.matchesResponseLine(selectedTopic, 0x2c5)) {
+			Common::Error lineError = playSparkyLine(0x34c);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC096AFstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x2c7)) {
+			Common::Error lineError = playSparkyLine(0x35f);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x2c8)) {
+			Common::Error lineError = playSparkyLine(0x36c);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playSparkyLine(0x36d);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignSparkyTopicBuffer(0x2c9);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x2ca) ||
+				runtime.matchesResponseLine(selectedTopic, 0x2cb)) {
+			Common::Error lineError = playSparkyLine(0x37a);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x2cc, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = playSparkyLine(0x38f);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = runtime.playDialogueFst(kDialogueNudetatuFstPath);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else if (responseIndex == 2) {
+				lineError = playSparkyLine(0x35f);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x2ce))
+			continue;
+
+		Common::Error lineError = playSparkyLine(0x3c1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/sparky_dialogue.h b/engines/harvester/npc/sparky_dialogue.h
new file mode 100644
index 00000000000..570db24cecc
--- /dev/null
+++ b/engines/harvester/npc/sparky_dialogue.h
@@ -0,0 +1,50 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_SPARKY_DIALOGUE_H
+#define HARVESTER_NPC_SPARKY_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class SparkyDialogueHandler : public NpcDialogueHandler {
+public:
+	struct SparkyRoomDialogueState {
+		bool introPending = true;
+		bool returnVisitPending = false;
+		int returnVisitDayIndex = -1;
+		Common::String currentTopicBuffer;
+		int currentTopicBufferLineIndex = -1;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = SparkyRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	SparkyRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_SPARKY_DIALOGUE_H


Commit: 8e36f3b45ed47d1e0b2a84651b1b51c222e0b1b0
    https://github.com/scummvm/scummvm/commit/8e36f3b45ed47d1e0b2a84651b1b51c222e0b1b0
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:11+02:00

Commit Message:
HARVESTER: Port sergeant dialogue

Changed paths:
  A engines/harvester/npc/sergeant_dialogue.cpp
  A engines/harvester/npc/sergeant_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk
    engines/harvester/npc/dialogue_handler.h
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/TRACKER.md b/TRACKER.md
index d6e309518a7..f90a6eb2d90 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -9,19 +9,19 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `886`
-- Named/documented: `569`
-- Still `FUN_*` / undocumented: `317`
+- Total functions: `896`
+- Named/documented: `577`
+- Still `FUN_*` / undocumented: `319`
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_sparky @ 0x39240` into a dedicated `engines/harvester/npc/sparky_dialogue.*` handler.
-  - Native Sparky dialogue is a compact stateful handler: one interrupt bark for the light switch, a first-visit `SPARKY_SPOTS_MEAT` sequence bracketed by `C135.FST` / `C098.FST`, and then a revisit keyword loop with a `0x2c9` submenu rewrite and `NUDETATU.FST` branch.
-  - This pass also fixed a Ghidra blind spot by promoting and renaming the tiny FST wrappers at `0x38630`, `0x38650`, `0x38660`, `0x38670`, `0x38690`, and `0x38b80` to `play_c135_fst`, `play_c096_fst`, `play_c096a_fst`, `play_nudetatu_fst`, `play_c098_fst`, and `play_c149_fst`.
+- Ported `handle_talk_to_sergeant @ 0x30340` into a dedicated `engines/harvester/npc/sergeant_dialogue.*` handler.
+  - Native Sergeant dialogue is still a compact quest-delivery handler: the intro/revisit bark pair plus direct item or flag-driven branches for `REMAINS`, `INVITE`, `BARBER_POLE`, `BOLTCLTH`, `SCRATCHED_TUCKER`, `DINER_BURNED`, and `COMPLETED_LODGE_APPLICATION`.
+  - This pass also added a minimal runtime `setRuntimeNpcState()` helper so dialogue handlers can mirror native `SET_NPC`-style scene changes, and the shared neutral Sergeant wrapper bits now live in engine-side shared dialogue state for later Stephanie/Dad followups.
 
 ## Next Suggested Action
 
-1. Move back to the more stateful visible handlers now that the compact visible bucket is mostly cleared.
-   - The next best candidate is `SERGEANT`, which is still compact enough to port directly but needs a couple of runtime NPC/object state helpers.
-2. After that visible evidence-handler pass, return to Boyle and the other larger hidden-topic handlers.
-   - After `SERGEANT`, the remaining Ghidra-backed handlers are `STEPHANIE`, `DAD`, and `BOYLE`.
+1. Port `handle_talk_to_stephanie @ 0x36710` next.
+   - Stephanie is now the last compact-ish named handler before the remaining heavier `DAD` / `BOYLE` hidden-topic ports, and Sergeant's shared-state writes are now in place for the known Tuesday-night / quest followups.
+2. After Stephanie, finish the last two Ghidra-identified top-level handlers: `handle_talk_to_dad @ 0x2b020` and `handle_talk_to_boyle @ 0x2cb20`.
+   - Revisit the Mom raw-disassembly audit afterward as a separate fidelity pass once the remaining named dispatch targets are no longer stubbed.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 6f495247416..a3f5f4ada98 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -74,6 +74,7 @@
 #include "harvester/npc/priest_dialogue.h"
 #include "harvester/npc/pta_mom_dialogue.h"
 #include "harvester/npc/ryder_dialogue.h"
+#include "harvester/npc/sergeant_dialogue.h"
 #include "harvester/npc/nude_man_dialogue.h"
 #include "harvester/npc/stub_dialogue.h"
 #include "harvester/npc/sparky_dialogue.h"
@@ -408,6 +409,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new JohnsonDialogueHandler());
 	_npcHandlers.push_back(new PtaMomDialogueHandler());
 
+	_npcHandlers.push_back(new SergeantDialogueHandler());
 	_npcHandlers.push_back(new StubNpcDialogueHandler("PTA_MOM"));
 }
 
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 75ca004bbd6..d8e36a53885 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -45,6 +45,7 @@ MODULE := engines/harvester
 	npc/priest_dialogue.o \
 	npc/pta_mom_dialogue.o \
 	npc/ryder_dialogue.o \
+	npc/sergeant_dialogue.o \
 	npc/sparky_dialogue.o \
 	npc/stub_dialogue.o \
 	npc/swell_dialogue.o \
diff --git a/engines/harvester/npc/dialogue_handler.h b/engines/harvester/npc/dialogue_handler.h
index 0a6d1392ca4..666de46e06f 100644
--- a/engines/harvester/npc/dialogue_handler.h
+++ b/engines/harvester/npc/dialogue_handler.h
@@ -37,8 +37,10 @@ struct DialogueSharedState {
 	bool dialogueStateD2f04 = false;
 	bool dialogueStateD2ea4 = false;
 	bool dialogueStateD2ea8 = false;
+	bool dialogueStateD2f00 = false;
 	bool karinKidnapedDialogueState = false;
 	bool discussedLodgeTopic = false;
+	bool dialogueStateD2f08 = false;
 	bool waspWomanDialogueState = false;
 };
 
diff --git a/engines/harvester/npc/sergeant_dialogue.cpp b/engines/harvester/npc/sergeant_dialogue.cpp
new file mode 100644
index 00000000000..20f1f7639e3
--- /dev/null
+++ b/engines/harvester/npc/sergeant_dialogue.cpp
@@ -0,0 +1,159 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/sergeant_dialogue.h"
+
+#include "common/array.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kDialogueC149FstPath = "GRAPHIC/FST/C149.FST";
+
+} // End of namespace
+
+bool SergeantDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("SERGEANT");
+}
+
+Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	SergeantRoomDialogueState &state = _state;
+	auto playSergeantLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "SERGEANT", headVariant);
+	};
+	auto hasInventoryItem = [&](const char *objectName) {
+		Common::Array<StartupObjectRecord> inventoryObjects;
+		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
+		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
+				return true;
+		}
+
+		return false;
+	};
+	auto markSergeantProgress = [&]() {
+		sharedState.dialogueStateD2f08 = true;
+		sharedState.dialogueStateD2f00 = true;
+		state.introPending = false;
+		state.dialogueStateD2d54 = true;
+	};
+	auto restoreItemToRah = [&](const char *objectName) {
+		(void)runtime.startupScript().resetRuntimeObjectToInitialState(objectName);
+		(void)runtime.startupScript().setRuntimeObjectVisible("RAH", objectName, true);
+	};
+	auto handleRemainsBranch = [&]() -> Common::Error {
+		markSergeantProgress();
+		restoreItemToRah("REMAINS");
+		return playSergeantLine(0x4333);
+	};
+	auto handleInviteBranch = [&]() -> Common::Error {
+		markSergeantProgress();
+		(void)runtime.startupScript().resetRuntimeObjectToInitialState("STEFSKULL");
+		(void)runtime.startupScript().setRuntimeObjectVisible("ST_BEDRM", "STEFSKULL", true);
+		restoreItemToRah("INVITE");
+		(void)runtime.startupScript().setRuntimeFlagValue("TAKEN_INVITE_TO_SERGEANT", true);
+		(void)runtime.startupScript().setRuntimeFlagValue("NEED_REMAINS_FOR_LODGE", true);
+		(void)runtime.startupScript().setRuntimeNpcState("DWAYNE_ST_BEDRM", false, false);
+		(void)runtime.startupScript().setRuntimeNpcState("MRS_POTTS_ST_BEDRM", false, false);
+		(void)runtime.startupScript().setRuntimeNpcState("MRS_POTTS", true, true);
+
+		Common::Error lineError = runtime.playDialogueFst(kDialogueC149FstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		return runtime.playDialogueLine(0x4305, "PC");
+	};
+	auto handleBarberPoleBranch = [&]() -> Common::Error {
+		markSergeantProgress();
+		restoreItemToRah("BARBER_POLE");
+		(void)runtime.startupScript().setRuntimeFlagValue("ASSIGNED_DNA_TASK", true);
+		return playSergeantLine(0x426f);
+	};
+	auto handleBoltClothBranch = [&]() -> Common::Error {
+		markSergeantProgress();
+		restoreItemToRah("BOLTCLTH");
+		return playSergeantLine(0x4241);
+	};
+	auto handleCompletedApplicationBranch = [&]() -> Common::Error {
+		sharedState.dialogueStateD2f08 = true;
+		sharedState.dialogueStateD2f00 = true;
+		state.introPending = false;
+		restoreItemToRah("COMPLETED_LODGE_APPLICATION");
+		if (!runtime.startupScript().getFlagValue("QUEST_1"))
+			return playSergeantLine(0x41cd);
+		if (!state.dialogueStateD2d54)
+			return playSergeantLine(0x41d2);
+		return playSergeantLine(0x41f0);
+	};
+
+	if (usedItemName.empty()) {
+		if (runtime.startupScript().getFlagValue("GOT_REMAINS_FOR_LODGE"))
+			return handleRemainsBranch();
+		if (runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD") &&
+				(hasInventoryItem("INVITE") ||
+					runtime.startupScript().getFlagValue("TAKEN_INVITE_TO_SERGEANT"))) {
+			return handleInviteBranch();
+		}
+		if (runtime.startupScript().getFlagValue("DINER_BURNED")) {
+			markSergeantProgress();
+			return playSergeantLine(0x42b5, 1);
+		}
+		if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN"))
+			return handleBarberPoleBranch();
+		if (runtime.startupScript().getFlagValue("BOLT_OF_CLOTH_TAKEN"))
+			return handleBoltClothBranch();
+		if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER")) {
+			sharedState.dialogueStateD2f08 = true;
+			sharedState.dialogueStateD2f00 = true;
+			state.introPending = false;
+			state.dialogueStateD2d54 = true;
+			state.completedFirstTaskState = true;
+			return playSergeantLine(0x420d);
+		}
+		if (runtime.startupScript().getFlagValue("HAVE_COMPLETED_LODGE_APP"))
+			return handleCompletedApplicationBranch();
+		if (state.introPending) {
+			sharedState.dialogueStateD2f08 = true;
+			state.introPending = false;
+			return playSergeantLine(0x413f);
+		}
+		return playSergeantLine(0x41ac);
+	}
+
+	if (usedItemName.equalsIgnoreCase("BARBER_POLE"))
+		return handleBarberPoleBranch();
+	if (usedItemName.equalsIgnoreCase("BOLTCLTH"))
+		return handleBoltClothBranch();
+	if (usedItemName.equalsIgnoreCase("COMPLETED_LODGE_APPLICATION"))
+		return handleCompletedApplicationBranch();
+	if (usedItemName.equalsIgnoreCase("INVITE"))
+		return handleInviteBranch();
+	if (usedItemName.equalsIgnoreCase("REMAINS"))
+		return handleRemainsBranch();
+
+	return playSergeantLine(0x42fa);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/sergeant_dialogue.h b/engines/harvester/npc/sergeant_dialogue.h
new file mode 100644
index 00000000000..1a7e5db2eb4
--- /dev/null
+++ b/engines/harvester/npc/sergeant_dialogue.h
@@ -0,0 +1,48 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_SERGEANT_DIALOGUE_H
+#define HARVESTER_NPC_SERGEANT_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class SergeantDialogueHandler : public NpcDialogueHandler {
+public:
+	struct SergeantRoomDialogueState {
+		bool introPending = true;
+		bool dialogueStateD2d54 = false;
+		bool completedFirstTaskState = false;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = SergeantRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	SergeantRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_SERGEANT_DIALOGUE_H
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index fa9ee62d99b..0b7e1b51245 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -994,6 +994,20 @@ bool StartupScript::setRuntimeObjectVisible(const Common::String &ownerOrRoom,
 	return changed;
 }
 
+bool StartupScript::setRuntimeNpcState(const Common::String &npcName, bool active, bool visible) {
+	if (npcName.empty())
+		return false;
+
+	StartupNpcRecord *runtimeNpc = findRuntimeNpc(npcName);
+	if (!runtimeNpc)
+		return false;
+
+	const bool changed = runtimeNpc->active != active || runtimeNpc->visible != visible;
+	runtimeNpc->active = active;
+	runtimeNpc->visible = visible;
+	return changed;
+}
+
 bool StartupScript::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupEntranceRecord *entrance,
 		StartupRoomSetupState &state) const {
 	state = StartupRoomSetupState();
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 09e1d95955a..ddd8258d01a 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -304,6 +304,7 @@ public:
 		bool addRuntimeObjectToInventory(const Common::String &objectName);
 		bool setRuntimeObjectVisible(const Common::String &ownerOrRoom, const Common::String &objectName,
 			bool visible);
+		bool setRuntimeNpcState(const Common::String &npcName, bool active, bool visible);
 		bool isNamedNpcDeathTypeClear(const Common::String &npcName) const;
 		int getCurrentStoryDayIndex() const;
 


Commit: 6d2e4eaa8a215fd67a9e50ae5343efe52d230465
    https://github.com/scummvm/scummvm/commit/6d2e4eaa8a215fd67a9e50ae5343efe52d230465
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:12+02:00

Commit Message:
HARVESTER: Port dad dialogue

Changed paths:
  A engines/harvester/npc/dad_dialogue.cpp
  A engines/harvester/npc/dad_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index f90a6eb2d90..de1b6037a95 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_sergeant @ 0x30340` into a dedicated `engines/harvester/npc/sergeant_dialogue.*` handler.
-  - Native Sergeant dialogue is still a compact quest-delivery handler: the intro/revisit bark pair plus direct item or flag-driven branches for `REMAINS`, `INVITE`, `BARBER_POLE`, `BOLTCLTH`, `SCRATCHED_TUCKER`, `DINER_BURNED`, and `COMPLETED_LODGE_APPLICATION`.
-  - This pass also added a minimal runtime `setRuntimeNpcState()` helper so dialogue handlers can mirror native `SET_NPC`-style scene changes, and the shared neutral Sergeant wrapper bits now live in engine-side shared dialogue state for later Stephanie/Dad followups.
+- Ported `handle_talk_to_dad @ 0x2b020` into a dedicated `engines/harvester/npc/dad_dialogue.*` handler.
+  - Dad is now covered as a compact visible handler: the `TAKING_BONDAGE` interrupt, the two-state no-item bark path, and the linear evidence-item fan-out for corpse photo, Whaley/Herrill photo, note/checkbook evidence, and `MEAT_PERMISSION0`.
+  - The only still-unrecovered piece in that handler is the gameplay-side setter behind Dad's local `MEAT_PERMISSION0` wrapper at `0x38400`; the engine keeps that state explicit instead of guessing a write path.
 
 ## Next Suggested Action
 
 1. Port `handle_talk_to_stephanie @ 0x36710` next.
-   - Stephanie is now the last compact-ish named handler before the remaining heavier `DAD` / `BOYLE` hidden-topic ports, and Sergeant's shared-state writes are now in place for the known Tuesday-night / quest followups.
-2. After Stephanie, finish the last two Ghidra-identified top-level handlers: `handle_talk_to_dad @ 0x2b020` and `handle_talk_to_boyle @ 0x2cb20`.
-   - Revisit the Mom raw-disassembly audit afterward as a separate fidelity pass once the remaining named dispatch targets are no longer stubbed.
+   - Stephanie is now the only remaining large visible/topic-heavy handler before the final compact Boyle pass.
+2. After Stephanie, finish the last remaining non-stub top-level handler: `handle_talk_to_boyle @ 0x2cb20`.
+   - Revisit the Mom raw-disassembly audit afterward as a separate fidelity pass once `STEPHANIE` and `BOYLE` are both in place.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index a3f5f4ada98..7866daa93e5 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -43,6 +43,7 @@
 #include "harvester/npc/chessmaster_dialogue.h"
 #include "harvester/npc/cloak_atnd_dialogue.h"
 #include "harvester/npc/curator_dialogue.h"
+#include "harvester/npc/dad_dialogue.h"
 #include "harvester/npc/dark_woman_dialogue.h"
 #include "harvester/npc/dwayne_dialogue.h"
 #include "harvester/npc/dialogue_runtime.h"
@@ -375,6 +376,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new ChessmasterDialogueHandler());
 	_npcHandlers.push_back(new CloakAtndDialogueHandler());
 	_npcHandlers.push_back(new CuratorDialogueHandler());
+	_npcHandlers.push_back(new DadDialogueHandler());
 	_npcHandlers.push_back(new DarkWomanDialogueHandler());
 	_npcHandlers.push_back(new JimmyDialogueHandler());
 	_npcHandlers.push_back(new InquisitorDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index d8e36a53885..90b4625c0a0 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -14,6 +14,7 @@ MODULE := engines/harvester
 	fst_player.o \
 	metaengine.o \
 	npc/curator_dialogue.o \
+	npc/dad_dialogue.o \
 	npc/dark_woman_dialogue.o \
 	npc/dwayne_dialogue.o \
 	npc/edna_dialogue.o \
diff --git a/engines/harvester/npc/dad_dialogue.cpp b/engines/harvester/npc/dad_dialogue.cpp
new file mode 100644
index 00000000000..f9383000330
--- /dev/null
+++ b/engines/harvester/npc/dad_dialogue.cpp
@@ -0,0 +1,80 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/dad_dialogue.h"
+
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool DadDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("DAD");
+}
+
+Common::Error DadDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &) {
+	DadRoomDialogueState &state = _state;
+	auto playDadLine = [&](int wavId, const char *speakerId = "DAD") -> Common::Error {
+		return runtime.playDialogueLine(wavId, speakerId);
+	};
+
+	if (runtime.startupScript().getFlagValue("TAKING_BONDAGE"))
+		return playDadLine(0x3a61);
+
+	if (usedItemName.empty()) {
+		if (state.introPending) {
+			state.introPending = false;
+			return playDadLine(0x39a5);
+		}
+		return playDadLine(0x3a17);
+	}
+
+	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+		return playDadLine(0x3a39);
+	}
+	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(
+			DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+		return playDadLine(0x3a41);
+	}
+	if (usedItemName.equalsIgnoreCase("NOTE") ||
+			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(
+			DialogueFlags::kShownEvidenceOfBlackmail, true);
+		return playDadLine(0x3a51);
+	}
+	if (usedItemName.equalsIgnoreCase("MEAT_PERMISSION0")) {
+		if (state.meatPermissionState)
+			return playDadLine(0x39f2, "PC");
+		return playDadLine(0x3a32);
+	}
+
+	return playDadLine(0x3a32);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/dad_dialogue.h b/engines/harvester/npc/dad_dialogue.h
new file mode 100644
index 00000000000..fa0c0af9b46
--- /dev/null
+++ b/engines/harvester/npc/dad_dialogue.h
@@ -0,0 +1,47 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_DAD_DIALOGUE_H
+#define HARVESTER_NPC_DAD_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class DadDialogueHandler : public NpcDialogueHandler {
+public:
+	struct DadRoomDialogueState {
+		bool introPending = true;
+		bool meatPermissionState = false;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = DadRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	DadRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_DAD_DIALOGUE_H


Commit: b4fa6537b87cf091b008986151827bced6a35405
    https://github.com/scummvm/scummvm/commit/b4fa6537b87cf091b008986151827bced6a35405
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:12+02:00

Commit Message:
HARVESTER: Port boyle dialogue

Changed paths:
  A engines/harvester/npc/boyle_dialogue.cpp
  A engines/harvester/npc/boyle_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index de1b6037a95..b153ed97cca 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_dad @ 0x2b020` into a dedicated `engines/harvester/npc/dad_dialogue.*` handler.
-  - Dad is now covered as a compact visible handler: the `TAKING_BONDAGE` interrupt, the two-state no-item bark path, and the linear evidence-item fan-out for corpse photo, Whaley/Herrill photo, note/checkbook evidence, and `MEAT_PERMISSION0`.
-  - The only still-unrecovered piece in that handler is the gameplay-side setter behind Dad's local `MEAT_PERMISSION0` wrapper at `0x38400`; the engine keeps that state explicit instead of guessing a write path.
+- Ported `handle_talk_to_boyle @ 0x2cb20` into a dedicated `engines/harvester/npc/boyle_dialogue.*` handler.
+  - Boyle is now covered as a compact visible handler: the one-shot no-item bark, the button / evidence item replies, and the recovered hidden `GASCAN` return exchange that awards `LODGE_APPLICATION`, clears the gas can, and sets `HAVE_LODGE_APP`.
+  - The larger hidden Boyle keyword block around `0x2d620` is still documented in `ARCHITECTURE.md` as a follow-on fidelity target, but the top-level dispatch target is no longer stubbed in the engine.
 
 ## Next Suggested Action
 
 1. Port `handle_talk_to_stephanie @ 0x36710` next.
-   - Stephanie is now the only remaining large visible/topic-heavy handler before the final compact Boyle pass.
-2. After Stephanie, finish the last remaining non-stub top-level handler: `handle_talk_to_boyle @ 0x2cb20`.
-   - Revisit the Mom raw-disassembly audit afterward as a separate fidelity pass once `STEPHANIE` and `BOYLE` are both in place.
+   - Stephanie is now the last remaining Ghidra-identified top-level NPC dialogue handler without an engine-side class, and the Sergeant / Potts shared-state prerequisites for her known branches are already in place.
+2. After Stephanie lands, switch from coverage work back to fidelity work.
+   - Highest-value followups are Stephanie's deeper hidden topic branches, Boyle's `0x2d620` keyword block, and the separate Mom raw-disassembly rebuild.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 7866daa93e5..937a30ae40a 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -34,6 +34,7 @@
 #include "graphics/framelimiter.h"
 #include "harvester/npc/authority_dialogue.h"
 #include "harvester/npc/beggar_dialogue.h"
+#include "harvester/npc/boyle_dialogue.h"
 #include "harvester/npc/buster_dialogue.h"
 #include "harvester/npc/butcher_dialogue.h"
 #include "harvester/cft_font.h"
@@ -367,6 +368,7 @@ DialogueSystem::~DialogueSystem() {
 void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new AuthorityDialogueHandler());
 	_npcHandlers.push_back(new BeggarDialogueHandler());
+	_npcHandlers.push_back(new BoyleDialogueHandler());
 	_npcHandlers.push_back(new BusterDialogueHandler());
 	_npcHandlers.push_back(new ButcherDialogueHandler());
 	_npcHandlers.push_back(new DwayneDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 90b4625c0a0..8c3a479ffcf 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -3,6 +3,7 @@ MODULE := engines/harvester
 	MODULE_OBJS = \
 	npc/authority_dialogue.o \
 	npc/beggar_dialogue.o \
+	npc/boyle_dialogue.o \
 	npc/buster_dialogue.o \
 	npc/butcher_dialogue.o \
 	cft_font.o \
diff --git a/engines/harvester/npc/boyle_dialogue.cpp b/engines/harvester/npc/boyle_dialogue.cpp
new file mode 100644
index 00000000000..5ceb02285eb
--- /dev/null
+++ b/engines/harvester/npc/boyle_dialogue.cpp
@@ -0,0 +1,94 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/boyle_dialogue.h"
+
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+bool BoyleDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("BOYLE");
+}
+
+Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	BoyleRoomDialogueState &state = _state;
+	auto playBoyleLine = [&](int wavId) -> Common::Error {
+		return runtime.playDialogueLine(wavId, "BOYLE");
+	};
+
+	if (usedItemName.empty()) {
+		if (state.introPending) {
+			state.introPending = false;
+			return playBoyleLine(0x7);
+		}
+		return playBoyleLine(0x23c);
+	}
+
+	if (usedItemName.equalsIgnoreCase("BOYLES_BUTTON"))
+		return playBoyleLine(0x155);
+
+	if (usedItemName.equalsIgnoreCase("NOTE") ||
+			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(
+			DialogueFlags::kShownEvidenceOfBlackmail, true);
+		return playBoyleLine(runtime.startupScript().getFlagValue("HAVE_LODGE_APP") ? 0x13d : 0x197);
+	}
+
+	if (usedItemName.equalsIgnoreCase("GASCAN") && sharedState.boyleGascanApplicationState) {
+		const DialogueLineEntry gascanLines[] = {
+			{ 0x1f2, "BOYLE", 0 },
+			{ 0x1f6, "BOYLE", 0 },
+			{ 0x1fa, "BOYLE", 0 },
+			{ 0x206, "BOYLE", 0 }
+		};
+		Common::Error lineError = runtime.playDialogueEntrySequence(gascanLines, ARRAYSIZE(gascanLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		sharedState.boyleGascanApplicationState = false;
+		(void)runtime.startupScript().resetRuntimeObjectToInitialState("GASCAN");
+		(void)runtime.startupScript().addRuntimeObjectToInventory("LODGE_APPLICATION");
+		(void)runtime.startupScript().setRuntimeFlagValue("HAVE_LODGE_APP", true);
+		return Common::kNoError;
+	}
+
+	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(
+			DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+		return playBoyleLine(0x25f);
+	}
+
+	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+		return playBoyleLine(0x24f);
+	}
+
+	return playBoyleLine(0x249);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/boyle_dialogue.h b/engines/harvester/npc/boyle_dialogue.h
new file mode 100644
index 00000000000..0de4053bdfa
--- /dev/null
+++ b/engines/harvester/npc/boyle_dialogue.h
@@ -0,0 +1,46 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_BOYLE_DIALOGUE_H
+#define HARVESTER_NPC_BOYLE_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class BoyleDialogueHandler : public NpcDialogueHandler {
+public:
+	struct BoyleRoomDialogueState {
+		bool introPending = true;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = BoyleRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	BoyleRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_BOYLE_DIALOGUE_H


Commit: 2ec10ae62397c912c8ccd0227fc0004f6d75d327
    https://github.com/scummvm/scummvm/commit/2ec10ae62397c912c8ccd0227fc0004f6d75d327
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:12+02:00

Commit Message:
HARVESTER: Port stephanie dialogue

Changed paths:
  A engines/harvester/npc/stephanie_dialogue.cpp
  A engines/harvester/npc/stephanie_dialogue.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index b153ed97cca..14d8d547194 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_boyle @ 0x2cb20` into a dedicated `engines/harvester/npc/boyle_dialogue.*` handler.
-  - Boyle is now covered as a compact visible handler: the one-shot no-item bark, the button / evidence item replies, and the recovered hidden `GASCAN` return exchange that awards `LODGE_APPLICATION`, clears the gas can, and sets `HAVE_LODGE_APP`.
-  - The larger hidden Boyle keyword block around `0x2d620` is still documented in `ARCHITECTURE.md` as a follow-on fidelity target, but the top-level dispatch target is no longer stubbed in the engine.
+- Ported `handle_talk_to_stephanie @ 0x36710` into a dedicated `engines/harvester/npc/stephanie_dialogue.*` handler.
+  - Stephanie now covers the confirmed top-level dispatch surface: evidence-item replies, the staged intro / revisit flow, the nasty-path FST and action-tag branch, the day-5 and Karin outcome one-shots, and the recovered keyword-menu branches that hang off her visible topic buffers.
+  - This completes engine-side coverage for every currently identified top-level NPC dialogue handler recovered from Ghidra.
 
 ## Next Suggested Action
 
-1. Port `handle_talk_to_stephanie @ 0x36710` next.
-   - Stephanie is now the last remaining Ghidra-identified top-level NPC dialogue handler without an engine-side class, and the Sergeant / Potts shared-state prerequisites for her known branches are already in place.
-2. After Stephanie lands, switch from coverage work back to fidelity work.
+1. Switch from NPC coverage work back to dialogue fidelity work.
    - Highest-value followups are Stephanie's deeper hidden topic branches, Boyle's `0x2d620` keyword block, and the separate Mom raw-disassembly rebuild.
+2. Keep folding any newly confirmed hidden helper fixes from Ghidra back into the per-NPC handlers.
+   - The coverage pass is complete, so remaining work is behavior parity and disassembly cleanup rather than adding more top-level handler classes.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 937a30ae40a..8461c3ad0f5 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -78,6 +78,7 @@
 #include "harvester/npc/ryder_dialogue.h"
 #include "harvester/npc/sergeant_dialogue.h"
 #include "harvester/npc/nude_man_dialogue.h"
+#include "harvester/npc/stephanie_dialogue.h"
 #include "harvester/npc/stub_dialogue.h"
 #include "harvester/npc/sparky_dialogue.h"
 #include "harvester/npc/swell_dialogue.h"
@@ -406,6 +407,7 @@ void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new ValetDialogueHandler());
 	_npcHandlers.push_back(new VetDialogueHandler());
 	_npcHandlers.push_back(new SparkyDialogueHandler());
+	_npcHandlers.push_back(new StephanieDialogueHandler());
 	_npcHandlers.push_back(new SwellDialogueHandler());
 	_npcHandlers.push_back(new WhaleyDialogueHandler());
 	_npcHandlers.push_back(new HankDialogueHandler());
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 8c3a479ffcf..3d6dea0dac4 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -48,6 +48,7 @@ MODULE := engines/harvester
 	npc/pta_mom_dialogue.o \
 	npc/ryder_dialogue.o \
 	npc/sergeant_dialogue.o \
+	npc/stephanie_dialogue.o \
 	npc/sparky_dialogue.o \
 	npc/stub_dialogue.o \
 	npc/swell_dialogue.o \
diff --git a/engines/harvester/npc/stephanie_dialogue.cpp b/engines/harvester/npc/stephanie_dialogue.cpp
new file mode 100644
index 00000000000..b20b73f2744
--- /dev/null
+++ b/engines/harvester/npc/stephanie_dialogue.cpp
@@ -0,0 +1,534 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <functional>
+
+#include "harvester/npc/stephanie_dialogue.h"
+
+#include "harvester/npc/dialogue_flags.h"
+#include "harvester/npc/dialogue_runtime.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kDialogueC022AFstPath = "GRAPHIC/FST/C022A.FST";
+static const char *const kDialogueC022BFstPath = "GRAPHIC/FST/C022B.FST";
+static const char *const kDialogueC023AFstPath = "GRAPHIC/FST/C023A.FST";
+static const char *const kDialogueC025AFstPath = "GRAPHIC/FST/C025A.FST";
+static const char *const kDialogueC025BFstPath = "GRAPHIC/FST/C025B.FST";
+static const char *const kDialogueC007FstPath = "GRAPHIC/FST/C007.FST";
+
+} // End of namespace
+
+bool StephanieDialogueHandler::matchesNpc(const Common::String &npcName) const {
+	return npcName.equalsIgnoreCase("STEPHANIE");
+}
+
+Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	StephanieRoomDialogueState &state = _state;
+	Common::String &stephanieTopicBuffer = state.currentTopicBuffer;
+	int &stephanieTopicBufferLineIndex = state.currentTopicBufferLineIndex;
+
+	auto assignStephanieTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(stephanieTopicBuffer, stephanieTopicBufferLineIndex,
+			responseLineIndex, "Stephanie topic buffer");
+	};
+	auto playStephanieLine = [&](int wavId, const char *speakerId = "STEPHANIE") -> Common::Error {
+		return runtime.playDialogueLine(wavId, speakerId);
+	};
+	auto executeDialogueActionTag = [&](const char *tag) {
+		StartupInteractionResult interaction;
+		if (runtime.startupScript().executeActionTag(tag, interaction)) {
+			runtime.applyImmediateDialogueInteractionEffects(interaction);
+			runtime.queueDialogueInteractionIfNeeded(interaction);
+		}
+	};
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("NOTE") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				DialogueFlags::kShownEvidenceOfBlackmail, true);
+			return playStephanieLine(0x466a);
+		}
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+			Common::Error lineError = playStephanieLine(0x4671);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueLine(0x4675, "PC");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			if (sharedState.dialogueStateD2ea8) {
+				lineError = runtime.playDialogueLine(0x467b, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			if (sharedState.dialogueStateD2ea4) {
+				lineError = runtime.playDialogueLine(0x467f, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			lineError = playStephanieLine(0x4684);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			return runtime.playDialogueLine(0x4689, "PC");
+		}
+		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+				usedItemName.equalsIgnoreCase("LEDGER2")) &&
+				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+			const DialogueLineEntry lines[] = {
+				{ 0x468f, "PC", 0 },
+				{ 0x4694, "STEPHANIE", 0 },
+				{ 0x469a, "PC", 0 },
+				{ 0x469e, "STEPHANIE", 0 }
+			};
+			return runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+		}
+
+		return playStephanieLine(0x4662);
+	}
+
+	if (state.introPending) {
+		state.introPending = false;
+		state.introDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
+		const DialogueLineEntry introLines[] = {
+			{ 0x43db, "STEPHANIE", 0 },
+			{ 0x43df, "PC", 0 },
+			{ 0x43e3, "STEPHANIE", 0 },
+			{ 0x43e7, "PC", 0 },
+			{ 0x43eb, "STEPHANIE", 0 },
+			{ 0x43f0, "PC", 0 },
+			{ 0x43f5, "STEPHANIE", 0 },
+			{ 0x43f9, "PC", 0 },
+			{ 0x43fd, "STEPHANIE", 0 }
+		};
+		Common::Error lineError = runtime.playDialogueEntrySequence(introLines, ARRAYSIZE(introLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignStephanieTopicBuffer(0x2cf);
+	} else if (runtime.startupScript().getCurrentStoryDayIndex() == state.introDayIndex) {
+		Common::Error lineError = playStephanieLine(0x459c);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignStephanieTopicBuffer(0x2d0);
+	} else if (state.firstFollowupPending) {
+		state.firstFollowupPending = false;
+		state.firstFollowupDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
+
+		Common::Error lineError = playStephanieLine(0x45a7);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (sharedState.dialogueStateD2f08) {
+			lineError = runtime.playDialogueLine(0x45ae, "PC");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		lineError = runtime.playDialogueLine(
+			(runtime.startupScript().getFlagValue("HAVE_LODGE_APP") &&
+				runtime.startupScript().getCurrentStoryDayIndex() == 1) ? 0x45b4 : 0x45b9,
+			"PC");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playStephanieLine(0x45be);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x2d1, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		const DialogueLineEntry followupLines[] = {
+			{ 0x45d8, "PC", 0 },
+			{ 0x45de, "PC", 0 },
+			{ 0x45e2, "STEPHANIE", 0 }
+		};
+		Common::Error lineSeqError = runtime.playDialogueEntrySequence(
+			followupLines, ARRAYSIZE(followupLines));
+		if (lineSeqError.getCode() != Common::kNoError)
+			return lineSeqError;
+	} else if (runtime.startupScript().getCurrentStoryDayIndex() == state.firstFollowupDayIndex) {
+		Common::Error lineError = playStephanieLine(0x4654);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		state.secondFollowupPending = true;
+	} else if (state.secondFollowupPending) {
+		state.secondFollowupPending = false;
+		state.secondFollowupDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
+
+		const DialogueLineEntry nastyIntroLines[] = {
+			{ 0x45e8, "STEPHANIE", 0 },
+			{ 0x45ed, "PC", 0 },
+			{ 0x45f2, "STEPHANIE", 0 }
+		};
+		Common::Error lineError = runtime.playDialogueEntrySequence(
+			nastyIntroLines, ARRAYSIZE(nastyIntroLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = runtime.playDialogueFst(kDialogueC023AFstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = runtime.playDialogueLine(0x45fe, "PC");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playStephanieLine(0x4602);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x2d3, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 1) {
+			lineError = playStephanieLine(0x460e);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			lineError = playStephanieLine(0x4612);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		const DialogueLineEntry nastyFollowupLines[] = {
+			{ 0x4616, "STEPHANIE", 0 },
+			{ 0x461b, "PC", 0 },
+			{ 0x461f, "STEPHANIE", 0 },
+			{ 0x4625, "PC", 0 },
+			{ 0x4629, "STEPHANIE", 0 }
+		};
+		lineError = runtime.playDialogueEntrySequence(nastyFollowupLines, ARRAYSIZE(nastyFollowupLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = runtime.playDialogueFst(kDialogueC025AFstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		responseIndex = 0;
+		responseError = runtime.runResponseMenu(0x2d5, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 1) {
+			(void)runtime.playDialogueFst(kDialogueC025BFstPath);
+			(void)runtime.playDialogueFst(kDialogueC007FstPath);
+			executeDialogueActionTag("LEAVE_STEPH_AFTER_NASTY");
+			return Common::kNoError;
+		}
+		if (responseIndex == 2) {
+			lineError = runtime.playDialogueLine(0x4649, "PC");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playStephanieLine(0x464e);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+	} else {
+		Common::Error lineError = playStephanieLine(0x4654);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getCurrentStoryDayIndex() == 5 && !state.dayFiveLinePlayed) {
+		state.dayFiveLinePlayed = true;
+		const DialogueLineEntry dayFiveLines[] = {
+			{ 0x46aa, "STEPHANIE", 0 },
+			{ 0x46ae, "PC", 0 },
+			{ 0x46b3, "STEPHANIE", 0 },
+			{ 0x46bf, "STEPHANIE", 0 },
+			{ 0x46ba, "PC", 0 },
+			{ 0x46c5, "PC", 0 },
+			{ 0x46c9, "STEPHANIE", 0 }
+		};
+		Common::Error lineError = runtime.playDialogueEntrySequence(dayFiveLines, ARRAYSIZE(dayFiveLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
+			!state.karinKidnapedLinePlayed &&
+			sharedState.karinKidnapedDialogueState) {
+		state.karinKidnapedLinePlayed = true;
+		const DialogueLineEntry kidnapedLines[] = {
+			{ 0x4957, "STEPHANIE", 0 },
+			{ 0x495b, "PC", 0 },
+			{ 0x4960, "STEPHANIE", 0 },
+			{ 0x4965, "PC", 0 },
+			{ 0x4969, "STEPHANIE", 0 }
+		};
+		Common::Error lineError = runtime.playDialogueEntrySequence(kidnapedLines, ARRAYSIZE(kidnapedLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if ((runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") ||
+				runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD")) &&
+			!state.karinOutcomeLinePlayed) {
+		state.karinOutcomeLinePlayed = true;
+		Common::Error lineError = playStephanieLine(0x4996);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE")) {
+			const DialogueLineEntry aliveLines[] = {
+				{ 0x499a, "PC", 0 },
+				{ 0x49a1, "STEPHANIE", 0 },
+				{ 0x49ad, "PC", 0 },
+				{ 0x49b1, "STEPHANIE", 0 },
+				{ 0x49b6, "PC", 0 },
+				{ 0x49ba, "STEPHANIE", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(aliveLines, ARRAYSIZE(aliveLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else {
+			const DialogueLineEntry deadLines[] = {
+				{ 0x49da, "PC", 0 },
+				{ 0x49e0, "STEPHANIE", 0 },
+				{ 0x49e4, "PC", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(deadLines, ARRAYSIZE(deadLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+	}
+
+	if (stephanieTopicBufferLineIndex < 0)
+		assignStephanieTopicBuffer(0x2d0);
+
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runtime.runKeywordMenu(
+			stephanieTopicBuffer, stephanieTopicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.empty())
+			return Common::kNoError;
+		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
+				runtime.matchesResponseLine(selectedTopic, 0x2e4)) {
+			return playStephanieLine(0x46a4);
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x2e5) ||
+				runtime.matchesResponseLine(selectedTopic, 0x2e6)) {
+			Common::Error lineError = playStephanieLine(0x4406);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x2e7, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1) {
+				const DialogueLineEntry lines[] = {
+					{ 0x4412, "STEPHANIE", 0 },
+					{ 0x4416, "PC", 0 },
+					{ 0x441a, "STEPHANIE", 0 }
+				};
+				lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				responseIndex = 0;
+				responseError = runtime.runResponseMenu(0x2e8, responseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+				if (responseIndex == 1)
+					lineError = playStephanieLine(0x4427);
+				else if (responseIndex == 2)
+					lineError = playStephanieLine(0x442c);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				return runtime.playDialogueLine(0x4430, "PC");
+			}
+			if (responseIndex == 2) {
+				lineError = playStephanieLine(0x4434);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = runtime.playDialogueLine(0x4439, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				return playStephanieLine(0x443d);
+			}
+			lineError = playStephanieLine(0x4441);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignStephanieTopicBuffer(0x2e9);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x2ea)) {
+			Common::Error lineError = playStephanieLine(0x444b);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueLine(0x4451, "PC");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playStephanieLine(0x4455);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x2eb, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1) {
+				const DialogueLineEntry lines[] = {
+					{ 0x4463, "STEPHANIE", 0 },
+					{ 0x446b, "PC", 0 },
+					{ 0x446f, "STEPHANIE", 0 },
+					{ 0x4477, "PC", 0 },
+					{ 0x447b, "STEPHANIE", 0 }
+				};
+				lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else if (responseIndex == 2) {
+				lineError = playStephanieLine(0x4480);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			assignStephanieTopicBuffer(0x2ec);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x2ed)) {
+			Common::Error lineError = playStephanieLine(0x448a);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x2ee, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1) {
+				lineError = playStephanieLine(0x4495);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else if (responseIndex == 2) {
+				lineError = playStephanieLine(0x4499);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = runtime.playDialogueLine(0x449e, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			const DialogueLineEntry lines[] = {
+				{ 0x44a2, "PC", 0 },
+				{ 0x44a6, "STEPHANIE", 0 },
+				{ 0x44ab, "PC", 0 },
+				{ 0x44af, "STEPHANIE", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignStephanieTopicBuffer(0x2ef);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x2f0)) {
+			const DialogueLineEntry lines[] = {
+				{ 0x44ba, "STEPHANIE", 0 },
+				{ 0x44bf, "PC", 0 },
+				{ 0x44c4, "STEPHANIE", 0 },
+				{ 0x44c9, "PC", 0 },
+				{ 0x44ce, "STEPHANIE", 0 },
+				{ 0x44dd, "PC", 0 },
+				{ 0x44e9, "STEPHANIE", 0 }
+			};
+			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignStephanieTopicBuffer(0x2f2);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x2f3)) {
+			Common::Error lineError = playStephanieLine(0x44f1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC022AFstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueLine(
+				sharedState.discussedLodgeTopic ? 0x4505 : 0x450a, "PC");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			sharedState.discussedLodgeTopic = true;
+			if (sharedState.dialogueStateD2f00) {
+				lineError = runtime.playDialogueLine(0x450f, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			const DialogueLineEntry lines[] = {
+				{ 0x4513, "STEPHANIE", 0 },
+				{ 0x4517, "PC", 0 },
+				{ 0x451b, "STEPHANIE", 0 },
+				{ 0x451f, "PC", 0 },
+				{ 0x4523, "STEPHANIE", 0 },
+				{ 0x452a, "PC", 0 },
+				{ 0x452e, "STEPHANIE", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC022BFstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x2f6)) {
+			Common::Error lineError = runtime.playDialogueLine(0x453d, "PC");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playStephanieLine(0x4541);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x2f7, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1) {
+				lineError = playStephanieLine(0x454b);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else if (responseIndex == 2) {
+				lineError = playStephanieLine(0x454f);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			assignStephanieTopicBuffer(0x2f8);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x2f9) ||
+				runtime.matchesResponseLine(selectedTopic, 0x2fa) ||
+				runtime.matchesResponseLine(selectedTopic, 0x2fb) ||
+				runtime.matchesResponseLine(selectedTopic, 0x2fc) ||
+				runtime.matchesResponseLine(selectedTopic, 0x2fd)) {
+			const DialogueLineEntry lines[] = {
+				{ 0x4a05, "PC", 0 },
+				{ 0x4a0a, "STEPHANIE", 0 },
+				{ 0x4a0e, "PC", 0 },
+				{ 0x4a12, "STEPHANIE", 0 }
+			};
+			return runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+		}
+
+		Common::Error lineError = playStephanieLine(0x465c);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/npc/stephanie_dialogue.h b/engines/harvester/npc/stephanie_dialogue.h
new file mode 100644
index 00000000000..d9da16043d9
--- /dev/null
+++ b/engines/harvester/npc/stephanie_dialogue.h
@@ -0,0 +1,56 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_NPC_STEPHANIE_DIALOGUE_H
+#define HARVESTER_NPC_STEPHANIE_DIALOGUE_H
+
+#include "harvester/npc/dialogue_handler.h"
+
+namespace Harvester {
+
+class StephanieDialogueHandler : public NpcDialogueHandler {
+public:
+	struct StephanieRoomDialogueState {
+		bool introPending = true;
+		int introDayIndex = -1;
+		bool firstFollowupPending = false;
+		int firstFollowupDayIndex = -1;
+		bool secondFollowupPending = false;
+		int secondFollowupDayIndex = -1;
+		bool dayFiveLinePlayed = false;
+		bool karinKidnapedLinePlayed = false;
+		bool karinOutcomeLinePlayed = false;
+		Common::String currentTopicBuffer;
+		int currentTopicBufferLineIndex = -1;
+	};
+
+	bool matchesNpc(const Common::String &npcName) const override;
+	void resetState() override { _state = StephanieRoomDialogueState(); }
+	Common::Error handleDialogue(DialogueRuntime &runtime,
+		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
+
+private:
+	StephanieRoomDialogueState _state;
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_NPC_STEPHANIE_DIALOGUE_H


Commit: be5183af01dc52710ff567a31f1e5b39db776e5e
    https://github.com/scummvm/scummvm/commit/be5183af01dc52710ff567a31f1e5b39db776e5e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:13+02:00

Commit Message:
HARVESTER: Rebuild mom dialogue from native flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/dialogue_handler.h
    engines/harvester/npc/mom_dialogue.cpp
    engines/harvester/npc/mom_dialogue.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 882b8544642..30ada192ea0 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -415,6 +415,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - Because `dialogue.idx` is opened without a `1:\` / `2:\` set prefix, `open_xfile_stream` routes it through the direct-file path rather than the DAT archives.
 - `play_dialogue_line` at `0x7a690` is the single-line voice/subtitle presenter used by the talk handlers.
   - It formats the voice sample path as `<VOICE config><wavId>.CMP`, loads that direct `CMP` file if present, seeks `g_dialogue_index_stream` to the matching `dialogue.idx` text span, XOR-decodes the subtitle text, selects the textbox art by wrapped-line count, refreshes the active portrait/head slot, and blocks until the line completes or is interrupted.
+  - The per-NPC talk handlers do not embed literal subtitle or keyword text in code. They hardcode WAV ids, head-id strings / variants, and zero-based `dialog.rsp` line indices; the human-readable subtitle and keyword text is loaded from `dialogue.idx` and `dialog.rsp` at runtime.
 - `load_dialogue_response_line` at `0x3a1a0` is the indexed `dialog.rsp` line loader used by the talk-to handlers.
   - It opens the ASCII `dialog.rsp` file, reads forward to the requested zero-based line index, strips the trailing CR/LF, and returns the shared `g_dialogue_response_line_buffer`.
   - Handlers such as `handle_talk_to_dwayne`, `handle_talk_to_hank`, and `handle_talk_to_mom` use it to map keyword-menu selections back to concrete topic strings before branching.
diff --git a/TRACKER.md b/TRACKER.md
index 14d8d547194..2c397825002 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,26 +2,25 @@
 
 ## Current Focus
 
-- Implement the remaining Ghidra-identified NPC dialogue handlers in the engine
+- Rebuild talk-handler ports from corrected native control flow
 - Keep each NPC port scoped to confirmed native behavior only
-- Update `TRACKER.md` and commit after each completed NPC
+- Commit each completed NPC update as an isolated change
 
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `896`
-- Named/documented: `577`
+- Total functions: `902`
+- Named/documented: `583`
 - Still `FUN_*` / undocumented: `319`
 
 ## Last Confirmed Action
 
-- Ported `handle_talk_to_stephanie @ 0x36710` into a dedicated `engines/harvester/npc/stephanie_dialogue.*` handler.
-  - Stephanie now covers the confirmed top-level dispatch surface: evidence-item replies, the staged intro / revisit flow, the nasty-path FST and action-tag branch, the day-5 and Karin outcome one-shots, and the recovered keyword-menu branches that hang off her visible topic buffers.
-  - This completes engine-side coverage for every currently identified top-level NPC dialogue handler recovered from Ghidra.
+- Rebuilt `handle_talk_to_mom @ 0x31140` from corrected native control flow and fixed the engine port to follow the full recovered sequence.
+  - Mom now uses the confirmed native line sequences, head variants, FST calls, action tags, and keyword / response menu branches instead of the earlier truncated decompile.
+  - The engine-side dialogue shared state now exposes Mom's day-5 cross-handler state so the remaining audit can wire the native producer back in.
 
 ## Next Suggested Action
 
-1. Switch from NPC coverage work back to dialogue fidelity work.
-   - Highest-value followups are Stephanie's deeper hidden topic branches, Boyle's `0x2d620` keyword block, and the separate Mom raw-disassembly rebuild.
-2. Keep folding any newly confirmed hidden helper fixes from Ghidra back into the per-NPC handlers.
-   - The coverage pass is complete, so remaining work is behavior parity and disassembly cleanup rather than adding more top-level handler classes.
+1. Audit the remaining `handle_talk_to_*` ports against the corrected native disassembly.
+   - The highest-value follow-up is `handle_talk_to_whaley @ 0x23ec0`, which still contains major unported menu and event logic, including the native producer for Mom's day-5 shared dialogue state.
+2. Apply any additional high-confidence Ghidra renames or comments that fall directly out of those audited handlers before each commit.
diff --git a/engines/harvester/npc/dialogue_handler.h b/engines/harvester/npc/dialogue_handler.h
index 666de46e06f..01c419e04c1 100644
--- a/engines/harvester/npc/dialogue_handler.h
+++ b/engines/harvester/npc/dialogue_handler.h
@@ -42,6 +42,7 @@ struct DialogueSharedState {
 	bool discussedLodgeTopic = false;
 	bool dialogueStateD2f08 = false;
 	bool waspWomanDialogueState = false;
+	bool momGoodCauseDay5State = false;
 };
 
 class NpcDialogueHandler {
diff --git a/engines/harvester/npc/mom_dialogue.cpp b/engines/harvester/npc/mom_dialogue.cpp
index a7acd8d829d..23245585fa2 100644
--- a/engines/harvester/npc/mom_dialogue.cpp
+++ b/engines/harvester/npc/mom_dialogue.cpp
@@ -25,6 +25,7 @@
 
 #include "harvester/npc/dialogue_flags.h"
 #include "harvester/npc/dialogue_runtime.h"
+#include "harvester/startup_script.h"
 
 namespace Harvester {
 
@@ -32,13 +33,173 @@ namespace {
 
 static const char *const kDialogueC008AFstPath = "GRAPHIC/FST/C008A.FST";
 static const char *const kDialogueC008BFstPath = "GRAPHIC/FST/C008B.FST";
+static const char *const kDialogueC009FstPath = "GRAPHIC/FST/C009.FST";
+static const char *const kDialogueC010FstPath = "GRAPHIC/FST/C010.FST";
 static const char *const kBabyGurgleActionTag = "BABY_GURGLE";
+static const char *const kBabyScreamActionTag = "BABY_SCREAM";
+static const char *const kSawSlashesOnBodyFlag = "IF_SAW_THE_SLASHES_ON_THE_BODY";
+
+static const DialogueLineEntry kMomLedgerLines[] = {
+	{ 0x2320, "MOM", 2 },
+	{ 0x2326, "PC", 2 },
+	{ 0x232b, "MOM", 1 },
+	{ 0x2331, "PC", 0 },
+	{ 0x2335, "MOM", 1 }
+};
+
+static const DialogueLineEntry kMomCasketPhotoLines[] = {
+	{ 0x233b, "PC", 0 },
+	{ 0x233f, "MOM", 1 },
+	{ 0x2344, "PC", 0 },
+	{ 0x2348, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomWhaleyPhotoLines[] = {
+	{ 0x239c, "MOM", 2 },
+	{ 0x23a0, "PC", 0 },
+	{ 0x23a5, "MOM", 0 }
+};
 
 static const DialogueLineEntry kMomIntroLines[] = {
 	{ 0x1dd7, "MOM", 1 },
 	{ 0x1ddc, "PC", 0 },
 	{ 0x1de0, "MOM", 2 },
-	{ 0x1de4, "PC", 2 }
+	{ 0x1de4, "PC", 2 },
+	{ 0x1de8, "MOM", 0 },
+	{ 0x1ded, "PC", 0 },
+	{ 0x1df1, "MOM", 1 },
+	{ 0x1df5, "PC", 2 },
+	{ 0x1dfa, "MOM", 0 },
+	{ 0x1e04, "MOM", 0 },
+	{ 0x1e5d, "PC", 0 },
+	{ 0x1e61, "MOM", 2 },
+	{ 0x1e66, "PC", 0 },
+	{ 0x1e6b, "MOM", 3 },
+	{ 0x1e6f, "PC", 2 },
+	{ 0x1e73, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomStephMidgameOpeningLines[] = {
+	{ 0x205a, "MOM", 3 },
+	{ 0x205e, "MOM", 1 }
+};
+
+static const DialogueLineEntry kMomStephMidgameClosingLines[] = {
+	{ 0x2063, "PC", 3 },
+	{ 0x2068, "MOM", 1 },
+	{ 0x206d, "PC", 2 },
+	{ 0x2071, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomBurnedTvStationOpeningLines[] = {
+	{ 0x2220, "MOM", 2 },
+	{ 0x2225, "PC", 0 },
+	{ 0x222a, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomBurnedTvStationPreActionLines[] = {
+	{ 0x223e, "MOM", 3 }
+};
+
+static const DialogueLineEntry kMomBurnedTvStationPreFstLines[] = {
+	{ 0x224e, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomBurnedTvStationPostFstLines[] = {
+	{ 0x2272, "MOM", 1 },
+	{ 0x2257, "MOM", 2 },
+	{ 0x2276, "PC", 4 },
+	{ 0x227b, "MOM", 1 },
+	{ 0x2282, "PC", 3 },
+	{ 0x2286, "MOM", 0 },
+	{ 0x228a, "MOM", 2 },
+	{ 0x2293, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomBarberPoleStolenLines[] = {
+	{ 0x23fb, "MOM", 1 },
+	{ 0x2401, "MOM", 1 },
+	{ 0x240d, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomBoltOfClothClearLines[] = {
+	{ 0x2416, "MOM", 1 }
+};
+
+static const DialogueLineEntry kMomBoltOfClothDefaultLines[] = {
+	{ 0x2420, "MOM", 1 },
+	{ 0x2428, "PC", 0 },
+	{ 0x242c, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomEscapedJailLines[] = {
+	{ 0x2495, "MOM", 0 },
+	{ 0x249b, "PC", 0 },
+	{ 0x249f, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomKarinKidnappedResponse2Lines[] = {
+	{ 0x24e8, "MOM", 0 },
+	{ 0x24ed, "MOM", 2 },
+	{ 0x24f3, "PC", 0 },
+	{ 0x24f7, "MOM", 2 },
+	{ 0x24fd, "PC", 0 },
+	{ 0x2501, "MOM", 1 }
+};
+
+static const DialogueLineEntry kMomKarinFoundAliveIntroLines[] = {
+	{ 0x2505, "MOM", 0 },
+	{ 0x2509, "PC", 0 },
+	{ 0x250d, "MOM", 2 },
+	{ 0x2511, "PC", 2 },
+	{ 0x2515, "MOM", 2 },
+	{ 0x2519, "PC", 2 },
+	{ 0x251d, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomKarinFoundAliveResponse1WithoutStephMidgameLines[] = {
+	{ 0x252a, "MOM", 2 },
+	{ 0x252e, "MOM", 2 },
+	{ 0x2534, "PC", 0 },
+	{ 0x2538, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomKarinFoundAliveResponse1WithStephMidgameLines[] = {
+	{ 0x252a, "MOM", 2 },
+	{ 0x2543, "MOM", 2 },
+	{ 0x2549, "PC", 0 },
+	{ 0x254d, "MOM", 2 },
+	{ 0x2552, "PC", 2 },
+	{ 0x2557, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomMoynahanAbsentLines[] = {
+	{ 0x2647, "MOM", 2 },
+	{ 0x264b, "PC", 0 },
+	{ 0x264f, "MOM", 3 }
+};
+
+static const DialogueLineEntry kMomJimmyAttackedLines[] = {
+	{ 0x2659, "MOM", 2 },
+	{ 0x265f, "PC", 0 },
+	{ 0x2663, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomStephanieDeadPreMidgameLines[] = {
+	{ 0x217c, "MOM", 3 },
+	{ 0x2180, "PC", 0 },
+	{ 0x2184, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomDay5Lines[] = {
+	{ 0x218d, "MOM", 1 },
+	{ 0x2192, "PC", 0 },
+	{ 0x2197, "MOM", 1 }
+};
+
+static const DialogueLineEntry kMomDay6Lines[] = {
+	{ 0x22a8, "MOM", 3 },
+	{ 0x22b1, "MOM", 3 }
 };
 
 static const DialogueLineEntry kMomCookiesLines[] = {
@@ -46,7 +207,12 @@ static const DialogueLineEntry kMomCookiesLines[] = {
 	{ 0x1e1d, "MOM", 1 },
 	{ 0x1e21, "PC", 0 },
 	{ 0x1e25, "MOM", 0 },
-	{ 0x1e29, "PC", 0 }
+	{ 0x1e29, "PC", 0 },
+	{ 0x1e2d, "MOM", 0 },
+	{ 0x1e31, "PC", 0 },
+	{ 0x1e35, "MOM", 1 },
+	{ 0x1e3a, "PC", 0 },
+	{ 0x1e3e, "MOM", 0 }
 };
 
 static const DialogueLineEntry kMomTopic119Lines[] = {
@@ -79,6 +245,13 @@ static const DialogueLineEntry kMomTopic121Lines[] = {
 	{ 0x1ecd, "MOM", 2 }
 };
 
+static const DialogueLineEntry kMomSparkyLines[] = {
+	{ 0x1ed7, "PC", 0 },
+	{ 0x1edb, "MOM", 2 },
+	{ 0x1ee1, "PC", 0 },
+	{ 0x1ee5, "MOM", 2 }
+};
+
 static const DialogueLineEntry kMomPtaLines[] = {
 	{ 0x1eee, "PC", 2 },
 	{ 0x1ef2, "MOM", 0 }
@@ -130,11 +303,11 @@ static const DialogueLineEntry kMomTopic137Lines[] = {
 	{ 0x1f8e, "MOM", 2 }
 };
 
-static const DialogueLineEntry kMomSparkyLines[] = {
-	{ 0x1ed7, "PC", 0 },
-	{ 0x1edb, "MOM", 2 },
-	{ 0x1ee1, "PC", 0 },
-	{ 0x1ee5, "MOM", 2 }
+static const DialogueLineEntry kMomSlaughterhouseLines[] = {
+	{ 0x1faf, "PC", 4 },
+	{ 0x1fb4, "MOM", 0 },
+	{ 0x1fb9, "PC", 2 },
+	{ 0x1fbd, "MOM", 0 }
 };
 
 static const DialogueLineEntry kMomFatherLines[] = {
@@ -154,16 +327,6 @@ static const DialogueLineEntry kMomLodgeLines[] = {
 	{ 0x1fef, "MOM", 1 }
 };
 
-static const DialogueLineEntry kMomLodgeResponse1Lines[] = {
-	{ 0x1ffe, "MOM", 1 }
-};
-
-static const DialogueLineEntry kMomLodgeResponse2Lines[] = {
-	{ 0x2005, "MOM", 2 },
-	{ 0x200a, "PC", 0 },
-	{ 0x200e, "MOM", 0 }
-};
-
 static const DialogueLineEntry kMomStephMidgameLodgeLines[] = {
 	{ 0x2154, "PC", 0 },
 	{ 0x215a, "MOM", 0 }
@@ -188,6 +351,42 @@ static const DialogueLineEntry kMomTopic14aLines[] = {
 	{ 0x203e, "MOM", 2 }
 };
 
+static const DialogueLineEntry kMomPottsdamFirstResponse1Lines[] = {
+	{ 0x2093, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomPottsdamFirstResponse2Lines[] = {
+	{ 0x2097, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomPottsdamCommonFollowupLines[] = {
+	{ 0x209c, "MOM", 1 }
+};
+
+static const DialogueLineEntry kMomPottsdamSecondResponse1Lines[] = {
+	{ 0x20a6, "MOM", 1 },
+	{ 0x20ab, "PC", 3 },
+	{ 0x20b0, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomPottsdamThirdResponse1Lines[] = {
+	{ 0x20bb, "MOM", 2 },
+	{ 0x20bf, "PC", 2 },
+	{ 0x20c4, "MOM", 1 }
+};
+
+static const DialogueLineEntry kMomPottsdamThirdResponse2Lines[] = {
+	{ 0x20ce, "MOM", 1 }
+};
+
+static const DialogueLineEntry kMomPottsdamSecondResponse2Lines[] = {
+	{ 0x20d5, "MOM", 0 },
+	{ 0x20da, "PC", 4 },
+	{ 0x20df, "MOM", 0 },
+	{ 0x20e3, "MOM", 3 },
+	{ 0x20e8, "MOM", 1 }
+};
+
 static const DialogueLineEntry kMomMeatPlantIntroLines[] = {
 	{ 0x20f7, "PC", 0 },
 	{ 0x20fb, "MOM", 0 }
@@ -219,19 +418,37 @@ static const DialogueLineEntry kMomTopic156Lines[] = {
 	{ 0x214b, "MOM", 0 }
 };
 
-static const DialogueLineEntry kMomGoodCauseDay5Lines[] = {
+static const DialogueLineEntry kMomGoodCauseDay5CompleteLines[] = {
 	{ 0x21a3, "PC", 0 },
 	{ 0x21a8, "MOM", 1 },
 	{ 0x21ad, "PC", 0 },
 	{ 0x21b2, "MOM", 0 },
-	{ 0x21b7, "PC", 0 }
+	{ 0x21b7, "PC", 0 },
+	{ 0x21bc, "MOM", 1 }
 };
 
-static const DialogueLineEntry kMomSlaughterhouseLines[] = {
-	{ 0x1faf, "PC", 4 },
-	{ 0x1fb4, "MOM", 0 },
-	{ 0x1fb9, "PC", 2 },
-	{ 0x1fbd, "MOM", 0 }
+static const DialogueLineEntry kMomGoodCauseDay5SlashBodyLines[] = {
+	{ 0x21c4, "PC", 0 },
+	{ 0x21ca, "MOM", 2 },
+	{ 0x21cf, "PC", 0 },
+	{ 0x21d3, "MOM", 2 }
+};
+
+static const DialogueLineEntry kMomGoodCauseDay5FirstTimeLines[] = {
+	{ 0x21dd, "PC", 0 },
+	{ 0x21e2, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomMoynahanTopicLines[] = {
+	{ 0x220a, "PC", 0 },
+	{ 0x220e, "MOM", 0 },
+	{ 0x2212, "PC", 0 },
+	{ 0x2216, "MOM", 0 }
+};
+
+static const DialogueLineEntry kMomTopic16dLines[] = {
+	{ 0x2696, "MOM", 0 },
+	{ 0x2697, "MOM", 0 }
 };
 
 static const int kMomPtaTopicResponseLines[] = { 0x125, 0x126, 0x127, 0x128 };
@@ -243,7 +460,7 @@ static const int kMomMoynahanTopicResponseLines[] = { 0x160, 0x161 };
 static const int kMomNewspaperFireTopicResponseLines[] = { 0x162, 0x163 };
 static const int kMomInitialTopicBufferResponseLine = 0x102;
 static const int kMomSameDayTopicBufferResponseLine = 0x104;
-static const int kMomPostIntroTopicBufferResponseLine = 0x105;
+static const int kMomStephMidgameTopicBufferResponseLine = 0x105;
 static const int kMomDay5TopicBufferResponseLine = 0x113;
 static const int kMomDay6TopicBufferResponseLine = 0x114;
 static const int kMomSlaughterhouseFollowupTopicBufferResponseLine = 0x13e;
@@ -272,13 +489,38 @@ Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		runtime.assignTopicBuffer(momTopicBuffer, momTopicBufferLineIndex,
 			responseLineIndex, "Mom topic buffer");
 	};
+	auto playMomLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "MOM", headVariant);
+	};
+	auto playPcLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "PC", headVariant);
+	};
+	auto playSequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
+		return runtime.playDialogueEntrySequence(lines, count);
+	};
+	auto executeActionTagIfSet = [&](const char *actionTag) {
+		StartupInteractionResult interaction;
+		if (runtime.startupScript().executeActionTag(actionTag, interaction)) {
+			runtime.applyImmediateDialogueInteractionEffects(interaction);
+			runtime.queueDialogueInteractionIfNeeded(interaction);
+		}
+	};
+	auto hasInventoryObject = [&](const char *objectName) -> bool {
+		Common::Array<StartupObjectRecord> inventoryObjects;
+		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
+		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
+				return true;
+		}
+		return false;
+	};
 	auto runMomGoodbye = [&]() -> Common::Error {
 		if (!runtime.startupScript().getFlagValue("DAY_FLAG"))
-			return runtime.playDialogueLine(0x256e, "MOM");
+			return playMomLine(0x256e, 1);
 		if (!runtime.startupScript().isNamedNpcDeathTypeClear("JIMMY"))
 			return Common::kNoError;
 
-		return runtime.playDialogueLine(0x2051, "MOM");
+		return playMomLine(0x2051, 1);
 	};
 
 	if (!usedItemName.empty()) {
@@ -287,197 +529,417 @@ Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
-			return runtime.playDialogueLine(0x2317, "MOM");
+			return playMomLine(0x2317, 1);
 		}
 		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
 				usedItemName.equalsIgnoreCase("LEDGER2")) &&
 				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
-			return runtime.playDialogueLine(0x2320, "MOM");
+			return playSequence(kMomLedgerLines, ARRAYSIZE(kMomLedgerLines));
 		}
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
-			return runtime.playDialogueLine(0x233b, "PC");
+			return playSequence(kMomCasketPhotoLines, ARRAYSIZE(kMomCasketPhotoLines));
 		}
 		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-			return runtime.playDialogueLine(0x239c, "MOM");
+			return playSequence(kMomWhaleyPhotoLines, ARRAYSIZE(kMomWhaleyPhotoLines));
 		}
 		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
 				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceSheriffOwns, true);
-			return runtime.playDialogueLine(0x2382, "MOM");
+			return playMomLine(0x2382, 1);
 		}
 
-		return runtime.playDialogueLine(0x26cc, "MOM");
+		return playMomLine(0x26cc, 2);
 	}
 
-	bool skipMomDefaultKeywordSeed = false;
 	if (state.introPending) {
 		state.introPending = false;
 		state.sameDayIntroLineEnabled = true;
 		state.postIntroDefaultLineEnabled = true;
 		state.introDayIndex = currentStoryDayIndex;
-		Common::Error lineError = runtime.playDialogueEntrySequence(kMomIntroLines, ARRAYSIZE(kMomIntroLines));
+
+		Common::Error lineError = playSequence(kMomIntroLines, ARRAYSIZE(kMomIntroLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 		assignMomTopicBuffer(kMomInitialTopicBufferResponseLine);
-		skipMomDefaultKeywordSeed = true;
-	}
-	if (!skipMomDefaultKeywordSeed && state.sameDayIntroLineEnabled &&
-			currentStoryDayIndex == state.introDayIndex) {
-		Common::Error lineError = runtime.playDialogueLine(0x2047, "MOM");
+	} else if (state.sameDayIntroLineEnabled && currentStoryDayIndex == state.introDayIndex) {
+		Common::Error lineError = playMomLine(0x2047, 0);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 		assignMomTopicBuffer(kMomSameDayTopicBufferResponseLine);
-		skipMomDefaultKeywordSeed = true;
-	}
-	if (!skipMomDefaultKeywordSeed && state.postIntroDefaultLineEnabled) {
-		Common::Error lineError = runtime.playDialogueLine(0x2311, "MOM");
+	} else if (state.postIntroDefaultLineEnabled) {
+		Common::Error lineError = playMomLine(0x2311, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		assignMomTopicBuffer(kMomPostIntroTopicBufferResponseLine);
 	}
+
 	if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
 			!state.stephMidgameShown) {
 		state.stephMidgameShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x205a, "MOM");
+
+		Common::Error lineError = playSequence(
+			kMomStephMidgameOpeningLines, ARRAYSIZE(kMomStephMidgameOpeningLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = runtime.playDialogueFst(kDialogueC009FstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playSequence(
+			kMomStephMidgameClosingLines, ARRAYSIZE(kMomStephMidgameClosingLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+		assignMomTopicBuffer(kMomStephMidgameTopicBufferResponseLine);
 	}
+
 	if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
 			(runtime.startupScript().getFlagValue("KARIN_KIDNAPED") ||
 				runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD")) &&
 			!state.dinerBurnedKarinMissingOrDeadShown) {
 		state.dinerBurnedKarinMissingOrDeadShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x2456, "MOM");
+
+		Common::Error lineError = playMomLine(0x2456, 0);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+		if (sharedState.dialogueStateD2ea4) {
+			lineError = playMomLine(0x245d, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x106, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			switch (responseIndex) {
+			case 1:
+				lineError = playMomLine(0x246b, 0);
+				break;
+			case 2:
+				lineError = playMomLine(0x246f, 1);
+				break;
+			default:
+				lineError = Common::kNoError;
+				break;
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
 	}
+
 	if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
 			!state.burnedTvStationShown) {
 		state.burnedTvStationShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x2220, "MOM");
+
+		Common::Error lineError = playSequence(
+			kMomBurnedTvStationOpeningLines, ARRAYSIZE(kMomBurnedTvStationOpeningLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x107, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 1) {
+			lineError = playMomLine(0x2237, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		lineError = playSequence(
+			kMomBurnedTvStationPreActionLines, ARRAYSIZE(kMomBurnedTvStationPreActionLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		executeActionTagIfSet(kBabyScreamActionTag);
+		lineError = playSequence(
+			kMomBurnedTvStationPreFstLines, ARRAYSIZE(kMomBurnedTvStationPreFstLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = runtime.playDialogueFst(kDialogueC010FstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playSequence(
+			kMomBurnedTvStationPostFstLines, ARRAYSIZE(kMomBurnedTvStationPostFstLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
+
 	if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER") &&
 			!state.scratchedTuckerShown) {
 		state.scratchedTuckerShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x23f3, "MOM");
+		Common::Error lineError = playMomLine(0x23f3, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
+
 	if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") &&
 			!state.barberPoleStolenShown) {
 		state.barberPoleStolenShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x23fb, "MOM");
+		Common::Error lineError = playSequence(
+			kMomBarberPoleStolenLines, ARRAYSIZE(kMomBarberPoleStolenLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
+
 	if (runtime.startupScript().getFlagValue("BOLT_OF_CLOTH_TAKEN") &&
 			!state.boltOfClothTakenShown) {
 		state.boltOfClothTakenShown = true;
-		if (runtime.startupScript().isNamedNpcDeathTypeClear("SPARKY") &&
-				runtime.startupScript().isNamedNpcDeathTypeClear("FIREMAN2")) {
-			Common::Error lineError = runtime.playDialogueLine(0x2416, "MOM");
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-		Common::Error lineError = runtime.playDialogueLine(0x2420, "MOM");
+
+		Common::Error lineError = runtime.startupScript().isNamedNpcDeathTypeClear("SPARKY") &&
+				runtime.startupScript().isNamedNpcDeathTypeClear("FIREMAN2")
+			? playSequence(kMomBoltOfClothClearLines, ARRAYSIZE(kMomBoltOfClothClearLines))
+			: playSequence(kMomBoltOfClothDefaultLines, ARRAYSIZE(kMomBoltOfClothDefaultLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
+
 	if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
 			runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
 			!state.dinerBurnedKarinAliveShown) {
 		state.dinerBurnedKarinAliveShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x2434, "MOM");
+
+		Common::Error lineError = playMomLine(0x2434, 0);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+		if (sharedState.dialogueStateD2ea4) {
+			lineError = playMomLine(0x2439, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x109, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			switch (responseIndex) {
+			case 1:
+				lineError = playMomLine(0x2448, 0);
+				break;
+			case 2:
+				lineError = playMomLine(0x244d, 1);
+				break;
+			default:
+				lineError = Common::kNoError;
+				break;
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
 	}
+
 	if (runtime.startupScript().getFlagValue("PC_ESCAPED_JAIL") &&
 			!state.escapedJailShown) {
 		state.escapedJailShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x2495, "MOM");
+		Common::Error lineError = playSequence(
+			kMomEscapedJailLines, ARRAYSIZE(kMomEscapedJailLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
+
 	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
 			!runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
 			!runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
 			!state.karinKidnapedUnresolvedShown) {
 		state.karinKidnapedUnresolvedShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x24d7, "MOM");
+
+		Common::Error lineError = playMomLine(0x24d7, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		sharedState.karinKidnapedDialogueState = true;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x10d, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		switch (responseIndex) {
+		case 1:
+			lineError = playMomLine(0x24e3, 0);
+			break;
+		case 2:
+			lineError = playSequence(
+				kMomKarinKidnappedResponse2Lines,
+				ARRAYSIZE(kMomKarinKidnappedResponse2Lines));
+			break;
+		default:
+			lineError = Common::kNoError;
+			break;
+		}
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
+
 	if (runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
 			!state.karinFoundAliveShown) {
 		state.karinFoundAliveShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x2505, "MOM");
+
+		Common::Error lineError = playSequence(
+			kMomKarinFoundAliveIntroLines, ARRAYSIZE(kMomKarinFoundAliveIntroLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x10e, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		switch (responseIndex) {
+		case 1:
+			lineError = runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED")
+				? playSequence(kMomKarinFoundAliveResponse1WithStephMidgameLines,
+					ARRAYSIZE(kMomKarinFoundAliveResponse1WithStephMidgameLines))
+				: playSequence(kMomKarinFoundAliveResponse1WithoutStephMidgameLines,
+					ARRAYSIZE(kMomKarinFoundAliveResponse1WithoutStephMidgameLines));
+			break;
+		case 2:
+			lineError = playMomLine(0x2560, 2);
+			break;
+		default:
+			lineError = Common::kNoError;
+			break;
+		}
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
+
 	if (runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
 			!state.karinFoundDeadShown) {
 		state.karinFoundDeadShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x2576, "MOM");
+
+		Common::Error lineError = playMomLine(0x2576, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = hasInventoryObject("K_PURSE")
+			? playPcLine(0x257b, 0)
+			: playPcLine(0x2580, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playPcLine(0x2585, 3);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+		lineError = playMomLine(0x258b, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (currentStoryDayIndex <= 5) {
+			lineError = playMomLine(0x258f, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playPcLine(0x2594, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playMomLine(0x2598, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
+				!runtime.startupScript().isNamedNpcDeathTypeClear("EDNA")) {
+			lineError = playMomLine(0x25a0, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
 	}
+
 	if (!runtime.startupScript().isNamedNpcDeathTypeClear("BUTCHER") &&
 			!state.butcherAbsentShown) {
 		state.butcherAbsentShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x2633, "MOM");
+		Common::Error lineError = playMomLine(0x2633, 0);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
+
 	if (!runtime.startupScript().isNamedNpcDeathTypeClear("MOYNAHAN") &&
 			!state.moynahanAbsentShown) {
 		state.moynahanAbsentShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x2647, "MOM");
+		Common::Error lineError = playSequence(
+			kMomMoynahanAbsentLines, ARRAYSIZE(kMomMoynahanAbsentLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
-	if (!runtime.startupScript().isNamedNpcDeathTypeClear("JIMMY") &&
-			!state.jimmyAbsentShown) {
-		state.jimmyAbsentShown = true;
-		if (runtime.startupScript().getFlagValue("JIMMY_ATTACKED")) {
-			Common::Error lineError = runtime.playDialogueLine(0x2659, "MOM");
+
+	if (!runtime.startupScript().isNamedNpcDeathTypeClear("JIMMY")) {
+		Common::Error lineError = runtime.startupScript().getFlagValue("JIMMY_ATTACKED")
+			? playSequence(kMomJimmyAttackedLines, ARRAYSIZE(kMomJimmyAttackedLines))
+			: playMomLine(0x266c, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (!runtime.startupScript().getFlagValue("JIMMY_ATTACKED")) {
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x111, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			switch (responseIndex) {
+			case 1:
+				lineError = playMomLine(0x2677, 0);
+				break;
+			case 2:
+				lineError = playMomLine(0x267b, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playMomLine(0x267f, 3);
+				break;
+			default:
+				lineError = Common::kNoError;
+				break;
+			}
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
-		Common::Error lineError = runtime.playDialogueLine(0x266c, "MOM");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
 	}
+
 	if (!runtime.startupScript().isNamedNpcDeathTypeClear("WASP_WOMAN") &&
 			!state.waspWomanAbsentShown) {
 		state.waspWomanAbsentShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x2689, "MOM");
+		Common::Error lineError = playMomLine(0x2689, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
+
 	if (runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD") &&
 			!runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
 			!state.stephanieDeadPreMidgameShown) {
 		state.stephanieDeadPreMidgameShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x217c, "MOM");
+		Common::Error lineError = playSequence(
+			kMomStephanieDeadPreMidgameLines,
+			ARRAYSIZE(kMomStephanieDeadPreMidgameLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
+
 	if (runtime.startupScript().getFlagValue("DAY_5") && !state.day5Shown) {
 		state.day5Shown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x218d, "MOM");
+
+		Common::Error lineError = playSequence(kMomDay5Lines, ARRAYSIZE(kMomDay5Lines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x112, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		switch (responseIndex) {
+		case 1:
+			lineError = playMomLine(0x20ce, 1);
+			break;
+		case 2:
+			lineError = playMomLine(0x2201, 0);
+			break;
+		default:
+			lineError = Common::kNoError;
+			break;
+		}
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 		assignMomTopicBuffer(kMomDay5TopicBufferResponseLine);
 	}
+
 	if (runtime.startupScript().getFlagValue("DAY_6") && !state.day6Shown) {
 		state.day6Shown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x22a8, "MOM");
+
+		Common::Error lineError = playSequence(kMomDay6Lines, ARRAYSIZE(kMomDay6Lines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 		assignMomTopicBuffer(kMomDay6TopicBufferResponseLine);
@@ -495,41 +957,33 @@ Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return runMomGoodbye();
 
 		if (runtime.matchesResponseLine(selectedTopic, 0x116)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomCookiesLines, ARRAYSIZE(kMomCookiesLines));
+			Common::Error lineError = playSequence(kMomCookiesLines, ARRAYSIZE(kMomCookiesLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignMomTopicBuffer(0x118);
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x119)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic119Lines, ARRAYSIZE(kMomTopic119Lines));
+			Common::Error lineError = playSequence(kMomTopic119Lines, ARRAYSIZE(kMomTopic119Lines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignMomTopicBuffer(0x11a);
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x11d)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
+			Common::Error lineError = playSequence(
 				kMomTopic11dOpeningLines, ARRAYSIZE(kMomTopic11dOpeningLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			lineError = runtime.playDialogueFst(kDialogueC008AFstPath);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			lineError = runtime.playDialogueEntrySequence(
+			lineError = playSequence(
 				kMomTopic11dPostC008ALines, ARRAYSIZE(kMomTopic11dPostC008ALines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-
-			StartupInteractionResult babyGurgleInteraction;
-			if (runtime.startupScript().executeActionTag(kBabyGurgleActionTag, babyGurgleInteraction)) {
-				runtime.applyImmediateDialogueInteractionEffects(babyGurgleInteraction);
-				runtime.queueDialogueInteractionIfNeeded(babyGurgleInteraction);
-			}
-
-			lineError = runtime.playDialogueEntrySequence(
+			executeActionTagIfSet(kBabyGurgleActionTag);
+			lineError = playSequence(
 				kMomTopic11dClosingLines, ARRAYSIZE(kMomTopic11dClosingLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
@@ -540,16 +994,15 @@ Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x121)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic121Lines, ARRAYSIZE(kMomTopic121Lines));
+			Common::Error lineError = playSequence(kMomTopic121Lines, ARRAYSIZE(kMomTopic121Lines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			sharedState.waspWomanDialogueState = true;
 			assignMomTopicBuffer(0x122);
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x123)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomSparkyLines, ARRAYSIZE(kMomSparkyLines));
+			Common::Error lineError = playSequence(kMomSparkyLines, ARRAYSIZE(kMomSparkyLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignMomTopicBuffer(0x124);
@@ -557,32 +1010,28 @@ Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 		if (runtime.matchesAnyResponseLine(selectedTopic, kMomPtaTopicResponseLines,
 				ARRAYSIZE(kMomPtaTopicResponseLines))) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomPtaLines, ARRAYSIZE(kMomPtaLines));
+			Common::Error lineError = playSequence(kMomPtaLines, ARRAYSIZE(kMomPtaLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignMomTopicBuffer(kMomPtaFollowupTopicBufferResponseLine);
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x12b)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic12bLines, ARRAYSIZE(kMomTopic12bLines));
+			Common::Error lineError = playSequence(kMomTopic12bLines, ARRAYSIZE(kMomTopic12bLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignMomTopicBuffer(0x12c);
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x12d)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic12dLines, ARRAYSIZE(kMomTopic12dLines));
+			Common::Error lineError = playSequence(kMomTopic12dLines, ARRAYSIZE(kMomTopic12dLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignMomTopicBuffer(0x12e);
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x12f)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic12fLines, ARRAYSIZE(kMomTopic12fLines));
+			Common::Error lineError = playSequence(kMomTopic12fLines, ARRAYSIZE(kMomTopic12fLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignMomTopicBuffer(0x130);
@@ -590,37 +1039,34 @@ Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 		if (runtime.matchesAnyResponseLine(selectedTopic, kMomCookingTopicResponseLines,
 				ARRAYSIZE(kMomCookingTopicResponseLines))) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomCookingLines, ARRAYSIZE(kMomCookingLines));
+			Common::Error lineError = playSequence(kMomCookingLines, ARRAYSIZE(kMomCookingLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignMomTopicBuffer(kMomCookingFollowupTopicBufferResponseLine);
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x135)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic135Lines, ARRAYSIZE(kMomTopic135Lines));
+			Common::Error lineError = playSequence(kMomTopic135Lines, ARRAYSIZE(kMomTopic135Lines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignMomTopicBuffer(0x136);
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x137)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic137Lines, ARRAYSIZE(kMomTopic137Lines));
+			Common::Error lineError = playSequence(kMomTopic137Lines, ARRAYSIZE(kMomTopic137Lines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x13a)) {
-			Common::Error lineError = runtime.playDialogueLine(0x1fa3, "MOM");
+			Common::Error lineError = playMomLine(0x1fa3, 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignMomTopicBuffer(0x13b);
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x13c)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
+			Common::Error lineError = playSequence(
 				kMomSlaughterhouseLines, ARRAYSIZE(kMomSlaughterhouseLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
@@ -628,13 +1074,11 @@ Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x13f)) {
-			state.fatherTopicState = true;
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomFatherLines, ARRAYSIZE(kMomFatherLines));
+			Common::Error lineError = playSequence(kMomFatherLines, ARRAYSIZE(kMomFatherLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			if (runtime.startupScript().getFlagValue("HEARD_DAD_MOAN")) {
-				lineError = runtime.playDialogueEntrySequence(
+				lineError = playSequence(
 					kMomFatherHeardDadMoanLines, ARRAYSIZE(kMomFatherHeardDadMoanLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
@@ -643,16 +1087,16 @@ Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x141)) {
-			Common::Error lineError = Common::kNoError;
 			if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED")) {
-				lineError = runtime.playDialogueEntrySequence(
+				Common::Error lineError = playSequence(
 					kMomStephMidgameLodgeLines, ARRAYSIZE(kMomStephMidgameLodgeLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				continue;
 			}
+
 			sharedState.discussedLodgeTopic = true;
-			lineError = runtime.playDialogueEntrySequence(kMomLodgeLines, ARRAYSIZE(kMomLodgeLines));
+			Common::Error lineError = playSequence(kMomLodgeLines, ARRAYSIZE(kMomLodgeLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
@@ -663,14 +1107,19 @@ Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 			switch (responseIndex) {
 			case 1:
-				lineError = runtime.playDialogueEntrySequence(
-					kMomLodgeResponse1Lines, ARRAYSIZE(kMomLodgeResponse1Lines));
+				lineError = playMomLine(0x1ffe, 1);
 				break;
 			case 2:
-				lineError = runtime.playDialogueEntrySequence(
-					kMomLodgeResponse2Lines, ARRAYSIZE(kMomLodgeResponse2Lines));
+				lineError = playMomLine(0x2005, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playPcLine(0x200a, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playMomLine(0x200e, 0);
 				break;
 			default:
+				lineError = Common::kNoError;
 				break;
 			}
 			if (lineError.getCode() != Common::kNoError)
@@ -680,33 +1129,106 @@ Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 		if (runtime.matchesAnyResponseLine(selectedTopic, kMomBakeSaleTopicResponseLines,
 				ARRAYSIZE(kMomBakeSaleTopicResponseLines))) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomBakeSaleLines, ARRAYSIZE(kMomBakeSaleLines));
+			Common::Error lineError = playSequence(kMomBakeSaleLines, ARRAYSIZE(kMomBakeSaleLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignMomTopicBuffer(kMomBakeSaleFollowupTopicBufferResponseLine);
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x14a)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic14aLines, ARRAYSIZE(kMomTopic14aLines));
+			Common::Error lineError = playSequence(kMomTopic14aLines, ARRAYSIZE(kMomTopic14aLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			continue;
 		}
 		if (runtime.matchesAnyResponseLine(selectedTopic, kMomPottsdamTopicResponseLines,
-				ARRAYSIZE(kMomPottsdamTopicResponseLines))) {
-			Common::Error lineError = Common::kNoError;
-			if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED"))
-				lineError = runtime.playDialogueLine(0x2086, "MOM");
+				ARRAYSIZE(kMomPottsdamTopicResponseLines)) &&
+				runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED")) {
+			Common::Error lineError = playMomLine(0x2086, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x14d, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			switch (responseIndex) {
+			case 1:
+				lineError = playSequence(
+					kMomPottsdamFirstResponse1Lines,
+					ARRAYSIZE(kMomPottsdamFirstResponse1Lines));
+				break;
+			case 2:
+				lineError = playSequence(
+					kMomPottsdamFirstResponse2Lines,
+					ARRAYSIZE(kMomPottsdamFirstResponse2Lines));
+				break;
+			default:
+				lineError = Common::kNoError;
+				break;
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			lineError = playSequence(
+				kMomPottsdamCommonFollowupLines,
+				ARRAYSIZE(kMomPottsdamCommonFollowupLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			responseIndex = 0;
+			responseError = runtime.runResponseMenu(0x14e, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			switch (responseIndex) {
+			case 1:
+				lineError = playSequence(
+					kMomPottsdamSecondResponse1Lines,
+					ARRAYSIZE(kMomPottsdamSecondResponse1Lines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				responseIndex = 0;
+				responseError = runtime.runResponseMenu(0x14f, responseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+
+				switch (responseIndex) {
+				case 1:
+					lineError = playSequence(
+						kMomPottsdamThirdResponse1Lines,
+						ARRAYSIZE(kMomPottsdamThirdResponse1Lines));
+					break;
+				case 2:
+					lineError = playSequence(
+						kMomPottsdamThirdResponse2Lines,
+						ARRAYSIZE(kMomPottsdamThirdResponse2Lines));
+					break;
+				default:
+					lineError = Common::kNoError;
+					break;
+				}
+				break;
+			case 2:
+				lineError = playSequence(
+					kMomPottsdamSecondResponse2Lines,
+					ARRAYSIZE(kMomPottsdamSecondResponse2Lines));
+				break;
+			default:
+				lineError = Common::kNoError;
+				break;
+			}
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+
 			assignMomTopicBuffer(kMomPottsdamFollowupTopicBufferResponseLine);
 			continue;
 		}
 		if (runtime.matchesAnyResponseLine(selectedTopic, kMomMeatPlantTopicResponseLines,
 				ARRAYSIZE(kMomMeatPlantTopicResponseLines))) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
+			Common::Error lineError = playSequence(
 				kMomMeatPlantIntroLines, ARRAYSIZE(kMomMeatPlantIntroLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
@@ -718,83 +1240,115 @@ Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 			switch (responseIndex) {
 			case 1:
-				lineError = runtime.playDialogueEntrySequence(
-					kMomMeatPlantResponse1Lines, ARRAYSIZE(kMomMeatPlantResponse1Lines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				continue;
+				lineError = playSequence(
+					kMomMeatPlantResponse1Lines,
+					ARRAYSIZE(kMomMeatPlantResponse1Lines));
+				break;
 			case 2:
-				lineError = runtime.playDialogueEntrySequence(
-					kMomMeatPlantResponse2Lines, ARRAYSIZE(kMomMeatPlantResponse2Lines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				assignMomTopicBuffer(kMomMeatPlantFollowupTopicBufferResponseLine);
-				continue;
+				lineError = playSequence(
+					kMomMeatPlantResponse2Lines,
+					ARRAYSIZE(kMomMeatPlantResponse2Lines));
+				break;
 			default:
-				continue;
+				lineError = Common::kNoError;
+				break;
 			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			if (responseIndex == 2)
+				assignMomTopicBuffer(kMomMeatPlantFollowupTopicBufferResponseLine);
+			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x155)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic155Lines, ARRAYSIZE(kMomTopic155Lines));
+			Common::Error lineError = playSequence(kMomTopic155Lines, ARRAYSIZE(kMomTopic155Lines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x156)) {
-			Common::Error lineError = runtime.playDialogueEntrySequence(
-				kMomTopic156Lines, ARRAYSIZE(kMomTopic156Lines));
+			Common::Error lineError = playSequence(kMomTopic156Lines, ARRAYSIZE(kMomTopic156Lines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x15b) && currentStoryDayIndex == 5) {
-			Common::Error lineError = Common::kNoError;
-			if (state.goodCauseDay5State) {
-				lineError = runtime.playDialogueEntrySequence(
-					kMomGoodCauseDay5Lines, ARRAYSIZE(kMomGoodCauseDay5Lines));
-			} else {
-				lineError = runtime.playDialogueLine(0x21dd, "PC");
-			}
+			Common::Error lineError = sharedState.momGoodCauseDay5State
+				? playSequence(kMomGoodCauseDay5CompleteLines,
+					ARRAYSIZE(kMomGoodCauseDay5CompleteLines))
+				: playSequence(kMomGoodCauseDay5FirstTimeLines,
+					ARRAYSIZE(kMomGoodCauseDay5FirstTimeLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			assignMomTopicBuffer(kMomGoodCauseDay5FollowupTopicBufferResponseLine);
+
+			if (sharedState.momGoodCauseDay5State &&
+					runtime.startupScript().getFlagValue(kSawSlashesOnBodyFlag)) {
+				lineError = playSequence(
+					kMomGoodCauseDay5SlashBodyLines,
+					ARRAYSIZE(kMomGoodCauseDay5SlashBodyLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			if (!sharedState.momGoodCauseDay5State)
+				assignMomTopicBuffer(kMomGoodCauseDay5FollowupTopicBufferResponseLine);
 			continue;
 		}
 		if (runtime.matchesAnyResponseLine(selectedTopic, kMomMoynahanTopicResponseLines,
 				ARRAYSIZE(kMomMoynahanTopicResponseLines))) {
-			Common::Error lineError = runtime.playDialogueLine(0x220a, "PC");
+			Common::Error lineError = playSequence(
+				kMomMoynahanTopicLines, ARRAYSIZE(kMomMoynahanTopicLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			continue;
 		}
 		if (runtime.matchesAnyResponseLine(selectedTopic, kMomNewspaperFireTopicResponseLines,
 				ARRAYSIZE(kMomNewspaperFireTopicResponseLines))) {
-			Common::Error lineError = runtime.playDialogueLine(0x229e, "MOM");
+			Common::Error lineError = playMomLine(0x229e, 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x16d)) {
-			Common::Error lineError = runtime.playDialogueLine(0x2696, "MOM");
+			Common::Error lineError = playSequence(kMomTopic16dLines, ARRAYSIZE(kMomTopic16dLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x16e)) {
-			Common::Error lineError = runtime.playDialogueLine(0x26a3, "MOM");
+			Common::Error lineError = playMomLine(0x26a3, 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			continue;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x16f, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			switch (responseIndex) {
+			case 1:
+				lineError = playMomLine(0x26ae, 0);
+				break;
+			case 2:
+				lineError = playMomLine(0x26b3, 2);
+				break;
+			default:
+				lineError = Common::kNoError;
+				break;
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			return runMomGoodbye();
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x170)) {
-			Common::Error lineError = runtime.playDialogueLine(0x26bc, "MOM");
+			Common::Error lineError = playMomLine(0x26bc, 2);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			continue;
 		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x171))
+			continue;
 
-		Common::Error lineError = runtime.playDialogueLine(0x26c6, "MOM");
+		Common::Error lineError = playMomLine(0x26c6, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 		return runMomGoodbye();
diff --git a/engines/harvester/npc/mom_dialogue.h b/engines/harvester/npc/mom_dialogue.h
index 0f18b7677e0..99961d7ddca 100644
--- a/engines/harvester/npc/mom_dialogue.h
+++ b/engines/harvester/npc/mom_dialogue.h
@@ -46,13 +46,10 @@ public:
 		bool karinFoundDeadShown = false;
 		bool butcherAbsentShown = false;
 		bool moynahanAbsentShown = false;
-		bool jimmyAbsentShown = false;
 		bool waspWomanAbsentShown = false;
 		bool stephanieDeadPreMidgameShown = false;
 		bool day5Shown = false;
 		bool day6Shown = false;
-		bool fatherTopicState = false;
-		bool goodCauseDay5State = false;
 	};
 
 	bool matchesNpc(const Common::String &npcName) const override;


Commit: 2b9decade5f54776de4b9e7ec6e897d8e2f22293
    https://github.com/scummvm/scummvm/commit/2b9decade5f54776de4b9e7ec6e897d8e2f22293
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:13+02:00

Commit Message:
HARVESTER: Rebuild whaley dialogue from native flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/dialogue_handler.h
    engines/harvester/npc/dwayne_dialogue.cpp
    engines/harvester/npc/dwayne_dialogue.h
    engines/harvester/npc/whaley_dialogue.cpp
    engines/harvester/npc/whaley_dialogue.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 30ada192ea0..18f752dd011 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -415,6 +415,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - Because `dialogue.idx` is opened without a `1:\` / `2:\` set prefix, `open_xfile_stream` routes it through the direct-file path rather than the DAT archives.
 - `play_dialogue_line` at `0x7a690` is the single-line voice/subtitle presenter used by the talk handlers.
   - It formats the voice sample path as `<VOICE config><wavId>.CMP`, loads that direct `CMP` file if present, seeks `g_dialogue_index_stream` to the matching `dialogue.idx` text span, XOR-decodes the subtitle text, selects the textbox art by wrapped-line count, refreshes the active portrait/head slot, and blocks until the line completes or is interrupted.
+  - Raw call-site tracing now confirms the argument registers used by the talk handlers: `EAX = wav id`, `EDX = speaker/head id string`, and `EBX = portrait variant`.
   - The per-NPC talk handlers do not embed literal subtitle or keyword text in code. They hardcode WAV ids, head-id strings / variants, and zero-based `dialog.rsp` line indices; the human-readable subtitle and keyword text is loaded from `dialogue.idx` and `dialog.rsp` at runtime.
 - `load_dialogue_response_line` at `0x3a1a0` is the indexed `dialog.rsp` line loader used by the talk-to handlers.
   - It opens the ASCII `dialog.rsp` file, reads forward to the requested zero-based line index, strips the trailing CR/LF, and returns the shared `g_dialogue_response_line_buffer`.
diff --git a/TRACKER.md b/TRACKER.md
index 2c397825002..ebcec7fa0c5 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -9,18 +9,18 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `902`
-- Named/documented: `583`
+- Total functions: `906`
+- Named/documented: `587`
 - Still `FUN_*` / undocumented: `319`
 
 ## Last Confirmed Action
 
-- Rebuilt `handle_talk_to_mom @ 0x31140` from corrected native control flow and fixed the engine port to follow the full recovered sequence.
-  - Mom now uses the confirmed native line sequences, head variants, FST calls, action tags, and keyword / response menu branches instead of the earlier truncated decompile.
-  - The engine-side dialogue shared state now exposes Mom's day-5 cross-handler state so the remaining audit can wire the native producer back in.
+- Rebuilt `handle_talk_to_whaley @ 0x23ec0` from corrected native control flow and fixed the engine port to follow the recovered event, menu, and keyword branches.
+  - Whaley now covers the busted-screwing event chain, the full evidence-item exchanges, the intro/revisit keyword setup, the Steph/day-5/Karin/BURNED_TV_STATION one-shots, and the looping `dialog.rsp` topic transitions.
+  - The engine-side shared dialogue state now carries the Dwayne/Whaley discipline follow-up bit, and the matching native FST helpers (`C052`, `C053`, `C055`, `C057`) are named in Ghidra.
 
 ## Next Suggested Action
 
 1. Audit the remaining `handle_talk_to_*` ports against the corrected native disassembly.
-   - The highest-value follow-up is `handle_talk_to_whaley @ 0x23ec0`, which still contains major unported menu and event logic, including the native producer for Mom's day-5 shared dialogue state.
+   - The highest-value follow-ups are the remaining short ports that still hide native menu/event logic behind mistyped `play_dialogue_line` calls, starting with `handle_talk_to_mr_potts`, `handle_talk_to_jimmy`, and the other handlers that still xref `run_dialogue_response_menu` / `run_dialogue_keyword_menu` from not-yet-audited code ranges.
 2. Apply any additional high-confidence Ghidra renames or comments that fall directly out of those audited handlers before each commit.
diff --git a/engines/harvester/npc/dialogue_handler.h b/engines/harvester/npc/dialogue_handler.h
index 01c419e04c1..df09853e8df 100644
--- a/engines/harvester/npc/dialogue_handler.h
+++ b/engines/harvester/npc/dialogue_handler.h
@@ -43,6 +43,7 @@ struct DialogueSharedState {
 	bool dialogueStateD2f08 = false;
 	bool waspWomanDialogueState = false;
 	bool momGoodCauseDay5State = false;
+	bool dwayneWhaleyDisciplineFollowupState = false;
 };
 
 class NpcDialogueHandler {
diff --git a/engines/harvester/npc/dwayne_dialogue.cpp b/engines/harvester/npc/dwayne_dialogue.cpp
index 6f45cdd2a53..c2d4c911e63 100644
--- a/engines/harvester/npc/dwayne_dialogue.cpp
+++ b/engines/harvester/npc/dwayne_dialogue.cpp
@@ -265,7 +265,7 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return lineError;
 	}
 
-	if (state.whaleyDisciplineFollowupState &&
+	if (sharedState.dwayneWhaleyDisciplineFollowupState &&
 			!state.whaleyDisciplineFollowupShown) {
 		state.whaleyDisciplineFollowupShown = true;
 		Common::Error lineError = playDwayneLine(0x34b3, 1);
diff --git a/engines/harvester/npc/dwayne_dialogue.h b/engines/harvester/npc/dwayne_dialogue.h
index b7778c24700..025e9f62c04 100644
--- a/engines/harvester/npc/dwayne_dialogue.h
+++ b/engines/harvester/npc/dwayne_dialogue.h
@@ -41,7 +41,6 @@ public:
 		bool completedKarinAliveFollowup = false;
 		int pendingKarinAliveFollowupDayIndex = 0;
 		bool bringKarinToSheriffLinePlayed = false;
-		bool whaleyDisciplineFollowupState = false;
 		bool whaleyDisciplineFollowupShown = false;
 		bool noteCheckbookFollowupShown = false;
 		bool scratchedTuckerShown = false;
diff --git a/engines/harvester/npc/whaley_dialogue.cpp b/engines/harvester/npc/whaley_dialogue.cpp
index ecd19f85325..77be2bb1d6f 100644
--- a/engines/harvester/npc/whaley_dialogue.cpp
+++ b/engines/harvester/npc/whaley_dialogue.cpp
@@ -28,45 +28,464 @@
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kDialogueC052FstPath = "GRAPHIC/FST/C052.FST";
+static const char *const kDialogueC053FstPath = "GRAPHIC/FST/C053.FST";
+static const char *const kDialogueC055FstPath = "GRAPHIC/FST/C055.FST";
+static const char *const kDialogueC057FstPath = "GRAPHIC/FST/C057.FST";
+static const char *const kReverseBroomActionTag = "REVERSE_BROOM";
+static const char *const kSoundAirRaidActionTag = "SOUND_AIR_RAID";
+static const char *const kWhaleyPhotoObjectName = "PHOTO_OF_WHALEY_HERRILL";
+
+static const DialogueLineEntry kWhaleyBustedScrewingResponse2WithoutPhotoLines[] = {
+	{ 0x1456, "HERRILL", 2 },
+	{ 0x145a, "HERRILL", 2 },
+	{ 0x145f, "WHALEY", 2 }
+};
+
+static const DialogueLineEntry kWhaleyBustedScrewingResponse2Menu2Lines[] = {
+	{ 0x1491, "HERRILL", 2 },
+	{ 0x1495, "WHALEY", 3 },
+	{ 0x1499, "HERRILL", 0 },
+	{ 0x149e, "WHALEY", 1 }
+};
+
+static const DialogueLineEntry kWhaleyCasketPhotoLines[] = {
+	{ 0x1417, "WHALEY", 0 },
+	{ 0x141b, "PC", 0 },
+	{ 0x141f, "WHALEY", 2 },
+	{ 0x1424, "PC", 0 }
+};
+
+static const DialogueLineEntry kWhaleyLedgerLines[] = {
+	{ 0x14a4, "WHALEY", 2 },
+	{ 0x14a9, "PC", 0 },
+	{ 0x14ae, "WHALEY", 1 },
+	{ 0x14b7, "WHALEY", 2 },
+	{ 0x14bc, "PC", 2 },
+	{ 0x14c0, "WHALEY", 0 }
+};
+
+static const DialogueLineEntry kWhaleyBlackmailLines[] = {
+	{ 0x14c7, "WHALEY", 0 },
+	{ 0x14cb, "PC", 0 },
+	{ 0x14cf, "WHALEY", 0 },
+	{ 0x14d7, "WHALEY", 0 }
+};
+
+static const DialogueLineEntry kWhaleyIntroLines[] = {
+	{ 0x12b6, "WHALEY", 1 },
+	{ 0x12c0, "WHALEY", 3 },
+	{ 0x12c5, "PC", 4 },
+	{ 0x12c9, "WHALEY", 0 },
+	{ 0x12cf, "PC", 0 },
+	{ 0x12d3, "WHALEY", 0 },
+	{ 0x12de, "WHALEY", 1 },
+	{ 0x12e3, "PC", 0 },
+	{ 0x12e7, "WHALEY", 0 },
+	{ 0x130b, "WHALEY", 1 }
+};
+
+static const DialogueLineEntry kWhaleyRevisitLines[] = {
+	{ 0x1385, "WHALEY", 0 },
+	{ 0x138e, "WHALEY", 0 }
+};
+
+static const DialogueLineEntry kWhaleyStephMidgameOpeningLines[] = {
+	{ 0x1394, "WHALEY", 1 },
+	{ 0x139d, "WHALEY", 1 }
+};
+
+static const DialogueLineEntry kWhaleyStephMidgameResponse1Lines[] = {
+	{ 0x13a8, "WHALEY", 0 },
+	{ 0x13ac, "PC", 0 },
+	{ 0x13b0, "WHALEY", 2 }
+};
+
+static const DialogueLineEntry kWhaleyDay5Response2Lines[] = {
+	{ 0x13d7, "WHALEY", 2 },
+	{ 0x13df, "WHALEY", 2 }
+};
+
+static const DialogueLineEntry kWhaleyKarinKidnapedLines[] = {
+	{ 0x13e5, "WHALEY", 0 },
+	{ 0x13ee, "WHALEY", 1 },
+	{ 0x13f3, "PC", 0 },
+	{ 0x13f8, "WHALEY", 0 },
+	{ 0x13ff, "WHALEY", 0 }
+};
+
+static const DialogueLineEntry kWhaleyBurnedTvStationLines[] = {
+	{ 0x14f7, "WHALEY", 1 },
+	{ 0x14fe, "WHALEY", 1 },
+	{ 0x1506, "WHALEY", 2 },
+	{ 0x150b, "WHALEY", 2 }
+};
+
+static const DialogueLineEntry kWhaleyTopic255Lines[] = {
+	{ 0x132a, "PC", 0 },
+	{ 0x132e, "WHALEY", 0 },
+	{ 0x1333, "PC", 0 },
+	{ 0x1337, "WHALEY", 0 }
+};
+
+static const DialogueLineEntry kWhaleyLodgeTopicLines[] = {
+	{ 0x1340, "PC", 0 },
+	{ 0x1344, "WHALEY", 0 }
+};
+
+} // End of namespace
+
 bool WhaleyDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("WHALEY");
 }
 
 Common::Error WhaleyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &) {
-	if (runtime.startupScript().getFlagValue("BUSTED_SCREWING_MIDGAME"))
-		return runtime.playDialogueLine(0x143b, "WHALEY");
-
-	if (usedItemName.empty()) {
-		if (_state.talkStatePending) {
-			_state.talkStatePending = false;
-			return runtime.playDialogueLine(0x12b6, "WHALEY");
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	WhaleyRoomDialogueState &state = _state;
+	Common::String whaleyTopicBuffer;
+	int whaleyTopicBufferLineIndex = -1;
+
+	auto assignWhaleyTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(whaleyTopicBuffer, whaleyTopicBufferLineIndex,
+			responseLineIndex, "Whaley topic buffer");
+	};
+	auto clearWhaleyTopicBuffer = [&]() {
+		whaleyTopicBuffer.clear();
+		whaleyTopicBufferLineIndex = -1;
+	};
+	auto playWhaleyLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "WHALEY", headVariant);
+	};
+	auto playHerrillLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "HERRILL", headVariant);
+	};
+	auto playPcLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "PC", headVariant);
+	};
+	auto playSequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
+		return runtime.playDialogueEntrySequence(lines, count);
+	};
+	auto executeActionTagIfSet = [&](const char *actionTag) {
+		StartupInteractionResult interaction;
+		if (runtime.startupScript().executeActionTag(actionTag, interaction)) {
+			runtime.applyImmediateDialogueInteractionEffects(interaction);
+			runtime.queueDialogueInteractionIfNeeded(interaction);
 		}
-		return runtime.playDialogueLine(0x1385, "WHALEY");
-	}
+	};
+	auto hasInventoryItem = [&](const char *objectName) {
+		Common::Array<StartupObjectRecord> inventoryObjects;
+		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
+		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
+				return true;
+		}
+		return false;
+	};
+
+	if (runtime.startupScript().getFlagValue("BUSTED_SCREWING_MIDGAME")) {
+		Common::Error lineError = playWhaleyLine(0x143b, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x24b, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playWhaleyLine(0x1445, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			lineError = playHerrillLine(0x1449, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playPcLine(0x144d, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			if (!hasInventoryItem(kWhaleyPhotoObjectName)) {
+				lineError = playSequence(kWhaleyBustedScrewingResponse2WithoutPhotoLines,
+					ARRAYSIZE(kWhaleyBustedScrewingResponse2WithoutPhotoLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else {
+				lineError = playHerrillLine(0x1465, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playWhaleyLine(0x1469, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				responseIndex = 0;
+				responseError = runtime.runResponseMenu(0x24c, responseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+
+				if (responseIndex == 1) {
+					lineError = playWhaleyLine(0x1476, 2);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+
+					int nestedResponseIndex = 0;
+					responseError = runtime.runResponseMenu(0x24d, nestedResponseIndex);
+					if (responseError.getCode() != Common::kNoError)
+						return responseError;
 
-	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL"))
-		return runtime.playDialogueLine(0x1411, "WHALEY");
-	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
-		return runtime.playDialogueLine(0x1417, "WHALEY");
+					if (nestedResponseIndex == 1) {
+						lineError = playWhaleyLine(0x1482, 0);
+						if (lineError.getCode() != Common::kNoError)
+							return lineError;
+						(void)runtime.startupScript().addRuntimeObjectToInventory(kWhaleyPhotoObjectName);
+					} else if (nestedResponseIndex == 2) {
+						lineError = playWhaleyLine(0x1487, 2);
+						if (lineError.getCode() != Common::kNoError)
+							return lineError;
+						lineError = playHerrillLine(0x148c, 2);
+						if (lineError.getCode() != Common::kNoError)
+							return lineError;
+					}
+				} else if (responseIndex == 2) {
+					lineError = playSequence(kWhaleyBustedScrewingResponse2Menu2Lines,
+						ARRAYSIZE(kWhaleyBustedScrewingResponse2Menu2Lines));
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+					(void)runtime.startupScript().addRuntimeObjectToInventory(kWhaleyPhotoObjectName);
+				}
+			}
+		}
+
+		(void)runtime.startupScript().setRuntimeFlagValue("BUSTED_SCREWING_MIDGAME", false);
+		if (runtime.startupScript().getFlagValue("TALKING_BROOM_WHALEY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue("TALKING_BROOM_WHALEY", false);
+			executeActionTagIfSet(kReverseBroomActionTag);
+		}
+		return Common::kNoError;
 	}
-	if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase(kWhaleyPhotoObjectName))
+			return playWhaleyLine(0x1411, 2);
+
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+			Common::Error lineError = playSequence(kWhaleyCasketPhotoLines,
+				ARRAYSIZE(kWhaleyCasketPhotoLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			if (runtime.startupScript().getFlagValue("IF_SAW_THE_SLASHES_ON_THE_BODY")) {
+				lineError = playWhaleyLine(0x1428, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = runtime.playDialogueFst(kDialogueC055FstPath);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playWhaleyLine(0x1430, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			return Common::kNoError;
+		}
+
+		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
 				usedItemName.equalsIgnoreCase("LEDGER2")) &&
-			runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
-		return runtime.playDialogueLine(0x14a4, "WHALEY");
+				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+			return playSequence(kWhaleyLedgerLines, ARRAYSIZE(kWhaleyLedgerLines));
+		}
+
+		if (usedItemName.equalsIgnoreCase("NOTE") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+			return playSequence(kWhaleyBlackmailLines, ARRAYSIZE(kWhaleyBlackmailLines));
+		}
+
+		return playWhaleyLine(0x140b, 2);
+	}
+
+	if (state.introPending) {
+		state.introPending = false;
+		Common::Error lineError = playSequence(kWhaleyIntroLines, ARRAYSIZE(kWhaleyIntroLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x24e, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			assignWhaleyTopicBuffer(0x24f);
+			lineError = playWhaleyLine(0x1316, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			clearWhaleyTopicBuffer();
+			lineError = playWhaleyLine(0x131a, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			return Common::kNoError;
+		}
+	} else {
+		Common::Error lineError = playSequence(kWhaleyRevisitLines, ARRAYSIZE(kWhaleyRevisitLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignWhaleyTopicBuffer(0x250);
+	}
+
+	if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
+			!state.stephMidgameShown) {
+		state.stephMidgameShown = true;
+
+		Common::Error lineError = playSequence(kWhaleyStephMidgameOpeningLines,
+			ARRAYSIZE(kWhaleyStephMidgameOpeningLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x251, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playSequence(kWhaleyStephMidgameResponse1Lines,
+				ARRAYSIZE(kWhaleyStephMidgameResponse1Lines));
+		} else if (responseIndex == 2) {
+			lineError = playWhaleyLine(0x13b5, 1);
+		} else {
+			lineError = Common::kNoError;
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getCurrentStoryDayIndex() == 5 && !state.day5Shown) {
+		state.day5Shown = true;
+
+		Common::Error lineError = playWhaleyLine(0x13bf, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x252, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playWhaleyLine(0x13ca, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC057FstPath);
+		} else if (responseIndex == 2) {
+			lineError = playSequence(kWhaleyDay5Response2Lines,
+				ARRAYSIZE(kWhaleyDay5Response2Lines));
+		} else {
+			lineError = Common::kNoError;
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
-	if (usedItemName.equalsIgnoreCase("NOTE") ||
-			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
-			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
-			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
-		return runtime.playDialogueLine(0x14c7, "WHALEY");
+
+	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
+			!state.karinKidnapedShown) {
+		state.karinKidnapedShown = true;
+		sharedState.karinKidnapedDialogueState = true;
+
+		Common::Error lineError = playSequence(kWhaleyKarinKidnapedLines,
+			ARRAYSIZE(kWhaleyKarinKidnapedLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill) &&
+			!state.whaleyHerrillPhotoFollowupShown) {
+		state.whaleyHerrillPhotoFollowupShown = true;
+		Common::Error lineError = playWhaleyLine(0x14dd, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 
-	return runtime.playDialogueLine(0x140b, "WHALEY");
+	if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
+			!state.burnedTvStationShown) {
+		state.burnedTvStationShown = true;
+
+		Common::Error lineError = playSequence(kWhaleyBurnedTvStationLines,
+			ARRAYSIZE(kWhaleyBurnedTvStationLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = runtime.playDialogueFst(kDialogueC055FstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playWhaleyLine(0x1512, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runtime.runKeywordMenu(
+			whaleyTopicBuffer, whaleyTopicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.empty())
+			return Common::kNoError;
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x254))
+			return playWhaleyLine(0x1373, 0);
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x255)) {
+			Common::Error lineError = playSequence(kWhaleyTopic255Lines,
+				ARRAYSIZE(kWhaleyTopic255Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignWhaleyTopicBuffer(0x256);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x257)) {
+			sharedState.discussedLodgeTopic = true;
+			Common::Error lineError = playSequence(kWhaleyLodgeTopicLines,
+				ARRAYSIZE(kWhaleyLodgeTopicLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignWhaleyTopicBuffer(0x258);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x259)) {
+			Common::Error lineError = playWhaleyLine(0x134e, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC053FstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			executeActionTagIfSet(kSoundAirRaidActionTag);
+			lineError = playWhaleyLine(0x135f, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC052FstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			sharedState.dwayneWhaleyDisciplineFollowupState = true;
+			lineError = playWhaleyLine(0x136c, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignWhaleyTopicBuffer(0x25b);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x25c))
+			continue;
+
+		Common::Error lineError = playWhaleyLine(0x1405, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/whaley_dialogue.h b/engines/harvester/npc/whaley_dialogue.h
index a5d15c98de5..d0f4886db3e 100644
--- a/engines/harvester/npc/whaley_dialogue.h
+++ b/engines/harvester/npc/whaley_dialogue.h
@@ -29,7 +29,12 @@ namespace Harvester {
 class WhaleyDialogueHandler : public NpcDialogueHandler {
 public:
 	struct WhaleyRoomDialogueState {
-		bool talkStatePending = true;
+		bool introPending = true;
+		bool stephMidgameShown = false;
+		bool day5Shown = false;
+		bool karinKidnapedShown = false;
+		bool whaleyHerrillPhotoFollowupShown = false;
+		bool burnedTvStationShown = false;
 	};
 
 	bool matchesNpc(const Common::String &npcName) const override;


Commit: ad0b88166d682466efcd948ef0cbbb563597169e
    https://github.com/scummvm/scummvm/commit/ad0b88166d682466efcd948ef0cbbb563597169e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:13+02:00

Commit Message:
HARVESTER: Rebuild mr potts dialogue from native flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/npc/dialogue_handler.h
    engines/harvester/npc/dialogue_runtime.h
    engines/harvester/npc/mom_dialogue.cpp
    engines/harvester/npc/mr_potts_dialogue.cpp
    engines/harvester/npc/mr_potts_dialogue.h
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 18f752dd011..5a63feb3b3e 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -431,6 +431,14 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `0x141` first calls `get_set_discussed_lodge_topic(0, 1)`, plays `0x1fe1` / `0x1fe5` / `0x1feb` / `0x1fef`, then shows zero-based `dialog.rsp` line `0x142`; response `1` plays `0x1ffe`, response `2` plays `0x2005` / `0x200a` / `0x200e`, and the branch then rewrites the keyword buffer to `0x144`.
     - When `STEPH_MIDGAME_PLAYED` is already set, that same `0x141` topic instead plays `0x2154` / `0x215a` and loops on the current keyword buffer without rewriting it.
     - `0x151..0x152` plays `0x20f7` / `0x20fb`, then shows zero-based `dialog.rsp` line `0x153`; response `1` plays `0x2106` / `0x210a` / `0x210e` and loops on the current keyword buffer, while response `2` plays `0x2114` / `0x2119` / `0x211d` and rewrites the buffer to `0x154`.
+  - Hidden `handle_talk_to_mr_potts` blocks confirm the native response-menu text format more precisely than the earlier engine port:
+    - Slash characters are explicit row breaks inside a menu entry, not generic option separators.
+    - Numeric prefixes (`1.`, `2.`, ...) start a new selectable response.
+    - The accusation branch builds a runtime menu string by taking zero-based `dialog.rsp` line `0x20f` and appending hardcoded accusation text fragments before routing the result back through the same response-menu routine.
+  - `handle_talk_to_mr_potts @ 0x26000` is now bounded enough for a direct startup-room port despite the truncated primary function stub.
+    - The auxiliary intro is the only place that reseeds Mr. Potts's keyword buffer, writing zero-based `dialog.rsp` lines `0x1d8`, `0x1d9`, `0x1da`, or `0x1db` depending on the selected response and whether Stephanie is already dead.
+    - That keyword buffer persists outside `reset_talk_to_mr_potts_aux_state`; later visits reuse it through the hidden loop rooted at `0x26fc8`, which clears the selected topic text and re-enters `run_dialogue_keyword_menu`.
+    - The hidden keyword loop now has confirmed branches for `AMNESIA`, `MARRIAGE`, `COMMITMENT`, `FATHER`, `SLAUGHTERHOUSE`, `MEAT`, `STEPHANIE`, `LODGE`, `NEWSPAPER`, `MOYNAHAN`, the peephole aliases `0x204..0x20a`, the murder aliases `0x20c..0x20e`, `TOILET`, the molest/pervert aliases `0x216..0x218`, the later `BYE` topic `0x21c`, and the generic fallback line `0x2c73`.
   - `handle_talk_to_pta_mom @ 0x34e30` is now constrained to a compact one-screen exchange.
     - If `PTA_RESPOND_TO_TV` is set, it plays `0x3233` with `PTA_MOM1` head variant `2`, clears that flag, and returns without opening the response menu.
     - Otherwise it chooses one of three random opener lines, `0x31ee`, `0x31f2`, or `0x31f6`, all spoken as `PTA_MOM1` with head variant `1`, then opens zero-based `dialog.rsp` line `0x297`.
diff --git a/TRACKER.md b/TRACKER.md
index ebcec7fa0c5..7a41f4683d2 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -9,18 +9,18 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `906`
-- Named/documented: `587`
+- Total functions: `908`
+- Named/documented: `589`
 - Still `FUN_*` / undocumented: `319`
 
 ## Last Confirmed Action
 
-- Rebuilt `handle_talk_to_whaley @ 0x23ec0` from corrected native control flow and fixed the engine port to follow the recovered event, menu, and keyword branches.
-  - Whaley now covers the busted-screwing event chain, the full evidence-item exchanges, the intro/revisit keyword setup, the Steph/day-5/Karin/BURNED_TV_STATION one-shots, and the looping `dialog.rsp` topic transitions.
-  - The engine-side shared dialogue state now carries the Dwayne/Whaley discipline follow-up bit, and the matching native FST helpers (`C052`, `C053`, `C055`, `C057`) are named in Ghidra.
+- Rebuilt `handle_talk_to_mr_potts @ 0x26000` from corrected native control flow and fixed the engine port to match the recovered event, response-menu, and keyword-loop branches.
+  - Mr. Potts now covers the POTTSDAM digging branches, evidence-item exchanges, auxiliary intro and revisit flow, the Karin/day-event one-shots, the persistent keyword buffer, the runtime-built accusation menu, and the hidden peephole / murder / molest topic logic.
+  - The engine dialogue runtime now supports native-style response-menu parsing for dynamically assembled menu text, and Ghidra now names the `C016A.FST` helper (`play_c016a_fst`) with updated notes on the broken Mr. Potts function stub.
 
 ## Next Suggested Action
 
 1. Audit the remaining `handle_talk_to_*` ports against the corrected native disassembly.
-   - The highest-value follow-ups are the remaining short ports that still hide native menu/event logic behind mistyped `play_dialogue_line` calls, starting with `handle_talk_to_mr_potts`, `handle_talk_to_jimmy`, and the other handlers that still xref `run_dialogue_response_menu` / `run_dialogue_keyword_menu` from not-yet-audited code ranges.
+   - The highest-value immediate follow-up is `handle_talk_to_jimmy`, then the other remaining short handlers that still hide native menu/event logic behind mistyped `play_dialogue_line` calls or orphan `run_dialogue_response_menu` / `run_dialogue_keyword_menu` blocks.
 2. Apply any additional high-confidence Ghidra renames or comments that fall directly out of those audited handlers before each commit.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 8461c3ad0f5..5dd0b73a0ec 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -760,24 +760,85 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		interaction.audioCommands.clear();
 	};
 
-	auto buildResponseMenuLayout = [&](const Common::String &responseLine,
+	auto parseResponseMenuLine = [&](const Common::String &responseLine,
 			Common::Array<DialogueResponseOptionLayout> &options, uint &totalRows) {
 		options.clear();
 		totalRows = 0;
+		if (responseLine.empty())
+			return;
+
+		Common::String segment;
+		Common::Array<Common::String> explicitLines;
+		Common::String optionText;
+
+		auto flushOption = [&]() {
+			if (explicitLines.empty())
+				return;
 
-		Common::Array<Common::String> rawOptions;
-		splitDialogueMenuLine(responseLine, rawOptions);
-		for (const Common::String &rawOption : rawOptions) {
 			DialogueResponseOptionLayout option;
-			option.text = rawOption;
-			menuFont->wordWrapText(rawOption, kDialogueSubtitleTextWidth, option.wrappedLines);
-			if (option.wrappedLines.empty())
-				option.wrappedLines.push_back(rawOption);
+			option.text = optionText;
 			option.rowStart = (int)totalRows;
+			for (const Common::String &explicitLine : explicitLines) {
+				Common::Array<Common::String> wrappedExplicitLines;
+				menuFont->wordWrapText(explicitLine, kDialogueSubtitleTextWidth, wrappedExplicitLines);
+				if (wrappedExplicitLines.empty())
+					wrappedExplicitLines.push_back(explicitLine);
+				for (const Common::String &wrappedExplicitLine : wrappedExplicitLines)
+					option.wrappedLines.push_back(wrappedExplicitLine);
+			}
 			option.rowCount = MAX<int>(1, option.wrappedLines.size());
 			totalRows += option.rowCount;
 			options.push_back(option);
+
+			explicitLines.clear();
+			optionText.clear();
+		};
+
+		auto consumeSegment = [&](const Common::String &rawSegment) {
+			Common::String segmentText = rawSegment;
+			if (segmentText.empty())
+				return;
+
+			uint digitEnd = 0;
+			while (digitEnd < segmentText.size() && segmentText[digitEnd] >= '0' &&
+					segmentText[digitEnd] <= '9') {
+				++digitEnd;
+			}
+			const bool startsNewOption = digitEnd > 0 && digitEnd < segmentText.size() &&
+				segmentText[digitEnd] == '.';
+			if (startsNewOption) {
+				flushOption();
+
+				Common::String optionLine = segmentText.substr(digitEnd + 1);
+				optionLine.trim();
+				explicitLines.push_back(optionLine);
+				optionText = optionLine;
+				return;
+			}
+
+			explicitLines.push_back(segmentText);
+			if (!optionText.empty())
+				optionText += ' ';
+			optionText += segmentText;
+		};
+
+		for (uint i = 0; i < responseLine.size(); ++i) {
+			if (responseLine[i] == '/') {
+				consumeSegment(segment);
+				segment.clear();
+				continue;
+			}
+
+			segment += responseLine[i];
 		}
+
+		consumeSegment(segment);
+		flushOption();
+	};
+
+	auto buildResponseMenuLayout = [&](const Common::String &responseLine,
+			Common::Array<DialogueResponseOptionLayout> &options, uint &totalRows) {
+		parseResponseMenuLine(responseLine, options, totalRows);
 	};
 
 	auto getResponseMenuItemAt = [&](const Common::Array<DialogueResponseOptionLayout> &options,
@@ -840,13 +901,17 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		screen->update();
 	};
 
-	auto runResponseMenu = [&](int responseLineIndex, int &selectedIndex) -> Common::Error {
+	auto runResponseMenuText = [&](const Common::String &responseLine, int responseLineIndex,
+			int &selectedIndex) -> Common::Error {
 		selectedIndex = 0;
 
-		const Common::String responseLine = startupText->getDialogueResponseLine(responseLineIndex);
 		if (responseLine.empty()) {
-			debugC(1, kDebugDialogue, "Harvester: response menu line 0x%x (%d) is empty",
-				responseLineIndex, responseLineIndex + 1);
+			if (responseLineIndex >= 0) {
+				debugC(1, kDebugDialogue, "Harvester: response menu line 0x%x (%d) is empty",
+					responseLineIndex, responseLineIndex + 1);
+			} else {
+				debugC(1, kDebugDialogue, "Harvester: response menu text is empty");
+			}
 			return Common::kNoError;
 		}
 
@@ -854,14 +919,19 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		uint totalRows = 0;
 		buildResponseMenuLayout(responseLine, options, totalRows);
 		if (options.empty()) {
-			debugC(1, kDebugDialogue, "Harvester: response menu line 0x%x (%d) produced no options",
-				responseLineIndex, responseLineIndex + 1);
+			if (responseLineIndex >= 0) {
+				debugC(1, kDebugDialogue, "Harvester: response menu line 0x%x (%d) produced no options",
+					responseLineIndex, responseLineIndex + 1);
+			} else {
+				debugC(1, kDebugDialogue, "Harvester: response menu text produced no options");
+			}
 			return Common::kNoError;
 		}
 
-		Common::Array<Common::String> rawOptions;
-		splitDialogueMenuLine(responseLine, rawOptions);
-		logDialogueMenuItems("Response menu", responseLineIndex, responseLine, rawOptions);
+		Common::Array<Common::String> optionTexts;
+		for (const DialogueResponseOptionLayout &option : options)
+			optionTexts.push_back(option.text);
+		logDialogueMenuItems("Response menu", responseLineIndex, responseLine, optionTexts);
 
 		const IndexedBitmap *textboxBitmap = startupArt->getTextboxBitmap(resolveDialogueResponseTextboxIndex(totalRows));
 		Common::Error releaseError = waitForPointerRelease();
@@ -881,27 +951,43 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 
 				if (event.type == Common::EVENT_KEYDOWN) {
 					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-						debugC(1, kDebugDialogue, "Harvester: response menu line 0x%x (%d) cancelled",
-							responseLineIndex, responseLineIndex + 1);
+						if (responseLineIndex >= 0) {
+							debugC(1, kDebugDialogue, "Harvester: response menu line 0x%x (%d) cancelled",
+								responseLineIndex, responseLineIndex + 1);
+						} else {
+							debugC(1, kDebugDialogue, "Harvester: response menu text cancelled");
+						}
 						return Common::kNoError;
 					}
 					if (event.kbd.ascii >= '1' && event.kbd.ascii <= '9') {
 						const int menuIndex = event.kbd.ascii - '0';
 						if (menuIndex >= 1 && menuIndex <= (int)options.size()) {
 							selectedIndex = menuIndex;
-							debugC(1, kDebugDialogue,
-								"Harvester: response menu line 0x%x (%d) selected option[%d]='%s' via hotkey",
-								responseLineIndex, responseLineIndex + 1,
-								selectedIndex, options[(uint)(selectedIndex - 1)].text.c_str());
+							if (responseLineIndex >= 0) {
+								debugC(1, kDebugDialogue,
+									"Harvester: response menu line 0x%x (%d) selected option[%d]='%s' via hotkey",
+									responseLineIndex, responseLineIndex + 1,
+									selectedIndex, options[(uint)(selectedIndex - 1)].text.c_str());
+							} else {
+								debugC(1, kDebugDialogue,
+									"Harvester: response menu text selected option[%d]='%s' via hotkey",
+									selectedIndex, options[(uint)(selectedIndex - 1)].text.c_str());
+							}
 							return waitForPointerRelease();
 						}
 					}
 				} else if (event.type == Common::EVENT_LBUTTONDOWN && hoveredOptionIndex >= 0) {
 					selectedIndex = hoveredOptionIndex + 1;
-					debugC(1, kDebugDialogue,
-						"Harvester: response menu line 0x%x (%d) selected option[%d]='%s' via click",
-						responseLineIndex, responseLineIndex + 1,
-						selectedIndex, options[(uint)hoveredOptionIndex].text.c_str());
+					if (responseLineIndex >= 0) {
+						debugC(1, kDebugDialogue,
+							"Harvester: response menu line 0x%x (%d) selected option[%d]='%s' via click",
+							responseLineIndex, responseLineIndex + 1,
+							selectedIndex, options[(uint)hoveredOptionIndex].text.c_str());
+					} else {
+						debugC(1, kDebugDialogue,
+							"Harvester: response menu text selected option[%d]='%s' via click",
+							selectedIndex, options[(uint)hoveredOptionIndex].text.c_str());
+					}
 					return waitForPointerRelease();
 				}
 			}
@@ -913,6 +999,11 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		}
 	};
 
+	auto runResponseMenu = [&](int responseLineIndex, int &selectedIndex) -> Common::Error {
+		const Common::String responseLine = startupText->getDialogueResponseLine(responseLineIndex);
+		return runResponseMenuText(responseLine, responseLineIndex, selectedIndex);
+	};
+
 	auto runKeywordMenu = [&](const Common::String &topicBuffer, int topicBufferLineIndex,
 			Common::String &selectedTopic) -> Common::Error {
 		selectedTopic.clear();
@@ -1036,7 +1127,11 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 	DialogueRuntime runtime(
 		_engine, *startupScript, *startupText, startupFlow, npc.roomName, genericByeTopic,
 		playDialogueLineWithVariant, playDialogueLine, playDialogueEntrySequence,
-		playDialogueFst, runKeywordMenu, runResponseMenu, assignTopicBuffer,
+		playDialogueFst, runKeywordMenu, runResponseMenu,
+		[&](const Common::String &responseLine, int &selectedIndex) {
+			return runResponseMenuText(responseLine, -1, selectedIndex);
+		},
+		assignTopicBuffer,
 		matchesResponseLine, matchesAnyResponseLine, queueDialogueInteractionIfNeeded,
 		applyImmediateDialogueInteractionEffects,
 		[&](int maxValue) { return _engine.getRandomNumber(maxValue); },
diff --git a/engines/harvester/npc/dialogue_handler.h b/engines/harvester/npc/dialogue_handler.h
index df09853e8df..b50e843685c 100644
--- a/engines/harvester/npc/dialogue_handler.h
+++ b/engines/harvester/npc/dialogue_handler.h
@@ -34,6 +34,8 @@ struct DialogueSharedState {
 	bool dialogueStateD2e98 = false;
 	bool dialogueStateD2eb0 = false;
 	bool dialogueStateD2eec = false;
+	bool dialogueStateD2ed0 = false;
+	bool dialogueStateD2ef4 = false;
 	bool dialogueStateD2f04 = false;
 	bool dialogueStateD2ea4 = false;
 	bool dialogueStateD2ea8 = false;
@@ -44,6 +46,8 @@ struct DialogueSharedState {
 	bool waspWomanDialogueState = false;
 	bool momGoodCauseDay5State = false;
 	bool dwayneWhaleyDisciplineFollowupState = false;
+	bool discussedMrPottsTuesdayNightAlibi = false;
+	bool confrontedMrPottsAboutSpyhole = false;
 };
 
 class NpcDialogueHandler {
diff --git a/engines/harvester/npc/dialogue_runtime.h b/engines/harvester/npc/dialogue_runtime.h
index 80b4f88d071..e3ec0c4f9fd 100644
--- a/engines/harvester/npc/dialogue_runtime.h
+++ b/engines/harvester/npc/dialogue_runtime.h
@@ -27,6 +27,7 @@ public:
 	typedef std::function<Common::Error(const Common::String &)> PlayDialogueFstFn;
 	typedef std::function<Common::Error(const Common::String &, int, Common::String &)> RunKeywordMenuFn;
 	typedef std::function<Common::Error(int, int &)> RunResponseMenuFn;
+	typedef std::function<Common::Error(const Common::String &, int &)> RunResponseMenuTextFn;
 	typedef std::function<void(Common::String &, int &, int, const char *)> AssignTopicBufferFn;
 	typedef std::function<bool(const Common::String &, int)> MatchesResponseLineFn;
 	typedef std::function<bool(const Common::String &, const int *, uint)> MatchesAnyResponseLineFn;
@@ -44,6 +45,7 @@ public:
 			const PlayDialogueFstFn &playDialogueFst,
 			const RunKeywordMenuFn &runKeywordMenu,
 			const RunResponseMenuFn &runResponseMenu,
+			const RunResponseMenuTextFn &runResponseMenuText,
 			const AssignTopicBufferFn &assignTopicBuffer,
 			const MatchesResponseLineFn &matchesResponseLine,
 			const MatchesAnyResponseLineFn &matchesAnyResponseLine,
@@ -57,7 +59,8 @@ public:
 		  _playDialogueLineWithVariant(playDialogueLineWithVariant),
 		  _playDialogueLine(playDialogueLine), _playDialogueEntrySequence(playDialogueEntrySequence),
 		  _playDialogueFst(playDialogueFst), _runKeywordMenu(runKeywordMenu),
-		  _runResponseMenu(runResponseMenu), _assignTopicBuffer(assignTopicBuffer),
+		  _runResponseMenu(runResponseMenu), _runResponseMenuText(runResponseMenuText),
+		  _assignTopicBuffer(assignTopicBuffer),
 		  _matchesResponseLine(matchesResponseLine),
 		  _matchesAnyResponseLine(matchesAnyResponseLine),
 		  _queueDialogueInteractionIfNeeded(queueDialogueInteractionIfNeeded),
@@ -91,6 +94,9 @@ public:
 	Common::Error runResponseMenu(int responseLineIndex, int &responseIndex) const {
 		return _runResponseMenu(responseLineIndex, responseIndex);
 	}
+	Common::Error runResponseMenuText(const Common::String &responseLine, int &responseIndex) const {
+		return _runResponseMenuText(responseLine, responseIndex);
+	}
 	void assignTopicBuffer(Common::String &topicBuffer, int &topicBufferLineIndex,
 			int responseLineIndex, const char *label) const {
 		_assignTopicBuffer(topicBuffer, topicBufferLineIndex, responseLineIndex, label);
@@ -126,6 +132,7 @@ private:
 	PlayDialogueFstFn _playDialogueFst;
 	RunKeywordMenuFn _runKeywordMenu;
 	RunResponseMenuFn _runResponseMenu;
+	RunResponseMenuTextFn _runResponseMenuText;
 	AssignTopicBufferFn _assignTopicBuffer;
 	MatchesResponseLineFn _matchesResponseLine;
 	MatchesAnyResponseLineFn _matchesAnyResponseLine;
diff --git a/engines/harvester/npc/mom_dialogue.cpp b/engines/harvester/npc/mom_dialogue.cpp
index 23245585fa2..47e5fd23027 100644
--- a/engines/harvester/npc/mom_dialogue.cpp
+++ b/engines/harvester/npc/mom_dialogue.cpp
@@ -560,6 +560,7 @@ Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		state.sameDayIntroLineEnabled = true;
 		state.postIntroDefaultLineEnabled = true;
 		state.introDayIndex = currentStoryDayIndex;
+		sharedState.dialogueStateD2ef4 = true;
 
 		Common::Error lineError = playSequence(kMomIntroLines, ARRAYSIZE(kMomIntroLines));
 		if (lineError.getCode() != Common::kNoError)
diff --git a/engines/harvester/npc/mr_potts_dialogue.cpp b/engines/harvester/npc/mr_potts_dialogue.cpp
index 84745a4a46e..774d117c07b 100644
--- a/engines/harvester/npc/mr_potts_dialogue.cpp
+++ b/engines/harvester/npc/mr_potts_dialogue.cpp
@@ -26,9 +26,127 @@
 #include "common/array.h"
 #include "harvester/npc/dialogue_flags.h"
 #include "harvester/npc/dialogue_runtime.h"
+#include "harvester/startup_text.h"
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kDialogueC016AFstPath = "GRAPHIC/FST/C016A.FST";
+static const char *const kWeddingMatchesObjectName = "WED_MATCHES";
+static const char *const kMrPottsMoynahanAccusationResponseText =
+	"Mr. Moynahan says you were out and/   about the night she disappeared.";
+static const char *const kMrPottsWeddingMatchesResponseText =
+	"I found one of our wedding match-/   books in the cemetary.";
+
+static const DialogueLineEntry kMrPottsPottsdamDiggingKarinLines[] = {
+	{ 0x2c33, "MR_POTTS", 2 },
+	{ 0x2c47, "MR_POTTS", 4 },
+	{ 0x2c48, "MR_POTTS", 4 },
+	{ 0x2c49, "MR_POTTS", 0 },
+	{ 0x2c54, "MR_POTTS", 0 },
+	{ 0x2c58, "MR_POTTS", 2 }
+};
+
+static const DialogueLineEntry kMrPottsAuxIntroResponse1Lines[] = {
+	{ 0x2994, "MR_POTTS", 1 },
+	{ 0x2998, "MR_POTTS", 2 },
+	{ 0x299d, "MR_POTTS", 1 }
+};
+
+static const DialogueLineEntry kMrPottsAuxIntroResponse1WithMomIntroLines[] = {
+	{ 0x29a3, "MR_POTTS", 1 },
+	{ 0x29ab, "MR_POTTS", 1 },
+	{ 0x29b0, "MR_POTTS", 1 }
+};
+
+static const DialogueLineEntry kMrPottsKarinKidnapedResponse1Lines[] = {
+	{ 0x2b2f, "MR_POTTS", 3 },
+	{ 0x2b34, "PC", 0 },
+	{ 0x2b38, "MR_POTTS", 0 }
+};
+
+static const DialogueLineEntry kMrPottsKarinKidnapedResponse1NestedResponse2Lines[] = {
+	{ 0x2b4b, "MR_POTTS", 0 },
+	{ 0x2b55, "MR_POTTS", 0 },
+	{ 0x2b5b, "MR_POTTS", 0 },
+	{ 0x2b5f, "MR_POTTS", 1 }
+};
+
+static const DialogueLineEntry kMrPottsKarinFoundAliveLines[] = {
+	{ 0x2b8d, "MR_POTTS", 3 },
+	{ 0x2b91, "MR_POTTS", 3 }
+};
+
+static const DialogueLineEntry kMrPottsKarinFoundAliveWithoutStephMidgameLines[] = {
+	{ 0x2b9b, "MR_POTTS", 0 },
+	{ 0x2b9f, "MR_POTTS", 0 },
+	{ 0x2ba4, "MR_POTTS", 0 }
+};
+
+static const DialogueLineEntry kMrPottsKarinFoundAliveWithStephMidgameLines[] = {
+	{ 0x2b96, "MR_POTTS", 3 },
+	{ 0x2b9f, "MR_POTTS", 0 },
+	{ 0x2ba4, "MR_POTTS", 0 }
+};
+
+static const DialogueLineEntry kMrPottsMurderAccusationOpeningLines[] = {
+	{ 0x2d58, "MR_POTTS", 2 },
+	{ 0x2d60, "MRS_POTTS", 1 },
+	{ 0x2d65, "MR_POTTS", 4 }
+};
+
+static const DialogueLineEntry kMrPottsWeddingMatchesAccusationLines[] = {
+	{ 0x2db3, "MR_POTTS", 0 },
+	{ 0x2db8, "MR_POTTS", 0 },
+	{ 0x2dbd, "MR_POTTS", 4 },
+	{ 0x2dc2, "MR_POTTS", 2 }
+};
+
+static const DialogueLineEntry kMrPottsPeepholeOpeningLines[] = {
+	{ 0x2cff, "MR_POTTS", 0 },
+	{ 0x2d03, "PC", 0 },
+	{ 0x2d08, "MR_POTTS", 0 }
+};
+
+static const DialogueLineEntry kMrPottsPeepholeResponse2Lines[] = {
+	{ 0x2d19, "MR_POTTS", 4 },
+	{ 0x2d1d, "MR_POTTS", 2 },
+	{ 0x2d22, "MR_POTTS", 1 }
+};
+
+static const DialogueLineEntry kMrPottsPeepholeClosingLines[] = {
+	{ 0x2d2c, "MR_POTTS", 0 },
+	{ 0x2d30, "MR_POTTS", 0 },
+	{ 0x2d35, "MR_POTTS", 0 }
+};
+
+static const DialogueLineEntry kMrPottsPeepholeFollowupLines[] = {
+	{ 0x2d39, "MR_POTTS", 0 },
+	{ 0x2d3e, "PC", 0 }
+};
+
+static const DialogueLineEntry kMrPottsMolestTopicOpeningLines[] = {
+	{ 0x2def, "MR_POTTS", 4 },
+	{ 0x2e06, "PC", 4 },
+	{ 0x2e0a, "MR_POTTS", 0 }
+};
+
+static const DialogueLineEntry kMrPottsMolestTopicResponse2Lines[] = {
+	{ 0x2e18, "MR_POTTS", 3 },
+	{ 0x2e1c, "PC", 2 },
+	{ 0x2e2b, "MR_POTTS", 3 }
+};
+
+static const int kMrPottsMurderTopicResponseLines[] = { 0x20c, 0x20d, 0x20e };
+static const int kMrPottsMolestTopicResponseLines[] = { 0x216, 0x217, 0x218 };
+static const int kMrPottsMoynahanTopicResponseLines[] = { 0x201, 0x202 };
+static const int kMrPottsPeepholeTopicResponseLines[] = {
+	0x204, 0x205, 0x206, 0x207, 0x208, 0x209, 0x20a
+};
+
+} // End of namespace
+
 bool MrPottsDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("MR_POTTS") ||
 		npcName.equalsIgnoreCase("MR_POTTS_CEM10") ||
@@ -36,10 +154,34 @@ bool MrPottsDialogueHandler::matchesNpc(const Common::String &npcName) const {
 }
 
 Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &) {
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	MrPottsRoomDialogueState &state = _state;
-	auto playMrPottsLine = [&](int wavId, const char *speakerId = "MR_POTTS") -> Common::Error {
-		return runtime.playDialogueLine(wavId, speakerId);
+	Common::String mrPottsTopicBuffer;
+	int mrPottsTopicBufferLineIndex = state.topicBufferLineIndex;
+
+	auto assignMrPottsTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(mrPottsTopicBuffer, mrPottsTopicBufferLineIndex,
+			responseLineIndex, "Mr Potts topic buffer");
+		state.topicBufferLineIndex = mrPottsTopicBufferLineIndex;
+	};
+	auto restoreMrPottsTopicBuffer = [&]() {
+		if (state.topicBufferLineIndex < 0)
+			return;
+
+		runtime.assignTopicBuffer(mrPottsTopicBuffer, mrPottsTopicBufferLineIndex,
+			state.topicBufferLineIndex, "Mr Potts topic buffer");
+	};
+	auto playMrPottsLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "MR_POTTS", headVariant);
+	};
+	auto playPcLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "PC", headVariant);
+	};
+	auto playPottsFleshLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "POTTS_FLESH", headVariant);
+	};
+	auto playSequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
+		return runtime.playDialogueEntrySequence(lines, count);
 	};
 	auto hasInventoryItem = [&](const char *objectName) {
 		Common::Array<StartupObjectRecord> inventoryObjects;
@@ -48,86 +190,687 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
 				return true;
 		}
-
 		return false;
 	};
+	auto queueNpcDeathOrMonsterfyTransition = [&](const char *npcName) {
+		StartupInteractionResult interaction;
+		if (runtime.startupScript().triggerRuntimeNpcDeathOrMonsterfy(npcName))
+			interaction.mutatedRuntimeState = true;
+		runtime.queueDialogueInteractionIfNeeded(interaction);
+	};
+
+	Common::Error lineError = Common::kNoError;
+	const int currentStoryDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
+	const bool stephanieIsDead = runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD");
 
 	if (hasInventoryItem("HARVEST_BLADE")) {
 		(void)runtime.startupScript().setRuntimeFlagValue("PC_TALKED_TO_POTTS", true);
-		return playMrPottsLine(0x2f1d, "POTTS_FLESH");
+		lineError = playPottsFleshLine(0x2f1d, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		return playMrPottsLine(0x2f22, 4);
 	}
 
 	if (runtime.startupScript().getFlagValue("STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(
 			"STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE", false);
-		return playMrPottsLine(0x2afc);
+		lineError = playMrPottsLine(0x2afc, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 
 	if (runtime.startupScript().getFlagValue("POTTSDAM_DIGGING")) {
 		if (!state.pottsdamDiggingLinePlayed) {
 			state.pottsdamDiggingLinePlayed = true;
-			Common::Error lineError = playMrPottsLine(0x2c63);
+			lineError = runtime.playDialogueFst(kDialogueC016AFstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playMrPottsLine(0x2c63, 2);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 
-		return playMrPottsLine(0x2cd9);
+		lineError = playMrPottsLine(0x2cd9, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 
-	if (runtime.startupScript().getFlagValue("POTTSDAM_DIGGING_KARIN"))
-		return playMrPottsLine(0x2c33);
+	if (runtime.startupScript().getFlagValue("POTTSDAM_DIGGING_KARIN")) {
+		lineError = playSequence(kMrPottsPottsdamDiggingKarinLines,
+			ARRAYSIZE(kMrPottsPottsdamDiggingKarinLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		queueNpcDeathOrMonsterfyTransition("MR_POTTS_CEM10");
+		return Common::kNoError;
+	}
 
 	if (!usedItemName.empty()) {
-		if (usedItemName.equalsIgnoreCase("MEAT"))
-			return playMrPottsLine(0x2c7f);
-		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+		if (usedItemName.equalsIgnoreCase("MEAT")) {
+			lineError = playMrPottsLine(0x2c7f, 1);
+		} else if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
-			return playMrPottsLine(0x2c97);
-		}
-		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			lineError = playMrPottsLine(0x2c97, 0);
+		} else if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(
 				DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-			return playMrPottsLine(0x2cb3);
-		}
-		if (usedItemName.equalsIgnoreCase("NOTE") ||
+			lineError = playMrPottsLine(0x2cb3, 1);
+		} else if (usedItemName.equalsIgnoreCase("NOTE") ||
 				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(
 				DialogueFlags::kShownEvidenceOfBlackmail, true);
-			return playMrPottsLine(0x2ce0);
-		}
-		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
+			lineError = playMrPottsLine(0x2ce0, 4);
+		} else if (usedItemName.equalsIgnoreCase("TV_DEED") ||
 				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(
 				DialogueFlags::kShownEvidenceSheriffOwns, true);
-			return playMrPottsLine(0x2ce0);
+			lineError = playMrPottsLine(0x2ce0, 4);
+		} else {
+			lineError = playMrPottsLine(0x2c79, 0);
 		}
-
-		return playMrPottsLine(0x2c79);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 
-	if (runtime.startupScript().getFlagValue("IF_TRY_TO_SEE_STEPHANIE"))
-		return playMrPottsLine(0x2b18);
+	if (runtime.startupScript().getFlagValue("IF_TRY_TO_SEE_STEPHANIE")) {
+		lineError = playMrPottsLine(0x2b18, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
 
-	if (state.auxIntroPending &&
-			!runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD")) {
+	if (state.auxIntroPending && !stephanieIsDead) {
 		state.auxIntroPending = false;
-		state.followupDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
-		return playMrPottsLine(0x2988);
+		state.followupDayIndex = currentStoryDayIndex;
+
+		lineError = playMrPottsLine(0x2988, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x1d7, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playSequence(kMrPottsAuxIntroResponse1Lines,
+				ARRAYSIZE(kMrPottsAuxIntroResponse1Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			sharedState.dialogueStateD2f04 = true;
+			if (sharedState.dialogueStateD2ef4) {
+				lineError = playSequence(kMrPottsAuxIntroResponse1WithMomIntroLines,
+					ARRAYSIZE(kMrPottsAuxIntroResponse1WithMomIntroLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			assignMrPottsTopicBuffer(
+				runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD") ? 0x1d9 : 0x1d8);
+		} else if (responseIndex == 2) {
+			lineError = playMrPottsLine(0x29ba, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMrPottsTopicBuffer(
+				runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD") ? 0x1db : 0x1da);
+		}
+	} else if (currentStoryDayIndex == state.followupDayIndex) {
+		lineError = stephanieIsDead
+			? playMrPottsLine(0x2aed, 3)
+			: playMrPottsLine(0x2ac7, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	} else if (!stephanieIsDead) {
+		lineError = playMrPottsLine(0x2ace, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x1dc, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playMrPottsLine(0x2ad8, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			lineError = playMrPottsLine(0x2adc, 4);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		if (!runtime.startupScript().getFlagValue("DAY_2") &&
+				!runtime.startupScript().getFlagValue("DAY_3") &&
+				!runtime.startupScript().getFlagValue("DAY_4") &&
+				!runtime.startupScript().getFlagValue("DAY_5") &&
+				!state.stephanieAliveLaterDayShown) {
+			state.stephanieAliveLaterDayShown = true;
+			lineError = playMrPottsLine(0x2ae5, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			state.followupDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
+		}
+	} else {
+		lineError = playMrPottsLine(0x2bdf, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") && !state.karinKidnapedShown) {
+		state.karinKidnapedShown = true;
+
+		lineError = playMrPottsLine(0x2b1f, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(stephanieIsDead ? 0x1df : 0x1de, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playSequence(kMrPottsKarinKidnapedResponse1Lines,
+				ARRAYSIZE(kMrPottsKarinKidnapedResponse1Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int nestedResponseIndex = 0;
+			responseError = runtime.runResponseMenu(0x1e0, nestedResponseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (nestedResponseIndex == 1) {
+				lineError = playMrPottsLine(0x2b45, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else if (nestedResponseIndex == 2) {
+				lineError = playMrPottsLine(0x2b4b, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				int followupResponseIndex = 0;
+				responseError = runtime.runResponseMenu(0x1e1, followupResponseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+
+				lineError = playSequence(kMrPottsKarinKidnapedResponse1NestedResponse2Lines,
+					ARRAYSIZE(kMrPottsKarinKidnapedResponse1NestedResponse2Lines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+		} else if (responseIndex == 2) {
+			lineError = runtime.startupScript().getFlagValue("DAY_6")
+				? playMrPottsLine(0x2b79, 3)
+				: playMrPottsLine(0x2b65, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+	}
+
+	if (runtime.startupScript().getFlagValue("DAY_6") &&
+			runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
+			!state.karinFoundDeadShown) {
+		state.karinFoundDeadShown = true;
+		lineError = playMrPottsLine(0x2b82, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue("KARIN") && !state.karinFoundAliveShown) {
+		state.karinFoundAliveShown = true;
+		lineError = playSequence(kMrPottsKarinFoundAliveLines,
+			ARRAYSIZE(kMrPottsKarinFoundAliveLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED")
+			? playSequence(kMrPottsKarinFoundAliveWithStephMidgameLines,
+				ARRAYSIZE(kMrPottsKarinFoundAliveWithStephMidgameLines))
+			: playSequence(kMrPottsKarinFoundAliveWithoutStephMidgameLines,
+				ARRAYSIZE(kMrPottsKarinFoundAliveWithoutStephMidgameLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x1e2, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playMrPottsLine(0x2bb2, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			lineError = playMrPottsLine(0x2bb6, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+	}
+
+	if (runtime.startupScript().getFlagValue("DAY_6") &&
+			runtime.startupScript().getFlagValue("KARIN") &&
+			!runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
+			!state.day6KarinShown) {
+		state.day6KarinShown = true;
+
+		lineError = playMrPottsLine(0x2bbd, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x1e3, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playMrPottsLine(0x2bc8, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			lineError = playMrPottsLine(0x2bd1, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+	}
+
+	if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER") && !state.scratchedTuckerShown) {
+		state.scratchedTuckerShown = true;
+		lineError = playMrPottsLine(0x2e54, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") && !state.barberPoleStolenShown) {
+		state.barberPoleStolenShown = true;
+		lineError = playMrPottsLine(0x2e61, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue("BOLT_OF_CLOTH_TAKEN") && !state.boltOfClothTakenShown) {
+		state.boltOfClothTakenShown = true;
+		lineError = playMrPottsLine(0x2e69, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue("DINER_BURNED") && !state.dinerBurnedShown) {
+		state.dinerBurnedShown = true;
+		lineError = playMrPottsLine(0x2e72, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = runtime.startupScript().getFlagValue("KARIN_KIDNAPED")
+			? playMrPottsLine(0x2e77, 0)
+			: playMrPottsLine(0x2e7c, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue("GOT_REMAINS_FOR_LODGE") &&
+			!state.gotRemainsForLodgeShown) {
+		state.gotRemainsForLodgeShown = true;
+
+		lineError = playMrPottsLine(0x2e82, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x1e7, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playMrPottsLine(0x2e8d, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			lineError = playMrPottsLine(0x2e91, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		lineError = playMrPottsLine(0x2e95, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue("PC_ESCAPED_JAIL") && !state.escapedJailShown) {
+		state.escapedJailShown = true;
+		lineError = playMrPottsLine(0x2e9c, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (!runtime.startupScript().isNamedNpcDeathTypeClear("BUTCHER") && !state.butcherDeathShown) {
+		state.butcherDeathShown = true;
+		lineError = playMrPottsLine(0x2ef2, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 
-	if (state.followupDayIndex == runtime.startupScript().getCurrentStoryDayIndex()) {
-		if (!runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD"))
-			return playMrPottsLine(0x2ac7);
-		return playMrPottsLine(0x2aed);
+	if (!runtime.startupScript().isNamedNpcDeathTypeClear("MOYNAHAN") && !state.moynahanDeathShown) {
+		state.moynahanDeathShown = true;
+		lineError = playMrPottsLine(0x2f08, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue("EDNA_HUNG") && !state.ednaHungShown) {
+		state.ednaHungShown = true;
+		lineError = playMrPottsLine(0x2f0f, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		if (runtime.startupScript().getFlagValue("KARIN_HUNG")) {
+			lineError = playMrPottsLine(0x2f14, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
 	}
 
-	if (!runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD"))
-		return playMrPottsLine(0x2ace);
+	restoreMrPottsTopicBuffer();
+	if (mrPottsTopicBufferLineIndex < 0)
+		return Common::kNoError;
 
-	return playMrPottsLine(0x2bdf);
+	Common::String selectedTopic;
+	for (;;) {
+		if (runtime.matchesResponseLine(selectedTopic, 0x1e9))
+			return Common::kNoError;
+
+		Common::Error keywordError = runtime.runKeywordMenu(
+			mrPottsTopicBuffer, mrPottsTopicBufferLineIndex, selectedTopic);
+		if (keywordError.getCode() != Common::kNoError)
+			return keywordError;
+		if (runtime.matchesResponseLine(selectedTopic, 0x1e9))
+			return Common::kNoError;
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x1ea)) {
+			if (!runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD")) {
+				lineError = playMrPottsLine(0x29c5, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x1ec)) {
+			if (!runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD")) {
+				lineError = playMrPottsLine(0x29de, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x1ee)) {
+			if (!runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD")) {
+				lineError = playMrPottsLine(0x29ee, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x1f0)) {
+			if (sharedState.dialogueStateD2ed0) {
+				lineError = playMrPottsLine(0x2a0d, 0);
+			} else if (runtime.startupScript().getFlagValue("HEARD_DAD_MOAN")) {
+				lineError = playMrPottsLine(0x2a09, 0);
+			} else {
+				lineError = playMrPottsLine(0x2a08, 0);
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x1f3)) {
+			lineError = playMrPottsLine(0x2a31, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x1f7)) {
+			if (!runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD")) {
+				lineError = playMrPottsLine(0x2a5e, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x1f9)) {
+			if (!runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD")) {
+				int responseIndex = 0;
+				Common::Error responseError = runtime.runResponseMenu(0x1fa, responseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+
+				if (responseIndex == 1) {
+					lineError = playMrPottsLine(0x2a73, 0);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				} else if (responseIndex == 2) {
+					lineError = playMrPottsLine(0x2a7c, 0);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				}
+
+				lineError = playMrPottsLine(0x2a81, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x1fd)) {
+			if (!runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD")) {
+				sharedState.discussedLodgeTopic = true;
+				lineError = playMrPottsLine(0x2a8b, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x1ff)) {
+			lineError = playMrPottsLine(0x2aaf, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMrPottsMoynahanTopicResponseLines,
+				ARRAYSIZE(kMrPottsMoynahanTopicResponseLines))) {
+			if (!runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD")) {
+				lineError = playMrPottsLine(0x2abb, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			continue;
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMrPottsMurderTopicResponseLines,
+				ARRAYSIZE(kMrPottsMurderTopicResponseLines))) {
+			if (!runtime.startupScript().getFlagValue("KARIN_KIDNAPED") ||
+					runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") ||
+					runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD")) {
+				lineError = playMrPottsLine(0x2bd9, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			}
+
+			lineError = playSequence(kMrPottsMurderAccusationOpeningLines,
+				ARRAYSIZE(kMrPottsMurderAccusationOpeningLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			const bool hasMoynahanAccusation = state.discussedMoynahanKarinKidnapedState;
+			const bool hasWeddingMatchesAccusation = hasInventoryItem(kWeddingMatchesObjectName);
+			if (!hasMoynahanAccusation && !hasWeddingMatchesAccusation) {
+				lineError = playPcLine(0x2e46, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playMrPottsLine(0x2e4a, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			}
+
+			Common::String accusationMenuLine =
+				runtime.startupText().getDialogueResponseLine(0x20f);
+			int nextResponseIndex = 2;
+			int moynahanResponseIndex = 0;
+			int weddingMatchesResponseIndex = 0;
+			if (hasMoynahanAccusation) {
+				moynahanResponseIndex = nextResponseIndex++;
+				accusationMenuLine += Common::String::format("/%d. %s",
+					moynahanResponseIndex, kMrPottsMoynahanAccusationResponseText);
+			}
+			if (hasWeddingMatchesAccusation) {
+				weddingMatchesResponseIndex = nextResponseIndex++;
+				accusationMenuLine += Common::String::format("/%d. %s",
+					weddingMatchesResponseIndex, kMrPottsWeddingMatchesResponseText);
+			}
+
+			int accusationResponseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenuText(
+				accusationMenuLine, accusationResponseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (accusationResponseIndex == 1) {
+				lineError = playMrPottsLine(0x2d7a, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else if (accusationResponseIndex == moynahanResponseIndex) {
+				lineError = playMrPottsLine(0x2d80, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				int responseIndex = 1;
+				if (sharedState.discussedMrPottsTuesdayNightAlibi) {
+					responseError = runtime.runResponseMenu(0x213, responseIndex);
+					if (responseError.getCode() != Common::kNoError)
+						return responseError;
+				}
+
+				if (responseIndex == 1) {
+					lineError = playMrPottsLine(0x2d8e, 2);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				} else if (responseIndex == 2) {
+					lineError = playMrPottsLine(0x2d93, 2);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				}
+			} else if (accusationResponseIndex == weddingMatchesResponseIndex) {
+				lineError = playSequence(kMrPottsWeddingMatchesAccusationLines,
+					ARRAYSIZE(kMrPottsWeddingMatchesAccusationLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x214)) {
+			lineError = playMrPottsLine(0x2dd3, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMrPottsMolestTopicResponseLines,
+				ARRAYSIZE(kMrPottsMolestTopicResponseLines))) {
+			lineError = playSequence(kMrPottsMolestTopicOpeningLines,
+				ARRAYSIZE(kMrPottsMolestTopicOpeningLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x21a, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = playMrPottsLine(0x2e14, 3);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else if (responseIndex == 2) {
+				lineError = playSequence(kMrPottsMolestTopicResponse2Lines,
+					ARRAYSIZE(kMrPottsMolestTopicResponse2Lines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				int followupResponseIndex = 0;
+				responseError = runtime.runResponseMenu(0x21b, followupResponseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+
+				if (followupResponseIndex == 1) {
+					lineError = playMrPottsLine(0x2e2f, 3);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				} else if (followupResponseIndex == 2) {
+					lineError = playPcLine(0x2e34, 2);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				}
+			}
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x21c)) {
+			selectedTopic.clear();
+			continue;
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMrPottsPeepholeTopicResponseLines,
+				ARRAYSIZE(kMrPottsPeepholeTopicResponseLines))) {
+			if (runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD")) {
+				lineError = playMrPottsLine(0x2bd9, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			}
+
+			lineError = playSequence(kMrPottsPeepholeOpeningLines,
+				ARRAYSIZE(kMrPottsPeepholeOpeningLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x20b, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = playMrPottsLine(0x2d14, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else if (responseIndex == 2) {
+				lineError = playSequence(kMrPottsPeepholeResponse2Lines,
+					ARRAYSIZE(kMrPottsPeepholeResponse2Lines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else if (responseIndex == 3) {
+				lineError = playMrPottsLine(0x2d28, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			lineError = playSequence(kMrPottsPeepholeClosingLines,
+				ARRAYSIZE(kMrPottsPeepholeClosingLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			sharedState.confrontedMrPottsAboutSpyhole = true;
+			lineError = playSequence(kMrPottsPeepholeFollowupLines,
+				ARRAYSIZE(kMrPottsPeepholeFollowupLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			selectedTopic.clear();
+			continue;
+		}
+
+		lineError = playMrPottsLine(0x2c73, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/mr_potts_dialogue.h b/engines/harvester/npc/mr_potts_dialogue.h
index 867f6c7154b..6b390b90084 100644
--- a/engines/harvester/npc/mr_potts_dialogue.h
+++ b/engines/harvester/npc/mr_potts_dialogue.h
@@ -29,13 +29,33 @@ namespace Harvester {
 class MrPottsDialogueHandler : public NpcDialogueHandler {
 public:
 	struct MrPottsRoomDialogueState {
-		bool auxIntroPending = false;
-		int followupDayIndex = -1;
+		bool auxIntroPending = true;
+		int followupDayIndex = 0;
 		bool pottsdamDiggingLinePlayed = false;
+		int topicBufferLineIndex = -1;
+		bool stephanieAliveLaterDayShown = false;
+		bool karinKidnapedShown = false;
+		bool karinFoundDeadShown = false;
+		bool karinFoundAliveShown = false;
+		bool day6KarinShown = false;
+		bool scratchedTuckerShown = false;
+		bool barberPoleStolenShown = false;
+		bool boltOfClothTakenShown = false;
+		bool dinerBurnedShown = false;
+		bool gotRemainsForLodgeShown = false;
+		bool escapedJailShown = false;
+		bool butcherDeathShown = false;
+		bool moynahanDeathShown = false;
+		bool ednaHungShown = false;
+		bool discussedMoynahanKarinKidnapedState = false;
 	};
 
 	bool matchesNpc(const Common::String &npcName) const override;
-	void resetState() override { _state = MrPottsRoomDialogueState(); }
+	void resetState() override {
+		const int topicBufferLineIndex = _state.topicBufferLineIndex;
+		_state = MrPottsRoomDialogueState();
+		_state.topicBufferLineIndex = topicBufferLineIndex;
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 0b7e1b51245..d0dc5fd269f 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -1008,6 +1008,32 @@ bool StartupScript::setRuntimeNpcState(const Common::String &npcName, bool activ
 	return changed;
 }
 
+bool StartupScript::triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcName) {
+	if (npcName.empty())
+		return false;
+
+	StartupNpcRecord *runtimeNpc = findRuntimeNpc(npcName);
+	if (!runtimeNpc)
+		return false;
+
+	bool monsterChanged = false;
+	if (!runtimeNpc->monsterfyTargetName.empty()) {
+		StartupMonsterRecord *runtimeMonster = findRuntimeMonster(runtimeNpc->monsterfyTargetName);
+		if (runtimeMonster) {
+			monsterChanged = !runtimeMonster->active || !runtimeMonster->visible;
+			runtimeMonster->active = true;
+			runtimeMonster->visible = true;
+		} else {
+			debug(1, "Harvester: unresolved monsterfy target for dialogue npc='%s' target='%s'",
+				runtimeNpc->npcName.c_str(), runtimeNpc->monsterfyTargetName.c_str());
+		}
+	}
+
+	const bool changed = !runtimeNpc->deathOrMonsterfyFlag;
+	runtimeNpc->deathOrMonsterfyFlag = true;
+	return changed || monsterChanged;
+}
+
 bool StartupScript::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupEntranceRecord *entrance,
 		StartupRoomSetupState &state) const {
 	state = StartupRoomSetupState();
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index ddd8258d01a..6edaeb812e8 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -305,6 +305,7 @@ public:
 		bool setRuntimeObjectVisible(const Common::String &ownerOrRoom, const Common::String &objectName,
 			bool visible);
 		bool setRuntimeNpcState(const Common::String &npcName, bool active, bool visible);
+		bool triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcName);
 		bool isNamedNpcDeathTypeClear(const Common::String &npcName) const;
 		int getCurrentStoryDayIndex() const;
 


Commit: 8278a82a26a6ddb2488eeb5d92af08e81655a017
    https://github.com/scummvm/scummvm/commit/8278a82a26a6ddb2488eeb5d92af08e81655a017
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:13+02:00

Commit Message:
HARVESTER: Correct jimmy dialogue branches

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/jimmy_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 5a63feb3b3e..9612e2261e3 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -472,6 +472,11 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `SCRATCHED_TUCKER` has its own one-shot re-entry hook: the first time that flag is seen, Johnson plays `0xc25`, re-arms the intro gate, and then immediately falls back into the normal intro path.
     - The steady-state no-item bark chain is fully linear and one-shot gated: `STEPH_MIDGAME_PLAYED` -> `0xbf0` / `0xbf1` / `0xbf2`, `KARIN_KIDNAPED` -> `0xbff` / `0xc06` plus another `d2f04` write, `KARIN_FOUND_ALIVE` -> `0xc13` / `0xc17`, `BARBER_POLE_STOLEN` -> `0xc46` / `0xc4d` / `0xc59`, `DINER_BURNED` -> `0xc6f`, `PC_ESCAPED_JAIL` -> `0xc77`, `GOT_REMAINS_FOR_LODGE` -> `0xc7f`, and `BURNED_TV_STATION` -> `0xc8f`.
     - Johnson's keyword loop now has a stable branch map: `0x177` exits on `0xbda`, `0x178` / `0x179` play `0xaa6`, open zero-based `dialog.rsp` line `0x17a`, then continue through `0xabd` / `0xac3` / `0xac7` and rewrite the keyword buffer to `0x17b`; `0x17c` plays `0xb17` / `0xb1c` / `0xb20` and rewrites to `0x17d`; `0x17e` plays `0xb2d` and rewrites to `0x17f`; `0x180` / `0x181` / `0x182`, `0x184` / `0x185`, and `0x187` each play one topic line, then fall through the generic `0xb69` / `0xbda` exit; `0x189` loops silently on the current buffer.
+  - `handle_talk_to_jimmy @ 0x28880` is a compact short-circuit handler with no keyword loop.
+    - The first no-item visit clears `g_jimmy_first_no_item_line_pending` and returns immediately on either `0x4a4c` when the paper-handoff intro has not fired yet or `0x4a58` once `g_jimmy_talk_state_block` is already set.
+    - Presenting `NEWSPAPER` sets `GIVEN_PAPER_TODAY`, runs the `ACTV_HOUSE_EXIT` action tag, restores `NEWSPAPER` to its startup state, and then either sets `g_jimmy_talk_state_block` and returns on `0x4a4c` or picks one of the two revisit barks `0x4acc` / `0x4a4b`.
+    - The evidence-item path is linear: `PHOTO_OF_WHALEY_HERRILL` plays `0x4af8`, `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` and `CASKET_PHOTO` / `CASKET_PHOTOCOPY` converge on `0x4b00`, note/checkbook evidence plays `0x4b21`, `SNEAKERS` is the dedicated `0x4a9e` branch, and all other items fall back to `0x4af2`.
+    - Later no-item visits remain a straight bark chain over inventory `SNEAKERS` without `BROOMKEY`, then `PAPER_CHK_4`, `PAPER_CHK_3`, `PAPER_CHK_2`, and the default `0x4b38`.
 - The shared dialogue evidence/topic state shims behind those handlers are now partially explicit:
   - `get_set_shared_dialogue_state_d2e98` at `0x38230` is a neutral wrapper over `g_shared_dialogue_state_d2e98`; the only currently recovered gameplay caller is Herrill's `GASCAN` branch at `0x2e056`, where `eax = 1` / `edx = 0` makes it a read-only gate and a zero value collapses the post-`0x30d8` continuation to `PC 0x3123`. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path and save/load serialization.
   - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
diff --git a/TRACKER.md b/TRACKER.md
index 7a41f4683d2..da38dc14628 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,12 +15,12 @@
 
 ## Last Confirmed Action
 
-- Rebuilt `handle_talk_to_mr_potts @ 0x26000` from corrected native control flow and fixed the engine port to match the recovered event, response-menu, and keyword-loop branches.
-  - Mr. Potts now covers the POTTSDAM digging branches, evidence-item exchanges, auxiliary intro and revisit flow, the Karin/day-event one-shots, the persistent keyword buffer, the runtime-built accusation menu, and the hidden peephole / murder / molest topic logic.
-  - The engine dialogue runtime now supports native-style response-menu parsing for dynamically assembled menu text, and Ghidra now names the `C016A.FST` helper (`play_c016a_fst`) with updated notes on the broken Mr. Potts function stub.
+- Re-audited `handle_talk_to_jimmy @ 0x28880` against the corrected native flow and fixed the short-circuit branches that the earlier engine port had collapsed together.
+  - Jimmy's first no-item visit, first `NEWSPAPER` handoff, and `SNEAKERS` item branch now return on the native single-line outcomes instead of falling through into later bark logic.
+  - The native notes now record Jimmy's compact branch layout, and the next highest-value corrective pass is the still-incomplete Boyle handler.
 
 ## Next Suggested Action
 
 1. Audit the remaining `handle_talk_to_*` ports against the corrected native disassembly.
-   - The highest-value immediate follow-up is `handle_talk_to_jimmy`, then the other remaining short handlers that still hide native menu/event logic behind mistyped `play_dialogue_line` calls or orphan `run_dialogue_response_menu` / `run_dialogue_keyword_menu` blocks.
+   - The highest-value immediate follow-up is `handle_talk_to_boyle`, then the other remaining short handlers that still hide native menu/event logic behind mistyped `play_dialogue_line` calls or orphan `run_dialogue_response_menu` / `run_dialogue_keyword_menu` blocks.
 2. Apply any additional high-confidence Ghidra renames or comments that fall directly out of those audited handlers before each commit.
diff --git a/engines/harvester/npc/jimmy_dialogue.cpp b/engines/harvester/npc/jimmy_dialogue.cpp
index d1e688995a3..e7c4ea0093f 100644
--- a/engines/harvester/npc/jimmy_dialogue.cpp
+++ b/engines/harvester/npc/jimmy_dialogue.cpp
@@ -86,9 +86,7 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 			if (!state.paperHandoffStateSet) {
 				state.paperHandoffStateSet = true;
-				Common::Error lineError = playJimmyLine(0x4a4c, 1);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
+				return playJimmyLine(0x4a4c, 1);
 			}
 
 			return runtime.getRandomNumber(1) == 0
@@ -120,9 +118,7 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return playJimmyLine(0x4b21, 0);
 		}
 		if (usedItemName.equalsIgnoreCase("SNEAKERS")) {
-			Common::Error lineError = playJimmyLine(0x4a9e, 1);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
+			return playJimmyLine(0x4a9e, 1);
 		}
 
 		return playJimmyLine(0x4af2, 0);
@@ -130,15 +126,9 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 	if (state.firstNoItemLinePending) {
 		state.firstNoItemLinePending = false;
-		if (!state.paperHandoffStateSet) {
-			Common::Error lineError = playJimmyLine(0x4a4c, 1);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-
-		Common::Error lineError = playJimmyLine(0x4a58, 0);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
+		return !state.paperHandoffStateSet
+			? playJimmyLine(0x4a4c, 1)
+			: playJimmyLine(0x4a58, 0);
 	}
 	if (hasInventoryItem("SNEAKERS") && !hasInventoryItem("BROOMKEY")) {
 		Common::Error lineError = playJimmyLine(0x4ac3, 0);


Commit: 9b5c3f7a09bbbcae6185b94fbc5fda4b431aa959
    https://github.com/scummvm/scummvm/commit/9b5c3f7a09bbbcae6185b94fbc5fda4b431aa959
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:14+02:00

Commit Message:
HARVESTER: Rebuild boyle dialogue from native flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/boyle_dialogue.cpp
    engines/harvester/npc/boyle_dialogue.h
    engines/harvester/npc/dialogue_handler.h
    engines/harvester/npc/dwayne_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 9612e2261e3..c17602e098a 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -481,6 +481,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `get_set_shared_dialogue_state_d2e98` at `0x38230` is a neutral wrapper over `g_shared_dialogue_state_d2e98`; the only currently recovered gameplay caller is Herrill's `GASCAN` branch at `0x2e056`, where `eax = 1` / `edx = 0` makes it a read-only gate and a zero value collapses the post-`0x30d8` continuation to `PC 0x3123`. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path and save/load serialization.
   - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
   - `get_set_shared_dialogue_state_d2eb0` at `0x382a0` is a neutral wrapper over `g_shared_dialogue_state_d2eb0`; the only currently recovered gameplay caller is a hidden Boyle dialogue branch at `0x2d6e3`, where `eax = 1` / `edx = 0` makes it a read-only gate between `BOYLE 0x10e` and `BOYLE 0x115` after the local item checks. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path.
+  - `get_set_shared_dialogue_state_d2ebc` at `0x382d0` is another neutral shared wrapper over `g_shared_dialogue_state_d2ebc`; the currently recovered gameplay caller is Boyle's `CASKET_PHOTO` / `CASKET_PHOTOCOPY` branch, where `eax = 1` / `edx = 0` makes it a read-only gate for the optional `0x253` line between `0x24f` and `0x258`. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path and save/load serialization.
   - `get_set_discussed_note_checkbook_evidence` at `0x38330` gates the shared `NOTE` / `NOTE_PHOTOCOPY` / `CHECKBOOK` / `CHECKBOOK_PHOTOCOPY` evidence path used by Dwayne, Edna, Herrill, Johnson, Mom, and related handlers.
   - `get_set_discussed_tv_deed_evidence` at `0x38340` gates the `TV_DEED` / `TV_DEED_PHOTOCOPY` evidence path used by Edna, Herrill, Johnson, and Mom.
   - `get_set_discussed_ledger_evidence` at `0x38350` gates the shared dual-ledger evidence path reached when `HAVE_BOTH_LEDGERS` is set.
@@ -507,12 +508,15 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `get_set_dwayne_whaley_discipline_followup_state` at `0x38500` is a Dwayne-local follow-up state bit read just before the `0x34b3` Miss Whaley school-discipline exchange and its immediate `0x79` response menu.
   - Each helper is a tiny read/write shim over a dedicated global (`g_discussed_note_checkbook_evidence`, `g_discussed_tv_deed_evidence`, `g_discussed_ledger_evidence`, `g_discussed_casket_photo_evidence`, `g_discussed_whaley_herrill_photo`, `g_discussed_karin_purse`, `g_discussed_lodge_topic`).
 - Boyle's hidden `GASCAN` return branch is now explicit through `get_set_boyle_gascan_application_state` at `0x38240`.
+  - Boyle's no-item intro is also fully bounded now. When the local intro bit is set, he clears it, plays the fixed `0x7` / `0xb` / `0xf` opener, then drives response menus `0x5`, `0x6`, `0x7`, and `0x8` before seeding keyword buffers `0x9` or `0xa`; steady revisits collapse to `0x23c` and optionally seed `0xb` when `BOYLES_BUTTON` is already in inventory.
+  - The post-intro no-item followups are one-shot gates rather than a single bark chain: the shared blackmail/button-evidence block plays `0x210` plus the optional `0x21a` / `0x215` inserts, branches through response menu `0xc` when `HAVE_LODGE_APP` is still clear, and then clears the active keyword buffer before the later `STEPH_MIDGAME_PLAYED`, `DAY_5`, and `BURNED_TV_STATION` one-shots.
   - When that state is nonzero, `handle_talk_to_boyle` plays the `0x1f2` / `0x1f6` / `0x1fa` / `0x206` exchange, removes `GASCAN` from inventory, awards `LODGE_APPLICATION`, and sets `HAVE_LODGE_APP`.
   - Boyle's hidden keyword block around `0x2d620` is now bounded enough to map its major topic groups.
     - `Amnesia` (zero-based `dialog.rsp` line `0x17`) plays the fixed `0xee` / `0xf2` / `0xf6` / `0xfa` exchange.
     - `Button`, `Epaulet`, `Shirt`, and `Postal Button` (`0x18` through `0x1b`) all converge on the neutral `get_set_shared_dialogue_state_d2eb0` read. When that shared bit is clear, Boyle first calls `play_c019b_fst_sequence` and then plays `0x10e`; when the bit is set, the same topic group jumps straight to `0x115`.
     - `Arson` (`0x1d`) and the literal keyword string `Arsonist` share one branch: Boyle plays `0x11c`, opens the zero-based `dialog.rsp` response menu at `0x1e`, then takes either the `0x126` / `0x12c` / `0x130` continuation for choice `1` or the `0x135` continuation for choice `2`.
     - `Blackmail` (`0x1f`) plays `0x13d` / `0x141` / `0x145` / `0x14a` / `0x14e`, `BYE` (`0x20`) falls through the common exit path, and unmatched keywords drop to Boyle's `0x243` fallback line.
+  - Boyle's item logic now cleanly splits into five native clusters: `BOYLES_BUTTON` uses response menu `0x2` and can arm both the gas-can application state and `SHERIFF_IN_DINER`; note/checkbook evidence either reuses the blackmail sequence or branches through response menus `0x3` and `0x4`; `PHOTO_OF_WHALEY_HERRILL` is the dedicated `0x25f` path; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` is the `0x24f` / optional `0x253` / `0x258` sequence; all other items fall back to `0x249`.
   - `play_c019b_fst_sequence` at `0x38620` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C019B.FST", 0)` and is currently only called from Boyle's `d2eb0 == 0` evidence-topic branch.
 - The adjacent `0x383d0`-`0x384e0` range is now confirmed to be another overlapping run of tiny read/write dialogue-state wrappers over globals in the same contiguous block (`0xd2f00` through `0xd2f28`).
   - `get_set_sergeant_completed_first_task_state` at `0x384e0` is the Sergeant-local bit set on the `SCRATCHED_TUCKER` branch immediately before dialogue `0x420d` ("So, you have completed your first task.").
diff --git a/TRACKER.md b/TRACKER.md
index da38dc14628..24921814824 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,17 +10,17 @@
 
 - Program: `HARVEST.LE`
 - Total functions: `908`
-- Named/documented: `589`
-- Still `FUN_*` / undocumented: `319`
+- Named/documented: `590`
+- Still `FUN_*` / undocumented: `318`
 
 ## Last Confirmed Action
 
-- Re-audited `handle_talk_to_jimmy @ 0x28880` against the corrected native flow and fixed the short-circuit branches that the earlier engine port had collapsed together.
-  - Jimmy's first no-item visit, first `NEWSPAPER` handoff, and `SNEAKERS` item branch now return on the native single-line outcomes instead of falling through into later bark logic.
-  - The native notes now record Jimmy's compact branch layout, and the next highest-value corrective pass is the still-incomplete Boyle handler.
+- Rebuilt `handle_talk_to_boyle @ 0x2cb20` from the corrected native flow and synchronized the recovered shared state wrapper rename `get_set_shared_dialogue_state_d2ebc @ 0x382d0`.
+  - Boyle's engine port now covers the native intro response-menu chain, keyword-buffer seeding, one-shot no-item followups, the hidden `Arsonist` alias in the keyword loop, and the full `BOYLES_BUTTON` / blackmail-evidence / `GASCAN` / photo item logic.
+  - The next highest-value corrective pass is the adjacent Pottsdam branch chain in `handle_talk_to_mrs_potts`, which is already tied to the recovered Tuesday-night alibi wrapper.
 
 ## Next Suggested Action
 
 1. Audit the remaining `handle_talk_to_*` ports against the corrected native disassembly.
-   - The highest-value immediate follow-up is `handle_talk_to_boyle`, then the other remaining short handlers that still hide native menu/event logic behind mistyped `play_dialogue_line` calls or orphan `run_dialogue_response_menu` / `run_dialogue_keyword_menu` blocks.
+   - The highest-value immediate follow-up is `handle_talk_to_mrs_potts`, then the other remaining handlers that still hide native menu/event logic behind mistyped `play_dialogue_line` calls or orphan `run_dialogue_response_menu` / `run_dialogue_keyword_menu` blocks.
 2. Apply any additional high-confidence Ghidra renames or comments that fall directly out of those audited handlers before each commit.
diff --git a/engines/harvester/npc/boyle_dialogue.cpp b/engines/harvester/npc/boyle_dialogue.cpp
index 5ceb02285eb..cb21829c8df 100644
--- a/engines/harvester/npc/boyle_dialogue.cpp
+++ b/engines/harvester/npc/boyle_dialogue.cpp
@@ -23,11 +23,83 @@
 
 #include "harvester/npc/boyle_dialogue.h"
 
+#include "common/array.h"
 #include "harvester/npc/dialogue_flags.h"
 #include "harvester/npc/dialogue_runtime.h"
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kBoyleArsonistTopic = "Arsonist";
+static const char *const kDialogueC019BFstPath = "GRAPHIC/FST/C019B.FST";
+
+static const DialogueLineEntry kBoyleHaveLodgeAppEvidenceLines[] = {
+	{ 0x13d, "BOYLE", 2 },
+	{ 0x141, "BOYLE", 0 },
+	{ 0x145, "BOYLE", 2 },
+	{ 0x14a, "BOYLE", 0 },
+	{ 0x14e, "BOYLE", 3 }
+};
+
+static const DialogueLineEntry kBoyleButtonResponse2Lines[] = {
+	{ 0x174, "BOYLE", 0 },
+	{ 0x179, "BOYLE", 0 },
+	{ 0x17d, "BOYLE", 0 }
+};
+
+static const DialogueLineEntry kBoyleNoteResponse1Lines[] = {
+	{ 0x1a1, "BOYLE", 2 },
+	{ 0x1a9, "BOYLE", 0 },
+	{ 0x1ad, "BOYLE", 0 },
+	{ 0x186, "BOYLE", 0 },
+	{ 0x1b8, "BOYLE", 0 },
+	{ 0x1d2, "BOYLE", 0 }
+};
+
+static const DialogueLineEntry kBoyleGascanRewardLines[] = {
+	{ 0x1f2, "BOYLE", 3 },
+	{ 0x1f6, "BOYLE", 2 },
+	{ 0x1fa, "BOYLE", 3 },
+	{ 0x202, "BOYLE", 2 },
+	{ 0x206, "BOYLE", 2 }
+};
+
+static const DialogueLineEntry kBoyleKeywordTopic0eLines[] = {
+	{ 0x8c, "BOYLE", 0 },
+	{ 0x90, "BOYLE", 0 },
+	{ 0x94, "BOYLE", 0 },
+	{ 0x98, "BOYLE", 0 },
+	{ 0x9d, "BOYLE", 0 },
+	{ 0xa1, "BOYLE", 1 }
+};
+
+static const DialogueLineEntry kBoyleKeywordTopic17Lines[] = {
+	{ 0xee, "BOYLE", 0 },
+	{ 0xf2, "BOYLE", 2 },
+	{ 0xf6, "BOYLE", 4 },
+	{ 0xfa, "BOYLE", 0 }
+};
+
+static const DialogueLineEntry kBoyleKeywordArsonResponse1Lines[] = {
+	{ 0x126, "BOYLE", 0 },
+	{ 0x12c, "BOYLE", 0 },
+	{ 0x130, "BOYLE", 2 }
+};
+
+static const DialogueLineEntry kBoyleFollowupBurnedTvLines[] = {
+	{ 0x28a, "BOYLE", 1 },
+	{ 0x28e, "BOYLE", 0 },
+	{ 0x292, "BOYLE", 1 }
+};
+
+static const int kBoyleKeywordTopic0fTo12Lines[] = { 0xf, 0x10, 0x11, 0x12 };
+static const int kBoyleKeywordTopic13To14Lines[] = { 0x13, 0x14 };
+static const int kBoyleKeywordTopic15To16Lines[] = { 0x15, 0x16 };
+static const int kBoyleKeywordTopic18To1bLines[] = { 0x18, 0x19, 0x1a, 0x1b };
+
+} // End of namespace
+
 bool BoyleDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("BOYLE");
 }
@@ -35,60 +107,412 @@ bool BoyleDialogueHandler::matchesNpc(const Common::String &npcName) const {
 Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	BoyleRoomDialogueState &state = _state;
-	auto playBoyleLine = [&](int wavId) -> Common::Error {
-		return runtime.playDialogueLine(wavId, "BOYLE");
+	Common::String boyleTopicBuffer;
+	int boyleTopicBufferLineIndex = -1;
+
+	auto assignBoyleTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(boyleTopicBuffer, boyleTopicBufferLineIndex,
+			responseLineIndex, "Boyle topic buffer");
+	};
+	auto clearBoyleTopicBuffer = [&]() {
+		boyleTopicBuffer.clear();
+		boyleTopicBufferLineIndex = -1;
+	};
+	auto playBoyleLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "BOYLE", headVariant);
+	};
+	auto playSequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
+		return runtime.playDialogueEntrySequence(lines, count);
 	};
+	auto hasInventoryItem = [&](const char *objectName) {
+		Common::Array<StartupObjectRecord> inventoryObjects;
+		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
+		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
+				return true;
+		}
+		return false;
+	};
+
+	Common::Error lineError = Common::kNoError;
+	const bool hasBoylesButton = hasInventoryItem("BOYLES_BUTTON");
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("BOYLES_BUTTON")) {
+			lineError = playBoyleLine(0x155, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playBoyleLine(0x15a, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playBoyleLine(0x15f, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x2, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
 
-	if (usedItemName.empty()) {
-		if (state.introPending) {
-			state.introPending = false;
-			return playBoyleLine(0x7);
+			if (responseIndex == 1) {
+				lineError = playBoyleLine(0x16d, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				return playBoyleLine(0x100, 1);
+			}
+			if (responseIndex == 2) {
+				lineError = playSequence(kBoyleButtonResponse2Lines,
+					ARRAYSIZE(kBoyleButtonResponse2Lines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				lineError = sharedState.boyleGascanApplicationState
+					? playBoyleLine(0x186, 0)
+					: playBoyleLine(0x1d2, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				if (sharedState.boyleGascanApplicationState) {
+					lineError = playBoyleLine(0x18b, 0);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				}
+
+				(void)runtime.startupScript().resetRuntimeObjectToInitialState("BOYLES_BUTTON");
+				sharedState.boyleGascanApplicationState = true;
+				sharedState.dialogueStateD2e98 = true;
+				sharedState.dialogueStateD2eb0 = true;
+				(void)runtime.startupScript().setRuntimeFlagValue("SHERIFF_IN_DINER", true);
+			}
+			return Common::kNoError;
 		}
-		return playBoyleLine(0x23c);
+
+		if (usedItemName.equalsIgnoreCase("NOTE") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				DialogueFlags::kShownEvidenceOfBlackmail, true);
+			if (runtime.startupScript().getFlagValue("HAVE_LODGE_APP"))
+				return playSequence(kBoyleHaveLodgeAppEvidenceLines,
+					ARRAYSIZE(kBoyleHaveLodgeAppEvidenceLines));
+
+			lineError = playBoyleLine(0x197, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x3, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				sharedState.boyleGascanApplicationState = true;
+				return playSequence(kBoyleNoteResponse1Lines,
+					ARRAYSIZE(kBoyleNoteResponse1Lines));
+			}
+			if (responseIndex == 2) {
+				lineError = playBoyleLine(0x1d9, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				int followupResponseIndex = 0;
+				responseError = runtime.runResponseMenu(0x4, followupResponseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+
+				if (followupResponseIndex == 1)
+					return playBoyleLine(0x1e5, 1);
+				if (followupResponseIndex == 2)
+					return playBoyleLine(0x1ea, 2);
+			}
+			return Common::kNoError;
+		}
+
+		if (usedItemName.equalsIgnoreCase("GASCAN") &&
+				sharedState.boyleGascanApplicationState) {
+			lineError = playSequence(kBoyleGascanRewardLines,
+				ARRAYSIZE(kBoyleGascanRewardLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			sharedState.boyleGascanApplicationState = false;
+			(void)runtime.startupScript().resetRuntimeObjectToInitialState("GASCAN");
+			(void)runtime.startupScript().setRuntimeFlagValue("SHERIFF_IN_DINER", true);
+			(void)runtime.startupScript().addRuntimeObjectToInventory("LODGE_APPLICATION");
+			(void)runtime.startupScript().setRuntimeFlagValue("HAVE_LODGE_APP", true);
+			return Common::kNoError;
+		}
+
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			return playBoyleLine(0x25f, 2);
+		}
+
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				DialogueFlags::kShownPhotoOfCorpse, true);
+			lineError = playBoyleLine(0x24f, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			if (sharedState.dialogueStateD2ebc) {
+				lineError = playBoyleLine(0x253, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			return playBoyleLine(0x258, 2);
+		}
+
+		return playBoyleLine(0x249, 0);
 	}
 
-	if (usedItemName.equalsIgnoreCase("BOYLES_BUTTON"))
-		return playBoyleLine(0x155);
+	if (state.introPending) {
+		state.introPending = false;
 
-	if (usedItemName.equalsIgnoreCase("NOTE") ||
-			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
-			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
-			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(
-			DialogueFlags::kShownEvidenceOfBlackmail, true);
-		return playBoyleLine(runtime.startupScript().getFlagValue("HAVE_LODGE_APP") ? 0x13d : 0x197);
+		lineError = playBoyleLine(0x7, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playBoyleLine(0xb, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playBoyleLine(0xf, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x5, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playBoyleLine(0x19, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playBoyleLine(0x1d, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playBoyleLine(0x21, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int followupResponseIndex = 0;
+			responseError = runtime.runResponseMenu(0x6, followupResponseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (followupResponseIndex == 1) {
+				lineError = playBoyleLine(0x2f, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else if (followupResponseIndex == 2) {
+				lineError = playBoyleLine(0x34, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			lineError = playBoyleLine(0x3a, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playBoyleLine(0x41, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playBoyleLine(0x45, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			lineError = playBoyleLine(0x4d, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int followupResponseIndex = 0;
+			responseError = runtime.runResponseMenu(0x7, followupResponseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (followupResponseIndex == 1) {
+				lineError = playBoyleLine(0x59, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				int nestedResponseIndex = 0;
+				responseError = runtime.runResponseMenu(0x8, nestedResponseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+
+				if (nestedResponseIndex == 1) {
+					lineError = playBoyleLine(0x68, 2);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				} else if (nestedResponseIndex == 2) {
+					lineError = playBoyleLine(0x6c, 2);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				}
+			} else if (followupResponseIndex == 2) {
+				sharedState.dialogueStateD2ebc = true;
+				lineError = playBoyleLine(0x76, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playBoyleLine(0x77, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			lineError = playBoyleLine(0x80, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignBoyleTopicBuffer(0x9);
+		}
+
+		if (hasBoylesButton)
+			assignBoyleTopicBuffer(0xa);
+	} else {
+		lineError = playBoyleLine(0x23c, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (hasBoylesButton)
+			assignBoyleTopicBuffer(0xb);
 	}
 
-	if (usedItemName.equalsIgnoreCase("GASCAN") && sharedState.boyleGascanApplicationState) {
-		const DialogueLineEntry gascanLines[] = {
-			{ 0x1f2, "BOYLE", 0 },
-			{ 0x1f6, "BOYLE", 0 },
-			{ 0x1fa, "BOYLE", 0 },
-			{ 0x206, "BOYLE", 0 }
-		};
-		Common::Error lineError = runtime.playDialogueEntrySequence(gascanLines, ARRAYSIZE(gascanLines));
+	if ((runtime.startupScript().getFlagValue(DialogueFlags::kShownEvidenceOfBlackmail) ||
+				sharedState.dwayneDiscussedBoylesButton) &&
+			!state.evidenceFollowupShown) {
+		state.evidenceFollowupShown = true;
+		lineError = playBoyleLine(0x210, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (runtime.startupScript().getFlagValue(DialogueFlags::kShownEvidenceOfBlackmail)) {
+			lineError = playBoyleLine(0x21a, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (sharedState.dwayneDiscussedBoylesButton) {
+			lineError = playBoyleLine(0x215, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		if (!runtime.startupScript().getFlagValue("HAVE_LODGE_APP")) {
+			lineError = playBoyleLine(0x21f, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0xc, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = playBoyleLine(0x22a, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else if (responseIndex == 2) {
+				lineError = playBoyleLine(0x22e, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+		} else {
+			lineError = playBoyleLine(0x233, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		clearBoyleTopicBuffer();
+	}
+
+	if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
+			!state.stephMidgameShown) {
+		state.stephMidgameShown = true;
+		lineError = playBoyleLine(0x267, 0);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		sharedState.boyleGascanApplicationState = false;
-		(void)runtime.startupScript().resetRuntimeObjectToInitialState("GASCAN");
-		(void)runtime.startupScript().addRuntimeObjectToInventory("LODGE_APPLICATION");
-		(void)runtime.startupScript().setRuntimeFlagValue("HAVE_LODGE_APP", true);
-		return Common::kNoError;
 	}
 
-	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(
-			DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-		return playBoyleLine(0x25f);
+	if (runtime.startupScript().getFlagValue("DAY_5") && !state.dayFiveShown) {
+		state.dayFiveShown = true;
+		lineError = playBoyleLine(0x26e, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 
-	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
-		return playBoyleLine(0x24f);
+	if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
+			!state.burnedTvStationShown) {
+		state.burnedTvStationShown = true;
+		lineError = playSequence(kBoyleFollowupBurnedTvLines,
+			ARRAYSIZE(kBoyleFollowupBurnedTvLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 
-	return playBoyleLine(0x249);
+	Common::String selectedTopic;
+	for (;;) {
+		Common::Error menuError = runtime.runKeywordMenu(
+			boyleTopicBuffer, boyleTopicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+
+		if (runtime.matchesResponseLine(selectedTopic, 0xd) ||
+				runtime.matchesResponseLine(selectedTopic, 0x20)) {
+			return playBoyleLine(0x100, 1);
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0xe)) {
+			lineError = playSequence(kBoyleKeywordTopic0eLines,
+				ARRAYSIZE(kBoyleKeywordTopic0eLines));
+		} else if (runtime.matchesAnyResponseLine(selectedTopic, kBoyleKeywordTopic0fTo12Lines,
+				ARRAYSIZE(kBoyleKeywordTopic0fTo12Lines))) {
+			lineError = playBoyleLine(0xab, 0);
+		} else if (runtime.matchesAnyResponseLine(selectedTopic, kBoyleKeywordTopic13To14Lines,
+				ARRAYSIZE(kBoyleKeywordTopic13To14Lines))) {
+			lineError = playBoyleLine(0xb6, 3);
+		} else if (runtime.matchesAnyResponseLine(selectedTopic, kBoyleKeywordTopic15To16Lines,
+				ARRAYSIZE(kBoyleKeywordTopic15To16Lines))) {
+			lineError = runtime.startupScript().getCurrentStoryDayIndex() >= 3
+				? playBoyleLine(0xc6, 0)
+				: playBoyleLine(0x243, 0);
+		} else if (runtime.matchesResponseLine(selectedTopic, 0x17)) {
+			lineError = playSequence(kBoyleKeywordTopic17Lines,
+				ARRAYSIZE(kBoyleKeywordTopic17Lines));
+		} else if (runtime.matchesAnyResponseLine(selectedTopic, kBoyleKeywordTopic18To1bLines,
+				ARRAYSIZE(kBoyleKeywordTopic18To1bLines))) {
+			if (!sharedState.dialogueStateD2eb0) {
+				lineError = runtime.playDialogueFst(kDialogueC019BFstPath);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playBoyleLine(0x10e, 0);
+			} else {
+				lineError = playBoyleLine(0x115, 1);
+			}
+		} else if (runtime.matchesResponseLine(selectedTopic, 0x1d) ||
+				selectedTopic.equalsIgnoreCase(kBoyleArsonistTopic)) {
+			lineError = playBoyleLine(0x11c, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x1e, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = playSequence(kBoyleKeywordArsonResponse1Lines,
+					ARRAYSIZE(kBoyleKeywordArsonResponse1Lines));
+			} else if (responseIndex == 2) {
+				lineError = playBoyleLine(0x135, 0);
+			} else {
+				lineError = Common::kNoError;
+			}
+		} else if (runtime.matchesResponseLine(selectedTopic, 0x1f)) {
+			lineError = playSequence(kBoyleHaveLodgeAppEvidenceLines,
+				ARRAYSIZE(kBoyleHaveLodgeAppEvidenceLines));
+		} else {
+			lineError = playBoyleLine(0x243, 0);
+		}
+
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/boyle_dialogue.h b/engines/harvester/npc/boyle_dialogue.h
index 0de4053bdfa..d299b6bb105 100644
--- a/engines/harvester/npc/boyle_dialogue.h
+++ b/engines/harvester/npc/boyle_dialogue.h
@@ -30,6 +30,10 @@ class BoyleDialogueHandler : public NpcDialogueHandler {
 public:
 	struct BoyleRoomDialogueState {
 		bool introPending = true;
+		bool evidenceFollowupShown = false;
+		bool stephMidgameShown = false;
+		bool dayFiveShown = false;
+		bool burnedTvStationShown = false;
 	};
 
 	bool matchesNpc(const Common::String &npcName) const override;
diff --git a/engines/harvester/npc/dialogue_handler.h b/engines/harvester/npc/dialogue_handler.h
index b50e843685c..903829d47b8 100644
--- a/engines/harvester/npc/dialogue_handler.h
+++ b/engines/harvester/npc/dialogue_handler.h
@@ -33,6 +33,7 @@ struct DialogueSharedState {
 	bool boyleGascanApplicationState = false;
 	bool dialogueStateD2e98 = false;
 	bool dialogueStateD2eb0 = false;
+	bool dialogueStateD2ebc = false;
 	bool dialogueStateD2eec = false;
 	bool dialogueStateD2ed0 = false;
 	bool dialogueStateD2ef4 = false;
@@ -46,6 +47,7 @@ struct DialogueSharedState {
 	bool waspWomanDialogueState = false;
 	bool momGoodCauseDay5State = false;
 	bool dwayneWhaleyDisciplineFollowupState = false;
+	bool dwayneDiscussedBoylesButton = false;
 	bool discussedMrPottsTuesdayNightAlibi = false;
 	bool confrontedMrPottsAboutSpyhole = false;
 };
diff --git a/engines/harvester/npc/dwayne_dialogue.cpp b/engines/harvester/npc/dwayne_dialogue.cpp
index c2d4c911e63..db4ae947c4d 100644
--- a/engines/harvester/npc/dwayne_dialogue.cpp
+++ b/engines/harvester/npc/dwayne_dialogue.cpp
@@ -134,6 +134,7 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("BOYLES_BUTTON")) {
 			state.discussedBoylesButton = true;
+			sharedState.dwayneDiscussedBoylesButton = true;
 			return playDwayneLine(0x339b);
 		}
 		if (usedItemName.equalsIgnoreCase("K_PURSE")) {


Commit: 60588d1f1d047e8cb8cb9c98bcbf8f55a85aa33d
    https://github.com/scummvm/scummvm/commit/60588d1f1d047e8cb8cb9c98bcbf8f55a85aa33d
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:14+02:00

Commit Message:
HARVESTER: Rebuild mrs potts dialogue from native flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/dialogue_handler.h
    engines/harvester/npc/mrs_potts_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index c17602e098a..527a49ae189 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -481,6 +481,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `get_set_shared_dialogue_state_d2e98` at `0x38230` is a neutral wrapper over `g_shared_dialogue_state_d2e98`; the only currently recovered gameplay caller is Herrill's `GASCAN` branch at `0x2e056`, where `eax = 1` / `edx = 0` makes it a read-only gate and a zero value collapses the post-`0x30d8` continuation to `PC 0x3123`. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path and save/load serialization.
   - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
   - `get_set_shared_dialogue_state_d2eb0` at `0x382a0` is a neutral wrapper over `g_shared_dialogue_state_d2eb0`; the only currently recovered gameplay caller is a hidden Boyle dialogue branch at `0x2d6e3`, where `eax = 1` / `edx = 0` makes it a read-only gate between `BOYLE 0x10e` and `BOYLE 0x115` after the local item checks. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path.
+  - `get_set_shared_dialogue_state_d2eb8` at `0x382c0` is another neutral shared wrapper over `g_shared_dialogue_state_d2eb8`; the currently recovered gameplay caller is Mrs. Potts's `STEPHANIE_IS_DEAD` branch, where `eax = 1` / `edx = 0` makes it a read-only gate between the opening `0x283b` response menu and the later room-specific dead-Stephanie branches. A nearby cutscene helper at `0x38930` also writes that same bit while setting `STEPH_MIDGAME_PLAYED`, but the full gameplay-side semantics of the shared state are still not bounded tightly enough for a stronger name.
   - `get_set_shared_dialogue_state_d2ebc` at `0x382d0` is another neutral shared wrapper over `g_shared_dialogue_state_d2ebc`; the currently recovered gameplay caller is Boyle's `CASKET_PHOTO` / `CASKET_PHOTOCOPY` branch, where `eax = 1` / `edx = 0` makes it a read-only gate for the optional `0x253` line between `0x24f` and `0x258`. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path and save/load serialization.
   - `get_set_discussed_note_checkbook_evidence` at `0x38330` gates the shared `NOTE` / `NOTE_PHOTOCOPY` / `CHECKBOOK` / `CHECKBOOK_PHOTOCOPY` evidence path used by Dwayne, Edna, Herrill, Johnson, Mom, and related handlers.
   - `get_set_discussed_tv_deed_evidence` at `0x38340` gates the `TV_DEED` / `TV_DEED_PHOTOCOPY` evidence path used by Edna, Herrill, Johnson, and Mom.
@@ -507,6 +508,12 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `get_set_discussed_lodge_topic` at `0x384c0` is the shared Lodge-topic flag set from the `dialog.rsp` keyword lines used by Dwayne, Hank, and Mom.
   - `get_set_dwayne_whaley_discipline_followup_state` at `0x38500` is a Dwayne-local follow-up state bit read just before the `0x34b3` Miss Whaley school-discipline exchange and its immediate `0x79` response menu.
   - Each helper is a tiny read/write shim over a dedicated global (`g_discussed_note_checkbook_evidence`, `g_discussed_tv_deed_evidence`, `g_discussed_ledger_evidence`, `g_discussed_casket_photo_evidence`, `g_discussed_whaley_herrill_photo`, `g_discussed_karin_purse`, `g_discussed_lodge_topic`).
+- `handle_talk_to_mrs_potts @ 0x2ee70` is now bounded enough to rebuild from native flow.
+  - The non-dead intro branch clears `g_mrs_potts_talk_aux_state_block`, sets the return-visit followup bit, opens zero-based `dialog.rsp` line `0x231`, and seeds keyword buffers `0x232` or `0x233` depending on the response path. Later no-item visits either use the `0x2800` response menu gate or the direct permission branch through `PERMISSION_TO_SEE_STEPHANIE` and `STEF_HALL_EXIT`.
+  - The non-dead event followups are one-shot gates rather than terminal barks: `DAY_5` -> `0x2889`, `BURNED_TV_STATION` -> `0x2891`, `KARIN_KIDNAPED` -> `0x28dc` plus response menu `0x235`, and `DINER_BURNED` -> `0x290e`. After those gates, the keyword loop matches `0x237` through `0x24a`, with the `0x247` / `0x248` Lodge topics explicitly setting `get_set_discussed_lodge_topic`.
+  - The `STEPHANIE_IS_DEAD` path is split three ways: `d2eb8 == 0` opens response menu `0x230` after `0x283b`; otherwise Mrs. Potts branches on room `STKITCHN`, then the `GOT_REMAINS_FOR_LODGE` and `TAKEN_INVITE_TO_SERGEANT` one-shots drive response menus `0x22d` and `0x22e`.
+  - Both the direct `REMAINS` item branch and the `GOT_REMAINS_FOR_LODGE` response-menu branch converge on the same death sequence: `0x28b7` / `0x28bd` / `0x28c1`, a black-screen clear, `play_c040_fst`, then `dispatch_action_tag_if_set("DIE_IN_CHAIR")`.
+- `play_c040_fst` at `0x38920` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C040.FST", 0)` and is currently only recovered from Mrs. Potts's death-in-chair branch.
 - Boyle's hidden `GASCAN` return branch is now explicit through `get_set_boyle_gascan_application_state` at `0x38240`.
   - Boyle's no-item intro is also fully bounded now. When the local intro bit is set, he clears it, plays the fixed `0x7` / `0xb` / `0xf` opener, then drives response menus `0x5`, `0x6`, `0x7`, and `0x8` before seeding keyword buffers `0x9` or `0xa`; steady revisits collapse to `0x23c` and optionally seed `0xb` when `BOYLES_BUTTON` is already in inventory.
   - The post-intro no-item followups are one-shot gates rather than a single bark chain: the shared blackmail/button-evidence block plays `0x210` plus the optional `0x21a` / `0x215` inserts, branches through response menu `0xc` when `HAVE_LODGE_APP` is still clear, and then clears the active keyword buffer before the later `STEPH_MIDGAME_PLAYED`, `DAY_5`, and `BURNED_TV_STATION` one-shots.
diff --git a/TRACKER.md b/TRACKER.md
index 24921814824..888bf6b9b3d 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -9,18 +9,18 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `908`
-- Named/documented: `590`
+- Total functions: `910`
+- Named/documented: `592`
 - Still `FUN_*` / undocumented: `318`
 
 ## Last Confirmed Action
 
-- Rebuilt `handle_talk_to_boyle @ 0x2cb20` from the corrected native flow and synchronized the recovered shared state wrapper rename `get_set_shared_dialogue_state_d2ebc @ 0x382d0`.
-  - Boyle's engine port now covers the native intro response-menu chain, keyword-buffer seeding, one-shot no-item followups, the hidden `Arsonist` alias in the keyword loop, and the full `BOYLES_BUTTON` / blackmail-evidence / `GASCAN` / photo item logic.
-  - The next highest-value corrective pass is the adjacent Pottsdam branch chain in `handle_talk_to_mrs_potts`, which is already tied to the recovered Tuesday-night alibi wrapper.
+- Rebuilt `handle_talk_to_mrs_potts @ 0x2ee70` from the corrected native flow and turned the orphan helpers at `0x382c0` and `0x38920` into named functions: `get_set_shared_dialogue_state_d2eb8` and `play_c040_fst`.
+  - Mrs. Potts now preserves the native intro response menu, the room-specific dead-Stephanie branches, the `REMAINS` death sequence through `C040.FST` and `DIE_IN_CHAIR`, the `KARIN_KIDNAPED` response menu, and the hidden keyword loop over `dialog.rsp[0x237..0x24a]`.
+  - The next highest-value corrective pass is `handle_talk_to_phelps`, whose native handler still contains response menus, multi-line evidence branches, and a large no-item one-shot chain that the current engine port flattens away.
 
 ## Next Suggested Action
 
 1. Audit the remaining `handle_talk_to_*` ports against the corrected native disassembly.
-   - The highest-value immediate follow-up is `handle_talk_to_mrs_potts`, then the other remaining handlers that still hide native menu/event logic behind mistyped `play_dialogue_line` calls or orphan `run_dialogue_response_menu` / `run_dialogue_keyword_menu` blocks.
+   - The highest-value immediate follow-up is `handle_talk_to_phelps`, then the other remaining handlers that still hide native menu/event logic behind mistyped `play_dialogue_line` calls or orphan `run_dialogue_response_menu` / `run_dialogue_keyword_menu` blocks.
 2. Apply any additional high-confidence Ghidra renames or comments that fall directly out of those audited handlers before each commit.
diff --git a/engines/harvester/npc/dialogue_handler.h b/engines/harvester/npc/dialogue_handler.h
index 903829d47b8..93f15c9bb5b 100644
--- a/engines/harvester/npc/dialogue_handler.h
+++ b/engines/harvester/npc/dialogue_handler.h
@@ -33,6 +33,7 @@ struct DialogueSharedState {
 	bool boyleGascanApplicationState = false;
 	bool dialogueStateD2e98 = false;
 	bool dialogueStateD2eb0 = false;
+	bool dialogueStateD2eb8 = false;
 	bool dialogueStateD2ebc = false;
 	bool dialogueStateD2eec = false;
 	bool dialogueStateD2ed0 = false;
diff --git a/engines/harvester/npc/mrs_potts_dialogue.cpp b/engines/harvester/npc/mrs_potts_dialogue.cpp
index 56e47bd28da..8b8650df8c3 100644
--- a/engines/harvester/npc/mrs_potts_dialogue.cpp
+++ b/engines/harvester/npc/mrs_potts_dialogue.cpp
@@ -25,9 +25,73 @@
 
 #include "harvester/npc/dialogue_flags.h"
 #include "harvester/npc/dialogue_runtime.h"
+#include "harvester/harvester.h"
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kDialogueC040FstPath = "GRAPHIC/FST/C040.FST";
+static const char *const kDieInChairActionTag = "DIE_IN_CHAIR";
+static const char *const kStephHallExitActionTag = "STEF_HALL_EXIT";
+static const char *const kPermissionToSeeStephanieFlag = "PERMISSION_TO_SEE_STEPHANIE";
+
+static const DialogueLineEntry kMrsPottsWhaleyLines[] = {
+	{ 0x293c, "MRS_POTTS", 0 },
+	{ 0x2940, "PC", 0 },
+	{ 0x2945, "MRS_POTTS", 0 }
+};
+
+static const DialogueLineEntry kMrsPottsBlackmailLines[] = {
+	{ 0x2954, "MRS_POTTS", 0 },
+	{ 0x2959, "PC", 0 },
+	{ 0x295e, "MRS_POTTS", 0 },
+	{ 0x2962, "PC", 0 },
+	{ 0x2966, "MRS_POTTS", 0 }
+};
+
+static const DialogueLineEntry kMrsPottsTvDeedLines[] = {
+	{ 0x296e, "MRS_POTTS", 0 },
+	{ 0x2973, "PC", 0 },
+	{ 0x2977, "MRS_POTTS", 0 }
+};
+
+static const DialogueLineEntry kMrsPottsIntroResponse1Lines[] = {
+	{ 0x2730, "MRS_POTTS", 0 },
+	{ 0x2735, "PC", 0 },
+	{ 0x273a, "MRS_POTTS", 0 }
+};
+
+static const DialogueLineEntry kMrsPottsIntroResponse2Lines[] = {
+	{ 0x2744, "MRS_POTTS", 0 },
+	{ 0x2748, "PC", 0 },
+	{ 0x274c, "MRS_POTTS", 0 }
+};
+
+static const DialogueLineEntry kMrsPottsGotRemainsIntroLines[] = {
+	{ 0x289e, "MRS_POTTS", 0 },
+	{ 0x28a8, "MRS_POTTS", 0 },
+	{ 0x28ad, "MRS_POTTS", 0 }
+};
+
+static const DialogueLineEntry kMrsPottsRemainsDeathLines[] = {
+	{ 0x28b7, "MRS_POTTS", 0 },
+	{ 0x28bd, "PC", 0 },
+	{ 0x28c1, "MRS_POTTS", 0 }
+};
+
+static const DialogueLineEntry kMrsPottsKarinKidnapedResponse1Lines[] = {
+	{ 0x28e8, "MRS_POTTS", 0 },
+	{ 0x28ee, "PC", 0 },
+	{ 0x28f2, "MRS_POTTS", 0 }
+};
+
+static const int kMrsPottsTopic27d6ResponseLines[] = { 0x241, 0x242 };
+static const int kMrsPottsTopic27e3ResponseLines[] = { 0x244, 0x245 };
+static const int kMrsPottsLodgeTopicResponseLines[] = { 0x247, 0x248 };
+
+} // End of namespace
+
 bool MrsPottsDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("MRS_POTTS") ||
 		npcName.equalsIgnoreCase("MRS_POTTS_ST_BEDRM");
@@ -36,15 +100,55 @@ bool MrsPottsDialogueHandler::matchesNpc(const Common::String &npcName) const {
 Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	MrsPottsRoomDialogueState &state = _state;
-	auto playMrsPottsLine = [&](int wavId, const char *speakerId = "MRS_POTTS") -> Common::Error {
-		return runtime.playDialogueLine(wavId, speakerId);
+	Common::String mrsPottsTopicBuffer;
+	int mrsPottsTopicBufferLineIndex = -1;
+
+	auto assignMrsPottsTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(mrsPottsTopicBuffer, mrsPottsTopicBufferLineIndex,
+			responseLineIndex, "Mrs Potts topic buffer");
+	};
+	auto playMrsPottsLine = [&](int wavId, int headVariant = 0,
+			const char *speakerId = "MRS_POTTS") -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, speakerId, headVariant);
+	};
+	auto playSequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
+		return runtime.playDialogueEntrySequence(lines, count);
+	};
+	auto executeDialogueActionTag = [&](const char *tag) {
+		StartupInteractionResult interaction;
+		if (runtime.startupScript().executeActionTag(tag, interaction)) {
+			runtime.applyImmediateDialogueInteractionEffects(interaction);
+			runtime.queueDialogueInteractionIfNeeded(interaction);
+		}
+	};
+	auto clearScreenToBlack = [&]() {
+		Graphics::Screen *screen = runtime.engine().getScreen();
+		if (!screen)
+			return;
+
+		screen->fillRect(screen->getBounds(), 0);
+		screen->update();
+	};
+	auto playRemainsDeathSequence = [&]() -> Common::Error {
+		Common::Error lineError = playSequence(
+			kMrsPottsRemainsDeathLines, ARRAYSIZE(kMrsPottsRemainsDeathLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		clearScreenToBlack();
+		lineError = runtime.playDialogueFst(kDialogueC040FstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		executeDialogueActionTag(kDieInChairActionTag);
+		return Common::kNoError;
 	};
 
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(
 				DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-			return playMrsPottsLine(0x293c);
+			return playSequence(kMrsPottsWhaleyLines, ARRAYSIZE(kMrsPottsWhaleyLines));
 		}
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
@@ -63,46 +167,92 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(
 				DialogueFlags::kShownEvidenceOfBlackmail, true);
-			return playMrsPottsLine(0x2954);
+			return playSequence(kMrsPottsBlackmailLines, ARRAYSIZE(kMrsPottsBlackmailLines));
 		}
 		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
 				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(
 				DialogueFlags::kShownEvidenceSheriffOwns, true);
-			return playMrsPottsLine(0x296e);
+			return playSequence(kMrsPottsTvDeedLines, ARRAYSIZE(kMrsPottsTvDeedLines));
 		}
 		if (usedItemName.equalsIgnoreCase("REMAINS"))
-			return playMrsPottsLine(0x28b7);
+			return playRemainsDeathSequence();
 
 		return playMrsPottsLine(0x2936);
 	}
 
 	if (runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD")) {
 		Common::Error lineError = Common::kNoError;
-		if (!sharedState.dialogueStateD2ea4) {
-			sharedState.dialogueStateD2ea4 = true;
+		if (!sharedState.dialogueStateD2eb8) {
 			lineError = playMrsPottsLine(0x283b);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x230, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1)
+				return playMrsPottsLine(0x2846);
+			if (responseIndex == 2)
+				return playMrsPottsLine(0x284b);
+			return Common::kNoError;
 		}
+
 		if (!runtime.currentRoomName().equalsIgnoreCase("STKITCHN")) {
 			lineError = playMrsPottsLine(0x2854);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x22f, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1)
+				return playMrsPottsLine(0x285e);
+			if (responseIndex == 2)
+				return playMrsPottsLine(0x2864);
+			return Common::kNoError;
 		}
+
 		if (runtime.startupScript().getFlagValue("GOT_REMAINS_FOR_LODGE") &&
 				!state.gotRemainsForLodgeLinePlayed) {
 			state.gotRemainsForLodgeLinePlayed = true;
-			lineError = playMrsPottsLine(0x289e);
+			lineError = playSequence(
+				kMrsPottsGotRemainsIntroLines, ARRAYSIZE(kMrsPottsGotRemainsIntroLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x22d, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1)
+				return playRemainsDeathSequence();
+			if (responseIndex == 2)
+				return playMrsPottsLine(0x28d6);
+			return Common::kNoError;
 		}
+
 		if (runtime.startupScript().getFlagValue("TAKEN_INVITE_TO_SERGEANT") &&
 				!state.takenInviteToSergeantLinePlayed) {
 			state.takenInviteToSergeantLinePlayed = true;
-			lineError = playMrsPottsLine(0x286b);
+			lineError = playMrsPottsLine(0x286b, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x22e, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1) {
+				lineError = playMrsPottsLine(0x2876);
+			} else if (responseIndex == 2) {
+				lineError = playMrsPottsLine(0x287a);
+			}
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			return playMrsPottsLine(0x2881);
 		}
 
 		return playMrsPottsLine(0x2835);
@@ -111,38 +261,97 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (state.auxIntroPending) {
 		state.auxIntroPending = false;
 		state.returnVisitFollowupPending = true;
-		return playMrsPottsLine(0x2726);
-	}
+		Common::Error lineError = playMrsPottsLine(0x2726);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 
-	if (state.returnVisitFollowupPending) {
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x231, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 1) {
+			lineError = playSequence(
+				kMrsPottsIntroResponse1Lines, ARRAYSIZE(kMrsPottsIntroResponse1Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMrsPottsTopicBuffer(0x232);
+		} else if (responseIndex == 2) {
+			lineError = playSequence(
+				kMrsPottsIntroResponse2Lines, ARRAYSIZE(kMrsPottsIntroResponse2Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMrsPottsTopicBuffer(0x233);
+		}
+	} else if (!state.returnVisitFollowupPending) {
+		const int storyDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
+		if (storyDayIndex > 1 && storyDayIndex < 6) {
+			state.returnVisitFollowupPending = true;
+			Common::Error lineError = playMrsPottsLine(0x281b, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playMrsPottsLine(
+				runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") ? 0x2825 : 0x2820);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else {
+			state.returnVisitFollowupPending = true;
+			Common::Error lineError = playMrsPottsLine(0x282d);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			(void)runtime.startupScript().setRuntimeFlagValue(kPermissionToSeeStephanieFlag, true);
+			executeDialogueActionTag(kStephHallExitActionTag);
+		}
+	} else {
 		state.returnVisitFollowupPending = false;
-		return playMrsPottsLine(0x2800);
-	}
+		Common::Error lineError = playMrsPottsLine(0x2800);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 
-	const int storyDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
-	if (storyDayIndex > 1 && storyDayIndex < 6) {
-		state.returnVisitFollowupPending = true;
-		return playMrsPottsLine(0x281b);
-	}
-
-	if (!runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD")) {
-		state.returnVisitFollowupPending = true;
-		return playMrsPottsLine(0x282d);
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x234, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 1) {
+			lineError = playMrsPottsLine(0x280a);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			(void)runtime.startupScript().setRuntimeFlagValue(kPermissionToSeeStephanieFlag, true);
+			executeDialogueActionTag(kStephHallExitActionTag);
+		} else if (responseIndex == 2) {
+			lineError = playMrsPottsLine(0x2811);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
 	}
 
-	if (storyDayIndex == 5 && !state.day5LinePlayed) {
+	if (runtime.startupScript().getCurrentStoryDayIndex() == 5 && !state.day5LinePlayed) {
 		state.day5LinePlayed = true;
 		return playMrsPottsLine(0x2889);
 	}
 	if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
 			!state.burnedTvStationLinePlayed) {
 		state.burnedTvStationLinePlayed = true;
-		return playMrsPottsLine(0x2891);
+		return playMrsPottsLine(0x2891, 1);
 	}
 	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
 			!state.karinKidnappedLinePlayed) {
 		state.karinKidnappedLinePlayed = true;
-		return playMrsPottsLine(0x28dc);
+		sharedState.karinKidnapedDialogueState = true;
+
+		Common::Error lineError = playMrsPottsLine(0x28dc);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x235, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 1) {
+			return playSequence(kMrsPottsKarinKidnapedResponse1Lines,
+				ARRAYSIZE(kMrsPottsKarinKidnapedResponse1Lines));
+		}
+		if (responseIndex == 2)
+			return playMrsPottsLine(0x28fb);
 	}
 	if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
 			!state.dinerBurnedLinePlayed) {
@@ -150,7 +359,41 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return playMrsPottsLine(0x290e);
 	}
 
-	return playMrsPottsLine(0x2930);
+	Common::String selectedTopic;
+	for (;;) {
+		Common::Error menuError = runtime.runKeywordMenu(
+			mrsPottsTopicBuffer, mrsPottsTopicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
+				runtime.matchesResponseLine(selectedTopic, 0x237)) {
+			return playMrsPottsLine(0x297e);
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x238))
+			return playMrsPottsLine(0x2756);
+		if (runtime.matchesResponseLine(selectedTopic, 0x23a))
+			return playMrsPottsLine(0x2775, 0, "PC");
+		if (runtime.matchesResponseLine(selectedTopic, 0x23c))
+			return playMrsPottsLine(0x278c, 0, "PC");
+		if (runtime.matchesResponseLine(selectedTopic, 0x23e))
+			return playMrsPottsLine(0x27a3);
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMrsPottsTopic27d6ResponseLines,
+				ARRAYSIZE(kMrsPottsTopic27d6ResponseLines))) {
+			return playMrsPottsLine(0x27d6);
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMrsPottsTopic27e3ResponseLines,
+				ARRAYSIZE(kMrsPottsTopic27e3ResponseLines))) {
+			return playMrsPottsLine(0x27e3);
+		}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMrsPottsLodgeTopicResponseLines,
+				ARRAYSIZE(kMrsPottsLodgeTopicResponseLines))) {
+			sharedState.discussedLodgeTopic = true;
+			return playMrsPottsLine(0x27ef, 0, "PC");
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x24a))
+			continue;
+		return playMrsPottsLine(0x2930);
+	}
 }
 
 } // End of namespace Harvester


Commit: 63757377a63d794d44e10b2b562ed66f4fd09019
    https://github.com/scummvm/scummvm/commit/63757377a63d794d44e10b2b562ed66f4fd09019
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:14+02:00

Commit Message:
HARVESTER: Rebuild phelps dialogue from native flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/phelps_dialogue.cpp
    engines/harvester/npc/phelps_dialogue.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 527a49ae189..07bb0251b3d 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -508,6 +508,11 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `get_set_discussed_lodge_topic` at `0x384c0` is the shared Lodge-topic flag set from the `dialog.rsp` keyword lines used by Dwayne, Hank, and Mom.
   - `get_set_dwayne_whaley_discipline_followup_state` at `0x38500` is a Dwayne-local follow-up state bit read just before the `0x34b3` Miss Whaley school-discipline exchange and its immediate `0x79` response menu.
   - Each helper is a tiny read/write shim over a dedicated global (`g_discussed_note_checkbook_evidence`, `g_discussed_tv_deed_evidence`, `g_discussed_ledger_evidence`, `g_discussed_casket_photo_evidence`, `g_discussed_whaley_herrill_photo`, `g_discussed_karin_purse`, `g_discussed_lodge_topic`).
+- `handle_talk_to_phelps @ 0x29920` is now bounded enough to rebuild from native flow.
+  - The opening no-item branch is not a single bark: Phelps's intro is the six-line `0x1b44` through `0x1b5a` exchange, and it also sets the shared `d2f04` dialogue bit before returning.
+  - Later no-item visits always play `0x1bf4`, then, before day 5, open response menu `0x22a` after `0x1b7b`; response `1` continues with `0x1b85`, while every other response falls through `0x1bbc` and `0x1c44` before the handler continues into the one-shot event chain.
+  - That one-shot chain is linear and ordered: `STEPH_MIDGAME_PLAYED` -> `0x1c4a`, day 5 -> `0x1c56`, `SCRATCHED_TUCKER` -> `0x1c65`, `BARBER_POLE_STOLEN` -> `0x1c6e` / `0x1c73`, `BOLT_OF_CLOTH_TAKEN` -> `0x1c80` with optional `0x1c87`, `DINER_BURNED` -> `0x1c8f`, `PC_ESCAPED_JAIL` -> `0x1c98`, `GOT_REMAINS_FOR_LODGE` while Moynahan is still alive -> `0x1ca0`, `BURNED_TV_STATION` -> `0x1cb4`, then the Butcher, Moynahan, and Jimmy death one-shots before the `KARIN_FOUND_DEAD` / `KARIN_KIDNAPED` endings.
+  - Phelps's item handling also expands beyond the old stub: `PHOTO_OF_WHALEY_HERRILL` is `0x1c0c` / `0x1c13` / `0x1c17`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` is `0x1c1e` / `0x1c22` / `0x1c27` / `0x1c2c` / `0x1c30`, and `QUARTER` dispatches `GET_PRN_MAG`, plays `0x1ba4` / `0x1bac` / `0x1bb0`, then dispatches `DEL_DRT_MAG`.
 - `handle_talk_to_mrs_potts @ 0x2ee70` is now bounded enough to rebuild from native flow.
   - The non-dead intro branch clears `g_mrs_potts_talk_aux_state_block`, sets the return-visit followup bit, opens zero-based `dialog.rsp` line `0x231`, and seeds keyword buffers `0x232` or `0x233` depending on the response path. Later no-item visits either use the `0x2800` response menu gate or the direct permission branch through `PERMISSION_TO_SEE_STEPHANIE` and `STEF_HALL_EXIT`.
   - The non-dead event followups are one-shot gates rather than terminal barks: `DAY_5` -> `0x2889`, `BURNED_TV_STATION` -> `0x2891`, `KARIN_KIDNAPED` -> `0x28dc` plus response menu `0x235`, and `DINER_BURNED` -> `0x290e`. After those gates, the keyword loop matches `0x237` through `0x24a`, with the `0x247` / `0x248` Lodge topics explicitly setting `get_set_discussed_lodge_topic`.
diff --git a/TRACKER.md b/TRACKER.md
index 888bf6b9b3d..0fb24d0da7f 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,12 +15,12 @@
 
 ## Last Confirmed Action
 
-- Rebuilt `handle_talk_to_mrs_potts @ 0x2ee70` from the corrected native flow and turned the orphan helpers at `0x382c0` and `0x38920` into named functions: `get_set_shared_dialogue_state_d2eb8` and `play_c040_fst`.
-  - Mrs. Potts now preserves the native intro response menu, the room-specific dead-Stephanie branches, the `REMAINS` death sequence through `C040.FST` and `DIE_IN_CHAIR`, the `KARIN_KIDNAPED` response menu, and the hidden keyword loop over `dialog.rsp[0x237..0x24a]`.
-  - The next highest-value corrective pass is `handle_talk_to_phelps`, whose native handler still contains response menus, multi-line evidence branches, and a large no-item one-shot chain that the current engine port flattens away.
+- Rebuilt `handle_talk_to_phelps @ 0x29920` from the corrected native flow and updated the native notes for its restored response menus and one-shot chain.
+  - Phelps now preserves the six-line intro, the pre-day-5 response menu at `dialog.rsp[0x22a]`, the multi-line Whaley / casket / quarter item branches, the quarter-side action tags `GET_PRN_MAG` and `DEL_DRT_MAG`, and the ordered late-game one-shot bark chain through the Jimmy, Butcher, Moynahan, and Karin outcomes.
+  - The next highest-value corrective pass is `handle_talk_to_moynahan`, whose native function still hides a much larger keyword/menu block and several item-driven response menus than the current engine port reflects.
 
 ## Next Suggested Action
 
 1. Audit the remaining `handle_talk_to_*` ports against the corrected native disassembly.
-   - The highest-value immediate follow-up is `handle_talk_to_phelps`, then the other remaining handlers that still hide native menu/event logic behind mistyped `play_dialogue_line` calls or orphan `run_dialogue_response_menu` / `run_dialogue_keyword_menu` blocks.
+   - The highest-value immediate follow-up is `handle_talk_to_moynahan`, then the other remaining handlers that still hide native menu/event logic behind mistyped `play_dialogue_line` calls or orphan `run_dialogue_response_menu` / `run_dialogue_keyword_menu` blocks.
 2. Apply any additional high-confidence Ghidra renames or comments that fall directly out of those audited handlers before each commit.
diff --git a/engines/harvester/npc/phelps_dialogue.cpp b/engines/harvester/npc/phelps_dialogue.cpp
index e5344ab93f1..8e12328c83f 100644
--- a/engines/harvester/npc/phelps_dialogue.cpp
+++ b/engines/harvester/npc/phelps_dialogue.cpp
@@ -31,9 +31,53 @@ namespace Harvester {
 namespace {
 
 static const char *const kGetPrnMagActionTag = "GET_PRN_MAG";
+static const char *const kDeleteDirtyMagazineActionTag = "DEL_DRT_MAG";
 static const char *const kUseRewardAtStoreActionTag = "USE_REWARD_AT_STORE";
 static const char *const kSetGenCuStuffActionTag = "SET_GEN_CU_STUFF";
 
+static const DialogueLineEntry kPhelpsWhaleyLines[] = {
+	{ 0x1c0c, "PHELPS", 0 },
+	{ 0x1c13, "PC", 0 },
+	{ 0x1c17, "PHELPS", 0 }
+};
+
+static const DialogueLineEntry kPhelpsCasketLines[] = {
+	{ 0x1c1e, "PHELPS", 0 },
+	{ 0x1c22, "PC", 0 },
+	{ 0x1c27, "PHELPS", 0 },
+	{ 0x1c2c, "PC", 0 },
+	{ 0x1c30, "PHELPS", 0 }
+};
+
+static const DialogueLineEntry kPhelpsQuarterLines[] = {
+	{ 0x1ba4, "PHELPS", 0 },
+	{ 0x1bac, "PC", 0 },
+	{ 0x1bb0, "PHELPS", 0 }
+};
+
+static const DialogueLineEntry kPhelpsIntroLines[] = {
+	{ 0x1b44, "PHELPS", 0 },
+	{ 0x1b48, "PC", 0 },
+	{ 0x1b4c, "PHELPS", 0 },
+	{ 0x1b52, "PC", 0 },
+	{ 0x1b56, "PHELPS", 0 },
+	{ 0x1b5a, "PC", 0 }
+};
+
+static const DialogueLineEntry kPhelpsKarinFoundDeadLines[] = {
+	{ 0x1d2a, "PHELPS", 0 },
+	{ 0x1d33, "PHELPS", 0 },
+	{ 0x1d3a, "PHELPS", 0 }
+};
+
+static const DialogueLineEntry kPhelpsKarinKidnapedLines[] = {
+	{ 0x1d40, "PHELPS", 0 },
+	{ 0x1d46, "PC", 0 },
+	{ 0x1d4a, "PHELPS", 0 },
+	{ 0x1d4f, "PC", 0 },
+	{ 0x1d53, "PHELPS", 0 }
+};
+
 } // End of namespace
 
 bool PhelpsDialogueHandler::matchesNpc(const Common::String &npcName) const {
@@ -41,7 +85,9 @@ bool PhelpsDialogueHandler::matchesNpc(const Common::String &npcName) const {
 }
 
 Common::Error PhelpsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &) {
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	PhelpsRoomDialogueState &state = _state;
+
 	auto executeDialogueActionTag = [&](const char *tag) {
 		StartupInteractionResult interaction;
 		if (runtime.startupScript().executeActionTag(tag, interaction)) {
@@ -49,40 +95,192 @@ Common::Error PhelpsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			runtime.queueDialogueInteractionIfNeeded(interaction);
 		}
 	};
+	auto playPhelpsLine = [&](int wavId, int headVariant = 0,
+			const char *speakerId = "PHELPS") -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, speakerId, headVariant);
+	};
+	auto playSequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
+		return runtime.playDialogueEntrySequence(lines, count);
+	};
 
 	if (usedItemName.empty()) {
-		if (_state.talkStatePending) {
-			_state.talkStatePending = false;
-			return runtime.playDialogueLine(0x1b44, "PHELPS");
+		if (state.talkStatePending) {
+			state.talkStatePending = false;
+			sharedState.dialogueStateD2f04 = true;
+			return playSequence(kPhelpsIntroLines, ARRAYSIZE(kPhelpsIntroLines));
+		}
+
+		Common::Error lineError = playPhelpsLine(0x1bf4);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		if (runtime.startupScript().getCurrentStoryDayIndex() < 5) {
+			lineError = playPhelpsLine(0x1b7b);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x22a, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1) {
+				lineError = playPhelpsLine(0x1b85);
+			} else {
+				lineError = playPhelpsLine(0x1bbc);
+				if (lineError.getCode() == Common::kNoError)
+					lineError = playPhelpsLine(0x1c44);
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") && !state.stephMidgameShown) {
+			state.stephMidgameShown = true;
+			lineError = playPhelpsLine(0x1c4a);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getCurrentStoryDayIndex() == 5 && !state.dayFiveShown) {
+			state.dayFiveShown = true;
+			lineError = playPhelpsLine(0x1c56);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 		}
-		return runtime.playDialogueLine(0x1bf4, "PHELPS");
+		if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER") && !state.scratchedTuckerShown) {
+			state.scratchedTuckerShown = true;
+			lineError = playPhelpsLine(0x1c65);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") &&
+				!state.barberPoleStolenShown) {
+			state.barberPoleStolenShown = true;
+			lineError = playPhelpsLine(0x1c6e, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playPhelpsLine(0x1c73);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("BOLT_OF_CLOTH_TAKEN") &&
+				!state.boltOfClothTakenShown) {
+			state.boltOfClothTakenShown = true;
+			lineError = playPhelpsLine(0x1c80);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			if (runtime.startupScript().isNamedNpcDeathTypeClear("FIREMAN2") &&
+					runtime.startupScript().isNamedNpcDeathTypeClear("SPARKY")) {
+				lineError = playPhelpsLine(0x1c87);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+		}
+		if (runtime.startupScript().getFlagValue("DINER_BURNED") && !state.dinerBurnedShown) {
+			state.dinerBurnedShown = true;
+			lineError = playPhelpsLine(0x1c8f);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("PC_ESCAPED_JAIL") && !state.escapedJailShown) {
+			state.escapedJailShown = true;
+			lineError = playPhelpsLine(0x1c98);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("GOT_REMAINS_FOR_LODGE") &&
+				runtime.startupScript().isNamedNpcDeathTypeClear("MOYNAHAN") &&
+				!state.gotRemainsForLodgeShown) {
+			state.gotRemainsForLodgeShown = true;
+			lineError = playPhelpsLine(0x1ca0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
+				!state.burnedTvStationShown) {
+			state.burnedTvStationShown = true;
+			lineError = playPhelpsLine(0x1cb4);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (!runtime.startupScript().isNamedNpcDeathTypeClear("BUTCHER") &&
+				!state.butcherDeadShown) {
+			state.butcherDeadShown = true;
+			lineError = playPhelpsLine(0x1cfe);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (!runtime.startupScript().isNamedNpcDeathTypeClear("MOYNAHAN") &&
+				!state.moynahanDeadShown) {
+			state.moynahanDeadShown = true;
+			lineError = playPhelpsLine(0x1d06, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (!runtime.startupScript().isNamedNpcDeathTypeClear("JIMMY") &&
+				!state.jimmyDeadShown) {
+			state.jimmyDeadShown = true;
+			lineError = playPhelpsLine(0x1d0f);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x22c, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1) {
+				lineError = playPhelpsLine(0x1d1c);
+			} else if (responseIndex == 2) {
+				lineError = playPhelpsLine(0x1d21);
+			} else {
+				lineError = Common::kNoError;
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
+				!state.karinFoundDeadShown) {
+			state.karinFoundDeadShown = true;
+			return playSequence(kPhelpsKarinFoundDeadLines, ARRAYSIZE(kPhelpsKarinFoundDeadLines));
+		}
+		if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
+				!state.karinKidnapedShown) {
+			state.karinKidnapedShown = true;
+			sharedState.karinKidnapedDialogueState = true;
+			return playSequence(kPhelpsKarinKidnapedLines, ARRAYSIZE(kPhelpsKarinKidnapedLines));
+		}
+
+		return Common::kNoError;
 	}
 
 	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-		return runtime.playDialogueLine(0x1c0c, "PHELPS");
+		return playSequence(kPhelpsWhaleyLines, ARRAYSIZE(kPhelpsWhaleyLines));
 	}
 	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
-		return runtime.playDialogueLine(0x1c1e, "PHELPS");
+		return playSequence(kPhelpsCasketLines, ARRAYSIZE(kPhelpsCasketLines));
 	}
 	if ((usedItemName.equalsIgnoreCase("LEDGER") ||
 				usedItemName.equalsIgnoreCase("LEDGER2")) &&
 			runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
-		return runtime.playDialogueLine(0x1c36, "PHELPS");
+		return playPhelpsLine(0x1c36);
 	}
 	if (usedItemName.equalsIgnoreCase("NOTE") ||
 			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
-		return runtime.playDialogueLine(0x1c3d, "PHELPS");
+		return playPhelpsLine(0x1c3d);
 	}
 	if (usedItemName.equalsIgnoreCase("QUARTER")) {
 		executeDialogueActionTag(kGetPrnMagActionTag);
-		return runtime.playDialogueLine(0x1ba4, "PHELPS");
+		Common::Error lineError = playSequence(kPhelpsQuarterLines, ARRAYSIZE(kPhelpsQuarterLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		executeDialogueActionTag(kDeleteDirtyMagazineActionTag);
+		return Common::kNoError;
 	}
 	if (usedItemName.equalsIgnoreCase("REWARD_MONEY")) {
 		executeDialogueActionTag(kUseRewardAtStoreActionTag);
@@ -90,7 +288,7 @@ Common::Error PhelpsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return Common::kNoError;
 	}
 
-	return runtime.playDialogueLine(0x1c06, "PHELPS");
+	return playPhelpsLine(0x1c06);
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/phelps_dialogue.h b/engines/harvester/npc/phelps_dialogue.h
index 31a88b35d13..7606c6bdefa 100644
--- a/engines/harvester/npc/phelps_dialogue.h
+++ b/engines/harvester/npc/phelps_dialogue.h
@@ -26,10 +26,24 @@
 
 namespace Harvester {
 
-class PhelpsDialogueHandler : public NpcDialogueHandler {
+	class PhelpsDialogueHandler : public NpcDialogueHandler {
 public:
 	struct PhelpsRoomDialogueState {
 		bool talkStatePending = true;
+		bool stephMidgameShown = false;
+		bool dayFiveShown = false;
+		bool scratchedTuckerShown = false;
+		bool barberPoleStolenShown = false;
+		bool boltOfClothTakenShown = false;
+		bool dinerBurnedShown = false;
+		bool escapedJailShown = false;
+		bool gotRemainsForLodgeShown = false;
+		bool burnedTvStationShown = false;
+		bool butcherDeadShown = false;
+		bool moynahanDeadShown = false;
+		bool jimmyDeadShown = false;
+		bool karinFoundDeadShown = false;
+		bool karinKidnapedShown = false;
 	};
 
 	bool matchesNpc(const Common::String &npcName) const override;


Commit: fbf2f333becb12e323faa2028ee909fc6680b81c
    https://github.com/scummvm/scummvm/commit/fbf2f333becb12e323faa2028ee909fc6680b81c
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:15+02:00

Commit Message:
HARVESTER: Rebuild moynahan dialogue from native flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/dialogue_handler.h
    engines/harvester/npc/moynahan_dialogue.cpp
    engines/harvester/npc/moynahan_dialogue.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 07bb0251b3d..b38b77be54b 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -477,12 +477,20 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - Presenting `NEWSPAPER` sets `GIVEN_PAPER_TODAY`, runs the `ACTV_HOUSE_EXIT` action tag, restores `NEWSPAPER` to its startup state, and then either sets `g_jimmy_talk_state_block` and returns on `0x4a4c` or picks one of the two revisit barks `0x4acc` / `0x4a4b`.
     - The evidence-item path is linear: `PHOTO_OF_WHALEY_HERRILL` plays `0x4af8`, `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` and `CASKET_PHOTO` / `CASKET_PHOTOCOPY` converge on `0x4b00`, note/checkbook evidence plays `0x4b21`, `SNEAKERS` is the dedicated `0x4a9e` branch, and all other items fall back to `0x4af2`.
     - Later no-item visits remain a straight bark chain over inventory `SNEAKERS` without `BROOMKEY`, then `PAPER_CHK_4`, `PAPER_CHK_3`, `PAPER_CHK_2`, and the default `0x4b38`.
+  - `handle_talk_to_moynahan @ 0x24a20` is now bounded enough for a direct startup-room port.
+    - `IF_TRY_TO_TAKE_THE_GLUE` is a strict early interrupt branch: it always plays `0x3e4d` / `0x3e52` / `0x3e56`, then either exits on `0x3e76` / `0x3e7a` or, once the slashes-on-body topic has been unlocked, continues through `0x3e5b` / `0x3e60` / `0x3e64` / `0x3e69` / `0x3e6d` / `0x3e71`.
+    - The first no-item visit clears the Moynahan-local intro gate and plays the fixed six-line opener `0x3d33` / `0x3d37` / `0x3d3b` / `0x3d40` / `0x3d45` / `0x3d47`; later visits start on `0x3fd1`, can inject the slashes-on-body topic into the live keyword buffer through zero-based `dialog.rsp` lines `0x194` / `0x195`, can re-arm the intro gate after the shared casket-photo evidence is discussed, and then run the `HAVE_BOTH_LEDGERS`, `STEPH_MIDGAME_PLAYED`, day-5, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, Butcher-death, Jimmy-death, and `KARIN_FOUND_DEAD` follow-ups in that priority order.
+    - The item path has three real response-menu branches: `CASKET_PHOTO` / `CASKET_PHOTOCOPY` uses zero-based `dialog.rsp` lines `0x18c` and `0x18d`, returns the presented photo to `RAH`, awards `GLUE`, and sets `JUST_GOT_GLUE`; `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` uses zero-based `dialog.rsp` line `0x18e`; and note/checkbook evidence uses zero-based `dialog.rsp` line `0x190`. `PHOTO_OF_WHALEY_HERRILL`, `WED_MATCHES`, and the fallback item path stay linear.
+    - Moynahan's keyword loop is typed-topic driven. Zero-based `dialog.rsp` line `0x199` is the explicit farewell topic behind `0x3e32`; `0x19a` / `0x19b` set the shared Mom day-5 good-cause state before `0x3d5f`; `0x19d..0x1a0`, `0x1a2..0x1a3`, and `0x1a9..0x1ab` each converge on single response lines; `0x1b2..0x1b3` is the day-5-only branch that writes the recovered shared `d2ec0` / `d2ec8` bits before `0x3df5`; `0x1b4..0x1b5` is room-specific to `MBLM`; `0x1b8..0x1be` and `0x1bf..0x1c2` are also day-5 gated; `0x1c4` loops silently on the current menu; everything else falls back to `0x3e39`.
 - The shared dialogue evidence/topic state shims behind those handlers are now partially explicit:
   - `get_set_shared_dialogue_state_d2e98` at `0x38230` is a neutral wrapper over `g_shared_dialogue_state_d2e98`; the only currently recovered gameplay caller is Herrill's `GASCAN` branch at `0x2e056`, where `eax = 1` / `edx = 0` makes it a read-only gate and a zero value collapses the post-`0x30d8` continuation to `PC 0x3123`. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path and save/load serialization.
   - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
   - `get_set_shared_dialogue_state_d2eb0` at `0x382a0` is a neutral wrapper over `g_shared_dialogue_state_d2eb0`; the only currently recovered gameplay caller is a hidden Boyle dialogue branch at `0x2d6e3`, where `eax = 1` / `edx = 0` makes it a read-only gate between `BOYLE 0x10e` and `BOYLE 0x115` after the local item checks. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path.
   - `get_set_shared_dialogue_state_d2eb8` at `0x382c0` is another neutral shared wrapper over `g_shared_dialogue_state_d2eb8`; the currently recovered gameplay caller is Mrs. Potts's `STEPHANIE_IS_DEAD` branch, where `eax = 1` / `edx = 0` makes it a read-only gate between the opening `0x283b` response menu and the later room-specific dead-Stephanie branches. A nearby cutscene helper at `0x38930` also writes that same bit while setting `STEPH_MIDGAME_PLAYED`, but the full gameplay-side semantics of the shared state are still not bounded tightly enough for a stronger name.
   - `get_set_shared_dialogue_state_d2ebc` at `0x382d0` is another neutral shared wrapper over `g_shared_dialogue_state_d2ebc`; the currently recovered gameplay caller is Boyle's `CASKET_PHOTO` / `CASKET_PHOTOCOPY` branch, where `eax = 1` / `edx = 0` makes it a read-only gate for the optional `0x253` line between `0x24f` and `0x258`. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path and save/load serialization.
+  - `get_set_shared_dialogue_state_d2ec0` at `0x382e0` is a neutral wrapper over `g_shared_dialogue_state_d2ec0`; the currently recovered gameplay writer is Moynahan's day-5 `0x1b2` / `0x1b3` keyword branch, which sets it immediately before line `0x3df5` alongside the already recovered shared `d2ebc` and `d2ec8` bits. No reader is bounded tightly enough yet for a stronger semantic name.
+  - `get_set_moynahan_karin_kidnaped_discussion_state` at `0x382f0` is a Moynahan-local follow-up bit written from the `HAVE_BOTH_LEDGERS` response menu when the player takes response `2` after story day `2`, immediately before `0x3f7a`.
+  - `get_set_shared_dialogue_state_d2ec8` at `0x38300` is a neutral wrapper over `g_shared_dialogue_state_d2ec8`; the currently recovered gameplay writers are Moynahan's slashes-on-body no-item follow-up (`0x3da0` / `0x3da1` / `0x3da2`) and his day-5 `0x1b2` / `0x1b3` keyword branch. The backing global is still too weakly bounded for a stronger name.
   - `get_set_discussed_note_checkbook_evidence` at `0x38330` gates the shared `NOTE` / `NOTE_PHOTOCOPY` / `CHECKBOOK` / `CHECKBOOK_PHOTOCOPY` evidence path used by Dwayne, Edna, Herrill, Johnson, Mom, and related handlers.
   - `get_set_discussed_tv_deed_evidence` at `0x38340` gates the `TV_DEED` / `TV_DEED_PHOTOCOPY` evidence path used by Edna, Herrill, Johnson, and Mom.
   - `get_set_discussed_ledger_evidence` at `0x38350` gates the shared dual-ledger evidence path reached when `HAVE_BOTH_LEDGERS` is set.
@@ -549,7 +557,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `g_sergeant_talk_state_block` at `0xd2d4c` is the quest-delivery state cluster toggled across the `REMAINS`, `INVITE`, `BARBER_POLE`, `BOLTCLTH`, `SCRATCHED_TUCKER`, and `COMPLETED_LODGE_APPLICATION` branches.
     - `g_stephanie_talk_state_block` at `0xd2e4c` begins with three one-shot intro flags and stores the corresponding story-day indices in later slots so the repeat lines can be suppressed until the day changes; later confirmed fields now include `g_stephanie_played_spyhole_branch` at `+0x10`, which is set by the hidden `0x2f9..0x2fc` spyhole keyword branch and later prepends `0x4970` before `0x4975`, and `g_stephanie_played_karin_outcome_branch` at `+0x48`, the one-shot gate for the `KARIN_FOUND_ALIVE` / `KARIN_FOUND_DEAD` payoff block.
     - `g_hank_talk_state` at `0xd2de8` is a serialized `0x2c` Hank-local record: first-visit pending at `+0x0`, last-story-day gate at `+0x4`, `rangshot` branch active/pending flags at `+0x8` / `+0xc`, the last story day at `+0x10`, and six one-shot event-response flags at `+0x14..+0x28` for `STEPH_MIDGAME_PLAYED`, `BURNED_TV_STATION`, `BUSTED_ONCE`, `KARIN_KIDNAPED`, `KARIN_FOUND_ALIVE`, and `KARIN_FOUND_DEAD`.
-    - `g_moynahan_talk_state_block` at `0xd2ad0` is the simple no-item intro gate for Moynahan, and the adjacent `g_moynahan_ledger_evidence_line_played` at `0xd2ad4` keeps the special `HAVE_BOTH_LEDGERS` follow-up one-shot once the generic ledger topic has already been discussed.
+    - `g_moynahan_talk_state_block` at `0xd2ad0` begins with the no-item intro gate, then carries the recovered room-local flags for the slashes-on-body topic unlock, the corresponding one-shot intro bark, the `GLUE` acquisition path, the first `HAVE_BOTH_LEDGERS` response-menu branch, and the later `STEPH_MIDGAME_PLAYED`, day-5, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, Butcher-death, Jimmy-death, and `KARIN_FOUND_DEAD` follow-ups. The adjacent `g_moynahan_ledger_evidence_line_played` at `0xd2ad4` remains the separate one-shot gate for the special ledger-evidence follow-up after the generic ledger topic has already been discussed.
     - `g_mr_potts_talk_aux_state_block` at `0xd2af4` includes at least two proven live fields: `g_mr_potts_followup_day_index` for the same-day vs later-day branch after the auxiliary intro, and `g_mr_potts_pottsdam_digging_line_played` for the one-shot `POTTSDAM_DIGGING` reaction.
     - `g_herrill_talk_state_block` carries a small gate tail: `g_herrill_dialogue_suppressed_flag` blocks the standard no-item branch, `g_herrill_requires_evidence_for_dialogue_flag` forces the inventory-evidence prerequisite, and `g_herrill_gascan_presented_flag` records the `GASCAN` presentation path.
     - `g_mrs_potts_talk_aux_state_block` at `0xd2d24` is a compact return-visit/event tail; `g_mrs_potts_return_visit_followup_pending` drives the generic revisit line, while the remaining named fields are one-shot reactions for `DAY_5`, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, `DINER_BURNED`, `GOT_REMAINS_FOR_LODGE`, and `TAKEN_INVITE_TO_SERGEANT`.
diff --git a/TRACKER.md b/TRACKER.md
index 0fb24d0da7f..e883431a005 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,17 +10,17 @@
 
 - Program: `HARVEST.LE`
 - Total functions: `910`
-- Named/documented: `592`
-- Still `FUN_*` / undocumented: `318`
+- Named/documented: `594`
+- Still `FUN_*` / undocumented: `316`
 
 ## Last Confirmed Action
 
-- Rebuilt `handle_talk_to_phelps @ 0x29920` from the corrected native flow and updated the native notes for its restored response menus and one-shot chain.
-  - Phelps now preserves the six-line intro, the pre-day-5 response menu at `dialog.rsp[0x22a]`, the multi-line Whaley / casket / quarter item branches, the quarter-side action tags `GET_PRN_MAG` and `DEL_DRT_MAG`, and the ordered late-game one-shot bark chain through the Jimmy, Butcher, Moynahan, and Karin outcomes.
-  - The next highest-value corrective pass is `handle_talk_to_moynahan`, whose native function still hides a much larger keyword/menu block and several item-driven response menus than the current engine port reflects.
+- Rebuilt `handle_talk_to_moynahan @ 0x24a20` from the corrected native flow and renamed the recovered shared wrappers at `0x382e0` / `0x38300`.
+  - Moynahan now preserves the six-line intro, the `IF_TRY_TO_TAKE_THE_GLUE` interrupt branch, the slashes-on-body keyword injection through `dialog.rsp[0x194]` / `0x195`, the `HAVE_BOTH_LEDGERS` / casket / blackmail response menus, the room-specific ledger portrait override, and the ordered late-game one-shot bark chain through Karin, Butcher, Jimmy, and the day-5-only typed-topic branches.
+  - The remaining work is the consistency audit across the other `handle_talk_to_*` ports to confirm which handlers still diverge materially from the corrected native control flow.
 
 ## Next Suggested Action
 
 1. Audit the remaining `handle_talk_to_*` ports against the corrected native disassembly.
-   - The highest-value immediate follow-up is `handle_talk_to_moynahan`, then the other remaining handlers that still hide native menu/event logic behind mistyped `play_dialogue_line` calls or orphan `run_dialogue_response_menu` / `run_dialogue_keyword_menu` blocks.
+   - The next audit pass should start with the remaining keyword-loop handlers, especially `handle_talk_to_hank` and `handle_talk_to_stephanie`, to confirm whether their existing engine ports already cover the corrected native menu transitions and late-game one-shot chains.
 2. Apply any additional high-confidence Ghidra renames or comments that fall directly out of those audited handlers before each commit.
diff --git a/engines/harvester/npc/dialogue_handler.h b/engines/harvester/npc/dialogue_handler.h
index 93f15c9bb5b..63b7b557380 100644
--- a/engines/harvester/npc/dialogue_handler.h
+++ b/engines/harvester/npc/dialogue_handler.h
@@ -35,6 +35,8 @@ struct DialogueSharedState {
 	bool dialogueStateD2eb0 = false;
 	bool dialogueStateD2eb8 = false;
 	bool dialogueStateD2ebc = false;
+	bool dialogueStateD2ec0 = false;
+	bool dialogueStateD2ec8 = false;
 	bool dialogueStateD2eec = false;
 	bool dialogueStateD2ed0 = false;
 	bool dialogueStateD2ef4 = false;
@@ -47,6 +49,7 @@ struct DialogueSharedState {
 	bool dialogueStateD2f08 = false;
 	bool waspWomanDialogueState = false;
 	bool momGoodCauseDay5State = false;
+	bool moynahanKarinKidnapedDiscussionState = false;
 	bool dwayneWhaleyDisciplineFollowupState = false;
 	bool dwayneDiscussedBoylesButton = false;
 	bool discussedMrPottsTuesdayNightAlibi = false;
diff --git a/engines/harvester/npc/moynahan_dialogue.cpp b/engines/harvester/npc/moynahan_dialogue.cpp
index 2735341cf96..89dc7f1ff70 100644
--- a/engines/harvester/npc/moynahan_dialogue.cpp
+++ b/engines/harvester/npc/moynahan_dialogue.cpp
@@ -25,58 +25,489 @@
 
 #include "harvester/npc/dialogue_flags.h"
 #include "harvester/npc/dialogue_runtime.h"
+#include "harvester/startup_text.h"
 
 namespace Harvester {
 
+namespace {
+
+static const int kMoynahanGoodCauseTopicLines[] = { 0x19a, 0x19b };
+static const int kMoynahanSlashMarksTopicLines[] = { 0x19d, 0x19e, 0x19f, 0x1a0 };
+static const int kMoynahanBodyTopicLines[] = { 0x1a2, 0x1a3 };
+static const int kMoynahanGeneralTopicLines[] = { 0x1a9, 0x1aa, 0x1ab };
+static const int kMoynahanDayFiveUnlockTopicLines[] = { 0x1b2, 0x1b3 };
+static const int kMoynahanEmbalmingRoomTopicLines[] = { 0x1b4, 0x1b5 };
+static const int kMoynahanLateTopicLines[] = { 0x1b8, 0x1b9, 0x1ba, 0x1bb, 0x1bc, 0x1bd, 0x1be };
+static const int kMoynahanGlueTopicLines[] = { 0x1bf, 0x1c0, 0x1c1, 0x1c2 };
+
+static const char *const kMoynahanNpc = "MOYNAHAN";
+static const char *const kMoynahanEmbalmingPortrait = "MOYNAHAN_MBLM";
+static const char *const kMoynahanHotelRoom = "HTL-RT";
+static const char *const kMoynahanEmbalmingRoom = "MBLM";
+static const char *const kRahRoom = "RAH";
+static const char *const kGlueObject = "GLUE";
+static const char *const kJustGotGlueFlag = "JUST_GOT_GLUE";
+
+static Common::Error playDialogueSequence(DialogueRuntime &runtime,
+		const DialogueLineEntry *lines, uint count) {
+	return runtime.playDialogueEntrySequence(lines, count);
+}
+
+} // End of namespace
+
 bool MoynahanDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("MOYNAHAN");
 }
 
 Common::Error MoynahanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &) {
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	MoynahanRoomDialogueState &state = _state;
+	Common::String topicBuffer;
+	int topicBufferLineIndex = -1;
 
-	if (runtime.startupScript().getFlagValue("IF_TRY_TO_TAKE_THE_GLUE"))
-		return runtime.playDialogueLine(0x3e4d, "MOYNAHAN");
+	auto appendSlashMarksTopic = [&]() {
+		if (topicBuffer.empty()) {
+			runtime.assignTopicBuffer(topicBuffer, topicBufferLineIndex,
+				0x194, "Moynahan keyword topics");
+			return;
+		}
+
+		topicBuffer += runtime.startupText().getDialogueResponseLine(0x195);
+		topicBufferLineIndex = -1;
+	};
+
+	if (runtime.startupScript().getFlagValue("IF_TRY_TO_TAKE_THE_GLUE")) {
+		const DialogueLineEntry initialLines[] = {
+			{ 0x3e4d, kMoynahanNpc, 0 },
+			{ 0x3e52, "PC", 0 },
+			{ 0x3e56, kMoynahanNpc, 0 }
+		};
+		Common::Error lineError = playDialogueSequence(runtime, initialLines, ARRAYSIZE(initialLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		if (!runtime.startupScript().getFlagValue("IF_SAW_THE_SLASHES_ON_THE_BODY") ||
+				!state.slashMarksTopicUnlocked) {
+			const DialogueLineEntry shortReplyLines[] = {
+				{ 0x3e76, "PC", 0 },
+				{ 0x3e7a, kMoynahanNpc, 0 }
+			};
+			return playDialogueSequence(runtime, shortReplyLines, ARRAYSIZE(shortReplyLines));
+		}
+
+		const DialogueLineEntry extendedReplyLines[] = {
+			{ 0x3e5b, "PC", 0 },
+			{ 0x3e60, kMoynahanNpc, 0 },
+			{ 0x3e64, "PC", 0 },
+			{ 0x3e69, kMoynahanNpc, 0 },
+			{ 0x3e6d, "PC", 0 },
+			{ 0x3e71, kMoynahanNpc, 0 }
+		};
+		return playDialogueSequence(runtime, extendedReplyLines, ARRAYSIZE(extendedReplyLines));
+	}
 
 	if (usedItemName.empty()) {
-		if (state.talkStatePending) {
-			state.talkStatePending = false;
-			return runtime.playDialogueLine(0x3d33, "MOYNAHAN");
+		if (state.introPending) {
+			state.introPending = false;
+			const DialogueLineEntry introLines[] = {
+				{ 0x3d33, kMoynahanNpc, 0 },
+				{ 0x3d37, "PC", 0 },
+				{ 0x3d3b, kMoynahanNpc, 0 },
+				{ 0x3d40, "PC", 0 },
+				{ 0x3d45, kMoynahanNpc, 0 },
+				{ 0x3d47, kMoynahanNpc, 0 }
+			};
+			return playDialogueSequence(runtime, introLines, ARRAYSIZE(introLines));
+		}
+
+		Common::Error lineError = runtime.playDialogueLine(0x3fd1, kMoynahanNpc);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		if (runtime.startupScript().getFlagValue("IF_SAW_THE_SLASHES_ON_THE_BODY") &&
+				!state.slashMarksIntroShown) {
+			const DialogueLineEntry slashMarksLines[] = {
+				{ 0x3da0, kMoynahanNpc, 0 },
+				{ 0x3da1, kMoynahanNpc, 0 },
+				{ 0x3da2, kMoynahanNpc, 0 }
+			};
+			lineError = playDialogueSequence(runtime, slashMarksLines, ARRAYSIZE(slashMarksLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			sharedState.dialogueStateD2ec8 = true;
+			state.slashMarksTopicUnlocked = true;
+			state.slashMarksIntroShown = true;
+			appendSlashMarksTopic();
+		}
+
+		if (runtime.startupScript().getFlagValue(DialogueFlags::kShownPhotoOfCorpse) &&
+				!state.introPending) {
+			state.introPending = true;
+			lineError = runtime.playDialogueLine(0x3f5b, kMoynahanNpc);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		if (runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS") && !state.ledgersIntroduced) {
+			state.ledgersIntroduced = true;
+			lineError = runtime.playDialogueLine(0x3f65, kMoynahanNpc);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x196, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = runtime.playDialogueLine(0x3f70, kMoynahanNpc);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				(void)runtime.startupScript().setRuntimeObjectVisible("INVENTORY", "LEDGER", false);
+				(void)runtime.startupScript().setRuntimeObjectVisible("INVENTORY", "LEDGER2", false);
+			} else if (responseIndex == 2) {
+				lineError = runtime.playDialogueLine(0x3f76, kMoynahanNpc);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				if (runtime.startupScript().getCurrentStoryDayIndex() > 2) {
+					sharedState.moynahanKarinKidnapedDiscussionState = true;
+					lineError = runtime.playDialogueLine(0x3f7a, kMoynahanNpc);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				}
+
+				lineError = runtime.playDialogueLine(0x3f7e, kMoynahanNpc);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+		}
+
+		if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
+				!state.stephMidgameShown) {
+			state.stephMidgameShown = true;
+			lineError = runtime.playDialogueLine(0x3fd9, kMoynahanNpc);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		if (runtime.startupScript().getCurrentStoryDayIndex() == 5 && !state.dayFiveShown) {
+			state.dayFiveShown = true;
+			const DialogueLineEntry dayFiveLines[] = {
+				{ 0x3fe5, kMoynahanNpc, 0 },
+				{ 0x3fea, "PC", 0 },
+				{ 0x3fee, kMoynahanNpc, 0 },
+				{ 0x3ff3, "PC", 0 },
+				{ 0x3ff7, kMoynahanNpc, 0 }
+			};
+			lineError = playDialogueSequence(runtime, dayFiveLines, ARRAYSIZE(dayFiveLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
+				!state.burnedTvStationShown) {
+			state.burnedTvStationShown = true;
+			lineError = runtime.playDialogueLine(0x3ffe, kMoynahanNpc);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
+				!state.karinKidnapedShown) {
+			state.karinKidnapedShown = true;
+			lineError = runtime.playDialogueLineWithVariant(0x4007, kMoynahanNpc, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x197, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = runtime.playDialogueLine(0x4012, kMoynahanNpc);
+			} else if (responseIndex == 2) {
+				const DialogueLineEntry responseTwoLines[] = {
+					{ 0x4017, kMoynahanNpc, 0 },
+					{ 0x401c, "PC", 0 }
+				};
+				lineError = playDialogueSequence(runtime, responseTwoLines, ARRAYSIZE(responseTwoLines));
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			const DialogueLineEntry kidnapedLines[] = {
+				{ 0x4020, kMoynahanNpc, 0 },
+				{ 0x4026, "PC", 0 },
+				{ 0x402a, kMoynahanNpc, 0 },
+				{ 0x4034, "PC", 0 },
+				{ 0x4038, kMoynahanNpc, 0 },
+				{ 0x403d, "PC", 0 }
+			};
+			return playDialogueSequence(runtime, kidnapedLines, ARRAYSIZE(kidnapedLines));
+		}
+
+		if (!runtime.startupScript().isNamedNpcDeathTypeClear("BUTCHER") &&
+				!state.butcherDeadShown) {
+			state.butcherDeadShown = true;
+			return runtime.playDialogueLine(0x40bb, kMoynahanNpc);
+		}
+
+		if (!runtime.startupScript().isNamedNpcDeathTypeClear("JIMMY") &&
+				!state.jimmyDeadShown) {
+			state.jimmyDeadShown = true;
+			return runtime.playDialogueLine(0x40cd, kMoynahanNpc);
+		}
+
+		if (runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
+				!state.karinFoundDeadShown) {
+			state.karinFoundDeadShown = true;
+			return runtime.playDialogueLine(0x40d4, kMoynahanNpc);
+		}
+
+		for (;;) {
+			Common::String selectedTopic;
+			Common::Error menuError = runtime.runKeywordMenu(
+				topicBuffer, topicBufferLineIndex, selectedTopic);
+			if (menuError.getCode() != Common::kNoError)
+				return menuError;
+
+			if (selectedTopic.empty())
+				return Common::kNoError;
+			if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
+					runtime.matchesResponseLine(selectedTopic, 0x199))
+				return runtime.playDialogueLine(0x3e32, kMoynahanNpc);
+
+			if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanGoodCauseTopicLines,
+					ARRAYSIZE(kMoynahanGoodCauseTopicLines))) {
+				sharedState.momGoodCauseDay5State = true;
+				return runtime.playDialogueLine(0x3d5f, kMoynahanNpc);
+			}
+
+			if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanSlashMarksTopicLines,
+					ARRAYSIZE(kMoynahanSlashMarksTopicLines)))
+				return runtime.playDialogueLine(0x3d76, kMoynahanNpc);
+
+			if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanBodyTopicLines,
+					ARRAYSIZE(kMoynahanBodyTopicLines)))
+				return runtime.playDialogueLine(0x3da0, kMoynahanNpc);
+
+			if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanGeneralTopicLines,
+					ARRAYSIZE(kMoynahanGeneralTopicLines)))
+				return runtime.playDialogueLine(0x3db1, kMoynahanNpc);
+
+			if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanDayFiveUnlockTopicLines,
+					ARRAYSIZE(kMoynahanDayFiveUnlockTopicLines))) {
+				if (runtime.startupScript().getCurrentStoryDayIndex() > 4) {
+					sharedState.dialogueStateD2ec8 = true;
+					sharedState.dialogueStateD2ebc = true;
+					sharedState.dialogueStateD2ec0 = true;
+					return runtime.playDialogueLine(0x3df5, kMoynahanNpc);
+				}
+			}
+
+			if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanEmbalmingRoomTopicLines,
+					ARRAYSIZE(kMoynahanEmbalmingRoomTopicLines))) {
+				if (runtime.currentRoomName().equalsIgnoreCase(kMoynahanEmbalmingRoom))
+					return runtime.playDialogueLine(0x3e18, kMoynahanNpc);
+			}
+
+			if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanLateTopicLines,
+					ARRAYSIZE(kMoynahanLateTopicLines))) {
+				if (runtime.startupScript().getCurrentStoryDayIndex() > 4)
+					return runtime.playDialogueLine(0x40f5, kMoynahanNpc);
+			}
+
+			if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanGlueTopicLines,
+					ARRAYSIZE(kMoynahanGlueTopicLines))) {
+				if (runtime.startupScript().getCurrentStoryDayIndex() > 4) {
+					if (!state.gotGlue)
+						return runtime.playDialogueLine(0x40fb, kMoynahanNpc);
+					return runtime.playDialogueLine(0x410d, kMoynahanNpc);
+				}
+			}
+
+			if (runtime.matchesResponseLine(selectedTopic, 0x1c4))
+				continue;
+
+			return runtime.playDialogueLine(0x3e39, kMoynahanNpc);
 		}
-		return runtime.playDialogueLine(0x3fd1, "MOYNAHAN");
 	}
 
 	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-		return runtime.playDialogueLine(0x3e45, "MOYNAHAN");
+		return runtime.playDialogueLine(0x3e45, kMoynahanNpc);
 	}
+
 	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY"))
-		return runtime.playDialogueLine(0x3e84, "MOYNAHAN");
+			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+		const DialogueLineEntry casketIntroLines[] = {
+			{ 0x3e84, kMoynahanNpc, 0 },
+			{ 0x3e89, "PC", 0 },
+			{ 0x3e8e, kMoynahanNpc, 0 },
+			{ 0x3e93, "PC", 0 },
+			{ 0x3e98, kMoynahanNpc, 0 }
+		};
+		Common::Error lineError = playDialogueSequence(runtime, casketIntroLines, ARRAYSIZE(casketIntroLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x18c, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			const DialogueLineEntry responseOneLines[] = {
+				{ 0x3ea4, kMoynahanNpc, 0 },
+				{ 0x3ea8, "PC", 0 },
+				{ 0x3ead, kMoynahanNpc, 0 }
+			};
+			lineError = playDialogueSequence(runtime, responseOneLines, ARRAYSIZE(responseOneLines));
+		} else if (responseIndex == 2) {
+			lineError = runtime.playDialogueLine(0x3eb1, kMoynahanNpc);
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		const DialogueLineEntry casketFollowupLines[] = {
+			{ 0x3eb7, "PC", 0 },
+			{ 0x3ebc, kMoynahanNpc, 0 }
+		};
+		lineError = playDialogueSequence(runtime, casketFollowupLines, ARRAYSIZE(casketFollowupLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		if (state.gotGlue)
+			return runtime.playDialogueLine(0x3ec3, kMoynahanNpc);
+
+		lineError = runtime.playDialogueLine(0x3ec8, kMoynahanNpc);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		responseIndex = 0;
+		responseError = runtime.runResponseMenu(0x18d, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = runtime.playDialogueLine(0x3ed2, kMoynahanNpc);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			(void)runtime.startupScript().resetRuntimeObjectToInitialState(usedItemName);
+			(void)runtime.startupScript().setRuntimeObjectVisible(kRahRoom, usedItemName, true);
+			(void)runtime.startupScript().setRuntimeObjectVisible(kRahRoom, kGlueObject, true);
+			(void)runtime.startupScript().addRuntimeObjectToInventory(kGlueObject);
+			(void)runtime.startupScript().setRuntimeFlagValue(kJustGotGlueFlag, true);
+			state.gotGlue = true;
+			return Common::kNoError;
+		}
+
+		if (responseIndex == 2)
+			return runtime.playDialogueLine(0x3ed7, kMoynahanNpc);
+
+		return Common::kNoError;
+	}
+
 	if ((usedItemName.equalsIgnoreCase("LEDGER") ||
 				usedItemName.equalsIgnoreCase("LEDGER2")) &&
 			runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
 		if (runtime.startupScript().getFlagValue(DialogueFlags::kShownLedgersToAnyone) &&
 				!state.ledgerEvidenceLinePlayed) {
 			state.ledgerEvidenceLinePlayed = true;
-			Common::Error lineError = runtime.playDialogueLine(0x3f87, "MOYNAHAN");
+			Common::Error lineError = runtime.playDialogueLine(0x3f87, kMoynahanNpc);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+
+			runtime.setActiveSpeakerPortrait(
+				runtime.currentRoomName().equalsIgnoreCase(kMoynahanHotelRoom)
+					? kMoynahanNpc
+					: kMoynahanEmbalmingPortrait,
+				0);
+			return Common::kNoError;
 		}
-		return runtime.playDialogueLine(0x3ede, "MOYNAHAN");
+
+		const DialogueLineEntry ledgerIntroLines[] = {
+			{ 0x3ede, "PC", 0 },
+			{ 0x3ee3, kMoynahanNpc, 0 },
+			{ 0x3ee7, "PC", 0 },
+			{ 0x3eec, kMoynahanNpc, 0 }
+		};
+		Common::Error lineError = playDialogueSequence(runtime, ledgerIntroLines, ARRAYSIZE(ledgerIntroLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x18e, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			const DialogueLineEntry responseOneLines[] = {
+				{ 0x3ef7, kMoynahanNpc, 0 },
+				{ 0x3efb, "PC", 0 },
+				{ 0x3eff, kMoynahanNpc, 0 },
+				{ 0x3f05, "PC", 0 }
+			};
+			lineError = playDialogueSequence(runtime, responseOneLines, ARRAYSIZE(responseOneLines));
+		} else if (responseIndex == 2) {
+			const DialogueLineEntry responseTwoLines[] = {
+				{ 0x3f09, kMoynahanNpc, 0 },
+				{ 0x3f0f, "PC", 0 },
+				{ 0x3f13, kMoynahanNpc, 0 },
+				{ 0x3f18, "PC", 0 }
+			};
+			lineError = playDialogueSequence(runtime, responseTwoLines, ARRAYSIZE(responseTwoLines));
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		return runtime.playDialogueLine(0x3ed7, kMoynahanNpc);
 	}
 	if (usedItemName.equalsIgnoreCase("NOTE") ||
 			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
-		return runtime.playDialogueLine(0x3f3e, "MOYNAHAN");
+		Common::Error lineError = runtime.playDialogueLine(0x3f3e, kMoynahanNpc);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x190, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = runtime.playDialogueLine(0x3f4a, kMoynahanNpc);
+		} else if (responseIndex == 2) {
+			lineError = runtime.playDialogueLine(0x3f4f, kMoynahanNpc);
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		return runtime.playDialogueLine(0x3f54, kMoynahanNpc);
+	}
+
+	if (usedItemName.equalsIgnoreCase("WED_MATCHES")) {
+		const DialogueLineEntry matchesLines[] = {
+			{ 0x4049, "PC", 0 },
+			{ 0x404d, kMoynahanNpc, 0 }
+		};
+		Common::Error lineError = playDialogueSequence(runtime, matchesLines, ARRAYSIZE(matchesLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED"))
+			return runtime.playDialogueLine(0x4054, kMoynahanNpc);
+		return Common::kNoError;
 	}
-	if (usedItemName.equalsIgnoreCase("WED_MATCHES"))
-		return runtime.playDialogueLine(0x4049, "MOYNAHAN");
 
-	return runtime.playDialogueLine(0x3e3f, "MOYNAHAN");
+	return runtime.playDialogueLine(0x3e3f, kMoynahanNpc);
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/moynahan_dialogue.h b/engines/harvester/npc/moynahan_dialogue.h
index f756cead198..458e892bf5f 100644
--- a/engines/harvester/npc/moynahan_dialogue.h
+++ b/engines/harvester/npc/moynahan_dialogue.h
@@ -29,8 +29,19 @@ namespace Harvester {
 class MoynahanDialogueHandler : public NpcDialogueHandler {
 public:
 	struct MoynahanRoomDialogueState {
-		bool talkStatePending = true;
+		bool introPending = true;
+		bool slashMarksTopicUnlocked = false;
+		bool slashMarksIntroShown = false;
+		bool gotGlue = false;
+		bool ledgersIntroduced = false;
 		bool ledgerEvidenceLinePlayed = false;
+		bool stephMidgameShown = false;
+		bool dayFiveShown = false;
+		bool burnedTvStationShown = false;
+		bool karinKidnapedShown = false;
+		bool butcherDeadShown = false;
+		bool jimmyDeadShown = false;
+		bool karinFoundDeadShown = false;
 	};
 
 	bool matchesNpc(const Common::String &npcName) const override;


Commit: e33c7da94b50733a963a692d687e142c50bf2f20
    https://github.com/scummvm/scummvm/commit/e33c7da94b50733a963a692d687e142c50bf2f20
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:15+02:00

Commit Message:
HARVESTER: Rebuild stephanie dialogue from native flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/dialogue_handler.h
    engines/harvester/npc/dwayne_dialogue.cpp
    engines/harvester/npc/stephanie_dialogue.cpp
    engines/harvester/npc/stephanie_dialogue.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index b38b77be54b..16216b72533 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -482,6 +482,13 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - The first no-item visit clears the Moynahan-local intro gate and plays the fixed six-line opener `0x3d33` / `0x3d37` / `0x3d3b` / `0x3d40` / `0x3d45` / `0x3d47`; later visits start on `0x3fd1`, can inject the slashes-on-body topic into the live keyword buffer through zero-based `dialog.rsp` lines `0x194` / `0x195`, can re-arm the intro gate after the shared casket-photo evidence is discussed, and then run the `HAVE_BOTH_LEDGERS`, `STEPH_MIDGAME_PLAYED`, day-5, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, Butcher-death, Jimmy-death, and `KARIN_FOUND_DEAD` follow-ups in that priority order.
     - The item path has three real response-menu branches: `CASKET_PHOTO` / `CASKET_PHOTOCOPY` uses zero-based `dialog.rsp` lines `0x18c` and `0x18d`, returns the presented photo to `RAH`, awards `GLUE`, and sets `JUST_GOT_GLUE`; `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` uses zero-based `dialog.rsp` line `0x18e`; and note/checkbook evidence uses zero-based `dialog.rsp` line `0x190`. `PHOTO_OF_WHALEY_HERRILL`, `WED_MATCHES`, and the fallback item path stay linear.
     - Moynahan's keyword loop is typed-topic driven. Zero-based `dialog.rsp` line `0x199` is the explicit farewell topic behind `0x3e32`; `0x19a` / `0x19b` set the shared Mom day-5 good-cause state before `0x3d5f`; `0x19d..0x1a0`, `0x1a2..0x1a3`, and `0x1a9..0x1ab` each converge on single response lines; `0x1b2..0x1b3` is the day-5-only branch that writes the recovered shared `d2ec0` / `d2ec8` bits before `0x3df5`; `0x1b4..0x1b5` is room-specific to `MBLM`; `0x1b8..0x1be` and `0x1bf..0x1c2` are also day-5 gated; `0x1c4` loops silently on the current menu; everything else falls back to `0x3e39`.
+  - `handle_talk_to_stephanie @ 0x36710` is now bounded enough for a direct startup-room port.
+    - The item path is not purely linear: note/checkbook evidence stays on `0x466a`, the casket-photo path now reads the recovered shared `d2ec0` and `d2ebc` bits between `0x4675` and `0x4684`, and the dual-ledger case remains the fixed `0x468f` / `0x4694` / `0x469a` / `0x469e` exchange.
+    - Stephanie's no-item opener is a three-stage day-gated ladder, not a simple intro/revisit split. The first visit seeds zero-based `dialog.rsp` line `0x2cf`; same-day revisits switch to `0x2d0`; later days run the first follow-up (`0x45a7` through `0x45e2` with zero-based `dialog.rsp` line `0x2d1`), then the second follow-up (`0x45e8` through `0x464e` with zero-based `dialog.rsp` lines `0x2d3` and `0x2d5`) before collapsing to the steady `0x4654` revisit line.
+    - The late-game no-item tail is a strict one-shot chain: day 5 -> `0x46aa`..`0x46c9`; `BURNED_TV_STATION` -> response menu `0x2d7`; `SCRATCHED_TUCKER` -> response menus `0x2d9` / `0x2da`; `BOLT_OF_CLOTH_TAKEN` -> response menu `0x2db`; `BARBER_POLE_STOLEN` -> response menu `0x2dd`; `DINER_BURNED` -> response menus `0x2df` / `0x2e0` / `0x2e1`; `PC_ESCAPED_JAIL` -> response menu `0x2e3`; `KARIN_KIDNAPED` -> `0x4957`..`0x4969`; and the Karin outcome block starts on `0x4996`, can insert `0x49c1` when Dwayne's completed Karin-alive follow-up bit is set, and always exits through `0x49ea` / `0x49ef`.
+    - The corrected raw-code tails show two hidden Stephanie continuations outside the current clean decompiler body.
+      - The `0x2f9..0x2fd` spyhole keyword branch does not return on `0x4a12`; it sets the local spyhole flag, continues through `0x4a19` or `0x4a1e` depending on whether Mr. Potts has already been confronted about the spyhole, then plays `0x4a23` and loops back into the keyword menu.
+      - The post-`KARIN_KIDNAPED` tail reuses that local spyhole flag to prepend `0x4970` / `0x4975`, optionally adds Mr. and Mrs. Potts's Tuesday-night alibi follow-ups (`0x4980` and `0x4984` / `0x4989` / `0x498f`), and only then checks the Karin-alive / Karin-dead payoff block.
 - The shared dialogue evidence/topic state shims behind those handlers are now partially explicit:
   - `get_set_shared_dialogue_state_d2e98` at `0x38230` is a neutral wrapper over `g_shared_dialogue_state_d2e98`; the only currently recovered gameplay caller is Herrill's `GASCAN` branch at `0x2e056`, where `eax = 1` / `edx = 0` makes it a read-only gate and a zero value collapses the post-`0x30d8` continuation to `PC 0x3123`. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path and save/load serialization.
   - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
@@ -555,7 +562,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - Both participate in `reset_all_talk_to_handler_state` and save/load serialization, but this pass did not find a visible talk handler that reads them directly.
   - Two handler-local block roles are now more explicit from disassembly:
     - `g_sergeant_talk_state_block` at `0xd2d4c` is the quest-delivery state cluster toggled across the `REMAINS`, `INVITE`, `BARBER_POLE`, `BOLTCLTH`, `SCRATCHED_TUCKER`, and `COMPLETED_LODGE_APPLICATION` branches.
-    - `g_stephanie_talk_state_block` at `0xd2e4c` begins with three one-shot intro flags and stores the corresponding story-day indices in later slots so the repeat lines can be suppressed until the day changes; later confirmed fields now include `g_stephanie_played_spyhole_branch` at `+0x10`, which is set by the hidden `0x2f9..0x2fc` spyhole keyword branch and later prepends `0x4970` before `0x4975`, and `g_stephanie_played_karin_outcome_branch` at `+0x48`, the one-shot gate for the `KARIN_FOUND_ALIVE` / `KARIN_FOUND_DEAD` payoff block.
+    - `g_stephanie_talk_state_block` at `0xd2e4c` begins with three day-gated intro/followup flags plus their stored story-day indices, then carries one-shot gates for `SCRATCHED_TUCKER`, `BOLT_OF_CLOTH_TAKEN`, `BARBER_POLE_STOLEN`, `BURNED_TV_STATION`, `DINER_BURNED`, the later Karin-alive diner sub-branch, day 5, `PC_ESCAPED_JAIL`, `KARIN_KIDNAPED`, and the Karin outcome payoff.
+    - Later confirmed Stephanie-local fields include `g_stephanie_played_spyhole_branch` at `+0x10`, which is set by the hidden `0x2f9..0x2fc` spyhole keyword branch and later prepends `0x4970` before `0x4975`, and `g_stephanie_played_karin_outcome_branch` at `+0x48`, the one-shot gate for the `KARIN_FOUND_ALIVE` / `KARIN_FOUND_DEAD` payoff block.
     - `g_hank_talk_state` at `0xd2de8` is a serialized `0x2c` Hank-local record: first-visit pending at `+0x0`, last-story-day gate at `+0x4`, `rangshot` branch active/pending flags at `+0x8` / `+0xc`, the last story day at `+0x10`, and six one-shot event-response flags at `+0x14..+0x28` for `STEPH_MIDGAME_PLAYED`, `BURNED_TV_STATION`, `BUSTED_ONCE`, `KARIN_KIDNAPED`, `KARIN_FOUND_ALIVE`, and `KARIN_FOUND_DEAD`.
     - `g_moynahan_talk_state_block` at `0xd2ad0` begins with the no-item intro gate, then carries the recovered room-local flags for the slashes-on-body topic unlock, the corresponding one-shot intro bark, the `GLUE` acquisition path, the first `HAVE_BOTH_LEDGERS` response-menu branch, and the later `STEPH_MIDGAME_PLAYED`, day-5, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, Butcher-death, Jimmy-death, and `KARIN_FOUND_DEAD` follow-ups. The adjacent `g_moynahan_ledger_evidence_line_played` at `0xd2ad4` remains the separate one-shot gate for the special ledger-evidence follow-up after the generic ledger topic has already been discussed.
     - `g_mr_potts_talk_aux_state_block` at `0xd2af4` includes at least two proven live fields: `g_mr_potts_followup_day_index` for the same-day vs later-day branch after the auxiliary intro, and `g_mr_potts_pottsdam_digging_line_played` for the one-shot `POTTSDAM_DIGGING` reaction.
diff --git a/TRACKER.md b/TRACKER.md
index e883431a005..c06f181f616 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -9,18 +9,18 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `910`
-- Named/documented: `594`
-- Still `FUN_*` / undocumented: `316`
+- Total functions: `899`
+- Named/documented: `586`
+- Still `FUN_*` / undocumented: `313`
 
 ## Last Confirmed Action
 
-- Rebuilt `handle_talk_to_moynahan @ 0x24a20` from the corrected native flow and renamed the recovered shared wrappers at `0x382e0` / `0x38300`.
-  - Moynahan now preserves the six-line intro, the `IF_TRY_TO_TAKE_THE_GLUE` interrupt branch, the slashes-on-body keyword injection through `dialog.rsp[0x194]` / `0x195`, the `HAVE_BOTH_LEDGERS` / casket / blackmail response menus, the room-specific ledger portrait override, and the ordered late-game one-shot bark chain through Karin, Butcher, Jimmy, and the day-5-only typed-topic branches.
-  - The remaining work is the consistency audit across the other `handle_talk_to_*` ports to confirm which handlers still diverge materially from the corrected native control flow.
+- Rebuilt `handle_talk_to_stephanie @ 0x36710` from the corrected native flow, labeled the recovered orphan tails at `0x37650` and `0x37e78`, and wired Dwayne's completed Karin-alive follow-up into shared dialogue state so Stephanie can see it.
+  - Stephanie now preserves the day-gated intro ladder, the hidden TV-station / SCRATCHED_TUCKER / BOLT_OF_CLOTH / BARBER_POLE / DINER / jail response menus, the `0x2f9..0x2fd` spyhole keyword continuation through `0x4a19` / `0x4a1e` / `0x4a23`, the post-`KARIN_KIDNAPED` Potts-alibi follow-up block, and the extended Karin outcome tail through `0x49c1` / `0x49ea` / `0x49ef`.
+  - The next corrective pass is now the audit of handlers that feed Stephanie's recovered shared follow-up states, starting with `handle_talk_to_mrs_potts`, because the engine still has a `discussedMrsPottsTuesdayNightAlibi` state that is never written.
 
 ## Next Suggested Action
 
 1. Audit the remaining `handle_talk_to_*` ports against the corrected native disassembly.
-   - The next audit pass should start with the remaining keyword-loop handlers, especially `handle_talk_to_hank` and `handle_talk_to_stephanie`, to confirm whether their existing engine ports already cover the corrected native menu transitions and late-game one-shot chains.
+   - The highest-value immediate follow-up is `handle_talk_to_mrs_potts`, then any other previously rebuilt handlers that still leave recovered shared dialogue states unwired in the engine-side ports.
 2. Apply any additional high-confidence Ghidra renames or comments that fall directly out of those audited handlers before each commit.
diff --git a/engines/harvester/npc/dialogue_handler.h b/engines/harvester/npc/dialogue_handler.h
index 63b7b557380..2759c83ee40 100644
--- a/engines/harvester/npc/dialogue_handler.h
+++ b/engines/harvester/npc/dialogue_handler.h
@@ -52,7 +52,9 @@ struct DialogueSharedState {
 	bool moynahanKarinKidnapedDiscussionState = false;
 	bool dwayneWhaleyDisciplineFollowupState = false;
 	bool dwayneDiscussedBoylesButton = false;
+	bool dwayneCompletedKarinAliveFollowup = false;
 	bool discussedMrPottsTuesdayNightAlibi = false;
+	bool discussedMrsPottsTuesdayNightAlibi = false;
 	bool confrontedMrPottsAboutSpyhole = false;
 };
 
diff --git a/engines/harvester/npc/dwayne_dialogue.cpp b/engines/harvester/npc/dwayne_dialogue.cpp
index db4ae947c4d..dfb2e40b2b2 100644
--- a/engines/harvester/npc/dwayne_dialogue.cpp
+++ b/engines/harvester/npc/dwayne_dialogue.cpp
@@ -364,6 +364,7 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		state.pendingKarinAliveFollowupLinePlayed = true;
 		state.pendingKarinAliveFollowup = false;
 		state.completedKarinAliveFollowup = true;
+		sharedState.dwayneCompletedKarinAliveFollowup = true;
 
 		const DialogueLineEntry lines[] = {
 			{ 0x3780, "DWAYNE", 0 },
diff --git a/engines/harvester/npc/stephanie_dialogue.cpp b/engines/harvester/npc/stephanie_dialogue.cpp
index b20b73f2744..124b57d816f 100644
--- a/engines/harvester/npc/stephanie_dialogue.cpp
+++ b/engines/harvester/npc/stephanie_dialogue.cpp
@@ -23,6 +23,8 @@
 
 #include "harvester/npc/stephanie_dialogue.h"
 
+#include "graphics/screen.h"
+#include "harvester/harvester.h"
 #include "harvester/npc/dialogue_flags.h"
 #include "harvester/npc/dialogue_runtime.h"
 
@@ -63,6 +65,14 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			runtime.queueDialogueInteractionIfNeeded(interaction);
 		}
 	};
+	auto clearScreenToBlack = [&]() {
+		Graphics::Screen *screen = runtime.engine().getScreen();
+		if (!screen)
+			return;
+
+		screen->fillRect(screen->getBounds(), 0);
+		screen->update();
+	};
 
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("NOTE") ||
@@ -82,12 +92,12 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			lineError = runtime.playDialogueLine(0x4675, "PC");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			if (sharedState.dialogueStateD2ea8) {
+			if (sharedState.dialogueStateD2ec0) {
 				lineError = runtime.playDialogueLine(0x467b, "PC");
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 			}
-			if (sharedState.dialogueStateD2ea4) {
+			if (sharedState.dialogueStateD2ebc) {
 				lineError = runtime.playDialogueLine(0x467f, "PC");
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
@@ -233,6 +243,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 		if (responseIndex == 1) {
+			clearScreenToBlack();
 			(void)runtime.playDialogueFst(kDialogueC025BFstPath);
 			(void)runtime.playDialogueFst(kDialogueC007FstPath);
 			executeDialogueActionTag("LEAVE_STEPH_AFTER_NASTY");
@@ -267,6 +278,299 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
+	if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
+			!state.burnedTvStationShown) {
+		state.burnedTvStationShown = true;
+		Common::Error lineError = runtime.playDialogueLineWithVariant(0x4918, "STEPHANIE", 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		const DialogueLineEntry openingLines[] = {
+			{ 0x491e, "PC", 0 },
+			{ 0x4928, "STEPHANIE", 0 },
+			{ 0x492c, "PC", 0 },
+			{ 0x4930, "STEPHANIE", 0 }
+		};
+		lineError = runtime.playDialogueEntrySequence(openingLines, ARRAYSIZE(openingLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x2d7, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playStephanieLine(0x493a);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			clearScreenToBlack();
+			(void)runtime.playDialogueFst(kDialogueC007FstPath);
+			executeDialogueActionTag("LEAVE_STEPH_AFTER_NASTY");
+			return Common::kNoError;
+		}
+		if (responseIndex == 2) {
+			const DialogueLineEntry responseLines[] = {
+				{ 0x4944, "STEPHANIE", 0 },
+				{ 0x4949, "PC", 0 },
+				{ 0x494d, "STEPHANIE", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(responseLines, ARRAYSIZE(responseLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+	}
+	if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER") &&
+			!state.scratchedTuckerShown) {
+		state.scratchedTuckerShown = true;
+		const DialogueLineEntry openingLines[] = {
+			{ 0x46d2, "STEPHANIE", 0 },
+			{ 0x46db, "PC", 0 },
+			{ 0x46e0, "STEPHANIE", 0 }
+		};
+		Common::Error lineError = runtime.playDialogueEntrySequence(openingLines, ARRAYSIZE(openingLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x2d9, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			const DialogueLineEntry responseOneLines[] = {
+				{ 0x46ea, "STEPHANIE", 0 },
+				{ 0x46ee, "PC", 0 },
+				{ 0x46f2, "STEPHANIE", 0 },
+				{ 0x46f6, "PC", 0 },
+				{ 0x46fb, "STEPHANIE", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(responseOneLines, ARRAYSIZE(responseOneLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int followupResponseIndex = 0;
+			responseError = runtime.runResponseMenu(0x2da, followupResponseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (followupResponseIndex == 1) {
+				const DialogueLineEntry followupLines[] = {
+					{ 0x4705, "STEPHANIE", 0 },
+					{ 0x4709, "PC", 0 }
+				};
+				lineError = runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
+			} else if (followupResponseIndex == 2) {
+				const DialogueLineEntry followupLines[] = {
+					{ 0x470e, "STEPHANIE", 0 },
+					{ 0x470f, "PC", 0 }
+				};
+				lineError = runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			lineError = playStephanieLine(0x4717);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			lineError = playStephanieLine(0x471e);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+	}
+	if (runtime.startupScript().getFlagValue("BOLT_OF_CLOTH_TAKEN") &&
+			!state.boltOfClothTakenShown) {
+		state.boltOfClothTakenShown = true;
+		Common::Error lineError = playStephanieLine(0x4724);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x2db, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playStephanieLine(0x472f);
+		} else if (responseIndex == 2) {
+			state.boltOfClothEscalationTriggered = true;
+			const DialogueLineEntry responseTwoLines[] = {
+				{ 0x4736, "STEPHANIE", 0 },
+				{ 0x473b, "PC", 0 },
+				{ 0x4740, "STEPHANIE", 0 },
+				{ 0x4744, "PC", 0 },
+				{ 0x4748, "STEPHANIE", 0 },
+				{ 0x474d, "PC", 0 },
+				{ 0x4753, "STEPHANIE", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(responseTwoLines, ARRAYSIZE(responseTwoLines));
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") &&
+			!state.barberPoleStolenShown) {
+		state.barberPoleStolenShown = true;
+		Common::Error lineError = playStephanieLine(0x47a0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x2dd, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			const DialogueLineEntry responseOneLines[] = {
+				{ 0x47af, "STEPHANIE", 0 },
+				{ 0x47b4, "PC", 0 },
+				{ 0x47b8, "STEPHANIE", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(responseOneLines, ARRAYSIZE(responseOneLines));
+		} else if (responseIndex == 2) {
+			const DialogueLineEntry responseTwoLines[] = {
+				{ 0x47be, "STEPHANIE", 0 },
+				{ 0x47c2, "PC", 0 },
+				{ 0x47c7, "STEPHANIE", 0 },
+				{ 0x47cc, "PC", 0 },
+				{ 0x47d0, "STEPHANIE", 0 },
+				{ 0x47d6, "PC", 0 },
+				{ 0x47da, "STEPHANIE", 0 },
+				{ 0x47de, "PC", 0 },
+				{ 0x47e3, "STEPHANIE", 0 },
+				{ 0x47eb, "PC", 0 },
+				{ 0x47ef, "STEPHANIE", 0 },
+				{ 0x482b, "STEPHANIE", 0 },
+				{ 0x4830, "PC", 0 },
+				{ 0x4834, "STEPHANIE", 0 },
+				{ 0x4839, "PC", 0 },
+				{ 0x483d, "STEPHANIE", 0 },
+				{ 0x47fe, "PC", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(responseTwoLines, ARRAYSIZE(responseTwoLines));
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
+			!state.dinerBurnedShown) {
+		state.dinerBurnedShown = true;
+		const DialogueLineEntry openingLines[] = {
+			{ 0x4845, "STEPHANIE", 0 },
+			{ 0x484a, "PC", 0 },
+			{ 0x484e, "STEPHANIE", 0 }
+		};
+		Common::Error lineError = runtime.playDialogueEntrySequence(openingLines, ARRAYSIZE(openingLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x2df, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playStephanieLine(0x4858);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			if (runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE")) {
+				lineError = playStephanieLine(0x485c);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			int followupResponseIndex = 0;
+			responseError = runtime.runResponseMenu(0x2e0, followupResponseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (followupResponseIndex == 1) {
+				const DialogueLineEntry followupLines[] = {
+					{ 0x4866, "STEPHANIE", 0 },
+					{ 0x486b, "PC", 0 },
+					{ 0x4870, "STEPHANIE", 0 }
+				};
+				lineError = runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
+			} else if (followupResponseIndex == 2) {
+				const DialogueLineEntry responseTwoLines[] = {
+					{ 0x4877, "STEPHANIE", 0 },
+					{ 0x487b, "PC", 0 },
+					{ 0x4880, "STEPHANIE", 0 }
+				};
+				lineError = runtime.playDialogueEntrySequence(responseTwoLines, ARRAYSIZE(responseTwoLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				int nestedResponseIndex = 0;
+				responseError = runtime.runResponseMenu(0x2e1, nestedResponseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+
+				if (nestedResponseIndex == 1) {
+					lineError = playStephanieLine(0x488e);
+				} else if (nestedResponseIndex == 2) {
+					const DialogueLineEntry nestedLines[] = {
+						{ 0x4895, "STEPHANIE", 0 },
+						{ 0x4899, "PC", 0 },
+						{ 0x489d, "STEPHANIE", 0 },
+						{ 0x461b, "PC", 0 }
+					};
+					lineError = runtime.playDialogueEntrySequence(nestedLines, ARRAYSIZE(nestedLines));
+				}
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			const DialogueLineEntry responseTwoLines[] = {
+				{ 0x48c2, "STEPHANIE", 0 },
+				{ 0x48c6, "PC", 0 },
+				{ 0x48ca, "STEPHANIE", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(responseTwoLines, ARRAYSIZE(responseTwoLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			if (runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
+					!state.dinerBurnedKarinAliveFollowupShown) {
+				state.dinerBurnedKarinAliveFollowupShown = true;
+				const DialogueLineEntry karinAliveLines[] = {
+					{ 0x48ce, "STEPHANIE", 0 },
+					{ 0x48d3, "STEPHANIE", 0 },
+					{ 0x48d8, "PC", 0 },
+					{ 0x48dc, "STEPHANIE", 0 },
+					{ 0x48e1, "PC", 0 },
+					{ 0x48e5, "STEPHANIE", 0 }
+				};
+				lineError = runtime.playDialogueEntrySequence(karinAliveLines, ARRAYSIZE(karinAliveLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+		}
+	}
+	if (runtime.startupScript().getFlagValue("PC_ESCAPED_JAIL") &&
+			!state.pcEscapedJailShown) {
+		state.pcEscapedJailShown = true;
+		Common::Error lineError = playStephanieLine(0x48eb);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x2e3, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			const DialogueLineEntry responseLines[] = {
+				{ 0x48f7, "STEPHANIE", 0 },
+				{ 0x4908, "PC", 0 },
+				{ 0x490d, "STEPHANIE", 0 },
+				{ 0x4912, "PC", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(responseLines, ARRAYSIZE(responseLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+	}
 	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
 			!state.karinKidnapedLinePlayed &&
 			sharedState.karinKidnapedDialogueState) {
@@ -282,35 +586,68 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
+	if (state.playedSpyholeBranch) {
+		Common::Error lineError = runtime.playDialogueLine(0x4970, "STEPHANIE");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = runtime.playDialogueLine(0x4975, "STEPHANIE");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (sharedState.discussedMrPottsTuesdayNightAlibi) {
+			lineError = runtime.playDialogueLineWithVariant(0x4980, "PC", 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (sharedState.discussedMrsPottsTuesdayNightAlibi) {
+			const DialogueLineEntry lines[] = {
+				{ 0x4984, "PC", 0 },
+				{ 0x4989, "PC", 0 },
+				{ 0x498f, "STEPHANIE", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+	}
 	if ((runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") ||
 				runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD")) &&
 			!state.karinOutcomeLinePlayed) {
 		state.karinOutcomeLinePlayed = true;
-		Common::Error lineError = playStephanieLine(0x4996);
+		Common::Error lineError = runtime.playDialogueLineWithVariant(0x4996, "STEPHANIE", 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 		if (runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE")) {
 			const DialogueLineEntry aliveLines[] = {
-				{ 0x499a, "PC", 0 },
-				{ 0x49a1, "STEPHANIE", 0 },
+				{ 0x499a, "PC", 2 },
+				{ 0x49a1, "STEPHANIE", 2 },
 				{ 0x49ad, "PC", 0 },
-				{ 0x49b1, "STEPHANIE", 0 },
+				{ 0x49b1, "STEPHANIE", 4 },
 				{ 0x49b6, "PC", 0 },
 				{ 0x49ba, "STEPHANIE", 0 }
 			};
 			lineError = runtime.playDialogueEntrySequence(aliveLines, ARRAYSIZE(aliveLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			if (sharedState.dwayneCompletedKarinAliveFollowup) {
+				lineError = playStephanieLine(0x49c1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
 		} else {
 			const DialogueLineEntry deadLines[] = {
-				{ 0x49da, "PC", 0 },
-				{ 0x49e0, "STEPHANIE", 0 },
-				{ 0x49e4, "PC", 0 }
+				{ 0x49da, "PC", 3 },
+				{ 0x49e0, "STEPHANIE", 3 },
+				{ 0x49e4, "PC", 3 }
 			};
 			lineError = runtime.playDialogueEntrySequence(deadLines, ARRAYSIZE(deadLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
+		const DialogueLineEntry outcomeTailLines[] = {
+			{ 0x49ea, "STEPHANIE", 2 },
+			{ 0x49ef, "PC", 2 }
+		};
+		return runtime.playDialogueEntrySequence(outcomeTailLines, ARRAYSIZE(outcomeTailLines));
 	}
 
 	if (stephanieTopicBufferLineIndex < 0)
@@ -516,13 +853,27 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				runtime.matchesResponseLine(selectedTopic, 0x2fb) ||
 				runtime.matchesResponseLine(selectedTopic, 0x2fc) ||
 				runtime.matchesResponseLine(selectedTopic, 0x2fd)) {
-			const DialogueLineEntry lines[] = {
-				{ 0x4a05, "PC", 0 },
-				{ 0x4a0a, "STEPHANIE", 0 },
-				{ 0x4a0e, "PC", 0 },
-				{ 0x4a12, "STEPHANIE", 0 }
-			};
-			return runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			state.playedSpyholeBranch = true;
+			Common::Error lineError = runtime.playDialogueLine(0x4a05, "PC");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueLineWithVariant(0x4a0a, "STEPHANIE", 4);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueLine(0x4a0e, "PC");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueLineWithVariant(0x4a12, "STEPHANIE", 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueLine(
+				sharedState.confrontedMrPottsAboutSpyhole ? 0x4a19 : 0x4a1e, "PC");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueLineWithVariant(0x4a23, "STEPHANIE", 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
 		}
 
 		Common::Error lineError = playStephanieLine(0x465c);
diff --git a/engines/harvester/npc/stephanie_dialogue.h b/engines/harvester/npc/stephanie_dialogue.h
index d9da16043d9..be17fd476bd 100644
--- a/engines/harvester/npc/stephanie_dialogue.h
+++ b/engines/harvester/npc/stephanie_dialogue.h
@@ -30,12 +30,21 @@ class StephanieDialogueHandler : public NpcDialogueHandler {
 public:
 	struct StephanieRoomDialogueState {
 		bool introPending = true;
-		int introDayIndex = -1;
-		bool firstFollowupPending = false;
-		int firstFollowupDayIndex = -1;
-		bool secondFollowupPending = false;
-		int secondFollowupDayIndex = -1;
+		int introDayIndex = 0;
+		bool firstFollowupPending = true;
+		int firstFollowupDayIndex = 0;
+		bool boltOfClothEscalationTriggered = false;
+		bool playedSpyholeBranch = false;
+		bool secondFollowupPending = true;
+		int secondFollowupDayIndex = 0;
+		bool scratchedTuckerShown = false;
+		bool boltOfClothTakenShown = false;
+		bool barberPoleStolenShown = false;
+		bool burnedTvStationShown = false;
+		bool dinerBurnedShown = false;
+		bool dinerBurnedKarinAliveFollowupShown = false;
 		bool dayFiveLinePlayed = false;
+		bool pcEscapedJailShown = false;
 		bool karinKidnapedLinePlayed = false;
 		bool karinOutcomeLinePlayed = false;
 		Common::String currentTopicBuffer;


Commit: 31942cf3c92ec42b9f5f73d7394946dfef02ea37
    https://github.com/scummvm/scummvm/commit/31942cf3c92ec42b9f5f73d7394946dfef02ea37
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:15+02:00

Commit Message:
HARVESTER: Rebuild mrs potts dialogue from native flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/mrs_potts_dialogue.cpp
    engines/harvester/npc/mrs_potts_dialogue.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 16216b72533..12b0bf6dc5e 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -530,7 +530,9 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - Phelps's item handling also expands beyond the old stub: `PHOTO_OF_WHALEY_HERRILL` is `0x1c0c` / `0x1c13` / `0x1c17`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` is `0x1c1e` / `0x1c22` / `0x1c27` / `0x1c2c` / `0x1c30`, and `QUARTER` dispatches `GET_PRN_MAG`, plays `0x1ba4` / `0x1bac` / `0x1bb0`, then dispatches `DEL_DRT_MAG`.
 - `handle_talk_to_mrs_potts @ 0x2ee70` is now bounded enough to rebuild from native flow.
   - The non-dead intro branch clears `g_mrs_potts_talk_aux_state_block`, sets the return-visit followup bit, opens zero-based `dialog.rsp` line `0x231`, and seeds keyword buffers `0x232` or `0x233` depending on the response path. Later no-item visits either use the `0x2800` response menu gate or the direct permission branch through `PERMISSION_TO_SEE_STEPHANIE` and `STEF_HALL_EXIT`.
-  - The non-dead event followups are one-shot gates rather than terminal barks: `DAY_5` -> `0x2889`, `BURNED_TV_STATION` -> `0x2891`, `KARIN_KIDNAPED` -> `0x28dc` plus response menu `0x235`, and `DINER_BURNED` -> `0x290e`. After those gates, the keyword loop matches `0x237` through `0x24a`, with the `0x247` / `0x248` Lodge topics explicitly setting `get_set_discussed_lodge_topic`.
+  - The non-dead event followups are one-shot gates rather than terminal barks: `DAY_5` -> `0x2889`, `BURNED_TV_STATION` -> `0x2891`, `KARIN_KIDNAPED` -> `0x28dc` plus response menu `0x235`, and `DINER_BURNED` -> `0x290e`. After those gates, the keyword loop matches `0x237` through `0x24a`.
+  - The hidden keyword loop tails are now explicit. `Pearls`, `Bake Sale`, `Wedding`, and `Grounded` expand into `0x2756`..`0x276b`, `0x2775`..`0x2783`, `0x278c`..`0x2799`, and `0x27a3`..`0x27cc`, then reseed keyword buffers `0x239`, `0x23b`, `0x23d`, and `0x240`. The later `Mr. Pottsdam`, `Moynahan`, and `Lodge` topic groups reseed `0x243`, `0x246`, and `0x249`, with the Lodge path explicitly setting `get_set_discussed_lodge_topic` before `0x27ef` / `0x27f3`.
+  - The hidden `KARIN_KIDNAPED` response-1 tail `0x28e8` / `0x28ee` / `0x28f2` is the Mrs. Potts Tuesday-night alibi branch: it sets `get_set_discussed_mrs_potts_tuesday_night_alibi`, while response `2` continues through `0x28fb` / `0x2903` / `0x2907` before the handler falls through to later event checks.
   - The `STEPHANIE_IS_DEAD` path is split three ways: `d2eb8 == 0` opens response menu `0x230` after `0x283b`; otherwise Mrs. Potts branches on room `STKITCHN`, then the `GOT_REMAINS_FOR_LODGE` and `TAKEN_INVITE_TO_SERGEANT` one-shots drive response menus `0x22d` and `0x22e`.
   - Both the direct `REMAINS` item branch and the `GOT_REMAINS_FOR_LODGE` response-menu branch converge on the same death sequence: `0x28b7` / `0x28bd` / `0x28c1`, a black-screen clear, `play_c040_fst`, then `dispatch_action_tag_if_set("DIE_IN_CHAIR")`.
 - `play_c040_fst` at `0x38920` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C040.FST", 0)` and is currently only recovered from Mrs. Potts's death-in-chair branch.
diff --git a/TRACKER.md b/TRACKER.md
index c06f181f616..5b593c228de 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,12 +15,12 @@
 
 ## Last Confirmed Action
 
-- Rebuilt `handle_talk_to_stephanie @ 0x36710` from the corrected native flow, labeled the recovered orphan tails at `0x37650` and `0x37e78`, and wired Dwayne's completed Karin-alive follow-up into shared dialogue state so Stephanie can see it.
-  - Stephanie now preserves the day-gated intro ladder, the hidden TV-station / SCRATCHED_TUCKER / BOLT_OF_CLOTH / BARBER_POLE / DINER / jail response menus, the `0x2f9..0x2fd` spyhole keyword continuation through `0x4a19` / `0x4a1e` / `0x4a23`, the post-`KARIN_KIDNAPED` Potts-alibi follow-up block, and the extended Karin outcome tail through `0x49c1` / `0x49ea` / `0x49ef`.
-  - The next corrective pass is now the audit of handlers that feed Stephanie's recovered shared follow-up states, starting with `handle_talk_to_mrs_potts`, because the engine still has a `discussedMrsPottsTuesdayNightAlibi` state that is never written.
+- Rebuilt `handle_talk_to_mrs_potts @ 0x2ee70` from the corrected native flow, labeled the recovered Karin-alibi and grounded-keyword tails at `0x2f694` and `0x2f9d1`, and wired Mrs. Potts's Tuesday-night alibi follow-up into shared dialogue state so Stephanie can see it.
+  - Mrs. Potts now preserves the native intro default, the non-dead one-shot event gates through the keyword loop, the hidden `Pearls` / `Bake Sale` / `Wedding` / `Grounded` topic expansions and reseeds, the later `Mr. Pottsdam` / `Moynahan` / `Lodge` reseeds, and the `KARIN_KIDNAPED` response-1 alibi setter through `0x28f2`.
+  - The next corrective pass is now the remaining talk-handler audit beyond the Potts family/Stephanie shared-state chain, starting with `handle_talk_to_johnson`.
 
 ## Next Suggested Action
 
 1. Audit the remaining `handle_talk_to_*` ports against the corrected native disassembly.
-   - The highest-value immediate follow-up is `handle_talk_to_mrs_potts`, then any other previously rebuilt handlers that still leave recovered shared dialogue states unwired in the engine-side ports.
+   - The highest-value immediate follow-up is `handle_talk_to_johnson`, then the rest of the remaining `handle_talk_to_*` ports whose keyword loops or event tails may still be truncated by the old `play_dialogue_line` decompile breakage.
 2. Apply any additional high-confidence Ghidra renames or comments that fall directly out of those audited handlers before each commit.
diff --git a/engines/harvester/npc/mrs_potts_dialogue.cpp b/engines/harvester/npc/mrs_potts_dialogue.cpp
index 8b8650df8c3..94d2981287a 100644
--- a/engines/harvester/npc/mrs_potts_dialogue.cpp
+++ b/engines/harvester/npc/mrs_potts_dialogue.cpp
@@ -68,6 +68,36 @@ static const DialogueLineEntry kMrsPottsIntroResponse2Lines[] = {
 	{ 0x274c, "MRS_POTTS", 0 }
 };
 
+static const DialogueLineEntry kMrsPottsPearlsLines[] = {
+	{ 0x2756, "MRS_POTTS", 0 },
+	{ 0x275d, "PC", 0 },
+	{ 0x2762, "MRS_POTTS", 0 },
+	{ 0x2766, "PC", 0 },
+	{ 0x276b, "MRS_POTTS", 0 }
+};
+
+static const DialogueLineEntry kMrsPottsBakeSaleLines[] = {
+	{ 0x2775, "PC", 0 },
+	{ 0x2779, "MRS_POTTS", 3 },
+	{ 0x277f, "PC", 0 },
+	{ 0x2783, "MRS_POTTS", 0 }
+};
+
+static const DialogueLineEntry kMrsPottsWeddingLines[] = {
+	{ 0x278c, "PC", 0 },
+	{ 0x2790, "MRS_POTTS", 0 },
+	{ 0x2795, "MRS_POTTS", 0 },
+	{ 0x2799, "PC", 0 }
+};
+
+static const DialogueLineEntry kMrsPottsGroundedIntroLines[] = {
+	{ 0x27a3, "MRS_POTTS", 3 },
+	{ 0x27a8, "PC", 0 },
+	{ 0x27ac, "MRS_POTTS", 2 },
+	{ 0x27b0, "PC", 2 },
+	{ 0x27b5, "MRS_POTTS", 2 }
+};
+
 static const DialogueLineEntry kMrsPottsGotRemainsIntroLines[] = {
 	{ 0x289e, "MRS_POTTS", 0 },
 	{ 0x28a8, "MRS_POTTS", 0 },
@@ -83,7 +113,18 @@ static const DialogueLineEntry kMrsPottsRemainsDeathLines[] = {
 static const DialogueLineEntry kMrsPottsKarinKidnapedResponse1Lines[] = {
 	{ 0x28e8, "MRS_POTTS", 0 },
 	{ 0x28ee, "PC", 0 },
-	{ 0x28f2, "MRS_POTTS", 0 }
+	{ 0x28f2, "MRS_POTTS", 2 }
+};
+
+static const DialogueLineEntry kMrsPottsKarinKidnapedResponse2Lines[] = {
+	{ 0x28fb, "MRS_POTTS", 3 },
+	{ 0x2903, "PC", 0 },
+	{ 0x2907, "MRS_POTTS", 2 }
+};
+
+static const DialogueLineEntry kMrsPottsLodgeLines[] = {
+	{ 0x27ef, "PC", 0 },
+	{ 0x27f3, "MRS_POTTS", 1 }
 };
 
 static const int kMrsPottsTopic27d6ResponseLines[] = { 0x241, 0x242 };
@@ -326,12 +367,16 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 	if (runtime.startupScript().getCurrentStoryDayIndex() == 5 && !state.day5LinePlayed) {
 		state.day5LinePlayed = true;
-		return playMrsPottsLine(0x2889);
+		Common::Error lineError = playMrsPottsLine(0x2889);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 	if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
 			!state.burnedTvStationLinePlayed) {
 		state.burnedTvStationLinePlayed = true;
-		return playMrsPottsLine(0x2891, 1);
+		Common::Error lineError = playMrsPottsLine(0x2891, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
 			!state.karinKidnappedLinePlayed) {
@@ -347,16 +392,24 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 		if (responseIndex == 1) {
-			return playSequence(kMrsPottsKarinKidnapedResponse1Lines,
+			lineError = playSequence(kMrsPottsKarinKidnapedResponse1Lines,
 				ARRAYSIZE(kMrsPottsKarinKidnapedResponse1Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			sharedState.discussedMrsPottsTuesdayNightAlibi = true;
+		} else if (responseIndex == 2) {
+			lineError = playSequence(kMrsPottsKarinKidnapedResponse2Lines,
+				ARRAYSIZE(kMrsPottsKarinKidnapedResponse2Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 		}
-		if (responseIndex == 2)
-			return playMrsPottsLine(0x28fb);
 	}
 	if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
 			!state.dinerBurnedLinePlayed) {
 		state.dinerBurnedLinePlayed = true;
-		return playMrsPottsLine(0x290e);
+		Common::Error lineError = playMrsPottsLine(0x290e, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 
 	Common::String selectedTopic;
@@ -365,34 +418,90 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			mrsPottsTopicBuffer, mrsPottsTopicBufferLineIndex, selectedTopic);
 		if (menuError.getCode() != Common::kNoError)
 			return menuError;
-		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
-				runtime.matchesResponseLine(selectedTopic, 0x237)) {
+		if (runtime.matchesResponseLine(selectedTopic, 0x237)) {
 			return playMrsPottsLine(0x297e);
 		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x238))
-			return playMrsPottsLine(0x2756);
-		if (runtime.matchesResponseLine(selectedTopic, 0x23a))
-			return playMrsPottsLine(0x2775, 0, "PC");
-		if (runtime.matchesResponseLine(selectedTopic, 0x23c))
-			return playMrsPottsLine(0x278c, 0, "PC");
-		if (runtime.matchesResponseLine(selectedTopic, 0x23e))
-			return playMrsPottsLine(0x27a3);
+		if (runtime.matchesResponseLine(selectedTopic, 0x238)) {
+			Common::Error lineError = playSequence(
+				kMrsPottsPearlsLines, ARRAYSIZE(kMrsPottsPearlsLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMrsPottsTopicBuffer(0x239);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x23a)) {
+			Common::Error lineError = playSequence(
+				kMrsPottsBakeSaleLines, ARRAYSIZE(kMrsPottsBakeSaleLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMrsPottsTopicBuffer(0x23b);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x23c)) {
+			Common::Error lineError = playSequence(
+				kMrsPottsWeddingLines, ARRAYSIZE(kMrsPottsWeddingLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMrsPottsTopicBuffer(0x23d);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0x23e)) {
+			Common::Error lineError = playSequence(
+				kMrsPottsGroundedIntroLines, ARRAYSIZE(kMrsPottsGroundedIntroLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x23f, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1) {
+				lineError = playMrsPottsLine(0x27c3, 1);
+			} else if (responseIndex == 2) {
+				lineError = playMrsPottsLine(0x27c7, 2, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playMrsPottsLine(0x27cc, 1);
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			(void)runtime.startupScript().setRuntimeFlagValue(kPermissionToSeeStephanieFlag, true);
+			executeDialogueActionTag(kStephHallExitActionTag);
+			assignMrsPottsTopicBuffer(0x240);
+			continue;
+		}
 		if (runtime.matchesAnyResponseLine(selectedTopic, kMrsPottsTopic27d6ResponseLines,
 				ARRAYSIZE(kMrsPottsTopic27d6ResponseLines))) {
-			return playMrsPottsLine(0x27d6);
+			Common::Error lineError = playMrsPottsLine(0x27d6, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMrsPottsTopicBuffer(0x243);
+			continue;
 		}
 		if (runtime.matchesAnyResponseLine(selectedTopic, kMrsPottsTopic27e3ResponseLines,
 				ARRAYSIZE(kMrsPottsTopic27e3ResponseLines))) {
-			return playMrsPottsLine(0x27e3);
+			Common::Error lineError = playMrsPottsLine(0x27e3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMrsPottsTopicBuffer(0x246);
+			continue;
 		}
 		if (runtime.matchesAnyResponseLine(selectedTopic, kMrsPottsLodgeTopicResponseLines,
 				ARRAYSIZE(kMrsPottsLodgeTopicResponseLines))) {
 			sharedState.discussedLodgeTopic = true;
-			return playMrsPottsLine(0x27ef, 0, "PC");
+			Common::Error lineError = playSequence(
+				kMrsPottsLodgeLines, ARRAYSIZE(kMrsPottsLodgeLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMrsPottsTopicBuffer(0x249);
+			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x24a))
 			continue;
-		return playMrsPottsLine(0x2930);
+		Common::Error lineError = playMrsPottsLine(0x2930);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 }
 
diff --git a/engines/harvester/npc/mrs_potts_dialogue.h b/engines/harvester/npc/mrs_potts_dialogue.h
index 12df8cde89e..8e3ec401528 100644
--- a/engines/harvester/npc/mrs_potts_dialogue.h
+++ b/engines/harvester/npc/mrs_potts_dialogue.h
@@ -29,7 +29,7 @@ namespace Harvester {
 class MrsPottsDialogueHandler : public NpcDialogueHandler {
 public:
 	struct MrsPottsRoomDialogueState {
-		bool auxIntroPending = false;
+		bool auxIntroPending = true;
 		bool returnVisitFollowupPending = false;
 		bool gotRemainsForLodgeLinePlayed = false;
 		bool takenInviteToSergeantLinePlayed = false;


Commit: ee39e69974377b6a3d7ff28eea09542a09a45869
    https://github.com/scummvm/scummvm/commit/ee39e69974377b6a3d7ff28eea09542a09a45869
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:16+02:00

Commit Message:
HARVESTER: Rebuild johnson dialogue from native flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/johnson_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 12b0bf6dc5e..d303e343341 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -471,7 +471,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - The main no-item gate is the Johnson-local intro bit at `0xd2bf4`. When set, Johnson plays `0xa86` / `0xa8a` / `0xa8e` / `0xa97` / `0xa9b`, sets `get_set_shared_dialogue_state_d2f04`, and seeds the keyword buffer from zero-based `dialog.rsp` line `0x176`; otherwise the steady revisit entry is `0xb62`.
     - `SCRATCHED_TUCKER` has its own one-shot re-entry hook: the first time that flag is seen, Johnson plays `0xc25`, re-arms the intro gate, and then immediately falls back into the normal intro path.
     - The steady-state no-item bark chain is fully linear and one-shot gated: `STEPH_MIDGAME_PLAYED` -> `0xbf0` / `0xbf1` / `0xbf2`, `KARIN_KIDNAPED` -> `0xbff` / `0xc06` plus another `d2f04` write, `KARIN_FOUND_ALIVE` -> `0xc13` / `0xc17`, `BARBER_POLE_STOLEN` -> `0xc46` / `0xc4d` / `0xc59`, `DINER_BURNED` -> `0xc6f`, `PC_ESCAPED_JAIL` -> `0xc77`, `GOT_REMAINS_FOR_LODGE` -> `0xc7f`, and `BURNED_TV_STATION` -> `0xc8f`.
-    - Johnson's keyword loop now has a stable branch map: `0x177` exits on `0xbda`, `0x178` / `0x179` play `0xaa6`, open zero-based `dialog.rsp` line `0x17a`, then continue through `0xabd` / `0xac3` / `0xac7` and rewrite the keyword buffer to `0x17b`; `0x17c` plays `0xb17` / `0xb1c` / `0xb20` and rewrites to `0x17d`; `0x17e` plays `0xb2d` and rewrites to `0x17f`; `0x180` / `0x181` / `0x182`, `0x184` / `0x185`, and `0x187` each play one topic line, then fall through the generic `0xb69` / `0xbda` exit; `0x189` loops silently on the current buffer.
+    - Johnson's keyword loop now has a stable branch map: `0x177` exits on `0xbda`, `0x178` / `0x179` play `0xaa6`, open zero-based `dialog.rsp` line `0x17a`, then continue through `0xabd` / `0xac3` / `0xac7` and rewrite the keyword buffer to `0x17b`; `0x17c` plays `0xb17` / `0xb1c` / `0xb20` and rewrites to `0x17d`; `0x17e` plays `0xb2d` and rewrites to `0x17f`; `0x180` / `0x181` / `0x182` play `0xb3a` and rewrite to `0x183`; `0x184` / `0x185` play `0xb46` and rewrite to `0x186`; `0x187` continues through `PC 0xb52` and `JOHNSON 0xb56`, then rewrites to `0x188`; `0x189` loops silently on the current buffer; fallback still uses `0xb69` then `0xbda`.
   - `handle_talk_to_jimmy @ 0x28880` is a compact short-circuit handler with no keyword loop.
     - The first no-item visit clears `g_jimmy_first_no_item_line_pending` and returns immediately on either `0x4a4c` when the paper-handoff intro has not fired yet or `0x4a58` once `g_jimmy_talk_state_block` is already set.
     - Presenting `NEWSPAPER` sets `GIVEN_PAPER_TODAY`, runs the `ACTV_HOUSE_EXIT` action tag, restores `NEWSPAPER` to its startup state, and then either sets `g_jimmy_talk_state_block` and returns on `0x4a4c` or picks one of the two revisit barks `0x4acc` / `0x4a4b`.
diff --git a/TRACKER.md b/TRACKER.md
index 5b593c228de..1e90013752b 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,12 +15,12 @@
 
 ## Last Confirmed Action
 
-- Rebuilt `handle_talk_to_mrs_potts @ 0x2ee70` from the corrected native flow, labeled the recovered Karin-alibi and grounded-keyword tails at `0x2f694` and `0x2f9d1`, and wired Mrs. Potts's Tuesday-night alibi follow-up into shared dialogue state so Stephanie can see it.
-  - Mrs. Potts now preserves the native intro default, the non-dead one-shot event gates through the keyword loop, the hidden `Pearls` / `Bake Sale` / `Wedding` / `Grounded` topic expansions and reseeds, the later `Mr. Pottsdam` / `Moynahan` / `Lodge` reseeds, and the `KARIN_KIDNAPED` response-1 alibi setter through `0x28f2`.
-  - The next corrective pass is now the remaining talk-handler audit beyond the Potts family/Stephanie shared-state chain, starting with `handle_talk_to_johnson`.
+- Rebuilt `handle_talk_to_johnson @ 0x27dd0` from the corrected native flow, labeled the recovered reseed tails at `0x28699`, `0x28708`, and `0x28760`, and fixed the engine-side keyword loop so the hidden topic-buffer rewrites are preserved instead of collapsing straight to the generic exit.
+  - Johnson now keeps `0x180` / `0x181` / `0x182` on the `0xb3a -> dialog.rsp[0x183]` path, `0x184` / `0x185` on the `0xb46 -> dialog.rsp[0x186]` path, and `0x187` on the `PC 0xb52` / `JOHNSON 0xb56` / `dialog.rsp[0x188]` path; only `0x177` is the direct exit, while `0x189` remains a silent loop.
+  - The next corrective pass is the remaining keyword-loop / event-tail audit, starting with `handle_talk_to_herrill`.
 
 ## Next Suggested Action
 
 1. Audit the remaining `handle_talk_to_*` ports against the corrected native disassembly.
-   - The highest-value immediate follow-up is `handle_talk_to_johnson`, then the rest of the remaining `handle_talk_to_*` ports whose keyword loops or event tails may still be truncated by the old `play_dialogue_line` decompile breakage.
+   - The highest-value immediate follow-up is `handle_talk_to_herrill`, then the rest of the remaining `handle_talk_to_*` ports whose keyword loops or event tails may still be truncated by the old `play_dialogue_line` decompile breakage.
 2. Apply any additional high-confidence Ghidra renames or comments that fall directly out of those audited handlers before each commit.
diff --git a/engines/harvester/npc/johnson_dialogue.cpp b/engines/harvester/npc/johnson_dialogue.cpp
index 9b35d0e56a4..5869cf71673 100644
--- a/engines/harvester/npc/johnson_dialogue.cpp
+++ b/engines/harvester/npc/johnson_dialogue.cpp
@@ -225,8 +225,7 @@ Common::Error JohnsonDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return menuError;
 		if (selectedTopic.empty())
 			return Common::kNoError;
-		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
-				runtime.matchesAnyResponseLine(selectedTopic, kJohnsonDirectExitTopicLines,
+		if (runtime.matchesAnyResponseLine(selectedTopic, kJohnsonDirectExitTopicLines,
 					ARRAYSIZE(kJohnsonDirectExitTopicLines))) {
 			return playJohnsonLine(0xbda, 1);
 		}
@@ -288,29 +287,26 @@ Common::Error JohnsonDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			Common::Error lineError = playJohnsonLine(0xb3a);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			lineError = playJohnsonLine(0xb69);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			return playJohnsonLine(0xbda, 1);
+			assignJohnsonTopicBuffer(0x183);
+			continue;
 		}
 		if (runtime.matchesAnyResponseLine(selectedTopic, kJohnsonSecondGenericReplyTopicLines,
 				ARRAYSIZE(kJohnsonSecondGenericReplyTopicLines))) {
 			Common::Error lineError = playJohnsonLine(0xb46);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			lineError = playJohnsonLine(0xb69);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			return playJohnsonLine(0xbda, 1);
+			assignJohnsonTopicBuffer(0x186);
+			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x187)) {
-			Common::Error lineError = playJohnsonLine(0xb52);
+			Common::Error lineError = runtime.playDialogueLineWithVariant(0xb52, "PC", 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			lineError = playJohnsonLine(0xb69);
+			lineError = playJohnsonLine(0xb56, 2);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			return playJohnsonLine(0xbda, 1);
+			assignJohnsonTopicBuffer(0x188);
+			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x189))
 			continue;


Commit: 17eb1fe041e6891cc5f24339c3f4a17ad01add59
    https://github.com/scummvm/scummvm/commit/17eb1fe041e6891cc5f24339c3f4a17ad01add59
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:16+02:00

Commit Message:
HARVESTER: Correct herrill bye routing

Changed paths:
    TRACKER.md
    engines/harvester/npc/herrill_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 1e90013752b..7a3ea6bf19b 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,12 +15,12 @@
 
 ## Last Confirmed Action
 
-- Rebuilt `handle_talk_to_johnson @ 0x27dd0` from the corrected native flow, labeled the recovered reseed tails at `0x28699`, `0x28708`, and `0x28760`, and fixed the engine-side keyword loop so the hidden topic-buffer rewrites are preserved instead of collapsing straight to the generic exit.
-  - Johnson now keeps `0x180` / `0x181` / `0x182` on the `0xb3a -> dialog.rsp[0x183]` path, `0x184` / `0x185` on the `0xb46 -> dialog.rsp[0x186]` path, and `0x187` on the `PC 0xb52` / `JOHNSON 0xb56` / `dialog.rsp[0x188]` path; only `0x177` is the direct exit, while `0x189` remains a silent loop.
-  - The next corrective pass is the remaining keyword-loop / event-tail audit, starting with `handle_talk_to_herrill`.
+- Corrected `handle_talk_to_herrill` so only zero-based `dialog.rsp[0x283]` is the direct BYE exit; zero-based `dialog.rsp[0x293]` once again follows the native `0x3099 -> 0x2ff4` path instead of being swallowed by a generic BYE string check.
+  - The matching native annotation on `handle_talk_to_herrill @ 0x2dc00` now records that `0x283` and `0x293` are distinct BYE branches and must not be collapsed together.
+  - The next corrective pass is the remaining keyword-loop / event-tail audit, starting with `handle_talk_to_hank`.
 
 ## Next Suggested Action
 
 1. Audit the remaining `handle_talk_to_*` ports against the corrected native disassembly.
-   - The highest-value immediate follow-up is `handle_talk_to_herrill`, then the rest of the remaining `handle_talk_to_*` ports whose keyword loops or event tails may still be truncated by the old `play_dialogue_line` decompile breakage.
+   - The highest-value immediate follow-up is `handle_talk_to_hank`, then the rest of the remaining `handle_talk_to_*` ports whose keyword loops or event tails may still be truncated by the old `play_dialogue_line` decompile breakage.
 2. Apply any additional high-confidence Ghidra renames or comments that fall directly out of those audited handlers before each commit.
diff --git a/engines/harvester/npc/herrill_dialogue.cpp b/engines/harvester/npc/herrill_dialogue.cpp
index a6d77149468..1359d1c2248 100644
--- a/engines/harvester/npc/herrill_dialogue.cpp
+++ b/engines/harvester/npc/herrill_dialogue.cpp
@@ -311,8 +311,7 @@ Common::Error HerrillDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return menuError;
 		if (selectedTopic.empty())
 			return Common::kNoError;
-		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
-				runtime.matchesResponseLine(selectedTopic, 0x283)) {
+		if (runtime.matchesResponseLine(selectedTopic, 0x283)) {
 			return playHerrillLine(0x2ff4);
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x284)) {


Commit: a222f4f42b35b76c7b3cfd192f757c586ed02a52
    https://github.com/scummvm/scummvm/commit/a222f4f42b35b76c7b3cfd192f757c586ed02a52
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:16+02:00

Commit Message:
HARVESTER: Rebuild hank dialogue from native flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/hank_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index d303e343341..7c6646224dc 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -421,7 +421,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - It opens the ASCII `dialog.rsp` file, reads forward to the requested zero-based line index, strips the trailing CR/LF, and returns the shared `g_dialogue_response_line_buffer`.
   - Handlers such as `handle_talk_to_dwayne`, `handle_talk_to_hank`, and `handle_talk_to_mom` use it to map keyword-menu selections back to concrete topic strings before branching.
   - `handle_talk_to_hank` has hidden post-`play_dialogue_line` blocks that immediately use this loader with `run_dialogue_response_menu`: after Hank's opening `0x703` line it shows zero-based `dialog.rsp` line `0xc7` (`1. Who are you?/2. Who am I?/3. Where am I?`) and seeds `g_dialogue_keyword_list_spec` from lines `0xc8`, `0xc9`, or `0xca` based on the selected slot.
-  - The same Hank handler also rewrites `g_dialogue_keyword_list_spec` from zero-based `dialog.rsp` line `0xd5` after the hidden multi-line `CoolDaddio` branch (`0x725` through `0x747`), which confirms the keyword buffer is live dialogue state rather than a static per-NPC list.
+  - The same Hank handler rewrites `g_dialogue_keyword_list_spec` from zero-based `dialog.rsp` lines `0xd5`, `0xd7`, `0xda`, `0xde`, `0xdf`, `0xe2`, `0xe4`, `0xe8`, and `0xec` inside its hidden topic tails, which confirms the keyword buffer is live dialogue state rather than a static per-NPC list.
+  - Hank's hidden topic map is now explicit: the multi-line `Mom` branch (`0x725` through `0x747`) seeds `0xd5`; `STEVE` (`0xd6`) plays `0x74f` / `0x753` / `0x758` / `0x75d` / `0x761` / `0x765` then seeds `0xd7`; `LOUSY RAT` / `RAT` (`0xd8` / `0xd9`) plays `0x76d` / `0x771` then seeds `0xda`; the `'LIGHTING OUT'` branch (`0xdb` / `0xdc`) plays `0x77b` / `0x77f` / `0x784` / `0x788` / `0x78e` / `0x793`, opens response menu `0xdd`, and then seeds `0xde` or `0xdf`; `DADDY` (`0xe1`) seeds `0xe2`; `GIRL` (`0xe3`) seeds `0xe4`; `TV` (`0xe6`) wraps `play_rangshot_fst_sequence` between `0x7fc`..`0x809` and `0x812`..`0x824`, then seeds `0xe8`; `COWBOY SHOW` (`0xe9`) plays `0x82d`..`0x84c` and opens response menu `0xea`; `SICK` (`0xeb`) seeds `0xec`; the `newspaper` / `fire` / `sentinel` group (`0xee` / `0xef` / `0xf0`) continues through `0x8a0` / `0x8a5` / `0x8a9` / `0x8af` / `0x8b3`; and `lodge` (`0xf1`) sets `get_set_discussed_lodge_topic` before `0x8bf` / `0x8c5` / `0x8c9` / `0x8cf` / `0x8d3`.
   - Raw disassembly inside `handle_talk_to_mom` shows additional inline `load_dialogue_response_line` -> copy-to-`g_dialogue_keyword_list_spec` blocks that the initial decompiler pass did not surface cleanly. The pre-menu seeds are now confirmed directly in code: intro `0x102`, same-day follow-up `0x104`, later default `0x105`, day-5 override `0x113`, and day-6 override `0x114`.
   - The same hidden Mom blocks also jump back to `run_dialogue_keyword_menu`, so her keyword flow is a looping dialogue state machine rather than a single-topic exchange. Confirmed keyword-buffer rewrites are `0x116->0x118`, `0x119->0x11a`, `0x11d->0x120`, `0x121->0x122`, `0x123->0x124`, `0x125..0x128->0x12a`, `0x12b->0x12c`, `0x12d->0x12e`, `0x12f->0x130`, `0x131..0x133->0x134`, `0x135->0x136`, `0x13a->0x13b`, `0x13c->0x13e`, `0x13f->0x140`, the non-`STEPH_MIDGAME_PLAYED` `0x141` branch -> `0x144`, `0x145..0x147->0x148`, `0x14b..0x14c->0x150`, `0x151..0x152` response-menu choice `2` -> `0x154`, and `0x15b->0x15c`.
   - Raw byte disassembly also recovered the hidden straight-line `play_dialogue_line` sequences that the earlier function listing truncated after the first visible call. Confirmed multi-line Mom exchanges now include `0x116`, `0x119`, `0x11d`, `0x121`, `0x125..0x128`, `0x12b`, `0x12d`, `0x12f`, `0x131..0x133`, `0x135`, `0x137`, `0x145..0x147`, `0x14a`, `0x155`, and `0x156`.
diff --git a/TRACKER.md b/TRACKER.md
index 7a3ea6bf19b..378edfc589e 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,12 +15,12 @@
 
 ## Last Confirmed Action
 
-- Corrected `handle_talk_to_herrill` so only zero-based `dialog.rsp[0x283]` is the direct BYE exit; zero-based `dialog.rsp[0x293]` once again follows the native `0x3099 -> 0x2ff4` path instead of being swallowed by a generic BYE string check.
-  - The matching native annotation on `handle_talk_to_herrill @ 0x2dc00` now records that `0x283` and `0x293` are distinct BYE branches and must not be collapsed together.
-  - The next corrective pass is the remaining keyword-loop / event-tail audit, starting with `handle_talk_to_hank`.
+- Rebuilt Hank's keyword loop from the corrected native flow, labeled the recovered topic tails at `0x34490`, `0x34775`, and `0x34b1b`, and replaced the old flat topic table with the native reseed/menu state machine.
+  - Hank now preserves the hidden `STEVE -> 0xd7`, `LOUSY RAT -> 0xda`, `'LIGHTING OUT' -> 0xdd -> 0xde/0xdf`, `DADDY -> 0xe2`, `GIRL -> 0xe4`, `TV -> RANGSHOT -> 0xe8`, `COWBOY SHOW -> 0xea`, `SICK -> 0xec`, `0xee/0xef/0xf0 -> 0x8a0..0x8b3`, and `lodge -> 0x8bf..0x8d3` loop paths instead of collapsing every handled topic through the generic `0xa32 -> 0x8dc` exit.
+  - The next corrective pass is the remaining keyword-loop / event-tail audit, starting with `handle_talk_to_mom`.
 
 ## Next Suggested Action
 
 1. Audit the remaining `handle_talk_to_*` ports against the corrected native disassembly.
-   - The highest-value immediate follow-up is `handle_talk_to_hank`, then the rest of the remaining `handle_talk_to_*` ports whose keyword loops or event tails may still be truncated by the old `play_dialogue_line` decompile breakage.
+   - The highest-value immediate follow-up is `handle_talk_to_mom`, then the rest of the remaining `handle_talk_to_*` ports whose keyword loops or event tails may still be truncated by the old `play_dialogue_line` decompile breakage.
 2. Apply any additional high-confidence Ghidra renames or comments that fall directly out of those audited handlers before each commit.
diff --git a/engines/harvester/npc/hank_dialogue.cpp b/engines/harvester/npc/hank_dialogue.cpp
index 9a35a6789c8..4f41ac4ffc4 100644
--- a/engines/harvester/npc/hank_dialogue.cpp
+++ b/engines/harvester/npc/hank_dialogue.cpp
@@ -23,12 +23,8 @@
 
 #include "harvester/npc/hank_dialogue.h"
 
-#include "common/array.h"
-#include "common/debug.h"
-#include "harvester/detection.h"
 #include "harvester/npc/dialogue_flags.h"
 #include "harvester/npc/dialogue_runtime.h"
-#include "harvester/startup_text.h"
 
 namespace Harvester {
 
@@ -36,52 +32,107 @@ namespace {
 
 static const char *const kDialogueRangshotFstPath = "GRAPHIC/FST/RANGSHOT.FST";
 
-struct HankDialogueTopicLine {
-	int responseLineIndex;
-	int wavId;
-	const char *speakerId;
-	bool setDiscussedLodgeTopic;
+static const DialogueLineEntry kHankMomTopicLines[] = {
+	{ 0x725, "HANK", 0 },
+	{ 0x729, "PC", 0 },
+	{ 0x72d, "HANK", 0 },
+	{ 0x733, "PC", 0 },
+	{ 0x737, "HANK", 0 },
+	{ 0x73b, "PC", 0 },
+	{ 0x741, "HANK", 0 },
+	{ 0x747, "MOM", 2 }
 };
 
-static const HankDialogueTopicLine kHankDialogueTopicLines[] = {
-	{ 0xd2, 0x725, "HANK", false },
-	{ 0xd6, 0x74f, "PC", false },
-	{ 0xd8, 0x76d, "PC", false },
-	{ 0xd9, 0x76d, "PC", false },
-	{ 0xdb, 0x77b, "PC", false },
-	{ 0xdc, 0x77b, "PC", false },
-	{ 0xe0, 0x7ad, "HANK", false },
-	{ 0xe1, 0x7b7, "PC", false },
-	{ 0xe3, 0x7d6, "PC", false },
-	{ 0xe5, 0x7f4, "HANK", false },
-	{ 0xe6, 0x7fc, "PC", false },
-	{ 0xe9, 0x82d, "PC", false },
-	{ 0xeb, 0x863, "PC", false },
-	{ 0xed, 0x881, "PC", false },
-	{ 0xee, 0x8a0, "HANK", false },
-	{ 0xef, 0x8a0, "HANK", false },
-	{ 0xf0, 0x8a0, "HANK", false },
-	{ 0xf1, 0x8bf, "HANK", true }
+static const DialogueLineEntry kHankSteveTopicLines[] = {
+	{ 0x74f, "PC", 0 },
+	{ 0x753, "HANK", 0 },
+	{ 0x758, "PC", 2 },
+	{ 0x75d, "HANK", 0 },
+	{ 0x761, "PC", 2 },
+	{ 0x765, "HANK", 0 }
 };
 
-static void splitDialogueMenuLine(const Common::String &line, Common::Array<Common::String> &parts) {
-	parts.clear();
-	if (line.empty())
-		return;
+static const DialogueLineEntry kHankLousyRatTopicLines[] = {
+	{ 0x76d, "PC", 0 },
+	{ 0x771, "HANK", 0 }
+};
 
-	Common::String token;
-	for (uint i = 0; i < line.size(); ++i) {
-		if (line[i] == '/') {
-			parts.push_back(token);
-			token.clear();
-			continue;
-		}
+static const DialogueLineEntry kHankLightingOutTopicLines[] = {
+	{ 0x77b, "PC", 0 },
+	{ 0x77f, "HANK", 0 },
+	{ 0x784, "PC", 2 },
+	{ 0x788, "HANK", 0 },
+	{ 0x78e, "PC", 2 },
+	{ 0x793, "HANK", 0 }
+};
 
-		token += line[i];
-	}
+static const DialogueLineEntry kHankDaddyTopicLines[] = {
+	{ 0x7b7, "PC", 0 },
+	{ 0x7bb, "HANK", 0 },
+	{ 0x7c0, "PC", 0 },
+	{ 0x7c4, "HANK", 0 },
+	{ 0x7ca, "PC", 0 },
+	{ 0x7ce, "HANK", 0 }
+};
 
-	parts.push_back(token);
-}
+static const DialogueLineEntry kHankGirlTopicLines[] = {
+	{ 0x7d6, "PC", 0 },
+	{ 0x7da, "HANK", 0 },
+	{ 0x7df, "PC", 0 },
+	{ 0x7e3, "HANK", 0 },
+	{ 0x7e8, "PC", 4 },
+	{ 0x7ec, "HANK", 0 }
+};
+
+static const DialogueLineEntry kHankTvTopicIntroLines[] = {
+	{ 0x7fc, "PC", 0 },
+	{ 0x801, "HANK", 0 },
+	{ 0x805, "PC", 0 },
+	{ 0x809, "HANK", 0 }
+};
+
+static const DialogueLineEntry kHankTvTopicOutroLines[] = {
+	{ 0x812, "HANK", 0 },
+	{ 0x816, "PC", 0 },
+	{ 0x81a, "HANK", 0 },
+	{ 0x81e, "PC", 0 },
+	{ 0x824, "HANK", 0 }
+};
+
+static const DialogueLineEntry kHankCowboyShowTopicLines[] = {
+	{ 0x82d, "PC", 0 },
+	{ 0x831, "HANK", 0 },
+	{ 0x835, "PC", 0 },
+	{ 0x839, "HANK", 0 },
+	{ 0x83d, "PC", 4 },
+	{ 0x842, "HANK", 0 },
+	{ 0x848, "HANK", 0 },
+	{ 0x84c, "HANK", 0 }
+};
+
+static const DialogueLineEntry kHankSickTopicLines[] = {
+	{ 0x863, "PC", 0 },
+	{ 0x867, "HANK", 0 },
+	{ 0x86b, "PC", 0 },
+	{ 0x86f, "HANK", 0 },
+	{ 0x873, "PC", 0 }
+};
+
+static const DialogueLineEntry kHankNewspaperFireSentinelTopicLines[] = {
+	{ 0x8a0, "HANK", 0 },
+	{ 0x8a5, "PC", 0 },
+	{ 0x8a9, "HANK", 0 },
+	{ 0x8af, "PC", 0 },
+	{ 0x8b3, "HANK", 0 }
+};
+
+static const DialogueLineEntry kHankLodgeTopicLines[] = {
+	{ 0x8bf, "HANK", 0 },
+	{ 0x8c5, "PC", 0 },
+	{ 0x8c9, "HANK", 0 },
+	{ 0x8cf, "PC", 0 },
+	{ 0x8d3, "HANK", 0 }
+};
 
 } // End of namespace
 
@@ -99,15 +150,13 @@ Common::Error HankDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		runtime.assignTopicBuffer(hankTopicBuffer, hankTopicBufferLineIndex,
 			responseLineIndex, "Hank topic buffer");
 	};
-
-	debugC(1, kDebugDialogue,
-		"Harvester: Hank dialogue start day=%d item='%s' initial=%d trackedDayValid=%d trackedDay=%d sameDayPending=%d rangshotPending=%d topicLine=%d topicBuffer='%s'",
-		currentStoryDayIndex, usedItemName.empty() ? "<none>" : usedItemName.c_str(),
-		(int)state.pendingInitialConversation,
-		(int)state.hasTrackedDayState, state.trackedDayIndex,
-		(int)state.pendingSameDayFollowup,
-		(int)state.pendingRangshotSequence,
-		hankTopicBufferLineIndex, hankTopicBuffer.c_str());
+	auto playSequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
+		return runtime.playDialogueEntrySequence(lines, count);
+	};
+	auto playLine = [&](int wavId, const char *speakerId = "HANK",
+			int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, speakerId, headVariant);
+	};
 
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
@@ -399,77 +448,160 @@ Common::Error HankDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 		if (selectedTopic.empty())
 			return Common::kNoError;
-		debugC(1, kDebugDialogue, "Harvester: Hank selected topic='%s'", selectedTopic.c_str());
-		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()))
-			return runtime.playDialogueLine(0x8dc, "HANK");
-
-		const Common::String momTopic = runtime.startupText().getDialogueResponseLine(0xd2);
-		Common::Array<Common::String> currentTopics;
-		splitDialogueMenuLine(hankTopicBuffer, currentTopics);
-		const bool matchesMomTopicByLine = !momTopic.empty() && selectedTopic.equalsIgnoreCase(momTopic);
-		const bool matchesMomTopicFromIntroBuffer = hankTopicBufferLineIndex == 0xc8 &&
-			!currentTopics.empty() && selectedTopic.equalsIgnoreCase(currentTopics[0]);
-		if (matchesMomTopicByLine || matchesMomTopicFromIntroBuffer) {
-			debugC(1, kDebugDialogue,
-				"Harvester: Hank matched special Mom branch '%s' (lineMatch=%d bufferMatch=%d)",
-				selectedTopic.c_str(), (int)matchesMomTopicByLine, (int)matchesMomTopicFromIntroBuffer);
-			const DialogueLineEntry lines[] = {
-				{ 0x725, "HANK", 0 },
-				{ 0x729, "PC", 0 },
-				{ 0x72d, "HANK", 0 },
-				{ 0x733, "PC", 0 },
-				{ 0x737, "HANK", 0 },
-				{ 0x73b, "PC", 0 },
-				{ 0x741, "HANK", 0 },
-				{ 0x747, "MOM", 2 }
-			};
-			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+		if (runtime.matchesResponseLine(selectedTopic, 0xd2)) {
+			Common::Error lineError = playSequence(
+				kHankMomTopicLines, ARRAYSIZE(kHankMomTopicLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			runtime.setActiveSpeakerPortrait("HANK", 0);
 			assignHankTopicBuffer(0xd5);
 			continue;
 		}
+		if (runtime.matchesResponseLine(selectedTopic, 0xd6)) {
+			Common::Error lineError = playSequence(
+				kHankSteveTopicLines, ARRAYSIZE(kHankSteveTopicLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignHankTopicBuffer(0xd7);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0xd8) ||
+				runtime.matchesResponseLine(selectedTopic, 0xd9)) {
+			Common::Error lineError = playSequence(
+				kHankLousyRatTopicLines, ARRAYSIZE(kHankLousyRatTopicLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignHankTopicBuffer(0xda);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0xdb) ||
+				runtime.matchesResponseLine(selectedTopic, 0xdc)) {
+			Common::Error lineError = playSequence(
+				kHankLightingOutTopicLines, ARRAYSIZE(kHankLightingOutTopicLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 
-		bool handledTopic = false;
-		for (const HankDialogueTopicLine &topic : kHankDialogueTopicLines) {
-			if (topic.responseLineIndex == 0xd2)
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0xdd, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1) {
+				lineError = playLine(0x79d);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignHankTopicBuffer(0xde);
 				continue;
-
-			const Common::String topicText =
-				runtime.startupText().getDialogueResponseLine(topic.responseLineIndex);
-			if (topicText.empty() || !selectedTopic.equalsIgnoreCase(topicText))
+			}
+			if (responseIndex == 2) {
+				lineError = playLine(0x7a4);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignHankTopicBuffer(0xdf);
 				continue;
+			}
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0xe0)) {
+			Common::Error lineError = playLine(0x7ad);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0xe1)) {
+			Common::Error lineError = playSequence(
+				kHankDaddyTopicLines, ARRAYSIZE(kHankDaddyTopicLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignHankTopicBuffer(0xe2);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0xe3)) {
+			Common::Error lineError = playSequence(
+				kHankGirlTopicLines, ARRAYSIZE(kHankGirlTopicLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignHankTopicBuffer(0xe4);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0xe5)) {
+			Common::Error lineError = playLine(0x7f4);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0xe6)) {
+			Common::Error lineError = playSequence(
+				kHankTvTopicIntroLines, ARRAYSIZE(kHankTvTopicIntroLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueRangshotFstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playSequence(
+				kHankTvTopicOutroLines, ARRAYSIZE(kHankTvTopicOutroLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignHankTopicBuffer(0xe8);
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0xe9)) {
+			Common::Error lineError = playSequence(
+				kHankCowboyShowTopicLines, ARRAYSIZE(kHankCowboyShowTopicLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 
-			if (topic.setDiscussedLodgeTopic)
-				sharedState.discussedLodgeTopic = true;
-
-			debugC(1, kDebugDialogue,
-				"Harvester: Hank matched topic '%s' to response line 0x%x (%d) -> wav=0x%x speaker='%s'",
-				selectedTopic.c_str(), topic.responseLineIndex, topic.responseLineIndex + 1,
-				topic.wavId, topic.speakerId);
-			Common::Error lineError = runtime.playDialogueLine(topic.wavId, topic.speakerId);
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0xea, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1) {
+				lineError = playLine(0x856);
+			} else if (responseIndex == 2) {
+				lineError = playLine(0x85a);
+			}
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			handledTopic = true;
-			break;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0xeb)) {
+			Common::Error lineError = playSequence(
+				kHankSickTopicLines, ARRAYSIZE(kHankSickTopicLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignHankTopicBuffer(0xec);
+			continue;
 		}
+		if (runtime.matchesResponseLine(selectedTopic, 0xed)) {
+			Common::Error lineError = playLine(0x881, "PC");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0xee) ||
+				runtime.matchesResponseLine(selectedTopic, 0xef) ||
+				runtime.matchesResponseLine(selectedTopic, 0xf0)) {
+			Common::Error lineError = playSequence(
+				kHankNewspaperFireSentinelTopicLines,
+				ARRAYSIZE(kHankNewspaperFireSentinelTopicLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0xf1)) {
+			sharedState.discussedLodgeTopic = true;
+			Common::Error lineError = playSequence(
+				kHankLodgeTopicLines, ARRAYSIZE(kHankLodgeTopicLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+		if (runtime.matchesResponseLine(selectedTopic, 0xf2))
+			return playLine(0x8dc);
 
-		Common::Error lineError = runtime.playDialogueLine(0xa32, "HANK");
+		Common::Error lineError = playLine(0xa32);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-
-		if (handledTopic) {
-			debugC(1, kDebugDialogue,
-				"Harvester: Hank handled topic '%s' and now falls through to generic response/exit",
-				selectedTopic.c_str());
-		} else {
-			debugC(1, kDebugDialogue,
-				"Harvester: Hank topic '%s' fell back to generic response and exits dialogue",
-				selectedTopic.c_str());
-		}
-
-		return runtime.playDialogueLine(0x8dc, "HANK");
+		return playLine(0x8dc);
 	}
 }
 


Commit: b89486bbb1e28e96dcf3b74df45132bfaf5a2935
    https://github.com/scummvm/scummvm/commit/b89486bbb1e28e96dcf3b74df45132bfaf5a2935
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:17+02:00

Commit Message:
HARVESTER: Correct sparky bye routing

Changed paths:
    TRACKER.md
    engines/harvester/npc/sparky_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 378edfc589e..b06b7f532bf 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,12 +15,12 @@
 
 ## Last Confirmed Action
 
-- Rebuilt Hank's keyword loop from the corrected native flow, labeled the recovered topic tails at `0x34490`, `0x34775`, and `0x34b1b`, and replaced the old flat topic table with the native reseed/menu state machine.
-  - Hank now preserves the hidden `STEVE -> 0xd7`, `LOUSY RAT -> 0xda`, `'LIGHTING OUT' -> 0xdd -> 0xde/0xdf`, `DADDY -> 0xe2`, `GIRL -> 0xe4`, `TV -> RANGSHOT -> 0xe8`, `COWBOY SHOW -> 0xea`, `SICK -> 0xec`, `0xee/0xef/0xf0 -> 0x8a0..0x8b3`, and `lodge -> 0x8bf..0x8d3` loop paths instead of collapsing every handled topic through the generic `0xa32 -> 0x8dc` exit.
-  - The next corrective pass is the remaining keyword-loop / event-tail audit, starting with `handle_talk_to_mom`.
+- Corrected `handle_talk_to_sparky` so only zero-based `dialog.rsp[0x2c3]` is the direct `0x39e` exit; zero-based `dialog.rsp[0x2ce]` once again follows the native silent-loop branch instead of being swallowed by a generic BYE string check.
+  - The matching native annotation on `handle_talk_to_sparky @ 0x39240` now records that `0x2c3` and `0x2ce` are distinct BYE branches and must not be collapsed together.
+  - The next corrective pass is the remaining audit of handlers that still looked suspicious after the larger rebuilds, starting with `handle_talk_to_mom`.
 
 ## Next Suggested Action
 
 1. Audit the remaining `handle_talk_to_*` ports against the corrected native disassembly.
-   - The highest-value immediate follow-up is `handle_talk_to_mom`, then the rest of the remaining `handle_talk_to_*` ports whose keyword loops or event tails may still be truncated by the old `play_dialogue_line` decompile breakage.
+   - The highest-value immediate follow-up is `handle_talk_to_mom`, then the remaining small-loop handlers (`wasp_woman`, `moynahan`, `stephanie`) to confirm whether any non-committed corrective tails are still left after the current rebuild set.
 2. Apply any additional high-confidence Ghidra renames or comments that fall directly out of those audited handlers before each commit.
diff --git a/engines/harvester/npc/sparky_dialogue.cpp b/engines/harvester/npc/sparky_dialogue.cpp
index 5db957f21ad..d762875d161 100644
--- a/engines/harvester/npc/sparky_dialogue.cpp
+++ b/engines/harvester/npc/sparky_dialogue.cpp
@@ -142,8 +142,7 @@ Common::Error SparkyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return menuError;
 		if (selectedTopic.empty())
 			return Common::kNoError;
-		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
-				runtime.matchesResponseLine(selectedTopic, 0x2c3)) {
+		if (runtime.matchesResponseLine(selectedTopic, 0x2c3)) {
 			return playSparkyLine(0x39e);
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x2c4) ||


Commit: 5411d3c7c571336555c6f81d5edb28bfd530478f
    https://github.com/scummvm/scummvm/commit/5411d3c7c571336555c6f81d5edb28bfd530478f
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:17+02:00

Commit Message:
HARVESTER: Correct stephanie bye routing

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/stephanie_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 7c6646224dc..c0977051839 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -488,7 +488,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - Stephanie's no-item opener is a three-stage day-gated ladder, not a simple intro/revisit split. The first visit seeds zero-based `dialog.rsp` line `0x2cf`; same-day revisits switch to `0x2d0`; later days run the first follow-up (`0x45a7` through `0x45e2` with zero-based `dialog.rsp` line `0x2d1`), then the second follow-up (`0x45e8` through `0x464e` with zero-based `dialog.rsp` lines `0x2d3` and `0x2d5`) before collapsing to the steady `0x4654` revisit line.
     - The late-game no-item tail is a strict one-shot chain: day 5 -> `0x46aa`..`0x46c9`; `BURNED_TV_STATION` -> response menu `0x2d7`; `SCRATCHED_TUCKER` -> response menus `0x2d9` / `0x2da`; `BOLT_OF_CLOTH_TAKEN` -> response menu `0x2db`; `BARBER_POLE_STOLEN` -> response menu `0x2dd`; `DINER_BURNED` -> response menus `0x2df` / `0x2e0` / `0x2e1`; `PC_ESCAPED_JAIL` -> response menu `0x2e3`; `KARIN_KIDNAPED` -> `0x4957`..`0x4969`; and the Karin outcome block starts on `0x4996`, can insert `0x49c1` when Dwayne's completed Karin-alive follow-up bit is set, and always exits through `0x49ea` / `0x49ef`.
     - The corrected raw-code tails show two hidden Stephanie continuations outside the current clean decompiler body.
-      - The `0x2f9..0x2fd` spyhole keyword branch does not return on `0x4a12`; it sets the local spyhole flag, continues through `0x4a19` or `0x4a1e` depending on whether Mr. Potts has already been confronted about the spyhole, then plays `0x4a23` and loops back into the keyword menu.
+      - The `0x2f9..0x2fd` spyhole keyword branch does not return on `0x4a12`; once the active keyword buffer has advanced to zero-based `dialog.rsp[0x2f8]`, that hidden continuation owns the later `BYE` text, sets the local spyhole flag, continues through `0x4a19` or `0x4a1e` depending on whether Mr. Potts has already been confronted about the spyhole, then plays `0x4a23` and loops back into the keyword menu. The earlier zero-based `dialog.rsp[0x2e4]` `BYE` remains the direct `0x46a4` exit for the pre-spyhole states.
       - The post-`KARIN_KIDNAPED` tail reuses that local spyhole flag to prepend `0x4970` / `0x4975`, optionally adds Mr. and Mrs. Potts's Tuesday-night alibi follow-ups (`0x4980` and `0x4984` / `0x4989` / `0x498f`), and only then checks the Karin-alive / Karin-dead payoff block.
 - The shared dialogue evidence/topic state shims behind those handlers are now partially explicit:
   - `get_set_shared_dialogue_state_d2e98` at `0x38230` is a neutral wrapper over `g_shared_dialogue_state_d2e98`; the only currently recovered gameplay caller is Herrill's `GASCAN` branch at `0x2e056`, where `eax = 1` / `edx = 0` makes it a read-only gate and a zero value collapses the post-`0x30d8` continuation to `PC 0x3123`. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path and save/load serialization.
diff --git a/TRACKER.md b/TRACKER.md
index b06b7f532bf..d5c82cc3f97 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,12 +15,14 @@
 
 ## Last Confirmed Action
 
-- Corrected `handle_talk_to_sparky` so only zero-based `dialog.rsp[0x2c3]` is the direct `0x39e` exit; zero-based `dialog.rsp[0x2ce]` once again follows the native silent-loop branch instead of being swallowed by a generic BYE string check.
-  - The matching native annotation on `handle_talk_to_sparky @ 0x39240` now records that `0x2c3` and `0x2ce` are distinct BYE branches and must not be collapsed together.
-  - The next corrective pass is the remaining audit of handlers that still looked suspicious after the larger rebuilds, starting with `handle_talk_to_mom`.
+- Audited `handle_talk_to_mom` against the corrected native control flow and confirmed the current rebuilt port already covers the recovered intro, Jimmy-absent, day-5, and keyword-tail continuations without needing another engine-side correction.
+- Corrected `handle_talk_to_stephanie` so the hidden zero-based `dialog.rsp[0x2f9..0x2fd]` spyhole continuation only takes over once the live keyword buffer has advanced to `dialog.rsp[0x2f8]`; the earlier zero-based `dialog.rsp[0x2e4]` `BYE` remains the direct `0x46a4` exit in the pre-spyhole states.
+  - The matching native annotation on `handle_talk_to_stephanie @ 0x36710` now needs to record that `0x2e4` and `0x2fd` are distinct BYE branches selected by control-flow stage, not a single generic BYE string check.
+  - The next corrective pass is the hidden `handle_talk_to_wasp_woman` tail, where the raw native range now confirms the current engine port missed the `0x4c00` intro continuation, the initial zero-based keyword seed `dialog.rsp[0x300]`, and the later `0x304` / `0x309` / `0x30b` topic-buffer rewrites.
 
 ## Next Suggested Action
 
-1. Audit the remaining `handle_talk_to_*` ports against the corrected native disassembly.
-   - The highest-value immediate follow-up is `handle_talk_to_mom`, then the remaining small-loop handlers (`wasp_woman`, `moynahan`, `stephanie`) to confirm whether any non-committed corrective tails are still left after the current rebuild set.
-2. Apply any additional high-confidence Ghidra renames or comments that fall directly out of those audited handlers before each commit.
+1. Rebuild `handle_talk_to_wasp_woman` from the newly recovered hidden native tail.
+   - The raw instruction range at `0x2ff20..0x3030b` confirms the intro-only `0x4c00` continuation, initial zero-based keyword seed `dialog.rsp[0x300]`, later keyword-buffer rewrites to `0x304`, `0x309`, and `0x30b`, the hidden `C027A.FST` playback after the `0x302` topic, and the explicit late `0x30e` farewell line `0x4c9a`.
+2. Revisit `handle_talk_to_moynahan` after the Wasp rebuild and decide whether the remaining `0x199` / `0x1c4` BYE ambiguity needs another native-stage correction or a runtime-side selection-context helper.
+3. Apply any additional high-confidence Ghidra renames or comments that fall directly out of those audited handlers before each commit.
diff --git a/engines/harvester/npc/stephanie_dialogue.cpp b/engines/harvester/npc/stephanie_dialogue.cpp
index 124b57d816f..4611c0632e0 100644
--- a/engines/harvester/npc/stephanie_dialogue.cpp
+++ b/engines/harvester/npc/stephanie_dialogue.cpp
@@ -661,10 +661,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return menuError;
 		if (selectedTopic.empty())
 			return Common::kNoError;
-		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
-				runtime.matchesResponseLine(selectedTopic, 0x2e4)) {
-			return playStephanieLine(0x46a4);
-		}
+		const bool inSpyholeKeywordState = stephanieTopicBufferLineIndex == 0x2f8;
 		if (runtime.matchesResponseLine(selectedTopic, 0x2e5) ||
 				runtime.matchesResponseLine(selectedTopic, 0x2e6)) {
 			Common::Error lineError = playStephanieLine(0x4406);
@@ -848,7 +845,9 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			assignStephanieTopicBuffer(0x2f8);
 			continue;
 		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x2f9) ||
+		if ((inSpyholeKeywordState &&
+				selectedTopic.equalsIgnoreCase(runtime.genericByeTopic())) ||
+				runtime.matchesResponseLine(selectedTopic, 0x2f9) ||
 				runtime.matchesResponseLine(selectedTopic, 0x2fa) ||
 				runtime.matchesResponseLine(selectedTopic, 0x2fb) ||
 				runtime.matchesResponseLine(selectedTopic, 0x2fc) ||
@@ -875,6 +874,11 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				return lineError;
 			continue;
 		}
+		if ((!inSpyholeKeywordState &&
+				selectedTopic.equalsIgnoreCase(runtime.genericByeTopic())) ||
+				runtime.matchesResponseLine(selectedTopic, 0x2e4)) {
+			return playStephanieLine(0x46a4);
+		}
 
 		Common::Error lineError = playStephanieLine(0x465c);
 		if (lineError.getCode() != Common::kNoError)


Commit: 5a369a90179353e67d6cd122e249965d600d0e2a
    https://github.com/scummvm/scummvm/commit/5a369a90179353e67d6cd122e249965d600d0e2a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:17+02:00

Commit Message:
HARVESTER: Rebuild wasp woman dialogue from native flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/wasp_woman_dialogue.cpp
    engines/harvester/npc/wasp_woman_dialogue.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index c0977051839..5a8f10a139f 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -490,6 +490,10 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - The corrected raw-code tails show two hidden Stephanie continuations outside the current clean decompiler body.
       - The `0x2f9..0x2fd` spyhole keyword branch does not return on `0x4a12`; once the active keyword buffer has advanced to zero-based `dialog.rsp[0x2f8]`, that hidden continuation owns the later `BYE` text, sets the local spyhole flag, continues through `0x4a19` or `0x4a1e` depending on whether Mr. Potts has already been confronted about the spyhole, then plays `0x4a23` and loops back into the keyword menu. The earlier zero-based `dialog.rsp[0x2e4]` `BYE` remains the direct `0x46a4` exit for the pre-spyhole states.
       - The post-`KARIN_KIDNAPED` tail reuses that local spyhole flag to prepend `0x4970` / `0x4975`, optionally adds Mr. and Mrs. Potts's Tuesday-night alibi follow-ups (`0x4980` and `0x4984` / `0x4989` / `0x498f`), and only then checks the Karin-alive / Karin-dead payoff block.
+  - `handle_talk_to_wasp_woman @ 0x2fde0` is now bounded enough for a direct startup-room port.
+    - The hidden no-item intro tail is larger than the clean decompiler body shows. The first visit clears the talk-state gate, plays `0x4bee`, optionally inserts the `0x4bf2` PC line when `get_set_wasp_woman_dialogue_state_d2f10` is set, continues through `0x4bf6` / `0x4bfc`, then adds the recovered `0x4c00` line before seeding the live keyword buffer with zero-based `dialog.rsp[0x300]`.
+    - Wasp Woman's keyword state persists across revisits. Later no-item visits only replay `0x4bee`; the live keyword buffer is whatever the prior branch last wrote, and the raw hidden tail now confirms the concrete rewrites: `0x302` expands into `0x4c0a` / `0x4c13` / `0x4c17`, plays `C027A.FST`, continues through `0x4c25` / `0x4c29`, then rewrites the buffer to zero-based `dialog.rsp[0x304]`; `0x308` rewrites to `0x309`; and `0x30a` plays `0x4c74` / `0x4c7f` before rewriting to `0x30b`.
+    - The hidden loop also clarifies the ambiguous BYE handling. In the initial `dialog.rsp[0x300]` state, the synthetic `BYE` selection exits silently through the early `dialog.rsp[0x301]` compare. Once the later keyword states are active, the zero-based `dialog.rsp[0x30e]` `BYE` loops silently back into the keyword menu instead, and only an unmatched late typed topic falls through to `0x4c9a`.
 - The shared dialogue evidence/topic state shims behind those handlers are now partially explicit:
   - `get_set_shared_dialogue_state_d2e98` at `0x38230` is a neutral wrapper over `g_shared_dialogue_state_d2e98`; the only currently recovered gameplay caller is Herrill's `GASCAN` branch at `0x2e056`, where `eax = 1` / `edx = 0` makes it a read-only gate and a zero value collapses the post-`0x30d8` continuation to `PC 0x3123`. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path and save/load serialization.
   - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
@@ -516,7 +520,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - Johnson writes that shared bit on both the primary intro branch (`0xa86` through `0xa9b`) and the `KARIN_KIDNAPED` follow-up (`0xbff` / `0xc06`).
   - `get_set_wasp_woman_dialogue_state_d2f10` at `0x38420` is a neutral wrapper over `g_wasp_woman_mom_dialogue_state_d2f10`; a program-wide linear call scan currently only finds two call sites, both inside hidden `handle_talk_to_wasp_woman` code at `0x2ff4e` and `0x3010f`.
   - On Wasp Woman's no-item intro path, that helper is called between `0x4bee` and the optional `0x4bf2` PC reply before the fixed `0x4bf6` / `0x4bfc` exchange.
-  - On Wasp Woman's hidden `0x305` keyword branch, the same helper is called after `0x4c31`; if it returns nonzero, the branch opens response line `0x307`, where choice `1` plays `0x4c4d` / `0x4c53` and choice `2` plays `0x4c5e`.
+  - On Wasp Woman's hidden `0x305` keyword branch, the same helper is called after `0x4c31`; if it returns nonzero, the branch opens response line `0x307`, where choice `1` plays `0x4c4d` / `0x4c53` / `0x4c57` and choice `2` plays `0x4c5e`.
   - `play_c008_prelude_fst` at `0x388c0` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C123.FST", 0)`; both normal xrefs and a full linear call scan currently show no direct callers, so it remains an orphan FST helper for now.
   - `get_set_discussed_mr_potts_tuesday_night_alibi` at `0x38440` is set on the hidden `handle_talk_to_mr_potts` Tuesday-night branch and later read by Johnson and Stephanie; Stephanie's hidden `0x4980` line, "Pottsdam lied. He said he stayed home Tuesday night.", makes the semantics explicit.
   - `get_set_discussed_mrs_potts_tuesday_night_alibi` at `0x38460` is set on the hidden `handle_talk_to_mrs_potts` Tuesday-night branch and later read by Stephanie; the hidden `0x4984` / `0x4989` / `0x498f` exchange anchors it to Mrs. Potts's version of Pottsdam's Tuesday-night alibi.
diff --git a/TRACKER.md b/TRACKER.md
index d5c82cc3f97..c5ad1f27ab5 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -17,12 +17,14 @@
 
 - Audited `handle_talk_to_mom` against the corrected native control flow and confirmed the current rebuilt port already covers the recovered intro, Jimmy-absent, day-5, and keyword-tail continuations without needing another engine-side correction.
 - Corrected `handle_talk_to_stephanie` so the hidden zero-based `dialog.rsp[0x2f9..0x2fd]` spyhole continuation only takes over once the live keyword buffer has advanced to `dialog.rsp[0x2f8]`; the earlier zero-based `dialog.rsp[0x2e4]` `BYE` remains the direct `0x46a4` exit in the pre-spyhole states.
-  - The matching native annotation on `handle_talk_to_stephanie @ 0x36710` now needs to record that `0x2e4` and `0x2fd` are distinct BYE branches selected by control-flow stage, not a single generic BYE string check.
-  - The next corrective pass is the hidden `handle_talk_to_wasp_woman` tail, where the raw native range now confirms the current engine port missed the `0x4c00` intro continuation, the initial zero-based keyword seed `dialog.rsp[0x300]`, and the later `0x304` / `0x309` / `0x30b` topic-buffer rewrites.
+- Rebuilt `handle_talk_to_wasp_woman` from the recovered hidden tail at `0x2ff20..0x3030b`.
+  - The engine port now restores the intro-only `0x4c00` continuation, keeps Wasp Woman's keyword-buffer stage persistent across revisits, and applies the native `0x300` -> `0x304` / `0x309` / `0x30b` buffer rewrites plus the hidden `C027A.FST` playback after the `0x302` topic.
+  - The raw native annotation on `handle_talk_to_wasp_woman @ 0x2fde0` now needs to record the real late-loop behavior: the initial `dialog.rsp[0x301]` `BYE` exits silently, the later `dialog.rsp[0x30e]` `BYE` loops silently, and only the fallback path plays `0x4c9a`.
+  - The next corrective pass is the remaining `handle_talk_to_moynahan` BYE ambiguity around zero-based `dialog.rsp[0x199]` and `dialog.rsp[0x1c4]`.
 
 ## Next Suggested Action
 
-1. Rebuild `handle_talk_to_wasp_woman` from the newly recovered hidden native tail.
-   - The raw instruction range at `0x2ff20..0x3030b` confirms the intro-only `0x4c00` continuation, initial zero-based keyword seed `dialog.rsp[0x300]`, later keyword-buffer rewrites to `0x304`, `0x309`, and `0x30b`, the hidden `C027A.FST` playback after the `0x302` topic, and the explicit late `0x30e` farewell line `0x4c9a`.
-2. Revisit `handle_talk_to_moynahan` after the Wasp rebuild and decide whether the remaining `0x199` / `0x1c4` BYE ambiguity needs another native-stage correction or a runtime-side selection-context helper.
-3. Apply any additional high-confidence Ghidra renames or comments that fall directly out of those audited handlers before each commit.
+1. Revisit `handle_talk_to_moynahan` and resolve the remaining zero-based `dialog.rsp[0x199]` / `dialog.rsp[0x1c4]` BYE ambiguity against the raw native loop.
+   - The recovered hidden compare at `0x25ef3` proves `0x1c4` is still live in the native loop, but the current engine port still lacks enough selection-context information to distinguish that silent-loop branch from the earlier `0x199` farewell path by text alone.
+2. Decide whether Moynahan can be corrected with a handler-local state reconstruction or whether the dialogue runtime now needs a small selection-context helper to preserve duplicate-topic control-flow stages.
+3. Apply any additional high-confidence Ghidra renames or comments that fall directly out of the remaining audit before the next commit.
diff --git a/engines/harvester/npc/wasp_woman_dialogue.cpp b/engines/harvester/npc/wasp_woman_dialogue.cpp
index 14c195681a8..0c0e006c977 100644
--- a/engines/harvester/npc/wasp_woman_dialogue.cpp
+++ b/engines/harvester/npc/wasp_woman_dialogue.cpp
@@ -30,6 +30,8 @@ namespace Harvester {
 
 namespace {
 
+static const char *const kDialogueC027AFstPath = "GRAPHIC/FST/C027A.FST";
+
 static const DialogueLineEntry kWaspWomanWhaleyLines[] = {
 	{ 0x4ca6, "WASP_WOMAN", 0 },
 	{ 0x4cae, "WASP_WOMAN", 1 },
@@ -42,12 +44,29 @@ static const DialogueLineEntry kWaspWomanIntroTailLines[] = {
 	{ 0x4bfc, "PC", 0 }
 };
 
+static const DialogueLineEntry kWaspWomanTopic302PreludeLines[] = {
+	{ 0x4c0a, "WASP_WOMAN", 0 },
+	{ 0x4c13, "PC", 0 },
+	{ 0x4c17, "WASP_WOMAN", 2 }
+};
+
+static const DialogueLineEntry kWaspWomanTopic302PostFstLines[] = {
+	{ 0x4c25, "WASP_WOMAN", 1 },
+	{ 0x4c29, "WASP_WOMAN", 1 }
+};
+
 static const DialogueLineEntry kWaspWomanTopic305Response1Lines[] = {
 	{ 0x4c4d, "WASP_WOMAN", 1 },
-	{ 0x4c53, "WASP_WOMAN", 1 }
+	{ 0x4c53, "WASP_WOMAN", 1 },
+	{ 0x4c57, "WASP_WOMAN", 2 }
+};
+
+static const DialogueLineEntry kWaspWomanTopic30aLines[] = {
+	{ 0x4c74, "WASP_WOMAN", 1 },
+	{ 0x4c7f, "WASP_WOMAN", 1 }
 };
 
-static const int kWaspWomanTopicBufferResponseLine = 0x301;
+static const int kWaspWomanInitialTopicBufferResponseLine = 0x300;
 
 } // End of namespace
 
@@ -59,8 +78,9 @@ Common::Error WaspWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	WaspWomanRoomDialogueState &state = _state;
 	Common::String waspWomanTopicBuffer;
-	int waspWomanTopicBufferLineIndex = -1;
+	int waspWomanTopicBufferLineIndex = state.topicBufferLineIndex;
 	auto assignWaspWomanTopicBuffer = [&](int responseLineIndex) {
+		state.topicBufferLineIndex = responseLineIndex;
 		runtime.assignTopicBuffer(waspWomanTopicBuffer, waspWomanTopicBufferLineIndex,
 			responseLineIndex, "Wasp Woman topic buffer");
 	};
@@ -105,30 +125,48 @@ Common::Error WaspWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			ARRAYSIZE(kWaspWomanIntroTailLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+		lineError = playWaspWomanLine(0x4c00, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignWaspWomanTopicBuffer(kWaspWomanInitialTopicBufferResponseLine);
+	} else {
+		Common::Error lineError = playWaspWomanLine(0x4bee, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignWaspWomanTopicBuffer(state.topicBufferLineIndex);
 	}
 
-	Common::Error lineError = playWaspWomanLine(0x4bee, 0);
-	if (lineError.getCode() != Common::kNoError)
-		return lineError;
-	assignWaspWomanTopicBuffer(kWaspWomanTopicBufferResponseLine);
-
 	for (;;) {
 		Common::String selectedTopic;
 		Common::Error menuError = runtime.runKeywordMenu(
 			waspWomanTopicBuffer, waspWomanTopicBufferLineIndex, selectedTopic);
 		if (menuError.getCode() != Common::kNoError)
 			return menuError;
-		if (selectedTopic.empty() || selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()))
+		if (selectedTopic.empty())
 			return Common::kNoError;
+		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic())) {
+			if (waspWomanTopicBufferLineIndex == kWaspWomanInitialTopicBufferResponseLine)
+				return Common::kNoError;
+			continue;
+		}
 
 		if (runtime.matchesResponseLine(selectedTopic, 0x302)) {
-			lineError = playWaspWomanLine(0x4c0a, 0);
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kWaspWomanTopic302PreludeLines, ARRAYSIZE(kWaspWomanTopic302PreludeLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC027AFstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueEntrySequence(
+				kWaspWomanTopic302PostFstLines, ARRAYSIZE(kWaspWomanTopic302PostFstLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			assignWaspWomanTopicBuffer(0x304);
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x305)) {
-			lineError = playWaspWomanLine(0x4c31, 0);
+			Common::Error lineError = playWaspWomanLine(0x4c31, 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			if (!sharedState.waspWomanDialogueState)
@@ -156,29 +194,30 @@ Common::Error WaspWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x308)) {
-			lineError = playWaspWomanLine(0x4c67, 0);
+			Common::Error lineError = playWaspWomanLine(0x4c67, 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			assignWaspWomanTopicBuffer(0x309);
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x30a)) {
-			lineError = playWaspWomanLine(0x4c74, 1);
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kWaspWomanTopic30aLines, ARRAYSIZE(kWaspWomanTopic30aLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			assignWaspWomanTopicBuffer(0x30b);
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x30c)) {
-			lineError = playWaspWomanLine(0x4c85, 0);
+			Common::Error lineError = playWaspWomanLine(0x4c85, 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			continue;
 		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x30e)) {
-			lineError = playWaspWomanLine(0x4c9a, 0);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			return Common::kNoError;
-		}
+		Common::Error lineError = playWaspWomanLine(0x4c9a, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		return Common::kNoError;
 	}
 }
 
diff --git a/engines/harvester/npc/wasp_woman_dialogue.h b/engines/harvester/npc/wasp_woman_dialogue.h
index a8a3d85df48..b3b9749225d 100644
--- a/engines/harvester/npc/wasp_woman_dialogue.h
+++ b/engines/harvester/npc/wasp_woman_dialogue.h
@@ -30,6 +30,7 @@ class WaspWomanDialogueHandler : public NpcDialogueHandler {
 public:
 	struct WaspWomanRoomDialogueState {
 		bool introPending = true;
+		int topicBufferLineIndex = 0x300;
 	};
 
 	bool matchesNpc(const Common::String &npcName) const override;


Commit: 9c46ecd88b6a9e2545f87ca093a5d2478f0b232f
    https://github.com/scummvm/scummvm/commit/9c46ecd88b6a9e2545f87ca093a5d2478f0b232f
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:18+02:00

Commit Message:
HARVESTER: Close out dialogue corrective audit

Changed paths:
    ARCHITECTURE.md
    TRACKER.md


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 5a8f10a139f..f47f86ca7de 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -482,7 +482,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `IF_TRY_TO_TAKE_THE_GLUE` is a strict early interrupt branch: it always plays `0x3e4d` / `0x3e52` / `0x3e56`, then either exits on `0x3e76` / `0x3e7a` or, once the slashes-on-body topic has been unlocked, continues through `0x3e5b` / `0x3e60` / `0x3e64` / `0x3e69` / `0x3e6d` / `0x3e71`.
     - The first no-item visit clears the Moynahan-local intro gate and plays the fixed six-line opener `0x3d33` / `0x3d37` / `0x3d3b` / `0x3d40` / `0x3d45` / `0x3d47`; later visits start on `0x3fd1`, can inject the slashes-on-body topic into the live keyword buffer through zero-based `dialog.rsp` lines `0x194` / `0x195`, can re-arm the intro gate after the shared casket-photo evidence is discussed, and then run the `HAVE_BOTH_LEDGERS`, `STEPH_MIDGAME_PLAYED`, day-5, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, Butcher-death, Jimmy-death, and `KARIN_FOUND_DEAD` follow-ups in that priority order.
     - The item path has three real response-menu branches: `CASKET_PHOTO` / `CASKET_PHOTOCOPY` uses zero-based `dialog.rsp` lines `0x18c` and `0x18d`, returns the presented photo to `RAH`, awards `GLUE`, and sets `JUST_GOT_GLUE`; `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` uses zero-based `dialog.rsp` line `0x18e`; and note/checkbook evidence uses zero-based `dialog.rsp` line `0x190`. `PHOTO_OF_WHALEY_HERRILL`, `WED_MATCHES`, and the fallback item path stay linear.
-    - Moynahan's keyword loop is typed-topic driven. Zero-based `dialog.rsp` line `0x199` is the explicit farewell topic behind `0x3e32`; `0x19a` / `0x19b` set the shared Mom day-5 good-cause state before `0x3d5f`; `0x19d..0x1a0`, `0x1a2..0x1a3`, and `0x1a9..0x1ab` each converge on single response lines; `0x1b2..0x1b3` is the day-5-only branch that writes the recovered shared `d2ec0` / `d2ec8` bits before `0x3df5`; `0x1b4..0x1b5` is room-specific to `MBLM`; `0x1b8..0x1be` and `0x1bf..0x1c2` are also day-5 gated; `0x1c4` loops silently on the current menu; everything else falls back to `0x3e39`.
+    - Moynahan's keyword loop is typed-topic driven. Zero-based `dialog.rsp` line `0x199` is the observable farewell path behind `0x3e32`; `0x19a` / `0x19b` set the shared Mom day-5 good-cause state before `0x3d5f`; `0x19d..0x1a0`, `0x1a2..0x1a3`, and `0x1a9..0x1ab` each converge on single response lines; `0x1b2..0x1b3` is the day-5-only branch that writes the recovered shared `d2ec0` / `d2ec8` bits before `0x3df5`; `0x1b4..0x1b5` is room-specific to `MBLM`; and `0x1b8..0x1be` plus `0x1bf..0x1c2` are also day-5 gated.
+    - The later hidden compare against zero-based `dialog.rsp[0x1c4]` is still present at `0x25ef3`, but the shipped `dialog.rsp` gives both `0x199` and `0x1c4` the same literal `BYE` bytes. Because the `0x1c4` match only jumps back into the top-of-loop `0x199` test, it does not produce a distinct engine-visible branch beyond the already recovered `0x3e32` farewell.
   - `handle_talk_to_stephanie @ 0x36710` is now bounded enough for a direct startup-room port.
     - The item path is not purely linear: note/checkbook evidence stays on `0x466a`, the casket-photo path now reads the recovered shared `d2ec0` and `d2ebc` bits between `0x4675` and `0x4684`, and the dual-ledger case remains the fixed `0x468f` / `0x4694` / `0x469a` / `0x469e` exchange.
     - Stephanie's no-item opener is a three-stage day-gated ladder, not a simple intro/revisit split. The first visit seeds zero-based `dialog.rsp` line `0x2cf`; same-day revisits switch to `0x2d0`; later days run the first follow-up (`0x45a7` through `0x45e2` with zero-based `dialog.rsp` line `0x2d1`), then the second follow-up (`0x45e8` through `0x464e` with zero-based `dialog.rsp` lines `0x2d3` and `0x2d5`) before collapsing to the steady `0x4654` revisit line.
diff --git a/TRACKER.md b/TRACKER.md
index c5ad1f27ab5..b2af518f7d6 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -20,11 +20,12 @@
 - Rebuilt `handle_talk_to_wasp_woman` from the recovered hidden tail at `0x2ff20..0x3030b`.
   - The engine port now restores the intro-only `0x4c00` continuation, keeps Wasp Woman's keyword-buffer stage persistent across revisits, and applies the native `0x300` -> `0x304` / `0x309` / `0x30b` buffer rewrites plus the hidden `C027A.FST` playback after the `0x302` topic.
   - The raw native annotation on `handle_talk_to_wasp_woman @ 0x2fde0` now needs to record the real late-loop behavior: the initial `dialog.rsp[0x301]` `BYE` exits silently, the later `dialog.rsp[0x30e]` `BYE` loops silently, and only the fallback path plays `0x4c9a`.
-  - The next corrective pass is the remaining `handle_talk_to_moynahan` BYE ambiguity around zero-based `dialog.rsp[0x199]` and `dialog.rsp[0x1c4]`.
+- Audited `handle_talk_to_moynahan` against the fully disassembled raw loop at `0x2563c..0x25f1d`.
+  - The hidden zero-based `dialog.rsp[0x1c4]` compare at `0x25ef3` is real, but the shipped resource gives both `0x199` and `0x1c4` the same literal `BYE` bytes. The `0x1c4` match only jumps back into the top-of-loop `0x199` test, so it does not create another distinct engine-visible branch beyond the already ported `0x3e32` farewell.
+  - That closes the outstanding `handle_talk_to_*` corrective-pass audit for the current dialogue subsystem sweep.
 
 ## Next Suggested Action
 
-1. Revisit `handle_talk_to_moynahan` and resolve the remaining zero-based `dialog.rsp[0x199]` / `dialog.rsp[0x1c4]` BYE ambiguity against the raw native loop.
-   - The recovered hidden compare at `0x25ef3` proves `0x1c4` is still live in the native loop, but the current engine port still lacks enough selection-context information to distinguish that silent-loop branch from the earlier `0x199` farewell path by text alone.
-2. Decide whether Moynahan can be corrected with a handler-local state reconstruction or whether the dialogue runtime now needs a small selection-context helper to preserve duplicate-topic control-flow stages.
-3. Apply any additional high-confidence Ghidra renames or comments that fall directly out of the remaining audit before the next commit.
+1. Pick the next confirmed dialogue-analysis thread outside the completed `handle_talk_to_*` corrective-pass sweep.
+   - The talk-handler audit is now closed for the currently suspicious ports (`mom`, `stephanie`, `wasp_woman`, and `moynahan`), so the next useful step should come from another confirmed native gap rather than another speculative BYE hunt.
+2. Apply any additional high-confidence Ghidra renames or comments that fall directly out of that next confirmed thread before the next commit.


Commit: ddcd72f6074ef9c34d07fc041f58b1b174aac8ab
    https://github.com/scummvm/scummvm/commit/ddcd72f6074ef9c34d07fc041f58b1b174aac8ab
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:18+02:00

Commit Message:
HARVESTER: Restore movie display mode for dialogue fst

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/fst_player.cpp
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index f47f86ca7de..3d9d244430e 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -178,6 +178,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `printf_ascii` prints the copyright line through `g_stdout_stream` after the `puts_ascii` version banner.
     - `set_random_seed` seeds `g_random_seed` from the first startup tick, and `next_random_value` is the 15-bit LCG step used by script/random branches.
     - `configure_video_surface` swaps `g_video_surface_context` between the normal gameplay surface and the narrower FST playback surface before the movie path hands off to `run_fst_sequence_player`.
+    - `play_fst_sequence` at `0x72550` is the wrapper that makes that swap visible to the rest of the engine: when a movie is triggered from the gameplay/UI side it pauses music, clears pending keypress state, switches the active surface back to `320x200x8` through `configure_video_surface(&g_video_surface_context, 0x140, 8, 1)`, runs the decoded FST player, then restores the `640`-wide gameplay surface with `configure_video_surface(&g_video_surface_context, 0x280)`, reapplies the gameplay palette, resets mouse bounds, and resumes music.
   - The video backend records behind that selector are now bounded as one linear VGA descriptor plus four 8bpp banked VESA descriptors:
     - `g_vga_mode_320x200_8bpp`
     - `g_vesa_mode_640x400_8bpp`
@@ -1098,7 +1099,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `run_fst_sequence_player` at `0x12b00` is the inner FST decoder/player reached from `play_fst_sequence`.
     - `FstFileHeader` is now a confirmed 0x20-byte header with fields `magic`, `width`, `height`, `max_frame_size`, `frame_count`, `frame_rate`, `sample_rate`, and `bits_per_sample`.
     - `FstFrameIndexEntry` is the 6-byte per-frame index record `{ video_size, audio_size }`; `run_fst_sequence_player` reads `frame_count` of them immediately after the header and then reads `video_size + audio_size` bytes for each frame.
-    - It configures `g_video_surface_context` from the header width/height at 8bpp backend class `1`, seeds PCM playback from the header sample rate / bits per sample, computes `bytes_per_frame = get_pcm_byte_rate(...) / frame_rate`, and uses `get_sound_state_playback_position` to keep movie frames aligned with the loaded soundtrack.
+    - It assumes the wrapper has already switched back to the movie-capable surface, then configures `g_video_surface_context` again from the header width/height at 8bpp backend class `1`, seeds PCM playback from the header sample rate / bits per sample, computes `bytes_per_frame = get_pcm_byte_rate(...) / frame_rate`, and uses `get_sound_state_playback_position` to keep movie frames aligned with the loaded soundtrack.
     - The frame loop queues each frame's PCM chunk before decode/blit and then waits until the cumulative playback cursor reaches the current frame's byte target; timer pacing is only the silent fallback.
     - `initialize_vesa_banked_mode` precomputes the banked-surface row tables that the movie decoder relies on:
       - `g_vesa_scanline_start_bank_indices` at `0xc7fca` gives the starting write-window bank for each possible scanline
diff --git a/TRACKER.md b/TRACKER.md
index b2af518f7d6..600df72a5c9 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,17 +15,12 @@
 
 ## Last Confirmed Action
 
-- Audited `handle_talk_to_mom` against the corrected native control flow and confirmed the current rebuilt port already covers the recovered intro, Jimmy-absent, day-5, and keyword-tail continuations without needing another engine-side correction.
-- Corrected `handle_talk_to_stephanie` so the hidden zero-based `dialog.rsp[0x2f9..0x2fd]` spyhole continuation only takes over once the live keyword buffer has advanced to `dialog.rsp[0x2f8]`; the earlier zero-based `dialog.rsp[0x2e4]` `BYE` remains the direct `0x46a4` exit in the pre-spyhole states.
-- Rebuilt `handle_talk_to_wasp_woman` from the recovered hidden tail at `0x2ff20..0x3030b`.
-  - The engine port now restores the intro-only `0x4c00` continuation, keeps Wasp Woman's keyword-buffer stage persistent across revisits, and applies the native `0x300` -> `0x304` / `0x309` / `0x30b` buffer rewrites plus the hidden `C027A.FST` playback after the `0x302` topic.
-  - The raw native annotation on `handle_talk_to_wasp_woman @ 0x2fde0` now needs to record the real late-loop behavior: the initial `dialog.rsp[0x301]` `BYE` exits silently, the later `dialog.rsp[0x30e]` `BYE` loops silently, and only the fallback path plays `0x4c9a`.
-- Audited `handle_talk_to_moynahan` against the fully disassembled raw loop at `0x2563c..0x25f1d`.
-  - The hidden zero-based `dialog.rsp[0x1c4]` compare at `0x25ef3` is real, but the shipped resource gives both `0x199` and `0x1c4` the same literal `BYE` bytes. The `0x1c4` match only jumps back into the top-of-loop `0x199` test, so it does not create another distinct engine-visible branch beyond the already ported `0x3e32` farewell.
-  - That closes the outstanding `handle_talk_to_*` corrective-pass audit for the current dialogue subsystem sweep.
+- Revisited dialogue-triggered FST playback from the native movie path and confirmed the display-surface mismatch.
+  - `play_fst_sequence @ 0x72550` does not leave gameplay/dialogue playback on the active `640`-wide UI surface. It pauses music, switches back to the `320x200x8` movie surface with `configure_video_surface(&g_video_surface_context, 0x140, 8, 1)`, runs the decoded FST player, then restores the `640`-wide gameplay surface with `configure_video_surface(&g_video_surface_context, 0x280)` before reapplying the palette and mouse bounds.
+  - The engine-side `FstPlayer` now mirrors that wrapper behavior, so dialogue FST files and other in-game movie playback no longer stay quarter-sized on the live `640x480` gameplay display.
 
 ## Next Suggested Action
 
-1. Pick the next confirmed dialogue-analysis thread outside the completed `handle_talk_to_*` corrective-pass sweep.
-   - The talk-handler audit is now closed for the currently suspicious ports (`mom`, `stephanie`, `wasp_woman`, and `moynahan`), so the next useful step should come from another confirmed native gap rather than another speculative BYE hunt.
+1. Exercise the remaining in-game FST callers against the shared movie-surface switch and confirm whether any of them rely on an additional redraw step after playback returns to the `640`-wide gameplay UI.
+   - The native wrapper now bounds the surface swap itself, but the surrounding caller-side redraw expectations still need a quick pass across dialogue-triggered clips and death-FST playback.
 2. Apply any additional high-confidence Ghidra renames or comments that fall directly out of that next confirmed thread before the next commit.
diff --git a/engines/harvester/fst_player.cpp b/engines/harvester/fst_player.cpp
index 476c07c958b..ca7a8719619 100644
--- a/engines/harvester/fst_player.cpp
+++ b/engines/harvester/fst_player.cpp
@@ -352,6 +352,14 @@ bool FstPlayer::play(const Common::String &path) {
 	byte palette[256 * 3] = { 0 };
 	g_system->getPaletteManager()->setPalette(palette, 0, 256);
 
+	const bool shouldSwitchToMovieDisplay =
+		header.width == 320 && header.height == 200 &&
+		(_vm.getDisplayWidth() != 320 || _vm.getDisplayHeight() != 200);
+	const int previousDisplayWidth = _vm.getDisplayWidth();
+	const int previousDisplayHeight = _vm.getDisplayHeight();
+	if (shouldSwitchToMovieDisplay)
+		_vm.setDisplayMode(320, 200);
+
 	const bool resumeMusicAfterPlayback = _vm.isStartupMusicPlaying();
 	if (resumeMusicAfterPlayback)
 		_vm.pauseStartupMusic(true);
@@ -487,6 +495,8 @@ bool FstPlayer::play(const Common::String &path) {
 	delete audioStream;
 	if (resumeMusicAfterPlayback)
 		_vm.pauseStartupMusic(false);
+	if (shouldSwitchToMovieDisplay)
+		_vm.setDisplayMode(previousDisplayWidth, previousDisplayHeight);
 
 	return !_vm.shouldQuit();
 }
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index e79c8219678..49ac5b3a518 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -415,6 +415,8 @@ void HarvesterEngine::setDisplayMode(int width, int height) {
 
 	initGraphics(width, height);
 	_screen = new Graphics::Screen();
+	_displayWidth = width;
+	_displayHeight = height;
 	debugC(1, kDebugGeneral, "Harvester: switched display mode to %dx%d", width, height);
 }
 
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 2a4c4dd7c95..032f1fa902c 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -75,9 +75,12 @@ public:
 	ResourceManager *getResources() const { return _resources; }
 	RuntimeEntityManager *getRuntimeEntities() const { return _runtimeEntities; }
 	Graphics::Screen *getScreen() const { return _screen; }
+	int getDisplayWidth() const { return _displayWidth; }
+	int getDisplayHeight() const { return _displayHeight; }
 	StartupScript *getStartupScript() const { return _startupScript; }
 	StartupArt *getStartupArt() const { return _startupArt; }
 	StartupText *getStartupText() const { return _startupText; }
+	void setDisplayMode(int width, int height);
 	bool isGoreEnabled() const;
 	const Common::String &getStartupMusicPath() const { return _startupMusicPath; }
 	bool isStartupMusicPlaying() const;
@@ -98,7 +101,6 @@ public:
 	void stopStartupSound();
 
 private:
-	void setDisplayMode(int width, int height);
 	void stopStartupSoundHandle(Audio::SoundHandle &handle);
 	bool validateStartupLoadedSoundSlot(int slot) const;
 
@@ -115,6 +117,8 @@ private:
 	Common::Array<byte> _startupLoadedSoundData[4];
 	Audio::SoundHandle _startupLoadedSoundHandles[4];
 	Graphics::Screen *_screen = nullptr;
+	int _displayWidth = 0;
+	int _displayHeight = 0;
 	ResourceManager *_resources = nullptr;
 	RuntimeEntityManager *_runtimeEntities = nullptr;
 	StartupScript *_startupScript = nullptr;


Commit: 999ca2355fd8c9fcd7b18e8abe5977fb05fb39a8
    https://github.com/scummvm/scummvm/commit/999ca2355fd8c9fcd7b18e8abe5977fb05fb39a8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:18+02:00

Commit Message:
HARVESTER: Refresh dialogue screen after fst playback

Changed paths:
    TRACKER.md
    engines/harvester/dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 600df72a5c9..3dee7d0e6de 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,12 +15,12 @@
 
 ## Last Confirmed Action
 
-- Revisited dialogue-triggered FST playback from the native movie path and confirmed the display-surface mismatch.
-  - `play_fst_sequence @ 0x72550` does not leave gameplay/dialogue playback on the active `640`-wide UI surface. It pauses music, switches back to the `320x200x8` movie surface with `configure_video_surface(&g_video_surface_context, 0x140, 8, 1)`, runs the decoded FST player, then restores the `640`-wide gameplay surface with `configure_video_surface(&g_video_surface_context, 0x280)` before reapplying the palette and mouse bounds.
-  - The engine-side `FstPlayer` now mirrors that wrapper behavior, so dialogue FST files and other in-game movie playback no longer stay quarter-sized on the live `640x480` gameplay display.
+- Fixed the dialogue crash introduced by the recovered movie-surface switch.
+  - The native `play_fst_sequence @ 0x72550` surface swap was correct, but `DialogueSystem::runRoomNpcDialogue` had cached `_engine.getScreen()` before FST playback and kept using that freed `Graphics::Screen` after `setDisplayMode()` recreated it.
+  - Dialogue rendering now resolves the active screen on demand for palette, blit, and cursor draws after FST playback returns, so native-style dialogue movie playback can switch `320x200` movie mode in and back out without leaving stale screen pointers behind.
 
 ## Next Suggested Action
 
 1. Exercise the remaining in-game FST callers against the shared movie-surface switch and confirm whether any of them rely on an additional redraw step after playback returns to the `640`-wide gameplay UI.
-   - The native wrapper now bounds the surface swap itself, but the surrounding caller-side redraw expectations still need a quick pass across dialogue-triggered clips and death-FST playback.
+   - The wrapper and dialogue system now both survive the mode transition, but the surrounding caller-side redraw expectations still need a quick pass across other dialogue-triggered clips and death-FST playback.
 2. Apply any additional high-confidence Ghidra renames or comments that fall directly out of that next confirmed thread before the next commit.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 5dd0b73a0ec..7e1ee86e6d2 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -474,6 +474,10 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 	if (genericByeTopic.empty())
 		genericByeTopic = "BYE";
 
+	auto getActiveScreen = [&]() -> Graphics::Screen * {
+		return _engine.getScreen();
+	};
+
 	IndexedBitmap leftHeadBitmap;
 	IndexedBitmap rightHeadBitmap;
 	Common::String leftHeadSpeakerId = buildDialogueHeadId(npc.npcName, 0);
@@ -506,10 +510,14 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 
 	auto drawFontString = [&](const Graphics::Font &font, bool usesCft, const Common::String &text,
 			int x, int y, int width, byte color) {
+		Graphics::Screen *activeScreen = getActiveScreen();
+		if (!activeScreen)
+			return;
+
 		if (usesCft)
-			font.drawString(screen, text, x, y, width, 0);
+			font.drawString(activeScreen, text, x, y, width, 0);
 		else
-			drawShadowedString(*screen, font, text, x, y, width, color);
+			drawShadowedString(*activeScreen, font, text, x, y, width, color);
 	};
 
 	auto ensureSpeakerPortrait = [&](const Common::String &speakerId, int headVariant) {
@@ -548,23 +556,27 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 	auto drawDialogueOverlay = [&](const IndexedBitmap *overlayBitmap,
 			const Common::Array<Common::String> *subtitleLines, const Common::Array<Common::String> *topics,
 			int hoveredTopicIndex, bool hoverOther, const Common::String *textEntryValue) {
-		setScaledPalette(*screen, palette, paletteBrightness);
-		blitBitmap(*screen, backdrop, 0, 0);
+		Graphics::Screen *activeScreen = getActiveScreen();
+		if (!activeScreen)
+			return;
+
+		setScaledPalette(*activeScreen, palette, paletteBrightness);
+		blitBitmap(*activeScreen, backdrop, 0, 0);
 		if (leftHeadVisible && leftHeadBitmap.isValid())
-			blitTransparentBitmap(*screen, leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
+			blitTransparentBitmap(*activeScreen, leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
 		if (rightHeadVisible && rightHeadBitmap.isValid())
-			blitTransparentBitmap(*screen, rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
+			blitTransparentBitmap(*activeScreen, rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
 		if (overlayBitmap && overlayBitmap->isValid())
-			blitTransparentBitmap(*screen, *overlayBitmap, kDialogueOverlayX, kDialogueOverlayY);
+			blitTransparentBitmap(*activeScreen, *overlayBitmap, kDialogueOverlayX, kDialogueOverlayY);
 
 		if (subtitleLines) {
 			if (subtitleFontUsesCft)
-				drawDialogueTextLines(*screen, *subtitleFont, *subtitleLines,
+				drawDialogueTextLines(*activeScreen, *subtitleFont, *subtitleLines,
 					kDialogueSubtitleTextX, kDialogueSubtitleTextY, kDialogueSubtitleTextWidth);
 			else {
 				const int lineHeight = getDialogueTextLineHeight(*subtitleFont);
 				for (uint i = 0; i < subtitleLines->size(); ++i) {
-					drawShadowedString(*screen, *subtitleFont, (*subtitleLines)[i],
+					drawShadowedString(*activeScreen, *subtitleFont, (*subtitleLines)[i],
 						kDialogueSubtitleTextX,
 						kDialogueSubtitleTextY + (int)i * lineHeight,
 						kDialogueSubtitleTextWidth, kTextColorNormal);
@@ -596,9 +608,9 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		}
 
 		if (runtimeEntities)
-			runtimeEntities->drawCursor(*screen);
-		screen->makeAllDirty();
-		screen->update();
+			runtimeEntities->drawCursor(*activeScreen);
+		activeScreen->makeAllDirty();
+		activeScreen->update();
 	};
 
 	auto playDialogueLineWithVariant = [&](int wavId, const Common::String &speakerId, int headVariant) -> Common::Error {
@@ -864,14 +876,18 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 
 	auto drawDialogueResponseMenu = [&](const IndexedBitmap *textboxBitmap,
 			const Common::Array<DialogueResponseOptionLayout> &options, int hoveredOptionIndex) {
-		setScaledPalette(*screen, palette, paletteBrightness);
-		blitBitmap(*screen, backdrop, 0, 0);
+		Graphics::Screen *activeScreen = getActiveScreen();
+		if (!activeScreen)
+			return;
+
+		setScaledPalette(*activeScreen, palette, paletteBrightness);
+		blitBitmap(*activeScreen, backdrop, 0, 0);
 		if (leftHeadVisible && leftHeadBitmap.isValid())
-			blitTransparentBitmap(*screen, leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
+			blitTransparentBitmap(*activeScreen, leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
 		if (rightHeadVisible && rightHeadBitmap.isValid())
-			blitTransparentBitmap(*screen, rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
+			blitTransparentBitmap(*activeScreen, rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
 		if (textboxBitmap && textboxBitmap->isValid())
-			blitTransparentBitmap(*screen, *textboxBitmap, kDialogueOverlayX, kDialogueOverlayY);
+			blitTransparentBitmap(*activeScreen, *textboxBitmap, kDialogueOverlayX, kDialogueOverlayY);
 
 		const Common::String title = "Responses";
 		const Graphics::Font &titleFont = *highlightFont;
@@ -896,9 +912,9 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		}
 
 		if (runtimeEntities)
-			runtimeEntities->drawCursor(*screen);
-		screen->makeAllDirty();
-		screen->update();
+			runtimeEntities->drawCursor(*activeScreen);
+		activeScreen->makeAllDirty();
+		activeScreen->update();
 	};
 
 	auto runResponseMenuText = [&](const Common::String &responseLine, int responseLineIndex,


Commit: c876ecdd7ebc87149e7fad9989dacea02c5905ee
    https://github.com/scummvm/scummvm/commit/c876ecdd7ebc87149e7fad9989dacea02c5905ee
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:19+02:00

Commit Message:
HARVESTER: Refresh room screen after fst playback

Changed paths:
    TRACKER.md
    engines/harvester/startup_room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 3dee7d0e6de..ebecc4bed85 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,12 +15,11 @@
 
 ## Last Confirmed Action
 
-- Fixed the dialogue crash introduced by the recovered movie-surface switch.
-  - The native `play_fst_sequence @ 0x72550` surface swap was correct, but `DialogueSystem::runRoomNpcDialogue` had cached `_engine.getScreen()` before FST playback and kept using that freed `Graphics::Screen` after `setDisplayMode()` recreated it.
-  - Dialogue rendering now resolves the active screen on demand for palette, blit, and cursor draws after FST playback returns, so native-style dialogue movie playback can switch `320x200` movie mode in and back out without leaving stale screen pointers behind.
+- Fixed the room-loop crash exposed by the recovered movie-surface switch.
+  - The native `play_fst_sequence @ 0x72550` surface swap remains correct, but `StartupRoomSystem::runRoomLoop` had cached `_engine.getScreen()` at room entry and kept reusing that freed `Graphics::Screen` for room redraws, dialogue-backdrop capture, and ESC room-menu capture after `setDisplayMode()` recreated the screen.
+  - Room rendering now resolves the active screen on demand in those post-FST paths, so dialogue movie playback can return to the `640`-wide gameplay UI without leaving the room loop holding a stale screen pointer.
 
 ## Next Suggested Action
 
-1. Exercise the remaining in-game FST callers against the shared movie-surface switch and confirm whether any of them rely on an additional redraw step after playback returns to the `640`-wide gameplay UI.
-   - The wrapper and dialogue system now both survive the mode transition, but the surrounding caller-side redraw expectations still need a quick pass across other dialogue-triggered clips and death-FST playback.
-2. Apply any additional high-confidence Ghidra renames or comments that fall directly out of that next confirmed thread before the next commit.
+1. Audit the remaining long-lived `_engine.getScreen()` caches that can survive an FST-triggered mode swap, especially startup/menu loops that redraw after returning from shared movie playback.
+2. Exercise other in-game FST callers, including death-FST and menu-adjacent playback, to confirm whether any caller still needs an explicit active-screen refresh or redraw after the native `320x200` movie surface returns to the gameplay UI.
diff --git a/engines/harvester/startup_room.cpp b/engines/harvester/startup_room.cpp
index 136db2517e7..c837f19c5b5 100644
--- a/engines/harvester/startup_room.cpp
+++ b/engines/harvester/startup_room.cpp
@@ -58,7 +58,10 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 	if (!loadRoomSceneResources(state, *_engine.getResources(), scene))
 		return Common::kReadingFailed;
 
-	Graphics::Screen *screen = _engine.getScreen();
+	auto getActiveScreen = [&]() -> Graphics::Screen * {
+		return _engine.getScreen();
+	};
+	Graphics::Screen *screen = getActiveScreen();
 	const StartupArt *art = _engine.getStartupArt();
 	const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
 	if (!screen || !art || !bodyFont)
@@ -161,8 +164,12 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		return _inventory.refresh();
 	};
 	auto captureDialogueBackdrop = [&](IndexedBitmap &dialogueBackdrop) {
-		drawRoomScene(_engine, *screen, scene, scene.targetPaletteBrightness);
-		return captureScreenBackdrop(*screen, dialogueBackdrop);
+		Graphics::Screen *activeScreen = getActiveScreen();
+		if (!activeScreen)
+			return false;
+
+		drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
+		return captureScreenBackdrop(*activeScreen, dialogueBackdrop);
 	};
 	auto runRoomExitCommands = [&]() {
 		Common::Array<StartupAudioCommand> exitAudioCommands;
@@ -389,6 +396,10 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			return Common::kNoError;
 
 		if (needsRedraw) {
+			Graphics::Screen *activeScreen = getActiveScreen();
+			if (!activeScreen)
+				return Common::kNoError;
+
 			const Common::Rect inventoryPanelBounds = _inventory.getPanelBounds();
 			const bool inventoryPanelContainsMouse = _inventory.isOpen() && inventoryPanelBounds.contains(_mousePos);
 			const bool suppressHover = showingInspectText || idleState.active || idleState.exiting ||
@@ -424,26 +435,27 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 						: hoverState.cursorSequence);
 			}
 
-			drawRoomScene(_engine, *screen, scene, scene.targetPaletteBrightness);
+			drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
 			if (_inventory.isOpen())
-				_inventory.drawOverlay(*screen, *bodyFont);
+				_inventory.drawOverlay(*activeScreen, *bodyFont);
 
 			if (showingInspectText) {
-				drawRoomInspectText(*screen, *art, *bodyFont, inspectText);
+				drawRoomInspectText(*activeScreen, *art, *bodyFont, inspectText);
 			} else if (!promptText.empty()) {
-				_engine.getScreen();
 				// Shared room prompt rendering remains in the orchestration layer.
 				{
 					const byte shadowColor = 0;
-					bodyFont->drawString(screen, promptText, 1, 463, 640, shadowColor, Graphics::kTextAlignCenter);
-					bodyFont->drawString(screen, promptText, 0, 462, 640, 0xce, Graphics::kTextAlignCenter);
+					bodyFont->drawString(activeScreen, promptText, 1, 463, 640, shadowColor,
+						Graphics::kTextAlignCenter);
+					bodyFont->drawString(activeScreen, promptText, 0, 462, 640, 0xce,
+						Graphics::kTextAlignCenter);
 				}
 			}
 
 			if (runtimeEntities)
-				runtimeEntities->drawCursor(*screen);
-			screen->makeAllDirty();
-			screen->update();
+				runtimeEntities->drawCursor(*activeScreen);
+			activeScreen->makeAllDirty();
+			activeScreen->update();
 			needsRedraw = false;
 		}
 
@@ -729,9 +741,12 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 					playerState.hasMoveTarget = false;
 					playerState.turnActive = false;
 					playerState.turnTargetFacing = -1;
+					Graphics::Screen *activeScreen = getActiveScreen();
+					if (!activeScreen)
+						return Common::kNoError;
 					IndexedBitmap roomMenuBackdrop;
-					drawRoomScene(_engine, *screen, scene, scene.targetPaletteBrightness);
-					if (!captureScreenBackdrop(*screen, roomMenuBackdrop))
+					drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
+					if (!captureScreenBackdrop(*activeScreen, roomMenuBackdrop))
 						return Common::kReadingFailed;
 					Common::Error menuError = startupFlow.runRoomMenuStub(roomMenuBackdrop);
 					if (menuError.getCode() != Common::kNoError)


Commit: 04e99136bfdccc58a48c0510b5bff6cef40493e3
    https://github.com/scummvm/scummvm/commit/04e99136bfdccc58a48c0510b5bff6cef40493e3
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:19+02:00

Commit Message:
HARVESTER: Clamp ambient room npc animation bank

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/startup_flow.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 3d9d244430e..3508f7095f3 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -723,6 +723,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `spawn_region_entity_from_record` builds runtime class `0x19` rectangular hotspots from the record bounds and Z span, copies `desired_facing`, and maps `cursor_enabled` onto the runtime hotspot interaction flag.
   - `spawn_anim_entity_from_record` immediately drives `show_entity_visual`, so room `ANIM` records use the centered anchor path during room setup, while room bitmap objects are inserted at their record `x` / `y` without that initial recentering step.
   - `spawn_timer_entity_from_record` builds runtime class `0x17` timer entities keyed by `TimerRecord.timer_name`; when the timer starts enabled it seeds the start tick from `DAT_000c7e44` and computes the first expiration as `start_tick + initial_value * 100`.
+  - `spawn_npc_entity_from_record` seeds ordinary room-NPC ambient playback as actor state `0x34` with a bounded frame window of `0..0x3b`; the later death / monsterfy / reaction banks begin above that range, so passive room NPCs do not free-run the whole ABM.
 - `CommandRecord` is now stable enough to use directly in the event interpreter:
   - `opcode`, `trigger_tag`, `arg1`..`arg4`, `runtime_flag`, `next`
 - `EntranceRecord` is stable and drives room transitions and save/load handoff:
@@ -982,6 +983,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - Runtime bytes `+0x11a1` and `+0x11a0` are now bounded separately:
     - `+0x11a1` is the actor dispatch-state byte; `update_actor_runtime_state` switches on it directly.
     - `+0x11a0` is the latched animation/state byte; it is usually mirrored into `+0x11a1` on direct state changes, but remains distinct during entry, turn, and terminal sequences.
+    - the room-NPC ambient state seeded by `spawn_npc_entity_from_record` is `0x34`; when both bytes remain `0x34` on a class-4 actor, the function bypasses the locomotion/combat switch and falls straight through to `tick_entity_visual_state`, so that state simply loops over the frame window the spawn path already seeded.
   - The state families are now bounded at a subsystem level:
     - `0` / `4` are the room-`min_z` locomotion family.
     - `3` / `0xb` are the room-`max_z` locomotion family.
diff --git a/TRACKER.md b/TRACKER.md
index ebecc4bed85..6ed42e03c9a 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -9,17 +9,17 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `899`
-- Named/documented: `586`
+- Total functions: `900`
+- Named/documented: `587`
 - Still `FUN_*` / undocumented: `313`
 
 ## Last Confirmed Action
 
-- Fixed the room-loop crash exposed by the recovered movie-surface switch.
-  - The native `play_fst_sequence @ 0x72550` surface swap remains correct, but `StartupRoomSystem::runRoomLoop` had cached `_engine.getScreen()` at room entry and kept reusing that freed `Graphics::Screen` for room redraws, dialogue-backdrop capture, and ESC room-menu capture after `setDisplayMode()` recreated the screen.
-  - Room rendering now resolves the active screen on demand in those post-FST paths, so dialogue movie playback can return to the `640`-wide gameplay UI without leaving the room loop holding a stale screen pointer.
+- Re-ran the native room-NPC spawn/update path from `room_setup` through `spawn_npc_entity_from_record` and `update_actor_runtime_state`.
+  - Native room NPCs are not allowed to free-run their full ABM. Ordinary passive NPC spawn seeds actor state `0x34` and clamps the live ambient frame window to `0..0x3b`; the higher banks are reserved for later state-machine-driven reactions, deaths, and monsterfy transitions.
+  - The engine startup-room NPC path now matches that bounded ambient loop instead of cycling every decoded frame in the ABM.
 
 ## Next Suggested Action
 
-1. Audit the remaining long-lived `_engine.getScreen()` caches that can survive an FST-triggered mode swap, especially startup/menu loops that redraw after returning from shared movie playback.
-2. Exercise other in-game FST callers, including death-FST and menu-adjacent playback, to confirm whether any caller still needs an explicit active-screen refresh or redraw after the native `320x200` movie surface returns to the gameplay UI.
+1. Exercise several room NPC ABMs with known post-`0x3b` reaction/death banks to confirm the bounded ambient loop fixes the visible over-seek without breaking passive idle playback.
+2. Continue the native actor-system pass on room monsters and any NPCs that rely on leaving ambient state `0x34`, so later startup-room behavior can move from static frame-window emulation toward the real class-4/class-6 state machine where needed.
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index be059abead6..9d8cf02224d 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -70,6 +70,7 @@ static const float kRoomPlayerHorizontalMoveBase = 8.0f;
 static const int kRoomRegionTargetXBias = 10;
 static const float kRoomDepthCompareEpsilon = 0.01f;
 static const int kRoomPlayerWalkAnimationRate = 17;
+static const int kRoomNpcAmbientLastFrame = 0x3b;
 
 static const byte kIdentTextColor = 0xd3;
 static const byte kTextColorNormal = 255;
@@ -1928,7 +1929,8 @@ bool StartupFlow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 
 		entity->setClassId(kRuntimeEntityClassNpc);
 		entity->setHitTestMode(kRuntimeEntityHitTestOpaquePixels);
-		entity->setLooping(true);
+		// Native spawn_npc_entity_from_record seeds the passive room-NPC loop as frames 0..0x3b.
+		entity->setAnimationFrameRange(0, MIN(entity->getLastFrame(), kRoomNpcAmbientLastFrame), true);
 		if (npc.frameDelay > 0)
 			entity->setAnimationRate(npc.frameDelay);
 		if (!applyRoomActorPlacement(state, *entity, npc.posX, npc.posY, (float)npc.posZ)) {


Commit: 7abcba2e756503d021d677ce277fd271ae9dd264
    https://github.com/scummvm/scummvm/commit/7abcba2e756503d021d677ce277fd271ae9dd264
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:19+02:00

Commit Message:
HARVESTER: Queue startup CHANGE_ROOM handoffs

Confirm in Ghidra that CHANGE_ROOM, CLOSEUP, and EXIT_CLOSEUP use the
pending-room main-loop handoff rather than a nested room stack.

Update the startup room loop to queue native CHANGE_ROOM transitions
in-place, keep CLOSEUP separate, and record the confirmed flow in
ARCHITECTURE.md and TRACKER.md.

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/startup_room.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 3508f7095f3..fe83cf83d18 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -267,10 +267,20 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `room_setup` uses it as a fade-in from `0.0` to either `1.0` or the dimmed target `0.6`.
   - The helper updates music stream state between palette uploads instead of blocking in a tight delay loop.
 - `dispatch_room_event_actions` at `0x60ee0` interprets action records keyed by a tag string. It is used by `room_setup`, the main loop, and nested action groups.
+- `parse_command_record` at `0x5e410` now has enough parser evidence to anchor the room-handoff opcodes directly.
+  - `CHANGE_ROOM -> 0x02`
+  - `CLOSEUP -> 0x06`
+  - `EXIT_CLOSEUP -> 0x35`
+- Ordinary room changes are queued rather than performed directly inside the dispatcher.
+  - `CHANGE_ROOM` resolves `arg1` through `resolve_room_entrance`, stores `g_current_room_entrance`, and queues `g_pending_room_name = entrance->room_name`.
+  - `CLOSEUP` queues the raw room name from `arg1` and clears `g_player_present_in_room`, so `room_setup` builds the target without spawning the live player.
+  - `EXIT_CLOSEUP` restores `g_pending_room_name = g_player_room_name`, copies the live player position/facing into the `SAVE_GAME` entrance, and restores `g_player_present_in_room = 1`.
+- `run_harvester_main_loop` is the code that actually consumes `g_pending_room_name`.
+  - At the top of each gameplay iteration it checks for a queued handoff, clears any selected carried object / interaction text state that would leak across the boundary, and then calls `room_setup(g_pending_room_name)`.
 - `dispatch_room_event_actions` supports:
   - Conditional branching between alternate action tags
   - Recursive dispatch of named action groups
-  - Room transitions via `room_setup`
+  - Queued room handoffs via `g_pending_room_name` / `room_setup`
   - Palette and fade mode changes
   - FST / cutscene style playback
   - Sound and music loading / playback / stop operations
@@ -313,7 +323,10 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `parse_room_record` at `0x5fea0`
   - `parse_text_record` at `0x600b0`
   - `parse_timer_record` at `0x60160`
-- `parse_command_record` now has enough parser evidence to anchor the room-event combat-control opcodes directly.
+- `parse_command_record` now has enough parser evidence to anchor both room-handoff and combat-control opcodes directly.
+  - `CHANGE_ROOM -> 0x02`
+  - `CLOSEUP -> 0x06`
+  - `EXIT_CLOSEUP -> 0x35`
   - `KILL_NPC -> 0x0c`
   - `KILL_PC -> 0x0d`
   - `MONSTERFY -> 0x0e`
@@ -351,6 +364,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `g_timer_records` at `0xd5ad0` is the `TIMER` list.
 - `g_useitem_records` at `0xd5ad4` is the `USEITEM` list.
 - `g_pending_room_name` at `0xd60a4` is the pending next-room string consumed by the main loop.
+- `g_player_present_in_room` at `0xc3eb1` gates whether `room_setup` re-spawns the live player from `g_current_room_entrance` or instead injects the synthetic `EXIT_BM` / `EXIT_HS` closeup exits.
 - `g_game_session_active` at `0xc3f08` is the live gameplay-session gate shared by room setup, the save/load menus, and the game-over flow.
   - `run_save_game_menu` returns immediately when it is clear.
   - `run_load_game_menu` sets it before applying a slot.
diff --git a/TRACKER.md b/TRACKER.md
index 6ed42e03c9a..d198b7d118d 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,9 +2,9 @@
 
 ## Current Focus
 
-- Rebuild talk-handler ports from corrected native control flow
-- Keep each NPC port scoped to confirmed native behavior only
-- Commit each completed NPC update as an isolated change
+- Match startup-room handoff behavior to the native `CHANGE_ROOM` / map-transition flow
+- Keep room and closeup transitions scoped to behavior confirmed in Ghidra
+- Only replace recursive engine behavior where the binary shows a queued pending-room handoff
 
 ## Progress
 
@@ -15,11 +15,11 @@
 
 ## Last Confirmed Action
 
-- Re-ran the native room-NPC spawn/update path from `room_setup` through `spawn_npc_entity_from_record` and `update_actor_runtime_state`.
-  - Native room NPCs are not allowed to free-run their full ABM. Ordinary passive NPC spawn seeds actor state `0x34` and clamps the live ambient frame window to `0..0x3b`; the higher banks are reserved for later state-machine-driven reactions, deaths, and monsterfy transitions.
-  - The engine startup-room NPC path now matches that bounded ambient loop instead of cycling every decoded frame in the ABM.
+- Recovered the native startup-room handoff path for `CHANGE_ROOM`, `CLOSEUP`, and `EXIT_CLOSEUP` from `parse_command_record`, `dispatch_room_event_actions`, and `run_harvester_main_loop`.
+  - `CHANGE_ROOM` resolves an entrance and queues `g_pending_room_name`; `CLOSEUP` queues a raw room name and clears `g_player_present_in_room`; the main loop later consumes `g_pending_room_name` and calls `room_setup`.
+  - Updated the Ghidra plate comments for those functions and changed the engine startup-room loop so `CHANGE_ROOM` now performs a same-loop handoff instead of recursing into a nested room loop and then rebuilding the source room on return.
 
 ## Next Suggested Action
 
-1. Exercise several room NPC ABMs with known post-`0x3b` reaction/death banks to confirm the bounded ambient loop fixes the visible over-seek without breaking passive idle playback.
-2. Continue the native actor-system pass on room monsters and any NPCs that rely on leaving ambient state `0x34`, so later startup-room behavior can move from static frame-window emulation toward the real class-4/class-6 state machine where needed.
+1. Run the live `PCHOUSE_X4B -> PCHOUSE_2_MAP` transition and confirm the room loop no longer rebuilds `PCHOUSE` after the click.
+2. Audit closeup exits against the native `EXIT_CLOSEUP` / `SAVE_GAME` handoff so closeups can eventually stop relying on recursive room-loop unwinding.
diff --git a/engines/harvester/startup_room.cpp b/engines/harvester/startup_room.cpp
index c837f19c5b5..0ff01c2ef2d 100644
--- a/engines/harvester/startup_room.cpp
+++ b/engines/harvester/startup_room.cpp
@@ -46,74 +46,77 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 	if (startupFlow.hasPendingMainMenuReturn())
 		return Common::kNoError;
 
-	StartupRoomSetupState state;
-	if (!_engine.getStartupScript()->resolveRoomSetupState(entranceName, state, *_engine.getResources()))
-		return Common::kReadingFailed;
+	Common::String currentRoomTarget = entranceName;
+	while (!currentRoomTarget.empty()) {
+		StartupRoomSetupState state;
+		if (!_engine.getStartupScript()->resolveRoomSetupState(currentRoomTarget, state, *_engine.getResources()))
+			return Common::kReadingFailed;
 
-	Common::Error transitionError = startupFlow.beginRoomSetupTransition();
-	if (transitionError.getCode() != Common::kNoError)
-		return transitionError;
+		Common::Error transitionError = startupFlow.beginRoomSetupTransition();
+		if (transitionError.getCode() != Common::kNoError)
+			return transitionError;
 
-	StartupRoomSceneResources scene;
-	if (!loadRoomSceneResources(state, *_engine.getResources(), scene))
-		return Common::kReadingFailed;
+		StartupRoomSceneResources scene;
+		if (!loadRoomSceneResources(state, *_engine.getResources(), scene))
+			return Common::kReadingFailed;
 
-	auto getActiveScreen = [&]() -> Graphics::Screen * {
-		return _engine.getScreen();
-	};
-	Graphics::Screen *screen = getActiveScreen();
-	const StartupArt *art = _engine.getStartupArt();
-	const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	if (!screen || !art || !bodyFont)
-		return Common::kNoError;
+		auto getActiveScreen = [&]() -> Graphics::Screen * {
+			return _engine.getScreen();
+		};
+		Graphics::Screen *screen = getActiveScreen();
+		const StartupArt *art = _engine.getStartupArt();
+		const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+		if (!screen || !art || !bodyFont)
+			return Common::kNoError;
 
-	if (!startupFlow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
-		return Common::kReadingFailed;
+		if (!startupFlow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
+			return Common::kReadingFailed;
 
-	logScenePaletteSummary("room setup stub palette", scene, 0.0f);
-	drawRoomScene(_engine, *screen, scene, 0.0f);
-	screen->makeAllDirty();
-	screen->update();
-
-	logScenePaletteSummary("room setup fade target", scene, scene.targetPaletteBrightness);
-	transitionError = startupFlow.fadeInRoomScene(scene.palette, scene.targetPaletteBrightness);
-	if (transitionError.getCode() != Common::kNoError)
-		return transitionError;
-
-	startupFlow.resetCursorAnimationSequence();
-	startupFlow.executeStartupAudioCommands(scene.state.audioCommands);
-	if (!scene.state.musicPath.empty())
-		(void)_engine.playStartupMusic(scene.state.musicPath);
-	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
-	StartupRoomPlayerState playerState;
-	playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName("PLAYER") : nullptr;
-	playerState.centerX = state.playerSpawnX;
-	playerState.bottomY = state.playerSpawnY;
-	playerState.z = (float)state.playerSpawnZ;
-	playerState.facing = state.playerFacing;
-	playerState.turnActive = false;
-	playerState.turnTargetFacing = -1;
-	StartupResolvedText inspectText;
-	bool showingInspectText = false;
-	bool inspectCanDismiss = false;
-	bool moveLeft = false;
-	bool moveRight = false;
-	bool moveUp = false;
-	bool moveDown = false;
-	Common::String pendingRegionName;
-	StartupRoomIdleAnimationState idleState;
-	bool needsRedraw = true;
-	auto resetIdleState = [&]() {
-		idleState = StartupRoomIdleAnimationState();
-		idleState.activityTick = getRuntimeClockTicks();
-		idleState.resetTick = idleState.activityTick;
-		updatePlayerIdleTrigger(idleState);
-	};
-	auto notePlayerActivity = [&]() {
-		idleState.activityTick = getRuntimeClockTicks();
-		updatePlayerIdleTrigger(idleState);
-	};
-	resetIdleState();
+		logScenePaletteSummary("room setup stub palette", scene, 0.0f);
+		drawRoomScene(_engine, *screen, scene, 0.0f);
+		screen->makeAllDirty();
+		screen->update();
+
+		logScenePaletteSummary("room setup fade target", scene, scene.targetPaletteBrightness);
+		transitionError = startupFlow.fadeInRoomScene(scene.palette, scene.targetPaletteBrightness);
+		if (transitionError.getCode() != Common::kNoError)
+			return transitionError;
+
+		startupFlow.resetCursorAnimationSequence();
+		startupFlow.executeStartupAudioCommands(scene.state.audioCommands);
+		if (!scene.state.musicPath.empty())
+			(void)_engine.playStartupMusic(scene.state.musicPath);
+		RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+		StartupRoomPlayerState playerState;
+		playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName("PLAYER") : nullptr;
+		playerState.centerX = state.playerSpawnX;
+		playerState.bottomY = state.playerSpawnY;
+		playerState.z = (float)state.playerSpawnZ;
+		playerState.facing = state.playerFacing;
+		playerState.turnActive = false;
+		playerState.turnTargetFacing = -1;
+		StartupResolvedText inspectText;
+		bool showingInspectText = false;
+		bool inspectCanDismiss = false;
+		bool moveLeft = false;
+		bool moveRight = false;
+		bool moveUp = false;
+		bool moveDown = false;
+		Common::String pendingRegionName;
+		Common::String pendingRoomChange;
+		StartupRoomIdleAnimationState idleState;
+		bool needsRedraw = true;
+		auto resetIdleState = [&]() {
+			idleState = StartupRoomIdleAnimationState();
+			idleState.activityTick = getRuntimeClockTicks();
+			idleState.resetTick = idleState.activityTick;
+			updatePlayerIdleTrigger(idleState);
+		};
+		auto notePlayerActivity = [&]() {
+			idleState.activityTick = getRuntimeClockTicks();
+			updatePlayerIdleTrigger(idleState);
+		};
+		resetIdleState();
 
 	auto openInventoryOverlay = [&]() {
 		moveLeft = false;
@@ -184,6 +187,7 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		StartupRoomSceneResources &scene;
 		StartupRoomPlayerState &playerState;
 		Common::String &pendingRegionName;
+		Common::String &pendingRoomChange;
 		decltype(refreshCurrentScene) &refreshCurrentSceneFn;
 		decltype(captureDialogueBackdrop) &captureDialogueBackdropFn;
 		decltype(runRoomExitCommands) &runRoomExitCommandsFn;
@@ -217,7 +221,19 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			}
 			startupFlow.executeStartupAudioCommands(interaction.audioCommands);
 
-			if (!interaction.nextRoomName.empty()) {
+			StartupRoomTransitionKind roomTransition = interaction.roomTransition;
+			if (roomTransition == kStartupRoomTransitionNone && !interaction.nextRoomName.empty())
+				roomTransition = kStartupRoomTransitionCloseup;
+
+			if (!interaction.nextRoomName.empty() &&
+					roomTransition == kStartupRoomTransitionChangeRoom) {
+				if (!runRoomExitCommandsFn())
+					return Common::kReadingFailed;
+
+				// Native CHANGE_ROOM queues a room handoff for the live loop instead of nesting.
+				pendingRoomChange = interaction.nextRoomName;
+				didTransition = true;
+			} else if (!interaction.nextRoomName.empty()) {
 				if (!runRoomExitCommandsFn())
 					return Common::kReadingFailed;
 
@@ -305,7 +321,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		}
 	};
 	InteractionProcessor interactionProcessor = {
-		_engine, startupFlow, scene, playerState, pendingRegionName, refreshCurrentScene,
+		_engine, startupFlow, scene, playerState, pendingRegionName, pendingRoomChange,
+		refreshCurrentScene,
 		captureDialogueBackdrop, runRoomExitCommands, resetIdleState
 	};
 	auto handleInventoryTargetInteraction = [&](const StartupObjectRecord &target, bool preferPickup) -> Common::Error {
@@ -389,11 +406,13 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 	Graphics::FrameLimiter limiter(g_system, 60);
 
 	if (shouldRunStartupRoomProbe())
-		logStartupRoomProbe(_engine, scene, entranceName, _mousePos);
+		logStartupRoomProbe(_engine, scene, currentRoomTarget, _mousePos);
 
 	while (!_engine.shouldQuit()) {
 		if (startupFlow.hasPendingMainMenuReturn())
 			return Common::kNoError;
+		if (!pendingRoomChange.empty())
+			break;
 
 		if (needsRedraw) {
 			Graphics::Screen *activeScreen = getActiveScreen();
@@ -778,6 +797,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 				break;
 			}
 		}
+		if (!pendingRoomChange.empty())
+			break;
 
 		if (updatePlayerTurnAnimationState(playerState))
 			needsRedraw = true;
@@ -812,6 +833,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			return pendingRegionError;
 		if (startupFlow.hasPendingMainMenuReturn())
 			return Common::kNoError;
+		if (!pendingRoomChange.empty())
+			break;
 
 		if (startupFlow.tickRuntimeEntities())
 			needsRedraw = true;
@@ -821,6 +844,15 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		limiter.delayBeforeSwap();
 		limiter.startFrame();
 	}
+	if (_engine.shouldQuit())
+		return Common::kNoError;
+	if (startupFlow.hasPendingMainMenuReturn())
+		return Common::kNoError;
+	if (pendingRoomChange.empty())
+		return Common::kNoError;
+
+	currentRoomTarget = pendingRoomChange;
+	}
 
 	return Common::kNoError;
 }
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index d0dc5fd269f..3941cae0f42 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -584,7 +584,8 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 	Common::Array<StartupAudioCommand> audioCommands;
 	bool mutatedRuntimeState = false;
 	executeCommandChain(room->onEnterCommand, "room setup command", room->roomName, false,
-		&musicPath, &audioCommands, nullptr, nullptr, nullptr, nullptr, nullptr, &mutatedRuntimeState);
+		&musicPath, &audioCommands, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
+		&mutatedRuntimeState);
 
 	if (!buildRuntimeRoomState(*room, entrance, state))
 		return false;
@@ -662,7 +663,8 @@ bool StartupScript::executeRoomExitCommands(const Common::String &roomName,
 
 	bool mutatedRuntimeState = false;
 	executeCommandChain(room->onExitCommand, "room exit command", room->roomName, false,
-		nullptr, &audioCommands, nullptr, nullptr, nullptr, nullptr, nullptr, &mutatedRuntimeState);
+		nullptr, &audioCommands, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
+		&mutatedRuntimeState);
 	return true;
 }
 
@@ -682,7 +684,7 @@ bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object,
 		return result.mutatedRuntimeState;
 
 	executeCommandChain(object.actionTag, "interaction command", object.objectName, true,
-		&result.musicPath, &result.audioCommands, &result.nextRoomName,
+		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
 		&result.deathFlicPath, &result.requestMainMenu,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.mutatedRuntimeState);
 
@@ -697,7 +699,7 @@ bool StartupScript::resolveRegionInteraction(const StartupRegionRecord &region,
 		return false;
 
 	executeCommandChain(region.actionTag, "region command", region.regionName, true,
-		&result.musicPath, &result.audioCommands, &result.nextRoomName,
+		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
 		&result.deathFlicPath, &result.requestMainMenu,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.mutatedRuntimeState);
 	return !result.nextRoomName.empty() || !result.deathFlicPath.empty() || result.requestMainMenu ||
@@ -715,7 +717,7 @@ bool StartupScript::resolveUseItemInteraction(const Common::String &itemName, co
 
 	executeCommandChain(useItem->actionTag, "useitem command",
 		Common::String::format("%s -> %s", itemName.c_str(), target.objectName.c_str()), true,
-		&result.musicPath, &result.audioCommands, &result.nextRoomName,
+		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
 		&result.deathFlicPath, &result.requestMainMenu,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.mutatedRuntimeState);
 	return true;
@@ -728,7 +730,7 @@ bool StartupScript::executeActionTag(const Common::String &tag, StartupInteracti
 		return false;
 
 	executeCommandChain(tag, "action tag", tag, allowTransitions,
-		&result.musicPath, &result.audioCommands, &result.nextRoomName,
+		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
 		&result.deathFlicPath, &result.requestMainMenu,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.mutatedRuntimeState);
 
@@ -1159,6 +1161,7 @@ bool StartupScript::buildRuntimeRoomState(const StartupRoomRecord &room, const S
 void StartupScript::executeCommandChain(const Common::String &initialTag, const char *contextLabel,
 		const Common::String &contextName, bool allowTransitions, Common::String *musicPath,
 		Common::Array<StartupAudioCommand> *audioCommands, Common::String *nextRoomName,
+		StartupRoomTransitionKind *roomTransition,
 		Common::String *deathFlicPath, bool *requestMainMenu, Common::String *dialogueNpcName,
 		Common::String *dialogueContinuationTag,
 		bool *mutatedRuntimeState) {
@@ -1409,11 +1412,17 @@ void StartupScript::executeCommandChain(const Common::String &initialTag, const
 
 		if (command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
-			if (allowTransitions && nextRoomName)
+			if (allowTransitions && nextRoomName) {
 				*nextRoomName = command->arg1;
-			else
+				if (roomTransition) {
+					*roomTransition = command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")
+						? kStartupRoomTransitionChangeRoom
+						: kStartupRoomTransitionCloseup;
+				}
+			} else {
 				debugC(1, kDebugScene, "Harvester: skipped transition opcode '%s' while processing %s '%s'",
 					command->opcodeName.c_str(), contextLabel, contextName.c_str());
+			}
 			return;
 		}
 
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 6edaeb812e8..1a23a4fa72f 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -235,6 +235,12 @@ struct StartupRoomSetupState {
 	Common::Array<StartupAudioCommand> audioCommands;
 };
 
+enum StartupRoomTransitionKind {
+	kStartupRoomTransitionNone = 0,
+	kStartupRoomTransitionChangeRoom,
+	kStartupRoomTransitionCloseup
+};
+
 struct StartupInteractionResult {
 	Common::String musicPath;
 	Common::String nextRoomName;
@@ -242,6 +248,7 @@ struct StartupInteractionResult {
 	Common::String dialogueNpcName;
 	Common::String dialogueContinuationTag;
 	Common::Array<StartupAudioCommand> audioCommands;
+	StartupRoomTransitionKind roomTransition = kStartupRoomTransitionNone;
 	bool requestMainMenu = false;
 	bool abortRemainingCommandChain = false;
 	bool mutatedRuntimeState = false;
@@ -332,6 +339,7 @@ private:
 	void executeCommandChain(const Common::String &initialTag, const char *contextLabel,
 		const Common::String &contextName, bool allowTransitions, Common::String *musicPath,
 		Common::Array<StartupAudioCommand> *audioCommands, Common::String *nextRoomName,
+		StartupRoomTransitionKind *roomTransition,
 		Common::String *deathFlicPath, bool *requestMainMenu, Common::String *dialogueNpcName,
 		Common::String *dialogueContinuationTag,
 		bool *mutatedRuntimeState);


Commit: 5d2d91da2c6755d21e17a136c9f92e40a1661bb4
    https://github.com/scummvm/scummvm/commit/5d2d91da2c6755d21e17a136c9f92e40a1661bb4
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:20+02:00

Commit Message:
HARVESTER: Preserve jimmy dialogue room handoff

Changed paths:
    TRACKER.md
    engines/harvester/npc/jimmy_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index d198b7d118d..616a9d9a677 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -18,8 +18,9 @@
 - Recovered the native startup-room handoff path for `CHANGE_ROOM`, `CLOSEUP`, and `EXIT_CLOSEUP` from `parse_command_record`, `dispatch_room_event_actions`, and `run_harvester_main_loop`.
   - `CHANGE_ROOM` resolves an entrance and queues `g_pending_room_name`; `CLOSEUP` queues a raw room name and clears `g_player_present_in_room`; the main loop later consumes `g_pending_room_name` and calls `room_setup`.
   - Updated the Ghidra plate comments for those functions and changed the engine startup-room loop so `CHANGE_ROOM` now performs a same-loop handoff instead of recursing into a nested room loop and then rebuilding the source room on return.
+- Confirmed the dialogue-triggered Jimmy paper handoff was still losing `CHANGE_ROOM` semantics in engine code: `jimmy_dialogue.cpp` manually copied `nextRoomName` out of `ACTV_HOUSE_EXIT` but dropped `roomTransition`, which downgraded the queued handoff back into the legacy nested-room path.
 
 ## Next Suggested Action
 
-1. Run the live `PCHOUSE_X4B -> PCHOUSE_2_MAP` transition and confirm the room loop no longer rebuilds `PCHOUSE` after the click.
+1. Re-test both `PCHOUSE_X4B -> PCHOUSE_2_MAP` and the Jimmy `DIAL_JIM_1_C -> CHANGE_ROOM PCHOUSE_2_MAP` path to confirm dialogue-issued room changes now stay on the queued handoff path.
 2. Audit closeup exits against the native `EXIT_CLOSEUP` / `SAVE_GAME` handoff so closeups can eventually stop relying on recursive room-loop unwinding.
diff --git a/engines/harvester/npc/jimmy_dialogue.cpp b/engines/harvester/npc/jimmy_dialogue.cpp
index e7c4ea0093f..aefa9f0c059 100644
--- a/engines/harvester/npc/jimmy_dialogue.cpp
+++ b/engines/harvester/npc/jimmy_dialogue.cpp
@@ -71,6 +71,8 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 					jimmyInteraction.musicPath = actionInteraction.musicPath;
 				if (!actionInteraction.nextRoomName.empty())
 					jimmyInteraction.nextRoomName = actionInteraction.nextRoomName;
+				if (actionInteraction.roomTransition != kStartupRoomTransitionNone)
+					jimmyInteraction.roomTransition = actionInteraction.roomTransition;
 				if (!actionInteraction.deathFlicPath.empty())
 					jimmyInteraction.deathFlicPath = actionInteraction.deathFlicPath;
 				if (!actionInteraction.dialogueNpcName.empty())


Commit: 5fa4636ceeb8568816e3478e762efe8119cafb96
    https://github.com/scummvm/scummvm/commit/5fa4636ceeb8568816e3478e762efe8119cafb96
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:20+02:00

Commit Message:
HARVESTER: Relax startup room background detection

Changed paths:
    TRACKER.md
    engines/harvester/startup_room.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/TRACKER.md b/TRACKER.md
index 616a9d9a677..f3c75823fb6 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -19,8 +19,9 @@
   - `CHANGE_ROOM` resolves an entrance and queues `g_pending_room_name`; `CLOSEUP` queues a raw room name and clears `g_player_present_in_room`; the main loop later consumes `g_pending_room_name` and calls `room_setup`.
   - Updated the Ghidra plate comments for those functions and changed the engine startup-room loop so `CHANGE_ROOM` now performs a same-loop handoff instead of recursing into a nested room loop and then rebuilding the source room on return.
 - Confirmed the dialogue-triggered Jimmy paper handoff was still losing `CHANGE_ROOM` semantics in engine code: `jimmy_dialogue.cpp` manually copied `nextRoomName` out of `ACTV_HOUSE_EXIT` but dropped `roomTransition`, which downgraded the queued handoff back into the legacy nested-room path.
+- Confirmed another room-setup mismatch against the recovered native constructor rules: the engine was requiring room backgrounds to live under `GRAPHIC/ROOMS/*.BM`, while the documented native `spawn_object_entity_from_record` promotion rule is based on a sprite-backed object at origin whose loaded bitmap is `640x480`.
 
 ## Next Suggested Action
 
-1. Re-test both `PCHOUSE_X4B -> PCHOUSE_2_MAP` and the Jimmy `DIAL_JIM_1_C -> CHANGE_ROOM PCHOUSE_2_MAP` path to confirm dialogue-issued room changes now stay on the queued handoff path.
+1. Re-test both `PCHOUSE_X4B -> PCHOUSE_2_MAP` and the Jimmy `DIAL_JIM_1_C -> CHANGE_ROOM PCHOUSE_2_MAP` path to confirm the map handoff no longer dies during target-room setup.
 2. Audit closeup exits against the native `EXIT_CLOSEUP` / `SAVE_GAME` handoff so closeups can eventually stop relying on recursive room-loop unwinding.
diff --git a/engines/harvester/startup_room.cpp b/engines/harvester/startup_room.cpp
index 0ff01c2ef2d..7c2022677e7 100644
--- a/engines/harvester/startup_room.cpp
+++ b/engines/harvester/startup_room.cpp
@@ -133,7 +133,7 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		const Common::Array<StartupAudioCommand> entryAudioCommands = scene.state.audioCommands;
 		StartupRoomSetupState refreshedState;
 		if (!_engine.getStartupScript()->materializeRoomState(
-				scene.state.entranceName, scene.state.roomName, refreshedState)) {
+				scene.state.entranceName, scene.state.roomName, refreshedState, *_engine.getResources())) {
 			return false;
 		}
 
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 3941cae0f42..52fabd8551a 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -24,6 +24,7 @@
 #include <cstdlib>
 
 #include "common/debug.h"
+#include "common/endian.h"
 #include "common/formats/ini-file.h"
 #include "common/memstream.h"
 #include "harvester/detection.h"
@@ -131,6 +132,33 @@ static bool appendStartupAudioCommand(const StartupCommandRecord &command, Commo
 	return false;
 }
 
+static bool loadBitmapDimensions(ResourceManager &resources, const Common::String &path,
+		uint32 &width, uint32 &height) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 8)
+		return false;
+
+	width = READ_LE_UINT32(data.data());
+	height = READ_LE_UINT32(data.data() + 4);
+	return true;
+}
+
+static bool isRoomBackgroundObject(const StartupObjectRecord &candidate, const StartupRoomRecord &room,
+		ResourceManager &resources) {
+	if (!candidate.currentOwnerOrRoom.equalsIgnoreCase(room.roomName) ||
+			candidate.spritePath.empty() ||
+			candidate.initialX != 0 || candidate.initialY != 0) {
+		return false;
+	}
+
+	uint32 width = 0;
+	uint32 height = 0;
+	if (!loadBitmapDimensions(resources, candidate.spritePath, width, height))
+		return false;
+
+	return width == 640 && height == 480;
+}
+
 static void tokenizeTownScriptLine(const Common::String &line, Common::Array<Common::String> &tokens) {
 	tokens.clear();
 
@@ -571,14 +599,16 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 
 bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
 		ResourceManager &resources) {
-	(void)resources;
 	state = StartupRoomSetupState();
 
 	const StartupEntranceRecord *entrance = findEntranceRecord(entranceName);
 	const Common::String resolvedRoomName = entrance ? entrance->roomName : entranceName;
 	const StartupRoomRecord *room = findRoomRecord(resolvedRoomName);
-	if (!room)
+	if (!room) {
+		warning("Harvester: unresolved room setup target '%s' (resolved room '%s')",
+			entranceName.c_str(), resolvedRoomName.c_str());
 		return false;
+	}
 
 	Common::String musicPath;
 	Common::Array<StartupAudioCommand> audioCommands;
@@ -587,7 +617,7 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 		&musicPath, &audioCommands, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
 		&mutatedRuntimeState);
 
-	if (!buildRuntimeRoomState(*room, entrance, state))
+	if (!buildRuntimeRoomState(*room, entrance, resources, state))
 		return false;
 
 	state.audioCommands = audioCommands;
@@ -636,7 +666,7 @@ void StartupScript::resetRuntimeState() {
 }
 
 bool StartupScript::materializeRoomState(const Common::String &entranceName, const Common::String &roomName,
-		StartupRoomSetupState &state) const {
+		StartupRoomSetupState &state, ResourceManager &resources) const {
 	state = StartupRoomSetupState();
 
 	const StartupEntranceRecord *entrance = findEntranceRecord(entranceName);
@@ -647,10 +677,13 @@ bool StartupScript::materializeRoomState(const Common::String &entranceName, con
 		return false;
 
 	const StartupRoomRecord *room = findRoomRecord(resolvedRoomName);
-	if (!room)
+	if (!room) {
+		warning("Harvester: unresolved materialized room target entrance='%s' room='%s'",
+			entranceName.c_str(), resolvedRoomName.c_str());
 		return false;
+	}
 
-	return buildRuntimeRoomState(*room, entrance, state);
+	return buildRuntimeRoomState(*room, entrance, resources, state);
 }
 
 bool StartupScript::executeRoomExitCommands(const Common::String &roomName,
@@ -1037,22 +1070,20 @@ bool StartupScript::triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcN
 }
 
 bool StartupScript::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupEntranceRecord *entrance,
-		StartupRoomSetupState &state) const {
+		ResourceManager &resources, StartupRoomSetupState &state) const {
 	state = StartupRoomSetupState();
 
 	const StartupObjectRecord *background = nullptr;
 	for (const StartupObjectRecord &candidate : _runtimeObjects) {
-		if (!candidate.currentOwnerOrRoom.equalsIgnoreCase(room.roomName) ||
-			!candidate.spritePath.hasPrefixIgnoreCase("GRAPHIC/ROOMS/") ||
-			!candidate.spritePath.hasSuffixIgnoreCase(".BM")) {
-			continue;
+		if (isRoomBackgroundObject(candidate, room, resources)) {
+			background = &candidate;
+			break;
 		}
-
-		background = &candidate;
-		break;
 	}
-	if (!background)
+	if (!background) {
+		warning("Harvester: no fullscreen background object found for room '%s'", room.roomName.c_str());
 		return false;
+	}
 
 	state.roomName = room.roomName;
 	state.palettePath = room.palettePath;
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 1a23a4fa72f..82b0fb9cd97 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -285,7 +285,7 @@ public:
 	bool resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
 		ResourceManager &resources);
 	bool materializeRoomState(const Common::String &entranceName, const Common::String &roomName,
-		StartupRoomSetupState &state) const;
+		StartupRoomSetupState &state, ResourceManager &resources) const;
 		bool executeRoomExitCommands(const Common::String &roomName, Common::Array<StartupAudioCommand> &audioCommands);
 		bool resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result);
 		bool resolveRegionInteraction(const StartupRegionRecord &region, StartupInteractionResult &result);
@@ -335,7 +335,7 @@ private:
 	StartupMonsterRecord *findRuntimeMonster(const Common::String &monsterName);
 	const StartupMonsterRecord *findRuntimeMonster(const Common::String &monsterName) const;
 	bool buildRuntimeRoomState(const StartupRoomRecord &room, const StartupEntranceRecord *entrance,
-		StartupRoomSetupState &state) const;
+		ResourceManager &resources, StartupRoomSetupState &state) const;
 	void executeCommandChain(const Common::String &initialTag, const char *contextLabel,
 		const Common::String &contextName, bool allowTransitions, Common::String *musicPath,
 		Common::Array<StartupAudioCommand> *audioCommands, Common::String *nextRoomName,


Commit: 401474022acf5b3ba39a7c0436ab11e84de438f8
    https://github.com/scummvm/scummvm/commit/401474022acf5b3ba39a7c0436ab11e84de438f8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:20+02:00

Commit Message:
HARVESTER: Add startup town map transitions

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_flow.h
    engines/harvester/startup_room.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index fe83cf83d18..a3283774689 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -396,10 +396,27 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `resolve_room_entrance` at `0x62d80` first searches `g_room_entrances` by entrance tag, then falls back to `g_town_map_entrypoints`.
   - On direct entrance matches it updates `g_pending_room_name`.
   - On map-entry matches it launches the town map selector and then updates the pending room / player position from the chosen destination.
+- `MapEntranceRecord` is now stable enough for engine use:
+  - `field_00`
+  - `field_04`
+  - `initial_panel_index`
+  - `entry_name`
+  - `next`
 - `select_town_map_destination` at `0x66460` is the town-map travel UI.
   - It loads `harvmap.pal` and `harvmap1.bm` through `harvmap4.bm`.
   - It seeds the initial map panel from `g_town_map_entrypoints`.
   - It uses `g_town_map_hotspots` to resolve the selected destination back into a named entrance record.
+- `MapLocationRecord` is now stable enough for engine use:
+  - `min_x`
+  - `min_y`
+  - `max_x`
+  - `max_y`
+  - `panel_index`
+  - `label_x`
+  - `label_y`
+  - `label_text`
+  - `destination_entrance`
+  - `next`
 - `get_flag_value` at `0x7c790` and `set_flag_value` at `0x7c7e0` are the core `FLAG` accessors used throughout startup, room setup, and event dispatch.
 - `run_npc_dialogue` at `0x79e50` is the conversation/head UI for named NPCs.
   - It validates the NPC through `g_npc_records`.
diff --git a/TRACKER.md b/TRACKER.md
index f3c75823fb6..c19d043baa4 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -20,8 +20,9 @@
   - Updated the Ghidra plate comments for those functions and changed the engine startup-room loop so `CHANGE_ROOM` now performs a same-loop handoff instead of recursing into a nested room loop and then rebuilding the source room on return.
 - Confirmed the dialogue-triggered Jimmy paper handoff was still losing `CHANGE_ROOM` semantics in engine code: `jimmy_dialogue.cpp` manually copied `nextRoomName` out of `ACTV_HOUSE_EXIT` but dropped `roomTransition`, which downgraded the queued handoff back into the legacy nested-room path.
 - Confirmed another room-setup mismatch against the recovered native constructor rules: the engine was requiring room backgrounds to live under `GRAPHIC/ROOMS/*.BM`, while the documented native `spawn_object_entity_from_record` promotion rule is based on a sprite-backed object at origin whose loaded bitmap is `640x480`.
+- Confirmed the remaining `PCHOUSE_2_MAP` failure was not a missing room at all: native `resolve_room_entrance` falls back from `ENTRANCE` to `MAP_ENTRANCE`, and map-entry matches open the town map UI which then resolves a final destination through `MAP_LOCATION -> destination_entrance -> ENTRANCE`.
 
 ## Next Suggested Action
 
-1. Re-test both `PCHOUSE_X4B -> PCHOUSE_2_MAP` and the Jimmy `DIAL_JIM_1_C -> CHANGE_ROOM PCHOUSE_2_MAP` path to confirm the map handoff no longer dies during target-room setup.
-2. Audit closeup exits against the native `EXIT_CLOSEUP` / `SAVE_GAME` handoff so closeups can eventually stop relying on recursive room-loop unwinding.
+1. Re-test both `PCHOUSE_X4B -> PCHOUSE_2_MAP` and the Jimmy `DIAL_JIM_1_C -> CHANGE_ROOM PCHOUSE_2_MAP` path and confirm they now open the town map UI instead of throwing `reading data failed`.
+2. If the map UI is functionally correct but visually off, compare initial cursor placement and panel-switch polish against the native `select_town_map_destination` path before moving on to closeup cleanup.
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 9d8cf02224d..8acbc249712 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -48,6 +48,14 @@ namespace {
 static const char *const kQuickTipsPath = "ADJHEAD.RCS";
 static const char *const kMenuPath = "MENU.INI";
 static const char *const kMenuSectionName = "menu";
+static const char *const kTownMapPalettePath = "1:/GRAPHIC/PAL/HARVMAP.PAL";
+static const char *const kTownMapMusicPath = "SOUND/MUSIC/MENACE.CMP";
+static const char *const kTownMapBitmapPaths[] = {
+	"1:/GRAPHIC/TOWN/HARVMAP1.BM",
+	"1:/GRAPHIC/TOWN/HARVMAP2.BM",
+	"1:/GRAPHIC/TOWN/HARVMAP3.BM",
+	"1:/GRAPHIC/TOWN/HARVMAP4.BM"
+};
 
 static const int kQuickTipsOverlayX = 167;
 static const int kQuickTipsOverlayY = 200;
@@ -71,11 +79,14 @@ static const int kRoomRegionTargetXBias = 10;
 static const float kRoomDepthCompareEpsilon = 0.01f;
 static const int kRoomPlayerWalkAnimationRate = 17;
 static const int kRoomNpcAmbientLastFrame = 0x3b;
+static const int kTownMapEdgeThreshold = 9;
+static const int kTownMapCursorHitExtent = 5;
 
 static const byte kIdentTextColor = 0xd3;
 static const byte kTextColorNormal = 255;
 static const byte kShadowColor = 0;
 static const byte kQuickTipActionColor = 0xc3;
+static const byte kTownMapLabelColor = 0x28;
 
 static const int kCursorSequenceWalk = 0;
 static const int kCursorSequenceExamine = 1;
@@ -120,6 +131,91 @@ static int roundToInt(float value) {
 	return value >= 0.0f ? (int)floorf(value + 0.5f) : (int)ceilf(value - 0.5f);
 }
 
+static int clampTownMapPanelIndex(int panelIndex) {
+	return CLIP<int>(panelIndex, 0, ARRAYSIZE(kTownMapBitmapPaths) - 1);
+}
+
+static int resolveTownMapEdgePanel(int currentPanel, const Common::Point &mousePos, int width, int height) {
+	if (mousePos.x <= kTownMapEdgeThreshold) {
+		if (currentPanel == 1)
+			return 0;
+		if (currentPanel == 3)
+			return 2;
+	} else if (mousePos.x >= width - 1 - kTownMapEdgeThreshold) {
+		if (currentPanel == 0)
+			return 1;
+		if (currentPanel == 2)
+			return 3;
+	} else if (mousePos.y <= kTownMapEdgeThreshold) {
+		if (currentPanel == 2)
+			return 0;
+		if (currentPanel == 3)
+			return 1;
+	} else if (mousePos.y >= height - 1 - kTownMapEdgeThreshold) {
+		if (currentPanel == 0)
+			return 2;
+		if (currentPanel == 1)
+			return 3;
+	}
+
+	return currentPanel;
+}
+
+static int resolveTownMapKeyPanel(int currentPanel, Common::KeyCode keycode) {
+	switch (keycode) {
+	case Common::KEYCODE_LEFT:
+		if (currentPanel == 1)
+			return 0;
+		if (currentPanel == 3)
+			return 2;
+		break;
+	case Common::KEYCODE_RIGHT:
+		if (currentPanel == 0)
+			return 1;
+		if (currentPanel == 2)
+			return 3;
+		break;
+	case Common::KEYCODE_UP:
+		if (currentPanel == 2)
+			return 0;
+		if (currentPanel == 3)
+			return 1;
+		break;
+	case Common::KEYCODE_DOWN:
+		if (currentPanel == 0)
+			return 2;
+		if (currentPanel == 1)
+			return 3;
+		break;
+	default:
+		break;
+	}
+
+	return currentPanel;
+}
+
+static bool isTownMapLocationHovered(const StartupMapLocationRecord &location, int currentPanel,
+		const Common::Point &mousePos) {
+	if (location.panelIndex != currentPanel)
+		return false;
+
+	const int cursorRight = mousePos.x + kTownMapCursorHitExtent;
+	const int cursorBottom = mousePos.y + kTownMapCursorHitExtent;
+	return mousePos.x <= location.maxX && cursorRight >= location.minX &&
+		mousePos.y <= location.maxY && cursorBottom >= location.minY;
+}
+
+static const StartupMapLocationRecord *findTownMapLocationAt(
+		const Common::Array<StartupMapLocationRecord> &locations, int currentPanel,
+		const Common::Point &mousePos) {
+	for (const StartupMapLocationRecord &location : locations) {
+		if (isTownMapLocationHovered(location, currentPanel, mousePos))
+			return &location;
+	}
+
+	return nullptr;
+}
+
 static int resolvePlayerFacingFrame(int facing) {
 	switch (facing) {
 	case 0:
@@ -1790,6 +1886,161 @@ Common::Error StartupFlow::runRoomNpcDialogue(const IndexedBitmap &backdrop, con
 	return _dialogue.runRoomNpcDialogue(backdrop, palette, paletteBrightness, npc, usedItemName, *this);
 }
 
+Common::Error StartupFlow::runTownMapSelector(const Common::String &mapEntryName,
+		Common::String &destinationEntranceName) {
+	destinationEntranceName.clear();
+
+	StartupScript *startupScript = _engine.getStartupScript();
+	ResourceManager *resources = _engine.getResources();
+	Graphics::Screen *screen = _engine.getScreen();
+	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	if (!startupScript || !resources || !screen || !font)
+		return Common::kReadingFailed;
+
+	const StartupMapEntranceRecord *mapEntrance = startupScript->findMapEntranceRecord(mapEntryName);
+	if (!mapEntrance) {
+		warning("Harvester: unresolved town map entry '%s'", mapEntryName.c_str());
+		return Common::kReadingFailed;
+	}
+
+	byte palette[256 * 3];
+	if (!loadPaletteResource(*resources, kTownMapPalettePath, palette))
+		return Common::kReadingFailed;
+
+	Common::Array<IndexedBitmap> panels;
+	panels.resize(ARRAYSIZE(kTownMapBitmapPaths));
+	for (uint i = 0; i < ARRAYSIZE(kTownMapBitmapPaths); ++i) {
+		if (!loadBitmapResource(*resources, kTownMapBitmapPaths[i], panels[i]))
+			return Common::kReadingFailed;
+	}
+
+	const Common::String previousMusicPath = _engine.getStartupMusicPath();
+	(void)_engine.playStartupMusic(kTownMapMusicPath);
+
+	if (!ensureCursorEntity())
+		return Common::kReadingFailed;
+	resetCursorAnimationSequence();
+
+	if (_mousePos.x < 0 || _mousePos.y < 0 || _mousePos.x >= screen->w || _mousePos.y >= screen->h)
+		_mousePos = Common::Point(screen->w / 2, screen->h / 2);
+	if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities())
+		(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+
+	int currentPanel = clampTownMapPanelIndex(mapEntrance->initialPanelIndex);
+	bool needsRedraw = true;
+	Graphics::FrameLimiter limiter(g_system, 60);
+
+	auto centerCursor = [&]() {
+		_mousePos = Common::Point(screen->w / 2, screen->h / 2);
+		if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities())
+			(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+	};
+	auto restorePreviousMusic = [&]() {
+		if (!destinationEntranceName.empty())
+			return;
+		if (!previousMusicPath.empty())
+			(void)_engine.playStartupMusic(previousMusicPath);
+		else
+			_engine.stopStartupMusic();
+	};
+
+	while (!_engine.shouldQuit()) {
+		const int edgePanel = resolveTownMapEdgePanel(currentPanel, _mousePos, screen->w, screen->h);
+		if (edgePanel != currentPanel) {
+			currentPanel = edgePanel;
+			centerCursor();
+			needsRedraw = true;
+		}
+
+		const StartupMapLocationRecord *hoveredLocation =
+			findTownMapLocationAt(startupScript->getMapLocations(), currentPanel, _mousePos);
+		if (needsRedraw) {
+			setScaledPalette(*screen, palette, 1.0f);
+			screen->fillRect(screen->getBounds(), 0);
+			blitBitmap(*screen, panels[(uint)currentPanel], 0, 0);
+			if (hoveredLocation) {
+				drawShadowedString(*screen, *font, hoveredLocation->labelText,
+					hoveredLocation->labelX, hoveredLocation->labelY, screen->w, kTownMapLabelColor);
+			}
+			if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities())
+				runtimeEntities->drawCursor(*screen);
+			screen->makeAllDirty();
+			screen->update();
+			needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (handleSystemEvent(event, result)) {
+				restorePreviousMusic();
+				return result;
+			}
+
+			switch (event.type) {
+			case Common::EVENT_MOUSEMOVE:
+				needsRedraw = true;
+				break;
+			case Common::EVENT_LBUTTONDOWN: {
+				const StartupMapLocationRecord *clickedLocation =
+					findTownMapLocationAt(startupScript->getMapLocations(), currentPanel, _mousePos);
+				if (!clickedLocation)
+					break;
+
+				const StartupEntranceRecord *destinationEntrance =
+					startupScript->findEntranceRecord(clickedLocation->destinationEntranceName);
+				if (!destinationEntrance) {
+					warning("Harvester: unresolved town map destination '%s' from '%s'",
+						clickedLocation->destinationEntranceName.c_str(), mapEntryName.c_str());
+					break;
+				}
+
+				destinationEntranceName = destinationEntrance->entranceName;
+				debugC(1, kDebugGeneral,
+					"Harvester: town map selection entry='%s' panel=%d label='%s' destination='%s'",
+					mapEntryName.c_str(), currentPanel, clickedLocation->labelText.c_str(),
+					destinationEntranceName.c_str());
+				return Common::kNoError;
+			}
+			case Common::EVENT_KEYDOWN: {
+				const int nextPanel = resolveTownMapKeyPanel(currentPanel, event.kbd.keycode);
+				if (nextPanel != currentPanel) {
+					currentPanel = nextPanel;
+					centerCursor();
+					needsRedraw = true;
+				}
+				break;
+			}
+			default:
+				break;
+			}
+		}
+
+		if (tickRuntimeEntities())
+			needsRedraw = true;
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	restorePreviousMusic();
+	return Common::kNoError;
+}
+
+Common::Error StartupFlow::resolveRoomTransitionTarget(const Common::String &targetName,
+		Common::String &resolvedTargetName) {
+	resolvedTargetName = targetName;
+
+	StartupScript *startupScript = _engine.getStartupScript();
+	if (!startupScript || targetName.empty())
+		return Common::kNoError;
+
+	if (startupScript->findMapEntranceRecord(targetName))
+		return runTownMapSelector(targetName, resolvedTargetName);
+
+	return Common::kNoError;
+}
+
 void StartupFlow::queueDialogueInteraction(const StartupInteractionResult &interaction) {
 	_queuedDialogueInteraction = interaction;
 	_hasQueuedDialogueInteraction = true;
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/startup_flow.h
index f91b2ca7f97..d29d78759ab 100644
--- a/engines/harvester/startup_flow.h
+++ b/engines/harvester/startup_flow.h
@@ -61,7 +61,9 @@ private:
 	Common::Error runRoomMenuStub(const IndexedBitmap &backdrop);
 	Common::Error runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, const StartupNpcRecord &npc, const Common::String &usedItemName);
+	Common::Error runTownMapSelector(const Common::String &mapEntryName, Common::String &destinationEntranceName);
 	Common::Error runRoomLoop(const Common::String &entranceName);
+	Common::Error resolveRoomTransitionTarget(const Common::String &targetName, Common::String &resolvedTargetName);
 	bool ensureCursorEntity();
 	bool populateRoomSceneEntities(const StartupRoomSetupState &state,
 		const Common::Array<StartupObjectRecord> &drawableObjects,
diff --git a/engines/harvester/startup_room.cpp b/engines/harvester/startup_room.cpp
index 7c2022677e7..ddc475e58db 100644
--- a/engines/harvester/startup_room.cpp
+++ b/engines/harvester/startup_room.cpp
@@ -227,11 +227,19 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 
 			if (!interaction.nextRoomName.empty() &&
 					roomTransition == kStartupRoomTransitionChangeRoom) {
+				Common::String resolvedTransitionTarget = interaction.nextRoomName;
+				Common::Error resolveError = startupFlow.resolveRoomTransitionTarget(
+					interaction.nextRoomName, resolvedTransitionTarget);
+				if (resolveError.getCode() != Common::kNoError)
+					return resolveError;
+				if (resolvedTransitionTarget.empty())
+					return Common::kNoError;
+
 				if (!runRoomExitCommandsFn())
 					return Common::kReadingFailed;
 
 				// Native CHANGE_ROOM queues a room handoff for the live loop instead of nesting.
-				pendingRoomChange = interaction.nextRoomName;
+				pendingRoomChange = resolvedTransitionTarget;
 				didTransition = true;
 			} else if (!interaction.nextRoomName.empty()) {
 				if (!runRoomExitCommandsFn())
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 52fabd8551a..41b23a3fff1 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -190,6 +190,8 @@ bool StartupScript::load(ResourceManager &resources) {
 	_path = kDefaultTownScript;
 	_data.clear();
 	_entrances.clear();
+	_mapEntrances.clear();
+	_mapLocations.clear();
 	_rooms.clear();
 	_objects.clear();
 	_animations.clear();
@@ -273,6 +275,8 @@ void StartupScript::decode() {
 
 void StartupScript::parseTownRecords(ResourceManager &resources) {
 	_entrances.clear();
+	_mapEntrances.clear();
+	_mapLocations.clear();
 	_rooms.clear();
 	_objects.clear();
 	_animations.clear();
@@ -296,7 +300,8 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 
 		uint tagIndex = tokens.size();
 		for (uint i = 0; i < tokens.size(); ++i) {
-			if (tokens[i] == "ANIM" || tokens[i] == "ENTRANCE" || tokens[i] == "ROOM" || tokens[i] == "OBJECT" ||
+			if (tokens[i] == "ANIM" || tokens[i] == "ENTRANCE" || tokens[i] == "MAP_ENTRANCE" ||
+				tokens[i] == "MAP_LOCATION" || tokens[i] == "ROOM" || tokens[i] == "OBJECT" ||
 				tokens[i] == "NPC" || tokens[i] == "REGION" || tokens[i] == "HEAD" ||
 				tokens[i] == "FLAG" || tokens[i] == "COMMAND" || tokens[i] == "TEXT" || tokens[i] == "USEITEM") {
 				tagIndex = i;
@@ -407,6 +412,47 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 			return;
 		}
 
+		if (tag == "MAP_ENTRANCE") {
+			if (tokens.size() < tagIndex + 2)
+				return;
+
+			StartupMapEntranceRecord mapEntrance;
+			if (tagIndex >= 3) {
+				mapEntrance.field0 = atoi(tokens[0].c_str());
+				mapEntrance.field4 = atoi(tokens[1].c_str());
+				mapEntrance.initialPanelIndex = atoi(tokens[2].c_str());
+			}
+			mapEntrance.entryName = tokens[tagIndex + 1];
+			if (!mapEntrance.entryName.empty())
+				_mapEntrances.push_back(mapEntrance);
+			return;
+		}
+
+		if (tag == "MAP_LOCATION") {
+			if (tokens.size() < tagIndex + 3)
+				return;
+
+			StartupMapLocationRecord mapLocation;
+			if (tagIndex >= 7) {
+				mapLocation.minX = atoi(tokens[0].c_str());
+				mapLocation.minY = atoi(tokens[1].c_str());
+				mapLocation.maxX = atoi(tokens[2].c_str());
+				mapLocation.maxY = atoi(tokens[3].c_str());
+				mapLocation.panelIndex = atoi(tokens[4].c_str());
+				mapLocation.labelX = atoi(tokens[5].c_str());
+				mapLocation.labelY = atoi(tokens[6].c_str());
+			}
+			mapLocation.labelText = tokens[tagIndex + 1];
+			for (uint i = 0; i < mapLocation.labelText.size(); ++i) {
+				if (mapLocation.labelText[i] == '_')
+					mapLocation.labelText.setChar(' ', i);
+			}
+			mapLocation.destinationEntranceName = tokens[tagIndex + 2];
+			if (!mapLocation.destinationEntranceName.empty())
+				_mapLocations.push_back(mapLocation);
+			return;
+		}
+
 		if (tag == "ROOM") {
 			if (tokens.size() < tagIndex + 10)
 				return;
@@ -590,8 +636,9 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 
 	parseLine(line);
 
-	debug(1, "Harvester: parsed %u entrances, %u rooms, %u objects, %u anims, %u npcs, %u regions, %u flags, %u commands, %u texts, %u heads, %u useitems from '%s'",
-		(uint)_entrances.size(), (uint)_rooms.size(), (uint)_objects.size(), (uint)_animations.size(),
+	debug(1, "Harvester: parsed %u entrances, %u map entrances, %u map locations, %u rooms, %u objects, %u anims, %u npcs, %u regions, %u flags, %u commands, %u texts, %u heads, %u useitems from '%s'",
+		(uint)_entrances.size(), (uint)_mapEntrances.size(), (uint)_mapLocations.size(),
+		(uint)_rooms.size(), (uint)_objects.size(), (uint)_animations.size(),
 		(uint)_npcs.size(), (uint)_regions.size(),
 		(uint)_flags.size(), (uint)_commands.size(), (uint)_texts.size(), (uint)_heads.size(),
 		(uint)_useItems.size(), _path.c_str());
@@ -825,6 +872,18 @@ const StartupEntranceRecord *StartupScript::findEntranceRecord(const Common::Str
 	return nullptr;
 }
 
+const StartupMapEntranceRecord *StartupScript::findMapEntranceRecord(const Common::String &entryName) const {
+	if (entryName.empty())
+		return nullptr;
+
+	for (const StartupMapEntranceRecord &mapEntrance : _mapEntrances) {
+		if (mapEntrance.entryName.equalsIgnoreCase(entryName))
+			return &mapEntrance;
+	}
+
+	return nullptr;
+}
+
 const StartupUseItemRecord *StartupScript::findUseItemRecord(const Common::String &itemName,
 		const StartupObjectRecord &target) const {
 	if (itemName.empty() || target.objectName.empty())
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 82b0fb9cd97..3a3e6836743 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -39,6 +39,25 @@ struct StartupEntranceRecord {
 	Common::String entranceName;
 };
 
+struct StartupMapEntranceRecord {
+	int field0 = 0;
+	int field4 = 0;
+	int initialPanelIndex = 0;
+	Common::String entryName;
+};
+
+struct StartupMapLocationRecord {
+	int minX = 0;
+	int minY = 0;
+	int maxX = 0;
+	int maxY = 0;
+	int panelIndex = 0;
+	int labelX = 0;
+	int labelY = 0;
+	Common::String labelText;
+	Common::String destinationEntranceName;
+};
+
 struct StartupRoomRecord {
 	int minZ = 0;
 	int maxZ = 0;
@@ -266,6 +285,8 @@ public:
 	const Common::String &getPath() const { return _path; }
 	const Common::Array<byte> &getData() const { return _data; }
 	const Common::Array<StartupEntranceRecord> &getEntrances() const { return _entrances; }
+	const Common::Array<StartupMapEntranceRecord> &getMapEntrances() const { return _mapEntrances; }
+	const Common::Array<StartupMapLocationRecord> &getMapLocations() const { return _mapLocations; }
 	const Common::Array<StartupRoomRecord> &getRooms() const { return _rooms; }
 	const Common::Array<StartupObjectRecord> &getObjects() const { return _objects; }
 	const Common::Array<StartupAnimRecord> &getAnimations() const { return _animations; }
@@ -304,6 +325,8 @@ public:
 		Common::String resolveObjectLabel(const StartupObjectRecord &object) const;
 		Common::String resolveTextValue(const Common::String &key) const;
 		const StartupHeadRecord *findHeadRecord(const Common::String &headId) const;
+		const StartupEntranceRecord *findEntranceRecord(const Common::String &entranceName) const;
+		const StartupMapEntranceRecord *findMapEntranceRecord(const Common::String &entryName) const;
 		bool getFlagValue(const Common::String &flagName) const;
 		const StartupNpcRecord *findRuntimeNpcRecord(const Common::String &npcName) const;
 		bool setRuntimeFlagValue(const Common::String &flagName, bool value);
@@ -320,7 +343,6 @@ private:
 	bool loadConfig(ResourceManager &resources);
 	void decode();
 	void parseTownRecords(ResourceManager &resources);
-	const StartupEntranceRecord *findEntranceRecord(const Common::String &entranceName) const;
 	const StartupRoomRecord *findRoomRecord(const Common::String &roomName) const;
 	const StartupCommandRecord *findCommandRecord(const Common::String &tag) const;
 	const StartupTextRecord *findTextRecord(const Common::String &key) const;
@@ -348,6 +370,8 @@ private:
 	Common::String _path;
 	Common::Array<byte> _data;
 	Common::Array<StartupEntranceRecord> _entrances;
+	Common::Array<StartupMapEntranceRecord> _mapEntrances;
+	Common::Array<StartupMapLocationRecord> _mapLocations;
 	Common::Array<StartupRoomRecord> _rooms;
 	Common::Array<StartupObjectRecord> _objects;
 	Common::Array<StartupAnimRecord> _animations;


Commit: 8ee2d7d7be1e50f1c45b0025b6c754e10a30dbd7
    https://github.com/scummvm/scummvm/commit/8ee2d7d7be1e50f1c45b0025b6c754e10a30dbd7
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:21+02:00

Commit Message:
HARVESTER: Restore startup exit overlap and fast clicks

Changed paths:
    TRACKER.md
    engines/harvester/startup_room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index c19d043baa4..d9c14dc6514 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,9 +2,9 @@
 
 ## Current Focus
 
-- Match startup-room handoff behavior to the native `CHANGE_ROOM` / map-transition flow
+- Match startup-room exit interactions to the native region-overlap and rapid-click behavior
 - Keep room and closeup transitions scoped to behavior confirmed in Ghidra
-- Only replace recursive engine behavior where the binary shows a queued pending-room handoff
+- Only replace recursive or simplified engine behavior where the binary shows a distinct queued or immediate handoff
 
 ## Progress
 
@@ -21,8 +21,15 @@
 - Confirmed the dialogue-triggered Jimmy paper handoff was still losing `CHANGE_ROOM` semantics in engine code: `jimmy_dialogue.cpp` manually copied `nextRoomName` out of `ACTV_HOUSE_EXIT` but dropped `roomTransition`, which downgraded the queued handoff back into the legacy nested-room path.
 - Confirmed another room-setup mismatch against the recovered native constructor rules: the engine was requiring room backgrounds to live under `GRAPHIC/ROOMS/*.BM`, while the documented native `spawn_object_entity_from_record` promotion rule is based on a sprite-backed object at origin whose loaded bitmap is `640x480`.
 - Confirmed the remaining `PCHOUSE_2_MAP` failure was not a missing room at all: native `resolve_room_entrance` falls back from `ENTRANCE` to `MAP_ENTRANCE`, and map-entry matches open the town map UI which then resolves a final destination through `MAP_LOCATION -> destination_entrance -> ENTRANCE`.
+- Confirmed two native exit-region behaviors in Ghidra before patching the room loop:
+  - `check_player_region_interaction` is called from `run_harvester_main_loop` for every class `0x19` region each frame and dispatches the region action as soon as the player sprite overlaps the region bounds, the Z ranges overlap, the region is enabled, and the facing matches.
+  - The mouse callback installed by `initialize_mouse_input` sets the `DAT_000d5975` fast-click latch when a left-button press happens within 20 ticks of the previous left-button release; `run_harvester_main_loop` uses that latch to jump straight into region dispatch on exit clicks except in `LAVAPIT`, `RMNBATH`, and `BOWLSNTRY1`.
+- Updated the engine room loop to match those confirmed behaviors:
+  - walking into an enabled exit region now triggers the region action without requiring that exact hotspot to have been clicked first;
+  - a native-style rapid second left click on an exit region now dispatches immediately instead of queuing a walk-up first;
+  - region activation now stops active room movement before dispatch so exit/dialogue actions do not continue the previous walk path.
 
 ## Next Suggested Action
 
-1. Re-test both `PCHOUSE_X4B -> PCHOUSE_2_MAP` and the Jimmy `DIAL_JIM_1_C -> CHANGE_ROOM PCHOUSE_2_MAP` path and confirm they now open the town map UI instead of throwing `reading data failed`.
-2. If the map UI is functionally correct but visually off, compare initial cursor placement and panel-switch polish against the native `select_town_map_destination` path before moving on to closeup cleanup.
+1. Re-test `PCHOUSE_X4B` in two ways: walk across the exit after clicking nearby ground, and rapid-double-click the exit itself. Both should now enter the town-map path without waiting for the player to finish a queued walk.
+2. Re-test a non-map exit plus one of the native fast-click blacklist rooms (`LAVAPIT`, `RMNBATH`, or `BOWLSNTRY1`) to confirm the new shortcut is limited to the same rooms the binary allows.
diff --git a/engines/harvester/startup_room.cpp b/engines/harvester/startup_room.cpp
index ddc475e58db..feb014da08c 100644
--- a/engines/harvester/startup_room.cpp
+++ b/engines/harvester/startup_room.cpp
@@ -37,6 +37,14 @@
 
 namespace Harvester {
 
+static const uint32 kRoomExitFastClickWindowTicks = 20;
+
+static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
+	return !roomName.equalsIgnoreCase("LAVAPIT") &&
+		!roomName.equalsIgnoreCase("RMNBATH") &&
+		!roomName.equalsIgnoreCase("BOWLSNTRY1");
+}
+
 StartupRoomSystem::StartupRoomSystem(HarvesterEngine &engine, Common::Point &mousePos,
 		StartupInventorySystem &inventory)
 	: _engine(engine), _mousePos(mousePos), _inventory(inventory) {
@@ -104,6 +112,7 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		bool moveDown = false;
 		Common::String pendingRegionName;
 		Common::String pendingRoomChange;
+		uint32 lastLeftButtonReleaseTick = 0;
 		StartupRoomIdleAnimationState idleState;
 		bool needsRedraw = true;
 		auto resetIdleState = [&]() {
@@ -117,6 +126,18 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			updatePlayerIdleTrigger(idleState);
 		};
 		resetIdleState();
+		auto stopPlayerRegionInteraction = [&]() {
+			moveLeft = false;
+			moveRight = false;
+			moveUp = false;
+			moveDown = false;
+			pendingRegionName.clear();
+			playerState.hasMoveTarget = false;
+			playerState.turnActive = false;
+			playerState.turnTargetFacing = -1;
+			if (playerState.entity && playerState.facing >= 0)
+				(void)setPlayerIdleAnimation(playerState, playerState.facing);
+		};
 
 	auto openInventoryOverlay = [&]() {
 		moveLeft = false;
@@ -406,12 +427,30 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			return Common::kNoError;
 		}
 
-		pendingRegionName.clear();
+		stopPlayerRegionInteraction();
 		return runRegionInteraction(*region);
 	};
-	if (!_inventory.refresh())
-		return Common::kReadingFailed;
-	Graphics::FrameLimiter limiter(g_system, 60);
+	auto tryActivateOverlappedRegion = [&]() -> Common::Error {
+		if (!playerState.entity)
+			return Common::kNoError;
+
+		for (const StartupRegionRecord &region : scene.sceneRegions) {
+			if (!region.startEnabled)
+				continue;
+			if (!doesPlayerOverlapRegion(*playerState.entity, region))
+				continue;
+			if (!doesPlayerFacingMatchRegion(playerState.facing, region))
+				continue;
+
+			stopPlayerRegionInteraction();
+			return runRegionInteraction(region);
+		}
+
+		return Common::kNoError;
+	};
+		if (!_inventory.refresh())
+			return Common::kReadingFailed;
+		Graphics::FrameLimiter limiter(g_system, 60);
 
 	if (shouldRunStartupRoomProbe())
 		logStartupRoomProbe(_engine, scene, currentRoomTarget, _mousePos);
@@ -511,10 +550,15 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 				}
 				break;
 			case Common::EVENT_LBUTTONUP:
+				lastLeftButtonReleaseTick = getRuntimeClockTicks();
 				if (showingInspectText)
 					inspectCanDismiss = true;
 				break;
 			case Common::EVENT_LBUTTONDOWN: {
+				const uint32 now = getRuntimeClockTicks();
+				const bool isFastExitClick =
+					lastLeftButtonReleaseTick != 0 &&
+					now - lastLeftButtonReleaseTick < kRoomExitFastClickWindowTicks;
 				notePlayerActivity();
 				if (idleState.active || idleState.exiting) {
 					if (requestPlayerIdleAnimationExit(scene.state, playerState, idleState))
@@ -640,6 +684,18 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 					break;
 				}
 				if (hoverState.region && playerState.entity) {
+					if (isFastExitClick &&
+						hoverState.region->startEnabled &&
+						roomAllowsImmediateExitClick(scene.state.roomName)) {
+						stopPlayerRegionInteraction();
+						Common::Error interactionError = runRegionInteraction(*hoverState.region);
+						if (interactionError.getCode() != Common::kNoError)
+							return interactionError;
+						if (startupFlow.hasPendingMainMenuReturn())
+							return Common::kNoError;
+						needsRedraw = true;
+						break;
+					}
 					queueRegionInteraction(*hoverState.region);
 					Common::Error interactionError = tryActivatePendingRegion();
 					if (interactionError.getCode() != Common::kNoError)
@@ -808,16 +864,21 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		if (!pendingRoomChange.empty())
 			break;
 
-		if (updatePlayerTurnAnimationState(playerState))
+		bool playerAdvancedThisFrame = false;
+		if (updatePlayerTurnAnimationState(playerState)) {
+			playerAdvancedThisFrame = true;
 			needsRedraw = true;
+		}
 
 		if (!idleState.active && !idleState.exiting) {
 			if (stepPlayerKeyboardMovement(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
 					playerState, moveLeft, moveRight, moveUp, moveDown)) {
+				playerAdvancedThisFrame = true;
 				notePlayerActivity();
 				needsRedraw = true;
 			} else if (stepPlayerMoveTarget(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
 					playerState)) {
+				playerAdvancedThisFrame = true;
 				notePlayerActivity();
 				needsRedraw = true;
 			} else if (!moveLeft && !moveRight && !moveUp && !moveDown && !playerState.hasMoveTarget &&
@@ -837,6 +898,13 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			}
 		}
 		Common::Error pendingRegionError = tryActivatePendingRegion();
+		if (pendingRegionError.getCode() != Common::kNoError)
+			return pendingRegionError;
+		if (startupFlow.hasPendingMainMenuReturn())
+			return Common::kNoError;
+		if (!pendingRoomChange.empty())
+			break;
+		pendingRegionError = playerAdvancedThisFrame ? tryActivateOverlappedRegion() : Common::kNoError;
 		if (pendingRegionError.getCode() != Common::kNoError)
 			return pendingRegionError;
 		if (startupFlow.hasPendingMainMenuReturn())


Commit: 290838d357d00db35dc729284e8cff09e38f55a8
    https://github.com/scummvm/scummvm/commit/290838d357d00db35dc729284e8cff09e38f55a8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:21+02:00

Commit Message:
HARVESTER: Restore inventory portrait health state

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/startup_inventory.cpp
    engines/harvester/startup_room.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index a3283774689..62abaf55ad0 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1168,6 +1168,33 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - It always appends `g_dialogue_keyword_menu_default_topic`, loaded by `load_dialogue_index` from zero-based `dialog.rsp` line `13`, plus a literal `Other` entry that calls `run_text_entry_dialog`.
   - ESC does not silently cancel the keyword menu; it writes that default topic into `g_dialogue_selected_topic_text` and returns through the normal handler path.
 
+## Player Health And Inventory Status Portrait
+
+**Confidence:** High
+
+**Evidence**
+- `run_inventory_screen` reads `RenderEntityRuntime.current_hit_points` from the live player combat-avatar runtime and selects `INV_STAT1`, `INV_STAT2`, `INV_STAT3`, or `INV_STAT4` for the portrait in the lower-left of the inventory panel.
+- The thresholds are explicit in the inventory screen logic: `23..30 -> INV_STAT1`, `15..22 -> INV_STAT2`, `8..14 -> INV_STAT3`, `0..7 -> INV_STAT4`.
+- `run_harvester_main_loop` initializes `g_player_current_hit_points` to `30`, `spawn_player_combat_avatar` copies that global into `RenderEntityRuntime.current_hit_points`, and `teardown_player_combat_avatar` writes the runtime value back to the same global.
+- `dispatch_room_event_actions` handles `HEAL_PC`, `ADJ_HP`, and `KILL_PC` against that player HP state; `HEAL_PC` and `ADJ_HP` add to the current value and clamp to `30`, while `KILL_PC` zeros it.
+- `run_save_game_menu` and `run_load_game_menu` persist the same player combat-avatar HP field.
+
+**Key Functions**
+- `run_inventory_screen`
+- `dispatch_room_event_actions`
+- `spawn_player_combat_avatar`
+- `teardown_player_combat_avatar`
+- `run_save_game_menu`
+- `run_load_game_menu`
+
+**Key Data**
+- `g_player_current_hit_points`
+- `RenderEntityRuntime.current_hit_points`
+
+**Notes**
+- The inventory status portrait is a presentation of the persistent player HP state, not a separate UI-only counter.
+- The portrait is recalculated inside the inventory loop, so native healing or damage that lands while the inventory is open updates the selected `INV_STAT*` image immediately.
+
 ## Current Blockers
 
 - The remaining work is no longer list recovery. The blocker is semantic naming for fields whose shape is clear but whose gameplay meaning is still ambiguous.
diff --git a/TRACKER.md b/TRACKER.md
index d9c14dc6514..1df8172ab35 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,9 +2,9 @@
 
 ## Current Focus
 
-- Match startup-room exit interactions to the native region-overlap and rapid-click behavior
-- Keep room and closeup transitions scoped to behavior confirmed in Ghidra
-- Only replace recursive or simplified engine behavior where the binary shows a distinct queued or immediate handoff
+- Trace the native player-HP path from the inventory portrait back through room-event and actor-runtime state
+- Keep health-related engine changes scoped to behavior confirmed in Ghidra
+- Only rename health state when the inventory screen, command handlers, and persistence path all agree on the same field or global
 
 ## Progress
 
@@ -15,21 +15,17 @@
 
 ## Last Confirmed Action
 
-- Recovered the native startup-room handoff path for `CHANGE_ROOM`, `CLOSEUP`, and `EXIT_CLOSEUP` from `parse_command_record`, `dispatch_room_event_actions`, and `run_harvester_main_loop`.
-  - `CHANGE_ROOM` resolves an entrance and queues `g_pending_room_name`; `CLOSEUP` queues a raw room name and clears `g_player_present_in_room`; the main loop later consumes `g_pending_room_name` and calls `room_setup`.
-  - Updated the Ghidra plate comments for those functions and changed the engine startup-room loop so `CHANGE_ROOM` now performs a same-loop handoff instead of recursing into a nested room loop and then rebuilding the source room on return.
-- Confirmed the dialogue-triggered Jimmy paper handoff was still losing `CHANGE_ROOM` semantics in engine code: `jimmy_dialogue.cpp` manually copied `nextRoomName` out of `ACTV_HOUSE_EXIT` but dropped `roomTransition`, which downgraded the queued handoff back into the legacy nested-room path.
-- Confirmed another room-setup mismatch against the recovered native constructor rules: the engine was requiring room backgrounds to live under `GRAPHIC/ROOMS/*.BM`, while the documented native `spawn_object_entity_from_record` promotion rule is based on a sprite-backed object at origin whose loaded bitmap is `640x480`.
-- Confirmed the remaining `PCHOUSE_2_MAP` failure was not a missing room at all: native `resolve_room_entrance` falls back from `ENTRANCE` to `MAP_ENTRANCE`, and map-entry matches open the town map UI which then resolves a final destination through `MAP_LOCATION -> destination_entrance -> ENTRANCE`.
-- Confirmed two native exit-region behaviors in Ghidra before patching the room loop:
-  - `check_player_region_interaction` is called from `run_harvester_main_loop` for every class `0x19` region each frame and dispatches the region action as soon as the player sprite overlaps the region bounds, the Z ranges overlap, the region is enabled, and the facing matches.
-  - The mouse callback installed by `initialize_mouse_input` sets the `DAT_000d5975` fast-click latch when a left-button press happens within 20 ticks of the previous left-button release; `run_harvester_main_loop` uses that latch to jump straight into region dispatch on exit clicks except in `LAVAPIT`, `RMNBATH`, and `BOWLSNTRY1`.
-- Updated the engine room loop to match those confirmed behaviors:
-  - walking into an enabled exit region now triggers the region action without requiring that exact hotspot to have been clicked first;
-  - a native-style rapid second left click on an exit region now dispatches immediately instead of queuing a walk-up first;
-  - region activation now stops active room movement before dispatch so exit/dialogue actions do not continue the previous walk path.
+- Traced the inventory portrait health path in Ghidra from `run_inventory_screen` back into room-event handling and actor persistence.
+  - Renamed `DAT_000d5bb4` to `g_player_current_hit_points` and recovered `RenderEntityRuntime.current_hit_points` at offset `0x1184`.
+  - Confirmed the inventory portrait thresholds used by `run_inventory_screen`: `23..30 -> INV_STAT1`, `15..22 -> INV_STAT2`, `8..14 -> INV_STAT3`, `0..7 -> INV_STAT4`.
+  - Confirmed `dispatch_room_event_actions` applies `HEAL_PC`, `ADJ_HP`, and `KILL_PC` directly to that HP state, with the healing/adjust path clamped to `30`.
+  - Confirmed `run_harvester_main_loop` seeds HP to `30`, `spawn_player_combat_avatar` copies the global into the live actor runtime, `teardown_player_combat_avatar` copies it back out, and the save/load menu code persists the same runtime field.
+- Updated the engine inventory/runtime path to match those confirmed behaviors:
+  - the startup script now keeps a persistent player HP value and applies native `HEAL_PC`, `ADJ_HP`, and `KILL_PC` mutations to it;
+  - the inventory panel now synthesizes the native `INV_STAT1..4` status object from current HP instead of treating those records as ordinary grid items;
+  - the health portrait remains non-actionable in the inventory UI.
 
 ## Next Suggested Action
 
-1. Re-test `PCHOUSE_X4B` in two ways: walk across the exit after clicking nearby ground, and rapid-double-click the exit itself. Both should now enter the town-map path without waiting for the player to finish a queued walk.
-2. Re-test a non-map exit plus one of the native fast-click blacklist rooms (`LAVAPIT`, `RMNBATH`, or `BOWLSNTRY1`) to confirm the new shortcut is limited to the same rooms the binary allows.
+1. Trace where combat or scripted damage outside `dispatch_room_event_actions` decrements `RenderEntityRuntime.current_hit_points`, then decide whether the engine needs a second HP producer beyond the startup-script command path.
+2. Confirm whether the adjacent player actor fields around `+0x1188` participate in death-state or portrait-side effects before naming more of `RenderEntityRuntime`.
diff --git a/engines/harvester/startup_inventory.cpp b/engines/harvester/startup_inventory.cpp
index cddc151c0b1..b1961168272 100644
--- a/engines/harvester/startup_inventory.cpp
+++ b/engines/harvester/startup_inventory.cpp
@@ -125,12 +125,17 @@ bool StartupInventorySystem::refresh() {
 			_items.push_back(visual);
 			continue;
 		}
-		if (isStatusObject(inventoryObject))
-			continue;
 
 		const Common::String spritePath = resolveSceneObjectSpritePath(inventoryObject);
 		if (!spritePath.empty() && loadBitmapResource(*resources, spritePath, visual.bitmap)) {
 			visual.hasBitmap = true;
+			if (isStatusObject(inventoryObject)) {
+				visual.bounds = Common::Rect(visual.object.currentX, visual.object.currentY,
+					visual.object.currentX + visual.bitmap.width, visual.object.currentY + visual.bitmap.height);
+				_items.push_back(visual);
+				continue;
+			}
+
 			if (nextX + (int)visual.bitmap.width > kInventoryItemMaxRight) {
 				nextX = kInventoryItemStartX;
 				nextY += rowHeight + kInventoryItemSpacing;
@@ -146,6 +151,11 @@ bool StartupInventorySystem::refresh() {
 			visual.bounds = getHotspotBounds(inventoryObject);
 		}
 
+		if (isStatusObject(inventoryObject)) {
+			_items.push_back(visual);
+			continue;
+		}
+
 		_items.push_back(visual);
 	}
 
diff --git a/engines/harvester/startup_room.cpp b/engines/harvester/startup_room.cpp
index feb014da08c..e7fb225bc65 100644
--- a/engines/harvester/startup_room.cpp
+++ b/engines/harvester/startup_room.cpp
@@ -480,13 +480,14 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 				if (_inventory.hasSelection()) {
 					Common::String targetLabel;
 					if (inventoryHover && !StartupInventorySystem::isExitObject(inventoryHover->object) &&
-						!StartupInventorySystem::isStatusObject(inventoryHover->object)) {
+							!StartupInventorySystem::isStatusObject(inventoryHover->object)) {
 						targetLabel = _engine.getStartupScript()->resolveObjectLabel(inventoryHover->object);
 					} else if (!inventoryPanelContainsMouse && hoverState.object) {
 						targetLabel = _engine.getStartupScript()->resolveObjectLabel(*hoverState.object);
 					}
 					promptText = _inventory.buildSelectedPrompt(targetLabel);
-				} else if (inventoryHover) {
+				} else if (inventoryHover &&
+						!StartupInventorySystem::isStatusObject(inventoryHover->object)) {
 					promptText = _engine.getStartupScript()->resolveObjectLabel(inventoryHover->object);
 				}
 				_inventory.setPromptText(promptText);
@@ -585,6 +586,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 								needsRedraw = true;
 							break;
 						}
+						if (StartupInventorySystem::isStatusObject(inventoryHover->object))
+							break;
 
 						if (!_inventory.hasSelection()) {
 							_inventory.selectItem(inventoryHover->object.objectName);
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 41b23a3fff1..bc79ac1d1b1 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -40,6 +40,27 @@ static const char *const kDefaultVoicePath = "SOUND/VOICE/";
 static const byte kTownScriptXorKey = 0xaa;
 static const float kDefaultPaletteBrightness = 1.0f;
 static const float kDimmedPaletteBrightness = 0.6f;
+static const int kDefaultPlayerHitPoints = 30;
+
+static int clampPlayerHitPoints(int hitPoints) {
+	if (hitPoints < 0)
+		return 0;
+	if (hitPoints > kDefaultPlayerHitPoints)
+		return kDefaultPlayerHitPoints;
+
+	return hitPoints;
+}
+
+static const char *resolveInventoryStatusObjectName(int hitPoints) {
+	if (hitPoints < 8)
+		return "INV_STAT4";
+	if (hitPoints < 15)
+		return "INV_STAT3";
+	if (hitPoints < 23)
+		return "INV_STAT2";
+
+	return "INV_STAT1";
+}
 
 static Common::String trimAsciiLine(const Common::String &value) {
 	uint start = 0;
@@ -690,6 +711,7 @@ void StartupScript::resetRuntimeState() {
 	_runtimeRegions = _regions;
 	_runtimeNpcs = _npcs;
 	_runtimeMonsters = _monsters;
+	_playerCurrentHitPoints = kDefaultPlayerHitPoints;
 
 	for (StartupObjectRecord &object : _runtimeObjects) {
 		object.currentX = object.initialX;
@@ -848,11 +870,29 @@ bool StartupScript::hasUseItemInteraction(const Common::String &itemName, const
 
 void StartupScript::getVisibleInventoryObjects(Common::Array<StartupObjectRecord> &objects) const {
 	objects.clear();
+	const char *const statusObjectName = resolveInventoryStatusObjectName(_playerCurrentHitPoints);
+	const StartupObjectRecord *statusObject = nullptr;
 
 	for (const StartupObjectRecord &object : _runtimeObjects) {
+		if (object.objectName.hasPrefixIgnoreCase("INV_STAT")) {
+			if (object.objectName.equalsIgnoreCase(statusObjectName))
+				statusObject = &object;
+			continue;
+		}
+
 		if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName) && object.visible)
 			objects.push_back(object);
 	}
+
+	if (!statusObject)
+		return;
+
+	StartupObjectRecord activeStatusObject = *statusObject;
+	activeStatusObject.currentOwnerOrRoom = kInventoryOwnerName;
+	activeStatusObject.visible = true;
+	activeStatusObject.runtimeVisible = true;
+	activeStatusObject.identShown = true;
+	objects.push_back(activeStatusObject);
 }
 
 void StartupScript::markObjectIdentShown(const StartupObjectRecord &object) {
@@ -1102,6 +1142,13 @@ bool StartupScript::setRuntimeNpcState(const Common::String &npcName, bool activ
 	return changed;
 }
 
+bool StartupScript::setPlayerCurrentHitPoints(int hitPoints) {
+	const int clampedHitPoints = clampPlayerHitPoints(hitPoints);
+	const bool changed = _playerCurrentHitPoints != clampedHitPoints;
+	_playerCurrentHitPoints = clampedHitPoints;
+	return changed;
+}
+
 bool StartupScript::triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcName) {
 	if (npcName.empty())
 		return false;
@@ -1500,6 +1547,20 @@ void StartupScript::executeCommandChain(const Common::String &initialTag, const
 			continue;
 		}
 
+		if (command->opcodeName.equalsIgnoreCase("HEAL_PC") ||
+			command->opcodeName.equalsIgnoreCase("ADJ_HP")) {
+			noteMutation(setPlayerCurrentHitPoints(
+				_playerCurrentHitPoints + atoi(command->arg1.c_str())));
+			currentTag = command->arg4;
+			continue;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("KILL_PC")) {
+			noteMutation(setPlayerCurrentHitPoints(0));
+			currentTag = command->arg4;
+			continue;
+		}
+
 		if (command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
 			if (allowTransitions && nextRoomName) {
@@ -1550,6 +1611,9 @@ bool StartupScript::hasActionableCommandChain(const Common::String &initialTag)
 			command->opcodeName.equalsIgnoreCase("GODEATHFLIC") ||
 			command->opcodeName.equalsIgnoreCase("KILL_NPC") ||
 			command->opcodeName.equalsIgnoreCase("MONSTERFY") ||
+			command->opcodeName.equalsIgnoreCase("HEAL_PC") ||
+			command->opcodeName.equalsIgnoreCase("ADJ_HP") ||
+			command->opcodeName.equalsIgnoreCase("KILL_PC") ||
 			command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
 			return true;
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 3a3e6836743..112d2d8f3c6 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -338,6 +338,7 @@ public:
 		bool triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcName);
 		bool isNamedNpcDeathTypeClear(const Common::String &npcName) const;
 		int getCurrentStoryDayIndex() const;
+		int getPlayerCurrentHitPoints() const { return _playerCurrentHitPoints; }
 
 private:
 	bool loadConfig(ResourceManager &resources);
@@ -366,6 +367,7 @@ private:
 		Common::String *dialogueContinuationTag,
 		bool *mutatedRuntimeState);
 	bool hasActionableCommandChain(const Common::String &initialTag) const;
+	bool setPlayerCurrentHitPoints(int hitPoints);
 
 	Common::String _path;
 	Common::Array<byte> _data;
@@ -389,6 +391,7 @@ private:
 	Common::Array<StartupRegionRecord> _runtimeRegions;
 	Common::Array<StartupNpcRecord> _runtimeNpcs;
 	Common::Array<StartupMonsterRecord> _runtimeMonsters;
+	int _playerCurrentHitPoints = 30;
 	bool _quickTipsEnabled = true;
 	Common::String _voicePath = "SOUND/VOICE/";
 	StartupDialogueTextMode _dialogueTextMode = kStartupDialogueTextYes;


Commit: 10482e14770b3669b36c3ccb853240f7f79a866d
    https://github.com/scummvm/scummvm/commit/10482e14770b3669b36c3ccb853240f7f79a866d
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:21+02:00

Commit Message:
HARVESTER: Stop depth-scaling room NPC spawns

Changed paths:
    engines/harvester/startup_flow.cpp


diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 8acbc249712..ba5927dd18e 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -362,8 +362,8 @@ static void setRoomActorScreenPosition(RuntimeEntity &entity, int centerX, int b
 	entity.setPosition(centerX - xOffset - width / 2, bottomY - height - yOffset, z);
 }
 
-bool applyRoomActorPlacement(const StartupRoomSetupState &state, RuntimeEntity &entity,
-		int centerX, int bottomY, float z, const Common::String *entranceName) {
+static bool applyRoomActorPlacementInternal(const StartupRoomSetupState &state, RuntimeEntity &entity,
+		int centerX, int bottomY, float z, const Common::String *entranceName, bool applyDepthScale) {
 	int width = 0;
 	int height = 0;
 	int xOffset = 0;
@@ -374,11 +374,14 @@ bool applyRoomActorPlacement(const StartupRoomSetupState &state, RuntimeEntity &
 	entity.setAnchorMode(kRuntimeEntityAnchorTopLeft);
 	setRoomActorScreenPosition(entity, centerX, bottomY, z, width, height, xOffset, yOffset);
 
-	const float depthScale = computeActorDepthScale(state, z);
-	entity.setDepthScale(depthScale);
-	if (!entity.getCurrentFrameMetrics(width, height, xOffset, yOffset))
-		return false;
-	setRoomActorScreenPosition(entity, centerX, bottomY, z, width, height, xOffset, yOffset);
+	float depthScale = 1.0f;
+	if (applyDepthScale) {
+		depthScale = computeActorDepthScale(state, z);
+		entity.setDepthScale(depthScale);
+		if (!entity.getCurrentFrameMetrics(width, height, xOffset, yOffset))
+			return false;
+		setRoomActorScreenPosition(entity, centerX, bottomY, z, width, height, xOffset, yOffset);
+	}
 
 	if (entranceName) {
 		debugC(1, kDebugGeneral,
@@ -389,6 +392,11 @@ bool applyRoomActorPlacement(const StartupRoomSetupState &state, RuntimeEntity &
 	return true;
 }
 
+bool applyRoomActorPlacement(const StartupRoomSetupState &state, RuntimeEntity &entity,
+		int centerX, int bottomY, float z, const Common::String *entranceName) {
+	return applyRoomActorPlacementInternal(state, entity, centerX, bottomY, z, entranceName, true);
+}
+
 static bool applyStartupActorPlacement(const StartupRoomSetupState &state, RuntimeEntity &entity) {
 	return applyRoomActorPlacement(state, entity,
 		state.playerSpawnX, state.playerSpawnY, (float)state.playerSpawnZ, &state.entranceName);
@@ -2184,7 +2192,9 @@ bool StartupFlow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 		entity->setAnimationFrameRange(0, MIN(entity->getLastFrame(), kRoomNpcAmbientLastFrame), true);
 		if (npc.frameDelay > 0)
 			entity->setAnimationRate(npc.frameDelay);
-		if (!applyRoomActorPlacement(state, *entity, npc.posX, npc.posY, (float)npc.posZ)) {
+		// Native room NPCs come from spawn_abm_entity_base, which leaves the depth-scale flag cleared.
+		if (!applyRoomActorPlacementInternal(state, *entity,
+				npc.posX, npc.posY, (float)npc.posZ, nullptr, false)) {
 			debug(1, "Harvester: unable to apply room npc placement for '%s'",
 				npc.npcName.c_str());
 		}


Commit: e64cc33137acb94d0cc13815b2654008b63cff74
    https://github.com/scummvm/scummvm/commit/e64cc33137acb94d0cc13815b2654008b63cff74
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:22+02:00

Commit Message:
HARVESTER: Key inventory portrait blits

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/startup_inventory.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 62abaf55ad0..3964f94e5e7 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1194,6 +1194,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 **Notes**
 - The inventory status portrait is a presentation of the persistent player HP state, not a separate UI-only counter.
 - The portrait is recalculated inside the inventory loop, so native healing or damage that lands while the inventory is open updates the selected `INV_STAT*` image immediately.
+- The portrait is materialized through `spawn_object_entity_from_record` and the shared render-entity path, so the rotated photo depends on palette-index-0 transparency rather than an opaque rectangular bitmap blit.
 
 ## Current Blockers
 
diff --git a/TRACKER.md b/TRACKER.md
index 1df8172ab35..f22e34a9919 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -23,7 +23,8 @@
 - Updated the engine inventory/runtime path to match those confirmed behaviors:
   - the startup script now keeps a persistent player HP value and applies native `HEAL_PC`, `ADJ_HP`, and `KILL_PC` mutations to it;
   - the inventory panel now synthesizes the native `INV_STAT1..4` status object from current HP instead of treating those records as ordinary grid items;
-  - the health portrait remains non-actionable in the inventory UI.
+  - the health portrait remains non-actionable in the inventory UI;
+  - revisiting `run_inventory_screen` showed the native portrait is spawned through `spawn_object_entity_from_record` and drawn by the shared render-entity keyed-blit path, so the engine inventory overlay now uses palette-index-0 transparency instead of an opaque bitmap copy.
 
 ## Next Suggested Action
 
diff --git a/engines/harvester/startup_inventory.cpp b/engines/harvester/startup_inventory.cpp
index b1961168272..f4f5b8123d8 100644
--- a/engines/harvester/startup_inventory.cpp
+++ b/engines/harvester/startup_inventory.cpp
@@ -22,7 +22,9 @@
 #include "harvester/startup_inventory.h"
 
 #include "common/endian.h"
+#include "graphics/blit.h"
 #include "graphics/font.h"
+#include "graphics/screen.h"
 #include "harvester/harvester.h"
 #include "harvester/resources.h"
 #include "harvester/startup_art.h"
@@ -39,12 +41,41 @@ static const int kInventoryItemMaxRight = 564;
 static const int kInventoryItemSpacing = 5;
 static const byte kShadowColor = 0;
 static const byte kRoomPromptColor = 0xce;
+static const byte kTransparentPaletteIndex = 0;
 
 static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
 	if (!bitmap.isValid())
 		return;
 
-	screen.copyRectToSurface(bitmap.pixels.data(), bitmap.width, x, y, bitmap.width, bitmap.height);
+	int destX = x;
+	int destY = y;
+	int srcX = 0;
+	int srcY = 0;
+	int width = (int)bitmap.width;
+	int height = (int)bitmap.height;
+
+	if (destX < 0) {
+		srcX = -destX;
+		width += destX;
+		destX = 0;
+	}
+	if (destY < 0) {
+		srcY = -destY;
+		height += destY;
+		destY = 0;
+	}
+	if (destX >= screen.w || destY >= screen.h || width <= 0 || height <= 0)
+		return;
+
+	width = MIN<int>(width, screen.w - destX);
+	height = MIN<int>(height, screen.h - destY);
+	if (width <= 0 || height <= 0)
+		return;
+
+	const byte *src = bitmap.pixels.data() + srcY * bitmap.width + srcX;
+	byte *dst = (byte *)screen.getBasePtr(destX, destY);
+	Graphics::keyBlit(dst, src, screen.pitch, bitmap.width, width, height,
+		screen.format.bytesPerPixel, kTransparentPaletteIndex);
 }
 
 static void drawShadowedString(Graphics::Screen &screen, const Graphics::Font &font, const Common::String &text,


Commit: 3bc7477b43e00fce2fdaa9b13e65a0a9c93d83d3
    https://github.com/scummvm/scummvm/commit/3bc7477b43e00fce2fdaa9b13e65a0a9c93d83d3
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:22+02:00

Commit Message:
HARVESTER: Emulate vga dac palette uploads

Changed paths:
  A engines/harvester/palette_utils.h
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/startup_art.cpp
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_menu.cpp


diff --git a/TRACKER.md b/TRACKER.md
index f22e34a9919..77217b1dd10 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,9 +2,9 @@
 
 ## Current Focus
 
-- Trace the native player-HP path from the inventory portrait back through room-event and actor-runtime state
-- Keep health-related engine changes scoped to behavior confirmed in Ghidra
-- Only rename health state when the inventory screen, command handlers, and persistence path all agree on the same field or global
+- Match room and inventory palette presentation to the native VGA DAC upload path
+- Keep palette changes scoped to confirmed Ghidra behavior instead of screenshot-only tuning
+- Distinguish true native defaults from user config settings before changing brightness or gamma handling
 
 ## Progress
 
@@ -25,8 +25,12 @@
   - the inventory panel now synthesizes the native `INV_STAT1..4` status object from current HP instead of treating those records as ordinary grid items;
   - the health portrait remains non-actionable in the inventory UI;
   - revisiting `run_inventory_screen` showed the native portrait is spawned through `spawn_object_entity_from_record` and drawn by the shared render-entity keyed-blit path, so the engine inventory overlay now uses palette-index-0 transparency instead of an opaque bitmap copy.
+- Revisited the palette/brightness path in Ghidra after comparing DOSBox and engine captures.
+  - Confirmed the native `CONFIG.INI` in the installed game currently has `GAMMA=0`, so the washed-out comparison is not explained by a non-default gamma setting that DOSBox applies and the engine ignores.
+  - Confirmed `update_gamma_brightness_scale` sets the native gamma scalar to `1.0` when `g_gamma_level_index <= 0`, and `upload_palette_to_vga` / `upload_palette_to_vga_unscaled` always quantize stored `.PAL` bytes down to VGA DAC `0..63` before display.
+  - The engine was uploading room and wait palettes as raw 8-bit RGB, so it skipped that DAC quantization step. Updated the engine palette path to emulate the native VGA upload rule before applying room brightness.
 
 ## Next Suggested Action
 
-1. Trace where combat or scripted damage outside `dispatch_room_event_actions` decrements `RenderEntityRuntime.current_hit_points`, then decide whether the engine needs a second HP producer beyond the startup-script command path.
-2. Confirm whether the adjacent player actor fields around `+0x1188` participate in death-state or portrait-side effects before naming more of `RenderEntityRuntime`.
+1. Re-test the same inventory scene against DOSBox after the DAC-quantized palette change to see whether the remaining mismatch is explained by palette upload alone or by a later post-processing/display difference.
+2. If the image is still visibly off, trace whether DOSBox capture differences come from a second native layer such as menu fade timing, scaler/aspect treatment, or platform-side palette handling rather than from Harvester’s own palette math.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 7e1ee86e6d2..3b86507d06a 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -78,6 +78,7 @@
 #include "harvester/npc/ryder_dialogue.h"
 #include "harvester/npc/sergeant_dialogue.h"
 #include "harvester/npc/nude_man_dialogue.h"
+#include "harvester/palette_utils.h"
 #include "harvester/npc/stephanie_dialogue.h"
 #include "harvester/npc/stub_dialogue.h"
 #include "harvester/npc/sparky_dialogue.h"
@@ -185,23 +186,9 @@ static void drawShadowedString(Graphics::Screen &screen, const Graphics::Font &f
 	font.drawString(&screen, text, x, y, width, color, align);
 }
 
-static void buildScaledPalette(const byte *source, float brightness, byte *dest) {
-	memset(dest, 0, 256 * 3);
-	if (!source)
-		return;
-
-	for (uint color = 1; color < 256; ++color) {
-		for (uint channel = 0; channel < 3; ++channel) {
-			const uint index = color * 3 + channel;
-			const int scaled = (int)(source[index] * brightness + 0.5f);
-			dest[index] = (byte)MIN<int>(scaled, 255);
-		}
-	}
-}
-
 static void setScaledPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
 	byte scaledPalette[256 * 3];
-	buildScaledPalette(palette, brightness, scaledPalette);
+	buildHarvesterDisplayPalette(palette, brightness, scaledPalette);
 	screen.setPalette(scaledPalette);
 }
 
diff --git a/engines/harvester/palette_utils.h b/engines/harvester/palette_utils.h
new file mode 100644
index 00000000000..7ca098911a5
--- /dev/null
+++ b/engines/harvester/palette_utils.h
@@ -0,0 +1,57 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_PALETTE_UTILS_H
+#define HARVESTER_PALETTE_UTILS_H
+
+#include <cstring>
+
+#include "common/scummsys.h"
+
+namespace Harvester {
+
+inline byte expandHarvesterVgaDacColor(byte value) {
+	return (value * 255 + 31) / 63;
+}
+
+inline void buildHarvesterDisplayPalette(const byte *source, float brightness, byte *dest) {
+	memset(dest, 0, 256 * 3);
+	if (!source)
+		return;
+
+	if (brightness < 0.0f)
+		brightness = 0.0f;
+
+	for (uint color = 1; color < 256; ++color) {
+		for (uint channel = 0; channel < 3; ++channel) {
+			const uint index = color * 3 + channel;
+			const int vgaDacValue = source[index] >> 2;
+			int scaled = (int)(vgaDacValue * brightness + 0.5f);
+			if (scaled > 63)
+				scaled = 63;
+			dest[index] = expandHarvesterVgaDacColor((byte)scaled);
+		}
+	}
+}
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_PALETTE_UTILS_H
diff --git a/engines/harvester/startup_art.cpp b/engines/harvester/startup_art.cpp
index df3966a19ba..c4355c768ac 100644
--- a/engines/harvester/startup_art.cpp
+++ b/engines/harvester/startup_art.cpp
@@ -26,6 +26,7 @@
 #include "graphics/blit.h"
 #include "graphics/screen.h"
 #include "harvester/detection.h"
+#include "harvester/palette_utils.h"
 #include "harvester/resources.h"
 
 namespace Harvester {
@@ -128,7 +129,9 @@ void StartupArt::drawWaitFrame(Graphics::Screen &screen) const {
 		return;
 
 	logPaletteSummary("applying wait palette", "1:/GRAPHIC/PAL/WAIT.PAL", _waitPalette);
-	screen.setPalette(_waitPalette);
+	byte displayPalette[256 * 3];
+	buildHarvesterDisplayPalette(_waitPalette, 1.0f, displayPalette);
+	screen.setPalette(displayPalette);
 	blitTransparentAnimationFrame(screen, _waitFrames, 0, kWaitX, kWaitY);
 	screen.makeAllDirty();
 	screen.update();
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index ba5927dd18e..218e4669381 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -36,6 +36,7 @@
 #include "graphics/framelimiter.h"
 #include "harvester/detection.h"
 #include "harvester/harvester.h"
+#include "harvester/palette_utils.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
 #include "harvester/startup_art.h"
@@ -685,23 +686,9 @@ static void drawWrappedShadowedText(Graphics::Screen &screen, const Graphics::Fo
 		drawShadowedString(screen, font, lines[i], x, y + i * lineHeight, width, color);
 }
 
-static void buildScaledPalette(const byte *source, float brightness, byte *dest) {
-	memset(dest, 0, 256 * 3);
-	if (!source)
-		return;
-
-	for (uint color = 1; color < 256; ++color) {
-		for (uint channel = 0; channel < 3; ++channel) {
-			const uint index = color * 3 + channel;
-			const int scaled = (int)(source[index] * brightness + 0.5f);
-			dest[index] = (byte)MIN<int>(scaled, 255);
-		}
-	}
-}
-
 static void setScaledPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
 	byte scaledPalette[256 * 3];
-	buildScaledPalette(palette, brightness, scaledPalette);
+	buildHarvesterDisplayPalette(palette, brightness, scaledPalette);
 	screen.setPalette(scaledPalette);
 }
 
diff --git a/engines/harvester/startup_menu.cpp b/engines/harvester/startup_menu.cpp
index 61afadd23a0..cda11bf34b6 100644
--- a/engines/harvester/startup_menu.cpp
+++ b/engines/harvester/startup_menu.cpp
@@ -29,6 +29,7 @@
 #include "graphics/framelimiter.h"
 #include "harvester/cft_font.h"
 #include "harvester/harvester.h"
+#include "harvester/palette_utils.h"
 #include "harvester/runtime_entity.h"
 #include "harvester/startup_art.h"
 #include "harvester/startup_flow.h"
@@ -346,7 +347,9 @@ void StartupMenuSystem::renderMainMenuStub(int selectedItem, const Common::Strin
 	if (!screen || !art || !titleFont || !bodyFont)
 		return;
 
-	screen->setPalette(art->getWaitPalette());
+	byte displayPalette[256 * 3];
+	buildHarvesterDisplayPalette(art->getWaitPalette(), 1.0f, displayPalette);
+	screen->setPalette(displayPalette);
 	screen->fillRect(screen->getBounds(), 0);
 	blitBitmap(*screen, art->getInventoryBitmap(), kInventoryX, kInventoryY);
 	blitBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);


Commit: a98a29dbe69964cf08ff4403cf40652847b395ea
    https://github.com/scummvm/scummvm/commit/a98a29dbe69964cf08ff4403cf40652847b395ea
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:22+02:00

Commit Message:
HARVESTER: Implement room options menu

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_flow.h
    engines/harvester/startup_menu.cpp
    engines/harvester/startup_menu.h
    engines/harvester/startup_room.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 3964f94e5e7..5d016693118 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1168,6 +1168,36 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - It always appends `g_dialogue_keyword_menu_default_topic`, loaded by `load_dialogue_index` from zero-based `dialog.rsp` line `13`, plus a literal `Other` entry that calls `run_text_entry_dialog`.
   - ESC does not silently cancel the keyword menu; it writes that default topic into `g_dialogue_selected_topic_text` and returns through the normal handler path.
 
+## Room Menu Options UI
+
+**Confidence:** High
+
+**Evidence**
+- `run_main_menu` handles the `OPTIONS` row inline as case `3` and loads `options_menu_1` through `options_menu_7` from `MENU.INI`.
+- It spawns three `VOLUME.BM` bars and three `INDICATR.BM` sliders, with the active positions driven by `g_fx_volume_level_index`, `g_music_volume_level_index`, and `g_gamma_level_index`.
+- Moving the FX slider previews `1:\SOUND\EFFECTS\WHIP2.WAV`; moving the music slider updates `g_music_stream_state`; moving the gamma slider calls `update_gamma_brightness_scale` and reuploads `g_current_palette_buffer`.
+- The text / gore / password rows append inline ` - ...` suffixes through `format_main_menu_option_status_suffix`.
+- The `QUICK TIPS` row opens the same `TIPS.BM` overlay used by the startup quick-tips path, fed by lines from `ADJHEAD.RCS`.
+- The password row calls `prompt_for_password`, which in turn uses the shared `run_text_entry_dialog`; the stored string lives in `g_parental_password_buffer`.
+
+**Key Functions**
+- `run_main_menu`
+- `format_main_menu_option_status_suffix`
+- `update_gamma_brightness_scale`
+- `prompt_for_password`
+- `run_text_entry_dialog`
+
+**Key Data**
+- `g_fx_volume_level_index`
+- `g_music_volume_level_index`
+- `g_gamma_level_index`
+- `g_text_option_mode`
+- `g_gore_enabled`
+- `g_parental_password_buffer`
+
+**Notes**
+- The native submenu persists `GAMMA`, `FX_VOLUME`, and `MUSIC_VOLUME` on exit and updates the string-backed `TEXT`, `GORE`, `QUICK_TIPS`, and password state inline while the submenu is active.
+
 ## Player Health And Inventory Status Portrait
 
 **Confidence:** High
diff --git a/TRACKER.md b/TRACKER.md
index 77217b1dd10..41ccf2987b8 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,9 +2,9 @@
 
 ## Current Focus
 
-- Match room and inventory palette presentation to the native VGA DAC upload path
-- Keep palette changes scoped to confirmed Ghidra behavior instead of screenshot-only tuning
-- Distinguish true native defaults from user config settings before changing brightness or gamma handling
+- Recover the native ESC room-menu systems one item at a time from `run_main_menu`
+- Keep each menu item implementation grounded in confirmed Ghidra behavior before broadening engine UI helpers
+- Extend engine-side persistence only where the native menu paths actually require it
 
 ## Progress
 
@@ -15,22 +15,17 @@
 
 ## Last Confirmed Action
 
-- Traced the inventory portrait health path in Ghidra from `run_inventory_screen` back into room-event handling and actor persistence.
-  - Renamed `DAT_000d5bb4` to `g_player_current_hit_points` and recovered `RenderEntityRuntime.current_hit_points` at offset `0x1184`.
-  - Confirmed the inventory portrait thresholds used by `run_inventory_screen`: `23..30 -> INV_STAT1`, `15..22 -> INV_STAT2`, `8..14 -> INV_STAT3`, `0..7 -> INV_STAT4`.
-  - Confirmed `dispatch_room_event_actions` applies `HEAL_PC`, `ADJ_HP`, and `KILL_PC` directly to that HP state, with the healing/adjust path clamped to `30`.
-  - Confirmed `run_harvester_main_loop` seeds HP to `30`, `spawn_player_combat_avatar` copies the global into the live actor runtime, `teardown_player_combat_avatar` copies it back out, and the save/load menu code persists the same runtime field.
-- Updated the engine inventory/runtime path to match those confirmed behaviors:
-  - the startup script now keeps a persistent player HP value and applies native `HEAL_PC`, `ADJ_HP`, and `KILL_PC` mutations to it;
-  - the inventory panel now synthesizes the native `INV_STAT1..4` status object from current HP instead of treating those records as ordinary grid items;
-  - the health portrait remains non-actionable in the inventory UI;
-  - revisiting `run_inventory_screen` showed the native portrait is spawned through `spawn_object_entity_from_record` and drawn by the shared render-entity keyed-blit path, so the engine inventory overlay now uses palette-index-0 transparency instead of an opaque bitmap copy.
-- Revisited the palette/brightness path in Ghidra after comparing DOSBox and engine captures.
-  - Confirmed the native `CONFIG.INI` in the installed game currently has `GAMMA=0`, so the washed-out comparison is not explained by a non-default gamma setting that DOSBox applies and the engine ignores.
-  - Confirmed `update_gamma_brightness_scale` sets the native gamma scalar to `1.0` when `g_gamma_level_index <= 0`, and `upload_palette_to_vga` / `upload_palette_to_vga_unscaled` always quantize stored `.PAL` bytes down to VGA DAC `0..63` before display.
-  - The engine was uploading room and wait palettes as raw 8-bit RGB, so it skipped that DAC quantization step. Updated the engine palette path to emulate the native VGA upload rule before applying room brightness.
+- Recovered the native `OPTIONS` room-menu branch from `run_main_menu` case `3`.
+  - Confirmed it loads `options_menu_1..7` from `MENU.INI`, draws three `VOLUME.BM` bars plus three `INDICATR.BM` sliders, previews `WHIP2.WAV` for FX volume, updates music volume through `g_music_stream_state`, and reapplies the current room palette after gamma changes through `update_gamma_brightness_scale`.
+  - Confirmed only the `TEXT`, `GORE`, and `PASSWORD` rows append native ` - ...` status suffixes through `format_main_menu_option_status_suffix`.
+  - Confirmed the `QUICK TIPS` row reuses the `TIPS.BM` overlay and `ADJHEAD.RCS` text pool, and the password row calls `prompt_for_password` / `run_text_entry_dialog`.
+  - Renamed `DAT_000c7e5c` to `g_parental_password_buffer` once the password storage path was bounded from both the options menu and parental-password validation path.
+- Implemented the engine-side `OPTIONS` submenu on the ESC room menu:
+  - room-menu `OPTIONS` now opens a native-style overlay with working FX / music / gamma sliders, text-mode cycling, gore toggling, quick-tips browsing, and parental-password add/remove behavior;
+  - startup config state now tracks `FX_VOLUME`, `MUSIC_VOLUME`, `GAMMA`, `TEXT`, `GORE`, `QUICK_TIPS`, and the parental password buffer, and rewrites sectionless `CONFIG.INI` updates back to the game directory;
+  - mixer volume and room/dialogue palette scaling now honor those recovered startup-option values so the submenu changes are live rather than cosmetic.
 
 ## Next Suggested Action
 
-1. Re-test the same inventory scene against DOSBox after the DAC-quantized palette change to see whether the remaining mismatch is explained by palette upload alone or by a later post-processing/display difference.
-2. If the image is still visibly off, trace whether DOSBox capture differences come from a second native layer such as menu fade timing, scaler/aspect treatment, or platform-side palette handling rather than from Harvester’s own palette math.
+1. Trace and implement the native `HELP` room-menu branch next by matching `run_controls_help_screen` / the inline `run_main_menu` help path, including the two-page bitmap toggle and palette restore behavior.
+2. After `HELP`, recover the `SAVE GAME` path with the current engine serializer in mind so the room-menu slot UI and actual save-state coverage can be closed together instead of drifting apart.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 3b86507d06a..ce7291fe5a6 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -188,7 +188,8 @@ static void drawShadowedString(Graphics::Screen &screen, const Graphics::Font &f
 
 static void setScaledPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
 	byte scaledPalette[256 * 3];
-	buildHarvesterDisplayPalette(palette, brightness, scaledPalette);
+	const float gammaBrightness = g_engine ? g_engine->getStartupGammaBrightnessScale() : 1.0f;
+	buildHarvesterDisplayPalette(palette, brightness * gammaBrightness, scaledPalette);
 	screen.setPalette(scaledPalette);
 }
 
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 49ac5b3a518..00e18696294 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -67,6 +67,23 @@ static bool shouldSkipStartupMoviesForDebug() {
 		ConfMan.getBool("harvester_debug_skip_startup_movies");
 }
 
+static int clampStartupOptionLevel(int level) {
+	if (level < 0)
+		return 0;
+	if (level > 9)
+		return 9;
+
+	return level;
+}
+
+static int mapStartupVolumeLevelToMixerVolume(int level) {
+	return (clampStartupOptionLevel(level) * Audio::Mixer::kMaxMixerVolume + 4) / 9;
+}
+
+static float mapStartupGammaLevelToBrightnessScale(int level) {
+	return 1.0f + 0.1f * clampStartupOptionLevel(level);
+}
+
 static uint32 decodeHarvesterFcmp(byte *dest, const byte *src, uint32 srcSize, uint16 bitsPerSample) {
 	const uint32 decodedBytesPerInputByte = bitsPerSample >> 2;
 	const uint32 decodedSize = srcSize * decodedBytesPerInputByte;
@@ -203,9 +220,58 @@ Common::String HarvesterEngine::getGameId() const {
 }
 
 bool HarvesterEngine::isGoreEnabled() const {
+	if (_startupScript)
+		return _startupScript->isGoreEnabled();
+
 	return !ConfMan.hasKey("gore") || ConfMan.getBool("gore");
 }
 
+int HarvesterEngine::getStartupFxVolumeLevel() const {
+	return _startupScript ? _startupScript->getFxVolumeLevel() : 9;
+}
+
+int HarvesterEngine::getStartupMusicVolumeLevel() const {
+	return _startupScript ? _startupScript->getMusicVolumeLevel() : 9;
+}
+
+int HarvesterEngine::getStartupGammaLevel() const {
+	return _startupScript ? _startupScript->getGammaLevel() : 0;
+}
+
+float HarvesterEngine::getStartupGammaBrightnessScale() const {
+	return mapStartupGammaLevelToBrightnessScale(getStartupGammaLevel());
+}
+
+void HarvesterEngine::applyStartupMixerLevels() {
+	if (!g_system || !g_system->getMixer())
+		return;
+
+	g_system->getMixer()->setVolumeForSoundType(Audio::Mixer::kSFXSoundType,
+		mapStartupVolumeLevelToMixerVolume(getStartupFxVolumeLevel()));
+	g_system->getMixer()->setVolumeForSoundType(Audio::Mixer::kMusicSoundType,
+		mapStartupVolumeLevelToMixerVolume(getStartupMusicVolumeLevel()));
+
+	if (g_system->getMixer()->isSoundHandleActive(_startupMusicHandle))
+		g_system->getMixer()->pauseHandle(_startupMusicHandle, getStartupMusicVolumeLevel() == 0);
+}
+
+void HarvesterEngine::setStartupFxVolumeLevel(int level) {
+	if (_startupScript)
+		_startupScript->setFxVolumeLevel(level);
+	applyStartupMixerLevels();
+}
+
+void HarvesterEngine::setStartupMusicVolumeLevel(int level) {
+	if (_startupScript)
+		_startupScript->setMusicVolumeLevel(level);
+	applyStartupMixerLevels();
+}
+
+void HarvesterEngine::setStartupGammaLevel(int level) {
+	if (_startupScript)
+		_startupScript->setGammaLevel(level);
+}
+
 bool HarvesterEngine::isStartupMusicPlaying() const {
 	return g_system && g_system->getMixer() &&
 		g_system->getMixer()->isSoundHandleActive(_startupMusicHandle);
@@ -231,6 +297,7 @@ bool HarvesterEngine::playStartupMusic(const Common::String &path) {
 	g_system->getMixer()->playStream(Audio::Mixer::kMusicSoundType, &_startupMusicHandle,
 		Audio::makeLoopingAudioStream(audioStream, 0));
 	_startupMusicPath = normalizedPath;
+	applyStartupMixerLevels();
 	return true;
 }
 
@@ -437,6 +504,7 @@ Common::Error HarvesterEngine::run() {
 	_startupScript = new StartupScript();
 	if (!_startupScript->load(*_resources))
 		return Common::kReadingFailed;
+	applyStartupMixerLevels();
 
 	// The intro FST files play on the narrower startup movie surface.
 	setDisplayMode(320, 200);
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 032f1fa902c..8343e6e2d03 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -82,6 +82,13 @@ public:
 	StartupText *getStartupText() const { return _startupText; }
 	void setDisplayMode(int width, int height);
 	bool isGoreEnabled() const;
+	int getStartupFxVolumeLevel() const;
+	int getStartupMusicVolumeLevel() const;
+	int getStartupGammaLevel() const;
+	float getStartupGammaBrightnessScale() const;
+	void setStartupFxVolumeLevel(int level);
+	void setStartupMusicVolumeLevel(int level);
+	void setStartupGammaLevel(int level);
 	const Common::String &getStartupMusicPath() const { return _startupMusicPath; }
 	bool isStartupMusicPlaying() const;
 	bool playStartupMusic(const Common::String &path);
@@ -103,6 +110,7 @@ public:
 private:
 	void stopStartupSoundHandle(Audio::SoundHandle &handle);
 	bool validateStartupLoadedSoundSlot(int slot) const;
+	void applyStartupMixerLevels();
 
 	const ADGameDescription *const _gameDescription;
 	Common::RandomSource _randomSource;
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index 218e4669381..be782045d9d 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -688,7 +688,8 @@ static void drawWrappedShadowedText(Graphics::Screen &screen, const Graphics::Fo
 
 static void setScaledPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
 	byte scaledPalette[256 * 3];
-	buildHarvesterDisplayPalette(palette, brightness, scaledPalette);
+	const float gammaBrightness = g_engine ? g_engine->getStartupGammaBrightnessScale() : 1.0f;
+	buildHarvesterDisplayPalette(palette, brightness * gammaBrightness, scaledPalette);
 	screen.setPalette(scaledPalette);
 }
 
@@ -1870,8 +1871,8 @@ Common::Error StartupFlow::runMainMenuStub() {
 	return _menu.runMainMenuStub(*this);
 }
 
-Common::Error StartupFlow::runRoomMenuStub(const IndexedBitmap &backdrop) {
-	return _menu.runRoomMenuStub(backdrop, *this);
+Common::Error StartupFlow::runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette, float paletteBrightness) {
+	return _menu.runRoomMenuStub(backdrop, palette, paletteBrightness, *this);
 }
 
 Common::Error StartupFlow::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/startup_flow.h
index d29d78759ab..181a6c31f80 100644
--- a/engines/harvester/startup_flow.h
+++ b/engines/harvester/startup_flow.h
@@ -58,7 +58,7 @@ private:
 	bool loadMenuItems();
 	Common::Error runQuickTips();
 	Common::Error runMainMenuStub();
-	Common::Error runRoomMenuStub(const IndexedBitmap &backdrop);
+	Common::Error runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette, float paletteBrightness);
 	Common::Error runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, const StartupNpcRecord &npc, const Common::String &usedItemName);
 	Common::Error runTownMapSelector(const Common::String &mapEntryName, Common::String &destinationEntranceName);
diff --git a/engines/harvester/startup_menu.cpp b/engines/harvester/startup_menu.cpp
index cda11bf34b6..7024caf7fb7 100644
--- a/engines/harvester/startup_menu.cpp
+++ b/engines/harvester/startup_menu.cpp
@@ -21,7 +21,11 @@
 
 #include "harvester/startup_menu.h"
 
+#include "common/config-manager.h"
+#include "common/endian.h"
 #include "common/events.h"
+#include "common/formats/ini-file.h"
+#include "common/memstream.h"
 #include "common/system.h"
 #include "graphics/blit.h"
 #include "graphics/font.h"
@@ -30,6 +34,7 @@
 #include "harvester/cft_font.h"
 #include "harvester/harvester.h"
 #include "harvester/palette_utils.h"
+#include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
 #include "harvester/startup_art.h"
 #include "harvester/startup_flow.h"
@@ -53,6 +58,38 @@ static const byte kTextColorDim = 248;
 static const byte kShadowColor = 0;
 static const byte kPanelFillColor = 1;
 static const byte kTransparentPaletteIndex = 0;
+static const byte kQuickTipActionColor = 0xc3;
+
+static const char *const kMenuPath = "MENU.INI";
+static const char *const kMenuSectionName = "menu";
+static const char *const kOptionsVolumeBitmapPath = "1:/GRAPHIC/OTHER/VOLUME.BM";
+static const char *const kOptionsIndicatorBitmapPath = "1:/GRAPHIC/OTHER/INDICATR.BM";
+static const char *const kOptionsPreviewSoundPath = "1:/SOUND/EFFECTS/WHIP2.WAV";
+
+static const int kOptionsSliderMinX = 0x16f;
+static const int kOptionsSliderMaxX = 0x22b;
+static const int kOptionsSliderStep = 0x13;
+static const int kOptionsVolumeBitmapX = 0x168;
+static const int kOptionsVolumeBitmapY = 0x68;
+static const int kOptionsIndicatorY = 0x6c;
+static const int kOptionsSliderHeight = 0x1e;
+static const int kOptionsItemCount = 7;
+static const int kStartupOptionMaxLevel = 9;
+
+static const int kQuickTipsOverlayX = 167;
+static const int kQuickTipsOverlayY = 200;
+static const int kQuickTipTextX = 180;
+static const int kQuickTipTextY = 228;
+static const int kQuickTipTextWidth = 280;
+
+struct RoomMenuTextConfig {
+	Common::Array<Common::String> optionItems;
+	Common::String yesLabel = "YES";
+	Common::String noLabel = "NO";
+	Common::String clickLabel = "CLICK";
+};
+
+static int getNativeRoomMenuLineHeight(const Graphics::Font &selectedFont);
 
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
 	const StartupText *startupText = engine.getStartupText();
@@ -67,6 +104,161 @@ static const CftFontResource *findStartupFontByName(const HarvesterEngine &engin
 	return nullptr;
 }
 
+static int clampStartupOptionLevel(int level) {
+	if (level < 0)
+		return 0;
+	if (level > kStartupOptionMaxLevel)
+		return kStartupOptionMaxLevel;
+
+	return level;
+}
+
+static Common::Rect quickTipsExitRect() {
+	return Common::Rect(180, 280, 238, 291);
+}
+
+static Common::Rect quickTipsNextRect() {
+	return Common::Rect(420, 280, 492, 291);
+}
+
+static Common::Rect quickTipsToggleRect() {
+	return Common::Rect(258, 280, 366, 291);
+}
+
+static Common::Rect textEntryInputRect() {
+	return Common::Rect(176, 212, 464, 234);
+}
+
+static Common::Rect textEntryOkRect() {
+	return Common::Rect(224, 278, 286, 292);
+}
+
+static Common::Rect textEntryCancelRect() {
+	return Common::Rect(344, 278, 424, 292);
+}
+
+static Common::Rect optionsSliderRect(int sliderIndex, int lineHeight) {
+	const int y = kOptionsIndicatorY + sliderIndex * lineHeight;
+	return Common::Rect(kOptionsSliderMinX, y, kOptionsSliderMaxX + 1, y + kOptionsSliderHeight + 1);
+}
+
+static int resolveOptionsSliderIndexFromMouseX(int mouseX) {
+	const int relativeX = mouseX - kOptionsSliderMinX;
+	return clampStartupOptionLevel((relativeX + (kOptionsSliderStep / 2)) / kOptionsSliderStep);
+}
+
+static bool loadMenuTextConfig(HarvesterEngine &engine, RoomMenuTextConfig &config) {
+	config = RoomMenuTextConfig();
+	config.optionItems.resize(kOptionsItemCount);
+	config.optionItems[0] = "SOUND FX";
+	config.optionItems[1] = "MUSIC";
+	config.optionItems[2] = "GAMMA";
+	config.optionItems[3] = "TEXT";
+	config.optionItems[4] = "GORE";
+	config.optionItems[5] = "QUICK TIPS";
+	config.optionItems[6] = "PASSWORD";
+
+	ResourceManager *resources = engine.getResources();
+	if (!resources)
+		return false;
+
+	Common::Array<byte> data;
+	if (!resources->loadFile(kMenuPath, data))
+		return false;
+
+	Common::MemoryReadStream stream(data.data(), data.size());
+	Common::INIFile menu;
+	menu.setDefaultSectionName(kMenuSectionName);
+	menu.requireKeyValueDelimiter();
+	menu.suppressValuelessLineWarning();
+	if (!menu.loadFromStream(stream))
+		return false;
+
+	for (uint i = 0; i < config.optionItems.size(); ++i) {
+		Common::String value;
+		if (menu.getKey(Common::String::format("options_menu_%u", i + 1), kMenuSectionName, value) && !value.empty())
+			config.optionItems[i] = value;
+	}
+
+	Common::String value;
+	if (menu.getKey("yes", kMenuSectionName, value) && !value.empty())
+		config.yesLabel = value;
+	if (menu.getKey("no", kMenuSectionName, value) && !value.empty())
+		config.noLabel = value;
+	if (menu.getKey("click", kMenuSectionName, value) && !value.empty())
+		config.clickLabel = value;
+
+	return true;
+}
+
+static bool loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 12)
+		return false;
+
+	bitmap.width = READ_LE_UINT32(data.data());
+	bitmap.height = READ_LE_UINT32(data.data() + 4);
+	const uint32 pixelCount = bitmap.width * bitmap.height;
+	if (bitmap.width == 0 || bitmap.height == 0 || data.size() < 12 + pixelCount)
+		return false;
+
+	bitmap.pixels.resize(pixelCount);
+	memcpy(bitmap.pixels.data(), data.data() + 12, pixelCount);
+	return true;
+}
+
+static void applyMenuPalette(Graphics::Screen &screen, const HarvesterEngine &engine,
+		const byte *palette, float brightness) {
+	if (!palette)
+		return;
+
+	byte displayPalette[256 * 3];
+	buildHarvesterDisplayPalette(palette, brightness * engine.getStartupGammaBrightnessScale(), displayPalette);
+	screen.setPalette(displayPalette);
+}
+
+static Common::String buildTextModeSuffix(const StartupScript &startupScript, const RoomMenuTextConfig &config) {
+	switch (startupScript.getDialogueTextMode()) {
+	case kStartupDialogueTextNone:
+		return Common::String::format(" - %s", config.noLabel.c_str());
+	case kStartupDialogueTextClick:
+		return Common::String::format(" - %s", config.clickLabel.c_str());
+	case kStartupDialogueTextYes:
+	default:
+		return Common::String::format(" - %s", config.yesLabel.c_str());
+	}
+}
+
+static Common::String buildOptionsMenuItemLabel(const StartupScript &startupScript,
+		const RoomMenuTextConfig &config, int index) {
+	if (index < 0 || index >= (int)config.optionItems.size())
+		return Common::String();
+
+	switch (index) {
+	case 3:
+		return config.optionItems[index] + buildTextModeSuffix(startupScript, config);
+	case 4:
+		return config.optionItems[index] + (startupScript.isGoreEnabled() ? " - On" : " - Off");
+	case 6:
+		return config.optionItems[index] + (startupScript.getParentalPassword().empty() ? " - Off" : " - On");
+	default:
+		return config.optionItems[index];
+	}
+}
+
+static bool appendTextEntryCharacter(Common::String &text, const Graphics::Font &font,
+		uint32 ascii, uint maxCharacters) {
+	if (ascii < 32 || ascii > 126 || ascii == '~' || text.size() >= maxCharacters)
+		return false;
+
+	const Common::String candidate = text + (char)ascii;
+	if (font.getStringWidth(candidate) > textEntryInputRect().width() - 10)
+		return false;
+
+	text = candidate;
+	return true;
+}
+
 static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
 	if (!bitmap.isValid())
 		return;
@@ -123,6 +315,119 @@ static void drawWrappedShadowedText(Graphics::Screen &screen, const Graphics::Fo
 		drawShadowedString(screen, font, lines[i], x, y + (int)i * (font.getFontHeight() + 2), width, color);
 }
 
+static void renderOptionsMenuScreen(HarvesterEngine &engine, const IndexedBitmap &backdrop,
+		const byte *palette, float paletteBrightness,
+		const Graphics::Font &selectedFont, const Graphics::Font &unselectedFont,
+		const StartupArt &art, const RoomMenuTextConfig &config,
+		const IndexedBitmap &volumeBar, const IndexedBitmap &indicator, int selectedItem) {
+	Graphics::Screen *screen = engine.getScreen();
+	StartupScript *startupScript = engine.getStartupScript();
+	if (!screen || !startupScript)
+		return;
+
+	applyMenuPalette(*screen, engine, palette, paletteBrightness);
+	blitBitmap(*screen, backdrop, 0, 0);
+	blitTransparentBitmap(*screen, art.getLogoBitmap(), kLogoX, kLogoY);
+
+	const int lineHeight = getNativeRoomMenuLineHeight(selectedFont);
+	for (int i = 0; i < 3; ++i) {
+		blitTransparentBitmap(*screen, volumeBar, kOptionsVolumeBitmapX, kOptionsVolumeBitmapY + i * lineHeight);
+	}
+
+	const int levels[3] = {
+		startupScript->getFxVolumeLevel(),
+		startupScript->getMusicVolumeLevel(),
+		startupScript->getGammaLevel()
+	};
+	for (int i = 0; i < 3; ++i) {
+		blitTransparentBitmap(*screen, indicator, kOptionsSliderMinX + levels[i] * kOptionsSliderStep,
+			kOptionsIndicatorY + i * lineHeight);
+	}
+
+	for (int i = 0; i < (int)config.optionItems.size(); ++i) {
+		const Common::String label = buildOptionsMenuItemLabel(*startupScript, config, i);
+		const Graphics::Font &font = (i == selectedItem) ? selectedFont : unselectedFont;
+		const int width = font.getStringWidth(label);
+		const int x = (screen->w - width) / 2;
+		const int y = kMenuStartY + i * lineHeight;
+		font.drawString(screen, label, x, y, width, 0);
+	}
+
+	if (engine.getRuntimeEntities())
+		engine.getRuntimeEntities()->drawCursor(*screen);
+	screen->makeAllDirty();
+	screen->update();
+}
+
+static void renderQuickTipsOverlay(HarvesterEngine &engine, const IndexedBitmap &backdrop,
+		const byte *palette, float paletteBrightness,
+		const Common::String &tipText) {
+	Graphics::Screen *screen = engine.getScreen();
+	const StartupArt *art = engine.getStartupArt();
+	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	StartupScript *startupScript = engine.getStartupScript();
+	if (!screen || !art || !font || !startupScript)
+		return;
+
+	applyMenuPalette(*screen, engine, palette, paletteBrightness);
+	blitBitmap(*screen, backdrop, 0, 0);
+	blitTransparentBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
+	blitBitmap(*screen, art->getTipsBitmap(), kQuickTipsOverlayX, kQuickTipsOverlayY);
+
+	drawWrappedShadowedText(*screen, *font, tipText, kQuickTipTextX, kQuickTipTextY, kQuickTipTextWidth,
+		kTextColorNormal);
+	const Common::String toggleLabel = startupScript->resolveTextValue(
+		startupScript->isQuickTipsEnabled() ? "Show_Tips_ON" : "Show_Tips_OFF");
+	drawShadowedString(*screen, *font, "Exit", quickTipsExitRect().left, quickTipsExitRect().top,
+		quickTipsExitRect().width(), kQuickTipActionColor);
+	drawShadowedString(*screen, *font, "Next", quickTipsNextRect().left, quickTipsNextRect().top,
+		quickTipsNextRect().width(), kQuickTipActionColor);
+	drawShadowedString(*screen, *font, toggleLabel, quickTipsToggleRect().left, quickTipsToggleRect().top,
+		quickTipsToggleRect().width(), kQuickTipActionColor);
+
+	if (engine.getRuntimeEntities())
+		engine.getRuntimeEntities()->drawCursor(*screen);
+	screen->makeAllDirty();
+	screen->update();
+}
+
+static void renderTextEntryDialog(HarvesterEngine &engine, const IndexedBitmap &backdrop,
+		const byte *palette, float paletteBrightness, const Graphics::Font &titleFont,
+		const Graphics::Font &entryFont, const Common::String &title, const Common::String &text,
+		bool cursorVisible, bool okHover, bool cancelHover) {
+	Graphics::Screen *screen = engine.getScreen();
+	if (!screen)
+		return;
+
+	applyMenuPalette(*screen, engine, palette, paletteBrightness);
+	blitBitmap(*screen, backdrop, 0, 0);
+
+	screen->fillRect(Common::Rect(132, 148, 508, 320), kPanelFillColor);
+	drawShadowedString(*screen, titleFont, title, 132, 168, 376, kTextColorNormal,
+		Graphics::kTextAlignCenter);
+	screen->fillRect(textEntryInputRect(), kTransparentPaletteIndex);
+	screen->frameRect(textEntryInputRect(), kTextColorDim);
+
+	Common::String displayText = text;
+	if (cursorVisible)
+		displayText += "_";
+	const Common::Rect inputRect = textEntryInputRect();
+	const Common::Rect okRect = textEntryOkRect();
+	const Common::Rect cancelRect = textEntryCancelRect();
+	drawShadowedString(*screen, entryFont, displayText, inputRect.left + 5, inputRect.top + 4,
+		inputRect.width() - 10, kTextColorNormal);
+	drawShadowedString(*screen, entryFont, "OK", okRect.left, okRect.top,
+		okRect.width(), okHover ? kTextColorHover : kTextColorNormal, Graphics::kTextAlignCenter);
+	drawShadowedString(*screen, entryFont, "Cancel", cancelRect.left, cancelRect.top,
+		cancelRect.width(), cancelHover ? kTextColorHover : kTextColorNormal,
+		Graphics::kTextAlignCenter);
+
+	if (engine.getRuntimeEntities())
+		engine.getRuntimeEntities()->drawCursor(*screen);
+	screen->makeAllDirty();
+	screen->update();
+}
+
 static int getNativeRoomMenuLineHeight(const Graphics::Font &selectedFont) {
 	return selectedFont.getFontHeight() + 2;
 }
@@ -134,9 +439,13 @@ static int getNativeRoomMenuSelectionFromMouse(const Graphics::Font &selectedFon
 
 	int selection = mousePos.y - kMenuStartY;
 	if (selection < 1)
-		selection = 1;
+		return -1;
+
+	selection = (selection - 1) / MAX<int>(1, getNativeRoomMenuLineHeight(selectedFont));
+	if (selection < 0 || selection >= (int)itemCount)
+		return -1;
 
-	return (selection - 1) / MAX<int>(1, getNativeRoomMenuLineHeight(selectedFont));
+	return selection;
 }
 
 } // End of anonymous namespace
@@ -266,7 +575,8 @@ Common::Error StartupMenuSystem::runMainMenuStub(StartupFlow &startupFlow) {
 	return Common::kNoError;
 }
 
-Common::Error StartupMenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, StartupFlow &startupFlow) {
+Common::Error StartupMenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, StartupFlow &startupFlow) {
 	Graphics::FrameLimiter limiter(g_system, 60);
 	int selectedItem = _menuItems.empty() ? -1 : 0;
 	bool needsRedraw = true;
@@ -308,8 +618,15 @@ Common::Error StartupMenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop,
 					needsRedraw = true;
 				} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
 						event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
-					debug(1, "Harvester: room menu item '%s' selected but not implemented",
-						selectedItem >= 0 ? _menuItems[selectedItem].c_str() : "");
+					if (selectedItem >= 0 && _menuItems[selectedItem].equalsIgnoreCase("OPTIONS")) {
+						Common::Error optionsError = runOptionsMenu(backdrop, palette, paletteBrightness, startupFlow);
+						if (optionsError.getCode() != Common::kNoError)
+							return optionsError;
+						needsRedraw = true;
+					} else {
+						debug(1, "Harvester: room menu item '%s' selected but not implemented",
+							selectedItem >= 0 ? _menuItems[selectedItem].c_str() : "");
+					}
 				}
 				break;
 			case Common::EVENT_LBUTTONDOWN:
@@ -320,8 +637,15 @@ Common::Error StartupMenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop,
 				if (selectedItem == -1)
 					break;
 
-				debug(1, "Harvester: room menu item '%s' clicked but not implemented",
-					_menuItems[selectedItem].c_str());
+				if (_menuItems[selectedItem].equalsIgnoreCase("OPTIONS")) {
+					Common::Error optionsError = runOptionsMenu(backdrop, palette, paletteBrightness, startupFlow);
+					if (optionsError.getCode() != Common::kNoError)
+						return optionsError;
+					needsRedraw = true;
+				} else {
+					debug(1, "Harvester: room menu item '%s' clicked but not implemented",
+						_menuItems[selectedItem].c_str());
+				}
 				break;
 			default:
 				break;
@@ -339,6 +663,386 @@ Common::Error StartupMenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop,
 	return Common::kNoError;
 }
 
+Common::Error StartupMenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, StartupFlow &startupFlow) {
+	const StartupArt *art = _engine.getStartupArt();
+	StartupScript *startupScript = _engine.getStartupScript();
+	ResourceManager *resources = _engine.getResources();
+	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
+	const CftFontResource *unselectedFontResource = findStartupFontByName(_engine, "HARVFNT2");
+	if (!art || !startupScript || !resources || !selectedFontResource || !unselectedFontResource)
+		return Common::kReadingFailed;
+
+	HarvesterCftFont selectedFont(*selectedFontResource);
+	HarvesterCftFont unselectedFont(*unselectedFontResource);
+	if (!selectedFont.isValid() || !unselectedFont.isValid())
+		return Common::kReadingFailed;
+
+	RoomMenuTextConfig config;
+	(void)loadMenuTextConfig(_engine, config);
+
+	IndexedBitmap volumeBar;
+	IndexedBitmap indicator;
+	if (!loadBitmapResource(*resources, kOptionsVolumeBitmapPath, volumeBar) ||
+			!loadBitmapResource(*resources, kOptionsIndicatorBitmapPath, indicator)) {
+		return Common::kReadingFailed;
+	}
+
+	const int lineHeight = getNativeRoomMenuLineHeight(selectedFont);
+	const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	const Graphics::Font &entryFont = bodyFont ? *bodyFont : static_cast<const Graphics::Font &>(unselectedFont);
+	int selectedItem = 0;
+	int capturedSlider = -1;
+	bool showingQuickTips = false;
+	bool needsRedraw = true;
+	uint quickTipIndex = startupFlow._quickTips.empty() ? 0 : _engine.getRandomNumber(startupFlow._quickTips.size() - 1);
+
+	auto persistConfig = [&]() {
+		(void)startupScript->saveConfig();
+	};
+
+	auto updateSlider = [&](int sliderIndex) {
+		const int newLevel = resolveOptionsSliderIndexFromMouseX(_mousePos.x);
+		switch (sliderIndex) {
+		case 0:
+			if (newLevel != startupScript->getFxVolumeLevel()) {
+				_engine.setStartupFxVolumeLevel(newLevel);
+				_engine.playStartupSingleSound(kOptionsPreviewSoundPath);
+				persistConfig();
+				needsRedraw = true;
+			}
+			break;
+		case 1:
+			if (newLevel != startupScript->getMusicVolumeLevel()) {
+				_engine.setStartupMusicVolumeLevel(newLevel);
+				persistConfig();
+				needsRedraw = true;
+			}
+			break;
+		case 2:
+			if (newLevel != startupScript->getGammaLevel()) {
+				_engine.setStartupGammaLevel(newLevel);
+				persistConfig();
+				needsRedraw = true;
+			}
+			break;
+		default:
+			break;
+		}
+	};
+
+	auto cycleTextMode = [&]() {
+		switch (startupScript->getDialogueTextMode()) {
+		case kStartupDialogueTextNone:
+			startupScript->setDialogueTextMode(kStartupDialogueTextYes);
+			break;
+		case kStartupDialogueTextYes:
+			startupScript->setDialogueTextMode(kStartupDialogueTextClick);
+			break;
+		case kStartupDialogueTextClick:
+		default:
+			startupScript->setDialogueTextMode(kStartupDialogueTextNone);
+			break;
+		}
+		persistConfig();
+		needsRedraw = true;
+	};
+
+	auto toggleGore = [&]() {
+		startupScript->setGoreEnabled(!startupScript->isGoreEnabled());
+		ConfMan.setBool("gore", startupScript->isGoreEnabled());
+		persistConfig();
+		needsRedraw = true;
+	};
+
+	auto togglePassword = [&]() {
+		if (startupScript->getParentalPassword().empty()) {
+			const Common::String password = runModalTextEntryDialog(backdrop, palette, paletteBrightness,
+				selectedFont, entryFont, "ENTER PASSWORD", Common::String(), startupFlow);
+			if (!password.empty()) {
+				startupScript->setParentalPassword(password);
+				persistConfig();
+				needsRedraw = true;
+			}
+		} else {
+			startupScript->setParentalPassword(Common::String());
+			persistConfig();
+			needsRedraw = true;
+		}
+	};
+
+	auto activateSelectedItem = [&]() -> Common::Error {
+		switch (selectedItem) {
+		case 3:
+			cycleTextMode();
+			break;
+		case 4:
+			toggleGore();
+			break;
+		case 5:
+			if (!startupFlow._quickTips.empty()) {
+				showingQuickTips = true;
+				needsRedraw = true;
+			}
+			break;
+		case 6:
+			togglePassword();
+			break;
+		default:
+			break;
+		}
+
+		return Common::kNoError;
+	};
+
+	Graphics::FrameLimiter limiter(g_system, 60);
+	while (!_engine.shouldQuit()) {
+		if (needsRedraw) {
+			if (showingQuickTips) {
+				renderQuickTipsOverlay(_engine, backdrop, palette, paletteBrightness,
+					startupFlow._quickTips[quickTipIndex]);
+			} else {
+				renderOptionsMenuScreen(_engine, backdrop, palette, paletteBrightness,
+					selectedFont, unselectedFont, *art, config, volumeBar, indicator, selectedItem);
+			}
+			needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (startupFlow.handleSystemEvent(event, result))
+				return result;
+
+			if (showingQuickTips) {
+				switch (event.type) {
+				case Common::EVENT_MOUSEMOVE:
+					needsRedraw = true;
+					break;
+				case Common::EVENT_LBUTTONDOWN:
+					if (quickTipsExitRect().contains(_mousePos)) {
+						showingQuickTips = false;
+						needsRedraw = true;
+					} else if (quickTipsNextRect().contains(_mousePos)) {
+						quickTipIndex = (quickTipIndex + 1) % startupFlow._quickTips.size();
+						needsRedraw = true;
+					} else if (quickTipsToggleRect().contains(_mousePos)) {
+						startupScript->setQuickTipsEnabled(!startupScript->isQuickTipsEnabled());
+						persistConfig();
+						needsRedraw = true;
+					}
+					break;
+				case Common::EVENT_RBUTTONDOWN:
+					showingQuickTips = false;
+					needsRedraw = true;
+					break;
+				case Common::EVENT_KEYDOWN:
+					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+						showingQuickTips = false;
+						needsRedraw = true;
+					}
+					break;
+				default:
+					break;
+				}
+				continue;
+			}
+
+			switch (event.type) {
+			case Common::EVENT_MOUSEMOVE: {
+				if (capturedSlider != -1) {
+					selectedItem = capturedSlider;
+					updateSlider(capturedSlider);
+					needsRedraw = true;
+				} else {
+					const int hoveredItem = getNativeRoomMenuSelectionFromMouse(selectedFont,
+						config.optionItems.size(), _mousePos);
+					if (hoveredItem != -1 && hoveredItem != selectedItem) {
+						selectedItem = hoveredItem;
+						needsRedraw = true;
+					}
+				}
+				break;
+			}
+			case Common::EVENT_LBUTTONDOWN: {
+				bool handled = false;
+				for (int sliderIndex = 0; sliderIndex < 3; ++sliderIndex) {
+					if (optionsSliderRect(sliderIndex, lineHeight).contains(_mousePos)) {
+						selectedItem = sliderIndex;
+						capturedSlider = sliderIndex;
+						updateSlider(sliderIndex);
+						needsRedraw = true;
+						handled = true;
+						break;
+					}
+				}
+				if (handled)
+					break;
+
+				const int clickedItem = getNativeRoomMenuSelectionFromMouse(selectedFont,
+					config.optionItems.size(), _mousePos);
+				if (clickedItem == -1)
+					break;
+
+				selectedItem = clickedItem;
+				needsRedraw = true;
+				if (selectedItem >= 3) {
+					Common::Error activateError = activateSelectedItem();
+					if (activateError.getCode() != Common::kNoError)
+						return activateError;
+				}
+				break;
+			}
+			case Common::EVENT_LBUTTONUP:
+				capturedSlider = -1;
+				break;
+			case Common::EVENT_RBUTTONDOWN:
+				return Common::kNoError;
+			case Common::EVENT_KEYDOWN:
+				switch (event.kbd.keycode) {
+				case Common::KEYCODE_ESCAPE:
+					return Common::kNoError;
+				case Common::KEYCODE_UP:
+					selectedItem = (selectedItem + (int)config.optionItems.size() - 1) % (int)config.optionItems.size();
+					needsRedraw = true;
+					break;
+				case Common::KEYCODE_DOWN:
+					selectedItem = (selectedItem + 1) % (int)config.optionItems.size();
+					needsRedraw = true;
+					break;
+				case Common::KEYCODE_LEFT:
+					if (selectedItem < 3) {
+						_mousePos.x = MAX<int>(kOptionsSliderMinX, _mousePos.x - kOptionsSliderStep);
+						updateSlider(selectedItem);
+						needsRedraw = true;
+					}
+					break;
+				case Common::KEYCODE_RIGHT:
+					if (selectedItem < 3) {
+						_mousePos.x = MIN<int>(kOptionsSliderMaxX, _mousePos.x + kOptionsSliderStep);
+						updateSlider(selectedItem);
+						needsRedraw = true;
+					}
+					break;
+				case Common::KEYCODE_RETURN:
+				case Common::KEYCODE_KP_ENTER: {
+					Common::Error activateError = activateSelectedItem();
+					if (activateError.getCode() != Common::kNoError)
+						return activateError;
+					break;
+				}
+				case Common::KEYCODE_t:
+					selectedItem = 3;
+					cycleTextMode();
+					break;
+				case Common::KEYCODE_g:
+					selectedItem = 4;
+					toggleGore();
+					break;
+				case Common::KEYCODE_q:
+					selectedItem = 5;
+					if (!startupFlow._quickTips.empty()) {
+						showingQuickTips = true;
+						needsRedraw = true;
+					}
+					break;
+				case Common::KEYCODE_p:
+					selectedItem = 6;
+					togglePassword();
+					break;
+				default:
+					break;
+				}
+				break;
+			default:
+				break;
+			}
+		}
+
+		RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+		if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
+			needsRedraw = true;
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+Common::String StartupMenuSystem::runModalTextEntryDialog(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, const Graphics::Font &titleFont, const Graphics::Font &entryFont,
+		const Common::String &title, const Common::String &initialText, StartupFlow &startupFlow) {
+	Graphics::FrameLimiter limiter(g_system, 60);
+	Common::String text = initialText;
+	const uint maxCharacters = 30;
+	bool needsRedraw = true;
+	bool cursorVisible = true;
+	uint32 cursorToggleTicks = g_system->getMillis() + 400;
+
+	while (!_engine.shouldQuit()) {
+		if (needsRedraw) {
+			renderTextEntryDialog(_engine, backdrop, palette, paletteBrightness, titleFont, entryFont,
+				title, text, cursorVisible, textEntryOkRect().contains(_mousePos), textEntryCancelRect().contains(_mousePos));
+			needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (startupFlow.handleSystemEvent(event, result))
+				return Common::String();
+
+			switch (event.type) {
+			case Common::EVENT_MOUSEMOVE:
+				needsRedraw = true;
+				break;
+			case Common::EVENT_LBUTTONDOWN:
+				if (textEntryOkRect().contains(_mousePos))
+					return text;
+				if (textEntryCancelRect().contains(_mousePos))
+					return Common::String();
+				break;
+			case Common::EVENT_RBUTTONDOWN:
+				return Common::String();
+			case Common::EVENT_KEYDOWN:
+				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+					return Common::String();
+				if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+						event.kbd.keycode == Common::KEYCODE_KP_ENTER)
+					return text;
+				if (event.kbd.keycode == Common::KEYCODE_BACKSPACE) {
+					if (!text.empty()) {
+						text.deleteLastChar();
+						needsRedraw = true;
+					}
+					break;
+				}
+				if (appendTextEntryCharacter(text, entryFont, event.kbd.ascii, maxCharacters))
+					needsRedraw = true;
+				break;
+			default:
+				break;
+			}
+		}
+
+		const uint32 now = g_system->getMillis();
+		if ((int32)(now - cursorToggleTicks) >= 0) {
+			cursorVisible = !cursorVisible;
+			cursorToggleTicks = now + 400;
+			needsRedraw = true;
+		}
+
+		if (_engine.getRuntimeEntities() && _engine.getRuntimeEntities()->syncCursorEntityPosition(_mousePos))
+			needsRedraw = true;
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::String();
+}
+
 void StartupMenuSystem::renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const {
 	Graphics::Screen *screen = _engine.getScreen();
 	const StartupArt *art = _engine.getStartupArt();
diff --git a/engines/harvester/startup_menu.h b/engines/harvester/startup_menu.h
index 0a24aab45b1..d3bf69a6b10 100644
--- a/engines/harvester/startup_menu.h
+++ b/engines/harvester/startup_menu.h
@@ -27,6 +27,10 @@
 #include "common/rect.h"
 #include "common/str.h"
 
+namespace Graphics {
+class Font;
+}
+
 namespace Harvester {
 
 class HarvesterEngine;
@@ -39,9 +43,15 @@ public:
 		const Common::Array<Common::String> &menuItems);
 
 	Common::Error runMainMenuStub(StartupFlow &startupFlow);
-	Common::Error runRoomMenuStub(const IndexedBitmap &backdrop, StartupFlow &startupFlow);
+	Common::Error runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, StartupFlow &startupFlow);
 
 private:
+	Common::Error runOptionsMenu(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, StartupFlow &startupFlow);
+	Common::String runModalTextEntryDialog(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, const Graphics::Font &titleFont, const Graphics::Font &entryFont,
+		const Common::String &title, const Common::String &initialText, StartupFlow &startupFlow);
 	void renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const;
 	void renderRoomMenuStub(const IndexedBitmap &backdrop, int selectedItem) const;
 	int getMenuItemAt(const Common::Point &mousePos) const;
diff --git a/engines/harvester/startup_room.cpp b/engines/harvester/startup_room.cpp
index e7fb225bc65..cb3eceb2f32 100644
--- a/engines/harvester/startup_room.cpp
+++ b/engines/harvester/startup_room.cpp
@@ -834,7 +834,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 					drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
 					if (!captureScreenBackdrop(*activeScreen, roomMenuBackdrop))
 						return Common::kReadingFailed;
-					Common::Error menuError = startupFlow.runRoomMenuStub(roomMenuBackdrop);
+					Common::Error menuError = startupFlow.runRoomMenuStub(
+						roomMenuBackdrop, scene.palette, scene.targetPaletteBrightness);
 					if (menuError.getCode() != Common::kNoError)
 						return menuError;
 					startupFlow.resetCursorAnimationSequence();
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index bc79ac1d1b1..22cc06af0d6 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -23,8 +23,10 @@
 
 #include <cstdlib>
 
+#include "common/config-manager.h"
 #include "common/debug.h"
 #include "common/endian.h"
+#include "common/file.h"
 #include "common/formats/ini-file.h"
 #include "common/memstream.h"
 #include "harvester/detection.h"
@@ -41,6 +43,16 @@ static const byte kTownScriptXorKey = 0xaa;
 static const float kDefaultPaletteBrightness = 1.0f;
 static const float kDimmedPaletteBrightness = 0.6f;
 static const int kDefaultPlayerHitPoints = 30;
+static const int kMaxStartupOptionLevel = 9;
+
+static int clampStartupOptionLevel(int level) {
+	if (level < 0)
+		return 0;
+	if (level > kMaxStartupOptionLevel)
+		return kMaxStartupOptionLevel;
+
+	return level;
+}
 
 static int clampPlayerHitPoints(int hitPoints) {
 	if (hitPoints < 0)
@@ -84,6 +96,28 @@ static Common::String normalizeInteractionLabel(const Common::String &value) {
 	return label;
 }
 
+static Common::String toNativeConfigPath(const Common::String &value) {
+	Common::String result(value);
+	for (uint i = 0; i < result.size(); ++i) {
+		if (result[i] == '/')
+			result.setChar('\\', i);
+	}
+
+	return result;
+}
+
+static Common::String dialogueTextModeToConfigValue(StartupDialogueTextMode mode) {
+	switch (mode) {
+	case kStartupDialogueTextNone:
+		return "NO";
+	case kStartupDialogueTextClick:
+		return "CLICK";
+	case kStartupDialogueTextYes:
+	default:
+		return "YES";
+	}
+}
+
 static int parseEntranceFacing(const Common::String &direction) {
 	if (direction.equalsIgnoreCase("FRONT"))
 		return 0;
@@ -224,8 +258,19 @@ bool StartupScript::load(ResourceManager &resources) {
 	_heads.clear();
 	_useItems.clear();
 	_runtimeRegions.clear();
+	_fxVolumeLevel = 9;
+	_musicVolumeLevel = 3;
+	_gammaLevel = 0;
 	_quickTipsEnabled = true;
+	_goreEnabled = true;
 	_voicePath = kDefaultVoicePath;
+	_parentalPassword.clear();
+	_savePath = "./";
+	_townPath = kDefaultTownScript;
+	_cdRomPath.clear();
+	_drive1Path.clear();
+	_drive2Path.clear();
+	_drive3Path.clear();
 	_dialogueTextMode = kStartupDialogueTextYes;
 
 	loadConfig(resources);
@@ -261,8 +306,19 @@ bool StartupScript::loadConfig(ResourceManager &resources) {
 	}
 
 	Common::String townPath;
-	if (config.getKey("TOWN", kConfigSectionName, townPath))
+	if (config.getKey("TOWN", kConfigSectionName, townPath)) {
+		_townPath = townPath;
 		_path = resources.normalizeResourcePath(townPath);
+	}
+
+	Common::String savePath;
+	if (config.getKey("SAVE", kConfigSectionName, savePath))
+		_savePath = savePath;
+
+	(void)config.getKey("CD_ROM", kConfigSectionName, _cdRomPath);
+	(void)config.getKey("DRIVE_1", kConfigSectionName, _drive1Path);
+	(void)config.getKey("DRIVE_2", kConfigSectionName, _drive2Path);
+	(void)config.getKey("DRIVE_3", kConfigSectionName, _drive3Path);
 
 	Common::String quickTipsValue;
 	if (config.getKey("QUICK_TIPS", kConfigSectionName, quickTipsValue))
@@ -272,6 +328,18 @@ bool StartupScript::loadConfig(ResourceManager &resources) {
 	if (config.getKey("VOICE", kConfigSectionName, voicePath))
 		_voicePath = resources.normalizeResourcePath(voicePath);
 
+	Common::String fxVolume;
+	if (config.getKey("FX_VOLUME", kConfigSectionName, fxVolume))
+		_fxVolumeLevel = clampStartupOptionLevel(atoi(fxVolume.c_str()));
+
+	Common::String musicVolume;
+	if (config.getKey("MUSIC_VOLUME", kConfigSectionName, musicVolume))
+		_musicVolumeLevel = clampStartupOptionLevel(atoi(musicVolume.c_str()));
+
+	Common::String gammaValue;
+	if (config.getKey("GAMMA", kConfigSectionName, gammaValue))
+		_gammaLevel = clampStartupOptionLevel(atoi(gammaValue.c_str()));
+
 	Common::String textMode;
 	if (config.getKey("TEXT", kConfigSectionName, textMode)) {
 		if (textMode.equalsIgnoreCase("NO"))
@@ -282,9 +350,66 @@ bool StartupScript::loadConfig(ResourceManager &resources) {
 			_dialogueTextMode = kStartupDialogueTextYes;
 	}
 
+	Common::String goreValue;
+	if (config.getKey("GORE", kConfigSectionName, goreValue))
+		_goreEnabled = !goreValue.equalsIgnoreCase("NO") && !goreValue.equalsIgnoreCase("OFF");
+
+	Common::String passwordValue;
+	if (config.getKey("PASSWORD", kConfigSectionName, passwordValue))
+		_parentalPassword = passwordValue;
+
 	return true;
 }
 
+bool StartupScript::saveConfig() const {
+	Common::DumpFile file;
+	const Common::Path configPath = ConfMan.getPath("path").join(kStartupConfigPath, Common::Path::kNoSeparator);
+	if (!file.open(configPath)) {
+		warning("Harvester: unable to save startup config '%s'", configPath.toString(Common::Path::kNativeSeparator).c_str());
+		return false;
+	}
+
+	auto writeKey = [&](const char *key, const Common::String &value) {
+		file.writeString(Common::String::format("%s=%s\n", key, value.c_str()));
+	};
+
+	writeKey("FX_VOLUME", Common::String::format("%d", _fxVolumeLevel));
+	writeKey("MUSIC_VOLUME", Common::String::format("%d", _musicVolumeLevel));
+	writeKey("TEXT", dialogueTextModeToConfigValue(_dialogueTextMode));
+	writeKey("GORE", _goreEnabled ? "YES" : "NO");
+	writeKey("QUICK_TIPS", _quickTipsEnabled ? "ON" : "OFF");
+	if (!_savePath.empty())
+		writeKey("SAVE", toNativeConfigPath(_savePath));
+	writeKey("VOICE", toNativeConfigPath(_voicePath));
+	writeKey("GAMMA", Common::String::format("%d", _gammaLevel));
+	writeKey("TOWN", toNativeConfigPath(_townPath));
+	if (!_parentalPassword.empty())
+		writeKey("PASSWORD", _parentalPassword);
+	if (!_cdRomPath.empty())
+		writeKey("CD_ROM", _cdRomPath);
+	if (!_drive1Path.empty())
+		writeKey("DRIVE_1", _drive1Path);
+	if (!_drive2Path.empty())
+		writeKey("DRIVE_2", _drive2Path);
+	if (!_drive3Path.empty())
+		writeKey("DRIVE_3", _drive3Path);
+
+	file.flush();
+	return !file.err();
+}
+
+void StartupScript::setFxVolumeLevel(int level) {
+	_fxVolumeLevel = clampStartupOptionLevel(level);
+}
+
+void StartupScript::setMusicVolumeLevel(int level) {
+	_musicVolumeLevel = clampStartupOptionLevel(level);
+}
+
+void StartupScript::setGammaLevel(int level) {
+	_gammaLevel = clampStartupOptionLevel(level);
+}
+
 void StartupScript::decode() {
 	for (byte &value : _data) {
 		if (value == '\r' || value == '\n')
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 112d2d8f3c6..d14fe7bed4d 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -301,7 +301,19 @@ public:
 	bool isQuickTipsEnabled() const { return _quickTipsEnabled; }
 	void setQuickTipsEnabled(bool enabled) { _quickTipsEnabled = enabled; }
 	const Common::String &getVoicePath() const { return _voicePath; }
+	bool isGoreEnabled() const { return _goreEnabled; }
+	void setGoreEnabled(bool enabled) { _goreEnabled = enabled; }
 	StartupDialogueTextMode getDialogueTextMode() const { return _dialogueTextMode; }
+	void setDialogueTextMode(StartupDialogueTextMode mode) { _dialogueTextMode = mode; }
+	int getFxVolumeLevel() const { return _fxVolumeLevel; }
+	void setFxVolumeLevel(int level);
+	int getMusicVolumeLevel() const { return _musicVolumeLevel; }
+	void setMusicVolumeLevel(int level);
+	int getGammaLevel() const { return _gammaLevel; }
+	void setGammaLevel(int level);
+	const Common::String &getParentalPassword() const { return _parentalPassword; }
+	void setParentalPassword(const Common::String &password) { _parentalPassword = password; }
+	bool saveConfig() const;
 	void resetRuntimeState();
 	bool resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
 		ResourceManager &resources);
@@ -392,8 +404,19 @@ private:
 	Common::Array<StartupNpcRecord> _runtimeNpcs;
 	Common::Array<StartupMonsterRecord> _runtimeMonsters;
 	int _playerCurrentHitPoints = 30;
+	int _fxVolumeLevel = 9;
+	int _musicVolumeLevel = 3;
+	int _gammaLevel = 0;
 	bool _quickTipsEnabled = true;
+	bool _goreEnabled = true;
 	Common::String _voicePath = "SOUND/VOICE/";
+	Common::String _parentalPassword;
+	Common::String _savePath = "./";
+	Common::String _townPath = "HARVEST.SCR";
+	Common::String _cdRomPath;
+	Common::String _drive1Path;
+	Common::String _drive2Path;
+	Common::String _drive3Path;
 	StartupDialogueTextMode _dialogueTextMode = kStartupDialogueTextYes;
 };
 


Commit: f19beab20e3b16386c793261f5d505970e3d8879
    https://github.com/scummvm/scummvm/commit/f19beab20e3b16386c793261f5d505970e3d8879
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:23+02:00

Commit Message:
HARVESTER: Implement room help menu

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/startup_menu.cpp
    engines/harvester/startup_menu.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 5d016693118..4ed23f91f28 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1198,6 +1198,27 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 **Notes**
 - The native submenu persists `GAMMA`, `FX_VOLUME`, and `MUSIC_VOLUME` on exit and updates the string-backed `TEXT`, `GORE`, `QUICK_TIPS`, and password state inline while the submenu is active.
 
+## Room Menu Help Screen
+
+**Confidence:** High
+
+**Evidence**
+- `run_controls_help_screen` at `0x6c3e0` is the dedicated two-page controls help viewer, and `run_main_menu` also contains the same behavior inline for the `HELP` row.
+- Page `0` loads `graphic\other\moushelp.bm` with `1:\graphic\pal\moushelp.pal`; page `1` loads `graphic\other\keyshelp.bm` with `1:\graphic\pal\keyshelp.pal`.
+- Each page is a fullscreen bitmap entity shown at `(0, 0)` after clearing the screen and uploading the page palette.
+- The loop waits for input, exits on `ESC` or secondary mouse, and toggles pages on primary mouse or any other key.
+- On exit it reuploads `g_current_palette_buffer`, clears the screen, reuploads the room palette again, and resumes the paused timer countdowns.
+
+**Key Functions**
+- `run_controls_help_screen`
+- `run_main_menu`
+
+**Key Data**
+- `g_current_palette_buffer`
+
+**Notes**
+- The help viewer is palette-driven and does not reuse the room backdrop while active; the room/menu image only returns after the fullscreen help page is dismissed.
+
 ## Player Health And Inventory Status Portrait
 
 **Confidence:** High
diff --git a/TRACKER.md b/TRACKER.md
index 41ccf2987b8..dba305983b2 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,17 +15,15 @@
 
 ## Last Confirmed Action
 
-- Recovered the native `OPTIONS` room-menu branch from `run_main_menu` case `3`.
-  - Confirmed it loads `options_menu_1..7` from `MENU.INI`, draws three `VOLUME.BM` bars plus three `INDICATR.BM` sliders, previews `WHIP2.WAV` for FX volume, updates music volume through `g_music_stream_state`, and reapplies the current room palette after gamma changes through `update_gamma_brightness_scale`.
-  - Confirmed only the `TEXT`, `GORE`, and `PASSWORD` rows append native ` - ...` status suffixes through `format_main_menu_option_status_suffix`.
-  - Confirmed the `QUICK TIPS` row reuses the `TIPS.BM` overlay and `ADJHEAD.RCS` text pool, and the password row calls `prompt_for_password` / `run_text_entry_dialog`.
-  - Renamed `DAT_000c7e5c` to `g_parental_password_buffer` once the password storage path was bounded from both the options menu and parental-password validation path.
-- Implemented the engine-side `OPTIONS` submenu on the ESC room menu:
-  - room-menu `OPTIONS` now opens a native-style overlay with working FX / music / gamma sliders, text-mode cycling, gore toggling, quick-tips browsing, and parental-password add/remove behavior;
-  - startup config state now tracks `FX_VOLUME`, `MUSIC_VOLUME`, `GAMMA`, `TEXT`, `GORE`, `QUICK_TIPS`, and the parental password buffer, and rewrites sectionless `CONFIG.INI` updates back to the game directory;
-  - mixer volume and room/dialogue palette scaling now honor those recovered startup-option values so the submenu changes are live rather than cosmetic.
+- Recovered the native `HELP` room-menu branch from both `run_controls_help_screen` and the inline `run_main_menu` help path.
+  - Confirmed it alternates between `MOUSHELP.BM` + `MOUSHELP.PAL` and `KEYSHELP.BM` + `KEYSHELP.PAL`, exits on `ESC` or right mouse, and toggles pages on left mouse or any other key.
+  - Confirmed the viewer is palette-driven fullscreen help rather than an overlay on the room/menu backdrop, and that it restores `g_current_palette_buffer` before returning to gameplay UI.
+- Implemented the engine-side `HELP` screen on the ESC room menu:
+  - room-menu `HELP` now loads and displays the two native help pages with their own palettes;
+  - page toggling and exit behavior now match the native input flow closely enough for side-by-side validation;
+  - returning from help restores the active room palette so the room menu redraw resumes on the correct color state.
 
 ## Next Suggested Action
 
-1. Trace and implement the native `HELP` room-menu branch next by matching `run_controls_help_screen` / the inline `run_main_menu` help path, including the two-page bitmap toggle and palette restore behavior.
-2. After `HELP`, recover the `SAVE GAME` path with the current engine serializer in mind so the room-menu slot UI and actual save-state coverage can be closed together instead of drifting apart.
+1. Recover the native `SAVE GAME` room-menu path next, including the slot list, description entry, and the pre-save room-state sync point, then compare that against the current ScummVM serializer coverage.
+2. If the serializer is still too shallow for real room-state saves, close that gap before wiring the room-menu save UI so the menu does not outpace actual persistence.
diff --git a/engines/harvester/startup_menu.cpp b/engines/harvester/startup_menu.cpp
index 7024caf7fb7..c3c64eb0211 100644
--- a/engines/harvester/startup_menu.cpp
+++ b/engines/harvester/startup_menu.cpp
@@ -89,6 +89,7 @@ struct RoomMenuTextConfig {
 	Common::String clickLabel = "CLICK";
 };
 
+static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y);
 static int getNativeRoomMenuLineHeight(const Graphics::Font &selectedFont);
 
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
@@ -207,6 +208,15 @@ static bool loadBitmapResource(ResourceManager &resources, const Common::String
 	return true;
 }
 
+static bool loadPaletteResource(ResourceManager &resources, const Common::String &path, byte *palette) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 256 * 3)
+		return false;
+
+	memcpy(palette, data.data(), 256 * 3);
+	return true;
+}
+
 static void applyMenuPalette(Graphics::Screen &screen, const HarvesterEngine &engine,
 		const byte *palette, float brightness) {
 	if (!palette)
@@ -217,6 +227,20 @@ static void applyMenuPalette(Graphics::Screen &screen, const HarvesterEngine &en
 	screen.setPalette(displayPalette);
 }
 
+static void renderHelpScreen(HarvesterEngine &engine, const IndexedBitmap &bitmap, const byte *palette) {
+	Graphics::Screen *screen = engine.getScreen();
+	if (!screen)
+		return;
+
+	applyMenuPalette(*screen, engine, palette, 1.0f);
+	screen->fillRect(screen->getBounds(), 0);
+	blitBitmap(*screen, bitmap, 0, 0);
+	if (engine.getRuntimeEntities())
+		engine.getRuntimeEntities()->drawCursor(*screen);
+	screen->makeAllDirty();
+	screen->update();
+}
+
 static Common::String buildTextModeSuffix(const StartupScript &startupScript, const RoomMenuTextConfig &config) {
 	switch (startupScript.getDialogueTextMode()) {
 	case kStartupDialogueTextNone:
@@ -623,6 +647,11 @@ Common::Error StartupMenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop,
 						if (optionsError.getCode() != Common::kNoError)
 							return optionsError;
 						needsRedraw = true;
+					} else if (selectedItem >= 0 && _menuItems[selectedItem].equalsIgnoreCase("HELP")) {
+						Common::Error helpError = runHelpScreen(palette, paletteBrightness, startupFlow);
+						if (helpError.getCode() != Common::kNoError)
+							return helpError;
+						needsRedraw = true;
 					} else {
 						debug(1, "Harvester: room menu item '%s' selected but not implemented",
 							selectedItem >= 0 ? _menuItems[selectedItem].c_str() : "");
@@ -642,6 +671,11 @@ Common::Error StartupMenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop,
 					if (optionsError.getCode() != Common::kNoError)
 						return optionsError;
 					needsRedraw = true;
+				} else if (_menuItems[selectedItem].equalsIgnoreCase("HELP")) {
+					Common::Error helpError = runHelpScreen(palette, paletteBrightness, startupFlow);
+					if (helpError.getCode() != Common::kNoError)
+						return helpError;
+					needsRedraw = true;
 				} else {
 					debug(1, "Harvester: room menu item '%s' clicked but not implemented",
 						_menuItems[selectedItem].c_str());
@@ -970,6 +1004,80 @@ Common::Error StartupMenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, c
 	return Common::kNoError;
 }
 
+Common::Error StartupMenuSystem::runHelpScreen(const byte *palette, float paletteBrightness, StartupFlow &startupFlow) {
+	ResourceManager *resources = _engine.getResources();
+	if (!resources)
+		return Common::kReadingFailed;
+
+	IndexedBitmap mouseHelp;
+	IndexedBitmap keysHelp;
+	byte mouseHelpPalette[256 * 3];
+	byte keysHelpPalette[256 * 3];
+	if (!loadBitmapResource(*resources, "1:/GRAPHIC/OTHER/MOUSHELP.BM", mouseHelp) ||
+			!loadBitmapResource(*resources, "1:/GRAPHIC/OTHER/KEYSHELP.BM", keysHelp) ||
+			!loadPaletteResource(*resources, "1:/GRAPHIC/PAL/MOUSHELP.PAL", mouseHelpPalette) ||
+			!loadPaletteResource(*resources, "1:/GRAPHIC/PAL/KEYSHELP.PAL", keysHelpPalette)) {
+		return Common::kReadingFailed;
+	}
+
+	int page = 0;
+	bool needsRedraw = true;
+	Graphics::FrameLimiter limiter(g_system, 60);
+
+	while (!_engine.shouldQuit()) {
+		if (needsRedraw) {
+			renderHelpScreen(_engine, page == 0 ? mouseHelp : keysHelp,
+				page == 0 ? mouseHelpPalette : keysHelpPalette);
+			needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (startupFlow.handleSystemEvent(event, result))
+				return result;
+
+			switch (event.type) {
+			case Common::EVENT_MOUSEMOVE:
+				needsRedraw = true;
+				break;
+			case Common::EVENT_RBUTTONDOWN:
+				if (Graphics::Screen *screen = _engine.getScreen()) {
+					applyMenuPalette(*screen, _engine, palette, paletteBrightness);
+					screen->fillRect(screen->getBounds(), 0);
+				}
+				return Common::kNoError;
+			case Common::EVENT_LBUTTONDOWN:
+				page ^= 1;
+				needsRedraw = true;
+				break;
+			case Common::EVENT_KEYDOWN:
+				if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					if (Graphics::Screen *screen = _engine.getScreen()) {
+						applyMenuPalette(*screen, _engine, palette, paletteBrightness);
+						screen->fillRect(screen->getBounds(), 0);
+					}
+					return Common::kNoError;
+				}
+				page ^= 1;
+				needsRedraw = true;
+				break;
+			default:
+				break;
+			}
+		}
+
+		RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+		if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
+			needsRedraw = true;
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
 Common::String StartupMenuSystem::runModalTextEntryDialog(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, const Graphics::Font &titleFont, const Graphics::Font &entryFont,
 		const Common::String &title, const Common::String &initialText, StartupFlow &startupFlow) {
diff --git a/engines/harvester/startup_menu.h b/engines/harvester/startup_menu.h
index d3bf69a6b10..47bb53fdbcd 100644
--- a/engines/harvester/startup_menu.h
+++ b/engines/harvester/startup_menu.h
@@ -49,6 +49,7 @@ public:
 private:
 	Common::Error runOptionsMenu(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, StartupFlow &startupFlow);
+	Common::Error runHelpScreen(const byte *palette, float paletteBrightness, StartupFlow &startupFlow);
 	Common::String runModalTextEntryDialog(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, const Graphics::Font &titleFont, const Graphics::Font &entryFont,
 		const Common::String &title, const Common::String &initialText, StartupFlow &startupFlow);


Commit: a96e6ea3aabae25cf56350392ef8da3252dad658
    https://github.com/scummvm/scummvm/commit/a96e6ea3aabae25cf56350392ef8da3252dad658
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:23+02:00

Commit Message:
HARVESTER: Implement room save game menu

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/metaengine.cpp
    engines/harvester/metaengine.h
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_menu.cpp
    engines/harvester/startup_menu.h
    engines/harvester/startup_room.cpp
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 4ed23f91f28..0f29ccc7be2 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -382,6 +382,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `run_save_game_menu` at `0x632c0` is the matching save-game UI.
   - It loads `savegame.pal` and `savegame.bm`.
   - It enumerates the same 25 save slots and supports text entry for save names.
+  - The active slot is derived from the list rectangle `x=0x08..0x27b`, `y=0x19..0x1c2` with a native row stride of `0x11`.
+  - Clicking a slot, pressing `Enter`, or clicking the lower-left action area `x=0x03..0x13d`, `y=0x1cc..0x1dc` all route into the same `run_text_entry_dialog` path for the active slot; `ESC` and the lower-right area `x=0x141..0x27b`, `y=0x1cc..0x1dc` cancel back to gameplay.
   - It first calls `sync_current_room_runtime_state_before_save_load`, which copies current-room monster runtime positions/facing back into `MonsterRecord` fields and refreshes the `SAVE_GAME` entrance from `g_player_combat_avatar`.
   - The save header is now explicit: current disc id, `g_player_present_in_room`, `g_game_clock_tick_remainder_ms`, `g_current_room_def->room_name`, and `g_player_room_name`.
   - The remainder of the save blob persists the player combat-avatar state, record lists (`ANIM`, `FLAG`, `MONSTER`, `NPC`, `OBJECT`, `REGION`, `TIMER`), the talk-state blocks, ammo counters, and `g_current_music_path`.
diff --git a/TRACKER.md b/TRACKER.md
index dba305983b2..dcebea3e3b7 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,15 +15,16 @@
 
 ## Last Confirmed Action
 
-- Recovered the native `HELP` room-menu branch from both `run_controls_help_screen` and the inline `run_main_menu` help path.
-  - Confirmed it alternates between `MOUSHELP.BM` + `MOUSHELP.PAL` and `KEYSHELP.BM` + `KEYSHELP.PAL`, exits on `ESC` or right mouse, and toggles pages on left mouse or any other key.
-  - Confirmed the viewer is palette-driven fullscreen help rather than an overlay on the room/menu backdrop, and that it restores `g_current_palette_buffer` before returning to gameplay UI.
-- Implemented the engine-side `HELP` screen on the ESC room menu:
-  - room-menu `HELP` now loads and displays the two native help pages with their own palettes;
-  - page toggling and exit behavior now match the native input flow closely enough for side-by-side validation;
-  - returning from help restores the active room palette so the room menu redraw resumes on the correct color state.
+- Recovered and implemented the native `SAVE GAME` room-menu path from `run_save_game_menu` and `sync_current_room_runtime_state_before_save_load`.
+  - Confirmed the save screen uses `SAVEGAME.BM` + `SAVEGAME.PAL`, exposes 25 fixed slots, and routes slot-click, `Enter`, and the lower-left action area through the same inline text-entry-and-save path.
+  - Confirmed the native pre-save sync step copies live room state back into persistent records before serialization, and that the room menu cancels via `ESC` or the lower-right action area.
+- Implemented the engine-side `SAVE GAME` screen on the ESC room menu:
+  - room-menu `SAVE GAME` now opens a native-shaped 25-slot save UI with inline text entry and immediate commit after confirmation;
+  - Harvester now has a real ScummVM save serializer for the current startup/runtime subset, including room resume context, runtime script records, and Steve's current HP;
+  - launcher/runtime loads now resume via `materializeRoomState()` so a loaded room comes back without replaying room-enter command chains;
+  - the Harvester metaengine now exposes the same `0..24` slot range the native menu uses and disables generic autosave slot reservation.
 
 ## Next Suggested Action
 
-1. Recover the native `SAVE GAME` room-menu path next, including the slot list, description entry, and the pre-save room-state sync point, then compare that against the current ScummVM serializer coverage.
-2. If the serializer is still too shallow for real room-state saves, close that gap before wiring the room-menu save UI so the menu does not outpace actual persistence.
+1. Recover the native `QUIT GAME` confirmation branch from `run_main_menu`, especially the `textbox4.bm` prompt, YES/NO hit regions, keyboard shortcuts, and the post-confirm quit handoff.
+2. Mirror that confirmation flow in the ESC room menu so `QUIT GAME` stops being a stub without regressing palette restoration or room-loop cleanup on cancel.
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 00e18696294..8372d212d34 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -84,6 +84,16 @@ static float mapStartupGammaLevelToBrightnessScale(int level) {
 	return 1.0f + 0.1f * clampStartupOptionLevel(level);
 }
 
+static void syncSerializedBool(Common::Serializer &s, bool &value) {
+	byte serialized = value ? 1 : 0;
+	s.syncAsByte(serialized);
+	if (s.isLoading())
+		value = serialized != 0;
+}
+
+static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
+static const uint32 kHarvesterSaveVersion = 1;
+
 static uint32 decodeHarvesterFcmp(byte *dest, const byte *src, uint32 srcSize, uint16 bitsPerSample) {
 	const uint32 decodedBytesPerInputByte = bitsPerSample >> 2;
 	const uint32 decodedSize = srcSize * decodedBytesPerInputByte;
@@ -219,6 +229,14 @@ Common::String HarvesterEngine::getGameId() const {
 	return _gameDescription->gameId;
 }
 
+bool HarvesterEngine::canLoadGameStateCurrently(Common::U32String *) {
+	return _startupScript != nullptr;
+}
+
+bool HarvesterEngine::canSaveGameStateCurrently(Common::U32String *) {
+	return _startupScript != nullptr && _currentStartupSaveRoomState.valid;
+}
+
 bool HarvesterEngine::isGoreEnabled() const {
 	if (_startupScript)
 		return _startupScript->isGoreEnabled();
@@ -467,6 +485,39 @@ void HarvesterEngine::stopStartupSound() {
 	_startupSoundSlotIndex = -1;
 }
 
+void HarvesterEngine::captureCurrentStartupSaveRoomState(const Common::String &entranceName,
+		const Common::String &roomName, int playerX, int playerY, int playerZ, int playerFacing,
+		const Common::String &musicPath) {
+	_currentStartupSaveRoomState.entranceName = entranceName;
+	_currentStartupSaveRoomState.roomName = roomName;
+	_currentStartupSaveRoomState.musicPath =
+		(_resources && !musicPath.empty()) ? _resources->normalizeResourcePath(musicPath) : musicPath;
+	_currentStartupSaveRoomState.playerX = playerX;
+	_currentStartupSaveRoomState.playerY = playerY;
+	_currentStartupSaveRoomState.playerZ = playerZ;
+	_currentStartupSaveRoomState.playerFacing = playerFacing;
+	_currentStartupSaveRoomState.valid = !roomName.empty();
+}
+
+void HarvesterEngine::clearCurrentStartupSaveRoomState() {
+	_currentStartupSaveRoomState.clear();
+}
+
+void HarvesterEngine::clearPendingLoadedStartupSaveRoomState() {
+	_pendingLoadedStartupSaveRoomState.clear();
+}
+
+void HarvesterEngine::syncStartupSaveRoomState(Common::Serializer &s, StartupSaveRoomState &state) {
+	syncSerializedBool(s, state.valid);
+	s.syncString(state.entranceName);
+	s.syncString(state.roomName);
+	s.syncString(state.musicPath);
+	s.syncAsSint32LE(state.playerX);
+	s.syncAsSint32LE(state.playerY);
+	s.syncAsSint32LE(state.playerZ);
+	s.syncAsSint32LE(state.playerFacing);
+}
+
 void HarvesterEngine::stopStartupSoundHandle(Audio::SoundHandle &handle) {
 	if (g_system && g_system->getMixer())
 		g_system->getMixer()->stopHandle(handle);
@@ -557,13 +608,29 @@ bool HarvesterEngine::hasFeature(EngineFeature f) const {
 }
 
 Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
-	// The Serializer has methods isLoading() and isSaving()
-	// if you need to specific steps; for example setting
-	// an array size after reading it's length, whereas
-	// for saving it would write the existing array's length
-	uint32 dummy = 0;
-	s.syncAsUint32LE(dummy);
+	if (!_startupScript)
+		return s.isLoading() ? Common::kReadingFailed : Common::kWritingFailed;
+	if (s.isSaving() && !_currentStartupSaveRoomState.valid)
+		return Common::kWritingFailed;
+	if (!s.matchBytes(kHarvesterSaveMagic, sizeof(kHarvesterSaveMagic)))
+		return Common::kReadingFailed;
+	if (!s.syncVersion(kHarvesterSaveVersion))
+		return Common::kReadingFailed;
 
+	StartupSaveRoomState roomState = s.isLoading()
+		? StartupSaveRoomState()
+		: _currentStartupSaveRoomState;
+	syncStartupSaveRoomState(s, roomState);
+	_startupScript->syncRuntimeSaveState(s);
+	if (s.err())
+		return s.isLoading() ? Common::kReadingFailed : Common::kWritingFailed;
+
+	if (s.isLoading()) {
+		if (!roomState.valid || roomState.roomName.empty())
+			return Common::kReadingFailed;
+		_currentStartupSaveRoomState = roomState;
+		_pendingLoadedStartupSaveRoomState = roomState;
+	}
 	return Common::kNoError;
 }
 
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 8343e6e2d03..8f35ee09489 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -41,6 +41,28 @@ class StartupScript;
 class StartupArt;
 class StartupText;
 
+struct StartupSaveRoomState {
+	Common::String entranceName;
+	Common::String roomName;
+	Common::String musicPath;
+	int playerX = 0;
+	int playerY = 0;
+	int playerZ = 0;
+	int playerFacing = -1;
+	bool valid = false;
+
+	void clear() {
+		entranceName.clear();
+		roomName.clear();
+		musicPath.clear();
+		playerX = 0;
+		playerY = 0;
+		playerZ = 0;
+		playerFacing = -1;
+		valid = false;
+	}
+};
+
 class HarvesterEngine : public Engine {
 public:
 	HarvesterEngine(OSystem *syst, const ADGameDescription *gameDesc);
@@ -61,8 +83,8 @@ public:
 		return _randomSource.getRandomNumber(maxNum);
 	}
 
-	bool canLoadGameStateCurrently(Common::U32String *msg = nullptr) override { return true; }
-	bool canSaveGameStateCurrently(Common::U32String *msg = nullptr) override { return true; }
+	bool canLoadGameStateCurrently(Common::U32String *msg = nullptr) override;
+	bool canSaveGameStateCurrently(Common::U32String *msg = nullptr) override;
 
 	/**
 	 * Uses a serializer to allow implementing savegame
@@ -106,8 +128,19 @@ public:
 	bool playStartupLoadedSound(int slot);
 	bool deleteStartupLoadedSound(int slot);
 	void stopStartupSound();
+	void captureCurrentStartupSaveRoomState(const Common::String &entranceName,
+		const Common::String &roomName, int playerX, int playerY, int playerZ, int playerFacing,
+		const Common::String &musicPath);
+	void clearCurrentStartupSaveRoomState();
+	bool hasCurrentStartupSaveRoomState() const { return _currentStartupSaveRoomState.valid; }
+	bool hasPendingLoadedStartupSaveRoomState() const { return _pendingLoadedStartupSaveRoomState.valid; }
+	const StartupSaveRoomState &getPendingLoadedStartupSaveRoomState() const {
+		return _pendingLoadedStartupSaveRoomState;
+	}
+	void clearPendingLoadedStartupSaveRoomState();
 
 private:
+	void syncStartupSaveRoomState(Common::Serializer &s, StartupSaveRoomState &state);
 	void stopStartupSoundHandle(Audio::SoundHandle &handle);
 	bool validateStartupLoadedSoundSlot(int slot) const;
 	void applyStartupMixerLevels();
@@ -132,6 +165,8 @@ private:
 	StartupScript *_startupScript = nullptr;
 	StartupArt *_startupArt = nullptr;
 	StartupText *_startupText = nullptr;
+	StartupSaveRoomState _currentStartupSaveRoomState;
+	StartupSaveRoomState _pendingLoadedStartupSaveRoomState;
 };
 
 extern HarvesterEngine *g_engine;
diff --git a/engines/harvester/metaengine.cpp b/engines/harvester/metaengine.cpp
index 00f7db6fa18..9d03547bc85 100644
--- a/engines/harvester/metaengine.cpp
+++ b/engines/harvester/metaengine.cpp
@@ -55,6 +55,14 @@ Common::Error HarvesterMetaEngine::createInstance(OSystem *syst, Engine **engine
 	return Common::kNoError;
 }
 
+int HarvesterMetaEngine::getMaximumSaveSlot() const {
+	return 24;
+}
+
+int HarvesterMetaEngine::getAutosaveSlot() const {
+	return -1;
+}
+
 bool HarvesterMetaEngine::hasFeature(MetaEngineFeature f) const {
 	return checkExtendedSaves(f) ||
 		(f == kSupportsLoadingDuringStartup);
diff --git a/engines/harvester/metaengine.h b/engines/harvester/metaengine.h
index 0a9a4fbfbe3..57644f2b606 100644
--- a/engines/harvester/metaengine.h
+++ b/engines/harvester/metaengine.h
@@ -30,6 +30,8 @@ public:
 	const ADExtraGuiOptionsMap *getAdvancedExtraGuiOptions() const override;
 
 	Common::Error createInstance(OSystem *syst, Engine **engine, const ADGameDescription *desc) const override;
+	int getMaximumSaveSlot() const override;
+	int getAutosaveSlot() const override;
 
 	/**
 	 * Determine whether the engine supports the specified MetaEngine feature.
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index be782045d9d..d532d287c8a 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -1704,14 +1704,23 @@ Common::Error StartupFlow::run() {
 
 	clearPendingMainMenuReturn();
 	resetRoomNpcDialogueState();
-	_engine.getStartupScript()->resetRuntimeState();
-	Common::Error error = runQuickTips();
-	if (error.getCode() != Common::kNoError)
-		return error;
-
-	clearPendingMainMenuReturn();
-	_engine.getStartupScript()->resetRuntimeState();
-	error = runRoomLoop("START");
+	Common::Error error = Common::kNoError;
+	if (_engine.hasPendingLoadedStartupSaveRoomState()) {
+		Common::String initialTarget = _engine.getPendingLoadedStartupSaveRoomState().entranceName;
+		if (initialTarget.empty())
+			initialTarget = _engine.getPendingLoadedStartupSaveRoomState().roomName;
+		error = runRoomLoop(initialTarget);
+	} else {
+		_engine.clearCurrentStartupSaveRoomState();
+		_engine.getStartupScript()->resetRuntimeState();
+		error = runQuickTips();
+		if (error.getCode() != Common::kNoError)
+			return error;
+
+		clearPendingMainMenuReturn();
+		_engine.getStartupScript()->resetRuntimeState();
+		error = runRoomLoop("START");
+	}
 	if (error.getCode() != Common::kNoError)
 		return error;
 	if (!takePendingMainMenuReturn())
@@ -2054,6 +2063,7 @@ bool StartupFlow::takeQueuedDialogueInteraction(StartupInteractionResult &intera
 
 void StartupFlow::requestMainMenuReturn() {
 	_pendingMainMenuReturn = true;
+	_engine.clearCurrentStartupSaveRoomState();
 }
 
 bool StartupFlow::hasPendingMainMenuReturn() const {
diff --git a/engines/harvester/startup_menu.cpp b/engines/harvester/startup_menu.cpp
index c3c64eb0211..652f5b8dd7f 100644
--- a/engines/harvester/startup_menu.cpp
+++ b/engines/harvester/startup_menu.cpp
@@ -27,6 +27,7 @@
 #include "common/formats/ini-file.h"
 #include "common/memstream.h"
 #include "common/system.h"
+#include "engines/metaengine.h"
 #include "graphics/blit.h"
 #include "graphics/font.h"
 #include "graphics/fontman.h"
@@ -65,6 +66,8 @@ static const char *const kMenuSectionName = "menu";
 static const char *const kOptionsVolumeBitmapPath = "1:/GRAPHIC/OTHER/VOLUME.BM";
 static const char *const kOptionsIndicatorBitmapPath = "1:/GRAPHIC/OTHER/INDICATR.BM";
 static const char *const kOptionsPreviewSoundPath = "1:/SOUND/EFFECTS/WHIP2.WAV";
+static const char *const kSaveGameBitmapPath = "1:/GRAPHIC/OTHER/SAVEGAME.BM";
+static const char *const kSaveGamePalettePath = "1:/GRAPHIC/PAL/SAVEGAME.PAL";
 
 static const int kOptionsSliderMinX = 0x16f;
 static const int kOptionsSliderMaxX = 0x22b;
@@ -81,6 +84,14 @@ static const int kQuickTipsOverlayY = 200;
 static const int kQuickTipTextX = 180;
 static const int kQuickTipTextY = 228;
 static const int kQuickTipTextWidth = 280;
+static const int kSaveSlotCount = 25;
+static const int kSaveSlotLabelX = 8;
+static const int kSaveSlotNameX = 0x50;
+static const int kSaveSlotStartY = 0x19;
+static const int kSaveSlotStride = 0x11;
+static const int kSaveSlotTextWidth = 0x27b - kSaveSlotNameX;
+static const int kSaveStatusTextY = 0x1b8;
+static const int kSaveSlotMaxCharacters = 63;
 
 struct RoomMenuTextConfig {
 	Common::Array<Common::String> optionItems;
@@ -138,6 +149,19 @@ static Common::Rect textEntryCancelRect() {
 	return Common::Rect(344, 278, 424, 292);
 }
 
+static Common::Rect saveSlotRect(int slotIndex) {
+	const int top = kSaveSlotStartY + slotIndex * kSaveSlotStride;
+	return Common::Rect(8, top, 0x27b + 1, top + kSaveSlotStride);
+}
+
+static Common::Rect saveConfirmRect() {
+	return Common::Rect(3, 0x1cc, 0x13d + 1, 0x1dc + 1);
+}
+
+static Common::Rect saveCancelRect() {
+	return Common::Rect(0x141, 0x1cc, 0x27b + 1, 0x1dc + 1);
+}
+
 static Common::Rect optionsSliderRect(int sliderIndex, int lineHeight) {
 	const int y = kOptionsIndicatorY + sliderIndex * lineHeight;
 	return Common::Rect(kOptionsSliderMinX, y, kOptionsSliderMaxX + 1, y + kOptionsSliderHeight + 1);
@@ -283,6 +307,22 @@ static bool appendTextEntryCharacter(Common::String &text, const Graphics::Font
 	return true;
 }
 
+static bool appendInlineSaveCharacter(Common::String &text, const Graphics::Font &font, uint32 ascii) {
+	if (ascii < 32 || ascii > 126 || ascii == '~' || text.size() >= kSaveSlotMaxCharacters)
+		return false;
+
+	const Common::String candidate = text + (char)ascii;
+	if (font.getStringWidth(candidate) > kSaveSlotTextWidth)
+		return false;
+
+	text = candidate;
+	return true;
+}
+
+static Common::String buildDefaultSaveSlotLabel(int slotIndex) {
+	return Common::String::format("GAME %02d", slotIndex + 1);
+}
+
 static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
 	if (!bitmap.isValid())
 		return;
@@ -452,6 +492,49 @@ static void renderTextEntryDialog(HarvesterEngine &engine, const IndexedBitmap &
 	screen->update();
 }
 
+static void renderSaveGameMenuScreen(HarvesterEngine &engine, const IndexedBitmap &background,
+		const byte *palette, float paletteBrightness, const Graphics::Font &selectedFont,
+		const Graphics::Font &unselectedFont, const Common::Array<Common::String> &slotTitles,
+		int activeSlot, bool hoverSave, bool hoverCancel, const Common::String &statusMessage,
+		int editingSlot = -1, const Common::String *editingText = nullptr) {
+	Graphics::Screen *screen = engine.getScreen();
+	if (!screen)
+		return;
+
+	applyMenuPalette(*screen, engine, palette, paletteBrightness);
+	blitBitmap(*screen, background, 0, 0);
+
+	for (int i = 0; i < kSaveSlotCount; ++i) {
+		const bool selected = i == activeSlot;
+		const Graphics::Font &labelFont = selected ? selectedFont : unselectedFont;
+		const Graphics::Font &nameFont = (i == editingSlot || selected) ? selectedFont : unselectedFont;
+		const int y = kSaveSlotStartY + i * kSaveSlotStride;
+		const Common::String slotLabel = buildDefaultSaveSlotLabel(i);
+		labelFont.drawString(screen, slotLabel, kSaveSlotLabelX, y, 64, 0);
+
+		Common::String displayTitle = slotTitles[i];
+		if (i == editingSlot && editingText)
+			displayTitle = *editingText + "_";
+		nameFont.drawString(screen, displayTitle, kSaveSlotNameX, y, kSaveSlotTextWidth, 0);
+	}
+
+	drawShadowedString(*screen, selectedFont, "SAVE", saveConfirmRect().left, saveConfirmRect().top,
+		saveConfirmRect().width(), hoverSave ? kTextColorHover : kTextColorNormal,
+		Graphics::kTextAlignCenter);
+	drawShadowedString(*screen, selectedFont, "CANCEL", saveCancelRect().left, saveCancelRect().top,
+		saveCancelRect().width(), hoverCancel ? kTextColorHover : kTextColorNormal,
+		Graphics::kTextAlignCenter);
+	if (!statusMessage.empty()) {
+		drawShadowedString(*screen, unselectedFont, statusMessage, 20, kSaveStatusTextY,
+			screen->w - 40, kTextColorNormal, Graphics::kTextAlignCenter);
+	}
+
+	if (engine.getRuntimeEntities())
+		engine.getRuntimeEntities()->drawCursor(*screen);
+	screen->makeAllDirty();
+	screen->update();
+}
+
 static int getNativeRoomMenuLineHeight(const Graphics::Font &selectedFont) {
 	return selectedFont.getFontHeight() + 2;
 }
@@ -652,6 +735,11 @@ Common::Error StartupMenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop,
 						if (helpError.getCode() != Common::kNoError)
 							return helpError;
 						needsRedraw = true;
+					} else if (selectedItem >= 0 && _menuItems[selectedItem].equalsIgnoreCase("SAVE GAME")) {
+						Common::Error saveError = runSaveGameMenu(palette, paletteBrightness, startupFlow);
+						if (saveError.getCode() != Common::kNoError)
+							return saveError;
+						needsRedraw = true;
 					} else {
 						debug(1, "Harvester: room menu item '%s' selected but not implemented",
 							selectedItem >= 0 ? _menuItems[selectedItem].c_str() : "");
@@ -676,6 +764,11 @@ Common::Error StartupMenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop,
 					if (helpError.getCode() != Common::kNoError)
 						return helpError;
 					needsRedraw = true;
+				} else if (_menuItems[selectedItem].equalsIgnoreCase("SAVE GAME")) {
+					Common::Error saveError = runSaveGameMenu(palette, paletteBrightness, startupFlow);
+					if (saveError.getCode() != Common::kNoError)
+						return saveError;
+					needsRedraw = true;
 				} else {
 					debug(1, "Harvester: room menu item '%s' clicked but not implemented",
 						_menuItems[selectedItem].c_str());
@@ -697,6 +790,236 @@ Common::Error StartupMenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop,
 	return Common::kNoError;
 }
 
+Common::Error StartupMenuSystem::runSaveGameMenu(const byte *palette, float paletteBrightness,
+		StartupFlow &startupFlow) {
+	(void)palette;
+	(void)paletteBrightness;
+	ResourceManager *resources = _engine.getResources();
+	MetaEngine *metaEngine = _engine.getMetaEngine();
+	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
+	const CftFontResource *unselectedFontResource = findStartupFontByName(_engine, "HARVFNT2");
+	if (!resources || !metaEngine || !selectedFontResource || !unselectedFontResource)
+		return Common::kReadingFailed;
+	if (!_engine.canSaveGameStateCurrently())
+		return Common::kNoError;
+
+	HarvesterCftFont selectedFont(*selectedFontResource);
+	HarvesterCftFont unselectedFont(*unselectedFontResource);
+	if (!selectedFont.isValid() || !unselectedFont.isValid())
+		return Common::kReadingFailed;
+
+	IndexedBitmap background;
+	byte savePalette[256 * 3];
+	if (!loadBitmapResource(*resources, kSaveGameBitmapPath, background) ||
+			!loadPaletteResource(*resources, kSaveGamePalettePath, savePalette)) {
+		return Common::kReadingFailed;
+	}
+
+	Common::Array<Common::String> slotTitles;
+	slotTitles.resize(kSaveSlotCount);
+	auto reloadSlotTitles = [&]() {
+		for (int i = 0; i < kSaveSlotCount; ++i)
+			slotTitles[i].clear();
+
+		const SaveStateList saves = metaEngine->listSaves(ConfMan.getActiveDomainName().c_str());
+		for (const SaveStateDescriptor &save : saves) {
+			const int slot = save.getSaveSlot();
+			if (slot < 0 || slot >= kSaveSlotCount)
+				continue;
+			slotTitles[slot] = save.getDescription().encode();
+		}
+	};
+	reloadSlotTitles();
+
+	auto resolveHoveredSlot = [&]() -> int {
+		for (int slot = 0; slot < kSaveSlotCount; ++slot) {
+			if (saveSlotRect(slot).contains(_mousePos))
+				return slot;
+		}
+		return -1;
+	};
+	auto runInlineSlotEditor = [&](int slotIndex, Common::String &editedTitle,
+			bool &confirmed) -> Common::Error {
+		confirmed = false;
+		bool needsRedraw = true;
+		startupFlow.resetCursorAnimationSequence();
+		Graphics::FrameLimiter entryLimiter(g_system, 60);
+
+		while (!_engine.shouldQuit()) {
+			const bool hoverSave = saveConfirmRect().contains(_mousePos);
+			const bool hoverCancel = saveCancelRect().contains(_mousePos);
+			if (needsRedraw) {
+				renderSaveGameMenuScreen(_engine, background, savePalette, 1.0f,
+					selectedFont, unselectedFont, slotTitles, slotIndex, hoverSave, hoverCancel,
+					Common::String(), slotIndex, &editedTitle);
+				needsRedraw = false;
+			}
+
+			Common::Event event;
+			while (g_system->getEventManager()->pollEvent(event)) {
+				Common::Error result = Common::kNoError;
+				if (startupFlow.handleSystemEvent(event, result))
+					return result;
+
+				switch (event.type) {
+				case Common::EVENT_MOUSEMOVE:
+					needsRedraw = true;
+					break;
+				case Common::EVENT_LBUTTONDOWN:
+					if (saveConfirmRect().contains(_mousePos)) {
+						confirmed = true;
+						return Common::kNoError;
+					}
+					if (saveCancelRect().contains(_mousePos))
+						return Common::kNoError;
+					break;
+				case Common::EVENT_KEYDOWN:
+					if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+						return Common::kNoError;
+					if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+							event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
+						confirmed = true;
+						return Common::kNoError;
+					}
+					if (event.kbd.keycode == Common::KEYCODE_BACKSPACE) {
+						if (!editedTitle.empty()) {
+							editedTitle.deleteLastChar();
+							needsRedraw = true;
+						}
+						break;
+					}
+					if (appendInlineSaveCharacter(editedTitle, selectedFont, event.kbd.ascii))
+						needsRedraw = true;
+					break;
+				default:
+					break;
+				}
+			}
+
+			if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities())
+				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+
+			entryLimiter.delayBeforeSwap();
+			entryLimiter.startFrame();
+		}
+
+		return Common::kNoError;
+	};
+
+	int activeSlot = 0;
+	Common::String statusMessage;
+	bool needsRedraw = true;
+	startupFlow.resetCursorAnimationSequence();
+	Graphics::FrameLimiter limiter(g_system, 60);
+
+	auto activateSelectedSlot = [&](bool &saved) -> Common::Error {
+		saved = false;
+		Common::String editedTitle = slotTitles[activeSlot];
+		bool confirmed = false;
+		Common::Error entryError = runInlineSlotEditor(activeSlot, editedTitle, confirmed);
+		if (entryError.getCode() != Common::kNoError)
+			return entryError;
+		if (!confirmed)
+			return Common::kNoError;
+
+		Common::Error saveError = _engine.saveGameState(activeSlot, editedTitle);
+		if (saveError.getCode() == Common::kNoError) {
+			saved = true;
+			return Common::kNoError;
+		}
+
+		statusMessage = saveError.getDesc();
+		reloadSlotTitles();
+		needsRedraw = true;
+		return Common::kNoError;
+	};
+
+	while (!_engine.shouldQuit()) {
+		const bool hoverSave = saveConfirmRect().contains(_mousePos);
+		const bool hoverCancel = saveCancelRect().contains(_mousePos);
+		if (needsRedraw) {
+			renderSaveGameMenuScreen(_engine, background, savePalette, 1.0f,
+				selectedFont, unselectedFont, slotTitles, activeSlot, hoverSave, hoverCancel, statusMessage);
+			needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (startupFlow.handleSystemEvent(event, result))
+				return result;
+
+			switch (event.type) {
+			case Common::EVENT_MOUSEMOVE: {
+				const int hoveredSlot = resolveHoveredSlot();
+				if (hoveredSlot != -1 && hoveredSlot != activeSlot)
+					activeSlot = hoveredSlot;
+				needsRedraw = true;
+				break;
+			}
+			case Common::EVENT_LBUTTONDOWN: {
+				const int hoveredSlot = resolveHoveredSlot();
+				bool saved = false;
+				statusMessage.clear();
+				if (hoveredSlot != -1) {
+					activeSlot = hoveredSlot;
+					Common::Error saveError = activateSelectedSlot(saved);
+					if (saveError.getCode() != Common::kNoError)
+						return saveError;
+					if (saved)
+						return Common::kNoError;
+					break;
+				}
+				if (saveConfirmRect().contains(_mousePos)) {
+					Common::Error saveError = activateSelectedSlot(saved);
+					if (saveError.getCode() != Common::kNoError)
+						return saveError;
+					if (saved)
+						return Common::kNoError;
+				} else if (saveCancelRect().contains(_mousePos)) {
+					return Common::kNoError;
+				}
+				needsRedraw = true;
+				break;
+			}
+			case Common::EVENT_KEYDOWN:
+				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+					return Common::kNoError;
+				if (event.kbd.keycode == Common::KEYCODE_UP) {
+					activeSlot = (activeSlot + kSaveSlotCount - 1) % kSaveSlotCount;
+					statusMessage.clear();
+					needsRedraw = true;
+				} else if (event.kbd.keycode == Common::KEYCODE_DOWN) {
+					activeSlot = (activeSlot + 1) % kSaveSlotCount;
+					statusMessage.clear();
+					needsRedraw = true;
+				} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+						event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
+					bool saved = false;
+					statusMessage.clear();
+					Common::Error saveError = activateSelectedSlot(saved);
+					if (saveError.getCode() != Common::kNoError)
+						return saveError;
+					if (saved)
+						return Common::kNoError;
+					needsRedraw = true;
+				}
+				break;
+			default:
+				break;
+			}
+		}
+
+		if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities())
+			(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
 Common::Error StartupMenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, StartupFlow &startupFlow) {
 	const StartupArt *art = _engine.getStartupArt();
diff --git a/engines/harvester/startup_menu.h b/engines/harvester/startup_menu.h
index 47bb53fdbcd..6efd5be92f1 100644
--- a/engines/harvester/startup_menu.h
+++ b/engines/harvester/startup_menu.h
@@ -50,6 +50,7 @@ private:
 	Common::Error runOptionsMenu(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, StartupFlow &startupFlow);
 	Common::Error runHelpScreen(const byte *palette, float paletteBrightness, StartupFlow &startupFlow);
+	Common::Error runSaveGameMenu(const byte *palette, float paletteBrightness, StartupFlow &startupFlow);
 	Common::String runModalTextEntryDialog(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, const Graphics::Font &titleFont, const Graphics::Font &entryFont,
 		const Common::String &title, const Common::String &initialText, StartupFlow &startupFlow);
diff --git a/engines/harvester/startup_room.cpp b/engines/harvester/startup_room.cpp
index cb3eceb2f32..27ff2f8b742 100644
--- a/engines/harvester/startup_room.cpp
+++ b/engines/harvester/startup_room.cpp
@@ -57,8 +57,25 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 	Common::String currentRoomTarget = entranceName;
 	while (!currentRoomTarget.empty()) {
 		StartupRoomSetupState state;
-		if (!_engine.getStartupScript()->resolveRoomSetupState(currentRoomTarget, state, *_engine.getResources()))
+		if (_engine.hasPendingLoadedStartupSaveRoomState()) {
+			startupFlow.resetRoomNpcDialogueState();
+			const StartupSaveRoomState &loadedState = _engine.getPendingLoadedStartupSaveRoomState();
+			if (!_engine.getStartupScript()->materializeRoomState(
+					loadedState.entranceName, loadedState.roomName, state, *_engine.getResources())) {
+				return Common::kReadingFailed;
+			}
+			state.entranceName = loadedState.entranceName;
+			state.playerSpawnX = loadedState.playerX;
+			state.playerSpawnY = loadedState.playerY;
+			state.playerSpawnZ = loadedState.playerZ;
+			state.playerFacing = loadedState.playerFacing;
+			state.musicPath = loadedState.musicPath;
+			currentRoomTarget = !loadedState.entranceName.empty() ? loadedState.entranceName : loadedState.roomName;
+			_engine.clearPendingLoadedStartupSaveRoomState();
+		} else if (!_engine.getStartupScript()->resolveRoomSetupState(
+				currentRoomTarget, state, *_engine.getResources())) {
 			return Common::kReadingFailed;
+		}
 
 		Common::Error transitionError = startupFlow.beginRoomSetupTransition();
 		if (transitionError.getCode() != Common::kNoError)
@@ -125,6 +142,12 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			idleState.activityTick = getRuntimeClockTicks();
 			updatePlayerIdleTrigger(idleState);
 		};
+		auto captureCurrentSaveState = [&]() {
+			const int facing = playerState.facing >= 0 ? playerState.facing : scene.state.playerFacing;
+			_engine.captureCurrentStartupSaveRoomState(scene.state.entranceName, scene.state.roomName,
+				playerState.centerX, playerState.bottomY, (int)playerState.z, facing,
+				_engine.getStartupMusicPath());
+		};
 		resetIdleState();
 		auto stopPlayerRegionInteraction = [&]() {
 			moveLeft = false;
@@ -185,6 +208,7 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		pendingRegionName.clear();
 		resetIdleState();
 		startupFlow.resetCursorAnimationSequence();
+		captureCurrentSaveState();
 		return _inventory.refresh();
 	};
 	auto captureDialogueBackdrop = [&](IndexedBitmap &dialogueBackdrop) {
@@ -447,10 +471,11 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		}
 
 		return Common::kNoError;
-	};
+		};
 		if (!_inventory.refresh())
 			return Common::kReadingFailed;
 		Graphics::FrameLimiter limiter(g_system, 60);
+		captureCurrentSaveState();
 
 	if (shouldRunStartupRoomProbe())
 		logStartupRoomProbe(_engine, scene, currentRoomTarget, _mousePos);
@@ -458,8 +483,15 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 	while (!_engine.shouldQuit()) {
 		if (startupFlow.hasPendingMainMenuReturn())
 			return Common::kNoError;
+		if (_engine.hasPendingLoadedStartupSaveRoomState()) {
+			pendingRoomChange = _engine.getPendingLoadedStartupSaveRoomState().entranceName;
+			if (pendingRoomChange.empty())
+				pendingRoomChange = _engine.getPendingLoadedStartupSaveRoomState().roomName;
+			break;
+		}
 		if (!pendingRoomChange.empty())
 			break;
+		captureCurrentSaveState();
 
 		if (needsRedraw) {
 			Graphics::Screen *activeScreen = getActiveScreen();
@@ -827,6 +859,7 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 					playerState.hasMoveTarget = false;
 					playerState.turnActive = false;
 					playerState.turnTargetFacing = -1;
+					captureCurrentSaveState();
 					Graphics::Screen *activeScreen = getActiveScreen();
 					if (!activeScreen)
 						return Common::kNoError;
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 22cc06af0d6..781fc64ff10 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -29,6 +29,7 @@
 #include "common/file.h"
 #include "common/formats/ini-file.h"
 #include "common/memstream.h"
+#include "common/serializer.h"
 #include "harvester/detection.h"
 #include "harvester/resources.h"
 
@@ -45,6 +46,123 @@ static const float kDimmedPaletteBrightness = 0.6f;
 static const int kDefaultPlayerHitPoints = 30;
 static const int kMaxStartupOptionLevel = 9;
 
+static void syncBool(Common::Serializer &s, bool &value) {
+	byte serialized = value ? 1 : 0;
+	s.syncAsByte(serialized);
+	if (s.isLoading())
+		value = serialized != 0;
+}
+
+template <typename RecordType, typename SyncFn>
+static void syncRecordArray(Common::Serializer &s, Common::Array<RecordType> &records, SyncFn syncFn) {
+	uint32 count = (uint32)records.size();
+	s.syncAsUint32LE(count);
+	if (s.isLoading())
+		records.resize(count);
+	for (uint32 i = 0; i < count; ++i)
+		syncFn(s, records[i]);
+}
+
+static void syncStartupFlagRecord(Common::Serializer &s, StartupFlagRecord &record) {
+	s.syncString(record.name);
+	syncBool(s, record.value);
+}
+
+static void syncStartupObjectRecord(Common::Serializer &s, StartupObjectRecord &record) {
+	s.syncAsSint32LE(record.initialX);
+	s.syncAsSint32LE(record.initialY);
+	s.syncAsSint32LE(record.initialZ);
+	s.syncAsSint32LE(record.currentX);
+	s.syncAsSint32LE(record.currentY);
+	s.syncAsSint32LE(record.currentZ);
+	s.syncAsSint32LE(record.boundsX2);
+	s.syncAsSint32LE(record.boundsY2);
+	s.syncAsSint32LE(record.zExtent);
+	s.syncString(record.actionTag);
+	s.syncString(record.spritePath);
+	s.syncString(record.altSpritePath);
+	s.syncString(record.objectName);
+	s.syncString(record.field34);
+	s.syncString(record.identTextKey);
+	s.syncString(record.currentOwnerOrRoom);
+	s.syncString(record.field40);
+	s.syncString(record.inventoryTextKey);
+	s.syncString(record.initialOwnerOrRoom);
+	s.syncString(record.interactionLabel);
+	syncBool(s, record.operatable);
+	syncBool(s, record.visible);
+	syncBool(s, record.runtimeVisible);
+	syncBool(s, record.identShown);
+}
+
+static void syncStartupAnimRecord(Common::Serializer &s, StartupAnimRecord &record) {
+	s.syncAsSint32LE(record.x);
+	s.syncAsSint32LE(record.y);
+	s.syncAsSint32LE(record.z);
+	s.syncAsSint32LE(record.frameDelay);
+	s.syncString(record.roomName);
+	s.syncString(record.resourcePath);
+	s.syncString(record.animName);
+	syncBool(s, record.active);
+	syncBool(s, record.visible);
+	syncBool(s, record.looping);
+	syncBool(s, record.backward);
+	syncBool(s, record.pingPong);
+	syncBool(s, record.remove);
+	syncBool(s, record.runtimeActive);
+	syncBool(s, record.runtimeVisible);
+	s.syncAsSint32LE(record.runtimeState);
+}
+
+static void syncStartupNpcRecord(Common::Serializer &s, StartupNpcRecord &record) {
+	s.syncAsSint32LE(record.posX);
+	s.syncAsSint32LE(record.posY);
+	s.syncAsSint32LE(record.posZ);
+	s.syncAsSint32LE(record.frameDelay);
+	s.syncString(record.onDeathActionTag);
+	s.syncString(record.modelPath);
+	s.syncString(record.npcName);
+	s.syncString(record.monsterfyTargetName);
+	s.syncString(record.roomName);
+	syncBool(s, record.deathOrMonsterfyFlag);
+	syncBool(s, record.runtimeSpawned);
+	syncBool(s, record.active);
+	syncBool(s, record.visible);
+	syncBool(s, record.savedVisible);
+	s.syncAsSint32LE(record.deathDamageType);
+	s.syncString(record.audioPath);
+	s.syncString(record.entityInitArg);
+}
+
+static void syncStartupMonsterRecord(Common::Serializer &s, StartupMonsterRecord &record) {
+	s.syncAsSint32LE(record.posX);
+	s.syncAsSint32LE(record.posY);
+	s.syncAsSint32LE(record.posZ);
+	s.syncAsSint32LE(record.initialFacing);
+	s.syncString(record.roomName);
+	s.syncString(record.monsterName);
+	s.syncString(record.modelPath);
+	s.syncString(record.onDeathActionTag);
+	syncBool(s, record.active);
+	syncBool(s, record.visible);
+}
+
+static void syncStartupRegionRecord(Common::Serializer &s, StartupRegionRecord &record) {
+	s.syncAsSint32LE(record.left);
+	s.syncAsSint32LE(record.top);
+	s.syncAsSint32LE(record.right);
+	s.syncAsSint32LE(record.bottom);
+	s.syncAsSint32LE(record.minZ);
+	s.syncAsSint32LE(record.maxZ);
+	s.syncAsSint32LE(record.desiredFacing);
+	s.syncString(record.regionName);
+	s.syncString(record.direction);
+	s.syncString(record.roomName);
+	s.syncString(record.actionTag);
+	syncBool(s, record.startEnabled);
+	syncBool(s, record.cursorEnabled);
+}
+
 static int clampStartupOptionLevel(int level) {
 	if (level < 0)
 		return 0;
@@ -859,6 +977,18 @@ void StartupScript::resetRuntimeState() {
 	}
 }
 
+void StartupScript::syncRuntimeSaveState(Common::Serializer &s) {
+	syncRecordArray(s, _runtimeFlags, syncStartupFlagRecord);
+	syncRecordArray(s, _runtimeObjects, syncStartupObjectRecord);
+	syncRecordArray(s, _runtimeAnimations, syncStartupAnimRecord);
+	syncRecordArray(s, _runtimeRegions, syncStartupRegionRecord);
+	syncRecordArray(s, _runtimeNpcs, syncStartupNpcRecord);
+	syncRecordArray(s, _runtimeMonsters, syncStartupMonsterRecord);
+	s.syncAsSint32LE(_playerCurrentHitPoints);
+	if (s.isLoading())
+		_playerCurrentHitPoints = clampPlayerHitPoints(_playerCurrentHitPoints);
+}
+
 bool StartupScript::materializeRoomState(const Common::String &entranceName, const Common::String &roomName,
 		StartupRoomSetupState &state, ResourceManager &resources) const {
 	state = StartupRoomSetupState();
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index d14fe7bed4d..4e6b6cd01b1 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -25,6 +25,10 @@
 #include "common/array.h"
 #include "common/str.h"
 
+namespace Common {
+class Serializer;
+}
+
 namespace Harvester {
 
 class ResourceManager;
@@ -315,6 +319,7 @@ public:
 	void setParentalPassword(const Common::String &password) { _parentalPassword = password; }
 	bool saveConfig() const;
 	void resetRuntimeState();
+	void syncRuntimeSaveState(Common::Serializer &s);
 	bool resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
 		ResourceManager &resources);
 	bool materializeRoomState(const Common::String &entranceName, const Common::String &roomName,


Commit: c65ca087d2a574aec44ab40cf960bef994194e00
    https://github.com/scummvm/scummvm/commit/c65ca087d2a574aec44ab40cf960bef994194e00
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:23+02:00

Commit Message:
HARVESTER: Implement room quit game confirm

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/startup_menu.cpp
    engines/harvester/startup_menu.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 0f29ccc7be2..7ef8a482e58 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1221,6 +1221,28 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 **Notes**
 - The help viewer is palette-driven and does not reuse the room backdrop while active; the room/menu image only returns after the fullscreen help page is dismissed.
 
+## Room Menu Quit Confirmation
+
+**Confidence:** High
+
+**Evidence**
+- `run_main_menu` handles the `QUIT GAME` row inline by spawning `textbox4.bm` at `(167, 200)` over the current room/menu backdrop and reusing the live room palette.
+- The prompt text comes from the `quitgame` key in `MENU.INI`, and the native string parser treats `/` as an inline line break for the confirmation message.
+- The YES/NO actions use fixed hit regions: YES at `x=0xd2..0x104`, `y=0xee..0x108`; NO at `x=0x172..0x19a`, `y=0xee..0x108`.
+- The loop confirms quit on the `Y` key path and cancels on `N`, `ESC`, or secondary mouse.
+- A confirmed quit returns the menu sentinel that unwinds the room loop into shutdown rather than restoring gameplay.
+
+**Key Functions**
+- `run_main_menu`
+
+**Key Data**
+- `MENU.INI:quitgame`
+- `textbox4.bm`
+
+**Notes**
+- Unlike the fullscreen help viewer or save UI, the quit confirm is an inline modal layered over the current room/menu frame.
+- The native branch is fully self-contained inside `run_main_menu`; no separate helper crossed the rename-confidence threshold during this pass.
+
 ## Player Health And Inventory Status Portrait
 
 **Confidence:** High
diff --git a/TRACKER.md b/TRACKER.md
index dcebea3e3b7..382b91f78d7 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,16 +15,14 @@
 
 ## Last Confirmed Action
 
-- Recovered and implemented the native `SAVE GAME` room-menu path from `run_save_game_menu` and `sync_current_room_runtime_state_before_save_load`.
-  - Confirmed the save screen uses `SAVEGAME.BM` + `SAVEGAME.PAL`, exposes 25 fixed slots, and routes slot-click, `Enter`, and the lower-left action area through the same inline text-entry-and-save path.
-  - Confirmed the native pre-save sync step copies live room state back into persistent records before serialization, and that the room menu cancels via `ESC` or the lower-right action area.
-- Implemented the engine-side `SAVE GAME` screen on the ESC room menu:
-  - room-menu `SAVE GAME` now opens a native-shaped 25-slot save UI with inline text entry and immediate commit after confirmation;
-  - Harvester now has a real ScummVM save serializer for the current startup/runtime subset, including room resume context, runtime script records, and Steve's current HP;
-  - launcher/runtime loads now resume via `materializeRoomState()` so a loaded room comes back without replaying room-enter command chains;
-  - the Harvester metaengine now exposes the same `0..24` slot range the native menu uses and disables generic autosave slot reservation.
+- Recovered and implemented the native `QUIT GAME` room-menu confirmation branch from `run_main_menu`.
+  - Confirmed the native dialog is an inline modal over the room/menu backdrop, uses `textbox4.bm` at `(167, 200)`, and pulls its prompt text from `MENU.INI:quitgame` with `/` treated as an explicit line break.
+  - Confirmed the native YES/NO hit regions are fixed at `0xd2..0x104,0xee..0x108` and `0x172..0x19a,0xee..0x108`, with keyboard confirm/cancel on `Y` / `N` and cancel also on `ESC` or secondary mouse.
+- Implemented the engine-side `QUIT GAME` room-menu flow:
+  - room-menu `QUIT GAME` now opens the native-shaped confirmation dialog instead of logging an unimplemented stub;
+  - confirm stops startup audio and exits cleanly through the engine quit path, while cancel returns to the room menu without disturbing the active room backdrop or palette.
 
 ## Next Suggested Action
 
-1. Recover the native `QUIT GAME` confirmation branch from `run_main_menu`, especially the `textbox4.bm` prompt, YES/NO hit regions, keyboard shortcuts, and the post-confirm quit handoff.
-2. Mirror that confirmation flow in the ESC room menu so `QUIT GAME` stops being a stub without regressing palette restoration or room-loop cleanup on cancel.
+1. Run a fresh native-vs-engine visual/input comparison for all four recovered ESC room-menu branches (`OPTIONS`, `HELP`, `SAVE GAME`, `QUIT GAME`) and log any remaining palette, layout, or interaction mismatches.
+2. Continue the room/UI pass from the next still-native-only modal or overlay path that shares `run_main_menu` assets and timing behavior.
diff --git a/engines/harvester/startup_menu.cpp b/engines/harvester/startup_menu.cpp
index 652f5b8dd7f..6598049d54e 100644
--- a/engines/harvester/startup_menu.cpp
+++ b/engines/harvester/startup_menu.cpp
@@ -84,6 +84,11 @@ static const int kQuickTipsOverlayY = 200;
 static const int kQuickTipTextX = 180;
 static const int kQuickTipTextY = 228;
 static const int kQuickTipTextWidth = 280;
+static const int kConfirmDialogX = 167;
+static const int kConfirmDialogY = 200;
+static const int kConfirmPromptX = 182;
+static const int kConfirmPromptY = 214;
+static const int kConfirmPromptWidth = 280;
 static const int kSaveSlotCount = 25;
 static const int kSaveSlotLabelX = 8;
 static const int kSaveSlotNameX = 0x50;
@@ -98,6 +103,7 @@ struct RoomMenuTextConfig {
 	Common::String yesLabel = "YES";
 	Common::String noLabel = "NO";
 	Common::String clickLabel = "CLICK";
+	Common::String quitGamePrompt = "QUIT GAME";
 };
 
 static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y);
@@ -162,6 +168,14 @@ static Common::Rect saveCancelRect() {
 	return Common::Rect(0x141, 0x1cc, 0x27b + 1, 0x1dc + 1);
 }
 
+static Common::Rect quitConfirmYesRect() {
+	return Common::Rect(0xd2, 0xee, 0x104 + 1, 0x108 + 1);
+}
+
+static Common::Rect quitConfirmNoRect() {
+	return Common::Rect(0x172, 0xee, 0x19a + 1, 0x108 + 1);
+}
+
 static Common::Rect optionsSliderRect(int sliderIndex, int lineHeight) {
 	const int y = kOptionsIndicatorY + sliderIndex * lineHeight;
 	return Common::Rect(kOptionsSliderMinX, y, kOptionsSliderMaxX + 1, y + kOptionsSliderHeight + 1);
@@ -212,6 +226,8 @@ static bool loadMenuTextConfig(HarvesterEngine &engine, RoomMenuTextConfig &conf
 		config.noLabel = value;
 	if (menu.getKey("click", kMenuSectionName, value) && !value.empty())
 		config.clickLabel = value;
+	if (menu.getKey("quitgame", kMenuSectionName, value) && !value.empty())
+		config.quitGamePrompt = value;
 
 	return true;
 }
@@ -379,6 +395,20 @@ static void drawWrappedShadowedText(Graphics::Screen &screen, const Graphics::Fo
 		drawShadowedString(screen, font, lines[i], x, y + (int)i * (font.getFontHeight() + 2), width, color);
 }
 
+static void splitMenuConfigLines(const Common::String &text, Common::Array<Common::String> &lines) {
+	lines.clear();
+	Common::String currentLine;
+	for (uint i = 0; i < text.size(); ++i) {
+		if (text[i] == '/') {
+			lines.push_back(currentLine);
+			currentLine.clear();
+			continue;
+		}
+		currentLine += text[i];
+	}
+	lines.push_back(currentLine);
+}
+
 static void renderOptionsMenuScreen(HarvesterEngine &engine, const IndexedBitmap &backdrop,
 		const byte *palette, float paletteBrightness,
 		const Graphics::Font &selectedFont, const Graphics::Font &unselectedFont,
@@ -535,6 +565,38 @@ static void renderSaveGameMenuScreen(HarvesterEngine &engine, const IndexedBitma
 	screen->update();
 }
 
+static void renderQuitGameConfirmScreen(HarvesterEngine &engine, const IndexedBitmap &backdrop,
+		const byte *palette, float paletteBrightness, const Graphics::Font &font,
+		const IndexedBitmap &textbox, const RoomMenuTextConfig &config, bool hoverYes, bool hoverNo) {
+	Graphics::Screen *screen = engine.getScreen();
+	const StartupArt *art = engine.getStartupArt();
+	if (!screen || !art)
+		return;
+
+	applyMenuPalette(*screen, engine, palette, paletteBrightness);
+	blitBitmap(*screen, backdrop, 0, 0);
+	blitTransparentBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
+	blitTransparentBitmap(*screen, textbox, kConfirmDialogX, kConfirmDialogY);
+	Common::Array<Common::String> promptLines;
+	splitMenuConfigLines(config.quitGamePrompt, promptLines);
+	for (uint i = 0; i < promptLines.size(); ++i) {
+		drawShadowedString(*screen, font, promptLines[i], kConfirmPromptX,
+			kConfirmPromptY + (int)i * (font.getFontHeight() + 2), kConfirmPromptWidth,
+			kTextColorNormal, Graphics::kTextAlignCenter);
+	}
+	drawShadowedString(*screen, font, config.yesLabel,
+		quitConfirmYesRect().left, quitConfirmYesRect().top, quitConfirmYesRect().width(),
+		hoverYes ? kTextColorHover : kTextColorNormal, Graphics::kTextAlignCenter);
+	drawShadowedString(*screen, font, config.noLabel,
+		quitConfirmNoRect().left, quitConfirmNoRect().top, quitConfirmNoRect().width(),
+		hoverNo ? kTextColorHover : kTextColorNormal, Graphics::kTextAlignCenter);
+
+	if (engine.getRuntimeEntities())
+		engine.getRuntimeEntities()->drawCursor(*screen);
+	screen->makeAllDirty();
+	screen->update();
+}
+
 static int getNativeRoomMenuLineHeight(const Graphics::Font &selectedFont) {
 	return selectedFont.getFontHeight() + 2;
 }
@@ -740,6 +802,11 @@ Common::Error StartupMenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop,
 						if (saveError.getCode() != Common::kNoError)
 							return saveError;
 						needsRedraw = true;
+					} else if (selectedItem >= 0 && _menuItems[selectedItem].equalsIgnoreCase("QUIT GAME")) {
+						Common::Error quitError = runQuitGameConfirm(backdrop, palette, paletteBrightness, startupFlow);
+						if (quitError.getCode() != Common::kNoError)
+							return quitError;
+						needsRedraw = true;
 					} else {
 						debug(1, "Harvester: room menu item '%s' selected but not implemented",
 							selectedItem >= 0 ? _menuItems[selectedItem].c_str() : "");
@@ -769,6 +836,11 @@ Common::Error StartupMenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop,
 					if (saveError.getCode() != Common::kNoError)
 						return saveError;
 					needsRedraw = true;
+				} else if (_menuItems[selectedItem].equalsIgnoreCase("QUIT GAME")) {
+					Common::Error quitError = runQuitGameConfirm(backdrop, palette, paletteBrightness, startupFlow);
+					if (quitError.getCode() != Common::kNoError)
+						return quitError;
+					needsRedraw = true;
 				} else {
 					debug(1, "Harvester: room menu item '%s' clicked but not implemented",
 						_menuItems[selectedItem].c_str());
@@ -1020,6 +1092,85 @@ Common::Error StartupMenuSystem::runSaveGameMenu(const byte *palette, float pale
 	return Common::kNoError;
 }
 
+Common::Error StartupMenuSystem::runQuitGameConfirm(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, StartupFlow &startupFlow) {
+	const StartupArt *art = _engine.getStartupArt();
+	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
+	if (!art || !selectedFontResource)
+		return Common::kReadingFailed;
+
+	HarvesterCftFont selectedFont(*selectedFontResource);
+	if (!selectedFont.isValid())
+		return Common::kReadingFailed;
+
+	RoomMenuTextConfig config;
+	(void)loadMenuTextConfig(_engine, config);
+	const IndexedBitmap *textbox = art->getTextboxBitmap(3);
+	if (!textbox || !textbox->isValid())
+		return Common::kReadingFailed;
+
+	startupFlow.resetCursorAnimationSequence();
+	bool needsRedraw = true;
+	Graphics::FrameLimiter limiter(g_system, 60);
+
+	while (!_engine.shouldQuit()) {
+		const bool hoverYes = quitConfirmYesRect().contains(_mousePos);
+		const bool hoverNo = quitConfirmNoRect().contains(_mousePos);
+		if (needsRedraw) {
+			renderQuitGameConfirmScreen(_engine, backdrop, palette, paletteBrightness,
+				selectedFont, *textbox, config, hoverYes, hoverNo);
+			needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (startupFlow.handleSystemEvent(event, result))
+				return result;
+
+			switch (event.type) {
+			case Common::EVENT_MOUSEMOVE:
+				needsRedraw = true;
+				break;
+			case Common::EVENT_RBUTTONDOWN:
+				return Common::kNoError;
+			case Common::EVENT_LBUTTONDOWN:
+				if (quitConfirmYesRect().contains(_mousePos)) {
+					_engine.stopStartupMusic();
+					_engine.stopStartupSound();
+					_engine.quitGame();
+					return Common::kNoError;
+				}
+				if (quitConfirmNoRect().contains(_mousePos))
+					return Common::kNoError;
+				break;
+			case Common::EVENT_KEYDOWN:
+				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+					return Common::kNoError;
+				if (event.kbd.keycode == Common::KEYCODE_y || event.kbd.ascii == 'y' || event.kbd.ascii == 'Y') {
+					_engine.stopStartupMusic();
+					_engine.stopStartupSound();
+					_engine.quitGame();
+					return Common::kNoError;
+				}
+				if (event.kbd.keycode == Common::KEYCODE_n || event.kbd.ascii == 'n' || event.kbd.ascii == 'N')
+					return Common::kNoError;
+				break;
+			default:
+				break;
+			}
+		}
+
+		if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities())
+			(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
 Common::Error StartupMenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, StartupFlow &startupFlow) {
 	const StartupArt *art = _engine.getStartupArt();
diff --git a/engines/harvester/startup_menu.h b/engines/harvester/startup_menu.h
index 6efd5be92f1..dc6e319d3f8 100644
--- a/engines/harvester/startup_menu.h
+++ b/engines/harvester/startup_menu.h
@@ -51,6 +51,8 @@ private:
 		float paletteBrightness, StartupFlow &startupFlow);
 	Common::Error runHelpScreen(const byte *palette, float paletteBrightness, StartupFlow &startupFlow);
 	Common::Error runSaveGameMenu(const byte *palette, float paletteBrightness, StartupFlow &startupFlow);
+	Common::Error runQuitGameConfirm(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, StartupFlow &startupFlow);
 	Common::String runModalTextEntryDialog(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, const Graphics::Font &titleFont, const Graphics::Font &entryFont,
 		const Common::String &title, const Common::String &initialText, StartupFlow &startupFlow);


Commit: cb70177ce8992e0bb855ecd5e1dcc8eec4dc292f
    https://github.com/scummvm/scummvm/commit/cb70177ce8992e0bb855ecd5e1dcc8eec4dc292f
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:24+02:00

Commit Message:
HARVESTER: Match options menu draw order

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/startup_menu.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 7ef8a482e58..e9dce310d5d 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1199,6 +1199,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 
 **Notes**
 - The native submenu persists `GAMMA`, `FX_VOLUME`, and `MUSIC_VOLUME` on exit and updates the string-backed `TEXT`, `GORE`, `QUICK_TIPS`, and password state inline while the submenu is active.
+- The options text entities are spawned before the `VOLUME.BM` bars and `INDICATR.BM` sliders are added to the render list, so the first three labels sit underneath the bar art where their trailing edge overlaps that UI.
 
 ## Room Menu Help Screen
 
diff --git a/TRACKER.md b/TRACKER.md
index 382b91f78d7..d91112a10b8 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -15,14 +15,12 @@
 
 ## Last Confirmed Action
 
-- Recovered and implemented the native `QUIT GAME` room-menu confirmation branch from `run_main_menu`.
-  - Confirmed the native dialog is an inline modal over the room/menu backdrop, uses `textbox4.bm` at `(167, 200)`, and pulls its prompt text from `MENU.INI:quitgame` with `/` treated as an explicit line break.
-  - Confirmed the native YES/NO hit regions are fixed at `0xd2..0x104,0xee..0x108` and `0x172..0x19a,0xee..0x108`, with keyboard confirm/cancel on `Y` / `N` and cancel also on `ESC` or secondary mouse.
-- Implemented the engine-side `QUIT GAME` room-menu flow:
-  - room-menu `QUIT GAME` now opens the native-shaped confirmation dialog instead of logging an unimplemented stub;
-  - confirm stops startup audio and exits cleanly through the engine quit path, while cancel returns to the room menu without disturbing the active room backdrop or palette.
+- Rechecked the native `OPTIONS` room-menu draw order in `run_main_menu` to explain the slider-row text mismatch from the visual comparison.
+  - Confirmed all seven option text entities are spawned before the three `VOLUME.BM` bars and three `INDICATR.BM` sliders are added to the render list, so the top three labels are partially covered where they overlap the slider art.
+- Adjusted the engine-side `OPTIONS` renderer to match that native layering.
+  - `SOUND FX`, `MUSIC`, and `GAMMA` now draw underneath the volume bars and indicators instead of painting on top of them, which restores the native-looking spacing against the sliders.
 
 ## Next Suggested Action
 
-1. Run a fresh native-vs-engine visual/input comparison for all four recovered ESC room-menu branches (`OPTIONS`, `HELP`, `SAVE GAME`, `QUIT GAME`) and log any remaining palette, layout, or interaction mismatches.
-2. Continue the room/UI pass from the next still-native-only modal or overlay path that shares `run_main_menu` assets and timing behavior.
+1. Re-run the native-vs-engine visual comparison for the `OPTIONS` submenu with the same selected row in both captures to confirm the slider-row spacing now matches.
+2. Continue the remaining room/UI visual-parity pass, especially any menus where native render-list ordering still differs from the engine's direct blit order.
diff --git a/engines/harvester/startup_menu.cpp b/engines/harvester/startup_menu.cpp
index 6598049d54e..8457b6df1ec 100644
--- a/engines/harvester/startup_menu.cpp
+++ b/engines/harvester/startup_menu.cpp
@@ -424,10 +424,21 @@ static void renderOptionsMenuScreen(HarvesterEngine &engine, const IndexedBitmap
 	blitTransparentBitmap(*screen, art.getLogoBitmap(), kLogoX, kLogoY);
 
 	const int lineHeight = getNativeRoomMenuLineHeight(selectedFont);
-	for (int i = 0; i < 3; ++i) {
-		blitTransparentBitmap(*screen, volumeBar, kOptionsVolumeBitmapX, kOptionsVolumeBitmapY + i * lineHeight);
+	for (int i = 0; i < (int)config.optionItems.size(); ++i) {
+		const Common::String label = buildOptionsMenuItemLabel(*startupScript, config, i);
+		const Graphics::Font &font = (i == selectedItem) ? selectedFont : unselectedFont;
+		const int width = font.getStringWidth(label);
+		const int x = (screen->w - width) / 2;
+		const int y = kMenuStartY + i * lineHeight;
+		font.drawString(screen, label, x, y, width, 0);
 	}
 
+	// Native run_main_menu spawns the option text entities before adding the
+	// VOLUME/INDICATR bitmap entities, so the bars clip the trailing overlap on
+	// the first three rows instead of the text painting over them.
+	for (int i = 0; i < 3; ++i)
+		blitTransparentBitmap(*screen, volumeBar, kOptionsVolumeBitmapX, kOptionsVolumeBitmapY + i * lineHeight);
+
 	const int levels[3] = {
 		startupScript->getFxVolumeLevel(),
 		startupScript->getMusicVolumeLevel(),
@@ -438,15 +449,6 @@ static void renderOptionsMenuScreen(HarvesterEngine &engine, const IndexedBitmap
 			kOptionsIndicatorY + i * lineHeight);
 	}
 
-	for (int i = 0; i < (int)config.optionItems.size(); ++i) {
-		const Common::String label = buildOptionsMenuItemLabel(*startupScript, config, i);
-		const Graphics::Font &font = (i == selectedItem) ? selectedFont : unselectedFont;
-		const int width = font.getStringWidth(label);
-		const int x = (screen->w - width) / 2;
-		const int y = kMenuStartY + i * lineHeight;
-		font.drawString(screen, label, x, y, width, 0);
-	}
-
 	if (engine.getRuntimeEntities())
 		engine.getRuntimeEntities()->drawCursor(*screen);
 	screen->makeAllDirty();


Commit: 35c75977b37e91ffcd954b6cb50ff1c2222558e5
    https://github.com/scummvm/scummvm/commit/35c75977b37e91ffcd954b6cb50ff1c2222558e5
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:24+02:00

Commit Message:
HARVESTER: Match startup inventory carry handoff

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/startup_inventory.cpp
    engines/harvester/startup_inventory.h
    engines/harvester/startup_room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index e9dce310d5d..b050795f630 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1272,6 +1272,30 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - The portrait is recalculated inside the inventory loop, so native healing or damage that lands while the inventory is open updates the selected `INV_STAT*` image immediately.
 - The portrait is materialized through `spawn_object_entity_from_record` and the shared render-entity path, so the rotated photo depends on palette-index-0 transparency rather than an opaque rectangular bitmap blit.
 
+## Inventory Item Carry / Use Handoff
+
+**Confidence:** High
+
+**Evidence**
+- `run_inventory_screen` sets the active-item latch when a carryable inventory object is selected, clears the previous interaction-text overlay, and rebuilds the centered `USING_ON_ID` prompt from `Use %s on ...`.
+- While that latch is set, `run_inventory_screen` treats the selected inventory object as the live dragged entity: it probes overlap against other inventory entities for `USEITEM` / dialogue routing, and if the dragged item leaves the inventory panel rect (`x=0x49..0x234`, `y=0x73..0x19b`) it reassigns the object's owner/room to the current room and returns that entity to the caller.
+- `run_harvester_main_loop` consumes that returned entity as the active selected item. Each frame it repositions the entity under the mouse, rebuilds the same `USING_ON_ID` prompt from overlapping room targets, and routes left-click use through `handle_target_interaction`.
+- The cancel paths are explicit in the main loop: right-click, room/menu exits, game-over cleanup, and the `I` reopen-inventory path all clear the active-item latch and put the carried object back into `INVENTORY`.
+
+**Key Functions**
+- `run_inventory_screen`
+- `run_harvester_main_loop`
+- `handle_target_interaction`
+
+**Key Data**
+- `g_useitem_records`
+- `ObjectRecord.current_owner_or_room`
+- the active-item latch at `0xd60b8`
+
+**Notes**
+- The standalone inventory UI does not use room-region exits while an item is active; the active item must first return to the room loop.
+- Room-world use and pickup both funnel through the same carried-item state, so picking up a room object while carrying an inventory item clears the carry state after the pickup path resolves.
+
 ## Current Blockers
 
 - The remaining work is no longer list recovery. The blocker is semantic naming for fields whose shape is clear but whose gameplay meaning is still ambiguous.
diff --git a/TRACKER.md b/TRACKER.md
index d91112a10b8..51f66f817cb 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,9 +2,9 @@
 
 ## Current Focus
 
-- Recover the native ESC room-menu systems one item at a time from `run_main_menu`
-- Keep each menu item implementation grounded in confirmed Ghidra behavior before broadening engine UI helpers
-- Extend engine-side persistence only where the native menu paths actually require it
+- Verify the startup-room inventory item carry/use handoff directly from `run_inventory_screen` and `run_harvester_main_loop`
+- Keep the engine-side inventory interaction flow grounded in confirmed Ghidra behavior before adding any extra room UI polish
+- Re-check only the remaining inventory drag/use details that still need native capture confirmation
 
 ## Progress
 
@@ -15,12 +15,13 @@
 
 ## Last Confirmed Action
 
-- Rechecked the native `OPTIONS` room-menu draw order in `run_main_menu` to explain the slider-row text mismatch from the visual comparison.
-  - Confirmed all seven option text entities are spawned before the three `VOLUME.BM` bars and three `INDICATR.BM` sliders are added to the render list, so the top three labels are partially covered where they overlap the slider art.
-- Adjusted the engine-side `OPTIONS` renderer to match that native layering.
-  - `SOUND FX`, `MUSIC`, and `GAMMA` now draw underneath the volume bars and indicators instead of painting on top of them, which restores the native-looking spacing against the sliders.
+- Recovered the native inventory-item carry handoff from Ghidra.
+  - `run_inventory_screen` sets the active-item latch, rebuilds the `Use %s on ...` prompt, and returns the selected item to `run_harvester_main_loop` once it leaves the inventory panel bounds.
+  - `run_harvester_main_loop` then keeps that item active under the mouse until use or cancel, updates the `USING_ON_ID` prompt from overlapping room targets, and clears the carry state on right-click / menu / inventory reopen paths.
+- Adjusted the engine-side startup-room inventory flow to match the verified behavior more closely.
+  - Selected inventory items can now leave the overlay and remain active in room mode, room clicks route through the active-item state instead of requiring the overlay to stay open, and cancel paths clear the carry state without inventing new cursor semantics.
 
 ## Next Suggested Action
 
-1. Re-run the native-vs-engine visual comparison for the `OPTIONS` submenu with the same selected row in both captures to confirm the slider-row spacing now matches.
-2. Continue the remaining room/UI visual-parity pass, especially any menus where native render-list ordering still differs from the engine's direct blit order.
+1. Compare the native and engine startup-room inventory flow in-game, focusing on the item drag-out transition, prompt text, and cancel behavior after an item is brought into room mode.
+2. If the carried-item sprite anchor still looks off versus native captures, inspect the selected-entity positioning path in `run_harvester_main_loop` further before changing the draw offset.
diff --git a/engines/harvester/startup_inventory.cpp b/engines/harvester/startup_inventory.cpp
index f4f5b8123d8..2548a85dc2d 100644
--- a/engines/harvester/startup_inventory.cpp
+++ b/engines/harvester/startup_inventory.cpp
@@ -294,6 +294,23 @@ Common::Rect StartupInventorySystem::getPanelBounds() const {
 		kInventoryX + (int)bitmap.width, kInventoryY + (int)bitmap.height);
 }
 
+void StartupInventorySystem::drawSelectedDragItem(Graphics::Screen &screen, const Common::Point &point) const {
+	if (_selectedItemName.empty())
+		return;
+
+	for (const StartupInventoryVisual &item : _items) {
+		if (!item.object.objectName.equalsIgnoreCase(_selectedItemName))
+			continue;
+		if (!item.hasBitmap || !item.bitmap.isValid())
+			return;
+
+		const int drawX = point.x - (int)item.bitmap.width / 2;
+		const int drawY = point.y - (int)item.bitmap.height / 2;
+		blitBitmap(screen, item.bitmap, drawX, drawY);
+		return;
+	}
+}
+
 void StartupInventorySystem::drawOverlay(Graphics::Screen &screen, const Graphics::Font &font) const {
 	const StartupArt *art = _engine.getStartupArt();
 	StartupScript *startupScript = _engine.getStartupScript();
diff --git a/engines/harvester/startup_inventory.h b/engines/harvester/startup_inventory.h
index 4f0aa0dec2f..59a0ff47617 100644
--- a/engines/harvester/startup_inventory.h
+++ b/engines/harvester/startup_inventory.h
@@ -63,6 +63,7 @@ public:
 	const StartupInventoryVisual *findItemAtPoint(const Common::Point &point) const;
 	Common::Rect getPanelBounds() const;
 	void drawOverlay(Graphics::Screen &screen, const Graphics::Font &font) const;
+	void drawSelectedDragItem(Graphics::Screen &screen, const Common::Point &point) const;
 
 	static bool isExitObject(const StartupObjectRecord &object);
 	static bool isStatusObject(const StartupObjectRecord &object);
diff --git a/engines/harvester/startup_room.cpp b/engines/harvester/startup_room.cpp
index 27ff2f8b742..4b8147c7e29 100644
--- a/engines/harvester/startup_room.cpp
+++ b/engines/harvester/startup_room.cpp
@@ -45,6 +45,16 @@ static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
 		!roomName.equalsIgnoreCase("BOWLSNTRY1");
 }
 
+static Common::String resolveStartupNpcLabel(const StartupNpcRecord &npc) {
+	Common::String label = !npc.entityInitArg.empty() ? npc.entityInitArg : npc.npcName;
+	for (uint i = 0; i < label.size(); ++i) {
+		if (label[i] == '_')
+			label.setChar(' ', i);
+	}
+
+	return label;
+}
+
 StartupRoomSystem::StartupRoomSystem(HarvesterEngine &engine, Common::Point &mousePos,
 		StartupInventorySystem &inventory)
 	: _engine(engine), _mousePos(mousePos), _inventory(inventory) {
@@ -499,26 +509,39 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 				return Common::kNoError;
 
 			const Common::Rect inventoryPanelBounds = _inventory.getPanelBounds();
+			const bool inventorySelectionActive = _inventory.hasSelection();
 			const bool inventoryPanelContainsMouse = _inventory.isOpen() && inventoryPanelBounds.contains(_mousePos);
 			const bool suppressHover = showingInspectText || idleState.active || idleState.exiting ||
-				(_inventory.isOpen() && (inventoryPanelContainsMouse || !_inventory.hasSelection()));
+				(_inventory.isOpen() && (inventoryPanelContainsMouse || !inventorySelectionActive));
 			StartupRoomHoverState hoverState = suppressHover
 				? StartupRoomHoverState()
 				: resolveRoomHoverState(_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
 					scene.sceneRegions, _mousePos);
 			Common::String promptText;
-			if (_inventory.isOpen()) {
+			if (inventorySelectionActive) {
+				Common::String targetLabel;
 				const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
-				if (_inventory.hasSelection()) {
-					Common::String targetLabel;
+				if (_inventory.isOpen()) {
 					if (inventoryHover && !StartupInventorySystem::isExitObject(inventoryHover->object) &&
-							!StartupInventorySystem::isStatusObject(inventoryHover->object)) {
+							!StartupInventorySystem::isStatusObject(inventoryHover->object) &&
+							!inventoryHover->object.objectName.equalsIgnoreCase(_inventory.getSelectedItemName())) {
 						targetLabel = _engine.getStartupScript()->resolveObjectLabel(inventoryHover->object);
 					} else if (!inventoryPanelContainsMouse && hoverState.object) {
 						targetLabel = _engine.getStartupScript()->resolveObjectLabel(*hoverState.object);
+					} else if (!inventoryPanelContainsMouse && hoverState.npc) {
+						targetLabel = resolveStartupNpcLabel(*hoverState.npc);
 					}
-					promptText = _inventory.buildSelectedPrompt(targetLabel);
-				} else if (inventoryHover &&
+				} else if (hoverState.object) {
+					targetLabel = _engine.getStartupScript()->resolveObjectLabel(*hoverState.object);
+				} else if (hoverState.npc) {
+					targetLabel = resolveStartupNpcLabel(*hoverState.npc);
+				}
+				promptText = _inventory.buildSelectedPrompt(targetLabel);
+				_inventory.setPromptText(promptText);
+				hoverState.cursorSequence = 7;
+			} else if (_inventory.isOpen()) {
+				const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
+				if (inventoryHover &&
 						!StartupInventorySystem::isStatusObject(inventoryHover->object)) {
 					promptText = _engine.getStartupScript()->resolveObjectLabel(inventoryHover->object);
 				}
@@ -529,7 +552,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			}
 			if (RuntimeEntity *cursor = runtimeEntities ? runtimeEntities->getCursorEntity() : nullptr) {
 				cursor->setAnimationSequence(
-					(showingInspectText || idleState.active || idleState.exiting || _inventory.isOpen())
+					(showingInspectText || idleState.active || idleState.exiting ||
+						_inventory.isOpen() || inventorySelectionActive)
 						? 7
 						: hoverState.cursorSequence);
 			}
@@ -537,6 +561,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
 			if (_inventory.isOpen())
 				_inventory.drawOverlay(*activeScreen, *bodyFont);
+			else if (inventorySelectionActive)
+				_inventory.drawSelectedDragItem(*activeScreen, _mousePos);
 
 			if (showingInspectText) {
 				drawRoomInspectText(*activeScreen, *art, *bodyFont, inspectText);
@@ -577,7 +603,14 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 				}
 				pendingRegionName.clear();
 				if (_inventory.isOpen()) {
-					if (_inventory.clearSelection() || _inventory.close())
+					const bool clearedSelection = _inventory.clearSelection();
+					const bool closedInventory = _inventory.close();
+					if (clearedSelection || closedInventory)
+						needsRedraw = true;
+					break;
+				}
+				if (_inventory.hasSelection()) {
+					if (_inventory.clearSelection())
 						needsRedraw = true;
 					break;
 				}
@@ -614,7 +647,9 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 					const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
 					if (inventoryHover) {
 						if (StartupInventorySystem::isExitObject(inventoryHover->object)) {
-							if (_inventory.close())
+							const bool clearedSelection = _inventory.clearSelection();
+							const bool closedInventory = _inventory.close();
+							if (clearedSelection || closedInventory)
 								needsRedraw = true;
 							break;
 						}
@@ -639,38 +674,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 					}
 
 					if (_inventory.hasSelection()) {
-						const StartupRoomHoverState useHoverState = resolveRoomHoverState(
-							_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
-							scene.sceneRegions, _mousePos);
-						if (useHoverState.npc) {
-							bool didTransition = false;
-							Common::Error dialogueError = interactionProcessor.runScriptedDialogue(
-								useHoverState.npc->npcName, _inventory.getSelectedItemName(),
-								Common::String(), didTransition);
-							if (dialogueError.getCode() != Common::kNoError)
-								return dialogueError;
-							if (startupFlow.hasPendingMainMenuReturn())
-								return Common::kNoError;
-							if (_inventory.clearSelection() || _inventory.close())
-								needsRedraw = true;
-							needsRedraw = true;
-							break;
-						}
-						const StartupObjectRecord *roomTarget = useHoverState.object
-							? findSceneObjectByName(scene.sceneObjects, useHoverState.object->objectName)
-							: nullptr;
-						if (roomTarget) {
-							Common::Error interactionError =
-								handleInventoryTargetInteraction(*roomTarget, true);
-							if (interactionError.getCode() != Common::kNoError)
-								return interactionError;
+						if (!inventoryPanelBounds.contains(_mousePos) && _inventory.close())
 							needsRedraw = true;
-							break;
-						}
-					}
-
-					if (!inventoryPanelBounds.contains(_mousePos) && _inventory.hasSelection()) {
-						needsRedraw = true;
 						break;
 					}
 					if (!inventoryPanelBounds.contains(_mousePos) && _inventory.close())
@@ -687,6 +692,33 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 					hoverState.npc ? hoverState.npc->npcName.c_str() : "",
 					hoverState.region ? hoverState.region->regionName.c_str() : "",
 					hoverState.cursorSequence, hoverState.promptText.c_str());
+				if (_inventory.hasSelection()) {
+					if (hoverState.npc) {
+						bool didTransition = false;
+						Common::Error dialogueError = interactionProcessor.runScriptedDialogue(
+							hoverState.npc->npcName, _inventory.getSelectedItemName(),
+							Common::String(), didTransition);
+						if (dialogueError.getCode() != Common::kNoError)
+							return dialogueError;
+						if (startupFlow.hasPendingMainMenuReturn())
+							return Common::kNoError;
+						if (_inventory.clearSelection())
+							needsRedraw = true;
+						break;
+					}
+
+					StartupObjectRecord *roomTarget = hoverState.object
+						? findSceneObjectByName(scene.sceneObjects, hoverState.object->objectName)
+						: nullptr;
+					if (roomTarget) {
+						Common::Error interactionError =
+							handleInventoryTargetInteraction(*roomTarget, true);
+						if (interactionError.getCode() != Common::kNoError)
+							return interactionError;
+						needsRedraw = true;
+					}
+					break;
+				}
 				if (hoverState.playerEntity) {
 					if (!playerState.entity || hoverState.playerEntity != playerState.entity ||
 						idleState.active || idleState.exiting ||
@@ -826,7 +858,9 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 
 				if (_inventory.isOpen()) {
 					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-						if (_inventory.clearSelection() || _inventory.close())
+						const bool clearedSelection = _inventory.clearSelection();
+						const bool closedInventory = _inventory.close();
+						if (clearedSelection || closedInventory)
 							needsRedraw = true;
 					} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
 							event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
@@ -836,6 +870,21 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 					}
 					break;
 				}
+				if (_inventory.hasSelection()) {
+					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+						if (_inventory.clearSelection())
+							needsRedraw = true;
+						break;
+					}
+					if (event.kbd.keycode == Common::KEYCODE_i) {
+						if (_inventory.clearSelection())
+							needsRedraw = true;
+						if (!openInventoryOverlay())
+							return Common::kReadingFailed;
+						needsRedraw = true;
+						break;
+					}
+				}
 
 				if (event.kbd.keycode == Common::KEYCODE_LEFT)
 					moveLeft = true;


Commit: 270522517b6f8a75d95854c5a5c795806213b057
    https://github.com/scummvm/scummvm/commit/270522517b6f8a75d95854c5a5c795806213b057
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:24+02:00

Commit Message:
HARVESTER: Match native item carry flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/startup_inventory.cpp
    engines/harvester/startup_inventory.h
    engines/harvester/startup_room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index b050795f630..0cdea1f09bc 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1279,8 +1279,9 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 **Evidence**
 - `run_inventory_screen` sets the active-item latch when a carryable inventory object is selected, clears the previous interaction-text overlay, and rebuilds the centered `USING_ON_ID` prompt from `Use %s on ...`.
 - While that latch is set, `run_inventory_screen` treats the selected inventory object as the live dragged entity: it probes overlap against other inventory entities for `USEITEM` / dialogue routing, and if the dragged item leaves the inventory panel rect (`x=0x49..0x234`, `y=0x73..0x19b`) it reassigns the object's owner/room to the current room and returns that entity to the caller.
-- `run_harvester_main_loop` consumes that returned entity as the active selected item. Each frame it repositions the entity under the mouse, rebuilds the same `USING_ON_ID` prompt from overlapping room targets, and routes left-click use through `handle_target_interaction`.
-- The cancel paths are explicit in the main loop: right-click, room/menu exits, game-over cleanup, and the `I` reopen-inventory path all clear the active-item latch and put the carried object back into `INVENTORY`.
+- `run_harvester_main_loop` consumes that returned entity as the active selected item. Each frame it repositions the entity under the mouse, rebuilds the same centered `USING_ON_ID` overlay with `g_medfont1_cft`, and routes left-click use through `handle_target_interaction`.
+- `spawn_player_combat_avatar` seeds the live player entity with class `5` and the label string `"your inventory"`, and `handle_target_interaction` special-cases overlap with that target to move a non-inventory carried room item into `INVENTORY`.
+- The room loop also enters that same carried-item state directly from pickup objects: selecting a carryable room object starts cursor-follow carry first, while right-click, room/menu exits, game-over cleanup, and the `I` reopen-inventory path clear the latch and stow the carried object into `INVENTORY`.
 
 **Key Functions**
 - `run_inventory_screen`
@@ -1294,7 +1295,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 
 **Notes**
 - The standalone inventory UI does not use room-region exits while an item is active; the active item must first return to the room loop.
-- Room-world use and pickup both funnel through the same carried-item state, so picking up a room object while carrying an inventory item clears the carry state after the pickup path resolves.
+- Native bottom-of-screen carry prompts are not drawn with the fallback GUI font path; they are materialized as the `USING_ON_ID` text-entry overlay using `MEDFONT1.CFT` at y `0x1ce`.
 
 ## Current Blockers
 
diff --git a/TRACKER.md b/TRACKER.md
index 51f66f817cb..4f861f4c1d7 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,9 +2,9 @@
 
 ## Current Focus
 
-- Verify the startup-room inventory item carry/use handoff directly from `run_inventory_screen` and `run_harvester_main_loop`
-- Keep the engine-side inventory interaction flow grounded in confirmed Ghidra behavior before adding any extra room UI polish
-- Re-check only the remaining inventory drag/use details that still need native capture confirmation
+- Verify the corrected native startup-room pickup flow directly from `run_harvester_main_loop`, `run_inventory_screen`, and `handle_target_interaction`
+- Keep the engine-side carry / prompt behavior grounded in confirmed `MEDFONT1` and `"your inventory"` usage before refining any remaining UI details
+- Re-check only the remaining in-game drag/drop edge cases that still need native capture confirmation
 
 ## Progress
 
@@ -15,13 +15,13 @@
 
 ## Last Confirmed Action
 
-- Recovered the native inventory-item carry handoff from Ghidra.
-  - `run_inventory_screen` sets the active-item latch, rebuilds the `Use %s on ...` prompt, and returns the selected item to `run_harvester_main_loop` once it leaves the inventory panel bounds.
-  - `run_harvester_main_loop` then keeps that item active under the mouse until use or cancel, updates the `USING_ON_ID` prompt from overlapping room targets, and clears the carry state on right-click / menu / inventory reopen paths.
-- Adjusted the engine-side startup-room inventory flow to match the verified behavior more closely.
-  - Selected inventory items can now leave the overlay and remain active in room mode, room clicks route through the active-item state instead of requiring the overlay to stay open, and cancel paths clear the carry state without inventing new cursor semantics.
+- Revisited the native carry flow in Ghidra and corrected the pickup-side details.
+  - `run_harvester_main_loop` starts carry mode directly from pickup objects, keeps the carried entity under the mouse, builds the bottom `USING_ON_ID` overlay with `g_medfont1_cft`, and recognizes the player entity's `"your inventory"` label as the explicit room-item-to-inventory handoff target.
+  - `handle_target_interaction` special-cases class `5` / `"your inventory"` overlap to move non-inventory carried items into `INVENTORY`, while right-click, menu, and inventory reopen paths also stow the carried item.
+- Adjusted the engine-side startup-room flow to match that corrected behavior more closely.
+  - Room pickups now enter a carried-item state before inventory transfer, inventory-selected items render as dragged sprites both inside and outside the overlay, dragging an inventory item out still closes the panel while keeping it active, and the shared bottom prompt now uses the native `MEDFONT1` path instead of the GUI font.
 
 ## Next Suggested Action
 
-1. Compare the native and engine startup-room inventory flow in-game, focusing on the item drag-out transition, prompt text, and cancel behavior after an item is brought into room mode.
-2. If the carried-item sprite anchor still looks off versus native captures, inspect the selected-entity positioning path in `run_harvester_main_loop` further before changing the draw offset.
+1. Compare the native and engine startup-room flow in-game, focusing on room pickup to `"your inventory"` transfer, inventory drag-out prompt text, and cancel / reopen behavior while carrying.
+2. If the carried-item sprite still looks offset versus DOSBox captures, inspect the selected-entity screen-position update in `run_harvester_main_loop` before changing the engine draw anchor.
diff --git a/engines/harvester/startup_inventory.cpp b/engines/harvester/startup_inventory.cpp
index 2548a85dc2d..445060f9527 100644
--- a/engines/harvester/startup_inventory.cpp
+++ b/engines/harvester/startup_inventory.cpp
@@ -39,8 +39,6 @@ static const int kInventoryItemStartX = 73;
 static const int kInventoryItemStartY = 115;
 static const int kInventoryItemMaxRight = 564;
 static const int kInventoryItemSpacing = 5;
-static const byte kShadowColor = 0;
-static const byte kRoomPromptColor = 0xce;
 static const byte kTransparentPaletteIndex = 0;
 
 static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
@@ -78,12 +76,6 @@ static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, in
 		screen.format.bytesPerPixel, kTransparentPaletteIndex);
 }
 
-static void drawShadowedString(Graphics::Screen &screen, const Graphics::Font &font, const Common::String &text,
-		int x, int y, int width, byte color, Graphics::TextAlign align = Graphics::kTextAlignLeft) {
-	font.drawString(&screen, text, x + 1, y + 1, width, kShadowColor, align);
-	font.drawString(&screen, text, x, y, width, color, align);
-}
-
 static Common::String buildUseItemPrompt(const Common::String &itemLabel, const Common::String &targetLabel) {
 	if (itemLabel.empty())
 		return Common::String();
@@ -311,31 +303,19 @@ void StartupInventorySystem::drawSelectedDragItem(Graphics::Screen &screen, cons
 	}
 }
 
-void StartupInventorySystem::drawOverlay(Graphics::Screen &screen, const Graphics::Font &font) const {
+void StartupInventorySystem::drawOverlay(Graphics::Screen &screen) const {
 	const StartupArt *art = _engine.getStartupArt();
-	StartupScript *startupScript = _engine.getStartupScript();
-	if (!art || !startupScript)
+	if (!art)
 		return;
 
 	blitBitmap(screen, art->getInventoryBitmap(), kInventoryX, kInventoryY);
 
 	for (const StartupInventoryVisual &item : _items) {
+		if (!_selectedItemName.empty() && item.object.objectName.equalsIgnoreCase(_selectedItemName))
+			continue;
 		if (item.hasBitmap && item.bitmap.isValid())
 			blitBitmap(screen, item.bitmap, item.object.currentX, item.object.currentY);
 	}
-
-	Common::String overlayPrompt = _promptText;
-	if (overlayPrompt.empty() && !_selectedItemName.empty()) {
-		for (const StartupInventoryVisual &item : _items) {
-			if (item.object.objectName.equalsIgnoreCase(_selectedItemName)) {
-				overlayPrompt = buildUseItemPrompt(startupScript->resolveObjectLabel(item.object), Common::String());
-				break;
-			}
-		}
-	}
-
-	if (!overlayPrompt.empty())
-		drawShadowedString(screen, font, overlayPrompt, 0, 462, 640, kRoomPromptColor, Graphics::kTextAlignCenter);
 }
 
 bool StartupInventorySystem::isExitObject(const StartupObjectRecord &object) {
diff --git a/engines/harvester/startup_inventory.h b/engines/harvester/startup_inventory.h
index 59a0ff47617..e41aa808081 100644
--- a/engines/harvester/startup_inventory.h
+++ b/engines/harvester/startup_inventory.h
@@ -29,7 +29,6 @@
 #include "harvester/startup_room_support.h"
 
 namespace Graphics {
-class Font;
 class Screen;
 }
 
@@ -62,7 +61,7 @@ public:
 	const Common::String &getPromptText() const;
 	const StartupInventoryVisual *findItemAtPoint(const Common::Point &point) const;
 	Common::Rect getPanelBounds() const;
-	void drawOverlay(Graphics::Screen &screen, const Graphics::Font &font) const;
+	void drawOverlay(Graphics::Screen &screen) const;
 	void drawSelectedDragItem(Graphics::Screen &screen, const Common::Point &point) const;
 
 	static bool isExitObject(const StartupObjectRecord &object);
diff --git a/engines/harvester/startup_room.cpp b/engines/harvester/startup_room.cpp
index 4b8147c7e29..c9065ba950b 100644
--- a/engines/harvester/startup_room.cpp
+++ b/engines/harvester/startup_room.cpp
@@ -21,14 +21,19 @@
 
 #include "harvester/startup_room.h"
 
+#include "common/endian.h"
 #include "common/events.h"
+#include "common/ptr.h"
 #include "common/system.h"
+#include "graphics/blit.h"
 #include "graphics/font.h"
 #include "graphics/fontman.h"
 #include "graphics/framelimiter.h"
+#include "harvester/cft_font.h"
 #include "harvester/detection.h"
 #include "harvester/fst_player.h"
 #include "harvester/harvester.h"
+#include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
 #include "harvester/startup_art.h"
 #include "harvester/startup_flow.h"
@@ -38,6 +43,8 @@
 namespace Harvester {
 
 static const uint32 kRoomExitFastClickWindowTicks = 20;
+static const char *const kPlayerInventoryLabel = "your inventory";
+static const byte kTransparentPaletteIndex = 0;
 
 static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
 	return !roomName.equalsIgnoreCase("LAVAPIT") &&
@@ -45,6 +52,63 @@ static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
 		!roomName.equalsIgnoreCase("BOWLSNTRY1");
 }
 
+static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
+	if (!bitmap.isValid())
+		return;
+
+	int destX = x;
+	int destY = y;
+	int srcX = 0;
+	int srcY = 0;
+	int width = (int)bitmap.width;
+	int height = (int)bitmap.height;
+
+	if (destX < 0) {
+		srcX = -destX;
+		width += destX;
+		destX = 0;
+	}
+	if (destY < 0) {
+		srcY = -destY;
+		height += destY;
+		destY = 0;
+	}
+	if (destX >= screen.w || destY >= screen.h || width <= 0 || height <= 0)
+		return;
+
+	width = MIN<int>(width, screen.w - destX);
+	height = MIN<int>(height, screen.h - destY);
+	if (width <= 0 || height <= 0)
+		return;
+
+	const byte *src = bitmap.pixels.data() + srcY * bitmap.width + srcX;
+	byte *dst = (byte *)screen.getBasePtr(destX, destY);
+	Graphics::keyBlit(dst, src, screen.pitch, bitmap.width, width, height,
+		screen.format.bytesPerPixel, kTransparentPaletteIndex);
+}
+
+static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
+	const StartupText *startupText = engine.getStartupText();
+	if (!startupText || !fontName)
+		return nullptr;
+
+	for (const CftFontResource &font : startupText->getFonts()) {
+		if (font.name.equalsIgnoreCase(fontName))
+			return &font;
+	}
+
+	return nullptr;
+}
+
+static Common::String buildUseItemPrompt(const Common::String &itemLabel, const Common::String &targetLabel) {
+	if (itemLabel.empty())
+		return Common::String();
+	if (targetLabel.empty())
+		return Common::String::format("Use %s on ...", itemLabel.c_str());
+
+	return Common::String::format("Use %s on %s", itemLabel.c_str(), targetLabel.c_str());
+}
+
 static Common::String resolveStartupNpcLabel(const StartupNpcRecord &npc) {
 	Common::String label = !npc.entityInitArg.empty() ? npc.entityInitArg : npc.npcName;
 	for (uint i = 0; i < label.size(); ++i) {
@@ -55,6 +119,52 @@ static Common::String resolveStartupNpcLabel(const StartupNpcRecord &npc) {
 	return label;
 }
 
+static Common::String resolveCarriedObjectSpritePath(const StartupObjectRecord &object) {
+	if (!object.altSpritePath.empty())
+		return object.altSpritePath;
+	return object.spritePath;
+}
+
+static bool loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 12)
+		return false;
+
+	bitmap = IndexedBitmap();
+	bitmap.width = READ_LE_UINT32(data.data());
+	bitmap.height = READ_LE_UINT32(data.data() + 4);
+	const uint32 pixelCount = bitmap.width * bitmap.height;
+	if (bitmap.width == 0 || bitmap.height == 0 || data.size() < 12 + pixelCount)
+		return false;
+
+	bitmap.pixels.resize(pixelCount);
+	memcpy(bitmap.pixels.data(), data.data() + 12, pixelCount);
+	return true;
+}
+
+static bool shouldDispatchPickupActionOnCarryStart(const StartupObjectRecord &object) {
+	if (object.actionTag.empty())
+		return false;
+
+	return !object.objectName.equalsIgnoreCase("SANDWICH") &&
+		!object.objectName.equalsIgnoreCase("SANDWICH2") &&
+		!object.objectName.equalsIgnoreCase("SYRINGE");
+}
+
+static void drawRoomPrompt(Graphics::Screen &screen, const Graphics::Font &font,
+		const Common::String &promptText, bool useNativeFont) {
+	if (promptText.empty())
+		return;
+
+	if (useNativeFont) {
+		font.drawString(&screen, promptText, 0, 462, 640, 0, Graphics::kTextAlignCenter);
+		return;
+	}
+
+	font.drawString(&screen, promptText, 1, 463, 640, 0, Graphics::kTextAlignCenter);
+	font.drawString(&screen, promptText, 0, 462, 640, 0xce, Graphics::kTextAlignCenter);
+}
+
 StartupRoomSystem::StartupRoomSystem(HarvesterEngine &engine, Common::Point &mousePos,
 		StartupInventorySystem &inventory)
 	: _engine(engine), _mousePos(mousePos), _inventory(inventory) {
@@ -101,7 +211,18 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		Graphics::Screen *screen = getActiveScreen();
 		const StartupArt *art = _engine.getStartupArt();
 		const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-		if (!screen || !art || !bodyFont)
+		const CftFontResource *promptFontResource = findStartupFontByName(_engine, "MEDFONT1");
+		Common::ScopedPtr<HarvesterCftFont> promptCftFont;
+		const Graphics::Font *promptFont = bodyFont;
+		bool useNativePromptFont = false;
+		if (promptFontResource) {
+			promptCftFont.reset(new HarvesterCftFont(*promptFontResource));
+			if (promptCftFont->isValid()) {
+				promptFont = promptCftFont.get();
+				useNativePromptFont = true;
+			}
+		}
+		if (!screen || !art || !bodyFont || !promptFont)
 			return Common::kNoError;
 
 		if (!startupFlow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
@@ -142,6 +263,17 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		uint32 lastLeftButtonReleaseTick = 0;
 		StartupRoomIdleAnimationState idleState;
 		bool needsRedraw = true;
+		Common::String carriedRoomItemName;
+		Common::String carriedRoomItemLabel;
+		IndexedBitmap carriedRoomItemBitmap;
+		auto hasCarriedRoomItem = [&]() {
+			return !carriedRoomItemName.empty();
+		};
+		auto clearCarriedRoomItem = [&]() {
+			carriedRoomItemName.clear();
+			carriedRoomItemLabel.clear();
+			carriedRoomItemBitmap = IndexedBitmap();
+		};
 		auto resetIdleState = [&]() {
 			idleState = StartupRoomIdleAnimationState();
 			idleState.activityTick = getRuntimeClockTicks();
@@ -171,18 +303,6 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			if (playerState.entity && playerState.facing >= 0)
 				(void)setPlayerIdleAnimation(playerState, playerState.facing);
 		};
-
-	auto openInventoryOverlay = [&]() {
-		moveLeft = false;
-		moveRight = false;
-		moveUp = false;
-		moveDown = false;
-		pendingRegionName.clear();
-		playerState.hasMoveTarget = false;
-		playerState.turnActive = false;
-		playerState.turnTargetFacing = -1;
-		return _inventory.open();
-	};
 	auto refreshCurrentScene = [&](bool preservePlayerPlacement) {
 		const Common::Array<StartupAudioCommand> entryAudioCommands = scene.state.audioCommands;
 		StartupRoomSetupState refreshedState;
@@ -221,6 +341,27 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		captureCurrentSaveState();
 		return _inventory.refresh();
 	};
+	auto stowCarriedRoomItemToInventory = [&]() {
+		if (!hasCarriedRoomItem())
+			return true;
+
+		_engine.getStartupScript()->addRuntimeObjectToInventory(carriedRoomItemName);
+		clearCarriedRoomItem();
+		return _inventory.refresh();
+	};
+	auto openInventoryOverlay = [&]() {
+		moveLeft = false;
+		moveRight = false;
+		moveUp = false;
+		moveDown = false;
+		pendingRegionName.clear();
+		playerState.hasMoveTarget = false;
+		playerState.turnActive = false;
+		playerState.turnTargetFacing = -1;
+		if (!stowCarriedRoomItemToInventory())
+			return false;
+		return _inventory.open();
+	};
 	auto captureDialogueBackdrop = [&](IndexedBitmap &dialogueBackdrop) {
 		Graphics::Screen *activeScreen = getActiveScreen();
 		if (!activeScreen)
@@ -388,25 +529,61 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		refreshCurrentScene,
 		captureDialogueBackdrop, runRoomExitCommands, resetIdleState
 	};
-	auto handleInventoryTargetInteraction = [&](const StartupObjectRecord &target, bool preferPickup) -> Common::Error {
+	auto beginRoomItemCarry = [&](const StartupObjectRecord &object) -> Common::Error {
+		StartupScript *startupScript = _engine.getStartupScript();
+		ResourceManager *resources = _engine.getResources();
+		if (!startupScript || !resources)
+			return Common::kReadingFailed;
+
+		clearCarriedRoomItem();
+		carriedRoomItemName = object.objectName;
+		carriedRoomItemLabel = startupScript->resolveObjectLabel(object);
+		const Common::String spritePath = resolveCarriedObjectSpritePath(object);
+		if (!spritePath.empty())
+			loadBitmapResource(*resources, spritePath, carriedRoomItemBitmap);
+
+		startupScript->setRuntimeObjectVisible(object.currentOwnerOrRoom, object.objectName, false);
+
+		bool didTransition = false;
+		if (shouldDispatchPickupActionOnCarryStart(object)) {
+			StartupInteractionResult pickupInteraction;
+			if (startupScript->executeActionTag(object.actionTag, pickupInteraction)) {
+				Common::Error interactionError =
+					interactionProcessor.handleInteractionResult(pickupInteraction, didTransition);
+				if (interactionError.getCode() != Common::kNoError)
+					return interactionError;
+				if (startupFlow.hasPendingMainMenuReturn())
+					return Common::kNoError;
+			}
+		}
+
+		if (!pendingRoomChange.empty()) {
+			if (!stowCarriedRoomItemToInventory())
+				return Common::kReadingFailed;
+			needsRedraw = true;
+			return Common::kNoError;
+		}
+		if (!didTransition && !refreshCurrentScene(true))
+			return Common::kReadingFailed;
+
+		needsRedraw = true;
+		return Common::kNoError;
+	};
+	auto handleInventoryTargetInteraction = [&](const StartupObjectRecord &target) -> Common::Error {
 		if (!_inventory.hasSelection())
 			return Common::kNoError;
 
 		StartupInteractionResult interaction;
-		bool handled = false;
-		if (preferPickup && _engine.getStartupScript()->isPickupObject(target))
-			handled = _engine.getStartupScript()->resolveObjectInteraction(target, interaction);
-		else
-			handled = _engine.getStartupScript()->resolveUseItemInteraction(
-				_inventory.getSelectedItemName(), target, interaction);
+		const bool handled = _engine.getStartupScript()->resolveUseItemInteraction(
+			_inventory.getSelectedItemName(), target, interaction);
 		if (!handled)
 			return Common::kNoError;
 
-		_inventory.clearSelection();
 		bool didTransition = false;
 		Common::Error interactionError = interactionProcessor.handleInteractionResult(interaction, didTransition);
 		if (interactionError.getCode() != Common::kNoError)
 			return interactionError;
+		_inventory.clearSelection();
 		if (startupFlow.hasPendingMainMenuReturn())
 			return Common::kNoError;
 		if (!_inventory.refresh())
@@ -497,10 +674,15 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			pendingRoomChange = _engine.getPendingLoadedStartupSaveRoomState().entranceName;
 			if (pendingRoomChange.empty())
 				pendingRoomChange = _engine.getPendingLoadedStartupSaveRoomState().roomName;
+			if (!stowCarriedRoomItemToInventory())
+				return Common::kReadingFailed;
 			break;
 		}
-		if (!pendingRoomChange.empty())
+		if (!pendingRoomChange.empty()) {
+			if (!stowCarriedRoomItemToInventory())
+				return Common::kReadingFailed;
 			break;
+		}
 		captureCurrentSaveState();
 
 		if (needsRedraw) {
@@ -510,6 +692,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 
 			const Common::Rect inventoryPanelBounds = _inventory.getPanelBounds();
 			const bool inventorySelectionActive = _inventory.hasSelection();
+			const bool roomCarryActive = hasCarriedRoomItem();
+			const bool activeCarry = inventorySelectionActive || roomCarryActive;
 			const bool inventoryPanelContainsMouse = _inventory.isOpen() && inventoryPanelBounds.contains(_mousePos);
 			const bool suppressHover = showingInspectText || idleState.active || idleState.exiting ||
 				(_inventory.isOpen() && (inventoryPanelContainsMouse || !inventorySelectionActive));
@@ -518,7 +702,16 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 				: resolveRoomHoverState(_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
 					scene.sceneRegions, _mousePos);
 			Common::String promptText;
-			if (inventorySelectionActive) {
+			auto resolveCarryTargetLabel = [&]() {
+				if (hoverState.playerEntity && playerState.entity && hoverState.playerEntity == playerState.entity)
+					return Common::String(kPlayerInventoryLabel);
+				if (hoverState.object)
+					return _engine.getStartupScript()->resolveObjectLabel(*hoverState.object);
+				if (hoverState.npc)
+					return resolveStartupNpcLabel(*hoverState.npc);
+				return Common::String();
+			};
+			if (activeCarry) {
 				Common::String targetLabel;
 				const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
 				if (_inventory.isOpen()) {
@@ -526,18 +719,18 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 							!StartupInventorySystem::isStatusObject(inventoryHover->object) &&
 							!inventoryHover->object.objectName.equalsIgnoreCase(_inventory.getSelectedItemName())) {
 						targetLabel = _engine.getStartupScript()->resolveObjectLabel(inventoryHover->object);
-					} else if (!inventoryPanelContainsMouse && hoverState.object) {
-						targetLabel = _engine.getStartupScript()->resolveObjectLabel(*hoverState.object);
-					} else if (!inventoryPanelContainsMouse && hoverState.npc) {
-						targetLabel = resolveStartupNpcLabel(*hoverState.npc);
+					} else if (!inventoryPanelContainsMouse) {
+						targetLabel = resolveCarryTargetLabel();
 					}
-				} else if (hoverState.object) {
-					targetLabel = _engine.getStartupScript()->resolveObjectLabel(*hoverState.object);
-				} else if (hoverState.npc) {
-					targetLabel = resolveStartupNpcLabel(*hoverState.npc);
+				} else {
+					targetLabel = resolveCarryTargetLabel();
+				}
+				if (inventorySelectionActive) {
+					promptText = _inventory.buildSelectedPrompt(targetLabel);
+					_inventory.setPromptText(promptText);
+				} else {
+					promptText = buildUseItemPrompt(carriedRoomItemLabel, targetLabel);
 				}
-				promptText = _inventory.buildSelectedPrompt(targetLabel);
-				_inventory.setPromptText(promptText);
 				hoverState.cursorSequence = 7;
 			} else if (_inventory.isOpen()) {
 				const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
@@ -553,28 +746,26 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			if (RuntimeEntity *cursor = runtimeEntities ? runtimeEntities->getCursorEntity() : nullptr) {
 				cursor->setAnimationSequence(
 					(showingInspectText || idleState.active || idleState.exiting ||
-						_inventory.isOpen() || inventorySelectionActive)
+						_inventory.isOpen() || activeCarry)
 						? 7
 						: hoverState.cursorSequence);
 			}
 
 			drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
 			if (_inventory.isOpen())
-				_inventory.drawOverlay(*activeScreen, *bodyFont);
-			else if (inventorySelectionActive)
+				_inventory.drawOverlay(*activeScreen);
+			if (inventorySelectionActive) {
 				_inventory.drawSelectedDragItem(*activeScreen, _mousePos);
+			} else if (roomCarryActive && carriedRoomItemBitmap.isValid()) {
+				const int drawX = _mousePos.x - (int)carriedRoomItemBitmap.width / 2;
+				const int drawY = _mousePos.y - (int)carriedRoomItemBitmap.height / 2;
+				blitBitmap(*activeScreen, carriedRoomItemBitmap, drawX, drawY);
+			}
 
 			if (showingInspectText) {
 				drawRoomInspectText(*activeScreen, *art, *bodyFont, inspectText);
 			} else if (!promptText.empty()) {
-				// Shared room prompt rendering remains in the orchestration layer.
-				{
-					const byte shadowColor = 0;
-					bodyFont->drawString(activeScreen, promptText, 1, 463, 640, shadowColor,
-						Graphics::kTextAlignCenter);
-					bodyFont->drawString(activeScreen, promptText, 0, 462, 640, 0xce,
-						Graphics::kTextAlignCenter);
-				}
+				drawRoomPrompt(*activeScreen, *promptFont, promptText, useNativePromptFont);
 			}
 
 			if (runtimeEntities)
@@ -614,6 +805,11 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 						needsRedraw = true;
 					break;
 				}
+				if (hasCarriedRoomItem()) {
+					if (!stowCarriedRoomItemToInventory())
+						return Common::kReadingFailed;
+					needsRedraw = true;
+				}
 				break;
 			case Common::EVENT_LBUTTONUP:
 				lastLeftButtonReleaseTick = getRuntimeClockTicks();
@@ -665,7 +861,7 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 
 						if (!inventoryHover->object.objectName.equalsIgnoreCase(_inventory.getSelectedItemName())) {
 							Common::Error interactionError =
-								handleInventoryTargetInteraction(inventoryHover->object, false);
+								handleInventoryTargetInteraction(inventoryHover->object);
 							if (interactionError.getCode() != Common::kNoError)
 								return interactionError;
 						}
@@ -692,6 +888,15 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 					hoverState.npc ? hoverState.npc->npcName.c_str() : "",
 					hoverState.region ? hoverState.region->regionName.c_str() : "",
 					hoverState.cursorSequence, hoverState.promptText.c_str());
+				if (hasCarriedRoomItem()) {
+					if (hoverState.playerEntity && playerState.entity &&
+							hoverState.playerEntity == playerState.entity) {
+						if (!stowCarriedRoomItemToInventory())
+							return Common::kReadingFailed;
+						needsRedraw = true;
+					}
+					break;
+				}
 				if (_inventory.hasSelection()) {
 					if (hoverState.npc) {
 						bool didTransition = false;
@@ -712,7 +917,7 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 						: nullptr;
 					if (roomTarget) {
 						Common::Error interactionError =
-							handleInventoryTargetInteraction(*roomTarget, true);
+							handleInventoryTargetInteraction(*roomTarget);
 						if (interactionError.getCode() != Common::kNoError)
 							return interactionError;
 						needsRedraw = true;
@@ -813,6 +1018,12 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 					needsRedraw = true;
 					break;
 				}
+				if (_engine.getStartupScript()->isPickupObject(*clickedObject)) {
+					Common::Error carryError = beginRoomItemCarry(*clickedObject);
+					if (carryError.getCode() != Common::kNoError)
+						return carryError;
+					break;
+				}
 
 				StartupInteractionResult interaction;
 				if (!_engine.getStartupScript()->resolveObjectInteraction(*clickedObject, interaction)) {
@@ -900,6 +1111,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 					needsRedraw = true;
 					break;
 				} else if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					if (!stowCarriedRoomItemToInventory())
+						return Common::kReadingFailed;
 					moveLeft = false;
 					moveRight = false;
 					moveUp = false;
@@ -928,6 +1141,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 
 				if (event.kbd.keycode == Common::KEYCODE_RETURN ||
 					event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
+					if (!stowCarriedRoomItemToInventory())
+						return Common::kReadingFailed;
 					if (!runRoomExitCommands())
 						return Common::kReadingFailed;
 					return Common::kNoError;
@@ -947,8 +1162,11 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 				break;
 			}
 		}
-		if (!pendingRoomChange.empty())
+		if (!pendingRoomChange.empty()) {
+			if (!stowCarriedRoomItemToInventory())
+				return Common::kReadingFailed;
 			break;
+		}
 
 		bool playerAdvancedThisFrame = false;
 		if (updatePlayerTurnAnimationState(playerState)) {
@@ -988,15 +1206,21 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			return pendingRegionError;
 		if (startupFlow.hasPendingMainMenuReturn())
 			return Common::kNoError;
-		if (!pendingRoomChange.empty())
+		if (!pendingRoomChange.empty()) {
+			if (!stowCarriedRoomItemToInventory())
+				return Common::kReadingFailed;
 			break;
+		}
 		pendingRegionError = playerAdvancedThisFrame ? tryActivateOverlappedRegion() : Common::kNoError;
 		if (pendingRegionError.getCode() != Common::kNoError)
 			return pendingRegionError;
 		if (startupFlow.hasPendingMainMenuReturn())
 			return Common::kNoError;
-		if (!pendingRoomChange.empty())
+		if (!pendingRoomChange.empty()) {
+			if (!stowCarriedRoomItemToInventory())
+				return Common::kReadingFailed;
 			break;
+		}
 
 		if (startupFlow.tickRuntimeEntities())
 			needsRedraw = true;


Commit: b0d0ccad76daf1ee0f575ab37cac5bdebf988e02
    https://github.com/scummvm/scummvm/commit/b0d0ccad76daf1ee0f575ab37cac5bdebf988e02
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:25+02:00

Commit Message:
HARVESTER: Match native closeup pickup branching

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/startup_room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 0cdea1f09bc..e8fa87d1fa2 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1281,7 +1281,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - While that latch is set, `run_inventory_screen` treats the selected inventory object as the live dragged entity: it probes overlap against other inventory entities for `USEITEM` / dialogue routing, and if the dragged item leaves the inventory panel rect (`x=0x49..0x234`, `y=0x73..0x19b`) it reassigns the object's owner/room to the current room and returns that entity to the caller.
 - `run_harvester_main_loop` consumes that returned entity as the active selected item. Each frame it repositions the entity under the mouse, rebuilds the same centered `USING_ON_ID` overlay with `g_medfont1_cft`, and routes left-click use through `handle_target_interaction`.
 - `spawn_player_combat_avatar` seeds the live player entity with class `5` and the label string `"your inventory"`, and `handle_target_interaction` special-cases overlap with that target to move a non-inventory carried room item into `INVENTORY`.
-- The room loop also enters that same carried-item state directly from pickup objects: selecting a carryable room object starts cursor-follow carry first, while right-click, room/menu exits, game-over cleanup, and the `I` reopen-inventory path clear the latch and stow the carried object into `INVENTORY`.
+- `run_harvester_main_loop` branches pickup handling on the room-player presence flag at `0xd596c`: when the player is present, selecting a carryable room object enters the same cursor-follow carry state; when the player is absent, the loop moves the object straight into `INVENTORY`, removes its live render entity, and only then considers its action tag.
 
 **Key Functions**
 - `run_inventory_screen`
@@ -1296,6 +1296,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 **Notes**
 - The standalone inventory UI does not use room-region exits while an item is active; the active item must first return to the room loop.
 - Native bottom-of-screen carry prompts are not drawn with the fallback GUI font path; they are materialized as the `USING_ON_ID` text-entry overlay using `MEDFONT1.CFT` at y `0x1ce`.
+- Plain pickup does not rerun full room materialization. Native mutates the live scene in place for both carry-start and direct-to-inventory pickup, and only action-driven runtime mutations or room transitions trigger the heavier refresh paths.
 
 ## Current Blockers
 
diff --git a/TRACKER.md b/TRACKER.md
index 4f861f4c1d7..4bbc3d4ab6d 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,8 +2,8 @@
 
 ## Current Focus
 
-- Verify the corrected native startup-room pickup flow directly from `run_harvester_main_loop`, `run_inventory_screen`, and `handle_target_interaction`
-- Keep the engine-side carry / prompt behavior grounded in confirmed `MEDFONT1` and `"your inventory"` usage before refining any remaining UI details
+- Verify the split between player-present carry pickup and no-player direct-to-inventory pickup directly from `run_harvester_main_loop`
+- Keep the engine-side pickup path aligned with the native in-place scene mutation instead of forcing a room rebuild after plain pickup
 - Re-check only the remaining in-game drag/drop edge cases that still need native capture confirmation
 
 ## Progress
@@ -15,13 +15,14 @@
 
 ## Last Confirmed Action
 
-- Revisited the native carry flow in Ghidra and corrected the pickup-side details.
-  - `run_harvester_main_loop` starts carry mode directly from pickup objects, keeps the carried entity under the mouse, builds the bottom `USING_ON_ID` overlay with `g_medfont1_cft`, and recognizes the player entity's `"your inventory"` label as the explicit room-item-to-inventory handoff target.
-  - `handle_target_interaction` special-cases class `5` / `"your inventory"` overlap to move non-inventory carried items into `INVENTORY`, while right-click, menu, and inventory reopen paths also stow the carried item.
-- Adjusted the engine-side startup-room flow to match that corrected behavior more closely.
-  - Room pickups now enter a carried-item state before inventory transfer, inventory-selected items render as dragged sprites both inside and outside the overlay, dragging an inventory item out still closes the panel while keeping it active, and the shared bottom prompt now uses the native `MEDFONT1` path instead of the GUI font.
+- Revisited the native pickup branch in Ghidra and confirmed the deciding condition is player presence in the room, not the item name.
+  - When the player is present, `run_harvester_main_loop` enters cursor-follow carry mode for pickup objects and keeps using the class `5` / `"your inventory"` overlap target for the explicit room-item-to-inventory handoff.
+  - When the player is absent, the same loop moves pickup objects straight into `INVENTORY`, removes the live room entity in place, and skips the carry state entirely.
+  - The pickup path itself does not rerun full room setup in the native binary; only later action-driven mutations or room transitions take the heavier refresh path.
+- Adjusted the engine-side startup-room flow to match that split more closely.
+  - Full rooms still use carried-item mode, closeup/no-player rooms now transfer pickup objects directly into inventory, and plain pickup no longer forces a full room scene rebuild.
 
 ## Next Suggested Action
 
-1. Compare the native and engine startup-room flow in-game, focusing on room pickup to `"your inventory"` transfer, inventory drag-out prompt text, and cancel / reopen behavior while carrying.
-2. If the carried-item sprite still looks offset versus DOSBox captures, inspect the selected-entity screen-position update in `run_harvester_main_loop` before changing the engine draw anchor.
+1. Compare the native and engine behavior in-game for both pickup branches: `NEWSPAPER` in `PCLIVRM` should stay carried until it reaches `"your inventory"`, while `PC_PEN` / `QUARTER` in `PCDRWR` should jump straight into inventory with no interim carry mode.
+2. If pickup-side visuals still diverge from DOSBox, inspect the native carried-entity anchor and the no-player closeup redraw order before changing any draw offsets.
diff --git a/engines/harvester/startup_room.cpp b/engines/harvester/startup_room.cpp
index c9065ba950b..8495dbb3c21 100644
--- a/engines/harvester/startup_room.cpp
+++ b/engines/harvester/startup_room.cpp
@@ -44,6 +44,7 @@ namespace Harvester {
 
 static const uint32 kRoomExitFastClickWindowTicks = 20;
 static const char *const kPlayerInventoryLabel = "your inventory";
+static const char *const kInventoryOwnerName = "INVENTORY";
 static const byte kTransparentPaletteIndex = 0;
 
 static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
@@ -151,6 +152,15 @@ static bool shouldDispatchPickupActionOnCarryStart(const StartupObjectRecord &ob
 		!object.objectName.equalsIgnoreCase("SYRINGE");
 }
 
+static bool shouldDispatchPickupActionOnDirectInventoryTransfer(const StartupObjectRecord &object) {
+	if (object.actionTag.empty())
+		return false;
+
+	return !object.objectName.equalsIgnoreCase("ST_ASPRIN") &&
+		!object.objectName.equalsIgnoreCase("ST_COUGHM") &&
+		!object.objectName.equalsIgnoreCase("ST_VITAMN");
+}
+
 static void drawRoomPrompt(Graphics::Screen &screen, const Graphics::Font &font,
 		const Common::String &promptText, bool useNativeFont) {
 	if (promptText.empty())
@@ -274,6 +284,23 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			carriedRoomItemLabel.clear();
 			carriedRoomItemBitmap = IndexedBitmap();
 		};
+		auto hideSceneObject = [&](const Common::String &objectName, const Common::String *ownerOrRoom) {
+			StartupObjectRecord *sceneObject = findSceneObjectByName(scene.sceneObjects, objectName);
+			if (!sceneObject)
+				return;
+
+			sceneObject->visible = false;
+			sceneObject->runtimeVisible = false;
+			if (ownerOrRoom)
+				sceneObject->currentOwnerOrRoom = *ownerOrRoom;
+		};
+		auto removeSceneObjectEntity = [&](const Common::String &objectName) {
+			if (!runtimeEntities)
+				return;
+
+			RuntimeEntity *entity = runtimeEntities->detachSceneEntityByName(objectName);
+			delete entity;
+		};
 		auto resetIdleState = [&]() {
 			idleState = StartupRoomIdleAnimationState();
 			idleState.activityTick = getRuntimeClockTicks();
@@ -529,6 +556,34 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		refreshCurrentScene,
 		captureDialogueBackdrop, runRoomExitCommands, resetIdleState
 	};
+	auto moveRoomItemDirectlyToInventory = [&](const StartupObjectRecord &object) -> Common::Error {
+		StartupScript *startupScript = _engine.getStartupScript();
+		if (!startupScript)
+			return Common::kReadingFailed;
+
+		startupScript->addRuntimeObjectToInventory(object.objectName);
+		const Common::String inventoryOwner(kInventoryOwnerName);
+		hideSceneObject(object.objectName, &inventoryOwner);
+		removeSceneObjectEntity(object.objectName);
+
+		bool didTransition = false;
+		if (shouldDispatchPickupActionOnDirectInventoryTransfer(object)) {
+			StartupInteractionResult pickupInteraction;
+			if (startupScript->executeActionTag(object.actionTag, pickupInteraction)) {
+				Common::Error interactionError =
+					interactionProcessor.handleInteractionResult(pickupInteraction, didTransition);
+				if (interactionError.getCode() != Common::kNoError)
+					return interactionError;
+				if (startupFlow.hasPendingMainMenuReturn())
+					return Common::kNoError;
+			}
+		}
+
+		if (!_inventory.refresh())
+			return Common::kReadingFailed;
+		needsRedraw = true;
+		return Common::kNoError;
+	};
 	auto beginRoomItemCarry = [&](const StartupObjectRecord &object) -> Common::Error {
 		StartupScript *startupScript = _engine.getStartupScript();
 		ResourceManager *resources = _engine.getResources();
@@ -543,6 +598,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			loadBitmapResource(*resources, spritePath, carriedRoomItemBitmap);
 
 		startupScript->setRuntimeObjectVisible(object.currentOwnerOrRoom, object.objectName, false);
+		hideSceneObject(object.objectName, nullptr);
+		removeSceneObjectEntity(object.objectName);
 
 		bool didTransition = false;
 		if (shouldDispatchPickupActionOnCarryStart(object)) {
@@ -563,8 +620,6 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			needsRedraw = true;
 			return Common::kNoError;
 		}
-		if (!didTransition && !refreshCurrentScene(true))
-			return Common::kReadingFailed;
 
 		needsRedraw = true;
 		return Common::kNoError;
@@ -1019,9 +1074,11 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 					break;
 				}
 				if (_engine.getStartupScript()->isPickupObject(*clickedObject)) {
-					Common::Error carryError = beginRoomItemCarry(*clickedObject);
-					if (carryError.getCode() != Common::kNoError)
-						return carryError;
+					Common::Error pickupError = playerState.entity
+						? beginRoomItemCarry(*clickedObject)
+						: moveRoomItemDirectlyToInventory(*clickedObject);
+					if (pickupError.getCode() != Common::kNoError)
+						return pickupError;
 					break;
 				}
 


Commit: 8328b77aa9b45cc9f18b82c6cf91817e3cb43a97
    https://github.com/scummvm/scummvm/commit/8328b77aa9b45cc9f18b82c6cf91817e3cb43a97
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:25+02:00

Commit Message:
HARVESTER: Match native jimmy talk branches

Changed paths:
    TRACKER.md
    engines/harvester/npc/jimmy_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 4bbc3d4ab6d..5361777bda5 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,9 +2,9 @@
 
 ## Current Focus
 
-- Verify the split between player-present carry pickup and no-player direct-to-inventory pickup directly from `run_harvester_main_loop`
-- Keep the engine-side pickup path aligned with the native in-place scene mutation instead of forcing a room rebuild after plain pickup
-- Re-check only the remaining in-game drag/drop edge cases that still need native capture confirmation
+- Verify NPC-specific dialogue handlers against their native hardcoded dispatchers before assuming they should use the generic keyword flow
+- Keep the engine-side Jimmy conversation aligned with the native short-circuit branch order instead of letting special barks fall through
+- Re-check only the remaining in-game drag/drop and dialogue edge cases that still need native capture confirmation
 
 ## Progress
 
@@ -15,14 +15,13 @@
 
 ## Last Confirmed Action
 
-- Revisited the native pickup branch in Ghidra and confirmed the deciding condition is player presence in the room, not the item name.
-  - When the player is present, `run_harvester_main_loop` enters cursor-follow carry mode for pickup objects and keeps using the class `5` / `"your inventory"` overlap target for the explicit room-item-to-inventory handoff.
-  - When the player is absent, the same loop moves pickup objects straight into `INVENTORY`, removes the live room entity in place, and skips the carry state entirely.
-  - The pickup path itself does not rerun full room setup in the native binary; only later action-driven mutations or room transitions take the heavier refresh path.
-- Adjusted the engine-side startup-room flow to match that split more closely.
-  - Full rooms still use carried-item mode, closeup/no-player rooms now transfer pickup objects directly into inventory, and plain pickup no longer forces a full room scene rebuild.
+- Revisited the native Jimmy conversation path in Ghidra and confirmed it is a dedicated hardcoded handler, not a generic keyword-loop dialogue.
+  - `handle_talk_to_jimmy` is a short-circuit chain: first no-item talk returns `0x4a4c` or `0x4a58`, special no-item cases for `SNEAKERS` and `PAPER_CHK_4/3/2` each terminate immediately on their own line, and only the final fallback returns `0x4b38`.
+  - The original Jimmy handler does not call the keyword or response menu helpers directly, so there is no evidence that bare click-talk should fan out into a larger dynamic conversation tree.
+- Adjusted the engine-side Jimmy handler to match that native branch behavior more closely.
+  - Jimmy's special no-item barks now return immediately instead of falling through to later lines, preserving the native priority order.
 
 ## Next Suggested Action
 
-1. Compare the native and engine behavior in-game for both pickup branches: `NEWSPAPER` in `PCLIVRM` should stay carried until it reaches `"your inventory"`, while `PC_PEN` / `QUARTER` in `PCDRWR` should jump straight into inventory with no interim carry mode.
-2. If pickup-side visuals still diverge from DOSBox, inspect the native carried-entity anchor and the no-player closeup redraw order before changing any draw offsets.
+1. Compare the native and engine Jimmy talk paths in-game with and without `SNEAKERS`, `BROOMKEY`, and `PAPER_CHK_2/3/4` set to confirm the branch priorities now match the DOS behavior.
+2. If another NPC still looks too shallow or too chatty versus DOSBox, verify that NPC's dedicated handler in Ghidra before changing any engine dialogue flow assumptions.
diff --git a/engines/harvester/npc/jimmy_dialogue.cpp b/engines/harvester/npc/jimmy_dialogue.cpp
index aefa9f0c059..8a3a4a1c3ab 100644
--- a/engines/harvester/npc/jimmy_dialogue.cpp
+++ b/engines/harvester/npc/jimmy_dialogue.cpp
@@ -132,26 +132,14 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			? playJimmyLine(0x4a4c, 1)
 			: playJimmyLine(0x4a58, 0);
 	}
-	if (hasInventoryItem("SNEAKERS") && !hasInventoryItem("BROOMKEY")) {
-		Common::Error lineError = playJimmyLine(0x4ac3, 0);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (runtime.startupScript().getFlagValue("PAPER_CHK_4")) {
-		Common::Error lineError = playJimmyLine(0x4ae2, 2);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (runtime.startupScript().getFlagValue("PAPER_CHK_3")) {
-		Common::Error lineError = playJimmyLine(0x4adb, 2);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-	if (runtime.startupScript().getFlagValue("PAPER_CHK_2")) {
-		Common::Error lineError = playJimmyLine(0x4ad4, 2);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
+	if (hasInventoryItem("SNEAKERS") && !hasInventoryItem("BROOMKEY"))
+		return playJimmyLine(0x4ac3, 0);
+	if (runtime.startupScript().getFlagValue("PAPER_CHK_4"))
+		return playJimmyLine(0x4ae2, 2);
+	if (runtime.startupScript().getFlagValue("PAPER_CHK_3"))
+		return playJimmyLine(0x4adb, 2);
+	if (runtime.startupScript().getFlagValue("PAPER_CHK_2"))
+		return playJimmyLine(0x4ad4, 2);
 
 	return playJimmyLine(0x4b38, 0);
 }


Commit: b8d9aea6b0ff5338e4f6c88b23afda808fbe8a50
    https://github.com/scummvm/scummvm/commit/b8d9aea6b0ff5338e4f6c88b23afda808fbe8a50
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:25+02:00

Commit Message:
HARVESTER: Prefer native dialogue response table

Changed paths:
    TRACKER.md
    engines/harvester/startup_text.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 5361777bda5..71fc2ce9534 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,8 +2,8 @@
 
 ## Current Focus
 
-- Verify NPC-specific dialogue handlers against their native hardcoded dispatchers before assuming they should use the generic keyword flow
-- Keep the engine-side Jimmy conversation aligned with the native short-circuit branch order instead of letting special barks fall through
+- Verify dialogue-resource selection against native expectations before changing NPC branch logic
+- Keep Hank's keyword and response flow aligned with the native `DIALOG.RSP` table the executable actually expects
 - Re-check only the remaining in-game drag/drop and dialogue edge cases that still need native capture confirmation
 
 ## Progress
@@ -15,13 +15,12 @@
 
 ## Last Confirmed Action
 
-- Revisited the native Jimmy conversation path in Ghidra and confirmed it is a dedicated hardcoded handler, not a generic keyword-loop dialogue.
-  - `handle_talk_to_jimmy` is a short-circuit chain: first no-item talk returns `0x4a4c` or `0x4a58`, special no-item cases for `SNEAKERS` and `PAPER_CHK_4/3/2` each terminate immediately on their own line, and only the final fallback returns `0x4b38`.
-  - The original Jimmy handler does not call the keyword or response menu helpers directly, so there is no evidence that bare click-talk should fan out into a larger dynamic conversation tree.
-- Adjusted the engine-side Jimmy handler to match that native branch behavior more closely.
-  - Jimmy's special no-item barks now return immediately instead of falling through to later lines, preserving the native priority order.
+- Revisited Hank's talk path in Ghidra and confirmed the native issue is data selection, not a missing generic conversation tree.
+  - `handle_talk_to_hank` is a native keyword/response-menu conversation that expects a `DIALOG.RSP` where zero-based line `0xd2` is `Mom`, `0xd6` is `STEVE`, `0xeb` is `SICK`, and `0xf2` is `bye`.
+  - The duplicate `DIALOG.RSP` under `iso/Harvester` does not match that layout, while the root-game `DIALOG.RSP` does, which explains why a visible `Mom` selection can fall into Hank's generic `0xa32` plus `0x8dc` fallback when the wrong copy is loaded.
+- Adjusted startup dialogue-text loading to prefer the native-compatible ancestor/root `DIALOG.RSP` when the initially resolved table does not match Hank's verified native signature.
 
 ## Next Suggested Action
 
-1. Compare the native and engine Jimmy talk paths in-game with and without `SNEAKERS`, `BROOMKEY`, and `PAPER_CHK_2/3/4` set to confirm the branch priorities now match the DOS behavior.
-2. If another NPC still looks too shallow or too chatty versus DOSBox, verify that NPC's dedicated handler in Ghidra before changing any engine dialogue flow assumptions.
+1. Re-test Hank's `Mom`, `TV`, and `Sick` topic paths in-game and confirm the keyword menu now reaches the native follow-up trees instead of the generic fallback pair.
+2. If another NPC still diverges, compare the active `DIALOG.RSP` lines for that branch before changing the handler logic itself.
diff --git a/engines/harvester/startup_text.cpp b/engines/harvester/startup_text.cpp
index 0fd6081acd4..0c21a9e2bd2 100644
--- a/engines/harvester/startup_text.cpp
+++ b/engines/harvester/startup_text.cpp
@@ -21,8 +21,12 @@
 
 #include "harvester/startup_text.h"
 
+#include "common/config-manager.h"
 #include "common/debug.h"
 #include "common/endian.h"
+#include "common/fs.h"
+#include "common/ptr.h"
+#include "common/stream.h"
 #include "harvester/resources.h"
 
 namespace Harvester {
@@ -46,6 +50,80 @@ static bool isDialogueDelimiter(byte value) {
 	return value == 0 || value == '\n' || value == '\r' || value == '\f';
 }
 
+static void parseDialogueResponseLines(const Common::Array<byte> &responseData,
+		Common::Array<Common::String> &lines) {
+	lines.clear();
+
+	Common::String line;
+	for (byte value : responseData) {
+		if (value == '\r')
+			continue;
+		if (value == '\n') {
+			lines.push_back(line);
+			line.clear();
+			continue;
+		}
+
+		line += (char)value;
+	}
+
+	if (!line.empty())
+		lines.push_back(line);
+}
+
+static bool hasNativeHankDialogueResponseLayout(const Common::Array<Common::String> &lines) {
+	return lines.size() > 0xf2 &&
+		lines[0xd2].equalsIgnoreCase("Mom") &&
+		lines[0xd6].equalsIgnoreCase("STEVE") &&
+		lines[0xeb].equalsIgnoreCase("SICK") &&
+		lines[0xf2].equalsIgnoreCase("bye");
+}
+
+static bool loadDialogueResponseLinesFromNode(const Common::FSNode &node,
+		Common::Array<Common::String> &lines) {
+	if (!node.exists() || node.isDirectory())
+		return false;
+
+	Common::ScopedPtr<Common::SeekableReadStream> stream(node.createReadStream());
+	if (!stream)
+		return false;
+
+	Common::Array<byte> responseData;
+	responseData.resize(stream->size());
+	if (!responseData.empty())
+		stream->read(responseData.data(), responseData.size());
+	parseDialogueResponseLines(responseData, lines);
+	return !lines.empty();
+}
+
+static bool tryLoadPreferredDialogueResponsesFromGamePath(Common::Array<Common::String> &lines) {
+	if (!ConfMan.hasKey("path"))
+		return false;
+
+	Common::FSNode current(ConfMan.getPath("path"));
+	if (!current.exists())
+		return false;
+
+	for (int depth = 0; depth < 4; ++depth) {
+		const Common::FSNode candidate = current.getChild(kDialogueResponsePath);
+		Common::Array<Common::String> candidateLines;
+		if (loadDialogueResponseLinesFromNode(candidate, candidateLines) &&
+				hasNativeHankDialogueResponseLayout(candidateLines)) {
+			lines = candidateLines;
+			debug(1, "Harvester: using native-compatible DIALOG.RSP from '%s'",
+				candidate.getPath().toString(Common::Path::kNativeSeparator).c_str());
+			return true;
+		}
+
+		const Common::FSNode parent = current.getParent();
+		if (parent.getPath().toString('/') == current.getPath().toString('/'))
+			break;
+		current = parent;
+	}
+
+	return false;
+}
+
 } // End of anonymous namespace
 
 bool StartupText::load(ResourceManager &resources) {
@@ -115,22 +193,16 @@ bool StartupText::loadDialogueResponses(ResourceManager &resources) {
 		return false;
 	}
 
-	Common::String line;
-	for (byte value : responseData) {
-		if (value == '\r')
-			continue;
-		if (value == '\n') {
-			_dialogueResponseLines.push_back(line);
-			line.clear();
-			continue;
-		}
+	parseDialogueResponseLines(responseData, _dialogueResponseLines);
+	if (_dialogueResponseLines.empty())
+		return false;
 
-		line += (char)value;
+	if (!hasNativeHankDialogueResponseLayout(_dialogueResponseLines)) {
+		Common::Array<Common::String> preferredLines;
+		if (tryLoadPreferredDialogueResponsesFromGamePath(preferredLines))
+			_dialogueResponseLines = preferredLines;
 	}
 
-	if (!line.empty())
-		_dialogueResponseLines.push_back(line);
-
 	return !_dialogueResponseLines.empty();
 }
 


Commit: f159e6197b1816087b2b0ec1655f41bd51a3c371
    https://github.com/scummvm/scummvm/commit/f159e6197b1816087b2b0ec1655f41bd51a3c371
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:26+02:00

Commit Message:
HARVESTER: Match native save and options menus

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/startup_menu.cpp
    engines/harvester/startup_menu.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index e8fa87d1fa2..d356c6148a1 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -384,6 +384,9 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - It enumerates the same 25 save slots and supports text entry for save names.
   - The active slot is derived from the list rectangle `x=0x08..0x27b`, `y=0x19..0x1c2` with a native row stride of `0x11`.
   - Clicking a slot, pressing `Enter`, or clicking the lower-left action area `x=0x03..0x13d`, `y=0x1cc..0x1dc` all route into the same `run_text_entry_dialog` path for the active slot; `ESC` and the lower-right area `x=0x141..0x27b`, `y=0x1cc..0x1dc` cancel back to gameplay.
+  - The slot list uses the medium fonts rather than the main room-menu fonts: unselected `GAME_##` labels use `g_medfont2_cft`, the active slot label is respawned with `g_medfont1_cft`, and all slot-name text plus inline slot editing also use `g_medfont1_cft` at `x=0x50`.
+  - The `SAVEGAME.BM` background already carries the lower action-strip art; the native code does not spawn extra `SAVE` / `CANCEL` text entities over that strip.
+  - Entering slot-name edit mode spawns `TEXT_ENTRY` at `(0x50, activeSlot * 0x11 + 0x19)`, temporarily moves the mouse to `(0x14, 0x1ce)`, constrains mouse travel to the bottom confirm/cancel band, and then calls `run_text_entry_dialog` with hidden max length `0xf6`.
   - It first calls `sync_current_room_runtime_state_before_save_load`, which copies current-room monster runtime positions/facing back into `MonsterRecord` fields and refreshes the `SAVE_GAME` entrance from `g_player_combat_avatar`.
   - The save header is now explicit: current disc id, `g_player_present_in_room`, `g_game_clock_tick_remainder_ms`, `g_current_room_def->room_name`, and `g_player_room_name`.
   - The remainder of the save blob persists the player combat-avatar state, record lists (`ANIM`, `FLAG`, `MONSTER`, `NPC`, `OBJECT`, `REGION`, `TIMER`), the talk-state blocks, ammo counters, and `g_current_music_path`.
@@ -449,8 +452,10 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - Because `dialogue.idx` is opened without a `1:\` / `2:\` set prefix, `open_xfile_stream` routes it through the direct-file path rather than the DAT archives.
 - `play_dialogue_line` at `0x7a690` is the single-line voice/subtitle presenter used by the talk handlers.
   - It formats the voice sample path as `<VOICE config><wavId>.CMP`, loads that direct `CMP` file if present, seeks `g_dialogue_index_stream` to the matching `dialogue.idx` text span, XOR-decodes the subtitle text, selects the textbox art by wrapped-line count, refreshes the active portrait/head slot, and blocks until the line completes or is interrupted.
+  - `load_dialogue_voice_sample` routes those `.CMP` loads back through `open_xfile_stream`, so numbered set paths can resolve through the `INDEX.00N` XFILE catalogs while bare relative voice paths still fall through to direct CD-ROM file access.
   - Raw call-site tracing now confirms the argument registers used by the talk handlers: `EAX = wav id`, `EDX = speaker/head id string`, and `EBX = portrait variant`.
   - The per-NPC talk handlers do not embed literal subtitle or keyword text in code. They hardcode WAV ids, head-id strings / variants, and zero-based `dialog.rsp` line indices; the human-readable subtitle and keyword text is loaded from `dialogue.idx` and `dialog.rsp` at runtime.
+  - The dialogue resources are not a declarative conversation tree: `dialog.rsp` supplies menu/topic text, `dialogue.idx` supplies subtitle spans keyed by WAV id, and `INDEX.00N` only catalogs archive-backed file entries for XFILE lookup. The actual topic branching, state gates, and follow-up rewrites stay compiled inside the per-NPC talk handlers reached from `g_talk_to_handler_entries`.
 - `load_dialogue_response_line` at `0x3a1a0` is the indexed `dialog.rsp` line loader used by the talk-to handlers.
   - It opens the ASCII `dialog.rsp` file, reads forward to the requested zero-based line index, strips the trailing CR/LF, and returns the shared `g_dialogue_response_line_buffer`.
   - Handlers such as `handle_talk_to_dwayne`, `handle_talk_to_hank`, and `handle_talk_to_mom` use it to map keyword-menu selections back to concrete topic strings before branching.
@@ -1168,6 +1173,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - It parses one numbered slash-delimited `dialog.rsp` line into wrapped response rows, chooses `TEXTBOX1`..`TEXTBOX8` from the total wrapped line count, titles the panel with the literal `Responses`, and returns a 1-based selection from mouse row hit-testing or digit keys `1` through `9`.
 - `run_dialogue_keyword_menu` at `0x7ba40` is the slash-delimited keyword menu plus free-text `Other` path used by the NPC dialogue system.
   - It always appends `g_dialogue_keyword_menu_default_topic`, loaded by `load_dialogue_index` from zero-based `dialog.rsp` line `13`, plus a literal `Other` entry that calls `run_text_entry_dialog`.
+  - Choosing a visible keyword copies that literal on-screen topic text into `g_dialogue_selected_topic_text`; the `Other` path copies the typed text there as well. There is no separate hidden topic-id remap layer behind `dialog.rsp`.
   - ESC does not silently cancel the keyword menu; it writes that default topic into `g_dialogue_selected_topic_text` and returns through the normal handler path.
 
 ## Room Menu Options UI
@@ -1200,6 +1206,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 **Notes**
 - The native submenu persists `GAMMA`, `FX_VOLUME`, and `MUSIC_VOLUME` on exit and updates the string-backed `TEXT`, `GORE`, `QUICK_TIPS`, and password state inline while the submenu is active.
 - The options text entities are spawned before the `VOLUME.BM` bars and `INDICATR.BM` sliders are added to the render list, so the first three labels sit underneath the bar art where their trailing edge overlaps that UI.
+- `prompt_for_password` does not open a separate boxed dialog. It overlays the current options menu with centered `ENTER PASSWORD` text rendered in `g_harvfont_cft`, spawns a `TEXT_ENTRY` entity at `(0xdc, 0xdc)` using `g_harvfnt2_cft`, hides the cursor entity while typing, and calls `run_text_entry_dialog` with hidden max length `8`.
 
 ## Room Menu Help Screen
 
diff --git a/TRACKER.md b/TRACKER.md
index 71fc2ce9534..a78fb9b756d 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,9 +2,9 @@
 
 ## Current Focus
 
-- Verify dialogue-resource selection against native expectations before changing NPC branch logic
-- Keep Hank's keyword and response flow aligned with the native `DIALOG.RSP` table the executable actually expects
-- Re-check only the remaining in-game drag/drop and dialogue edge cases that still need native capture confirmation
+- Keep the in-room ESC menu handlers aligned with the native SAVE / OPTIONS layout and font usage
+- Verify remaining menu overlays against Ghidra before introducing any more custom UI panels
+- Re-test room-menu edge cases after each menu fix instead of bundling multiple visual changes together
 
 ## Progress
 
@@ -15,12 +15,13 @@
 
 ## Last Confirmed Action
 
-- Revisited Hank's talk path in Ghidra and confirmed the native issue is data selection, not a missing generic conversation tree.
-  - `handle_talk_to_hank` is a native keyword/response-menu conversation that expects a `DIALOG.RSP` where zero-based line `0xd2` is `Mom`, `0xd6` is `STEVE`, `0xeb` is `SICK`, and `0xf2` is `bye`.
-  - The duplicate `DIALOG.RSP` under `iso/Harvester` does not match that layout, while the root-game `DIALOG.RSP` does, which explains why a visible `Mom` selection can fall into Hank's generic `0xa32` plus `0x8dc` fallback when the wrong copy is loaded.
-- Adjusted startup dialogue-text loading to prefer the native-compatible ancestor/root `DIALOG.RSP` when the initially resolved table does not match Hank's verified native signature.
+- Revisited `run_main_menu`, `run_save_game_menu`, `prompt_for_password`, and `run_text_entry_dialog` in Ghidra and corrected the engine-side SAVE / OPTIONS menu mismatches they exposed.
+  - Native SAVE uses `MEDFONT2` for inactive `GAME_##` labels, `MEDFONT1` for the active label and every slot title, and relies on `SAVEGAME.BM` for the lower action-strip art instead of drawing extra `SAVE` / `CANCEL` text.
+  - Native save-name editing spawns `TEXT_ENTRY` inline at `(0x50, rowY)` with `g_medfont1_cft`, while password entry overlays the options menu with centered `ENTER PASSWORD` text in `g_harvfont_cft` and `TEXT_ENTRY` at `(0xdc, 0xdc)` in `g_harvfnt2_cft`.
+  - The password prompt's hidden max-length register is `8`, not the larger generic limit the engine had been using.
+- Updated `startup_menu.cpp` to match those native font/layout rules and rebuilt `engines/harvester/startup_menu.o` successfully.
 
 ## Next Suggested Action
 
-1. Re-test Hank's `Mom`, `TV`, and `Sick` topic paths in-game and confirm the keyword menu now reaches the native follow-up trees instead of the generic fallback pair.
-2. If another NPC still diverges, compare the active `DIALOG.RSP` lines for that branch before changing the handler logic itself.
+1. Re-test the in-room SAVE menu and OPTIONS password prompt in-engine and compare them against DOSBox captures for any remaining cursor or quick-tips overlay drift.
+2. If another menu artifact remains, capture the exact submenu state first and then inspect the corresponding `run_main_menu` branch in Ghidra before editing.
diff --git a/engines/harvester/startup_menu.cpp b/engines/harvester/startup_menu.cpp
index 8457b6df1ec..8c281cbf686 100644
--- a/engines/harvester/startup_menu.cpp
+++ b/engines/harvester/startup_menu.cpp
@@ -96,7 +96,11 @@ static const int kSaveSlotStartY = 0x19;
 static const int kSaveSlotStride = 0x11;
 static const int kSaveSlotTextWidth = 0x27b - kSaveSlotNameX;
 static const int kSaveStatusTextY = 0x1b8;
-static const int kSaveSlotMaxCharacters = 63;
+static const int kSaveSlotMaxCharacters = 0xf6;
+static const int kPasswordEntryX = 0xdc;
+static const int kPasswordEntryY = 0xdc;
+static const int kPasswordEntryWidth = 0x226;
+static const int kPasswordMaxCharacters = 8;
 
 struct RoomMenuTextConfig {
 	Common::Array<Common::String> optionItems;
@@ -143,18 +147,6 @@ static Common::Rect quickTipsToggleRect() {
 	return Common::Rect(258, 280, 366, 291);
 }
 
-static Common::Rect textEntryInputRect() {
-	return Common::Rect(176, 212, 464, 234);
-}
-
-static Common::Rect textEntryOkRect() {
-	return Common::Rect(224, 278, 286, 292);
-}
-
-static Common::Rect textEntryCancelRect() {
-	return Common::Rect(344, 278, 424, 292);
-}
-
 static Common::Rect saveSlotRect(int slotIndex) {
 	const int top = kSaveSlotStartY + slotIndex * kSaveSlotStride;
 	return Common::Rect(8, top, 0x27b + 1, top + kSaveSlotStride);
@@ -310,13 +302,13 @@ static Common::String buildOptionsMenuItemLabel(const StartupScript &startupScri
 	}
 }
 
-static bool appendTextEntryCharacter(Common::String &text, const Graphics::Font &font,
-		uint32 ascii, uint maxCharacters) {
+static bool appendBoundedTextEntryCharacter(Common::String &text, const Graphics::Font &font,
+		uint32 ascii, uint maxCharacters, int maxWidth) {
 	if (ascii < 32 || ascii > 126 || ascii == '~' || text.size() >= maxCharacters)
 		return false;
 
 	const Common::String candidate = text + (char)ascii;
-	if (font.getStringWidth(candidate) > textEntryInputRect().width() - 10)
+	if (font.getStringWidth(candidate) > maxWidth)
 		return false;
 
 	text = candidate;
@@ -324,15 +316,7 @@ static bool appendTextEntryCharacter(Common::String &text, const Graphics::Font
 }
 
 static bool appendInlineSaveCharacter(Common::String &text, const Graphics::Font &font, uint32 ascii) {
-	if (ascii < 32 || ascii > 126 || ascii == '~' || text.size() >= kSaveSlotMaxCharacters)
-		return false;
-
-	const Common::String candidate = text + (char)ascii;
-	if (font.getStringWidth(candidate) > kSaveSlotTextWidth)
-		return false;
-
-	text = candidate;
-	return true;
+	return appendBoundedTextEntryCharacter(text, font, ascii, kSaveSlotMaxCharacters, kSaveSlotTextWidth);
 }
 
 static Common::String buildDefaultSaveSlotLabel(int slotIndex) {
@@ -413,7 +397,8 @@ static void renderOptionsMenuScreen(HarvesterEngine &engine, const IndexedBitmap
 		const byte *palette, float paletteBrightness,
 		const Graphics::Font &selectedFont, const Graphics::Font &unselectedFont,
 		const StartupArt &art, const RoomMenuTextConfig &config,
-		const IndexedBitmap &volumeBar, const IndexedBitmap &indicator, int selectedItem) {
+		const IndexedBitmap &volumeBar, const IndexedBitmap &indicator, int selectedItem,
+		bool drawCursor = true) {
 	Graphics::Screen *screen = engine.getScreen();
 	StartupScript *startupScript = engine.getStartupScript();
 	if (!screen || !startupScript)
@@ -449,7 +434,7 @@ static void renderOptionsMenuScreen(HarvesterEngine &engine, const IndexedBitmap
 			kOptionsIndicatorY + i * lineHeight);
 	}
 
-	if (engine.getRuntimeEntities())
+	if (drawCursor && engine.getRuntimeEntities())
 		engine.getRuntimeEntities()->drawCursor(*screen);
 	screen->makeAllDirty();
 	screen->update();
@@ -487,48 +472,12 @@ static void renderQuickTipsOverlay(HarvesterEngine &engine, const IndexedBitmap
 	screen->update();
 }
 
-static void renderTextEntryDialog(HarvesterEngine &engine, const IndexedBitmap &backdrop,
-		const byte *palette, float paletteBrightness, const Graphics::Font &titleFont,
-		const Graphics::Font &entryFont, const Common::String &title, const Common::String &text,
-		bool cursorVisible, bool okHover, bool cancelHover) {
-	Graphics::Screen *screen = engine.getScreen();
-	if (!screen)
-		return;
-
-	applyMenuPalette(*screen, engine, palette, paletteBrightness);
-	blitBitmap(*screen, backdrop, 0, 0);
-
-	screen->fillRect(Common::Rect(132, 148, 508, 320), kPanelFillColor);
-	drawShadowedString(*screen, titleFont, title, 132, 168, 376, kTextColorNormal,
-		Graphics::kTextAlignCenter);
-	screen->fillRect(textEntryInputRect(), kTransparentPaletteIndex);
-	screen->frameRect(textEntryInputRect(), kTextColorDim);
-
-	Common::String displayText = text;
-	if (cursorVisible)
-		displayText += "_";
-	const Common::Rect inputRect = textEntryInputRect();
-	const Common::Rect okRect = textEntryOkRect();
-	const Common::Rect cancelRect = textEntryCancelRect();
-	drawShadowedString(*screen, entryFont, displayText, inputRect.left + 5, inputRect.top + 4,
-		inputRect.width() - 10, kTextColorNormal);
-	drawShadowedString(*screen, entryFont, "OK", okRect.left, okRect.top,
-		okRect.width(), okHover ? kTextColorHover : kTextColorNormal, Graphics::kTextAlignCenter);
-	drawShadowedString(*screen, entryFont, "Cancel", cancelRect.left, cancelRect.top,
-		cancelRect.width(), cancelHover ? kTextColorHover : kTextColorNormal,
-		Graphics::kTextAlignCenter);
-
-	if (engine.getRuntimeEntities())
-		engine.getRuntimeEntities()->drawCursor(*screen);
-	screen->makeAllDirty();
-	screen->update();
-}
-
 static void renderSaveGameMenuScreen(HarvesterEngine &engine, const IndexedBitmap &background,
-		const byte *palette, float paletteBrightness, const Graphics::Font &selectedFont,
-		const Graphics::Font &unselectedFont, const Common::Array<Common::String> &slotTitles,
-		int activeSlot, bool hoverSave, bool hoverCancel, const Common::String &statusMessage,
-		int editingSlot = -1, const Common::String *editingText = nullptr) {
+		const byte *palette, float paletteBrightness, const Graphics::Font &selectedLabelFont,
+		const Graphics::Font &unselectedLabelFont, const Graphics::Font &slotNameFont,
+		const Common::Array<Common::String> &slotTitles, int activeSlot,
+		const Common::String &statusMessage, int editingSlot = -1,
+		const Common::String *editingText = nullptr) {
 	Graphics::Screen *screen = engine.getScreen();
 	if (!screen)
 		return;
@@ -537,9 +486,7 @@ static void renderSaveGameMenuScreen(HarvesterEngine &engine, const IndexedBitma
 	blitBitmap(*screen, background, 0, 0);
 
 	for (int i = 0; i < kSaveSlotCount; ++i) {
-		const bool selected = i == activeSlot;
-		const Graphics::Font &labelFont = selected ? selectedFont : unselectedFont;
-		const Graphics::Font &nameFont = (i == editingSlot || selected) ? selectedFont : unselectedFont;
+		const Graphics::Font &labelFont = (i == activeSlot) ? selectedLabelFont : unselectedLabelFont;
 		const int y = kSaveSlotStartY + i * kSaveSlotStride;
 		const Common::String slotLabel = buildDefaultSaveSlotLabel(i);
 		labelFont.drawString(screen, slotLabel, kSaveSlotLabelX, y, 64, 0);
@@ -547,17 +494,11 @@ static void renderSaveGameMenuScreen(HarvesterEngine &engine, const IndexedBitma
 		Common::String displayTitle = slotTitles[i];
 		if (i == editingSlot && editingText)
 			displayTitle = *editingText + "_";
-		nameFont.drawString(screen, displayTitle, kSaveSlotNameX, y, kSaveSlotTextWidth, 0);
+		slotNameFont.drawString(screen, displayTitle, kSaveSlotNameX, y, kSaveSlotTextWidth, 0);
 	}
 
-	drawShadowedString(*screen, selectedFont, "SAVE", saveConfirmRect().left, saveConfirmRect().top,
-		saveConfirmRect().width(), hoverSave ? kTextColorHover : kTextColorNormal,
-		Graphics::kTextAlignCenter);
-	drawShadowedString(*screen, selectedFont, "CANCEL", saveCancelRect().left, saveCancelRect().top,
-		saveCancelRect().width(), hoverCancel ? kTextColorHover : kTextColorNormal,
-		Graphics::kTextAlignCenter);
 	if (!statusMessage.empty()) {
-		drawShadowedString(*screen, unselectedFont, statusMessage, 20, kSaveStatusTextY,
+		drawShadowedString(*screen, slotNameFont, statusMessage, 20, kSaveStatusTextY,
 			screen->w - 40, kTextColorNormal, Graphics::kTextAlignCenter);
 	}
 
@@ -610,11 +551,11 @@ static int getNativeRoomMenuSelectionFromMouse(const Graphics::Font &selectedFon
 
 	int selection = mousePos.y - kMenuStartY;
 	if (selection < 1)
-		return -1;
+		selection = 1;
 
-	selection = (selection - 1) / MAX<int>(1, getNativeRoomMenuLineHeight(selectedFont));
-	if (selection < 0 || selection >= (int)itemCount)
-		return -1;
+	selection /= MAX<int>(1, getNativeRoomMenuLineHeight(selectedFont));
+	if (selection >= (int)itemCount)
+		selection = (int)itemCount - 1;
 
 	return selection;
 }
@@ -870,16 +811,16 @@ Common::Error StartupMenuSystem::runSaveGameMenu(const byte *palette, float pale
 	(void)paletteBrightness;
 	ResourceManager *resources = _engine.getResources();
 	MetaEngine *metaEngine = _engine.getMetaEngine();
-	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
-	const CftFontResource *unselectedFontResource = findStartupFontByName(_engine, "HARVFNT2");
-	if (!resources || !metaEngine || !selectedFontResource || !unselectedFontResource)
+	const CftFontResource *slotNameFontResource = findStartupFontByName(_engine, "MEDFONT1");
+	const CftFontResource *slotLabelFontResource = findStartupFontByName(_engine, "MEDFONT2");
+	if (!resources || !metaEngine || !slotNameFontResource || !slotLabelFontResource)
 		return Common::kReadingFailed;
 	if (!_engine.canSaveGameStateCurrently())
 		return Common::kNoError;
 
-	HarvesterCftFont selectedFont(*selectedFontResource);
-	HarvesterCftFont unselectedFont(*unselectedFontResource);
-	if (!selectedFont.isValid() || !unselectedFont.isValid())
+	HarvesterCftFont slotNameFont(*slotNameFontResource);
+	HarvesterCftFont slotLabelFont(*slotLabelFontResource);
+	if (!slotNameFont.isValid() || !slotLabelFont.isValid())
 		return Common::kReadingFailed;
 
 	IndexedBitmap background;
@@ -920,11 +861,9 @@ Common::Error StartupMenuSystem::runSaveGameMenu(const byte *palette, float pale
 		Graphics::FrameLimiter entryLimiter(g_system, 60);
 
 		while (!_engine.shouldQuit()) {
-			const bool hoverSave = saveConfirmRect().contains(_mousePos);
-			const bool hoverCancel = saveCancelRect().contains(_mousePos);
 			if (needsRedraw) {
 				renderSaveGameMenuScreen(_engine, background, savePalette, 1.0f,
-					selectedFont, unselectedFont, slotTitles, slotIndex, hoverSave, hoverCancel,
+					slotNameFont, slotLabelFont, slotNameFont, slotTitles, slotIndex,
 					Common::String(), slotIndex, &editedTitle);
 				needsRedraw = false;
 			}
@@ -962,7 +901,7 @@ Common::Error StartupMenuSystem::runSaveGameMenu(const byte *palette, float pale
 						}
 						break;
 					}
-					if (appendInlineSaveCharacter(editedTitle, selectedFont, event.kbd.ascii))
+					if (appendInlineSaveCharacter(editedTitle, slotNameFont, event.kbd.ascii))
 						needsRedraw = true;
 					break;
 				default:
@@ -1009,11 +948,9 @@ Common::Error StartupMenuSystem::runSaveGameMenu(const byte *palette, float pale
 	};
 
 	while (!_engine.shouldQuit()) {
-		const bool hoverSave = saveConfirmRect().contains(_mousePos);
-		const bool hoverCancel = saveCancelRect().contains(_mousePos);
 		if (needsRedraw) {
 			renderSaveGameMenuScreen(_engine, background, savePalette, 1.0f,
-				selectedFont, unselectedFont, slotTitles, activeSlot, hoverSave, hoverCancel, statusMessage);
+				slotNameFont, slotLabelFont, slotNameFont, slotTitles, activeSlot, statusMessage);
 			needsRedraw = false;
 		}
 
@@ -1199,8 +1136,6 @@ Common::Error StartupMenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, c
 	}
 
 	const int lineHeight = getNativeRoomMenuLineHeight(selectedFont);
-	const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	const Graphics::Font &entryFont = bodyFont ? *bodyFont : static_cast<const Graphics::Font &>(unselectedFont);
 	int selectedItem = 0;
 	int capturedSlider = -1;
 	bool showingQuickTips = false;
@@ -1265,10 +1200,85 @@ Common::Error StartupMenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, c
 		needsRedraw = true;
 	};
 
+	auto runPasswordPrompt = [&]() -> Common::String {
+		Graphics::FrameLimiter promptLimiter(g_system, 60);
+		Common::String text;
+		bool needsPromptRedraw = true;
+		bool cursorVisible = true;
+		uint32 cursorToggleTicks = g_system->getMillis() + 400;
+
+		while (!_engine.shouldQuit()) {
+			if (needsPromptRedraw) {
+				renderOptionsMenuScreen(_engine, backdrop, palette, paletteBrightness,
+					selectedFont, unselectedFont, *art, config, volumeBar, indicator, selectedItem, false);
+
+				Graphics::Screen *screen = _engine.getScreen();
+				if (screen) {
+					const int titleWidth = selectedFont.getStringWidth("ENTER PASSWORD");
+					const int titleX = (screen->w - titleWidth) / 2;
+					selectedFont.drawString(screen, "ENTER PASSWORD", titleX, 0xa0, titleWidth, 0);
+
+					Common::String displayText = text;
+					if (cursorVisible)
+						displayText += "_";
+					unselectedFont.drawString(screen, displayText, kPasswordEntryX, kPasswordEntryY,
+						kPasswordEntryWidth, 0);
+					screen->makeAllDirty();
+					screen->update();
+				}
+
+				needsPromptRedraw = false;
+			}
+
+			Common::Event event;
+			while (g_system->getEventManager()->pollEvent(event)) {
+				Common::Error result = Common::kNoError;
+				if (startupFlow.handleSystemEvent(event, result))
+					return Common::String();
+
+				switch (event.type) {
+				case Common::EVENT_RBUTTONDOWN:
+					return Common::String();
+				case Common::EVENT_KEYDOWN:
+					if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+						return Common::String();
+					if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+							event.kbd.keycode == Common::KEYCODE_KP_ENTER)
+						return text;
+					if (event.kbd.keycode == Common::KEYCODE_BACKSPACE) {
+						if (!text.empty()) {
+							text.deleteLastChar();
+							needsPromptRedraw = true;
+						}
+						break;
+					}
+					if (appendBoundedTextEntryCharacter(text, unselectedFont, event.kbd.ascii,
+							kPasswordMaxCharacters, kPasswordEntryWidth)) {
+						needsPromptRedraw = true;
+					}
+					break;
+				default:
+					break;
+				}
+			}
+
+			const uint32 now = g_system->getMillis();
+			if ((int32)(now - cursorToggleTicks) >= 0) {
+				cursorVisible = !cursorVisible;
+				cursorToggleTicks = now + 400;
+				needsPromptRedraw = true;
+			}
+
+			promptLimiter.delayBeforeSwap();
+			promptLimiter.startFrame();
+		}
+
+		return Common::String();
+	};
+
 	auto togglePassword = [&]() {
 		if (startupScript->getParentalPassword().empty()) {
-			const Common::String password = runModalTextEntryDialog(backdrop, palette, paletteBrightness,
-				selectedFont, entryFont, "ENTER PASSWORD", Common::String(), startupFlow);
+			const Common::String password = runPasswordPrompt();
 			if (!password.empty()) {
 				startupScript->setParentalPassword(password);
 				persistConfig();
@@ -1554,79 +1564,6 @@ Common::Error StartupMenuSystem::runHelpScreen(const byte *palette, float palett
 	return Common::kNoError;
 }
 
-Common::String StartupMenuSystem::runModalTextEntryDialog(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, const Graphics::Font &titleFont, const Graphics::Font &entryFont,
-		const Common::String &title, const Common::String &initialText, StartupFlow &startupFlow) {
-	Graphics::FrameLimiter limiter(g_system, 60);
-	Common::String text = initialText;
-	const uint maxCharacters = 30;
-	bool needsRedraw = true;
-	bool cursorVisible = true;
-	uint32 cursorToggleTicks = g_system->getMillis() + 400;
-
-	while (!_engine.shouldQuit()) {
-		if (needsRedraw) {
-			renderTextEntryDialog(_engine, backdrop, palette, paletteBrightness, titleFont, entryFont,
-				title, text, cursorVisible, textEntryOkRect().contains(_mousePos), textEntryCancelRect().contains(_mousePos));
-			needsRedraw = false;
-		}
-
-		Common::Event event;
-		while (g_system->getEventManager()->pollEvent(event)) {
-			Common::Error result = Common::kNoError;
-			if (startupFlow.handleSystemEvent(event, result))
-				return Common::String();
-
-			switch (event.type) {
-			case Common::EVENT_MOUSEMOVE:
-				needsRedraw = true;
-				break;
-			case Common::EVENT_LBUTTONDOWN:
-				if (textEntryOkRect().contains(_mousePos))
-					return text;
-				if (textEntryCancelRect().contains(_mousePos))
-					return Common::String();
-				break;
-			case Common::EVENT_RBUTTONDOWN:
-				return Common::String();
-			case Common::EVENT_KEYDOWN:
-				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
-					return Common::String();
-				if (event.kbd.keycode == Common::KEYCODE_RETURN ||
-						event.kbd.keycode == Common::KEYCODE_KP_ENTER)
-					return text;
-				if (event.kbd.keycode == Common::KEYCODE_BACKSPACE) {
-					if (!text.empty()) {
-						text.deleteLastChar();
-						needsRedraw = true;
-					}
-					break;
-				}
-				if (appendTextEntryCharacter(text, entryFont, event.kbd.ascii, maxCharacters))
-					needsRedraw = true;
-				break;
-			default:
-				break;
-			}
-		}
-
-		const uint32 now = g_system->getMillis();
-		if ((int32)(now - cursorToggleTicks) >= 0) {
-			cursorVisible = !cursorVisible;
-			cursorToggleTicks = now + 400;
-			needsRedraw = true;
-		}
-
-		if (_engine.getRuntimeEntities() && _engine.getRuntimeEntities()->syncCursorEntityPosition(_mousePos))
-			needsRedraw = true;
-
-		limiter.delayBeforeSwap();
-		limiter.startFrame();
-	}
-
-	return Common::String();
-}
-
 void StartupMenuSystem::renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const {
 	Graphics::Screen *screen = _engine.getScreen();
 	const StartupArt *art = _engine.getStartupArt();
diff --git a/engines/harvester/startup_menu.h b/engines/harvester/startup_menu.h
index dc6e319d3f8..5676b4223cd 100644
--- a/engines/harvester/startup_menu.h
+++ b/engines/harvester/startup_menu.h
@@ -53,9 +53,6 @@ private:
 	Common::Error runSaveGameMenu(const byte *palette, float paletteBrightness, StartupFlow &startupFlow);
 	Common::Error runQuitGameConfirm(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, StartupFlow &startupFlow);
-	Common::String runModalTextEntryDialog(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, const Graphics::Font &titleFont, const Graphics::Font &entryFont,
-		const Common::String &title, const Common::String &initialText, StartupFlow &startupFlow);
 	void renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const;
 	void renderRoomMenuStub(const IndexedBitmap &backdrop, int selectedItem) const;
 	int getMenuItemAt(const Common::Point &mousePos) const;


Commit: 6f1803636631bf15f7172d47166e77a64c89ac74
    https://github.com/scummvm/scummvm/commit/6f1803636631bf15f7172d47166e77a64c89ac74
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:26+02:00

Commit Message:
HARVESTER: Restore jimmy response menus

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/jimmy_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index d356c6148a1..0fbbe7804e9 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -513,9 +513,13 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - The steady-state no-item bark chain is fully linear and one-shot gated: `STEPH_MIDGAME_PLAYED` -> `0xbf0` / `0xbf1` / `0xbf2`, `KARIN_KIDNAPED` -> `0xbff` / `0xc06` plus another `d2f04` write, `KARIN_FOUND_ALIVE` -> `0xc13` / `0xc17`, `BARBER_POLE_STOLEN` -> `0xc46` / `0xc4d` / `0xc59`, `DINER_BURNED` -> `0xc6f`, `PC_ESCAPED_JAIL` -> `0xc77`, `GOT_REMAINS_FOR_LODGE` -> `0xc7f`, and `BURNED_TV_STATION` -> `0xc8f`.
     - Johnson's keyword loop now has a stable branch map: `0x177` exits on `0xbda`, `0x178` / `0x179` play `0xaa6`, open zero-based `dialog.rsp` line `0x17a`, then continue through `0xabd` / `0xac3` / `0xac7` and rewrite the keyword buffer to `0x17b`; `0x17c` plays `0xb17` / `0xb1c` / `0xb20` and rewrites to `0x17d`; `0x17e` plays `0xb2d` and rewrites to `0x17f`; `0x180` / `0x181` / `0x182` play `0xb3a` and rewrite to `0x183`; `0x184` / `0x185` play `0xb46` and rewrite to `0x186`; `0x187` continues through `PC 0xb52` and `JOHNSON 0xb56`, then rewrites to `0x188`; `0x189` loops silently on the current buffer; fallback still uses `0xb69` then `0xbda`.
   - `handle_talk_to_jimmy @ 0x28880` is a compact short-circuit handler with no keyword loop.
-    - The first no-item visit clears `g_jimmy_first_no_item_line_pending` and returns immediately on either `0x4a4c` when the paper-handoff intro has not fired yet or `0x4a58` once `g_jimmy_talk_state_block` is already set.
-    - Presenting `NEWSPAPER` sets `GIVEN_PAPER_TODAY`, runs the `ACTV_HOUSE_EXIT` action tag, restores `NEWSPAPER` to its startup state, and then either sets `g_jimmy_talk_state_block` and returns on `0x4a4c` or picks one of the two revisit barks `0x4acc` / `0x4a4b`.
-    - The evidence-item path is linear: `PHOTO_OF_WHALEY_HERRILL` plays `0x4af8`, `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` and `CASKET_PHOTO` / `CASKET_PHOTOCOPY` converge on `0x4b00`, note/checkbook evidence plays `0x4b21`, `SNEAKERS` is the dedicated `0x4a9e` branch, and all other items fall back to `0x4af2`.
+    - The first no-item visit clears `g_jimmy_first_no_item_line_pending`, conditionally plays `0x4a4c` if `g_jimmy_talk_state_block` is still clear, then always continues through `0x4a58`, sets `PAPER_CHK_1` and `GIVEN_PAPER_TODAY`, and opens three response menus from zero-based `dialog.rsp` lines `0xf5`, `0xf6`, and `0xf7`.
+    - Jimmy's hidden first-meeting response map is now explicit:
+      - `0xf5` (`1. I've got better things to do./2. I'm supposed to?  I don't remember.`) selects `0x4a63` or `0x4a67`, then always continues through the fixed exchange `0x4a6b` / `PC 0x4a70` / `0x4a74` / `0x4a7a`.
+      - `0xf6` (`1. Yes, I do./2. Nope, sorry.`) sends response `1` into `0x4a86` / `PC 0x4a8a` / `0x4a8e` plus the third response menu `0xf7`, while response `2` exits that branch on `0x4abc`.
+      - `0xf7` (`1. Okay... take the sneakers./2. Forget it... what do I care about what they're doing?`) checks live inventory `SNEAKERS`: response `1` with sneakers plays `0x4a9e`, makes `SNEAKERS` visible for `RAH`, and adds `BROOMKEY` to inventory; response `1` without sneakers plays `0x4aa6` / `PC 0x4aaa` / `0x4aae`; response `2` plays `0x4ab4`.
+    - Presenting `NEWSPAPER` sets `GIVEN_PAPER_TODAY`, runs the `ACTV_HOUSE_EXIT` action tag, restores `NEWSPAPER` to its startup state, and on the first paper handoff plays `0x4a4c` before the random revisit bark `0x4acc` / `0x4a4b`.
+    - The evidence-item path is otherwise linear: `PHOTO_OF_WHALEY_HERRILL` plays `0x4af8`, `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` and `CASKET_PHOTO` / `CASKET_PHOTOCOPY` converge on `0x4b00`, note/checkbook evidence plays `0x4b21`, `SNEAKERS` plays `0x4a9e` then falls through to `0x4af2`, and all other items fall back to `0x4af2`.
     - Later no-item visits remain a straight bark chain over inventory `SNEAKERS` without `BROOMKEY`, then `PAPER_CHK_4`, `PAPER_CHK_3`, `PAPER_CHK_2`, and the default `0x4b38`.
   - `handle_talk_to_moynahan @ 0x24a20` is now bounded enough for a direct startup-room port.
     - `IF_TRY_TO_TAKE_THE_GLUE` is a strict early interrupt branch: it always plays `0x3e4d` / `0x3e52` / `0x3e56`, then either exits on `0x3e76` / `0x3e7a` or, once the slashes-on-body topic has been unlocked, continues through `0x3e5b` / `0x3e60` / `0x3e64` / `0x3e69` / `0x3e6d` / `0x3e71`.
diff --git a/TRACKER.md b/TRACKER.md
index a78fb9b756d..61029ac478d 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,9 +2,9 @@
 
 ## Current Focus
 
-- Keep the in-room ESC menu handlers aligned with the native SAVE / OPTIONS layout and font usage
-- Verify remaining menu overlays against Ghidra before introducing any more custom UI panels
-- Re-test room-menu edge cases after each menu fix instead of bundling multiple visual changes together
+- Re-test Jimmy's first no-item conversation in-engine against DOSBox now that the hidden response-menu chain is wired up
+- Verify any remaining NPC openings that were decompiled as single-line returns against raw post-`play_dialogue_line` blocks in Ghidra
+- Keep dialogue handler edits tied to confirmed `DIALOG.RSP` line indices and native state writes instead of inferred subtitle order
 
 ## Progress
 
@@ -15,13 +15,12 @@
 
 ## Last Confirmed Action
 
-- Revisited `run_main_menu`, `run_save_game_menu`, `prompt_for_password`, and `run_text_entry_dialog` in Ghidra and corrected the engine-side SAVE / OPTIONS menu mismatches they exposed.
-  - Native SAVE uses `MEDFONT2` for inactive `GAME_##` labels, `MEDFONT1` for the active label and every slot title, and relies on `SAVEGAME.BM` for the lower action-strip art instead of drawing extra `SAVE` / `CANCEL` text.
-  - Native save-name editing spawns `TEXT_ENTRY` inline at `(0x50, rowY)` with `g_medfont1_cft`, while password entry overlays the options menu with centered `ENTER PASSWORD` text in `g_harvfont_cft` and `TEXT_ENTRY` at `(0xdc, 0xdc)` in `g_harvfnt2_cft`.
-  - The password prompt's hidden max-length register is `8`, not the larger generic limit the engine had been using.
-- Updated `startup_menu.cpp` to match those native font/layout rules and rebuilt `engines/harvester/startup_menu.o` successfully.
+- Revisited `handle_talk_to_jimmy`, `run_dialogue_response_menu`, and the hidden post-`play_dialogue_line` Jimmy blocks in Ghidra after DOSBox showed the engine was stopping too early.
+  - Jimmy still has no keyword loop, but his first no-item conversation is not a single bark: native code plays `0x4a4c` / `0x4a58`, sets `PAPER_CHK_1` and `GIVEN_PAPER_TODAY`, then opens zero-based `DIALOG.RSP` response menus `0xf5`, `0xf6`, and `0xf7` in sequence.
+  - The third response-menu branch checks inventory `SNEAKERS`, restores them to `RAH`, and awards `BROOMKEY`; the direct `SNEAKERS` and first-time `NEWSPAPER` item paths also continue into extra native follow-up lines that the earlier decompile had hidden.
+- Updated `engines/harvester/npc/jimmy_dialogue.cpp` to follow that native response order and rebuilt `engines/harvester/npc/jimmy_dialogue.o` successfully.
 
 ## Next Suggested Action
 
-1. Re-test the in-room SAVE menu and OPTIONS password prompt in-engine and compare them against DOSBox captures for any remaining cursor or quick-tips overlay drift.
-2. If another menu artifact remains, capture the exact submenu state first and then inspect the corresponding `run_main_menu` branch in Ghidra before editing.
+1. Re-test Jimmy's first conversation from a fresh state and confirm the response menus appear in the native `0xf5 -> 0xf6 -> 0xf7` order with the same follow-up lines as DOSBox.
+2. If another NPC opener still stops after one line, inspect its raw xrefs to `run_dialogue_response_menu` and `load_dialogue_response_line` before trusting the decompiler output.
diff --git a/engines/harvester/npc/jimmy_dialogue.cpp b/engines/harvester/npc/jimmy_dialogue.cpp
index 8a3a4a1c3ab..ae9d603144b 100644
--- a/engines/harvester/npc/jimmy_dialogue.cpp
+++ b/engines/harvester/npc/jimmy_dialogue.cpp
@@ -29,6 +29,36 @@
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kJimmyNpc = "JIMMY";
+static const char *const kPcSpeaker = "PC";
+static const char *const kRahRoomName = "RAH";
+static const char *const kJimmyFirstMeetingFlag = "PAPER_CHK_1";
+static const int kJimmyFirstResponseLineIndex = 0xf5;
+static const int kJimmySecondResponseLineIndex = 0xf6;
+static const int kJimmyThirdResponseLineIndex = 0xf7;
+
+static const DialogueLineEntry kJimmyFirstMeetingFollowupLines[] = {
+	{ 0x4a6b, kJimmyNpc, 0 },
+	{ 0x4a70, kPcSpeaker, 0 },
+	{ 0x4a74, kJimmyNpc, 0 },
+	{ 0x4a7a, kJimmyNpc, 0 }
+};
+
+static const DialogueLineEntry kJimmySecondResponsePreludeLines[] = {
+	{ 0x4a8a, kPcSpeaker, 0 },
+	{ 0x4a8e, kJimmyNpc, 1 }
+};
+
+static const DialogueLineEntry kJimmyNoSneakersLines[] = {
+	{ 0x4aa6, kJimmyNpc, 0 },
+	{ 0x4aaa, kPcSpeaker, 0 },
+	{ 0x4aae, kJimmyNpc, 2 }
+};
+
+} // End of namespace
+
 bool JimmyDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("JIMMY");
 }
@@ -36,9 +66,13 @@ bool JimmyDialogueHandler::matchesNpc(const Common::String &npcName) const {
 Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &) {
 	JimmyRoomDialogueState &state = _state;
+	Common::Error lineError = Common::kNoError;
 
 	auto playJimmyLine = [&](int wavId, int headVariant) -> Common::Error {
-		return runtime.playDialogueLineWithVariant(wavId, "JIMMY", headVariant);
+		return runtime.playDialogueLineWithVariant(wavId, kJimmyNpc, headVariant);
+	};
+	auto playJimmySequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
+		return runtime.playDialogueEntrySequence(lines, count);
 	};
 	auto hasInventoryItem = [&](const char *objectName) {
 		Common::Array<StartupObjectRecord> inventoryObjects;
@@ -50,6 +84,18 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 		return false;
 	};
+	auto playJimmyNoItemFallback = [&]() -> Common::Error {
+		if (hasInventoryItem("SNEAKERS") && !hasInventoryItem("BROOMKEY"))
+			return playJimmyLine(0x4ac3, 0);
+		if (runtime.startupScript().getFlagValue("PAPER_CHK_4"))
+			return playJimmyLine(0x4ae2, 2);
+		if (runtime.startupScript().getFlagValue("PAPER_CHK_3"))
+			return playJimmyLine(0x4adb, 2);
+		if (runtime.startupScript().getFlagValue("PAPER_CHK_2"))
+			return playJimmyLine(0x4ad4, 2);
+
+		return playJimmyLine(0x4b38, 0);
+	};
 
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("NEWSPAPER")) {
@@ -88,7 +134,9 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 			if (!state.paperHandoffStateSet) {
 				state.paperHandoffStateSet = true;
-				return playJimmyLine(0x4a4c, 1);
+				lineError = playJimmyLine(0x4a4c, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
 			}
 
 			return runtime.getRandomNumber(1) == 0
@@ -120,7 +168,9 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return playJimmyLine(0x4b21, 0);
 		}
 		if (usedItemName.equalsIgnoreCase("SNEAKERS")) {
-			return playJimmyLine(0x4a9e, 1);
+			lineError = playJimmyLine(0x4a9e, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 		}
 
 		return playJimmyLine(0x4af2, 0);
@@ -128,20 +178,85 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 	if (state.firstNoItemLinePending) {
 		state.firstNoItemLinePending = false;
-		return !state.paperHandoffStateSet
-			? playJimmyLine(0x4a4c, 1)
-			: playJimmyLine(0x4a58, 0);
+		if (!state.paperHandoffStateSet) {
+			lineError = playJimmyLine(0x4a4c, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		lineError = playJimmyLine(0x4a58, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		(void)runtime.startupScript().setRuntimeFlagValue(kJimmyFirstMeetingFlag, true);
+		(void)runtime.startupScript().setRuntimeFlagValue("GIVEN_PAPER_TODAY", true);
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(kJimmyFirstResponseLineIndex, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playJimmyLine(0x4a63, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			lineError = playJimmyLine(0x4a67, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		lineError = playJimmySequence(kJimmyFirstMeetingFollowupLines, ARRAYSIZE(kJimmyFirstMeetingFollowupLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		responseIndex = 0;
+		responseError = runtime.runResponseMenu(kJimmySecondResponseLineIndex, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playJimmyLine(0x4a86, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			lineError = playJimmySequence(kJimmySecondResponsePreludeLines,
+				ARRAYSIZE(kJimmySecondResponsePreludeLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int thirdResponseIndex = 0;
+			responseError = runtime.runResponseMenu(kJimmyThirdResponseLineIndex, thirdResponseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (thirdResponseIndex == 1) {
+				if (hasInventoryItem("SNEAKERS")) {
+					lineError = playJimmyLine(0x4a9e, 1);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+
+					(void)runtime.startupScript().resetRuntimeObjectToInitialState("SNEAKERS");
+					(void)runtime.startupScript().setRuntimeObjectVisible(kRahRoomName, "SNEAKERS", true);
+					(void)runtime.startupScript().addRuntimeObjectToInventory("BROOMKEY");
+				} else {
+					lineError = playJimmySequence(kJimmyNoSneakersLines, ARRAYSIZE(kJimmyNoSneakersLines));
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				}
+			} else if (thirdResponseIndex == 2) {
+				lineError = playJimmyLine(0x4ab4, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+		} else if (responseIndex == 2) {
+			lineError = playJimmyLine(0x4abc, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
 	}
-	if (hasInventoryItem("SNEAKERS") && !hasInventoryItem("BROOMKEY"))
-		return playJimmyLine(0x4ac3, 0);
-	if (runtime.startupScript().getFlagValue("PAPER_CHK_4"))
-		return playJimmyLine(0x4ae2, 2);
-	if (runtime.startupScript().getFlagValue("PAPER_CHK_3"))
-		return playJimmyLine(0x4adb, 2);
-	if (runtime.startupScript().getFlagValue("PAPER_CHK_2"))
-		return playJimmyLine(0x4ad4, 2);
-
-	return playJimmyLine(0x4b38, 0);
+
+	return playJimmyNoItemFallback();
 }
 
 } // End of namespace Harvester


Commit: 1e7212104a9d6ea56d8eec33fb77913581c2e280
    https://github.com/scummvm/scummvm/commit/1e7212104a9d6ea56d8eec33fb77913581c2e280
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:26+02:00

Commit Message:
HARVESTER: Strip Startup prefix from helper classes

Changed paths:
    engines/harvester/dialogue.cpp
    engines/harvester/dialogue.h
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/npc/dialogue_runtime.h
    engines/harvester/startup_art.cpp
    engines/harvester/startup_art.h
    engines/harvester/startup_flow.cpp
    engines/harvester/startup_flow.h
    engines/harvester/startup_inventory.cpp
    engines/harvester/startup_inventory.h
    engines/harvester/startup_menu.cpp
    engines/harvester/startup_menu.h
    engines/harvester/startup_room.cpp
    engines/harvester/startup_room.h
    engines/harvester/startup_room_support.h
    engines/harvester/startup_script.cpp
    engines/harvester/startup_script.h
    engines/harvester/startup_text.cpp
    engines/harvester/startup_text.h


diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index ce7291fe5a6..812adcf7f57 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -126,7 +126,7 @@ static const byte kShadowColor = 0;
 static const byte kTransparentPaletteIndex = 0;
 
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
-	const StartupText *startupText = engine.getStartupText();
+	const Text *startupText = engine.getStartupText();
 	if (!startupText || !fontName)
 		return nullptr;
 
@@ -304,7 +304,7 @@ static Common::String buildDialogueHeadId(const Common::String &speakerId, int h
 
 static bool loadDialogueHeadBitmap(HarvesterEngine &engine, const Common::String &speakerId, int headVariant,
 		IndexedBitmap &bitmap) {
-	StartupScript *startupScript = engine.getStartupScript();
+	Script *startupScript = engine.getStartupScript();
 	ResourceManager *resources = engine.getResources();
 	if (!startupScript || !resources || speakerId.empty())
 		return false;
@@ -317,7 +317,7 @@ static bool loadDialogueHeadBitmap(HarvesterEngine &engine, const Common::String
 	return loadBitmapResource(*resources, head->portraitPath + ".BM", bitmap);
 }
 
-static Common::String buildDialogueVoicePath(const StartupScript &startupScript, int wavId) {
+static Common::String buildDialogueVoicePath(const Script &startupScript, int wavId) {
 	if (wavId <= 0)
 		return Common::String();
 
@@ -415,7 +415,7 @@ void DialogueSystem::resetRoomNpcDialogueState() {
 
 Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, const StartupNpcRecord &npc, const Common::String &usedItemName,
-		StartupFlow &startupFlow) {
+		Flow &startupFlow) {
 	struct DialogueResponseOptionLayout {
 		Common::String text;
 		Common::Array<Common::String> wrappedLines;
@@ -424,9 +424,9 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 	};
 
 	Graphics::Screen *screen = _engine.getScreen();
-	StartupScript *startupScript = _engine.getStartupScript();
-	StartupText *startupText = _engine.getStartupText();
-	StartupArt *startupArt = _engine.getStartupArt();
+	Script *startupScript = _engine.getStartupScript();
+	Text *startupText = _engine.getStartupText();
+	Art *startupArt = _engine.getStartupArt();
 	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
 	const Graphics::Font *fallbackFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
 	if (!screen || !startupScript || !startupText || !startupArt || !fallbackFont || !backdrop.isValid())
diff --git a/engines/harvester/dialogue.h b/engines/harvester/dialogue.h
index ba0d3710da7..e7bce5419f3 100644
--- a/engines/harvester/dialogue.h
+++ b/engines/harvester/dialogue.h
@@ -37,8 +37,8 @@ namespace Harvester {
 
 class HarvesterEngine;
 class NpcDialogueHandler;
-class StartupFlow;
-class StartupText;
+class Flow;
+class Text;
 struct IndexedBitmap;
 
 class DialogueSystem {
@@ -49,7 +49,7 @@ public:
 	void resetRoomNpcDialogueState();
 	Common::Error runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, const StartupNpcRecord &npc, const Common::String &usedItemName,
-		StartupFlow &startupFlow);
+		Flow &startupFlow);
 
 private:
 	void registerNpcHandlers();
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 8372d212d34..431b49a30d3 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -552,7 +552,7 @@ Common::Error HarvesterEngine::run() {
 	_resources = new ResourceManager();
 	_resources->mountStartupArchives();
 	_runtimeEntities = new RuntimeEntityManager(*_resources);
-	_startupScript = new StartupScript();
+	_startupScript = new Script();
 	if (!_startupScript->load(*_resources))
 		return Common::kReadingFailed;
 	applyStartupMixerLevels();
@@ -576,13 +576,13 @@ Common::Error HarvesterEngine::run() {
 	// The original executable switches back to the gameplay UI surface after INTROFIN.FST.
 	setDisplayMode(640, 480);
 
-	_startupArt = new StartupArt();
+	_startupArt = new Art();
 	if (!_startupArt->load(*_resources))
 		return Common::kReadingFailed;
 	if (_screen)
 		_startupArt->drawWaitFrame(*_screen);
 
-	_startupText = new StartupText();
+	_startupText = new Text();
 	if (!_startupText->load(*_resources))
 		return Common::kReadingFailed;
 
@@ -594,7 +594,7 @@ Common::Error HarvesterEngine::run() {
 	if (saveSlot != -1)
 		(void)loadGameState(saveSlot);
 
-	StartupFlow startupFlow(*this);
+	Flow startupFlow(*this);
 	if (!startupFlow.load())
 		return Common::kReadingFailed;
 
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 8f35ee09489..e3df22722f2 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -37,9 +37,9 @@ namespace Harvester {
 
 class ResourceManager;
 class RuntimeEntityManager;
-class StartupScript;
-class StartupArt;
-class StartupText;
+class Script;
+class Art;
+class Text;
 
 struct StartupSaveRoomState {
 	Common::String entranceName;
@@ -99,9 +99,9 @@ public:
 	Graphics::Screen *getScreen() const { return _screen; }
 	int getDisplayWidth() const { return _displayWidth; }
 	int getDisplayHeight() const { return _displayHeight; }
-	StartupScript *getStartupScript() const { return _startupScript; }
-	StartupArt *getStartupArt() const { return _startupArt; }
-	StartupText *getStartupText() const { return _startupText; }
+	Script *getStartupScript() const { return _startupScript; }
+	Art *getStartupArt() const { return _startupArt; }
+	Text *getStartupText() const { return _startupText; }
 	void setDisplayMode(int width, int height);
 	bool isGoreEnabled() const;
 	int getStartupFxVolumeLevel() const;
@@ -162,9 +162,9 @@ private:
 	int _displayHeight = 0;
 	ResourceManager *_resources = nullptr;
 	RuntimeEntityManager *_runtimeEntities = nullptr;
-	StartupScript *_startupScript = nullptr;
-	StartupArt *_startupArt = nullptr;
-	StartupText *_startupText = nullptr;
+	Script *_startupScript = nullptr;
+	Art *_startupArt = nullptr;
+	Text *_startupText = nullptr;
 	StartupSaveRoomState _currentStartupSaveRoomState;
 	StartupSaveRoomState _pendingLoadedStartupSaveRoomState;
 };
diff --git a/engines/harvester/npc/dialogue_runtime.h b/engines/harvester/npc/dialogue_runtime.h
index e3ec0c4f9fd..e3906ba274d 100644
--- a/engines/harvester/npc/dialogue_runtime.h
+++ b/engines/harvester/npc/dialogue_runtime.h
@@ -10,8 +10,8 @@
 namespace Harvester {
 
 class HarvesterEngine;
-class StartupFlow;
-class StartupText;
+class Flow;
+class Text;
 
 struct DialogueLineEntry {
 	int wavId;
@@ -36,8 +36,8 @@ public:
 	typedef std::function<int(int)> GetRandomNumberFn;
 	typedef std::function<void(const Common::String &, int)> SetActiveSpeakerPortraitFn;
 
-	DialogueRuntime(HarvesterEngine &engine, StartupScript &startupScript, StartupText &startupText,
-			StartupFlow &startupFlow, const Common::String &currentRoomName,
+	DialogueRuntime(HarvesterEngine &engine, Script &startupScript, Text &startupText,
+			Flow &startupFlow, const Common::String &currentRoomName,
 			const Common::String &genericByeTopic,
 			const PlayDialogueLineWithVariantFn &playDialogueLineWithVariant,
 			const PlayDialogueLineFn &playDialogueLine,
@@ -70,9 +70,9 @@ public:
 	}
 
 	HarvesterEngine &engine() const { return _engine; }
-	StartupScript &startupScript() const { return _startupScript; }
-	StartupText &startupText() const { return _startupText; }
-	StartupFlow &startupFlow() const { return _startupFlow; }
+	Script &startupScript() const { return _startupScript; }
+	Text &startupText() const { return _startupText; }
+	Flow &startupFlow() const { return _startupFlow; }
 	const Common::String &currentRoomName() const { return _currentRoomName; }
 	const Common::String &genericByeTopic() const { return _genericByeTopic; }
 
@@ -121,9 +121,9 @@ public:
 
 private:
 	HarvesterEngine &_engine;
-	StartupScript &_startupScript;
-	StartupText &_startupText;
-	StartupFlow &_startupFlow;
+	Script &_startupScript;
+	Text &_startupText;
+	Flow &_startupFlow;
 	const Common::String &_currentRoomName;
 	const Common::String &_genericByeTopic;
 	PlayDialogueLineWithVariantFn _playDialogueLineWithVariant;
diff --git a/engines/harvester/startup_art.cpp b/engines/harvester/startup_art.cpp
index c4355c768ac..73060db99b5 100644
--- a/engines/harvester/startup_art.cpp
+++ b/engines/harvester/startup_art.cpp
@@ -94,7 +94,7 @@ static const char *const kAmmoIconPaths[] = {
 
 } // End of anonymous namespace
 
-bool StartupArt::load(ResourceManager &resources) {
+bool Art::load(ResourceManager &resources) {
 	_waitFrames.clear();
 	_textboxes.clear();
 	_ammoIcons.clear();
@@ -108,7 +108,7 @@ bool StartupArt::load(ResourceManager &resources) {
 	       loadBitmap(resources, "1:/GRAPHIC/OTHER/HARVLOGO.BM", _logoBitmap);
 }
 
-bool StartupArt::loadQuickTipsResources(ResourceManager &resources) {
+bool Art::loadQuickTipsResources(ResourceManager &resources) {
 	_textboxes.resize(ARRAYSIZE(kTextboxPaths));
 	for (uint i = 0; i < _textboxes.size(); ++i) {
 		if (!loadBitmap(resources, kTextboxPaths[i], _textboxes[i]))
@@ -124,7 +124,7 @@ bool StartupArt::loadQuickTipsResources(ResourceManager &resources) {
 	return loadBitmap(resources, "1:/GRAPHIC/OTHER/TIPS.BM", _tipsBitmap);
 }
 
-void StartupArt::drawWaitFrame(Graphics::Screen &screen) const {
+void Art::drawWaitFrame(Graphics::Screen &screen) const {
 	if (_waitFrames.empty() || !_waitFrames[0].isValid())
 		return;
 
@@ -137,7 +137,7 @@ void StartupArt::drawWaitFrame(Graphics::Screen &screen) const {
 	screen.update();
 }
 
-bool StartupArt::loadPalette(ResourceManager &resources, const Common::String &path, byte *dest) const {
+bool Art::loadPalette(ResourceManager &resources, const Common::String &path, byte *dest) const {
 	Common::Array<byte> data;
 	if (!resources.loadFile(path, data) || data.size() < 256 * 3) {
 		warning("Harvester: unable to load palette '%s'", path.c_str());
@@ -150,7 +150,7 @@ bool StartupArt::loadPalette(ResourceManager &resources, const Common::String &p
 	return true;
 }
 
-bool StartupArt::loadBitmap(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) const {
+bool Art::loadBitmap(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) const {
 	Common::Array<byte> data;
 	if (!resources.loadFile(path, data) || data.size() < 12) {
 		warning("Harvester: unable to load bitmap '%s'", path.c_str());
@@ -170,7 +170,7 @@ bool StartupArt::loadBitmap(ResourceManager &resources, const Common::String &pa
 	return true;
 }
 
-bool StartupArt::loadAnimation(ResourceManager &resources, const Common::String &path, Common::Array<AbmFrame> &frames) const {
+bool Art::loadAnimation(ResourceManager &resources, const Common::String &path, Common::Array<AbmFrame> &frames) const {
 	Common::Array<byte> data;
 	if (!resources.loadFile(path, data) || data.size() < 8) {
 		warning("Harvester: unable to load animation '%s'", path.c_str());
@@ -213,7 +213,7 @@ bool StartupArt::loadAnimation(ResourceManager &resources, const Common::String
 	return true;
 }
 
-bool StartupArt::decodeAnimationFrame(const byte *source, uint32 sourceSize, bool compressed, Common::Array<byte> &dest) const {
+bool Art::decodeAnimationFrame(const byte *source, uint32 sourceSize, bool compressed, Common::Array<byte> &dest) const {
 	if (!compressed) {
 		if (sourceSize < dest.size())
 			return false;
@@ -245,7 +245,7 @@ bool StartupArt::decodeAnimationFrame(const byte *source, uint32 sourceSize, boo
 	return dstOffset == dest.size();
 }
 
-void StartupArt::blitTransparentBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) const {
+void Art::blitTransparentBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) const {
 	if (!bitmap.isValid())
 		return;
 
@@ -280,7 +280,7 @@ void StartupArt::blitTransparentBitmap(Graphics::Screen &screen, const IndexedBi
 		screen.format.bytesPerPixel, kTransparentPaletteIndex);
 }
 
-void StartupArt::blitTransparentAnimationFrame(Graphics::Screen &screen, const Common::Array<AbmFrame> &frames,
+void Art::blitTransparentAnimationFrame(Graphics::Screen &screen, const Common::Array<AbmFrame> &frames,
 		uint frameIndex, int x, int y) const {
 	if (frameIndex >= frames.size() || !frames[frameIndex].isValid())
 		return;
diff --git a/engines/harvester/startup_art.h b/engines/harvester/startup_art.h
index 449894f930e..2f314a13dc3 100644
--- a/engines/harvester/startup_art.h
+++ b/engines/harvester/startup_art.h
@@ -47,7 +47,7 @@ struct AbmFrame : IndexedBitmap {
 	int32 yOffset = 0;
 };
 
-class StartupArt {
+class Art {
 public:
 	bool load(ResourceManager &resources);
 	bool loadQuickTipsResources(ResourceManager &resources);
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/startup_flow.cpp
index d532d287c8a..3db5cb9e4ae 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/startup_flow.cpp
@@ -773,7 +773,7 @@ static bool isBackgroundSceneObject(const StartupObjectRecord &object, const Run
 		entity.getBoundsWidth() == 640 && entity.getBoundsHeight() == 480;
 }
 
-static bool isInteractiveSceneHotspot(const StartupObjectRecord &object, StartupScript *startupScript) {
+static bool isInteractiveSceneHotspot(const StartupObjectRecord &object, Script *startupScript) {
 	if (object.operatable || !object.actionTag.empty())
 		return true;
 	if (!startupScript)
@@ -786,7 +786,7 @@ static bool isInteractiveSceneHotspot(const StartupObjectRecord &object, Startup
 }
 
 static int resolveSceneObjectClass(const StartupObjectRecord &object, const RuntimeEntity *entity,
-		StartupScript *startupScript) {
+		Script *startupScript) {
 	if (entity)
 		return isBackgroundSceneObject(object, *entity) ? kRuntimeEntityClassBackground : kRuntimeEntityClassObject;
 
@@ -973,7 +973,7 @@ static const StartupNpcRecord *findRoomNpcAtPoint(HarvesterEngine &engine,
 	return findSceneNpcByName(sceneNpcs, entity->getName());
 }
 
-const IndexedBitmap *resolveInspectTextboxBitmap(const StartupArt &art, const StartupResolvedText &text) {
+const IndexedBitmap *resolveInspectTextboxBitmap(const Art &art, const StartupResolvedText &text) {
 	if (text.boxName.equalsIgnoreCase("BOX1"))
 		return art.getTextboxBitmap(0);
 	if (text.boxName.equalsIgnoreCase("BOX2"))
@@ -986,7 +986,7 @@ const IndexedBitmap *resolveInspectTextboxBitmap(const StartupArt &art, const St
 	return nullptr;
 }
 
-void drawRoomInspectText(Graphics::Screen &screen, const StartupArt &art, const Graphics::Font &font,
+void drawRoomInspectText(Graphics::Screen &screen, const Art &art, const Graphics::Font &font,
 		const StartupResolvedText &inspectText) {
 	const IndexedBitmap *textbox = resolveInspectTextboxBitmap(art, inspectText);
 	if (!textbox || !textbox->isValid())
@@ -1001,11 +1001,11 @@ void drawRoomInspectText(Graphics::Screen &screen, const StartupArt &art, const
 }
 
 bool unlocksRoomObjectInteractionAfterInitialExamine(const StartupObjectRecord &object,
-		StartupScript &startupScript) {
+		Script &startupScript) {
 	return object.operatable || startupScript.isPickupObject(object);
 }
 
-static int resolveRoomObjectCursorSequence(const StartupObjectRecord &object, StartupScript &startupScript) {
+static int resolveRoomObjectCursorSequence(const StartupObjectRecord &object, Script &startupScript) {
 	if (object.objectName.equalsIgnoreCase("EXIT_BM") || object.objectName.equalsIgnoreCase("EXIT_HS"))
 		return kCursorSequenceTransition;
 
@@ -1026,7 +1026,7 @@ static int resolveRoomObjectCursorSequence(const StartupObjectRecord &object, St
 	return kCursorSequenceNeutral;
 }
 
-static Common::String buildRoomObjectPrompt(const StartupObjectRecord &object, StartupScript &startupScript,
+static Common::String buildRoomObjectPrompt(const StartupObjectRecord &object, Script &startupScript,
 		int cursorSequence) {
 	const Common::String label = startupScript.resolveObjectLabel(object);
 	if (label.empty())
@@ -1088,7 +1088,7 @@ StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const Start
 		return hoverState;
 	}
 
-	StartupScript *startupScript = engine.getStartupScript();
+	Script *startupScript = engine.getStartupScript();
 	if (!startupScript)
 		return hoverState;
 	hoverState.object = findRoomObjectAtPoint(engine, sceneObjects, mousePos);
@@ -1527,7 +1527,7 @@ static bool findRoomObjectProbePoint(HarvesterEngine &engine, const Common::Arra
 void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResources &scene,
 		const Common::String &entranceName, Common::Point &mousePos) {
 	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
-	StartupScript *startupScript = engine.getStartupScript();
+	Script *startupScript = engine.getStartupScript();
 	if (!runtimeEntities || !startupScript)
 		return;
 
@@ -1661,9 +1661,9 @@ static bool loadQuickTipsScene(HarvesterEngine &engine, StartupRoomSceneResource
 static void renderQuickTipsScreen(HarvesterEngine &engine, const StartupRoomSceneResources &scene,
 		const Common::Point &mousePos, const Common::String &tipText) {
 	Graphics::Screen *screen = engine.getScreen();
-	const StartupArt *art = engine.getStartupArt();
+	const Art *art = engine.getStartupArt();
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	StartupScript *startupScript = engine.getStartupScript();
+	Script *startupScript = engine.getStartupScript();
 	if (!screen || !art || !font || !startupScript)
 		return;
 
@@ -1689,16 +1689,16 @@ static void renderQuickTipsScreen(HarvesterEngine &engine, const StartupRoomScen
 	screen->update();
 }
 
-StartupFlow::StartupFlow(HarvesterEngine &engine)
+Flow::Flow(HarvesterEngine &engine)
 	: _engine(engine), _mousePos(320, 200), _dialogue(engine, _mousePos), _inventory(engine),
 	  _menu(engine, _mousePos, _menuItems), _room(engine, _mousePos, _inventory) {
 }
 
-bool StartupFlow::load() {
+bool Flow::load() {
 	return loadQuickTips() && loadMenuItems();
 }
 
-Common::Error StartupFlow::run() {
+Common::Error Flow::run() {
 	if (!ensureCursorEntity())
 		return Common::kReadingFailed;
 
@@ -1731,7 +1731,7 @@ Common::Error StartupFlow::run() {
 	return runMainMenuStub();
 }
 
-bool StartupFlow::loadQuickTips() {
+bool Flow::loadQuickTips() {
 	_quickTips.clear();
 
 	Common::Array<byte> data;
@@ -1764,7 +1764,7 @@ bool StartupFlow::loadQuickTips() {
 	return true;
 }
 
-bool StartupFlow::loadMenuItems() {
+bool Flow::loadMenuItems() {
 	_menuItems.clear();
 
 	Common::Array<byte> data;
@@ -1794,7 +1794,7 @@ bool StartupFlow::loadMenuItems() {
 	return true;
 }
 
-Common::Error StartupFlow::runQuickTips() {
+Common::Error Flow::runQuickTips() {
 	if (!_engine.getStartupScript()->isQuickTipsEnabled() || _quickTips.empty())
 		return Common::kNoError;
 
@@ -1876,26 +1876,26 @@ Common::Error StartupFlow::runQuickTips() {
 	return Common::kNoError;
 }
 
-Common::Error StartupFlow::runMainMenuStub() {
+Common::Error Flow::runMainMenuStub() {
 	return _menu.runMainMenuStub(*this);
 }
 
-Common::Error StartupFlow::runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette, float paletteBrightness) {
+Common::Error Flow::runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette, float paletteBrightness) {
 	return _menu.runRoomMenuStub(backdrop, palette, paletteBrightness, *this);
 }
 
-Common::Error StartupFlow::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
+Common::Error Flow::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, const StartupNpcRecord &npc, const Common::String &usedItemName) {
 	_queuedDialogueInteraction = StartupInteractionResult();
 	_hasQueuedDialogueInteraction = false;
 	return _dialogue.runRoomNpcDialogue(backdrop, palette, paletteBrightness, npc, usedItemName, *this);
 }
 
-Common::Error StartupFlow::runTownMapSelector(const Common::String &mapEntryName,
+Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 		Common::String &destinationEntranceName) {
 	destinationEntranceName.clear();
 
-	StartupScript *startupScript = _engine.getStartupScript();
+	Script *startupScript = _engine.getStartupScript();
 	ResourceManager *resources = _engine.getResources();
 	Graphics::Screen *screen = _engine.getScreen();
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
@@ -2032,11 +2032,11 @@ Common::Error StartupFlow::runTownMapSelector(const Common::String &mapEntryName
 	return Common::kNoError;
 }
 
-Common::Error StartupFlow::resolveRoomTransitionTarget(const Common::String &targetName,
+Common::Error Flow::resolveRoomTransitionTarget(const Common::String &targetName,
 		Common::String &resolvedTargetName) {
 	resolvedTargetName = targetName;
 
-	StartupScript *startupScript = _engine.getStartupScript();
+	Script *startupScript = _engine.getStartupScript();
 	if (!startupScript || targetName.empty())
 		return Common::kNoError;
 
@@ -2046,12 +2046,12 @@ Common::Error StartupFlow::resolveRoomTransitionTarget(const Common::String &tar
 	return Common::kNoError;
 }
 
-void StartupFlow::queueDialogueInteraction(const StartupInteractionResult &interaction) {
+void Flow::queueDialogueInteraction(const StartupInteractionResult &interaction) {
 	_queuedDialogueInteraction = interaction;
 	_hasQueuedDialogueInteraction = true;
 }
 
-bool StartupFlow::takeQueuedDialogueInteraction(StartupInteractionResult &interaction) {
+bool Flow::takeQueuedDialogueInteraction(StartupInteractionResult &interaction) {
 	if (!_hasQueuedDialogueInteraction)
 		return false;
 
@@ -2061,30 +2061,30 @@ bool StartupFlow::takeQueuedDialogueInteraction(StartupInteractionResult &intera
 	return true;
 }
 
-void StartupFlow::requestMainMenuReturn() {
+void Flow::requestMainMenuReturn() {
 	_pendingMainMenuReturn = true;
 	_engine.clearCurrentStartupSaveRoomState();
 }
 
-bool StartupFlow::hasPendingMainMenuReturn() const {
+bool Flow::hasPendingMainMenuReturn() const {
 	return _pendingMainMenuReturn;
 }
 
-bool StartupFlow::takePendingMainMenuReturn() {
+bool Flow::takePendingMainMenuReturn() {
 	const bool requested = _pendingMainMenuReturn;
 	_pendingMainMenuReturn = false;
 	return requested;
 }
 
-void StartupFlow::clearPendingMainMenuReturn() {
+void Flow::clearPendingMainMenuReturn() {
 	_pendingMainMenuReturn = false;
 }
 
-Common::Error StartupFlow::runRoomLoop(const Common::String &entranceName) {
+Common::Error Flow::runRoomLoop(const Common::String &entranceName) {
 	return _room.runRoomLoop(*this, entranceName);
 }
 
-bool StartupFlow::ensureCursorEntity() {
+bool Flow::ensureCursorEntity() {
 	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
 	if (!runtimeEntities)
 		return false;
@@ -2094,7 +2094,7 @@ bool StartupFlow::ensureCursorEntity() {
 	return runtimeEntities->spawnCursorEntity(_mousePos) != nullptr;
 }
 
-bool StartupFlow::populateRoomSceneEntities(const StartupRoomSetupState &state,
+bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 		const Common::Array<StartupObjectRecord> &drawableObjects,
 		const Common::Array<StartupAnimRecord> &drawableAnimations) {
 	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
@@ -2264,11 +2264,11 @@ bool StartupFlow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 	return true;
 }
 
-Common::Error StartupFlow::beginRoomSetupTransition() {
+Common::Error Flow::beginRoomSetupTransition() {
 	if (_engine.getRuntimeEntities())
 		_engine.getRuntimeEntities()->hideCursor();
 
-	const StartupArt *art = _engine.getStartupArt();
+	const Art *art = _engine.getStartupArt();
 	Graphics::Screen *screen = _engine.getScreen();
 	if (art && screen)
 		art->drawWaitFrame(*screen);
@@ -2280,7 +2280,7 @@ Common::Error StartupFlow::beginRoomSetupTransition() {
 	return Common::kNoError;
 }
 
-Common::Error StartupFlow::fadeInRoomScene(const byte *palette, float targetBrightness) {
+Common::Error Flow::fadeInRoomScene(const byte *palette, float targetBrightness) {
 	Graphics::Screen *screen = _engine.getScreen();
 	if (!screen || !palette)
 		return Common::kNoError;
@@ -2305,7 +2305,7 @@ Common::Error StartupFlow::fadeInRoomScene(const byte *palette, float targetBrig
 	return Common::kNoError;
 }
 
-bool StartupFlow::pumpTransitionEvents(Common::Error &result) {
+bool Flow::pumpTransitionEvents(Common::Error &result) {
 	Common::Event event;
 	while (g_system->getEventManager()->pollEvent(event)) {
 		if (handleSystemEvent(event, result))
@@ -2315,16 +2315,16 @@ bool StartupFlow::pumpTransitionEvents(Common::Error &result) {
 	return false;
 }
 
-void StartupFlow::executeStartupAudioCommands(const Common::Array<StartupAudioCommand> &commands) {
+void Flow::executeStartupAudioCommands(const Common::Array<StartupAudioCommand> &commands) {
 	for (const StartupAudioCommand &command : commands)
 		(void)_engine.executeStartupAudioCommand(command);
 }
 
-void StartupFlow::resetRoomNpcDialogueState() {
+void Flow::resetRoomNpcDialogueState() {
 	_dialogue.resetRoomNpcDialogueState();
 }
 
-void StartupFlow::resetCursorAnimationSequence() {
+void Flow::resetCursorAnimationSequence() {
 	if (!ensureCursorEntity())
 		return;
 
@@ -2337,14 +2337,14 @@ void StartupFlow::resetCursorAnimationSequence() {
 	(void)_engine.getRuntimeEntities()->syncCursorEntityPosition(_mousePos);
 }
 
-bool StartupFlow::tickRuntimeEntities() {
+bool Flow::tickRuntimeEntities() {
 	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
 	if (!runtimeEntities)
 		return false;
 	return runtimeEntities->tickSceneEntities() || runtimeEntities->syncCursorEntityPosition(_mousePos);
 }
 
-bool StartupFlow::handleSystemEvent(const Common::Event &event, Common::Error &result) {
+bool Flow::handleSystemEvent(const Common::Event &event, Common::Error &result) {
 	switch (event.type) {
 	case Common::EVENT_QUIT:
 	case Common::EVENT_RETURN_TO_LAUNCHER:
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/startup_flow.h
index 181a6c31f80..fec814a2be2 100644
--- a/engines/harvester/startup_flow.h
+++ b/engines/harvester/startup_flow.h
@@ -41,18 +41,18 @@ namespace Harvester {
 class HarvesterEngine;
 struct IndexedBitmap;
 
-class StartupFlow {
+class Flow {
 public:
-	explicit StartupFlow(HarvesterEngine &engine);
+	explicit Flow(HarvesterEngine &engine);
 
 	bool load();
 	Common::Error run();
 
 private:
 	friend class DialogueSystem;
-	friend class StartupInventorySystem;
-	friend class StartupMenuSystem;
-	friend class StartupRoomSystem;
+	friend class InventorySystem;
+	friend class MenuSystem;
+	friend class RoomSystem;
 
 	bool loadQuickTips();
 	bool loadMenuItems();
@@ -88,9 +88,9 @@ private:
 	Common::Array<Common::String> _menuItems;
 	Common::Point _mousePos;
 	DialogueSystem _dialogue;
-	StartupInventorySystem _inventory;
-	StartupMenuSystem _menu;
-	StartupRoomSystem _room;
+	InventorySystem _inventory;
+	MenuSystem _menu;
+	RoomSystem _room;
 	StartupInteractionResult _queuedDialogueInteraction;
 	bool _hasQueuedDialogueInteraction = false;
 	bool _pendingMainMenuReturn = false;
diff --git a/engines/harvester/startup_inventory.cpp b/engines/harvester/startup_inventory.cpp
index 445060f9527..618e439302f 100644
--- a/engines/harvester/startup_inventory.cpp
+++ b/engines/harvester/startup_inventory.cpp
@@ -122,13 +122,13 @@ static bool loadBitmapResource(ResourceManager &resources, const Common::String
 
 } // End of anonymous namespace
 
-StartupInventorySystem::StartupInventorySystem(HarvesterEngine &engine) : _engine(engine) {
+InventorySystem::InventorySystem(HarvesterEngine &engine) : _engine(engine) {
 }
 
-bool StartupInventorySystem::refresh() {
+bool InventorySystem::refresh() {
 	_items.clear();
 
-	StartupScript *startupScript = _engine.getStartupScript();
+	Script *startupScript = _engine.getStartupScript();
 	ResourceManager *resources = _engine.getResources();
 	if (!startupScript || !resources)
 		return false;
@@ -195,14 +195,14 @@ bool StartupInventorySystem::refresh() {
 	return true;
 }
 
-bool StartupInventorySystem::open() {
+bool InventorySystem::open() {
 	const bool wasOpen = _open;
 	_open = true;
 	_promptText.clear();
 	return refresh() || !wasOpen;
 }
 
-bool StartupInventorySystem::close() {
+bool InventorySystem::close() {
 	if (!_open)
 		return false;
 
@@ -210,7 +210,7 @@ bool StartupInventorySystem::close() {
 	return true;
 }
 
-bool StartupInventorySystem::clearSelection() {
+bool InventorySystem::clearSelection() {
 	if (_selectedItemName.empty() && _promptText.empty())
 		return false;
 
@@ -219,20 +219,20 @@ bool StartupInventorySystem::clearSelection() {
 	return true;
 }
 
-bool StartupInventorySystem::isOpen() const {
+bool InventorySystem::isOpen() const {
 	return _open;
 }
 
-bool StartupInventorySystem::hasSelection() const {
+bool InventorySystem::hasSelection() const {
 	return !_selectedItemName.empty();
 }
 
-const Common::String &StartupInventorySystem::getSelectedItemName() const {
+const Common::String &InventorySystem::getSelectedItemName() const {
 	return _selectedItemName;
 }
 
-Common::String StartupInventorySystem::resolveSelectedLabel() const {
-	StartupScript *startupScript = _engine.getStartupScript();
+Common::String InventorySystem::resolveSelectedLabel() const {
+	Script *startupScript = _engine.getStartupScript();
 	if (!startupScript || _selectedItemName.empty())
 		return Common::String();
 
@@ -251,23 +251,23 @@ Common::String StartupInventorySystem::resolveSelectedLabel() const {
 	return normalizeHarvesterResourcePath(_selectedItemName);
 }
 
-Common::String StartupInventorySystem::buildSelectedPrompt(const Common::String &targetLabel) const {
+Common::String InventorySystem::buildSelectedPrompt(const Common::String &targetLabel) const {
 	return buildUseItemPrompt(resolveSelectedLabel(), targetLabel);
 }
 
-void StartupInventorySystem::selectItem(const Common::String &objectName) {
+void InventorySystem::selectItem(const Common::String &objectName) {
 	_selectedItemName = objectName;
 }
 
-void StartupInventorySystem::setPromptText(const Common::String &promptText) {
+void InventorySystem::setPromptText(const Common::String &promptText) {
 	_promptText = promptText;
 }
 
-const Common::String &StartupInventorySystem::getPromptText() const {
+const Common::String &InventorySystem::getPromptText() const {
 	return _promptText;
 }
 
-const StartupInventoryVisual *StartupInventorySystem::findItemAtPoint(const Common::Point &point) const {
+const StartupInventoryVisual *InventorySystem::findItemAtPoint(const Common::Point &point) const {
 	for (int i = (int)_items.size() - 1; i >= 0; --i) {
 		if (_items[i].bounds.contains(point))
 			return &_items[i];
@@ -276,8 +276,8 @@ const StartupInventoryVisual *StartupInventorySystem::findItemAtPoint(const Comm
 	return nullptr;
 }
 
-Common::Rect StartupInventorySystem::getPanelBounds() const {
-	const StartupArt *art = _engine.getStartupArt();
+Common::Rect InventorySystem::getPanelBounds() const {
+	const Art *art = _engine.getStartupArt();
 	if (!art)
 		return Common::Rect();
 
@@ -286,7 +286,7 @@ Common::Rect StartupInventorySystem::getPanelBounds() const {
 		kInventoryX + (int)bitmap.width, kInventoryY + (int)bitmap.height);
 }
 
-void StartupInventorySystem::drawSelectedDragItem(Graphics::Screen &screen, const Common::Point &point) const {
+void InventorySystem::drawSelectedDragItem(Graphics::Screen &screen, const Common::Point &point) const {
 	if (_selectedItemName.empty())
 		return;
 
@@ -303,8 +303,8 @@ void StartupInventorySystem::drawSelectedDragItem(Graphics::Screen &screen, cons
 	}
 }
 
-void StartupInventorySystem::drawOverlay(Graphics::Screen &screen) const {
-	const StartupArt *art = _engine.getStartupArt();
+void InventorySystem::drawOverlay(Graphics::Screen &screen) const {
+	const Art *art = _engine.getStartupArt();
 	if (!art)
 		return;
 
@@ -318,11 +318,11 @@ void StartupInventorySystem::drawOverlay(Graphics::Screen &screen) const {
 	}
 }
 
-bool StartupInventorySystem::isExitObject(const StartupObjectRecord &object) {
+bool InventorySystem::isExitObject(const StartupObjectRecord &object) {
 	return object.objectName.equalsIgnoreCase("INV_EXIT");
 }
 
-bool StartupInventorySystem::isStatusObject(const StartupObjectRecord &object) {
+bool InventorySystem::isStatusObject(const StartupObjectRecord &object) {
 	return object.objectName.hasPrefixIgnoreCase("INV_STAT");
 }
 
diff --git a/engines/harvester/startup_inventory.h b/engines/harvester/startup_inventory.h
index e41aa808081..b0d9ad1d1f2 100644
--- a/engines/harvester/startup_inventory.h
+++ b/engines/harvester/startup_inventory.h
@@ -43,9 +43,9 @@ struct StartupInventoryVisual {
 	bool hasBitmap = false;
 };
 
-class StartupInventorySystem {
+class InventorySystem {
 public:
-	explicit StartupInventorySystem(HarvesterEngine &engine);
+	explicit InventorySystem(HarvesterEngine &engine);
 
 	bool refresh();
 	bool open();
diff --git a/engines/harvester/startup_menu.cpp b/engines/harvester/startup_menu.cpp
index 8c281cbf686..957f2924ab2 100644
--- a/engines/harvester/startup_menu.cpp
+++ b/engines/harvester/startup_menu.cpp
@@ -114,7 +114,7 @@ static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, in
 static int getNativeRoomMenuLineHeight(const Graphics::Font &selectedFont);
 
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
-	const StartupText *startupText = engine.getStartupText();
+	const Text *startupText = engine.getStartupText();
 	if (!startupText || !fontName)
 		return nullptr;
 
@@ -273,7 +273,7 @@ static void renderHelpScreen(HarvesterEngine &engine, const IndexedBitmap &bitma
 	screen->update();
 }
 
-static Common::String buildTextModeSuffix(const StartupScript &startupScript, const RoomMenuTextConfig &config) {
+static Common::String buildTextModeSuffix(const Script &startupScript, const RoomMenuTextConfig &config) {
 	switch (startupScript.getDialogueTextMode()) {
 	case kStartupDialogueTextNone:
 		return Common::String::format(" - %s", config.noLabel.c_str());
@@ -285,7 +285,7 @@ static Common::String buildTextModeSuffix(const StartupScript &startupScript, co
 	}
 }
 
-static Common::String buildOptionsMenuItemLabel(const StartupScript &startupScript,
+static Common::String buildOptionsMenuItemLabel(const Script &startupScript,
 		const RoomMenuTextConfig &config, int index) {
 	if (index < 0 || index >= (int)config.optionItems.size())
 		return Common::String();
@@ -396,11 +396,11 @@ static void splitMenuConfigLines(const Common::String &text, Common::Array<Commo
 static void renderOptionsMenuScreen(HarvesterEngine &engine, const IndexedBitmap &backdrop,
 		const byte *palette, float paletteBrightness,
 		const Graphics::Font &selectedFont, const Graphics::Font &unselectedFont,
-		const StartupArt &art, const RoomMenuTextConfig &config,
+		const Art &art, const RoomMenuTextConfig &config,
 		const IndexedBitmap &volumeBar, const IndexedBitmap &indicator, int selectedItem,
 		bool drawCursor = true) {
 	Graphics::Screen *screen = engine.getScreen();
-	StartupScript *startupScript = engine.getStartupScript();
+	Script *startupScript = engine.getStartupScript();
 	if (!screen || !startupScript)
 		return;
 
@@ -444,9 +444,9 @@ static void renderQuickTipsOverlay(HarvesterEngine &engine, const IndexedBitmap
 		const byte *palette, float paletteBrightness,
 		const Common::String &tipText) {
 	Graphics::Screen *screen = engine.getScreen();
-	const StartupArt *art = engine.getStartupArt();
+	const Art *art = engine.getStartupArt();
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	StartupScript *startupScript = engine.getStartupScript();
+	Script *startupScript = engine.getStartupScript();
 	if (!screen || !art || !font || !startupScript)
 		return;
 
@@ -512,7 +512,7 @@ static void renderQuitGameConfirmScreen(HarvesterEngine &engine, const IndexedBi
 		const byte *palette, float paletteBrightness, const Graphics::Font &font,
 		const IndexedBitmap &textbox, const RoomMenuTextConfig &config, bool hoverYes, bool hoverNo) {
 	Graphics::Screen *screen = engine.getScreen();
-	const StartupArt *art = engine.getStartupArt();
+	const Art *art = engine.getStartupArt();
 	if (!screen || !art)
 		return;
 
@@ -562,12 +562,12 @@ static int getNativeRoomMenuSelectionFromMouse(const Graphics::Font &selectedFon
 
 } // End of anonymous namespace
 
-StartupMenuSystem::StartupMenuSystem(HarvesterEngine &engine, Common::Point &mousePos,
+MenuSystem::MenuSystem(HarvesterEngine &engine, Common::Point &mousePos,
 		const Common::Array<Common::String> &menuItems)
 	: _engine(engine), _mousePos(mousePos), _menuItems(menuItems) {
 }
 
-Common::Error StartupMenuSystem::runMainMenuStub(StartupFlow &startupFlow) {
+Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 	Graphics::FrameLimiter limiter(g_system, 60);
 	int selectedItem = _menuItems.empty() ? -1 : 0;
 	Common::String statusMessage = "Main menu stub active.";
@@ -687,8 +687,8 @@ Common::Error StartupMenuSystem::runMainMenuStub(StartupFlow &startupFlow) {
 	return Common::kNoError;
 }
 
-Common::Error StartupMenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, StartupFlow &startupFlow) {
+Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, Flow &startupFlow) {
 	Graphics::FrameLimiter limiter(g_system, 60);
 	int selectedItem = _menuItems.empty() ? -1 : 0;
 	bool needsRedraw = true;
@@ -805,8 +805,8 @@ Common::Error StartupMenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop,
 	return Common::kNoError;
 }
 
-Common::Error StartupMenuSystem::runSaveGameMenu(const byte *palette, float paletteBrightness,
-		StartupFlow &startupFlow) {
+Common::Error MenuSystem::runSaveGameMenu(const byte *palette, float paletteBrightness,
+		Flow &startupFlow) {
 	(void)palette;
 	(void)paletteBrightness;
 	ResourceManager *resources = _engine.getResources();
@@ -1031,9 +1031,9 @@ Common::Error StartupMenuSystem::runSaveGameMenu(const byte *palette, float pale
 	return Common::kNoError;
 }
 
-Common::Error StartupMenuSystem::runQuitGameConfirm(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, StartupFlow &startupFlow) {
-	const StartupArt *art = _engine.getStartupArt();
+Common::Error MenuSystem::runQuitGameConfirm(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, Flow &startupFlow) {
+	const Art *art = _engine.getStartupArt();
 	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
 	if (!art || !selectedFontResource)
 		return Common::kReadingFailed;
@@ -1110,10 +1110,10 @@ Common::Error StartupMenuSystem::runQuitGameConfirm(const IndexedBitmap &backdro
 	return Common::kNoError;
 }
 
-Common::Error StartupMenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, StartupFlow &startupFlow) {
-	const StartupArt *art = _engine.getStartupArt();
-	StartupScript *startupScript = _engine.getStartupScript();
+Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, Flow &startupFlow) {
+	const Art *art = _engine.getStartupArt();
+	Script *startupScript = _engine.getStartupScript();
 	ResourceManager *resources = _engine.getResources();
 	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
 	const CftFontResource *unselectedFontResource = findStartupFontByName(_engine, "HARVFNT2");
@@ -1490,7 +1490,7 @@ Common::Error StartupMenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, c
 	return Common::kNoError;
 }
 
-Common::Error StartupMenuSystem::runHelpScreen(const byte *palette, float paletteBrightness, StartupFlow &startupFlow) {
+Common::Error MenuSystem::runHelpScreen(const byte *palette, float paletteBrightness, Flow &startupFlow) {
 	ResourceManager *resources = _engine.getResources();
 	if (!resources)
 		return Common::kReadingFailed;
@@ -1564,9 +1564,9 @@ Common::Error StartupMenuSystem::runHelpScreen(const byte *palette, float palett
 	return Common::kNoError;
 }
 
-void StartupMenuSystem::renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const {
+void MenuSystem::renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const {
 	Graphics::Screen *screen = _engine.getScreen();
-	const StartupArt *art = _engine.getStartupArt();
+	const Art *art = _engine.getStartupArt();
 	const Graphics::Font *titleFont = FontMan.getFontByUsage(Graphics::FontManager::kBigGUIFont);
 	const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
 	if (!screen || !art || !titleFont || !bodyFont)
@@ -1605,9 +1605,9 @@ void StartupMenuSystem::renderMainMenuStub(int selectedItem, const Common::Strin
 	screen->update();
 }
 
-void StartupMenuSystem::renderRoomMenuStub(const IndexedBitmap &backdrop, int selectedItem) const {
+void MenuSystem::renderRoomMenuStub(const IndexedBitmap &backdrop, int selectedItem) const {
 	Graphics::Screen *screen = _engine.getScreen();
-	const StartupArt *art = _engine.getStartupArt();
+	const Art *art = _engine.getStartupArt();
 	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
 	const CftFontResource *unselectedFontResource = findStartupFontByName(_engine, "HARVFNT2");
 	if (!screen || !art || !selectedFontResource || !unselectedFontResource || !backdrop.isValid())
@@ -1638,7 +1638,7 @@ void StartupMenuSystem::renderRoomMenuStub(const IndexedBitmap &backdrop, int se
 	screen->update();
 }
 
-int StartupMenuSystem::getMenuItemAt(const Common::Point &mousePos) const {
+int MenuSystem::getMenuItemAt(const Common::Point &mousePos) const {
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
 	if (!font)
 		return -1;
@@ -1655,7 +1655,7 @@ int StartupMenuSystem::getMenuItemAt(const Common::Point &mousePos) const {
 	return -1;
 }
 
-int StartupMenuSystem::getRoomMenuItemAt(const Common::Point &mousePos) const {
+int MenuSystem::getRoomMenuItemAt(const Common::Point &mousePos) const {
 	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
 	if (!selectedFontResource)
 		return getMenuItemAt(mousePos);
diff --git a/engines/harvester/startup_menu.h b/engines/harvester/startup_menu.h
index 5676b4223cd..8374079ca72 100644
--- a/engines/harvester/startup_menu.h
+++ b/engines/harvester/startup_menu.h
@@ -34,25 +34,25 @@ class Font;
 namespace Harvester {
 
 class HarvesterEngine;
-class StartupFlow;
+class Flow;
 struct IndexedBitmap;
 
-class StartupMenuSystem {
+class MenuSystem {
 public:
-	StartupMenuSystem(HarvesterEngine &engine, Common::Point &mousePos,
+	MenuSystem(HarvesterEngine &engine, Common::Point &mousePos,
 		const Common::Array<Common::String> &menuItems);
 
-	Common::Error runMainMenuStub(StartupFlow &startupFlow);
+	Common::Error runMainMenuStub(Flow &startupFlow);
 	Common::Error runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, StartupFlow &startupFlow);
+		float paletteBrightness, Flow &startupFlow);
 
 private:
 	Common::Error runOptionsMenu(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, StartupFlow &startupFlow);
-	Common::Error runHelpScreen(const byte *palette, float paletteBrightness, StartupFlow &startupFlow);
-	Common::Error runSaveGameMenu(const byte *palette, float paletteBrightness, StartupFlow &startupFlow);
+		float paletteBrightness, Flow &startupFlow);
+	Common::Error runHelpScreen(const byte *palette, float paletteBrightness, Flow &startupFlow);
+	Common::Error runSaveGameMenu(const byte *palette, float paletteBrightness, Flow &startupFlow);
 	Common::Error runQuitGameConfirm(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, StartupFlow &startupFlow);
+		float paletteBrightness, Flow &startupFlow);
 	void renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const;
 	void renderRoomMenuStub(const IndexedBitmap &backdrop, int selectedItem) const;
 	int getMenuItemAt(const Common::Point &mousePos) const;
diff --git a/engines/harvester/startup_room.cpp b/engines/harvester/startup_room.cpp
index 8495dbb3c21..a25ab524623 100644
--- a/engines/harvester/startup_room.cpp
+++ b/engines/harvester/startup_room.cpp
@@ -89,7 +89,7 @@ static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, in
 }
 
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
-	const StartupText *startupText = engine.getStartupText();
+	const Text *startupText = engine.getStartupText();
 	if (!startupText || !fontName)
 		return nullptr;
 
@@ -175,12 +175,12 @@ static void drawRoomPrompt(Graphics::Screen &screen, const Graphics::Font &font,
 	font.drawString(&screen, promptText, 0, 462, 640, 0xce, Graphics::kTextAlignCenter);
 }
 
-StartupRoomSystem::StartupRoomSystem(HarvesterEngine &engine, Common::Point &mousePos,
-		StartupInventorySystem &inventory)
+RoomSystem::RoomSystem(HarvesterEngine &engine, Common::Point &mousePos,
+		InventorySystem &inventory)
 	: _engine(engine), _mousePos(mousePos), _inventory(inventory) {
 }
 
-Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Common::String &entranceName) {
+Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &entranceName) {
 	if (startupFlow.hasPendingMainMenuReturn())
 		return Common::kNoError;
 
@@ -219,7 +219,7 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			return _engine.getScreen();
 		};
 		Graphics::Screen *screen = getActiveScreen();
-		const StartupArt *art = _engine.getStartupArt();
+		const Art *art = _engine.getStartupArt();
 		const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
 		const CftFontResource *promptFontResource = findStartupFontByName(_engine, "MEDFONT1");
 		Common::ScopedPtr<HarvesterCftFont> promptCftFont;
@@ -406,7 +406,7 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 	};
 	struct InteractionProcessor {
 		HarvesterEngine &engine;
-		StartupFlow &startupFlow;
+		Flow &startupFlow;
 		StartupRoomSceneResources &scene;
 		StartupRoomPlayerState &playerState;
 		Common::String &pendingRegionName;
@@ -557,7 +557,7 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		captureDialogueBackdrop, runRoomExitCommands, resetIdleState
 	};
 	auto moveRoomItemDirectlyToInventory = [&](const StartupObjectRecord &object) -> Common::Error {
-		StartupScript *startupScript = _engine.getStartupScript();
+		Script *startupScript = _engine.getStartupScript();
 		if (!startupScript)
 			return Common::kReadingFailed;
 
@@ -585,7 +585,7 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 		return Common::kNoError;
 	};
 	auto beginRoomItemCarry = [&](const StartupObjectRecord &object) -> Common::Error {
-		StartupScript *startupScript = _engine.getStartupScript();
+		Script *startupScript = _engine.getStartupScript();
 		ResourceManager *resources = _engine.getResources();
 		if (!startupScript || !resources)
 			return Common::kReadingFailed;
@@ -770,8 +770,8 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 				Common::String targetLabel;
 				const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
 				if (_inventory.isOpen()) {
-					if (inventoryHover && !StartupInventorySystem::isExitObject(inventoryHover->object) &&
-							!StartupInventorySystem::isStatusObject(inventoryHover->object) &&
+					if (inventoryHover && !InventorySystem::isExitObject(inventoryHover->object) &&
+							!InventorySystem::isStatusObject(inventoryHover->object) &&
 							!inventoryHover->object.objectName.equalsIgnoreCase(_inventory.getSelectedItemName())) {
 						targetLabel = _engine.getStartupScript()->resolveObjectLabel(inventoryHover->object);
 					} else if (!inventoryPanelContainsMouse) {
@@ -790,7 +790,7 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 			} else if (_inventory.isOpen()) {
 				const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
 				if (inventoryHover &&
-						!StartupInventorySystem::isStatusObject(inventoryHover->object)) {
+						!InventorySystem::isStatusObject(inventoryHover->object)) {
 					promptText = _engine.getStartupScript()->resolveObjectLabel(inventoryHover->object);
 				}
 				_inventory.setPromptText(promptText);
@@ -897,14 +897,14 @@ Common::Error StartupRoomSystem::runRoomLoop(StartupFlow &startupFlow, const Com
 				if (_inventory.isOpen()) {
 					const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
 					if (inventoryHover) {
-						if (StartupInventorySystem::isExitObject(inventoryHover->object)) {
+						if (InventorySystem::isExitObject(inventoryHover->object)) {
 							const bool clearedSelection = _inventory.clearSelection();
 							const bool closedInventory = _inventory.close();
 							if (clearedSelection || closedInventory)
 								needsRedraw = true;
 							break;
 						}
-						if (StartupInventorySystem::isStatusObject(inventoryHover->object))
+						if (InventorySystem::isStatusObject(inventoryHover->object))
 							break;
 
 						if (!_inventory.hasSelection()) {
diff --git a/engines/harvester/startup_room.h b/engines/harvester/startup_room.h
index 4005965a44e..6850c9484fc 100644
--- a/engines/harvester/startup_room.h
+++ b/engines/harvester/startup_room.h
@@ -29,19 +29,19 @@
 namespace Harvester {
 
 class HarvesterEngine;
-class StartupFlow;
-class StartupInventorySystem;
+class Flow;
+class InventorySystem;
 
-class StartupRoomSystem {
+class RoomSystem {
 public:
-	StartupRoomSystem(HarvesterEngine &engine, Common::Point &mousePos, StartupInventorySystem &inventory);
+	RoomSystem(HarvesterEngine &engine, Common::Point &mousePos, InventorySystem &inventory);
 
-	Common::Error runRoomLoop(StartupFlow &startupFlow, const Common::String &entranceName);
+	Common::Error runRoomLoop(Flow &startupFlow, const Common::String &entranceName);
 
 private:
 	HarvesterEngine &_engine;
 	Common::Point &_mousePos;
-	StartupInventorySystem &_inventory;
+	InventorySystem &_inventory;
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/startup_room_support.h b/engines/harvester/startup_room_support.h
index d69206f5664..9693ce55961 100644
--- a/engines/harvester/startup_room_support.h
+++ b/engines/harvester/startup_room_support.h
@@ -38,7 +38,7 @@ namespace Harvester {
 class HarvesterEngine;
 class ResourceManager;
 class RuntimeEntity;
-class StartupArt;
+class Art;
 struct IndexedBitmap;
 
 struct StartupRoomSceneResources {
@@ -111,11 +111,11 @@ const StartupRegionRecord *findSceneRegionByName(const Common::Array<StartupRegi
 	const Common::String &regionName);
 StartupObjectRecord *findSceneObjectByName(Common::Array<StartupObjectRecord> &objects,
 	const Common::String &objectName);
-const IndexedBitmap *resolveInspectTextboxBitmap(const StartupArt &art, const StartupResolvedText &text);
-void drawRoomInspectText(Graphics::Screen &screen, const StartupArt &art, const Graphics::Font &font,
+const IndexedBitmap *resolveInspectTextboxBitmap(const Art &art, const StartupResolvedText &text);
+void drawRoomInspectText(Graphics::Screen &screen, const Art &art, const Graphics::Font &font,
 	const StartupResolvedText &inspectText);
 bool unlocksRoomObjectInteractionAfterInitialExamine(const StartupObjectRecord &object,
-	StartupScript &startupScript);
+	Script &startupScript);
 bool doesPlayerFacingMatchRegion(int playerFacing, const StartupRegionRecord &region);
 bool doesPlayerOverlapRegion(const RuntimeEntity &playerEntity, const StartupRegionRecord &region);
 StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const StartupRoomSetupState &state,
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/startup_script.cpp
index 781fc64ff10..bf816b340d1 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/startup_script.cpp
@@ -359,7 +359,7 @@ static void tokenizeTownScriptLine(const Common::String &line, Common::Array<Com
 	}
 }
 
-bool StartupScript::load(ResourceManager &resources) {
+bool Script::load(ResourceManager &resources) {
 	_path = kDefaultTownScript;
 	_data.clear();
 	_entrances.clear();
@@ -405,7 +405,7 @@ bool StartupScript::load(ResourceManager &resources) {
 	return true;
 }
 
-bool StartupScript::loadConfig(ResourceManager &resources) {
+bool Script::loadConfig(ResourceManager &resources) {
 	Common::Array<byte> configData;
 	if (!resources.loadFile(kStartupConfigPath, configData)) {
 		warning("Harvester: unable to load startup config '%s', using defaults", kStartupConfigPath);
@@ -479,7 +479,7 @@ bool StartupScript::loadConfig(ResourceManager &resources) {
 	return true;
 }
 
-bool StartupScript::saveConfig() const {
+bool Script::saveConfig() const {
 	Common::DumpFile file;
 	const Common::Path configPath = ConfMan.getPath("path").join(kStartupConfigPath, Common::Path::kNoSeparator);
 	if (!file.open(configPath)) {
@@ -516,19 +516,19 @@ bool StartupScript::saveConfig() const {
 	return !file.err();
 }
 
-void StartupScript::setFxVolumeLevel(int level) {
+void Script::setFxVolumeLevel(int level) {
 	_fxVolumeLevel = clampStartupOptionLevel(level);
 }
 
-void StartupScript::setMusicVolumeLevel(int level) {
+void Script::setMusicVolumeLevel(int level) {
 	_musicVolumeLevel = clampStartupOptionLevel(level);
 }
 
-void StartupScript::setGammaLevel(int level) {
+void Script::setGammaLevel(int level) {
 	_gammaLevel = clampStartupOptionLevel(level);
 }
 
-void StartupScript::decode() {
+void Script::decode() {
 	for (byte &value : _data) {
 		if (value == '\r' || value == '\n')
 			continue;
@@ -537,7 +537,7 @@ void StartupScript::decode() {
 	}
 }
 
-void StartupScript::parseTownRecords(ResourceManager &resources) {
+void Script::parseTownRecords(ResourceManager &resources) {
 	_entrances.clear();
 	_mapEntrances.clear();
 	_mapLocations.clear();
@@ -908,7 +908,7 @@ void StartupScript::parseTownRecords(ResourceManager &resources) {
 		(uint)_useItems.size(), _path.c_str());
 }
 
-bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
+bool Script::resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
 		ResourceManager &resources) {
 	state = StartupRoomSetupState();
 
@@ -947,7 +947,7 @@ bool StartupScript::resolveRoomSetupState(const Common::String &entranceName, St
 	return true;
 }
 
-void StartupScript::resetRuntimeState() {
+void Script::resetRuntimeState() {
 	_runtimeFlags = _flags;
 	_runtimeObjects = _objects;
 	_runtimeAnimations = _animations;
@@ -977,7 +977,7 @@ void StartupScript::resetRuntimeState() {
 	}
 }
 
-void StartupScript::syncRuntimeSaveState(Common::Serializer &s) {
+void Script::syncRuntimeSaveState(Common::Serializer &s) {
 	syncRecordArray(s, _runtimeFlags, syncStartupFlagRecord);
 	syncRecordArray(s, _runtimeObjects, syncStartupObjectRecord);
 	syncRecordArray(s, _runtimeAnimations, syncStartupAnimRecord);
@@ -989,7 +989,7 @@ void StartupScript::syncRuntimeSaveState(Common::Serializer &s) {
 		_playerCurrentHitPoints = clampPlayerHitPoints(_playerCurrentHitPoints);
 }
 
-bool StartupScript::materializeRoomState(const Common::String &entranceName, const Common::String &roomName,
+bool Script::materializeRoomState(const Common::String &entranceName, const Common::String &roomName,
 		StartupRoomSetupState &state, ResourceManager &resources) const {
 	state = StartupRoomSetupState();
 
@@ -1010,7 +1010,7 @@ bool StartupScript::materializeRoomState(const Common::String &entranceName, con
 	return buildRuntimeRoomState(*room, entrance, resources, state);
 }
 
-bool StartupScript::executeRoomExitCommands(const Common::String &roomName,
+bool Script::executeRoomExitCommands(const Common::String &roomName,
 		Common::Array<StartupAudioCommand> &audioCommands) {
 	audioCommands.clear();
 
@@ -1025,7 +1025,7 @@ bool StartupScript::executeRoomExitCommands(const Common::String &roomName,
 	return true;
 }
 
-bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result) {
+bool Script::resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result) {
 	result = StartupInteractionResult();
 
 	if (isPickupObject(object)) {
@@ -1050,7 +1050,7 @@ bool StartupScript::resolveObjectInteraction(const StartupObjectRecord &object,
 		result.mutatedRuntimeState || hasActionableCommandChain(object.actionTag);
 }
 
-bool StartupScript::resolveRegionInteraction(const StartupRegionRecord &region, StartupInteractionResult &result) {
+bool Script::resolveRegionInteraction(const StartupRegionRecord &region, StartupInteractionResult &result) {
 	result = StartupInteractionResult();
 	if (region.actionTag.empty())
 		return false;
@@ -1064,7 +1064,7 @@ bool StartupScript::resolveRegionInteraction(const StartupRegionRecord &region,
 		result.mutatedRuntimeState || hasActionableCommandChain(region.actionTag);
 }
 
-bool StartupScript::resolveUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target,
+bool Script::resolveUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target,
 		StartupInteractionResult &result) {
 	result = StartupInteractionResult();
 
@@ -1080,7 +1080,7 @@ bool StartupScript::resolveUseItemInteraction(const Common::String &itemName, co
 	return true;
 }
 
-bool StartupScript::executeActionTag(const Common::String &tag, StartupInteractionResult &result,
+bool Script::executeActionTag(const Common::String &tag, StartupInteractionResult &result,
 		bool allowTransitions) {
 	result = StartupInteractionResult();
 	if (tag.empty())
@@ -1096,7 +1096,7 @@ bool StartupScript::executeActionTag(const Common::String &tag, StartupInteracti
 		result.mutatedRuntimeState || hasActionableCommandChain(tag);
 }
 
-bool StartupScript::executeNestedActionTag(const Common::String &tag, StartupInteractionResult &result,
+bool Script::executeNestedActionTag(const Common::String &tag, StartupInteractionResult &result,
 		bool allowTransitions) {
 	const bool handled = executeActionTag(tag, result, allowTransitions);
 	if (handled)
@@ -1105,12 +1105,12 @@ bool StartupScript::executeNestedActionTag(const Common::String &tag, StartupInt
 	return handled;
 }
 
-bool StartupScript::isPickupObject(const StartupObjectRecord &object) const {
+bool Script::isPickupObject(const StartupObjectRecord &object) const {
 	return !object.altSpritePath.empty() &&
 		!object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName);
 }
 
-bool StartupScript::hasObjectInteraction(const StartupObjectRecord &object) const {
+bool Script::hasObjectInteraction(const StartupObjectRecord &object) const {
 	if (isPickupObject(object))
 		return true;
 	if (object.actionTag.empty())
@@ -1119,11 +1119,11 @@ bool StartupScript::hasObjectInteraction(const StartupObjectRecord &object) cons
 	return hasActionableCommandChain(object.actionTag);
 }
 
-bool StartupScript::hasUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target) const {
+bool Script::hasUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target) const {
 	return findUseItemRecord(itemName, target) != nullptr;
 }
 
-void StartupScript::getVisibleInventoryObjects(Common::Array<StartupObjectRecord> &objects) const {
+void Script::getVisibleInventoryObjects(Common::Array<StartupObjectRecord> &objects) const {
 	objects.clear();
 	const char *const statusObjectName = resolveInventoryStatusObjectName(_playerCurrentHitPoints);
 	const StartupObjectRecord *statusObject = nullptr;
@@ -1150,12 +1150,12 @@ void StartupScript::getVisibleInventoryObjects(Common::Array<StartupObjectRecord
 	objects.push_back(activeStatusObject);
 }
 
-void StartupScript::markObjectIdentShown(const StartupObjectRecord &object) {
+void Script::markObjectIdentShown(const StartupObjectRecord &object) {
 	if (StartupObjectRecord *runtimeObject = findRuntimeObject(object.currentOwnerOrRoom, object.objectName))
 		runtimeObject->identShown = true;
 }
 
-const StartupEntranceRecord *StartupScript::findEntranceRecord(const Common::String &entranceName) const {
+const StartupEntranceRecord *Script::findEntranceRecord(const Common::String &entranceName) const {
 	if (entranceName.empty())
 		return nullptr;
 
@@ -1167,7 +1167,7 @@ const StartupEntranceRecord *StartupScript::findEntranceRecord(const Common::Str
 	return nullptr;
 }
 
-const StartupMapEntranceRecord *StartupScript::findMapEntranceRecord(const Common::String &entryName) const {
+const StartupMapEntranceRecord *Script::findMapEntranceRecord(const Common::String &entryName) const {
 	if (entryName.empty())
 		return nullptr;
 
@@ -1179,7 +1179,7 @@ const StartupMapEntranceRecord *StartupScript::findMapEntranceRecord(const Commo
 	return nullptr;
 }
 
-const StartupUseItemRecord *StartupScript::findUseItemRecord(const Common::String &itemName,
+const StartupUseItemRecord *Script::findUseItemRecord(const Common::String &itemName,
 		const StartupObjectRecord &target) const {
 	if (itemName.empty() || target.objectName.empty())
 		return nullptr;
@@ -1201,7 +1201,7 @@ const StartupUseItemRecord *StartupScript::findUseItemRecord(const Common::Strin
 	return nullptr;
 }
 
-const StartupRoomRecord *StartupScript::findRoomRecord(const Common::String &roomName) const {
+const StartupRoomRecord *Script::findRoomRecord(const Common::String &roomName) const {
 	if (roomName.empty())
 		return nullptr;
 
@@ -1213,7 +1213,7 @@ const StartupRoomRecord *StartupScript::findRoomRecord(const Common::String &roo
 	return nullptr;
 }
 
-const StartupCommandRecord *StartupScript::findCommandRecord(const Common::String &tag) const {
+const StartupCommandRecord *Script::findCommandRecord(const Common::String &tag) const {
 	if (tag.empty())
 		return nullptr;
 
@@ -1225,7 +1225,7 @@ const StartupCommandRecord *StartupScript::findCommandRecord(const Common::Strin
 	return nullptr;
 }
 
-const StartupFlagRecord *StartupScript::findRuntimeFlag(const Common::String &flagName) const {
+const StartupFlagRecord *Script::findRuntimeFlag(const Common::String &flagName) const {
 	if (flagName.empty())
 		return nullptr;
 
@@ -1237,7 +1237,7 @@ const StartupFlagRecord *StartupScript::findRuntimeFlag(const Common::String &fl
 	return nullptr;
 }
 
-StartupFlagRecord *StartupScript::findRuntimeFlag(const Common::String &flagName) {
+StartupFlagRecord *Script::findRuntimeFlag(const Common::String &flagName) {
 	if (flagName.empty())
 		return nullptr;
 
@@ -1249,7 +1249,7 @@ StartupFlagRecord *StartupScript::findRuntimeFlag(const Common::String &flagName
 	return nullptr;
 }
 
-StartupObjectRecord *StartupScript::findRuntimeObject(const Common::String &ownerOrRoom,
+StartupObjectRecord *Script::findRuntimeObject(const Common::String &ownerOrRoom,
 		const Common::String &objectName) {
 	if (objectName.empty())
 		return nullptr;
@@ -1274,7 +1274,7 @@ StartupObjectRecord *StartupScript::findRuntimeObject(const Common::String &owne
 	return fallback;
 }
 
-StartupAnimRecord *StartupScript::findRuntimeAnim(const Common::String &animName) {
+StartupAnimRecord *Script::findRuntimeAnim(const Common::String &animName) {
 	if (animName.empty())
 		return nullptr;
 
@@ -1286,7 +1286,7 @@ StartupAnimRecord *StartupScript::findRuntimeAnim(const Common::String &animName
 	return nullptr;
 }
 
-StartupRegionRecord *StartupScript::findRuntimeRegion(const Common::String &regionName) {
+StartupRegionRecord *Script::findRuntimeRegion(const Common::String &regionName) {
 	if (regionName.empty())
 		return nullptr;
 
@@ -1298,7 +1298,7 @@ StartupRegionRecord *StartupScript::findRuntimeRegion(const Common::String &regi
 	return nullptr;
 }
 
-StartupNpcRecord *StartupScript::findRuntimeNpc(const Common::String &npcName) {
+StartupNpcRecord *Script::findRuntimeNpc(const Common::String &npcName) {
 	if (npcName.empty())
 		return nullptr;
 
@@ -1310,7 +1310,7 @@ StartupNpcRecord *StartupScript::findRuntimeNpc(const Common::String &npcName) {
 	return nullptr;
 }
 
-StartupMonsterRecord *StartupScript::findRuntimeMonster(const Common::String &monsterName) {
+StartupMonsterRecord *Script::findRuntimeMonster(const Common::String &monsterName) {
 	if (monsterName.empty())
 		return nullptr;
 
@@ -1322,7 +1322,7 @@ StartupMonsterRecord *StartupScript::findRuntimeMonster(const Common::String &mo
 	return nullptr;
 }
 
-const StartupNpcRecord *StartupScript::findRuntimeNpc(const Common::String &npcName) const {
+const StartupNpcRecord *Script::findRuntimeNpc(const Common::String &npcName) const {
 	if (npcName.empty())
 		return nullptr;
 
@@ -1334,7 +1334,7 @@ const StartupNpcRecord *StartupScript::findRuntimeNpc(const Common::String &npcN
 	return nullptr;
 }
 
-const StartupMonsterRecord *StartupScript::findRuntimeMonster(const Common::String &monsterName) const {
+const StartupMonsterRecord *Script::findRuntimeMonster(const Common::String &monsterName) const {
 	if (monsterName.empty())
 		return nullptr;
 
@@ -1346,11 +1346,11 @@ const StartupMonsterRecord *StartupScript::findRuntimeMonster(const Common::Stri
 	return nullptr;
 }
 
-const StartupNpcRecord *StartupScript::findRuntimeNpcRecord(const Common::String &npcName) const {
+const StartupNpcRecord *Script::findRuntimeNpcRecord(const Common::String &npcName) const {
 	return findRuntimeNpc(npcName);
 }
 
-bool StartupScript::addRuntimeObjectToInventory(const Common::String &objectName) {
+bool Script::addRuntimeObjectToInventory(const Common::String &objectName) {
 	StartupObjectRecord *runtimeObject = findRuntimeObject(Common::String(), objectName);
 	if (!runtimeObject)
 		return false;
@@ -1364,7 +1364,7 @@ bool StartupScript::addRuntimeObjectToInventory(const Common::String &objectName
 	return changed;
 }
 
-bool StartupScript::setRuntimeObjectVisible(const Common::String &ownerOrRoom,
+bool Script::setRuntimeObjectVisible(const Common::String &ownerOrRoom,
 		const Common::String &objectName, bool visible) {
 	if (objectName.empty())
 		return false;
@@ -1383,7 +1383,7 @@ bool StartupScript::setRuntimeObjectVisible(const Common::String &ownerOrRoom,
 	return changed;
 }
 
-bool StartupScript::setRuntimeNpcState(const Common::String &npcName, bool active, bool visible) {
+bool Script::setRuntimeNpcState(const Common::String &npcName, bool active, bool visible) {
 	if (npcName.empty())
 		return false;
 
@@ -1397,14 +1397,14 @@ bool StartupScript::setRuntimeNpcState(const Common::String &npcName, bool activ
 	return changed;
 }
 
-bool StartupScript::setPlayerCurrentHitPoints(int hitPoints) {
+bool Script::setPlayerCurrentHitPoints(int hitPoints) {
 	const int clampedHitPoints = clampPlayerHitPoints(hitPoints);
 	const bool changed = _playerCurrentHitPoints != clampedHitPoints;
 	_playerCurrentHitPoints = clampedHitPoints;
 	return changed;
 }
 
-bool StartupScript::triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcName) {
+bool Script::triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcName) {
 	if (npcName.empty())
 		return false;
 
@@ -1430,7 +1430,7 @@ bool StartupScript::triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcN
 	return changed || monsterChanged;
 }
 
-bool StartupScript::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupEntranceRecord *entrance,
+bool Script::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupEntranceRecord *entrance,
 		ResourceManager &resources, StartupRoomSetupState &state) const {
 	state = StartupRoomSetupState();
 
@@ -1550,7 +1550,7 @@ bool StartupScript::buildRuntimeRoomState(const StartupRoomRecord &room, const S
 	return true;
 }
 
-void StartupScript::executeCommandChain(const Common::String &initialTag, const char *contextLabel,
+void Script::executeCommandChain(const Common::String &initialTag, const char *contextLabel,
 		const Common::String &contextName, bool allowTransitions, Common::String *musicPath,
 		Common::Array<StartupAudioCommand> *audioCommands, Common::String *nextRoomName,
 		StartupRoomTransitionKind *roomTransition,
@@ -1838,7 +1838,7 @@ void StartupScript::executeCommandChain(const Common::String &initialTag, const
 	}
 }
 
-bool StartupScript::hasActionableCommandChain(const Common::String &initialTag) const {
+bool Script::hasActionableCommandChain(const Common::String &initialTag) const {
 	Common::String currentTag = initialTag;
 	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
 		const StartupCommandRecord *command = findCommandRecord(currentTag);
@@ -1884,7 +1884,7 @@ bool StartupScript::hasActionableCommandChain(const Common::String &initialTag)
 	return false;
 }
 
-const StartupTextRecord *StartupScript::findTextRecord(const Common::String &key) const {
+const StartupTextRecord *Script::findTextRecord(const Common::String &key) const {
 	if (key.empty())
 		return nullptr;
 
@@ -1896,7 +1896,7 @@ const StartupTextRecord *StartupScript::findTextRecord(const Common::String &key
 	return nullptr;
 }
 
-bool StartupScript::resolveObjectInspectText(const StartupObjectRecord &object, StartupResolvedText &text) const {
+bool Script::resolveObjectInspectText(const StartupObjectRecord &object, StartupResolvedText &text) const {
 	text = StartupResolvedText();
 
 	const StartupTextRecord *textRecord = findTextRecord(object.identTextKey);
@@ -1908,7 +1908,7 @@ bool StartupScript::resolveObjectInspectText(const StartupObjectRecord &object,
 	return !text.value.empty();
 }
 
-Common::String StartupScript::resolveObjectLabel(const StartupObjectRecord &object) const {
+Common::String Script::resolveObjectLabel(const StartupObjectRecord &object) const {
 	if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)) {
 		const StartupTextRecord *inventoryText = findTextRecord(object.inventoryTextKey);
 		if (inventoryText && !inventoryText->value.empty())
@@ -1931,7 +1931,7 @@ Common::String StartupScript::resolveObjectLabel(const StartupObjectRecord &obje
 	return Common::String();
 }
 
-Common::String StartupScript::resolveTextValue(const Common::String &key) const {
+Common::String Script::resolveTextValue(const Common::String &key) const {
 	const StartupTextRecord *textRecord = findTextRecord(key);
 	if (textRecord && !textRecord->value.empty())
 		return textRecord->value;
@@ -1939,7 +1939,7 @@ Common::String StartupScript::resolveTextValue(const Common::String &key) const
 	return normalizeInteractionLabel(key);
 }
 
-const StartupHeadRecord *StartupScript::findHeadRecord(const Common::String &headId) const {
+const StartupHeadRecord *Script::findHeadRecord(const Common::String &headId) const {
 	if (headId.empty())
 		return nullptr;
 
@@ -1951,12 +1951,12 @@ const StartupHeadRecord *StartupScript::findHeadRecord(const Common::String &hea
 	return nullptr;
 }
 
-bool StartupScript::getFlagValue(const Common::String &flagName) const {
+bool Script::getFlagValue(const Common::String &flagName) const {
 	const StartupFlagRecord *flag = findRuntimeFlag(flagName);
 	return flag && flag->value;
 }
 
-bool StartupScript::setRuntimeFlagValue(const Common::String &flagName, bool value) {
+bool Script::setRuntimeFlagValue(const Common::String &flagName, bool value) {
 	if (flagName.empty())
 		return false;
 
@@ -1974,7 +1974,7 @@ bool StartupScript::setRuntimeFlagValue(const Common::String &flagName, bool val
 	return true;
 }
 
-bool StartupScript::resetRuntimeObjectToInitialState(const Common::String &objectName) {
+bool Script::resetRuntimeObjectToInitialState(const Common::String &objectName) {
 	if (objectName.empty())
 		return false;
 
@@ -2010,12 +2010,12 @@ bool StartupScript::resetRuntimeObjectToInitialState(const Common::String &objec
 	return changed;
 }
 
-bool StartupScript::isNamedNpcDeathTypeClear(const Common::String &npcName) const {
+bool Script::isNamedNpcDeathTypeClear(const Common::String &npcName) const {
 	const StartupNpcRecord *npc = findRuntimeNpc(npcName);
 	return npc && npc->deathDamageType == 0;
 }
 
-int StartupScript::getCurrentStoryDayIndex() const {
+int Script::getCurrentStoryDayIndex() const {
 	if (getFlagValue("DAY_1"))
 		return 1;
 	if (getFlagValue("DAY_2") || getFlagValue("NIGHT_2"))
diff --git a/engines/harvester/startup_script.h b/engines/harvester/startup_script.h
index 4e6b6cd01b1..c0549485f18 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/startup_script.h
@@ -282,7 +282,7 @@ struct StartupResolvedText {
 	Common::String value;
 };
 
-class StartupScript {
+class Script {
 public:
 	bool load(ResourceManager &resources);
 
diff --git a/engines/harvester/startup_text.cpp b/engines/harvester/startup_text.cpp
index 0c21a9e2bd2..4493cc10fbd 100644
--- a/engines/harvester/startup_text.cpp
+++ b/engines/harvester/startup_text.cpp
@@ -126,7 +126,7 @@ static bool tryLoadPreferredDialogueResponsesFromGamePath(Common::Array<Common::
 
 } // End of anonymous namespace
 
-bool StartupText::load(ResourceManager &resources) {
+bool Text::load(ResourceManager &resources) {
 	_dialogueData.clear();
 	_dialogueEntries.clear();
 	_dialogueResponseLines.clear();
@@ -145,7 +145,7 @@ bool StartupText::load(ResourceManager &resources) {
 	return true;
 }
 
-bool StartupText::loadDialogueIndex(ResourceManager &resources) {
+bool Text::loadDialogueIndex(ResourceManager &resources) {
 	if (!resources.loadFile("DIALOGUE.IDX", _dialogueData) || _dialogueData.empty()) {
 		warning("Harvester: unable to load DIALOGUE.IDX");
 		return false;
@@ -186,7 +186,7 @@ bool StartupText::loadDialogueIndex(ResourceManager &resources) {
 	return !_dialogueEntries.empty();
 }
 
-bool StartupText::loadDialogueResponses(ResourceManager &resources) {
+bool Text::loadDialogueResponses(ResourceManager &resources) {
 	Common::Array<byte> responseData;
 	if (!resources.loadFile(kDialogueResponsePath, responseData) || responseData.empty()) {
 		warning("Harvester: unable to load %s", kDialogueResponsePath);
@@ -206,7 +206,7 @@ bool StartupText::loadDialogueResponses(ResourceManager &resources) {
 	return !_dialogueResponseLines.empty();
 }
 
-bool StartupText::loadFont(ResourceManager &resources, const Common::String &path) {
+bool Text::loadFont(ResourceManager &resources, const Common::String &path) {
 	Common::Array<byte> data;
 	if (!resources.loadFile(path, data) || data.size() < kCftBitmapHeaderSize) {
 		warning("Harvester: unable to load font '%s'", path.c_str());
@@ -239,7 +239,7 @@ bool StartupText::loadFont(ResourceManager &resources, const Common::String &pat
 	return true;
 }
 
-void StartupText::decodeXorText(Common::Array<byte> &data) const {
+void Text::decodeXorText(Common::Array<byte> &data) const {
 	for (byte &value : data) {
 		if (value == '\n' || value == '\r')
 			continue;
@@ -247,7 +247,7 @@ void StartupText::decodeXorText(Common::Array<byte> &data) const {
 	}
 }
 
-bool StartupText::resolveDialogueSubtitle(int wavId, Common::String &text) const {
+bool Text::resolveDialogueSubtitle(int wavId, Common::String &text) const {
 	text.clear();
 	if (wavId <= 0 || _dialogueEntries.empty() || _dialogueData.empty())
 		return false;
@@ -273,7 +273,7 @@ bool StartupText::resolveDialogueSubtitle(int wavId, Common::String &text) const
 	return false;
 }
 
-Common::String StartupText::getDialogueResponseLine(int zeroBasedIndex) const {
+Common::String Text::getDialogueResponseLine(int zeroBasedIndex) const {
 	if (zeroBasedIndex < 0 || (uint)zeroBasedIndex >= _dialogueResponseLines.size())
 		return Common::String();
 
diff --git a/engines/harvester/startup_text.h b/engines/harvester/startup_text.h
index 6d0c6e82067..01330b0783b 100644
--- a/engines/harvester/startup_text.h
+++ b/engines/harvester/startup_text.h
@@ -44,7 +44,7 @@ struct CftFontResource {
 	Common::Array<byte> atlasPixels;
 };
 
-class StartupText {
+class Text {
 public:
 	bool load(ResourceManager &resources);
 


Commit: f6d1433c048f4ec5e3b688579298361c08c39fb1
    https://github.com/scummvm/scummvm/commit/f6d1433c048f4ec5e3b688579298361c08c39fb1
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:27+02:00

Commit Message:
HARVESTER: Rename startup helper files

Changed paths:
  A engines/harvester/art.cpp
  A engines/harvester/art.h
  A engines/harvester/flow.cpp
  A engines/harvester/flow.h
  A engines/harvester/inventory.cpp
  A engines/harvester/inventory.h
  A engines/harvester/menu.cpp
  A engines/harvester/menu.h
  A engines/harvester/room.cpp
  A engines/harvester/room.h
  A engines/harvester/room_support.h
  A engines/harvester/script.cpp
  A engines/harvester/script.h
  A engines/harvester/text.cpp
  A engines/harvester/text.h
  R engines/harvester/startup_art.cpp
  R engines/harvester/startup_art.h
  R engines/harvester/startup_flow.cpp
  R engines/harvester/startup_flow.h
  R engines/harvester/startup_inventory.cpp
  R engines/harvester/startup_inventory.h
  R engines/harvester/startup_menu.cpp
  R engines/harvester/startup_menu.h
  R engines/harvester/startup_room.cpp
  R engines/harvester/startup_room.h
  R engines/harvester/startup_room_support.h
  R engines/harvester/startup_script.cpp
  R engines/harvester/startup_script.h
  R engines/harvester/startup_text.cpp
  R engines/harvester/startup_text.h
    engines/harvester/cft_font.h
    engines/harvester/dialogue.cpp
    engines/harvester/dialogue.h
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/module.mk
    engines/harvester/npc/dialogue_runtime.h
    engines/harvester/npc/mom_dialogue.cpp
    engines/harvester/npc/moynahan_dialogue.cpp
    engines/harvester/npc/mr_potts_dialogue.cpp
    engines/harvester/runtime_entity.h


diff --git a/engines/harvester/startup_art.cpp b/engines/harvester/art.cpp
similarity index 99%
rename from engines/harvester/startup_art.cpp
rename to engines/harvester/art.cpp
index 73060db99b5..8dd57e1a3cf 100644
--- a/engines/harvester/startup_art.cpp
+++ b/engines/harvester/art.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/startup_art.h"
+#include "harvester/art.h"
 
 #include "common/debug.h"
 #include "common/endian.h"
diff --git a/engines/harvester/startup_art.h b/engines/harvester/art.h
similarity index 96%
rename from engines/harvester/startup_art.h
rename to engines/harvester/art.h
index 2f314a13dc3..cdca733cbbe 100644
--- a/engines/harvester/startup_art.h
+++ b/engines/harvester/art.h
@@ -19,8 +19,8 @@
  *
  */
 
-#ifndef HARVESTER_STARTUP_ART_H
-#define HARVESTER_STARTUP_ART_H
+#ifndef HARVESTER_ART_H
+#define HARVESTER_ART_H
 
 #include "common/array.h"
 
@@ -82,4 +82,4 @@ private:
 
 } // End of namespace Harvester
 
-#endif // HARVESTER_STARTUP_ART_H
+#endif // HARVESTER_ART_H
diff --git a/engines/harvester/cft_font.h b/engines/harvester/cft_font.h
index 5bde9d347fa..63535fe8a4d 100644
--- a/engines/harvester/cft_font.h
+++ b/engines/harvester/cft_font.h
@@ -23,7 +23,7 @@
 #define HARVESTER_CFT_FONT_H
 
 #include "graphics/font.h"
-#include "harvester/startup_text.h"
+#include "harvester/text.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 812adcf7f57..8a5b350e745 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -89,9 +89,9 @@
 #include "harvester/npc/wasp_woman_dialogue.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
-#include "harvester/startup_art.h"
-#include "harvester/startup_flow.h"
-#include "harvester/startup_text.h"
+#include "harvester/art.h"
+#include "harvester/flow.h"
+#include "harvester/text.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/dialogue.h b/engines/harvester/dialogue.h
index e7bce5419f3..304d37872a2 100644
--- a/engines/harvester/dialogue.h
+++ b/engines/harvester/dialogue.h
@@ -27,7 +27,7 @@
 #include "common/rect.h"
 #include "common/str.h"
 #include "harvester/npc/dialogue_handler.h"
-#include "harvester/startup_script.h"
+#include "harvester/script.h"
 
 namespace Common {
 struct Event;
diff --git a/engines/harvester/startup_flow.cpp b/engines/harvester/flow.cpp
similarity index 99%
rename from engines/harvester/startup_flow.cpp
rename to engines/harvester/flow.cpp
index 3db5cb9e4ae..a70681eb022 100644
--- a/engines/harvester/startup_flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -21,7 +21,7 @@
 
 #include <math.h>
 
-#include "harvester/startup_flow.h"
+#include "harvester/flow.h"
 
 #include "common/config-manager.h"
 #include "common/debug.h"
@@ -39,8 +39,8 @@
 #include "harvester/palette_utils.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
-#include "harvester/startup_art.h"
-#include "harvester/startup_script.h"
+#include "harvester/art.h"
+#include "harvester/script.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/startup_flow.h b/engines/harvester/flow.h
similarity index 93%
rename from engines/harvester/startup_flow.h
rename to engines/harvester/flow.h
index fec814a2be2..184526eec17 100644
--- a/engines/harvester/startup_flow.h
+++ b/engines/harvester/flow.h
@@ -19,18 +19,18 @@
  *
  */
 
-#ifndef HARVESTER_STARTUP_FLOW_H
-#define HARVESTER_STARTUP_FLOW_H
+#ifndef HARVESTER_FLOW_H
+#define HARVESTER_FLOW_H
 
 #include "common/array.h"
 #include "common/error.h"
 #include "common/rect.h"
 #include "common/str.h"
 #include "harvester/dialogue.h"
-#include "harvester/startup_inventory.h"
-#include "harvester/startup_menu.h"
-#include "harvester/startup_room.h"
-#include "harvester/startup_script.h"
+#include "harvester/inventory.h"
+#include "harvester/menu.h"
+#include "harvester/room.h"
+#include "harvester/script.h"
 
 namespace Common {
 struct Event;
@@ -98,4 +98,4 @@ private:
 
 } // End of namespace Harvester
 
-#endif // HARVESTER_STARTUP_FLOW_H
+#endif // HARVESTER_FLOW_H
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 431b49a30d3..c764115781a 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -36,10 +36,10 @@
 #include "harvester/fst_player.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
-#include "harvester/startup_art.h"
-#include "harvester/startup_flow.h"
-#include "harvester/startup_script.h"
-#include "harvester/startup_text.h"
+#include "harvester/art.h"
+#include "harvester/flow.h"
+#include "harvester/script.h"
+#include "harvester/text.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index e3df22722f2..4530ed4a9f4 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -29,7 +29,7 @@
 #include "common/serializer.h"
 #include "engines/engine.h"
 #include "graphics/screen.h"
-#include "harvester/startup_script.h"
+#include "harvester/script.h"
 
 struct ADGameDescription;
 
diff --git a/engines/harvester/startup_inventory.cpp b/engines/harvester/inventory.cpp
similarity index 99%
rename from engines/harvester/startup_inventory.cpp
rename to engines/harvester/inventory.cpp
index 618e439302f..11222489920 100644
--- a/engines/harvester/startup_inventory.cpp
+++ b/engines/harvester/inventory.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/startup_inventory.h"
+#include "harvester/inventory.h"
 
 #include "common/endian.h"
 #include "graphics/blit.h"
@@ -27,7 +27,7 @@
 #include "graphics/screen.h"
 #include "harvester/harvester.h"
 #include "harvester/resources.h"
-#include "harvester/startup_art.h"
+#include "harvester/art.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/startup_inventory.h b/engines/harvester/inventory.h
similarity index 92%
rename from engines/harvester/startup_inventory.h
rename to engines/harvester/inventory.h
index b0d9ad1d1f2..e5856c8f7d5 100644
--- a/engines/harvester/startup_inventory.h
+++ b/engines/harvester/inventory.h
@@ -19,14 +19,14 @@
  *
  */
 
-#ifndef HARVESTER_STARTUP_INVENTORY_H
-#define HARVESTER_STARTUP_INVENTORY_H
+#ifndef HARVESTER_INVENTORY_H
+#define HARVESTER_INVENTORY_H
 
 #include "common/array.h"
 #include "common/rect.h"
 #include "common/str.h"
-#include "harvester/startup_art.h"
-#include "harvester/startup_room_support.h"
+#include "harvester/art.h"
+#include "harvester/room_support.h"
 
 namespace Graphics {
 class Screen;
@@ -77,4 +77,4 @@ private:
 
 } // End of namespace Harvester
 
-#endif // HARVESTER_STARTUP_INVENTORY_H
+#endif // HARVESTER_INVENTORY_H
diff --git a/engines/harvester/startup_menu.cpp b/engines/harvester/menu.cpp
similarity index 99%
rename from engines/harvester/startup_menu.cpp
rename to engines/harvester/menu.cpp
index 957f2924ab2..85365456bb7 100644
--- a/engines/harvester/startup_menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/startup_menu.h"
+#include "harvester/menu.h"
 
 #include "common/config-manager.h"
 #include "common/endian.h"
@@ -37,10 +37,10 @@
 #include "harvester/palette_utils.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
-#include "harvester/startup_art.h"
-#include "harvester/startup_flow.h"
-#include "harvester/startup_script.h"
-#include "harvester/startup_text.h"
+#include "harvester/art.h"
+#include "harvester/flow.h"
+#include "harvester/script.h"
+#include "harvester/text.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/startup_menu.h b/engines/harvester/menu.h
similarity index 95%
rename from engines/harvester/startup_menu.h
rename to engines/harvester/menu.h
index 8374079ca72..197b647044f 100644
--- a/engines/harvester/startup_menu.h
+++ b/engines/harvester/menu.h
@@ -19,8 +19,8 @@
  *
  */
 
-#ifndef HARVESTER_STARTUP_MENU_H
-#define HARVESTER_STARTUP_MENU_H
+#ifndef HARVESTER_MENU_H
+#define HARVESTER_MENU_H
 
 #include "common/array.h"
 #include "common/error.h"
@@ -65,4 +65,4 @@ private:
 
 } // End of namespace Harvester
 
-#endif // HARVESTER_STARTUP_MENU_H
+#endif // HARVESTER_MENU_H
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 3d6dea0dac4..282363b44fd 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -58,13 +58,13 @@ MODULE := engines/harvester
 	npc/wasp_woman_dialogue.o \
 	resources.o \
 	runtime_entity.o \
-	startup_art.o \
-	startup_flow.o \
-	startup_inventory.o \
-	startup_menu.o \
-	startup_room.o \
-	startup_script.o \
-	startup_text.o \
+	art.o \
+	flow.o \
+	inventory.o \
+	menu.o \
+	room.o \
+	script.o \
+	text.o \
 	xfile_archive.o
 
 # This module can be built as a plugin
diff --git a/engines/harvester/npc/dialogue_runtime.h b/engines/harvester/npc/dialogue_runtime.h
index e3906ba274d..f8bcabbf16c 100644
--- a/engines/harvester/npc/dialogue_runtime.h
+++ b/engines/harvester/npc/dialogue_runtime.h
@@ -5,7 +5,7 @@
 
 #include "common/error.h"
 #include "common/str.h"
-#include "harvester/startup_script.h"
+#include "harvester/script.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/npc/mom_dialogue.cpp b/engines/harvester/npc/mom_dialogue.cpp
index 47e5fd23027..3145c384b9d 100644
--- a/engines/harvester/npc/mom_dialogue.cpp
+++ b/engines/harvester/npc/mom_dialogue.cpp
@@ -25,7 +25,7 @@
 
 #include "harvester/npc/dialogue_flags.h"
 #include "harvester/npc/dialogue_runtime.h"
-#include "harvester/startup_script.h"
+#include "harvester/script.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/npc/moynahan_dialogue.cpp b/engines/harvester/npc/moynahan_dialogue.cpp
index 89dc7f1ff70..3fcdbd9941f 100644
--- a/engines/harvester/npc/moynahan_dialogue.cpp
+++ b/engines/harvester/npc/moynahan_dialogue.cpp
@@ -25,7 +25,7 @@
 
 #include "harvester/npc/dialogue_flags.h"
 #include "harvester/npc/dialogue_runtime.h"
-#include "harvester/startup_text.h"
+#include "harvester/text.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/npc/mr_potts_dialogue.cpp b/engines/harvester/npc/mr_potts_dialogue.cpp
index 774d117c07b..d73f765d0ef 100644
--- a/engines/harvester/npc/mr_potts_dialogue.cpp
+++ b/engines/harvester/npc/mr_potts_dialogue.cpp
@@ -26,7 +26,7 @@
 #include "common/array.h"
 #include "harvester/npc/dialogue_flags.h"
 #include "harvester/npc/dialogue_runtime.h"
-#include "harvester/startup_text.h"
+#include "harvester/text.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/startup_room.cpp b/engines/harvester/room.cpp
similarity index 99%
rename from engines/harvester/startup_room.cpp
rename to engines/harvester/room.cpp
index a25ab524623..53ff66cce3d 100644
--- a/engines/harvester/startup_room.cpp
+++ b/engines/harvester/room.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/startup_room.h"
+#include "harvester/room.h"
 
 #include "common/endian.h"
 #include "common/events.h"
@@ -35,10 +35,10 @@
 #include "harvester/harvester.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
-#include "harvester/startup_art.h"
-#include "harvester/startup_flow.h"
-#include "harvester/startup_inventory.h"
-#include "harvester/startup_room_support.h"
+#include "harvester/art.h"
+#include "harvester/flow.h"
+#include "harvester/inventory.h"
+#include "harvester/room_support.h"
 
 namespace Harvester {
 
diff --git a/engines/harvester/startup_room.h b/engines/harvester/room.h
similarity index 93%
rename from engines/harvester/startup_room.h
rename to engines/harvester/room.h
index 6850c9484fc..47848b8ddf8 100644
--- a/engines/harvester/startup_room.h
+++ b/engines/harvester/room.h
@@ -19,8 +19,8 @@
  *
  */
 
-#ifndef HARVESTER_STARTUP_ROOM_H
-#define HARVESTER_STARTUP_ROOM_H
+#ifndef HARVESTER_ROOM_H
+#define HARVESTER_ROOM_H
 
 #include "common/error.h"
 #include "common/rect.h"
@@ -46,4 +46,4 @@ private:
 
 } // End of namespace Harvester
 
-#endif // HARVESTER_STARTUP_ROOM_H
+#endif // HARVESTER_ROOM_H
diff --git a/engines/harvester/startup_room_support.h b/engines/harvester/room_support.h
similarity index 97%
rename from engines/harvester/startup_room_support.h
rename to engines/harvester/room_support.h
index 9693ce55961..6027119320b 100644
--- a/engines/harvester/startup_room_support.h
+++ b/engines/harvester/room_support.h
@@ -19,14 +19,14 @@
  *
  */
 
-#ifndef HARVESTER_STARTUP_ROOM_SUPPORT_H
-#define HARVESTER_STARTUP_ROOM_SUPPORT_H
+#ifndef HARVESTER_ROOM_SUPPORT_H
+#define HARVESTER_ROOM_SUPPORT_H
 
 #include "common/array.h"
 #include "common/rect.h"
 #include "common/scummsys.h"
 #include "common/str.h"
-#include "harvester/startup_script.h"
+#include "harvester/script.h"
 
 namespace Graphics {
 class Font;
@@ -145,4 +145,4 @@ void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResource
 
 } // End of namespace Harvester
 
-#endif // HARVESTER_STARTUP_ROOM_SUPPORT_H
+#endif // HARVESTER_ROOM_SUPPORT_H
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index 47a51be1983..341f617697c 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -25,7 +25,7 @@
 #include "common/array.h"
 #include "common/rect.h"
 #include "common/str.h"
-#include "harvester/startup_art.h"
+#include "harvester/art.h"
 
 namespace Graphics {
 class Screen;
diff --git a/engines/harvester/startup_script.cpp b/engines/harvester/script.cpp
similarity index 99%
rename from engines/harvester/startup_script.cpp
rename to engines/harvester/script.cpp
index bf816b340d1..b5523d5c25f 100644
--- a/engines/harvester/startup_script.cpp
+++ b/engines/harvester/script.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/startup_script.h"
+#include "harvester/script.h"
 
 #include <cstdlib>
 
diff --git a/engines/harvester/startup_script.h b/engines/harvester/script.h
similarity index 99%
rename from engines/harvester/startup_script.h
rename to engines/harvester/script.h
index c0549485f18..34c0c9e9f10 100644
--- a/engines/harvester/startup_script.h
+++ b/engines/harvester/script.h
@@ -19,8 +19,8 @@
  *
  */
 
-#ifndef HARVESTER_STARTUP_SCRIPT_H
-#define HARVESTER_STARTUP_SCRIPT_H
+#ifndef HARVESTER_SCRIPT_H
+#define HARVESTER_SCRIPT_H
 
 #include "common/array.h"
 #include "common/str.h"
@@ -427,4 +427,4 @@ private:
 
 } // End of namespace Harvester
 
-#endif // HARVESTER_STARTUP_SCRIPT_H
+#endif // HARVESTER_SCRIPT_H
diff --git a/engines/harvester/startup_text.cpp b/engines/harvester/text.cpp
similarity index 99%
rename from engines/harvester/startup_text.cpp
rename to engines/harvester/text.cpp
index 4493cc10fbd..88f3ff4f2af 100644
--- a/engines/harvester/startup_text.cpp
+++ b/engines/harvester/text.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/startup_text.h"
+#include "harvester/text.h"
 
 #include "common/config-manager.h"
 #include "common/debug.h"
diff --git a/engines/harvester/startup_text.h b/engines/harvester/text.h
similarity index 95%
rename from engines/harvester/startup_text.h
rename to engines/harvester/text.h
index 01330b0783b..be8367c5def 100644
--- a/engines/harvester/startup_text.h
+++ b/engines/harvester/text.h
@@ -19,8 +19,8 @@
  *
  */
 
-#ifndef HARVESTER_STARTUP_TEXT_H
-#define HARVESTER_STARTUP_TEXT_H
+#ifndef HARVESTER_TEXT_H
+#define HARVESTER_TEXT_H
 
 #include "common/array.h"
 #include "common/str.h"
@@ -69,4 +69,4 @@ private:
 
 } // End of namespace Harvester
 
-#endif // HARVESTER_STARTUP_TEXT_H
+#endif // HARVESTER_TEXT_H


Commit: ef3dca90c7602b10eb0a8397867a7e199d363e47
    https://github.com/scummvm/scummvm/commit/ef3dca90c7602b10eb0a8397867a7e199d363e47
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:27+02:00

Commit Message:
HARVESTER: Centralize media resource management

Changed paths:
  A engines/harvester/media_manager.cpp
  A engines/harvester/media_manager.h
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/module.mk


diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index c764115781a..75c67d8456b 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -19,71 +19,27 @@
  *
  */
 
-#include <cstring>
-
 #include "harvester/harvester.h"
 
-#include "audio/audiostream.h"
-#include "audio/decoders/raw.h"
-#include "audio/decoders/wave.h"
 #include "common/config-manager.h"
-#include "common/memstream.h"
-#include "common/system.h"
 #include "engines/util.h"
-#include "graphics/paletteman.h"
 #include "harvester/console.h"
 #include "harvester/detection.h"
 #include "harvester/fst_player.h"
+#include "harvester/media_manager.h"
 #include "harvester/resources.h"
-#include "harvester/runtime_entity.h"
-#include "harvester/art.h"
 #include "harvester/flow.h"
 #include "harvester/script.h"
-#include "harvester/text.h"
 
 namespace Harvester {
 
 namespace {
 
-static const int8 kHarvesterImaIndexAdjustTable[16] = {
-	-1, -1, -1, -1, 2, 4, 6, 8,
-	-1, -1, -1, -1, 2, 4, 6, 8
-};
-
-static const uint16 kHarvesterImaStepTable[89] = {
-	7, 8, 9, 10, 11, 12, 13, 14, 16, 17,
-	19, 21, 23, 25, 28, 31, 34, 37, 41, 45,
-	50, 55, 60, 66, 73, 80, 88, 97, 107, 118,
-	130, 143, 157, 173, 190, 209, 230, 253, 279, 307,
-	337, 371, 408, 449, 494, 544, 598, 658, 724, 796,
-	876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066,
-	2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358,
-	5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899,
-	15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767
-};
-
 static bool shouldSkipStartupMoviesForDebug() {
 	return ConfMan.hasKey("harvester_debug_skip_startup_movies") &&
 		ConfMan.getBool("harvester_debug_skip_startup_movies");
 }
 
-static int clampStartupOptionLevel(int level) {
-	if (level < 0)
-		return 0;
-	if (level > 9)
-		return 9;
-
-	return level;
-}
-
-static int mapStartupVolumeLevelToMixerVolume(int level) {
-	return (clampStartupOptionLevel(level) * Audio::Mixer::kMaxMixerVolume + 4) / 9;
-}
-
-static float mapStartupGammaLevelToBrightnessScale(int level) {
-	return 1.0f + 0.1f * clampStartupOptionLevel(level);
-}
-
 static void syncSerializedBool(Common::Serializer &s, bool &value) {
 	byte serialized = value ? 1 : 0;
 	s.syncAsByte(serialized);
@@ -94,116 +50,6 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
 static const uint32 kHarvesterSaveVersion = 1;
 
-static uint32 decodeHarvesterFcmp(byte *dest, const byte *src, uint32 srcSize, uint16 bitsPerSample) {
-	const uint32 decodedBytesPerInputByte = bitsPerSample >> 2;
-	const uint32 decodedSize = srcSize * decodedBytesPerInputByte;
-	const uint32 sampleCount = (bitsPerSample == 16) ? (decodedSize >> 1) : decodedSize;
-	int predictor = 0;
-	int stepIndex = 0;
-	int stepSize = 7;
-	byte cachedByte = 0;
-	uint32 srcPos = 0;
-
-	for (uint32 sample = 0; sample < sampleCount; ++sample) {
-		const byte nibble = ((sample & 1) == 0)
-			? ((cachedByte = src[srcPos++]) & 0x0f)
-			: ((cachedByte >> 4) & 0x0f);
-		int delta = stepSize >> 3;
-		if (nibble & 4)
-			delta += stepSize;
-		if (nibble & 2)
-			delta += stepSize >> 1;
-		if (nibble & 1)
-			delta += stepSize >> 2;
-		if (nibble & 8)
-			delta = -delta;
-
-		predictor += delta;
-		if (predictor > 0x7fff)
-			predictor = 0x7fff;
-		else if (predictor < -0x8000)
-			predictor = -0x8000;
-
-		if (bitsPerSample == 16) {
-			WRITE_LE_INT16(dest, predictor);
-			dest += 2;
-		} else {
-			*dest++ = (byte)(((predictor >> 8) & 0xff) ^ 0x80);
-		}
-
-		stepIndex += kHarvesterImaIndexAdjustTable[nibble];
-		if (stepIndex < 0)
-			stepIndex = 0;
-		else if (stepIndex > 88)
-			stepIndex = 88;
-		stepSize = kHarvesterImaStepTable[stepIndex];
-	}
-
-	return decodedSize;
-}
-
-static Audio::SeekableAudioStream *decodeStartupAudioStream(Common::SeekableReadStream *stream) {
-	if (!stream)
-		return nullptr;
-
-	if (stream->size() >= 14) {
-		char magic[4];
-		stream->read(magic, sizeof(magic));
-		if (!memcmp(magic, "FCMP", sizeof(magic))) {
-			const uint32 payloadSizeFromHeader = stream->readUint32LE();
-			const uint32 sampleRate = stream->readUint32LE();
-			const uint16 bitsPerSample = stream->readUint16LE();
-			const uint32 availablePayloadSize = stream->size() - 14;
-			const uint32 payloadSize = payloadSizeFromHeader != 0
-				? MIN<uint32>(payloadSizeFromHeader, availablePayloadSize)
-				: availablePayloadSize;
-
-			if (sampleRate == 0 || (bitsPerSample != 8 && bitsPerSample != 16) || payloadSize == 0) {
-				delete stream;
-				return nullptr;
-			}
-
-			if (!stream->seek(14)) {
-				delete stream;
-				return nullptr;
-			}
-
-			byte *compressedPayload = (byte *)malloc(payloadSize);
-			if (!compressedPayload) {
-				delete stream;
-				return nullptr;
-			}
-			if (stream->read(compressedPayload, payloadSize) != payloadSize) {
-				free(compressedPayload);
-				delete stream;
-				return nullptr;
-			}
-
-			const uint32 decodedSize = payloadSize * (bitsPerSample >> 2);
-			byte *decodedPcm = (byte *)malloc(decodedSize);
-			if (!decodedPcm) {
-				free(compressedPayload);
-				delete stream;
-				return nullptr;
-			}
-
-			decodeHarvesterFcmp(decodedPcm, compressedPayload, payloadSize, bitsPerSample);
-			free(compressedPayload);
-			delete stream;
-			return Audio::makeRawStream(decodedPcm, decodedSize, sampleRate,
-				Audio::FLAG_LITTLE_ENDIAN | ((bitsPerSample == 16) ? Audio::FLAG_16BITS : 0),
-				DisposeAfterUse::YES);
-		}
-	}
-
-	stream->seek(0);
-	return Audio::makeWAVStream(stream, DisposeAfterUse::YES);
-}
-
-static Audio::SeekableAudioStream *openStartupAudioStream(ResourceManager &resources, const Common::String &path) {
-	return decodeStartupAudioStream(resources.openFile(path));
-}
-
 } // End of anonymous namespace
 
 HarvesterEngine *g_engine = nullptr;
@@ -214,14 +60,9 @@ HarvesterEngine::HarvesterEngine(OSystem *syst, const ADGameDescription *gameDes
 }
 
 HarvesterEngine::~HarvesterEngine() {
-	stopStartupMusic();
-	stopStartupSound();
-	delete _startupText;
-	delete _startupArt;
+	delete _media;
 	delete _startupScript;
-	delete _runtimeEntities;
 	delete _resources;
-	delete _screen;
 	g_engine = nullptr;
 }
 
@@ -237,6 +78,30 @@ bool HarvesterEngine::canSaveGameStateCurrently(Common::U32String *) {
 	return _startupScript != nullptr && _currentStartupSaveRoomState.valid;
 }
 
+RuntimeEntityManager *HarvesterEngine::getRuntimeEntities() const {
+	return _media ? _media->getRuntimeEntities() : nullptr;
+}
+
+Graphics::Screen *HarvesterEngine::getScreen() const {
+	return _media ? _media->getScreen() : nullptr;
+}
+
+int HarvesterEngine::getDisplayWidth() const {
+	return _media ? _media->getDisplayWidth() : 0;
+}
+
+int HarvesterEngine::getDisplayHeight() const {
+	return _media ? _media->getDisplayHeight() : 0;
+}
+
+Art *HarvesterEngine::getStartupArt() const {
+	return _media ? _media->getArt() : nullptr;
+}
+
+Text *HarvesterEngine::getStartupText() const {
+	return _media ? _media->getText() : nullptr;
+}
+
 bool HarvesterEngine::isGoreEnabled() const {
 	if (_startupScript)
 		return _startupScript->isGoreEnabled();
@@ -257,20 +122,12 @@ int HarvesterEngine::getStartupGammaLevel() const {
 }
 
 float HarvesterEngine::getStartupGammaBrightnessScale() const {
-	return mapStartupGammaLevelToBrightnessScale(getStartupGammaLevel());
+	return MediaManager::mapGammaLevelToBrightnessScale(getStartupGammaLevel());
 }
 
 void HarvesterEngine::applyStartupMixerLevels() {
-	if (!g_system || !g_system->getMixer())
-		return;
-
-	g_system->getMixer()->setVolumeForSoundType(Audio::Mixer::kSFXSoundType,
-		mapStartupVolumeLevelToMixerVolume(getStartupFxVolumeLevel()));
-	g_system->getMixer()->setVolumeForSoundType(Audio::Mixer::kMusicSoundType,
-		mapStartupVolumeLevelToMixerVolume(getStartupMusicVolumeLevel()));
-
-	if (g_system->getMixer()->isSoundHandleActive(_startupMusicHandle))
-		g_system->getMixer()->pauseHandle(_startupMusicHandle, getStartupMusicVolumeLevel() == 0);
+	if (_media)
+		_media->applyMixerLevels(getStartupFxVolumeLevel(), getStartupMusicVolumeLevel());
 }
 
 void HarvesterEngine::setStartupFxVolumeLevel(int level) {
@@ -290,199 +147,84 @@ void HarvesterEngine::setStartupGammaLevel(int level) {
 		_startupScript->setGammaLevel(level);
 }
 
+const Common::String &HarvesterEngine::getStartupMusicPath() const {
+	static const Common::String kEmptyMusicPath;
+	return _media ? _media->getMusicPath() : kEmptyMusicPath;
+}
+
 bool HarvesterEngine::isStartupMusicPlaying() const {
-	return g_system && g_system->getMixer() &&
-		g_system->getMixer()->isSoundHandleActive(_startupMusicHandle);
+	return _media && _media->isMusicPlaying();
 }
 
 bool HarvesterEngine::playStartupMusic(const Common::String &path) {
-	if (path.empty() || !_resources || !g_system || !g_system->getMixer())
-		return false;
-
-	const Common::String normalizedPath = _resources->normalizeResourcePath(path);
-	if (_startupMusicPath.equalsIgnoreCase(normalizedPath) &&
-			g_system->getMixer()->isSoundHandleActive(_startupMusicHandle)) {
-		return true;
-	}
-
-	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(*_resources, path);
-	if (!audioStream) {
-		warning("Harvester: unable to decode startup music '%s'", path.c_str());
+	if (!_media)
 		return false;
-	}
 
-	stopStartupMusic();
-	g_system->getMixer()->playStream(Audio::Mixer::kMusicSoundType, &_startupMusicHandle,
-		Audio::makeLoopingAudioStream(audioStream, 0));
-	_startupMusicPath = normalizedPath;
-	applyStartupMixerLevels();
-	return true;
+	const bool started = _media->playMusic(path);
+	if (started)
+		applyStartupMixerLevels();
+	return started;
 }
 
 void HarvesterEngine::pauseStartupMusic(bool paused) {
-	if (g_system && g_system->getMixer())
-		g_system->getMixer()->pauseHandle(_startupMusicHandle, paused);
+	if (_media)
+		_media->pauseMusic(paused);
 }
 
 void HarvesterEngine::stopStartupMusic() {
-	if (g_system && g_system->getMixer())
-		stopStartupSoundHandle(_startupMusicHandle);
-	_startupMusicPath.clear();
+	if (_media)
+		_media->stopMusic();
 }
 
 bool HarvesterEngine::executeStartupAudioCommand(const StartupAudioCommand &command) {
-	switch (command.type) {
-	case kStartupAudioCommandStartWav:
-		return playStartupSound(command.path);
-	case kStartupAudioCommandStartSingleWav:
-		return playStartupSingleSound(command.path);
-	case kStartupAudioCommandLoadWav:
-		return loadStartupSound(command.slot, command.path);
-	case kStartupAudioCommandPlayWav:
-		return playStartupLoadedSound(command.slot);
-	case kStartupAudioCommandDeleteWav:
-		return deleteStartupLoadedSound(command.slot);
-	default:
-		return false;
-	}
+	return _media && _media->executeAudioCommand(command);
 }
 
 bool HarvesterEngine::playStartupSound(const Common::String &path) {
-	if (path.empty() || !_resources || !g_system || !g_system->getMixer())
-		return false;
-
-	const Common::String normalizedPath = _resources->normalizeResourcePath(path);
-	for (int i = 0; i < ARRAYSIZE(_startupSoundPaths); ++i) {
-		if (_startupSoundPaths[i].equalsIgnoreCase(normalizedPath)) {
-			stopStartupSoundHandle(_startupSoundHandles[i]);
-			_startupSoundPaths[i].clear();
-		}
-	}
-
-	_startupSoundSlotIndex = (_startupSoundSlotIndex + 1) % ARRAYSIZE(_startupSoundHandles);
-	stopStartupSoundHandle(_startupSoundHandles[_startupSoundSlotIndex]);
-
-	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(*_resources, path);
-	if (!audioStream) {
-		warning("Harvester: unable to decode startup sound '%s'", path.c_str());
-		return false;
-	}
-
-	g_system->getMixer()->playStream(Audio::Mixer::kSFXSoundType,
-		&_startupSoundHandles[_startupSoundSlotIndex], audioStream);
-	_startupSoundPaths[_startupSoundSlotIndex] = normalizedPath;
-	return true;
+	return _media && _media->playSound(path);
 }
 
 bool HarvesterEngine::playStartupSingleSound(const Common::String &path) {
-	if (path.empty() || !_resources || !g_system || !g_system->getMixer())
-		return false;
-
-	stopStartupSoundHandle(_startupSingleSoundHandle);
-	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(*_resources, path);
-	if (!audioStream) {
-		warning("Harvester: unable to decode startup sound '%s'", path.c_str());
-		return false;
-	}
-
-	g_system->getMixer()->playStream(Audio::Mixer::kSFXSoundType, &_startupSingleSoundHandle, audioStream);
-	return true;
+	return _media && _media->playSingleSound(path);
 }
 
 void HarvesterEngine::stopStartupSingleSound() {
-	stopStartupSoundHandle(_startupSingleSoundHandle);
+	if (_media)
+		_media->stopSingleSound();
 }
 
 bool HarvesterEngine::isStartupSingleSoundPlaying() const {
-	return g_system && g_system->getMixer() &&
-		g_system->getMixer()->isSoundHandleActive(_startupSingleSoundHandle);
+	return _media && _media->isSingleSoundPlaying();
 }
 
 bool HarvesterEngine::playStartupSpeech(const Common::String &path) {
-	if (path.empty() || !_resources || !g_system || !g_system->getMixer())
-		return false;
-
-	stopStartupSoundHandle(_startupSpeechHandle);
-	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(*_resources, path);
-	if (!audioStream) {
-		warning("Harvester: unable to decode startup speech '%s'", path.c_str());
-		return false;
-	}
-
-	g_system->getMixer()->playStream(Audio::Mixer::kSpeechSoundType, &_startupSpeechHandle, audioStream);
-	return true;
+	return _media && _media->playSpeech(path);
 }
 
 void HarvesterEngine::stopStartupSpeech() {
-	stopStartupSoundHandle(_startupSpeechHandle);
+	if (_media)
+		_media->stopSpeech();
 }
 
 bool HarvesterEngine::isStartupSpeechPlaying() const {
-	return g_system && g_system->getMixer() &&
-		g_system->getMixer()->isSoundHandleActive(_startupSpeechHandle);
+	return _media && _media->isSpeechPlaying();
 }
 
 bool HarvesterEngine::loadStartupSound(int slot, const Common::String &path) {
-	if (!validateStartupLoadedSoundSlot(slot) || path.empty() || !_resources)
-		return false;
-
-	stopStartupSoundHandle(_startupLoadedSoundHandles[slot]);
-	_startupLoadedSoundPaths[slot].clear();
-	_startupLoadedSoundData[slot].clear();
-
-	if (!_resources->loadFile(path, _startupLoadedSoundData[slot])) {
-		warning("Harvester: unable to load startup sound '%s' into slot %d", path.c_str(), slot);
-		return false;
-	}
-
-	_startupLoadedSoundPaths[slot] = _resources->normalizeResourcePath(path);
-	return true;
+	return _media && _media->loadSound(slot, path);
 }
 
 bool HarvesterEngine::playStartupLoadedSound(int slot) {
-	if (!validateStartupLoadedSoundSlot(slot) || !g_system || !g_system->getMixer())
-		return false;
-	if (_startupLoadedSoundData[slot].empty())
-		return false;
-
-	stopStartupSoundHandle(_startupLoadedSoundHandles[slot]);
-
-	Common::SeekableReadStream *stream = new Common::MemoryReadStream(
-		_startupLoadedSoundData[slot].data(), _startupLoadedSoundData[slot].size(), DisposeAfterUse::NO);
-	Audio::SeekableAudioStream *audioStream = decodeStartupAudioStream(stream);
-	if (!audioStream) {
-		warning("Harvester: unable to decode startup sound slot %d ('%s')",
-			slot, _startupLoadedSoundPaths[slot].c_str());
-		return false;
-	}
-
-	g_system->getMixer()->playStream(Audio::Mixer::kSFXSoundType, &_startupLoadedSoundHandles[slot], audioStream);
-	return true;
+	return _media && _media->playLoadedSound(slot);
 }
 
 bool HarvesterEngine::deleteStartupLoadedSound(int slot) {
-	if (!validateStartupLoadedSoundSlot(slot))
-		return false;
-
-	stopStartupSoundHandle(_startupLoadedSoundHandles[slot]);
-	_startupLoadedSoundPaths[slot].clear();
-	_startupLoadedSoundData[slot].clear();
-	return true;
+	return _media && _media->deleteLoadedSound(slot);
 }
 
 void HarvesterEngine::stopStartupSound() {
-	stopStartupSingleSound();
-	stopStartupSpeech();
-	for (int i = 0; i < ARRAYSIZE(_startupSoundHandles); ++i) {
-		stopStartupSoundHandle(_startupSoundHandles[i]);
-		_startupSoundPaths[i].clear();
-	}
-	for (int i = 0; i < ARRAYSIZE(_startupLoadedSoundHandles); ++i) {
-		stopStartupSoundHandle(_startupLoadedSoundHandles[i]);
-		_startupLoadedSoundPaths[i].clear();
-		_startupLoadedSoundData[i].clear();
-	}
-	_startupSoundSlotIndex = -1;
+	if (_media)
+		_media->stopSound();
 }
 
 void HarvesterEngine::captureCurrentStartupSaveRoomState(const Common::String &entranceName,
@@ -518,23 +260,10 @@ void HarvesterEngine::syncStartupSaveRoomState(Common::Serializer &s, StartupSav
 	s.syncAsSint32LE(state.playerFacing);
 }
 
-void HarvesterEngine::stopStartupSoundHandle(Audio::SoundHandle &handle) {
-	if (g_system && g_system->getMixer())
-		g_system->getMixer()->stopHandle(handle);
-}
-
-bool HarvesterEngine::validateStartupLoadedSoundSlot(int slot) const {
-	return slot >= 0 && slot < ARRAYSIZE(_startupLoadedSoundHandles);
-}
-
 void HarvesterEngine::setDisplayMode(int width, int height) {
-	delete _screen;
-	_screen = nullptr;
-
 	initGraphics(width, height);
-	_screen = new Graphics::Screen();
-	_displayWidth = width;
-	_displayHeight = height;
+	if (_media)
+		_media->resetScreen(width, height);
 	debugC(1, kDebugGeneral, "Harvester: switched display mode to %dx%d", width, height);
 }
 
@@ -551,7 +280,7 @@ Common::Error HarvesterEngine::run() {
 
 	_resources = new ResourceManager();
 	_resources->mountStartupArchives();
-	_runtimeEntities = new RuntimeEntityManager(*_resources);
+	_media = new MediaManager(*_resources);
 	_startupScript = new Script();
 	if (!_startupScript->load(*_resources))
 		return Common::kReadingFailed;
@@ -576,17 +305,14 @@ Common::Error HarvesterEngine::run() {
 	// The original executable switches back to the gameplay UI surface after INTROFIN.FST.
 	setDisplayMode(640, 480);
 
-	_startupArt = new Art();
-	if (!_startupArt->load(*_resources))
+	if (!_media->loadArt())
 		return Common::kReadingFailed;
-	if (_screen)
-		_startupArt->drawWaitFrame(*_screen);
+	_media->drawWaitFrame();
 
-	_startupText = new Text();
-	if (!_startupText->load(*_resources))
+	if (!_media->loadText())
 		return Common::kReadingFailed;
 
-	if (!_startupArt->loadQuickTipsResources(*_resources))
+	if (!_media->loadQuickTipsResources())
 		return Common::kReadingFailed;
 
 	// If a savegame was selected from the launcher, load it
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 4530ed4a9f4..ac72e036db2 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -37,6 +37,7 @@ namespace Harvester {
 
 class ResourceManager;
 class RuntimeEntityManager;
+class MediaManager;
 class Script;
 class Art;
 class Text;
@@ -95,13 +96,14 @@ public:
 	Common::Error loadGameStream(Common::SeekableReadStream *stream) override;
 
 	ResourceManager *getResources() const { return _resources; }
-	RuntimeEntityManager *getRuntimeEntities() const { return _runtimeEntities; }
-	Graphics::Screen *getScreen() const { return _screen; }
-	int getDisplayWidth() const { return _displayWidth; }
-	int getDisplayHeight() const { return _displayHeight; }
+	MediaManager *getMedia() const { return _media; }
+	RuntimeEntityManager *getRuntimeEntities() const;
+	Graphics::Screen *getScreen() const;
+	int getDisplayWidth() const;
+	int getDisplayHeight() const;
 	Script *getStartupScript() const { return _startupScript; }
-	Art *getStartupArt() const { return _startupArt; }
-	Text *getStartupText() const { return _startupText; }
+	Art *getStartupArt() const;
+	Text *getStartupText() const;
 	void setDisplayMode(int width, int height);
 	bool isGoreEnabled() const;
 	int getStartupFxVolumeLevel() const;
@@ -111,7 +113,7 @@ public:
 	void setStartupFxVolumeLevel(int level);
 	void setStartupMusicVolumeLevel(int level);
 	void setStartupGammaLevel(int level);
-	const Common::String &getStartupMusicPath() const { return _startupMusicPath; }
+	const Common::String &getStartupMusicPath() const;
 	bool isStartupMusicPlaying() const;
 	bool playStartupMusic(const Common::String &path);
 	void pauseStartupMusic(bool paused);
@@ -141,30 +143,13 @@ public:
 
 private:
 	void syncStartupSaveRoomState(Common::Serializer &s, StartupSaveRoomState &state);
-	void stopStartupSoundHandle(Audio::SoundHandle &handle);
-	bool validateStartupLoadedSoundSlot(int slot) const;
 	void applyStartupMixerLevels();
 
 	const ADGameDescription *const _gameDescription;
 	Common::RandomSource _randomSource;
-	Audio::SoundHandle _startupMusicHandle;
-	Audio::SoundHandle _startupSingleSoundHandle;
-	Audio::SoundHandle _startupSpeechHandle;
-	Common::String _startupMusicPath;
-	int _startupSoundSlotIndex = -1;
-	Common::String _startupSoundPaths[8];
-	Audio::SoundHandle _startupSoundHandles[8];
-	Common::String _startupLoadedSoundPaths[4];
-	Common::Array<byte> _startupLoadedSoundData[4];
-	Audio::SoundHandle _startupLoadedSoundHandles[4];
-	Graphics::Screen *_screen = nullptr;
-	int _displayWidth = 0;
-	int _displayHeight = 0;
 	ResourceManager *_resources = nullptr;
-	RuntimeEntityManager *_runtimeEntities = nullptr;
+	MediaManager *_media = nullptr;
 	Script *_startupScript = nullptr;
-	Art *_startupArt = nullptr;
-	Text *_startupText = nullptr;
 	StartupSaveRoomState _currentStartupSaveRoomState;
 	StartupSaveRoomState _pendingLoadedStartupSaveRoomState;
 };
diff --git a/engines/harvester/media_manager.cpp b/engines/harvester/media_manager.cpp
new file mode 100644
index 00000000000..691863468fa
--- /dev/null
+++ b/engines/harvester/media_manager.cpp
@@ -0,0 +1,458 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <cstdlib>
+#include <cstring>
+
+#include "harvester/media_manager.h"
+
+#include "audio/audiostream.h"
+#include "audio/decoders/raw.h"
+#include "audio/decoders/wave.h"
+#include "common/endian.h"
+#include "common/memstream.h"
+#include "common/system.h"
+#include "graphics/screen.h"
+#include "harvester/art.h"
+#include "harvester/resources.h"
+#include "harvester/runtime_entity.h"
+#include "harvester/script.h"
+#include "harvester/text.h"
+
+namespace Harvester {
+
+namespace {
+
+static const int8 kHarvesterImaIndexAdjustTable[16] = {
+	-1, -1, -1, -1, 2, 4, 6, 8,
+	-1, -1, -1, -1, 2, 4, 6, 8
+};
+
+static const uint16 kHarvesterImaStepTable[89] = {
+	7, 8, 9, 10, 11, 12, 13, 14, 16, 17,
+	19, 21, 23, 25, 28, 31, 34, 37, 41, 45,
+	50, 55, 60, 66, 73, 80, 88, 97, 107, 118,
+	130, 143, 157, 173, 190, 209, 230, 253, 279, 307,
+	337, 371, 408, 449, 494, 544, 598, 658, 724, 796,
+	876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066,
+	2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358,
+	5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899,
+	15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767
+};
+
+static int clampStartupOptionLevel(int level) {
+	if (level < 0)
+		return 0;
+	if (level > 9)
+		return 9;
+
+	return level;
+}
+
+static int mapStartupVolumeLevelToMixerVolume(int level) {
+	return (clampStartupOptionLevel(level) * Audio::Mixer::kMaxMixerVolume + 4) / 9;
+}
+
+static uint32 decodeHarvesterFcmp(byte *dest, const byte *src, uint32 srcSize, uint16 bitsPerSample) {
+	const uint32 decodedBytesPerInputByte = bitsPerSample >> 2;
+	const uint32 decodedSize = srcSize * decodedBytesPerInputByte;
+	const uint32 sampleCount = (bitsPerSample == 16) ? (decodedSize >> 1) : decodedSize;
+	int predictor = 0;
+	int stepIndex = 0;
+	int stepSize = 7;
+	byte cachedByte = 0;
+	uint32 srcPos = 0;
+
+	for (uint32 sample = 0; sample < sampleCount; ++sample) {
+		const byte nibble = ((sample & 1) == 0)
+			? ((cachedByte = src[srcPos++]) & 0x0f)
+			: ((cachedByte >> 4) & 0x0f);
+		int delta = stepSize >> 3;
+		if (nibble & 4)
+			delta += stepSize;
+		if (nibble & 2)
+			delta += stepSize >> 1;
+		if (nibble & 1)
+			delta += stepSize >> 2;
+		if (nibble & 8)
+			delta = -delta;
+
+		predictor += delta;
+		if (predictor > 0x7fff)
+			predictor = 0x7fff;
+		else if (predictor < -0x8000)
+			predictor = -0x8000;
+
+		if (bitsPerSample == 16) {
+			WRITE_LE_INT16(dest, predictor);
+			dest += 2;
+		} else {
+			*dest++ = (byte)(((predictor >> 8) & 0xff) ^ 0x80);
+		}
+
+		stepIndex += kHarvesterImaIndexAdjustTable[nibble];
+		if (stepIndex < 0)
+			stepIndex = 0;
+		else if (stepIndex > 88)
+			stepIndex = 88;
+		stepSize = kHarvesterImaStepTable[stepIndex];
+	}
+
+	return decodedSize;
+}
+
+static Audio::SeekableAudioStream *decodeStartupAudioStream(Common::SeekableReadStream *stream) {
+	if (!stream)
+		return nullptr;
+
+	if (stream->size() >= 14) {
+		char magic[4];
+		stream->read(magic, sizeof(magic));
+		if (!memcmp(magic, "FCMP", sizeof(magic))) {
+			const uint32 payloadSizeFromHeader = stream->readUint32LE();
+			const uint32 sampleRate = stream->readUint32LE();
+			const uint16 bitsPerSample = stream->readUint16LE();
+			const uint32 availablePayloadSize = stream->size() - 14;
+			const uint32 payloadSize = payloadSizeFromHeader != 0
+				? MIN<uint32>(payloadSizeFromHeader, availablePayloadSize)
+				: availablePayloadSize;
+
+			if (sampleRate == 0 || (bitsPerSample != 8 && bitsPerSample != 16) || payloadSize == 0) {
+				delete stream;
+				return nullptr;
+			}
+
+			if (!stream->seek(14)) {
+				delete stream;
+				return nullptr;
+			}
+
+			byte *compressedPayload = (byte *)malloc(payloadSize);
+			if (!compressedPayload) {
+				delete stream;
+				return nullptr;
+			}
+			if (stream->read(compressedPayload, payloadSize) != payloadSize) {
+				free(compressedPayload);
+				delete stream;
+				return nullptr;
+			}
+
+			const uint32 decodedSize = payloadSize * (bitsPerSample >> 2);
+			byte *decodedPcm = (byte *)malloc(decodedSize);
+			if (!decodedPcm) {
+				free(compressedPayload);
+				delete stream;
+				return nullptr;
+			}
+
+			decodeHarvesterFcmp(decodedPcm, compressedPayload, payloadSize, bitsPerSample);
+			free(compressedPayload);
+			delete stream;
+			return Audio::makeRawStream(decodedPcm, decodedSize, sampleRate,
+				Audio::FLAG_LITTLE_ENDIAN | ((bitsPerSample == 16) ? Audio::FLAG_16BITS : 0),
+				DisposeAfterUse::YES);
+		}
+	}
+
+	stream->seek(0);
+	return Audio::makeWAVStream(stream, DisposeAfterUse::YES);
+}
+
+static Audio::SeekableAudioStream *openStartupAudioStream(ResourceManager &resources, const Common::String &path) {
+	return decodeStartupAudioStream(resources.openFile(path));
+}
+
+} // End of anonymous namespace
+
+MediaManager::MediaManager(ResourceManager &resources) : _resources(resources) {
+	_runtimeEntities = new RuntimeEntityManager(_resources);
+}
+
+MediaManager::~MediaManager() {
+	stopMusic();
+	stopSound();
+	delete _text;
+	delete _art;
+	delete _screen;
+	delete _runtimeEntities;
+}
+
+float MediaManager::mapGammaLevelToBrightnessScale(int level) {
+	return 1.0f + 0.1f * clampStartupOptionLevel(level);
+}
+
+void MediaManager::resetScreen(int width, int height) {
+	delete _screen;
+	_screen = new Graphics::Screen();
+	_displayWidth = width;
+	_displayHeight = height;
+}
+
+bool MediaManager::loadArt() {
+	Art *art = new Art();
+	if (!art->load(_resources)) {
+		delete art;
+		return false;
+	}
+
+	delete _art;
+	_art = art;
+	return true;
+}
+
+bool MediaManager::loadText() {
+	Text *text = new Text();
+	if (!text->load(_resources)) {
+		delete text;
+		return false;
+	}
+
+	delete _text;
+	_text = text;
+	return true;
+}
+
+bool MediaManager::loadQuickTipsResources() {
+	return _art && _art->loadQuickTipsResources(_resources);
+}
+
+void MediaManager::drawWaitFrame() const {
+	if (_art && _screen)
+		_art->drawWaitFrame(*_screen);
+}
+
+void MediaManager::applyMixerLevels(int fxLevel, int musicLevel) {
+	if (!g_system || !g_system->getMixer())
+		return;
+
+	g_system->getMixer()->setVolumeForSoundType(Audio::Mixer::kSFXSoundType,
+		mapStartupVolumeLevelToMixerVolume(fxLevel));
+	g_system->getMixer()->setVolumeForSoundType(Audio::Mixer::kMusicSoundType,
+		mapStartupVolumeLevelToMixerVolume(musicLevel));
+
+	if (g_system->getMixer()->isSoundHandleActive(_musicHandle))
+		g_system->getMixer()->pauseHandle(_musicHandle, musicLevel == 0);
+}
+
+bool MediaManager::isMusicPlaying() const {
+	return g_system && g_system->getMixer() &&
+		g_system->getMixer()->isSoundHandleActive(_musicHandle);
+}
+
+bool MediaManager::playMusic(const Common::String &path) {
+	if (path.empty() || !g_system || !g_system->getMixer())
+		return false;
+
+	const Common::String normalizedPath = _resources.normalizeResourcePath(path);
+	if (_musicPath.equalsIgnoreCase(normalizedPath) &&
+			g_system->getMixer()->isSoundHandleActive(_musicHandle)) {
+		return true;
+	}
+
+	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(_resources, path);
+	if (!audioStream) {
+		warning("Harvester: unable to decode startup music '%s'", path.c_str());
+		return false;
+	}
+
+	stopMusic();
+	g_system->getMixer()->playStream(Audio::Mixer::kMusicSoundType, &_musicHandle,
+		Audio::makeLoopingAudioStream(audioStream, 0));
+	_musicPath = normalizedPath;
+	return true;
+}
+
+void MediaManager::pauseMusic(bool paused) {
+	if (g_system && g_system->getMixer())
+		g_system->getMixer()->pauseHandle(_musicHandle, paused);
+}
+
+void MediaManager::stopMusic() {
+	stopSoundHandle(_musicHandle);
+	_musicPath.clear();
+}
+
+bool MediaManager::executeAudioCommand(const StartupAudioCommand &command) {
+	switch (command.type) {
+	case kStartupAudioCommandStartWav:
+		return playSound(command.path);
+	case kStartupAudioCommandStartSingleWav:
+		return playSingleSound(command.path);
+	case kStartupAudioCommandLoadWav:
+		return loadSound(command.slot, command.path);
+	case kStartupAudioCommandPlayWav:
+		return playLoadedSound(command.slot);
+	case kStartupAudioCommandDeleteWav:
+		return deleteLoadedSound(command.slot);
+	default:
+		return false;
+	}
+}
+
+bool MediaManager::playSound(const Common::String &path) {
+	if (path.empty() || !g_system || !g_system->getMixer())
+		return false;
+
+	const Common::String normalizedPath = _resources.normalizeResourcePath(path);
+	for (int i = 0; i < ARRAYSIZE(_soundPaths); ++i) {
+		if (_soundPaths[i].equalsIgnoreCase(normalizedPath)) {
+			stopSoundHandle(_soundHandles[i]);
+			_soundPaths[i].clear();
+		}
+	}
+
+	_soundSlotIndex = (_soundSlotIndex + 1) % ARRAYSIZE(_soundHandles);
+	stopSoundHandle(_soundHandles[_soundSlotIndex]);
+
+	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(_resources, path);
+	if (!audioStream) {
+		warning("Harvester: unable to decode startup sound '%s'", path.c_str());
+		return false;
+	}
+
+	g_system->getMixer()->playStream(Audio::Mixer::kSFXSoundType,
+		&_soundHandles[_soundSlotIndex], audioStream);
+	_soundPaths[_soundSlotIndex] = normalizedPath;
+	return true;
+}
+
+bool MediaManager::playSingleSound(const Common::String &path) {
+	if (path.empty() || !g_system || !g_system->getMixer())
+		return false;
+
+	stopSoundHandle(_singleSoundHandle);
+	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(_resources, path);
+	if (!audioStream) {
+		warning("Harvester: unable to decode startup sound '%s'", path.c_str());
+		return false;
+	}
+
+	g_system->getMixer()->playStream(Audio::Mixer::kSFXSoundType, &_singleSoundHandle, audioStream);
+	return true;
+}
+
+void MediaManager::stopSingleSound() {
+	stopSoundHandle(_singleSoundHandle);
+}
+
+bool MediaManager::isSingleSoundPlaying() const {
+	return g_system && g_system->getMixer() &&
+		g_system->getMixer()->isSoundHandleActive(_singleSoundHandle);
+}
+
+bool MediaManager::playSpeech(const Common::String &path) {
+	if (path.empty() || !g_system || !g_system->getMixer())
+		return false;
+
+	stopSoundHandle(_speechHandle);
+	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(_resources, path);
+	if (!audioStream) {
+		warning("Harvester: unable to decode startup speech '%s'", path.c_str());
+		return false;
+	}
+
+	g_system->getMixer()->playStream(Audio::Mixer::kSpeechSoundType, &_speechHandle, audioStream);
+	return true;
+}
+
+void MediaManager::stopSpeech() {
+	stopSoundHandle(_speechHandle);
+}
+
+bool MediaManager::isSpeechPlaying() const {
+	return g_system && g_system->getMixer() &&
+		g_system->getMixer()->isSoundHandleActive(_speechHandle);
+}
+
+bool MediaManager::loadSound(int slot, const Common::String &path) {
+	if (!validateLoadedSoundSlot(slot) || path.empty())
+		return false;
+
+	stopSoundHandle(_loadedSoundHandles[slot]);
+	_loadedSoundPaths[slot].clear();
+	_loadedSoundData[slot].clear();
+
+	if (!_resources.loadFile(path, _loadedSoundData[slot])) {
+		warning("Harvester: unable to load startup sound '%s' into slot %d", path.c_str(), slot);
+		return false;
+	}
+
+	_loadedSoundPaths[slot] = _resources.normalizeResourcePath(path);
+	return true;
+}
+
+bool MediaManager::playLoadedSound(int slot) {
+	if (!validateLoadedSoundSlot(slot) || !g_system || !g_system->getMixer())
+		return false;
+	if (_loadedSoundData[slot].empty())
+		return false;
+
+	stopSoundHandle(_loadedSoundHandles[slot]);
+
+	Common::SeekableReadStream *stream = new Common::MemoryReadStream(
+		_loadedSoundData[slot].data(), _loadedSoundData[slot].size(), DisposeAfterUse::NO);
+	Audio::SeekableAudioStream *audioStream = decodeStartupAudioStream(stream);
+	if (!audioStream) {
+		warning("Harvester: unable to decode startup sound slot %d ('%s')",
+			slot, _loadedSoundPaths[slot].c_str());
+		return false;
+	}
+
+	g_system->getMixer()->playStream(Audio::Mixer::kSFXSoundType, &_loadedSoundHandles[slot], audioStream);
+	return true;
+}
+
+bool MediaManager::deleteLoadedSound(int slot) {
+	if (!validateLoadedSoundSlot(slot))
+		return false;
+
+	stopSoundHandle(_loadedSoundHandles[slot]);
+	_loadedSoundPaths[slot].clear();
+	_loadedSoundData[slot].clear();
+	return true;
+}
+
+void MediaManager::stopSound() {
+	stopSingleSound();
+	stopSpeech();
+	for (int i = 0; i < ARRAYSIZE(_soundHandles); ++i) {
+		stopSoundHandle(_soundHandles[i]);
+		_soundPaths[i].clear();
+	}
+	for (int i = 0; i < ARRAYSIZE(_loadedSoundHandles); ++i) {
+		stopSoundHandle(_loadedSoundHandles[i]);
+		_loadedSoundPaths[i].clear();
+		_loadedSoundData[i].clear();
+	}
+	_soundSlotIndex = -1;
+}
+
+void MediaManager::stopSoundHandle(Audio::SoundHandle &handle) {
+	if (g_system && g_system->getMixer())
+		g_system->getMixer()->stopHandle(handle);
+}
+
+bool MediaManager::validateLoadedSoundSlot(int slot) const {
+	return slot >= 0 && slot < ARRAYSIZE(_loadedSoundHandles);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/media_manager.h b/engines/harvester/media_manager.h
new file mode 100644
index 00000000000..19691ef12ae
--- /dev/null
+++ b/engines/harvester/media_manager.h
@@ -0,0 +1,104 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_MEDIA_MANAGER_H
+#define HARVESTER_MEDIA_MANAGER_H
+
+#include "audio/mixer.h"
+#include "common/array.h"
+#include "common/str.h"
+
+namespace Graphics {
+class Screen;
+}
+
+namespace Harvester {
+
+class Art;
+class ResourceManager;
+class RuntimeEntityManager;
+struct StartupAudioCommand;
+class Text;
+
+class MediaManager {
+public:
+	explicit MediaManager(ResourceManager &resources);
+	~MediaManager();
+
+	RuntimeEntityManager *getRuntimeEntities() const { return _runtimeEntities; }
+	Graphics::Screen *getScreen() const { return _screen; }
+	int getDisplayWidth() const { return _displayWidth; }
+	int getDisplayHeight() const { return _displayHeight; }
+	Art *getArt() const { return _art; }
+	Text *getText() const { return _text; }
+	const Common::String &getMusicPath() const { return _musicPath; }
+
+	static float mapGammaLevelToBrightnessScale(int level);
+
+	void resetScreen(int width, int height);
+	bool loadArt();
+	bool loadText();
+	bool loadQuickTipsResources();
+	void drawWaitFrame() const;
+	void applyMixerLevels(int fxLevel, int musicLevel);
+	bool isMusicPlaying() const;
+	bool playMusic(const Common::String &path);
+	void pauseMusic(bool paused);
+	void stopMusic();
+	bool executeAudioCommand(const StartupAudioCommand &command);
+	bool playSound(const Common::String &path);
+	bool playSingleSound(const Common::String &path);
+	void stopSingleSound();
+	bool isSingleSoundPlaying() const;
+	bool playSpeech(const Common::String &path);
+	void stopSpeech();
+	bool isSpeechPlaying() const;
+	bool loadSound(int slot, const Common::String &path);
+	bool playLoadedSound(int slot);
+	bool deleteLoadedSound(int slot);
+	void stopSound();
+
+private:
+	void stopSoundHandle(Audio::SoundHandle &handle);
+	bool validateLoadedSoundSlot(int slot) const;
+
+	ResourceManager &_resources;
+	RuntimeEntityManager *_runtimeEntities = nullptr;
+	Graphics::Screen *_screen = nullptr;
+	int _displayWidth = 0;
+	int _displayHeight = 0;
+	Art *_art = nullptr;
+	Text *_text = nullptr;
+	Audio::SoundHandle _musicHandle;
+	Audio::SoundHandle _singleSoundHandle;
+	Audio::SoundHandle _speechHandle;
+	Common::String _musicPath;
+	int _soundSlotIndex = -1;
+	Common::String _soundPaths[8];
+	Audio::SoundHandle _soundHandles[8];
+	Common::String _loadedSoundPaths[4];
+	Common::Array<byte> _loadedSoundData[4];
+	Audio::SoundHandle _loadedSoundHandles[4];
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_MEDIA_MANAGER_H
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 282363b44fd..553017e81c0 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -14,6 +14,7 @@ MODULE := engines/harvester
 	dialogue.o \
 	fst_player.o \
 	metaengine.o \
+	media_manager.o \
 	npc/curator_dialogue.o \
 	npc/dad_dialogue.o \
 	npc/dark_woman_dialogue.o \


Commit: 8d981383d921787e5843e961d8115fba86f20a0c
    https://github.com/scummvm/scummvm/commit/8d981383d921787e5843e961d8115fba86f20a0c
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:27+02:00

Commit Message:
HARVESTER: Wire startup and load game menus

Changed paths:
    TRACKER.md
    engines/harvester/flow.cpp
    engines/harvester/flow.h
    engines/harvester/menu.cpp
    engines/harvester/menu.h
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 61029ac478d..f05d975ff41 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -2,25 +2,63 @@
 
 ## Current Focus
 
-- Re-test Jimmy's first no-item conversation in-engine against DOSBox now that the hidden response-menu chain is wired up
-- Verify any remaining NPC openings that were decompiled as single-line returns against raw post-`play_dialogue_line` blocks in Ghidra
-- Keep dialogue handler edits tied to confirmed `DIALOG.RSP` line indices and native state writes instead of inferred subtitle order
+- Work down the remaining `FUN_*` list in address order, but stop when only literal HMIDRV subcommand IDs or unresolved runtime/x87 structure fields are proven
+- Keep renames tied to concrete caller/callee context, strings, DOS/BIOS/DPMI interrupt semantics, and direct buffer/global side effects
+- Leave jump-entered dialogue blocks, zero-xref `.image::` fragments, and still-ambiguous runtime/x87 helpers unnamed until the owning structures are reconstructed
 
 ## Progress
 
 - Program: `HARVEST.LE`
 - Total functions: `900`
-- Named/documented: `587`
-- Still `FUN_*` / undocumented: `313`
+- Named/documented: `784`
+- Remaining `FUN_*`: `116`
+- Remaining undocumented total: `116`
 
 ## Last Confirmed Action
 
-- Revisited `handle_talk_to_jimmy`, `run_dialogue_response_menu`, and the hidden post-`play_dialogue_line` Jimmy blocks in Ghidra after DOSBox showed the engine was stopping too early.
-  - Jimmy still has no keyword loop, but his first no-item conversation is not a single bark: native code plays `0x4a4c` / `0x4a58`, sets `PAPER_CHK_1` and `GIVEN_PAPER_TODAY`, then opens zero-based `DIALOG.RSP` response menus `0xf5`, `0xf6`, and `0xf7` in sequence.
-  - The third response-menu branch checks inventory `SNEAKERS`, restores them to `RAH`, and awards `BROOMKEY`; the direct `SNEAKERS` and first-time `NEWSPAPER` item paths also continue into extra native follow-up lines that the earlier decompile had hidden.
-- Updated `engines/harvester/npc/jimmy_dialogue.cpp` to follow that native response order and rebuilt `engines/harvester/npc/jimmy_dialogue.o` successfully.
+- On March 19, 2026, completed the first real startup/room-menu parity pass in `engines/harvester`: `Flow::run()` now hands off from quick tips into the startup menu instead of dropping straight into `runRoomLoop("START")`; `LOAD GAME` now uses the native `LOADGAME.BM` / `LOADGAME.PAL` slot picker; `NEW GAME` now uses the confirmed `MENU.INI:newgame` confirmation prompt in both startup and in-room menus; and the in-room ESC menu now unwinds cleanly into restart/load handoffs instead of leaving those rows as stubs.
+- Rebuilt the touched Harvester objects successfully: `engines/harvester/menu.o`, `engines/harvester/flow.o`, `engines/harvester/room.o`, and `engines/harvester/harvester.o`.
+- On March 19, 2026, audited the current `engines/harvester` implementation against the live `HARVEST.LE` Ghidra session and the confirmed notes in `ARCHITECTURE.md`; verified that boot/resource loading, room setup, inventory, dialogue, save menu, help, options, and quit confirmation are already implemented, while the main remaining reimplementation gaps are startup/room menu parity, actor runtime/combat/timers, and wider script opcode coverage.
+- Finished another rename pass, saved `HARVEST.LE`, and advanced the current tracker cycle to 194 confident renames total.
+- The latest two passes resolved 12 more high-confidence names:
+  `execute_runtime_handler_list_until_target`, `find_and_store_runtime_handler_type3_entry`,
+  `convert_int32_to_extended`, `convert_double_words_to_extended`, `convert_extended_to_float_bits`, `convert_extended_to_double_words`,
+  `convert_float_bits_to_extended`, `round_extended_by_control_word_mode`,
+  `compute_extended_atan2`, `compute_extended_atan_core`, `compute_extended_log2`, and `compute_extended_natural_log`.
+- The current boundary is now narrower:
+  `FUN_00021100` / `FUN_00021120` are still only proven as paired min/max extent updaters with unresolved owner-axis semantics;
+  `FUN_0002e3b4`, `FUN_0002e41b`, `FUN_0002e46a`, `FUN_0002f7fd`, `FUN_000324a2`, and `FUN_0003351b` are still jump-entered dialogue mis-splits rather than stable standalone functions;
+  `FUN_00083fb3`, `FUN_00084a8b`, `FUN_00084b6b`, and the `FUN_00087005` through `FUN_00087439` band are still HMIDRV/sound-bank wrappers where only subcommand IDs and buffer movement are proven;
+  the remaining `FUN_0008c3d8` through `FUN_00091518` cluster is now mostly x87/no-87 runtime state, shared polynomial helpers, operand classifiers, and throw/unwind support whose table semantics, hidden ABI, or record layouts are still below the rename threshold;
+  the leading `.image::` entries remain zero-xref fragments with no reliable subsystem context.
 
 ## Next Suggested Action
 
-1. Re-test Jimmy's first conversation from a fresh state and confirm the response menus appear in the native `0xf5 -> 0xf6 -> 0xf7` order with the same follow-up lines as DOSBox.
-2. If another NPC opener still stops after one line, inspect its raw xrefs to `run_dialogue_response_menu` and `load_dialogue_response_line` before trusting the decompiler output.
+1. Re-test the startup menu and in-room ESC menu in-engine against the confirmed `run_main_menu` / `run_load_game_menu` notes: verify `NEW GAME`, `SAVE GAME`, `LOAD GAME`, `OPTIONS`, `HELP`, and `QUIT GAME` all unwind to the correct room/palette state and that the restart/load handoffs do not leave stale scene state behind.
+2. Finish the remaining startup-menu parity gap by replacing the temporary inventory-panel startup backdrop with the live `START` room/menu presentation and checking the missing timer pause/resume details before moving on to the actor runtime/combat/timer work in priority item 2.
+
+## Reimplementation Priority Order
+
+1. Finish startup and room menu parity.
+   Files: `engines/harvester/menu.cpp`, `engines/harvester/menu.h`, `engines/harvester/flow.cpp`, `engines/harvester/harvester.cpp`, `engines/harvester/harvester.h`.
+   Native anchors: `run_main_menu @ 0x67390`, `run_load_game_menu @ 0x64910`, `run_save_game_menu @ 0x632c0`, `run_controls_help_screen @ 0x6c3e0`.
+   Concrete tasks: keep the new real `LOAD GAME` path, startup-menu handoff, and `NEW GAME` / restart confirmation aligned with the confirmed native notes; finish swapping the startup menu off the temporary stub backdrop and onto the live `START` scene; verify the menu/timer/palette restore details after each branch.
+   Exit criteria: every visible menu row performs real work instead of falling back to a debug stub, and the startup-menu return path plus the in-room ESC menu expose the same native actions.
+2. Implement the actor runtime and combat-adjacent substrate.
+   Files: `engines/harvester/room.cpp`, `engines/harvester/flow.cpp`, `engines/harvester/runtime_entity.cpp`, `engines/harvester/runtime_entity.h`, `engines/harvester/script.cpp`, `engines/harvester/script.h`.
+   Native anchors: `spawn_player_combat_avatar @ 0x54220`, `update_player_combat_avatar_state @ 0x553a0`, `teardown_player_combat_avatar @ 0x55010`, `tick_monster_entity_runtime @ 0x54140`, `spawn_timer_entity_from_record @ 0x59390`, `pause_timer_entity_countdowns @ 0x80460`, `resume_timer_entity_countdowns @ 0x804a0`.
+   Concrete tasks: separate passive scene visuals from live actor state; persist the player combat-avatar HP/loadout state instead of only the coarse room-placement snapshot; add monster update hooks; add timer countdown bookkeeping needed by help/menu pauses and room events.
+   Exit criteria: monsters are no longer static scene art only, and timer-sensitive menu/help behavior matches the confirmed native flow.
+3. Widen town-script action and room-event coverage only where native behavior is already confirmed.
+   Files: `engines/harvester/script.cpp`, `engines/harvester/room.cpp`, `engines/harvester/dialogue.cpp`.
+   Native anchors: `dispatch_room_event_actions @ 0x60ee0`, `run_town_script_interpreter @ 0x46d80`, and the confirmed town-script notes in `ARCHITECTURE.md`.
+   Concrete tasks: drive missing work from broken rooms and interactions; add only the opcodes and side effects needed to unblock verified room paths; keep unsupported commands explicit rather than guessing.
+   Exit criteria: room progression failures shrink to a small, explicit list of still-unimplemented native commands instead of broad "unsupported" fallthrough.
+4. Tighten inventory and item-use flow against that actor runtime.
+   Files: `engines/harvester/inventory.cpp`, `engines/harvester/room.cpp`, `engines/harvester/script.cpp`.
+   Native anchors: `run_inventory_screen @ 0x7df10`, `add_object_to_inventory @ 0x7c8b0`, `sync_player_combat_weapon_resource_icons @ 0x792c0`, and the confirmed inventory carry/use notes in `ARCHITECTURE.md`.
+   Concrete tasks: keep the current carry/use handoff model, but recheck it once actor runtime state, HP, timers, and combat-adjacent HUD elements are live; fold any missing status/icon behavior in here rather than earlier.
+   Exit criteria: inventory, carried items, and room-target use cases all operate against the same live actor state that room gameplay uses.
+5. Leave low-level runtime, HMIDRV, and x87 cleanup as deferred RE work unless they block a confirmed gameplay feature.
+   Ghidra anchors: the remaining unwind helpers around `FUN_0008f64b` through `FUN_000905df`, the HMIDRV wrapper band around `FUN_00087005`, and the x87/no-87 runtime cluster beginning near `FUN_0008c3d8`.
+   Rationale: those clusters still matter for naming completeness, but the current engine-side gaps are better bounded and have higher reimplementation value.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index a70681eb022..b6c0e96ad34 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -1703,6 +1703,7 @@ Common::Error Flow::run() {
 		return Common::kReadingFailed;
 
 	clearPendingMainMenuReturn();
+	clearPendingNewGameRestart();
 	resetRoomNpcDialogueState();
 	Common::Error error = Common::kNoError;
 	if (_engine.hasPendingLoadedStartupSaveRoomState()) {
@@ -1718,8 +1719,9 @@ Common::Error Flow::run() {
 			return error;
 
 		clearPendingMainMenuReturn();
-		_engine.getStartupScript()->resetRuntimeState();
-		error = runRoomLoop("START");
+		clearPendingNewGameRestart();
+		error = runMainMenuStub();
+		return error;
 	}
 	if (error.getCode() != Common::kNoError)
 		return error;
@@ -2061,6 +2063,34 @@ bool Flow::takeQueuedDialogueInteraction(StartupInteractionResult &interaction)
 	return true;
 }
 
+void Flow::prepareForNewGame() {
+	clearPendingMainMenuReturn();
+	clearPendingNewGameRestart();
+	_engine.clearPendingLoadedStartupSaveRoomState();
+	_engine.clearCurrentStartupSaveRoomState();
+	if (_engine.getStartupScript())
+		_engine.getStartupScript()->resetRuntimeState();
+	resetRoomNpcDialogueState();
+}
+
+void Flow::requestNewGameRestart() {
+	_pendingNewGameRestart = true;
+}
+
+bool Flow::hasPendingNewGameRestart() const {
+	return _pendingNewGameRestart;
+}
+
+bool Flow::takePendingNewGameRestart() {
+	const bool requested = _pendingNewGameRestart;
+	_pendingNewGameRestart = false;
+	return requested;
+}
+
+void Flow::clearPendingNewGameRestart() {
+	_pendingNewGameRestart = false;
+}
+
 void Flow::requestMainMenuReturn() {
 	_pendingMainMenuReturn = true;
 	_engine.clearCurrentStartupSaveRoomState();
diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index 184526eec17..8b32701d35d 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -72,13 +72,18 @@ private:
 		Common::Error fadeInRoomScene(const byte *palette, float targetBrightness);
 		bool pumpTransitionEvents(Common::Error &result);
 		void executeStartupAudioCommands(const Common::Array<StartupAudioCommand> &commands);
-		void queueDialogueInteraction(const StartupInteractionResult &interaction);
-		bool takeQueuedDialogueInteraction(StartupInteractionResult &interaction);
-		void requestMainMenuReturn();
-		bool hasPendingMainMenuReturn() const;
-		bool takePendingMainMenuReturn();
-		void clearPendingMainMenuReturn();
-		void resetRoomNpcDialogueState();
+	void queueDialogueInteraction(const StartupInteractionResult &interaction);
+	bool takeQueuedDialogueInteraction(StartupInteractionResult &interaction);
+	void prepareForNewGame();
+	void requestNewGameRestart();
+	bool hasPendingNewGameRestart() const;
+	bool takePendingNewGameRestart();
+	void clearPendingNewGameRestart();
+	void requestMainMenuReturn();
+	bool hasPendingMainMenuReturn() const;
+	bool takePendingMainMenuReturn();
+	void clearPendingMainMenuReturn();
+	void resetRoomNpcDialogueState();
 		void resetCursorAnimationSequence();
 		bool tickRuntimeEntities();
 		bool handleSystemEvent(const Common::Event &event, Common::Error &result);
@@ -93,6 +98,7 @@ private:
 	RoomSystem _room;
 	StartupInteractionResult _queuedDialogueInteraction;
 	bool _hasQueuedDialogueInteraction = false;
+	bool _pendingNewGameRestart = false;
 	bool _pendingMainMenuReturn = false;
 };
 
diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index 85365456bb7..390afe3672d 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -36,6 +36,7 @@
 #include "harvester/harvester.h"
 #include "harvester/palette_utils.h"
 #include "harvester/resources.h"
+#include "harvester/room_support.h"
 #include "harvester/runtime_entity.h"
 #include "harvester/art.h"
 #include "harvester/flow.h"
@@ -66,6 +67,8 @@ static const char *const kMenuSectionName = "menu";
 static const char *const kOptionsVolumeBitmapPath = "1:/GRAPHIC/OTHER/VOLUME.BM";
 static const char *const kOptionsIndicatorBitmapPath = "1:/GRAPHIC/OTHER/INDICATR.BM";
 static const char *const kOptionsPreviewSoundPath = "1:/SOUND/EFFECTS/WHIP2.WAV";
+static const char *const kLoadGameBitmapPath = "1:/GRAPHIC/OTHER/LOADGAME.BM";
+static const char *const kLoadGamePalettePath = "1:/GRAPHIC/PAL/LOADGAME.PAL";
 static const char *const kSaveGameBitmapPath = "1:/GRAPHIC/OTHER/SAVEGAME.BM";
 static const char *const kSaveGamePalettePath = "1:/GRAPHIC/PAL/SAVEGAME.PAL";
 
@@ -107,6 +110,7 @@ struct RoomMenuTextConfig {
 	Common::String yesLabel = "YES";
 	Common::String noLabel = "NO";
 	Common::String clickLabel = "CLICK";
+	Common::String newGamePrompt = "NEW GAME";
 	Common::String quitGamePrompt = "QUIT GAME";
 };
 
@@ -218,6 +222,8 @@ static bool loadMenuTextConfig(HarvesterEngine &engine, RoomMenuTextConfig &conf
 		config.noLabel = value;
 	if (menu.getKey("click", kMenuSectionName, value) && !value.empty())
 		config.clickLabel = value;
+	if (menu.getKey("newgame", kMenuSectionName, value) && !value.empty())
+		config.newGamePrompt = value;
 	if (menu.getKey("quitgame", kMenuSectionName, value) && !value.empty())
 		config.quitGamePrompt = value;
 
@@ -508,9 +514,10 @@ static void renderSaveGameMenuScreen(HarvesterEngine &engine, const IndexedBitma
 	screen->update();
 }
 
-static void renderQuitGameConfirmScreen(HarvesterEngine &engine, const IndexedBitmap &backdrop,
+static void renderConfirmPromptScreen(HarvesterEngine &engine, const IndexedBitmap &backdrop,
 		const byte *palette, float paletteBrightness, const Graphics::Font &font,
-		const IndexedBitmap &textbox, const RoomMenuTextConfig &config, bool hoverYes, bool hoverNo) {
+		const IndexedBitmap &textbox, const Common::String &promptText,
+		const RoomMenuTextConfig &config, bool hoverYes, bool hoverNo) {
 	Graphics::Screen *screen = engine.getScreen();
 	const Art *art = engine.getStartupArt();
 	if (!screen || !art)
@@ -521,7 +528,7 @@ static void renderQuitGameConfirmScreen(HarvesterEngine &engine, const IndexedBi
 	blitTransparentBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
 	blitTransparentBitmap(*screen, textbox, kConfirmDialogX, kConfirmDialogY);
 	Common::Array<Common::String> promptLines;
-	splitMenuConfigLines(config.quitGamePrompt, promptLines);
+	splitMenuConfigLines(promptText, promptLines);
 	for (uint i = 0; i < promptLines.size(); ++i) {
 		drawShadowedString(*screen, font, promptLines[i], kConfirmPromptX,
 			kConfirmPromptY + (int)i * (font.getFontHeight() + 2), kConfirmPromptWidth,
@@ -568,11 +575,120 @@ MenuSystem::MenuSystem(HarvesterEngine &engine, Common::Point &mousePos,
 }
 
 Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
+	const Art *art = _engine.getStartupArt();
+	if (!art)
+		return Common::kReadingFailed;
+
 	Graphics::FrameLimiter limiter(g_system, 60);
 	int selectedItem = _menuItems.empty() ? -1 : 0;
-	Common::String statusMessage = "Main menu stub active.";
+	Common::String statusMessage;
 	bool needsRedraw = true;
 
+	auto captureMenuBackdrop = [&](IndexedBitmap &backdrop) -> bool {
+		renderMainMenuScreen(selectedItem, statusMessage, false);
+		Graphics::Screen *screen = _engine.getScreen();
+		return screen && captureScreenBackdrop(*screen, backdrop);
+	};
+	auto runSelectedRoomLoop = [&](const Common::String &targetName) -> Common::Error {
+		Common::Error roomError = startupFlow.runRoomLoop(targetName);
+		_engine.stopStartupMusic();
+		_engine.stopStartupSound();
+		if (roomError.getCode() == Common::kReadingFailed) {
+			statusMessage = Common::String::format(
+				"Unable to resolve %s room setup from HARVEST.SCR.", targetName.c_str());
+			needsRedraw = true;
+			return Common::kNoError;
+		}
+		if (roomError.getCode() != Common::kNoError)
+			return roomError;
+
+		statusMessage.clear();
+		needsRedraw = true;
+		(void)startupFlow.takePendingMainMenuReturn();
+		return Common::kNoError;
+	};
+	auto activateSelectedItem = [&]() -> Common::Error {
+		if (selectedItem < 0 || selectedItem >= (int)_menuItems.size())
+			return Common::kNoError;
+
+		const Common::String &item = _menuItems[selectedItem];
+		const byte *menuPalette = art->getWaitPalette();
+		statusMessage.clear();
+
+		if (item.equalsIgnoreCase("NEW GAME")) {
+			RoomMenuTextConfig config;
+			(void)loadMenuTextConfig(_engine, config);
+
+			IndexedBitmap menuBackdrop;
+			if (!captureMenuBackdrop(menuBackdrop))
+				return Common::kReadingFailed;
+
+			bool confirmed = false;
+			Common::Error confirmError = runConfirmPrompt(
+				menuBackdrop, menuPalette, 1.0f, startupFlow, config.newGamePrompt, confirmed);
+			if (confirmError.getCode() != Common::kNoError)
+				return confirmError;
+			needsRedraw = true;
+			if (!confirmed)
+				return Common::kNoError;
+
+			startupFlow.prepareForNewGame();
+			return runSelectedRoomLoop("START");
+		}
+
+		if (item.equalsIgnoreCase("LOAD GAME")) {
+			bool loadedGame = false;
+			Common::Error loadError = runLoadGameMenu(menuPalette, 1.0f, startupFlow, loadedGame);
+			if (loadError.getCode() != Common::kNoError)
+				return loadError;
+			needsRedraw = true;
+			if (!loadedGame)
+				return Common::kNoError;
+
+			Common::String targetName = _engine.getPendingLoadedStartupSaveRoomState().entranceName;
+			if (targetName.empty())
+				targetName = _engine.getPendingLoadedStartupSaveRoomState().roomName;
+			if (targetName.empty()) {
+				statusMessage = "Loaded save is missing its room target.";
+				return Common::kNoError;
+			}
+
+			return runSelectedRoomLoop(targetName);
+		}
+
+		if (item.equalsIgnoreCase("SAVE GAME")) {
+			Common::Error saveError = runSaveGameMenu(menuPalette, 1.0f, startupFlow);
+			needsRedraw = true;
+			return saveError;
+		}
+
+		if (item.equalsIgnoreCase("OPTIONS")) {
+			IndexedBitmap menuBackdrop;
+			if (!captureMenuBackdrop(menuBackdrop))
+				return Common::kReadingFailed;
+			Common::Error optionsError = runOptionsMenu(menuBackdrop, menuPalette, 1.0f, startupFlow);
+			needsRedraw = true;
+			return optionsError;
+		}
+
+		if (item.equalsIgnoreCase("HELP")) {
+			Common::Error helpError = runHelpScreen(menuPalette, 1.0f, startupFlow);
+			needsRedraw = true;
+			return helpError;
+		}
+
+		if (item.equalsIgnoreCase("QUIT GAME")) {
+			IndexedBitmap menuBackdrop;
+			if (!captureMenuBackdrop(menuBackdrop))
+				return Common::kReadingFailed;
+			Common::Error quitError = runQuitGameConfirm(menuBackdrop, menuPalette, 1.0f, startupFlow);
+			needsRedraw = true;
+			return quitError;
+		}
+
+		return Common::kNoError;
+	};
+
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
 			renderMainMenuStub(selectedItem, statusMessage);
@@ -609,35 +725,12 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 					needsRedraw = true;
 				} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
 						event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
-					if (_menuItems[selectedItem].equalsIgnoreCase("QUIT GAME"))
-						return Common::kNoError;
-
-					if (_menuItems[selectedItem].equalsIgnoreCase("NEW GAME")) {
-						startupFlow.clearPendingMainMenuReturn();
-						_engine.getStartupScript()->resetRuntimeState();
-						Common::Error roomError = startupFlow.runRoomLoop("START");
-						_engine.stopStartupMusic();
-						_engine.stopStartupSound();
-						if (roomError.getCode() == Common::kReadingFailed) {
-							statusMessage = "Unable to resolve START room setup from HARVEST.SCR.";
-							needsRedraw = true;
-						} else if (roomError.getCode() != Common::kNoError) {
-							return roomError;
-						} else if (startupFlow.takePendingMainMenuReturn()) {
-							statusMessage = "Returned to main menu after startup death sequence.";
-							needsRedraw = true;
-						} else {
-							statusMessage = "Resolved START room handoff from HARVEST.SCR.";
-							needsRedraw = true;
-						}
-					} else {
-						statusMessage = Common::String::format("%s is stubbed; room setup and gameplay loop are next.",
-							_menuItems[selectedItem].c_str());
-						needsRedraw = true;
-					}
+					Common::Error activateError = activateSelectedItem();
+					if (activateError.getCode() != Common::kNoError)
+						return activateError;
 				}
 				break;
-			case Common::EVENT_LBUTTONDOWN:
+			case Common::EVENT_LBUTTONDOWN: {
 				if (_menuItems.empty())
 					break;
 
@@ -645,33 +738,11 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 				if (selectedItem == -1)
 					break;
 
-				if (_menuItems[selectedItem].equalsIgnoreCase("QUIT GAME"))
-					return Common::kNoError;
-
-				if (_menuItems[selectedItem].equalsIgnoreCase("NEW GAME")) {
-					startupFlow.clearPendingMainMenuReturn();
-					_engine.getStartupScript()->resetRuntimeState();
-					Common::Error roomError = startupFlow.runRoomLoop("START");
-					_engine.stopStartupMusic();
-					_engine.stopStartupSound();
-					if (roomError.getCode() == Common::kReadingFailed) {
-						statusMessage = "Unable to resolve START room setup from HARVEST.SCR.";
-						needsRedraw = true;
-					} else if (roomError.getCode() != Common::kNoError) {
-						return roomError;
-					} else if (startupFlow.takePendingMainMenuReturn()) {
-						statusMessage = "Returned to main menu after startup death sequence.";
-						needsRedraw = true;
-					} else {
-						statusMessage = "Resolved START room handoff from HARVEST.SCR.";
-						needsRedraw = true;
-					}
-				} else {
-					statusMessage = Common::String::format("%s is stubbed; room setup and gameplay loop are next.",
-						_menuItems[selectedItem].c_str());
-					needsRedraw = true;
-				}
+				Common::Error activateError = activateSelectedItem();
+				if (activateError.getCode() != Common::kNoError)
+					return activateError;
 				break;
+			}
 			default:
 				break;
 			}
@@ -692,8 +763,78 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 	Graphics::FrameLimiter limiter(g_system, 60);
 	int selectedItem = _menuItems.empty() ? -1 : 0;
 	bool needsRedraw = true;
+	bool shouldCloseMenu = false;
 	startupFlow.resetCursorAnimationSequence();
 
+	auto activateSelectedItem = [&]() -> Common::Error {
+		if (selectedItem < 0 || selectedItem >= (int)_menuItems.size())
+			return Common::kNoError;
+
+		const Common::String &item = _menuItems[selectedItem];
+		if (item.equalsIgnoreCase("NEW GAME")) {
+			RoomMenuTextConfig config;
+			(void)loadMenuTextConfig(_engine, config);
+
+			bool confirmed = false;
+			Common::Error confirmError = runConfirmPrompt(
+				backdrop, palette, paletteBrightness, startupFlow, config.newGamePrompt, confirmed);
+			if (confirmError.getCode() != Common::kNoError)
+				return confirmError;
+			needsRedraw = true;
+			if (confirmed) {
+				startupFlow.requestNewGameRestart();
+				shouldCloseMenu = true;
+			}
+			return Common::kNoError;
+		}
+
+		if (item.equalsIgnoreCase("LOAD GAME")) {
+			bool loadedGame = false;
+			Common::Error loadError = runLoadGameMenu(palette, paletteBrightness, startupFlow, loadedGame);
+			if (loadError.getCode() != Common::kNoError)
+				return loadError;
+			needsRedraw = true;
+			if (loadedGame)
+				shouldCloseMenu = true;
+			return Common::kNoError;
+		}
+
+		if (item.equalsIgnoreCase("OPTIONS")) {
+			Common::Error optionsError = runOptionsMenu(backdrop, palette, paletteBrightness, startupFlow);
+			if (optionsError.getCode() != Common::kNoError)
+				return optionsError;
+			needsRedraw = true;
+			return Common::kNoError;
+		}
+
+		if (item.equalsIgnoreCase("HELP")) {
+			Common::Error helpError = runHelpScreen(palette, paletteBrightness, startupFlow);
+			if (helpError.getCode() != Common::kNoError)
+				return helpError;
+			needsRedraw = true;
+			return Common::kNoError;
+		}
+
+		if (item.equalsIgnoreCase("SAVE GAME")) {
+			Common::Error saveError = runSaveGameMenu(palette, paletteBrightness, startupFlow);
+			if (saveError.getCode() != Common::kNoError)
+				return saveError;
+			needsRedraw = true;
+			return Common::kNoError;
+		}
+
+		if (item.equalsIgnoreCase("QUIT GAME")) {
+			Common::Error quitError = runQuitGameConfirm(backdrop, palette, paletteBrightness, startupFlow);
+			if (quitError.getCode() != Common::kNoError)
+				return quitError;
+			needsRedraw = true;
+			return Common::kNoError;
+		}
+
+		debug(1, "Harvester: room menu item '%s' selected but not implemented", item.c_str());
+		return Common::kNoError;
+	};
+
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
 			renderRoomMenuStub(backdrop, selectedItem);
@@ -730,33 +871,15 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 					needsRedraw = true;
 				} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
 						event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
-					if (selectedItem >= 0 && _menuItems[selectedItem].equalsIgnoreCase("OPTIONS")) {
-						Common::Error optionsError = runOptionsMenu(backdrop, palette, paletteBrightness, startupFlow);
-						if (optionsError.getCode() != Common::kNoError)
-							return optionsError;
-						needsRedraw = true;
-					} else if (selectedItem >= 0 && _menuItems[selectedItem].equalsIgnoreCase("HELP")) {
-						Common::Error helpError = runHelpScreen(palette, paletteBrightness, startupFlow);
-						if (helpError.getCode() != Common::kNoError)
-							return helpError;
-						needsRedraw = true;
-					} else if (selectedItem >= 0 && _menuItems[selectedItem].equalsIgnoreCase("SAVE GAME")) {
-						Common::Error saveError = runSaveGameMenu(palette, paletteBrightness, startupFlow);
-						if (saveError.getCode() != Common::kNoError)
-							return saveError;
-						needsRedraw = true;
-					} else if (selectedItem >= 0 && _menuItems[selectedItem].equalsIgnoreCase("QUIT GAME")) {
-						Common::Error quitError = runQuitGameConfirm(backdrop, palette, paletteBrightness, startupFlow);
-						if (quitError.getCode() != Common::kNoError)
-							return quitError;
-						needsRedraw = true;
-					} else {
-						debug(1, "Harvester: room menu item '%s' selected but not implemented",
-							selectedItem >= 0 ? _menuItems[selectedItem].c_str() : "");
-					}
+					shouldCloseMenu = false;
+					Common::Error activateError = activateSelectedItem();
+					if (activateError.getCode() != Common::kNoError)
+						return activateError;
+					if (shouldCloseMenu)
+						return Common::kNoError;
 				}
 				break;
-			case Common::EVENT_LBUTTONDOWN:
+			case Common::EVENT_LBUTTONDOWN: {
 				if (_menuItems.empty())
 					break;
 
@@ -764,29 +887,172 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 				if (selectedItem == -1)
 					break;
 
-				if (_menuItems[selectedItem].equalsIgnoreCase("OPTIONS")) {
-					Common::Error optionsError = runOptionsMenu(backdrop, palette, paletteBrightness, startupFlow);
-					if (optionsError.getCode() != Common::kNoError)
-						return optionsError;
+				shouldCloseMenu = false;
+				Common::Error activateError = activateSelectedItem();
+				if (activateError.getCode() != Common::kNoError)
+					return activateError;
+				if (shouldCloseMenu)
+					return Common::kNoError;
+				break;
+			}
+			default:
+				break;
+			}
+		}
+
+		RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+		if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
+			needsRedraw = true;
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error MenuSystem::runLoadGameMenu(const byte *palette, float paletteBrightness,
+		Flow &startupFlow, bool &loadedGame) {
+	(void)palette;
+	(void)paletteBrightness;
+	loadedGame = false;
+
+	ResourceManager *resources = _engine.getResources();
+	MetaEngine *metaEngine = _engine.getMetaEngine();
+	const CftFontResource *slotNameFontResource = findStartupFontByName(_engine, "MEDFONT1");
+	const CftFontResource *slotLabelFontResource = findStartupFontByName(_engine, "MEDFONT2");
+	if (!resources || !metaEngine || !slotNameFontResource || !slotLabelFontResource)
+		return Common::kReadingFailed;
+	if (!_engine.canLoadGameStateCurrently())
+		return Common::kNoError;
+
+	HarvesterCftFont slotNameFont(*slotNameFontResource);
+	HarvesterCftFont slotLabelFont(*slotLabelFontResource);
+	if (!slotNameFont.isValid() || !slotLabelFont.isValid())
+		return Common::kReadingFailed;
+
+	IndexedBitmap background;
+	byte loadPalette[256 * 3];
+	if (!loadBitmapResource(*resources, kLoadGameBitmapPath, background) ||
+			!loadPaletteResource(*resources, kLoadGamePalettePath, loadPalette)) {
+		return Common::kReadingFailed;
+	}
+
+	Common::Array<Common::String> slotTitles;
+	slotTitles.resize(kSaveSlotCount);
+	for (int i = 0; i < kSaveSlotCount; ++i)
+		slotTitles[i].clear();
+
+	const SaveStateList saves = metaEngine->listSaves(ConfMan.getActiveDomainName().c_str());
+	for (const SaveStateDescriptor &save : saves) {
+		const int slot = save.getSaveSlot();
+		if (slot < 0 || slot >= kSaveSlotCount)
+			continue;
+		slotTitles[slot] = save.getDescription().encode();
+	}
+
+	auto resolveHoveredSlot = [&]() -> int {
+		for (int slot = 0; slot < kSaveSlotCount; ++slot) {
+			if (saveSlotRect(slot).contains(_mousePos))
+				return slot;
+		}
+		return -1;
+	};
+
+	int activeSlot = 0;
+	for (int slot = 0; slot < kSaveSlotCount; ++slot) {
+		if (!slotTitles[slot].empty()) {
+			activeSlot = slot;
+			break;
+		}
+	}
+
+	Common::String statusMessage;
+	bool needsRedraw = true;
+	startupFlow.resetCursorAnimationSequence();
+	Graphics::FrameLimiter limiter(g_system, 60);
+
+	auto activateSelectedSlot = [&]() -> Common::Error {
+		if (slotTitles[activeSlot].empty())
+			return Common::kNoError;
+
+		Common::Error loadError = _engine.loadGameState(activeSlot);
+		if (loadError.getCode() == Common::kNoError) {
+			loadedGame = true;
+			return Common::kNoError;
+		}
+
+		statusMessage = loadError.getDesc();
+		needsRedraw = true;
+		return Common::kNoError;
+	};
+
+	while (!_engine.shouldQuit()) {
+		if (needsRedraw) {
+			renderSaveGameMenuScreen(_engine, background, loadPalette, 1.0f,
+				slotNameFont, slotLabelFont, slotNameFont, slotTitles, activeSlot, statusMessage);
+			needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (startupFlow.handleSystemEvent(event, result))
+				return result;
+
+			switch (event.type) {
+			case Common::EVENT_MOUSEMOVE: {
+				const int hoveredSlot = resolveHoveredSlot();
+				if (hoveredSlot != -1 && hoveredSlot != activeSlot)
+					activeSlot = hoveredSlot;
+				needsRedraw = true;
+				break;
+			}
+			case Common::EVENT_LBUTTONDOWN: {
+				const int hoveredSlot = resolveHoveredSlot();
+				statusMessage.clear();
+				if (hoveredSlot != -1) {
+					activeSlot = hoveredSlot;
+					Common::Error loadError = activateSelectedSlot();
+					if (loadError.getCode() != Common::kNoError)
+						return loadError;
+					if (loadedGame)
+						return Common::kNoError;
 					needsRedraw = true;
-				} else if (_menuItems[selectedItem].equalsIgnoreCase("HELP")) {
-					Common::Error helpError = runHelpScreen(palette, paletteBrightness, startupFlow);
-					if (helpError.getCode() != Common::kNoError)
-						return helpError;
+					break;
+				}
+				if (saveConfirmRect().contains(_mousePos)) {
+					Common::Error loadError = activateSelectedSlot();
+					if (loadError.getCode() != Common::kNoError)
+						return loadError;
+					if (loadedGame)
+						return Common::kNoError;
+				} else if (saveCancelRect().contains(_mousePos)) {
+					return Common::kNoError;
+				}
+				needsRedraw = true;
+				break;
+			}
+			case Common::EVENT_KEYDOWN:
+				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+					return Common::kNoError;
+				if (event.kbd.keycode == Common::KEYCODE_UP) {
+					activeSlot = (activeSlot + kSaveSlotCount - 1) % kSaveSlotCount;
+					statusMessage.clear();
 					needsRedraw = true;
-				} else if (_menuItems[selectedItem].equalsIgnoreCase("SAVE GAME")) {
-					Common::Error saveError = runSaveGameMenu(palette, paletteBrightness, startupFlow);
-					if (saveError.getCode() != Common::kNoError)
-						return saveError;
+				} else if (event.kbd.keycode == Common::KEYCODE_DOWN) {
+					activeSlot = (activeSlot + 1) % kSaveSlotCount;
+					statusMessage.clear();
 					needsRedraw = true;
-				} else if (_menuItems[selectedItem].equalsIgnoreCase("QUIT GAME")) {
-					Common::Error quitError = runQuitGameConfirm(backdrop, palette, paletteBrightness, startupFlow);
-					if (quitError.getCode() != Common::kNoError)
-						return quitError;
+				} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+						event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
+					statusMessage.clear();
+					Common::Error loadError = activateSelectedSlot();
+					if (loadError.getCode() != Common::kNoError)
+						return loadError;
+					if (loadedGame)
+						return Common::kNoError;
 					needsRedraw = true;
-				} else {
-					debug(1, "Harvester: room menu item '%s' clicked but not implemented",
-						_menuItems[selectedItem].c_str());
 				}
 				break;
 			default:
@@ -794,9 +1060,8 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 			}
 		}
 
-		RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
-		if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
-			needsRedraw = true;
+		if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities())
+			(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
 
 		limiter.delayBeforeSwap();
 		limiter.startFrame();
@@ -1031,8 +1296,9 @@ Common::Error MenuSystem::runSaveGameMenu(const byte *palette, float paletteBrig
 	return Common::kNoError;
 }
 
-Common::Error MenuSystem::runQuitGameConfirm(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, Flow &startupFlow) {
+Common::Error MenuSystem::runConfirmPrompt(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, Flow &startupFlow, const Common::String &promptText,
+		bool &confirmed) {
 	const Art *art = _engine.getStartupArt();
 	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
 	if (!art || !selectedFontResource)
@@ -1048,6 +1314,7 @@ Common::Error MenuSystem::runQuitGameConfirm(const IndexedBitmap &backdrop, cons
 	if (!textbox || !textbox->isValid())
 		return Common::kReadingFailed;
 
+	confirmed = false;
 	startupFlow.resetCursorAnimationSequence();
 	bool needsRedraw = true;
 	Graphics::FrameLimiter limiter(g_system, 60);
@@ -1056,8 +1323,8 @@ Common::Error MenuSystem::runQuitGameConfirm(const IndexedBitmap &backdrop, cons
 		const bool hoverYes = quitConfirmYesRect().contains(_mousePos);
 		const bool hoverNo = quitConfirmNoRect().contains(_mousePos);
 		if (needsRedraw) {
-			renderQuitGameConfirmScreen(_engine, backdrop, palette, paletteBrightness,
-				selectedFont, *textbox, config, hoverYes, hoverNo);
+			renderConfirmPromptScreen(_engine, backdrop, palette, paletteBrightness,
+				selectedFont, *textbox, promptText, config, hoverYes, hoverNo);
 			needsRedraw = false;
 		}
 
@@ -1075,9 +1342,7 @@ Common::Error MenuSystem::runQuitGameConfirm(const IndexedBitmap &backdrop, cons
 				return Common::kNoError;
 			case Common::EVENT_LBUTTONDOWN:
 				if (quitConfirmYesRect().contains(_mousePos)) {
-					_engine.stopStartupMusic();
-					_engine.stopStartupSound();
-					_engine.quitGame();
+					confirmed = true;
 					return Common::kNoError;
 				}
 				if (quitConfirmNoRect().contains(_mousePos))
@@ -1087,9 +1352,7 @@ Common::Error MenuSystem::runQuitGameConfirm(const IndexedBitmap &backdrop, cons
 				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
 					return Common::kNoError;
 				if (event.kbd.keycode == Common::KEYCODE_y || event.kbd.ascii == 'y' || event.kbd.ascii == 'Y') {
-					_engine.stopStartupMusic();
-					_engine.stopStartupSound();
-					_engine.quitGame();
+					confirmed = true;
 					return Common::kNoError;
 				}
 				if (event.kbd.keycode == Common::KEYCODE_n || event.kbd.ascii == 'n' || event.kbd.ascii == 'N')
@@ -1110,6 +1373,25 @@ Common::Error MenuSystem::runQuitGameConfirm(const IndexedBitmap &backdrop, cons
 	return Common::kNoError;
 }
 
+Common::Error MenuSystem::runQuitGameConfirm(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, Flow &startupFlow) {
+	RoomMenuTextConfig config;
+	(void)loadMenuTextConfig(_engine, config);
+
+	bool confirmed = false;
+	Common::Error confirmError = runConfirmPrompt(
+		backdrop, palette, paletteBrightness, startupFlow, config.quitGamePrompt, confirmed);
+	if (confirmError.getCode() != Common::kNoError)
+		return confirmError;
+	if (!confirmed)
+		return Common::kNoError;
+
+	_engine.stopStartupMusic();
+	_engine.stopStartupSound();
+	_engine.quitGame();
+	return Common::kNoError;
+}
+
 Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, Flow &startupFlow) {
 	const Art *art = _engine.getStartupArt();
@@ -1565,6 +1847,11 @@ Common::Error MenuSystem::runHelpScreen(const byte *palette, float paletteBright
 }
 
 void MenuSystem::renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const {
+	renderMainMenuScreen(selectedItem, statusMessage, true);
+}
+
+void MenuSystem::renderMainMenuScreen(int selectedItem, const Common::String &statusMessage,
+		bool drawCursor) const {
 	Graphics::Screen *screen = _engine.getScreen();
 	const Art *art = _engine.getStartupArt();
 	const Graphics::Font *titleFont = FontMan.getFontByUsage(Graphics::FontManager::kBigGUIFont);
@@ -1582,7 +1869,7 @@ void MenuSystem::renderMainMenuStub(int selectedItem, const Common::String &stat
 	const Common::Rect panel(96, 96, 544, 432);
 	screen->fillRect(panel, kPanelFillColor);
 
-	drawShadowedString(*screen, *titleFont, "Startup Main Menu Stub", panel.left, 110, panel.width(),
+	drawShadowedString(*screen, *titleFont, "Harvester", panel.left, 110, panel.width(),
 		kTextColorNormal, Graphics::kTextAlignCenter);
 
 	for (uint i = 0; i < _menuItems.size(); ++i) {
@@ -1592,14 +1879,14 @@ void MenuSystem::renderMainMenuStub(int selectedItem, const Common::String &stat
 			Graphics::kTextAlignCenter);
 	}
 
-	drawShadowedString(*screen, *bodyFont, "This is the next startup handoff after quick tips.", panel.left, 330,
-		panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
-	drawWrappedShadowedText(*screen, *bodyFont, statusMessage, panel.left + 24, 360, panel.width() - 48,
-		kTextColorNormal);
+	if (!statusMessage.empty()) {
+		drawWrappedShadowedText(*screen, *bodyFont, statusMessage, panel.left + 24, 352, panel.width() - 48,
+			kTextColorNormal);
+	}
 	drawShadowedString(*screen, *bodyFont, "Use mouse or arrow keys. Enter activates. Esc returns to launcher.",
 		panel.left, 404, panel.width(), kTextColorDim, Graphics::kTextAlignCenter);
 
-	if (_engine.getRuntimeEntities())
+	if (drawCursor && _engine.getRuntimeEntities())
 		_engine.getRuntimeEntities()->drawCursor(*screen);
 	screen->makeAllDirty();
 	screen->update();
diff --git a/engines/harvester/menu.h b/engines/harvester/menu.h
index 197b647044f..3505d760a78 100644
--- a/engines/harvester/menu.h
+++ b/engines/harvester/menu.h
@@ -47,13 +47,20 @@ public:
 		float paletteBrightness, Flow &startupFlow);
 
 private:
+	Common::Error runLoadGameMenu(const byte *palette, float paletteBrightness,
+		Flow &startupFlow, bool &loadedGame);
 	Common::Error runOptionsMenu(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, Flow &startupFlow);
 	Common::Error runHelpScreen(const byte *palette, float paletteBrightness, Flow &startupFlow);
 	Common::Error runSaveGameMenu(const byte *palette, float paletteBrightness, Flow &startupFlow);
+	Common::Error runConfirmPrompt(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, Flow &startupFlow, const Common::String &promptText,
+		bool &confirmed);
 	Common::Error runQuitGameConfirm(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, Flow &startupFlow);
 	void renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const;
+	void renderMainMenuScreen(int selectedItem, const Common::String &statusMessage,
+		bool drawCursor) const;
 	void renderRoomMenuStub(const IndexedBitmap &backdrop, int selectedItem) const;
 	int getMenuItemAt(const Common::Point &mousePos) const;
 	int getRoomMenuItemAt(const Common::Point &mousePos) const;
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 53ff66cce3d..26eed4ad1ad 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -725,6 +725,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 	while (!_engine.shouldQuit()) {
 		if (startupFlow.hasPendingMainMenuReturn())
 			return Common::kNoError;
+		if (startupFlow.takePendingNewGameRestart()) {
+			startupFlow.prepareForNewGame();
+			pendingRoomChange = "START";
+			if (!stowCarriedRoomItemToInventory())
+				return Common::kReadingFailed;
+			break;
+		}
 		if (_engine.hasPendingLoadedStartupSaveRoomState()) {
 			pendingRoomChange = _engine.getPendingLoadedStartupSaveRoomState().entranceName;
 			if (pendingRoomChange.empty())


Commit: f974c6f3bbfb64a76174bfe486e84f4a3841914d
    https://github.com/scummvm/scummvm/commit/f974c6f3bbfb64a76174bfe486e84f4a3841914d
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:28+02:00

Commit Message:
HARVESTER: Restore cold start room handoff

Changed paths:
    TRACKER.md
    engines/harvester/flow.cpp


diff --git a/TRACKER.md b/TRACKER.md
index f05d975ff41..a2859253b4a 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,7 +16,9 @@
 
 ## Last Confirmed Action
 
-- On March 19, 2026, completed the first real startup/room-menu parity pass in `engines/harvester`: `Flow::run()` now hands off from quick tips into the startup menu instead of dropping straight into `runRoomLoop("START")`; `LOAD GAME` now uses the native `LOADGAME.BM` / `LOADGAME.PAL` slot picker; `NEW GAME` now uses the confirmed `MENU.INI:newgame` confirmation prompt in both startup and in-room menus; and the in-room ESC menu now unwinds cleanly into restart/load handoffs instead of leaving those rows as stubs.
+- On March 19, 2026, fixed the cold-start regression from the previous menu pass: `Flow::run()` no longer jumps from the initial quick-tips overlay into `run_main_menu`, and instead restores the confirmed native cold-start path back into `runRoomLoop("START")`. The real `run_main_menu` work remains limited to its live main-loop / game-over entry points, while the new `LOAD GAME` UI and `NEW GAME` confirmation support stay available on those legitimate menu paths.
+- Rebuilt the touched Harvester object successfully after that correction: `engines/harvester/flow.o`.
+- On March 19, 2026, completed the first real `run_main_menu` / room-menu parity pass in `engines/harvester`: `LOAD GAME` now uses the native `LOADGAME.BM` / `LOADGAME.PAL` slot picker; `NEW GAME` now uses the confirmed `MENU.INI:newgame` confirmation prompt in both the legitimate top-level menu path and the in-room ESC menu; and the in-room ESC menu now unwinds cleanly into restart/load handoffs instead of leaving those rows as stubs.
 - Rebuilt the touched Harvester objects successfully: `engines/harvester/menu.o`, `engines/harvester/flow.o`, `engines/harvester/room.o`, and `engines/harvester/harvester.o`.
 - On March 19, 2026, audited the current `engines/harvester` implementation against the live `HARVEST.LE` Ghidra session and the confirmed notes in `ARCHITECTURE.md`; verified that boot/resource loading, room setup, inventory, dialogue, save menu, help, options, and quit confirmation are already implemented, while the main remaining reimplementation gaps are startup/room menu parity, actor runtime/combat/timers, and wider script opcode coverage.
 - Finished another rename pass, saved `HARVEST.LE`, and advanced the current tracker cycle to 194 confident renames total.
@@ -34,15 +36,15 @@
 
 ## Next Suggested Action
 
-1. Re-test the startup menu and in-room ESC menu in-engine against the confirmed `run_main_menu` / `run_load_game_menu` notes: verify `NEW GAME`, `SAVE GAME`, `LOAD GAME`, `OPTIONS`, `HELP`, and `QUIT GAME` all unwind to the correct room/palette state and that the restart/load handoffs do not leave stale scene state behind.
-2. Finish the remaining startup-menu parity gap by replacing the temporary inventory-panel startup backdrop with the live `START` room/menu presentation and checking the missing timer pause/resume details before moving on to the actor runtime/combat/timer work in priority item 2.
+1. Re-test only the legitimate `run_main_menu` entry points in-engine against the confirmed notes: the live in-room ESC menu plus any game-over / no-active-session path that actually reaches `run_main_menu`. Verify `NEW GAME`, `SAVE GAME`, `LOAD GAME`, `OPTIONS`, `HELP`, and `QUIT GAME` all unwind to the correct room/palette state and that restart/load handoffs do not leave stale scene state behind.
+2. Separate the remaining top-level/main-title rendering work from the cold-start path before touching it again: the startup quick-tips exit already belongs to `room_setup("START")`, so any future `run_main_menu` parity work should target only the true callers from `run_harvester_main_loop` and `run_game_over_screen`.
 
 ## Reimplementation Priority Order
 
 1. Finish startup and room menu parity.
    Files: `engines/harvester/menu.cpp`, `engines/harvester/menu.h`, `engines/harvester/flow.cpp`, `engines/harvester/harvester.cpp`, `engines/harvester/harvester.h`.
    Native anchors: `run_main_menu @ 0x67390`, `run_load_game_menu @ 0x64910`, `run_save_game_menu @ 0x632c0`, `run_controls_help_screen @ 0x6c3e0`.
-   Concrete tasks: keep the new real `LOAD GAME` path, startup-menu handoff, and `NEW GAME` / restart confirmation aligned with the confirmed native notes; finish swapping the startup menu off the temporary stub backdrop and onto the live `START` scene; verify the menu/timer/palette restore details after each branch.
+   Concrete tasks: keep the new real `LOAD GAME` path and `NEW GAME` / restart confirmation aligned with the confirmed native notes; do not route the cold-start quick-tips exit through `run_main_menu`; finish the remaining true `run_main_menu` rendering/timer/palette restore details only on its legitimate live callers.
    Exit criteria: every visible menu row performs real work instead of falling back to a debug stub, and the startup-menu return path plus the in-room ESC menu expose the same native actions.
 2. Implement the actor runtime and combat-adjacent substrate.
    Files: `engines/harvester/room.cpp`, `engines/harvester/flow.cpp`, `engines/harvester/runtime_entity.cpp`, `engines/harvester/runtime_entity.h`, `engines/harvester/script.cpp`, `engines/harvester/script.h`.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index b6c0e96ad34..07d8ac6dded 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -1719,9 +1719,8 @@ Common::Error Flow::run() {
 			return error;
 
 		clearPendingMainMenuReturn();
-		clearPendingNewGameRestart();
-		error = runMainMenuStub();
-		return error;
+		_engine.getStartupScript()->resetRuntimeState();
+		error = runRoomLoop("START");
 	}
 	if (error.getCode() != Common::kNoError)
 		return error;


Commit: 208dc5239c38ca1da5d4e975fbb33186d6a43880
    https://github.com/scummvm/scummvm/commit/208dc5239c38ca1da5d4e975fbb33186d6a43880
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:28+02:00

Commit Message:
HARVESTER: Bound FST decode to video payload

Changed paths:
    engines/harvester/fst_player.cpp


diff --git a/engines/harvester/fst_player.cpp b/engines/harvester/fst_player.cpp
index ca7a8719619..e6343684918 100644
--- a/engines/harvester/fst_player.cpp
+++ b/engines/harvester/fst_player.cpp
@@ -259,23 +259,23 @@ static void decodeMaskBlock(byte *dest, int pitch, const byte *source) {
 	}
 }
 
-static bool decodeFrame(const FstHeader &header, const Common::Array<byte> &frameData, Common::Array<byte> &pixels, byte *palette) {
-	if (frameData.size() < 2)
+static bool decodeFrame(const FstHeader &header, const byte *frameData, uint32 frameSize, Common::Array<byte> &pixels, byte *palette) {
+	if (!frameData || frameSize < 2)
 		return false;
 
-	const uint16 bitCount = READ_LE_UINT16(frameData.data());
+	const uint16 bitCount = READ_LE_UINT16(frameData);
 	const uint32 bitstreamSize = (bitCount >> 3) + 1;
-	if (frameData.size() < 2 + bitstreamSize)
+	if (frameSize < 2 + bitstreamSize)
 		return false;
 
-	FstBitReader bitReader(frameData.data() + 2, bitCount);
+	FstBitReader bitReader(frameData + 2, bitCount);
 	uint32 payloadOffset = 2 + bitstreamSize;
 
 	if (bitReader.readBit()) {
-		if (frameData.size() < payloadOffset + 256 * 3)
+		if (frameSize < payloadOffset + 256 * 3)
 			return false;
 
-		convertVgaPalette(frameData.data() + payloadOffset, palette);
+		convertVgaPalette(frameData + payloadOffset, palette);
 		payloadOffset += 256 * 3;
 	}
 
@@ -283,6 +283,7 @@ static bool decodeFrame(const FstHeader &header, const Common::Array<byte> &fram
 	const int height = (int)header.height;
 	const int blocksX = width / 4;
 	const int blocksY = height / 4;
+	bool truncatedTail = false;
 
 	for (int blockY = 0; blockY < blocksY; ++blockY) {
 		for (int blockX = 0; blockX < blocksX; ++blockX) {
@@ -293,20 +294,29 @@ static bool decodeFrame(const FstHeader &header, const Common::Array<byte> &fram
 			byte *dest = pixels.data() + blockOffset;
 
 			if (bitReader.readBit()) {
-				if (frameData.size() < payloadOffset + 4)
-					return false;
+				// Some shipped FST frames stop before the full 320x200 block grid.
+				// Treat the missing tail blocks as unchanged instead of reading into PCM data.
+				if (frameSize < payloadOffset + 4) {
+					truncatedTail = true;
+					break;
+				}
 
-				decodeMaskBlock(dest, width, frameData.data() + payloadOffset);
+				decodeMaskBlock(dest, width, frameData + payloadOffset);
 				payloadOffset += 4;
 			} else {
-				if (frameData.size() < payloadOffset + 16)
-					return false;
+				if (frameSize < payloadOffset + 16) {
+					truncatedTail = true;
+					break;
+				}
 
 				for (int y = 0; y < 4; ++y)
-					memcpy(dest + y * width, frameData.data() + payloadOffset + y * 4, 4);
+					memcpy(dest + y * width, frameData + payloadOffset + y * 4, 4);
 				payloadOffset += 16;
 			}
 		}
+
+		if (truncatedTail)
+			break;
 	}
 
 	return true;
@@ -466,7 +476,7 @@ bool FstPlayer::play(const Common::String &path) {
 			continue;
 		}
 
-		if (!decodeFrame(header, frameData, pixels, palette)) {
+		if (!decodeFrame(header, videoData, videoSize, pixels, palette)) {
 			warning("Harvester: unable to decode '%s' frame %u", path.c_str(), frameIndex);
 			break;
 		}


Commit: f4f24f9b41ee112e03d6737ca72219bb491337fc
    https://github.com/scummvm/scummvm/commit/f4f24f9b41ee112e03d6737ca72219bb491337fc
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:28+02:00

Commit Message:
HARVESTER: Fix confirm dialog font layout

Changed paths:
    engines/harvester/menu.cpp


diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index 390afe3672d..b183263e193 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -89,9 +89,11 @@ static const int kQuickTipTextY = 228;
 static const int kQuickTipTextWidth = 280;
 static const int kConfirmDialogX = 167;
 static const int kConfirmDialogY = 200;
-static const int kConfirmPromptX = 182;
-static const int kConfirmPromptY = 214;
-static const int kConfirmPromptWidth = 280;
+static const int kConfirmPromptTextX = 0xea;
+static const int kConfirmPromptTextY = 0xd6;
+static const int kConfirmChoiceTextY = 0xf8;
+static const int kConfirmYesTextX = 0xdc;
+static const int kConfirmNoTextX = 0x17c;
 static const int kSaveSlotCount = 25;
 static const int kSaveSlotLabelX = 8;
 static const int kSaveSlotNameX = 0x50;
@@ -377,6 +379,14 @@ static void drawShadowedString(Graphics::Screen &screen, const Graphics::Font &f
 	font.drawString(&screen, text, x, y, width, color, align);
 }
 
+static void drawSinglePassString(Graphics::Screen &screen, const Graphics::Font &font,
+		const Common::String &text, int x, int y) {
+	if (text.empty())
+		return;
+
+	font.drawString(&screen, text, x, y, font.getStringWidth(text), 0);
+}
+
 static void drawWrappedShadowedText(Graphics::Screen &screen, const Graphics::Font &font,
 		const Common::String &text, int x, int y, int width, byte color) {
 	Common::Array<Common::String> lines;
@@ -515,9 +525,10 @@ static void renderSaveGameMenuScreen(HarvesterEngine &engine, const IndexedBitma
 }
 
 static void renderConfirmPromptScreen(HarvesterEngine &engine, const IndexedBitmap &backdrop,
-		const byte *palette, float paletteBrightness, const Graphics::Font &font,
+		const byte *palette, float paletteBrightness, const Graphics::Font &promptFont,
+		const Graphics::Font &yesFont, const Graphics::Font &noFont,
 		const IndexedBitmap &textbox, const Common::String &promptText,
-		const RoomMenuTextConfig &config, bool hoverYes, bool hoverNo) {
+		const RoomMenuTextConfig &config) {
 	Graphics::Screen *screen = engine.getScreen();
 	const Art *art = engine.getStartupArt();
 	if (!screen || !art)
@@ -530,16 +541,11 @@ static void renderConfirmPromptScreen(HarvesterEngine &engine, const IndexedBitm
 	Common::Array<Common::String> promptLines;
 	splitMenuConfigLines(promptText, promptLines);
 	for (uint i = 0; i < promptLines.size(); ++i) {
-		drawShadowedString(*screen, font, promptLines[i], kConfirmPromptX,
-			kConfirmPromptY + (int)i * (font.getFontHeight() + 2), kConfirmPromptWidth,
-			kTextColorNormal, Graphics::kTextAlignCenter);
+		drawSinglePassString(*screen, promptFont, promptLines[i], kConfirmPromptTextX,
+			kConfirmPromptTextY + (int)i * (promptFont.getFontHeight() + 2));
 	}
-	drawShadowedString(*screen, font, config.yesLabel,
-		quitConfirmYesRect().left, quitConfirmYesRect().top, quitConfirmYesRect().width(),
-		hoverYes ? kTextColorHover : kTextColorNormal, Graphics::kTextAlignCenter);
-	drawShadowedString(*screen, font, config.noLabel,
-		quitConfirmNoRect().left, quitConfirmNoRect().top, quitConfirmNoRect().width(),
-		hoverNo ? kTextColorHover : kTextColorNormal, Graphics::kTextAlignCenter);
+	drawSinglePassString(*screen, yesFont, config.yesLabel, kConfirmYesTextX, kConfirmChoiceTextY);
+	drawSinglePassString(*screen, noFont, config.noLabel, kConfirmNoTextX, kConfirmChoiceTextY);
 
 	if (engine.getRuntimeEntities())
 		engine.getRuntimeEntities()->drawCursor(*screen);
@@ -1300,12 +1306,14 @@ Common::Error MenuSystem::runConfirmPrompt(const IndexedBitmap &backdrop, const
 		float paletteBrightness, Flow &startupFlow, const Common::String &promptText,
 		bool &confirmed) {
 	const Art *art = _engine.getStartupArt();
-	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
-	if (!art || !selectedFontResource)
+	const CftFontResource *promptFontResource = findStartupFontByName(_engine, "MEDFONT1");
+	const CftFontResource *choiceFontResource = findStartupFontByName(_engine, "MEDFONT2");
+	if (!art || !promptFontResource || !choiceFontResource)
 		return Common::kReadingFailed;
 
-	HarvesterCftFont selectedFont(*selectedFontResource);
-	if (!selectedFont.isValid())
+	HarvesterCftFont promptFont(*promptFontResource);
+	HarvesterCftFont choiceFont(*choiceFontResource);
+	if (!promptFont.isValid() || !choiceFont.isValid())
 		return Common::kReadingFailed;
 
 	RoomMenuTextConfig config;
@@ -1323,8 +1331,12 @@ Common::Error MenuSystem::runConfirmPrompt(const IndexedBitmap &backdrop, const
 		const bool hoverYes = quitConfirmYesRect().contains(_mousePos);
 		const bool hoverNo = quitConfirmNoRect().contains(_mousePos);
 		if (needsRedraw) {
+			const Graphics::Font &yesFont = hoverYes ? static_cast<const Graphics::Font &>(promptFont)
+				: static_cast<const Graphics::Font &>(choiceFont);
+			const Graphics::Font &noFont = hoverNo ? static_cast<const Graphics::Font &>(promptFont)
+				: static_cast<const Graphics::Font &>(choiceFont);
 			renderConfirmPromptScreen(_engine, backdrop, palette, paletteBrightness,
-				selectedFont, *textbox, promptText, config, hoverYes, hoverNo);
+				promptFont, yesFont, noFont, *textbox, promptText, config);
 			needsRedraw = false;
 		}
 


Commit: 22b832088ed7149a23fbcda5fe0cb58f68db1615
    https://github.com/scummvm/scummvm/commit/22b832088ed7149a23fbcda5fe0cb58f68db1615
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:29+02:00

Commit Message:
HARVESTER: Preserve confirm prompt spacing

Changed paths:
    engines/harvester/menu.cpp


diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index b183263e193..3fe048ff020 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -184,6 +184,38 @@ static int resolveOptionsSliderIndexFromMouseX(int mouseX) {
 	return clampStartupOptionLevel((relativeX + (kOptionsSliderStep / 2)) / kOptionsSliderStep);
 }
 
+static bool loadRawMenuValue(const Common::Array<byte> &data, const char *key, Common::String &value) {
+	value.clear();
+	if (!key)
+		return false;
+
+	Common::String line;
+	for (uint i = 0; i <= data.size(); ++i) {
+		const bool atEnd = i == data.size();
+		const byte chr = atEnd ? '\n' : data[i];
+		if (chr == '\r')
+			continue;
+
+		if (chr != '\n') {
+			line += (char)chr;
+			continue;
+		}
+
+		const char *separator = strchr(line.c_str(), '=');
+		if (separator) {
+			const Common::String currentKey(line.c_str(), separator);
+			if (currentKey.equalsIgnoreCase(key)) {
+				value = Common::String(separator + 1);
+				return true;
+			}
+		}
+
+		line.clear();
+	}
+
+	return false;
+}
+
 static bool loadMenuTextConfig(HarvesterEngine &engine, RoomMenuTextConfig &config) {
 	config = RoomMenuTextConfig();
 	config.optionItems.resize(kOptionsItemCount);
@@ -224,9 +256,9 @@ static bool loadMenuTextConfig(HarvesterEngine &engine, RoomMenuTextConfig &conf
 		config.noLabel = value;
 	if (menu.getKey("click", kMenuSectionName, value) && !value.empty())
 		config.clickLabel = value;
-	if (menu.getKey("newgame", kMenuSectionName, value) && !value.empty())
+	if (loadRawMenuValue(data, "newgame", value) && !value.empty())
 		config.newGamePrompt = value;
-	if (menu.getKey("quitgame", kMenuSectionName, value) && !value.empty())
+	if (loadRawMenuValue(data, "quitgame", value) && !value.empty())
 		config.quitGamePrompt = value;
 
 	return true;


Commit: d5a874f8cb5197d57ecf2d75a976341be353244f
    https://github.com/scummvm/scummvm/commit/d5a874f8cb5197d57ecf2d75a976341be353244f
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:29+02:00

Commit Message:
HARVESTER: Align true main menu rows

Changed paths:
    TRACKER.md
    engines/harvester/menu.cpp
    engines/harvester/menu.h


diff --git a/TRACKER.md b/TRACKER.md
index a2859253b4a..15489a6752b 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 19, 2026, audited the legitimate `run_main_menu` callers against the live `HARVEST.LE` Ghidra session instead of the cold-start path: confirmed that the six-row in-room ESC overlay belongs to the room-menu path, while the true `run_main_menu` return/death path blanks `SAVE GAME` and `LOAD GAME` unless an active room session is still live.
+- Applied that parity fix in `engines/harvester/menu.cpp` / `engines/harvester/menu.h`: `runMainMenuStub` now filters `SAVE GAME` and `LOAD GAME` out of the true main-menu stub when there is no current startup save-room state, while `runRoomMenuStub` continues to expose the full six-row in-room menu.
+- Rebuilt the touched Harvester objects successfully after the caller-audit fix: `engines/harvester/menu.o`, `engines/harvester/flow.o`, `engines/harvester/room.o`, and `engines/harvester/harvester.o`.
 - On March 19, 2026, fixed the cold-start regression from the previous menu pass: `Flow::run()` no longer jumps from the initial quick-tips overlay into `run_main_menu`, and instead restores the confirmed native cold-start path back into `runRoomLoop("START")`. The real `run_main_menu` work remains limited to its live main-loop / game-over entry points, while the new `LOAD GAME` UI and `NEW GAME` confirmation support stay available on those legitimate menu paths.
 - Rebuilt the touched Harvester object successfully after that correction: `engines/harvester/flow.o`.
 - On March 19, 2026, completed the first real `run_main_menu` / room-menu parity pass in `engines/harvester`: `LOAD GAME` now uses the native `LOADGAME.BM` / `LOADGAME.PAL` slot picker; `NEW GAME` now uses the confirmed `MENU.INI:newgame` confirmation prompt in both the legitimate top-level menu path and the in-room ESC menu; and the in-room ESC menu now unwinds cleanly into restart/load handoffs instead of leaving those rows as stubs.
@@ -36,8 +39,8 @@
 
 ## Next Suggested Action
 
-1. Re-test only the legitimate `run_main_menu` entry points in-engine against the confirmed notes: the live in-room ESC menu plus any game-over / no-active-session path that actually reaches `run_main_menu`. Verify `NEW GAME`, `SAVE GAME`, `LOAD GAME`, `OPTIONS`, `HELP`, and `QUIT GAME` all unwind to the correct room/palette state and that restart/load handoffs do not leave stale scene state behind.
-2. Separate the remaining top-level/main-title rendering work from the cold-start path before touching it again: the startup quick-tips exit already belongs to `room_setup("START")`, so any future `run_main_menu` parity work should target only the true callers from `run_harvester_main_loop` and `run_game_over_screen`.
+1. Run a manual desktop validation pass against the legitimate menu entry points with a persistent GUI session: confirm that the true `run_main_menu` path now shows only `NEW GAME`, `OPTIONS`, `HELP`, and `QUIT GAME`, while the live in-room ESC menu still shows the full six-row overlay and preserves `NEW GAME` / `SAVE GAME` / `LOAD GAME` / `OPTIONS` / `HELP` / `QUIT GAME` behavior without palette or scene-state leaks.
+2. Continue the remaining top-level/main-title rendering parity only on the true `run_main_menu` callers from `run_harvester_main_loop` / `run_game_over_screen`; the cold-start quick-tips exit and the room ESC overlay are now separated from that work.
 
 ## Reimplementation Priority Order
 
diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index 3fe048ff020..c28856ce8f1 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -617,13 +617,22 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 	if (!art)
 		return Common::kReadingFailed;
 
+	Common::Array<Common::String> mainMenuItems;
+	const bool showSessionActions = _engine.hasCurrentStartupSaveRoomState();
+	for (const Common::String &item : _menuItems) {
+		if (!showSessionActions &&
+				(item.equalsIgnoreCase("SAVE GAME") || item.equalsIgnoreCase("LOAD GAME")))
+			continue;
+		mainMenuItems.push_back(item);
+	}
+
 	Graphics::FrameLimiter limiter(g_system, 60);
-	int selectedItem = _menuItems.empty() ? -1 : 0;
+	int selectedItem = mainMenuItems.empty() ? -1 : 0;
 	Common::String statusMessage;
 	bool needsRedraw = true;
 
 	auto captureMenuBackdrop = [&](IndexedBitmap &backdrop) -> bool {
-		renderMainMenuScreen(selectedItem, statusMessage, false);
+		renderMainMenuScreen(selectedItem, statusMessage, false, mainMenuItems);
 		Graphics::Screen *screen = _engine.getScreen();
 		return screen && captureScreenBackdrop(*screen, backdrop);
 	};
@@ -646,10 +655,10 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 		return Common::kNoError;
 	};
 	auto activateSelectedItem = [&]() -> Common::Error {
-		if (selectedItem < 0 || selectedItem >= (int)_menuItems.size())
+		if (selectedItem < 0 || selectedItem >= (int)mainMenuItems.size())
 			return Common::kNoError;
 
-		const Common::String &item = _menuItems[selectedItem];
+		const Common::String &item = mainMenuItems[selectedItem];
 		const byte *menuPalette = art->getWaitPalette();
 		statusMessage.clear();
 
@@ -729,7 +738,7 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
-			renderMainMenuStub(selectedItem, statusMessage);
+			renderMainMenuStub(mainMenuItems, selectedItem, statusMessage);
 			needsRedraw = false;
 		}
 
@@ -741,7 +750,7 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 
 			switch (event.type) {
 			case Common::EVENT_MOUSEMOVE: {
-				const int hoveredItem = getMenuItemAt(_mousePos);
+				const int hoveredItem = getMenuItemAt(_mousePos, mainMenuItems);
 				if (hoveredItem != -1 && hoveredItem != selectedItem) {
 					selectedItem = hoveredItem;
 					needsRedraw = true;
@@ -752,14 +761,14 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
 					return Common::kNoError;
 
-				if (_menuItems.empty())
+				if (mainMenuItems.empty())
 					break;
 
 				if (event.kbd.keycode == Common::KEYCODE_UP) {
-					selectedItem = (selectedItem + _menuItems.size() - 1) % _menuItems.size();
+					selectedItem = (selectedItem + mainMenuItems.size() - 1) % mainMenuItems.size();
 					needsRedraw = true;
 				} else if (event.kbd.keycode == Common::KEYCODE_DOWN) {
-					selectedItem = (selectedItem + 1) % _menuItems.size();
+					selectedItem = (selectedItem + 1) % mainMenuItems.size();
 					needsRedraw = true;
 				} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
 						event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
@@ -769,10 +778,10 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 				}
 				break;
 			case Common::EVENT_LBUTTONDOWN: {
-				if (_menuItems.empty())
+				if (mainMenuItems.empty())
 					break;
 
-				selectedItem = getMenuItemAt(_mousePos);
+				selectedItem = getMenuItemAt(_mousePos, mainMenuItems);
 				if (selectedItem == -1)
 					break;
 
@@ -1890,12 +1899,13 @@ Common::Error MenuSystem::runHelpScreen(const byte *palette, float paletteBright
 	return Common::kNoError;
 }
 
-void MenuSystem::renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const {
-	renderMainMenuScreen(selectedItem, statusMessage, true);
+void MenuSystem::renderMainMenuStub(const Common::Array<Common::String> &menuItems, int selectedItem,
+		const Common::String &statusMessage) const {
+	renderMainMenuScreen(selectedItem, statusMessage, true, menuItems);
 }
 
 void MenuSystem::renderMainMenuScreen(int selectedItem, const Common::String &statusMessage,
-		bool drawCursor) const {
+		bool drawCursor, const Common::Array<Common::String> &menuItems) const {
 	Graphics::Screen *screen = _engine.getScreen();
 	const Art *art = _engine.getStartupArt();
 	const Graphics::Font *titleFont = FontMan.getFontByUsage(Graphics::FontManager::kBigGUIFont);
@@ -1916,10 +1926,10 @@ void MenuSystem::renderMainMenuScreen(int selectedItem, const Common::String &st
 	drawShadowedString(*screen, *titleFont, "Harvester", panel.left, 110, panel.width(),
 		kTextColorNormal, Graphics::kTextAlignCenter);
 
-	for (uint i = 0; i < _menuItems.size(); ++i) {
+	for (uint i = 0; i < menuItems.size(); ++i) {
 		const int itemY = kMenuStartY + (int)i * kMenuLineSpacing;
 		const byte color = ((int)i == selectedItem) ? kTextColorHover : kTextColorNormal;
-		drawShadowedString(*screen, *bodyFont, _menuItems[i], panel.left, itemY, panel.width(), color,
+		drawShadowedString(*screen, *bodyFont, menuItems[i], panel.left, itemY, panel.width(), color,
 			Graphics::kTextAlignCenter);
 	}
 
@@ -1969,13 +1979,14 @@ void MenuSystem::renderRoomMenuStub(const IndexedBitmap &backdrop, int selectedI
 	screen->update();
 }
 
-int MenuSystem::getMenuItemAt(const Common::Point &mousePos) const {
+int MenuSystem::getMenuItemAt(const Common::Point &mousePos,
+		const Common::Array<Common::String> &menuItems) const {
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
 	if (!font)
 		return -1;
 
-	for (uint i = 0; i < _menuItems.size(); ++i) {
-		const int width = font->getStringWidth(_menuItems[i]);
+	for (uint i = 0; i < menuItems.size(); ++i) {
+		const int width = font->getStringWidth(menuItems[i]);
 		const int x = (640 - width) / 2;
 		const int y = kMenuStartY + (int)i * kMenuLineSpacing;
 		const Common::Rect bounds(x - 12, y - 2, x + width + 12, y + font->getFontHeight() + 2);
@@ -1989,11 +2000,11 @@ int MenuSystem::getMenuItemAt(const Common::Point &mousePos) const {
 int MenuSystem::getRoomMenuItemAt(const Common::Point &mousePos) const {
 	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
 	if (!selectedFontResource)
-		return getMenuItemAt(mousePos);
+		return getMenuItemAt(mousePos, _menuItems);
 
 	HarvesterCftFont selectedFont(*selectedFontResource);
 	if (!selectedFont.isValid())
-		return getMenuItemAt(mousePos);
+		return getMenuItemAt(mousePos, _menuItems);
 
 	return getNativeRoomMenuSelectionFromMouse(selectedFont, _menuItems.size(), mousePos);
 }
diff --git a/engines/harvester/menu.h b/engines/harvester/menu.h
index 3505d760a78..ab7fb040e67 100644
--- a/engines/harvester/menu.h
+++ b/engines/harvester/menu.h
@@ -58,11 +58,12 @@ private:
 		bool &confirmed);
 	Common::Error runQuitGameConfirm(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, Flow &startupFlow);
-	void renderMainMenuStub(int selectedItem, const Common::String &statusMessage) const;
+	void renderMainMenuStub(const Common::Array<Common::String> &menuItems, int selectedItem,
+		const Common::String &statusMessage) const;
 	void renderMainMenuScreen(int selectedItem, const Common::String &statusMessage,
-		bool drawCursor) const;
+		bool drawCursor, const Common::Array<Common::String> &menuItems) const;
 	void renderRoomMenuStub(const IndexedBitmap &backdrop, int selectedItem) const;
-	int getMenuItemAt(const Common::Point &mousePos) const;
+	int getMenuItemAt(const Common::Point &mousePos, const Common::Array<Common::String> &menuItems) const;
 	int getRoomMenuItemAt(const Common::Point &mousePos) const;
 
 	HarvesterEngine &_engine;


Commit: b495872d03bd5d373934846e3e522af7a554df28
    https://github.com/scummvm/scummvm/commit/b495872d03bd5d373934846e3e522af7a554df28
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:29+02:00

Commit Message:
HARVESTER: Restore room menu palette after submenus

Changed paths:
    TRACKER.md
    engines/harvester/menu.cpp
    engines/harvester/menu.h


diff --git a/TRACKER.md b/TRACKER.md
index 15489a6752b..b8bff96cc86 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 19, 2026, ran a manual desktop validation pass against the live in-room ESC overlay with a persistent ScummVM GUI session and `osascript` keyboard input: confirmed the six-row room menu is reachable and keyboard-driven, reproduced a real palette-return regression on `SAVE GAME -> Esc` / `LOAD GAME -> Esc`, and fixed it by reapplying the room palette whenever `runRoomMenuStub` redraws its backdrop after a submenu returns.
+- Rebuilt the touched Harvester binary after that room-menu palette fix: `engines/harvester/menu.o` and `scummvm`, then revalidated that both `SAVE GAME -> Esc` and `LOAD GAME -> Esc` return to the room-menu overlay without the corrupted palette.
 - On March 19, 2026, audited the legitimate `run_main_menu` callers against the live `HARVEST.LE` Ghidra session instead of the cold-start path: confirmed that the six-row in-room ESC overlay belongs to the room-menu path, while the true `run_main_menu` return/death path blanks `SAVE GAME` and `LOAD GAME` unless an active room session is still live.
 - Applied that parity fix in `engines/harvester/menu.cpp` / `engines/harvester/menu.h`: `runMainMenuStub` now filters `SAVE GAME` and `LOAD GAME` out of the true main-menu stub when there is no current startup save-room state, while `runRoomMenuStub` continues to expose the full six-row in-room menu.
 - Rebuilt the touched Harvester objects successfully after the caller-audit fix: `engines/harvester/menu.o`, `engines/harvester/flow.o`, `engines/harvester/room.o`, and `engines/harvester/harvester.o`.
@@ -39,7 +41,7 @@
 
 ## Next Suggested Action
 
-1. Run a manual desktop validation pass against the legitimate menu entry points with a persistent GUI session: confirm that the true `run_main_menu` path now shows only `NEW GAME`, `OPTIONS`, `HELP`, and `QUIT GAME`, while the live in-room ESC menu still shows the full six-row overlay and preserves `NEW GAME` / `SAVE GAME` / `LOAD GAME` / `OPTIONS` / `HELP` / `QUIT GAME` behavior without palette or scene-state leaks.
+1. Continue the manual desktop validation pass from the live GUI session: finish spot-checking the remaining in-room ESC rows (`NEW GAME`, `OPTIONS`, `HELP`, `QUIT GAME`), then find a reproducible `GODEATHFLIC` / game-over trigger or suitable save so the legitimate `run_main_menu` path can be reached and confirmed to show only `NEW GAME`, `OPTIONS`, `HELP`, and `QUIT GAME`.
 2. Continue the remaining top-level/main-title rendering parity only on the true `run_main_menu` callers from `run_harvester_main_loop` / `run_game_over_screen`; the cold-start quick-tips exit and the room ESC overlay are now separated from that work.
 
 ## Reimplementation Priority Order
diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index c28856ce8f1..e7a85e933ce 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -884,7 +884,7 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
-			renderRoomMenuStub(backdrop, selectedItem);
+			renderRoomMenuStub(backdrop, palette, paletteBrightness, selectedItem);
 			needsRedraw = false;
 		}
 
@@ -1946,7 +1946,8 @@ void MenuSystem::renderMainMenuScreen(int selectedItem, const Common::String &st
 	screen->update();
 }
 
-void MenuSystem::renderRoomMenuStub(const IndexedBitmap &backdrop, int selectedItem) const {
+void MenuSystem::renderRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, int selectedItem) const {
 	Graphics::Screen *screen = _engine.getScreen();
 	const Art *art = _engine.getStartupArt();
 	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
@@ -1959,6 +1960,7 @@ void MenuSystem::renderRoomMenuStub(const IndexedBitmap &backdrop, int selectedI
 	if (!selectedFont.isValid() || !unselectedFont.isValid())
 		return;
 
+	applyMenuPalette(*screen, _engine, palette, paletteBrightness);
 	blitBitmap(*screen, backdrop, 0, 0);
 	blitTransparentBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
 
diff --git a/engines/harvester/menu.h b/engines/harvester/menu.h
index ab7fb040e67..2dcddee3fcb 100644
--- a/engines/harvester/menu.h
+++ b/engines/harvester/menu.h
@@ -62,7 +62,8 @@ private:
 		const Common::String &statusMessage) const;
 	void renderMainMenuScreen(int selectedItem, const Common::String &statusMessage,
 		bool drawCursor, const Common::Array<Common::String> &menuItems) const;
-	void renderRoomMenuStub(const IndexedBitmap &backdrop, int selectedItem) const;
+	void renderRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, int selectedItem) const;
 	int getMenuItemAt(const Common::Point &mousePos, const Common::Array<Common::String> &menuItems) const;
 	int getRoomMenuItemAt(const Common::Point &mousePos) const;
 


Commit: 19b23e64cfa7f031e7f3cc86dc4feb594bd9119b
    https://github.com/scummvm/scummvm/commit/19b23e64cfa7f031e7f3cc86dc4feb594bd9119b
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:30+02:00

Commit Message:
HARVESTER: Add room timer runtime substrate

Changed paths:
    TRACKER.md
    engines/harvester/flow.cpp
    engines/harvester/harvester.cpp
    engines/harvester/menu.cpp
    engines/harvester/room.cpp
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index b8bff96cc86..20d751c6003 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 19, 2026, implemented the first actor-runtime / combat-adjacent substrate pass in `engines/harvester`: parsed and persisted richer `MONSTER` plus `TIMER` records from `HARVEST.SCR`, bumped startup-save serialization to version `2` with backward-compatible load fallback, preserved player combat HP/loadout/control-pause state in the startup runtime snapshot, spawned non-visual timer entities during room setup, and paused/resumed their countdowns across room setup fades and the true `run_main_menu` path.
+- Extended the live room loop to sync timer and monster runtime state back into the startup snapshot before save/refresh handoffs, dispatch expired room timers through the confirmed town-script action-tag path, and promote room monsters from static single-frame props into live runtime entities with facing-based idle/walk frame bands.
+- Rebuilt the touched Harvester objects successfully after the actor-runtime/timer pass: `engines/harvester/script.o`, `engines/harvester/runtime_entity.o`, `engines/harvester/flow.o`, `engines/harvester/room.o`, `engines/harvester/menu.o`, `engines/harvester/harvester.o`, and `scummvm`.
 - On March 19, 2026, ran a manual desktop validation pass against the live in-room ESC overlay with a persistent ScummVM GUI session and `osascript` keyboard input: confirmed the six-row room menu is reachable and keyboard-driven, reproduced a real palette-return regression on `SAVE GAME -> Esc` / `LOAD GAME -> Esc`, and fixed it by reapplying the room palette whenever `runRoomMenuStub` redraws its backdrop after a submenu returns.
 - Rebuilt the touched Harvester binary after that room-menu palette fix: `engines/harvester/menu.o` and `scummvm`, then revalidated that both `SAVE GAME -> Esc` and `LOAD GAME -> Esc` return to the room-menu overlay without the corrupted palette.
 - On March 19, 2026, audited the legitimate `run_main_menu` callers against the live `HARVEST.LE` Ghidra session instead of the cold-start path: confirmed that the six-row in-room ESC overlay belongs to the room-menu path, while the true `run_main_menu` return/death path blanks `SAVE GAME` and `LOAD GAME` unless an active room session is still live.
@@ -41,31 +44,26 @@
 
 ## Next Suggested Action
 
-1. Continue the manual desktop validation pass from the live GUI session: finish spot-checking the remaining in-room ESC rows (`NEW GAME`, `OPTIONS`, `HELP`, `QUIT GAME`), then find a reproducible `GODEATHFLIC` / game-over trigger or suitable save so the legitimate `run_main_menu` path can be reached and confirmed to show only `NEW GAME`, `OPTIONS`, `HELP`, and `QUIT GAME`.
-2. Continue the remaining top-level/main-title rendering parity only on the true `run_main_menu` callers from `run_harvester_main_loop` / `run_game_over_screen`; the cold-start quick-tips exit and the room ESC overlay are now separated from that work.
+1. Run a manual desktop validation pass against a live gameplay room using the new actor-runtime/timer substrate: confirm room setup pauses timer countdowns during the fade-in, `HELP` and the true `run_main_menu` stop countdowns while open, expired room timers fire the expected action tags once, and a monster room now shows animated live monster entities instead of static props.
+2. If that validation exposes any remaining room progression failures, widen only the specific town-script opcodes or runtime side effects revealed by those timer/monster rooms; keep unsupported commands explicit instead of guessing.
 
 ## Reimplementation Priority Order
 
-1. Finish startup and room menu parity.
-   Files: `engines/harvester/menu.cpp`, `engines/harvester/menu.h`, `engines/harvester/flow.cpp`, `engines/harvester/harvester.cpp`, `engines/harvester/harvester.h`.
-   Native anchors: `run_main_menu @ 0x67390`, `run_load_game_menu @ 0x64910`, `run_save_game_menu @ 0x632c0`, `run_controls_help_screen @ 0x6c3e0`.
-   Concrete tasks: keep the new real `LOAD GAME` path and `NEW GAME` / restart confirmation aligned with the confirmed native notes; do not route the cold-start quick-tips exit through `run_main_menu`; finish the remaining true `run_main_menu` rendering/timer/palette restore details only on its legitimate live callers.
-   Exit criteria: every visible menu row performs real work instead of falling back to a debug stub, and the startup-menu return path plus the in-room ESC menu expose the same native actions.
-2. Implement the actor runtime and combat-adjacent substrate.
+1. Validate and finish the actor runtime and combat-adjacent substrate.
    Files: `engines/harvester/room.cpp`, `engines/harvester/flow.cpp`, `engines/harvester/runtime_entity.cpp`, `engines/harvester/runtime_entity.h`, `engines/harvester/script.cpp`, `engines/harvester/script.h`.
    Native anchors: `spawn_player_combat_avatar @ 0x54220`, `update_player_combat_avatar_state @ 0x553a0`, `teardown_player_combat_avatar @ 0x55010`, `tick_monster_entity_runtime @ 0x54140`, `spawn_timer_entity_from_record @ 0x59390`, `pause_timer_entity_countdowns @ 0x80460`, `resume_timer_entity_countdowns @ 0x804a0`.
-   Concrete tasks: separate passive scene visuals from live actor state; persist the player combat-avatar HP/loadout state instead of only the coarse room-placement snapshot; add monster update hooks; add timer countdown bookkeeping needed by help/menu pauses and room events.
-   Exit criteria: monsters are no longer static scene art only, and timer-sensitive menu/help behavior matches the confirmed native flow.
-3. Widen town-script action and room-event coverage only where native behavior is already confirmed.
+   Concrete tasks: validate the new timer pause/resume bookkeeping and timer-to-action dispatch in a live room; confirm the true `run_main_menu` pause path now freezes room countdowns; if the live monster rooms still need more than the new facing/animation hooks, extend only the proven actor state transitions from the native anchors.
+   Exit criteria: room timers survive save/load and pause correctly across menus/help, and the remaining monster/combat gaps are narrowed to explicit missing native state-machine branches instead of static-scene placeholders.
+2. Widen town-script action and room-event coverage only where native behavior is already confirmed.
    Files: `engines/harvester/script.cpp`, `engines/harvester/room.cpp`, `engines/harvester/dialogue.cpp`.
    Native anchors: `dispatch_room_event_actions @ 0x60ee0`, `run_town_script_interpreter @ 0x46d80`, and the confirmed town-script notes in `ARCHITECTURE.md`.
    Concrete tasks: drive missing work from broken rooms and interactions; add only the opcodes and side effects needed to unblock verified room paths; keep unsupported commands explicit rather than guessing.
    Exit criteria: room progression failures shrink to a small, explicit list of still-unimplemented native commands instead of broad "unsupported" fallthrough.
-4. Tighten inventory and item-use flow against that actor runtime.
+3. Tighten inventory and item-use flow against that actor runtime.
    Files: `engines/harvester/inventory.cpp`, `engines/harvester/room.cpp`, `engines/harvester/script.cpp`.
    Native anchors: `run_inventory_screen @ 0x7df10`, `add_object_to_inventory @ 0x7c8b0`, `sync_player_combat_weapon_resource_icons @ 0x792c0`, and the confirmed inventory carry/use notes in `ARCHITECTURE.md`.
    Concrete tasks: keep the current carry/use handoff model, but recheck it once actor runtime state, HP, timers, and combat-adjacent HUD elements are live; fold any missing status/icon behavior in here rather than earlier.
    Exit criteria: inventory, carried items, and room-target use cases all operate against the same live actor state that room gameplay uses.
-5. Leave low-level runtime, HMIDRV, and x87 cleanup as deferred RE work unless they block a confirmed gameplay feature.
+4. Leave low-level runtime, HMIDRV, and x87 cleanup as deferred RE work unless they block a confirmed gameplay feature.
    Ghidra anchors: the remaining unwind helpers around `FUN_0008f64b` through `FUN_000905df`, the HMIDRV wrapper band around `FUN_00087005`, and the x87/no-87 runtime cluster beginning near `FUN_0008c3d8`.
    Rationale: those clusters still matter for naming completeness, but the current engine-side gaps are better bounded and have higher reimplementation value.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 07d8ac6dded..5dd81b897e0 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -234,6 +234,8 @@ static int resolvePlayerFacingFrame(int facing) {
 
 static int resolveMonsterFacingFrame(int facing) {
 	switch (facing) {
+	case 0:
+		return 0x3b;
 	case 1:
 		return 0x0e;
 	case 2:
@@ -241,7 +243,7 @@ static int resolveMonsterFacingFrame(int facing) {
 	case 3:
 		return 0x28;
 	default:
-		return 0;
+		return 0x3b;
 	}
 }
 
@@ -2159,6 +2161,19 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 			regionBounds.left, regionBounds.top, regionBounds.right, regionBounds.bottom,
 			region.minZ, region.maxZ, region.desiredFacing, region.cursorEnabled, region.actionTag.c_str());
 	}
+	for (const StartupTimerRecord &timer : state.roomTimers) {
+		if (!runtimeEntities->spawnSceneTimerEntity(timer.timerName,
+				timer.initialValue, timer.currentValue, timer.enabled, timer.looping, timer.global)) {
+			debug(1, "Harvester: unable to spawn room timer entity '%s'",
+				timer.timerName.c_str());
+			continue;
+		}
+
+		debugC(1, kDebugScene,
+			"Harvester: scene timer spawned room='%s' timer='%s' current=%d initial=%d enabled=%d loop=%d global=%d",
+			state.roomName.c_str(), timer.timerName.c_str(), timer.currentValue, timer.initialValue,
+			timer.enabled, timer.looping, timer.global);
+	}
 	for (const StartupObjectRecord &object : drawableObjects) {
 		RuntimeEntity *entity = nullptr;
 		const Common::String spritePath = resolveSceneObjectSpritePath(object);
@@ -2234,7 +2249,7 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 	for (const StartupMonsterRecord &monster : state.roomMonsters) {
 		RuntimeEntity *entity = runtimeEntities->spawnSceneActorEntity(monster.monsterName,
 			monster.modelPath, Common::Point(monster.posX, monster.posY), (float)monster.posZ,
-			resolveMonsterFacingFrame(monster.initialFacing));
+			resolveMonsterFacingFrame(monster.facing));
 		if (!entity) {
 			debug(1, "Harvester: unable to spawn room monster entity '%s' from '%s'",
 				monster.monsterName.c_str(), monster.modelPath.c_str());
@@ -2249,9 +2264,10 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 				monster.monsterName.c_str());
 		}
 		debugC(1, kDebugScene,
-			"Harvester: scene monster spawned room='%s' monster='%s' class=0x%x pos=(%d,%d,z=%.2f) facing=%d model='%s' active=%d visible=%d",
+			"Harvester: scene monster spawned room='%s' monster='%s' class=0x%x pos=(%d,%d,z=%.2f) facing=%d hp=%d/%d model='%s' active=%d visible=%d",
 			state.roomName.c_str(), monster.monsterName.c_str(), entity->getClassId(),
-			entity->getX(), entity->getY(), (double)entity->getZ(), monster.initialFacing,
+			entity->getX(), entity->getY(), (double)entity->getZ(), monster.facing,
+			monster.currentHitPoints, monster.initialHitPoints,
 			monster.modelPath.c_str(), monster.active, monster.visible);
 	}
 	if (state.hasEntrance) {
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 75c67d8456b..3b7cf44021b 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -48,7 +48,7 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 }
 
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
-static const uint32 kHarvesterSaveVersion = 1;
+static const uint32 kHarvesterSaveVersion = 2;
 
 } // End of anonymous namespace
 
diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index e7a85e933ce..22a36957032 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -116,6 +116,38 @@ struct RoomMenuTextConfig {
 	Common::String quitGamePrompt = "QUIT GAME";
 };
 
+class ScopedSceneTimerPause {
+public:
+	explicit ScopedSceneTimerPause(HarvesterEngine &engine) : _engine(engine) {
+		pause();
+	}
+
+	~ScopedSceneTimerPause() {
+		resume();
+	}
+
+	void pause() {
+		if (_paused)
+			return;
+		if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities()) {
+			runtimeEntities->pauseTimerCountdowns();
+			_paused = true;
+		}
+	}
+
+	void resume() {
+		if (!_paused)
+			return;
+		if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities())
+			runtimeEntities->resumeTimerCountdowns();
+		_paused = false;
+	}
+
+private:
+	HarvesterEngine &_engine;
+	bool _paused = false;
+};
+
 static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y);
 static int getNativeRoomMenuLineHeight(const Graphics::Font &selectedFont);
 
@@ -616,6 +648,7 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 	const Art *art = _engine.getStartupArt();
 	if (!art)
 		return Common::kReadingFailed;
+	ScopedSceneTimerPause pausedTimers(_engine);
 
 	Common::Array<Common::String> mainMenuItems;
 	const bool showSessionActions = _engine.hasCurrentStartupSaveRoomState();
@@ -637,7 +670,9 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 		return screen && captureScreenBackdrop(*screen, backdrop);
 	};
 	auto runSelectedRoomLoop = [&](const Common::String &targetName) -> Common::Error {
+		pausedTimers.resume();
 		Common::Error roomError = startupFlow.runRoomLoop(targetName);
+		pausedTimers.pause();
 		_engine.stopStartupMusic();
 		_engine.stopStartupSound();
 		if (roomError.getCode() == Common::kReadingFailed) {
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 26eed4ad1ad..8ed93d32e21 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -46,6 +46,17 @@ static const uint32 kRoomExitFastClickWindowTicks = 20;
 static const char *const kPlayerInventoryLabel = "your inventory";
 static const char *const kInventoryOwnerName = "INVENTORY";
 static const byte kTransparentPaletteIndex = 0;
+static const int kRoomMonsterAnimationRate = 17;
+
+struct MonsterAnimationRange {
+	MonsterAnimationRange() {}
+	MonsterAnimationRange(int walkFirstFrame, int walkLastFrame, int idleFrame)
+		: walkFirstFrame(walkFirstFrame), walkLastFrame(walkLastFrame), idleFrame(idleFrame) {}
+
+	int walkFirstFrame = 0;
+	int walkLastFrame = 0;
+	int idleFrame = 0;
+};
 
 static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
 	return !roomName.equalsIgnoreCase("LAVAPIT") &&
@@ -175,6 +186,39 @@ static void drawRoomPrompt(Graphics::Screen &screen, const Graphics::Font &font,
 	font.drawString(&screen, promptText, 0, 462, 640, 0xce, Graphics::kTextAlignCenter);
 }
 
+static MonsterAnimationRange resolveRoomMonsterAnimationRange(int facing) {
+	switch (facing) {
+	case 0:
+		return MonsterAnimationRange(0x00, 0x09, 0x3b);
+	case 1:
+		return MonsterAnimationRange(0x0f, 0x18, 0x0e);
+	case 2:
+		return MonsterAnimationRange(0x2d, 0x36, 0x2c);
+	case 3:
+		return MonsterAnimationRange(0x1e, 0x27, 0x28);
+	default:
+		return MonsterAnimationRange(0x00, 0x09, 0x3b);
+	}
+}
+
+static void applyRoomMonsterAnimation(RuntimeEntity &entity, const StartupMonsterRecord &monster) {
+	entity.setVisible(monster.visible);
+	if (!monster.visible)
+		return;
+
+	const MonsterAnimationRange range = resolveRoomMonsterAnimationRange(monster.facing);
+	if (monster.active) {
+		entity.setAnimationRate(kRoomMonsterAnimationRate);
+		entity.setAnimationFrameRange(range.walkFirstFrame, range.walkLastFrame, true);
+		entity.setAnimationEnabled(true);
+	} else {
+		entity.setAnimationRate(0);
+		entity.setAnimationFrameRange(range.idleFrame, range.idleFrame, false);
+		entity.setCurrentFrame(range.idleFrame);
+		entity.setAnimationEnabled(false);
+	}
+}
+
 RoomSystem::RoomSystem(HarvesterEngine &engine, Common::Point &mousePos,
 		InventorySystem &inventory)
 	: _engine(engine), _mousePos(mousePos), _inventory(inventory) {
@@ -234,9 +278,18 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		}
 		if (!screen || !art || !bodyFont || !promptFont)
 			return Common::kNoError;
+		RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
 
 		if (!startupFlow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
 			return Common::kReadingFailed;
+		if (runtimeEntities) {
+			for (const StartupMonsterRecord &monster : scene.state.roomMonsters) {
+				RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(monster.monsterName);
+				if (entity)
+					applyRoomMonsterAnimation(*entity, monster);
+			}
+			runtimeEntities->pauseTimerCountdowns();
+		}
 
 		logScenePaletteSummary("room setup stub palette", scene, 0.0f);
 		drawRoomScene(_engine, *screen, scene, 0.0f);
@@ -247,12 +300,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		transitionError = startupFlow.fadeInRoomScene(scene.palette, scene.targetPaletteBrightness);
 		if (transitionError.getCode() != Common::kNoError)
 			return transitionError;
+		if (runtimeEntities)
+			runtimeEntities->resumeTimerCountdowns();
 
 		startupFlow.resetCursorAnimationSequence();
 		startupFlow.executeStartupAudioCommands(scene.state.audioCommands);
 		if (!scene.state.musicPath.empty())
 			(void)_engine.playStartupMusic(scene.state.musicPath);
-		RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
 		StartupRoomPlayerState playerState;
 		playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName("PLAYER") : nullptr;
 		playerState.centerX = state.playerSpawnX;
@@ -311,7 +365,27 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			idleState.activityTick = getRuntimeClockTicks();
 			updatePlayerIdleTrigger(idleState);
 		};
+		auto syncCurrentRoomRuntimeState = [&]() {
+			Script *startupScript = _engine.getStartupScript();
+			if (!startupScript)
+				return;
+
+			for (const StartupMonsterRecord &monster : scene.state.roomMonsters)
+				(void)startupScript->syncRuntimeMonsterRecord(monster);
+			if (!runtimeEntities)
+				return;
+
+			for (StartupTimerRecord &timer : scene.state.roomTimers) {
+				RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(timer.timerName);
+				if (!entity)
+					continue;
+				timer.currentValue = entity->getTimerCurrentValue();
+				timer.enabled = entity->isTimerEnabled();
+				(void)startupScript->syncRuntimeTimerRecord(timer);
+			}
+		};
 		auto captureCurrentSaveState = [&]() {
+			syncCurrentRoomRuntimeState();
 			const int facing = playerState.facing >= 0 ? playerState.facing : scene.state.playerFacing;
 			_engine.captureCurrentStartupSaveRoomState(scene.state.entranceName, scene.state.roomName,
 				playerState.centerX, playerState.bottomY, (int)playerState.z, facing,
@@ -343,6 +417,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			return false;
 		if (!startupFlow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
 			return false;
+		if (runtimeEntities) {
+			for (const StartupMonsterRecord &monster : scene.state.roomMonsters) {
+				RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(monster.monsterName);
+				if (entity)
+					applyRoomMonsterAnimation(*entity, monster);
+			}
+		}
 
 		playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName("PLAYER") : nullptr;
 		if (playerState.entity) {
@@ -1288,6 +1369,28 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 		if (startupFlow.tickRuntimeEntities())
 			needsRedraw = true;
+		if (runtimeEntities) {
+			Common::Array<Common::String> expiredTimerNames;
+			if (runtimeEntities->takeExpiredTimerNames(expiredTimerNames)) {
+				syncCurrentRoomRuntimeState();
+				for (const Common::String &timerName : expiredTimerNames) {
+					StartupInteractionResult timerInteraction;
+					if (!_engine.getStartupScript()->executeActionTag(timerName, timerInteraction))
+						continue;
+
+					bool didTransition = false;
+					Common::Error interactionError =
+						interactionProcessor.handleInteractionResult(timerInteraction, didTransition);
+					if (interactionError.getCode() != Common::kNoError)
+						return interactionError;
+					if (startupFlow.hasPendingMainMenuReturn())
+						return Common::kNoError;
+					needsRedraw = true;
+					if (!pendingRoomChange.empty())
+						break;
+				}
+			}
+		}
 		if (updatePlayerIdleAnimation(scene.state, playerState, idleState))
 			needsRedraw = true;
 
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index caec66eb121..fb0a45baefa 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -383,6 +383,97 @@ Common::Rect RuntimeEntity::getScreenRect() const {
 	return getFrameRect();
 }
 
+void RuntimeEntity::configureTimerCountdown(int initialValue, int currentValue,
+		bool enabled, bool looping, bool global) {
+	_timerInitialValue = MAX(initialValue, 0);
+	_timerCurrentValue = MAX(currentValue, 0);
+	_timerEnabled = false;
+	_timerLooping = looping;
+	_timerGlobal = global;
+	_timerPaused = false;
+	_timerPauseTick = 0;
+	_timerStartTick = 0;
+	_timerNextFireTick = 0;
+	_visible = false;
+	_drawEnabled = false;
+	_animationEnabled = false;
+	_hitTestMode = kRuntimeEntityHitTestNone;
+	setTimerEnabled(enabled);
+}
+
+void RuntimeEntity::setTimerEnabled(bool enabled) {
+	if (_classId != kRuntimeEntityClassTimer)
+		return;
+
+	if (!enabled) {
+		_timerEnabled = false;
+		_timerPaused = false;
+		_timerPauseTick = 0;
+		_timerStartTick = 0;
+		_timerNextFireTick = 0;
+		return;
+	}
+
+	const uint32 now = getAnimationClockTicks();
+	const int remainingValue = _timerCurrentValue > 0 ? _timerCurrentValue : _timerInitialValue;
+	_timerEnabled = true;
+	_timerPaused = false;
+	_timerPauseTick = 0;
+	_timerStartTick = now;
+	_timerCurrentValue = MAX(remainingValue, 0);
+	_timerNextFireTick = now + (uint32)_timerCurrentValue * 100U;
+}
+
+bool RuntimeEntity::tickTimerState(uint32 now, Common::Array<Common::String> &expiredTimerNames) {
+	if (_classId != kRuntimeEntityClassTimer || !_timerEnabled || _timerPaused)
+		return false;
+
+	if (_timerNextFireTick != 0 && (int32)(now - _timerNextFireTick) < 0) {
+		const uint32 remainingTicks = _timerNextFireTick - now;
+		_timerCurrentValue = (int)((remainingTicks + 99U) / 100U);
+		return false;
+	}
+
+	_timerCurrentValue = 0;
+	expiredTimerNames.push_back(_name);
+	if (_timerLooping) {
+		_timerCurrentValue = _timerInitialValue;
+		_timerStartTick = now;
+		_timerNextFireTick = now + (uint32)_timerInitialValue * 100U;
+	} else {
+		_timerEnabled = false;
+		_timerStartTick = 0;
+		_timerNextFireTick = 0;
+	}
+
+	return false;
+}
+
+void RuntimeEntity::pauseTimerCountdown(uint32 now) {
+	if (_classId != kRuntimeEntityClassTimer || !_timerEnabled || _timerPaused)
+		return;
+
+	if (_timerNextFireTick != 0 && (int32)(now - _timerNextFireTick) < 0) {
+		const uint32 remainingTicks = _timerNextFireTick - now;
+		_timerCurrentValue = (int)((remainingTicks + 99U) / 100U);
+	} else {
+		_timerCurrentValue = 0;
+	}
+
+	_timerPaused = true;
+	_timerPauseTick = now;
+}
+
+void RuntimeEntity::resumeTimerCountdown(uint32 now) {
+	if (_classId != kRuntimeEntityClassTimer || !_timerEnabled || !_timerPaused)
+		return;
+
+	if (_timerNextFireTick != 0)
+		_timerNextFireTick += now - _timerPauseTick;
+	_timerPaused = false;
+	_timerPauseTick = 0;
+}
+
 void RuntimeEntity::draw(Graphics::Screen &screen) const {
 	if (!_visible || !_drawEnabled || _currentFrame < 0)
 		return;
@@ -620,6 +711,8 @@ void RuntimeEntityManager::clearSceneEntities() {
 	for (RuntimeEntity *entity : _sceneEntities)
 		delete entity;
 	_sceneEntities.clear();
+	_expiredTimerNames.clear();
+	_timerPauseDepth = 0;
 }
 
 RuntimeEntity *RuntimeEntityManager::spawnAbmEntityFromResource(const Common::String &name,
@@ -742,6 +835,16 @@ RuntimeEntity *RuntimeEntityManager::spawnSceneActorEntity(const Common::String
 	return entity;
 }
 
+RuntimeEntity *RuntimeEntityManager::spawnSceneTimerEntity(const Common::String &name,
+		int initialValue, int currentValue, bool enabled, bool looping, bool global) {
+	RuntimeEntity *entity = new RuntimeEntity();
+	entity->setName(name);
+	entity->setClassId(kRuntimeEntityClassTimer);
+	entity->configureTimerCountdown(initialValue, currentValue, enabled, looping, global);
+	insertSceneEntity(entity);
+	return entity;
+}
+
 void RuntimeEntityManager::hideCursor() {
 	if (_cursorEntity)
 		_cursorEntity->setVisible(false);
@@ -752,12 +855,48 @@ void RuntimeEntityManager::showCursor() {
 		_cursorEntity->setVisible(true);
 }
 
+void RuntimeEntityManager::pauseTimerCountdowns() {
+	if (_timerPauseDepth++ > 0)
+		return;
+
+	const uint32 now = getAnimationClockTicks();
+	for (RuntimeEntity *entity : _sceneEntities)
+		entity->pauseTimerCountdown(now);
+}
+
+void RuntimeEntityManager::resumeTimerCountdowns() {
+	if (_timerPauseDepth == 0)
+		return;
+	if (--_timerPauseDepth > 0)
+		return;
+
+	const uint32 now = getAnimationClockTicks();
+	for (RuntimeEntity *entity : _sceneEntities)
+		entity->resumeTimerCountdown(now);
+}
+
+bool RuntimeEntityManager::takeExpiredTimerNames(Common::Array<Common::String> &expiredTimerNames) {
+	if (_expiredTimerNames.empty())
+		return false;
+
+	expiredTimerNames = _expiredTimerNames;
+	_expiredTimerNames.clear();
+	return true;
+}
+
 bool RuntimeEntityManager::tickSceneEntities() {
 	const uint32 now = getAnimationClockTicks();
 	bool changed = false;
 
-	for (RuntimeEntity *entity : _sceneEntities)
+	for (RuntimeEntity *entity : _sceneEntities) {
+		if (entity->getClassId() == kRuntimeEntityClassTimer) {
+			if (_timerPauseDepth == 0)
+				(void)entity->tickTimerState(now, _expiredTimerNames);
+			continue;
+		}
+
 		changed |= entity->tickVisualState(now);
+	}
 
 	return changed;
 }
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index 341f617697c..d4608a11c86 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -45,6 +45,7 @@ enum RuntimeEntityClass {
 	kRuntimeEntityClassMonster = 6,
 	kRuntimeEntityClassRectHotspot = 0x15,
 	kRuntimeEntityClassDisabledHotspot = 0x16,
+	kRuntimeEntityClassTimer = 0x17,
 	kRuntimeEntityClassRectHotspot18 = 0x18,
 	kRuntimeEntityClassRectHotspot19 = 0x19
 };
@@ -101,6 +102,16 @@ public:
 	bool getCurrentFrameMetrics(int &width, int &height, int &xOffset, int &yOffset) const;
 	void setDepthScale(float scale);
 	Common::Rect getScreenRect() const;
+	void configureTimerCountdown(int initialValue, int currentValue, bool enabled, bool looping, bool global);
+	bool tickTimerState(uint32 now, Common::Array<Common::String> &expiredTimerNames);
+	void pauseTimerCountdown(uint32 now);
+	void resumeTimerCountdown(uint32 now);
+	void setTimerEnabled(bool enabled);
+	bool isTimerEnabled() const { return _timerEnabled; }
+	bool isTimerLooping() const { return _timerLooping; }
+	bool isTimerGlobal() const { return _timerGlobal; }
+	int getTimerInitialValue() const { return _timerInitialValue; }
+	int getTimerCurrentValue() const { return _timerCurrentValue; }
 
 	bool hasFrames() const { return !_frames.empty(); }
 	bool tickVisualState(uint32 now);
@@ -146,6 +157,15 @@ private:
 	RuntimeEntityAnchorMode _anchorMode = kRuntimeEntityAnchorTopLeft;
 	float _zExtent = 0.0f;
 	float _depthScale = 1.0f;
+	int _timerInitialValue = 0;
+	int _timerCurrentValue = 0;
+	uint32 _timerStartTick = 0;
+	uint32 _timerNextFireTick = 0;
+	uint32 _timerPauseTick = 0;
+	bool _timerEnabled = false;
+	bool _timerLooping = false;
+	bool _timerGlobal = false;
+	bool _timerPaused = false;
 };
 
 class RuntimeEntityManager {
@@ -168,9 +188,14 @@ public:
 		bool playBackwards, bool pingPong);
 	RuntimeEntity *spawnSceneActorEntity(const Common::String &name, const Common::String &resourcePath,
 		const Common::Point &position, float z, int initialFrame);
+	RuntimeEntity *spawnSceneTimerEntity(const Common::String &name, int initialValue, int currentValue,
+		bool enabled, bool looping, bool global);
 	RuntimeEntity *getCursorEntity() const { return _cursorEntity; }
 	void hideCursor();
 	void showCursor();
+	void pauseTimerCountdowns();
+	void resumeTimerCountdowns();
+	bool takeExpiredTimerNames(Common::Array<Common::String> &expiredTimerNames);
 	bool tickSceneEntities();
 	bool syncCursorEntityPosition(const Common::Point &position);
 	void drawSceneEntities(Graphics::Screen &screen) const;
@@ -186,7 +211,9 @@ private:
 
 	ResourceManager &_resources;
 	Common::Array<RuntimeEntity *> _sceneEntities;
+	Common::Array<Common::String> _expiredTimerNames;
 	RuntimeEntity *_cursorEntity = nullptr;
+	int _timerPauseDepth = 0;
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index b5523d5c25f..aa4a762ce0f 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -44,6 +44,8 @@ static const byte kTownScriptXorKey = 0xaa;
 static const float kDefaultPaletteBrightness = 1.0f;
 static const float kDimmedPaletteBrightness = 0.6f;
 static const int kDefaultPlayerHitPoints = 30;
+static const int kDefaultPlayerCombatLoadout = 0;
+static const int kMaxPlayerCombatLoadout = 0x14;
 static const int kMaxStartupOptionLevel = 9;
 
 static void syncBool(Common::Serializer &s, bool &value) {
@@ -138,13 +140,53 @@ static void syncStartupMonsterRecord(Common::Serializer &s, StartupMonsterRecord
 	s.syncAsSint32LE(record.posX);
 	s.syncAsSint32LE(record.posY);
 	s.syncAsSint32LE(record.posZ);
-	s.syncAsSint32LE(record.initialFacing);
+	s.syncAsSint32LE(record.facing);
 	s.syncString(record.roomName);
 	s.syncString(record.monsterName);
 	s.syncString(record.modelPath);
 	s.syncString(record.onDeathActionTag);
 	syncBool(s, record.active);
 	syncBool(s, record.visible);
+	if (s.getVersion() < 2)
+		return;
+
+	s.syncAsSint32LE(record.initialHitPoints);
+	s.syncAsSint32LE(record.currentHitPoints);
+	s.syncAsSint32LE(record.damageAmount);
+	s.syncAsSint32LE(record.engageDistance);
+	s.syncAsSint32LE(record.damageType);
+	s.syncString(record.field38);
+	s.syncString(record.field3c);
+	s.syncString(record.field44);
+	s.syncString(record.field48);
+	s.syncString(record.attackSound1);
+	s.syncString(record.attackSound2);
+	s.syncString(record.attackSound3);
+	s.syncString(record.hitSound1);
+	s.syncString(record.hitSound2);
+	s.syncString(record.hitSound3);
+	s.syncString(record.footstepSoundLeft);
+	s.syncString(record.footstepSoundRight);
+	s.syncString(record.deathSound);
+	syncBool(s, record.savedVisible);
+	syncBool(s, record.runtimeSpawned);
+	s.syncAsSint32LE(record.minXBound);
+	s.syncAsSint32LE(record.maxXBound);
+	s.syncAsSint32LE(record.field70);
+	s.syncAsSint32LE(record.field74);
+	s.syncAsSint32LE(record.field78);
+	s.syncAsSint32LE(record.field7c);
+}
+
+static void syncStartupTimerRecord(Common::Serializer &s, StartupTimerRecord &record) {
+	s.syncAsSint32LE(record.initialValue);
+	s.syncAsSint32LE(record.currentValue);
+	s.syncString(record.timerName);
+	s.syncString(record.arg1);
+	s.syncString(record.arg2);
+	syncBool(s, record.enabled);
+	syncBool(s, record.looping);
+	syncBool(s, record.global);
 }
 
 static void syncStartupRegionRecord(Common::Serializer &s, StartupRegionRecord &record) {
@@ -181,6 +223,10 @@ static int clampPlayerHitPoints(int hitPoints) {
 	return hitPoints;
 }
 
+static int clampPlayerCombatLoadout(int loadout) {
+	return CLIP<int>(loadout, 0, kMaxPlayerCombatLoadout);
+}
+
 static const char *resolveInventoryStatusObjectName(int hitPoints) {
 	if (hitPoints < 8)
 		return "INV_STAT4";
@@ -369,13 +415,24 @@ bool Script::load(ResourceManager &resources) {
 	_objects.clear();
 	_animations.clear();
 	_npcs.clear();
+	_monsters.clear();
+	_timers.clear();
 	_regions.clear();
 	_flags.clear();
 	_commands.clear();
 	_texts.clear();
 	_heads.clear();
 	_useItems.clear();
+	_runtimeFlags.clear();
+	_runtimeObjects.clear();
+	_runtimeAnimations.clear();
+	_runtimeNpcs.clear();
+	_runtimeMonsters.clear();
+	_runtimeTimers.clear();
 	_runtimeRegions.clear();
+	_playerCurrentHitPoints = kDefaultPlayerHitPoints;
+	_playerCombatLoadout = kDefaultPlayerCombatLoadout;
+	_playerControlPaused = false;
 	_fxVolumeLevel = 9;
 	_musicVolumeLevel = 3;
 	_gammaLevel = 0;
@@ -545,6 +602,8 @@ void Script::parseTownRecords(ResourceManager &resources) {
 	_objects.clear();
 	_animations.clear();
 	_npcs.clear();
+	_monsters.clear();
+	_timers.clear();
 	_regions.clear();
 	_flags.clear();
 	_commands.clear();
@@ -566,8 +625,9 @@ void Script::parseTownRecords(ResourceManager &resources) {
 		for (uint i = 0; i < tokens.size(); ++i) {
 			if (tokens[i] == "ANIM" || tokens[i] == "ENTRANCE" || tokens[i] == "MAP_ENTRANCE" ||
 				tokens[i] == "MAP_LOCATION" || tokens[i] == "ROOM" || tokens[i] == "OBJECT" ||
-				tokens[i] == "NPC" || tokens[i] == "REGION" || tokens[i] == "HEAD" ||
-				tokens[i] == "FLAG" || tokens[i] == "COMMAND" || tokens[i] == "TEXT" || tokens[i] == "USEITEM") {
+				tokens[i] == "NPC" || tokens[i] == "MONSTER" || tokens[i] == "REGION" ||
+				tokens[i] == "HEAD" || tokens[i] == "FLAG" || tokens[i] == "COMMAND" ||
+				tokens[i] == "TEXT" || tokens[i] == "TIMER" || tokens[i] == "USEITEM") {
 				tagIndex = i;
 				break;
 			}
@@ -781,23 +841,74 @@ void Script::parseTownRecords(ResourceManager &resources) {
 				return;
 
 			StartupMonsterRecord monster;
-			if (tagIndex >= 3) {
+			if (tagIndex >= 6) {
 				monster.posX = atoi(tokens[0].c_str());
 				monster.posY = atoi(tokens[1].c_str());
 				monster.posZ = atoi(tokens[2].c_str());
+				monster.initialHitPoints = atoi(tokens[3].c_str());
+				monster.damageAmount = atoi(tokens[4].c_str());
+				monster.engageDistance = atoi(tokens[5].c_str());
+			}
+			if (tagIndex >= 10) {
+				monster.field70 = atoi(tokens[6].c_str());
+				monster.field74 = atoi(tokens[7].c_str());
+				monster.field78 = atoi(tokens[8].c_str());
+				monster.field7c = atoi(tokens[9].c_str());
 			}
 			monster.roomName = tokens[tagIndex + 1];
 			monster.monsterName = tokens[tagIndex + 2];
 			monster.modelPath = resources.normalizeResourcePath(tokens[tagIndex + 3]);
-			monster.initialFacing = parseEntranceFacing(tokens[tagIndex + 8]);
+			monster.field44 = tokens[tagIndex + 4];
+			monster.field48 = tokens[tagIndex + 5];
+			monster.field38 = tokens[tagIndex + 6];
+			monster.field3c = tokens[tagIndex + 7];
+			monster.facing = parseEntranceFacing(tokens[tagIndex + 8]);
+			monster.damageType = parseNpcDeathDamageType(tokens[tagIndex + 9]);
+			monster.attackSound1 = resources.normalizeResourcePath(tokens[tagIndex + 10]);
+			monster.attackSound2 = resources.normalizeResourcePath(tokens[tagIndex + 11]);
+			monster.attackSound3 = resources.normalizeResourcePath(tokens[tagIndex + 12]);
+			monster.hitSound1 = resources.normalizeResourcePath(tokens[tagIndex + 13]);
+			monster.hitSound2 = resources.normalizeResourcePath(tokens[tagIndex + 14]);
+			monster.hitSound3 = resources.normalizeResourcePath(tokens[tagIndex + 15]);
+			monster.footstepSoundLeft = resources.normalizeResourcePath(tokens[tagIndex + 16]);
+			monster.footstepSoundRight = resources.normalizeResourcePath(tokens[tagIndex + 17]);
+			monster.deathSound = resources.normalizeResourcePath(tokens[tagIndex + 18]);
 			monster.active = tokens[tagIndex + 19].equalsIgnoreCase("T");
 			monster.visible = tokens[tagIndex + 20].equalsIgnoreCase("T");
 			monster.onDeathActionTag = tokens[tagIndex + 21];
+			if (tokens.size() > tagIndex + 22 && !tokens[tagIndex + 22].empty())
+				monster.minXBound = atoi(tokens[tagIndex + 22].c_str());
+			if (tokens.size() > tagIndex + 23 && !tokens[tagIndex + 23].empty())
+				monster.maxXBound = atoi(tokens[tagIndex + 23].c_str());
+			monster.currentHitPoints = monster.initialHitPoints;
+			monster.savedVisible = monster.visible;
+			if (monster.active)
+				monster.visible = true;
 			if (!monster.roomName.empty() && !monster.monsterName.empty() && !monster.modelPath.empty())
 				_monsters.push_back(monster);
 			return;
 		}
 
+		if (tag == "TIMER") {
+			if (tokens.size() < tagIndex + 7)
+				return;
+
+			StartupTimerRecord timer;
+			if (tagIndex >= 1) {
+				timer.initialValue = atoi(tokens[0].c_str());
+				timer.currentValue = timer.initialValue;
+			}
+			timer.timerName = tokens[tagIndex + 1];
+			timer.arg1 = tokens[tagIndex + 2];
+			timer.arg2 = tokens[tagIndex + 3];
+			timer.enabled = tokens[tagIndex + 4].equalsIgnoreCase("T");
+			timer.looping = tokens[tagIndex + 5].equalsIgnoreCase("T");
+			timer.global = tokens[tagIndex + 6].equalsIgnoreCase("T");
+			if (!timer.timerName.empty())
+				_timers.push_back(timer);
+			return;
+		}
+
 		if (tag == "REGION") {
 			if (tokens.size() < tagIndex + 7)
 				return;
@@ -936,13 +1047,14 @@ bool Script::resolveRoomSetupState(const Common::String &entranceName, StartupRo
 		state.musicPath = musicPath;
 
 	debugC(1, kDebugGeneral,
-		"Harvester: resolveRoomSetupState('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u roomNpcs=%u roomMonsters=%u roomRegions=%u mutated=%d",
+		"Harvester: resolveRoomSetupState('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u roomNpcs=%u roomMonsters=%u roomTimers=%u roomRegions=%u mutated=%d",
 		entranceName.c_str(), state.roomName.c_str(), state.entranceName.c_str(),
 		state.playerSpawnX, state.playerSpawnY, state.playerSpawnZ, state.playerFacing,
 		state.palettePath.c_str(), state.backgroundPath.c_str(), state.musicPath.c_str(),
 		(double)state.paletteBrightness, (uint)state.roomObjects.size(),
 		(uint)state.activeObjects.size(), (uint)state.roomAnimations.size(), (uint)state.roomNpcs.size(),
-		(uint)state.roomMonsters.size(), (uint)state.roomRegions.size(), mutatedRuntimeState);
+		(uint)state.roomMonsters.size(), (uint)state.roomTimers.size(),
+		(uint)state.roomRegions.size(), mutatedRuntimeState);
 
 	return true;
 }
@@ -954,7 +1066,10 @@ void Script::resetRuntimeState() {
 	_runtimeRegions = _regions;
 	_runtimeNpcs = _npcs;
 	_runtimeMonsters = _monsters;
+	_runtimeTimers = _timers;
 	_playerCurrentHitPoints = kDefaultPlayerHitPoints;
+	_playerCombatLoadout = kDefaultPlayerCombatLoadout;
+	_playerControlPaused = false;
 
 	for (StartupObjectRecord &object : _runtimeObjects) {
 		object.currentX = object.initialX;
@@ -975,6 +1090,17 @@ void Script::resetRuntimeState() {
 		npc.runtimeSpawned = false;
 		npc.savedVisible = npc.visible;
 	}
+
+	for (StartupMonsterRecord &monster : _runtimeMonsters) {
+		monster.currentHitPoints = monster.initialHitPoints;
+		monster.runtimeSpawned = false;
+		monster.savedVisible = monster.visible;
+		if (monster.active)
+			monster.visible = true;
+	}
+
+	for (StartupTimerRecord &timer : _runtimeTimers)
+		timer.currentValue = timer.initialValue;
 }
 
 void Script::syncRuntimeSaveState(Common::Serializer &s) {
@@ -985,8 +1111,58 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 	syncRecordArray(s, _runtimeNpcs, syncStartupNpcRecord);
 	syncRecordArray(s, _runtimeMonsters, syncStartupMonsterRecord);
 	s.syncAsSint32LE(_playerCurrentHitPoints);
-	if (s.isLoading())
+	if (s.getVersion() >= 2) {
+		syncRecordArray(s, _runtimeTimers, syncStartupTimerRecord);
+		s.syncAsSint32LE(_playerCombatLoadout);
+		syncBool(s, _playerControlPaused);
+	}
+	if (s.isLoading()) {
 		_playerCurrentHitPoints = clampPlayerHitPoints(_playerCurrentHitPoints);
+		_playerCombatLoadout = clampPlayerCombatLoadout(_playerCombatLoadout);
+		if (s.getVersion() < 2) {
+			_runtimeTimers = _timers;
+			for (StartupMonsterRecord &runtimeMonster : _runtimeMonsters) {
+				const StartupMonsterRecord *baseMonster = nullptr;
+				for (const StartupMonsterRecord &monster : _monsters) {
+					if (monster.monsterName.equalsIgnoreCase(runtimeMonster.monsterName)) {
+						baseMonster = &monster;
+						break;
+					}
+				}
+				if (!baseMonster)
+					continue;
+
+				runtimeMonster.initialHitPoints = baseMonster->initialHitPoints;
+				runtimeMonster.currentHitPoints = runtimeMonster.initialHitPoints;
+				runtimeMonster.damageAmount = baseMonster->damageAmount;
+				runtimeMonster.engageDistance = baseMonster->engageDistance;
+				runtimeMonster.damageType = baseMonster->damageType;
+				runtimeMonster.field38 = baseMonster->field38;
+				runtimeMonster.field3c = baseMonster->field3c;
+				runtimeMonster.field44 = baseMonster->field44;
+				runtimeMonster.field48 = baseMonster->field48;
+				runtimeMonster.attackSound1 = baseMonster->attackSound1;
+				runtimeMonster.attackSound2 = baseMonster->attackSound2;
+				runtimeMonster.attackSound3 = baseMonster->attackSound3;
+				runtimeMonster.hitSound1 = baseMonster->hitSound1;
+				runtimeMonster.hitSound2 = baseMonster->hitSound2;
+				runtimeMonster.hitSound3 = baseMonster->hitSound3;
+				runtimeMonster.footstepSoundLeft = baseMonster->footstepSoundLeft;
+				runtimeMonster.footstepSoundRight = baseMonster->footstepSoundRight;
+				runtimeMonster.deathSound = baseMonster->deathSound;
+				runtimeMonster.savedVisible = runtimeMonster.visible;
+				runtimeMonster.runtimeSpawned = false;
+				runtimeMonster.minXBound = baseMonster->minXBound;
+				runtimeMonster.maxXBound = baseMonster->maxXBound;
+				runtimeMonster.field70 = baseMonster->field70;
+				runtimeMonster.field74 = baseMonster->field74;
+				runtimeMonster.field78 = baseMonster->field78;
+				runtimeMonster.field7c = baseMonster->field7c;
+			}
+			_playerCombatLoadout = kDefaultPlayerCombatLoadout;
+			_playerControlPaused = false;
+		}
+	}
 }
 
 bool Script::materializeRoomState(const Common::String &entranceName, const Common::String &roomName,
@@ -1322,6 +1498,18 @@ StartupMonsterRecord *Script::findRuntimeMonster(const Common::String &monsterNa
 	return nullptr;
 }
 
+StartupTimerRecord *Script::findRuntimeTimer(const Common::String &timerName) {
+	if (timerName.empty())
+		return nullptr;
+
+	for (StartupTimerRecord &timer : _runtimeTimers) {
+		if (timer.timerName.equalsIgnoreCase(timerName))
+			return &timer;
+	}
+
+	return nullptr;
+}
+
 const StartupNpcRecord *Script::findRuntimeNpc(const Common::String &npcName) const {
 	if (npcName.empty())
 		return nullptr;
@@ -1346,6 +1534,18 @@ const StartupMonsterRecord *Script::findRuntimeMonster(const Common::String &mon
 	return nullptr;
 }
 
+const StartupTimerRecord *Script::findRuntimeTimer(const Common::String &timerName) const {
+	if (timerName.empty())
+		return nullptr;
+
+	for (const StartupTimerRecord &timer : _runtimeTimers) {
+		if (timer.timerName.equalsIgnoreCase(timerName))
+			return &timer;
+	}
+
+	return nullptr;
+}
+
 const StartupNpcRecord *Script::findRuntimeNpcRecord(const Common::String &npcName) const {
 	return findRuntimeNpc(npcName);
 }
@@ -1404,6 +1604,50 @@ bool Script::setPlayerCurrentHitPoints(int hitPoints) {
 	return changed;
 }
 
+bool Script::setPlayerCombatLoadout(int loadout) {
+	const int clampedLoadout = clampPlayerCombatLoadout(loadout);
+	const bool changed = _playerCombatLoadout != clampedLoadout;
+	_playerCombatLoadout = clampedLoadout;
+	return changed;
+}
+
+bool Script::setPlayerControlPaused(bool paused) {
+	const bool changed = _playerControlPaused != paused;
+	_playerControlPaused = paused;
+	return changed;
+}
+
+bool Script::syncRuntimeMonsterRecord(const StartupMonsterRecord &monster) {
+	StartupMonsterRecord *runtimeMonster = findRuntimeMonster(monster.monsterName);
+	if (!runtimeMonster)
+		return false;
+
+	*runtimeMonster = monster;
+	return true;
+}
+
+bool Script::syncRuntimeTimerRecord(const StartupTimerRecord &timer) {
+	StartupTimerRecord *runtimeTimer = findRuntimeTimer(timer.timerName);
+	if (!runtimeTimer)
+		return false;
+
+	*runtimeTimer = timer;
+	return true;
+}
+
+bool Script::setRuntimeTimerEnabled(const Common::String &timerName, bool enabled) {
+	StartupTimerRecord *runtimeTimer = findRuntimeTimer(timerName);
+	if (!runtimeTimer)
+		return false;
+
+	const bool changed = runtimeTimer->enabled != enabled ||
+		(enabled && runtimeTimer->currentValue != runtimeTimer->initialValue);
+	runtimeTimer->enabled = enabled;
+	if (enabled)
+		runtimeTimer->currentValue = runtimeTimer->initialValue;
+	return changed;
+}
+
 bool Script::triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcName) {
 	if (npcName.empty())
 		return false;
@@ -1419,6 +1663,8 @@ bool Script::triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcName) {
 			monsterChanged = !runtimeMonster->active || !runtimeMonster->visible;
 			runtimeMonster->active = true;
 			runtimeMonster->visible = true;
+			if (runtimeMonster->currentHitPoints <= 0)
+				runtimeMonster->currentHitPoints = runtimeMonster->initialHitPoints;
 		} else {
 			debug(1, "Harvester: unresolved monsterfy target for dialogue npc='%s' target='%s'",
 				runtimeNpc->npcName.c_str(), runtimeNpc->monsterfyTargetName.c_str());
@@ -1506,6 +1752,10 @@ bool Script::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupE
 
 		state.roomMonsters.push_back(monster);
 	}
+	for (const StartupTimerRecord &timer : _runtimeTimers) {
+		if (timer.arg1.equalsIgnoreCase(room.roomName))
+			state.roomTimers.push_back(timer);
+	}
 	for (const StartupRegionRecord &region : _runtimeRegions) {
 		if (region.roomName.equalsIgnoreCase(room.roomName) && region.startEnabled)
 			state.roomRegions.push_back(region);
@@ -1533,19 +1783,27 @@ bool Script::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupE
 	}
 	for (const StartupMonsterRecord &monster : state.roomMonsters) {
 		debugC(1, kDebugScene,
-			"Harvester: materialized room monster room='%s' monster='%s' visible=%d active=%d pos=(%d,%d,%d) facing=%d model='%s' on_death='%s'",
+			"Harvester: materialized room monster room='%s' monster='%s' visible=%d active=%d pos=(%d,%d,%d) facing=%d hp=%d/%d model='%s' on_death='%s'",
 			state.roomName.c_str(), monster.monsterName.c_str(), monster.visible, monster.active,
-			monster.posX, monster.posY, monster.posZ, monster.initialFacing,
+			monster.posX, monster.posY, monster.posZ, monster.facing,
+			monster.currentHitPoints, monster.initialHitPoints,
 			monster.modelPath.c_str(), monster.onDeathActionTag.c_str());
 	}
+	for (const StartupTimerRecord &timer : state.roomTimers) {
+		debugC(1, kDebugScene,
+			"Harvester: materialized room timer room='%s' timer='%s' current=%d initial=%d enabled=%d loop=%d global=%d",
+			state.roomName.c_str(), timer.timerName.c_str(), timer.currentValue, timer.initialValue,
+			timer.enabled, timer.looping, timer.global);
+	}
 
 	debugC(1, kDebugGeneral,
-		"Harvester: materializeRoomState room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u roomAnims=%u roomNpcs=%u roomMonsters=%u roomRegions=%u",
+		"Harvester: materializeRoomState room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u roomAnims=%u roomNpcs=%u roomMonsters=%u roomTimers=%u roomRegions=%u",
 		state.roomName.c_str(), state.entranceName.c_str(), state.playerSpawnX, state.playerSpawnY,
 		state.playerSpawnZ, state.playerFacing, state.palettePath.c_str(), state.backgroundPath.c_str(),
 		state.musicPath.c_str(), (double)state.paletteBrightness,
 		(uint)state.roomObjects.size(), (uint)state.roomAnimations.size(),
-		(uint)state.roomNpcs.size(), (uint)state.roomMonsters.size(), (uint)state.roomRegions.size());
+		(uint)state.roomNpcs.size(), (uint)state.roomMonsters.size(),
+		(uint)state.roomTimers.size(), (uint)state.roomRegions.size());
 
 	return true;
 }
@@ -1728,12 +1986,26 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			const bool visible = isTruthy(command->arg3);
 			const bool changed = runtimeMonster->active != active || runtimeMonster->visible != visible;
 			runtimeMonster->active = active;
-			runtimeMonster->visible = visible;
+			runtimeMonster->visible = active ? true : visible;
+			if (active && runtimeMonster->currentHitPoints <= 0)
+				runtimeMonster->currentHitPoints = runtimeMonster->initialHitPoints;
 			noteMutation(changed);
 			currentTag = command->arg4;
 			continue;
 		}
 
+		if (command->opcodeName.equalsIgnoreCase("SET_TIMER")) {
+			noteMutation(setRuntimeTimerEnabled(command->arg1, command->arg2.equalsIgnoreCase("ON")));
+			currentTag = command->arg4;
+			continue;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("KILL_TIMER")) {
+			noteMutation(setRuntimeTimerEnabled(command->arg1, false));
+			currentTag = command->arg4;
+			continue;
+		}
+
 		if (command->opcodeName.equalsIgnoreCase("START_DIALOG")) {
 			if (!dialogueNpcName || !dialogueContinuationTag) {
 				debug(1, "Harvester: unsupported startup command '%s' for %s '%s' without dialogue context",
@@ -1785,6 +2057,8 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 					monsterChanged = !runtimeMonster->active || !runtimeMonster->visible;
 					runtimeMonster->active = true;
 					runtimeMonster->visible = true;
+					if (runtimeMonster->currentHitPoints <= 0)
+						runtimeMonster->currentHitPoints = runtimeMonster->initialHitPoints;
 				} else {
 					debug(1, "Harvester: unresolved monsterfy target for %s '%s' npc='%s' target='%s'",
 						contextLabel, contextName.c_str(), command->arg1.c_str(),
@@ -1816,6 +2090,18 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			continue;
 		}
 
+		if (command->opcodeName.equalsIgnoreCase("PAUSE_PC")) {
+			noteMutation(setPlayerControlPaused(true));
+			currentTag = command->arg4;
+			continue;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("RESUME_PC")) {
+			noteMutation(setPlayerControlPaused(false));
+			currentTag = command->arg4;
+			continue;
+		}
+
 		if (command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
 			if (allowTransitions && nextRoomName) {
@@ -1862,6 +2148,8 @@ bool Script::hasActionableCommandChain(const Common::String &initialTag) const {
 			command->opcodeName.equalsIgnoreCase("SET_REGION") ||
 			command->opcodeName.equalsIgnoreCase("SET_NPC") ||
 			command->opcodeName.equalsIgnoreCase("SET_MONSTER") ||
+			command->opcodeName.equalsIgnoreCase("SET_TIMER") ||
+			command->opcodeName.equalsIgnoreCase("KILL_TIMER") ||
 			command->opcodeName.equalsIgnoreCase("START_DIALOG") ||
 			command->opcodeName.equalsIgnoreCase("GODEATHFLIC") ||
 			command->opcodeName.equalsIgnoreCase("KILL_NPC") ||
@@ -1869,6 +2157,8 @@ bool Script::hasActionableCommandChain(const Common::String &initialTag) const {
 			command->opcodeName.equalsIgnoreCase("HEAL_PC") ||
 			command->opcodeName.equalsIgnoreCase("ADJ_HP") ||
 			command->opcodeName.equalsIgnoreCase("KILL_PC") ||
+			command->opcodeName.equalsIgnoreCase("PAUSE_PC") ||
+			command->opcodeName.equalsIgnoreCase("RESUME_PC") ||
 			command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
 			return true;
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 34c0c9e9f10..dd22453f1e1 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -152,13 +152,50 @@ struct StartupMonsterRecord {
 	int posX = 0;
 	int posY = 0;
 	int posZ = 0;
-	int initialFacing = -1;
+	int initialHitPoints = 0;
+	int currentHitPoints = 0;
+	int damageAmount = 0;
+	int engageDistance = 0;
+	int damageType = 0;
+	int facing = -1;
 	Common::String roomName;
 	Common::String monsterName;
 	Common::String modelPath;
+	Common::String field38;
+	Common::String field3c;
+	Common::String field44;
+	Common::String field48;
+	Common::String attackSound1;
+	Common::String attackSound2;
+	Common::String attackSound3;
+	Common::String hitSound1;
+	Common::String hitSound2;
+	Common::String hitSound3;
+	Common::String footstepSoundLeft;
+	Common::String footstepSoundRight;
+	Common::String deathSound;
 	Common::String onDeathActionTag;
 	bool active = false;
 	bool visible = false;
+	bool savedVisible = false;
+	bool runtimeSpawned = false;
+	int minXBound = 0x14;
+	int maxXBound = 0x26b;
+	int field70 = 0;
+	int field74 = 0;
+	int field78 = 0;
+	int field7c = 0;
+};
+
+struct StartupTimerRecord {
+	int initialValue = 0;
+	int currentValue = 0;
+	Common::String timerName;
+	Common::String arg1;
+	Common::String arg2;
+	bool enabled = false;
+	bool looping = false;
+	bool global = false;
 };
 
 struct StartupRegionRecord {
@@ -254,6 +291,7 @@ struct StartupRoomSetupState {
 	Common::Array<StartupAnimRecord> roomAnimations;
 	Common::Array<StartupNpcRecord> roomNpcs;
 	Common::Array<StartupMonsterRecord> roomMonsters;
+	Common::Array<StartupTimerRecord> roomTimers;
 	Common::Array<StartupRegionRecord> roomRegions;
 	Common::Array<StartupAudioCommand> audioCommands;
 };
@@ -296,6 +334,7 @@ public:
 	const Common::Array<StartupAnimRecord> &getAnimations() const { return _animations; }
 	const Common::Array<StartupNpcRecord> &getNpcs() const { return _npcs; }
 	const Common::Array<StartupMonsterRecord> &getMonsters() const { return _monsters; }
+	const Common::Array<StartupTimerRecord> &getTimers() const { return _timers; }
 	const Common::Array<StartupRegionRecord> &getRegions() const { return _regions; }
 	const Common::Array<StartupFlagRecord> &getFlags() const { return _flags; }
 	const Common::Array<StartupCommandRecord> &getCommands() const { return _commands; }
@@ -354,8 +393,15 @@ public:
 		bool setRuntimeNpcState(const Common::String &npcName, bool active, bool visible);
 		bool triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcName);
 		bool isNamedNpcDeathTypeClear(const Common::String &npcName) const;
-		int getCurrentStoryDayIndex() const;
-		int getPlayerCurrentHitPoints() const { return _playerCurrentHitPoints; }
+	int getCurrentStoryDayIndex() const;
+	int getPlayerCurrentHitPoints() const { return _playerCurrentHitPoints; }
+	int getPlayerCombatLoadout() const { return _playerCombatLoadout; }
+	bool isPlayerControlPaused() const { return _playerControlPaused; }
+	bool setPlayerCombatLoadout(int loadout);
+	bool setPlayerControlPaused(bool paused);
+	bool syncRuntimeMonsterRecord(const StartupMonsterRecord &monster);
+	bool syncRuntimeTimerRecord(const StartupTimerRecord &timer);
+	bool setRuntimeTimerEnabled(const Common::String &timerName, bool enabled);
 
 private:
 	bool loadConfig(ResourceManager &resources);
@@ -374,6 +420,8 @@ private:
 	const StartupNpcRecord *findRuntimeNpc(const Common::String &npcName) const;
 	StartupMonsterRecord *findRuntimeMonster(const Common::String &monsterName);
 	const StartupMonsterRecord *findRuntimeMonster(const Common::String &monsterName) const;
+	StartupTimerRecord *findRuntimeTimer(const Common::String &timerName);
+	const StartupTimerRecord *findRuntimeTimer(const Common::String &timerName) const;
 	bool buildRuntimeRoomState(const StartupRoomRecord &room, const StartupEntranceRecord *entrance,
 		ResourceManager &resources, StartupRoomSetupState &state) const;
 	void executeCommandChain(const Common::String &initialTag, const char *contextLabel,
@@ -396,6 +444,7 @@ private:
 	Common::Array<StartupAnimRecord> _animations;
 	Common::Array<StartupNpcRecord> _npcs;
 	Common::Array<StartupMonsterRecord> _monsters;
+	Common::Array<StartupTimerRecord> _timers;
 	Common::Array<StartupRegionRecord> _regions;
 	Common::Array<StartupFlagRecord> _flags;
 	Common::Array<StartupCommandRecord> _commands;
@@ -408,7 +457,10 @@ private:
 	Common::Array<StartupRegionRecord> _runtimeRegions;
 	Common::Array<StartupNpcRecord> _runtimeNpcs;
 	Common::Array<StartupMonsterRecord> _runtimeMonsters;
+	Common::Array<StartupTimerRecord> _runtimeTimers;
 	int _playerCurrentHitPoints = 30;
+	int _playerCombatLoadout = 0;
+	bool _playerControlPaused = false;
 	int _fxVolumeLevel = 9;
 	int _musicVolumeLevel = 3;
 	int _gammaLevel = 0;


Commit: ff0aab0c67a458a43491b858d9ca198558f9e2c6
    https://github.com/scummvm/scummvm/commit/ff0aab0c67a458a43491b858d9ca198558f9e2c6
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:30+02:00

Commit Message:
HARVESTER: Widen confirmed town script coverage

Changed paths:
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index 20d751c6003..a0d1b2ba32d 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 19, 2026, widened the confirmed town-script / room-event coverage in `engines/harvester` using only opcode behavior already bounded in Ghidra and `ARCHITECTURE.md`: added `CHECK_PERC`, `EXEC_LIST`, `GOFLIC`, `SHOW_TEXT`, `PC_GOTO_XZ`, and `CHANGE_LIGHTING` to the startup-script dispatcher, parsed `EXEC_LIST` records from `HARVEST.SCR`, and threaded the new deferred action results through room interactions plus queued dialogue follow-ups instead of dropping them as generic unsupported fallthrough.
+- The room loop now applies confirmed deferred side effects in native order where applicable: `GOFLIC` plays FST cutscenes then continues with the chained action tag, `SHOW_TEXT` runs a blocking room-modal textbox using the existing inspect-text resources, `PC_GOTO_XZ` repositions the live player avatar on the current room depth line, and `CHANGE_LIGHTING` covers the confirmed `DIM`, `NORMAL`, and `NONE` modes while keeping `FADE_IN` explicit as a still-unbounded no-op.
+- Rebuilt the touched Harvester objects successfully after that script/event coverage pass: `engines/harvester/script.o`, `engines/harvester/room.o`, `engines/harvester/dialogue.o`, and `scummvm`.
 - On March 19, 2026, implemented the first actor-runtime / combat-adjacent substrate pass in `engines/harvester`: parsed and persisted richer `MONSTER` plus `TIMER` records from `HARVEST.SCR`, bumped startup-save serialization to version `2` with backward-compatible load fallback, preserved player combat HP/loadout/control-pause state in the startup runtime snapshot, spawned non-visual timer entities during room setup, and paused/resumed their countdowns across room setup fades and the true `run_main_menu` path.
 - Extended the live room loop to sync timer and monster runtime state back into the startup snapshot before save/refresh handoffs, dispatch expired room timers through the confirmed town-script action-tag path, and promote room monsters from static single-frame props into live runtime entities with facing-based idle/walk frame bands.
 - Rebuilt the touched Harvester objects successfully after the actor-runtime/timer pass: `engines/harvester/script.o`, `engines/harvester/runtime_entity.o`, `engines/harvester/flow.o`, `engines/harvester/room.o`, `engines/harvester/menu.o`, `engines/harvester/harvester.o`, and `scummvm`.
@@ -44,8 +47,8 @@
 
 ## Next Suggested Action
 
-1. Run a manual desktop validation pass against a live gameplay room using the new actor-runtime/timer substrate: confirm room setup pauses timer countdowns during the fade-in, `HELP` and the true `run_main_menu` stop countdowns while open, expired room timers fire the expected action tags once, and a monster room now shows animated live monster entities instead of static props.
-2. If that validation exposes any remaining room progression failures, widen only the specific town-script opcodes or runtime side effects revealed by those timer/monster rooms; keep unsupported commands explicit instead of guessing.
+1. Run a manual desktop validation pass against the specific live rooms/interactions that exercise the newly supported deferred action opcodes: verify at least one `GOFLIC`, `SHOW_TEXT`, `PC_GOTO_XZ`, `CHANGE_LIGHTING`, and `EXEC_LIST` room path against the desktop build so the remaining progression failures are grounded in concrete room names instead of static unsupported-opcode logs.
+2. Keep the residual unsupported list explicit during that pass. The current narrowed gaps are `CHANGE_CD` and the exact room-side effect of `CHANGE_LIGHTING FADE_IN`; if those still block progression, bound them from the native anchors before widening anything else.
 
 ## Reimplementation Priority Order
 
@@ -54,11 +57,11 @@
    Native anchors: `spawn_player_combat_avatar @ 0x54220`, `update_player_combat_avatar_state @ 0x553a0`, `teardown_player_combat_avatar @ 0x55010`, `tick_monster_entity_runtime @ 0x54140`, `spawn_timer_entity_from_record @ 0x59390`, `pause_timer_entity_countdowns @ 0x80460`, `resume_timer_entity_countdowns @ 0x804a0`.
    Concrete tasks: validate the new timer pause/resume bookkeeping and timer-to-action dispatch in a live room; confirm the true `run_main_menu` pause path now freezes room countdowns; if the live monster rooms still need more than the new facing/animation hooks, extend only the proven actor state transitions from the native anchors.
    Exit criteria: room timers survive save/load and pause correctly across menus/help, and the remaining monster/combat gaps are narrowed to explicit missing native state-machine branches instead of static-scene placeholders.
-2. Widen town-script action and room-event coverage only where native behavior is already confirmed.
+2. Validate the widened town-script action and room-event coverage, then only implement any remaining script-side gaps that are still explicitly proven.
    Files: `engines/harvester/script.cpp`, `engines/harvester/room.cpp`, `engines/harvester/dialogue.cpp`.
    Native anchors: `dispatch_room_event_actions @ 0x60ee0`, `run_town_script_interpreter @ 0x46d80`, and the confirmed town-script notes in `ARCHITECTURE.md`.
-   Concrete tasks: drive missing work from broken rooms and interactions; add only the opcodes and side effects needed to unblock verified room paths; keep unsupported commands explicit rather than guessing.
-   Exit criteria: room progression failures shrink to a small, explicit list of still-unimplemented native commands instead of broad "unsupported" fallthrough.
+   Concrete tasks: exercise the now-supported `CHECK_PERC`, `EXEC_LIST`, `GOFLIC`, `SHOW_TEXT`, `PC_GOTO_XZ`, and `CHANGE_LIGHTING` paths in live rooms; only if validation still fails, add the exact missing command or side effect proven by that room.
+   Exit criteria: the remaining room progression failures reduce to a short, named list of still-unimplemented native commands or side effects, with `CHANGE_CD` / `FADE_IN` either confirmed as blockers or explicitly ruled out.
 3. Tighten inventory and item-use flow against that actor runtime.
    Files: `engines/harvester/inventory.cpp`, `engines/harvester/room.cpp`, `engines/harvester/script.cpp`.
    Native anchors: `run_inventory_screen @ 0x7df10`, `add_object_to_inventory @ 0x7c8b0`, `sync_player_combat_weapon_resource_icons @ 0x792c0`, and the confirmed inventory carry/use notes in `ARCHITECTURE.md`.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 8a5b350e745..a52a2ea1497 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -744,9 +744,14 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 	auto queueDialogueInteractionIfNeeded = [&](const StartupInteractionResult &interaction) {
 		if (interaction.abortRemainingCommandChain || interaction.mutatedRuntimeState ||
 				!interaction.musicPath.empty() || !interaction.nextRoomName.empty() ||
+				!interaction.cutscenePath.empty() ||
 				!interaction.deathFlicPath.empty() || interaction.requestMainMenu ||
 				!interaction.dialogueNpcName.empty() ||
 				!interaction.dialogueContinuationTag.empty() ||
+				!interaction.continuationTag.empty() ||
+				!interaction.modalText.value.empty() ||
+				interaction.lightingCommand != kStartupLightingCommandNone ||
+				interaction.requestPlayerGotoXZ ||
 				!interaction.audioCommands.empty()) {
 			startupFlow.queueDialogueInteraction(interaction);
 		}
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 8ed93d32e21..f6ef23ec6cd 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -33,6 +33,7 @@
 #include "harvester/detection.h"
 #include "harvester/fst_player.h"
 #include "harvester/harvester.h"
+#include "harvester/palette_utils.h"
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
 #include "harvester/art.h"
@@ -186,6 +187,33 @@ static void drawRoomPrompt(Graphics::Screen &screen, const Graphics::Font &font,
 	font.drawString(&screen, promptText, 0, 462, 640, 0xce, Graphics::kTextAlignCenter);
 }
 
+static void setScaledRoomPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
+	byte scaledPalette[256 * 3];
+	const float gammaBrightness = g_engine ? g_engine->getStartupGammaBrightnessScale() : 1.0f;
+	buildHarvesterDisplayPalette(palette, brightness * gammaBrightness, scaledPalette);
+	screen.setPalette(scaledPalette);
+}
+
+static float clampRoomDepthForEvent(const StartupRoomSetupState &state, float z) {
+	return CLIP<float>(z,
+		(float)MIN(state.roomMinZ, state.roomMaxZ),
+		(float)MAX(state.roomMinZ, state.roomMaxZ));
+}
+
+static int mapRoomDepthToScreenYForEvent(const StartupRoomSetupState &state, float z, int fallbackY) {
+	if (state.roomMaxZScreenY < 0 || state.roomMinZScreenY < state.roomMaxZScreenY)
+		return fallbackY;
+	if (state.roomMaxZ == state.roomMinZ)
+		return CLIP<int>(fallbackY, state.roomMaxZScreenY, state.roomMinZScreenY);
+
+	const float clampedZ = clampRoomDepthForEvent(state, z);
+	const float offset = ((float)state.roomMaxZ - clampedZ) *
+		(float)(state.roomMinZScreenY - state.roomMaxZScreenY) /
+		(float)(state.roomMaxZ - state.roomMinZ);
+	return CLIP<int>(state.roomMaxZScreenY + (int)(offset >= 0.0f ? offset + 0.5f : offset - 0.5f),
+		state.roomMaxZScreenY, state.roomMinZScreenY);
+}
+
 static MonsterAnimationRange resolveRoomMonsterAnimationRange(int facing) {
 	switch (facing) {
 	case 0:
@@ -478,165 +506,322 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
 		return captureScreenBackdrop(*activeScreen, dialogueBackdrop);
 	};
-	auto runRoomExitCommands = [&]() {
-		Common::Array<StartupAudioCommand> exitAudioCommands;
-		if (!_engine.getStartupScript()->executeRoomExitCommands(scene.state.roomName, exitAudioCommands))
-			return false;
-		startupFlow.executeStartupAudioCommands(exitAudioCommands);
-		return true;
-	};
-	struct InteractionProcessor {
-		HarvesterEngine &engine;
-		Flow &startupFlow;
-		StartupRoomSceneResources &scene;
-		StartupRoomPlayerState &playerState;
-		Common::String &pendingRegionName;
-		Common::String &pendingRoomChange;
-		decltype(refreshCurrentScene) &refreshCurrentSceneFn;
-		decltype(captureDialogueBackdrop) &captureDialogueBackdropFn;
-		decltype(runRoomExitCommands) &runRoomExitCommandsFn;
-		decltype(resetIdleState) &resetIdleStateFn;
-
-		Common::Error handleInteractionResult(const StartupInteractionResult &interaction,
-				bool &didTransition) {
-			didTransition = false;
+		auto runRoomExitCommands = [&]() {
+			Common::Array<StartupAudioCommand> exitAudioCommands;
+			if (!_engine.getStartupScript()->executeRoomExitCommands(scene.state.roomName, exitAudioCommands))
+				return false;
+			startupFlow.executeStartupAudioCommands(exitAudioCommands);
+			return true;
+		};
+		auto applyLightingCommand = [&](StartupLightingCommand lightingCommand) -> Common::Error {
+			Graphics::Screen *activeScreen = getActiveScreen();
+			if (!activeScreen)
+				return Common::kNoError;
+
+			switch (lightingCommand) {
+			case kStartupLightingCommandNone:
+				return Common::kNoError;
+			case kStartupLightingCommandDim:
+				drawRoomScene(_engine, *activeScreen, scene, 0.6f);
+				setScaledRoomPalette(*activeScreen, scene.palette, 0.6f);
+				activeScreen->makeAllDirty();
+				activeScreen->update();
+				return Common::kNoError;
+			case kStartupLightingCommandNormal:
+				drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
+				setScaledRoomPalette(*activeScreen, scene.palette, scene.targetPaletteBrightness);
+				activeScreen->makeAllDirty();
+				activeScreen->update();
+				return Common::kNoError;
+			case kStartupLightingCommandBlack: {
+				byte blackPalette[256 * 3] = { 0 };
+				activeScreen->fillRect(activeScreen->getBounds(), 0);
+				activeScreen->setPalette(blackPalette);
+				activeScreen->makeAllDirty();
+				activeScreen->update();
+				return Common::kNoError;
+			}
+			case kStartupLightingCommandFadeIn:
+				debugC(1, kDebugScene,
+					"Harvester: CHANGE_LIGHTING FADE_IN has no direct room-side equivalent yet; preserving control flow");
+				return Common::kNoError;
+			}
+
+			return Common::kNoError;
+		};
+		auto applyPlayerGotoXZ = [&](int x, int z) {
 			playerState.hasMoveTarget = false;
 			playerState.turnActive = false;
 			playerState.turnTargetFacing = -1;
-			pendingRegionName.clear();
+			playerState.centerX = x;
+			playerState.z = clampRoomDepthForEvent(scene.state, (float)z);
+			playerState.bottomY = mapRoomDepthToScreenYForEvent(scene.state, playerState.z, playerState.bottomY);
+			if (playerState.entity) {
+				const int facing = playerState.facing >= 0 ? playerState.facing : scene.state.playerFacing;
+				if (facing >= 0)
+					(void)setPlayerIdleAnimation(playerState, facing);
+				(void)applyRoomActorPlacement(scene.state, *playerState.entity,
+					playerState.centerX, playerState.bottomY, playerState.z);
+			}
+			resetIdleState();
+			captureCurrentSaveState();
+		};
+		auto runModalShowText = [&](const StartupResolvedText &modalText) -> Common::Error {
+			Graphics::Screen *activeScreen = getActiveScreen();
+			if (!activeScreen || !art || !bodyFont)
+				return Common::kNoError;
+			if (!resolveInspectTextboxBitmap(*art, modalText)) {
+				debug(1, "Harvester: unsupported SHOW_TEXT textbox '%s' text='%s'",
+					modalText.boxName.c_str(), modalText.value.c_str());
+				return Common::kNoError;
+			}
 
-			if (interaction.requestMainMenu) {
-				engine.stopStartupMusic();
-				engine.stopStartupSound();
-				if (!interaction.deathFlicPath.empty()) {
-					FstPlayer fstPlayer(engine);
-					if (!fstPlayer.play(interaction.deathFlicPath))
-						return Common::kReadingFailed;
+			IndexedBitmap backdrop;
+			if (!captureDialogueBackdrop(backdrop))
+				return Common::kReadingFailed;
+
+			Graphics::FrameLimiter limiter(g_system, 60);
+			bool initialInputReleased = false;
+			bool dismissPressed = false;
+			uint frameCount = 0;
+
+			while (!_engine.shouldQuit()) {
+				Common::Event event;
+				Common::Error result = Common::kNoError;
+				while (g_system->getEventManager()->pollEvent(event)) {
+					if (startupFlow.handleSystemEvent(event, result))
+						return result;
+
+					if (!initialInputReleased) {
+						if (event.type == Common::EVENT_LBUTTONUP ||
+								event.type == Common::EVENT_RBUTTONUP ||
+								event.type == Common::EVENT_KEYUP) {
+							initialInputReleased = true;
+						}
+						continue;
+					}
+
+					if (!dismissPressed) {
+						if (event.type == Common::EVENT_LBUTTONDOWN ||
+								event.type == Common::EVENT_RBUTTONDOWN) {
+							dismissPressed = true;
+						} else if (event.type == Common::EVENT_KEYDOWN) {
+							return Common::kNoError;
+						}
+						continue;
+					}
+
+					if (event.type == Common::EVENT_LBUTTONUP ||
+							event.type == Common::EVENT_RBUTTONUP) {
+						return Common::kNoError;
+					}
 				}
 
-				startupFlow.requestMainMenuReturn();
-				return Common::kNoError;
-			}
+				if (!initialInputReleased && frameCount++ > 0)
+					initialInputReleased = true;
+
+				blitBitmap(*activeScreen, backdrop, 0, 0);
+				setScaledRoomPalette(*activeScreen, scene.palette, scene.targetPaletteBrightness);
+				drawRoomInspectText(*activeScreen, *art, *bodyFont, modalText);
+				activeScreen->makeAllDirty();
+				activeScreen->update();
 
-			Common::String restoreMusicPath = engine.getStartupMusicPath();
-			if (!interaction.musicPath.empty()) {
-				(void)engine.playStartupMusic(interaction.musicPath);
-				restoreMusicPath = engine.getStartupMusicPath();
+				limiter.delayBeforeSwap();
+				limiter.startFrame();
 			}
-			startupFlow.executeStartupAudioCommands(interaction.audioCommands);
-
-			StartupRoomTransitionKind roomTransition = interaction.roomTransition;
-			if (roomTransition == kStartupRoomTransitionNone && !interaction.nextRoomName.empty())
-				roomTransition = kStartupRoomTransitionCloseup;
-
-			if (!interaction.nextRoomName.empty() &&
-					roomTransition == kStartupRoomTransitionChangeRoom) {
-				Common::String resolvedTransitionTarget = interaction.nextRoomName;
-				Common::Error resolveError = startupFlow.resolveRoomTransitionTarget(
-					interaction.nextRoomName, resolvedTransitionTarget);
-				if (resolveError.getCode() != Common::kNoError)
-					return resolveError;
-				if (resolvedTransitionTarget.empty())
-					return Common::kNoError;
 
-				if (!runRoomExitCommandsFn())
-					return Common::kReadingFailed;
+			return Common::kNoError;
+		};
+		struct InteractionProcessor {
+			HarvesterEngine &engine;
+			Flow &startupFlow;
+			StartupRoomSceneResources &scene;
+			StartupRoomPlayerState &playerState;
+			Common::String &pendingRegionName;
+			Common::String &pendingRoomChange;
+			decltype(refreshCurrentScene) &refreshCurrentSceneFn;
+			decltype(captureDialogueBackdrop) &captureDialogueBackdropFn;
+			decltype(runRoomExitCommands) &runRoomExitCommandsFn;
+			decltype(applyLightingCommand) &applyLightingCommandFn;
+			decltype(applyPlayerGotoXZ) &applyPlayerGotoXZFn;
+			decltype(runModalShowText) &runModalShowTextFn;
+			decltype(resetIdleState) &resetIdleStateFn;
+
+			Common::Error handleInteractionResult(const StartupInteractionResult &interaction,
+					bool &didTransition) {
+				didTransition = false;
+				playerState.hasMoveTarget = false;
+				playerState.turnActive = false;
+				playerState.turnTargetFacing = -1;
+				pendingRegionName.clear();
 
-				// Native CHANGE_ROOM queues a room handoff for the live loop instead of nesting.
-				pendingRoomChange = resolvedTransitionTarget;
-				didTransition = true;
-			} else if (!interaction.nextRoomName.empty()) {
-				if (!runRoomExitCommandsFn())
-					return Common::kReadingFailed;
+				if (interaction.requestMainMenu) {
+					engine.stopStartupMusic();
+					engine.stopStartupSound();
+					if (!interaction.deathFlicPath.empty()) {
+						FstPlayer fstPlayer(engine);
+						if (!fstPlayer.play(interaction.deathFlicPath))
+							return Common::kReadingFailed;
+					}
 
-				Common::Error roomError = startupFlow.runRoomLoop(interaction.nextRoomName);
-				if (startupFlow.hasPendingMainMenuReturn())
+					startupFlow.requestMainMenuReturn();
 					return Common::kNoError;
-				if (roomError.getCode() != Common::kReadingFailed &&
-					roomError.getCode() != Common::kNoError) {
-					return roomError;
 				}
 
-				if (!refreshCurrentSceneFn(true))
-					return Common::kReadingFailed;
+				Common::String restoreMusicPath = engine.getStartupMusicPath();
+				if (!interaction.musicPath.empty()) {
+					(void)engine.playStartupMusic(interaction.musicPath);
+					restoreMusicPath = engine.getStartupMusicPath();
+				}
+				startupFlow.executeStartupAudioCommands(interaction.audioCommands);
+
+				StartupRoomTransitionKind roomTransition = interaction.roomTransition;
+				if (roomTransition == kStartupRoomTransitionNone && !interaction.nextRoomName.empty())
+					roomTransition = kStartupRoomTransitionCloseup;
+
+				if (!interaction.nextRoomName.empty() &&
+						roomTransition == kStartupRoomTransitionChangeRoom) {
+					Common::String resolvedTransitionTarget = interaction.nextRoomName;
+					Common::Error resolveError = startupFlow.resolveRoomTransitionTarget(
+						interaction.nextRoomName, resolvedTransitionTarget);
+					if (resolveError.getCode() != Common::kNoError)
+						return resolveError;
+					if (resolvedTransitionTarget.empty())
+						return Common::kNoError;
 
-				startupFlow.executeStartupAudioCommands(scene.state.audioCommands);
-				if (!restoreMusicPath.empty())
-					(void)engine.playStartupMusic(restoreMusicPath);
-				else
-					engine.stopStartupMusic();
-				didTransition = true;
-			} else if (interaction.mutatedRuntimeState) {
-				if (!refreshCurrentSceneFn(true))
-					return Common::kReadingFailed;
-			}
+					if (!runRoomExitCommandsFn())
+						return Common::kReadingFailed;
 
-			if (didTransition)
-				return Common::kNoError;
+					// Native CHANGE_ROOM queues a room handoff for the live loop instead of nesting.
+					pendingRoomChange = resolvedTransitionTarget;
+					didTransition = true;
+				} else if (!interaction.nextRoomName.empty()) {
+					if (!runRoomExitCommandsFn())
+						return Common::kReadingFailed;
 
-			if (!interaction.dialogueNpcName.empty()) {
-				Common::Error dialogueError = runScriptedDialogue(
-					interaction.dialogueNpcName, Common::String(), interaction.dialogueContinuationTag,
-					didTransition);
-				if (dialogueError.getCode() != Common::kNoError)
-					return dialogueError;
-			}
+					Common::Error roomError = startupFlow.runRoomLoop(interaction.nextRoomName);
+					if (startupFlow.hasPendingMainMenuReturn())
+						return Common::kNoError;
+					if (roomError.getCode() != Common::kReadingFailed &&
+						roomError.getCode() != Common::kNoError) {
+						return roomError;
+					}
 
-			return Common::kNoError;
-		}
+					if (!refreshCurrentSceneFn(true))
+						return Common::kReadingFailed;
 
-		Common::Error runScriptedDialogue(const Common::String &npcName, const Common::String &usedItemName,
-				const Common::String &continuationTag, bool &didTransition) {
-			didTransition = false;
-
-			const StartupNpcRecord *dialogueNpc = engine.getStartupScript()->findRuntimeNpcRecord(npcName);
-			if (dialogueNpc) {
-				IndexedBitmap dialogueBackdrop;
-				if (!captureDialogueBackdropFn(dialogueBackdrop))
-					return Common::kReadingFailed;
-
-				Common::Error dialogueError = startupFlow.runRoomNpcDialogue(
-					dialogueBackdrop, scene.palette, scene.targetPaletteBrightness, *dialogueNpc,
-					usedItemName);
-				if (dialogueError.getCode() != Common::kNoError)
-					return dialogueError;
-			} else {
-				warning("Harvester: unresolved startup dialogue npc '%s' while processing room dialogue",
-					npcName.c_str());
-			}
+					startupFlow.executeStartupAudioCommands(scene.state.audioCommands);
+					if (!restoreMusicPath.empty())
+						(void)engine.playStartupMusic(restoreMusicPath);
+					else
+						engine.stopStartupMusic();
+					didTransition = true;
+				} else if (interaction.mutatedRuntimeState) {
+					if (!refreshCurrentSceneFn(true))
+						return Common::kReadingFailed;
+				}
 
-			StartupInteractionResult dialogueInteraction;
-			bool abortRemainingCommandChain = false;
-			if (startupFlow.takeQueuedDialogueInteraction(dialogueInteraction)) {
-				abortRemainingCommandChain = dialogueInteraction.abortRemainingCommandChain;
-				Common::Error interactionError = handleInteractionResult(dialogueInteraction, didTransition);
-				if (interactionError.getCode() != Common::kNoError)
-					return interactionError;
-				if (startupFlow.hasPendingMainMenuReturn())
+				if (didTransition)
+					return Common::kNoError;
+
+				if (interaction.requestPlayerGotoXZ)
+					applyPlayerGotoXZFn(interaction.playerGotoX, interaction.playerGotoZ);
+
+				if (interaction.lightingCommand != kStartupLightingCommandNone) {
+					Common::Error lightingError = applyLightingCommandFn(interaction.lightingCommand);
+					if (lightingError.getCode() != Common::kNoError)
+						return lightingError;
+				}
+
+				if (!interaction.cutscenePath.empty()) {
+					FstPlayer fstPlayer(engine);
+					if (!fstPlayer.play(interaction.cutscenePath))
+						return Common::kReadingFailed;
+				}
+
+				if (!interaction.modalText.value.empty()) {
+					Common::Error modalError = runModalShowTextFn(interaction.modalText);
+					if (modalError.getCode() != Common::kNoError)
+						return modalError;
+				}
+
+				if (!interaction.dialogueNpcName.empty()) {
+					Common::Error dialogueError = runScriptedDialogue(
+						interaction.dialogueNpcName, Common::String(), interaction.dialogueContinuationTag,
+						didTransition);
+					if (dialogueError.getCode() != Common::kNoError)
+						return dialogueError;
+				}
+
+				if (didTransition)
 					return Common::kNoError;
+
+				if (!interaction.continuationTag.empty()) {
+					StartupInteractionResult continuationInteraction;
+					if (engine.getStartupScript()->executeActionTag(
+							interaction.continuationTag, continuationInteraction)) {
+						Common::Error interactionError =
+							handleInteractionResult(continuationInteraction, didTransition);
+						if (interactionError.getCode() != Common::kNoError)
+							return interactionError;
+					}
+				}
+
+				return Common::kNoError;
 			}
-			if (!didTransition && !abortRemainingCommandChain && !continuationTag.empty()) {
-				StartupInteractionResult continuationInteraction;
-				if (engine.getStartupScript()->executeActionTag(continuationTag, continuationInteraction)) {
-					Common::Error interactionError =
-						handleInteractionResult(continuationInteraction, didTransition);
+
+			Common::Error runScriptedDialogue(const Common::String &npcName, const Common::String &usedItemName,
+					const Common::String &continuationTag, bool &didTransition) {
+				didTransition = false;
+
+				const StartupNpcRecord *dialogueNpc = engine.getStartupScript()->findRuntimeNpcRecord(npcName);
+				if (dialogueNpc) {
+					IndexedBitmap dialogueBackdrop;
+					if (!captureDialogueBackdropFn(dialogueBackdrop))
+						return Common::kReadingFailed;
+
+					Common::Error dialogueError = startupFlow.runRoomNpcDialogue(
+						dialogueBackdrop, scene.palette, scene.targetPaletteBrightness, *dialogueNpc,
+						usedItemName);
+					if (dialogueError.getCode() != Common::kNoError)
+						return dialogueError;
+				} else {
+					warning("Harvester: unresolved startup dialogue npc '%s' while processing room dialogue",
+						npcName.c_str());
+				}
+
+				StartupInteractionResult dialogueInteraction;
+				bool abortRemainingCommandChain = false;
+				if (startupFlow.takeQueuedDialogueInteraction(dialogueInteraction)) {
+					abortRemainingCommandChain = dialogueInteraction.abortRemainingCommandChain;
+					Common::Error interactionError = handleInteractionResult(dialogueInteraction, didTransition);
 					if (interactionError.getCode() != Common::kNoError)
 						return interactionError;
 					if (startupFlow.hasPendingMainMenuReturn())
 						return Common::kNoError;
 				}
-			}
+				if (!didTransition && !abortRemainingCommandChain && !continuationTag.empty()) {
+					StartupInteractionResult continuationInteraction;
+					if (engine.getStartupScript()->executeActionTag(continuationTag, continuationInteraction)) {
+						Common::Error interactionError =
+							handleInteractionResult(continuationInteraction, didTransition);
+						if (interactionError.getCode() != Common::kNoError)
+							return interactionError;
+						if (startupFlow.hasPendingMainMenuReturn())
+							return Common::kNoError;
+					}
+				}
 
-			startupFlow.resetCursorAnimationSequence();
-			resetIdleStateFn();
-			return Common::kNoError;
-		}
-	};
-	InteractionProcessor interactionProcessor = {
-		_engine, startupFlow, scene, playerState, pendingRegionName, pendingRoomChange,
-		refreshCurrentScene,
-		captureDialogueBackdrop, runRoomExitCommands, resetIdleState
-	};
+				startupFlow.resetCursorAnimationSequence();
+				resetIdleStateFn();
+				return Common::kNoError;
+			}
+		};
+		InteractionProcessor interactionProcessor = {
+			_engine, startupFlow, scene, playerState, pendingRegionName, pendingRoomChange,
+			refreshCurrentScene, captureDialogueBackdrop, runRoomExitCommands,
+			applyLightingCommand, applyPlayerGotoXZ, runModalShowText, resetIdleState
+		};
 	auto moveRoomItemDirectlyToInventory = [&](const StartupObjectRecord &object) -> Common::Error {
 		Script *startupScript = _engine.getStartupScript();
 		if (!startupScript)
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index aa4a762ce0f..8af25de30ad 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -31,6 +31,7 @@
 #include "common/memstream.h"
 #include "common/serializer.h"
 #include "harvester/detection.h"
+#include "harvester/harvester.h"
 #include "harvester/resources.h"
 
 namespace Harvester {
@@ -420,6 +421,7 @@ bool Script::load(ResourceManager &resources) {
 	_regions.clear();
 	_flags.clear();
 	_commands.clear();
+	_execLists.clear();
 	_texts.clear();
 	_heads.clear();
 	_useItems.clear();
@@ -607,6 +609,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 	_regions.clear();
 	_flags.clear();
 	_commands.clear();
+	_execLists.clear();
 	_texts.clear();
 	_heads.clear();
 	_useItems.clear();
@@ -627,7 +630,8 @@ void Script::parseTownRecords(ResourceManager &resources) {
 				tokens[i] == "MAP_LOCATION" || tokens[i] == "ROOM" || tokens[i] == "OBJECT" ||
 				tokens[i] == "NPC" || tokens[i] == "MONSTER" || tokens[i] == "REGION" ||
 				tokens[i] == "HEAD" || tokens[i] == "FLAG" || tokens[i] == "COMMAND" ||
-				tokens[i] == "TEXT" || tokens[i] == "TIMER" || tokens[i] == "USEITEM") {
+				tokens[i] == "EXEC_LIST" || tokens[i] == "TEXT" || tokens[i] == "TIMER" ||
+				tokens[i] == "USEITEM") {
 				tagIndex = i;
 				break;
 			}
@@ -674,6 +678,22 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			return;
 		}
 
+		if (tag == "EXEC_LIST") {
+			if (tokens.size() < tagIndex + 2)
+				return;
+
+			StartupExecListRecord execList;
+			execList.listName = tokens[tagIndex + 1];
+			for (uint i = tagIndex + 2; i < tokens.size(); ++i) {
+				if (tokens[i].empty())
+					break;
+				execList.entries.push_back(tokens[i]);
+			}
+			if (!execList.listName.empty())
+				_execLists.push_back(execList);
+			return;
+		}
+
 		if (tag == "TEXT") {
 			if (tokens.size() < tagIndex + 4)
 				return;
@@ -1011,11 +1031,12 @@ void Script::parseTownRecords(ResourceManager &resources) {
 
 	parseLine(line);
 
-	debug(1, "Harvester: parsed %u entrances, %u map entrances, %u map locations, %u rooms, %u objects, %u anims, %u npcs, %u regions, %u flags, %u commands, %u texts, %u heads, %u useitems from '%s'",
+	debug(1, "Harvester: parsed %u entrances, %u map entrances, %u map locations, %u rooms, %u objects, %u anims, %u npcs, %u regions, %u flags, %u commands, %u exec_lists, %u texts, %u heads, %u useitems from '%s'",
 		(uint)_entrances.size(), (uint)_mapEntrances.size(), (uint)_mapLocations.size(),
 		(uint)_rooms.size(), (uint)_objects.size(), (uint)_animations.size(),
 		(uint)_npcs.size(), (uint)_regions.size(),
-		(uint)_flags.size(), (uint)_commands.size(), (uint)_texts.size(), (uint)_heads.size(),
+		(uint)_flags.size(), (uint)_commands.size(), (uint)_execLists.size(),
+		(uint)_texts.size(), (uint)_heads.size(),
 		(uint)_useItems.size(), _path.c_str());
 }
 
@@ -1037,6 +1058,7 @@ bool Script::resolveRoomSetupState(const Common::String &entranceName, StartupRo
 	bool mutatedRuntimeState = false;
 	executeCommandChain(room->onEnterCommand, "room setup command", room->roomName, false,
 		&musicPath, &audioCommands, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
+		nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
 		&mutatedRuntimeState);
 
 	if (!buildRuntimeRoomState(*room, entrance, resources, state))
@@ -1197,6 +1219,7 @@ bool Script::executeRoomExitCommands(const Common::String &roomName,
 	bool mutatedRuntimeState = false;
 	executeCommandChain(room->onExitCommand, "room exit command", room->roomName, false,
 		nullptr, &audioCommands, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
+		nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
 		&mutatedRuntimeState);
 	return true;
 }
@@ -1218,11 +1241,16 @@ bool Script::resolveObjectInteraction(const StartupObjectRecord &object, Startup
 
 	executeCommandChain(object.actionTag, "interaction command", object.objectName, true,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
-		&result.deathFlicPath, &result.requestMainMenu,
-		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.mutatedRuntimeState);
+		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu,
+		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
+		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
+		&result.playerGotoX, &result.playerGotoZ, &result.mutatedRuntimeState);
 
-	return !result.nextRoomName.empty() || !result.deathFlicPath.empty() || result.requestMainMenu ||
+	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
+		!result.deathFlicPath.empty() || result.requestMainMenu ||
 		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
+		!result.continuationTag.empty() || !result.modalText.value.empty() ||
+		result.lightingCommand != kStartupLightingCommandNone || result.requestPlayerGotoXZ ||
 		result.mutatedRuntimeState || hasActionableCommandChain(object.actionTag);
 }
 
@@ -1233,10 +1261,15 @@ bool Script::resolveRegionInteraction(const StartupRegionRecord &region, Startup
 
 	executeCommandChain(region.actionTag, "region command", region.regionName, true,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
-		&result.deathFlicPath, &result.requestMainMenu,
-		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.mutatedRuntimeState);
-	return !result.nextRoomName.empty() || !result.deathFlicPath.empty() || result.requestMainMenu ||
+		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu,
+		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
+		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
+		&result.playerGotoX, &result.playerGotoZ, &result.mutatedRuntimeState);
+	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
+		!result.deathFlicPath.empty() || result.requestMainMenu ||
 		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
+		!result.continuationTag.empty() || !result.modalText.value.empty() ||
+		result.lightingCommand != kStartupLightingCommandNone || result.requestPlayerGotoXZ ||
 		result.mutatedRuntimeState || hasActionableCommandChain(region.actionTag);
 }
 
@@ -1251,8 +1284,10 @@ bool Script::resolveUseItemInteraction(const Common::String &itemName, const Sta
 	executeCommandChain(useItem->actionTag, "useitem command",
 		Common::String::format("%s -> %s", itemName.c_str(), target.objectName.c_str()), true,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
-		&result.deathFlicPath, &result.requestMainMenu,
-		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.mutatedRuntimeState);
+		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu,
+		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
+		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
+		&result.playerGotoX, &result.playerGotoZ, &result.mutatedRuntimeState);
 	return true;
 }
 
@@ -1264,11 +1299,16 @@ bool Script::executeActionTag(const Common::String &tag, StartupInteractionResul
 
 	executeCommandChain(tag, "action tag", tag, allowTransitions,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
-		&result.deathFlicPath, &result.requestMainMenu,
-		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.mutatedRuntimeState);
+		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu,
+		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
+		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
+		&result.playerGotoX, &result.playerGotoZ, &result.mutatedRuntimeState);
 
-	return !result.nextRoomName.empty() || !result.deathFlicPath.empty() || result.requestMainMenu ||
+	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
+		!result.deathFlicPath.empty() || result.requestMainMenu ||
 		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
+		!result.continuationTag.empty() || !result.modalText.value.empty() ||
+		result.lightingCommand != kStartupLightingCommandNone || result.requestPlayerGotoXZ ||
 		result.mutatedRuntimeState || hasActionableCommandChain(tag);
 }
 
@@ -1401,6 +1441,18 @@ const StartupCommandRecord *Script::findCommandRecord(const Common::String &tag)
 	return nullptr;
 }
 
+const StartupExecListRecord *Script::findExecListRecord(const Common::String &name) const {
+	if (name.empty())
+		return nullptr;
+
+	for (const StartupExecListRecord &execList : _execLists) {
+		if (execList.listName.equalsIgnoreCase(name))
+			return &execList;
+	}
+
+	return nullptr;
+}
+
 const StartupFlagRecord *Script::findRuntimeFlag(const Common::String &flagName) const {
 	if (flagName.empty())
 		return nullptr;
@@ -1812,8 +1864,11 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		const Common::String &contextName, bool allowTransitions, Common::String *musicPath,
 		Common::Array<StartupAudioCommand> *audioCommands, Common::String *nextRoomName,
 		StartupRoomTransitionKind *roomTransition,
-		Common::String *deathFlicPath, bool *requestMainMenu, Common::String *dialogueNpcName,
-		Common::String *dialogueContinuationTag,
+		Common::String *cutscenePath, Common::String *deathFlicPath, bool *requestMainMenu,
+		Common::String *dialogueNpcName, Common::String *dialogueContinuationTag,
+		Common::String *continuationTag, StartupResolvedText *modalText,
+		StartupLightingCommand *lightingCommand, bool *requestPlayerGotoXZ,
+		int *playerGotoX, int *playerGotoZ,
 		bool *mutatedRuntimeState) {
 	auto isTruthy = [](const Common::String &value) {
 		return value.equalsIgnoreCase("T") || value.equalsIgnoreCase("ON") || value.equalsIgnoreCase("TRUE");
@@ -1822,6 +1877,17 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		if (changed && mutatedRuntimeState)
 			*mutatedRuntimeState = true;
 	};
+	auto hasDeferredInteractionOutputs = [&]() {
+		return (nextRoomName && !nextRoomName->empty()) ||
+			(cutscenePath && !cutscenePath->empty()) ||
+			(deathFlicPath && !deathFlicPath->empty()) ||
+			(requestMainMenu && *requestMainMenu) ||
+			(dialogueNpcName && !dialogueNpcName->empty()) ||
+			(continuationTag && !continuationTag->empty()) ||
+			(modalText && !modalText->value.empty()) ||
+			(lightingCommand && *lightingCommand != kStartupLightingCommandNone) ||
+			(requestPlayerGotoXZ && *requestPlayerGotoXZ);
+	};
 
 	Common::String currentTag = initialTag;
 	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
@@ -1846,6 +1912,13 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			continue;
 		}
 
+		if (command->opcodeName.equalsIgnoreCase("CHECK_PERC")) {
+			const int threshold = CLIP<int>(atoi(command->arg1.c_str()), 0, 100);
+			const int roll = g_engine ? (int)g_engine->getRandomNumber(99) : 0;
+			currentTag = roll < threshold ? command->arg2 : command->arg3;
+			continue;
+		}
+
 		if (command->opcodeName.equalsIgnoreCase("SET_FLAG")) {
 			const bool flagValue = isTruthy(command->arg2);
 			StartupFlagRecord *flag = findRuntimeFlag(command->arg1);
@@ -2006,6 +2079,28 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			continue;
 		}
 
+		if (command->opcodeName.equalsIgnoreCase("EXEC_LIST")) {
+			const StartupExecListRecord *execList = findExecListRecord(command->arg1);
+			if (!execList) {
+				debug(1, "Harvester: unresolved exec list for %s '%s' list='%s'",
+					contextLabel, contextName.c_str(), command->arg1.c_str());
+				currentTag = command->arg4;
+				continue;
+			}
+
+			for (const Common::String &entry : execList->entries) {
+				executeCommandChain(entry, "exec list entry", execList->listName, allowTransitions,
+					musicPath, audioCommands, nextRoomName, roomTransition, cutscenePath,
+					deathFlicPath, requestMainMenu, dialogueNpcName, dialogueContinuationTag,
+					continuationTag, modalText, lightingCommand, requestPlayerGotoXZ,
+					playerGotoX, playerGotoZ, mutatedRuntimeState);
+				if (hasDeferredInteractionOutputs())
+					return;
+			}
+			currentTag = command->arg4;
+			continue;
+		}
+
 		if (command->opcodeName.equalsIgnoreCase("START_DIALOG")) {
 			if (!dialogueNpcName || !dialogueContinuationTag) {
 				debug(1, "Harvester: unsupported startup command '%s' for %s '%s' without dialogue context",
@@ -2022,6 +2117,20 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			return;
 		}
 
+		if (command->opcodeName.equalsIgnoreCase("GOFLIC")) {
+			if (!cutscenePath) {
+				debug(1, "Harvester: deferred startup command '%s' for %s '%s' has no cutscene context",
+					command->opcodeName.c_str(), contextLabel, contextName.c_str());
+				currentTag = command->arg4;
+				continue;
+			}
+
+			*cutscenePath = command->arg1;
+			if (continuationTag)
+				*continuationTag = command->arg4;
+			return;
+		}
+
 		if (command->opcodeName.equalsIgnoreCase("GODEATHFLIC")) {
 			if (!deathFlicPath || !requestMainMenu) {
 				debug(1, "Harvester: unsupported startup command '%s' for %s '%s' without menu-exit context",
@@ -2076,6 +2185,25 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			continue;
 		}
 
+		if (command->opcodeName.equalsIgnoreCase("SHOW_TEXT")) {
+			if (!modalText) {
+				debug(1, "Harvester: deferred startup command '%s' for %s '%s' has no modal-text context",
+					command->opcodeName.c_str(), contextLabel, contextName.c_str());
+				currentTag = command->arg4;
+				continue;
+			}
+
+			if (!resolveTextRecord(command->arg1, *modalText)) {
+				debug(1, "Harvester: unresolved SHOW_TEXT record for %s '%s' key='%s'",
+					contextLabel, contextName.c_str(), command->arg1.c_str());
+				currentTag = command->arg4;
+				continue;
+			}
+			if (continuationTag)
+				*continuationTag = command->arg4;
+			return;
+		}
+
 		if (command->opcodeName.equalsIgnoreCase("HEAL_PC") ||
 			command->opcodeName.equalsIgnoreCase("ADJ_HP")) {
 			noteMutation(setPlayerCurrentHitPoints(
@@ -2102,6 +2230,46 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			continue;
 		}
 
+		if (command->opcodeName.equalsIgnoreCase("PC_GOTO_XZ")) {
+			if (!requestPlayerGotoXZ || !playerGotoX || !playerGotoZ) {
+				debug(1, "Harvester: deferred startup command '%s' for %s '%s' has no player-move context",
+					command->opcodeName.c_str(), contextLabel, contextName.c_str());
+				currentTag = command->arg4;
+				continue;
+			}
+
+			*requestPlayerGotoXZ = true;
+			*playerGotoX = atoi(command->arg1.c_str());
+			*playerGotoZ = atoi(command->arg2.c_str());
+			if (continuationTag)
+				*continuationTag = command->arg4;
+			return;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("CHANGE_LIGHTING")) {
+			if (!lightingCommand) {
+				debug(1, "Harvester: deferred startup command '%s' for %s '%s' has no lighting context",
+					command->opcodeName.c_str(), contextLabel, contextName.c_str());
+				currentTag = command->arg4;
+				continue;
+			}
+
+			if (command->arg1.equalsIgnoreCase("DIM"))
+				*lightingCommand = kStartupLightingCommandDim;
+			else if (command->arg1.equalsIgnoreCase("NORMAL"))
+				*lightingCommand = kStartupLightingCommandNormal;
+			else if (command->arg1.equalsIgnoreCase("NONE"))
+				*lightingCommand = kStartupLightingCommandBlack;
+			else if (command->arg1.equalsIgnoreCase("FADE_IN"))
+				*lightingCommand = kStartupLightingCommandFadeIn;
+			else
+				debug(1, "Harvester: unsupported CHANGE_LIGHTING mode '%s' for %s '%s', preserving control flow",
+					command->arg1.c_str(), contextLabel, contextName.c_str());
+			if (continuationTag)
+				*continuationTag = command->arg4;
+			return;
+		}
+
 		if (command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
 			if (allowTransitions && nextRoomName) {
@@ -2139,6 +2307,11 @@ bool Script::hasActionableCommandChain(const Common::String &initialTag) const {
 			continue;
 		}
 
+		if (command->opcodeName.equalsIgnoreCase("CHECK_PERC")) {
+			currentTag = command->arg2.empty() ? command->arg3 : command->arg2;
+			continue;
+		}
+
 		if (command->opcodeName.equalsIgnoreCase("SET_FLAG") ||
 			command->opcodeName.equalsIgnoreCase("SPOOL_MUSIC") ||
 			command->opcodeName.equalsIgnoreCase("ADD") ||
@@ -2150,15 +2323,20 @@ bool Script::hasActionableCommandChain(const Common::String &initialTag) const {
 			command->opcodeName.equalsIgnoreCase("SET_MONSTER") ||
 			command->opcodeName.equalsIgnoreCase("SET_TIMER") ||
 			command->opcodeName.equalsIgnoreCase("KILL_TIMER") ||
+			command->opcodeName.equalsIgnoreCase("EXEC_LIST") ||
 			command->opcodeName.equalsIgnoreCase("START_DIALOG") ||
+			command->opcodeName.equalsIgnoreCase("GOFLIC") ||
 			command->opcodeName.equalsIgnoreCase("GODEATHFLIC") ||
 			command->opcodeName.equalsIgnoreCase("KILL_NPC") ||
 			command->opcodeName.equalsIgnoreCase("MONSTERFY") ||
+			command->opcodeName.equalsIgnoreCase("SHOW_TEXT") ||
 			command->opcodeName.equalsIgnoreCase("HEAL_PC") ||
 			command->opcodeName.equalsIgnoreCase("ADJ_HP") ||
 			command->opcodeName.equalsIgnoreCase("KILL_PC") ||
 			command->opcodeName.equalsIgnoreCase("PAUSE_PC") ||
 			command->opcodeName.equalsIgnoreCase("RESUME_PC") ||
+			command->opcodeName.equalsIgnoreCase("PC_GOTO_XZ") ||
+			command->opcodeName.equalsIgnoreCase("CHANGE_LIGHTING") ||
 			command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
 			return true;
@@ -2198,6 +2376,18 @@ bool Script::resolveObjectInspectText(const StartupObjectRecord &object, Startup
 	return !text.value.empty();
 }
 
+bool Script::resolveTextRecord(const Common::String &key, StartupResolvedText &text) const {
+	text = StartupResolvedText();
+
+	const StartupTextRecord *textRecord = findTextRecord(key);
+	if (!textRecord)
+		return false;
+
+	text.boxName = textRecord->boxName;
+	text.value = textRecord->value;
+	return !text.value.empty();
+}
+
 Common::String Script::resolveObjectLabel(const StartupObjectRecord &object) const {
 	if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)) {
 		const StartupTextRecord *inventoryText = findTextRecord(object.inventoryTextKey);
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index dd22453f1e1..5c11fc92ccc 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -228,6 +228,11 @@ struct StartupCommandRecord {
 	Common::String arg4;
 };
 
+struct StartupExecListRecord {
+	Common::String listName;
+	Common::Array<Common::String> entries;
+};
+
 struct StartupTextRecord {
 	Common::String key;
 	Common::String boxName;
@@ -302,24 +307,39 @@ enum StartupRoomTransitionKind {
 	kStartupRoomTransitionCloseup
 };
 
+enum StartupLightingCommand {
+	kStartupLightingCommandNone = 0,
+	kStartupLightingCommandDim,
+	kStartupLightingCommandNormal,
+	kStartupLightingCommandBlack,
+	kStartupLightingCommandFadeIn
+};
+
+struct StartupResolvedText {
+	Common::String boxName;
+	Common::String value;
+};
+
 struct StartupInteractionResult {
 	Common::String musicPath;
 	Common::String nextRoomName;
+	Common::String cutscenePath;
 	Common::String deathFlicPath;
 	Common::String dialogueNpcName;
 	Common::String dialogueContinuationTag;
+	Common::String continuationTag;
+	StartupResolvedText modalText;
 	Common::Array<StartupAudioCommand> audioCommands;
 	StartupRoomTransitionKind roomTransition = kStartupRoomTransitionNone;
+	StartupLightingCommand lightingCommand = kStartupLightingCommandNone;
+	int playerGotoX = 0;
+	int playerGotoZ = 0;
+	bool requestPlayerGotoXZ = false;
 	bool requestMainMenu = false;
 	bool abortRemainingCommandChain = false;
 	bool mutatedRuntimeState = false;
 };
 
-struct StartupResolvedText {
-	Common::String boxName;
-	Common::String value;
-};
-
 class Script {
 public:
 	bool load(ResourceManager &resources);
@@ -338,6 +358,7 @@ public:
 	const Common::Array<StartupRegionRecord> &getRegions() const { return _regions; }
 	const Common::Array<StartupFlagRecord> &getFlags() const { return _flags; }
 	const Common::Array<StartupCommandRecord> &getCommands() const { return _commands; }
+	const Common::Array<StartupExecListRecord> &getExecLists() const { return _execLists; }
 	const Common::Array<StartupTextRecord> &getTexts() const { return _texts; }
 	const Common::Array<StartupHeadRecord> &getHeads() const { return _heads; }
 	const Common::Array<StartupUseItemRecord> &getUseItems() const { return _useItems; }
@@ -378,6 +399,7 @@ public:
 		void getVisibleInventoryObjects(Common::Array<StartupObjectRecord> &objects) const;
 		void markObjectIdentShown(const StartupObjectRecord &object);
 		bool resolveObjectInspectText(const StartupObjectRecord &object, StartupResolvedText &text) const;
+		bool resolveTextRecord(const Common::String &key, StartupResolvedText &text) const;
 		Common::String resolveObjectLabel(const StartupObjectRecord &object) const;
 		Common::String resolveTextValue(const Common::String &key) const;
 		const StartupHeadRecord *findHeadRecord(const Common::String &headId) const;
@@ -409,6 +431,7 @@ private:
 	void parseTownRecords(ResourceManager &resources);
 	const StartupRoomRecord *findRoomRecord(const Common::String &roomName) const;
 	const StartupCommandRecord *findCommandRecord(const Common::String &tag) const;
+	const StartupExecListRecord *findExecListRecord(const Common::String &name) const;
 	const StartupTextRecord *findTextRecord(const Common::String &key) const;
 	const StartupUseItemRecord *findUseItemRecord(const Common::String &itemName, const StartupObjectRecord &target) const;
 	const StartupFlagRecord *findRuntimeFlag(const Common::String &flagName) const;
@@ -428,8 +451,11 @@ private:
 		const Common::String &contextName, bool allowTransitions, Common::String *musicPath,
 		Common::Array<StartupAudioCommand> *audioCommands, Common::String *nextRoomName,
 		StartupRoomTransitionKind *roomTransition,
-		Common::String *deathFlicPath, bool *requestMainMenu, Common::String *dialogueNpcName,
-		Common::String *dialogueContinuationTag,
+		Common::String *cutscenePath, Common::String *deathFlicPath, bool *requestMainMenu,
+		Common::String *dialogueNpcName, Common::String *dialogueContinuationTag,
+		Common::String *continuationTag, StartupResolvedText *modalText,
+		StartupLightingCommand *lightingCommand, bool *requestPlayerGotoXZ,
+		int *playerGotoX, int *playerGotoZ,
 		bool *mutatedRuntimeState);
 	bool hasActionableCommandChain(const Common::String &initialTag) const;
 	bool setPlayerCurrentHitPoints(int hitPoints);
@@ -448,6 +474,7 @@ private:
 	Common::Array<StartupRegionRecord> _regions;
 	Common::Array<StartupFlagRecord> _flags;
 	Common::Array<StartupCommandRecord> _commands;
+	Common::Array<StartupExecListRecord> _execLists;
 	Common::Array<StartupTextRecord> _texts;
 	Common::Array<StartupHeadRecord> _heads;
 	Common::Array<StartupUseItemRecord> _useItems;


Commit: 9ed92f140bed420386bbc66ad300221088e629f8
    https://github.com/scummvm/scummvm/commit/9ed92f140bed420386bbc66ad300221088e629f8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:30+02:00

Commit Message:
HARVESTER: Tighten inventory runtime flow

Changed paths:
    TRACKER.md
    engines/harvester/inventory.cpp
    engines/harvester/inventory.h
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index a0d1b2ba32d..6d08a53f7ad 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, tightened the current integrated inventory / room-item handoff against the persisted actor runtime instead of inventing a separate inventory state machine: right-clicking confirmed weapon inventory items now toggles the saved player combat loadout ids recovered from the native inventory screen, the open inventory overlay now refreshes its `INV_STAT*` portrait immediately when live player HP changes, and secondary-click while an inventory item is actively selected now cancels that carry/use latch without forcibly closing the panel.
+- Kept the current ScummVM-side carry/use handoff model intact, but bounded it more tightly to the same live actor state that save/load and timer-driven room actions already use: inventory weapon toggles write through `Script::setPlayerCombatLoadout`, and the room loop captures that updated runtime state immediately for later save/menu handoffs.
+- Rebuilt the touched Harvester objects successfully after that inventory/runtime parity pass: `engines/harvester/inventory.o`, `engines/harvester/room.o`, and `scummvm`.
 - On March 19, 2026, widened the confirmed town-script / room-event coverage in `engines/harvester` using only opcode behavior already bounded in Ghidra and `ARCHITECTURE.md`: added `CHECK_PERC`, `EXEC_LIST`, `GOFLIC`, `SHOW_TEXT`, `PC_GOTO_XZ`, and `CHANGE_LIGHTING` to the startup-script dispatcher, parsed `EXEC_LIST` records from `HARVEST.SCR`, and threaded the new deferred action results through room interactions plus queued dialogue follow-ups instead of dropping them as generic unsupported fallthrough.
 - The room loop now applies confirmed deferred side effects in native order where applicable: `GOFLIC` plays FST cutscenes then continues with the chained action tag, `SHOW_TEXT` runs a blocking room-modal textbox using the existing inspect-text resources, `PC_GOTO_XZ` repositions the live player avatar on the current room depth line, and `CHANGE_LIGHTING` covers the confirmed `DIM`, `NORMAL`, and `NONE` modes while keeping `FADE_IN` explicit as a still-unbounded no-op.
 - Rebuilt the touched Harvester objects successfully after that script/event coverage pass: `engines/harvester/script.o`, `engines/harvester/room.o`, `engines/harvester/dialogue.o`, and `scummvm`.
@@ -47,8 +50,8 @@
 
 ## Next Suggested Action
 
-1. Run a manual desktop validation pass against the specific live rooms/interactions that exercise the newly supported deferred action opcodes: verify at least one `GOFLIC`, `SHOW_TEXT`, `PC_GOTO_XZ`, `CHANGE_LIGHTING`, and `EXEC_LIST` room path against the desktop build so the remaining progression failures are grounded in concrete room names instead of static unsupported-opcode logs.
-2. Keep the residual unsupported list explicit during that pass. The current narrowed gaps are `CHANGE_CD` and the exact room-side effect of `CHANGE_LIGHTING FADE_IN`; if those still block progression, bound them from the native anchors before widening anything else.
+1. Run a manual desktop validation pass that specifically covers the tightened inventory/runtime hooks: verify live HP changes update the open inventory `INV_STAT*` portrait immediately, right-clicking weapon items toggles the persisted combat loadout without desynchronizing save/load state, and closing/reopening the inventory preserves carried-item handoff semantics.
+2. If that pass exposes any remaining inventory gaps, keep them narrowly bounded to confirmed native behaviors only. The next likely candidates are the room HUD weapon-resource icon strip and any still-missing inventory secondary-click item actions beyond the now-confirmed weapon-loadout path.
 
 ## Reimplementation Priority Order
 
@@ -62,11 +65,11 @@
    Native anchors: `dispatch_room_event_actions @ 0x60ee0`, `run_town_script_interpreter @ 0x46d80`, and the confirmed town-script notes in `ARCHITECTURE.md`.
    Concrete tasks: exercise the now-supported `CHECK_PERC`, `EXEC_LIST`, `GOFLIC`, `SHOW_TEXT`, `PC_GOTO_XZ`, and `CHANGE_LIGHTING` paths in live rooms; only if validation still fails, add the exact missing command or side effect proven by that room.
    Exit criteria: the remaining room progression failures reduce to a short, named list of still-unimplemented native commands or side effects, with `CHANGE_CD` / `FADE_IN` either confirmed as blockers or explicitly ruled out.
-3. Tighten inventory and item-use flow against that actor runtime.
+3. Validate the tightened inventory and item-use flow against live gameplay, then only extend the remaining confirmed inventory-specific behavior that still blocks parity.
    Files: `engines/harvester/inventory.cpp`, `engines/harvester/room.cpp`, `engines/harvester/script.cpp`.
    Native anchors: `run_inventory_screen @ 0x7df10`, `add_object_to_inventory @ 0x7c8b0`, `sync_player_combat_weapon_resource_icons @ 0x792c0`, and the confirmed inventory carry/use notes in `ARCHITECTURE.md`.
-   Concrete tasks: keep the current carry/use handoff model, but recheck it once actor runtime state, HP, timers, and combat-adjacent HUD elements are live; fold any missing status/icon behavior in here rather than earlier.
-   Exit criteria: inventory, carried items, and room-target use cases all operate against the same live actor state that room gameplay uses.
+   Concrete tasks: validate the new live HP portrait refresh plus weapon-loadout toggles, then decide from a desktop pass whether the remaining native inventory-specific gaps are limited to HUD icon strip restoration or broader item secondary-click actions.
+   Exit criteria: any remaining inventory parity work is reduced to a short, explicit list of still-unimplemented native inventory behaviors instead of a broad actor-runtime mismatch.
 4. Leave low-level runtime, HMIDRV, and x87 cleanup as deferred RE work unless they block a confirmed gameplay feature.
    Ghidra anchors: the remaining unwind helpers around `FUN_0008f64b` through `FUN_000905df`, the HMIDRV wrapper band around `FUN_00087005`, and the x87/no-87 runtime cluster beginning near `FUN_0008c3d8`.
    Rationale: those clusters still matter for naming completeness, but the current engine-side gaps are better bounded and have higher reimplementation value.
diff --git a/engines/harvester/inventory.cpp b/engines/harvester/inventory.cpp
index 11222489920..2cbbf874db7 100644
--- a/engines/harvester/inventory.cpp
+++ b/engines/harvester/inventory.cpp
@@ -41,6 +41,34 @@ static const int kInventoryItemMaxRight = 564;
 static const int kInventoryItemSpacing = 5;
 static const byte kTransparentPaletteIndex = 0;
 
+struct InventoryCombatLoadoutEntry {
+	const char *objectName;
+	int loadoutId;
+};
+
+static const InventoryCombatLoadoutEntry kInventoryCombatLoadoutMap[] = {
+	{ "CLEAVER", 1 },
+	{ "NAILGUN", 2 },
+	{ "SHOTGUN", 3 },
+	{ "9GUN", 4 },
+	{ "38GUN", 5 },
+	{ "TOMAHAWK", 6 },
+	{ "KNIFE", 7 },
+	{ "FLAIL", 8 },
+	{ "HANDAXE", 9 },
+	{ "WRENCH", 10 },
+	{ "PITCHFORK", 11 },
+	{ "SCYTHE", 12 },
+	{ "SWORD", 13 },
+	{ "CHAINSAW", 14 },
+	{ "HARVEST_BLADE", 15 },
+	{ "SHOVEL", 16 },
+	{ "FIREAXE", 17 },
+	{ "BAT", 18 },
+	{ "RAZOR", 19 },
+	{ "POOLSTICK", 20 }
+};
+
 static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
 	if (!bitmap.isValid())
 		return;
@@ -120,6 +148,21 @@ static bool loadBitmapResource(ResourceManager &resources, const Common::String
 	return true;
 }
 
+static bool resolveInventoryCombatLoadoutId(const Common::String &objectName, int &loadoutId) {
+	loadoutId = 0;
+	if (objectName.empty())
+		return false;
+
+	for (const InventoryCombatLoadoutEntry &entry : kInventoryCombatLoadoutMap) {
+		if (objectName.equalsIgnoreCase(entry.objectName)) {
+			loadoutId = entry.loadoutId;
+			return true;
+		}
+	}
+
+	return false;
+}
+
 } // End of anonymous namespace
 
 InventorySystem::InventorySystem(HarvesterEngine &engine) : _engine(engine) {
@@ -133,6 +176,8 @@ bool InventorySystem::refresh() {
 	if (!startupScript || !resources)
 		return false;
 
+	_lastPlayerHitPoints = startupScript->getPlayerCurrentHitPoints();
+
 	Common::Array<StartupObjectRecord> inventoryObjects;
 	startupScript->getVisibleInventoryObjects(inventoryObjects);
 	int nextX = kInventoryItemStartX;
@@ -219,6 +264,18 @@ bool InventorySystem::clearSelection() {
 	return true;
 }
 
+bool InventorySystem::refreshIfRuntimeStateChanged() {
+	Script *startupScript = _engine.getStartupScript();
+	if (!startupScript)
+		return false;
+
+	const int currentHitPoints = startupScript->getPlayerCurrentHitPoints();
+	if (currentHitPoints == _lastPlayerHitPoints)
+		return false;
+
+	return refresh();
+}
+
 bool InventorySystem::isOpen() const {
 	return _open;
 }
@@ -259,6 +316,22 @@ void InventorySystem::selectItem(const Common::String &objectName) {
 	_selectedItemName = objectName;
 }
 
+bool InventorySystem::toggleCombatLoadout(const StartupObjectRecord &object, bool &changed) {
+	changed = false;
+
+	int loadoutId = 0;
+	if (!resolveInventoryCombatLoadoutId(object.objectName, loadoutId))
+		return false;
+
+	Script *startupScript = _engine.getStartupScript();
+	if (!startupScript)
+		return false;
+
+	const int nextLoadout = startupScript->getPlayerCombatLoadout() == loadoutId ? 0 : loadoutId;
+	changed = startupScript->setPlayerCombatLoadout(nextLoadout);
+	return true;
+}
+
 void InventorySystem::setPromptText(const Common::String &promptText) {
 	_promptText = promptText;
 }
diff --git a/engines/harvester/inventory.h b/engines/harvester/inventory.h
index e5856c8f7d5..25d829ab413 100644
--- a/engines/harvester/inventory.h
+++ b/engines/harvester/inventory.h
@@ -51,12 +51,14 @@ public:
 	bool open();
 	bool close();
 	bool clearSelection();
+	bool refreshIfRuntimeStateChanged();
 	bool isOpen() const;
 	bool hasSelection() const;
 	const Common::String &getSelectedItemName() const;
 	Common::String resolveSelectedLabel() const;
 	Common::String buildSelectedPrompt(const Common::String &targetLabel) const;
 	void selectItem(const Common::String &objectName);
+	bool toggleCombatLoadout(const StartupObjectRecord &object, bool &changed);
 	void setPromptText(const Common::String &promptText);
 	const Common::String &getPromptText() const;
 	const StartupInventoryVisual *findItemAtPoint(const Common::Point &point) const;
@@ -70,6 +72,7 @@ public:
 private:
 	Common::Array<StartupInventoryVisual> _items;
 	bool _open = false;
+	int _lastPlayerHitPoints = -1;
 	Common::String _selectedItemName;
 	Common::String _promptText;
 	HarvesterEngine &_engine;
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index f6ef23ec6cd..a80657e5042 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -962,9 +962,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		stopPlayerRegionInteraction();
 		return runRegionInteraction(*region);
 	};
-	auto tryActivateOverlappedRegion = [&]() -> Common::Error {
-		if (!playerState.entity)
-			return Common::kNoError;
+		auto tryActivateOverlappedRegion = [&]() -> Common::Error {
+			if (!playerState.entity)
+				return Common::kNoError;
 
 		for (const StartupRegionRecord &region : scene.sceneRegions) {
 			if (!region.startEnabled)
@@ -978,7 +978,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			return runRegionInteraction(region);
 		}
 
-		return Common::kNoError;
+			return Common::kNoError;
 		};
 		if (!_inventory.refresh())
 			return Common::kReadingFailed;
@@ -1012,6 +1012,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			break;
 		}
 		captureCurrentSaveState();
+		if (_inventory.isOpen() && _inventory.refreshIfRuntimeStateChanged())
+			needsRedraw = true;
 
 		if (needsRedraw) {
 			Graphics::Screen *activeScreen = getActiveScreen();
@@ -1122,9 +1124,27 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				}
 				pendingRegionName.clear();
 				if (_inventory.isOpen()) {
-					const bool clearedSelection = _inventory.clearSelection();
-					const bool closedInventory = _inventory.close();
-					if (clearedSelection || closedInventory)
+					const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
+					if (_inventory.hasSelection()) {
+						if (_inventory.clearSelection())
+							needsRedraw = true;
+						break;
+					}
+					if (inventoryHover &&
+							!InventorySystem::isExitObject(inventoryHover->object) &&
+							!InventorySystem::isStatusObject(inventoryHover->object)) {
+						bool loadoutChanged = false;
+						if (_inventory.toggleCombatLoadout(inventoryHover->object, loadoutChanged)) {
+							if (loadoutChanged) {
+								captureCurrentSaveState();
+								if (!_inventory.refresh())
+									return Common::kReadingFailed;
+							}
+							needsRedraw = true;
+							break;
+						}
+					}
+					if (_inventory.close())
 						needsRedraw = true;
 					break;
 				}


Commit: 539bcf728bf38e173a478f16a3804252c6a3a1bf
    https://github.com/scummvm/scummvm/commit/539bcf728bf38e173a478f16a3804252c6a3a1bf
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:30+02:00

Commit Message:
HARVESTER: Fix player idle animation timing

Changed paths:
    ARCHITECTURE.md
    engines/harvester/flow.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 0fbbe7804e9..9511f4d4e1c 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -727,6 +727,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `room_setup`, `run_save_game_menu`, `run_load_game_menu`, the intro/logo sequence helper, the main-menu return path, and the idle-animation cleanup path all refresh it from the current tick source.
 - `g_idle_animation_trigger_time` at `0xd60c4` is the computed threshold for the `IDLE_ANIM` room-animation path.
   - `run_harvester_main_loop` recomputes it as `max(g_idle_animation_activity_timestamp, g_idle_animation_reset_timestamp) + 3000`, skips the branch in excluded rooms, and otherwise spawns `IDLE_ANIM` from `graphic\\roomanim\\pcloun02.abm`.
+  - The same idle-animation spawn path loads `EBX = 0x0e` immediately before `spawn_scaled_abm_entity_from_resource`, so the native `PCLOUN02.ABM` animation rate is 14 rather than the previously inferred 30.
   - After the idle animation exits, the main loop re-arms the threshold by another 3000 ticks.
 - `show_pause_message` at `0x797c0` renders the `HARVESTER IS PAUSED` overlay and backdates both idle timestamps by `0xc1c`, allowing the idle-animation gate to become eligible again immediately after pause dismissal.
   - It reuses `g_box3_panel_entity`, while `show_target_ident_text` selects among `g_box1_panel_entity` .. `g_box4_panel_entity` by matching `TextRecord.panel_id` against the `BOX1` / `BOX2` / `BOX3` / `BOX4` strings.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 5dd81b897e0..26c3b77dd2e 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -100,7 +100,8 @@ static const char *const kPlayerIdleAnimationEntityName = "IDLE_ANIM";
 static const char *const kPlayerIdleAnimationResourcePath = "1:/GRAPHIC/ROOMANIM/PCLOUN02.ABM";
 static const uint32 kRuntimeClockDivisorMs = 10;
 static const uint32 kRoomPlayerIdleDelayTicks = 3000;
-static const int kRoomPlayerIdleAnimationRate = 30;
+// Native run_harvester_main_loop sets EBX=0x0e before spawning PCLOUN02.ABM.
+static const int kRoomPlayerIdleAnimationRate = 14;
 static const int kRoomPlayerIdleLoopStartFrame = 0x0f;
 static const int kRoomPlayerIdleLoopLastFrame = 0xb2;
 static const int kRoomPlayerIdleExitLastFrame = 0xbf;


Commit: 627eece00f0c3794293624ba6e12ee2d496a67ab
    https://github.com/scummvm/scummvm/commit/627eece00f0c3794293624ba6e12ee2d496a67ab
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:31+02:00

Commit Message:
HARVESTER: Use native map label font

Changed paths:
    engines/harvester/flow.cpp


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 26c3b77dd2e..7d66a5ee1c2 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -20,6 +20,7 @@
  */
 
 #include <math.h>
+#include <memory>
 
 #include "harvester/flow.h"
 
@@ -34,6 +35,7 @@
 #include "graphics/font.h"
 #include "graphics/fontman.h"
 #include "graphics/framelimiter.h"
+#include "harvester/cft_font.h"
 #include "harvester/detection.h"
 #include "harvester/harvester.h"
 #include "harvester/palette_utils.h"
@@ -133,6 +135,19 @@ static int roundToInt(float value) {
 	return value >= 0.0f ? (int)floorf(value + 0.5f) : (int)ceilf(value - 0.5f);
 }
 
+static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
+	const Text *startupText = engine.getStartupText();
+	if (!startupText || !fontName)
+		return nullptr;
+
+	for (const CftFontResource &font : startupText->getFonts()) {
+		if (font.name.equalsIgnoreCase(fontName))
+			return &font;
+	}
+
+	return nullptr;
+}
+
 static int clampTownMapPanelIndex(int panelIndex) {
 	return CLIP<int>(panelIndex, 0, ARRAYSIZE(kTownMapBitmapPaths) - 1);
 }
@@ -1903,6 +1918,14 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 	ResourceManager *resources = _engine.getResources();
 	Graphics::Screen *screen = _engine.getScreen();
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	std::unique_ptr<HarvesterCftFont> townMapFont;
+	if (const CftFontResource *townMapFontResource = findStartupFontByName(_engine, "TEXTFONT")) {
+		townMapFont.reset(new HarvesterCftFont(*townMapFontResource));
+		if (townMapFont->isValid())
+			font = townMapFont.get();
+		else
+			townMapFont.reset();
+	}
 	if (!startupScript || !resources || !screen || !font)
 		return Common::kReadingFailed;
 


Commit: e4f5cc01f4b4d57570148cc08f732c3c5ed1c04c
    https://github.com/scummvm/scummvm/commit/e4f5cc01f4b4d57570148cc08f732c3c5ed1c04c
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:31+02:00

Commit Message:
HARVESTER: Fix dialogue textbox wrapping

Changed paths:
    engines/harvester/dialogue.cpp


diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index a52a2ea1497..420a5fb92b5 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -261,6 +261,70 @@ static void drawDialogueTextLines(Graphics::Screen &screen, const Graphics::Font
 		font.drawString(&screen, lines[i], x, y + (int)i * lineHeight, width, 0);
 }
 
+static void wrapDialogueTextLikeNative(const Graphics::Font &font, bool usesCft,
+		const Common::String &text, int width, Common::Array<Common::String> &lines) {
+	lines.clear();
+	if (text.empty())
+		return;
+
+	if (!usesCft || width <= 0) {
+		font.wordWrapText(text, width, lines);
+		if (lines.empty())
+			lines.push_back(text);
+		return;
+	}
+
+	Common::String wrappedText;
+	for (uint i = 0; i < text.size(); ++i) {
+		const char c = text[i];
+		if (c != '\r')
+			wrappedText += c;
+	}
+
+	const int wrapCharsPerLine = width / MAX<int>(1, font.getCharWidth(' ') - 1);
+	if (wrapCharsPerLine <= 0) {
+		lines.push_back(wrappedText);
+		return;
+	}
+
+	uint lineStart = 0;
+	while (lineStart < wrappedText.size()) {
+		uint lineEnd = lineStart;
+		while (lineEnd < wrappedText.size() && wrappedText[lineEnd] != '\n')
+			++lineEnd;
+
+		if (lineEnd - lineStart > (uint)wrapCharsPerLine) {
+			uint breakPos = MIN<uint>(lineStart + (uint)wrapCharsPerLine, lineEnd - 1);
+			while (breakPos > lineStart && wrappedText[breakPos] != ' ')
+				--breakPos;
+
+			if (breakPos > lineStart && wrappedText[breakPos] == ' ') {
+				wrappedText.setChar('\n', breakPos);
+				while (breakPos + 1 < wrappedText.size() && wrappedText[breakPos + 1] == ' ')
+					wrappedText.deleteChar(breakPos + 1);
+				lineStart = breakPos + 1;
+				continue;
+			}
+		}
+
+		lineStart = lineEnd + 1;
+	}
+
+	Common::String line;
+	for (uint i = 0; i < wrappedText.size(); ++i) {
+		if (wrappedText[i] == '\n') {
+			lines.push_back(line);
+			line.clear();
+			continue;
+		}
+
+		line += wrappedText[i];
+	}
+
+	if (!line.empty() || lines.empty())
+		lines.push_back(line);
+}
+
 static void splitDialogueMenuLine(const Common::String &line, Common::Array<Common::String> &parts) {
 	parts.clear();
 	if (line.empty())
@@ -610,7 +674,8 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		Common::Array<Common::String> subtitleLines;
 		const IndexedBitmap *textboxBitmap = nullptr;
 		if (textEnabled) {
-			subtitleFont->wordWrapText(subtitleText, kDialogueSubtitleTextWidth, subtitleLines);
+			wrapDialogueTextLikeNative(*subtitleFont, subtitleFontUsesCft,
+				subtitleText, kDialogueSubtitleTextWidth, subtitleLines);
 			textboxBitmap = startupArt->getTextboxBitmap(resolveDialogueTextboxIndex(subtitleLines.size()));
 		}
 
@@ -785,7 +850,8 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			option.rowStart = (int)totalRows;
 			for (const Common::String &explicitLine : explicitLines) {
 				Common::Array<Common::String> wrappedExplicitLines;
-				menuFont->wordWrapText(explicitLine, kDialogueSubtitleTextWidth, wrappedExplicitLines);
+				wrapDialogueTextLikeNative(*menuFont, menuFontUsesCft,
+					explicitLine, kDialogueSubtitleTextWidth, wrappedExplicitLines);
 				if (wrappedExplicitLines.empty())
 					wrappedExplicitLines.push_back(explicitLine);
 				for (const Common::String &wrappedExplicitLine : wrappedExplicitLines)


Commit: db4cbbbc9dbed2240035e5b2a1c41ff0a0a2eba0
    https://github.com/scummvm/scummvm/commit/db4cbbbc9dbed2240035e5b2a1c41ff0a0a2eba0
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:31+02:00

Commit Message:
HARVESTER: Add room debug overlay command

Changed paths:
    engines/harvester/console.cpp
    engines/harvester/console.h
    engines/harvester/flow.cpp
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h


diff --git a/engines/harvester/console.cpp b/engines/harvester/console.cpp
index 5e406af68c5..5ec6a02d8fb 100644
--- a/engines/harvester/console.cpp
+++ b/engines/harvester/console.cpp
@@ -20,11 +20,13 @@
  */
 
 #include "harvester/console.h"
+#include "harvester/harvester.h"
 
 namespace Harvester {
 
 Console::Console() : GUI::Debugger() {
 	registerCmd("about", WRAP_METHOD(Console, Cmd_about));
+	registerCmd("DEBUG_ROOM", WRAP_METHOD(Console, Cmd_debugRoom));
 }
 
 Console::~Console() {
@@ -35,4 +37,20 @@ bool Console::Cmd_about(int argc, const char **argv) {
 	return true;
 }
 
+bool Console::Cmd_debugRoom(int argc, const char **argv) {
+	if (argc != 1) {
+		debugPrintf("Usage: DEBUG_ROOM\n");
+		return true;
+	}
+
+	if (!g_engine) {
+		debugPrintf("Harvester engine is not active\n");
+		return true;
+	}
+
+	const bool enabled = g_engine->toggleRoomDebugEnabled();
+	debugPrintf("Room debug overlay %s\n", enabled ? "enabled" : "disabled");
+	return true;
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/console.h b/engines/harvester/console.h
index e01fc87f1ce..076c77b8b96 100644
--- a/engines/harvester/console.h
+++ b/engines/harvester/console.h
@@ -30,6 +30,7 @@ namespace Harvester {
 class Console : public GUI::Debugger {
 private:
 	bool Cmd_about(int argc, const char **argv);
+	bool Cmd_debugRoom(int argc, const char **argv);
 public:
 	Console();
 	~Console() override;
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 7d66a5ee1c2..319f96f081f 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -90,6 +90,10 @@ static const byte kTextColorNormal = 255;
 static const byte kShadowColor = 0;
 static const byte kQuickTipActionColor = 0xc3;
 static const byte kTownMapLabelColor = 0x28;
+static const int kRoomDebugLabelPaddingX = 3;
+static const int kRoomDebugLabelPaddingY = 2;
+static const int kRoomDebugRoomNameX = 4;
+static const int kRoomDebugRoomNameY = 4;
 
 static const int kCursorSequenceWalk = 0;
 static const int kCursorSequenceExamine = 1;
@@ -148,6 +152,82 @@ static const CftFontResource *findStartupFontByName(const HarvesterEngine &engin
 	return nullptr;
 }
 
+static byte findNearestPaletteColor(const byte *palette, byte red, byte green, byte blue) {
+	if (!palette)
+		return 0;
+
+	byte bestIndex = 0;
+	uint32 bestDistance = 0xffffffffu;
+	for (int i = 0; i < 256; ++i) {
+		const int paletteOffset = i * 3;
+		const int dr = (int)palette[paletteOffset] - red;
+		const int dg = (int)palette[paletteOffset + 1] - green;
+		const int db = (int)palette[paletteOffset + 2] - blue;
+		const uint32 distance = (uint32)(dr * dr + dg * dg + db * db);
+		if (distance < bestDistance) {
+			bestDistance = distance;
+			bestIndex = (byte)i;
+		}
+	}
+
+	return bestIndex;
+}
+
+static Common::String resolveRoomDebugObjectLabel(HarvesterEngine &engine, const StartupObjectRecord &object) {
+	if (Script *startupScript = engine.getStartupScript()) {
+		const Common::String resolvedLabel = startupScript->resolveObjectLabel(object);
+		if (!resolvedLabel.empty())
+			return resolvedLabel;
+	}
+
+	return object.objectName;
+}
+
+static void drawRoomDebugLabel(Graphics::Screen &screen, const Graphics::Font &font,
+		const Common::String &text, int x, int y, byte textColor, byte backgroundColor) {
+	if (text.empty())
+		return;
+
+	const int labelWidth = font.getStringWidth(text) + kRoomDebugLabelPaddingX * 2;
+	const int labelHeight = font.getFontHeight() + kRoomDebugLabelPaddingY * 2;
+	const int drawX = CLIP<int>(x, 0, MAX(0, screen.w - labelWidth));
+	const int drawY = CLIP<int>(y, 0, MAX(0, screen.h - labelHeight));
+	const Common::Rect background(drawX, drawY,
+		MIN<int>(screen.w, drawX + labelWidth), MIN<int>(screen.h, drawY + labelHeight));
+	screen.fillRect(background, backgroundColor);
+	font.drawString(&screen, text,
+		background.left + kRoomDebugLabelPaddingX,
+		background.top + kRoomDebugLabelPaddingY,
+		MAX(0, background.width() - kRoomDebugLabelPaddingX * 2),
+		textColor);
+}
+
+static void drawRoomDebugOverlay(HarvesterEngine &engine, Graphics::Screen &screen,
+		const StartupRoomSceneResources &scene) {
+	if (!engine.isRoomDebugEnabled())
+		return;
+
+	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	if (!font)
+		return;
+
+	byte displayPalette[256 * 3];
+	screen.getPalette(displayPalette);
+	const byte black = findNearestPaletteColor(displayPalette, 0x00, 0x00, 0x00);
+	const byte white = findNearestPaletteColor(displayPalette, 0xff, 0xff, 0xff);
+	const byte darkGray = findNearestPaletteColor(displayPalette, 0x40, 0x40, 0x40);
+
+	drawRoomDebugLabel(screen, *font, scene.state.roomName,
+		kRoomDebugRoomNameX, kRoomDebugRoomNameY, black, white);
+
+	for (const StartupRegionRecord &region : scene.sceneRegions)
+		drawRoomDebugLabel(screen, *font, region.regionName, region.left, region.top, white, darkGray);
+
+	for (const StartupObjectRecord &object : scene.sceneObjects)
+		drawRoomDebugLabel(screen, *font, resolveRoomDebugObjectLabel(engine, object),
+			object.currentX, object.currentY, white, black);
+}
+
 static int clampTownMapPanelIndex(int panelIndex) {
 	return CLIP<int>(panelIndex, 0, ARRAYSIZE(kTownMapBitmapPaths) - 1);
 }
@@ -874,6 +954,7 @@ void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const Star
 	screen.fillRect(screen.getBounds(), 0);
 	if (engine.getRuntimeEntities())
 		engine.getRuntimeEntities()->drawSceneEntities(screen);
+	drawRoomDebugOverlay(engine, screen, scene);
 }
 
 const StartupObjectRecord *findSceneObjectByName(const Common::Array<StartupObjectRecord> &objects,
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 3b7cf44021b..4b15c76ab5f 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -227,6 +227,11 @@ void HarvesterEngine::stopStartupSound() {
 		_media->stopSound();
 }
 
+bool HarvesterEngine::toggleRoomDebugEnabled() {
+	_roomDebugEnabled = !_roomDebugEnabled;
+	return _roomDebugEnabled;
+}
+
 void HarvesterEngine::captureCurrentStartupSaveRoomState(const Common::String &entranceName,
 		const Common::String &roomName, int playerX, int playerY, int playerZ, int playerFacing,
 		const Common::String &musicPath) {
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index ac72e036db2..7ad2f470c7b 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -130,6 +130,8 @@ public:
 	bool playStartupLoadedSound(int slot);
 	bool deleteStartupLoadedSound(int slot);
 	void stopStartupSound();
+	bool isRoomDebugEnabled() const { return _roomDebugEnabled; }
+	bool toggleRoomDebugEnabled();
 	void captureCurrentStartupSaveRoomState(const Common::String &entranceName,
 		const Common::String &roomName, int playerX, int playerY, int playerZ, int playerFacing,
 		const Common::String &musicPath);
@@ -152,6 +154,7 @@ private:
 	Script *_startupScript = nullptr;
 	StartupSaveRoomState _currentStartupSaveRoomState;
 	StartupSaveRoomState _pendingLoadedStartupSaveRoomState;
+	bool _roomDebugEnabled = false;
 };
 
 extern HarvesterEngine *g_engine;


Commit: 1cbfadab201ee4a7d79efeb22d5c83073b982b42
    https://github.com/scummvm/scummvm/commit/1cbfadab201ee4a7d79efeb22d5c83073b982b42
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:32+02:00

Commit Message:
HARVESTER: Dismiss quick tips on escape

Changed paths:
    engines/harvester/flow.cpp


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 319f96f081f..0500d24ac43 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -1961,6 +1961,10 @@ Common::Error Flow::runQuickTips() {
 					needsRedraw = true;
 				}
 				break;
+			case Common::EVENT_KEYDOWN:
+				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+					return Common::kNoError;
+				break;
 			default:
 				break;
 			}


Commit: 8a8e236d5106e0d6f20d1224034fc03b3fdea0b6
    https://github.com/scummvm/scummvm/commit/8a8e236d5106e0d6f20d1224034fc03b3fdea0b6
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:32+02:00

Commit Message:
HARVESTER: Skip background label in room debug

Changed paths:
    engines/harvester/flow.cpp


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 0500d24ac43..daca5d9597b 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -216,16 +216,26 @@ static void drawRoomDebugOverlay(HarvesterEngine &engine, Graphics::Screen &scre
 	const byte black = findNearestPaletteColor(displayPalette, 0x00, 0x00, 0x00);
 	const byte white = findNearestPaletteColor(displayPalette, 0xff, 0xff, 0xff);
 	const byte darkGray = findNearestPaletteColor(displayPalette, 0x40, 0x40, 0x40);
+	Common::String roomName = scene.state.roomName;
+	roomName.toUppercase();
 
-	drawRoomDebugLabel(screen, *font, scene.state.roomName,
+	drawRoomDebugLabel(screen, *font, roomName,
 		kRoomDebugRoomNameX, kRoomDebugRoomNameY, black, white);
 
 	for (const StartupRegionRecord &region : scene.sceneRegions)
 		drawRoomDebugLabel(screen, *font, region.regionName, region.left, region.top, white, darkGray);
 
-	for (const StartupObjectRecord &object : scene.sceneObjects)
+	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
+	for (const StartupObjectRecord &object : scene.sceneObjects) {
+		const RuntimeEntity *entity = runtimeEntities
+			? runtimeEntities->findSceneEntityByName(object.objectName)
+			: nullptr;
+		if (entity && entity->getClassId() == kRuntimeEntityClassBackground)
+			continue;
+
 		drawRoomDebugLabel(screen, *font, resolveRoomDebugObjectLabel(engine, object),
 			object.currentX, object.currentY, white, black);
+	}
 }
 
 static int clampTownMapPanelIndex(int panelIndex) {


Commit: 5bd858de86ba095e82e57b1602389b01e3098248
    https://github.com/scummvm/scummvm/commit/5bd858de86ba095e82e57b1602389b01e3098248
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:32+02:00

Commit Message:
HARVESTER: Label npcs in room debug overlay

Changed paths:
    engines/harvester/flow.cpp


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index daca5d9597b..8206d62bdfa 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -183,6 +183,16 @@ static Common::String resolveRoomDebugObjectLabel(HarvesterEngine &engine, const
 	return object.objectName;
 }
 
+static Common::String resolveRoomDebugNpcLabel(const StartupNpcRecord &npc) {
+	Common::String label = !npc.entityInitArg.empty() ? npc.entityInitArg : npc.npcName;
+	for (uint i = 0; i < label.size(); ++i) {
+		if (label[i] == '_')
+			label.setChar(' ', i);
+	}
+
+	return label;
+}
+
 static void drawRoomDebugLabel(Graphics::Screen &screen, const Graphics::Font &font,
 		const Common::String &text, int x, int y, byte textColor, byte backgroundColor) {
 	if (text.empty())
@@ -216,6 +226,7 @@ static void drawRoomDebugOverlay(HarvesterEngine &engine, Graphics::Screen &scre
 	const byte black = findNearestPaletteColor(displayPalette, 0x00, 0x00, 0x00);
 	const byte white = findNearestPaletteColor(displayPalette, 0xff, 0xff, 0xff);
 	const byte darkGray = findNearestPaletteColor(displayPalette, 0x40, 0x40, 0x40);
+	const byte red = findNearestPaletteColor(displayPalette, 0xff, 0x00, 0x00);
 	Common::String roomName = scene.state.roomName;
 	roomName.toUppercase();
 
@@ -236,6 +247,18 @@ static void drawRoomDebugOverlay(HarvesterEngine &engine, Graphics::Screen &scre
 		drawRoomDebugLabel(screen, *font, resolveRoomDebugObjectLabel(engine, object),
 			object.currentX, object.currentY, white, black);
 	}
+
+	for (const StartupNpcRecord &npc : scene.state.roomNpcs) {
+		const RuntimeEntity *entity = runtimeEntities
+			? runtimeEntities->findSceneEntityByName(npc.npcName)
+			: nullptr;
+		if (!entity || entity->getClassId() != kRuntimeEntityClassNpc || !entity->isVisible())
+			continue;
+
+		const Common::Rect npcRect = entity->getScreenRect();
+		drawRoomDebugLabel(screen, *font, resolveRoomDebugNpcLabel(npc),
+			npcRect.left, npcRect.top, white, red);
+	}
 }
 
 static int clampTownMapPanelIndex(int panelIndex) {


Commit: d9367c1e6e46e3f8260772bb91a1239fa66caa70
    https://github.com/scummvm/scummvm/commit/d9367c1e6e46e3f8260772bb91a1239fa66caa70
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:33+02:00

Commit Message:
HARVESTER: Apply Loomis magazine room-script side effects

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/loomis_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 9511f4d4e1c..b108ba3eb96 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -363,6 +363,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `g_text_records` at `0xd5acc` is the `TEXT` list.
 - `g_timer_records` at `0xd5ad0` is the `TIMER` list.
 - `g_useitem_records` at `0xd5ad4` is the `USEITEM` list.
+  - The list is not limited to object hotspots. Confirmed NPC-targeted entries include sheriff-office `INV_MAG -> LOOMIS -> GO_LOOMISA`, which sets `GAVE_MAG_TO_LOOMIS_TODAY` and immediately hides Loomis with `SET_NPC "LOOMIS" "F" "F"`, plus post-office Boyle entries for `BOYLES_BUTTON` and `GASCAN`.
 - `g_pending_room_name` at `0xd60a4` is the pending next-room string consumed by the main loop.
 - `g_player_present_in_room` at `0xc3eb1` gates whether `room_setup` re-spawns the live player from `g_current_room_entrance` or instead injects the synthetic `EXIT_BM` / `EXIT_HS` closeup exits.
 - `g_game_session_active` at `0xc3f08` is the live gameplay-session gate shared by room setup, the save/load menus, and the game-over flow.
diff --git a/TRACKER.md b/TRACKER.md
index 6d08a53f7ad..d5015ba36f6 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, compared the ScummVM Loomis item-use path against both native `handle_talk_to_loomis @ 0x34f80` in Ghidra and the decoded `HARVEST.SCR` sheriff-office records instead of guessing from dialogue alone.
+- Confirmed that the native Loomis talk handler already matches the ScummVM dialogue lines for `INV_MAG`, but the room script also defines `USEITEM "INV_MAG" "SHRFOFC" "LOOMIS" "GO_LOOMISA"`; that action sets `GAVE_MAG_TO_LOOMIS_TODAY` and immediately `SET_NPC "LOOMIS" "F" "F"`, which explained why ScummVM kept Loomis in the room after the magazine handoff.
+- Patched `engines/harvester/npc/loomis_dialogue.cpp` to apply those confirmed room-script side effects during the Loomis magazine branch and queue a mutated-runtime refresh so the live sheriff-office scene drops Loomis after the dialogue, then rebuilt `scummvm` successfully.
 - On March 20, 2026, tightened the current integrated inventory / room-item handoff against the persisted actor runtime instead of inventing a separate inventory state machine: right-clicking confirmed weapon inventory items now toggles the saved player combat loadout ids recovered from the native inventory screen, the open inventory overlay now refreshes its `INV_STAT*` portrait immediately when live player HP changes, and secondary-click while an inventory item is actively selected now cancels that carry/use latch without forcibly closing the panel.
 - Kept the current ScummVM-side carry/use handoff model intact, but bounded it more tightly to the same live actor state that save/load and timer-driven room actions already use: inventory weapon toggles write through `Script::setPlayerCombatLoadout`, and the room loop captures that updated runtime state immediately for later save/menu handoffs.
 - Rebuilt the touched Harvester objects successfully after that inventory/runtime parity pass: `engines/harvester/inventory.o`, `engines/harvester/room.o`, and `scummvm`.
@@ -50,8 +53,9 @@
 
 ## Next Suggested Action
 
-1. Run a manual desktop validation pass that specifically covers the tightened inventory/runtime hooks: verify live HP changes update the open inventory `INV_STAT*` portrait immediately, right-clicking weapon items toggles the persisted combat loadout without desynchronizing save/load state, and closing/reopening the inventory preserves carried-item handoff semantics.
-2. If that pass exposes any remaining inventory gaps, keep them narrowly bounded to confirmed native behaviors only. The next likely candidates are the room HUD weapon-resource icon strip and any still-missing inventory secondary-click item actions beyond the now-confirmed weapon-loadout path.
+1. Run a live Harvester validation pass on the sheriff office magazine handoff and then audit the remaining NPC-targeted `USEITEM` records, especially Boyle's `BOYLES_BUTTON` and `GASCAN` branches, to determine whether any other room-script side effects are still bypassing the current dialogue-first item path.
+2. Run a manual desktop validation pass that specifically covers the tightened inventory/runtime hooks: verify live HP changes update the open inventory `INV_STAT*` portrait immediately, right-clicking weapon items toggles the persisted combat loadout without desynchronizing save/load state, and closing/reopening the inventory preserves carried-item handoff semantics.
+3. If that pass exposes any remaining inventory gaps, keep them narrowly bounded to confirmed native behaviors only. The next likely candidates are the room HUD weapon-resource icon strip and any still-missing inventory secondary-click item actions beyond the now-confirmed weapon-loadout path.
 
 ## Reimplementation Priority Order
 
diff --git a/engines/harvester/npc/loomis_dialogue.cpp b/engines/harvester/npc/loomis_dialogue.cpp
index 931f66c537e..a65ac906df4 100644
--- a/engines/harvester/npc/loomis_dialogue.cpp
+++ b/engines/harvester/npc/loomis_dialogue.cpp
@@ -71,6 +71,15 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.playDialogueLine(0x1229, "LOOMIS");
 	}
 	if (usedItemName.equalsIgnoreCase("INV_MAG")) {
+		if (runtime.currentRoomName().equalsIgnoreCase("SHRFOFC")) {
+			StartupInteractionResult interaction;
+			const bool changedFlag = runtime.startupScript().setRuntimeFlagValue(
+				"GAVE_MAG_TO_LOOMIS_TODAY", true);
+			const bool changedNpc = runtime.startupScript().setRuntimeNpcState("LOOMIS", false, false);
+			interaction.mutatedRuntimeState = changedFlag || changedNpc;
+			runtime.queueDialogueInteractionIfNeeded(interaction);
+		}
+
 		if (runtime.startupScript().getFlagValue("SHERIFF_IN_DINER"))
 			return runtime.playDialogueLine(0x11bb, "LOOMIS");
 


Commit: cf10612c72857d7a857a6aa428351ad6b5693c25
    https://github.com/scummvm/scummvm/commit/cf10612c72857d7a857a6aa428351ad6b5693c25
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:33+02:00

Commit Message:
HARVESTER: Document npc useitem audit results

Changed paths:
    ARCHITECTURE.md
    TRACKER.md


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index b108ba3eb96..8e16274b465 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -363,7 +363,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `g_text_records` at `0xd5acc` is the `TEXT` list.
 - `g_timer_records` at `0xd5ad0` is the `TIMER` list.
 - `g_useitem_records` at `0xd5ad4` is the `USEITEM` list.
-  - The list is not limited to object hotspots. Confirmed NPC-targeted entries include sheriff-office `INV_MAG -> LOOMIS -> GO_LOOMISA`, which sets `GAVE_MAG_TO_LOOMIS_TODAY` and immediately hides Loomis with `SET_NPC "LOOMIS" "F" "F"`, plus post-office Boyle entries for `BOYLES_BUTTON` and `GASCAN`.
+  - The list is not limited to object hotspots. The decoded script contains three confirmed NPC-targeted entries: sheriff-office `INV_MAG -> LOOMIS -> GO_LOOMISA`, which sets `GAVE_MAG_TO_LOOMIS_TODAY` and immediately hides Loomis with `SET_NPC "LOOMIS" "F" "F"`, plus post-office Boyle entries for `BOYLES_BUTTON` and `GASCAN`.
+  - Native handler comparison shows the two Boyle branches are already covered directly inside `handle_talk_to_boyle`, while Loomis was the one script-side room-state gap that ScummVM had to mirror explicitly.
 - `g_pending_room_name` at `0xd60a4` is the pending next-room string consumed by the main loop.
 - `g_player_present_in_room` at `0xc3eb1` gates whether `room_setup` re-spawns the live player from `g_current_room_entrance` or instead injects the synthetic `EXIT_BM` / `EXIT_HS` closeup exits.
 - `g_game_session_active` at `0xc3f08` is the live gameplay-session gate shared by room setup, the save/load menus, and the game-over flow.
diff --git a/TRACKER.md b/TRACKER.md
index d5015ba36f6..4784b88322b 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, rescanned every native startup-room NPC item branch against the decoded `HARVEST.SCR` `USEITEM` table instead of assuming that every NPC-targeted action tag still needed separate ScummVM plumbing.
+- Confirmed there are only three NPC-targeted `USEITEM` records in the shipped script: `INV_MAG -> LOOMIS`, `BOYLES_BUTTON -> BOYLE`, and `GASCAN -> BOYLE`. Ghidra plus the current `handle_talk_to_boyle` port showed Boyle's two cases already carry their native item-side effects inside the dialogue handler, while Loomis was the only remaining script-side room-state gap.
+- No additional engine-side behavior changes were justified after that audit pass; the earlier Loomis patch remains the only required NPC-targeted `USEITEM` fix, and the next safe step is live validation rather than more speculative handler changes.
 - On March 20, 2026, compared the ScummVM Loomis item-use path against both native `handle_talk_to_loomis @ 0x34f80` in Ghidra and the decoded `HARVEST.SCR` sheriff-office records instead of guessing from dialogue alone.
 - Confirmed that the native Loomis talk handler already matches the ScummVM dialogue lines for `INV_MAG`, but the room script also defines `USEITEM "INV_MAG" "SHRFOFC" "LOOMIS" "GO_LOOMISA"`; that action sets `GAVE_MAG_TO_LOOMIS_TODAY` and immediately `SET_NPC "LOOMIS" "F" "F"`, which explained why ScummVM kept Loomis in the room after the magazine handoff.
 - Patched `engines/harvester/npc/loomis_dialogue.cpp` to apply those confirmed room-script side effects during the Loomis magazine branch and queue a mutated-runtime refresh so the live sheriff-office scene drops Loomis after the dialogue, then rebuilt `scummvm` successfully.
@@ -53,7 +56,7 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester validation pass on the sheriff office magazine handoff and then audit the remaining NPC-targeted `USEITEM` records, especially Boyle's `BOYLES_BUTTON` and `GASCAN` branches, to determine whether any other room-script side effects are still bypassing the current dialogue-first item path.
+1. Run a live Harvester validation pass that covers the sheriff-office magazine handoff plus Boyle's button/gascan item branches, confirming that the Loomis disappearance now lands correctly and that the already-ported Boyle logic still matches the native outcomes in gameplay.
 2. Run a manual desktop validation pass that specifically covers the tightened inventory/runtime hooks: verify live HP changes update the open inventory `INV_STAT*` portrait immediately, right-clicking weapon items toggles the persisted combat loadout without desynchronizing save/load state, and closing/reopening the inventory preserves carried-item handoff semantics.
 3. If that pass exposes any remaining inventory gaps, keep them narrowly bounded to confirmed native behaviors only. The next likely candidates are the room HUD weapon-resource icon strip and any still-missing inventory secondary-click item actions beyond the now-confirmed weapon-loadout path.
 


Commit: 657e8c583b47f4fd902932a7d8845f9c6efff3bd
    https://github.com/scummvm/scummvm/commit/657e8c583b47f4fd902932a7d8845f9c6efff3bd
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:33+02:00

Commit Message:
HARVESTER: Fix room turn direction and walk bounds

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/flow.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 8e16274b465..aa2ea7c2443 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1034,6 +1034,11 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `3` / `0xb` are the room-`max_z` locomotion family.
     - `1` / `7` and `2` / `0xe` are the two opposing horizontal locomotion families.
     - `5`, `6`, `9`, `0xa`, `0xc`, `0xd`, `0x10`, and `0x11` are bridging turn/transition handlers between those locomotion families.
+    - Those turn handlers are directional 5-frame banks rather than symmetric bank swaps:
+      `0x05` is up->left (`0x0a..0x0e` forward), `0x06` is left->up (same bank reversed),
+      `0x09` is left->down (`0x19..0x1d` forward), `0x0a` is down->left (same bank reversed),
+      `0x0c` is down->right (`0x28..0x2c` forward), `0x0d` is right->down (same bank reversed),
+      `0x10` is right->up (`0x37..0x3b` forward), and `0x11` is up->right (same bank reversed).
     - `0x16` through `0x1b` are attack states; on a confirmed hit they assign victim states `0x1c` through `0x21` via `attacker_state + 6`, which bounds `0x1c` through `0x21` as hit-reaction states.
     - `0x28` through `0x33` are death-animation entry states. They play the actor death sound, run the final animation bank, and fall into terminal state `0x38`.
     - `0x35` is the NPC death / monsterfy transition state, and `0x38` is the terminal dead / removed state that dispatches `on_death_action_tag`.
@@ -1050,6 +1055,9 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `g_player_attack_pressed` at `0xd5929` is the player-only combat input that selects attack states `0x16` through `0x1b`, and `g_player_attack_cooldown_deadline` at `0xd5a74` rate-limits repeated attack entry.
   - The same wrapper also confirms that live actor field `+0x11bc` is the player combat loadout selection: it is seeded by `spawn_player_combat_avatar`, changed by `set_player_combat_loadout`, and serialized through the save/load path.
   - `g_input_right_shift_pressed` at `0xd5942` is the separate Right Shift modifier gate, not a duplicate attack input.
+  - The wrapper also enforces native horizontal room bounds against the live opaque sprite edges, not an abstract `0..639` center clamp:
+    left-facing states stop when `screen_x + frame_x_offset <= 4`, and right-facing states stop when
+    `screen_x + frame_x_offset + scaled_bitmap->width >= 0x27c`.
   - Under that gate, `g_input_up_pressed` selects states `0x12` / `0x13` by facing, and those states apply horizontal velocity `-16` / `+16`, opposite the current facing family. That bounds `0x12` / `0x13` as fast reverse-step states.
   - The paired down-input states `0x14` / `0x15` are selected from the same modifier gate and use the slower signed horizontal speeds `-8` / `+8`, again opposite the current facing family. That bounds `0x14` / `0x15` as the slower reverse-step pair.
   - States `0x39` and `0x3a` are now bounded as forced horizontal shove states. They install fixed left/right-facing animation banks, drive `x` velocity as `-8` / `+8`, and fall back to locomotion states `1` / `2` once the actor hits world bounds or a blocking entity.
diff --git a/TRACKER.md b/TRACKER.md
index 4784b88322b..5be633a0a15 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,18 +10,25 @@
 
 - Program: `HARVEST.LE`
 - Total functions: `900`
-- Named/documented: `784`
-- Remaining `FUN_*`: `116`
-- Remaining undocumented total: `116`
+- Named/documented: `789`
+- Remaining `FUN_*`: `111`
+- Remaining undocumented total: `111`
 
 ## Last Confirmed Action
 
-- On March 20, 2026, rescanned every native startup-room NPC item branch against the decoded `HARVEST.SCR` `USEITEM` table instead of assuming that every NPC-targeted action tag still needed separate ScummVM plumbing.
-- Confirmed there are only three NPC-targeted `USEITEM` records in the shipped script: `INV_MAG -> LOOMIS`, `BOYLES_BUTTON -> BOYLE`, and `GASCAN -> BOYLE`. Ghidra plus the current `handle_talk_to_boyle` port showed Boyle's two cases already carry their native item-side effects inside the dialogue handler, while Loomis was the only remaining script-side room-state gap.
-- No additional engine-side behavior changes were justified after that audit pass; the earlier Loomis patch remains the only required NPC-targeted `USEITEM` fix, and the next safe step is live validation rather than more speculative handler changes.
+- On March 20, 2026, re-audited room-player locomotion and input-driven turn handling against native `update_player_combat_avatar_state @ 0x553a0` and `update_actor_runtime_state @ 0x4d750` instead of relying on the simplified ScummVM helper alone.
+- Confirmed the native 5-frame turn banks are directional, not symmetric: up->left uses `0x0a..0x0e` forward while left->up reverses that bank; left->down uses `0x19..0x1d` forward while down->left reverses it; down->right uses `0x28..0x2c` forward while right->down reverses it; right->up uses `0x37..0x3b` forward while up->right reverses it. That exposed that `resolvePlayerTurnAnimationRange` in `engines/harvester/flow.cpp` had every recovered turn pair inverted.
+- Confirmed the native room wrapper also clamps horizontal movement against the live opaque sprite edges rather than a raw center `0..639` range: left-facing motion stops once `screen_x + frame_x_offset <= 4`, and right-facing motion stops once `screen_x + frame_x_offset + scaled_bitmap->width >= 0x27c`.
+- Updated the `update_actor_runtime_state` plate comment in Ghidra with that directional turn-bank mapping, saved `HARVEST.LE`, patched `engines/harvester/flow.cpp` to mirror the recovered turn directions plus native horizontal bounds, documented the confirmed rules in `ARCHITECTURE.md`, and rebuilt `scummvm` successfully.
 - On March 20, 2026, compared the ScummVM Loomis item-use path against both native `handle_talk_to_loomis @ 0x34f80` in Ghidra and the decoded `HARVEST.SCR` sheriff-office records instead of guessing from dialogue alone.
 - Confirmed that the native Loomis talk handler already matches the ScummVM dialogue lines for `INV_MAG`, but the room script also defines `USEITEM "INV_MAG" "SHRFOFC" "LOOMIS" "GO_LOOMISA"`; that action sets `GAVE_MAG_TO_LOOMIS_TODAY` and immediately `SET_NPC "LOOMIS" "F" "F"`, which explained why ScummVM kept Loomis in the room after the magazine handoff.
 - Patched `engines/harvester/npc/loomis_dialogue.cpp` to apply those confirmed room-script side effects during the Loomis magazine branch and queue a mutated-runtime refresh so the live sheriff-office scene drops Loomis after the dialogue, then rebuilt `scummvm` successfully.
+- On March 20, 2026, traced the startup room idle-animation branch back to native `run_harvester_main_loop` and confirmed the player idle overlay does not use the previously inferred ScummVM rate of `30`: the `PCLOUN02.ABM` spawn path sets `EBX = 0x0e` immediately before `spawn_scaled_abm_entity_from_resource`, so `engines/harvester/flow.cpp` now uses `kRoomPlayerIdleAnimationRate = 14` to match the original timing.
+- Recorded that recovered native timing detail in `ARCHITECTURE.md` under the confirmed idle-animation notes so the parity fix stays tied to the underlying evidence instead of a visual guess.
+- On March 20, 2026, ran another conservative Ghidra rename pass across the remaining non-image `FUN_*` set and only kept names with direct behavioral proof from callers/callees, data flow, and decompiler output instead of semantic guesses.
+- Saved `HARVEST.LE` after renaming five high-confidence helpers: `set_math_status_flags_and_invoke_callback` (`FUN_0008c5e8`), `compare_extended_values_with_nan_code` (`FUN_0008dcc2`), `convert_uint32_to_extended` (`FUN_0008e12b`), `rewind_runtime_handler_iterator_by_two` (`FUN_0008f64b`), and `debug_break_or_write_console_message_and_exit` (`FUN_0008f368`).
+- The same pass explicitly rejected several tempting but still-ambiguous entries: `FUN_00077e80`, `FUN_000813b4`, and `FUN_000820e8` decompile as stubs only; `FUN_000c55f8` still looks like a bad function boundary / misidentified data block rather than a stable callable function; and the remaining `0x8daee-0x91518` band still needs owner-structure recovery before more precise names are justified.
+- The unresolved buckets are now narrower but unchanged in kind: `FUN_00021100` / `FUN_00021120` are still only proven as paired extent updaters; `FUN_0002e3b4`, `FUN_0002e41b`, `FUN_0002e46a`, `FUN_0002f7fd`, `FUN_000324a2`, and `FUN_0003351b` remain jump-entered dialogue mis-splits; the `FUN_00083fb3` / `FUN_00084a8b` / `FUN_00084b6b` plus `FUN_00087005` through `FUN_00087439` band remains HMIDRV wrapper territory; and the leading `.image::` entries are still zero-xref fragments with no reliable subsystem context.
 - On March 20, 2026, tightened the current integrated inventory / room-item handoff against the persisted actor runtime instead of inventing a separate inventory state machine: right-clicking confirmed weapon inventory items now toggles the saved player combat loadout ids recovered from the native inventory screen, the open inventory overlay now refreshes its `INV_STAT*` portrait immediately when live player HP changes, and secondary-click while an inventory item is actively selected now cancels that carry/use latch without forcibly closing the panel.
 - Kept the current ScummVM-side carry/use handoff model intact, but bounded it more tightly to the same live actor state that save/load and timer-driven room actions already use: inventory weapon toggles write through `Script::setPlayerCombatLoadout`, and the room loop captures that updated runtime state immediately for later save/menu handoffs.
 - Rebuilt the touched Harvester objects successfully after that inventory/runtime parity pass: `engines/harvester/inventory.o`, `engines/harvester/room.o`, and `scummvm`.
@@ -56,9 +63,10 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester validation pass that covers the sheriff-office magazine handoff plus Boyle's button/gascan item branches, confirming that the Loomis disappearance now lands correctly and that the already-ported Boyle logic still matches the native outcomes in gameplay.
-2. Run a manual desktop validation pass that specifically covers the tightened inventory/runtime hooks: verify live HP changes update the open inventory `INV_STAT*` portrait immediately, right-clicking weapon items toggles the persisted combat loadout without desynchronizing save/load state, and closing/reopening the inventory preserves carried-item handoff semantics.
-3. If that pass exposes any remaining inventory gaps, keep them narrowly bounded to confirmed native behaviors only. The next likely candidates are the room HUD weapon-resource icon strip and any still-missing inventory secondary-click item actions beyond the now-confirmed weapon-loadout path.
+1. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
+2. If any room movement mismatch remains after that live pass, continue the same RE thread from `run_harvester_main_loop @ 0x6dc70` by reconstructing the mouse-floor waypoint writes and `DAT_000c3f0c` direction selection, rather than widening the engine logic speculatively.
+3. Run a live Harvester validation pass on the sheriff office magazine handoff and then audit the remaining NPC-targeted `USEITEM` records, especially Boyle's `BOYLES_BUTTON` and `GASCAN` branches, to determine whether any other room-script side effects are still bypassing the current dialogue-first item path.
+4. Reconstruct owner tables and hidden state for the remaining no87/x87 runtime band before attempting another rename pass. The best starting points are the still-unnamed helpers around `FUN_0008c3d8`, `FUN_0008db56`, `FUN_0008dd85`, `FUN_0008df5a`, `FUN_0008e0ab`, and `FUN_00090c10`, using their named callers (`convert_double_to_decimal_digits`, `report_math_error_from_status_flags`, `execute_runtime_module_handler_list`, `compute_extended_*`) to recover structure fields first.
 
 ## Reimplementation Priority Order
 
@@ -78,5 +86,5 @@
    Concrete tasks: validate the new live HP portrait refresh plus weapon-loadout toggles, then decide from a desktop pass whether the remaining native inventory-specific gaps are limited to HUD icon strip restoration or broader item secondary-click actions.
    Exit criteria: any remaining inventory parity work is reduced to a short, explicit list of still-unimplemented native inventory behaviors instead of a broad actor-runtime mismatch.
 4. Leave low-level runtime, HMIDRV, and x87 cleanup as deferred RE work unless they block a confirmed gameplay feature.
-   Ghidra anchors: the remaining unwind helpers around `FUN_0008f64b` through `FUN_000905df`, the HMIDRV wrapper band around `FUN_00087005`, and the x87/no-87 runtime cluster beginning near `FUN_0008c3d8`.
+   Ghidra anchors: the remaining unwind helpers around `rewind_runtime_handler_iterator_by_two @ 0x8f64b` through `FUN_000905df`, the HMIDRV wrapper band around `FUN_00087005`, and the x87/no-87 runtime cluster beginning near `FUN_0008c3d8`.
    Rationale: those clusters still matter for naming completeness, but the current engine-side gaps are better bounded and have higher reimplementation value.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 8206d62bdfa..750b03c472a 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -81,6 +81,8 @@ static const float kRoomPlayerHorizontalMoveBase = 8.0f;
 static const int kRoomRegionTargetXBias = 10;
 static const float kRoomDepthCompareEpsilon = 0.01f;
 static const int kRoomPlayerWalkAnimationRate = 17;
+static const int kRoomPlayerMinOpaqueLeftX = 4;
+static const int kRoomPlayerMaxOpaqueRightX = 0x27c;
 static const int kRoomNpcAmbientLastFrame = 0x3b;
 static const int kTownMapEdgeThreshold = 9;
 static const int kTownMapCursorHitExtent = 5;
@@ -392,37 +394,37 @@ static PlayerAnimationRange resolvePlayerAnimationRange(int facing) {
 }
 
 static bool resolvePlayerTurnAnimationRange(int fromFacing, int toFacing, PlayerTurnAnimationRange &range) {
-	// Ghidra confirms stationary startup keyboard turn states between the locomotion banks.
+	// Native desired-state transitions play these banks forward or backward depending on the source facing.
 	if (fromFacing == 0 && toFacing == 1) {
-		range = PlayerTurnAnimationRange{ 0x0a, 0x0e, true };
+		range = PlayerTurnAnimationRange{ 0x0a, 0x0e, false };
 		return true;
 	}
 	if (fromFacing == 1 && toFacing == 0) {
-		range = PlayerTurnAnimationRange{ 0x0a, 0x0e, false };
+		range = PlayerTurnAnimationRange{ 0x0a, 0x0e, true };
 		return true;
 	}
 	if (fromFacing == 1 && toFacing == 3) {
-		range = PlayerTurnAnimationRange{ 0x19, 0x1d, true };
+		range = PlayerTurnAnimationRange{ 0x19, 0x1d, false };
 		return true;
 	}
 	if (fromFacing == 3 && toFacing == 1) {
-		range = PlayerTurnAnimationRange{ 0x19, 0x1d, false };
+		range = PlayerTurnAnimationRange{ 0x19, 0x1d, true };
 		return true;
 	}
 	if (fromFacing == 3 && toFacing == 2) {
-		range = PlayerTurnAnimationRange{ 0x28, 0x2c, true };
+		range = PlayerTurnAnimationRange{ 0x28, 0x2c, false };
 		return true;
 	}
 	if (fromFacing == 2 && toFacing == 3) {
-		range = PlayerTurnAnimationRange{ 0x28, 0x2c, false };
+		range = PlayerTurnAnimationRange{ 0x28, 0x2c, true };
 		return true;
 	}
 	if (fromFacing == 2 && toFacing == 0) {
-		range = PlayerTurnAnimationRange{ 0x37, 0x3b, true };
+		range = PlayerTurnAnimationRange{ 0x37, 0x3b, false };
 		return true;
 	}
 	if (fromFacing == 0 && toFacing == 2) {
-		range = PlayerTurnAnimationRange{ 0x37, 0x3b, false };
+		range = PlayerTurnAnimationRange{ 0x37, 0x3b, true };
 		return true;
 	}
 
@@ -557,6 +559,23 @@ static float computeRoomPlayerDepthStep(const StartupRoomSetupState &state) {
 	return state.roomZVelocityStep > 0.0f ? state.roomZVelocityStep : 1.0f;
 }
 
+static int clampPlayerCenterXToNativeBounds(const StartupRoomPlayerState &playerState, int centerX) {
+	if (!playerState.entity)
+		return CLIP<int>(centerX, 0, 639);
+
+	int width = 0;
+	int height = 0;
+	int xOffset = 0;
+	int yOffset = 0;
+	if (!playerState.entity->getCurrentFrameMetrics(width, height, xOffset, yOffset))
+		return CLIP<int>(centerX, 0, 639);
+
+	const int minCenterX = kRoomPlayerMinOpaqueLeftX + width / 2;
+	const int maxCenterX = MAX(minCenterX,
+		kRoomPlayerMaxOpaqueRightX - (width - width / 2));
+	return CLIP<int>(centerX, minCenterX, maxCenterX);
+}
+
 uint32 getRuntimeClockTicks();
 
 static uint32 computeAnimationTickInterval(int rate) {
@@ -647,7 +666,7 @@ static bool tryApplyPlayerMovement(HarvesterEngine &engine, const StartupRoomSet
 	if (!playerState.entity)
 		return false;
 
-	candidateCenterX = CLIP<int>(candidateCenterX, 0, 639);
+	candidateCenterX = clampPlayerCenterXToNativeBounds(playerState, candidateCenterX);
 	candidateZ = clampRoomDepth(state, candidateZ);
 	if (candidateCenterX == playerState.centerX &&
 			fabsf(candidateZ - playerState.z) <= kRoomDepthCompareEpsilon) {
@@ -669,7 +688,7 @@ void setPlayerMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerSt
 		int targetX, float targetZ) {
 	playerState.hasMoveTarget = true;
 	playerState.nextMovementTick = 0;
-	playerState.targetX = CLIP<int>(targetX, 0, 639);
+	playerState.targetX = clampPlayerCenterXToNativeBounds(playerState, targetX);
 	playerState.targetZ = clampRoomDepth(state, targetZ);
 	debugC(1, kDebugScene,
 		"Harvester: player move target room='%s' current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f)",
@@ -1411,7 +1430,8 @@ bool stepPlayerKeyboardMovement(HarvesterEngine &engine, const StartupRoomSetupS
 	const float previousZ = playerState.z;
 	const int horizontalStep = computeRoomPlayerHorizontalStep(state, playerState.z);
 	const float depthStep = computeRoomPlayerDepthStep(state);
-	int candidateCenterX = CLIP<int>(playerState.centerX + horizontalInput * horizontalStep, 0, 639);
+	int candidateCenterX = clampPlayerCenterXToNativeBounds(
+		playerState, playerState.centerX + horizontalInput * horizontalStep);
 	float candidateZ = playerState.z;
 	if (verticalInput != 0) {
 		const float positiveZ = clampRoomDepth(state, playerState.z + depthStep);


Commit: eb83fd482e5a33eccb42993392b74e0c69d0be6e
    https://github.com/scummvm/scummvm/commit/eb83fd482e5a33eccb42993392b74e0c69d0be6e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:34+02:00

Commit Message:
HARVESTER: Route NPC item uses through script flow

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index 5be633a0a15..9026be0c247 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, traced the room inventory-click path against the existing Loomis notes instead of layering more Loomis-specific special cases onto `handle_talk_to_loomis`.
+- Confirmed the broader parity gap was room-side: selected inventory items clicked on NPCs bypassed `HARVEST.SCR` `USEITEM` records entirely and jumped straight into `runRoomNpcDialogue`, which meant NPC-targeted script actions such as sheriff-office `INV_MAG -> LOOMIS -> GO_LOOMISA` could not drive their `START_DIALOG`/continuation flow or keep `g_dialogue_presented_object_name` semantics intact.
+- Patched `engines/harvester/script.{h,cpp}` and `engines/harvester/room.cpp` to resolve NPC-targeted `USEITEM` records, preserve the selected item name through deferred scripted dialogue/continuation handling, and only fall back to direct NPC dialogue when no matching NPC `USEITEM` record exists; rebuilt `engines/harvester/script.o`, `engines/harvester/room.o`, and `scummvm` successfully.
 - On March 20, 2026, re-audited room-player locomotion and input-driven turn handling against native `update_player_combat_avatar_state @ 0x553a0` and `update_actor_runtime_state @ 0x4d750` instead of relying on the simplified ScummVM helper alone.
 - Confirmed the native 5-frame turn banks are directional, not symmetric: up->left uses `0x0a..0x0e` forward while left->up reverses that bank; left->down uses `0x19..0x1d` forward while down->left reverses it; down->right uses `0x28..0x2c` forward while right->down reverses it; right->up uses `0x37..0x3b` forward while up->right reverses it. That exposed that `resolvePlayerTurnAnimationRange` in `engines/harvester/flow.cpp` had every recovered turn pair inverted.
 - Confirmed the native room wrapper also clamps horizontal movement against the live opaque sprite edges rather than a raw center `0..639` range: left-facing motion stops once `screen_x + frame_x_offset <= 4`, and right-facing motion stops once `screen_x + frame_x_offset + scaled_bitmap->width >= 0x27c`.
@@ -63,10 +66,10 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
-2. If any room movement mismatch remains after that live pass, continue the same RE thread from `run_harvester_main_loop @ 0x6dc70` by reconstructing the mouse-floor waypoint writes and `DAT_000c3f0c` direction selection, rather than widening the engine logic speculatively.
-3. Run a live Harvester validation pass on the sheriff office magazine handoff and then audit the remaining NPC-targeted `USEITEM` records, especially Boyle's `BOYLES_BUTTON` and `GASCAN` branches, to determine whether any other room-script side effects are still bypassing the current dialogue-first item path.
-4. Reconstruct owner tables and hidden state for the remaining no87/x87 runtime band before attempting another rename pass. The best starting points are the still-unnamed helpers around `FUN_0008c3d8`, `FUN_0008db56`, `FUN_0008dd85`, `FUN_0008df5a`, `FUN_0008e0ab`, and `FUN_00090c10`, using their named callers (`convert_double_to_decimal_digits`, `report_math_error_from_status_flags`, `execute_runtime_module_handler_list`, `compute_extended_*`) to recover structure fields first.
+1. Run a live Harvester validation pass on NPC-targeted `USEITEM` dialogue, starting with the sheriff-office Loomis magazine handoff, to confirm the recovered script path now surfaces the expected confirmation dialogue/options and only sets/hides Loomis after the accepted branch completes.
+2. Audit the remaining NPC-targeted `USEITEM` records, especially Boyle's `BOYLES_BUTTON` and `GASCAN` branches, to determine whether any additional room-script side effects or deferred dialogue continuations still need explicit parity fixes.
+3. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
+4. If any room movement mismatch remains after that live pass, continue the same RE thread from `run_harvester_main_loop @ 0x6dc70` by reconstructing the mouse-floor waypoint writes and `DAT_000c3f0c` direction selection, rather than widening the engine logic speculatively.
 
 ## Reimplementation Priority Order
 
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index a80657e5042..de8c105acdd 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -648,7 +648,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			decltype(resetIdleState) &resetIdleStateFn;
 
 			Common::Error handleInteractionResult(const StartupInteractionResult &interaction,
-					bool &didTransition) {
+					bool &didTransition, const Common::String &usedItemName) {
 				didTransition = false;
 				playerState.hasMoveTarget = false;
 				playerState.turnActive = false;
@@ -747,7 +747,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 				if (!interaction.dialogueNpcName.empty()) {
 					Common::Error dialogueError = runScriptedDialogue(
-						interaction.dialogueNpcName, Common::String(), interaction.dialogueContinuationTag,
+						interaction.dialogueNpcName, usedItemName, interaction.dialogueContinuationTag,
 						didTransition);
 					if (dialogueError.getCode() != Common::kNoError)
 						return dialogueError;
@@ -761,7 +761,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					if (engine.getStartupScript()->executeActionTag(
 							interaction.continuationTag, continuationInteraction)) {
 						Common::Error interactionError =
-							handleInteractionResult(continuationInteraction, didTransition);
+							handleInteractionResult(continuationInteraction, didTransition, usedItemName);
 						if (interactionError.getCode() != Common::kNoError)
 							return interactionError;
 					}
@@ -794,7 +794,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				bool abortRemainingCommandChain = false;
 				if (startupFlow.takeQueuedDialogueInteraction(dialogueInteraction)) {
 					abortRemainingCommandChain = dialogueInteraction.abortRemainingCommandChain;
-					Common::Error interactionError = handleInteractionResult(dialogueInteraction, didTransition);
+					Common::Error interactionError =
+						handleInteractionResult(dialogueInteraction, didTransition, usedItemName);
 					if (interactionError.getCode() != Common::kNoError)
 						return interactionError;
 					if (startupFlow.hasPendingMainMenuReturn())
@@ -804,7 +805,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					StartupInteractionResult continuationInteraction;
 					if (engine.getStartupScript()->executeActionTag(continuationTag, continuationInteraction)) {
 						Common::Error interactionError =
-							handleInteractionResult(continuationInteraction, didTransition);
+							handleInteractionResult(continuationInteraction, didTransition, usedItemName);
 						if (interactionError.getCode() != Common::kNoError)
 							return interactionError;
 						if (startupFlow.hasPendingMainMenuReturn())
@@ -837,7 +838,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			StartupInteractionResult pickupInteraction;
 			if (startupScript->executeActionTag(object.actionTag, pickupInteraction)) {
 				Common::Error interactionError =
-					interactionProcessor.handleInteractionResult(pickupInteraction, didTransition);
+					interactionProcessor.handleInteractionResult(
+						pickupInteraction, didTransition, Common::String());
 				if (interactionError.getCode() != Common::kNoError)
 					return interactionError;
 				if (startupFlow.hasPendingMainMenuReturn())
@@ -872,7 +874,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			StartupInteractionResult pickupInteraction;
 			if (startupScript->executeActionTag(object.actionTag, pickupInteraction)) {
 				Common::Error interactionError =
-					interactionProcessor.handleInteractionResult(pickupInteraction, didTransition);
+					interactionProcessor.handleInteractionResult(
+						pickupInteraction, didTransition, Common::String());
 				if (interactionError.getCode() != Common::kNoError)
 					return interactionError;
 				if (startupFlow.hasPendingMainMenuReturn())
@@ -894,14 +897,16 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		if (!_inventory.hasSelection())
 			return Common::kNoError;
 
+		const Common::String selectedItemName = _inventory.getSelectedItemName();
 		StartupInteractionResult interaction;
 		const bool handled = _engine.getStartupScript()->resolveUseItemInteraction(
-			_inventory.getSelectedItemName(), target, interaction);
+			selectedItemName, target, interaction);
 		if (!handled)
 			return Common::kNoError;
 
 		bool didTransition = false;
-		Common::Error interactionError = interactionProcessor.handleInteractionResult(interaction, didTransition);
+		Common::Error interactionError =
+			interactionProcessor.handleInteractionResult(interaction, didTransition, selectedItemName);
 		if (interactionError.getCode() != Common::kNoError)
 			return interactionError;
 		_inventory.clearSelection();
@@ -931,7 +936,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 		bool didTransition = false;
 		Common::Error interactionError =
-			interactionProcessor.handleInteractionResult(interaction, didTransition);
+			interactionProcessor.handleInteractionResult(
+				interaction, didTransition, Common::String());
 		if (interactionError.getCode() != Common::kNoError)
 			return interactionError;
 		if (startupFlow.hasPendingMainMenuReturn())
@@ -1246,13 +1252,20 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					break;
 				}
 				if (_inventory.hasSelection()) {
+					const Common::String selectedItemName = _inventory.getSelectedItemName();
 					if (hoverState.npc) {
+						StartupInteractionResult interaction;
 						bool didTransition = false;
-						Common::Error dialogueError = interactionProcessor.runScriptedDialogue(
-							hoverState.npc->npcName, _inventory.getSelectedItemName(),
-							Common::String(), didTransition);
-						if (dialogueError.getCode() != Common::kNoError)
-							return dialogueError;
+						// Some NPC item uses are script-driven and only fall back to direct dialogue otherwise.
+						const bool handled = _engine.getStartupScript()->resolveUseItemInteraction(
+							selectedItemName, *hoverState.npc, interaction);
+						Common::Error interactionError = handled
+							? interactionProcessor.handleInteractionResult(
+								interaction, didTransition, selectedItemName)
+							: interactionProcessor.runScriptedDialogue(
+								hoverState.npc->npcName, selectedItemName, Common::String(), didTransition);
+						if (interactionError.getCode() != Common::kNoError)
+							return interactionError;
 						if (startupFlow.hasPendingMainMenuReturn())
 							return Common::kNoError;
 						if (_inventory.clearSelection())
@@ -1391,7 +1404,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 				bool didTransition = false;
 				Common::Error interactionError =
-					interactionProcessor.handleInteractionResult(interaction, didTransition);
+					interactionProcessor.handleInteractionResult(
+						interaction, didTransition, Common::String());
 				if (interactionError.getCode() != Common::kNoError)
 					return interactionError;
 				if (startupFlow.hasPendingMainMenuReturn())
@@ -1585,7 +1599,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 					bool didTransition = false;
 					Common::Error interactionError =
-						interactionProcessor.handleInteractionResult(timerInteraction, didTransition);
+						interactionProcessor.handleInteractionResult(
+							timerInteraction, didTransition, Common::String());
 					if (interactionError.getCode() != Common::kNoError)
 						return interactionError;
 					if (startupFlow.hasPendingMainMenuReturn())
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 8af25de30ad..b39d9234fda 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1291,6 +1291,24 @@ bool Script::resolveUseItemInteraction(const Common::String &itemName, const Sta
 	return true;
 }
 
+bool Script::resolveUseItemInteraction(const Common::String &itemName, const StartupNpcRecord &target,
+		StartupInteractionResult &result) {
+	result = StartupInteractionResult();
+
+	const StartupUseItemRecord *useItem = findUseItemRecord(itemName, target);
+	if (!useItem)
+		return false;
+
+	executeCommandChain(useItem->actionTag, "useitem command",
+		Common::String::format("%s -> %s", itemName.c_str(), target.npcName.c_str()), true,
+		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
+		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu,
+		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
+		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
+		&result.playerGotoX, &result.playerGotoZ, &result.mutatedRuntimeState);
+	return true;
+}
+
 bool Script::executeActionTag(const Common::String &tag, StartupInteractionResult &result,
 		bool allowTransitions) {
 	result = StartupInteractionResult();
@@ -1417,6 +1435,28 @@ const StartupUseItemRecord *Script::findUseItemRecord(const Common::String &item
 	return nullptr;
 }
 
+const StartupUseItemRecord *Script::findUseItemRecord(const Common::String &itemName,
+		const StartupNpcRecord &target) const {
+	if (itemName.empty() || target.npcName.empty())
+		return nullptr;
+
+	for (const StartupUseItemRecord &useItem : _useItems) {
+		if (!useItem.itemName.equalsIgnoreCase(itemName) ||
+			!useItem.targetName.equalsIgnoreCase(target.npcName)) {
+			continue;
+		}
+
+		if (!useItem.ownerOrRoom.empty() &&
+			!useItem.ownerOrRoom.equalsIgnoreCase(target.roomName)) {
+			continue;
+		}
+
+		return &useItem;
+	}
+
+	return nullptr;
+}
+
 const StartupRoomRecord *Script::findRoomRecord(const Common::String &roomName) const {
 	if (roomName.empty())
 		return nullptr;
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 5c11fc92ccc..027156bea51 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -389,6 +389,8 @@ public:
 		bool resolveRegionInteraction(const StartupRegionRecord &region, StartupInteractionResult &result);
 		bool resolveUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target,
 			StartupInteractionResult &result);
+		bool resolveUseItemInteraction(const Common::String &itemName, const StartupNpcRecord &target,
+			StartupInteractionResult &result);
 		bool executeActionTag(const Common::String &tag, StartupInteractionResult &result,
 			bool allowTransitions = true);
 		bool executeNestedActionTag(const Common::String &tag, StartupInteractionResult &result,
@@ -433,7 +435,10 @@ private:
 	const StartupCommandRecord *findCommandRecord(const Common::String &tag) const;
 	const StartupExecListRecord *findExecListRecord(const Common::String &name) const;
 	const StartupTextRecord *findTextRecord(const Common::String &key) const;
-	const StartupUseItemRecord *findUseItemRecord(const Common::String &itemName, const StartupObjectRecord &target) const;
+	const StartupUseItemRecord *findUseItemRecord(const Common::String &itemName,
+		const StartupObjectRecord &target) const;
+	const StartupUseItemRecord *findUseItemRecord(const Common::String &itemName,
+		const StartupNpcRecord &target) const;
 	const StartupFlagRecord *findRuntimeFlag(const Common::String &flagName) const;
 	StartupFlagRecord *findRuntimeFlag(const Common::String &flagName);
 	StartupObjectRecord *findRuntimeObject(const Common::String &ownerOrRoom, const Common::String &objectName);


Commit: 717bbc05a767f3313981a31ea14acb263ec5ab72
    https://github.com/scummvm/scummvm/commit/717bbc05a767f3313981a31ea14acb263ec5ab72
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:34+02:00

Commit Message:
HARVESTER: Restore native NPC item dialogue routing

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index 9026be0c247..ce379c05e4d 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, rechecked the Loomis sheriff-office handoff against native `handle_target_interaction @ 0x7ff50` after the immediate-disappear regression instead of assuming NPC-targeted `USEITEM` records were the direct room-click entry point.
+- Confirmed the native room loop treats class-4 NPC clicks differently from object hotspots: item-on-NPC interaction calls `run_npc_dialogue(npcName, presentedObject)` directly, which seeds `g_dialogue_presented_object_name` before the talk handler runs, while the generic `USEITEM` record scan is only reached for non-NPC targets.
+- Reverted the generic NPC `USEITEM` interception in `engines/harvester/room.cpp` and removed the now-unneeded NPC overloads from `engines/harvester/script.{h,cpp}` so Loomis item use again enters the native-style dialogue path first; rebuilt `engines/harvester/script.o`, `engines/harvester/room.o`, and `scummvm` successfully.
 - On March 20, 2026, traced the room inventory-click path against the existing Loomis notes instead of layering more Loomis-specific special cases onto `handle_talk_to_loomis`.
 - Confirmed the broader parity gap was room-side: selected inventory items clicked on NPCs bypassed `HARVEST.SCR` `USEITEM` records entirely and jumped straight into `runRoomNpcDialogue`, which meant NPC-targeted script actions such as sheriff-office `INV_MAG -> LOOMIS -> GO_LOOMISA` could not drive their `START_DIALOG`/continuation flow or keep `g_dialogue_presented_object_name` semantics intact.
 - Patched `engines/harvester/script.{h,cpp}` and `engines/harvester/room.cpp` to resolve NPC-targeted `USEITEM` records, preserve the selected item name through deferred scripted dialogue/continuation handling, and only fall back to direct NPC dialogue when no matching NPC `USEITEM` record exists; rebuilt `engines/harvester/script.o`, `engines/harvester/room.o`, and `scummvm` successfully.
@@ -66,10 +69,10 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester validation pass on NPC-targeted `USEITEM` dialogue, starting with the sheriff-office Loomis magazine handoff, to confirm the recovered script path now surfaces the expected confirmation dialogue/options and only sets/hides Loomis after the accepted branch completes.
-2. Audit the remaining NPC-targeted `USEITEM` records, especially Boyle's `BOYLES_BUTTON` and `GASCAN` branches, to determine whether any additional room-script side effects or deferred dialogue continuations still need explicit parity fixes.
-3. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
-4. If any room movement mismatch remains after that live pass, continue the same RE thread from `run_harvester_main_loop @ 0x6dc70` by reconstructing the mouse-floor waypoint writes and `DAT_000c3f0c` direction selection, rather than widening the engine logic speculatively.
+1. Run a live Harvester validation pass on the Loomis sheriff-office magazine handoff to re-establish the correct entry path, then trace which post-dialogue side effect still gates the expected confirmation/follow-up exchange before any `SET_FLAG` / `SET_NPC` hide happens.
+2. Re-audit native `handle_talk_to_loomis @ 0x34f80` against the live subtitles/response menus to determine where the missing Loomis confirmation dialogue and option mapping actually live, rather than assuming the room-script `GO_LOOMISA` tag owns that branch.
+3. Only after that Loomis path is bounded again, audit the remaining NPC-targeted `USEITEM` records, especially Boyle's `BOYLES_BUTTON` and `GASCAN` branches, to determine whether they are post-dialogue side effects, non-NPC target entries, or true unused script leftovers.
+4. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
 
 ## Reimplementation Priority Order
 
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index de8c105acdd..bbfe0b056b1 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1254,18 +1254,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				if (_inventory.hasSelection()) {
 					const Common::String selectedItemName = _inventory.getSelectedItemName();
 					if (hoverState.npc) {
-						StartupInteractionResult interaction;
 						bool didTransition = false;
-						// Some NPC item uses are script-driven and only fall back to direct dialogue otherwise.
-						const bool handled = _engine.getStartupScript()->resolveUseItemInteraction(
-							selectedItemName, *hoverState.npc, interaction);
-						Common::Error interactionError = handled
-							? interactionProcessor.handleInteractionResult(
-								interaction, didTransition, selectedItemName)
-							: interactionProcessor.runScriptedDialogue(
-								hoverState.npc->npcName, selectedItemName, Common::String(), didTransition);
-						if (interactionError.getCode() != Common::kNoError)
-							return interactionError;
+						Common::Error dialogueError = interactionProcessor.runScriptedDialogue(
+							hoverState.npc->npcName, selectedItemName, Common::String(), didTransition);
+						if (dialogueError.getCode() != Common::kNoError)
+							return dialogueError;
 						if (startupFlow.hasPendingMainMenuReturn())
 							return Common::kNoError;
 						if (_inventory.clearSelection())
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index b39d9234fda..8af25de30ad 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1291,24 +1291,6 @@ bool Script::resolveUseItemInteraction(const Common::String &itemName, const Sta
 	return true;
 }
 
-bool Script::resolveUseItemInteraction(const Common::String &itemName, const StartupNpcRecord &target,
-		StartupInteractionResult &result) {
-	result = StartupInteractionResult();
-
-	const StartupUseItemRecord *useItem = findUseItemRecord(itemName, target);
-	if (!useItem)
-		return false;
-
-	executeCommandChain(useItem->actionTag, "useitem command",
-		Common::String::format("%s -> %s", itemName.c_str(), target.npcName.c_str()), true,
-		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
-		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu,
-		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
-		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
-		&result.playerGotoX, &result.playerGotoZ, &result.mutatedRuntimeState);
-	return true;
-}
-
 bool Script::executeActionTag(const Common::String &tag, StartupInteractionResult &result,
 		bool allowTransitions) {
 	result = StartupInteractionResult();
@@ -1435,28 +1417,6 @@ const StartupUseItemRecord *Script::findUseItemRecord(const Common::String &item
 	return nullptr;
 }
 
-const StartupUseItemRecord *Script::findUseItemRecord(const Common::String &itemName,
-		const StartupNpcRecord &target) const {
-	if (itemName.empty() || target.npcName.empty())
-		return nullptr;
-
-	for (const StartupUseItemRecord &useItem : _useItems) {
-		if (!useItem.itemName.equalsIgnoreCase(itemName) ||
-			!useItem.targetName.equalsIgnoreCase(target.npcName)) {
-			continue;
-		}
-
-		if (!useItem.ownerOrRoom.empty() &&
-			!useItem.ownerOrRoom.equalsIgnoreCase(target.roomName)) {
-			continue;
-		}
-
-		return &useItem;
-	}
-
-	return nullptr;
-}
-
 const StartupRoomRecord *Script::findRoomRecord(const Common::String &roomName) const {
 	if (roomName.empty())
 		return nullptr;
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 027156bea51..fcc38542d97 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -389,8 +389,6 @@ public:
 		bool resolveRegionInteraction(const StartupRegionRecord &region, StartupInteractionResult &result);
 		bool resolveUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target,
 			StartupInteractionResult &result);
-		bool resolveUseItemInteraction(const Common::String &itemName, const StartupNpcRecord &target,
-			StartupInteractionResult &result);
 		bool executeActionTag(const Common::String &tag, StartupInteractionResult &result,
 			bool allowTransitions = true);
 		bool executeNestedActionTag(const Common::String &tag, StartupInteractionResult &result,
@@ -437,8 +435,6 @@ private:
 	const StartupTextRecord *findTextRecord(const Common::String &key) const;
 	const StartupUseItemRecord *findUseItemRecord(const Common::String &itemName,
 		const StartupObjectRecord &target) const;
-	const StartupUseItemRecord *findUseItemRecord(const Common::String &itemName,
-		const StartupNpcRecord &target) const;
 	const StartupFlagRecord *findRuntimeFlag(const Common::String &flagName) const;
 	StartupFlagRecord *findRuntimeFlag(const Common::String &flagName);
 	StartupObjectRecord *findRuntimeObject(const Common::String &ownerOrRoom, const Common::String &objectName);


Commit: 3ac565a66ff3f2637e287f075f1e4d06baa5a4a6
    https://github.com/scummvm/scummvm/commit/3ac565a66ff3f2637e287f075f1e4d06baa5a4a6
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:34+02:00

Commit Message:
HARVESTER: Restore Loomis sheriff dialogue flow

Changed paths:
    TRACKER.md
    engines/harvester/npc/loomis_dialogue.cpp
    engines/harvester/npc/loomis_dialogue.h


diff --git a/TRACKER.md b/TRACKER.md
index ce379c05e4d..bded9284ebd 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,10 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, re-opened the Loomis conversation branch with the native subtitles plus `DIALOG.RSP` instead of assuming the sheriff-office magazine problem was only a room-script side effect.
+- Confirmed the missing Loomis response mapping from the shipped text resources: `DIALOG.RSP` line `468` is the magazine confirmation menu (`"1. Sure... you can have it!/2. No, just thought you'd like a peek."`), while nearby `DIALOGUE.IDX` lines `4539`, `4550`, `4556`, and `4571` are the ask / accept / refuse / sheriff-present guard lines for the magazine handoff.
+- Confirmed the first Loomis no-item conversation was also underimplemented in ScummVM: the subtitle block around `4349..4502` plus `DIALOG.RSP` lines `84..102` exposes the missing sheriff-office topic menu (`Stephanie`, `Mrs. Loomis`, `Mrs. Phelps`, `A Man's Needs`, `French Postcards`, `Sheriff Dwayne`, `Mattress`) and the follow-up `4520/4524/4528` revisit exchange that the simplified handler never ran.
+- Patched `engines/harvester/npc/loomis_dialogue.{h,cpp}` to restore that topic-menu/follow-up flow, route the magazine branch through the recovered confirmation response menu, and only set `GAVE_MAG_TO_LOOMIS_TODAY` / hide Loomis on the accepted branch; the accept path also now flips the sheriff-office drawer pair to `SHERIF_DRAWR` visible and `SHERIF_DRAWR2` hidden so the post-handoff drawer interaction can surface in the refreshed room state. Rebuilt `engines/harvester/npc/loomis_dialogue.o` and `scummvm` successfully.
 - On March 20, 2026, rechecked the Loomis sheriff-office handoff against native `handle_target_interaction @ 0x7ff50` after the immediate-disappear regression instead of assuming NPC-targeted `USEITEM` records were the direct room-click entry point.
 - Confirmed the native room loop treats class-4 NPC clicks differently from object hotspots: item-on-NPC interaction calls `run_npc_dialogue(npcName, presentedObject)` directly, which seeds `g_dialogue_presented_object_name` before the talk handler runs, while the generic `USEITEM` record scan is only reached for non-NPC targets.
 - Reverted the generic NPC `USEITEM` interception in `engines/harvester/room.cpp` and removed the now-unneeded NPC overloads from `engines/harvester/script.{h,cpp}` so Loomis item use again enters the native-style dialogue path first; rebuilt `engines/harvester/script.o`, `engines/harvester/room.o`, and `scummvm` successfully.
@@ -69,9 +73,9 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester validation pass on the Loomis sheriff-office magazine handoff to re-establish the correct entry path, then trace which post-dialogue side effect still gates the expected confirmation/follow-up exchange before any `SET_FLAG` / `SET_NPC` hide happens.
-2. Re-audit native `handle_talk_to_loomis @ 0x34f80` against the live subtitles/response menus to determine where the missing Loomis confirmation dialogue and option mapping actually live, rather than assuming the room-script `GO_LOOMISA` tag owns that branch.
-3. Only after that Loomis path is bounded again, audit the remaining NPC-targeted `USEITEM` records, especially Boyle's `BOYLES_BUTTON` and `GASCAN` branches, to determine whether they are post-dialogue side effects, non-NPC target entries, or true unused script leftovers.
+1. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the magazine accept/refuse menu all appear in the right order and that accepting the magazine now both hides Loomis and exposes the real drawer hotspot.
+2. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
+3. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
 4. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
 
 ## Reimplementation Priority Order
diff --git a/engines/harvester/npc/loomis_dialogue.cpp b/engines/harvester/npc/loomis_dialogue.cpp
index a65ac906df4..d96818e1d97 100644
--- a/engines/harvester/npc/loomis_dialogue.cpp
+++ b/engines/harvester/npc/loomis_dialogue.cpp
@@ -28,6 +28,12 @@
 
 namespace Harvester {
 
+namespace {
+
+static const int kLoomisSheriffTopicLineIndices[] = { 0x5f, 0x60, 0x61 };
+
+} // End of namespace
+
 bool LoomisDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("LOOMIS");
 }
@@ -35,24 +41,140 @@ bool LoomisDialogueHandler::matchesNpc(const Common::String &npcName) const {
 Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &) {
 	LoomisRoomDialogueState &state = _state;
+	Common::String &loomisTopicBuffer = state.currentTopicBuffer;
+	int &loomisTopicBufferLineIndex = state.currentTopicBufferLineIndex;
+
+	auto assignLoomisTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(loomisTopicBuffer, loomisTopicBufferLineIndex,
+			responseLineIndex, "Loomis topic buffer");
+	};
+	auto playLoomisLine = [&](int wavId, const char *speakerId = "LOOMIS") -> Common::Error {
+		return runtime.playDialogueLine(wavId, speakerId);
+	};
+	auto clearLoomisTopicBuffer = [&]() {
+		loomisTopicBuffer.clear();
+		loomisTopicBufferLineIndex = -1;
+	};
 
 	if (usedItemName.empty()) {
 		if (!state.talkStatePending) {
 			if (state.returnVisitFollowupPending) {
 				state.returnVisitFollowupPending = false;
-				return runtime.playDialogueLine(0x11a8, "LOOMIS");
+				const DialogueLineEntry followupLines[] = {
+					{ 0x11a8, "LOOMIS", 0 },
+					{ 0x11ac, "PC", 0 },
+					{ 0x11b0, "LOOMIS", 0 }
+				};
+				return runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
 			}
 			return runtime.playDialogueLine(0x11f6, "LOOMIS");
 		}
 
 		state.talkStatePending = false;
-		state.returnVisitFollowupPending = true;
 		if (!runtime.startupScript().getFlagValue("DWAYNE_INTRODUCED")) {
-			Common::Error lineError = runtime.playDialogueLine(0x10fd, "LOOMIS");
+			const DialogueLineEntry introLines[] = {
+				{ 0x10fd, "LOOMIS", 0 },
+				{ 0x1101, "PC", 0 },
+				{ 0x1105, "LOOMIS", 0 },
+				{ 0x110a, "PC", 0 },
+				{ 0x110e, "LOOMIS", 0 },
+				{ 0x1112, "PC", 0 },
+				{ runtime.startupScript().getFlagValue("SHERIFF_IN_DINER") ? 0x1118 : 0x111e, "LOOMIS", 0 },
+				{ 0x1121, "LOOMIS", 0 }
+			};
+			Common::Error lineError = runtime.playDialogueEntrySequence(introLines, ARRAYSIZE(introLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else {
+			Common::Error lineError = runtime.playDialogueLine(0x1121, "LOOMIS");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
-		return runtime.playDialogueLine(0x1121, "LOOMIS");
+
+		Common::Error lineError = playLoomisLine(0x1130);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		assignLoomisTopicBuffer(0x54);
+		for (;;) {
+			Common::String selectedTopic;
+			Common::Error menuError = runtime.runKeywordMenu(
+				loomisTopicBuffer, loomisTopicBufferLineIndex, selectedTopic);
+			if (menuError.getCode() != Common::kNoError)
+				return menuError;
+			if (selectedTopic.empty())
+				return Common::kNoError;
+
+			if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
+					runtime.matchesResponseLine(selectedTopic, 0x56) ||
+					runtime.matchesResponseLine(selectedTopic, 0x66)) {
+				clearLoomisTopicBuffer();
+				state.returnVisitFollowupPending = true;
+				return playLoomisLine(0x113a);
+			}
+
+			if (runtime.matchesResponseLine(selectedTopic, 0x57)) {
+				lineError = playLoomisLine(0x1143);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignLoomisTopicBuffer(0x58);
+				continue;
+			}
+			if (runtime.matchesResponseLine(selectedTopic, 0x59)) {
+				const DialogueLineEntry mrsLoomisLines[] = {
+					{ 0x114f, "LOOMIS", 0 },
+					{ 0x1150, "LOOMIS", 0 },
+					{ 0x1151, "LOOMIS", 0 },
+					{ 0x1152, "LOOMIS", 0 },
+					{ 0x1153, "LOOMIS", 0 }
+				};
+				lineError = runtime.playDialogueEntrySequence(mrsLoomisLines, ARRAYSIZE(mrsLoomisLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignLoomisTopicBuffer(0x5a);
+				continue;
+			}
+			if (runtime.matchesResponseLine(selectedTopic, 0x5b)) {
+				const DialogueLineEntry mrsPhelpsLines[] = {
+					{ 0x1161, "LOOMIS", 0 },
+					{ 0x1162, "LOOMIS", 0 },
+					{ 0x1163, "LOOMIS", 0 }
+				};
+				lineError = runtime.playDialogueEntrySequence(mrsPhelpsLines, ARRAYSIZE(mrsPhelpsLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignLoomisTopicBuffer(0x5c);
+				continue;
+			}
+			if (runtime.matchesResponseLine(selectedTopic, 0x5d)) {
+				lineError = playLoomisLine(0x1170);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignLoomisTopicBuffer(0x5e);
+				continue;
+			}
+			if (runtime.matchesAnyResponseLine(selectedTopic,
+					kLoomisSheriffTopicLineIndices, ARRAYSIZE(kLoomisSheriffTopicLineIndices))) {
+				lineError = playLoomisLine(0x117e);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignLoomisTopicBuffer(0x62);
+				continue;
+			}
+			if (runtime.matchesResponseLine(selectedTopic, 0x63)) {
+				lineError = playLoomisLine(0x118a);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignLoomisTopicBuffer(0x64);
+				continue;
+			}
+			if (runtime.matchesResponseLine(selectedTopic, 0x65)) {
+				lineError = playLoomisLine(0x1196);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				continue;
+			}
+		}
 	}
 
 	if (usedItemName.equalsIgnoreCase("NOTE") ||
@@ -71,20 +193,36 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.playDialogueLine(0x1229, "LOOMIS");
 	}
 	if (usedItemName.equalsIgnoreCase("INV_MAG")) {
-		if (runtime.currentRoomName().equalsIgnoreCase("SHRFOFC")) {
+		if (!runtime.startupScript().getFlagValue("SHERIFF_IN_DINER")) {
+			(void)runtime.startupScript().addRuntimeObjectToInventory("INV_MAG");
+			return runtime.playDialogueLine(0x11db, "LOOMIS");
+		}
+
+		Common::Error lineError = runtime.playDialogueLine(0x11bb, "LOOMIS");
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x1d4, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
 			StartupInteractionResult interaction;
-			const bool changedFlag = runtime.startupScript().setRuntimeFlagValue(
-				"GAVE_MAG_TO_LOOMIS_TODAY", true);
-			const bool changedNpc = runtime.startupScript().setRuntimeNpcState("LOOMIS", false, false);
-			interaction.mutatedRuntimeState = changedFlag || changedNpc;
+			bool changed = false;
+			if (runtime.currentRoomName().equalsIgnoreCase("SHRFOFC")) {
+				changed |= runtime.startupScript().setRuntimeFlagValue("GAVE_MAG_TO_LOOMIS_TODAY", true);
+				changed |= runtime.startupScript().setRuntimeNpcState("LOOMIS", false, false);
+				changed |= runtime.startupScript().setRuntimeObjectVisible("SHRFOFC", "SHERIF_DRAWR", true);
+				changed |= runtime.startupScript().setRuntimeObjectVisible("SHRFOFC", "SHERIF_DRAWR2", false);
+			}
+			interaction.mutatedRuntimeState = changed;
 			runtime.queueDialogueInteractionIfNeeded(interaction);
+			return runtime.playDialogueLine(0x11c6, "LOOMIS");
 		}
 
-		if (runtime.startupScript().getFlagValue("SHERIFF_IN_DINER"))
-			return runtime.playDialogueLine(0x11bb, "LOOMIS");
-
 		(void)runtime.startupScript().addRuntimeObjectToInventory("INV_MAG");
-		return runtime.playDialogueLine(0x11db, "LOOMIS");
+		return runtime.playDialogueLine(0x11cc, "LOOMIS");
 	}
 	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL"))
 		return runtime.playDialogueLine(0x1143, "LOOMIS");
diff --git a/engines/harvester/npc/loomis_dialogue.h b/engines/harvester/npc/loomis_dialogue.h
index ee327218250..9a802f5adb7 100644
--- a/engines/harvester/npc/loomis_dialogue.h
+++ b/engines/harvester/npc/loomis_dialogue.h
@@ -29,6 +29,8 @@ namespace Harvester {
 class LoomisDialogueHandler : public NpcDialogueHandler {
 public:
 	struct LoomisRoomDialogueState {
+		Common::String currentTopicBuffer;
+		int currentTopicBufferLineIndex = -1;
 		bool talkStatePending = true;
 		bool returnVisitFollowupPending = false;
 	};


Commit: 36015d137354aef98e73b798a5f924e1f1a3c9ab
    https://github.com/scummvm/scummvm/commit/36015d137354aef98e73b798a5f924e1f1a3c9ab
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:35+02:00

Commit Message:
HARVESTER: Play Loomis magazine handoff FST

Changed paths:
    TRACKER.md
    engines/harvester/npc/loomis_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index bded9284ebd..73d90c7a10c 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,10 +16,10 @@
 
 ## Last Confirmed Action
 
-- On March 20, 2026, re-opened the Loomis conversation branch with the native subtitles plus `DIALOG.RSP` instead of assuming the sheriff-office magazine problem was only a room-script side effect.
-- Confirmed the missing Loomis response mapping from the shipped text resources: `DIALOG.RSP` line `468` is the magazine confirmation menu (`"1. Sure... you can have it!/2. No, just thought you'd like a peek."`), while nearby `DIALOGUE.IDX` lines `4539`, `4550`, `4556`, and `4571` are the ask / accept / refuse / sheriff-present guard lines for the magazine handoff.
+- On March 20, 2026, re-opened the Loomis magazine handoff in the native `handle_talk_to_loomis @ 0x34f80` control-flow gap instead of assuming the sheriff-office acceptance branch was completely driven by the nearby room script or by the explicit `DIALOG.RSP` magazine prompt text.
+- Decoded the hidden native block after Loomis line `0x11bb`: it loads response line `0x51`, runs the response menu, plays line `0x11c6` on the accepted branch, then sets `GAVE_MAG_TO_LOOMIS_TODAY`, flips `SHERIF_DRAWR` visible / `SHERIF_DRAWR2` hidden, hides Loomis, and finally calls `FUN_00038750`, which decompiles to `play_fst_sequence("GRAPHIC/FST/C048.FST", ...)`; the refusal branch plays `0x11cc`.
 - Confirmed the first Loomis no-item conversation was also underimplemented in ScummVM: the subtitle block around `4349..4502` plus `DIALOG.RSP` lines `84..102` exposes the missing sheriff-office topic menu (`Stephanie`, `Mrs. Loomis`, `Mrs. Phelps`, `A Man's Needs`, `French Postcards`, `Sheriff Dwayne`, `Mattress`) and the follow-up `4520/4524/4528` revisit exchange that the simplified handler never ran.
-- Patched `engines/harvester/npc/loomis_dialogue.{h,cpp}` to restore that topic-menu/follow-up flow, route the magazine branch through the recovered confirmation response menu, and only set `GAVE_MAG_TO_LOOMIS_TODAY` / hide Loomis on the accepted branch; the accept path also now flips the sheriff-office drawer pair to `SHERIF_DRAWR` visible and `SHERIF_DRAWR2` hidden so the post-handoff drawer interaction can surface in the refreshed room state. Rebuilt `engines/harvester/npc/loomis_dialogue.o` and `scummvm` successfully.
+- Patched `engines/harvester/npc/loomis_dialogue.{h,cpp}` to restore that topic-menu/follow-up flow and to mirror the native accepted magazine branch more closely: it now uses the native response line `0x51`, consumes the magazine, applies the confirmed sheriff-office flag/object/NPC mutations, and plays `GRAPHIC/FST/C048.FST` before queuing the room refresh. Rebuilt `engines/harvester/npc/loomis_dialogue.o` and `scummvm` successfully.
 - On March 20, 2026, rechecked the Loomis sheriff-office handoff against native `handle_target_interaction @ 0x7ff50` after the immediate-disappear regression instead of assuming NPC-targeted `USEITEM` records were the direct room-click entry point.
 - Confirmed the native room loop treats class-4 NPC clicks differently from object hotspots: item-on-NPC interaction calls `run_npc_dialogue(npcName, presentedObject)` directly, which seeds `g_dialogue_presented_object_name` before the talk handler runs, while the generic `USEITEM` record scan is only reached for non-NPC targets.
 - Reverted the generic NPC `USEITEM` interception in `engines/harvester/room.cpp` and removed the now-unneeded NPC overloads from `engines/harvester/script.{h,cpp}` so Loomis item use again enters the native-style dialogue path first; rebuilt `engines/harvester/script.o`, `engines/harvester/room.o`, and `scummvm` successfully.
@@ -73,7 +73,7 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the magazine accept/refuse menu all appear in the right order and that accepting the magazine now both hides Loomis and exposes the real drawer hotspot.
+1. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
 2. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
 3. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
 4. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
diff --git a/engines/harvester/npc/loomis_dialogue.cpp b/engines/harvester/npc/loomis_dialogue.cpp
index d96818e1d97..2160ba92b7a 100644
--- a/engines/harvester/npc/loomis_dialogue.cpp
+++ b/engines/harvester/npc/loomis_dialogue.cpp
@@ -31,6 +31,7 @@ namespace Harvester {
 namespace {
 
 static const int kLoomisSheriffTopicLineIndices[] = { 0x5f, 0x60, 0x61 };
+static const char *const kDialogueC048FstPath = "GRAPHIC/FST/C048.FST";
 
 } // End of namespace
 
@@ -203,22 +204,29 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return lineError;
 
 		int responseIndex = 0;
-		Common::Error responseError = runtime.runResponseMenu(0x1d4, responseIndex);
+		Common::Error responseError = runtime.runResponseMenu(0x51, responseIndex);
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 
 		if (responseIndex == 1) {
-			StartupInteractionResult interaction;
-			bool changed = false;
+			bool changed = runtime.startupScript().setRuntimeObjectVisible("INVENTORY", "INV_MAG", false);
 			if (runtime.currentRoomName().equalsIgnoreCase("SHRFOFC")) {
 				changed |= runtime.startupScript().setRuntimeFlagValue("GAVE_MAG_TO_LOOMIS_TODAY", true);
 				changed |= runtime.startupScript().setRuntimeNpcState("LOOMIS", false, false);
 				changed |= runtime.startupScript().setRuntimeObjectVisible("SHRFOFC", "SHERIF_DRAWR", true);
 				changed |= runtime.startupScript().setRuntimeObjectVisible("SHRFOFC", "SHERIF_DRAWR2", false);
 			}
+			lineError = runtime.playDialogueLine(0x11c6, "LOOMIS");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC048FstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			StartupInteractionResult interaction;
 			interaction.mutatedRuntimeState = changed;
 			runtime.queueDialogueInteractionIfNeeded(interaction);
-			return runtime.playDialogueLine(0x11c6, "LOOMIS");
+			return Common::kNoError;
 		}
 
 		(void)runtime.startupScript().addRuntimeObjectToInventory("INV_MAG");


Commit: 48cb42475f9923a8db5fa07ff4852b3edb0c52ce
    https://github.com/scummvm/scummvm/commit/48cb42475f9923a8db5fa07ff4852b3edb0c52ce
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:35+02:00

Commit Message:
HARVESTER: Close inventory on drag-out

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 73d90c7a10c..d492a8ef777 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, rechecked the inventory drag-out close path against native `run_inventory_screen @ 0x7df10` in Ghidra instead of assuming the room overlay should only close on an outside click.
+- Confirmed the native inventory loop sets its close flag while the primary mouse button is still held, no inventory render entity is under the cursor, and the cursor leaves the hardcoded drag window `x=69..568`, `y=75..435`; when that close exits with a carried inventory entity still active, the caller keeps the carry state rather than clearing it.
+- Patched `engines/harvester/room.cpp` to mirror that behavior in the overlay room loop: during `EVENT_MOUSEMOVE`, a selected inventory item now closes the inventory overlay once the cursor is dragged outside the recovered native bounds with the primary button still held, while leaving the selection active for room targeting. Rebuilt `engines/harvester/room.o` and `scummvm` successfully.
 - On March 20, 2026, re-opened the Loomis magazine handoff in the native `handle_talk_to_loomis @ 0x34f80` control-flow gap instead of assuming the sheriff-office acceptance branch was completely driven by the nearby room script or by the explicit `DIALOG.RSP` magazine prompt text.
 - Decoded the hidden native block after Loomis line `0x11bb`: it loads response line `0x51`, runs the response menu, plays line `0x11c6` on the accepted branch, then sets `GAVE_MAG_TO_LOOMIS_TODAY`, flips `SHERIF_DRAWR` visible / `SHERIF_DRAWR2` hidden, hides Loomis, and finally calls `FUN_00038750`, which decompiles to `play_fst_sequence("GRAPHIC/FST/C048.FST", ...)`; the refusal branch plays `0x11cc`.
 - Confirmed the first Loomis no-item conversation was also underimplemented in ScummVM: the subtitle block around `4349..4502` plus `DIALOG.RSP` lines `84..102` exposes the missing sheriff-office topic menu (`Stephanie`, `Mrs. Loomis`, `Mrs. Phelps`, `A Man's Needs`, `French Postcards`, `Sheriff Dwayne`, `Mattress`) and the follow-up `4520/4524/4528` revisit exchange that the simplified handler never ran.
@@ -73,10 +76,11 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
-2. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
-3. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
-4. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
+1. Run a live Harvester validation pass on the inventory overlay: click and hold an inventory item, drag it past the native window `x=69..568`, `y=75..435`, and confirm the overlay closes immediately while the selected item remains active for room use.
+2. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
+3. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
+4. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
+5. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
 
 ## Reimplementation Priority Order
 
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index bbfe0b056b1..54c92a34adb 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -48,6 +48,10 @@ static const char *const kPlayerInventoryLabel = "your inventory";
 static const char *const kInventoryOwnerName = "INVENTORY";
 static const byte kTransparentPaletteIndex = 0;
 static const int kRoomMonsterAnimationRate = 17;
+static const int kNativeInventoryDragCloseLeft = 0x45;
+static const int kNativeInventoryDragCloseTop = 0x4b;
+static const int kNativeInventoryDragCloseRight = 0x239;
+static const int kNativeInventoryDragCloseBottom = 0x1b4;
 
 struct MonsterAnimationRange {
 	MonsterAnimationRange() {}
@@ -65,6 +69,18 @@ static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
 		!roomName.equalsIgnoreCase("BOWLSNTRY1");
 }
 
+static bool isPrimaryMouseDown() {
+	return g_system && g_system->getEventManager() &&
+		((g_system->getEventManager()->getButtonState() & 1) != 0);
+}
+
+static bool isOutsideNativeInventoryDragCloseBounds(const Common::Point &point) {
+	return point.x < kNativeInventoryDragCloseLeft ||
+		point.x >= kNativeInventoryDragCloseRight ||
+		point.y < kNativeInventoryDragCloseTop ||
+		point.y >= kNativeInventoryDragCloseBottom;
+}
+
 static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
 	if (!bitmap.isValid())
 		return;
@@ -1119,6 +1135,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 			switch (event.type) {
 			case Common::EVENT_MOUSEMOVE:
+				if (_inventory.isOpen() && _inventory.hasSelection() && isPrimaryMouseDown()) {
+					const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
+					if (!inventoryHover && isOutsideNativeInventoryDragCloseBounds(_mousePos)) {
+						if (_inventory.close())
+							needsRedraw = true;
+					}
+				}
 				needsRedraw = true;
 				break;
 			case Common::EVENT_RBUTTONDOWN:


Commit: 739c488b74109f3e2ce9c283f63af4a243a9a70d
    https://github.com/scummvm/scummvm/commit/739c488b74109f3e2ce9c283f63af4a243a9a70d
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:35+02:00

Commit Message:
HARVESTER: Log save and restore state

Changed paths:
    TRACKER.md
    engines/harvester/harvester.cpp
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index d492a8ef777..5cf3133d7fa 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,10 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, instrumented the Harvester save/load path instead of relying on menu-level load confirmation or room-materialization logs alone.
+- Added top-level savegame debug summaries in `HarvesterEngine::syncGame` for both save and load, including the serialized room target, spawn coordinates, facing, and music path; added a room-loop log when a pending loaded room state is actually applied.
+- Added runtime save-state dumps in `Script::syncRuntimeSaveState` that summarize the serialized record counts plus player HP/loadout/control pause state, then emit detailed logs for restored or saved runtime flags and for mutated runtime objects, NPCs, monsters, and timers relative to their script defaults. Also added explicit logs for script `SET_FLAG` execution and direct `setRuntimeFlagValue` mutations so flag changes are visible before they are written to a save.
+- Rebuilt `engines/harvester/harvester.o`, `engines/harvester/script.o`, `engines/harvester/room.o`, and `scummvm` successfully.
 - On March 20, 2026, rechecked the inventory drag-out close path against native `run_inventory_screen @ 0x7df10` in Ghidra instead of assuming the room overlay should only close on an outside click.
 - Confirmed the native inventory loop sets its close flag while the primary mouse button is still held, no inventory render entity is under the cursor, and the cursor leaves the hardcoded drag window `x=69..568`, `y=75..435`; when that close exits with a carried inventory entity still active, the caller keeps the carry state rather than clearing it.
 - Patched `engines/harvester/room.cpp` to mirror that behavior in the overlay room loop: during `EVENT_MOUSEMOVE`, a selected inventory item now closes the inventory overlay once the cursor is dragged outside the recovered native bounds with the primary button still held, while leaving the selection active for room targeting. Rebuilt `engines/harvester/room.o` and `scummvm` successfully.
@@ -76,11 +80,12 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester validation pass on the inventory overlay: click and hold an inventory item, drag it past the native window `x=69..568`, `y=75..435`, and confirm the overlay closes immediately while the selected item remains active for room use.
-2. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
-3. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
-4. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
-5. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
+1. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the new logs show: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
+2. Run a live Harvester validation pass on the inventory overlay: click and hold an inventory item, drag it past the native window `x=69..568`, `y=75..435`, and confirm the overlay closes immediately while the selected item remains active for room use.
+3. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
+4. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
+5. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
+6. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
 
 ## Reimplementation Priority Order
 
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 4b15c76ab5f..c8e357cb56c 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -50,6 +50,13 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
 static const uint32 kHarvesterSaveVersion = 2;
 
+static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
+	debugC(1, kDebugGeneral,
+		"Harvester: %s startup save room state valid=%d entrance='%s' room='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
+		operation, state.valid, state.entranceName.c_str(), state.roomName.c_str(),
+		state.playerX, state.playerY, state.playerZ, state.playerFacing, state.musicPath.c_str());
+}
+
 } // End of anonymous namespace
 
 HarvesterEngine *g_engine = nullptr;
@@ -351,6 +358,8 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 	StartupSaveRoomState roomState = s.isLoading()
 		? StartupSaveRoomState()
 		: _currentStartupSaveRoomState;
+	if (s.isSaving())
+		logStartupSaveRoomState("saving", roomState);
 	syncStartupSaveRoomState(s, roomState);
 	_startupScript->syncRuntimeSaveState(s);
 	if (s.err())
@@ -361,6 +370,7 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 			return Common::kReadingFailed;
 		_currentStartupSaveRoomState = roomState;
 		_pendingLoadedStartupSaveRoomState = roomState;
+		logStartupSaveRoomState("loaded", roomState);
 	}
 	return Common::kNoError;
 }
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 54c92a34adb..65cecca439a 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -278,6 +278,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		if (_engine.hasPendingLoadedStartupSaveRoomState()) {
 			startupFlow.resetRoomNpcDialogueState();
 			const StartupSaveRoomState &loadedState = _engine.getPendingLoadedStartupSaveRoomState();
+			debugC(1, kDebugGeneral,
+				"Harvester: applying pending loaded room state entrance='%s' room='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
+				loadedState.entranceName.c_str(), loadedState.roomName.c_str(),
+				loadedState.playerX, loadedState.playerY, loadedState.playerZ,
+				loadedState.playerFacing, loadedState.musicPath.c_str());
 			if (!_engine.getStartupScript()->materializeRoomState(
 					loadedState.entranceName, loadedState.roomName, state, *_engine.getResources())) {
 				return Common::kReadingFailed;
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 8af25de30ad..579e60229fc 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1125,7 +1125,165 @@ void Script::resetRuntimeState() {
 		timer.currentValue = timer.initialValue;
 }
 
+void Script::logRuntimeSaveState(const char *operation) const {
+	debugC(1, kDebugGeneral,
+		"Harvester: %s runtime save state flags=%u objects=%u anims=%u regions=%u npcs=%u monsters=%u timers=%u hp=%d loadout=%d paused=%d",
+		operation, (uint)_runtimeFlags.size(), (uint)_runtimeObjects.size(),
+		(uint)_runtimeAnimations.size(), (uint)_runtimeRegions.size(),
+		(uint)_runtimeNpcs.size(), (uint)_runtimeMonsters.size(),
+		(uint)_runtimeTimers.size(), _playerCurrentHitPoints, _playerCombatLoadout,
+		_playerControlPaused);
+
+	for (const StartupFlagRecord &flag : _runtimeFlags) {
+		const StartupFlagRecord *baseFlag = nullptr;
+		for (const StartupFlagRecord &candidate : _flags) {
+			if (candidate.name.equalsIgnoreCase(flag.name)) {
+				baseFlag = &candidate;
+				break;
+			}
+		}
+		if (!baseFlag || flag.value != baseFlag->value) {
+			debugC(1, kDebugGeneral,
+				"Harvester: %s runtime flag '%s' value=%d base=%d defined_in_script=%d",
+				operation, flag.name.c_str(), flag.value, baseFlag ? baseFlag->value : 0,
+				baseFlag != nullptr);
+		}
+	}
+
+	for (const StartupObjectRecord &object : _runtimeObjects) {
+		const StartupObjectRecord *baseObject = nullptr;
+		for (const StartupObjectRecord &candidate : _objects) {
+			if (candidate.objectName.equalsIgnoreCase(object.objectName)) {
+				baseObject = &candidate;
+				break;
+			}
+		}
+
+		const Common::String &defaultOwner = baseObject ? baseObject->initialOwnerOrRoom : object.initialOwnerOrRoom;
+		const int defaultX = baseObject ? baseObject->initialX : object.initialX;
+		const int defaultY = baseObject ? baseObject->initialY : object.initialY;
+		const int defaultZ = baseObject ? baseObject->initialZ : object.initialZ;
+		const bool defaultRuntimeVisible = baseObject ? baseObject->visible : object.visible;
+		const bool defaultVisible = baseObject ? baseObject->visible : object.visible;
+		const bool defaultIdentShown = baseObject ? baseObject->identTextKey.empty() : object.identTextKey.empty();
+		if (!baseObject ||
+				!object.currentOwnerOrRoom.equalsIgnoreCase(defaultOwner) ||
+				object.currentX != defaultX ||
+				object.currentY != defaultY ||
+				object.currentZ != defaultZ ||
+				object.runtimeVisible != defaultRuntimeVisible ||
+				object.visible != defaultVisible ||
+				object.identShown != defaultIdentShown) {
+			debugC(1, kDebugGeneral,
+				"Harvester: %s runtime object '%s' owner='%s' pos=(%d,%d,%d) visible=%d runtimeVisible=%d identShown=%d base_owner='%s' base_pos=(%d,%d,%d) base_visible=%d base_runtimeVisible=%d base_identShown=%d",
+				operation, object.objectName.c_str(), object.currentOwnerOrRoom.c_str(),
+				object.currentX, object.currentY, object.currentZ,
+				object.visible, object.runtimeVisible, object.identShown,
+				defaultOwner.c_str(), defaultX, defaultY, defaultZ,
+				defaultVisible, defaultRuntimeVisible, defaultIdentShown);
+		}
+	}
+
+	for (const StartupNpcRecord &npc : _runtimeNpcs) {
+		const StartupNpcRecord *baseNpc = nullptr;
+		for (const StartupNpcRecord &candidate : _npcs) {
+			if (candidate.npcName.equalsIgnoreCase(npc.npcName)) {
+				baseNpc = &candidate;
+				break;
+			}
+		}
+
+		const bool defaultActive = baseNpc ? baseNpc->active : npc.active;
+		const bool defaultVisible = baseNpc ? baseNpc->visible : npc.visible;
+		const bool defaultSavedVisible = baseNpc ? baseNpc->visible : npc.visible;
+		const bool defaultDeathOrMonsterfy = baseNpc ? baseNpc->deathOrMonsterfyFlag : npc.deathOrMonsterfyFlag;
+		const int defaultDeathDamageType = baseNpc ? baseNpc->deathDamageType : npc.deathDamageType;
+		if (!baseNpc ||
+				npc.active != defaultActive ||
+				npc.visible != defaultVisible ||
+				npc.savedVisible != defaultSavedVisible ||
+				npc.deathOrMonsterfyFlag != defaultDeathOrMonsterfy ||
+				npc.deathDamageType != defaultDeathDamageType ||
+				npc.runtimeSpawned) {
+			debugC(1, kDebugGeneral,
+				"Harvester: %s runtime npc '%s' active=%d visible=%d savedVisible=%d spawned=%d deathOrMonsterfy=%d damageType=%d base_active=%d base_visible=%d base_savedVisible=%d base_deathOrMonsterfy=%d base_damageType=%d",
+				operation, npc.npcName.c_str(), npc.active, npc.visible, npc.savedVisible,
+				npc.runtimeSpawned, npc.deathOrMonsterfyFlag, npc.deathDamageType,
+				defaultActive, defaultVisible, defaultSavedVisible,
+				defaultDeathOrMonsterfy, defaultDeathDamageType);
+		}
+	}
+
+	for (const StartupMonsterRecord &monster : _runtimeMonsters) {
+		const StartupMonsterRecord *baseMonster = nullptr;
+		for (const StartupMonsterRecord &candidate : _monsters) {
+			if (candidate.monsterName.equalsIgnoreCase(monster.monsterName)) {
+				baseMonster = &candidate;
+				break;
+			}
+		}
+
+		const bool defaultActive = baseMonster ? baseMonster->active : monster.active;
+		const bool defaultVisible = baseMonster
+			? (baseMonster->active ? true : baseMonster->visible)
+			: (monster.active ? true : monster.visible);
+		const bool defaultSavedVisible = defaultVisible;
+		const int defaultCurrentHitPoints = baseMonster ? baseMonster->initialHitPoints : monster.initialHitPoints;
+		const int defaultMinXBound = baseMonster ? baseMonster->minXBound : monster.minXBound;
+		const int defaultMaxXBound = baseMonster ? baseMonster->maxXBound : monster.maxXBound;
+		if (!baseMonster ||
+				monster.active != defaultActive ||
+				monster.visible != defaultVisible ||
+				monster.savedVisible != defaultSavedVisible ||
+				monster.currentHitPoints != defaultCurrentHitPoints ||
+				monster.runtimeSpawned ||
+				monster.minXBound != defaultMinXBound ||
+				monster.maxXBound != defaultMaxXBound) {
+			debugC(1, kDebugGeneral,
+				"Harvester: %s runtime monster '%s' active=%d visible=%d savedVisible=%d spawned=%d hp=%d/%d bounds=[%d,%d] base_active=%d base_visible=%d base_savedVisible=%d base_hp=%d/%d base_bounds=[%d,%d]",
+				operation, monster.monsterName.c_str(), monster.active, monster.visible,
+				monster.savedVisible, monster.runtimeSpawned,
+				monster.currentHitPoints, monster.initialHitPoints,
+				monster.minXBound, monster.maxXBound,
+				defaultActive, defaultVisible, defaultSavedVisible,
+				defaultCurrentHitPoints, baseMonster ? baseMonster->initialHitPoints : monster.initialHitPoints,
+				defaultMinXBound, defaultMaxXBound);
+		}
+	}
+
+	for (const StartupTimerRecord &timer : _runtimeTimers) {
+		const StartupTimerRecord *baseTimer = nullptr;
+		for (const StartupTimerRecord &candidate : _timers) {
+			if (candidate.timerName.equalsIgnoreCase(timer.timerName)) {
+				baseTimer = &candidate;
+				break;
+			}
+		}
+
+		const int defaultInitialValue = baseTimer ? baseTimer->initialValue : timer.initialValue;
+		const int defaultCurrentValue = defaultInitialValue;
+		const bool defaultEnabled = baseTimer ? baseTimer->enabled : timer.enabled;
+		const bool defaultLooping = baseTimer ? baseTimer->looping : timer.looping;
+		const bool defaultGlobal = baseTimer ? baseTimer->global : timer.global;
+		if (!baseTimer ||
+				timer.initialValue != defaultInitialValue ||
+				timer.currentValue != defaultCurrentValue ||
+				timer.enabled != defaultEnabled ||
+				timer.looping != defaultLooping ||
+				timer.global != defaultGlobal) {
+			debugC(1, kDebugGeneral,
+				"Harvester: %s runtime timer '%s' current=%d initial=%d enabled=%d loop=%d global=%d base_current=%d base_initial=%d base_enabled=%d base_loop=%d base_global=%d",
+				operation, timer.timerName.c_str(), timer.currentValue, timer.initialValue,
+				timer.enabled, timer.looping, timer.global,
+				defaultCurrentValue, defaultInitialValue, defaultEnabled, defaultLooping, defaultGlobal);
+		}
+	}
+}
+
 void Script::syncRuntimeSaveState(Common::Serializer &s) {
+	if (s.isSaving())
+		logRuntimeSaveState("saving");
+
 	syncRecordArray(s, _runtimeFlags, syncStartupFlagRecord);
 	syncRecordArray(s, _runtimeObjects, syncStartupObjectRecord);
 	syncRecordArray(s, _runtimeAnimations, syncStartupAnimRecord);
@@ -1184,6 +1342,7 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 			_playerCombatLoadout = kDefaultPlayerCombatLoadout;
 			_playerControlPaused = false;
 		}
+		logRuntimeSaveState("loaded");
 	}
 }
 
@@ -1923,7 +2082,10 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			const bool flagValue = isTruthy(command->arg2);
 			StartupFlagRecord *flag = findRuntimeFlag(command->arg1);
 			bool changed = false;
+			bool oldValue = false;
+			const bool existed = flag != nullptr;
 			if (flag) {
+				oldValue = flag->value;
 				changed = flag->value != flagValue;
 				flag->value = flagValue;
 			} else {
@@ -1933,6 +2095,10 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 				_runtimeFlags.push_back(newFlag);
 				changed = true;
 			}
+			debugC(1, kDebugGeneral,
+				"Harvester: %s '%s' SET_FLAG '%s' %d -> %d existed=%d changed=%d",
+				contextLabel, contextName.c_str(), command->arg1.c_str(), oldValue, flagValue,
+				existed, changed);
 			noteMutation(changed);
 			currentTag = command->arg4;
 			continue;
@@ -2443,7 +2609,11 @@ bool Script::setRuntimeFlagValue(const Common::String &flagName, bool value) {
 	StartupFlagRecord *flag = findRuntimeFlag(flagName);
 	if (flag) {
 		const bool changed = flag->value != value;
+		const bool oldValue = flag->value;
 		flag->value = value;
+		debugC(1, kDebugGeneral,
+			"Harvester: direct runtime flag '%s' %d -> %d existed=1 changed=%d",
+			flagName.c_str(), oldValue, value, changed);
 		return changed;
 	}
 
@@ -2451,6 +2621,9 @@ bool Script::setRuntimeFlagValue(const Common::String &flagName, bool value) {
 	newFlag.name = flagName;
 	newFlag.value = value;
 	_runtimeFlags.push_back(newFlag);
+	debugC(1, kDebugGeneral,
+		"Harvester: direct runtime flag '%s' %d -> %d existed=0 changed=1",
+		flagName.c_str(), 0, value);
 	return true;
 }
 
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index fcc38542d97..8da4c7fd1f5 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -460,6 +460,7 @@ private:
 		bool *mutatedRuntimeState);
 	bool hasActionableCommandChain(const Common::String &initialTag) const;
 	bool setPlayerCurrentHitPoints(int hitPoints);
+	void logRuntimeSaveState(const char *operation) const;
 
 	Common::String _path;
 	Common::Array<byte> _data;


Commit: a4e1e09acb8ebb3c8d2b7f62af2701588af54ce8
    https://github.com/scummvm/scummvm/commit/a4e1e09acb8ebb3c8d2b7f62af2701588af54ce8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:36+02:00

Commit Message:
HARVESTER: Extract save/load module

Changed paths:
  A engines/harvester/saveload.cpp
  A engines/harvester/saveload.h
    TRACKER.md
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 5cf3133d7fa..af16a16855e 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, split Harvester save/load plumbing into a dedicated module instead of continuing to grow `harvester.cpp` with serializer helpers and save-state mutation code.
+- Moved the `StartupSaveRoomState` type into new `engines/harvester/saveload.h`, moved the `HarvesterEngine` save/load/capture/clear implementations into new `engines/harvester/saveload.cpp`, removed the now-local serializer helpers from `harvester.cpp`, and wired `saveload.o` into `engines/harvester/module.mk`.
+- Kept the existing save/load behavior and debug instrumentation intact while changing only the code organization; rebuilt `engines/harvester/saveload.o`, `engines/harvester/harvester.o`, and `scummvm` successfully.
 - On March 20, 2026, instrumented the Harvester save/load path instead of relying on menu-level load confirmation or room-materialization logs alone.
 - Added top-level savegame debug summaries in `HarvesterEngine::syncGame` for both save and load, including the serialized room target, spawn coordinates, facing, and music path; added a room-loop log when a pending loaded room state is actually applied.
 - Added runtime save-state dumps in `Script::syncRuntimeSaveState` that summarize the serialized record counts plus player HP/loadout/control pause state, then emit detailed logs for restored or saved runtime flags and for mutated runtime objects, NPCs, monsters, and timers relative to their script defaults. Also added explicit logs for script `SET_FLAG` execution and direct `setRuntimeFlagValue` mutations so flag changes are visible before they are written to a save.
@@ -80,7 +83,7 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the new logs show: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
+1. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
 2. Run a live Harvester validation pass on the inventory overlay: click and hold an inventory item, drag it past the native window `x=69..568`, `y=75..435`, and confirm the overlay closes immediately while the selected item remains active for room use.
 3. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
 4. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index c8e357cb56c..16340f4f07c 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -40,23 +40,6 @@ static bool shouldSkipStartupMoviesForDebug() {
 		ConfMan.getBool("harvester_debug_skip_startup_movies");
 }
 
-static void syncSerializedBool(Common::Serializer &s, bool &value) {
-	byte serialized = value ? 1 : 0;
-	s.syncAsByte(serialized);
-	if (s.isLoading())
-		value = serialized != 0;
-}
-
-static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
-static const uint32 kHarvesterSaveVersion = 2;
-
-static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
-	debugC(1, kDebugGeneral,
-		"Harvester: %s startup save room state valid=%d entrance='%s' room='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
-		operation, state.valid, state.entranceName.c_str(), state.roomName.c_str(),
-		state.playerX, state.playerY, state.playerZ, state.playerFacing, state.musicPath.c_str());
-}
-
 } // End of anonymous namespace
 
 HarvesterEngine *g_engine = nullptr;
@@ -239,39 +222,6 @@ bool HarvesterEngine::toggleRoomDebugEnabled() {
 	return _roomDebugEnabled;
 }
 
-void HarvesterEngine::captureCurrentStartupSaveRoomState(const Common::String &entranceName,
-		const Common::String &roomName, int playerX, int playerY, int playerZ, int playerFacing,
-		const Common::String &musicPath) {
-	_currentStartupSaveRoomState.entranceName = entranceName;
-	_currentStartupSaveRoomState.roomName = roomName;
-	_currentStartupSaveRoomState.musicPath =
-		(_resources && !musicPath.empty()) ? _resources->normalizeResourcePath(musicPath) : musicPath;
-	_currentStartupSaveRoomState.playerX = playerX;
-	_currentStartupSaveRoomState.playerY = playerY;
-	_currentStartupSaveRoomState.playerZ = playerZ;
-	_currentStartupSaveRoomState.playerFacing = playerFacing;
-	_currentStartupSaveRoomState.valid = !roomName.empty();
-}
-
-void HarvesterEngine::clearCurrentStartupSaveRoomState() {
-	_currentStartupSaveRoomState.clear();
-}
-
-void HarvesterEngine::clearPendingLoadedStartupSaveRoomState() {
-	_pendingLoadedStartupSaveRoomState.clear();
-}
-
-void HarvesterEngine::syncStartupSaveRoomState(Common::Serializer &s, StartupSaveRoomState &state) {
-	syncSerializedBool(s, state.valid);
-	s.syncString(state.entranceName);
-	s.syncString(state.roomName);
-	s.syncString(state.musicPath);
-	s.syncAsSint32LE(state.playerX);
-	s.syncAsSint32LE(state.playerY);
-	s.syncAsSint32LE(state.playerZ);
-	s.syncAsSint32LE(state.playerFacing);
-}
-
 void HarvesterEngine::setDisplayMode(int width, int height) {
 	initGraphics(width, height);
 	if (_media)
@@ -345,44 +295,4 @@ bool HarvesterEngine::hasFeature(EngineFeature f) const {
 	       (f == kSupportsReturnToLauncher);
 }
 
-Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
-	if (!_startupScript)
-		return s.isLoading() ? Common::kReadingFailed : Common::kWritingFailed;
-	if (s.isSaving() && !_currentStartupSaveRoomState.valid)
-		return Common::kWritingFailed;
-	if (!s.matchBytes(kHarvesterSaveMagic, sizeof(kHarvesterSaveMagic)))
-		return Common::kReadingFailed;
-	if (!s.syncVersion(kHarvesterSaveVersion))
-		return Common::kReadingFailed;
-
-	StartupSaveRoomState roomState = s.isLoading()
-		? StartupSaveRoomState()
-		: _currentStartupSaveRoomState;
-	if (s.isSaving())
-		logStartupSaveRoomState("saving", roomState);
-	syncStartupSaveRoomState(s, roomState);
-	_startupScript->syncRuntimeSaveState(s);
-	if (s.err())
-		return s.isLoading() ? Common::kReadingFailed : Common::kWritingFailed;
-
-	if (s.isLoading()) {
-		if (!roomState.valid || roomState.roomName.empty())
-			return Common::kReadingFailed;
-		_currentStartupSaveRoomState = roomState;
-		_pendingLoadedStartupSaveRoomState = roomState;
-		logStartupSaveRoomState("loaded", roomState);
-	}
-	return Common::kNoError;
-}
-
-Common::Error HarvesterEngine::saveGameStream(Common::WriteStream *stream, bool isAutosave) {
-	Common::Serializer serializer(nullptr, stream);
-	return syncGame(serializer);
-}
-
-Common::Error HarvesterEngine::loadGameStream(Common::SeekableReadStream *stream) {
-	Common::Serializer serializer(stream, nullptr);
-	return syncGame(serializer);
-}
-
 } // End of namespace Harvester
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 7ad2f470c7b..c2dc6273973 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -29,6 +29,7 @@
 #include "common/serializer.h"
 #include "engines/engine.h"
 #include "graphics/screen.h"
+#include "harvester/saveload.h"
 #include "harvester/script.h"
 
 struct ADGameDescription;
@@ -42,28 +43,6 @@ class Script;
 class Art;
 class Text;
 
-struct StartupSaveRoomState {
-	Common::String entranceName;
-	Common::String roomName;
-	Common::String musicPath;
-	int playerX = 0;
-	int playerY = 0;
-	int playerZ = 0;
-	int playerFacing = -1;
-	bool valid = false;
-
-	void clear() {
-		entranceName.clear();
-		roomName.clear();
-		musicPath.clear();
-		playerX = 0;
-		playerY = 0;
-		playerZ = 0;
-		playerFacing = -1;
-		valid = false;
-	}
-};
-
 class HarvesterEngine : public Engine {
 public:
 	HarvesterEngine(OSystem *syst, const ADGameDescription *gameDesc);
@@ -144,7 +123,6 @@ public:
 	void clearPendingLoadedStartupSaveRoomState();
 
 private:
-	void syncStartupSaveRoomState(Common::Serializer &s, StartupSaveRoomState &state);
 	void applyStartupMixerLevels();
 
 	const ADGameDescription *const _gameDescription;
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 553017e81c0..159bbbf16aa 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -15,6 +15,7 @@ MODULE := engines/harvester
 	fst_player.o \
 	metaengine.o \
 	media_manager.o \
+	saveload.o \
 	npc/curator_dialogue.o \
 	npc/dad_dialogue.o \
 	npc/dark_woman_dialogue.o \
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
new file mode 100644
index 00000000000..7ecf3c807d7
--- /dev/null
+++ b/engines/harvester/saveload.cpp
@@ -0,0 +1,125 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/harvester.h"
+
+#include "common/serializer.h"
+#include "harvester/detection.h"
+#include "harvester/resources.h"
+#include "harvester/script.h"
+
+namespace Harvester {
+
+namespace {
+
+static void syncSerializedBool(Common::Serializer &s, bool &value) {
+	byte serialized = value ? 1 : 0;
+	s.syncAsByte(serialized);
+	if (s.isLoading())
+		value = serialized != 0;
+}
+
+static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
+static const uint32 kHarvesterSaveVersion = 2;
+
+static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
+	debugC(1, kDebugGeneral,
+		"Harvester: %s startup save room state valid=%d entrance='%s' room='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
+		operation, state.valid, state.entranceName.c_str(), state.roomName.c_str(),
+		state.playerX, state.playerY, state.playerZ, state.playerFacing, state.musicPath.c_str());
+}
+
+static void syncStartupSaveRoomState(Common::Serializer &s, StartupSaveRoomState &state) {
+	syncSerializedBool(s, state.valid);
+	s.syncString(state.entranceName);
+	s.syncString(state.roomName);
+	s.syncString(state.musicPath);
+	s.syncAsSint32LE(state.playerX);
+	s.syncAsSint32LE(state.playerY);
+	s.syncAsSint32LE(state.playerZ);
+	s.syncAsSint32LE(state.playerFacing);
+}
+
+} // End of anonymous namespace
+
+void HarvesterEngine::captureCurrentStartupSaveRoomState(const Common::String &entranceName,
+		const Common::String &roomName, int playerX, int playerY, int playerZ, int playerFacing,
+		const Common::String &musicPath) {
+	_currentStartupSaveRoomState.entranceName = entranceName;
+	_currentStartupSaveRoomState.roomName = roomName;
+	_currentStartupSaveRoomState.musicPath =
+		(_resources && !musicPath.empty()) ? _resources->normalizeResourcePath(musicPath) : musicPath;
+	_currentStartupSaveRoomState.playerX = playerX;
+	_currentStartupSaveRoomState.playerY = playerY;
+	_currentStartupSaveRoomState.playerZ = playerZ;
+	_currentStartupSaveRoomState.playerFacing = playerFacing;
+	_currentStartupSaveRoomState.valid = !roomName.empty();
+}
+
+void HarvesterEngine::clearCurrentStartupSaveRoomState() {
+	_currentStartupSaveRoomState.clear();
+}
+
+void HarvesterEngine::clearPendingLoadedStartupSaveRoomState() {
+	_pendingLoadedStartupSaveRoomState.clear();
+}
+
+Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
+	if (!_startupScript)
+		return s.isLoading() ? Common::kReadingFailed : Common::kWritingFailed;
+	if (s.isSaving() && !_currentStartupSaveRoomState.valid)
+		return Common::kWritingFailed;
+	if (!s.matchBytes(kHarvesterSaveMagic, sizeof(kHarvesterSaveMagic)))
+		return Common::kReadingFailed;
+	if (!s.syncVersion(kHarvesterSaveVersion))
+		return Common::kReadingFailed;
+
+	StartupSaveRoomState roomState = s.isLoading()
+		? StartupSaveRoomState()
+		: _currentStartupSaveRoomState;
+	if (s.isSaving())
+		logStartupSaveRoomState("saving", roomState);
+	syncStartupSaveRoomState(s, roomState);
+	_startupScript->syncRuntimeSaveState(s);
+	if (s.err())
+		return s.isLoading() ? Common::kReadingFailed : Common::kWritingFailed;
+
+	if (s.isLoading()) {
+		if (!roomState.valid || roomState.roomName.empty())
+			return Common::kReadingFailed;
+		_currentStartupSaveRoomState = roomState;
+		_pendingLoadedStartupSaveRoomState = roomState;
+		logStartupSaveRoomState("loaded", roomState);
+	}
+	return Common::kNoError;
+}
+
+Common::Error HarvesterEngine::saveGameStream(Common::WriteStream *stream, bool isAutosave) {
+	Common::Serializer serializer(nullptr, stream);
+	return syncGame(serializer);
+}
+
+Common::Error HarvesterEngine::loadGameStream(Common::SeekableReadStream *stream) {
+	Common::Serializer serializer(stream, nullptr);
+	return syncGame(serializer);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/saveload.h b/engines/harvester/saveload.h
new file mode 100644
index 00000000000..e5563b5a270
--- /dev/null
+++ b/engines/harvester/saveload.h
@@ -0,0 +1,53 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_SAVELOAD_H
+#define HARVESTER_SAVELOAD_H
+
+#include "common/str.h"
+
+namespace Harvester {
+
+struct StartupSaveRoomState {
+	Common::String entranceName;
+	Common::String roomName;
+	Common::String musicPath;
+	int playerX = 0;
+	int playerY = 0;
+	int playerZ = 0;
+	int playerFacing = -1;
+	bool valid = false;
+
+	void clear() {
+		entranceName.clear();
+		roomName.clear();
+		musicPath.clear();
+		playerX = 0;
+		playerY = 0;
+		playerZ = 0;
+		playerFacing = -1;
+		valid = false;
+	}
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_SAVELOAD_H


Commit: fc411197611c238d7751ece7d1ca6313f5c784e9
    https://github.com/scummvm/scummvm/commit/fc411197611c238d7751ece7d1ca6313f5c784e9
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:36+02:00

Commit Message:
HARVESTER: Replace atoi with Common-based parser

Changed paths:
  A engines/harvester/parse_utils.h
    TRACKER.md
    engines/harvester/script.cpp
    engines/harvester/text.cpp


diff --git a/TRACKER.md b/TRACKER.md
index af16a16855e..8517124e6c8 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -9,13 +9,19 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `900`
-- Named/documented: `789`
+- Total functions: `901`
+- Named/documented: `790`
 - Remaining `FUN_*`: `111`
 - Remaining undocumented total: `111`
 
 ## Last Confirmed Action
 
+- On March 20, 2026, scanned `engines/harvester` for native libc/std helper usage instead of assuming the existing parser code already matched ScummVM conventions.
+- Confirmed Harvester's `MIN`/`MAX` calls already come from ScummVM's own `common/util.h`, so the only real replacement target in the engine sources was `atoi`.
+- Added `engines/harvester/parse_utils.h` with `parseAsciiIntOrZero`, a Common-based permissive decimal parser that mirrors Harvester's prior `atoi` usage closely enough for config/script/dialogue fields while relying on `Common::String`, `Common::isSpace`, and `Common::isDigit` instead of libc; rewired all Harvester `atoi` call sites in `script.cpp` and `text.cpp` to use it, removed the direct `<cstdlib>` dependency from `script.cpp`, and rebuilt `engines/harvester/script.o` plus `engines/harvester/text.o` successfully.
+- On March 20, 2026, scanned the remaining `FUN_*` band in the live `HARVEST.LE` Ghidra session and only applied a rename where the evidence was explicit enough to survive review.
+- Confirmed `FUN_00038750 @ 0x38750` is a single-purpose wrapper around `play_fst_sequence("GRAPHIC/FST/C048.FST", 0, param_3, param_2)`, renamed it to `play_c048_fst_sequence`, added a plate comment with the hardcoded asset-path evidence plus the Loomis sheriff-office caller context, and saved `HARVEST.LE`.
+- Rechecked the other still-unnamed outliers instead of forcing more names: the `0x21100/0x21120` pair is still only proven as shared min/max extent updaters with unresolved axis ownership, the `0x83fb3..0x87439` band is still HMIDRV / sound-driver plumbing where only subcommand IDs and timer/vector side effects are concrete, and the remaining `0x8c3d8..0x91518` cluster is still largely exception / x87 runtime support below the semantic rename threshold.
 - On March 20, 2026, split Harvester save/load plumbing into a dedicated module instead of continuing to grow `harvester.cpp` with serializer helpers and save-state mutation code.
 - Moved the `StartupSaveRoomState` type into new `engines/harvester/saveload.h`, moved the `HarvesterEngine` save/load/capture/clear implementations into new `engines/harvester/saveload.cpp`, removed the now-local serializer helpers from `harvester.cpp`, and wired `saveload.o` into `engines/harvester/module.mk`.
 - Kept the existing save/load behavior and debug instrumentation intact while changing only the code organization; rebuilt `engines/harvester/saveload.o`, `engines/harvester/harvester.o`, and `scummvm` successfully.
@@ -83,12 +89,14 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
-2. Run a live Harvester validation pass on the inventory overlay: click and hold an inventory item, drag it past the native window `x=69..568`, `y=75..435`, and confirm the overlay closes immediately while the selected item remains active for room use.
-3. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
-4. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
-5. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
-6. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
+1. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
+2. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
+3. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
+4. Run a live Harvester validation pass on the inventory overlay: click and hold an inventory item, drag it past the native window `x=69..568`, `y=75..435`, and confirm the overlay closes immediately while the selected item remains active for room use.
+5. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
+6. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
+7. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
+8. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
 
 ## Reimplementation Priority Order
 
diff --git a/engines/harvester/parse_utils.h b/engines/harvester/parse_utils.h
new file mode 100644
index 00000000000..67457ada0f4
--- /dev/null
+++ b/engines/harvester/parse_utils.h
@@ -0,0 +1,65 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_PARSE_UTILS_H
+#define HARVESTER_PARSE_UTILS_H
+
+#include <climits>
+
+#include "common/str.h"
+#include "common/util.h"
+
+namespace Harvester {
+
+// Mirrors atoi's permissive decimal parsing while relying on Common helpers.
+inline int parseAsciiIntOrZero(const Common::String &value) {
+	uint start = 0;
+	while (start < value.size() && Common::isSpace((byte)value[start]))
+		++start;
+
+	bool negative = false;
+	if (start < value.size() && (value[start] == '+' || value[start] == '-')) {
+		negative = value[start] == '-';
+		++start;
+	}
+
+	uint end = start;
+	while (end < value.size() && Common::isDigit((byte)value[end]))
+		++end;
+
+	if (start == end)
+		return 0;
+
+	const uint64 magnitude = value.substr(start, end - start).asUint64();
+	if (negative) {
+		if (magnitude > (uint64)INT_MAX + 1)
+			return INT_MIN;
+		return -(int)magnitude;
+	}
+
+	if (magnitude > (uint64)INT_MAX)
+		return INT_MAX;
+	return (int)magnitude;
+}
+
+} // End of namespace Harvester
+
+#endif
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 579e60229fc..11eb79d79d7 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -21,8 +21,6 @@
 
 #include "harvester/script.h"
 
-#include <cstdlib>
-
 #include "common/config-manager.h"
 #include "common/debug.h"
 #include "common/endian.h"
@@ -32,6 +30,7 @@
 #include "common/serializer.h"
 #include "harvester/detection.h"
 #include "harvester/harvester.h"
+#include "harvester/parse_utils.h"
 #include "harvester/resources.h"
 
 namespace Harvester {
@@ -328,7 +327,7 @@ static bool appendStartupAudioCommand(const StartupCommandRecord &command, Commo
 		StartupAudioCommand audioCommand;
 		audioCommand.type = kStartupAudioCommandLoadWav;
 		audioCommand.path = command.arg1;
-		audioCommand.slot = command.arg2.empty() ? -1 : atoi(command.arg2.c_str());
+		audioCommand.slot = command.arg2.empty() ? -1 : parseAsciiIntOrZero(command.arg2);
 		commands.push_back(audioCommand);
 		return true;
 	}
@@ -336,7 +335,7 @@ static bool appendStartupAudioCommand(const StartupCommandRecord &command, Commo
 	if (command.opcodeName.equalsIgnoreCase("PLAY_WAV")) {
 		StartupAudioCommand audioCommand;
 		audioCommand.type = kStartupAudioCommandPlayWav;
-		audioCommand.slot = command.arg1.empty() ? -1 : atoi(command.arg1.c_str());
+		audioCommand.slot = command.arg1.empty() ? -1 : parseAsciiIntOrZero(command.arg1);
 		commands.push_back(audioCommand);
 		return true;
 	}
@@ -344,7 +343,7 @@ static bool appendStartupAudioCommand(const StartupCommandRecord &command, Commo
 	if (command.opcodeName.equalsIgnoreCase("DELETE_WAV")) {
 		StartupAudioCommand audioCommand;
 		audioCommand.type = kStartupAudioCommandDeleteWav;
-		audioCommand.slot = command.arg1.empty() ? -1 : atoi(command.arg1.c_str());
+		audioCommand.slot = command.arg1.empty() ? -1 : parseAsciiIntOrZero(command.arg1);
 		commands.push_back(audioCommand);
 		return true;
 	}
@@ -507,15 +506,15 @@ bool Script::loadConfig(ResourceManager &resources) {
 
 	Common::String fxVolume;
 	if (config.getKey("FX_VOLUME", kConfigSectionName, fxVolume))
-		_fxVolumeLevel = clampStartupOptionLevel(atoi(fxVolume.c_str()));
+		_fxVolumeLevel = clampStartupOptionLevel(parseAsciiIntOrZero(fxVolume));
 
 	Common::String musicVolume;
 	if (config.getKey("MUSIC_VOLUME", kConfigSectionName, musicVolume))
-		_musicVolumeLevel = clampStartupOptionLevel(atoi(musicVolume.c_str()));
+		_musicVolumeLevel = clampStartupOptionLevel(parseAsciiIntOrZero(musicVolume));
 
 	Common::String gammaValue;
 	if (config.getKey("GAMMA", kConfigSectionName, gammaValue))
-		_gammaLevel = clampStartupOptionLevel(atoi(gammaValue.c_str()));
+		_gammaLevel = clampStartupOptionLevel(parseAsciiIntOrZero(gammaValue));
 
 	Common::String textMode;
 	if (config.getKey("TEXT", kConfigSectionName, textMode)) {
@@ -743,9 +742,9 @@ void Script::parseTownRecords(ResourceManager &resources) {
 
 			StartupEntranceRecord entrance;
 			if (tagIndex >= 3) {
-				entrance.posX = atoi(tokens[0].c_str());
-				entrance.posY = atoi(tokens[1].c_str());
-				entrance.posZ = atoi(tokens[2].c_str());
+				entrance.posX = parseAsciiIntOrZero(tokens[0]);
+				entrance.posY = parseAsciiIntOrZero(tokens[1]);
+				entrance.posZ = parseAsciiIntOrZero(tokens[2]);
 			}
 			entrance.direction = tokens[tagIndex + 1];
 			entrance.facing = parseEntranceFacing(entrance.direction);
@@ -762,9 +761,9 @@ void Script::parseTownRecords(ResourceManager &resources) {
 
 			StartupMapEntranceRecord mapEntrance;
 			if (tagIndex >= 3) {
-				mapEntrance.field0 = atoi(tokens[0].c_str());
-				mapEntrance.field4 = atoi(tokens[1].c_str());
-				mapEntrance.initialPanelIndex = atoi(tokens[2].c_str());
+				mapEntrance.field0 = parseAsciiIntOrZero(tokens[0]);
+				mapEntrance.field4 = parseAsciiIntOrZero(tokens[1]);
+				mapEntrance.initialPanelIndex = parseAsciiIntOrZero(tokens[2]);
 			}
 			mapEntrance.entryName = tokens[tagIndex + 1];
 			if (!mapEntrance.entryName.empty())
@@ -778,13 +777,13 @@ void Script::parseTownRecords(ResourceManager &resources) {
 
 			StartupMapLocationRecord mapLocation;
 			if (tagIndex >= 7) {
-				mapLocation.minX = atoi(tokens[0].c_str());
-				mapLocation.minY = atoi(tokens[1].c_str());
-				mapLocation.maxX = atoi(tokens[2].c_str());
-				mapLocation.maxY = atoi(tokens[3].c_str());
-				mapLocation.panelIndex = atoi(tokens[4].c_str());
-				mapLocation.labelX = atoi(tokens[5].c_str());
-				mapLocation.labelY = atoi(tokens[6].c_str());
+				mapLocation.minX = parseAsciiIntOrZero(tokens[0]);
+				mapLocation.minY = parseAsciiIntOrZero(tokens[1]);
+				mapLocation.maxX = parseAsciiIntOrZero(tokens[2]);
+				mapLocation.maxY = parseAsciiIntOrZero(tokens[3]);
+				mapLocation.panelIndex = parseAsciiIntOrZero(tokens[4]);
+				mapLocation.labelX = parseAsciiIntOrZero(tokens[5]);
+				mapLocation.labelY = parseAsciiIntOrZero(tokens[6]);
 			}
 			mapLocation.labelText = tokens[tagIndex + 1];
 			for (uint i = 0; i < mapLocation.labelText.size(); ++i) {
@@ -803,12 +802,12 @@ void Script::parseTownRecords(ResourceManager &resources) {
 
 			StartupRoomRecord room;
 			if (tagIndex >= 6) {
-				room.minZ = atoi(tokens[0].c_str());
-				room.maxZ = atoi(tokens[1].c_str());
-				room.maxZScreenY = atoi(tokens[2].c_str());
-				room.minZScreenY = atoi(tokens[3].c_str());
-				room.fullScaleZ = atoi(tokens[4].c_str());
-				room.maxZScalePercent = atoi(tokens[5].c_str());
+				room.minZ = parseAsciiIntOrZero(tokens[0]);
+				room.maxZ = parseAsciiIntOrZero(tokens[1]);
+				room.maxZScreenY = parseAsciiIntOrZero(tokens[2]);
+				room.minZScreenY = parseAsciiIntOrZero(tokens[3]);
+				room.fullScaleZ = parseAsciiIntOrZero(tokens[4]);
+				room.maxZScalePercent = parseAsciiIntOrZero(tokens[5]);
 				if (room.maxZ != room.fullScaleZ) {
 					room.perspectiveScale = ((100.0f - (float)room.maxZScalePercent) /
 						(float)(room.maxZ - room.fullScaleZ)) * 0.01f;
@@ -834,10 +833,10 @@ void Script::parseTownRecords(ResourceManager &resources) {
 
 			StartupNpcRecord npc;
 			if (tagIndex >= 4) {
-				npc.posX = atoi(tokens[0].c_str());
-				npc.posY = atoi(tokens[1].c_str());
-				npc.posZ = atoi(tokens[2].c_str());
-				npc.frameDelay = atoi(tokens[3].c_str());
+				npc.posX = parseAsciiIntOrZero(tokens[0]);
+				npc.posY = parseAsciiIntOrZero(tokens[1]);
+				npc.posZ = parseAsciiIntOrZero(tokens[2]);
+				npc.frameDelay = parseAsciiIntOrZero(tokens[3]);
 			}
 			npc.roomName = tokens[tagIndex + 1];
 			npc.modelPath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
@@ -862,18 +861,18 @@ void Script::parseTownRecords(ResourceManager &resources) {
 
 			StartupMonsterRecord monster;
 			if (tagIndex >= 6) {
-				monster.posX = atoi(tokens[0].c_str());
-				monster.posY = atoi(tokens[1].c_str());
-				monster.posZ = atoi(tokens[2].c_str());
-				monster.initialHitPoints = atoi(tokens[3].c_str());
-				monster.damageAmount = atoi(tokens[4].c_str());
-				monster.engageDistance = atoi(tokens[5].c_str());
+				monster.posX = parseAsciiIntOrZero(tokens[0]);
+				monster.posY = parseAsciiIntOrZero(tokens[1]);
+				monster.posZ = parseAsciiIntOrZero(tokens[2]);
+				monster.initialHitPoints = parseAsciiIntOrZero(tokens[3]);
+				monster.damageAmount = parseAsciiIntOrZero(tokens[4]);
+				monster.engageDistance = parseAsciiIntOrZero(tokens[5]);
 			}
 			if (tagIndex >= 10) {
-				monster.field70 = atoi(tokens[6].c_str());
-				monster.field74 = atoi(tokens[7].c_str());
-				monster.field78 = atoi(tokens[8].c_str());
-				monster.field7c = atoi(tokens[9].c_str());
+				monster.field70 = parseAsciiIntOrZero(tokens[6]);
+				monster.field74 = parseAsciiIntOrZero(tokens[7]);
+				monster.field78 = parseAsciiIntOrZero(tokens[8]);
+				monster.field7c = parseAsciiIntOrZero(tokens[9]);
 			}
 			monster.roomName = tokens[tagIndex + 1];
 			monster.monsterName = tokens[tagIndex + 2];
@@ -897,9 +896,9 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			monster.visible = tokens[tagIndex + 20].equalsIgnoreCase("T");
 			monster.onDeathActionTag = tokens[tagIndex + 21];
 			if (tokens.size() > tagIndex + 22 && !tokens[tagIndex + 22].empty())
-				monster.minXBound = atoi(tokens[tagIndex + 22].c_str());
+				monster.minXBound = parseAsciiIntOrZero(tokens[tagIndex + 22]);
 			if (tokens.size() > tagIndex + 23 && !tokens[tagIndex + 23].empty())
-				monster.maxXBound = atoi(tokens[tagIndex + 23].c_str());
+				monster.maxXBound = parseAsciiIntOrZero(tokens[tagIndex + 23]);
 			monster.currentHitPoints = monster.initialHitPoints;
 			monster.savedVisible = monster.visible;
 			if (monster.active)
@@ -915,7 +914,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 
 			StartupTimerRecord timer;
 			if (tagIndex >= 1) {
-				timer.initialValue = atoi(tokens[0].c_str());
+				timer.initialValue = parseAsciiIntOrZero(tokens[0]);
 				timer.currentValue = timer.initialValue;
 			}
 			timer.timerName = tokens[tagIndex + 1];
@@ -935,12 +934,12 @@ void Script::parseTownRecords(ResourceManager &resources) {
 
 			StartupRegionRecord region;
 			if (tagIndex >= 6) {
-				region.left = atoi(tokens[0].c_str());
-				region.top = atoi(tokens[1].c_str());
-				region.right = atoi(tokens[2].c_str());
-				region.bottom = atoi(tokens[3].c_str());
-				region.minZ = atoi(tokens[4].c_str());
-				region.maxZ = atoi(tokens[5].c_str());
+				region.left = parseAsciiIntOrZero(tokens[0]);
+				region.top = parseAsciiIntOrZero(tokens[1]);
+				region.right = parseAsciiIntOrZero(tokens[2]);
+				region.bottom = parseAsciiIntOrZero(tokens[3]);
+				region.minZ = parseAsciiIntOrZero(tokens[4]);
+				region.maxZ = parseAsciiIntOrZero(tokens[5]);
 			}
 			region.regionName = tokens[tagIndex + 1];
 			region.direction = tokens[tagIndex + 2];
@@ -960,10 +959,10 @@ void Script::parseTownRecords(ResourceManager &resources) {
 
 			StartupAnimRecord anim;
 			if (tagIndex >= 4) {
-				anim.x = atoi(tokens[0].c_str());
-				anim.y = atoi(tokens[1].c_str());
-				anim.z = atoi(tokens[2].c_str());
-				anim.frameDelay = atoi(tokens[3].c_str());
+				anim.x = parseAsciiIntOrZero(tokens[0]);
+				anim.y = parseAsciiIntOrZero(tokens[1]);
+				anim.z = parseAsciiIntOrZero(tokens[2]);
+				anim.frameDelay = parseAsciiIntOrZero(tokens[3]);
 			}
 			anim.roomName = tokens[tagIndex + 1];
 			anim.resourcePath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
@@ -986,12 +985,12 @@ void Script::parseTownRecords(ResourceManager &resources) {
 
 		StartupObjectRecord object;
 		if (tagIndex >= 6) {
-			object.initialX = atoi(tokens[0].c_str());
-			object.initialY = atoi(tokens[1].c_str());
-			object.boundsX2 = atoi(tokens[2].c_str());
-			object.boundsY2 = atoi(tokens[3].c_str());
-			object.initialZ = atoi(tokens[4].c_str());
-			object.zExtent = atoi(tokens[5].c_str());
+			object.initialX = parseAsciiIntOrZero(tokens[0]);
+			object.initialY = parseAsciiIntOrZero(tokens[1]);
+			object.boundsX2 = parseAsciiIntOrZero(tokens[2]);
+			object.boundsY2 = parseAsciiIntOrZero(tokens[3]);
+			object.initialZ = parseAsciiIntOrZero(tokens[4]);
+			object.zExtent = parseAsciiIntOrZero(tokens[5]);
 		}
 		object.currentX = object.initialX;
 		object.currentY = object.initialY;
@@ -2072,7 +2071,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("CHECK_PERC")) {
-			const int threshold = CLIP<int>(atoi(command->arg1.c_str()), 0, 100);
+			const int threshold = CLIP<int>(parseAsciiIntOrZero(command->arg1), 0, 100);
 			const int roll = g_engine ? (int)g_engine->getRandomNumber(99) : 0;
 			currentTag = roll < threshold ? command->arg2 : command->arg3;
 			continue;
@@ -2373,7 +2372,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		if (command->opcodeName.equalsIgnoreCase("HEAL_PC") ||
 			command->opcodeName.equalsIgnoreCase("ADJ_HP")) {
 			noteMutation(setPlayerCurrentHitPoints(
-				_playerCurrentHitPoints + atoi(command->arg1.c_str())));
+				_playerCurrentHitPoints + parseAsciiIntOrZero(command->arg1)));
 			currentTag = command->arg4;
 			continue;
 		}
@@ -2405,8 +2404,8 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			}
 
 			*requestPlayerGotoXZ = true;
-			*playerGotoX = atoi(command->arg1.c_str());
-			*playerGotoZ = atoi(command->arg2.c_str());
+			*playerGotoX = parseAsciiIntOrZero(command->arg1);
+			*playerGotoZ = parseAsciiIntOrZero(command->arg2);
 			if (continuationTag)
 				*continuationTag = command->arg4;
 			return;
diff --git a/engines/harvester/text.cpp b/engines/harvester/text.cpp
index 88f3ff4f2af..4984e8be46f 100644
--- a/engines/harvester/text.cpp
+++ b/engines/harvester/text.cpp
@@ -27,6 +27,7 @@
 #include "common/fs.h"
 #include "common/ptr.h"
 #include "common/stream.h"
+#include "harvester/parse_utils.h"
 #include "harvester/resources.h"
 
 namespace Harvester {
@@ -165,7 +166,7 @@ bool Text::loadDialogueIndex(ResourceManager &resources) {
 			++cursor;
 
 		const Common::String wavIdString((const char *)_dialogueData.data() + wavIdStart, cursor - wavIdStart);
-		const int wavId = atoi(wavIdString.c_str());
+		const int wavId = parseAsciiIntOrZero(wavIdString);
 
 		while (cursor < _dialogueData.size() && isDialogueDelimiter(_dialogueData[cursor]))
 			++cursor;


Commit: d64f664fa7314d949654dd76e94bf58779a4e8e8
    https://github.com/scummvm/scummvm/commit/d64f664fa7314d949654dd76e94bf58779a4e8e8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:36+02:00

Commit Message:
HARVESTER: Restore inventory secondary-click actions

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/inventory.cpp
    engines/harvester/inventory.h
    engines/harvester/room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index aa2ea7c2443..b42d9666ba5 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1320,6 +1320,30 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - Native bottom-of-screen carry prompts are not drawn with the fallback GUI font path; they are materialized as the `USING_ON_ID` text-entry overlay using `MEDFONT1.CFT` at y `0x1ce`.
 - Plain pickup does not rerun full room materialization. Native mutates the live scene in place for both carry-start and direct-to-inventory pickup, and only action-driven runtime mutations or room transitions trigger the heavier refresh paths.
 
+## Inventory Secondary-Click Actions
+
+**Confidence:** High
+
+**Evidence**
+- In `run_inventory_screen`, the right-click branch first maps the weapon subset (`CLEAVER` through `POOLSTICK`) to fixed combat-loadout ids `1..20`, then calls `set_player_combat_loadout` and refreshes the inventory status state without leaving the inventory loop.
+- The same branch maps a separate hardcoded document/photo subset to fixed action tags rather than object-record metadata: `NOTE_PHOTOCOPY -> GO_BOYLCOPYCU`, `CHECKBOOK -> GO_REGISTERCU`, `CHECKBOOK_PHOTOCOPY -> GO_RGSTRCPYCU`, `TV_DEED -> GO_TVDEED1CU`, `TV_DEED_PHOTOCOPY -> GO_TVDEED2CU`, `CLUE -> GOTO_CLUE_CU`, `AUTOGRAPH -> GO_AUTOGRPHCU`, plus the corresponding Whaley photo, casket photo, permit, lodge-application, safebook, patrol-schedule, and invite entries.
+- After dispatching one of those hardcoded closeup action tags, `run_inventory_screen` tears down the inventory render state and returns to the caller instead of remaining inside the inventory loop.
+- Only the six consumable/self-use items `SANDWICH`, `SANDWICH2`, `SYRINGE`, `ST_ASPRIN`, `ST_COUGHM`, and `ST_VITAMN` fall back to the clicked object's own `action_tag`; that branch stays inside the inventory loop so HP/status changes are visible immediately.
+
+**Key Functions**
+- `run_inventory_screen`
+- `dispatch_room_event_actions`
+- `set_player_combat_loadout`
+
+**Key Data**
+- the weapon-name to loadout-id mapping inside `run_inventory_screen`
+- the document/photo action-tag table at `0xc40c8`
+- `ObjectRecord.action_tag`
+
+**Notes**
+- Native inventory secondary-click is not a generic inspect path. It is a hardcoded dispatcher with three distinct behaviors: weapon loadout toggle, document/photo closeup, and object-defined consumable self-use.
+- The document/photo closeups are driven by global action tags rather than per-object metadata, which is why the ScummVM inventory overlay needs its own recovered mapping table for parity.
+
 ## Current Blockers
 
 - The remaining work is no longer list recovery. The blocker is semantic naming for fields whose shape is clear but whose gameplay meaning is still ambiguous.
diff --git a/TRACKER.md b/TRACKER.md
index 8517124e6c8..3452422fef9 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,10 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, re-opened native `run_inventory_screen @ 0x7df10` in Ghidra specifically to recover the missing inventory secondary-click behavior instead of guessing from room-side scripts or from the existing ScummVM combat-loadout shortcut.
+- Confirmed the native right-click path is a three-way hardcoded dispatcher: weapons map to fixed combat-loadout ids; document/photo items such as `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` map to a fixed action-tag table at `0xc40c8`; and only the six consumable/self-use items (`SANDWICH`, `SANDWICH2`, `SYRINGE`, `ST_ASPRIN`, `ST_COUGHM`, `ST_VITAMN`) fall back to their object `action_tag`.
+- Confirmed the native closeup/document branch tears down the inventory screen after dispatching its hardcoded action tag, while the consumable/action-tag and weapon-loadout branches stay inside the inventory loop so HP/status changes can update in place.
+- Patched `engines/harvester/inventory.{h,cpp}` and `engines/harvester/room.cpp` to mirror that split: the room overlay now resolves the recovered native right-click action table, closes the overlay before document/photo closeups, preserves the existing weapon-loadout toggle, and executes consumable object action tags without closing the inventory. Documented the confirmed native behavior in `ARCHITECTURE.md` and rebuilt `engines/harvester/inventory.o`, `engines/harvester/room.o`, and `scummvm` successfully.
 - On March 20, 2026, scanned `engines/harvester` for native libc/std helper usage instead of assuming the existing parser code already matched ScummVM conventions.
 - Confirmed Harvester's `MIN`/`MAX` calls already come from ScummVM's own `common/util.h`, so the only real replacement target in the engine sources was `atoi`.
 - Added `engines/harvester/parse_utils.h` with `parseAsciiIntOrZero`, a Common-based permissive decimal parser that mirrors Harvester's prior `atoi` usage closely enough for config/script/dialogue fields while relying on `Common::String`, `Common::isSpace`, and `Common::isDigit` instead of libc; rewired all Harvester `atoi` call sites in `script.cpp` and `text.cpp` to use it, removed the direct `<cstdlib>` dependency from `script.cpp`, and rebuilt `engines/harvester/script.o` plus `engines/harvester/text.o` successfully.
@@ -92,7 +96,7 @@
 1. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
 2. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
 3. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
-4. Run a live Harvester validation pass on the inventory overlay: click and hold an inventory item, drag it past the native window `x=69..568`, `y=75..435`, and confirm the overlay closes immediately while the selected item remains active for room use.
+4. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup action closes the inventory before showing the native document view, then right-click `SANDWICH` / `SYRINGE` / `ST_*` consumables and confirm they fire their object action tags while the inventory stays open and the HP portrait updates immediately.
 5. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
 6. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
 7. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
@@ -113,7 +117,7 @@
 3. Validate the tightened inventory and item-use flow against live gameplay, then only extend the remaining confirmed inventory-specific behavior that still blocks parity.
    Files: `engines/harvester/inventory.cpp`, `engines/harvester/room.cpp`, `engines/harvester/script.cpp`.
    Native anchors: `run_inventory_screen @ 0x7df10`, `add_object_to_inventory @ 0x7c8b0`, `sync_player_combat_weapon_resource_icons @ 0x792c0`, and the confirmed inventory carry/use notes in `ARCHITECTURE.md`.
-   Concrete tasks: validate the new live HP portrait refresh plus weapon-loadout toggles, then decide from a desktop pass whether the remaining native inventory-specific gaps are limited to HUD icon strip restoration or broader item secondary-click actions.
+   Concrete tasks: validate the recovered live HP portrait refresh, weapon-loadout toggles, and document/consumable secondary-click actions; if parity still fails after that pass, narrow the remainder to HUD icon strip restoration or another explicit native inventory branch.
    Exit criteria: any remaining inventory parity work is reduced to a short, explicit list of still-unimplemented native inventory behaviors instead of a broad actor-runtime mismatch.
 4. Leave low-level runtime, HMIDRV, and x87 cleanup as deferred RE work unless they block a confirmed gameplay feature.
    Ghidra anchors: the remaining unwind helpers around `rewind_runtime_handler_iterator_by_two @ 0x8f64b` through `FUN_000905df`, the HMIDRV wrapper band around `FUN_00087005`, and the x87/no-87 runtime cluster beginning near `FUN_0008c3d8`.
diff --git a/engines/harvester/inventory.cpp b/engines/harvester/inventory.cpp
index 2cbbf874db7..b9437e696b5 100644
--- a/engines/harvester/inventory.cpp
+++ b/engines/harvester/inventory.cpp
@@ -46,6 +46,12 @@ struct InventoryCombatLoadoutEntry {
 	int loadoutId;
 };
 
+struct InventorySecondaryActionEntry {
+	const char *objectName;
+	const char *actionTag;
+	bool closeInventory;
+};
+
 static const InventoryCombatLoadoutEntry kInventoryCombatLoadoutMap[] = {
 	{ "CLEAVER", 1 },
 	{ "NAILGUN", 2 },
@@ -69,6 +75,38 @@ static const InventoryCombatLoadoutEntry kInventoryCombatLoadoutMap[] = {
 	{ "POOLSTICK", 20 }
 };
 
+// Recovered from native run_inventory_screen: right-clicked document/photo items dispatch
+// hardcoded closeup action tags instead of using their object records directly.
+static const InventorySecondaryActionEntry kInventorySecondaryActionMap[] = {
+	{ "NOTE_PHOTOCOPY", "GO_BOYLCOPYCU", true },
+	{ "PHOTO_OF_WHALEY_HERRILL_PHOTOCOPY", "GO_BROOMCPYCU", true },
+	{ "PHOTO_OF_WHALEY_HERRILL", "GO_BROOMPICCU", true },
+	{ "CASKET_PHOTOCOPY", "GO_CASKTCPYCU", true },
+	{ "CASKET_PHOTO", "GO_CASKTPICCU", true },
+	{ "LODGE_APPLICATION", "GO_LODGAPP1CU", true },
+	{ "COMPLETED_LODGE_APPLICATION", "GO_LODGAPP2CU", true },
+	{ "MEAT_PERMISSION0", "GO_PERMIT1CU", true },
+	{ "MEAT_PERMISSION", "GO_PERMIT2CU", true },
+	{ "CHECKBOOK", "GO_REGISTERCU", true },
+	{ "CHECKBOOK_PHOTOCOPY", "GO_RGSTRCPYCU", true },
+	{ "SAFEBOOK", "GO_SAFEBOOKCU", true },
+	{ "PATROL_SCHED", "GO_SCHEDULECU", true },
+	{ "TV_DEED", "GO_TVDEED1CU", true },
+	{ "TV_DEED_PHOTOCOPY", "GO_TVDEED2CU", true },
+	{ "INVITE", "GO_INVITECU", true },
+	{ "CLUE", "GOTO_CLUE_CU", true },
+	{ "AUTOGRAPH", "GO_AUTOGRPHCU", true }
+};
+
+static const char *const kInventoryObjectActionItems[] = {
+	"SANDWICH",
+	"SANDWICH2",
+	"SYRINGE",
+	"ST_ASPRIN",
+	"ST_COUGHM",
+	"ST_VITAMN"
+};
+
 static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
 	if (!bitmap.isValid())
 		return;
@@ -163,6 +201,36 @@ static bool resolveInventoryCombatLoadoutId(const Common::String &objectName, in
 	return false;
 }
 
+static bool resolveInventorySecondaryActionEntry(const Common::String &objectName,
+		InventorySecondaryAction &action) {
+	action = InventorySecondaryAction();
+	if (objectName.empty())
+		return false;
+
+	for (const InventorySecondaryActionEntry &entry : kInventorySecondaryActionMap) {
+		if (!objectName.equalsIgnoreCase(entry.objectName))
+			continue;
+
+		action.actionTag = entry.actionTag;
+		action.closeInventory = entry.closeInventory;
+		return true;
+	}
+
+	return false;
+}
+
+static bool usesObjectActionForInventorySecondaryClick(const Common::String &objectName) {
+	if (objectName.empty())
+		return false;
+
+	for (const char *itemName : kInventoryObjectActionItems) {
+		if (objectName.equalsIgnoreCase(itemName))
+			return true;
+	}
+
+	return false;
+}
+
 } // End of anonymous namespace
 
 InventorySystem::InventorySystem(HarvesterEngine &engine) : _engine(engine) {
@@ -332,6 +400,19 @@ bool InventorySystem::toggleCombatLoadout(const StartupObjectRecord &object, boo
 	return true;
 }
 
+bool InventorySystem::resolveSecondaryAction(const StartupObjectRecord &object,
+		InventorySecondaryAction &action) const {
+	if (resolveInventorySecondaryActionEntry(object.objectName, action))
+		return true;
+
+	action = InventorySecondaryAction();
+	if (object.actionTag.empty() || !usesObjectActionForInventorySecondaryClick(object.objectName))
+		return false;
+
+	action.actionTag = object.actionTag;
+	return true;
+}
+
 void InventorySystem::setPromptText(const Common::String &promptText) {
 	_promptText = promptText;
 }
diff --git a/engines/harvester/inventory.h b/engines/harvester/inventory.h
index 25d829ab413..d4afa235907 100644
--- a/engines/harvester/inventory.h
+++ b/engines/harvester/inventory.h
@@ -43,6 +43,11 @@ struct StartupInventoryVisual {
 	bool hasBitmap = false;
 };
 
+struct InventorySecondaryAction {
+	Common::String actionTag;
+	bool closeInventory = false;
+};
+
 class InventorySystem {
 public:
 	explicit InventorySystem(HarvesterEngine &engine);
@@ -59,6 +64,7 @@ public:
 	Common::String buildSelectedPrompt(const Common::String &targetLabel) const;
 	void selectItem(const Common::String &objectName);
 	bool toggleCombatLoadout(const StartupObjectRecord &object, bool &changed);
+	bool resolveSecondaryAction(const StartupObjectRecord &object, InventorySecondaryAction &action) const;
 	void setPromptText(const Common::String &promptText);
 	const Common::String &getPromptText() const;
 	const StartupInventoryVisual *findItemAtPoint(const Common::Point &point) const;
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 65cecca439a..d1c9258a375 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1177,6 +1177,30 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 							needsRedraw = true;
 							break;
 						}
+
+						InventorySecondaryAction secondaryAction;
+						if (_inventory.resolveSecondaryAction(inventoryHover->object, secondaryAction)) {
+							if (secondaryAction.closeInventory)
+								(void)_inventory.close();
+
+							StartupInteractionResult interaction;
+							if (_engine.getStartupScript()->executeActionTag(
+									secondaryAction.actionTag, interaction)) {
+								bool didTransition = false;
+								Common::Error interactionError =
+									interactionProcessor.handleInteractionResult(
+										interaction, didTransition, Common::String());
+								if (interactionError.getCode() != Common::kNoError)
+									return interactionError;
+								if (startupFlow.hasPendingMainMenuReturn())
+									return Common::kNoError;
+							}
+
+							if (!_inventory.refresh())
+								return Common::kReadingFailed;
+							needsRedraw = true;
+							break;
+						}
 					}
 					if (_inventory.close())
 						needsRedraw = true;


Commit: a8423e9ccfa8c7e789bd6c11ce51eefdd4d213f1
    https://github.com/scummvm/scummvm/commit/a8423e9ccfa8c7e789bd6c11ce51eefdd4d213f1
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:37+02:00

Commit Message:
HARVESTER: Restore POL_EVD exit cutscene playback

Native HARVEST.SCR gates POL_EVD room exit through
JUST_LEFT_EV_ROOM_1ST, plays GRAPHIC/FST/C044.FST on first exit, then
latches the flag via a continuation tag.

ScummVM was evaluating room exit commands with audio-only outputs, so
GOFLIC was dropped while the continuation still advanced the one-shot
state. Preserve full room-exit interaction results, play deferred FSTs
during room handoff, and continue chained action tags afterward.

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index 3452422fef9..e8e4b76c92d 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,10 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, re-opened the sheriff evidence-room return path in native Harvester instead of treating the missing `POLEVD`/`POL_EVD` movie as another generic room-loop regression.
+- Confirmed from the decoded `HARVEST.SCR` sheriff-office cluster that the evidence room is `POL_EVD`, its exit region `EVDEXIT1` changes back to entrance `EVED_2_NSID`, and the room on-exit chain is explicitly one-shot: `POL_EVD_EXIT_COM` checks `JUST_LEFT_EV_ROOM_1ST`, runs `GOFLIC "GRAPHIC\FST\C044.FST"` on the first exit, then continues to `POL_EVD_EXIT_COM_2` to set `JUST_LEFT_EV_ROOM_1ST = T`. Ghidra also exposes the matching hardcoded `C044.FST` wrapper site at the sole xref to the asset string.
+- Traced the ScummVM gap to `Script::executeRoomExitCommands`: room on-exit commands were being evaluated with audio-only output plumbing, so `GOFLIC` had no cutscene context, got skipped, and still fell through to the continuation tag that consumed the one-shot flag.
+- Patched `engines/harvester/script.{h,cpp}` and `engines/harvester/room.cpp` so room exit commands now preserve deferred interaction outputs, play exit-chain FSTs before the room handoff completes, and then continue through any chained action tag such as `POL_EVD_EXIT_COM_2`; rebuilt `engines/harvester/script.o`, `engines/harvester/room.o`, and `scummvm` successfully.
 - On March 20, 2026, re-opened native `run_inventory_screen @ 0x7df10` in Ghidra specifically to recover the missing inventory secondary-click behavior instead of guessing from room-side scripts or from the existing ScummVM combat-loadout shortcut.
 - Confirmed the native right-click path is a three-way hardcoded dispatcher: weapons map to fixed combat-loadout ids; document/photo items such as `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` map to a fixed action-tag table at `0xc40c8`; and only the six consumable/self-use items (`SANDWICH`, `SANDWICH2`, `SYRINGE`, `ST_ASPRIN`, `ST_COUGHM`, `ST_VITAMN`) fall back to their object `action_tag`.
 - Confirmed the native closeup/document branch tears down the inventory screen after dispatching its hardcoded action tag, while the consumable/action-tag and weapon-loadout branches stay inside the inventory loop so HP/status changes can update in place.
@@ -93,14 +97,15 @@
 
 ## Next Suggested Action
 
-1. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
-2. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
-3. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
-4. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup action closes the inventory before showing the native document view, then right-click `SANDWICH` / `SYRINGE` / `ST_*` consumables and confirm they fire their object action tags while the inventory stays open and the HP portrait updates immediately.
-5. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
-6. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
-7. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
-8. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
+1. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
+2. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
+3. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
+4. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
+5. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup action closes the inventory before showing the native document view, then right-click `SANDWICH` / `SYRINGE` / `ST_*` consumables and confirm they fire their object action tags while the inventory stays open and the HP portrait updates immediately.
+6. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
+7. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
+8. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
+9. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
 
 ## Reimplementation Priority Order
 
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index d1c9258a375..2ce34f1f8c5 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -527,12 +527,47 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
 		return captureScreenBackdrop(*activeScreen, dialogueBackdrop);
 	};
-		auto runRoomExitCommands = [&]() {
-			Common::Array<StartupAudioCommand> exitAudioCommands;
-			if (!_engine.getStartupScript()->executeRoomExitCommands(scene.state.roomName, exitAudioCommands))
-				return false;
-			startupFlow.executeStartupAudioCommands(exitAudioCommands);
-			return true;
+	auto runRoomExitCommands = [&]() -> Common::Error {
+			StartupInteractionResult exitInteraction;
+			if (!_engine.getStartupScript()->executeRoomExitCommands(scene.state.roomName, exitInteraction))
+				return Common::kReadingFailed;
+
+			for (uint exitStep = 0; exitStep < 128; ++exitStep) {
+				if (!exitInteraction.musicPath.empty())
+					(void)_engine.playStartupMusic(exitInteraction.musicPath);
+				startupFlow.executeStartupAudioCommands(exitInteraction.audioCommands);
+
+				if (!exitInteraction.cutscenePath.empty()) {
+					FstPlayer fstPlayer(_engine);
+					if (!fstPlayer.play(exitInteraction.cutscenePath))
+						return Common::kReadingFailed;
+				}
+
+				if (!exitInteraction.dialogueNpcName.empty() ||
+						!exitInteraction.dialogueContinuationTag.empty() ||
+						!exitInteraction.modalText.value.empty() ||
+						exitInteraction.lightingCommand != kStartupLightingCommandNone ||
+						exitInteraction.requestPlayerGotoXZ) {
+					debugC(1, kDebugScene,
+						"Harvester: room exit command for '%s' produced unsupported deferred output; preserving accumulated state",
+						scene.state.roomName.c_str());
+				}
+
+				if (exitInteraction.continuationTag.empty())
+					return Common::kNoError;
+
+				StartupInteractionResult continuationInteraction;
+				if (!_engine.getStartupScript()->executeActionTag(
+						exitInteraction.continuationTag, continuationInteraction, false)) {
+					return Common::kNoError;
+				}
+
+				exitInteraction = continuationInteraction;
+			}
+
+			warning("Harvester: room exit command chain for '%s' exceeded continuation safety limit",
+				scene.state.roomName.c_str());
+			return Common::kNoError;
 		};
 		auto applyLightingCommand = [&](StartupLightingCommand lightingCommand) -> Common::Error {
 			Graphics::Screen *activeScreen = getActiveScreen();
@@ -710,15 +745,17 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					if (resolvedTransitionTarget.empty())
 						return Common::kNoError;
 
-					if (!runRoomExitCommandsFn())
-						return Common::kReadingFailed;
+					Common::Error exitError = runRoomExitCommandsFn();
+					if (exitError.getCode() != Common::kNoError)
+						return exitError;
 
 					// Native CHANGE_ROOM queues a room handoff for the live loop instead of nesting.
 					pendingRoomChange = resolvedTransitionTarget;
 					didTransition = true;
 				} else if (!interaction.nextRoomName.empty()) {
-					if (!runRoomExitCommandsFn())
-						return Common::kReadingFailed;
+					Common::Error exitError = runRoomExitCommandsFn();
+					if (exitError.getCode() != Common::kNoError)
+						return exitError;
 
 					Common::Error roomError = startupFlow.runRoomLoop(interaction.nextRoomName);
 					if (startupFlow.hasPendingMainMenuReturn())
@@ -1395,8 +1432,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				pendingRegionName.clear();
 				if (clickedObject->objectName.equalsIgnoreCase("EXIT_BM") ||
 					clickedObject->objectName.equalsIgnoreCase("EXIT_HS")) {
-					if (!runRoomExitCommands())
-						return Common::kReadingFailed;
+					Common::Error exitError = runRoomExitCommands();
+					if (exitError.getCode() != Common::kNoError)
+						return exitError;
 					return Common::kNoError;
 				}
 
@@ -1552,8 +1590,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
 					if (!stowCarriedRoomItemToInventory())
 						return Common::kReadingFailed;
-					if (!runRoomExitCommands())
-						return Common::kReadingFailed;
+					Common::Error exitError = runRoomExitCommands();
+					if (exitError.getCode() != Common::kNoError)
+						return exitError;
 					return Common::kNoError;
 				}
 				break;
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 11eb79d79d7..4b24439e063 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1367,18 +1367,19 @@ bool Script::materializeRoomState(const Common::String &entranceName, const Comm
 }
 
 bool Script::executeRoomExitCommands(const Common::String &roomName,
-		Common::Array<StartupAudioCommand> &audioCommands) {
-	audioCommands.clear();
+		StartupInteractionResult &result) {
+	result = StartupInteractionResult();
 
 	const StartupRoomRecord *room = findRoomRecord(roomName);
 	if (!room)
 		return false;
 
-	bool mutatedRuntimeState = false;
 	executeCommandChain(room->onExitCommand, "room exit command", room->roomName, false,
-		nullptr, &audioCommands, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
-		nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
-		&mutatedRuntimeState);
+		&result.musicPath, &result.audioCommands, nullptr, nullptr, &result.cutscenePath,
+		nullptr, nullptr, &result.dialogueNpcName, &result.dialogueContinuationTag,
+		&result.continuationTag, &result.modalText, &result.lightingCommand,
+		&result.requestPlayerGotoXZ, &result.playerGotoX, &result.playerGotoZ,
+		&result.mutatedRuntimeState);
 	return true;
 }
 
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 8da4c7fd1f5..8fc564e675b 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -384,30 +384,30 @@ public:
 		ResourceManager &resources);
 	bool materializeRoomState(const Common::String &entranceName, const Common::String &roomName,
 		StartupRoomSetupState &state, ResourceManager &resources) const;
-		bool executeRoomExitCommands(const Common::String &roomName, Common::Array<StartupAudioCommand> &audioCommands);
-		bool resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result);
-		bool resolveRegionInteraction(const StartupRegionRecord &region, StartupInteractionResult &result);
-		bool resolveUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target,
-			StartupInteractionResult &result);
-		bool executeActionTag(const Common::String &tag, StartupInteractionResult &result,
-			bool allowTransitions = true);
-		bool executeNestedActionTag(const Common::String &tag, StartupInteractionResult &result,
-			bool allowTransitions = true);
-		bool isPickupObject(const StartupObjectRecord &object) const;
-		bool hasObjectInteraction(const StartupObjectRecord &object) const;
-		bool hasUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target) const;
-		void getVisibleInventoryObjects(Common::Array<StartupObjectRecord> &objects) const;
-		void markObjectIdentShown(const StartupObjectRecord &object);
-		bool resolveObjectInspectText(const StartupObjectRecord &object, StartupResolvedText &text) const;
-		bool resolveTextRecord(const Common::String &key, StartupResolvedText &text) const;
-		Common::String resolveObjectLabel(const StartupObjectRecord &object) const;
-		Common::String resolveTextValue(const Common::String &key) const;
-		const StartupHeadRecord *findHeadRecord(const Common::String &headId) const;
-		const StartupEntranceRecord *findEntranceRecord(const Common::String &entranceName) const;
-		const StartupMapEntranceRecord *findMapEntranceRecord(const Common::String &entryName) const;
-		bool getFlagValue(const Common::String &flagName) const;
-		const StartupNpcRecord *findRuntimeNpcRecord(const Common::String &npcName) const;
-		bool setRuntimeFlagValue(const Common::String &flagName, bool value);
+	bool executeRoomExitCommands(const Common::String &roomName, StartupInteractionResult &result);
+	bool resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result);
+	bool resolveRegionInteraction(const StartupRegionRecord &region, StartupInteractionResult &result);
+	bool resolveUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target,
+		StartupInteractionResult &result);
+	bool executeActionTag(const Common::String &tag, StartupInteractionResult &result,
+		bool allowTransitions = true);
+	bool executeNestedActionTag(const Common::String &tag, StartupInteractionResult &result,
+		bool allowTransitions = true);
+	bool isPickupObject(const StartupObjectRecord &object) const;
+	bool hasObjectInteraction(const StartupObjectRecord &object) const;
+	bool hasUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target) const;
+	void getVisibleInventoryObjects(Common::Array<StartupObjectRecord> &objects) const;
+	void markObjectIdentShown(const StartupObjectRecord &object);
+	bool resolveObjectInspectText(const StartupObjectRecord &object, StartupResolvedText &text) const;
+	bool resolveTextRecord(const Common::String &key, StartupResolvedText &text) const;
+	Common::String resolveObjectLabel(const StartupObjectRecord &object) const;
+	Common::String resolveTextValue(const Common::String &key) const;
+	const StartupHeadRecord *findHeadRecord(const Common::String &headId) const;
+	const StartupEntranceRecord *findEntranceRecord(const Common::String &entranceName) const;
+	const StartupMapEntranceRecord *findMapEntranceRecord(const Common::String &entryName) const;
+	bool getFlagValue(const Common::String &flagName) const;
+	const StartupNpcRecord *findRuntimeNpcRecord(const Common::String &npcName) const;
+	bool setRuntimeFlagValue(const Common::String &flagName, bool value);
 		bool resetRuntimeObjectToInitialState(const Common::String &objectName);
 		bool addRuntimeObjectToInventory(const Common::String &objectName);
 		bool setRuntimeObjectVisible(const Common::String &ownerOrRoom, const Common::String &objectName,


Commit: 32c0afa1999690922c0755c28ed53618a6357774
    https://github.com/scummvm/scummvm/commit/32c0afa1999690922c0755c28ed53618a6357774
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:37+02:00

Commit Message:
HARVESTER: Slow room transition fades

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/flow.cpp
    engines/harvester/flow.h
    engines/harvester/room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index b42d9666ba5..4f19e804016 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -254,6 +254,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `room_setup` resolves the requested room or `START` entrance, finds the matching room definition, loads the room palette, instantiates exits / hotspots / overlays / room actors, positions the player, runs room-enter handlers, and updates the current music file.
 - `g_current_palette_buffer` at `0xd6084` is the shared room/menu palette buffer used by `run_harvester_main_loop`, `room_setup`, `dispatch_room_event_actions`, inventory/help screens, and the CD prompt path.
   - `room_setup` first resolves the target room / entrance and, when `g_current_room_def->palette_path` is non-null, rereads that room palette into `g_current_palette_buffer`.
+  - Before rebuilding the wait transition, `room_setup` uses `ramp_palette_brightness` to fade the outgoing palette down to black when a current room palette is active; only the no-current-room startup case skips directly to a black palette upload.
   - It then reloads `WAIT.PAL` and `WAIT.ABM`, hides the cursor entity, flushes the wait transition while the cursor is hidden, re-shows the cursor, uploads the wait palette, builds the room entities, and only after that uploads the room palette from `g_current_palette_buffer`.
   - Once the wait transition is flushed, `room_setup` rebuilds the room render list in this order: matching enabled regions, room timers, visible object records whose `current_owner_or_room` matches the room, then room `ANIM` records whose `active` or `visible` state is set.
   - On the player-present branch it then re-adds the stored live player actor from `DAT_000d5bd4`, places it from `g_current_room_entrance`, and seeds exact facing frames `0x3b`, `0x0e`, `0x2c`, and `0x28` for entrance-facing values `0`, `1`, `2`, and `3`.
@@ -264,7 +265,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `upload_palette_to_vga` at `0x22770` writes a caller-supplied RGB palette to the VGA DAC, forces palette index 0 to black, and applies a caller-supplied brightness scalar before clamping each channel to the hardware `0x3f` range.
   - Archive inspection plus the helper body confirm the room/menu `.PAL` resources are already stored as 768-byte 8-bit RGB triplets; the upload path shifts those bytes down to VGA `0..63` before brightness scaling instead of expanding 6-bit source values.
 - `ramp_palette_brightness` at `0x23a30` is the shared timed palette ramp helper.
-  - `room_setup` uses it as a fade-in from `0.0` to either `1.0` or the dimmed target `0.6`.
+  - `room_setup`, `run_inventory_screen`, and `dispatch_room_event_actions` all call it on room / closeup transition paths.
+  - The helper steps brightness in `0.1` increments and waits until `get_elapsed_milliseconds() + 4` between uploads while pumping `update_music_stream`, so the native fade pacing comes from repeated short timed palette uploads rather than a single blocking delay.
   - The helper updates music stream state between palette uploads instead of blocking in a tight delay loop.
 - `dispatch_room_event_actions` at `0x60ee0` interprets action records keyed by a tag string. It is used by `room_setup`, the main loop, and nested action groups.
 - `parse_command_record` at `0x5e410` now has enough parser evidence to anchor the room-handoff opcodes directly.
diff --git a/TRACKER.md b/TRACKER.md
index e8e4b76c92d..bce6fa20eb1 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, re-opened native `room_setup @ 0x73540`, `run_inventory_screen @ 0x7df10`, and `ramp_palette_brightness @ 0x23a30` to compare the inventory-document closeup transition against the current ScummVM fade timing instead of only tuning the visible effect by eye.
+- Confirmed the native transition sequence is layered: `room_setup` fades the outgoing palette to black first, flushes `WAIT_BM` under `WAIT.PAL` while the destination room is being built, then fades the destination palette back in. Also confirmed `ramp_palette_brightness` itself advances in `0.1` brightness steps with a `get_elapsed_milliseconds() + 4` pacing loop while pumping `update_music_stream`; there is no separate explicit wait-frame timer in the original code.
+- Patched `engines/harvester/flow.{h,cpp}` and `engines/harvester/room.cpp` to restore the missing fade-out before the wait frame, keep `WAIT_BM` visible for a short minimum floor on fast local loads so the transition still reads like the native loader, and then run the existing room fade-in once the destination scene is ready. Updated `ARCHITECTURE.md` with the confirmed native transition details and rebuilt `engines/harvester/flow.o`, `engines/harvester/room.o`, and `scummvm` successfully.
 - On March 20, 2026, re-opened the sheriff evidence-room return path in native Harvester instead of treating the missing `POLEVD`/`POL_EVD` movie as another generic room-loop regression.
 - Confirmed from the decoded `HARVEST.SCR` sheriff-office cluster that the evidence room is `POL_EVD`, its exit region `EVDEXIT1` changes back to entrance `EVED_2_NSID`, and the room on-exit chain is explicitly one-shot: `POL_EVD_EXIT_COM` checks `JUST_LEFT_EV_ROOM_1ST`, runs `GOFLIC "GRAPHIC\FST\C044.FST"` on the first exit, then continues to `POL_EVD_EXIT_COM_2` to set `JUST_LEFT_EV_ROOM_1ST = T`. Ghidra also exposes the matching hardcoded `C044.FST` wrapper site at the sole xref to the asset string.
 - Traced the ScummVM gap to `Script::executeRoomExitCommands`: room on-exit commands were being evaluated with audio-only output plumbing, so `GOFLIC` had no cutscene context, got skipped, and still fell through to the continuation tag that consumed the one-shot flag.
@@ -101,7 +104,7 @@
 2. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
 3. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
 4. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
-5. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup action closes the inventory before showing the native document view, then right-click `SANDWICH` / `SYRINGE` / `ST_*` consumables and confirm they fire their object action tags while the inventory stays open and the HP portrait updates immediately.
+5. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
 6. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
 7. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
 8. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 750b03c472a..39f182ef33e 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -77,6 +77,8 @@ static const char *const kPlayerActorResourcePath = "1:/GRAPHIC/MONSTERS/PC/PC0.
 static const uint32 kPaletteFadeTickMs = 4;
 static const float kPaletteFadeStep = 0.1f;
 static const float kPaletteBrightnessBlack = 0.0f;
+// Native keeps WAIT_BM visible while room construction runs; fast local loads need a short floor.
+static const uint32 kTransitionWaitFrameMinMs = 120;
 static const float kRoomPlayerHorizontalMoveBase = 8.0f;
 static const int kRoomRegionTargetXBias = 10;
 static const float kRoomDepthCompareEpsilon = 0.01f;
@@ -843,6 +845,14 @@ static void setScaledPalette(Graphics::Screen &screen, const byte *palette, floa
 	screen.setPalette(scaledPalette);
 }
 
+static void setScaledDisplayPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
+	byte scaledPalette[256 * 3];
+	const float clampedBrightness = CLIP<float>(brightness, 0.0f, 1.0f);
+	for (uint i = 0; i < ARRAYSIZE(scaledPalette); ++i)
+		scaledPalette[i] = (byte)CLIP<int>((int)(palette[i] * clampedBrightness + 0.5f), 0, 255);
+	screen.setPalette(scaledPalette);
+}
+
 static bool loadPaletteResource(ResourceManager &resources, const Common::String &path, byte *dest) {
 	Common::Array<byte> data;
 	if (!resources.loadFile(path, data) || data.size() < 256 * 3)
@@ -1964,6 +1974,10 @@ Common::Error Flow::runQuickTips() {
 		if (!populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
 			return Common::kReadingFailed;
 
+		transitionError = waitForRoomSetupTransitionHold();
+		if (transitionError.getCode() != Common::kNoError)
+			return transitionError;
+
 		logScenePaletteSummary("quick tips scene palette", scene, kPaletteBrightnessBlack);
 		drawRoomScene(_engine, *screen, scene, kPaletteBrightnessBlack);
 		screen->makeAllDirty();
@@ -2472,13 +2486,47 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 }
 
 Common::Error Flow::beginRoomSetupTransition() {
+	Graphics::Screen *screen = _engine.getScreen();
+	if (screen) {
+		byte displayPalette[256 * 3];
+		screen->getPalette(displayPalette);
+		bool hasVisiblePalette = false;
+		for (byte value : displayPalette) {
+			if (value != 0) {
+				hasVisiblePalette = true;
+				break;
+			}
+		}
+
+		if (hasVisiblePalette) {
+			for (float brightness = 1.0f - kPaletteFadeStep;
+					brightness > kPaletteBrightnessBlack; brightness -= kPaletteFadeStep) {
+				setScaledDisplayPalette(*screen, displayPalette, brightness);
+				screen->makeAllDirty();
+				screen->update();
+
+				const uint32 nextTick = g_system->getMillis() + kPaletteFadeTickMs;
+				while ((int32)(nextTick - g_system->getMillis()) > 0) {
+					Common::Error result = Common::kNoError;
+					if (pumpTransitionEvents(result))
+						return result;
+					g_system->delayMillis(1);
+				}
+			}
+
+			setScaledDisplayPalette(*screen, displayPalette, kPaletteBrightnessBlack);
+			screen->makeAllDirty();
+			screen->update();
+		}
+	}
+
 	if (_engine.getRuntimeEntities())
 		_engine.getRuntimeEntities()->hideCursor();
 
 	const Art *art = _engine.getStartupArt();
-	Graphics::Screen *screen = _engine.getScreen();
 	if (art && screen)
 		art->drawWaitFrame(*screen);
+	_roomSetupTransitionShownTick = g_system ? g_system->getMillis() : 0;
 
 	Common::Error result = Common::kNoError;
 	if (pumpTransitionEvents(result))
@@ -2487,6 +2535,22 @@ Common::Error Flow::beginRoomSetupTransition() {
 	return Common::kNoError;
 }
 
+Common::Error Flow::waitForRoomSetupTransitionHold() {
+	if (_roomSetupTransitionShownTick == 0 || !g_system)
+		return Common::kNoError;
+
+	const uint32 deadline = _roomSetupTransitionShownTick + kTransitionWaitFrameMinMs;
+	while ((int32)(deadline - g_system->getMillis()) > 0) {
+		Common::Error result = Common::kNoError;
+		if (pumpTransitionEvents(result))
+			return result;
+		g_system->delayMillis(1);
+	}
+
+	_roomSetupTransitionShownTick = 0;
+	return Common::kNoError;
+}
+
 Common::Error Flow::fadeInRoomScene(const byte *palette, float targetBrightness) {
 	Graphics::Screen *screen = _engine.getScreen();
 	if (!screen || !palette)
diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index 8b32701d35d..9a32d8ff3f4 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -69,9 +69,10 @@ private:
 		const Common::Array<StartupObjectRecord> &drawableObjects,
 		const Common::Array<StartupAnimRecord> &drawableAnimations);
 	Common::Error beginRoomSetupTransition();
-		Common::Error fadeInRoomScene(const byte *palette, float targetBrightness);
-		bool pumpTransitionEvents(Common::Error &result);
-		void executeStartupAudioCommands(const Common::Array<StartupAudioCommand> &commands);
+	Common::Error waitForRoomSetupTransitionHold();
+	Common::Error fadeInRoomScene(const byte *palette, float targetBrightness);
+	bool pumpTransitionEvents(Common::Error &result);
+	void executeStartupAudioCommands(const Common::Array<StartupAudioCommand> &commands);
 	void queueDialogueInteraction(const StartupInteractionResult &interaction);
 	bool takeQueuedDialogueInteraction(StartupInteractionResult &interaction);
 	void prepareForNewGame();
@@ -100,6 +101,7 @@ private:
 	bool _hasQueuedDialogueInteraction = false;
 	bool _pendingNewGameRestart = false;
 	bool _pendingMainMenuReturn = false;
+	uint32 _roomSetupTransitionShownTick = 0;
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 2ce34f1f8c5..b7b39cc7c19 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -340,6 +340,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			runtimeEntities->pauseTimerCountdowns();
 		}
 
+		transitionError = startupFlow.waitForRoomSetupTransitionHold();
+		if (transitionError.getCode() != Common::kNoError)
+			return transitionError;
+
 		logScenePaletteSummary("room setup stub palette", scene, 0.0f);
 		drawRoomScene(_engine, *screen, scene, 0.0f);
 		screen->makeAllDirty();


Commit: ee7bc8884664fc77bc0572903e411cce3ace422e
    https://github.com/scummvm/scummvm/commit/ee7bc8884664fc77bc0572903e411cce3ace422e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:37+02:00

Commit Message:
HARVESTER: Restore native inventory hover tooltips

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 4f19e804016..52f48c1a459 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1346,6 +1346,30 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - Native inventory secondary-click is not a generic inspect path. It is a hardcoded dispatcher with three distinct behaviors: weapon loadout toggle, document/photo closeup, and object-defined consumable self-use.
 - The document/photo closeups are driven by global action tags rather than per-object metadata, which is why the ScummVM inventory overlay needs its own recovered mapping table for parity.
 
+## Inventory Hover Tooltip
+
+**Confidence:** High
+
+**Evidence**
+- In `run_inventory_screen`, the plain hover branch only runs when no inventory item is actively selected and neither mouse button is pressed. It walks `g_text_records`, matches the hovered inventory object's `inventory_text_key`, and rebuilds the hover label only when the resolved text changes.
+- The rebuilt label is created through `spawn_text_entity(..., "INV_TEXT", 0xbc, 0x19e, 0xfffffff4, 0)`, which confirms a fixed left-aligned tooltip anchor at `(188, 414)` with color `-12` rather than the centered bottom-of-screen interaction prompt path.
+- The callsite loads `EBX` from `g_textfont_cft @ 0xd5bc4` immediately before `spawn_text_entity`, so the inventory hover label uses `TEXTFONT.CFT`, not `MEDFONT1.CFT`.
+- The separate carry/use branch still measures text with `g_medfont1_cft` and spawns the centered `USING_ON_ID` overlay at y `0x1ce`, so the inventory tooltip and the carry prompt are distinct native UI elements.
+
+**Key Functions**
+- `run_inventory_screen`
+- `spawn_text_entity`
+- `spawn_text_entry_entity`
+
+**Key Data**
+- `ObjectRecord.inventory_text_key`
+- `g_textfont_cft`
+- `g_medfont1_cft`
+
+**Notes**
+- Native inventory hover text is not centered and does not reuse the room hover prompt renderer.
+- The inventory exit entity is handled separately; the fixed `INV_TEXT` hover path applies to inventory objects and the `INV_STAT*` status entities that resolve through `inventory_text_key`.
+
 ## Current Blockers
 
 - The remaining work is no longer list recovery. The blocker is semantic naming for fields whose shape is clear but whose gameplay meaning is still ambiguous.
diff --git a/TRACKER.md b/TRACKER.md
index bce6fa20eb1..21f403e9168 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, re-opened native `run_inventory_screen @ 0x7df10` in Ghidra to verify the inventory hover tooltip path instead of continuing to reuse the room prompt renderer by approximation.
+- Confirmed the plain inventory hover branch resolves the hovered object's `inventory_text_key`, rebuilds a dedicated `INV_TEXT` entity at fixed coordinates `(0xbc, 0x19e)`, and uses `g_textfont_cft @ 0xd5bc4` with color `0xfffffff4`; the centered bottom carry prompt is a different path that still uses `g_medfont1_cft` and `USING_ON_ID` at y `0x1ce`.
+- Patched `engines/harvester/script.{h,cpp}` and `engines/harvester/room.cpp` so inventory hover labels now use an inventory-only text resolver plus a dedicated `TEXTFONT` draw path at the native anchor instead of the centered room prompt path. Updated `ARCHITECTURE.md` with the confirmed native tooltip split.
 - On March 20, 2026, re-opened native `room_setup @ 0x73540`, `run_inventory_screen @ 0x7df10`, and `ramp_palette_brightness @ 0x23a30` to compare the inventory-document closeup transition against the current ScummVM fade timing instead of only tuning the visible effect by eye.
 - Confirmed the native transition sequence is layered: `room_setup` fades the outgoing palette to black first, flushes `WAIT_BM` under `WAIT.PAL` while the destination room is being built, then fades the destination palette back in. Also confirmed `ramp_palette_brightness` itself advances in `0.1` brightness steps with a `get_elapsed_milliseconds() + 4` pacing loop while pumping `update_music_stream`; there is no separate explicit wait-frame timer in the original code.
 - Patched `engines/harvester/flow.{h,cpp}` and `engines/harvester/room.cpp` to restore the missing fade-out before the wait frame, keep `WAIT_BM` visible for a short minimum floor on fast local loads so the transition still reads like the native loader, and then run the existing room fade-in once the destination scene is ready. Updated `ARCHITECTURE.md` with the confirmed native transition details and rebuilt `engines/harvester/flow.o`, `engines/harvester/room.o`, and `scummvm` successfully.
@@ -100,15 +103,16 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
-2. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
-3. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
-4. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
-5. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
-6. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
-7. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
-8. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
-9. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
+1. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
+2. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
+3. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
+4. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
+5. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
+6. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
+7. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
+8. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
+9. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
+10. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
 
 ## Reimplementation Priority Order
 
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index b7b39cc7c19..dc7762e09fb 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -52,6 +52,9 @@ static const int kNativeInventoryDragCloseLeft = 0x45;
 static const int kNativeInventoryDragCloseTop = 0x4b;
 static const int kNativeInventoryDragCloseRight = 0x239;
 static const int kNativeInventoryDragCloseBottom = 0x1b4;
+static const int kNativeInventoryTooltipX = 0xbc;
+static const int kNativeInventoryTooltipY = 0x19e;
+static const byte kNativeInventoryTooltipColor = 0xf4;
 
 struct MonsterAnimationRange {
 	MonsterAnimationRange() {}
@@ -203,6 +206,15 @@ static void drawRoomPrompt(Graphics::Screen &screen, const Graphics::Font &font,
 	font.drawString(&screen, promptText, 0, 462, 640, 0xce, Graphics::kTextAlignCenter);
 }
 
+static void drawInventoryTooltip(Graphics::Screen &screen, const Graphics::Font &font,
+		const Common::String &tooltipText) {
+	if (tooltipText.empty())
+		return;
+
+	font.drawString(&screen, tooltipText, kNativeInventoryTooltipX, kNativeInventoryTooltipY,
+		font.getStringWidth(tooltipText), kNativeInventoryTooltipColor);
+}
+
 static void setScaledRoomPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
 	byte scaledPalette[256 * 3];
 	const float gammaBrightness = g_engine ? g_engine->getStartupGammaBrightnessScale() : 1.0f;
@@ -315,8 +327,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		const Art *art = _engine.getStartupArt();
 		const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
 		const CftFontResource *promptFontResource = findStartupFontByName(_engine, "MEDFONT1");
+		const CftFontResource *inventoryTooltipFontResource = findStartupFontByName(_engine, "TEXTFONT");
 		Common::ScopedPtr<HarvesterCftFont> promptCftFont;
+		Common::ScopedPtr<HarvesterCftFont> inventoryTooltipCftFont;
 		const Graphics::Font *promptFont = bodyFont;
+		const Graphics::Font *inventoryTooltipFont = bodyFont;
 		bool useNativePromptFont = false;
 		if (promptFontResource) {
 			promptCftFont.reset(new HarvesterCftFont(*promptFontResource));
@@ -325,7 +340,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				useNativePromptFont = true;
 			}
 		}
-		if (!screen || !art || !bodyFont || !promptFont)
+		if (inventoryTooltipFontResource) {
+			inventoryTooltipCftFont.reset(new HarvesterCftFont(*inventoryTooltipFontResource));
+			if (inventoryTooltipCftFont->isValid())
+				inventoryTooltipFont = inventoryTooltipCftFont.get();
+		}
+		if (!screen || !art || !bodyFont || !promptFont || !inventoryTooltipFont)
 			return Common::kNoError;
 		RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
 
@@ -1100,6 +1120,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				: resolveRoomHoverState(_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
 					scene.sceneRegions, _mousePos);
 			Common::String promptText;
+			Common::String inventoryTooltipText;
 			auto resolveCarryTargetLabel = [&]() {
 				if (hoverState.playerEntity && playerState.entity && hoverState.playerEntity == playerState.entity)
 					return Common::String(kPlayerInventoryLabel);
@@ -1133,10 +1154,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			} else if (_inventory.isOpen()) {
 				const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
 				if (inventoryHover &&
-						!InventorySystem::isStatusObject(inventoryHover->object)) {
-					promptText = _engine.getStartupScript()->resolveObjectLabel(inventoryHover->object);
+						!InventorySystem::isExitObject(inventoryHover->object)) {
+					inventoryTooltipText =
+						_engine.getStartupScript()->resolveInventoryTooltipText(inventoryHover->object);
 				}
-				_inventory.setPromptText(promptText);
+				_inventory.setPromptText(Common::String());
 				hoverState.cursorSequence = 7;
 			} else {
 				promptText = hoverState.promptText;
@@ -1162,6 +1184,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 			if (showingInspectText) {
 				drawRoomInspectText(*activeScreen, *art, *bodyFont, inspectText);
+			} else if (!inventoryTooltipText.empty()) {
+				drawInventoryTooltip(*activeScreen, *inventoryTooltipFont, inventoryTooltipText);
 			} else if (!promptText.empty()) {
 				drawRoomPrompt(*activeScreen, *promptFont, promptText, useNativePromptFont);
 			}
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 4b24439e063..4ed5091569d 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -2554,11 +2554,19 @@ bool Script::resolveTextRecord(const Common::String &key, StartupResolvedText &t
 	return !text.value.empty();
 }
 
+Common::String Script::resolveInventoryTooltipText(const StartupObjectRecord &object) const {
+	const StartupTextRecord *textRecord = findTextRecord(object.inventoryTextKey);
+	if (textRecord && !textRecord->value.empty())
+		return textRecord->value;
+
+	return Common::String();
+}
+
 Common::String Script::resolveObjectLabel(const StartupObjectRecord &object) const {
 	if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)) {
-		const StartupTextRecord *inventoryText = findTextRecord(object.inventoryTextKey);
-		if (inventoryText && !inventoryText->value.empty())
-			return inventoryText->value;
+		const Common::String inventoryLabel = resolveInventoryTooltipText(object);
+		if (!inventoryLabel.empty())
+			return inventoryLabel;
 	}
 
 	if (!object.interactionLabel.empty() && !object.interactionLabel.equalsIgnoreCase("NULL_ID"))
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 8fc564e675b..8fc1465bb16 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -400,6 +400,7 @@ public:
 	void markObjectIdentShown(const StartupObjectRecord &object);
 	bool resolveObjectInspectText(const StartupObjectRecord &object, StartupResolvedText &text) const;
 	bool resolveTextRecord(const Common::String &key, StartupResolvedText &text) const;
+	Common::String resolveInventoryTooltipText(const StartupObjectRecord &object) const;
 	Common::String resolveObjectLabel(const StartupObjectRecord &object) const;
 	Common::String resolveTextValue(const Common::String &key) const;
 	const StartupHeadRecord *findHeadRecord(const Common::String &headId) const;


Commit: ba227cc336fb50b45375f8d912c687d3222a8d0a
    https://github.com/scummvm/scummvm/commit/ba227cc336fb50b45375f8d912c687d3222a8d0a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:38+02:00

Commit Message:
HARVESTER: Add inventory click debug logging

Changed paths:
    TRACKER.md
    engines/harvester/detection.cpp
    engines/harvester/detection.h
    engines/harvester/inventory.cpp
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 21f403e9168..45007dded47 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, instrumented the inventory overlay click path instead of guessing why right-clicking the note bitmap was not reaching the recovered secondary-action dispatcher.
+- Added a dedicated `inventory` debug channel and wired logs through `engines/harvester/inventory.cpp` and `engines/harvester/room.cpp` so a repro now prints the live inventory visual list, the actual object name and sprite paths under each click, whether the item hit the combat-toggle branch, whether secondary-action lookup matched the hardcoded table or consumable fallback, and whether `executeActionTag()` returned an interaction result.
+- The instrumentation is meant to answer the current ambiguity around the note item specifically: whether `NOTE.BM` belongs to a different runtime object name than `NOTE_PHOTOCOPY`, whether the click is landing on a status/exit visual instead of the intended item, or whether the action dispatch succeeds and the failure happens later in interaction handling.
 - On March 20, 2026, re-opened native `run_inventory_screen @ 0x7df10` in Ghidra to verify the inventory hover tooltip path instead of continuing to reuse the room prompt renderer by approximation.
 - Confirmed the plain inventory hover branch resolves the hovered object's `inventory_text_key`, rebuilds a dedicated `INV_TEXT` entity at fixed coordinates `(0xbc, 0x19e)`, and uses `g_textfont_cft @ 0xd5bc4` with color `0xfffffff4`; the centered bottom carry prompt is a different path that still uses `g_medfont1_cft` and `USING_ON_ID` at y `0x1ce`.
 - Patched `engines/harvester/script.{h,cpp}` and `engines/harvester/room.cpp` so inventory hover labels now use an inventory-only text resolver plus a dedicated `TEXTFONT` draw path at the native anchor instead of the centered room prompt path. Updated `ARCHITECTURE.md` with the confirmed native tooltip split.
@@ -103,16 +106,17 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
-2. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
-3. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
-4. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
-5. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
-6. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
-7. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
-8. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
-9. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
-10. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
+1. Reproduce the `NOTE.BM` inventory right-click with `--debugflags=inventory` and inspect the new logs for the actual runtime object name, chosen sprite path, click-hit bounds, secondary-action lookup result, and `executeActionTag()` outcome; that should tell us whether the missing closeup is a naming mismatch or a later interaction failure.
+2. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
+3. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
+4. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
+5. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
+6. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
+7. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
+8. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
+9. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
+10. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
+11. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
 
 ## Reimplementation Priority Order
 
diff --git a/engines/harvester/detection.cpp b/engines/harvester/detection.cpp
index d2255193960..63044dd02e8 100644
--- a/engines/harvester/detection.cpp
+++ b/engines/harvester/detection.cpp
@@ -29,6 +29,7 @@ const DebugChannelDef HarvesterMetaEngineDetection::debugFlagList[] = {
 	{ Harvester::kDebugResources, "resources", "Resource loading" },
 	{ Harvester::kDebugScene, "scene", "Startup room, hotspot, and actor debug" },
 	{ Harvester::kDebugDialogue, "dialogue", "Startup dialogue and NPC interaction debug" },
+	{ Harvester::kDebugInventory, "inventory", "Inventory hover, click, and secondary-action debug" },
 	DEBUG_CHANNEL_END
 };
 
diff --git a/engines/harvester/detection.h b/engines/harvester/detection.h
index 8d248d3bac1..c1f876f4a93 100644
--- a/engines/harvester/detection.h
+++ b/engines/harvester/detection.h
@@ -32,7 +32,8 @@ enum HarvesterDebugChannels {
 	kDebugGeneral   = 1 << 0,
 	kDebugResources = 1 << 1,
 	kDebugScene     = 1 << 2,
-	kDebugDialogue  = 1 << 3
+	kDebugDialogue  = 1 << 3,
+	kDebugInventory = 1 << 4
 };
 
 extern const PlainGameDescriptor harvesterGames[];
diff --git a/engines/harvester/inventory.cpp b/engines/harvester/inventory.cpp
index b9437e696b5..bb300026626 100644
--- a/engines/harvester/inventory.cpp
+++ b/engines/harvester/inventory.cpp
@@ -21,10 +21,12 @@
 
 #include "harvester/inventory.h"
 
+#include "common/debug.h"
 #include "common/endian.h"
 #include "graphics/blit.h"
 #include "graphics/font.h"
 #include "graphics/screen.h"
+#include "harvester/detection.h"
 #include "harvester/harvester.h"
 #include "harvester/resources.h"
 #include "harvester/art.h"
@@ -231,6 +233,15 @@ static bool usesObjectActionForInventorySecondaryClick(const Common::String &obj
 	return false;
 }
 
+static void debugLogInventoryVisual(const StartupInventoryVisual &visual, const Common::String &spritePath) {
+	debugC(1, kDebugInventory,
+		"Harvester: inventory visual object='%s' sprite='%s' alt='%s' chosen='%s' bounds=(%d,%d)-(%d,%d) action='%s' owner='%s' text='%s'",
+		visual.object.objectName.c_str(), visual.object.spritePath.c_str(), visual.object.altSpritePath.c_str(),
+		spritePath.c_str(), visual.bounds.left, visual.bounds.top, visual.bounds.right, visual.bounds.bottom,
+		visual.object.actionTag.c_str(), visual.object.currentOwnerOrRoom.c_str(),
+		visual.object.inventoryTextKey.c_str());
+}
+
 } // End of anonymous namespace
 
 InventorySystem::InventorySystem(HarvesterEngine &engine) : _engine(engine) {
@@ -289,10 +300,12 @@ bool InventorySystem::refresh() {
 
 		if (isStatusObject(inventoryObject)) {
 			_items.push_back(visual);
+			debugLogInventoryVisual(visual, spritePath);
 			continue;
 		}
 
 		_items.push_back(visual);
+		debugLogInventoryVisual(visual, spritePath);
 	}
 
 	if (_selectedItemName.empty())
@@ -397,19 +410,34 @@ bool InventorySystem::toggleCombatLoadout(const StartupObjectRecord &object, boo
 
 	const int nextLoadout = startupScript->getPlayerCombatLoadout() == loadoutId ? 0 : loadoutId;
 	changed = startupScript->setPlayerCombatLoadout(nextLoadout);
+	debugC(1, kDebugInventory,
+		"Harvester: inventory combat toggle object='%s' requested_loadout=%d changed=%d resulting_loadout=%d",
+		object.objectName.c_str(), nextLoadout, changed, startupScript->getPlayerCombatLoadout());
 	return true;
 }
 
 bool InventorySystem::resolveSecondaryAction(const StartupObjectRecord &object,
 		InventorySecondaryAction &action) const {
-	if (resolveInventorySecondaryActionEntry(object.objectName, action))
+	if (resolveInventorySecondaryActionEntry(object.objectName, action)) {
+		debugC(1, kDebugInventory,
+			"Harvester: inventory secondary action object='%s' resolved hardcoded action='%s' closeInventory=%d",
+			object.objectName.c_str(), action.actionTag.c_str(), action.closeInventory);
 		return true;
+	}
 
 	action = InventorySecondaryAction();
-	if (object.actionTag.empty() || !usesObjectActionForInventorySecondaryClick(object.objectName))
+	if (object.actionTag.empty() || !usesObjectActionForInventorySecondaryClick(object.objectName)) {
+		debugC(1, kDebugInventory,
+			"Harvester: inventory secondary action object='%s' has no supported action (objectAction='%s' supported=%d)",
+			object.objectName.c_str(), object.actionTag.c_str(),
+			usesObjectActionForInventorySecondaryClick(object.objectName));
 		return false;
+	}
 
 	action.actionTag = object.actionTag;
+	debugC(1, kDebugInventory,
+		"Harvester: inventory secondary action object='%s' falling back to object action='%s'",
+		object.objectName.c_str(), action.actionTag.c_str());
 	return true;
 }
 
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index dc7762e09fb..25c290f0457 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -84,6 +84,25 @@ static bool isOutsideNativeInventoryDragCloseBounds(const Common::Point &point)
 		point.y >= kNativeInventoryDragCloseBottom;
 }
 
+static void debugLogInventoryClick(const char *buttonLabel, const Common::Point &point,
+		const StartupInventoryVisual *inventoryHover) {
+	if (!inventoryHover) {
+		debugC(1, kDebugInventory,
+			"Harvester: inventory %s click at (%d,%d) hit no item", buttonLabel, point.x, point.y);
+		return;
+	}
+
+	debugC(1, kDebugInventory,
+		"Harvester: inventory %s click at (%d,%d) object='%s' sprite='%s' alt='%s' action='%s' owner='%s' text='%s' bounds=(%d,%d)-(%d,%d) exit=%d status=%d",
+		buttonLabel, point.x, point.y, inventoryHover->object.objectName.c_str(),
+		inventoryHover->object.spritePath.c_str(), inventoryHover->object.altSpritePath.c_str(),
+		inventoryHover->object.actionTag.c_str(), inventoryHover->object.currentOwnerOrRoom.c_str(),
+		inventoryHover->object.inventoryTextKey.c_str(), inventoryHover->bounds.left,
+		inventoryHover->bounds.top, inventoryHover->bounds.right, inventoryHover->bounds.bottom,
+		InventorySystem::isExitObject(inventoryHover->object),
+		InventorySystem::isStatusObject(inventoryHover->object));
+}
+
 static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, int x, int y) {
 	if (!bitmap.isValid())
 		return;
@@ -1224,7 +1243,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				pendingRegionName.clear();
 				if (_inventory.isOpen()) {
 					const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
+					debugLogInventoryClick("right", _mousePos, inventoryHover);
 					if (_inventory.hasSelection()) {
+						debugC(1, kDebugInventory,
+							"Harvester: inventory right click cleared active selection='%s'",
+							_inventory.getSelectedItemName().c_str());
 						if (_inventory.clearSelection())
 							needsRedraw = true;
 						break;
@@ -1234,6 +1257,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 							!InventorySystem::isStatusObject(inventoryHover->object)) {
 						bool loadoutChanged = false;
 						if (_inventory.toggleCombatLoadout(inventoryHover->object, loadoutChanged)) {
+							debugC(1, kDebugInventory,
+								"Harvester: inventory right click handled as combat toggle object='%s' changed=%d",
+								inventoryHover->object.objectName.c_str(), loadoutChanged);
 							if (loadoutChanged) {
 								captureCurrentSaveState();
 								if (!_inventory.refresh())
@@ -1245,16 +1271,30 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 						InventorySecondaryAction secondaryAction;
 						if (_inventory.resolveSecondaryAction(inventoryHover->object, secondaryAction)) {
+							debugC(1, kDebugInventory,
+								"Harvester: inventory right click executing secondary action object='%s' action='%s' closeInventory=%d",
+								inventoryHover->object.objectName.c_str(),
+								secondaryAction.actionTag.c_str(), secondaryAction.closeInventory);
 							if (secondaryAction.closeInventory)
 								(void)_inventory.close();
 
 							StartupInteractionResult interaction;
-							if (_engine.getStartupScript()->executeActionTag(
-									secondaryAction.actionTag, interaction)) {
+							const bool executedActionTag = _engine.getStartupScript()->executeActionTag(
+									secondaryAction.actionTag, interaction);
+							debugC(1, kDebugInventory,
+								"Harvester: inventory right click executeActionTag('%s') -> %d music='%s' cutscene='%s' nextRoom='%s'",
+								secondaryAction.actionTag.c_str(), executedActionTag,
+								interaction.musicPath.c_str(), interaction.cutscenePath.c_str(),
+								interaction.nextRoomName.c_str());
+							if (executedActionTag) {
 								bool didTransition = false;
 								Common::Error interactionError =
 									interactionProcessor.handleInteractionResult(
 										interaction, didTransition, Common::String());
+								debugC(1, kDebugInventory,
+									"Harvester: inventory right click action='%s' interaction result error=%d transitioned=%d pendingMainMenu=%d",
+									secondaryAction.actionTag.c_str(), interactionError.getCode(),
+									didTransition, startupFlow.hasPendingMainMenuReturn());
 								if (interactionError.getCode() != Common::kNoError)
 									return interactionError;
 								if (startupFlow.hasPendingMainMenuReturn())
@@ -1266,7 +1306,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 							needsRedraw = true;
 							break;
 						}
+						debugC(1, kDebugInventory,
+							"Harvester: inventory right click object='%s' produced no combat toggle or secondary action",
+							inventoryHover->object.objectName.c_str());
 					}
+					debugC(1, kDebugInventory, "Harvester: inventory right click closing overlay");
 					if (_inventory.close())
 						needsRedraw = true;
 					break;
@@ -1312,8 +1356,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				const Common::Rect inventoryPanelBounds = _inventory.getPanelBounds();
 				if (_inventory.isOpen()) {
 					const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
+					debugLogInventoryClick("left", _mousePos, inventoryHover);
 					if (inventoryHover) {
 						if (InventorySystem::isExitObject(inventoryHover->object)) {
+							debugC(1, kDebugInventory, "Harvester: inventory left click exit button");
 							const bool clearedSelection = _inventory.clearSelection();
 							const bool closedInventory = _inventory.close();
 							if (clearedSelection || closedInventory)
@@ -1324,6 +1370,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 							break;
 
 						if (!_inventory.hasSelection()) {
+							debugC(1, kDebugInventory,
+								"Harvester: inventory left click selecting object='%s'",
+								inventoryHover->object.objectName.c_str());
 							_inventory.selectItem(inventoryHover->object.objectName);
 							_inventory.setPromptText(_inventory.buildSelectedPrompt(Common::String()));
 							needsRedraw = true;
@@ -1331,6 +1380,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 						}
 
 						if (!inventoryHover->object.objectName.equalsIgnoreCase(_inventory.getSelectedItemName())) {
+							debugC(1, kDebugInventory,
+								"Harvester: inventory left click using selected='%s' on target='%s'",
+								_inventory.getSelectedItemName().c_str(),
+								inventoryHover->object.objectName.c_str());
 							Common::Error interactionError =
 								handleInventoryTargetInteraction(inventoryHover->object);
 							if (interactionError.getCode() != Common::kNoError)


Commit: 2eb5a1b1ad258a22457a26fed7ffb2a1007c7348
    https://github.com/scummvm/scummvm/commit/2eb5a1b1ad258a22457a26fed7ffb2a1007c7348
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:38+02:00

Commit Message:
HARVESTER: Restore inventory weekday label

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/inventory.cpp
    engines/harvester/inventory.h
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 52f48c1a459..bc1c0b46c9b 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1370,6 +1370,29 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - Native inventory hover text is not centered and does not reuse the room hover prompt renderer.
 - The inventory exit entity is handled separately; the fixed `INV_TEXT` hover path applies to inventory objects and the `INV_STAT*` status entities that resolve through `inventory_text_key`.
 
+## Inventory Weekday Label
+
+**Confidence:** High
+
+**Evidence**
+- Early in `run_inventory_screen`, the setup branch calls `is_object_in_inventory("HARVEST_BLADE")` and only spawns the weekday label when that check is false.
+- The same branch calls `get_current_story_day_index`, indexes the pointer table at `0xc4110`, and resolves the visible strings `Monday` through `Saturday` from that table.
+- The resulting text entity is spawned through the inventory text-font path with text id `DAYS_TEXT` at `(0x1e0, 0x18c)` and color `0xfffffff4`, which matches the recovered `TEXTFONT.CFT` inventory-label styling rather than the centered bottom prompt path.
+
+**Key Functions**
+- `run_inventory_screen`
+- `get_current_story_day_index`
+- `is_object_in_inventory`
+
+**Key Data**
+- the weekday string table at `0xc4110`
+- `g_textfont_cft`
+- `DAYS_TEXT`
+
+**Notes**
+- Native inventory weekday display is a persistent overlay element, not part of the hover-tooltip or carry-prompt branches.
+- The visible label is explicitly suppressed once `HARVEST_BLADE` has entered inventory.
+
 ## Current Blockers
 
 - The remaining work is no longer list recovery. The blocker is semantic naming for fields whose shape is clear but whose gameplay meaning is still ambiguous.
diff --git a/TRACKER.md b/TRACKER.md
index 45007dded47..3932fbf58a3 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, re-opened native `run_inventory_screen @ 0x7df10` and `get_current_story_day_index @ 0x38bc0` in Ghidra specifically to verify the missing inventory weekday label instead of inferring it from script flags alone.
+- Confirmed the native inventory screen allocates a dedicated `DAYS_TEXT` text entity at `(0x1e0, 0x18c)` with `g_textfont_cft @ 0xd5bc4` and color `0xfffffff4`, indexing the Monday-through-Saturday string table at `0xc4110` through `get_current_story_day_index`. The same setup branch skips the label completely when `is_object_in_inventory("HARVEST_BLADE")` is true.
+- Patched `engines/harvester/script.{h,cpp}`, `engines/harvester/inventory.{h,cpp}`, and `engines/harvester/room.cpp` so the room renderer now draws that native weekday label while the inventory overlay is open, and the inventory runtime-state watcher now triggers redraws when story day, HP portrait state, or `HARVEST_BLADE` ownership changes while the panel stays open.
 - On March 20, 2026, instrumented the inventory overlay click path instead of guessing why right-clicking the note bitmap was not reaching the recovered secondary-action dispatcher.
 - Added a dedicated `inventory` debug channel and wired logs through `engines/harvester/inventory.cpp` and `engines/harvester/room.cpp` so a repro now prints the live inventory visual list, the actual object name and sprite paths under each click, whether the item hit the combat-toggle branch, whether secondary-action lookup matched the hardcoded table or consumable fallback, and whether `executeActionTag()` returned an interaction result.
 - The instrumentation is meant to answer the current ambiguity around the note item specifically: whether `NOTE.BM` belongs to a different runtime object name than `NOTE_PHOTOCOPY`, whether the click is landing on a status/exit visual instead of the intended item, or whether the action dispatch succeeds and the failure happens later in interaction handling.
@@ -106,17 +109,18 @@
 
 ## Next Suggested Action
 
-1. Reproduce the `NOTE.BM` inventory right-click with `--debugflags=inventory` and inspect the new logs for the actual runtime object name, chosen sprite path, click-hit bounds, secondary-action lookup result, and `executeActionTag()` outcome; that should tell us whether the missing closeup is a naming mismatch or a later interaction failure.
-2. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
-3. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
-4. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
-5. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
-6. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
-7. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
-8. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
-9. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
-10. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
-11. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
+1. Run a live Harvester validation pass on the inventory overlay and confirm the native weekday label now appears at `(480, 396)` in `TEXTFONT` when `HARVEST_BLADE` is not in inventory, updates if the day changes while the panel stays open, and disappears immediately once `HARVEST_BLADE` is acquired.
+2. Reproduce the `NOTE.BM` inventory right-click with `--debugflags=inventory` and inspect the new logs for the actual runtime object name, chosen sprite path, click-hit bounds, secondary-action lookup result, and `executeActionTag()` outcome; that should tell us whether the missing closeup is a naming mismatch or a later interaction failure.
+3. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
+4. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
+5. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
+6. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
+7. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
+8. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
+9. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
+10. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
+11. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
+12. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
 
 ## Reimplementation Priority Order
 
diff --git a/engines/harvester/inventory.cpp b/engines/harvester/inventory.cpp
index bb300026626..57487e3f909 100644
--- a/engines/harvester/inventory.cpp
+++ b/engines/harvester/inventory.cpp
@@ -42,6 +42,7 @@ static const int kInventoryItemStartY = 115;
 static const int kInventoryItemMaxRight = 564;
 static const int kInventoryItemSpacing = 5;
 static const byte kTransparentPaletteIndex = 0;
+static const char *const kHarvestBladeObjectName = "HARVEST_BLADE";
 
 struct InventoryCombatLoadoutEntry {
 	const char *objectName;
@@ -233,6 +234,25 @@ static bool usesObjectActionForInventorySecondaryClick(const Common::String &obj
 	return false;
 }
 
+static Common::String resolveInventoryWeekdayLabel(int storyDayIndex) {
+	switch (storyDayIndex) {
+	case 1:
+		return "Monday";
+	case 2:
+		return "Tuesday";
+	case 3:
+		return "Wednesday";
+	case 4:
+		return "Thursday";
+	case 5:
+		return "Friday";
+	case 6:
+		return "Saturday";
+	default:
+		return Common::String();
+	}
+}
+
 static void debugLogInventoryVisual(const StartupInventoryVisual &visual, const Common::String &spritePath) {
 	debugC(1, kDebugInventory,
 		"Harvester: inventory visual object='%s' sprite='%s' alt='%s' chosen='%s' bounds=(%d,%d)-(%d,%d) action='%s' owner='%s' text='%s'",
@@ -256,6 +276,8 @@ bool InventorySystem::refresh() {
 		return false;
 
 	_lastPlayerHitPoints = startupScript->getPlayerCurrentHitPoints();
+	_lastStoryDayIndex = startupScript->getCurrentStoryDayIndex();
+	_lastHasHarvestBlade = startupScript->isObjectInInventory(kHarvestBladeObjectName);
 
 	Common::Array<StartupObjectRecord> inventoryObjects;
 	startupScript->getVisibleInventoryObjects(inventoryObjects);
@@ -351,7 +373,11 @@ bool InventorySystem::refreshIfRuntimeStateChanged() {
 		return false;
 
 	const int currentHitPoints = startupScript->getPlayerCurrentHitPoints();
-	if (currentHitPoints == _lastPlayerHitPoints)
+	const int currentStoryDayIndex = startupScript->getCurrentStoryDayIndex();
+	const bool hasHarvestBlade = startupScript->isObjectInInventory(kHarvestBladeObjectName);
+	if (currentHitPoints == _lastPlayerHitPoints &&
+			currentStoryDayIndex == _lastStoryDayIndex &&
+			hasHarvestBlade == _lastHasHarvestBlade)
 		return false;
 
 	return refresh();
@@ -449,6 +475,14 @@ const Common::String &InventorySystem::getPromptText() const {
 	return _promptText;
 }
 
+Common::String InventorySystem::resolveWeekdayLabel() const {
+	Script *startupScript = _engine.getStartupScript();
+	if (!startupScript || startupScript->isObjectInInventory(kHarvestBladeObjectName))
+		return Common::String();
+
+	return resolveInventoryWeekdayLabel(startupScript->getCurrentStoryDayIndex());
+}
+
 const StartupInventoryVisual *InventorySystem::findItemAtPoint(const Common::Point &point) const {
 	for (int i = (int)_items.size() - 1; i >= 0; --i) {
 		if (_items[i].bounds.contains(point))
diff --git a/engines/harvester/inventory.h b/engines/harvester/inventory.h
index d4afa235907..ff0d2ca9b0c 100644
--- a/engines/harvester/inventory.h
+++ b/engines/harvester/inventory.h
@@ -67,6 +67,7 @@ public:
 	bool resolveSecondaryAction(const StartupObjectRecord &object, InventorySecondaryAction &action) const;
 	void setPromptText(const Common::String &promptText);
 	const Common::String &getPromptText() const;
+	Common::String resolveWeekdayLabel() const;
 	const StartupInventoryVisual *findItemAtPoint(const Common::Point &point) const;
 	Common::Rect getPanelBounds() const;
 	void drawOverlay(Graphics::Screen &screen) const;
@@ -79,6 +80,8 @@ private:
 	Common::Array<StartupInventoryVisual> _items;
 	bool _open = false;
 	int _lastPlayerHitPoints = -1;
+	int _lastStoryDayIndex = -1;
+	bool _lastHasHarvestBlade = false;
 	Common::String _selectedItemName;
 	Common::String _promptText;
 	HarvesterEngine &_engine;
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 25c290f0457..53dd1af38a8 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -54,6 +54,8 @@ static const int kNativeInventoryDragCloseRight = 0x239;
 static const int kNativeInventoryDragCloseBottom = 0x1b4;
 static const int kNativeInventoryTooltipX = 0xbc;
 static const int kNativeInventoryTooltipY = 0x19e;
+static const int kNativeInventoryWeekdayX = 0x1e0;
+static const int kNativeInventoryWeekdayY = 0x18c;
 static const byte kNativeInventoryTooltipColor = 0xf4;
 
 struct MonsterAnimationRange {
@@ -234,6 +236,15 @@ static void drawInventoryTooltip(Graphics::Screen &screen, const Graphics::Font
 		font.getStringWidth(tooltipText), kNativeInventoryTooltipColor);
 }
 
+static void drawInventoryWeekday(Graphics::Screen &screen, const Graphics::Font &font,
+		const Common::String &weekdayText) {
+	if (weekdayText.empty())
+		return;
+
+	font.drawString(&screen, weekdayText, kNativeInventoryWeekdayX, kNativeInventoryWeekdayY,
+		font.getStringWidth(weekdayText), kNativeInventoryTooltipColor);
+}
+
 static void setScaledRoomPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
 	byte scaledPalette[256 * 3];
 	const float gammaBrightness = g_engine ? g_engine->getStartupGammaBrightnessScale() : 1.0f;
@@ -1191,8 +1202,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			}
 
 			drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
-			if (_inventory.isOpen())
+			if (_inventory.isOpen()) {
 				_inventory.drawOverlay(*activeScreen);
+				drawInventoryWeekday(*activeScreen, *inventoryTooltipFont, _inventory.resolveWeekdayLabel());
+			}
 			if (inventorySelectionActive) {
 				_inventory.drawSelectedDragItem(*activeScreen, _mousePos);
 			} else if (roomCarryActive && carriedRoomItemBitmap.isValid()) {
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 4ed5091569d..f7d49b786bf 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1525,6 +1525,19 @@ void Script::getVisibleInventoryObjects(Common::Array<StartupObjectRecord> &obje
 	objects.push_back(activeStatusObject);
 }
 
+bool Script::isObjectInInventory(const Common::String &objectName) const {
+	if (objectName.empty())
+		return false;
+
+	for (const StartupObjectRecord &object : _runtimeObjects) {
+		if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName) &&
+				object.objectName.equalsIgnoreCase(objectName))
+			return true;
+	}
+
+	return false;
+}
+
 void Script::markObjectIdentShown(const StartupObjectRecord &object) {
 	if (StartupObjectRecord *runtimeObject = findRuntimeObject(object.currentOwnerOrRoom, object.objectName))
 		runtimeObject->identShown = true;
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 8fc1465bb16..078dd7eafd2 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -397,6 +397,7 @@ public:
 	bool hasObjectInteraction(const StartupObjectRecord &object) const;
 	bool hasUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target) const;
 	void getVisibleInventoryObjects(Common::Array<StartupObjectRecord> &objects) const;
+	bool isObjectInInventory(const Common::String &objectName) const;
 	void markObjectIdentShown(const StartupObjectRecord &object);
 	bool resolveObjectInspectText(const StartupObjectRecord &object, StartupResolvedText &text) const;
 	bool resolveTextRecord(const Common::String &key, StartupResolvedText &text) const;


Commit: fa0ab0617d358b6c3ec8488c568c68ca7b65e2dc
    https://github.com/scummvm/scummvm/commit/fa0ab0617d358b6c3ec8488c568c68ca7b65e2dc
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:38+02:00

Commit Message:
HARVESTER: Map inventory note closeup action

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/inventory.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index bc1c0b46c9b..d6c68deaabc 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1328,7 +1328,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 
 **Evidence**
 - In `run_inventory_screen`, the right-click branch first maps the weapon subset (`CLEAVER` through `POOLSTICK`) to fixed combat-loadout ids `1..20`, then calls `set_player_combat_loadout` and refreshes the inventory status state without leaving the inventory loop.
-- The same branch maps a separate hardcoded document/photo subset to fixed action tags rather than object-record metadata: `NOTE_PHOTOCOPY -> GO_BOYLCOPYCU`, `CHECKBOOK -> GO_REGISTERCU`, `CHECKBOOK_PHOTOCOPY -> GO_RGSTRCPYCU`, `TV_DEED -> GO_TVDEED1CU`, `TV_DEED_PHOTOCOPY -> GO_TVDEED2CU`, `CLUE -> GOTO_CLUE_CU`, `AUTOGRAPH -> GO_AUTOGRPHCU`, plus the corresponding Whaley photo, casket photo, permit, lodge-application, safebook, patrol-schedule, and invite entries.
+- The same branch maps a separate hardcoded document/photo subset to fixed action tags rather than object-record metadata: `NOTE_PHOTOCOPY -> GO_BOYLCOPYCU`, `NOTE -> GO_BOYLNOTECU`, `CHECKBOOK -> GO_REGISTERCU`, `CHECKBOOK_PHOTOCOPY -> GO_RGSTRCPYCU`, `TV_DEED -> GO_TVDEED1CU`, `TV_DEED_PHOTOCOPY -> GO_TVDEED2CU`, `CLUE -> GOTO_CLUE_CU`, `AUTOGRAPH -> GO_AUTOGRPHCU`, plus the corresponding Whaley photo, casket photo, permit, lodge-application, safebook, patrol-schedule, and invite entries.
 - After dispatching one of those hardcoded closeup action tags, `run_inventory_screen` tears down the inventory render state and returns to the caller instead of remaining inside the inventory loop.
 - Only the six consumable/self-use items `SANDWICH`, `SANDWICH2`, `SYRINGE`, `ST_ASPRIN`, `ST_COUGHM`, and `ST_VITAMN` fall back to the clicked object's own `action_tag`; that branch stays inside the inventory loop so HP/status changes are visible immediately.
 
diff --git a/TRACKER.md b/TRACKER.md
index 3932fbf58a3..e7dc9b62f9e 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, re-opened the native inventory secondary-click dispatcher in `run_inventory_screen @ 0x7df10` after live ScummVM logs showed the runtime object under `NOTE.BM` is `NOTE`, not `NOTE_PHOTOCOPY`.
+- Confirmed from the hardcoded object-name ladder around `0x7efc6` plus the adjacent action-tag pointer table at `0xc40c8` that native supports both note variants separately: `NOTE_PHOTOCOPY -> GO_BOYLCOPYCU` and `NOTE -> GO_BOYLNOTECU`, with the note entry occupying the slot immediately after `NOTE_PHOTOCOPY` before the Whaley photo entries.
+- Patched `engines/harvester/inventory.cpp` to add the missing `NOTE` secondary-action mapping so right-clicking the live note object now follows the native closeup/document branch instead of falling through to the generic "close inventory" path.
 - On March 20, 2026, re-opened native `run_inventory_screen @ 0x7df10` and `get_current_story_day_index @ 0x38bc0` in Ghidra specifically to verify the missing inventory weekday label instead of inferring it from script flags alone.
 - Confirmed the native inventory screen allocates a dedicated `DAYS_TEXT` text entity at `(0x1e0, 0x18c)` with `g_textfont_cft @ 0xd5bc4` and color `0xfffffff4`, indexing the Monday-through-Saturday string table at `0xc4110` through `get_current_story_day_index`. The same setup branch skips the label completely when `is_object_in_inventory("HARVEST_BLADE")` is true.
 - Patched `engines/harvester/script.{h,cpp}`, `engines/harvester/inventory.{h,cpp}`, and `engines/harvester/room.cpp` so the room renderer now draws that native weekday label while the inventory overlay is open, and the inventory runtime-state watcher now triggers redraws when story day, HP portrait state, or `HARVEST_BLADE` ownership changes while the panel stays open.
@@ -109,14 +112,14 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester validation pass on the inventory overlay and confirm the native weekday label now appears at `(480, 396)` in `TEXTFONT` when `HARVEST_BLADE` is not in inventory, updates if the day changes while the panel stays open, and disappears immediately once `HARVEST_BLADE` is acquired.
-2. Reproduce the `NOTE.BM` inventory right-click with `--debugflags=inventory` and inspect the new logs for the actual runtime object name, chosen sprite path, click-hit bounds, secondary-action lookup result, and `executeActionTag()` outcome; that should tell us whether the missing closeup is a naming mismatch or a later interaction failure.
+1. Run a live Harvester validation pass on the inventory overlay and confirm right-clicking `NOTE` now dispatches `GO_BOYLNOTECU`, closes the overlay only because the closeup branch is executing, and enters the same document-view transition class as `NOTE_PHOTOCOPY`.
+2. Run a live Harvester validation pass on the inventory overlay and confirm the native weekday label now appears at `(480, 396)` in `TEXTFONT` when `HARVEST_BLADE` is not in inventory, updates if the day changes while the panel stays open, and disappears immediately once `HARVEST_BLADE` is acquired.
 3. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
 4. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
 5. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
 6. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
 7. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
-8. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
+8. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
 9. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
 10. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
 11. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
diff --git a/engines/harvester/inventory.cpp b/engines/harvester/inventory.cpp
index 57487e3f909..ca500e40f87 100644
--- a/engines/harvester/inventory.cpp
+++ b/engines/harvester/inventory.cpp
@@ -82,6 +82,7 @@ static const InventoryCombatLoadoutEntry kInventoryCombatLoadoutMap[] = {
 // hardcoded closeup action tags instead of using their object records directly.
 static const InventorySecondaryActionEntry kInventorySecondaryActionMap[] = {
 	{ "NOTE_PHOTOCOPY", "GO_BOYLCOPYCU", true },
+	{ "NOTE", "GO_BOYLNOTECU", true },
 	{ "PHOTO_OF_WHALEY_HERRILL_PHOTOCOPY", "GO_BROOMCPYCU", true },
 	{ "PHOTO_OF_WHALEY_HERRILL", "GO_BROOMPICCU", true },
 	{ "CASKET_PHOTOCOPY", "GO_CASKTCPYCU", true },


Commit: 311631201c95ad02f8f1185e2cc35a20575b6290
    https://github.com/scummvm/scummvm/commit/311631201c95ad02f8f1185e2cc35a20575b6290
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:39+02:00

Commit Message:
HARVESTER: Skip inventory refresh after closeups

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index e7dc9b62f9e..e99e3db91ed 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, re-checked the native document-closeup exit path in `run_inventory_screen @ 0x7df10` after the live `NOTE` fix still showed redundant ScummVM inventory bitmap loads following `GO_BOYLNOTECU`.
+- Confirmed the native hardcoded document/photo branch destroys the inventory-local text/status entities, dispatches the closeup action, and tears down the inventory screen before returning to the caller; it does not rebuild the carried inventory visuals after the closeup branch closes.
+- Patched `engines/harvester/room.cpp` so right-click secondary actions only call `_inventory.refresh()` when the overlay is still open; closeup actions such as `NOTE -> GO_BOYLNOTECU` now skip the redundant inventory cache rebuild once the inventory screen has been closed.
 - On March 20, 2026, re-opened the native inventory secondary-click dispatcher in `run_inventory_screen @ 0x7df10` after live ScummVM logs showed the runtime object under `NOTE.BM` is `NOTE`, not `NOTE_PHOTOCOPY`.
 - Confirmed from the hardcoded object-name ladder around `0x7efc6` plus the adjacent action-tag pointer table at `0xc40c8` that native supports both note variants separately: `NOTE_PHOTOCOPY -> GO_BOYLCOPYCU` and `NOTE -> GO_BOYLNOTECU`, with the note entry occupying the slot immediately after `NOTE_PHOTOCOPY` before the Whaley photo entries.
 - Patched `engines/harvester/inventory.cpp` to add the missing `NOTE` secondary-action mapping so right-clicking the live note object now follows the native closeup/document branch instead of falling through to the generic "close inventory" path.
@@ -112,7 +115,7 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester validation pass on the inventory overlay and confirm right-clicking `NOTE` now dispatches `GO_BOYLNOTECU`, closes the overlay only because the closeup branch is executing, and enters the same document-view transition class as `NOTE_PHOTOCOPY`.
+1. Run a live Harvester validation pass on the inventory overlay and confirm right-clicking `NOTE` now dispatches `GO_BOYLNOTECU` without the redundant post-closeup inventory bitmap reloads, and that the overlay only reappears when the player explicitly reopens it after returning from the closeup.
 2. Run a live Harvester validation pass on the inventory overlay and confirm the native weekday label now appears at `(480, 396)` in `TEXTFONT` when `HARVEST_BLADE` is not in inventory, updates if the day changes while the panel stays open, and disappears immediately once `HARVEST_BLADE` is acquired.
 3. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
 4. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 53dd1af38a8..aba7faa39e9 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1292,6 +1292,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 								(void)_inventory.close();
 
 							StartupInteractionResult interaction;
+							bool didTransition = false;
 							const bool executedActionTag = _engine.getStartupScript()->executeActionTag(
 									secondaryAction.actionTag, interaction);
 							debugC(1, kDebugInventory,
@@ -1300,7 +1301,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 								interaction.musicPath.c_str(), interaction.cutscenePath.c_str(),
 								interaction.nextRoomName.c_str());
 							if (executedActionTag) {
-								bool didTransition = false;
 								Common::Error interactionError =
 									interactionProcessor.handleInteractionResult(
 										interaction, didTransition, Common::String());
@@ -1314,8 +1314,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 									return Common::kNoError;
 							}
 
-							if (!_inventory.refresh())
-								return Common::kReadingFailed;
+							if (_inventory.isOpen()) {
+								if (!_inventory.refresh())
+									return Common::kReadingFailed;
+							} else {
+								debugC(1, kDebugInventory,
+									"Harvester: inventory right click skipped overlay refresh after action='%s' transitioned=%d closeInventory=%d",
+									secondaryAction.actionTag.c_str(), didTransition, secondaryAction.closeInventory);
+							}
 							needsRedraw = true;
 							break;
 						}


Commit: 3a06dde494635d46a76ebfdd7627b1478027bbad
    https://github.com/scummvm/scummvm/commit/3a06dde494635d46a76ebfdd7627b1478027bbad
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:39+02:00

Commit Message:
HARVESTER: Restore sergeant application day handoff

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/sergeant_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index d6c68deaabc..f496cca0de3 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -604,6 +604,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `get_set_sergeant_completed_first_task_state` at `0x384e0` is the Sergeant-local bit set on the `SCRATCHED_TUCKER` branch immediately before dialogue `0x420d` ("So, you have completed your first task.").
   - The same block also contains the recovered Pottsdam alibi bits at `0x38440` and `0x38460`, so it is no longer Sergeant-only even though several remaining wrappers in that span are still only identified by locality.
   - `handle_talk_to_sergeant` calls into that run for quest-driven lines tied to `REMAINS`, `INVITE`, `BARBER_POLE`, `BOLTCLTH`, `SCRATCHED_TUCKER`, and `COMPLETED_LODGE_APPLICATION`.
+  - The native `COMPLETED_LODGE_APPLICATION` Monday branch is a deferred quest handoff rather than a single bark: after restoring the item to `RAH`, the handler plays Sergeant lines `0x41cd` then `0x4292`, sets `QUEST_1`, and dispatches action tag `GO_DAY_2`, which drives the story-day advance / cutscene path.
   - `handle_talk_to_dad` also reads the neighboring `FUN_00038400` wrapper on the `MEAT_PERMISSION0` item path.
   - The wrapper mechanics are confirmed, but most of the remaining local quest-state semantics in that run are still left unnamed pending stronger caller/readback evidence from the hidden no-function regions.
 - `reset_all_talk_to_handler_state` at `0x3a0b0` is the startup/menu bulk reset for the talk-to subsystem.
diff --git a/TRACKER.md b/TRACKER.md
index e99e3db91ed..198d4727b59 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, re-opened the hidden `handle_talk_to_sergeant @ 0x30340` tail blocks in Ghidra after the live Monday lodge-application repro showed ScummVM stopping on Sergeant line `0x41cd` instead of handing off into the next day.
+- Confirmed from the recovered native `COMPLETED_LODGE_APPLICATION` branch at `0x30bc2..0x30c35` that the `QUEST_1 == false` path does not end at `0x41cd`: it immediately plays Sergeant line `0x4292`, sets `QUEST_1`, and dispatches action tag `GO_DAY_2`, which is the Monday-to-Tuesday transition/cutscene handoff.
+- Patched `engines/harvester/npc/sergeant_dialogue.cpp` so the completed-application branch now mirrors that native sequence, including the native `0x41cd` / `0x41d2` head variant, the `QUEST_1` latch, and deferred `GO_DAY_2` action-tag execution through the existing dialogue-interaction queue; updated `ARCHITECTURE.md` with the confirmed branch evidence and rebuilt `engines/harvester/npc/sergeant_dialogue.o` plus `scummvm`.
 - On March 20, 2026, re-checked the native document-closeup exit path in `run_inventory_screen @ 0x7df10` after the live `NOTE` fix still showed redundant ScummVM inventory bitmap loads following `GO_BOYLNOTECU`.
 - Confirmed the native hardcoded document/photo branch destroys the inventory-local text/status entities, dispatches the closeup action, and tears down the inventory screen before returning to the caller; it does not rebuild the carried inventory visuals after the closeup branch closes.
 - Patched `engines/harvester/room.cpp` so right-click secondary actions only call `_inventory.refresh()` when the overlay is still open; closeup actions such as `NOTE -> GO_BOYLNOTECU` now skip the redundant inventory cache rebuild once the inventory screen has been closed.
@@ -115,18 +118,20 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester validation pass on the inventory overlay and confirm right-clicking `NOTE` now dispatches `GO_BOYLNOTECU` without the redundant post-closeup inventory bitmap reloads, and that the overlay only reappears when the player explicitly reopens it after returning from the closeup.
-2. Run a live Harvester validation pass on the inventory overlay and confirm the native weekday label now appears at `(480, 396)` in `TEXTFONT` when `HARVEST_BLADE` is not in inventory, updates if the day changes while the panel stays open, and disappears immediately once `HARVEST_BLADE` is acquired.
-3. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
-4. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
-5. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
-6. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
-7. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
-8. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
-9. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
-10. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
-11. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
-12. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
+1. Run a live Harvester validation pass in `SERGEANTRM` on Monday with `HAVE_COMPLETED_LODGE_APP` true and `QUEST_1` false: talk to Sergeant with the completed application and confirm ScummVM now plays the recovered `0x41cd` -> `0x4292` sequence, dispatches `GO_DAY_2`, advances the story to Tuesday, and shows the native day-change cutscene/FST.
+2. Re-open the remaining hidden Sergeant talk tails around `0x30ae2..0x30fcb` and mirror the still-missing native response-menu followups for the intro, `SCRATCHED_TUCKER`, and post-`QUEST_1` lodge-application branches only where the line ids / response lines / state flips are fully decoded.
+3. Run a live Harvester validation pass on the inventory overlay and confirm right-clicking `NOTE` now dispatches `GO_BOYLNOTECU` without the redundant post-closeup inventory bitmap reloads, and that the overlay only reappears when the player explicitly reopens it after returning from the closeup.
+4. Run a live Harvester validation pass on the inventory overlay and confirm the native weekday label now appears at `(480, 396)` in `TEXTFONT` when `HARVEST_BLADE` is not in inventory, updates if the day changes while the panel stays open, and disappears immediately once `HARVEST_BLADE` is acquired.
+5. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
+6. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
+7. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
+8. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
+9. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
+10. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
+11. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
+12. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
+13. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
+14. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
 
 ## Reimplementation Priority Order
 
diff --git a/engines/harvester/npc/sergeant_dialogue.cpp b/engines/harvester/npc/sergeant_dialogue.cpp
index 20f1f7639e3..7e0b568560c 100644
--- a/engines/harvester/npc/sergeant_dialogue.cpp
+++ b/engines/harvester/npc/sergeant_dialogue.cpp
@@ -31,6 +31,7 @@ namespace Harvester {
 namespace {
 
 static const char *const kDialogueC149FstPath = "GRAPHIC/FST/C149.FST";
+static const char *const kGoDay2ActionTag = "GO_DAY_2";
 
 } // End of namespace
 
@@ -64,6 +65,15 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		(void)runtime.startupScript().resetRuntimeObjectToInitialState(objectName);
 		(void)runtime.startupScript().setRuntimeObjectVisible("RAH", objectName, true);
 	};
+	auto executeActionTagIfSet = [&](const char *actionTag) -> Common::Error {
+		StartupInteractionResult interaction;
+		if (!runtime.startupScript().executeActionTag(actionTag, interaction))
+			return Common::kNoError;
+
+		runtime.applyImmediateDialogueInteractionEffects(interaction);
+		runtime.queueDialogueInteractionIfNeeded(interaction);
+		return Common::kNoError;
+	};
 	auto handleRemainsBranch = [&]() -> Common::Error {
 		markSergeantProgress();
 		restoreItemToRah("REMAINS");
@@ -101,10 +111,18 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		sharedState.dialogueStateD2f00 = true;
 		state.introPending = false;
 		restoreItemToRah("COMPLETED_LODGE_APPLICATION");
-		if (!runtime.startupScript().getFlagValue("QUEST_1"))
-			return playSergeantLine(0x41cd);
+		if (!runtime.startupScript().getFlagValue("QUEST_1")) {
+			Common::Error lineError = playSergeantLine(0x41cd, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playSergeantLine(0x4292);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			(void)runtime.startupScript().setRuntimeFlagValue("QUEST_1", true);
+			return executeActionTagIfSet(kGoDay2ActionTag);
+		}
 		if (!state.dialogueStateD2d54)
-			return playSergeantLine(0x41d2);
+			return playSergeantLine(0x41d2, 1);
 		return playSergeantLine(0x41f0);
 	};
 


Commit: 6ecbf100d785ccd6217d07ea2fdcbd8be33d7e31
    https://github.com/scummvm/scummvm/commit/6ecbf100d785ccd6217d07ea2fdcbd8be33d7e31
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:39+02:00

Commit Message:
HARVESTER: Tighten FST playback parity

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/fst_player.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index f496cca0de3..d695c5ded2b 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1157,8 +1157,10 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `run_fst_sequence_player` at `0x12b00` is the inner FST decoder/player reached from `play_fst_sequence`.
     - `FstFileHeader` is now a confirmed 0x20-byte header with fields `magic`, `width`, `height`, `max_frame_size`, `frame_count`, `frame_rate`, `sample_rate`, and `bits_per_sample`.
     - `FstFrameIndexEntry` is the 6-byte per-frame index record `{ video_size, audio_size }`; `run_fst_sequence_player` reads `frame_count` of them immediately after the header and then reads `video_size + audio_size` bytes for each frame.
+    - Native allocates one large payload buffer up front and reuses it for every `video_size + audio_size` frame read instead of reallocating a temporary frame buffer during playback.
     - It assumes the wrapper has already switched back to the movie-capable surface, then configures `g_video_surface_context` again from the header width/height at 8bpp backend class `1`, seeds PCM playback from the header sample rate / bits per sample, computes `bytes_per_frame = get_pcm_byte_rate(...) / frame_rate`, and uses `get_sound_state_playback_position` to keep movie frames aligned with the loaded soundtrack.
     - The frame loop queues each frame's PCM chunk before decode/blit and then waits until the cumulative playback cursor reaches the current frame's byte target; timer pacing is only the silent fallback.
+    - `play_fst_sequence` clears pending keypress state before entering the player, and `run_fst_sequence_player` only honors skip input after frame `0` has already started; the first frame is not skipped by a stale triggering click.
     - `initialize_vesa_banked_mode` precomputes the banked-surface row tables that the movie decoder relies on:
       - `g_vesa_scanline_start_bank_indices` at `0xc7fca` gives the starting write-window bank for each possible scanline
       - `g_vesa_scanline_window_offsets` at `0xc87cc` gives the in-window linear offset for that scanline's left edge
diff --git a/TRACKER.md b/TRACKER.md
index 198d4727b59..f42e4519594 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -9,13 +9,16 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `901`
+- Total functions: `903`
 - Named/documented: `790`
-- Remaining `FUN_*`: `111`
-- Remaining undocumented total: `111`
+- Remaining `FUN_*`: `113`
+- Remaining undocumented total: `113`
 
 ## Last Confirmed Action
 
+- On March 20, 2026, re-decompiled `play_fst_sequence @ 0x72550` and `run_fst_sequence_player @ 0x12b00` in Ghidra from scratch to check whether ScummVM's FST playback path was missing any native decode or wrapper steps that could explain the reported video-quality gap.
+- Confirmed the native FST block codec already matches the current ScummVM implementation closely: 4x4 changed-block decoding, optional per-frame VGA palette chunks, per-frame PCM queuing, and the same "truncated tail in the last scan band" behavior seen in shipped assets such as `VIRGLOGO.FST`, `FVLOGO.FST`, `INTROFIN.FST`, `C044.FST`, and `C048.FST`. The concrete native differences in this pass were player-side plumbing: one reused payload buffer for the whole sequence, wrapper-side stale-input clearing before playback, and skip acceptance only after frame 0 has started.
+- Patched `engines/harvester/fst_player.cpp` to mirror those proven runtime details on the ScummVM side by reusing a single frame payload buffer for the full sequence instead of reallocating per frame, draining stale skip input before playback begins, and deferring skip requests until after frame 0. Updated `ARCHITECTURE.md` with the confirmed native FST-player notes and rebuilt `engines/harvester/fst_player.o` plus `scummvm` successfully.
 - On March 20, 2026, re-opened the hidden `handle_talk_to_sergeant @ 0x30340` tail blocks in Ghidra after the live Monday lodge-application repro showed ScummVM stopping on Sergeant line `0x41cd` instead of handing off into the next day.
 - Confirmed from the recovered native `COMPLETED_LODGE_APPLICATION` branch at `0x30bc2..0x30c35` that the `QUEST_1 == false` path does not end at `0x41cd`: it immediately plays Sergeant line `0x4292`, sets `QUEST_1`, and dispatches action tag `GO_DAY_2`, which is the Monday-to-Tuesday transition/cutscene handoff.
 - Patched `engines/harvester/npc/sergeant_dialogue.cpp` so the completed-application branch now mirrors that native sequence, including the native `0x41cd` / `0x41d2` head variant, the `QUEST_1` latch, and deferred `GO_DAY_2` action-tag execution through the existing dialogue-interaction queue; updated `ARCHITECTURE.md` with the confirmed branch evidence and rebuilt `engines/harvester/npc/sergeant_dialogue.o` plus `scummvm`.
@@ -118,20 +121,21 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester validation pass in `SERGEANTRM` on Monday with `HAVE_COMPLETED_LODGE_APP` true and `QUEST_1` false: talk to Sergeant with the completed application and confirm ScummVM now plays the recovered `0x41cd` -> `0x4292` sequence, dispatches `GO_DAY_2`, advances the story to Tuesday, and shows the native day-change cutscene/FST.
-2. Re-open the remaining hidden Sergeant talk tails around `0x30ae2..0x30fcb` and mirror the still-missing native response-menu followups for the intro, `SCRATCHED_TUCKER`, and post-`QUEST_1` lodge-application branches only where the line ids / response lines / state flips are fully decoded.
-3. Run a live Harvester validation pass on the inventory overlay and confirm right-clicking `NOTE` now dispatches `GO_BOYLNOTECU` without the redundant post-closeup inventory bitmap reloads, and that the overlay only reappears when the player explicitly reopens it after returning from the closeup.
-4. Run a live Harvester validation pass on the inventory overlay and confirm the native weekday label now appears at `(480, 396)` in `TEXTFONT` when `HARVEST_BLADE` is not in inventory, updates if the day changes while the panel stays open, and disappears immediately once `HARVEST_BLADE` is acquired.
-5. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
-6. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
-7. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
-8. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
-9. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
-10. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
-11. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
-12. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
-13. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
-14. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
+1. Run a live Harvester validation pass on FST playback itself: compare `VIRGLOGO.FST`, `FVLOGO.FST`, `INTROFIN.FST`, `C044.FST`, and `C048.FST` against the original executable and confirm the reused payload buffer plus native first-frame skip gating remove any remaining startup/cutscene stutter or premature-skip artifacts.
+2. Run a live Harvester validation pass in `SERGEANTRM` on Monday with `HAVE_COMPLETED_LODGE_APP` true and `QUEST_1` false: talk to Sergeant with the completed application and confirm ScummVM now plays the recovered `0x41cd` -> `0x4292` sequence, dispatches `GO_DAY_2`, advances the story to Tuesday, and shows the native day-change cutscene/FST.
+3. Re-open the remaining hidden Sergeant talk tails around `0x30ae2..0x30fcb` and mirror the still-missing native response-menu followups for the intro, `SCRATCHED_TUCKER`, and post-`QUEST_1` lodge-application branches only where the line ids / response lines / state flips are fully decoded.
+4. Run a live Harvester validation pass on the inventory overlay and confirm right-clicking `NOTE` now dispatches `GO_BOYLNOTECU` without the redundant post-closeup inventory bitmap reloads, and that the overlay only reappears when the player explicitly reopens it after returning from the closeup.
+5. Run a live Harvester validation pass on the inventory overlay and confirm the native weekday label now appears at `(480, 396)` in `TEXTFONT` when `HARVEST_BLADE` is not in inventory, updates if the day changes while the panel stays open, and disappears immediately once `HARVEST_BLADE` is acquired.
+6. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
+7. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
+8. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
+9. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
+10. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
+11. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
+12. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
+13. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
+14. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
+15. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
 
 ## Reimplementation Priority Order
 
diff --git a/engines/harvester/fst_player.cpp b/engines/harvester/fst_player.cpp
index e6343684918..f1f438b1dc1 100644
--- a/engines/harvester/fst_player.cpp
+++ b/engines/harvester/fst_player.cpp
@@ -198,7 +198,22 @@ static bool extractFramePalette(const byte *frameData, uint32 frameSize, Common:
 	return true;
 }
 
-static bool pumpVideoEvents(HarvesterEngine &vm, bool &skipRequested) {
+static bool clearPendingVideoInput(HarvesterEngine &vm) {
+	Common::Event event;
+	while (g_system->getEventManager()->pollEvent(event)) {
+		switch (event.type) {
+		case Common::EVENT_QUIT:
+		case Common::EVENT_RETURN_TO_LAUNCHER:
+			return false;
+		default:
+			break;
+		}
+	}
+
+	return !vm.shouldQuit();
+}
+
+static bool pumpVideoEvents(HarvesterEngine &vm, bool &skipRequested, bool &deferredSkipRequested, bool allowSkipInput) {
 	Common::Event event;
 	while (g_system->getEventManager()->pollEvent(event)) {
 		switch (event.type) {
@@ -208,7 +223,11 @@ static bool pumpVideoEvents(HarvesterEngine &vm, bool &skipRequested) {
 		case Common::EVENT_KEYDOWN:
 		case Common::EVENT_LBUTTONDOWN:
 		case Common::EVENT_RBUTTONDOWN:
-			skipRequested = true;
+			if (allowSkipInput) {
+				skipRequested = true;
+			} else {
+				deferredSkipRequested = true;
+			}
 			break;
 		default:
 			break;
@@ -219,13 +238,14 @@ static bool pumpVideoEvents(HarvesterEngine &vm, bool &skipRequested) {
 }
 
 static bool waitForAudioSync(HarvesterEngine &vm, Audio::Mixer &mixer, Audio::SoundHandle audioHandle,
-		uint64 targetPlaybackBytes, uint32 audioByteRate, bool &skipRequested) {
+		uint64 targetPlaybackBytes, uint32 audioByteRate, bool &skipRequested,
+		bool &deferredSkipRequested, bool allowSkipInput) {
 	if (audioByteRate == 0)
 		return true;
 
 	const uint32 targetPlaybackMs = (uint32)((targetPlaybackBytes * 1000ULL) / audioByteRate);
 	while (!skipRequested) {
-		if (!pumpVideoEvents(vm, skipRequested))
+		if (!pumpVideoEvents(vm, skipRequested, deferredSkipRequested, allowSkipInput))
 			return false;
 		if (skipRequested || !mixer.isSoundHandleActive(audioHandle))
 			return true;
@@ -350,18 +370,28 @@ bool FstPlayer::play(const Common::String &path) {
 
 	Common::Array<FstFrameEntry> frames;
 	frames.resize(header.frameCount);
+	uint32 maxTotalFrameSize = 0;
 	for (uint32 i = 0; i < header.frameCount; ++i) {
 		frames[i].videoSize = stream->readUint32LE();
 		frames[i].audioSize = stream->readUint16LE();
+		maxTotalFrameSize = MAX(maxTotalFrameSize, frames[i].videoSize + (uint32)frames[i].audioSize);
 	}
 
 	Common::Array<byte> pixels;
 	pixels.resize(header.width * header.height);
 	Common::fill(pixels.begin(), pixels.end(), 0);
 
+	// The original player keeps one payload buffer for the full sequence instead of
+	// reallocating per frame during playback.
+	Common::Array<byte> frameData;
+	frameData.resize(maxTotalFrameSize);
+
 	byte palette[256 * 3] = { 0 };
 	g_system->getPaletteManager()->setPalette(palette, 0, 256);
 
+	if (!clearPendingVideoInput(_vm))
+		return false;
+
 	const bool shouldSwitchToMovieDisplay =
 		header.width == 320 && header.height == 200 &&
 		(_vm.getDisplayWidth() != 320 || _vm.getDisplayHeight() != 200);
@@ -394,6 +424,7 @@ bool FstPlayer::play(const Common::String &path) {
 
 	bool audioStarted = false;
 	bool skipRequested = false;
+	bool deferredSkipRequested = false;
 	uint32 nextFrameTime = g_system->getMillis();
 	const uint32 audioByteRate = hasPcmAudio ? header.sampleRate * 2 : 0;
 	const uint32 audioBytesPerFrame = (audioByteRate != 0 && header.frameRate != 0) ? audioByteRate / header.frameRate : 0;
@@ -401,9 +432,13 @@ bool FstPlayer::play(const Common::String &path) {
 	Audio::Mixer *mixer = g_system->getMixer();
 
 	for (uint32 frameIndex = 0; frameIndex < header.frameCount; ++frameIndex) {
+		if (frameIndex != 0 && deferredSkipRequested)
+			skipRequested = true;
+		if (skipRequested)
+			break;
+
+		const bool allowSkipInput = frameIndex != 0;
 		const uint32 totalFrameSize = frames[frameIndex].videoSize + frames[frameIndex].audioSize;
-		Common::Array<byte> frameData;
-		frameData.resize(totalFrameSize);
 		if (stream->read(frameData.data(), totalFrameSize) != totalFrameSize) {
 			warning("Harvester: short read while decoding '%s' frame %u", path.c_str(), frameIndex);
 			break;
@@ -425,7 +460,9 @@ bool FstPlayer::play(const Common::String &path) {
 			}
 		}
 
-		if (audioStarted && !waitForAudioSync(_vm, *mixer, audioHandle, nextAudioSyncBytes, audioByteRate, skipRequested))
+		if (audioStarted &&
+				!waitForAudioSync(_vm, *mixer, audioHandle, nextAudioSyncBytes, audioByteRate,
+					skipRequested, deferredSkipRequested, allowSkipInput))
 			break;
 
 		const byte *videoData = frameData.data();
@@ -465,13 +502,13 @@ bool FstPlayer::play(const Common::String &path) {
 			} else {
 				nextFrameTime += 1000 / header.frameRate;
 				while (!skipRequested && g_system->getMillis() < nextFrameTime) {
-					if (!pumpVideoEvents(_vm, skipRequested))
+					if (!pumpVideoEvents(_vm, skipRequested, deferredSkipRequested, allowSkipInput))
 						break;
 					g_system->delayMillis(1);
 				}
 			}
 
-			if (!pumpVideoEvents(_vm, skipRequested) || skipRequested)
+			if (!pumpVideoEvents(_vm, skipRequested, deferredSkipRequested, allowSkipInput) || skipRequested)
 				break;
 			continue;
 		}
@@ -490,13 +527,13 @@ bool FstPlayer::play(const Common::String &path) {
 		} else {
 			nextFrameTime += 1000 / header.frameRate;
 			while (!skipRequested && g_system->getMillis() < nextFrameTime) {
-				if (!pumpVideoEvents(_vm, skipRequested))
+				if (!pumpVideoEvents(_vm, skipRequested, deferredSkipRequested, allowSkipInput))
 					break;
 				g_system->delayMillis(1);
 			}
 		}
 
-		if (!pumpVideoEvents(_vm, skipRequested) || skipRequested)
+		if (!pumpVideoEvents(_vm, skipRequested, deferredSkipRequested, allowSkipInput) || skipRequested)
 			break;
 	}
 


Commit: e41ea552a6db929df99b233b74b417978d793b7e
    https://github.com/scummvm/scummvm/commit/e41ea552a6db929df99b233b74b417978d793b7e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:40+02:00

Commit Message:
HARVESTER: Preserve room animation state across rebuilds

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/flow.cpp
    engines/harvester/room.cpp
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index d695c5ded2b..94c1e9db52b 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -869,6 +869,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `room_name`, `anim_path`, `anim_name`
   - script booleans: `active`, `visible`, `loop`, `backward`, `ping_pong`, `remove`
   - `room_setup` and room teardown reuse the same live bytes as runtime state: `runtime_active`, `runtime_visible`, and `runtime_state`
+  - Before tearing the outgoing room down, `room_setup` walks the live render list, finds room anim entities by `anim_name`, copies each entity's `current_frame_index` back into `runtime_state`, forces `runtime_visible = 1`, and mirrors the live animation-enabled bit back into `runtime_active`
+  - `spawn_anim_entity_from_record` uses that saved `runtime_state` as the initial frame for non-reverse anims, so same-room rebuilds and save/load do not restart a partially progressed visible animation from frame `0`
   - `room_setup` materializes a room animation when either `active` or `visible` is set, but `spawn_anim_entity_from_record` copies only `active` into the live animation-enable flag.
   - The `SET_ANIM` dispatcher branch updates both record bytes, respawns or removes the live entity from `visible`, and toggles ticking on the already-spawned entity from `active`, so a visible-but-inactive room anim remains on its initial frame instead of advancing.
 - `TextRecord` now has stable string identity:
diff --git a/TRACKER.md b/TRACKER.md
index f42e4519594..6a3134a08a3 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, re-opened native `room_setup @ 0x73540`, `spawn_anim_entity_from_record @ 0x4bbe0`, and `tick_entity_visual_state @ 0x4cb70` in the live `HARVEST.LE` Ghidra session after the ScummVM school-hall broom-closet repro showed the `HALLDOOR.ABM` door animation respawning not open enough after the `C132.FST` transition.
+- Confirmed the native renderer already matches ScummVM's centered room-animation anchor math, so the visual gap was not a draw-origin issue. The real native behavior is room-animation state persistence: before `room_setup` tears the outgoing room down, it walks live class-1 anim entities, copies each entity's `current_frame_index` back into `AnimRecord.runtime_state`, forces `runtime_visible = 1`, and mirrors `animation_enabled` back into `runtime_active`; `spawn_anim_entity_from_record` then uses that saved `runtime_state` when it respawns non-reverse room anims.
+- Patched `engines/harvester/script.{h,cpp}`, `engines/harvester/runtime_entity.{h,cpp}`, `engines/harvester/flow.cpp`, and `engines/harvester/room.cpp` so ScummVM now preserves live room-animation state across same-room rebuilds and room/save transitions, and respawns room anim entities from the native saved frame instead of always starting from frame `0`. Updated `ARCHITECTURE.md` with the confirmed native `AnimRecord.runtime_state` note and rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 - On March 20, 2026, re-decompiled `play_fst_sequence @ 0x72550` and `run_fst_sequence_player @ 0x12b00` in Ghidra from scratch to check whether ScummVM's FST playback path was missing any native decode or wrapper steps that could explain the reported video-quality gap.
 - Confirmed the native FST block codec already matches the current ScummVM implementation closely: 4x4 changed-block decoding, optional per-frame VGA palette chunks, per-frame PCM queuing, and the same "truncated tail in the last scan band" behavior seen in shipped assets such as `VIRGLOGO.FST`, `FVLOGO.FST`, `INTROFIN.FST`, `C044.FST`, and `C048.FST`. The concrete native differences in this pass were player-side plumbing: one reused payload buffer for the whole sequence, wrapper-side stale-input clearing before playback, and skip acceptance only after frame 0 has started.
 - Patched `engines/harvester/fst_player.cpp` to mirror those proven runtime details on the ScummVM side by reusing a single frame payload buffer for the full sequence instead of reallocating per frame, draining stale skip input before playback begins, and deferring skip requests until after frame 0. Updated `ARCHITECTURE.md` with the confirmed native FST-player notes and rebuilt `engines/harvester/fst_player.o` plus `scummvm` successfully.
@@ -121,21 +124,22 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester validation pass on FST playback itself: compare `VIRGLOGO.FST`, `FVLOGO.FST`, `INTROFIN.FST`, `C044.FST`, and `C048.FST` against the original executable and confirm the reused payload buffer plus native first-frame skip gating remove any remaining startup/cutscene stutter or premature-skip artifacts.
-2. Run a live Harvester validation pass in `SERGEANTRM` on Monday with `HAVE_COMPLETED_LODGE_APP` true and `QUEST_1` false: talk to Sergeant with the completed application and confirm ScummVM now plays the recovered `0x41cd` -> `0x4292` sequence, dispatches `GO_DAY_2`, advances the story to Tuesday, and shows the native day-change cutscene/FST.
-3. Re-open the remaining hidden Sergeant talk tails around `0x30ae2..0x30fcb` and mirror the still-missing native response-menu followups for the intro, `SCRATCHED_TUCKER`, and post-`QUEST_1` lodge-application branches only where the line ids / response lines / state flips are fully decoded.
-4. Run a live Harvester validation pass on the inventory overlay and confirm right-clicking `NOTE` now dispatches `GO_BOYLNOTECU` without the redundant post-closeup inventory bitmap reloads, and that the overlay only reappears when the player explicitly reopens it after returning from the closeup.
-5. Run a live Harvester validation pass on the inventory overlay and confirm the native weekday label now appears at `(480, 396)` in `TEXTFONT` when `HARVEST_BLADE` is not in inventory, updates if the day changes while the panel stays open, and disappears immediately once `HARVEST_BLADE` is acquired.
-6. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
-7. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
-8. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
-9. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
-10. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
-11. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
-12. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
-13. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
-14. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
-15. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
+1. Run a live Harvester validation pass in `SCHLHL`: use `BROOMKEY` on `SCHL_DOOR_HS`, let `GRAPHIC/FST/C132.FST` finish, and confirm the `SCHDOOR` room anim now respawns from its native saved frame/open state instead of restarting from the closed-frame default.
+2. Run a live Harvester validation pass on FST playback itself: compare `VIRGLOGO.FST`, `FVLOGO.FST`, `INTROFIN.FST`, `C044.FST`, and `C048.FST` against the original executable and confirm the reused payload buffer plus native first-frame skip gating remove any remaining startup/cutscene stutter or premature-skip artifacts.
+3. Run a live Harvester validation pass in `SERGEANTRM` on Monday with `HAVE_COMPLETED_LODGE_APP` true and `QUEST_1` false: talk to Sergeant with the completed application and confirm ScummVM now plays the recovered `0x41cd` -> `0x4292` sequence, dispatches `GO_DAY_2`, advances the story to Tuesday, and shows the native day-change cutscene/FST.
+4. Re-open the remaining hidden Sergeant talk tails around `0x30ae2..0x30fcb` and mirror the still-missing native response-menu followups for the intro, `SCRATCHED_TUCKER`, and post-`QUEST_1` lodge-application branches only where the line ids / response lines / state flips are fully decoded.
+5. Run a live Harvester validation pass on the inventory overlay and confirm right-clicking `NOTE` now dispatches `GO_BOYLNOTECU` without the redundant post-closeup inventory bitmap reloads, and that the overlay only reappears when the player explicitly reopens it after returning from the closeup.
+6. Run a live Harvester validation pass on the inventory overlay and confirm the native weekday label now appears at `(480, 396)` in `TEXTFONT` when `HARVEST_BLADE` is not in inventory, updates if the day changes while the panel stays open, and disappears immediately once `HARVEST_BLADE` is acquired.
+7. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
+8. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
+9. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
+10. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
+11. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
+12. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
+13. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
+14. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
+15. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
+16. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
 
 ## Reimplementation Priority Order
 
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 39f182ef33e..3e5b7b81465 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -2390,7 +2390,7 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 	for (const StartupAnimRecord &anim : drawableAnimations) {
 		if (!runtimeEntities->spawnSceneAnimationEntity(anim.animName, anim.resourcePath,
 				Common::Point(anim.x, anim.y), (float)anim.z, anim.frameDelay, anim.active, anim.visible,
-				anim.looping, anim.backward, anim.pingPong)) {
+				anim.looping, anim.backward, anim.pingPong, anim.runtimeState)) {
 			debug(1, "Harvester: unable to spawn room anim entity '%s' from '%s'",
 				anim.animName.c_str(), anim.resourcePath.c_str());
 		}
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index aba7faa39e9..7766eb0e2e0 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -473,6 +473,15 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			if (!startupScript)
 				return;
 
+			if (runtimeEntities) {
+				for (StartupAnimRecord &anim : scene.state.roomAnimations) {
+					RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(anim.animName);
+					if (!entity)
+						continue;
+					(void)startupScript->syncRuntimeAnimState(anim.animName,
+						entity->isAnimationEnabled(), entity->isVisible(), entity->getCurrentFrame());
+				}
+			}
 			for (const StartupMonsterRecord &monster : scene.state.roomMonsters)
 				(void)startupScript->syncRuntimeMonsterRecord(monster);
 			if (!runtimeEntities)
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index fb0a45baefa..270a4a4e21a 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -791,7 +791,7 @@ RuntimeEntity *RuntimeEntityManager::spawnSceneHotspotEntity(const Common::Strin
 
 RuntimeEntity *RuntimeEntityManager::spawnSceneAnimationEntity(const Common::String &name,
 		const Common::String &resourcePath, const Common::Point &position, float z, int animationRate,
-		bool active, bool visible, bool looping, bool playBackwards, bool pingPong) {
+		bool active, bool visible, bool looping, bool playBackwards, bool pingPong, int initialFrame) {
 	RuntimeEntity *entity = spawnAbmEntityFromResource(name, resourcePath, kRuntimeEntityClassAnimation,
 		position, z, animationRate, looping, pingPong);
 	if (!entity)
@@ -802,10 +802,10 @@ RuntimeEntity *RuntimeEntityManager::spawnSceneAnimationEntity(const Common::Str
 	entity->setVisible(visible);
 	entity->setPlayBackwards(playBackwards);
 	entity->setHitTestMode(kRuntimeEntityHitTestNone);
-	if (playBackwards)
-		entity->setCurrentFrame(entity->getLastFrame());
-	else
-		entity->setCurrentFrame(0);
+	int startFrame = playBackwards ? entity->getLastFrame() : 0;
+	if (!playBackwards && initialFrame >= 0)
+		startFrame = CLIP<int>(initialFrame, 0, entity->getLastFrame());
+	entity->setCurrentFrame(startFrame);
 	entity->setAnimationEnabled(active);
 
 	if (!active && !visible)
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index d4608a11c86..ad3e3d09621 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -89,6 +89,7 @@ public:
 	void setAnimationRate(int rate);
 	int getAnimationRate() const { return _animationRate; }
 	void setAnimationEnabled(bool enabled);
+	bool isAnimationEnabled() const { return _animationEnabled; }
 	void setCurrentFrame(int frame);
 	int getCurrentFrame() const { return _currentFrame; }
 	int getLastFrame() const { return _lastFrame; }
@@ -185,7 +186,7 @@ public:
 	RuntimeEntity *spawnSceneHotspotEntity(const Common::String &name, const Common::Rect &bounds, float z);
 	RuntimeEntity *spawnSceneAnimationEntity(const Common::String &name, const Common::String &resourcePath,
 		const Common::Point &position, float z, int animationRate, bool active, bool visible, bool looping,
-		bool playBackwards, bool pingPong);
+		bool playBackwards, bool pingPong, int initialFrame = -1);
 	RuntimeEntity *spawnSceneActorEntity(const Common::String &name, const Common::String &resourcePath,
 		const Common::Point &position, float z, int initialFrame);
 	RuntimeEntity *spawnSceneTimerEntity(const Common::String &name, int initialValue, int currentValue,
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index f7d49b786bf..f3d78c596dc 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1841,6 +1841,24 @@ bool Script::setPlayerControlPaused(bool paused) {
 	return changed;
 }
 
+bool Script::syncRuntimeAnimState(const Common::String &animName, bool active, bool visible, int currentFrame) {
+	StartupAnimRecord *runtimeAnim = findRuntimeAnim(animName);
+	if (!runtimeAnim)
+		return false;
+
+	const bool changed = runtimeAnim->active != active ||
+		runtimeAnim->visible != visible ||
+		runtimeAnim->runtimeActive != active ||
+		runtimeAnim->runtimeVisible != visible ||
+		runtimeAnim->runtimeState != currentFrame;
+	runtimeAnim->active = active;
+	runtimeAnim->visible = visible;
+	runtimeAnim->runtimeActive = active;
+	runtimeAnim->runtimeVisible = visible;
+	runtimeAnim->runtimeState = currentFrame;
+	return changed;
+}
+
 bool Script::syncRuntimeMonsterRecord(const StartupMonsterRecord &monster) {
 	StartupMonsterRecord *runtimeMonster = findRuntimeMonster(monster.monsterName);
 	if (!runtimeMonster)
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 078dd7eafd2..e5c7e48be97 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -423,6 +423,7 @@ public:
 	bool isPlayerControlPaused() const { return _playerControlPaused; }
 	bool setPlayerCombatLoadout(int loadout);
 	bool setPlayerControlPaused(bool paused);
+	bool syncRuntimeAnimState(const Common::String &animName, bool active, bool visible, int currentFrame);
 	bool syncRuntimeMonsterRecord(const StartupMonsterRecord &monster);
 	bool syncRuntimeTimerRecord(const StartupTimerRecord &timer);
 	bool setRuntimeTimerEnabled(const Common::String &timerName, bool enabled);


Commit: e5baf05687222a170a898a23098bf67f91f2db00
    https://github.com/scummvm/scummvm/commit/e5baf05687222a170a898a23098bf67f91f2db00
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:40+02:00

Commit Message:
HARVESTER: Defer room refresh until after GOFLIC

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 94c1e9db52b..26e6d0111d8 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -873,6 +873,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `spawn_anim_entity_from_record` uses that saved `runtime_state` as the initial frame for non-reverse anims, so same-room rebuilds and save/load do not restart a partially progressed visible animation from frame `0`
   - `room_setup` materializes a room animation when either `active` or `visible` is set, but `spawn_anim_entity_from_record` copies only `active` into the live animation-enable flag.
   - The `SET_ANIM` dispatcher branch updates both record bytes, respawns or removes the live entity from `visible`, and toggles ticking on the already-spawned entity from `active`, so a visible-but-inactive room anim remains on its initial frame instead of advancing.
+  - The `GOFLIC` dispatcher branch runs `play_fst_sequence` immediately after those record/entity mutations; it does not force an intermediate same-room rebuild before the FST starts.
+  - For same-room interactions such as `USE_BROOMKEY -> SCHL_DOOR_HS`, native ordering is therefore: mutate `SCHDOOR` / `WAYLHERL` with `SET_ANIM`, play `GRAPHIC/FST/C132.FST`, then let the later room refresh respawn `SCHDOOR` from the saved post-animation `runtime_state`.
 - `TextRecord` now has stable string identity:
   - `text_id`, `panel_id`, `text_value`, `next`
   - The parser normalizes `_` to space characters in `text_value`
diff --git a/TRACKER.md b/TRACKER.md
index 6a3134a08a3..387ccdc7e38 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, re-opened `dispatch_room_event_actions @ 0x60ee0` in the live `HARVEST.LE` Ghidra session after the DOSBox-vs-ScummVM `SCHDOOR` screenshot still showed the school-hall broom-closet door respawning too closed even after preserving `AnimRecord.runtime_state`.
+- Confirmed the remaining native difference was opcode ordering, not draw math or FST decode: the `GOFLIC` case calls `play_fst_sequence` immediately, and the native `SET_ANIM` handler mutates the live `SCHDOOR` room anim in place without forcing a same-room rebuild first. That means the original executable shows `GRAPHIC/FST/C132.FST` before any rebuilt `SCHLHL` scene can respawn the door.
+- Patched `engines/harvester/room.cpp` so same-room `mutatedRuntimeState` interactions now defer `refreshCurrentScene(true)` until after `interaction.cutscenePath` finishes, which matches the confirmed native `SET_ANIM` -> `GOFLIC` ordering for the broom-closet sequence. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 - On March 20, 2026, re-opened native `room_setup @ 0x73540`, `spawn_anim_entity_from_record @ 0x4bbe0`, and `tick_entity_visual_state @ 0x4cb70` in the live `HARVEST.LE` Ghidra session after the ScummVM school-hall broom-closet repro showed the `HALLDOOR.ABM` door animation respawning not open enough after the `C132.FST` transition.
 - Confirmed the native renderer already matches ScummVM's centered room-animation anchor math, so the visual gap was not a draw-origin issue. The real native behavior is room-animation state persistence: before `room_setup` tears the outgoing room down, it walks live class-1 anim entities, copies each entity's `current_frame_index` back into `AnimRecord.runtime_state`, forces `runtime_visible = 1`, and mirrors `animation_enabled` back into `runtime_active`; `spawn_anim_entity_from_record` then uses that saved `runtime_state` when it respawns non-reverse room anims.
 - Patched `engines/harvester/script.{h,cpp}`, `engines/harvester/runtime_entity.{h,cpp}`, `engines/harvester/flow.cpp`, and `engines/harvester/room.cpp` so ScummVM now preserves live room-animation state across same-room rebuilds and room/save transitions, and respawns room anim entities from the native saved frame instead of always starting from frame `0`. Updated `ARCHITECTURE.md` with the confirmed native `AnimRecord.runtime_state` note and rebuilt `build-vscode-harvester-debug/scummvm` successfully.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 7766eb0e2e0..ee1656ea4de 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -769,6 +769,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			Common::Error handleInteractionResult(const StartupInteractionResult &interaction,
 					bool &didTransition, const Common::String &usedItemName) {
 				didTransition = false;
+				bool deferredSceneRefresh = false;
 				playerState.hasMoveTarget = false;
 				playerState.turnActive = false;
 				playerState.turnTargetFacing = -1;
@@ -838,8 +839,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 						engine.stopStartupMusic();
 					didTransition = true;
 				} else if (interaction.mutatedRuntimeState) {
-					if (!refreshCurrentSceneFn(true))
-						return Common::kReadingFailed;
+					deferredSceneRefresh = !interaction.cutscenePath.empty();
+					if (!deferredSceneRefresh) {
+						if (!refreshCurrentSceneFn(true))
+							return Common::kReadingFailed;
+					}
 				}
 
 				if (didTransition)
@@ -858,6 +862,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					FstPlayer fstPlayer(engine);
 					if (!fstPlayer.play(interaction.cutscenePath))
 						return Common::kReadingFailed;
+					if (deferredSceneRefresh) {
+						if (!refreshCurrentSceneFn(true))
+							return Common::kReadingFailed;
+						deferredSceneRefresh = false;
+					}
 				}
 
 				if (!interaction.modalText.value.empty()) {


Commit: 2175820703ac6dfdd3dfd2e933d69d9f68fbbf7a
    https://github.com/scummvm/scummvm/commit/2175820703ac6dfdd3dfd2e933d69d9f68fbbf7a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:40+02:00

Commit Message:
HARVESTER: Fix room animation anchor drift

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/runtime_entity.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 26e6d0111d8..184ca7d61d0 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -769,7 +769,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `spawn_object_entity_from_record` promotes sprite-backed objects whose initial coordinates are `(0, 0)` and whose loaded bitmap is `640x480` to runtime class `3`, the room-background object class.
   - Hotspot-only objects with no action tag become class `0x16`; the other hotspot-only objects become class `0x15`.
   - `spawn_region_entity_from_record` builds runtime class `0x19` rectangular hotspots from the record bounds and Z span, copies `desired_facing`, and maps `cursor_enabled` onto the runtime hotspot interaction flag.
-  - `spawn_anim_entity_from_record` immediately drives `show_entity_visual`, so room `ANIM` records use the centered anchor path during room setup, while room bitmap objects are inserted at their record `x` / `y` without that initial recentering step.
+  - `spawn_anim_entity_from_record` immediately drives `show_entity_visual`, but room `ANIM` records do not use a centered draw anchor. The runtime keeps the record `x` / `y` as the fixed screen anchor and applies only the current ABM frame header `x_offset` / `y_offset`.
   - `spawn_timer_entity_from_record` builds runtime class `0x17` timer entities keyed by `TimerRecord.timer_name`; when the timer starts enabled it seeds the start tick from `DAT_000c7e44` and computes the first expiration as `start_tick + initial_value * 100`.
   - `spawn_npc_entity_from_record` seeds ordinary room-NPC ambient playback as actor state `0x34` with a bounded frame window of `0..0x3b`; the later death / monsterfy / reaction banks begin above that range, so passive room NPCs do not free-run the whole ABM.
 - `CommandRecord` is now stable enough to use directly in the event interpreter:
@@ -868,11 +868,13 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `pos_x`, `pos_y`, `pos_z`, `frame_delay`
   - `room_name`, `anim_path`, `anim_name`
   - script booleans: `active`, `visible`, `loop`, `backward`, `ping_pong`, `remove`
+  - `advance_entity_animation_frame` copies the current ABM frame header offsets into `frame_x_offset` / `frame_y_offset`, and `update_render_entity_screen_position` renders from `screen_x + frame_x_offset` / `screen_y + frame_y_offset`; room anims are not recentered from the current frame width/height.
   - `room_setup` and room teardown reuse the same live bytes as runtime state: `runtime_active`, `runtime_visible`, and `runtime_state`
   - Before tearing the outgoing room down, `room_setup` walks the live render list, finds room anim entities by `anim_name`, copies each entity's `current_frame_index` back into `runtime_state`, forces `runtime_visible = 1`, and mirrors the live animation-enabled bit back into `runtime_active`
   - `spawn_anim_entity_from_record` uses that saved `runtime_state` as the initial frame for non-reverse anims, so same-room rebuilds and save/load do not restart a partially progressed visible animation from frame `0`
   - `room_setup` materializes a room animation when either `active` or `visible` is set, but `spawn_anim_entity_from_record` copies only `active` into the live animation-enable flag.
   - The `SET_ANIM` dispatcher branch updates both record bytes, respawns or removes the live entity from `visible`, and toggles ticking on the already-spawned entity from `active`, so a visible-but-inactive room anim remains on its initial frame instead of advancing.
+  - `HALLDOOR.ABM` frame headers carry zero `x_offset`, so the broom-closet door stays pinned to its record `x` while later open frames narrow; a centered re-anchor makes that sequence drift too far right.
   - The `GOFLIC` dispatcher branch runs `play_fst_sequence` immediately after those record/entity mutations; it does not force an intermediate same-room rebuild before the FST starts.
   - For same-room interactions such as `USE_BROOMKEY -> SCHL_DOOR_HS`, native ordering is therefore: mutate `SCHDOOR` / `WAYLHERL` with `SET_ANIM`, play `GRAPHIC/FST/C132.FST`, then let the later room refresh respawn `SCHDOOR` from the saved post-animation `runtime_state`.
 - `TextRecord` now has stable string identity:
diff --git a/TRACKER.md b/TRACKER.md
index 387ccdc7e38..d8d87d3c7c2 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,11 +16,14 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, re-opened `spawn_anim_entity_from_record @ 0x4bbe0`, `advance_entity_animation_frame @ 0x4c8a0`, and `update_render_entity_screen_position @ 0x5cc10` in the live `HARVEST.LE` Ghidra session after a fresh DOSBox-vs-ScummVM `SCHDOOR` screenshot suggested the broom-closet door was still drifting too far right while opening.
+- Confirmed the remaining placement bug was a horizontal anchor mismatch: native room anims keep their record `x` / `y` as the fixed screen anchor, then render from `screen_x + frame_x_offset` and `screen_y + frame_y_offset`. `advance_entity_animation_frame` only copies the current ABM header offsets into those runtime fields; it does not recenter by frame width, and `HALLDOOR.ABM`'s frame headers carry zero `x_offset`, so the open-door frames stay pinned instead of sliding right as they narrow.
+- Patched `engines/harvester/runtime_entity.cpp` so `spawnSceneAnimationEntity` now uses the native top-left anchor path instead of recentering scene ABMs on the current frame size. Updated `ARCHITECTURE.md` with the corrected room-animation placement note.
 - On March 20, 2026, re-opened `dispatch_room_event_actions @ 0x60ee0` in the live `HARVEST.LE` Ghidra session after the DOSBox-vs-ScummVM `SCHDOOR` screenshot still showed the school-hall broom-closet door respawning too closed even after preserving `AnimRecord.runtime_state`.
 - Confirmed the remaining native difference was opcode ordering, not draw math or FST decode: the `GOFLIC` case calls `play_fst_sequence` immediately, and the native `SET_ANIM` handler mutates the live `SCHDOOR` room anim in place without forcing a same-room rebuild first. That means the original executable shows `GRAPHIC/FST/C132.FST` before any rebuilt `SCHLHL` scene can respawn the door.
 - Patched `engines/harvester/room.cpp` so same-room `mutatedRuntimeState` interactions now defer `refreshCurrentScene(true)` until after `interaction.cutscenePath` finishes, which matches the confirmed native `SET_ANIM` -> `GOFLIC` ordering for the broom-closet sequence. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 - On March 20, 2026, re-opened native `room_setup @ 0x73540`, `spawn_anim_entity_from_record @ 0x4bbe0`, and `tick_entity_visual_state @ 0x4cb70` in the live `HARVEST.LE` Ghidra session after the ScummVM school-hall broom-closet repro showed the `HALLDOOR.ABM` door animation respawning not open enough after the `C132.FST` transition.
-- Confirmed the native renderer already matches ScummVM's centered room-animation anchor math, so the visual gap was not a draw-origin issue. The real native behavior is room-animation state persistence: before `room_setup` tears the outgoing room down, it walks live class-1 anim entities, copies each entity's `current_frame_index` back into `AnimRecord.runtime_state`, forces `runtime_visible = 1`, and mirrors `animation_enabled` back into `runtime_active`; `spawn_anim_entity_from_record` then uses that saved `runtime_state` when it respawns non-reverse room anims.
+- Confirmed the real native behavior is room-animation state persistence: before `room_setup` tears the outgoing room down, it walks live class-1 anim entities, copies each entity's `current_frame_index` back into `AnimRecord.runtime_state`, forces `runtime_visible = 1`, and mirrors `animation_enabled` back into `runtime_active`; `spawn_anim_entity_from_record` then uses that saved `runtime_state` when it respawns non-reverse room anims.
 - Patched `engines/harvester/script.{h,cpp}`, `engines/harvester/runtime_entity.{h,cpp}`, `engines/harvester/flow.cpp`, and `engines/harvester/room.cpp` so ScummVM now preserves live room-animation state across same-room rebuilds and room/save transitions, and respawns room anim entities from the native saved frame instead of always starting from frame `0`. Updated `ARCHITECTURE.md` with the confirmed native `AnimRecord.runtime_state` note and rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 - On March 20, 2026, re-decompiled `play_fst_sequence @ 0x72550` and `run_fst_sequence_player @ 0x12b00` in Ghidra from scratch to check whether ScummVM's FST playback path was missing any native decode or wrapper steps that could explain the reported video-quality gap.
 - Confirmed the native FST block codec already matches the current ScummVM implementation closely: 4x4 changed-block decoding, optional per-frame VGA palette chunks, per-frame PCM queuing, and the same "truncated tail in the last scan band" behavior seen in shipped assets such as `VIRGLOGO.FST`, `FVLOGO.FST`, `INTROFIN.FST`, `C044.FST`, and `C048.FST`. The concrete native differences in this pass were player-side plumbing: one reused payload buffer for the whole sequence, wrapper-side stale-input clearing before playback, and skip acceptance only after frame 0 has started.
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index 270a4a4e21a..96a01b3f1d0 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -797,7 +797,10 @@ RuntimeEntity *RuntimeEntityManager::spawnSceneAnimationEntity(const Common::Str
 	if (!entity)
 		return nullptr;
 
-	entity->setAnchorMode(kRuntimeEntityAnchorCentered);
+	// Native room ANIM entities keep their record x/y as the fixed screen anchor.
+	// Per-frame ABM x/y offsets handle any origin adjustment, so recentering on the
+	// current frame size makes narrowing animations drift horizontally.
+	entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
 	entity->setZExtent(3.0f);
 	entity->setVisible(visible);
 	entity->setPlayBackwards(playBackwards);


Commit: 7747eeb493528aa45ffd00619f617f9b194de81c
    https://github.com/scummvm/scummvm/commit/7747eeb493528aa45ffd00619f617f9b194de81c
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:41+02:00

Commit Message:
HARVESTER: Recover room right-click attacks

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/flow.cpp
    engines/harvester/room.cpp
    engines/harvester/room_support.h
    engines/harvester/runtime_entity.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 184ca7d61d0..4d3893e5760 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -279,6 +279,10 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `EXIT_CLOSEUP` restores `g_pending_room_name = g_player_room_name`, copies the live player position/facing into the `SAVE_GAME` entrance, and restores `g_player_present_in_room = 1`.
 - `run_harvester_main_loop` is the code that actually consumes `g_pending_room_name`.
   - At the top of each gameplay iteration it checks for a queued handoff, clears any selected carried object / interaction text state that would leak across the boundary, and then calls `room_setup(g_pending_room_name)`.
+  - The same loop also owns the live room right-click attack selector when no carried item is active and the player is alive.
+  - It sets `DAT_000d61d0 = 3`, compares cursor X against the player midpoint, and then compares cursor Y against `player_bottom - depth_scale * 144.44` and `player_bottom - depth_scale * 75.36`.
+  - Those thresholds choose the six directional attack states `0x18/0x17/0x16` on the left side and `0x1b/0x1a/0x19` on the right side.
+  - `update_actor_runtime_state` maps those states to frame banks `0x50..0x59`, `0x5a..0x63`, `0x64..0x6d`, `0x6e..0x77`, `0x78..0x81`, and `0x82..0x8b`.
 - `dispatch_room_event_actions` supports:
   - Conditional branching between alternate action tags
   - Recursive dispatch of named action groups
@@ -769,7 +773,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `spawn_object_entity_from_record` promotes sprite-backed objects whose initial coordinates are `(0, 0)` and whose loaded bitmap is `640x480` to runtime class `3`, the room-background object class.
   - Hotspot-only objects with no action tag become class `0x16`; the other hotspot-only objects become class `0x15`.
   - `spawn_region_entity_from_record` builds runtime class `0x19` rectangular hotspots from the record bounds and Z span, copies `desired_facing`, and maps `cursor_enabled` onto the runtime hotspot interaction flag.
-  - `spawn_anim_entity_from_record` immediately drives `show_entity_visual`, but room `ANIM` records do not use a centered draw anchor. The runtime keeps the record `x` / `y` as the fixed screen anchor and applies only the current ABM frame header `x_offset` / `y_offset`.
+  - `spawn_anim_entity_from_record` immediately drives `show_entity_visual`, which centers the current decoded bitmap on the record `x` / `y` pivot before later movement code applies the current frame header `x_offset` / `y_offset`.
   - `spawn_timer_entity_from_record` builds runtime class `0x17` timer entities keyed by `TimerRecord.timer_name`; when the timer starts enabled it seeds the start tick from `DAT_000c7e44` and computes the first expiration as `start_tick + initial_value * 100`.
   - `spawn_npc_entity_from_record` seeds ordinary room-NPC ambient playback as actor state `0x34` with a bounded frame window of `0..0x3b`; the later death / monsterfy / reaction banks begin above that range, so passive room NPCs do not free-run the whole ABM.
 - `CommandRecord` is now stable enough to use directly in the event interpreter:
@@ -868,13 +872,12 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `pos_x`, `pos_y`, `pos_z`, `frame_delay`
   - `room_name`, `anim_path`, `anim_name`
   - script booleans: `active`, `visible`, `loop`, `backward`, `ping_pong`, `remove`
-  - `advance_entity_animation_frame` copies the current ABM frame header offsets into `frame_x_offset` / `frame_y_offset`, and `update_render_entity_screen_position` renders from `screen_x + frame_x_offset` / `screen_y + frame_y_offset`; room anims are not recentered from the current frame width/height.
+  - `advance_entity_animation_frame` copies the current ABM frame header offsets into `frame_x_offset` / `frame_y_offset`, and `update_visible_entity_screen_position` later adds those offsets onto the already-centered entity position computed by `show_entity_visual`.
   - `room_setup` and room teardown reuse the same live bytes as runtime state: `runtime_active`, `runtime_visible`, and `runtime_state`
   - Before tearing the outgoing room down, `room_setup` walks the live render list, finds room anim entities by `anim_name`, copies each entity's `current_frame_index` back into `runtime_state`, forces `runtime_visible = 1`, and mirrors the live animation-enabled bit back into `runtime_active`
   - `spawn_anim_entity_from_record` uses that saved `runtime_state` as the initial frame for non-reverse anims, so same-room rebuilds and save/load do not restart a partially progressed visible animation from frame `0`
   - `room_setup` materializes a room animation when either `active` or `visible` is set, but `spawn_anim_entity_from_record` copies only `active` into the live animation-enable flag.
   - The `SET_ANIM` dispatcher branch updates both record bytes, respawns or removes the live entity from `visible`, and toggles ticking on the already-spawned entity from `active`, so a visible-but-inactive room anim remains on its initial frame instead of advancing.
-  - `HALLDOOR.ABM` frame headers carry zero `x_offset`, so the broom-closet door stays pinned to its record `x` while later open frames narrow; a centered re-anchor makes that sequence drift too far right.
   - The `GOFLIC` dispatcher branch runs `play_fst_sequence` immediately after those record/entity mutations; it does not force an intermediate same-room rebuild before the FST starts.
   - For same-room interactions such as `USE_BROOMKEY -> SCHL_DOOR_HS`, native ordering is therefore: mutate `SCHDOOR` / `WAYLHERL` with `SET_ANIM`, play `GRAPHIC/FST/C132.FST`, then let the later room refresh respawn `SCHDOOR` from the saved post-animation `runtime_state`.
 - `TextRecord` now has stable string identity:
diff --git a/TRACKER.md b/TRACKER.md
index d8d87d3c7c2..88319319945 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,9 +16,13 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, re-opened `run_harvester_main_loop @ 0x6dc70`, `update_actor_runtime_state @ 0x4d750`, and `set_player_combat_loadout @ 0x55c10` in the live `HARVEST.LE` Ghidra session to recover the native room right-click attack path instead of guessing from the existing ScummVM inventory weapon toggle.
+- Confirmed the native room attack selector uses the current `pc*.abm` loadout art, compares cursor X against the player midpoint, then compares cursor Y against `player_bottom - depth_scale * 144.44` and `player_bottom - depth_scale * 75.36` to choose six directional banks: left `0x18/0x17/0x16` or right `0x1b/0x1a/0x19`, which map to frames `0x50..0x59`, `0x5a..0x63`, `0x64..0x6d`, `0x6e..0x77`, `0x78..0x81`, and `0x82..0x8b`.
+- Patched `engines/harvester/flow.cpp`, `engines/harvester/room.cpp`, and `engines/harvester/room_support.h` so room right-click now starts that recovered directional attack, inventory weapon toggles reload the matching `PC0/PC01..PC20` actor art in-room, and the room loop suppresses movement/hover/region dispatch until the attack bank completes. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
+- On March 20, 2026, re-opened `show_entity_visual @ 0x4c290`, `update_visible_entity_screen_position @ 0x4c860`, and `set_entity_screen_position @ 0x4b6f0` in the live `HARVEST.LE` Ghidra session after the previous room-animation anchor change made `SCHDOOR` float far down-right in `SCHLHL`.
+- Confirmed the prior top-left interpretation was wrong. Native room anims are centered around their record `x` / `y`: `show_entity_visual` subtracts half the current decoded bitmap width/height before storing the live entity screen position, and `update_visible_entity_screen_position` applies the frame header `x_offset` / `y_offset` afterward. Reverted `engines/harvester/runtime_entity.cpp` back to the centered room-animation anchor path and corrected the room-animation notes in `ARCHITECTURE.md`.
 - On March 20, 2026, re-opened `spawn_anim_entity_from_record @ 0x4bbe0`, `advance_entity_animation_frame @ 0x4c8a0`, and `update_render_entity_screen_position @ 0x5cc10` in the live `HARVEST.LE` Ghidra session after a fresh DOSBox-vs-ScummVM `SCHDOOR` screenshot suggested the broom-closet door was still drifting too far right while opening.
-- Confirmed the remaining placement bug was a horizontal anchor mismatch: native room anims keep their record `x` / `y` as the fixed screen anchor, then render from `screen_x + frame_x_offset` and `screen_y + frame_y_offset`. `advance_entity_animation_frame` only copies the current ABM header offsets into those runtime fields; it does not recenter by frame width, and `HALLDOOR.ABM`'s frame headers carry zero `x_offset`, so the open-door frames stay pinned instead of sliding right as they narrow.
-- Patched `engines/harvester/runtime_entity.cpp` so `spawnSceneAnimationEntity` now uses the native top-left anchor path instead of recentering scene ABMs on the current frame size. Updated `ARCHITECTURE.md` with the corrected room-animation placement note.
+- Confirmed `HALLDOOR.ABM` frame headers carry zero `x_offset` and `0/1` `y_offset`, which means any remaining horizontal mismatch has to come from the room-animation pivot/placement path rather than from unexpected per-frame header shifts.
 - On March 20, 2026, re-opened `dispatch_room_event_actions @ 0x60ee0` in the live `HARVEST.LE` Ghidra session after the DOSBox-vs-ScummVM `SCHDOOR` screenshot still showed the school-hall broom-closet door respawning too closed even after preserving `AnimRecord.runtime_state`.
 - Confirmed the remaining native difference was opcode ordering, not draw math or FST decode: the `GOFLIC` case calls `play_fst_sequence` immediately, and the native `SET_ANIM` handler mutates the live `SCHDOOR` room anim in place without forcing a same-room rebuild first. That means the original executable shows `GRAPHIC/FST/C132.FST` before any rebuilt `SCHLHL` scene can respawn the door.
 - Patched `engines/harvester/room.cpp` so same-room `mutatedRuntimeState` interactions now defer `refreshCurrentScene(true)` until after `interaction.cutscenePath` finishes, which matches the confirmed native `SET_ANIM` -> `GOFLIC` ordering for the broom-closet sequence. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
@@ -130,22 +134,23 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester validation pass in `SCHLHL`: use `BROOMKEY` on `SCHL_DOOR_HS`, let `GRAPHIC/FST/C132.FST` finish, and confirm the `SCHDOOR` room anim now respawns from its native saved frame/open state instead of restarting from the closed-frame default.
-2. Run a live Harvester validation pass on FST playback itself: compare `VIRGLOGO.FST`, `FVLOGO.FST`, `INTROFIN.FST`, `C044.FST`, and `C048.FST` against the original executable and confirm the reused payload buffer plus native first-frame skip gating remove any remaining startup/cutscene stutter or premature-skip artifacts.
-3. Run a live Harvester validation pass in `SERGEANTRM` on Monday with `HAVE_COMPLETED_LODGE_APP` true and `QUEST_1` false: talk to Sergeant with the completed application and confirm ScummVM now plays the recovered `0x41cd` -> `0x4292` sequence, dispatches `GO_DAY_2`, advances the story to Tuesday, and shows the native day-change cutscene/FST.
-4. Re-open the remaining hidden Sergeant talk tails around `0x30ae2..0x30fcb` and mirror the still-missing native response-menu followups for the intro, `SCRATCHED_TUCKER`, and post-`QUEST_1` lodge-application branches only where the line ids / response lines / state flips are fully decoded.
-5. Run a live Harvester validation pass on the inventory overlay and confirm right-clicking `NOTE` now dispatches `GO_BOYLNOTECU` without the redundant post-closeup inventory bitmap reloads, and that the overlay only reappears when the player explicitly reopens it after returning from the closeup.
-6. Run a live Harvester validation pass on the inventory overlay and confirm the native weekday label now appears at `(480, 396)` in `TEXTFONT` when `HARVEST_BLADE` is not in inventory, updates if the day changes while the panel stays open, and disappears immediately once `HARVEST_BLADE` is acquired.
-7. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
-8. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
-9. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
-10. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
-11. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
-12. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
-13. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
-14. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
-15. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
-16. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
+1. Run a live Harvester validation pass on room right-click attacks with both bare hands and an equipped weapon: confirm the player swaps to the correct `PC0/PC01..PC20` art after inventory weapon toggles, and that right-clicking left/right plus high/mid/low around the player selects the recovered six attack banks instead of opening inventory or continuing movement.
+2. Run a live Harvester validation pass in `SCHLHL`: use `BROOMKEY` on `SCHL_DOOR_HS`, let `GRAPHIC/FST/C132.FST` finish, and confirm the `SCHDOOR` room anim now respawns from its native saved frame/open state instead of restarting from the closed-frame default.
+3. Run a live Harvester validation pass on FST playback itself: compare `VIRGLOGO.FST`, `FVLOGO.FST`, `INTROFIN.FST`, `C044.FST`, and `C048.FST` against the original executable and confirm the reused payload buffer plus native first-frame skip gating remove any remaining startup/cutscene stutter or premature-skip artifacts.
+4. Run a live Harvester validation pass in `SERGEANTRM` on Monday with `HAVE_COMPLETED_LODGE_APP` true and `QUEST_1` false: talk to Sergeant with the completed application and confirm ScummVM now plays the recovered `0x41cd` -> `0x4292` sequence, dispatches `GO_DAY_2`, advances the story to Tuesday, and shows the native day-change cutscene/FST.
+5. Re-open the remaining hidden Sergeant talk tails around `0x30ae2..0x30fcb` and mirror the still-missing native response-menu followups for the intro, `SCRATCHED_TUCKER`, and post-`QUEST_1` lodge-application branches only where the line ids / response lines / state flips are fully decoded.
+6. Run a live Harvester validation pass on the inventory overlay and confirm right-clicking `NOTE` now dispatches `GO_BOYLNOTECU` without the redundant post-closeup inventory bitmap reloads, and that the overlay only reappears when the player explicitly reopens it after returning from the closeup.
+7. Run a live Harvester validation pass on the inventory overlay and confirm the native weekday label now appears at `(480, 396)` in `TEXTFONT` when `HARVEST_BLADE` is not in inventory, updates if the day changes while the panel stays open, and disappears immediately once `HARVEST_BLADE` is acquired.
+8. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
+9. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
+10. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
+11. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
+12. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
+13. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
+14. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
+15. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
+16. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
+17. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
 
 ## Reimplementation Priority Order
 
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 3e5b7b81465..5812c706887 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -83,9 +83,12 @@ static const float kRoomPlayerHorizontalMoveBase = 8.0f;
 static const int kRoomRegionTargetXBias = 10;
 static const float kRoomDepthCompareEpsilon = 0.01f;
 static const int kRoomPlayerWalkAnimationRate = 17;
+static const int kRoomPlayerAttackAnimationRate = kRoomPlayerWalkAnimationRate;
 static const int kRoomPlayerMinOpaqueLeftX = 4;
 static const int kRoomPlayerMaxOpaqueRightX = 0x27c;
 static const int kRoomNpcAmbientLastFrame = 0x3b;
+static const float kRoomPlayerAttackUpperYOffset = 144.44f;
+static const float kRoomPlayerAttackMidYOffset = 75.36f;
 static const int kTownMapEdgeThreshold = 9;
 static const int kTownMapCursorHitExtent = 5;
 
@@ -137,6 +140,16 @@ struct PlayerTurnAnimationRange {
 	bool playBackwards = false;
 };
 
+struct PlayerAttackAnimationRange {
+	PlayerAttackAnimationRange() {}
+	PlayerAttackAnimationRange(int firstFrame, int lastFrame, int resumeFacing)
+		: firstFrame(firstFrame), lastFrame(lastFrame), resumeFacing(resumeFacing) {}
+
+	int firstFrame = 0;
+	int lastFrame = 0;
+	int resumeFacing = 0;
+};
+
 } // End of anonymous namespace
 
 static int roundToInt(float value) {
@@ -433,6 +446,46 @@ static bool resolvePlayerTurnAnimationRange(int fromFacing, int toFacing, Player
 	return false;
 }
 
+static Common::String resolvePlayerCombatLoadoutResourcePath(int loadout) {
+	if (loadout <= 0)
+		return Common::String(kPlayerActorResourcePath);
+
+	return Common::String::format("1:/GRAPHIC/MONSTERS/PC/PC%02d.ABM", loadout);
+}
+
+static float computeActorDepthScale(const StartupRoomSetupState &state, float z);
+
+static bool resolvePlayerAttackAnimationRange(const StartupRoomSetupState &state,
+		const StartupRoomPlayerState &playerState, const Common::Point &mousePos,
+		PlayerAttackAnimationRange &range) {
+	if (!playerState.entity)
+		return false;
+
+	const float depthScale = computeActorDepthScale(state, playerState.z);
+	const float upperThresholdY =
+		(float)playerState.bottomY - depthScale * kRoomPlayerAttackUpperYOffset;
+	const float midThresholdY =
+		(float)playerState.bottomY - depthScale * kRoomPlayerAttackMidYOffset;
+
+	if (mousePos.x < playerState.centerX) {
+		if ((float)mousePos.y < upperThresholdY)
+			range = PlayerAttackAnimationRange(0x50, 0x59, 1);
+		else if ((float)mousePos.y < midThresholdY)
+			range = PlayerAttackAnimationRange(0x5a, 0x63, 1);
+		else
+			range = PlayerAttackAnimationRange(0x64, 0x6d, 1);
+	} else {
+		if ((float)mousePos.y < upperThresholdY)
+			range = PlayerAttackAnimationRange(0x6e, 0x77, 2);
+		else if ((float)mousePos.y < midThresholdY)
+			range = PlayerAttackAnimationRange(0x78, 0x81, 2);
+		else
+			range = PlayerAttackAnimationRange(0x82, 0x8b, 2);
+	}
+
+	return true;
+}
+
 static bool roomSupportsMovementBand(const StartupRoomSetupState &state) {
 	return state.roomMaxZScreenY >= 0 &&
 		state.roomMinZScreenY >= state.roomMaxZScreenY;
@@ -1297,6 +1350,38 @@ bool setPlayerIdleAnimation(StartupRoomPlayerState &playerState, int facing) {
 	return changed;
 }
 
+bool syncPlayerCombatLoadoutVisual(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		StartupRoomPlayerState &playerState, int loadout) {
+	if (!playerState.entity)
+		return false;
+
+	ResourceManager *resources = engine.getResources();
+	if (!resources)
+		return false;
+
+	const int clampedLoadout = CLIP<int>(loadout, 0, 0x14);
+	if (playerState.combatLoadout == clampedLoadout)
+		return false;
+
+	const Common::String resourcePath = resolvePlayerCombatLoadoutResourcePath(clampedLoadout);
+	if (!playerState.entity->loadAbmResource(*resources, resourcePath))
+		return false;
+
+	playerState.combatLoadout = clampedLoadout;
+	playerState.attackActive = false;
+	playerState.attackFirstFrame = -1;
+	playerState.attackLastFrame = -1;
+	playerState.attackResumeFacing = -1;
+	(void)setPlayerIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
+	(void)applyRoomActorPlacement(state, *playerState.entity,
+		playerState.centerX, playerState.bottomY, playerState.z);
+	debugC(1, kDebugScene,
+		"Harvester: player combat loadout visual loadout=%d resource='%s' facing=%d pos=(%d,%d,z=%.2f)",
+		playerState.combatLoadout, resourcePath.c_str(), playerState.facing,
+		playerState.centerX, playerState.bottomY, (double)playerState.z);
+	return true;
+}
+
 static bool setPlayerWalkAnimation(StartupRoomPlayerState &playerState, int facing) {
 	if (!playerState.entity)
 		return false;
@@ -1318,6 +1403,52 @@ static bool setPlayerWalkAnimation(StartupRoomPlayerState &playerState, int faci
 	return true;
 }
 
+bool startPlayerAttackAnimation(const StartupRoomSetupState &state,
+		StartupRoomPlayerState &playerState, const Common::Point &mousePos) {
+	if (!playerState.entity || playerState.attackActive)
+		return false;
+
+	PlayerAttackAnimationRange range;
+	if (!resolvePlayerAttackAnimationRange(state, playerState, mousePos, range))
+		return false;
+
+	playerState.hasMoveTarget = false;
+	playerState.turnActive = false;
+	playerState.turnTargetFacing = -1;
+	playerState.turnFirstFrame = -1;
+	playerState.turnLastFrame = -1;
+	playerState.turnEndFrame = -1;
+	playerState.turnPlayBackwards = false;
+	playerState.attackActive = true;
+	playerState.attackFirstFrame = range.firstFrame;
+	playerState.attackLastFrame = range.lastFrame;
+	playerState.attackResumeFacing = range.resumeFacing;
+	playerState.nextMovementTick = 0;
+	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
+	playerState.entity->setAnimationRate(kRoomPlayerAttackAnimationRate);
+	playerState.entity->setCurrentFrame(range.firstFrame);
+	debugC(1, kDebugScene,
+		"Harvester: player attack animation frames=%d..%d resume_facing=%d cursor=(%d,%d)",
+		range.firstFrame, range.lastFrame, range.resumeFacing, mousePos.x, mousePos.y);
+	return true;
+}
+
+bool updatePlayerAttackAnimationState(StartupRoomPlayerState &playerState) {
+	if (!playerState.attackActive || !playerState.entity)
+		return false;
+	if (playerState.entity->getCurrentFrame() != playerState.attackLastFrame)
+		return false;
+
+	const int resumeFacing = playerState.attackResumeFacing >= 0
+		? playerState.attackResumeFacing
+		: (playerState.facing >= 0 ? playerState.facing : 0);
+	playerState.attackActive = false;
+	playerState.attackFirstFrame = -1;
+	playerState.attackLastFrame = -1;
+	playerState.attackResumeFacing = -1;
+	return setPlayerIdleAnimation(playerState, resumeFacing);
+}
+
 bool startPlayerTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing) {
 	if (!playerState.entity || playerState.facing < 0 || playerState.facing == targetFacing)
 		return false;
@@ -2448,15 +2579,19 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 	}
 	if (state.hasEntrance) {
 		const int playerFrame = resolvePlayerFacingFrame(state.playerFacing);
+		Script *startupScript = _engine.getStartupScript();
+		const int playerCombatLoadout = startupScript ? startupScript->getPlayerCombatLoadout() : 0;
+		const Common::String playerResourcePath =
+			resolvePlayerCombatLoadoutResourcePath(playerCombatLoadout);
 		const bool reusedPlayer = preservedPlayer != nullptr;
 		RuntimeEntity *player = preservedPlayer;
 		if (!reusedPlayer) {
 			player = runtimeEntities->spawnSceneActorEntity(kPlayerActorEntityName,
-				kPlayerActorResourcePath, Common::Point(state.playerSpawnX, state.playerSpawnY),
+				playerResourcePath, Common::Point(state.playerSpawnX, state.playerSpawnY),
 				(float)state.playerSpawnZ, playerFrame);
 		}
 		if (!player) {
-			debug(1, "Harvester: unable to spawn startup player actor from '%s'", kPlayerActorResourcePath);
+			debug(1, "Harvester: unable to spawn startup player actor from '%s'", playerResourcePath.c_str());
 		} else {
 			player->setClassId(kRuntimeEntityClassPlayer);
 			player->setAnchorMode(kRuntimeEntityAnchorTopLeft);
@@ -2469,11 +2604,21 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 				debug(1, "Harvester: unable to apply startup player placement for entrance '%s'",
 					state.entranceName.c_str());
 			}
+			if (reusedPlayer) {
+				StartupRoomPlayerState playerState;
+				playerState.entity = player;
+				playerState.centerX = state.playerSpawnX;
+				playerState.bottomY = state.playerSpawnY;
+				playerState.z = (float)state.playerSpawnZ;
+				playerState.facing = state.playerFacing;
+				playerState.combatLoadout = -1;
+				(void)syncPlayerCombatLoadoutVisual(_engine, state, playerState, playerCombatLoadout);
+			}
 			if (reusedPlayer)
 				runtimeEntities->adoptSceneEntity(player);
 			debugC(1, kDebugGeneral,
 				"Harvester: %s startup player actor '%s' entrance='%s' pos=(%d,%d,%d) facing=%d frame=%d",
-				reusedPlayer ? "reused" : "spawned", kPlayerActorResourcePath, state.entranceName.c_str(),
+				reusedPlayer ? "reused" : "spawned", playerResourcePath.c_str(), state.entranceName.c_str(),
 				state.playerSpawnX, state.playerSpawnY, state.playerSpawnZ,
 				state.playerFacing, playerFrame);
 		}
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index ee1656ea4de..eb746b5c9e9 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -416,6 +416,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		playerState.bottomY = state.playerSpawnY;
 		playerState.z = (float)state.playerSpawnZ;
 		playerState.facing = state.playerFacing;
+		playerState.combatLoadout =
+			_engine.getStartupScript() ? _engine.getStartupScript()->getPlayerCombatLoadout() : 0;
 		playerState.turnActive = false;
 		playerState.turnTargetFacing = -1;
 		StartupResolvedText inspectText;
@@ -468,6 +470,21 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			idleState.activityTick = getRuntimeClockTicks();
 			updatePlayerIdleTrigger(idleState);
 		};
+		auto cancelPlayerAttackAnimation = [&]() {
+			if (!playerState.attackActive)
+				return false;
+
+			playerState.attackActive = false;
+			playerState.attackFirstFrame = -1;
+			playerState.attackLastFrame = -1;
+			if (!playerState.entity)
+				return false;
+			const int resumeFacing = playerState.attackResumeFacing >= 0
+				? playerState.attackResumeFacing
+				: (playerState.facing >= 0 ? playerState.facing : 0);
+			playerState.attackResumeFacing = -1;
+			return setPlayerIdleAnimation(playerState, resumeFacing);
+		};
 		auto syncCurrentRoomRuntimeState = [&]() {
 			Script *startupScript = _engine.getStartupScript();
 			if (!startupScript)
@@ -510,87 +527,95 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			moveUp = false;
 			moveDown = false;
 			pendingRegionName.clear();
+			(void)cancelPlayerAttackAnimation();
 			playerState.hasMoveTarget = false;
 			playerState.turnActive = false;
 			playerState.turnTargetFacing = -1;
 			if (playerState.entity && playerState.facing >= 0)
 				(void)setPlayerIdleAnimation(playerState, playerState.facing);
 		};
-	auto refreshCurrentScene = [&](bool preservePlayerPlacement) {
-		const Common::Array<StartupAudioCommand> entryAudioCommands = scene.state.audioCommands;
-		StartupRoomSetupState refreshedState;
-		if (!_engine.getStartupScript()->materializeRoomState(
-				scene.state.entranceName, scene.state.roomName, refreshedState, *_engine.getResources())) {
-			return false;
-		}
-
-		refreshedState.audioCommands = entryAudioCommands;
-		if (!loadRoomSceneResources(refreshedState, *_engine.getResources(), scene))
-			return false;
-		if (!startupFlow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
-			return false;
-		if (runtimeEntities) {
-			for (const StartupMonsterRecord &monster : scene.state.roomMonsters) {
-				RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(monster.monsterName);
-				if (entity)
-					applyRoomMonsterAnimation(*entity, monster);
+		auto refreshCurrentScene = [&](bool preservePlayerPlacement) {
+			const Common::Array<StartupAudioCommand> entryAudioCommands = scene.state.audioCommands;
+			StartupRoomSetupState refreshedState;
+			if (!_engine.getStartupScript()->materializeRoomState(
+					scene.state.entranceName, scene.state.roomName, refreshedState, *_engine.getResources())) {
+				return false;
 			}
-		}
 
-		playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName("PLAYER") : nullptr;
-		if (playerState.entity) {
-			if (!preservePlayerPlacement || playerState.facing < 0) {
-				playerState.centerX = scene.state.playerSpawnX;
-				playerState.bottomY = scene.state.playerSpawnY;
-				playerState.z = (float)scene.state.playerSpawnZ;
-				playerState.facing = scene.state.playerFacing;
+			refreshedState.audioCommands = entryAudioCommands;
+			if (!loadRoomSceneResources(refreshedState, *_engine.getResources(), scene))
+				return false;
+			if (!startupFlow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
+				return false;
+			if (runtimeEntities) {
+				for (const StartupMonsterRecord &monster : scene.state.roomMonsters) {
+					RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(monster.monsterName);
+					if (entity)
+						applyRoomMonsterAnimation(*entity, monster);
+				}
 			}
 
-			const int facing = playerState.facing >= 0 ? playerState.facing : scene.state.playerFacing;
-			(void)setPlayerIdleAnimation(playerState, facing);
-			(void)applyRoomActorPlacement(scene.state, *playerState.entity,
-				playerState.centerX, playerState.bottomY, playerState.z);
-		}
+			playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName("PLAYER") : nullptr;
+			if (playerState.entity) {
+				if (!preservePlayerPlacement || playerState.facing < 0) {
+					playerState.centerX = scene.state.playerSpawnX;
+					playerState.bottomY = scene.state.playerSpawnY;
+					playerState.z = (float)scene.state.playerSpawnZ;
+					playerState.facing = scene.state.playerFacing;
+				}
 
-		playerState.hasMoveTarget = false;
-		playerState.turnActive = false;
-		playerState.turnTargetFacing = -1;
-		pendingRegionName.clear();
-		resetIdleState();
-		startupFlow.resetCursorAnimationSequence();
-		captureCurrentSaveState();
-		return _inventory.refresh();
-	};
-	auto stowCarriedRoomItemToInventory = [&]() {
-		if (!hasCarriedRoomItem())
-			return true;
+				const int facing = playerState.facing >= 0 ? playerState.facing : scene.state.playerFacing;
+				(void)setPlayerIdleAnimation(playerState, facing);
+				(void)applyRoomActorPlacement(scene.state, *playerState.entity,
+					playerState.centerX, playerState.bottomY, playerState.z);
+			}
 
-		_engine.getStartupScript()->addRuntimeObjectToInventory(carriedRoomItemName);
-		clearCarriedRoomItem();
-		return _inventory.refresh();
-	};
-	auto openInventoryOverlay = [&]() {
-		moveLeft = false;
-		moveRight = false;
-		moveUp = false;
-		moveDown = false;
-		pendingRegionName.clear();
-		playerState.hasMoveTarget = false;
-		playerState.turnActive = false;
-		playerState.turnTargetFacing = -1;
-		if (!stowCarriedRoomItemToInventory())
-			return false;
-		return _inventory.open();
-	};
-	auto captureDialogueBackdrop = [&](IndexedBitmap &dialogueBackdrop) {
-		Graphics::Screen *activeScreen = getActiveScreen();
-		if (!activeScreen)
-			return false;
+			playerState.hasMoveTarget = false;
+			playerState.turnActive = false;
+			playerState.turnTargetFacing = -1;
+			playerState.attackActive = false;
+			playerState.attackFirstFrame = -1;
+			playerState.attackLastFrame = -1;
+			playerState.attackResumeFacing = -1;
+			playerState.combatLoadout =
+				_engine.getStartupScript() ? _engine.getStartupScript()->getPlayerCombatLoadout() : 0;
+			pendingRegionName.clear();
+			resetIdleState();
+			startupFlow.resetCursorAnimationSequence();
+			captureCurrentSaveState();
+			return _inventory.refresh();
+		};
+		auto stowCarriedRoomItemToInventory = [&]() {
+			if (!hasCarriedRoomItem())
+				return true;
 
-		drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
-		return captureScreenBackdrop(*activeScreen, dialogueBackdrop);
-	};
-	auto runRoomExitCommands = [&]() -> Common::Error {
+			_engine.getStartupScript()->addRuntimeObjectToInventory(carriedRoomItemName);
+			clearCarriedRoomItem();
+			return _inventory.refresh();
+		};
+		auto openInventoryOverlay = [&]() {
+			moveLeft = false;
+			moveRight = false;
+			moveUp = false;
+			moveDown = false;
+			pendingRegionName.clear();
+			(void)cancelPlayerAttackAnimation();
+			playerState.hasMoveTarget = false;
+			playerState.turnActive = false;
+			playerState.turnTargetFacing = -1;
+			if (!stowCarriedRoomItemToInventory())
+				return false;
+			return _inventory.open();
+		};
+		auto captureDialogueBackdrop = [&](IndexedBitmap &dialogueBackdrop) {
+			Graphics::Screen *activeScreen = getActiveScreen();
+			if (!activeScreen)
+				return false;
+
+			drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
+			return captureScreenBackdrop(*activeScreen, dialogueBackdrop);
+		};
+		auto runRoomExitCommands = [&]() -> Common::Error {
 			StartupInteractionResult exitInteraction;
 			if (!_engine.getStartupScript()->executeRoomExitCommands(scene.state.roomName, exitInteraction))
 				return Common::kReadingFailed;
@@ -669,6 +694,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			return Common::kNoError;
 		};
 		auto applyPlayerGotoXZ = [&](int x, int z) {
+			(void)cancelPlayerAttackAnimation();
 			playerState.hasMoveTarget = false;
 			playerState.turnActive = false;
 			playerState.turnTargetFacing = -1;
@@ -1162,6 +1188,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			const bool activeCarry = inventorySelectionActive || roomCarryActive;
 			const bool inventoryPanelContainsMouse = _inventory.isOpen() && inventoryPanelBounds.contains(_mousePos);
 			const bool suppressHover = showingInspectText || idleState.active || idleState.exiting ||
+				playerState.attackActive ||
 				(_inventory.isOpen() && (inventoryPanelContainsMouse || !inventorySelectionActive));
 			StartupRoomHoverState hoverState = suppressHover
 				? StartupRoomHoverState()
@@ -1214,6 +1241,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			if (RuntimeEntity *cursor = runtimeEntities ? runtimeEntities->getCursorEntity() : nullptr) {
 				cursor->setAnimationSequence(
 					(showingInspectText || idleState.active || idleState.exiting ||
+						playerState.attackActive ||
 						_inventory.isOpen() || activeCarry)
 						? 7
 						: hoverState.cursorSequence);
@@ -1264,13 +1292,15 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				}
 				needsRedraw = true;
 				break;
-			case Common::EVENT_RBUTTONDOWN:
+			case Common::EVENT_RBUTTONDOWN: {
 				notePlayerActivity();
 				if (idleState.active || idleState.exiting) {
 					if (requestPlayerIdleAnimationExit(scene.state, playerState, idleState))
 						needsRedraw = true;
 					break;
 				}
+				if (playerState.attackActive)
+					break;
 				pendingRegionName.clear();
 				if (_inventory.isOpen()) {
 					const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
@@ -1293,6 +1323,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 								inventoryHover->object.objectName.c_str(), loadoutChanged);
 							if (loadoutChanged) {
 								captureCurrentSaveState();
+								if (playerState.entity) {
+									(void)syncPlayerCombatLoadoutVisual(_engine, scene.state, playerState,
+										_engine.getStartupScript()->getPlayerCombatLoadout());
+								}
 								if (!_inventory.refresh())
 									return Common::kReadingFailed;
 							}
@@ -1361,8 +1395,25 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					if (!stowCarriedRoomItemToInventory())
 						return Common::kReadingFailed;
 					needsRedraw = true;
+					break;
+				}
+				Script *startupScript = _engine.getStartupScript();
+				if (!showingInspectText && startupScript && playerState.entity &&
+						startupScript->getPlayerCurrentHitPoints() > 0) {
+					playerState.hasMoveTarget = false;
+					playerState.turnActive = false;
+					playerState.turnTargetFacing = -1;
+					playerState.turnFirstFrame = -1;
+					playerState.turnLastFrame = -1;
+					playerState.turnEndFrame = -1;
+					playerState.turnPlayBackwards = false;
+					(void)syncPlayerCombatLoadoutVisual(_engine, scene.state, playerState,
+						startupScript->getPlayerCombatLoadout());
+					if (startPlayerAttackAnimation(scene.state, playerState, _mousePos))
+						needsRedraw = true;
 				}
 				break;
+			}
 			case Common::EVENT_LBUTTONUP:
 				lastLeftButtonReleaseTick = getRuntimeClockTicks();
 				if (showingInspectText)
@@ -1389,6 +1440,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					}
 					break;
 				}
+				if (playerState.attackActive)
+					break;
 
 				const Common::Rect inventoryPanelBounds = _inventory.getPanelBounds();
 				if (_inventory.isOpen()) {
@@ -1676,6 +1729,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					needsRedraw = true;
 					break;
 				} else if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					if (cancelPlayerAttackAnimation())
+						needsRedraw = true;
 					if (!stowCarriedRoomItemToInventory())
 						return Common::kReadingFailed;
 					moveLeft = false;
@@ -1735,12 +1790,15 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		}
 
 		bool playerAdvancedThisFrame = false;
-		if (updatePlayerTurnAnimationState(playerState)) {
+		if (updatePlayerAttackAnimationState(playerState)) {
+			needsRedraw = true;
+		}
+		if (!playerState.attackActive && updatePlayerTurnAnimationState(playerState)) {
 			playerAdvancedThisFrame = true;
 			needsRedraw = true;
 		}
 
-		if (!idleState.active && !idleState.exiting) {
+		if (!playerState.attackActive && !idleState.active && !idleState.exiting) {
 			if (stepPlayerKeyboardMovement(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
 					playerState, moveLeft, moveRight, moveUp, moveDown)) {
 				playerAdvancedThisFrame = true;
@@ -1767,7 +1825,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				needsRedraw = true;
 			}
 		}
-		Common::Error pendingRegionError = tryActivatePendingRegion();
+		Common::Error pendingRegionError =
+			playerState.attackActive ? Common::kNoError : tryActivatePendingRegion();
 		if (pendingRegionError.getCode() != Common::kNoError)
 			return pendingRegionError;
 		if (startupFlow.hasPendingMainMenuReturn())
@@ -1777,7 +1836,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				return Common::kReadingFailed;
 			break;
 		}
-		pendingRegionError = playerAdvancedThisFrame ? tryActivateOverlappedRegion() : Common::kNoError;
+		pendingRegionError =
+			(playerAdvancedThisFrame && !playerState.attackActive)
+				? tryActivateOverlappedRegion()
+				: Common::kNoError;
 		if (pendingRegionError.getCode() != Common::kNoError)
 			return pendingRegionError;
 		if (startupFlow.hasPendingMainMenuReturn())
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index 6027119320b..b81d82fea60 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -56,6 +56,7 @@ struct StartupRoomPlayerState {
 	int bottomY = 0;
 	float z = 0.0f;
 	int facing = -1;
+	int combatLoadout = 0;
 	uint32 nextMovementTick = 0;
 	bool hasMoveTarget = false;
 	int targetX = 0;
@@ -66,6 +67,10 @@ struct StartupRoomPlayerState {
 	int turnLastFrame = -1;
 	int turnEndFrame = -1;
 	bool turnPlayBackwards = false;
+	bool attackActive = false;
+	int attackFirstFrame = -1;
+	int attackLastFrame = -1;
+	int attackResumeFacing = -1;
 };
 
 struct StartupRoomHoverState {
@@ -123,7 +128,12 @@ StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const Start
 	const Common::Array<StartupNpcRecord> &npcs,
 	const Common::Array<StartupRegionRecord> &regions,
 	const Common::Point &mousePos);
+bool syncPlayerCombatLoadoutVisual(HarvesterEngine &engine, const StartupRoomSetupState &state,
+	StartupRoomPlayerState &playerState, int loadout);
 bool setPlayerIdleAnimation(StartupRoomPlayerState &playerState, int facing);
+bool startPlayerAttackAnimation(const StartupRoomSetupState &state,
+	StartupRoomPlayerState &playerState, const Common::Point &mousePos);
+bool updatePlayerAttackAnimationState(StartupRoomPlayerState &playerState);
 bool startPlayerTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing);
 bool updatePlayerTurnAnimationState(StartupRoomPlayerState &playerState);
 bool stepPlayerMoveTarget(HarvesterEngine &engine, const StartupRoomSetupState &state,
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index 96a01b3f1d0..fb436ef760e 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -797,10 +797,10 @@ RuntimeEntity *RuntimeEntityManager::spawnSceneAnimationEntity(const Common::Str
 	if (!entity)
 		return nullptr;
 
-	// Native room ANIM entities keep their record x/y as the fixed screen anchor.
-	// Per-frame ABM x/y offsets handle any origin adjustment, so recentering on the
-	// current frame size makes narrowing animations drift horizontally.
-	entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
+	// Native room ANIM entities are positioned around their record x/y pivot.
+	// The runtime centers the current decoded bitmap on that pivot, then applies
+	// the ABM frame header x/y offsets as a separate step.
+	entity->setAnchorMode(kRuntimeEntityAnchorCentered);
 	entity->setZExtent(3.0f);
 	entity->setVisible(visible);
 	entity->setPlayBackwards(playBackwards);


Commit: 902dd980e4e6a951b24d7538600f91428029b8c9
    https://github.com/scummvm/scummvm/commit/902dd980e4e6a951b24d7538600f91428029b8c9
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:41+02:00

Commit Message:
HARVESTER: Recover whaley bat reward

Changed paths:
    TRACKER.md
    engines/harvester/npc/whaley_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 88319319945..7f9f722ef6a 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, re-opened the Whaley/Herrill broom-closet reward continuation in raw `HARVEST.LE` code around `0x23fb9..0x240fb` after a live `SCHLHL` repro showed ScummVM never granting the promised baseball bat after response line `0x24c` option `2`.
+- Confirmed both native "accept the deal" exits call `add_object_to_inventory("BAT")` (`0x24013` and `0x240ad`) and then hide `PHOTO_OF_WHALEY_HERRILL` from `INVENTORY` through `set_object_visibility_for_owner_or_room("INVENTORY", "PHOTO_OF_WHALEY_HERRILL", 0)` before clearing `BUSTED_SCREWING_MIDGAME` and optionally dispatching `REVERSE_BROOM`. Patched `engines/harvester/npc/whaley_dialogue.cpp` so ScummVM now mirrors that bat-for-photo trade instead of erroneously re-adding the photo.
 - On March 20, 2026, re-opened `run_harvester_main_loop @ 0x6dc70`, `update_actor_runtime_state @ 0x4d750`, and `set_player_combat_loadout @ 0x55c10` in the live `HARVEST.LE` Ghidra session to recover the native room right-click attack path instead of guessing from the existing ScummVM inventory weapon toggle.
 - Confirmed the native room attack selector uses the current `pc*.abm` loadout art, compares cursor X against the player midpoint, then compares cursor Y against `player_bottom - depth_scale * 144.44` and `player_bottom - depth_scale * 75.36` to choose six directional banks: left `0x18/0x17/0x16` or right `0x1b/0x1a/0x19`, which map to frames `0x50..0x59`, `0x5a..0x63`, `0x64..0x6d`, `0x6e..0x77`, `0x78..0x81`, and `0x82..0x8b`.
 - Patched `engines/harvester/flow.cpp`, `engines/harvester/room.cpp`, and `engines/harvester/room_support.h` so room right-click now starts that recovered directional attack, inventory weapon toggles reload the matching `PC0/PC01..PC20` actor art in-room, and the room loop suppresses movement/hover/region dispatch until the attack bank completes. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
@@ -134,23 +136,24 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester validation pass on room right-click attacks with both bare hands and an equipped weapon: confirm the player swaps to the correct `PC0/PC01..PC20` art after inventory weapon toggles, and that right-clicking left/right plus high/mid/low around the player selects the recovered six attack banks instead of opening inventory or continuing movement.
-2. Run a live Harvester validation pass in `SCHLHL`: use `BROOMKEY` on `SCHL_DOOR_HS`, let `GRAPHIC/FST/C132.FST` finish, and confirm the `SCHDOOR` room anim now respawns from its native saved frame/open state instead of restarting from the closed-frame default.
-3. Run a live Harvester validation pass on FST playback itself: compare `VIRGLOGO.FST`, `FVLOGO.FST`, `INTROFIN.FST`, `C044.FST`, and `C048.FST` against the original executable and confirm the reused payload buffer plus native first-frame skip gating remove any remaining startup/cutscene stutter or premature-skip artifacts.
-4. Run a live Harvester validation pass in `SERGEANTRM` on Monday with `HAVE_COMPLETED_LODGE_APP` true and `QUEST_1` false: talk to Sergeant with the completed application and confirm ScummVM now plays the recovered `0x41cd` -> `0x4292` sequence, dispatches `GO_DAY_2`, advances the story to Tuesday, and shows the native day-change cutscene/FST.
-5. Re-open the remaining hidden Sergeant talk tails around `0x30ae2..0x30fcb` and mirror the still-missing native response-menu followups for the intro, `SCRATCHED_TUCKER`, and post-`QUEST_1` lodge-application branches only where the line ids / response lines / state flips are fully decoded.
-6. Run a live Harvester validation pass on the inventory overlay and confirm right-clicking `NOTE` now dispatches `GO_BOYLNOTECU` without the redundant post-closeup inventory bitmap reloads, and that the overlay only reappears when the player explicitly reopens it after returning from the closeup.
-7. Run a live Harvester validation pass on the inventory overlay and confirm the native weekday label now appears at `(480, 396)` in `TEXTFONT` when `HARVEST_BLADE` is not in inventory, updates if the day changes while the panel stays open, and disappears immediately once `HARVEST_BLADE` is acquired.
-8. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
-9. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
-10. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
-11. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
-12. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
-13. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
-14. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
-15. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
-16. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
-17. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
+1. Run a live Harvester validation pass in `SCHLHL` covering both Whaley/Herrill acceptance paths: after taking the broom-closet photo, choose response line `0x24c` option `2` and the nested `0x24d` option `1`, and confirm both paths now add `BAT`, hide `PHOTO_OF_WHALEY_HERRILL` from the inventory strip, clear `BUSTED_SCREWING_MIDGAME`, and still unwind the broom-door state through `REVERSE_BROOM` when that flag is set.
+2. Run a live Harvester validation pass on room right-click attacks with both bare hands and an equipped weapon: confirm the player swaps to the correct `PC0/PC01..PC20` art after inventory weapon toggles, and that right-clicking left/right plus high/mid/low around the player selects the recovered six attack banks instead of opening inventory or continuing movement.
+3. Run a live Harvester validation pass in `SCHLHL`: use `BROOMKEY` on `SCHL_DOOR_HS`, let `GRAPHIC/FST/C132.FST` finish, and confirm the `SCHDOOR` room anim now respawns from its native saved frame/open state instead of restarting from the closed-frame default.
+4. Run a live Harvester validation pass on FST playback itself: compare `VIRGLOGO.FST`, `FVLOGO.FST`, `INTROFIN.FST`, `C044.FST`, and `C048.FST` against the original executable and confirm the reused payload buffer plus native first-frame skip gating remove any remaining startup/cutscene stutter or premature-skip artifacts.
+5. Run a live Harvester validation pass in `SERGEANTRM` on Monday with `HAVE_COMPLETED_LODGE_APP` true and `QUEST_1` false: talk to Sergeant with the completed application and confirm ScummVM now plays the recovered `0x41cd` -> `0x4292` sequence, dispatches `GO_DAY_2`, advances the story to Tuesday, and shows the native day-change cutscene/FST.
+6. Re-open the remaining hidden Sergeant talk tails around `0x30ae2..0x30fcb` and mirror the still-missing native response-menu followups for the intro, `SCRATCHED_TUCKER`, and post-`QUEST_1` lodge-application branches only where the line ids / response lines / state flips are fully decoded.
+7. Run a live Harvester validation pass on the inventory overlay and confirm right-clicking `NOTE` now dispatches `GO_BOYLNOTECU` without the redundant post-closeup inventory bitmap reloads, and that the overlay only reappears when the player explicitly reopens it after returning from the closeup.
+8. Run a live Harvester validation pass on the inventory overlay and confirm the native weekday label now appears at `(480, 396)` in `TEXTFONT` when `HARVEST_BLADE` is not in inventory, updates if the day changes while the panel stays open, and disappears immediately once `HARVEST_BLADE` is acquired.
+9. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
+10. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
+11. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
+12. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
+13. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
+14. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
+15. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
+16. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
+17. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
+18. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
 
 ## Reimplementation Priority Order
 
diff --git a/engines/harvester/npc/whaley_dialogue.cpp b/engines/harvester/npc/whaley_dialogue.cpp
index 77be2bb1d6f..c9e2783a5d6 100644
--- a/engines/harvester/npc/whaley_dialogue.cpp
+++ b/engines/harvester/npc/whaley_dialogue.cpp
@@ -34,6 +34,8 @@ static const char *const kDialogueC052FstPath = "GRAPHIC/FST/C052.FST";
 static const char *const kDialogueC053FstPath = "GRAPHIC/FST/C053.FST";
 static const char *const kDialogueC055FstPath = "GRAPHIC/FST/C055.FST";
 static const char *const kDialogueC057FstPath = "GRAPHIC/FST/C057.FST";
+static const char *const kBaseballBatObjectName = "BAT";
+static const char *const kInventoryOwnerName = "INVENTORY";
 static const char *const kReverseBroomActionTag = "REVERSE_BROOM";
 static const char *const kSoundAirRaidActionTag = "SOUND_AIR_RAID";
 static const char *const kWhaleyPhotoObjectName = "PHOTO_OF_WHALEY_HERRILL";
@@ -183,6 +185,11 @@ Common::Error WhaleyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 		return false;
 	};
+	auto tradePhotoForBat = [&]() {
+		(void)runtime.startupScript().addRuntimeObjectToInventory(kBaseballBatObjectName);
+		(void)runtime.startupScript().setRuntimeObjectVisible(
+			kInventoryOwnerName, kWhaleyPhotoObjectName, false);
+	};
 
 	if (runtime.startupScript().getFlagValue("BUSTED_SCREWING_MIDGAME")) {
 		Common::Error lineError = playWhaleyLine(0x143b, 2);
@@ -238,7 +245,7 @@ Common::Error WhaleyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 						lineError = playWhaleyLine(0x1482, 0);
 						if (lineError.getCode() != Common::kNoError)
 							return lineError;
-						(void)runtime.startupScript().addRuntimeObjectToInventory(kWhaleyPhotoObjectName);
+						tradePhotoForBat();
 					} else if (nestedResponseIndex == 2) {
 						lineError = playWhaleyLine(0x1487, 2);
 						if (lineError.getCode() != Common::kNoError)
@@ -252,7 +259,7 @@ Common::Error WhaleyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 						ARRAYSIZE(kWhaleyBustedScrewingResponse2Menu2Lines));
 					if (lineError.getCode() != Common::kNoError)
 						return lineError;
-					(void)runtime.startupScript().addRuntimeObjectToInventory(kWhaleyPhotoObjectName);
+					tradePhotoForBat();
 				}
 			}
 		}


Commit: 8c8a1266742f8f31a17ae952d34dee21be787498
    https://github.com/scummvm/scummvm/commit/8c8a1266742f8f31a17ae952d34dee21be787498
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:41+02:00

Commit Message:
HARVESTER: Restore room attack animation sounds

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/flow.cpp
    engines/harvester/room.cpp
    engines/harvester/room_support.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 4d3893e5760..7aafe5e05c7 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -865,6 +865,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `current_hit_points` is the save/load-persisted monster HP field; parser initialization copies `initial_hit_points` into it.
   - `min_x_bound` defaults to `0x14` and `max_x_bound` defaults to `0x26b` when omitted by the parser.
   - The sound-slot mapping is confirmed both by `spawn_monster_entity_from_record` and by the player combat avatar builder, which installs the same runtime sound layout explicitly.
+  - `spawn_player_combat_avatar` seeds loadout-specific player attack samples into those same `+0x1148..+0x1150` slots, and `update_actor_runtime_state` triggers the player attack sound when `current_frame_index == first_frame_index + 1`; melee loadouts randomly choose one loaded attack sample, while ranged loadouts route that frame through the weapon fire/empty helper instead of the random selector.
   - `field_38`, `field_3c`, `field_44`, and `field_48` are heap strings freed during world teardown, but no confirmed live consumers have been recovered yet.
   - `field_74` and `field_7c` are copied into live runtime slots `+0x1170` and `+0x1178`, but the current binary only writes those slots; no read-side consumers were recovered.
   - Remaining unknowns include `field_1c`, `field_38`, `field_3c`, `field_44`, `field_48`, `field_74`, `field_7c`
diff --git a/TRACKER.md b/TRACKER.md
index 7f9f722ef6a..a7a1f8a0f74 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, re-opened `spawn_player_combat_avatar @ 0x54220`, `update_actor_runtime_state @ 0x4d750`, and `consume_weapon_ammo_and_play_fire_or_empty_sound @ 0x57910` in the live `HARVEST.LE` Ghidra session to recover where the original room right-click attacks actually trigger audio instead of guessing from the parsed monster sound fields.
+- Confirmed the native player combat avatar hardcodes its own loadout-specific attack samples: melee loadouts seed `+0x1148..+0x1150` with swoosh variants and fire them when `current_frame_index == first_frame_index + 1`, while ranged loadouts route that same timing window through the weapon fire/empty helper instead of the random attack-sound selector. Patched `engines/harvester/flow.cpp`, `engines/harvester/room.cpp`, and `engines/harvester/room_support.h` so ScummVM now mirrors that player-side timing by firing recovered loadout-specific attack sounds on the second frame of each room attack bank. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 - On March 20, 2026, re-opened the Whaley/Herrill broom-closet reward continuation in raw `HARVEST.LE` code around `0x23fb9..0x240fb` after a live `SCHLHL` repro showed ScummVM never granting the promised baseball bat after response line `0x24c` option `2`.
 - Confirmed both native "accept the deal" exits call `add_object_to_inventory("BAT")` (`0x24013` and `0x240ad`) and then hide `PHOTO_OF_WHALEY_HERRILL` from `INVENTORY` through `set_object_visibility_for_owner_or_room("INVENTORY", "PHOTO_OF_WHALEY_HERRILL", 0)` before clearing `BUSTED_SCREWING_MIDGAME` and optionally dispatching `REVERSE_BROOM`. Patched `engines/harvester/npc/whaley_dialogue.cpp` so ScummVM now mirrors that bat-for-photo trade instead of erroneously re-adding the photo.
 - On March 20, 2026, re-opened `run_harvester_main_loop @ 0x6dc70`, `update_actor_runtime_state @ 0x4d750`, and `set_player_combat_loadout @ 0x55c10` in the live `HARVEST.LE` Ghidra session to recover the native room right-click attack path instead of guessing from the existing ScummVM inventory weapon toggle.
@@ -136,24 +138,25 @@
 
 ## Next Suggested Action
 
-1. Run a live Harvester validation pass in `SCHLHL` covering both Whaley/Herrill acceptance paths: after taking the broom-closet photo, choose response line `0x24c` option `2` and the nested `0x24d` option `1`, and confirm both paths now add `BAT`, hide `PHOTO_OF_WHALEY_HERRILL` from the inventory strip, clear `BUSTED_SCREWING_MIDGAME`, and still unwind the broom-door state through `REVERSE_BROOM` when that flag is set.
-2. Run a live Harvester validation pass on room right-click attacks with both bare hands and an equipped weapon: confirm the player swaps to the correct `PC0/PC01..PC20` art after inventory weapon toggles, and that right-clicking left/right plus high/mid/low around the player selects the recovered six attack banks instead of opening inventory or continuing movement.
-3. Run a live Harvester validation pass in `SCHLHL`: use `BROOMKEY` on `SCHL_DOOR_HS`, let `GRAPHIC/FST/C132.FST` finish, and confirm the `SCHDOOR` room anim now respawns from its native saved frame/open state instead of restarting from the closed-frame default.
-4. Run a live Harvester validation pass on FST playback itself: compare `VIRGLOGO.FST`, `FVLOGO.FST`, `INTROFIN.FST`, `C044.FST`, and `C048.FST` against the original executable and confirm the reused payload buffer plus native first-frame skip gating remove any remaining startup/cutscene stutter or premature-skip artifacts.
-5. Run a live Harvester validation pass in `SERGEANTRM` on Monday with `HAVE_COMPLETED_LODGE_APP` true and `QUEST_1` false: talk to Sergeant with the completed application and confirm ScummVM now plays the recovered `0x41cd` -> `0x4292` sequence, dispatches `GO_DAY_2`, advances the story to Tuesday, and shows the native day-change cutscene/FST.
-6. Re-open the remaining hidden Sergeant talk tails around `0x30ae2..0x30fcb` and mirror the still-missing native response-menu followups for the intro, `SCRATCHED_TUCKER`, and post-`QUEST_1` lodge-application branches only where the line ids / response lines / state flips are fully decoded.
-7. Run a live Harvester validation pass on the inventory overlay and confirm right-clicking `NOTE` now dispatches `GO_BOYLNOTECU` without the redundant post-closeup inventory bitmap reloads, and that the overlay only reappears when the player explicitly reopens it after returning from the closeup.
-8. Run a live Harvester validation pass on the inventory overlay and confirm the native weekday label now appears at `(480, 396)` in `TEXTFONT` when `HARVEST_BLADE` is not in inventory, updates if the day changes while the panel stays open, and disappears immediately once `HARVEST_BLADE` is acquired.
-9. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
-10. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
-11. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
-12. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
-13. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
-14. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
-15. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
-16. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
-17. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
-18. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
+1. Run a live Harvester validation pass on room right-click attacks with both bare hands and equipped weapons: confirm the player still swaps to the correct `PC0/PC01..PC20` art after inventory weapon toggles, and that attacks now play the recovered second-frame sound timing for unarmed swings, melee weapons, firearms, and the chainsaw.
+2. If any player loadout still sounds wrong in that pass, re-open `spawn_player_combat_avatar @ 0x54220` and the frame-commit logic around `0x52762..0x52993` to mirror any still-missing special cases such as the firearm secondary timing branch or the chainsaw empty/fuel path exactly.
+3. Run a live Harvester validation pass in `SCHLHL` covering both Whaley/Herrill acceptance paths: after taking the broom-closet photo, choose response line `0x24c` option `2` and the nested `0x24d` option `1`, and confirm both paths now add `BAT`, hide `PHOTO_OF_WHALEY_HERRILL` from the inventory strip, clear `BUSTED_SCREWING_MIDGAME`, and still unwind the broom-door state through `REVERSE_BROOM` when that flag is set.
+4. Run a live Harvester validation pass in `SCHLHL`: use `BROOMKEY` on `SCHL_DOOR_HS`, let `GRAPHIC/FST/C132.FST` finish, and confirm the `SCHDOOR` room anim now respawns from its native saved frame/open state instead of restarting from the closed-frame default.
+5. Run a live Harvester validation pass on FST playback itself: compare `VIRGLOGO.FST`, `FVLOGO.FST`, `INTROFIN.FST`, `C044.FST`, and `C048.FST` against the original executable and confirm the reused payload buffer plus native first-frame skip gating remove any remaining startup/cutscene stutter or premature-skip artifacts.
+6. Run a live Harvester validation pass in `SERGEANTRM` on Monday with `HAVE_COMPLETED_LODGE_APP` true and `QUEST_1` false: talk to Sergeant with the completed application and confirm ScummVM now plays the recovered `0x41cd` -> `0x4292` sequence, dispatches `GO_DAY_2`, advances the story to Tuesday, and shows the native day-change cutscene/FST.
+7. Re-open the remaining hidden Sergeant talk tails around `0x30ae2..0x30fcb` and mirror the still-missing native response-menu followups for the intro, `SCRATCHED_TUCKER`, and post-`QUEST_1` lodge-application branches only where the line ids / response lines / state flips are fully decoded.
+8. Run a live Harvester validation pass on the inventory overlay and confirm right-clicking `NOTE` now dispatches `GO_BOYLNOTECU` without the redundant post-closeup inventory bitmap reloads, and that the overlay only reappears when the player explicitly reopens it after returning from the closeup.
+9. Run a live Harvester validation pass on the inventory overlay and confirm the native weekday label now appears at `(480, 396)` in `TEXTFONT` when `HARVEST_BLADE` is not in inventory, updates if the day changes while the panel stays open, and disappears immediately once `HARVEST_BLADE` is acquired.
+10. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
+11. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
+12. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
+13. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
+14. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
+15. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
+16. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
+17. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
+18. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
+19. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
 
 ## Reimplementation Priority Order
 
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 5812c706887..729d59570bc 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -150,6 +150,38 @@ struct PlayerAttackAnimationRange {
 	int resumeFacing = 0;
 };
 
+struct PlayerAttackSoundSet {
+	uint soundCount;
+	const char *soundPaths[3];
+};
+
+// Recovered from spawn_player_combat_avatar and update_actor_runtime_state:
+// the native room-combat path commits player attack audio on first_frame + 1.
+static const int kPlayerAttackSoundTriggerFrameOffset = 1;
+static const PlayerAttackSoundSet kPlayerAttackSoundSets[] = {
+	{ 3, { "2:/sound/effects/swoosh1.wav", "2:/sound/effects/swoosh2.wav", "2:/sound/effects/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 1, { "2:/SOUND/EFFECTS/NAILGUN1.wav", nullptr, nullptr } },
+	{ 1, { "1:/sound/effects/shotgun2.wav", nullptr, nullptr } },
+	{ 1, { "2:/SOUND/EFFECTS/gunshot.wav", nullptr, nullptr } },
+	{ 1, { "2:/SOUND/EFFECTS/gunshot.wav", nullptr, nullptr } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/sound/effects/swoosh1.wav", "2:/sound/effects/swoosh2.wav", "2:/sound/effects/swoosh3.wav" } },
+	{ 1, { "1:/sound/effects/CLN_atk0.wav", nullptr, nullptr } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh3.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } }
+};
+
 } // End of anonymous namespace
 
 static int roundToInt(float value) {
@@ -453,6 +485,29 @@ static Common::String resolvePlayerCombatLoadoutResourcePath(int loadout) {
 	return Common::String::format("1:/GRAPHIC/MONSTERS/PC/PC%02d.ABM", loadout);
 }
 
+static const PlayerAttackSoundSet *resolvePlayerAttackSoundSet(int loadout) {
+	if (loadout < 0 || loadout >= (int)ARRAYSIZE(kPlayerAttackSoundSets))
+		return nullptr;
+
+	if (kPlayerAttackSoundSets[loadout].soundCount == 0)
+		return nullptr;
+
+	return &kPlayerAttackSoundSets[loadout];
+}
+
+static bool playPlayerAttackSound(HarvesterEngine &engine, int loadout) {
+	const PlayerAttackSoundSet *soundSet = resolvePlayerAttackSoundSet(loadout);
+	if (!soundSet)
+		return false;
+
+	uint soundIndex = 0;
+	if (soundSet->soundCount > 1)
+		soundIndex = engine.getRandomNumber(soundSet->soundCount - 1);
+
+	const char *soundPath = soundSet->soundPaths[soundIndex];
+	return soundPath && engine.playStartupSound(soundPath);
+}
+
 static float computeActorDepthScale(const StartupRoomSetupState &state, float z);
 
 static bool resolvePlayerAttackAnimationRange(const StartupRoomSetupState &state,
@@ -1372,6 +1427,8 @@ bool syncPlayerCombatLoadoutVisual(HarvesterEngine &engine, const StartupRoomSet
 	playerState.attackFirstFrame = -1;
 	playerState.attackLastFrame = -1;
 	playerState.attackResumeFacing = -1;
+	playerState.attackSoundPlayed = false;
+	playerState.attackSoundFrame = -1;
 	(void)setPlayerIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
 	(void)applyRoomActorPlacement(state, *playerState.entity,
 		playerState.centerX, playerState.bottomY, playerState.z);
@@ -1423,6 +1480,10 @@ bool startPlayerAttackAnimation(const StartupRoomSetupState &state,
 	playerState.attackFirstFrame = range.firstFrame;
 	playerState.attackLastFrame = range.lastFrame;
 	playerState.attackResumeFacing = range.resumeFacing;
+	playerState.attackSoundPlayed = resolvePlayerAttackSoundSet(playerState.combatLoadout) == nullptr;
+	playerState.attackSoundFrame = playerState.attackSoundPlayed
+		? -1
+		: (range.firstFrame + kPlayerAttackSoundTriggerFrameOffset);
 	playerState.nextMovementTick = 0;
 	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
 	playerState.entity->setAnimationRate(kRoomPlayerAttackAnimationRate);
@@ -1433,9 +1494,15 @@ bool startPlayerAttackAnimation(const StartupRoomSetupState &state,
 	return true;
 }
 
-bool updatePlayerAttackAnimationState(StartupRoomPlayerState &playerState) {
+bool updatePlayerAttackAnimationState(HarvesterEngine &engine, StartupRoomPlayerState &playerState) {
 	if (!playerState.attackActive || !playerState.entity)
 		return false;
+	if (!playerState.attackSoundPlayed &&
+			playerState.attackSoundFrame >= 0 &&
+			playerState.entity->getCurrentFrame() >= playerState.attackSoundFrame) {
+		(void)playPlayerAttackSound(engine, playerState.combatLoadout);
+		playerState.attackSoundPlayed = true;
+	}
 	if (playerState.entity->getCurrentFrame() != playerState.attackLastFrame)
 		return false;
 
@@ -1446,6 +1513,8 @@ bool updatePlayerAttackAnimationState(StartupRoomPlayerState &playerState) {
 	playerState.attackFirstFrame = -1;
 	playerState.attackLastFrame = -1;
 	playerState.attackResumeFacing = -1;
+	playerState.attackSoundPlayed = false;
+	playerState.attackSoundFrame = -1;
 	return setPlayerIdleAnimation(playerState, resumeFacing);
 }
 
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index eb746b5c9e9..909e7f4fbb6 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -479,6 +479,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			playerState.attackLastFrame = -1;
 			if (!playerState.entity)
 				return false;
+			playerState.attackSoundPlayed = false;
+			playerState.attackSoundFrame = -1;
 			const int resumeFacing = playerState.attackResumeFacing >= 0
 				? playerState.attackResumeFacing
 				: (playerState.facing >= 0 ? playerState.facing : 0);
@@ -577,6 +579,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			playerState.attackFirstFrame = -1;
 			playerState.attackLastFrame = -1;
 			playerState.attackResumeFacing = -1;
+			playerState.attackSoundPlayed = false;
+			playerState.attackSoundFrame = -1;
 			playerState.combatLoadout =
 				_engine.getStartupScript() ? _engine.getStartupScript()->getPlayerCombatLoadout() : 0;
 			pendingRegionName.clear();
@@ -1317,7 +1321,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 							!InventorySystem::isExitObject(inventoryHover->object) &&
 							!InventorySystem::isStatusObject(inventoryHover->object)) {
 						bool loadoutChanged = false;
-						if (_inventory.toggleCombatLoadout(inventoryHover->object, loadoutChanged)) {
+						if (_inventory.toggleCombatLoadout(inventoryHover->object,
+								playerState.combatLoadout, loadoutChanged)) {
 							debugC(1, kDebugInventory,
 								"Harvester: inventory right click handled as combat toggle object='%s' changed=%d",
 								inventoryHover->object.objectName.c_str(), loadoutChanged);
@@ -1326,6 +1331,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 								if (playerState.entity) {
 									(void)syncPlayerCombatLoadoutVisual(_engine, scene.state, playerState,
 										_engine.getStartupScript()->getPlayerCombatLoadout());
+								} else if (_engine.getStartupScript()) {
+									playerState.combatLoadout =
+										_engine.getStartupScript()->getPlayerCombatLoadout();
 								}
 								if (!_inventory.refresh())
 									return Common::kReadingFailed;
@@ -1790,7 +1798,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		}
 
 		bool playerAdvancedThisFrame = false;
-		if (updatePlayerAttackAnimationState(playerState)) {
+		if (updatePlayerAttackAnimationState(_engine, playerState)) {
 			needsRedraw = true;
 		}
 		if (!playerState.attackActive && updatePlayerTurnAnimationState(playerState)) {
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index b81d82fea60..206c0719db4 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -71,6 +71,8 @@ struct StartupRoomPlayerState {
 	int attackFirstFrame = -1;
 	int attackLastFrame = -1;
 	int attackResumeFacing = -1;
+	bool attackSoundPlayed = false;
+	int attackSoundFrame = -1;
 };
 
 struct StartupRoomHoverState {
@@ -133,7 +135,7 @@ bool syncPlayerCombatLoadoutVisual(HarvesterEngine &engine, const StartupRoomSet
 bool setPlayerIdleAnimation(StartupRoomPlayerState &playerState, int facing);
 bool startPlayerAttackAnimation(const StartupRoomSetupState &state,
 	StartupRoomPlayerState &playerState, const Common::Point &mousePos);
-bool updatePlayerAttackAnimationState(StartupRoomPlayerState &playerState);
+bool updatePlayerAttackAnimationState(HarvesterEngine &engine, StartupRoomPlayerState &playerState);
 bool startPlayerTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing);
 bool updatePlayerTurnAnimationState(StartupRoomPlayerState &playerState);
 bool stepPlayerMoveTarget(HarvesterEngine &engine, const StartupRoomSetupState &state,


Commit: 88da00e59249928ae74f9953a59222c2accda0a0
    https://github.com/scummvm/scummvm/commit/88da00e59249928ae74f9953a59222c2accda0a0
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:42+02:00

Commit Message:
HARVESTER: Document formats and inventory toggle

Add AGENTS guidance for reverse-engineering work, expand FILE_FORMATS
with confirmed resource and text format notes, and align inventory
combat loadout toggling with the active avatar loadout state.

Changed paths:
  A AGENTS.md
    FILE_FORMATS.md
    engines/harvester/inventory.cpp
    engines/harvester/inventory.h


diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000000..9581e51fba8
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,516 @@
+# AGENTS.md
+
+## Purpose
+
+This project uses LLM-assisted reverse engineering with Ghidra and Ghidra MCP to analyze a DOS game binary. The goal is to progressively recover meaningful program structure by tracing execution from the application entry point, identifying functions, variables, globals, data structures, and subsystem boundaries, and renaming only when there is high confidence.
+
+This repository also maintains an `ARCHITECTURE.md` file that records confirmed subsystem discoveries and their relationships.
+
+Accuracy matters more than speed. Never guess.
+
+---
+
+## Primary Objectives
+
+1. Start analysis at the application entry point.
+2. Follow control flow outward to identify:
+   - functions
+   - global variables
+   - local variables
+   - data structures
+   - tables
+   - buffers
+   - dispatch logic
+   - subsystem boundaries
+3. Rename symbols only when their purpose is supported by strong evidence.
+4. Record confirmed subsystem discoveries in `ARCHITECTURE.md`.
+5. Use strings, string cross-references, DOS interrupts, calling patterns, and data flow as primary sources of evidence.
+6. Preserve uncertainty explicitly. If confidence is low, do not rename and do not document as fact.
+
+---
+
+## Ground Rules
+
+### 1. Never guess
+Do not rename a function, variable, struct, enum, field, or table unless the available evidence supports the meaning with high confidence.
+
+Avoid speculative names such as:
+- `maybe_draw_sprite`
+- `probably_load_file`
+- `unknown_audio_thing`
+
+If confidence is insufficient, leave the original name in place or apply only a strictly descriptive neutral name if justified by observable behavior, such as:
+- `memcpy_like`
+- `int21_file_io_wrapper`
+- `table_of_far_ptrs`
+- `state_dispatch_table`
+
+### 2. Evidence over intuition
+Every rename and every `ARCHITECTURE.md` update must be grounded in evidence such as:
+- string contents and string references
+- DOS interrupt usage
+- BIOS interrupt usage
+- file access patterns
+- video memory writes
+- buffer shapes and access patterns
+- call graph relationships
+- repeated call-site behavior
+- resource loading sequences
+- script interpreter patterns
+- structure layout and field usage
+
+### 3. Work from the entry point outward
+Begin at the program entry point and proceed in execution order as much as possible. Prefer understanding initialization, subsystem setup, and top-level dispatch before diving into leaf functions.
+
+### 4. Prefer reversible, minimal, precise changes
+Rename conservatively. A smaller number of correct renames is better than many wrong ones.
+
+### 5. Separate confirmed facts from working hypotheses
+Only confirmed facts belong in `ARCHITECTURE.md`.
+
+Do not write:
+- guesses
+- possibilities
+- loose speculation
+- subsystem claims based on one weak clue
+
+---
+
+## Analysis Workflow
+
+## Phase 1: Entry Point and Startup Recovery
+
+Start at the application entry point and identify:
+
+- startup and initialization flow
+- memory/model setup
+- segment register initialization
+- heap/buffer setup
+- resource/bootstrap loading
+- video/audio/input initialization
+- main loop entry
+- shutdown/cleanup path
+
+Tasks:
+1. Trace the first layer of calls from the entry point.
+2. Identify initialization clusters by behavior.
+3. Mark wrappers around common DOS/BIOS interrupts.
+4. Identify central state objects, global flags, mode variables, and dispatch tables.
+5. Rename only high-confidence startup functions.
+
+Examples of acceptable names if justified:
+- `game_entry`
+- `initialize_video`
+- `initialize_audio`
+- `initialize_input`
+- `main_loop`
+- `shutdown_and_exit`
+
+Only use these names when the evidence is strong.
+
+---
+
+## Phase 2: Systematic Symbol Recovery
+
+For each discovered function, variable, or data structure:
+
+### Functions
+Determine:
+- what calls it
+- what it calls
+- what data it reads/writes
+- whether it wraps a DOS/BIOS interrupt
+- whether it processes strings, files, graphics, scripts, or resources
+- whether it is a leaf helper or subsystem coordinator
+
+Rename functions using:
+- concrete behavior
+- subsystem context
+- observable side effects
+
+Good examples:
+- `open_resource_file`
+- `read_resource_chunk`
+- `draw_mouse_cursor`
+- `decode_rle_scanline`
+- `script_execute_opcode`
+- `blit_backbuffer_to_vram`
+
+Bad examples:
+- `handle_game_stuff`
+- `video_related`
+- `sound_func`
+- `do_script_maybe`
+
+### Variables and globals
+Determine:
+- lifetime
+- scope
+- initialization site
+- write/read locations
+- relation to mode/state/subsystem operation
+- whether it is a pointer, counter, flag, buffer, handle, or table
+
+Prefer names like:
+- `current_video_mode`
+- `resource_file_handle`
+- `mouse_x`
+- `mouse_y`
+- `active_script_pc`
+- `palette_buffer`
+
+Only if proven.
+
+### Data structures
+Look for:
+- repeated field offsets
+- arrays of records
+- pointer tables
+- object/state records
+- decoded resource headers
+- animation/script/resource metadata
+
+Name structures only after enough field usage is understood.
+
+Good examples:
+- `ResourceHeader`
+- `SpriteDescriptor`
+- `ScriptContext`
+- `CursorState`
+
+If not enough is known, prefer temporary neutral names such as:
+- `struct_XXXX_candidate`
+- `resource_record_candidate`
+
+---
+
+## Strings-Driven Analysis
+
+The strings table is a major source of context and must be used aggressively.
+
+For each meaningful string:
+1. Identify cross-references to the string.
+2. Determine whether it is used for:
+   - error reporting
+   - debug/logging
+   - file/resource names
+   - script commands
+   - UI text
+   - copy protection
+   - device/system checks
+   - command dispatch
+3. Follow the referencing function outward and inward in the call graph.
+4. Use clustered strings to infer subsystem boundaries.
+
+Examples:
+- File extensions or resource names may indicate resource loading or archive management.
+- UI/status messages may reveal menu, inventory, cursor, or script systems.
+- Error strings may expose file handling, memory allocation, decompression, or driver init paths.
+
+Do not infer more than the string supports.
+
+A string saying `AdLib` may suggest audio relevance. It does not by itself prove the exact role of the entire function.
+
+---
+
+## DOS and BIOS Interrupt Heuristics
+
+Interrupt usage is a strong clue and must be incorporated into analysis.
+
+### DOS interrupts
+Pay particular attention to:
+- `int 21h` for file management, memory allocation, program termination, device I/O, directory access, etc.
+- FCB- or handle-based file operations
+- load/execute behaviors
+- DTA manipulation
+- PSP/environment interactions
+
+### BIOS interrupts
+Pay particular attention to:
+- `int 10h` for video mode changes, cursor, text output, palette/video services
+- `int 13h` for disk access
+- `int 16h` for keyboard input
+- `int 1Ah` for timer/time services
+- `int 33h` for mouse services, if present via driver interrupt interface
+
+### Hardware-facing patterns
+Also look for:
+- direct writes to VGA memory
+- palette register I/O
+- PIT/PC speaker programming
+- AdLib/Sound Blaster port I/O
+- keyboard controller access
+- DMA-related setup
+- timer hooks or interrupt vector manipulation
+
+Use these clues to classify behavior, but only rename once supported by surrounding code and data flow.
+
+Example:
+- A function invoking `int 21h` alone is not necessarily `load_file`.
+- A function opening a named asset, seeking, reading into a buffer, and returning a handle-sized or byte-count result may justify `open_resource_file` or `read_resource_data`.
+
+---
+
+## Subsystem Discovery Rules
+
+As subsystem boundaries become clear, record them in `ARCHITECTURE.md`.
+
+Candidate subsystems include:
+- video
+- graphics rendering
+- sprite or animation handling
+- palette management
+- cursor management
+- keyboard/mouse input
+- audio/music/sfx
+- script engine
+- text/dialogue
+- decoders/decompression
+- resource/archive management
+- save/load
+- memory management
+- scene/state management
+
+### Only document a subsystem when at least one of the following is true:
+1. There is a clear cluster of related functions with consistent behavior.
+2. There are clear shared globals/structures that define subsystem state.
+3. Strings or resources strongly tie the functions together.
+4. Interrupt/hardware usage and data flow clearly indicate a distinct responsibility.
+
+### For each confirmed subsystem, record:
+- subsystem name
+- confidence level: `High`
+- why it is considered confirmed
+- key functions
+- key globals/structures
+- notable strings
+- relevant interrupts or hardware clues
+- known relationships to other subsystems
+
+Do not add low-confidence or speculative subsystems.
+
+---
+
+## ARCHITECTURE.md Update Policy
+
+`ARCHITECTURE.md` is a record of confirmed understanding, not a scratchpad.
+
+Only add content when:
+- the subsystem or relationship is supported by multiple strong clues
+- names used are stable and justified
+- the finding would still make sense to another analyst reviewing the evidence later
+
+Each entry should be concise and factual.
+
+Recommended format:
+
+```markdown
+## Video Subsystem
+
+**Confidence:** High
+
+**Evidence**
+- Functions at `FUN_xxxx`, `FUN_yyyy`, and `FUN_zzzz` change video mode via `int 10h`
+- Shared global buffer used as backbuffer before copy to VRAM
+- Palette update routine writes through VGA-related I/O sequence
+- Strings referencing mode/setup failure are used by the initialization path
+
+**Key Functions**
+- `initialize_video`
+- `set_video_mode`
+- `blit_backbuffer_to_vram`
+- `update_palette`
+
+**Key Data**
+- `video_state`
+- `backbuffer`
+- `palette_buffer`
+
+**Notes**
+- Video initializes before the main loop
+- Rendering appears to be separated from resource decoding
+````
+
+Do not include unresolved claims.
+
+---
+
+## Confidence Standard for Renaming
+
+A rename is allowed only when the name is supported by multiple converging signals.
+
+High-confidence signals include combinations of:
+
+* clear interrupt semantics
+* clear string references
+* clear file/resource names
+* repeated consistent call-site usage
+* obvious buffer or structure behavior
+* direct hardware interaction
+* strong structural relationships in the call graph
+
+### Rename threshold
+
+Rename only if at least two or more strong signals converge, or one signal is exceptionally definitive.
+
+Examples of sufficiently strong evidence:
+
+* function opens a named asset file, uses DOS file interrupts, reads into a destination buffer, and is called by resource init code
+* function writes to video memory or uses video BIOS services and is called by rendering flow
+* function dispatches on bytecodes read from a script stream and updates script context fields
+* function uses mouse interrupt services and updates cursor coordinates/state
+
+### If evidence is incomplete
+
+Do one of the following:
+
+* leave the original name unchanged
+* apply a narrowly descriptive placeholder based on directly observable mechanics only
+
+Examples:
+
+* `reads_buffer_with_length_prefix`
+* `far_ptr_dispatcher`
+* `int10_video_service_wrapper`
+* `copies_words_to_segment`
+
+Avoid semantic overreach.
+
+---
+
+## Naming Conventions
+
+Use clear, consistent, descriptive names.
+
+### Functions
+
+Use verb-oriented names:
+
+* `initialize_video`
+* `load_palette`
+* `decode_sprite_frame`
+* `execute_script_command`
+* `poll_keyboard_input`
+
+### Variables
+
+Use noun-oriented names:
+
+* `current_room_id`
+* `resource_index`
+* `cursor_visible`
+* `audio_driver_type`
+
+### Structures
+
+Use PascalCase:
+
+* `VideoState`
+* `ScriptContext`
+* `ResourceEntry`
+
+### Constants / enums
+
+Use uppercase when appropriate:
+
+* `VIDEO_MODE_13H`
+* `RESOURCE_TYPE_SPRITE`
+
+### Unknowns
+
+When forced to use an interim name, keep it descriptive and non-speculative:
+
+* `bytecode_stream_ptr`
+* `video_buffer_candidate`
+* `file_io_ctx_candidate`
+
+---
+
+## Preferred Investigation Tactics
+
+When analyzing a function, prefer this order:
+
+1. Identify callers.
+2. Identify callees.
+3. Inspect strings referenced directly or indirectly.
+4. Inspect interrupts and I/O operations.
+5. Track major buffers and globals touched.
+6. Look for repeated structural patterns.
+7. Determine whether the function belongs to an already-known subsystem.
+8. Decide whether rename confidence is high enough.
+
+When analyzing a global or structure:
+
+1. Find all writes.
+2. Find all reads.
+3. Determine initialization.
+4. Determine whether access patterns imply flags, counters, coordinates, handles, or pointers.
+5. Associate with a subsystem only if the evidence is strong.
+
+---
+
+## MCP / Ghidra Usage Expectations
+
+When operating through Ghidra MCP:
+
+* begin from the entry point unless continuing an already-confirmed analysis thread
+* inspect decompiler output, disassembly, xrefs, and data definitions together
+* follow string references systematically
+* inspect interrupt usage and surrounding setup/register state
+* examine tables and indirect call/jump targets
+* improve type information when supported by evidence
+* rename incrementally and conservatively
+* update `ARCHITECTURE.md` only after confirmation
+
+Do not mass-rename symbols based on pattern matching alone.
+
+---
+
+## What to Avoid
+
+Do not:
+
+* invent subsystem names without proof
+* rename based on vague resemblance
+* treat every `int 21h` call as generic file loading
+* treat every memory copy as rendering
+* assume every byte stream is a script
+* collapse unrelated helpers into a subsystem prematurely
+* document tentative conclusions in `ARCHITECTURE.md`
+* overwrite neutral names with stronger semantic names unless the new evidence truly supports it
+
+---
+
+## Output Expectations
+
+During analysis, produce:
+
+1. Conservative symbol renames with high confidence
+2. Confirmed subsystem notes appended to `ARCHITECTURE.md`
+3. Clear explanation of evidence for each non-trivial rename
+4. Explicit acknowledgment of uncertainty where confidence is not high enough
+
+For every important rename, include rationale in working notes or commit messages such as:
+
+* string references
+* interrupt semantics
+* caller/callee context
+* buffer usage
+* structure field evidence
+
+---
+
+## Operating Principle
+
+Recover the program one confirmed fact at a time.
+
+Start from the entry point.
+Use strings and xrefs aggressively.
+Use DOS/BIOS interrupts as behavioral clues.
+Track data flow carefully.
+Rename only with high confidence.
+Record only confirmed architecture.
+Record the last confirmed action we completed, and the next suggested action in TRACKER.md
+Record high level progress indicator that summarizes how many total functions are in Ghidra, and how want are still not renamed (ex: FUN_*) in TRACKER.md
+
+Never guess.
\ No newline at end of file
diff --git a/FILE_FORMATS.md b/FILE_FORMATS.md
index 85f43070930..9df3e51de57 100644
--- a/FILE_FORMATS.md
+++ b/FILE_FORMATS.md
@@ -2,6 +2,8 @@
 
 This document tracks file-format facts that are confirmed by the current Harvester reverse-engineering work. It is intentionally conservative: if a field or behavior is not supported by code or verified runtime notes, it is left out.
 
+The sections below combine three sources of evidence: the current ScummVM Harvester engine code, named native functions and data types in Ghidra, and spot-checks against extracted sample files from the Harvester CD image. Unknown or only partially understood fields stay marked as such.
+
 ## DAT (XFILE archive payload)
 
 Harvester's `.DAT` files act as payload containers for the game's numbered XFILE resource sets. The important wrinkle is that the `.DAT` file is not the whole format by itself: the loader depends on a sidecar `INDEX.00N` file for the directory. In other words, the `.DAT` holds the bytes, while the `INDEX.00N` records tell the engine where each member starts, how large it is, and whether it must be unpacked after reading.
@@ -77,3 +79,475 @@ For extraction or tooling work, the current evidence supports treating Harvester
 4. If `packed_flag` is nonzero, expand the slice with the literal/repeat decoder above until `unpacked_size` bytes are produced.
 
 That model is enough to explain the startup resource mounts already confirmed in the reverse-engineering notes, and it matches the current ScummVM-side archive loader.
+
+## RCS (Quick-tip text lists)
+
+The only confirmed `.RCS` use so far is `ADJHEAD.RCS`, the quick-tips file shown by the startup/options overlay. Both native `run_quick_tips_screen` and the ScummVM `Flow::loadQuickTips` path treat it as plain text rather than as a binary container.
+
+### Summary
+
+| Property | Value |
+| --- | --- |
+| Format role | Plaintext list of quick-tip strings |
+| Confirmed consumer | Startup quick-tips overlay and options-menu quick-tips screen |
+| Encoding | Plain ASCII text in the sampled file |
+| Header | None |
+| Delimiters | CR/LF line endings in the sampled file; loaders split on `\n` and ignore `\r` |
+
+### File layout
+
+| Offset / unit | Size | Description |
+| --- | --- | --- |
+| `0x00..EOF` | variable | Sequence of text lines. Each non-empty trimmed line becomes one quick-tip string. |
+
+### Notes
+
+- The sampled `ADJHEAD.RCS` begins directly with readable text: `Double click the left mouse button...`.
+- The native quick-tips path advances through the file with `read_line_from_file_stream`, wraps back to the start on EOF, and chooses a random starting point the first time it runs.
+- This is a good example of Harvester's resource pipeline staying pragmatic: not every gameplay-facing asset is wrapped in a custom binary format.
+
+## DIALOG.RSP (Dialogue response and keyword text)
+
+`DIALOG.RSP` is a plaintext line table used by the dialogue UI. It does not appear to contain ids, offsets, or a binary header; the engine indexes it by zero-based line number and then uses the line text directly.
+
+### Summary
+
+| Property | Value |
+| --- | --- |
+| Format role | Plaintext dialogue response / keyword string table |
+| Confirmed consumers | `load_dialogue_response_line`, response menu, keyword menu |
+| Encoding | Plain ASCII text in the sampled file |
+| Header | None |
+| Addressing model | Zero-based line index |
+
+### File layout
+
+| Unit | Description |
+| --- | --- |
+| Line `n` | One response, keyword label, or menu text string |
+| `/` within a line | In response/keyword UI contexts, splits one source line into multiple visible menu items |
+
+### Notes
+
+- The native loader reads a requested zero-based line, strips the trailing CR/LF, and returns the shared text buffer directly.
+- `run_dialogue_response_menu` and `run_dialogue_keyword_menu` split visible options on `/`; there is no confirmed hidden topic-id layer behind those labels.
+- `load_dialogue_index` also pulls zero-based line `13` from `DIALOG.RSP` to seed the default keyword topic; in the sampled file that line is `BYE` (one-based line 14).
+- As a narrative point for a blog post, `DIALOG.RSP` shows Harvester leaning on designer-editable text tables even inside a fairly custom dialogue stack.
+
+## DIALOGUE.IDX (Subtitle index)
+
+`DIALOGUE.IDX` is an XOR-obfuscated text file that maps voice sample ids to subtitle strings. The native `load_dialogue_index` path and the ScummVM `Text::loadDialogueIndex` reimplementation both decode it with XOR `0xAA` while leaving CR/LF intact.
+
+### Summary
+
+| Property | Value |
+| --- | --- |
+| Format role | Voice-id to subtitle-text index |
+| Encoding on disk | ASCII text obfuscated with XOR `0xAA` on every byte except CR/LF |
+| Header | None |
+| Confirmed delimiters | `NUL`, `LF`, `CR`, and form-feed (`0x0c`) |
+| Key native type | `DialogueIndexEntry { wav_id, text_offset, text_length }` |
+
+### Decoded stream layout
+
+| Sequence element | Description |
+| --- | --- |
+| ASCII decimal token | `wav_id` for one spoken line |
+| Delimiter run | One or more of `NUL`, `LF`, `CR`, `FF` |
+| ASCII text token | Subtitle text for that voice id |
+| Delimiter run | Ends the subtitle record and starts the next id |
+
+### Derived index entry
+
+| Field | Meaning |
+| --- | --- |
+| `wav_id` | Positive decimal voice/sample id |
+| `text_offset` | Byte offset of the decoded subtitle text inside the decoded blob |
+| `text_length` | Subtitle length, clamped to `0x19a` bytes by the native loader |
+
+### Notes
+
+- After XOR decode, the sampled file starts with a simple alternating pattern: `1`, `"junk init"`, `7`, `"Yes?"`, `11`, `"I need some help..."`, and so on.
+- The native loader builds a 3000-entry in-memory table and keeps the decoded text blob around so `play_dialogue_line` can seek back into it cheaply.
+- Architecturally, this splits dialogue cleanly in two: `DIALOGUE.IDX` carries spoken-line subtitles keyed by numeric wav ids, while `DIALOG.RSP` carries menu-facing response text.
+
+```python
+python3 -c 'import sys,pathlib,signal; signal.signal(signal.SIGPIPE, signal.SIG_DFL); d=pathlib.Path(sys.argv[1]).read_bytes(); sys.stdout.buffer.write(bytes(b if b in (10,13) else b ^ 0xAA for b in d))' DIALOGUE.IDX | head -n 10
+1
+"junk init"
+7
+"Yes?"
+11
+"I need some help... Mister...?"
+15
+"Postmaster Boyle.  What can I do you for today?"
+25
+"Sorry, youngster, we're out of applications right now."
+```
+
+
+## SCR (Town/world startup script)
+
+`HARVEST.SCR` is the central world-definition and startup-script file. It is XOR-obfuscated text, not bytecode. The native `load_xor_obfuscated_town_script` path and the ScummVM `Script::decode` path both XOR each non-CR/LF byte with `0xAA`, then parse whitespace-separated records with quoted strings preserved.
+
+### Summary
+
+| Property | Value |
+| --- | --- |
+| Format role | World database, startup configuration script, and command graph |
+| Encoding on disk | ASCII text obfuscated with XOR `0xAA` on every byte except CR/LF |
+| Header | None |
+| Comment syntax | Lines whose first non-space character is `{` are skipped |
+| Tokenization | Whitespace-delimited tokens with quoted strings kept intact |
+
+### Confirmed record forms
+
+| Tag | Leading numeric fields | Fields after the tag |
+| --- | --- | --- |
+| `ENTRANCE` | `x y z` | `direction roomName entranceName` |
+| `MAP_ENTRANCE` | `field0 field4 initialPanelIndex` | `entryName` |
+| `MAP_LOCATION` | `minX minY maxX maxY panelIndex labelX labelY` | `labelText destinationEntranceName` |
+| `ROOM` | `minZ maxZ maxZScreenY minZScreenY fullScaleZ maxZScalePercent` | `roomName musicPath field38 field3c field40 palettePath dimmable onEnterCommand onExitCommand` |
+| `OBJECT` | `initialX initialY boundsX2 boundsY2 initialZ zExtent` | `initialOwnerOrRoom objectName spritePath altSpritePath field40 inventoryTextKey field34 identTextKey operatable visible actionTag interactionLabel` |
+| `ANIM` | `x y z frameDelay` | `roomName resourcePath animName active visible looping backward pingPong remove` |
+| `NPC` | `x y z frameDelay` | `roomName modelPath npcName monsterfyTargetName active visible onDeathActionTag audioPath entityInitArg` |
+| `MONSTER` | `x y z ...` | Sample data and native analysis confirm a wider combat-oriented record. The current work has high confidence only for `roomName`, `monsterName`, `modelPath`, `initialFacing`, `active`, `visible`, and `onDeathActionTag`; several intermediate sound/combat columns remain only partially typed. |
+| `REGION` | `left top right bottom minZ maxZ` | `regionName direction roomName actionTag startEnabled cursorEnabled` |
+| `FLAG` | none | `name value` |
+| `COMMAND` | none | `triggerTag opcodeName arg1 arg2 arg3 [arg4]` |
+| `TEXT` | none | `key boxName value` |
+| `HEAD` | none | `headId portraitPath` |
+| `USEITEM` | none | `itemName ownerOrRoom targetName actionTag` |
+
+### Notes
+
+- The sampled file starts with readable structural records immediately after XOR decode, for example `ENTRANCE`, `OBJECT`, and later `ROOM`, `TEXT`, `HEAD`, and `COMMAND`.
+- Paths embedded in script records are not uniform: archive-backed resources use `1:\...`, `2:\...`, `3:\...`, while some direct-file assets are bare relative paths such as `dialogue.idx`.
+- `GOFLIC` and `GODEATHFLIC` are especially revealing command names. In the sampled script, those opcodes point to `.FST` paths, which suggests the script vocabulary preserved older naming while the shipping runtime movie path used FST files.
+- As a blog-post narrative, `HARVEST.SCR` is the clearest proof that Harvester's resource architecture is data-driven at the top level: rooms, objects, dialogue portraits, commands, music, palettes, and cutscene triggers all converge here.
+
+```bash
+python3 -c 'import sys,pathlib,signal; signal.signal(signal.SIGPIPE, signal.SIG_DFL); d=pathlib.Path(sys.argv[1]).read_bytes(); sys.stdout.buffer.write(bytes(b if b in (10,13) else b ^ 0xAA for b in d))' HARVEST.SCR | head -n 10
+{// HARVESTER (c) 1995-96 Scripting Language
+{// Town
+        0   0   0   ENTRANCE "BACK" "TOWN_2_LODGE" "SERGEANT_2_LODGE"
+        0   0   0   ENTRANCE "FRONT" "" "SAVE_GAME"
+        492 67  554 96   0  1 OBJECT "INVENTORY" "INV_EXIT" "" "" "" "" "" "X" "F" "T" "" "Inventory"
+        299 0   0   0    2  1 OBJECT "NULL_ID"          "EXIT_BM"  "1:\GRAPHIC\OTHER\EXITSIGN.BM" "" "" "" ""  "" "F" "T" "" "exit"
+        299 0   375 61   0  1 OBJECT "NULL_ID"          "EXIT_HS"  ""                             "" "" "" "X" "" "T" "T" "" "exit"
+
+{// inventory health indicator
+     72 314 0   0  -12  1 OBJECT "INVENTORY" "INV_STAT1" "1:\graphic\other\head-a1.bm" "" "" "INV_STAT_ST" "" "" "F" "F" "" "Health_Indicator"
+```
+
+## BM (Static indexed bitmap)
+
+`.BM` is Harvester's simplest custom image format: a small fixed header followed by raw 8-bit indexed pixels. It is used for UI panels, portraits, inventory art, help screens, and other non-animated images.
+
+### Summary
+
+| Property | Value |
+| --- | --- |
+| Format role | Raw 8-bit indexed bitmap |
+| Confirmed consumers | `spawn_bitmap_entity_from_resource`, `reload_bitmap_entity_pixels_from_resource`, menu/help/dialogue overlay loaders |
+| Compression | None confirmed |
+| Pixel format | 1 byte per pixel, palette-indexed |
+
+### File layout
+
+| Offset | Size | Type | Description |
+| --- | --- | --- | --- |
+| `0x00` | `0x04` | uint32le | `width` |
+| `0x04` | `0x04` | uint32le | `height` |
+| `0x08` | `0x04` | uint32le | Unused/reserved in current analysis. Both native and ScummVM loaders ignore it; sampled `MOUSHELP.BM` stores zero here. |
+| `0x0c` | `width * height` | bytes | Raw indexed pixel payload, row-major |
+
+### Notes
+
+- The native binary reuses the same `RawBitmap`/`BitmapBuffer` shape in memory: `{ width, height, pixels }`.
+- The format itself does not encode transparency, but many callers render it with palette index `0` treated as transparent.
+- Compared with many later adventure engines, this is an aggressively direct format: no chunking, no palette sidecar inside the file, and no per-row metadata.
+
+## PAL (Standalone palette)
+
+Harvester's `.PAL` files are raw palette payloads with no header. The native palette upload helpers and ScummVM `Art::loadPalette` both treat them as 256 RGB triplets.
+
+### Summary
+
+| Property | Value |
+| --- | --- |
+| Format role | Standalone 256-color palette resource |
+| Confirmed consumers | Room setup, menus, help screens, wait overlay, town map |
+| Header | None |
+| Payload size | 768 bytes (`256 * 3`) |
+
+### File layout
+
+| Offset / range | Size | Description |
+| --- | --- | --- |
+| `0x000..0x2ff` | 768 bytes | 256 consecutive `(R, G, B)` triplets stored as 8-bit channel values |
+
+### Notes
+
+- Native `upload_palette_to_vga` forces palette index `0` to black at upload time, applies brightness scaling, and then shifts the stored 8-bit channels down to VGA's `0..63` DAC range.
+- The sampled `INVHELP.PAL` contains full-range byte values up to `0xff`, which matches the native analysis that `.PAL` is stored as 8-bit RGB, not pre-divided 6-bit VGA values.
+- This makes `.PAL` a nice contrast with FST's embedded movie palettes, which are stored in 6-bit VGA form inside each frame payload.
+
+
+```bash
+python3 -m pip install pillow
+python3 -c 'from PIL import Image; import sys,struct; d=open(sys.argv[1],"rb").read(); w,h=struct.unpack_from("<II",d,0); i=Image.frombytes("P",(w,h),d[12:12+w*h]); p=open(sys.argv[2],"rb").read()[:768] if len(sys.argv)>2 else bytes(c for n in range(256) for c in (n,n,n)); i.putpalette(p); i.show()' GRAPHIC/OTHER/INVHELP.BM GRAPHIC/PAL/INVHELP.PAL
+```
+
+## CFT (Bitmap font)
+
+`.CFT` packages a bitmap font as a fixed metrics header plus one raw 8-bit atlas image. The native `load_font_resource` and the ScummVM `Text::loadFont` / `HarvesterCftFont` code agree on the basic structure.
+
+### Summary
+
+| Property | Value |
+| --- | --- |
+| Format role | Bitmap font resource with metrics tables and atlas |
+| Glyph count | 256 slots |
+| Rendering model | Each glyph is a horizontal slice out of one shared atlas bitmap |
+| Confirmed consumers | Menu text, room labels, dialogue, save/load UI, text-entry widgets |
+
+### File layout
+
+| Offset | Size | Type | Description |
+| --- | --- | --- | --- |
+| `0x000` | `0x40` | char[64] | NUL-terminated font name (`HARVFONT` in the sampled file) |
+| `0x040` | `0x02` | uint16le | Font height |
+| `0x042` | `0x200` | uint16le[256] | Glyph start-X table |
+| `0x242` | `0x200` | uint16le[256] | Glyph width table |
+| `0x442` | `0x02` | uint16le | Space width |
+| `0x444` | `0x04` | unknown | Unused/reserved in current analysis |
+| `0x448` | `0x04` | uint32le | Atlas width |
+| `0x44c` | `0x04` | uint32le | Atlas height |
+| `0x450` | `0x04` | unknown | Unused/reserved in current analysis; sampled `HARVFONT.CFT` stores zero here |
+| `0x454` | `atlasWidth * atlasHeight` | bytes | Raw atlas pixels, row-major, 8-bit indexed |
+
+### Notes
+
+- The renderer derives each glyph by slicing `width` pixels from `x` inside the shared atlas. There is no per-glyph bitmap chunking.
+- `drawHeight` is effectively `atlasHeight - 1` in the current font renderer, which matches the native behavior of treating the last row as non-drawing padding.
+- The font renderer treats both `' '` and `'_'` as space-width characters. That ties neatly back to the script/text resources, where underscore-heavy identifiers and UI labels coexist with visible text.
+
+```bash
+python3 -c 'from PIL import Image; import sys,struct; d=open(sys.argv[1],"rb").read(); aw,ah=struct.unpack_from("<II",d,0x448); h=max(1,min(struct.unpack_from("<H",d,0x40)[0] or ah-1,ah-1)); sw=struct.unpack_from("<H",d,0x442)[0] or 1; s=[struct.unpack_from("<H",d,0x42+i*2)[0] for i in range(256)]; w=[struct.unpack_from("<H",d,0x242+i*2)[0] for i in range(256)]; a=Image.frombytes("L",(aw,ah),d[0x454:0x454+aw*ah]); t=sys.argv[2]; W=sum(sw if ord(c)>255 or c in " _" or w[ord(c)]<=0 else w[ord(c)] for c in t); i=Image.new("L",(max(1,W),h),0); x=0; exec("for c in t:\n o=ord(c)\n gw=sw if o>255 or c in \" _\" or w[o]<=0 or s[o]>=aw else w[o]\n if gw and o<=255 and c not in \" _\" and w[o]>0 and s[o]<aw: i.paste(255,(x,0,x+gw,h),a.crop((s[o],0,s[o]+gw,h)))\n x+=gw"); i.resize((max(1,i.width*4),max(1,i.height*4)),Image.NEAREST).show()' "GRAPHIC/FONT/HARVFNT2.CFT" "Hello World"
+```
+
+## ABM (Animated bitmap / sprite strip)
+
+`.ABM` is Harvester's main custom sprite/animation format. It backs cursor art, actor sprites, room animations, combat entities, and wait-overlay animation frames.
+
+### Summary
+
+| Property | Value |
+| --- | --- |
+| Format role | Multi-frame indexed animation resource |
+| Confirmed consumers | `attach_abm_resource_to_entity`, `spawn_abm_entity_from_resource`, startup art loaders, room animation runtime |
+| Pixel format | 8-bit indexed pixels |
+| Compression | Optional per-frame RLE-like stream |
+
+### File header
+
+| Offset | Size | Type | Description |
+| --- | --- | --- | --- |
+| `0x00` | `0x04` | uint32le | `frame_count` |
+| `0x04` | `0x04` | uint32le | Native runtime uses this value to size the temporary decoded-frame buffer before adding `0x10` bytes of slack. The current ScummVM loader does not otherwise interpret it. Sampled `BLOOD.ABM` stores `0x888` here. |
+| `0x08` | variable | sequence | First frame record begins here |
+
+### Per-frame record
+
+| Offset within frame | Size | Type | Description |
+| --- | --- | --- | --- |
+| `0x00` | `0x04` | int32le | `x_offset` |
+| `0x04` | `0x04` | int32le | `y_offset` |
+| `0x08` | `0x04` | uint32le | `width` |
+| `0x0c` | `0x04` | uint32le | `height` |
+| `0x10` | `0x01` | byte | `compressed_flag` |
+| `0x11` | `0x04` | uint32le | `encoded_size` |
+| `0x15` | `encoded_size` | bytes | Encoded or raw pixel payload for this frame |
+
+### Compressed frame stream
+
+| Control byte form | Meaning |
+| --- | --- |
+| `0x00..0x7f` | Copy the next `control` bytes literally |
+| `0x80..0xff` | Read one byte and repeat it `control & 0x7f` times |
+
+### Notes
+
+- Each frame decodes to exactly `width * height` bytes of indexed pixels.
+- The sampled `BLOOD.ABM` starts with 5 frames; its first frame is offset `(4, 0)`, size `38 x 50`, and marked compressed.
+- ABM is central to Harvester's scene graph. The same format scales from tiny cursor sprites up to actor and monster visual state banks, which makes it a strong candidate for a blog-post "one format, many subsystems" section.
+
+```bash
+python3 -c $'from PIL import Image\nimport sys,struct,tempfile,pathlib,webbrowser\n\ndef dec(s,n):\n i=0; o=bytearray()\n while i<len(s) and len(o)<n:\n  k=s[i]; i+=1\n  if k<128:\n   c=min(k,len(s)-i,n-len(o)); o+=s[i:i+c]; i+=c\n  elif i<len(s):\n   o.extend([s[i]]*min(k&127,n-len(o))); i+=1\n return bytes(o)\n\nd=open(sys.argv[1],"rb").read()\npal=open(sys.argv[2],"rb").read()[:768] if len(sys.argv)>2 else bytes(c for n in range(256) for c in (n,n,n))\nfc=struct.unpack_from("<I",d,0)[0]; off=8; F=[]; minx=miny=10**9; maxx=maxy=-10**9\nfor _ in range(fc):\n x,y,w,h=struct.unpack_from("<iiii",d,off); c=d[off+16]; n=struct.unpack_from("<I",d,off+17)[0]; s=d[off+25:off+25+n]; off+=25+n; p=s[:w*h] if not c else dec(s,w*h); F.append((x,y,w,h,p)); minx=min(minx,x); miny=min(miny,y); maxx=max(maxx,x+w); maxy=max(maxy,y+h)\nminx=min(0,minx); miny=min(0,miny); W=maxx-minx; H=maxy-miny; G=[]\nfor x,y,w,h,p in F:\n src=Image.frombytes("P",(w,h),p); src.putpalette(pal); m=src.point(lambda v:0 if v==0 else 255,"L"); fr=Image.new("RGBA",(W,H),(0,0,0,0)); fr.paste(src.convert("RGBA"),(x-minx,y-miny),m); G.append(fr)\nf=tempfile.NamedTemporaryFile(suffix=".gif",delete=False).name; G[0].save(f,save_all=True,append_images=G[1:],duration=100,loop=0,disposal=2,transparency=0); webbrowser.open(pathlib.Path(f).as_uri())' "BLOOD.ABM" "WAIT.PAL"
+```
+
+## CMP (FCMP-compressed audio)
+
+`.CMP` is Harvester's custom compressed audio wrapper. The sampled files begin with `FCMP`, and the native `load_sound_sample` / `load_dialogue_voice_sample` code plus the ScummVM `decodeHarvesterFcmp` path all treat that payload as IMA-ADPCM-like compressed audio.
+
+### Summary
+
+| Property | Value |
+| --- | --- |
+| Format role | Compressed audio for music, dialogue voice, and sound effects |
+| Confirmed magic | `FCMP` |
+| Confirmed codec | IMA-ADPCM-style nibble stream using the standard step/index tables recovered in the binary |
+| Supported output depths | 8-bit and 16-bit PCM |
+
+### File layout
+
+| Offset | Size | Type | Description |
+| --- | --- | --- | --- |
+| `0x00` | `0x04` | ASCII | `FCMP` magic |
+| `0x04` | `0x04` | uint32le | Compressed payload size |
+| `0x08` | `0x04` | uint32le | Sample rate in Hz |
+| `0x0c` | `0x02` | uint16le | Output bits per sample (`8` or `16`) |
+| `0x0e` | variable | bytes | ADPCM payload |
+
+### Notes
+
+- Sampled `MENACE.CMP` starts with `FCMP`, a payload size of `0x0db48b`, sample rate `22050`, and `16` bits per sample.
+- The loader family is tolerant: some call paths accept either `FCMP` or raw `WAVE` data. The `.CMP` extension itself points to the compressed path, but the runtime checks the actual file signature before decoding.
+- From an architectural perspective, this is the audio-side equivalent of Harvester's XFILE abstraction: one wrapper format reused across music, speech, and effects.
+
+## FST (Cutscene / streamed animation)
+
+`.FST` is Harvester's custom streamed movie format. It combines a file header, a compact per-frame index table, block-coded video payloads, optional per-frame palettes, and per-frame audio chunks.
+
+### Summary
+
+| Property | Value |
+| --- | --- |
+| Format role | Cutscene / transition movie format |
+| Confirmed magic | `FST2` (`0x32545346` on disk, little-endian) |
+| Video model | 8-bit indexed frames decoded in `4 x 4` blocks |
+| Audio model | Per-frame audio chunks described by the frame index table |
+| Confirmed consumers | `run_fst_sequence_player`, `play_fst_sequence`, startup intro path, scripted room transitions |
+
+### File header
+
+| Offset | Size | Type | Description |
+| --- | --- | --- | --- |
+| `0x00` | `0x04` | ASCII | `FST2` magic |
+| `0x04` | `0x04` | uint32le | Frame width |
+| `0x08` | `0x04` | uint32le | Frame height |
+| `0x0c` | `0x04` | uint32le | `max_frame_size` |
+| `0x10` | `0x04` | uint32le | `frame_count` |
+| `0x14` | `0x04` | uint32le | `frame_rate` |
+| `0x18` | `0x04` | uint32le | `sample_rate` |
+| `0x1c` | `0x04` | uint32le | `bits_per_sample` |
+| `0x20` | `frame_count * 6` | array | `FstFrameIndexEntry[frame_count]` |
+
+### Frame index entry
+
+| Offset within entry | Size | Type | Description |
+| --- | --- | --- | --- |
+| `0x00` | `0x04` | uint32le | `video_size` |
+| `0x04` | `0x02` | uint16le | `audio_size` |
+
+### Video payload for one frame
+
+| Sequence element | Description |
+| --- | --- |
+| `uint16 bit_count` | Number of bits in the control bitstream |
+| `((bit_count >> 3) + 1)` bytes | Packed control-bit stream |
+| Optional `256 * 3` bytes | Present only when the first control bit is set; this is a VGA-style 6-bit palette block, not a `.PAL` resource block |
+| Block payload stream | One record per changed `4 x 4` tile across the frame |
+
+### Block coding
+
+| Control bits / payload | Meaning |
+| --- | --- |
+| Changed-bit `0` | Leave the existing `4 x 4` block unchanged |
+| Changed-bit `1`, mode-bit `0`, 16-byte payload | Literal `4 x 4` pixel block |
+| Changed-bit `1`, mode-bit `1`, 4-byte payload | Two colors plus a 16-bit mask describing a `4 x 4` block |
+
+### Notes
+
+- Sampled `VIRGLOGO.FST` is `320 x 200`, stores `131` frames, plays at `15` fps, and carries `22050 Hz` / `16-bit` audio.
+- The native and ScummVM players both treat FST as a streaming format: read one frame, queue its audio, decode its video, and advance without loading the whole movie at once.
+- `HARVEST.SCR` uses `GOFLIC`/`GODEATHFLIC` opcodes to trigger `.FST` files. That mismatch between opcode names and file extension is a strong architectural clue that FST replaced or wrapped an older movie concept without rewriting the script vocabulary.
+- The censorship path also shows how self-contained the format is: FST can carry its own palette updates frame-by-frame, while the player temporarily swaps in an external `CENSORED.PCX` overlay when gore is disabled.
+
+## PCX (Standard indexed still image)
+
+Harvester does use standard `.PCX` files in at least one confirmed place: the censorship overlay shown during certain FST sequences when gore is disabled.
+
+### Summary
+
+| Property | Value |
+| --- | --- |
+| Format role | Standard 8-bit indexed PCX still image |
+| Confirmed consumer | Native `load_pcx_bitmap`; ScummVM FST censorship overlay loader |
+| Encoding | PCX RLE |
+| Palette | Trailing 256-color palette block when present |
+
+### Confirmed header fields
+
+| Offset | Size | Type | Description |
+| --- | --- | --- | --- |
+| `0x00` | `0x01` | byte | Manufacturer (`0x0a` in sampled `CENSORED.PCX`) |
+| `0x01` | `0x01` | byte | Version (`0x05` in the sample) |
+| `0x02` | `0x01` | byte | Encoding (`0x01` = RLE) |
+| `0x03` | `0x01` | byte | Bits per pixel per plane (`0x08` in the sample) |
+| `0x04` | `0x02` | uint16le | `xMin` |
+| `0x06` | `0x02` | uint16le | `yMin` |
+| `0x08` | `0x02` | uint16le | `xMax` |
+| `0x0a` | `0x02` | uint16le | `yMax` |
+| `0x41` | `0x01` | byte | Color planes (`0x01` in the sample) |
+| `0x42` | `0x02` | uint16le | Bytes per line (`320` in the sample) |
+| `0x80` | variable | bytes | RLE-compressed image data |
+| `EOF - 769` | `0x01` | byte | Standard palette marker `0x0c` in the sampled file |
+| `EOF - 768` | `0x300` | bytes | 256-color palette block when present |
+
+### Notes
+
+- Sampled `CENSORED.PCX` is a textbook single-plane `320 x 200` PCX, which is why the ScummVM port can use the generic PCX decoder for it.
+- The native loader contains two Harvester-specific wrinkles: it trims the optional `0x0c` palette marker by jumping straight to the last `0x300` bytes, and when the logical width from `xMax` is one pixel smaller than `bytesPerLine`, it trims the padded stride byte after decode.
+- This is another useful contrast point for a blog post: Harvester mostly ships custom image formats (`BM`, `ABM`, `FST`), but it still keeps a standards-based escape hatch where that was convenient.
+
+## FLC (Standard Autodesk FLIC animation)
+
+Harvester does ship `.FLC` files, and the sampled files match the standard Autodesk FLIC/FLC header rather than a Harvester-specific wrapper. The current Harvester engine work has not yet recovered the exact native Harvester-side call path that consumes them, so this section stays limited to what is directly supported by sample bytes, binary strings, and the generic ScummVM FLIC decoder.
+
+### Summary
+
+| Property | Value |
+| --- | --- |
+| Format role | Standard 8-bit FLIC/FLC animation files bundled with Harvester |
+| Evidence | Sample files such as `GRAPHIC/ROOMANIM/HARVPNTR.FLC` and `GRAPHIC/FST/CHESMOV1.FLC`; native strings `Could not load flic.` and `flic.cpp`; hardcoded `.flc` paths in the binary |
+| Harvester-specific loader status | Not yet fully recovered in current Harvester analysis |
+| Decoder in repo | Generic ScummVM `video/flic_decoder.cpp` |
+
+### Confirmed standard header fields
+
+| Offset | Size | Type | Description |
+| --- | --- | --- | --- |
+| `0x00` | `0x04` | uint32le | File size |
+| `0x04` | `0x02` | uint16le | Magic `0xaf12` |
+| `0x06` | `0x02` | uint16le | Frame count |
+| `0x08` | `0x02` | uint16le | Width |
+| `0x0a` | `0x02` | uint16le | Height |
+| `0x0c` | `0x02` | uint16le | Color depth (`8` in the sampled file) |
+| `0x0e` | `0x02` | uint16le | Flags |
+| `0x10` | `0x04` | uint32le | Frame delay in milliseconds |
+| `0x50` | `0x04` | uint32le | Offset of frame 1 |
+| `0x54` | `0x04` | uint32le | Offset of frame 2 |
+| `0x80` | variable | bytes | First frame/chunk stream begins here in the generic decoder |
+
+### Notes
+
+- Sampled `HARVPNTR.FLC` starts with a valid FLC header: size `0x214a`, magic `0xaf12`, `10` frames, `26 x 26`, and `8`-bit color.
+- The decoded `HARVEST.SCR` sample does not reference `.FLC` files directly; its `GOFLIC` opcodes currently point to `.FST` files instead. That suggests `.FLC` belongs to a parallel or older asset path rather than the main shipping story-transition pipeline. This last point is an inference from the sampled script and should stay provisional.
+- Taken together, the resource set shows three animation strata: lightweight sprite ABMs, streamed FST movies, and a residual standard FLC layer. That is a strong narrative hook for explaining how mixed-tool resource pipelines often survive into shipped games.
+
+```
+ffplay -loop -1 "GRAPHIC/ROOMANIM/HARVPNTR.FLC"
+ffmpeg -i "GRAPHIC/ROOMANIM/HARVPNTR.FLC" out.gif
+```
\ No newline at end of file
diff --git a/engines/harvester/inventory.cpp b/engines/harvester/inventory.cpp
index ca500e40f87..2bf255a6265 100644
--- a/engines/harvester/inventory.cpp
+++ b/engines/harvester/inventory.cpp
@@ -424,7 +424,8 @@ void InventorySystem::selectItem(const Common::String &objectName) {
 	_selectedItemName = objectName;
 }
 
-bool InventorySystem::toggleCombatLoadout(const StartupObjectRecord &object, bool &changed) {
+bool InventorySystem::toggleCombatLoadout(const StartupObjectRecord &object, int currentLoadout,
+		bool &changed) {
 	changed = false;
 
 	int loadoutId = 0;
@@ -435,11 +436,16 @@ bool InventorySystem::toggleCombatLoadout(const StartupObjectRecord &object, boo
 	if (!startupScript)
 		return false;
 
-	const int nextLoadout = startupScript->getPlayerCombatLoadout() == loadoutId ? 0 : loadoutId;
-	changed = startupScript->setPlayerCombatLoadout(nextLoadout);
+	// Native run_inventory_screen compares the clicked weapon against the live combat avatar loadout.
+	const int savedLoadout = startupScript->getPlayerCombatLoadout();
+	const int activeLoadout = currentLoadout >= 0 ? currentLoadout : savedLoadout;
+	const int nextLoadout = activeLoadout == loadoutId ? 0 : loadoutId;
+	const bool scriptChanged = startupScript->setPlayerCombatLoadout(nextLoadout);
+	changed = activeLoadout != nextLoadout;
 	debugC(1, kDebugInventory,
-		"Harvester: inventory combat toggle object='%s' requested_loadout=%d changed=%d resulting_loadout=%d",
-		object.objectName.c_str(), nextLoadout, changed, startupScript->getPlayerCombatLoadout());
+		"Harvester: inventory combat toggle object='%s' active_loadout=%d saved_loadout=%d requested_loadout=%d changed=%d script_changed=%d resulting_loadout=%d",
+		object.objectName.c_str(), activeLoadout, savedLoadout, nextLoadout, changed,
+		scriptChanged, startupScript->getPlayerCombatLoadout());
 	return true;
 }
 
diff --git a/engines/harvester/inventory.h b/engines/harvester/inventory.h
index ff0d2ca9b0c..2af4e5c61ca 100644
--- a/engines/harvester/inventory.h
+++ b/engines/harvester/inventory.h
@@ -63,7 +63,7 @@ public:
 	Common::String resolveSelectedLabel() const;
 	Common::String buildSelectedPrompt(const Common::String &targetLabel) const;
 	void selectItem(const Common::String &objectName);
-	bool toggleCombatLoadout(const StartupObjectRecord &object, bool &changed);
+	bool toggleCombatLoadout(const StartupObjectRecord &object, int currentLoadout, bool &changed);
 	bool resolveSecondaryAction(const StartupObjectRecord &object, InventorySecondaryAction &action) const;
 	void setPromptText(const Common::String &promptText);
 	const Common::String &getPromptText() const;


Commit: 0ddf63d87385486526bbfbcd178e1399ef8781b6
    https://github.com/scummvm/scummvm/commit/0ddf63d87385486526bbfbcd178e1399ef8781b6
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:42+02:00

Commit Message:
HARVESTER: Restore Karin dialogue head variants

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/karin_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 7aafe5e05c7..0a2d3ecb3b8 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -493,6 +493,11 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - Response `1` plays `0x3204` then `0x320b`, both as `PTA_MOM1` with head variant `2`.
     - Response `2` plays `0x320f` then `0x3213`, both as `PTA_MOM1` with head variant `0`.
     - Response `3` plays `0x3217`, `0x321c`, `0x3221`, and `0x3226`, where `0x3221` is the only `PC` line and the others stay on `PTA_MOM1` head variant `0`.
+  - `handle_talk_to_karin @ 0x2ea90` is now bounded enough for a direct startup-room port.
+    - `KARIN_FOUND_ALIVE` plus `IN_CEM10` drives a one-shot opener: Karin first triggers `PC 0x10a5` with head variant `4`, then falls through into the normal item/no-item handling.
+    - The photo evidence branch is compact and local-state driven: `CASKET_PHOTO`, `CASKET_PHOTOCOPY`, and `PHOTO_OF_WHALEY_HERRILL` all answer with `KARIN 0x10e3` on head variant `4`, while the clear `g_karin_photo_reply_override_flag` path also records the shared corpse-photo or Whaley/Herrill-photo evidence bit before that line.
+    - `INV_MAG` is the one special non-photo item and exits on `KARIN 0x10ee`; all other presented items fall back to `KARIN 0x10d6` with head variant `1`.
+    - The no-item tail is linear: before Karin is found alive, the first visit plays `PC 0x1076` with head variant `1`, then always ends on `KARIN 0x10dc`.
   - `handle_talk_to_dwayne @ 0x3a310` is now bounded enough to mirror directly in the engine-side room dispatcher.
     - The top-level no-item flow splits between the sheriff-in-diner intro (`0x3482` / `0x3486` / `0x348b` / `0x3490`, optional `EDNA 0x3496` when `KARIN_KIDNAPED`, then `0x349b` / `0x34ac`) and the outside-sheriff opener (`0x3245` / `0x324a` / `0x324e` / `0x3252`), which seeds the first keyword buffer from zero-based `dialog.rsp` line `0x78`.
     - The arrest path clears `ARREST_FLAG` and then dispatches a strict priority bark chain over the `PC_BUSTED_KILLED_*` flags, `IF_KILL_POTTSDAM_AT_GRAVE`, `GENERIC_BUST`, `DNALFT_PERVERT`, the `PC_HAS_GOOJF_CARD` special case, and the escalating `BUSTED_ONCE` / `BUSTED_TWICE` / `BUSTED_THIRD` lines.
diff --git a/TRACKER.md b/TRACKER.md
index a7a1f8a0f74..7aa44dec441 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 20, 2026, re-opened `handle_talk_to_karin @ 0x2ea90`, `handle_talk_to_edna @ 0x358a0`, and `handle_talk_to_dwayne @ 0x3a310` in the live `HARVEST.LE` Ghidra session to verify the native dialogue maps for KARIN, EDNA, and DWAYNE against the current ScummVM handlers.
+- Confirmed Edna and Dwayne already match the existing recovered dialogue-map notes in `ARCHITECTURE.md`, and bounded Karin completely from raw disassembly after the live decompiler mis-modeled `play_dialogue_line` fallthrough. The native Karin handler uses `PC 0x10a5` head variant `4`, `KARIN 0x10e3` head variant `4`, `KARIN 0x10d6` head variant `1`, `PC 0x1076` head variant `1`, and otherwise keeps the same item/no-item branching already present in ScummVM. Patched `engines/harvester/npc/karin_dialogue.cpp`, added the confirmed Karin handler notes to `ARCHITECTURE.md`, renamed the duplicate Ghidra thunk at `0x2e97a` to `thunk_play_dialogue_line`, and corrected both `thunk_play_dialogue_line @ 0x2e97a` and `play_dialogue_line @ 0x7a690` from erroneous `noreturn` metadata back to returning before saving the live `HARVEST.LE` program.
 - On March 20, 2026, re-opened `spawn_player_combat_avatar @ 0x54220`, `update_actor_runtime_state @ 0x4d750`, and `consume_weapon_ammo_and_play_fire_or_empty_sound @ 0x57910` in the live `HARVEST.LE` Ghidra session to recover where the original room right-click attacks actually trigger audio instead of guessing from the parsed monster sound fields.
 - Confirmed the native player combat avatar hardcodes its own loadout-specific attack samples: melee loadouts seed `+0x1148..+0x1150` with swoosh variants and fire them when `current_frame_index == first_frame_index + 1`, while ranged loadouts route that same timing window through the weapon fire/empty helper instead of the random attack-sound selector. Patched `engines/harvester/flow.cpp`, `engines/harvester/room.cpp`, and `engines/harvester/room_support.h` so ScummVM now mirrors that player-side timing by firing recovered loadout-specific attack sounds on the second frame of each room attack bank. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 - On March 20, 2026, re-opened the Whaley/Herrill broom-closet reward continuation in raw `HARVEST.LE` code around `0x23fb9..0x240fb` after a live `SCHLHL` repro showed ScummVM never granting the promised baseball bat after response line `0x24c` option `2`.
@@ -138,6 +140,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run a live Harvester validation pass on KARIN, EDNA, and DWAYNE. Verify Karin now uses the recovered portrait variants on `0x10a5`, `0x10e3`, `0x10d6`, and `0x1076`, and confirm Edna/Dwayne still match the previously recovered intro, evidence, and keyword-menu maps after the Ghidra cleanup.
 1. Run a live Harvester validation pass on room right-click attacks with both bare hands and equipped weapons: confirm the player still swaps to the correct `PC0/PC01..PC20` art after inventory weapon toggles, and that attacks now play the recovered second-frame sound timing for unarmed swings, melee weapons, firearms, and the chainsaw.
 2. If any player loadout still sounds wrong in that pass, re-open `spawn_player_combat_avatar @ 0x54220` and the frame-commit logic around `0x52762..0x52993` to mirror any still-missing special cases such as the firearm secondary timing branch or the chainsaw empty/fuel path exactly.
 3. Run a live Harvester validation pass in `SCHLHL` covering both Whaley/Herrill acceptance paths: after taking the broom-closet photo, choose response line `0x24c` option `2` and the nested `0x24d` option `1`, and confirm both paths now add `BAT`, hide `PHOTO_OF_WHALEY_HERRILL` from the inventory strip, clear `BUSTED_SCREWING_MIDGAME`, and still unwind the broom-door state through `REVERSE_BROOM` when that flag is set.
diff --git a/engines/harvester/npc/karin_dialogue.cpp b/engines/harvester/npc/karin_dialogue.cpp
index 424b4a2a2a1..d2a8fb92a11 100644
--- a/engines/harvester/npc/karin_dialogue.cpp
+++ b/engines/harvester/npc/karin_dialogue.cpp
@@ -35,12 +35,18 @@ bool KarinDialogueHandler::matchesNpc(const Common::String &npcName) const {
 Common::Error KarinDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &) {
 	KarinRoomDialogueState &state = _state;
+	auto playKarinLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "KARIN", headVariant);
+	};
+	auto playPcLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "PC", headVariant);
+	};
 
 	const bool karinFoundAlive = runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE");
 	if (karinFoundAlive && runtime.startupScript().getFlagValue("IN_CEM10") &&
 			!state.cem10AliveLinePlayed) {
 		state.cem10AliveLinePlayed = true;
-		Common::Error lineError = runtime.playDialogueLine(0x10a5, "PC");
+		Common::Error lineError = playPcLine(0x10a5, 4);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
@@ -53,26 +59,26 @@ Common::Error KarinDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
 					(void)runtime.startupScript().setRuntimeFlagValue(
 						DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-				} else {
-					(void)runtime.startupScript().setRuntimeFlagValue(
-						DialogueFlags::kShownPhotoOfCorpse, true);
+					} else {
+						(void)runtime.startupScript().setRuntimeFlagValue(
+							DialogueFlags::kShownPhotoOfCorpse, true);
+					}
 				}
-			}
-			return runtime.playDialogueLine(0x10e3, "KARIN");
+			return playKarinLine(0x10e3, 4);
 		}
 		if (usedItemName.equalsIgnoreCase("INV_MAG"))
-			return runtime.playDialogueLine(0x10ee, "KARIN");
-		return runtime.playDialogueLine(0x10d6, "KARIN");
+			return playKarinLine(0x10ee);
+		return playKarinLine(0x10d6, 1);
 	}
 
 	if (!karinFoundAlive && !state.notFoundAliveLinePlayed) {
 		state.notFoundAliveLinePlayed = true;
-		Common::Error lineError = runtime.playDialogueLine(0x1076, "PC");
+		Common::Error lineError = playPcLine(0x1076, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 
-	return runtime.playDialogueLine(0x10dc, "KARIN");
+	return playKarinLine(0x10dc);
 }
 
 } // End of namespace Harvester


Commit: 652b43bdbb5e1b070091c698b024131f622ab8c6
    https://github.com/scummvm/scummvm/commit/652b43bdbb5e1b070091c698b024131f622ab8c6
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:42+02:00

Commit Message:
HARVESTER: Extract room player and monster logic

Changed paths:
  A engines/harvester/room_monster.cpp
  A engines/harvester/room_monster.h
  A engines/harvester/room_player.cpp
  A engines/harvester/room_player.h
    engines/harvester/flow.cpp
    engines/harvester/module.mk
    engines/harvester/room.cpp
    engines/harvester/room_support.h


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 729d59570bc..919f098e856 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -42,6 +42,8 @@
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
 #include "harvester/art.h"
+#include "harvester/room_monster.h"
+#include "harvester/room_player.h"
 #include "harvester/script.h"
 
 namespace Harvester {
@@ -73,22 +75,12 @@ static const int kIdentTextboxY = 177;
 static const int kIdentTextboxTextInsetX = 10;
 static const int kIdentTextboxTextInsetY = 5;
 static const char *const kPlayerActorEntityName = "PLAYER";
-static const char *const kPlayerActorResourcePath = "1:/GRAPHIC/MONSTERS/PC/PC0.ABM";
 static const uint32 kPaletteFadeTickMs = 4;
 static const float kPaletteFadeStep = 0.1f;
 static const float kPaletteBrightnessBlack = 0.0f;
 // Native keeps WAIT_BM visible while room construction runs; fast local loads need a short floor.
 static const uint32 kTransitionWaitFrameMinMs = 120;
-static const float kRoomPlayerHorizontalMoveBase = 8.0f;
-static const int kRoomRegionTargetXBias = 10;
-static const float kRoomDepthCompareEpsilon = 0.01f;
-static const int kRoomPlayerWalkAnimationRate = 17;
-static const int kRoomPlayerAttackAnimationRate = kRoomPlayerWalkAnimationRate;
-static const int kRoomPlayerMinOpaqueLeftX = 4;
-static const int kRoomPlayerMaxOpaqueRightX = 0x27c;
 static const int kRoomNpcAmbientLastFrame = 0x3b;
-static const float kRoomPlayerAttackUpperYOffset = 144.44f;
-static const float kRoomPlayerAttackMidYOffset = 75.36f;
 static const int kTownMapEdgeThreshold = 9;
 static const int kTownMapCursorHitExtent = 5;
 
@@ -109,79 +101,6 @@ static const int kCursorSequenceOperate = 4;
 static const int kCursorSequencePickup = 5;
 static const int kCursorSequenceTransition = 6;
 static const int kCursorSequenceNeutral = 7;
-static const char *const kPlayerIdleAnimationEntityName = "IDLE_ANIM";
-static const char *const kPlayerIdleAnimationResourcePath = "1:/GRAPHIC/ROOMANIM/PCLOUN02.ABM";
-static const uint32 kRuntimeClockDivisorMs = 10;
-static const uint32 kRoomPlayerIdleDelayTicks = 3000;
-// Native run_harvester_main_loop sets EBX=0x0e before spawning PCLOUN02.ABM.
-static const int kRoomPlayerIdleAnimationRate = 14;
-static const int kRoomPlayerIdleLoopStartFrame = 0x0f;
-static const int kRoomPlayerIdleLoopLastFrame = 0xb2;
-static const int kRoomPlayerIdleExitLastFrame = 0xbf;
-static const int kRoomPlayerIdleYOffset = 4;
-
-struct PlayerAnimationRange {
-	PlayerAnimationRange() {}
-	PlayerAnimationRange(int walkFirstFrame, int walkLastFrame, int idleFrame)
-		: walkFirstFrame(walkFirstFrame), walkLastFrame(walkLastFrame), idleFrame(idleFrame) {}
-
-	int walkFirstFrame = 0;
-	int walkLastFrame = 0;
-	int idleFrame = 0;
-};
-
-struct PlayerTurnAnimationRange {
-	PlayerTurnAnimationRange() {}
-	PlayerTurnAnimationRange(int firstFrame, int lastFrame, bool playBackwards)
-		: firstFrame(firstFrame), lastFrame(lastFrame), playBackwards(playBackwards) {}
-
-	int firstFrame = 0;
-	int lastFrame = 0;
-	bool playBackwards = false;
-};
-
-struct PlayerAttackAnimationRange {
-	PlayerAttackAnimationRange() {}
-	PlayerAttackAnimationRange(int firstFrame, int lastFrame, int resumeFacing)
-		: firstFrame(firstFrame), lastFrame(lastFrame), resumeFacing(resumeFacing) {}
-
-	int firstFrame = 0;
-	int lastFrame = 0;
-	int resumeFacing = 0;
-};
-
-struct PlayerAttackSoundSet {
-	uint soundCount;
-	const char *soundPaths[3];
-};
-
-// Recovered from spawn_player_combat_avatar and update_actor_runtime_state:
-// the native room-combat path commits player attack audio on first_frame + 1.
-static const int kPlayerAttackSoundTriggerFrameOffset = 1;
-static const PlayerAttackSoundSet kPlayerAttackSoundSets[] = {
-	{ 3, { "2:/sound/effects/swoosh1.wav", "2:/sound/effects/swoosh2.wav", "2:/sound/effects/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 1, { "2:/SOUND/EFFECTS/NAILGUN1.wav", nullptr, nullptr } },
-	{ 1, { "1:/sound/effects/shotgun2.wav", nullptr, nullptr } },
-	{ 1, { "2:/SOUND/EFFECTS/gunshot.wav", nullptr, nullptr } },
-	{ 1, { "2:/SOUND/EFFECTS/gunshot.wav", nullptr, nullptr } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/sound/effects/swoosh1.wav", "2:/sound/effects/swoosh2.wav", "2:/sound/effects/swoosh3.wav" } },
-	{ 1, { "1:/sound/effects/CLN_atk0.wav", nullptr, nullptr } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh3.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
-	{ 3, { "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } }
-};
-
 } // End of anonymous namespace
 
 static int roundToInt(float value) {
@@ -395,212 +314,6 @@ static const StartupMapLocationRecord *findTownMapLocationAt(
 	return nullptr;
 }
 
-static int resolvePlayerFacingFrame(int facing) {
-	switch (facing) {
-	case 0:
-		return 0x3b;
-	case 1:
-		return 0x0e;
-	case 2:
-		return 0x2c;
-	case 3:
-		return 0x28;
-	default:
-		return 0x0e;
-	}
-}
-
-static int resolveMonsterFacingFrame(int facing) {
-	switch (facing) {
-	case 0:
-		return 0x3b;
-	case 1:
-		return 0x0e;
-	case 2:
-		return 0x2c;
-	case 3:
-		return 0x28;
-	default:
-		return 0x3b;
-	}
-}
-
-static PlayerAnimationRange resolvePlayerAnimationRange(int facing) {
-	switch (facing) {
-	case 0:
-		return PlayerAnimationRange{ 0x00, 0x09, 0x3b };
-	case 1:
-		return PlayerAnimationRange{ 0x0f, 0x18, 0x0e };
-	case 2:
-		return PlayerAnimationRange{ 0x2d, 0x36, 0x2c };
-	case 3:
-		return PlayerAnimationRange{ 0x1e, 0x27, 0x28 };
-	default:
-		return PlayerAnimationRange{ 0x00, 0x09, 0x3b };
-	}
-}
-
-static bool resolvePlayerTurnAnimationRange(int fromFacing, int toFacing, PlayerTurnAnimationRange &range) {
-	// Native desired-state transitions play these banks forward or backward depending on the source facing.
-	if (fromFacing == 0 && toFacing == 1) {
-		range = PlayerTurnAnimationRange{ 0x0a, 0x0e, false };
-		return true;
-	}
-	if (fromFacing == 1 && toFacing == 0) {
-		range = PlayerTurnAnimationRange{ 0x0a, 0x0e, true };
-		return true;
-	}
-	if (fromFacing == 1 && toFacing == 3) {
-		range = PlayerTurnAnimationRange{ 0x19, 0x1d, false };
-		return true;
-	}
-	if (fromFacing == 3 && toFacing == 1) {
-		range = PlayerTurnAnimationRange{ 0x19, 0x1d, true };
-		return true;
-	}
-	if (fromFacing == 3 && toFacing == 2) {
-		range = PlayerTurnAnimationRange{ 0x28, 0x2c, false };
-		return true;
-	}
-	if (fromFacing == 2 && toFacing == 3) {
-		range = PlayerTurnAnimationRange{ 0x28, 0x2c, true };
-		return true;
-	}
-	if (fromFacing == 2 && toFacing == 0) {
-		range = PlayerTurnAnimationRange{ 0x37, 0x3b, false };
-		return true;
-	}
-	if (fromFacing == 0 && toFacing == 2) {
-		range = PlayerTurnAnimationRange{ 0x37, 0x3b, true };
-		return true;
-	}
-
-	return false;
-}
-
-static Common::String resolvePlayerCombatLoadoutResourcePath(int loadout) {
-	if (loadout <= 0)
-		return Common::String(kPlayerActorResourcePath);
-
-	return Common::String::format("1:/GRAPHIC/MONSTERS/PC/PC%02d.ABM", loadout);
-}
-
-static const PlayerAttackSoundSet *resolvePlayerAttackSoundSet(int loadout) {
-	if (loadout < 0 || loadout >= (int)ARRAYSIZE(kPlayerAttackSoundSets))
-		return nullptr;
-
-	if (kPlayerAttackSoundSets[loadout].soundCount == 0)
-		return nullptr;
-
-	return &kPlayerAttackSoundSets[loadout];
-}
-
-static bool playPlayerAttackSound(HarvesterEngine &engine, int loadout) {
-	const PlayerAttackSoundSet *soundSet = resolvePlayerAttackSoundSet(loadout);
-	if (!soundSet)
-		return false;
-
-	uint soundIndex = 0;
-	if (soundSet->soundCount > 1)
-		soundIndex = engine.getRandomNumber(soundSet->soundCount - 1);
-
-	const char *soundPath = soundSet->soundPaths[soundIndex];
-	return soundPath && engine.playStartupSound(soundPath);
-}
-
-static float computeActorDepthScale(const StartupRoomSetupState &state, float z);
-
-static bool resolvePlayerAttackAnimationRange(const StartupRoomSetupState &state,
-		const StartupRoomPlayerState &playerState, const Common::Point &mousePos,
-		PlayerAttackAnimationRange &range) {
-	if (!playerState.entity)
-		return false;
-
-	const float depthScale = computeActorDepthScale(state, playerState.z);
-	const float upperThresholdY =
-		(float)playerState.bottomY - depthScale * kRoomPlayerAttackUpperYOffset;
-	const float midThresholdY =
-		(float)playerState.bottomY - depthScale * kRoomPlayerAttackMidYOffset;
-
-	if (mousePos.x < playerState.centerX) {
-		if ((float)mousePos.y < upperThresholdY)
-			range = PlayerAttackAnimationRange(0x50, 0x59, 1);
-		else if ((float)mousePos.y < midThresholdY)
-			range = PlayerAttackAnimationRange(0x5a, 0x63, 1);
-		else
-			range = PlayerAttackAnimationRange(0x64, 0x6d, 1);
-	} else {
-		if ((float)mousePos.y < upperThresholdY)
-			range = PlayerAttackAnimationRange(0x6e, 0x77, 2);
-		else if ((float)mousePos.y < midThresholdY)
-			range = PlayerAttackAnimationRange(0x78, 0x81, 2);
-		else
-			range = PlayerAttackAnimationRange(0x82, 0x8b, 2);
-	}
-
-	return true;
-}
-
-static bool roomSupportsMovementBand(const StartupRoomSetupState &state) {
-	return state.roomMaxZScreenY >= 0 &&
-		state.roomMinZScreenY >= state.roomMaxZScreenY;
-}
-
-static int clampRoomMovementY(const StartupRoomSetupState &state, int screenY) {
-	if (!roomSupportsMovementBand(state))
-		return screenY;
-
-	return CLIP<int>(screenY, state.roomMaxZScreenY, state.roomMinZScreenY);
-}
-
-static float mapRoomScreenYToDepth(const StartupRoomSetupState &state, int screenY) {
-	if (!roomSupportsMovementBand(state))
-		return (float)state.playerSpawnZ;
-
-	if (screenY <= state.roomMaxZScreenY)
-		return (float)state.roomMaxZ;
-	if (screenY >= state.roomMinZScreenY)
-		return (float)state.roomMinZ;
-
-	return (float)(state.roomMaxZ - ((state.roomMaxZ - state.roomMinZ) *
-		(screenY - state.roomMaxZScreenY)) /
-		(state.roomMinZScreenY - state.roomMaxZScreenY));
-}
-
-static float clampRoomDepth(const StartupRoomSetupState &state, float z) {
-	if (!roomSupportsMovementBand(state))
-		return z;
-
-	return CLIP<float>(z,
-		(float)MIN(state.roomMinZ, state.roomMaxZ),
-		(float)MAX(state.roomMinZ, state.roomMaxZ));
-}
-
-static int mapRoomDepthToScreenY(const StartupRoomSetupState &state, float z) {
-	if (!roomSupportsMovementBand(state))
-		return state.playerSpawnY;
-
-	if (state.roomMaxZ == state.roomMinZ)
-		return clampRoomMovementY(state, state.playerSpawnY);
-
-	const float clampedZ = clampRoomDepth(state, z);
-	const float offset = ((float)state.roomMaxZ - clampedZ) *
-		(float)(state.roomMinZScreenY - state.roomMaxZScreenY) /
-		(float)(state.roomMaxZ - state.roomMinZ);
-	return clampRoomMovementY(state, state.roomMaxZScreenY + roundToInt(offset));
-}
-
-static float computeActorDepthScale(const StartupRoomSetupState &state, float z) {
-	float scale = 1.0f;
-	if (state.roomPerspectiveScale != 0.0f) {
-		scale -= (z - (float)state.roomFullScaleZ) * state.roomPerspectiveScale;
-		if (scale <= 0.0f)
-			scale = 1.0f;
-	}
-
-	return scale;
-}
-
 static void setRoomActorScreenPosition(RuntimeEntity &entity, int centerX, int bottomY, float z,
 		int width, int height, int xOffset, int yOffset) {
 	entity.setPosition(centerX - xOffset - width / 2, bottomY - height - yOffset, z);
@@ -620,7 +333,7 @@ static bool applyRoomActorPlacementInternal(const StartupRoomSetupState &state,
 
 	float depthScale = 1.0f;
 	if (applyDepthScale) {
-		depthScale = computeActorDepthScale(state, z);
+		depthScale = RoomPlayerLogic::computeDepthScale(state, z);
 		entity.setDepthScale(depthScale);
 		if (!entity.getCurrentFrameMetrics(width, height, xOffset, yOffset))
 			return false;
@@ -646,222 +359,6 @@ static bool applyStartupActorPlacement(const StartupRoomSetupState &state, Runti
 		state.playerSpawnX, state.playerSpawnY, (float)state.playerSpawnZ, &state.entranceName);
 }
 
-static int resolveFacingFromMovementDelta(int dx, int dy) {
-	if (ABS(dx) > ABS(dy))
-		return dx < 0 ? 1 : 2;
-	if (dy < 0)
-		return 3;
-	return 0;
-}
-
-static int resolveFacingFromRoomMovement(const StartupRoomSetupState &state,
-		int fromCenterX, float fromZ, int toCenterX, float toZ) {
-	return resolveFacingFromMovementDelta(
-		toCenterX - fromCenterX,
-		mapRoomDepthToScreenY(state, toZ) - mapRoomDepthToScreenY(state, fromZ));
-}
-
-static int computeRoomPlayerHorizontalStep(const StartupRoomSetupState &state, float z) {
-	return MAX<int>(1, roundToInt(computeActorDepthScale(state, z) * kRoomPlayerHorizontalMoveBase));
-}
-
-static float computeRoomPlayerDepthStep(const StartupRoomSetupState &state) {
-	return state.roomZVelocityStep > 0.0f ? state.roomZVelocityStep : 1.0f;
-}
-
-static int clampPlayerCenterXToNativeBounds(const StartupRoomPlayerState &playerState, int centerX) {
-	if (!playerState.entity)
-		return CLIP<int>(centerX, 0, 639);
-
-	int width = 0;
-	int height = 0;
-	int xOffset = 0;
-	int yOffset = 0;
-	if (!playerState.entity->getCurrentFrameMetrics(width, height, xOffset, yOffset))
-		return CLIP<int>(centerX, 0, 639);
-
-	const int minCenterX = kRoomPlayerMinOpaqueLeftX + width / 2;
-	const int maxCenterX = MAX(minCenterX,
-		kRoomPlayerMaxOpaqueRightX - (width - width / 2));
-	return CLIP<int>(centerX, minCenterX, maxCenterX);
-}
-
-uint32 getRuntimeClockTicks();
-
-static uint32 computeAnimationTickInterval(int rate) {
-	return rate > 0 ? MAX<uint32>(1, 100U / (uint32)rate) : 0;
-}
-
-static bool consumePlayerMovementTick(StartupRoomPlayerState &playerState) {
-	const uint32 interval = computeAnimationTickInterval(kRoomPlayerWalkAnimationRate);
-	if (interval == 0) {
-		playerState.nextMovementTick = 0;
-		return true;
-	}
-
-	const uint32 now = getRuntimeClockTicks();
-	if (playerState.nextMovementTick != 0 &&
-			(int32)(now - playerState.nextMovementTick) < 0) {
-		return false;
-	}
-
-	playerState.nextMovementTick = now + interval;
-	return true;
-}
-
-static int stepTowardsInt(int current, int target, int step) {
-	if (step <= 0 || current == target)
-		return current;
-	if (current < target)
-		return MIN(current + step, target);
-	return MAX(current - step, target);
-}
-
-static float stepTowardsFloat(float current, float target, float step) {
-	if (step <= 0.0f || fabsf(target - current) <= kRoomDepthCompareEpsilon)
-		return target;
-	if (current < target)
-		return MIN(current + step, target);
-	return MAX(current - step, target);
-}
-
-static bool doesPlayerOverlapRoomBlocker(HarvesterEngine &engine,
-		const Common::Array<StartupObjectRecord> &sceneObjects,
-		const Common::Array<StartupAnimRecord> &sceneAnimations, const RuntimeEntity &playerEntity) {
-	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
-	if (!runtimeEntities)
-		return false;
-
-	for (const StartupObjectRecord &object : sceneObjects) {
-		const RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(object.objectName);
-		if (entity && playerEntity.overlapsEntity(*entity))
-			return true;
-	}
-
-	for (const StartupAnimRecord &anim : sceneAnimations) {
-		const RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(anim.animName);
-		if (entity && playerEntity.overlapsEntity(*entity))
-			return true;
-	}
-
-	return false;
-}
-
-static bool isPlayerMovementBlocked(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		const Common::Array<StartupObjectRecord> &sceneObjects,
-		const Common::Array<StartupAnimRecord> &sceneAnimations,
-		StartupRoomPlayerState &playerState, int candidateCenterX, float candidateZ) {
-	if (!playerState.entity)
-		return true;
-
-	const int oldCenterX = playerState.centerX;
-	const int oldBottomY = playerState.bottomY;
-	const float oldZ = playerState.z;
-	const int candidateBottomY = mapRoomDepthToScreenY(state, candidateZ);
-	if (!applyRoomActorPlacement(state, *playerState.entity,
-			candidateCenterX, candidateBottomY, candidateZ)) {
-		(void)applyRoomActorPlacement(state, *playerState.entity, oldCenterX, oldBottomY, oldZ);
-		return true;
-	}
-
-	const bool blocked = doesPlayerOverlapRoomBlocker(engine, sceneObjects, sceneAnimations, *playerState.entity);
-	(void)applyRoomActorPlacement(state, *playerState.entity, oldCenterX, oldBottomY, oldZ);
-	return blocked;
-}
-
-static bool tryApplyPlayerMovement(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		const Common::Array<StartupObjectRecord> &sceneObjects,
-		const Common::Array<StartupAnimRecord> &sceneAnimations,
-		StartupRoomPlayerState &playerState, int candidateCenterX, float candidateZ) {
-	if (!playerState.entity)
-		return false;
-
-	candidateCenterX = clampPlayerCenterXToNativeBounds(playerState, candidateCenterX);
-	candidateZ = clampRoomDepth(state, candidateZ);
-	if (candidateCenterX == playerState.centerX &&
-			fabsf(candidateZ - playerState.z) <= kRoomDepthCompareEpsilon) {
-		return false;
-	}
-	if (isPlayerMovementBlocked(engine, state, sceneObjects, sceneAnimations,
-			playerState, candidateCenterX, candidateZ)) {
-		return false;
-	}
-
-	playerState.centerX = candidateCenterX;
-	playerState.z = candidateZ;
-	playerState.bottomY = mapRoomDepthToScreenY(state, candidateZ);
-	return applyRoomActorPlacement(state, *playerState.entity,
-		playerState.centerX, playerState.bottomY, playerState.z);
-}
-
-void setPlayerMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
-		int targetX, float targetZ) {
-	playerState.hasMoveTarget = true;
-	playerState.nextMovementTick = 0;
-	playerState.targetX = clampPlayerCenterXToNativeBounds(playerState, targetX);
-	playerState.targetZ = clampRoomDepth(state, targetZ);
-	debugC(1, kDebugScene,
-		"Harvester: player move target room='%s' current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f)",
-		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
-		playerState.targetX, mapRoomDepthToScreenY(state, playerState.targetZ), (double)playerState.targetZ);
-}
-
-void setPlayerMoveTargetFromScreenPoint(const StartupRoomSetupState &state,
-		StartupRoomPlayerState &playerState, int targetX, int targetBottomY) {
-	setPlayerMoveTarget(state, playerState, targetX,
-		mapRoomScreenYToDepth(state, clampRoomMovementY(state, targetBottomY)));
-}
-
-int resolveRegionTargetX(const StartupRegionRecord &region, const StartupRoomPlayerState &playerState) {
-	const Common::Rect bounds(region.left, region.top, region.right + 1, region.bottom + 1);
-	int targetX = bounds.left + bounds.width() / 2;
-	if (playerState.entity && targetX + kRoomRegionTargetXBias < playerState.entity->getScreenRect().right)
-		targetX -= kRoomRegionTargetXBias;
-	return CLIP<int>(targetX, 0, 639);
-}
-
-float resolveRegionTargetZ(const StartupRegionRecord &region) {
-	if (region.desiredFacing == 0)
-		return (float)region.maxZ;
-	if (region.desiredFacing == 3)
-		return (float)region.minZ;
-
-	return (float)(region.minZ + (region.maxZ - region.minZ) / 2);
-}
-
-uint32 getRuntimeClockTicks() {
-	return g_system ? (g_system->getMillis() / kRuntimeClockDivisorMs) : 0;
-}
-
-bool isIdleAnimationExcludedRoom(const Common::String &roomName) {
-	static const char *const kExcludedRooms[] = {
-		"JAWS",
-		"SUPPLY1",
-		"SUPPLY2",
-		"SUPPLY3",
-		"LAVAPIT",
-		"RMNBATH",
-		"SERGENTRM",
-		"CHAP2"
-	};
-
-	for (const char *excludedRoom : kExcludedRooms) {
-		if (roomName.equalsIgnoreCase(excludedRoom))
-			return true;
-	}
-
-	return false;
-}
-
-void updatePlayerIdleTrigger(StartupRoomIdleAnimationState &idleState) {
-	idleState.triggerTick = MAX(idleState.activityTick, idleState.resetTick) + kRoomPlayerIdleDelayTicks;
-}
-
-static void notePlayerIdleReset(StartupRoomIdleAnimationState &idleState) {
-	idleState.resetTick = getRuntimeClockTicks();
-	updatePlayerIdleTrigger(idleState);
-}
-
 static void getPaletteByteRange(const byte *palette, byte &minValue, byte &maxValue) {
 	minValue = 255;
 	maxValue = 0;
@@ -1375,7 +872,7 @@ StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const Start
 		return hoverState;
 	}
 
-	if (roomSupportsMovementBand(state) &&
+	if (RoomPlayerLogic::supportsMovementBand(state) &&
 		mousePos.y >= state.roomMaxZScreenY &&
 		mousePos.y <= state.roomMinZScreenY) {
 		hoverState.cursorSequence = kCursorSequenceWalk;
@@ -1384,470 +881,6 @@ StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const Start
 	return hoverState;
 }
 
-bool setPlayerIdleAnimation(StartupRoomPlayerState &playerState, int facing) {
-	if (!playerState.entity)
-		return false;
-
-	const PlayerAnimationRange range = resolvePlayerAnimationRange(facing);
-	const bool changed = playerState.facing != facing ||
-		playerState.entity->getCurrentFrame() != range.idleFrame ||
-		playerState.entity->getAnimationRate() != 0;
-	playerState.facing = facing;
-	playerState.nextMovementTick = 0;
-	playerState.entity->setAnimationRate(0);
-	playerState.entity->setAnimationFrameRange(range.idleFrame, range.idleFrame, false);
-	playerState.entity->setCurrentFrame(range.idleFrame);
-	if (changed) {
-		debugC(1, kDebugScene,
-			"Harvester: player idle animation facing=%d frame=%d",
-			facing, range.idleFrame);
-	}
-	return changed;
-}
-
-bool syncPlayerCombatLoadoutVisual(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		StartupRoomPlayerState &playerState, int loadout) {
-	if (!playerState.entity)
-		return false;
-
-	ResourceManager *resources = engine.getResources();
-	if (!resources)
-		return false;
-
-	const int clampedLoadout = CLIP<int>(loadout, 0, 0x14);
-	if (playerState.combatLoadout == clampedLoadout)
-		return false;
-
-	const Common::String resourcePath = resolvePlayerCombatLoadoutResourcePath(clampedLoadout);
-	if (!playerState.entity->loadAbmResource(*resources, resourcePath))
-		return false;
-
-	playerState.combatLoadout = clampedLoadout;
-	playerState.attackActive = false;
-	playerState.attackFirstFrame = -1;
-	playerState.attackLastFrame = -1;
-	playerState.attackResumeFacing = -1;
-	playerState.attackSoundPlayed = false;
-	playerState.attackSoundFrame = -1;
-	(void)setPlayerIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
-	(void)applyRoomActorPlacement(state, *playerState.entity,
-		playerState.centerX, playerState.bottomY, playerState.z);
-	debugC(1, kDebugScene,
-		"Harvester: player combat loadout visual loadout=%d resource='%s' facing=%d pos=(%d,%d,z=%.2f)",
-		playerState.combatLoadout, resourcePath.c_str(), playerState.facing,
-		playerState.centerX, playerState.bottomY, (double)playerState.z);
-	return true;
-}
-
-static bool setPlayerWalkAnimation(StartupRoomPlayerState &playerState, int facing) {
-	if (!playerState.entity)
-		return false;
-
-	const PlayerAnimationRange range = resolvePlayerAnimationRange(facing);
-	const int currentFrame = playerState.entity->getCurrentFrame();
-	const bool alreadyWalking = playerState.facing == facing &&
-		playerState.entity->getAnimationRate() == kRoomPlayerWalkAnimationRate &&
-		currentFrame >= range.walkFirstFrame && currentFrame <= range.walkLastFrame;
-	if (alreadyWalking)
-		return false;
-
-	playerState.facing = facing;
-	playerState.entity->setAnimationFrameRange(range.walkFirstFrame, range.walkLastFrame, true);
-	playerState.entity->setAnimationRate(kRoomPlayerWalkAnimationRate);
-	debugC(1, kDebugScene,
-		"Harvester: player walk animation facing=%d frames=%d..%d rate=%d",
-		facing, range.walkFirstFrame, range.walkLastFrame, kRoomPlayerWalkAnimationRate);
-	return true;
-}
-
-bool startPlayerAttackAnimation(const StartupRoomSetupState &state,
-		StartupRoomPlayerState &playerState, const Common::Point &mousePos) {
-	if (!playerState.entity || playerState.attackActive)
-		return false;
-
-	PlayerAttackAnimationRange range;
-	if (!resolvePlayerAttackAnimationRange(state, playerState, mousePos, range))
-		return false;
-
-	playerState.hasMoveTarget = false;
-	playerState.turnActive = false;
-	playerState.turnTargetFacing = -1;
-	playerState.turnFirstFrame = -1;
-	playerState.turnLastFrame = -1;
-	playerState.turnEndFrame = -1;
-	playerState.turnPlayBackwards = false;
-	playerState.attackActive = true;
-	playerState.attackFirstFrame = range.firstFrame;
-	playerState.attackLastFrame = range.lastFrame;
-	playerState.attackResumeFacing = range.resumeFacing;
-	playerState.attackSoundPlayed = resolvePlayerAttackSoundSet(playerState.combatLoadout) == nullptr;
-	playerState.attackSoundFrame = playerState.attackSoundPlayed
-		? -1
-		: (range.firstFrame + kPlayerAttackSoundTriggerFrameOffset);
-	playerState.nextMovementTick = 0;
-	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
-	playerState.entity->setAnimationRate(kRoomPlayerAttackAnimationRate);
-	playerState.entity->setCurrentFrame(range.firstFrame);
-	debugC(1, kDebugScene,
-		"Harvester: player attack animation frames=%d..%d resume_facing=%d cursor=(%d,%d)",
-		range.firstFrame, range.lastFrame, range.resumeFacing, mousePos.x, mousePos.y);
-	return true;
-}
-
-bool updatePlayerAttackAnimationState(HarvesterEngine &engine, StartupRoomPlayerState &playerState) {
-	if (!playerState.attackActive || !playerState.entity)
-		return false;
-	if (!playerState.attackSoundPlayed &&
-			playerState.attackSoundFrame >= 0 &&
-			playerState.entity->getCurrentFrame() >= playerState.attackSoundFrame) {
-		(void)playPlayerAttackSound(engine, playerState.combatLoadout);
-		playerState.attackSoundPlayed = true;
-	}
-	if (playerState.entity->getCurrentFrame() != playerState.attackLastFrame)
-		return false;
-
-	const int resumeFacing = playerState.attackResumeFacing >= 0
-		? playerState.attackResumeFacing
-		: (playerState.facing >= 0 ? playerState.facing : 0);
-	playerState.attackActive = false;
-	playerState.attackFirstFrame = -1;
-	playerState.attackLastFrame = -1;
-	playerState.attackResumeFacing = -1;
-	playerState.attackSoundPlayed = false;
-	playerState.attackSoundFrame = -1;
-	return setPlayerIdleAnimation(playerState, resumeFacing);
-}
-
-bool startPlayerTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing) {
-	if (!playerState.entity || playerState.facing < 0 || playerState.facing == targetFacing)
-		return false;
-
-	PlayerTurnAnimationRange range;
-	if (!resolvePlayerTurnAnimationRange(playerState.facing, targetFacing, range))
-		return false;
-
-	playerState.turnActive = true;
-	playerState.turnTargetFacing = targetFacing;
-	playerState.turnFirstFrame = range.firstFrame;
-	playerState.turnLastFrame = range.lastFrame;
-	playerState.turnEndFrame = range.playBackwards ? range.firstFrame : range.lastFrame;
-	playerState.turnPlayBackwards = range.playBackwards;
-	playerState.nextMovementTick = 0;
-	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
-	playerState.entity->setPlayBackwards(range.playBackwards);
-	playerState.entity->setAnimationRate(kRoomPlayerWalkAnimationRate);
-	playerState.entity->setCurrentFrame(range.playBackwards ? range.lastFrame : range.firstFrame);
-	debugC(1, kDebugScene,
-		"Harvester: player turn animation from=%d to=%d frames=%d..%d backwards=%d rate=%d",
-		playerState.facing, targetFacing, range.firstFrame, range.lastFrame,
-		range.playBackwards, kRoomPlayerWalkAnimationRate);
-	return true;
-}
-
-bool updatePlayerTurnAnimationState(StartupRoomPlayerState &playerState) {
-	if (!playerState.turnActive || !playerState.entity)
-		return false;
-	if (playerState.entity->getCurrentFrame() != playerState.turnEndFrame)
-		return false;
-
-	playerState.turnActive = false;
-	playerState.facing = playerState.turnTargetFacing;
-	playerState.turnTargetFacing = -1;
-	playerState.turnFirstFrame = -1;
-	playerState.turnLastFrame = -1;
-	playerState.turnEndFrame = -1;
-	playerState.turnPlayBackwards = false;
-	playerState.entity->setAnimationRate(0);
-	debugC(1, kDebugScene,
-		"Harvester: player turn complete facing=%d frame=%d",
-		playerState.facing, playerState.entity->getCurrentFrame());
-	return true;
-}
-
-bool stepPlayerMoveTarget(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		const Common::Array<StartupObjectRecord> &sceneObjects,
-		const Common::Array<StartupAnimRecord> &sceneAnimations,
-		StartupRoomPlayerState &playerState) {
-	if (!playerState.entity || !playerState.hasMoveTarget || playerState.turnActive)
-		return false;
-
-	if (playerState.centerX == playerState.targetX &&
-			fabsf(playerState.z - playerState.targetZ) <= kRoomDepthCompareEpsilon) {
-		playerState.hasMoveTarget = false;
-		return setPlayerIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
-	}
-
-	const int previousCenterX = playerState.centerX;
-	const float previousZ = playerState.z;
-	const int targetBottomY = mapRoomDepthToScreenY(state, playerState.targetZ);
-	const int moveFacing = resolveFacingFromMovementDelta(
-		playerState.targetX - playerState.centerX, targetBottomY - playerState.bottomY);
-	if (!consumePlayerMovementTick(playerState))
-		return false;
-	const int horizontalStep = computeRoomPlayerHorizontalStep(state, playerState.z);
-	const float depthStep = computeRoomPlayerDepthStep(state);
-	const int candidateCenterX = stepTowardsInt(playerState.centerX, playerState.targetX, horizontalStep);
-	const float candidateZ = stepTowardsFloat(playerState.z, playerState.targetZ, depthStep);
-
-	bool moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-		playerState, candidateCenterX, candidateZ);
-	if (!moved && candidateCenterX != previousCenterX) {
-		moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-			playerState, candidateCenterX, previousZ);
-	}
-	if (!moved && fabsf(candidateZ - previousZ) > kRoomDepthCompareEpsilon) {
-		moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-			playerState, previousCenterX, candidateZ);
-	}
-	if (!moved) {
-		playerState.hasMoveTarget = false;
-		return setPlayerIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : moveFacing);
-	}
-
-	const int actualFacing = resolveFacingFromRoomMovement(
-		state, previousCenterX, previousZ, playerState.centerX, playerState.z);
-	(void)setPlayerWalkAnimation(playerState, actualFacing);
-	if (playerState.centerX == playerState.targetX &&
-			fabsf(playerState.z - playerState.targetZ) <= kRoomDepthCompareEpsilon) {
-		playerState.bottomY = mapRoomDepthToScreenY(state, playerState.targetZ);
-		playerState.z = playerState.targetZ;
-		playerState.hasMoveTarget = false;
-		(void)setPlayerIdleAnimation(playerState, actualFacing);
-	}
-	debugC(playerState.hasMoveTarget ? 2 : 1, kDebugScene,
-		"Harvester: player move step room='%s' pos=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f) facing=%d frame=%d active=%d moved=%d",
-		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
-		playerState.targetX, mapRoomDepthToScreenY(state, playerState.targetZ), (double)playerState.targetZ,
-		playerState.facing, playerState.entity->getCurrentFrame(), playerState.hasMoveTarget, moved);
-	return true;
-}
-
-bool stepPlayerKeyboardMovement(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		const Common::Array<StartupObjectRecord> &sceneObjects,
-		const Common::Array<StartupAnimRecord> &sceneAnimations,
-		StartupRoomPlayerState &playerState, bool moveLeft, bool moveRight, bool moveUp, bool moveDown) {
-	if (!playerState.entity || !roomSupportsMovementBand(state))
-		return false;
-
-	const int horizontalInput = (moveRight ? 1 : 0) - (moveLeft ? 1 : 0);
-	const int verticalInput = (moveDown ? 1 : 0) - (moveUp ? 1 : 0);
-	if (horizontalInput == 0 && verticalInput == 0)
-		return false;
-	if (playerState.turnActive)
-		return false;
-
-	const int previousCenterX = playerState.centerX;
-	const float previousZ = playerState.z;
-	const int horizontalStep = computeRoomPlayerHorizontalStep(state, playerState.z);
-	const float depthStep = computeRoomPlayerDepthStep(state);
-	int candidateCenterX = clampPlayerCenterXToNativeBounds(
-		playerState, playerState.centerX + horizontalInput * horizontalStep);
-	float candidateZ = playerState.z;
-	if (verticalInput != 0) {
-		const float positiveZ = clampRoomDepth(state, playerState.z + depthStep);
-		const float negativeZ = clampRoomDepth(state, playerState.z - depthStep);
-		const int positiveY = mapRoomDepthToScreenY(state, positiveZ);
-		const int negativeY = mapRoomDepthToScreenY(state, negativeZ);
-		if (verticalInput < 0) {
-			if (positiveY < playerState.bottomY)
-				candidateZ = positiveZ;
-			else if (negativeY < playerState.bottomY)
-				candidateZ = negativeZ;
-		} else {
-			if (positiveY > playerState.bottomY)
-				candidateZ = positiveZ;
-			else if (negativeY > playerState.bottomY)
-				candidateZ = negativeZ;
-		}
-	}
-	if (candidateCenterX == playerState.centerX &&
-			fabsf(candidateZ - playerState.z) <= kRoomDepthCompareEpsilon) {
-		return false;
-	}
-
-	const int desiredFacing = resolveFacingFromRoomMovement(
-		state, playerState.centerX, playerState.z, candidateCenterX, candidateZ);
-	if (desiredFacing != playerState.facing && startPlayerTurnAnimation(playerState, desiredFacing))
-		return true;
-
-	if (!consumePlayerMovementTick(playerState))
-		return false;
-
-	playerState.hasMoveTarget = false;
-	bool moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-		playerState, candidateCenterX, candidateZ);
-	if (!moved && candidateCenterX != previousCenterX) {
-		moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-			playerState, candidateCenterX, previousZ);
-	}
-	if (!moved && fabsf(candidateZ - previousZ) > kRoomDepthCompareEpsilon) {
-		moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-			playerState, previousCenterX, candidateZ);
-	}
-	if (!moved)
-		return false;
-
-	const int actualFacing = resolveFacingFromRoomMovement(
-		state, previousCenterX, previousZ, playerState.centerX, playerState.z);
-	(void)setPlayerWalkAnimation(playerState, actualFacing);
-	debugC(2, kDebugScene,
-		"Harvester: player keyboard move room='%s' input=(%d,%d) pos=(%d,%d,z=%.2f) facing=%d frame=%d moved=%d",
-		state.roomName.c_str(), horizontalInput, verticalInput,
-		playerState.centerX, playerState.bottomY, (double)playerState.z,
-		playerState.facing, playerState.entity->getCurrentFrame(), moved);
-	return true;
-}
-
-static void positionPlayerIdleAnimationEntity(const StartupRoomSetupState &state,
-		const StartupRoomPlayerState &playerState, RuntimeEntity &idleEntity) {
-	if (!playerState.entity)
-		return;
-
-	idleEntity.setAnchorMode(kRuntimeEntityAnchorTopLeft);
-	idleEntity.setDepthScale(computeActorDepthScale(state, playerState.z));
-	idleEntity.setPosition(playerState.entity->getX(),
-		playerState.entity->getY() + kRoomPlayerIdleYOffset, playerState.z);
-}
-
-static RuntimeEntity *ensurePlayerIdleAnimationEntity(HarvesterEngine &engine,
-		const StartupRoomSetupState &state, const StartupRoomPlayerState &playerState,
-		StartupRoomIdleAnimationState &idleState) {
-	if (idleState.entity || !playerState.entity)
-		return idleState.entity;
-
-	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
-	if (!runtimeEntities)
-		return nullptr;
-
-	idleState.entity = runtimeEntities->spawnSceneAnimationEntity(
-		kPlayerIdleAnimationEntityName, kPlayerIdleAnimationResourcePath,
-		Common::Point(playerState.entity->getX(),
-			playerState.entity->getY() + kRoomPlayerIdleYOffset),
-		playerState.z, 0, false, false, false, false, false);
-	if (!idleState.entity)
-		return nullptr;
-
-	positionPlayerIdleAnimationEntity(state, playerState, *idleState.entity);
-	idleState.entity->setVisible(false);
-	debugC(1, kDebugScene,
-		"Harvester: spawned player idle animation '%s' room='%s' frames=0..%d",
-		kPlayerIdleAnimationResourcePath, state.roomName.c_str(), idleState.entity->getLastFrame());
-	return idleState.entity;
-}
-
-static bool finishPlayerIdleAnimation(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
-		StartupRoomIdleAnimationState &idleState) {
-	if (!idleState.active)
-		return false;
-
-	if (idleState.entity) {
-		idleState.entity->setVisible(false);
-		idleState.entity->setAnimationRate(0);
-		idleState.entity->setCurrentFrame(0);
-		positionPlayerIdleAnimationEntity(state, playerState, *idleState.entity);
-	}
-	if (playerState.entity) {
-		playerState.entity->setVisible(true);
-		if (idleState.restoreFacing >= 0)
-			(void)setPlayerIdleAnimation(playerState, idleState.restoreFacing);
-	}
-
-	idleState.active = false;
-	idleState.loopStarted = false;
-	idleState.exiting = false;
-	idleState.restoreFacing = -1;
-	notePlayerIdleReset(idleState);
-	debugC(1, kDebugScene,
-		"Harvester: player idle animation finished room='%s' facing=%d",
-		state.roomName.c_str(), playerState.facing);
-	return true;
-}
-
-bool requestPlayerIdleAnimationExit(const StartupRoomSetupState &state,
-		StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState) {
-	if (!idleState.active || idleState.exiting || !idleState.entity)
-		return false;
-
-	const int lastFrame = idleState.entity->getLastFrame();
-	const int exitStartFrame = MIN(lastFrame, kRoomPlayerIdleLoopLastFrame);
-	const int exitLastFrame = MIN(lastFrame, kRoomPlayerIdleExitLastFrame);
-	if (exitLastFrame <= exitStartFrame)
-		return finishPlayerIdleAnimation(state, playerState, idleState);
-
-	positionPlayerIdleAnimationEntity(state, playerState, *idleState.entity);
-	idleState.exiting = true;
-	idleState.entity->setAnimationFrameRange(exitStartFrame, exitLastFrame, false);
-	idleState.entity->setCurrentFrame(exitStartFrame);
-	idleState.entity->setAnimationRate(kRoomPlayerIdleAnimationRate);
-	debugC(1, kDebugScene,
-		"Harvester: player idle animation exit room='%s' frames=%d..%d rate=%d",
-		state.roomName.c_str(), exitStartFrame, exitLastFrame, kRoomPlayerIdleAnimationRate);
-	return true;
-}
-
-bool startPlayerIdleAnimation(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState) {
-	if (!playerState.entity || idleState.active || isIdleAnimationExcludedRoom(state.roomName))
-		return false;
-
-	RuntimeEntity *idleEntity = ensurePlayerIdleAnimationEntity(engine, state, playerState, idleState);
-	if (!idleEntity)
-		return false;
-
-	const int lastFrame = idleEntity->getLastFrame();
-	const int loopLastFrame = MIN(lastFrame, kRoomPlayerIdleLoopLastFrame);
-	positionPlayerIdleAnimationEntity(state, playerState, *idleEntity);
-	idleState.restoreFacing = playerState.facing;
-	idleState.active = true;
-	idleState.loopStarted = false;
-	idleState.exiting = false;
-	idleEntity->setVisible(true);
-	idleEntity->setAnimationFrameRange(0, loopLastFrame, false);
-	idleEntity->setCurrentFrame(0);
-	idleEntity->setAnimationRate(kRoomPlayerIdleAnimationRate);
-	playerState.hasMoveTarget = false;
-	playerState.turnActive = false;
-	playerState.turnTargetFacing = -1;
-	playerState.entity->setVisible(false);
-	debugC(1, kDebugScene,
-		"Harvester: player idle animation start room='%s' facing=%d frames=%d..%d rate=%d trigger_tick=%u",
-		state.roomName.c_str(), idleState.restoreFacing, 0, loopLastFrame,
-		kRoomPlayerIdleAnimationRate, idleState.triggerTick);
-	return true;
-}
-
-bool updatePlayerIdleAnimation(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
-		StartupRoomIdleAnimationState &idleState) {
-	if (!idleState.active || !idleState.entity)
-		return false;
-
-	const int lastFrame = idleState.entity->getLastFrame();
-	const int loopLastFrame = MIN(lastFrame, kRoomPlayerIdleLoopLastFrame);
-	if (!idleState.exiting && !idleState.loopStarted &&
-			idleState.entity->getCurrentFrame() >= loopLastFrame) {
-		const int loopStartFrame = MIN(lastFrame, kRoomPlayerIdleLoopStartFrame);
-		idleState.loopStarted = true;
-		if (loopStartFrame < loopLastFrame) {
-			idleState.entity->setAnimationFrameRange(loopStartFrame, loopLastFrame, true);
-			idleState.entity->setCurrentFrame(loopStartFrame);
-			idleState.entity->setAnimationRate(kRoomPlayerIdleAnimationRate);
-		}
-		debugC(1, kDebugScene,
-			"Harvester: player idle animation loop room='%s' frames=%d..%d rate=%d",
-			state.roomName.c_str(), loopStartFrame, loopLastFrame, kRoomPlayerIdleAnimationRate);
-		return true;
-	}
-
-	if (!idleState.exiting)
-		return false;
-
-	const int exitLastFrame = MIN(lastFrame, kRoomPlayerIdleExitLastFrame);
-	if (idleState.entity->getCurrentFrame() < exitLastFrame)
-		return false;
-
-	return finishPlayerIdleAnimation(state, playerState, idleState);
-}
-
 static bool findRoomObjectProbePoint(HarvesterEngine &engine, const Common::Array<StartupObjectRecord> &sceneObjects,
 		const StartupObjectRecord &object, Common::Point &probePoint) {
 	const Common::Rect bounds = getHotspotBounds(object);
@@ -1944,7 +977,7 @@ void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResource
 			hoveredObject->actionTag.c_str(), hasInteraction,
 			"", hasInspectText);
 
-		if (roomSupportsMovementBand(scene.state)) {
+		if (RoomPlayerLogic::supportsMovementBand(scene.state)) {
 			Common::Point floorProbe;
 			bool foundFloorProbe = false;
 			for (int y = scene.state.roomMaxZScreenY; y <= scene.state.roomMinZScreenY && !foundFloorProbe; y += 12) {
@@ -1977,9 +1010,10 @@ void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResource
 				probePlayer.bottomY = scene.state.playerSpawnY;
 				probePlayer.z = (float)scene.state.playerSpawnZ;
 				probePlayer.facing = scene.state.playerFacing;
-				setPlayerMoveTargetFromScreenPoint(scene.state, probePlayer, floorProbe.x, floorProbe.y);
+				RoomPlayerLogic::setMoveTargetFromScreenPoint(scene.state, probePlayer, floorProbe.x, floorProbe.y);
 				for (int i = 0; i < 32 && probePlayer.hasMoveTarget; ++i)
-					(void)stepPlayerMoveTarget(engine, scene.state, scene.sceneObjects, scene.sceneAnimations, probePlayer);
+					(void)RoomPlayerLogic::stepMoveTarget(
+						engine, scene.state, scene.sceneObjects, scene.sceneAnimations, probePlayer);
 
 				const Common::Rect movedRect = player->getScreenRect();
 				debugC(1, kDebugGeneral,
@@ -1987,7 +1021,7 @@ void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResource
 					scene.state.roomName.c_str(), floorProbe.x, floorProbe.y,
 					movedRect.left, movedRect.top, movedRect.right, movedRect.bottom, (double)probePlayer.z);
 
-				player->setCurrentFrame(resolvePlayerFacingFrame(scene.state.playerFacing));
+				player->setCurrentFrame(RoomPlayerLogic::resolveFacingFrame(scene.state.playerFacing));
 				(void)applyRoomActorPlacement(scene.state, *player,
 					scene.state.playerSpawnX, scene.state.playerSpawnY, (float)scene.state.playerSpawnZ);
 			}
@@ -2625,7 +1659,7 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 	for (const StartupMonsterRecord &monster : state.roomMonsters) {
 		RuntimeEntity *entity = runtimeEntities->spawnSceneActorEntity(monster.monsterName,
 			monster.modelPath, Common::Point(monster.posX, monster.posY), (float)monster.posZ,
-			resolveMonsterFacingFrame(monster.facing));
+			RoomMonsterLogic::resolveFacingFrame(monster.facing));
 		if (!entity) {
 			debug(1, "Harvester: unable to spawn room monster entity '%s' from '%s'",
 				monster.monsterName.c_str(), monster.modelPath.c_str());
@@ -2647,11 +1681,11 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 			monster.modelPath.c_str(), monster.active, monster.visible);
 	}
 	if (state.hasEntrance) {
-		const int playerFrame = resolvePlayerFacingFrame(state.playerFacing);
+		const int playerFrame = RoomPlayerLogic::resolveFacingFrame(state.playerFacing);
 		Script *startupScript = _engine.getStartupScript();
 		const int playerCombatLoadout = startupScript ? startupScript->getPlayerCombatLoadout() : 0;
 		const Common::String playerResourcePath =
-			resolvePlayerCombatLoadoutResourcePath(playerCombatLoadout);
+			RoomPlayerLogic::resolveCombatLoadoutResourcePath(playerCombatLoadout);
 		const bool reusedPlayer = preservedPlayer != nullptr;
 		RuntimeEntity *player = preservedPlayer;
 		if (!reusedPlayer) {
@@ -2681,7 +1715,7 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 				playerState.z = (float)state.playerSpawnZ;
 				playerState.facing = state.playerFacing;
 				playerState.combatLoadout = -1;
-				(void)syncPlayerCombatLoadoutVisual(_engine, state, playerState, playerCombatLoadout);
+				(void)RoomPlayerLogic::syncCombatLoadoutVisual(_engine, state, playerState, playerCombatLoadout);
 			}
 			if (reusedPlayer)
 				runtimeEntities->adoptSceneEntity(player);
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 159bbbf16aa..9db607ede03 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -64,6 +64,8 @@ MODULE := engines/harvester
 	flow.o \
 	inventory.o \
 	menu.o \
+	room_monster.o \
+	room_player.o \
 	room.o \
 	script.o \
 	text.o \
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 909e7f4fbb6..4a2f10fcec0 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -39,6 +39,8 @@
 #include "harvester/art.h"
 #include "harvester/flow.h"
 #include "harvester/inventory.h"
+#include "harvester/room_monster.h"
+#include "harvester/room_player.h"
 #include "harvester/room_support.h"
 
 namespace Harvester {
@@ -47,7 +49,6 @@ static const uint32 kRoomExitFastClickWindowTicks = 20;
 static const char *const kPlayerInventoryLabel = "your inventory";
 static const char *const kInventoryOwnerName = "INVENTORY";
 static const byte kTransparentPaletteIndex = 0;
-static const int kRoomMonsterAnimationRate = 17;
 static const int kNativeInventoryDragCloseLeft = 0x45;
 static const int kNativeInventoryDragCloseTop = 0x4b;
 static const int kNativeInventoryDragCloseRight = 0x239;
@@ -58,16 +59,6 @@ static const int kNativeInventoryWeekdayX = 0x1e0;
 static const int kNativeInventoryWeekdayY = 0x18c;
 static const byte kNativeInventoryTooltipColor = 0xf4;
 
-struct MonsterAnimationRange {
-	MonsterAnimationRange() {}
-	MonsterAnimationRange(int walkFirstFrame, int walkLastFrame, int idleFrame)
-		: walkFirstFrame(walkFirstFrame), walkLastFrame(walkLastFrame), idleFrame(idleFrame) {}
-
-	int walkFirstFrame = 0;
-	int walkLastFrame = 0;
-	int idleFrame = 0;
-};
-
 static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
 	return !roomName.equalsIgnoreCase("LAVAPIT") &&
 		!roomName.equalsIgnoreCase("RMNBATH") &&
@@ -272,39 +263,6 @@ static int mapRoomDepthToScreenYForEvent(const StartupRoomSetupState &state, flo
 		state.roomMaxZScreenY, state.roomMinZScreenY);
 }
 
-static MonsterAnimationRange resolveRoomMonsterAnimationRange(int facing) {
-	switch (facing) {
-	case 0:
-		return MonsterAnimationRange(0x00, 0x09, 0x3b);
-	case 1:
-		return MonsterAnimationRange(0x0f, 0x18, 0x0e);
-	case 2:
-		return MonsterAnimationRange(0x2d, 0x36, 0x2c);
-	case 3:
-		return MonsterAnimationRange(0x1e, 0x27, 0x28);
-	default:
-		return MonsterAnimationRange(0x00, 0x09, 0x3b);
-	}
-}
-
-static void applyRoomMonsterAnimation(RuntimeEntity &entity, const StartupMonsterRecord &monster) {
-	entity.setVisible(monster.visible);
-	if (!monster.visible)
-		return;
-
-	const MonsterAnimationRange range = resolveRoomMonsterAnimationRange(monster.facing);
-	if (monster.active) {
-		entity.setAnimationRate(kRoomMonsterAnimationRate);
-		entity.setAnimationFrameRange(range.walkFirstFrame, range.walkLastFrame, true);
-		entity.setAnimationEnabled(true);
-	} else {
-		entity.setAnimationRate(0);
-		entity.setAnimationFrameRange(range.idleFrame, range.idleFrame, false);
-		entity.setCurrentFrame(range.idleFrame);
-		entity.setAnimationEnabled(false);
-	}
-}
-
 RoomSystem::RoomSystem(HarvesterEngine &engine, Common::Point &mousePos,
 		InventorySystem &inventory)
 	: _engine(engine), _mousePos(mousePos), _inventory(inventory) {
@@ -385,7 +343,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			for (const StartupMonsterRecord &monster : scene.state.roomMonsters) {
 				RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(monster.monsterName);
 				if (entity)
-					applyRoomMonsterAnimation(*entity, monster);
+					RoomMonsterLogic::applyAnimation(*entity, monster);
 			}
 			runtimeEntities->pauseTimerCountdowns();
 		}
@@ -462,13 +420,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		};
 		auto resetIdleState = [&]() {
 			idleState = StartupRoomIdleAnimationState();
-			idleState.activityTick = getRuntimeClockTicks();
+			idleState.activityTick = RoomPlayerLogic::getRuntimeClockTicks();
 			idleState.resetTick = idleState.activityTick;
-			updatePlayerIdleTrigger(idleState);
+			RoomPlayerLogic::updateIdleTrigger(idleState);
 		};
 		auto notePlayerActivity = [&]() {
-			idleState.activityTick = getRuntimeClockTicks();
-			updatePlayerIdleTrigger(idleState);
+			idleState.activityTick = RoomPlayerLogic::getRuntimeClockTicks();
+			RoomPlayerLogic::updateIdleTrigger(idleState);
 		};
 		auto cancelPlayerAttackAnimation = [&]() {
 			if (!playerState.attackActive)
@@ -485,7 +443,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				? playerState.attackResumeFacing
 				: (playerState.facing >= 0 ? playerState.facing : 0);
 			playerState.attackResumeFacing = -1;
-			return setPlayerIdleAnimation(playerState, resumeFacing);
+			return RoomPlayerLogic::setIdleAnimation(playerState, resumeFacing);
 		};
 		auto syncCurrentRoomRuntimeState = [&]() {
 			Script *startupScript = _engine.getStartupScript();
@@ -534,7 +492,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			playerState.turnActive = false;
 			playerState.turnTargetFacing = -1;
 			if (playerState.entity && playerState.facing >= 0)
-				(void)setPlayerIdleAnimation(playerState, playerState.facing);
+				(void)RoomPlayerLogic::setIdleAnimation(playerState, playerState.facing);
 		};
 		auto refreshCurrentScene = [&](bool preservePlayerPlacement) {
 			const Common::Array<StartupAudioCommand> entryAudioCommands = scene.state.audioCommands;
@@ -553,7 +511,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				for (const StartupMonsterRecord &monster : scene.state.roomMonsters) {
 					RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(monster.monsterName);
 					if (entity)
-						applyRoomMonsterAnimation(*entity, monster);
+						RoomMonsterLogic::applyAnimation(*entity, monster);
 				}
 			}
 
@@ -567,7 +525,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				}
 
 				const int facing = playerState.facing >= 0 ? playerState.facing : scene.state.playerFacing;
-				(void)setPlayerIdleAnimation(playerState, facing);
+				(void)RoomPlayerLogic::setIdleAnimation(playerState, facing);
 				(void)applyRoomActorPlacement(scene.state, *playerState.entity,
 					playerState.centerX, playerState.bottomY, playerState.z);
 			}
@@ -708,7 +666,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			if (playerState.entity) {
 				const int facing = playerState.facing >= 0 ? playerState.facing : scene.state.playerFacing;
 				if (facing >= 0)
-					(void)setPlayerIdleAnimation(playerState, facing);
+					(void)RoomPlayerLogic::setIdleAnimation(playerState, facing);
 				(void)applyRoomActorPlacement(scene.state, *playerState.entity,
 					playerState.centerX, playerState.bottomY, playerState.z);
 			}
@@ -1086,8 +1044,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		if (doesPlayerOverlapRegion(*playerState.entity, region))
 			return;
 
-		setPlayerMoveTarget(scene.state, playerState,
-			resolveRegionTargetX(region, playerState), resolveRegionTargetZ(region));
+		RoomPlayerLogic::setMoveTarget(scene.state, playerState,
+			RoomPlayerLogic::resolveRegionTargetX(region, playerState),
+			RoomPlayerLogic::resolveRegionTargetZ(region));
 	};
 	auto runRegionInteraction = [&](const StartupRegionRecord &region) -> Common::Error {
 		StartupInteractionResult interaction;
@@ -1121,7 +1080,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			return Common::kNoError;
 		if (!doesPlayerFacingMatchRegion(playerState.facing, *region)) {
 			if (!playerState.hasMoveTarget && !playerState.turnActive && region->desiredFacing >= 0)
-				(void)startPlayerTurnAnimation(playerState, region->desiredFacing);
+				(void)RoomPlayerLogic::startTurnAnimation(playerState, region->desiredFacing);
 			return Common::kNoError;
 		}
 
@@ -1299,7 +1258,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			case Common::EVENT_RBUTTONDOWN: {
 				notePlayerActivity();
 				if (idleState.active || idleState.exiting) {
-					if (requestPlayerIdleAnimationExit(scene.state, playerState, idleState))
+					if (RoomPlayerLogic::requestIdleAnimationExit(scene.state, playerState, idleState))
 						needsRedraw = true;
 					break;
 				}
@@ -1329,7 +1288,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 							if (loadoutChanged) {
 								captureCurrentSaveState();
 								if (playerState.entity) {
-									(void)syncPlayerCombatLoadoutVisual(_engine, scene.state, playerState,
+									(void)RoomPlayerLogic::syncCombatLoadoutVisual(_engine, scene.state, playerState,
 										_engine.getStartupScript()->getPlayerCombatLoadout());
 								} else if (_engine.getStartupScript()) {
 									playerState.combatLoadout =
@@ -1415,26 +1374,26 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					playerState.turnLastFrame = -1;
 					playerState.turnEndFrame = -1;
 					playerState.turnPlayBackwards = false;
-					(void)syncPlayerCombatLoadoutVisual(_engine, scene.state, playerState,
+					(void)RoomPlayerLogic::syncCombatLoadoutVisual(_engine, scene.state, playerState,
 						startupScript->getPlayerCombatLoadout());
-					if (startPlayerAttackAnimation(scene.state, playerState, _mousePos))
+					if (RoomPlayerLogic::startAttackAnimation(scene.state, playerState, _mousePos))
 						needsRedraw = true;
 				}
 				break;
 			}
 			case Common::EVENT_LBUTTONUP:
-				lastLeftButtonReleaseTick = getRuntimeClockTicks();
+				lastLeftButtonReleaseTick = RoomPlayerLogic::getRuntimeClockTicks();
 				if (showingInspectText)
 					inspectCanDismiss = true;
 				break;
 			case Common::EVENT_LBUTTONDOWN: {
-				const uint32 now = getRuntimeClockTicks();
+				const uint32 now = RoomPlayerLogic::getRuntimeClockTicks();
 				const bool isFastExitClick =
 					lastLeftButtonReleaseTick != 0 &&
 					now - lastLeftButtonReleaseTick < kRoomExitFastClickWindowTicks;
 				notePlayerActivity();
 				if (idleState.active || idleState.exiting) {
-					if (requestPlayerIdleAnimationExit(scene.state, playerState, idleState))
+					if (RoomPlayerLogic::requestIdleAnimationExit(scene.state, playerState, idleState))
 						needsRedraw = true;
 					break;
 				}
@@ -1603,7 +1562,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				if (!clickedObject) {
 					pendingRegionName.clear();
 					if (hoverState.cursorSequence == 0 && playerState.entity) {
-						setPlayerMoveTargetFromScreenPoint(scene.state, playerState, _mousePos.x, _mousePos.y);
+						RoomPlayerLogic::setMoveTargetFromScreenPoint(scene.state, playerState, _mousePos.x, _mousePos.y);
 						needsRedraw = true;
 					}
 					break;
@@ -1688,7 +1647,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 				notePlayerActivity();
 				if (idleState.active || idleState.exiting) {
-					if (requestPlayerIdleAnimationExit(scene.state, playerState, idleState))
+					if (RoomPlayerLogic::requestIdleAnimationExit(scene.state, playerState, idleState))
 						needsRedraw = true;
 					break;
 				}
@@ -1798,21 +1757,22 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		}
 
 		bool playerAdvancedThisFrame = false;
-		if (updatePlayerAttackAnimationState(_engine, playerState)) {
+		if (RoomPlayerLogic::updateAttackAnimationState(_engine, playerState)) {
 			needsRedraw = true;
 		}
-		if (!playerState.attackActive && updatePlayerTurnAnimationState(playerState)) {
+		if (!playerState.attackActive && RoomPlayerLogic::updateTurnAnimationState(playerState)) {
 			playerAdvancedThisFrame = true;
 			needsRedraw = true;
 		}
 
 		if (!playerState.attackActive && !idleState.active && !idleState.exiting) {
-			if (stepPlayerKeyboardMovement(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
+			if (RoomPlayerLogic::stepKeyboardMovement(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
 					playerState, moveLeft, moveRight, moveUp, moveDown)) {
 				playerAdvancedThisFrame = true;
 				notePlayerActivity();
 				needsRedraw = true;
-			} else if (stepPlayerMoveTarget(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
+			} else if (RoomPlayerLogic::stepMoveTarget(
+					_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
 					playerState)) {
 				playerAdvancedThisFrame = true;
 				notePlayerActivity();
@@ -1820,16 +1780,16 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			} else if (!moveLeft && !moveRight && !moveUp && !moveDown && !playerState.hasMoveTarget &&
 					!playerState.turnActive &&
 					playerState.entity && playerState.facing >= 0 &&
-					setPlayerIdleAnimation(playerState, playerState.facing)) {
+					RoomPlayerLogic::setIdleAnimation(playerState, playerState.facing)) {
 				needsRedraw = true;
 			}
 
 			if (!showingInspectText && !moveLeft && !moveRight && !moveUp && !moveDown &&
 					!playerState.hasMoveTarget && !playerState.turnActive &&
 					playerState.entity && playerState.facing >= 0 &&
-					!isIdleAnimationExcludedRoom(scene.state.roomName) &&
-					getRuntimeClockTicks() > idleState.triggerTick &&
-					startPlayerIdleAnimation(_engine, scene.state, playerState, idleState)) {
+					!RoomPlayerLogic::isIdleAnimationExcludedRoom(scene.state.roomName) &&
+					RoomPlayerLogic::getRuntimeClockTicks() > idleState.triggerTick &&
+					RoomPlayerLogic::startIdleAnimation(_engine, scene.state, playerState, idleState)) {
 				needsRedraw = true;
 			}
 		}
@@ -1883,7 +1843,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				}
 			}
 		}
-		if (updatePlayerIdleAnimation(scene.state, playerState, idleState))
+		if (RoomPlayerLogic::updateIdleAnimation(scene.state, playerState, idleState))
 			needsRedraw = true;
 
 		limiter.delayBeforeSwap();
diff --git a/engines/harvester/room_monster.cpp b/engines/harvester/room_monster.cpp
new file mode 100644
index 00000000000..a7731c006bd
--- /dev/null
+++ b/engines/harvester/room_monster.cpp
@@ -0,0 +1,81 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/room_monster.h"
+
+#include "harvester/runtime_entity.h"
+
+namespace Harvester {
+
+namespace {
+
+static const int kRoomMonsterAnimationRate = 17;
+
+struct MonsterAnimationRange {
+	MonsterAnimationRange() {}
+	MonsterAnimationRange(int walkFirstFrame, int walkLastFrame, int idleFrame)
+		: walkFirstFrame(walkFirstFrame), walkLastFrame(walkLastFrame), idleFrame(idleFrame) {}
+
+	int walkFirstFrame = 0;
+	int walkLastFrame = 0;
+	int idleFrame = 0;
+};
+
+static MonsterAnimationRange resolveAnimationRange(int facing) {
+	switch (facing) {
+	case 0:
+		return MonsterAnimationRange(0x00, 0x09, 0x3b);
+	case 1:
+		return MonsterAnimationRange(0x0f, 0x18, 0x0e);
+	case 2:
+		return MonsterAnimationRange(0x2d, 0x36, 0x2c);
+	case 3:
+		return MonsterAnimationRange(0x1e, 0x27, 0x28);
+	default:
+		return MonsterAnimationRange(0x00, 0x09, 0x3b);
+	}
+}
+
+} // End of anonymous namespace
+
+int RoomMonsterLogic::resolveFacingFrame(int facing) {
+	return resolveAnimationRange(facing).idleFrame;
+}
+
+void RoomMonsterLogic::applyAnimation(RuntimeEntity &entity, const StartupMonsterRecord &monster) {
+	entity.setVisible(monster.visible);
+	if (!monster.visible)
+		return;
+
+	const MonsterAnimationRange range = resolveAnimationRange(monster.facing);
+	if (monster.active) {
+		entity.setAnimationRate(kRoomMonsterAnimationRate);
+		entity.setAnimationFrameRange(range.walkFirstFrame, range.walkLastFrame, true);
+		entity.setAnimationEnabled(true);
+	} else {
+		entity.setAnimationRate(0);
+		entity.setAnimationFrameRange(range.idleFrame, range.idleFrame, false);
+		entity.setCurrentFrame(range.idleFrame);
+		entity.setAnimationEnabled(false);
+	}
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/room_monster.h b/engines/harvester/room_monster.h
new file mode 100644
index 00000000000..253a5107eb2
--- /dev/null
+++ b/engines/harvester/room_monster.h
@@ -0,0 +1,39 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_ROOM_MONSTER_H
+#define HARVESTER_ROOM_MONSTER_H
+
+#include "harvester/script.h"
+
+namespace Harvester {
+
+class RuntimeEntity;
+
+class RoomMonsterLogic {
+public:
+	static int resolveFacingFrame(int facing);
+	static void applyAnimation(RuntimeEntity &entity, const StartupMonsterRecord &monster);
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_ROOM_MONSTER_H
diff --git a/engines/harvester/room_player.cpp b/engines/harvester/room_player.cpp
new file mode 100644
index 00000000000..df1b5c58857
--- /dev/null
+++ b/engines/harvester/room_player.cpp
@@ -0,0 +1,995 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <math.h>
+
+#include "harvester/room_player.h"
+
+#include "common/debug.h"
+#include "common/system.h"
+#include "harvester/detection.h"
+#include "harvester/harvester.h"
+#include "harvester/resources.h"
+#include "harvester/runtime_entity.h"
+
+namespace Harvester {
+
+namespace {
+
+static const char *const kPlayerActorResourcePath = "1:/GRAPHIC/MONSTERS/PC/PC0.ABM";
+static const float kRoomPlayerHorizontalMoveBase = 8.0f;
+static const int kRoomRegionTargetXBias = 10;
+static const float kRoomDepthCompareEpsilon = 0.01f;
+static const int kRoomPlayerWalkAnimationRate = 17;
+static const int kRoomPlayerAttackAnimationRate = kRoomPlayerWalkAnimationRate;
+static const int kRoomPlayerMinOpaqueLeftX = 4;
+static const int kRoomPlayerMaxOpaqueRightX = 0x27c;
+static const float kRoomPlayerAttackUpperYOffset = 144.44f;
+static const float kRoomPlayerAttackMidYOffset = 75.36f;
+static const char *const kPlayerIdleAnimationEntityName = "IDLE_ANIM";
+static const char *const kPlayerIdleAnimationResourcePath = "1:/GRAPHIC/ROOMANIM/PCLOUN02.ABM";
+static const uint32 kRuntimeClockDivisorMs = 10;
+static const uint32 kRoomPlayerIdleDelayTicks = 3000;
+// Native run_harvester_main_loop sets EBX=0x0e before spawning PCLOUN02.ABM.
+static const int kRoomPlayerIdleAnimationRate = 14;
+static const int kRoomPlayerIdleLoopStartFrame = 0x0f;
+static const int kRoomPlayerIdleLoopLastFrame = 0xb2;
+static const int kRoomPlayerIdleExitLastFrame = 0xbf;
+static const int kRoomPlayerIdleYOffset = 4;
+
+struct PlayerAnimationRange {
+	PlayerAnimationRange() {}
+	PlayerAnimationRange(int walkFirstFrame, int walkLastFrame, int idleFrame)
+		: walkFirstFrame(walkFirstFrame), walkLastFrame(walkLastFrame), idleFrame(idleFrame) {}
+
+	int walkFirstFrame = 0;
+	int walkLastFrame = 0;
+	int idleFrame = 0;
+};
+
+struct PlayerTurnAnimationRange {
+	PlayerTurnAnimationRange() {}
+	PlayerTurnAnimationRange(int firstFrame, int lastFrame, bool playBackwards)
+		: firstFrame(firstFrame), lastFrame(lastFrame), playBackwards(playBackwards) {}
+
+	int firstFrame = 0;
+	int lastFrame = 0;
+	bool playBackwards = false;
+};
+
+struct PlayerAttackAnimationRange {
+	PlayerAttackAnimationRange() {}
+	PlayerAttackAnimationRange(int firstFrame, int lastFrame, int resumeFacing)
+		: firstFrame(firstFrame), lastFrame(lastFrame), resumeFacing(resumeFacing) {}
+
+	int firstFrame = 0;
+	int lastFrame = 0;
+	int resumeFacing = 0;
+};
+
+struct PlayerAttackSoundSet {
+	uint soundCount;
+	const char *soundPaths[3];
+};
+
+// Recovered from spawn_player_combat_avatar and update_actor_runtime_state:
+// the native room-combat path commits player attack audio on first_frame + 1.
+static const int kPlayerAttackSoundTriggerFrameOffset = 1;
+static const PlayerAttackSoundSet kPlayerAttackSoundSets[] = {
+	{ 3, { "2:/sound/effects/swoosh1.wav", "2:/sound/effects/swoosh2.wav", "2:/sound/effects/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 1, { "2:/SOUND/EFFECTS/NAILGUN1.wav", nullptr, nullptr } },
+	{ 1, { "1:/sound/effects/shotgun2.wav", nullptr, nullptr } },
+	{ 1, { "2:/SOUND/EFFECTS/gunshot.wav", nullptr, nullptr } },
+	{ 1, { "2:/SOUND/EFFECTS/gunshot.wav", nullptr, nullptr } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/sound/effects/swoosh1.wav", "2:/sound/effects/swoosh2.wav", "2:/sound/effects/swoosh3.wav" } },
+	{ 1, { "1:/sound/effects/CLN_atk0.wav", nullptr, nullptr } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh3.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh1.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } },
+	{ 3, { "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } }
+};
+
+static int roundToInt(float value) {
+	return value >= 0.0f ? (int)floorf(value + 0.5f) : (int)ceilf(value - 0.5f);
+}
+
+static PlayerAnimationRange resolvePlayerAnimationRange(int facing) {
+	switch (facing) {
+	case 0:
+		return PlayerAnimationRange{ 0x00, 0x09, 0x3b };
+	case 1:
+		return PlayerAnimationRange{ 0x0f, 0x18, 0x0e };
+	case 2:
+		return PlayerAnimationRange{ 0x2d, 0x36, 0x2c };
+	case 3:
+		return PlayerAnimationRange{ 0x1e, 0x27, 0x28 };
+	default:
+		return PlayerAnimationRange{ 0x00, 0x09, 0x3b };
+	}
+}
+
+static bool resolvePlayerTurnAnimationRange(int fromFacing, int toFacing, PlayerTurnAnimationRange &range) {
+	// Native desired-state transitions play these banks forward or backward depending on the source facing.
+	if (fromFacing == 0 && toFacing == 1) {
+		range = PlayerTurnAnimationRange{ 0x0a, 0x0e, false };
+		return true;
+	}
+	if (fromFacing == 1 && toFacing == 0) {
+		range = PlayerTurnAnimationRange{ 0x0a, 0x0e, true };
+		return true;
+	}
+	if (fromFacing == 1 && toFacing == 3) {
+		range = PlayerTurnAnimationRange{ 0x19, 0x1d, false };
+		return true;
+	}
+	if (fromFacing == 3 && toFacing == 1) {
+		range = PlayerTurnAnimationRange{ 0x19, 0x1d, true };
+		return true;
+	}
+	if (fromFacing == 3 && toFacing == 2) {
+		range = PlayerTurnAnimationRange{ 0x28, 0x2c, false };
+		return true;
+	}
+	if (fromFacing == 2 && toFacing == 3) {
+		range = PlayerTurnAnimationRange{ 0x28, 0x2c, true };
+		return true;
+	}
+	if (fromFacing == 2 && toFacing == 0) {
+		range = PlayerTurnAnimationRange{ 0x37, 0x3b, false };
+		return true;
+	}
+	if (fromFacing == 0 && toFacing == 2) {
+		range = PlayerTurnAnimationRange{ 0x37, 0x3b, true };
+		return true;
+	}
+
+	return false;
+}
+
+static const PlayerAttackSoundSet *resolvePlayerAttackSoundSet(int loadout) {
+	if (loadout < 0 || loadout >= (int)ARRAYSIZE(kPlayerAttackSoundSets))
+		return nullptr;
+
+	if (kPlayerAttackSoundSets[loadout].soundCount == 0)
+		return nullptr;
+
+	return &kPlayerAttackSoundSets[loadout];
+}
+
+static bool playPlayerAttackSound(HarvesterEngine &engine, int loadout) {
+	const PlayerAttackSoundSet *soundSet = resolvePlayerAttackSoundSet(loadout);
+	if (!soundSet)
+		return false;
+
+	uint soundIndex = 0;
+	if (soundSet->soundCount > 1)
+		soundIndex = engine.getRandomNumber(soundSet->soundCount - 1);
+
+	const char *soundPath = soundSet->soundPaths[soundIndex];
+	return soundPath && engine.playStartupSound(soundPath);
+}
+
+static bool resolvePlayerAttackAnimationRange(const StartupRoomSetupState &state,
+		const StartupRoomPlayerState &playerState, const Common::Point &mousePos,
+		PlayerAttackAnimationRange &range) {
+	if (!playerState.entity)
+		return false;
+
+	const float depthScale = RoomPlayerLogic::computeDepthScale(state, playerState.z);
+	const float upperThresholdY =
+		(float)playerState.bottomY - depthScale * kRoomPlayerAttackUpperYOffset;
+	const float midThresholdY =
+		(float)playerState.bottomY - depthScale * kRoomPlayerAttackMidYOffset;
+
+	if (mousePos.x < playerState.centerX) {
+		if ((float)mousePos.y < upperThresholdY)
+			range = PlayerAttackAnimationRange(0x50, 0x59, 1);
+		else if ((float)mousePos.y < midThresholdY)
+			range = PlayerAttackAnimationRange(0x5a, 0x63, 1);
+		else
+			range = PlayerAttackAnimationRange(0x64, 0x6d, 1);
+	} else {
+		if ((float)mousePos.y < upperThresholdY)
+			range = PlayerAttackAnimationRange(0x6e, 0x77, 2);
+		else if ((float)mousePos.y < midThresholdY)
+			range = PlayerAttackAnimationRange(0x78, 0x81, 2);
+		else
+			range = PlayerAttackAnimationRange(0x82, 0x8b, 2);
+	}
+
+	return true;
+}
+
+static int clampRoomMovementY(const StartupRoomSetupState &state, int screenY) {
+	if (!RoomPlayerLogic::supportsMovementBand(state))
+		return screenY;
+
+	return CLIP<int>(screenY, state.roomMaxZScreenY, state.roomMinZScreenY);
+}
+
+static float mapRoomScreenYToDepth(const StartupRoomSetupState &state, int screenY) {
+	if (!RoomPlayerLogic::supportsMovementBand(state))
+		return (float)state.playerSpawnZ;
+
+	if (screenY <= state.roomMaxZScreenY)
+		return (float)state.roomMaxZ;
+	if (screenY >= state.roomMinZScreenY)
+		return (float)state.roomMinZ;
+
+	return (float)(state.roomMaxZ - ((state.roomMaxZ - state.roomMinZ) *
+		(screenY - state.roomMaxZScreenY)) /
+		(state.roomMinZScreenY - state.roomMaxZScreenY));
+}
+
+static float clampRoomDepth(const StartupRoomSetupState &state, float z) {
+	if (!RoomPlayerLogic::supportsMovementBand(state))
+		return z;
+
+	return CLIP<float>(z,
+		(float)MIN(state.roomMinZ, state.roomMaxZ),
+		(float)MAX(state.roomMinZ, state.roomMaxZ));
+}
+
+static int mapRoomDepthToScreenY(const StartupRoomSetupState &state, float z) {
+	if (!RoomPlayerLogic::supportsMovementBand(state))
+		return state.playerSpawnY;
+
+	if (state.roomMaxZ == state.roomMinZ)
+		return clampRoomMovementY(state, state.playerSpawnY);
+
+	const float clampedZ = clampRoomDepth(state, z);
+	const float offset = ((float)state.roomMaxZ - clampedZ) *
+		(float)(state.roomMinZScreenY - state.roomMaxZScreenY) /
+		(float)(state.roomMaxZ - state.roomMinZ);
+	return clampRoomMovementY(state, state.roomMaxZScreenY + roundToInt(offset));
+}
+
+static int resolveFacingFromMovementDelta(int dx, int dy) {
+	if (ABS(dx) > ABS(dy))
+		return dx < 0 ? 1 : 2;
+	if (dy < 0)
+		return 3;
+	return 0;
+}
+
+static int resolveFacingFromRoomMovement(const StartupRoomSetupState &state,
+		int fromCenterX, float fromZ, int toCenterX, float toZ) {
+	return resolveFacingFromMovementDelta(
+		toCenterX - fromCenterX,
+		mapRoomDepthToScreenY(state, toZ) - mapRoomDepthToScreenY(state, fromZ));
+}
+
+static int computeRoomPlayerHorizontalStep(const StartupRoomSetupState &state, float z) {
+	return MAX<int>(1, roundToInt(RoomPlayerLogic::computeDepthScale(state, z) * kRoomPlayerHorizontalMoveBase));
+}
+
+static float computeRoomPlayerDepthStep(const StartupRoomSetupState &state) {
+	return state.roomZVelocityStep > 0.0f ? state.roomZVelocityStep : 1.0f;
+}
+
+static int clampPlayerCenterXToNativeBounds(const StartupRoomPlayerState &playerState, int centerX) {
+	if (!playerState.entity)
+		return CLIP<int>(centerX, 0, 639);
+
+	int width = 0;
+	int height = 0;
+	int xOffset = 0;
+	int yOffset = 0;
+	if (!playerState.entity->getCurrentFrameMetrics(width, height, xOffset, yOffset))
+		return CLIP<int>(centerX, 0, 639);
+
+	const int minCenterX = kRoomPlayerMinOpaqueLeftX + width / 2;
+	const int maxCenterX = MAX(minCenterX,
+		kRoomPlayerMaxOpaqueRightX - (width - width / 2));
+	return CLIP<int>(centerX, minCenterX, maxCenterX);
+}
+
+static uint32 computeAnimationTickInterval(int rate) {
+	return rate > 0 ? MAX<uint32>(1, 100U / (uint32)rate) : 0;
+}
+
+static bool consumePlayerMovementTick(StartupRoomPlayerState &playerState) {
+	const uint32 interval = computeAnimationTickInterval(kRoomPlayerWalkAnimationRate);
+	if (interval == 0) {
+		playerState.nextMovementTick = 0;
+		return true;
+	}
+
+	const uint32 now = RoomPlayerLogic::getRuntimeClockTicks();
+	if (playerState.nextMovementTick != 0 &&
+			(int32)(now - playerState.nextMovementTick) < 0) {
+		return false;
+	}
+
+	playerState.nextMovementTick = now + interval;
+	return true;
+}
+
+static int stepTowardsInt(int current, int target, int step) {
+	if (step <= 0 || current == target)
+		return current;
+	if (current < target)
+		return MIN(current + step, target);
+	return MAX(current - step, target);
+}
+
+static float stepTowardsFloat(float current, float target, float step) {
+	if (step <= 0.0f || fabsf(target - current) <= kRoomDepthCompareEpsilon)
+		return target;
+	if (current < target)
+		return MIN(current + step, target);
+	return MAX(current - step, target);
+}
+
+static bool doesPlayerOverlapRoomBlocker(HarvesterEngine &engine,
+		const Common::Array<StartupObjectRecord> &sceneObjects,
+		const Common::Array<StartupAnimRecord> &sceneAnimations, const RuntimeEntity &playerEntity) {
+	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
+	if (!runtimeEntities)
+		return false;
+
+	for (const StartupObjectRecord &object : sceneObjects) {
+		const RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(object.objectName);
+		if (entity && playerEntity.overlapsEntity(*entity))
+			return true;
+	}
+
+	for (const StartupAnimRecord &anim : sceneAnimations) {
+		const RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(anim.animName);
+		if (entity && playerEntity.overlapsEntity(*entity))
+			return true;
+	}
+
+	return false;
+}
+
+static bool isPlayerMovementBlocked(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		const Common::Array<StartupObjectRecord> &sceneObjects,
+		const Common::Array<StartupAnimRecord> &sceneAnimations,
+		StartupRoomPlayerState &playerState, int candidateCenterX, float candidateZ) {
+	if (!playerState.entity)
+		return true;
+
+	const int oldCenterX = playerState.centerX;
+	const int oldBottomY = playerState.bottomY;
+	const float oldZ = playerState.z;
+	const int candidateBottomY = mapRoomDepthToScreenY(state, candidateZ);
+	if (!applyRoomActorPlacement(state, *playerState.entity,
+			candidateCenterX, candidateBottomY, candidateZ)) {
+		(void)applyRoomActorPlacement(state, *playerState.entity, oldCenterX, oldBottomY, oldZ);
+		return true;
+	}
+
+	const bool blocked = doesPlayerOverlapRoomBlocker(engine, sceneObjects, sceneAnimations, *playerState.entity);
+	(void)applyRoomActorPlacement(state, *playerState.entity, oldCenterX, oldBottomY, oldZ);
+	return blocked;
+}
+
+static bool tryApplyPlayerMovement(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		const Common::Array<StartupObjectRecord> &sceneObjects,
+		const Common::Array<StartupAnimRecord> &sceneAnimations,
+		StartupRoomPlayerState &playerState, int candidateCenterX, float candidateZ) {
+	if (!playerState.entity)
+		return false;
+
+	candidateCenterX = clampPlayerCenterXToNativeBounds(playerState, candidateCenterX);
+	candidateZ = clampRoomDepth(state, candidateZ);
+	if (candidateCenterX == playerState.centerX &&
+			fabsf(candidateZ - playerState.z) <= kRoomDepthCompareEpsilon) {
+		return false;
+	}
+	if (isPlayerMovementBlocked(engine, state, sceneObjects, sceneAnimations,
+			playerState, candidateCenterX, candidateZ)) {
+		return false;
+	}
+
+	playerState.centerX = candidateCenterX;
+	playerState.z = candidateZ;
+	playerState.bottomY = mapRoomDepthToScreenY(state, candidateZ);
+	return applyRoomActorPlacement(state, *playerState.entity,
+		playerState.centerX, playerState.bottomY, playerState.z);
+}
+
+static void notePlayerIdleReset(StartupRoomIdleAnimationState &idleState) {
+	idleState.resetTick = RoomPlayerLogic::getRuntimeClockTicks();
+	RoomPlayerLogic::updateIdleTrigger(idleState);
+}
+
+static bool setPlayerWalkAnimation(StartupRoomPlayerState &playerState, int facing) {
+	if (!playerState.entity)
+		return false;
+
+	const PlayerAnimationRange range = resolvePlayerAnimationRange(facing);
+	const int currentFrame = playerState.entity->getCurrentFrame();
+	const bool alreadyWalking = playerState.facing == facing &&
+		playerState.entity->getAnimationRate() == kRoomPlayerWalkAnimationRate &&
+		currentFrame >= range.walkFirstFrame && currentFrame <= range.walkLastFrame;
+	if (alreadyWalking)
+		return false;
+
+	playerState.facing = facing;
+	playerState.entity->setAnimationFrameRange(range.walkFirstFrame, range.walkLastFrame, true);
+	playerState.entity->setAnimationRate(kRoomPlayerWalkAnimationRate);
+	debugC(1, kDebugScene,
+		"Harvester: player walk animation facing=%d frames=%d..%d rate=%d",
+		facing, range.walkFirstFrame, range.walkLastFrame, kRoomPlayerWalkAnimationRate);
+	return true;
+}
+
+static void positionPlayerIdleAnimationEntity(const StartupRoomSetupState &state,
+		const StartupRoomPlayerState &playerState, RuntimeEntity &idleEntity) {
+	if (!playerState.entity)
+		return;
+
+	idleEntity.setAnchorMode(kRuntimeEntityAnchorTopLeft);
+	idleEntity.setDepthScale(RoomPlayerLogic::computeDepthScale(state, playerState.z));
+	idleEntity.setPosition(playerState.entity->getX(),
+		playerState.entity->getY() + kRoomPlayerIdleYOffset, playerState.z);
+}
+
+static RuntimeEntity *ensurePlayerIdleAnimationEntity(HarvesterEngine &engine,
+		const StartupRoomSetupState &state, const StartupRoomPlayerState &playerState,
+		StartupRoomIdleAnimationState &idleState) {
+	if (idleState.entity || !playerState.entity)
+		return idleState.entity;
+
+	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
+	if (!runtimeEntities)
+		return nullptr;
+
+	idleState.entity = runtimeEntities->spawnSceneAnimationEntity(
+		kPlayerIdleAnimationEntityName, kPlayerIdleAnimationResourcePath,
+		Common::Point(playerState.entity->getX(),
+			playerState.entity->getY() + kRoomPlayerIdleYOffset),
+		playerState.z, 0, false, false, false, false, false);
+	if (!idleState.entity)
+		return nullptr;
+
+	positionPlayerIdleAnimationEntity(state, playerState, *idleState.entity);
+	idleState.entity->setVisible(false);
+	debugC(1, kDebugScene,
+		"Harvester: spawned player idle animation '%s' room='%s' frames=0..%d",
+		kPlayerIdleAnimationResourcePath, state.roomName.c_str(), idleState.entity->getLastFrame());
+	return idleState.entity;
+}
+
+static bool finishPlayerIdleAnimation(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
+		StartupRoomIdleAnimationState &idleState) {
+	if (!idleState.active)
+		return false;
+
+	if (idleState.entity) {
+		idleState.entity->setVisible(false);
+		idleState.entity->setAnimationRate(0);
+		idleState.entity->setCurrentFrame(0);
+		positionPlayerIdleAnimationEntity(state, playerState, *idleState.entity);
+	}
+	if (playerState.entity) {
+		playerState.entity->setVisible(true);
+		if (idleState.restoreFacing >= 0)
+			(void)RoomPlayerLogic::setIdleAnimation(playerState, idleState.restoreFacing);
+	}
+
+	idleState.active = false;
+	idleState.loopStarted = false;
+	idleState.exiting = false;
+	idleState.restoreFacing = -1;
+	notePlayerIdleReset(idleState);
+	debugC(1, kDebugScene,
+		"Harvester: player idle animation finished room='%s' facing=%d",
+		state.roomName.c_str(), playerState.facing);
+	return true;
+}
+
+} // End of anonymous namespace
+
+float RoomPlayerLogic::computeDepthScale(const StartupRoomSetupState &state, float z) {
+	float scale = 1.0f;
+	if (state.roomPerspectiveScale != 0.0f) {
+		scale -= (z - (float)state.roomFullScaleZ) * state.roomPerspectiveScale;
+		if (scale <= 0.0f)
+			scale = 1.0f;
+	}
+
+	return scale;
+}
+
+int RoomPlayerLogic::resolveFacingFrame(int facing) {
+	switch (facing) {
+	case 0:
+		return 0x3b;
+	case 1:
+		return 0x0e;
+	case 2:
+		return 0x2c;
+	case 3:
+		return 0x28;
+	default:
+		return 0x0e;
+	}
+}
+
+Common::String RoomPlayerLogic::resolveCombatLoadoutResourcePath(int loadout) {
+	if (loadout <= 0)
+		return Common::String(kPlayerActorResourcePath);
+
+	return Common::String::format("1:/GRAPHIC/MONSTERS/PC/PC%02d.ABM", loadout);
+}
+
+bool RoomPlayerLogic::supportsMovementBand(const StartupRoomSetupState &state) {
+	return state.roomMaxZScreenY >= 0 &&
+		state.roomMinZScreenY >= state.roomMaxZScreenY;
+}
+
+uint32 RoomPlayerLogic::getRuntimeClockTicks() {
+	return g_system ? (g_system->getMillis() / kRuntimeClockDivisorMs) : 0;
+}
+
+bool RoomPlayerLogic::isIdleAnimationExcludedRoom(const Common::String &roomName) {
+	static const char *const kExcludedRooms[] = {
+		"JAWS",
+		"SUPPLY1",
+		"SUPPLY2",
+		"SUPPLY3",
+		"LAVAPIT",
+		"RMNBATH",
+		"SERGENTRM",
+		"CHAP2"
+	};
+
+	for (const char *excludedRoom : kExcludedRooms) {
+		if (roomName.equalsIgnoreCase(excludedRoom))
+			return true;
+	}
+
+	return false;
+}
+
+void RoomPlayerLogic::updateIdleTrigger(StartupRoomIdleAnimationState &idleState) {
+	idleState.triggerTick = MAX(idleState.activityTick, idleState.resetTick) + kRoomPlayerIdleDelayTicks;
+}
+
+void RoomPlayerLogic::setMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
+		int targetX, float targetZ) {
+	playerState.hasMoveTarget = true;
+	playerState.nextMovementTick = 0;
+	playerState.targetX = clampPlayerCenterXToNativeBounds(playerState, targetX);
+	playerState.targetZ = clampRoomDepth(state, targetZ);
+	debugC(1, kDebugScene,
+		"Harvester: player move target room='%s' current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f)",
+		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
+		playerState.targetX, mapRoomDepthToScreenY(state, playerState.targetZ), (double)playerState.targetZ);
+}
+
+void RoomPlayerLogic::setMoveTargetFromScreenPoint(const StartupRoomSetupState &state,
+		StartupRoomPlayerState &playerState, int targetX, int targetBottomY) {
+	setMoveTarget(state, playerState, targetX,
+		mapRoomScreenYToDepth(state, clampRoomMovementY(state, targetBottomY)));
+}
+
+int RoomPlayerLogic::resolveRegionTargetX(const StartupRegionRecord &region,
+		const StartupRoomPlayerState &playerState) {
+	const Common::Rect bounds(region.left, region.top, region.right + 1, region.bottom + 1);
+	int targetX = bounds.left + bounds.width() / 2;
+	if (playerState.entity && targetX + kRoomRegionTargetXBias < playerState.entity->getScreenRect().right)
+		targetX -= kRoomRegionTargetXBias;
+	return CLIP<int>(targetX, 0, 639);
+}
+
+float RoomPlayerLogic::resolveRegionTargetZ(const StartupRegionRecord &region) {
+	if (region.desiredFacing == 0)
+		return (float)region.maxZ;
+	if (region.desiredFacing == 3)
+		return (float)region.minZ;
+
+	return (float)(region.minZ + (region.maxZ - region.minZ) / 2);
+}
+
+bool RoomPlayerLogic::syncCombatLoadoutVisual(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		StartupRoomPlayerState &playerState, int loadout) {
+	if (!playerState.entity)
+		return false;
+
+	ResourceManager *resources = engine.getResources();
+	if (!resources)
+		return false;
+
+	const int clampedLoadout = CLIP<int>(loadout, 0, 0x14);
+	if (playerState.combatLoadout == clampedLoadout)
+		return false;
+
+	const Common::String resourcePath = resolveCombatLoadoutResourcePath(clampedLoadout);
+	if (!playerState.entity->loadAbmResource(*resources, resourcePath))
+		return false;
+
+	playerState.combatLoadout = clampedLoadout;
+	playerState.attackActive = false;
+	playerState.attackFirstFrame = -1;
+	playerState.attackLastFrame = -1;
+	playerState.attackResumeFacing = -1;
+	playerState.attackSoundPlayed = false;
+	playerState.attackSoundFrame = -1;
+	(void)setIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
+	(void)applyRoomActorPlacement(state, *playerState.entity,
+		playerState.centerX, playerState.bottomY, playerState.z);
+	debugC(1, kDebugScene,
+		"Harvester: player combat loadout visual loadout=%d resource='%s' facing=%d pos=(%d,%d,z=%.2f)",
+		playerState.combatLoadout, resourcePath.c_str(), playerState.facing,
+		playerState.centerX, playerState.bottomY, (double)playerState.z);
+	return true;
+}
+
+bool RoomPlayerLogic::setIdleAnimation(StartupRoomPlayerState &playerState, int facing) {
+	if (!playerState.entity)
+		return false;
+
+	const PlayerAnimationRange range = resolvePlayerAnimationRange(facing);
+	const bool changed = playerState.facing != facing ||
+		playerState.entity->getCurrentFrame() != range.idleFrame ||
+		playerState.entity->getAnimationRate() != 0;
+	playerState.facing = facing;
+	playerState.nextMovementTick = 0;
+	playerState.entity->setAnimationRate(0);
+	playerState.entity->setAnimationFrameRange(range.idleFrame, range.idleFrame, false);
+	playerState.entity->setCurrentFrame(range.idleFrame);
+	if (changed) {
+		debugC(1, kDebugScene,
+			"Harvester: player idle animation facing=%d frame=%d",
+			facing, range.idleFrame);
+	}
+	return changed;
+}
+
+bool RoomPlayerLogic::startAttackAnimation(const StartupRoomSetupState &state,
+		StartupRoomPlayerState &playerState, const Common::Point &mousePos) {
+	if (!playerState.entity || playerState.attackActive)
+		return false;
+
+	PlayerAttackAnimationRange range;
+	if (!resolvePlayerAttackAnimationRange(state, playerState, mousePos, range))
+		return false;
+
+	playerState.hasMoveTarget = false;
+	playerState.turnActive = false;
+	playerState.turnTargetFacing = -1;
+	playerState.turnFirstFrame = -1;
+	playerState.turnLastFrame = -1;
+	playerState.turnEndFrame = -1;
+	playerState.turnPlayBackwards = false;
+	playerState.attackActive = true;
+	playerState.attackFirstFrame = range.firstFrame;
+	playerState.attackLastFrame = range.lastFrame;
+	playerState.attackResumeFacing = range.resumeFacing;
+	playerState.attackSoundPlayed = resolvePlayerAttackSoundSet(playerState.combatLoadout) == nullptr;
+	playerState.attackSoundFrame = playerState.attackSoundPlayed
+		? -1
+		: (range.firstFrame + kPlayerAttackSoundTriggerFrameOffset);
+	playerState.nextMovementTick = 0;
+	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
+	playerState.entity->setAnimationRate(kRoomPlayerAttackAnimationRate);
+	playerState.entity->setCurrentFrame(range.firstFrame);
+	debugC(1, kDebugScene,
+		"Harvester: player attack animation frames=%d..%d resume_facing=%d cursor=(%d,%d)",
+		range.firstFrame, range.lastFrame, range.resumeFacing, mousePos.x, mousePos.y);
+	return true;
+}
+
+bool RoomPlayerLogic::updateAttackAnimationState(HarvesterEngine &engine,
+		StartupRoomPlayerState &playerState) {
+	if (!playerState.attackActive || !playerState.entity)
+		return false;
+	if (!playerState.attackSoundPlayed &&
+			playerState.attackSoundFrame >= 0 &&
+			playerState.entity->getCurrentFrame() >= playerState.attackSoundFrame) {
+		(void)playPlayerAttackSound(engine, playerState.combatLoadout);
+		playerState.attackSoundPlayed = true;
+	}
+	if (playerState.entity->getCurrentFrame() != playerState.attackLastFrame)
+		return false;
+
+	const int resumeFacing = playerState.attackResumeFacing >= 0
+		? playerState.attackResumeFacing
+		: (playerState.facing >= 0 ? playerState.facing : 0);
+	playerState.attackActive = false;
+	playerState.attackFirstFrame = -1;
+	playerState.attackLastFrame = -1;
+	playerState.attackResumeFacing = -1;
+	playerState.attackSoundPlayed = false;
+	playerState.attackSoundFrame = -1;
+	return setIdleAnimation(playerState, resumeFacing);
+}
+
+bool RoomPlayerLogic::startTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing) {
+	if (!playerState.entity || playerState.facing < 0 || playerState.facing == targetFacing)
+		return false;
+
+	PlayerTurnAnimationRange range;
+	if (!resolvePlayerTurnAnimationRange(playerState.facing, targetFacing, range))
+		return false;
+
+	playerState.turnActive = true;
+	playerState.turnTargetFacing = targetFacing;
+	playerState.turnFirstFrame = range.firstFrame;
+	playerState.turnLastFrame = range.lastFrame;
+	playerState.turnEndFrame = range.playBackwards ? range.firstFrame : range.lastFrame;
+	playerState.turnPlayBackwards = range.playBackwards;
+	playerState.nextMovementTick = 0;
+	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
+	playerState.entity->setPlayBackwards(range.playBackwards);
+	playerState.entity->setAnimationRate(kRoomPlayerWalkAnimationRate);
+	playerState.entity->setCurrentFrame(range.playBackwards ? range.lastFrame : range.firstFrame);
+	debugC(1, kDebugScene,
+		"Harvester: player turn animation from=%d to=%d frames=%d..%d backwards=%d rate=%d",
+		playerState.facing, targetFacing, range.firstFrame, range.lastFrame,
+		range.playBackwards, kRoomPlayerWalkAnimationRate);
+	return true;
+}
+
+bool RoomPlayerLogic::updateTurnAnimationState(StartupRoomPlayerState &playerState) {
+	if (!playerState.turnActive || !playerState.entity)
+		return false;
+	if (playerState.entity->getCurrentFrame() != playerState.turnEndFrame)
+		return false;
+
+	playerState.turnActive = false;
+	playerState.facing = playerState.turnTargetFacing;
+	playerState.turnTargetFacing = -1;
+	playerState.turnFirstFrame = -1;
+	playerState.turnLastFrame = -1;
+	playerState.turnEndFrame = -1;
+	playerState.turnPlayBackwards = false;
+	playerState.entity->setAnimationRate(0);
+	debugC(1, kDebugScene,
+		"Harvester: player turn complete facing=%d frame=%d",
+		playerState.facing, playerState.entity->getCurrentFrame());
+	return true;
+}
+
+bool RoomPlayerLogic::stepMoveTarget(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		const Common::Array<StartupObjectRecord> &sceneObjects,
+		const Common::Array<StartupAnimRecord> &sceneAnimations,
+		StartupRoomPlayerState &playerState) {
+	if (!playerState.entity || !playerState.hasMoveTarget || playerState.turnActive)
+		return false;
+
+	if (playerState.centerX == playerState.targetX &&
+			fabsf(playerState.z - playerState.targetZ) <= kRoomDepthCompareEpsilon) {
+		playerState.hasMoveTarget = false;
+		return setIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
+	}
+
+	const int previousCenterX = playerState.centerX;
+	const float previousZ = playerState.z;
+	const int targetBottomY = mapRoomDepthToScreenY(state, playerState.targetZ);
+	const int moveFacing = resolveFacingFromMovementDelta(
+		playerState.targetX - playerState.centerX, targetBottomY - playerState.bottomY);
+	if (!consumePlayerMovementTick(playerState))
+		return false;
+	const int horizontalStep = computeRoomPlayerHorizontalStep(state, playerState.z);
+	const float depthStep = computeRoomPlayerDepthStep(state);
+	const int candidateCenterX = stepTowardsInt(playerState.centerX, playerState.targetX, horizontalStep);
+	const float candidateZ = stepTowardsFloat(playerState.z, playerState.targetZ, depthStep);
+
+	bool moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+		playerState, candidateCenterX, candidateZ);
+	if (!moved && candidateCenterX != previousCenterX) {
+		moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+			playerState, candidateCenterX, previousZ);
+	}
+	if (!moved && fabsf(candidateZ - previousZ) > kRoomDepthCompareEpsilon) {
+		moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+			playerState, previousCenterX, candidateZ);
+	}
+	if (!moved) {
+		playerState.hasMoveTarget = false;
+		return setIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : moveFacing);
+	}
+
+	const int actualFacing = resolveFacingFromRoomMovement(
+		state, previousCenterX, previousZ, playerState.centerX, playerState.z);
+	(void)setPlayerWalkAnimation(playerState, actualFacing);
+	if (playerState.centerX == playerState.targetX &&
+			fabsf(playerState.z - playerState.targetZ) <= kRoomDepthCompareEpsilon) {
+		playerState.bottomY = mapRoomDepthToScreenY(state, playerState.targetZ);
+		playerState.z = playerState.targetZ;
+		playerState.hasMoveTarget = false;
+		(void)setIdleAnimation(playerState, actualFacing);
+	}
+	debugC(playerState.hasMoveTarget ? 2 : 1, kDebugScene,
+		"Harvester: player move step room='%s' pos=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f) facing=%d frame=%d active=%d moved=%d",
+		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
+		playerState.targetX, mapRoomDepthToScreenY(state, playerState.targetZ), (double)playerState.targetZ,
+		playerState.facing, playerState.entity->getCurrentFrame(), playerState.hasMoveTarget, moved);
+	return true;
+}
+
+bool RoomPlayerLogic::stepKeyboardMovement(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		const Common::Array<StartupObjectRecord> &sceneObjects,
+		const Common::Array<StartupAnimRecord> &sceneAnimations,
+		StartupRoomPlayerState &playerState, bool moveLeft, bool moveRight, bool moveUp, bool moveDown) {
+	if (!playerState.entity || !supportsMovementBand(state))
+		return false;
+
+	const int horizontalInput = (moveRight ? 1 : 0) - (moveLeft ? 1 : 0);
+	const int verticalInput = (moveDown ? 1 : 0) - (moveUp ? 1 : 0);
+	if (horizontalInput == 0 && verticalInput == 0)
+		return false;
+	if (playerState.turnActive)
+		return false;
+
+	const int previousCenterX = playerState.centerX;
+	const float previousZ = playerState.z;
+	const int horizontalStep = computeRoomPlayerHorizontalStep(state, playerState.z);
+	const float depthStep = computeRoomPlayerDepthStep(state);
+	int candidateCenterX = clampPlayerCenterXToNativeBounds(
+		playerState, playerState.centerX + horizontalInput * horizontalStep);
+	float candidateZ = playerState.z;
+	if (verticalInput != 0) {
+		const float positiveZ = clampRoomDepth(state, playerState.z + depthStep);
+		const float negativeZ = clampRoomDepth(state, playerState.z - depthStep);
+		const int positiveY = mapRoomDepthToScreenY(state, positiveZ);
+		const int negativeY = mapRoomDepthToScreenY(state, negativeZ);
+		if (verticalInput < 0) {
+			if (positiveY < playerState.bottomY)
+				candidateZ = positiveZ;
+			else if (negativeY < playerState.bottomY)
+				candidateZ = negativeZ;
+		} else {
+			if (positiveY > playerState.bottomY)
+				candidateZ = positiveZ;
+			else if (negativeY > playerState.bottomY)
+				candidateZ = negativeZ;
+		}
+	}
+	if (candidateCenterX == playerState.centerX &&
+			fabsf(candidateZ - playerState.z) <= kRoomDepthCompareEpsilon) {
+		return false;
+	}
+
+	const int desiredFacing = resolveFacingFromRoomMovement(
+		state, playerState.centerX, playerState.z, candidateCenterX, candidateZ);
+	if (desiredFacing != playerState.facing && startTurnAnimation(playerState, desiredFacing))
+		return true;
+
+	if (!consumePlayerMovementTick(playerState))
+		return false;
+
+	playerState.hasMoveTarget = false;
+	bool moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+		playerState, candidateCenterX, candidateZ);
+	if (!moved && candidateCenterX != previousCenterX) {
+		moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+			playerState, candidateCenterX, previousZ);
+	}
+	if (!moved && fabsf(candidateZ - previousZ) > kRoomDepthCompareEpsilon) {
+		moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+			playerState, previousCenterX, candidateZ);
+	}
+	if (!moved)
+		return false;
+
+	const int actualFacing = resolveFacingFromRoomMovement(
+		state, previousCenterX, previousZ, playerState.centerX, playerState.z);
+	(void)setPlayerWalkAnimation(playerState, actualFacing);
+	debugC(2, kDebugScene,
+		"Harvester: player keyboard move room='%s' input=(%d,%d) pos=(%d,%d,z=%.2f) facing=%d frame=%d moved=%d",
+		state.roomName.c_str(), horizontalInput, verticalInput,
+		playerState.centerX, playerState.bottomY, (double)playerState.z,
+		playerState.facing, playerState.entity->getCurrentFrame(), moved);
+	return true;
+}
+
+bool RoomPlayerLogic::requestIdleAnimationExit(const StartupRoomSetupState &state,
+		StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState) {
+	if (!idleState.active || idleState.exiting || !idleState.entity)
+		return false;
+
+	const int lastFrame = idleState.entity->getLastFrame();
+	const int exitStartFrame = MIN(lastFrame, kRoomPlayerIdleLoopLastFrame);
+	const int exitLastFrame = MIN(lastFrame, kRoomPlayerIdleExitLastFrame);
+	if (exitLastFrame <= exitStartFrame)
+		return finishPlayerIdleAnimation(state, playerState, idleState);
+
+	positionPlayerIdleAnimationEntity(state, playerState, *idleState.entity);
+	idleState.exiting = true;
+	idleState.entity->setAnimationFrameRange(exitStartFrame, exitLastFrame, false);
+	idleState.entity->setCurrentFrame(exitStartFrame);
+	idleState.entity->setAnimationRate(kRoomPlayerIdleAnimationRate);
+	debugC(1, kDebugScene,
+		"Harvester: player idle animation exit room='%s' frames=%d..%d rate=%d",
+		state.roomName.c_str(), exitStartFrame, exitLastFrame, kRoomPlayerIdleAnimationRate);
+	return true;
+}
+
+bool RoomPlayerLogic::startIdleAnimation(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState) {
+	if (!playerState.entity || idleState.active || isIdleAnimationExcludedRoom(state.roomName))
+		return false;
+
+	RuntimeEntity *idleEntity = ensurePlayerIdleAnimationEntity(engine, state, playerState, idleState);
+	if (!idleEntity)
+		return false;
+
+	const int lastFrame = idleEntity->getLastFrame();
+	const int loopLastFrame = MIN(lastFrame, kRoomPlayerIdleLoopLastFrame);
+	positionPlayerIdleAnimationEntity(state, playerState, *idleEntity);
+	idleState.restoreFacing = playerState.facing;
+	idleState.active = true;
+	idleState.loopStarted = false;
+	idleState.exiting = false;
+	idleEntity->setVisible(true);
+	idleEntity->setAnimationFrameRange(0, loopLastFrame, false);
+	idleEntity->setCurrentFrame(0);
+	idleEntity->setAnimationRate(kRoomPlayerIdleAnimationRate);
+	playerState.hasMoveTarget = false;
+	playerState.turnActive = false;
+	playerState.turnTargetFacing = -1;
+	playerState.entity->setVisible(false);
+	debugC(1, kDebugScene,
+		"Harvester: player idle animation start room='%s' facing=%d frames=%d..%d rate=%d trigger_tick=%u",
+		state.roomName.c_str(), idleState.restoreFacing, 0, loopLastFrame,
+		kRoomPlayerIdleAnimationRate, idleState.triggerTick);
+	return true;
+}
+
+bool RoomPlayerLogic::updateIdleAnimation(const StartupRoomSetupState &state,
+		StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState) {
+	if (!idleState.active || !idleState.entity)
+		return false;
+
+	const int lastFrame = idleState.entity->getLastFrame();
+	const int loopLastFrame = MIN(lastFrame, kRoomPlayerIdleLoopLastFrame);
+	if (!idleState.exiting && !idleState.loopStarted &&
+			idleState.entity->getCurrentFrame() >= loopLastFrame) {
+		const int loopStartFrame = MIN(lastFrame, kRoomPlayerIdleLoopStartFrame);
+		idleState.loopStarted = true;
+		if (loopStartFrame < loopLastFrame) {
+			idleState.entity->setAnimationFrameRange(loopStartFrame, loopLastFrame, true);
+			idleState.entity->setCurrentFrame(loopStartFrame);
+			idleState.entity->setAnimationRate(kRoomPlayerIdleAnimationRate);
+		}
+		debugC(1, kDebugScene,
+			"Harvester: player idle animation loop room='%s' frames=%d..%d rate=%d",
+			state.roomName.c_str(), loopStartFrame, loopLastFrame, kRoomPlayerIdleAnimationRate);
+		return true;
+	}
+
+	if (!idleState.exiting)
+		return false;
+
+	const int exitLastFrame = MIN(lastFrame, kRoomPlayerIdleExitLastFrame);
+	if (idleState.entity->getCurrentFrame() < exitLastFrame)
+		return false;
+
+	return finishPlayerIdleAnimation(state, playerState, idleState);
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/room_player.h b/engines/harvester/room_player.h
new file mode 100644
index 00000000000..fbb70230249
--- /dev/null
+++ b/engines/harvester/room_player.h
@@ -0,0 +1,71 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_ROOM_PLAYER_H
+#define HARVESTER_ROOM_PLAYER_H
+
+#include "harvester/room_support.h"
+
+namespace Harvester {
+
+class RoomPlayerLogic {
+public:
+	static float computeDepthScale(const StartupRoomSetupState &state, float z);
+	static int resolveFacingFrame(int facing);
+	static Common::String resolveCombatLoadoutResourcePath(int loadout);
+	static bool supportsMovementBand(const StartupRoomSetupState &state);
+	static uint32 getRuntimeClockTicks();
+	static bool isIdleAnimationExcludedRoom(const Common::String &roomName);
+	static void updateIdleTrigger(StartupRoomIdleAnimationState &idleState);
+	static void setMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
+		int targetX, float targetZ);
+	static void setMoveTargetFromScreenPoint(const StartupRoomSetupState &state,
+		StartupRoomPlayerState &playerState, int targetX, int targetBottomY);
+	static int resolveRegionTargetX(const StartupRegionRecord &region,
+		const StartupRoomPlayerState &playerState);
+	static float resolveRegionTargetZ(const StartupRegionRecord &region);
+	static bool syncCombatLoadoutVisual(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		StartupRoomPlayerState &playerState, int loadout);
+	static bool setIdleAnimation(StartupRoomPlayerState &playerState, int facing);
+	static bool startAttackAnimation(const StartupRoomSetupState &state,
+		StartupRoomPlayerState &playerState, const Common::Point &mousePos);
+	static bool updateAttackAnimationState(HarvesterEngine &engine, StartupRoomPlayerState &playerState);
+	static bool startTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing);
+	static bool updateTurnAnimationState(StartupRoomPlayerState &playerState);
+	static bool stepMoveTarget(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		const Common::Array<StartupObjectRecord> &sceneObjects,
+		const Common::Array<StartupAnimRecord> &sceneAnimations,
+		StartupRoomPlayerState &playerState);
+	static bool stepKeyboardMovement(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		const Common::Array<StartupObjectRecord> &sceneObjects,
+		const Common::Array<StartupAnimRecord> &sceneAnimations,
+		StartupRoomPlayerState &playerState, bool moveLeft, bool moveRight, bool moveUp, bool moveDown);
+	static bool requestIdleAnimationExit(const StartupRoomSetupState &state,
+		StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState);
+	static bool startIdleAnimation(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState);
+	static bool updateIdleAnimation(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
+		StartupRoomIdleAnimationState &idleState);
+};
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_ROOM_PLAYER_H
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index 206c0719db4..ffcb866de69 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -97,15 +97,6 @@ struct StartupRoomIdleAnimationState {
 
 bool applyRoomActorPlacement(const StartupRoomSetupState &state, RuntimeEntity &entity,
 	int centerX, int bottomY, float z, const Common::String *entranceName = nullptr);
-void setPlayerMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
-	int targetX, float targetZ);
-void setPlayerMoveTargetFromScreenPoint(const StartupRoomSetupState &state,
-	StartupRoomPlayerState &playerState, int targetX, int targetBottomY);
-int resolveRegionTargetX(const StartupRegionRecord &region, const StartupRoomPlayerState &playerState);
-float resolveRegionTargetZ(const StartupRegionRecord &region);
-uint32 getRuntimeClockTicks();
-bool isIdleAnimationExcludedRoom(const Common::String &roomName);
-void updatePlayerIdleTrigger(StartupRoomIdleAnimationState &idleState);
 void logScenePaletteSummary(const char *label, const StartupRoomSceneResources &scene, float brightness);
 bool captureScreenBackdrop(const Graphics::Screen &screen, IndexedBitmap &bitmap);
 bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceManager &resources, StartupRoomSceneResources &scene);
@@ -130,28 +121,6 @@ StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const Start
 	const Common::Array<StartupNpcRecord> &npcs,
 	const Common::Array<StartupRegionRecord> &regions,
 	const Common::Point &mousePos);
-bool syncPlayerCombatLoadoutVisual(HarvesterEngine &engine, const StartupRoomSetupState &state,
-	StartupRoomPlayerState &playerState, int loadout);
-bool setPlayerIdleAnimation(StartupRoomPlayerState &playerState, int facing);
-bool startPlayerAttackAnimation(const StartupRoomSetupState &state,
-	StartupRoomPlayerState &playerState, const Common::Point &mousePos);
-bool updatePlayerAttackAnimationState(HarvesterEngine &engine, StartupRoomPlayerState &playerState);
-bool startPlayerTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing);
-bool updatePlayerTurnAnimationState(StartupRoomPlayerState &playerState);
-bool stepPlayerMoveTarget(HarvesterEngine &engine, const StartupRoomSetupState &state,
-	const Common::Array<StartupObjectRecord> &sceneObjects,
-	const Common::Array<StartupAnimRecord> &sceneAnimations,
-	StartupRoomPlayerState &playerState);
-bool stepPlayerKeyboardMovement(HarvesterEngine &engine, const StartupRoomSetupState &state,
-	const Common::Array<StartupObjectRecord> &sceneObjects,
-	const Common::Array<StartupAnimRecord> &sceneAnimations,
-	StartupRoomPlayerState &playerState, bool moveLeft, bool moveRight, bool moveUp, bool moveDown);
-bool requestPlayerIdleAnimationExit(const StartupRoomSetupState &state,
-	StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState);
-bool startPlayerIdleAnimation(HarvesterEngine &engine, const StartupRoomSetupState &state,
-	StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState);
-bool updatePlayerIdleAnimation(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
-	StartupRoomIdleAnimationState &idleState);
 void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResources &scene,
 	const Common::String &entranceName, Common::Point &mousePos);
 


Commit: 24417c2f1759045153168ed6dfac914fcb31179b
    https://github.com/scummvm/scummvm/commit/24417c2f1759045153168ed6dfac914fcb31179b
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:43+02:00

Commit Message:
HARVESTER: Rename player and monster room helpers

Changed paths:
  A engines/harvester/monster.cpp
  A engines/harvester/monster.h
  A engines/harvester/player.cpp
  A engines/harvester/player.h
  R engines/harvester/room_monster.cpp
  R engines/harvester/room_monster.h
  R engines/harvester/room_player.cpp
  R engines/harvester/room_player.h
    engines/harvester/flow.cpp
    engines/harvester/module.mk
    engines/harvester/room.cpp
    engines/harvester/script.h


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 919f098e856..21423e43624 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -42,8 +42,8 @@
 #include "harvester/resources.h"
 #include "harvester/runtime_entity.h"
 #include "harvester/art.h"
-#include "harvester/room_monster.h"
-#include "harvester/room_player.h"
+#include "harvester/monster.h"
+#include "harvester/player.h"
 #include "harvester/script.h"
 
 namespace Harvester {
@@ -333,7 +333,7 @@ static bool applyRoomActorPlacementInternal(const StartupRoomSetupState &state,
 
 	float depthScale = 1.0f;
 	if (applyDepthScale) {
-		depthScale = RoomPlayerLogic::computeDepthScale(state, z);
+		depthScale = Player::computeDepthScale(state, z);
 		entity.setDepthScale(depthScale);
 		if (!entity.getCurrentFrameMetrics(width, height, xOffset, yOffset))
 			return false;
@@ -872,7 +872,7 @@ StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const Start
 		return hoverState;
 	}
 
-	if (RoomPlayerLogic::supportsMovementBand(state) &&
+	if (Player::supportsMovementBand(state) &&
 		mousePos.y >= state.roomMaxZScreenY &&
 		mousePos.y <= state.roomMinZScreenY) {
 		hoverState.cursorSequence = kCursorSequenceWalk;
@@ -977,7 +977,7 @@ void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResource
 			hoveredObject->actionTag.c_str(), hasInteraction,
 			"", hasInspectText);
 
-		if (RoomPlayerLogic::supportsMovementBand(scene.state)) {
+		if (Player::supportsMovementBand(scene.state)) {
 			Common::Point floorProbe;
 			bool foundFloorProbe = false;
 			for (int y = scene.state.roomMaxZScreenY; y <= scene.state.roomMinZScreenY && !foundFloorProbe; y += 12) {
@@ -1010,9 +1010,9 @@ void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResource
 				probePlayer.bottomY = scene.state.playerSpawnY;
 				probePlayer.z = (float)scene.state.playerSpawnZ;
 				probePlayer.facing = scene.state.playerFacing;
-				RoomPlayerLogic::setMoveTargetFromScreenPoint(scene.state, probePlayer, floorProbe.x, floorProbe.y);
+				Player::setMoveTargetFromScreenPoint(scene.state, probePlayer, floorProbe.x, floorProbe.y);
 				for (int i = 0; i < 32 && probePlayer.hasMoveTarget; ++i)
-					(void)RoomPlayerLogic::stepMoveTarget(
+					(void)Player::stepMoveTarget(
 						engine, scene.state, scene.sceneObjects, scene.sceneAnimations, probePlayer);
 
 				const Common::Rect movedRect = player->getScreenRect();
@@ -1021,7 +1021,7 @@ void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResource
 					scene.state.roomName.c_str(), floorProbe.x, floorProbe.y,
 					movedRect.left, movedRect.top, movedRect.right, movedRect.bottom, (double)probePlayer.z);
 
-				player->setCurrentFrame(RoomPlayerLogic::resolveFacingFrame(scene.state.playerFacing));
+				player->setCurrentFrame(Player::resolveFacingFrame(scene.state.playerFacing));
 				(void)applyRoomActorPlacement(scene.state, *player,
 					scene.state.playerSpawnX, scene.state.playerSpawnY, (float)scene.state.playerSpawnZ);
 			}
@@ -1659,7 +1659,7 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 	for (const StartupMonsterRecord &monster : state.roomMonsters) {
 		RuntimeEntity *entity = runtimeEntities->spawnSceneActorEntity(monster.monsterName,
 			monster.modelPath, Common::Point(monster.posX, monster.posY), (float)monster.posZ,
-			RoomMonsterLogic::resolveFacingFrame(monster.facing));
+			Monster::resolveFacingFrame(monster.facing));
 		if (!entity) {
 			debug(1, "Harvester: unable to spawn room monster entity '%s' from '%s'",
 				monster.monsterName.c_str(), monster.modelPath.c_str());
@@ -1681,11 +1681,11 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 			monster.modelPath.c_str(), monster.active, monster.visible);
 	}
 	if (state.hasEntrance) {
-		const int playerFrame = RoomPlayerLogic::resolveFacingFrame(state.playerFacing);
+		const int playerFrame = Player::resolveFacingFrame(state.playerFacing);
 		Script *startupScript = _engine.getStartupScript();
 		const int playerCombatLoadout = startupScript ? startupScript->getPlayerCombatLoadout() : 0;
 		const Common::String playerResourcePath =
-			RoomPlayerLogic::resolveCombatLoadoutResourcePath(playerCombatLoadout);
+			Player::resolveCombatLoadoutResourcePath(playerCombatLoadout);
 		const bool reusedPlayer = preservedPlayer != nullptr;
 		RuntimeEntity *player = preservedPlayer;
 		if (!reusedPlayer) {
@@ -1715,7 +1715,7 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 				playerState.z = (float)state.playerSpawnZ;
 				playerState.facing = state.playerFacing;
 				playerState.combatLoadout = -1;
-				(void)RoomPlayerLogic::syncCombatLoadoutVisual(_engine, state, playerState, playerCombatLoadout);
+				(void)Player::syncCombatLoadoutVisual(_engine, state, playerState, playerCombatLoadout);
 			}
 			if (reusedPlayer)
 				runtimeEntities->adoptSceneEntity(player);
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 9db607ede03..b09ac75d113 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -64,8 +64,8 @@ MODULE := engines/harvester
 	flow.o \
 	inventory.o \
 	menu.o \
-	room_monster.o \
-	room_player.o \
+	monster.o \
+	player.o \
 	room.o \
 	script.o \
 	text.o \
diff --git a/engines/harvester/room_monster.cpp b/engines/harvester/monster.cpp
similarity index 92%
rename from engines/harvester/room_monster.cpp
rename to engines/harvester/monster.cpp
index a7731c006bd..064422e7fd4 100644
--- a/engines/harvester/room_monster.cpp
+++ b/engines/harvester/monster.cpp
@@ -19,7 +19,7 @@
  *
  */
 
-#include "harvester/room_monster.h"
+#include "harvester/monster.h"
 
 #include "harvester/runtime_entity.h"
 
@@ -56,11 +56,11 @@ static MonsterAnimationRange resolveAnimationRange(int facing) {
 
 } // End of anonymous namespace
 
-int RoomMonsterLogic::resolveFacingFrame(int facing) {
+int Monster::resolveFacingFrame(int facing) {
 	return resolveAnimationRange(facing).idleFrame;
 }
 
-void RoomMonsterLogic::applyAnimation(RuntimeEntity &entity, const StartupMonsterRecord &monster) {
+void Monster::applyAnimation(RuntimeEntity &entity, const StartupMonsterRecord &monster) {
 	entity.setVisible(monster.visible);
 	if (!monster.visible)
 		return;
diff --git a/engines/harvester/room_monster.h b/engines/harvester/monster.h
similarity index 51%
rename from engines/harvester/room_monster.h
rename to engines/harvester/monster.h
index 253a5107eb2..73fe9ee6f95 100644
--- a/engines/harvester/room_monster.h
+++ b/engines/harvester/monster.h
@@ -19,16 +19,55 @@
  *
  */
 
-#ifndef HARVESTER_ROOM_MONSTER_H
-#define HARVESTER_ROOM_MONSTER_H
+#ifndef HARVESTER_MONSTER_H
+#define HARVESTER_MONSTER_H
 
-#include "harvester/script.h"
+#include "common/str.h"
 
 namespace Harvester {
 
 class RuntimeEntity;
 
-class RoomMonsterLogic {
+struct StartupMonsterRecord {
+	int posX = 0;
+	int posY = 0;
+	int posZ = 0;
+	int initialHitPoints = 0;
+	int currentHitPoints = 0;
+	int damageAmount = 0;
+	int engageDistance = 0;
+	int damageType = 0;
+	int facing = -1;
+	Common::String roomName;
+	Common::String monsterName;
+	Common::String modelPath;
+	Common::String field38;
+	Common::String field3c;
+	Common::String field44;
+	Common::String field48;
+	Common::String attackSound1;
+	Common::String attackSound2;
+	Common::String attackSound3;
+	Common::String hitSound1;
+	Common::String hitSound2;
+	Common::String hitSound3;
+	Common::String footstepSoundLeft;
+	Common::String footstepSoundRight;
+	Common::String deathSound;
+	Common::String onDeathActionTag;
+	bool active = false;
+	bool visible = false;
+	bool savedVisible = false;
+	bool runtimeSpawned = false;
+	int minXBound = 0x14;
+	int maxXBound = 0x26b;
+	int field70 = 0;
+	int field74 = 0;
+	int field78 = 0;
+	int field7c = 0;
+};
+
+class Monster {
 public:
 	static int resolveFacingFrame(int facing);
 	static void applyAnimation(RuntimeEntity &entity, const StartupMonsterRecord &monster);
@@ -36,4 +75,4 @@ public:
 
 } // End of namespace Harvester
 
-#endif // HARVESTER_ROOM_MONSTER_H
+#endif // HARVESTER_MONSTER_H
diff --git a/engines/harvester/room_player.cpp b/engines/harvester/player.cpp
similarity index 93%
rename from engines/harvester/room_player.cpp
rename to engines/harvester/player.cpp
index df1b5c58857..3f2e8fb3c11 100644
--- a/engines/harvester/room_player.cpp
+++ b/engines/harvester/player.cpp
@@ -21,7 +21,7 @@
 
 #include <math.h>
 
-#include "harvester/room_player.h"
+#include "harvester/player.h"
 
 #include "common/debug.h"
 #include "common/system.h"
@@ -203,7 +203,7 @@ static bool resolvePlayerAttackAnimationRange(const StartupRoomSetupState &state
 	if (!playerState.entity)
 		return false;
 
-	const float depthScale = RoomPlayerLogic::computeDepthScale(state, playerState.z);
+	const float depthScale = Player::computeDepthScale(state, playerState.z);
 	const float upperThresholdY =
 		(float)playerState.bottomY - depthScale * kRoomPlayerAttackUpperYOffset;
 	const float midThresholdY =
@@ -229,14 +229,14 @@ static bool resolvePlayerAttackAnimationRange(const StartupRoomSetupState &state
 }
 
 static int clampRoomMovementY(const StartupRoomSetupState &state, int screenY) {
-	if (!RoomPlayerLogic::supportsMovementBand(state))
+	if (!Player::supportsMovementBand(state))
 		return screenY;
 
 	return CLIP<int>(screenY, state.roomMaxZScreenY, state.roomMinZScreenY);
 }
 
 static float mapRoomScreenYToDepth(const StartupRoomSetupState &state, int screenY) {
-	if (!RoomPlayerLogic::supportsMovementBand(state))
+	if (!Player::supportsMovementBand(state))
 		return (float)state.playerSpawnZ;
 
 	if (screenY <= state.roomMaxZScreenY)
@@ -250,7 +250,7 @@ static float mapRoomScreenYToDepth(const StartupRoomSetupState &state, int scree
 }
 
 static float clampRoomDepth(const StartupRoomSetupState &state, float z) {
-	if (!RoomPlayerLogic::supportsMovementBand(state))
+	if (!Player::supportsMovementBand(state))
 		return z;
 
 	return CLIP<float>(z,
@@ -259,7 +259,7 @@ static float clampRoomDepth(const StartupRoomSetupState &state, float z) {
 }
 
 static int mapRoomDepthToScreenY(const StartupRoomSetupState &state, float z) {
-	if (!RoomPlayerLogic::supportsMovementBand(state))
+	if (!Player::supportsMovementBand(state))
 		return state.playerSpawnY;
 
 	if (state.roomMaxZ == state.roomMinZ)
@@ -288,7 +288,7 @@ static int resolveFacingFromRoomMovement(const StartupRoomSetupState &state,
 }
 
 static int computeRoomPlayerHorizontalStep(const StartupRoomSetupState &state, float z) {
-	return MAX<int>(1, roundToInt(RoomPlayerLogic::computeDepthScale(state, z) * kRoomPlayerHorizontalMoveBase));
+	return MAX<int>(1, roundToInt(Player::computeDepthScale(state, z) * kRoomPlayerHorizontalMoveBase));
 }
 
 static float computeRoomPlayerDepthStep(const StartupRoomSetupState &state) {
@@ -323,7 +323,7 @@ static bool consumePlayerMovementTick(StartupRoomPlayerState &playerState) {
 		return true;
 	}
 
-	const uint32 now = RoomPlayerLogic::getRuntimeClockTicks();
+	const uint32 now = Player::getRuntimeClockTicks();
 	if (playerState.nextMovementTick != 0 &&
 			(int32)(now - playerState.nextMovementTick) < 0) {
 		return false;
@@ -419,8 +419,8 @@ static bool tryApplyPlayerMovement(HarvesterEngine &engine, const StartupRoomSet
 }
 
 static void notePlayerIdleReset(StartupRoomIdleAnimationState &idleState) {
-	idleState.resetTick = RoomPlayerLogic::getRuntimeClockTicks();
-	RoomPlayerLogic::updateIdleTrigger(idleState);
+	idleState.resetTick = Player::getRuntimeClockTicks();
+	Player::updateIdleTrigger(idleState);
 }
 
 static bool setPlayerWalkAnimation(StartupRoomPlayerState &playerState, int facing) {
@@ -450,7 +450,7 @@ static void positionPlayerIdleAnimationEntity(const StartupRoomSetupState &state
 		return;
 
 	idleEntity.setAnchorMode(kRuntimeEntityAnchorTopLeft);
-	idleEntity.setDepthScale(RoomPlayerLogic::computeDepthScale(state, playerState.z));
+	idleEntity.setDepthScale(Player::computeDepthScale(state, playerState.z));
 	idleEntity.setPosition(playerState.entity->getX(),
 		playerState.entity->getY() + kRoomPlayerIdleYOffset, playerState.z);
 }
@@ -495,7 +495,7 @@ static bool finishPlayerIdleAnimation(const StartupRoomSetupState &state, Startu
 	if (playerState.entity) {
 		playerState.entity->setVisible(true);
 		if (idleState.restoreFacing >= 0)
-			(void)RoomPlayerLogic::setIdleAnimation(playerState, idleState.restoreFacing);
+			(void)Player::setIdleAnimation(playerState, idleState.restoreFacing);
 	}
 
 	idleState.active = false;
@@ -511,7 +511,7 @@ static bool finishPlayerIdleAnimation(const StartupRoomSetupState &state, Startu
 
 } // End of anonymous namespace
 
-float RoomPlayerLogic::computeDepthScale(const StartupRoomSetupState &state, float z) {
+float Player::computeDepthScale(const StartupRoomSetupState &state, float z) {
 	float scale = 1.0f;
 	if (state.roomPerspectiveScale != 0.0f) {
 		scale -= (z - (float)state.roomFullScaleZ) * state.roomPerspectiveScale;
@@ -522,7 +522,7 @@ float RoomPlayerLogic::computeDepthScale(const StartupRoomSetupState &state, flo
 	return scale;
 }
 
-int RoomPlayerLogic::resolveFacingFrame(int facing) {
+int Player::resolveFacingFrame(int facing) {
 	switch (facing) {
 	case 0:
 		return 0x3b;
@@ -537,23 +537,23 @@ int RoomPlayerLogic::resolveFacingFrame(int facing) {
 	}
 }
 
-Common::String RoomPlayerLogic::resolveCombatLoadoutResourcePath(int loadout) {
+Common::String Player::resolveCombatLoadoutResourcePath(int loadout) {
 	if (loadout <= 0)
 		return Common::String(kPlayerActorResourcePath);
 
 	return Common::String::format("1:/GRAPHIC/MONSTERS/PC/PC%02d.ABM", loadout);
 }
 
-bool RoomPlayerLogic::supportsMovementBand(const StartupRoomSetupState &state) {
+bool Player::supportsMovementBand(const StartupRoomSetupState &state) {
 	return state.roomMaxZScreenY >= 0 &&
 		state.roomMinZScreenY >= state.roomMaxZScreenY;
 }
 
-uint32 RoomPlayerLogic::getRuntimeClockTicks() {
+uint32 Player::getRuntimeClockTicks() {
 	return g_system ? (g_system->getMillis() / kRuntimeClockDivisorMs) : 0;
 }
 
-bool RoomPlayerLogic::isIdleAnimationExcludedRoom(const Common::String &roomName) {
+bool Player::isIdleAnimationExcludedRoom(const Common::String &roomName) {
 	static const char *const kExcludedRooms[] = {
 		"JAWS",
 		"SUPPLY1",
@@ -573,11 +573,11 @@ bool RoomPlayerLogic::isIdleAnimationExcludedRoom(const Common::String &roomName
 	return false;
 }
 
-void RoomPlayerLogic::updateIdleTrigger(StartupRoomIdleAnimationState &idleState) {
+void Player::updateIdleTrigger(StartupRoomIdleAnimationState &idleState) {
 	idleState.triggerTick = MAX(idleState.activityTick, idleState.resetTick) + kRoomPlayerIdleDelayTicks;
 }
 
-void RoomPlayerLogic::setMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
+void Player::setMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
 		int targetX, float targetZ) {
 	playerState.hasMoveTarget = true;
 	playerState.nextMovementTick = 0;
@@ -589,13 +589,13 @@ void RoomPlayerLogic::setMoveTarget(const StartupRoomSetupState &state, StartupR
 		playerState.targetX, mapRoomDepthToScreenY(state, playerState.targetZ), (double)playerState.targetZ);
 }
 
-void RoomPlayerLogic::setMoveTargetFromScreenPoint(const StartupRoomSetupState &state,
+void Player::setMoveTargetFromScreenPoint(const StartupRoomSetupState &state,
 		StartupRoomPlayerState &playerState, int targetX, int targetBottomY) {
 	setMoveTarget(state, playerState, targetX,
 		mapRoomScreenYToDepth(state, clampRoomMovementY(state, targetBottomY)));
 }
 
-int RoomPlayerLogic::resolveRegionTargetX(const StartupRegionRecord &region,
+int Player::resolveRegionTargetX(const StartupRegionRecord &region,
 		const StartupRoomPlayerState &playerState) {
 	const Common::Rect bounds(region.left, region.top, region.right + 1, region.bottom + 1);
 	int targetX = bounds.left + bounds.width() / 2;
@@ -604,7 +604,7 @@ int RoomPlayerLogic::resolveRegionTargetX(const StartupRegionRecord &region,
 	return CLIP<int>(targetX, 0, 639);
 }
 
-float RoomPlayerLogic::resolveRegionTargetZ(const StartupRegionRecord &region) {
+float Player::resolveRegionTargetZ(const StartupRegionRecord &region) {
 	if (region.desiredFacing == 0)
 		return (float)region.maxZ;
 	if (region.desiredFacing == 3)
@@ -613,7 +613,7 @@ float RoomPlayerLogic::resolveRegionTargetZ(const StartupRegionRecord &region) {
 	return (float)(region.minZ + (region.maxZ - region.minZ) / 2);
 }
 
-bool RoomPlayerLogic::syncCombatLoadoutVisual(HarvesterEngine &engine, const StartupRoomSetupState &state,
+bool Player::syncCombatLoadoutVisual(HarvesterEngine &engine, const StartupRoomSetupState &state,
 		StartupRoomPlayerState &playerState, int loadout) {
 	if (!playerState.entity)
 		return false;
@@ -647,7 +647,7 @@ bool RoomPlayerLogic::syncCombatLoadoutVisual(HarvesterEngine &engine, const Sta
 	return true;
 }
 
-bool RoomPlayerLogic::setIdleAnimation(StartupRoomPlayerState &playerState, int facing) {
+bool Player::setIdleAnimation(StartupRoomPlayerState &playerState, int facing) {
 	if (!playerState.entity)
 		return false;
 
@@ -668,7 +668,7 @@ bool RoomPlayerLogic::setIdleAnimation(StartupRoomPlayerState &playerState, int
 	return changed;
 }
 
-bool RoomPlayerLogic::startAttackAnimation(const StartupRoomSetupState &state,
+bool Player::startAttackAnimation(const StartupRoomSetupState &state,
 		StartupRoomPlayerState &playerState, const Common::Point &mousePos) {
 	if (!playerState.entity || playerState.attackActive)
 		return false;
@@ -702,7 +702,7 @@ bool RoomPlayerLogic::startAttackAnimation(const StartupRoomSetupState &state,
 	return true;
 }
 
-bool RoomPlayerLogic::updateAttackAnimationState(HarvesterEngine &engine,
+bool Player::updateAttackAnimationState(HarvesterEngine &engine,
 		StartupRoomPlayerState &playerState) {
 	if (!playerState.attackActive || !playerState.entity)
 		return false;
@@ -727,7 +727,7 @@ bool RoomPlayerLogic::updateAttackAnimationState(HarvesterEngine &engine,
 	return setIdleAnimation(playerState, resumeFacing);
 }
 
-bool RoomPlayerLogic::startTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing) {
+bool Player::startTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing) {
 	if (!playerState.entity || playerState.facing < 0 || playerState.facing == targetFacing)
 		return false;
 
@@ -753,7 +753,7 @@ bool RoomPlayerLogic::startTurnAnimation(StartupRoomPlayerState &playerState, in
 	return true;
 }
 
-bool RoomPlayerLogic::updateTurnAnimationState(StartupRoomPlayerState &playerState) {
+bool Player::updateTurnAnimationState(StartupRoomPlayerState &playerState) {
 	if (!playerState.turnActive || !playerState.entity)
 		return false;
 	if (playerState.entity->getCurrentFrame() != playerState.turnEndFrame)
@@ -773,7 +773,7 @@ bool RoomPlayerLogic::updateTurnAnimationState(StartupRoomPlayerState &playerSta
 	return true;
 }
 
-bool RoomPlayerLogic::stepMoveTarget(HarvesterEngine &engine, const StartupRoomSetupState &state,
+bool Player::stepMoveTarget(HarvesterEngine &engine, const StartupRoomSetupState &state,
 		const Common::Array<StartupObjectRecord> &sceneObjects,
 		const Common::Array<StartupAnimRecord> &sceneAnimations,
 		StartupRoomPlayerState &playerState) {
@@ -831,7 +831,7 @@ bool RoomPlayerLogic::stepMoveTarget(HarvesterEngine &engine, const StartupRoomS
 	return true;
 }
 
-bool RoomPlayerLogic::stepKeyboardMovement(HarvesterEngine &engine, const StartupRoomSetupState &state,
+bool Player::stepKeyboardMovement(HarvesterEngine &engine, const StartupRoomSetupState &state,
 		const Common::Array<StartupObjectRecord> &sceneObjects,
 		const Common::Array<StartupAnimRecord> &sceneAnimations,
 		StartupRoomPlayerState &playerState, bool moveLeft, bool moveRight, bool moveUp, bool moveDown) {
@@ -907,7 +907,7 @@ bool RoomPlayerLogic::stepKeyboardMovement(HarvesterEngine &engine, const Startu
 	return true;
 }
 
-bool RoomPlayerLogic::requestIdleAnimationExit(const StartupRoomSetupState &state,
+bool Player::requestIdleAnimationExit(const StartupRoomSetupState &state,
 		StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState) {
 	if (!idleState.active || idleState.exiting || !idleState.entity)
 		return false;
@@ -929,7 +929,7 @@ bool RoomPlayerLogic::requestIdleAnimationExit(const StartupRoomSetupState &stat
 	return true;
 }
 
-bool RoomPlayerLogic::startIdleAnimation(HarvesterEngine &engine, const StartupRoomSetupState &state,
+bool Player::startIdleAnimation(HarvesterEngine &engine, const StartupRoomSetupState &state,
 		StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState) {
 	if (!playerState.entity || idleState.active || isIdleAnimationExcludedRoom(state.roomName))
 		return false;
@@ -960,7 +960,7 @@ bool RoomPlayerLogic::startIdleAnimation(HarvesterEngine &engine, const StartupR
 	return true;
 }
 
-bool RoomPlayerLogic::updateIdleAnimation(const StartupRoomSetupState &state,
+bool Player::updateIdleAnimation(const StartupRoomSetupState &state,
 		StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState) {
 	if (!idleState.active || !idleState.entity)
 		return false;
diff --git a/engines/harvester/room_player.h b/engines/harvester/player.h
similarity index 96%
rename from engines/harvester/room_player.h
rename to engines/harvester/player.h
index fbb70230249..eb56a25d193 100644
--- a/engines/harvester/room_player.h
+++ b/engines/harvester/player.h
@@ -19,14 +19,14 @@
  *
  */
 
-#ifndef HARVESTER_ROOM_PLAYER_H
-#define HARVESTER_ROOM_PLAYER_H
+#ifndef HARVESTER_PLAYER_H
+#define HARVESTER_PLAYER_H
 
 #include "harvester/room_support.h"
 
 namespace Harvester {
 
-class RoomPlayerLogic {
+class Player {
 public:
 	static float computeDepthScale(const StartupRoomSetupState &state, float z);
 	static int resolveFacingFrame(int facing);
@@ -68,4 +68,4 @@ public:
 
 } // End of namespace Harvester
 
-#endif // HARVESTER_ROOM_PLAYER_H
+#endif // HARVESTER_PLAYER_H
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 4a2f10fcec0..00367da1fa5 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -39,8 +39,8 @@
 #include "harvester/art.h"
 #include "harvester/flow.h"
 #include "harvester/inventory.h"
-#include "harvester/room_monster.h"
-#include "harvester/room_player.h"
+#include "harvester/monster.h"
+#include "harvester/player.h"
 #include "harvester/room_support.h"
 
 namespace Harvester {
@@ -343,7 +343,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			for (const StartupMonsterRecord &monster : scene.state.roomMonsters) {
 				RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(monster.monsterName);
 				if (entity)
-					RoomMonsterLogic::applyAnimation(*entity, monster);
+					Monster::applyAnimation(*entity, monster);
 			}
 			runtimeEntities->pauseTimerCountdowns();
 		}
@@ -420,13 +420,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		};
 		auto resetIdleState = [&]() {
 			idleState = StartupRoomIdleAnimationState();
-			idleState.activityTick = RoomPlayerLogic::getRuntimeClockTicks();
+			idleState.activityTick = Player::getRuntimeClockTicks();
 			idleState.resetTick = idleState.activityTick;
-			RoomPlayerLogic::updateIdleTrigger(idleState);
+			Player::updateIdleTrigger(idleState);
 		};
 		auto notePlayerActivity = [&]() {
-			idleState.activityTick = RoomPlayerLogic::getRuntimeClockTicks();
-			RoomPlayerLogic::updateIdleTrigger(idleState);
+			idleState.activityTick = Player::getRuntimeClockTicks();
+			Player::updateIdleTrigger(idleState);
 		};
 		auto cancelPlayerAttackAnimation = [&]() {
 			if (!playerState.attackActive)
@@ -443,7 +443,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				? playerState.attackResumeFacing
 				: (playerState.facing >= 0 ? playerState.facing : 0);
 			playerState.attackResumeFacing = -1;
-			return RoomPlayerLogic::setIdleAnimation(playerState, resumeFacing);
+			return Player::setIdleAnimation(playerState, resumeFacing);
 		};
 		auto syncCurrentRoomRuntimeState = [&]() {
 			Script *startupScript = _engine.getStartupScript();
@@ -492,7 +492,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			playerState.turnActive = false;
 			playerState.turnTargetFacing = -1;
 			if (playerState.entity && playerState.facing >= 0)
-				(void)RoomPlayerLogic::setIdleAnimation(playerState, playerState.facing);
+				(void)Player::setIdleAnimation(playerState, playerState.facing);
 		};
 		auto refreshCurrentScene = [&](bool preservePlayerPlacement) {
 			const Common::Array<StartupAudioCommand> entryAudioCommands = scene.state.audioCommands;
@@ -511,7 +511,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				for (const StartupMonsterRecord &monster : scene.state.roomMonsters) {
 					RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(monster.monsterName);
 					if (entity)
-						RoomMonsterLogic::applyAnimation(*entity, monster);
+						Monster::applyAnimation(*entity, monster);
 				}
 			}
 
@@ -525,7 +525,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				}
 
 				const int facing = playerState.facing >= 0 ? playerState.facing : scene.state.playerFacing;
-				(void)RoomPlayerLogic::setIdleAnimation(playerState, facing);
+				(void)Player::setIdleAnimation(playerState, facing);
 				(void)applyRoomActorPlacement(scene.state, *playerState.entity,
 					playerState.centerX, playerState.bottomY, playerState.z);
 			}
@@ -666,7 +666,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			if (playerState.entity) {
 				const int facing = playerState.facing >= 0 ? playerState.facing : scene.state.playerFacing;
 				if (facing >= 0)
-					(void)RoomPlayerLogic::setIdleAnimation(playerState, facing);
+					(void)Player::setIdleAnimation(playerState, facing);
 				(void)applyRoomActorPlacement(scene.state, *playerState.entity,
 					playerState.centerX, playerState.bottomY, playerState.z);
 			}
@@ -1044,9 +1044,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		if (doesPlayerOverlapRegion(*playerState.entity, region))
 			return;
 
-		RoomPlayerLogic::setMoveTarget(scene.state, playerState,
-			RoomPlayerLogic::resolveRegionTargetX(region, playerState),
-			RoomPlayerLogic::resolveRegionTargetZ(region));
+		Player::setMoveTarget(scene.state, playerState,
+			Player::resolveRegionTargetX(region, playerState),
+			Player::resolveRegionTargetZ(region));
 	};
 	auto runRegionInteraction = [&](const StartupRegionRecord &region) -> Common::Error {
 		StartupInteractionResult interaction;
@@ -1080,7 +1080,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			return Common::kNoError;
 		if (!doesPlayerFacingMatchRegion(playerState.facing, *region)) {
 			if (!playerState.hasMoveTarget && !playerState.turnActive && region->desiredFacing >= 0)
-				(void)RoomPlayerLogic::startTurnAnimation(playerState, region->desiredFacing);
+				(void)Player::startTurnAnimation(playerState, region->desiredFacing);
 			return Common::kNoError;
 		}
 
@@ -1258,7 +1258,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			case Common::EVENT_RBUTTONDOWN: {
 				notePlayerActivity();
 				if (idleState.active || idleState.exiting) {
-					if (RoomPlayerLogic::requestIdleAnimationExit(scene.state, playerState, idleState))
+					if (Player::requestIdleAnimationExit(scene.state, playerState, idleState))
 						needsRedraw = true;
 					break;
 				}
@@ -1288,7 +1288,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 							if (loadoutChanged) {
 								captureCurrentSaveState();
 								if (playerState.entity) {
-									(void)RoomPlayerLogic::syncCombatLoadoutVisual(_engine, scene.state, playerState,
+									(void)Player::syncCombatLoadoutVisual(_engine, scene.state, playerState,
 										_engine.getStartupScript()->getPlayerCombatLoadout());
 								} else if (_engine.getStartupScript()) {
 									playerState.combatLoadout =
@@ -1374,26 +1374,26 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					playerState.turnLastFrame = -1;
 					playerState.turnEndFrame = -1;
 					playerState.turnPlayBackwards = false;
-					(void)RoomPlayerLogic::syncCombatLoadoutVisual(_engine, scene.state, playerState,
+					(void)Player::syncCombatLoadoutVisual(_engine, scene.state, playerState,
 						startupScript->getPlayerCombatLoadout());
-					if (RoomPlayerLogic::startAttackAnimation(scene.state, playerState, _mousePos))
+					if (Player::startAttackAnimation(scene.state, playerState, _mousePos))
 						needsRedraw = true;
 				}
 				break;
 			}
 			case Common::EVENT_LBUTTONUP:
-				lastLeftButtonReleaseTick = RoomPlayerLogic::getRuntimeClockTicks();
+				lastLeftButtonReleaseTick = Player::getRuntimeClockTicks();
 				if (showingInspectText)
 					inspectCanDismiss = true;
 				break;
 			case Common::EVENT_LBUTTONDOWN: {
-				const uint32 now = RoomPlayerLogic::getRuntimeClockTicks();
+				const uint32 now = Player::getRuntimeClockTicks();
 				const bool isFastExitClick =
 					lastLeftButtonReleaseTick != 0 &&
 					now - lastLeftButtonReleaseTick < kRoomExitFastClickWindowTicks;
 				notePlayerActivity();
 				if (idleState.active || idleState.exiting) {
-					if (RoomPlayerLogic::requestIdleAnimationExit(scene.state, playerState, idleState))
+					if (Player::requestIdleAnimationExit(scene.state, playerState, idleState))
 						needsRedraw = true;
 					break;
 				}
@@ -1562,7 +1562,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				if (!clickedObject) {
 					pendingRegionName.clear();
 					if (hoverState.cursorSequence == 0 && playerState.entity) {
-						RoomPlayerLogic::setMoveTargetFromScreenPoint(scene.state, playerState, _mousePos.x, _mousePos.y);
+						Player::setMoveTargetFromScreenPoint(scene.state, playerState, _mousePos.x, _mousePos.y);
 						needsRedraw = true;
 					}
 					break;
@@ -1647,7 +1647,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 				notePlayerActivity();
 				if (idleState.active || idleState.exiting) {
-					if (RoomPlayerLogic::requestIdleAnimationExit(scene.state, playerState, idleState))
+					if (Player::requestIdleAnimationExit(scene.state, playerState, idleState))
 						needsRedraw = true;
 					break;
 				}
@@ -1757,21 +1757,21 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		}
 
 		bool playerAdvancedThisFrame = false;
-		if (RoomPlayerLogic::updateAttackAnimationState(_engine, playerState)) {
+		if (Player::updateAttackAnimationState(_engine, playerState)) {
 			needsRedraw = true;
 		}
-		if (!playerState.attackActive && RoomPlayerLogic::updateTurnAnimationState(playerState)) {
+		if (!playerState.attackActive && Player::updateTurnAnimationState(playerState)) {
 			playerAdvancedThisFrame = true;
 			needsRedraw = true;
 		}
 
 		if (!playerState.attackActive && !idleState.active && !idleState.exiting) {
-			if (RoomPlayerLogic::stepKeyboardMovement(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
+			if (Player::stepKeyboardMovement(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
 					playerState, moveLeft, moveRight, moveUp, moveDown)) {
 				playerAdvancedThisFrame = true;
 				notePlayerActivity();
 				needsRedraw = true;
-			} else if (RoomPlayerLogic::stepMoveTarget(
+			} else if (Player::stepMoveTarget(
 					_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
 					playerState)) {
 				playerAdvancedThisFrame = true;
@@ -1780,16 +1780,16 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			} else if (!moveLeft && !moveRight && !moveUp && !moveDown && !playerState.hasMoveTarget &&
 					!playerState.turnActive &&
 					playerState.entity && playerState.facing >= 0 &&
-					RoomPlayerLogic::setIdleAnimation(playerState, playerState.facing)) {
+					Player::setIdleAnimation(playerState, playerState.facing)) {
 				needsRedraw = true;
 			}
 
 			if (!showingInspectText && !moveLeft && !moveRight && !moveUp && !moveDown &&
 					!playerState.hasMoveTarget && !playerState.turnActive &&
 					playerState.entity && playerState.facing >= 0 &&
-					!RoomPlayerLogic::isIdleAnimationExcludedRoom(scene.state.roomName) &&
-					RoomPlayerLogic::getRuntimeClockTicks() > idleState.triggerTick &&
-					RoomPlayerLogic::startIdleAnimation(_engine, scene.state, playerState, idleState)) {
+					!Player::isIdleAnimationExcludedRoom(scene.state.roomName) &&
+					Player::getRuntimeClockTicks() > idleState.triggerTick &&
+					Player::startIdleAnimation(_engine, scene.state, playerState, idleState)) {
 				needsRedraw = true;
 			}
 		}
@@ -1843,7 +1843,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				}
 			}
 		}
-		if (RoomPlayerLogic::updateIdleAnimation(scene.state, playerState, idleState))
+		if (Player::updateIdleAnimation(scene.state, playerState, idleState))
 			needsRedraw = true;
 
 		limiter.delayBeforeSwap();
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index e5c7e48be97..c48cf51d029 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -24,6 +24,7 @@
 
 #include "common/array.h"
 #include "common/str.h"
+#include "harvester/monster.h"
 
 namespace Common {
 class Serializer;
@@ -148,45 +149,6 @@ struct StartupNpcRecord {
 	Common::String entityInitArg;
 };
 
-struct StartupMonsterRecord {
-	int posX = 0;
-	int posY = 0;
-	int posZ = 0;
-	int initialHitPoints = 0;
-	int currentHitPoints = 0;
-	int damageAmount = 0;
-	int engageDistance = 0;
-	int damageType = 0;
-	int facing = -1;
-	Common::String roomName;
-	Common::String monsterName;
-	Common::String modelPath;
-	Common::String field38;
-	Common::String field3c;
-	Common::String field44;
-	Common::String field48;
-	Common::String attackSound1;
-	Common::String attackSound2;
-	Common::String attackSound3;
-	Common::String hitSound1;
-	Common::String hitSound2;
-	Common::String hitSound3;
-	Common::String footstepSoundLeft;
-	Common::String footstepSoundRight;
-	Common::String deathSound;
-	Common::String onDeathActionTag;
-	bool active = false;
-	bool visible = false;
-	bool savedVisible = false;
-	bool runtimeSpawned = false;
-	int minXBound = 0x14;
-	int maxXBound = 0x26b;
-	int field70 = 0;
-	int field74 = 0;
-	int field78 = 0;
-	int field7c = 0;
-};
-
 struct StartupTimerRecord {
 	int initialValue = 0;
 	int currentValue = 0;


Commit: 98d7f3e898e15ed730b070c251681c6b4ea7db3b
    https://github.com/scummvm/scummvm/commit/98d7f3e898e15ed730b070c251681c6b4ea7db3b
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:43+02:00

Commit Message:
HARVESTER: Name recovered record fields

Changed paths:
    ARCHITECTURE.md
    FILE_FORMATS.md
    TRACKER.md
    engines/harvester/monster.h
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 0a2d3ecb3b8..7c33c344151 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -410,11 +410,12 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - On direct entrance matches it updates `g_pending_room_name`.
   - On map-entry matches it launches the town map selector and then updates the pending room / player position from the chosen destination.
 - `MapEntranceRecord` is now stable enough for engine use:
-  - `field_00`
-  - `field_04`
+  - `map_x`
+  - `map_y`
   - `initial_panel_index`
   - `entry_name`
   - `next`
+  - The selector currently only reads `initial_panel_index`; `map_x` / `map_y` remain stored script coordinates with no recovered read-side consumer in the current binary.
 - `select_town_map_destination` at `0x66460` is the town-map travel UI.
   - It loads `harvmap.pal` and `harvmap1.bm` through `harvmap4.bm`.
   - It seeds the initial map panel from `g_town_map_entrypoints`.
@@ -804,7 +805,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - Save/load only persists the mutable object state: `current_x`, `current_y`, `current_z`, `current_owner_or_room`, `visible`, and `ident_shown`, keyed by `object_name`.
   - `interaction_label` is copied into the runtime object entity and drives the main-loop hover / use prompt text; a `NULL_ID` label suppresses prompt generation.
   - `z_extent` is copied into the runtime object entity at offset `+0x20`; `FUN_0004cae0` uses the live interval `[entity + 0x1c, entity + 0x1c + 0x20]` before the 2D overlap test, which confirms that the field participates in third-axis collision gating.
-  - `field_40` is a heap string freed by `free_loaded_world_data`, but no confirmed live consumer has been recovered yet.
+  - `reserved_x_flag` is a parsed string slot that is either empty or the literal `X` in current `HARVEST.SCR` data. No read-side consumer has been recovered, so it remains a reserved script flag rather than a gameplay name.
+  - `reserved_string_40` is a heap string freed by `free_loaded_world_data`, but no confirmed live consumer has been recovered yet. In current `HARVEST.SCR` data it is always empty.
   - `spawn_object_entity_from_record` uses `sprite_path` while the object remains at its initial position / owner outside `INVENTORY`, otherwise it switches to `alt_sprite_path`.
   - When `sprite_path` is null, the same helper allocates a rectangle-only hotspot from `current_x`, `current_y`, `bounds_x2`, and `bounds_y2` instead of a bitmap-backed render entity.
 - `RegionRecord` is now structurally clear:
@@ -864,16 +866,17 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - attacker `0x1000` must be set before attack states `0x17/0x1a` can be chosen
     - target `0x10000` must be set before attack states `0x18/0x1b` can be chosen
     - the player combat-avatar base mask `0x00fffff8` keeps both bits set by default
-  - `field_70` is the attack-sound trigger frame
-  - `field_78` is the walk / footstep-sound trigger frame
+  - `attack_sound_trigger_frame` is the attack-sound trigger frame
+  - `hit_sound_trigger_frame` is the reserved frame slot paired with the hit-sound bank at runtime offsets `+0x1154..+0x115c` / `+0x1170`
+  - `footstep_sound_trigger_frame` is the walk / footstep-sound trigger frame
+  - `death_sound_trigger_frame` is the reserved frame slot paired with the death sound slot at runtime offsets `+0x1168` / `+0x1178`
   - `saved_enabled`, `runtime_spawned`, `next`
   - `current_hit_points` is the save/load-persisted monster HP field; parser initialization copies `initial_hit_points` into it.
   - `min_x_bound` defaults to `0x14` and `max_x_bound` defaults to `0x26b` when omitted by the parser.
   - The sound-slot mapping is confirmed both by `spawn_monster_entity_from_record` and by the player combat avatar builder, which installs the same runtime sound layout explicitly.
   - `spawn_player_combat_avatar` seeds loadout-specific player attack samples into those same `+0x1148..+0x1150` slots, and `update_actor_runtime_state` triggers the player attack sound when `current_frame_index == first_frame_index + 1`; melee loadouts randomly choose one loaded attack sample, while ranged loadouts route that frame through the weapon fire/empty helper instead of the random selector.
-  - `field_38`, `field_3c`, `field_44`, and `field_48` are heap strings freed during world teardown, but no confirmed live consumers have been recovered yet.
-  - `field_74` and `field_7c` are copied into live runtime slots `+0x1170` and `+0x1178`, but the current binary only writes those slots; no read-side consumers were recovered.
-  - Remaining unknowns include `field_1c`, `field_38`, `field_3c`, `field_44`, `field_48`, `field_74`, `field_7c`
+  - `reserved_string_38`, `reserved_string_3c`, `reserved_string_44`, and `reserved_string_48` are heap strings freed during world teardown, but no confirmed live consumers have been recovered yet. In the sampled shipping `HARVEST.SCR` data those columns are empty for every monster record.
+  - `reserved_dword_1c` remains zero-filled with no recovered producer beyond the initial allocator clear and no recovered read-side consumer.
 - `AnimRecord` now has stable placement/resource identity plus mirrored runtime state:
   - `pos_x`, `pos_y`, `pos_z`, `frame_delay`
   - `room_name`, `anim_path`, `anim_name`
@@ -908,9 +911,9 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `full_scale_z` is the room Z depth where entities remain at 100% sprite scale.
   - `max_z_scale_percent` is the sprite scale percentage at `max_z`; the parser derives `perspective_scale` from `(100 - max_z_scale_percent) / (max_z - full_scale_z)`, and the runtime applies it with the additional `0.01` factor stored at `0xbed80`.
   - `z_velocity_step` is the room-specific vertical movement increment used by the main actor state machine
-  - the trailing unknown room slots at `field_38`, `field_3c`, and `field_40` are confirmed `char *` fields.
-  - parser order is now explicit: after `room_name` and `music_path`, the parser reads three additional strings into `field_38`, `field_3c`, and `field_40`, then reads the separately validated `palette_path`, then the `dimmable` token, then `on_enter_tag` / `on_exit_tag`.
-  - `free_loaded_world_data` frees `palette_path`, `field_38`, `field_3c`, and `field_40` as room-owned strings, which confirms that the old `runtime_40` interpretation was wrong.
+  - the trailing `reserved_string_38`, `reserved_string_3c`, and `reserved_string_40` slots are confirmed `char *` fields.
+  - parser order is now explicit: after `room_name` and `music_path`, the parser reads three additional strings into `reserved_string_38`, `reserved_string_3c`, and `reserved_string_40`, then reads the separately validated `palette_path`, then the `dimmable` token, then `on_enter_tag` / `on_exit_tag`.
+  - `free_loaded_world_data` frees `palette_path`, `reserved_string_38`, `reserved_string_3c`, and `reserved_string_40` as room-owned strings, which confirms that the old `runtime_40` interpretation was wrong. In the sampled shipping `HARVEST.SCR` data all three reserved strings are empty.
 
 ## Helper Functions
 
@@ -1415,15 +1418,13 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 
 - The remaining work is no longer list recovery. The blocker is semantic naming for fields whose shape is clear but whose gameplay meaning is still ambiguous.
 - The main unresolved clusters are:
-  - `MonsterRecord.field_1c`, which is still an untyped numeric field with no confirmed consumer
-  - `MonsterRecord.field_38`, `field_3c`, `field_44`, and `field_48`, which are bounded as monster-owned heap strings but still have no confirmed live consumers
-  - `MonsterRecord.field_74` and `field_7c`, which are copied into live runtime slots at `+0x1170` and `+0x1178`; constructor/reset writes were recovered, but no read-side consumers were found in the current binary
   - the exact persisted-visibility semantics of `NpcRecord.saved_visible`
-  - `ObjectRecord.field_34`, which is a parsed heap string but is still absent from the direct `find_object_record_by_name` consumers and from the object save/load blob
-  - `ObjectRecord.field_40`, which is now bounded as an object-owned heap string, but still has no confirmed live consumer
-  - `RoomRecord` remaining string fields at `field_38`, `field_3c`, `field_40`
+  - `ObjectRecord.reserved_x_flag`, which is a parsed heap string that is either empty or the literal `X`, but is still absent from the direct `find_object_record_by_name` consumers and from the object save/load blob
+  - `ObjectRecord.reserved_string_40`, which is now bounded as an object-owned heap string, but still has no confirmed live consumer and is empty in current shipping script data
+  - `RoomRecord.reserved_string_38`, `reserved_string_3c`, `reserved_string_40`
+  - `MonsterRecord.reserved_dword_1c`, which remains zero-filled with no recovered producer or consumer
+  - `MonsterRecord.reserved_string_38`, `reserved_string_3c`, `reserved_string_44`, and `reserved_string_48`
   - `TextRecord.field_00` / `field_04`
-- The monster frame slots at `field_74` and `field_7c` are now more tightly bounded: they are copied into the shared runtime sound-trigger area (`+0x1170` and `+0x1178`) by the monster and player constructors, but no confirmed consumers have been recovered yet, so they remain unnamed.
 - The remaining actor-state work is narrower now:
   - the shared input/UI bytes at `0xd596c` and `0xd5971` through `0xd597c`; the keyboard producer side is now resolved through the IRQ1 handler, but the mouse/button producer path is still not named
 - On March 13, 2026 I resumed with a live bridge and used Java Ghidra scripts to push the recovered struct names back into `HARVEST.LE`.
diff --git a/FILE_FORMATS.md b/FILE_FORMATS.md
index 9df3e51de57..b24136661cf 100644
--- a/FILE_FORMATS.md
+++ b/FILE_FORMATS.md
@@ -205,13 +205,13 @@ python3 -c 'import sys,pathlib,signal; signal.signal(signal.SIGPIPE, signal.SIG_
 | Tag | Leading numeric fields | Fields after the tag |
 | --- | --- | --- |
 | `ENTRANCE` | `x y z` | `direction roomName entranceName` |
-| `MAP_ENTRANCE` | `field0 field4 initialPanelIndex` | `entryName` |
+| `MAP_ENTRANCE` | `mapX mapY initialPanelIndex` | `entryName` |
 | `MAP_LOCATION` | `minX minY maxX maxY panelIndex labelX labelY` | `labelText destinationEntranceName` |
-| `ROOM` | `minZ maxZ maxZScreenY minZScreenY fullScaleZ maxZScalePercent` | `roomName musicPath field38 field3c field40 palettePath dimmable onEnterCommand onExitCommand` |
-| `OBJECT` | `initialX initialY boundsX2 boundsY2 initialZ zExtent` | `initialOwnerOrRoom objectName spritePath altSpritePath field40 inventoryTextKey field34 identTextKey operatable visible actionTag interactionLabel` |
+| `ROOM` | `minZ maxZ maxZScreenY minZScreenY fullScaleZ maxZScalePercent` | `roomName musicPath reservedString38 reservedString3c reservedString40 palettePath dimmable onEnterCommand onExitCommand` |
+| `OBJECT` | `initialX initialY boundsX2 boundsY2 initialZ zExtent` | `initialOwnerOrRoom objectName spritePath altSpritePath reservedString40 inventoryTextKey reservedXFlag identTextKey operatable visible actionTag interactionLabel` |
 | `ANIM` | `x y z frameDelay` | `roomName resourcePath animName active visible looping backward pingPong remove` |
 | `NPC` | `x y z frameDelay` | `roomName modelPath npcName monsterfyTargetName active visible onDeathActionTag audioPath entityInitArg` |
-| `MONSTER` | `x y z ...` | Sample data and native analysis confirm a wider combat-oriented record. The current work has high confidence only for `roomName`, `monsterName`, `modelPath`, `initialFacing`, `active`, `visible`, and `onDeathActionTag`; several intermediate sound/combat columns remain only partially typed. |
+| `MONSTER` | `x y z ...` | Sample data and native analysis confirm sound-trigger timing columns for attack, hit, footstep, and death. The intermediate string columns at offsets `0x38`, `0x3c`, `0x44`, and `0x48` remain reserved in current data and have no recovered read-side consumers. |
 | `REGION` | `left top right bottom minZ maxZ` | `regionName direction roomName actionTag startEnabled cursorEnabled` |
 | `FLAG` | none | `name value` |
 | `COMMAND` | none | `triggerTag opcodeName arg1 arg2 arg3 [arg4]` |
@@ -550,4 +550,4 @@ Harvester does ship `.FLC` files, and the sampled files match the standard Autod
 ```
 ffplay -loop -1 "GRAPHIC/ROOMANIM/HARVPNTR.FLC"
 ffmpeg -i "GRAPHIC/ROOMANIM/HARVPNTR.FLC" out.gif
-```
\ No newline at end of file
+```
diff --git a/TRACKER.md b/TRACKER.md
index 7aa44dec441..7d122697aeb 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 21, 2026, re-opened `parse_map_entrance_record @ 0x5ef20`, `parse_object_record @ 0x5fa40`, `parse_room_record @ 0x5fea0`, `parse_monster_record @ 0x5f080`, `spawn_monster_entity_from_record @ 0x53a10`, `spawn_player_combat_avatar @ 0x54220`, `update_actor_runtime_state @ 0x4d750`, and `select_town_map_destination @ 0x66460` in the live `HARVEST.LE` Ghidra session to resolve the remaining placeholder fields in `MapEntranceRecord`, `ObjectRecord`, `RoomRecord`, and `MonsterRecord` before mirroring the names into ScummVM.
+- Confirmed `MAP_ENTRANCE` stores a coordinate pair plus `initial_panel_index`; the selector currently only reads the panel index, but the first two columns stay bounded as stored map coordinates. Confirmed the monster timing quartet around `+0x70..+0x7c` is a sound-trigger block by pairing `spawn_monster_entity_from_record` with the player constructor: `attack_sound_trigger_frame`, `hit_sound_trigger_frame`, `footstep_sound_trigger_frame`, and `death_sound_trigger_frame`. Confirmed the remaining room/object/monster string placeholders are reserved script columns in current shipping data: room reserved strings are always empty, monster reserved strings are always empty, `ObjectRecord.reserved_string_40` is always empty, and `ObjectRecord.reserved_x_flag` is only an empty-or-`X` marker with no recovered read-side consumer. Patched the ScummVM Harvester structs/parser serialization to use those names and updated the local RE notes so the reserved-vs-semantic split is explicit.
 - On March 20, 2026, re-opened `handle_talk_to_karin @ 0x2ea90`, `handle_talk_to_edna @ 0x358a0`, and `handle_talk_to_dwayne @ 0x3a310` in the live `HARVEST.LE` Ghidra session to verify the native dialogue maps for KARIN, EDNA, and DWAYNE against the current ScummVM handlers.
 - Confirmed Edna and Dwayne already match the existing recovered dialogue-map notes in `ARCHITECTURE.md`, and bounded Karin completely from raw disassembly after the live decompiler mis-modeled `play_dialogue_line` fallthrough. The native Karin handler uses `PC 0x10a5` head variant `4`, `KARIN 0x10e3` head variant `4`, `KARIN 0x10d6` head variant `1`, `PC 0x1076` head variant `1`, and otherwise keeps the same item/no-item branching already present in ScummVM. Patched `engines/harvester/npc/karin_dialogue.cpp`, added the confirmed Karin handler notes to `ARCHITECTURE.md`, renamed the duplicate Ghidra thunk at `0x2e97a` to `thunk_play_dialogue_line`, and corrected both `thunk_play_dialogue_line @ 0x2e97a` and `play_dialogue_line @ 0x7a690` from erroneous `noreturn` metadata back to returning before saving the live `HARVEST.LE` program.
 - On March 20, 2026, re-opened `spawn_player_combat_avatar @ 0x54220`, `update_actor_runtime_state @ 0x4d750`, and `consume_weapon_ammo_and_play_fire_or_empty_sound @ 0x57910` in the live `HARVEST.LE` Ghidra session to recover where the original room right-click attacks actually trigger audio instead of guessing from the parsed monster sound fields.
@@ -160,6 +162,7 @@
 17. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
 18. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
 19. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
+20. Run a live Harvester validation pass on one map transition plus one monster room to confirm the reserved-field cleanup was documentation-only: open the town map from at least `PCHOUSE` and `BARB_OUT`, then visit a monster room such as `CRYPT` and verify monster attack/hit/footstep/death audio still lines up with the renamed trigger-frame fields.
 
 ## Reimplementation Priority Order
 
diff --git a/engines/harvester/monster.h b/engines/harvester/monster.h
index 73fe9ee6f95..2923132760f 100644
--- a/engines/harvester/monster.h
+++ b/engines/harvester/monster.h
@@ -41,10 +41,10 @@ struct StartupMonsterRecord {
 	Common::String roomName;
 	Common::String monsterName;
 	Common::String modelPath;
-	Common::String field38;
-	Common::String field3c;
-	Common::String field44;
-	Common::String field48;
+	Common::String reservedString38;
+	Common::String reservedString3c;
+	Common::String reservedString44;
+	Common::String reservedString48;
 	Common::String attackSound1;
 	Common::String attackSound2;
 	Common::String attackSound3;
@@ -61,10 +61,10 @@ struct StartupMonsterRecord {
 	bool runtimeSpawned = false;
 	int minXBound = 0x14;
 	int maxXBound = 0x26b;
-	int field70 = 0;
-	int field74 = 0;
-	int field78 = 0;
-	int field7c = 0;
+	int attackSoundTriggerFrame = 0;
+	int hitSoundTriggerFrame = 0;
+	int footstepSoundTriggerFrame = 0;
+	int deathSoundTriggerFrame = 0;
 };
 
 class Monster {
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index f3d78c596dc..e45f87851eb 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -84,10 +84,10 @@ static void syncStartupObjectRecord(Common::Serializer &s, StartupObjectRecord &
 	s.syncString(record.spritePath);
 	s.syncString(record.altSpritePath);
 	s.syncString(record.objectName);
-	s.syncString(record.field34);
+	s.syncString(record.reservedXFlag);
 	s.syncString(record.identTextKey);
 	s.syncString(record.currentOwnerOrRoom);
-	s.syncString(record.field40);
+	s.syncString(record.reservedString40);
 	s.syncString(record.inventoryTextKey);
 	s.syncString(record.initialOwnerOrRoom);
 	s.syncString(record.interactionLabel);
@@ -155,10 +155,10 @@ static void syncStartupMonsterRecord(Common::Serializer &s, StartupMonsterRecord
 	s.syncAsSint32LE(record.damageAmount);
 	s.syncAsSint32LE(record.engageDistance);
 	s.syncAsSint32LE(record.damageType);
-	s.syncString(record.field38);
-	s.syncString(record.field3c);
-	s.syncString(record.field44);
-	s.syncString(record.field48);
+	s.syncString(record.reservedString38);
+	s.syncString(record.reservedString3c);
+	s.syncString(record.reservedString44);
+	s.syncString(record.reservedString48);
 	s.syncString(record.attackSound1);
 	s.syncString(record.attackSound2);
 	s.syncString(record.attackSound3);
@@ -172,10 +172,10 @@ static void syncStartupMonsterRecord(Common::Serializer &s, StartupMonsterRecord
 	syncBool(s, record.runtimeSpawned);
 	s.syncAsSint32LE(record.minXBound);
 	s.syncAsSint32LE(record.maxXBound);
-	s.syncAsSint32LE(record.field70);
-	s.syncAsSint32LE(record.field74);
-	s.syncAsSint32LE(record.field78);
-	s.syncAsSint32LE(record.field7c);
+	s.syncAsSint32LE(record.attackSoundTriggerFrame);
+	s.syncAsSint32LE(record.hitSoundTriggerFrame);
+	s.syncAsSint32LE(record.footstepSoundTriggerFrame);
+	s.syncAsSint32LE(record.deathSoundTriggerFrame);
 }
 
 static void syncStartupTimerRecord(Common::Serializer &s, StartupTimerRecord &record) {
@@ -761,8 +761,8 @@ void Script::parseTownRecords(ResourceManager &resources) {
 
 			StartupMapEntranceRecord mapEntrance;
 			if (tagIndex >= 3) {
-				mapEntrance.field0 = parseAsciiIntOrZero(tokens[0]);
-				mapEntrance.field4 = parseAsciiIntOrZero(tokens[1]);
+				mapEntrance.mapX = parseAsciiIntOrZero(tokens[0]);
+				mapEntrance.mapY = parseAsciiIntOrZero(tokens[1]);
 				mapEntrance.initialPanelIndex = parseAsciiIntOrZero(tokens[2]);
 			}
 			mapEntrance.entryName = tokens[tagIndex + 1];
@@ -815,9 +815,9 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			}
 			room.roomName = tokens[tagIndex + 1];
 			room.musicPath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
-			room.field38 = tokens[tagIndex + 3];
-			room.field3c = tokens[tagIndex + 4];
-			room.field40 = tokens[tagIndex + 5];
+			room.reservedString38 = tokens[tagIndex + 3];
+			room.reservedString3c = tokens[tagIndex + 4];
+			room.reservedString40 = tokens[tagIndex + 5];
 			room.palettePath = resources.normalizeResourcePath(tokens[tagIndex + 6]);
 			room.dimmable = tokens[tagIndex + 7].equalsIgnoreCase("T");
 			room.onEnterCommand = tokens[tagIndex + 8];
@@ -869,18 +869,18 @@ void Script::parseTownRecords(ResourceManager &resources) {
 				monster.engageDistance = parseAsciiIntOrZero(tokens[5]);
 			}
 			if (tagIndex >= 10) {
-				monster.field70 = parseAsciiIntOrZero(tokens[6]);
-				monster.field74 = parseAsciiIntOrZero(tokens[7]);
-				monster.field78 = parseAsciiIntOrZero(tokens[8]);
-				monster.field7c = parseAsciiIntOrZero(tokens[9]);
+				monster.attackSoundTriggerFrame = parseAsciiIntOrZero(tokens[6]);
+				monster.hitSoundTriggerFrame = parseAsciiIntOrZero(tokens[7]);
+				monster.footstepSoundTriggerFrame = parseAsciiIntOrZero(tokens[8]);
+				monster.deathSoundTriggerFrame = parseAsciiIntOrZero(tokens[9]);
 			}
 			monster.roomName = tokens[tagIndex + 1];
 			monster.monsterName = tokens[tagIndex + 2];
 			monster.modelPath = resources.normalizeResourcePath(tokens[tagIndex + 3]);
-			monster.field44 = tokens[tagIndex + 4];
-			monster.field48 = tokens[tagIndex + 5];
-			monster.field38 = tokens[tagIndex + 6];
-			monster.field3c = tokens[tagIndex + 7];
+			monster.reservedString44 = tokens[tagIndex + 4];
+			monster.reservedString48 = tokens[tagIndex + 5];
+			monster.reservedString38 = tokens[tagIndex + 6];
+			monster.reservedString3c = tokens[tagIndex + 7];
 			monster.facing = parseEntranceFacing(tokens[tagIndex + 8]);
 			monster.damageType = parseNpcDeathDamageType(tokens[tagIndex + 9]);
 			monster.attackSound1 = resources.normalizeResourcePath(tokens[tagIndex + 10]);
@@ -999,9 +999,9 @@ void Script::parseTownRecords(ResourceManager &resources) {
 		object.objectName = tokens[tagIndex + 2];
 		object.spritePath = resources.normalizeResourcePath(tokens[tagIndex + 3]);
 		object.altSpritePath = resources.normalizeResourcePath(tokens[tagIndex + 4]);
-		object.field40 = tokens[tagIndex + 5];
+		object.reservedString40 = tokens[tagIndex + 5];
 		object.inventoryTextKey = tokens[tagIndex + 6];
-		object.field34 = tokens[tagIndex + 7];
+		object.reservedXFlag = tokens[tagIndex + 7];
 		object.identTextKey = tokens[tagIndex + 8];
 		object.operatable = tokens[tagIndex + 9].equalsIgnoreCase("T");
 		object.visible = tokens[tagIndex + 10].equalsIgnoreCase("T");
@@ -1316,10 +1316,10 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 				runtimeMonster.damageAmount = baseMonster->damageAmount;
 				runtimeMonster.engageDistance = baseMonster->engageDistance;
 				runtimeMonster.damageType = baseMonster->damageType;
-				runtimeMonster.field38 = baseMonster->field38;
-				runtimeMonster.field3c = baseMonster->field3c;
-				runtimeMonster.field44 = baseMonster->field44;
-				runtimeMonster.field48 = baseMonster->field48;
+				runtimeMonster.reservedString38 = baseMonster->reservedString38;
+				runtimeMonster.reservedString3c = baseMonster->reservedString3c;
+				runtimeMonster.reservedString44 = baseMonster->reservedString44;
+				runtimeMonster.reservedString48 = baseMonster->reservedString48;
 				runtimeMonster.attackSound1 = baseMonster->attackSound1;
 				runtimeMonster.attackSound2 = baseMonster->attackSound2;
 				runtimeMonster.attackSound3 = baseMonster->attackSound3;
@@ -1333,10 +1333,10 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 				runtimeMonster.runtimeSpawned = false;
 				runtimeMonster.minXBound = baseMonster->minXBound;
 				runtimeMonster.maxXBound = baseMonster->maxXBound;
-				runtimeMonster.field70 = baseMonster->field70;
-				runtimeMonster.field74 = baseMonster->field74;
-				runtimeMonster.field78 = baseMonster->field78;
-				runtimeMonster.field7c = baseMonster->field7c;
+				runtimeMonster.attackSoundTriggerFrame = baseMonster->attackSoundTriggerFrame;
+				runtimeMonster.hitSoundTriggerFrame = baseMonster->hitSoundTriggerFrame;
+				runtimeMonster.footstepSoundTriggerFrame = baseMonster->footstepSoundTriggerFrame;
+				runtimeMonster.deathSoundTriggerFrame = baseMonster->deathSoundTriggerFrame;
 			}
 			_playerCombatLoadout = kDefaultPlayerCombatLoadout;
 			_playerControlPaused = false;
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index c48cf51d029..cbc2a13fc51 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -45,8 +45,8 @@ struct StartupEntranceRecord {
 };
 
 struct StartupMapEntranceRecord {
-	int field0 = 0;
-	int field4 = 0;
+	int mapX = 0;
+	int mapY = 0;
 	int initialPanelIndex = 0;
 	Common::String entryName;
 };
@@ -74,9 +74,9 @@ struct StartupRoomRecord {
 	float zVelocityStep = 1.0f;
 	Common::String roomName;
 	Common::String musicPath;
-	Common::String field38;
-	Common::String field3c;
-	Common::String field40;
+	Common::String reservedString38;
+	Common::String reservedString3c;
+	Common::String reservedString40;
 	Common::String palettePath;
 	Common::String onEnterCommand;
 	Common::String onExitCommand;
@@ -97,10 +97,10 @@ struct StartupObjectRecord {
 	Common::String spritePath;
 	Common::String altSpritePath;
 	Common::String objectName;
-	Common::String field34;
+	Common::String reservedXFlag;
 	Common::String identTextKey;
 	Common::String currentOwnerOrRoom;
-	Common::String field40;
+	Common::String reservedString40;
 	Common::String inventoryTextKey;
 	Common::String initialOwnerOrRoom;
 	Common::String interactionLabel;


Commit: 57fc115d3d2c026aec3d735286de6f6e0c6bb0b0
    https://github.com/scummvm/scummvm/commit/57fc115d3d2c026aec3d735286de6f6e0c6bb0b0
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:43+02:00

Commit Message:
HARVESTER: Show save slots as 01-25

Changed paths:
    TRACKER.md
    base/commandLine.cpp
    engines/harvester/metaengine.cpp
    engines/harvester/metaengine.h
    engines/metaengine.cpp
    engines/metaengine.h
    gui/saveload-dialog.cpp
    gui/saveload-dialog.h


diff --git a/TRACKER.md b/TRACKER.md
index 7d122697aeb..3ee72bf439c 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 21, 2026, re-opened native `run_save_game_menu @ 0x632c0` and `run_load_game_menu` in the live `HARVEST.LE` Ghidra session to confirm how the original executable presents save slots before changing ScummVM's generic save/load display path.
+- Confirmed both native menu handlers build the slot labels from the static `PTR_s_GAME_01_000c3c20` table, which expands to `GAME 01` through `GAME 25` on screen. Also confirmed the underlying save filename formatter still consumes the raw zero-based slot index when it emits `%sSAVE.%s`, so the parity fix belongs in ScummVM's presentation layer rather than in the on-disk slot/file mapping. Patched the shared save/load UI plus `--list-saves` output to use a new meta-engine display formatter, and overrode Harvester to expose slots as `01..25` without renumbering the actual save files.
 - On March 21, 2026, re-opened `parse_map_entrance_record @ 0x5ef20`, `parse_object_record @ 0x5fa40`, `parse_room_record @ 0x5fea0`, `parse_monster_record @ 0x5f080`, `spawn_monster_entity_from_record @ 0x53a10`, `spawn_player_combat_avatar @ 0x54220`, `update_actor_runtime_state @ 0x4d750`, and `select_town_map_destination @ 0x66460` in the live `HARVEST.LE` Ghidra session to resolve the remaining placeholder fields in `MapEntranceRecord`, `ObjectRecord`, `RoomRecord`, and `MonsterRecord` before mirroring the names into ScummVM.
 - Confirmed `MAP_ENTRANCE` stores a coordinate pair plus `initial_panel_index`; the selector currently only reads the panel index, but the first two columns stay bounded as stored map coordinates. Confirmed the monster timing quartet around `+0x70..+0x7c` is a sound-trigger block by pairing `spawn_monster_entity_from_record` with the player constructor: `attack_sound_trigger_frame`, `hit_sound_trigger_frame`, `footstep_sound_trigger_frame`, and `death_sound_trigger_frame`. Confirmed the remaining room/object/monster string placeholders are reserved script columns in current shipping data: room reserved strings are always empty, monster reserved strings are always empty, `ObjectRecord.reserved_string_40` is always empty, and `ObjectRecord.reserved_x_flag` is only an empty-or-`X` marker with no recovered read-side consumer. Patched the ScummVM Harvester structs/parser serialization to use those names and updated the local RE notes so the reserved-vs-semantic split is explicit.
 - On March 20, 2026, re-opened `handle_talk_to_karin @ 0x2ea90`, `handle_talk_to_edna @ 0x358a0`, and `handle_talk_to_dwayne @ 0x3a310` in the live `HARVEST.LE` Ghidra session to verify the native dialogue maps for KARIN, EDNA, and DWAYNE against the current ScummVM handlers.
@@ -142,7 +144,7 @@
 
 ## Next Suggested Action
 
-- Immediate: run a live Harvester validation pass on KARIN, EDNA, and DWAYNE. Verify Karin now uses the recovered portrait variants on `0x10a5`, `0x10e3`, `0x10d6`, and `0x1076`, and confirm Edna/Dwayne still match the previously recovered intro, evidence, and keyword-menu maps after the Ghidra cleanup.
+- Immediate: run a live Harvester validation pass on both the in-game save/load menu and ScummVM's generic save/load surfaces. Confirm the startup `SAVE GAME` / `LOAD GAME` screens still show `GAME 01` through `GAME 25`, and verify the launcher or `--list-saves` path now reports Harvester slots as `01..25` while existing save files remain loadable without migration.
 1. Run a live Harvester validation pass on room right-click attacks with both bare hands and equipped weapons: confirm the player still swaps to the correct `PC0/PC01..PC20` art after inventory weapon toggles, and that attacks now play the recovered second-frame sound timing for unarmed swings, melee weapons, firearms, and the chainsaw.
 2. If any player loadout still sounds wrong in that pass, re-open `spawn_player_combat_avatar @ 0x54220` and the frame-commit logic around `0x52762..0x52993` to mirror any still-missing special cases such as the firearm secondary timing branch or the chainsaw empty/fuel path exactly.
 3. Run a live Harvester validation pass in `SCHLHL` covering both Whaley/Herrill acceptance paths: after taking the broom-closet photo, choose response line `0x24c` option `2` and the nested `0x24d` option `1`, and confirm both paths now add `BAT`, hide `PHOTO_OF_WHALEY_HERRILL` from the inventory strip, clear `BUSTED_SCREWING_MIDGAME`, and still unwind the broom-door state through `REVERSE_BROOM` when that flag is set.
diff --git a/base/commandLine.cpp b/base/commandLine.cpp
index c29d1eb09ac..575161b0cda 100644
--- a/base/commandLine.cpp
+++ b/base/commandLine.cpp
@@ -1573,7 +1573,8 @@ static Common::Error listSaves(const Common::String &singleTarget) {
 					   "  ---- ------------------------------------------------------\n");
 
 			for (const auto &x : saveList) {
-				printf("  %-4d %s\n", x.getSaveSlot(), x.getDescription().c_str());
+				const Common::String displaySlot = metaEngine.formatSaveSlotForDisplay(x.getSaveSlot());
+				printf("  %-4s %s\n", displaySlot.c_str(), x.getDescription().c_str());
 				// TODO: Could also iterate over the full hashmap, printing all key-value pairs
 			}
 			atLeastOneFound = true;
diff --git a/engines/harvester/metaengine.cpp b/engines/harvester/metaengine.cpp
index 9d03547bc85..985376e5f9d 100644
--- a/engines/harvester/metaengine.cpp
+++ b/engines/harvester/metaengine.cpp
@@ -63,6 +63,10 @@ int HarvesterMetaEngine::getAutosaveSlot() const {
 	return -1;
 }
 
+Common::String HarvesterMetaEngine::formatSaveSlotForDisplay(int slot) const {
+	return Common::String::format("%02d", slot + 1);
+}
+
 bool HarvesterMetaEngine::hasFeature(MetaEngineFeature f) const {
 	return checkExtendedSaves(f) ||
 		(f == kSupportsLoadingDuringStartup);
diff --git a/engines/harvester/metaengine.h b/engines/harvester/metaengine.h
index 57644f2b606..c564cc120e9 100644
--- a/engines/harvester/metaengine.h
+++ b/engines/harvester/metaengine.h
@@ -32,6 +32,7 @@ public:
 	Common::Error createInstance(OSystem *syst, Engine **engine, const ADGameDescription *desc) const override;
 	int getMaximumSaveSlot() const override;
 	int getAutosaveSlot() const override;
+	Common::String formatSaveSlotForDisplay(int slot) const override;
 
 	/**
 	 * Determine whether the engine supports the specified MetaEngine feature.
diff --git a/engines/metaengine.cpp b/engines/metaengine.cpp
index 5a2bfb0f09b..aaf9a18b549 100644
--- a/engines/metaengine.cpp
+++ b/engines/metaengine.cpp
@@ -69,6 +69,10 @@ Common::String MetaEngine::getSavegameFile(int saveGameIdx, const char *target)
 	}
 }
 
+Common::String MetaEngine::formatSaveSlotForDisplay(int slot) const {
+	return Common::String::format("%d", slot);
+}
+
 Common::KeymapArray MetaEngine::initKeymaps(const char *target) const {
 	using namespace Common;
 
diff --git a/engines/metaengine.h b/engines/metaengine.h
index 116cd4c5491..977c0d1abef 100644
--- a/engines/metaengine.h
+++ b/engines/metaengine.h
@@ -374,6 +374,14 @@ public:
 	 */
 	virtual Common::String getSavegameFile(int saveGameIdx, const char *target = nullptr) const;
 
+	/**
+	 * Return the user-facing slot label for the given save slot.
+	 *
+	 * This only affects presentation in generic ScummVM UI and does not alter
+	 * the underlying save slot number or filename mapping.
+	 */
+	virtual Common::String formatSaveSlotForDisplay(int slot) const;
+
 	/**
 	 * Return the pattern for save files.
 	 *
diff --git a/gui/saveload-dialog.cpp b/gui/saveload-dialog.cpp
index abfe10e1a66..5cdbc074e39 100644
--- a/gui/saveload-dialog.cpp
+++ b/gui/saveload-dialog.cpp
@@ -1130,7 +1130,7 @@ int SaveLoadChooserGrid::runIntern() {
 
 bool SaveLoadChooserGrid::selectDescription() {
 	_savenameDialog.setDescription(_resultString);
-	_savenameDialog.setTargetSlot(getResult());
+	_savenameDialog.setTargetSlot(_metaEngine, getResult());
 	if (_savenameDialog.runModal() == 0) {
 		_resultString = _savenameDialog.getDescription();
 		return true;
@@ -1168,6 +1168,7 @@ void SaveLoadChooserGrid::updateSaves() {
 
 	for (uint i = _curPage * _entriesPerPage, curNum = 0; i < _saveList.size() && curNum < _entriesPerPage; ++i, ++curNum) {
 		const uint saveSlot = _saveList[i].getSaveSlot();
+		const Common::String displaySlot = _metaEngine->formatSaveSlotForDisplay(saveSlot);
 
 		SaveStateDescriptor desc =  (_saveList[i].getLocked() ? _saveList[i] : _metaEngine->querySaveMetaInfos(_target.c_str(), saveSlot));
 		if (!_saveList[i].getLocked() && desc.getSaveSlot() >= 0 && !desc.getDescription().empty())
@@ -1180,7 +1181,7 @@ void SaveLoadChooserGrid::updateSaves() {
 		} else {
 			curButton.button->setGfx(kThumbnailWidth, kThumbnailHeight2, 0, 0, 0);
 		}
-		curButton.description->setLabel(Common::U32String::format("%d. %s", saveSlot, _saveList[i].getDescription().c_str()));
+		curButton.description->setLabel(Common::U32String(displaySlot + ". ") + _saveList[i].getDescription().decode());
 
 		Common::U32String tooltip(_("Name: "));
 		tooltip += _saveList[i].getDescription().decode();
@@ -1237,7 +1238,8 @@ void SaveLoadChooserGrid::updateSaves() {
 }
 
 SavenameDialog::SavenameDialog()
-	: Dialog("SavenameDialog") {
+	: Dialog("SavenameDialog"),
+	  _metaEngine(nullptr) {
 	_title = new StaticTextWidget(this, "SavenameDialog.DescriptionText", Common::String());
 
 	new ButtonWidget(this, "SavenameDialog.Cancel", _("Cancel"), Common::U32String(), kCloseCmd);
@@ -1260,7 +1262,10 @@ void SavenameDialog::open() {
 	Dialog::open();
 	setResult(-1);
 
-	_title->setLabel(Common::U32String::format(_("Enter a description for slot %d:"), _targetSlot));
+	const Common::String displaySlot = _metaEngine
+		? _metaEngine->formatSaveSlotForDisplay(_targetSlot)
+		: Common::String::format("%d", _targetSlot);
+	_title->setLabel(Common::U32String::format(_("Enter a description for slot %s:"), displaySlot.c_str()));
 }
 
 void SavenameDialog::handleCommand(CommandSender *sender, uint32 cmd, uint32 data) {
diff --git a/gui/saveload-dialog.h b/gui/saveload-dialog.h
index db70a4358ba..2620eff10b7 100644
--- a/gui/saveload-dialog.h
+++ b/gui/saveload-dialog.h
@@ -191,12 +191,16 @@ public:
 	void setDescription(const Common::String &desc);
 	const Common::String getDescription();
 
-	void setTargetSlot(int slot) { _targetSlot = slot; }
+	void setTargetSlot(const MetaEngine *metaEngine, int slot) {
+		_metaEngine = metaEngine;
+		_targetSlot = slot;
+	}
 
 	void open() override;
 protected:
 	void handleCommand(CommandSender *sender, uint32 cmd, uint32 data) override;
 private:
+	const MetaEngine *_metaEngine;
 	int _targetSlot;
 	StaticTextWidget *_title;
 	EditTextWidget *_description;


Commit: b716dcd301af47f6bddf19556ffc20651b714ee0
    https://github.com/scummvm/scummvm/commit/b716dcd301af47f6bddf19556ffc20651b714ee0
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:44+02:00

Commit Message:
HARVESTER: Restore Sergeant dialogue branches

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/sergeant_dialogue.cpp
    engines/harvester/npc/sergeant_dialogue.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 7c33c344151..7085cfd64f6 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -614,7 +614,10 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `get_set_sergeant_completed_first_task_state` at `0x384e0` is the Sergeant-local bit set on the `SCRATCHED_TUCKER` branch immediately before dialogue `0x420d` ("So, you have completed your first task.").
   - The same block also contains the recovered Pottsdam alibi bits at `0x38440` and `0x38460`, so it is no longer Sergeant-only even though several remaining wrappers in that span are still only identified by locality.
   - `handle_talk_to_sergeant` calls into that run for quest-driven lines tied to `REMAINS`, `INVITE`, `BARBER_POLE`, `BOLTCLTH`, `SCRATCHED_TUCKER`, and `COMPLETED_LODGE_APPLICATION`.
+  - The hidden intro / revisit tails at `0x30db4..0x30f98` are now bounded from native code and `DIALOG.RSP`: first contact is not a single bark. After `0x413f`, Sergeant runs the fixed `0x4144` / `0x4145` / `0x4146` / `0x4148` exchange, then drives response menus `0x2b7`, `0x2b8`, and `0x2b9`. Later no-item revisits start from `0x41ac`, optionally insert `0x41b0` when `HAVE_LODGE_APP` is still clear, then play `0x41b4` and branch through response menu `0x2bb`.
   - The native `COMPLETED_LODGE_APPLICATION` Monday branch is a deferred quest handoff rather than a single bark: after restoring the item to `RAH`, the handler plays Sergeant lines `0x41cd` then `0x4292`, sets `QUEST_1`, and dispatches action tag `GO_DAY_2`, which drives the story-day advance / cutscene path.
+  - The later `QUEST_1`-set application branch is also response-driven. If the local `d2d54` bit is still clear, Sergeant plays `0x41d2`, opens response menu `0x2b6`, and either (choice `1`) continues through `0x41db` / `0x41e2` / `0x41e6` / `0x41ec` / `0x41f0` / `0x41f6` / `0x41fa` while setting `d2d54`, or (choice `2`) plays `0x4201` / `0x4202` / `0x4203` and leaves `d2d54` clear. Once `d2d54` is set, revisits skip the menu and replay the `0x41f0` / `0x41f6` / `0x41fa` task briefing directly.
+  - Those intro / revisit / post-application tails share a day-5 one-shot exit gate: if `DAY_5` is set and the Sergeant-local `d2d70` bit is still clear, the branch ends by latching `d2d70` and playing `0x4324`.
   - `handle_talk_to_dad` also reads the neighboring `FUN_00038400` wrapper on the `MEAT_PERMISSION0` item path.
   - The wrapper mechanics are confirmed, but most of the remaining local quest-state semantics in that run are still left unnamed pending stronger caller/readback evidence from the hidden no-function regions.
 - `reset_all_talk_to_handler_state` at `0x3a0b0` is the startup/menu bulk reset for the talk-to subsystem.
diff --git a/TRACKER.md b/TRACKER.md
index 3ee72bf439c..872695bd640 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,9 @@
 
 ## Last Confirmed Action
 
+- On March 21, 2026, re-opened the remaining hidden Sergeant talk tails in the live `HARVEST.LE` Ghidra session and cross-checked them against the native `DIALOG.RSP` plus `iso/Harvester/DIALOGUE.IDX` data after a live `SERGEANTRM` repro showed ScummVM stopping on Sergeant line `0x41d2` without opening the expected prompt.
+- Confirmed the missing native Sergeant flow is broader than the single `0x41d2` menu: the first-contact branch continues past `0x413f` through `0x4144` / `0x4145` / `0x4146` / `0x4148` and response lines `0x2b7`, `0x2b8`, `0x2b9`; the generic revisit branch plays `0x41ac`, optionally `0x41b0`, then `0x41b4` and response line `0x2bb`; the post-`QUEST_1` application branch opens response line `0x2b6` after `0x41d2`, branches either into the Johnson-task briefing `0x41db`..`0x41fa` or the refusal path `0x4201` / `0x4202` / `0x4203`, and later revisits replay `0x41f0` / `0x41f6` / `0x41fa` once the local `d2d54` bit is set. Those tails also share a day-5 one-shot exit line `0x4324` guarded by the local `d2d70` latch.
+- Patched `engines/harvester/npc/sergeant_dialogue.cpp` and `engines/harvester/npc/sergeant_dialogue.h` so ScummVM now mirrors those recovered Sergeant menus, line sequences, head variants, and local state bits instead of reducing them to single-line returns; updated `ARCHITECTURE.md` with the confirmed hidden-tail evidence.
 - On March 21, 2026, re-opened native `run_save_game_menu @ 0x632c0` and `run_load_game_menu` in the live `HARVEST.LE` Ghidra session to confirm how the original executable presents save slots before changing ScummVM's generic save/load display path.
 - Confirmed both native menu handlers build the slot labels from the static `PTR_s_GAME_01_000c3c20` table, which expands to `GAME 01` through `GAME 25` on screen. Also confirmed the underlying save filename formatter still consumes the raw zero-based slot index when it emits `%sSAVE.%s`, so the parity fix belongs in ScummVM's presentation layer rather than in the on-disk slot/file mapping. Patched the shared save/load UI plus `--list-saves` output to use a new meta-engine display formatter, and overrode Harvester to expose slots as `01..25` without renumbering the actual save files.
 - On March 21, 2026, re-opened `parse_map_entrance_record @ 0x5ef20`, `parse_object_record @ 0x5fa40`, `parse_room_record @ 0x5fea0`, `parse_monster_record @ 0x5f080`, `spawn_monster_entity_from_record @ 0x53a10`, `spawn_player_combat_avatar @ 0x54220`, `update_actor_runtime_state @ 0x4d750`, and `select_town_map_destination @ 0x66460` in the live `HARVEST.LE` Ghidra session to resolve the remaining placeholder fields in `MapEntranceRecord`, `ObjectRecord`, `RoomRecord`, and `MonsterRecord` before mirroring the names into ScummVM.
@@ -144,6 +147,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run a live Harvester validation pass in `SERGEANTRM` across all recovered Sergeant states. Verify first contact now reaches `0x413f` -> `0x4148` with response lines `0x2b7` / `0x2b8` / `0x2b9`, the application reminder path now reaches `0x41ac` / optional `0x41b0` / `0x41b4` with response line `0x2bb`, and the post-application path now opens response line `0x2b6` after `0x41d2` before branching into the Johnson-task or refusal sequence.
 - Immediate: run a live Harvester validation pass on both the in-game save/load menu and ScummVM's generic save/load surfaces. Confirm the startup `SAVE GAME` / `LOAD GAME` screens still show `GAME 01` through `GAME 25`, and verify the launcher or `--list-saves` path now reports Harvester slots as `01..25` while existing save files remain loadable without migration.
 1. Run a live Harvester validation pass on room right-click attacks with both bare hands and equipped weapons: confirm the player still swaps to the correct `PC0/PC01..PC20` art after inventory weapon toggles, and that attacks now play the recovered second-frame sound timing for unarmed swings, melee weapons, firearms, and the chainsaw.
 2. If any player loadout still sounds wrong in that pass, re-open `spawn_player_combat_avatar @ 0x54220` and the frame-commit logic around `0x52762..0x52993` to mirror any still-missing special cases such as the firearm secondary timing branch or the chainsaw empty/fuel path exactly.
@@ -151,7 +155,7 @@
 4. Run a live Harvester validation pass in `SCHLHL`: use `BROOMKEY` on `SCHL_DOOR_HS`, let `GRAPHIC/FST/C132.FST` finish, and confirm the `SCHDOOR` room anim now respawns from its native saved frame/open state instead of restarting from the closed-frame default.
 5. Run a live Harvester validation pass on FST playback itself: compare `VIRGLOGO.FST`, `FVLOGO.FST`, `INTROFIN.FST`, `C044.FST`, and `C048.FST` against the original executable and confirm the reused payload buffer plus native first-frame skip gating remove any remaining startup/cutscene stutter or premature-skip artifacts.
 6. Run a live Harvester validation pass in `SERGEANTRM` on Monday with `HAVE_COMPLETED_LODGE_APP` true and `QUEST_1` false: talk to Sergeant with the completed application and confirm ScummVM now plays the recovered `0x41cd` -> `0x4292` sequence, dispatches `GO_DAY_2`, advances the story to Tuesday, and shows the native day-change cutscene/FST.
-7. Re-open the remaining hidden Sergeant talk tails around `0x30ae2..0x30fcb` and mirror the still-missing native response-menu followups for the intro, `SCRATCHED_TUCKER`, and post-`QUEST_1` lodge-application branches only where the line ids / response lines / state flips are fully decoded.
+7. Run a live Harvester validation pass in `SERGEANTRM` on the newly mirrored Sergeant dialogue tails: first talk through the `0x2b7` / `0x2b8` / `0x2b9` intro menus, then revisit before and after obtaining a lodge application to confirm `0x2bb` and `0x2b6` both appear, and finally re-check the `DAY_5` follow-up so `0x4324` only plays once.
 8. Run a live Harvester validation pass on the inventory overlay and confirm right-clicking `NOTE` now dispatches `GO_BOYLNOTECU` without the redundant post-closeup inventory bitmap reloads, and that the overlay only reappears when the player explicitly reopens it after returning from the closeup.
 9. Run a live Harvester validation pass on the inventory overlay and confirm the native weekday label now appears at `(480, 396)` in `TEXTFONT` when `HARVEST_BLADE` is not in inventory, updates if the day changes while the panel stays open, and disappears immediately once `HARVEST_BLADE` is acquired.
 10. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
diff --git a/engines/harvester/npc/sergeant_dialogue.cpp b/engines/harvester/npc/sergeant_dialogue.cpp
index 7e0b568560c..9b39a47c5ad 100644
--- a/engines/harvester/npc/sergeant_dialogue.cpp
+++ b/engines/harvester/npc/sergeant_dialogue.cpp
@@ -32,6 +32,11 @@ namespace {
 
 static const char *const kDialogueC149FstPath = "GRAPHIC/FST/C149.FST";
 static const char *const kGoDay2ActionTag = "GO_DAY_2";
+static const int kSergeantCompletedApplicationResponseLine = 0x2b6;
+static const int kSergeantIntroResponseLine = 0x2b7;
+static const int kSergeantApplicationResponseLine = 0x2b8;
+static const int kSergeantAmnesiaResponseLine = 0x2b9;
+static const int kSergeantReminderResponseLine = 0x2bb;
 
 } // End of namespace
 
@@ -59,6 +64,7 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		sharedState.dialogueStateD2f08 = true;
 		sharedState.dialogueStateD2f00 = true;
 		state.introPending = false;
+		state.dialogueStateD2d50 = true;
 		state.dialogueStateD2d54 = true;
 	};
 	auto restoreItemToRah = [&](const char *objectName) {
@@ -74,6 +80,119 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 		return Common::kNoError;
 	};
+	auto runDay5ReminderIfNeeded = [&]() -> Common::Error {
+		if (!runtime.startupScript().getFlagValue("DAY_5") || state.day5ReminderShown)
+			return Common::kNoError;
+
+		state.day5ReminderShown = true;
+		return playSergeantLine(0x4324, 1);
+	};
+	auto runApplicationDiscussion = [&]() -> Common::Error {
+		Common::Error lineError = playSergeantLine(0x415d);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(
+			kSergeantApplicationResponseLine, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			const DialogueLineEntry applicationLines[] = {
+				{ 0x4168, "SERGEANT", 1 },
+				{ 0x416c, "PC", 0 },
+				{ 0x4170, "SERGEANT", 1 }
+			};
+			lineError = runtime.playDialogueEntrySequence(
+				applicationLines, ARRAYSIZE(applicationLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			responseIndex = 0;
+			responseError = runtime.runResponseMenu(kSergeantAmnesiaResponseLine, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1)
+				lineError = playSergeantLine(0x417c);
+			else if (responseIndex == 2)
+				lineError = playSergeantLine(0x4182, 2);
+		} else {
+			const DialogueLineEntry applicationLines[] = {
+				{ 0x4188, "SERGEANT", 0 },
+				{ 0x418e, "PC", 0 },
+				{ 0x4192, "SERGEANT", 0 }
+			};
+			lineError = runtime.playDialogueEntrySequence(
+				applicationLines, ARRAYSIZE(applicationLines));
+		}
+
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		return runDay5ReminderIfNeeded();
+	};
+	auto runInitialIntroBranch = [&]() -> Common::Error {
+		sharedState.dialogueStateD2f08 = true;
+		state.introPending = false;
+
+		const DialogueLineEntry introLines[] = {
+			{ 0x413f, "SERGEANT", 2 },
+			{ 0x4144, "PC", 0 },
+			{ 0x4145, "SERGEANT", 0 },
+			{ 0x4146, "PC", 0 },
+			{ 0x4148, "SERGEANT", 2 }
+		};
+		Common::Error lineError = runtime.playDialogueEntrySequence(
+			introLines, ARRAYSIZE(introLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(
+			kSergeantIntroResponseLine, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1)
+			lineError = playSergeantLine(0x4153);
+		else if (responseIndex == 2)
+			lineError = playSergeantLine(0x4157, 1);
+
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		return runApplicationDiscussion();
+	};
+	auto runApplicationReminderBranch = [&]() -> Common::Error {
+		Common::Error lineError = playSergeantLine(0x41ac);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (!runtime.startupScript().getFlagValue("HAVE_LODGE_APP")) {
+			lineError = playSergeantLine(0x41b0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		lineError = playSergeantLine(0x41b4, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(
+			kSergeantReminderResponseLine, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playSergeantLine(0x41bf, 2);
+			state.dialogueStateD2d50 = true;
+		} else if (responseIndex == 2) {
+			lineError = playSergeantLine(0x41c4);
+		}
+
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		return runDay5ReminderIfNeeded();
+	};
 	auto handleRemainsBranch = [&]() -> Common::Error {
 		markSergeantProgress();
 		restoreItemToRah("REMAINS");
@@ -110,6 +229,7 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		sharedState.dialogueStateD2f08 = true;
 		sharedState.dialogueStateD2f00 = true;
 		state.introPending = false;
+		state.dialogueStateD2d50 = true;
 		restoreItemToRah("COMPLETED_LODGE_APPLICATION");
 		if (!runtime.startupScript().getFlagValue("QUEST_1")) {
 			Common::Error lineError = playSergeantLine(0x41cd, 1);
@@ -121,9 +241,58 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			(void)runtime.startupScript().setRuntimeFlagValue("QUEST_1", true);
 			return executeActionTagIfSet(kGoDay2ActionTag);
 		}
-		if (!state.dialogueStateD2d54)
-			return playSergeantLine(0x41d2, 1);
-		return playSergeantLine(0x41f0);
+		if (!state.dialogueStateD2d54) {
+			Common::Error lineError = playSergeantLine(0x41d2, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(
+				kSergeantCompletedApplicationResponseLine, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				const DialogueLineEntry acceptedTaskLines[] = {
+					{ 0x41db, "SERGEANT", 0 },
+					{ 0x41e2, "PC", 0 },
+					{ 0x41e6, "SERGEANT", 0 },
+					{ 0x41ec, "PC", 2 },
+					{ 0x41f0, "SERGEANT", 0 },
+					{ 0x41f6, "PC", 0 },
+					{ 0x41fa, "SERGEANT", 0 }
+				};
+				lineError = runtime.playDialogueEntrySequence(
+					acceptedTaskLines, ARRAYSIZE(acceptedTaskLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				state.dialogueStateD2d54 = true;
+			} else if (responseIndex == 2) {
+				const DialogueLineEntry declinedTaskLines[] = {
+					{ 0x4201, "SERGEANT", 3 },
+					{ 0x4202, "SERGEANT", 3 },
+					{ 0x4203, "SERGEANT", 2 }
+				};
+				lineError = runtime.playDialogueEntrySequence(
+					declinedTaskLines, ARRAYSIZE(declinedTaskLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				state.dialogueStateD2d54 = false;
+			}
+
+			return runDay5ReminderIfNeeded();
+		}
+
+		const DialogueLineEntry repeatTaskLines[] = {
+			{ 0x41f0, "SERGEANT", 0 },
+			{ 0x41f6, "PC", 0 },
+			{ 0x41fa, "SERGEANT", 0 }
+		};
+		Common::Error lineError = runtime.playDialogueEntrySequence(
+			repeatTaskLines, ARRAYSIZE(repeatTaskLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		return runDay5ReminderIfNeeded();
 	};
 
 	if (usedItemName.empty()) {
@@ -146,18 +315,15 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			sharedState.dialogueStateD2f08 = true;
 			sharedState.dialogueStateD2f00 = true;
 			state.introPending = false;
+			state.dialogueStateD2d50 = true;
 			state.dialogueStateD2d54 = true;
-			state.completedFirstTaskState = true;
 			return playSergeantLine(0x420d);
 		}
 		if (runtime.startupScript().getFlagValue("HAVE_COMPLETED_LODGE_APP"))
 			return handleCompletedApplicationBranch();
-		if (state.introPending) {
-			sharedState.dialogueStateD2f08 = true;
-			state.introPending = false;
-			return playSergeantLine(0x413f);
-		}
-		return playSergeantLine(0x41ac);
+		if (state.introPending)
+			return runInitialIntroBranch();
+		return runApplicationReminderBranch();
 	}
 
 	if (usedItemName.equalsIgnoreCase("BARBER_POLE"))
diff --git a/engines/harvester/npc/sergeant_dialogue.h b/engines/harvester/npc/sergeant_dialogue.h
index 1a7e5db2eb4..a42259a39c4 100644
--- a/engines/harvester/npc/sergeant_dialogue.h
+++ b/engines/harvester/npc/sergeant_dialogue.h
@@ -30,8 +30,9 @@ class SergeantDialogueHandler : public NpcDialogueHandler {
 public:
 	struct SergeantRoomDialogueState {
 		bool introPending = true;
+		bool dialogueStateD2d50 = false;
 		bool dialogueStateD2d54 = false;
-		bool completedFirstTaskState = false;
+		bool day5ReminderShown = false;
 	};
 
 	bool matchesNpc(const Common::String &npcName) const override;


Commit: d760ae48f969cc2d362354b31ea7b4030a2c5d19
    https://github.com/scummvm/scummvm/commit/d760ae48f969cc2d362354b31ea7b4030a2c5d19
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:44+02:00

Commit Message:
HARVESTER: Add DEBUG_DIALOGUE console dump

Changed paths:
  A engines/harvester/dialogue_debug.cpp
  A engines/harvester/dialogue_debug.h
    TRACKER.md
    engines/harvester/console.cpp
    engines/harvester/console.h
    engines/harvester/module.mk


diff --git a/TRACKER.md b/TRACKER.md
index 872695bd640..b451088639a 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 21, 2026, added a Harvester debugger-console workflow helper instead of re-opening Ghidra for every Sergeant comparison: new `DEBUG_DIALOGUE <NPC_ID>` support now routes through `engines/harvester/dialogue_debug.cpp` and prints a structured branch dump with flag conditions, `DIALOG.RSP` response indices, WAV ids, head variants, and resolved subtitle text.
+- The first structured dump targets `SERGEANT`, mirroring the currently recovered ScummVM tree for the intro `0x2b7` / `0x2b8` / `0x2b9` menus, the `0x2bb` application-reminder menu, the post-`0x41d2` `0x2b6` branch, and the item-use shortcuts so those paths can be diffed against Ghidra without manually hopping back into the native executable for every check.
 - On March 21, 2026, re-opened the remaining hidden Sergeant talk tails in the live `HARVEST.LE` Ghidra session and cross-checked them against the native `DIALOG.RSP` plus `iso/Harvester/DIALOGUE.IDX` data after a live `SERGEANTRM` repro showed ScummVM stopping on Sergeant line `0x41d2` without opening the expected prompt.
 - Confirmed the missing native Sergeant flow is broader than the single `0x41d2` menu: the first-contact branch continues past `0x413f` through `0x4144` / `0x4145` / `0x4146` / `0x4148` and response lines `0x2b7`, `0x2b8`, `0x2b9`; the generic revisit branch plays `0x41ac`, optionally `0x41b0`, then `0x41b4` and response line `0x2bb`; the post-`QUEST_1` application branch opens response line `0x2b6` after `0x41d2`, branches either into the Johnson-task briefing `0x41db`..`0x41fa` or the refusal path `0x4201` / `0x4202` / `0x4203`, and later revisits replay `0x41f0` / `0x41f6` / `0x41fa` once the local `d2d54` bit is set. Those tails also share a day-5 one-shot exit line `0x4324` guarded by the local `d2d70` latch.
 - Patched `engines/harvester/npc/sergeant_dialogue.cpp` and `engines/harvester/npc/sergeant_dialogue.h` so ScummVM now mirrors those recovered Sergeant menus, line sequences, head variants, and local state bits instead of reducing them to single-line returns; updated `ARCHITECTURE.md` with the confirmed hidden-tail evidence.
@@ -147,6 +149,7 @@
 
 ## Next Suggested Action
 
+- Immediate: use `DEBUG_DIALOGUE SERGEANT` during the next live `SERGEANTRM` repro and compare the printed branch structure against the Ghidra tail blocks. If the workflow is useful, extend `engines/harvester/dialogue_debug.cpp` to the next NPC you are actively auditing instead of re-reading native control flow from scratch each time.
 - Immediate: run a live Harvester validation pass in `SERGEANTRM` across all recovered Sergeant states. Verify first contact now reaches `0x413f` -> `0x4148` with response lines `0x2b7` / `0x2b8` / `0x2b9`, the application reminder path now reaches `0x41ac` / optional `0x41b0` / `0x41b4` with response line `0x2bb`, and the post-application path now opens response line `0x2b6` after `0x41d2` before branching into the Johnson-task or refusal sequence.
 - Immediate: run a live Harvester validation pass on both the in-game save/load menu and ScummVM's generic save/load surfaces. Confirm the startup `SAVE GAME` / `LOAD GAME` screens still show `GAME 01` through `GAME 25`, and verify the launcher or `--list-saves` path now reports Harvester slots as `01..25` while existing save files remain loadable without migration.
 1. Run a live Harvester validation pass on room right-click attacks with both bare hands and equipped weapons: confirm the player still swaps to the correct `PC0/PC01..PC20` art after inventory weapon toggles, and that attacks now play the recovered second-frame sound timing for unarmed swings, melee weapons, firearms, and the chainsaw.
diff --git a/engines/harvester/console.cpp b/engines/harvester/console.cpp
index 5ec6a02d8fb..e1587c8d5d8 100644
--- a/engines/harvester/console.cpp
+++ b/engines/harvester/console.cpp
@@ -20,12 +20,14 @@
  */
 
 #include "harvester/console.h"
+#include "harvester/dialogue_debug.h"
 #include "harvester/harvester.h"
 
 namespace Harvester {
 
 Console::Console() : GUI::Debugger() {
 	registerCmd("about", WRAP_METHOD(Console, Cmd_about));
+	registerCmd("DEBUG_DIALOGUE", WRAP_METHOD(Console, Cmd_debugDialogue));
 	registerCmd("DEBUG_ROOM", WRAP_METHOD(Console, Cmd_debugRoom));
 }
 
@@ -37,6 +39,34 @@ bool Console::Cmd_about(int argc, const char **argv) {
 	return true;
 }
 
+bool Console::Cmd_debugDialogue(int argc, const char **argv) {
+	if (argc != 2) {
+		debugPrintf("Usage: DEBUG_DIALOGUE <NPC_ID>\n");
+		return true;
+	}
+
+	if (!g_engine) {
+		debugPrintf("Harvester engine is not active\n");
+		return true;
+	}
+
+	Text *startupText = g_engine->getStartupText();
+	if (!startupText) {
+		debugPrintf("Harvester startup text is not loaded\n");
+		return true;
+	}
+
+	Common::Array<Common::String> dumpLines;
+	if (!buildDialogueDebugDump(argv[1], *startupText, dumpLines)) {
+		debugPrintf("No structured dialogue dump is available for NPC '%s'\n", argv[1]);
+		return true;
+	}
+
+	for (uint i = 0; i < dumpLines.size(); ++i)
+		debugPrintf("%s\n", dumpLines[i].c_str());
+	return true;
+}
+
 bool Console::Cmd_debugRoom(int argc, const char **argv) {
 	if (argc != 1) {
 		debugPrintf("Usage: DEBUG_ROOM\n");
diff --git a/engines/harvester/console.h b/engines/harvester/console.h
index 076c77b8b96..4bc37d442fd 100644
--- a/engines/harvester/console.h
+++ b/engines/harvester/console.h
@@ -30,6 +30,7 @@ namespace Harvester {
 class Console : public GUI::Debugger {
 private:
 	bool Cmd_about(int argc, const char **argv);
+	bool Cmd_debugDialogue(int argc, const char **argv);
 	bool Cmd_debugRoom(int argc, const char **argv);
 public:
 	Console();
diff --git a/engines/harvester/dialogue_debug.cpp b/engines/harvester/dialogue_debug.cpp
new file mode 100644
index 00000000000..ad59119a71a
--- /dev/null
+++ b/engines/harvester/dialogue_debug.cpp
@@ -0,0 +1,197 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/dialogue_debug.h"
+
+#include "harvester/text.h"
+
+namespace Harvester {
+
+namespace {
+
+static Common::String buildSubtitleSummary(const Text &text, int wavId) {
+	Common::String subtitle;
+	if (!text.resolveDialogueSubtitle(wavId, subtitle))
+		subtitle = "<missing subtitle>";
+	return subtitle;
+}
+
+static Common::String buildResponseSummary(const Text &text, int responseLineIndex) {
+	Common::String responseLine = text.getDialogueResponseLine(responseLineIndex);
+	if (responseLine.empty())
+		responseLine = "<missing response line>";
+	return responseLine;
+}
+
+static void appendLine(Common::Array<Common::String> &lines, const Common::String &line) {
+	lines.push_back(line);
+}
+
+static void appendDialogueLine(Common::Array<Common::String> &lines, const Text &text,
+		const char *indent, const char *speakerId, int wavId, int headVariant) {
+	appendLine(lines, Common::String::format(
+		"%sLINE speaker=%s wav=0x%x (%d) head=%d: %s",
+		indent, speakerId, wavId, wavId, headVariant, buildSubtitleSummary(text, wavId).c_str()));
+}
+
+static void appendResponseMenu(Common::Array<Common::String> &lines, const Text &text,
+		const char *indent, int responseLineIndex) {
+	appendLine(lines, Common::String::format(
+		"%sMENU rsp=0x%x (%d): %s",
+		indent, responseLineIndex, responseLineIndex + 1,
+		buildResponseSummary(text, responseLineIndex).c_str()));
+}
+
+static void appendSergeantDay5Exit(Common::Array<Common::String> &lines, const Text &text,
+		const char *indent) {
+	appendLine(lines, Common::String::format(
+		"%sIF FLAG DAY_5 && !STATE sergeant.day5ReminderShown", indent));
+	appendDialogueLine(lines, text, Common::String::format("%s  ", indent).c_str(),
+		"SERGEANT", 0x4324, 1);
+}
+
+static void buildSergeantDialogueDebugDump(const Text &text, Common::Array<Common::String> &lines) {
+	appendLine(lines, "NPC SERGEANT");
+	appendLine(lines, "STATE sergeant.introPending, sergeant.dialogueStateD2d50, sergeant.dialogueStateD2d54, sergeant.day5ReminderShown");
+	appendLine(lines, "BRANCH no_item");
+	appendLine(lines, "  IF FLAG GOT_REMAINS_FOR_LODGE");
+	appendLine(lines, "    ACTION restore item REMAINS to owner RAH");
+	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x4333, 0);
+	appendLine(lines, "  IF FLAG STEPHANIE_IS_DEAD && (INVENTORY INVITE || FLAG TAKEN_INVITE_TO_SERGEANT)");
+	appendLine(lines, "    ACTION show ST_BEDRM/STEFSKULL");
+	appendLine(lines, "    ACTION restore item INVITE to owner RAH");
+	appendLine(lines, "    ACTION set flags TAKEN_INVITE_TO_SERGEANT, NEED_REMAINS_FOR_LODGE");
+	appendLine(lines, "    ACTION hide NPCs DWAYNE_ST_BEDRM, MRS_POTTS_ST_BEDRM; show NPC MRS_POTTS");
+	appendLine(lines, "    ACTION play FST GRAPHIC/FST/C149.FST");
+	appendDialogueLine(lines, text, "    ", "PC", 0x4305, 0);
+	appendLine(lines, "  IF FLAG DINER_BURNED");
+	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x42b5, 1);
+	appendLine(lines, "  IF FLAG BARBER_POLE_STOLEN");
+	appendLine(lines, "    ACTION restore item BARBER_POLE to owner RAH");
+	appendLine(lines, "    ACTION set flag ASSIGNED_DNA_TASK");
+	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x426f, 0);
+	appendLine(lines, "  IF FLAG BOLT_OF_CLOTH_TAKEN");
+	appendLine(lines, "    ACTION restore item BOLTCLTH to owner RAH");
+	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x4241, 0);
+	appendLine(lines, "  IF FLAG SCRATCHED_TUCKER");
+	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x420d, 0);
+	appendLine(lines, "  IF FLAG HAVE_COMPLETED_LODGE_APP");
+	appendLine(lines, "    ACTION restore item COMPLETED_LODGE_APPLICATION to owner RAH");
+	appendLine(lines, "    IF !FLAG QUEST_1");
+	appendDialogueLine(lines, text, "      ", "SERGEANT", 0x41cd, 1);
+	appendDialogueLine(lines, text, "      ", "SERGEANT", 0x4292, 0);
+	appendLine(lines, "      ACTION set flag QUEST_1");
+	appendLine(lines, "      ACTION execute GO_DAY_2");
+	appendLine(lines, "    ELSE IF !STATE sergeant.dialogueStateD2d54");
+	appendDialogueLine(lines, text, "      ", "SERGEANT", 0x41d2, 1);
+	appendResponseMenu(lines, text, "      ", 0x2b6);
+	appendLine(lines, "        CHOICE 1");
+	appendDialogueLine(lines, text, "          ", "SERGEANT", 0x41db, 0);
+	appendDialogueLine(lines, text, "          ", "PC", 0x41e2, 0);
+	appendDialogueLine(lines, text, "          ", "SERGEANT", 0x41e6, 0);
+	appendDialogueLine(lines, text, "          ", "PC", 0x41ec, 2);
+	appendDialogueLine(lines, text, "          ", "SERGEANT", 0x41f0, 0);
+	appendDialogueLine(lines, text, "          ", "PC", 0x41f6, 0);
+	appendDialogueLine(lines, text, "          ", "SERGEANT", 0x41fa, 0);
+	appendLine(lines, "          ACTION set STATE sergeant.dialogueStateD2d54");
+	appendLine(lines, "        CHOICE 2");
+	appendDialogueLine(lines, text, "          ", "SERGEANT", 0x4201, 3);
+	appendDialogueLine(lines, text, "          ", "SERGEANT", 0x4202, 3);
+	appendDialogueLine(lines, text, "          ", "SERGEANT", 0x4203, 2);
+	appendLine(lines, "          ACTION clear STATE sergeant.dialogueStateD2d54");
+	appendSergeantDay5Exit(lines, text, "      ");
+	appendLine(lines, "    ELSE");
+	appendDialogueLine(lines, text, "      ", "SERGEANT", 0x41f0, 0);
+	appendDialogueLine(lines, text, "      ", "PC", 0x41f6, 0);
+	appendDialogueLine(lines, text, "      ", "SERGEANT", 0x41fa, 0);
+	appendSergeantDay5Exit(lines, text, "      ");
+	appendLine(lines, "  IF STATE sergeant.introPending");
+	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x413f, 2);
+	appendDialogueLine(lines, text, "    ", "PC", 0x4144, 0);
+	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x4145, 0);
+	appendDialogueLine(lines, text, "    ", "PC", 0x4146, 0);
+	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x4148, 2);
+	appendResponseMenu(lines, text, "    ", 0x2b7);
+	appendLine(lines, "      CHOICE 1");
+	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x4153, 0);
+	appendLine(lines, "      CHOICE 2");
+	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x4157, 1);
+	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x415d, 0);
+	appendResponseMenu(lines, text, "    ", 0x2b8);
+	appendLine(lines, "      CHOICE 1");
+	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x4168, 1);
+	appendDialogueLine(lines, text, "        ", "PC", 0x416c, 0);
+	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x4170, 1);
+	appendResponseMenu(lines, text, "        ", 0x2b9);
+	appendLine(lines, "          CHOICE 1");
+	appendDialogueLine(lines, text, "            ", "SERGEANT", 0x417c, 0);
+	appendLine(lines, "          CHOICE 2");
+	appendDialogueLine(lines, text, "            ", "SERGEANT", 0x4182, 2);
+	appendLine(lines, "      ELSE");
+	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x4188, 0);
+	appendDialogueLine(lines, text, "        ", "PC", 0x418e, 0);
+	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x4192, 0);
+	appendSergeantDay5Exit(lines, text, "    ");
+	appendLine(lines, "  ELSE");
+	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x41ac, 0);
+	appendLine(lines, "    IF !FLAG HAVE_LODGE_APP");
+	appendDialogueLine(lines, text, "      ", "SERGEANT", 0x41b0, 0);
+	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x41b4, 3);
+	appendResponseMenu(lines, text, "    ", 0x2bb);
+	appendLine(lines, "      CHOICE 1");
+	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x41bf, 2);
+	appendLine(lines, "        ACTION set STATE sergeant.dialogueStateD2d50");
+	appendLine(lines, "      CHOICE 2");
+	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x41c4, 0);
+	appendSergeantDay5Exit(lines, text, "    ");
+	appendLine(lines, "BRANCH item_use");
+	appendLine(lines, "  ITEM BARBER_POLE");
+	appendLine(lines, "    ACTION restore item BARBER_POLE to owner RAH");
+	appendLine(lines, "    ACTION set flag ASSIGNED_DNA_TASK");
+	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x426f, 0);
+	appendLine(lines, "  ITEM BOLTCLTH");
+	appendLine(lines, "    ACTION restore item BOLTCLTH to owner RAH");
+	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x4241, 0);
+	appendLine(lines, "  ITEM COMPLETED_LODGE_APPLICATION");
+	appendLine(lines, "    SAME AS no_item / FLAG HAVE_COMPLETED_LODGE_APP branch");
+	appendLine(lines, "  ITEM INVITE");
+	appendLine(lines, "    SAME AS no_item / STEPHANIE_IS_DEAD invite branch");
+	appendLine(lines, "  ITEM REMAINS");
+	appendLine(lines, "    SAME AS no_item / GOT_REMAINS_FOR_LODGE branch");
+	appendLine(lines, "  ELSE");
+	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x42fa, 0);
+}
+
+} // End of anonymous namespace
+
+bool buildDialogueDebugDump(const Common::String &npcName, const Text &text,
+		Common::Array<Common::String> &lines) {
+	lines.clear();
+
+	if (npcName.equalsIgnoreCase("SERGEANT")) {
+		buildSergeantDialogueDebugDump(text, lines);
+		return true;
+	}
+
+	return false;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/dialogue_debug.h b/engines/harvester/dialogue_debug.h
new file mode 100644
index 00000000000..4a68324e572
--- /dev/null
+++ b/engines/harvester/dialogue_debug.h
@@ -0,0 +1,37 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_DIALOGUE_DEBUG_H
+#define HARVESTER_DIALOGUE_DEBUG_H
+
+#include "common/array.h"
+#include "common/str.h"
+
+namespace Harvester {
+
+class Text;
+
+bool buildDialogueDebugDump(const Common::String &npcName, const Text &text,
+	Common::Array<Common::String> &lines);
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_DIALOGUE_DEBUG_H
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index b09ac75d113..30367e6194f 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -11,6 +11,7 @@ MODULE := engines/harvester
 	npc/chessmaster_dialogue.o \
 	npc/cloak_atnd_dialogue.o \
 	console.o \
+	dialogue_debug.o \
 	dialogue.o \
 	fst_player.o \
 	metaengine.o \


Commit: 47a39f38d9a00b8e96097aa1263591bb8c87b24e
    https://github.com/scummvm/scummvm/commit/47a39f38d9a00b8e96097aa1263591bb8c87b24e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:44+02:00

Commit Message:
HARVESTER: Revert save slot numbering

This reverts commit 9501179f810546ae0170825a553ed59b795eab24.

Keep Harvester's native save/load screens as the only place that shows
GAME 01 through GAME 25.

Changed paths:
    TRACKER.md
    base/commandLine.cpp
    engines/harvester/metaengine.cpp
    engines/harvester/metaengine.h
    engines/metaengine.cpp
    engines/metaengine.h
    gui/saveload-dialog.cpp
    gui/saveload-dialog.h


diff --git a/TRACKER.md b/TRACKER.md
index b451088639a..1feb9836950 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -21,8 +21,6 @@
 - On March 21, 2026, re-opened the remaining hidden Sergeant talk tails in the live `HARVEST.LE` Ghidra session and cross-checked them against the native `DIALOG.RSP` plus `iso/Harvester/DIALOGUE.IDX` data after a live `SERGEANTRM` repro showed ScummVM stopping on Sergeant line `0x41d2` without opening the expected prompt.
 - Confirmed the missing native Sergeant flow is broader than the single `0x41d2` menu: the first-contact branch continues past `0x413f` through `0x4144` / `0x4145` / `0x4146` / `0x4148` and response lines `0x2b7`, `0x2b8`, `0x2b9`; the generic revisit branch plays `0x41ac`, optionally `0x41b0`, then `0x41b4` and response line `0x2bb`; the post-`QUEST_1` application branch opens response line `0x2b6` after `0x41d2`, branches either into the Johnson-task briefing `0x41db`..`0x41fa` or the refusal path `0x4201` / `0x4202` / `0x4203`, and later revisits replay `0x41f0` / `0x41f6` / `0x41fa` once the local `d2d54` bit is set. Those tails also share a day-5 one-shot exit line `0x4324` guarded by the local `d2d70` latch.
 - Patched `engines/harvester/npc/sergeant_dialogue.cpp` and `engines/harvester/npc/sergeant_dialogue.h` so ScummVM now mirrors those recovered Sergeant menus, line sequences, head variants, and local state bits instead of reducing them to single-line returns; updated `ARCHITECTURE.md` with the confirmed hidden-tail evidence.
-- On March 21, 2026, re-opened native `run_save_game_menu @ 0x632c0` and `run_load_game_menu` in the live `HARVEST.LE` Ghidra session to confirm how the original executable presents save slots before changing ScummVM's generic save/load display path.
-- Confirmed both native menu handlers build the slot labels from the static `PTR_s_GAME_01_000c3c20` table, which expands to `GAME 01` through `GAME 25` on screen. Also confirmed the underlying save filename formatter still consumes the raw zero-based slot index when it emits `%sSAVE.%s`, so the parity fix belongs in ScummVM's presentation layer rather than in the on-disk slot/file mapping. Patched the shared save/load UI plus `--list-saves` output to use a new meta-engine display formatter, and overrode Harvester to expose slots as `01..25` without renumbering the actual save files.
 - On March 21, 2026, re-opened `parse_map_entrance_record @ 0x5ef20`, `parse_object_record @ 0x5fa40`, `parse_room_record @ 0x5fea0`, `parse_monster_record @ 0x5f080`, `spawn_monster_entity_from_record @ 0x53a10`, `spawn_player_combat_avatar @ 0x54220`, `update_actor_runtime_state @ 0x4d750`, and `select_town_map_destination @ 0x66460` in the live `HARVEST.LE` Ghidra session to resolve the remaining placeholder fields in `MapEntranceRecord`, `ObjectRecord`, `RoomRecord`, and `MonsterRecord` before mirroring the names into ScummVM.
 - Confirmed `MAP_ENTRANCE` stores a coordinate pair plus `initial_panel_index`; the selector currently only reads the panel index, but the first two columns stay bounded as stored map coordinates. Confirmed the monster timing quartet around `+0x70..+0x7c` is a sound-trigger block by pairing `spawn_monster_entity_from_record` with the player constructor: `attack_sound_trigger_frame`, `hit_sound_trigger_frame`, `footstep_sound_trigger_frame`, and `death_sound_trigger_frame`. Confirmed the remaining room/object/monster string placeholders are reserved script columns in current shipping data: room reserved strings are always empty, monster reserved strings are always empty, `ObjectRecord.reserved_string_40` is always empty, and `ObjectRecord.reserved_x_flag` is only an empty-or-`X` marker with no recovered read-side consumer. Patched the ScummVM Harvester structs/parser serialization to use those names and updated the local RE notes so the reserved-vs-semantic split is explicit.
 - On March 20, 2026, re-opened `handle_talk_to_karin @ 0x2ea90`, `handle_talk_to_edna @ 0x358a0`, and `handle_talk_to_dwayne @ 0x3a310` in the live `HARVEST.LE` Ghidra session to verify the native dialogue maps for KARIN, EDNA, and DWAYNE against the current ScummVM handlers.
@@ -151,7 +149,6 @@
 
 - Immediate: use `DEBUG_DIALOGUE SERGEANT` during the next live `SERGEANTRM` repro and compare the printed branch structure against the Ghidra tail blocks. If the workflow is useful, extend `engines/harvester/dialogue_debug.cpp` to the next NPC you are actively auditing instead of re-reading native control flow from scratch each time.
 - Immediate: run a live Harvester validation pass in `SERGEANTRM` across all recovered Sergeant states. Verify first contact now reaches `0x413f` -> `0x4148` with response lines `0x2b7` / `0x2b8` / `0x2b9`, the application reminder path now reaches `0x41ac` / optional `0x41b0` / `0x41b4` with response line `0x2bb`, and the post-application path now opens response line `0x2b6` after `0x41d2` before branching into the Johnson-task or refusal sequence.
-- Immediate: run a live Harvester validation pass on both the in-game save/load menu and ScummVM's generic save/load surfaces. Confirm the startup `SAVE GAME` / `LOAD GAME` screens still show `GAME 01` through `GAME 25`, and verify the launcher or `--list-saves` path now reports Harvester slots as `01..25` while existing save files remain loadable without migration.
 1. Run a live Harvester validation pass on room right-click attacks with both bare hands and equipped weapons: confirm the player still swaps to the correct `PC0/PC01..PC20` art after inventory weapon toggles, and that attacks now play the recovered second-frame sound timing for unarmed swings, melee weapons, firearms, and the chainsaw.
 2. If any player loadout still sounds wrong in that pass, re-open `spawn_player_combat_avatar @ 0x54220` and the frame-commit logic around `0x52762..0x52993` to mirror any still-missing special cases such as the firearm secondary timing branch or the chainsaw empty/fuel path exactly.
 3. Run a live Harvester validation pass in `SCHLHL` covering both Whaley/Herrill acceptance paths: after taking the broom-closet photo, choose response line `0x24c` option `2` and the nested `0x24d` option `1`, and confirm both paths now add `BAT`, hide `PHOTO_OF_WHALEY_HERRILL` from the inventory strip, clear `BUSTED_SCREWING_MIDGAME`, and still unwind the broom-door state through `REVERSE_BROOM` when that flag is set.
diff --git a/base/commandLine.cpp b/base/commandLine.cpp
index 575161b0cda..c29d1eb09ac 100644
--- a/base/commandLine.cpp
+++ b/base/commandLine.cpp
@@ -1573,8 +1573,7 @@ static Common::Error listSaves(const Common::String &singleTarget) {
 					   "  ---- ------------------------------------------------------\n");
 
 			for (const auto &x : saveList) {
-				const Common::String displaySlot = metaEngine.formatSaveSlotForDisplay(x.getSaveSlot());
-				printf("  %-4s %s\n", displaySlot.c_str(), x.getDescription().c_str());
+				printf("  %-4d %s\n", x.getSaveSlot(), x.getDescription().c_str());
 				// TODO: Could also iterate over the full hashmap, printing all key-value pairs
 			}
 			atLeastOneFound = true;
diff --git a/engines/harvester/metaengine.cpp b/engines/harvester/metaengine.cpp
index 985376e5f9d..9d03547bc85 100644
--- a/engines/harvester/metaengine.cpp
+++ b/engines/harvester/metaengine.cpp
@@ -63,10 +63,6 @@ int HarvesterMetaEngine::getAutosaveSlot() const {
 	return -1;
 }
 
-Common::String HarvesterMetaEngine::formatSaveSlotForDisplay(int slot) const {
-	return Common::String::format("%02d", slot + 1);
-}
-
 bool HarvesterMetaEngine::hasFeature(MetaEngineFeature f) const {
 	return checkExtendedSaves(f) ||
 		(f == kSupportsLoadingDuringStartup);
diff --git a/engines/harvester/metaengine.h b/engines/harvester/metaengine.h
index c564cc120e9..57644f2b606 100644
--- a/engines/harvester/metaengine.h
+++ b/engines/harvester/metaengine.h
@@ -32,7 +32,6 @@ public:
 	Common::Error createInstance(OSystem *syst, Engine **engine, const ADGameDescription *desc) const override;
 	int getMaximumSaveSlot() const override;
 	int getAutosaveSlot() const override;
-	Common::String formatSaveSlotForDisplay(int slot) const override;
 
 	/**
 	 * Determine whether the engine supports the specified MetaEngine feature.
diff --git a/engines/metaengine.cpp b/engines/metaengine.cpp
index aaf9a18b549..5a2bfb0f09b 100644
--- a/engines/metaengine.cpp
+++ b/engines/metaengine.cpp
@@ -69,10 +69,6 @@ Common::String MetaEngine::getSavegameFile(int saveGameIdx, const char *target)
 	}
 }
 
-Common::String MetaEngine::formatSaveSlotForDisplay(int slot) const {
-	return Common::String::format("%d", slot);
-}
-
 Common::KeymapArray MetaEngine::initKeymaps(const char *target) const {
 	using namespace Common;
 
diff --git a/engines/metaengine.h b/engines/metaengine.h
index 977c0d1abef..116cd4c5491 100644
--- a/engines/metaengine.h
+++ b/engines/metaengine.h
@@ -374,14 +374,6 @@ public:
 	 */
 	virtual Common::String getSavegameFile(int saveGameIdx, const char *target = nullptr) const;
 
-	/**
-	 * Return the user-facing slot label for the given save slot.
-	 *
-	 * This only affects presentation in generic ScummVM UI and does not alter
-	 * the underlying save slot number or filename mapping.
-	 */
-	virtual Common::String formatSaveSlotForDisplay(int slot) const;
-
 	/**
 	 * Return the pattern for save files.
 	 *
diff --git a/gui/saveload-dialog.cpp b/gui/saveload-dialog.cpp
index 5cdbc074e39..2f58f9a5476 100644
--- a/gui/saveload-dialog.cpp
+++ b/gui/saveload-dialog.cpp
@@ -1130,7 +1130,7 @@ int SaveLoadChooserGrid::runIntern() {
 
 bool SaveLoadChooserGrid::selectDescription() {
 	_savenameDialog.setDescription(_resultString);
-	_savenameDialog.setTargetSlot(_metaEngine, getResult());
+	_savenameDialog.setTargetSlot(getResult());
 	if (_savenameDialog.runModal() == 0) {
 		_resultString = _savenameDialog.getDescription();
 		return true;
@@ -1168,7 +1168,6 @@ void SaveLoadChooserGrid::updateSaves() {
 
 	for (uint i = _curPage * _entriesPerPage, curNum = 0; i < _saveList.size() && curNum < _entriesPerPage; ++i, ++curNum) {
 		const uint saveSlot = _saveList[i].getSaveSlot();
-		const Common::String displaySlot = _metaEngine->formatSaveSlotForDisplay(saveSlot);
 
 		SaveStateDescriptor desc =  (_saveList[i].getLocked() ? _saveList[i] : _metaEngine->querySaveMetaInfos(_target.c_str(), saveSlot));
 		if (!_saveList[i].getLocked() && desc.getSaveSlot() >= 0 && !desc.getDescription().empty())
@@ -1181,7 +1180,7 @@ void SaveLoadChooserGrid::updateSaves() {
 		} else {
 			curButton.button->setGfx(kThumbnailWidth, kThumbnailHeight2, 0, 0, 0);
 		}
-		curButton.description->setLabel(Common::U32String(displaySlot + ". ") + _saveList[i].getDescription().decode());
+		curButton.description->setLabel(Common::U32String(Common::String::format("%d. ", saveSlot)) + _saveList[i].getDescription().decode());
 
 		Common::U32String tooltip(_("Name: "));
 		tooltip += _saveList[i].getDescription().decode();
@@ -1238,8 +1237,7 @@ void SaveLoadChooserGrid::updateSaves() {
 }
 
 SavenameDialog::SavenameDialog()
-	: Dialog("SavenameDialog"),
-	  _metaEngine(nullptr) {
+	: Dialog("SavenameDialog") {
 	_title = new StaticTextWidget(this, "SavenameDialog.DescriptionText", Common::String());
 
 	new ButtonWidget(this, "SavenameDialog.Cancel", _("Cancel"), Common::U32String(), kCloseCmd);
@@ -1262,10 +1260,7 @@ void SavenameDialog::open() {
 	Dialog::open();
 	setResult(-1);
 
-	const Common::String displaySlot = _metaEngine
-		? _metaEngine->formatSaveSlotForDisplay(_targetSlot)
-		: Common::String::format("%d", _targetSlot);
-	_title->setLabel(Common::U32String::format(_("Enter a description for slot %s:"), displaySlot.c_str()));
+	_title->setLabel(Common::U32String::format(_("Enter a description for slot %d:"), _targetSlot));
 }
 
 void SavenameDialog::handleCommand(CommandSender *sender, uint32 cmd, uint32 data) {
diff --git a/gui/saveload-dialog.h b/gui/saveload-dialog.h
index 2620eff10b7..db70a4358ba 100644
--- a/gui/saveload-dialog.h
+++ b/gui/saveload-dialog.h
@@ -191,16 +191,12 @@ public:
 	void setDescription(const Common::String &desc);
 	const Common::String getDescription();
 
-	void setTargetSlot(const MetaEngine *metaEngine, int slot) {
-		_metaEngine = metaEngine;
-		_targetSlot = slot;
-	}
+	void setTargetSlot(int slot) { _targetSlot = slot; }
 
 	void open() override;
 protected:
 	void handleCommand(CommandSender *sender, uint32 cmd, uint32 data) override;
 private:
-	const MetaEngine *_metaEngine;
 	int _targetSlot;
 	StaticTextWidget *_title;
 	EditTextWidget *_description;


Commit: deff837af048f706f8627005e3325834dd705c1c
    https://github.com/scummvm/scummvm/commit/deff837af048f706f8627005e3325834dd705c1c
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:45+02:00

Commit Message:
HARVESTER: Fix startup save slot label clipping

Changed paths:
    TRACKER.md
    engines/harvester/menu.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 1feb9836950..3a664d93554 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 21, 2026, re-opened native `run_save_game_menu @ 0x632c0` and `run_load_game_menu @ 0x64910` in the live `HARVEST.LE` Ghidra session after ScummVM’s startup save/load menus started clipping slot labels such as `GAME 02` and `GAME 25`.
+- Confirmed the original executable spawns the slot-label text entities directly at `x = 8` using the static `PTR_s_GAME_01_000c3c20` table, while the editable save-name text starts separately at `x = 0x50`; there is no native width clamp on the `GAME xx` label. Patched `engines/harvester/menu.cpp` to stop drawing the label through the ScummVM-only `64` pixel cap so the native `GAME 01` through `GAME 25` labels render in full again.
 - On March 21, 2026, added a Harvester debugger-console workflow helper instead of re-opening Ghidra for every Sergeant comparison: new `DEBUG_DIALOGUE <NPC_ID>` support now routes through `engines/harvester/dialogue_debug.cpp` and prints a structured branch dump with flag conditions, `DIALOG.RSP` response indices, WAV ids, head variants, and resolved subtitle text.
 - The first structured dump targets `SERGEANT`, mirroring the currently recovered ScummVM tree for the intro `0x2b7` / `0x2b8` / `0x2b9` menus, the `0x2bb` application-reminder menu, the post-`0x41d2` `0x2b6` branch, and the item-use shortcuts so those paths can be diffed against Ghidra without manually hopping back into the native executable for every check.
 - On March 21, 2026, re-opened the remaining hidden Sergeant talk tails in the live `HARVEST.LE` Ghidra session and cross-checked them against the native `DIALOG.RSP` plus `iso/Harvester/DIALOGUE.IDX` data after a live `SERGEANTRM` repro showed ScummVM stopping on Sergeant line `0x41d2` without opening the expected prompt.
@@ -147,6 +149,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run a live Harvester validation pass on the native startup `SAVE GAME` and `LOAD GAME` screens. Confirm all labels now render as full `GAME 01` through `GAME 25` strings, including previously clipped rows such as `GAME 02`, `GAME 09`, `GAME 20`, and `GAME 25`.
 - Immediate: use `DEBUG_DIALOGUE SERGEANT` during the next live `SERGEANTRM` repro and compare the printed branch structure against the Ghidra tail blocks. If the workflow is useful, extend `engines/harvester/dialogue_debug.cpp` to the next NPC you are actively auditing instead of re-reading native control flow from scratch each time.
 - Immediate: run a live Harvester validation pass in `SERGEANTRM` across all recovered Sergeant states. Verify first contact now reaches `0x413f` -> `0x4148` with response lines `0x2b7` / `0x2b8` / `0x2b9`, the application reminder path now reaches `0x41ac` / optional `0x41b0` / `0x41b4` with response line `0x2bb`, and the post-application path now opens response line `0x2b6` after `0x41d2` before branching into the Johnson-task or refusal sequence.
 1. Run a live Harvester validation pass on room right-click attacks with both bare hands and equipped weapons: confirm the player still swaps to the correct `PC0/PC01..PC20` art after inventory weapon toggles, and that attacks now play the recovered second-frame sound timing for unarmed swings, melee weapons, firearms, and the chainsaw.
diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index 22a36957032..4108d3aedc3 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -569,7 +569,8 @@ static void renderSaveGameMenuScreen(HarvesterEngine &engine, const IndexedBitma
 		const Graphics::Font &labelFont = (i == activeSlot) ? selectedLabelFont : unselectedLabelFont;
 		const int y = kSaveSlotStartY + i * kSaveSlotStride;
 		const Common::String slotLabel = buildDefaultSaveSlotLabel(i);
-		labelFont.drawString(screen, slotLabel, kSaveSlotLabelX, y, 64, 0);
+		labelFont.drawString(screen, slotLabel, kSaveSlotLabelX, y,
+			labelFont.getStringWidth(slotLabel), 0);
 
 		Common::String displayTitle = slotTitles[i];
 		if (i == editingSlot && editingText)


Commit: 999c7ca865c00fbe005fb82f7dc8e89456fa87a5
    https://github.com/scummvm/scummvm/commit/999c7ca865c00fbe005fb82f7dc8e89456fa87a5
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:45+02:00

Commit Message:
HARVESTER: Dispatch expired timers by target tag

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 7085cfd64f6..3edea52efc1 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -280,6 +280,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `run_harvester_main_loop` is the code that actually consumes `g_pending_room_name`.
   - At the top of each gameplay iteration it checks for a queued handoff, clears any selected carried object / interaction text state that would leak across the boundary, and then calls `room_setup(g_pending_room_name)`.
   - The same loop also owns the live room right-click attack selector when no carried item is active and the player is alive.
+  - When a live class `0x17` timer entity expires, the same loop resolves the backing `TimerRecord` by `timer_name` and dispatches `dispatch_room_event_actions(timer->arg2)`; the timer name is only the lookup key, not the action tag.
   - It sets `DAT_000d61d0 = 3`, compares cursor X against the player midpoint, and then compares cursor Y against `player_bottom - depth_scale * 144.44` and `player_bottom - depth_scale * 75.36`.
   - Those thresholds choose the six directional attack states `0x18/0x17/0x16` on the left side and `0x1b/0x1a/0x19` on the right side.
   - `update_actor_runtime_state` maps those states to frame banks `0x50..0x59`, `0x5a..0x63`, `0x64..0x6d`, `0x6e..0x77`, `0x78..0x81`, and `0x82..0x8b`.
@@ -905,6 +906,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `initial_value`, `current_value`, `timer_name`, `arg1`, `arg2`, `enabled`, `next`
   - parser booleans are `loop` and `global`
   - `room_setup` treats `arg1` as the room/scope key for room-local timers and only materializes timer entities when it matches the resolved room name.
+  - `arg2` is the action-tag target executed when the timer expires; native main-loop expiry first resolves the record by `timer_name`, then dispatches `dispatch_room_event_actions(arg2)`.
   - `initialize_timer_entity_common_fields` at `0x4ab10` zeroes the lightweight timer-entity header before `spawn_timer_entity_from_record` installs the countdown fields and adds the entity to `g_render_entity_list`.
 - `UseItemRecord` is stable:
   - `item_name`, `target_name`, `action_tag`, `next`
diff --git a/TRACKER.md b/TRACKER.md
index 3a664d93554..486b6c5a608 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 21, 2026, re-opened `run_harvester_main_loop @ 0x6dc70`, `find_timer_record_by_name @ 0x62e80`, `spawn_timer_entity_from_record @ 0x59390`, and `parse_timer_record @ 0x60160` in the live `HARVEST.LE` Ghidra session after a Pottsdam bathroom repro hit the unresolved timer tag `WALL_PIC_T` instead of starting the native peephole follow-up.
+- Confirmed the native timer-expiry path does not dispatch the timer name as an action tag. When a class `0x17` timer entity expires, `run_harvester_main_loop` resolves the backing `TimerRecord` by `timer_name` and then calls `dispatch_room_event_actions(timer->arg2)`. That matches the decoded `HARVEST.SCR` data for the Pottsdam bathroom sequence: `ST_BATHRM_WALL_PIC0` enables two-second timer `WALL_PIC_T`, the timer record is scoped to `ST_BATHRM`, and its action target is `ST_BATHRM_WALL_PIC1`, which selects the correct `PEEPER0*` FST and then clears the timer. Patched `engines/harvester/script.{h,cpp}` and `engines/harvester/room.cpp` so ScummVM now mirrors that native timer dispatch instead of executing the timer name itself; updated `ARCHITECTURE.md` with the confirmed timer-record note.
 - On March 21, 2026, re-opened native `run_save_game_menu @ 0x632c0` and `run_load_game_menu @ 0x64910` in the live `HARVEST.LE` Ghidra session after ScummVM’s startup save/load menus started clipping slot labels such as `GAME 02` and `GAME 25`.
 - Confirmed the original executable spawns the slot-label text entities directly at `x = 8` using the static `PTR_s_GAME_01_000c3c20` table, while the editable save-name text starts separately at `x = 0x50`; there is no native width clamp on the `GAME xx` label. Patched `engines/harvester/menu.cpp` to stop drawing the label through the ScummVM-only `64` pixel cap so the native `GAME 01` through `GAME 25` labels render in full again.
 - On March 21, 2026, added a Harvester debugger-console workflow helper instead of re-opening Ghidra for every Sergeant comparison: new `DEBUG_DIALOGUE <NPC_ID>` support now routes through `engines/harvester/dialogue_debug.cpp` and prints a structured branch dump with flag conditions, `DIALOG.RSP` response indices, WAV ids, head variants, and resolved subtitle text.
@@ -149,6 +151,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run a live Harvester validation pass in `ST_BATHRM`. Click `ST_WALPIC` before and after the quest-gate flags (`SCRATCHED_TUCKER`, `BOLT_OF_CLOTH_TAKEN`, `BARBER_POLE_STOLEN`, `DINER_BURNED`) and confirm the recovered timer path now waits about two seconds, plays `PEEPER01.FST` / `PEEPER02.FST` / `PEEPER4.FST` / `PEEPER5.FST` through `ST_BATHRM_WALL_PIC1`, sets `FOUND_PEEPHOLE`, and then disables/kills `WALL_PIC_T`.
 - Immediate: run a live Harvester validation pass on the native startup `SAVE GAME` and `LOAD GAME` screens. Confirm all labels now render as full `GAME 01` through `GAME 25` strings, including previously clipped rows such as `GAME 02`, `GAME 09`, `GAME 20`, and `GAME 25`.
 - Immediate: use `DEBUG_DIALOGUE SERGEANT` during the next live `SERGEANTRM` repro and compare the printed branch structure against the Ghidra tail blocks. If the workflow is useful, extend `engines/harvester/dialogue_debug.cpp` to the next NPC you are actively auditing instead of re-reading native control flow from scratch each time.
 - Immediate: run a live Harvester validation pass in `SERGEANTRM` across all recovered Sergeant states. Verify first contact now reaches `0x413f` -> `0x4148` with response lines `0x2b7` / `0x2b8` / `0x2b9`, the application reminder path now reaches `0x41ac` / optional `0x41b0` / `0x41b4` with response line `0x2bb`, and the post-application path now opens response line `0x2b6` after `0x41d2` before branching into the Johnson-task or refusal sequence.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 00367da1fa5..fb4621d01e5 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1826,7 +1826,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				syncCurrentRoomRuntimeState();
 				for (const Common::String &timerName : expiredTimerNames) {
 					StartupInteractionResult timerInteraction;
-					if (!_engine.getStartupScript()->executeActionTag(timerName, timerInteraction))
+					if (!_engine.getStartupScript()->executeTimerAction(timerName, timerInteraction))
 						continue;
 
 					bool didTransition = false;
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index e45f87851eb..73ae62408c8 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1471,6 +1471,37 @@ bool Script::executeActionTag(const Common::String &tag, StartupInteractionResul
 		result.mutatedRuntimeState || hasActionableCommandChain(tag);
 }
 
+bool Script::executeTimerAction(const Common::String &timerName, StartupInteractionResult &result,
+		bool allowTransitions) {
+	result = StartupInteractionResult();
+	if (timerName.empty())
+		return false;
+
+	const StartupTimerRecord *timer = findRuntimeTimer(timerName);
+	if (!timer) {
+		debug(1, "Harvester: unresolved timer record '%s'", timerName.c_str());
+		return false;
+	}
+	if (timer->arg2.empty()) {
+		debug(1, "Harvester: timer '%s' has no action tag", timerName.c_str());
+		return false;
+	}
+
+	executeCommandChain(timer->arg2, "timer command", timer->timerName, allowTransitions,
+		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
+		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu,
+		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
+		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
+		&result.playerGotoX, &result.playerGotoZ, &result.mutatedRuntimeState);
+
+	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
+		!result.deathFlicPath.empty() || result.requestMainMenu ||
+		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
+		!result.continuationTag.empty() || !result.modalText.value.empty() ||
+		result.lightingCommand != kStartupLightingCommandNone || result.requestPlayerGotoXZ ||
+		result.mutatedRuntimeState || hasActionableCommandChain(timer->arg2);
+}
+
 bool Script::executeNestedActionTag(const Common::String &tag, StartupInteractionResult &result,
 		bool allowTransitions) {
 	const bool handled = executeActionTag(tag, result, allowTransitions);
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index cbc2a13fc51..6645bc901a3 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -353,6 +353,8 @@ public:
 		StartupInteractionResult &result);
 	bool executeActionTag(const Common::String &tag, StartupInteractionResult &result,
 		bool allowTransitions = true);
+	bool executeTimerAction(const Common::String &timerName, StartupInteractionResult &result,
+		bool allowTransitions = true);
 	bool executeNestedActionTag(const Common::String &tag, StartupInteractionResult &result,
 		bool allowTransitions = true);
 	bool isPickupObject(const StartupObjectRecord &object) const;


Commit: cf803e42a241184c16d447d58a44aeec443d76eb
    https://github.com/scummvm/scummvm/commit/cf803e42a241184c16d447d58a44aeec443d76eb
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:45+02:00

Commit Message:
HARVESTER: Fix stephanie post-kidnap dialogue tail

Changed paths:
    TRACKER.md
    engines/harvester/npc/stephanie_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 486b6c5a608..47eb85fb14a 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 21, 2026, re-opened native `handle_talk_to_stephanie @ 0x36710` together with the hidden Stephanie tails now labeled `stephanie_post_karin_kidnaped_tail @ 0x37650` and `stephanie_spyhole_keyword_continuation @ 0x37e78` in the live `HARVEST.LE` Ghidra session after the current ScummVM map still replayed spyhole/alibi lines on generic post-spyhole revisits.
+- Confirmed the native handler only reaches the `0x4970` / `0x4975` spyhole/alibi tail from the post-`KARIN_KIDNAPED` continuation. The standalone `0x2f9..0x2fd` spyhole keyword branch merely sets `g_stephanie_played_spyhole_branch`, optionally varies `0x4a19` vs `0x4a1e` based on the Mr. Potts confrontation bit, plays `0x4a23`, and loops back into the keyword menu. Fixed the Stephanie-local Ghidra state names (`g_stephanie_scratched_tucker_shown`, `g_stephanie_bolt_of_cloth_taken_shown`, `g_stephanie_barber_pole_stolen_shown`, `g_stephanie_burned_tv_station_shown`, `g_stephanie_diner_burned_shown`, `g_stephanie_diner_burned_karin_alive_followup_shown`, `g_stephanie_day_five_line_played`, `g_stephanie_pc_escaped_jail_shown`, `g_stephanie_karin_kidnaped_line_played`, and `g_stephanie_bolt_of_cloth_escalation_triggered`), saved `HARVEST.LE`, and patched `engines/harvester/npc/stephanie_dialogue.cpp` so ScummVM only replays the `0x4970` / `0x4975` plus optional `0x4980` / `0x4984` / `0x4989` / `0x498f` tail when the native `KARIN_KIDNAPED` branch has actually just fired.
 - On March 21, 2026, re-opened `run_harvester_main_loop @ 0x6dc70`, `find_timer_record_by_name @ 0x62e80`, `spawn_timer_entity_from_record @ 0x59390`, and `parse_timer_record @ 0x60160` in the live `HARVEST.LE` Ghidra session after a Pottsdam bathroom repro hit the unresolved timer tag `WALL_PIC_T` instead of starting the native peephole follow-up.
 - Confirmed the native timer-expiry path does not dispatch the timer name as an action tag. When a class `0x17` timer entity expires, `run_harvester_main_loop` resolves the backing `TimerRecord` by `timer_name` and then calls `dispatch_room_event_actions(timer->arg2)`. That matches the decoded `HARVEST.SCR` data for the Pottsdam bathroom sequence: `ST_BATHRM_WALL_PIC0` enables two-second timer `WALL_PIC_T`, the timer record is scoped to `ST_BATHRM`, and its action target is `ST_BATHRM_WALL_PIC1`, which selects the correct `PEEPER0*` FST and then clears the timer. Patched `engines/harvester/script.{h,cpp}` and `engines/harvester/room.cpp` so ScummVM now mirrors that native timer dispatch instead of executing the timer name itself; updated `ARCHITECTURE.md` with the confirmed timer-record note.
 - On March 21, 2026, re-opened native `run_save_game_menu @ 0x632c0` and `run_load_game_menu @ 0x64910` in the live `HARVEST.LE` Ghidra session after ScummVM’s startup save/load menus started clipping slot labels such as `GAME 02` and `GAME 25`.
@@ -151,6 +153,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run a live Harvester validation pass on Stephanie's recovered no-item tails. Trigger the `0x2f9..0x2fd` spyhole keyword branch before `KARIN_KIDNAPED` and confirm it only returns to the keyword menu after `0x4a23`; then repeat after `KARIN_KIDNAPED`, first without and then with the Mr./Mrs. Potts Tuesday-night alibi bits set, to confirm ScummVM now gates `0x4970` / `0x4975` / `0x4980` / `0x4984` / `0x4989` / `0x498f` exactly like the native handler before the Karin outcome block exits through `0x49ea` / `0x49ef`.
 - Immediate: run a live Harvester validation pass in `ST_BATHRM`. Click `ST_WALPIC` before and after the quest-gate flags (`SCRATCHED_TUCKER`, `BOLT_OF_CLOTH_TAKEN`, `BARBER_POLE_STOLEN`, `DINER_BURNED`) and confirm the recovered timer path now waits about two seconds, plays `PEEPER01.FST` / `PEEPER02.FST` / `PEEPER4.FST` / `PEEPER5.FST` through `ST_BATHRM_WALL_PIC1`, sets `FOUND_PEEPHOLE`, and then disables/kills `WALL_PIC_T`.
 - Immediate: run a live Harvester validation pass on the native startup `SAVE GAME` and `LOAD GAME` screens. Confirm all labels now render as full `GAME 01` through `GAME 25` strings, including previously clipped rows such as `GAME 02`, `GAME 09`, `GAME 20`, and `GAME 25`.
 - Immediate: use `DEBUG_DIALOGUE SERGEANT` during the next live `SERGEANTRM` repro and compare the printed branch structure against the Ghidra tail blocks. If the workflow is useful, extend `engines/harvester/dialogue_debug.cpp` to the next NPC you are actively auditing instead of re-reading native control flow from scratch each time.
diff --git a/engines/harvester/npc/stephanie_dialogue.cpp b/engines/harvester/npc/stephanie_dialogue.cpp
index 4611c0632e0..2ebb1df71f6 100644
--- a/engines/harvester/npc/stephanie_dialogue.cpp
+++ b/engines/harvester/npc/stephanie_dialogue.cpp
@@ -571,10 +571,12 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				return lineError;
 		}
 	}
+	bool ranKarinKidnapedBranch = false;
 	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
 			!state.karinKidnapedLinePlayed &&
 			sharedState.karinKidnapedDialogueState) {
 		state.karinKidnapedLinePlayed = true;
+		ranKarinKidnapedBranch = true;
 		const DialogueLineEntry kidnapedLines[] = {
 			{ 0x4957, "STEPHANIE", 0 },
 			{ 0x495b, "PC", 0 },
@@ -586,7 +588,8 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
-	if (state.playedSpyholeBranch) {
+	// Native only reaches the spyhole/alibi tail from the post-kidnap continuation.
+	if (ranKarinKidnapedBranch && state.playedSpyholeBranch) {
 		Common::Error lineError = runtime.playDialogueLine(0x4970, "STEPHANIE");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;


Commit: d49b2cd3a72966386c881849857fde4c1b000533
    https://github.com/scummvm/scummvm/commit/d49b2cd3a72966386c881849857fde4c1b000533
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:46+02:00

Commit Message:
HARVESTER: Restore Potts dialogue parity

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/mr_potts_dialogue.cpp
    engines/harvester/npc/mr_potts_dialogue.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 3edea52efc1..ff8676322b3 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -488,6 +488,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `handle_talk_to_mr_potts @ 0x26000` is now bounded enough for a direct startup-room port despite the truncated primary function stub.
     - The auxiliary intro is the only place that reseeds Mr. Potts's keyword buffer, writing zero-based `dialog.rsp` lines `0x1d8`, `0x1d9`, `0x1da`, or `0x1db` depending on the selected response and whether Stephanie is already dead.
     - That keyword buffer persists outside `reset_talk_to_mr_potts_aux_state`; later visits reuse it through the hidden loop rooted at `0x26fc8`, which clears the selected topic text and re-enters `run_dialogue_keyword_menu`.
+    - The hidden day-6 Karin-found-alive continuation does not end on `0x2bd1`. When the `0x1e3` response-2 path reaches `0x2bd1` with `KARIN_FOUND_ALIVE` set and the local follow-up still clear, Mr. Potts opens zero-based `dialog.rsp` line `0x1e4`: response `1` continues through `0x2be9` / `0x2bf2` / `0x2bf7` / `0x2bfe`, sets `get_set_discussed_mr_potts_tuesday_night_alibi`, then branches through `0x1e5` or `0x1e6` based on `get_set_moynahan_karin_kidnaped_discussion_state`, while response `2` exits on `0x2c2c`.
+    - On that nested Mr. Potts day-6 follow-up, zero-based `dialog.rsp[0x1e5]` / `dialog.rsp[0x1e6]` choice `1` plays `0x2c15` when the Moynahan Karin-kidnaped discussion bit is already set or `0x2c1a` / `0x2c20` when it is not; choice `2` instead plays `0x2c24`.
     - The hidden keyword loop now has confirmed branches for `AMNESIA`, `MARRIAGE`, `COMMITMENT`, `FATHER`, `SLAUGHTERHOUSE`, `MEAT`, `STEPHANIE`, `LODGE`, `NEWSPAPER`, `MOYNAHAN`, the peephole aliases `0x204..0x20a`, the murder aliases `0x20c..0x20e`, `TOILET`, the molest/pervert aliases `0x216..0x218`, the later `BYE` topic `0x21c`, and the generic fallback line `0x2c73`.
   - `handle_talk_to_pta_mom @ 0x34e30` is now constrained to a compact one-screen exchange.
     - If `PTA_RESPOND_TO_TV` is set, it plays `0x3233` with `PTA_MOM1` head variant `2`, clears that flag, and returns without opening the response menu.
diff --git a/TRACKER.md b/TRACKER.md
index 47eb85fb14a..76c9b68a76b 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,10 @@
 
 ## Last Confirmed Action
 
+- On March 21, 2026, re-opened native `handle_talk_to_mr_potts @ 0x26000` and `handle_talk_to_mrs_potts @ 0x2ee70` in the live `HARVEST.LE` Ghidra session to verify the Potts dialogue maps against the current ScummVM handlers.
+- Fixed the broken Ghidra function definitions first by deleting the one-instruction stub bodies and recreating both handlers with real code ranges before comparing any dialogue flow. Confirmed Mrs. Potts already matches the recovered native `0x28dc` / `0x28e8` / `0x28ee` / `0x28f2` / `0x28fb` / `0x2903` / `0x2907` plus keyword-tail map, then bounded the missing hidden Mr. Potts continuation after `DAY_6` line `0x2bd1`: when `KARIN_FOUND_ALIVE` is set and the local follow-up is still clear, zero-based `dialog.rsp` line `0x1e4` either runs `0x2be9` / `0x2bf2` / `0x2bf7` / `0x2bfe`, sets `get_set_discussed_mr_potts_tuesday_night_alibi`, and branches through zero-based `dialog.rsp` line `0x1e5` or `0x1e6` into `0x2c15` or `0x2c1a` / `0x2c20` / `0x2c24`, or else exits through `0x2c2c`. Patched `engines/harvester/npc/mr_potts_dialogue.{h,cpp}` so ScummVM now mirrors that native tail and reads the shared Moynahan Karin-kidnaped accusation bit from `DialogueSharedState` instead of the unused Mr.-Potts-local placeholder; saved `HARVEST.LE` after recreating both function bodies.
+- On March 21, 2026, traced the suspicious `WALL_PIC_T enabled=1 current=2` bathroom log back through the ScummVM runtime instead of treating it as another timer parser regression.
+- Confirmed the logged sequence is the expected same-room rebuild after interacting with `ST_WALPIC`, not proof that a cold `ST_BATHRM` entry starts with the timer armed. The shipped `HARVEST.SCR` room record for `ST_BATHRM` has an empty on-enter command and the base `TIMER "WALL_PIC_T"` record is disabled by default; `ANIM_WALLPIC` is the path that runs `SET_TIMER "WALL_PIC_T" "ON"`. In ScummVM, `SET_TIMER` mutates `_runtimeTimers`, `InteractionProcessor::handleInteractionResult` immediately calls `refreshCurrentScene(true)` on that runtime mutation, and that refresh path uses `materializeRoomState(...)` rather than `resolveRoomSetupState(...)`. That is why the debug excerpt starts on `materialized room timer ... enabled=1` with no preceding `resolveRoomSetupState(...)` line. The same runtime timer state is also serialized in save version `2`, so an armed timer can legitimately reappear after loading a save taken mid-countdown.
 - On March 21, 2026, re-opened native `handle_talk_to_stephanie @ 0x36710` together with the hidden Stephanie tails now labeled `stephanie_post_karin_kidnaped_tail @ 0x37650` and `stephanie_spyhole_keyword_continuation @ 0x37e78` in the live `HARVEST.LE` Ghidra session after the current ScummVM map still replayed spyhole/alibi lines on generic post-spyhole revisits.
 - Confirmed the native handler only reaches the `0x4970` / `0x4975` spyhole/alibi tail from the post-`KARIN_KIDNAPED` continuation. The standalone `0x2f9..0x2fd` spyhole keyword branch merely sets `g_stephanie_played_spyhole_branch`, optionally varies `0x4a19` vs `0x4a1e` based on the Mr. Potts confrontation bit, plays `0x4a23`, and loops back into the keyword menu. Fixed the Stephanie-local Ghidra state names (`g_stephanie_scratched_tucker_shown`, `g_stephanie_bolt_of_cloth_taken_shown`, `g_stephanie_barber_pole_stolen_shown`, `g_stephanie_burned_tv_station_shown`, `g_stephanie_diner_burned_shown`, `g_stephanie_diner_burned_karin_alive_followup_shown`, `g_stephanie_day_five_line_played`, `g_stephanie_pc_escaped_jail_shown`, `g_stephanie_karin_kidnaped_line_played`, and `g_stephanie_bolt_of_cloth_escalation_triggered`), saved `HARVEST.LE`, and patched `engines/harvester/npc/stephanie_dialogue.cpp` so ScummVM only replays the `0x4970` / `0x4975` plus optional `0x4980` / `0x4984` / `0x4989` / `0x498f` tail when the native `KARIN_KIDNAPED` branch has actually just fired.
 - On March 21, 2026, re-opened `run_harvester_main_loop @ 0x6dc70`, `find_timer_record_by_name @ 0x62e80`, `spawn_timer_entity_from_record @ 0x59390`, and `parse_timer_record @ 0x60160` in the live `HARVEST.LE` Ghidra session after a Pottsdam bathroom repro hit the unresolved timer tag `WALL_PIC_T` instead of starting the native peephole follow-up.
@@ -153,6 +157,8 @@
 
 ## Next Suggested Action
 
+- Immediate: run a live Harvester validation pass on Mr. and Mrs. Potts. On a `DAY_6` / `KARIN_FOUND_ALIVE` Mr. Potts visit, take the `0x1e3` response-2 path and confirm the newly mirrored `0x1e4` continuation appears once, sets up `0x2be9` / `0x2bf2` / `0x2bf7` / `0x2bfe`, and then reaches `0x2c15` or `0x2c1a` / `0x2c20` / `0x2c24` / `0x2c2c` exactly like the native branch; then re-check the murder accusation path to confirm the Moynahan accusation option now unlocks `0x2d80` plus response line `0x213`. Re-run Mrs. Potts with `KARIN_KIDNAPED` to confirm the unchanged `0x28e8` / `0x28ee` / `0x28f2` and `0x28fb` / `0x2903` / `0x2907` paths still match.
+- Immediate: run one true cold-entry `ST_BATHRM` repro with `general` debug enabled and no mid-room save/load. Confirm the first bathroom arrival logs `resolveRoomSetupState('ST_HALL2BATH')` and materializes `WALL_PIC_T` as disabled, then click `ST_WALPIC` once and confirm the later `materializeRoomState(...)`/`scene timer spawned ... enabled=1 current=2` pair only appears on the same-room rebuild after `SET_TIMER ON`.
 - Immediate: run a live Harvester validation pass on Stephanie's recovered no-item tails. Trigger the `0x2f9..0x2fd` spyhole keyword branch before `KARIN_KIDNAPED` and confirm it only returns to the keyword menu after `0x4a23`; then repeat after `KARIN_KIDNAPED`, first without and then with the Mr./Mrs. Potts Tuesday-night alibi bits set, to confirm ScummVM now gates `0x4970` / `0x4975` / `0x4980` / `0x4984` / `0x4989` / `0x498f` exactly like the native handler before the Karin outcome block exits through `0x49ea` / `0x49ef`.
 - Immediate: run a live Harvester validation pass in `ST_BATHRM`. Click `ST_WALPIC` before and after the quest-gate flags (`SCRATCHED_TUCKER`, `BOLT_OF_CLOTH_TAKEN`, `BARBER_POLE_STOLEN`, `DINER_BURNED`) and confirm the recovered timer path now waits about two seconds, plays `PEEPER01.FST` / `PEEPER02.FST` / `PEEPER4.FST` / `PEEPER5.FST` through `ST_BATHRM_WALL_PIC1`, sets `FOUND_PEEPHOLE`, and then disables/kills `WALL_PIC_T`.
 - Immediate: run a live Harvester validation pass on the native startup `SAVE GAME` and `LOAD GAME` screens. Confirm all labels now render as full `GAME 01` through `GAME 25` strings, including previously clipped rows such as `GAME 02`, `GAME 09`, `GAME 20`, and `GAME 25`.
diff --git a/engines/harvester/npc/mr_potts_dialogue.cpp b/engines/harvester/npc/mr_potts_dialogue.cpp
index d73f765d0ef..380f6e63646 100644
--- a/engines/harvester/npc/mr_potts_dialogue.cpp
+++ b/engines/harvester/npc/mr_potts_dialogue.cpp
@@ -90,6 +90,17 @@ static const DialogueLineEntry kMrPottsKarinFoundAliveWithStephMidgameLines[] =
 	{ 0x2ba4, "MR_POTTS", 0 }
 };
 
+static const DialogueLineEntry kMrPottsDay6KarinFoundAliveAlibiFollowupLines[] = {
+	{ 0x2bf2, "MRS_POTTS", 1 },
+	{ 0x2bf7, "MR_POTTS", 2 },
+	{ 0x2bfe, "MR_POTTS", 4 }
+};
+
+static const DialogueLineEntry kMrPottsDay6KarinFoundAliveNoMoynahanLines[] = {
+	{ 0x2c1a, "MR_POTTS", 0 },
+	{ 0x2c20, "MRS_POTTS", 0 }
+};
+
 static const DialogueLineEntry kMrPottsMurderAccusationOpeningLines[] = {
 	{ 0x2d58, "MR_POTTS", 2 },
 	{ 0x2d60, "MRS_POTTS", 1 },
@@ -174,6 +185,9 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	auto playMrPottsLine = [&](int wavId, int headVariant) -> Common::Error {
 		return runtime.playDialogueLineWithVariant(wavId, "MR_POTTS", headVariant);
 	};
+	auto playMrsPottsLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "MRS_POTTS", headVariant);
+	};
 	auto playPcLine = [&](int wavId, int headVariant) -> Common::Error {
 		return runtime.playDialogueLineWithVariant(wavId, "PC", headVariant);
 	};
@@ -474,6 +488,52 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			lineError = playMrPottsLine(0x2bd1, 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+
+			if (runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE") &&
+					!state.day6KarinFoundAliveFollowupShown) {
+				state.day6KarinFoundAliveFollowupShown = true;
+
+				int followupResponseIndex = 0;
+				responseError = runtime.runResponseMenu(0x1e4, followupResponseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+
+				if (followupResponseIndex == 1) {
+					lineError = playMrPottsLine(0x2be9, 2);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+
+					sharedState.discussedMrPottsTuesdayNightAlibi = true;
+					lineError = playSequence(kMrPottsDay6KarinFoundAliveAlibiFollowupLines,
+						ARRAYSIZE(kMrPottsDay6KarinFoundAliveAlibiFollowupLines));
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+
+					const bool discussedMoynahanKarinKidnaped =
+						sharedState.moynahanKarinKidnapedDiscussionState;
+					int nestedResponseIndex = 0;
+					responseError = runtime.runResponseMenu(
+						discussedMoynahanKarinKidnaped ? 0x1e5 : 0x1e6,
+						nestedResponseIndex);
+					if (responseError.getCode() != Common::kNoError)
+						return responseError;
+
+					if (nestedResponseIndex == 1) {
+						lineError = discussedMoynahanKarinKidnaped
+							? playMrPottsLine(0x2c15, 2)
+							: playSequence(kMrPottsDay6KarinFoundAliveNoMoynahanLines,
+								ARRAYSIZE(kMrPottsDay6KarinFoundAliveNoMoynahanLines));
+					} else if (nestedResponseIndex == 2) {
+						lineError = playMrsPottsLine(0x2c24, 2);
+					}
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				} else if (followupResponseIndex == 2) {
+					lineError = playMrPottsLine(0x2c2c, 0);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				}
+			}
 		}
 	}
 
@@ -702,7 +762,7 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
-			const bool hasMoynahanAccusation = state.discussedMoynahanKarinKidnapedState;
+			const bool hasMoynahanAccusation = sharedState.moynahanKarinKidnapedDiscussionState;
 			const bool hasWeddingMatchesAccusation = hasInventoryItem(kWeddingMatchesObjectName);
 			if (!hasMoynahanAccusation && !hasWeddingMatchesAccusation) {
 				lineError = playPcLine(0x2e46, 2);
diff --git a/engines/harvester/npc/mr_potts_dialogue.h b/engines/harvester/npc/mr_potts_dialogue.h
index 6b390b90084..8032e72c9af 100644
--- a/engines/harvester/npc/mr_potts_dialogue.h
+++ b/engines/harvester/npc/mr_potts_dialogue.h
@@ -47,7 +47,7 @@ public:
 		bool butcherDeathShown = false;
 		bool moynahanDeathShown = false;
 		bool ednaHungShown = false;
-		bool discussedMoynahanKarinKidnapedState = false;
+		bool day6KarinFoundAliveFollowupShown = false;
 	};
 
 	bool matchesNpc(const Common::String &npcName) const override;


Commit: f84f414a2edefb4e642e506561297eeea84281dd
    https://github.com/scummvm/scummvm/commit/f84f414a2edefb4e642e506561297eeea84281dd
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:46+02:00

Commit Message:
HARVESTER: Fix Stephanie peephole exit

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/stephanie_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ff8676322b3..c2b78d96750 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -549,7 +549,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - Stephanie's no-item opener is a three-stage day-gated ladder, not a simple intro/revisit split. The first visit seeds zero-based `dialog.rsp` line `0x2cf`; same-day revisits switch to `0x2d0`; later days run the first follow-up (`0x45a7` through `0x45e2` with zero-based `dialog.rsp` line `0x2d1`), then the second follow-up (`0x45e8` through `0x464e` with zero-based `dialog.rsp` lines `0x2d3` and `0x2d5`) before collapsing to the steady `0x4654` revisit line.
     - The late-game no-item tail is a strict one-shot chain: day 5 -> `0x46aa`..`0x46c9`; `BURNED_TV_STATION` -> response menu `0x2d7`; `SCRATCHED_TUCKER` -> response menus `0x2d9` / `0x2da`; `BOLT_OF_CLOTH_TAKEN` -> response menu `0x2db`; `BARBER_POLE_STOLEN` -> response menu `0x2dd`; `DINER_BURNED` -> response menus `0x2df` / `0x2e0` / `0x2e1`; `PC_ESCAPED_JAIL` -> response menu `0x2e3`; `KARIN_KIDNAPED` -> `0x4957`..`0x4969`; and the Karin outcome block starts on `0x4996`, can insert `0x49c1` when Dwayne's completed Karin-alive follow-up bit is set, and always exits through `0x49ea` / `0x49ef`.
     - The corrected raw-code tails show two hidden Stephanie continuations outside the current clean decompiler body.
-      - The `0x2f9..0x2fd` spyhole keyword branch does not return on `0x4a12`; once the active keyword buffer has advanced to zero-based `dialog.rsp[0x2f8]`, that hidden continuation owns the later `BYE` text, sets the local spyhole flag, continues through `0x4a19` or `0x4a1e` depending on whether Mr. Potts has already been confronted about the spyhole, then plays `0x4a23` and loops back into the keyword menu. The earlier zero-based `dialog.rsp[0x2e4]` `BYE` remains the direct `0x46a4` exit for the pre-spyhole states.
+      - The `0x2f9..0x2fd` spyhole keyword branch does not return on `0x4a12`; once the active keyword buffer has advanced to zero-based `dialog.rsp[0x2f8]`, that hidden continuation is only reached from the explicit `0x2f9..0x2fd` topic matches, sets the local spyhole flag, continues through `0x4a19` or `0x4a1e` depending on whether Mr. Potts has already been confronted about the spyhole, then plays `0x4a23` and loops back into the keyword menu. The generic zero-based `dialog.rsp[0x2e4]` `BYE` still exits through `0x46a4`, including after the spyhole buffer has been seeded.
       - The post-`KARIN_KIDNAPED` tail reuses that local spyhole flag to prepend `0x4970` / `0x4975`, optionally adds Mr. and Mrs. Potts's Tuesday-night alibi follow-ups (`0x4980` and `0x4984` / `0x4989` / `0x498f`), and only then checks the Karin-alive / Karin-dead payoff block.
   - `handle_talk_to_wasp_woman @ 0x2fde0` is now bounded enough for a direct startup-room port.
     - The hidden no-item intro tail is larger than the clean decompiler body shows. The first visit clears the talk-state gate, plays `0x4bee`, optionally inserts the `0x4bf2` PC line when `get_set_wasp_woman_dialogue_state_d2f10` is set, continues through `0x4bf6` / `0x4bfc`, then adds the recovered `0x4c00` line before seeding the live keyword buffer with zero-based `dialog.rsp[0x300]`.
diff --git a/TRACKER.md b/TRACKER.md
index 76c9b68a76b..ae0bc3e36af 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 21, 2026, re-opened the live `handle_talk_to_stephanie @ 0x36710` keyword loop in `HARVEST.LE` after a fresh ScummVM repro showed the post-peephole Stephanie conversation replaying `0x4a05` / `0x4a0a` / `0x4a0e` / `0x4a12` / `0x4a1e` / `0x4a23` every time `BYE` was selected, trapping the player in conversation mode.
+- Confirmed the native Stephanie spyhole continuation is only entered from explicit zero-based `dialog.rsp[0x2f9]..[0x2fd]` topic matches after `0x2f8` seeds the later keyword buffer. The generic `dialog.rsp[0x2e4]` `BYE` path still exits through `0x46a4` even after the spyhole buffer is active, so ScummVM's extra `inSpyholeKeywordState && BYE` hook was incorrect. Patched `engines/harvester/npc/stephanie_dialogue.cpp` to route generic `BYE` back through the normal exit path and left the hidden spyhole branch gated only on the confirmed `0x2f9..0x2fd` topics.
 - On March 21, 2026, re-opened native `handle_talk_to_mr_potts @ 0x26000` and `handle_talk_to_mrs_potts @ 0x2ee70` in the live `HARVEST.LE` Ghidra session to verify the Potts dialogue maps against the current ScummVM handlers.
 - Fixed the broken Ghidra function definitions first by deleting the one-instruction stub bodies and recreating both handlers with real code ranges before comparing any dialogue flow. Confirmed Mrs. Potts already matches the recovered native `0x28dc` / `0x28e8` / `0x28ee` / `0x28f2` / `0x28fb` / `0x2903` / `0x2907` plus keyword-tail map, then bounded the missing hidden Mr. Potts continuation after `DAY_6` line `0x2bd1`: when `KARIN_FOUND_ALIVE` is set and the local follow-up is still clear, zero-based `dialog.rsp` line `0x1e4` either runs `0x2be9` / `0x2bf2` / `0x2bf7` / `0x2bfe`, sets `get_set_discussed_mr_potts_tuesday_night_alibi`, and branches through zero-based `dialog.rsp` line `0x1e5` or `0x1e6` into `0x2c15` or `0x2c1a` / `0x2c20` / `0x2c24`, or else exits through `0x2c2c`. Patched `engines/harvester/npc/mr_potts_dialogue.{h,cpp}` so ScummVM now mirrors that native tail and reads the shared Moynahan Karin-kidnaped accusation bit from `DialogueSharedState` instead of the unused Mr.-Potts-local placeholder; saved `HARVEST.LE` after recreating both function bodies.
 - On March 21, 2026, traced the suspicious `WALL_PIC_T enabled=1 current=2` bathroom log back through the ScummVM runtime instead of treating it as another timer parser regression.
@@ -157,6 +159,7 @@
 
 ## Next Suggested Action
 
+- Immediate: rerun the Stephanie peephole repro. Reach the post-peephole conversation state, select `BYE`, and confirm ScummVM now exits on `0x46a4` instead of replaying `0x4a05` / `0x4a0a` / `0x4a0e` / `0x4a12` / `0x4a19|0x4a1e` / `0x4a23`; then re-enter Stephanie and verify the hidden spyhole branch still triggers only from the explicit `0x2f9..0x2fd` keyword topics.
 - Immediate: run a live Harvester validation pass on Mr. and Mrs. Potts. On a `DAY_6` / `KARIN_FOUND_ALIVE` Mr. Potts visit, take the `0x1e3` response-2 path and confirm the newly mirrored `0x1e4` continuation appears once, sets up `0x2be9` / `0x2bf2` / `0x2bf7` / `0x2bfe`, and then reaches `0x2c15` or `0x2c1a` / `0x2c20` / `0x2c24` / `0x2c2c` exactly like the native branch; then re-check the murder accusation path to confirm the Moynahan accusation option now unlocks `0x2d80` plus response line `0x213`. Re-run Mrs. Potts with `KARIN_KIDNAPED` to confirm the unchanged `0x28e8` / `0x28ee` / `0x28f2` and `0x28fb` / `0x2903` / `0x2907` paths still match.
 - Immediate: run one true cold-entry `ST_BATHRM` repro with `general` debug enabled and no mid-room save/load. Confirm the first bathroom arrival logs `resolveRoomSetupState('ST_HALL2BATH')` and materializes `WALL_PIC_T` as disabled, then click `ST_WALPIC` once and confirm the later `materializeRoomState(...)`/`scene timer spawned ... enabled=1 current=2` pair only appears on the same-room rebuild after `SET_TIMER ON`.
 - Immediate: run a live Harvester validation pass on Stephanie's recovered no-item tails. Trigger the `0x2f9..0x2fd` spyhole keyword branch before `KARIN_KIDNAPED` and confirm it only returns to the keyword menu after `0x4a23`; then repeat after `KARIN_KIDNAPED`, first without and then with the Mr./Mrs. Potts Tuesday-night alibi bits set, to confirm ScummVM now gates `0x4970` / `0x4975` / `0x4980` / `0x4984` / `0x4989` / `0x498f` exactly like the native handler before the Karin outcome block exits through `0x49ea` / `0x49ef`.
diff --git a/engines/harvester/npc/stephanie_dialogue.cpp b/engines/harvester/npc/stephanie_dialogue.cpp
index 2ebb1df71f6..16f2f0cafaa 100644
--- a/engines/harvester/npc/stephanie_dialogue.cpp
+++ b/engines/harvester/npc/stephanie_dialogue.cpp
@@ -664,7 +664,6 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return menuError;
 		if (selectedTopic.empty())
 			return Common::kNoError;
-		const bool inSpyholeKeywordState = stephanieTopicBufferLineIndex == 0x2f8;
 		if (runtime.matchesResponseLine(selectedTopic, 0x2e5) ||
 				runtime.matchesResponseLine(selectedTopic, 0x2e6)) {
 			Common::Error lineError = playStephanieLine(0x4406);
@@ -848,9 +847,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			assignStephanieTopicBuffer(0x2f8);
 			continue;
 		}
-		if ((inSpyholeKeywordState &&
-				selectedTopic.equalsIgnoreCase(runtime.genericByeTopic())) ||
-				runtime.matchesResponseLine(selectedTopic, 0x2f9) ||
+		if (runtime.matchesResponseLine(selectedTopic, 0x2f9) ||
 				runtime.matchesResponseLine(selectedTopic, 0x2fa) ||
 				runtime.matchesResponseLine(selectedTopic, 0x2fb) ||
 				runtime.matchesResponseLine(selectedTopic, 0x2fc) ||
@@ -877,8 +874,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				return lineError;
 			continue;
 		}
-		if ((!inSpyholeKeywordState &&
-				selectedTopic.equalsIgnoreCase(runtime.genericByeTopic())) ||
+		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
 				runtime.matchesResponseLine(selectedTopic, 0x2e4)) {
 			return playStephanieLine(0x46a4);
 		}


Commit: 73fbf8ad989ad27b83a05b233b114dbe2007a1de
    https://github.com/scummvm/scummvm/commit/73fbf8ad989ad27b83a05b233b114dbe2007a1de
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:46+02:00

Commit Message:
HARVESTER: Fix Stephanie kidnap tail

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/stephanie_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index c2b78d96750..09d0728b1f9 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -550,7 +550,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - The late-game no-item tail is a strict one-shot chain: day 5 -> `0x46aa`..`0x46c9`; `BURNED_TV_STATION` -> response menu `0x2d7`; `SCRATCHED_TUCKER` -> response menus `0x2d9` / `0x2da`; `BOLT_OF_CLOTH_TAKEN` -> response menu `0x2db`; `BARBER_POLE_STOLEN` -> response menu `0x2dd`; `DINER_BURNED` -> response menus `0x2df` / `0x2e0` / `0x2e1`; `PC_ESCAPED_JAIL` -> response menu `0x2e3`; `KARIN_KIDNAPED` -> `0x4957`..`0x4969`; and the Karin outcome block starts on `0x4996`, can insert `0x49c1` when Dwayne's completed Karin-alive follow-up bit is set, and always exits through `0x49ea` / `0x49ef`.
     - The corrected raw-code tails show two hidden Stephanie continuations outside the current clean decompiler body.
       - The `0x2f9..0x2fd` spyhole keyword branch does not return on `0x4a12`; once the active keyword buffer has advanced to zero-based `dialog.rsp[0x2f8]`, that hidden continuation is only reached from the explicit `0x2f9..0x2fd` topic matches, sets the local spyhole flag, continues through `0x4a19` or `0x4a1e` depending on whether Mr. Potts has already been confronted about the spyhole, then plays `0x4a23` and loops back into the keyword menu. The generic zero-based `dialog.rsp[0x2e4]` `BYE` still exits through `0x46a4`, including after the spyhole buffer has been seeded.
-      - The post-`KARIN_KIDNAPED` tail reuses that local spyhole flag to prepend `0x4970` / `0x4975`, optionally adds Mr. and Mrs. Potts's Tuesday-night alibi follow-ups (`0x4980` and `0x4984` / `0x4989` / `0x498f`), and only then checks the Karin-alive / Karin-dead payoff block.
+      - The post-`KARIN_KIDNAPED` tail always continues through `0x4975` once the kidnap branch has fired. If the local spyhole flag is already set it first prepends `0x4970`, then optionally adds Mr. and Mrs. Potts's Tuesday-night alibi follow-ups (`0x4980` and `0x4984` / `0x4989` / `0x498f`) before checking the Karin-alive / Karin-dead payoff block.
   - `handle_talk_to_wasp_woman @ 0x2fde0` is now bounded enough for a direct startup-room port.
     - The hidden no-item intro tail is larger than the clean decompiler body shows. The first visit clears the talk-state gate, plays `0x4bee`, optionally inserts the `0x4bf2` PC line when `get_set_wasp_woman_dialogue_state_d2f10` is set, continues through `0x4bf6` / `0x4bfc`, then adds the recovered `0x4c00` line before seeding the live keyword buffer with zero-based `dialog.rsp[0x300]`.
     - Wasp Woman's keyword state persists across revisits. Later no-item visits only replay `0x4bee`; the live keyword buffer is whatever the prior branch last wrote, and the raw hidden tail now confirms the concrete rewrites: `0x302` expands into `0x4c0a` / `0x4c13` / `0x4c17`, plays `C027A.FST`, continues through `0x4c25` / `0x4c29`, then rewrites the buffer to zero-based `dialog.rsp[0x304]`; `0x308` rewrites to `0x309`; and `0x30a` plays `0x4c74` / `0x4c7f` before rewriting to `0x30b`.
diff --git a/TRACKER.md b/TRACKER.md
index ae0bc3e36af..a11e22a50db 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 21, 2026, re-opened `handle_talk_to_stephanie @ 0x36710` in the live `HARVEST.LE` Ghidra session after another ScummVM peephole/BYE report and found the function body had regressed to a one-instruction stub again.
+- Recreated the full Stephanie body (`0x36710-0x37f5d`) before re-checking the late keyword loop with clean disassembly and raw byte reads. Confirmed the generic `dialog.rsp[0x2e4]` `BYE` still exits through `0x46a4`, the hidden spyhole continuation is still limited to explicit `0x2f9..0x2fd` topic matches, and the post-`KARIN_KIDNAPED` tail always plays `0x4975` while only prepending `0x4970` when `g_stephanie_played_spyhole_branch` is already set. Patched `engines/harvester/npc/stephanie_dialogue.cpp` so ScummVM now mirrors that native kidnap tail instead of wrongly gating the whole `0x4975` / `0x4980` / `0x4984` / `0x4989` / `0x498f` sequence on the spyhole flag.
 - On March 21, 2026, re-opened the live `handle_talk_to_stephanie @ 0x36710` keyword loop in `HARVEST.LE` after a fresh ScummVM repro showed the post-peephole Stephanie conversation replaying `0x4a05` / `0x4a0a` / `0x4a0e` / `0x4a12` / `0x4a1e` / `0x4a23` every time `BYE` was selected, trapping the player in conversation mode.
 - Confirmed the native Stephanie spyhole continuation is only entered from explicit zero-based `dialog.rsp[0x2f9]..[0x2fd]` topic matches after `0x2f8` seeds the later keyword buffer. The generic `dialog.rsp[0x2e4]` `BYE` path still exits through `0x46a4` even after the spyhole buffer is active, so ScummVM's extra `inSpyholeKeywordState && BYE` hook was incorrect. Patched `engines/harvester/npc/stephanie_dialogue.cpp` to route generic `BYE` back through the normal exit path and left the hidden spyhole branch gated only on the confirmed `0x2f9..0x2fd` topics.
 - On March 21, 2026, re-opened native `handle_talk_to_mr_potts @ 0x26000` and `handle_talk_to_mrs_potts @ 0x2ee70` in the live `HARVEST.LE` Ghidra session to verify the Potts dialogue maps against the current ScummVM handlers.
@@ -159,6 +161,7 @@
 
 ## Next Suggested Action
 
+- Immediate: rerun the Stephanie sequence from a clean pre-peephole save. First trigger the `0x2f9..0x2fd` spyhole topic and confirm `BYE` now exits on `0x46a4` without replaying `0x4a05`..`0x4a23`; then advance to `KARIN_KIDNAPED` and confirm the post-kidnap tail now always includes `0x4975`, only prepends `0x4970` when the spyhole flag was set earlier, and still conditionally adds `0x4980` / `0x4984` / `0x4989` / `0x498f` from the shared Potts alibi bits.
 - Immediate: rerun the Stephanie peephole repro. Reach the post-peephole conversation state, select `BYE`, and confirm ScummVM now exits on `0x46a4` instead of replaying `0x4a05` / `0x4a0a` / `0x4a0e` / `0x4a12` / `0x4a19|0x4a1e` / `0x4a23`; then re-enter Stephanie and verify the hidden spyhole branch still triggers only from the explicit `0x2f9..0x2fd` keyword topics.
 - Immediate: run a live Harvester validation pass on Mr. and Mrs. Potts. On a `DAY_6` / `KARIN_FOUND_ALIVE` Mr. Potts visit, take the `0x1e3` response-2 path and confirm the newly mirrored `0x1e4` continuation appears once, sets up `0x2be9` / `0x2bf2` / `0x2bf7` / `0x2bfe`, and then reaches `0x2c15` or `0x2c1a` / `0x2c20` / `0x2c24` / `0x2c2c` exactly like the native branch; then re-check the murder accusation path to confirm the Moynahan accusation option now unlocks `0x2d80` plus response line `0x213`. Re-run Mrs. Potts with `KARIN_KIDNAPED` to confirm the unchanged `0x28e8` / `0x28ee` / `0x28f2` and `0x28fb` / `0x2903` / `0x2907` paths still match.
 - Immediate: run one true cold-entry `ST_BATHRM` repro with `general` debug enabled and no mid-room save/load. Confirm the first bathroom arrival logs `resolveRoomSetupState('ST_HALL2BATH')` and materializes `WALL_PIC_T` as disabled, then click `ST_WALPIC` once and confirm the later `materializeRoomState(...)`/`scene timer spawned ... enabled=1 current=2` pair only appears on the same-room rebuild after `SET_TIMER ON`.
diff --git a/engines/harvester/npc/stephanie_dialogue.cpp b/engines/harvester/npc/stephanie_dialogue.cpp
index 16f2f0cafaa..5e982977bf4 100644
--- a/engines/harvester/npc/stephanie_dialogue.cpp
+++ b/engines/harvester/npc/stephanie_dialogue.cpp
@@ -588,11 +588,15 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
-	// Native only reaches the spyhole/alibi tail from the post-kidnap continuation.
-	if (ranKarinKidnapedBranch && state.playedSpyholeBranch) {
-		Common::Error lineError = runtime.playDialogueLine(0x4970, "STEPHANIE");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
+	// Native only reaches the kidnap tail from the post-kidnap continuation, but 0x4975
+	// always fires once that branch runs; the spyhole flag only prepends 0x4970.
+	if (ranKarinKidnapedBranch) {
+		Common::Error lineError = Common::kNoError;
+		if (state.playedSpyholeBranch) {
+			lineError = runtime.playDialogueLine(0x4970, "STEPHANIE");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
 		lineError = runtime.playDialogueLine(0x4975, "STEPHANIE");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;


Commit: e97fbeb3e6e617dd643cd1a9d9dda30281749cbd
    https://github.com/scummvm/scummvm/commit/e97fbeb3e6e617dd643cd1a9d9dda30281749cbd
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:47+02:00

Commit Message:
HARVESTER: Restore native room combat retaliation

Changed paths:
    TRACKER.md
    engines/harvester/player.cpp
    engines/harvester/player.h
    engines/harvester/room.cpp
    engines/harvester/room_support.h
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index a11e22a50db..d38642f39ce 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 21, 2026, re-opened native room-combat flow around `run_harvester_main_loop @ 0x6dc70`, `update_actor_runtime_state @ 0x4d750`, `queue_npc_death_or_monsterfy_transition @ 0x53810`, `spawn_monster_entity_from_record @ 0x53a10`, `tick_monster_entity_runtime @ 0x54140`, and `set_player_combat_loadout @ 0x55c10` in the live `HARVEST.LE` Ghidra session after the current ScummVM Harvester room loop still played the recovered right-click attack banks without ever resolving NPC retaliation or monster combat.
+- Confirmed the native close-range NPC hit path shares the same `queue_npc_death_or_monsterfy_transition` helper used by script `KILL_NPC` / `MONSTERFY`, so attacking an NPC can either latch its death state or force the `monsterfy_target_name` replacement monster. Also confirmed player and monster attack contact both use the recovered per-loadout / per-monster `+0x113c` frame offset, with monsters defaulting to contact frame `+2` and `MUCKEY` overriding that offset to `+7`, while live class-6 monster pursuit and retaliation stay in the `tick_monster_entity_runtime` / `update_actor_runtime_state` path keyed by `engage_distance`, `damage_amount`, and the parsed attack-sound trigger frame. Patched `engines/harvester/player.cpp`, `engines/harvester/script.{h,cpp}`, `engines/harvester/room.cpp`, and `engines/harvester/room_support.h` so room attacks now capture a real NPC/monster target, apply native-style NPC death-or-monsterfy bookkeeping on the confirmed contact frame, set `JIMMY_ATTACKED` when Jimmy is attacked, run a minimal live monster pursuit/attack loop against the player, and subtract/player-sync monster HP instead of leaving combat as pure animation. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 - On March 21, 2026, re-opened `handle_talk_to_stephanie @ 0x36710` in the live `HARVEST.LE` Ghidra session after another ScummVM peephole/BYE report and found the function body had regressed to a one-instruction stub again.
 - Recreated the full Stephanie body (`0x36710-0x37f5d`) before re-checking the late keyword loop with clean disassembly and raw byte reads. Confirmed the generic `dialog.rsp[0x2e4]` `BYE` still exits through `0x46a4`, the hidden spyhole continuation is still limited to explicit `0x2f9..0x2fd` topic matches, and the post-`KARIN_KIDNAPED` tail always plays `0x4975` while only prepending `0x4970` when `g_stephanie_played_spyhole_branch` is already set. Patched `engines/harvester/npc/stephanie_dialogue.cpp` so ScummVM now mirrors that native kidnap tail instead of wrongly gating the whole `0x4975` / `0x4980` / `0x4984` / `0x4989` / `0x498f` sequence on the spyhole flag.
 - On March 21, 2026, re-opened the live `handle_talk_to_stephanie @ 0x36710` keyword loop in `HARVEST.LE` after a fresh ScummVM repro showed the post-peephole Stephanie conversation replaying `0x4a05` / `0x4a0a` / `0x4a0e` / `0x4a12` / `0x4a1e` / `0x4a23` every time `BYE` was selected, trapping the player in conversation mode.
@@ -161,6 +163,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run a live Harvester validation pass in one room with an NPC that has `monsterfy_target_name` plus one plain monster room. Confirm that right-clicking the NPC now latches the native death/monsterfy transition on the recovered contact frame, spawns the replacement monster when applicable, sets `JIMMY_ATTACKED` if Jimmy is the victim, and that the spawned/active monster now closes to `engage_distance`, plays its attack bank, and removes player HP on contact instead of remaining a passive walk-cycle prop.
 - Immediate: rerun the Stephanie sequence from a clean pre-peephole save. First trigger the `0x2f9..0x2fd` spyhole topic and confirm `BYE` now exits on `0x46a4` without replaying `0x4a05`..`0x4a23`; then advance to `KARIN_KIDNAPED` and confirm the post-kidnap tail now always includes `0x4975`, only prepends `0x4970` when the spyhole flag was set earlier, and still conditionally adds `0x4980` / `0x4984` / `0x4989` / `0x498f` from the shared Potts alibi bits.
 - Immediate: rerun the Stephanie peephole repro. Reach the post-peephole conversation state, select `BYE`, and confirm ScummVM now exits on `0x46a4` instead of replaying `0x4a05` / `0x4a0a` / `0x4a0e` / `0x4a12` / `0x4a19|0x4a1e` / `0x4a23`; then re-enter Stephanie and verify the hidden spyhole branch still triggers only from the explicit `0x2f9..0x2fd` keyword topics.
 - Immediate: run a live Harvester validation pass on Mr. and Mrs. Potts. On a `DAY_6` / `KARIN_FOUND_ALIVE` Mr. Potts visit, take the `0x1e3` response-2 path and confirm the newly mirrored `0x1e4` continuation appears once, sets up `0x2be9` / `0x2bf2` / `0x2bf7` / `0x2bfe`, and then reaches `0x2c15` or `0x2c1a` / `0x2c20` / `0x2c24` / `0x2c2c` exactly like the native branch; then re-check the murder accusation path to confirm the Moynahan accusation option now unlocks `0x2d80` plus response line `0x213`. Re-run Mrs. Potts with `KARIN_KIDNAPED` to confirm the unchanged `0x28e8` / `0x28ee` / `0x28f2` and `0x28fb` / `0x2903` / `0x2907` paths still match.
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 3f2e8fb3c11..6d0499bb271 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -90,6 +90,12 @@ struct PlayerAttackSoundSet {
 	const char *soundPaths[3];
 };
 
+struct PlayerCombatLoadoutTuning {
+	int damageType;
+	int damageAmount;
+	int contactFrameOffset;
+};
+
 // Recovered from spawn_player_combat_avatar and update_actor_runtime_state:
 // the native room-combat path commits player attack audio on first_frame + 1.
 static const int kPlayerAttackSoundTriggerFrameOffset = 1;
@@ -117,6 +123,30 @@ static const PlayerAttackSoundSet kPlayerAttackSoundSets[] = {
 	{ 3, { "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } }
 };
 
+static const PlayerCombatLoadoutTuning kPlayerCombatLoadoutTunings[] = {
+	{ 1, 1, 5 },
+	{ 2, 4, 2 },
+	{ 4, 2, 2 },
+	{ 4, 7, 2 },
+	{ 4, 6, 2 },
+	{ 4, 6, 2 },
+	{ 2, 3, 2 },
+	{ 2, 3, 2 },
+	{ 2, 3, 2 },
+	{ 2, 3, 2 },
+	{ 1, 3, 2 },
+	{ 2, 3, 2 },
+	{ 2, 4, 2 },
+	{ 2, 5, 2 },
+	{ 2, 4, 2 },
+	{ 2, 8, 2 },
+	{ 1, 3, 2 },
+	{ 2, 3, 2 },
+	{ 1, 3, 2 },
+	{ 2, 3, 2 },
+	{ 1, 2, 2 }
+};
+
 static int roundToInt(float value) {
 	return value >= 0.0f ? (int)floorf(value + 0.5f) : (int)ceilf(value - 0.5f);
 }
@@ -184,6 +214,15 @@ static const PlayerAttackSoundSet *resolvePlayerAttackSoundSet(int loadout) {
 	return &kPlayerAttackSoundSets[loadout];
 }
 
+static const PlayerCombatLoadoutTuning &resolvePlayerCombatLoadoutTuning(int loadout) {
+	static const PlayerCombatLoadoutTuning kDefaultTuning = { 1, 1, 2 };
+
+	if (loadout < 0 || loadout >= (int)ARRAYSIZE(kPlayerCombatLoadoutTunings))
+		return kDefaultTuning;
+
+	return kPlayerCombatLoadoutTunings[loadout];
+}
+
 static bool playPlayerAttackSound(HarvesterEngine &engine, int loadout) {
 	const PlayerAttackSoundSet *soundSet = resolvePlayerAttackSoundSet(loadout);
 	if (!soundSet)
@@ -544,6 +583,22 @@ Common::String Player::resolveCombatLoadoutResourcePath(int loadout) {
 	return Common::String::format("1:/GRAPHIC/MONSTERS/PC/PC%02d.ABM", loadout);
 }
 
+int Player::resolveCombatLoadoutDamageAmount(int loadout) {
+	return resolvePlayerCombatLoadoutTuning(loadout).damageAmount;
+}
+
+int Player::resolveCombatLoadoutDamageType(int loadout) {
+	return resolvePlayerCombatLoadoutTuning(loadout).damageType;
+}
+
+int Player::resolveCombatLoadoutContactFrameOffset(int loadout) {
+	return resolvePlayerCombatLoadoutTuning(loadout).contactFrameOffset;
+}
+
+bool Player::isProjectileCombatLoadout(int loadout) {
+	return resolveCombatLoadoutDamageType(loadout) == 4;
+}
+
 bool Player::supportsMovementBand(const StartupRoomSetupState &state) {
 	return state.roomMaxZScreenY >= 0 &&
 		state.roomMinZScreenY >= state.roomMaxZScreenY;
@@ -634,9 +689,13 @@ bool Player::syncCombatLoadoutVisual(HarvesterEngine &engine, const StartupRoomS
 	playerState.attackActive = false;
 	playerState.attackFirstFrame = -1;
 	playerState.attackLastFrame = -1;
+	playerState.attackContactFrame = -1;
 	playerState.attackResumeFacing = -1;
 	playerState.attackSoundPlayed = false;
 	playerState.attackSoundFrame = -1;
+	playerState.attackContactResolved = false;
+	playerState.attackTargetName.clear();
+	playerState.attackTargetClassId = -1;
 	(void)setIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
 	(void)applyRoomActorPlacement(state, *playerState.entity,
 		playerState.centerX, playerState.bottomY, playerState.z);
@@ -687,11 +746,14 @@ bool Player::startAttackAnimation(const StartupRoomSetupState &state,
 	playerState.attackActive = true;
 	playerState.attackFirstFrame = range.firstFrame;
 	playerState.attackLastFrame = range.lastFrame;
+	playerState.attackContactFrame =
+		range.firstFrame + resolveCombatLoadoutContactFrameOffset(playerState.combatLoadout);
 	playerState.attackResumeFacing = range.resumeFacing;
 	playerState.attackSoundPlayed = resolvePlayerAttackSoundSet(playerState.combatLoadout) == nullptr;
 	playerState.attackSoundFrame = playerState.attackSoundPlayed
 		? -1
 		: (range.firstFrame + kPlayerAttackSoundTriggerFrameOffset);
+	playerState.attackContactResolved = false;
 	playerState.nextMovementTick = 0;
 	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
 	playerState.entity->setAnimationRate(kRoomPlayerAttackAnimationRate);
@@ -721,9 +783,13 @@ bool Player::updateAttackAnimationState(HarvesterEngine &engine,
 	playerState.attackActive = false;
 	playerState.attackFirstFrame = -1;
 	playerState.attackLastFrame = -1;
+	playerState.attackContactFrame = -1;
 	playerState.attackResumeFacing = -1;
 	playerState.attackSoundPlayed = false;
 	playerState.attackSoundFrame = -1;
+	playerState.attackContactResolved = false;
+	playerState.attackTargetName.clear();
+	playerState.attackTargetClassId = -1;
 	return setIdleAnimation(playerState, resumeFacing);
 }
 
diff --git a/engines/harvester/player.h b/engines/harvester/player.h
index eb56a25d193..a77be2ae690 100644
--- a/engines/harvester/player.h
+++ b/engines/harvester/player.h
@@ -31,6 +31,10 @@ public:
 	static float computeDepthScale(const StartupRoomSetupState &state, float z);
 	static int resolveFacingFrame(int facing);
 	static Common::String resolveCombatLoadoutResourcePath(int loadout);
+	static int resolveCombatLoadoutDamageAmount(int loadout);
+	static int resolveCombatLoadoutDamageType(int loadout);
+	static int resolveCombatLoadoutContactFrameOffset(int loadout);
+	static bool isProjectileCombatLoadout(int loadout);
 	static bool supportsMovementBand(const StartupRoomSetupState &state);
 	static uint32 getRuntimeClockTicks();
 	static bool isIdleAnimationExcludedRoom(const Common::String &roomName);
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index fb4621d01e5..d0a21da79c7 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -58,6 +58,42 @@ static const int kNativeInventoryTooltipY = 0x19e;
 static const int kNativeInventoryWeekdayX = 0x1e0;
 static const int kNativeInventoryWeekdayY = 0x18c;
 static const byte kNativeInventoryTooltipColor = 0xf4;
+static const int kRoomMonsterAnimationRate = 17;
+static const float kRoomActorAttackUpperYOffset = 144.44f;
+static const float kRoomActorAttackMidYOffset = 75.36f;
+static const int kDefaultMonsterAttackContactFrameOffset = 2;
+static const int kMuckeyAttackContactFrameOffset = 7;
+
+struct RoomMonsterFacingAnimationRange {
+	RoomMonsterFacingAnimationRange() {}
+	RoomMonsterFacingAnimationRange(int walkFirstFrame, int walkLastFrame, int idleFrame)
+		: walkFirstFrame(walkFirstFrame), walkLastFrame(walkLastFrame), idleFrame(idleFrame) {}
+
+	int walkFirstFrame = 0;
+	int walkLastFrame = 0;
+	int idleFrame = 0;
+};
+
+struct RoomAttackAnimationRange {
+	RoomAttackAnimationRange() {}
+	RoomAttackAnimationRange(int firstFrame, int lastFrame, int resumeFacing)
+		: firstFrame(firstFrame), lastFrame(lastFrame), resumeFacing(resumeFacing) {}
+
+	int firstFrame = 0;
+	int lastFrame = 0;
+	int resumeFacing = 0;
+};
+
+struct RoomMonsterCombatState {
+	bool attackActive = false;
+	int attackFirstFrame = -1;
+	int attackLastFrame = -1;
+	int attackContactFrame = -1;
+	int attackResumeFacing = -1;
+	bool attackSoundPlayed = false;
+	uint32 nextMovementTick = 0;
+	uint32 nextAttackAllowedTick = 0;
+};
 
 static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
 	return !roomName.equalsIgnoreCase("LAVAPIT") &&
@@ -243,6 +279,89 @@ static void setScaledRoomPalette(Graphics::Screen &screen, const byte *palette,
 	screen.setPalette(scaledPalette);
 }
 
+static RoomMonsterFacingAnimationRange resolveRoomMonsterFacingAnimationRange(int facing) {
+	switch (facing) {
+	case 0:
+		return RoomMonsterFacingAnimationRange(0x00, 0x09, 0x3b);
+	case 1:
+		return RoomMonsterFacingAnimationRange(0x0f, 0x18, 0x0e);
+	case 2:
+		return RoomMonsterFacingAnimationRange(0x2d, 0x36, 0x2c);
+	case 3:
+		return RoomMonsterFacingAnimationRange(0x1e, 0x27, 0x28);
+	default:
+		return RoomMonsterFacingAnimationRange(0x00, 0x09, 0x3b);
+	}
+}
+
+static bool setRoomMonsterAnimation(RuntimeEntity &entity, int facing, bool walking) {
+	const RoomMonsterFacingAnimationRange range = resolveRoomMonsterFacingAnimationRange(facing);
+	if (walking) {
+		const bool changed = entity.getAnimationRate() != kRoomMonsterAnimationRate ||
+			entity.getCurrentFrame() < range.walkFirstFrame ||
+			entity.getCurrentFrame() > range.walkLastFrame;
+		entity.setAnimationRate(kRoomMonsterAnimationRate);
+		entity.setAnimationFrameRange(range.walkFirstFrame, range.walkLastFrame, true);
+		entity.setAnimationEnabled(true);
+		return changed;
+	}
+
+	const bool changed = entity.getAnimationRate() != 0 ||
+		entity.getCurrentFrame() != range.idleFrame;
+	entity.setAnimationRate(0);
+	entity.setAnimationFrameRange(range.idleFrame, range.idleFrame, false);
+	entity.setCurrentFrame(range.idleFrame);
+	entity.setAnimationEnabled(false);
+	return changed;
+}
+
+static bool resolveRoomActorAttackAnimationRange(const StartupRoomSetupState &state,
+		int actorCenterX, int actorBottomY, float actorZ, const Common::Point &targetPoint,
+		RoomAttackAnimationRange &range) {
+	const float depthScale = Player::computeDepthScale(state, actorZ);
+	const float upperThresholdY =
+		(float)actorBottomY - depthScale * kRoomActorAttackUpperYOffset;
+	const float midThresholdY =
+		(float)actorBottomY - depthScale * kRoomActorAttackMidYOffset;
+
+	if (targetPoint.x < actorCenterX) {
+		if ((float)targetPoint.y < upperThresholdY)
+			range = RoomAttackAnimationRange(0x50, 0x59, 1);
+		else if ((float)targetPoint.y < midThresholdY)
+			range = RoomAttackAnimationRange(0x5a, 0x63, 1);
+		else
+			range = RoomAttackAnimationRange(0x64, 0x6d, 1);
+	} else {
+		if ((float)targetPoint.y < upperThresholdY)
+			range = RoomAttackAnimationRange(0x6e, 0x77, 2);
+		else if ((float)targetPoint.y < midThresholdY)
+			range = RoomAttackAnimationRange(0x78, 0x81, 2);
+		else
+			range = RoomAttackAnimationRange(0x82, 0x8b, 2);
+	}
+
+	return true;
+}
+
+static bool playRandomRoomAttackSound(HarvesterEngine &engine, const Common::String &sound1,
+		const Common::String &sound2, const Common::String &sound3) {
+	const Common::String *availableSounds[3];
+	uint availableCount = 0;
+	if (!sound1.empty())
+		availableSounds[availableCount++] = &sound1;
+	if (!sound2.empty())
+		availableSounds[availableCount++] = &sound2;
+	if (!sound3.empty())
+		availableSounds[availableCount++] = &sound3;
+	if (availableCount == 0)
+		return false;
+
+	const uint soundIndex = availableCount > 1
+		? engine.getRandomNumber(availableCount - 1)
+		: 0;
+	return engine.playStartupSound(*availableSounds[soundIndex]);
+}
+
 static float clampRoomDepthForEvent(const StartupRoomSetupState &state, float z) {
 	return CLIP<float>(z,
 		(float)MIN(state.roomMinZ, state.roomMaxZ),
@@ -263,6 +382,30 @@ static int mapRoomDepthToScreenYForEvent(const StartupRoomSetupState &state, flo
 		state.roomMaxZScreenY, state.roomMinZScreenY);
 }
 
+static int roundRoomCombatFloat(float value) {
+	return value >= 0.0f ? (int)(value + 0.5f) : (int)(value - 0.5f);
+}
+
+static int mapRoomDepthToScreenYForCombat(const StartupRoomSetupState &state, float z, int fallbackY) {
+	if (state.roomMaxZScreenY < 0 || state.roomMinZScreenY < state.roomMaxZScreenY)
+		return fallbackY;
+	if (state.roomMaxZ == state.roomMinZ)
+		return CLIP<int>(fallbackY, state.roomMaxZScreenY, state.roomMinZScreenY);
+
+	const float clampedZ = clampRoomDepthForEvent(state, z);
+	const float offset = ((float)state.roomMaxZ - clampedZ) *
+		(float)(state.roomMinZScreenY - state.roomMaxZScreenY) /
+		(float)(state.roomMaxZ - state.roomMinZ);
+	return CLIP<int>(state.roomMaxZScreenY + roundRoomCombatFloat(offset),
+		state.roomMaxZScreenY, state.roomMinZScreenY);
+}
+
+static int resolveMonsterAttackContactFrameOffset(const StartupMonsterRecord &monster) {
+	return monster.monsterName.equalsIgnoreCase("MUCKEY")
+		? kMuckeyAttackContactFrameOffset
+		: kDefaultMonsterAttackContactFrameOffset;
+}
+
 RoomSystem::RoomSystem(HarvesterEngine &engine, Common::Point &mousePos,
 		InventorySystem &inventory)
 	: _engine(engine), _mousePos(mousePos), _inventory(inventory) {
@@ -378,6 +521,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			_engine.getStartupScript() ? _engine.getStartupScript()->getPlayerCombatLoadout() : 0;
 		playerState.turnActive = false;
 		playerState.turnTargetFacing = -1;
+		Common::Array<RoomMonsterCombatState> monsterCombatStates;
+		monsterCombatStates.resize(scene.state.roomMonsters.size());
 		StartupResolvedText inspectText;
 		bool showingInspectText = false;
 		bool inspectCanDismiss = false;
@@ -435,10 +580,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			playerState.attackActive = false;
 			playerState.attackFirstFrame = -1;
 			playerState.attackLastFrame = -1;
+			playerState.attackContactFrame = -1;
 			if (!playerState.entity)
 				return false;
 			playerState.attackSoundPlayed = false;
 			playerState.attackSoundFrame = -1;
+			playerState.attackContactResolved = false;
+			playerState.attackTargetName.clear();
+			playerState.attackTargetClassId = -1;
 			const int resumeFacing = playerState.attackResumeFacing >= 0
 				? playerState.attackResumeFacing
 				: (playerState.facing >= 0 ? playerState.facing : 0);
@@ -536,11 +685,17 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			playerState.attackActive = false;
 			playerState.attackFirstFrame = -1;
 			playerState.attackLastFrame = -1;
+			playerState.attackContactFrame = -1;
 			playerState.attackResumeFacing = -1;
 			playerState.attackSoundPlayed = false;
 			playerState.attackSoundFrame = -1;
+			playerState.attackContactResolved = false;
+			playerState.attackTargetName.clear();
+			playerState.attackTargetClassId = -1;
 			playerState.combatLoadout =
 				_engine.getStartupScript() ? _engine.getStartupScript()->getPlayerCombatLoadout() : 0;
+			monsterCombatStates.clear();
+			monsterCombatStates.resize(scene.state.roomMonsters.size());
 			pendingRegionName.clear();
 			resetIdleState();
 			startupFlow.resetCursorAnimationSequence();
@@ -941,6 +1096,350 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			refreshCurrentScene, captureDialogueBackdrop, runRoomExitCommands,
 			applyLightingCommand, applyPlayerGotoXZ, runModalShowText, resetIdleState
 		};
+	auto findRoomNpcRecordByName = [&](const Common::String &npcName) -> StartupNpcRecord * {
+		for (StartupNpcRecord &npc : scene.state.roomNpcs) {
+			if (npc.npcName.equalsIgnoreCase(npcName))
+				return &npc;
+		}
+
+		return nullptr;
+	};
+	auto findRoomMonsterRecordByName = [&](const Common::String &monsterName) -> StartupMonsterRecord * {
+		for (StartupMonsterRecord &monster : scene.state.roomMonsters) {
+			if (monster.monsterName.equalsIgnoreCase(monsterName))
+				return &monster;
+		}
+
+		return nullptr;
+	};
+	auto findRoomMonsterIndexByName = [&](const Common::String &monsterName) -> int {
+		for (uint i = 0; i < scene.state.roomMonsters.size(); ++i) {
+			if (scene.state.roomMonsters[i].monsterName.equalsIgnoreCase(monsterName))
+				return (int)i;
+		}
+
+		return -1;
+	};
+	auto findSceneRuntimeEntity = [&](const Common::String &entityName) -> RuntimeEntity * {
+		return runtimeEntities ? runtimeEntities->findSceneEntityByName(entityName) : nullptr;
+	};
+	auto findMonsterTargetAtPoint = [&](const Common::Point &point) -> StartupMonsterRecord * {
+		StartupMonsterRecord *bestMonster = nullptr;
+		int bestZ = -0x7fffffff;
+		int bestTop = -0x7fffffff;
+		for (StartupMonsterRecord &monster : scene.state.roomMonsters) {
+			if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
+				continue;
+
+			RuntimeEntity *entity = findSceneRuntimeEntity(monster.monsterName);
+			if (!entity || !entity->isVisible())
+				continue;
+
+			const Common::Rect entityRect = entity->getScreenRect();
+			if (!entityRect.contains(point))
+				continue;
+
+			if (!bestMonster || monster.posZ > bestZ ||
+					(monster.posZ == bestZ && entityRect.top > bestTop)) {
+				bestMonster = &monster;
+				bestZ = monster.posZ;
+				bestTop = entityRect.top;
+			}
+		}
+
+		return bestMonster;
+	};
+	auto findOverlappingMonsterTarget = [&]() -> StartupMonsterRecord * {
+		if (!playerState.entity)
+			return nullptr;
+
+		for (StartupMonsterRecord &monster : scene.state.roomMonsters) {
+			if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
+				continue;
+
+			RuntimeEntity *entity = findSceneRuntimeEntity(monster.monsterName);
+			if (entity && playerState.entity->overlapsEntity(*entity))
+				return &monster;
+		}
+
+		return nullptr;
+	};
+	auto findOverlappingNpcTarget = [&]() -> StartupNpcRecord * {
+		if (!playerState.entity)
+			return nullptr;
+
+		for (StartupNpcRecord &npc : scene.state.roomNpcs) {
+			if (!npc.visible || npc.deathOrMonsterfyFlag)
+				continue;
+
+			RuntimeEntity *entity = findSceneRuntimeEntity(npc.npcName);
+			if (entity && playerState.entity->overlapsEntity(*entity))
+				return &npc;
+		}
+
+		return nullptr;
+	};
+	auto playerAttackCanReachTarget = [&](RuntimeEntity *targetEntity) {
+		if (!playerState.entity || !targetEntity)
+			return false;
+		if (Player::isProjectileCombatLoadout(playerState.combatLoadout))
+			return true;
+
+		return playerState.entity->overlapsEntity(*targetEntity);
+	};
+	auto handleCombatInteraction = [&](StartupInteractionResult interaction) -> Common::Error {
+		bool didTransition = false;
+		Common::Error interactionError =
+			interactionProcessor.handleInteractionResult(interaction, didTransition, Common::String());
+		if (interactionError.getCode() != Common::kNoError)
+			return interactionError;
+
+		needsRedraw = true;
+		return Common::kNoError;
+	};
+	auto capturePlayerAttackTarget = [&]() {
+		playerState.attackTargetName.clear();
+		playerState.attackTargetClassId = -1;
+
+		const StartupRoomHoverState hoverState = resolveRoomHoverState(
+			_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions, _mousePos);
+		if (hoverState.npc) {
+			playerState.attackTargetName = hoverState.npc->npcName;
+			playerState.attackTargetClassId = kRuntimeEntityClassNpc;
+			return;
+		}
+
+		if (StartupMonsterRecord *monster = findMonsterTargetAtPoint(_mousePos)) {
+			playerState.attackTargetName = monster->monsterName;
+			playerState.attackTargetClassId = kRuntimeEntityClassMonster;
+		}
+	};
+	auto resolvePlayerAttackContact = [&]() -> Common::Error {
+		if (!playerState.attackActive || playerState.attackContactResolved || !playerState.entity ||
+				playerState.attackContactFrame < 0 ||
+				playerState.entity->getCurrentFrame() < playerState.attackContactFrame) {
+			return Common::kNoError;
+		}
+
+		playerState.attackContactResolved = true;
+		Script *startupScript = _engine.getStartupScript();
+		if (!startupScript)
+			return Common::kNoError;
+
+		const int damageType = Player::resolveCombatLoadoutDamageType(playerState.combatLoadout);
+		if (playerState.attackTargetClassId < 0 && !Player::isProjectileCombatLoadout(playerState.combatLoadout)) {
+			if (StartupNpcRecord *fallbackNpc = findOverlappingNpcTarget()) {
+				playerState.attackTargetName = fallbackNpc->npcName;
+				playerState.attackTargetClassId = kRuntimeEntityClassNpc;
+			}
+		}
+		if (playerState.attackTargetClassId == kRuntimeEntityClassNpc) {
+			StartupNpcRecord *npc = findRoomNpcRecordByName(playerState.attackTargetName);
+			RuntimeEntity *npcEntity = npc ? findSceneRuntimeEntity(npc->npcName) : nullptr;
+			if (!npc || !npcEntity || !playerAttackCanReachTarget(npcEntity))
+				npc = Player::isProjectileCombatLoadout(playerState.combatLoadout) ? nullptr : findOverlappingNpcTarget();
+			if (!npc)
+				return Common::kNoError;
+
+			const bool runtimeChanged =
+				startupScript->triggerRuntimeNpcDeathOrMonsterfy(npc->npcName, damageType);
+			const bool jimmyFlagChanged = npc->npcName.equalsIgnoreCase("JIMMY")
+				? startupScript->setRuntimeFlagValue("JIMMY_ATTACKED", true)
+				: false;
+			if (!runtimeChanged && !jimmyFlagChanged)
+				return Common::kNoError;
+
+			StartupInteractionResult interaction;
+			interaction.mutatedRuntimeState = runtimeChanged;
+			if (npc->monsterfyTargetName.empty() && !npc->onDeathActionTag.empty()) {
+				StartupInteractionResult deathInteraction;
+				if (startupScript->executeActionTag(npc->onDeathActionTag, deathInteraction)) {
+					interaction = deathInteraction;
+					interaction.mutatedRuntimeState = true;
+				}
+			}
+
+			return handleCombatInteraction(interaction);
+		}
+
+		StartupMonsterRecord *monster = nullptr;
+		if (playerState.attackTargetClassId == kRuntimeEntityClassMonster)
+			monster = findRoomMonsterRecordByName(playerState.attackTargetName);
+		RuntimeEntity *monsterEntity = monster ? findSceneRuntimeEntity(monster->monsterName) : nullptr;
+		if (!monster || !monsterEntity || !playerAttackCanReachTarget(monsterEntity)) {
+			if (!Player::isProjectileCombatLoadout(playerState.combatLoadout))
+				monster = findOverlappingMonsterTarget();
+			else
+				monster = nullptr;
+			monsterEntity = monster ? findSceneRuntimeEntity(monster->monsterName) : nullptr;
+		}
+		if (!monster || !monsterEntity)
+			return Common::kNoError;
+
+		monster->currentHitPoints = MAX(0,
+			monster->currentHitPoints - Player::resolveCombatLoadoutDamageAmount(playerState.combatLoadout));
+		if (monster->currentHitPoints > 0) {
+			(void)startupScript->syncRuntimeMonsterRecord(*monster);
+			needsRedraw = true;
+			return Common::kNoError;
+		}
+
+		monster->active = false;
+		monster->visible = false;
+		const int monsterIndex = findRoomMonsterIndexByName(monster->monsterName);
+		if (monsterIndex >= 0 && monsterIndex < (int)monsterCombatStates.size())
+			monsterCombatStates[(uint)monsterIndex] = RoomMonsterCombatState();
+		(void)startupScript->syncRuntimeMonsterRecord(*monster);
+
+		StartupInteractionResult interaction;
+		interaction.mutatedRuntimeState = true;
+		if (!monster->onDeathActionTag.empty()) {
+			StartupInteractionResult deathInteraction;
+			if (startupScript->executeActionTag(monster->onDeathActionTag, deathInteraction)) {
+				interaction = deathInteraction;
+				interaction.mutatedRuntimeState = true;
+			}
+		}
+
+		return handleCombatInteraction(interaction);
+	};
+	auto updateRoomMonsterCombat = [&]() -> Common::Error {
+		if (!runtimeEntities)
+			return Common::kNoError;
+
+		Script *startupScript = _engine.getStartupScript();
+		const uint32 now = Player::getRuntimeClockTicks();
+		const uint32 moveInterval = MAX<uint32>(1, 100U / (uint32)kRoomMonsterAnimationRate);
+		const int horizontalStepBase = 8;
+		const int depthStep = MAX<int>(1, roundRoomCombatFloat(
+			scene.state.roomZVelocityStep > 0.0f ? scene.state.roomZVelocityStep : 1.0f));
+		bool playerAlive = startupScript && startupScript->getPlayerCurrentHitPoints() > 0;
+
+		if (monsterCombatStates.size() != scene.state.roomMonsters.size())
+			monsterCombatStates.resize(scene.state.roomMonsters.size());
+
+		for (uint i = 0; i < scene.state.roomMonsters.size(); ++i) {
+			StartupMonsterRecord &monster = scene.state.roomMonsters[i];
+			RoomMonsterCombatState &combatState = monsterCombatStates[i];
+			RuntimeEntity *entity = findSceneRuntimeEntity(monster.monsterName);
+			if (!entity || !monster.visible || monster.currentHitPoints <= 0) {
+				combatState = RoomMonsterCombatState();
+				continue;
+			}
+
+			if (!monster.active || !playerState.entity || !playerAlive) {
+				combatState = RoomMonsterCombatState();
+				needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
+				continue;
+			}
+
+			if (combatState.attackActive) {
+				const int currentFrame = entity->getCurrentFrame();
+				if (!combatState.attackSoundPlayed &&
+						monster.attackSoundTriggerFrame >= 0 &&
+						currentFrame >= combatState.attackFirstFrame + monster.attackSoundTriggerFrame) {
+					(void)playRandomRoomAttackSound(_engine,
+						monster.attackSound1, monster.attackSound2, monster.attackSound3);
+					combatState.attackSoundPlayed = true;
+				}
+				if (combatState.attackContactFrame >= 0 &&
+						currentFrame >= combatState.attackContactFrame) {
+					combatState.attackContactFrame = -1;
+					if (startupScript &&
+							startupScript->adjustPlayerCurrentHitPoints(-monster.damageAmount) &&
+							startupScript->getPlayerCurrentHitPoints() <= 0) {
+						playerAlive = false;
+						stopPlayerRegionInteraction();
+					}
+					needsRedraw = true;
+				}
+				if (currentFrame == combatState.attackLastFrame) {
+					combatState.attackActive = false;
+					combatState.attackFirstFrame = -1;
+					combatState.attackLastFrame = -1;
+					combatState.attackContactFrame = -1;
+					combatState.attackResumeFacing = -1;
+					combatState.attackSoundPlayed = false;
+					needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
+				}
+				continue;
+			}
+
+			if (combatState.nextMovementTick != 0 && (int32)(now - combatState.nextMovementTick) < 0)
+				continue;
+			combatState.nextMovementTick = now + moveInterval;
+
+			const int targetBottomY = playerState.bottomY;
+			const int dx = playerState.centerX - monster.posX;
+			const int dy = targetBottomY - monster.posY;
+			const int absDx = ABS(dx);
+			const int absDy = ABS(dy);
+			const int engageDistance = MAX(0, monster.engageDistance);
+			bool moved = false;
+			if (absDx > engageDistance) {
+				const int step = MAX<int>(1, roundRoomCombatFloat(
+					Player::computeDepthScale(scene.state, (float)monster.posZ) * (float)horizontalStepBase));
+				const int direction = dx < 0 ? -1 : 1;
+				const int desiredX = playerState.centerX - direction * engageDistance;
+				if (direction < 0)
+					monster.posX = MAX(monster.posX - step, desiredX);
+				else
+					monster.posX = MIN(monster.posX + step, desiredX);
+				monster.posX = CLIP<int>(monster.posX, monster.minXBound, monster.maxXBound);
+				moved = true;
+			}
+			if (absDy > depthStep) {
+				if (dy < 0)
+					monster.posZ -= depthStep;
+				else
+					monster.posZ += depthStep;
+				monster.posZ = (int)clampRoomDepthForEvent(scene.state, (float)monster.posZ);
+				monster.posY = mapRoomDepthToScreenYForCombat(scene.state, (float)monster.posZ, monster.posY);
+				moved = true;
+			}
+
+			if (moved) {
+				if (absDx > absDy)
+					monster.facing = dx < 0 ? 1 : 2;
+				else if (dy != 0)
+					monster.facing = dy < 0 ? 3 : 0;
+				(void)applyRoomActorPlacement(scene.state, *entity, monster.posX, monster.posY, (float)monster.posZ);
+				if (startupScript)
+					(void)startupScript->syncRuntimeMonsterRecord(monster);
+				needsRedraw = true;
+				needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, true) || needsRedraw;
+				continue;
+			}
+
+			needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
+			const bool attackCooldownExpired =
+				combatState.nextAttackAllowedTick == 0 || (int32)(now - combatState.nextAttackAllowedTick) >= 0;
+			if (!attackCooldownExpired && absDx > MAX(1, engageDistance / 3))
+				continue;
+
+			RoomAttackAnimationRange range;
+			const Common::Point playerPoint(playerState.centerX, playerState.bottomY);
+			(void)resolveRoomActorAttackAnimationRange(
+				scene.state, monster.posX, monster.posY, (float)monster.posZ, playerPoint, range);
+			combatState.attackActive = true;
+			combatState.attackFirstFrame = range.firstFrame;
+			combatState.attackLastFrame = range.lastFrame;
+			combatState.attackContactFrame =
+				range.firstFrame + resolveMonsterAttackContactFrameOffset(monster);
+			combatState.attackResumeFacing = range.resumeFacing;
+			combatState.attackSoundPlayed =
+				monster.attackSound1.empty() && monster.attackSound2.empty() && monster.attackSound3.empty();
+			combatState.nextAttackAllowedTick = now + 50 + _engine.getRandomNumber(99);
+			monster.facing = range.resumeFacing;
+			entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
+			entity->setAnimationRate(kRoomMonsterAnimationRate);
+			entity->setAnimationEnabled(true);
+			entity->setCurrentFrame(range.firstFrame);
+			needsRedraw = true;
+		}
+
+		return Common::kNoError;
+	};
 	auto moveRoomItemDirectlyToInventory = [&](const StartupObjectRecord &object) -> Common::Error {
 		Script *startupScript = _engine.getStartupScript();
 		if (!startupScript)
@@ -1376,8 +1875,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					playerState.turnPlayBackwards = false;
 					(void)Player::syncCombatLoadoutVisual(_engine, scene.state, playerState,
 						startupScript->getPlayerCombatLoadout());
+					capturePlayerAttackTarget();
 					if (Player::startAttackAnimation(scene.state, playerState, _mousePos))
 						needsRedraw = true;
+					else {
+						playerState.attackTargetName.clear();
+						playerState.attackTargetClassId = -1;
+					}
 				}
 				break;
 			}
@@ -1757,6 +2261,16 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		}
 
 		bool playerAdvancedThisFrame = false;
+		Common::Error combatError = resolvePlayerAttackContact();
+		if (combatError.getCode() != Common::kNoError)
+			return combatError;
+		if (startupFlow.hasPendingMainMenuReturn())
+			return Common::kNoError;
+		if (!pendingRoomChange.empty()) {
+			if (!stowCarriedRoomItemToInventory())
+				return Common::kReadingFailed;
+			break;
+		}
 		if (Player::updateAttackAnimationState(_engine, playerState)) {
 			needsRedraw = true;
 		}
@@ -1793,6 +2307,16 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				needsRedraw = true;
 			}
 		}
+		combatError = updateRoomMonsterCombat();
+		if (combatError.getCode() != Common::kNoError)
+			return combatError;
+		if (startupFlow.hasPendingMainMenuReturn())
+			return Common::kNoError;
+		if (!pendingRoomChange.empty()) {
+			if (!stowCarriedRoomItemToInventory())
+				return Common::kReadingFailed;
+			break;
+		}
 		Common::Error pendingRegionError =
 			playerState.attackActive ? Common::kNoError : tryActivatePendingRegion();
 		if (pendingRegionError.getCode() != Common::kNoError)
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index ffcb866de69..45ec65c57f1 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -70,9 +70,13 @@ struct StartupRoomPlayerState {
 	bool attackActive = false;
 	int attackFirstFrame = -1;
 	int attackLastFrame = -1;
+	int attackContactFrame = -1;
 	int attackResumeFacing = -1;
 	bool attackSoundPlayed = false;
 	int attackSoundFrame = -1;
+	bool attackContactResolved = false;
+	Common::String attackTargetName;
+	int attackTargetClassId = -1;
 };
 
 struct StartupRoomHoverState {
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 73ae62408c8..26ea5c592d9 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1859,6 +1859,10 @@ bool Script::setPlayerCurrentHitPoints(int hitPoints) {
 	return changed;
 }
 
+bool Script::adjustPlayerCurrentHitPoints(int delta) {
+	return setPlayerCurrentHitPoints(_playerCurrentHitPoints + delta);
+}
+
 bool Script::setPlayerCombatLoadout(int loadout) {
 	const int clampedLoadout = clampPlayerCombatLoadout(loadout);
 	const bool changed = _playerCombatLoadout != clampedLoadout;
@@ -1921,7 +1925,7 @@ bool Script::setRuntimeTimerEnabled(const Common::String &timerName, bool enable
 	return changed;
 }
 
-bool Script::triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcName) {
+bool Script::triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType) {
 	if (npcName.empty())
 		return false;
 
@@ -1944,8 +1948,11 @@ bool Script::triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcName) {
 		}
 	}
 
-	const bool changed = !runtimeNpc->deathOrMonsterfyFlag;
+	const bool changed = !runtimeNpc->deathOrMonsterfyFlag ||
+		(deathDamageType != 0 && runtimeNpc->deathDamageType != deathDamageType);
 	runtimeNpc->deathOrMonsterfyFlag = true;
+	if (deathDamageType != 0)
+		runtimeNpc->deathDamageType = deathDamageType;
 	return changed || monsterChanged;
 }
 
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 6645bc901a3..1b3a4b3bfd2 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -379,12 +379,13 @@ public:
 		bool setRuntimeObjectVisible(const Common::String &ownerOrRoom, const Common::String &objectName,
 			bool visible);
 		bool setRuntimeNpcState(const Common::String &npcName, bool active, bool visible);
-		bool triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcName);
+		bool triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType = 0);
 		bool isNamedNpcDeathTypeClear(const Common::String &npcName) const;
 	int getCurrentStoryDayIndex() const;
 	int getPlayerCurrentHitPoints() const { return _playerCurrentHitPoints; }
 	int getPlayerCombatLoadout() const { return _playerCombatLoadout; }
 	bool isPlayerControlPaused() const { return _playerControlPaused; }
+	bool adjustPlayerCurrentHitPoints(int delta);
 	bool setPlayerCombatLoadout(int loadout);
 	bool setPlayerControlPaused(bool paused);
 	bool syncRuntimeAnimState(const Common::String &animName, bool active, bool visible, int currentFrame);


Commit: 64f1a9bb024fd5881ba10c634afcd8b16da9d3bc
    https://github.com/scummvm/scummvm/commit/64f1a9bb024fd5881ba10c634afcd8b16da9d3bc
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:47+02:00

Commit Message:
HARVESTER: Tighten room combat pursuit

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/detection.cpp
    engines/harvester/detection.h
    engines/harvester/room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 09d0728b1f9..7d28dd51f2b 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1020,9 +1020,11 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - On success it returns the matching `RegionRecord *` so the main loop can dispatch the region's `action_tag`.
 - `reset_player_combat_avatar` at `0x56a40` is the player-avatar reset helper used on teardown / restart paths.
   - It zeroes transient combat/runtime state, restores the default health/state baseline, and if the current loadout is non-default it rebuilds the player combat avatar back through loadout `0`.
-- `update_actor_runtime_state` at `0x4d750` is the shared per-frame actor state machine for live actor entities.
+  - `update_actor_runtime_state` at `0x4d750` is the shared per-frame actor state machine for live actor entities.
   - It is called from `run_harvester_main_loop`, the dialogue/response/keyword modal loops, and the player-combat wrapper, so actor motion/combat continues advancing while blocking UI is active.
   - It handles class-4/5/6 actor entities, advances animation/state transitions, maintains pursuit spacing against the player using `engage_distance`, applies room Z bounds and vertical motion, fires frame-timed sound hooks, resolves hit damage through `damage_amount`, and returns `0` when the caller should remove the actor entity from the world list.
+  - For class-6 monsters specifically, pursuit is not a free-running room-script heuristic: the native state machine first closes base Z to within `g_player_combat_avatar->z +/- 2.0`, then compares center-to-center X against `engage_distance`, and only arms the close-range attack picker after that spacing gate is satisfied.
+  - The same class-6 path derives its horizontal chase waypoint from the player's live left edge plus the current player/monster frame widths and treats that waypoint as satisfied within `50.0 * depth_scale` pixels, so native monster pursuit does not require exact center equality before it can stop walking and attack.
   - It also contains the confirmed NPC-to-monster replacement path used by the `change2monster` flow.
   - The attack-state reader side is now bounded directly from states `0x16` through `0x1b`:
     - it treats `+0x1134` as the attack sample count and randomly selects from `+0x1148/+0x114c/+0x1150`
diff --git a/TRACKER.md b/TRACKER.md
index d38642f39ce..4ee833c933a 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 21, 2026, re-opened native `update_actor_runtime_state @ 0x4d750` in the live `HARVEST.LE` Ghidra session after a fresh monstrified-NPC combat repro still showed class-6 monsters snapping onto the player's column and looping their walk cycle without closing or landing hits reliably.
+- Confirmed the native class-6 pursuit gate is tighter than the current room-loop shortcut: monsters first close base Z to within `player.z +/- 2.0`, only seed the close-range attack picker once center-to-center X is within `engage_distance`, and treat the horizontal chase waypoint as satisfied within `50 * depth_scale` pixels instead of requiring exact positional equality. Patched `engines/harvester/room.cpp` to stop treating clamped no-op chase steps as movement, chase depth from the player's live Z with the recovered tolerance, and use the same native-style close-range reach gate for player and monster contact instead of opaque-overlap-only melee hits. Added a dedicated `combat` debug channel in `engines/harvester/detection.{h,cpp}` so target capture, chase movement/stalls, attack start, contact, and HP deltas now log explicitly. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 - On March 21, 2026, re-opened native room-combat flow around `run_harvester_main_loop @ 0x6dc70`, `update_actor_runtime_state @ 0x4d750`, `queue_npc_death_or_monsterfy_transition @ 0x53810`, `spawn_monster_entity_from_record @ 0x53a10`, `tick_monster_entity_runtime @ 0x54140`, and `set_player_combat_loadout @ 0x55c10` in the live `HARVEST.LE` Ghidra session after the current ScummVM Harvester room loop still played the recovered right-click attack banks without ever resolving NPC retaliation or monster combat.
 - Confirmed the native close-range NPC hit path shares the same `queue_npc_death_or_monsterfy_transition` helper used by script `KILL_NPC` / `MONSTERFY`, so attacking an NPC can either latch its death state or force the `monsterfy_target_name` replacement monster. Also confirmed player and monster attack contact both use the recovered per-loadout / per-monster `+0x113c` frame offset, with monsters defaulting to contact frame `+2` and `MUCKEY` overriding that offset to `+7`, while live class-6 monster pursuit and retaliation stay in the `tick_monster_entity_runtime` / `update_actor_runtime_state` path keyed by `engage_distance`, `damage_amount`, and the parsed attack-sound trigger frame. Patched `engines/harvester/player.cpp`, `engines/harvester/script.{h,cpp}`, `engines/harvester/room.cpp`, and `engines/harvester/room_support.h` so room attacks now capture a real NPC/monster target, apply native-style NPC death-or-monsterfy bookkeeping on the confirmed contact frame, set `JIMMY_ATTACKED` when Jimmy is attacked, run a minimal live monster pursuit/attack loop against the player, and subtract/player-sync monster HP instead of leaving combat as pure animation. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 - On March 21, 2026, re-opened `handle_talk_to_stephanie @ 0x36710` in the live `HARVEST.LE` Ghidra session after another ScummVM peephole/BYE report and found the function body had regressed to a one-instruction stub again.
@@ -163,6 +165,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run a live Harvester validation pass with `combat` debug enabled in one room that monsterfies an NPC plus one stock-monster room. Confirm the new logs print `combat player target capture`, `combat monster chase move|stalled`, `combat monster attack start`, and `combat ... hit|miss` at contact, that a newly monstrified NPC no longer freezes into an in-place walk cycle on the player's column, and that both player swings and monster swings now change HP whenever the recovered close-range gate is satisfied.
 - Immediate: run a live Harvester validation pass in one room with an NPC that has `monsterfy_target_name` plus one plain monster room. Confirm that right-clicking the NPC now latches the native death/monsterfy transition on the recovered contact frame, spawns the replacement monster when applicable, sets `JIMMY_ATTACKED` if Jimmy is the victim, and that the spawned/active monster now closes to `engage_distance`, plays its attack bank, and removes player HP on contact instead of remaining a passive walk-cycle prop.
 - Immediate: rerun the Stephanie sequence from a clean pre-peephole save. First trigger the `0x2f9..0x2fd` spyhole topic and confirm `BYE` now exits on `0x46a4` without replaying `0x4a05`..`0x4a23`; then advance to `KARIN_KIDNAPED` and confirm the post-kidnap tail now always includes `0x4975`, only prepends `0x4970` when the spyhole flag was set earlier, and still conditionally adds `0x4980` / `0x4984` / `0x4989` / `0x498f` from the shared Potts alibi bits.
 - Immediate: rerun the Stephanie peephole repro. Reach the post-peephole conversation state, select `BYE`, and confirm ScummVM now exits on `0x46a4` instead of replaying `0x4a05` / `0x4a0a` / `0x4a0e` / `0x4a12` / `0x4a19|0x4a1e` / `0x4a23`; then re-enter Stephanie and verify the hidden spyhole branch still triggers only from the explicit `0x2f9..0x2fd` keyword topics.
diff --git a/engines/harvester/detection.cpp b/engines/harvester/detection.cpp
index 63044dd02e8..e6e6980daaa 100644
--- a/engines/harvester/detection.cpp
+++ b/engines/harvester/detection.cpp
@@ -26,6 +26,7 @@
 
 const DebugChannelDef HarvesterMetaEngineDetection::debugFlagList[] = {
 	{ Harvester::kDebugGeneral, "general", "General debug level" },
+	{ Harvester::kDebugCombat, "combat", "Room combat pursuit, attack, and damage debug" },
 	{ Harvester::kDebugResources, "resources", "Resource loading" },
 	{ Harvester::kDebugScene, "scene", "Startup room, hotspot, and actor debug" },
 	{ Harvester::kDebugDialogue, "dialogue", "Startup dialogue and NPC interaction debug" },
diff --git a/engines/harvester/detection.h b/engines/harvester/detection.h
index c1f876f4a93..c8fc434cc68 100644
--- a/engines/harvester/detection.h
+++ b/engines/harvester/detection.h
@@ -30,10 +30,11 @@ namespace Harvester {
 
 enum HarvesterDebugChannels {
 	kDebugGeneral   = 1 << 0,
-	kDebugResources = 1 << 1,
-	kDebugScene     = 1 << 2,
-	kDebugDialogue  = 1 << 3,
-	kDebugInventory = 1 << 4
+	kDebugCombat    = 1 << 1,
+	kDebugResources = 1 << 2,
+	kDebugScene     = 1 << 3,
+	kDebugDialogue  = 1 << 4,
+	kDebugInventory = 1 << 5
 };
 
 extern const PlainGameDescriptor harvesterGames[];
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index d0a21da79c7..e0c32436515 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -63,6 +63,10 @@ static const float kRoomActorAttackUpperYOffset = 144.44f;
 static const float kRoomActorAttackMidYOffset = 75.36f;
 static const int kDefaultMonsterAttackContactFrameOffset = 2;
 static const int kMuckeyAttackContactFrameOffset = 7;
+static const float kNativeMonsterPursuitZTolerance = 2.0f;
+static const float kNativeMonsterHorizontalWaypointTolerance = 50.0f;
+
+static int roundRoomCombatFloat(float value);
 
 struct RoomMonsterFacingAnimationRange {
 	RoomMonsterFacingAnimationRange() {}
@@ -343,6 +347,44 @@ static bool resolveRoomActorAttackAnimationRange(const StartupRoomSetupState &st
 	return true;
 }
 
+static int stepTowardsRoomCombatInt(int current, int target, int step) {
+	if (step <= 0 || current == target)
+		return current;
+	if (current < target)
+		return MIN(current + step, target);
+	return MAX(current - step, target);
+}
+
+static int computeRectAxisGap(int minA, int maxA, int minB, int maxB) {
+	if (maxA <= minB)
+		return minB - maxA;
+	if (maxB <= minA)
+		return minA - maxB;
+	return 0;
+}
+
+static int computeRuntimeEntityHorizontalGap(const RuntimeEntity &left, const RuntimeEntity &right) {
+	const Common::Rect leftRect = left.getScreenRect();
+	const Common::Rect rightRect = right.getScreenRect();
+	return computeRectAxisGap(leftRect.left, leftRect.right, rightRect.left, rightRect.right);
+}
+
+static bool areCombatantsWithinNativeCloseRange(const StartupRoomSetupState &state,
+		const RuntimeEntity &attacker, float attackerZ,
+		const RuntimeEntity &target, float targetZ, int engageDistance) {
+	if (attacker.overlapsEntity(target))
+		return true;
+
+	const float zDelta = attackerZ >= targetZ ? attackerZ - targetZ : targetZ - attackerZ;
+	if (zDelta > kNativeMonsterPursuitZTolerance)
+		return false;
+
+	const float nearZ = attackerZ < targetZ ? attackerZ : targetZ;
+	const int nativeTolerance = roundRoomCombatFloat(
+		Player::computeDepthScale(state, nearZ) * kNativeMonsterHorizontalWaypointTolerance);
+	return computeRuntimeEntityHorizontalGap(attacker, target) <= MAX(MAX(0, engageDistance), nativeTolerance);
+}
+
 static bool playRandomRoomAttackSound(HarvesterEngine &engine, const Common::String &sound1,
 		const Common::String &sound2, const Common::String &sound3) {
 	const Common::String *availableSounds[3];
@@ -1158,7 +1200,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				continue;
 
 			RuntimeEntity *entity = findSceneRuntimeEntity(monster.monsterName);
-			if (entity && playerState.entity->overlapsEntity(*entity))
+			if (entity && areCombatantsWithinNativeCloseRange(scene.state,
+					*playerState.entity, playerState.z, *entity, (float)monster.posZ, monster.engageDistance))
 				return &monster;
 		}
 
@@ -1173,19 +1216,40 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				continue;
 
 			RuntimeEntity *entity = findSceneRuntimeEntity(npc.npcName);
-			if (entity && playerState.entity->overlapsEntity(*entity))
+			if (entity && areCombatantsWithinNativeCloseRange(scene.state,
+					*playerState.entity, playerState.z, *entity, entity->getZ(), 0))
 				return &npc;
 		}
 
 		return nullptr;
 	};
-	auto playerAttackCanReachTarget = [&](RuntimeEntity *targetEntity) {
+	auto playerAttackCanReachTarget = [&](RuntimeEntity *targetEntity, int engageDistance = 0) {
 		if (!playerState.entity || !targetEntity)
 			return false;
 		if (Player::isProjectileCombatLoadout(playerState.combatLoadout))
 			return true;
 
-		return playerState.entity->overlapsEntity(*targetEntity);
+		return areCombatantsWithinNativeCloseRange(scene.state,
+			*playerState.entity, playerState.z, *targetEntity, targetEntity->getZ(), engageDistance);
+	};
+	auto monsterAttackCanReachPlayer = [&](const StartupMonsterRecord &monster, const RuntimeEntity &monsterEntity) {
+		if (!playerState.entity)
+			return false;
+
+		return areCombatantsWithinNativeCloseRange(scene.state,
+			monsterEntity, (float)monster.posZ, *playerState.entity, playerState.z, monster.engageDistance);
+	};
+	auto describeCombatTargetClass = [](int classId) -> const char * {
+		switch (classId) {
+		case kRuntimeEntityClassNpc:
+			return "npc";
+		case kRuntimeEntityClassMonster:
+			return "monster";
+		case kRuntimeEntityClassPlayer:
+			return "player";
+		default:
+			return "none";
+		}
 	};
 	auto handleCombatInteraction = [&](StartupInteractionResult interaction) -> Common::Error {
 		bool didTransition = false;
@@ -1206,13 +1270,24 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		if (hoverState.npc) {
 			playerState.attackTargetName = hoverState.npc->npcName;
 			playerState.attackTargetClassId = kRuntimeEntityClassNpc;
+			debugC(1, kDebugCombat,
+				"Harvester: combat player target capture class='npc' name='%s' cursor=(%d,%d)",
+				playerState.attackTargetName.c_str(), _mousePos.x, _mousePos.y);
 			return;
 		}
 
 		if (StartupMonsterRecord *monster = findMonsterTargetAtPoint(_mousePos)) {
 			playerState.attackTargetName = monster->monsterName;
 			playerState.attackTargetClassId = kRuntimeEntityClassMonster;
+			debugC(1, kDebugCombat,
+				"Harvester: combat player target capture class='monster' name='%s' cursor=(%d,%d)",
+				playerState.attackTargetName.c_str(), _mousePos.x, _mousePos.y);
+			return;
 		}
+
+		debugC(1, kDebugCombat,
+			"Harvester: combat player target capture class='none' cursor=(%d,%d)",
+			_mousePos.x, _mousePos.y);
 	};
 	auto resolvePlayerAttackContact = [&]() -> Common::Error {
 		if (!playerState.attackActive || playerState.attackContactResolved || !playerState.entity ||
@@ -1222,6 +1297,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		}
 
 		playerState.attackContactResolved = true;
+		debugC(1, kDebugCombat,
+			"Harvester: combat player attack contact frame=%d loadout=%d target_class='%s' target='%s'",
+			playerState.entity->getCurrentFrame(), playerState.combatLoadout,
+			describeCombatTargetClass(playerState.attackTargetClassId), playerState.attackTargetName.c_str());
 		Script *startupScript = _engine.getStartupScript();
 		if (!startupScript)
 			return Common::kNoError;
@@ -1238,14 +1317,21 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			RuntimeEntity *npcEntity = npc ? findSceneRuntimeEntity(npc->npcName) : nullptr;
 			if (!npc || !npcEntity || !playerAttackCanReachTarget(npcEntity))
 				npc = Player::isProjectileCombatLoadout(playerState.combatLoadout) ? nullptr : findOverlappingNpcTarget();
-			if (!npc)
+			if (!npc) {
+				debugC(1, kDebugCombat,
+					"Harvester: combat player attack missed class='npc' target='%s' reason='no reachable npc'",
+					playerState.attackTargetName.c_str());
 				return Common::kNoError;
+			}
 
 			const bool runtimeChanged =
 				startupScript->triggerRuntimeNpcDeathOrMonsterfy(npc->npcName, damageType);
 			const bool jimmyFlagChanged = npc->npcName.equalsIgnoreCase("JIMMY")
 				? startupScript->setRuntimeFlagValue("JIMMY_ATTACKED", true)
 				: false;
+			debugC(1, kDebugCombat,
+				"Harvester: combat player npc hit target='%s' damage_type=%d runtime_changed=%d jimmy_flag=%d",
+				npc->npcName.c_str(), damageType, runtimeChanged, jimmyFlagChanged);
 			if (!runtimeChanged && !jimmyFlagChanged)
 				return Common::kNoError;
 
@@ -1266,18 +1352,27 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		if (playerState.attackTargetClassId == kRuntimeEntityClassMonster)
 			monster = findRoomMonsterRecordByName(playerState.attackTargetName);
 		RuntimeEntity *monsterEntity = monster ? findSceneRuntimeEntity(monster->monsterName) : nullptr;
-		if (!monster || !monsterEntity || !playerAttackCanReachTarget(monsterEntity)) {
+		if (!monster || !monsterEntity || !playerAttackCanReachTarget(monsterEntity, monster ? monster->engageDistance : 0)) {
 			if (!Player::isProjectileCombatLoadout(playerState.combatLoadout))
 				monster = findOverlappingMonsterTarget();
 			else
 				monster = nullptr;
 			monsterEntity = monster ? findSceneRuntimeEntity(monster->monsterName) : nullptr;
 		}
-		if (!monster || !monsterEntity)
+		if (!monster || !monsterEntity) {
+			debugC(1, kDebugCombat,
+				"Harvester: combat player attack missed class='monster' target='%s' reason='no reachable monster'",
+				playerState.attackTargetName.c_str());
 			return Common::kNoError;
+		}
 
+		const int damageAmount = Player::resolveCombatLoadoutDamageAmount(playerState.combatLoadout);
+		const int hitPointsBefore = monster->currentHitPoints;
 		monster->currentHitPoints = MAX(0,
-			monster->currentHitPoints - Player::resolveCombatLoadoutDamageAmount(playerState.combatLoadout));
+			monster->currentHitPoints - damageAmount);
+		debugC(1, kDebugCombat,
+			"Harvester: combat player monster hit target='%s' damage=%d hp=%d->%d reachable=1",
+			monster->monsterName.c_str(), damageAmount, hitPointsBefore, monster->currentHitPoints);
 		if (monster->currentHitPoints > 0) {
 			(void)startupScript->syncRuntimeMonsterRecord(*monster);
 			needsRedraw = true;
@@ -1293,6 +1388,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 		StartupInteractionResult interaction;
 		interaction.mutatedRuntimeState = true;
+		debugC(1, kDebugCombat,
+			"Harvester: combat monster defeated target='%s' on_death='%s'",
+			monster->monsterName.c_str(), monster->onDeathActionTag.c_str());
 		if (!monster->onDeathActionTag.empty()) {
 			StartupInteractionResult deathInteraction;
 			if (startupScript->executeActionTag(monster->onDeathActionTag, deathInteraction)) {
@@ -1345,11 +1443,27 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				if (combatState.attackContactFrame >= 0 &&
 						currentFrame >= combatState.attackContactFrame) {
 					combatState.attackContactFrame = -1;
-					if (startupScript &&
-							startupScript->adjustPlayerCurrentHitPoints(-monster.damageAmount) &&
-							startupScript->getPlayerCurrentHitPoints() <= 0) {
-						playerAlive = false;
-						stopPlayerRegionInteraction();
+					const bool playerInRange = playerState.entity && monsterAttackCanReachPlayer(monster, *entity);
+					if (!startupScript) {
+						debugC(1, kDebugCombat,
+							"Harvester: combat monster attack contact monster='%s' frame=%d skipped reason='no startup script'",
+							monster.monsterName.c_str(), currentFrame);
+					} else if (!playerInRange) {
+						debugC(1, kDebugCombat,
+							"Harvester: combat monster attack miss monster='%s' frame=%d player_hp=%d reason='out of range'",
+							monster.monsterName.c_str(), currentFrame, startupScript->getPlayerCurrentHitPoints());
+					} else {
+						const int playerHitPointsBefore = startupScript->getPlayerCurrentHitPoints();
+						const bool changed = startupScript->adjustPlayerCurrentHitPoints(-monster.damageAmount);
+						const int playerHitPointsAfter = startupScript->getPlayerCurrentHitPoints();
+						debugC(1, kDebugCombat,
+							"Harvester: combat monster attack hit monster='%s' frame=%d damage=%d player_hp=%d->%d changed=%d",
+							monster.monsterName.c_str(), currentFrame, monster.damageAmount,
+							playerHitPointsBefore, playerHitPointsAfter, changed);
+						if (changed && playerHitPointsAfter <= 0) {
+							playerAlive = false;
+							stopPlayerRegionInteraction();
+						}
 					}
 					needsRedraw = true;
 				}
@@ -1369,48 +1483,60 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				continue;
 			combatState.nextMovementTick = now + moveInterval;
 
-			const int targetBottomY = playerState.bottomY;
+			const int previousX = monster.posX;
+			const int previousY = monster.posY;
+			const int previousZ = monster.posZ;
 			const int dx = playerState.centerX - monster.posX;
-			const int dy = targetBottomY - monster.posY;
 			const int absDx = ABS(dx);
-			const int absDy = ABS(dy);
+			const int playerTargetZ = roundRoomCombatFloat(playerState.z);
+			const float zDelta = playerState.z - (float)monster.posZ;
+			const float absZDelta = zDelta >= 0.0f ? zDelta : -zDelta;
 			const int engageDistance = MAX(0, monster.engageDistance);
-			bool moved = false;
+			bool attemptedMoveX = false;
+			bool attemptedMoveZ = false;
 			if (absDx > engageDistance) {
+				attemptedMoveX = true;
 				const int step = MAX<int>(1, roundRoomCombatFloat(
 					Player::computeDepthScale(scene.state, (float)monster.posZ) * (float)horizontalStepBase));
 				const int direction = dx < 0 ? -1 : 1;
 				const int desiredX = playerState.centerX - direction * engageDistance;
-				if (direction < 0)
-					monster.posX = MAX(monster.posX - step, desiredX);
-				else
-					monster.posX = MIN(monster.posX + step, desiredX);
+				monster.posX = stepTowardsRoomCombatInt(monster.posX, desiredX, step);
 				monster.posX = CLIP<int>(monster.posX, monster.minXBound, monster.maxXBound);
-				moved = true;
 			}
-			if (absDy > depthStep) {
-				if (dy < 0)
-					monster.posZ -= depthStep;
-				else
-					monster.posZ += depthStep;
+			if (absZDelta > kNativeMonsterPursuitZTolerance) {
+				attemptedMoveZ = true;
+				monster.posZ = stepTowardsRoomCombatInt(monster.posZ, playerTargetZ, depthStep);
 				monster.posZ = (int)clampRoomDepthForEvent(scene.state, (float)monster.posZ);
 				monster.posY = mapRoomDepthToScreenYForCombat(scene.state, (float)monster.posZ, monster.posY);
-				moved = true;
 			}
 
+			const bool moved = monster.posX != previousX || monster.posY != previousY || monster.posZ != previousZ;
 			if (moved) {
-				if (absDx > absDy)
-					monster.facing = dx < 0 ? 1 : 2;
-				else if (dy != 0)
-					monster.facing = dy < 0 ? 3 : 0;
+				const int movedX = monster.posX - previousX;
+				const int movedY = monster.posY - previousY;
+				if (ABS(movedX) > ABS(movedY))
+					monster.facing = movedX < 0 ? 1 : 2;
+				else if (movedY != 0)
+					monster.facing = movedY < 0 ? 3 : 0;
 				(void)applyRoomActorPlacement(scene.state, *entity, monster.posX, monster.posY, (float)monster.posZ);
 				if (startupScript)
 					(void)startupScript->syncRuntimeMonsterRecord(monster);
+				debugC(1, kDebugCombat,
+					"Harvester: combat monster chase move monster='%s' center_dx=%d z_delta=%.2f engage=%d from=(%d,%d,z=%d) to=(%d,%d,z=%d) facing=%d",
+					monster.monsterName.c_str(), dx, (double)zDelta, engageDistance,
+					previousX, previousY, previousZ, monster.posX, monster.posY, monster.posZ, monster.facing);
 				needsRedraw = true;
 				needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, true) || needsRedraw;
 				continue;
 			}
 
+			if (attemptedMoveX || attemptedMoveZ) {
+				debugC(1, kDebugCombat,
+					"Harvester: combat monster chase stalled monster='%s' center_dx=%d z_delta=%.2f engage=%d pos=(%d,%d,z=%d) attempted_x=%d attempted_z=%d",
+					monster.monsterName.c_str(), dx, (double)zDelta, engageDistance,
+					monster.posX, monster.posY, monster.posZ, attemptedMoveX, attemptedMoveZ);
+			}
+
 			needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
 			const bool attackCooldownExpired =
 				combatState.nextAttackAllowedTick == 0 || (int32)(now - combatState.nextAttackAllowedTick) >= 0;
@@ -1435,6 +1561,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			entity->setAnimationRate(kRoomMonsterAnimationRate);
 			entity->setAnimationEnabled(true);
 			entity->setCurrentFrame(range.firstFrame);
+			debugC(1, kDebugCombat,
+				"Harvester: combat monster attack start monster='%s' frames=%d..%d contact=%d resume_facing=%d center_dx=%d z_delta=%.2f engage=%d next_attack_tick=%u",
+				monster.monsterName.c_str(), range.firstFrame, range.lastFrame,
+				combatState.attackContactFrame, range.resumeFacing, dx, (double)zDelta,
+				engageDistance, combatState.nextAttackAllowedTick);
 			needsRedraw = true;
 		}
 
@@ -1876,9 +2007,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					(void)Player::syncCombatLoadoutVisual(_engine, scene.state, playerState,
 						startupScript->getPlayerCombatLoadout());
 					capturePlayerAttackTarget();
-					if (Player::startAttackAnimation(scene.state, playerState, _mousePos))
+					if (Player::startAttackAnimation(scene.state, playerState, _mousePos)) {
+						debugC(1, kDebugCombat,
+							"Harvester: combat player attack start loadout=%d target_class='%s' target='%s' cursor=(%d,%d)",
+							playerState.combatLoadout,
+							describeCombatTargetClass(playerState.attackTargetClassId),
+							playerState.attackTargetName.c_str(), _mousePos.x, _mousePos.y);
 						needsRedraw = true;
-					else {
+					} else {
 						playerState.attackTargetName.clear();
 						playerState.attackTargetClassId = -1;
 					}


Commit: f99506b78fc7a2d5a326652b958e61bba7b73b56
    https://github.com/scummvm/scummvm/commit/f99506b78fc7a2d5a326652b958e61bba7b73b56
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:47+02:00

Commit Message:
HARVESTER: Mirror ctrl-arrow keyboard combat

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/player.cpp
    engines/harvester/player.h
    engines/harvester/room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 7d28dd51f2b..c9b2d8fc138 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -691,6 +691,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `g_input_up_pressed` / `g_input_left_pressed` / `g_input_right_pressed` / `g_input_down_pressed` are the `0x48` / `0x4b` / `0x4d` / `0x50` slots in `g_keyboard_scancode_pressed`.
   - `g_input_right_shift_pressed` at `0xd5942` is scan code `0x36` and is the reverse-step modifier used by `update_player_combat_avatar_state`.
   - `g_player_attack_pressed` at `0xd5929` is the scan-code `0x1d` slot consumed by the player-combat attack path.
+  - With `g_player_attack_pressed` held, `update_player_combat_avatar_state` uses the directional scan-code slots as a keyboard-only attack selector: right picks state `0x1a` / frames `0x78..0x81`, left picks `0x17` / `0x5a..0x63`, and up/down only choose the upper/lower side-attack banks (`0x1b` / `0x6e..0x77`, `0x18` / `0x50..0x59`, `0x19` / `0x82..0x8b`, `0x16` / `0x64..0x6d`) when the current facing family is already right or left.
 
 ## Pointer / Cursor Input
 
@@ -1031,6 +1032,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - it plays the selected attack sample when `current_frame == anim_base + +0x116c`
     - it waits until `current_frame >= anim_base + +0x113c` before resolving contact
     - on a confirmed hit it subtracts attacker `+0x1180` from victim HP
+    - for keyboard-driven player attacks, the same contact path picks the nearest live target on the chosen side; upper/lower side attacks also require the target center to stay within a `30`-pixel horizontal side window and to overlap the attacker's live Z/depth span
   - Runtime dword `+0x118c` is now bounded as a mixed combat/capability mask rather than as a pure damage-type field.
     - its low bits carry the attack damage type (`1 = BLUDGE`, `2 = SLASH`, `4 = PROJ`)
     - the player combat-avatar path seeds it with `0x00fffff8` before adding those low bits, while monster construction copies `MonsterRecord.damage_type` and then ORs extra low-byte availability bits from ABM frame counts
diff --git a/TRACKER.md b/TRACKER.md
index 4ee833c933a..165ae275073 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 21, 2026, re-opened native `update_player_combat_avatar_state @ 0x553a0` in the live `HARVEST.LE` Ghidra session after confirming ScummVM still lacked the keyboard combat path that lets Ctrl plus the arrow keys drive room attacks without the mouse.
+- Confirmed the keyboard-only attack selector is explicit, not a generic movement override: scan-code `0x1d` (Ctrl) gates the branch, right/left immediately pick the mid side-attack banks `0x1a` / `0x78..0x81` and `0x17` / `0x5a..0x63`, while up/down only choose `0x1b` / `0x6e..0x77`, `0x18` / `0x50..0x59`, `0x19` / `0x82..0x8b`, or `0x16` / `0x64..0x6d` when the current facing family is already right or left. Recovered from the same native path that keyboard contact chooses the nearest live target on the selected side, with upper/lower attacks also requiring the target center to remain inside a `30`-pixel horizontal side window and the live depth spans to overlap. Patched `engines/harvester/player.{h,cpp}` and `engines/harvester/room.cpp` so Ctrl+arrow now starts the recovered attack banks, suppresses walking while the modifier is held, rate-limits repeat starts like the native loop, pre-captures a native-style directional target for melee and projectile contact, and logs keyboard attack starts through the existing `combat` debug channel. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 - On March 21, 2026, re-opened native `update_actor_runtime_state @ 0x4d750` in the live `HARVEST.LE` Ghidra session after a fresh monstrified-NPC combat repro still showed class-6 monsters snapping onto the player's column and looping their walk cycle without closing or landing hits reliably.
 - Confirmed the native class-6 pursuit gate is tighter than the current room-loop shortcut: monsters first close base Z to within `player.z +/- 2.0`, only seed the close-range attack picker once center-to-center X is within `engage_distance`, and treat the horizontal chase waypoint as satisfied within `50 * depth_scale` pixels instead of requiring exact positional equality. Patched `engines/harvester/room.cpp` to stop treating clamped no-op chase steps as movement, chase depth from the player's live Z with the recovered tolerance, and use the same native-style close-range reach gate for player and monster contact instead of opaque-overlap-only melee hits. Added a dedicated `combat` debug channel in `engines/harvester/detection.{h,cpp}` so target capture, chase movement/stalls, attack start, contact, and HP deltas now log explicitly. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 - On March 21, 2026, re-opened native room-combat flow around `run_harvester_main_loop @ 0x6dc70`, `update_actor_runtime_state @ 0x4d750`, `queue_npc_death_or_monsterfy_transition @ 0x53810`, `spawn_monster_entity_from_record @ 0x53a10`, `tick_monster_entity_runtime @ 0x54140`, and `set_player_combat_loadout @ 0x55c10` in the live `HARVEST.LE` Ghidra session after the current ScummVM Harvester room loop still played the recovered right-click attack banks without ever resolving NPC retaliation or monster combat.
@@ -165,6 +167,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run a live Harvester validation pass with `--debugflags=combat` in one room where the player can swing at an NPC/monster without the mouse. Hold Ctrl plus Left/Right/Up/Down and confirm ScummVM now enters the recovered side-attack banks without walking, reacquires the correct native-style directional target between repeated swings, and still applies melee/projectile damage on the confirmed contact frame.
 - Immediate: run a live Harvester validation pass with `combat` debug enabled in one room that monsterfies an NPC plus one stock-monster room. Confirm the new logs print `combat player target capture`, `combat monster chase move|stalled`, `combat monster attack start`, and `combat ... hit|miss` at contact, that a newly monstrified NPC no longer freezes into an in-place walk cycle on the player's column, and that both player swings and monster swings now change HP whenever the recovered close-range gate is satisfied.
 - Immediate: run a live Harvester validation pass in one room with an NPC that has `monsterfy_target_name` plus one plain monster room. Confirm that right-clicking the NPC now latches the native death/monsterfy transition on the recovered contact frame, spawns the replacement monster when applicable, sets `JIMMY_ATTACKED` if Jimmy is the victim, and that the spawned/active monster now closes to `engage_distance`, plays its attack bank, and removes player HP on contact instead of remaining a passive walk-cycle prop.
 - Immediate: rerun the Stephanie sequence from a clean pre-peephole save. First trigger the `0x2f9..0x2fd` spyhole topic and confirm `BYE` now exits on `0x46a4` without replaying `0x4a05`..`0x4a23`; then advance to `KARIN_KIDNAPED` and confirm the post-kidnap tail now always includes `0x4975`, only prepends `0x4970` when the spyhole flag was set earlier, and still conditionally adds `0x4980` / `0x4984` / `0x4989` / `0x498f` from the shared Potts alibi bits.
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 6d0499bb271..440e54df563 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -267,6 +267,76 @@ static bool resolvePlayerAttackAnimationRange(const StartupRoomSetupState &state
 	return true;
 }
 
+static bool resolveKeyboardAttackAnimationRange(const StartupRoomPlayerState &playerState,
+		bool attackLeft, bool attackRight, bool attackUp, bool attackDown,
+		PlayerAttackAnimationRange &range) {
+	// Native update_player_combat_avatar_state uses scan-code 0x1d as the attack modifier:
+	// Left/Right pick the side mid attacks directly, while Up/Down only choose upper/lower
+	// attacks when the current actor state is already in the matching horizontal family.
+	if (attackRight) {
+		range = PlayerAttackAnimationRange(0x78, 0x81, 2);
+		return true;
+	}
+	if (attackLeft) {
+		range = PlayerAttackAnimationRange(0x5a, 0x63, 1);
+		return true;
+	}
+	if (attackUp) {
+		if (playerState.facing == 2) {
+			range = PlayerAttackAnimationRange(0x6e, 0x77, 2);
+			return true;
+		}
+		if (playerState.facing == 1) {
+			range = PlayerAttackAnimationRange(0x50, 0x59, 1);
+			return true;
+		}
+		return false;
+	}
+	if (attackDown) {
+		if (playerState.facing == 2) {
+			range = PlayerAttackAnimationRange(0x82, 0x8b, 2);
+			return true;
+		}
+		if (playerState.facing == 1) {
+			range = PlayerAttackAnimationRange(0x64, 0x6d, 1);
+			return true;
+		}
+		return false;
+	}
+
+	return false;
+}
+
+static bool startResolvedAttackAnimation(StartupRoomPlayerState &playerState,
+		const PlayerAttackAnimationRange &range) {
+	if (!playerState.entity || playerState.attackActive)
+		return false;
+
+	playerState.hasMoveTarget = false;
+	playerState.turnActive = false;
+	playerState.turnTargetFacing = -1;
+	playerState.turnFirstFrame = -1;
+	playerState.turnLastFrame = -1;
+	playerState.turnEndFrame = -1;
+	playerState.turnPlayBackwards = false;
+	playerState.attackActive = true;
+	playerState.attackFirstFrame = range.firstFrame;
+	playerState.attackLastFrame = range.lastFrame;
+	playerState.attackContactFrame =
+		range.firstFrame + Player::resolveCombatLoadoutContactFrameOffset(playerState.combatLoadout);
+	playerState.attackResumeFacing = range.resumeFacing;
+	playerState.attackSoundPlayed = resolvePlayerAttackSoundSet(playerState.combatLoadout) == nullptr;
+	playerState.attackSoundFrame = playerState.attackSoundPlayed
+		? -1
+		: (range.firstFrame + kPlayerAttackSoundTriggerFrameOffset);
+	playerState.attackContactResolved = false;
+	playerState.nextMovementTick = 0;
+	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
+	playerState.entity->setAnimationRate(kRoomPlayerAttackAnimationRate);
+	playerState.entity->setCurrentFrame(range.firstFrame);
+	return true;
+}
+
 static int clampRoomMovementY(const StartupRoomSetupState &state, int screenY) {
 	if (!Player::supportsMovementBand(state))
 		return screenY;
@@ -729,41 +799,33 @@ bool Player::setIdleAnimation(StartupRoomPlayerState &playerState, int facing) {
 
 bool Player::startAttackAnimation(const StartupRoomSetupState &state,
 		StartupRoomPlayerState &playerState, const Common::Point &mousePos) {
-	if (!playerState.entity || playerState.attackActive)
-		return false;
-
 	PlayerAttackAnimationRange range;
 	if (!resolvePlayerAttackAnimationRange(state, playerState, mousePos, range))
 		return false;
-
-	playerState.hasMoveTarget = false;
-	playerState.turnActive = false;
-	playerState.turnTargetFacing = -1;
-	playerState.turnFirstFrame = -1;
-	playerState.turnLastFrame = -1;
-	playerState.turnEndFrame = -1;
-	playerState.turnPlayBackwards = false;
-	playerState.attackActive = true;
-	playerState.attackFirstFrame = range.firstFrame;
-	playerState.attackLastFrame = range.lastFrame;
-	playerState.attackContactFrame =
-		range.firstFrame + resolveCombatLoadoutContactFrameOffset(playerState.combatLoadout);
-	playerState.attackResumeFacing = range.resumeFacing;
-	playerState.attackSoundPlayed = resolvePlayerAttackSoundSet(playerState.combatLoadout) == nullptr;
-	playerState.attackSoundFrame = playerState.attackSoundPlayed
-		? -1
-		: (range.firstFrame + kPlayerAttackSoundTriggerFrameOffset);
-	playerState.attackContactResolved = false;
-	playerState.nextMovementTick = 0;
-	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
-	playerState.entity->setAnimationRate(kRoomPlayerAttackAnimationRate);
-	playerState.entity->setCurrentFrame(range.firstFrame);
+	if (!startResolvedAttackAnimation(playerState, range))
+		return false;
 	debugC(1, kDebugScene,
 		"Harvester: player attack animation frames=%d..%d resume_facing=%d cursor=(%d,%d)",
 		range.firstFrame, range.lastFrame, range.resumeFacing, mousePos.x, mousePos.y);
 	return true;
 }
 
+bool Player::startKeyboardAttackAnimation(const StartupRoomSetupState &state,
+		StartupRoomPlayerState &playerState, bool attackLeft, bool attackRight, bool attackUp, bool attackDown) {
+	(void)state;
+
+	PlayerAttackAnimationRange range;
+	if (!resolveKeyboardAttackAnimationRange(playerState, attackLeft, attackRight, attackUp, attackDown, range))
+		return false;
+	if (!startResolvedAttackAnimation(playerState, range))
+		return false;
+	debugC(1, kDebugScene,
+		"Harvester: player keyboard attack animation frames=%d..%d resume_facing=%d input=(L=%d R=%d U=%d D=%d) facing=%d",
+		range.firstFrame, range.lastFrame, range.resumeFacing,
+		attackLeft, attackRight, attackUp, attackDown, playerState.facing);
+	return true;
+}
+
 bool Player::updateAttackAnimationState(HarvesterEngine &engine,
 		StartupRoomPlayerState &playerState) {
 	if (!playerState.attackActive || !playerState.entity)
diff --git a/engines/harvester/player.h b/engines/harvester/player.h
index a77be2ae690..60311e4c053 100644
--- a/engines/harvester/player.h
+++ b/engines/harvester/player.h
@@ -51,6 +51,8 @@ public:
 	static bool setIdleAnimation(StartupRoomPlayerState &playerState, int facing);
 	static bool startAttackAnimation(const StartupRoomSetupState &state,
 		StartupRoomPlayerState &playerState, const Common::Point &mousePos);
+	static bool startKeyboardAttackAnimation(const StartupRoomSetupState &state,
+		StartupRoomPlayerState &playerState, bool attackLeft, bool attackRight, bool attackUp, bool attackDown);
 	static bool updateAttackAnimationState(HarvesterEngine &engine, StartupRoomPlayerState &playerState);
 	static bool startTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing);
 	static bool updateTurnAnimationState(StartupRoomPlayerState &playerState);
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index e0c32436515..cf4256db672 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -65,6 +65,8 @@ static const int kDefaultMonsterAttackContactFrameOffset = 2;
 static const int kMuckeyAttackContactFrameOffset = 7;
 static const float kNativeMonsterPursuitZTolerance = 2.0f;
 static const float kNativeMonsterHorizontalWaypointTolerance = 50.0f;
+static const uint32 kNativeKeyboardAttackRepeatTicks = 25;
+static const int kNativeKeyboardAttackSideWindow = 30;
 
 static int roundRoomCombatFloat(float value);
 
@@ -369,6 +371,14 @@ static int computeRuntimeEntityHorizontalGap(const RuntimeEntity &left, const Ru
 	return computeRectAxisGap(leftRect.left, leftRect.right, rightRect.left, rightRect.right);
 }
 
+static bool doRuntimeEntityDepthExtentsOverlap(const RuntimeEntity &first, const RuntimeEntity &second) {
+	const float firstZMin = first.getZ();
+	const float firstZMax = firstZMin + first.getZExtent();
+	const float secondZMin = second.getZ();
+	const float secondZMax = secondZMin + second.getZExtent();
+	return !(firstZMax < secondZMin || secondZMax < firstZMin);
+}
+
 static bool areCombatantsWithinNativeCloseRange(const StartupRoomSetupState &state,
 		const RuntimeEntity &attacker, float attackerZ,
 		const RuntimeEntity &target, float targetZ, int engageDistance) {
@@ -572,9 +582,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		bool moveRight = false;
 		bool moveUp = false;
 		bool moveDown = false;
+		bool attackModifierHeld = false;
 		Common::String pendingRegionName;
 		Common::String pendingRoomChange;
 		uint32 lastLeftButtonReleaseTick = 0;
+		uint32 nextKeyboardAttackAllowedTick = 0;
 		StartupRoomIdleAnimationState idleState;
 		bool needsRedraw = true;
 		Common::String carriedRoomItemName;
@@ -1289,6 +1301,125 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			"Harvester: combat player target capture class='none' cursor=(%d,%d)",
 			_mousePos.x, _mousePos.y);
 	};
+	auto capturePlayerAttackTargetForKeyboard = [&](bool attackLeft, bool attackRight, bool attackUp, bool attackDown) {
+		playerState.attackTargetName.clear();
+		playerState.attackTargetClassId = -1;
+		if (!playerState.entity)
+			return;
+
+		enum KeyboardAttackSide {
+			kKeyboardAttackSideNone,
+			kKeyboardAttackSideLeft,
+			kKeyboardAttackSideRight
+		};
+		enum KeyboardAttackBand {
+			kKeyboardAttackBandMid,
+			kKeyboardAttackBandUpper,
+			kKeyboardAttackBandLower
+		};
+
+		KeyboardAttackSide attackSide = kKeyboardAttackSideNone;
+		KeyboardAttackBand attackBand = kKeyboardAttackBandMid;
+		if (attackRight) {
+			attackSide = kKeyboardAttackSideRight;
+			attackBand = kKeyboardAttackBandMid;
+		} else if (attackLeft) {
+			attackSide = kKeyboardAttackSideLeft;
+			attackBand = kKeyboardAttackBandMid;
+		} else if (attackUp) {
+			if (playerState.facing == 2) {
+				attackSide = kKeyboardAttackSideRight;
+				attackBand = kKeyboardAttackBandUpper;
+			} else if (playerState.facing == 1) {
+				attackSide = kKeyboardAttackSideLeft;
+				attackBand = kKeyboardAttackBandUpper;
+			}
+		} else if (attackDown) {
+			if (playerState.facing == 2) {
+				attackSide = kKeyboardAttackSideRight;
+				attackBand = kKeyboardAttackBandLower;
+			} else if (playerState.facing == 1) {
+				attackSide = kKeyboardAttackSideLeft;
+				attackBand = kKeyboardAttackBandLower;
+			}
+		}
+		if (attackSide == kKeyboardAttackSideNone) {
+			debugC(1, kDebugCombat,
+				"Harvester: combat player keyboard target capture class='none' input=(L=%d R=%d U=%d D=%d) facing=%d reason='no native attack family'",
+				attackLeft, attackRight, attackUp, attackDown, playerState.facing);
+			return;
+		}
+
+		const Common::Rect playerRect = playerState.entity->getScreenRect();
+		const int playerLeft = playerRect.left;
+		const int playerRight = playerRect.right;
+		const int playerCenterX = playerState.centerX;
+		RuntimeEntity *bestEntity = nullptr;
+		Common::String bestName;
+		int bestClassId = -1;
+		int bestLeadingEdge = attackSide == kKeyboardAttackSideLeft ? -0x7fffffff : 0x7fffffff;
+
+		auto considerTarget = [&](RuntimeEntity *targetEntity, const Common::String &targetName, int classId) {
+			if (!targetEntity || !targetEntity->isVisible() ||
+					!doRuntimeEntityDepthExtentsOverlap(*playerState.entity, *targetEntity)) {
+				return;
+			}
+
+			const Common::Rect targetRect = targetEntity->getScreenRect();
+			const int targetLeft = targetRect.left;
+			const int targetCenterX = targetLeft + (targetRect.width() / 2);
+			bool matchesDirection = false;
+			if (attackSide == kKeyboardAttackSideLeft) {
+				if (attackBand == kKeyboardAttackBandMid)
+					matchesDirection = targetLeft < playerCenterX;
+				else
+					matchesDirection =
+						targetLeft < playerCenterX && targetCenterX > playerLeft - kNativeKeyboardAttackSideWindow;
+				if (!matchesDirection || targetLeft <= bestLeadingEdge)
+					return;
+			} else {
+				if (attackBand == kKeyboardAttackBandMid)
+					matchesDirection = playerCenterX < targetLeft;
+				else
+					matchesDirection =
+						playerCenterX < targetLeft && targetCenterX < playerRight + kNativeKeyboardAttackSideWindow;
+				if (!matchesDirection || targetLeft >= bestLeadingEdge)
+					return;
+			}
+
+			bestEntity = targetEntity;
+			bestName = targetName;
+			bestClassId = classId;
+			bestLeadingEdge = targetLeft;
+		};
+
+		for (StartupNpcRecord &npc : scene.state.roomNpcs) {
+			if (!npc.visible || npc.deathOrMonsterfyFlag)
+				continue;
+			considerTarget(findSceneRuntimeEntity(npc.npcName), npc.npcName, kRuntimeEntityClassNpc);
+		}
+		for (StartupMonsterRecord &monster : scene.state.roomMonsters) {
+			if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
+				continue;
+			considerTarget(findSceneRuntimeEntity(monster.monsterName), monster.monsterName, kRuntimeEntityClassMonster);
+		}
+
+		if (!bestEntity) {
+			debugC(1, kDebugCombat,
+				"Harvester: combat player keyboard target capture class='none' input=(L=%d R=%d U=%d D=%d) facing=%d reason='no directional target'",
+				attackLeft, attackRight, attackUp, attackDown, playerState.facing);
+			return;
+		}
+
+		playerState.attackTargetName = bestName;
+		playerState.attackTargetClassId = bestClassId;
+		debugC(1, kDebugCombat,
+			"Harvester: combat player keyboard target capture class='%s' name='%s' input=(L=%d R=%d U=%d D=%d) facing=%d target_rect=(%d,%d)-(%d,%d)",
+			describeCombatTargetClass(bestClassId), bestName.c_str(),
+			attackLeft, attackRight, attackUp, attackDown, playerState.facing,
+			bestEntity->getScreenRect().left, bestEntity->getScreenRect().top,
+			bestEntity->getScreenRect().right, bestEntity->getScreenRect().bottom);
+	};
 	auto resolvePlayerAttackContact = [&]() -> Common::Error {
 		if (!playerState.attackActive || playerState.attackContactResolved || !playerState.entity ||
 				playerState.attackContactFrame < 0 ||
@@ -2286,6 +2417,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				}
 
 				notePlayerActivity();
+				if (event.kbd.hasFlags(Common::KBD_CTRL))
+					attackModifierHeld = true;
 				if (idleState.active || idleState.exiting) {
 					if (Player::requestIdleAnimationExit(scene.state, playerState, idleState))
 						needsRedraw = true;
@@ -2322,7 +2455,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					}
 				}
 
-				if (event.kbd.keycode == Common::KEYCODE_LEFT)
+				if (event.kbd.keycode == Common::KEYCODE_LCTRL ||
+						event.kbd.keycode == Common::KEYCODE_RCTRL) {
+					attackModifierHeld = true;
+				} else if (event.kbd.keycode == Common::KEYCODE_LEFT)
 					moveLeft = true;
 				else if (event.kbd.keycode == Common::KEYCODE_RIGHT)
 					moveRight = true;
@@ -2377,7 +2513,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				}
 				break;
 			case Common::EVENT_KEYUP:
-				if (event.kbd.keycode == Common::KEYCODE_LEFT)
+				if (event.kbd.keycode == Common::KEYCODE_LCTRL ||
+						event.kbd.keycode == Common::KEYCODE_RCTRL)
+					attackModifierHeld = false;
+				else if (event.kbd.keycode == Common::KEYCODE_LEFT)
 					moveLeft = false;
 				else if (event.kbd.keycode == Common::KEYCODE_RIGHT)
 					moveRight = false;
@@ -2410,12 +2549,36 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		if (Player::updateAttackAnimationState(_engine, playerState)) {
 			needsRedraw = true;
 		}
+		const bool keyboardAttackRequested =
+			attackModifierHeld && (moveLeft || moveRight || moveUp || moveDown);
+		if (!playerState.attackActive && keyboardAttackRequested &&
+				!idleState.active && !idleState.exiting) {
+			const uint32 now = Player::getRuntimeClockTicks();
+			if (nextKeyboardAttackAllowedTick == 0 || (int32)(now - nextKeyboardAttackAllowedTick) >= 0) {
+				capturePlayerAttackTargetForKeyboard(moveLeft, moveRight, moveUp, moveDown);
+				if (Player::startKeyboardAttackAnimation(
+						scene.state, playerState, moveLeft, moveRight, moveUp, moveDown)) {
+					nextKeyboardAttackAllowedTick = now + kNativeKeyboardAttackRepeatTicks;
+					notePlayerActivity();
+					debugC(1, kDebugCombat,
+						"Harvester: combat player keyboard attack start loadout=%d target_class='%s' target='%s' input=(L=%d R=%d U=%d D=%d) facing=%d",
+						playerState.combatLoadout,
+						describeCombatTargetClass(playerState.attackTargetClassId),
+						playerState.attackTargetName.c_str(),
+						moveLeft, moveRight, moveUp, moveDown, playerState.facing);
+					needsRedraw = true;
+				} else {
+					playerState.attackTargetName.clear();
+					playerState.attackTargetClassId = -1;
+				}
+			}
+		}
 		if (!playerState.attackActive && Player::updateTurnAnimationState(playerState)) {
 			playerAdvancedThisFrame = true;
 			needsRedraw = true;
 		}
 
-		if (!playerState.attackActive && !idleState.active && !idleState.exiting) {
+		if (!playerState.attackActive && !keyboardAttackRequested && !idleState.active && !idleState.exiting) {
 			if (Player::stepKeyboardMovement(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
 					playerState, moveLeft, moveRight, moveUp, moveDown)) {
 				playerAdvancedThisFrame = true;
@@ -2434,7 +2597,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				needsRedraw = true;
 			}
 
-			if (!showingInspectText && !moveLeft && !moveRight && !moveUp && !moveDown &&
+			if (!showingInspectText && !keyboardAttackRequested &&
+					!moveLeft && !moveRight && !moveUp && !moveDown &&
 					!playerState.hasMoveTarget && !playerState.turnActive &&
 					playerState.entity && playerState.facing >= 0 &&
 					!Player::isIdleAnimationExcludedRoom(scene.state.roomName) &&


Commit: e7cdca8197ac0aa94a7471e82d13885d0bf7be9a
    https://github.com/scummvm/scummvm/commit/e7cdca8197ac0aa94a7471e82d13885d0bf7be9a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:48+02:00

Commit Message:
HARVESTER: Restore monster chase and death states

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index c9b2d8fc138..18e680b88d7 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1026,6 +1026,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - It handles class-4/5/6 actor entities, advances animation/state transitions, maintains pursuit spacing against the player using `engage_distance`, applies room Z bounds and vertical motion, fires frame-timed sound hooks, resolves hit damage through `damage_amount`, and returns `0` when the caller should remove the actor entity from the world list.
   - For class-6 monsters specifically, pursuit is not a free-running room-script heuristic: the native state machine first closes base Z to within `g_player_combat_avatar->z +/- 2.0`, then compares center-to-center X against `engage_distance`, and only arms the close-range attack picker after that spacing gate is satisfied.
   - The same class-6 path derives its horizontal chase waypoint from the player's live left edge plus the current player/monster frame widths and treats that waypoint as satisfied within `50.0 * depth_scale` pixels, so native monster pursuit does not require exact center equality before it can stop walking and attack.
+  - Fresh monster spawns do not wait in a neutral idle loop: `spawn_monster_entity_from_record` immediately seeds desired state `0x07` or `0x0e` from the monster's placement relative to the player, which is the native locomotion handoff into left/right pursuit.
   - It also contains the confirmed NPC-to-monster replacement path used by the `change2monster` flow.
   - The attack-state reader side is now bounded directly from states `0x16` through `0x1b`:
     - it treats `+0x1134` as the attack sample count and randomly selects from `+0x1148/+0x114c/+0x1150`
@@ -1047,6 +1048,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - Runtime byte `+0x118e` is the death-bank availability byte, with one shared hit-reaction gate in its low bit.
     - bit `0x01` is the third hit-reaction gate used by states `0x1e/0x21`
     - bits `0x02`, `0x04`, and `0x08` gate the death-state families `0x28/0x29/0x2e/0x2f`, `0x2a/0x2b/0x30/0x31`, and `0x2c/0x2d/0x32/0x33`
+  - Once `current_hit_points < 1`, native class-6 monsters do not disappear immediately. `update_actor_runtime_state` pushes them into facing-specific death states `0x28/0x29`, `0x2a/0x2b`, or `0x2c/0x2d` according to gore/damage-type availability, then leaves desired state `0x38` latched until the last death frame dispatches the monster record's `on_death_action_tag`.
   - Runtime byte `+0x11b4` is now bounded for the player-combat path:
     - `parse_command_record` maps room-event commands `PAUSE_PC` / `RESUME_PC` to action cases `0x25` / `0x26`, and those cases set and clear `g_player + 0x11b4`
     - `update_actor_runtime_state` consults that byte only for class-5 player combat-avatar updates and returns early while it is set
diff --git a/TRACKER.md b/TRACKER.md
index 165ae275073..5be6de0d3e0 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 21, 2026, re-opened native class-6 monster handling in `update_actor_runtime_state @ 0x4d750` and `spawn_monster_entity_from_record @ 0x53a10` after a fresh ScummVM Johnson-monster repro showed the spawned monster standing in place until the player killed it, and then disappearing instantly with no death animation.
+- Confirmed two native details the current room loop was still skipping: class-6 spawns seed desired locomotion state `0x07` or `0x0e` immediately from the monster's placement relative to the player, and once `current_hit_points < 1` the actor runtime enters facing-specific death states `0x28/0x29`, `0x2a/0x2b`, or `0x2c/0x2d` before desired state `0x38` dispatches the monster record's `on_death_action_tag` on the last death frame. Patched `engines/harvester/room.cpp` so pursuit now measures live player/monster geometry instead of the stale scripted center when deciding whether chase spacing is satisfied, logs the recovered live-center/waypoint values, and defers monster removal until a recovered death-bank frame run finishes and only then executes the room action tag. Rebuilt `engines/harvester/room.o` successfully.
 - On March 21, 2026, re-opened native `update_player_combat_avatar_state @ 0x553a0` in the live `HARVEST.LE` Ghidra session after confirming ScummVM still lacked the keyboard combat path that lets Ctrl plus the arrow keys drive room attacks without the mouse.
 - Confirmed the keyboard-only attack selector is explicit, not a generic movement override: scan-code `0x1d` (Ctrl) gates the branch, right/left immediately pick the mid side-attack banks `0x1a` / `0x78..0x81` and `0x17` / `0x5a..0x63`, while up/down only choose `0x1b` / `0x6e..0x77`, `0x18` / `0x50..0x59`, `0x19` / `0x82..0x8b`, or `0x16` / `0x64..0x6d` when the current facing family is already right or left. Recovered from the same native path that keyboard contact chooses the nearest live target on the selected side, with upper/lower attacks also requiring the target center to remain inside a `30`-pixel horizontal side window and the live depth spans to overlap. Patched `engines/harvester/player.{h,cpp}` and `engines/harvester/room.cpp` so Ctrl+arrow now starts the recovered attack banks, suppresses walking while the modifier is held, rate-limits repeat starts like the native loop, pre-captures a native-style directional target for melee and projectile contact, and logs keyboard attack starts through the existing `combat` debug channel. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 - On March 21, 2026, re-opened native `update_actor_runtime_state @ 0x4d750` in the live `HARVEST.LE` Ghidra session after a fresh monstrified-NPC combat repro still showed class-6 monsters snapping onto the player's column and looping their walk cycle without closing or landing hits reliably.
@@ -167,6 +169,7 @@
 
 ## Next Suggested Action
 
+- Immediate: rerun the Johnson monster or another stock-monster room with `--debugflags=combat`. Confirm the new `live_center_dx` chase logs now appear before contact, the monster actually closes using the recovered live-width waypoint instead of idling on the wrong column, and a lethal player hit now prints `combat monster death start` followed by `combat monster death complete` before the on-death room action fires.
 - Immediate: run a live Harvester validation pass with `--debugflags=combat` in one room where the player can swing at an NPC/monster without the mouse. Hold Ctrl plus Left/Right/Up/Down and confirm ScummVM now enters the recovered side-attack banks without walking, reacquires the correct native-style directional target between repeated swings, and still applies melee/projectile damage on the confirmed contact frame.
 - Immediate: run a live Harvester validation pass with `combat` debug enabled in one room that monsterfies an NPC plus one stock-monster room. Confirm the new logs print `combat player target capture`, `combat monster chase move|stalled`, `combat monster attack start`, and `combat ... hit|miss` at contact, that a newly monstrified NPC no longer freezes into an in-place walk cycle on the player's column, and that both player swings and monster swings now change HP whenever the recovered close-range gate is satisfied.
 - Immediate: run a live Harvester validation pass in one room with an NPC that has `monsterfy_target_name` plus one plain monster room. Confirm that right-clicking the NPC now latches the native death/monsterfy transition on the recovered contact frame, spawns the replacement monster when applicable, sets `JIMMY_ATTACKED` if Jimmy is the victim, and that the spawned/active monster now closes to `engage_distance`, plays its attack bank, and removes player HP on contact instead of remaining a passive walk-cycle prop.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index cf4256db672..6db5b782f1a 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -90,6 +90,15 @@ struct RoomAttackAnimationRange {
 	int resumeFacing = 0;
 };
 
+struct RoomDeathAnimationRange {
+	RoomDeathAnimationRange() {}
+	RoomDeathAnimationRange(int firstFrame, int lastFrame)
+		: firstFrame(firstFrame), lastFrame(lastFrame) {}
+
+	int firstFrame = -1;
+	int lastFrame = -1;
+};
+
 struct RoomMonsterCombatState {
 	bool attackActive = false;
 	int attackFirstFrame = -1;
@@ -97,6 +106,10 @@ struct RoomMonsterCombatState {
 	int attackContactFrame = -1;
 	int attackResumeFacing = -1;
 	bool attackSoundPlayed = false;
+	bool deathActive = false;
+	int deathFirstFrame = -1;
+	int deathLastFrame = -1;
+	int deathDamageType = 0;
 	uint32 nextMovementTick = 0;
 	uint32 nextAttackAllowedTick = 0;
 };
@@ -349,6 +362,49 @@ static bool resolveRoomActorAttackAnimationRange(const StartupRoomSetupState &st
 	return true;
 }
 
+static bool runtimeEntityHasFrameRange(const RuntimeEntity &entity, int firstFrame, int lastFrame) {
+	return entity.hasFrames() && firstFrame >= 0 && entity.getLastFrame() >= lastFrame;
+}
+
+static bool resolveMonsterDeathAnimationRange(const RuntimeEntity &entity, int facing,
+		int deathDamageType, bool goreEnabled, RoomDeathAnimationRange &range) {
+	const bool preferLeftBank = facing == 1;
+	const RoomDeathAnimationRange rightBludge(0xb0, 0xb9);
+	const RoomDeathAnimationRange leftBludge(0xba, 0xc3);
+	const RoomDeathAnimationRange rightSlash(0x100, 0x109);
+	const RoomDeathAnimationRange leftSlash(0x10a, 0x113);
+	const RoomDeathAnimationRange rightProjectile(0x114, 0x11d);
+	const RoomDeathAnimationRange leftProjectile(0x11e, 0x127);
+
+	const RoomDeathAnimationRange primaryBludge = preferLeftBank ? leftBludge : rightBludge;
+	const RoomDeathAnimationRange primarySlash = preferLeftBank ? leftSlash : rightSlash;
+	const RoomDeathAnimationRange primaryProjectile = preferLeftBank ? leftProjectile : rightProjectile;
+	const RoomDeathAnimationRange fallbackBludge = preferLeftBank ? rightBludge : leftBludge;
+	const RoomDeathAnimationRange fallbackSlash = preferLeftBank ? rightSlash : leftSlash;
+	const RoomDeathAnimationRange fallbackProjectile = preferLeftBank ? rightProjectile : leftProjectile;
+
+	auto chooseIfAvailable = [&](const RoomDeathAnimationRange &candidate) {
+		if (!runtimeEntityHasFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
+			return false;
+		range = candidate;
+		return true;
+	};
+
+	if ((!goreEnabled || deathDamageType == 1) &&
+			(chooseIfAvailable(primaryBludge) || chooseIfAvailable(fallbackBludge)))
+		return true;
+	if (deathDamageType == 2 &&
+			(chooseIfAvailable(primarySlash) || chooseIfAvailable(fallbackSlash)))
+		return true;
+	if (deathDamageType == 4 &&
+			(chooseIfAvailable(primaryProjectile) || chooseIfAvailable(fallbackProjectile)))
+		return true;
+
+	return chooseIfAvailable(primaryBludge) || chooseIfAvailable(primarySlash) ||
+		chooseIfAvailable(primaryProjectile) || chooseIfAvailable(fallbackBludge) ||
+		chooseIfAvailable(fallbackSlash) || chooseIfAvailable(fallbackProjectile);
+}
+
 static int stepTowardsRoomCombatInt(int current, int target, int step) {
 	if (step <= 0 || current == target)
 		return current;
@@ -458,6 +514,10 @@ static int resolveMonsterAttackContactFrameOffset(const StartupMonsterRecord &mo
 		: kDefaultMonsterAttackContactFrameOffset;
 }
 
+static void clearRoomMonsterCombatState(RoomMonsterCombatState &combatState) {
+	combatState = RoomMonsterCombatState();
+}
+
 RoomSystem::RoomSystem(HarvesterEngine &engine, Common::Point &mousePos,
 		InventorySystem &inventory)
 	: _engine(engine), _mousePos(mousePos), _inventory(inventory) {
@@ -1510,18 +1570,46 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			return Common::kNoError;
 		}
 
-		monster->active = false;
-		monster->visible = false;
 		const int monsterIndex = findRoomMonsterIndexByName(monster->monsterName);
-		if (monsterIndex >= 0 && monsterIndex < (int)monsterCombatStates.size())
-			monsterCombatStates[(uint)monsterIndex] = RoomMonsterCombatState();
+		RoomMonsterCombatState *combatState =
+			monsterIndex >= 0 && monsterIndex < (int)monsterCombatStates.size()
+				? &monsterCombatStates[(uint)monsterIndex]
+				: nullptr;
+		if (combatState)
+			clearRoomMonsterCombatState(*combatState);
 		(void)startupScript->syncRuntimeMonsterRecord(*monster);
 
-		StartupInteractionResult interaction;
-		interaction.mutatedRuntimeState = true;
+		RoomDeathAnimationRange deathRange;
+		if (combatState && resolveMonsterDeathAnimationRange(*monsterEntity, monster->facing,
+				damageType, _engine.isGoreEnabled(), deathRange)) {
+			combatState->deathActive = true;
+			combatState->deathFirstFrame = deathRange.firstFrame;
+			combatState->deathLastFrame = deathRange.lastFrame;
+			combatState->deathDamageType = damageType;
+			combatState->nextMovementTick = 0;
+			combatState->nextAttackAllowedTick = 0;
+			monsterEntity->setAnimationFrameRange(deathRange.firstFrame, deathRange.lastFrame, false);
+			monsterEntity->setAnimationRate(kRoomMonsterAnimationRate);
+			monsterEntity->setAnimationEnabled(true);
+			monsterEntity->setCurrentFrame(deathRange.firstFrame);
+			if (!monster->deathSound.empty())
+				(void)_engine.playStartupSound(monster->deathSound);
+			debugC(1, kDebugCombat,
+				"Harvester: combat monster death start target='%s' damage_type=%d frames=%d..%d gore=%d on_death='%s'",
+				monster->monsterName.c_str(), damageType,
+				deathRange.firstFrame, deathRange.lastFrame,
+				_engine.isGoreEnabled(), monster->onDeathActionTag.c_str());
+			needsRedraw = true;
+			return Common::kNoError;
+		}
+
+		monster->active = false;
+		monster->visible = false;
 		debugC(1, kDebugCombat,
-			"Harvester: combat monster defeated target='%s' on_death='%s'",
+			"Harvester: combat monster defeated target='%s' on_death='%s' fallback='no death bank'",
 			monster->monsterName.c_str(), monster->onDeathActionTag.c_str());
+		StartupInteractionResult interaction;
+		interaction.mutatedRuntimeState = true;
 		if (!monster->onDeathActionTag.empty()) {
 			StartupInteractionResult deathInteraction;
 			if (startupScript->executeActionTag(monster->onDeathActionTag, deathInteraction)) {
@@ -1551,13 +1639,43 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			StartupMonsterRecord &monster = scene.state.roomMonsters[i];
 			RoomMonsterCombatState &combatState = monsterCombatStates[i];
 			RuntimeEntity *entity = findSceneRuntimeEntity(monster.monsterName);
-			if (!entity || !monster.visible || monster.currentHitPoints <= 0) {
-				combatState = RoomMonsterCombatState();
+			if (!entity || !monster.visible) {
+				clearRoomMonsterCombatState(combatState);
+				continue;
+			}
+			if (combatState.deathActive) {
+				if (entity->getCurrentFrame() == combatState.deathLastFrame) {
+					combatState.deathActive = false;
+					monster.active = false;
+					monster.visible = false;
+					(void)startupScript->syncRuntimeMonsterRecord(monster);
+					debugC(1, kDebugCombat,
+						"Harvester: combat monster death complete target='%s' damage_type=%d on_death='%s'",
+						monster.monsterName.c_str(), combatState.deathDamageType,
+						monster.onDeathActionTag.c_str());
+					clearRoomMonsterCombatState(combatState);
+
+					StartupInteractionResult interaction;
+					interaction.mutatedRuntimeState = true;
+					if (startupScript && !monster.onDeathActionTag.empty()) {
+						StartupInteractionResult deathInteraction;
+						if (startupScript->executeActionTag(monster.onDeathActionTag, deathInteraction)) {
+							interaction = deathInteraction;
+							interaction.mutatedRuntimeState = true;
+						}
+					}
+
+					return handleCombatInteraction(interaction);
+				}
+				continue;
+			}
+			if (monster.currentHitPoints <= 0) {
+				clearRoomMonsterCombatState(combatState);
 				continue;
 			}
 
 			if (!monster.active || !playerState.entity || !playerAlive) {
-				combatState = RoomMonsterCombatState();
+				clearRoomMonsterCombatState(combatState);
 				needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
 				continue;
 			}
@@ -1617,28 +1735,39 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			const int previousX = monster.posX;
 			const int previousY = monster.posY;
 			const int previousZ = monster.posZ;
-			const int dx = playerState.centerX - monster.posX;
-			const int absDx = ABS(dx);
+			const Common::Rect playerRect = playerState.entity->getScreenRect();
+			const Common::Rect monsterRect = entity->getScreenRect();
+			const int playerCenterX = playerRect.left + playerRect.width() / 2;
+			const int monsterCenterX = monsterRect.left + monsterRect.width() / 2;
+			const int liveCenterDx = playerCenterX - monsterCenterX;
+			const int absLiveCenterDx = ABS(liveCenterDx);
+			const int monsterLiveCenterOffset = monsterCenterX - monster.posX;
 			const int playerTargetZ = roundRoomCombatFloat(playerState.z);
 			const float zDelta = playerState.z - (float)monster.posZ;
 			const float absZDelta = zDelta >= 0.0f ? zDelta : -zDelta;
 			const int engageDistance = MAX(0, monster.engageDistance);
+			const int liveWaypointTolerance = roundRoomCombatFloat(
+				Player::computeDepthScale(scene.state,
+					MIN<float>(playerState.z, (float)monster.posZ)) * kNativeMonsterHorizontalWaypointTolerance);
 			bool attemptedMoveX = false;
 			bool attemptedMoveZ = false;
-			if (absDx > engageDistance) {
-				attemptedMoveX = true;
-				const int step = MAX<int>(1, roundRoomCombatFloat(
-					Player::computeDepthScale(scene.state, (float)monster.posZ) * (float)horizontalStepBase));
-				const int direction = dx < 0 ? -1 : 1;
-				const int desiredX = playerState.centerX - direction * engageDistance;
-				monster.posX = stepTowardsRoomCombatInt(monster.posX, desiredX, step);
-				monster.posX = CLIP<int>(monster.posX, monster.minXBound, monster.maxXBound);
-			}
 			if (absZDelta > kNativeMonsterPursuitZTolerance) {
 				attemptedMoveZ = true;
 				monster.posZ = stepTowardsRoomCombatInt(monster.posZ, playerTargetZ, depthStep);
 				monster.posZ = (int)clampRoomDepthForEvent(scene.state, (float)monster.posZ);
 				monster.posY = mapRoomDepthToScreenYForCombat(scene.state, (float)monster.posZ, monster.posY);
+			} else if (absLiveCenterDx > engageDistance) {
+				const int desiredLiveCenterX = monsterCenterX < playerCenterX
+					? playerRect.left - engageDistance + monsterRect.width() / 2
+					: playerRect.right + engageDistance - monsterRect.width() / 2;
+				if (ABS(monsterCenterX - desiredLiveCenterX) > liveWaypointTolerance) {
+					attemptedMoveX = true;
+					const int step = MAX<int>(1, roundRoomCombatFloat(
+						Player::computeDepthScale(scene.state, (float)monster.posZ) * (float)horizontalStepBase));
+					const int desiredLogicalCenterX = desiredLiveCenterX - monsterLiveCenterOffset;
+					monster.posX = stepTowardsRoomCombatInt(monster.posX, desiredLogicalCenterX, step);
+					monster.posX = CLIP<int>(monster.posX, monster.minXBound, monster.maxXBound);
+				}
 			}
 
 			const bool moved = monster.posX != previousX || monster.posY != previousY || monster.posZ != previousZ;
@@ -1653,8 +1782,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				if (startupScript)
 					(void)startupScript->syncRuntimeMonsterRecord(monster);
 				debugC(1, kDebugCombat,
-					"Harvester: combat monster chase move monster='%s' center_dx=%d z_delta=%.2f engage=%d from=(%d,%d,z=%d) to=(%d,%d,z=%d) facing=%d",
-					monster.monsterName.c_str(), dx, (double)zDelta, engageDistance,
+					"Harvester: combat monster chase move monster='%s' live_center_dx=%d z_delta=%.2f engage=%d waypoint_tol=%d from=(%d,%d,z=%d) to=(%d,%d,z=%d) facing=%d",
+					monster.monsterName.c_str(), liveCenterDx, (double)zDelta, engageDistance, liveWaypointTolerance,
 					previousX, previousY, previousZ, monster.posX, monster.posY, monster.posZ, monster.facing);
 				needsRedraw = true;
 				needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, true) || needsRedraw;
@@ -1663,19 +1792,24 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 			if (attemptedMoveX || attemptedMoveZ) {
 				debugC(1, kDebugCombat,
-					"Harvester: combat monster chase stalled monster='%s' center_dx=%d z_delta=%.2f engage=%d pos=(%d,%d,z=%d) attempted_x=%d attempted_z=%d",
-					monster.monsterName.c_str(), dx, (double)zDelta, engageDistance,
+					"Harvester: combat monster chase stalled monster='%s' live_center_dx=%d z_delta=%.2f engage=%d waypoint_tol=%d pos=(%d,%d,z=%d) attempted_x=%d attempted_z=%d",
+					monster.monsterName.c_str(), liveCenterDx, (double)zDelta, engageDistance, liveWaypointTolerance,
 					monster.posX, monster.posY, monster.posZ, attemptedMoveX, attemptedMoveZ);
 			}
 
 			needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
+			const bool closeEnoughForAttack =
+				absZDelta <= kNativeMonsterPursuitZTolerance &&
+				absLiveCenterDx <= engageDistance;
 			const bool attackCooldownExpired =
 				combatState.nextAttackAllowedTick == 0 || (int32)(now - combatState.nextAttackAllowedTick) >= 0;
-			if (!attackCooldownExpired && absDx > MAX(1, engageDistance / 3))
+			if (!closeEnoughForAttack)
+				continue;
+			if (!attackCooldownExpired && absLiveCenterDx > MAX(1, engageDistance / 3))
 				continue;
 
 			RoomAttackAnimationRange range;
-			const Common::Point playerPoint(playerState.centerX, playerState.bottomY);
+			const Common::Point playerPoint(playerCenterX, playerState.bottomY);
 			(void)resolveRoomActorAttackAnimationRange(
 				scene.state, monster.posX, monster.posY, (float)monster.posZ, playerPoint, range);
 			combatState.attackActive = true;
@@ -1693,9 +1827,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			entity->setAnimationEnabled(true);
 			entity->setCurrentFrame(range.firstFrame);
 			debugC(1, kDebugCombat,
-				"Harvester: combat monster attack start monster='%s' frames=%d..%d contact=%d resume_facing=%d center_dx=%d z_delta=%.2f engage=%d next_attack_tick=%u",
+				"Harvester: combat monster attack start monster='%s' frames=%d..%d contact=%d resume_facing=%d live_center_dx=%d z_delta=%.2f engage=%d next_attack_tick=%u",
 				monster.monsterName.c_str(), range.firstFrame, range.lastFrame,
-				combatState.attackContactFrame, range.resumeFacing, dx, (double)zDelta,
+				combatState.attackContactFrame, range.resumeFacing, liveCenterDx, (double)zDelta,
 				engageDistance, combatState.nextAttackAllowedTick);
 			needsRedraw = true;
 		}


Commit: f137a1d3d6976bb9ac17989aa1e26d05f2c08e51
    https://github.com/scummvm/scummvm/commit/f137a1d3d6976bb9ac17989aa1e26d05f2c08e51
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:48+02:00

Commit Message:
HARVESTER: Restore nude man dialogue flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/nude_man_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 18e680b88d7..9256fe6206a 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -497,6 +497,11 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - Response `1` plays `0x3204` then `0x320b`, both as `PTA_MOM1` with head variant `2`.
     - Response `2` plays `0x320f` then `0x3213`, both as `PTA_MOM1` with head variant `0`.
     - Response `3` plays `0x3217`, `0x321c`, `0x3221`, and `0x3226`, where `0x3221` is the only `PC` line and the others stay on `PTA_MOM1` head variant `0`.
+  - `handle_talk_to_nude_man @ 0x2d9b0` is now bounded enough for a direct startup-room port.
+    - The broken one-byte Ghidra stub was reconstructed to the real `0x2d9b0-0x2db3e` body before comparing flow.
+    - The first no-item visit is not a single bark. It plays `0x606` / `0x60a` / `0x60e` / `0x612` / `0x618` / `0x61c`, then opens zero-based `dialog.rsp` line `0x25d`.
+    - On that first `0x25d` menu, response `1` plays `NUDE_MAN 0x628` with head variant `1`; if `get_set_sergeant_completed_first_task_state` is already set, the handler also plays `0x62c`, opens zero-based `dialog.rsp` line `0x25e`, and answers with `0x636` or `0x63f`. Response `2` instead plays `NUDE_MAN 0x648` with head variant `1`.
+    - Later no-item revisits are also multi-line: they play `PC 0x651`, `NUDE_MAN 0x655`, `PC 0x3063`, then `NUDE_MAN 0x628`. `DAY_FLAG` still short-circuits the whole handler to `NUDE_MAN 0x65c`.
   - `handle_talk_to_karin @ 0x2ea90` is now bounded enough for a direct startup-room port.
     - `KARIN_FOUND_ALIVE` plus `IN_CEM10` drives a one-shot opener: Karin first triggers `PC 0x10a5` with head variant `4`, then falls through into the normal item/no-item handling.
     - The photo evidence branch is compact and local-state driven: `CASKET_PHOTO`, `CASKET_PHOTOCOPY`, and `PHOTO_OF_WHALEY_HERRILL` all answer with `KARIN 0x10e3` on head variant `4`, while the clear `g_karin_photo_reply_override_flag` path also records the shared corpse-photo or Whaley/Herrill-photo evidence bit before that line.
diff --git a/TRACKER.md b/TRACKER.md
index 5be6de0d3e0..fea3ed760be 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 21, 2026, re-opened `handle_talk_to_nude_man @ 0x2d9b0` in the live `HARVEST.LE` Ghidra session after a Fireman’s Side repro showed ScummVM reducing the uncovered `NUDE_MAN` conversation to the single barks `0x606` and `0x651`.
+- Fixed the broken one-byte Ghidra stub first by clearing and recreating the real `0x2d9b0-0x2db3e` function body, then confirmed the missing native dialogue flow: the first visit plays `0x606` / `0x60a` / `0x60e` / `0x612` / `0x618` / `0x61c`, opens response line `0x25d`, branches to `0x628` or `0x648`, and when the Sergeant first-task state is already set continues through `0x62c` plus response line `0x25e` into `0x636` or `0x63f`; later revisits play `0x651` / `0x655` / `0x3063` / `0x628`, while `DAY_FLAG` still short-circuits to `0x65c`. Patched `engines/harvester/npc/nude_man_dialogue.cpp` to mirror that native menu/tail flow and kept the Sergeant gate mapped to the shared `dialogueStateD2f08` bit.
 - On March 21, 2026, re-opened native class-6 monster handling in `update_actor_runtime_state @ 0x4d750` and `spawn_monster_entity_from_record @ 0x53a10` after a fresh ScummVM Johnson-monster repro showed the spawned monster standing in place until the player killed it, and then disappearing instantly with no death animation.
 - Confirmed two native details the current room loop was still skipping: class-6 spawns seed desired locomotion state `0x07` or `0x0e` immediately from the monster's placement relative to the player, and once `current_hit_points < 1` the actor runtime enters facing-specific death states `0x28/0x29`, `0x2a/0x2b`, or `0x2c/0x2d` before desired state `0x38` dispatches the monster record's `on_death_action_tag` on the last death frame. Patched `engines/harvester/room.cpp` so pursuit now measures live player/monster geometry instead of the stale scripted center when deciding whether chase spacing is satisfied, logs the recovered live-center/waypoint values, and defers monster removal until a recovered death-bank frame run finishes and only then executes the room action tag. Rebuilt `engines/harvester/room.o` successfully.
 - On March 21, 2026, re-opened native `update_player_combat_avatar_state @ 0x553a0` in the live `HARVEST.LE` Ghidra session after confirming ScummVM still lacked the keyboard combat path that lets Ctrl plus the arrow keys drive room attacks without the mouse.
@@ -169,6 +171,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run a live Harvester validation pass in `FIRENSIDE` after uncovering `NUDE_MAN`. Confirm first contact now reaches the recovered `0x606` / `0x60a` / `0x60e` / `0x612` / `0x618` / `0x61c` opener, opens response line `0x25d`, and then branches into `0x628` or `0x648`; if the Sergeant first-task bit is already set, confirm the extra `0x62c` prompt opens response line `0x25e` and ends on `0x636` or `0x63f`. Re-enter the conversation afterward and confirm revisits now play `0x651` / `0x655` / `0x3063` / `0x628` instead of stopping after a single bark.
 - Immediate: rerun the Johnson monster or another stock-monster room with `--debugflags=combat`. Confirm the new `live_center_dx` chase logs now appear before contact, the monster actually closes using the recovered live-width waypoint instead of idling on the wrong column, and a lethal player hit now prints `combat monster death start` followed by `combat monster death complete` before the on-death room action fires.
 - Immediate: run a live Harvester validation pass with `--debugflags=combat` in one room where the player can swing at an NPC/monster without the mouse. Hold Ctrl plus Left/Right/Up/Down and confirm ScummVM now enters the recovered side-attack banks without walking, reacquires the correct native-style directional target between repeated swings, and still applies melee/projectile damage on the confirmed contact frame.
 - Immediate: run a live Harvester validation pass with `combat` debug enabled in one room that monsterfies an NPC plus one stock-monster room. Confirm the new logs print `combat player target capture`, `combat monster chase move|stalled`, `combat monster attack start`, and `combat ... hit|miss` at contact, that a newly monstrified NPC no longer freezes into an in-place walk cycle on the player's column, and that both player swings and monster swings now change HP whenever the recovered close-range gate is satisfied.
diff --git a/engines/harvester/npc/nude_man_dialogue.cpp b/engines/harvester/npc/nude_man_dialogue.cpp
index 6fe8148665b..afa6305a548 100644
--- a/engines/harvester/npc/nude_man_dialogue.cpp
+++ b/engines/harvester/npc/nude_man_dialogue.cpp
@@ -27,21 +27,85 @@
 
 namespace Harvester {
 
+namespace {
+
+static const int kNudeManInitialResponseLine = 0x25d;
+static const int kNudeManFollowupResponseLine = 0x25e;
+
+} // End of namespace
+
 bool NudeManDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("NUDE_MAN");
 }
 
 Common::Error NudeManDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &, DialogueSharedState &) {
+		const Common::String &, DialogueSharedState &sharedState) {
+	auto playNudeManLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "NUDE_MAN", headVariant);
+	};
+
 	if (runtime.startupScript().getFlagValue("DAY_FLAG"))
-		return runtime.playDialogueLine(0x65c, "NUDE_MAN");
+		return playNudeManLine(0x65c);
 
 	if (_state.talkStatePending) {
 		_state.talkStatePending = false;
-		return runtime.playDialogueLine(0x606, "PC");
+		const DialogueLineEntry introLines[] = {
+			{ 0x606, "PC", 4 },
+			{ 0x60a, "NUDE_MAN", 0 },
+			{ 0x60e, "PC", 0 },
+			{ 0x612, "NUDE_MAN", 0 },
+			{ 0x618, "PC", 0 },
+			{ 0x61c, "NUDE_MAN", 0 }
+		};
+		Common::Error lineError = runtime.playDialogueEntrySequence(
+			introLines, ARRAYSIZE(introLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(
+			kNudeManInitialResponseLine, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playNudeManLine(0x628, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			// Native gates the second prompt on get_set_sergeant_completed_first_task_state.
+			if (!sharedState.dialogueStateD2f08)
+				return Common::kNoError;
+
+			lineError = playNudeManLine(0x62c);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			responseIndex = 0;
+			responseError = runtime.runResponseMenu(
+				kNudeManFollowupResponseLine, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1)
+				return playNudeManLine(0x636);
+			if (responseIndex == 2)
+				return playNudeManLine(0x63f);
+			return Common::kNoError;
+		}
+
+		if (responseIndex == 2)
+			return playNudeManLine(0x648, 1);
+		return Common::kNoError;
 	}
 
-	return runtime.playDialogueLine(0x651, "PC");
+	const DialogueLineEntry revisitLines[] = {
+		{ 0x651, "PC", 0 },
+		{ 0x655, "NUDE_MAN", 0 },
+		{ 0x3063, "PC", 0 },
+		{ 0x628, "NUDE_MAN", 0 }
+	};
+	return runtime.playDialogueEntrySequence(revisitLines, ARRAYSIZE(revisitLines));
 }
 
 } // End of namespace Harvester


Commit: dbb8c742b513130434bd04b95041b823be2e8498
    https://github.com/scummvm/scummvm/commit/dbb8c742b513130434bd04b95041b823be2e8498
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:48+02:00

Commit Message:
HARVESTER: Fix Pottsdam cemetery interaction gating

Changed paths:
    engines/harvester/flow.cpp
    engines/harvester/npc/mr_potts_dialogue.cpp


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 21423e43624..23025eaab0f 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -794,6 +794,11 @@ static int resolveRoomObjectCursorSequence(const StartupObjectRecord &object, Sc
 
 static Common::String buildRoomObjectPrompt(const StartupObjectRecord &object, Script &startupScript,
 		int cursorSequence) {
+	// Native room prompts come from explicit interaction metadata. Neutral scene sprites
+	// should not surface synthetic "Examine <object id>" prompts or steal hover/click focus.
+	if (cursorSequence == kCursorSequenceNeutral)
+		return Common::String();
+
 	const Common::String label = startupScript.resolveObjectLabel(object);
 	if (label.empty())
 		return Common::String();
@@ -864,6 +869,7 @@ StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const Start
 		hoverState.promptText = buildRoomObjectPrompt(*hoverState.object, *startupScript, hoverState.cursorSequence);
 		if (hoverState.cursorSequence != kCursorSequenceNeutral || !hoverState.promptText.empty())
 			return hoverState;
+		hoverState.object = nullptr;
 	}
 	if (hoverState.region && hoverState.region->cursorEnabled) {
 		hoverState.object = nullptr;
diff --git a/engines/harvester/npc/mr_potts_dialogue.cpp b/engines/harvester/npc/mr_potts_dialogue.cpp
index 380f6e63646..727e58c7874 100644
--- a/engines/harvester/npc/mr_potts_dialogue.cpp
+++ b/engines/harvester/npc/mr_potts_dialogue.cpp
@@ -242,11 +242,13 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			lineError = playMrPottsLine(0x2c63, 2);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			return Common::kNoError;
 		}
 
 		lineError = playMrPottsLine(0x2cd9, 2);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+		return Common::kNoError;
 	}
 
 	if (runtime.startupScript().getFlagValue("POTTSDAM_DIGGING_KARIN")) {


Commit: 39480f291b98caee4c2eca7c3c8b1b23826ac62b
    https://github.com/scummvm/scummvm/commit/39480f291b98caee4c2eca7c3c8b1b23826ac62b
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:48+02:00

Commit Message:
HARVESTER: Fix native dialogue head variants

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/buster_dialogue.cpp
    engines/harvester/npc/butcher_dialogue.cpp
    engines/harvester/npc/curator_dialogue.cpp
    engines/harvester/npc/dark_woman_dialogue.cpp
    engines/harvester/npc/fireman2_dialogue.cpp
    engines/harvester/npc/lodge_chef_dialogue.cpp
    engines/harvester/npc/loomis_dialogue.cpp
    engines/harvester/npc/maint_man_dialogue.cpp
    engines/harvester/npc/mcknight_dialogue.cpp
    engines/harvester/npc/memb_dir_dialogue.cpp
    engines/harvester/npc/mother_dialogue.cpp
    engines/harvester/npc/valet_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 9256fe6206a..5db59786907 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -657,6 +657,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `play_dialogue_line` binary-searches that table by WAV id, seeks through `g_dialogue_index_stream`, and reads the subtitle text by offset/length.
 - `play_dialogue_line` at `0x7a690` is the inner dialogue-line player.
   - It resolves a `VOICE` asset, finds subtitle text for the WAV id, updates current head/textbox visuals, and waits for playback or interruption to finish.
+  - Its live call ABI is now confirmed as custom register storage rather than a named Borland convention: callers pass `wavId` in `EAX`, `speakerId` in `EDX`, and `headVariant` in `EBX`.
   - The voice path is formatted from the `VOICE` config value plus the decimal WAV id (`<VOICE><wavId>.CMP`), while portrait loads use the `HEAD` `portrait_path` base plus `.bm`.
   - Subtitle text is rendered at `(178, 12)` with width `316`, and the textbox art is selected strictly from wrapped line count:
     - 1 line -> `TEXTBOX1`
@@ -674,6 +675,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - `g_dialogue_head_bitmap_path` is the shared path buffer used by `run_npc_dialogue` and `play_dialogue_line` when selecting the current head bitmap resource.
     - `g_dialogue_head_id_slots` is the five-slot cached head-ID table used to avoid unnecessary portrait reloads within the same conversation.
     - `g_dialogue_skip_full_ui_flag` is checked at the top of `play_dialogue_line`; when nonzero, the function skips the full portrait/textbox presentation path.
+  - The decompiler warning `Removing unreachable block (ram,0x7adfb)` is a false positive from the post-line input-release stabilization loop, not an embedded lookup table or missing body chunk.
   - After picking a non-`PC` speaker, it removes the right PC head entity from the render list, refreshes the left portrait if needed, and re-adds only the speaking-side portrait before the line finishes; `run_dialogue_response_menu` and `run_dialogue_keyword_menu` inherit that render-list state, which is why native conversation menus show only the last speaker's portrait.
   - `reload_bitmap_entity_pixels_from_resource` at `0x4b7a0` refreshes the pixel payload of an already spawned bitmap entity from a same-sized XFILE bitmap resource; `play_dialogue_line` uses it to swap the currently visible left or right head portrait without respawning the entity.
 - The dialogue/text bitmap raster helpers on the same UI path are now explicit:
diff --git a/TRACKER.md b/TRACKER.md
index fea3ed760be..e73ef5e5975 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,12 +10,16 @@
 
 - Program: `HARVEST.LE`
 - Total functions: `903`
-- Named/documented: `790`
-- Remaining `FUN_*`: `113`
-- Remaining undocumented total: `113`
+- Named/documented: `791`
+- Remaining `FUN_*`: `112`
+- Remaining undocumented total: `112`
 
 ## Last Confirmed Action
 
+- On March 22, 2026, re-audited the ScummVM NPC talk handlers immediately after fixing `play_dialogue_line @ 0x7a690` to take its third argument from `EBX` instead of the bogus prior `ECX` decompile path.
+- Confirmed a clean low-risk set of portrait mismatches where ScummVM still hardcoded head variant `0` even though the native handlers now decompile with stable nonzero variants. Patched `engines/harvester/npc/maint_man_dialogue.cpp`, `buster_dialogue.cpp`, `mother_dialogue.cpp`, `butcher_dialogue.cpp`, `curator_dialogue.cpp`, `lodge_chef_dialogue.cpp`, `memb_dir_dialogue.cpp`, `mcknight_dialogue.cpp`, `dark_woman_dialogue.cpp`, `fireman2_dialogue.cpp`, `valet_dialogue.cpp`, and `loomis_dialogue.cpp` to use the recovered native variants: `MAINT_MAN` `0xdaf -> 1`, `0xdbc/0xdc4 -> 2`; Buster intro `0x163c/0x1828/0x18c5 -> 2`; `MOTHER 0x26d6 -> 3`; `BUTCHER 0x1ac4 -> 1`; `CURATOR 0x425 -> 1`; `LODGE_CHEF 0x1b10 -> 2`; `MEMB_DIR 0x2b7/0x2d5 -> 1`; McKnight `0x15d0/0x15c9/0x1587 -> 2` plus `0x15ea/0x1611/0x161b -> 1`; `DARK_WOMAN 0x4cd5 -> 1`, `0x4d03 -> 2`; `FIREMAN2 0x5b9/0x5c7 -> 2`; `VALET 0xf2c -> 1`; and Loomis `0x11a8/0x11db/0x1143 -> 1`. Rebuilt the touched NPC object files successfully in `build-vscode-harvester-debug`.
+- On March 22, 2026, re-opened `play_dialogue_line @ 0x7a690` in the live `HARVEST.LE` Ghidra session after repeated NPC-dialogue audits kept running into a suspicious decompiler warning about a removed unreachable block.
+- Confirmed from raw disassembly and live call sites that the function body is still contiguous `0x7a690-0x7af06`; the `ram,0x7adfb` warning is only a decompiler structuring artifact in the keyboard/mouse input-release stabilization loop, not a missing lookup table or a truncated body. Also fixed the more concrete metadata bug by rebuilding the function signature with custom register storage so callers now recover the real ABI `wavId -> EAX`, `speakerId -> EDX`, `headVariant -> EBX` instead of the previous bogus third-parameter `ECX` assignment; added matching function/block comments and saved `HARVEST.LE`.
 - On March 21, 2026, re-opened `handle_talk_to_nude_man @ 0x2d9b0` in the live `HARVEST.LE` Ghidra session after a Fireman’s Side repro showed ScummVM reducing the uncovered `NUDE_MAN` conversation to the single barks `0x606` and `0x651`.
 - Fixed the broken one-byte Ghidra stub first by clearing and recreating the real `0x2d9b0-0x2db3e` function body, then confirmed the missing native dialogue flow: the first visit plays `0x606` / `0x60a` / `0x60e` / `0x612` / `0x618` / `0x61c`, opens response line `0x25d`, branches to `0x628` or `0x648`, and when the Sergeant first-task state is already set continues through `0x62c` plus response line `0x25e` into `0x636` or `0x63f`; later revisits play `0x651` / `0x655` / `0x3063` / `0x628`, while `DAY_FLAG` still short-circuits to `0x65c`. Patched `engines/harvester/npc/nude_man_dialogue.cpp` to mirror that native menu/tail flow and kept the Sergeant gate mapped to the shared `dialogueStateD2f08` bit.
 - On March 21, 2026, re-opened native class-6 monster handling in `update_actor_runtime_state @ 0x4d750` and `spawn_monster_entity_from_record @ 0x53a10` after a fresh ScummVM Johnson-monster repro showed the spawned monster standing in place until the player killed it, and then disappearing instantly with no death animation.
@@ -171,6 +175,8 @@
 
 ## Next Suggested Action
 
+- Immediate: use the corrected `EBX` head-variant ABI to re-audit the still larger dialogue handlers where the fresh native decompiles now show broader mismatches than just portrait constants. Start with `handle_talk_to_valet` and `handle_talk_to_ryder`, because both now show nonzero native variants plus additional response/menu flow that the current ScummVM handlers still compress heavily.
+- Immediate: the next time an NPC conversation looks wrong, compare it against the fresh `play_dialogue_line` Ghidra state first instead of second-guessing the warning at `0x7adfb`. Use the corrected `EBX` head-variant parameter plus the saved block comments to walk subtitle lookup, portrait swaps, and input-release behavior before chasing per-NPC handler bugs.
 - Immediate: run a live Harvester validation pass in `FIRENSIDE` after uncovering `NUDE_MAN`. Confirm first contact now reaches the recovered `0x606` / `0x60a` / `0x60e` / `0x612` / `0x618` / `0x61c` opener, opens response line `0x25d`, and then branches into `0x628` or `0x648`; if the Sergeant first-task bit is already set, confirm the extra `0x62c` prompt opens response line `0x25e` and ends on `0x636` or `0x63f`. Re-enter the conversation afterward and confirm revisits now play `0x651` / `0x655` / `0x3063` / `0x628` instead of stopping after a single bark.
 - Immediate: rerun the Johnson monster or another stock-monster room with `--debugflags=combat`. Confirm the new `live_center_dx` chase logs now appear before contact, the monster actually closes using the recovered live-width waypoint instead of idling on the wrong column, and a lethal player hit now prints `combat monster death start` followed by `combat monster death complete` before the on-death room action fires.
 - Immediate: run a live Harvester validation pass with `--debugflags=combat` in one room where the player can swing at an NPC/monster without the mouse. Hold Ctrl plus Left/Right/Up/Down and confirm ScummVM now enters the recovered side-attack banks without walking, reacquires the correct native-style directional target between repeated swings, and still applies melee/projectile damage on the confirmed contact frame.
diff --git a/engines/harvester/npc/buster_dialogue.cpp b/engines/harvester/npc/buster_dialogue.cpp
index 67405a1adf8..d029612a4fa 100644
--- a/engines/harvester/npc/buster_dialogue.cpp
+++ b/engines/harvester/npc/buster_dialogue.cpp
@@ -57,16 +57,16 @@ Common::Error BusterDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (_state.introPending) {
 		_state.introPending = false;
 		_state.secondIntroPending = true;
-		return runtime.playDialogueLine(0x163c, "BUSTER");
+		return runtime.playDialogueLineWithVariant(0x163c, "BUSTER", 2);
 	}
 	if (_state.secondIntroPending) {
 		_state.secondIntroPending = false;
 		_state.thirdIntroPending = true;
-		return runtime.playDialogueLine(0x1828, "BUSTER");
+		return runtime.playDialogueLineWithVariant(0x1828, "BUSTER", 2);
 	}
 	if (_state.thirdIntroPending) {
 		_state.thirdIntroPending = false;
-		return runtime.playDialogueLine(0x18c5, "BUSTER");
+		return runtime.playDialogueLineWithVariant(0x18c5, "BUSTER", 2);
 	}
 
 	return runtime.playDialogueLine(0x1868, "BUSTER");
diff --git a/engines/harvester/npc/butcher_dialogue.cpp b/engines/harvester/npc/butcher_dialogue.cpp
index 154e3d8b733..76daec3fe29 100644
--- a/engines/harvester/npc/butcher_dialogue.cpp
+++ b/engines/harvester/npc/butcher_dialogue.cpp
@@ -65,7 +65,7 @@ Common::Error ButcherDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.playDialogueLine(0x1a9e, "BUTCHER");
 	}
 	if (usedItemName.equalsIgnoreCase("MEAT_PERMISSION"))
-		return runtime.playDialogueLine(0x1ac4, "BUTCHER");
+		return runtime.playDialogueLineWithVariant(0x1ac4, "BUTCHER", 1);
 
 	return runtime.playDialogueLine(0x1a54, "BUTCHER");
 }
diff --git a/engines/harvester/npc/curator_dialogue.cpp b/engines/harvester/npc/curator_dialogue.cpp
index efa7dbfaf48..30de64c7ea4 100644
--- a/engines/harvester/npc/curator_dialogue.cpp
+++ b/engines/harvester/npc/curator_dialogue.cpp
@@ -33,7 +33,7 @@ bool CuratorDialogueHandler::matchesNpc(const Common::String &npcName) const {
 
 Common::Error CuratorDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
-	return runtime.playDialogueLine(0x425, "CURATOR");
+	return runtime.playDialogueLineWithVariant(0x425, "CURATOR", 1);
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/dark_woman_dialogue.cpp b/engines/harvester/npc/dark_woman_dialogue.cpp
index fd7fb5f4139..6fde045c2fd 100644
--- a/engines/harvester/npc/dark_woman_dialogue.cpp
+++ b/engines/harvester/npc/dark_woman_dialogue.cpp
@@ -40,13 +40,13 @@ bool DarkWomanDialogueHandler::matchesNpc(const Common::String &npcName) const {
 Common::Error DarkWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &) {
 	if (usedItemName.equalsIgnoreCase(kHandMirrorItemName))
-		return runtime.playDialogueLine(0x4d03, "DARK_WOMAN");
+		return runtime.playDialogueLineWithVariant(0x4d03, "DARK_WOMAN", 2);
 
 	if (!_state.talkStatePending)
 		return Common::kNoError;
 
 	_state.talkStatePending = false;
-	return runtime.playDialogueLine(0x4cd5, "DARK_WOMAN");
+	return runtime.playDialogueLineWithVariant(0x4cd5, "DARK_WOMAN", 1);
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/fireman2_dialogue.cpp b/engines/harvester/npc/fireman2_dialogue.cpp
index 7290a8efb8e..be8783a2ad6 100644
--- a/engines/harvester/npc/fireman2_dialogue.cpp
+++ b/engines/harvester/npc/fireman2_dialogue.cpp
@@ -35,10 +35,10 @@ Common::Error Fireman2DialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
 	if (_state.talkStatePending) {
 		_state.talkStatePending = false;
-		return runtime.playDialogueLine(0x5b9, "FIREMAN2");
+		return runtime.playDialogueLineWithVariant(0x5b9, "FIREMAN2", 2);
 	}
 
-	return runtime.playDialogueLine(0x5c7, "FIREMAN2");
+	return runtime.playDialogueLineWithVariant(0x5c7, "FIREMAN2", 2);
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/lodge_chef_dialogue.cpp b/engines/harvester/npc/lodge_chef_dialogue.cpp
index a92495a9bcf..70f2c4da210 100644
--- a/engines/harvester/npc/lodge_chef_dialogue.cpp
+++ b/engines/harvester/npc/lodge_chef_dialogue.cpp
@@ -33,7 +33,7 @@ bool LodgeChefDialogueHandler::matchesNpc(const Common::String &npcName) const {
 
 Common::Error LodgeChefDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
-	return runtime.playDialogueLine(0x1b10, "LODGE_CHEF");
+	return runtime.playDialogueLineWithVariant(0x1b10, "LODGE_CHEF", 2);
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/loomis_dialogue.cpp b/engines/harvester/npc/loomis_dialogue.cpp
index 2160ba92b7a..c0d39c0fbf1 100644
--- a/engines/harvester/npc/loomis_dialogue.cpp
+++ b/engines/harvester/npc/loomis_dialogue.cpp
@@ -49,8 +49,9 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		runtime.assignTopicBuffer(loomisTopicBuffer, loomisTopicBufferLineIndex,
 			responseLineIndex, "Loomis topic buffer");
 	};
-	auto playLoomisLine = [&](int wavId, const char *speakerId = "LOOMIS") -> Common::Error {
-		return runtime.playDialogueLine(wavId, speakerId);
+	auto playLoomisLine = [&](int wavId, const char *speakerId = "LOOMIS",
+			int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, speakerId, headVariant);
 	};
 	auto clearLoomisTopicBuffer = [&]() {
 		loomisTopicBuffer.clear();
@@ -62,7 +63,7 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (state.returnVisitFollowupPending) {
 				state.returnVisitFollowupPending = false;
 				const DialogueLineEntry followupLines[] = {
-					{ 0x11a8, "LOOMIS", 0 },
+					{ 0x11a8, "LOOMIS", 1 },
 					{ 0x11ac, "PC", 0 },
 					{ 0x11b0, "LOOMIS", 0 }
 				};
@@ -115,7 +116,7 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			}
 
 			if (runtime.matchesResponseLine(selectedTopic, 0x57)) {
-				lineError = playLoomisLine(0x1143);
+				lineError = playLoomisLine(0x1143, "LOOMIS", 1);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				assignLoomisTopicBuffer(0x58);
@@ -196,7 +197,7 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (usedItemName.equalsIgnoreCase("INV_MAG")) {
 		if (!runtime.startupScript().getFlagValue("SHERIFF_IN_DINER")) {
 			(void)runtime.startupScript().addRuntimeObjectToInventory("INV_MAG");
-			return runtime.playDialogueLine(0x11db, "LOOMIS");
+			return runtime.playDialogueLineWithVariant(0x11db, "LOOMIS", 1);
 		}
 
 		Common::Error lineError = runtime.playDialogueLine(0x11bb, "LOOMIS");
@@ -233,7 +234,7 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.playDialogueLine(0x11cc, "LOOMIS");
 	}
 	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL"))
-		return runtime.playDialogueLine(0x1143, "LOOMIS");
+		return runtime.playDialogueLineWithVariant(0x1143, "LOOMIS", 1);
 
 	return runtime.playDialogueLine(0x1202, "LOOMIS");
 }
diff --git a/engines/harvester/npc/maint_man_dialogue.cpp b/engines/harvester/npc/maint_man_dialogue.cpp
index 170b20e1138..0cfd39911eb 100644
--- a/engines/harvester/npc/maint_man_dialogue.cpp
+++ b/engines/harvester/npc/maint_man_dialogue.cpp
@@ -34,13 +34,13 @@ bool MaintManDialogueHandler::matchesNpc(const Common::String &npcName) const {
 Common::Error MaintManDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
 	if (runtime.startupScript().getFlagValue("MAINTENANCE_MAN_FIRST_CONVERSATION"))
-		return runtime.playDialogueLine(0xdaf, "MAINT_MAN");
+		return runtime.playDialogueLineWithVariant(0xdaf, "MAINT_MAN", 1);
 
 	if (runtime.startupScript().getFlagValue("MAINTENANCE_MAN_THIRD_CONVERSATION"))
-		return runtime.playDialogueLine(0xdbc, "MAINT_MAN");
+		return runtime.playDialogueLineWithVariant(0xdbc, "MAINT_MAN", 2);
 
 	if (runtime.startupScript().getFlagValue("MAINTENANCE_MAN_FOURTH_CONVERSATION"))
-		return runtime.playDialogueLine(0xdc4, "MAINT_MAN");
+		return runtime.playDialogueLineWithVariant(0xdc4, "MAINT_MAN", 2);
 
 	return runtime.playDialogueLine(0xdb6, "MAINT_MAN");
 }
diff --git a/engines/harvester/npc/mcknight_dialogue.cpp b/engines/harvester/npc/mcknight_dialogue.cpp
index e50b439e643..b6c64d8de55 100644
--- a/engines/harvester/npc/mcknight_dialogue.cpp
+++ b/engines/harvester/npc/mcknight_dialogue.cpp
@@ -34,44 +34,48 @@ bool McknightDialogueHandler::matchesNpc(const Common::String &npcName) const {
 
 Common::Error McknightDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &) {
+	auto playMcknightLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "MCKNIGHT", headVariant);
+	};
+
 	if (runtime.startupScript().getFlagValue("LOOK_SAFE_2ND"))
-		return runtime.playDialogueLine(0x15d0, "MCKNIGHT");
+		return playMcknightLine(0x15d0, 2);
 
 	if (runtime.startupScript().getFlagValue("LOOK_SAFE_1ST") && !_state.lookSafeFirstLineGate)
-		return runtime.playDialogueLine(0x15c9, "MCKNIGHT");
+		return playMcknightLine(0x15c9, 2);
 
 	if (usedItemName.empty()) {
 		if (_state.talkStatePending) {
 			_state.talkStatePending = false;
-			return runtime.playDialogueLine(0x1587, "MCKNIGHT");
+			return playMcknightLine(0x1587, 2);
 		}
-		return runtime.playDialogueLine(0x15e4, "MCKNIGHT");
+		return playMcknightLine(0x15e4);
 	}
 
 	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-		return runtime.playDialogueLine(0x15ea, "MCKNIGHT");
+		return playMcknightLine(0x15ea, 1);
 	}
 	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
-		return runtime.playDialogueLine(0x15f2, "MCKNIGHT");
+		return playMcknightLine(0x15f2);
 	}
 	if ((usedItemName.equalsIgnoreCase("LEDGER") ||
 				usedItemName.equalsIgnoreCase("LEDGER2")) &&
 			runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
-		return runtime.playDialogueLine(0x1611, "MCKNIGHT");
+		return playMcknightLine(0x1611, 1);
 	}
 	if (usedItemName.equalsIgnoreCase("NOTE") ||
 			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
-		return runtime.playDialogueLine(0x161b, "MCKNIGHT");
+		return playMcknightLine(0x161b, 1);
 	}
 
-	return runtime.playDialogueLine(0x15de, "MCKNIGHT");
+	return playMcknightLine(0x15de);
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/memb_dir_dialogue.cpp b/engines/harvester/npc/memb_dir_dialogue.cpp
index 496ec323da7..2c4b22fdf49 100644
--- a/engines/harvester/npc/memb_dir_dialogue.cpp
+++ b/engines/harvester/npc/memb_dir_dialogue.cpp
@@ -34,13 +34,13 @@ bool MembDirDialogueHandler::matchesNpc(const Common::String &npcName) const {
 Common::Error MembDirDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
 	if (!runtime.startupScript().getFlagValue("PC_KILLED_KEWPIE"))
-		return runtime.playDialogueLine(0x2b7, "MEMB_DIR");
+		return runtime.playDialogueLineWithVariant(0x2b7, "MEMB_DIR", 1);
 
 	if (_state.kewpieKillLinePlayed)
 		return Common::kNoError;
 
 	_state.kewpieKillLinePlayed = true;
-	return runtime.playDialogueLine(0x2d5, "MEMB_DIR");
+	return runtime.playDialogueLineWithVariant(0x2d5, "MEMB_DIR", 1);
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/mother_dialogue.cpp b/engines/harvester/npc/mother_dialogue.cpp
index 5f00760dcac..80768fbafb3 100644
--- a/engines/harvester/npc/mother_dialogue.cpp
+++ b/engines/harvester/npc/mother_dialogue.cpp
@@ -37,7 +37,7 @@ Common::Error MotherDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return Common::kNoError;
 
 	_state.talkStatePending = false;
-	return runtime.playDialogueLine(0x26d6, "MOTHER");
+	return runtime.playDialogueLineWithVariant(0x26d6, "MOTHER", 3);
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/valet_dialogue.cpp b/engines/harvester/npc/valet_dialogue.cpp
index 4bc8e6e33eb..fbf70ebf9cf 100644
--- a/engines/harvester/npc/valet_dialogue.cpp
+++ b/engines/harvester/npc/valet_dialogue.cpp
@@ -33,7 +33,7 @@ bool ValetDialogueHandler::matchesNpc(const Common::String &npcName) const {
 
 Common::Error ValetDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
-	return runtime.playDialogueLine(0xf2c, "VALET");
+	return runtime.playDialogueLineWithVariant(0xf2c, "VALET", 1);
 }
 
 } // End of namespace Harvester


Commit: 40a3214db6c76db4b9f4e235c3284c004755b8dd
    https://github.com/scummvm/scummvm/commit/40a3214db6c76db4b9f4e235c3284c004755b8dd
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:49+02:00

Commit Message:
HARVESTER: Dim the town map palette at night

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/flow.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 5db59786907..16893c1d69a 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -419,6 +419,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - The selector currently only reads `initial_panel_index`; `map_x` / `map_y` remain stored script coordinates with no recovered read-side consumer in the current binary.
 - `select_town_map_destination` at `0x66460` is the town-map travel UI.
   - It loads `harvmap.pal` and `harvmap1.bm` through `harvmap4.bm`.
+  - It checks `DAY_FLAG` before both map fade points; night travel uses the same dim target as room setup, `1.0 + (-0.4) = 0.6`, instead of the daytime full-bright palette.
   - It seeds the initial map panel from `g_town_map_entrypoints`.
   - It uses `g_town_map_hotspots` to resolve the selected destination back into a named entrance record.
 - `MapLocationRecord` is now stable enough for engine use:
diff --git a/TRACKER.md b/TRACKER.md
index e73ef5e5975..7277b3a334e 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -9,13 +9,15 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `903`
+- Total functions: `906`
 - Named/documented: `791`
-- Remaining `FUN_*`: `112`
-- Remaining undocumented total: `112`
+- Remaining `FUN_*`: `115`
+- Remaining undocumented total: `115`
 
 ## Last Confirmed Action
 
+- On March 22, 2026, re-opened native `select_town_map_destination @ 0x66460` in the live `HARVEST.LE` Ghidra session after the ScummVM town map still rendered with the daytime palette at night.
+- Confirmed the native town-map travel UI checks `DAY_FLAG` at both palette-ramp sites and uses the same dim target as room setup when the flag is clear: `1.0 + (-0.4) = 0.6`. Patched `engines/harvester/flow.cpp` so the ScummVM town map now scales `HARVMAP.PAL` to `0.6f` at night instead of always using `1.0f`, and recorded the confirmed behavior in `ARCHITECTURE.md`.
 - On March 22, 2026, re-audited the ScummVM NPC talk handlers immediately after fixing `play_dialogue_line @ 0x7a690` to take its third argument from `EBX` instead of the bogus prior `ECX` decompile path.
 - Confirmed a clean low-risk set of portrait mismatches where ScummVM still hardcoded head variant `0` even though the native handlers now decompile with stable nonzero variants. Patched `engines/harvester/npc/maint_man_dialogue.cpp`, `buster_dialogue.cpp`, `mother_dialogue.cpp`, `butcher_dialogue.cpp`, `curator_dialogue.cpp`, `lodge_chef_dialogue.cpp`, `memb_dir_dialogue.cpp`, `mcknight_dialogue.cpp`, `dark_woman_dialogue.cpp`, `fireman2_dialogue.cpp`, `valet_dialogue.cpp`, and `loomis_dialogue.cpp` to use the recovered native variants: `MAINT_MAN` `0xdaf -> 1`, `0xdbc/0xdc4 -> 2`; Buster intro `0x163c/0x1828/0x18c5 -> 2`; `MOTHER 0x26d6 -> 3`; `BUTCHER 0x1ac4 -> 1`; `CURATOR 0x425 -> 1`; `LODGE_CHEF 0x1b10 -> 2`; `MEMB_DIR 0x2b7/0x2d5 -> 1`; McKnight `0x15d0/0x15c9/0x1587 -> 2` plus `0x15ea/0x1611/0x161b -> 1`; `DARK_WOMAN 0x4cd5 -> 1`, `0x4d03 -> 2`; `FIREMAN2 0x5b9/0x5c7 -> 2`; `VALET 0xf2c -> 1`; and Loomis `0x11a8/0x11db/0x1143 -> 1`. Rebuilt the touched NPC object files successfully in `build-vscode-harvester-debug`.
 - On March 22, 2026, re-opened `play_dialogue_line @ 0x7a690` in the live `HARVEST.LE` Ghidra session after repeated NPC-dialogue audits kept running into a suspicious decompiler warning about a removed unreachable block.
@@ -175,6 +177,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run a live Harvester validation pass on the town map from one daytime entrance and one nighttime entrance such as `PCHOUSE` versus `BARB_OUT` after dusk. Confirm the native-mirrored `DAY_FLAG` gate now keeps daytime `HARVMAP.PAL` at `1.0` brightness and dims the night map to `0.6` without changing hotspot labels or destination selection.
 - Immediate: use the corrected `EBX` head-variant ABI to re-audit the still larger dialogue handlers where the fresh native decompiles now show broader mismatches than just portrait constants. Start with `handle_talk_to_valet` and `handle_talk_to_ryder`, because both now show nonzero native variants plus additional response/menu flow that the current ScummVM handlers still compress heavily.
 - Immediate: the next time an NPC conversation looks wrong, compare it against the fresh `play_dialogue_line` Ghidra state first instead of second-guessing the warning at `0x7adfb`. Use the corrected `EBX` head-variant parameter plus the saved block comments to walk subtitle lookup, portrait swaps, and input-release behavior before chasing per-NPC handler bugs.
 - Immediate: run a live Harvester validation pass in `FIRENSIDE` after uncovering `NUDE_MAN`. Confirm first contact now reaches the recovered `0x606` / `0x60a` / `0x60e` / `0x612` / `0x618` / `0x61c` opener, opens response line `0x25d`, and then branches into `0x628` or `0x648`; if the Sergeant first-task bit is already set, confirm the extra `0x62c` prompt opens response line `0x25e` and ends on `0x636` or `0x63f`. Re-enter the conversation afterward and confirm revisits now play `0x651` / `0x655` / `0x3063` / `0x628` instead of stopping after a single bark.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 23025eaab0f..0e55fdc3661 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -78,6 +78,7 @@ static const char *const kPlayerActorEntityName = "PLAYER";
 static const uint32 kPaletteFadeTickMs = 4;
 static const float kPaletteFadeStep = 0.1f;
 static const float kPaletteBrightnessBlack = 0.0f;
+static const float kTownMapNightPaletteBrightness = 0.6f;
 // Native keeps WAIT_BM visible while room construction runs; fast local loads need a short floor.
 static const uint32 kTransitionWaitFrameMinMs = 120;
 static const int kRoomNpcAmbientLastFrame = 0x3b;
@@ -1326,6 +1327,9 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 		warning("Harvester: unresolved town map entry '%s'", mapEntryName.c_str());
 		return Common::kReadingFailed;
 	}
+	const float paletteBrightness = startupScript->getFlagValue("DAY_FLAG")
+		? 1.0f
+		: kTownMapNightPaletteBrightness;
 
 	byte palette[256 * 3];
 	if (!loadPaletteResource(*resources, kTownMapPalettePath, palette))
@@ -1379,7 +1383,7 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 		const StartupMapLocationRecord *hoveredLocation =
 			findTownMapLocationAt(startupScript->getMapLocations(), currentPanel, _mousePos);
 		if (needsRedraw) {
-			setScaledPalette(*screen, palette, 1.0f);
+			setScaledPalette(*screen, palette, paletteBrightness);
 			screen->fillRect(screen->getBounds(), 0);
 			blitBitmap(*screen, panels[(uint)currentPanel], 0, 0);
 			if (hoveredLocation) {


Commit: 3bd682163eefaf6d19c79d360edfd7fef1585a62
    https://github.com/scummvm/scummvm/commit/3bd682163eefaf6d19c79d360edfd7fef1585a62
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:49+02:00

Commit Message:
HARVESTER: Restore native valet and ryder dialogue flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/ryder_dialogue.cpp
    engines/harvester/npc/valet_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 16893c1d69a..637ecd6d7f6 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -473,6 +473,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `handle_talk_to_hank` has hidden post-`play_dialogue_line` blocks that immediately use this loader with `run_dialogue_response_menu`: after Hank's opening `0x703` line it shows zero-based `dialog.rsp` line `0xc7` (`1. Who are you?/2. Who am I?/3. Where am I?`) and seeds `g_dialogue_keyword_list_spec` from lines `0xc8`, `0xc9`, or `0xca` based on the selected slot.
   - The same Hank handler rewrites `g_dialogue_keyword_list_spec` from zero-based `dialog.rsp` lines `0xd5`, `0xd7`, `0xda`, `0xde`, `0xdf`, `0xe2`, `0xe4`, `0xe8`, and `0xec` inside its hidden topic tails, which confirms the keyword buffer is live dialogue state rather than a static per-NPC list.
   - Hank's hidden topic map is now explicit: the multi-line `Mom` branch (`0x725` through `0x747`) seeds `0xd5`; `STEVE` (`0xd6`) plays `0x74f` / `0x753` / `0x758` / `0x75d` / `0x761` / `0x765` then seeds `0xd7`; `LOUSY RAT` / `RAT` (`0xd8` / `0xd9`) plays `0x76d` / `0x771` then seeds `0xda`; the `'LIGHTING OUT'` branch (`0xdb` / `0xdc`) plays `0x77b` / `0x77f` / `0x784` / `0x788` / `0x78e` / `0x793`, opens response menu `0xdd`, and then seeds `0xde` or `0xdf`; `DADDY` (`0xe1`) seeds `0xe2`; `GIRL` (`0xe3`) seeds `0xe4`; `TV` (`0xe6`) wraps `play_rangshot_fst_sequence` between `0x7fc`..`0x809` and `0x812`..`0x824`, then seeds `0xe8`; `COWBOY SHOW` (`0xe9`) plays `0x82d`..`0x84c` and opens response menu `0xea`; `SICK` (`0xeb`) seeds `0xec`; the `newspaper` / `fire` / `sentinel` group (`0xee` / `0xef` / `0xf0`) continues through `0x8a0` / `0x8a5` / `0x8a9` / `0x8af` / `0x8b3`; and `lodge` (`0xf1`) sets `get_set_discussed_lodge_topic` before `0x8bf` / `0x8c5` / `0x8c9` / `0x8cf` / `0x8d3`.
+  - `handle_talk_to_valet @ 0x2eda0` is a fixed response-menu exchange rather than a single bark. It always plays `VALET 0xf2c` (head variant `1`), `PC 0xf31`, `VALET 0xf35`, `PC 0xf39`, inserts `play_fst_sequence("GRAPHIC/FST/MIDGET02.FST", 0)`, opens zero-based `dialog.rsp` line `0x2fe`, branches to `VALET 0xf4a` (variant `0`) or `VALET 0xf50` (variant `2`), and then always closes on `VALET 0xf56`.
+  - `handle_talk_to_ryder @ 0x38c80` is a full stateful topic/menu handler, not a single-line bark. The first no-item visit plays `0x477`, `C063A.FST`, `0x485`, `PC 0x489`, `C063B.FST`, `0x49b`, and response menu `0x2a1`, optionally granting `AUTOGRAPH`; later no-item visits fall back to `0x4cf` before the shared topic loop. The handler also has a one-shot `KARIN_KIDNAPED` tail (`0x56d`, `C063D.FST`, `0x57f`, response line `0x2a3`, then either `0x589` / `0x592` / `C063E.FST` or `0x58e` / `0x4a5` plus `AUTOGRAPH`), a keyword loop seeded from response line `0x2a5`, a `0x2a7` / `0x2a8` interview branch wrapped around `C101.FST`, and a burned-TV override that ignores presented items and runs `RYDER_TV_OUT 0x5a6` (variant `3`), optional `PC 0x5ab` (variant `4`), then `RYDER_TV_OUT 0x5af` (variant `3`).
   - Raw disassembly inside `handle_talk_to_mom` shows additional inline `load_dialogue_response_line` -> copy-to-`g_dialogue_keyword_list_spec` blocks that the initial decompiler pass did not surface cleanly. The pre-menu seeds are now confirmed directly in code: intro `0x102`, same-day follow-up `0x104`, later default `0x105`, day-5 override `0x113`, and day-6 override `0x114`.
   - The same hidden Mom blocks also jump back to `run_dialogue_keyword_menu`, so her keyword flow is a looping dialogue state machine rather than a single-topic exchange. Confirmed keyword-buffer rewrites are `0x116->0x118`, `0x119->0x11a`, `0x11d->0x120`, `0x121->0x122`, `0x123->0x124`, `0x125..0x128->0x12a`, `0x12b->0x12c`, `0x12d->0x12e`, `0x12f->0x130`, `0x131..0x133->0x134`, `0x135->0x136`, `0x13a->0x13b`, `0x13c->0x13e`, `0x13f->0x140`, the non-`STEPH_MIDGAME_PLAYED` `0x141` branch -> `0x144`, `0x145..0x147->0x148`, `0x14b..0x14c->0x150`, `0x151..0x152` response-menu choice `2` -> `0x154`, and `0x15b->0x15c`.
   - Raw byte disassembly also recovered the hidden straight-line `play_dialogue_line` sequences that the earlier function listing truncated after the first visible call. Confirmed multi-line Mom exchanges now include `0x116`, `0x119`, `0x11d`, `0x121`, `0x125..0x128`, `0x12b`, `0x12d`, `0x12f`, `0x131..0x133`, `0x135`, `0x137`, `0x145..0x147`, `0x14a`, `0x155`, and `0x156`.
diff --git a/TRACKER.md b/TRACKER.md
index 7277b3a334e..3a88bce9fde 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 22, 2026, re-opened `handle_talk_to_valet @ 0x2eda0` and `handle_talk_to_ryder @ 0x38c80` in the live `HARVEST.LE` Ghidra session after the fixed `play_dialogue_line` ABI made it clear both handlers were missing broader native control-flow, not just portrait constants.
+- Confirmed `handle_talk_to_valet` is the fixed `0xf2c` / `0xf31` / `0xf35` / `0xf39` exchange plus `MIDGET02.FST`, response line `0x2fe`, branch `0xf4a` or `0xf50`, and closing `0xf56`; confirmed `handle_talk_to_ryder` is a full stateful menu/topic handler with the first-visit `0x477` + `C063A/C063B` opener, the optional `AUTOGRAPH` rewards, the one-shot `KARIN_KIDNAPED` `0x56d` + `C063D/C063E` tail, the keyword loop rooted at response line `0x2a5`, the `0x2a7/0x2a8` topic branch wrapped around `C101.FST`, and the burned-TV `RYDER_TV_OUT 0x5a6` / optional `PC 0x5ab` / `0x5af` override. Patched `engines/harvester/npc/valet_dialogue.cpp` and `ryder_dialogue.cpp` to mirror those recovered native branches, updated `ARCHITECTURE.md`, and rebuilt the touched NPC object files successfully in `build-vscode-harvester-debug`.
 - On March 22, 2026, re-opened native `select_town_map_destination @ 0x66460` in the live `HARVEST.LE` Ghidra session after the ScummVM town map still rendered with the daytime palette at night.
 - Confirmed the native town-map travel UI checks `DAY_FLAG` at both palette-ramp sites and uses the same dim target as room setup when the flag is clear: `1.0 + (-0.4) = 0.6`. Patched `engines/harvester/flow.cpp` so the ScummVM town map now scales `HARVMAP.PAL` to `0.6f` at night instead of always using `1.0f`, and recorded the confirmed behavior in `ARCHITECTURE.md`.
 - On March 22, 2026, re-audited the ScummVM NPC talk handlers immediately after fixing `play_dialogue_line @ 0x7a690` to take its third argument from `EBX` instead of the bogus prior `ECX` decompile path.
@@ -177,8 +179,8 @@
 
 ## Next Suggested Action
 
+- Immediate: run a live Harvester validation pass for the newly expanded Valet and Ryder handlers. Confirm Valet now reaches `0xf2c` / `0xf31` / `0xf35` / `0xf39`, plays `MIDGET02.FST`, opens response line `0x2fe`, and then lands on `0xf4a|0xf50` plus `0xf56`; confirm Ryder first contact reaches `0x477`, `C063A.FST`, `0x485`, `0x489`, `C063B.FST`, `0x49b`, response line `0x2a1`, and the keyword loop, and verify the burned-TV path now plays `0x5a6` / optional `0x5ab` / `0x5af` instead of collapsing to a single bark.
 - Immediate: run a live Harvester validation pass on the town map from one daytime entrance and one nighttime entrance such as `PCHOUSE` versus `BARB_OUT` after dusk. Confirm the native-mirrored `DAY_FLAG` gate now keeps daytime `HARVMAP.PAL` at `1.0` brightness and dims the night map to `0.6` without changing hotspot labels or destination selection.
-- Immediate: use the corrected `EBX` head-variant ABI to re-audit the still larger dialogue handlers where the fresh native decompiles now show broader mismatches than just portrait constants. Start with `handle_talk_to_valet` and `handle_talk_to_ryder`, because both now show nonzero native variants plus additional response/menu flow that the current ScummVM handlers still compress heavily.
 - Immediate: the next time an NPC conversation looks wrong, compare it against the fresh `play_dialogue_line` Ghidra state first instead of second-guessing the warning at `0x7adfb`. Use the corrected `EBX` head-variant parameter plus the saved block comments to walk subtitle lookup, portrait swaps, and input-release behavior before chasing per-NPC handler bugs.
 - Immediate: run a live Harvester validation pass in `FIRENSIDE` after uncovering `NUDE_MAN`. Confirm first contact now reaches the recovered `0x606` / `0x60a` / `0x60e` / `0x612` / `0x618` / `0x61c` opener, opens response line `0x25d`, and then branches into `0x628` or `0x648`; if the Sergeant first-task bit is already set, confirm the extra `0x62c` prompt opens response line `0x25e` and ends on `0x636` or `0x63f`. Re-enter the conversation afterward and confirm revisits now play `0x651` / `0x655` / `0x3063` / `0x628` instead of stopping after a single bark.
 - Immediate: rerun the Johnson monster or another stock-monster room with `--debugflags=combat`. Confirm the new `live_center_dx` chase logs now appear before contact, the monster actually closes using the recovered live-width waypoint instead of idling on the wrong column, and a lethal player hit now prints `combat monster death start` followed by `combat monster death complete` before the on-death room action fires.
diff --git a/engines/harvester/npc/ryder_dialogue.cpp b/engines/harvester/npc/ryder_dialogue.cpp
index d7d6ed87f4b..c35e507257c 100644
--- a/engines/harvester/npc/ryder_dialogue.cpp
+++ b/engines/harvester/npc/ryder_dialogue.cpp
@@ -28,44 +28,239 @@
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kPcSpeakerId = "PC";
+static const char *const kRyderSpeakerId = "RYDER";
+static const char *const kRyderTvOutSpeakerId = "RYDER_TV_OUT";
+static const char *const kRyderIntroFstPath = "GRAPHIC/FST/C063A.FST";
+static const char *const kRyderFollowupFstPath = "GRAPHIC/FST/C063B.FST";
+static const char *const kRyderKarinIntroFstPath = "GRAPHIC/FST/C063D.FST";
+static const char *const kRyderKarinExitFstPath = "GRAPHIC/FST/C063E.FST";
+static const char *const kRyderTopicFstPath = "GRAPHIC/FST/C101.FST";
+static const int kRyderInterviewTopicLineIndices[] = { 0x2a7, 0x2a8 };
+
+static const DialogueLineEntry kRyderWhaleyPhotoLines[] = {
+	{ 0x52d, kRyderSpeakerId, 0 },
+	{ 0x533, kPcSpeakerId, 2 },
+	{ 0x538, kRyderSpeakerId, 0 },
+	{ 0x542, kRyderSpeakerId, 1 },
+	{ 0x546, kRyderSpeakerId, 1 }
+};
+
+static const DialogueLineEntry kRyderBlackmailEvidenceLines[] = {
+	{ 0x556, kRyderSpeakerId, 0 },
+	{ 0x560, kRyderSpeakerId, 1 },
+	{ 0x564, kRyderSpeakerId, 1 }
+};
+
+} // End of namespace
+
 bool RyderDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("RYDER") || npcName.equalsIgnoreCase("RYDER_TV_OUT");
+	return npcName.equalsIgnoreCase(kRyderSpeakerId) || npcName.equalsIgnoreCase(kRyderTvOutSpeakerId);
 }
 
 Common::Error RyderDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &) {
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	const bool burnedTvStation = runtime.startupScript().getFlagValue("BURNED_TV_STATION");
-	const char *speakerId = burnedTvStation ? "RYDER_TV_OUT" : "RYDER";
-
-	if (burnedTvStation)
-		return runtime.playDialogueLine(0x5a6, speakerId);
+	const char *speakerId = burnedTvStation ? kRyderTvOutSpeakerId : kRyderSpeakerId;
+	auto playSpeakerLine = [&](int wavId, int headVariant, const char *lineSpeakerId) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, lineSpeakerId, headVariant);
+	};
+	auto playRyderLine = [&](int wavId, int headVariant) -> Common::Error {
+		return playSpeakerLine(wavId, headVariant, speakerId);
+	};
 
-	if (usedItemName.empty()) {
-		if (_state.talkStatePending) {
-			_state.talkStatePending = false;
-			return runtime.playDialogueLine(0x477, speakerId);
+	if (burnedTvStation) {
+		Common::Error lineError = playSpeakerLine(0x5a6, 3, kRyderTvOutSpeakerId);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (!runtime.startupScript().getFlagValue("BOYLE_BURNED_TV")) {
+			lineError = playSpeakerLine(0x5ab, 4, kPcSpeakerId);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 		}
-		return runtime.playDialogueLine(0x4cf, speakerId);
+		return playSpeakerLine(0x5af, 3, kRyderTvOutSpeakerId);
 	}
 
 	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-		return runtime.playDialogueLine(0x52d, speakerId);
+		return runtime.playDialogueEntrySequence(kRyderWhaleyPhotoLines, ARRAYSIZE(kRyderWhaleyPhotoLines));
 	}
 	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
-		return runtime.playDialogueLine(0x54f, speakerId);
+		return playRyderLine(0x54f, 1);
 	}
 	if (usedItemName.equalsIgnoreCase("NOTE") ||
 			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
-		return runtime.playDialogueLine(0x556, speakerId);
+		return runtime.playDialogueEntrySequence(
+			kRyderBlackmailEvidenceLines, ARRAYSIZE(kRyderBlackmailEvidenceLines));
+	}
+	if (!usedItemName.empty())
+		return playRyderLine(0x4db, 1);
+
+	Common::Error lineError = Common::kNoError;
+	if (_state.talkStatePending) {
+		_state.talkStatePending = false;
+
+		lineError = playRyderLine(0x477, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = runtime.playDialogueFst(kRyderIntroFstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playRyderLine(0x485, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playSpeakerLine(0x489, 0, kPcSpeakerId);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = runtime.playDialogueFst(kRyderFollowupFstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playRyderLine(0x49b, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x2a1, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			(void)runtime.startupScript().addRuntimeObjectToInventory("AUTOGRAPH");
+			lineError = playRyderLine(0x4a5, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			lineError = playRyderLine(0x4a9, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playRyderLine(0x4ae, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+	} else {
+		lineError = playRyderLine(0x4cf, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
+			!sharedState.karinKidnapedDialogueState) {
+		sharedState.karinKidnapedDialogueState = true;
+
+		lineError = playRyderLine(0x56d, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = runtime.playDialogueFst(kRyderKarinIntroFstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playRyderLine(0x57f, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x2a3, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playRyderLine(0x589, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playRyderLine(0x592, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kRyderKarinExitFstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			lineError = playRyderLine(0x58e, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playRyderLine(0x4a5, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			(void)runtime.startupScript().addRuntimeObjectToInventory("AUTOGRAPH");
+		}
 	}
 
-	return runtime.playDialogueLine(0x4db, speakerId);
+	Common::String topicBuffer;
+	int topicBufferLineIndex = -1;
+	runtime.assignTopicBuffer(topicBuffer, topicBufferLineIndex, 0x2a5, "Ryder topic buffer");
+
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runtime.runKeywordMenu(topicBuffer, topicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.empty())
+			return Common::kNoError;
+
+		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
+				runtime.matchesResponseLine(selectedTopic, 0x2a6))
+			return playRyderLine(0x4e1, 1);
+
+		if (runtime.matchesAnyResponseLine(selectedTopic, kRyderInterviewTopicLineIndices,
+					ARRAYSIZE(kRyderInterviewTopicLineIndices))) {
+			lineError = playRyderLine(0x4e7, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x2a9, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = playRyderLine(0x4f2, 0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playSpeakerLine(0x4f9, 0, kPcSpeakerId);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playRyderLine(0x4fe, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = runtime.playDialogueFst(kRyderTopicFstPath);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playRyderLine(0x4ff, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playSpeakerLine(0x50c, 0, kPcSpeakerId);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playRyderLine(0x511, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else if (responseIndex == 2) {
+				lineError = playRyderLine(0x517, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playSpeakerLine(0x51c, 0, kPcSpeakerId);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playRyderLine(0x521, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			return playRyderLine(0x527, 0);
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x2aa))
+			continue;
+
+		lineError = playRyderLine(0x4d5, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/valet_dialogue.cpp b/engines/harvester/npc/valet_dialogue.cpp
index fbf70ebf9cf..da98537f3bb 100644
--- a/engines/harvester/npc/valet_dialogue.cpp
+++ b/engines/harvester/npc/valet_dialogue.cpp
@@ -27,13 +27,48 @@
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kValetSpeakerId = "VALET";
+static const char *const kValetCutscenePath = "GRAPHIC/FST/MIDGET02.FST";
+
+static const DialogueLineEntry kValetIntroLines[] = {
+	{ 0xf2c, kValetSpeakerId, 1 },
+	{ 0xf31, "PC", 0 },
+	{ 0xf35, kValetSpeakerId, 0 },
+	{ 0xf39, "PC", 0 }
+};
+
+} // End of namespace
+
 bool ValetDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("VALET");
 }
 
 Common::Error ValetDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
-	return runtime.playDialogueLineWithVariant(0xf2c, "VALET", 1);
+	Common::Error lineError = runtime.playDialogueEntrySequence(kValetIntroLines, ARRAYSIZE(kValetIntroLines));
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = runtime.playDialogueFst(kValetCutscenePath);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(0x2fe, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1) {
+		lineError = runtime.playDialogueLine(0xf4a, kValetSpeakerId);
+	} else if (responseIndex == 2) {
+		lineError = runtime.playDialogueLineWithVariant(0xf50, kValetSpeakerId, 2);
+	}
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	return runtime.playDialogueLine(0xf56, kValetSpeakerId);
 }
 
 } // End of namespace Harvester


Commit: ae4fa5174cd024cd7455565ce51b03028850599f
    https://github.com/scummvm/scummvm/commit/ae4fa5174cd024cd7455565ce51b03028850599f
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:49+02:00

Commit Message:
HARVESTER: Fix native ident overlay text

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/flow.cpp
    engines/harvester/room.cpp
    engines/harvester/room_support.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 637ecd6d7f6..dc4b68afe24 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -688,6 +688,9 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `handle_target_interaction` at `0x7ff50` is the high-level click/use dispatcher for world targets.
   - It can branch into NPC dialogue, inventory transfer, `USEITEM` actions, or generic fallback responses depending on the clicked entity and current item state.
 - `show_target_ident_text` at `0x80270` renders `IDENT` text for the current target by resolving the matching `TEXT` record and showing it until dismissal.
+  - The callsite selects `g_box1_panel_entity` .. `g_box4_panel_entity` from `TextRecord.panel_id`, then reuses the same textbox entity anchor loaded at startup: `x = 0xb1`, `y = 0x55`.
+  - Immediately before `spawn_text_entity`, it loads `EBX` from `g_textfont_cft @ 0xd5bc4`, so IDENT overlays use `TEXTFONT.CFT` atlas pixels rather than a generic single-color font path.
+  - The same callsite passes the selected panel bitmap width as the wrap bound, so IDENT text layout is derived from the textbox art instead of a room-local hardcoded inset.
 
 ## Keyboard Input
 
diff --git a/TRACKER.md b/TRACKER.md
index 3a88bce9fde..073fe0a252f 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 22, 2026, re-opened native `show_target_ident_text @ 0x80270`, `spawn_text_entity @ 0x59c00`, and the `run_harvester_main_loop @ 0x6dc70` textbox preload path after a DOSBox-vs-ScummVM night graveyard screenshot showed object IDENT overlays landing in the wrong place and using the wrong text shade.
+- Confirmed the native IDENT path reuses the startup-loaded `TEXTBOX1`..`TEXTBOX4` entities at `(0xb1, 0x55)`, loads `EBX` from `g_textfont_cft @ 0xd5bc4` immediately before `spawn_text_entity`, and therefore inherits `TEXTFONT.CFT` atlas colors instead of any hardcoded palette index. Patched `engines/harvester/flow.cpp`, `engines/harvester/room.cpp`, and `engines/harvester/room_support.h` so IDENT and room-modal `SHOW_TEXT` overlays now use the recovered textbox anchor plus the native `TEXTFONT` draw path instead of the old swapped coordinates and generic GUI-font shadow rendering. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 - On March 22, 2026, re-opened `handle_talk_to_valet @ 0x2eda0` and `handle_talk_to_ryder @ 0x38c80` in the live `HARVEST.LE` Ghidra session after the fixed `play_dialogue_line` ABI made it clear both handlers were missing broader native control-flow, not just portrait constants.
 - Confirmed `handle_talk_to_valet` is the fixed `0xf2c` / `0xf31` / `0xf35` / `0xf39` exchange plus `MIDGET02.FST`, response line `0x2fe`, branch `0xf4a` or `0xf50`, and closing `0xf56`; confirmed `handle_talk_to_ryder` is a full stateful menu/topic handler with the first-visit `0x477` + `C063A/C063B` opener, the optional `AUTOGRAPH` rewards, the one-shot `KARIN_KIDNAPED` `0x56d` + `C063D/C063E` tail, the keyword loop rooted at response line `0x2a5`, the `0x2a7/0x2a8` topic branch wrapped around `C101.FST`, and the burned-TV `RYDER_TV_OUT 0x5a6` / optional `PC 0x5ab` / `0x5af` override. Patched `engines/harvester/npc/valet_dialogue.cpp` and `ryder_dialogue.cpp` to mirror those recovered native branches, updated `ARCHITECTURE.md`, and rebuilt the touched NPC object files successfully in `build-vscode-harvester-debug`.
 - On March 22, 2026, re-opened native `select_town_map_destination @ 0x66460` in the live `HARVEST.LE` Ghidra session after the ScummVM town map still rendered with the daytime palette at night.
@@ -179,6 +181,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run a live Harvester validation pass in a nighttime room with object IDENT text, such as the graveyard tombstones. Confirm the recovered `TEXTBOX*.BM` anchor now places the panel at `(177, 85)` instead of the old swapped coordinates, and verify the overlay glyphs now match DOSBox's `TEXTFONT.CFT` palette shading rather than the old generic GUI-font shadowed text.
 - Immediate: run a live Harvester validation pass for the newly expanded Valet and Ryder handlers. Confirm Valet now reaches `0xf2c` / `0xf31` / `0xf35` / `0xf39`, plays `MIDGET02.FST`, opens response line `0x2fe`, and then lands on `0xf4a|0xf50` plus `0xf56`; confirm Ryder first contact reaches `0x477`, `C063A.FST`, `0x485`, `0x489`, `C063B.FST`, `0x49b`, response line `0x2a1`, and the keyword loop, and verify the burned-TV path now plays `0x5a6` / optional `0x5ab` / `0x5af` instead of collapsing to a single bark.
 - Immediate: run a live Harvester validation pass on the town map from one daytime entrance and one nighttime entrance such as `PCHOUSE` versus `BARB_OUT` after dusk. Confirm the native-mirrored `DAY_FLAG` gate now keeps daytime `HARVMAP.PAL` at `1.0` brightness and dims the night map to `0.6` without changing hotspot labels or destination selection.
 - Immediate: the next time an NPC conversation looks wrong, compare it against the fresh `play_dialogue_line` Ghidra state first instead of second-guessing the warning at `0x7adfb`. Use the corrected `EBX` head-variant parameter plus the saved block comments to walk subtitle lookup, portrait swaps, and input-release behavior before chasing per-NPC handler bugs.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 0e55fdc3661..83b82bb6b48 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -70,10 +70,12 @@ static const int kQuickTipTextY = 228;
 static const int kQuickTipTextWidth = 280;
 
 static const int kCursorSequence7 = 7;
-static const int kIdentTextboxX = 85;
-static const int kIdentTextboxY = 177;
+static const int kIdentTextboxX = 177;
+static const int kIdentTextboxY = 85;
 static const int kIdentTextboxTextInsetX = 10;
 static const int kIdentTextboxTextInsetY = 5;
+static const int kNativeIdentTextYOffset = 1;
+static const int kNativeIdentTextLineSpacing = 3;
 static const char *const kPlayerActorEntityName = "PLAYER";
 static const uint32 kPaletteFadeTickMs = 4;
 static const float kPaletteFadeStep = 0.1f;
@@ -444,6 +446,16 @@ static void drawWrappedShadowedText(Graphics::Screen &screen, const Graphics::Fo
 		drawShadowedString(screen, font, lines[i], x, y + i * lineHeight, width, color);
 }
 
+static void drawWrappedText(Graphics::Screen &screen, const Graphics::Font &font, const Common::String &text,
+		int x, int y, int width, byte color, int lineSpacing) {
+	Common::Array<Common::String> lines;
+	font.wordWrapText(text, width, lines);
+
+	const int lineHeight = font.getFontHeight() + lineSpacing;
+	for (uint i = 0; i < lines.size(); ++i)
+		font.drawString(&screen, lines[i], x, y + i * lineHeight, width, color);
+}
+
 static void setScaledPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
 	byte scaledPalette[256 * 3];
 	const float gammaBrightness = g_engine ? g_engine->getStartupGammaBrightnessScale() : 1.0f;
@@ -754,12 +766,22 @@ const IndexedBitmap *resolveInspectTextboxBitmap(const Art &art, const StartupRe
 }
 
 void drawRoomInspectText(Graphics::Screen &screen, const Art &art, const Graphics::Font &font,
-		const StartupResolvedText &inspectText) {
+		const StartupResolvedText &inspectText, bool useNativeFont) {
 	const IndexedBitmap *textbox = resolveInspectTextboxBitmap(art, inspectText);
 	if (!textbox || !textbox->isValid())
 		return;
 
 	blitBitmap(screen, *textbox, kIdentTextboxX, kIdentTextboxY);
+	if (useNativeFont) {
+		drawWrappedText(screen, font, inspectText.value,
+			kIdentTextboxX,
+			kIdentTextboxY + kNativeIdentTextYOffset,
+			(int)textbox->width,
+			0,
+			kNativeIdentTextLineSpacing);
+		return;
+	}
+
 	drawWrappedShadowedText(screen, font, inspectText.value,
 		kIdentTextboxX + kIdentTextboxTextInsetX,
 		kIdentTextboxY + kIdentTextboxTextInsetY,
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 6db5b782f1a..882d8604324 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -574,8 +574,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		Common::ScopedPtr<HarvesterCftFont> promptCftFont;
 		Common::ScopedPtr<HarvesterCftFont> inventoryTooltipCftFont;
 		const Graphics::Font *promptFont = bodyFont;
+		const Graphics::Font *inspectFont = bodyFont;
 		const Graphics::Font *inventoryTooltipFont = bodyFont;
 		bool useNativePromptFont = false;
+		bool useNativeInspectFont = false;
 		if (promptFontResource) {
 			promptCftFont.reset(new HarvesterCftFont(*promptFontResource));
 			if (promptCftFont->isValid()) {
@@ -585,10 +587,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		}
 		if (inventoryTooltipFontResource) {
 			inventoryTooltipCftFont.reset(new HarvesterCftFont(*inventoryTooltipFontResource));
-			if (inventoryTooltipCftFont->isValid())
+			if (inventoryTooltipCftFont->isValid()) {
 				inventoryTooltipFont = inventoryTooltipCftFont.get();
+				inspectFont = inventoryTooltipCftFont.get();
+				useNativeInspectFont = true;
+			}
 		}
-		if (!screen || !art || !bodyFont || !promptFont || !inventoryTooltipFont)
+		if (!screen || !art || !bodyFont || !promptFont || !inventoryTooltipFont || !inspectFont)
 			return Common::kNoError;
 		RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
 
@@ -944,7 +949,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		};
 		auto runModalShowText = [&](const StartupResolvedText &modalText) -> Common::Error {
 			Graphics::Screen *activeScreen = getActiveScreen();
-			if (!activeScreen || !art || !bodyFont)
+			if (!activeScreen || !art || !inspectFont)
 				return Common::kNoError;
 			if (!resolveInspectTextboxBitmap(*art, modalText)) {
 				debug(1, "Harvester: unsupported SHOW_TEXT textbox '%s' text='%s'",
@@ -998,7 +1003,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 				blitBitmap(*activeScreen, backdrop, 0, 0);
 				setScaledRoomPalette(*activeScreen, scene.palette, scene.targetPaletteBrightness);
-				drawRoomInspectText(*activeScreen, *art, *bodyFont, modalText);
+				drawRoomInspectText(*activeScreen, *art, *inspectFont, modalText, useNativeInspectFont);
 				activeScreen->makeAllDirty();
 				activeScreen->update();
 
@@ -2119,7 +2124,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			}
 
 			if (showingInspectText) {
-				drawRoomInspectText(*activeScreen, *art, *bodyFont, inspectText);
+				drawRoomInspectText(*activeScreen, *art, *inspectFont, inspectText, useNativeInspectFont);
 			} else if (!inventoryTooltipText.empty()) {
 				drawInventoryTooltip(*activeScreen, *inventoryTooltipFont, inventoryTooltipText);
 			} else if (!promptText.empty()) {
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index 45ec65c57f1..ec4e19fddfc 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -115,7 +115,7 @@ StartupObjectRecord *findSceneObjectByName(Common::Array<StartupObjectRecord> &o
 	const Common::String &objectName);
 const IndexedBitmap *resolveInspectTextboxBitmap(const Art &art, const StartupResolvedText &text);
 void drawRoomInspectText(Graphics::Screen &screen, const Art &art, const Graphics::Font &font,
-	const StartupResolvedText &inspectText);
+	const StartupResolvedText &inspectText, bool useNativeFont);
 bool unlocksRoomObjectInteractionAfterInitialExamine(const StartupObjectRecord &object,
 	Script &startupScript);
 bool doesPlayerFacingMatchRegion(int playerFacing, const StartupRegionRecord &region);


Commit: ec7923a703d717dd6dee8df31bf278558bd9752c
    https://github.com/scummvm/scummvm/commit/ec7923a703d717dd6dee8df31bf278558bd9752c
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:50+02:00

Commit Message:
HARVESTER: Restore black wait-screen backdrop

Changed paths:
    TRACKER.md
    engines/harvester/art.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 073fe0a252f..eb48d7a85a0 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 22, 2026, re-opened native `room_setup @ 0x73540` and `flush_dirty_rects_to_screen @ 0x5dca0` in the live `HARVEST.LE` Ghidra session after the ScummVM room-transition `WAIT_BM` screen still showed recolored room pixels behind "Please Wait" instead of the original black field.
+- Confirmed the native transition path fades the outgoing room palette to `0.0`, loads `WAIT.PAL`, spawns `WAIT_BM`, and then redraws through `flush_dirty_rects_to_screen`, which fills uncovered dirty areas with palette index `0` before `WAIT.PAL` is uploaded. Patched `engines/harvester/art.cpp` so `Art::drawWaitFrame()` clears the full gameplay surface to palette index `0` before the transparent wait animation is blitted, restoring the native black background. Next suggested action: run a live Harvester validation pass across at least one same-CD room transition and one town-map return to confirm the recovered wait-screen backing matches DOSBox throughout.
 - On March 22, 2026, re-opened native `show_target_ident_text @ 0x80270`, `spawn_text_entity @ 0x59c00`, and the `run_harvester_main_loop @ 0x6dc70` textbox preload path after a DOSBox-vs-ScummVM night graveyard screenshot showed object IDENT overlays landing in the wrong place and using the wrong text shade.
 - Confirmed the native IDENT path reuses the startup-loaded `TEXTBOX1`..`TEXTBOX4` entities at `(0xb1, 0x55)`, loads `EBX` from `g_textfont_cft @ 0xd5bc4` immediately before `spawn_text_entity`, and therefore inherits `TEXTFONT.CFT` atlas colors instead of any hardcoded palette index. Patched `engines/harvester/flow.cpp`, `engines/harvester/room.cpp`, and `engines/harvester/room_support.h` so IDENT and room-modal `SHOW_TEXT` overlays now use the recovered textbox anchor plus the native `TEXTFONT` draw path instead of the old swapped coordinates and generic GUI-font shadow rendering. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 - On March 22, 2026, re-opened `handle_talk_to_valet @ 0x2eda0` and `handle_talk_to_ryder @ 0x38c80` in the live `HARVEST.LE` Ghidra session after the fixed `play_dialogue_line` ABI made it clear both handlers were missing broader native control-flow, not just portrait constants.
diff --git a/engines/harvester/art.cpp b/engines/harvester/art.cpp
index 8dd57e1a3cf..bd7c8006c32 100644
--- a/engines/harvester/art.cpp
+++ b/engines/harvester/art.cpp
@@ -128,6 +128,10 @@ void Art::drawWaitFrame(Graphics::Screen &screen) const {
 	if (_waitFrames.empty() || !_waitFrames[0].isValid())
 		return;
 
+	// Native room_setup redraws the wait screen through flush_dirty_rects_to_screen(),
+	// which fills uncovered areas with palette index 0 before WAIT.PAL is uploaded.
+	// Clear first so the transparent WAIT art sits on black instead of old room pixels.
+	screen.fillRect(screen.getBounds(), kTransparentPaletteIndex);
 	logPaletteSummary("applying wait palette", "1:/GRAPHIC/PAL/WAIT.PAL", _waitPalette);
 	byte displayPalette[256 * 3];
 	buildHarvesterDisplayPalette(_waitPalette, 1.0f, displayPalette);


Commit: 8aa63417aa28a29c54b13bde57b73d0038e7cb44
    https://github.com/scummvm/scummvm/commit/8aa63417aa28a29c54b13bde57b73d0038e7cb44
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:50+02:00

Commit Message:
HARVESTER: Restore ident text padding

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/flow.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index dc4b68afe24..7ab0093e1bf 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -690,7 +690,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `show_target_ident_text` at `0x80270` renders `IDENT` text for the current target by resolving the matching `TEXT` record and showing it until dismissal.
   - The callsite selects `g_box1_panel_entity` .. `g_box4_panel_entity` from `TextRecord.panel_id`, then reuses the same textbox entity anchor loaded at startup: `x = 0xb1`, `y = 0x55`.
   - Immediately before `spawn_text_entity`, it loads `EBX` from `g_textfont_cft @ 0xd5bc4`, so IDENT overlays use `TEXTFONT.CFT` atlas pixels rather than a generic single-color font path.
-  - The same callsite passes the selected panel bitmap width as the wrap bound, so IDENT text layout is derived from the textbox art instead of a room-local hardcoded inset.
+  - The text spawn is padded relative to that panel anchor: native adds `+10` on X and `+5` on Y before creating the IDENT entity, and passes `panel_width - 2` as the wrap-width argument.
 
 ## Keyboard Input
 
diff --git a/TRACKER.md b/TRACKER.md
index eb48d7a85a0..278ec74037c 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -16,6 +16,8 @@
 
 ## Last Confirmed Action
 
+- On March 22, 2026, re-opened the `show_target_ident_text @ 0x80270` callsite in raw disassembly after the first IDENT overlay fix still drew `TEXTFONT` flush against the textbox border in ScummVM.
+- Confirmed the native padding is explicit at the callsite, not hidden inside the text entity renderer: `spawn_text_entity` receives `panel_x + 10`, `panel_y + 5`, and `panel_width - 2`. Patched `engines/harvester/flow.cpp` so CFT-backed IDENT and room-modal `SHOW_TEXT` overlays now honor that recovered inset instead of starting at the panel origin. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 - On March 22, 2026, re-opened native `room_setup @ 0x73540` and `flush_dirty_rects_to_screen @ 0x5dca0` in the live `HARVEST.LE` Ghidra session after the ScummVM room-transition `WAIT_BM` screen still showed recolored room pixels behind "Please Wait" instead of the original black field.
 - Confirmed the native transition path fades the outgoing room palette to `0.0`, loads `WAIT.PAL`, spawns `WAIT_BM`, and then redraws through `flush_dirty_rects_to_screen`, which fills uncovered dirty areas with palette index `0` before `WAIT.PAL` is uploaded. Patched `engines/harvester/art.cpp` so `Art::drawWaitFrame()` clears the full gameplay surface to palette index `0` before the transparent wait animation is blitted, restoring the native black background. Next suggested action: run a live Harvester validation pass across at least one same-CD room transition and one town-map return to confirm the recovered wait-screen backing matches DOSBox throughout.
 - On March 22, 2026, re-opened native `show_target_ident_text @ 0x80270`, `spawn_text_entity @ 0x59c00`, and the `run_harvester_main_loop @ 0x6dc70` textbox preload path after a DOSBox-vs-ScummVM night graveyard screenshot showed object IDENT overlays landing in the wrong place and using the wrong text shade.
@@ -183,6 +185,7 @@
 
 ## Next Suggested Action
 
+- Immediate: rerun the same DOSBox-vs-ScummVM IDENT repro in Johnson's garage or another one-line inspect target. Confirm the text now lands with the recovered `+10,+5` inset inside the textbox border while preserving the earlier fixed panel anchor and `TEXTFONT.CFT` palette shading.
 - Immediate: run a live Harvester validation pass in a nighttime room with object IDENT text, such as the graveyard tombstones. Confirm the recovered `TEXTBOX*.BM` anchor now places the panel at `(177, 85)` instead of the old swapped coordinates, and verify the overlay glyphs now match DOSBox's `TEXTFONT.CFT` palette shading rather than the old generic GUI-font shadowed text.
 - Immediate: run a live Harvester validation pass for the newly expanded Valet and Ryder handlers. Confirm Valet now reaches `0xf2c` / `0xf31` / `0xf35` / `0xf39`, plays `MIDGET02.FST`, opens response line `0x2fe`, and then lands on `0xf4a|0xf50` plus `0xf56`; confirm Ryder first contact reaches `0x477`, `C063A.FST`, `0x485`, `0x489`, `C063B.FST`, `0x49b`, response line `0x2a1`, and the keyword loop, and verify the burned-TV path now plays `0x5a6` / optional `0x5ab` / `0x5af` instead of collapsing to a single bark.
 - Immediate: run a live Harvester validation pass on the town map from one daytime entrance and one nighttime entrance such as `PCHOUSE` versus `BARB_OUT` after dusk. Confirm the native-mirrored `DAY_FLAG` gate now keeps daytime `HARVMAP.PAL` at `1.0` brightness and dims the night map to `0.6` without changing hotspot labels or destination selection.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 83b82bb6b48..447879bdb9f 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -74,7 +74,6 @@ static const int kIdentTextboxX = 177;
 static const int kIdentTextboxY = 85;
 static const int kIdentTextboxTextInsetX = 10;
 static const int kIdentTextboxTextInsetY = 5;
-static const int kNativeIdentTextYOffset = 1;
 static const int kNativeIdentTextLineSpacing = 3;
 static const char *const kPlayerActorEntityName = "PLAYER";
 static const uint32 kPaletteFadeTickMs = 4;
@@ -774,9 +773,9 @@ void drawRoomInspectText(Graphics::Screen &screen, const Art &art, const Graphic
 	blitBitmap(screen, *textbox, kIdentTextboxX, kIdentTextboxY);
 	if (useNativeFont) {
 		drawWrappedText(screen, font, inspectText.value,
-			kIdentTextboxX,
-			kIdentTextboxY + kNativeIdentTextYOffset,
-			(int)textbox->width,
+			kIdentTextboxX + kIdentTextboxTextInsetX,
+			kIdentTextboxY + kIdentTextboxTextInsetY,
+			MAX<int>(0, (int)textbox->width - 2),
 			0,
 			kNativeIdentTextLineSpacing);
 		return;


Commit: 389a28074aafa0a4b48a2f19a65addb85133fa2a
    https://github.com/scummvm/scummvm/commit/389a28074aafa0a4b48a2f19a65addb85133fa2a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:50+02:00

Commit Message:
HARVESTER: Match VGA DAC palette expansion

Changed paths:
    TRACKER.md
    engines/harvester/palette_utils.h


diff --git a/TRACKER.md b/TRACKER.md
index 278ec74037c..66b57aca5d3 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -1,11 +1,5 @@
 # TRACKER
 
-## Current Focus
-
-- Work down the remaining `FUN_*` list in address order, but stop when only literal HMIDRV subcommand IDs or unresolved runtime/x87 structure fields are proven
-- Keep renames tied to concrete caller/callee context, strings, DOS/BIOS/DPMI interrupt semantics, and direct buffer/global side effects
-- Leave jump-entered dialogue blocks, zero-xref `.image::` fragments, and still-ambiguous runtime/x87 helpers unnamed until the owning structures are reconstructed
-
 ## Progress
 
 - Program: `HARVEST.LE`
@@ -16,232 +10,9 @@
 
 ## Last Confirmed Action
 
-- On March 22, 2026, re-opened the `show_target_ident_text @ 0x80270` callsite in raw disassembly after the first IDENT overlay fix still drew `TEXTFONT` flush against the textbox border in ScummVM.
-- Confirmed the native padding is explicit at the callsite, not hidden inside the text entity renderer: `spawn_text_entity` receives `panel_x + 10`, `panel_y + 5`, and `panel_width - 2`. Patched `engines/harvester/flow.cpp` so CFT-backed IDENT and room-modal `SHOW_TEXT` overlays now honor that recovered inset instead of starting at the panel origin. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
-- On March 22, 2026, re-opened native `room_setup @ 0x73540` and `flush_dirty_rects_to_screen @ 0x5dca0` in the live `HARVEST.LE` Ghidra session after the ScummVM room-transition `WAIT_BM` screen still showed recolored room pixels behind "Please Wait" instead of the original black field.
-- Confirmed the native transition path fades the outgoing room palette to `0.0`, loads `WAIT.PAL`, spawns `WAIT_BM`, and then redraws through `flush_dirty_rects_to_screen`, which fills uncovered dirty areas with palette index `0` before `WAIT.PAL` is uploaded. Patched `engines/harvester/art.cpp` so `Art::drawWaitFrame()` clears the full gameplay surface to palette index `0` before the transparent wait animation is blitted, restoring the native black background. Next suggested action: run a live Harvester validation pass across at least one same-CD room transition and one town-map return to confirm the recovered wait-screen backing matches DOSBox throughout.
-- On March 22, 2026, re-opened native `show_target_ident_text @ 0x80270`, `spawn_text_entity @ 0x59c00`, and the `run_harvester_main_loop @ 0x6dc70` textbox preload path after a DOSBox-vs-ScummVM night graveyard screenshot showed object IDENT overlays landing in the wrong place and using the wrong text shade.
-- Confirmed the native IDENT path reuses the startup-loaded `TEXTBOX1`..`TEXTBOX4` entities at `(0xb1, 0x55)`, loads `EBX` from `g_textfont_cft @ 0xd5bc4` immediately before `spawn_text_entity`, and therefore inherits `TEXTFONT.CFT` atlas colors instead of any hardcoded palette index. Patched `engines/harvester/flow.cpp`, `engines/harvester/room.cpp`, and `engines/harvester/room_support.h` so IDENT and room-modal `SHOW_TEXT` overlays now use the recovered textbox anchor plus the native `TEXTFONT` draw path instead of the old swapped coordinates and generic GUI-font shadow rendering. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
-- On March 22, 2026, re-opened `handle_talk_to_valet @ 0x2eda0` and `handle_talk_to_ryder @ 0x38c80` in the live `HARVEST.LE` Ghidra session after the fixed `play_dialogue_line` ABI made it clear both handlers were missing broader native control-flow, not just portrait constants.
-- Confirmed `handle_talk_to_valet` is the fixed `0xf2c` / `0xf31` / `0xf35` / `0xf39` exchange plus `MIDGET02.FST`, response line `0x2fe`, branch `0xf4a` or `0xf50`, and closing `0xf56`; confirmed `handle_talk_to_ryder` is a full stateful menu/topic handler with the first-visit `0x477` + `C063A/C063B` opener, the optional `AUTOGRAPH` rewards, the one-shot `KARIN_KIDNAPED` `0x56d` + `C063D/C063E` tail, the keyword loop rooted at response line `0x2a5`, the `0x2a7/0x2a8` topic branch wrapped around `C101.FST`, and the burned-TV `RYDER_TV_OUT 0x5a6` / optional `PC 0x5ab` / `0x5af` override. Patched `engines/harvester/npc/valet_dialogue.cpp` and `ryder_dialogue.cpp` to mirror those recovered native branches, updated `ARCHITECTURE.md`, and rebuilt the touched NPC object files successfully in `build-vscode-harvester-debug`.
-- On March 22, 2026, re-opened native `select_town_map_destination @ 0x66460` in the live `HARVEST.LE` Ghidra session after the ScummVM town map still rendered with the daytime palette at night.
-- Confirmed the native town-map travel UI checks `DAY_FLAG` at both palette-ramp sites and uses the same dim target as room setup when the flag is clear: `1.0 + (-0.4) = 0.6`. Patched `engines/harvester/flow.cpp` so the ScummVM town map now scales `HARVMAP.PAL` to `0.6f` at night instead of always using `1.0f`, and recorded the confirmed behavior in `ARCHITECTURE.md`.
-- On March 22, 2026, re-audited the ScummVM NPC talk handlers immediately after fixing `play_dialogue_line @ 0x7a690` to take its third argument from `EBX` instead of the bogus prior `ECX` decompile path.
-- Confirmed a clean low-risk set of portrait mismatches where ScummVM still hardcoded head variant `0` even though the native handlers now decompile with stable nonzero variants. Patched `engines/harvester/npc/maint_man_dialogue.cpp`, `buster_dialogue.cpp`, `mother_dialogue.cpp`, `butcher_dialogue.cpp`, `curator_dialogue.cpp`, `lodge_chef_dialogue.cpp`, `memb_dir_dialogue.cpp`, `mcknight_dialogue.cpp`, `dark_woman_dialogue.cpp`, `fireman2_dialogue.cpp`, `valet_dialogue.cpp`, and `loomis_dialogue.cpp` to use the recovered native variants: `MAINT_MAN` `0xdaf -> 1`, `0xdbc/0xdc4 -> 2`; Buster intro `0x163c/0x1828/0x18c5 -> 2`; `MOTHER 0x26d6 -> 3`; `BUTCHER 0x1ac4 -> 1`; `CURATOR 0x425 -> 1`; `LODGE_CHEF 0x1b10 -> 2`; `MEMB_DIR 0x2b7/0x2d5 -> 1`; McKnight `0x15d0/0x15c9/0x1587 -> 2` plus `0x15ea/0x1611/0x161b -> 1`; `DARK_WOMAN 0x4cd5 -> 1`, `0x4d03 -> 2`; `FIREMAN2 0x5b9/0x5c7 -> 2`; `VALET 0xf2c -> 1`; and Loomis `0x11a8/0x11db/0x1143 -> 1`. Rebuilt the touched NPC object files successfully in `build-vscode-harvester-debug`.
-- On March 22, 2026, re-opened `play_dialogue_line @ 0x7a690` in the live `HARVEST.LE` Ghidra session after repeated NPC-dialogue audits kept running into a suspicious decompiler warning about a removed unreachable block.
-- Confirmed from raw disassembly and live call sites that the function body is still contiguous `0x7a690-0x7af06`; the `ram,0x7adfb` warning is only a decompiler structuring artifact in the keyboard/mouse input-release stabilization loop, not a missing lookup table or a truncated body. Also fixed the more concrete metadata bug by rebuilding the function signature with custom register storage so callers now recover the real ABI `wavId -> EAX`, `speakerId -> EDX`, `headVariant -> EBX` instead of the previous bogus third-parameter `ECX` assignment; added matching function/block comments and saved `HARVEST.LE`.
-- On March 21, 2026, re-opened `handle_talk_to_nude_man @ 0x2d9b0` in the live `HARVEST.LE` Ghidra session after a Fireman’s Side repro showed ScummVM reducing the uncovered `NUDE_MAN` conversation to the single barks `0x606` and `0x651`.
-- Fixed the broken one-byte Ghidra stub first by clearing and recreating the real `0x2d9b0-0x2db3e` function body, then confirmed the missing native dialogue flow: the first visit plays `0x606` / `0x60a` / `0x60e` / `0x612` / `0x618` / `0x61c`, opens response line `0x25d`, branches to `0x628` or `0x648`, and when the Sergeant first-task state is already set continues through `0x62c` plus response line `0x25e` into `0x636` or `0x63f`; later revisits play `0x651` / `0x655` / `0x3063` / `0x628`, while `DAY_FLAG` still short-circuits to `0x65c`. Patched `engines/harvester/npc/nude_man_dialogue.cpp` to mirror that native menu/tail flow and kept the Sergeant gate mapped to the shared `dialogueStateD2f08` bit.
-- On March 21, 2026, re-opened native class-6 monster handling in `update_actor_runtime_state @ 0x4d750` and `spawn_monster_entity_from_record @ 0x53a10` after a fresh ScummVM Johnson-monster repro showed the spawned monster standing in place until the player killed it, and then disappearing instantly with no death animation.
-- Confirmed two native details the current room loop was still skipping: class-6 spawns seed desired locomotion state `0x07` or `0x0e` immediately from the monster's placement relative to the player, and once `current_hit_points < 1` the actor runtime enters facing-specific death states `0x28/0x29`, `0x2a/0x2b`, or `0x2c/0x2d` before desired state `0x38` dispatches the monster record's `on_death_action_tag` on the last death frame. Patched `engines/harvester/room.cpp` so pursuit now measures live player/monster geometry instead of the stale scripted center when deciding whether chase spacing is satisfied, logs the recovered live-center/waypoint values, and defers monster removal until a recovered death-bank frame run finishes and only then executes the room action tag. Rebuilt `engines/harvester/room.o` successfully.
-- On March 21, 2026, re-opened native `update_player_combat_avatar_state @ 0x553a0` in the live `HARVEST.LE` Ghidra session after confirming ScummVM still lacked the keyboard combat path that lets Ctrl plus the arrow keys drive room attacks without the mouse.
-- Confirmed the keyboard-only attack selector is explicit, not a generic movement override: scan-code `0x1d` (Ctrl) gates the branch, right/left immediately pick the mid side-attack banks `0x1a` / `0x78..0x81` and `0x17` / `0x5a..0x63`, while up/down only choose `0x1b` / `0x6e..0x77`, `0x18` / `0x50..0x59`, `0x19` / `0x82..0x8b`, or `0x16` / `0x64..0x6d` when the current facing family is already right or left. Recovered from the same native path that keyboard contact chooses the nearest live target on the selected side, with upper/lower attacks also requiring the target center to remain inside a `30`-pixel horizontal side window and the live depth spans to overlap. Patched `engines/harvester/player.{h,cpp}` and `engines/harvester/room.cpp` so Ctrl+arrow now starts the recovered attack banks, suppresses walking while the modifier is held, rate-limits repeat starts like the native loop, pre-captures a native-style directional target for melee and projectile contact, and logs keyboard attack starts through the existing `combat` debug channel. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
-- On March 21, 2026, re-opened native `update_actor_runtime_state @ 0x4d750` in the live `HARVEST.LE` Ghidra session after a fresh monstrified-NPC combat repro still showed class-6 monsters snapping onto the player's column and looping their walk cycle without closing or landing hits reliably.
-- Confirmed the native class-6 pursuit gate is tighter than the current room-loop shortcut: monsters first close base Z to within `player.z +/- 2.0`, only seed the close-range attack picker once center-to-center X is within `engage_distance`, and treat the horizontal chase waypoint as satisfied within `50 * depth_scale` pixels instead of requiring exact positional equality. Patched `engines/harvester/room.cpp` to stop treating clamped no-op chase steps as movement, chase depth from the player's live Z with the recovered tolerance, and use the same native-style close-range reach gate for player and monster contact instead of opaque-overlap-only melee hits. Added a dedicated `combat` debug channel in `engines/harvester/detection.{h,cpp}` so target capture, chase movement/stalls, attack start, contact, and HP deltas now log explicitly. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
-- On March 21, 2026, re-opened native room-combat flow around `run_harvester_main_loop @ 0x6dc70`, `update_actor_runtime_state @ 0x4d750`, `queue_npc_death_or_monsterfy_transition @ 0x53810`, `spawn_monster_entity_from_record @ 0x53a10`, `tick_monster_entity_runtime @ 0x54140`, and `set_player_combat_loadout @ 0x55c10` in the live `HARVEST.LE` Ghidra session after the current ScummVM Harvester room loop still played the recovered right-click attack banks without ever resolving NPC retaliation or monster combat.
-- Confirmed the native close-range NPC hit path shares the same `queue_npc_death_or_monsterfy_transition` helper used by script `KILL_NPC` / `MONSTERFY`, so attacking an NPC can either latch its death state or force the `monsterfy_target_name` replacement monster. Also confirmed player and monster attack contact both use the recovered per-loadout / per-monster `+0x113c` frame offset, with monsters defaulting to contact frame `+2` and `MUCKEY` overriding that offset to `+7`, while live class-6 monster pursuit and retaliation stay in the `tick_monster_entity_runtime` / `update_actor_runtime_state` path keyed by `engage_distance`, `damage_amount`, and the parsed attack-sound trigger frame. Patched `engines/harvester/player.cpp`, `engines/harvester/script.{h,cpp}`, `engines/harvester/room.cpp`, and `engines/harvester/room_support.h` so room attacks now capture a real NPC/monster target, apply native-style NPC death-or-monsterfy bookkeeping on the confirmed contact frame, set `JIMMY_ATTACKED` when Jimmy is attacked, run a minimal live monster pursuit/attack loop against the player, and subtract/player-sync monster HP instead of leaving combat as pure animation. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
-- On March 21, 2026, re-opened `handle_talk_to_stephanie @ 0x36710` in the live `HARVEST.LE` Ghidra session after another ScummVM peephole/BYE report and found the function body had regressed to a one-instruction stub again.
-- Recreated the full Stephanie body (`0x36710-0x37f5d`) before re-checking the late keyword loop with clean disassembly and raw byte reads. Confirmed the generic `dialog.rsp[0x2e4]` `BYE` still exits through `0x46a4`, the hidden spyhole continuation is still limited to explicit `0x2f9..0x2fd` topic matches, and the post-`KARIN_KIDNAPED` tail always plays `0x4975` while only prepending `0x4970` when `g_stephanie_played_spyhole_branch` is already set. Patched `engines/harvester/npc/stephanie_dialogue.cpp` so ScummVM now mirrors that native kidnap tail instead of wrongly gating the whole `0x4975` / `0x4980` / `0x4984` / `0x4989` / `0x498f` sequence on the spyhole flag.
-- On March 21, 2026, re-opened the live `handle_talk_to_stephanie @ 0x36710` keyword loop in `HARVEST.LE` after a fresh ScummVM repro showed the post-peephole Stephanie conversation replaying `0x4a05` / `0x4a0a` / `0x4a0e` / `0x4a12` / `0x4a1e` / `0x4a23` every time `BYE` was selected, trapping the player in conversation mode.
-- Confirmed the native Stephanie spyhole continuation is only entered from explicit zero-based `dialog.rsp[0x2f9]..[0x2fd]` topic matches after `0x2f8` seeds the later keyword buffer. The generic `dialog.rsp[0x2e4]` `BYE` path still exits through `0x46a4` even after the spyhole buffer is active, so ScummVM's extra `inSpyholeKeywordState && BYE` hook was incorrect. Patched `engines/harvester/npc/stephanie_dialogue.cpp` to route generic `BYE` back through the normal exit path and left the hidden spyhole branch gated only on the confirmed `0x2f9..0x2fd` topics.
-- On March 21, 2026, re-opened native `handle_talk_to_mr_potts @ 0x26000` and `handle_talk_to_mrs_potts @ 0x2ee70` in the live `HARVEST.LE` Ghidra session to verify the Potts dialogue maps against the current ScummVM handlers.
-- Fixed the broken Ghidra function definitions first by deleting the one-instruction stub bodies and recreating both handlers with real code ranges before comparing any dialogue flow. Confirmed Mrs. Potts already matches the recovered native `0x28dc` / `0x28e8` / `0x28ee` / `0x28f2` / `0x28fb` / `0x2903` / `0x2907` plus keyword-tail map, then bounded the missing hidden Mr. Potts continuation after `DAY_6` line `0x2bd1`: when `KARIN_FOUND_ALIVE` is set and the local follow-up is still clear, zero-based `dialog.rsp` line `0x1e4` either runs `0x2be9` / `0x2bf2` / `0x2bf7` / `0x2bfe`, sets `get_set_discussed_mr_potts_tuesday_night_alibi`, and branches through zero-based `dialog.rsp` line `0x1e5` or `0x1e6` into `0x2c15` or `0x2c1a` / `0x2c20` / `0x2c24`, or else exits through `0x2c2c`. Patched `engines/harvester/npc/mr_potts_dialogue.{h,cpp}` so ScummVM now mirrors that native tail and reads the shared Moynahan Karin-kidnaped accusation bit from `DialogueSharedState` instead of the unused Mr.-Potts-local placeholder; saved `HARVEST.LE` after recreating both function bodies.
-- On March 21, 2026, traced the suspicious `WALL_PIC_T enabled=1 current=2` bathroom log back through the ScummVM runtime instead of treating it as another timer parser regression.
-- Confirmed the logged sequence is the expected same-room rebuild after interacting with `ST_WALPIC`, not proof that a cold `ST_BATHRM` entry starts with the timer armed. The shipped `HARVEST.SCR` room record for `ST_BATHRM` has an empty on-enter command and the base `TIMER "WALL_PIC_T"` record is disabled by default; `ANIM_WALLPIC` is the path that runs `SET_TIMER "WALL_PIC_T" "ON"`. In ScummVM, `SET_TIMER` mutates `_runtimeTimers`, `InteractionProcessor::handleInteractionResult` immediately calls `refreshCurrentScene(true)` on that runtime mutation, and that refresh path uses `materializeRoomState(...)` rather than `resolveRoomSetupState(...)`. That is why the debug excerpt starts on `materialized room timer ... enabled=1` with no preceding `resolveRoomSetupState(...)` line. The same runtime timer state is also serialized in save version `2`, so an armed timer can legitimately reappear after loading a save taken mid-countdown.
-- On March 21, 2026, re-opened native `handle_talk_to_stephanie @ 0x36710` together with the hidden Stephanie tails now labeled `stephanie_post_karin_kidnaped_tail @ 0x37650` and `stephanie_spyhole_keyword_continuation @ 0x37e78` in the live `HARVEST.LE` Ghidra session after the current ScummVM map still replayed spyhole/alibi lines on generic post-spyhole revisits.
-- Confirmed the native handler only reaches the `0x4970` / `0x4975` spyhole/alibi tail from the post-`KARIN_KIDNAPED` continuation. The standalone `0x2f9..0x2fd` spyhole keyword branch merely sets `g_stephanie_played_spyhole_branch`, optionally varies `0x4a19` vs `0x4a1e` based on the Mr. Potts confrontation bit, plays `0x4a23`, and loops back into the keyword menu. Fixed the Stephanie-local Ghidra state names (`g_stephanie_scratched_tucker_shown`, `g_stephanie_bolt_of_cloth_taken_shown`, `g_stephanie_barber_pole_stolen_shown`, `g_stephanie_burned_tv_station_shown`, `g_stephanie_diner_burned_shown`, `g_stephanie_diner_burned_karin_alive_followup_shown`, `g_stephanie_day_five_line_played`, `g_stephanie_pc_escaped_jail_shown`, `g_stephanie_karin_kidnaped_line_played`, and `g_stephanie_bolt_of_cloth_escalation_triggered`), saved `HARVEST.LE`, and patched `engines/harvester/npc/stephanie_dialogue.cpp` so ScummVM only replays the `0x4970` / `0x4975` plus optional `0x4980` / `0x4984` / `0x4989` / `0x498f` tail when the native `KARIN_KIDNAPED` branch has actually just fired.
-- On March 21, 2026, re-opened `run_harvester_main_loop @ 0x6dc70`, `find_timer_record_by_name @ 0x62e80`, `spawn_timer_entity_from_record @ 0x59390`, and `parse_timer_record @ 0x60160` in the live `HARVEST.LE` Ghidra session after a Pottsdam bathroom repro hit the unresolved timer tag `WALL_PIC_T` instead of starting the native peephole follow-up.
-- Confirmed the native timer-expiry path does not dispatch the timer name as an action tag. When a class `0x17` timer entity expires, `run_harvester_main_loop` resolves the backing `TimerRecord` by `timer_name` and then calls `dispatch_room_event_actions(timer->arg2)`. That matches the decoded `HARVEST.SCR` data for the Pottsdam bathroom sequence: `ST_BATHRM_WALL_PIC0` enables two-second timer `WALL_PIC_T`, the timer record is scoped to `ST_BATHRM`, and its action target is `ST_BATHRM_WALL_PIC1`, which selects the correct `PEEPER0*` FST and then clears the timer. Patched `engines/harvester/script.{h,cpp}` and `engines/harvester/room.cpp` so ScummVM now mirrors that native timer dispatch instead of executing the timer name itself; updated `ARCHITECTURE.md` with the confirmed timer-record note.
-- On March 21, 2026, re-opened native `run_save_game_menu @ 0x632c0` and `run_load_game_menu @ 0x64910` in the live `HARVEST.LE` Ghidra session after ScummVM’s startup save/load menus started clipping slot labels such as `GAME 02` and `GAME 25`.
-- Confirmed the original executable spawns the slot-label text entities directly at `x = 8` using the static `PTR_s_GAME_01_000c3c20` table, while the editable save-name text starts separately at `x = 0x50`; there is no native width clamp on the `GAME xx` label. Patched `engines/harvester/menu.cpp` to stop drawing the label through the ScummVM-only `64` pixel cap so the native `GAME 01` through `GAME 25` labels render in full again.
-- On March 21, 2026, added a Harvester debugger-console workflow helper instead of re-opening Ghidra for every Sergeant comparison: new `DEBUG_DIALOGUE <NPC_ID>` support now routes through `engines/harvester/dialogue_debug.cpp` and prints a structured branch dump with flag conditions, `DIALOG.RSP` response indices, WAV ids, head variants, and resolved subtitle text.
-- The first structured dump targets `SERGEANT`, mirroring the currently recovered ScummVM tree for the intro `0x2b7` / `0x2b8` / `0x2b9` menus, the `0x2bb` application-reminder menu, the post-`0x41d2` `0x2b6` branch, and the item-use shortcuts so those paths can be diffed against Ghidra without manually hopping back into the native executable for every check.
-- On March 21, 2026, re-opened the remaining hidden Sergeant talk tails in the live `HARVEST.LE` Ghidra session and cross-checked them against the native `DIALOG.RSP` plus `iso/Harvester/DIALOGUE.IDX` data after a live `SERGEANTRM` repro showed ScummVM stopping on Sergeant line `0x41d2` without opening the expected prompt.
-- Confirmed the missing native Sergeant flow is broader than the single `0x41d2` menu: the first-contact branch continues past `0x413f` through `0x4144` / `0x4145` / `0x4146` / `0x4148` and response lines `0x2b7`, `0x2b8`, `0x2b9`; the generic revisit branch plays `0x41ac`, optionally `0x41b0`, then `0x41b4` and response line `0x2bb`; the post-`QUEST_1` application branch opens response line `0x2b6` after `0x41d2`, branches either into the Johnson-task briefing `0x41db`..`0x41fa` or the refusal path `0x4201` / `0x4202` / `0x4203`, and later revisits replay `0x41f0` / `0x41f6` / `0x41fa` once the local `d2d54` bit is set. Those tails also share a day-5 one-shot exit line `0x4324` guarded by the local `d2d70` latch.
-- Patched `engines/harvester/npc/sergeant_dialogue.cpp` and `engines/harvester/npc/sergeant_dialogue.h` so ScummVM now mirrors those recovered Sergeant menus, line sequences, head variants, and local state bits instead of reducing them to single-line returns; updated `ARCHITECTURE.md` with the confirmed hidden-tail evidence.
-- On March 21, 2026, re-opened `parse_map_entrance_record @ 0x5ef20`, `parse_object_record @ 0x5fa40`, `parse_room_record @ 0x5fea0`, `parse_monster_record @ 0x5f080`, `spawn_monster_entity_from_record @ 0x53a10`, `spawn_player_combat_avatar @ 0x54220`, `update_actor_runtime_state @ 0x4d750`, and `select_town_map_destination @ 0x66460` in the live `HARVEST.LE` Ghidra session to resolve the remaining placeholder fields in `MapEntranceRecord`, `ObjectRecord`, `RoomRecord`, and `MonsterRecord` before mirroring the names into ScummVM.
-- Confirmed `MAP_ENTRANCE` stores a coordinate pair plus `initial_panel_index`; the selector currently only reads the panel index, but the first two columns stay bounded as stored map coordinates. Confirmed the monster timing quartet around `+0x70..+0x7c` is a sound-trigger block by pairing `spawn_monster_entity_from_record` with the player constructor: `attack_sound_trigger_frame`, `hit_sound_trigger_frame`, `footstep_sound_trigger_frame`, and `death_sound_trigger_frame`. Confirmed the remaining room/object/monster string placeholders are reserved script columns in current shipping data: room reserved strings are always empty, monster reserved strings are always empty, `ObjectRecord.reserved_string_40` is always empty, and `ObjectRecord.reserved_x_flag` is only an empty-or-`X` marker with no recovered read-side consumer. Patched the ScummVM Harvester structs/parser serialization to use those names and updated the local RE notes so the reserved-vs-semantic split is explicit.
-- On March 20, 2026, re-opened `handle_talk_to_karin @ 0x2ea90`, `handle_talk_to_edna @ 0x358a0`, and `handle_talk_to_dwayne @ 0x3a310` in the live `HARVEST.LE` Ghidra session to verify the native dialogue maps for KARIN, EDNA, and DWAYNE against the current ScummVM handlers.
-- Confirmed Edna and Dwayne already match the existing recovered dialogue-map notes in `ARCHITECTURE.md`, and bounded Karin completely from raw disassembly after the live decompiler mis-modeled `play_dialogue_line` fallthrough. The native Karin handler uses `PC 0x10a5` head variant `4`, `KARIN 0x10e3` head variant `4`, `KARIN 0x10d6` head variant `1`, `PC 0x1076` head variant `1`, and otherwise keeps the same item/no-item branching already present in ScummVM. Patched `engines/harvester/npc/karin_dialogue.cpp`, added the confirmed Karin handler notes to `ARCHITECTURE.md`, renamed the duplicate Ghidra thunk at `0x2e97a` to `thunk_play_dialogue_line`, and corrected both `thunk_play_dialogue_line @ 0x2e97a` and `play_dialogue_line @ 0x7a690` from erroneous `noreturn` metadata back to returning before saving the live `HARVEST.LE` program.
-- On March 20, 2026, re-opened `spawn_player_combat_avatar @ 0x54220`, `update_actor_runtime_state @ 0x4d750`, and `consume_weapon_ammo_and_play_fire_or_empty_sound @ 0x57910` in the live `HARVEST.LE` Ghidra session to recover where the original room right-click attacks actually trigger audio instead of guessing from the parsed monster sound fields.
-- Confirmed the native player combat avatar hardcodes its own loadout-specific attack samples: melee loadouts seed `+0x1148..+0x1150` with swoosh variants and fire them when `current_frame_index == first_frame_index + 1`, while ranged loadouts route that same timing window through the weapon fire/empty helper instead of the random attack-sound selector. Patched `engines/harvester/flow.cpp`, `engines/harvester/room.cpp`, and `engines/harvester/room_support.h` so ScummVM now mirrors that player-side timing by firing recovered loadout-specific attack sounds on the second frame of each room attack bank. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
-- On March 20, 2026, re-opened the Whaley/Herrill broom-closet reward continuation in raw `HARVEST.LE` code around `0x23fb9..0x240fb` after a live `SCHLHL` repro showed ScummVM never granting the promised baseball bat after response line `0x24c` option `2`.
-- Confirmed both native "accept the deal" exits call `add_object_to_inventory("BAT")` (`0x24013` and `0x240ad`) and then hide `PHOTO_OF_WHALEY_HERRILL` from `INVENTORY` through `set_object_visibility_for_owner_or_room("INVENTORY", "PHOTO_OF_WHALEY_HERRILL", 0)` before clearing `BUSTED_SCREWING_MIDGAME` and optionally dispatching `REVERSE_BROOM`. Patched `engines/harvester/npc/whaley_dialogue.cpp` so ScummVM now mirrors that bat-for-photo trade instead of erroneously re-adding the photo.
-- On March 20, 2026, re-opened `run_harvester_main_loop @ 0x6dc70`, `update_actor_runtime_state @ 0x4d750`, and `set_player_combat_loadout @ 0x55c10` in the live `HARVEST.LE` Ghidra session to recover the native room right-click attack path instead of guessing from the existing ScummVM inventory weapon toggle.
-- Confirmed the native room attack selector uses the current `pc*.abm` loadout art, compares cursor X against the player midpoint, then compares cursor Y against `player_bottom - depth_scale * 144.44` and `player_bottom - depth_scale * 75.36` to choose six directional banks: left `0x18/0x17/0x16` or right `0x1b/0x1a/0x19`, which map to frames `0x50..0x59`, `0x5a..0x63`, `0x64..0x6d`, `0x6e..0x77`, `0x78..0x81`, and `0x82..0x8b`.
-- Patched `engines/harvester/flow.cpp`, `engines/harvester/room.cpp`, and `engines/harvester/room_support.h` so room right-click now starts that recovered directional attack, inventory weapon toggles reload the matching `PC0/PC01..PC20` actor art in-room, and the room loop suppresses movement/hover/region dispatch until the attack bank completes. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
-- On March 20, 2026, re-opened `show_entity_visual @ 0x4c290`, `update_visible_entity_screen_position @ 0x4c860`, and `set_entity_screen_position @ 0x4b6f0` in the live `HARVEST.LE` Ghidra session after the previous room-animation anchor change made `SCHDOOR` float far down-right in `SCHLHL`.
-- Confirmed the prior top-left interpretation was wrong. Native room anims are centered around their record `x` / `y`: `show_entity_visual` subtracts half the current decoded bitmap width/height before storing the live entity screen position, and `update_visible_entity_screen_position` applies the frame header `x_offset` / `y_offset` afterward. Reverted `engines/harvester/runtime_entity.cpp` back to the centered room-animation anchor path and corrected the room-animation notes in `ARCHITECTURE.md`.
-- On March 20, 2026, re-opened `spawn_anim_entity_from_record @ 0x4bbe0`, `advance_entity_animation_frame @ 0x4c8a0`, and `update_render_entity_screen_position @ 0x5cc10` in the live `HARVEST.LE` Ghidra session after a fresh DOSBox-vs-ScummVM `SCHDOOR` screenshot suggested the broom-closet door was still drifting too far right while opening.
-- Confirmed `HALLDOOR.ABM` frame headers carry zero `x_offset` and `0/1` `y_offset`, which means any remaining horizontal mismatch has to come from the room-animation pivot/placement path rather than from unexpected per-frame header shifts.
-- On March 20, 2026, re-opened `dispatch_room_event_actions @ 0x60ee0` in the live `HARVEST.LE` Ghidra session after the DOSBox-vs-ScummVM `SCHDOOR` screenshot still showed the school-hall broom-closet door respawning too closed even after preserving `AnimRecord.runtime_state`.
-- Confirmed the remaining native difference was opcode ordering, not draw math or FST decode: the `GOFLIC` case calls `play_fst_sequence` immediately, and the native `SET_ANIM` handler mutates the live `SCHDOOR` room anim in place without forcing a same-room rebuild first. That means the original executable shows `GRAPHIC/FST/C132.FST` before any rebuilt `SCHLHL` scene can respawn the door.
-- Patched `engines/harvester/room.cpp` so same-room `mutatedRuntimeState` interactions now defer `refreshCurrentScene(true)` until after `interaction.cutscenePath` finishes, which matches the confirmed native `SET_ANIM` -> `GOFLIC` ordering for the broom-closet sequence. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
-- On March 20, 2026, re-opened native `room_setup @ 0x73540`, `spawn_anim_entity_from_record @ 0x4bbe0`, and `tick_entity_visual_state @ 0x4cb70` in the live `HARVEST.LE` Ghidra session after the ScummVM school-hall broom-closet repro showed the `HALLDOOR.ABM` door animation respawning not open enough after the `C132.FST` transition.
-- Confirmed the real native behavior is room-animation state persistence: before `room_setup` tears the outgoing room down, it walks live class-1 anim entities, copies each entity's `current_frame_index` back into `AnimRecord.runtime_state`, forces `runtime_visible = 1`, and mirrors `animation_enabled` back into `runtime_active`; `spawn_anim_entity_from_record` then uses that saved `runtime_state` when it respawns non-reverse room anims.
-- Patched `engines/harvester/script.{h,cpp}`, `engines/harvester/runtime_entity.{h,cpp}`, `engines/harvester/flow.cpp`, and `engines/harvester/room.cpp` so ScummVM now preserves live room-animation state across same-room rebuilds and room/save transitions, and respawns room anim entities from the native saved frame instead of always starting from frame `0`. Updated `ARCHITECTURE.md` with the confirmed native `AnimRecord.runtime_state` note and rebuilt `build-vscode-harvester-debug/scummvm` successfully.
-- On March 20, 2026, re-decompiled `play_fst_sequence @ 0x72550` and `run_fst_sequence_player @ 0x12b00` in Ghidra from scratch to check whether ScummVM's FST playback path was missing any native decode or wrapper steps that could explain the reported video-quality gap.
-- Confirmed the native FST block codec already matches the current ScummVM implementation closely: 4x4 changed-block decoding, optional per-frame VGA palette chunks, per-frame PCM queuing, and the same "truncated tail in the last scan band" behavior seen in shipped assets such as `VIRGLOGO.FST`, `FVLOGO.FST`, `INTROFIN.FST`, `C044.FST`, and `C048.FST`. The concrete native differences in this pass were player-side plumbing: one reused payload buffer for the whole sequence, wrapper-side stale-input clearing before playback, and skip acceptance only after frame 0 has started.
-- Patched `engines/harvester/fst_player.cpp` to mirror those proven runtime details on the ScummVM side by reusing a single frame payload buffer for the full sequence instead of reallocating per frame, draining stale skip input before playback begins, and deferring skip requests until after frame 0. Updated `ARCHITECTURE.md` with the confirmed native FST-player notes and rebuilt `engines/harvester/fst_player.o` plus `scummvm` successfully.
-- On March 20, 2026, re-opened the hidden `handle_talk_to_sergeant @ 0x30340` tail blocks in Ghidra after the live Monday lodge-application repro showed ScummVM stopping on Sergeant line `0x41cd` instead of handing off into the next day.
-- Confirmed from the recovered native `COMPLETED_LODGE_APPLICATION` branch at `0x30bc2..0x30c35` that the `QUEST_1 == false` path does not end at `0x41cd`: it immediately plays Sergeant line `0x4292`, sets `QUEST_1`, and dispatches action tag `GO_DAY_2`, which is the Monday-to-Tuesday transition/cutscene handoff.
-- Patched `engines/harvester/npc/sergeant_dialogue.cpp` so the completed-application branch now mirrors that native sequence, including the native `0x41cd` / `0x41d2` head variant, the `QUEST_1` latch, and deferred `GO_DAY_2` action-tag execution through the existing dialogue-interaction queue; updated `ARCHITECTURE.md` with the confirmed branch evidence and rebuilt `engines/harvester/npc/sergeant_dialogue.o` plus `scummvm`.
-- On March 20, 2026, re-checked the native document-closeup exit path in `run_inventory_screen @ 0x7df10` after the live `NOTE` fix still showed redundant ScummVM inventory bitmap loads following `GO_BOYLNOTECU`.
-- Confirmed the native hardcoded document/photo branch destroys the inventory-local text/status entities, dispatches the closeup action, and tears down the inventory screen before returning to the caller; it does not rebuild the carried inventory visuals after the closeup branch closes.
-- Patched `engines/harvester/room.cpp` so right-click secondary actions only call `_inventory.refresh()` when the overlay is still open; closeup actions such as `NOTE -> GO_BOYLNOTECU` now skip the redundant inventory cache rebuild once the inventory screen has been closed.
-- On March 20, 2026, re-opened the native inventory secondary-click dispatcher in `run_inventory_screen @ 0x7df10` after live ScummVM logs showed the runtime object under `NOTE.BM` is `NOTE`, not `NOTE_PHOTOCOPY`.
-- Confirmed from the hardcoded object-name ladder around `0x7efc6` plus the adjacent action-tag pointer table at `0xc40c8` that native supports both note variants separately: `NOTE_PHOTOCOPY -> GO_BOYLCOPYCU` and `NOTE -> GO_BOYLNOTECU`, with the note entry occupying the slot immediately after `NOTE_PHOTOCOPY` before the Whaley photo entries.
-- Patched `engines/harvester/inventory.cpp` to add the missing `NOTE` secondary-action mapping so right-clicking the live note object now follows the native closeup/document branch instead of falling through to the generic "close inventory" path.
-- On March 20, 2026, re-opened native `run_inventory_screen @ 0x7df10` and `get_current_story_day_index @ 0x38bc0` in Ghidra specifically to verify the missing inventory weekday label instead of inferring it from script flags alone.
-- Confirmed the native inventory screen allocates a dedicated `DAYS_TEXT` text entity at `(0x1e0, 0x18c)` with `g_textfont_cft @ 0xd5bc4` and color `0xfffffff4`, indexing the Monday-through-Saturday string table at `0xc4110` through `get_current_story_day_index`. The same setup branch skips the label completely when `is_object_in_inventory("HARVEST_BLADE")` is true.
-- Patched `engines/harvester/script.{h,cpp}`, `engines/harvester/inventory.{h,cpp}`, and `engines/harvester/room.cpp` so the room renderer now draws that native weekday label while the inventory overlay is open, and the inventory runtime-state watcher now triggers redraws when story day, HP portrait state, or `HARVEST_BLADE` ownership changes while the panel stays open.
-- On March 20, 2026, instrumented the inventory overlay click path instead of guessing why right-clicking the note bitmap was not reaching the recovered secondary-action dispatcher.
-- Added a dedicated `inventory` debug channel and wired logs through `engines/harvester/inventory.cpp` and `engines/harvester/room.cpp` so a repro now prints the live inventory visual list, the actual object name and sprite paths under each click, whether the item hit the combat-toggle branch, whether secondary-action lookup matched the hardcoded table or consumable fallback, and whether `executeActionTag()` returned an interaction result.
-- The instrumentation is meant to answer the current ambiguity around the note item specifically: whether `NOTE.BM` belongs to a different runtime object name than `NOTE_PHOTOCOPY`, whether the click is landing on a status/exit visual instead of the intended item, or whether the action dispatch succeeds and the failure happens later in interaction handling.
-- On March 20, 2026, re-opened native `run_inventory_screen @ 0x7df10` in Ghidra to verify the inventory hover tooltip path instead of continuing to reuse the room prompt renderer by approximation.
-- Confirmed the plain inventory hover branch resolves the hovered object's `inventory_text_key`, rebuilds a dedicated `INV_TEXT` entity at fixed coordinates `(0xbc, 0x19e)`, and uses `g_textfont_cft @ 0xd5bc4` with color `0xfffffff4`; the centered bottom carry prompt is a different path that still uses `g_medfont1_cft` and `USING_ON_ID` at y `0x1ce`.
-- Patched `engines/harvester/script.{h,cpp}` and `engines/harvester/room.cpp` so inventory hover labels now use an inventory-only text resolver plus a dedicated `TEXTFONT` draw path at the native anchor instead of the centered room prompt path. Updated `ARCHITECTURE.md` with the confirmed native tooltip split.
-- On March 20, 2026, re-opened native `room_setup @ 0x73540`, `run_inventory_screen @ 0x7df10`, and `ramp_palette_brightness @ 0x23a30` to compare the inventory-document closeup transition against the current ScummVM fade timing instead of only tuning the visible effect by eye.
-- Confirmed the native transition sequence is layered: `room_setup` fades the outgoing palette to black first, flushes `WAIT_BM` under `WAIT.PAL` while the destination room is being built, then fades the destination palette back in. Also confirmed `ramp_palette_brightness` itself advances in `0.1` brightness steps with a `get_elapsed_milliseconds() + 4` pacing loop while pumping `update_music_stream`; there is no separate explicit wait-frame timer in the original code.
-- Patched `engines/harvester/flow.{h,cpp}` and `engines/harvester/room.cpp` to restore the missing fade-out before the wait frame, keep `WAIT_BM` visible for a short minimum floor on fast local loads so the transition still reads like the native loader, and then run the existing room fade-in once the destination scene is ready. Updated `ARCHITECTURE.md` with the confirmed native transition details and rebuilt `engines/harvester/flow.o`, `engines/harvester/room.o`, and `scummvm` successfully.
-- On March 20, 2026, re-opened the sheriff evidence-room return path in native Harvester instead of treating the missing `POLEVD`/`POL_EVD` movie as another generic room-loop regression.
-- Confirmed from the decoded `HARVEST.SCR` sheriff-office cluster that the evidence room is `POL_EVD`, its exit region `EVDEXIT1` changes back to entrance `EVED_2_NSID`, and the room on-exit chain is explicitly one-shot: `POL_EVD_EXIT_COM` checks `JUST_LEFT_EV_ROOM_1ST`, runs `GOFLIC "GRAPHIC\FST\C044.FST"` on the first exit, then continues to `POL_EVD_EXIT_COM_2` to set `JUST_LEFT_EV_ROOM_1ST = T`. Ghidra also exposes the matching hardcoded `C044.FST` wrapper site at the sole xref to the asset string.
-- Traced the ScummVM gap to `Script::executeRoomExitCommands`: room on-exit commands were being evaluated with audio-only output plumbing, so `GOFLIC` had no cutscene context, got skipped, and still fell through to the continuation tag that consumed the one-shot flag.
-- Patched `engines/harvester/script.{h,cpp}` and `engines/harvester/room.cpp` so room exit commands now preserve deferred interaction outputs, play exit-chain FSTs before the room handoff completes, and then continue through any chained action tag such as `POL_EVD_EXIT_COM_2`; rebuilt `engines/harvester/script.o`, `engines/harvester/room.o`, and `scummvm` successfully.
-- On March 20, 2026, re-opened native `run_inventory_screen @ 0x7df10` in Ghidra specifically to recover the missing inventory secondary-click behavior instead of guessing from room-side scripts or from the existing ScummVM combat-loadout shortcut.
-- Confirmed the native right-click path is a three-way hardcoded dispatcher: weapons map to fixed combat-loadout ids; document/photo items such as `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` map to a fixed action-tag table at `0xc40c8`; and only the six consumable/self-use items (`SANDWICH`, `SANDWICH2`, `SYRINGE`, `ST_ASPRIN`, `ST_COUGHM`, `ST_VITAMN`) fall back to their object `action_tag`.
-- Confirmed the native closeup/document branch tears down the inventory screen after dispatching its hardcoded action tag, while the consumable/action-tag and weapon-loadout branches stay inside the inventory loop so HP/status changes can update in place.
-- Patched `engines/harvester/inventory.{h,cpp}` and `engines/harvester/room.cpp` to mirror that split: the room overlay now resolves the recovered native right-click action table, closes the overlay before document/photo closeups, preserves the existing weapon-loadout toggle, and executes consumable object action tags without closing the inventory. Documented the confirmed native behavior in `ARCHITECTURE.md` and rebuilt `engines/harvester/inventory.o`, `engines/harvester/room.o`, and `scummvm` successfully.
-- On March 20, 2026, scanned `engines/harvester` for native libc/std helper usage instead of assuming the existing parser code already matched ScummVM conventions.
-- Confirmed Harvester's `MIN`/`MAX` calls already come from ScummVM's own `common/util.h`, so the only real replacement target in the engine sources was `atoi`.
-- Added `engines/harvester/parse_utils.h` with `parseAsciiIntOrZero`, a Common-based permissive decimal parser that mirrors Harvester's prior `atoi` usage closely enough for config/script/dialogue fields while relying on `Common::String`, `Common::isSpace`, and `Common::isDigit` instead of libc; rewired all Harvester `atoi` call sites in `script.cpp` and `text.cpp` to use it, removed the direct `<cstdlib>` dependency from `script.cpp`, and rebuilt `engines/harvester/script.o` plus `engines/harvester/text.o` successfully.
-- On March 20, 2026, scanned the remaining `FUN_*` band in the live `HARVEST.LE` Ghidra session and only applied a rename where the evidence was explicit enough to survive review.
-- Confirmed `FUN_00038750 @ 0x38750` is a single-purpose wrapper around `play_fst_sequence("GRAPHIC/FST/C048.FST", 0, param_3, param_2)`, renamed it to `play_c048_fst_sequence`, added a plate comment with the hardcoded asset-path evidence plus the Loomis sheriff-office caller context, and saved `HARVEST.LE`.
-- Rechecked the other still-unnamed outliers instead of forcing more names: the `0x21100/0x21120` pair is still only proven as shared min/max extent updaters with unresolved axis ownership, the `0x83fb3..0x87439` band is still HMIDRV / sound-driver plumbing where only subcommand IDs and timer/vector side effects are concrete, and the remaining `0x8c3d8..0x91518` cluster is still largely exception / x87 runtime support below the semantic rename threshold.
-- On March 20, 2026, split Harvester save/load plumbing into a dedicated module instead of continuing to grow `harvester.cpp` with serializer helpers and save-state mutation code.
-- Moved the `StartupSaveRoomState` type into new `engines/harvester/saveload.h`, moved the `HarvesterEngine` save/load/capture/clear implementations into new `engines/harvester/saveload.cpp`, removed the now-local serializer helpers from `harvester.cpp`, and wired `saveload.o` into `engines/harvester/module.mk`.
-- Kept the existing save/load behavior and debug instrumentation intact while changing only the code organization; rebuilt `engines/harvester/saveload.o`, `engines/harvester/harvester.o`, and `scummvm` successfully.
-- On March 20, 2026, instrumented the Harvester save/load path instead of relying on menu-level load confirmation or room-materialization logs alone.
-- Added top-level savegame debug summaries in `HarvesterEngine::syncGame` for both save and load, including the serialized room target, spawn coordinates, facing, and music path; added a room-loop log when a pending loaded room state is actually applied.
-- Added runtime save-state dumps in `Script::syncRuntimeSaveState` that summarize the serialized record counts plus player HP/loadout/control pause state, then emit detailed logs for restored or saved runtime flags and for mutated runtime objects, NPCs, monsters, and timers relative to their script defaults. Also added explicit logs for script `SET_FLAG` execution and direct `setRuntimeFlagValue` mutations so flag changes are visible before they are written to a save.
-- Rebuilt `engines/harvester/harvester.o`, `engines/harvester/script.o`, `engines/harvester/room.o`, and `scummvm` successfully.
-- On March 20, 2026, rechecked the inventory drag-out close path against native `run_inventory_screen @ 0x7df10` in Ghidra instead of assuming the room overlay should only close on an outside click.
-- Confirmed the native inventory loop sets its close flag while the primary mouse button is still held, no inventory render entity is under the cursor, and the cursor leaves the hardcoded drag window `x=69..568`, `y=75..435`; when that close exits with a carried inventory entity still active, the caller keeps the carry state rather than clearing it.
-- Patched `engines/harvester/room.cpp` to mirror that behavior in the overlay room loop: during `EVENT_MOUSEMOVE`, a selected inventory item now closes the inventory overlay once the cursor is dragged outside the recovered native bounds with the primary button still held, while leaving the selection active for room targeting. Rebuilt `engines/harvester/room.o` and `scummvm` successfully.
-- On March 20, 2026, re-opened the Loomis magazine handoff in the native `handle_talk_to_loomis @ 0x34f80` control-flow gap instead of assuming the sheriff-office acceptance branch was completely driven by the nearby room script or by the explicit `DIALOG.RSP` magazine prompt text.
-- Decoded the hidden native block after Loomis line `0x11bb`: it loads response line `0x51`, runs the response menu, plays line `0x11c6` on the accepted branch, then sets `GAVE_MAG_TO_LOOMIS_TODAY`, flips `SHERIF_DRAWR` visible / `SHERIF_DRAWR2` hidden, hides Loomis, and finally calls `FUN_00038750`, which decompiles to `play_fst_sequence("GRAPHIC/FST/C048.FST", ...)`; the refusal branch plays `0x11cc`.
-- Confirmed the first Loomis no-item conversation was also underimplemented in ScummVM: the subtitle block around `4349..4502` plus `DIALOG.RSP` lines `84..102` exposes the missing sheriff-office topic menu (`Stephanie`, `Mrs. Loomis`, `Mrs. Phelps`, `A Man's Needs`, `French Postcards`, `Sheriff Dwayne`, `Mattress`) and the follow-up `4520/4524/4528` revisit exchange that the simplified handler never ran.
-- Patched `engines/harvester/npc/loomis_dialogue.{h,cpp}` to restore that topic-menu/follow-up flow and to mirror the native accepted magazine branch more closely: it now uses the native response line `0x51`, consumes the magazine, applies the confirmed sheriff-office flag/object/NPC mutations, and plays `GRAPHIC/FST/C048.FST` before queuing the room refresh. Rebuilt `engines/harvester/npc/loomis_dialogue.o` and `scummvm` successfully.
-- On March 20, 2026, rechecked the Loomis sheriff-office handoff against native `handle_target_interaction @ 0x7ff50` after the immediate-disappear regression instead of assuming NPC-targeted `USEITEM` records were the direct room-click entry point.
-- Confirmed the native room loop treats class-4 NPC clicks differently from object hotspots: item-on-NPC interaction calls `run_npc_dialogue(npcName, presentedObject)` directly, which seeds `g_dialogue_presented_object_name` before the talk handler runs, while the generic `USEITEM` record scan is only reached for non-NPC targets.
-- Reverted the generic NPC `USEITEM` interception in `engines/harvester/room.cpp` and removed the now-unneeded NPC overloads from `engines/harvester/script.{h,cpp}` so Loomis item use again enters the native-style dialogue path first; rebuilt `engines/harvester/script.o`, `engines/harvester/room.o`, and `scummvm` successfully.
-- On March 20, 2026, traced the room inventory-click path against the existing Loomis notes instead of layering more Loomis-specific special cases onto `handle_talk_to_loomis`.
-- Confirmed the broader parity gap was room-side: selected inventory items clicked on NPCs bypassed `HARVEST.SCR` `USEITEM` records entirely and jumped straight into `runRoomNpcDialogue`, which meant NPC-targeted script actions such as sheriff-office `INV_MAG -> LOOMIS -> GO_LOOMISA` could not drive their `START_DIALOG`/continuation flow or keep `g_dialogue_presented_object_name` semantics intact.
-- Patched `engines/harvester/script.{h,cpp}` and `engines/harvester/room.cpp` to resolve NPC-targeted `USEITEM` records, preserve the selected item name through deferred scripted dialogue/continuation handling, and only fall back to direct NPC dialogue when no matching NPC `USEITEM` record exists; rebuilt `engines/harvester/script.o`, `engines/harvester/room.o`, and `scummvm` successfully.
-- On March 20, 2026, re-audited room-player locomotion and input-driven turn handling against native `update_player_combat_avatar_state @ 0x553a0` and `update_actor_runtime_state @ 0x4d750` instead of relying on the simplified ScummVM helper alone.
-- Confirmed the native 5-frame turn banks are directional, not symmetric: up->left uses `0x0a..0x0e` forward while left->up reverses that bank; left->down uses `0x19..0x1d` forward while down->left reverses it; down->right uses `0x28..0x2c` forward while right->down reverses it; right->up uses `0x37..0x3b` forward while up->right reverses it. That exposed that `resolvePlayerTurnAnimationRange` in `engines/harvester/flow.cpp` had every recovered turn pair inverted.
-- Confirmed the native room wrapper also clamps horizontal movement against the live opaque sprite edges rather than a raw center `0..639` range: left-facing motion stops once `screen_x + frame_x_offset <= 4`, and right-facing motion stops once `screen_x + frame_x_offset + scaled_bitmap->width >= 0x27c`.
-- Updated the `update_actor_runtime_state` plate comment in Ghidra with that directional turn-bank mapping, saved `HARVEST.LE`, patched `engines/harvester/flow.cpp` to mirror the recovered turn directions plus native horizontal bounds, documented the confirmed rules in `ARCHITECTURE.md`, and rebuilt `scummvm` successfully.
-- On March 20, 2026, compared the ScummVM Loomis item-use path against both native `handle_talk_to_loomis @ 0x34f80` in Ghidra and the decoded `HARVEST.SCR` sheriff-office records instead of guessing from dialogue alone.
-- Confirmed that the native Loomis talk handler already matches the ScummVM dialogue lines for `INV_MAG`, but the room script also defines `USEITEM "INV_MAG" "SHRFOFC" "LOOMIS" "GO_LOOMISA"`; that action sets `GAVE_MAG_TO_LOOMIS_TODAY` and immediately `SET_NPC "LOOMIS" "F" "F"`, which explained why ScummVM kept Loomis in the room after the magazine handoff.
-- Patched `engines/harvester/npc/loomis_dialogue.cpp` to apply those confirmed room-script side effects during the Loomis magazine branch and queue a mutated-runtime refresh so the live sheriff-office scene drops Loomis after the dialogue, then rebuilt `scummvm` successfully.
-- On March 20, 2026, traced the startup room idle-animation branch back to native `run_harvester_main_loop` and confirmed the player idle overlay does not use the previously inferred ScummVM rate of `30`: the `PCLOUN02.ABM` spawn path sets `EBX = 0x0e` immediately before `spawn_scaled_abm_entity_from_resource`, so `engines/harvester/flow.cpp` now uses `kRoomPlayerIdleAnimationRate = 14` to match the original timing.
-- Recorded that recovered native timing detail in `ARCHITECTURE.md` under the confirmed idle-animation notes so the parity fix stays tied to the underlying evidence instead of a visual guess.
-- On March 20, 2026, ran another conservative Ghidra rename pass across the remaining non-image `FUN_*` set and only kept names with direct behavioral proof from callers/callees, data flow, and decompiler output instead of semantic guesses.
-- Saved `HARVEST.LE` after renaming five high-confidence helpers: `set_math_status_flags_and_invoke_callback` (`FUN_0008c5e8`), `compare_extended_values_with_nan_code` (`FUN_0008dcc2`), `convert_uint32_to_extended` (`FUN_0008e12b`), `rewind_runtime_handler_iterator_by_two` (`FUN_0008f64b`), and `debug_break_or_write_console_message_and_exit` (`FUN_0008f368`).
-- The same pass explicitly rejected several tempting but still-ambiguous entries: `FUN_00077e80`, `FUN_000813b4`, and `FUN_000820e8` decompile as stubs only; `FUN_000c55f8` still looks like a bad function boundary / misidentified data block rather than a stable callable function; and the remaining `0x8daee-0x91518` band still needs owner-structure recovery before more precise names are justified.
-- The unresolved buckets are now narrower but unchanged in kind: `FUN_00021100` / `FUN_00021120` are still only proven as paired extent updaters; `FUN_0002e3b4`, `FUN_0002e41b`, `FUN_0002e46a`, `FUN_0002f7fd`, `FUN_000324a2`, and `FUN_0003351b` remain jump-entered dialogue mis-splits; the `FUN_00083fb3` / `FUN_00084a8b` / `FUN_00084b6b` plus `FUN_00087005` through `FUN_00087439` band remains HMIDRV wrapper territory; and the leading `.image::` entries are still zero-xref fragments with no reliable subsystem context.
-- On March 20, 2026, tightened the current integrated inventory / room-item handoff against the persisted actor runtime instead of inventing a separate inventory state machine: right-clicking confirmed weapon inventory items now toggles the saved player combat loadout ids recovered from the native inventory screen, the open inventory overlay now refreshes its `INV_STAT*` portrait immediately when live player HP changes, and secondary-click while an inventory item is actively selected now cancels that carry/use latch without forcibly closing the panel.
-- Kept the current ScummVM-side carry/use handoff model intact, but bounded it more tightly to the same live actor state that save/load and timer-driven room actions already use: inventory weapon toggles write through `Script::setPlayerCombatLoadout`, and the room loop captures that updated runtime state immediately for later save/menu handoffs.
-- Rebuilt the touched Harvester objects successfully after that inventory/runtime parity pass: `engines/harvester/inventory.o`, `engines/harvester/room.o`, and `scummvm`.
-- On March 19, 2026, widened the confirmed town-script / room-event coverage in `engines/harvester` using only opcode behavior already bounded in Ghidra and `ARCHITECTURE.md`: added `CHECK_PERC`, `EXEC_LIST`, `GOFLIC`, `SHOW_TEXT`, `PC_GOTO_XZ`, and `CHANGE_LIGHTING` to the startup-script dispatcher, parsed `EXEC_LIST` records from `HARVEST.SCR`, and threaded the new deferred action results through room interactions plus queued dialogue follow-ups instead of dropping them as generic unsupported fallthrough.
-- The room loop now applies confirmed deferred side effects in native order where applicable: `GOFLIC` plays FST cutscenes then continues with the chained action tag, `SHOW_TEXT` runs a blocking room-modal textbox using the existing inspect-text resources, `PC_GOTO_XZ` repositions the live player avatar on the current room depth line, and `CHANGE_LIGHTING` covers the confirmed `DIM`, `NORMAL`, and `NONE` modes while keeping `FADE_IN` explicit as a still-unbounded no-op.
-- Rebuilt the touched Harvester objects successfully after that script/event coverage pass: `engines/harvester/script.o`, `engines/harvester/room.o`, `engines/harvester/dialogue.o`, and `scummvm`.
-- On March 19, 2026, implemented the first actor-runtime / combat-adjacent substrate pass in `engines/harvester`: parsed and persisted richer `MONSTER` plus `TIMER` records from `HARVEST.SCR`, bumped startup-save serialization to version `2` with backward-compatible load fallback, preserved player combat HP/loadout/control-pause state in the startup runtime snapshot, spawned non-visual timer entities during room setup, and paused/resumed their countdowns across room setup fades and the true `run_main_menu` path.
-- Extended the live room loop to sync timer and monster runtime state back into the startup snapshot before save/refresh handoffs, dispatch expired room timers through the confirmed town-script action-tag path, and promote room monsters from static single-frame props into live runtime entities with facing-based idle/walk frame bands.
-- Rebuilt the touched Harvester objects successfully after the actor-runtime/timer pass: `engines/harvester/script.o`, `engines/harvester/runtime_entity.o`, `engines/harvester/flow.o`, `engines/harvester/room.o`, `engines/harvester/menu.o`, `engines/harvester/harvester.o`, and `scummvm`.
-- On March 19, 2026, ran a manual desktop validation pass against the live in-room ESC overlay with a persistent ScummVM GUI session and `osascript` keyboard input: confirmed the six-row room menu is reachable and keyboard-driven, reproduced a real palette-return regression on `SAVE GAME -> Esc` / `LOAD GAME -> Esc`, and fixed it by reapplying the room palette whenever `runRoomMenuStub` redraws its backdrop after a submenu returns.
-- Rebuilt the touched Harvester binary after that room-menu palette fix: `engines/harvester/menu.o` and `scummvm`, then revalidated that both `SAVE GAME -> Esc` and `LOAD GAME -> Esc` return to the room-menu overlay without the corrupted palette.
-- On March 19, 2026, audited the legitimate `run_main_menu` callers against the live `HARVEST.LE` Ghidra session instead of the cold-start path: confirmed that the six-row in-room ESC overlay belongs to the room-menu path, while the true `run_main_menu` return/death path blanks `SAVE GAME` and `LOAD GAME` unless an active room session is still live.
-- Applied that parity fix in `engines/harvester/menu.cpp` / `engines/harvester/menu.h`: `runMainMenuStub` now filters `SAVE GAME` and `LOAD GAME` out of the true main-menu stub when there is no current startup save-room state, while `runRoomMenuStub` continues to expose the full six-row in-room menu.
-- Rebuilt the touched Harvester objects successfully after the caller-audit fix: `engines/harvester/menu.o`, `engines/harvester/flow.o`, `engines/harvester/room.o`, and `engines/harvester/harvester.o`.
-- On March 19, 2026, fixed the cold-start regression from the previous menu pass: `Flow::run()` no longer jumps from the initial quick-tips overlay into `run_main_menu`, and instead restores the confirmed native cold-start path back into `runRoomLoop("START")`. The real `run_main_menu` work remains limited to its live main-loop / game-over entry points, while the new `LOAD GAME` UI and `NEW GAME` confirmation support stay available on those legitimate menu paths.
-- Rebuilt the touched Harvester object successfully after that correction: `engines/harvester/flow.o`.
-- On March 19, 2026, completed the first real `run_main_menu` / room-menu parity pass in `engines/harvester`: `LOAD GAME` now uses the native `LOADGAME.BM` / `LOADGAME.PAL` slot picker; `NEW GAME` now uses the confirmed `MENU.INI:newgame` confirmation prompt in both the legitimate top-level menu path and the in-room ESC menu; and the in-room ESC menu now unwinds cleanly into restart/load handoffs instead of leaving those rows as stubs.
-- Rebuilt the touched Harvester objects successfully: `engines/harvester/menu.o`, `engines/harvester/flow.o`, `engines/harvester/room.o`, and `engines/harvester/harvester.o`.
-- On March 19, 2026, audited the current `engines/harvester` implementation against the live `HARVEST.LE` Ghidra session and the confirmed notes in `ARCHITECTURE.md`; verified that boot/resource loading, room setup, inventory, dialogue, save menu, help, options, and quit confirmation are already implemented, while the main remaining reimplementation gaps are startup/room menu parity, actor runtime/combat/timers, and wider script opcode coverage.
-- Finished another rename pass, saved `HARVEST.LE`, and advanced the current tracker cycle to 194 confident renames total.
-- The latest two passes resolved 12 more high-confidence names:
-  `execute_runtime_handler_list_until_target`, `find_and_store_runtime_handler_type3_entry`,
-  `convert_int32_to_extended`, `convert_double_words_to_extended`, `convert_extended_to_float_bits`, `convert_extended_to_double_words`,
-  `convert_float_bits_to_extended`, `round_extended_by_control_word_mode`,
-  `compute_extended_atan2`, `compute_extended_atan_core`, `compute_extended_log2`, and `compute_extended_natural_log`.
-- The current boundary is now narrower:
-  `FUN_00021100` / `FUN_00021120` are still only proven as paired min/max extent updaters with unresolved owner-axis semantics;
-  `FUN_0002e3b4`, `FUN_0002e41b`, `FUN_0002e46a`, `FUN_0002f7fd`, `FUN_000324a2`, and `FUN_0003351b` are still jump-entered dialogue mis-splits rather than stable standalone functions;
-  `FUN_00083fb3`, `FUN_00084a8b`, `FUN_00084b6b`, and the `FUN_00087005` through `FUN_00087439` band are still HMIDRV/sound-bank wrappers where only subcommand IDs and buffer movement are proven;
-  the remaining `FUN_0008c3d8` through `FUN_00091518` cluster is now mostly x87/no-87 runtime state, shared polynomial helpers, operand classifiers, and throw/unwind support whose table semantics, hidden ABI, or record layouts are still below the rename threshold;
-  the leading `.image::` entries remain zero-xref fragments with no reliable subsystem context.
+- On March 22, 2026, compared DOSBox-X output against ScummVM's startup room/menu palette handling and re-opened the native palette path in Ghidra.
+- Confirmed the executable's room/menu brightness behavior still matches the existing reverse-engineered gamma logic (`update_gamma_brightness_scale` keeps `1.0 + 0.1 * GAMMA` and `upload_palette_to_vga` still writes 6-bit DAC values after truncating `.PAL` bytes with `>> 2`), but ScummVM's `expandHarvesterVgaDacColor()` was re-expanding those DAC values with rounded `*255/63`. Patched `engines/harvester/palette_utils.h` to use VGA-faithful bit replication (`(value << 2) | (value >> 4)`), which matches DOSBox-X's 6-bit DAC expansion and should reduce the default "washed out" bias in midtones.
 
 ## Next Suggested Action
 
-- Immediate: rerun the same DOSBox-vs-ScummVM IDENT repro in Johnson's garage or another one-line inspect target. Confirm the text now lands with the recovered `+10,+5` inset inside the textbox border while preserving the earlier fixed panel anchor and `TEXTFONT.CFT` palette shading.
-- Immediate: run a live Harvester validation pass in a nighttime room with object IDENT text, such as the graveyard tombstones. Confirm the recovered `TEXTBOX*.BM` anchor now places the panel at `(177, 85)` instead of the old swapped coordinates, and verify the overlay glyphs now match DOSBox's `TEXTFONT.CFT` palette shading rather than the old generic GUI-font shadowed text.
-- Immediate: run a live Harvester validation pass for the newly expanded Valet and Ryder handlers. Confirm Valet now reaches `0xf2c` / `0xf31` / `0xf35` / `0xf39`, plays `MIDGET02.FST`, opens response line `0x2fe`, and then lands on `0xf4a|0xf50` plus `0xf56`; confirm Ryder first contact reaches `0x477`, `C063A.FST`, `0x485`, `0x489`, `C063B.FST`, `0x49b`, response line `0x2a1`, and the keyword loop, and verify the burned-TV path now plays `0x5a6` / optional `0x5ab` / `0x5af` instead of collapsing to a single bark.
-- Immediate: run a live Harvester validation pass on the town map from one daytime entrance and one nighttime entrance such as `PCHOUSE` versus `BARB_OUT` after dusk. Confirm the native-mirrored `DAY_FLAG` gate now keeps daytime `HARVMAP.PAL` at `1.0` brightness and dims the night map to `0.6` without changing hotspot labels or destination selection.
-- Immediate: the next time an NPC conversation looks wrong, compare it against the fresh `play_dialogue_line` Ghidra state first instead of second-guessing the warning at `0x7adfb`. Use the corrected `EBX` head-variant parameter plus the saved block comments to walk subtitle lookup, portrait swaps, and input-release behavior before chasing per-NPC handler bugs.
-- Immediate: run a live Harvester validation pass in `FIRENSIDE` after uncovering `NUDE_MAN`. Confirm first contact now reaches the recovered `0x606` / `0x60a` / `0x60e` / `0x612` / `0x618` / `0x61c` opener, opens response line `0x25d`, and then branches into `0x628` or `0x648`; if the Sergeant first-task bit is already set, confirm the extra `0x62c` prompt opens response line `0x25e` and ends on `0x636` or `0x63f`. Re-enter the conversation afterward and confirm revisits now play `0x651` / `0x655` / `0x3063` / `0x628` instead of stopping after a single bark.
-- Immediate: rerun the Johnson monster or another stock-monster room with `--debugflags=combat`. Confirm the new `live_center_dx` chase logs now appear before contact, the monster actually closes using the recovered live-width waypoint instead of idling on the wrong column, and a lethal player hit now prints `combat monster death start` followed by `combat monster death complete` before the on-death room action fires.
-- Immediate: run a live Harvester validation pass with `--debugflags=combat` in one room where the player can swing at an NPC/monster without the mouse. Hold Ctrl plus Left/Right/Up/Down and confirm ScummVM now enters the recovered side-attack banks without walking, reacquires the correct native-style directional target between repeated swings, and still applies melee/projectile damage on the confirmed contact frame.
-- Immediate: run a live Harvester validation pass with `combat` debug enabled in one room that monsterfies an NPC plus one stock-monster room. Confirm the new logs print `combat player target capture`, `combat monster chase move|stalled`, `combat monster attack start`, and `combat ... hit|miss` at contact, that a newly monstrified NPC no longer freezes into an in-place walk cycle on the player's column, and that both player swings and monster swings now change HP whenever the recovered close-range gate is satisfied.
-- Immediate: run a live Harvester validation pass in one room with an NPC that has `monsterfy_target_name` plus one plain monster room. Confirm that right-clicking the NPC now latches the native death/monsterfy transition on the recovered contact frame, spawns the replacement monster when applicable, sets `JIMMY_ATTACKED` if Jimmy is the victim, and that the spawned/active monster now closes to `engage_distance`, plays its attack bank, and removes player HP on contact instead of remaining a passive walk-cycle prop.
-- Immediate: rerun the Stephanie sequence from a clean pre-peephole save. First trigger the `0x2f9..0x2fd` spyhole topic and confirm `BYE` now exits on `0x46a4` without replaying `0x4a05`..`0x4a23`; then advance to `KARIN_KIDNAPED` and confirm the post-kidnap tail now always includes `0x4975`, only prepends `0x4970` when the spyhole flag was set earlier, and still conditionally adds `0x4980` / `0x4984` / `0x4989` / `0x498f` from the shared Potts alibi bits.
-- Immediate: rerun the Stephanie peephole repro. Reach the post-peephole conversation state, select `BYE`, and confirm ScummVM now exits on `0x46a4` instead of replaying `0x4a05` / `0x4a0a` / `0x4a0e` / `0x4a12` / `0x4a19|0x4a1e` / `0x4a23`; then re-enter Stephanie and verify the hidden spyhole branch still triggers only from the explicit `0x2f9..0x2fd` keyword topics.
-- Immediate: run a live Harvester validation pass on Mr. and Mrs. Potts. On a `DAY_6` / `KARIN_FOUND_ALIVE` Mr. Potts visit, take the `0x1e3` response-2 path and confirm the newly mirrored `0x1e4` continuation appears once, sets up `0x2be9` / `0x2bf2` / `0x2bf7` / `0x2bfe`, and then reaches `0x2c15` or `0x2c1a` / `0x2c20` / `0x2c24` / `0x2c2c` exactly like the native branch; then re-check the murder accusation path to confirm the Moynahan accusation option now unlocks `0x2d80` plus response line `0x213`. Re-run Mrs. Potts with `KARIN_KIDNAPED` to confirm the unchanged `0x28e8` / `0x28ee` / `0x28f2` and `0x28fb` / `0x2903` / `0x2907` paths still match.
-- Immediate: run one true cold-entry `ST_BATHRM` repro with `general` debug enabled and no mid-room save/load. Confirm the first bathroom arrival logs `resolveRoomSetupState('ST_HALL2BATH')` and materializes `WALL_PIC_T` as disabled, then click `ST_WALPIC` once and confirm the later `materializeRoomState(...)`/`scene timer spawned ... enabled=1 current=2` pair only appears on the same-room rebuild after `SET_TIMER ON`.
-- Immediate: run a live Harvester validation pass on Stephanie's recovered no-item tails. Trigger the `0x2f9..0x2fd` spyhole keyword branch before `KARIN_KIDNAPED` and confirm it only returns to the keyword menu after `0x4a23`; then repeat after `KARIN_KIDNAPED`, first without and then with the Mr./Mrs. Potts Tuesday-night alibi bits set, to confirm ScummVM now gates `0x4970` / `0x4975` / `0x4980` / `0x4984` / `0x4989` / `0x498f` exactly like the native handler before the Karin outcome block exits through `0x49ea` / `0x49ef`.
-- Immediate: run a live Harvester validation pass in `ST_BATHRM`. Click `ST_WALPIC` before and after the quest-gate flags (`SCRATCHED_TUCKER`, `BOLT_OF_CLOTH_TAKEN`, `BARBER_POLE_STOLEN`, `DINER_BURNED`) and confirm the recovered timer path now waits about two seconds, plays `PEEPER01.FST` / `PEEPER02.FST` / `PEEPER4.FST` / `PEEPER5.FST` through `ST_BATHRM_WALL_PIC1`, sets `FOUND_PEEPHOLE`, and then disables/kills `WALL_PIC_T`.
-- Immediate: run a live Harvester validation pass on the native startup `SAVE GAME` and `LOAD GAME` screens. Confirm all labels now render as full `GAME 01` through `GAME 25` strings, including previously clipped rows such as `GAME 02`, `GAME 09`, `GAME 20`, and `GAME 25`.
-- Immediate: use `DEBUG_DIALOGUE SERGEANT` during the next live `SERGEANTRM` repro and compare the printed branch structure against the Ghidra tail blocks. If the workflow is useful, extend `engines/harvester/dialogue_debug.cpp` to the next NPC you are actively auditing instead of re-reading native control flow from scratch each time.
-- Immediate: run a live Harvester validation pass in `SERGEANTRM` across all recovered Sergeant states. Verify first contact now reaches `0x413f` -> `0x4148` with response lines `0x2b7` / `0x2b8` / `0x2b9`, the application reminder path now reaches `0x41ac` / optional `0x41b0` / `0x41b4` with response line `0x2bb`, and the post-application path now opens response line `0x2b6` after `0x41d2` before branching into the Johnson-task or refusal sequence.
-1. Run a live Harvester validation pass on room right-click attacks with both bare hands and equipped weapons: confirm the player still swaps to the correct `PC0/PC01..PC20` art after inventory weapon toggles, and that attacks now play the recovered second-frame sound timing for unarmed swings, melee weapons, firearms, and the chainsaw.
-2. If any player loadout still sounds wrong in that pass, re-open `spawn_player_combat_avatar @ 0x54220` and the frame-commit logic around `0x52762..0x52993` to mirror any still-missing special cases such as the firearm secondary timing branch or the chainsaw empty/fuel path exactly.
-3. Run a live Harvester validation pass in `SCHLHL` covering both Whaley/Herrill acceptance paths: after taking the broom-closet photo, choose response line `0x24c` option `2` and the nested `0x24d` option `1`, and confirm both paths now add `BAT`, hide `PHOTO_OF_WHALEY_HERRILL` from the inventory strip, clear `BUSTED_SCREWING_MIDGAME`, and still unwind the broom-door state through `REVERSE_BROOM` when that flag is set.
-4. Run a live Harvester validation pass in `SCHLHL`: use `BROOMKEY` on `SCHL_DOOR_HS`, let `GRAPHIC/FST/C132.FST` finish, and confirm the `SCHDOOR` room anim now respawns from its native saved frame/open state instead of restarting from the closed-frame default.
-5. Run a live Harvester validation pass on FST playback itself: compare `VIRGLOGO.FST`, `FVLOGO.FST`, `INTROFIN.FST`, `C044.FST`, and `C048.FST` against the original executable and confirm the reused payload buffer plus native first-frame skip gating remove any remaining startup/cutscene stutter or premature-skip artifacts.
-6. Run a live Harvester validation pass in `SERGEANTRM` on Monday with `HAVE_COMPLETED_LODGE_APP` true and `QUEST_1` false: talk to Sergeant with the completed application and confirm ScummVM now plays the recovered `0x41cd` -> `0x4292` sequence, dispatches `GO_DAY_2`, advances the story to Tuesday, and shows the native day-change cutscene/FST.
-7. Run a live Harvester validation pass in `SERGEANTRM` on the newly mirrored Sergeant dialogue tails: first talk through the `0x2b7` / `0x2b8` / `0x2b9` intro menus, then revisit before and after obtaining a lodge application to confirm `0x2bb` and `0x2b6` both appear, and finally re-check the `DAY_5` follow-up so `0x4324` only plays once.
-8. Run a live Harvester validation pass on the inventory overlay and confirm right-clicking `NOTE` now dispatches `GO_BOYLNOTECU` without the redundant post-closeup inventory bitmap reloads, and that the overlay only reappears when the player explicitly reopens it after returning from the closeup.
-9. Run a live Harvester validation pass on the inventory overlay and confirm the native weekday label now appears at `(480, 396)` in `TEXTFONT` when `HARVEST_BLADE` is not in inventory, updates if the day changes while the panel stays open, and disappears immediately once `HARVEST_BLADE` is acquired.
-10. Run a live Harvester validation pass on the inventory overlay and confirm plain hover labels now use the native `TEXTFONT` placement: check regular inventory items such as `CHECKBOOK` plus any `INV_STAT*` status portrait entry that has an `inventory_text_key`, and verify the label appears at `(188, 414)` instead of the centered bottom prompt line.
-11. Run a live Harvester validation pass in `SHRFOFC` / `POL_EVD`: enter the evidence room from the sheriff office, leave via `EVDEXIT1`, and confirm `GRAPHIC/FST/C044.FST` now plays exactly once before the return to `SHRFOFC`, with later exits skipping the movie because `JUST_LEFT_EV_ROOM_1ST` stayed latched.
-12. Run a Harvester smoke pass that exercises every numeric text-input source touched by the new parser helper: startup `CONFIG.INI` option loading, `HARVEST.SCR` room/script parsing, and `DIALOGUE.IDX` WAV-id loading, so the Common-based `parseAsciiIntOrZero` replacement is confirmed against live data instead of only object compilation.
-13. Re-open the IRQ0 sound-timer helper band around `install_irq0_timer_handler @ 0x8b1f3`, the second same-named function at `0x8b30d`, and `FUN_0008b389`: the `0x8b30d` body clearly restores the saved IRQ0 vector rather than installing a new one, and `FUN_0008b389` appears to participate in that saved callback/vector unwind path, so that cluster is the best remaining non-x87/non-HMIDRV candidate for another high-confidence rename pass.
-14. Run a live Harvester save/load validation pass with `general` debug enabled and confirm the extracted `saveload` module still produces the same logs and behavior: the serialized room target at save time, the loaded room target at load time, the pending room state application in `RoomSystem`, and the restored runtime flags/objects/NPCs/monsters/timers that differ from script defaults.
-15. Run a live Harvester validation pass on the inventory overlay: right-click `CHECKBOOK`, `CHECKBOOK_PHOTOCOPY`, `TV_DEED`, `NOTE`, `NOTE_PHOTOCOPY`, `CLUE`, and `AUTOGRAPH` and confirm each recovered closeup now fades the current screen to black, keeps `WAIT_BM` on screen long enough to read, and then fades the closeup scene back in; also re-check `SANDWICH` / `SYRINGE` / `ST_*` consumables to confirm the added transition floor did not change in-inventory self-use behavior.
-16. Run a live Harvester validation pass in `SHRFOFC` to confirm the restored Loomis first-talk topic menu, the `4520/4524/4528` revisit follow-up, and the accepted magazine branch now plays `C048.FST`, consumes the magazine, hides Loomis, and exposes the real drawer hotspot.
-17. If the drawer is still blocked after the accepted magazine branch, instrument `resolveRoomHoverState` / `findRoomObjectAtPoint` in `SHRFOFC` to verify whether the `SHERIF_DRAWR` / `SHERIF_DRAWR2` visibility flip is sufficient or whether same-bounds hotspot precedence is still masking the unlocked drawer.
-18. Re-audit the remaining Loomis response-driven evidence branches next, especially the blackmail submenu implied by `DIALOG.RSP` line `80` and subtitle ids `4617/4628/4633/4639`, since the simplified handler still reduces those to single-line returns.
-19. Run a live Harvester validation pass focused on room-player walking at the screen edges and on stationary turn starts from all four facings, to confirm the corrected directional turn banks and opaque-edge clamps now match the native feel closely enough that no additional waypoint or frame-boundary work is needed.
-20. Run a live Harvester validation pass on one map transition plus one monster room to confirm the reserved-field cleanup was documentation-only: open the town map from at least `PCHOUSE` and `BARB_OUT`, then visit a monster room such as `CRYPT` and verify monster attack/hit/footstep/death audio still lines up with the renamed trigger-frame fields.
-
-## Reimplementation Priority Order
-
-1. Validate and finish the actor runtime and combat-adjacent substrate.
-   Files: `engines/harvester/room.cpp`, `engines/harvester/flow.cpp`, `engines/harvester/runtime_entity.cpp`, `engines/harvester/runtime_entity.h`, `engines/harvester/script.cpp`, `engines/harvester/script.h`.
-   Native anchors: `spawn_player_combat_avatar @ 0x54220`, `update_player_combat_avatar_state @ 0x553a0`, `teardown_player_combat_avatar @ 0x55010`, `tick_monster_entity_runtime @ 0x54140`, `spawn_timer_entity_from_record @ 0x59390`, `pause_timer_entity_countdowns @ 0x80460`, `resume_timer_entity_countdowns @ 0x804a0`.
-   Concrete tasks: validate the new timer pause/resume bookkeeping and timer-to-action dispatch in a live room; confirm the true `run_main_menu` pause path now freezes room countdowns; if the live monster rooms still need more than the new facing/animation hooks, extend only the proven actor state transitions from the native anchors.
-   Exit criteria: room timers survive save/load and pause correctly across menus/help, and the remaining monster/combat gaps are narrowed to explicit missing native state-machine branches instead of static-scene placeholders.
-2. Validate the widened town-script action and room-event coverage, then only implement any remaining script-side gaps that are still explicitly proven.
-   Files: `engines/harvester/script.cpp`, `engines/harvester/room.cpp`, `engines/harvester/dialogue.cpp`.
-   Native anchors: `dispatch_room_event_actions @ 0x60ee0`, `run_town_script_interpreter @ 0x46d80`, and the confirmed town-script notes in `ARCHITECTURE.md`.
-   Concrete tasks: exercise the now-supported `CHECK_PERC`, `EXEC_LIST`, `GOFLIC`, `SHOW_TEXT`, `PC_GOTO_XZ`, and `CHANGE_LIGHTING` paths in live rooms; only if validation still fails, add the exact missing command or side effect proven by that room.
-   Exit criteria: the remaining room progression failures reduce to a short, named list of still-unimplemented native commands or side effects, with `CHANGE_CD` / `FADE_IN` either confirmed as blockers or explicitly ruled out.
-3. Validate the tightened inventory and item-use flow against live gameplay, then only extend the remaining confirmed inventory-specific behavior that still blocks parity.
-   Files: `engines/harvester/inventory.cpp`, `engines/harvester/room.cpp`, `engines/harvester/script.cpp`.
-   Native anchors: `run_inventory_screen @ 0x7df10`, `add_object_to_inventory @ 0x7c8b0`, `sync_player_combat_weapon_resource_icons @ 0x792c0`, and the confirmed inventory carry/use notes in `ARCHITECTURE.md`.
-   Concrete tasks: validate the recovered live HP portrait refresh, weapon-loadout toggles, and document/consumable secondary-click actions; if parity still fails after that pass, narrow the remainder to HUD icon strip restoration or another explicit native inventory branch.
-   Exit criteria: any remaining inventory parity work is reduced to a short, explicit list of still-unimplemented native inventory behaviors instead of a broad actor-runtime mismatch.
-4. Leave low-level runtime, HMIDRV, and x87 cleanup as deferred RE work unless they block a confirmed gameplay feature.
-   Ghidra anchors: the remaining unwind helpers around `rewind_runtime_handler_iterator_by_two @ 0x8f64b` through `FUN_000905df`, the HMIDRV wrapper band around `FUN_00087005`, and the x87/no-87 runtime cluster beginning near `FUN_0008c3d8`.
-   Rationale: those clusters still matter for naming completeness, but the current engine-side gaps are better bounded and have higher reimplementation value.
+- Immediate: rerun the DOSBox-vs-ScummVM startup menu / first-room comparison from the same save or boot path. Confirm the darker wall gradients and red menu lettering now sit closer to DOSBox-X at `GAMMA=0`, then sanity-check one dim room and one nonzero gamma setting to make sure the palette shift stays faithful outside the default case.
diff --git a/engines/harvester/palette_utils.h b/engines/harvester/palette_utils.h
index 7ca098911a5..5351eec8243 100644
--- a/engines/harvester/palette_utils.h
+++ b/engines/harvester/palette_utils.h
@@ -29,7 +29,8 @@
 namespace Harvester {
 
 inline byte expandHarvesterVgaDacColor(byte value) {
-	return (value * 255 + 31) / 63;
+	// Match VGA/DOSBox-X DAC expansion: replicate the high 2 bits into the low 2 bits.
+	return (value << 2) | (value >> 4);
 }
 
 inline void buildHarvesterDisplayPalette(const byte *source, float brightness, byte *dest) {


Commit: 2008a01a5aa6a7cf0cf35c875e7ca0fce47db5c2
    https://github.com/scummvm/scummvm/commit/2008a01a5aa6a7cf0cf35c875e7ca0fce47db5c2
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:51+02:00

Commit Message:
HARVESTER: Recover sergeant day 2 task menu

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/dialogue_debug.cpp
    engines/harvester/npc/sergeant_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 7ab0093e1bf..e46632f7d88 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -623,6 +623,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `play_c019b_fst_sequence` at `0x38620` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C019B.FST", 0)` and is currently only called from Boyle's `d2eb0 == 0` evidence-topic branch.
 - The adjacent `0x383d0`-`0x384e0` range is now confirmed to be another overlapping run of tiny read/write dialogue-state wrappers over globals in the same contiguous block (`0xd2f00` through `0xd2f28`).
   - `get_set_sergeant_completed_first_task_state` at `0x384e0` is the Sergeant-local bit set on the `SCRATCHED_TUCKER` branch immediately before dialogue `0x420d` ("So, you have completed your first task.").
+  - The `SCRATCHED_TUCKER` no-item path is not just that single bark. After `0x420d`, Sergeant continues with `0x421e`, opens zero-based `dialog.rsp` line `0x2b4`, and then either accepts the handoff with `0x422a` or pushes back through `0x422f` / `0x4239`.
   - The same block also contains the recovered Pottsdam alibi bits at `0x38440` and `0x38460`, so it is no longer Sergeant-only even though several remaining wrappers in that span are still only identified by locality.
   - `handle_talk_to_sergeant` calls into that run for quest-driven lines tied to `REMAINS`, `INVITE`, `BARBER_POLE`, `BOLTCLTH`, `SCRATCHED_TUCKER`, and `COMPLETED_LODGE_APPLICATION`.
   - The hidden intro / revisit tails at `0x30db4..0x30f98` are now bounded from native code and `DIALOG.RSP`: first contact is not a single bark. After `0x413f`, Sergeant runs the fixed `0x4144` / `0x4145` / `0x4146` / `0x4148` exchange, then drives response menus `0x2b7`, `0x2b8`, and `0x2b9`. Later no-item revisits start from `0x41ac`, optionally insert `0x41b0` when `HAVE_LODGE_APP` is still clear, then play `0x41b4` and branch through response menu `0x2bb`.
diff --git a/TRACKER.md b/TRACKER.md
index 66b57aca5d3..d9290e6b567 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,9 +10,10 @@
 
 ## Last Confirmed Action
 
-- On March 22, 2026, compared DOSBox-X output against ScummVM's startup room/menu palette handling and re-opened the native palette path in Ghidra.
-- Confirmed the executable's room/menu brightness behavior still matches the existing reverse-engineered gamma logic (`update_gamma_brightness_scale` keeps `1.0 + 0.1 * GAMMA` and `upload_palette_to_vga` still writes 6-bit DAC values after truncating `.PAL` bytes with `>> 2`), but ScummVM's `expandHarvesterVgaDacColor()` was re-expanding those DAC values with rounded `*255/63`. Patched `engines/harvester/palette_utils.h` to use VGA-faithful bit replication (`(value << 2) | (value >> 4)`), which matches DOSBox-X's 6-bit DAC expansion and should reduce the default "washed out" bias in midtones.
+- On March 22, 2026, re-opened Sergeant's `SCRATCHED_TUCKER` no-item branch against the shipped `DIALOGUE.IDX` and `DIALOG.RSP` assets after ScummVM stopped at Sergeant line `0x420d` without the expected Day 2 response menu.
+- Confirmed the missing continuation is asset-backed: Sergeant should continue with `0x421e`, open zero-based `dialog.rsp` line `0x2b4`, and then branch to `0x422a` or `0x422f` / `0x4239`. Patched `engines/harvester/npc/sergeant_dialogue.cpp`, updated the Sergeant dialogue debug dump and `ARCHITECTURE.md`, and rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 
 ## Next Suggested Action
 
-- Immediate: rerun the DOSBox-vs-ScummVM startup menu / first-room comparison from the same save or boot path. Confirm the darker wall gradients and red menu lettering now sit closer to DOSBox-X at `GAMMA=0`, then sanity-check one dim room and one nonzero gamma setting to make sure the palette shift stays faithful outside the default case.
+- Immediate: rerun the `DAY_2` Sergeant repro in `SERGEANTRM` and confirm ScummVM now shows the recovered `0x2b4` response menu after `0x420d` / `0x421e`, with the accept tail on `0x422a` and the pushback tail on `0x422f` / `0x4239`.
+- Follow-up: keep recovering Sergeant's later task-delivery gaps from the same asset-backed evidence, starting with the `BOLT_OF_CLOTH_TAKEN` and `BARBER_POLE_STOLEN` branches, which still collapse early in the current handler.
diff --git a/engines/harvester/dialogue_debug.cpp b/engines/harvester/dialogue_debug.cpp
index ad59119a71a..634884705b5 100644
--- a/engines/harvester/dialogue_debug.cpp
+++ b/engines/harvester/dialogue_debug.cpp
@@ -93,6 +93,13 @@ static void buildSergeantDialogueDebugDump(const Text &text, Common::Array<Commo
 	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x4241, 0);
 	appendLine(lines, "  IF FLAG SCRATCHED_TUCKER");
 	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x420d, 0);
+	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x421e, 0);
+	appendResponseMenu(lines, text, "    ", 0x2b4);
+	appendLine(lines, "      CHOICE 1");
+	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x422a, 0);
+	appendLine(lines, "      CHOICE 2");
+	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x422f, 0);
+	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x4239, 0);
 	appendLine(lines, "  IF FLAG HAVE_COMPLETED_LODGE_APP");
 	appendLine(lines, "    ACTION restore item COMPLETED_LODGE_APPLICATION to owner RAH");
 	appendLine(lines, "    IF !FLAG QUEST_1");
diff --git a/engines/harvester/npc/sergeant_dialogue.cpp b/engines/harvester/npc/sergeant_dialogue.cpp
index 9b39a47c5ad..c5b7391e811 100644
--- a/engines/harvester/npc/sergeant_dialogue.cpp
+++ b/engines/harvester/npc/sergeant_dialogue.cpp
@@ -32,6 +32,7 @@ namespace {
 
 static const char *const kDialogueC149FstPath = "GRAPHIC/FST/C149.FST";
 static const char *const kGoDay2ActionTag = "GO_DAY_2";
+static const int kSergeantSecondTaskResponseLine = 0x2b4;
 static const int kSergeantCompletedApplicationResponseLine = 0x2b6;
 static const int kSergeantIntroResponseLine = 0x2b7;
 static const int kSergeantApplicationResponseLine = 0x2b8;
@@ -225,6 +226,36 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		restoreItemToRah("BOLTCLTH");
 		return playSergeantLine(0x4241);
 	};
+	auto handleCompletedFirstTaskBranch = [&]() -> Common::Error {
+		markSergeantProgress();
+
+		Common::Error lineError = playSergeantLine(0x420d);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playSergeantLine(0x421e);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(
+			kSergeantSecondTaskResponseLine, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1)
+			return playSergeantLine(0x422a);
+
+		if (responseIndex == 2) {
+			const DialogueLineEntry reluctantTaskLines[] = {
+				{ 0x422f, "SERGEANT", 0 },
+				{ 0x4239, "SERGEANT", 0 }
+			};
+			return runtime.playDialogueEntrySequence(
+				reluctantTaskLines, ARRAYSIZE(reluctantTaskLines));
+		}
+
+		return Common::kNoError;
+	};
 	auto handleCompletedApplicationBranch = [&]() -> Common::Error {
 		sharedState.dialogueStateD2f08 = true;
 		sharedState.dialogueStateD2f00 = true;
@@ -312,12 +343,7 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (runtime.startupScript().getFlagValue("BOLT_OF_CLOTH_TAKEN"))
 			return handleBoltClothBranch();
 		if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER")) {
-			sharedState.dialogueStateD2f08 = true;
-			sharedState.dialogueStateD2f00 = true;
-			state.introPending = false;
-			state.dialogueStateD2d50 = true;
-			state.dialogueStateD2d54 = true;
-			return playSergeantLine(0x420d);
+			return handleCompletedFirstTaskBranch();
 		}
 		if (runtime.startupScript().getFlagValue("HAVE_COMPLETED_LODGE_APP"))
 			return handleCompletedApplicationBranch();


Commit: 0dc6834bdd9ddeb41608b7e244ad3701148a051a
    https://github.com/scummvm/scummvm/commit/0dc6834bdd9ddeb41608b7e244ad3701148a051a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:51+02:00

Commit Message:
HARVESTER: Restore sergeant day 3 handoff

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/dialogue_debug.cpp
    engines/harvester/npc/sergeant_dialogue.cpp
    engines/harvester/npc/sergeant_dialogue.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index e46632f7d88..d9ad700550e 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -623,7 +623,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `play_c019b_fst_sequence` at `0x38620` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C019B.FST", 0)` and is currently only called from Boyle's `d2eb0 == 0` evidence-topic branch.
 - The adjacent `0x383d0`-`0x384e0` range is now confirmed to be another overlapping run of tiny read/write dialogue-state wrappers over globals in the same contiguous block (`0xd2f00` through `0xd2f28`).
   - `get_set_sergeant_completed_first_task_state` at `0x384e0` is the Sergeant-local bit set on the `SCRATCHED_TUCKER` branch immediately before dialogue `0x420d` ("So, you have completed your first task.").
-  - The `SCRATCHED_TUCKER` no-item path is not just that single bark. After `0x420d`, Sergeant continues with `0x421e`, opens zero-based `dialog.rsp` line `0x2b4`, and then either accepts the handoff with `0x422a` or pushes back through `0x422f` / `0x4239`.
+  - The `SCRATCHED_TUCKER` no-item path is not just that single bark. After `0x420d`, Sergeant continues with `0x421e`, opens zero-based `dialog.rsp` line `0x2b4`, and then either accepts the handoff with `0x422a` or pushes back through `0x422f` / `0x4239`. Both outcomes then fall through a one-shot local `d2d58` gate that dispatches action tag `GO_DAY_3`.
   - The same block also contains the recovered Pottsdam alibi bits at `0x38440` and `0x38460`, so it is no longer Sergeant-only even though several remaining wrappers in that span are still only identified by locality.
   - `handle_talk_to_sergeant` calls into that run for quest-driven lines tied to `REMAINS`, `INVITE`, `BARBER_POLE`, `BOLTCLTH`, `SCRATCHED_TUCKER`, and `COMPLETED_LODGE_APPLICATION`.
   - The hidden intro / revisit tails at `0x30db4..0x30f98` are now bounded from native code and `DIALOG.RSP`: first contact is not a single bark. After `0x413f`, Sergeant runs the fixed `0x4144` / `0x4145` / `0x4146` / `0x4148` exchange, then drives response menus `0x2b7`, `0x2b8`, and `0x2b9`. Later no-item revisits start from `0x41ac`, optionally insert `0x41b0` when `HAVE_LODGE_APP` is still clear, then play `0x41b4` and branch through response menu `0x2bb`.
diff --git a/TRACKER.md b/TRACKER.md
index d9290e6b567..157a008caba 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,17 +3,20 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `906`
+- Total functions: `908`
 - Named/documented: `791`
-- Remaining `FUN_*`: `115`
-- Remaining undocumented total: `115`
+- Remaining `FUN_*`: `117`
+- Remaining undocumented total: `117`
 
 ## Last Confirmed Action
 
 - On March 22, 2026, re-opened Sergeant's `SCRATCHED_TUCKER` no-item branch against the shipped `DIALOGUE.IDX` and `DIALOG.RSP` assets after ScummVM stopped at Sergeant line `0x420d` without the expected Day 2 response menu.
 - Confirmed the missing continuation is asset-backed: Sergeant should continue with `0x421e`, open zero-based `dialog.rsp` line `0x2b4`, and then branch to `0x422a` or `0x422f` / `0x4239`. Patched `engines/harvester/npc/sergeant_dialogue.cpp`, updated the Sergeant dialogue debug dump and `ARCHITECTURE.md`, and rebuilt `build-vscode-harvester-debug/scummvm` successfully.
+- On March 22, 2026, cleaned up the native Sergeant room handler in Ghidra by rebuilding `handle_talk_to_sergeant` at `0x30340` from a broken one-byte stub into its real front-half body, setting an explicit `void __stdcall` prototype, and renaming the Sergeant-local `0xd2d50..0xd2d70` state slots to stable labels. The only remaining decompiler noise in that front half is the known register-lift artifact on `ASSIGNED_DNA_TASK` and the odd extra register shown on the `BOLTCLTH` branch.
+- On March 22, 2026, recovered the remaining in-line Sergeant continuation heads from native control flow instead of raw asset chasing: labeled and commented `sergeant_completed_application_followup` (`0x30c42`), `sergeant_intro_or_revisit_dispatch` (`0x30d7e`), `sergeant_first_contact_intro_sequence` (`0x30d8b`), `sergeant_revisit_reminder_sequence` (`0x30eff`), and the shared `sergeant_day5_exit_check` / `sergeant_day5_exit_branch` (`0x30f99` / `0x30fa7`). The plate comment on `handle_talk_to_sergeant` now points directly at those continuation blocks.
+- On March 22, 2026, reapplied the recovered Sergeant `SCRATCHED_TUCKER` continuation to ScummVM: after the `0x2b4` response menu, both the `0x422a` accept tail and the `0x422f` / `0x4239` pushback tail now fall through a one-shot local `dialogueStateD2d58` gate that dispatches `GO_DAY_3`, matching the native `0x30b84..0x30ba6` action-tag handoff. Updated `engines/harvester/npc/sergeant_dialogue.cpp`, `engines/harvester/dialogue_debug.cpp`, `ARCHITECTURE.md`, and rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 
 ## Next Suggested Action
 
-- Immediate: rerun the `DAY_2` Sergeant repro in `SERGEANTRM` and confirm ScummVM now shows the recovered `0x2b4` response menu after `0x420d` / `0x421e`, with the accept tail on `0x422a` and the pushback tail on `0x422f` / `0x4239`.
-- Follow-up: keep recovering Sergeant's later task-delivery gaps from the same asset-backed evidence, starting with the `BOLT_OF_CLOTH_TAKEN` and `BARBER_POLE_STOLEN` branches, which still collapse early in the current handler.
+- Immediate: rerun the `SERGEANTRM` `SCRATCHED_TUCKER` repro and confirm the `0x2b4` menu now falls through into the native `GO_DAY_3` handoff, including the expected end-of-day FST/cutscene transition after either response branch.
+- Follow-up: if the day transition now matches native, return to the remaining Sergeant signature cleanup in Ghidra so the `ASSIGNED_DNA_TASK` and `BOLTCLTH` branches decompile as plain literal `1` writes.
diff --git a/engines/harvester/dialogue_debug.cpp b/engines/harvester/dialogue_debug.cpp
index 634884705b5..e708c27513f 100644
--- a/engines/harvester/dialogue_debug.cpp
+++ b/engines/harvester/dialogue_debug.cpp
@@ -70,26 +70,31 @@ static void appendSergeantDay5Exit(Common::Array<Common::String> &lines, const T
 
 static void buildSergeantDialogueDebugDump(const Text &text, Common::Array<Common::String> &lines) {
 	appendLine(lines, "NPC SERGEANT");
-	appendLine(lines, "STATE sergeant.introPending, sergeant.dialogueStateD2d50, sergeant.dialogueStateD2d54, sergeant.day5ReminderShown");
+	appendLine(lines, "STATE sergeant.introPending, sergeant.dialogueStateD2d50, sergeant.dialogueStateD2d54, sergeant.dialogueStateD2d58, sergeant.day5ReminderShown");
 	appendLine(lines, "BRANCH no_item");
 	appendLine(lines, "  IF FLAG GOT_REMAINS_FOR_LODGE");
 	appendLine(lines, "    ACTION restore item REMAINS to owner RAH");
+	appendLine(lines, "    ACTION set STATE sergeant.dialogueStateD2d58");
 	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x4333, 0);
 	appendLine(lines, "  IF FLAG STEPHANIE_IS_DEAD && (INVENTORY INVITE || FLAG TAKEN_INVITE_TO_SERGEANT)");
 	appendLine(lines, "    ACTION show ST_BEDRM/STEFSKULL");
 	appendLine(lines, "    ACTION restore item INVITE to owner RAH");
 	appendLine(lines, "    ACTION set flags TAKEN_INVITE_TO_SERGEANT, NEED_REMAINS_FOR_LODGE");
+	appendLine(lines, "    ACTION set STATE sergeant.dialogueStateD2d58");
 	appendLine(lines, "    ACTION hide NPCs DWAYNE_ST_BEDRM, MRS_POTTS_ST_BEDRM; show NPC MRS_POTTS");
 	appendLine(lines, "    ACTION play FST GRAPHIC/FST/C149.FST");
 	appendDialogueLine(lines, text, "    ", "PC", 0x4305, 0);
 	appendLine(lines, "  IF FLAG DINER_BURNED");
+	appendLine(lines, "    ACTION set STATE sergeant.dialogueStateD2d58");
 	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x42b5, 1);
 	appendLine(lines, "  IF FLAG BARBER_POLE_STOLEN");
 	appendLine(lines, "    ACTION restore item BARBER_POLE to owner RAH");
 	appendLine(lines, "    ACTION set flag ASSIGNED_DNA_TASK");
+	appendLine(lines, "    ACTION set STATE sergeant.dialogueStateD2d58");
 	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x426f, 0);
 	appendLine(lines, "  IF FLAG BOLT_OF_CLOTH_TAKEN");
 	appendLine(lines, "    ACTION restore item BOLTCLTH to owner RAH");
+	appendLine(lines, "    ACTION set STATE sergeant.dialogueStateD2d58");
 	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x4241, 0);
 	appendLine(lines, "  IF FLAG SCRATCHED_TUCKER");
 	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x420d, 0);
@@ -100,6 +105,9 @@ static void buildSergeantDialogueDebugDump(const Text &text, Common::Array<Commo
 	appendLine(lines, "      CHOICE 2");
 	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x422f, 0);
 	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x4239, 0);
+	appendLine(lines, "    IF !STATE sergeant.dialogueStateD2d58");
+	appendLine(lines, "      ACTION set STATE sergeant.dialogueStateD2d58");
+	appendLine(lines, "      ACTION execute GO_DAY_3");
 	appendLine(lines, "  IF FLAG HAVE_COMPLETED_LODGE_APP");
 	appendLine(lines, "    ACTION restore item COMPLETED_LODGE_APPLICATION to owner RAH");
 	appendLine(lines, "    IF !FLAG QUEST_1");
diff --git a/engines/harvester/npc/sergeant_dialogue.cpp b/engines/harvester/npc/sergeant_dialogue.cpp
index c5b7391e811..9242411559b 100644
--- a/engines/harvester/npc/sergeant_dialogue.cpp
+++ b/engines/harvester/npc/sergeant_dialogue.cpp
@@ -32,6 +32,7 @@ namespace {
 
 static const char *const kDialogueC149FstPath = "GRAPHIC/FST/C149.FST";
 static const char *const kGoDay2ActionTag = "GO_DAY_2";
+static const char *const kGoDay3ActionTag = "GO_DAY_3";
 static const int kSergeantSecondTaskResponseLine = 0x2b4;
 static const int kSergeantCompletedApplicationResponseLine = 0x2b6;
 static const int kSergeantIntroResponseLine = 0x2b7;
@@ -196,11 +197,13 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto handleRemainsBranch = [&]() -> Common::Error {
 		markSergeantProgress();
+		state.dialogueStateD2d58 = true;
 		restoreItemToRah("REMAINS");
 		return playSergeantLine(0x4333);
 	};
 	auto handleInviteBranch = [&]() -> Common::Error {
 		markSergeantProgress();
+		state.dialogueStateD2d58 = true;
 		(void)runtime.startupScript().resetRuntimeObjectToInitialState("STEFSKULL");
 		(void)runtime.startupScript().setRuntimeObjectVisible("ST_BEDRM", "STEFSKULL", true);
 		restoreItemToRah("INVITE");
@@ -217,12 +220,14 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto handleBarberPoleBranch = [&]() -> Common::Error {
 		markSergeantProgress();
+		state.dialogueStateD2d58 = true;
 		restoreItemToRah("BARBER_POLE");
 		(void)runtime.startupScript().setRuntimeFlagValue("ASSIGNED_DNA_TASK", true);
 		return playSergeantLine(0x426f);
 	};
 	auto handleBoltClothBranch = [&]() -> Common::Error {
 		markSergeantProgress();
+		state.dialogueStateD2d58 = true;
 		restoreItemToRah("BOLTCLTH");
 		return playSergeantLine(0x4241);
 	};
@@ -242,18 +247,24 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 
+		lineError = Common::kNoError;
 		if (responseIndex == 1)
-			return playSergeantLine(0x422a);
-
-		if (responseIndex == 2) {
+			lineError = playSergeantLine(0x422a);
+		else if (responseIndex == 2) {
 			const DialogueLineEntry reluctantTaskLines[] = {
 				{ 0x422f, "SERGEANT", 0 },
 				{ 0x4239, "SERGEANT", 0 }
 			};
-			return runtime.playDialogueEntrySequence(
+			lineError = runtime.playDialogueEntrySequence(
 				reluctantTaskLines, ARRAYSIZE(reluctantTaskLines));
 		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 
+		if (!state.dialogueStateD2d58) {
+			state.dialogueStateD2d58 = true;
+			return executeActionTagIfSet(kGoDay3ActionTag);
+		}
 		return Common::kNoError;
 	};
 	auto handleCompletedApplicationBranch = [&]() -> Common::Error {
@@ -336,6 +347,7 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 		if (runtime.startupScript().getFlagValue("DINER_BURNED")) {
 			markSergeantProgress();
+			state.dialogueStateD2d58 = true;
 			return playSergeantLine(0x42b5, 1);
 		}
 		if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN"))
diff --git a/engines/harvester/npc/sergeant_dialogue.h b/engines/harvester/npc/sergeant_dialogue.h
index a42259a39c4..09ee93462db 100644
--- a/engines/harvester/npc/sergeant_dialogue.h
+++ b/engines/harvester/npc/sergeant_dialogue.h
@@ -32,6 +32,7 @@ public:
 		bool introPending = true;
 		bool dialogueStateD2d50 = false;
 		bool dialogueStateD2d54 = false;
+		bool dialogueStateD2d58 = false;
 		bool day5ReminderShown = false;
 	};
 


Commit: 5ff2a880af9755cfb1cd9285f51db55a243b22f0
    https://github.com/scummvm/scummvm/commit/5ff2a880af9755cfb1cd9285f51db55a243b22f0
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:51+02:00

Commit Message:
HARVESTER: Remove debug dialogue console command

Changed paths:
    engines/harvester/console.cpp
    engines/harvester/console.h


diff --git a/engines/harvester/console.cpp b/engines/harvester/console.cpp
index e1587c8d5d8..5ec6a02d8fb 100644
--- a/engines/harvester/console.cpp
+++ b/engines/harvester/console.cpp
@@ -20,14 +20,12 @@
  */
 
 #include "harvester/console.h"
-#include "harvester/dialogue_debug.h"
 #include "harvester/harvester.h"
 
 namespace Harvester {
 
 Console::Console() : GUI::Debugger() {
 	registerCmd("about", WRAP_METHOD(Console, Cmd_about));
-	registerCmd("DEBUG_DIALOGUE", WRAP_METHOD(Console, Cmd_debugDialogue));
 	registerCmd("DEBUG_ROOM", WRAP_METHOD(Console, Cmd_debugRoom));
 }
 
@@ -39,34 +37,6 @@ bool Console::Cmd_about(int argc, const char **argv) {
 	return true;
 }
 
-bool Console::Cmd_debugDialogue(int argc, const char **argv) {
-	if (argc != 2) {
-		debugPrintf("Usage: DEBUG_DIALOGUE <NPC_ID>\n");
-		return true;
-	}
-
-	if (!g_engine) {
-		debugPrintf("Harvester engine is not active\n");
-		return true;
-	}
-
-	Text *startupText = g_engine->getStartupText();
-	if (!startupText) {
-		debugPrintf("Harvester startup text is not loaded\n");
-		return true;
-	}
-
-	Common::Array<Common::String> dumpLines;
-	if (!buildDialogueDebugDump(argv[1], *startupText, dumpLines)) {
-		debugPrintf("No structured dialogue dump is available for NPC '%s'\n", argv[1]);
-		return true;
-	}
-
-	for (uint i = 0; i < dumpLines.size(); ++i)
-		debugPrintf("%s\n", dumpLines[i].c_str());
-	return true;
-}
-
 bool Console::Cmd_debugRoom(int argc, const char **argv) {
 	if (argc != 1) {
 		debugPrintf("Usage: DEBUG_ROOM\n");
diff --git a/engines/harvester/console.h b/engines/harvester/console.h
index 4bc37d442fd..076c77b8b96 100644
--- a/engines/harvester/console.h
+++ b/engines/harvester/console.h
@@ -30,7 +30,6 @@ namespace Harvester {
 class Console : public GUI::Debugger {
 private:
 	bool Cmd_about(int argc, const char **argv);
-	bool Cmd_debugDialogue(int argc, const char **argv);
 	bool Cmd_debugRoom(int argc, const char **argv);
 public:
 	Console();


Commit: 6ee968267b0fb6c1fd835030899d4085646b6e34
    https://github.com/scummvm/scummvm/commit/6ee968267b0fb6c1fd835030899d4085646b6e34
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:52+02:00

Commit Message:
HARVESTER: Remove dialogue debug helper

Changed paths:
  R engines/harvester/dialogue_debug.cpp
  R engines/harvester/dialogue_debug.h
    engines/harvester/module.mk


diff --git a/engines/harvester/dialogue_debug.cpp b/engines/harvester/dialogue_debug.cpp
deleted file mode 100644
index e708c27513f..00000000000
--- a/engines/harvester/dialogue_debug.cpp
+++ /dev/null
@@ -1,212 +0,0 @@
-/* ScummVM - Graphic Adventure Engine
- *
- * ScummVM is the legal property of its developers, whose names
- * are too numerous to list here. Please refer to the COPYRIGHT
- * file distributed with this source distribution.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-#include "harvester/dialogue_debug.h"
-
-#include "harvester/text.h"
-
-namespace Harvester {
-
-namespace {
-
-static Common::String buildSubtitleSummary(const Text &text, int wavId) {
-	Common::String subtitle;
-	if (!text.resolveDialogueSubtitle(wavId, subtitle))
-		subtitle = "<missing subtitle>";
-	return subtitle;
-}
-
-static Common::String buildResponseSummary(const Text &text, int responseLineIndex) {
-	Common::String responseLine = text.getDialogueResponseLine(responseLineIndex);
-	if (responseLine.empty())
-		responseLine = "<missing response line>";
-	return responseLine;
-}
-
-static void appendLine(Common::Array<Common::String> &lines, const Common::String &line) {
-	lines.push_back(line);
-}
-
-static void appendDialogueLine(Common::Array<Common::String> &lines, const Text &text,
-		const char *indent, const char *speakerId, int wavId, int headVariant) {
-	appendLine(lines, Common::String::format(
-		"%sLINE speaker=%s wav=0x%x (%d) head=%d: %s",
-		indent, speakerId, wavId, wavId, headVariant, buildSubtitleSummary(text, wavId).c_str()));
-}
-
-static void appendResponseMenu(Common::Array<Common::String> &lines, const Text &text,
-		const char *indent, int responseLineIndex) {
-	appendLine(lines, Common::String::format(
-		"%sMENU rsp=0x%x (%d): %s",
-		indent, responseLineIndex, responseLineIndex + 1,
-		buildResponseSummary(text, responseLineIndex).c_str()));
-}
-
-static void appendSergeantDay5Exit(Common::Array<Common::String> &lines, const Text &text,
-		const char *indent) {
-	appendLine(lines, Common::String::format(
-		"%sIF FLAG DAY_5 && !STATE sergeant.day5ReminderShown", indent));
-	appendDialogueLine(lines, text, Common::String::format("%s  ", indent).c_str(),
-		"SERGEANT", 0x4324, 1);
-}
-
-static void buildSergeantDialogueDebugDump(const Text &text, Common::Array<Common::String> &lines) {
-	appendLine(lines, "NPC SERGEANT");
-	appendLine(lines, "STATE sergeant.introPending, sergeant.dialogueStateD2d50, sergeant.dialogueStateD2d54, sergeant.dialogueStateD2d58, sergeant.day5ReminderShown");
-	appendLine(lines, "BRANCH no_item");
-	appendLine(lines, "  IF FLAG GOT_REMAINS_FOR_LODGE");
-	appendLine(lines, "    ACTION restore item REMAINS to owner RAH");
-	appendLine(lines, "    ACTION set STATE sergeant.dialogueStateD2d58");
-	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x4333, 0);
-	appendLine(lines, "  IF FLAG STEPHANIE_IS_DEAD && (INVENTORY INVITE || FLAG TAKEN_INVITE_TO_SERGEANT)");
-	appendLine(lines, "    ACTION show ST_BEDRM/STEFSKULL");
-	appendLine(lines, "    ACTION restore item INVITE to owner RAH");
-	appendLine(lines, "    ACTION set flags TAKEN_INVITE_TO_SERGEANT, NEED_REMAINS_FOR_LODGE");
-	appendLine(lines, "    ACTION set STATE sergeant.dialogueStateD2d58");
-	appendLine(lines, "    ACTION hide NPCs DWAYNE_ST_BEDRM, MRS_POTTS_ST_BEDRM; show NPC MRS_POTTS");
-	appendLine(lines, "    ACTION play FST GRAPHIC/FST/C149.FST");
-	appendDialogueLine(lines, text, "    ", "PC", 0x4305, 0);
-	appendLine(lines, "  IF FLAG DINER_BURNED");
-	appendLine(lines, "    ACTION set STATE sergeant.dialogueStateD2d58");
-	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x42b5, 1);
-	appendLine(lines, "  IF FLAG BARBER_POLE_STOLEN");
-	appendLine(lines, "    ACTION restore item BARBER_POLE to owner RAH");
-	appendLine(lines, "    ACTION set flag ASSIGNED_DNA_TASK");
-	appendLine(lines, "    ACTION set STATE sergeant.dialogueStateD2d58");
-	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x426f, 0);
-	appendLine(lines, "  IF FLAG BOLT_OF_CLOTH_TAKEN");
-	appendLine(lines, "    ACTION restore item BOLTCLTH to owner RAH");
-	appendLine(lines, "    ACTION set STATE sergeant.dialogueStateD2d58");
-	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x4241, 0);
-	appendLine(lines, "  IF FLAG SCRATCHED_TUCKER");
-	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x420d, 0);
-	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x421e, 0);
-	appendResponseMenu(lines, text, "    ", 0x2b4);
-	appendLine(lines, "      CHOICE 1");
-	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x422a, 0);
-	appendLine(lines, "      CHOICE 2");
-	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x422f, 0);
-	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x4239, 0);
-	appendLine(lines, "    IF !STATE sergeant.dialogueStateD2d58");
-	appendLine(lines, "      ACTION set STATE sergeant.dialogueStateD2d58");
-	appendLine(lines, "      ACTION execute GO_DAY_3");
-	appendLine(lines, "  IF FLAG HAVE_COMPLETED_LODGE_APP");
-	appendLine(lines, "    ACTION restore item COMPLETED_LODGE_APPLICATION to owner RAH");
-	appendLine(lines, "    IF !FLAG QUEST_1");
-	appendDialogueLine(lines, text, "      ", "SERGEANT", 0x41cd, 1);
-	appendDialogueLine(lines, text, "      ", "SERGEANT", 0x4292, 0);
-	appendLine(lines, "      ACTION set flag QUEST_1");
-	appendLine(lines, "      ACTION execute GO_DAY_2");
-	appendLine(lines, "    ELSE IF !STATE sergeant.dialogueStateD2d54");
-	appendDialogueLine(lines, text, "      ", "SERGEANT", 0x41d2, 1);
-	appendResponseMenu(lines, text, "      ", 0x2b6);
-	appendLine(lines, "        CHOICE 1");
-	appendDialogueLine(lines, text, "          ", "SERGEANT", 0x41db, 0);
-	appendDialogueLine(lines, text, "          ", "PC", 0x41e2, 0);
-	appendDialogueLine(lines, text, "          ", "SERGEANT", 0x41e6, 0);
-	appendDialogueLine(lines, text, "          ", "PC", 0x41ec, 2);
-	appendDialogueLine(lines, text, "          ", "SERGEANT", 0x41f0, 0);
-	appendDialogueLine(lines, text, "          ", "PC", 0x41f6, 0);
-	appendDialogueLine(lines, text, "          ", "SERGEANT", 0x41fa, 0);
-	appendLine(lines, "          ACTION set STATE sergeant.dialogueStateD2d54");
-	appendLine(lines, "        CHOICE 2");
-	appendDialogueLine(lines, text, "          ", "SERGEANT", 0x4201, 3);
-	appendDialogueLine(lines, text, "          ", "SERGEANT", 0x4202, 3);
-	appendDialogueLine(lines, text, "          ", "SERGEANT", 0x4203, 2);
-	appendLine(lines, "          ACTION clear STATE sergeant.dialogueStateD2d54");
-	appendSergeantDay5Exit(lines, text, "      ");
-	appendLine(lines, "    ELSE");
-	appendDialogueLine(lines, text, "      ", "SERGEANT", 0x41f0, 0);
-	appendDialogueLine(lines, text, "      ", "PC", 0x41f6, 0);
-	appendDialogueLine(lines, text, "      ", "SERGEANT", 0x41fa, 0);
-	appendSergeantDay5Exit(lines, text, "      ");
-	appendLine(lines, "  IF STATE sergeant.introPending");
-	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x413f, 2);
-	appendDialogueLine(lines, text, "    ", "PC", 0x4144, 0);
-	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x4145, 0);
-	appendDialogueLine(lines, text, "    ", "PC", 0x4146, 0);
-	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x4148, 2);
-	appendResponseMenu(lines, text, "    ", 0x2b7);
-	appendLine(lines, "      CHOICE 1");
-	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x4153, 0);
-	appendLine(lines, "      CHOICE 2");
-	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x4157, 1);
-	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x415d, 0);
-	appendResponseMenu(lines, text, "    ", 0x2b8);
-	appendLine(lines, "      CHOICE 1");
-	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x4168, 1);
-	appendDialogueLine(lines, text, "        ", "PC", 0x416c, 0);
-	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x4170, 1);
-	appendResponseMenu(lines, text, "        ", 0x2b9);
-	appendLine(lines, "          CHOICE 1");
-	appendDialogueLine(lines, text, "            ", "SERGEANT", 0x417c, 0);
-	appendLine(lines, "          CHOICE 2");
-	appendDialogueLine(lines, text, "            ", "SERGEANT", 0x4182, 2);
-	appendLine(lines, "      ELSE");
-	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x4188, 0);
-	appendDialogueLine(lines, text, "        ", "PC", 0x418e, 0);
-	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x4192, 0);
-	appendSergeantDay5Exit(lines, text, "    ");
-	appendLine(lines, "  ELSE");
-	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x41ac, 0);
-	appendLine(lines, "    IF !FLAG HAVE_LODGE_APP");
-	appendDialogueLine(lines, text, "      ", "SERGEANT", 0x41b0, 0);
-	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x41b4, 3);
-	appendResponseMenu(lines, text, "    ", 0x2bb);
-	appendLine(lines, "      CHOICE 1");
-	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x41bf, 2);
-	appendLine(lines, "        ACTION set STATE sergeant.dialogueStateD2d50");
-	appendLine(lines, "      CHOICE 2");
-	appendDialogueLine(lines, text, "        ", "SERGEANT", 0x41c4, 0);
-	appendSergeantDay5Exit(lines, text, "    ");
-	appendLine(lines, "BRANCH item_use");
-	appendLine(lines, "  ITEM BARBER_POLE");
-	appendLine(lines, "    ACTION restore item BARBER_POLE to owner RAH");
-	appendLine(lines, "    ACTION set flag ASSIGNED_DNA_TASK");
-	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x426f, 0);
-	appendLine(lines, "  ITEM BOLTCLTH");
-	appendLine(lines, "    ACTION restore item BOLTCLTH to owner RAH");
-	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x4241, 0);
-	appendLine(lines, "  ITEM COMPLETED_LODGE_APPLICATION");
-	appendLine(lines, "    SAME AS no_item / FLAG HAVE_COMPLETED_LODGE_APP branch");
-	appendLine(lines, "  ITEM INVITE");
-	appendLine(lines, "    SAME AS no_item / STEPHANIE_IS_DEAD invite branch");
-	appendLine(lines, "  ITEM REMAINS");
-	appendLine(lines, "    SAME AS no_item / GOT_REMAINS_FOR_LODGE branch");
-	appendLine(lines, "  ELSE");
-	appendDialogueLine(lines, text, "    ", "SERGEANT", 0x42fa, 0);
-}
-
-} // End of anonymous namespace
-
-bool buildDialogueDebugDump(const Common::String &npcName, const Text &text,
-		Common::Array<Common::String> &lines) {
-	lines.clear();
-
-	if (npcName.equalsIgnoreCase("SERGEANT")) {
-		buildSergeantDialogueDebugDump(text, lines);
-		return true;
-	}
-
-	return false;
-}
-
-} // End of namespace Harvester
diff --git a/engines/harvester/dialogue_debug.h b/engines/harvester/dialogue_debug.h
deleted file mode 100644
index 4a68324e572..00000000000
--- a/engines/harvester/dialogue_debug.h
+++ /dev/null
@@ -1,37 +0,0 @@
-/* ScummVM - Graphic Adventure Engine
- *
- * ScummVM is the legal property of its developers, whose names
- * are too numerous to list here. Please refer to the COPYRIGHT
- * file distributed with this source distribution.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-#ifndef HARVESTER_DIALOGUE_DEBUG_H
-#define HARVESTER_DIALOGUE_DEBUG_H
-
-#include "common/array.h"
-#include "common/str.h"
-
-namespace Harvester {
-
-class Text;
-
-bool buildDialogueDebugDump(const Common::String &npcName, const Text &text,
-	Common::Array<Common::String> &lines);
-
-} // End of namespace Harvester
-
-#endif // HARVESTER_DIALOGUE_DEBUG_H
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 30367e6194f..b09ac75d113 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -11,7 +11,6 @@ MODULE := engines/harvester
 	npc/chessmaster_dialogue.o \
 	npc/cloak_atnd_dialogue.o \
 	console.o \
-	dialogue_debug.o \
 	dialogue.o \
 	fst_player.o \
 	metaengine.o \


Commit: 75f0f961ae33fe8018e22d16aaed76440c5bd27f
    https://github.com/scummvm/scummvm/commit/75f0f961ae33fe8018e22d16aaed76440c5bd27f
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:52+02:00

Commit Message:
HARVESTER: Reimplement sergeant dialogue flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/sergeant_dialogue.cpp
    engines/harvester/npc/sergeant_dialogue.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index d9ad700550e..a8deb124f41 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -626,6 +626,12 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - The `SCRATCHED_TUCKER` no-item path is not just that single bark. After `0x420d`, Sergeant continues with `0x421e`, opens zero-based `dialog.rsp` line `0x2b4`, and then either accepts the handoff with `0x422a` or pushes back through `0x422f` / `0x4239`. Both outcomes then fall through a one-shot local `d2d58` gate that dispatches action tag `GO_DAY_3`.
   - The same block also contains the recovered Pottsdam alibi bits at `0x38440` and `0x38460`, so it is no longer Sergeant-only even though several remaining wrappers in that span are still only identified by locality.
   - `handle_talk_to_sergeant` calls into that run for quest-driven lines tied to `REMAINS`, `INVITE`, `BARBER_POLE`, `BOLTCLTH`, `SCRATCHED_TUCKER`, and `COMPLETED_LODGE_APPLICATION`.
+  - The native item/event branches are wider than the earlier engine stubs:
+    - `REMAINS` is the late story payoff branch, not a single line. After `0x4333`, Sergeant plays `C150.FST`, continues through `0x433f` / `0x4344` / `0x4348` / `0x434f` / `0x4353`, opens response menu `0x2ad`, then continues through `0x4368` / `0x4372` / `0x4376` / `0x4381` / `0x4385` before the palette-clear / `C076.FST` tail.
+    - `INVITE` now cleanly includes `0x4305`, `0x4309`, response menu `0x2ae`, optional `0x4314` or `0x4319`, and the common `0x431d` exit while also toggling the Mrs. Potts / Dwayne room-state handoff.
+    - `DINER_BURNED` is `0x42b5`, `0x42b9`, response menu `0x2af`, optional `0x42c5` or `0x42cb`, then the common `0x42d2` tail.
+    - `BARBER_POLE` is `0x426f`, `0x4274`, response menu `0x2b1`, optional `0x4281` or `0x4289`, then the common `0x428d` tail while also setting `ASSIGNED_DNA_TASK`.
+    - `BOLTCLTH` is `0x4241`, `0x4245`, `0x4252`, response menu `0x2b3`, optional `0x425e` or `0x4266`, then the firemen-suicide world-state handoff: `FIREMEN_SUICIDE`, `ASSIGNED_POLE_TASK`, and queued `SLASH` death/monsterfy transitions for `SPARKY`, `NUDE_MAN`, and `FIREMAN2`.
   - The hidden intro / revisit tails at `0x30db4..0x30f98` are now bounded from native code and `DIALOG.RSP`: first contact is not a single bark. After `0x413f`, Sergeant runs the fixed `0x4144` / `0x4145` / `0x4146` / `0x4148` exchange, then drives response menus `0x2b7`, `0x2b8`, and `0x2b9`. Later no-item revisits start from `0x41ac`, optionally insert `0x41b0` when `HAVE_LODGE_APP` is still clear, then play `0x41b4` and branch through response menu `0x2bb`.
   - The native `COMPLETED_LODGE_APPLICATION` Monday branch is a deferred quest handoff rather than a single bark: after restoring the item to `RAH`, the handler plays Sergeant lines `0x41cd` then `0x4292`, sets `QUEST_1`, and dispatches action tag `GO_DAY_2`, which drives the story-day advance / cutscene path.
   - The later `QUEST_1`-set application branch is also response-driven. If the local `d2d54` bit is still clear, Sergeant plays `0x41d2`, opens response menu `0x2b6`, and either (choice `1`) continues through `0x41db` / `0x41e2` / `0x41e6` / `0x41ec` / `0x41f0` / `0x41f6` / `0x41fa` while setting `d2d54`, or (choice `2`) plays `0x4201` / `0x4202` / `0x4203` and leaves `d2d54` clear. Once `d2d54` is set, revisits skip the menu and replay the `0x41f0` / `0x41f6` / `0x41fa` task briefing directly.
diff --git a/TRACKER.md b/TRACKER.md
index 157a008caba..fd7e847f76c 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,10 +3,10 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `908`
-- Named/documented: `791`
-- Remaining `FUN_*`: `117`
-- Remaining undocumented total: `117`
+- Total functions: `907`
+- Named/documented: `792`
+- Remaining `FUN_*`: `115`
+- Remaining undocumented total: `115`
 
 ## Last Confirmed Action
 
@@ -15,8 +15,15 @@
 - On March 22, 2026, cleaned up the native Sergeant room handler in Ghidra by rebuilding `handle_talk_to_sergeant` at `0x30340` from a broken one-byte stub into its real front-half body, setting an explicit `void __stdcall` prototype, and renaming the Sergeant-local `0xd2d50..0xd2d70` state slots to stable labels. The only remaining decompiler noise in that front half is the known register-lift artifact on `ASSIGNED_DNA_TASK` and the odd extra register shown on the `BOLTCLTH` branch.
 - On March 22, 2026, recovered the remaining in-line Sergeant continuation heads from native control flow instead of raw asset chasing: labeled and commented `sergeant_completed_application_followup` (`0x30c42`), `sergeant_intro_or_revisit_dispatch` (`0x30d7e`), `sergeant_first_contact_intro_sequence` (`0x30d8b`), `sergeant_revisit_reminder_sequence` (`0x30eff`), and the shared `sergeant_day5_exit_check` / `sergeant_day5_exit_branch` (`0x30f99` / `0x30fa7`). The plate comment on `handle_talk_to_sergeant` now points directly at those continuation blocks.
 - On March 22, 2026, reapplied the recovered Sergeant `SCRATCHED_TUCKER` continuation to ScummVM: after the `0x2b4` response menu, both the `0x422a` accept tail and the `0x422f` / `0x4239` pushback tail now fall through a one-shot local `dialogueStateD2d58` gate that dispatches `GO_DAY_3`, matching the native `0x30b84..0x30ba6` action-tag handoff. Updated `engines/harvester/npc/sergeant_dialogue.cpp`, `engines/harvester/dialogue_debug.cpp`, `ARCHITECTURE.md`, and rebuilt `build-vscode-harvester-debug/scummvm` successfully.
+- On March 22, 2026, added a batch Ghidra audit script at `~/ghidra_scripts/RecoverTalkHandlerReachability.java` to walk `g_talk_to_handler_entries` in one pass, verify every NPC talk-handler pointer resolves to a real function entry, flag any table target that lands inside an existing function instead of at its entry point, and scan each recovered `handle_talk_to_*` body for off-function direct `CALL` or `JMP` targets. Ran the script against `HARVEST.LE` in report mode and confirmed the current database is clean: `48` table handlers, `0` missing table targets, `0` mid-function table targets, `0` missing call targets, and `0` missing jump-only continuation blocks.
+- On March 22, 2026, confirmed the remaining orphan NPC talk-handler code is not a bad `g_talk_to_handler_entries` pointer-table issue. The real failure mode is Ghidra analysis around `play_dialogue_line`: many talk handlers had dialogue call sites incorrectly marked `CALL_RETURN`, and `play_dialogue_line` itself kept being rediscovered as `noreturn`, which truncated handler bodies and left the next code blocks unmapped. Added `~/ghidra_scripts/AuditTalkHandlerDialogueFallthroughs.java` and `~/ghidra_scripts/InspectCallTermination.java` to prove the pattern and verify call-site state.
+- On March 22, 2026, re-ran the working manual recovery sequence on live handlers: force `play_dialogue_line` back to returning, clear the bad flow overrides at the affected dialogue call sites, disassemble the fallthrough bytes immediately after those calls, then delete/recreate the handler entry. That sequence cleanly reattached Sergeant's `0x30b28` continuation back under `handle_talk_to_sergeant`, and also expanded `handle_talk_to_stephanie` from its truncated `0x526`-byte body to a wider `0x668`-byte body. The problem is iterative rather than one-shot: once additional continuation blocks are absorbed, newly reachable `play_dialogue_line` sites inside those blocks need the same treatment on the next pass.
+- On March 22, 2026, pushed the iterative Sergeant recovery through two more dialogue-call waves. `play_dialogue_line` and `thunk_play_dialogue_line` now remain marked returning in the active database, `handle_talk_to_sergeant` rebuilt cleanly to a `0x8a1`-byte body (`0x30340..0x30fd2`), and the decompile now reaches the native `SCRATCHED_TUCKER` Day 3 handoff (`dispatch_action_tag_if_set("GO_DAY_3")`) plus the shared Day 5 exit gate at `0x30f99`. The remaining `CALL_RETURN` sites visible in the Sergeant handler are now narrow tail cases rather than the missing mid-function continuations that were previously orphaning the core logic.
+- On March 22, 2026, checked the unmapped bytes immediately after Sergeant's terminal `RET` at `0x30fd2`. That region is recoverable, but it is not additional `handle_talk_to_sergeant` continuation logic: the bytes starting at `0x30fe0`, `0x31000`, `0x31020`, `0x31040`, `0x31060`, `0x31080`, `0x310a0`, and `0x310c0` form a compact cluster of standalone Sergeant-local getter/setter helpers over globals `0xd2d4c..0xd2d6c`, using the same `if (param_1 == 0) global = param_2; return global;` pattern as the already-defined `get_set_sergeant_*` wrappers elsewhere in the binary. The hard `RET` at `0x30fd2` is therefore a real function end for the talk handler.
+- On March 22, 2026, defined the post-`0x30fd2` Sergeant accessor cluster as standalone functions instead of leaving it as unmapped code: `get_set_sergeant_talk_state_block` (`0x30fe0`), `get_set_sergeant_dialogue_state_d2d50` (`0x31000`), `get_set_sergeant_post_application_briefing_completed` (`0x31020`), `get_set_sergeant_dialogue_state_d2d58` (`0x31040`), `get_set_sergeant_dialogue_state_d2d5c` (`0x31060`), `get_set_sergeant_dialogue_state_d2d60` (`0x31080`), `get_set_sergeant_dialogue_state_d2d64` (`0x310a0`), `get_set_sergeant_dialogue_state_d2d68` (`0x310c0`), and `get_set_sergeant_dialogue_state_d2d6c` (`0x310e0`). Applied matching two-parameter `get/set` prototypes and removed the stale temporary labels that overlapped the new helper bodies.
+- On March 22, 2026, reimplemented `engines/harvester/npc/sergeant_dialogue.cpp` against the current recovered native Sergeant state instead of the earlier partial stub logic. The engine now mirrors the helper-backed local state block (`d2d4c`, `d2d50`, `d2d54`, `d2d58`, `d2d5c`, `d2d60`, `d2d64`, `d2d68`, `d2d6c`, `d2d70`), uses the recovered menu/FST tails for `REMAINS`, `INVITE`, `DINER_BURNED`, `BARBER_POLE`, `BOLTCLTH`, `SCRATCHED_TUCKER`, and the intro/revisit/application branches, and carries over the native Day 5 exit gate and `BOLTCLTH` slash-transition side effects. Verified by building `engines/harvester/npc/sergeant_dialogue.o` and `engines/harvester/dialogue.o` with `make`.
 
 ## Next Suggested Action
 
-- Immediate: rerun the `SERGEANTRM` `SCRATCHED_TUCKER` repro and confirm the `0x2b4` menu now falls through into the native `GO_DAY_3` handoff, including the expected end-of-day FST/cutscene transition after either response branch.
-- Follow-up: if the day transition now matches native, return to the remaining Sergeant signature cleanup in Ghidra so the `ASSIGNED_DNA_TASK` and `BOLTCLTH` branches decompile as plain literal `1` writes.
+- Immediate: replay the recovered Sergeant runtime branches in-engine, especially `INVITE`, `DINER_BURNED`, `BARBER_POLE`, `BOLTCLTH`, and the late `REMAINS` payoff, to confirm the longer native menu/FST tails behave correctly under ScummVM's current room/action infrastructure.
+- Follow-up: trim the remaining stale `temp_sergeant_*` labels/comments in Ghidra, then apply the same clear/disassemble/recreate loop to the other widest talk handlers, starting with Stephanie.
diff --git a/engines/harvester/npc/sergeant_dialogue.cpp b/engines/harvester/npc/sergeant_dialogue.cpp
index 9242411559b..bf24f6bbae8 100644
--- a/engines/harvester/npc/sergeant_dialogue.cpp
+++ b/engines/harvester/npc/sergeant_dialogue.cpp
@@ -31,8 +31,17 @@ namespace Harvester {
 namespace {
 
 static const char *const kDialogueC149FstPath = "GRAPHIC/FST/C149.FST";
+static const char *const kDialogueC150FstPath = "GRAPHIC/FST/C150.FST";
+static const char *const kDialogueC076FstPath = "GRAPHIC/FST/C076.FST";
 static const char *const kGoDay2ActionTag = "GO_DAY_2";
 static const char *const kGoDay3ActionTag = "GO_DAY_3";
+static const char *const kRahRoomName = "RAH";
+static const int kSlashDeathDamageType = 2;
+static const int kSergeantRemainsResponseLine = 0x2ad;
+static const int kSergeantInviteResponseLine = 0x2ae;
+static const int kSergeantDinerBurnedResponseLine = 0x2af;
+static const int kSergeantBarberPoleResponseLine = 0x2b1;
+static const int kSergeantBoltClothResponseLine = 0x2b3;
 static const int kSergeantSecondTaskResponseLine = 0x2b4;
 static const int kSergeantCompletedApplicationResponseLine = 0x2b6;
 static const int kSergeantIntroResponseLine = 0x2b7;
@@ -49,8 +58,17 @@ bool SergeantDialogueHandler::matchesNpc(const Common::String &npcName) const {
 Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	SergeantRoomDialogueState &state = _state;
+	auto playLine = [&](int wavId, const char *speakerId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, speakerId, headVariant);
+	};
 	auto playSergeantLine = [&](int wavId, int headVariant = 0) -> Common::Error {
-		return runtime.playDialogueLineWithVariant(wavId, "SERGEANT", headVariant);
+		return playLine(wavId, "SERGEANT", headVariant);
+	};
+	auto playPcLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return playLine(wavId, "PC", headVariant);
+	};
+	auto playSequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
+		return runtime.playDialogueEntrySequence(lines, count);
 	};
 	auto hasInventoryItem = [&](const char *objectName) {
 		Common::Array<StartupObjectRecord> inventoryObjects;
@@ -62,16 +80,9 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 		return false;
 	};
-	auto markSergeantProgress = [&]() {
-		sharedState.dialogueStateD2f08 = true;
-		sharedState.dialogueStateD2f00 = true;
-		state.introPending = false;
-		state.dialogueStateD2d50 = true;
-		state.dialogueStateD2d54 = true;
-	};
-	auto restoreItemToRah = [&](const char *objectName) {
+	auto returnItemToRah = [&](const char *objectName, bool visible) {
 		(void)runtime.startupScript().resetRuntimeObjectToInitialState(objectName);
-		(void)runtime.startupScript().setRuntimeObjectVisible("RAH", objectName, true);
+		(void)runtime.startupScript().setRuntimeObjectVisible(kRahRoomName, objectName, visible);
 	};
 	auto executeActionTagIfSet = [&](const char *actionTag) -> Common::Error {
 		StartupInteractionResult interaction;
@@ -82,159 +93,222 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 		return Common::kNoError;
 	};
-	auto runDay5ReminderIfNeeded = [&]() -> Common::Error {
-		if (!runtime.startupScript().getFlagValue("DAY_5") || state.day5ReminderShown)
+	auto queueNpcSlashTransition = [&](const char *npcName) {
+		StartupInteractionResult interaction;
+		if (runtime.startupScript().triggerRuntimeNpcDeathOrMonsterfy(
+				npcName, kSlashDeathDamageType)) {
+			interaction.mutatedRuntimeState = true;
+		}
+		runtime.queueDialogueInteractionIfNeeded(interaction);
+	};
+	auto runDay5ExitIfNeeded = [&]() -> Common::Error {
+		if (!runtime.startupScript().getFlagValue("DAY_5") || state.day5ExitLinePlayed)
 			return Common::kNoError;
 
-		state.day5ReminderShown = true;
+		state.day5ExitLinePlayed = true;
 		return playSergeantLine(0x4324, 1);
 	};
-	auto runApplicationDiscussion = [&]() -> Common::Error {
-		Common::Error lineError = playSergeantLine(0x415d);
+	auto handleRemainsBranch = [&]() -> Common::Error {
+		sharedState.dialogueStateD2f08 = true;
+		sharedState.dialogueStateD2f00 = true;
+		state.talkStateBlock = true;
+		state.dialogueStateD2d50 = true;
+		state.postApplicationBriefingCompleted = true;
+		state.dialogueStateD2d58 = true;
+		state.dialogueStateD2d5c = true;
+		state.dialogueStateD2d60 = true;
+		state.dialogueStateD2d64 = true;
+		state.dialogueStateD2d68 = true;
+		state.dialogueStateD2d6c = true;
+		returnItemToRah("REMAINS", false);
+
+		Common::Error lineError = playSergeantLine(0x4333, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = runtime.playDialogueFst(kDialogueC150FstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		const DialogueLineEntry remainsIntroLines[] = {
+			{ 0x433f, "SERGEANT", 0 },
+			{ 0x4344, "PC", 2 },
+			{ 0x4348, "SERGEANT", 0 },
+			{ 0x434f, "SERGEANT", 0 },
+			{ 0x4353, "SERGEANT", 1 }
+		};
+		lineError = playSequence(remainsIntroLines, ARRAYSIZE(remainsIntroLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
 		int responseIndex = 0;
 		Common::Error responseError = runtime.runResponseMenu(
-			kSergeantApplicationResponseLine, responseIndex);
+			kSergeantRemainsResponseLine, responseIndex);
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 
 		if (responseIndex == 1) {
-			const DialogueLineEntry applicationLines[] = {
-				{ 0x4168, "SERGEANT", 1 },
-				{ 0x416c, "PC", 0 },
-				{ 0x4170, "SERGEANT", 1 }
-			};
-			lineError = runtime.playDialogueEntrySequence(
-				applicationLines, ARRAYSIZE(applicationLines));
+			lineError = playSergeantLine(0x435f, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			lineError = playSergeantLine(0x4364);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-
-			responseIndex = 0;
-			responseError = runtime.runResponseMenu(kSergeantAmnesiaResponseLine, responseIndex);
-			if (responseError.getCode() != Common::kNoError)
-				return responseError;
-
-			if (responseIndex == 1)
-				lineError = playSergeantLine(0x417c);
-			else if (responseIndex == 2)
-				lineError = playSergeantLine(0x4182, 2);
-		} else {
-			const DialogueLineEntry applicationLines[] = {
-				{ 0x4188, "SERGEANT", 0 },
-				{ 0x418e, "PC", 0 },
-				{ 0x4192, "SERGEANT", 0 }
-			};
-			lineError = runtime.playDialogueEntrySequence(
-				applicationLines, ARRAYSIZE(applicationLines));
 		}
 
+		const DialogueLineEntry remainsExitLines[] = {
+			{ 0x4368, "SERGEANT", 0 },
+			{ 0x4372, "PC", 2 },
+			{ 0x4376, "SERGEANT", 0 },
+			{ 0x4381, "PC", 2 },
+			{ 0x4385, "SERGEANT", 0 }
+		};
+		lineError = playSequence(remainsExitLines, ARRAYSIZE(remainsExitLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		return runDay5ReminderIfNeeded();
+
+		// Native blacks the screen and then prompts for the next disc after this FST chain.
+		return runtime.playDialogueFst(kDialogueC076FstPath);
 	};
-	auto runInitialIntroBranch = [&]() -> Common::Error {
+	auto handleInviteBranch = [&]() -> Common::Error {
 		sharedState.dialogueStateD2f08 = true;
-		state.introPending = false;
-
-		const DialogueLineEntry introLines[] = {
-			{ 0x413f, "SERGEANT", 2 },
-			{ 0x4144, "PC", 0 },
-			{ 0x4145, "SERGEANT", 0 },
-			{ 0x4146, "PC", 0 },
-			{ 0x4148, "SERGEANT", 2 }
+		sharedState.dialogueStateD2f00 = true;
+		state.talkStateBlock = true;
+		state.dialogueStateD2d50 = true;
+		state.postApplicationBriefingCompleted = true;
+		state.dialogueStateD2d58 = true;
+		state.dialogueStateD2d5c = true;
+		state.dialogueStateD2d60 = true;
+		state.dialogueStateD2d64 = true;
+		state.dialogueStateD2d68 = true;
+		(void)runtime.startupScript().resetRuntimeObjectToInitialState("STEFSKULL");
+		(void)runtime.startupScript().setRuntimeObjectVisible("ST_BEDRM", "STEFSKULL", true);
+		returnItemToRah("INVITE", false);
+		(void)runtime.startupScript().setRuntimeFlagValue("TAKEN_INVITE_TO_SERGEANT", true);
+		(void)runtime.startupScript().setRuntimeFlagValue("NEED_REMAINS_FOR_LODGE", true);
+		(void)runtime.startupScript().setRuntimeNpcState("DWAYNE_ST_BEDRM", false, false);
+		(void)runtime.startupScript().setRuntimeNpcState("MRS_POTTS_ST_BEDRM", false, false);
+		(void)runtime.startupScript().setRuntimeNpcState("MRS_POTTS", true, true);
+
+		Common::Error lineError = runtime.playDialogueFst(kDialogueC149FstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		const DialogueLineEntry inviteLines[] = {
+			{ 0x4305, "PC", 0 },
+			{ 0x4309, "SERGEANT", 2 }
 		};
-		Common::Error lineError = runtime.playDialogueEntrySequence(
-			introLines, ARRAYSIZE(introLines));
+		lineError = playSequence(inviteLines, ARRAYSIZE(inviteLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
 		int responseIndex = 0;
 		Common::Error responseError = runtime.runResponseMenu(
-			kSergeantIntroResponseLine, responseIndex);
+			kSergeantInviteResponseLine, responseIndex);
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 
-		if (responseIndex == 1)
-			lineError = playSergeantLine(0x4153);
-		else if (responseIndex == 2)
-			lineError = playSergeantLine(0x4157, 1);
+		if (responseIndex == 1) {
+			lineError = playSergeantLine(0x4314, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			lineError = playSergeantLine(0x4319, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
 
+		lineError = playSergeantLine(0x431d);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		return runApplicationDiscussion();
+		return runDay5ExitIfNeeded();
 	};
-	auto runApplicationReminderBranch = [&]() -> Common::Error {
-		Common::Error lineError = playSergeantLine(0x41ac);
+	auto handleBarberPoleBranch = [&]() -> Common::Error {
+		sharedState.dialogueStateD2f08 = true;
+		sharedState.dialogueStateD2f00 = true;
+		state.talkStateBlock = true;
+		state.dialogueStateD2d50 = true;
+		state.postApplicationBriefingCompleted = true;
+		state.dialogueStateD2d58 = true;
+		state.dialogueStateD2d5c = true;
+		state.dialogueStateD2d60 = true;
+		returnItemToRah("BARBER_POLE", false);
+		(void)runtime.startupScript().setRuntimeFlagValue("ASSIGNED_DNA_TASK", true);
+
+		Common::Error lineError = playSergeantLine(0x426f, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		if (!runtime.startupScript().getFlagValue("HAVE_LODGE_APP")) {
-			lineError = playSergeantLine(0x41b0);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
-		lineError = playSergeantLine(0x41b4, 3);
+		lineError = playSergeantLine(0x4274);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
 		int responseIndex = 0;
 		Common::Error responseError = runtime.runResponseMenu(
-			kSergeantReminderResponseLine, responseIndex);
+			kSergeantBarberPoleResponseLine, responseIndex);
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 
 		if (responseIndex == 1) {
-			lineError = playSergeantLine(0x41bf, 2);
-			state.dialogueStateD2d50 = true;
+			lineError = playSergeantLine(0x4281);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 		} else if (responseIndex == 2) {
-			lineError = playSergeantLine(0x41c4);
+			lineError = playSergeantLine(0x4289);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 		}
 
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		return runDay5ReminderIfNeeded();
+		return playSergeantLine(0x428d);
 	};
-	auto handleRemainsBranch = [&]() -> Common::Error {
-		markSergeantProgress();
-		state.dialogueStateD2d58 = true;
-		restoreItemToRah("REMAINS");
-		return playSergeantLine(0x4333);
-	};
-	auto handleInviteBranch = [&]() -> Common::Error {
-		markSergeantProgress();
+	auto handleBoltClothBranch = [&]() -> Common::Error {
+		sharedState.dialogueStateD2f08 = true;
+		sharedState.dialogueStateD2f00 = true;
+		state.talkStateBlock = true;
+		state.dialogueStateD2d50 = true;
+		state.postApplicationBriefingCompleted = true;
 		state.dialogueStateD2d58 = true;
-		(void)runtime.startupScript().resetRuntimeObjectToInitialState("STEFSKULL");
-		(void)runtime.startupScript().setRuntimeObjectVisible("ST_BEDRM", "STEFSKULL", true);
-		restoreItemToRah("INVITE");
-		(void)runtime.startupScript().setRuntimeFlagValue("TAKEN_INVITE_TO_SERGEANT", true);
-		(void)runtime.startupScript().setRuntimeFlagValue("NEED_REMAINS_FOR_LODGE", true);
-		(void)runtime.startupScript().setRuntimeNpcState("DWAYNE_ST_BEDRM", false, false);
-		(void)runtime.startupScript().setRuntimeNpcState("MRS_POTTS_ST_BEDRM", false, false);
-		(void)runtime.startupScript().setRuntimeNpcState("MRS_POTTS", true, true);
+		state.dialogueStateD2d5c = true;
 
-		Common::Error lineError = runtime.playDialogueFst(kDialogueC149FstPath);
+		const DialogueLineEntry boltClothLines[] = {
+			{ 0x4241, "SERGEANT", 1 },
+			{ 0x4245, "SERGEANT", 1 },
+			{ 0x4252, "SERGEANT", 0 }
+		};
+		Common::Error lineError = playSequence(boltClothLines, ARRAYSIZE(boltClothLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		return runtime.playDialogueLine(0x4305, "PC");
-	};
-	auto handleBarberPoleBranch = [&]() -> Common::Error {
-		markSergeantProgress();
-		state.dialogueStateD2d58 = true;
-		restoreItemToRah("BARBER_POLE");
-		(void)runtime.startupScript().setRuntimeFlagValue("ASSIGNED_DNA_TASK", true);
-		return playSergeantLine(0x426f);
-	};
-	auto handleBoltClothBranch = [&]() -> Common::Error {
-		markSergeantProgress();
-		state.dialogueStateD2d58 = true;
-		restoreItemToRah("BOLTCLTH");
-		return playSergeantLine(0x4241);
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(
+			kSergeantBoltClothResponseLine, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playSergeantLine(0x425e, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			lineError = playSergeantLine(0x4266, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		returnItemToRah("BOLTCLTH", false);
+		(void)runtime.startupScript().setRuntimeFlagValue("FIREMEN_SUICIDE", true);
+		(void)runtime.startupScript().setRuntimeFlagValue("ASSIGNED_POLE_TASK", true);
+		queueNpcSlashTransition("SPARKY");
+		queueNpcSlashTransition("NUDE_MAN");
+		queueNpcSlashTransition("FIREMAN2");
+		return runDay5ExitIfNeeded();
 	};
 	auto handleCompletedFirstTaskBranch = [&]() -> Common::Error {
-		markSergeantProgress();
+		sharedState.dialogueStateD2f08 = true;
+		sharedState.dialogueStateD2f00 = true;
+		state.talkStateBlock = true;
+		state.dialogueStateD2d50 = true;
+		state.postApplicationBriefingCompleted = true;
+		state.completedFirstTaskState = true;
 
-		Common::Error lineError = playSergeantLine(0x420d);
+		Common::Error lineError = playSergeantLine(0x420d, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 		lineError = playSergeantLine(0x421e);
@@ -247,32 +321,35 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 
-		lineError = Common::kNoError;
-		if (responseIndex == 1)
+		if (responseIndex == 1) {
 			lineError = playSergeantLine(0x422a);
-		else if (responseIndex == 2) {
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
 			const DialogueLineEntry reluctantTaskLines[] = {
-				{ 0x422f, "SERGEANT", 0 },
-				{ 0x4239, "SERGEANT", 0 }
+				{ 0x422f, "SERGEANT", 2 },
+				{ 0x4239, "SERGEANT", 3 }
 			};
-			lineError = runtime.playDialogueEntrySequence(
+			lineError = playSequence(
 				reluctantTaskLines, ARRAYSIZE(reluctantTaskLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 		}
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
 
 		if (!state.dialogueStateD2d58) {
 			state.dialogueStateD2d58 = true;
-			return executeActionTagIfSet(kGoDay3ActionTag);
+			Common::Error actionError = executeActionTagIfSet(kGoDay3ActionTag);
+			if (actionError.getCode() != Common::kNoError)
+				return actionError;
 		}
-		return Common::kNoError;
+		return runDay5ExitIfNeeded();
 	};
 	auto handleCompletedApplicationBranch = [&]() -> Common::Error {
 		sharedState.dialogueStateD2f08 = true;
 		sharedState.dialogueStateD2f00 = true;
-		state.introPending = false;
+		state.talkStateBlock = true;
 		state.dialogueStateD2d50 = true;
-		restoreItemToRah("COMPLETED_LODGE_APPLICATION");
+		returnItemToRah("COMPLETED_LODGE_APPLICATION", false);
 		if (!runtime.startupScript().getFlagValue("QUEST_1")) {
 			Common::Error lineError = playSergeantLine(0x41cd, 1);
 			if (lineError.getCode() != Common::kNoError)
@@ -283,7 +360,7 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			(void)runtime.startupScript().setRuntimeFlagValue("QUEST_1", true);
 			return executeActionTagIfSet(kGoDay2ActionTag);
 		}
-		if (!state.dialogueStateD2d54) {
+		if (!state.postApplicationBriefingCompleted) {
 			Common::Error lineError = playSergeantLine(0x41d2, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
@@ -308,7 +385,7 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 					acceptedTaskLines, ARRAYSIZE(acceptedTaskLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
-				state.dialogueStateD2d54 = true;
+				state.postApplicationBriefingCompleted = true;
 			} else if (responseIndex == 2) {
 				const DialogueLineEntry declinedTaskLines[] = {
 					{ 0x4201, "SERGEANT", 3 },
@@ -319,10 +396,10 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 					declinedTaskLines, ARRAYSIZE(declinedTaskLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
-				state.dialogueStateD2d54 = false;
+				state.postApplicationBriefingCompleted = false;
 			}
 
-			return runDay5ReminderIfNeeded();
+			return runDay5ExitIfNeeded();
 		}
 
 		const DialogueLineEntry repeatTaskLines[] = {
@@ -334,7 +411,120 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			repeatTaskLines, ARRAYSIZE(repeatTaskLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		return runDay5ReminderIfNeeded();
+		return runDay5ExitIfNeeded();
+	};
+	auto runIntroOrReminderBranch = [&]() -> Common::Error {
+		if (!state.talkStateBlock) {
+			sharedState.dialogueStateD2f08 = true;
+			state.talkStateBlock = true;
+
+			const DialogueLineEntry introLines[] = {
+				{ 0x413f, "SERGEANT", 2 },
+				{ 0x4144, "PC", 0 },
+				{ 0x4145, "SERGEANT", 0 },
+				{ 0x4146, "PC", 0 },
+				{ 0x4148, "SERGEANT", 2 }
+			};
+			Common::Error lineError = playSequence(introLines, ARRAYSIZE(introLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(
+				kSergeantIntroResponseLine, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = playSergeantLine(0x4153);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else if (responseIndex == 2) {
+				lineError = playSergeantLine(0x4157, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			lineError = playSergeantLine(0x415d);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			responseIndex = 0;
+			responseError = runtime.runResponseMenu(
+				kSergeantApplicationResponseLine, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				const DialogueLineEntry applicationLines[] = {
+					{ 0x4168, "SERGEANT", 1 },
+					{ 0x416c, "PC", 0 },
+					{ 0x4170, "SERGEANT", 1 }
+				};
+				lineError = playSequence(applicationLines, ARRAYSIZE(applicationLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				responseIndex = 0;
+				responseError = runtime.runResponseMenu(
+					kSergeantAmnesiaResponseLine, responseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+
+				if (responseIndex == 1) {
+					lineError = playSergeantLine(0x417c);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				} else if (responseIndex == 2) {
+					lineError = playSergeantLine(0x4182, 2);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				}
+			} else {
+				const DialogueLineEntry applicationLines[] = {
+					{ 0x4188, "SERGEANT", 0 },
+					{ 0x418e, "PC", 0 },
+					{ 0x4192, "SERGEANT", 0 }
+				};
+				lineError = playSequence(applicationLines, ARRAYSIZE(applicationLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			return runDay5ExitIfNeeded();
+		}
+
+		state.talkStateBlock = true;
+		Common::Error lineError = playSergeantLine(0x41ac);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (!runtime.startupScript().getFlagValue("HAVE_LODGE_APP")) {
+			lineError = playPcLine(0x41b0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		lineError = playSergeantLine(0x41b4, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(
+			kSergeantReminderResponseLine, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playSergeantLine(0x41bf, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			state.dialogueStateD2d50 = true;
+		} else if (responseIndex == 2) {
+			lineError = playSergeantLine(0x41c4);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		return runDay5ExitIfNeeded();
 	};
 
 	if (usedItemName.empty()) {
@@ -346,9 +536,40 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return handleInviteBranch();
 		}
 		if (runtime.startupScript().getFlagValue("DINER_BURNED")) {
-			markSergeantProgress();
+			sharedState.dialogueStateD2f08 = true;
+			sharedState.dialogueStateD2f00 = true;
+			state.talkStateBlock = true;
+			state.dialogueStateD2d50 = true;
+			state.postApplicationBriefingCompleted = true;
 			state.dialogueStateD2d58 = true;
-			return playSergeantLine(0x42b5, 1);
+			state.dialogueStateD2d5c = true;
+			state.dialogueStateD2d60 = true;
+			state.dialogueStateD2d64 = true;
+
+			Common::Error lineError = playSergeantLine(0x42b5, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playSergeantLine(0x42b9);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(
+				kSergeantDinerBurnedResponseLine, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = playSergeantLine(0x42c5);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else if (responseIndex == 2) {
+				lineError = playSergeantLine(0x42cb);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			return playSergeantLine(0x42d2, 1);
 		}
 		if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN"))
 			return handleBarberPoleBranch();
@@ -359,9 +580,7 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 		if (runtime.startupScript().getFlagValue("HAVE_COMPLETED_LODGE_APP"))
 			return handleCompletedApplicationBranch();
-		if (state.introPending)
-			return runInitialIntroBranch();
-		return runApplicationReminderBranch();
+		return runIntroOrReminderBranch();
 	}
 
 	if (usedItemName.equalsIgnoreCase("BARBER_POLE"))
diff --git a/engines/harvester/npc/sergeant_dialogue.h b/engines/harvester/npc/sergeant_dialogue.h
index 09ee93462db..112e15b3548 100644
--- a/engines/harvester/npc/sergeant_dialogue.h
+++ b/engines/harvester/npc/sergeant_dialogue.h
@@ -29,11 +29,17 @@ namespace Harvester {
 class SergeantDialogueHandler : public NpcDialogueHandler {
 public:
 	struct SergeantRoomDialogueState {
-		bool introPending = true;
+		bool talkStateBlock = false;
 		bool dialogueStateD2d50 = false;
-		bool dialogueStateD2d54 = false;
+		bool postApplicationBriefingCompleted = false;
 		bool dialogueStateD2d58 = false;
-		bool day5ReminderShown = false;
+		bool dialogueStateD2d5c = false;
+		bool dialogueStateD2d60 = false;
+		bool dialogueStateD2d64 = false;
+		bool dialogueStateD2d68 = false;
+		bool dialogueStateD2d6c = false;
+		bool completedFirstTaskState = false;
+		bool day5ExitLinePlayed = false;
 	};
 
 	bool matchesNpc(const Common::String &npcName) const override;


Commit: c2b87ef752baedea3202fdfc51ffc1dd967fcdb2
    https://github.com/scummvm/scummvm/commit/c2b87ef752baedea3202fdfc51ffc1dd967fcdb2
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:52+02:00

Commit Message:
HARVESTER: Align monster melee contact with native combat

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index a8deb124f41..eba8bce89b8 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1046,9 +1046,12 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - It handles class-4/5/6 actor entities, advances animation/state transitions, maintains pursuit spacing against the player using `engage_distance`, applies room Z bounds and vertical motion, fires frame-timed sound hooks, resolves hit damage through `damage_amount`, and returns `0` when the caller should remove the actor entity from the world list.
   - For class-6 monsters specifically, pursuit is not a free-running room-script heuristic: the native state machine first closes base Z to within `g_player_combat_avatar->z +/- 2.0`, then compares center-to-center X against `engage_distance`, and only arms the close-range attack picker after that spacing gate is satisfied.
   - The same class-6 path derives its horizontal chase waypoint from the player's live left edge plus the current player/monster frame widths and treats that waypoint as satisfied within `50.0 * depth_scale` pixels, so native monster pursuit does not require exact center equality before it can stop walking and attack.
+  - Once that close-range gate is satisfied and the per-attack cooldown allows it, the class-6 branch stores `g_player_combat_avatar` into live actor field `+0x11a4` before entering the close-range attack family; the later contact block resolves against that linked target instead of re-running the coarse engage-distance spacing test.
   - Fresh monster spawns do not wait in a neutral idle loop: `spawn_monster_entity_from_record` immediately seeds desired state `0x07` or `0x0e` from the monster's placement relative to the player, which is the native locomotion handoff into left/right pursuit.
   - It also contains the confirmed NPC-to-monster replacement path used by the `change2monster` flow.
   - The attack-state reader side is now bounded directly from states `0x16` through `0x1b`:
+    - monster melee attack-family selection is side-based rather than target-Y based: targets on the actor's left pick from `0x16/0x17/0x18`, while targets on the right pick from `0x19/0x1a/0x1b`
+    - within that side, the picker is randomized across the available family states instead of deterministically mapping upper/mid/lower target bands
     - it treats `+0x1134` as the attack sample count and randomly selects from `+0x1148/+0x114c/+0x1150`
     - it plays the selected attack sample when `current_frame == anim_base + +0x116c`
     - it waits until `current_frame >= anim_base + +0x113c` before resolving contact
diff --git a/TRACKER.md b/TRACKER.md
index fd7e847f76c..aad25ef458a 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Last Confirmed Action
 
+- On March 22, 2026, rechecked Harvester's native room-combat monster attack path in Ghidra after ScummVM monsters could pursue but kept logging `reason='out of range'` at contact. Confirmed from `update_actor_runtime_state` (`0x4d750`) that class-6 monsters arm melee by storing `g_player_combat_avatar` into live actor field `+0x11a4`, pick a random side-appropriate close-range attack family (`0x16..0x18` on the left, `0x19..0x1b` on the right) instead of using a target-Y band mapping, and resolve contact against that linked target after the `+0x113c` contact frame instead of re-running the coarse engage-distance spacing test. Patched [`room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) to retain the scheduled player target through the swing, resolve monster contact once against that linked target, and randomize native-style left/right attack bank selection from the ABM frame ranges. Verified by rebuilding [`room.o`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/engines/harvester/room.o) and relinking [`scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm).
 - On March 22, 2026, re-opened Sergeant's `SCRATCHED_TUCKER` no-item branch against the shipped `DIALOGUE.IDX` and `DIALOG.RSP` assets after ScummVM stopped at Sergeant line `0x420d` without the expected Day 2 response menu.
 - Confirmed the missing continuation is asset-backed: Sergeant should continue with `0x421e`, open zero-based `dialog.rsp` line `0x2b4`, and then branch to `0x422a` or `0x422f` / `0x4239`. Patched `engines/harvester/npc/sergeant_dialogue.cpp`, updated the Sergeant dialogue debug dump and `ARCHITECTURE.md`, and rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 - On March 22, 2026, cleaned up the native Sergeant room handler in Ghidra by rebuilding `handle_talk_to_sergeant` at `0x30340` from a broken one-byte stub into its real front-half body, setting an explicit `void __stdcall` prototype, and renaming the Sergeant-local `0xd2d50..0xd2d70` state slots to stable labels. The only remaining decompiler noise in that front half is the known register-lift artifact on `ASSIGNED_DNA_TASK` and the odd extra register shown on the `BOLTCLTH` branch.
@@ -25,5 +26,5 @@
 
 ## Next Suggested Action
 
-- Immediate: replay the recovered Sergeant runtime branches in-engine, especially `INVITE`, `DINER_BURNED`, `BARBER_POLE`, `BOLTCLTH`, and the late `REMAINS` payoff, to confirm the longer native menu/FST tails behave correctly under ScummVM's current room/action infrastructure.
-- Follow-up: trim the remaining stale `temp_sergeant_*` labels/comments in Ghidra, then apply the same clear/disassemble/recreate loop to the other widest talk handlers, starting with Stephanie.
+- Immediate: run an in-engine Harvester combat pass in `PCHOUSE` against `JIMMY_M` and one additional melee monster to confirm the new room-combat path now produces native-style attack starts plus contact hits while the player is still the linked target, and that the random left/right attack-family picker no longer depends on target Y bands.
+- Follow-up: if those hits land correctly, compare native victim reaction behavior next. The remaining confirmed gap in ScummVM's room-combat path is the shared native hit-resolution side effects after contact (`+0x1188`, reciprocal `+0x11b8`, knockback, and reaction/death-state gating), which are still only partially modeled.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 882d8604324..d44df300882 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -59,8 +59,6 @@ static const int kNativeInventoryWeekdayX = 0x1e0;
 static const int kNativeInventoryWeekdayY = 0x18c;
 static const byte kNativeInventoryTooltipColor = 0xf4;
 static const int kRoomMonsterAnimationRate = 17;
-static const float kRoomActorAttackUpperYOffset = 144.44f;
-static const float kRoomActorAttackMidYOffset = 75.36f;
 static const int kDefaultMonsterAttackContactFrameOffset = 2;
 static const int kMuckeyAttackContactFrameOffset = 7;
 static const float kNativeMonsterPursuitZTolerance = 2.0f;
@@ -106,6 +104,8 @@ struct RoomMonsterCombatState {
 	int attackContactFrame = -1;
 	int attackResumeFacing = -1;
 	bool attackSoundPlayed = false;
+	bool attackContactResolved = false;
+	Common::String attackTargetName;
 	bool deathActive = false;
 	int deathFirstFrame = -1;
 	int deathLastFrame = -1;
@@ -334,36 +334,41 @@ static bool setRoomMonsterAnimation(RuntimeEntity &entity, int facing, bool walk
 	return changed;
 }
 
-static bool resolveRoomActorAttackAnimationRange(const StartupRoomSetupState &state,
-		int actorCenterX, int actorBottomY, float actorZ, const Common::Point &targetPoint,
+static bool runtimeEntityHasFrameRange(const RuntimeEntity &entity, int firstFrame, int lastFrame) {
+	return entity.hasFrames() && firstFrame >= 0 && entity.getLastFrame() >= lastFrame;
+}
+
+static bool resolveMonsterAttackAnimationRange(HarvesterEngine &engine,
+		const RuntimeEntity &entity, int actorCenterX, int targetCenterX,
 		RoomAttackAnimationRange &range) {
-	const float depthScale = Player::computeDepthScale(state, actorZ);
-	const float upperThresholdY =
-		(float)actorBottomY - depthScale * kRoomActorAttackUpperYOffset;
-	const float midThresholdY =
-		(float)actorBottomY - depthScale * kRoomActorAttackMidYOffset;
-
-	if (targetPoint.x < actorCenterX) {
-		if ((float)targetPoint.y < upperThresholdY)
-			range = RoomAttackAnimationRange(0x50, 0x59, 1);
-		else if ((float)targetPoint.y < midThresholdY)
-			range = RoomAttackAnimationRange(0x5a, 0x63, 1);
-		else
-			range = RoomAttackAnimationRange(0x64, 0x6d, 1);
-	} else {
-		if ((float)targetPoint.y < upperThresholdY)
-			range = RoomAttackAnimationRange(0x6e, 0x77, 2);
-		else if ((float)targetPoint.y < midThresholdY)
-			range = RoomAttackAnimationRange(0x78, 0x81, 2);
-		else
-			range = RoomAttackAnimationRange(0x82, 0x8b, 2);
+	static const RoomAttackAnimationRange kLeftAttackRanges[3] = {
+		RoomAttackAnimationRange(0x50, 0x59, 1),
+		RoomAttackAnimationRange(0x5a, 0x63, 1),
+		RoomAttackAnimationRange(0x64, 0x6d, 1)
+	};
+	static const RoomAttackAnimationRange kRightAttackRanges[3] = {
+		RoomAttackAnimationRange(0x6e, 0x77, 2),
+		RoomAttackAnimationRange(0x78, 0x81, 2),
+		RoomAttackAnimationRange(0x82, 0x8b, 2)
+	};
+
+	const RoomAttackAnimationRange *candidates =
+		targetCenterX < actorCenterX ? kLeftAttackRanges : kRightAttackRanges;
+	int availableCandidateIndices[3];
+	int availableCandidateCount = 0;
+	for (int i = 0; i < 3; ++i) {
+		if (!runtimeEntityHasFrameRange(entity, candidates[i].firstFrame, candidates[i].lastFrame))
+			continue;
+		availableCandidateIndices[availableCandidateCount++] = i;
 	}
 
-	return true;
-}
+	if (availableCandidateCount == 0)
+		return false;
 
-static bool runtimeEntityHasFrameRange(const RuntimeEntity &entity, int firstFrame, int lastFrame) {
-	return entity.hasFrames() && firstFrame >= 0 && entity.getLastFrame() >= lastFrame;
+	const int selectedIndex = availableCandidateIndices[
+		availableCandidateCount > 1 ? engine.getRandomNumber(availableCandidateCount - 1) : 0];
+	range = candidates[selectedIndex];
+	return true;
 }
 
 static bool resolveMonsterDeathAnimationRange(const RuntimeEntity &entity, int facing,
@@ -518,6 +523,17 @@ static void clearRoomMonsterCombatState(RoomMonsterCombatState &combatState) {
 	combatState = RoomMonsterCombatState();
 }
 
+static void clearRoomMonsterAttackState(RoomMonsterCombatState &combatState) {
+	combatState.attackActive = false;
+	combatState.attackFirstFrame = -1;
+	combatState.attackLastFrame = -1;
+	combatState.attackContactFrame = -1;
+	combatState.attackResumeFacing = -1;
+	combatState.attackSoundPlayed = false;
+	combatState.attackContactResolved = false;
+	combatState.attackTargetName.clear();
+}
+
 RoomSystem::RoomSystem(HarvesterEngine &engine, Common::Point &mousePos,
 		InventorySystem &inventory)
 	: _engine(engine), _mousePos(mousePos), _inventory(inventory) {
@@ -1309,13 +1325,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		return areCombatantsWithinNativeCloseRange(scene.state,
 			*playerState.entity, playerState.z, *targetEntity, targetEntity->getZ(), engageDistance);
 	};
-	auto monsterAttackCanReachPlayer = [&](const StartupMonsterRecord &monster, const RuntimeEntity &monsterEntity) {
-		if (!playerState.entity)
-			return false;
-
-		return areCombatantsWithinNativeCloseRange(scene.state,
-			monsterEntity, (float)monster.posZ, *playerState.entity, playerState.z, monster.engageDistance);
-	};
 	auto describeCombatTargetClass = [](int classId) -> const char * {
 		switch (classId) {
 		case kRuntimeEntityClassNpc:
@@ -1694,17 +1703,22 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 						monster.attackSound1, monster.attackSound2, monster.attackSound3);
 					combatState.attackSoundPlayed = true;
 				}
-				if (combatState.attackContactFrame >= 0 &&
+				if (!combatState.attackContactResolved &&
+						combatState.attackContactFrame >= 0 &&
 						currentFrame >= combatState.attackContactFrame) {
+					combatState.attackContactResolved = true;
 					combatState.attackContactFrame = -1;
-					const bool playerInRange = playerState.entity && monsterAttackCanReachPlayer(monster, *entity);
+					const bool hasTrackedPlayerTarget =
+						playerState.entity &&
+						!combatState.attackTargetName.empty() &&
+						playerState.entity->getName().equalsIgnoreCase(combatState.attackTargetName);
 					if (!startupScript) {
 						debugC(1, kDebugCombat,
 							"Harvester: combat monster attack contact monster='%s' frame=%d skipped reason='no startup script'",
 							monster.monsterName.c_str(), currentFrame);
-					} else if (!playerInRange) {
+					} else if (!playerAlive || !hasTrackedPlayerTarget) {
 						debugC(1, kDebugCombat,
-							"Harvester: combat monster attack miss monster='%s' frame=%d player_hp=%d reason='out of range'",
+							"Harvester: combat monster attack miss monster='%s' frame=%d player_hp=%d reason='no live target'",
 							monster.monsterName.c_str(), currentFrame, startupScript->getPlayerCurrentHitPoints());
 					} else {
 						const int playerHitPointsBefore = startupScript->getPlayerCurrentHitPoints();
@@ -1722,12 +1736,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					needsRedraw = true;
 				}
 				if (currentFrame == combatState.attackLastFrame) {
-					combatState.attackActive = false;
-					combatState.attackFirstFrame = -1;
-					combatState.attackLastFrame = -1;
-					combatState.attackContactFrame = -1;
-					combatState.attackResumeFacing = -1;
-					combatState.attackSoundPlayed = false;
+					clearRoomMonsterAttackState(combatState);
 					needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
 				}
 				continue;
@@ -1814,9 +1823,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				continue;
 
 			RoomAttackAnimationRange range;
-			const Common::Point playerPoint(playerCenterX, playerState.bottomY);
-			(void)resolveRoomActorAttackAnimationRange(
-				scene.state, monster.posX, monster.posY, (float)monster.posZ, playerPoint, range);
+			if (!resolveMonsterAttackAnimationRange(_engine, *entity, monsterCenterX, playerCenterX, range)) {
+				debugC(1, kDebugCombat,
+					"Harvester: combat monster attack skipped monster='%s' reason='no native attack bank' target_side=%s",
+					monster.monsterName.c_str(), playerCenterX < monsterCenterX ? "left" : "right");
+				continue;
+			}
 			combatState.attackActive = true;
 			combatState.attackFirstFrame = range.firstFrame;
 			combatState.attackLastFrame = range.lastFrame;
@@ -1825,6 +1837,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			combatState.attackResumeFacing = range.resumeFacing;
 			combatState.attackSoundPlayed =
 				monster.attackSound1.empty() && monster.attackSound2.empty() && monster.attackSound3.empty();
+			combatState.attackContactResolved = false;
+			combatState.attackTargetName = playerState.entity->getName();
 			combatState.nextAttackAllowedTick = now + 50 + _engine.getRandomNumber(99);
 			monster.facing = range.resumeFacing;
 			entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
@@ -1832,8 +1846,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			entity->setAnimationEnabled(true);
 			entity->setCurrentFrame(range.firstFrame);
 			debugC(1, kDebugCombat,
-				"Harvester: combat monster attack start monster='%s' frames=%d..%d contact=%d resume_facing=%d live_center_dx=%d z_delta=%.2f engage=%d next_attack_tick=%u",
-				monster.monsterName.c_str(), range.firstFrame, range.lastFrame,
+				"Harvester: combat monster attack start monster='%s' target='%s' frames=%d..%d contact=%d resume_facing=%d live_center_dx=%d z_delta=%.2f engage=%d next_attack_tick=%u",
+				monster.monsterName.c_str(), combatState.attackTargetName.c_str(), range.firstFrame, range.lastFrame,
 				combatState.attackContactFrame, range.resumeFacing, liveCenterDx, (double)zDelta,
 				engageDistance, combatState.nextAttackAllowedTick);
 			needsRedraw = true;


Commit: d7ade5e70fb46f3ed6cbe81d617b7a1cce75c8e3
    https://github.com/scummvm/scummvm/commit/d7ade5e70fb46f3ed6cbe81d617b7a1cce75c8e3
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:53+02:00

Commit Message:
HARVESTER: Align dialogue state with native save/load

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/dialogue.h
    engines/harvester/flow.cpp
    engines/harvester/flow.h
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/npc/boyle_dialogue.h
    engines/harvester/npc/buster_dialogue.h
    engines/harvester/npc/butcher_dialogue.h
    engines/harvester/npc/dad_dialogue.h
    engines/harvester/npc/dark_woman_dialogue.h
    engines/harvester/npc/dialogue_handler.h
    engines/harvester/npc/dwayne_dialogue.h
    engines/harvester/npc/edna_dialogue.h
    engines/harvester/npc/fireman1_dialogue.h
    engines/harvester/npc/fireman2_dialogue.h
    engines/harvester/npc/hank_dialogue.h
    engines/harvester/npc/herrill_dialogue.h
    engines/harvester/npc/inquisitor_dialogue.h
    engines/harvester/npc/jimmy_dialogue.h
    engines/harvester/npc/johnson_dialogue.h
    engines/harvester/npc/karin_dialogue.h
    engines/harvester/npc/loomis_dialogue.h
    engines/harvester/npc/mcknight_dialogue.h
    engines/harvester/npc/memb_dir_dialogue.h
    engines/harvester/npc/mom_dialogue.h
    engines/harvester/npc/mother_dialogue.h
    engines/harvester/npc/moynahan_dialogue.h
    engines/harvester/npc/mr_potts_dialogue.h
    engines/harvester/npc/mrs_potts_dialogue.h
    engines/harvester/npc/nude_man_dialogue.h
    engines/harvester/npc/parsons_dialogue.h
    engines/harvester/npc/phelps_dialogue.h
    engines/harvester/npc/ryder_dialogue.h
    engines/harvester/npc/sergeant_dialogue.h
    engines/harvester/npc/sparky_dialogue.h
    engines/harvester/npc/stephanie_dialogue.cpp
    engines/harvester/npc/stephanie_dialogue.h
    engines/harvester/npc/swell_dialogue.h
    engines/harvester/npc/wasp_woman_dialogue.h
    engines/harvester/npc/whaley_dialogue.h
    engines/harvester/room.cpp
    engines/harvester/saveload.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index eba8bce89b8..5cc93a61626 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -399,6 +399,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - It first calls `sync_current_room_runtime_state_before_save_load`, which copies current-room monster runtime positions/facing back into `MonsterRecord` fields and refreshes the `SAVE_GAME` entrance from `g_player_combat_avatar`.
   - The save header is now explicit: current disc id, `g_player_present_in_room`, `g_game_clock_tick_remainder_ms`, `g_current_room_def->room_name`, and `g_player_room_name`.
   - The remainder of the save blob persists the player combat-avatar state, record lists (`ANIM`, `FLAG`, `MONSTER`, `NPC`, `OBJECT`, `REGION`, `TIMER`), the talk-state blocks, ammo counters, and `g_current_music_path`.
+  - The dialogue-state write sizes are now explicit from the save/load callsites: Sergeant writes `0x28` bytes from `g_sergeant_talk_state_block @ 0xd2d4c`, Stephanie writes `0x4c` bytes from `g_stephanie_talk_state_block @ 0xd2e4c`, and the shared discussion / follow-up block writes `0x9c` bytes from `0xd2e98`, ending immediately before `g_ryder_talk_state_block @ 0xd2f34`.
   - The object-state portion of the slot format persists only `object_name`, `current_x`, `current_y`, `current_z`, `current_owner_or_room`, `visible`, and `ident_shown`; the other object heap strings are reloaded from world data rather than restored from the save blob.
   - `g_player_room_name` at `0xd60a8` is serialized separately from `g_current_room_def->room_name` and tracks the room string currently assigned to the live player actor.
 - `run_text_entry_dialog` at `0x59fe0` is the shared modal text-entry widget used by the save menu, password prompt, and dialogue input.
diff --git a/TRACKER.md b/TRACKER.md
index aad25ef458a..6eba8e74f61 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -11,6 +11,8 @@
 ## Last Confirmed Action
 
 - On March 22, 2026, rechecked Harvester's native room-combat monster attack path in Ghidra after ScummVM monsters could pursue but kept logging `reason='out of range'` at contact. Confirmed from `update_actor_runtime_state` (`0x4d750`) that class-6 monsters arm melee by storing `g_player_combat_avatar` into live actor field `+0x11a4`, pick a random side-appropriate close-range attack family (`0x16..0x18` on the left, `0x19..0x1b` on the right) instead of using a target-Y band mapping, and resolve contact against that linked target after the `+0x113c` contact frame instead of re-running the coarse engage-distance spacing test. Patched [`room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) to retain the scheduled player target through the swing, resolve monster contact once against that linked target, and randomize native-style left/right attack bank selection from the ABM frame ranges. Verified by rebuilding [`room.o`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/engines/harvester/room.o) and relinking [`scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm).
+- On March 22, 2026, audited native save/load persistence in Ghidra before changing the engine-side dialogue save schema. `run_save_game_menu` / `run_load_game_menu` restore dedicated talk-state blocks in addition to the world/script records, including Sergeant's `0x28`-byte local block at `g_sergeant_talk_state_block @ 0xd2d4c`, Stephanie's `0x4c`-byte block at `g_stephanie_talk_state_block @ 0xd2e4c`, and the shared discussion / follow-up block at `0xd2e98` with size `0x9c`.
+- On March 22, 2026, patched ScummVM's Harvester save/load path to preserve and reload those dialogue branches. `HarvesterEngine` now carries a pending dialogue-state blob for launcher/runtime loads, `Flow` serializes and restores the active `DialogueSystem`, `DialogueSystem` syncs shared plus per-handler local talk state, and room-load handoff reapplies the blob immediately after the normal dialogue reset. Bumped the Harvester save version to `3` and rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 - On March 22, 2026, re-opened Sergeant's `SCRATCHED_TUCKER` no-item branch against the shipped `DIALOGUE.IDX` and `DIALOG.RSP` assets after ScummVM stopped at Sergeant line `0x420d` without the expected Day 2 response menu.
 - Confirmed the missing continuation is asset-backed: Sergeant should continue with `0x421e`, open zero-based `dialog.rsp` line `0x2b4`, and then branch to `0x422a` or `0x422f` / `0x4239`. Patched `engines/harvester/npc/sergeant_dialogue.cpp`, updated the Sergeant dialogue debug dump and `ARCHITECTURE.md`, and rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 - On March 22, 2026, cleaned up the native Sergeant room handler in Ghidra by rebuilding `handle_talk_to_sergeant` at `0x30340` from a broken one-byte stub into its real front-half body, setting an explicit `void __stdcall` prototype, and renaming the Sergeant-local `0xd2d50..0xd2d70` state slots to stable labels. The only remaining decompiler noise in that front half is the known register-lift artifact on `ASSIGNED_DNA_TASK` and the odd extra register shown on the `BOLTCLTH` branch.
@@ -23,6 +25,8 @@
 - On March 22, 2026, checked the unmapped bytes immediately after Sergeant's terminal `RET` at `0x30fd2`. That region is recoverable, but it is not additional `handle_talk_to_sergeant` continuation logic: the bytes starting at `0x30fe0`, `0x31000`, `0x31020`, `0x31040`, `0x31060`, `0x31080`, `0x310a0`, and `0x310c0` form a compact cluster of standalone Sergeant-local getter/setter helpers over globals `0xd2d4c..0xd2d6c`, using the same `if (param_1 == 0) global = param_2; return global;` pattern as the already-defined `get_set_sergeant_*` wrappers elsewhere in the binary. The hard `RET` at `0x30fd2` is therefore a real function end for the talk handler.
 - On March 22, 2026, defined the post-`0x30fd2` Sergeant accessor cluster as standalone functions instead of leaving it as unmapped code: `get_set_sergeant_talk_state_block` (`0x30fe0`), `get_set_sergeant_dialogue_state_d2d50` (`0x31000`), `get_set_sergeant_post_application_briefing_completed` (`0x31020`), `get_set_sergeant_dialogue_state_d2d58` (`0x31040`), `get_set_sergeant_dialogue_state_d2d5c` (`0x31060`), `get_set_sergeant_dialogue_state_d2d60` (`0x31080`), `get_set_sergeant_dialogue_state_d2d64` (`0x310a0`), `get_set_sergeant_dialogue_state_d2d68` (`0x310c0`), and `get_set_sergeant_dialogue_state_d2d6c` (`0x310e0`). Applied matching two-parameter `get/set` prototypes and removed the stale temporary labels that overlapped the new helper bodies.
 - On March 22, 2026, reimplemented `engines/harvester/npc/sergeant_dialogue.cpp` against the current recovered native Sergeant state instead of the earlier partial stub logic. The engine now mirrors the helper-backed local state block (`d2d4c`, `d2d50`, `d2d54`, `d2d58`, `d2d5c`, `d2d60`, `d2d64`, `d2d68`, `d2d6c`, `d2d70`), uses the recovered menu/FST tails for `REMAINS`, `INVITE`, `DINER_BURNED`, `BARBER_POLE`, `BOLTCLTH`, `SCRATCHED_TUCKER`, and the intro/revisit/application branches, and carries over the native Day 5 exit gate and `BOLTCLTH` slash-transition side effects. Verified by building `engines/harvester/npc/sergeant_dialogue.o` and `engines/harvester/dialogue.o` with `make`.
+- On March 22, 2026, re-ran the Stephanie talk-handler recovery using the stricter `InspectCallTermination` approach that checks the exact fallthrough byte after each `play_dialogue_line` call instead of relying on `inst.getNext()`. Clearing those stale `CALL_RETURN` overrides and disassembling the immediate fallthrough bytes recovered the Stephanie handler far enough to confirm the day-five, `BURNED_TV_STATION`, `SCRATCHED_TUCKER`, `BOLT_OF_CLOTH_TAKEN`, `BARBER_POLE_STOLEN`, `DINER_BURNED`, jail, and keyword-menu branch layouts plus the small orphan Stephanie continuations at `0x37650` and `0x37e78`.
+- On March 22, 2026, patched `engines/harvester/npc/stephanie_dialogue.cpp` against those confirmed native Stephanie paths: corrected the recovered Stephanie/PC head variants that were still hardcoded as `0`, removed the bogus unconditional `0x4717` tail after the nested `SCRATCHED_TUCKER` reply, and restored the native early-return behavior on the `BOLT_OF_CLOTH_TAKEN`, `BARBER_POLE_STOLEN`, `DINER_BURNED`, and `0x2ed` keyword-response exits. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 
 ## Next Suggested Action
 
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 420a5fb92b5..f08d6e671dc 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -125,6 +125,35 @@ static const byte kTextColorHover = 251;
 static const byte kShadowColor = 0;
 static const byte kTransparentPaletteIndex = 0;
 
+static void syncDialogueSharedState(Common::Serializer &s, DialogueSharedState &state) {
+	syncDialogueBool(s, state.boyleGascanApplicationState);
+	syncDialogueBool(s, state.dialogueStateD2e98);
+	syncDialogueBool(s, state.dialogueStateD2eb0);
+	syncDialogueBool(s, state.dialogueStateD2eb8);
+	syncDialogueBool(s, state.dialogueStateD2ebc);
+	syncDialogueBool(s, state.dialogueStateD2ec0);
+	syncDialogueBool(s, state.dialogueStateD2ec8);
+	syncDialogueBool(s, state.dialogueStateD2eec);
+	syncDialogueBool(s, state.dialogueStateD2ed0);
+	syncDialogueBool(s, state.dialogueStateD2ef4);
+	syncDialogueBool(s, state.dialogueStateD2f04);
+	syncDialogueBool(s, state.dialogueStateD2ea4);
+	syncDialogueBool(s, state.dialogueStateD2ea8);
+	syncDialogueBool(s, state.dialogueStateD2f00);
+	syncDialogueBool(s, state.karinKidnapedDialogueState);
+	syncDialogueBool(s, state.discussedLodgeTopic);
+	syncDialogueBool(s, state.dialogueStateD2f08);
+	syncDialogueBool(s, state.waspWomanDialogueState);
+	syncDialogueBool(s, state.momGoodCauseDay5State);
+	syncDialogueBool(s, state.moynahanKarinKidnapedDiscussionState);
+	syncDialogueBool(s, state.dwayneWhaleyDisciplineFollowupState);
+	syncDialogueBool(s, state.dwayneDiscussedBoylesButton);
+	syncDialogueBool(s, state.dwayneCompletedKarinAliveFollowup);
+	syncDialogueBool(s, state.discussedMrPottsTuesdayNightAlibi);
+	syncDialogueBool(s, state.discussedMrsPottsTuesdayNightAlibi);
+	syncDialogueBool(s, state.confrontedMrPottsAboutSpyhole);
+}
+
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
 	const Text *startupText = engine.getStartupText();
 	if (!startupText || !fontName)
@@ -477,6 +506,12 @@ void DialogueSystem::resetRoomNpcDialogueState() {
 		_npcHandlers[i]->resetState();
 }
 
+void DialogueSystem::syncRuntimeSaveState(Common::Serializer &s) {
+	syncDialogueSharedState(s, _sharedDialogueState);
+	for (uint i = 0; i < _npcHandlers.size(); ++i)
+		_npcHandlers[i]->syncState(s);
+}
+
 Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, const StartupNpcRecord &npc, const Common::String &usedItemName,
 		Flow &startupFlow) {
diff --git a/engines/harvester/dialogue.h b/engines/harvester/dialogue.h
index 304d37872a2..b44a9216564 100644
--- a/engines/harvester/dialogue.h
+++ b/engines/harvester/dialogue.h
@@ -47,6 +47,7 @@ public:
 	~DialogueSystem();
 
 	void resetRoomNpcDialogueState();
+	void syncRuntimeSaveState(Common::Serializer &s);
 	Common::Error runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, const StartupNpcRecord &npc, const Common::String &usedItemName,
 		Flow &startupFlow);
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 447879bdb9f..b8578919d85 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -30,6 +30,7 @@
 #include "common/events.h"
 #include "common/formats/ini-file.h"
 #include "common/memstream.h"
+#include "common/serializer.h"
 #include "common/system.h"
 #include "graphics/blit.h"
 #include "graphics/font.h"
@@ -1122,6 +1123,29 @@ bool Flow::load() {
 	return loadQuickTips() && loadMenuItems();
 }
 
+bool Flow::buildDialogueSaveStateBlob(Common::Array<byte> &blob) {
+	Common::MemoryWriteStreamDynamic stream(DisposeAfterUse::NO);
+	Common::Serializer serializer(nullptr, &stream);
+	_dialogue.syncRuntimeSaveState(serializer);
+	if (serializer.err())
+		return false;
+
+	blob.resize(stream.size());
+	if (!blob.empty())
+		memcpy(blob.data(), stream.getData(), blob.size());
+	return true;
+}
+
+bool Flow::loadDialogueSaveStateBlob(const Common::Array<byte> &blob) {
+	if (blob.empty())
+		return false;
+
+	Common::MemoryReadStream stream(blob.data(), blob.size());
+	Common::Serializer serializer(&stream, nullptr);
+	_dialogue.syncRuntimeSaveState(serializer);
+	return !serializer.err();
+}
+
 Common::Error Flow::run() {
 	if (!ensureCursorEntity())
 		return Common::kReadingFailed;
@@ -1137,6 +1161,7 @@ Common::Error Flow::run() {
 		error = runRoomLoop(initialTarget);
 	} else {
 		_engine.clearCurrentStartupSaveRoomState();
+		_engine.clearPendingLoadedDialogueStateBlob();
 		_engine.getStartupScript()->resetRuntimeState();
 		error = runQuickTips();
 		if (error.getCode() != Common::kNoError)
@@ -1509,6 +1534,7 @@ void Flow::prepareForNewGame() {
 	clearPendingMainMenuReturn();
 	clearPendingNewGameRestart();
 	_engine.clearPendingLoadedStartupSaveRoomState();
+	_engine.clearPendingLoadedDialogueStateBlob();
 	_engine.clearCurrentStartupSaveRoomState();
 	if (_engine.getStartupScript())
 		_engine.getStartupScript()->resetRuntimeState();
diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index 9a32d8ff3f4..7cc5ccfba16 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -46,6 +46,8 @@ public:
 	explicit Flow(HarvesterEngine &engine);
 
 	bool load();
+	bool buildDialogueSaveStateBlob(Common::Array<byte> &blob);
+	bool loadDialogueSaveStateBlob(const Common::Array<byte> &blob);
 	Common::Error run();
 
 private:
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 16340f4f07c..3422f508276 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -283,10 +283,15 @@ Common::Error HarvesterEngine::run() {
 		(void)loadGameState(saveSlot);
 
 	Flow startupFlow(*this);
-	if (!startupFlow.load())
+	_activeFlow = &startupFlow;
+	if (!startupFlow.load()) {
+		_activeFlow = nullptr;
 		return Common::kReadingFailed;
+	}
 
-	return startupFlow.run();
+	const Common::Error error = startupFlow.run();
+	_activeFlow = nullptr;
+	return error;
 }
 
 bool HarvesterEngine::hasFeature(EngineFeature f) const {
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index c2dc6273973..2932f17f220 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -42,6 +42,7 @@ class MediaManager;
 class Script;
 class Art;
 class Text;
+class Flow;
 
 class HarvesterEngine : public Engine {
 public:
@@ -121,6 +122,9 @@ public:
 		return _pendingLoadedStartupSaveRoomState;
 	}
 	void clearPendingLoadedStartupSaveRoomState();
+	bool hasPendingLoadedDialogueStateBlob() const { return !_pendingLoadedDialogueStateBlob.empty(); }
+	const Common::Array<byte> &getPendingLoadedDialogueStateBlob() const { return _pendingLoadedDialogueStateBlob; }
+	void clearPendingLoadedDialogueStateBlob() { _pendingLoadedDialogueStateBlob.clear(); }
 
 private:
 	void applyStartupMixerLevels();
@@ -132,6 +136,8 @@ private:
 	Script *_startupScript = nullptr;
 	StartupSaveRoomState _currentStartupSaveRoomState;
 	StartupSaveRoomState _pendingLoadedStartupSaveRoomState;
+	Common::Array<byte> _pendingLoadedDialogueStateBlob;
+	Flow *_activeFlow = nullptr;
 	bool _roomDebugEnabled = false;
 };
 
diff --git a/engines/harvester/npc/boyle_dialogue.h b/engines/harvester/npc/boyle_dialogue.h
index d299b6bb105..15da6904043 100644
--- a/engines/harvester/npc/boyle_dialogue.h
+++ b/engines/harvester/npc/boyle_dialogue.h
@@ -38,6 +38,13 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = BoyleRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.introPending);
+		syncDialogueBool(s, _state.evidenceFollowupShown);
+		syncDialogueBool(s, _state.stephMidgameShown);
+		syncDialogueBool(s, _state.dayFiveShown);
+		syncDialogueBool(s, _state.burnedTvStationShown);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/buster_dialogue.h b/engines/harvester/npc/buster_dialogue.h
index c39b09c4eac..553ca522da3 100644
--- a/engines/harvester/npc/buster_dialogue.h
+++ b/engines/harvester/npc/buster_dialogue.h
@@ -36,6 +36,11 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = BusterRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.introPending);
+		syncDialogueBool(s, _state.secondIntroPending);
+		syncDialogueBool(s, _state.thirdIntroPending);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/butcher_dialogue.h b/engines/harvester/npc/butcher_dialogue.h
index ad7447d6445..cab6a456420 100644
--- a/engines/harvester/npc/butcher_dialogue.h
+++ b/engines/harvester/npc/butcher_dialogue.h
@@ -34,6 +34,9 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = ButcherRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.talkStatePending);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/dad_dialogue.h b/engines/harvester/npc/dad_dialogue.h
index fa0c0af9b46..5b5a523fdf5 100644
--- a/engines/harvester/npc/dad_dialogue.h
+++ b/engines/harvester/npc/dad_dialogue.h
@@ -35,6 +35,10 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = DadRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.introPending);
+		syncDialogueBool(s, _state.meatPermissionState);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/dark_woman_dialogue.h b/engines/harvester/npc/dark_woman_dialogue.h
index bed729fd495..9351d9c15c6 100644
--- a/engines/harvester/npc/dark_woman_dialogue.h
+++ b/engines/harvester/npc/dark_woman_dialogue.h
@@ -34,6 +34,9 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = DarkWomanRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.talkStatePending);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/dialogue_handler.h b/engines/harvester/npc/dialogue_handler.h
index 2759c83ee40..dd2bae3506d 100644
--- a/engines/harvester/npc/dialogue_handler.h
+++ b/engines/harvester/npc/dialogue_handler.h
@@ -23,10 +23,26 @@
 #define HARVESTER_NPC_DIALOGUE_HANDLER_H
 
 #include "common/error.h"
+#include "common/serializer.h"
 #include "common/str.h"
 
 namespace Harvester {
 
+inline void syncDialogueBool(Common::Serializer &s, bool &value) {
+	byte serialized = value ? 1 : 0;
+	s.syncAsByte(serialized);
+	if (s.isLoading())
+		value = serialized != 0;
+}
+
+inline void syncDialogueInt(Common::Serializer &s, int &value) {
+	s.syncAsSint32LE(value);
+}
+
+inline void syncDialogueString(Common::Serializer &s, Common::String &value) {
+	s.syncString(value);
+}
+
 class DialogueRuntime;
 
 struct DialogueSharedState {
@@ -64,6 +80,7 @@ public:
 
 	virtual bool matchesNpc(const Common::String &npcName) const = 0;
 	virtual void resetState() {}
+	virtual void syncState(Common::Serializer &) {}
 	virtual Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) = 0;
 };
diff --git a/engines/harvester/npc/dwayne_dialogue.h b/engines/harvester/npc/dwayne_dialogue.h
index 025e9f62c04..a5106960d8e 100644
--- a/engines/harvester/npc/dwayne_dialogue.h
+++ b/engines/harvester/npc/dwayne_dialogue.h
@@ -63,6 +63,38 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = DwayneRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.pendingInitialConversation);
+		syncDialogueBool(s, _state.sheriffInDinerIntroPending);
+		syncDialogueBool(s, _state.sheriffInDinerIntroPlayed);
+		syncDialogueBool(s, _state.eventFollowupGate);
+		syncDialogueBool(s, _state.tvDeedReplyOverride);
+		syncDialogueBool(s, _state.presentedEvidenceReplyOverride);
+		syncDialogueBool(s, _state.discussedBoylesButton);
+		syncDialogueBool(s, _state.discussedKarinPurse);
+		syncDialogueBool(s, _state.pendingKarinAliveFollowup);
+		syncDialogueBool(s, _state.completedKarinAliveFollowup);
+		syncDialogueInt(s, _state.pendingKarinAliveFollowupDayIndex);
+		syncDialogueBool(s, _state.bringKarinToSheriffLinePlayed);
+		syncDialogueBool(s, _state.whaleyDisciplineFollowupShown);
+		syncDialogueBool(s, _state.noteCheckbookFollowupShown);
+		syncDialogueBool(s, _state.scratchedTuckerShown);
+		syncDialogueBool(s, _state.boltOfClothTakenShown);
+		syncDialogueBool(s, _state.barberPoleStolenShown);
+		syncDialogueBool(s, _state.dinerBurnedShown);
+		syncDialogueBool(s, _state.escapedJailShown);
+		syncDialogueBool(s, _state.gotRemainsForLodgeShown);
+		syncDialogueBool(s, _state.burnedTvStationShown);
+		syncDialogueBool(s, _state.karinKidnapedShown);
+		syncDialogueBool(s, _state.pendingKarinAliveFollowupLinePlayed);
+		syncDialogueBool(s, _state.karinFoundDeadWithoutPurseShown);
+		syncDialogueBool(s, _state.jimmyAbsentShown);
+		syncDialogueBool(s, _state.moynahanAbsentShown);
+		syncDialogueBool(s, _state.ednaHungShown);
+		syncDialogueBool(s, _state.mcknightAbsentShown);
+		syncDialogueString(s, _state.currentTopicBuffer);
+		syncDialogueInt(s, _state.currentTopicBufferLineIndex);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/edna_dialogue.h b/engines/harvester/npc/edna_dialogue.h
index 4961b9e662a..4b91781a2de 100644
--- a/engines/harvester/npc/edna_dialogue.h
+++ b/engines/harvester/npc/edna_dialogue.h
@@ -35,6 +35,10 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = EdnaRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.introPending);
+		syncDialogueBool(s, _state.karinPurseLinePlayed);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/fireman1_dialogue.h b/engines/harvester/npc/fireman1_dialogue.h
index 93e911b139f..84a11bd89f4 100644
--- a/engines/harvester/npc/fireman1_dialogue.h
+++ b/engines/harvester/npc/fireman1_dialogue.h
@@ -34,6 +34,9 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = Fireman1RoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.talkStatePending);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/fireman2_dialogue.h b/engines/harvester/npc/fireman2_dialogue.h
index 721f9942a94..c5f7d538159 100644
--- a/engines/harvester/npc/fireman2_dialogue.h
+++ b/engines/harvester/npc/fireman2_dialogue.h
@@ -34,6 +34,9 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = Fireman2RoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.talkStatePending);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/hank_dialogue.h b/engines/harvester/npc/hank_dialogue.h
index 4a08c3609ae..b6e3a3028d0 100644
--- a/engines/harvester/npc/hank_dialogue.h
+++ b/engines/harvester/npc/hank_dialogue.h
@@ -46,6 +46,21 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = HankRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.pendingInitialConversation);
+		syncDialogueBool(s, _state.hasTrackedDayState);
+		syncDialogueBool(s, _state.pendingRangshotSequence);
+		syncDialogueBool(s, _state.pendingSameDayFollowup);
+		syncDialogueInt(s, _state.trackedDayIndex);
+		syncDialogueBool(s, _state.stephMidgamePlayedShown);
+		syncDialogueBool(s, _state.burnedTvStationShown);
+		syncDialogueBool(s, _state.bustedOnceShown);
+		syncDialogueBool(s, _state.karinKidnapedShown);
+		syncDialogueBool(s, _state.karinFoundAliveShown);
+		syncDialogueBool(s, _state.karinFoundDeadShown);
+		syncDialogueString(s, _state.currentTopicBuffer);
+		syncDialogueInt(s, _state.currentTopicBufferLineIndex);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/herrill_dialogue.h b/engines/harvester/npc/herrill_dialogue.h
index d1d34a781fb..0a68d9356de 100644
--- a/engines/harvester/npc/herrill_dialogue.h
+++ b/engines/harvester/npc/herrill_dialogue.h
@@ -49,6 +49,24 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = HerrillRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.introPending);
+		syncDialogueBool(s, _state.gascanPresented);
+		syncDialogueBool(s, _state.dialogueStateD2ce4);
+		syncDialogueBool(s, _state.dialogueSuppressed);
+		syncDialogueBool(s, _state.requiresEvidenceForDialogue);
+		syncDialogueBool(s, _state.stephMidgamePlayedShown);
+		syncDialogueBool(s, _state.dayFiveShown);
+		syncDialogueBool(s, _state.scratchedTuckerShown);
+		syncDialogueBool(s, _state.barberPoleStolenShown);
+		syncDialogueBool(s, _state.karinKidnapedShown);
+		syncDialogueBool(s, _state.karinFoundAliveShown);
+		syncDialogueBool(s, _state.karinFoundDeadShown);
+		syncDialogueBool(s, _state.dinerBurnedShown);
+		syncDialogueBool(s, _state.whaleyHerrillFollowupShown);
+		syncDialogueString(s, _state.currentTopicBuffer);
+		syncDialogueInt(s, _state.currentTopicBufferLineIndex);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/inquisitor_dialogue.h b/engines/harvester/npc/inquisitor_dialogue.h
index 1dcee7e9e23..dd91094d0e5 100644
--- a/engines/harvester/npc/inquisitor_dialogue.h
+++ b/engines/harvester/npc/inquisitor_dialogue.h
@@ -34,6 +34,9 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = InquisitorRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.talkStatePending);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/jimmy_dialogue.h b/engines/harvester/npc/jimmy_dialogue.h
index 1fa273b5406..47e723b79e7 100644
--- a/engines/harvester/npc/jimmy_dialogue.h
+++ b/engines/harvester/npc/jimmy_dialogue.h
@@ -35,6 +35,10 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = JimmyRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.firstNoItemLinePending);
+		syncDialogueBool(s, _state.paperHandoffStateSet);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/johnson_dialogue.h b/engines/harvester/npc/johnson_dialogue.h
index 6f0a35e0e2f..39381727dbf 100644
--- a/engines/harvester/npc/johnson_dialogue.h
+++ b/engines/harvester/npc/johnson_dialogue.h
@@ -46,6 +46,21 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = JohnsonRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.introPending);
+		syncDialogueBool(s, _state.tryToScratchTuckerShown);
+		syncDialogueBool(s, _state.scratchedTuckerReintroShown);
+		syncDialogueBool(s, _state.stephMidgamePlayedShown);
+		syncDialogueBool(s, _state.karinKidnapedShown);
+		syncDialogueBool(s, _state.karinFoundAliveShown);
+		syncDialogueBool(s, _state.barberPoleStolenShown);
+		syncDialogueBool(s, _state.burnedTvStationShown);
+		syncDialogueBool(s, _state.dinerBurnedShown);
+		syncDialogueBool(s, _state.escapedJailShown);
+		syncDialogueBool(s, _state.gotRemainsForLodgeShown);
+		syncDialogueString(s, _state.currentTopicBuffer);
+		syncDialogueInt(s, _state.currentTopicBufferLineIndex);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/karin_dialogue.h b/engines/harvester/npc/karin_dialogue.h
index fb704dd3650..7ef62b48bee 100644
--- a/engines/harvester/npc/karin_dialogue.h
+++ b/engines/harvester/npc/karin_dialogue.h
@@ -36,6 +36,11 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = KarinRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.photoReplyOverrideFlag);
+		syncDialogueBool(s, _state.cem10AliveLinePlayed);
+		syncDialogueBool(s, _state.notFoundAliveLinePlayed);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/loomis_dialogue.h b/engines/harvester/npc/loomis_dialogue.h
index 9a802f5adb7..8d28d9f3809 100644
--- a/engines/harvester/npc/loomis_dialogue.h
+++ b/engines/harvester/npc/loomis_dialogue.h
@@ -37,6 +37,12 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = LoomisRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueString(s, _state.currentTopicBuffer);
+		syncDialogueInt(s, _state.currentTopicBufferLineIndex);
+		syncDialogueBool(s, _state.talkStatePending);
+		syncDialogueBool(s, _state.returnVisitFollowupPending);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/mcknight_dialogue.h b/engines/harvester/npc/mcknight_dialogue.h
index 0d3503cd82f..df202a9d3ff 100644
--- a/engines/harvester/npc/mcknight_dialogue.h
+++ b/engines/harvester/npc/mcknight_dialogue.h
@@ -35,6 +35,10 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = McknightRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.talkStatePending);
+		syncDialogueBool(s, _state.lookSafeFirstLineGate);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/memb_dir_dialogue.h b/engines/harvester/npc/memb_dir_dialogue.h
index c089f3c7b64..4ca07624980 100644
--- a/engines/harvester/npc/memb_dir_dialogue.h
+++ b/engines/harvester/npc/memb_dir_dialogue.h
@@ -34,6 +34,9 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = MembDirRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.kewpieKillLinePlayed);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/mom_dialogue.h b/engines/harvester/npc/mom_dialogue.h
index 99961d7ddca..701ddab590a 100644
--- a/engines/harvester/npc/mom_dialogue.h
+++ b/engines/harvester/npc/mom_dialogue.h
@@ -54,6 +54,29 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = MomRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.introPending);
+		syncDialogueBool(s, _state.sameDayIntroLineEnabled);
+		syncDialogueBool(s, _state.postIntroDefaultLineEnabled);
+		syncDialogueInt(s, _state.introDayIndex);
+		syncDialogueBool(s, _state.stephMidgameShown);
+		syncDialogueBool(s, _state.dinerBurnedKarinMissingOrDeadShown);
+		syncDialogueBool(s, _state.burnedTvStationShown);
+		syncDialogueBool(s, _state.scratchedTuckerShown);
+		syncDialogueBool(s, _state.barberPoleStolenShown);
+		syncDialogueBool(s, _state.boltOfClothTakenShown);
+		syncDialogueBool(s, _state.dinerBurnedKarinAliveShown);
+		syncDialogueBool(s, _state.escapedJailShown);
+		syncDialogueBool(s, _state.karinKidnapedUnresolvedShown);
+		syncDialogueBool(s, _state.karinFoundAliveShown);
+		syncDialogueBool(s, _state.karinFoundDeadShown);
+		syncDialogueBool(s, _state.butcherAbsentShown);
+		syncDialogueBool(s, _state.moynahanAbsentShown);
+		syncDialogueBool(s, _state.waspWomanAbsentShown);
+		syncDialogueBool(s, _state.stephanieDeadPreMidgameShown);
+		syncDialogueBool(s, _state.day5Shown);
+		syncDialogueBool(s, _state.day6Shown);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/mother_dialogue.h b/engines/harvester/npc/mother_dialogue.h
index 444dddc5f28..b52827e2b7b 100644
--- a/engines/harvester/npc/mother_dialogue.h
+++ b/engines/harvester/npc/mother_dialogue.h
@@ -34,6 +34,9 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = MotherRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.talkStatePending);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/moynahan_dialogue.h b/engines/harvester/npc/moynahan_dialogue.h
index 458e892bf5f..f72ca8e1116 100644
--- a/engines/harvester/npc/moynahan_dialogue.h
+++ b/engines/harvester/npc/moynahan_dialogue.h
@@ -46,6 +46,21 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = MoynahanRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.introPending);
+		syncDialogueBool(s, _state.slashMarksTopicUnlocked);
+		syncDialogueBool(s, _state.slashMarksIntroShown);
+		syncDialogueBool(s, _state.gotGlue);
+		syncDialogueBool(s, _state.ledgersIntroduced);
+		syncDialogueBool(s, _state.ledgerEvidenceLinePlayed);
+		syncDialogueBool(s, _state.stephMidgameShown);
+		syncDialogueBool(s, _state.dayFiveShown);
+		syncDialogueBool(s, _state.burnedTvStationShown);
+		syncDialogueBool(s, _state.karinKidnapedShown);
+		syncDialogueBool(s, _state.butcherDeadShown);
+		syncDialogueBool(s, _state.jimmyDeadShown);
+		syncDialogueBool(s, _state.karinFoundDeadShown);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/mr_potts_dialogue.h b/engines/harvester/npc/mr_potts_dialogue.h
index 8032e72c9af..f2727b9faa9 100644
--- a/engines/harvester/npc/mr_potts_dialogue.h
+++ b/engines/harvester/npc/mr_potts_dialogue.h
@@ -56,6 +56,27 @@ public:
 		_state = MrPottsRoomDialogueState();
 		_state.topicBufferLineIndex = topicBufferLineIndex;
 	}
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.auxIntroPending);
+		syncDialogueInt(s, _state.followupDayIndex);
+		syncDialogueBool(s, _state.pottsdamDiggingLinePlayed);
+		syncDialogueInt(s, _state.topicBufferLineIndex);
+		syncDialogueBool(s, _state.stephanieAliveLaterDayShown);
+		syncDialogueBool(s, _state.karinKidnapedShown);
+		syncDialogueBool(s, _state.karinFoundDeadShown);
+		syncDialogueBool(s, _state.karinFoundAliveShown);
+		syncDialogueBool(s, _state.day6KarinShown);
+		syncDialogueBool(s, _state.scratchedTuckerShown);
+		syncDialogueBool(s, _state.barberPoleStolenShown);
+		syncDialogueBool(s, _state.boltOfClothTakenShown);
+		syncDialogueBool(s, _state.dinerBurnedShown);
+		syncDialogueBool(s, _state.gotRemainsForLodgeShown);
+		syncDialogueBool(s, _state.escapedJailShown);
+		syncDialogueBool(s, _state.butcherDeathShown);
+		syncDialogueBool(s, _state.moynahanDeathShown);
+		syncDialogueBool(s, _state.ednaHungShown);
+		syncDialogueBool(s, _state.day6KarinFoundAliveFollowupShown);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/mrs_potts_dialogue.h b/engines/harvester/npc/mrs_potts_dialogue.h
index 8e3ec401528..4907b23ffe6 100644
--- a/engines/harvester/npc/mrs_potts_dialogue.h
+++ b/engines/harvester/npc/mrs_potts_dialogue.h
@@ -41,6 +41,16 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = MrsPottsRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.auxIntroPending);
+		syncDialogueBool(s, _state.returnVisitFollowupPending);
+		syncDialogueBool(s, _state.gotRemainsForLodgeLinePlayed);
+		syncDialogueBool(s, _state.takenInviteToSergeantLinePlayed);
+		syncDialogueBool(s, _state.day5LinePlayed);
+		syncDialogueBool(s, _state.burnedTvStationLinePlayed);
+		syncDialogueBool(s, _state.karinKidnappedLinePlayed);
+		syncDialogueBool(s, _state.dinerBurnedLinePlayed);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/nude_man_dialogue.h b/engines/harvester/npc/nude_man_dialogue.h
index 70ed10ec535..3468cfed78f 100644
--- a/engines/harvester/npc/nude_man_dialogue.h
+++ b/engines/harvester/npc/nude_man_dialogue.h
@@ -34,6 +34,9 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = NudeManRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.talkStatePending);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/parsons_dialogue.h b/engines/harvester/npc/parsons_dialogue.h
index 488112718cb..0598fbeb7b2 100644
--- a/engines/harvester/npc/parsons_dialogue.h
+++ b/engines/harvester/npc/parsons_dialogue.h
@@ -34,6 +34,9 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = ParsonsRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.talkStatePending);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/phelps_dialogue.h b/engines/harvester/npc/phelps_dialogue.h
index 7606c6bdefa..5703b37517e 100644
--- a/engines/harvester/npc/phelps_dialogue.h
+++ b/engines/harvester/npc/phelps_dialogue.h
@@ -48,6 +48,23 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = PhelpsRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.talkStatePending);
+		syncDialogueBool(s, _state.stephMidgameShown);
+		syncDialogueBool(s, _state.dayFiveShown);
+		syncDialogueBool(s, _state.scratchedTuckerShown);
+		syncDialogueBool(s, _state.barberPoleStolenShown);
+		syncDialogueBool(s, _state.boltOfClothTakenShown);
+		syncDialogueBool(s, _state.dinerBurnedShown);
+		syncDialogueBool(s, _state.escapedJailShown);
+		syncDialogueBool(s, _state.gotRemainsForLodgeShown);
+		syncDialogueBool(s, _state.burnedTvStationShown);
+		syncDialogueBool(s, _state.butcherDeadShown);
+		syncDialogueBool(s, _state.moynahanDeadShown);
+		syncDialogueBool(s, _state.jimmyDeadShown);
+		syncDialogueBool(s, _state.karinFoundDeadShown);
+		syncDialogueBool(s, _state.karinKidnapedShown);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/ryder_dialogue.h b/engines/harvester/npc/ryder_dialogue.h
index 52c3b24d1c6..45d176023fe 100644
--- a/engines/harvester/npc/ryder_dialogue.h
+++ b/engines/harvester/npc/ryder_dialogue.h
@@ -34,6 +34,9 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = RyderRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.talkStatePending);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/sergeant_dialogue.h b/engines/harvester/npc/sergeant_dialogue.h
index 112e15b3548..0b04f4ddcf7 100644
--- a/engines/harvester/npc/sergeant_dialogue.h
+++ b/engines/harvester/npc/sergeant_dialogue.h
@@ -44,6 +44,19 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = SergeantRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.talkStateBlock);
+		syncDialogueBool(s, _state.dialogueStateD2d50);
+		syncDialogueBool(s, _state.postApplicationBriefingCompleted);
+		syncDialogueBool(s, _state.dialogueStateD2d58);
+		syncDialogueBool(s, _state.dialogueStateD2d5c);
+		syncDialogueBool(s, _state.dialogueStateD2d60);
+		syncDialogueBool(s, _state.dialogueStateD2d64);
+		syncDialogueBool(s, _state.dialogueStateD2d68);
+		syncDialogueBool(s, _state.dialogueStateD2d6c);
+		syncDialogueBool(s, _state.completedFirstTaskState);
+		syncDialogueBool(s, _state.day5ExitLinePlayed);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/sparky_dialogue.h b/engines/harvester/npc/sparky_dialogue.h
index 570db24cecc..eed533feada 100644
--- a/engines/harvester/npc/sparky_dialogue.h
+++ b/engines/harvester/npc/sparky_dialogue.h
@@ -38,6 +38,13 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = SparkyRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.introPending);
+		syncDialogueBool(s, _state.returnVisitPending);
+		syncDialogueInt(s, _state.returnVisitDayIndex);
+		syncDialogueString(s, _state.currentTopicBuffer);
+		syncDialogueInt(s, _state.currentTopicBufferLineIndex);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/stephanie_dialogue.cpp b/engines/harvester/npc/stephanie_dialogue.cpp
index 5e982977bf4..a6ed7e66bb7 100644
--- a/engines/harvester/npc/stephanie_dialogue.cpp
+++ b/engines/harvester/npc/stephanie_dialogue.cpp
@@ -58,6 +58,9 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	auto playStephanieLine = [&](int wavId, const char *speakerId = "STEPHANIE") -> Common::Error {
 		return runtime.playDialogueLine(wavId, speakerId);
 	};
+	auto playStephanieLineWithVariant = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "STEPHANIE", headVariant);
+	};
 	auto executeDialogueActionTag = [&](const char *tag) {
 		StartupInteractionResult interaction;
 		if (runtime.startupScript().executeActionTag(tag, interaction)) {
@@ -81,12 +84,12 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(
 				DialogueFlags::kShownEvidenceOfBlackmail, true);
-			return playStephanieLine(0x466a);
+			return playStephanieLineWithVariant(0x466a, 2);
 		}
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
-			Common::Error lineError = playStephanieLine(0x4671);
+			Common::Error lineError = playStephanieLineWithVariant(0x4671, 4);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			lineError = runtime.playDialogueLine(0x4675, "PC");
@@ -127,7 +130,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		state.introPending = false;
 		state.introDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
 		const DialogueLineEntry introLines[] = {
-			{ 0x43db, "STEPHANIE", 0 },
+			{ 0x43db, "STEPHANIE", 2 },
 			{ 0x43df, "PC", 0 },
 			{ 0x43e3, "STEPHANIE", 0 },
 			{ 0x43e7, "PC", 0 },
@@ -150,7 +153,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		state.firstFollowupPending = false;
 		state.firstFollowupDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
 
-		Common::Error lineError = playStephanieLine(0x45a7);
+		Common::Error lineError = playStephanieLineWithVariant(0x45a7, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 		if (sharedState.dialogueStateD2f08) {
@@ -192,7 +195,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		state.secondFollowupDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
 
 		const DialogueLineEntry nastyIntroLines[] = {
-			{ 0x45e8, "STEPHANIE", 0 },
+			{ 0x45e8, "STEPHANIE", 1 },
 			{ 0x45ed, "PC", 0 },
 			{ 0x45f2, "STEPHANIE", 0 }
 		};
@@ -268,11 +271,11 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const DialogueLineEntry dayFiveLines[] = {
 			{ 0x46aa, "STEPHANIE", 0 },
 			{ 0x46ae, "PC", 0 },
-			{ 0x46b3, "STEPHANIE", 0 },
+			{ 0x46b3, "STEPHANIE", 2 },
 			{ 0x46bf, "STEPHANIE", 0 },
 			{ 0x46ba, "PC", 0 },
 			{ 0x46c5, "PC", 0 },
-			{ 0x46c9, "STEPHANIE", 0 }
+			{ 0x46c9, "STEPHANIE", 3 }
 		};
 		Common::Error lineError = runtime.playDialogueEntrySequence(dayFiveLines, ARRAYSIZE(dayFiveLines));
 		if (lineError.getCode() != Common::kNoError)
@@ -281,14 +284,14 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
 			!state.burnedTvStationShown) {
 		state.burnedTvStationShown = true;
-		Common::Error lineError = runtime.playDialogueLineWithVariant(0x4918, "STEPHANIE", 1);
+		Common::Error lineError = runtime.playDialogueLineWithVariant(0x4918, "STEPHANIE", 3);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 		const DialogueLineEntry openingLines[] = {
 			{ 0x491e, "PC", 0 },
-			{ 0x4928, "STEPHANIE", 0 },
-			{ 0x492c, "PC", 0 },
-			{ 0x4930, "STEPHANIE", 0 }
+			{ 0x4928, "STEPHANIE", 3 },
+			{ 0x492c, "PC", 3 },
+			{ 0x4930, "STEPHANIE", 3 }
 		};
 		lineError = runtime.playDialogueEntrySequence(openingLines, ARRAYSIZE(openingLines));
 		if (lineError.getCode() != Common::kNoError)
@@ -300,7 +303,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return responseError;
 
 		if (responseIndex == 1) {
-			lineError = playStephanieLine(0x493a);
+			lineError = playStephanieLineWithVariant(0x493a, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			clearScreenToBlack();
@@ -310,9 +313,9 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 		if (responseIndex == 2) {
 			const DialogueLineEntry responseLines[] = {
-				{ 0x4944, "STEPHANIE", 0 },
-				{ 0x4949, "PC", 0 },
-				{ 0x494d, "STEPHANIE", 0 }
+				{ 0x4944, "STEPHANIE", 3 },
+				{ 0x4949, "PC", 3 },
+				{ 0x494d, "STEPHANIE", 3 }
 			};
 			lineError = runtime.playDialogueEntrySequence(responseLines, ARRAYSIZE(responseLines));
 			if (lineError.getCode() != Common::kNoError)
@@ -338,11 +341,11 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 		if (responseIndex == 1) {
 			const DialogueLineEntry responseOneLines[] = {
-				{ 0x46ea, "STEPHANIE", 0 },
+				{ 0x46ea, "STEPHANIE", 2 },
 				{ 0x46ee, "PC", 0 },
-				{ 0x46f2, "STEPHANIE", 0 },
+				{ 0x46f2, "STEPHANIE", 2 },
 				{ 0x46f6, "PC", 0 },
-				{ 0x46fb, "STEPHANIE", 0 }
+				{ 0x46fb, "STEPHANIE", 2 }
 			};
 			lineError = runtime.playDialogueEntrySequence(responseOneLines, ARRAYSIZE(responseOneLines));
 			if (lineError.getCode() != Common::kNoError)
@@ -355,21 +358,18 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 			if (followupResponseIndex == 1) {
 				const DialogueLineEntry followupLines[] = {
-					{ 0x4705, "STEPHANIE", 0 },
+					{ 0x4705, "STEPHANIE", 2 },
 					{ 0x4709, "PC", 0 }
 				};
-				lineError = runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
+				return runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
 			} else if (followupResponseIndex == 2) {
 				const DialogueLineEntry followupLines[] = {
-					{ 0x470e, "STEPHANIE", 0 },
+					{ 0x470e, "STEPHANIE", 2 },
 					{ 0x470f, "PC", 0 }
 				};
-				lineError = runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
+				return runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
 			}
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-
-			lineError = playStephanieLine(0x4717);
+			lineError = playStephanieLineWithVariant(0x4717, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		} else if (responseIndex == 2) {
@@ -395,18 +395,19 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		} else if (responseIndex == 2) {
 			state.boltOfClothEscalationTriggered = true;
 			const DialogueLineEntry responseTwoLines[] = {
-				{ 0x4736, "STEPHANIE", 0 },
+				{ 0x4736, "STEPHANIE", 2 },
 				{ 0x473b, "PC", 0 },
-				{ 0x4740, "STEPHANIE", 0 },
+				{ 0x4740, "STEPHANIE", 2 },
 				{ 0x4744, "PC", 0 },
-				{ 0x4748, "STEPHANIE", 0 },
-				{ 0x474d, "PC", 0 },
+				{ 0x4748, "STEPHANIE", 2 },
+				{ 0x474d, "PC", 4 },
 				{ 0x4753, "STEPHANIE", 0 }
 			};
 			lineError = runtime.playDialogueEntrySequence(responseTwoLines, ARRAYSIZE(responseTwoLines));
 		}
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+		return Common::kNoError;
 	}
 	if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") &&
 			!state.barberPoleStolenShown) {
@@ -429,20 +430,20 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			lineError = runtime.playDialogueEntrySequence(responseOneLines, ARRAYSIZE(responseOneLines));
 		} else if (responseIndex == 2) {
 			const DialogueLineEntry responseTwoLines[] = {
-				{ 0x47be, "STEPHANIE", 0 },
-				{ 0x47c2, "PC", 0 },
-				{ 0x47c7, "STEPHANIE", 0 },
-				{ 0x47cc, "PC", 0 },
-				{ 0x47d0, "STEPHANIE", 0 },
-				{ 0x47d6, "PC", 0 },
+				{ 0x47be, "STEPHANIE", 2 },
+				{ 0x47c2, "PC", 3 },
+				{ 0x47c7, "STEPHANIE", 4 },
+				{ 0x47cc, "PC", 2 },
+				{ 0x47d0, "STEPHANIE", 2 },
+				{ 0x47d6, "PC", 2 },
 				{ 0x47da, "STEPHANIE", 0 },
-				{ 0x47de, "PC", 0 },
+				{ 0x47de, "PC", 2 },
 				{ 0x47e3, "STEPHANIE", 0 },
 				{ 0x47eb, "PC", 0 },
-				{ 0x47ef, "STEPHANIE", 0 },
-				{ 0x482b, "STEPHANIE", 0 },
-				{ 0x4830, "PC", 0 },
-				{ 0x4834, "STEPHANIE", 0 },
+				{ 0x47ef, "STEPHANIE", 2 },
+				{ 0x482b, "STEPHANIE", 2 },
+				{ 0x4830, "PC", 4 },
+				{ 0x4834, "STEPHANIE", 2 },
 				{ 0x4839, "PC", 0 },
 				{ 0x483d, "STEPHANIE", 0 },
 				{ 0x47fe, "PC", 0 }
@@ -451,12 +452,13 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+		return Common::kNoError;
 	}
 	if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
 			!state.dinerBurnedShown) {
 		state.dinerBurnedShown = true;
 		const DialogueLineEntry openingLines[] = {
-			{ 0x4845, "STEPHANIE", 0 },
+			{ 0x4845, "STEPHANIE", 3 },
 			{ 0x484a, "PC", 0 },
 			{ 0x484e, "STEPHANIE", 0 }
 		};
@@ -470,11 +472,11 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return responseError;
 
 		if (responseIndex == 1) {
-			lineError = playStephanieLine(0x4858);
+			lineError = playStephanieLineWithVariant(0x4858, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			if (runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE")) {
-				lineError = playStephanieLine(0x485c);
+				lineError = playStephanieLineWithVariant(0x485c, 3);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 			}
@@ -486,14 +488,14 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 			if (followupResponseIndex == 1) {
 				const DialogueLineEntry followupLines[] = {
-					{ 0x4866, "STEPHANIE", 0 },
-					{ 0x486b, "PC", 0 },
-					{ 0x4870, "STEPHANIE", 0 }
+					{ 0x4866, "STEPHANIE", 2 },
+					{ 0x486b, "PC", 4 },
+					{ 0x4870, "STEPHANIE", 2 }
 				};
-				lineError = runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
+				return runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
 			} else if (followupResponseIndex == 2) {
 				const DialogueLineEntry responseTwoLines[] = {
-					{ 0x4877, "STEPHANIE", 0 },
+					{ 0x4877, "STEPHANIE", 3 },
 					{ 0x487b, "PC", 0 },
 					{ 0x4880, "STEPHANIE", 0 }
 				};
@@ -507,19 +509,20 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 					return responseError;
 
 				if (nestedResponseIndex == 1) {
-					lineError = playStephanieLine(0x488e);
+					lineError = playStephanieLineWithVariant(0x488e, 2);
 				} else if (nestedResponseIndex == 2) {
 					const DialogueLineEntry nestedLines[] = {
-						{ 0x4895, "STEPHANIE", 0 },
-						{ 0x4899, "PC", 0 },
-						{ 0x489d, "STEPHANIE", 0 },
-						{ 0x461b, "PC", 0 }
+						{ 0x4895, "STEPHANIE", 1 },
+						{ 0x4899, "PC", 3 },
+						{ 0x489d, "STEPHANIE", 3 },
+						{ 0x461b, "PC", 3 }
 					};
 					lineError = runtime.playDialogueEntrySequence(nestedLines, ARRAYSIZE(nestedLines));
 				}
 			}
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			return Common::kNoError;
 		} else if (responseIndex == 2) {
 			const DialogueLineEntry responseTwoLines[] = {
 				{ 0x48c2, "STEPHANIE", 0 },
@@ -534,23 +537,21 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 					!state.dinerBurnedKarinAliveFollowupShown) {
 				state.dinerBurnedKarinAliveFollowupShown = true;
 				const DialogueLineEntry karinAliveLines[] = {
-					{ 0x48ce, "STEPHANIE", 0 },
-					{ 0x48d3, "STEPHANIE", 0 },
+					{ 0x48ce, "STEPHANIE", 3 },
+					{ 0x48d3, "STEPHANIE", 3 },
 					{ 0x48d8, "PC", 0 },
 					{ 0x48dc, "STEPHANIE", 0 },
 					{ 0x48e1, "PC", 0 },
 					{ 0x48e5, "STEPHANIE", 0 }
 				};
-				lineError = runtime.playDialogueEntrySequence(karinAliveLines, ARRAYSIZE(karinAliveLines));
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
+				return runtime.playDialogueEntrySequence(karinAliveLines, ARRAYSIZE(karinAliveLines));
 			}
 		}
 	}
 	if (runtime.startupScript().getFlagValue("PC_ESCAPED_JAIL") &&
 			!state.pcEscapedJailShown) {
 		state.pcEscapedJailShown = true;
-		Common::Error lineError = playStephanieLine(0x48eb);
+		Common::Error lineError = playStephanieLineWithVariant(0x48eb, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
@@ -561,7 +562,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 		if (responseIndex == 1) {
 			const DialogueLineEntry responseLines[] = {
-				{ 0x48f7, "STEPHANIE", 0 },
+				{ 0x48f7, "STEPHANIE", 3 },
 				{ 0x4908, "PC", 0 },
 				{ 0x490d, "STEPHANIE", 0 },
 				{ 0x4912, "PC", 0 }
@@ -755,9 +756,10 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (responseError.getCode() != Common::kNoError)
 				return responseError;
 			if (responseIndex == 1) {
-				lineError = playStephanieLine(0x4495);
+				lineError = playStephanieLineWithVariant(0x4495, 2);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
+				return Common::kNoError;
 			} else if (responseIndex == 2) {
 				lineError = playStephanieLine(0x4499);
 				if (lineError.getCode() != Common::kNoError)
@@ -765,6 +767,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				lineError = runtime.playDialogueLine(0x449e, "PC");
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
+				return Common::kNoError;
 			}
 			const DialogueLineEntry lines[] = {
 				{ 0x44a2, "PC", 0 },
@@ -782,7 +785,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			const DialogueLineEntry lines[] = {
 				{ 0x44ba, "STEPHANIE", 0 },
 				{ 0x44bf, "PC", 0 },
-				{ 0x44c4, "STEPHANIE", 0 },
+				{ 0x44c4, "STEPHANIE", 1 },
 				{ 0x44c9, "PC", 0 },
 				{ 0x44ce, "STEPHANIE", 0 },
 				{ 0x44dd, "PC", 0 },
@@ -795,7 +798,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x2f3)) {
-			Common::Error lineError = playStephanieLine(0x44f1);
+			Common::Error lineError = playStephanieLineWithVariant(0x44f1, 2);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			lineError = runtime.playDialogueFst(kDialogueC022AFstPath);
@@ -812,9 +815,9 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 					return lineError;
 			}
 			const DialogueLineEntry lines[] = {
-				{ 0x4513, "STEPHANIE", 0 },
+				{ 0x4513, "STEPHANIE", 2 },
 				{ 0x4517, "PC", 0 },
-				{ 0x451b, "STEPHANIE", 0 },
+				{ 0x451b, "STEPHANIE", 2 },
 				{ 0x451f, "PC", 0 },
 				{ 0x4523, "STEPHANIE", 0 },
 				{ 0x452a, "PC", 0 },
@@ -832,7 +835,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			Common::Error lineError = runtime.playDialogueLine(0x453d, "PC");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			lineError = playStephanieLine(0x4541);
+			lineError = playStephanieLineWithVariant(0x4541, 2);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			int responseIndex = 0;
@@ -840,11 +843,11 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (responseError.getCode() != Common::kNoError)
 				return responseError;
 			if (responseIndex == 1) {
-				lineError = playStephanieLine(0x454b);
+				lineError = playStephanieLineWithVariant(0x454b, 2);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 			} else if (responseIndex == 2) {
-				lineError = playStephanieLine(0x454f);
+				lineError = playStephanieLineWithVariant(0x454f, 2);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 			}
diff --git a/engines/harvester/npc/stephanie_dialogue.h b/engines/harvester/npc/stephanie_dialogue.h
index be17fd476bd..69baff68d84 100644
--- a/engines/harvester/npc/stephanie_dialogue.h
+++ b/engines/harvester/npc/stephanie_dialogue.h
@@ -53,6 +53,28 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = StephanieRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.introPending);
+		syncDialogueInt(s, _state.introDayIndex);
+		syncDialogueBool(s, _state.firstFollowupPending);
+		syncDialogueInt(s, _state.firstFollowupDayIndex);
+		syncDialogueBool(s, _state.boltOfClothEscalationTriggered);
+		syncDialogueBool(s, _state.playedSpyholeBranch);
+		syncDialogueBool(s, _state.secondFollowupPending);
+		syncDialogueInt(s, _state.secondFollowupDayIndex);
+		syncDialogueBool(s, _state.scratchedTuckerShown);
+		syncDialogueBool(s, _state.boltOfClothTakenShown);
+		syncDialogueBool(s, _state.barberPoleStolenShown);
+		syncDialogueBool(s, _state.burnedTvStationShown);
+		syncDialogueBool(s, _state.dinerBurnedShown);
+		syncDialogueBool(s, _state.dinerBurnedKarinAliveFollowupShown);
+		syncDialogueBool(s, _state.dayFiveLinePlayed);
+		syncDialogueBool(s, _state.pcEscapedJailShown);
+		syncDialogueBool(s, _state.karinKidnapedLinePlayed);
+		syncDialogueBool(s, _state.karinOutcomeLinePlayed);
+		syncDialogueString(s, _state.currentTopicBuffer);
+		syncDialogueInt(s, _state.currentTopicBufferLineIndex);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/swell_dialogue.h b/engines/harvester/npc/swell_dialogue.h
index 419b8996396..f1a58bbe0e2 100644
--- a/engines/harvester/npc/swell_dialogue.h
+++ b/engines/harvester/npc/swell_dialogue.h
@@ -34,6 +34,9 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = SwellRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.talkStatePending);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/wasp_woman_dialogue.h b/engines/harvester/npc/wasp_woman_dialogue.h
index b3b9749225d..80724e7c525 100644
--- a/engines/harvester/npc/wasp_woman_dialogue.h
+++ b/engines/harvester/npc/wasp_woman_dialogue.h
@@ -35,6 +35,10 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = WaspWomanRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.introPending);
+		syncDialogueInt(s, _state.topicBufferLineIndex);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/npc/whaley_dialogue.h b/engines/harvester/npc/whaley_dialogue.h
index d0f4886db3e..8a3447bc889 100644
--- a/engines/harvester/npc/whaley_dialogue.h
+++ b/engines/harvester/npc/whaley_dialogue.h
@@ -39,6 +39,14 @@ public:
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = WhaleyRoomDialogueState(); }
+	void syncState(Common::Serializer &s) override {
+		syncDialogueBool(s, _state.introPending);
+		syncDialogueBool(s, _state.stephMidgameShown);
+		syncDialogueBool(s, _state.day5Shown);
+		syncDialogueBool(s, _state.karinKidnapedShown);
+		syncDialogueBool(s, _state.whaleyHerrillPhotoFollowupShown);
+		syncDialogueBool(s, _state.burnedTvStationShown);
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index d44df300882..372f5a7d7b6 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -548,6 +548,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		StartupRoomSetupState state;
 		if (_engine.hasPendingLoadedStartupSaveRoomState()) {
 			startupFlow.resetRoomNpcDialogueState();
+			if (_engine.hasPendingLoadedDialogueStateBlob()) {
+				if (!startupFlow.loadDialogueSaveStateBlob(_engine.getPendingLoadedDialogueStateBlob()))
+					return Common::kReadingFailed;
+				_engine.clearPendingLoadedDialogueStateBlob();
+			}
 			const StartupSaveRoomState &loadedState = _engine.getPendingLoadedStartupSaveRoomState();
 			debugC(1, kDebugGeneral,
 				"Harvester: applying pending loaded room state entrance='%s' room='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index 7ecf3c807d7..8eab4143eb3 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -23,6 +23,7 @@
 
 #include "common/serializer.h"
 #include "harvester/detection.h"
+#include "harvester/flow.h"
 #include "harvester/resources.h"
 #include "harvester/script.h"
 
@@ -38,7 +39,7 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 }
 
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
-static const uint32 kHarvesterSaveVersion = 2;
+static const uint32 kHarvesterSaveVersion = 3;
 
 static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
 	debugC(1, kDebugGeneral,
@@ -85,6 +86,8 @@ void HarvesterEngine::clearPendingLoadedStartupSaveRoomState() {
 Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 	if (!_startupScript)
 		return s.isLoading() ? Common::kReadingFailed : Common::kWritingFailed;
+	if (s.isLoading())
+		clearPendingLoadedDialogueStateBlob();
 	if (s.isSaving() && !_currentStartupSaveRoomState.valid)
 		return Common::kWritingFailed;
 	if (!s.matchBytes(kHarvesterSaveMagic, sizeof(kHarvesterSaveMagic)))
@@ -99,6 +102,26 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 		logStartupSaveRoomState("saving", roomState);
 	syncStartupSaveRoomState(s, roomState);
 	_startupScript->syncRuntimeSaveState(s);
+	if (s.getVersion() >= 3) {
+		Common::Array<byte> dialogueStateBlob;
+		uint32 dialogueStateSize = 0;
+		if (s.isSaving()) {
+			if (!_activeFlow || !_activeFlow->buildDialogueSaveStateBlob(dialogueStateBlob))
+				return Common::kWritingFailed;
+			dialogueStateSize = dialogueStateBlob.size();
+			if (dialogueStateSize == 0)
+				return Common::kWritingFailed;
+		}
+		s.syncAsUint32LE(dialogueStateSize);
+		if (s.isLoading()) {
+			if (dialogueStateSize == 0)
+				return Common::kReadingFailed;
+			dialogueStateBlob.resize(dialogueStateSize);
+		}
+		s.syncBytes(dialogueStateBlob.data(), dialogueStateSize);
+		if (s.isLoading())
+			_pendingLoadedDialogueStateBlob = dialogueStateBlob;
+	}
 	if (s.err())
 		return s.isLoading() ? Common::kReadingFailed : Common::kWritingFailed;
 


Commit: 8881d735de89c0087fc1044c4bbcc9430f27b7c0
    https://github.com/scummvm/scummvm/commit/8881d735de89c0087fc1044c4bbcc9430f27b7c0
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:53+02:00

Commit Message:
HARVESTER: Expand dialogue save state coverage

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/flow.cpp
    engines/harvester/flow.h
    engines/harvester/harvester.h
    engines/harvester/npc/dialogue_handler.h
    engines/harvester/room.cpp
    engines/harvester/saveload.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 5cc93a61626..03392f59adc 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -400,6 +400,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - The save header is now explicit: current disc id, `g_player_present_in_room`, `g_game_clock_tick_remainder_ms`, `g_current_room_def->room_name`, and `g_player_room_name`.
   - The remainder of the save blob persists the player combat-avatar state, record lists (`ANIM`, `FLAG`, `MONSTER`, `NPC`, `OBJECT`, `REGION`, `TIMER`), the talk-state blocks, ammo counters, and `g_current_music_path`.
   - The dialogue-state write sizes are now explicit from the save/load callsites: Sergeant writes `0x28` bytes from `g_sergeant_talk_state_block @ 0xd2d4c`, Stephanie writes `0x4c` bytes from `g_stephanie_talk_state_block @ 0xd2e4c`, and the shared discussion / follow-up block writes `0x9c` bytes from `0xd2e98`, ending immediately before `g_ryder_talk_state_block @ 0xd2f34`.
+  - Native save/load also serializes the wider talk-state inventory in dedicated block groups after the `TIMER` records: Boyle, Dwayne, Loomis, Mom, Hank, Dad, Mr. Potts aux/main, Stephanie, Johnson, Swell (`g_serialized_talk_state_block_d2c30`), Parsons, Mcknight, Edna, Moynahan, Phelps, Herrill, Wasp Woman, Sparky, Fireman 1/2, Nude Man, Butcher, Ryder, Buster, Dark Woman, Memb Dir, Mother, Mrs. Potts, Inquisitor, the standalone `g_serialized_talk_state_flag_d2cd8 @ 0xd2cd8`, and the shared dialogue dword range `0xd2e98..0xd2f30`.
   - The object-state portion of the slot format persists only `object_name`, `current_x`, `current_y`, `current_z`, `current_owner_or_room`, `visible`, and `ident_shown`; the other object heap strings are reloaded from world data rather than restored from the save blob.
   - `g_player_room_name` at `0xd60a8` is serialized separately from `g_current_room_def->room_name` and tracks the room string currently assigned to the live player actor.
 - `run_text_entry_dialog` at `0x59fe0` is the shared modal text-entry widget used by the save menu, password prompt, and dialogue input.
diff --git a/TRACKER.md b/TRACKER.md
index 6eba8e74f61..51aeecb8d05 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,20 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `907`
-- Named/documented: `792`
+- Total functions: `911`
+- Named/documented: `796`
 - Remaining `FUN_*`: `115`
 - Remaining undocumented total: `115`
 
 ## Last Confirmed Action
 
+- On March 22, 2026, re-audited Harvester's native save/load dialogue tail in Ghidra instead of relying on the earlier three-block summary. Confirmed `run_save_game_menu` / `run_load_game_menu` serialize the full post-`TIMER` talk-state inventory, including the standalone `g_serialized_talk_state_flag_d2cd8 @ 0xd2cd8`, the shared dialogue dword range `0xd2e98..0xd2f30`, and the dedicated NPC-local blocks for Boyle, Dwayne, Loomis, Mom, Hank, Dad, Mr. Potts aux/main, Stephanie, Johnson, Swell, Parsons, Mcknight, Edna, Moynahan, Phelps, Herrill, Wasp Woman, Sparky, Fireman 1/2, Nude Man, Butcher, Ryder, Buster, Dark Woman, Memb Dir, Mother, Mrs. Potts, and Inquisitor.
+- On March 22, 2026, patched ScummVM's Harvester save schema again so the dialogue blob carries the outer save version, allowing new dialogue-state fields to be appended without breaking version-3 blobs. Added the unresolved native shared-talk slots still present in `0xd2cd8` and `0xd2e98..0xd2f30` to `DialogueSharedState`, bumped the Harvester save version to `4`, and rebuilt `build-vscode-harvester-debug/scummvm` successfully.
+- On March 22, 2026, ran `repair AUTHORITY` through the generalized `InspectCallTermination.java` workflow and re-verified the saved `HARVEST.LE` state. The script cleared Authority's `2` stale dialogue-call fallthrough gaps in one pass, expanding `handle_talk_to_authority` from `0x5f` bytes (`0x2b730..0x2b7b2`) to a clean `0x83`-byte body with `3` reachable dialogue calls and `0` remaining gaps while keeping both `play_dialogue_line` entry points marked returning.
+- On March 22, 2026, ran `repair JIMMY` through the generalized `InspectCallTermination.java` workflow and re-verified the result in the saved `HARVEST.LE` database. The script cleared Jimmy's `13` stale dialogue-call fallthrough gaps in one pass, expanding `handle_talk_to_jimmy` from `0x327` bytes (`0x28880..0x28eb9`) to a clean `0x642`-byte body (`0x28880..0x28ec1`) with `39` reachable dialogue calls and `0` remaining gaps while keeping both `play_dialogue_line` entry points marked returning.
+- On March 22, 2026, batch-verified all `48` named `handle_talk_to_*` entries with the repaired `InspectCallTermination.java` workflow. The current active `HARVEST.LE` state now reports `play_dialogue_line` and `thunk_play_dialogue_line` as returning, `handle_talk_to_stephanie` as the only fully clean talk handler, and `47` remaining handlers still needing iterative fallthrough repair. The heaviest confirmed remaining truncation clusters are `handle_talk_to_mom` (`61` gaps), `handle_talk_to_dwayne` (`59` gaps), and `handle_talk_to_hank` (`28` gaps).
+- On March 22, 2026, repaired `~/ghidra_scripts/InspectCallTermination.java` so it now accepts argv-driven NPC commands (`list`, `verify <NPC>`, `repair <NPC>`, plus `verify ALL`) instead of being hardwired to one handler and failing to load under Ghidra's Java script provider. The real loader breakage was a bad `FlowOverride` import; after correcting that and switching the rebuild step back to GhidraScript's auto-body `createFunction(entry, name)` helper, the script now validates the immediate fallthrough byte after every `play_dialogue_line` / `thunk_play_dialogue_line` call, confirms both dialogue callees are marked returning, clears stale call overrides, disassembles newly reachable fallthrough bytes, and iteratively recreates the selected `handle_talk_to_*` body until the interaction graph is clean.
+- On March 22, 2026, verified the generalized script live against multiple NPCs in `HARVEST.LE`: `verify HANK` now reports Hank's stale `CALL_RETURN` dialogue exits generically, while `repair STEPHANIE` completed in two passes, expanding `handle_talk_to_stephanie` from `0x20e` bytes (`0x36710..0x36cd8`) to a clean `0x1816`-byte body (`0x36710..0x37f5d`) with `195` reachable dialogue calls and `0` remaining fallthrough gaps. Saved the updated `HARVEST.LE` database after the successful Stephanie repair.
 - On March 22, 2026, rechecked Harvester's native room-combat monster attack path in Ghidra after ScummVM monsters could pursue but kept logging `reason='out of range'` at contact. Confirmed from `update_actor_runtime_state` (`0x4d750`) that class-6 monsters arm melee by storing `g_player_combat_avatar` into live actor field `+0x11a4`, pick a random side-appropriate close-range attack family (`0x16..0x18` on the left, `0x19..0x1b` on the right) instead of using a target-Y band mapping, and resolve contact against that linked target after the `+0x113c` contact frame instead of re-running the coarse engage-distance spacing test. Patched [`room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) to retain the scheduled player target through the swing, resolve monster contact once against that linked target, and randomize native-style left/right attack bank selection from the ABM frame ranges. Verified by rebuilding [`room.o`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/engines/harvester/room.o) and relinking [`scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm).
 - On March 22, 2026, audited native save/load persistence in Ghidra before changing the engine-side dialogue save schema. `run_save_game_menu` / `run_load_game_menu` restore dedicated talk-state blocks in addition to the world/script records, including Sergeant's `0x28`-byte local block at `g_sergeant_talk_state_block @ 0xd2d4c`, Stephanie's `0x4c`-byte block at `g_stephanie_talk_state_block @ 0xd2e4c`, and the shared discussion / follow-up block at `0xd2e98` with size `0x9c`.
 - On March 22, 2026, patched ScummVM's Harvester save/load path to preserve and reload those dialogue branches. `HarvesterEngine` now carries a pending dialogue-state blob for launcher/runtime loads, `Flow` serializes and restores the active `DialogueSystem`, `DialogueSystem` syncs shared plus per-handler local talk state, and room-load handoff reapplies the blob immediately after the normal dialogue reset. Bumped the Harvester save version to `3` and rebuilt `build-vscode-harvester-debug/scummvm` successfully.
@@ -30,5 +37,5 @@
 
 ## Next Suggested Action
 
-- Immediate: run an in-engine Harvester combat pass in `PCHOUSE` against `JIMMY_M` and one additional melee monster to confirm the new room-combat path now produces native-style attack starts plus contact hits while the player is still the linked target, and that the random left/right attack-family picker no longer depends on target Y bands.
-- Follow-up: if those hits land correctly, compare native victim reaction behavior next. The remaining confirmed gap in ScummVM's room-combat path is the shared native hit-resolution side effects after contact (`+0x1188`, reciprocal `+0x11b8`, knockback, and reaction/death-state gating), which are still only partially modeled.
+- Immediate: run an in-engine Harvester save/load pass on at least one shared-dialogue branch and one handler-local branch, preferably an evidence discussion under Stephanie or Mr. Potts plus a Ryder or Dark Woman revisit, to confirm the version-4 blob now rebuilds both shared and local talk state after room reset.
+- Follow-up: after that runtime check, continue the native dialogue recovery queue with one of the highest-gap handlers from the batch audit, preferably `repair MOM` or `repair DWAYNE`, and compare any newly recovered talk-state globals against the engine's persisted dialogue schema before using them.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index f08d6e671dc..9f7366db992 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -152,6 +152,20 @@ static void syncDialogueSharedState(Common::Serializer &s, DialogueSharedState &
 	syncDialogueBool(s, state.discussedMrPottsTuesdayNightAlibi);
 	syncDialogueBool(s, state.discussedMrsPottsTuesdayNightAlibi);
 	syncDialogueBool(s, state.confrontedMrPottsAboutSpyhole);
+	syncDialogueInt(s, state.serializedTalkStateFlagD2cd8, 4);
+	syncDialogueInt(s, state.dwaynePendingKarinAliveFollowupState, 4);
+	syncDialogueInt(s, state.dialogueStateD2eac, 4);
+	syncDialogueInt(s, state.dialogueStateD2eb4, 4);
+	syncDialogueInt(s, state.discussedNoteCheckbookEvidence, 4);
+	syncDialogueInt(s, state.discussedTvDeedEvidence, 4);
+	syncDialogueInt(s, state.discussedLedgerEvidence, 4);
+	syncDialogueInt(s, state.discussedCasketPhotoEvidence, 4);
+	syncDialogueInt(s, state.discussedWhaleyHerrillPhoto, 4);
+	syncDialogueInt(s, state.discussedKarinPurse, 4);
+	syncDialogueInt(s, state.momFatherTopicState, 4);
+	syncDialogueInt(s, state.dadMeatPermissionState, 4);
+	syncDialogueInt(s, state.sergeantCompletedFirstTaskState, 4);
+	syncDialogueInt(s, state.dialogueStateD2f30, 4);
 }
 
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index b8578919d85..4a2124bb08c 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -1123,9 +1123,10 @@ bool Flow::load() {
 	return loadQuickTips() && loadMenuItems();
 }
 
-bool Flow::buildDialogueSaveStateBlob(Common::Array<byte> &blob) {
+bool Flow::buildDialogueSaveStateBlob(Common::Array<byte> &blob, uint32 saveVersion) {
 	Common::MemoryWriteStreamDynamic stream(DisposeAfterUse::NO);
 	Common::Serializer serializer(nullptr, &stream);
+	serializer.setVersion(saveVersion);
 	_dialogue.syncRuntimeSaveState(serializer);
 	if (serializer.err())
 		return false;
@@ -1136,12 +1137,13 @@ bool Flow::buildDialogueSaveStateBlob(Common::Array<byte> &blob) {
 	return true;
 }
 
-bool Flow::loadDialogueSaveStateBlob(const Common::Array<byte> &blob) {
+bool Flow::loadDialogueSaveStateBlob(const Common::Array<byte> &blob, uint32 saveVersion) {
 	if (blob.empty())
 		return false;
 
 	Common::MemoryReadStream stream(blob.data(), blob.size());
 	Common::Serializer serializer(&stream, nullptr);
+	serializer.setVersion(saveVersion);
 	_dialogue.syncRuntimeSaveState(serializer);
 	return !serializer.err();
 }
diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index 7cc5ccfba16..19a29da6ebf 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -46,8 +46,8 @@ public:
 	explicit Flow(HarvesterEngine &engine);
 
 	bool load();
-	bool buildDialogueSaveStateBlob(Common::Array<byte> &blob);
-	bool loadDialogueSaveStateBlob(const Common::Array<byte> &blob);
+	bool buildDialogueSaveStateBlob(Common::Array<byte> &blob, uint32 saveVersion);
+	bool loadDialogueSaveStateBlob(const Common::Array<byte> &blob, uint32 saveVersion);
 	Common::Error run();
 
 private:
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 2932f17f220..a227f1d3ec6 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -124,7 +124,11 @@ public:
 	void clearPendingLoadedStartupSaveRoomState();
 	bool hasPendingLoadedDialogueStateBlob() const { return !_pendingLoadedDialogueStateBlob.empty(); }
 	const Common::Array<byte> &getPendingLoadedDialogueStateBlob() const { return _pendingLoadedDialogueStateBlob; }
-	void clearPendingLoadedDialogueStateBlob() { _pendingLoadedDialogueStateBlob.clear(); }
+	uint32 getPendingLoadedDialogueStateBlobVersion() const { return _pendingLoadedDialogueStateBlobVersion; }
+	void clearPendingLoadedDialogueStateBlob() {
+		_pendingLoadedDialogueStateBlob.clear();
+		_pendingLoadedDialogueStateBlobVersion = 0;
+	}
 
 private:
 	void applyStartupMixerLevels();
@@ -137,6 +141,7 @@ private:
 	StartupSaveRoomState _currentStartupSaveRoomState;
 	StartupSaveRoomState _pendingLoadedStartupSaveRoomState;
 	Common::Array<byte> _pendingLoadedDialogueStateBlob;
+	uint32 _pendingLoadedDialogueStateBlobVersion = 0;
 	Flow *_activeFlow = nullptr;
 	bool _roomDebugEnabled = false;
 };
diff --git a/engines/harvester/npc/dialogue_handler.h b/engines/harvester/npc/dialogue_handler.h
index dd2bae3506d..45e3f9f060f 100644
--- a/engines/harvester/npc/dialogue_handler.h
+++ b/engines/harvester/npc/dialogue_handler.h
@@ -28,19 +28,27 @@
 
 namespace Harvester {
 
-inline void syncDialogueBool(Common::Serializer &s, bool &value) {
+inline void syncDialogueBool(Common::Serializer &s, bool &value,
+		Common::Serializer::Version minVersion = 0,
+		Common::Serializer::Version maxVersion = Common::Serializer::kLastVersion) {
+	if (s.getVersion() < minVersion || s.getVersion() > maxVersion)
+		return;
 	byte serialized = value ? 1 : 0;
 	s.syncAsByte(serialized);
 	if (s.isLoading())
 		value = serialized != 0;
 }
 
-inline void syncDialogueInt(Common::Serializer &s, int &value) {
-	s.syncAsSint32LE(value);
+inline void syncDialogueInt(Common::Serializer &s, int &value,
+		Common::Serializer::Version minVersion = 0,
+		Common::Serializer::Version maxVersion = Common::Serializer::kLastVersion) {
+	s.syncAsSint32LE(value, minVersion, maxVersion);
 }
 
-inline void syncDialogueString(Common::Serializer &s, Common::String &value) {
-	s.syncString(value);
+inline void syncDialogueString(Common::Serializer &s, Common::String &value,
+		Common::Serializer::Version minVersion = 0,
+		Common::Serializer::Version maxVersion = Common::Serializer::kLastVersion) {
+	s.syncString(value, minVersion, maxVersion);
 }
 
 class DialogueRuntime;
@@ -72,6 +80,20 @@ struct DialogueSharedState {
 	bool discussedMrPottsTuesdayNightAlibi = false;
 	bool discussedMrsPottsTuesdayNightAlibi = false;
 	bool confrontedMrPottsAboutSpyhole = false;
+	int serializedTalkStateFlagD2cd8 = 1;
+	int dwaynePendingKarinAliveFollowupState = 0;
+	int dialogueStateD2eac = 0;
+	int dialogueStateD2eb4 = 0;
+	int discussedNoteCheckbookEvidence = 0;
+	int discussedTvDeedEvidence = 0;
+	int discussedLedgerEvidence = 0;
+	int discussedCasketPhotoEvidence = 0;
+	int discussedWhaleyHerrillPhoto = 0;
+	int discussedKarinPurse = 0;
+	int momFatherTopicState = 0;
+	int dadMeatPermissionState = 0;
+	int sergeantCompletedFirstTaskState = 0;
+	int dialogueStateD2f30 = 0;
 };
 
 class NpcDialogueHandler {
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 372f5a7d7b6..68477082b24 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -549,7 +549,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		if (_engine.hasPendingLoadedStartupSaveRoomState()) {
 			startupFlow.resetRoomNpcDialogueState();
 			if (_engine.hasPendingLoadedDialogueStateBlob()) {
-				if (!startupFlow.loadDialogueSaveStateBlob(_engine.getPendingLoadedDialogueStateBlob()))
+				if (!startupFlow.loadDialogueSaveStateBlob(
+						_engine.getPendingLoadedDialogueStateBlob(),
+						_engine.getPendingLoadedDialogueStateBlobVersion()))
 					return Common::kReadingFailed;
 				_engine.clearPendingLoadedDialogueStateBlob();
 			}
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index 8eab4143eb3..32bc0e36fcb 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -39,7 +39,7 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 }
 
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
-static const uint32 kHarvesterSaveVersion = 3;
+static const uint32 kHarvesterSaveVersion = 4;
 
 static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
 	debugC(1, kDebugGeneral,
@@ -102,28 +102,30 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 		logStartupSaveRoomState("saving", roomState);
 	syncStartupSaveRoomState(s, roomState);
 	_startupScript->syncRuntimeSaveState(s);
-	if (s.getVersion() >= 3) {
-		Common::Array<byte> dialogueStateBlob;
-		uint32 dialogueStateSize = 0;
-		if (s.isSaving()) {
-			if (!_activeFlow || !_activeFlow->buildDialogueSaveStateBlob(dialogueStateBlob))
-				return Common::kWritingFailed;
-			dialogueStateSize = dialogueStateBlob.size();
-			if (dialogueStateSize == 0)
-				return Common::kWritingFailed;
+		if (s.getVersion() >= 3) {
+			Common::Array<byte> dialogueStateBlob;
+			uint32 dialogueStateSize = 0;
+			if (s.isSaving()) {
+				if (!_activeFlow || !_activeFlow->buildDialogueSaveStateBlob(dialogueStateBlob, s.getVersion()))
+					return Common::kWritingFailed;
+				dialogueStateSize = dialogueStateBlob.size();
+				if (dialogueStateSize == 0)
+					return Common::kWritingFailed;
 		}
 		s.syncAsUint32LE(dialogueStateSize);
 		if (s.isLoading()) {
 			if (dialogueStateSize == 0)
 				return Common::kReadingFailed;
 			dialogueStateBlob.resize(dialogueStateSize);
+			}
+			s.syncBytes(dialogueStateBlob.data(), dialogueStateSize);
+			if (s.isLoading()) {
+				_pendingLoadedDialogueStateBlob = dialogueStateBlob;
+				_pendingLoadedDialogueStateBlobVersion = s.getVersion();
+			}
 		}
-		s.syncBytes(dialogueStateBlob.data(), dialogueStateSize);
-		if (s.isLoading())
-			_pendingLoadedDialogueStateBlob = dialogueStateBlob;
-	}
-	if (s.err())
-		return s.isLoading() ? Common::kReadingFailed : Common::kWritingFailed;
+		if (s.err())
+			return s.isLoading() ? Common::kReadingFailed : Common::kWritingFailed;
 
 	if (s.isLoading()) {
 		if (!roomState.valid || roomState.roomName.empty())


Commit: 26a1faf0583b15292c4d872eb52212a50da20a01
    https://github.com/scummvm/scummvm/commit/26a1faf0583b15292c4d872eb52212a50da20a01
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:53+02:00

Commit Message:
HARVESTER: Fix monster combat frame-range checks

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp
    engines/harvester/runtime_entity.h


diff --git a/TRACKER.md b/TRACKER.md
index 51aeecb8d05..954d95453df 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Last Confirmed Action
 
+- On March 22, 2026, rechecked Harvester's native room-combat melee gating in Ghidra after ScummVM still logged `reason='no native attack bank'` for Johnson's `MRJ_M`. Confirmed from `update_actor_runtime_state` (`0x4d750`) that the native class-6 attack picker is state-driven and side-based, then verified from the shipped XFILE archives that `GRAPHIC/MONSTERS/MRJ.ABM` actually contains `296` frames, so ScummVM's failure was local rather than asset-driven. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) and [`engines/harvester/runtime_entity.h`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h) so monster attack/death bank checks validate against the full ABM frame count instead of the entity's currently active animation subrange. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully.
 - On March 22, 2026, re-audited Harvester's native save/load dialogue tail in Ghidra instead of relying on the earlier three-block summary. Confirmed `run_save_game_menu` / `run_load_game_menu` serialize the full post-`TIMER` talk-state inventory, including the standalone `g_serialized_talk_state_flag_d2cd8 @ 0xd2cd8`, the shared dialogue dword range `0xd2e98..0xd2f30`, and the dedicated NPC-local blocks for Boyle, Dwayne, Loomis, Mom, Hank, Dad, Mr. Potts aux/main, Stephanie, Johnson, Swell, Parsons, Mcknight, Edna, Moynahan, Phelps, Herrill, Wasp Woman, Sparky, Fireman 1/2, Nude Man, Butcher, Ryder, Buster, Dark Woman, Memb Dir, Mother, Mrs. Potts, and Inquisitor.
 - On March 22, 2026, patched ScummVM's Harvester save schema again so the dialogue blob carries the outer save version, allowing new dialogue-state fields to be appended without breaking version-3 blobs. Added the unresolved native shared-talk slots still present in `0xd2cd8` and `0xd2e98..0xd2f30` to `DialogueSharedState`, bumped the Harvester save version to `4`, and rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 - On March 22, 2026, ran `repair AUTHORITY` through the generalized `InspectCallTermination.java` workflow and re-verified the saved `HARVEST.LE` state. The script cleared Authority's `2` stale dialogue-call fallthrough gaps in one pass, expanding `handle_talk_to_authority` from `0x5f` bytes (`0x2b730..0x2b7b2`) to a clean `0x83`-byte body with `3` reachable dialogue calls and `0` remaining gaps while keeping both `play_dialogue_line` entry points marked returning.
@@ -37,5 +38,5 @@
 
 ## Next Suggested Action
 
-- Immediate: run an in-engine Harvester save/load pass on at least one shared-dialogue branch and one handler-local branch, preferably an evidence discussion under Stephanie or Mr. Potts plus a Ryder or Dark Woman revisit, to confirm the version-4 blob now rebuilds both shared and local talk state after room reset.
-- Follow-up: after that runtime check, continue the native dialogue recovery queue with one of the highest-gap handlers from the batch audit, preferably `repair MOM` or `repair DWAYNE`, and compare any newly recovered talk-state globals against the engine's persisted dialogue schema before using them.
+- Immediate: run an in-engine Harvester combat pass in `MR_JOHNS` to confirm Johnson's monster now enters melee instead of looping `reason='no native attack bank'`, and verify at least one lethal monster hit still plays a death bank now that full-frame-range checks are used.
+- Follow-up: if melee now triggers but the chase still feels too aggressive, capture one short native-vs-ScummVM comparison clip and re-audit the class-6 locomotion step sizing around `update_actor_runtime_state` before changing movement cadence; the current Ghidra evidence does not justify slowing monsters yet.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 68477082b24..ec91213ae5b 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -335,7 +335,10 @@ static bool setRoomMonsterAnimation(RuntimeEntity &entity, int facing, bool walk
 }
 
 static bool runtimeEntityHasFrameRange(const RuntimeEntity &entity, int firstFrame, int lastFrame) {
-	return entity.hasFrames() && firstFrame >= 0 && entity.getLastFrame() >= lastFrame;
+	if (!entity.hasFrames() || firstFrame < 0 || lastFrame < firstFrame)
+		return false;
+
+	return (uint)lastFrame < entity.getFrameCount();
 }
 
 static bool resolveMonsterAttackAnimationRange(HarvesterEngine &engine,
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index ad3e3d09621..542e4516db5 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -93,6 +93,7 @@ public:
 	void setCurrentFrame(int frame);
 	int getCurrentFrame() const { return _currentFrame; }
 	int getLastFrame() const { return _lastFrame; }
+	uint getFrameCount() const { return _frames.size(); }
 	void setAnimationFrameRange(int firstFrame, int lastFrame, bool looping);
 	void setAnimationSequence(int sequence);
 	int getAnimationSequence() const { return _animationSequence; }


Commit: 0c33314042a7b4927861ffe55539934cc0e4c520
    https://github.com/scummvm/scummvm/commit/0c33314042a7b4927861ffe55539934cc0e4c520
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:54+02:00

Commit Message:
HARVESTER: Align Stephanie dialogue with native flow

Changed paths:
    TRACKER.md
    engines/harvester/npc/stephanie_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 954d95453df..199625915e4 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,20 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `911`
-- Named/documented: `796`
-- Remaining `FUN_*`: `115`
-- Remaining undocumented total: `115`
+- Total functions: `918`
+- Named/documented: `798`
+- Remaining `FUN_*`: `120`
+- Remaining undocumented total: `120`
 
 ## Last Confirmed Action
 
+- On March 22, 2026, reimplemented the remaining Stephanie dialogue mismatches in ScummVM directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_stephanie` (`0x36710..0x37f5d`). A forced redecompile plus spot disassembly confirmed the still-wrong native details in the current port: intro variants `0x43eb -> STEPHANIE[2]` and `0x43f5 -> STEPHANIE[3]`, item/evidence variants `0x4684 -> STEPHANIE[2]` and `0x469e -> STEPHANIE[2]`, first-followup tail `0x45d8 -> STEPHANIE[2]` / `0x45e2 -> STEPHANIE[3]`, second-followup variants on `0x460e`, `0x4612`, `0x4616`, `0x461b`, `0x4649`, and `0x464e`, the nested `SCRATCHED_TUCKER` reply `0x4709 -> PC[2]`, and the Mrs. Potts alibi tail `0x4989 -> STEPHANIE[0]` rather than PC. The refreshed late-tail listing around `0x377a3..0x37820` also resolved the Karin-alive control flow: after `0x49ba`, Stephanie only reaches `0x49c1` plus the shared `0x49ea/0x49ef` return when the Dwayne Karin-alive follow-up gate is set; otherwise the conversation falls back into the keyword menu immediately. Patched [`engines/harvester/npc/stephanie_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/stephanie_dialogue.cpp) to match those native branches and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully.
+- On March 22, 2026, reran `repair STEPHANIE` with the updated `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Stephanie showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_stephanie` cleanly to `0x36710..0x37f5d` (`6166` address-count, `195` dialogue calls) but the fresh verify fell back to the short `0x36710..0x36cd8` body with `9` stale dialogue gaps and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Stephanie durably clean again at `0x36710..0x37f5d` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Stephanie belongs in the same post-save retry class as Johnson, Butcher, Dad, Loomis, and similar handlers.
+- On March 22, 2026, closed out the stubborn `HERRILL` case. The saved `HARVEST.LE` database already held the widened `handle_talk_to_herrill` body (`0x2dc00..0x2e987`, `1755` address-count) from the earlier repair/save pass, but `InspectCallTermination.java` kept falsely flagging `0x2dda1` as a missing-fallthrough dialogue call. Direct inspection showed the live listing at `0x2dda1` is actually `JMP 0x2e97a` with stale `CALL_RETURN` metadata, so the real remaining defect was verifier-side rather than a truncated handler body. Patched `~/ghidra_scripts/InspectCallTermination.java` so tail `JMP` transfers to `play_dialogue_line` / `thunk_play_dialogue_line` are counted as terminal dialogue transfers instead of bogus repair gaps; a fresh `verify HERRILL` now reports `status=clean` with `dialogueCalls=39` and `gapCount=0`. This raises the current durable/confirmed sweep count to `40/48`.
+- On March 22, 2026, resumed the one-at-a-time `InspectCallTermination.java` NPC sweep against the live `HARVEST.LE` session using the durable workflow `repair <NPC> -> wait -> save_program -> fresh verify <NPC>`. Including the already-confirmed earlier `STEPHANIE` repair in the same saved database, this pass has now confirmed the following talk handlers as durably clean: `WHALEY`, `MOYNAHAN`, `MR_POTTS`, `JOHNSON`, `JIMMY`, `LIBRARIAN`, `SWELL`, `PRIEST`, `PHELPS`, `BUTCHER`, `MADAM`, `INQUISITOR`, `DAD`, `MOTHER`, `MAINT_MAN`, `AUTHORITY`, `BUSTER`, `GLADIATOR`, `MCKNIGHT`, `BOYLE`, `CHESSMASTER`, `NUDE_MAN`, `CLOAK_ATND`, `KARIN`, `VALET`, `MRS_POTTS`, `VET`, `WASP_WOMAN`, `SERGEANT`, `MOM`, `DARK_WOMAN`, `FIREMAN1`, `FIREMAN2`, `HANK`, `CURATOR`, `BEGGAR`, `PTA_MOM`, `LOOMIS`, and `STEPHANIE` (`39/48`). The sweep also exposed three real outlier patterns beyond the simple retry-after-save snapback: missing immediate fallthrough instructions that require clearing/disassembling the exact continuation address before rerunning repair (seen in Boyle and Wasp Woman), tiny stray tail functions that must be deleted so the main talk handler can absorb the last continuation block (seen in Mom), and a save-time reappearing `CALL_RETURN` override that still leaves `handle_talk_to_herrill` one call short even after direct clearing at `0x2dda1`. The current active retry is `EDNA`, which reverted to its short body after the first save/verify cycle and still needs the standard second repair pass.
+- On March 22, 2026, reran `repair STEPHANIE` through the live `HARVEST.LE` Ghidra session with `~/ghidra_scripts/InspectCallTermination.java`, then immediately called `save_program` and re-ran `verify STEPHANIE` in a fresh script invocation. The repair converged in two passes, expanding `handle_talk_to_stephanie` from `0x20e` bytes (`0x36710..0x36cd8`) with `9` fallthrough gaps to a clean `0x1816`-byte body (`0x36710..0x37f5d`) with `195` reachable dialogue calls, `0` remaining gaps, and `27` repair actions. The explicit save succeeded and the fresh verify still reported `status=clean`, so Stephanie's repaired handler is now confirmed durable in the saved `HARVEST.LE` database.
+- On March 22, 2026, attempted to run `repair STEPHANIE` again with `~/ghidra_scripts/InspectCallTermination.java`, but the live Ghidra plugin server was still wedged: the bridge layer was gone after cleanup, direct HTTP probes to `http://127.0.0.1:8089/check_connection` timed out even though the Java process still owned the port, and the fallback headless check showed the only on-disk project at `~/.ghidra-projects/harvest_prelim` contains `HARVEST.EXE` rather than the active `HARVEST.LE` program database. No Stephanie repair was applied in this attempt because there is currently no responsive path into the `HARVEST.LE` state without restarting the live Ghidra/plugin session.
+- On March 22, 2026, reworked `~/ghidra_scripts/InspectCallTermination.java` again to debug why per-NPC `repair` runs looked successful but reverted after later saves. Confirmed the original nested/manual transaction model only preserved repairs transiently inside the active script invocation. Switched the script to end the runner-owned transaction up front, use an explicit per-handler program transaction inside `repairHandler`, and keep the `repair <NPC>` / `verify <NPC>` argv workflow intact. With that model, `repair JIMMY` followed by a fresh `verify JIMMY` now survives across separate script invocations in the active `HARVEST.LE` session. The remaining blocker is durable save of dirty handlers: once a handler actually changes, both in-script `saveProgram()` and MCP `save_program` still hit Ghidra-side locking/analysis problems (`Unable to lock due to active transaction`, then `AutoAnalysisManager.scheduleWorker may not be invoked from Swing thread`), and the final dirty-save experiment left the MCP/Ghidra session unresponsive. Do not trust `repairrange` for persistence; the only confirmed good path so far is single-NPC repair/verify while the session stays alive.
 - On March 22, 2026, rechecked Harvester's native room-combat melee gating in Ghidra after ScummVM still logged `reason='no native attack bank'` for Johnson's `MRJ_M`. Confirmed from `update_actor_runtime_state` (`0x4d750`) that the native class-6 attack picker is state-driven and side-based, then verified from the shipped XFILE archives that `GRAPHIC/MONSTERS/MRJ.ABM` actually contains `296` frames, so ScummVM's failure was local rather than asset-driven. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) and [`engines/harvester/runtime_entity.h`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h) so monster attack/death bank checks validate against the full ABM frame count instead of the entity's currently active animation subrange. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully.
 - On March 22, 2026, re-audited Harvester's native save/load dialogue tail in Ghidra instead of relying on the earlier three-block summary. Confirmed `run_save_game_menu` / `run_load_game_menu` serialize the full post-`TIMER` talk-state inventory, including the standalone `g_serialized_talk_state_flag_d2cd8 @ 0xd2cd8`, the shared dialogue dword range `0xd2e98..0xd2f30`, and the dedicated NPC-local blocks for Boyle, Dwayne, Loomis, Mom, Hank, Dad, Mr. Potts aux/main, Stephanie, Johnson, Swell, Parsons, Mcknight, Edna, Moynahan, Phelps, Herrill, Wasp Woman, Sparky, Fireman 1/2, Nude Man, Butcher, Ryder, Buster, Dark Woman, Memb Dir, Mother, Mrs. Potts, and Inquisitor.
 - On March 22, 2026, patched ScummVM's Harvester save schema again so the dialogue blob carries the outer save version, allowing new dialogue-state fields to be appended without breaking version-3 blobs. Added the unresolved native shared-talk slots still present in `0xd2cd8` and `0xd2e98..0xd2f30` to `DialogueSharedState`, bumped the Harvester save version to `4`, and rebuilt `build-vscode-harvester-debug/scummvm` successfully.
@@ -38,5 +45,13 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Stephanie validation pass that hits the corrected native-only branches: first intro/revisit, the `0x45d8/0x45e2` first-followup tail, `SCRATCHED_TUCKER` response `1 -> 1` to confirm `0x4709` keeps the PC portrait on variant `2`, the Mrs. Potts alibi tail around `0x4984/0x4989/0x498f`, and both Karin-alive outcomes with and without the Dwayne follow-up gate to verify that only the gated path plays `0x49c1` plus `0x49ea/0x49ef`.
+- Immediate: resume the sequential talk-handler sweep at `EDNA`, using the now-proven workflow `repair -> wait ~10s -> save_program -> verify`; when a handler snaps back after the first save, immediately rerun that same NPC once more before moving on. The remaining not-yet-confirmed handlers after the current checkpoint are `EDNA`, `PASTORELLI`, `LODGE_CHEF`, `RYDER`, `SPARKY`, `MEMB_DIR`, `PARSONS`, and `DWAYNE`.
+- Follow-up: after the sweep finishes, fold the reusable gap-fix logic back into `InspectCallTermination.java` itself so it can automatically clear/disassemble exact fallthrough ranges and optionally absorb tiny tail functions instead of requiring ad hoc helper-script intervention for cases like Boyle, Wasp Woman, and Mom.
+- Follow-up: keep an eye out for any additional tail `JMP` dialogue transfers when finishing the remaining NPCs. `HERRILL` proved that some apparent one-gap leftovers are verifier false positives caused by stale `CALL_RETURN` metadata on jump-based dialogue tails rather than missing handler continuations.
+- Immediate: continue the NPC talk-handler sweep one handler at a time with top-level `verify <NPC>` followed by `repair <NPC>` only when needed, and after each successful repair call `save_program` plus a fresh `verify <NPC>` to confirm the saved `HARVEST.LE` state actually held. Keep using single-handler passes; do not resume `repairrange`.
+- Immediate: restart the live Ghidra GUI/plugin session that owns `HARVEST.LE`, then verify the plugin server is responding on `127.0.0.1:8089` before retrying `repair STEPHANIE`. The current server process is listening but not answering, so no further MCP/script work against `HARVEST.LE` is possible until that restart happens.
+- Immediate: restart the Ghidra GUI / MCP bridge cleanly before attempting any more NPC repairs, because the last dirty-handler save experiment left `run_ghidra_script`, `save_program`, and even `check_connection` timing out. After restart, reopen `HARVEST.LE`, rerun `verify JIMMY` first to confirm whether the saved single-handler repair survived restart, then continue with top-level `repair <NPC>` one handler at a time from `BUSTER` onward.
+- Follow-up: once the fresh session is up, finish isolating the dirty-save blocker inside `InspectCallTermination.java`. The most likely next experiments are: wait/poll for auto-analysis completion without calling `analyzeChanges()` from the Swing thread, or temporarily disable/suppress auto-analysis around handler recreation so `saveProgram()` can complete after a dirty repair. Do not resume `repairrange` until a dirty single-handler save has been proven durable on disk.
 - Immediate: run an in-engine Harvester combat pass in `MR_JOHNS` to confirm Johnson's monster now enters melee instead of looping `reason='no native attack bank'`, and verify at least one lethal monster hit still plays a death bank now that full-frame-range checks are used.
 - Follow-up: if melee now triggers but the chase still feels too aggressive, capture one short native-vs-ScummVM comparison clip and re-audit the class-6 locomotion step sizing around `update_actor_runtime_state` before changing movement cadence; the current Ghidra evidence does not justify slowing monsters yet.
diff --git a/engines/harvester/npc/stephanie_dialogue.cpp b/engines/harvester/npc/stephanie_dialogue.cpp
index a6ed7e66bb7..a1fba7a1289 100644
--- a/engines/harvester/npc/stephanie_dialogue.cpp
+++ b/engines/harvester/npc/stephanie_dialogue.cpp
@@ -55,6 +55,9 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		runtime.assignTopicBuffer(stephanieTopicBuffer, stephanieTopicBufferLineIndex,
 			responseLineIndex, "Stephanie topic buffer");
 	};
+	auto playPcLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "PC", headVariant);
+	};
 	auto playStephanieLine = [&](int wavId, const char *speakerId = "STEPHANIE") -> Common::Error {
 		return runtime.playDialogueLine(wavId, speakerId);
 	};
@@ -105,7 +108,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 			}
-			lineError = playStephanieLine(0x4684);
+			lineError = playStephanieLineWithVariant(0x4684, 2);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			return runtime.playDialogueLine(0x4689, "PC");
@@ -118,7 +121,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				{ 0x468f, "PC", 0 },
 				{ 0x4694, "STEPHANIE", 0 },
 				{ 0x469a, "PC", 0 },
-				{ 0x469e, "STEPHANIE", 0 }
+				{ 0x469e, "STEPHANIE", 2 }
 			};
 			return runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
 		}
@@ -134,9 +137,9 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			{ 0x43df, "PC", 0 },
 			{ 0x43e3, "STEPHANIE", 0 },
 			{ 0x43e7, "PC", 0 },
-			{ 0x43eb, "STEPHANIE", 0 },
+			{ 0x43eb, "STEPHANIE", 2 },
 			{ 0x43f0, "PC", 0 },
-			{ 0x43f5, "STEPHANIE", 0 },
+			{ 0x43f5, "STEPHANIE", 3 },
 			{ 0x43f9, "PC", 0 },
 			{ 0x43fd, "STEPHANIE", 0 }
 		};
@@ -167,7 +170,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			"PC");
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		lineError = playStephanieLine(0x45be);
+		lineError = playStephanieLineWithVariant(0x45be, 3);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
@@ -177,9 +180,9 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return responseError;
 
 		const DialogueLineEntry followupLines[] = {
-			{ 0x45d8, "PC", 0 },
+			{ 0x45d8, "STEPHANIE", 2 },
 			{ 0x45de, "PC", 0 },
-			{ 0x45e2, "STEPHANIE", 0 }
+			{ 0x45e2, "STEPHANIE", 3 }
 		};
 		Common::Error lineSeqError = runtime.playDialogueEntrySequence(
 			followupLines, ARRAYSIZE(followupLines));
@@ -218,18 +221,18 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 		if (responseIndex == 1) {
-			lineError = playStephanieLine(0x460e);
+			lineError = playStephanieLineWithVariant(0x460e, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		} else if (responseIndex == 2) {
-			lineError = playStephanieLine(0x4612);
+			lineError = playStephanieLineWithVariant(0x4612, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 
 		const DialogueLineEntry nastyFollowupLines[] = {
-			{ 0x4616, "STEPHANIE", 0 },
-			{ 0x461b, "PC", 0 },
+			{ 0x4616, "STEPHANIE", 3 },
+			{ 0x461b, "PC", 3 },
 			{ 0x461f, "STEPHANIE", 0 },
 			{ 0x4625, "PC", 0 },
 			{ 0x4629, "STEPHANIE", 0 }
@@ -253,10 +256,10 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return Common::kNoError;
 		}
 		if (responseIndex == 2) {
-			lineError = runtime.playDialogueLine(0x4649, "PC");
+			lineError = playPcLine(0x4649, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			lineError = playStephanieLine(0x464e);
+			lineError = playStephanieLineWithVariant(0x464e, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
@@ -359,7 +362,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (followupResponseIndex == 1) {
 				const DialogueLineEntry followupLines[] = {
 					{ 0x4705, "STEPHANIE", 2 },
-					{ 0x4709, "PC", 0 }
+					{ 0x4709, "PC", 2 }
 				};
 				return runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
 			} else if (followupResponseIndex == 2) {
@@ -609,7 +612,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (sharedState.discussedMrsPottsTuesdayNightAlibi) {
 			const DialogueLineEntry lines[] = {
 				{ 0x4984, "PC", 0 },
-				{ 0x4989, "PC", 0 },
+				{ 0x4989, "STEPHANIE", 0 },
 				{ 0x498f, "STEPHANIE", 0 }
 			};
 			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
@@ -640,6 +643,11 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				lineError = playStephanieLine(0x49c1);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
+				const DialogueLineEntry outcomeTailLines[] = {
+					{ 0x49ea, "STEPHANIE", 2 },
+					{ 0x49ef, "PC", 2 }
+				};
+				return runtime.playDialogueEntrySequence(outcomeTailLines, ARRAYSIZE(outcomeTailLines));
 			}
 		} else {
 			const DialogueLineEntry deadLines[] = {
@@ -650,12 +658,12 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			lineError = runtime.playDialogueEntrySequence(deadLines, ARRAYSIZE(deadLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			const DialogueLineEntry outcomeTailLines[] = {
+				{ 0x49ea, "STEPHANIE", 2 },
+				{ 0x49ef, "PC", 2 }
+			};
+			return runtime.playDialogueEntrySequence(outcomeTailLines, ARRAYSIZE(outcomeTailLines));
 		}
-		const DialogueLineEntry outcomeTailLines[] = {
-			{ 0x49ea, "STEPHANIE", 2 },
-			{ 0x49ef, "PC", 2 }
-		};
-		return runtime.playDialogueEntrySequence(outcomeTailLines, ARRAYSIZE(outcomeTailLines));
 	}
 
 	if (stephanieTopicBufferLineIndex < 0)


Commit: 31dd3b1f14a6750fa3d4fd2e7d8f65b3805742fd
    https://github.com/scummvm/scummvm/commit/31dd3b1f14a6750fa3d4fd2e7d8f65b3805742fd
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:54+02:00

Commit Message:
HARVESTER: Restore missing sergeant dialogue tails

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/sergeant_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 03392f59adc..7077239f3d4 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -631,8 +631,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - The native item/event branches are wider than the earlier engine stubs:
     - `REMAINS` is the late story payoff branch, not a single line. After `0x4333`, Sergeant plays `C150.FST`, continues through `0x433f` / `0x4344` / `0x4348` / `0x434f` / `0x4353`, opens response menu `0x2ad`, then continues through `0x4368` / `0x4372` / `0x4376` / `0x4381` / `0x4385` before the palette-clear / `C076.FST` tail.
     - `INVITE` now cleanly includes `0x4305`, `0x4309`, response menu `0x2ae`, optional `0x4314` or `0x4319`, and the common `0x431d` exit while also toggling the Mrs. Potts / Dwayne room-state handoff.
-    - `DINER_BURNED` is `0x42b5`, `0x42b9`, response menu `0x2af`, optional `0x42c5` or `0x42cb`, then the common `0x42d2` tail.
-    - `BARBER_POLE` is `0x426f`, `0x4274`, response menu `0x2b1`, optional `0x4281` or `0x4289`, then the common `0x428d` tail while also setting `ASSIGNED_DNA_TASK`.
+    - `DINER_BURNED` is a two-menu continuation rather than a terminal bark. Sergeant plays `0x42b5`, `0x42b9`, response menu `0x2af`, optional `0x42c5` or `0x42cb`, then `0x42d2`, response menu `0x2b0`, optional `0x42de` or `0x42e3`, and the fixed `0x42e9` / `0x42ef` / `0x42f3` exit. That tail also re-exposes `INVITE` and `STEFSKULL` in `ST_BEDRM`, reactivates `DWAYNE_ST_BEDRM`, and sets both `STEPHANIE_IS_DEAD` and `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE`.
+    - `BARBER_POLE` is also wider than the first recovered stub: `0x426f`, `0x4274`, response menu `0x2b1`, optional `0x4281` or `0x4289`, then `0x428d`, `0x4299`, response menu `0x2b2`, and either `0x42a4` with the conditional `PC_KILLED_ANYONE -> 0x42a8` insert or the direct `0x42ad` tail, while also setting `ASSIGNED_DNA_TASK`.
     - `BOLTCLTH` is `0x4241`, `0x4245`, `0x4252`, response menu `0x2b3`, optional `0x425e` or `0x4266`, then the firemen-suicide world-state handoff: `FIREMEN_SUICIDE`, `ASSIGNED_POLE_TASK`, and queued `SLASH` death/monsterfy transitions for `SPARKY`, `NUDE_MAN`, and `FIREMAN2`.
   - The hidden intro / revisit tails at `0x30db4..0x30f98` are now bounded from native code and `DIALOG.RSP`: first contact is not a single bark. After `0x413f`, Sergeant runs the fixed `0x4144` / `0x4145` / `0x4146` / `0x4148` exchange, then drives response menus `0x2b7`, `0x2b8`, and `0x2b9`. Later no-item revisits start from `0x41ac`, optionally insert `0x41b0` when `HAVE_LODGE_APP` is still clear, then play `0x41b4` and branch through response menu `0x2bb`.
   - The native `COMPLETED_LODGE_APPLICATION` Monday branch is a deferred quest handoff rather than a single bark: after restoring the item to `RAH`, the handler plays Sergeant lines `0x41cd` then `0x4292`, sets `QUEST_1`, and dispatches action tag `GO_DAY_2`, which drives the story-day advance / cutscene path.
diff --git a/TRACKER.md b/TRACKER.md
index 199625915e4..a3fa1349138 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,15 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `918`
+- Total functions: `920`
 - Named/documented: `798`
-- Remaining `FUN_*`: `120`
-- Remaining undocumented total: `120`
+- Remaining `FUN_*`: `122`
+- Remaining undocumented total: `122`
 
 ## Last Confirmed Action
 
+- On March 22, 2026, rechecked `handle_talk_to_sergeant` in the live `HARVEST.LE` Ghidra body at `0x30340..0x30fd2` and found two native continuations still missing from ScummVM despite the earlier broad Sergeant port. The confirmed gaps were the post-`DINER_BURNED` second response menu at `0x2b0` (`0x42de` / `0x42e3` / `0x42e9` / `0x42ef` / `0x42f3`) plus the room-state mutations that re-show `INVITE` and `STEFSKULL`, reactivate `DWAYNE_ST_BEDRM`, and set `STEPHANIE_IS_DEAD` / `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE`, and the post-`BARBER_POLE` continuation at `0x4299` with response menu `0x2b2` and the optional `PC_KILLED_ANYONE` tail `0x42a4 -> 0x42a8` / `0x42ad`. Patched `engines/harvester/npc/sergeant_dialogue.cpp` to match those branches, updated `ARCHITECTURE.md`, and rebuilt the Harvester debug target successfully. Progress counts reflect the current Ghidra database totals (`920` functions, `122` remaining `FUN_*`).
+- On March 22, 2026, reran the full durable `repair SERGEANT -> wait -> save_program -> verify SERGEANT` cycle against the live `HARVEST.LE` session to confirm Sergeant still survives cleanly with the updated verifier and current save behavior. `handle_talk_to_sergeant` rebuilt to `0x30340..0x30fd2` (`3219` address-count, `70` dialogue calls, `27` repair actions) and the first post-save verify held without needing a retry: both `play_dialogue_line` entry points still verify as returning and `handle_talk_to_sergeant` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Sergeant was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer `InspectCallTermination.java` logic.
 - On March 22, 2026, reimplemented the remaining Stephanie dialogue mismatches in ScummVM directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_stephanie` (`0x36710..0x37f5d`). A forced redecompile plus spot disassembly confirmed the still-wrong native details in the current port: intro variants `0x43eb -> STEPHANIE[2]` and `0x43f5 -> STEPHANIE[3]`, item/evidence variants `0x4684 -> STEPHANIE[2]` and `0x469e -> STEPHANIE[2]`, first-followup tail `0x45d8 -> STEPHANIE[2]` / `0x45e2 -> STEPHANIE[3]`, second-followup variants on `0x460e`, `0x4612`, `0x4616`, `0x461b`, `0x4649`, and `0x464e`, the nested `SCRATCHED_TUCKER` reply `0x4709 -> PC[2]`, and the Mrs. Potts alibi tail `0x4989 -> STEPHANIE[0]` rather than PC. The refreshed late-tail listing around `0x377a3..0x37820` also resolved the Karin-alive control flow: after `0x49ba`, Stephanie only reaches `0x49c1` plus the shared `0x49ea/0x49ef` return when the Dwayne Karin-alive follow-up gate is set; otherwise the conversation falls back into the keyword menu immediately. Patched [`engines/harvester/npc/stephanie_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/stephanie_dialogue.cpp) to match those native branches and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully.
 - On March 22, 2026, reran `repair STEPHANIE` with the updated `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Stephanie showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_stephanie` cleanly to `0x36710..0x37f5d` (`6166` address-count, `195` dialogue calls) but the fresh verify fell back to the short `0x36710..0x36cd8` body with `9` stale dialogue gaps and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Stephanie durably clean again at `0x36710..0x37f5d` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Stephanie belongs in the same post-save retry class as Johnson, Butcher, Dad, Loomis, and similar handlers.
 - On March 22, 2026, closed out the stubborn `HERRILL` case. The saved `HARVEST.LE` database already held the widened `handle_talk_to_herrill` body (`0x2dc00..0x2e987`, `1755` address-count) from the earlier repair/save pass, but `InspectCallTermination.java` kept falsely flagging `0x2dda1` as a missing-fallthrough dialogue call. Direct inspection showed the live listing at `0x2dda1` is actually `JMP 0x2e97a` with stale `CALL_RETURN` metadata, so the real remaining defect was verifier-side rather than a truncated handler body. Patched `~/ghidra_scripts/InspectCallTermination.java` so tail `JMP` transfers to `play_dialogue_line` / `thunk_play_dialogue_line` are counted as terminal dialogue transfers instead of bogus repair gaps; a fresh `verify HERRILL` now reports `status=clean` with `dialogueCalls=39` and `gapCount=0`. This raises the current durable/confirmed sweep count to `40/48`.
@@ -45,6 +47,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Sergeant validation pass that explicitly covers the newly restored native tails: `DINER_BURNED` through both `0x2af` and `0x2b0` menus to confirm the `INVITE` / `STEFSKULL` / `DWAYNE_ST_BEDRM` world-state mutations, and `BARBER_POLE` through response menu `0x2b2` with and without `PC_KILLED_ANYONE` set to verify the `0x42a4` / `0x42a8` / `0x42ad` control flow and the day-5 exit fallthrough.
 - Immediate: run an in-engine Stephanie validation pass that hits the corrected native-only branches: first intro/revisit, the `0x45d8/0x45e2` first-followup tail, `SCRATCHED_TUCKER` response `1 -> 1` to confirm `0x4709` keeps the PC portrait on variant `2`, the Mrs. Potts alibi tail around `0x4984/0x4989/0x498f`, and both Karin-alive outcomes with and without the Dwayne follow-up gate to verify that only the gated path plays `0x49c1` plus `0x49ea/0x49ef`.
 - Immediate: resume the sequential talk-handler sweep at `EDNA`, using the now-proven workflow `repair -> wait ~10s -> save_program -> verify`; when a handler snaps back after the first save, immediately rerun that same NPC once more before moving on. The remaining not-yet-confirmed handlers after the current checkpoint are `EDNA`, `PASTORELLI`, `LODGE_CHEF`, `RYDER`, `SPARKY`, `MEMB_DIR`, `PARSONS`, and `DWAYNE`.
 - Follow-up: after the sweep finishes, fold the reusable gap-fix logic back into `InspectCallTermination.java` itself so it can automatically clear/disassemble exact fallthrough ranges and optionally absorb tiny tail functions instead of requiring ad hoc helper-script intervention for cases like Boyle, Wasp Woman, and Mom.
diff --git a/engines/harvester/npc/sergeant_dialogue.cpp b/engines/harvester/npc/sergeant_dialogue.cpp
index bf24f6bbae8..4e2fb2053f9 100644
--- a/engines/harvester/npc/sergeant_dialogue.cpp
+++ b/engines/harvester/npc/sergeant_dialogue.cpp
@@ -40,7 +40,9 @@ static const int kSlashDeathDamageType = 2;
 static const int kSergeantRemainsResponseLine = 0x2ad;
 static const int kSergeantInviteResponseLine = 0x2ae;
 static const int kSergeantDinerBurnedResponseLine = 0x2af;
+static const int kSergeantDinerBurnedFollowupResponseLine = 0x2b0;
 static const int kSergeantBarberPoleResponseLine = 0x2b1;
+static const int kSergeantBarberPoleFollowupResponseLine = 0x2b2;
 static const int kSergeantBoltClothResponseLine = 0x2b3;
 static const int kSergeantSecondTaskResponseLine = 0x2b4;
 static const int kSergeantCompletedApplicationResponseLine = 0x2b6;
@@ -80,9 +82,12 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 		return false;
 	};
-	auto returnItemToRah = [&](const char *objectName, bool visible) {
+	auto resetAndSetObjectVisible = [&](const char *ownerOrRoom, const char *objectName, bool visible) {
 		(void)runtime.startupScript().resetRuntimeObjectToInitialState(objectName);
-		(void)runtime.startupScript().setRuntimeObjectVisible(kRahRoomName, objectName, visible);
+		(void)runtime.startupScript().setRuntimeObjectVisible(ownerOrRoom, objectName, visible);
+	};
+	auto returnItemToRah = [&](const char *objectName, bool visible) {
+		resetAndSetObjectVisible(kRahRoomName, objectName, visible);
 	};
 	auto executeActionTagIfSet = [&](const char *actionTag) -> Common::Error {
 		StartupInteractionResult interaction;
@@ -180,8 +185,7 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		state.dialogueStateD2d60 = true;
 		state.dialogueStateD2d64 = true;
 		state.dialogueStateD2d68 = true;
-		(void)runtime.startupScript().resetRuntimeObjectToInitialState("STEFSKULL");
-		(void)runtime.startupScript().setRuntimeObjectVisible("ST_BEDRM", "STEFSKULL", true);
+		resetAndSetObjectVisible("ST_BEDRM", "STEFSKULL", true);
 		returnItemToRah("INVITE", false);
 		(void)runtime.startupScript().setRuntimeFlagValue("TAKEN_INVITE_TO_SERGEANT", true);
 		(void)runtime.startupScript().setRuntimeFlagValue("NEED_REMAINS_FOR_LODGE", true);
@@ -256,7 +260,35 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				return lineError;
 		}
 
-		return playSergeantLine(0x428d);
+		lineError = playSergeantLine(0x428d);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playSergeantLine(0x4299);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		responseIndex = 0;
+		responseError = runtime.runResponseMenu(
+			kSergeantBarberPoleFollowupResponseLine, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playSergeantLine(0x42a4, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			if (runtime.startupScript().getFlagValue("PC_KILLED_ANYONE")) {
+				lineError = playSergeantLine(0x42a8);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+		} else if (responseIndex == 2) {
+			lineError = playSergeantLine(0x42ad);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		return runDay5ExitIfNeeded();
 	};
 	auto handleBoltClothBranch = [&]() -> Common::Error {
 		sharedState.dialogueStateD2f08 = true;
@@ -569,7 +601,42 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 					return lineError;
 			}
 
-			return playSergeantLine(0x42d2, 1);
+			lineError = playSergeantLine(0x42d2, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			responseIndex = 0;
+			responseError = runtime.runResponseMenu(
+				kSergeantDinerBurnedFollowupResponseLine, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = playSergeantLine(0x42de, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else if (responseIndex == 2) {
+				lineError = playSergeantLine(0x42e3);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			const DialogueLineEntry dinerBurnedTailLines[] = {
+				{ 0x42e9, "SERGEANT", 0 },
+				{ 0x42ef, "PC", 0 },
+				{ 0x42f3, "SERGEANT", 0 }
+			};
+			lineError = playSequence(dinerBurnedTailLines, ARRAYSIZE(dinerBurnedTailLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			resetAndSetObjectVisible("ST_BEDRM", "INVITE", true);
+			resetAndSetObjectVisible("ST_BEDRM", "STEFSKULL", true);
+			(void)runtime.startupScript().setRuntimeNpcState("DWAYNE_ST_BEDRM", true, true);
+			(void)runtime.startupScript().setRuntimeFlagValue("STEPHANIE_IS_DEAD", true);
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				"STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE", true);
+			return runDay5ExitIfNeeded();
 		}
 		if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN"))
 			return handleBarberPoleBranch();


Commit: 4c645de3e22c95df90c21fba603a0c94465f48d3
    https://github.com/scummvm/scummvm/commit/4c645de3e22c95df90c21fba603a0c94465f48d3
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:54+02:00

Commit Message:
HARVESTER: Align Hank dialogue with native flow

Changed paths:
    engines/harvester/npc/hank_dialogue.cpp


diff --git a/engines/harvester/npc/hank_dialogue.cpp b/engines/harvester/npc/hank_dialogue.cpp
index 4f41ac4ffc4..8c8a9a7120a 100644
--- a/engines/harvester/npc/hank_dialogue.cpp
+++ b/engines/harvester/npc/hank_dialogue.cpp
@@ -30,7 +30,8 @@ namespace Harvester {
 
 namespace {
 
-static const char *const kDialogueRangshotFstPath = "GRAPHIC/FST/RANGSHOT.FST";
+static const char *const kDialogueC021FstPath = "GRAPHIC/FST/C021.FST";
+static const char *const kInventoryOwnerName = "INVENTORY";
 
 static const DialogueLineEntry kHankMomTopicLines[] = {
 	{ 0x725, "HANK", 0 },
@@ -62,7 +63,7 @@ static const DialogueLineEntry kHankLightingOutTopicLines[] = {
 	{ 0x77f, "HANK", 0 },
 	{ 0x784, "PC", 2 },
 	{ 0x788, "HANK", 0 },
-	{ 0x78e, "PC", 2 },
+	{ 0x78e, "MOM", 2 },
 	{ 0x793, "HANK", 0 }
 };
 
@@ -115,7 +116,8 @@ static const DialogueLineEntry kHankSickTopicLines[] = {
 	{ 0x867, "HANK", 0 },
 	{ 0x86b, "PC", 0 },
 	{ 0x86f, "HANK", 0 },
-	{ 0x873, "PC", 0 }
+	{ 0x873, "PC", 0 },
+	{ 0x877, "HANK", 0 }
 };
 
 static const DialogueLineEntry kHankNewspaperFireSentinelTopicLines[] = {
@@ -134,6 +136,45 @@ static const DialogueLineEntry kHankLodgeTopicLines[] = {
 	{ 0x8d3, "HANK", 0 }
 };
 
+static const DialogueLineEntry kHankRangshotResponse1Lines[] = {
+	{ 0x8ff, "HANK", 0 },
+	{ 0x903, "PC", 0 },
+	{ 0x908, "HANK", 0 },
+	{ 0x90d, "PC", 4 },
+	{ 0x911, "HANK", 0 }
+};
+
+static const DialogueLineEntry kHankRangshotResponse2Lines[] = {
+	{ 0x918, "HANK", 0 },
+	{ 0x91f, "PC", 0 },
+	{ 0x924, "HANK", 0 }
+};
+
+static const DialogueLineEntry kHankBurnedTvStationLines[] = {
+	{ 0x987, "PC", 0 },
+	{ 0x98b, "HANK", 0 },
+	{ 0x98f, "PC", 0 },
+	{ 0x993, "HANK", 0 },
+	{ 0x99a, "MOM", 2 },
+	{ 0x99f, "HANK", 0 },
+	{ 0x9a6, "HANK", 0 },
+	{ 0x9aa, "HANK", 0 }
+};
+
+static const DialogueLineEntry kHankKarinFoundDeadResponse2Lines[] = {
+	{ 0xa26, "HANK", 0 },
+	{ 0xa2b, "HANK", 0 }
+};
+
+static const DialogueLineEntry kHankResponse0EdTopicLines[] = {
+	{ 0x881, "PC", 0 },
+	{ 0x885, "HANK", 0 },
+	{ 0x88a, "PC", 0 },
+	{ 0x88f, "HANK", 0 },
+	{ 0x894, "PC", 0 },
+	{ 0x898, "HANK", 0 }
+};
+
 } // End of namespace
 
 bool HankDialogueHandler::matchesNpc(const Common::String &npcName) const {
@@ -157,16 +198,50 @@ Common::Error HankDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			int headVariant = 0) -> Common::Error {
 		return runtime.playDialogueLineWithVariant(wavId, speakerId, headVariant);
 	};
+	auto hidePresentedInventoryItem = [&]() {
+		(void)runtime.startupScript().setRuntimeObjectVisible(
+			kInventoryOwnerName, usedItemName, false);
+	};
 
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
-			return runtime.playDialogueLine(0xa3e, "HANK");
+			Common::Error lineError = runtime.playDialogueLine(0xa3e, "HANK");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0xc5, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1) {
+				(void)runtime.startupScript().addRuntimeObjectToInventory("GILLMAN");
+				hidePresentedInventoryItem();
+				return runtime.playDialogueLine(0xa5e, "HANK");
+			}
+			if (responseIndex == 2)
+				return runtime.playDialogueLine(0xa62, "HANK");
+			return Common::kNoError;
 		}
 		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-			return runtime.playDialogueLine(0xa53, "HANK");
+			Common::Error lineError = runtime.playDialogueLine(0xa53, "HANK");
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0xc6, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1) {
+				(void)runtime.startupScript().addRuntimeObjectToInventory("ROBOT");
+				hidePresentedInventoryItem();
+				return runtime.playDialogueLine(0xa5e, "HANK");
+			}
+			if (responseIndex == 2)
+				return runtime.playDialogueLine(0xa62, "HANK");
+			return Common::kNoError;
 		}
 		return runtime.playDialogueLine(0xa38, "HANK");
 	}
@@ -218,18 +293,32 @@ Common::Error HankDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			currentStoryDayIndex != state.trackedDayIndex)) {
 		if (state.pendingSameDayFollowup) {
 			state.trackedDayIndex = currentStoryDayIndex;
+			state.hasTrackedDayState = true;
 			state.pendingSameDayFollowup = false;
 			state.pendingRangshotSequence = true;
 
 			Common::Error lineError = runtime.playDialogueLine(0x8f5, "HANK");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-		}
-
-		if (state.pendingRangshotSequence) {
+			if (currentStoryDayIndex < 4) {
+				int responseIndex = 0;
+				Common::Error responseError = runtime.runResponseMenu(0xcb, responseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+				if (responseIndex == 1) {
+					lineError = playSequence(
+						kHankRangshotResponse1Lines, ARRAYSIZE(kHankRangshotResponse1Lines));
+				} else if (responseIndex == 2) {
+					lineError = playSequence(
+						kHankRangshotResponse2Lines, ARRAYSIZE(kHankRangshotResponse2Lines));
+				}
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+		} else if (state.pendingRangshotSequence) {
 			Common::Error lineError = currentStoryDayIndex > 5
 				? runtime.playDialogueLine(0x8e2, "HANK")
-				: runtime.playDialogueFst(kDialogueRangshotFstPath);
+				: runtime.playDialogueFst(kDialogueC021FstPath);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
@@ -237,7 +326,7 @@ Common::Error HankDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		state.pendingSameDayFollowup = true;
 		Common::Error lineError = currentStoryDayIndex > 5
 			? runtime.playDialogueLine(0x8e2, "HANK")
-			: runtime.playDialogueFst(kDialogueRangshotFstPath);
+			: runtime.playDialogueFst(kDialogueC021FstPath);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
@@ -315,7 +404,8 @@ Common::Error HankDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (!skipHankFollowupBranches && runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
 			!state.burnedTvStationShown) {
 		state.burnedTvStationShown = true;
-		Common::Error lineError = runtime.playDialogueLine(0x987, "PC");
+		Common::Error lineError = playSequence(
+			kHankBurnedTvStationLines, ARRAYSIZE(kHankBurnedTvStationLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
@@ -334,8 +424,7 @@ Common::Error HankDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		switch (responseIndex) {
 		case 1: {
 			const DialogueLineEntry lines[] = {
-				{ 0x9bc, "HANK", 0 },
-				{ 0x9c9, "HANK", 0 }
+				{ 0x9bc, "HANK", 0 }
 			};
 			lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
 			break;
@@ -426,17 +515,15 @@ Common::Error HankDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			lineError = runtime.playDialogueLine(0xa20, "HANK");
 			break;
 		case 2:
-			lineError = runtime.playDialogueLine(0xa26, "HANK");
+			lineError = playSequence(
+				kHankKarinFoundDeadResponse2Lines,
+				ARRAYSIZE(kHankKarinFoundDeadResponse2Lines));
 			break;
 		default:
 			break;
 		}
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-
-		lineError = runtime.playDialogueLine(0xa2b, "HANK");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
 	}
 
 	for (;;) {
@@ -534,7 +621,7 @@ Common::Error HankDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				kHankTvTopicIntroLines, ARRAYSIZE(kHankTvTopicIntroLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			lineError = runtime.playDialogueFst(kDialogueRangshotFstPath);
+			lineError = runtime.playDialogueFst(kDialogueC021FstPath);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			lineError = playSequence(
@@ -572,7 +659,8 @@ Common::Error HankDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0xed)) {
-			Common::Error lineError = playLine(0x881, "PC");
+			Common::Error lineError = playSequence(
+				kHankResponse0EdTopicLines, ARRAYSIZE(kHankResponse0EdTopicLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			continue;


Commit: b9715d3bd3986ae40da3a121fe3a6311a92a2ae7
    https://github.com/scummvm/scummvm/commit/b9715d3bd3986ae40da3a121fe3a6311a92a2ae7
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:55+02:00

Commit Message:
HARVESTER: Align mom dialogue with ghidra

Changed paths:
    TRACKER.md
    engines/harvester/npc/mom_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index a3fa1349138..1a516d7e1e7 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,16 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `920`
-- Named/documented: `798`
-- Remaining `FUN_*`: `122`
-- Remaining undocumented total: `122`
+- Total functions: `923`
+- Named/documented: `799`
+- Remaining `FUN_*`: `124`
+- Remaining undocumented total: `124`
 
 ## Last Confirmed Action
 
+- On March 22, 2026, reimplemented the remaining confirmed Mom dialogue mismatches in ScummVM directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mom` (`0x31140..0x33523`). Raw instruction windows over the native item and hidden-topic tails confirmed that two evidence branches were still over-ported (`CASKET_PHOTO` is only `PC 0x233b`; `PHOTO_OF_WHALEY_HERRILL` is only `MOM 0x239c`), `JIMMY_ATTACKED` uses `PC 0x265f` with head variant `2`, the `POTTSDAM` second-response branch uses `PC 0x20e3` rather than Mom, the `MEAT PLANT` first response is `MOM 0x2106 -> PC 0x210a -> MOM 0x210e`, the `FATHER` keyword sets the native `momFatherTopicState`, and the fallback `0x26c6` path loops back into the keyword menu instead of forcing an immediate goodbye. Patched [`engines/harvester/npc/mom_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mom_dialogue.cpp) to match those confirmed native branches and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts now reflect the current live Ghidra database totals (`923` functions, `124` remaining `FUN_*`).
+- On March 22, 2026, reran the full durable `repair MOM -> wait -> save_program -> verify MOM` cycle against the live `HARVEST.LE` session to confirm Mom still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_mom` rebuilt to `0x31140..0x33523` (`8778` address-count, `251` dialogue calls, `116` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_mom` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Mom was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
+- On March 22, 2026, reran the full durable `repair HANK -> wait -> save_program -> verify HANK` cycle against the live `HARVEST.LE` session to confirm Hank still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_hank` rebuilt to `0x33a30..0x34bcb` (`4508` address-count, `131` dialogue calls, `58` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_hank` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Hank was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
 - On March 22, 2026, rechecked `handle_talk_to_sergeant` in the live `HARVEST.LE` Ghidra body at `0x30340..0x30fd2` and found two native continuations still missing from ScummVM despite the earlier broad Sergeant port. The confirmed gaps were the post-`DINER_BURNED` second response menu at `0x2b0` (`0x42de` / `0x42e3` / `0x42e9` / `0x42ef` / `0x42f3`) plus the room-state mutations that re-show `INVITE` and `STEFSKULL`, reactivate `DWAYNE_ST_BEDRM`, and set `STEPHANIE_IS_DEAD` / `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE`, and the post-`BARBER_POLE` continuation at `0x4299` with response menu `0x2b2` and the optional `PC_KILLED_ANYONE` tail `0x42a4 -> 0x42a8` / `0x42ad`. Patched `engines/harvester/npc/sergeant_dialogue.cpp` to match those branches, updated `ARCHITECTURE.md`, and rebuilt the Harvester debug target successfully. Progress counts reflect the current Ghidra database totals (`920` functions, `122` remaining `FUN_*`).
 - On March 22, 2026, reran the full durable `repair SERGEANT -> wait -> save_program -> verify SERGEANT` cycle against the live `HARVEST.LE` session to confirm Sergeant still survives cleanly with the updated verifier and current save behavior. `handle_talk_to_sergeant` rebuilt to `0x30340..0x30fd2` (`3219` address-count, `70` dialogue calls, `27` repair actions) and the first post-save verify held without needing a retry: both `play_dialogue_line` entry points still verify as returning and `handle_talk_to_sergeant` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Sergeant was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer `InspectCallTermination.java` logic.
 - On March 22, 2026, reimplemented the remaining Stephanie dialogue mismatches in ScummVM directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_stephanie` (`0x36710..0x37f5d`). A forced redecompile plus spot disassembly confirmed the still-wrong native details in the current port: intro variants `0x43eb -> STEPHANIE[2]` and `0x43f5 -> STEPHANIE[3]`, item/evidence variants `0x4684 -> STEPHANIE[2]` and `0x469e -> STEPHANIE[2]`, first-followup tail `0x45d8 -> STEPHANIE[2]` / `0x45e2 -> STEPHANIE[3]`, second-followup variants on `0x460e`, `0x4612`, `0x4616`, `0x461b`, `0x4649`, and `0x464e`, the nested `SCRATCHED_TUCKER` reply `0x4709 -> PC[2]`, and the Mrs. Potts alibi tail `0x4989 -> STEPHANIE[0]` rather than PC. The refreshed late-tail listing around `0x377a3..0x37820` also resolved the Karin-alive control flow: after `0x49ba`, Stephanie only reaches `0x49c1` plus the shared `0x49ea/0x49ef` return when the Dwayne Karin-alive follow-up gate is set; otherwise the conversation falls back into the keyword menu immediately. Patched [`engines/harvester/npc/stephanie_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/stephanie_dialogue.cpp) to match those native branches and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully.
@@ -47,6 +50,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Mom validation pass that explicitly hits the now-corrected native-only paths: `CASKET_PHOTO`, `PHOTO_OF_WHALEY_HERRILL`, the `JIMMY_ATTACKED` branch, the `POTTSDAM` second response that reaches `0x20e3`, the `MEAT PLANT` first response (`0x2106 -> 0x210a -> 0x210e`), the `FATHER` topic to confirm `momFatherTopicState` persists through save/load, and the fallback `0x26c6` bark to verify it returns to the keyword loop instead of ending the conversation.
 - Immediate: run an in-engine Sergeant validation pass that explicitly covers the newly restored native tails: `DINER_BURNED` through both `0x2af` and `0x2b0` menus to confirm the `INVITE` / `STEFSKULL` / `DWAYNE_ST_BEDRM` world-state mutations, and `BARBER_POLE` through response menu `0x2b2` with and without `PC_KILLED_ANYONE` set to verify the `0x42a4` / `0x42a8` / `0x42ad` control flow and the day-5 exit fallthrough.
 - Immediate: run an in-engine Stephanie validation pass that hits the corrected native-only branches: first intro/revisit, the `0x45d8/0x45e2` first-followup tail, `SCRATCHED_TUCKER` response `1 -> 1` to confirm `0x4709` keeps the PC portrait on variant `2`, the Mrs. Potts alibi tail around `0x4984/0x4989/0x498f`, and both Karin-alive outcomes with and without the Dwayne follow-up gate to verify that only the gated path plays `0x49c1` plus `0x49ea/0x49ef`.
 - Immediate: resume the sequential talk-handler sweep at `EDNA`, using the now-proven workflow `repair -> wait ~10s -> save_program -> verify`; when a handler snaps back after the first save, immediately rerun that same NPC once more before moving on. The remaining not-yet-confirmed handlers after the current checkpoint are `EDNA`, `PASTORELLI`, `LODGE_CHEF`, `RYDER`, `SPARKY`, `MEMB_DIR`, `PARSONS`, and `DWAYNE`.
diff --git a/engines/harvester/npc/mom_dialogue.cpp b/engines/harvester/npc/mom_dialogue.cpp
index 3145c384b9d..6666630914b 100644
--- a/engines/harvester/npc/mom_dialogue.cpp
+++ b/engines/harvester/npc/mom_dialogue.cpp
@@ -48,16 +48,11 @@ static const DialogueLineEntry kMomLedgerLines[] = {
 };
 
 static const DialogueLineEntry kMomCasketPhotoLines[] = {
-	{ 0x233b, "PC", 0 },
-	{ 0x233f, "MOM", 1 },
-	{ 0x2344, "PC", 0 },
-	{ 0x2348, "MOM", 0 }
+	{ 0x233b, "PC", 0 }
 };
 
 static const DialogueLineEntry kMomWhaleyPhotoLines[] = {
-	{ 0x239c, "MOM", 2 },
-	{ 0x23a0, "PC", 0 },
-	{ 0x23a5, "MOM", 0 }
+	{ 0x239c, "MOM", 2 }
 };
 
 static const DialogueLineEntry kMomIntroLines[] = {
@@ -181,7 +176,7 @@ static const DialogueLineEntry kMomMoynahanAbsentLines[] = {
 
 static const DialogueLineEntry kMomJimmyAttackedLines[] = {
 	{ 0x2659, "MOM", 2 },
-	{ 0x265f, "PC", 0 },
+	{ 0x265f, "PC", 2 },
 	{ 0x2663, "MOM", 0 }
 };
 
@@ -383,7 +378,7 @@ static const DialogueLineEntry kMomPottsdamSecondResponse2Lines[] = {
 	{ 0x20d5, "MOM", 0 },
 	{ 0x20da, "PC", 4 },
 	{ 0x20df, "MOM", 0 },
-	{ 0x20e3, "MOM", 3 },
+	{ 0x20e3, "PC", 3 },
 	{ 0x20e8, "MOM", 1 }
 };
 
@@ -393,9 +388,9 @@ static const DialogueLineEntry kMomMeatPlantIntroLines[] = {
 };
 
 static const DialogueLineEntry kMomMeatPlantResponse1Lines[] = {
-	{ 0x2106, "PC", 0 },
-	{ 0x210a, "MOM", 0 },
-	{ 0x210e, "PC", 0 }
+	{ 0x2106, "MOM", 0 },
+	{ 0x210a, "PC", 0 },
+	{ 0x210e, "MOM", 0 }
 };
 
 static const DialogueLineEntry kMomMeatPlantResponse2Lines[] = {
@@ -1075,6 +1070,7 @@ Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x13f)) {
+			sharedState.momFatherTopicState = 1;
 			Common::Error lineError = playSequence(kMomFatherLines, ARRAYSIZE(kMomFatherLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
@@ -1352,7 +1348,7 @@ Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		Common::Error lineError = playMomLine(0x26c6, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		return runMomGoodbye();
+		continue;
 	}
 }
 


Commit: a3d097388e2fb0f155bde97aabb10a57079340ea
    https://github.com/scummvm/scummvm/commit/a3d097388e2fb0f155bde97aabb10a57079340ea
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:55+02:00

Commit Message:
HARVESTER: Align Jimmy dialogue with native flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/jimmy_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 7077239f3d4..b632cfedcfb 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -540,14 +540,14 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - The steady-state no-item bark chain is fully linear and one-shot gated: `STEPH_MIDGAME_PLAYED` -> `0xbf0` / `0xbf1` / `0xbf2`, `KARIN_KIDNAPED` -> `0xbff` / `0xc06` plus another `d2f04` write, `KARIN_FOUND_ALIVE` -> `0xc13` / `0xc17`, `BARBER_POLE_STOLEN` -> `0xc46` / `0xc4d` / `0xc59`, `DINER_BURNED` -> `0xc6f`, `PC_ESCAPED_JAIL` -> `0xc77`, `GOT_REMAINS_FOR_LODGE` -> `0xc7f`, and `BURNED_TV_STATION` -> `0xc8f`.
     - Johnson's keyword loop now has a stable branch map: `0x177` exits on `0xbda`, `0x178` / `0x179` play `0xaa6`, open zero-based `dialog.rsp` line `0x17a`, then continue through `0xabd` / `0xac3` / `0xac7` and rewrite the keyword buffer to `0x17b`; `0x17c` plays `0xb17` / `0xb1c` / `0xb20` and rewrites to `0x17d`; `0x17e` plays `0xb2d` and rewrites to `0x17f`; `0x180` / `0x181` / `0x182` play `0xb3a` and rewrite to `0x183`; `0x184` / `0x185` play `0xb46` and rewrite to `0x186`; `0x187` continues through `PC 0xb52` and `JOHNSON 0xb56`, then rewrites to `0x188`; `0x189` loops silently on the current buffer; fallback still uses `0xb69` then `0xbda`.
   - `handle_talk_to_jimmy @ 0x28880` is a compact short-circuit handler with no keyword loop.
-    - The first no-item visit clears `g_jimmy_first_no_item_line_pending`, conditionally plays `0x4a4c` if `g_jimmy_talk_state_block` is still clear, then always continues through `0x4a58`, sets `PAPER_CHK_1` and `GIVEN_PAPER_TODAY`, and opens three response menus from zero-based `dialog.rsp` lines `0xf5`, `0xf6`, and `0xf7`.
+    - The first no-item visit clears `g_jimmy_first_no_item_line_pending`, conditionally plays `0x4a4c` / `PC 0x4a52` if `g_jimmy_talk_state_block` is still clear, then always continues through `0x4a58`, sets `PAPER_CHK_1` and `GIVEN_PAPER_TODAY`, and opens three response menus from zero-based `dialog.rsp` lines `0xf5`, `0xf6`, and `0xf7`.
     - Jimmy's hidden first-meeting response map is now explicit:
       - `0xf5` (`1. I've got better things to do./2. I'm supposed to?  I don't remember.`) selects `0x4a63` or `0x4a67`, then always continues through the fixed exchange `0x4a6b` / `PC 0x4a70` / `0x4a74` / `0x4a7a`.
       - `0xf6` (`1. Yes, I do./2. Nope, sorry.`) sends response `1` into `0x4a86` / `PC 0x4a8a` / `0x4a8e` plus the third response menu `0xf7`, while response `2` exits that branch on `0x4abc`.
       - `0xf7` (`1. Okay... take the sneakers./2. Forget it... what do I care about what they're doing?`) checks live inventory `SNEAKERS`: response `1` with sneakers plays `0x4a9e`, makes `SNEAKERS` visible for `RAH`, and adds `BROOMKEY` to inventory; response `1` without sneakers plays `0x4aa6` / `PC 0x4aaa` / `0x4aae`; response `2` plays `0x4ab4`.
-    - Presenting `NEWSPAPER` sets `GIVEN_PAPER_TODAY`, runs the `ACTV_HOUSE_EXIT` action tag, restores `NEWSPAPER` to its startup state, and on the first paper handoff plays `0x4a4c` before the random revisit bark `0x4acc` / `0x4a4b`.
-    - The evidence-item path is otherwise linear: `PHOTO_OF_WHALEY_HERRILL` plays `0x4af8`, `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` and `CASKET_PHOTO` / `CASKET_PHOTOCOPY` converge on `0x4b00`, note/checkbook evidence plays `0x4b21`, `SNEAKERS` plays `0x4a9e` then falls through to `0x4af2`, and all other items fall back to `0x4af2`.
-    - Later no-item visits remain a straight bark chain over inventory `SNEAKERS` without `BROOMKEY`, then `PAPER_CHK_4`, `PAPER_CHK_3`, `PAPER_CHK_2`, and the default `0x4b38`.
+    - Presenting `NEWSPAPER` sets `GIVEN_PAPER_TODAY`, runs the `ACTV_HOUSE_EXIT` action tag, restores `NEWSPAPER` to its startup state, clears the first-no-item gate, sets `PAPER_CHK_1`, and on the first paper handoff plays `0x4a4c` / `PC 0x4a52` before the random revisit bark `0x4acc` / `0x4a4b`.
+    - The evidence-item path is otherwise linear: `PHOTO_OF_WHALEY_HERRILL` plays `0x4af8`; `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` and `CASKET_PHOTO` / `CASKET_PHOTOCOPY` share the seven-line exchange `0x4b00` / `PC 0x4b04` / `0x4b08` / `PC 0x4b0c` / `0x4b10` / `PC 0x4b15` / `0x4b19`; note/checkbook evidence plays `0x4b21` / `PC 0x4b25` / `0x4b29` / `PC 0x4b2d` / `0x4b31`; direct `SNEAKERS` use plays `0x4a9e`, returns the sneakers to `RAH`, and awards `BROOMKEY`; all other items fall back to `0x4af2`.
+    - Later no-item visits first check live inventory `SNEAKERS` without `BROOMKEY`: that path plays `0x4ac3`, opens zero-based `dialog.rsp` line `0xf8`, and reuses the same `0x4a86` / `0x4a8a` / `0x4a8e` plus `0xf7` sneakers-trade flow before the bark chain continues through `PAPER_CHK_4`, `PAPER_CHK_3`, `PAPER_CHK_2`, and the default `0x4b38`.
   - `handle_talk_to_moynahan @ 0x24a20` is now bounded enough for a direct startup-room port.
     - `IF_TRY_TO_TAKE_THE_GLUE` is a strict early interrupt branch: it always plays `0x3e4d` / `0x3e52` / `0x3e56`, then either exits on `0x3e76` / `0x3e7a` or, once the slashes-on-body topic has been unlocked, continues through `0x3e5b` / `0x3e60` / `0x3e64` / `0x3e69` / `0x3e6d` / `0x3e71`.
     - The first no-item visit clears the Moynahan-local intro gate and plays the fixed six-line opener `0x3d33` / `0x3d37` / `0x3d3b` / `0x3d40` / `0x3d45` / `0x3d47`; later visits start on `0x3fd1`, can inject the slashes-on-body topic into the live keyword buffer through zero-based `dialog.rsp` lines `0x194` / `0x195`, can re-arm the intro gate after the shared casket-photo evidence is discussed, and then run the `HAVE_BOTH_LEDGERS`, `STEPH_MIDGAME_PLAYED`, day-5, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, Butcher-death, Jimmy-death, and `KARIN_FOUND_DEAD` follow-ups in that priority order.
diff --git a/TRACKER.md b/TRACKER.md
index 1a516d7e1e7..e4e15b1341d 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,8 @@
 
 ## Last Confirmed Action
 
+- On March 22, 2026, reimplemented the ScummVM Jimmy dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_jimmy` (`0x28880..0x28ec1`). The confirmed source drift was all in native control-flow details: the first paper-handoff gate was missing `PC 0x4a52`, later no-item visits with `SNEAKERS` but no `BROOMKEY` were missing the native response menu `0xf8` and shared `0x4a86` / `0x4a8a` / `0x4a8e` branch, direct `SNEAKERS` use failed to return the shoes to `RAH` and award `BROOMKEY`, and the `0x4b00` corpse/ledger plus `0x4b21` blackmail evidence branches had been collapsed to single-line barks instead of the native multi-line exchanges. Patched [`engines/harvester/npc/jimmy_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/jimmy_dialogue.cpp), updated the confirmed Jimmy notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts are unchanged at `923` total functions and `124` remaining `FUN_*`.
+- On March 22, 2026, reran the full durable `repair JIMMY -> wait -> save_program -> verify JIMMY` cycle against the live `HARVEST.LE` session to confirm Jimmy still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_jimmy` rebuilt to `0x28880..0x28ec1` (`1602` address-count, `39` dialogue calls, `27` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_jimmy` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Jimmy was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
 - On March 22, 2026, reimplemented the remaining confirmed Mom dialogue mismatches in ScummVM directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mom` (`0x31140..0x33523`). Raw instruction windows over the native item and hidden-topic tails confirmed that two evidence branches were still over-ported (`CASKET_PHOTO` is only `PC 0x233b`; `PHOTO_OF_WHALEY_HERRILL` is only `MOM 0x239c`), `JIMMY_ATTACKED` uses `PC 0x265f` with head variant `2`, the `POTTSDAM` second-response branch uses `PC 0x20e3` rather than Mom, the `MEAT PLANT` first response is `MOM 0x2106 -> PC 0x210a -> MOM 0x210e`, the `FATHER` keyword sets the native `momFatherTopicState`, and the fallback `0x26c6` path loops back into the keyword menu instead of forcing an immediate goodbye. Patched [`engines/harvester/npc/mom_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mom_dialogue.cpp) to match those confirmed native branches and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts now reflect the current live Ghidra database totals (`923` functions, `124` remaining `FUN_*`).
 - On March 22, 2026, reran the full durable `repair MOM -> wait -> save_program -> verify MOM` cycle against the live `HARVEST.LE` session to confirm Mom still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_mom` rebuilt to `0x31140..0x33523` (`8778` address-count, `251` dialogue calls, `116` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_mom` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Mom was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
 - On March 22, 2026, reran the full durable `repair HANK -> wait -> save_program -> verify HANK` cycle against the live `HARVEST.LE` session to confirm Hank still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_hank` rebuilt to `0x33a30..0x34bcb` (`4508` address-count, `131` dialogue calls, `58` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_hank` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Hank was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
@@ -50,6 +52,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Jimmy validation pass that explicitly hits the corrected native-only paths: the first no-item visit through response menus `0xf5`, `0xf6`, and `0xf7`; the later no-item `SNEAKERS` revisit through response menu `0xf8`; first and repeat `NEWSPAPER` handoffs to confirm `0x4a4c` / `0x4a52` and the `0x4acc` / `0x4a4b` random bark; direct `SNEAKERS` use to confirm `BROOMKEY` is awarded; and the `LEDGER` / corpse-photo plus note/checkbook evidence exchanges to verify the restored `0x4b19` and `0x4b31` tails.
 - Immediate: run an in-engine Mom validation pass that explicitly hits the now-corrected native-only paths: `CASKET_PHOTO`, `PHOTO_OF_WHALEY_HERRILL`, the `JIMMY_ATTACKED` branch, the `POTTSDAM` second response that reaches `0x20e3`, the `MEAT PLANT` first response (`0x2106 -> 0x210a -> 0x210e`), the `FATHER` topic to confirm `momFatherTopicState` persists through save/load, and the fallback `0x26c6` bark to verify it returns to the keyword loop instead of ending the conversation.
 - Immediate: run an in-engine Sergeant validation pass that explicitly covers the newly restored native tails: `DINER_BURNED` through both `0x2af` and `0x2b0` menus to confirm the `INVITE` / `STEFSKULL` / `DWAYNE_ST_BEDRM` world-state mutations, and `BARBER_POLE` through response menu `0x2b2` with and without `PC_KILLED_ANYONE` set to verify the `0x42a4` / `0x42a8` / `0x42ad` control flow and the day-5 exit fallthrough.
 - Immediate: run an in-engine Stephanie validation pass that hits the corrected native-only branches: first intro/revisit, the `0x45d8/0x45e2` first-followup tail, `SCRATCHED_TUCKER` response `1 -> 1` to confirm `0x4709` keeps the PC portrait on variant `2`, the Mrs. Potts alibi tail around `0x4984/0x4989/0x498f`, and both Karin-alive outcomes with and without the Dwayne follow-up gate to verify that only the gated path plays `0x49c1` plus `0x49ea/0x49ef`.
diff --git a/engines/harvester/npc/jimmy_dialogue.cpp b/engines/harvester/npc/jimmy_dialogue.cpp
index ae9d603144b..6c6a583eb46 100644
--- a/engines/harvester/npc/jimmy_dialogue.cpp
+++ b/engines/harvester/npc/jimmy_dialogue.cpp
@@ -35,9 +35,16 @@ static const char *const kJimmyNpc = "JIMMY";
 static const char *const kPcSpeaker = "PC";
 static const char *const kRahRoomName = "RAH";
 static const char *const kJimmyFirstMeetingFlag = "PAPER_CHK_1";
+static const char *const kGivenPaperTodayFlag = "GIVEN_PAPER_TODAY";
 static const int kJimmyFirstResponseLineIndex = 0xf5;
 static const int kJimmySecondResponseLineIndex = 0xf6;
 static const int kJimmyThirdResponseLineIndex = 0xf7;
+static const int kJimmySneakersResponseLineIndex = 0xf8;
+
+static const DialogueLineEntry kJimmyPaperHandoffLines[] = {
+	{ 0x4a4c, kJimmyNpc, 1 },
+	{ 0x4a52, kPcSpeaker, 0 }
+};
 
 static const DialogueLineEntry kJimmyFirstMeetingFollowupLines[] = {
 	{ 0x4a6b, kJimmyNpc, 0 },
@@ -57,6 +64,24 @@ static const DialogueLineEntry kJimmyNoSneakersLines[] = {
 	{ 0x4aae, kJimmyNpc, 2 }
 };
 
+static const DialogueLineEntry kJimmyLedgersAndCorpseEvidenceLines[] = {
+	{ 0x4b00, kJimmyNpc, 0 },
+	{ 0x4b04, kPcSpeaker, 0 },
+	{ 0x4b08, kJimmyNpc, 0 },
+	{ 0x4b0c, kPcSpeaker, 0 },
+	{ 0x4b10, kJimmyNpc, 0 },
+	{ 0x4b15, kPcSpeaker, 0 },
+	{ 0x4b19, kJimmyNpc, 0 }
+};
+
+static const DialogueLineEntry kJimmyBlackmailEvidenceLines[] = {
+	{ 0x4b21, kJimmyNpc, 0 },
+	{ 0x4b25, kPcSpeaker, 0 },
+	{ 0x4b29, kJimmyNpc, 0 },
+	{ 0x4b2d, kPcSpeaker, 0 },
+	{ 0x4b31, kJimmyNpc, 1 }
+};
+
 } // End of namespace
 
 bool JimmyDialogueHandler::matchesNpc(const Common::String &npcName) const {
@@ -75,18 +100,92 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.playDialogueEntrySequence(lines, count);
 	};
 	auto hasInventoryItem = [&](const char *objectName) {
-		Common::Array<StartupObjectRecord> inventoryObjects;
-		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
-		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
-			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
-				return true;
+		return runtime.startupScript().isObjectInInventory(objectName);
+	};
+	auto executeActionTagIfSet = [&](const char *tag) {
+		StartupInteractionResult interaction;
+		if (!runtime.startupScript().executeActionTag(tag, interaction))
+			return;
+
+		runtime.applyImmediateDialogueInteractionEffects(interaction);
+		runtime.queueDialogueInteractionIfNeeded(interaction);
+	};
+	auto restoreItemToRah = [&](const char *objectName) {
+		(void)runtime.startupScript().resetRuntimeObjectToInitialState(objectName);
+		(void)runtime.startupScript().setRuntimeObjectVisible(kRahRoomName, objectName, true);
+	};
+	auto completeSneakersTrade = [&]() {
+		restoreItemToRah("SNEAKERS");
+		(void)runtime.startupScript().addRuntimeObjectToInventory("BROOMKEY");
+	};
+	auto playPaperHandoffPreludeIfNeeded = [&]() -> Common::Error {
+		if (state.paperHandoffStateSet)
+			return Common::kNoError;
+
+		Common::Error preludeError = playJimmySequence(
+			kJimmyPaperHandoffLines, ARRAYSIZE(kJimmyPaperHandoffLines));
+		if (preludeError.getCode() != Common::kNoError)
+			return preludeError;
+
+		state.paperHandoffStateSet = true;
+		return Common::kNoError;
+	};
+	auto runSneakersOfferMenu = [&]() -> Common::Error {
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(kJimmyThirdResponseLineIndex, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			if (hasInventoryItem("SNEAKERS")) {
+				Common::Error lineError = playJimmyLine(0x4a9e, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				completeSneakersTrade();
+				return Common::kNoError;
+			}
+
+			return playJimmySequence(kJimmyNoSneakersLines, ARRAYSIZE(kJimmyNoSneakersLines));
 		}
+		if (responseIndex == 2)
+			return playJimmyLine(0x4ab4, 1);
 
-		return false;
+		return Common::kNoError;
+	};
+	auto runJimmySneakersPrompt = [&](int responseLineIndex) -> Common::Error {
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(responseLineIndex, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			Common::Error lineError = playJimmyLine(0x4a86, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			lineError = playJimmySequence(kJimmySecondResponsePreludeLines,
+				ARRAYSIZE(kJimmySecondResponsePreludeLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			return runSneakersOfferMenu();
+		}
+		if (responseIndex == 2)
+			return playJimmyLine(0x4abc, 0);
+
+		return Common::kNoError;
 	};
 	auto playJimmyNoItemFallback = [&]() -> Common::Error {
-		if (hasInventoryItem("SNEAKERS") && !hasInventoryItem("BROOMKEY"))
-			return playJimmyLine(0x4ac3, 0);
+		if (hasInventoryItem("SNEAKERS") && !hasInventoryItem("BROOMKEY")) {
+			Common::Error sneakersLineError = playJimmyLine(0x4ac3, 0);
+			if (sneakersLineError.getCode() != Common::kNoError)
+				return sneakersLineError;
+
+			Common::Error sneakersPromptError = runJimmySneakersPrompt(kJimmySneakersResponseLineIndex);
+			if (sneakersPromptError.getCode() != Common::kNoError)
+				return sneakersPromptError;
+		}
 		if (runtime.startupScript().getFlagValue("PAPER_CHK_4"))
 			return playJimmyLine(0x4ae2, 2);
 		if (runtime.startupScript().getFlagValue("PAPER_CHK_3"))
@@ -99,49 +198,25 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("NEWSPAPER")) {
-			StartupInteractionResult jimmyInteraction;
-			const bool changedGivenPaperToday =
-				runtime.startupScript().setRuntimeFlagValue("GIVEN_PAPER_TODAY", true);
-			const bool changedNewspaperState =
-				runtime.startupScript().resetRuntimeObjectToInitialState("NEWSPAPER");
-			jimmyInteraction.mutatedRuntimeState = changedGivenPaperToday || changedNewspaperState;
-
-			StartupInteractionResult actionInteraction;
-			if (runtime.startupScript().executeNestedActionTag("ACTV_HOUSE_EXIT", actionInteraction)) {
-				jimmyInteraction.abortRemainingCommandChain =
-					jimmyInteraction.abortRemainingCommandChain ||
-					actionInteraction.abortRemainingCommandChain;
-				jimmyInteraction.mutatedRuntimeState =
-					jimmyInteraction.mutatedRuntimeState || actionInteraction.mutatedRuntimeState;
-				if (!actionInteraction.musicPath.empty())
-					jimmyInteraction.musicPath = actionInteraction.musicPath;
-				if (!actionInteraction.nextRoomName.empty())
-					jimmyInteraction.nextRoomName = actionInteraction.nextRoomName;
-				if (actionInteraction.roomTransition != kStartupRoomTransitionNone)
-					jimmyInteraction.roomTransition = actionInteraction.roomTransition;
-				if (!actionInteraction.deathFlicPath.empty())
-					jimmyInteraction.deathFlicPath = actionInteraction.deathFlicPath;
-				if (!actionInteraction.dialogueNpcName.empty())
-					jimmyInteraction.dialogueNpcName = actionInteraction.dialogueNpcName;
-				if (!actionInteraction.dialogueContinuationTag.empty())
-					jimmyInteraction.dialogueContinuationTag = actionInteraction.dialogueContinuationTag;
-				jimmyInteraction.requestMainMenu =
-					jimmyInteraction.requestMainMenu || actionInteraction.requestMainMenu;
-				for (const StartupAudioCommand &command : actionInteraction.audioCommands)
-					jimmyInteraction.audioCommands.push_back(command);
-			}
-			runtime.queueDialogueInteractionIfNeeded(jimmyInteraction);
+			(void)runtime.startupScript().setRuntimeFlagValue(kGivenPaperTodayFlag, true);
+			executeActionTagIfSet("ACTV_HOUSE_EXIT");
+			restoreItemToRah("NEWSPAPER");
 
-			if (!state.paperHandoffStateSet) {
-				state.paperHandoffStateSet = true;
-				lineError = playJimmyLine(0x4a4c, 1);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-			}
+			lineError = playPaperHandoffPreludeIfNeeded();
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 
-			return runtime.getRandomNumber(1) == 0
-				? playJimmyLine(0x4acc, 0)
-				: playJimmyLine(0x4a4b, 1);
+			const bool playJimmyRevisitBark = runtime.getRandomNumber(1) == 0;
+			lineError = playJimmyLine(
+				playJimmyRevisitBark ? 0x4acc : 0x4a4b,
+				playJimmyRevisitBark ? 0 : 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			state.firstNoItemLinePending = false;
+			(void)runtime.startupScript().setRuntimeFlagValue(kJimmyFirstMeetingFlag, true);
+			(void)runtime.startupScript().setRuntimeFlagValue(kGivenPaperTodayFlag, true);
+			return Common::kNoError;
 		}
 		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
@@ -158,19 +233,24 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			} else {
 				(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
 			}
-			return playJimmyLine(0x4b00, 0);
+			return playJimmySequence(kJimmyLedgersAndCorpseEvidenceLines,
+				ARRAYSIZE(kJimmyLedgersAndCorpseEvidenceLines));
 		}
 		if (usedItemName.equalsIgnoreCase("NOTE") ||
 				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
-			return playJimmyLine(0x4b21, 0);
+			return playJimmySequence(kJimmyBlackmailEvidenceLines,
+				ARRAYSIZE(kJimmyBlackmailEvidenceLines));
 		}
 		if (usedItemName.equalsIgnoreCase("SNEAKERS")) {
 			lineError = playJimmyLine(0x4a9e, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+
+			completeSneakersTrade();
+			return Common::kNoError;
 		}
 
 		return playJimmyLine(0x4af2, 0);
@@ -178,18 +258,16 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 	if (state.firstNoItemLinePending) {
 		state.firstNoItemLinePending = false;
-		if (!state.paperHandoffStateSet) {
-			lineError = playJimmyLine(0x4a4c, 1);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
+		lineError = playPaperHandoffPreludeIfNeeded();
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 
 		lineError = playJimmyLine(0x4a58, 0);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
 		(void)runtime.startupScript().setRuntimeFlagValue(kJimmyFirstMeetingFlag, true);
-		(void)runtime.startupScript().setRuntimeFlagValue("GIVEN_PAPER_TODAY", true);
+		(void)runtime.startupScript().setRuntimeFlagValue(kGivenPaperTodayFlag, true);
 
 		int responseIndex = 0;
 		Common::Error responseError = runtime.runResponseMenu(kJimmyFirstResponseLineIndex, responseIndex);
@@ -210,50 +288,9 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
-		responseIndex = 0;
-		responseError = runtime.runResponseMenu(kJimmySecondResponseLineIndex, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
-
-		if (responseIndex == 1) {
-			lineError = playJimmyLine(0x4a86, 1);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-
-			lineError = playJimmySequence(kJimmySecondResponsePreludeLines,
-				ARRAYSIZE(kJimmySecondResponsePreludeLines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-
-			int thirdResponseIndex = 0;
-			responseError = runtime.runResponseMenu(kJimmyThirdResponseLineIndex, thirdResponseIndex);
-			if (responseError.getCode() != Common::kNoError)
-				return responseError;
-
-			if (thirdResponseIndex == 1) {
-				if (hasInventoryItem("SNEAKERS")) {
-					lineError = playJimmyLine(0x4a9e, 1);
-					if (lineError.getCode() != Common::kNoError)
-						return lineError;
-
-					(void)runtime.startupScript().resetRuntimeObjectToInitialState("SNEAKERS");
-					(void)runtime.startupScript().setRuntimeObjectVisible(kRahRoomName, "SNEAKERS", true);
-					(void)runtime.startupScript().addRuntimeObjectToInventory("BROOMKEY");
-				} else {
-					lineError = playJimmySequence(kJimmyNoSneakersLines, ARRAYSIZE(kJimmyNoSneakersLines));
-					if (lineError.getCode() != Common::kNoError)
-						return lineError;
-				}
-			} else if (thirdResponseIndex == 2) {
-				lineError = playJimmyLine(0x4ab4, 1);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-			}
-		} else if (responseIndex == 2) {
-			lineError = playJimmyLine(0x4abc, 0);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
+		lineError = runJimmySneakersPrompt(kJimmySecondResponseLineIndex);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 
 	return playJimmyNoItemFallback();


Commit: cdd4213b1368870633862af56b91f6bb8b90b941
    https://github.com/scummvm/scummvm/commit/cdd4213b1368870633862af56b91f6bb8b90b941
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:55+02:00

Commit Message:
HARVESTER: Align Mr Potts dialogue with native flow

Changed paths:
    TRACKER.md
    engines/harvester/npc/mr_potts_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index e4e15b1341d..4d4f7698a9f 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Last Confirmed Action
 
+- On March 22, 2026, reimplemented the ScummVM Mr. Potts dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mr_potts` (`0x26000..0x27d07`). The confirmed source drift was concentrated in native-only control flow that the earlier port had collapsed: the `HARVEST_BLADE` interrupt was missing the response menu plus `MONSTERFY_POTTS` tail, the `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE` interrupt was missing the full five-line exchange, `STEPH_MIDGAME_PLAYED` / `d2eb8` mutation, `C001B.FST`, and `STHLEXIT2B`, the `POTTSDAM_DIGGING` / `POTTSDAM_DIGGING_KARIN` branches were still truncated, every presented-item path was incorrectly falling through into the no-item conversation, the later-day Stephanie-alive revisit had the native day-2 through day-5 `0x2ae5` gate inverted, the `KARIN_KIDNAPED` response-2 path was checking `DAY_6` instead of `STEPH_MIDGAME_PLAYED`, the `KARIN_FOUND_DEAD` follow-up at `0x2b8d..0x2bb6` still used the wrong flag/speakers, and the hidden keyword-loop rewrites and response menus for `AMNESIA`, `MARRIAGE`, `COMMITMENT`, `FATHER`, `SLAUGHTERHOUSE`, `MEAT`, `STEPHANIE`, `LODGE`, `NEWSPAPER`, `MOYNAHAN`, `TOILET`, and `MOLEST` were still over-simplified. Patched [`engines/harvester/npc/mr_potts_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mr_potts_dialogue.cpp) to match those confirmed native branches and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts remain unchanged at `923` total functions and `124` remaining `FUN_*`.
 - On March 22, 2026, reimplemented the ScummVM Jimmy dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_jimmy` (`0x28880..0x28ec1`). The confirmed source drift was all in native control-flow details: the first paper-handoff gate was missing `PC 0x4a52`, later no-item visits with `SNEAKERS` but no `BROOMKEY` were missing the native response menu `0xf8` and shared `0x4a86` / `0x4a8a` / `0x4a8e` branch, direct `SNEAKERS` use failed to return the shoes to `RAH` and award `BROOMKEY`, and the `0x4b00` corpse/ledger plus `0x4b21` blackmail evidence branches had been collapsed to single-line barks instead of the native multi-line exchanges. Patched [`engines/harvester/npc/jimmy_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/jimmy_dialogue.cpp), updated the confirmed Jimmy notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts are unchanged at `923` total functions and `124` remaining `FUN_*`.
 - On March 22, 2026, reran the full durable `repair JIMMY -> wait -> save_program -> verify JIMMY` cycle against the live `HARVEST.LE` session to confirm Jimmy still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_jimmy` rebuilt to `0x28880..0x28ec1` (`1602` address-count, `39` dialogue calls, `27` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_jimmy` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Jimmy was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
 - On March 22, 2026, reimplemented the remaining confirmed Mom dialogue mismatches in ScummVM directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mom` (`0x31140..0x33523`). Raw instruction windows over the native item and hidden-topic tails confirmed that two evidence branches were still over-ported (`CASKET_PHOTO` is only `PC 0x233b`; `PHOTO_OF_WHALEY_HERRILL` is only `MOM 0x239c`), `JIMMY_ATTACKED` uses `PC 0x265f` with head variant `2`, the `POTTSDAM` second-response branch uses `PC 0x20e3` rather than Mom, the `MEAT PLANT` first response is `MOM 0x2106 -> PC 0x210a -> MOM 0x210e`, the `FATHER` keyword sets the native `momFatherTopicState`, and the fallback `0x26c6` path loops back into the keyword menu instead of forcing an immediate goodbye. Patched [`engines/harvester/npc/mom_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mom_dialogue.cpp) to match those confirmed native branches and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts now reflect the current live Ghidra database totals (`923` functions, `124` remaining `FUN_*`).
@@ -52,6 +53,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Mr. Potts validation pass that explicitly hits the restored native-only branches: the `HARVEST_BLADE` response menu and `MONSTERFY_POTTS` tail; `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE` to confirm the full `C001B.FST` / `STHLEXIT2B` exit; first and repeat `POTTSDAM_DIGGING`; `POTTSDAM_DIGGING_KARIN`; `MEAT` through both `0x1d4` responses to confirm the reset path; `CASKET_PHOTO`, `PHOTO_OF_WHALEY_HERRILL`, note/checkbook, and TV-deed evidence to verify the restored multi-line exchanges; the later-day Stephanie-alive revisit on day 2 through day 5 to confirm `0x2ae5`; the `KARIN_KIDNAPED` response-2 branch to verify the `STEPH_MIDGAME_PLAYED` gate; the `KARIN_FOUND_DEAD` `0x2b8d..0x2bb6` follow-up; and the topic loop branches for `AMNESIA`, `FATHER`, `SLAUGHTERHOUSE`, `TOILET`, and `MOLEST` to confirm the recovered buffer rewrites and nested menus.
 - Immediate: run an in-engine Jimmy validation pass that explicitly hits the corrected native-only paths: the first no-item visit through response menus `0xf5`, `0xf6`, and `0xf7`; the later no-item `SNEAKERS` revisit through response menu `0xf8`; first and repeat `NEWSPAPER` handoffs to confirm `0x4a4c` / `0x4a52` and the `0x4acc` / `0x4a4b` random bark; direct `SNEAKERS` use to confirm `BROOMKEY` is awarded; and the `LEDGER` / corpse-photo plus note/checkbook evidence exchanges to verify the restored `0x4b19` and `0x4b31` tails.
 - Immediate: run an in-engine Mom validation pass that explicitly hits the now-corrected native-only paths: `CASKET_PHOTO`, `PHOTO_OF_WHALEY_HERRILL`, the `JIMMY_ATTACKED` branch, the `POTTSDAM` second response that reaches `0x20e3`, the `MEAT PLANT` first response (`0x2106 -> 0x210a -> 0x210e`), the `FATHER` topic to confirm `momFatherTopicState` persists through save/load, and the fallback `0x26c6` bark to verify it returns to the keyword loop instead of ending the conversation.
 - Immediate: run an in-engine Sergeant validation pass that explicitly covers the newly restored native tails: `DINER_BURNED` through both `0x2af` and `0x2b0` menus to confirm the `INVITE` / `STEFSKULL` / `DWAYNE_ST_BEDRM` world-state mutations, and `BARBER_POLE` through response menu `0x2b2` with and without `PC_KILLED_ANYONE` set to verify the `0x42a4` / `0x42a8` / `0x42ad` control flow and the day-5 exit fallthrough.
diff --git a/engines/harvester/npc/mr_potts_dialogue.cpp b/engines/harvester/npc/mr_potts_dialogue.cpp
index 727e58c7874..a53af54e28c 100644
--- a/engines/harvester/npc/mr_potts_dialogue.cpp
+++ b/engines/harvester/npc/mr_potts_dialogue.cpp
@@ -23,7 +23,8 @@
 
 #include "harvester/npc/mr_potts_dialogue.h"
 
-#include "common/array.h"
+#include "graphics/screen.h"
+#include "harvester/harvester.h"
 #include "harvester/npc/dialogue_flags.h"
 #include "harvester/npc/dialogue_runtime.h"
 #include "harvester/text.h"
@@ -32,32 +33,99 @@ namespace Harvester {
 
 namespace {
 
+static const char *const kDialogueC001BFstPath = "GRAPHIC/FST/C001B.FST";
 static const char *const kDialogueC016AFstPath = "GRAPHIC/FST/C016A.FST";
+static const char *const kStephHallExitActionTag = "STHLEXIT2B";
+static const char *const kMonsterfyPottsActionTag = "MONSTERFY_POTTS";
+static const char *const kMeatObjectName = "MEAT";
 static const char *const kWeddingMatchesObjectName = "WED_MATCHES";
 static const char *const kMrPottsMoynahanAccusationResponseText =
 	"Mr. Moynahan says you were out and/   about the night she disappeared.";
 static const char *const kMrPottsWeddingMatchesResponseText =
 	"I found one of our wedding match-/   books in the cemetary.";
 
+static const DialogueLineEntry kMrPottsHarvestBladeOpeningLines[] = {
+	{ 0x2f1d, "POTTS_FLESH", 2 },
+	{ 0x2f22, "PC", 4 },
+	{ 0x2f26, "POTTS_FLESH", 2 },
+	{ 0x2f2d, "PC", 0 },
+	{ 0x2f31, "POTTS_FLESH", 2 }
+};
+
+static const DialogueLineEntry kMrPottsStephanieDeadInHallLines[] = {
+	{ 0x2afc, "MR_POTTS", 3 },
+	{ 0x2b00, "PC", 0 },
+	{ 0x2b04, "MR_POTTS", 3 },
+	{ 0x2b09, "PC", 2 },
+	{ 0x2b0d, "MR_POTTS", 3 }
+};
+
+static const DialogueLineEntry kMrPottsPottsdamDiggingIntroLines[] = {
+	{ 0x2c63, "MR_POTTS", 2 },
+	{ 0x2c69, "PC", 0 },
+	{ 0x2c6d, "MR_POTTS", 4 }
+};
+
 static const DialogueLineEntry kMrPottsPottsdamDiggingKarinLines[] = {
 	{ 0x2c33, "MR_POTTS", 2 },
+	{ 0x2c37, "PC", 0 },
+	{ 0x2c3b, "MR_POTTS", 4 },
+	{ 0x2c42, "PC", 0 },
+	{ 0x2c46, "MR_POTTS", 2 },
 	{ 0x2c47, "MR_POTTS", 4 },
 	{ 0x2c48, "MR_POTTS", 4 },
 	{ 0x2c49, "MR_POTTS", 0 },
-	{ 0x2c54, "MR_POTTS", 0 },
+	{ 0x2c54, "PC", 0 },
 	{ 0x2c58, "MR_POTTS", 2 }
 };
 
 static const DialogueLineEntry kMrPottsAuxIntroResponse1Lines[] = {
 	{ 0x2994, "MR_POTTS", 1 },
-	{ 0x2998, "MR_POTTS", 2 },
+	{ 0x2998, "PC", 2 },
 	{ 0x299d, "MR_POTTS", 1 }
 };
 
 static const DialogueLineEntry kMrPottsAuxIntroResponse1WithMomIntroLines[] = {
 	{ 0x29a3, "MR_POTTS", 1 },
-	{ 0x29ab, "MR_POTTS", 1 },
-	{ 0x29b0, "MR_POTTS", 1 }
+	{ 0x29ab, "MRS_POTTS", 1 }
+};
+
+static const DialogueLineEntry kMrPottsAmnesiaTopicLines[] = {
+	{ 0x29c5, "PC", 2 },
+	{ 0x29ca, "MR_POTTS", 4 },
+	{ 0x29cf, "PC", 2 },
+	{ 0x29d3, "MR_POTTS", 4 }
+};
+
+static const DialogueLineEntry kMrPottsMarriageTopicLines[] = {
+	{ 0x29de, "PC", 0 },
+	{ 0x29e2, "MR_POTTS", 3 }
+};
+
+static const DialogueLineEntry kMrPottsCommitmentTopicLines[] = {
+	{ 0x29ee, "PC", 0 },
+	{ 0x29f3, "MR_POTTS", 0 },
+	{ 0x29f4, "MR_POTTS", 4 },
+	{ 0x29f5, "MR_POTTS", 4 },
+	{ 0x29f6, "MR_POTTS", 4 }
+};
+
+static const DialogueLineEntry kMrPottsFatherTopicLines[] = {
+	{ 0x2a12, "MR_POTTS", 4 },
+	{ 0x2a16, "PC", 0 },
+	{ 0x2a1b, "MR_POTTS", 1 },
+	{ 0x2a22, "PC", 0 },
+	{ 0x2a26, "MR_POTTS", 4 }
+};
+
+static const DialogueLineEntry kMrPottsSlaughterhouseTopicClosingLines[] = {
+	{ 0x2a4c, "MR_POTTS", 0 },
+	{ 0x2a55, "MR_POTTS", 1 }
+};
+
+static const DialogueLineEntry kMrPottsLodgeTopicLines[] = {
+	{ 0x2a8b, "PC", 0 },
+	{ 0x2aa2, "MR_POTTS", 1 }
 };
 
 static const DialogueLineEntry kMrPottsKarinKidnapedResponse1Lines[] = {
@@ -67,26 +135,25 @@ static const DialogueLineEntry kMrPottsKarinKidnapedResponse1Lines[] = {
 };
 
 static const DialogueLineEntry kMrPottsKarinKidnapedResponse1NestedResponse2Lines[] = {
-	{ 0x2b4b, "MR_POTTS", 0 },
 	{ 0x2b55, "MR_POTTS", 0 },
-	{ 0x2b5b, "MR_POTTS", 0 },
+	{ 0x2b5b, "PC", 0 },
 	{ 0x2b5f, "MR_POTTS", 1 }
 };
 
-static const DialogueLineEntry kMrPottsKarinFoundAliveLines[] = {
+static const DialogueLineEntry kMrPottsKarinFoundDeadIntroLines[] = {
 	{ 0x2b8d, "MR_POTTS", 3 },
-	{ 0x2b91, "MR_POTTS", 3 }
+	{ 0x2b91, "PC", 3 }
 };
 
-static const DialogueLineEntry kMrPottsKarinFoundAliveWithoutStephMidgameLines[] = {
+static const DialogueLineEntry kMrPottsKarinFoundDeadWithoutStephMidgameLines[] = {
 	{ 0x2b9b, "MR_POTTS", 0 },
-	{ 0x2b9f, "MR_POTTS", 0 },
+	{ 0x2b9f, "PC", 0 },
 	{ 0x2ba4, "MR_POTTS", 0 }
 };
 
-static const DialogueLineEntry kMrPottsKarinFoundAliveWithStephMidgameLines[] = {
-	{ 0x2b96, "MR_POTTS", 3 },
-	{ 0x2b9f, "MR_POTTS", 0 },
+static const DialogueLineEntry kMrPottsKarinFoundDeadWithStephMidgameLines[] = {
+	{ 0x2b96, "PC", 3 },
+	{ 0x2b9f, "PC", 0 },
 	{ 0x2ba4, "MR_POTTS", 0 }
 };
 
@@ -122,14 +189,14 @@ static const DialogueLineEntry kMrPottsPeepholeOpeningLines[] = {
 
 static const DialogueLineEntry kMrPottsPeepholeResponse2Lines[] = {
 	{ 0x2d19, "MR_POTTS", 4 },
-	{ 0x2d1d, "MR_POTTS", 2 },
+	{ 0x2d1d, "PC", 2 },
 	{ 0x2d22, "MR_POTTS", 1 }
 };
 
 static const DialogueLineEntry kMrPottsPeepholeClosingLines[] = {
-	{ 0x2d2c, "MR_POTTS", 0 },
+	{ 0x2d2c, "PC", 0 },
 	{ 0x2d30, "MR_POTTS", 0 },
-	{ 0x2d35, "MR_POTTS", 0 }
+	{ 0x2d35, "PC", 0 }
 };
 
 static const DialogueLineEntry kMrPottsPeepholeFollowupLines[] = {
@@ -137,16 +204,52 @@ static const DialogueLineEntry kMrPottsPeepholeFollowupLines[] = {
 	{ 0x2d3e, "PC", 0 }
 };
 
-static const DialogueLineEntry kMrPottsMolestTopicOpeningLines[] = {
-	{ 0x2def, "MR_POTTS", 4 },
-	{ 0x2e06, "PC", 4 },
+static const DialogueLineEntry kMrPottsMolestResponse1OpeningLines[] = {
+	{ 0x2dfb, "MR_POTTS", 2 },
+	{ 0x2e01, "MR_POTTS", 0 },
+	{ 0x2e06, "PC", 2 },
 	{ 0x2e0a, "MR_POTTS", 0 }
 };
 
-static const DialogueLineEntry kMrPottsMolestTopicResponse2Lines[] = {
+static const DialogueLineEntry kMrPottsMolestResponse2IntroLines[] = {
 	{ 0x2e18, "MR_POTTS", 3 },
 	{ 0x2e1c, "PC", 2 },
-	{ 0x2e2b, "MR_POTTS", 3 }
+};
+
+static const DialogueLineEntry kMrPottsMolestAlternativeOpeningLines[] = {
+	{ 0x2e3a, "MR_POTTS", 1 },
+	{ 0x2e3e, "PC", 0 },
+	{ 0x2e42, "MR_POTTS", 0 },
+	{ 0x2e46, "PC", 2 },
+	{ 0x2e4a, "MR_POTTS", 1 }
+};
+
+static const DialogueLineEntry kMrPottsCasketPhotoLines[] = {
+	{ 0x2c97, "MR_POTTS", 0 },
+	{ 0x2c9b, "PC", 2 },
+	{ 0x2ca0, "MR_POTTS", 0 }
+};
+
+static const DialogueLineEntry kMrPottsCasketPhotoFollowupLines[] = {
+	{ 0x2ca4, "PC", 2 },
+	{ 0x2cab, "MR_POTTS", 0 }
+};
+
+static const DialogueLineEntry kMrPottsWhaleyHerrillPhotoLines[] = {
+	{ 0x2cb3, "MR_POTTS", 1 },
+	{ 0x2cb8, "PC", 1 },
+	{ 0x2cbd, "MR_POTTS", 0 }
+};
+
+static const DialogueLineEntry kMrPottsBlackmailEvidenceLines[] = {
+	{ 0x2ce0, "MR_POTTS", 4 },
+	{ 0x2ce6, "PC", 0 }
+};
+
+static const DialogueLineEntry kMrPottsTvDeedEvidenceLines[] = {
+	{ 0x2ce0, "MR_POTTS", 4 },
+	{ 0x2ceb, "PC", 0 },
+	{ 0x2cf0, "MR_POTTS", 2 }
 };
 
 static const int kMrPottsMurderTopicResponseLines[] = { 0x20c, 0x20d, 0x20e };
@@ -198,13 +301,15 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.playDialogueEntrySequence(lines, count);
 	};
 	auto hasInventoryItem = [&](const char *objectName) {
-		Common::Array<StartupObjectRecord> inventoryObjects;
-		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
-		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
-			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
-				return true;
-		}
-		return false;
+		return runtime.startupScript().isObjectInInventory(objectName);
+	};
+	auto executeActionTagIfSet = [&](const char *tag) {
+		StartupInteractionResult interaction;
+		if (!runtime.startupScript().executeActionTag(tag, interaction))
+			return;
+
+		runtime.applyImmediateDialogueInteractionEffects(interaction);
+		runtime.queueDialogueInteractionIfNeeded(interaction);
 	};
 	auto queueNpcDeathOrMonsterfyTransition = [&](const char *npcName) {
 		StartupInteractionResult interaction;
@@ -212,6 +317,14 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			interaction.mutatedRuntimeState = true;
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 	};
+	auto clearScreenToBlack = [&]() {
+		Graphics::Screen *screen = runtime.engine().getScreen();
+		if (!screen)
+			return;
+
+		screen->fillRect(screen->getBounds(), 0);
+		screen->update();
+	};
 
 	Common::Error lineError = Common::kNoError;
 	const int currentStoryDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
@@ -219,18 +332,52 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 	if (hasInventoryItem("HARVEST_BLADE")) {
 		(void)runtime.startupScript().setRuntimeFlagValue("PC_TALKED_TO_POTTS", true);
-		lineError = playPottsFleshLine(0x2f1d, 2);
+		lineError = playSequence(kMrPottsHarvestBladeOpeningLines,
+			ARRAYSIZE(kMrPottsHarvestBladeOpeningLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x1d2, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playPottsFleshLine(0x2f3d, 4);
+		} else if (responseIndex == 2) {
+			lineError = playPottsFleshLine(0x2f42, 0);
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = playPottsFleshLine(0x2f46, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playPottsFleshLine(0x2f4d, 4);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		return playMrPottsLine(0x2f22, 4);
+
+		executeActionTagIfSet(kMonsterfyPottsActionTag);
+		return Common::kNoError;
 	}
 
 	if (runtime.startupScript().getFlagValue("STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(
 			"STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE", false);
-		lineError = playMrPottsLine(0x2afc, 3);
+		lineError = playSequence(kMrPottsStephanieDeadInHallLines,
+			ARRAYSIZE(kMrPottsStephanieDeadInHallLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+
+		sharedState.dialogueStateD2eb8 = true;
+		(void)runtime.startupScript().setRuntimeFlagValue("STEPH_MIDGAME_PLAYED", true);
+		clearScreenToBlack();
+		lineError = runtime.playDialogueFst(kDialogueC001BFstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		executeActionTagIfSet(kStephHallExitActionTag);
+		return Common::kNoError;
 	}
 
 	if (runtime.startupScript().getFlagValue("POTTSDAM_DIGGING")) {
@@ -239,7 +386,8 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			lineError = runtime.playDialogueFst(kDialogueC016AFstPath);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			lineError = playMrPottsLine(0x2c63, 2);
+			lineError = playSequence(kMrPottsPottsdamDiggingIntroLines,
+				ARRAYSIZE(kMrPottsPottsdamDiggingIntroLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			return Common::kNoError;
@@ -264,37 +412,85 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("MEAT")) {
 			lineError = playMrPottsLine(0x2c7f, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x1d4, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = playMrPottsLine(0x2c8a, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				(void)runtime.startupScript().resetRuntimeObjectToInitialState(kMeatObjectName);
+			} else if (responseIndex == 2) {
+				lineError = playMrPottsLine(0x2c8f, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			return Common::kNoError;
 		} else if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
-			lineError = playMrPottsLine(0x2c97, 0);
+			lineError = playSequence(kMrPottsCasketPhotoLines, ARRAYSIZE(kMrPottsCasketPhotoLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			if (sharedState.dialogueStateD2ebc) {
+				lineError = playSequence(
+					kMrPottsCasketPhotoFollowupLines, ARRAYSIZE(kMrPottsCasketPhotoFollowupLines));
+			}
 		} else if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(
 				DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-			lineError = playMrPottsLine(0x2cb3, 1);
+			lineError = playSequence(
+				kMrPottsWhaleyHerrillPhotoLines, ARRAYSIZE(kMrPottsWhaleyHerrillPhotoLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x1d5, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = playMrPottsLine(0x2cca, 0);
+			} else if (responseIndex == 2) {
+				lineError = playMrPottsLine(0x2ccf, 0);
+			} else if (responseIndex == 3) {
+				lineError = playMrPottsLine(0x2cd4, 0);
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playMrPottsLine(0x2cd9, 0);
 		} else if (usedItemName.equalsIgnoreCase("NOTE") ||
 				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(
 				DialogueFlags::kShownEvidenceOfBlackmail, true);
-			lineError = playMrPottsLine(0x2ce0, 4);
+			lineError = playSequence(
+				kMrPottsBlackmailEvidenceLines, ARRAYSIZE(kMrPottsBlackmailEvidenceLines));
 		} else if (usedItemName.equalsIgnoreCase("TV_DEED") ||
 				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(
 				DialogueFlags::kShownEvidenceSheriffOwns, true);
-			lineError = playMrPottsLine(0x2ce0, 4);
+			lineError = playSequence(
+				kMrPottsTvDeedEvidenceLines, ARRAYSIZE(kMrPottsTvDeedEvidenceLines));
 		} else {
 			lineError = playMrPottsLine(0x2c79, 0);
 		}
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+		return Common::kNoError;
 	}
 
 	if (runtime.startupScript().getFlagValue("IF_TRY_TO_SEE_STEPHANIE")) {
 		lineError = playMrPottsLine(0x2b18, 3);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+		return Common::kNoError;
 	}
 
 	if (state.auxIntroPending && !stephanieIsDead) {
@@ -323,6 +519,9 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 			}
+			lineError = playMrPottsLine(0x29b0, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 
 			assignMrPottsTopicBuffer(
 				runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD") ? 0x1d9 : 0x1d8);
@@ -359,17 +558,17 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				return lineError;
 		}
 
-		if (!runtime.startupScript().getFlagValue("DAY_2") &&
-				!runtime.startupScript().getFlagValue("DAY_3") &&
-				!runtime.startupScript().getFlagValue("DAY_4") &&
-				!runtime.startupScript().getFlagValue("DAY_5") &&
+		if ((runtime.startupScript().getFlagValue("DAY_2") ||
+				runtime.startupScript().getFlagValue("DAY_3") ||
+				runtime.startupScript().getFlagValue("DAY_4") ||
+				runtime.startupScript().getFlagValue("DAY_5")) &&
 				!state.stephanieAliveLaterDayShown) {
 			state.stephanieAliveLaterDayShown = true;
 			lineError = playMrPottsLine(0x2ae5, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			state.followupDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
 		}
+		state.followupDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
 	} else {
 		lineError = playMrPottsLine(0x2bdf, 3);
 		if (lineError.getCode() != Common::kNoError)
@@ -408,8 +607,8 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 
-				int followupResponseIndex = 0;
-				responseError = runtime.runResponseMenu(0x1e1, followupResponseIndex);
+				int ignoredResponseIndex = 0;
+				responseError = runtime.runResponseMenu(0x1e1, ignoredResponseIndex);
 				if (responseError.getCode() != Common::kNoError)
 					return responseError;
 
@@ -419,7 +618,7 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 					return lineError;
 			}
 		} else if (responseIndex == 2) {
-			lineError = runtime.startupScript().getFlagValue("DAY_6")
+			lineError = runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED")
 				? playMrPottsLine(0x2b79, 3)
 				: playMrPottsLine(0x2b65, 0);
 			if (lineError.getCode() != Common::kNoError)
@@ -436,18 +635,18 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return lineError;
 	}
 
-	if (runtime.startupScript().getFlagValue("KARIN") && !state.karinFoundAliveShown) {
+	if (runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") && !state.karinFoundAliveShown) {
 		state.karinFoundAliveShown = true;
-		lineError = playSequence(kMrPottsKarinFoundAliveLines,
-			ARRAYSIZE(kMrPottsKarinFoundAliveLines));
+		lineError = playSequence(kMrPottsKarinFoundDeadIntroLines,
+			ARRAYSIZE(kMrPottsKarinFoundDeadIntroLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
 		lineError = runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED")
-			? playSequence(kMrPottsKarinFoundAliveWithStephMidgameLines,
-				ARRAYSIZE(kMrPottsKarinFoundAliveWithStephMidgameLines))
-			: playSequence(kMrPottsKarinFoundAliveWithoutStephMidgameLines,
-				ARRAYSIZE(kMrPottsKarinFoundAliveWithoutStephMidgameLines));
+			? playSequence(kMrPottsKarinFoundDeadWithStephMidgameLines,
+				ARRAYSIZE(kMrPottsKarinFoundDeadWithStephMidgameLines))
+			: playSequence(kMrPottsKarinFoundDeadWithoutStephMidgameLines,
+				ARRAYSIZE(kMrPottsKarinFoundDeadWithoutStephMidgameLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
@@ -468,8 +667,8 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	}
 
 	if (runtime.startupScript().getFlagValue("DAY_6") &&
-			runtime.startupScript().getFlagValue("KARIN") &&
-			!runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
+			(runtime.startupScript().getFlagValue("KARIN") ||
+				runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD")) &&
 			!state.day6KarinShown) {
 		state.day6KarinShown = true;
 
@@ -653,25 +852,29 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 		if (runtime.matchesResponseLine(selectedTopic, 0x1ea)) {
 			if (!runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD")) {
-				lineError = playMrPottsLine(0x29c5, 2);
+				lineError = playSequence(kMrPottsAmnesiaTopicLines, ARRAYSIZE(kMrPottsAmnesiaTopicLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
+				assignMrPottsTopicBuffer(0x1eb);
 			}
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x1ec)) {
 			if (!runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD")) {
-				lineError = playMrPottsLine(0x29de, 0);
+				lineError = playSequence(kMrPottsMarriageTopicLines, ARRAYSIZE(kMrPottsMarriageTopicLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
+				assignMrPottsTopicBuffer(0x1ed);
 			}
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x1ee)) {
 			if (!runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD")) {
-				lineError = playMrPottsLine(0x29ee, 0);
+				lineError = playSequence(
+					kMrPottsCommitmentTopicLines, ARRAYSIZE(kMrPottsCommitmentTopicLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
+				assignMrPottsTopicBuffer(0x1ef);
 			}
 			continue;
 		}
@@ -685,12 +888,36 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			}
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			lineError = playSequence(kMrPottsFatherTopicLines, ARRAYSIZE(kMrPottsFatherTopicLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMrPottsTopicBuffer(
+				runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD") ? 0x1f2 : 0x1f1);
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x1f3)) {
 			lineError = playMrPottsLine(0x2a31, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x1f4, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = playMrPottsLine(0x2a3c, 2);
+			} else if (responseIndex == 2) {
+				lineError = playMrPottsLine(0x2a42, 1);
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playSequence(kMrPottsSlaughterhouseTopicClosingLines,
+				ARRAYSIZE(kMrPottsSlaughterhouseTopicClosingLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignMrPottsTopicBuffer(
+				runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD") ? 0x1f6 : 0x1f5);
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x1f7)) {
@@ -698,6 +925,7 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				lineError = playMrPottsLine(0x2a5e, 0);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
+				assignMrPottsTopicBuffer(0x1f8);
 			}
 			continue;
 		}
@@ -721,15 +949,17 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				lineError = playMrPottsLine(0x2a81, 0);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
+				assignMrPottsTopicBuffer(0x1fb);
 			}
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x1fd)) {
 			if (!runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD")) {
 				sharedState.discussedLodgeTopic = true;
-				lineError = playMrPottsLine(0x2a8b, 0);
+				lineError = playSequence(kMrPottsLodgeTopicLines, ARRAYSIZE(kMrPottsLodgeTopicLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
+				assignMrPottsTopicBuffer(0x1fe);
 			}
 			continue;
 		}
@@ -737,6 +967,7 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			lineError = playMrPottsLine(0x2aaf, 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			assignMrPottsTopicBuffer(0x200);
 			continue;
 		}
 		if (runtime.matchesAnyResponseLine(selectedTopic, kMrPottsMoynahanTopicResponseLines,
@@ -745,6 +976,7 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				lineError = playMrPottsLine(0x2abb, 0);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
+				assignMrPottsTopicBuffer(0x203);
 			}
 			continue;
 		}
@@ -776,20 +1008,22 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				continue;
 			}
 
-			Common::String accusationMenuLine =
-				runtime.startupText().getDialogueResponseLine(0x20f);
+			Common::String accusationMenuLine = runtime.startupText().getDialogueResponseLine(0x20f);
 			int nextResponseIndex = 2;
 			int moynahanResponseIndex = 0;
 			int weddingMatchesResponseIndex = 0;
 			if (hasMoynahanAccusation) {
 				moynahanResponseIndex = nextResponseIndex++;
-				accusationMenuLine += Common::String::format("/%d. %s",
-					moynahanResponseIndex, kMrPottsMoynahanAccusationResponseText);
+				accusationMenuLine = Common::String::format(
+					runtime.startupText().getDialogueResponseLine(0x210).c_str(),
+					accusationMenuLine.c_str(), kMrPottsMoynahanAccusationResponseText);
 			}
 			if (hasWeddingMatchesAccusation) {
 				weddingMatchesResponseIndex = nextResponseIndex++;
-				accusationMenuLine += Common::String::format("/%d. %s",
-					weddingMatchesResponseIndex, kMrPottsWeddingMatchesResponseText);
+				accusationMenuLine = Common::String::format(
+					runtime.startupText().getDialogueResponseLine(0x212).c_str(),
+					accusationMenuLine.c_str(), weddingMatchesResponseIndex,
+					kMrPottsWeddingMatchesResponseText);
 			}
 
 			int accusationResponseIndex = 0;
@@ -833,46 +1067,83 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x214)) {
 			lineError = playMrPottsLine(0x2dd3, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x215, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = playMrPottsLine(0x2ddf, 1);
+			} else if (responseIndex == 2) {
+				lineError = playMrPottsLine(0x2de4, 0);
+			}
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			continue;
 		}
 		if (runtime.matchesAnyResponseLine(selectedTopic, kMrPottsMolestTopicResponseLines,
 				ARRAYSIZE(kMrPottsMolestTopicResponseLines))) {
-			lineError = playSequence(kMrPottsMolestTopicOpeningLines,
-				ARRAYSIZE(kMrPottsMolestTopicOpeningLines));
+			lineError = playMrPottsLine(0x2def, 4);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
 			int responseIndex = 0;
-			Common::Error responseError = runtime.runResponseMenu(0x21a, responseIndex);
+			Common::Error responseError = runtime.runResponseMenu(0x219, responseIndex);
 			if (responseError.getCode() != Common::kNoError)
 				return responseError;
 
 			if (responseIndex == 1) {
-				lineError = playMrPottsLine(0x2e14, 3);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-			} else if (responseIndex == 2) {
-				lineError = playSequence(kMrPottsMolestTopicResponse2Lines,
-					ARRAYSIZE(kMrPottsMolestTopicResponse2Lines));
+				lineError = playSequence(kMrPottsMolestResponse1OpeningLines,
+					ARRAYSIZE(kMrPottsMolestResponse1OpeningLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 
-				int followupResponseIndex = 0;
-				responseError = runtime.runResponseMenu(0x21b, followupResponseIndex);
+				responseIndex = 0;
+				responseError = runtime.runResponseMenu(0x21a, responseIndex);
 				if (responseError.getCode() != Common::kNoError)
 					return responseError;
 
-				if (followupResponseIndex == 1) {
-					lineError = playMrPottsLine(0x2e2f, 3);
+				if (responseIndex == 1) {
+					lineError = playMrPottsLine(0x2e14, 3);
 					if (lineError.getCode() != Common::kNoError)
 						return lineError;
-				} else if (followupResponseIndex == 2) {
-					lineError = playPcLine(0x2e34, 2);
+				} else if (responseIndex == 2) {
+					lineError = playSequence(kMrPottsMolestResponse2IntroLines,
+						ARRAYSIZE(kMrPottsMolestResponse2IntroLines));
 					if (lineError.getCode() != Common::kNoError)
 						return lineError;
+
+					for (;;) {
+						lineError = playMrPottsLine(0x2e2b, 3);
+						if (lineError.getCode() != Common::kNoError)
+							return lineError;
+
+						int followupResponseIndex = 0;
+						responseError = runtime.runResponseMenu(0x21b, followupResponseIndex);
+						if (responseError.getCode() != Common::kNoError)
+							return responseError;
+
+						if (followupResponseIndex == 1)
+							continue;
+						if (followupResponseIndex == 2) {
+							lineError = playMrPottsLine(0x2e2f, 3);
+							if (lineError.getCode() != Common::kNoError)
+								return lineError;
+							lineError = playPcLine(0x2e34, 2);
+							if (lineError.getCode() != Common::kNoError)
+								return lineError;
+						}
+						break;
+					}
 				}
+			} else if (responseIndex == 2) {
+				lineError = playSequence(kMrPottsMolestAlternativeOpeningLines,
+					ARRAYSIZE(kMrPottsMolestAlternativeOpeningLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
 			}
 			continue;
 		}


Commit: 938cd0adf0c5771dd213aace1edd64201cf1c2ac
    https://github.com/scummvm/scummvm/commit/938cd0adf0c5771dd213aace1edd64201cf1c2ac
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:56+02:00

Commit Message:
HARVESTER: Reimplement Mrs. Potts dialogue

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/mrs_potts_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index b632cfedcfb..b38563ce2b4 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -606,7 +606,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - Phelps's item handling also expands beyond the old stub: `PHOTO_OF_WHALEY_HERRILL` is `0x1c0c` / `0x1c13` / `0x1c17`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` is `0x1c1e` / `0x1c22` / `0x1c27` / `0x1c2c` / `0x1c30`, and `QUARTER` dispatches `GET_PRN_MAG`, plays `0x1ba4` / `0x1bac` / `0x1bb0`, then dispatches `DEL_DRT_MAG`.
 - `handle_talk_to_mrs_potts @ 0x2ee70` is now bounded enough to rebuild from native flow.
   - The non-dead intro branch clears `g_mrs_potts_talk_aux_state_block`, sets the return-visit followup bit, opens zero-based `dialog.rsp` line `0x231`, and seeds keyword buffers `0x232` or `0x233` depending on the response path. Later no-item visits either use the `0x2800` response menu gate or the direct permission branch through `PERMISSION_TO_SEE_STEPHANIE` and `STEF_HALL_EXIT`.
-  - The non-dead event followups are one-shot gates rather than terminal barks: `DAY_5` -> `0x2889`, `BURNED_TV_STATION` -> `0x2891`, `KARIN_KIDNAPED` -> `0x28dc` plus response menu `0x235`, and `DINER_BURNED` -> `0x290e`. After those gates, the keyword loop matches `0x237` through `0x24a`.
+  - The non-dead event followups are one-shot gates rather than terminal barks: `DAY_5` -> `0x2889`, `BURNED_TV_STATION` -> `0x2891`, `KARIN_KIDNAPED` -> `0x28dc` plus response menu `0x235`, and `DINER_BURNED` -> `0x290e` with the wider `0x2914` (`KARIN_HUNG`), `0x2918`, and response menu `0x236` continuation. Response `1` stays in dialogue with `0x2926`; response `2` reuses the shared `0x28b7` / `0x28bd` / `0x28c1` death-in-chair sequence. After those gates, the keyword loop matches `0x237` through `0x24a`.
   - The hidden keyword loop tails are now explicit. `Pearls`, `Bake Sale`, `Wedding`, and `Grounded` expand into `0x2756`..`0x276b`, `0x2775`..`0x2783`, `0x278c`..`0x2799`, and `0x27a3`..`0x27cc`, then reseed keyword buffers `0x239`, `0x23b`, `0x23d`, and `0x240`. The later `Mr. Pottsdam`, `Moynahan`, and `Lodge` topic groups reseed `0x243`, `0x246`, and `0x249`, with the Lodge path explicitly setting `get_set_discussed_lodge_topic` before `0x27ef` / `0x27f3`.
   - The hidden `KARIN_KIDNAPED` response-1 tail `0x28e8` / `0x28ee` / `0x28f2` is the Mrs. Potts Tuesday-night alibi branch: it sets `get_set_discussed_mrs_potts_tuesday_night_alibi`, while response `2` continues through `0x28fb` / `0x2903` / `0x2907` before the handler falls through to later event checks.
   - The `STEPHANIE_IS_DEAD` path is split three ways: `d2eb8 == 0` opens response menu `0x230` after `0x283b`; otherwise Mrs. Potts branches on room `STKITCHN`, then the `GOT_REMAINS_FOR_LODGE` and `TAKEN_INVITE_TO_SERGEANT` one-shots drive response menus `0x22d` and `0x22e`.
diff --git a/TRACKER.md b/TRACKER.md
index 4d4f7698a9f..a9c959d59d7 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,7 +10,10 @@
 
 ## Last Confirmed Action
 
+- On March 22, 2026, reimplemented the remaining confirmed Mrs. Potts dialogue mismatches in ScummVM directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mrs_potts` (`0x2ee70..0x2f7be`) plus its overlapping keyword-tail region at `FUN_0002f7fd` (`0x2f7d8..0x2fc78`). The confirmed source drift was concentrated in two native areas the earlier port had flattened: several visible `play_dialogue_line` calls still used the wrong portrait variants (`0x2726`, `0x281b`, `0x2891`, the `0x294d` evidence bark, the `0x293c..0x2945` Whaley photo sequence, and the `0x289e..0x28c1` remains branches), and the `DINER_BURNED` one-shot was missing the rest of the native continuation (`0x2914` behind `KARIN_HUNG`, `0x2918`, response menu `0x236`, `0x2926`, and the response-2 jump back into the shared death-in-chair tail). Patched [`engines/harvester/npc/mrs_potts_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mrs_potts_dialogue.cpp), updated the confirmed Mrs. Potts notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts remain unchanged at `923` total functions and `124` remaining `FUN_*`.
+- On March 22, 2026, reran the full durable `repair MRS_POTTS -> wait -> save_program -> verify MRS_POTTS` cycle against the live `HARVEST.LE` session to confirm Mrs. Potts still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_mrs_potts` rebuilt to `0x2ee70..0x2fc81` (`2392` address-count during repair; fresh verify reports the saved body as `0x2ee70..0x2f7be` with `54` dialogue calls) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_mrs_potts` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Mrs. Potts was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
 - On March 22, 2026, reimplemented the ScummVM Mr. Potts dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mr_potts` (`0x26000..0x27d07`). The confirmed source drift was concentrated in native-only control flow that the earlier port had collapsed: the `HARVEST_BLADE` interrupt was missing the response menu plus `MONSTERFY_POTTS` tail, the `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE` interrupt was missing the full five-line exchange, `STEPH_MIDGAME_PLAYED` / `d2eb8` mutation, `C001B.FST`, and `STHLEXIT2B`, the `POTTSDAM_DIGGING` / `POTTSDAM_DIGGING_KARIN` branches were still truncated, every presented-item path was incorrectly falling through into the no-item conversation, the later-day Stephanie-alive revisit had the native day-2 through day-5 `0x2ae5` gate inverted, the `KARIN_KIDNAPED` response-2 path was checking `DAY_6` instead of `STEPH_MIDGAME_PLAYED`, the `KARIN_FOUND_DEAD` follow-up at `0x2b8d..0x2bb6` still used the wrong flag/speakers, and the hidden keyword-loop rewrites and response menus for `AMNESIA`, `MARRIAGE`, `COMMITMENT`, `FATHER`, `SLAUGHTERHOUSE`, `MEAT`, `STEPHANIE`, `LODGE`, `NEWSPAPER`, `MOYNAHAN`, `TOILET`, and `MOLEST` were still over-simplified. Patched [`engines/harvester/npc/mr_potts_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mr_potts_dialogue.cpp) to match those confirmed native branches and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts remain unchanged at `923` total functions and `124` remaining `FUN_*`.
+- On March 22, 2026, reran `repair MR_POTTS` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Mr. Potts showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_mr_potts` cleanly to `0x26000..0x27d07` (`6853` address-count, `152` dialogue calls) but the fresh verify fell back to the short `0x26000..0x26915` body with `15` stale dialogue gaps and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Mr. Potts durably clean again at `0x26000..0x27d07` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Mr. Potts belongs in the same post-save retry class as Stephanie, Johnson, Butcher, Dad, and similar handlers.
 - On March 22, 2026, reimplemented the ScummVM Jimmy dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_jimmy` (`0x28880..0x28ec1`). The confirmed source drift was all in native control-flow details: the first paper-handoff gate was missing `PC 0x4a52`, later no-item visits with `SNEAKERS` but no `BROOMKEY` were missing the native response menu `0xf8` and shared `0x4a86` / `0x4a8a` / `0x4a8e` branch, direct `SNEAKERS` use failed to return the shoes to `RAH` and award `BROOMKEY`, and the `0x4b00` corpse/ledger plus `0x4b21` blackmail evidence branches had been collapsed to single-line barks instead of the native multi-line exchanges. Patched [`engines/harvester/npc/jimmy_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/jimmy_dialogue.cpp), updated the confirmed Jimmy notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts are unchanged at `923` total functions and `124` remaining `FUN_*`.
 - On March 22, 2026, reran the full durable `repair JIMMY -> wait -> save_program -> verify JIMMY` cycle against the live `HARVEST.LE` session to confirm Jimmy still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_jimmy` rebuilt to `0x28880..0x28ec1` (`1602` address-count, `39` dialogue calls, `27` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_jimmy` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Jimmy was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
 - On March 22, 2026, reimplemented the remaining confirmed Mom dialogue mismatches in ScummVM directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mom` (`0x31140..0x33523`). Raw instruction windows over the native item and hidden-topic tails confirmed that two evidence branches were still over-ported (`CASKET_PHOTO` is only `PC 0x233b`; `PHOTO_OF_WHALEY_HERRILL` is only `MOM 0x239c`), `JIMMY_ATTACKED` uses `PC 0x265f` with head variant `2`, the `POTTSDAM` second-response branch uses `PC 0x20e3` rather than Mom, the `MEAT PLANT` first response is `MOM 0x2106 -> PC 0x210a -> MOM 0x210e`, the `FATHER` keyword sets the native `momFatherTopicState`, and the fallback `0x26c6` path loops back into the keyword menu instead of forcing an immediate goodbye. Patched [`engines/harvester/npc/mom_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mom_dialogue.cpp) to match those confirmed native branches and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts now reflect the current live Ghidra database totals (`923` functions, `124` remaining `FUN_*`).
@@ -53,6 +56,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Mrs. Potts validation pass that explicitly hits the restored native-only paths: `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `LEDGER` with `HAVE_BOTH_LEDGERS`, and the `GOT_REMAINS_FOR_LODGE` response-1 death branch to confirm the corrected portrait variants; the non-dead revisit gate around `0x281b` / `0x2820` / `0x2825`; and `DINER_BURNED` with and without `KARIN_HUNG`, taking both `0x236` responses to verify the new `0x2914` / `0x2918` / `0x2926` continuation and the response-2 jump into the shared death-in-chair sequence.
 - Immediate: run an in-engine Mr. Potts validation pass that explicitly hits the restored native-only branches: the `HARVEST_BLADE` response menu and `MONSTERFY_POTTS` tail; `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE` to confirm the full `C001B.FST` / `STHLEXIT2B` exit; first and repeat `POTTSDAM_DIGGING`; `POTTSDAM_DIGGING_KARIN`; `MEAT` through both `0x1d4` responses to confirm the reset path; `CASKET_PHOTO`, `PHOTO_OF_WHALEY_HERRILL`, note/checkbook, and TV-deed evidence to verify the restored multi-line exchanges; the later-day Stephanie-alive revisit on day 2 through day 5 to confirm `0x2ae5`; the `KARIN_KIDNAPED` response-2 branch to verify the `STEPH_MIDGAME_PLAYED` gate; the `KARIN_FOUND_DEAD` `0x2b8d..0x2bb6` follow-up; and the topic loop branches for `AMNESIA`, `FATHER`, `SLAUGHTERHOUSE`, `TOILET`, and `MOLEST` to confirm the recovered buffer rewrites and nested menus.
 - Immediate: run an in-engine Jimmy validation pass that explicitly hits the corrected native-only paths: the first no-item visit through response menus `0xf5`, `0xf6`, and `0xf7`; the later no-item `SNEAKERS` revisit through response menu `0xf8`; first and repeat `NEWSPAPER` handoffs to confirm `0x4a4c` / `0x4a52` and the `0x4acc` / `0x4a4b` random bark; direct `SNEAKERS` use to confirm `BROOMKEY` is awarded; and the `LEDGER` / corpse-photo plus note/checkbook evidence exchanges to verify the restored `0x4b19` and `0x4b31` tails.
 - Immediate: run an in-engine Mom validation pass that explicitly hits the now-corrected native-only paths: `CASKET_PHOTO`, `PHOTO_OF_WHALEY_HERRILL`, the `JIMMY_ATTACKED` branch, the `POTTSDAM` second response that reaches `0x20e3`, the `MEAT PLANT` first response (`0x2106 -> 0x210a -> 0x210e`), the `FATHER` topic to confirm `momFatherTopicState` persists through save/load, and the fallback `0x26c6` bark to verify it returns to the keyword loop instead of ending the conversation.
diff --git a/engines/harvester/npc/mrs_potts_dialogue.cpp b/engines/harvester/npc/mrs_potts_dialogue.cpp
index 94d2981287a..269a07c918b 100644
--- a/engines/harvester/npc/mrs_potts_dialogue.cpp
+++ b/engines/harvester/npc/mrs_potts_dialogue.cpp
@@ -37,9 +37,9 @@ static const char *const kStephHallExitActionTag = "STEF_HALL_EXIT";
 static const char *const kPermissionToSeeStephanieFlag = "PERMISSION_TO_SEE_STEPHANIE";
 
 static const DialogueLineEntry kMrsPottsWhaleyLines[] = {
-	{ 0x293c, "MRS_POTTS", 0 },
-	{ 0x2940, "PC", 0 },
-	{ 0x2945, "MRS_POTTS", 0 }
+	{ 0x293c, "MRS_POTTS", 1 },
+	{ 0x2940, "PC", 1 },
+	{ 0x2945, "MRS_POTTS", 1 }
 };
 
 static const DialogueLineEntry kMrsPottsBlackmailLines[] = {
@@ -47,17 +47,17 @@ static const DialogueLineEntry kMrsPottsBlackmailLines[] = {
 	{ 0x2959, "PC", 0 },
 	{ 0x295e, "MRS_POTTS", 0 },
 	{ 0x2962, "PC", 0 },
-	{ 0x2966, "MRS_POTTS", 0 }
+	{ 0x2966, "MRS_POTTS", 1 }
 };
 
 static const DialogueLineEntry kMrsPottsTvDeedLines[] = {
-	{ 0x296e, "MRS_POTTS", 0 },
+	{ 0x296e, "MRS_POTTS", 1 },
 	{ 0x2973, "PC", 0 },
 	{ 0x2977, "MRS_POTTS", 0 }
 };
 
 static const DialogueLineEntry kMrsPottsIntroResponse1Lines[] = {
-	{ 0x2730, "MRS_POTTS", 0 },
+	{ 0x2730, "MRS_POTTS", 2 },
 	{ 0x2735, "PC", 0 },
 	{ 0x273a, "MRS_POTTS", 0 }
 };
@@ -99,15 +99,15 @@ static const DialogueLineEntry kMrsPottsGroundedIntroLines[] = {
 };
 
 static const DialogueLineEntry kMrsPottsGotRemainsIntroLines[] = {
-	{ 0x289e, "MRS_POTTS", 0 },
-	{ 0x28a8, "MRS_POTTS", 0 },
-	{ 0x28ad, "MRS_POTTS", 0 }
+	{ 0x289e, "MRS_POTTS", 2 },
+	{ 0x28a8, "MRS_POTTS", 2 },
+	{ 0x28ad, "MRS_POTTS", 2 }
 };
 
 static const DialogueLineEntry kMrsPottsRemainsDeathLines[] = {
-	{ 0x28b7, "MRS_POTTS", 0 },
+	{ 0x28b7, "MRS_POTTS", 1 },
 	{ 0x28bd, "PC", 0 },
-	{ 0x28c1, "MRS_POTTS", 0 }
+	{ 0x28c1, "MRS_POTTS", 1 }
 };
 
 static const DialogueLineEntry kMrsPottsKarinKidnapedResponse1Lines[] = {
@@ -194,13 +194,13 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
-			return playMrsPottsLine(0x294d);
+			return playMrsPottsLine(0x294d, 3);
 		}
 		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
 				usedItemName.equalsIgnoreCase("LEDGER2")) &&
 				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
-			return playMrsPottsLine(0x294d);
+			return playMrsPottsLine(0x294d, 3);
 		}
 		if (usedItemName.equalsIgnoreCase("NOTE") ||
 				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
@@ -225,7 +225,7 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (runtime.startupScript().getFlagValue("STEPHANIE_IS_DEAD")) {
 		Common::Error lineError = Common::kNoError;
 		if (!sharedState.dialogueStateD2eb8) {
-			lineError = playMrsPottsLine(0x283b);
+			lineError = playMrsPottsLine(0x283b, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
@@ -236,12 +236,12 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (responseIndex == 1)
 				return playMrsPottsLine(0x2846);
 			if (responseIndex == 2)
-				return playMrsPottsLine(0x284b);
+				return playMrsPottsLine(0x284b, 3);
 			return Common::kNoError;
 		}
 
 		if (!runtime.currentRoomName().equalsIgnoreCase("STKITCHN")) {
-			lineError = playMrsPottsLine(0x2854);
+			lineError = playMrsPottsLine(0x2854, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
@@ -250,7 +250,7 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (responseError.getCode() != Common::kNoError)
 				return responseError;
 			if (responseIndex == 1)
-				return playMrsPottsLine(0x285e);
+				return playMrsPottsLine(0x285e, 3);
 			if (responseIndex == 2)
 				return playMrsPottsLine(0x2864);
 			return Common::kNoError;
@@ -271,7 +271,7 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (responseIndex == 1)
 				return playRemainsDeathSequence();
 			if (responseIndex == 2)
-				return playMrsPottsLine(0x28d6);
+				return playMrsPottsLine(0x28d6, 1);
 			return Common::kNoError;
 		}
 
@@ -287,22 +287,22 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (responseError.getCode() != Common::kNoError)
 				return responseError;
 			if (responseIndex == 1) {
-				lineError = playMrsPottsLine(0x2876);
+				lineError = playMrsPottsLine(0x2876, 3);
 			} else if (responseIndex == 2) {
-				lineError = playMrsPottsLine(0x287a);
+				lineError = playMrsPottsLine(0x287a, 3);
 			}
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			return playMrsPottsLine(0x2881);
+			return playMrsPottsLine(0x2881, 3);
 		}
 
-		return playMrsPottsLine(0x2835);
+		return playMrsPottsLine(0x2835, 1);
 	}
 
 	if (state.auxIntroPending) {
 		state.auxIntroPending = false;
 		state.returnVisitFollowupPending = true;
-		Common::Error lineError = playMrsPottsLine(0x2726);
+		Common::Error lineError = playMrsPottsLine(0x2726, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
@@ -327,16 +327,17 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const int storyDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
 		if (storyDayIndex > 1 && storyDayIndex < 6) {
 			state.returnVisitFollowupPending = true;
-			Common::Error lineError = playMrsPottsLine(0x281b, 1);
+			Common::Error lineError = playMrsPottsLine(0x281b, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			lineError = playMrsPottsLine(
-				runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") ? 0x2825 : 0x2820);
+				runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") ? 0x2825 : 0x2820,
+				runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") ? 0 : 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		} else {
 			state.returnVisitFollowupPending = true;
-			Common::Error lineError = playMrsPottsLine(0x282d);
+			Common::Error lineError = playMrsPottsLine(0x282d, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			(void)runtime.startupScript().setRuntimeFlagValue(kPermissionToSeeStephanieFlag, true);
@@ -344,7 +345,7 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 	} else {
 		state.returnVisitFollowupPending = false;
-		Common::Error lineError = playMrsPottsLine(0x2800);
+		Common::Error lineError = playMrsPottsLine(0x2800, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
@@ -353,7 +354,7 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 		if (responseIndex == 1) {
-			lineError = playMrsPottsLine(0x280a);
+			lineError = playMrsPottsLine(0x280a, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			(void)runtime.startupScript().setRuntimeFlagValue(kPermissionToSeeStephanieFlag, true);
@@ -367,14 +368,14 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 	if (runtime.startupScript().getCurrentStoryDayIndex() == 5 && !state.day5LinePlayed) {
 		state.day5LinePlayed = true;
-		Common::Error lineError = playMrsPottsLine(0x2889);
+		Common::Error lineError = playMrsPottsLine(0x2889, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 	if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
 			!state.burnedTvStationLinePlayed) {
 		state.burnedTvStationLinePlayed = true;
-		Common::Error lineError = playMrsPottsLine(0x2891, 1);
+		Common::Error lineError = playMrsPottsLine(0x2891, 3);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
@@ -410,6 +411,28 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		Common::Error lineError = playMrsPottsLine(0x290e, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+
+		if (runtime.startupScript().getFlagValue("KARIN_HUNG")) {
+			lineError = playMrsPottsLine(0x2914, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		lineError = playMrsPottsLine(0x2918);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x236, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 1) {
+			lineError = playMrsPottsLine(0x2926);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			return playRemainsDeathSequence();
+		}
 	}
 
 	Common::String selectedTopic;


Commit: 0925182cd17a982ca573f0ddfbd39219433a4ce6
    https://github.com/scummvm/scummvm/commit/0925182cd17a982ca573f0ddfbd39219433a4ce6
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:56+02:00

Commit Message:
HARVESTER: Sync Loomis dialogue with Ghidra

Changed paths:
    TRACKER.md
    engines/harvester/npc/loomis_dialogue.cpp
    engines/harvester/npc/loomis_dialogue.h


diff --git a/TRACKER.md b/TRACKER.md
index a9c959d59d7..bd9940e9945 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,16 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `923`
-- Named/documented: `799`
-- Remaining `FUN_*`: `124`
-- Remaining undocumented total: `124`
+- Total functions: `912`
+- Named/documented: `792`
+- Remaining `FUN_*`: `120`
+- Remaining undocumented total: `120`
 
 ## Last Confirmed Action
 
+- On March 22, 2026, reimplemented the ScummVM Loomis dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_loomis` (`0x34f80..0x35826`) and `reset_talk_to_loomis_state` (`0x35870..0x3588d`). The confirmed source drift was concentrated in four native areas the earlier port had flattened: the no-item intro path was still treating `0x1112` as unconditional, substituting a nonexistent `0x111e` for native `0x1118`, and skipping response menu `0x53` plus Loomis `0x1130[3]`; revisit conversations were returning before the native keyword loop and therefore missed the `STEPH_MIDGAME_PLAYED` one-shot `0x1230[3]`, the day-5 bark `0x123c`, and the corrected `0x11b0[3]` follow-up; the keyword loop still used the wrong exit/fallback flow (`0x113a` instead of `0x11a1[1]`, no `0x11fc` unknown-topic bark, and no native aliases for `LOOMIS`, `Phelps`, or `Man's Needs`) along with the wrong portrait variants on `0x114f..0x1153`, `0x1162..0x1163`, `0x1170`, and `0x1196`; and the evidence / dirty-magazine item paths were missing the native response menu tails (`0x50 -> 0x1214/0x1219 -> 0x121f[3]` plus `PC_FRY_DADDY_1`) and the corrected `INV_MAG` response variants and room-state mutations. Patched [`engines/harvester/npc/loomis_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/loomis_dialogue.cpp) and [`engines/harvester/npc/loomis_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/loomis_dialogue.h), and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Progress counts now reflect the current live Ghidra database totals (`912` functions, `120` remaining `FUN_*`).
+- On March 22, 2026, reran `repair JOHNSON` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Johnson showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_johnson` cleanly to `0x27dd0..0x28802` (`2539` address-count, `51` dialogue calls) but the fresh verify fell back to the short `0x27dd0..0x287f9` body with `12` stale dialogue gaps and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Johnson durably clean again at `0x27dd0..0x28802` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Johnson belongs in the same post-save retry class as Stephanie, Mr. Potts, Loomis, Butcher, and similar handlers.
+- On March 22, 2026, reran `repair LOOMIS` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Loomis showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_loomis` cleanly to `0x34f80..0x35826` (`2071` address-count, `33` dialogue calls) but the fresh verify fell back to the short `0x34f80..0x35406` body with `9` stale dialogue gaps and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Loomis durably clean again at `0x34f80..0x35826` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Loomis belongs in the same post-save retry class as Stephanie, Mr. Potts, Johnson, Butcher, and similar handlers.
 - On March 22, 2026, reimplemented the remaining confirmed Mrs. Potts dialogue mismatches in ScummVM directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mrs_potts` (`0x2ee70..0x2f7be`) plus its overlapping keyword-tail region at `FUN_0002f7fd` (`0x2f7d8..0x2fc78`). The confirmed source drift was concentrated in two native areas the earlier port had flattened: several visible `play_dialogue_line` calls still used the wrong portrait variants (`0x2726`, `0x281b`, `0x2891`, the `0x294d` evidence bark, the `0x293c..0x2945` Whaley photo sequence, and the `0x289e..0x28c1` remains branches), and the `DINER_BURNED` one-shot was missing the rest of the native continuation (`0x2914` behind `KARIN_HUNG`, `0x2918`, response menu `0x236`, `0x2926`, and the response-2 jump back into the shared death-in-chair tail). Patched [`engines/harvester/npc/mrs_potts_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mrs_potts_dialogue.cpp), updated the confirmed Mrs. Potts notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts remain unchanged at `923` total functions and `124` remaining `FUN_*`.
 - On March 22, 2026, reran the full durable `repair MRS_POTTS -> wait -> save_program -> verify MRS_POTTS` cycle against the live `HARVEST.LE` session to confirm Mrs. Potts still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_mrs_potts` rebuilt to `0x2ee70..0x2fc81` (`2392` address-count during repair; fresh verify reports the saved body as `0x2ee70..0x2f7be` with `54` dialogue calls) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_mrs_potts` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Mrs. Potts was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
 - On March 22, 2026, reimplemented the ScummVM Mr. Potts dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mr_potts` (`0x26000..0x27d07`). The confirmed source drift was concentrated in native-only control flow that the earlier port had collapsed: the `HARVEST_BLADE` interrupt was missing the response menu plus `MONSTERFY_POTTS` tail, the `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE` interrupt was missing the full five-line exchange, `STEPH_MIDGAME_PLAYED` / `d2eb8` mutation, `C001B.FST`, and `STHLEXIT2B`, the `POTTSDAM_DIGGING` / `POTTSDAM_DIGGING_KARIN` branches were still truncated, every presented-item path was incorrectly falling through into the no-item conversation, the later-day Stephanie-alive revisit had the native day-2 through day-5 `0x2ae5` gate inverted, the `KARIN_KIDNAPED` response-2 path was checking `DAY_6` instead of `STEPH_MIDGAME_PLAYED`, the `KARIN_FOUND_DEAD` follow-up at `0x2b8d..0x2bb6` still used the wrong flag/speakers, and the hidden keyword-loop rewrites and response menus for `AMNESIA`, `MARRIAGE`, `COMMITMENT`, `FATHER`, `SLAUGHTERHOUSE`, `MEAT`, `STEPHANIE`, `LODGE`, `NEWSPAPER`, `MOYNAHAN`, `TOILET`, and `MOLEST` were still over-simplified. Patched [`engines/harvester/npc/mr_potts_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mr_potts_dialogue.cpp) to match those confirmed native branches and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts remain unchanged at `923` total functions and `124` remaining `FUN_*`.
@@ -56,6 +59,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Loomis validation pass that explicitly hits the restored native-only branches: first talk with and without `SHERIFF_IN_DINER` to confirm the `0x1112 -> 0x1118 -> 0x1121 -> response 0x53 -> 0x1130[3]` intro; the second and third no-item visits to confirm the one-shot `0x11a8/0x11ac/0x11b0[3]` follow-up, the later `0x11f6` bark, and the `STEPH_MIDGAME_PLAYED` / day-5 `0x1230[3]` and `0x123c` gates; the keyword loop through `LOOMIS`, `Phelps`, `Man's Needs`, sheriff topics, `0x65`, `0x66`, typed unknown text, and goodbye to verify the restored alias matches, portrait variants, `0x11fc` fallback, and `0x11a1[1]` exit; note/checkbook or TV-deed evidence through both `0x50` responses to confirm `0x1214` / `0x1219` / `0x121f[3]` plus `PC_FRY_DADDY_1`; and `INV_MAG` in the diner branch through both `0x51` responses to confirm `0x11c6[1]`, `0x11cc[3]`, drawer visibility, `GAVE_MAG_TO_LOOMIS_TODAY`, and the `C048.FST` tail.
 - Immediate: run an in-engine Mrs. Potts validation pass that explicitly hits the restored native-only paths: `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `LEDGER` with `HAVE_BOTH_LEDGERS`, and the `GOT_REMAINS_FOR_LODGE` response-1 death branch to confirm the corrected portrait variants; the non-dead revisit gate around `0x281b` / `0x2820` / `0x2825`; and `DINER_BURNED` with and without `KARIN_HUNG`, taking both `0x236` responses to verify the new `0x2914` / `0x2918` / `0x2926` continuation and the response-2 jump into the shared death-in-chair sequence.
 - Immediate: run an in-engine Mr. Potts validation pass that explicitly hits the restored native-only branches: the `HARVEST_BLADE` response menu and `MONSTERFY_POTTS` tail; `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE` to confirm the full `C001B.FST` / `STHLEXIT2B` exit; first and repeat `POTTSDAM_DIGGING`; `POTTSDAM_DIGGING_KARIN`; `MEAT` through both `0x1d4` responses to confirm the reset path; `CASKET_PHOTO`, `PHOTO_OF_WHALEY_HERRILL`, note/checkbook, and TV-deed evidence to verify the restored multi-line exchanges; the later-day Stephanie-alive revisit on day 2 through day 5 to confirm `0x2ae5`; the `KARIN_KIDNAPED` response-2 branch to verify the `STEPH_MIDGAME_PLAYED` gate; the `KARIN_FOUND_DEAD` `0x2b8d..0x2bb6` follow-up; and the topic loop branches for `AMNESIA`, `FATHER`, `SLAUGHTERHOUSE`, `TOILET`, and `MOLEST` to confirm the recovered buffer rewrites and nested menus.
 - Immediate: run an in-engine Jimmy validation pass that explicitly hits the corrected native-only paths: the first no-item visit through response menus `0xf5`, `0xf6`, and `0xf7`; the later no-item `SNEAKERS` revisit through response menu `0xf8`; first and repeat `NEWSPAPER` handoffs to confirm `0x4a4c` / `0x4a52` and the `0x4acc` / `0x4a4b` random bark; direct `SNEAKERS` use to confirm `BROOMKEY` is awarded; and the `LEDGER` / corpse-photo plus note/checkbook evidence exchanges to verify the restored `0x4b19` and `0x4b31` tails.
diff --git a/engines/harvester/npc/loomis_dialogue.cpp b/engines/harvester/npc/loomis_dialogue.cpp
index c0d39c0fbf1..815bd2bd229 100644
--- a/engines/harvester/npc/loomis_dialogue.cpp
+++ b/engines/harvester/npc/loomis_dialogue.cpp
@@ -31,8 +31,42 @@ namespace Harvester {
 namespace {
 
 static const int kLoomisSheriffTopicLineIndices[] = { 0x5f, 0x60, 0x61 };
+static const char *const kLoomisNpc = "LOOMIS";
+static const char *const kPcSpeaker = "PC";
+static const char *const kLoomisTopicAlias = "LOOMIS";
+static const char *const kPhelpsTopicAlias = "Phelps";
+static const char *const kMensNeedsTopicAlias = "Man's Needs";
+static const char *const kPcFryDaddyActionTag = "PC_FRY_DADDY_1";
 static const char *const kDialogueC048FstPath = "GRAPHIC/FST/C048.FST";
 
+static const DialogueLineEntry kLoomisIntroOpeningLines[] = {
+	{ 0x10fd, kLoomisNpc, 0 },
+	{ 0x1101, kPcSpeaker, 0 },
+	{ 0x1105, kLoomisNpc, 0 },
+	{ 0x110a, kPcSpeaker, 0 },
+	{ 0x110e, kLoomisNpc, 0 }
+};
+
+static const DialogueLineEntry kLoomisReturnVisitFollowupLines[] = {
+	{ 0x11a8, kLoomisNpc, 1 },
+	{ 0x11ac, kPcSpeaker, 0 },
+	{ 0x11b0, kLoomisNpc, 3 }
+};
+
+static const DialogueLineEntry kLoomisMrsLoomisLines[] = {
+	{ 0x114f, kLoomisNpc, 1 },
+	{ 0x1150, kLoomisNpc, 2 },
+	{ 0x1151, kLoomisNpc, 2 },
+	{ 0x1152, kLoomisNpc, 2 },
+	{ 0x1153, kLoomisNpc, 3 }
+};
+
+static const DialogueLineEntry kLoomisMrsPhelpsLines[] = {
+	{ 0x1161, kLoomisNpc, 0 },
+	{ 0x1162, kLoomisNpc, 3 },
+	{ 0x1163, kLoomisNpc, 3 }
+};
+
 } // End of namespace
 
 bool LoomisDialogueHandler::matchesNpc(const Common::String &npcName) const {
@@ -40,7 +74,7 @@ bool LoomisDialogueHandler::matchesNpc(const Common::String &npcName) const {
 }
 
 Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &) {
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	LoomisRoomDialogueState &state = _state;
 	Common::String &loomisTopicBuffer = state.currentTopicBuffer;
 	int &loomisTopicBufferLineIndex = state.currentTopicBufferLineIndex;
@@ -49,53 +83,80 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		runtime.assignTopicBuffer(loomisTopicBuffer, loomisTopicBufferLineIndex,
 			responseLineIndex, "Loomis topic buffer");
 	};
-	auto playLoomisLine = [&](int wavId, const char *speakerId = "LOOMIS",
+	auto playLoomisLine = [&](int wavId, const char *speakerId = kLoomisNpc,
 			int headVariant = 0) -> Common::Error {
 		return runtime.playDialogueLineWithVariant(wavId, speakerId, headVariant);
 	};
-	auto clearLoomisTopicBuffer = [&]() {
-		loomisTopicBuffer.clear();
-		loomisTopicBufferLineIndex = -1;
+	auto executeLoomisActionTag = [&](const char *tag) {
+		StartupInteractionResult interaction;
+		if (!runtime.startupScript().executeActionTag(tag, interaction))
+			return;
+
+		runtime.applyImmediateDialogueInteractionEffects(interaction);
+		runtime.queueDialogueInteractionIfNeeded(interaction);
 	};
 
 	if (usedItemName.empty()) {
-		if (!state.talkStatePending) {
-			if (state.returnVisitFollowupPending) {
-				state.returnVisitFollowupPending = false;
-				const DialogueLineEntry followupLines[] = {
-					{ 0x11a8, "LOOMIS", 1 },
-					{ 0x11ac, "PC", 0 },
-					{ 0x11b0, "LOOMIS", 0 }
-				};
-				return runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
+		Common::Error lineError = Common::kNoError;
+		if (state.talkStatePending) {
+			state.talkStatePending = false;
+			state.returnVisitFollowupPending = true;
+
+			if (!runtime.startupScript().getFlagValue("DWAYNE_INTRODUCED")) {
+				lineError = runtime.playDialogueEntrySequence(
+					kLoomisIntroOpeningLines, ARRAYSIZE(kLoomisIntroOpeningLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				sharedState.dialogueStateD2f04 = true;
+				if (runtime.startupScript().getFlagValue("SHERIFF_IN_DINER")) {
+					lineError = playLoomisLine(0x1112, kPcSpeaker);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+					lineError = playLoomisLine(0x1118);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				}
 			}
-			return runtime.playDialogueLine(0x11f6, "LOOMIS");
-		}
 
-		state.talkStatePending = false;
-		if (!runtime.startupScript().getFlagValue("DWAYNE_INTRODUCED")) {
-			const DialogueLineEntry introLines[] = {
-				{ 0x10fd, "LOOMIS", 0 },
-				{ 0x1101, "PC", 0 },
-				{ 0x1105, "LOOMIS", 0 },
-				{ 0x110a, "PC", 0 },
-				{ 0x110e, "LOOMIS", 0 },
-				{ 0x1112, "PC", 0 },
-				{ runtime.startupScript().getFlagValue("SHERIFF_IN_DINER") ? 0x1118 : 0x111e, "LOOMIS", 0 },
-				{ 0x1121, "LOOMIS", 0 }
-			};
-			Common::Error lineError = runtime.playDialogueEntrySequence(introLines, ARRAYSIZE(introLines));
+			lineError = playLoomisLine(0x1121);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int ignoredResponseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x53, ignoredResponseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			lineError = playLoomisLine(0x1130, kLoomisNpc, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (state.returnVisitFollowupPending) {
+			state.returnVisitFollowupPending = false;
+			lineError = runtime.playDialogueEntrySequence(
+				kLoomisReturnVisitFollowupLines, ARRAYSIZE(kLoomisReturnVisitFollowupLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		} else {
-			Common::Error lineError = runtime.playDialogueLine(0x1121, "LOOMIS");
+			lineError = playLoomisLine(0x11f6);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 
-		Common::Error lineError = playLoomisLine(0x1130);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
+		if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
+				!state.stephMidgameShown) {
+			state.stephMidgameShown = true;
+			lineError = playLoomisLine(0x1230, kLoomisNpc, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+		if (runtime.startupScript().getCurrentStoryDayIndex() == 5 &&
+				!state.dayFiveShown) {
+			state.dayFiveShown = true;
+			lineError = playLoomisLine(0x123c);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
 
 		assignLoomisTopicBuffer(0x54);
 		for (;;) {
@@ -108,48 +169,38 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				return Common::kNoError;
 
 			if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
-					runtime.matchesResponseLine(selectedTopic, 0x56) ||
-					runtime.matchesResponseLine(selectedTopic, 0x66)) {
-				clearLoomisTopicBuffer();
-				state.returnVisitFollowupPending = true;
-				return playLoomisLine(0x113a);
+					runtime.matchesResponseLine(selectedTopic, 0x56)) {
+				return playLoomisLine(0x11a1, kLoomisNpc, 1);
 			}
 
 			if (runtime.matchesResponseLine(selectedTopic, 0x57)) {
-				lineError = playLoomisLine(0x1143, "LOOMIS", 1);
+				lineError = playLoomisLine(0x1143, kLoomisNpc, 1);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				assignLoomisTopicBuffer(0x58);
 				continue;
 			}
-			if (runtime.matchesResponseLine(selectedTopic, 0x59)) {
-				const DialogueLineEntry mrsLoomisLines[] = {
-					{ 0x114f, "LOOMIS", 0 },
-					{ 0x1150, "LOOMIS", 0 },
-					{ 0x1151, "LOOMIS", 0 },
-					{ 0x1152, "LOOMIS", 0 },
-					{ 0x1153, "LOOMIS", 0 }
-				};
-				lineError = runtime.playDialogueEntrySequence(mrsLoomisLines, ARRAYSIZE(mrsLoomisLines));
+			if (runtime.matchesResponseLine(selectedTopic, 0x59) ||
+					selectedTopic.equalsIgnoreCase(kLoomisTopicAlias)) {
+				lineError = runtime.playDialogueEntrySequence(
+					kLoomisMrsLoomisLines, ARRAYSIZE(kLoomisMrsLoomisLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				assignLoomisTopicBuffer(0x5a);
 				continue;
 			}
-			if (runtime.matchesResponseLine(selectedTopic, 0x5b)) {
-				const DialogueLineEntry mrsPhelpsLines[] = {
-					{ 0x1161, "LOOMIS", 0 },
-					{ 0x1162, "LOOMIS", 0 },
-					{ 0x1163, "LOOMIS", 0 }
-				};
-				lineError = runtime.playDialogueEntrySequence(mrsPhelpsLines, ARRAYSIZE(mrsPhelpsLines));
+			if (runtime.matchesResponseLine(selectedTopic, 0x5b) ||
+					selectedTopic.equalsIgnoreCase(kPhelpsTopicAlias)) {
+				lineError = runtime.playDialogueEntrySequence(
+					kLoomisMrsPhelpsLines, ARRAYSIZE(kLoomisMrsPhelpsLines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				assignLoomisTopicBuffer(0x5c);
 				continue;
 			}
-			if (runtime.matchesResponseLine(selectedTopic, 0x5d)) {
-				lineError = playLoomisLine(0x1170);
+			if (runtime.matchesResponseLine(selectedTopic, 0x5d) ||
+					selectedTopic.equalsIgnoreCase(kMensNeedsTopicAlias)) {
+				lineError = playLoomisLine(0x1170, kLoomisNpc, 1);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				assignLoomisTopicBuffer(0x5e);
@@ -171,11 +222,17 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				continue;
 			}
 			if (runtime.matchesResponseLine(selectedTopic, 0x65)) {
-				lineError = playLoomisLine(0x1196);
+				lineError = playLoomisLine(0x1196, kLoomisNpc, 1);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				continue;
 			}
+			if (runtime.matchesResponseLine(selectedTopic, 0x66))
+				continue;
+
+			lineError = playLoomisLine(0x11fc);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 		}
 	}
 
@@ -186,21 +243,45 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			usedItemName.equalsIgnoreCase("TV_DEED") ||
 			usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
-		return runtime.playDialogueLine(0x1209, "LOOMIS");
+		Common::Error lineError = playLoomisLine(0x1209);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x50, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1) {
+			lineError = playLoomisLine(0x1214);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			lineError = playLoomisLine(0x1219);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+
+		lineError = playLoomisLine(0x121f, kLoomisNpc, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		executeLoomisActionTag(kPcFryDaddyActionTag);
+		return Common::kNoError;
 	}
 	if ((usedItemName.equalsIgnoreCase("LEDGER") ||
 				usedItemName.equalsIgnoreCase("LEDGER2")) &&
 			runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
-		return runtime.playDialogueLine(0x1229, "LOOMIS");
+		return playLoomisLine(0x1229);
 	}
 	if (usedItemName.equalsIgnoreCase("INV_MAG")) {
 		if (!runtime.startupScript().getFlagValue("SHERIFF_IN_DINER")) {
 			(void)runtime.startupScript().addRuntimeObjectToInventory("INV_MAG");
-			return runtime.playDialogueLineWithVariant(0x11db, "LOOMIS", 1);
+			return playLoomisLine(0x11db, kLoomisNpc, 1);
 		}
 
-		Common::Error lineError = runtime.playDialogueLine(0x11bb, "LOOMIS");
+		Common::Error lineError = playLoomisLine(0x11bb);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
@@ -210,16 +291,17 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return responseError;
 
 		if (responseIndex == 1) {
-			bool changed = runtime.startupScript().setRuntimeObjectVisible("INVENTORY", "INV_MAG", false);
-			if (runtime.currentRoomName().equalsIgnoreCase("SHRFOFC")) {
-				changed |= runtime.startupScript().setRuntimeFlagValue("GAVE_MAG_TO_LOOMIS_TODAY", true);
-				changed |= runtime.startupScript().setRuntimeNpcState("LOOMIS", false, false);
-				changed |= runtime.startupScript().setRuntimeObjectVisible("SHRFOFC", "SHERIF_DRAWR", true);
-				changed |= runtime.startupScript().setRuntimeObjectVisible("SHRFOFC", "SHERIF_DRAWR2", false);
-			}
-			lineError = runtime.playDialogueLine(0x11c6, "LOOMIS");
+			lineError = playLoomisLine(0x11c6, kLoomisNpc, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+
+			bool changed = false;
+			changed |= runtime.startupScript().setRuntimeFlagValue("GAVE_MAG_TO_LOOMIS_TODAY", true);
+			changed |= runtime.startupScript().setRuntimeObjectVisible("INVENTORY", "INV_MAG", false);
+			changed |= runtime.startupScript().setRuntimeObjectVisible("SHRFOFC", "SHERIF_DRAWR", true);
+			changed |= runtime.startupScript().setRuntimeObjectVisible("SHRFOFC", "SHERIF_DRAWR2", false);
+			changed |= runtime.startupScript().setRuntimeNpcState("LOOMIS", false, false);
+
 			lineError = runtime.playDialogueFst(kDialogueC048FstPath);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
@@ -231,12 +313,14 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 
 		(void)runtime.startupScript().addRuntimeObjectToInventory("INV_MAG");
-		return runtime.playDialogueLine(0x11cc, "LOOMIS");
+		if (responseIndex == 2)
+			return playLoomisLine(0x11cc, kLoomisNpc, 3);
+		return Common::kNoError;
 	}
 	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL"))
-		return runtime.playDialogueLineWithVariant(0x1143, "LOOMIS", 1);
+		return playLoomisLine(0x1143, kLoomisNpc, 1);
 
-	return runtime.playDialogueLine(0x1202, "LOOMIS");
+	return playLoomisLine(0x1202);
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/loomis_dialogue.h b/engines/harvester/npc/loomis_dialogue.h
index 8d28d9f3809..a340151260a 100644
--- a/engines/harvester/npc/loomis_dialogue.h
+++ b/engines/harvester/npc/loomis_dialogue.h
@@ -33,6 +33,8 @@ public:
 		int currentTopicBufferLineIndex = -1;
 		bool talkStatePending = true;
 		bool returnVisitFollowupPending = false;
+		bool stephMidgameShown = false;
+		bool dayFiveShown = false;
 	};
 
 	bool matchesNpc(const Common::String &npcName) const override;
@@ -42,6 +44,8 @@ public:
 		syncDialogueInt(s, _state.currentTopicBufferLineIndex);
 		syncDialogueBool(s, _state.talkStatePending);
 		syncDialogueBool(s, _state.returnVisitFollowupPending);
+		syncDialogueBool(s, _state.stephMidgameShown);
+		syncDialogueBool(s, _state.dayFiveShown);
 	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;


Commit: 3462828ae89f1aa4073d7a95da023d29c61885c9
    https://github.com/scummvm/scummvm/commit/3462828ae89f1aa4073d7a95da023d29c61885c9
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:56+02:00

Commit Message:
HARVESTER: Reimplement Johnson dialogue handler

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/johnson_dialogue.cpp
    engines/harvester/npc/johnson_dialogue.h
    engines/harvester/saveload.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index b38563ce2b4..a415a8b1939 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -533,11 +533,11 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - The steady-state no-item followups are a linear one-shot bark chain over `STEPH_MIDGAME_PLAYED`, story day `5`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `KARIN_KIDNAPED`, `KARIN_FOUND_ALIVE`, `KARIN_FOUND_DEAD`, `DINER_BURNED`, and the shared Whaley/Herrill-photo evidence flag.
     - Herrill's keyword loop now has a clean branch map: response line `0x283` exits on `0x2ff4`, `0x284` / `0x286` / `0x288` / `0x28a` / `0x28c` / `0x291` each play a single line and loop, `0x28e` sets `get_set_discussed_lodge_topic` before `0x2fdd`, `0x28f` loops silently, and `0x293` exits through `0x3099` then `0x2ff4`.
   - `handle_talk_to_johnson @ 0x27dd0` is now bounded enough for a direct startup-room port.
-    - Johnson has three early short-circuit branches before the normal room flow: `2ND_SCRATCH_TUCKER` returns on `0xbe6`, `TRY_TO_SCRATCH_TUCKER` is a one-shot `0xbe0` bark keyed by the local flag at `0xd2bf8`, and `OPENING_MANHOLE_DAYTIME` also returns on `0xbe0`.
-    - The item path is another straight shared-evidence cluster: `PHOTO_OF_WHALEY_HERRILL` plays `0xb75`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` plays `0xb89`, the dual-ledger case plays `0xba0`, note/checkbook evidence plays `0xbb1`, TV deed evidence plays `0xbce`, and all other presented objects fall back to `0xb6f`.
+    - Johnson has three early short-circuit branches before the normal room flow: `2ND_SCRATCH_TUCKER` returns on `0xbe6`, `TRY_TO_SCRATCH_TUCKER` is a one-shot `0xbe0` bark keyed by the local flag at `0xd2bf8`, and `OPENING_MANHOLE_DAYTIME` plays `0xbe0`, clears that runtime flag, and returns.
+    - The item path is another shared-evidence cluster, but several branches are multi-line rather than single barks: `PHOTO_OF_WHALEY_HERRILL` plays `0xb75` / `PC 0xb7b` / `0xb80`; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` always plays `0xb89`, and when shared state `d2ebc` is set it continues through `PC 0xb90` / `0xb94` / `0xb99`; the dual-ledger case plays `0xba0` / `PC 0xba4` / `0xba8`; note/checkbook evidence plays `0xbb1`; TV deed evidence plays `0xbce`; and all other presented objects fall back to `0xb6f`.
     - The main no-item gate is the Johnson-local intro bit at `0xd2bf4`. When set, Johnson plays `0xa86` / `0xa8a` / `0xa8e` / `0xa97` / `0xa9b`, sets `get_set_shared_dialogue_state_d2f04`, and seeds the keyword buffer from zero-based `dialog.rsp` line `0x176`; otherwise the steady revisit entry is `0xb62`.
-    - `SCRATCHED_TUCKER` has its own one-shot re-entry hook: the first time that flag is seen, Johnson plays `0xc25`, re-arms the intro gate, and then immediately falls back into the normal intro path.
-    - The steady-state no-item bark chain is fully linear and one-shot gated: `STEPH_MIDGAME_PLAYED` -> `0xbf0` / `0xbf1` / `0xbf2`, `KARIN_KIDNAPED` -> `0xbff` / `0xc06` plus another `d2f04` write, `KARIN_FOUND_ALIVE` -> `0xc13` / `0xc17`, `BARBER_POLE_STOLEN` -> `0xc46` / `0xc4d` / `0xc59`, `DINER_BURNED` -> `0xc6f`, `PC_ESCAPED_JAIL` -> `0xc77`, `GOT_REMAINS_FOR_LODGE` -> `0xc7f`, and `BURNED_TV_STATION` -> `0xc8f`.
+    - `SCRATCHED_TUCKER` has its own one-shot re-entry hook: the first time that flag is seen, Johnson sets the intro gate back on, plays `0xc25` / `0xc2b` / `0xc30`, and then continues into the later one-shot bark chain without replaying the intro on that same visit. Inside that path only, `BOLT_OF_CLOTH_TAKEN` has its own Johnson-local gate at `0xd2c04` for the additional `0xc5f` / `0xc69` pair.
+    - The steady-state no-item bark chain is fully linear and one-shot gated: `STEPH_MIDGAME_PLAYED` -> `0xbf0` / `0xbf1` / `0xbf2`, `KARIN_KIDNAPED` -> `0xbff` / `0xc06` plus a write to `get_set_shared_karin_kidnaped_dialogue_state`, `KARIN_FOUND_ALIVE` -> `0xc13` / `0xc17`, `BARBER_POLE_STOLEN` -> `0xc46` / `0xc4d` / `0xc59`, `DINER_BURNED` -> `0xc6f`, `PC_ESCAPED_JAIL` -> `0xc77`, `GOT_REMAINS_FOR_LODGE` -> `0xc7f`, and `BURNED_TV_STATION` -> `0xc8f`.
     - Johnson's keyword loop now has a stable branch map: `0x177` exits on `0xbda`, `0x178` / `0x179` play `0xaa6`, open zero-based `dialog.rsp` line `0x17a`, then continue through `0xabd` / `0xac3` / `0xac7` and rewrite the keyword buffer to `0x17b`; `0x17c` plays `0xb17` / `0xb1c` / `0xb20` and rewrites to `0x17d`; `0x17e` plays `0xb2d` and rewrites to `0x17f`; `0x180` / `0x181` / `0x182` play `0xb3a` and rewrite to `0x183`; `0x184` / `0x185` play `0xb46` and rewrite to `0x186`; `0x187` continues through `PC 0xb52` and `JOHNSON 0xb56`, then rewrites to `0x188`; `0x189` loops silently on the current buffer; fallback still uses `0xb69` then `0xbda`.
   - `handle_talk_to_jimmy @ 0x28880` is a compact short-circuit handler with no keyword loop.
     - The first no-item visit clears `g_jimmy_first_no_item_line_pending`, conditionally plays `0x4a4c` / `PC 0x4a52` if `g_jimmy_talk_state_block` is still clear, then always continues through `0x4a58`, sets `PAPER_CHK_1` and `GIVEN_PAPER_TODAY`, and opens three response menus from zero-based `dialog.rsp` lines `0xf5`, `0xf6`, and `0xf7`.
diff --git a/TRACKER.md b/TRACKER.md
index bd9940e9945..c6919741fa1 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Last Confirmed Action
 
+- On March 22, 2026, reimplemented the remaining confirmed Johnson dialogue mismatches in ScummVM directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_johnson` (`0x27dd0..0x28802`). The confirmed source drift was concentrated in four native areas the earlier port had flattened or miswired: the Whaley-photo, casket-photo, and dual-ledger item branches were missing their native follow-up lines and PC speaker turns (`0xb7b`, `0xb80`, `0xb90`, `0xba4`, `0xba8`, plus the conditional `0xb94` / `0xb99` casket tail behind shared state `d2ebc`); the `OPENING_MANHOLE_DAYTIME` short-circuit was not clearing its runtime flag after `0xbe0`; the `SCRATCHED_TUCKER` re-entry was incorrectly dropping straight back into the intro instead of playing the native `0xc25` / `0xc2b` / `0xc30` sequence, preserving the re-armed intro gate for a later visit, and conditionally running Johnson's local `BOLT_OF_CLOTH_TAKEN` one-shot `0xc5f` / `0xc69`; and the `KARIN_KIDNAPED` branch was writing the wrong shared state, using `d2f04` instead of the real shared kidnapped-dialogue bit. Patched [`engines/harvester/npc/johnson_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/johnson_dialogue.cpp), added the missing Johnson-local save bit with versioned serialization in [`engines/harvester/npc/johnson_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/johnson_dialogue.h), bumped Harvester save format version to `5` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp), updated the confirmed Johnson notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts remain aligned with the current live Ghidra database totals (`912` functions, `120` remaining `FUN_*`).
 - On March 22, 2026, reimplemented the ScummVM Loomis dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_loomis` (`0x34f80..0x35826`) and `reset_talk_to_loomis_state` (`0x35870..0x3588d`). The confirmed source drift was concentrated in four native areas the earlier port had flattened: the no-item intro path was still treating `0x1112` as unconditional, substituting a nonexistent `0x111e` for native `0x1118`, and skipping response menu `0x53` plus Loomis `0x1130[3]`; revisit conversations were returning before the native keyword loop and therefore missed the `STEPH_MIDGAME_PLAYED` one-shot `0x1230[3]`, the day-5 bark `0x123c`, and the corrected `0x11b0[3]` follow-up; the keyword loop still used the wrong exit/fallback flow (`0x113a` instead of `0x11a1[1]`, no `0x11fc` unknown-topic bark, and no native aliases for `LOOMIS`, `Phelps`, or `Man's Needs`) along with the wrong portrait variants on `0x114f..0x1153`, `0x1162..0x1163`, `0x1170`, and `0x1196`; and the evidence / dirty-magazine item paths were missing the native response menu tails (`0x50 -> 0x1214/0x1219 -> 0x121f[3]` plus `PC_FRY_DADDY_1`) and the corrected `INV_MAG` response variants and room-state mutations. Patched [`engines/harvester/npc/loomis_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/loomis_dialogue.cpp) and [`engines/harvester/npc/loomis_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/loomis_dialogue.h), and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Progress counts now reflect the current live Ghidra database totals (`912` functions, `120` remaining `FUN_*`).
 - On March 22, 2026, reran `repair JOHNSON` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Johnson showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_johnson` cleanly to `0x27dd0..0x28802` (`2539` address-count, `51` dialogue calls) but the fresh verify fell back to the short `0x27dd0..0x287f9` body with `12` stale dialogue gaps and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Johnson durably clean again at `0x27dd0..0x28802` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Johnson belongs in the same post-save retry class as Stephanie, Mr. Potts, Loomis, Butcher, and similar handlers.
 - On March 22, 2026, reran `repair LOOMIS` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Loomis showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_loomis` cleanly to `0x34f80..0x35826` (`2071` address-count, `33` dialogue calls) but the fresh verify fell back to the short `0x34f80..0x35406` body with `9` stale dialogue gaps and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Loomis durably clean again at `0x34f80..0x35826` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Loomis belongs in the same post-save retry class as Stephanie, Mr. Potts, Johnson, Butcher, and similar handlers.
@@ -59,6 +60,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Johnson validation pass that explicitly hits the restored native-only branches: `OPENING_MANHOLE_DAYTIME` twice to confirm `0xbe0` now clears the flag; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared state `d2ebc`, and `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` to verify the restored `0xb7b`, `0xb80`, `0xb90`, `0xb94`, `0xb99`, `0xba4`, and `0xba8` follow-ups; the first `SCRATCHED_TUCKER` visit with `BOLT_OF_CLOTH_TAKEN` already set to confirm `0xc25` / `0xc2b` / `0xc30` plus `0xc5f` / `0xc69` and that the Johnson intro only replays on the later revisit; and a `KARIN_KIDNAPED` follow-up that later reaches a consumer such as Stephanie to confirm the shared kidnapped-dialogue state now propagates correctly.
 - Immediate: run an in-engine Loomis validation pass that explicitly hits the restored native-only branches: first talk with and without `SHERIFF_IN_DINER` to confirm the `0x1112 -> 0x1118 -> 0x1121 -> response 0x53 -> 0x1130[3]` intro; the second and third no-item visits to confirm the one-shot `0x11a8/0x11ac/0x11b0[3]` follow-up, the later `0x11f6` bark, and the `STEPH_MIDGAME_PLAYED` / day-5 `0x1230[3]` and `0x123c` gates; the keyword loop through `LOOMIS`, `Phelps`, `Man's Needs`, sheriff topics, `0x65`, `0x66`, typed unknown text, and goodbye to verify the restored alias matches, portrait variants, `0x11fc` fallback, and `0x11a1[1]` exit; note/checkbook or TV-deed evidence through both `0x50` responses to confirm `0x1214` / `0x1219` / `0x121f[3]` plus `PC_FRY_DADDY_1`; and `INV_MAG` in the diner branch through both `0x51` responses to confirm `0x11c6[1]`, `0x11cc[3]`, drawer visibility, `GAVE_MAG_TO_LOOMIS_TODAY`, and the `C048.FST` tail.
 - Immediate: run an in-engine Mrs. Potts validation pass that explicitly hits the restored native-only paths: `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `LEDGER` with `HAVE_BOTH_LEDGERS`, and the `GOT_REMAINS_FOR_LODGE` response-1 death branch to confirm the corrected portrait variants; the non-dead revisit gate around `0x281b` / `0x2820` / `0x2825`; and `DINER_BURNED` with and without `KARIN_HUNG`, taking both `0x236` responses to verify the new `0x2914` / `0x2918` / `0x2926` continuation and the response-2 jump into the shared death-in-chair sequence.
 - Immediate: run an in-engine Mr. Potts validation pass that explicitly hits the restored native-only branches: the `HARVEST_BLADE` response menu and `MONSTERFY_POTTS` tail; `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE` to confirm the full `C001B.FST` / `STHLEXIT2B` exit; first and repeat `POTTSDAM_DIGGING`; `POTTSDAM_DIGGING_KARIN`; `MEAT` through both `0x1d4` responses to confirm the reset path; `CASKET_PHOTO`, `PHOTO_OF_WHALEY_HERRILL`, note/checkbook, and TV-deed evidence to verify the restored multi-line exchanges; the later-day Stephanie-alive revisit on day 2 through day 5 to confirm `0x2ae5`; the `KARIN_KIDNAPED` response-2 branch to verify the `STEPH_MIDGAME_PLAYED` gate; the `KARIN_FOUND_DEAD` `0x2b8d..0x2bb6` follow-up; and the topic loop branches for `AMNESIA`, `FATHER`, `SLAUGHTERHOUSE`, `TOILET`, and `MOLEST` to confirm the recovered buffer rewrites and nested menus.
diff --git a/engines/harvester/npc/johnson_dialogue.cpp b/engines/harvester/npc/johnson_dialogue.cpp
index 5869cf71673..f02822c7382 100644
--- a/engines/harvester/npc/johnson_dialogue.cpp
+++ b/engines/harvester/npc/johnson_dialogue.cpp
@@ -30,6 +30,9 @@ namespace Harvester {
 
 namespace {
 
+static const char *const kJohnsonNpc = "JOHNSON";
+static const char *const kPcSpeaker = "PC";
+
 static const int kJohnsonInitialTopicBufferLine = 0x176;
 static const int kJohnsonResponseTopicBufferLine = 0x17b;
 static const int kJohnsonStephTopicBufferLine = 0x17d;
@@ -41,28 +44,51 @@ static const int kJohnsonGenericReplyTopicLines[] = { 0x180, 0x181, 0x182 };
 static const int kJohnsonSecondGenericReplyTopicLines[] = { 0x184, 0x185 };
 
 static const DialogueLineEntry kJohnsonIntroLines[] = {
-	{ 0xa86, "JOHNSON", 1 },
-	{ 0xa8a, "PC", 0 },
-	{ 0xa8e, "JOHNSON", 1 },
-	{ 0xa97, "PC", 0 },
-	{ 0xa9b, "JOHNSON", 0 }
+	{ 0xa86, kJohnsonNpc, 1 },
+	{ 0xa8a, kPcSpeaker, 0 },
+	{ 0xa8e, kJohnsonNpc, 1 },
+	{ 0xa97, kPcSpeaker, 0 },
+	{ 0xa9b, kJohnsonNpc, 0 }
+};
+
+static const DialogueLineEntry kJohnsonWhaleyPhotoLines[] = {
+	{ 0xb75, kJohnsonNpc, 1 },
+	{ 0xb7b, kPcSpeaker, 1 },
+	{ 0xb80, kJohnsonNpc, 1 }
+};
+
+static const DialogueLineEntry kJohnsonLedgerLines[] = {
+	{ 0xba0, kJohnsonNpc, 1 },
+	{ 0xba4, kPcSpeaker, 0 },
+	{ 0xba8, kJohnsonNpc, 1 }
+};
+
+static const DialogueLineEntry kJohnsonScratchedTuckerLines[] = {
+	{ 0xc25, kJohnsonNpc, 2 },
+	{ 0xc2b, kJohnsonNpc, 3 },
+	{ 0xc30, kJohnsonNpc, 2 }
+};
+
+static const DialogueLineEntry kJohnsonBoltOfClothLines[] = {
+	{ 0xc5f, kJohnsonNpc, 3 },
+	{ 0xc69, kJohnsonNpc, 3 }
 };
 
 static const DialogueLineEntry kJohnsonStephMidgameLines[] = {
-	{ 0xbf0, "JOHNSON", 3 },
-	{ 0xbf1, "JOHNSON", 3 },
-	{ 0xbf2, "JOHNSON", 3 }
+	{ 0xbf0, kJohnsonNpc, 3 },
+	{ 0xbf1, kJohnsonNpc, 3 },
+	{ 0xbf2, kJohnsonNpc, 3 }
 };
 
 static const DialogueLineEntry kJohnsonKarinKidnapedLines[] = {
-	{ 0xbff, "JOHNSON", 0 },
-	{ 0xc06, "JOHNSON", 0 }
+	{ 0xbff, kJohnsonNpc, 0 },
+	{ 0xc06, kJohnsonNpc, 0 }
 };
 
 static const DialogueLineEntry kJohnsonBarberPoleStolenLines[] = {
-	{ 0xc46, "JOHNSON", 3 },
-	{ 0xc4d, "JOHNSON", 3 },
-	{ 0xc59, "JOHNSON", 3 }
+	{ 0xc46, kJohnsonNpc, 3 },
+	{ 0xc4d, kJohnsonNpc, 3 },
+	{ 0xc59, kJohnsonNpc, 3 }
 };
 
 } // End of namespace
@@ -82,7 +108,7 @@ Common::Error JohnsonDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			responseLineIndex, "Johnson topic buffer");
 	};
 	auto playJohnsonLine = [&](int wavId, int headVariant = 0) -> Common::Error {
-		return runtime.playDialogueLineWithVariant(wavId, "JOHNSON", headVariant);
+		return runtime.playDialogueLineWithVariant(wavId, kJohnsonNpc, headVariant);
 	};
 
 	if (runtime.startupScript().getFlagValue("2ND_SCRATCH_TUCKER"))
@@ -93,23 +119,43 @@ Common::Error JohnsonDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return playJohnsonLine(0xbe0, 2);
 	}
 	if (runtime.startupScript().getFlagValue("OPENING_MANHOLE_DAYTIME"))
-		return playJohnsonLine(0xbe0, 2);
+	{
+		Common::Error lineError = playJohnsonLine(0xbe0, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		(void)runtime.startupScript().setRuntimeFlagValue("OPENING_MANHOLE_DAYTIME", false);
+		return Common::kNoError;
+	}
 
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-			return playJohnsonLine(0xb75, 1);
+			return runtime.playDialogueEntrySequence(
+				kJohnsonWhaleyPhotoLines, ARRAYSIZE(kJohnsonWhaleyPhotoLines));
 		}
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
-			return playJohnsonLine(0xb89, 1);
+			Common::Error lineError = playJohnsonLine(0xb89, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			if (!sharedState.dialogueStateD2ebc)
+				return Common::kNoError;
+
+			const DialogueLineEntry followupLines[] = {
+				{ 0xb90, kPcSpeaker, 0 },
+				{ 0xb94, kJohnsonNpc, 0 },
+				{ 0xb99, kJohnsonNpc, 0 }
+			};
+			return runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
 		}
 		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
 					usedItemName.equalsIgnoreCase("LEDGER2")) &&
 				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
-			return playJohnsonLine(0xba0, 1);
+			return runtime.playDialogueEntrySequence(
+				kJohnsonLedgerLines, ARRAYSIZE(kJohnsonLedgerLines));
 		}
 		if (usedItemName.equalsIgnoreCase("NOTE") ||
 				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
@@ -131,23 +177,33 @@ Common::Error JohnsonDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			!state.scratchedTuckerReintroShown) {
 		state.scratchedTuckerReintroShown = true;
 		state.introPending = true;
-		Common::Error lineError = playJohnsonLine(0xc25, 2);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-	}
-
-	if (state.introPending) {
-		state.introPending = false;
 		Common::Error lineError = runtime.playDialogueEntrySequence(
-			kJohnsonIntroLines, ARRAYSIZE(kJohnsonIntroLines));
+			kJohnsonScratchedTuckerLines, ARRAYSIZE(kJohnsonScratchedTuckerLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		sharedState.dialogueStateD2f04 = true;
-		assignJohnsonTopicBuffer(kJohnsonInitialTopicBufferLine);
+
+		if (runtime.startupScript().getFlagValue("BOLT_OF_CLOTH_TAKEN") &&
+				!state.boltOfClothTakenShown) {
+			state.boltOfClothTakenShown = true;
+			lineError = runtime.playDialogueEntrySequence(
+				kJohnsonBoltOfClothLines, ARRAYSIZE(kJohnsonBoltOfClothLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
 	} else {
-		Common::Error lineError = playJohnsonLine(0xb62);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
+		if (state.introPending) {
+			state.introPending = false;
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kJohnsonIntroLines, ARRAYSIZE(kJohnsonIntroLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			sharedState.dialogueStateD2f04 = true;
+			assignJohnsonTopicBuffer(kJohnsonInitialTopicBufferLine);
+		} else {
+			Common::Error lineError = playJohnsonLine(0xb62);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
 	}
 
 	if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
@@ -161,7 +217,7 @@ Common::Error JohnsonDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
 			!state.karinKidnapedShown) {
 		state.karinKidnapedShown = true;
-		sharedState.dialogueStateD2f04 = true;
+		sharedState.karinKidnapedDialogueState = true;
 		Common::Error lineError = runtime.playDialogueEntrySequence(
 			kJohnsonKarinKidnapedLines, ARRAYSIZE(kJohnsonKarinKidnapedLines));
 		if (lineError.getCode() != Common::kNoError)
@@ -252,9 +308,9 @@ Common::Error JohnsonDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			}
 
 			const DialogueLineEntry followupLines[] = {
-				{ 0xabd, "JOHNSON", 0 },
-				{ 0xac3, "JOHNSON", 0 },
-				{ 0xac7, "JOHNSON", 2 }
+				{ 0xabd, kJohnsonNpc, 0 },
+				{ 0xac3, kPcSpeaker, 0 },
+				{ 0xac7, kJohnsonNpc, 2 }
 			};
 			lineError = runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
 			if (lineError.getCode() != Common::kNoError)
@@ -265,9 +321,9 @@ Common::Error JohnsonDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x17c)) {
 			const DialogueLineEntry lines[] = {
-				{ 0xb17, "JOHNSON", 0 },
-				{ 0xb1c, "JOHNSON", 0 },
-				{ 0xb20, "JOHNSON", 1 }
+				{ 0xb17, kJohnsonNpc, 0 },
+				{ 0xb1c, kPcSpeaker, 0 },
+				{ 0xb20, kJohnsonNpc, 1 }
 			};
 			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
 			if (lineError.getCode() != Common::kNoError)
diff --git a/engines/harvester/npc/johnson_dialogue.h b/engines/harvester/npc/johnson_dialogue.h
index 39381727dbf..125c3d2ddef 100644
--- a/engines/harvester/npc/johnson_dialogue.h
+++ b/engines/harvester/npc/johnson_dialogue.h
@@ -33,6 +33,7 @@ public:
 		bool tryToScratchTuckerShown = false;
 		bool scratchedTuckerReintroShown = false;
 		bool stephMidgamePlayedShown = false;
+		bool boltOfClothTakenShown = false;
 		bool karinKidnapedShown = false;
 		bool karinFoundAliveShown = false;
 		bool barberPoleStolenShown = false;
@@ -51,6 +52,7 @@ public:
 		syncDialogueBool(s, _state.tryToScratchTuckerShown);
 		syncDialogueBool(s, _state.scratchedTuckerReintroShown);
 		syncDialogueBool(s, _state.stephMidgamePlayedShown);
+		syncDialogueBool(s, _state.boltOfClothTakenShown, 5);
 		syncDialogueBool(s, _state.karinKidnapedShown);
 		syncDialogueBool(s, _state.karinFoundAliveShown);
 		syncDialogueBool(s, _state.barberPoleStolenShown);
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index 32bc0e36fcb..c036811dd03 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -39,7 +39,7 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 }
 
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
-static const uint32 kHarvesterSaveVersion = 4;
+static const uint32 kHarvesterSaveVersion = 5;
 
 static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
 	debugC(1, kDebugGeneral,


Commit: 86ac4569dd19e14cb2cf698625c034e65d0a1460
    https://github.com/scummvm/scummvm/commit/86ac4569dd19e14cb2cf698625c034e65d0a1460
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:57+02:00

Commit Message:
HARVESTER: Reimplement Dwayne dialogue handler

Changed paths:
    TRACKER.md
    engines/harvester/npc/dwayne_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index c6919741fa1..ec33568d81a 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,9 @@
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Dwayne dialogue handler directly against the repaired live `HARVEST.LE` Ghidra body at `handle_talk_to_dwayne` (`0x3a310..0x3c385`) plus `reset_talk_to_dwayne_state` (`0x3c490..0x3c531`). The confirmed source drift was concentrated in five native areas the earlier port had flattened or inverted: the top-level `ARREST_FLAG`, `CHECK_EVIDENCE_DOOR`, `ST_BEDRM`, and sheriff-in-diner intro/revisit flow was returning too early and missing native flag clears plus Edna/PC lines; the evidence-item branches for Boyle's button, Karin's purse, note/checkbook originals vs photocopies, ledgers, corpse photo, Whaley photo, and especially `TV_DEED` / `TV_DEED_PHOTOCOPY` were missing native menus, PC turns, confiscation side effects, and the real-deed vs photocopy split; the note/checkbook and TV-deed follow-up state was not wired to the native save-backed shared fields, so Dwayne's later `0x355f..0x3573` reaction and the Karin-purse gate were wrong after save/load; the one-shot event branches for `BARBER_POLE_STOLEN`, `DINER_BURNED`, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, and `KARIN_FOUND_DEAD` were truncated and skipped their recovered response menus; and the keyword loop still had native speaker/portrait drift on the Loomis, Stephanie-midgame, lodge, and `0x98` topic continuations. Patched [`engines/harvester/npc/dwayne_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dwayne_dialogue.cpp) to match those confirmed branches and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts remain aligned with the current live Ghidra totals (`912` functions, `120` remaining `FUN_*`).
+- On March 23, 2026, reran the full durable `repair KARIN -> wait -> save_program -> verify KARIN` cycle against the live `HARVEST.LE` session to confirm Karin still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_karin` rebuilt to `0x2ea90..0x2ed7f` (`752` address-count, `21` dialogue calls, `14` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_karin` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Karin was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
+- On March 22, 2026, repaired `DWAYNE` durably in the live `HARVEST.LE` Ghidra database with the current `InspectCallTermination.java` workflow. The first `repair DWAYNE` pass expanded `handle_talk_to_dwayne` from the short `0x3a310..0x3c385` body with `59` reachable dialogue calls to `185` calls, but it stalled on one real fallthrough hole at `0x3baa7 -> 0x3baac` where the raw bytes were valid code and Ghidra had misaligned the instruction stream. Cleared and re-disassembled `0x3baac..0x3bb0b`, which restored the missing fallthrough head as `MOV EDX,0xb8c8e`, then reran `repair DWAYNE`; that final pass rebuilt `handle_talk_to_dwayne` cleanly to `0x3a310..0x3c385 (8219 bytes)` with `186` reachable dialogue calls, `gapCount=0`, and both `play_dialogue_line` entry points marked returning. After a short settle, saved `HARVEST.LE` and re-ran `verify DWAYNE`; the fresh verify still reported `status=clean`, so Dwayne is now confirmed durable in the saved database. Progress counts remain aligned with the current live Ghidra totals (`912` functions, `120` remaining `FUN_*`).
 - On March 22, 2026, reimplemented the remaining confirmed Johnson dialogue mismatches in ScummVM directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_johnson` (`0x27dd0..0x28802`). The confirmed source drift was concentrated in four native areas the earlier port had flattened or miswired: the Whaley-photo, casket-photo, and dual-ledger item branches were missing their native follow-up lines and PC speaker turns (`0xb7b`, `0xb80`, `0xb90`, `0xba4`, `0xba8`, plus the conditional `0xb94` / `0xb99` casket tail behind shared state `d2ebc`); the `OPENING_MANHOLE_DAYTIME` short-circuit was not clearing its runtime flag after `0xbe0`; the `SCRATCHED_TUCKER` re-entry was incorrectly dropping straight back into the intro instead of playing the native `0xc25` / `0xc2b` / `0xc30` sequence, preserving the re-armed intro gate for a later visit, and conditionally running Johnson's local `BOLT_OF_CLOTH_TAKEN` one-shot `0xc5f` / `0xc69`; and the `KARIN_KIDNAPED` branch was writing the wrong shared state, using `d2f04` instead of the real shared kidnapped-dialogue bit. Patched [`engines/harvester/npc/johnson_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/johnson_dialogue.cpp), added the missing Johnson-local save bit with versioned serialization in [`engines/harvester/npc/johnson_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/johnson_dialogue.h), bumped Harvester save format version to `5` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp), updated the confirmed Johnson notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts remain aligned with the current live Ghidra database totals (`912` functions, `120` remaining `FUN_*`).
 - On March 22, 2026, reimplemented the ScummVM Loomis dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_loomis` (`0x34f80..0x35826`) and `reset_talk_to_loomis_state` (`0x35870..0x3588d`). The confirmed source drift was concentrated in four native areas the earlier port had flattened: the no-item intro path was still treating `0x1112` as unconditional, substituting a nonexistent `0x111e` for native `0x1118`, and skipping response menu `0x53` plus Loomis `0x1130[3]`; revisit conversations were returning before the native keyword loop and therefore missed the `STEPH_MIDGAME_PLAYED` one-shot `0x1230[3]`, the day-5 bark `0x123c`, and the corrected `0x11b0[3]` follow-up; the keyword loop still used the wrong exit/fallback flow (`0x113a` instead of `0x11a1[1]`, no `0x11fc` unknown-topic bark, and no native aliases for `LOOMIS`, `Phelps`, or `Man's Needs`) along with the wrong portrait variants on `0x114f..0x1153`, `0x1162..0x1163`, `0x1170`, and `0x1196`; and the evidence / dirty-magazine item paths were missing the native response menu tails (`0x50 -> 0x1214/0x1219 -> 0x121f[3]` plus `PC_FRY_DADDY_1`) and the corrected `INV_MAG` response variants and room-state mutations. Patched [`engines/harvester/npc/loomis_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/loomis_dialogue.cpp) and [`engines/harvester/npc/loomis_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/loomis_dialogue.h), and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Progress counts now reflect the current live Ghidra database totals (`912` functions, `120` remaining `FUN_*`).
 - On March 22, 2026, reran `repair JOHNSON` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Johnson showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_johnson` cleanly to `0x27dd0..0x28802` (`2539` address-count, `51` dialogue calls) but the fresh verify fell back to the short `0x27dd0..0x287f9` body with `12` stale dialogue gaps and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Johnson durably clean again at `0x27dd0..0x28802` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Johnson belongs in the same post-save retry class as Stephanie, Mr. Potts, Loomis, Butcher, and similar handlers.
@@ -60,6 +63,8 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Dwayne validation pass that explicitly hits the restored native-only branches: `CHECK_EVIDENCE_DOOR` and the first/second `ST_BEDRM` visits to confirm the new flag clears; the sheriff-in-diner intro and revisit to verify Edna `0x3490/0x3496`, the optional `0x34a0/0x34a4` tail, and the later keyword loop; Boyle's button, Karin's purse, note/checkbook photocopy -> original note/checkbook, both `TV_DEED` and `TV_DEED_PHOTOCOPY` response trees plus the override revisit on the real deed, corpse photo with and without shared state `d2ec8`, and both-ledger handoff with and without `SHERIFF_IN_DINER`; `KARIN_KIDNAPED` through response menus `0x7b/0x7c`, `KARIN_FOUND_DEAD` without the purse through `0x7f/0x80`, and the `PC_HAS_GOOJF_CARD` arrest path with and without `PC_KILLED_ANYONE` to confirm the confiscation and bust follow-up state now matches native.
+- Immediate: resume the sequential talk-handler sweep at `EDNA`, using the proven single-handler workflow `verify <NPC> -> repair <NPC> -> wait ~10s -> save_program -> verify <NPC>` and immediately rerunning the same NPC once when the first saved verify snaps back. After Dwayne, the remaining not-yet-confirmed handlers in that queue are `EDNA`, `PASTORELLI`, `LODGE_CHEF`, `RYDER`, `SPARKY`, `MEMB_DIR`, and `PARSONS`.
 - Immediate: run an in-engine Johnson validation pass that explicitly hits the restored native-only branches: `OPENING_MANHOLE_DAYTIME` twice to confirm `0xbe0` now clears the flag; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared state `d2ebc`, and `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` to verify the restored `0xb7b`, `0xb80`, `0xb90`, `0xb94`, `0xb99`, `0xba4`, and `0xba8` follow-ups; the first `SCRATCHED_TUCKER` visit with `BOLT_OF_CLOTH_TAKEN` already set to confirm `0xc25` / `0xc2b` / `0xc30` plus `0xc5f` / `0xc69` and that the Johnson intro only replays on the later revisit; and a `KARIN_KIDNAPED` follow-up that later reaches a consumer such as Stephanie to confirm the shared kidnapped-dialogue state now propagates correctly.
 - Immediate: run an in-engine Loomis validation pass that explicitly hits the restored native-only branches: first talk with and without `SHERIFF_IN_DINER` to confirm the `0x1112 -> 0x1118 -> 0x1121 -> response 0x53 -> 0x1130[3]` intro; the second and third no-item visits to confirm the one-shot `0x11a8/0x11ac/0x11b0[3]` follow-up, the later `0x11f6` bark, and the `STEPH_MIDGAME_PLAYED` / day-5 `0x1230[3]` and `0x123c` gates; the keyword loop through `LOOMIS`, `Phelps`, `Man's Needs`, sheriff topics, `0x65`, `0x66`, typed unknown text, and goodbye to verify the restored alias matches, portrait variants, `0x11fc` fallback, and `0x11a1[1]` exit; note/checkbook or TV-deed evidence through both `0x50` responses to confirm `0x1214` / `0x1219` / `0x121f[3]` plus `PC_FRY_DADDY_1`; and `INV_MAG` in the diner branch through both `0x51` responses to confirm `0x11c6[1]`, `0x11cc[3]`, drawer visibility, `GAVE_MAG_TO_LOOMIS_TODAY`, and the `C048.FST` tail.
 - Immediate: run an in-engine Mrs. Potts validation pass that explicitly hits the restored native-only paths: `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `LEDGER` with `HAVE_BOTH_LEDGERS`, and the `GOT_REMAINS_FOR_LODGE` response-1 death branch to confirm the corrected portrait variants; the non-dead revisit gate around `0x281b` / `0x2820` / `0x2825`; and `DINER_BURNED` with and without `KARIN_HUNG`, taking both `0x236` responses to verify the new `0x2914` / `0x2918` / `0x2926` continuation and the response-2 jump into the shared death-in-chair sequence.
@@ -68,7 +73,6 @@
 - Immediate: run an in-engine Mom validation pass that explicitly hits the now-corrected native-only paths: `CASKET_PHOTO`, `PHOTO_OF_WHALEY_HERRILL`, the `JIMMY_ATTACKED` branch, the `POTTSDAM` second response that reaches `0x20e3`, the `MEAT PLANT` first response (`0x2106 -> 0x210a -> 0x210e`), the `FATHER` topic to confirm `momFatherTopicState` persists through save/load, and the fallback `0x26c6` bark to verify it returns to the keyword loop instead of ending the conversation.
 - Immediate: run an in-engine Sergeant validation pass that explicitly covers the newly restored native tails: `DINER_BURNED` through both `0x2af` and `0x2b0` menus to confirm the `INVITE` / `STEFSKULL` / `DWAYNE_ST_BEDRM` world-state mutations, and `BARBER_POLE` through response menu `0x2b2` with and without `PC_KILLED_ANYONE` set to verify the `0x42a4` / `0x42a8` / `0x42ad` control flow and the day-5 exit fallthrough.
 - Immediate: run an in-engine Stephanie validation pass that hits the corrected native-only branches: first intro/revisit, the `0x45d8/0x45e2` first-followup tail, `SCRATCHED_TUCKER` response `1 -> 1` to confirm `0x4709` keeps the PC portrait on variant `2`, the Mrs. Potts alibi tail around `0x4984/0x4989/0x498f`, and both Karin-alive outcomes with and without the Dwayne follow-up gate to verify that only the gated path plays `0x49c1` plus `0x49ea/0x49ef`.
-- Immediate: resume the sequential talk-handler sweep at `EDNA`, using the now-proven workflow `repair -> wait ~10s -> save_program -> verify`; when a handler snaps back after the first save, immediately rerun that same NPC once more before moving on. The remaining not-yet-confirmed handlers after the current checkpoint are `EDNA`, `PASTORELLI`, `LODGE_CHEF`, `RYDER`, `SPARKY`, `MEMB_DIR`, `PARSONS`, and `DWAYNE`.
 - Follow-up: after the sweep finishes, fold the reusable gap-fix logic back into `InspectCallTermination.java` itself so it can automatically clear/disassemble exact fallthrough ranges and optionally absorb tiny tail functions instead of requiring ad hoc helper-script intervention for cases like Boyle, Wasp Woman, and Mom.
 - Follow-up: keep an eye out for any additional tail `JMP` dialogue transfers when finishing the remaining NPCs. `HERRILL` proved that some apparent one-gap leftovers are verifier false positives caused by stale `CALL_RETURN` metadata on jump-based dialogue tails rather than missing handler continuations.
 - Immediate: continue the NPC talk-handler sweep one handler at a time with top-level `verify <NPC>` followed by `repair <NPC>` only when needed, and after each successful repair call `save_program` plus a fresh `verify <NPC>` to confirm the saved `HARVEST.LE` state actually held. Keep using single-handler passes; do not resume `repairrange`.
diff --git a/engines/harvester/npc/dwayne_dialogue.cpp b/engines/harvester/npc/dwayne_dialogue.cpp
index dfb2e40b2b2..366fc9f9168 100644
--- a/engines/harvester/npc/dwayne_dialogue.cpp
+++ b/engines/harvester/npc/dwayne_dialogue.cpp
@@ -31,124 +31,316 @@ namespace Harvester {
 
 namespace {
 
+static const char *const kDwayneNpc = "DWAYNE";
+static const char *const kEdnaNpc = "EDNA";
+static const char *const kPcSpeaker = "PC";
+static const char *const kInventoryOwnerName = "INVENTORY";
+static const char *const kRahRoomName = "RAH";
+
 static const int kDwayneWhaleyTopicResponseLines[] = { 0x85, 0x86, 0x87 };
 static const int kDwayneLoomisTopicResponseLines[] = { 0x8c, 0x8d };
 
 } // End of namespace
 
 bool DwayneDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("DWAYNE");
+	return npcName.equalsIgnoreCase(kDwayneNpc);
 }
 
 Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	DwayneRoomDialogueState &state = _state;
+	Script &startupScript = runtime.startupScript();
 	Common::String &dwayneTopicBuffer = state.currentTopicBuffer;
 	int &dwayneTopicBufferLineIndex = state.currentTopicBufferLineIndex;
 	auto assignDwayneTopicBuffer = [&](int responseLineIndex) {
 		runtime.assignTopicBuffer(dwayneTopicBuffer, dwayneTopicBufferLineIndex,
 			responseLineIndex, "Dwayne topic buffer");
 	};
+	auto playSequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
+		return runtime.playDialogueEntrySequence(lines, count);
+	};
 	auto playDwayneLine = [&](int wavId, int headVariant = 0) -> Common::Error {
-		return runtime.playDialogueLineWithVariant(wavId, "DWAYNE", headVariant);
+		return runtime.playDialogueLineWithVariant(wavId, kDwayneNpc, headVariant);
 	};
 	auto playEdnaLine = [&](int wavId, int headVariant = 0) -> Common::Error {
-		return runtime.playDialogueLineWithVariant(wavId, "EDNA", headVariant);
+		return runtime.playDialogueLineWithVariant(wavId, kEdnaNpc, headVariant);
 	};
-	auto hasInventoryItem = [&](const char *objectName) {
-		Common::Array<StartupObjectRecord> inventoryObjects;
-		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
-		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
-			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
-				return true;
-		}
+	auto hideObjectIfPresent = [&](const char *ownerOrRoom, const Common::String &objectName) {
+		if (startupScript.isObjectInInventory(objectName))
+			(void)startupScript.setRuntimeObjectVisible(ownerOrRoom, objectName, false);
+	};
+	auto confiscateSheriffEvidence = [&]() {
+		hideObjectIfPresent(kInventoryOwnerName, "CHECKBOOK");
+		hideObjectIfPresent(kInventoryOwnerName, "CHECKBOOK_PHOTOCOPY");
+		hideObjectIfPresent(kInventoryOwnerName, "NOTE");
+		hideObjectIfPresent(kInventoryOwnerName, "NOTE_PHOTOCOPY");
+		hideObjectIfPresent(kRahRoomName, "TV_DEED");
+		hideObjectIfPresent(kRahRoomName, "TV_DEED_PHOTOCOPY");
+	};
+	auto awardGoojfCard = [&]() {
+		(void)startupScript.addRuntimeObjectToInventory("GOOJF_CARD");
+		(void)startupScript.setRuntimeFlagValue("PC_HAS_GOOJF_CARD", true);
+	};
+	auto executeActionTagIfSet = [&](const char *tag) {
+		StartupInteractionResult interaction;
+		if (!startupScript.executeActionTag(tag, interaction))
+			return;
 
-		return false;
+		runtime.applyImmediateDialogueInteractionEffects(interaction);
+		runtime.queueDialogueInteractionIfNeeded(interaction);
 	};
 	auto setDwayneIntroduced = [&]() {
-		(void)runtime.startupScript().setRuntimeFlagValue("DWAYNE_INTRODUCED", true);
+		(void)startupScript.setRuntimeFlagValue("DWAYNE_INTRODUCED", true);
+	};
+	auto setPendingKarinAliveFollowup = [&]() {
+		state.pendingKarinAliveFollowup = true;
+		state.pendingKarinAliveFollowupDayIndex = startupScript.getCurrentStoryDayIndex();
+		sharedState.dwaynePendingKarinAliveFollowupState = 1;
+	};
+	auto clearGoojfCard = [&]() {
+		(void)startupScript.setRuntimeFlagValue("PC_HAS_GOOJF_CARD", false);
+		if (startupScript.isObjectInInventory("GOOJF_CARD"))
+			(void)startupScript.setRuntimeObjectVisible(kRahRoomName, "GOOJF_CARD", false);
 	};
-	const bool sheriffInDiner = runtime.startupScript().getFlagValue("SHERIFF_IN_DINER");
-
-	if (runtime.startupScript().getFlagValue("ARREST_FLAG")) {
-		(void)runtime.startupScript().setRuntimeFlagValue("ARREST_FLAG", false);
-		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_MOYNAHAN"))
-			return playDwayneLine(0x38d6, 2);
-		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_JIMMY"))
-			return playDwayneLine(0x384d, 2);
-		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_JOHNSON"))
-			return playDwayneLine(0x386d, 2);
-		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_PHELPS"))
-			return playDwayneLine(0x38bc, 2);
-		if (runtime.startupScript().getFlagValue("PC_BUSTED_KILLED_OREILLY"))
-			return playDwayneLine(0x38b5, 2);
-		if (runtime.startupScript().getFlagValue("IF_KILL_POTTSDAM_AT_GRAVE"))
-			return playDwayneLine(0x37ed, 2);
-		if (runtime.startupScript().getFlagValue("GENERIC_BUST"))
-			return playDwayneLine(0x38fc, 2);
-		if (runtime.startupScript().getFlagValue("DNALFT_PERVERT"))
-			return playDwayneLine(0x3580, 2);
-		if (runtime.startupScript().getFlagValue("PC_HAS_GOOJF_CARD") &&
-				!runtime.startupScript().getFlagValue("PC_FRY_IN_CHAIR")) {
-			return playDwayneLine(0x3814);
-		}
-		if (!runtime.startupScript().getFlagValue("PC_FRY_IN_CHAIR")) {
-			if (runtime.startupScript().getFlagValue("BUSTED_THIRD"))
-				return playDwayneLine(0x3928, 2);
-			if (runtime.startupScript().getFlagValue("BUSTED_TWICE"))
-				return playDwayneLine(0x3921, 2);
-			if (runtime.startupScript().getFlagValue("BUSTED_ONCE"))
-				return playDwayneLine(0x3918);
+	const bool sheriffInDiner = startupScript.getFlagValue("SHERIFF_IN_DINER");
+	const int currentStoryDayIndex = startupScript.getCurrentStoryDayIndex();
+
+	if (startupScript.getFlagValue("ARREST_FLAG")) {
+		(void)startupScript.setRuntimeFlagValue("ARREST_FLAG", false);
+
+		if (startupScript.getFlagValue("PC_BUSTED_KILLED_MOYNAHAN")) {
+			Common::Error lineError = playDwayneLine(0x38d6, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			(void)startupScript.setRuntimeFlagValue("PC_BUSTED_KILLED_MOYNAHAN", false);
+		} else if (startupScript.getFlagValue("PC_BUSTED_KILLED_JIMMY")) {
+			Common::Error lineError = playDwayneLine(0x384d, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			(void)startupScript.setRuntimeFlagValue("PC_BUSTED_KILLED_JIMMY", false);
+			(void)startupScript.setRuntimeFlagValue("KILLED_JIMMY", false);
+		} else if (startupScript.getFlagValue("PC_BUSTED_KILLED_JOHNSON")) {
+			Common::Error lineError = playDwayneLine(0x386d, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			(void)startupScript.setRuntimeFlagValue("PC_BUSTED_KILLED_JOHNSON", false);
+		} else if (startupScript.getFlagValue("PC_BUSTED_KILLED_PHELPS")) {
+			Common::Error lineError = playDwayneLine(0x38bc, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			(void)startupScript.setRuntimeFlagValue("PC_BUSTED_KILLED_PHELPS", false);
+		} else if (startupScript.getFlagValue("PC_BUSTED_KILLED_OREILLY")) {
+			Common::Error lineError = playDwayneLine(0x38b5, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			(void)startupScript.setRuntimeFlagValue("PC_BUSTED_KILLED_OREILLY", false);
+			(void)startupScript.setRuntimeFlagValue("KILLED_OREILLY", false);
+		} else if (startupScript.getFlagValue("IF_KILL_POTTSDAM_AT_GRAVE")) {
+			Common::Error lineError = playDwayneLine(0x37ed, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			(void)startupScript.setRuntimeFlagValue("IF_KILL_POTTSDAM_AT_GRAVE", false);
+		} else if (startupScript.getFlagValue("GENERIC_BUST")) {
+			Common::Error lineError = playDwayneLine(0x38fc, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			(void)startupScript.setRuntimeFlagValue("GENERIC_BUST", false);
+		} else if (startupScript.getFlagValue("DNALFT_PERVERT")) {
+			Common::Error lineError = playDwayneLine(0x3580, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			(void)startupScript.setRuntimeFlagValue("DNALFT_PERVERT", false);
 		}
+
+		if (startupScript.getFlagValue("PC_HAS_GOOJF_CARD") &&
+				!startupScript.getFlagValue("PC_FRY_IN_CHAIR")) {
+			Common::Error lineError = playDwayneLine(0x3814);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			if (startupScript.getFlagValue("PC_KILLED_ANYONE")) {
+				const DialogueLineEntry goojfCardLines[] = {
+					{ 0x3820, kDwayneNpc, 0 },
+					{ 0x3824, kPcSpeaker, 0 },
+					{ 0x3828, kDwayneNpc, 0 }
+				};
+				lineError = playSequence(goojfCardLines, ARRAYSIZE(goojfCardLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				int responseIndex = 0;
+				Common::Error responseError = runtime.runResponseMenu(0x69, responseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+				if (responseIndex == 1) {
+					lineError = playDwayneLine(0x3832, 1);
+				} else if (responseIndex == 2) {
+					lineError = playDwayneLine(0x383b, 2);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+					lineError = runtime.playDialogueLineWithVariant(0x3840, kPcSpeaker, 0);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+					lineError = playDwayneLine(0x3844, 2);
+				}
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			clearGoojfCard();
+			return Common::kNoError;
+		}
+
+		if (startupScript.getFlagValue("PC_FRY_IN_CHAIR"))
+			return Common::kNoError;
+		if (startupScript.getFlagValue("BUSTED_THIRD"))
+			return playDwayneLine(0x3928, 2);
+		if (startupScript.getFlagValue("BUSTED_TWICE"))
+			return playDwayneLine(0x3921, 2);
+		if (startupScript.getFlagValue("BUSTED_ONCE"))
+			return playDwayneLine(0x3918);
 		return Common::kNoError;
 	}
 
-	if (runtime.startupScript().getFlagValue("CHECK_EVIDENCE_DOOR"))
-		return playDwayneLine(0x3386);
-	if (runtime.startupScript().getFlagValue("PC_TRIES_TO_TAKE_STEPHANIES_REMAINS"))
+	if (startupScript.getFlagValue("CHECK_EVIDENCE_DOOR")) {
+		Common::Error lineError = playDwayneLine(0x3386);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		(void)startupScript.setRuntimeFlagValue("CHECK_EVIDENCE_DOOR", false);
+		return Common::kNoError;
+	}
+	if (startupScript.getFlagValue("PC_TRIES_TO_TAKE_STEPHANIES_REMAINS"))
 		return playDwayneLine(0x34e3, 2);
 	if (runtime.currentRoomName().equalsIgnoreCase("ST_BEDRM")) {
-		if (!runtime.startupScript().getFlagValue("SD_TALKED_OF_CARD")) {
+		if (!startupScript.getFlagValue("SD_TALKED_OF_CARD")) {
 			Common::Error lineError = playDwayneLine(0x34da);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			(void)startupScript.setRuntimeFlagValue("SD_TALKED_OF_CARD", true);
+			return Common::kNoError;
 		}
 		return playDwayneLine(0x3395);
 	}
 
-	if (runtime.startupScript().getFlagValue("BRING_KARIN_TO_SHERIFF") &&
+	if (startupScript.getFlagValue("BRING_KARIN_TO_SHERIFF") &&
 			!sheriffInDiner &&
 			!state.bringKarinToSheriffLinePlayed) {
-		state.pendingKarinAliveFollowup = true;
-		state.pendingKarinAliveFollowupDayIndex =
-			runtime.startupScript().getCurrentStoryDayIndex();
+		setPendingKarinAliveFollowup();
 		state.bringKarinToSheriffLinePlayed = true;
-		return playDwayneLine(0x3750);
-	}
-	if (runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE")) {
-		state.pendingKarinAliveFollowup = true;
-		state.pendingKarinAliveFollowupDayIndex =
-			runtime.startupScript().getCurrentStoryDayIndex();
+		const DialogueLineEntry lines[] = {
+			{ 0x3750, kDwayneNpc, 1 },
+			{ 0x3754, kPcSpeaker, 0 },
+			{ 0x3759, kDwayneNpc, 0 },
+			{ 0x375d, kDwayneNpc, 1 }
+		};
+		return playSequence(lines, ARRAYSIZE(lines));
 	}
+	if (startupScript.getFlagValue("KARIN_FOUND_ALIVE"))
+		setPendingKarinAliveFollowup();
 
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("BOYLES_BUTTON")) {
 			state.discussedBoylesButton = true;
 			sharedState.dwayneDiscussedBoylesButton = true;
-			return playDwayneLine(0x339b);
+			const DialogueLineEntry lines[] = {
+				{ 0x339b, kDwayneNpc, 0 },
+				{ 0x33a0, kPcSpeaker, 0 },
+				{ 0x33a5, kDwayneNpc, 0 },
+				{ 0x33a9, kPcSpeaker, 0 },
+				{ 0x33ad, kDwayneNpc, 2 }
+			};
+			return playSequence(lines, ARRAYSIZE(lines));
 		}
 		if (usedItemName.equalsIgnoreCase("K_PURSE")) {
 			state.discussedKarinPurse = true;
-			return playDwayneLine(0x37cb);
+			sharedState.discussedKarinPurse = 1;
+			const DialogueLineEntry lines[] = {
+				{ 0x37cb, kDwayneNpc, 1 },
+				{ 0x37cf, kPcSpeaker, 3 },
+				{ 0x37b4, kDwayneNpc, 3 },
+				{ 0x37c1, kDwayneNpc, 0 },
+				{ 0x37c5, kPcSpeaker, 0 }
+			};
+			return playSequence(lines, ARRAYSIZE(lines));
 		}
 		if (usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY") ||
 				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY")) {
-			return playDwayneLine(0x33b9, 2);
+			sharedState.discussedNoteCheckbookEvidence = 1;
+			(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+			Common::Error lineError = playDwayneLine(0x33b9, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x6e, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1) {
+				lineError = playDwayneLine(0x33c3, 2);
+			} else if (responseIndex == 2) {
+				lineError = playDwayneLine(0x33c7, 2);
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			lineError = runtime.playDialogueLineWithVariant(0x33cb, kPcSpeaker, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playDwayneLine(0x33d2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			responseIndex = 0;
+			responseError = runtime.runResponseMenu(0x6f, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1) {
+				lineError = playDwayneLine(0x33e0);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				state.presentedEvidenceReplyOverride = true;
+			} else if (responseIndex == 2) {
+				lineError = playDwayneLine(0x33e7, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				executeActionTagIfSet("PC_FRY_DADDY_1");
+			}
+			return Common::kNoError;
 		}
 		if (usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 				usedItemName.equalsIgnoreCase("NOTE")) {
-			if (state.presentedEvidenceReplyOverride)
-				return playDwayneLine(0x342c);
+			sharedState.discussedNoteCheckbookEvidence = 1;
+			(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+			if (state.presentedEvidenceReplyOverride) {
+				const DialogueLineEntry introLines[] = {
+					{ 0x342c, kDwayneNpc, 0 },
+					{ 0x3430, kPcSpeaker, 0 },
+					{ 0x3435, kDwayneNpc, 2 }
+				};
+				Common::Error lineError = playSequence(introLines, ARRAYSIZE(introLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				int responseIndex = 0;
+				Common::Error responseError = runtime.runResponseMenu(0x72, responseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+				if (responseIndex == 1) {
+					lineError = playDwayneLine(0x3440, 2);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+					executeActionTagIfSet("PC_FRY_DADDY_1");
+					return Common::kNoError;
+				}
+				if (responseIndex == 2) {
+					lineError = playDwayneLine(0x3448);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+					confiscateSheriffEvidence();
+					awardGoojfCard();
+				}
+				return Common::kNoError;
+			}
 
 			Common::Error lineError = playDwayneLine(0x33f4, 2);
 			if (lineError.getCode() != Common::kNoError)
@@ -167,7 +359,7 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
-			lineError = playDwayneLine(0x3407, 2);
+			lineError = runtime.playDialogueLineWithVariant(0x3407, kPcSpeaker, 2);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			lineError = playDwayneLine(0x340d);
@@ -180,36 +372,224 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				return responseError;
 			if (responseIndex == 0)
 				return Common::kNoError;
-			if (responseIndex == 1)
-				return playDwayneLine(0x341a, 2);
-			if (responseIndex == 2)
-				return playDwayneLine(0x3422, 2);
+			if (responseIndex == 1) {
+				lineError = playDwayneLine(0x341a);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				executeActionTagIfSet("PC_FRY_DADDY_1");
+				return Common::kNoError;
+			}
+			if (responseIndex == 2) {
+				lineError = playDwayneLine(0x3422, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				executeActionTagIfSet("PC_FRY_DADDY_1");
+				return Common::kNoError;
+			}
 			return Common::kNoError;
 		}
 		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
 					usedItemName.equalsIgnoreCase("LEDGER2")) &&
-				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
-			return playDwayneLine(0x3452);
+				startupScript.getFlagValue("HAVE_BOTH_LEDGERS")) {
+			sharedState.discussedLedgerEvidence = 1;
+			(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+			const DialogueLineEntry lines[] = {
+				{ 0x3452, kDwayneNpc, 0 },
+				{ 0x3457, kPcSpeaker, 0 },
+				{ 0x345c, kDwayneNpc, 0 },
+				{ 0x3460, kPcSpeaker, 2 },
+				{ 0x3464, kDwayneNpc, 0 },
+				{ 0x346a, kPcSpeaker, 2 },
+				{ 0x346e, kDwayneNpc, 0 },
+				{ 0x3474, kPcSpeaker, 4 },
+				{ 0x3478, kDwayneNpc, 2 }
+			};
+			Common::Error lineError = playSequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			(void)startupScript.resetRuntimeObjectToInitialState("LEDGER");
+			(void)startupScript.resetRuntimeObjectToInitialState("LEDGER2");
+			executeActionTagIfSet(sheriffInDiner ? "CALL_POLICE" : "LEDGER_BUST");
+			return Common::kNoError;
 		}
 		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-			return playDwayneLine(0x3975, 3);
+			sharedState.discussedWhaleyHerrillPhoto = 1;
+			(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			const DialogueLineEntry lines[] = {
+				{ 0x3975, kDwayneNpc, 3 },
+				{ 0x3979, kPcSpeaker, 0 },
+				{ 0x397e, kDwayneNpc, 0 }
+			};
+			return playSequence(lines, ARRAYSIZE(lines));
 		}
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
-			return playDwayneLine(0x3942, 2);
+			sharedState.discussedCasketPhotoEvidence = 1;
+			(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+
+			Common::Error lineError = playDwayneLine(0x3942, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			if (sharedState.dialogueStateD2ec8) {
+				const DialogueLineEntry lines[] = {
+					{ 0x3949, kPcSpeaker, 0 },
+					{ 0x3950, kDwayneNpc, 0 },
+					{ 0x3955, kPcSpeaker, 4 },
+					{ 0x3959, kDwayneNpc, 1 },
+					{ 0x396d, kDwayneNpc, 0 }
+				};
+				lineError = playSequence(lines, ARRAYSIZE(lines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				hideObjectIfPresent(kInventoryOwnerName, usedItemName);
+				executeActionTagIfSet("CALL_POLICE");
+			} else {
+				const DialogueLineEntry lines[] = {
+					{ 0x3961, kPcSpeaker, 0 },
+					{ 0x3967, kDwayneNpc, 0 },
+					{ 0x396d, kDwayneNpc, 0 }
+				};
+				lineError = playSequence(lines, ARRAYSIZE(lines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				hideObjectIfPresent(kInventoryOwnerName, usedItemName);
+			}
+			return Common::kNoError;
 		}
-		if (state.tvDeedReplyOverride &&
-				usedItemName.equalsIgnoreCase("TV_DEED")) {
-			return playDwayneLine(0x3686);
+		if (state.tvDeedReplyOverride && usedItemName.equalsIgnoreCase("TV_DEED")) {
+			sharedState.discussedTvDeedEvidence = 1;
+			(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownEvidenceSheriffOwns, true);
+
+			const DialogueLineEntry introLines[] = {
+				{ 0x3686, kDwayneNpc, 0 },
+				{ 0x368a, kPcSpeaker, 0 },
+				{ 0x3690, kDwayneNpc, 0 }
+			};
+			Common::Error lineError = playSequence(introLines, ARRAYSIZE(introLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x73, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1) {
+				lineError = playDwayneLine(0x369b, 2);
+			} else if (responseIndex == 2) {
+				const DialogueLineEntry responseLines[] = {
+					{ 0x36a3, kDwayneNpc, 0 },
+					{ 0x36a8, kPcSpeaker, 2 },
+					{ 0x36ac, kDwayneNpc, 2 }
+				};
+				lineError = playSequence(responseLines, ARRAYSIZE(responseLines));
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			confiscateSheriffEvidence();
+			awardGoojfCard();
+			return Common::kNoError;
 		}
-		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
-				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
+		if (!usedItemName.equalsIgnoreCase("TV_DEED") &&
+				!usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
 			return playDwayneLine(0x3935);
 		}
-		return playDwayneLine(0x3608);
+
+		sharedState.discussedTvDeedEvidence = 1;
+		(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownEvidenceSheriffOwns, true);
+
+		Common::Error lineError = playDwayneLine(0x3608);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x74, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 1) {
+			lineError = playDwayneLine(0x3613);
+		} else if (responseIndex == 2) {
+			lineError = playDwayneLine(0x3618);
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = playDwayneLine(0x361c);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		responseIndex = 0;
+		responseError = runtime.runResponseMenu(0x75, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 1) {
+			lineError = playDwayneLine(0x3629, 2);
+		} else if (responseIndex == 2) {
+			lineError = playDwayneLine(0x362d);
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		responseIndex = 0;
+		responseError = runtime.runResponseMenu(0x76, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (usedItemName.equalsIgnoreCase("TV_DEED")) {
+			const DialogueLineEntry deedLines[] = {
+				{ 0x363b, kDwayneNpc, 0 },
+				{ 0x3641, kPcSpeaker, 0 },
+				{ 0x3645, kDwayneNpc, 0 }
+			};
+			lineError = playSequence(deedLines, ARRAYSIZE(deedLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			executeActionTagIfSet("PC_FRY_DADDY_1");
+			return Common::kNoError;
+		}
+		if (responseIndex != 1) {
+			lineError = playDwayneLine(0x367b, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			hideObjectIfPresent(kRahRoomName, "TV_DEED_PHOTOCOPY");
+			return Common::kNoError;
+		}
+
+		const DialogueLineEntry copyLines[] = {
+			{ 0x364e, kDwayneNpc, 0 },
+			{ 0x3656, kPcSpeaker, 0 },
+			{ 0x365b, kDwayneNpc, 0 }
+		};
+		lineError = playSequence(copyLines, ARRAYSIZE(copyLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		responseIndex = 0;
+		responseError = runtime.runResponseMenu(0x77, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 1) {
+			lineError = playDwayneLine(0x3665);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			state.tvDeedReplyOverride = true;
+			return Common::kNoError;
+		}
+		if (responseIndex == 2) {
+			const DialogueLineEntry responseLines[] = {
+				{ 0x366b, kDwayneNpc, 3 },
+				{ 0x3670, kPcSpeaker, 2 },
+				{ 0x3674, kDwayneNpc, 2 }
+			};
+			lineError = playSequence(responseLines, ARRAYSIZE(responseLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			executeActionTagIfSet("PC_FRY_DADDY_1");
+		}
+		return Common::kNoError;
 	}
 
 	if (state.sheriffInDinerIntroPending && sheriffInDiner) {
@@ -218,15 +598,15 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		setDwayneIntroduced();
 
 		const DialogueLineEntry introLines[] = {
-			{ 0x3482, "DWAYNE", 0 },
-			{ 0x3486, "PC", 0 },
-			{ 0x348b, "DWAYNE", 1 },
-			{ 0x3490, "PC", 0 }
+			{ 0x3482, kDwayneNpc, 1 },
+			{ 0x3486, kPcSpeaker, 0 },
+			{ 0x348b, kDwayneNpc, 1 },
+			{ 0x3490, kEdnaNpc, 0 }
 		};
-		Common::Error lineError = runtime.playDialogueEntrySequence(introLines, ARRAYSIZE(introLines));
+		Common::Error lineError = playSequence(introLines, ARRAYSIZE(introLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED")) {
+		if (startupScript.getFlagValue("KARIN_KIDNAPED")) {
 			sharedState.karinKidnapedDialogueState = true;
 			lineError = playEdnaLine(0x3496, 3);
 			if (lineError.getCode() != Common::kNoError)
@@ -235,32 +615,40 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		lineError = playDwayneLine(0x349b, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		return playDwayneLine(0x34ac);
+		if (state.pendingInitialConversation) {
+			const DialogueLineEntry initialLines[] = {
+				{ 0x34a0, kPcSpeaker, 0 },
+				{ 0x34a4, kDwayneNpc, 1 }
+			};
+			lineError = playSequence(initialLines, ARRAYSIZE(initialLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
 	}
-
-	if (state.sheriffInDinerIntroPlayed && sheriffInDiner)
-		return playDwayneLine(0x34ac);
-
-	if (state.pendingInitialConversation && !sheriffInDiner) {
+	else if (state.sheriffInDinerIntroPlayed && sheriffInDiner) {
+		Common::Error lineError = playDwayneLine(0x34ac);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	} else if (state.pendingInitialConversation && !sheriffInDiner) {
 		state.pendingInitialConversation = false;
 		state.eventFollowupGate = true;
 		setDwayneIntroduced();
 
 		const DialogueLineEntry initialLines[] = {
-			{ 0x3245, "DWAYNE", 1 },
+			{ 0x3245, kDwayneNpc, 1 },
 			{ 0x324a, "LOOMIS", 1 },
-			{ 0x324e, "PC", 0 },
-			{ 0x3252, "DWAYNE", 0 }
+			{ 0x324e, kPcSpeaker, 0 },
+			{ 0x3252, kDwayneNpc, 0 }
 		};
-		Common::Error lineError = runtime.playDialogueEntrySequence(initialLines, ARRAYSIZE(initialLines));
+		Common::Error lineError = playSequence(initialLines, ARRAYSIZE(initialLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 		assignDwayneTopicBuffer(0x78);
 		if (sharedState.boyleGascanApplicationState)
-			(void)runtime.startupScript().setRuntimeFlagValue("MOVE_SHERIFF", true);
+			(void)startupScript.setRuntimeFlagValue("MOVE_SHERIFF", true);
 	}
 
-	if (state.eventFollowupGate && !sharedState.boyleGascanApplicationState) {
+	if (state.eventFollowupGate) {
 		Common::Error lineError = playDwayneLine(0x34d3, 2);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
@@ -269,7 +657,7 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (sharedState.dwayneWhaleyDisciplineFollowupState &&
 			!state.whaleyDisciplineFollowupShown) {
 		state.whaleyDisciplineFollowupShown = true;
-		Common::Error lineError = playDwayneLine(0x34b3, 1);
+		Common::Error lineError = playDwayneLine(0x34b3);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
@@ -290,110 +678,210 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return lineError;
 	}
 
-	if (runtime.startupScript().getFlagValue(DialogueFlags::kShownEvidenceOfBlackmail) &&
+	if (sharedState.discussedNoteCheckbookEvidence != 0 &&
 			!state.noteCheckbookFollowupShown) {
 		state.noteCheckbookFollowupShown = true;
 		Common::Error lineError = playDwayneLine(0x355f, 2);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+		if (sharedState.discussedTvDeedEvidence != 0) {
+			const DialogueLineEntry lines[] = {
+				{ 0x3565, kDwayneNpc, 2 },
+				{ 0x356a, kDwayneNpc, 2 },
+				{ 0x356f, kPcSpeaker, 2 },
+				{ 0x3573, kDwayneNpc, 0 }
+			};
+			lineError = playSequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			executeActionTagIfSet("PC_FRY_DADDY_1");
+			return Common::kNoError;
+		}
 	}
 
 	if (!sheriffInDiner) {
-		if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER") &&
+		if (startupScript.getFlagValue("SCRATCHED_TUCKER") &&
 				!state.scratchedTuckerShown) {
 			state.scratchedTuckerShown = true;
 			Common::Error lineError = playDwayneLine(0x358b, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
-		if (runtime.startupScript().getFlagValue("BOLT_OF_CLOTH_TAKEN") &&
+		if (startupScript.getFlagValue("BOLT_OF_CLOTH_TAKEN") &&
 				!state.boltOfClothTakenShown) {
 			state.boltOfClothTakenShown = true;
 			Common::Error lineError = playDwayneLine(0x3594, 2);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
-		if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") &&
+		if (startupScript.getFlagValue("BARBER_POLE_STOLEN") &&
 				!state.barberPoleStolenShown) {
 			state.barberPoleStolenShown = true;
-			Common::Error lineError = playDwayneLine(0x359d, 1);
+			const DialogueLineEntry lines[] = {
+				{ 0x359d, kDwayneNpc, 0 },
+				{ 0x35a4, kDwayneNpc, 0 },
+				{ 0x35ab, kDwayneNpc, 0 }
+			};
+			Common::Error lineError = playSequence(lines, ARRAYSIZE(lines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
-		if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
+		if (startupScript.getFlagValue("DINER_BURNED") &&
 				!state.dinerBurnedShown) {
 			state.dinerBurnedShown = true;
 			Common::Error lineError = playDwayneLine(0x35bc, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			if (startupScript.getFlagValue("KARIN_FOUND_ALIVE")) {
+				const DialogueLineEntry lines[] = {
+					{ 0x35c1, kDwayneNpc, 3 },
+					{ 0x35c5, kDwayneNpc, 3 }
+				};
+				lineError = playSequence(lines, ARRAYSIZE(lines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
 		}
-		if (runtime.startupScript().getFlagValue("PC_ESCAPED_JAIL") &&
+		if (startupScript.getFlagValue("PC_ESCAPED_JAIL") &&
 				!state.escapedJailShown) {
 			state.escapedJailShown = true;
 			Common::Error lineError = playDwayneLine(0x35cd);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
-		if (runtime.startupScript().getFlagValue("GOT_REMAINS_FOR_LODGE") &&
+		if (startupScript.getFlagValue("GOT_REMAINS_FOR_LODGE") &&
 				!state.gotRemainsForLodgeShown) {
 			state.gotRemainsForLodgeShown = true;
-			Common::Error lineError = playDwayneLine(0x35d7, 1);
+			Common::Error lineError = playDwayneLine(0x35d7);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
-		if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
+		if (startupScript.getFlagValue("BURNED_TV_STATION") &&
 				!state.burnedTvStationShown) {
 			state.burnedTvStationShown = true;
-			Common::Error lineError = playDwayneLine(0x35f6, 2);
+			const DialogueLineEntry lines[] = {
+				{ 0x35f6, kDwayneNpc, 2 },
+				{ 0x35fb, kDwayneNpc, 2 },
+				{ 0x3601, kDwayneNpc, 2 }
+			};
+			Common::Error lineError = playSequence(lines, ARRAYSIZE(lines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
-		if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
+		if (startupScript.getFlagValue("KARIN_KIDNAPED") &&
 				!state.karinKidnapedShown) {
 			state.karinKidnapedShown = true;
 			Common::Error lineError = playDwayneLine(0x36b2, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			sharedState.karinKidnapedDialogueState = true;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x7b, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1) {
+				lineError = playDwayneLine(0x36bd);
+			} else if (responseIndex == 2) {
+				lineError = playDwayneLine(0x36c1);
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			lineError = playDwayneLine(0x36c5);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			responseIndex = 0;
+			responseError = runtime.runResponseMenu(0x7c, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1) {
+				lineError = playDwayneLine(0x36d0);
+			} else if (responseIndex == 2) {
+				lineError = playDwayneLine(0x36d6, 1);
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			lineError = playDwayneLine(0x36e7, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 		}
 	}
 
-	const int currentStoryDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
 	if (state.pendingKarinAliveFollowup &&
 			currentStoryDayIndex != state.pendingKarinAliveFollowupDayIndex &&
 			!state.pendingKarinAliveFollowupLinePlayed) {
 		state.pendingKarinAliveFollowupLinePlayed = true;
 		state.pendingKarinAliveFollowup = false;
+		sharedState.dwaynePendingKarinAliveFollowupState = 0;
 		state.completedKarinAliveFollowup = true;
 		sharedState.dwayneCompletedKarinAliveFollowup = true;
 
 		const DialogueLineEntry lines[] = {
-			{ 0x3780, "DWAYNE", 0 },
-			{ 0x3786, "PC", 4 },
-			{ 0x378a, "DWAYNE", 2 }
+			{ 0x3780, kDwayneNpc, 0 },
+			{ 0x3786, kPcSpeaker, 4 },
+			{ 0x378a, kDwayneNpc, 2 }
 		};
-		Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+		Common::Error lineError = playSequence(lines, ARRAYSIZE(lines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 
-	if (runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
+	if (startupScript.getFlagValue("KARIN_FOUND_DEAD") &&
 			!state.discussedKarinPurse &&
+			sharedState.discussedKarinPurse == 0 &&
 			!sheriffInDiner &&
 			!state.karinFoundDeadWithoutPurseShown) {
 		state.karinFoundDeadWithoutPurseShown = true;
 		Common::Error lineError = playDwayneLine(0x3793);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x7f, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 1) {
+			lineError = playDwayneLine(0x379d);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		} else if (responseIndex == 2) {
+			lineError = playDwayneLine(0x37a4, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			responseIndex = 0;
+			responseError = runtime.runResponseMenu(0x80, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1) {
+				lineError = playDwayneLine(0x37ae);
+			} else if (responseIndex == 2) {
+				lineError = playDwayneLine(0x37b4, 3);
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			const DialogueLineEntry lines[] = {
+				{ 0x37c1, kDwayneNpc, 0 },
+				{ 0x37c5, kPcSpeaker, 0 }
+			};
+			lineError = playSequence(lines, ARRAYSIZE(lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
 	}
 
-	if (!runtime.startupScript().isNamedNpcDeathTypeClear("JIMMY") &&
+	if (!startupScript.isNamedNpcDeathTypeClear("JIMMY") &&
 			!sheriffInDiner &&
 			!state.jimmyAbsentShown) {
 		state.jimmyAbsentShown = true;
 		Common::Error lineError = playDwayneLine(0x3855, 3);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		if (runtime.startupScript().getFlagValue("JIMMY_ATTACKED")) {
+		if (startupScript.getFlagValue("JIMMY_ATTACKED")) {
 			lineError = playDwayneLine(0x37f8);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
@@ -413,7 +901,7 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 	}
 
-	if (!runtime.startupScript().isNamedNpcDeathTypeClear("MOYNAHAN") &&
+	if (!startupScript.isNamedNpcDeathTypeClear("MOYNAHAN") &&
 			!sheriffInDiner &&
 			!state.moynahanAbsentShown) {
 		state.moynahanAbsentShown = true;
@@ -422,7 +910,7 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return lineError;
 	}
 
-	if (runtime.startupScript().getFlagValue("EDNA_HUNG") &&
+	if (startupScript.getFlagValue("EDNA_HUNG") &&
 			!state.ednaHungShown) {
 		state.ednaHungShown = true;
 		Common::Error lineError = playDwayneLine(0x38f1, 3);
@@ -430,11 +918,11 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return lineError;
 	}
 
-	if (!runtime.startupScript().isNamedNpcDeathTypeClear("MCKNIGHT") &&
+	if (!startupScript.isNamedNpcDeathTypeClear("MCKNIGHT") &&
 			!sheriffInDiner &&
 			!state.mcknightAbsentShown) {
 		state.mcknightAbsentShown = true;
-		Common::Error lineError = hasInventoryItem("TV_DEED")
+		Common::Error lineError = startupScript.isObjectInInventory("TV_DEED")
 			? playDwayneLine(0x3904, 3)
 			: playDwayneLine(0x390f, 1);
 		if (lineError.getCode() != Common::kNoError)
@@ -465,16 +953,16 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				ARRAYSIZE(kDwayneWhaleyTopicResponseLines))) {
 			if (!sheriffInDiner) {
 				const DialogueLineEntry lines[] = {
-					{ 0x327b, "DWAYNE", 0 },
-					{ 0x3280, "PC", 2 },
-					{ 0x3285, "DWAYNE", 0 },
+					{ 0x327b, kDwayneNpc, 0 },
+					{ 0x3280, kPcSpeaker, 2 },
+					{ 0x3285, kDwayneNpc, 0 },
 					{ 0x328b, "LOOMIS", 1 },
-					{ 0x3290, "DWAYNE", 2 },
+					{ 0x3290, kDwayneNpc, 2 },
 					{ 0x3294, "LOOMIS", 3 },
-					{ 0x3299, "PC", 4 },
-					{ 0x329e, "DWAYNE", 1 }
+					{ 0x3299, kPcSpeaker, 4 },
+					{ 0x329e, kDwayneNpc, 1 }
 				};
-				Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+				Common::Error lineError = playSequence(lines, ARRAYSIZE(lines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				sharedState.dialogueStateD2f04 = true;
@@ -487,15 +975,16 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				ARRAYSIZE(kDwayneLoomisTopicResponseLines))) {
 			if (!sheriffInDiner) {
 				const DialogueLineEntry lines[] = {
-					{ 0x32bf, "DWAYNE", 1 },
-					{ 0x32c1, "DWAYNE", 0 },
+					{ 0x32bf, kDwayneNpc, 1 },
+					{ 0x32c1, kDwayneNpc, 0 },
 					{ 0x32c7, "LOOMIS", 3 },
-					{ 0x32cb, "DWAYNE", 0 },
-					{ 0x32d1, "DWAYNE", 1 }
+					{ 0x32cb, kDwayneNpc, 0 },
+					{ 0x32d1, "LOOMIS", 1 }
 				};
-				Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+				Common::Error lineError = playSequence(lines, ARRAYSIZE(lines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
+				runtime.setActiveSpeakerPortrait(kDwayneNpc, 0);
 				assignDwayneTopicBuffer(0x8e);
 			}
 			continue;
@@ -503,11 +992,11 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 		if (runtime.matchesResponseLine(selectedTopic, 0x8f)) {
 			const DialogueLineEntry lines[] = {
-				{ 0x32db, "DWAYNE", 0 },
-				{ 0x32e2, "PC", 4 },
-				{ 0x32e6, "DWAYNE", 0 }
+				{ 0x32db, kDwayneNpc, 0 },
+				{ 0x32e2, kPcSpeaker, 4 },
+				{ 0x32e6, kDwayneNpc, 0 }
 			};
-			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			Common::Error lineError = playSequence(lines, ARRAYSIZE(lines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignDwayneTopicBuffer(0x90);
@@ -516,13 +1005,13 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 		if (runtime.matchesResponseLine(selectedTopic, 0x91)) {
 			const DialogueLineEntry lines[] = {
-				{ 0x32f2, "DWAYNE", 0 },
-				{ 0x32f9, "PC", 0 },
-				{ 0x32fd, "DWAYNE", 0 },
-				{ 0x32fe, "DWAYNE", 0 },
-				{ 0x32ff, "DWAYNE", 0 }
+				{ 0x32f2, kDwayneNpc, 0 },
+				{ 0x32f9, kPcSpeaker, 0 },
+				{ 0x32fd, kDwayneNpc, 0 },
+				{ 0x32fe, kDwayneNpc, 0 },
+				{ 0x32ff, kDwayneNpc, 0 }
 			};
-			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			Common::Error lineError = playSequence(lines, ARRAYSIZE(lines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignDwayneTopicBuffer(0x92);
@@ -530,18 +1019,24 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 
 		if (runtime.matchesResponseLine(selectedTopic, 0x93)) {
-			if (!runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED")) {
-				Common::Error lineError = playDwayneLine(0x330d, 1);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
+			Common::Error lineError = Common::kNoError;
+			if (!startupScript.getFlagValue("STEPH_MIDGAME_PLAYED")) {
+				const DialogueLineEntry lines[] = {
+					{ 0x330d, kDwayneNpc, 1 },
+					{ 0x3314, kPcSpeaker, 0 },
+					{ 0x3318, kDwayneNpc, 0 },
+					{ 0x332c, kDwayneNpc, 0 }
+				};
+				lineError = playSequence(lines, ARRAYSIZE(lines));
+			} else {
+				const DialogueLineEntry lines[] = {
+					{ 0x331f, kDwayneNpc, 3 },
+					{ 0x3320, kDwayneNpc, 0 },
+					{ 0x3321, kDwayneNpc, 0 },
+					{ 0x332c, kDwayneNpc, 0 }
+				};
+				lineError = playSequence(lines, ARRAYSIZE(lines));
 			}
-			const DialogueLineEntry lines[] = {
-				{ 0x331f, "DWAYNE", 3 },
-				{ 0x3320, "DWAYNE", 0 },
-				{ 0x3321, "DWAYNE", 0 },
-				{ 0x332c, "DWAYNE", 0 }
-			};
-			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignDwayneTopicBuffer(0x94);
@@ -550,7 +1045,7 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 		if (runtime.matchesResponseLine(selectedTopic, 0x95)) {
 			sharedState.discussedLodgeTopic = true;
-			Common::Error lineError = playDwayneLine(0x3339);
+			Common::Error lineError = playDwayneLine(0x3339, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
@@ -561,11 +1056,11 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			lineError = Common::kNoError;
 			if (responseIndex == 1) {
 				const DialogueLineEntry responseLines[] = {
-					{ 0x3345, "DWAYNE", 2 },
-					{ 0x3346, "DWAYNE", 2 },
-					{ 0x3347, "DWAYNE", 2 }
+					{ 0x3345, kDwayneNpc, 2 },
+					{ 0x3346, kDwayneNpc, 2 },
+					{ 0x3347, kDwayneNpc, 2 }
 				};
-				lineError = runtime.playDialogueEntrySequence(responseLines, ARRAYSIZE(responseLines));
+				lineError = playSequence(responseLines, ARRAYSIZE(responseLines));
 			} else if (responseIndex == 2) {
 				lineError = playDwayneLine(0x3350);
 			}
@@ -577,16 +1072,16 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 		if (runtime.matchesResponseLine(selectedTopic, 0x98)) {
 			const DialogueLineEntry lines[] = {
-				{ 0x335b, "DWAYNE", 2 },
-				{ 0x3360, "PC", 2 },
-				{ 0x3364, "DWAYNE", 3 }
+				{ 0x335b, kDwayneNpc, 2 },
+				{ 0x3360, kPcSpeaker, 2 },
+				{ 0x3364, kDwayneNpc, 3 }
 			};
-			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			Common::Error lineError = playSequence(lines, ARRAYSIZE(lines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			lineError = sharedState.dialogueStateD2f04
-				? playDwayneLine(0x3368, 2)
-				: playDwayneLine(0x336c, 2);
+				? runtime.playDialogueLineWithVariant(0x3368, kPcSpeaker, 2)
+				: runtime.playDialogueLineWithVariant(0x336c, kPcSpeaker, 2);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			lineError = playDwayneLine(0x3370, 2);
@@ -606,10 +1101,10 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 		if (runtime.matchesResponseLine(selectedTopic, 0x9c)) {
 			const DialogueLineEntry lines[] = {
-				{ 0x3386, "DWAYNE", 0 },
-				{ 0x3387, "DWAYNE", 0 }
+				{ 0x3386, kDwayneNpc, 0 },
+				{ 0x3387, kDwayneNpc, 0 }
 			};
-			Common::Error lineError = runtime.playDialogueEntrySequence(lines, ARRAYSIZE(lines));
+			Common::Error lineError = playSequence(lines, ARRAYSIZE(lines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignDwayneTopicBuffer(0x9d);


Commit: cc2dfbb46c558f57777e55c58d237d245c29b66e
    https://github.com/scummvm/scummvm/commit/cc2dfbb46c558f57777e55c58d237d245c29b66e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:57+02:00

Commit Message:
HARVESTER: Reimplement Karin dialogue handler

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/karin_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index a415a8b1939..1bda6b46ecb 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -508,10 +508,10 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - On that first `0x25d` menu, response `1` plays `NUDE_MAN 0x628` with head variant `1`; if `get_set_sergeant_completed_first_task_state` is already set, the handler also plays `0x62c`, opens zero-based `dialog.rsp` line `0x25e`, and answers with `0x636` or `0x63f`. Response `2` instead plays `NUDE_MAN 0x648` with head variant `1`.
     - Later no-item revisits are also multi-line: they play `PC 0x651`, `NUDE_MAN 0x655`, `PC 0x3063`, then `NUDE_MAN 0x628`. `DAY_FLAG` still short-circuits the whole handler to `NUDE_MAN 0x65c`.
   - `handle_talk_to_karin @ 0x2ea90` is now bounded enough for a direct startup-room port.
-    - `KARIN_FOUND_ALIVE` plus `IN_CEM10` drives a one-shot opener: Karin first triggers `PC 0x10a5` with head variant `4`, then falls through into the normal item/no-item handling.
-    - The photo evidence branch is compact and local-state driven: `CASKET_PHOTO`, `CASKET_PHOTOCOPY`, and `PHOTO_OF_WHALEY_HERRILL` all answer with `KARIN 0x10e3` on head variant `4`, while the clear `g_karin_photo_reply_override_flag` path also records the shared corpse-photo or Whaley/Herrill-photo evidence bit before that line.
-    - `INV_MAG` is the one special non-photo item and exits on `KARIN 0x10ee`; all other presented items fall back to `KARIN 0x10d6` with head variant `1`.
-    - The no-item tail is linear: before Karin is found alive, the first visit plays `PC 0x1076` with head variant `1`, then always ends on `KARIN 0x10dc`.
+    - `KARIN_FOUND_ALIVE` plus `IN_CEM10` drives an early-return one-shot sequence: `PC 0x10a5[4]`, `KARIN 0x10a9[3]`, `PC 0x10ae[2]`, `KARIN 0x10b2[3]`, `PC 0x10b6[0]`, `KARIN 0x10ba[3]`, `PC 0x10bf[0]`, `KARIN 0x10c3[3]`, then the handler returns without falling through.
+    - The photo evidence branch is compact and local-state driven: `CASKET_PHOTO`, `CASKET_PHOTOCOPY`, and `PHOTO_OF_WHALEY_HERRILL` all answer with `KARIN 0x10e3[4]`, but the first presentation also writes the shared corpse-photo or Whaley/Herrill-photo dialogue state, continues through `EDNA 0x10e7[2]`, restores Karin's portrait, and sets the local override bit. Later photo presentations skip Edna, set `DNALFT_PERVERT`, and dispatch `CALL_POLICE`.
+    - `INV_MAG` is the one other special item branch: it plays `KARIN 0x10ee`, then `EDNA 0x10f2[2]`, sets `DNALFT_PERVERT`, and dispatches `CALL_POLICE`. All other presented items fall back to `KARIN 0x10d6[1]`.
+    - The no-item tail is linear but multi-line on first contact: before Karin is found alive, the first visit plays `PC 0x1076[1]`, `KARIN 0x107a[1]`, `PC 0x107e[1]`, `KARIN 0x1082[1]`, `PC 0x1087[0]`, `KARIN 0x108b[1]`, then always ends on `KARIN 0x10dc[0]`.
   - `handle_talk_to_dwayne @ 0x3a310` is now bounded enough to mirror directly in the engine-side room dispatcher.
     - The top-level no-item flow splits between the sheriff-in-diner intro (`0x3482` / `0x3486` / `0x348b` / `0x3490`, optional `EDNA 0x3496` when `KARIN_KIDNAPED`, then `0x349b` / `0x34ac`) and the outside-sheriff opener (`0x3245` / `0x324a` / `0x324e` / `0x3252`), which seeds the first keyword buffer from zero-based `dialog.rsp` line `0x78`.
     - The arrest path clears `ARREST_FLAG` and then dispatches a strict priority bark chain over the `PC_BUSTED_KILLED_*` flags, `IF_KILL_POTTSDAM_AT_GRAVE`, `GENERIC_BUST`, `DNALFT_PERVERT`, the `PC_HAS_GOOJF_CARD` special case, and the escalating `BUSTED_ONCE` / `BUSTED_TWICE` / `BUSTED_THIRD` lines.
diff --git a/TRACKER.md b/TRACKER.md
index ec33568d81a..19f5370d5b5 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Karin dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_karin` (`0x2ea90..0x2ed7f`). The confirmed source drift was concentrated in four native paths the earlier port had flattened: the `KARIN_FOUND_ALIVE` + `IN_CEM10` branch is an eight-line early-return sequence (`0x10a5`..`0x10c3`) rather than a one-line opener that falls back into normal handling; the first no-item visit was missing Karin's full `0x1076`..`0x108b` exchange before the shared `0x10dc` tail; the photo evidence branch was missing Edna's `0x10e7` follow-up, the portrait reset, the one-shot local override bit, and the repeat-photo `DNALFT_PERVERT` / `CALL_POLICE` escalation while also incorrectly writing the broader runtime photo flags instead of Karin's native shared dialogue-state ints; and `INV_MAG` was missing Edna `0x10f2[2]` plus the same police side effects. Patched [`engines/harvester/npc/karin_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/karin_dialogue.cpp), corrected the confirmed Karin handler notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Progress counts remain aligned with the current live Ghidra database totals (`912` functions, `120` remaining `FUN_*`).
 - On March 23, 2026, reimplemented the ScummVM Dwayne dialogue handler directly against the repaired live `HARVEST.LE` Ghidra body at `handle_talk_to_dwayne` (`0x3a310..0x3c385`) plus `reset_talk_to_dwayne_state` (`0x3c490..0x3c531`). The confirmed source drift was concentrated in five native areas the earlier port had flattened or inverted: the top-level `ARREST_FLAG`, `CHECK_EVIDENCE_DOOR`, `ST_BEDRM`, and sheriff-in-diner intro/revisit flow was returning too early and missing native flag clears plus Edna/PC lines; the evidence-item branches for Boyle's button, Karin's purse, note/checkbook originals vs photocopies, ledgers, corpse photo, Whaley photo, and especially `TV_DEED` / `TV_DEED_PHOTOCOPY` were missing native menus, PC turns, confiscation side effects, and the real-deed vs photocopy split; the note/checkbook and TV-deed follow-up state was not wired to the native save-backed shared fields, so Dwayne's later `0x355f..0x3573` reaction and the Karin-purse gate were wrong after save/load; the one-shot event branches for `BARBER_POLE_STOLEN`, `DINER_BURNED`, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, and `KARIN_FOUND_DEAD` were truncated and skipped their recovered response menus; and the keyword loop still had native speaker/portrait drift on the Loomis, Stephanie-midgame, lodge, and `0x98` topic continuations. Patched [`engines/harvester/npc/dwayne_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dwayne_dialogue.cpp) to match those confirmed branches and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts remain aligned with the current live Ghidra totals (`912` functions, `120` remaining `FUN_*`).
 - On March 23, 2026, reran the full durable `repair KARIN -> wait -> save_program -> verify KARIN` cycle against the live `HARVEST.LE` session to confirm Karin still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_karin` rebuilt to `0x2ea90..0x2ed7f` (`752` address-count, `21` dialogue calls, `14` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_karin` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Karin was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
 - On March 22, 2026, repaired `DWAYNE` durably in the live `HARVEST.LE` Ghidra database with the current `InspectCallTermination.java` workflow. The first `repair DWAYNE` pass expanded `handle_talk_to_dwayne` from the short `0x3a310..0x3c385` body with `59` reachable dialogue calls to `185` calls, but it stalled on one real fallthrough hole at `0x3baa7 -> 0x3baac` where the raw bytes were valid code and Ghidra had misaligned the instruction stream. Cleared and re-disassembled `0x3baac..0x3bb0b`, which restored the missing fallthrough head as `MOV EDX,0xb8c8e`, then reran `repair DWAYNE`; that final pass rebuilt `handle_talk_to_dwayne` cleanly to `0x3a310..0x3c385 (8219 bytes)` with `186` reachable dialogue calls, `gapCount=0`, and both `play_dialogue_line` entry points marked returning. After a short settle, saved `HARVEST.LE` and re-ran `verify DWAYNE`; the fresh verify still reported `status=clean`, so Dwayne is now confirmed durable in the saved database. Progress counts remain aligned with the current live Ghidra totals (`912` functions, `120` remaining `FUN_*`).
@@ -63,6 +64,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Karin validation pass that explicitly hits the restored native-only branches: the first `KARIN_FOUND_ALIVE` + `IN_CEM10` visit to confirm the full `0x10a5`..`0x10c3` early-return sequence; first and repeat no-item visits before Karin is found alive to confirm the recovered `0x1076`..`0x108b` prelude plus the shared `0x10dc` bark; first and repeat uses of `CASKET_PHOTO` or `PHOTO_OF_WHALEY_HERRILL` to verify the `0x10e7` Edna follow-up, portrait reset, shared evidence-state write, one-shot override, and repeat-photo `CALL_POLICE` escalation; and `INV_MAG` to confirm `0x10ee` / `0x10f2[2]` plus `DNALFT_PERVERT`.
 - Immediate: run an in-engine Dwayne validation pass that explicitly hits the restored native-only branches: `CHECK_EVIDENCE_DOOR` and the first/second `ST_BEDRM` visits to confirm the new flag clears; the sheriff-in-diner intro and revisit to verify Edna `0x3490/0x3496`, the optional `0x34a0/0x34a4` tail, and the later keyword loop; Boyle's button, Karin's purse, note/checkbook photocopy -> original note/checkbook, both `TV_DEED` and `TV_DEED_PHOTOCOPY` response trees plus the override revisit on the real deed, corpse photo with and without shared state `d2ec8`, and both-ledger handoff with and without `SHERIFF_IN_DINER`; `KARIN_KIDNAPED` through response menus `0x7b/0x7c`, `KARIN_FOUND_DEAD` without the purse through `0x7f/0x80`, and the `PC_HAS_GOOJF_CARD` arrest path with and without `PC_KILLED_ANYONE` to confirm the confiscation and bust follow-up state now matches native.
 - Immediate: resume the sequential talk-handler sweep at `EDNA`, using the proven single-handler workflow `verify <NPC> -> repair <NPC> -> wait ~10s -> save_program -> verify <NPC>` and immediately rerunning the same NPC once when the first saved verify snaps back. After Dwayne, the remaining not-yet-confirmed handlers in that queue are `EDNA`, `PASTORELLI`, `LODGE_CHEF`, `RYDER`, `SPARKY`, `MEMB_DIR`, and `PARSONS`.
 - Immediate: run an in-engine Johnson validation pass that explicitly hits the restored native-only branches: `OPENING_MANHOLE_DAYTIME` twice to confirm `0xbe0` now clears the flag; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared state `d2ebc`, and `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` to verify the restored `0xb7b`, `0xb80`, `0xb90`, `0xb94`, `0xb99`, `0xba4`, and `0xba8` follow-ups; the first `SCRATCHED_TUCKER` visit with `BOLT_OF_CLOTH_TAKEN` already set to confirm `0xc25` / `0xc2b` / `0xc30` plus `0xc5f` / `0xc69` and that the Johnson intro only replays on the later revisit; and a `KARIN_KIDNAPED` follow-up that later reaches a consumer such as Stephanie to confirm the shared kidnapped-dialogue state now propagates correctly.
diff --git a/engines/harvester/npc/karin_dialogue.cpp b/engines/harvester/npc/karin_dialogue.cpp
index d2a8fb92a11..621fb6494fd 100644
--- a/engines/harvester/npc/karin_dialogue.cpp
+++ b/engines/harvester/npc/karin_dialogue.cpp
@@ -23,62 +23,129 @@
 
 #include "harvester/npc/karin_dialogue.h"
 
-#include "harvester/npc/dialogue_flags.h"
 #include "harvester/npc/dialogue_runtime.h"
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kKarinNpc = "KARIN";
+static const char *const kPcSpeaker = "PC";
+static const char *const kEdnaNpc = "EDNA";
+
+static const DialogueLineEntry kKarinCem10AliveLines[] = {
+	{ 0x10a5, kPcSpeaker, 4 },
+	{ 0x10a9, kKarinNpc, 3 },
+	{ 0x10ae, kPcSpeaker, 2 },
+	{ 0x10b2, kKarinNpc, 3 },
+	{ 0x10b6, kPcSpeaker, 0 },
+	{ 0x10ba, kKarinNpc, 3 },
+	{ 0x10bf, kPcSpeaker, 0 },
+	{ 0x10c3, kKarinNpc, 3 }
+};
+
+static const DialogueLineEntry kKarinFirstNoItemLines[] = {
+	{ 0x1076, kPcSpeaker, 1 },
+	{ 0x107a, kKarinNpc, 1 },
+	{ 0x107e, kPcSpeaker, 1 },
+	{ 0x1082, kKarinNpc, 1 },
+	{ 0x1087, kPcSpeaker, 0 },
+	{ 0x108b, kKarinNpc, 1 }
+};
+
+static const DialogueLineEntry kKarinFirstPhotoReplyLines[] = {
+	{ 0x10e3, kKarinNpc, 4 },
+	{ 0x10e7, kEdnaNpc, 2 }
+};
+
+static const DialogueLineEntry kKarinInvMagLines[] = {
+	{ 0x10ee, kKarinNpc, 0 },
+	{ 0x10f2, kEdnaNpc, 2 }
+};
+
+} // End of namespace
+
 bool KarinDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("KARIN");
+	return npcName.equalsIgnoreCase(kKarinNpc);
 }
 
 Common::Error KarinDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &) {
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	KarinRoomDialogueState &state = _state;
 	auto playKarinLine = [&](int wavId, int headVariant = 0) -> Common::Error {
-		return runtime.playDialogueLineWithVariant(wavId, "KARIN", headVariant);
+		return runtime.playDialogueLineWithVariant(wavId, kKarinNpc, headVariant);
+	};
+	auto playSequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
+		return runtime.playDialogueEntrySequence(lines, count);
 	};
-	auto playPcLine = [&](int wavId, int headVariant = 0) -> Common::Error {
-		return runtime.playDialogueLineWithVariant(wavId, "PC", headVariant);
+	auto executeActionTagIfSet = [&](const char *tag) {
+		StartupInteractionResult interaction;
+		if (!runtime.startupScript().executeActionTag(tag, interaction))
+			return;
+
+		runtime.applyImmediateDialogueInteractionEffects(interaction);
+		runtime.queueDialogueInteractionIfNeeded(interaction);
 	};
 
 	const bool karinFoundAlive = runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE");
 	if (karinFoundAlive && runtime.startupScript().getFlagValue("IN_CEM10") &&
 			!state.cem10AliveLinePlayed) {
 		state.cem10AliveLinePlayed = true;
-		Common::Error lineError = playPcLine(0x10a5, 4);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
+		return playSequence(kKarinCem10AliveLines, ARRAYSIZE(kKarinCem10AliveLines));
 	}
 
-	if (!usedItemName.empty()) {
-		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY") ||
-				usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-			if (!state.photoReplyOverrideFlag) {
-				if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-					(void)runtime.startupScript().setRuntimeFlagValue(
-						DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-					} else {
-						(void)runtime.startupScript().setRuntimeFlagValue(
-							DialogueFlags::kShownPhotoOfCorpse, true);
-					}
-				}
-			return playKarinLine(0x10e3, 4);
+	if (usedItemName.empty()) {
+		if (!karinFoundAlive && !state.notFoundAliveLinePlayed) {
+			state.notFoundAliveLinePlayed = true;
+			Common::Error lineError = playSequence(
+				kKarinFirstNoItemLines, ARRAYSIZE(kKarinFirstNoItemLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 		}
-		if (usedItemName.equalsIgnoreCase("INV_MAG"))
-			return playKarinLine(0x10ee);
-		return playKarinLine(0x10d6, 1);
+
+		return playKarinLine(0x10dc);
 	}
 
-	if (!karinFoundAlive && !state.notFoundAliveLinePlayed) {
-		state.notFoundAliveLinePlayed = true;
-		Common::Error lineError = playPcLine(0x1076, 1);
+	const bool photoEvidence = usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+		usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY") ||
+		usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL");
+	if (photoEvidence) {
+		if (!state.photoReplyOverrideFlag) {
+			if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL"))
+				sharedState.discussedWhaleyHerrillPhoto = 1;
+			else
+				sharedState.discussedCasketPhotoEvidence = 1;
+
+			Common::Error lineError = playSequence(
+				kKarinFirstPhotoReplyLines, ARRAYSIZE(kKarinFirstPhotoReplyLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			runtime.setActiveSpeakerPortrait(kKarinNpc, 0);
+			state.photoReplyOverrideFlag = true;
+			return Common::kNoError;
+		}
+
+		Common::Error lineError = playKarinLine(0x10e3, 4);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+
+		(void)runtime.startupScript().setRuntimeFlagValue("DNALFT_PERVERT", true);
+		executeActionTagIfSet("CALL_POLICE");
+		return Common::kNoError;
+	}
+
+	if (usedItemName.equalsIgnoreCase("INV_MAG")) {
+		Common::Error lineError = playSequence(kKarinInvMagLines, ARRAYSIZE(kKarinInvMagLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		(void)runtime.startupScript().setRuntimeFlagValue("DNALFT_PERVERT", true);
+		executeActionTagIfSet("CALL_POLICE");
+		return Common::kNoError;
 	}
 
-	return playKarinLine(0x10dc);
+	return playKarinLine(0x10d6, 1);
 }
 
 } // End of namespace Harvester


Commit: adba4e211ad8bcfaefb0cfe2fa5f3b0c8bab5b1d
    https://github.com/scummvm/scummvm/commit/adba4e211ad8bcfaefb0cfe2fa5f3b0c8bab5b1d
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:57+02:00

Commit Message:
HARVESTER: Reimplement Edna dialogue handler

Changed paths:
    TRACKER.md
    engines/harvester/npc/edna_dialogue.cpp
    engines/harvester/npc/edna_dialogue.h
    engines/harvester/saveload.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 19f5370d5b5..e57cfc1000d 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,14 +3,16 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `912`
+- Total functions: `914`
 - Named/documented: `792`
-- Remaining `FUN_*`: `120`
-- Remaining undocumented total: `120`
+- Remaining `FUN_*`: `122`
+- Remaining undocumented total: `122`
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Edna dialogue handler directly against the repaired live `HARVEST.LE` Ghidra body at `handle_talk_to_edna` (`0x358a0..0x3667b`) plus `reset_talk_to_edna_state` (`0x366c0..0x366e8`). The confirmed source drift was concentrated in four native areas the earlier port had truncated or flattened: the top-level `KILLED_KARIN1`, `DNA_S_SUICIDE_NOTE`, purse handoff, and `BRING_KARIN_TO_SHERIFF` branches were missing native flag clears plus the reward-money / `K_PURSE` room-state side effects; the first no-item visit was missing Edna's full `0x3a95` intro flow, the `0x3aa1..0x3aae` / `0x3ab2..0x3ac2` response branches, the `0x3ac6..0x3acf` shared tail, and the native `d2f04` write; the revisit path after `0x3bf8` / `0x3bff` was missing every recovered one-shot story branch for day 5, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `BOLT_OF_CLOTH_TAKEN`, `PC_ESCAPED_JAIL`, `BURNED_TV_STATION`, and the full `KARIN_FOUND_DEAD` nested response tree including the purse-dependent `0xa3` / `0xa4` menu split; and the entire keyword loop from `0xa5` through `0xc2` was absent, including the `C091.FST` and `C070.FST` topic exits plus the shared lodge / kidnapped-dialogue state writes. Patched [`engines/harvester/npc/edna_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/edna_dialogue.cpp), extended Edna's save-backed room flags in [`engines/harvester/npc/edna_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/edna_dialogue.h), bumped the Harvester save blob version in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) so the new Edna state persists cleanly, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Progress counts now align with the current live Ghidra database totals (`914` functions, `122` remaining `FUN_*`).
 - On March 23, 2026, reimplemented the ScummVM Karin dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_karin` (`0x2ea90..0x2ed7f`). The confirmed source drift was concentrated in four native paths the earlier port had flattened: the `KARIN_FOUND_ALIVE` + `IN_CEM10` branch is an eight-line early-return sequence (`0x10a5`..`0x10c3`) rather than a one-line opener that falls back into normal handling; the first no-item visit was missing Karin's full `0x1076`..`0x108b` exchange before the shared `0x10dc` tail; the photo evidence branch was missing Edna's `0x10e7` follow-up, the portrait reset, the one-shot local override bit, and the repeat-photo `DNALFT_PERVERT` / `CALL_POLICE` escalation while also incorrectly writing the broader runtime photo flags instead of Karin's native shared dialogue-state ints; and `INV_MAG` was missing Edna `0x10f2[2]` plus the same police side effects. Patched [`engines/harvester/npc/karin_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/karin_dialogue.cpp), corrected the confirmed Karin handler notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Progress counts remain aligned with the current live Ghidra database totals (`912` functions, `120` remaining `FUN_*`).
+- On March 23, 2026, reran `repair EDNA` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Edna showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_edna` cleanly to `0x358a0..0x3667b` (`3505` address-count, `64` dialogue calls) but the fresh verify fell back to the short `0x358a0..0x35d1f` body with `11` stale dialogue gaps and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Edna durably clean again at `0x358a0..0x3667b` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Edna belongs in the same post-save retry class as Stephanie, Mr. Potts, Johnson, Loomis, and similar handlers.
 - On March 23, 2026, reimplemented the ScummVM Dwayne dialogue handler directly against the repaired live `HARVEST.LE` Ghidra body at `handle_talk_to_dwayne` (`0x3a310..0x3c385`) plus `reset_talk_to_dwayne_state` (`0x3c490..0x3c531`). The confirmed source drift was concentrated in five native areas the earlier port had flattened or inverted: the top-level `ARREST_FLAG`, `CHECK_EVIDENCE_DOOR`, `ST_BEDRM`, and sheriff-in-diner intro/revisit flow was returning too early and missing native flag clears plus Edna/PC lines; the evidence-item branches for Boyle's button, Karin's purse, note/checkbook originals vs photocopies, ledgers, corpse photo, Whaley photo, and especially `TV_DEED` / `TV_DEED_PHOTOCOPY` were missing native menus, PC turns, confiscation side effects, and the real-deed vs photocopy split; the note/checkbook and TV-deed follow-up state was not wired to the native save-backed shared fields, so Dwayne's later `0x355f..0x3573` reaction and the Karin-purse gate were wrong after save/load; the one-shot event branches for `BARBER_POLE_STOLEN`, `DINER_BURNED`, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, and `KARIN_FOUND_DEAD` were truncated and skipped their recovered response menus; and the keyword loop still had native speaker/portrait drift on the Loomis, Stephanie-midgame, lodge, and `0x98` topic continuations. Patched [`engines/harvester/npc/dwayne_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dwayne_dialogue.cpp) to match those confirmed branches and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts remain aligned with the current live Ghidra totals (`912` functions, `120` remaining `FUN_*`).
 - On March 23, 2026, reran the full durable `repair KARIN -> wait -> save_program -> verify KARIN` cycle against the live `HARVEST.LE` session to confirm Karin still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_karin` rebuilt to `0x2ea90..0x2ed7f` (`752` address-count, `21` dialogue calls, `14` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_karin` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Karin was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
 - On March 22, 2026, repaired `DWAYNE` durably in the live `HARVEST.LE` Ghidra database with the current `InspectCallTermination.java` workflow. The first `repair DWAYNE` pass expanded `handle_talk_to_dwayne` from the short `0x3a310..0x3c385` body with `59` reachable dialogue calls to `185` calls, but it stalled on one real fallthrough hole at `0x3baa7 -> 0x3baac` where the raw bytes were valid code and Ghidra had misaligned the instruction stream. Cleared and re-disassembled `0x3baac..0x3bb0b`, which restored the missing fallthrough head as `MOV EDX,0xb8c8e`, then reran `repair DWAYNE`; that final pass rebuilt `handle_talk_to_dwayne` cleanly to `0x3a310..0x3c385 (8219 bytes)` with `186` reachable dialogue calls, `gapCount=0`, and both `play_dialogue_line` entry points marked returning. After a short settle, saved `HARVEST.LE` and re-ran `verify DWAYNE`; the fresh verify still reported `status=clean`, so Dwayne is now confirmed durable in the saved database. Progress counts remain aligned with the current live Ghidra totals (`912` functions, `120` remaining `FUN_*`).
@@ -64,9 +66,10 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Edna validation pass that explicitly hits the restored native-only branches: `KILLED_KARIN1` to confirm the flag now clears; `DNA_S_SUICIDE_NOTE` with and without `KARIN_FOUND_ALIVE` to confirm `C043.FST` / `C043K.FST`, shared states `d2ea4` / `d2ea8`, and `DNAEXT:SIGNOUT`; direct `K_PURSE` use plus the `KARIN_FOUND_DEAD -> 0xa2 -> 0xa3/0xa4` nested purse menu to verify `REWARD_MONEY` and the `RAH`-hidden purse side effect; the day-5 alive and dead follow-ups; and the keyword loop through the recovered `0xa6`, `0xac/0xad`, `0xb0`, `0xb2`, `0xb6/0xb7`, `0xba`, `0xbc/0xbd/0xbe`, `0xc0`, `0xc2`, typed unknown text, and goodbye branches to confirm the restored menu buffer rewrites and both `C091.FST` / `C070.FST` exits.
 - Immediate: run an in-engine Karin validation pass that explicitly hits the restored native-only branches: the first `KARIN_FOUND_ALIVE` + `IN_CEM10` visit to confirm the full `0x10a5`..`0x10c3` early-return sequence; first and repeat no-item visits before Karin is found alive to confirm the recovered `0x1076`..`0x108b` prelude plus the shared `0x10dc` bark; first and repeat uses of `CASKET_PHOTO` or `PHOTO_OF_WHALEY_HERRILL` to verify the `0x10e7` Edna follow-up, portrait reset, shared evidence-state write, one-shot override, and repeat-photo `CALL_POLICE` escalation; and `INV_MAG` to confirm `0x10ee` / `0x10f2[2]` plus `DNALFT_PERVERT`.
 - Immediate: run an in-engine Dwayne validation pass that explicitly hits the restored native-only branches: `CHECK_EVIDENCE_DOOR` and the first/second `ST_BEDRM` visits to confirm the new flag clears; the sheriff-in-diner intro and revisit to verify Edna `0x3490/0x3496`, the optional `0x34a0/0x34a4` tail, and the later keyword loop; Boyle's button, Karin's purse, note/checkbook photocopy -> original note/checkbook, both `TV_DEED` and `TV_DEED_PHOTOCOPY` response trees plus the override revisit on the real deed, corpse photo with and without shared state `d2ec8`, and both-ledger handoff with and without `SHERIFF_IN_DINER`; `KARIN_KIDNAPED` through response menus `0x7b/0x7c`, `KARIN_FOUND_DEAD` without the purse through `0x7f/0x80`, and the `PC_HAS_GOOJF_CARD` arrest path with and without `PC_KILLED_ANYONE` to confirm the confiscation and bust follow-up state now matches native.
-- Immediate: resume the sequential talk-handler sweep at `EDNA`, using the proven single-handler workflow `verify <NPC> -> repair <NPC> -> wait ~10s -> save_program -> verify <NPC>` and immediately rerunning the same NPC once when the first saved verify snaps back. After Dwayne, the remaining not-yet-confirmed handlers in that queue are `EDNA`, `PASTORELLI`, `LODGE_CHEF`, `RYDER`, `SPARKY`, `MEMB_DIR`, and `PARSONS`.
+- Immediate: resume the sequential talk-handler sweep at `PASTORELLI`, using the proven single-handler workflow `verify <NPC> -> repair <NPC> -> wait ~10s -> save_program -> verify <NPC>` and immediately rerunning the same NPC once when the first saved verify snaps back. After Dwayne and Edna, the remaining not-yet-confirmed handlers in that queue are `PASTORELLI`, `LODGE_CHEF`, `RYDER`, `SPARKY`, `MEMB_DIR`, and `PARSONS`.
 - Immediate: run an in-engine Johnson validation pass that explicitly hits the restored native-only branches: `OPENING_MANHOLE_DAYTIME` twice to confirm `0xbe0` now clears the flag; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared state `d2ebc`, and `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` to verify the restored `0xb7b`, `0xb80`, `0xb90`, `0xb94`, `0xb99`, `0xba4`, and `0xba8` follow-ups; the first `SCRATCHED_TUCKER` visit with `BOLT_OF_CLOTH_TAKEN` already set to confirm `0xc25` / `0xc2b` / `0xc30` plus `0xc5f` / `0xc69` and that the Johnson intro only replays on the later revisit; and a `KARIN_KIDNAPED` follow-up that later reaches a consumer such as Stephanie to confirm the shared kidnapped-dialogue state now propagates correctly.
 - Immediate: run an in-engine Loomis validation pass that explicitly hits the restored native-only branches: first talk with and without `SHERIFF_IN_DINER` to confirm the `0x1112 -> 0x1118 -> 0x1121 -> response 0x53 -> 0x1130[3]` intro; the second and third no-item visits to confirm the one-shot `0x11a8/0x11ac/0x11b0[3]` follow-up, the later `0x11f6` bark, and the `STEPH_MIDGAME_PLAYED` / day-5 `0x1230[3]` and `0x123c` gates; the keyword loop through `LOOMIS`, `Phelps`, `Man's Needs`, sheriff topics, `0x65`, `0x66`, typed unknown text, and goodbye to verify the restored alias matches, portrait variants, `0x11fc` fallback, and `0x11a1[1]` exit; note/checkbook or TV-deed evidence through both `0x50` responses to confirm `0x1214` / `0x1219` / `0x121f[3]` plus `PC_FRY_DADDY_1`; and `INV_MAG` in the diner branch through both `0x51` responses to confirm `0x11c6[1]`, `0x11cc[3]`, drawer visibility, `GAVE_MAG_TO_LOOMIS_TODAY`, and the `C048.FST` tail.
 - Immediate: run an in-engine Mrs. Potts validation pass that explicitly hits the restored native-only paths: `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `LEDGER` with `HAVE_BOTH_LEDGERS`, and the `GOT_REMAINS_FOR_LODGE` response-1 death branch to confirm the corrected portrait variants; the non-dead revisit gate around `0x281b` / `0x2820` / `0x2825`; and `DINER_BURNED` with and without `KARIN_HUNG`, taking both `0x236` responses to verify the new `0x2914` / `0x2918` / `0x2926` continuation and the response-2 jump into the shared death-in-chair sequence.
diff --git a/engines/harvester/npc/edna_dialogue.cpp b/engines/harvester/npc/edna_dialogue.cpp
index 725beb5f642..a56b416c7fb 100644
--- a/engines/harvester/npc/edna_dialogue.cpp
+++ b/engines/harvester/npc/edna_dialogue.cpp
@@ -30,29 +30,125 @@ namespace Harvester {
 
 namespace {
 
+static const char *const kEdnaNpc = "EDNA";
+static const char *const kPcSpeaker = "PC";
+static const char *const kRahRoomName = "RAH";
 static const char *const kDialogueC043FstPath = "GRAPHIC/FST/C043.FST";
 static const char *const kDialogueC043KFstPath = "GRAPHIC/FST/C043K.FST";
+static const char *const kDialogueC070FstPath = "GRAPHIC/FST/C070.FST";
+static const char *const kDialogueC091FstPath = "GRAPHIC/FST/C091.FST";
+
+static const int kEdnaIntroResponseMenuLine = 0x9f;
+static const int kEdnaInitialTopicBufferLine = 0xa0;
+static const int kEdnaDayFiveAliveResponseMenuLine = 0xa1;
+static const int kEdnaKarinFoundDeadResponseMenuLine = 0xa2;
+static const int kEdnaKarinPurseAvailableResponseMenuLine = 0xa3;
+static const int kEdnaKarinPurseUnavailableResponseMenuLine = 0xa4;
+static const int kEdnaExitTopicLine = 0xa5;
+static const int kEdnaAcAdTopicLineIndices[] = { 0xac, 0xad };
+static const int kEdnaLodgeTopicLineIndices[] = { 0xb6, 0xb7 };
+static const int kEdnaBcBdBeTopicLineIndices[] = { 0xbc, 0xbd, 0xbe };
+
+static const DialogueLineEntry kEdnaBringKarinToSheriffLines[] = {
+	{ 0x3cce, kEdnaNpc, 2 },
+	{ 0x3cd2, kPcSpeaker, 0 },
+	{ 0x3cd7, kEdnaNpc, 1 }
+};
+
+static const DialogueLineEntry kEdnaIntroResponseTwoLines[] = {
+	{ 0x3ab2, kEdnaNpc, 0 },
+	{ 0x3ab7, kPcSpeaker, 0 },
+	{ 0x3abb, kEdnaNpc, 0 },
+	{ 0x3ac2, kPcSpeaker, 0 }
+};
+
+static const DialogueLineEntry kEdnaDayFiveAliveResponseOneLines[] = {
+	{ 0x3c23, kEdnaNpc, 0 },
+	{ 0x3c29, kPcSpeaker, 0 },
+	{ 0x3c2e, kEdnaNpc, 3 }
+};
+
+static const DialogueLineEntry kEdnaBarberPoleStolenLines[] = {
+	{ 0x3c4e, kEdnaNpc, 3 },
+	{ 0x3c54, kPcSpeaker, 0 },
+	{ 0x3c5a, kEdnaNpc, 2 }
+};
+
+static const DialogueLineEntry kEdnaKarinFoundDeadIntroLines[] = {
+	{ 0x3cf0, kEdnaNpc, 2 },
+	{ 0x3cf5, kPcSpeaker, 0 },
+	{ 0x3cf9, kEdnaNpc, 2 }
+};
+
+static const DialogueLineEntry kEdnaTopicB2Lines[] = {
+	{ 0x3b15, kEdnaNpc, 1 },
+	{ 0x3b1a, kPcSpeaker, 0 },
+	{ 0x3b1e, kEdnaNpc, 0 }
+};
+
+static const DialogueLineEntry kEdnaLodgeTopicLines[] = {
+	{ 0x3b57, kEdnaNpc, 0 },
+	{ 0x3b5d, kPcSpeaker, 0 },
+	{ 0x3b61, kEdnaNpc, 0 },
+	{ 0x3b62, kEdnaNpc, 0 },
+	{ 0x3b63, kEdnaNpc, 0 },
+	{ 0x3b6c, kPcSpeaker, 0 },
+	{ 0x3b70, kEdnaNpc, 0 },
+	{ 0x3b75, kPcSpeaker, 0 },
+	{ 0x3b79, kEdnaNpc, 0 }
+};
+
+static const DialogueLineEntry kEdnaTopicBcBdBeLines[] = {
+	{ 0x3b8e, kEdnaNpc, 0 },
+	{ 0x3b8f, kEdnaNpc, 0 }
+};
 
 } // End of namespace
 
 bool EdnaDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("EDNA");
+	return npcName.equalsIgnoreCase(kEdnaNpc);
 }
 
 Common::Error EdnaDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	EdnaRoomDialogueState &state = _state;
+	Script &startupScript = runtime.startupScript();
+	Common::String topicBuffer;
+	int topicBufferLineIndex = -1;
+
+	auto assignEdnaTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(topicBuffer, topicBufferLineIndex,
+			responseLineIndex, "Edna topic buffer");
+	};
 	auto playEdnaLine = [&](int wavId, int headVariant = 0) -> Common::Error {
-		return runtime.playDialogueLineWithVariant(wavId, "EDNA", headVariant);
+		return runtime.playDialogueLineWithVariant(wavId, kEdnaNpc, headVariant);
 	};
+	auto playSequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
+		return runtime.playDialogueEntrySequence(lines, count);
+	};
+	auto completeKarinPurseExchange = [&]() -> Common::Error {
+		state.karinPurseLinePlayed = true;
+		Common::Error lineError = playEdnaLine(0x3d05, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 
-	if (runtime.startupScript().getFlagValue("KILLED_KARIN1"))
-		return playEdnaLine(0x3cdf, 2);
+		(void)startupScript.addRuntimeObjectToInventory("REWARD_MONEY");
+		(void)startupScript.setRuntimeObjectVisible(kRahRoomName, "K_PURSE", false);
+		return Common::kNoError;
+	};
+
+	if (startupScript.getFlagValue("KILLED_KARIN1")) {
+		Common::Error lineError = playEdnaLine(0x3cdf, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		(void)startupScript.setRuntimeFlagValue("KILLED_KARIN1", false);
+		return Common::kNoError;
+	}
 
-	if (runtime.startupScript().getFlagValue("DNA_S_SUICIDE_NOTE")) {
-		(void)runtime.startupScript().setRuntimeFlagValue("DNA_S_SUICIDE_NOTE", false);
+	if (startupScript.getFlagValue("DNA_S_SUICIDE_NOTE")) {
+		(void)startupScript.setRuntimeFlagValue("DNA_S_SUICIDE_NOTE", false);
 		Common::Error lineError = runtime.playDialogueFst(
-			runtime.startupScript().getFlagValue("KARIN_FOUND_ALIVE")
+			startupScript.getFlagValue("KARIN_FOUND_ALIVE")
 				? kDialogueC043KFstPath
 				: kDialogueC043FstPath);
 		if (lineError.getCode() != Common::kNoError)
@@ -65,54 +161,47 @@ Common::Error EdnaDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	}
 
 	if (!usedItemName.empty()) {
-		if (usedItemName.equalsIgnoreCase("K_PURSE")) {
-			state.karinPurseLinePlayed = true;
-			return playEdnaLine(0x3d05, 3);
-		}
+		if (usedItemName.equalsIgnoreCase("K_PURSE"))
+			return completeKarinPurseExchange();
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+			(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
 			return playEdnaLine(0x3bd6);
 		}
 		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
 				usedItemName.equalsIgnoreCase("LEDGER2")) &&
-				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+				startupScript.getFlagValue("HAVE_BOTH_LEDGERS")) {
+			(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
 			return playEdnaLine(0x3bd6);
 		}
 		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
 			return playEdnaLine(0x3bde, 2);
 		}
 		if (usedItemName.equalsIgnoreCase("NOTE") ||
 				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+			(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
 			return playEdnaLine(0x3be5, 2);
 		}
 		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
 				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceSheriffOwns, true);
+			(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownEvidenceSheriffOwns, true);
 			return playEdnaLine(0x3bee);
 		}
 
 		return playEdnaLine(0x3bcf);
 	}
 
-	if (runtime.startupScript().getFlagValue("BRING_KARIN_TO_SHERIFF")) {
-		Common::Error lineError = playEdnaLine(0x3cce, 2);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		lineError = runtime.playDialogueLine(0x3cd2, "PC");
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		lineError = playEdnaLine(0x3cd7, 1);
+	if (startupScript.getFlagValue("BRING_KARIN_TO_SHERIFF")) {
+		Common::Error lineError = playSequence(
+			kEdnaBringKarinToSheriffLines, ARRAYSIZE(kEdnaBringKarinToSheriffLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
-		(void)runtime.startupScript().addRuntimeObjectToInventory("REWARD_MONEY");
-		(void)runtime.startupScript().setRuntimeFlagValue("BRING_KARIN_TO_SHERIFF", false);
+		(void)startupScript.addRuntimeObjectToInventory("REWARD_MONEY");
+		(void)startupScript.setRuntimeFlagValue("BRING_KARIN_TO_SHERIFF", false);
 		return Common::kNoError;
 	}
 
@@ -123,7 +212,7 @@ Common::Error EdnaDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return lineError;
 
 		int responseIndex = 0;
-		Common::Error responseError = runtime.runResponseMenu(0x9f, responseIndex);
+		Common::Error responseError = runtime.runResponseMenu(kEdnaIntroResponseMenuLine, responseIndex);
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 
@@ -138,17 +227,11 @@ Common::Error EdnaDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			sharedState.dialogueStateD2f04 = true;
-			lineError = runtime.playDialogueLine(0x3aae, "PC");
+			lineError = runtime.playDialogueLine(0x3aae, kPcSpeaker);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		} else if (responseIndex == 2) {
-			const DialogueLineEntry responseLines[] = {
-				{ 0x3ab2, "EDNA", 0 },
-				{ 0x3ab7, "PC", 0 },
-				{ 0x3abb, "EDNA", 0 },
-				{ 0x3ac2, "EDNA", 0 }
-			};
-			lineError = runtime.playDialogueEntrySequence(responseLines, ARRAYSIZE(responseLines));
+			lineError = playSequence(kEdnaIntroResponseTwoLines, ARRAYSIZE(kEdnaIntroResponseTwoLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
@@ -156,15 +239,278 @@ Common::Error EdnaDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		lineError = playEdnaLine(0x3ac6, 2);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+		lineError = playEdnaLine(0x3aca);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playEdnaLine(0x3acf);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		assignEdnaTopicBuffer(kEdnaInitialTopicBufferLine);
 	}
 
-	if (!runtime.startupScript().getFlagValue("KARIN_KIDNAPED")) {
+	if (!startupScript.getFlagValue("KARIN_KIDNAPED")) {
 		Common::Error lineError = playEdnaLine(0x3bf8);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+	} else {
+		Common::Error lineError = playEdnaLine(0x3bff);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		sharedState.karinKidnapedDialogueState = true;
+	}
+
+	if (startupScript.getFlagValue("DAY_5") && !state.dayFiveShown) {
+		state.dayFiveShown = true;
+		if (startupScript.getFlagValue("KARIN_FOUND_ALIVE")) {
+			Common::Error lineError = playEdnaLine(0x3c17);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(
+				kEdnaDayFiveAliveResponseMenuLine, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+			if (responseIndex == 1) {
+				lineError = playSequence(kEdnaDayFiveAliveResponseOneLines,
+					ARRAYSIZE(kEdnaDayFiveAliveResponseOneLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else if (responseIndex == 2) {
+				lineError = playEdnaLine(0x3c33);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+		} else if (startupScript.getFlagValue("KARIN_FOUND_DEAD")) {
+			Common::Error lineError = playEdnaLine(0x3c3a, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+	}
+
+	if (startupScript.getFlagValue("SCRATCHED_TUCKER") && !state.scratchedTuckerShown) {
+		state.scratchedTuckerShown = true;
+		Common::Error lineError = playEdnaLine(0x3c45);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (startupScript.getFlagValue("BARBER_POLE_STOLEN") &&
+			!state.barberPoleStolenShown) {
+		state.barberPoleStolenShown = true;
+		Common::Error lineError = playSequence(
+			kEdnaBarberPoleStolenLines, ARRAYSIZE(kEdnaBarberPoleStolenLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (startupScript.getFlagValue("BOLT_OF_CLOTH_TAKEN") &&
+			!state.boltOfClothTakenShown) {
+		state.boltOfClothTakenShown = true;
+		Common::Error lineError = playEdnaLine(0x3c62);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (startupScript.getFlagValue("PC_ESCAPED_JAIL") &&
+			!state.escapedJailShown) {
+		state.escapedJailShown = true;
+		Common::Error lineError = playEdnaLine(0x3c92, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (startupScript.getFlagValue("BURNED_TV_STATION") &&
+			!state.burnedTvStationShown) {
+		state.burnedTvStationShown = true;
+		Common::Error lineError = playEdnaLine(0x3ca2, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
+	if (startupScript.getFlagValue("KARIN_FOUND_DEAD") &&
+			!state.karinFoundDeadShown) {
+		state.karinFoundDeadShown = true;
+		Common::Error lineError = playEdnaLine(0x3ce5, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(
+			kEdnaKarinFoundDeadResponseMenuLine, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+		if (responseIndex == 1) {
+			lineError = playSequence(
+				kEdnaKarinFoundDeadIntroLines, ARRAYSIZE(kEdnaKarinFoundDeadIntroLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			const bool canDiscussKarinPurse = state.karinPurseLinePlayed ||
+				startupScript.isObjectInInventory("K_PURSE");
+			responseIndex = 0;
+			responseError = runtime.runResponseMenu(
+				canDiscussKarinPurse
+					? kEdnaKarinPurseAvailableResponseMenuLine
+					: kEdnaKarinPurseUnavailableResponseMenuLine,
+				responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
 
-	return playEdnaLine(0x3bff);
+			if (responseIndex == 1) {
+				if (canDiscussKarinPurse) {
+					lineError = completeKarinPurseExchange();
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				}
+			} else if (responseIndex == 2) {
+				lineError = playEdnaLine(0x3d0a, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+		} else if (responseIndex == 2) {
+			lineError = playEdnaLine(0x3d0e, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+	}
+
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runtime.runKeywordMenu(
+			topicBuffer, topicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.empty())
+			return Common::kNoError;
+
+		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
+				runtime.matchesResponseLine(selectedTopic, kEdnaExitTopicLine)) {
+			if (!startupScript.getFlagValue("KARIN_KIDNAPED") &&
+					!startupScript.getFlagValue("KARIN_FOUND_DEAD") &&
+					!startupScript.getFlagValue("KARIN_FOUND_ALIVE")) {
+				Common::Error lineError = playEdnaLine(0x3bc3, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			return Common::kNoError;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0xa6)) {
+			if (!startupScript.getFlagValue("KARIN_KIDNAPED") &&
+					!startupScript.getFlagValue("KARIN_FOUND_DEAD") &&
+					!startupScript.getFlagValue("KARIN_FOUND_ALIVE")) {
+				Common::Error lineError = playEdnaLine(0x3ad9, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = runtime.playDialogueFst(kDialogueC091FstPath);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else {
+				Common::Error lineError = playEdnaLine(0x3ae3, 3);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				if (!startupScript.getFlagValue("KARIN_FOUND_DEAD")) {
+					lineError = playEdnaLine(0x3ae9, 3);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+					assignEdnaTopicBuffer(
+						startupScript.getFlagValue("SHERIFF_IN_DINER") ? 0xab : 0xaa);
+				}
+			}
+			continue;
+		}
+
+		if (runtime.matchesAnyResponseLine(selectedTopic, kEdnaAcAdTopicLineIndices,
+					ARRAYSIZE(kEdnaAcAdTopicLineIndices))) {
+			Common::Error lineError = playEdnaLine(0x3af6);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playEdnaLine(0x3af8);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignEdnaTopicBuffer(startupScript.getFlagValue("SHERIFF_IN_DINER") ? 0xaf : 0xae);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0xb0)) {
+			if (!startupScript.getFlagValue("SHERIFF_IN_DINER")) {
+				Common::Error lineError = playEdnaLine(0x3b03);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playEdnaLine(0x3b07);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignEdnaTopicBuffer(0xb1);
+			}
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0xb2)) {
+			Common::Error lineError = playSequence(kEdnaTopicB2Lines, ARRAYSIZE(kEdnaTopicB2Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+
+		if (runtime.matchesAnyResponseLine(selectedTopic, kEdnaLodgeTopicLineIndices,
+					ARRAYSIZE(kEdnaLodgeTopicLineIndices))) {
+			sharedState.discussedLodgeTopic = true;
+			Common::Error lineError = playSequence(kEdnaLodgeTopicLines, ARRAYSIZE(kEdnaLodgeTopicLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignEdnaTopicBuffer(startupScript.getFlagValue("SHERIFF_IN_DINER") ? 0xb9 : 0xb8);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0xba)) {
+			if (!startupScript.getFlagValue("SHERIFF_IN_DINER")) {
+				Common::Error lineError = playEdnaLine(0x3b82);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignEdnaTopicBuffer(0xbb);
+			}
+			continue;
+		}
+
+		if (runtime.matchesAnyResponseLine(selectedTopic, kEdnaBcBdBeTopicLineIndices,
+					ARRAYSIZE(kEdnaBcBdBeTopicLineIndices))) {
+			if (!startupScript.getFlagValue("SHERIFF_IN_DINER")) {
+				Common::Error lineError = playSequence(
+					kEdnaTopicBcBdBeLines, ARRAYSIZE(kEdnaTopicBcBdBeLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignEdnaTopicBuffer(0xbf);
+			}
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0xc0)) {
+			if (startupScript.getFlagValue("KARIN_KIDNAPED")) {
+				Common::Error lineError = playEdnaLine(0x3b9e, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				sharedState.karinKidnapedDialogueState = true;
+
+				int responseIndex = 0;
+				Common::Error responseError = runtime.runResponseMenu(0xc1, responseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+
+				lineError = playEdnaLine(responseIndex == 1 ? 0x3bad : 0x3bb3, 3);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = runtime.playDialogueFst(kDialogueC070FstPath);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				return Common::kNoError;
+			}
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0xc2))
+			continue;
+
+		Common::Error lineError = playEdnaLine(0x3bc9);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/edna_dialogue.h b/engines/harvester/npc/edna_dialogue.h
index 4b91781a2de..e37e893db2c 100644
--- a/engines/harvester/npc/edna_dialogue.h
+++ b/engines/harvester/npc/edna_dialogue.h
@@ -31,6 +31,13 @@ public:
 	struct EdnaRoomDialogueState {
 		bool introPending = true;
 		bool karinPurseLinePlayed = false;
+		bool dayFiveShown = false;
+		bool scratchedTuckerShown = false;
+		bool barberPoleStolenShown = false;
+		bool boltOfClothTakenShown = false;
+		bool escapedJailShown = false;
+		bool burnedTvStationShown = false;
+		bool karinFoundDeadShown = false;
 	};
 
 	bool matchesNpc(const Common::String &npcName) const override;
@@ -38,6 +45,13 @@ public:
 	void syncState(Common::Serializer &s) override {
 		syncDialogueBool(s, _state.introPending);
 		syncDialogueBool(s, _state.karinPurseLinePlayed);
+		syncDialogueBool(s, _state.dayFiveShown, 6);
+		syncDialogueBool(s, _state.scratchedTuckerShown, 6);
+		syncDialogueBool(s, _state.barberPoleStolenShown, 6);
+		syncDialogueBool(s, _state.boltOfClothTakenShown, 6);
+		syncDialogueBool(s, _state.escapedJailShown, 6);
+		syncDialogueBool(s, _state.burnedTvStationShown, 6);
+		syncDialogueBool(s, _state.karinFoundDeadShown, 6);
 	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index c036811dd03..6adc12341bb 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -39,7 +39,7 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 }
 
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
-static const uint32 kHarvesterSaveVersion = 5;
+static const uint32 kHarvesterSaveVersion = 6;
 
 static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
 	debugC(1, kDebugGeneral,


Commit: b47cfa1359a385a752afc7f50239a6355349f7e4
    https://github.com/scummvm/scummvm/commit/b47cfa1359a385a752afc7f50239a6355349f7e4
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:58+02:00

Commit Message:
HARVESTER: Reimplement butcher dialogue handler

Changed paths:
    TRACKER.md
    engines/harvester/npc/butcher_dialogue.cpp
    engines/harvester/npc/butcher_dialogue.h
    engines/harvester/npc/dad_dialogue.cpp
    engines/harvester/saveload.cpp


diff --git a/TRACKER.md b/TRACKER.md
index e57cfc1000d..398183bac9d 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,15 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `914`
+- Total functions: `924`
 - Named/documented: `792`
-- Remaining `FUN_*`: `122`
-- Remaining undocumented total: `122`
+- Remaining `FUN_*`: `132`
+- Remaining undocumented total: `132`
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Butcher dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_butcher` (`0x2a180..0x2ad7c`) plus `reset_talk_to_butcher_state` (`0x2ada0..0x2adb0`). The confirmed source drift was concentrated in five native areas the earlier stub omitted: the first no-item visit was missing Butcher's full `0x1902..0x1921` intro sequence, the `dialogueStateD2ed0` conditional on `0x190f` vs `0x1913`, and the correct initial topic buffer; the revisit path was missing the native topic-menu gating on `MEAT_PERMISSION0` / `MEAT_PERMISSION` ownership and story day; the handler had no recovered keyword loop for topics `0x263`, `0x267`, `0x26b`, `0x26e`, `0x270`, `0x273/0x274`, `0x277`, and the `0x279` no-op path, including the `C119.FST`, `C060.FST`, `C119A.FST`, `C119B.FST`, `C123.FST`, `C123A.FST`, `C123B.FST`, and `C119C.FST` tails plus the menu-driven topic-buffer rewrites; the item branches for Whaley's photo, the corpse photo, both ledgers, note/checkbook evidence, and presented `MEAT_PERMISSION` were missing their native multi-line exchanges, the `C060A.FST` reward tail, and the `MEAT` inventory / `GOT_MEAT` side effects; and the native cross-handler state writes into `dad_meat_permission_state`, `dialogueStateD2ed0`, and `discussedLodgeTopic` were absent. Patched [`engines/harvester/npc/butcher_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/butcher_dialogue.cpp), extended Butcher's save-backed room state in [`engines/harvester/npc/butcher_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/butcher_dialogue.h), fixed Dad's two native shared-state dependencies in [`engines/harvester/npc/dad_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dad_dialogue.cpp), bumped the Harvester save format to `7` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts now align with the current live Ghidra database totals (`924` functions, `132` remaining `FUN_*`).
+- On March 23, 2026, reran the full durable `repair BUTCHER -> wait -> save_program -> verify BUTCHER` cycle against the live `HARVEST.LE` session to confirm Butcher still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_butcher` rebuilt to `0x2a180..0x2ad7c` (`2936` address-count, `60` dialogue calls, `42` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_butcher` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Butcher was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
 - On March 23, 2026, reimplemented the ScummVM Edna dialogue handler directly against the repaired live `HARVEST.LE` Ghidra body at `handle_talk_to_edna` (`0x358a0..0x3667b`) plus `reset_talk_to_edna_state` (`0x366c0..0x366e8`). The confirmed source drift was concentrated in four native areas the earlier port had truncated or flattened: the top-level `KILLED_KARIN1`, `DNA_S_SUICIDE_NOTE`, purse handoff, and `BRING_KARIN_TO_SHERIFF` branches were missing native flag clears plus the reward-money / `K_PURSE` room-state side effects; the first no-item visit was missing Edna's full `0x3a95` intro flow, the `0x3aa1..0x3aae` / `0x3ab2..0x3ac2` response branches, the `0x3ac6..0x3acf` shared tail, and the native `d2f04` write; the revisit path after `0x3bf8` / `0x3bff` was missing every recovered one-shot story branch for day 5, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `BOLT_OF_CLOTH_TAKEN`, `PC_ESCAPED_JAIL`, `BURNED_TV_STATION`, and the full `KARIN_FOUND_DEAD` nested response tree including the purse-dependent `0xa3` / `0xa4` menu split; and the entire keyword loop from `0xa5` through `0xc2` was absent, including the `C091.FST` and `C070.FST` topic exits plus the shared lodge / kidnapped-dialogue state writes. Patched [`engines/harvester/npc/edna_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/edna_dialogue.cpp), extended Edna's save-backed room flags in [`engines/harvester/npc/edna_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/edna_dialogue.h), bumped the Harvester save blob version in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) so the new Edna state persists cleanly, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Progress counts now align with the current live Ghidra database totals (`914` functions, `122` remaining `FUN_*`).
 - On March 23, 2026, reimplemented the ScummVM Karin dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_karin` (`0x2ea90..0x2ed7f`). The confirmed source drift was concentrated in four native paths the earlier port had flattened: the `KARIN_FOUND_ALIVE` + `IN_CEM10` branch is an eight-line early-return sequence (`0x10a5`..`0x10c3`) rather than a one-line opener that falls back into normal handling; the first no-item visit was missing Karin's full `0x1076`..`0x108b` exchange before the shared `0x10dc` tail; the photo evidence branch was missing Edna's `0x10e7` follow-up, the portrait reset, the one-shot local override bit, and the repeat-photo `DNALFT_PERVERT` / `CALL_POLICE` escalation while also incorrectly writing the broader runtime photo flags instead of Karin's native shared dialogue-state ints; and `INV_MAG` was missing Edna `0x10f2[2]` plus the same police side effects. Patched [`engines/harvester/npc/karin_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/karin_dialogue.cpp), corrected the confirmed Karin handler notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Progress counts remain aligned with the current live Ghidra database totals (`912` functions, `120` remaining `FUN_*`).
 - On March 23, 2026, reran `repair EDNA` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Edna showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_edna` cleanly to `0x358a0..0x3667b` (`3505` address-count, `64` dialogue calls) but the fresh verify fell back to the short `0x358a0..0x35d1f` body with `11` stale dialogue gaps and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Edna durably clean again at `0x358a0..0x3667b` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Edna belongs in the same post-save retry class as Stephanie, Mr. Potts, Johnson, Loomis, and similar handlers.
@@ -66,6 +68,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Butcher and Dad validation pass that explicitly hits the recovered native-only paths: first Butcher talk before and after talking to Dad once to confirm `0x190f` vs `0x1913`; the revisit bark on day 1 with and without `MEAT_PERMISSION0` / `MEAT_PERMISSION` to confirm the `0x261` topic buffer disappears once permission is owned or after day 4; topics `0x263`, `0x267`, `0x26b`, `0x26e`, `0x270`, `0x273/0x274`, `0x277`, and the `0x279` no-op path to verify the restored menu rewrites plus `C119/C060/C119A/C119B/C123/C123A` FST tails; presented `MEAT_PERMISSION` to confirm `MEAT` is awarded and `GOT_MEAT` is set; and then Dad with `MEAT_PERMISSION0` both before and after Butcher grants it to confirm Dad now reads the shared permission state and the first Dad intro seeds `dialogueStateD2ed0`.
 - Immediate: run an in-engine Edna validation pass that explicitly hits the restored native-only branches: `KILLED_KARIN1` to confirm the flag now clears; `DNA_S_SUICIDE_NOTE` with and without `KARIN_FOUND_ALIVE` to confirm `C043.FST` / `C043K.FST`, shared states `d2ea4` / `d2ea8`, and `DNAEXT:SIGNOUT`; direct `K_PURSE` use plus the `KARIN_FOUND_DEAD -> 0xa2 -> 0xa3/0xa4` nested purse menu to verify `REWARD_MONEY` and the `RAH`-hidden purse side effect; the day-5 alive and dead follow-ups; and the keyword loop through the recovered `0xa6`, `0xac/0xad`, `0xb0`, `0xb2`, `0xb6/0xb7`, `0xba`, `0xbc/0xbd/0xbe`, `0xc0`, `0xc2`, typed unknown text, and goodbye branches to confirm the restored menu buffer rewrites and both `C091.FST` / `C070.FST` exits.
 - Immediate: run an in-engine Karin validation pass that explicitly hits the restored native-only branches: the first `KARIN_FOUND_ALIVE` + `IN_CEM10` visit to confirm the full `0x10a5`..`0x10c3` early-return sequence; first and repeat no-item visits before Karin is found alive to confirm the recovered `0x1076`..`0x108b` prelude plus the shared `0x10dc` bark; first and repeat uses of `CASKET_PHOTO` or `PHOTO_OF_WHALEY_HERRILL` to verify the `0x10e7` Edna follow-up, portrait reset, shared evidence-state write, one-shot override, and repeat-photo `CALL_POLICE` escalation; and `INV_MAG` to confirm `0x10ee` / `0x10f2[2]` plus `DNALFT_PERVERT`.
 - Immediate: run an in-engine Dwayne validation pass that explicitly hits the restored native-only branches: `CHECK_EVIDENCE_DOOR` and the first/second `ST_BEDRM` visits to confirm the new flag clears; the sheriff-in-diner intro and revisit to verify Edna `0x3490/0x3496`, the optional `0x34a0/0x34a4` tail, and the later keyword loop; Boyle's button, Karin's purse, note/checkbook photocopy -> original note/checkbook, both `TV_DEED` and `TV_DEED_PHOTOCOPY` response trees plus the override revisit on the real deed, corpse photo with and without shared state `d2ec8`, and both-ledger handoff with and without `SHERIFF_IN_DINER`; `KARIN_KIDNAPED` through response menus `0x7b/0x7c`, `KARIN_FOUND_DEAD` without the purse through `0x7f/0x80`, and the `PC_HAS_GOOJF_CARD` arrest path with and without `PC_KILLED_ANYONE` to confirm the confiscation and bust follow-up state now matches native.
diff --git a/engines/harvester/npc/butcher_dialogue.cpp b/engines/harvester/npc/butcher_dialogue.cpp
index 76daec3fe29..4f92bb10e60 100644
--- a/engines/harvester/npc/butcher_dialogue.cpp
+++ b/engines/harvester/npc/butcher_dialogue.cpp
@@ -28,46 +28,392 @@
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kButcherNpc = "BUTCHER";
+static const char *const kPcSpeaker = "PC";
+static const char *const kDialogueC060FstPath = "GRAPHIC/FST/C060.FST";
+static const char *const kDialogueC060AFstPath = "GRAPHIC/FST/C060A.FST";
+static const char *const kDialogueC119FstPath = "GRAPHIC/FST/C119.FST";
+static const char *const kDialogueC119AFstPath = "GRAPHIC/FST/C119A.FST";
+static const char *const kDialogueC119BFstPath = "GRAPHIC/FST/C119B.FST";
+static const char *const kDialogueC119CFstPath = "GRAPHIC/FST/C119C.FST";
+static const char *const kDialogueC123FstPath = "GRAPHIC/FST/C123.FST";
+static const char *const kDialogueC123AFstPath = "GRAPHIC/FST/C123A.FST";
+static const char *const kDialogueC123BFstPath = "GRAPHIC/FST/C123B.FST";
+
+static const DialogueLineEntry kButcherWhaleyPhotoLines[] = {
+	{ 0x1a5a, kButcherNpc, 0 },
+	{ 0x1a5e, kPcSpeaker, 0 },
+	{ 0x1a62, kButcherNpc, 1 }
+};
+
+static const DialogueLineEntry kButcherCasketPhotoLines[] = {
+	{ 0x1a69, kButcherNpc, 0 },
+	{ 0x1a6d, kPcSpeaker, 4 },
+	{ 0x1a71, kButcherNpc, 1 },
+	{ 0x1a76, kPcSpeaker, 0 },
+	{ 0x1a7a, kButcherNpc, 0 }
+};
+
+static const DialogueLineEntry kButcherLedgerLines[] = {
+	{ 0x1a80, kButcherNpc, 0 },
+	{ 0x1a85, kPcSpeaker, 0 },
+	{ 0x1a8a, kButcherNpc, 0 },
+	{ 0x1a92, kButcherNpc, 0 },
+	{ 0x1a96, kButcherNpc, 2 }
+};
+
+static const DialogueLineEntry kButcherOpeningQuestionLines[] = {
+	{ 0x192a, kPcSpeaker, 0 },
+	{ 0x192f, kButcherNpc, 0 },
+	{ 0x1933, kPcSpeaker, 0 },
+	{ 0x1938, kButcherNpc, 2 }
+};
+
+static const DialogueLineEntry kButcherLodgeTopicLines[] = {
+	{ 0x1a07, kButcherNpc, 0 },
+	{ 0x1a0d, kPcSpeaker, 0 },
+	{ 0x1a11, kButcherNpc, 1 },
+	{ 0x1a18, kPcSpeaker, 0 },
+	{ 0x1a1c, kButcherNpc, 1 }
+};
+
+} // End of namespace
+
 bool ButcherDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("BUTCHER");
+	return npcName.equalsIgnoreCase(kButcherNpc);
 }
 
 Common::Error ButcherDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &) {
-	if (usedItemName.empty()) {
-		if (_state.talkStatePending) {
-			_state.talkStatePending = false;
-			return runtime.playDialogueLine(0x1902, "BUTCHER");
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	ButcherRoomDialogueState &state = _state;
+	Common::String &butcherTopicBuffer = state.currentTopicBuffer;
+	int &butcherTopicBufferLineIndex = state.currentTopicBufferLineIndex;
+
+	auto assignButcherTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(butcherTopicBuffer, butcherTopicBufferLineIndex,
+			responseLineIndex, "Butcher topic buffer");
+	};
+	auto clearButcherTopicBuffer = [&]() {
+		butcherTopicBuffer.clear();
+		butcherTopicBufferLineIndex = -1;
+	};
+	auto playButcherLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kButcherNpc, headVariant);
+	};
+	auto playPcLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kPcSpeaker, headVariant);
+	};
+	auto playSequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
+		return runtime.playDialogueEntrySequence(lines, count);
+	};
+	auto hasMeatPermission = [&]() {
+		return runtime.startupScript().isObjectInInventory("MEAT_PERMISSION0") ||
+			runtime.startupScript().isObjectInInventory("MEAT_PERMISSION");
+	};
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			return playSequence(kButcherWhaleyPhotoLines, ARRAYSIZE(kButcherWhaleyPhotoLines));
 		}
-		return runtime.playDialogueLine(0x1a47, "BUTCHER");
-	}
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				DialogueFlags::kShownPhotoOfCorpse, true);
+			return playSequence(kButcherCasketPhotoLines, ARRAYSIZE(kButcherCasketPhotoLines));
+		}
+		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+					usedItemName.equalsIgnoreCase("LEDGER2")) &&
+				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				DialogueFlags::kShownLedgersToAnyone, true);
+			return playSequence(kButcherLedgerLines, ARRAYSIZE(kButcherLedgerLines));
+		}
+		if (usedItemName.equalsIgnoreCase("NOTE") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				DialogueFlags::kShownEvidenceOfBlackmail, true);
+			return playButcherLine(0x1a9e);
+		}
+		if (usedItemName.equalsIgnoreCase("MEAT_PERMISSION")) {
+			Common::Error lineError = playButcherLine(0x1ac4, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 
-	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-		return runtime.playDialogueLine(0x1a5a, "BUTCHER");
+			int ignoredResponseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x25f, ignoredResponseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			lineError = playButcherLine(0x1ace, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC060AFstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			(void)runtime.startupScript().addRuntimeObjectToInventory("MEAT");
+			(void)runtime.startupScript().setRuntimeFlagValue("GOT_MEAT", true);
+			sharedState.dadMeatPermissionState = 0;
+			return Common::kNoError;
+		}
+
+		return playButcherLine(0x1a54);
 	}
-	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
-		return runtime.playDialogueLine(0x1a69, "BUTCHER");
+
+	if (state.introPending) {
+		state.introPending = false;
+
+		Common::Error lineError = playButcherLine(0x1902);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playPcLine(0x1906);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playButcherLine(0x190a);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playPcLine(sharedState.dialogueStateD2ed0 ? 0x1913 : 0x190f);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playButcherLine(0x1917, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playPcLine(0x191d);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playButcherLine(0x1921, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		assignButcherTopicBuffer(0x260);
+	} else {
+		Common::Error lineError = playButcherLine(0x1a47);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		if (!hasMeatPermission() && runtime.startupScript().getCurrentStoryDayIndex() <= 3)
+			assignButcherTopicBuffer(0x261);
+		else
+			clearButcherTopicBuffer();
 	}
-	if ((usedItemName.equalsIgnoreCase("LEDGER") ||
-				usedItemName.equalsIgnoreCase("LEDGER2")) &&
-			runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
-		return runtime.playDialogueLine(0x1a80, "BUTCHER");
+
+	if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
+			!state.stephMidgameShown) {
+		state.stephMidgameShown = true;
+		Common::Error lineError = playButcherLine(0x1aa7, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = runtime.playDialogueFst(kDialogueC123BFstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
-	if (usedItemName.equalsIgnoreCase("NOTE") ||
-			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
-			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
-			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
-		return runtime.playDialogueLine(0x1a9e, "BUTCHER");
+	if (runtime.startupScript().getFlagValue("DAY_5") && !state.dayFiveShown) {
+		state.dayFiveShown = true;
+		Common::Error lineError = playButcherLine(0x1ab6, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = runtime.playDialogueFst(kDialogueC119CFstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
-	if (usedItemName.equalsIgnoreCase("MEAT_PERMISSION"))
-		return runtime.playDialogueLineWithVariant(0x1ac4, "BUTCHER", 1);
 
-	return runtime.playDialogueLine(0x1a54, "BUTCHER");
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runtime.runKeywordMenu(
+			butcherTopicBuffer, butcherTopicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.empty())
+			return Common::kNoError;
+
+		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
+				runtime.matchesResponseLine(selectedTopic, 0x262)) {
+			return playButcherLine(0x1a41);
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x263)) {
+			Common::Error lineError = playSequence(
+				kButcherOpeningQuestionLines, ARRAYSIZE(kButcherOpeningQuestionLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x264, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = playButcherLine(0x1943, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = runtime.playDialogueFst(kDialogueC119FstPath);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else if (responseIndex == 2) {
+				lineError = playButcherLine(0x1950, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playPcLine(0x1955);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playButcherLine(0x195a);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				assignButcherTopicBuffer(hasMeatPermission() ? 0x266 : 0x265);
+			}
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x267) && !hasMeatPermission()) {
+			Common::Error lineError = playPcLine(0x1964);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playButcherLine(0x1969);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC060FstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playButcherLine(0x1975);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC119AFstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playButcherLine(0x1980);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x268, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = playButcherLine(0x198b);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				sharedState.dadMeatPermissionState = 1;
+				(void)runtime.startupScript().addRuntimeObjectToInventory("MEAT_PERMISSION0");
+				assignButcherTopicBuffer(0x269);
+			} else if (responseIndex == 2) {
+				lineError = playButcherLine(0x1992, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignButcherTopicBuffer(0x26a);
+			}
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x26b)) {
+			Common::Error lineError = playButcherLine(0x199c);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC119BFstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playButcherLine(0x19a7);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playButcherLine(0x19ae);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playButcherLine(0x19b3, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC123FstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playButcherLine(0x19bf, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			assignButcherTopicBuffer(hasMeatPermission() ? 0x26d : 0x26c);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x26e)) {
+			Common::Error lineError = playButcherLine(0x19ca);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			if (sharedState.momFatherTopicState == 0) {
+				lineError = playPcLine(0x19ce);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else {
+				lineError = playPcLine(0x19d2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playButcherLine(0x19d6);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			if (sharedState.dialogueStateD2ed0) {
+				lineError = playPcLine(0x19dc);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playButcherLine(0x19e1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playPcLine(0x19e5, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playButcherLine(0x19e9, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				assignButcherTopicBuffer(0x26f);
+			}
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x270)) {
+			Common::Error lineError = playButcherLine(0x19f6);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC123AFstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			assignButcherTopicBuffer(hasMeatPermission() ? 0x272 : 0x271);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x273) ||
+				runtime.matchesResponseLine(selectedTopic, 0x274)) {
+			sharedState.discussedLodgeTopic = true;
+			Common::Error lineError = playSequence(
+				kButcherLodgeTopicLines, ARRAYSIZE(kButcherLodgeTopicLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			assignButcherTopicBuffer(hasMeatPermission() ? 0x276 : 0x275);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x277)) {
+			Common::Error lineError = playPcLine(0x1a24, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			return playButcherLine(0x1a29, 1);
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x279))
+			continue;
+
+		Common::Error lineError = playButcherLine(0x1a4e);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/butcher_dialogue.h b/engines/harvester/npc/butcher_dialogue.h
index cab6a456420..a670528bf7c 100644
--- a/engines/harvester/npc/butcher_dialogue.h
+++ b/engines/harvester/npc/butcher_dialogue.h
@@ -29,13 +29,21 @@ namespace Harvester {
 class ButcherDialogueHandler : public NpcDialogueHandler {
 public:
 	struct ButcherRoomDialogueState {
-		bool talkStatePending = true;
+		bool introPending = true;
+		bool stephMidgameShown = false;
+		bool dayFiveShown = false;
+		Common::String currentTopicBuffer;
+		int currentTopicBufferLineIndex = -1;
 	};
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = ButcherRoomDialogueState(); }
 	void syncState(Common::Serializer &s) override {
-		syncDialogueBool(s, _state.talkStatePending);
+		syncDialogueBool(s, _state.introPending);
+		syncDialogueBool(s, _state.stephMidgameShown, 7);
+		syncDialogueBool(s, _state.dayFiveShown, 7);
+		syncDialogueString(s, _state.currentTopicBuffer, 7);
+		syncDialogueInt(s, _state.currentTopicBufferLineIndex, 7);
 	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
diff --git a/engines/harvester/npc/dad_dialogue.cpp b/engines/harvester/npc/dad_dialogue.cpp
index f9383000330..2e1ddb4c0a8 100644
--- a/engines/harvester/npc/dad_dialogue.cpp
+++ b/engines/harvester/npc/dad_dialogue.cpp
@@ -33,7 +33,7 @@ bool DadDialogueHandler::matchesNpc(const Common::String &npcName) const {
 }
 
 Common::Error DadDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &) {
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	DadRoomDialogueState &state = _state;
 	auto playDadLine = [&](int wavId, const char *speakerId = "DAD") -> Common::Error {
 		return runtime.playDialogueLine(wavId, speakerId);
@@ -45,6 +45,7 @@ Common::Error DadDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (usedItemName.empty()) {
 		if (state.introPending) {
 			state.introPending = false;
+			sharedState.dialogueStateD2ed0 = true;
 			return playDadLine(0x39a5);
 		}
 		return playDadLine(0x3a17);
@@ -69,7 +70,7 @@ Common::Error DadDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return playDadLine(0x3a51);
 	}
 	if (usedItemName.equalsIgnoreCase("MEAT_PERMISSION0")) {
-		if (state.meatPermissionState)
+		if (sharedState.dadMeatPermissionState)
 			return playDadLine(0x39f2, "PC");
 		return playDadLine(0x3a32);
 	}
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index 6adc12341bb..369852b432f 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -39,7 +39,7 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 }
 
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
-static const uint32 kHarvesterSaveVersion = 6;
+static const uint32 kHarvesterSaveVersion = 7;
 
 static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
 	debugC(1, kDebugGeneral,


Commit: 022b50081f00abe2e34d7524d299ca78e67133eb
    https://github.com/scummvm/scummvm/commit/022b50081f00abe2e34d7524d299ca78e67133eb
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:58+02:00

Commit Message:
HARVESTER: Reimplement Dad dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/dad_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 398183bac9d..c666cc58aa0 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,15 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `924`
+- Total functions: `915`
 - Named/documented: `792`
-- Remaining `FUN_*`: `132`
-- Remaining undocumented total: `132`
+- Remaining `FUN_*`: `123`
+- Remaining undocumented total: `123`
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Dad dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_dad` (`0x2b020..0x2b581`) plus `reset_talk_to_dad_state` (`0x2b5a0..0x2b5a6`) and the shared Dad permission helper at `get_set_dad_meat_permission_state` (`0x38400..0x3840f`). The confirmed source drift was concentrated in four native areas the earlier port had flattened or miswired: the `TAKING_BONDAGE` branch was missing Dad's second `0x3a6b` line and the intervening `C011.FST` tail after `0x3a61`; the item branches were writing the wrong ScummVM runtime evidence flags, missing Dad's multi-line Whaley-photo and note/checkbook exchanges, and omitting the `MEAT_PERMISSION0` conversion path that plays `0x39f2` / `0x39f7`, runs `C112S4.FST`, sets shared state `d2eb4`, adds `MEAT_PERMISSION`, and restores `MEAT_PERMISSION0` visibility in inventory; the first no-item visit was missing Dad's full `0x39a5` / `0x39a9` / `0x39ad` / `0x39b1[PC2]` / `0x39b5` intro plus the shared `d2f04` write; and the revisit path was incorrectly ending after `0x3a17` instead of running Dad's native Other-only keyword loop, which leaves the visible topic list empty, only special-cases typed `Lodge` / `The Lodge` through `0x3a7d` / `0x3a83` / `0x3a88`, and otherwise falls back to `0x3a2c`. Patched [`engines/harvester/npc/dad_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dad_dialogue.cpp), preserved the existing save layout in [`engines/harvester/npc/dad_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dad_dialogue.h), rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and refreshed the tracker progress totals from the current live Ghidra database (`915` functions, `123` remaining `FUN_*`).
+- On March 23, 2026, reran `repair DAD` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Dad showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_dad` cleanly to `0x2b020..0x2b581` (`954` address-count, `20` dialogue calls) but the fresh verify fell back to the short `0x2b020..0x2b2e9` body with `7` stale dialogue gaps and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Dad durably clean again at `0x2b020..0x2b581` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Dad belongs in the same post-save retry class as Stephanie, Mr. Potts, Johnson, Loomis, and similar handlers.
 - On March 23, 2026, reimplemented the ScummVM Butcher dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_butcher` (`0x2a180..0x2ad7c`) plus `reset_talk_to_butcher_state` (`0x2ada0..0x2adb0`). The confirmed source drift was concentrated in five native areas the earlier stub omitted: the first no-item visit was missing Butcher's full `0x1902..0x1921` intro sequence, the `dialogueStateD2ed0` conditional on `0x190f` vs `0x1913`, and the correct initial topic buffer; the revisit path was missing the native topic-menu gating on `MEAT_PERMISSION0` / `MEAT_PERMISSION` ownership and story day; the handler had no recovered keyword loop for topics `0x263`, `0x267`, `0x26b`, `0x26e`, `0x270`, `0x273/0x274`, `0x277`, and the `0x279` no-op path, including the `C119.FST`, `C060.FST`, `C119A.FST`, `C119B.FST`, `C123.FST`, `C123A.FST`, `C123B.FST`, and `C119C.FST` tails plus the menu-driven topic-buffer rewrites; the item branches for Whaley's photo, the corpse photo, both ledgers, note/checkbook evidence, and presented `MEAT_PERMISSION` were missing their native multi-line exchanges, the `C060A.FST` reward tail, and the `MEAT` inventory / `GOT_MEAT` side effects; and the native cross-handler state writes into `dad_meat_permission_state`, `dialogueStateD2ed0`, and `discussedLodgeTopic` were absent. Patched [`engines/harvester/npc/butcher_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/butcher_dialogue.cpp), extended Butcher's save-backed room state in [`engines/harvester/npc/butcher_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/butcher_dialogue.h), fixed Dad's two native shared-state dependencies in [`engines/harvester/npc/dad_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dad_dialogue.cpp), bumped the Harvester save format to `7` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts now align with the current live Ghidra database totals (`924` functions, `132` remaining `FUN_*`).
 - On March 23, 2026, reran the full durable `repair BUTCHER -> wait -> save_program -> verify BUTCHER` cycle against the live `HARVEST.LE` session to confirm Butcher still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_butcher` rebuilt to `0x2a180..0x2ad7c` (`2936` address-count, `60` dialogue calls, `42` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_butcher` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Butcher was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
 - On March 23, 2026, reimplemented the ScummVM Edna dialogue handler directly against the repaired live `HARVEST.LE` Ghidra body at `handle_talk_to_edna` (`0x358a0..0x3667b`) plus `reset_talk_to_edna_state` (`0x366c0..0x366e8`). The confirmed source drift was concentrated in four native areas the earlier port had truncated or flattened: the top-level `KILLED_KARIN1`, `DNA_S_SUICIDE_NOTE`, purse handoff, and `BRING_KARIN_TO_SHERIFF` branches were missing native flag clears plus the reward-money / `K_PURSE` room-state side effects; the first no-item visit was missing Edna's full `0x3a95` intro flow, the `0x3aa1..0x3aae` / `0x3ab2..0x3ac2` response branches, the `0x3ac6..0x3acf` shared tail, and the native `d2f04` write; the revisit path after `0x3bf8` / `0x3bff` was missing every recovered one-shot story branch for day 5, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `BOLT_OF_CLOTH_TAKEN`, `PC_ESCAPED_JAIL`, `BURNED_TV_STATION`, and the full `KARIN_FOUND_DEAD` nested response tree including the purse-dependent `0xa3` / `0xa4` menu split; and the entire keyword loop from `0xa5` through `0xc2` was absent, including the `C091.FST` and `C070.FST` topic exits plus the shared lodge / kidnapped-dialogue state writes. Patched [`engines/harvester/npc/edna_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/edna_dialogue.cpp), extended Edna's save-backed room flags in [`engines/harvester/npc/edna_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/edna_dialogue.h), bumped the Harvester save blob version in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) so the new Edna state persists cleanly, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Progress counts now align with the current live Ghidra database totals (`914` functions, `122` remaining `FUN_*`).
@@ -68,6 +70,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Dad validation pass that explicitly hits the newly restored native-only paths: `TAKING_BONDAGE` to confirm the `0x3a61 -> C011.FST -> 0x3a6b` tail; first no-item talk to confirm the full `0x39a5` / `0x39a9` / `0x39ad` / `0x39b1[PC2]` / `0x39b5` intro and the shared `d2ed0` / `d2f04` writes; no-item revisits with and without shared state `dadMeatPermissionState` to confirm the automatic `C112S4.FST` permission-conversion branch and the empty visible keyword menu; typed `Lodge`, `The Lodge`, and an unrelated free-text topic through Dad's Other input path to verify the restored `0x3a7d` / `0x3a83` / `0x3a88` special case vs the generic `0x3a2c` fallback; and presented `PHOTO_OF_WHALEY_HERRILL`, note/checkbook evidence, and `MEAT_PERMISSION0` to confirm the corrected shared dialogue-state writes and inventory side effects.
 - Immediate: run an in-engine Butcher and Dad validation pass that explicitly hits the recovered native-only paths: first Butcher talk before and after talking to Dad once to confirm `0x190f` vs `0x1913`; the revisit bark on day 1 with and without `MEAT_PERMISSION0` / `MEAT_PERMISSION` to confirm the `0x261` topic buffer disappears once permission is owned or after day 4; topics `0x263`, `0x267`, `0x26b`, `0x26e`, `0x270`, `0x273/0x274`, `0x277`, and the `0x279` no-op path to verify the restored menu rewrites plus `C119/C060/C119A/C119B/C123/C123A` FST tails; presented `MEAT_PERMISSION` to confirm `MEAT` is awarded and `GOT_MEAT` is set; and then Dad with `MEAT_PERMISSION0` both before and after Butcher grants it to confirm Dad now reads the shared permission state and the first Dad intro seeds `dialogueStateD2ed0`.
 - Immediate: run an in-engine Edna validation pass that explicitly hits the restored native-only branches: `KILLED_KARIN1` to confirm the flag now clears; `DNA_S_SUICIDE_NOTE` with and without `KARIN_FOUND_ALIVE` to confirm `C043.FST` / `C043K.FST`, shared states `d2ea4` / `d2ea8`, and `DNAEXT:SIGNOUT`; direct `K_PURSE` use plus the `KARIN_FOUND_DEAD -> 0xa2 -> 0xa3/0xa4` nested purse menu to verify `REWARD_MONEY` and the `RAH`-hidden purse side effect; the day-5 alive and dead follow-ups; and the keyword loop through the recovered `0xa6`, `0xac/0xad`, `0xb0`, `0xb2`, `0xb6/0xb7`, `0xba`, `0xbc/0xbd/0xbe`, `0xc0`, `0xc2`, typed unknown text, and goodbye branches to confirm the restored menu buffer rewrites and both `C091.FST` / `C070.FST` exits.
 - Immediate: run an in-engine Karin validation pass that explicitly hits the restored native-only branches: the first `KARIN_FOUND_ALIVE` + `IN_CEM10` visit to confirm the full `0x10a5`..`0x10c3` early-return sequence; first and repeat no-item visits before Karin is found alive to confirm the recovered `0x1076`..`0x108b` prelude plus the shared `0x10dc` bark; first and repeat uses of `CASKET_PHOTO` or `PHOTO_OF_WHALEY_HERRILL` to verify the `0x10e7` Edna follow-up, portrait reset, shared evidence-state write, one-shot override, and repeat-photo `CALL_POLICE` escalation; and `INV_MAG` to confirm `0x10ee` / `0x10f2[2]` plus `DNALFT_PERVERT`.
diff --git a/engines/harvester/npc/dad_dialogue.cpp b/engines/harvester/npc/dad_dialogue.cpp
index 2e1ddb4c0a8..31b5bc2d947 100644
--- a/engines/harvester/npc/dad_dialogue.cpp
+++ b/engines/harvester/npc/dad_dialogue.cpp
@@ -23,59 +23,168 @@
 
 #include "harvester/npc/dad_dialogue.h"
 
-#include "harvester/npc/dialogue_flags.h"
 #include "harvester/npc/dialogue_runtime.h"
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kDadNpc = "DAD";
+static const char *const kPcSpeaker = "PC";
+static const char *const kInventoryOwnerName = "INVENTORY";
+static const char *const kDialogueC011FstPath = "GRAPHIC/FST/C011.FST";
+static const char *const kDialogueC112S4FstPath = "GRAPHIC/FST/C112S4.FST";
+
+static const DialogueLineEntry kDadIntroLines[] = {
+	{ 0x39a5, kDadNpc, 0 },
+	{ 0x39a9, kPcSpeaker, 0 },
+	{ 0x39ad, kDadNpc, 0 },
+	{ 0x39b1, kPcSpeaker, 2 }
+};
+
+static const DialogueLineEntry kDadWhaleyPhotoLines[] = {
+	{ 0x3a41, kDadNpc, 0 },
+	{ 0x3a46, kPcSpeaker, 0 },
+	{ 0x3a4a, kDadNpc, 0 }
+};
+
+static const DialogueLineEntry kDadBlackmailEvidenceLines[] = {
+	{ 0x3a51, kDadNpc, 0 },
+	{ 0x3a55, kPcSpeaker, 0 },
+	{ 0x3a59, kDadNpc, 0 }
+};
+
+static const DialogueLineEntry kDadMeatPermissionConversionLines[] = {
+	{ 0x39f2, kPcSpeaker, 0 },
+	{ 0x39f7, kDadNpc, 0 }
+};
+
+static const DialogueLineEntry kDadLodgeTopicLines[] = {
+	{ 0x3a7d, kDadNpc, 0 },
+	{ 0x3a83, kPcSpeaker, 0 },
+	{ 0x3a88, kDadNpc, 0 }
+};
+
+} // End of namespace
+
 bool DadDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("DAD");
+	return npcName.equalsIgnoreCase(kDadNpc);
 }
 
 Common::Error DadDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	DadRoomDialogueState &state = _state;
-	auto playDadLine = [&](int wavId, const char *speakerId = "DAD") -> Common::Error {
-		return runtime.playDialogueLine(wavId, speakerId);
+	auto playDadLine = [&](int wavId, const char *speakerId = kDadNpc,
+			int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, speakerId, headVariant);
+	};
+	auto playSequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
+		return runtime.playDialogueEntrySequence(lines, count);
 	};
+	auto playDadMeatPermissionConversion = [&]() -> Common::Error {
+		Common::Error lineError = playSequence(kDadMeatPermissionConversionLines,
+			ARRAYSIZE(kDadMeatPermissionConversionLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 
-	if (runtime.startupScript().getFlagValue("TAKING_BONDAGE"))
-		return playDadLine(0x3a61);
+		lineError = runtime.playDialogueFst(kDialogueC112S4FstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 
-	if (usedItemName.empty()) {
-		if (state.introPending) {
-			state.introPending = false;
-			sharedState.dialogueStateD2ed0 = true;
-			return playDadLine(0x39a5);
-		}
-		return playDadLine(0x3a17);
-	}
+		sharedState.dialogueStateD2eb4 = 1;
+		(void)runtime.startupScript().addRuntimeObjectToInventory("MEAT_PERMISSION");
+		(void)runtime.startupScript().setRuntimeObjectVisible(
+			kInventoryOwnerName, "MEAT_PERMISSION0", true);
+		return Common::kNoError;
+	};
+	auto isBlackmailEvidence = [&](const Common::String &itemName) {
+		return itemName.equalsIgnoreCase("NOTE") ||
+			itemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+			itemName.equalsIgnoreCase("CHECKBOOK") ||
+			itemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY");
+	};
+	auto isLodgeTopic = [&](const Common::String &topic) {
+		return topic.equalsIgnoreCase("Lodge") || topic.equalsIgnoreCase("The Lodge");
+	};
 
-	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
-		return playDadLine(0x3a39);
+	if (runtime.startupScript().getFlagValue("TAKING_BONDAGE")) {
+		Common::Error lineError = playDadLine(0x3a61);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = runtime.playDialogueFst(kDialogueC011FstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		return playDadLine(0x3a6b);
 	}
-	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(
-			DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-		return playDadLine(0x3a41);
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			return playDadLine(0x3a39);
+		}
+
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			sharedState.discussedWhaleyHerrillPhoto = 1;
+			return playSequence(kDadWhaleyPhotoLines, ARRAYSIZE(kDadWhaleyPhotoLines));
+		}
+
+		if (isBlackmailEvidence(usedItemName)) {
+			sharedState.discussedNoteCheckbookEvidence = 1;
+			return playSequence(kDadBlackmailEvidenceLines, ARRAYSIZE(kDadBlackmailEvidenceLines));
+		}
+
+		if (usedItemName.equalsIgnoreCase("MEAT_PERMISSION0")) {
+			if (sharedState.dadMeatPermissionState != 0)
+				return playDadMeatPermissionConversion();
+
+			return playDadLine(0x3a32);
+		}
+
+		return playDadLine(0x3a32);
 	}
-	if (usedItemName.equalsIgnoreCase("NOTE") ||
-			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
-			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
-			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(
-			DialogueFlags::kShownEvidenceOfBlackmail, true);
-		return playDadLine(0x3a51);
+
+	if (state.introPending) {
+		sharedState.dialogueStateD2ed0 = true;
+		state.introPending = false;
+
+		Common::Error lineError = playSequence(kDadIntroLines, ARRAYSIZE(kDadIntroLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		sharedState.dialogueStateD2f04 = true;
+		return playDadLine(0x39b5);
 	}
-	if (usedItemName.equalsIgnoreCase("MEAT_PERMISSION0")) {
-		if (sharedState.dadMeatPermissionState)
-			return playDadLine(0x39f2, "PC");
-		return playDadLine(0x3a32);
+
+	Common::Error lineError = playDadLine(0x3a17);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	if (sharedState.dadMeatPermissionState != 0) {
+		lineError = playDadMeatPermissionConversion();
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 
-	return playDadLine(0x3a32);
+	const Common::String emptyKeywordList;
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runtime.runKeywordMenu(emptyKeywordList, -1, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.empty() || selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()))
+			return Common::kNoError;
+
+		if (isLodgeTopic(selectedTopic)) {
+			sharedState.discussedLodgeTopic = true;
+			lineError = playSequence(kDadLodgeTopicLines, ARRAYSIZE(kDadLodgeTopicLines));
+		} else {
+			lineError = playDadLine(0x3a2c);
+		}
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
 }
 
 } // End of namespace Harvester


Commit: 36aef9fc38090f640949bdc82862b0710ad7c596
    https://github.com/scummvm/scummvm/commit/36aef9fc38090f640949bdc82862b0710ad7c596
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:58+02:00

Commit Message:
HARVESTER: Fix Herrill dialogue flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/herrill_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 1bda6b46ecb..bb7082e0f4e 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -526,12 +526,12 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - The item path is linear and evidence-driven: `K_PURSE` plays `0x3d05` and sets the one-shot purse bit, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` and the dual-ledger case share `0x3bd6`, `PHOTO_OF_WHALEY_HERRILL` plays `0x3bde`, note/checkbook evidence plays `0x3be5`, TV deed evidence plays `0x3bee`, and all other presented objects fall back to `0x3bcf`.
     - The steady-state no-item tail is also simple: if `KARIN_KIDNAPED` is still clear Edna plays `0x3bf8`, then always ends the branch on `0x3bff`.
   - `handle_talk_to_herrill @ 0x2dc00` is now bounded enough for a direct startup-room port.
-    - The early lodge branch is a strict short-circuit: `HERRILL_IN_LODGE` plus a clear `PC_TALKED_TO_HERRILL` flag plays `HERRILL_LOG 0x31ca` and returns, while the normal room path first checks the Herrill-local suppression and evidence-required bits.
-    - The item path reuses the shared evidence cluster: `PHOTO_OF_WHALEY_HERRILL` plays `0x309f`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` and the dual-ledger case share `0x30a6`, note/checkbook evidence plays `0x30bf`, TV deed evidence plays `0x3188`, and all other presented objects fall back to `0x3099`.
+    - The early lodge branch is a self-contained cutscene gate: `HERRILL_IN_LODGE` plus a clear `PC_TALKED_TO_HERRILL` flag plays `HERRILL_LOG 0x31ca`, `PC 0x31ce[4]`, `HERRILL_LOG 0x31d2`, `PC 0x31da`, and `GRAND_MUCK 0x31de[2]`, conditionally inserts `GRAND_MUCK 0x31e0[2]` plus `HERRILL_LOG 0x31e2[2]` when the shared Whaley/Herrill-photo flag is already set, then monsterfies `GRAND_MUCK`, sets `PC_TALKED_TO_HERRILL`, and returns. The normal room path only runs when that lodge gate is inactive and still begins with Herrill-local suppression / evidence-required checks.
+    - The item path is mostly the shared evidence cluster, but not every branch writes the same shared state: `PHOTO_OF_WHALEY_HERRILL` is only the dedicated `0x309f` bark; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` and the dual-ledger case share `0x30a6` and route through the shared corpse-photo / ledger evidence helpers; note/checkbook evidence plays `0x30bf`; TV deed evidence plays `0x3188`; and all other presented objects fall back to `0x3099`.
     - `GASCAN` is the one special item branch. It first plays the fixed `0x30cf` / `0x30d3` / `0x30d8` exchange, then reads neutral shared talk-state bits at `0xd2e98`, `0xd2eb0`, and `0xd2eec` plus the shared Boyle gas-can application bit. When `0xd2e98` is clear the branch collapses to `PC 0x3123`; otherwise it opens zero-based `dialog.rsp` line `0x280`, where response `1` continues through `0x30e7` and the optional `0x30eb` / `0x30f4` / `0x3104` inserts before the `HAVE_LODGE_APP` split, while response `2` exits on `0x311b`.
     - The first no-item visit is gated by `g_herrill_talk_state_block`: it plays `0x2f59` / `0x2f5e` / `0x2f62` / `0x1654` / `0x2f6e` and seeds the keyword buffer from zero-based `dialog.rsp` line `0x281`. Later no-item visits start on `0x2ffb`.
-    - The steady-state no-item followups are a linear one-shot bark chain over `STEPH_MIDGAME_PLAYED`, story day `5`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `KARIN_KIDNAPED`, `KARIN_FOUND_ALIVE`, `KARIN_FOUND_DEAD`, `DINER_BURNED`, and the shared Whaley/Herrill-photo evidence flag.
-    - Herrill's keyword loop now has a clean branch map: response line `0x283` exits on `0x2ff4`, `0x284` / `0x286` / `0x288` / `0x28a` / `0x28c` / `0x291` each play a single line and loop, `0x28e` sets `get_set_discussed_lodge_topic` before `0x2fdd`, `0x28f` loops silently, and `0x293` exits through `0x3099` then `0x2ff4`.
+    - The steady-state no-item followups are a linear one-shot bark chain over `STEPH_MIDGAME_PLAYED`, story day `5`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `KARIN_KIDNAPED`, `KARIN_FOUND_ALIVE`, `KARIN_FOUND_DEAD`, `DINER_BURNED`, and the shared Whaley/Herrill-photo evidence flag. `KARIN_FOUND_ALIVE` is only `0x304c[1]`; `KARIN_FOUND_DEAD` is `0x305e`, optional `0x3067[3]` behind `STEPH_MIDGAME_PLAYED`, then `0x306b[2]` or `0x3073[3]` depending on the shared photo flag.
+    - Herrill's keyword loop now has a clean branch map: response lines `0x283` and `0x293` are both `BYE` exits on `0x2ff4`; `0x284` / `0x286` / `0x288` / `0x28a` / `0x28c` / `0x291` each play a single line and loop; `0x28e` and `0x28f` both set `get_set_discussed_lodge_topic` before `0x2fdd`; and unmatched typed text falls back through `0x3099` then `0x2ff4`.
   - `handle_talk_to_johnson @ 0x27dd0` is now bounded enough for a direct startup-room port.
     - Johnson has three early short-circuit branches before the normal room flow: `2ND_SCRATCH_TUCKER` returns on `0xbe6`, `TRY_TO_SCRATCH_TUCKER` is a one-shot `0xbe0` bark keyed by the local flag at `0xd2bf8`, and `OPENING_MANHOLE_DAYTIME` plays `0xbe0`, clears that runtime flag, and returns.
     - The item path is another shared-evidence cluster, but several branches are multi-line rather than single barks: `PHOTO_OF_WHALEY_HERRILL` plays `0xb75` / `PC 0xb7b` / `0xb80`; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` always plays `0xb89`, and when shared state `d2ebc` is set it continues through `PC 0xb90` / `0xb94` / `0xb99`; the dual-ledger case plays `0xba0` / `PC 0xba4` / `0xba8`; note/checkbook evidence plays `0xbb1`; TV deed evidence plays `0xbce`; and all other presented objects fall back to `0xb6f`.
diff --git a/TRACKER.md b/TRACKER.md
index c666cc58aa0..a235d5c1637 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,15 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `915`
+- Total functions: `912`
 - Named/documented: `792`
-- Remaining `FUN_*`: `123`
-- Remaining undocumented total: `123`
+- Remaining `FUN_*`: `120`
+- Remaining undocumented total: `120`
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the remaining confirmed Herrill dialogue mismatches in ScummVM directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_herrill` (`0x2dc00..0x2e987`). The confirmed source drift was concentrated in four native areas the earlier port had flattened or misread: the `HERRILL_IN_LODGE` branch was truncated to a single `0x31ca` bark and was missing the native `0x31ce` / `0x31d2` / `0x31da` / `0x31de` / optional `0x31e0` / `0x31e2` sequence plus the `GRAND_MUCK` slash-transition and `PC_TALKED_TO_HERRILL` write; the `PHOTO_OF_WHALEY_HERRILL` item branch was incorrectly writing the shared Whaley/Herrill-photo runtime flag even though native only plays `0x309f`; the steady-state `KARIN_FOUND_ALIVE` / `KARIN_FOUND_DEAD` followups had non-native extra lines (`0x3050` / `0x3057` and `PC 0x461b`) that are absent from the real handler; and the keyword loop mishandled `The Lodge`, the second `BYE` line at `0x293`, and the unmatched typed-topic fallback that should run `0x3099` before `0x2ff4`. Patched [`engines/harvester/npc/herrill_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/herrill_dialogue.cpp), corrected the confirmed Herrill notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and refreshed the tracker progress totals from the current live Ghidra database (`912` functions, `120` remaining `FUN_*`).
+- On March 23, 2026, reran `repair HERRILL` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Herrill showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_herrill` cleanly to `0x2dc00..0x2e987` (`764` address-count, `17` dialogue calls under the current tail-jump-aware verifier) but the fresh verify fell back to the short `0x2dc00..0x2e987` body with only `4` reachable dialogue calls, `3` stale dialogue gaps, and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Herrill durably clean again at `0x2dc00..0x2e987` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Herrill now behaves like the broader post-save retry class rather than the old verifier-only anomaly.
 - On March 23, 2026, reimplemented the ScummVM Dad dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_dad` (`0x2b020..0x2b581`) plus `reset_talk_to_dad_state` (`0x2b5a0..0x2b5a6`) and the shared Dad permission helper at `get_set_dad_meat_permission_state` (`0x38400..0x3840f`). The confirmed source drift was concentrated in four native areas the earlier port had flattened or miswired: the `TAKING_BONDAGE` branch was missing Dad's second `0x3a6b` line and the intervening `C011.FST` tail after `0x3a61`; the item branches were writing the wrong ScummVM runtime evidence flags, missing Dad's multi-line Whaley-photo and note/checkbook exchanges, and omitting the `MEAT_PERMISSION0` conversion path that plays `0x39f2` / `0x39f7`, runs `C112S4.FST`, sets shared state `d2eb4`, adds `MEAT_PERMISSION`, and restores `MEAT_PERMISSION0` visibility in inventory; the first no-item visit was missing Dad's full `0x39a5` / `0x39a9` / `0x39ad` / `0x39b1[PC2]` / `0x39b5` intro plus the shared `d2f04` write; and the revisit path was incorrectly ending after `0x3a17` instead of running Dad's native Other-only keyword loop, which leaves the visible topic list empty, only special-cases typed `Lodge` / `The Lodge` through `0x3a7d` / `0x3a83` / `0x3a88`, and otherwise falls back to `0x3a2c`. Patched [`engines/harvester/npc/dad_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dad_dialogue.cpp), preserved the existing save layout in [`engines/harvester/npc/dad_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dad_dialogue.h), rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and refreshed the tracker progress totals from the current live Ghidra database (`915` functions, `123` remaining `FUN_*`).
 - On March 23, 2026, reran `repair DAD` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Dad showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_dad` cleanly to `0x2b020..0x2b581` (`954` address-count, `20` dialogue calls) but the fresh verify fell back to the short `0x2b020..0x2b2e9` body with `7` stale dialogue gaps and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Dad durably clean again at `0x2b020..0x2b581` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Dad belongs in the same post-save retry class as Stephanie, Mr. Potts, Johnson, Loomis, and similar handlers.
 - On March 23, 2026, reimplemented the ScummVM Butcher dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_butcher` (`0x2a180..0x2ad7c`) plus `reset_talk_to_butcher_state` (`0x2ada0..0x2adb0`). The confirmed source drift was concentrated in five native areas the earlier stub omitted: the first no-item visit was missing Butcher's full `0x1902..0x1921` intro sequence, the `dialogueStateD2ed0` conditional on `0x190f` vs `0x1913`, and the correct initial topic buffer; the revisit path was missing the native topic-menu gating on `MEAT_PERMISSION0` / `MEAT_PERMISSION` ownership and story day; the handler had no recovered keyword loop for topics `0x263`, `0x267`, `0x26b`, `0x26e`, `0x270`, `0x273/0x274`, `0x277`, and the `0x279` no-op path, including the `C119.FST`, `C060.FST`, `C119A.FST`, `C119B.FST`, `C123.FST`, `C123A.FST`, `C123B.FST`, and `C119C.FST` tails plus the menu-driven topic-buffer rewrites; the item branches for Whaley's photo, the corpse photo, both ledgers, note/checkbook evidence, and presented `MEAT_PERMISSION` were missing their native multi-line exchanges, the `C060A.FST` reward tail, and the `MEAT` inventory / `GOT_MEAT` side effects; and the native cross-handler state writes into `dad_meat_permission_state`, `dialogueStateD2ed0`, and `discussedLodgeTopic` were absent. Patched [`engines/harvester/npc/butcher_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/butcher_dialogue.cpp), extended Butcher's save-backed room state in [`engines/harvester/npc/butcher_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/butcher_dialogue.h), fixed Dad's two native shared-state dependencies in [`engines/harvester/npc/dad_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dad_dialogue.cpp), bumped the Harvester save format to `7` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts now align with the current live Ghidra database totals (`924` functions, `132` remaining `FUN_*`).
@@ -70,6 +72,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Herrill validation pass that explicitly hits the corrected native-only paths: `HERRILL_IN_LODGE` before `PC_TALKED_TO_HERRILL` to confirm the full `0x31ca` / `0x31ce[4]` / `0x31d2` / `0x31da` / `0x31de[2]` sequence, the optional `0x31e0[2]` / `0x31e2[2]` insert when the shared Whaley/Herrill-photo flag is already set, the `GRAND_MUCK` monsterfy transition, and the one-shot `PC_TALKED_TO_HERRILL` gate; `PHOTO_OF_WHALEY_HERRILL` followed by a no-item revisit to confirm Herrill no longer self-arms the shared photo followup; `KARIN_FOUND_ALIVE` and `KARIN_FOUND_DEAD` to verify the non-native `0x3050` / `0x3057` and `PC 0x461b` lines are gone; `Lodge`, `The Lodge`, and an unrelated typed topic through the keyword loop to confirm `0x2fdd` on both lodge aliases versus the `0x3099 -> 0x2ff4` unknown-topic fallback; and both visible `BYE` entries to confirm they now share the direct `0x2ff4` exit.
 - Immediate: run an in-engine Dad validation pass that explicitly hits the newly restored native-only paths: `TAKING_BONDAGE` to confirm the `0x3a61 -> C011.FST -> 0x3a6b` tail; first no-item talk to confirm the full `0x39a5` / `0x39a9` / `0x39ad` / `0x39b1[PC2]` / `0x39b5` intro and the shared `d2ed0` / `d2f04` writes; no-item revisits with and without shared state `dadMeatPermissionState` to confirm the automatic `C112S4.FST` permission-conversion branch and the empty visible keyword menu; typed `Lodge`, `The Lodge`, and an unrelated free-text topic through Dad's Other input path to verify the restored `0x3a7d` / `0x3a83` / `0x3a88` special case vs the generic `0x3a2c` fallback; and presented `PHOTO_OF_WHALEY_HERRILL`, note/checkbook evidence, and `MEAT_PERMISSION0` to confirm the corrected shared dialogue-state writes and inventory side effects.
 - Immediate: run an in-engine Butcher and Dad validation pass that explicitly hits the recovered native-only paths: first Butcher talk before and after talking to Dad once to confirm `0x190f` vs `0x1913`; the revisit bark on day 1 with and without `MEAT_PERMISSION0` / `MEAT_PERMISSION` to confirm the `0x261` topic buffer disappears once permission is owned or after day 4; topics `0x263`, `0x267`, `0x26b`, `0x26e`, `0x270`, `0x273/0x274`, `0x277`, and the `0x279` no-op path to verify the restored menu rewrites plus `C119/C060/C119A/C119B/C123/C123A` FST tails; presented `MEAT_PERMISSION` to confirm `MEAT` is awarded and `GOT_MEAT` is set; and then Dad with `MEAT_PERMISSION0` both before and after Butcher grants it to confirm Dad now reads the shared permission state and the first Dad intro seeds `dialogueStateD2ed0`.
 - Immediate: run an in-engine Edna validation pass that explicitly hits the restored native-only branches: `KILLED_KARIN1` to confirm the flag now clears; `DNA_S_SUICIDE_NOTE` with and without `KARIN_FOUND_ALIVE` to confirm `C043.FST` / `C043K.FST`, shared states `d2ea4` / `d2ea8`, and `DNAEXT:SIGNOUT`; direct `K_PURSE` use plus the `KARIN_FOUND_DEAD -> 0xa2 -> 0xa3/0xa4` nested purse menu to verify `REWARD_MONEY` and the `RAH`-hidden purse side effect; the day-5 alive and dead follow-ups; and the keyword loop through the recovered `0xa6`, `0xac/0xad`, `0xb0`, `0xb2`, `0xb6/0xb7`, `0xba`, `0xbc/0xbd/0xbe`, `0xc0`, `0xc2`, typed unknown text, and goodbye branches to confirm the restored menu buffer rewrites and both `C091.FST` / `C070.FST` exits.
diff --git a/engines/harvester/npc/herrill_dialogue.cpp b/engines/harvester/npc/herrill_dialogue.cpp
index 1359d1c2248..b23684c3b26 100644
--- a/engines/harvester/npc/herrill_dialogue.cpp
+++ b/engines/harvester/npc/herrill_dialogue.cpp
@@ -31,21 +31,27 @@ namespace Harvester {
 
 namespace {
 
+static const char *const kHerrillNpc = "HERRILL";
+static const char *const kHerrillLogNpc = "HERRILL_LOG";
+static const char *const kPcSpeaker = "PC";
+static const char *const kGrandMuckNpc = "GRAND_MUCK";
+static const int kSlashDeathDamageType = 1;
+
 static const int kHerrillGascanResponseLine = 0x280;
 static const int kHerrillTopicBufferResponseLine = 0x281;
 
 static const DialogueLineEntry kHerrillIntroLines[] = {
-	{ 0x2f59, "HERRILL", 1 },
-	{ 0x2f5e, "PC", 0 },
-	{ 0x2f62, "HERRILL", 1 },
-	{ 0x1654, "PC", 0 },
-	{ 0x2f6e, "HERRILL", 0 }
+	{ 0x2f59, kHerrillNpc, 1 },
+	{ 0x2f5e, kPcSpeaker, 0 },
+	{ 0x2f62, kHerrillNpc, 1 },
+	{ 0x1654, kPcSpeaker, 0 },
+	{ 0x2f6e, kHerrillNpc, 0 }
 };
 
 static const DialogueLineEntry kHerrillGascanIntroLines[] = {
-	{ 0x30cf, "HERRILL", 1 },
-	{ 0x30d3, "PC", 0 },
-	{ 0x30d8, "HERRILL", 0 }
+	{ 0x30cf, kHerrillNpc, 1 },
+	{ 0x30d3, kPcSpeaker, 0 },
+	{ 0x30d8, kHerrillNpc, 0 }
 };
 
 } // End of namespace
@@ -65,13 +71,24 @@ Common::Error HerrillDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			responseLineIndex, "Herrill topic buffer");
 	};
 	auto playHerrillLine = [&](int wavId, int headVariant = 0) -> Common::Error {
-		return runtime.playDialogueLineWithVariant(wavId, "HERRILL", headVariant);
+		return runtime.playDialogueLineWithVariant(wavId, kHerrillNpc, headVariant);
 	};
 	auto playHerrillLogLine = [&](int wavId, int headVariant = 0) -> Common::Error {
-		return runtime.playDialogueLineWithVariant(wavId, "HERRILL_LOG", headVariant);
+		return runtime.playDialogueLineWithVariant(wavId, kHerrillLogNpc, headVariant);
 	};
 	auto playPcLine = [&](int wavId, int headVariant = 0) -> Common::Error {
-		return runtime.playDialogueLineWithVariant(wavId, "PC", headVariant);
+		return runtime.playDialogueLineWithVariant(wavId, kPcSpeaker, headVariant);
+	};
+	auto playGrandMuckLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kGrandMuckNpc, headVariant);
+	};
+	auto queueNpcSlashTransition = [&](const char *npcName) {
+		StartupInteractionResult interaction;
+		if (runtime.startupScript().triggerRuntimeNpcDeathOrMonsterfy(
+				npcName, kSlashDeathDamageType)) {
+			interaction.mutatedRuntimeState = true;
+		}
+		runtime.queueDialogueInteractionIfNeeded(interaction);
 	};
 	auto hasInventoryItem = [&](const char *objectName) {
 		Common::Array<StartupObjectRecord> inventoryObjects;
@@ -96,8 +113,34 @@ Common::Error HerrillDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 
 	if (runtime.startupScript().getFlagValue("HERRILL_IN_LODGE")) {
-		if (!runtime.startupScript().getFlagValue("PC_TALKED_TO_HERRILL"))
-			return playHerrillLogLine(0x31ca, 1);
+		if (!runtime.startupScript().getFlagValue("PC_TALKED_TO_HERRILL")) {
+			Common::Error lineError = playHerrillLogLine(0x31ca, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playPcLine(0x31ce, 4);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playHerrillLogLine(0x31d2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playPcLine(0x31da);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playGrandMuckLine(0x31de, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			if (shownWhaleyHerrillPhoto()) {
+				lineError = playGrandMuckLine(0x31e0, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playHerrillLogLine(0x31e2, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			queueNpcSlashTransition(kGrandMuckNpc);
+			(void)runtime.startupScript().setRuntimeFlagValue("PC_TALKED_TO_HERRILL", true);
+		}
 		return Common::kNoError;
 	}
 
@@ -108,7 +151,6 @@ Common::Error HerrillDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
 			return playHerrillLine(0x309f, 2);
 		}
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
@@ -260,19 +302,11 @@ Common::Error HerrillDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		Common::Error lineError = playHerrillLine(0x304c, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		lineError = shownWhaleyHerrillPhoto()
-			? playHerrillLine(0x3050, 2)
-			: playHerrillLine(0x3057, 1);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
 	}
 	if (runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
 			!state.karinFoundDeadShown) {
 		state.karinFoundDeadShown = true;
 		Common::Error lineError = playHerrillLine(0x305e);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		lineError = playPcLine(0x461b, 3);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 		if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED")) {
@@ -351,21 +385,25 @@ Common::Error HerrillDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				return lineError;
 			continue;
 		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x28f))
+		if (runtime.matchesResponseLine(selectedTopic, 0x28f)) {
+			sharedState.discussedLodgeTopic = true;
+			Common::Error lineError = playHerrillLine(0x2fdd);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 			continue;
+		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x291)) {
 			Common::Error lineError = playHerrillLine(0x2fe9, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			continue;
 		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x293)) {
-			Common::Error lineError = playHerrillLine(0x3099);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
+		if (runtime.matchesResponseLine(selectedTopic, 0x293))
 			return playHerrillLine(0x2ff4);
-		}
 
+		Common::Error lineError = playHerrillLine(0x3099);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 		return playHerrillLine(0x2ff4);
 	}
 }


Commit: 51de7bb07d08b67a06ae6e8bc703b9d2dd21113d
    https://github.com/scummvm/scummvm/commit/51de7bb07d08b67a06ae6e8bc703b9d2dd21113d
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:59+02:00

Commit Message:
HARVESTER: Reimplement moynahan dialogue handler

Changed paths:
    TRACKER.md
    engines/harvester/npc/moynahan_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index a235d5c1637..da88592d040 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,8 @@
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Moynahan dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_moynahan` (`0x24a20..0x25f43`). The confirmed source drift was concentrated in five native areas the earlier port had flattened or miswired: the `IF_TRY_TO_TAKE_THE_GLUE` interrupt and several presented-item branches were using wrong head variants and were missing the native response-menu continuations for `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, both ledgers, note/checkbook evidence, and `WED_MATCHES`; the handler was writing the wrong shared state for Whaley-photo and blackmail evidence, rearming the intro off the wrong corpse-photo state, and omitting the `KARIN_KIDNAPED` tail's `0x4041` line plus the shared kidnapped-discussion writes; the late no-item bark chain after `0x3fd1` still had truncated control flow and non-native portrait variants on the slash/body/day-5/Butcher/Jimmy/Karin-dead branches; the typed keyword loop was incorrectly treated as mostly terminal single-line replies instead of a looping menu that rewrites the live topic buffer through `0x19c`, `0x1a1`, `0x1a6` / `0x1a7` / `0x1a8`, `0x1ad`, `0x1b0` / `0x1b1`, and `0x1b6`; and the day-5 glue-topic path `0x1bf..0x1c2` plus response menu `0x1c3` was missing its native `GLUE` inventory / `JUST_GOT_GLUE` side effects. Patched [`engines/harvester/npc/moynahan_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/moynahan_dialogue.cpp), preserved the existing save layout in [`engines/harvester/npc/moynahan_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/moynahan_dialogue.h), rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully, and refreshed the tracker progress totals from the current live Ghidra database (`912` functions, `120` remaining `FUN_*`).
+- On March 23, 2026, reran the full durable `repair MOYNAHAN -> wait -> save_program -> verify MOYNAHAN` cycle against the live `HARVEST.LE` session to confirm Moynahan still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_moynahan` rebuilt to `0x24a20..0x25f43` (`5254` address-count, `117` dialogue calls, `59` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_moynahan` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Moynahan was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
 - On March 23, 2026, reimplemented the remaining confirmed Herrill dialogue mismatches in ScummVM directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_herrill` (`0x2dc00..0x2e987`). The confirmed source drift was concentrated in four native areas the earlier port had flattened or misread: the `HERRILL_IN_LODGE` branch was truncated to a single `0x31ca` bark and was missing the native `0x31ce` / `0x31d2` / `0x31da` / `0x31de` / optional `0x31e0` / `0x31e2` sequence plus the `GRAND_MUCK` slash-transition and `PC_TALKED_TO_HERRILL` write; the `PHOTO_OF_WHALEY_HERRILL` item branch was incorrectly writing the shared Whaley/Herrill-photo runtime flag even though native only plays `0x309f`; the steady-state `KARIN_FOUND_ALIVE` / `KARIN_FOUND_DEAD` followups had non-native extra lines (`0x3050` / `0x3057` and `PC 0x461b`) that are absent from the real handler; and the keyword loop mishandled `The Lodge`, the second `BYE` line at `0x293`, and the unmatched typed-topic fallback that should run `0x3099` before `0x2ff4`. Patched [`engines/harvester/npc/herrill_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/herrill_dialogue.cpp), corrected the confirmed Herrill notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and refreshed the tracker progress totals from the current live Ghidra database (`912` functions, `120` remaining `FUN_*`).
 - On March 23, 2026, reran `repair HERRILL` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Herrill showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_herrill` cleanly to `0x2dc00..0x2e987` (`764` address-count, `17` dialogue calls under the current tail-jump-aware verifier) but the fresh verify fell back to the short `0x2dc00..0x2e987` body with only `4` reachable dialogue calls, `3` stale dialogue gaps, and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Herrill durably clean again at `0x2dc00..0x2e987` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Herrill now behaves like the broader post-save retry class rather than the old verifier-only anomaly.
 - On March 23, 2026, reimplemented the ScummVM Dad dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_dad` (`0x2b020..0x2b581`) plus `reset_talk_to_dad_state` (`0x2b5a0..0x2b5a6`) and the shared Dad permission helper at `get_set_dad_meat_permission_state` (`0x38400..0x3840f`). The confirmed source drift was concentrated in four native areas the earlier port had flattened or miswired: the `TAKING_BONDAGE` branch was missing Dad's second `0x3a6b` line and the intervening `C011.FST` tail after `0x3a61`; the item branches were writing the wrong ScummVM runtime evidence flags, missing Dad's multi-line Whaley-photo and note/checkbook exchanges, and omitting the `MEAT_PERMISSION0` conversion path that plays `0x39f2` / `0x39f7`, runs `C112S4.FST`, sets shared state `d2eb4`, adds `MEAT_PERMISSION`, and restores `MEAT_PERMISSION0` visibility in inventory; the first no-item visit was missing Dad's full `0x39a5` / `0x39a9` / `0x39ad` / `0x39b1[PC2]` / `0x39b5` intro plus the shared `d2f04` write; and the revisit path was incorrectly ending after `0x3a17` instead of running Dad's native Other-only keyword loop, which leaves the visible topic list empty, only special-cases typed `Lodge` / `The Lodge` through `0x3a7d` / `0x3a83` / `0x3a88`, and otherwise falls back to `0x3a2c`. Patched [`engines/harvester/npc/dad_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dad_dialogue.cpp), preserved the existing save layout in [`engines/harvester/npc/dad_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dad_dialogue.h), rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and refreshed the tracker progress totals from the current live Ghidra database (`915` functions, `123` remaining `FUN_*`).
@@ -72,6 +74,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Moynahan validation pass that explicitly hits the corrected native-only branches: `IF_TRY_TO_TAKE_THE_GLUE` before and after the slashes-on-body topic unlock to confirm the short `0x3e76` / `0x3e7a` versus extended `0x3e5b..0x3e71` interrupt; presented `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, both ledgers, note/checkbook evidence, `PHOTO_OF_WHALEY_HERRILL`, and `WED_MATCHES` to verify the restored response menus, shared-state writes, portrait variants, and both glue handoff paths; a no-item revisit after shared corpse-photo evidence to confirm `0x3f5b` re-arms the intro; day 5, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, Butcher death, Jimmy death, and `KARIN_FOUND_DEAD` to verify the one-shot bark chain plus the shared kidnapped-discussion writes; and the keyword loop through `0x19a/0x19b`, `0x19d..0x1a0`, `0x1a2/0x1a3`, `0x1a9..0x1ab`, `0x1b2/0x1b3`, `0x1b4/0x1b5` in `MBLM`, `0x1b8..0x1be`, `0x1bf..0x1c2`, typed unknown text, and both visible `BYE` entries to confirm the restored topic-buffer rewrites and fallback flow.
 - Immediate: run an in-engine Herrill validation pass that explicitly hits the corrected native-only paths: `HERRILL_IN_LODGE` before `PC_TALKED_TO_HERRILL` to confirm the full `0x31ca` / `0x31ce[4]` / `0x31d2` / `0x31da` / `0x31de[2]` sequence, the optional `0x31e0[2]` / `0x31e2[2]` insert when the shared Whaley/Herrill-photo flag is already set, the `GRAND_MUCK` monsterfy transition, and the one-shot `PC_TALKED_TO_HERRILL` gate; `PHOTO_OF_WHALEY_HERRILL` followed by a no-item revisit to confirm Herrill no longer self-arms the shared photo followup; `KARIN_FOUND_ALIVE` and `KARIN_FOUND_DEAD` to verify the non-native `0x3050` / `0x3057` and `PC 0x461b` lines are gone; `Lodge`, `The Lodge`, and an unrelated typed topic through the keyword loop to confirm `0x2fdd` on both lodge aliases versus the `0x3099 -> 0x2ff4` unknown-topic fallback; and both visible `BYE` entries to confirm they now share the direct `0x2ff4` exit.
 - Immediate: run an in-engine Dad validation pass that explicitly hits the newly restored native-only paths: `TAKING_BONDAGE` to confirm the `0x3a61 -> C011.FST -> 0x3a6b` tail; first no-item talk to confirm the full `0x39a5` / `0x39a9` / `0x39ad` / `0x39b1[PC2]` / `0x39b5` intro and the shared `d2ed0` / `d2f04` writes; no-item revisits with and without shared state `dadMeatPermissionState` to confirm the automatic `C112S4.FST` permission-conversion branch and the empty visible keyword menu; typed `Lodge`, `The Lodge`, and an unrelated free-text topic through Dad's Other input path to verify the restored `0x3a7d` / `0x3a83` / `0x3a88` special case vs the generic `0x3a2c` fallback; and presented `PHOTO_OF_WHALEY_HERRILL`, note/checkbook evidence, and `MEAT_PERMISSION0` to confirm the corrected shared dialogue-state writes and inventory side effects.
 - Immediate: run an in-engine Butcher and Dad validation pass that explicitly hits the recovered native-only paths: first Butcher talk before and after talking to Dad once to confirm `0x190f` vs `0x1913`; the revisit bark on day 1 with and without `MEAT_PERMISSION0` / `MEAT_PERMISSION` to confirm the `0x261` topic buffer disappears once permission is owned or after day 4; topics `0x263`, `0x267`, `0x26b`, `0x26e`, `0x270`, `0x273/0x274`, `0x277`, and the `0x279` no-op path to verify the restored menu rewrites plus `C119/C060/C119A/C119B/C123/C123A` FST tails; presented `MEAT_PERMISSION` to confirm `MEAT` is awarded and `GOT_MEAT` is set; and then Dad with `MEAT_PERMISSION0` both before and after Butcher grants it to confirm Dad now reads the shared permission state and the first Dad intro seeds `dialogueStateD2ed0`.
diff --git a/engines/harvester/npc/moynahan_dialogue.cpp b/engines/harvester/npc/moynahan_dialogue.cpp
index 3fcdbd9941f..8aa4d8260dd 100644
--- a/engines/harvester/npc/moynahan_dialogue.cpp
+++ b/engines/harvester/npc/moynahan_dialogue.cpp
@@ -23,7 +23,6 @@
 
 #include "harvester/npc/moynahan_dialogue.h"
 
-#include "harvester/npc/dialogue_flags.h"
 #include "harvester/npc/dialogue_runtime.h"
 #include "harvester/text.h"
 
@@ -31,6 +30,15 @@ namespace Harvester {
 
 namespace {
 
+static const char *const kMoynahanNpc = "MOYNAHAN";
+static const char *const kMoynahanEmbalmingPortrait = "MOYNAHAN_MBLM";
+static const char *const kPcSpeaker = "PC";
+static const char *const kMoynahanHotelRoom = "HTL-RT";
+static const char *const kMoynahanEmbalmingRoom = "MBLM";
+static const char *const kRahRoom = "RAH";
+static const char *const kGlueObject = "GLUE";
+static const char *const kJustGotGlueFlag = "JUST_GOT_GLUE";
+
 static const int kMoynahanGoodCauseTopicLines[] = { 0x19a, 0x19b };
 static const int kMoynahanSlashMarksTopicLines[] = { 0x19d, 0x19e, 0x19f, 0x1a0 };
 static const int kMoynahanBodyTopicLines[] = { 0x1a2, 0x1a3 };
@@ -40,13 +48,147 @@ static const int kMoynahanEmbalmingRoomTopicLines[] = { 0x1b4, 0x1b5 };
 static const int kMoynahanLateTopicLines[] = { 0x1b8, 0x1b9, 0x1ba, 0x1bb, 0x1bc, 0x1bd, 0x1be };
 static const int kMoynahanGlueTopicLines[] = { 0x1bf, 0x1c0, 0x1c1, 0x1c2 };
 
-static const char *const kMoynahanNpc = "MOYNAHAN";
-static const char *const kMoynahanEmbalmingPortrait = "MOYNAHAN_MBLM";
-static const char *const kMoynahanHotelRoom = "HTL-RT";
-static const char *const kMoynahanEmbalmingRoom = "MBLM";
-static const char *const kRahRoom = "RAH";
-static const char *const kGlueObject = "GLUE";
-static const char *const kJustGotGlueFlag = "JUST_GOT_GLUE";
+static const DialogueLineEntry kMoynahanGlueInterruptOpeningLines[] = {
+	{ 0x3e4d, kMoynahanNpc, 2 },
+	{ 0x3e52, kPcSpeaker, 0 },
+	{ 0x3e56, kMoynahanNpc, 0 }
+};
+
+static const DialogueLineEntry kMoynahanGlueInterruptShortReplyLines[] = {
+	{ 0x3e76, kPcSpeaker, 0 },
+	{ 0x3e7a, kMoynahanNpc, 0 }
+};
+
+static const DialogueLineEntry kMoynahanGlueInterruptExtendedReplyLines[] = {
+	{ 0x3e5b, kPcSpeaker, 0 },
+	{ 0x3e60, kMoynahanNpc, 0 },
+	{ 0x3e64, kPcSpeaker, 2 },
+	{ 0x3e69, kMoynahanNpc, 0 },
+	{ 0x3e6d, kPcSpeaker, 2 },
+	{ 0x3e71, kMoynahanNpc, 2 }
+};
+
+static const DialogueLineEntry kMoynahanIntroLines[] = {
+	{ 0x3d33, kMoynahanNpc, 0 },
+	{ 0x3d37, kPcSpeaker, 0 },
+	{ 0x3d3b, kMoynahanNpc, 0 },
+	{ 0x3d40, kPcSpeaker, 0 },
+	{ 0x3d45, kMoynahanNpc, 0 },
+	{ 0x3d47, kMoynahanNpc, 0 }
+};
+
+static const DialogueLineEntry kMoynahanSlashMarksIntroLines[] = {
+	{ 0x3da0, kMoynahanNpc, 3 },
+	{ 0x3da1, kMoynahanNpc, 3 },
+	{ 0x3da2, kMoynahanNpc, 2 }
+};
+
+static const DialogueLineEntry kMoynahanDayFiveLines[] = {
+	{ 0x3fe5, kMoynahanNpc, 1 },
+	{ 0x3fea, kPcSpeaker, 0 },
+	{ 0x3fee, kMoynahanNpc, 2 },
+	{ 0x3ff3, kPcSpeaker, 0 },
+	{ 0x3ff7, kMoynahanNpc, 0 }
+};
+
+static const DialogueLineEntry kMoynahanKarinKidnapedTailLines[] = {
+	{ 0x4020, kMoynahanNpc, 0 },
+	{ 0x4026, kPcSpeaker, 0 },
+	{ 0x402a, kMoynahanNpc, 0 },
+	{ 0x4034, kPcSpeaker, 0 },
+	{ 0x4038, kMoynahanNpc, 0 },
+	{ 0x403d, kPcSpeaker, 0 },
+	{ 0x4041, kMoynahanNpc, 1 }
+};
+
+static const DialogueLineEntry kMoynahanCasketIntroLines[] = {
+	{ 0x3e84, kMoynahanNpc, 0 },
+	{ 0x3e89, kPcSpeaker, 0 },
+	{ 0x3e8e, kMoynahanNpc, 0 },
+	{ 0x3e93, kPcSpeaker, 0 },
+	{ 0x3e98, kMoynahanNpc, 2 }
+};
+
+static const DialogueLineEntry kMoynahanCasketResponseOneLines[] = {
+	{ 0x3ea4, kMoynahanNpc, 0 },
+	{ 0x3ea8, kPcSpeaker, 2 },
+	{ 0x3ead, kMoynahanNpc, 2 }
+};
+
+static const DialogueLineEntry kMoynahanCasketFollowupLines[] = {
+	{ 0x3eb7, kPcSpeaker, 2 },
+	{ 0x3ebc, kMoynahanNpc, 2 }
+};
+
+static const DialogueLineEntry kMoynahanLedgerIntroLines[] = {
+	{ 0x3ede, kPcSpeaker, 0 },
+	{ 0x3ee3, kMoynahanNpc, 0 },
+	{ 0x3ee7, kPcSpeaker, 0 },
+	{ 0x3eec, kMoynahanNpc, 0 }
+};
+
+static const DialogueLineEntry kMoynahanLedgerResponseOneLines[] = {
+	{ 0x3ef7, kMoynahanNpc, 0 },
+	{ 0x3efb, kPcSpeaker, 0 },
+	{ 0x3eff, kMoynahanNpc, 2 },
+	{ 0x3f05, kPcSpeaker, 2 }
+};
+
+static const DialogueLineEntry kMoynahanLedgerResponseTwoLines[] = {
+	{ 0x3f09, kMoynahanNpc, 0 },
+	{ 0x3f0f, kPcSpeaker, 2 },
+	{ 0x3f13, kMoynahanNpc, 0 },
+	{ 0x3f18, kPcSpeaker, 2 }
+};
+
+static const DialogueLineEntry kMoynahanMatchesLines[] = {
+	{ 0x4049, kPcSpeaker, 0 },
+	{ 0x404d, kMoynahanNpc, 0 }
+};
+
+static const DialogueLineEntry kMoynahanGoodCauseLines[] = {
+	{ 0x3d5f, kMoynahanNpc, 0 },
+	{ 0x3d66, kPcSpeaker, 0 },
+	{ 0x3d6a, kMoynahanNpc, 0 }
+};
+
+static const DialogueLineEntry kMoynahanSlashMarksTopicReplyLines[] = {
+	{ 0x3d76, kMoynahanNpc, 0 },
+	{ 0x3d7d, kPcSpeaker, 0 },
+	{ 0x3d81, kMoynahanNpc, 0 },
+	{ 0x3d88, kPcSpeaker, 0 },
+	{ 0x3d8c, kMoynahanNpc, 0 },
+	{ 0x3d92, kPcSpeaker, 0 },
+	{ 0x3d96, kMoynahanNpc, 0 }
+};
+
+static const DialogueLineEntry kMoynahanGeneralTopicResponseOneLines[] = {
+	{ 0x3dbe, kMoynahanNpc, 1 },
+	{ 0x3dc4, kPcSpeaker, 0 },
+	{ 0x3dc8, kMoynahanNpc, 2 }
+};
+
+static const DialogueLineEntry kMoynahanGeneralTopicResponseTwoLines[] = {
+	{ 0x3dcf, kMoynahanNpc, 0 },
+	{ 0x3dd4, kPcSpeaker, 0 },
+	{ 0x3dd8, kMoynahanNpc, 1 },
+	{ 0x3ddd, kPcSpeaker, 0 },
+	{ 0x3de2, kMoynahanNpc, 2 }
+};
+
+static const DialogueLineEntry kMoynahanDayFiveUnlockLines[] = {
+	{ 0x3df5, kMoynahanNpc, 3 },
+	{ 0x3dfc, kPcSpeaker, 0 },
+	{ 0x3e00, kMoynahanNpc, 0 },
+	{ 0x3e05, kPcSpeaker, 0 },
+	{ 0x3e0a, kMoynahanNpc, 0 }
+};
+
+static const DialogueLineEntry kMoynahanEmbalmingRoomLines[] = {
+	{ 0x3e18, kMoynahanNpc, 0 },
+	{ 0x3e22, kPcSpeaker, 0 },
+	{ 0x3e26, kMoynahanNpc, 2 }
+};
 
 static Common::Error playDialogueSequence(DialogueRuntime &runtime,
 		const DialogueLineEntry *lines, uint count) {
@@ -56,7 +198,7 @@ static Common::Error playDialogueSequence(DialogueRuntime &runtime,
 } // End of namespace
 
 bool MoynahanDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("MOYNAHAN");
+	return npcName.equalsIgnoreCase(kMoynahanNpc);
 }
 
 Common::Error MoynahanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
@@ -65,449 +207,530 @@ Common::Error MoynahanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	Common::String topicBuffer;
 	int topicBufferLineIndex = -1;
 
-	auto appendSlashMarksTopic = [&]() {
+	auto assignTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(topicBuffer, topicBufferLineIndex,
+			responseLineIndex, "Moynahan topic buffer");
+	};
+	auto appendTopicBuffer = [&](int responseLineIndex) {
 		if (topicBuffer.empty()) {
-			runtime.assignTopicBuffer(topicBuffer, topicBufferLineIndex,
-				0x194, "Moynahan keyword topics");
+			assignTopicBuffer(responseLineIndex);
 			return;
 		}
 
-		topicBuffer += runtime.startupText().getDialogueResponseLine(0x195);
+		topicBuffer += runtime.startupText().getDialogueResponseLine(responseLineIndex);
+		topicBufferLineIndex = -1;
+	};
+	auto clearTopicBuffer = [&]() {
+		topicBuffer.clear();
 		topicBufferLineIndex = -1;
 	};
+	auto playMoynahanLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kMoynahanNpc, headVariant);
+	};
+	auto giveGlue = [&]() {
+		(void)runtime.startupScript().setRuntimeObjectVisible(kRahRoom, kGlueObject, true);
+		(void)runtime.startupScript().addRuntimeObjectToInventory(kGlueObject);
+		(void)runtime.startupScript().setRuntimeFlagValue(kJustGotGlueFlag, true);
+	};
+	auto isCasketPhoto = [&](const Common::String &itemName) {
+		return itemName.equalsIgnoreCase("CASKET_PHOTO") ||
+			itemName.equalsIgnoreCase("CASKET_PHOTOCOPY");
+	};
+	auto isLedger = [&](const Common::String &itemName) {
+		return itemName.equalsIgnoreCase("LEDGER") ||
+			itemName.equalsIgnoreCase("LEDGER2");
+	};
+	auto isBlackmailEvidence = [&](const Common::String &itemName) {
+		return itemName.equalsIgnoreCase("NOTE") ||
+			itemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+			itemName.equalsIgnoreCase("CHECKBOOK") ||
+			itemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY");
+	};
 
 	if (runtime.startupScript().getFlagValue("IF_TRY_TO_TAKE_THE_GLUE")) {
-		const DialogueLineEntry initialLines[] = {
-			{ 0x3e4d, kMoynahanNpc, 0 },
-			{ 0x3e52, "PC", 0 },
-			{ 0x3e56, kMoynahanNpc, 0 }
-		};
-		Common::Error lineError = playDialogueSequence(runtime, initialLines, ARRAYSIZE(initialLines));
+		Common::Error lineError = playDialogueSequence(runtime,
+			kMoynahanGlueInterruptOpeningLines, ARRAYSIZE(kMoynahanGlueInterruptOpeningLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
 		if (!runtime.startupScript().getFlagValue("IF_SAW_THE_SLASHES_ON_THE_BODY") ||
 				!state.slashMarksTopicUnlocked) {
-			const DialogueLineEntry shortReplyLines[] = {
-				{ 0x3e76, "PC", 0 },
-				{ 0x3e7a, kMoynahanNpc, 0 }
-			};
-			return playDialogueSequence(runtime, shortReplyLines, ARRAYSIZE(shortReplyLines));
+			return playDialogueSequence(runtime,
+				kMoynahanGlueInterruptShortReplyLines,
+				ARRAYSIZE(kMoynahanGlueInterruptShortReplyLines));
 		}
 
-		const DialogueLineEntry extendedReplyLines[] = {
-			{ 0x3e5b, "PC", 0 },
-			{ 0x3e60, kMoynahanNpc, 0 },
-			{ 0x3e64, "PC", 0 },
-			{ 0x3e69, kMoynahanNpc, 0 },
-			{ 0x3e6d, "PC", 0 },
-			{ 0x3e71, kMoynahanNpc, 0 }
-		};
-		return playDialogueSequence(runtime, extendedReplyLines, ARRAYSIZE(extendedReplyLines));
+		return playDialogueSequence(runtime,
+			kMoynahanGlueInterruptExtendedReplyLines,
+			ARRAYSIZE(kMoynahanGlueInterruptExtendedReplyLines));
 	}
 
-	if (usedItemName.empty()) {
-		if (state.introPending) {
-			state.introPending = false;
-			const DialogueLineEntry introLines[] = {
-				{ 0x3d33, kMoynahanNpc, 0 },
-				{ 0x3d37, "PC", 0 },
-				{ 0x3d3b, kMoynahanNpc, 0 },
-				{ 0x3d40, "PC", 0 },
-				{ 0x3d45, kMoynahanNpc, 0 },
-				{ 0x3d47, kMoynahanNpc, 0 }
-			};
-			return playDialogueSequence(runtime, introLines, ARRAYSIZE(introLines));
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			sharedState.discussedWhaleyHerrillPhoto = 1;
+			return playMoynahanLine(0x3e45);
 		}
 
-		Common::Error lineError = runtime.playDialogueLine(0x3fd1, kMoynahanNpc);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-
-		if (runtime.startupScript().getFlagValue("IF_SAW_THE_SLASHES_ON_THE_BODY") &&
-				!state.slashMarksIntroShown) {
-			const DialogueLineEntry slashMarksLines[] = {
-				{ 0x3da0, kMoynahanNpc, 0 },
-				{ 0x3da1, kMoynahanNpc, 0 },
-				{ 0x3da2, kMoynahanNpc, 0 }
-			};
-			lineError = playDialogueSequence(runtime, slashMarksLines, ARRAYSIZE(slashMarksLines));
+		if (isCasketPhoto(usedItemName)) {
+			Common::Error lineError = playDialogueSequence(runtime,
+				kMoynahanCasketIntroLines, ARRAYSIZE(kMoynahanCasketIntroLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
-			sharedState.dialogueStateD2ec8 = true;
-			state.slashMarksTopicUnlocked = true;
-			state.slashMarksIntroShown = true;
-			appendSlashMarksTopic();
-		}
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x18c, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = playDialogueSequence(runtime, kMoynahanCasketResponseOneLines,
+					ARRAYSIZE(kMoynahanCasketResponseOneLines));
+			} else if (responseIndex == 2) {
+				lineError = playMoynahanLine(0x3eb1, 2);
+			}
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 
-		if (runtime.startupScript().getFlagValue(DialogueFlags::kShownPhotoOfCorpse) &&
-				!state.introPending) {
-			state.introPending = true;
-			lineError = runtime.playDialogueLine(0x3f5b, kMoynahanNpc);
+			lineError = playDialogueSequence(runtime, kMoynahanCasketFollowupLines,
+				ARRAYSIZE(kMoynahanCasketFollowupLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-		}
 
-		if (runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS") && !state.ledgersIntroduced) {
-			state.ledgersIntroduced = true;
-			lineError = runtime.playDialogueLine(0x3f65, kMoynahanNpc);
+			if (state.gotGlue)
+				return playMoynahanLine(0x3ec3, 2);
+
+			lineError = playMoynahanLine(0x3ec8, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
-			int responseIndex = 0;
-			Common::Error responseError = runtime.runResponseMenu(0x196, responseIndex);
+			responseIndex = 0;
+			responseError = runtime.runResponseMenu(0x18d, responseIndex);
 			if (responseError.getCode() != Common::kNoError)
 				return responseError;
 
 			if (responseIndex == 1) {
-				lineError = runtime.playDialogueLine(0x3f70, kMoynahanNpc);
+				lineError = playMoynahanLine(0x3ed2, 2);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 
-				(void)runtime.startupScript().setRuntimeObjectVisible("INVENTORY", "LEDGER", false);
-				(void)runtime.startupScript().setRuntimeObjectVisible("INVENTORY", "LEDGER2", false);
-			} else if (responseIndex == 2) {
-				lineError = runtime.playDialogueLine(0x3f76, kMoynahanNpc);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
+				(void)runtime.startupScript().resetRuntimeObjectToInitialState(usedItemName);
+				(void)runtime.startupScript().setRuntimeObjectVisible(kRahRoom, usedItemName, true);
+				giveGlue();
+				state.gotGlue = true;
+				return Common::kNoError;
+			}
 
-				if (runtime.startupScript().getCurrentStoryDayIndex() > 2) {
-					sharedState.moynahanKarinKidnapedDiscussionState = true;
-					lineError = runtime.playDialogueLine(0x3f7a, kMoynahanNpc);
-					if (lineError.getCode() != Common::kNoError)
-						return lineError;
-				}
+			if (responseIndex == 2)
+				return playMoynahanLine(0x3ed7, 1);
+
+			return Common::kNoError;
+		}
 
-				lineError = runtime.playDialogueLine(0x3f7e, kMoynahanNpc);
+		if (isLedger(usedItemName) &&
+				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+			if (sharedState.discussedLedgerEvidence != 0 && !state.ledgerEvidenceLinePlayed) {
+				state.ledgerEvidenceLinePlayed = true;
+				Common::Error lineError = playMoynahanLine(0x3f87, 2);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
+
+				runtime.setActiveSpeakerPortrait(
+					runtime.currentRoomName().equalsIgnoreCase(kMoynahanHotelRoom)
+						? kMoynahanNpc
+						: kMoynahanEmbalmingPortrait,
+					0);
+				return Common::kNoError;
 			}
-		}
 
-		if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
-				!state.stephMidgameShown) {
-			state.stephMidgameShown = true;
-			lineError = runtime.playDialogueLine(0x3fd9, kMoynahanNpc);
+			Common::Error lineError = playDialogueSequence(runtime,
+				kMoynahanLedgerIntroLines, ARRAYSIZE(kMoynahanLedgerIntroLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-		}
 
-		if (runtime.startupScript().getCurrentStoryDayIndex() == 5 && !state.dayFiveShown) {
-			state.dayFiveShown = true;
-			const DialogueLineEntry dayFiveLines[] = {
-				{ 0x3fe5, kMoynahanNpc, 0 },
-				{ 0x3fea, "PC", 0 },
-				{ 0x3fee, kMoynahanNpc, 0 },
-				{ 0x3ff3, "PC", 0 },
-				{ 0x3ff7, kMoynahanNpc, 0 }
-			};
-			lineError = playDialogueSequence(runtime, dayFiveLines, ARRAYSIZE(dayFiveLines));
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-		}
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x18e, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
 
-		if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
-				!state.burnedTvStationShown) {
-			state.burnedTvStationShown = true;
-			lineError = runtime.playDialogueLine(0x3ffe, kMoynahanNpc);
+			if (responseIndex == 1) {
+				lineError = playDialogueSequence(runtime,
+					kMoynahanLedgerResponseOneLines,
+					ARRAYSIZE(kMoynahanLedgerResponseOneLines));
+			} else if (responseIndex == 2) {
+				lineError = playDialogueSequence(runtime,
+					kMoynahanLedgerResponseTwoLines,
+					ARRAYSIZE(kMoynahanLedgerResponseTwoLines));
+			}
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+
+			return playMoynahanLine(0x3ed7, 1);
 		}
 
-		if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
-				!state.karinKidnapedShown) {
-			state.karinKidnapedShown = true;
-			lineError = runtime.playDialogueLineWithVariant(0x4007, kMoynahanNpc, 1);
+		if (isBlackmailEvidence(usedItemName)) {
+			sharedState.discussedNoteCheckbookEvidence = 1;
+
+			Common::Error lineError = playMoynahanLine(0x3f3e);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
 			int responseIndex = 0;
-			Common::Error responseError = runtime.runResponseMenu(0x197, responseIndex);
+			Common::Error responseError = runtime.runResponseMenu(0x190, responseIndex);
 			if (responseError.getCode() != Common::kNoError)
 				return responseError;
 
 			if (responseIndex == 1) {
-				lineError = runtime.playDialogueLine(0x4012, kMoynahanNpc);
+				lineError = playMoynahanLine(0x3f4a);
 			} else if (responseIndex == 2) {
-				const DialogueLineEntry responseTwoLines[] = {
-					{ 0x4017, kMoynahanNpc, 0 },
-					{ 0x401c, "PC", 0 }
-				};
-				lineError = playDialogueSequence(runtime, responseTwoLines, ARRAYSIZE(responseTwoLines));
+				lineError = playMoynahanLine(0x3f4f, 2);
 			}
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
-			const DialogueLineEntry kidnapedLines[] = {
-				{ 0x4020, kMoynahanNpc, 0 },
-				{ 0x4026, "PC", 0 },
-				{ 0x402a, kMoynahanNpc, 0 },
-				{ 0x4034, "PC", 0 },
-				{ 0x4038, kMoynahanNpc, 0 },
-				{ 0x403d, "PC", 0 }
-			};
-			return playDialogueSequence(runtime, kidnapedLines, ARRAYSIZE(kidnapedLines));
+			return playMoynahanLine(0x3f54, 2);
 		}
 
-		if (!runtime.startupScript().isNamedNpcDeathTypeClear("BUTCHER") &&
-				!state.butcherDeadShown) {
-			state.butcherDeadShown = true;
-			return runtime.playDialogueLine(0x40bb, kMoynahanNpc);
-		}
+		if (usedItemName.equalsIgnoreCase("WED_MATCHES")) {
+			Common::Error lineError = playDialogueSequence(runtime,
+				kMoynahanMatchesLines, ARRAYSIZE(kMoynahanMatchesLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 
-		if (!runtime.startupScript().isNamedNpcDeathTypeClear("JIMMY") &&
-				!state.jimmyDeadShown) {
-			state.jimmyDeadShown = true;
-			return runtime.playDialogueLine(0x40cd, kMoynahanNpc);
+			if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED"))
+				return playMoynahanLine(0x4054, 3);
+			return Common::kNoError;
 		}
 
-		if (runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
-				!state.karinFoundDeadShown) {
-			state.karinFoundDeadShown = true;
-			return runtime.playDialogueLine(0x40d4, kMoynahanNpc);
-		}
+		return playMoynahanLine(0x3e3f, 2);
+	}
 
-		for (;;) {
-			Common::String selectedTopic;
-			Common::Error menuError = runtime.runKeywordMenu(
-				topicBuffer, topicBufferLineIndex, selectedTopic);
-			if (menuError.getCode() != Common::kNoError)
-				return menuError;
+	if (state.introPending) {
+		state.introPending = false;
+		return playDialogueSequence(runtime, kMoynahanIntroLines, ARRAYSIZE(kMoynahanIntroLines));
+	}
 
-			if (selectedTopic.empty())
-				return Common::kNoError;
-			if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
-					runtime.matchesResponseLine(selectedTopic, 0x199))
-				return runtime.playDialogueLine(0x3e32, kMoynahanNpc);
-
-			if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanGoodCauseTopicLines,
-					ARRAYSIZE(kMoynahanGoodCauseTopicLines))) {
-				sharedState.momGoodCauseDay5State = true;
-				return runtime.playDialogueLine(0x3d5f, kMoynahanNpc);
-			}
+	Common::Error lineError = playMoynahanLine(0x3fd1);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
 
-			if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanSlashMarksTopicLines,
-					ARRAYSIZE(kMoynahanSlashMarksTopicLines)))
-				return runtime.playDialogueLine(0x3d76, kMoynahanNpc);
+	if (runtime.startupScript().getFlagValue("IF_SAW_THE_SLASHES_ON_THE_BODY") &&
+			!state.slashMarksIntroShown) {
+		lineError = playDialogueSequence(runtime, kMoynahanSlashMarksIntroLines,
+			ARRAYSIZE(kMoynahanSlashMarksIntroLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 
-			if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanBodyTopicLines,
-					ARRAYSIZE(kMoynahanBodyTopicLines)))
-				return runtime.playDialogueLine(0x3da0, kMoynahanNpc);
+		sharedState.dialogueStateD2ec8 = true;
+		state.slashMarksTopicUnlocked = true;
+		state.slashMarksIntroShown = true;
+		if (topicBuffer.empty())
+			assignTopicBuffer(0x194);
+		else
+			appendTopicBuffer(0x195);
+	}
 
-			if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanGeneralTopicLines,
-					ARRAYSIZE(kMoynahanGeneralTopicLines)))
-				return runtime.playDialogueLine(0x3db1, kMoynahanNpc);
+	if (sharedState.discussedCasketPhotoEvidence != 0 && !state.introPending) {
+		state.introPending = true;
+		lineError = playMoynahanLine(0x3f5b, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
 
-			if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanDayFiveUnlockTopicLines,
-					ARRAYSIZE(kMoynahanDayFiveUnlockTopicLines))) {
-				if (runtime.startupScript().getCurrentStoryDayIndex() > 4) {
-					sharedState.dialogueStateD2ec8 = true;
-					sharedState.dialogueStateD2ebc = true;
-					sharedState.dialogueStateD2ec0 = true;
-					return runtime.playDialogueLine(0x3df5, kMoynahanNpc);
-				}
-			}
+	if (runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS") && !state.ledgersIntroduced) {
+		state.ledgersIntroduced = true;
+		lineError = playMoynahanLine(0x3f65);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 
-			if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanEmbalmingRoomTopicLines,
-					ARRAYSIZE(kMoynahanEmbalmingRoomTopicLines))) {
-				if (runtime.currentRoomName().equalsIgnoreCase(kMoynahanEmbalmingRoom))
-					return runtime.playDialogueLine(0x3e18, kMoynahanNpc);
-			}
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(0x196, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
 
-			if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanLateTopicLines,
-					ARRAYSIZE(kMoynahanLateTopicLines))) {
-				if (runtime.startupScript().getCurrentStoryDayIndex() > 4)
-					return runtime.playDialogueLine(0x40f5, kMoynahanNpc);
-			}
+		if (responseIndex == 1) {
+			lineError = playMoynahanLine(0x3f70);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 
-			if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanGlueTopicLines,
-					ARRAYSIZE(kMoynahanGlueTopicLines))) {
-				if (runtime.startupScript().getCurrentStoryDayIndex() > 4) {
-					if (!state.gotGlue)
-						return runtime.playDialogueLine(0x40fb, kMoynahanNpc);
-					return runtime.playDialogueLine(0x410d, kMoynahanNpc);
-				}
-			}
+			(void)runtime.startupScript().resetRuntimeObjectToInitialState("LEDGER");
+			(void)runtime.startupScript().resetRuntimeObjectToInitialState("LEDGER2");
+		} else if (responseIndex == 2) {
+			lineError = playMoynahanLine(0x3f76);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 
-			if (runtime.matchesResponseLine(selectedTopic, 0x1c4))
-				continue;
+			if (runtime.startupScript().getCurrentStoryDayIndex() > 2) {
+				sharedState.moynahanKarinKidnapedDiscussionState = true;
+				lineError = playMoynahanLine(0x3f7a);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
 
-			return runtime.playDialogueLine(0x3e39, kMoynahanNpc);
+			lineError = playMoynahanLine(0x3f7e, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 		}
 	}
 
-	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-		return runtime.playDialogueLine(0x3e45, kMoynahanNpc);
+	if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
+			!state.stephMidgameShown) {
+		state.stephMidgameShown = true;
+		lineError = playMoynahanLine(0x3fd9);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getCurrentStoryDayIndex() == 5 && !state.dayFiveShown) {
+		state.dayFiveShown = true;
+		lineError = playDialogueSequence(runtime, kMoynahanDayFiveLines,
+			ARRAYSIZE(kMoynahanDayFiveLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
+			!state.burnedTvStationShown) {
+		state.burnedTvStationShown = true;
+		lineError = playMoynahanLine(0x3ffe);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 
-	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-		const DialogueLineEntry casketIntroLines[] = {
-			{ 0x3e84, kMoynahanNpc, 0 },
-			{ 0x3e89, "PC", 0 },
-			{ 0x3e8e, kMoynahanNpc, 0 },
-			{ 0x3e93, "PC", 0 },
-			{ 0x3e98, kMoynahanNpc, 0 }
-		};
-		Common::Error lineError = playDialogueSequence(runtime, casketIntroLines, ARRAYSIZE(casketIntroLines));
+	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
+			!state.karinKidnapedShown) {
+		state.karinKidnapedShown = true;
+		lineError = playMoynahanLine(0x4007);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
 		int responseIndex = 0;
-		Common::Error responseError = runtime.runResponseMenu(0x18c, responseIndex);
+		Common::Error responseError = runtime.runResponseMenu(0x197, responseIndex);
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 
 		if (responseIndex == 1) {
-			const DialogueLineEntry responseOneLines[] = {
-				{ 0x3ea4, kMoynahanNpc, 0 },
-				{ 0x3ea8, "PC", 0 },
-				{ 0x3ead, kMoynahanNpc, 0 }
-			};
-			lineError = playDialogueSequence(runtime, responseOneLines, ARRAYSIZE(responseOneLines));
+			lineError = playMoynahanLine(0x4012);
 		} else if (responseIndex == 2) {
-			lineError = runtime.playDialogueLine(0x3eb1, kMoynahanNpc);
+			const DialogueLineEntry responseTwoLines[] = {
+				{ 0x4017, kMoynahanNpc, 0 },
+				{ 0x401c, kPcSpeaker, 0 }
+			};
+			lineError = playDialogueSequence(runtime, responseTwoLines, ARRAYSIZE(responseTwoLines));
 		}
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
-		const DialogueLineEntry casketFollowupLines[] = {
-			{ 0x3eb7, "PC", 0 },
-			{ 0x3ebc, kMoynahanNpc, 0 }
-		};
-		lineError = playDialogueSequence(runtime, casketFollowupLines, ARRAYSIZE(casketFollowupLines));
+		lineError = playDialogueSequence(runtime, kMoynahanKarinKidnapedTailLines,
+			ARRAYSIZE(kMoynahanKarinKidnapedTailLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
-		if (state.gotGlue)
-			return runtime.playDialogueLine(0x3ec3, kMoynahanNpc);
+		sharedState.karinKidnapedDialogueState = true;
+		sharedState.moynahanKarinKidnapedDiscussionState = true;
+	}
 
-		lineError = runtime.playDialogueLine(0x3ec8, kMoynahanNpc);
+	if (!runtime.startupScript().isNamedNpcDeathTypeClear("BUTCHER") &&
+			!state.butcherDeadShown) {
+		state.butcherDeadShown = true;
+		lineError = playMoynahanLine(0x40bb, 3);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+	}
 
-		responseIndex = 0;
-		responseError = runtime.runResponseMenu(0x18d, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
+	if (!runtime.startupScript().isNamedNpcDeathTypeClear("JIMMY") &&
+			!state.jimmyDeadShown) {
+		state.jimmyDeadShown = true;
+		lineError = playMoynahanLine(0x40cd, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
 
-		if (responseIndex == 1) {
-			lineError = runtime.playDialogueLine(0x3ed2, kMoynahanNpc);
+	if (runtime.startupScript().getFlagValue("KARIN_FOUND_DEAD") &&
+			!state.karinFoundDeadShown) {
+		state.karinFoundDeadShown = true;
+		lineError = playMoynahanLine(0x40d4, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runtime.runKeywordMenu(
+			topicBuffer, topicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.empty())
+			return Common::kNoError;
+
+		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
+				runtime.matchesResponseLine(selectedTopic, 0x199))
+			return playMoynahanLine(0x3e32, 3);
+
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanGoodCauseTopicLines,
+				ARRAYSIZE(kMoynahanGoodCauseTopicLines))) {
+			sharedState.momGoodCauseDay5State = true;
+			lineError = playDialogueSequence(runtime, kMoynahanGoodCauseLines,
+				ARRAYSIZE(kMoynahanGoodCauseLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			assignTopicBuffer(0x19c);
+			continue;
+		}
 
-			(void)runtime.startupScript().resetRuntimeObjectToInitialState(usedItemName);
-			(void)runtime.startupScript().setRuntimeObjectVisible(kRahRoom, usedItemName, true);
-			(void)runtime.startupScript().setRuntimeObjectVisible(kRahRoom, kGlueObject, true);
-			(void)runtime.startupScript().addRuntimeObjectToInventory(kGlueObject);
-			(void)runtime.startupScript().setRuntimeFlagValue(kJustGotGlueFlag, true);
-			state.gotGlue = true;
-			return Common::kNoError;
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanSlashMarksTopicLines,
+				ARRAYSIZE(kMoynahanSlashMarksTopicLines))) {
+			lineError = playDialogueSequence(runtime, kMoynahanSlashMarksTopicReplyLines,
+				ARRAYSIZE(kMoynahanSlashMarksTopicReplyLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignTopicBuffer(0x1a1);
+			continue;
 		}
 
-		if (responseIndex == 2)
-			return runtime.playDialogueLine(0x3ed7, kMoynahanNpc);
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanBodyTopicLines,
+				ARRAYSIZE(kMoynahanBodyTopicLines))) {
+			lineError = playMoynahanLine(0x3da0, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			if (runtime.startupScript().getCurrentStoryDayIndex() > 4) {
+				lineError = playMoynahanLine(0x3da1, 3);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
 
-		return Common::kNoError;
-	}
+				sharedState.dialogueStateD2ec8 = true;
+				state.slashMarksTopicUnlocked = true;
+			}
 
-	if ((usedItemName.equalsIgnoreCase("LEDGER") ||
-				usedItemName.equalsIgnoreCase("LEDGER2")) &&
-			runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
-		if (runtime.startupScript().getFlagValue(DialogueFlags::kShownLedgersToAnyone) &&
-				!state.ledgerEvidenceLinePlayed) {
-			state.ledgerEvidenceLinePlayed = true;
-			Common::Error lineError = runtime.playDialogueLine(0x3f87, kMoynahanNpc);
+			lineError = playMoynahanLine(0x3da2, 2);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
-			runtime.setActiveSpeakerPortrait(
-				runtime.currentRoomName().equalsIgnoreCase(kMoynahanHotelRoom)
-					? kMoynahanNpc
-					: kMoynahanEmbalmingPortrait,
-				0);
-			return Common::kNoError;
+			assignTopicBuffer(0x1a6);
+			if (runtime.startupScript().getCurrentStoryDayIndex() > 4)
+				appendTopicBuffer(0x1a7);
+			if (runtime.currentRoomName().equalsIgnoreCase(kMoynahanEmbalmingRoom))
+				appendTopicBuffer(0x1a8);
+			continue;
 		}
 
-		const DialogueLineEntry ledgerIntroLines[] = {
-			{ 0x3ede, "PC", 0 },
-			{ 0x3ee3, kMoynahanNpc, 0 },
-			{ 0x3ee7, "PC", 0 },
-			{ 0x3eec, kMoynahanNpc, 0 }
-		};
-		Common::Error lineError = playDialogueSequence(runtime, ledgerIntroLines, ARRAYSIZE(ledgerIntroLines));
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanGeneralTopicLines,
+				ARRAYSIZE(kMoynahanGeneralTopicLines))) {
+			lineError = playMoynahanLine(0x3db1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
 
-		int responseIndex = 0;
-		Common::Error responseError = runtime.runResponseMenu(0x18e, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(0x1ac, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
 
-		if (responseIndex == 1) {
-			const DialogueLineEntry responseOneLines[] = {
-				{ 0x3ef7, kMoynahanNpc, 0 },
-				{ 0x3efb, "PC", 0 },
-				{ 0x3eff, kMoynahanNpc, 0 },
-				{ 0x3f05, "PC", 0 }
-			};
-			lineError = playDialogueSequence(runtime, responseOneLines, ARRAYSIZE(responseOneLines));
-		} else if (responseIndex == 2) {
-			const DialogueLineEntry responseTwoLines[] = {
-				{ 0x3f09, kMoynahanNpc, 0 },
-				{ 0x3f0f, "PC", 0 },
-				{ 0x3f13, kMoynahanNpc, 0 },
-				{ 0x3f18, "PC", 0 }
-			};
-			lineError = playDialogueSequence(runtime, responseTwoLines, ARRAYSIZE(responseTwoLines));
+			if (responseIndex == 1) {
+				lineError = playDialogueSequence(runtime,
+					kMoynahanGeneralTopicResponseOneLines,
+					ARRAYSIZE(kMoynahanGeneralTopicResponseOneLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignTopicBuffer(0x1ad);
+			} else if (responseIndex == 2) {
+				lineError = playDialogueSequence(runtime,
+					kMoynahanGeneralTopicResponseTwoLines,
+					ARRAYSIZE(kMoynahanGeneralTopicResponseTwoLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				if (runtime.startupScript().getCurrentStoryDayIndex() > 4) {
+					lineError = playMoynahanLine(0x3deb);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+					sharedState.dialogueStateD2ec8 = true;
+				}
+
+				assignTopicBuffer(0x1b0);
+				if (runtime.startupScript().getCurrentStoryDayIndex() > 4)
+					appendTopicBuffer(0x1b1);
+			}
+			continue;
 		}
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
 
-		return runtime.playDialogueLine(0x3ed7, kMoynahanNpc);
-	}
-	if (usedItemName.equalsIgnoreCase("NOTE") ||
-			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
-			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
-			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
-		Common::Error lineError = runtime.playDialogueLine(0x3f3e, kMoynahanNpc);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanDayFiveUnlockTopicLines,
+				ARRAYSIZE(kMoynahanDayFiveUnlockTopicLines))) {
+			if (runtime.startupScript().getCurrentStoryDayIndex() >= 5) {
+				sharedState.dialogueStateD2ec8 = true;
+				sharedState.dialogueStateD2ebc = true;
+				sharedState.dialogueStateD2ec0 = true;
+				lineError = playDialogueSequence(runtime, kMoynahanDayFiveUnlockLines,
+					ARRAYSIZE(kMoynahanDayFiveUnlockLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
 
-		int responseIndex = 0;
-		Common::Error responseError = runtime.runResponseMenu(0x190, responseIndex);
-		if (responseError.getCode() != Common::kNoError)
-			return responseError;
+				state.slashMarksTopicUnlocked = true;
+				clearTopicBuffer();
+			}
+			continue;
+		}
 
-		if (responseIndex == 1) {
-			lineError = runtime.playDialogueLine(0x3f4a, kMoynahanNpc);
-		} else if (responseIndex == 2) {
-			lineError = runtime.playDialogueLine(0x3f4f, kMoynahanNpc);
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanEmbalmingRoomTopicLines,
+				ARRAYSIZE(kMoynahanEmbalmingRoomTopicLines))) {
+			if (runtime.currentRoomName().equalsIgnoreCase(kMoynahanEmbalmingRoom)) {
+				lineError = playDialogueSequence(runtime, kMoynahanEmbalmingRoomLines,
+					ARRAYSIZE(kMoynahanEmbalmingRoomLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				assignTopicBuffer(0x1b6);
+			}
+			continue;
 		}
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
 
-		return runtime.playDialogueLine(0x3f54, kMoynahanNpc);
-	}
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanLateTopicLines,
+				ARRAYSIZE(kMoynahanLateTopicLines))) {
+			if (runtime.startupScript().getCurrentStoryDayIndex() > 4) {
+				lineError = playMoynahanLine(0x40f5);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+			continue;
+		}
+
+		if (runtime.matchesAnyResponseLine(selectedTopic, kMoynahanGlueTopicLines,
+				ARRAYSIZE(kMoynahanGlueTopicLines))) {
+			if (runtime.startupScript().getCurrentStoryDayIndex() > 4) {
+				if (!state.gotGlue) {
+					lineError = playMoynahanLine(0x40fb);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
 
-	if (usedItemName.equalsIgnoreCase("WED_MATCHES")) {
-		const DialogueLineEntry matchesLines[] = {
-			{ 0x4049, "PC", 0 },
-			{ 0x404d, kMoynahanNpc, 0 }
-		};
-		Common::Error lineError = playDialogueSequence(runtime, matchesLines, ARRAYSIZE(matchesLines));
+					int responseIndex = 0;
+					Common::Error responseError = runtime.runResponseMenu(0x1c3, responseIndex);
+					if (responseError.getCode() != Common::kNoError)
+						return responseError;
+
+					if (responseIndex == 1) {
+						lineError = playMoynahanLine(0x4108, 2);
+						if (lineError.getCode() != Common::kNoError)
+							return lineError;
+						giveGlue();
+					} else if (responseIndex == 2) {
+						lineError = playMoynahanLine(0x410d, 2);
+						if (lineError.getCode() != Common::kNoError)
+							return lineError;
+					}
+				} else {
+					lineError = playMoynahanLine(0x410d, 2);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				}
+			}
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x1c4))
+			continue;
+
+		lineError = playMoynahanLine(0x3e39);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-
-		if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED"))
-			return runtime.playDialogueLine(0x4054, kMoynahanNpc);
-		return Common::kNoError;
 	}
-
-	return runtime.playDialogueLine(0x3e3f, kMoynahanNpc);
 }
 
 } // End of namespace Harvester


Commit: 2e0d42b359833dfb62ec2779852d4e191f510666
    https://github.com/scummvm/scummvm/commit/2e0d42b359833dfb62ec2779852d4e191f510666
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:59+02:00

Commit Message:
HARVESTER: Reimplement Boyle dialogue handler

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/boyle_dialogue.cpp
    engines/harvester/npc/boyle_dialogue.h
    engines/harvester/saveload.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index bb7082e0f4e..067d6b7e75d 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -566,9 +566,9 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - Wasp Woman's keyword state persists across revisits. Later no-item visits only replay `0x4bee`; the live keyword buffer is whatever the prior branch last wrote, and the raw hidden tail now confirms the concrete rewrites: `0x302` expands into `0x4c0a` / `0x4c13` / `0x4c17`, plays `C027A.FST`, continues through `0x4c25` / `0x4c29`, then rewrites the buffer to zero-based `dialog.rsp[0x304]`; `0x308` rewrites to `0x309`; and `0x30a` plays `0x4c74` / `0x4c7f` before rewriting to `0x30b`.
     - The hidden loop also clarifies the ambiguous BYE handling. In the initial `dialog.rsp[0x300]` state, the synthetic `BYE` selection exits silently through the early `dialog.rsp[0x301]` compare. Once the later keyword states are active, the zero-based `dialog.rsp[0x30e]` `BYE` loops silently back into the keyword menu instead, and only an unmatched late typed topic falls through to `0x4c9a`.
 - The shared dialogue evidence/topic state shims behind those handlers are now partially explicit:
-  - `get_set_shared_dialogue_state_d2e98` at `0x38230` is a neutral wrapper over `g_shared_dialogue_state_d2e98`; the only currently recovered gameplay caller is Herrill's `GASCAN` branch at `0x2e056`, where `eax = 1` / `edx = 0` makes it a read-only gate and a zero value collapses the post-`0x30d8` continuation to `PC 0x3123`. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path and save/load serialization.
+  - `get_set_shared_dialogue_state_d2e98` at `0x38230` is a neutral wrapper over `g_shared_dialogue_state_d2e98`; the currently recovered gameplay callers are Boyle's `BOYLES_BUTTON` response-2 branch, which writes it immediately before `SHERIFF_IN_DINER`, and Herrill's `GASCAN` branch at `0x2e056`, where `eax = 1` / `edx = 0` makes it a read-only gate and a zero value collapses the post-`0x30d8` continuation to `PC 0x3123`. Outside those handlers, the backing global is only touched by the shared reset path and save/load serialization.
   - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
-  - `get_set_shared_dialogue_state_d2eb0` at `0x382a0` is a neutral wrapper over `g_shared_dialogue_state_d2eb0`; the only currently recovered gameplay caller is a hidden Boyle dialogue branch at `0x2d6e3`, where `eax = 1` / `edx = 0` makes it a read-only gate between `BOYLE 0x10e` and `BOYLE 0x115` after the local item checks. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path.
+  - `get_set_shared_dialogue_state_d2eb0` at `0x382a0` is a neutral wrapper over `g_shared_dialogue_state_d2eb0`; the currently recovered gameplay callers are Boyle's `BOYLES_BUTTON` response-2 branch, which writes it alongside `d2e98`, and the hidden Boyle dialogue branch at `0x2d6e3`, where `eax = 1` / `edx = 0` makes it a read-only gate between `BOYLE 0x10e` and `BOYLE 0x115` after the local item checks. Outside those handlers, the backing global is only touched by the shared reset path and save/load serialization.
   - `get_set_shared_dialogue_state_d2eb8` at `0x382c0` is another neutral shared wrapper over `g_shared_dialogue_state_d2eb8`; the currently recovered gameplay caller is Mrs. Potts's `STEPHANIE_IS_DEAD` branch, where `eax = 1` / `edx = 0` makes it a read-only gate between the opening `0x283b` response menu and the later room-specific dead-Stephanie branches. A nearby cutscene helper at `0x38930` also writes that same bit while setting `STEPH_MIDGAME_PLAYED`, but the full gameplay-side semantics of the shared state are still not bounded tightly enough for a stronger name.
   - `get_set_shared_dialogue_state_d2ebc` at `0x382d0` is another neutral shared wrapper over `g_shared_dialogue_state_d2ebc`; the currently recovered gameplay caller is Boyle's `CASKET_PHOTO` / `CASKET_PHOTOCOPY` branch, where `eax = 1` / `edx = 0` makes it a read-only gate for the optional `0x253` line between `0x24f` and `0x258`. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path and save/load serialization.
   - `get_set_shared_dialogue_state_d2ec0` at `0x382e0` is a neutral wrapper over `g_shared_dialogue_state_d2ec0`; the currently recovered gameplay writer is Moynahan's day-5 `0x1b2` / `0x1b3` keyword branch, which sets it immediately before line `0x3df5` alongside the already recovered shared `d2ebc` and `d2ec8` bits. No reader is bounded tightly enough yet for a stronger semantic name.
@@ -615,13 +615,14 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - Boyle's hidden `GASCAN` return branch is now explicit through `get_set_boyle_gascan_application_state` at `0x38240`.
   - Boyle's no-item intro is also fully bounded now. When the local intro bit is set, he clears it, plays the fixed `0x7` / `0xb` / `0xf` opener, then drives response menus `0x5`, `0x6`, `0x7`, and `0x8` before seeding keyword buffers `0x9` or `0xa`; steady revisits collapse to `0x23c` and optionally seed `0xb` when `BOYLES_BUTTON` is already in inventory.
   - The post-intro no-item followups are one-shot gates rather than a single bark chain: the shared blackmail/button-evidence block plays `0x210` plus the optional `0x21a` / `0x215` inserts, branches through response menu `0xc` when `HAVE_LODGE_APP` is still clear, and then clears the active keyword buffer before the later `STEPH_MIDGAME_PLAYED`, `DAY_5`, and `BURNED_TV_STATION` one-shots.
-  - When that state is nonzero, `handle_talk_to_boyle` plays the `0x1f2` / `0x1f6` / `0x1fa` / `0x206` exchange, removes `GASCAN` from inventory, awards `LODGE_APPLICATION`, and sets `HAVE_LODGE_APP`.
+  - When that state is nonzero, `handle_talk_to_boyle` plays the `0x1f2` / `0x1f6` / `0x1fa` / `0x202` / `0x206` exchange, removes `GASCAN` from inventory, awards `LODGE_APPLICATION`, and sets `HAVE_LODGE_APP`.
   - Boyle's hidden keyword block around `0x2d620` is now bounded enough to map its major topic groups.
     - `Amnesia` (zero-based `dialog.rsp` line `0x17`) plays the fixed `0xee` / `0xf2` / `0xf6` / `0xfa` exchange.
     - `Button`, `Epaulet`, `Shirt`, and `Postal Button` (`0x18` through `0x1b`) all converge on the neutral `get_set_shared_dialogue_state_d2eb0` read. When that shared bit is clear, Boyle first calls `play_c019b_fst_sequence` and then plays `0x10e`; when the bit is set, the same topic group jumps straight to `0x115`.
     - `Arson` (`0x1d`) and the literal keyword string `Arsonist` share one branch: Boyle plays `0x11c`, opens the zero-based `dialog.rsp` response menu at `0x1e`, then takes either the `0x126` / `0x12c` / `0x130` continuation for choice `1` or the `0x135` continuation for choice `2`.
     - `Blackmail` (`0x1f`) plays `0x13d` / `0x141` / `0x145` / `0x14a` / `0x14e`, `BYE` (`0x20`) falls through the common exit path, and unmatched keywords drop to Boyle's `0x243` fallback line.
-  - Boyle's item logic now cleanly splits into five native clusters: `BOYLES_BUTTON` uses response menu `0x2` and can arm both the gas-can application state and `SHERIFF_IN_DINER`; note/checkbook evidence either reuses the blackmail sequence or branches through response menus `0x3` and `0x4`; `PHOTO_OF_WHALEY_HERRILL` is the dedicated `0x25f` path; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` is the `0x24f` / optional `0x253` / `0x258` sequence; all other items fall back to `0x249`.
+  - Boyle's item logic now cleanly splits into five native clusters: `BOYLES_BUTTON` uses response menu `0x2` and can arm the Boyle-local gas-can application state, shared `d2e98` / `d2eb0`, and `SHERIFF_IN_DINER`; note/checkbook evidence either reuses the blackmail sequence or branches through response menus `0x3` and `0x4`; `PHOTO_OF_WHALEY_HERRILL` is the dedicated `0x25f` path; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` is the `0x24f` / optional `0x253` / `0x258` sequence; all other items fall back to `0x249`.
+  - A Boyle-local save-backed bit at `0xd2cbc` now has a confirmed behavior: intro response menu `0x7` choice `2` sets it before `0x76` / `0x77`, and later `BOYLES_BUTTON` or note/checkbook response-1 branches use it to insert `PC 0x186` before Boyle's `0x18b` / `0x1b8` evidence-reply variants instead of the shorter direct tail.
   - `play_c019b_fst_sequence` at `0x38620` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C019B.FST", 0)` and is currently only called from Boyle's `d2eb0 == 0` evidence-topic branch.
 - The adjacent `0x383d0`-`0x384e0` range is now confirmed to be another overlapping run of tiny read/write dialogue-state wrappers over globals in the same contiguous block (`0xd2f00` through `0xd2f28`).
   - `get_set_sergeant_completed_first_task_state` at `0x384e0` is the Sergeant-local bit set on the `SCRATCHED_TUCKER` branch immediately before dialogue `0x420d` ("So, you have completed your first task.").
diff --git a/TRACKER.md b/TRACKER.md
index da88592d040..07e511daa6e 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,8 @@
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Boyle dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_boyle` (`0x2cb20..0x2d885`) plus `reset_talk_to_boyle_state` (`0x2d8a0..0x2d8b7`). The confirmed source drift was concentrated in five native areas the earlier port had flattened or miswired: the handler was assigning the wrong speakers on Boyle's intro, keyword, button, gascan, blackmail, and burned-TV exchanges, especially the native PC turns at `0xb`, `0x15a`, `0x179`, `0x1a9`, `0x202`, `0x28e`, and the `0x8c` / `0x94` / `0x9d` / `0xee` / `0xf6` / `0x12c` keyword branches; the port was missing Boyle's sixth save-backed local bit at `0xd2cbc`, so intro response menu `0x7` choice `2` was incorrectly writing shared `d2ebc` instead of a Boyle-local evidence-reply gate that later changes the `BOYLES_BUTTON` and note/checkbook continuations; the item paths were mutating the wrong runtime state by setting generic evidence flags that native never touches here, by resetting `BOYLES_BUTTON` / `GASCAN` to their initial startup records instead of simply hiding them from inventory, and by clearing the Boyle gascan-application bit plus reasserting `SHERIFF_IN_DINER` during the gascan reward even though native only hides `GASCAN`, awards `LODGE_APPLICATION`, and sets `HAVE_LODGE_APP`; the post-intro no-item evidence follow-up was gated off the wrong ScummVM flag instead of native shared state `discussedNoteCheckbookEvidence`; and the intro response-menu `0x5` cancel path was incorrectly seeding the button keyword buffer even though native only seeds `0xa` after the two handled intro branches. Patched [`engines/harvester/npc/boyle_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/boyle_dialogue.cpp), extended Boyle's save-backed room state in [`engines/harvester/npc/boyle_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/boyle_dialogue.h), bumped the Harvester save blob version to `8` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) so the new Boyle-local bit persists cleanly, corrected the confirmed Boyle wrapper notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts remain aligned with the current live Ghidra database totals (`912` functions, `120` remaining `FUN_*`).
+- On March 23, 2026, reran the full durable `repair BOYLE -> wait -> save_program -> verify BOYLE` cycle against the live `HARVEST.LE` session to confirm Boyle still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_boyle` rebuilt to `0x2cb20..0x2d885` (`3430` address-count, `85` dialogue calls, `52` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_boyle` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Boyle was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
 - On March 23, 2026, reimplemented the ScummVM Moynahan dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_moynahan` (`0x24a20..0x25f43`). The confirmed source drift was concentrated in five native areas the earlier port had flattened or miswired: the `IF_TRY_TO_TAKE_THE_GLUE` interrupt and several presented-item branches were using wrong head variants and were missing the native response-menu continuations for `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, both ledgers, note/checkbook evidence, and `WED_MATCHES`; the handler was writing the wrong shared state for Whaley-photo and blackmail evidence, rearming the intro off the wrong corpse-photo state, and omitting the `KARIN_KIDNAPED` tail's `0x4041` line plus the shared kidnapped-discussion writes; the late no-item bark chain after `0x3fd1` still had truncated control flow and non-native portrait variants on the slash/body/day-5/Butcher/Jimmy/Karin-dead branches; the typed keyword loop was incorrectly treated as mostly terminal single-line replies instead of a looping menu that rewrites the live topic buffer through `0x19c`, `0x1a1`, `0x1a6` / `0x1a7` / `0x1a8`, `0x1ad`, `0x1b0` / `0x1b1`, and `0x1b6`; and the day-5 glue-topic path `0x1bf..0x1c2` plus response menu `0x1c3` was missing its native `GLUE` inventory / `JUST_GOT_GLUE` side effects. Patched [`engines/harvester/npc/moynahan_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/moynahan_dialogue.cpp), preserved the existing save layout in [`engines/harvester/npc/moynahan_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/moynahan_dialogue.h), rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully, and refreshed the tracker progress totals from the current live Ghidra database (`912` functions, `120` remaining `FUN_*`).
 - On March 23, 2026, reran the full durable `repair MOYNAHAN -> wait -> save_program -> verify MOYNAHAN` cycle against the live `HARVEST.LE` session to confirm Moynahan still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_moynahan` rebuilt to `0x24a20..0x25f43` (`5254` address-count, `117` dialogue calls, `59` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_moynahan` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Moynahan was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
 - On March 23, 2026, reimplemented the remaining confirmed Herrill dialogue mismatches in ScummVM directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_herrill` (`0x2dc00..0x2e987`). The confirmed source drift was concentrated in four native areas the earlier port had flattened or misread: the `HERRILL_IN_LODGE` branch was truncated to a single `0x31ca` bark and was missing the native `0x31ce` / `0x31d2` / `0x31da` / `0x31de` / optional `0x31e0` / `0x31e2` sequence plus the `GRAND_MUCK` slash-transition and `PC_TALKED_TO_HERRILL` write; the `PHOTO_OF_WHALEY_HERRILL` item branch was incorrectly writing the shared Whaley/Herrill-photo runtime flag even though native only plays `0x309f`; the steady-state `KARIN_FOUND_ALIVE` / `KARIN_FOUND_DEAD` followups had non-native extra lines (`0x3050` / `0x3057` and `PC 0x461b`) that are absent from the real handler; and the keyword loop mishandled `The Lodge`, the second `BYE` line at `0x293`, and the unmatched typed-topic fallback that should run `0x3099` before `0x2ff4`. Patched [`engines/harvester/npc/herrill_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/herrill_dialogue.cpp), corrected the confirmed Herrill notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and refreshed the tracker progress totals from the current live Ghidra database (`912` functions, `120` remaining `FUN_*`).
@@ -74,6 +76,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Boyle validation pass that explicitly hits the corrected native-only paths: intro response menu `0x5 -> 0x7 -> 2` to confirm the new Boyle-local evidence-reply bit persists and later changes the `BOYLES_BUTTON` and note/checkbook continuations; `BOYLES_BUTTON` through both `0x2` responses to verify `PC 0x179`, the optional `PC 0x186 -> BOYLE 0x18b` tail, the `d2e98` / `d2eb0` writes, and simple inventory hiding instead of object reset; note/checkbook evidence through response menus `0x3` and `0x4` with and without `HAVE_LODGE_APP` to confirm the restored `PC 0x141` / `0x14a`, `PC 0x1a9`, optional `PC 0x186 -> BOYLE 0x1b8`, and the no-item follow-up staying gated off shared note/checkbook state rather than the local Boyle branch; `GASCAN` after Boyle has accepted the application deal to confirm only `GASCAN` hides and `LODGE_APPLICATION` is awarded; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared `d2ebc` to verify the optional `PC 0x253`; and keyword topics `0xe`, `0x17`, `0x18..0x1b`, typed `Arsonist`, and both visible `BYE` entries to confirm the restored PC/Boyle speaker split and native exit flow.
 - Immediate: run an in-engine Moynahan validation pass that explicitly hits the corrected native-only branches: `IF_TRY_TO_TAKE_THE_GLUE` before and after the slashes-on-body topic unlock to confirm the short `0x3e76` / `0x3e7a` versus extended `0x3e5b..0x3e71` interrupt; presented `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, both ledgers, note/checkbook evidence, `PHOTO_OF_WHALEY_HERRILL`, and `WED_MATCHES` to verify the restored response menus, shared-state writes, portrait variants, and both glue handoff paths; a no-item revisit after shared corpse-photo evidence to confirm `0x3f5b` re-arms the intro; day 5, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, Butcher death, Jimmy death, and `KARIN_FOUND_DEAD` to verify the one-shot bark chain plus the shared kidnapped-discussion writes; and the keyword loop through `0x19a/0x19b`, `0x19d..0x1a0`, `0x1a2/0x1a3`, `0x1a9..0x1ab`, `0x1b2/0x1b3`, `0x1b4/0x1b5` in `MBLM`, `0x1b8..0x1be`, `0x1bf..0x1c2`, typed unknown text, and both visible `BYE` entries to confirm the restored topic-buffer rewrites and fallback flow.
 - Immediate: run an in-engine Herrill validation pass that explicitly hits the corrected native-only paths: `HERRILL_IN_LODGE` before `PC_TALKED_TO_HERRILL` to confirm the full `0x31ca` / `0x31ce[4]` / `0x31d2` / `0x31da` / `0x31de[2]` sequence, the optional `0x31e0[2]` / `0x31e2[2]` insert when the shared Whaley/Herrill-photo flag is already set, the `GRAND_MUCK` monsterfy transition, and the one-shot `PC_TALKED_TO_HERRILL` gate; `PHOTO_OF_WHALEY_HERRILL` followed by a no-item revisit to confirm Herrill no longer self-arms the shared photo followup; `KARIN_FOUND_ALIVE` and `KARIN_FOUND_DEAD` to verify the non-native `0x3050` / `0x3057` and `PC 0x461b` lines are gone; `Lodge`, `The Lodge`, and an unrelated typed topic through the keyword loop to confirm `0x2fdd` on both lodge aliases versus the `0x3099 -> 0x2ff4` unknown-topic fallback; and both visible `BYE` entries to confirm they now share the direct `0x2ff4` exit.
 - Immediate: run an in-engine Dad validation pass that explicitly hits the newly restored native-only paths: `TAKING_BONDAGE` to confirm the `0x3a61 -> C011.FST -> 0x3a6b` tail; first no-item talk to confirm the full `0x39a5` / `0x39a9` / `0x39ad` / `0x39b1[PC2]` / `0x39b5` intro and the shared `d2ed0` / `d2f04` writes; no-item revisits with and without shared state `dadMeatPermissionState` to confirm the automatic `C112S4.FST` permission-conversion branch and the empty visible keyword menu; typed `Lodge`, `The Lodge`, and an unrelated free-text topic through Dad's Other input path to verify the restored `0x3a7d` / `0x3a83` / `0x3a88` special case vs the generic `0x3a2c` fallback; and presented `PHOTO_OF_WHALEY_HERRILL`, note/checkbook evidence, and `MEAT_PERMISSION0` to confirm the corrected shared dialogue-state writes and inventory side effects.
diff --git a/engines/harvester/npc/boyle_dialogue.cpp b/engines/harvester/npc/boyle_dialogue.cpp
index cb21829c8df..bc0ce9256e7 100644
--- a/engines/harvester/npc/boyle_dialogue.cpp
+++ b/engines/harvester/npc/boyle_dialogue.cpp
@@ -24,73 +24,106 @@
 #include "harvester/npc/boyle_dialogue.h"
 
 #include "common/array.h"
-#include "harvester/npc/dialogue_flags.h"
 #include "harvester/npc/dialogue_runtime.h"
 
 namespace Harvester {
 
 namespace {
 
+static const char *const kBoyleNpc = "BOYLE";
+static const char *const kPcSpeaker = "PC";
+static const char *const kInventoryOwnerName = "INVENTORY";
+static const char *const kBoylesButtonObject = "BOYLES_BUTTON";
+static const char *const kGascanObject = "GASCAN";
+static const char *const kLodgeApplicationObject = "LODGE_APPLICATION";
+static const char *const kHaveLodgeAppFlag = "HAVE_LODGE_APP";
+static const char *const kSheriffInDinerFlag = "SHERIFF_IN_DINER";
 static const char *const kBoyleArsonistTopic = "Arsonist";
 static const char *const kDialogueC019BFstPath = "GRAPHIC/FST/C019B.FST";
 
 static const DialogueLineEntry kBoyleHaveLodgeAppEvidenceLines[] = {
-	{ 0x13d, "BOYLE", 2 },
-	{ 0x141, "BOYLE", 0 },
-	{ 0x145, "BOYLE", 2 },
-	{ 0x14a, "BOYLE", 0 },
-	{ 0x14e, "BOYLE", 3 }
+	{ 0x13d, kBoyleNpc, 2 },
+	{ 0x141, kPcSpeaker, 0 },
+	{ 0x145, kBoyleNpc, 2 },
+	{ 0x14a, kPcSpeaker, 0 },
+	{ 0x14e, kBoyleNpc, 3 }
+};
+
+static const DialogueLineEntry kBoyleButtonIntroLines[] = {
+	{ 0x155, kBoyleNpc, 1 },
+	{ 0x15a, kPcSpeaker, 0 },
+	{ 0x15f, kBoyleNpc, 2 }
 };
 
 static const DialogueLineEntry kBoyleButtonResponse2Lines[] = {
-	{ 0x174, "BOYLE", 0 },
-	{ 0x179, "BOYLE", 0 },
-	{ 0x17d, "BOYLE", 0 }
+	{ 0x174, kBoyleNpc, 0 },
+	{ 0x179, kPcSpeaker, 0 },
+	{ 0x17d, kBoyleNpc, 0 }
 };
 
-static const DialogueLineEntry kBoyleNoteResponse1Lines[] = {
-	{ 0x1a1, "BOYLE", 2 },
-	{ 0x1a9, "BOYLE", 0 },
-	{ 0x1ad, "BOYLE", 0 },
-	{ 0x186, "BOYLE", 0 },
-	{ 0x1b8, "BOYLE", 0 },
-	{ 0x1d2, "BOYLE", 0 }
+static const DialogueLineEntry kBoyleNoteResponse1IntroLines[] = {
+	{ 0x1a1, kBoyleNpc, 2 },
+	{ 0x1a9, kPcSpeaker, 0 },
+	{ 0x1ad, kBoyleNpc, 0 }
 };
 
 static const DialogueLineEntry kBoyleGascanRewardLines[] = {
-	{ 0x1f2, "BOYLE", 3 },
-	{ 0x1f6, "BOYLE", 2 },
-	{ 0x1fa, "BOYLE", 3 },
-	{ 0x202, "BOYLE", 2 },
-	{ 0x206, "BOYLE", 2 }
+	{ 0x1f2, kBoyleNpc, 3 },
+	{ 0x1f6, kPcSpeaker, 2 },
+	{ 0x1fa, kBoyleNpc, 3 },
+	{ 0x202, kPcSpeaker, 2 },
+	{ 0x206, kBoyleNpc, 2 }
+};
+
+static const DialogueLineEntry kBoyleIntroOpeningLines[] = {
+	{ 0x7, kBoyleNpc, 0 },
+	{ 0xb, kPcSpeaker, 0 },
+	{ 0xf, kBoyleNpc, 1 }
+};
+
+static const DialogueLineEntry kBoyleIntroResponse1Lines[] = {
+	{ 0x19, kBoyleNpc, 0 },
+	{ 0x1d, kPcSpeaker, 2 },
+	{ 0x21, kBoyleNpc, 1 }
+};
+
+static const DialogueLineEntry kBoyleIntroResponse1TailLines[] = {
+	{ 0x3a, kBoyleNpc, 0 },
+	{ 0x41, kPcSpeaker, 2 },
+	{ 0x45, kBoyleNpc, 0 }
+};
+
+static const DialogueLineEntry kBoyleIntroResponse2Choice2Lines[] = {
+	{ 0x76, kBoyleNpc, 0 },
+	{ 0x77, kBoyleNpc, 2 }
 };
 
 static const DialogueLineEntry kBoyleKeywordTopic0eLines[] = {
-	{ 0x8c, "BOYLE", 0 },
-	{ 0x90, "BOYLE", 0 },
-	{ 0x94, "BOYLE", 0 },
-	{ 0x98, "BOYLE", 0 },
-	{ 0x9d, "BOYLE", 0 },
-	{ 0xa1, "BOYLE", 1 }
+	{ 0x8c, kPcSpeaker, 0 },
+	{ 0x90, kBoyleNpc, 0 },
+	{ 0x94, kPcSpeaker, 0 },
+	{ 0x98, kBoyleNpc, 0 },
+	{ 0x9d, kPcSpeaker, 0 },
+	{ 0xa1, kBoyleNpc, 1 }
 };
 
 static const DialogueLineEntry kBoyleKeywordTopic17Lines[] = {
-	{ 0xee, "BOYLE", 0 },
-	{ 0xf2, "BOYLE", 2 },
-	{ 0xf6, "BOYLE", 4 },
-	{ 0xfa, "BOYLE", 0 }
+	{ 0xee, kPcSpeaker, 0 },
+	{ 0xf2, kBoyleNpc, 2 },
+	{ 0xf6, kPcSpeaker, 4 },
+	{ 0xfa, kBoyleNpc, 0 }
 };
 
 static const DialogueLineEntry kBoyleKeywordArsonResponse1Lines[] = {
-	{ 0x126, "BOYLE", 0 },
-	{ 0x12c, "BOYLE", 0 },
-	{ 0x130, "BOYLE", 2 }
+	{ 0x126, kBoyleNpc, 0 },
+	{ 0x12c, kPcSpeaker, 0 },
+	{ 0x130, kBoyleNpc, 2 }
 };
 
 static const DialogueLineEntry kBoyleFollowupBurnedTvLines[] = {
-	{ 0x28a, "BOYLE", 1 },
-	{ 0x28e, "BOYLE", 0 },
-	{ 0x292, "BOYLE", 1 }
+	{ 0x28a, kBoyleNpc, 1 },
+	{ 0x28e, kPcSpeaker, 0 },
+	{ 0x292, kBoyleNpc, 1 }
 };
 
 static const int kBoyleKeywordTopic0fTo12Lines[] = { 0xf, 0x10, 0x11, 0x12 };
@@ -101,7 +134,7 @@ static const int kBoyleKeywordTopic18To1bLines[] = { 0x18, 0x19, 0x1a, 0x1b };
 } // End of namespace
 
 bool BoyleDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("BOYLE");
+	return npcName.equalsIgnoreCase(kBoyleNpc);
 }
 
 Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
@@ -118,12 +151,19 @@ Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		boyleTopicBuffer.clear();
 		boyleTopicBufferLineIndex = -1;
 	};
-	auto playBoyleLine = [&](int wavId, int headVariant) -> Common::Error {
-		return runtime.playDialogueLineWithVariant(wavId, "BOYLE", headVariant);
+	auto playLine = [&](int wavId, const char *speakerId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, speakerId, headVariant);
+	};
+	auto playBoyleLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return playLine(wavId, kBoyleNpc, headVariant);
 	};
 	auto playSequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
 		return runtime.playDialogueEntrySequence(lines, count);
 	};
+	auto hideInventoryItem = [&](const char *objectName) {
+		return runtime.startupScript().setRuntimeObjectVisible(
+			kInventoryOwnerName, objectName, false);
+	};
 	auto hasInventoryItem = [&](const char *objectName) {
 		Common::Array<StartupObjectRecord> inventoryObjects;
 		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
@@ -133,19 +173,19 @@ Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 		return false;
 	};
+	auto isBlackmailEvidence = [&](const Common::String &itemName) {
+		return itemName.equalsIgnoreCase("NOTE") ||
+			itemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+			itemName.equalsIgnoreCase("CHECKBOOK") ||
+			itemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY");
+	};
 
 	Common::Error lineError = Common::kNoError;
-	const bool hasBoylesButton = hasInventoryItem("BOYLES_BUTTON");
+	const bool hasBoylesButton = hasInventoryItem(kBoylesButtonObject);
 
 	if (!usedItemName.empty()) {
-		if (usedItemName.equalsIgnoreCase("BOYLES_BUTTON")) {
-			lineError = playBoyleLine(0x155, 1);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			lineError = playBoyleLine(0x15a, 0);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			lineError = playBoyleLine(0x15f, 2);
+		if (usedItemName.equalsIgnoreCase(kBoylesButtonObject)) {
+			lineError = playSequence(kBoyleButtonIntroLines, ARRAYSIZE(kBoyleButtonIntroLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
@@ -166,37 +206,35 @@ Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 
-				lineError = sharedState.boyleGascanApplicationState
-					? playBoyleLine(0x186, 0)
-					: playBoyleLine(0x1d2, 0);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				if (sharedState.boyleGascanApplicationState) {
-					lineError = playBoyleLine(0x18b, 0);
+				if (state.extraEvidenceReplyEnabled) {
+					lineError = playLine(0x186, kPcSpeaker, 0);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+					lineError = playBoyleLine(0x18b);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				} else {
+					lineError = playBoyleLine(0x1d2);
 					if (lineError.getCode() != Common::kNoError)
 						return lineError;
 				}
 
-				(void)runtime.startupScript().resetRuntimeObjectToInitialState("BOYLES_BUTTON");
+				(void)hideInventoryItem(kBoylesButtonObject);
 				sharedState.boyleGascanApplicationState = true;
 				sharedState.dialogueStateD2e98 = true;
 				sharedState.dialogueStateD2eb0 = true;
-				(void)runtime.startupScript().setRuntimeFlagValue("SHERIFF_IN_DINER", true);
+				(void)runtime.startupScript().setRuntimeFlagValue(kSheriffInDinerFlag, true);
 			}
 			return Common::kNoError;
 		}
 
-		if (usedItemName.equalsIgnoreCase("NOTE") ||
-				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
-				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
-				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(
-				DialogueFlags::kShownEvidenceOfBlackmail, true);
-			if (runtime.startupScript().getFlagValue("HAVE_LODGE_APP"))
+		if (isBlackmailEvidence(usedItemName)) {
+			if (runtime.startupScript().getFlagValue(kHaveLodgeAppFlag)) {
 				return playSequence(kBoyleHaveLodgeAppEvidenceLines,
 					ARRAYSIZE(kBoyleHaveLodgeAppEvidenceLines));
+			}
 
-			lineError = playBoyleLine(0x197, 0);
+			lineError = playBoyleLine(0x197);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
@@ -207,8 +245,19 @@ Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 			if (responseIndex == 1) {
 				sharedState.boyleGascanApplicationState = true;
-				return playSequence(kBoyleNoteResponse1Lines,
-					ARRAYSIZE(kBoyleNoteResponse1Lines));
+				lineError = playSequence(kBoyleNoteResponse1IntroLines,
+					ARRAYSIZE(kBoyleNoteResponse1IntroLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				if (state.extraEvidenceReplyEnabled) {
+					lineError = playLine(0x186, kPcSpeaker, 0);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+					lineError = playBoyleLine(0x1b8);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				}
+				return playBoyleLine(0x1d2);
 			}
 			if (responseIndex == 2) {
 				lineError = playBoyleLine(0x1d9, 1);
@@ -228,55 +277,45 @@ Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return Common::kNoError;
 		}
 
-		if (usedItemName.equalsIgnoreCase("GASCAN") &&
+		if (usedItemName.equalsIgnoreCase(kGascanObject) &&
 				sharedState.boyleGascanApplicationState) {
 			lineError = playSequence(kBoyleGascanRewardLines,
 				ARRAYSIZE(kBoyleGascanRewardLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
-			sharedState.boyleGascanApplicationState = false;
-			(void)runtime.startupScript().resetRuntimeObjectToInitialState("GASCAN");
-			(void)runtime.startupScript().setRuntimeFlagValue("SHERIFF_IN_DINER", true);
-			(void)runtime.startupScript().addRuntimeObjectToInventory("LODGE_APPLICATION");
-			(void)runtime.startupScript().setRuntimeFlagValue("HAVE_LODGE_APP", true);
+			(void)hideInventoryItem(kGascanObject);
+			(void)runtime.startupScript().addRuntimeObjectToInventory(kLodgeApplicationObject);
+			(void)runtime.startupScript().setRuntimeFlagValue(kHaveLodgeAppFlag, true);
 			return Common::kNoError;
 		}
 
 		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(
-				DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			sharedState.discussedWhaleyHerrillPhoto = 1;
 			return playBoyleLine(0x25f, 2);
 		}
 
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(
-				DialogueFlags::kShownPhotoOfCorpse, true);
+			sharedState.discussedCasketPhotoEvidence = 1;
 			lineError = playBoyleLine(0x24f, 2);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			if (sharedState.dialogueStateD2ebc) {
-				lineError = playBoyleLine(0x253, 0);
+				lineError = playLine(0x253, kPcSpeaker, 0);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 			}
 			return playBoyleLine(0x258, 2);
 		}
 
-		return playBoyleLine(0x249, 0);
+		return playBoyleLine(0x249);
 	}
 
 	if (state.introPending) {
 		state.introPending = false;
 
-		lineError = playBoyleLine(0x7, 0);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		lineError = playBoyleLine(0xb, 0);
-		if (lineError.getCode() != Common::kNoError)
-			return lineError;
-		lineError = playBoyleLine(0xf, 1);
+		lineError = playSequence(kBoyleIntroOpeningLines, ARRAYSIZE(kBoyleIntroOpeningLines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
@@ -285,14 +324,10 @@ Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 
+		bool seedButtonTopicBuffer = false;
 		if (responseIndex == 1) {
-			lineError = playBoyleLine(0x19, 0);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			lineError = playBoyleLine(0x1d, 2);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			lineError = playBoyleLine(0x21, 1);
+			lineError = playSequence(kBoyleIntroResponse1Lines,
+				ARRAYSIZE(kBoyleIntroResponse1Lines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
@@ -302,7 +337,7 @@ Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				return responseError;
 
 			if (followupResponseIndex == 1) {
-				lineError = playBoyleLine(0x2f, 0);
+				lineError = playBoyleLine(0x2f);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 			} else if (followupResponseIndex == 2) {
@@ -311,15 +346,11 @@ Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 					return lineError;
 			}
 
-			lineError = playBoyleLine(0x3a, 0);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			lineError = playBoyleLine(0x41, 2);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
-			lineError = playBoyleLine(0x45, 0);
+			lineError = playSequence(kBoyleIntroResponse1TailLines,
+				ARRAYSIZE(kBoyleIntroResponse1TailLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			seedButtonTopicBuffer = true;
 		} else if (responseIndex == 2) {
 			lineError = playBoyleLine(0x4d, 1);
 			if (lineError.getCode() != Common::kNoError)
@@ -350,11 +381,9 @@ Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 						return lineError;
 				}
 			} else if (followupResponseIndex == 2) {
-				sharedState.dialogueStateD2ebc = true;
-				lineError = playBoyleLine(0x76, 0);
-				if (lineError.getCode() != Common::kNoError)
-					return lineError;
-				lineError = playBoyleLine(0x77, 2);
+				state.extraEvidenceReplyEnabled = true;
+				lineError = playSequence(kBoyleIntroResponse2Choice2Lines,
+					ARRAYSIZE(kBoyleIntroResponse2Choice2Lines));
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 			}
@@ -363,26 +392,27 @@ Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignBoyleTopicBuffer(0x9);
+			seedButtonTopicBuffer = true;
 		}
 
-		if (hasBoylesButton)
+		if (seedButtonTopicBuffer && hasBoylesButton)
 			assignBoyleTopicBuffer(0xa);
 	} else {
-		lineError = playBoyleLine(0x23c, 0);
+		lineError = playBoyleLine(0x23c);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 		if (hasBoylesButton)
 			assignBoyleTopicBuffer(0xb);
 	}
 
-	if ((runtime.startupScript().getFlagValue(DialogueFlags::kShownEvidenceOfBlackmail) ||
+	if ((sharedState.discussedNoteCheckbookEvidence != 0 ||
 				sharedState.dwayneDiscussedBoylesButton) &&
 			!state.evidenceFollowupShown) {
 		state.evidenceFollowupShown = true;
 		lineError = playBoyleLine(0x210, 2);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		if (runtime.startupScript().getFlagValue(DialogueFlags::kShownEvidenceOfBlackmail)) {
+		if (sharedState.discussedNoteCheckbookEvidence != 0) {
 			lineError = playBoyleLine(0x21a, 2);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
@@ -393,7 +423,7 @@ Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				return lineError;
 		}
 
-		if (!runtime.startupScript().getFlagValue("HAVE_LODGE_APP")) {
+		if (!runtime.startupScript().getFlagValue(kHaveLodgeAppFlag)) {
 			lineError = playBoyleLine(0x21f, 2);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
@@ -424,7 +454,7 @@ Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
 			!state.stephMidgameShown) {
 		state.stephMidgameShown = true;
-		lineError = playBoyleLine(0x267, 0);
+		lineError = playBoyleLine(0x267);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
@@ -462,15 +492,15 @@ Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				ARRAYSIZE(kBoyleKeywordTopic0eLines));
 		} else if (runtime.matchesAnyResponseLine(selectedTopic, kBoyleKeywordTopic0fTo12Lines,
 				ARRAYSIZE(kBoyleKeywordTopic0fTo12Lines))) {
-			lineError = playBoyleLine(0xab, 0);
+			lineError = playBoyleLine(0xab);
 		} else if (runtime.matchesAnyResponseLine(selectedTopic, kBoyleKeywordTopic13To14Lines,
 				ARRAYSIZE(kBoyleKeywordTopic13To14Lines))) {
 			lineError = playBoyleLine(0xb6, 3);
 		} else if (runtime.matchesAnyResponseLine(selectedTopic, kBoyleKeywordTopic15To16Lines,
 				ARRAYSIZE(kBoyleKeywordTopic15To16Lines))) {
 			lineError = runtime.startupScript().getCurrentStoryDayIndex() >= 3
-				? playBoyleLine(0xc6, 0)
-				: playBoyleLine(0x243, 0);
+				? playBoyleLine(0xc6)
+				: playBoyleLine(0x243);
 		} else if (runtime.matchesResponseLine(selectedTopic, 0x17)) {
 			lineError = playSequence(kBoyleKeywordTopic17Lines,
 				ARRAYSIZE(kBoyleKeywordTopic17Lines));
@@ -480,13 +510,13 @@ Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				lineError = runtime.playDialogueFst(kDialogueC019BFstPath);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
-				lineError = playBoyleLine(0x10e, 0);
+				lineError = playBoyleLine(0x10e);
 			} else {
 				lineError = playBoyleLine(0x115, 1);
 			}
 		} else if (runtime.matchesResponseLine(selectedTopic, 0x1d) ||
 				selectedTopic.equalsIgnoreCase(kBoyleArsonistTopic)) {
-			lineError = playBoyleLine(0x11c, 0);
+			lineError = playBoyleLine(0x11c);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
@@ -499,7 +529,7 @@ Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				lineError = playSequence(kBoyleKeywordArsonResponse1Lines,
 					ARRAYSIZE(kBoyleKeywordArsonResponse1Lines));
 			} else if (responseIndex == 2) {
-				lineError = playBoyleLine(0x135, 0);
+				lineError = playBoyleLine(0x135);
 			} else {
 				lineError = Common::kNoError;
 			}
@@ -507,7 +537,7 @@ Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			lineError = playSequence(kBoyleHaveLodgeAppEvidenceLines,
 				ARRAYSIZE(kBoyleHaveLodgeAppEvidenceLines));
 		} else {
-			lineError = playBoyleLine(0x243, 0);
+			lineError = playBoyleLine(0x243);
 		}
 
 		if (lineError.getCode() != Common::kNoError)
diff --git a/engines/harvester/npc/boyle_dialogue.h b/engines/harvester/npc/boyle_dialogue.h
index 15da6904043..843bb0cf3f1 100644
--- a/engines/harvester/npc/boyle_dialogue.h
+++ b/engines/harvester/npc/boyle_dialogue.h
@@ -30,6 +30,7 @@ class BoyleDialogueHandler : public NpcDialogueHandler {
 public:
 	struct BoyleRoomDialogueState {
 		bool introPending = true;
+		bool extraEvidenceReplyEnabled = false;
 		bool evidenceFollowupShown = false;
 		bool stephMidgameShown = false;
 		bool dayFiveShown = false;
@@ -40,6 +41,7 @@ public:
 	void resetState() override { _state = BoyleRoomDialogueState(); }
 	void syncState(Common::Serializer &s) override {
 		syncDialogueBool(s, _state.introPending);
+		syncDialogueBool(s, _state.extraEvidenceReplyEnabled, 8);
 		syncDialogueBool(s, _state.evidenceFollowupShown);
 		syncDialogueBool(s, _state.stephMidgameShown);
 		syncDialogueBool(s, _state.dayFiveShown);
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index 369852b432f..b8418ce0d47 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -39,7 +39,7 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 }
 
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
-static const uint32 kHarvesterSaveVersion = 7;
+static const uint32 kHarvesterSaveVersion = 8;
 
 static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
 	debugC(1, kDebugGeneral,


Commit: 4948c49572543749ecec0bbdbee0b346fc6bd27e
    https://github.com/scummvm/scummvm/commit/4948c49572543749ecec0bbdbee0b346fc6bd27e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:59+02:00

Commit Message:
HARVESTER: Reimplement mother dialogue handler

Changed paths:
    TRACKER.md
    engines/harvester/npc/mother_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 07e511daa6e..f4024694b24 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,14 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `912`
-- Named/documented: `792`
-- Remaining `FUN_*`: `120`
-- Remaining undocumented total: `120`
+- Total functions: `913`
+- Named/documented: `794`
+- Remaining `FUN_*`: `119`
+- Remaining undocumented total: `119`
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Mother dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mother` (`0x2b5b0..0x2b66f`) plus `reset_talk_to_mother_state` (`0x2b690..0x2b69a`). The recovered native flow was not the old one-line bark: it gates on a single local talk-state bit, plays `0x26d6[3]`, runs `C072.FST`, plays `0x26e9[0]`, and opens response menu `0x173`; response `1` exits through `0x26f5[3] -> C073.FST`, response `2` exits through `C073.FST -> 0x2709[3] -> C072.FST -> 0x2714[0] -> 0x2719[3]`, and any other menu result falls through silently after the first menu. Patched [`engines/harvester/npc/mother_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mother_dialogue.cpp), rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and renamed the two directly referenced `FUN_*` wrappers in the live Ghidra database to `play_dialogue_c072_fst_sequence` (`0x38ae0`) and `play_dialogue_c073_fst_sequence` (`0x38910`) because each is a single-call wrapper around the exact `GRAPHIC/FST/C072.FST` / `GRAPHIC/FST/C073.FST` assets. After saving `HARVEST.LE`, progress now aligns with the live database totals (`913` functions, `794` documented, `119` remaining `FUN_*`).
 - On March 23, 2026, reimplemented the ScummVM Boyle dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_boyle` (`0x2cb20..0x2d885`) plus `reset_talk_to_boyle_state` (`0x2d8a0..0x2d8b7`). The confirmed source drift was concentrated in five native areas the earlier port had flattened or miswired: the handler was assigning the wrong speakers on Boyle's intro, keyword, button, gascan, blackmail, and burned-TV exchanges, especially the native PC turns at `0xb`, `0x15a`, `0x179`, `0x1a9`, `0x202`, `0x28e`, and the `0x8c` / `0x94` / `0x9d` / `0xee` / `0xf6` / `0x12c` keyword branches; the port was missing Boyle's sixth save-backed local bit at `0xd2cbc`, so intro response menu `0x7` choice `2` was incorrectly writing shared `d2ebc` instead of a Boyle-local evidence-reply gate that later changes the `BOYLES_BUTTON` and note/checkbook continuations; the item paths were mutating the wrong runtime state by setting generic evidence flags that native never touches here, by resetting `BOYLES_BUTTON` / `GASCAN` to their initial startup records instead of simply hiding them from inventory, and by clearing the Boyle gascan-application bit plus reasserting `SHERIFF_IN_DINER` during the gascan reward even though native only hides `GASCAN`, awards `LODGE_APPLICATION`, and sets `HAVE_LODGE_APP`; the post-intro no-item evidence follow-up was gated off the wrong ScummVM flag instead of native shared state `discussedNoteCheckbookEvidence`; and the intro response-menu `0x5` cancel path was incorrectly seeding the button keyword buffer even though native only seeds `0xa` after the two handled intro branches. Patched [`engines/harvester/npc/boyle_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/boyle_dialogue.cpp), extended Boyle's save-backed room state in [`engines/harvester/npc/boyle_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/boyle_dialogue.h), bumped the Harvester save blob version to `8` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) so the new Boyle-local bit persists cleanly, corrected the confirmed Boyle wrapper notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts remain aligned with the current live Ghidra database totals (`912` functions, `120` remaining `FUN_*`).
 - On March 23, 2026, reran the full durable `repair BOYLE -> wait -> save_program -> verify BOYLE` cycle against the live `HARVEST.LE` session to confirm Boyle still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_boyle` rebuilt to `0x2cb20..0x2d885` (`3430` address-count, `85` dialogue calls, `52` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_boyle` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Boyle was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
 - On March 23, 2026, reimplemented the ScummVM Moynahan dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_moynahan` (`0x24a20..0x25f43`). The confirmed source drift was concentrated in five native areas the earlier port had flattened or miswired: the `IF_TRY_TO_TAKE_THE_GLUE` interrupt and several presented-item branches were using wrong head variants and were missing the native response-menu continuations for `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, both ledgers, note/checkbook evidence, and `WED_MATCHES`; the handler was writing the wrong shared state for Whaley-photo and blackmail evidence, rearming the intro off the wrong corpse-photo state, and omitting the `KARIN_KIDNAPED` tail's `0x4041` line plus the shared kidnapped-discussion writes; the late no-item bark chain after `0x3fd1` still had truncated control flow and non-native portrait variants on the slash/body/day-5/Butcher/Jimmy/Karin-dead branches; the typed keyword loop was incorrectly treated as mostly terminal single-line replies instead of a looping menu that rewrites the live topic buffer through `0x19c`, `0x1a1`, `0x1a6` / `0x1a7` / `0x1a8`, `0x1ad`, `0x1b0` / `0x1b1`, and `0x1b6`; and the day-5 glue-topic path `0x1bf..0x1c2` plus response menu `0x1c3` was missing its native `GLUE` inventory / `JUST_GOT_GLUE` side effects. Patched [`engines/harvester/npc/moynahan_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/moynahan_dialogue.cpp), preserved the existing save layout in [`engines/harvester/npc/moynahan_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/moynahan_dialogue.h), rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully, and refreshed the tracker progress totals from the current live Ghidra database (`912` functions, `120` remaining `FUN_*`).
@@ -76,6 +77,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Mother validation pass that explicitly hits the recovered native-only flow: first talk with response `1` to confirm `0x26d6[3] -> C072.FST -> 0x26e9[0] -> menu 0x173 -> 0x26f5[3] -> C073.FST`; restart or reload and take response `2` to confirm `C073.FST -> 0x2709[3] -> C072.FST -> 0x2714[0] -> 0x2719[3]`; then cancel the menu once to verify the native silent fallthrough and confirm a second talk in the same room returns immediately because the local talk-state bit is cleared after the first entry.
 - Immediate: run an in-engine Boyle validation pass that explicitly hits the corrected native-only paths: intro response menu `0x5 -> 0x7 -> 2` to confirm the new Boyle-local evidence-reply bit persists and later changes the `BOYLES_BUTTON` and note/checkbook continuations; `BOYLES_BUTTON` through both `0x2` responses to verify `PC 0x179`, the optional `PC 0x186 -> BOYLE 0x18b` tail, the `d2e98` / `d2eb0` writes, and simple inventory hiding instead of object reset; note/checkbook evidence through response menus `0x3` and `0x4` with and without `HAVE_LODGE_APP` to confirm the restored `PC 0x141` / `0x14a`, `PC 0x1a9`, optional `PC 0x186 -> BOYLE 0x1b8`, and the no-item follow-up staying gated off shared note/checkbook state rather than the local Boyle branch; `GASCAN` after Boyle has accepted the application deal to confirm only `GASCAN` hides and `LODGE_APPLICATION` is awarded; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared `d2ebc` to verify the optional `PC 0x253`; and keyword topics `0xe`, `0x17`, `0x18..0x1b`, typed `Arsonist`, and both visible `BYE` entries to confirm the restored PC/Boyle speaker split and native exit flow.
 - Immediate: run an in-engine Moynahan validation pass that explicitly hits the corrected native-only branches: `IF_TRY_TO_TAKE_THE_GLUE` before and after the slashes-on-body topic unlock to confirm the short `0x3e76` / `0x3e7a` versus extended `0x3e5b..0x3e71` interrupt; presented `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, both ledgers, note/checkbook evidence, `PHOTO_OF_WHALEY_HERRILL`, and `WED_MATCHES` to verify the restored response menus, shared-state writes, portrait variants, and both glue handoff paths; a no-item revisit after shared corpse-photo evidence to confirm `0x3f5b` re-arms the intro; day 5, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, Butcher death, Jimmy death, and `KARIN_FOUND_DEAD` to verify the one-shot bark chain plus the shared kidnapped-discussion writes; and the keyword loop through `0x19a/0x19b`, `0x19d..0x1a0`, `0x1a2/0x1a3`, `0x1a9..0x1ab`, `0x1b2/0x1b3`, `0x1b4/0x1b5` in `MBLM`, `0x1b8..0x1be`, `0x1bf..0x1c2`, typed unknown text, and both visible `BYE` entries to confirm the restored topic-buffer rewrites and fallback flow.
 - Immediate: run an in-engine Herrill validation pass that explicitly hits the corrected native-only paths: `HERRILL_IN_LODGE` before `PC_TALKED_TO_HERRILL` to confirm the full `0x31ca` / `0x31ce[4]` / `0x31d2` / `0x31da` / `0x31de[2]` sequence, the optional `0x31e0[2]` / `0x31e2[2]` insert when the shared Whaley/Herrill-photo flag is already set, the `GRAND_MUCK` monsterfy transition, and the one-shot `PC_TALKED_TO_HERRILL` gate; `PHOTO_OF_WHALEY_HERRILL` followed by a no-item revisit to confirm Herrill no longer self-arms the shared photo followup; `KARIN_FOUND_ALIVE` and `KARIN_FOUND_DEAD` to verify the non-native `0x3050` / `0x3057` and `PC 0x461b` lines are gone; `Lodge`, `The Lodge`, and an unrelated typed topic through the keyword loop to confirm `0x2fdd` on both lodge aliases versus the `0x3099 -> 0x2ff4` unknown-topic fallback; and both visible `BYE` entries to confirm they now share the direct `0x2ff4` exit.
diff --git a/engines/harvester/npc/mother_dialogue.cpp b/engines/harvester/npc/mother_dialogue.cpp
index 80768fbafb3..f6c4c85bc3f 100644
--- a/engines/harvester/npc/mother_dialogue.cpp
+++ b/engines/harvester/npc/mother_dialogue.cpp
@@ -27,6 +27,14 @@
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kMotherSpeakerId = "MOTHER";
+static const char *const kDialogueC072FstPath = "GRAPHIC/FST/C072.FST";
+static const char *const kDialogueC073FstPath = "GRAPHIC/FST/C073.FST";
+
+} // End of namespace
+
 bool MotherDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("MOTHER");
 }
@@ -36,8 +44,57 @@ Common::Error MotherDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (!_state.talkStatePending)
 		return Common::kNoError;
 
+	auto playMotherLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kMotherSpeakerId, headVariant);
+	};
+
 	_state.talkStatePending = false;
-	return runtime.playDialogueLineWithVariant(0x26d6, "MOTHER", 3);
+
+	Common::Error lineError = playMotherLine(0x26d6, 3);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = runtime.playDialogueFst(kDialogueC072FstPath);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = playMotherLine(0x26e9, 0);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(0x173, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1) {
+		lineError = playMotherLine(0x26f5, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		return runtime.playDialogueFst(kDialogueC073FstPath);
+	}
+
+	if (responseIndex != 2)
+		return Common::kNoError;
+
+	lineError = runtime.playDialogueFst(kDialogueC073FstPath);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = playMotherLine(0x2709, 3);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = runtime.playDialogueFst(kDialogueC072FstPath);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = playMotherLine(0x2714, 0);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	return playMotherLine(0x2719, 3);
 }
 
 } // End of namespace Harvester


Commit: d7449e45b08c16f99cfd3dd4f3df366408397c02
    https://github.com/scummvm/scummvm/commit/d7449e45b08c16f99cfd3dd4f3df366408397c02
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:07:59+02:00

Commit Message:
HARVESTER: Reimplement parsons dialogue

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/parsons_dialogue.cpp
    engines/harvester/npc/parsons_dialogue.h
    engines/harvester/saveload.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 067d6b7e75d..cbf0845bdaf 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -624,6 +624,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - Boyle's item logic now cleanly splits into five native clusters: `BOYLES_BUTTON` uses response menu `0x2` and can arm the Boyle-local gas-can application state, shared `d2e98` / `d2eb0`, and `SHERIFF_IN_DINER`; note/checkbook evidence either reuses the blackmail sequence or branches through response menus `0x3` and `0x4`; `PHOTO_OF_WHALEY_HERRILL` is the dedicated `0x25f` path; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` is the `0x24f` / optional `0x253` / `0x258` sequence; all other items fall back to `0x249`.
   - A Boyle-local save-backed bit at `0xd2cbc` now has a confirmed behavior: intro response menu `0x7` choice `2` sets it before `0x76` / `0x77`, and later `BOYLES_BUTTON` or note/checkbook response-1 branches use it to insert `PC 0x186` before Boyle's `0x18b` / `0x1b8` evidence-reply variants instead of the shorter direct tail.
   - `play_c019b_fst_sequence` at `0x38620` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C019B.FST", 0)` and is currently only called from Boyle's `d2eb0 == 0` evidence-topic branch.
+  - `play_c093_fst_sequence` at `0x38730` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C093.FST", 0)` and is currently only called from Parsons's `Pole` topic branch.
+  - `play_c094_fst_sequence` at `0x38740` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C094.FST", 0)` and is currently only called from Parsons's `Aliens` / `Pastorelli` topic branch.
 - The adjacent `0x383d0`-`0x384e0` range is now confirmed to be another overlapping run of tiny read/write dialogue-state wrappers over globals in the same contiguous block (`0xd2f00` through `0xd2f28`).
   - `get_set_sergeant_completed_first_task_state` at `0x384e0` is the Sergeant-local bit set on the `SCRATCHED_TUCKER` branch immediately before dialogue `0x420d` ("So, you have completed your first task.").
   - The `SCRATCHED_TUCKER` no-item path is not just that single bark. After `0x420d`, Sergeant continues with `0x421e`, opens zero-based `dialog.rsp` line `0x2b4`, and then either accepts the handoff with `0x422a` or pushes back through `0x422f` / `0x4239`. Both outcomes then fall through a one-shot local `d2d58` gate that dispatches action tag `GO_DAY_3`.
diff --git a/TRACKER.md b/TRACKER.md
index f4024694b24..1b4a474dc96 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -4,13 +4,17 @@
 
 - Program: `HARVEST.LE`
 - Total functions: `913`
-- Named/documented: `794`
-- Remaining `FUN_*`: `119`
-- Remaining undocumented total: `119`
+- Named/documented: `796`
+- Remaining `FUN_*`: `117`
+- Remaining undocumented total: `117`
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Parsons dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_parsons` (`0x397c0..0x39f1b`) and `reset_talk_to_parsons_state` (`0x39f40..0x39f69`). The recovered native flow was much wider than the old evidence stub: no-item dialogue now runs the full `0xdcc/0xdd2/0xdd6/0xde3` intro with Swell, the one-shot event barks for `STEPH_MIDGAME_PLAYED`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `DINER_BURNED`, `BURNED_TV_STATION`, and `KARIN_KIDNAPED`, and the looping `dialog.rsp` keyword branches for `Pole`, `Alarm System`, and the `Aliens` / `Pastorelli` group with the native `C093.FST` and `C094.FST` inserts. The item logic also had three native continuations the stub was missing: `CASKET_PHOTO` / `CASKET_PHOTOCOPY` optionally extends through `PC 0xe84 -> PARSONS 0xe88` when shared state `d2ebc` is already set, note/checkbook evidence is the three-line `0xe95 -> 0xe99 -> 0xe9d` exchange rather than a single bark, and all native portrait variants now match the live assembly. Patched [`engines/harvester/npc/parsons_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/parsons_dialogue.cpp), extended Parsons's save-backed room state in [`engines/harvester/npc/parsons_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/parsons_dialogue.h), bumped the Harvester save blob version to `9` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) so the six new Parsons-local one-shot bits persist cleanly, and renamed the two directly referenced `FUN_*` wrappers in the live Ghidra database to `play_c093_fst_sequence` (`0x38730`) and `play_c094_fst_sequence` (`0x38740`) because each is a single-call wrapper around the exact `GRAPHIC/FST/C093.FST` / `GRAPHIC/FST/C094.FST` assets. Progress now aligns with the saved live database totals (`913` functions, `796` documented, `117` remaining `FUN_*`).
+- On March 23, 2026, reran the full durable `repair PARSONS -> wait -> save_program -> verify PARSONS` cycle against the live `HARVEST.LE` session. `handle_talk_to_parsons @ 0x397c0` started as a truncated `0x397c0..0x39a7a` body (`468` address-count) with `7` reachable dialogue calls and `7` stale `CALL_RETURN` gaps. The repair needed two in-memory passes: pass 1 expanded the handler to `0x397c0..0x39f1b` with `32` dialogue calls but left `7` newly exposed gaps, and pass 2 finished the rebuild cleanly at `0x397c0..0x39f1b` (`1884` address-count) with `43` reachable dialogue calls, `gapCount=0`, and `28` total repair actions. After the normal settle and explicit save, a fresh `verify PARSONS` still reported `status=clean` with both dialogue entry points returning, so Parsons is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Mother dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mother` (`0x2b5b0..0x2b66f`) plus `reset_talk_to_mother_state` (`0x2b690..0x2b69a`). The recovered native flow was not the old one-line bark: it gates on a single local talk-state bit, plays `0x26d6[3]`, runs `C072.FST`, plays `0x26e9[0]`, and opens response menu `0x173`; response `1` exits through `0x26f5[3] -> C073.FST`, response `2` exits through `C073.FST -> 0x2709[3] -> C072.FST -> 0x2714[0] -> 0x2719[3]`, and any other menu result falls through silently after the first menu. Patched [`engines/harvester/npc/mother_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mother_dialogue.cpp), rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and renamed the two directly referenced `FUN_*` wrappers in the live Ghidra database to `play_dialogue_c072_fst_sequence` (`0x38ae0`) and `play_dialogue_c073_fst_sequence` (`0x38910`) because each is a single-call wrapper around the exact `GRAPHIC/FST/C072.FST` / `GRAPHIC/FST/C073.FST` assets. After saving `HARVEST.LE`, progress now aligns with the live database totals (`913` functions, `794` documented, `119` remaining `FUN_*`).
+- On March 23, 2026, reran the full durable `repair MOTHER -> wait -> save_program -> verify MOTHER` cycle against the live `HARVEST.LE` session. `handle_talk_to_mother @ 0x2b5b0` started as a truncated `0x2b5b0..0x2b66f` stub with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap at `0x2b5d6`, then rebuilt cleanly on the first pass to `0x2b5b0..0x2b66f` (`192` address-count, `6` dialogue calls, `3` repair actions). After the normal settle and explicit save, a fresh `verify MOTHER` still reported `gapCount=0`, `status=clean`, and both dialogue entry points returning, so Mother is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
+- On March 23, 2026, fixed [`InspectCallTermination.java`](/Users/alex/ghidra_scripts/InspectCallTermination.java) to recognize exact `dispatch_action_tag_if_set` talk stubs as valid data-driven handlers instead of false `noDialogueCalls` repair failures. `handle_talk_to_pastorelli @ 0x38160` is one of those cases: the live `HARVEST.LE` body is an exact 13-byte stub (`0x38160..0x3816c`) that loads `PASTORELLI_TALK`, calls `dispatch_action_tag_if_set`, and returns, while the real dialogue work is deferred into `dispatch_room_event_actions`, whose opcode `0x1a` path runs NPC dialogue from room-command data. After the script patch, both `repair PASTORELLI` and a fresh `verify PASTORELLI` report `dispatchOnly=true`, `gapCount=0`, and `status=clean` with no database repair actions required. Progress counts are unchanged because the Ghidra program state did not change; this was a verifier classification fix.
 - On March 23, 2026, reimplemented the ScummVM Boyle dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_boyle` (`0x2cb20..0x2d885`) plus `reset_talk_to_boyle_state` (`0x2d8a0..0x2d8b7`). The confirmed source drift was concentrated in five native areas the earlier port had flattened or miswired: the handler was assigning the wrong speakers on Boyle's intro, keyword, button, gascan, blackmail, and burned-TV exchanges, especially the native PC turns at `0xb`, `0x15a`, `0x179`, `0x1a9`, `0x202`, `0x28e`, and the `0x8c` / `0x94` / `0x9d` / `0xee` / `0xf6` / `0x12c` keyword branches; the port was missing Boyle's sixth save-backed local bit at `0xd2cbc`, so intro response menu `0x7` choice `2` was incorrectly writing shared `d2ebc` instead of a Boyle-local evidence-reply gate that later changes the `BOYLES_BUTTON` and note/checkbook continuations; the item paths were mutating the wrong runtime state by setting generic evidence flags that native never touches here, by resetting `BOYLES_BUTTON` / `GASCAN` to their initial startup records instead of simply hiding them from inventory, and by clearing the Boyle gascan-application bit plus reasserting `SHERIFF_IN_DINER` during the gascan reward even though native only hides `GASCAN`, awards `LODGE_APPLICATION`, and sets `HAVE_LODGE_APP`; the post-intro no-item evidence follow-up was gated off the wrong ScummVM flag instead of native shared state `discussedNoteCheckbookEvidence`; and the intro response-menu `0x5` cancel path was incorrectly seeding the button keyword buffer even though native only seeds `0xa` after the two handled intro branches. Patched [`engines/harvester/npc/boyle_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/boyle_dialogue.cpp), extended Boyle's save-backed room state in [`engines/harvester/npc/boyle_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/boyle_dialogue.h), bumped the Harvester save blob version to `8` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) so the new Boyle-local bit persists cleanly, corrected the confirmed Boyle wrapper notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts remain aligned with the current live Ghidra database totals (`912` functions, `120` remaining `FUN_*`).
 - On March 23, 2026, reran the full durable `repair BOYLE -> wait -> save_program -> verify BOYLE` cycle against the live `HARVEST.LE` session to confirm Boyle still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_boyle` rebuilt to `0x2cb20..0x2d885` (`3430` address-count, `85` dialogue calls, `52` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_boyle` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Boyle was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
 - On March 23, 2026, reimplemented the ScummVM Moynahan dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_moynahan` (`0x24a20..0x25f43`). The confirmed source drift was concentrated in five native areas the earlier port had flattened or miswired: the `IF_TRY_TO_TAKE_THE_GLUE` interrupt and several presented-item branches were using wrong head variants and were missing the native response-menu continuations for `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, both ledgers, note/checkbook evidence, and `WED_MATCHES`; the handler was writing the wrong shared state for Whaley-photo and blackmail evidence, rearming the intro off the wrong corpse-photo state, and omitting the `KARIN_KIDNAPED` tail's `0x4041` line plus the shared kidnapped-discussion writes; the late no-item bark chain after `0x3fd1` still had truncated control flow and non-native portrait variants on the slash/body/day-5/Butcher/Jimmy/Karin-dead branches; the typed keyword loop was incorrectly treated as mostly terminal single-line replies instead of a looping menu that rewrites the live topic buffer through `0x19c`, `0x1a1`, `0x1a6` / `0x1a7` / `0x1a8`, `0x1ad`, `0x1b0` / `0x1b1`, and `0x1b6`; and the day-5 glue-topic path `0x1bf..0x1c2` plus response menu `0x1c3` was missing its native `GLUE` inventory / `JUST_GOT_GLUE` side effects. Patched [`engines/harvester/npc/moynahan_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/moynahan_dialogue.cpp), preserved the existing save layout in [`engines/harvester/npc/moynahan_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/moynahan_dialogue.h), rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully, and refreshed the tracker progress totals from the current live Ghidra database (`912` functions, `120` remaining `FUN_*`).
@@ -77,6 +81,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Parsons validation pass that explicitly hits the restored native-only flow: first no-item talk to confirm the full `0xdcc[1] -> 0xdd2[0] -> 0xdd6[0] -> 0xde3[1]` intro plus the initial `Pole/Alarm System/Pastorelli` topic buffer; `STEPH_MIDGAME_PLAYED`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `DINER_BURNED`, `BURNED_TV_STATION`, and `KARIN_KIDNAPED` on separate revisits to verify each Parsons-local one-shot gate now persists across save/load and that the Swell exchanges reset the active portrait back to Parsons; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared state `d2ebc` to confirm the optional `PC 0xe84 -> PARSONS 0xe88` continuation; note/checkbook and TV-deed evidence to verify the restored multi-line evidence replies and portrait variants; and the keyword loop through `Pole`, `Alarm System`, `Aliens`, `Pastorelli`, `barber`, and `Mr. Pastorelli` plus both visible `BYE` exits to confirm the `C093.FST` / `C094.FST` inserts, topic-buffer rewrites, and common `0xe5f` exit line.
 - Immediate: run an in-engine Mother validation pass that explicitly hits the recovered native-only flow: first talk with response `1` to confirm `0x26d6[3] -> C072.FST -> 0x26e9[0] -> menu 0x173 -> 0x26f5[3] -> C073.FST`; restart or reload and take response `2` to confirm `C073.FST -> 0x2709[3] -> C072.FST -> 0x2714[0] -> 0x2719[3]`; then cancel the menu once to verify the native silent fallthrough and confirm a second talk in the same room returns immediately because the local talk-state bit is cleared after the first entry.
 - Immediate: run an in-engine Boyle validation pass that explicitly hits the corrected native-only paths: intro response menu `0x5 -> 0x7 -> 2` to confirm the new Boyle-local evidence-reply bit persists and later changes the `BOYLES_BUTTON` and note/checkbook continuations; `BOYLES_BUTTON` through both `0x2` responses to verify `PC 0x179`, the optional `PC 0x186 -> BOYLE 0x18b` tail, the `d2e98` / `d2eb0` writes, and simple inventory hiding instead of object reset; note/checkbook evidence through response menus `0x3` and `0x4` with and without `HAVE_LODGE_APP` to confirm the restored `PC 0x141` / `0x14a`, `PC 0x1a9`, optional `PC 0x186 -> BOYLE 0x1b8`, and the no-item follow-up staying gated off shared note/checkbook state rather than the local Boyle branch; `GASCAN` after Boyle has accepted the application deal to confirm only `GASCAN` hides and `LODGE_APPLICATION` is awarded; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared `d2ebc` to verify the optional `PC 0x253`; and keyword topics `0xe`, `0x17`, `0x18..0x1b`, typed `Arsonist`, and both visible `BYE` entries to confirm the restored PC/Boyle speaker split and native exit flow.
 - Immediate: run an in-engine Moynahan validation pass that explicitly hits the corrected native-only branches: `IF_TRY_TO_TAKE_THE_GLUE` before and after the slashes-on-body topic unlock to confirm the short `0x3e76` / `0x3e7a` versus extended `0x3e5b..0x3e71` interrupt; presented `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, both ledgers, note/checkbook evidence, `PHOTO_OF_WHALEY_HERRILL`, and `WED_MATCHES` to verify the restored response menus, shared-state writes, portrait variants, and both glue handoff paths; a no-item revisit after shared corpse-photo evidence to confirm `0x3f5b` re-arms the intro; day 5, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, Butcher death, Jimmy death, and `KARIN_FOUND_DEAD` to verify the one-shot bark chain plus the shared kidnapped-discussion writes; and the keyword loop through `0x19a/0x19b`, `0x19d..0x1a0`, `0x1a2/0x1a3`, `0x1a9..0x1ab`, `0x1b2/0x1b3`, `0x1b4/0x1b5` in `MBLM`, `0x1b8..0x1be`, `0x1bf..0x1c2`, typed unknown text, and both visible `BYE` entries to confirm the restored topic-buffer rewrites and fallback flow.
@@ -86,7 +91,7 @@
 - Immediate: run an in-engine Edna validation pass that explicitly hits the restored native-only branches: `KILLED_KARIN1` to confirm the flag now clears; `DNA_S_SUICIDE_NOTE` with and without `KARIN_FOUND_ALIVE` to confirm `C043.FST` / `C043K.FST`, shared states `d2ea4` / `d2ea8`, and `DNAEXT:SIGNOUT`; direct `K_PURSE` use plus the `KARIN_FOUND_DEAD -> 0xa2 -> 0xa3/0xa4` nested purse menu to verify `REWARD_MONEY` and the `RAH`-hidden purse side effect; the day-5 alive and dead follow-ups; and the keyword loop through the recovered `0xa6`, `0xac/0xad`, `0xb0`, `0xb2`, `0xb6/0xb7`, `0xba`, `0xbc/0xbd/0xbe`, `0xc0`, `0xc2`, typed unknown text, and goodbye branches to confirm the restored menu buffer rewrites and both `C091.FST` / `C070.FST` exits.
 - Immediate: run an in-engine Karin validation pass that explicitly hits the restored native-only branches: the first `KARIN_FOUND_ALIVE` + `IN_CEM10` visit to confirm the full `0x10a5`..`0x10c3` early-return sequence; first and repeat no-item visits before Karin is found alive to confirm the recovered `0x1076`..`0x108b` prelude plus the shared `0x10dc` bark; first and repeat uses of `CASKET_PHOTO` or `PHOTO_OF_WHALEY_HERRILL` to verify the `0x10e7` Edna follow-up, portrait reset, shared evidence-state write, one-shot override, and repeat-photo `CALL_POLICE` escalation; and `INV_MAG` to confirm `0x10ee` / `0x10f2[2]` plus `DNALFT_PERVERT`.
 - Immediate: run an in-engine Dwayne validation pass that explicitly hits the restored native-only branches: `CHECK_EVIDENCE_DOOR` and the first/second `ST_BEDRM` visits to confirm the new flag clears; the sheriff-in-diner intro and revisit to verify Edna `0x3490/0x3496`, the optional `0x34a0/0x34a4` tail, and the later keyword loop; Boyle's button, Karin's purse, note/checkbook photocopy -> original note/checkbook, both `TV_DEED` and `TV_DEED_PHOTOCOPY` response trees plus the override revisit on the real deed, corpse photo with and without shared state `d2ec8`, and both-ledger handoff with and without `SHERIFF_IN_DINER`; `KARIN_KIDNAPED` through response menus `0x7b/0x7c`, `KARIN_FOUND_DEAD` without the purse through `0x7f/0x80`, and the `PC_HAS_GOOJF_CARD` arrest path with and without `PC_KILLED_ANYONE` to confirm the confiscation and bust follow-up state now matches native.
-- Immediate: resume the sequential talk-handler sweep at `PASTORELLI`, using the proven single-handler workflow `verify <NPC> -> repair <NPC> -> wait ~10s -> save_program -> verify <NPC>` and immediately rerunning the same NPC once when the first saved verify snaps back. After Dwayne and Edna, the remaining not-yet-confirmed handlers in that queue are `PASTORELLI`, `LODGE_CHEF`, `RYDER`, `SPARKY`, `MEMB_DIR`, and `PARSONS`.
+- Immediate: resume the sequential talk-handler sweep at `LODGE_CHEF`, using the proven single-handler workflow `verify <NPC> -> repair <NPC> -> wait ~10s -> save_program -> verify <NPC>` and immediately rerunning the same NPC once when the first saved verify snaps back. After the Pastorelli verifier fix and the durable Parsons rebuild, the remaining not-yet-confirmed handlers in that queue are `LODGE_CHEF`, `RYDER`, `SPARKY`, and `MEMB_DIR`.
 - Immediate: run an in-engine Johnson validation pass that explicitly hits the restored native-only branches: `OPENING_MANHOLE_DAYTIME` twice to confirm `0xbe0` now clears the flag; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared state `d2ebc`, and `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` to verify the restored `0xb7b`, `0xb80`, `0xb90`, `0xb94`, `0xb99`, `0xba4`, and `0xba8` follow-ups; the first `SCRATCHED_TUCKER` visit with `BOLT_OF_CLOTH_TAKEN` already set to confirm `0xc25` / `0xc2b` / `0xc30` plus `0xc5f` / `0xc69` and that the Johnson intro only replays on the later revisit; and a `KARIN_KIDNAPED` follow-up that later reaches a consumer such as Stephanie to confirm the shared kidnapped-dialogue state now propagates correctly.
 - Immediate: run an in-engine Loomis validation pass that explicitly hits the restored native-only branches: first talk with and without `SHERIFF_IN_DINER` to confirm the `0x1112 -> 0x1118 -> 0x1121 -> response 0x53 -> 0x1130[3]` intro; the second and third no-item visits to confirm the one-shot `0x11a8/0x11ac/0x11b0[3]` follow-up, the later `0x11f6` bark, and the `STEPH_MIDGAME_PLAYED` / day-5 `0x1230[3]` and `0x123c` gates; the keyword loop through `LOOMIS`, `Phelps`, `Man's Needs`, sheriff topics, `0x65`, `0x66`, typed unknown text, and goodbye to verify the restored alias matches, portrait variants, `0x11fc` fallback, and `0x11a1[1]` exit; note/checkbook or TV-deed evidence through both `0x50` responses to confirm `0x1214` / `0x1219` / `0x121f[3]` plus `PC_FRY_DADDY_1`; and `INV_MAG` in the diner branch through both `0x51` responses to confirm `0x11c6[1]`, `0x11cc[3]`, drawer visibility, `GAVE_MAG_TO_LOOMIS_TODAY`, and the `C048.FST` tail.
 - Immediate: run an in-engine Mrs. Potts validation pass that explicitly hits the restored native-only paths: `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `LEDGER` with `HAVE_BOTH_LEDGERS`, and the `GOT_REMAINS_FOR_LODGE` response-1 death branch to confirm the corrected portrait variants; the non-dead revisit gate around `0x281b` / `0x2820` / `0x2825`; and `DINER_BURNED` with and without `KARIN_HUNG`, taking both `0x236` responses to verify the new `0x2914` / `0x2918` / `0x2926` continuation and the response-2 jump into the shared death-in-chair sequence.
diff --git a/engines/harvester/npc/parsons_dialogue.cpp b/engines/harvester/npc/parsons_dialogue.cpp
index 4f8f899fb71..34df03988e2 100644
--- a/engines/harvester/npc/parsons_dialogue.cpp
+++ b/engines/harvester/npc/parsons_dialogue.cpp
@@ -28,49 +28,295 @@
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kParsonsSpeakerId = "PARSONS";
+static const char *const kSwellSpeakerId = "SWELL";
+static const char *const kPcSpeakerId = "PC";
+static const char *const kDialogueC093FstPath = "GRAPHIC/FST/C093.FST";
+static const char *const kDialogueC094FstPath = "GRAPHIC/FST/C094.FST";
+
+static const int kParsonsIntroTopicLine = 0x1c5;
+static const int kParsonsExitTopicLine = 0x1c6;
+static const int kParsonsPoleTopicLine = 0x1c7;
+static const int kParsonsPoleFollowupTopicLine = 0x1c8;
+static const int kParsonsAlarmSystemTopicLine = 0x1c9;
+static const int kParsonsAlarmSystemFollowupTopicLine = 0x1ca;
+static const int kParsonsAliensTopicLine = 0x1cb;
+static const int kParsonsPastorelliTopicLine = 0x1cd;
+static const int kParsonsBarberTopicLine = 0x1ce;
+static const int kParsonsMrPastorelliTopicLine = 0x1cf;
+static const int kParsonsAliensFollowupTopicLine = 0x1d0;
+static const int kParsonsHiddenExitTopicLine = 0x1d1;
+
+static const int kParsonsAliensTopicResponseLines[] = {
+	kParsonsAliensTopicLine,
+	kParsonsPastorelliTopicLine,
+	kParsonsBarberTopicLine,
+	kParsonsMrPastorelliTopicLine
+};
+
+static const DialogueLineEntry kParsonsIntroLines[] = {
+	{ 0xdcc, kParsonsSpeakerId, 1 },
+	{ 0xdd2, kSwellSpeakerId, 0 },
+	{ 0xdd6, kParsonsSpeakerId, 0 },
+	{ 0xde3, kParsonsSpeakerId, 1 }
+};
+
+static const DialogueLineEntry kParsonsStephMidgameLines[] = {
+	{ 0xeab, kParsonsSpeakerId, 3 },
+	{ 0xeb3, kSwellSpeakerId, 0 },
+	{ 0xeb7, kParsonsSpeakerId, 1 }
+};
+
+static const DialogueLineEntry kParsonsScratchedTuckerLines[] = {
+	{ 0xec2, kParsonsSpeakerId, 2 },
+	{ 0xec9, kParsonsSpeakerId, 2 }
+};
+
+static const DialogueLineEntry kParsonsBurnedTvStationLines[] = {
+	{ 0xee5, kParsonsSpeakerId, 0 },
+	{ 0xeea, kSwellSpeakerId, 1 },
+	{ 0xeef, kParsonsSpeakerId, 0 },
+	{ 0xef4, kSwellSpeakerId, 3 }
+};
+
+static const DialogueLineEntry kParsonsKarinKidnapedLines[] = {
+	{ 0xefa, kParsonsSpeakerId, 3 },
+	{ 0xf03, kSwellSpeakerId, 3 }
+};
+
+static const DialogueLineEntry kParsonsBlackmailEvidenceLines[] = {
+	{ 0xe95, kParsonsSpeakerId, 0 },
+	{ 0xe99, kPcSpeakerId, 0 },
+	{ 0xe9d, kParsonsSpeakerId, 1 }
+};
+
+} // End of anonymous namespace
+
 bool ParsonsDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("PARSONS");
+	return npcName.equalsIgnoreCase(kParsonsSpeakerId);
 }
 
 Common::Error ParsonsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &) {
-	if (usedItemName.empty()) {
-		if (_state.talkStatePending) {
-			_state.talkStatePending = false;
-			return runtime.playDialogueLine(0xdcc, "PARSONS");
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	auto playParsonsLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kParsonsSpeakerId, headVariant);
+	};
+	auto playSwellLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kSwellSpeakerId, headVariant);
+	};
+	auto playPcLine = [&](int wavId, int headVariant) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kPcSpeakerId, headVariant);
+	};
+	auto assignParsonsTopicBuffer = [&](Common::String &topicBuffer, int &topicBufferLineIndex,
+			int responseLineIndex) {
+		runtime.assignTopicBuffer(topicBuffer, topicBufferLineIndex,
+			responseLineIndex, "Parsons topic buffer");
+	};
+
+	if (!usedItemName.empty()) {
+		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			return playParsonsLine(0xe77, 1);
+		}
+
+		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
+				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				DialogueFlags::kShownPhotoOfCorpse, true);
+
+			Common::Error lineError = playParsonsLine(0xe7e, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			if (!sharedState.dialogueStateD2ebc)
+				return Common::kNoError;
+
+			lineError = playPcLine(0xe84, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			return playParsonsLine(0xe88, 0);
+		}
+
+		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
+					usedItemName.equalsIgnoreCase("LEDGER2")) &&
+				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				DialogueFlags::kShownLedgersToAnyone, true);
+			return playParsonsLine(0xe8e, 0);
+		}
+
+		if (usedItemName.equalsIgnoreCase("NOTE") ||
+				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
+				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				DialogueFlags::kShownEvidenceOfBlackmail, true);
+			return runtime.playDialogueEntrySequence(
+				kParsonsBlackmailEvidenceLines, ARRAYSIZE(kParsonsBlackmailEvidenceLines));
+		}
+
+		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
+				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
+			(void)runtime.startupScript().setRuntimeFlagValue(
+				DialogueFlags::kShownEvidenceSheriffOwns, true);
+			return playParsonsLine(0xea4, 1);
 		}
-		return runtime.playDialogueLine(0xe65, "PARSONS");
+
+		return playParsonsLine(0xe71, 2);
 	}
 
-	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-		return runtime.playDialogueLine(0xe77, "PARSONS");
+	Common::Error lineError = Common::kNoError;
+	Common::String topicBuffer;
+	int topicBufferLineIndex = -1;
+	assignParsonsTopicBuffer(topicBuffer, topicBufferLineIndex, kParsonsIntroTopicLine);
+
+	if (_state.introPending) {
+		_state.introPending = false;
+		lineError = runtime.playDialogueEntrySequence(kParsonsIntroLines, ARRAYSIZE(kParsonsIntroLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	} else {
+		lineError = playParsonsLine(0xe65, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
-	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
-		return runtime.playDialogueLine(0xe7e, "PARSONS");
+
+	if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
+			!_state.stephMidgameShown) {
+		_state.stephMidgameShown = true;
+		lineError = runtime.playDialogueEntrySequence(
+			kParsonsStephMidgameLines, ARRAYSIZE(kParsonsStephMidgameLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
-	if ((usedItemName.equalsIgnoreCase("LEDGER") ||
-				usedItemName.equalsIgnoreCase("LEDGER2")) &&
-			runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
-		return runtime.playDialogueLine(0xe8e, "PARSONS");
+
+	if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER") &&
+			!_state.scratchedTuckerShown) {
+		_state.scratchedTuckerShown = true;
+		lineError = runtime.playDialogueEntrySequence(
+			kParsonsScratchedTuckerLines, ARRAYSIZE(kParsonsScratchedTuckerLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
-	if (usedItemName.equalsIgnoreCase("NOTE") ||
-			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
-			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
-			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
-		return runtime.playDialogueLine(0xe95, "PARSONS");
+
+	if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") &&
+			!_state.barberPoleStolenShown) {
+		_state.barberPoleStolenShown = true;
+		lineError = playParsonsLine(0xed2, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
-	if (usedItemName.equalsIgnoreCase("TV_DEED") ||
-			usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
-		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceSheriffOwns, true);
-		return runtime.playDialogueLine(0xea4, "PARSONS");
+
+	if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
+			!_state.dinerBurnedShown) {
+		_state.dinerBurnedShown = true;
+		lineError = playParsonsLine(0xedd, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
+			!_state.burnedTvStationShown) {
+		_state.burnedTvStationShown = true;
+		lineError = runtime.playDialogueEntrySequence(
+			kParsonsBurnedTvStationLines, ARRAYSIZE(kParsonsBurnedTvStationLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		runtime.setActiveSpeakerPortrait(kParsonsSpeakerId, 0);
+	}
+
+	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
+			!_state.karinKidnapedShown) {
+		_state.karinKidnapedShown = true;
+		lineError = runtime.playDialogueEntrySequence(
+			kParsonsKarinKidnapedLines, ARRAYSIZE(kParsonsKarinKidnapedLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		runtime.setActiveSpeakerPortrait(kParsonsSpeakerId, 0);
 	}
 
-	return runtime.playDialogueLine(0xe71, "PARSONS");
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runtime.runKeywordMenu(topicBuffer, topicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.empty())
+			return Common::kNoError;
+
+		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
+				runtime.matchesResponseLine(selectedTopic, kParsonsExitTopicLine) ||
+				runtime.matchesResponseLine(selectedTopic, kParsonsHiddenExitTopicLine)) {
+			return playParsonsLine(0xe5f, 0);
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, kParsonsPoleTopicLine)) {
+			lineError = playParsonsLine(0xdee, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC093FstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playParsonsLine(0xdf9, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playSwellLine(0xe00, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playParsonsLine(0xe04, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignParsonsTopicBuffer(topicBuffer, topicBufferLineIndex, kParsonsPoleFollowupTopicLine);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, kParsonsAlarmSystemTopicLine)) {
+			lineError = playParsonsLine(0xe0f, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignParsonsTopicBuffer(topicBuffer, topicBufferLineIndex,
+				kParsonsAlarmSystemFollowupTopicLine);
+			continue;
+		}
+
+		if (runtime.matchesAnyResponseLine(selectedTopic, kParsonsAliensTopicResponseLines,
+					ARRAYSIZE(kParsonsAliensTopicResponseLines))) {
+			lineError = playParsonsLine(0xe1c, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playPcLine(0xe24, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playParsonsLine(0xe28, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC094FstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playParsonsLine(0xe33, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playSwellLine(0xe39, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playParsonsLine(0xe3e, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playParsonsLine(0xe46, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playParsonsLine(0xe4b, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignParsonsTopicBuffer(topicBuffer, topicBufferLineIndex, kParsonsAliensFollowupTopicLine);
+			continue;
+		}
+
+		lineError = playParsonsLine(0xe6b, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/parsons_dialogue.h b/engines/harvester/npc/parsons_dialogue.h
index 0598fbeb7b2..dba26849b7d 100644
--- a/engines/harvester/npc/parsons_dialogue.h
+++ b/engines/harvester/npc/parsons_dialogue.h
@@ -29,13 +29,25 @@ namespace Harvester {
 class ParsonsDialogueHandler : public NpcDialogueHandler {
 public:
 	struct ParsonsRoomDialogueState {
-		bool talkStatePending = true;
+		bool introPending = true;
+		bool stephMidgameShown = false;
+		bool scratchedTuckerShown = false;
+		bool barberPoleStolenShown = false;
+		bool dinerBurnedShown = false;
+		bool burnedTvStationShown = false;
+		bool karinKidnapedShown = false;
 	};
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = ParsonsRoomDialogueState(); }
 	void syncState(Common::Serializer &s) override {
-		syncDialogueBool(s, _state.talkStatePending);
+		syncDialogueBool(s, _state.introPending);
+		syncDialogueBool(s, _state.stephMidgameShown, 9);
+		syncDialogueBool(s, _state.scratchedTuckerShown, 9);
+		syncDialogueBool(s, _state.barberPoleStolenShown, 9);
+		syncDialogueBool(s, _state.dinerBurnedShown, 9);
+		syncDialogueBool(s, _state.burnedTvStationShown, 9);
+		syncDialogueBool(s, _state.karinKidnapedShown, 9);
 	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index b8418ce0d47..d59feb22ea0 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -39,7 +39,7 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 }
 
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
-static const uint32 kHarvesterSaveVersion = 8;
+static const uint32 kHarvesterSaveVersion = 9;
 
 static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
 	debugC(1, kDebugGeneral,


Commit: 4fc2c04d1664a92cefee5a4245ef1d3720105b8b
    https://github.com/scummvm/scummvm/commit/4fc2c04d1664a92cefee5a4245ef1d3720105b8b
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:00+02:00

Commit Message:
HARVESTER: Fix Nude Man task gate

Changed paths:
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/npc/dialogue_handler.h
    engines/harvester/npc/nude_man_dialogue.cpp
    engines/harvester/npc/sergeant_dialogue.cpp
    engines/harvester/npc/sergeant_dialogue.h


diff --git a/TRACKER.md b/TRACKER.md
index 1b4a474dc96..706d2cda18f 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,8 @@
 
 ## Last Confirmed Action
 
+- On March 23, 2026, re-audited the live `HARVEST.LE` Nude Man path in Ghidra instead of treating the existing ScummVM port as settled. `handle_talk_to_nude_man` (`0x2d9b0..0x2db3e`) still matched the repaired native dialogue body closely, but the Ghidra helper call at `0x2da7d` showed the second response menu is gated by `get_set_sergeant_completed_first_task_state @ 0x384e0`, not by the broader Sergeant shared bit `d2f08` that the engine was using. Patched [`engines/harvester/npc/nude_man_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/nude_man_dialogue.cpp) to read the dedicated `sergeantCompletedFirstTaskState`, updated [`engines/harvester/npc/sergeant_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.cpp) to set that state on the native `SCRATCHED_TUCKER` completion branch, and added a load-time shared-state migration hook in [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), [`engines/harvester/npc/dialogue_handler.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dialogue_handler.h), and [`engines/harvester/npc/sergeant_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.h) so older ScummVM saves that only persisted Sergeant's legacy local bool still unlock Nude Man correctly after load. No new `FUN_*` helpers were touched in this thread, so live Ghidra progress remains unchanged at `913` functions, `796` named/documented, and `117` remaining `FUN_*`.
+- On March 23, 2026, reran the full durable `repair NUDE_MAN -> wait -> save_program -> verify NUDE_MAN` cycle against the live `HARVEST.LE` session. `handle_talk_to_nude_man @ 0x2d9b0` started as a truncated `0x2d9b0..0x2db39` body (`96` address-count) with `3` reachable dialogue calls and `3` stale `CALL_RETURN` gaps. A single repair pass rebuilt it cleanly to `0x2d9b0..0x2db3e` (`399` address-count) with `14` reachable dialogue calls, `gapCount=0`, and `6` repair actions. After the normal settle and explicit save, a fresh `verify NUDE_MAN` still reported `status=clean` with both dialogue entry points returning, so Nude Man is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Parsons dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_parsons` (`0x397c0..0x39f1b`) and `reset_talk_to_parsons_state` (`0x39f40..0x39f69`). The recovered native flow was much wider than the old evidence stub: no-item dialogue now runs the full `0xdcc/0xdd2/0xdd6/0xde3` intro with Swell, the one-shot event barks for `STEPH_MIDGAME_PLAYED`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `DINER_BURNED`, `BURNED_TV_STATION`, and `KARIN_KIDNAPED`, and the looping `dialog.rsp` keyword branches for `Pole`, `Alarm System`, and the `Aliens` / `Pastorelli` group with the native `C093.FST` and `C094.FST` inserts. The item logic also had three native continuations the stub was missing: `CASKET_PHOTO` / `CASKET_PHOTOCOPY` optionally extends through `PC 0xe84 -> PARSONS 0xe88` when shared state `d2ebc` is already set, note/checkbook evidence is the three-line `0xe95 -> 0xe99 -> 0xe9d` exchange rather than a single bark, and all native portrait variants now match the live assembly. Patched [`engines/harvester/npc/parsons_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/parsons_dialogue.cpp), extended Parsons's save-backed room state in [`engines/harvester/npc/parsons_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/parsons_dialogue.h), bumped the Harvester save blob version to `9` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) so the six new Parsons-local one-shot bits persist cleanly, and renamed the two directly referenced `FUN_*` wrappers in the live Ghidra database to `play_c093_fst_sequence` (`0x38730`) and `play_c094_fst_sequence` (`0x38740`) because each is a single-call wrapper around the exact `GRAPHIC/FST/C093.FST` / `GRAPHIC/FST/C094.FST` assets. Progress now aligns with the saved live database totals (`913` functions, `796` documented, `117` remaining `FUN_*`).
 - On March 23, 2026, reran the full durable `repair PARSONS -> wait -> save_program -> verify PARSONS` cycle against the live `HARVEST.LE` session. `handle_talk_to_parsons @ 0x397c0` started as a truncated `0x397c0..0x39a7a` body (`468` address-count) with `7` reachable dialogue calls and `7` stale `CALL_RETURN` gaps. The repair needed two in-memory passes: pass 1 expanded the handler to `0x397c0..0x39f1b` with `32` dialogue calls but left `7` newly exposed gaps, and pass 2 finished the rebuild cleanly at `0x397c0..0x39f1b` (`1884` address-count) with `43` reachable dialogue calls, `gapCount=0`, and `28` total repair actions. After the normal settle and explicit save, a fresh `verify PARSONS` still reported `status=clean` with both dialogue entry points returning, so Parsons is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Mother dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mother` (`0x2b5b0..0x2b66f`) plus `reset_talk_to_mother_state` (`0x2b690..0x2b69a`). The recovered native flow was not the old one-line bark: it gates on a single local talk-state bit, plays `0x26d6[3]`, runs `C072.FST`, plays `0x26e9[0]`, and opens response menu `0x173`; response `1` exits through `0x26f5[3] -> C073.FST`, response `2` exits through `C073.FST -> 0x2709[3] -> C072.FST -> 0x2714[0] -> 0x2719[3]`, and any other menu result falls through silently after the first menu. Patched [`engines/harvester/npc/mother_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mother_dialogue.cpp), rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and renamed the two directly referenced `FUN_*` wrappers in the live Ghidra database to `play_dialogue_c072_fst_sequence` (`0x38ae0`) and `play_dialogue_c073_fst_sequence` (`0x38910`) because each is a single-call wrapper around the exact `GRAPHIC/FST/C072.FST` / `GRAPHIC/FST/C073.FST` assets. After saving `HARVEST.LE`, progress now aligns with the live database totals (`913` functions, `794` documented, `119` remaining `FUN_*`).
@@ -81,6 +83,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Sergeant and Nude Man validation pass against both a fresh playthrough state and an older ScummVM save created before this fix. Confirm Nude Man now stops after `0x628[1]` until the Sergeant `SCRATCHED_TUCKER` branch has actually set the native first-task completion state, then verify the second menu `0x25e` unlocks correctly after that branch and still unlocks after loading a pre-fix save where only Sergeant's legacy local completion bit was persisted.
 - Immediate: run an in-engine Parsons validation pass that explicitly hits the restored native-only flow: first no-item talk to confirm the full `0xdcc[1] -> 0xdd2[0] -> 0xdd6[0] -> 0xde3[1]` intro plus the initial `Pole/Alarm System/Pastorelli` topic buffer; `STEPH_MIDGAME_PLAYED`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `DINER_BURNED`, `BURNED_TV_STATION`, and `KARIN_KIDNAPED` on separate revisits to verify each Parsons-local one-shot gate now persists across save/load and that the Swell exchanges reset the active portrait back to Parsons; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared state `d2ebc` to confirm the optional `PC 0xe84 -> PARSONS 0xe88` continuation; note/checkbook and TV-deed evidence to verify the restored multi-line evidence replies and portrait variants; and the keyword loop through `Pole`, `Alarm System`, `Aliens`, `Pastorelli`, `barber`, and `Mr. Pastorelli` plus both visible `BYE` exits to confirm the `C093.FST` / `C094.FST` inserts, topic-buffer rewrites, and common `0xe5f` exit line.
 - Immediate: run an in-engine Mother validation pass that explicitly hits the recovered native-only flow: first talk with response `1` to confirm `0x26d6[3] -> C072.FST -> 0x26e9[0] -> menu 0x173 -> 0x26f5[3] -> C073.FST`; restart or reload and take response `2` to confirm `C073.FST -> 0x2709[3] -> C072.FST -> 0x2714[0] -> 0x2719[3]`; then cancel the menu once to verify the native silent fallthrough and confirm a second talk in the same room returns immediately because the local talk-state bit is cleared after the first entry.
 - Immediate: run an in-engine Boyle validation pass that explicitly hits the corrected native-only paths: intro response menu `0x5 -> 0x7 -> 2` to confirm the new Boyle-local evidence-reply bit persists and later changes the `BOYLES_BUTTON` and note/checkbook continuations; `BOYLES_BUTTON` through both `0x2` responses to verify `PC 0x179`, the optional `PC 0x186 -> BOYLE 0x18b` tail, the `d2e98` / `d2eb0` writes, and simple inventory hiding instead of object reset; note/checkbook evidence through response menus `0x3` and `0x4` with and without `HAVE_LODGE_APP` to confirm the restored `PC 0x141` / `0x14a`, `PC 0x1a9`, optional `PC 0x186 -> BOYLE 0x1b8`, and the no-item follow-up staying gated off shared note/checkbook state rather than the local Boyle branch; `GASCAN` after Boyle has accepted the application deal to confirm only `GASCAN` hides and `LODGE_APPLICATION` is awarded; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared `d2ebc` to verify the optional `PC 0x253`; and keyword topics `0xe`, `0x17`, `0x18..0x1b`, typed `Arsonist`, and both visible `BYE` entries to confirm the restored PC/Boyle speaker split and native exit flow.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 9f7366db992..27f0fbb66f6 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -524,6 +524,10 @@ void DialogueSystem::syncRuntimeSaveState(Common::Serializer &s) {
 	syncDialogueSharedState(s, _sharedDialogueState);
 	for (uint i = 0; i < _npcHandlers.size(); ++i)
 		_npcHandlers[i]->syncState(s);
+	if (s.isLoading()) {
+		for (uint i = 0; i < _npcHandlers.size(); ++i)
+			_npcHandlers[i]->migrateSharedState(_sharedDialogueState);
+	}
 }
 
 Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
diff --git a/engines/harvester/npc/dialogue_handler.h b/engines/harvester/npc/dialogue_handler.h
index 45e3f9f060f..d3c45f829ad 100644
--- a/engines/harvester/npc/dialogue_handler.h
+++ b/engines/harvester/npc/dialogue_handler.h
@@ -103,6 +103,7 @@ public:
 	virtual bool matchesNpc(const Common::String &npcName) const = 0;
 	virtual void resetState() {}
 	virtual void syncState(Common::Serializer &) {}
+	virtual void migrateSharedState(DialogueSharedState &) {}
 	virtual Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) = 0;
 };
diff --git a/engines/harvester/npc/nude_man_dialogue.cpp b/engines/harvester/npc/nude_man_dialogue.cpp
index afa6305a548..5a95cd64ff6 100644
--- a/engines/harvester/npc/nude_man_dialogue.cpp
+++ b/engines/harvester/npc/nude_man_dialogue.cpp
@@ -73,8 +73,7 @@ Common::Error NudeManDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
-			// Native gates the second prompt on get_set_sergeant_completed_first_task_state.
-			if (!sharedState.dialogueStateD2f08)
+			if (sharedState.sergeantCompletedFirstTaskState == 0)
 				return Common::kNoError;
 
 			lineError = playNudeManLine(0x62c);
diff --git a/engines/harvester/npc/sergeant_dialogue.cpp b/engines/harvester/npc/sergeant_dialogue.cpp
index 4e2fb2053f9..890de03ca6d 100644
--- a/engines/harvester/npc/sergeant_dialogue.cpp
+++ b/engines/harvester/npc/sergeant_dialogue.cpp
@@ -335,6 +335,7 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	auto handleCompletedFirstTaskBranch = [&]() -> Common::Error {
 		sharedState.dialogueStateD2f08 = true;
 		sharedState.dialogueStateD2f00 = true;
+		sharedState.sergeantCompletedFirstTaskState = 1;
 		state.talkStateBlock = true;
 		state.dialogueStateD2d50 = true;
 		state.postApplicationBriefingCompleted = true;
diff --git a/engines/harvester/npc/sergeant_dialogue.h b/engines/harvester/npc/sergeant_dialogue.h
index 0b04f4ddcf7..4d82fcfc4be 100644
--- a/engines/harvester/npc/sergeant_dialogue.h
+++ b/engines/harvester/npc/sergeant_dialogue.h
@@ -57,6 +57,10 @@ public:
 		syncDialogueBool(s, _state.completedFirstTaskState);
 		syncDialogueBool(s, _state.day5ExitLinePlayed);
 	}
+	void migrateSharedState(DialogueSharedState &sharedState) override {
+		if (sharedState.sergeantCompletedFirstTaskState == 0 && _state.completedFirstTaskState)
+			sharedState.sergeantCompletedFirstTaskState = 1;
+	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
 


Commit: bc90cfe09c41c7e9128abdd93ef280919dccbef8
    https://github.com/scummvm/scummvm/commit/bc90cfe09c41c7e9128abdd93ef280919dccbef8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:00+02:00

Commit Message:
HARVESTER: Fix Phelps dialogue variants

Changed paths:
    TRACKER.md
    engines/harvester/npc/phelps_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 706d2cda18f..eec8fd865ea 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,8 @@
 
 ## Last Confirmed Action
 
+- On March 23, 2026, re-audited the ScummVM Phelps dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_phelps` (`0x29920..0x2a0ef`) and `reset_talk_to_phelps_state` (`0x2a110..0x2a139`). The branch structure in [`engines/harvester/npc/phelps_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/phelps_dialogue.cpp) was already close, but the native body showed two concrete source drifts: most of Phelps's talk branches were still using the wrong portrait/head variants, including the intro, default revisit, Whaley photo, casket photo, quarter, blackmail evidence, Jimmy-death follow-up, and the one-shot event barks for `STEPH_MIDGAME_PLAYED`, day 5, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `PC_ESCAPED_JAIL`, `GOT_REMAINS_FOR_LODGE`, Butcher death, Moynahan death, and both Karin outcomes; and the engine was setting shared dialogue state `d2f04` before the entire intro instead of after native line `0x1b56` and before the final `PC 0x1b5a` line. Patched [`engines/harvester/npc/phelps_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/phelps_dialogue.cpp) to match the recovered variants and intro ordering, rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and checked the directly related Ghidra callees; no new `FUN_*` rename in this thread met the project evidence bar, so live progress remains `913` total functions, `796` named/documented, and `117` remaining `FUN_*`.
+- On March 23, 2026, reran the full durable `repair PHELPS -> wait -> save_program -> verify PHELPS` cycle against the live `HARVEST.LE` session. `handle_talk_to_phelps @ 0x29920` started as a truncated `0x29920..0x29cac` body (`501` address-count) with `8` reachable dialogue calls and `8` stale `CALL_RETURN` gaps. The repair needed two in-memory passes: pass 1 expanded the handler to `0x29920..0x2a0ef` with `43` dialogue calls but left `2` newly exposed gaps, and pass 2 finished the rebuild cleanly at `0x29920..0x2a0ef` (`1849` address-count) with `43` reachable dialogue calls, `gapCount=0`, and `20` total repair actions. After the normal settle and explicit save, a fresh `verify PHELPS` still reported `status=clean` with both dialogue entry points returning, so Phelps is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, re-audited the live `HARVEST.LE` Nude Man path in Ghidra instead of treating the existing ScummVM port as settled. `handle_talk_to_nude_man` (`0x2d9b0..0x2db3e`) still matched the repaired native dialogue body closely, but the Ghidra helper call at `0x2da7d` showed the second response menu is gated by `get_set_sergeant_completed_first_task_state @ 0x384e0`, not by the broader Sergeant shared bit `d2f08` that the engine was using. Patched [`engines/harvester/npc/nude_man_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/nude_man_dialogue.cpp) to read the dedicated `sergeantCompletedFirstTaskState`, updated [`engines/harvester/npc/sergeant_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.cpp) to set that state on the native `SCRATCHED_TUCKER` completion branch, and added a load-time shared-state migration hook in [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), [`engines/harvester/npc/dialogue_handler.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dialogue_handler.h), and [`engines/harvester/npc/sergeant_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.h) so older ScummVM saves that only persisted Sergeant's legacy local bool still unlock Nude Man correctly after load. No new `FUN_*` helpers were touched in this thread, so live Ghidra progress remains unchanged at `913` functions, `796` named/documented, and `117` remaining `FUN_*`.
 - On March 23, 2026, reran the full durable `repair NUDE_MAN -> wait -> save_program -> verify NUDE_MAN` cycle against the live `HARVEST.LE` session. `handle_talk_to_nude_man @ 0x2d9b0` started as a truncated `0x2d9b0..0x2db39` body (`96` address-count) with `3` reachable dialogue calls and `3` stale `CALL_RETURN` gaps. A single repair pass rebuilt it cleanly to `0x2d9b0..0x2db3e` (`399` address-count) with `14` reachable dialogue calls, `gapCount=0`, and `6` repair actions. After the normal settle and explicit save, a fresh `verify NUDE_MAN` still reported `status=clean` with both dialogue entry points returning, so Nude Man is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Parsons dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_parsons` (`0x397c0..0x39f1b`) and `reset_talk_to_parsons_state` (`0x39f40..0x39f69`). The recovered native flow was much wider than the old evidence stub: no-item dialogue now runs the full `0xdcc/0xdd2/0xdd6/0xde3` intro with Swell, the one-shot event barks for `STEPH_MIDGAME_PLAYED`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `DINER_BURNED`, `BURNED_TV_STATION`, and `KARIN_KIDNAPED`, and the looping `dialog.rsp` keyword branches for `Pole`, `Alarm System`, and the `Aliens` / `Pastorelli` group with the native `C093.FST` and `C094.FST` inserts. The item logic also had three native continuations the stub was missing: `CASKET_PHOTO` / `CASKET_PHOTOCOPY` optionally extends through `PC 0xe84 -> PARSONS 0xe88` when shared state `d2ebc` is already set, note/checkbook evidence is the three-line `0xe95 -> 0xe99 -> 0xe9d` exchange rather than a single bark, and all native portrait variants now match the live assembly. Patched [`engines/harvester/npc/parsons_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/parsons_dialogue.cpp), extended Parsons's save-backed room state in [`engines/harvester/npc/parsons_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/parsons_dialogue.h), bumped the Harvester save blob version to `9` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) so the six new Parsons-local one-shot bits persist cleanly, and renamed the two directly referenced `FUN_*` wrappers in the live Ghidra database to `play_c093_fst_sequence` (`0x38730`) and `play_c094_fst_sequence` (`0x38740`) because each is a single-call wrapper around the exact `GRAPHIC/FST/C093.FST` / `GRAPHIC/FST/C094.FST` assets. Progress now aligns with the saved live database totals (`913` functions, `796` documented, `117` remaining `FUN_*`).
@@ -83,6 +85,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Phelps validation pass that explicitly hits the corrected native-only portrait flow: first no-item talk to confirm the `0x1b44[1] -> 0x1b48 -> 0x1b4c -> 0x1b52 -> 0x1b56[1] -> 0x1b5a` intro and the delayed `d2f04` write; a later day-1 to day-4 revisit through both `0x22a` responses to confirm `0x1bf4[1]`, `0x1b7b[1]`, `0x1b85[0]`, and `0x1bbc[1] -> 0x1c44[1]`; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, note/checkbook evidence, and `QUARTER` to verify the recovered portrait variants plus `GET_PRN_MAG` / `DEL_DRT_MAG`; and separate revisits for `STEPH_MIDGAME_PLAYED`, day 5, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `PC_ESCAPED_JAIL`, Jimmy death response menu `0x22c`, `KARIN_FOUND_DEAD`, and `KARIN_KIDNAPED` to confirm the restored one-shot bark variants and shared kidnapped-dialogue write.
 - Immediate: run an in-engine Sergeant and Nude Man validation pass against both a fresh playthrough state and an older ScummVM save created before this fix. Confirm Nude Man now stops after `0x628[1]` until the Sergeant `SCRATCHED_TUCKER` branch has actually set the native first-task completion state, then verify the second menu `0x25e` unlocks correctly after that branch and still unlocks after loading a pre-fix save where only Sergeant's legacy local completion bit was persisted.
 - Immediate: run an in-engine Parsons validation pass that explicitly hits the restored native-only flow: first no-item talk to confirm the full `0xdcc[1] -> 0xdd2[0] -> 0xdd6[0] -> 0xde3[1]` intro plus the initial `Pole/Alarm System/Pastorelli` topic buffer; `STEPH_MIDGAME_PLAYED`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `DINER_BURNED`, `BURNED_TV_STATION`, and `KARIN_KIDNAPED` on separate revisits to verify each Parsons-local one-shot gate now persists across save/load and that the Swell exchanges reset the active portrait back to Parsons; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared state `d2ebc` to confirm the optional `PC 0xe84 -> PARSONS 0xe88` continuation; note/checkbook and TV-deed evidence to verify the restored multi-line evidence replies and portrait variants; and the keyword loop through `Pole`, `Alarm System`, `Aliens`, `Pastorelli`, `barber`, and `Mr. Pastorelli` plus both visible `BYE` exits to confirm the `C093.FST` / `C094.FST` inserts, topic-buffer rewrites, and common `0xe5f` exit line.
 - Immediate: run an in-engine Mother validation pass that explicitly hits the recovered native-only flow: first talk with response `1` to confirm `0x26d6[3] -> C072.FST -> 0x26e9[0] -> menu 0x173 -> 0x26f5[3] -> C073.FST`; restart or reload and take response `2` to confirm `C073.FST -> 0x2709[3] -> C072.FST -> 0x2714[0] -> 0x2719[3]`; then cancel the menu once to verify the native silent fallthrough and confirm a second talk in the same room returns immediately because the local talk-state bit is cleared after the first entry.
diff --git a/engines/harvester/npc/phelps_dialogue.cpp b/engines/harvester/npc/phelps_dialogue.cpp
index 8e12328c83f..611e7f9ea6a 100644
--- a/engines/harvester/npc/phelps_dialogue.cpp
+++ b/engines/harvester/npc/phelps_dialogue.cpp
@@ -36,42 +36,41 @@ static const char *const kUseRewardAtStoreActionTag = "USE_REWARD_AT_STORE";
 static const char *const kSetGenCuStuffActionTag = "SET_GEN_CU_STUFF";
 
 static const DialogueLineEntry kPhelpsWhaleyLines[] = {
-	{ 0x1c0c, "PHELPS", 0 },
+	{ 0x1c0c, "PHELPS", 4 },
 	{ 0x1c13, "PC", 0 },
-	{ 0x1c17, "PHELPS", 0 }
+	{ 0x1c17, "PHELPS", 4 }
 };
 
 static const DialogueLineEntry kPhelpsCasketLines[] = {
-	{ 0x1c1e, "PHELPS", 0 },
+	{ 0x1c1e, "PHELPS", 2 },
 	{ 0x1c22, "PC", 0 },
 	{ 0x1c27, "PHELPS", 0 },
 	{ 0x1c2c, "PC", 0 },
-	{ 0x1c30, "PHELPS", 0 }
+	{ 0x1c30, "PHELPS", 2 }
 };
 
 static const DialogueLineEntry kPhelpsQuarterLines[] = {
-	{ 0x1ba4, "PHELPS", 0 },
+	{ 0x1ba4, "PHELPS", 4 },
 	{ 0x1bac, "PC", 0 },
-	{ 0x1bb0, "PHELPS", 0 }
+	{ 0x1bb0, "PHELPS", 1 }
 };
 
-static const DialogueLineEntry kPhelpsIntroLines[] = {
-	{ 0x1b44, "PHELPS", 0 },
+static const DialogueLineEntry kPhelpsIntroPreludeLines[] = {
+	{ 0x1b44, "PHELPS", 1 },
 	{ 0x1b48, "PC", 0 },
 	{ 0x1b4c, "PHELPS", 0 },
 	{ 0x1b52, "PC", 0 },
-	{ 0x1b56, "PHELPS", 0 },
-	{ 0x1b5a, "PC", 0 }
+	{ 0x1b56, "PHELPS", 1 }
 };
 
 static const DialogueLineEntry kPhelpsKarinFoundDeadLines[] = {
-	{ 0x1d2a, "PHELPS", 0 },
+	{ 0x1d2a, "PHELPS", 3 },
 	{ 0x1d33, "PHELPS", 0 },
 	{ 0x1d3a, "PHELPS", 0 }
 };
 
 static const DialogueLineEntry kPhelpsKarinKidnapedLines[] = {
-	{ 0x1d40, "PHELPS", 0 },
+	{ 0x1d40, "PHELPS", 4 },
 	{ 0x1d46, "PC", 0 },
 	{ 0x1d4a, "PHELPS", 0 },
 	{ 0x1d4f, "PC", 0 },
@@ -106,16 +105,21 @@ Common::Error PhelpsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (usedItemName.empty()) {
 		if (state.talkStatePending) {
 			state.talkStatePending = false;
+			Common::Error lineError = playSequence(kPhelpsIntroPreludeLines,
+				ARRAYSIZE(kPhelpsIntroPreludeLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
 			sharedState.dialogueStateD2f04 = true;
-			return playSequence(kPhelpsIntroLines, ARRAYSIZE(kPhelpsIntroLines));
+			return playPhelpsLine(0x1b5a, 0, "PC");
 		}
 
-		Common::Error lineError = playPhelpsLine(0x1bf4);
+		Common::Error lineError = playPhelpsLine(0x1bf4, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
 		if (runtime.startupScript().getCurrentStoryDayIndex() < 5) {
-			lineError = playPhelpsLine(0x1b7b);
+			lineError = playPhelpsLine(0x1b7b, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
@@ -126,9 +130,9 @@ Common::Error PhelpsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (responseIndex == 1) {
 				lineError = playPhelpsLine(0x1b85);
 			} else {
-				lineError = playPhelpsLine(0x1bbc);
+				lineError = playPhelpsLine(0x1bbc, 1);
 				if (lineError.getCode() == Common::kNoError)
-					lineError = playPhelpsLine(0x1c44);
+					lineError = playPhelpsLine(0x1c44, 1);
 			}
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
@@ -136,29 +140,29 @@ Common::Error PhelpsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 		if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") && !state.stephMidgameShown) {
 			state.stephMidgameShown = true;
-			lineError = playPhelpsLine(0x1c4a);
+			lineError = playPhelpsLine(0x1c4a, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 		if (runtime.startupScript().getCurrentStoryDayIndex() == 5 && !state.dayFiveShown) {
 			state.dayFiveShown = true;
-			lineError = playPhelpsLine(0x1c56);
+			lineError = playPhelpsLine(0x1c56, 2);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 		if (runtime.startupScript().getFlagValue("SCRATCHED_TUCKER") && !state.scratchedTuckerShown) {
 			state.scratchedTuckerShown = true;
-			lineError = playPhelpsLine(0x1c65);
+			lineError = playPhelpsLine(0x1c65, 4);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 		if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") &&
 				!state.barberPoleStolenShown) {
 			state.barberPoleStolenShown = true;
-			lineError = playPhelpsLine(0x1c6e, 1);
+			lineError = playPhelpsLine(0x1c6e, 2);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			lineError = playPhelpsLine(0x1c73);
+			lineError = playPhelpsLine(0x1c73, 4);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
@@ -183,7 +187,7 @@ Common::Error PhelpsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 		if (runtime.startupScript().getFlagValue("PC_ESCAPED_JAIL") && !state.escapedJailShown) {
 			state.escapedJailShown = true;
-			lineError = playPhelpsLine(0x1c98);
+			lineError = playPhelpsLine(0x1c98, 4);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
@@ -191,7 +195,7 @@ Common::Error PhelpsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				runtime.startupScript().isNamedNpcDeathTypeClear("MOYNAHAN") &&
 				!state.gotRemainsForLodgeShown) {
 			state.gotRemainsForLodgeShown = true;
-			lineError = playPhelpsLine(0x1ca0);
+			lineError = playPhelpsLine(0x1ca0, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
@@ -205,14 +209,14 @@ Common::Error PhelpsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (!runtime.startupScript().isNamedNpcDeathTypeClear("BUTCHER") &&
 				!state.butcherDeadShown) {
 			state.butcherDeadShown = true;
-			lineError = playPhelpsLine(0x1cfe);
+			lineError = playPhelpsLine(0x1cfe, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
 		if (!runtime.startupScript().isNamedNpcDeathTypeClear("MOYNAHAN") &&
 				!state.moynahanDeadShown) {
 			state.moynahanDeadShown = true;
-			lineError = playPhelpsLine(0x1d06, 1);
+			lineError = playPhelpsLine(0x1d06, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
@@ -228,7 +232,7 @@ Common::Error PhelpsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (responseError.getCode() != Common::kNoError)
 				return responseError;
 			if (responseIndex == 1) {
-				lineError = playPhelpsLine(0x1d1c);
+				lineError = playPhelpsLine(0x1d1c, 1);
 			} else if (responseIndex == 2) {
 				lineError = playPhelpsLine(0x1d21);
 			} else {
@@ -272,7 +276,7 @@ Common::Error PhelpsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
-		return playPhelpsLine(0x1c3d);
+		return playPhelpsLine(0x1c3d, 1);
 	}
 	if (usedItemName.equalsIgnoreCase("QUARTER")) {
 		executeDialogueActionTag(kGetPrnMagActionTag);
@@ -288,7 +292,7 @@ Common::Error PhelpsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return Common::kNoError;
 	}
 
-	return playPhelpsLine(0x1c06);
+	return playPhelpsLine(0x1c06, 1);
 }
 
 } // End of namespace Harvester


Commit: ac77fc9866eded877656b33841c53db1d66aa5a1
    https://github.com/scummvm/scummvm/commit/ac77fc9866eded877656b33841c53db1d66aa5a1
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:00+02:00

Commit Message:
HARVESTER: Realign Sparky dialogue with native flow

Changed paths:
    TRACKER.md
    engines/harvester/npc/sparky_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index eec8fd865ea..712821193c8 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,15 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `913`
-- Named/documented: `796`
+- Total functions: `916`
+- Named/documented: `799`
 - Remaining `FUN_*`: `117`
 - Remaining undocumented total: `117`
 
 ## Last Confirmed Action
 
+- On March 23, 2026, re-audited the ScummVM Sparky dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_sparky` (`0x39240..0x39652`) and `reset_talk_to_sparky_state` (`0x39690..0x396c5`). The native body showed two concrete source drifts in [`engines/harvester/npc/sparky_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sparky_dialogue.cpp): most of Sparky's talk branches were still using the wrong portrait/head variants, including the light-switch bark, item rejection, intro exchange, return-visit lines, and the `0x34c`, `0x37a`, `0x38f`, `0x39e`, and `0x3c1` keyword replies; and the ScummVM port was incorrectly seeding a persistent keyword buffer from `dialog.rsp[0x2c3]`, while the native handler starts each conversation with an empty keyword list and only rewrites that list to `dialog.rsp[0x2c9]` after topic `0x2c8`. Patched [`engines/harvester/npc/sparky_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sparky_dialogue.cpp) to match the recovered variants and one-call topic rewrite, rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and checked the directly encountered `FUN_*` symbols surfaced during the keyword-menu trace. Those `FUN_*` hits (`FUN_0002e3b4`, `FUN_0002e41b`, and `FUN_0002f7fd`) are overlapping mid-body dialogue entries inside larger Herrill and Mrs. Potts handlers rather than clean standalone helpers, so no new rename met the project evidence bar without first repairing the underlying function boundaries. Live progress now reflects the current saved database totals of `916` functions, `799` documented, and `117` remaining `FUN_*`.
+- On March 23, 2026, reran the full durable `repair SPARKY -> wait -> save_program -> verify SPARKY` cycle against the live `HARVEST.LE` session. `handle_talk_to_sparky @ 0x39240` started as a truncated `0x39240..0x39649` body (`559` address-count) with `10` reachable dialogue calls and `10` stale `CALL_RETURN` gaps. The first repair pass rebuilt Sparky cleanly in-session to `0x39240..0x39652` (`860` address-count, `18` dialogue calls, `20` repair actions), but the first saved verify snapped back to the old body and also flipped both dialogue entry points back to non-returning. An immediate second full cycle fixed it: the retry repair rebuilt `handle_talk_to_sparky` cleanly again (`21` repair actions on the retry pass), and after the second settle and explicit save a fresh `verify SPARKY` held at `0x39240..0x39652` with `gapCount=0`, `status=clean`, and both dialogue entry points returning. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, re-audited the ScummVM Phelps dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_phelps` (`0x29920..0x2a0ef`) and `reset_talk_to_phelps_state` (`0x2a110..0x2a139`). The branch structure in [`engines/harvester/npc/phelps_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/phelps_dialogue.cpp) was already close, but the native body showed two concrete source drifts: most of Phelps's talk branches were still using the wrong portrait/head variants, including the intro, default revisit, Whaley photo, casket photo, quarter, blackmail evidence, Jimmy-death follow-up, and the one-shot event barks for `STEPH_MIDGAME_PLAYED`, day 5, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `PC_ESCAPED_JAIL`, `GOT_REMAINS_FOR_LODGE`, Butcher death, Moynahan death, and both Karin outcomes; and the engine was setting shared dialogue state `d2f04` before the entire intro instead of after native line `0x1b56` and before the final `PC 0x1b5a` line. Patched [`engines/harvester/npc/phelps_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/phelps_dialogue.cpp) to match the recovered variants and intro ordering, rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and checked the directly related Ghidra callees; no new `FUN_*` rename in this thread met the project evidence bar, so live progress remains `913` total functions, `796` named/documented, and `117` remaining `FUN_*`.
 - On March 23, 2026, reran the full durable `repair PHELPS -> wait -> save_program -> verify PHELPS` cycle against the live `HARVEST.LE` session. `handle_talk_to_phelps @ 0x29920` started as a truncated `0x29920..0x29cac` body (`501` address-count) with `8` reachable dialogue calls and `8` stale `CALL_RETURN` gaps. The repair needed two in-memory passes: pass 1 expanded the handler to `0x29920..0x2a0ef` with `43` dialogue calls but left `2` newly exposed gaps, and pass 2 finished the rebuild cleanly at `0x29920..0x2a0ef` (`1849` address-count) with `43` reachable dialogue calls, `gapCount=0`, and `20` total repair actions. After the normal settle and explicit save, a fresh `verify PHELPS` still reported `status=clean` with both dialogue entry points returning, so Phelps is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, re-audited the live `HARVEST.LE` Nude Man path in Ghidra instead of treating the existing ScummVM port as settled. `handle_talk_to_nude_man` (`0x2d9b0..0x2db3e`) still matched the repaired native dialogue body closely, but the Ghidra helper call at `0x2da7d` showed the second response menu is gated by `get_set_sergeant_completed_first_task_state @ 0x384e0`, not by the broader Sergeant shared bit `d2f08` that the engine was using. Patched [`engines/harvester/npc/nude_man_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/nude_man_dialogue.cpp) to read the dedicated `sergeantCompletedFirstTaskState`, updated [`engines/harvester/npc/sergeant_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.cpp) to set that state on the native `SCRATCHED_TUCKER` completion branch, and added a load-time shared-state migration hook in [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), [`engines/harvester/npc/dialogue_handler.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dialogue_handler.h), and [`engines/harvester/npc/sergeant_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.h) so older ScummVM saves that only persisted Sergeant's legacy local bool still unlock Nude Man correctly after load. No new `FUN_*` helpers were touched in this thread, so live Ghidra progress remains unchanged at `913` functions, `796` named/documented, and `117` remaining `FUN_*`.
@@ -85,6 +87,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Sparky validation pass that explicitly hits the corrected native-only paths: `PC_TRIES_TO_TURN_ON_LIGHT`; any non-empty presented item for the `0x3c7[2]` rejection bark; the intro through all three `0x2be` responses to confirm `0x2f7[1]`, `0x2fe[2]`, `0x30d[1]`, the `SPARKY_SPOTS_MEAT` action tag, and the `C135/C098/C096` FST inserts; same-day and later-day revisits to confirm `0x3bb[1]` versus `0x3ac[1] -> 0x3b0 -> 0x3b4`; and the keyword loop starting from the native empty menu so typed topics `0x2c4/0x2c5`, `0x2c7`, `0x2c8`, `0x2ca/0x2cb`, and an unknown topic verify the restored `0x34c[1]`, `0x35f[0]`, `0x36c[0]`, `0x36d[0]`, `0x37a[3]`, `0x38f[4]`, `0x39e[1]`, and `0x3c1[1]` portrait variants plus the non-persistent `0x2c9` topic-list rewrite.
 - Immediate: run an in-engine Phelps validation pass that explicitly hits the corrected native-only portrait flow: first no-item talk to confirm the `0x1b44[1] -> 0x1b48 -> 0x1b4c -> 0x1b52 -> 0x1b56[1] -> 0x1b5a` intro and the delayed `d2f04` write; a later day-1 to day-4 revisit through both `0x22a` responses to confirm `0x1bf4[1]`, `0x1b7b[1]`, `0x1b85[0]`, and `0x1bbc[1] -> 0x1c44[1]`; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, note/checkbook evidence, and `QUARTER` to verify the recovered portrait variants plus `GET_PRN_MAG` / `DEL_DRT_MAG`; and separate revisits for `STEPH_MIDGAME_PLAYED`, day 5, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `PC_ESCAPED_JAIL`, Jimmy death response menu `0x22c`, `KARIN_FOUND_DEAD`, and `KARIN_KIDNAPED` to confirm the restored one-shot bark variants and shared kidnapped-dialogue write.
 - Immediate: run an in-engine Sergeant and Nude Man validation pass against both a fresh playthrough state and an older ScummVM save created before this fix. Confirm Nude Man now stops after `0x628[1]` until the Sergeant `SCRATCHED_TUCKER` branch has actually set the native first-task completion state, then verify the second menu `0x25e` unlocks correctly after that branch and still unlocks after loading a pre-fix save where only Sergeant's legacy local completion bit was persisted.
 - Immediate: run an in-engine Parsons validation pass that explicitly hits the restored native-only flow: first no-item talk to confirm the full `0xdcc[1] -> 0xdd2[0] -> 0xdd6[0] -> 0xde3[1]` intro plus the initial `Pole/Alarm System/Pastorelli` topic buffer; `STEPH_MIDGAME_PLAYED`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `DINER_BURNED`, `BURNED_TV_STATION`, and `KARIN_KIDNAPED` on separate revisits to verify each Parsons-local one-shot gate now persists across save/load and that the Swell exchanges reset the active portrait back to Parsons; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared state `d2ebc` to confirm the optional `PC 0xe84 -> PARSONS 0xe88` continuation; note/checkbook and TV-deed evidence to verify the restored multi-line evidence replies and portrait variants; and the keyword loop through `Pole`, `Alarm System`, `Aliens`, `Pastorelli`, `barber`, and `Mr. Pastorelli` plus both visible `BYE` exits to confirm the `C093.FST` / `C094.FST` inserts, topic-buffer rewrites, and common `0xe5f` exit line.
@@ -97,7 +100,7 @@
 - Immediate: run an in-engine Edna validation pass that explicitly hits the restored native-only branches: `KILLED_KARIN1` to confirm the flag now clears; `DNA_S_SUICIDE_NOTE` with and without `KARIN_FOUND_ALIVE` to confirm `C043.FST` / `C043K.FST`, shared states `d2ea4` / `d2ea8`, and `DNAEXT:SIGNOUT`; direct `K_PURSE` use plus the `KARIN_FOUND_DEAD -> 0xa2 -> 0xa3/0xa4` nested purse menu to verify `REWARD_MONEY` and the `RAH`-hidden purse side effect; the day-5 alive and dead follow-ups; and the keyword loop through the recovered `0xa6`, `0xac/0xad`, `0xb0`, `0xb2`, `0xb6/0xb7`, `0xba`, `0xbc/0xbd/0xbe`, `0xc0`, `0xc2`, typed unknown text, and goodbye branches to confirm the restored menu buffer rewrites and both `C091.FST` / `C070.FST` exits.
 - Immediate: run an in-engine Karin validation pass that explicitly hits the restored native-only branches: the first `KARIN_FOUND_ALIVE` + `IN_CEM10` visit to confirm the full `0x10a5`..`0x10c3` early-return sequence; first and repeat no-item visits before Karin is found alive to confirm the recovered `0x1076`..`0x108b` prelude plus the shared `0x10dc` bark; first and repeat uses of `CASKET_PHOTO` or `PHOTO_OF_WHALEY_HERRILL` to verify the `0x10e7` Edna follow-up, portrait reset, shared evidence-state write, one-shot override, and repeat-photo `CALL_POLICE` escalation; and `INV_MAG` to confirm `0x10ee` / `0x10f2[2]` plus `DNALFT_PERVERT`.
 - Immediate: run an in-engine Dwayne validation pass that explicitly hits the restored native-only branches: `CHECK_EVIDENCE_DOOR` and the first/second `ST_BEDRM` visits to confirm the new flag clears; the sheriff-in-diner intro and revisit to verify Edna `0x3490/0x3496`, the optional `0x34a0/0x34a4` tail, and the later keyword loop; Boyle's button, Karin's purse, note/checkbook photocopy -> original note/checkbook, both `TV_DEED` and `TV_DEED_PHOTOCOPY` response trees plus the override revisit on the real deed, corpse photo with and without shared state `d2ec8`, and both-ledger handoff with and without `SHERIFF_IN_DINER`; `KARIN_KIDNAPED` through response menus `0x7b/0x7c`, `KARIN_FOUND_DEAD` without the purse through `0x7f/0x80`, and the `PC_HAS_GOOJF_CARD` arrest path with and without `PC_KILLED_ANYONE` to confirm the confiscation and bust follow-up state now matches native.
-- Immediate: resume the sequential talk-handler sweep at `LODGE_CHEF`, using the proven single-handler workflow `verify <NPC> -> repair <NPC> -> wait ~10s -> save_program -> verify <NPC>` and immediately rerunning the same NPC once when the first saved verify snaps back. After the Pastorelli verifier fix and the durable Parsons rebuild, the remaining not-yet-confirmed handlers in that queue are `LODGE_CHEF`, `RYDER`, `SPARKY`, and `MEMB_DIR`.
+- Immediate: resume the sequential talk-handler sweep at `LODGE_CHEF`, using the proven single-handler workflow `verify <NPC> -> repair <NPC> -> wait ~10s -> save_program -> verify <NPC>` and immediately rerunning the same NPC once when the first saved verify snaps back. After the durable Sparky retry and the earlier Pastorelli/Parsons work, the remaining not-yet-confirmed handlers in that queue are `LODGE_CHEF`, `RYDER`, and `MEMB_DIR`.
 - Immediate: run an in-engine Johnson validation pass that explicitly hits the restored native-only branches: `OPENING_MANHOLE_DAYTIME` twice to confirm `0xbe0` now clears the flag; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared state `d2ebc`, and `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` to verify the restored `0xb7b`, `0xb80`, `0xb90`, `0xb94`, `0xb99`, `0xba4`, and `0xba8` follow-ups; the first `SCRATCHED_TUCKER` visit with `BOLT_OF_CLOTH_TAKEN` already set to confirm `0xc25` / `0xc2b` / `0xc30` plus `0xc5f` / `0xc69` and that the Johnson intro only replays on the later revisit; and a `KARIN_KIDNAPED` follow-up that later reaches a consumer such as Stephanie to confirm the shared kidnapped-dialogue state now propagates correctly.
 - Immediate: run an in-engine Loomis validation pass that explicitly hits the restored native-only branches: first talk with and without `SHERIFF_IN_DINER` to confirm the `0x1112 -> 0x1118 -> 0x1121 -> response 0x53 -> 0x1130[3]` intro; the second and third no-item visits to confirm the one-shot `0x11a8/0x11ac/0x11b0[3]` follow-up, the later `0x11f6` bark, and the `STEPH_MIDGAME_PLAYED` / day-5 `0x1230[3]` and `0x123c` gates; the keyword loop through `LOOMIS`, `Phelps`, `Man's Needs`, sheriff topics, `0x65`, `0x66`, typed unknown text, and goodbye to verify the restored alias matches, portrait variants, `0x11fc` fallback, and `0x11a1[1]` exit; note/checkbook or TV-deed evidence through both `0x50` responses to confirm `0x1214` / `0x1219` / `0x121f[3]` plus `PC_FRY_DADDY_1`; and `INV_MAG` in the diner branch through both `0x51` responses to confirm `0x11c6[1]`, `0x11cc[3]`, drawer visibility, `GAVE_MAG_TO_LOOMIS_TODAY`, and the `C048.FST` tail.
 - Immediate: run an in-engine Mrs. Potts validation pass that explicitly hits the restored native-only paths: `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `LEDGER` with `HAVE_BOTH_LEDGERS`, and the `GOT_REMAINS_FOR_LODGE` response-1 death branch to confirm the corrected portrait variants; the non-dead revisit gate around `0x281b` / `0x2820` / `0x2825`; and `DINER_BURNED` with and without `KARIN_HUNG`, taking both `0x236` responses to verify the new `0x2914` / `0x2918` / `0x2926` continuation and the response-2 jump into the shared death-in-chair sequence.
diff --git a/engines/harvester/npc/sparky_dialogue.cpp b/engines/harvester/npc/sparky_dialogue.cpp
index d762875d161..c0549eb7b68 100644
--- a/engines/harvester/npc/sparky_dialogue.cpp
+++ b/engines/harvester/npc/sparky_dialogue.cpp
@@ -44,15 +44,18 @@ bool SparkyDialogueHandler::matchesNpc(const Common::String &npcName) const {
 Common::Error SparkyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	SparkyRoomDialogueState &state = _state;
-	Common::String &sparkyTopicBuffer = state.currentTopicBuffer;
-	int &sparkyTopicBufferLineIndex = state.currentTopicBufferLineIndex;
+	Common::String sparkyTopicBuffer;
+	int sparkyTopicBufferLineIndex = -1;
 
 	auto assignSparkyTopicBuffer = [&](int responseLineIndex) {
 		runtime.assignTopicBuffer(sparkyTopicBuffer, sparkyTopicBufferLineIndex,
 			responseLineIndex, "Sparky topic buffer");
 	};
-	auto playSparkyLine = [&](int wavId, const char *speakerId = "SPARKY") -> Common::Error {
-		return runtime.playDialogueLine(wavId, speakerId);
+	auto playSparkyLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "SPARKY", headVariant);
+	};
+	auto playPcLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, "PC", headVariant);
 	};
 	auto executeDialogueActionTag = [&](const char *tag) {
 		StartupInteractionResult interaction;
@@ -64,11 +67,11 @@ Common::Error SparkyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 	if (runtime.startupScript().getFlagValue("PC_TRIES_TO_TURN_ON_LIGHT")) {
 		(void)runtime.startupScript().setRuntimeFlagValue("PC_TRIES_TO_TURN_ON_LIGHT", false);
-		return playSparkyLine(0x3a4);
+		return playSparkyLine(0x3a4, 0);
 	}
 
 	if (!usedItemName.empty())
-		return playSparkyLine(0x3c7);
+		return playSparkyLine(0x3c7, 2);
 
 	if (state.introPending) {
 		state.introPending = false;
@@ -76,8 +79,8 @@ Common::Error SparkyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		state.returnVisitDayIndex = runtime.startupScript().getCurrentStoryDayIndex();
 
 		const DialogueLineEntry introLines[] = {
-			{ 0x2f7, "PC", 0 },
-			{ 0x2fe, "SPARKY", 0 },
+			{ 0x2f7, "PC", 1 },
+			{ 0x2fe, "SPARKY", 2 },
 			{ 0x302, "SPARKY", 0 }
 		};
 		Common::Error lineError = runtime.playDialogueEntrySequence(introLines, ARRAYSIZE(introLines));
@@ -90,7 +93,7 @@ Common::Error SparkyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		lineError = runtime.playDialogueFst(kDialogueC098FstPath);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		lineError = runtime.playDialogueLine(0x30d, "PC");
+		lineError = playPcLine(0x30d, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
@@ -101,26 +104,26 @@ Common::Error SparkyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 		if (responseIndex == 1) {
 			sharedState.dialogueStateD2f04 = true;
-			return playSparkyLine(0x319);
+			return playSparkyLine(0x319, 1);
 		}
 		if (responseIndex == 2)
-			return playSparkyLine(0x334);
+			return playSparkyLine(0x334, 0);
 
 		lineError = runtime.playDialogueFst(kDialogueC096FstPath);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		return playSparkyLine(0x33f);
+		return playSparkyLine(0x33f, 0);
 	}
 
 	if (state.returnVisitPending) {
 		Common::Error lineError = Common::kNoError;
 		if (runtime.startupScript().getCurrentStoryDayIndex() == state.returnVisitDayIndex) {
-			lineError = playSparkyLine(0x3bb);
+			lineError = playSparkyLine(0x3bb, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		} else {
 			const DialogueLineEntry returnVisitLines[] = {
-				{ 0x3ac, "SPARKY", 0 },
+				{ 0x3ac, "SPARKY", 1 },
 				{ 0x3b0, "PC", 0 },
 				{ 0x3b4, "SPARKY", 0 }
 			};
@@ -131,9 +134,6 @@ Common::Error SparkyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 	}
 
-	if (sparkyTopicBufferLineIndex < 0)
-		assignSparkyTopicBuffer(0x2c3);
-
 	for (;;) {
 		Common::String selectedTopic;
 		Common::Error menuError = runtime.runKeywordMenu(
@@ -143,11 +143,11 @@ Common::Error SparkyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (selectedTopic.empty())
 			return Common::kNoError;
 		if (runtime.matchesResponseLine(selectedTopic, 0x2c3)) {
-			return playSparkyLine(0x39e);
+			return playSparkyLine(0x39e, 1);
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x2c4) ||
 				runtime.matchesResponseLine(selectedTopic, 0x2c5)) {
-			Common::Error lineError = playSparkyLine(0x34c);
+			Common::Error lineError = playSparkyLine(0x34c, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			lineError = runtime.playDialogueFst(kDialogueC096AFstPath);
@@ -156,16 +156,16 @@ Common::Error SparkyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x2c7)) {
-			Common::Error lineError = playSparkyLine(0x35f);
+			Common::Error lineError = playSparkyLine(0x35f, 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			continue;
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x2c8)) {
-			Common::Error lineError = playSparkyLine(0x36c);
+			Common::Error lineError = playSparkyLine(0x36c, 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			lineError = playSparkyLine(0x36d);
+			lineError = playSparkyLine(0x36d, 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			assignSparkyTopicBuffer(0x2c9);
@@ -173,7 +173,7 @@ Common::Error SparkyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 		if (runtime.matchesResponseLine(selectedTopic, 0x2ca) ||
 				runtime.matchesResponseLine(selectedTopic, 0x2cb)) {
-			Common::Error lineError = playSparkyLine(0x37a);
+			Common::Error lineError = playSparkyLine(0x37a, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
@@ -183,14 +183,14 @@ Common::Error SparkyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				return responseError;
 
 			if (responseIndex == 1) {
-				lineError = playSparkyLine(0x38f);
+				lineError = playSparkyLine(0x38f, 4);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 				lineError = runtime.playDialogueFst(kDialogueNudetatuFstPath);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 			} else if (responseIndex == 2) {
-				lineError = playSparkyLine(0x35f);
+				lineError = playSparkyLine(0x35f, 0);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 			}
@@ -199,7 +199,7 @@ Common::Error SparkyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (runtime.matchesResponseLine(selectedTopic, 0x2ce))
 			continue;
 
-		Common::Error lineError = playSparkyLine(0x3c1);
+		Common::Error lineError = playSparkyLine(0x3c1, 1);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}


Commit: d92ba434be29f0d6d3de846cc833543086d58887
    https://github.com/scummvm/scummvm/commit/d92ba434be29f0d6d3de846cc833543086d58887
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:01+02:00

Commit Message:
HARVESTER: Reimplement Buster dialogue from Ghidra

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/npc/buster_dialogue.cpp
    engines/harvester/npc/buster_dialogue.h
    engines/harvester/npc/dialogue_runtime.h
    engines/harvester/saveload.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index cbf0845bdaf..eee8400e39f 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -626,6 +626,9 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `play_c019b_fst_sequence` at `0x38620` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C019B.FST", 0)` and is currently only called from Boyle's `d2eb0 == 0` evidence-topic branch.
   - `play_c093_fst_sequence` at `0x38730` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C093.FST", 0)` and is currently only called from Parsons's `Pole` topic branch.
   - `play_c094_fst_sequence` at `0x38740` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C094.FST", 0)` and is currently only called from Parsons's `Aliens` / `Pastorelli` topic branch.
+  - `play_c085_fst_sequence` at `0x387d0` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C085.FST", 0)` and is currently only called from Buster's shared `dialog.rsp[0x28/0x29]` topic branch.
+  - `play_c086_blackout_fst_sequence` at `0x387e0` clears the current palette to black and then wraps `play_fst_sequence("GRAPHIC/FST/C086.FST", 0)`; it is currently only called from Buster's fatal response branches immediately before `run_game_over_screen`.
+  - `play_c058_fst_sequence` at `0x38800` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C058.FST", 0)` and is currently only called from Buster's `dialog.rsp[0x38]` topic branch.
 - The adjacent `0x383d0`-`0x384e0` range is now confirmed to be another overlapping run of tiny read/write dialogue-state wrappers over globals in the same contiguous block (`0xd2f00` through `0xd2f28`).
   - `get_set_sergeant_completed_first_task_state` at `0x384e0` is the Sergeant-local bit set on the `SCRATCHED_TUCKER` branch immediately before dialogue `0x420d` ("So, you have completed your first task.").
   - The `SCRATCHED_TUCKER` no-item path is not just that single bark. After `0x420d`, Sergeant continues with `0x421e`, opens zero-based `dialog.rsp` line `0x2b4`, and then either accepts the handoff with `0x422a` or pushes back through `0x422f` / `0x4239`. Both outcomes then fall through a one-shot local `d2d58` gate that dispatches action tag `GO_DAY_3`.
diff --git a/TRACKER.md b/TRACKER.md
index 712821193c8..0567c77cafc 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,15 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `916`
-- Named/documented: `799`
+- Total functions: `919`
+- Named/documented: `802`
 - Remaining `FUN_*`: `117`
 - Remaining undocumented total: `117`
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Buster dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_buster` (`0x2b7c0..0x2c575`) and `reset_talk_to_buster_state` (`0x2c600..0x2c613`). The recovered native flow was much wider than the old stub in [`engines/harvester/npc/buster_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/buster_dialogue.cpp): item handling now matches the native casket-photo follow-up and blackmail response menu, no-item dialogue restores the full `0x163c..0x168e` opener plus the `0x1828..0x185e` / `0x18c5..0x18cd` follow-ups, the looping keyword tree now covers the recovered `dialog.rsp` topic groups `0x28/0x29`, `0x2c/0x2d`, `0x32`, `0x34`, `0x36`, `0x38`, `0x3b`, `0x3e`, `0x40/0x41`, `0x44/0x45/0x46`, `0x48`, and `0x49`, and Buster's fatal branches now run the native `C086.FST` blackout plus a restored game-over screen instead of falling through to short barks. Patched [`engines/harvester/npc/buster_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/buster_dialogue.cpp), extended Buster's save-backed room state in [`engines/harvester/npc/buster_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/buster_dialogue.h), added a reusable dialogue-side game-over helper in [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp) and [`engines/harvester/npc/dialogue_runtime.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dialogue_runtime.h), bumped the Harvester save blob version to `10` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) for the new Buster-local state bit, rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and renamed the directly encountered live Ghidra `FUN_*` helpers to `play_c085_fst_sequence` (`0x387d0`), `play_c086_blackout_fst_sequence` (`0x387e0`), and `play_c058_fst_sequence` (`0x38800`). After saving `HARVEST.LE`, live progress stands at `919` total functions, `802` named/documented, and `117` remaining `FUN_*`.
+- On March 23, 2026, reran the full durable `repair BUSTER -> wait -> save_program -> verify BUSTER` cycle against the live `HARVEST.LE` session. `handle_talk_to_buster @ 0x2b7c0` started as a truncated `0x2b7c0..0x2c56c` body (`449` address-count) with `8` reachable dialogue calls and `8` stale `CALL_RETURN` gaps. The repair needed two in-memory passes: pass 1 expanded the handler to `0x2b7c0..0x2c575` with `95` dialogue calls but left `1` newly exposed gap, and pass 2 finished the rebuild cleanly at `0x2b7c0..0x2c575` (`3510` address-count) with `95` reachable dialogue calls, `gapCount=0`, and `19` total repair actions. After the normal settle and explicit save, a fresh `verify BUSTER` still reported `status=clean` with both dialogue entry points returning, so Buster is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, re-audited the ScummVM Sparky dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_sparky` (`0x39240..0x39652`) and `reset_talk_to_sparky_state` (`0x39690..0x396c5`). The native body showed two concrete source drifts in [`engines/harvester/npc/sparky_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sparky_dialogue.cpp): most of Sparky's talk branches were still using the wrong portrait/head variants, including the light-switch bark, item rejection, intro exchange, return-visit lines, and the `0x34c`, `0x37a`, `0x38f`, `0x39e`, and `0x3c1` keyword replies; and the ScummVM port was incorrectly seeding a persistent keyword buffer from `dialog.rsp[0x2c3]`, while the native handler starts each conversation with an empty keyword list and only rewrites that list to `dialog.rsp[0x2c9]` after topic `0x2c8`. Patched [`engines/harvester/npc/sparky_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sparky_dialogue.cpp) to match the recovered variants and one-call topic rewrite, rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and checked the directly encountered `FUN_*` symbols surfaced during the keyword-menu trace. Those `FUN_*` hits (`FUN_0002e3b4`, `FUN_0002e41b`, and `FUN_0002f7fd`) are overlapping mid-body dialogue entries inside larger Herrill and Mrs. Potts handlers rather than clean standalone helpers, so no new rename met the project evidence bar without first repairing the underlying function boundaries. Live progress now reflects the current saved database totals of `916` functions, `799` documented, and `117` remaining `FUN_*`.
 - On March 23, 2026, reran the full durable `repair SPARKY -> wait -> save_program -> verify SPARKY` cycle against the live `HARVEST.LE` session. `handle_talk_to_sparky @ 0x39240` started as a truncated `0x39240..0x39649` body (`559` address-count) with `10` reachable dialogue calls and `10` stale `CALL_RETURN` gaps. The first repair pass rebuilt Sparky cleanly in-session to `0x39240..0x39652` (`860` address-count, `18` dialogue calls, `20` repair actions), but the first saved verify snapped back to the old body and also flipped both dialogue entry points back to non-returning. An immediate second full cycle fixed it: the retry repair rebuilt `handle_talk_to_sparky` cleanly again (`21` repair actions on the retry pass), and after the second settle and explicit save a fresh `verify SPARKY` held at `0x39240..0x39652` with `gapCount=0`, `status=clean`, and both dialogue entry points returning. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, re-audited the ScummVM Phelps dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_phelps` (`0x29920..0x2a0ef`) and `reset_talk_to_phelps_state` (`0x2a110..0x2a139`). The branch structure in [`engines/harvester/npc/phelps_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/phelps_dialogue.cpp) was already close, but the native body showed two concrete source drifts: most of Phelps's talk branches were still using the wrong portrait/head variants, including the intro, default revisit, Whaley photo, casket photo, quarter, blackmail evidence, Jimmy-death follow-up, and the one-shot event barks for `STEPH_MIDGAME_PLAYED`, day 5, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `PC_ESCAPED_JAIL`, `GOT_REMAINS_FOR_LODGE`, Butcher death, Moynahan death, and both Karin outcomes; and the engine was setting shared dialogue state `d2f04` before the entire intro instead of after native line `0x1b56` and before the final `PC 0x1b5a` line. Patched [`engines/harvester/npc/phelps_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/phelps_dialogue.cpp) to match the recovered variants and intro ordering, rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and checked the directly related Ghidra callees; no new `FUN_*` rename in this thread met the project evidence bar, so live progress remains `913` total functions, `796` named/documented, and `117` remaining `FUN_*`.
@@ -94,6 +96,7 @@
 - Immediate: run an in-engine Mother validation pass that explicitly hits the recovered native-only flow: first talk with response `1` to confirm `0x26d6[3] -> C072.FST -> 0x26e9[0] -> menu 0x173 -> 0x26f5[3] -> C073.FST`; restart or reload and take response `2` to confirm `C073.FST -> 0x2709[3] -> C072.FST -> 0x2714[0] -> 0x2719[3]`; then cancel the menu once to verify the native silent fallthrough and confirm a second talk in the same room returns immediately because the local talk-state bit is cleared after the first entry.
 - Immediate: run an in-engine Boyle validation pass that explicitly hits the corrected native-only paths: intro response menu `0x5 -> 0x7 -> 2` to confirm the new Boyle-local evidence-reply bit persists and later changes the `BOYLES_BUTTON` and note/checkbook continuations; `BOYLES_BUTTON` through both `0x2` responses to verify `PC 0x179`, the optional `PC 0x186 -> BOYLE 0x18b` tail, the `d2e98` / `d2eb0` writes, and simple inventory hiding instead of object reset; note/checkbook evidence through response menus `0x3` and `0x4` with and without `HAVE_LODGE_APP` to confirm the restored `PC 0x141` / `0x14a`, `PC 0x1a9`, optional `PC 0x186 -> BOYLE 0x1b8`, and the no-item follow-up staying gated off shared note/checkbook state rather than the local Boyle branch; `GASCAN` after Boyle has accepted the application deal to confirm only `GASCAN` hides and `LODGE_APPLICATION` is awarded; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared `d2ebc` to verify the optional `PC 0x253`; and keyword topics `0xe`, `0x17`, `0x18..0x1b`, typed `Arsonist`, and both visible `BYE` entries to confirm the restored PC/Boyle speaker split and native exit flow.
 - Immediate: run an in-engine Moynahan validation pass that explicitly hits the corrected native-only branches: `IF_TRY_TO_TAKE_THE_GLUE` before and after the slashes-on-body topic unlock to confirm the short `0x3e76` / `0x3e7a` versus extended `0x3e5b..0x3e71` interrupt; presented `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, both ledgers, note/checkbook evidence, `PHOTO_OF_WHALEY_HERRILL`, and `WED_MATCHES` to verify the restored response menus, shared-state writes, portrait variants, and both glue handoff paths; a no-item revisit after shared corpse-photo evidence to confirm `0x3f5b` re-arms the intro; day 5, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, Butcher death, Jimmy death, and `KARIN_FOUND_DEAD` to verify the one-shot bark chain plus the shared kidnapped-discussion writes; and the keyword loop through `0x19a/0x19b`, `0x19d..0x1a0`, `0x1a2/0x1a3`, `0x1a9..0x1ab`, `0x1b2/0x1b3`, `0x1b4/0x1b5` in `MBLM`, `0x1b8..0x1be`, `0x1bf..0x1c2`, typed unknown text, and both visible `BYE` entries to confirm the restored topic-buffer rewrites and fallback flow.
+- Immediate: run an in-engine Buster validation pass that explicitly hits the restored native-only paths: `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, and note/checkbook evidence through response menu `0x23` to confirm the recovered shared-state writes and both blackmail continuations; the first three no-item visits to verify the full `0x163c..0x168e`, `0x1828..0x185e`, and `0x18c5..0x18cd` sequences plus the saved `secondIntroResponseTwoSeen` gate; the keyword loop through topic groups `0x28/0x29`, `0x2c/0x2d`, `0x32`, `0x34`, `0x36`, `0x38`, `0x3b`, `0x3e`, `0x40/0x41`, `0x44/0x45/0x46`, `0x48`, and `0x49` to confirm the restored topic-buffer rewrites and `C058.FST` / `C085.FST` inserts; and at least one fatal branch (`0x183a`, `0x1850`, `0x16d0`, `0x16ef`, `0x1702`, `0x1804`, or `0x18df`) to confirm `C086.FST` plus the recovered game-over screen now hand back to the main menu correctly.
 - Immediate: run an in-engine Herrill validation pass that explicitly hits the corrected native-only paths: `HERRILL_IN_LODGE` before `PC_TALKED_TO_HERRILL` to confirm the full `0x31ca` / `0x31ce[4]` / `0x31d2` / `0x31da` / `0x31de[2]` sequence, the optional `0x31e0[2]` / `0x31e2[2]` insert when the shared Whaley/Herrill-photo flag is already set, the `GRAND_MUCK` monsterfy transition, and the one-shot `PC_TALKED_TO_HERRILL` gate; `PHOTO_OF_WHALEY_HERRILL` followed by a no-item revisit to confirm Herrill no longer self-arms the shared photo followup; `KARIN_FOUND_ALIVE` and `KARIN_FOUND_DEAD` to verify the non-native `0x3050` / `0x3057` and `PC 0x461b` lines are gone; `Lodge`, `The Lodge`, and an unrelated typed topic through the keyword loop to confirm `0x2fdd` on both lodge aliases versus the `0x3099 -> 0x2ff4` unknown-topic fallback; and both visible `BYE` entries to confirm they now share the direct `0x2ff4` exit.
 - Immediate: run an in-engine Dad validation pass that explicitly hits the newly restored native-only paths: `TAKING_BONDAGE` to confirm the `0x3a61 -> C011.FST -> 0x3a6b` tail; first no-item talk to confirm the full `0x39a5` / `0x39a9` / `0x39ad` / `0x39b1[PC2]` / `0x39b5` intro and the shared `d2ed0` / `d2f04` writes; no-item revisits with and without shared state `dadMeatPermissionState` to confirm the automatic `C112S4.FST` permission-conversion branch and the empty visible keyword menu; typed `Lodge`, `The Lodge`, and an unrelated free-text topic through Dad's Other input path to verify the restored `0x3a7d` / `0x3a83` / `0x3a88` special case vs the generic `0x3a2c` fallback; and presented `PHOTO_OF_WHALEY_HERRILL`, note/checkbook evidence, and `MEAT_PERMISSION0` to confirm the corrected shared dialogue-state writes and inventory side effects.
 - Immediate: run an in-engine Butcher and Dad validation pass that explicitly hits the recovered native-only paths: first Butcher talk before and after talking to Dad once to confirm `0x190f` vs `0x1913`; the revisit bark on day 1 with and without `MEAT_PERMISSION0` / `MEAT_PERMISSION` to confirm the `0x261` topic buffer disappears once permission is owned or after day 4; topics `0x263`, `0x267`, `0x26b`, `0x26e`, `0x270`, `0x273/0x274`, `0x277`, and the `0x279` no-op path to verify the restored menu rewrites plus `C119/C060/C119A/C119B/C123/C123A` FST tails; presented `MEAT_PERMISSION` to confirm `MEAT` is awarded and `GOT_MEAT` is set; and then Dad with `MEAT_PERMISSION0` both before and after Butcher grants it to confirm Dad now reads the shared permission state and the first Dad intro seeds `dialogueStateD2ed0`.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 27f0fbb66f6..b4805edfe3c 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -119,6 +119,9 @@ static const int kDialogueOtherStartY = 170;
 static const int kDialogueOtherEndY = 193;
 static const int kDialogueGenericByeResponseIndex = 13;
 static const char *const kDialogueKeywordBitmapPath = "1:/GRAPHIC/OTHER/KEYWORD.BM";
+static const char *const kDialogueGameOverBitmapPath = "1:/GRAPHIC/OTHER/GAMEOVER.BM";
+static const char *const kDialogueGameOverPalettePath = "1:/GRAPHIC/PAL/GAMEOVER.PAL";
+static const char *const kDialogueGameOverMusicPath = "SOUND/MUSIC/ANXIETY.CMP";
 
 static const byte kTextColorNormal = 255;
 static const byte kTextColorHover = 251;
@@ -253,6 +256,15 @@ static bool loadBitmapResource(ResourceManager &resources, const Common::String
 	return true;
 }
 
+static bool loadPaletteResource(ResourceManager &resources, const Common::String &path, byte *dest) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 256 * 3)
+		return false;
+
+	memcpy(dest, data.data(), 256 * 3);
+	return true;
+}
+
 static bool isPrimaryMouseDown() {
 	return g_system && g_system->getEventManager() &&
 		((g_system->getEventManager()->getButtonState() & 1) != 0);
@@ -858,6 +870,72 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 
 		return Common::kNoError;
 	};
+	auto runGameOverScreen = [&]() -> Common::Error {
+		Graphics::Screen *screen = _engine.getScreen();
+		ResourceManager *resources = _engine.getResources();
+		if (!screen || !resources)
+			return Common::kReadingFailed;
+
+		IndexedBitmap gameOverBitmap;
+		byte gameOverPalette[256 * 3];
+		if (!loadBitmapResource(*resources, kDialogueGameOverBitmapPath, gameOverBitmap) ||
+				!loadPaletteResource(*resources, kDialogueGameOverPalettePath, gameOverPalette)) {
+			return Common::kReadingFailed;
+		}
+
+		_engine.stopStartupSpeech();
+		_engine.stopStartupMusic();
+		_engine.stopStartupSound();
+
+		screen->fillRect(screen->getBounds(), 0);
+		blitBitmap(*screen, gameOverBitmap, 0, 0);
+		setScaledPalette(*screen, gameOverPalette, 1.0f);
+		screen->makeAllDirty();
+		screen->update();
+
+		(void)_engine.playStartupMusic(kDialogueGameOverMusicPath);
+
+		bool dismissed = false;
+		Graphics::FrameLimiter limiter(g_system, 60);
+		while (!dismissed && !SHOULD_QUIT) {
+			Common::Event event;
+			while (g_system->getEventManager()->pollEvent(event)) {
+				Common::Error result = Common::kNoError;
+				if (startupFlow.handleSystemEvent(event, result)) {
+					_engine.stopStartupMusic();
+					return result;
+				}
+
+				switch (event.type) {
+				case Common::EVENT_LBUTTONDOWN:
+				case Common::EVENT_RBUTTONDOWN:
+				case Common::EVENT_KEYDOWN:
+					dismissed = true;
+					break;
+				default:
+					break;
+				}
+			}
+
+			if (runtimeEntities)
+				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+			if (dismissed)
+				break;
+
+			limiter.delayBeforeSwap();
+			limiter.startFrame();
+		}
+
+		_engine.stopStartupMusic();
+		if (dismissed) {
+			Common::Error releaseError = waitForPointerRelease();
+			if (releaseError.getCode() != Common::kNoError)
+				return releaseError;
+		}
+
+		startupFlow.requestMainMenuReturn();
+		return Common::kNoError;
+	};
 
 	auto queueDialogueInteractionIfNeeded = [&](const StartupInteractionResult &interaction) {
 		if (interaction.abortRemainingCommandChain || interaction.mutatedRuntimeState ||
@@ -1259,6 +1337,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		[&](const Common::String &responseLine, int &selectedIndex) {
 			return runResponseMenuText(responseLine, -1, selectedIndex);
 		},
+		runGameOverScreen,
 		assignTopicBuffer,
 		matchesResponseLine, matchesAnyResponseLine, queueDialogueInteractionIfNeeded,
 		applyImmediateDialogueInteractionEffects,
diff --git a/engines/harvester/npc/buster_dialogue.cpp b/engines/harvester/npc/buster_dialogue.cpp
index d029612a4fa..4ae09aad0b1 100644
--- a/engines/harvester/npc/buster_dialogue.cpp
+++ b/engines/harvester/npc/buster_dialogue.cpp
@@ -21,6 +21,9 @@
 
 #include <functional>
 
+#include "graphics/screen.h"
+#include "harvester/harvester.h"
+
 #include "harvester/npc/buster_dialogue.h"
 
 #include "harvester/npc/dialogue_flags.h"
@@ -28,48 +31,534 @@
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kBusterNpc = "BUSTER";
+static const char *const kPcSpeaker = "PC";
+static const char *const kDialogueC058FstPath = "GRAPHIC/FST/C058.FST";
+static const char *const kDialogueC085FstPath = "GRAPHIC/FST/C085.FST";
+static const char *const kDialogueC086FstPath = "GRAPHIC/FST/C086.FST";
+
+static const int kBusterEvidenceResponseLine = 0x23;
+static const int kBusterIntroTopicBufferLine = 0x24;
+static const int kBusterSecondIntroResponseLine = 0x25;
+static const int kBusterSecondIntroFollowupResponseLine = 0x26;
+static const int kBusterExitTopicLine = 0x27;
+static const int kBusterTopic0x28Or0x29Lines[] = { 0x28, 0x29 };
+static const int kBusterTopic0x2cOr0x2dLines[] = { 0x2c, 0x2d };
+static const int kBusterTopic0x2cResponseLine = 0x2e;
+static const int kBusterTopic0x2cFollowupResponseLine = 0x2f;
+static const int kBusterTopic0x2cNestedResponseLine = 0x30;
+static const int kBusterTopic0x32Line = 0x32;
+static const int kBusterTopic0x32TopicBufferLine = 0x33;
+static const int kBusterTopic0x34Line = 0x34;
+static const int kBusterTopic0x34TopicBufferLine = 0x35;
+static const int kBusterTopic0x36Line = 0x36;
+static const int kBusterTopic0x36TopicBufferLine = 0x37;
+static const int kBusterTopic0x38Line = 0x38;
+static const int kBusterTopic0x38ResponseLine = 0x39;
+static const int kBusterTopic0x38TopicBufferLine = 0x3a;
+static const int kBusterTopic0x3bLine = 0x3b;
+static const int kBusterTopic0x3bResponseLine = 0x3c;
+static const int kBusterTopic0x3bTopicBufferLine = 0x3d;
+static const int kBusterTopic0x3eLine = 0x3e;
+static const int kBusterTopic0x3eTopicBufferLine = 0x3f;
+static const int kBusterTopic0x40Or0x41Lines[] = { 0x40, 0x41 };
+static const int kBusterTopic0x40ResponseLine = 0x42;
+static const int kBusterTopic0x40TopicBufferLine = 0x43;
+static const int kBusterTopic0x44Or0x45Or0x46Lines[] = { 0x44, 0x45, 0x46 };
+static const int kBusterTopic0x44TopicBufferLine = 0x47;
+static const int kBusterTopic0x48Line = 0x48;
+static const int kBusterTopic0x49Line = 0x49;
+
+static const DialogueLineEntry kBusterCorpsePhotoLines[] = {
+	{ 0x1884, kBusterNpc, 0 },
+	{ 0x1889, kPcSpeaker, 4 },
+	{ 0x188e, kBusterNpc, 2 },
+	{ 0x1895, kBusterNpc, 2 }
+};
+
+static const DialogueLineEntry kBusterIntroLines[] = {
+	{ 0x163c, kBusterNpc, 2 },
+	{ 0x1640, kPcSpeaker, 0 },
+	{ 0x1644, kBusterNpc, 0 },
+	{ 0x1649, kPcSpeaker, 0 },
+	{ 0x164d, kBusterNpc, 0 },
+	{ 0x1654, kPcSpeaker, 0 },
+	{ 0x1658, kBusterNpc, 0 },
+	{ 0x165e, kPcSpeaker, 4 },
+	{ 0x1662, kBusterNpc, 0 },
+	{ 0x1669, kPcSpeaker, 0 },
+	{ 0x166e, kBusterNpc, 0 },
+	{ 0x1673, kPcSpeaker, 0 },
+	{ 0x1677, kBusterNpc, 0 },
+	{ 0x167e, kBusterNpc, 0 },
+	{ 0x1682, kBusterNpc, 2 },
+	{ 0x168e, kBusterNpc, 0 }
+};
+
+static const DialogueLineEntry kBusterSecondIntroPreludeLines[] = {
+	{ 0x1828, kBusterNpc, 2 },
+	{ 0x182c, kPcSpeaker, 2 },
+	{ 0x1830, kBusterNpc, 2 }
+};
+
+static const DialogueLineEntry kBusterThirdIntroLines[] = {
+	{ 0x18c5, kBusterNpc, 2 },
+	{ 0x18c9, kPcSpeaker, 4 },
+	{ 0x18cd, kBusterNpc, 2 }
+};
+
+static const DialogueLineEntry kBusterTopic0x28Or0x29LinesSequence[] = {
+	{ 0x1698, kBusterNpc, 0 },
+	{ 0x169c, kPcSpeaker, 4 },
+	{ 0x16a1, kBusterNpc, 0 }
+};
+
+static const DialogueLineEntry kBusterTopic0x28Or0x29TailLines[] = {
+	{ 0x16ad, kBusterNpc, 2 },
+	{ 0x16b6, kBusterNpc, 0 }
+};
+
+static const DialogueLineEntry kBusterTopic0x32Lines[] = {
+	{ 0x170a, kPcSpeaker, 0 },
+	{ 0x170f, kBusterNpc, 0 },
+	{ 0x1710, kBusterNpc, 0 },
+	{ 0x1711, kBusterNpc, 0 },
+	{ 0x1712, kBusterNpc, 0 }
+};
+
+static const DialogueLineEntry kBusterTopic0x34Lines[] = {
+	{ 0x1727, kBusterNpc, 0 },
+	{ 0x1728, kBusterNpc, 0 },
+	{ 0x1729, kBusterNpc, 0 },
+	{ 0x172a, kBusterNpc, 0 },
+	{ 0x172b, kBusterNpc, 0 }
+};
+
+static const DialogueLineEntry kBusterTopic0x36Lines[] = {
+	{ 0x173d, kPcSpeaker, 0 },
+	{ 0x1742, kBusterNpc, 0 },
+	{ 0x1749, kPcSpeaker, 0 },
+	{ 0x174d, kBusterNpc, 0 },
+	{ 0x174e, kBusterNpc, 0 },
+	{ 0x174f, kBusterNpc, 0 },
+	{ 0x1750, kBusterNpc, 2 },
+	{ 0x1762, kBusterNpc, 2 }
+};
+
+static const DialogueLineEntry kBusterTopic0x38ResponseTwoLines[] = {
+	{ 0x1787, kBusterNpc, 0 },
+	{ 0x1791, kBusterNpc, 0 },
+	{ 0x1796, kPcSpeaker, 0 },
+	{ 0x179d, kBusterNpc, 0 }
+};
+
+static const DialogueLineEntry kBusterTopic0x3bTailLines[] = {
+	{ 0x17bb, kBusterNpc, 0 },
+	{ 0x17c3, kBusterNpc, 0 }
+};
+
+static const DialogueLineEntry kBusterTopic0x3eLines[] = {
+	{ 0x17cc, kBusterNpc, 0 },
+	{ 0x17cd, kBusterNpc, 0 },
+	{ 0x17ce, kBusterNpc, 0 },
+	{ 0x17db, kBusterNpc, 0 }
+};
+
+static const DialogueLineEntry kBusterTopic0x40Or0x41PreludeLines[] = {
+	{ 0x17e4, kBusterNpc, 0 },
+	{ 0x17e5, kBusterNpc, 0 },
+	{ 0x17e6, kBusterNpc, 0 },
+	{ 0x17e7, kBusterNpc, 0 },
+	{ 0x17f7, kBusterNpc, 0 }
+};
+
+} // End of namespace
+
 bool BusterDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("BUSTER");
+	return npcName.equalsIgnoreCase(kBusterNpc);
 }
 
 Common::Error BusterDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &) {
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
+	Common::String topicBuffer;
+	int topicBufferLineIndex = -1;
+
+	auto assignBusterTopicBuffer = [&](int responseLineIndex) {
+		runtime.assignTopicBuffer(topicBuffer, topicBufferLineIndex,
+			responseLineIndex, "Buster topic buffer");
+	};
+	auto playBusterLine = [&](int wavId, int headVariant = 0,
+			const char *speakerId = kBusterNpc) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, speakerId, headVariant);
+	};
+	auto playSequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
+		return runtime.playDialogueEntrySequence(lines, count);
+	};
+	auto clearScreenToBlack = [&]() {
+		Graphics::Screen *screen = runtime.engine().getScreen();
+		if (!screen)
+			return;
+
+		screen->fillRect(screen->getBounds(), 0);
+		screen->update();
+	};
+	auto playFatalBusterBranch = [&](int wavId, int headVariant = 0) -> Common::Error {
+		Common::Error lineError = playBusterLine(wavId, headVariant);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		clearScreenToBlack();
+		lineError = runtime.playDialogueFst(kDialogueC086FstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		return runtime.runGameOverScreen();
+	};
+	auto isCorpsePhoto = [&](const Common::String &itemName) {
+		return itemName.equalsIgnoreCase("CASKET_PHOTO") ||
+			itemName.equalsIgnoreCase("CASKET_PHOTOCOPY");
+	};
+	auto isBlackmailEvidence = [&](const Common::String &itemName) {
+		return itemName.equalsIgnoreCase("NOTE_CASKET_PHOTO") ||
+			itemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
+			itemName.equalsIgnoreCase("CHECKBOOK") ||
+			itemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY");
+	};
+
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			sharedState.discussedWhaleyHerrillPhoto = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-			return runtime.playDialogueLine(0x187b, "BUSTER");
+			return playBusterLine(0x187b);
 		}
-		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
-				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+		if (isCorpsePhoto(usedItemName)) {
+			sharedState.discussedCasketPhotoEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
-			return runtime.playDialogueLine(0x1884, "BUSTER");
+			return playSequence(kBusterCorpsePhotoLines, ARRAYSIZE(kBusterCorpsePhotoLines));
 		}
-		if (usedItemName.equalsIgnoreCase("NOTE_CASKET_PHOTO") ||
-				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
-				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
-				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+		if (isBlackmailEvidence(usedItemName)) {
+			sharedState.discussedNoteCheckbookEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
-			return runtime.playDialogueLine(0x189b, "BUSTER");
+			Common::Error lineError = playBusterLine(0x189b);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(
+				kBusterEvidenceResponseLine, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = playBusterLine(0x18a6, 1);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				lineError = playBusterLine(0x18aa, 0, kPcSpeaker);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				return playBusterLine(0x18ae);
+			}
+			if (responseIndex == 2) {
+				lineError = playBusterLine(0x18b5, 2);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+				return playBusterLine(0x18be, 2);
+			}
+			return Common::kNoError;
 		}
-		return runtime.playDialogueLine(0x1875, "BUSTER");
+		return playBusterLine(0x1875);
 	}
 
 	if (_state.introPending) {
 		_state.introPending = false;
 		_state.secondIntroPending = true;
-		return runtime.playDialogueLineWithVariant(0x163c, "BUSTER", 2);
-	}
-	if (_state.secondIntroPending) {
+		Common::Error lineError = playSequence(kBusterIntroLines, ARRAYSIZE(kBusterIntroLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		assignBusterTopicBuffer(kBusterIntroTopicBufferLine);
+	} else if (_state.secondIntroPending) {
 		_state.secondIntroPending = false;
 		_state.thirdIntroPending = true;
-		return runtime.playDialogueLineWithVariant(0x1828, "BUSTER", 2);
-	}
-	if (_state.thirdIntroPending) {
+		Common::Error lineError = playSequence(
+			kBusterSecondIntroPreludeLines, ARRAYSIZE(kBusterSecondIntroPreludeLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(
+			kBusterSecondIntroResponseLine, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1)
+			return playFatalBusterBranch(0x183a, 2);
+		if (responseIndex == 2) {
+			lineError = playBusterLine(0x1841);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			if (!_state.secondIntroResponseTwoSeen) {
+				lineError = playBusterLine(0x185e);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				_state.secondIntroResponseTwoSeen = true;
+				_state.secondIntroPending = true;
+				_state.thirdIntroPending = false;
+			} else {
+				lineError = playBusterLine(0x1845);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				int followupResponseIndex = 0;
+				responseError = runtime.runResponseMenu(
+					kBusterSecondIntroFollowupResponseLine, followupResponseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+
+				if (followupResponseIndex == 1)
+					return playFatalBusterBranch(0x1850);
+				if (followupResponseIndex == 2) {
+					lineError = playBusterLine(0x1858);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+				}
+			}
+		}
+	} else if (_state.thirdIntroPending) {
 		_state.thirdIntroPending = false;
-		return runtime.playDialogueLineWithVariant(0x18c5, "BUSTER", 2);
+		return playSequence(kBusterThirdIntroLines, ARRAYSIZE(kBusterThirdIntroLines));
+	} else {
+		return playBusterLine(0x1868);
 	}
 
-	return runtime.playDialogueLine(0x1868, "BUSTER");
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runtime.runKeywordMenu(topicBuffer, topicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.empty())
+			return Common::kNoError;
+
+		if (runtime.matchesResponseLine(selectedTopic, kBusterExitTopicLine))
+			return playBusterLine(0x1818);
+
+		if (runtime.matchesAnyResponseLine(selectedTopic, kBusterTopic0x28Or0x29Lines,
+				ARRAYSIZE(kBusterTopic0x28Or0x29Lines))) {
+			Common::Error lineError = playSequence(
+				kBusterTopic0x28Or0x29LinesSequence, ARRAYSIZE(kBusterTopic0x28Or0x29LinesSequence));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC085FstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playSequence(
+				kBusterTopic0x28Or0x29TailLines, ARRAYSIZE(kBusterTopic0x28Or0x29TailLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			assignBusterTopicBuffer(0x2b);
+			continue;
+		}
+
+		if (runtime.matchesAnyResponseLine(selectedTopic, kBusterTopic0x2cOr0x2dLines,
+				ARRAYSIZE(kBusterTopic0x2cOr0x2dLines))) {
+			Common::Error lineError = playBusterLine(0x16be);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = playBusterLine(0x16c5);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(
+				kBusterTopic0x2cResponseLine, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1)
+				return playFatalBusterBranch(0x16d0);
+			if (responseIndex == 2) {
+				lineError = playBusterLine(0x16d6);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				int followupResponseIndex = 0;
+				responseError = runtime.runResponseMenu(
+					kBusterTopic0x2cFollowupResponseLine, followupResponseIndex);
+				if (responseError.getCode() != Common::kNoError)
+					return responseError;
+
+				if (followupResponseIndex == 1) {
+					lineError = playBusterLine(0x16e2);
+					if (lineError.getCode() != Common::kNoError)
+						return lineError;
+
+					int nestedResponseIndex = 0;
+					responseError = runtime.runResponseMenu(
+						kBusterTopic0x2cNestedResponseLine, nestedResponseIndex);
+					if (responseError.getCode() != Common::kNoError)
+						return responseError;
+
+					if (nestedResponseIndex == 1)
+						return playFatalBusterBranch(0x16ef);
+					if (nestedResponseIndex == 2) {
+						lineError = playBusterLine(0x16f6);
+						if (lineError.getCode() != Common::kNoError)
+							return lineError;
+
+						assignBusterTopicBuffer(0x31);
+					}
+				} else if (followupResponseIndex == 2) {
+					return playFatalBusterBranch(0x1702);
+				}
+			}
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, kBusterTopic0x32Line)) {
+			Common::Error lineError = playSequence(
+				kBusterTopic0x32Lines, ARRAYSIZE(kBusterTopic0x32Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			assignBusterTopicBuffer(kBusterTopic0x32TopicBufferLine);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, kBusterTopic0x34Line)) {
+			Common::Error lineError = playSequence(
+				kBusterTopic0x34Lines, ARRAYSIZE(kBusterTopic0x34Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			assignBusterTopicBuffer(kBusterTopic0x34TopicBufferLine);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, kBusterTopic0x36Line)) {
+			Common::Error lineError = playSequence(
+				kBusterTopic0x36Lines, ARRAYSIZE(kBusterTopic0x36Lines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			assignBusterTopicBuffer(kBusterTopic0x36TopicBufferLine);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, kBusterTopic0x38Line)) {
+			Common::Error lineError = playBusterLine(0x176b);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC058FstPath);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(
+				kBusterTopic0x38ResponseLine, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1)
+				return playBusterLine(0x1781);
+			if (responseIndex == 2) {
+				lineError = playSequence(
+					kBusterTopic0x38ResponseTwoLines, ARRAYSIZE(kBusterTopic0x38ResponseTwoLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			assignBusterTopicBuffer(kBusterTopic0x38TopicBufferLine);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, kBusterTopic0x3bLine)) {
+			Common::Error lineError = playBusterLine(0x17a5);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(
+				kBusterTopic0x3bResponseLine, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1) {
+				lineError = playBusterLine(0x17b3);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			} else if (responseIndex == 2) {
+				lineError = playBusterLine(0x17b7);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+			}
+
+			lineError = playSequence(kBusterTopic0x3bTailLines, ARRAYSIZE(kBusterTopic0x3bTailLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			assignBusterTopicBuffer(kBusterTopic0x3bTopicBufferLine);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, kBusterTopic0x3eLine)) {
+			Common::Error lineError = playSequence(
+				kBusterTopic0x3eLines, ARRAYSIZE(kBusterTopic0x3eLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			assignBusterTopicBuffer(kBusterTopic0x3eTopicBufferLine);
+			continue;
+		}
+
+		if (runtime.matchesAnyResponseLine(selectedTopic, kBusterTopic0x40Or0x41Lines,
+				ARRAYSIZE(kBusterTopic0x40Or0x41Lines))) {
+			Common::Error lineError = playSequence(kBusterTopic0x40Or0x41PreludeLines,
+				ARRAYSIZE(kBusterTopic0x40Or0x41PreludeLines));
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			int responseIndex = 0;
+			Common::Error responseError = runtime.runResponseMenu(
+				kBusterTopic0x40ResponseLine, responseIndex);
+			if (responseError.getCode() != Common::kNoError)
+				return responseError;
+
+			if (responseIndex == 1)
+				return playFatalBusterBranch(0x1804);
+			if (responseIndex == 2) {
+				lineError = playBusterLine(0x180b);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
+
+				assignBusterTopicBuffer(kBusterTopic0x40TopicBufferLine);
+			}
+			continue;
+		}
+
+		if (runtime.matchesAnyResponseLine(selectedTopic, kBusterTopic0x44Or0x45Or0x46Lines,
+				ARRAYSIZE(kBusterTopic0x44Or0x45Or0x46Lines))) {
+			Common::Error lineError = playBusterLine(0x181e, 2);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+
+			assignBusterTopicBuffer(kBusterTopic0x44TopicBufferLine);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, kBusterTopic0x48Line))
+			return playFatalBusterBranch(0x18df, 2);
+
+		if (runtime.matchesResponseLine(selectedTopic, kBusterTopic0x49Line)) {
+			Common::Error lineError = playBusterLine(0x186f);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+		}
+	}
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/buster_dialogue.h b/engines/harvester/npc/buster_dialogue.h
index 553ca522da3..cab792e8c97 100644
--- a/engines/harvester/npc/buster_dialogue.h
+++ b/engines/harvester/npc/buster_dialogue.h
@@ -32,6 +32,7 @@ public:
 		bool introPending = true;
 		bool secondIntroPending = false;
 		bool thirdIntroPending = false;
+		bool secondIntroResponseTwoSeen = false;
 	};
 
 	bool matchesNpc(const Common::String &npcName) const override;
@@ -40,6 +41,7 @@ public:
 		syncDialogueBool(s, _state.introPending);
 		syncDialogueBool(s, _state.secondIntroPending);
 		syncDialogueBool(s, _state.thirdIntroPending);
+		syncDialogueBool(s, _state.secondIntroResponseTwoSeen, 10);
 	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
diff --git a/engines/harvester/npc/dialogue_runtime.h b/engines/harvester/npc/dialogue_runtime.h
index f8bcabbf16c..8a446bb4336 100644
--- a/engines/harvester/npc/dialogue_runtime.h
+++ b/engines/harvester/npc/dialogue_runtime.h
@@ -28,6 +28,7 @@ public:
 	typedef std::function<Common::Error(const Common::String &, int, Common::String &)> RunKeywordMenuFn;
 	typedef std::function<Common::Error(int, int &)> RunResponseMenuFn;
 	typedef std::function<Common::Error(const Common::String &, int &)> RunResponseMenuTextFn;
+	typedef std::function<Common::Error()> RunGameOverScreenFn;
 	typedef std::function<void(Common::String &, int &, int, const char *)> AssignTopicBufferFn;
 	typedef std::function<bool(const Common::String &, int)> MatchesResponseLineFn;
 	typedef std::function<bool(const Common::String &, const int *, uint)> MatchesAnyResponseLineFn;
@@ -46,6 +47,7 @@ public:
 			const RunKeywordMenuFn &runKeywordMenu,
 			const RunResponseMenuFn &runResponseMenu,
 			const RunResponseMenuTextFn &runResponseMenuText,
+			const RunGameOverScreenFn &runGameOverScreen,
 			const AssignTopicBufferFn &assignTopicBuffer,
 			const MatchesResponseLineFn &matchesResponseLine,
 			const MatchesAnyResponseLineFn &matchesAnyResponseLine,
@@ -60,6 +62,7 @@ public:
 		  _playDialogueLine(playDialogueLine), _playDialogueEntrySequence(playDialogueEntrySequence),
 		  _playDialogueFst(playDialogueFst), _runKeywordMenu(runKeywordMenu),
 		  _runResponseMenu(runResponseMenu), _runResponseMenuText(runResponseMenuText),
+		  _runGameOverScreen(runGameOverScreen),
 		  _assignTopicBuffer(assignTopicBuffer),
 		  _matchesResponseLine(matchesResponseLine),
 		  _matchesAnyResponseLine(matchesAnyResponseLine),
@@ -97,6 +100,7 @@ public:
 	Common::Error runResponseMenuText(const Common::String &responseLine, int &responseIndex) const {
 		return _runResponseMenuText(responseLine, responseIndex);
 	}
+	Common::Error runGameOverScreen() const { return _runGameOverScreen(); }
 	void assignTopicBuffer(Common::String &topicBuffer, int &topicBufferLineIndex,
 			int responseLineIndex, const char *label) const {
 		_assignTopicBuffer(topicBuffer, topicBufferLineIndex, responseLineIndex, label);
@@ -133,6 +137,7 @@ private:
 	RunKeywordMenuFn _runKeywordMenu;
 	RunResponseMenuFn _runResponseMenu;
 	RunResponseMenuTextFn _runResponseMenuText;
+	RunGameOverScreenFn _runGameOverScreen;
 	AssignTopicBufferFn _assignTopicBuffer;
 	MatchesResponseLineFn _matchesResponseLine;
 	MatchesAnyResponseLineFn _matchesAnyResponseLine;
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index d59feb22ea0..360904c2223 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -39,7 +39,7 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 }
 
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
-static const uint32 kHarvesterSaveVersion = 9;
+static const uint32 kHarvesterSaveVersion = 10;
 
 static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
 	debugC(1, kDebugGeneral,


Commit: 0b109e80d474a886affe9a70edc01462955aa82c
    https://github.com/scummvm/scummvm/commit/0b109e80d474a886affe9a70edc01462955aa82c
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:01+02:00

Commit Message:
HARVESTER: Reimplement fireman1 dialogue followups

Changed paths:
    TRACKER.md
    engines/harvester/npc/fireman1_dialogue.cpp
    engines/harvester/npc/fireman1_dialogue.h
    engines/harvester/saveload.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 0567c77cafc..5fe0110a65b 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,8 @@
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Fireman1 dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_fireman1` (`0x33850..0x338f6`) and `reset_talk_to_fireman1_state` (`0x33920..0x33937`). The recovered native flow is still itemless, but the old stub in [`engines/harvester/npc/fireman1_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman1_dialogue.cpp) was missing both native one-shot follow-ups: after the first-talk `0x453` or revisit `0x463` bark, Fireman1 now conditionally plays `0x45a[3]` when `STEPH_MIDGAME_PLAYED` is set and `0x46a[2]` when `BOLT_OF_CLOTH_TAKEN` is set, each guarded by Fireman1-local room state. Patched [`engines/harvester/npc/fireman1_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman1_dialogue.cpp), extended Fireman1's save-backed room state in [`engines/harvester/npc/fireman1_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman1_dialogue.h), bumped the Harvester save version to `11` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) for the two new local state bits, and renamed the directly evidenced Fireman1 globals at `0xd2dd4` and `0xd2dd8` to `g_fireman1_steph_midgame_followup_shown` and `g_fireman1_bolt_of_cloth_followup_shown`. No standalone `FUN_*` helper surfaced in this analysis thread, so live progress remains `919` total functions, `802` named/documented, and `117` remaining `FUN_*`.
+- On March 23, 2026, reran the full durable `repair FIREMAN1 -> wait -> save_program -> verify FIREMAN1` cycle against the live `HARVEST.LE` session. `handle_talk_to_fireman1 @ 0x33850` started as a truncated `0x33850..0x33883` body (`52` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x33850..0x338f6` (`167` address-count) with `3` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify FIREMAN1` still reported `status=clean` with both dialogue entry points returning, so Fireman1 is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Buster dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_buster` (`0x2b7c0..0x2c575`) and `reset_talk_to_buster_state` (`0x2c600..0x2c613`). The recovered native flow was much wider than the old stub in [`engines/harvester/npc/buster_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/buster_dialogue.cpp): item handling now matches the native casket-photo follow-up and blackmail response menu, no-item dialogue restores the full `0x163c..0x168e` opener plus the `0x1828..0x185e` / `0x18c5..0x18cd` follow-ups, the looping keyword tree now covers the recovered `dialog.rsp` topic groups `0x28/0x29`, `0x2c/0x2d`, `0x32`, `0x34`, `0x36`, `0x38`, `0x3b`, `0x3e`, `0x40/0x41`, `0x44/0x45/0x46`, `0x48`, and `0x49`, and Buster's fatal branches now run the native `C086.FST` blackout plus a restored game-over screen instead of falling through to short barks. Patched [`engines/harvester/npc/buster_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/buster_dialogue.cpp), extended Buster's save-backed room state in [`engines/harvester/npc/buster_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/buster_dialogue.h), added a reusable dialogue-side game-over helper in [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp) and [`engines/harvester/npc/dialogue_runtime.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dialogue_runtime.h), bumped the Harvester save blob version to `10` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) for the new Buster-local state bit, rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and renamed the directly encountered live Ghidra `FUN_*` helpers to `play_c085_fst_sequence` (`0x387d0`), `play_c086_blackout_fst_sequence` (`0x387e0`), and `play_c058_fst_sequence` (`0x38800`). After saving `HARVEST.LE`, live progress stands at `919` total functions, `802` named/documented, and `117` remaining `FUN_*`.
 - On March 23, 2026, reran the full durable `repair BUSTER -> wait -> save_program -> verify BUSTER` cycle against the live `HARVEST.LE` session. `handle_talk_to_buster @ 0x2b7c0` started as a truncated `0x2b7c0..0x2c56c` body (`449` address-count) with `8` reachable dialogue calls and `8` stale `CALL_RETURN` gaps. The repair needed two in-memory passes: pass 1 expanded the handler to `0x2b7c0..0x2c575` with `95` dialogue calls but left `1` newly exposed gap, and pass 2 finished the rebuild cleanly at `0x2b7c0..0x2c575` (`3510` address-count) with `95` reachable dialogue calls, `gapCount=0`, and `19` total repair actions. After the normal settle and explicit save, a fresh `verify BUSTER` still reported `status=clean` with both dialogue entry points returning, so Buster is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, re-audited the ScummVM Sparky dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_sparky` (`0x39240..0x39652`) and `reset_talk_to_sparky_state` (`0x39690..0x396c5`). The native body showed two concrete source drifts in [`engines/harvester/npc/sparky_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sparky_dialogue.cpp): most of Sparky's talk branches were still using the wrong portrait/head variants, including the light-switch bark, item rejection, intro exchange, return-visit lines, and the `0x34c`, `0x37a`, `0x38f`, `0x39e`, and `0x3c1` keyword replies; and the ScummVM port was incorrectly seeding a persistent keyword buffer from `dialog.rsp[0x2c3]`, while the native handler starts each conversation with an empty keyword list and only rewrites that list to `dialog.rsp[0x2c9]` after topic `0x2c8`. Patched [`engines/harvester/npc/sparky_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sparky_dialogue.cpp) to match the recovered variants and one-call topic rewrite, rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and checked the directly encountered `FUN_*` symbols surfaced during the keyword-menu trace. Those `FUN_*` hits (`FUN_0002e3b4`, `FUN_0002e41b`, and `FUN_0002f7fd`) are overlapping mid-body dialogue entries inside larger Herrill and Mrs. Potts handlers rather than clean standalone helpers, so no new rename met the project evidence bar without first repairing the underlying function boundaries. Live progress now reflects the current saved database totals of `916` functions, `799` documented, and `117` remaining `FUN_*`.
@@ -89,6 +91,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Fireman1 validation pass that explicitly covers the recovered native-only follow-ups: first talk with neither flag set to confirm only `0x453`; a revisit with neither flag set to confirm only `0x463`; a talk after `STEPH_MIDGAME_PLAYED` to confirm the appended `0x45a[3]` bark; a separate talk after `BOLT_OF_CLOTH_TAKEN` to confirm the appended `0x46a[2]` bark; and a save/load cycle after each follow-up has fired once to verify both Fireman1-local gates persist and do not replay on the next revisit.
 - Immediate: run an in-engine Sparky validation pass that explicitly hits the corrected native-only paths: `PC_TRIES_TO_TURN_ON_LIGHT`; any non-empty presented item for the `0x3c7[2]` rejection bark; the intro through all three `0x2be` responses to confirm `0x2f7[1]`, `0x2fe[2]`, `0x30d[1]`, the `SPARKY_SPOTS_MEAT` action tag, and the `C135/C098/C096` FST inserts; same-day and later-day revisits to confirm `0x3bb[1]` versus `0x3ac[1] -> 0x3b0 -> 0x3b4`; and the keyword loop starting from the native empty menu so typed topics `0x2c4/0x2c5`, `0x2c7`, `0x2c8`, `0x2ca/0x2cb`, and an unknown topic verify the restored `0x34c[1]`, `0x35f[0]`, `0x36c[0]`, `0x36d[0]`, `0x37a[3]`, `0x38f[4]`, `0x39e[1]`, and `0x3c1[1]` portrait variants plus the non-persistent `0x2c9` topic-list rewrite.
 - Immediate: run an in-engine Phelps validation pass that explicitly hits the corrected native-only portrait flow: first no-item talk to confirm the `0x1b44[1] -> 0x1b48 -> 0x1b4c -> 0x1b52 -> 0x1b56[1] -> 0x1b5a` intro and the delayed `d2f04` write; a later day-1 to day-4 revisit through both `0x22a` responses to confirm `0x1bf4[1]`, `0x1b7b[1]`, `0x1b85[0]`, and `0x1bbc[1] -> 0x1c44[1]`; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, note/checkbook evidence, and `QUARTER` to verify the recovered portrait variants plus `GET_PRN_MAG` / `DEL_DRT_MAG`; and separate revisits for `STEPH_MIDGAME_PLAYED`, day 5, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `PC_ESCAPED_JAIL`, Jimmy death response menu `0x22c`, `KARIN_FOUND_DEAD`, and `KARIN_KIDNAPED` to confirm the restored one-shot bark variants and shared kidnapped-dialogue write.
 - Immediate: run an in-engine Sergeant and Nude Man validation pass against both a fresh playthrough state and an older ScummVM save created before this fix. Confirm Nude Man now stops after `0x628[1]` until the Sergeant `SCRATCHED_TUCKER` branch has actually set the native first-task completion state, then verify the second menu `0x25e` unlocks correctly after that branch and still unlocks after loading a pre-fix save where only Sergeant's legacy local completion bit was persisted.
diff --git a/engines/harvester/npc/fireman1_dialogue.cpp b/engines/harvester/npc/fireman1_dialogue.cpp
index a4bc97c13dd..6504c030823 100644
--- a/engines/harvester/npc/fireman1_dialogue.cpp
+++ b/engines/harvester/npc/fireman1_dialogue.cpp
@@ -27,18 +27,47 @@
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kFireman1Npc = "FIREMAN1";
+
+} // End of namespace
+
 bool Fireman1DialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("FIREMAN1");
+	return npcName.equalsIgnoreCase(kFireman1Npc);
 }
 
 Common::Error Fireman1DialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
-	if (_state.talkStatePending) {
-		_state.talkStatePending = false;
-		return runtime.playDialogueLine(0x453, "FIREMAN1");
+	Fireman1RoomDialogueState &state = _state;
+
+	Common::Error lineError = Common::kNoError;
+	if (state.talkStatePending) {
+		state.talkStatePending = false;
+		lineError = runtime.playDialogueLine(0x453, kFireman1Npc);
+	} else {
+		lineError = runtime.playDialogueLine(0x463, kFireman1Npc);
+	}
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") &&
+			!state.stephMidgamePlayedShown) {
+		state.stephMidgamePlayedShown = true;
+		lineError = runtime.playDialogueLineWithVariant(0x45a, kFireman1Npc, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	if (runtime.startupScript().getFlagValue("BOLT_OF_CLOTH_TAKEN") &&
+			!state.boltOfClothTakenShown) {
+		state.boltOfClothTakenShown = true;
+		lineError = runtime.playDialogueLineWithVariant(0x46a, kFireman1Npc, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 
-	return runtime.playDialogueLine(0x463, "FIREMAN1");
+	return Common::kNoError;
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/fireman1_dialogue.h b/engines/harvester/npc/fireman1_dialogue.h
index 84a11bd89f4..c096a1c9541 100644
--- a/engines/harvester/npc/fireman1_dialogue.h
+++ b/engines/harvester/npc/fireman1_dialogue.h
@@ -30,12 +30,16 @@ class Fireman1DialogueHandler : public NpcDialogueHandler {
 public:
 	struct Fireman1RoomDialogueState {
 		bool talkStatePending = true;
+		bool stephMidgamePlayedShown = false;
+		bool boltOfClothTakenShown = false;
 	};
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = Fireman1RoomDialogueState(); }
 	void syncState(Common::Serializer &s) override {
 		syncDialogueBool(s, _state.talkStatePending);
+		syncDialogueBool(s, _state.stephMidgamePlayedShown, 11);
+		syncDialogueBool(s, _state.boltOfClothTakenShown, 11);
 	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index 360904c2223..e32406c9fb4 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -39,7 +39,7 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 }
 
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
-static const uint32 kHarvesterSaveVersion = 10;
+static const uint32 kHarvesterSaveVersion = 11;
 
 static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
 	debugC(1, kDebugGeneral,


Commit: 1d175720919e4f9d26da40012377a01556b73e95
    https://github.com/scummvm/scummvm/commit/1d175720919e4f9d26da40012377a01556b73e95
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:02+02:00

Commit Message:
HARVESTER: Align fireman2 dialogue state with ghidra

Changed paths:
    TRACKER.md
    engines/harvester/npc/fireman2_dialogue.cpp
    engines/harvester/npc/fireman2_dialogue.h
    engines/harvester/saveload.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 5fe0110a65b..2dc8130bab5 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,8 @@
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reran the full durable `repair FIREMAN2 -> wait -> save_program -> verify FIREMAN2` cycle against the live `HARVEST.LE` session. `handle_talk_to_fireman2 @ 0x33940` started as a truncated `0x33940..0x33979` body (`58` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x33940..0x339e9` (`170` address-count) with `3` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify FIREMAN2` still reported `status=clean` with both dialogue entry points returning, so Fireman2 is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
+- On March 23, 2026, reimplemented the ScummVM Fireman2 dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_fireman2` (`0x33940..0x33979`) and `reset_talk_to_fireman2_state` (`0x33a10..0x33a25`). The native dialogue logic itself is still only a two-line switch: when `g_fireman2_talk_state_block` is nonzero, Fireman2 plays `0x5b9[2]` and clears that block; otherwise the handler plays `0x5c7[2]`. The real source drift in [`engines/harvester/npc/fireman2_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman2_dialogue.cpp) was state fidelity rather than missing branches: native reset clears a three-dword local state block, while ScummVM only modeled the first flag. Patched [`engines/harvester/npc/fireman2_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman2_dialogue.h) and [`engines/harvester/npc/fireman2_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman2_dialogue.cpp) to carry the full local block (`talkStateBlock`, `dialogueStateD2de0`, `dialogueStateD2de4`) and serialize the two additional native ints starting with save version `12`; bumped the Harvester save blob version to `12` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp); rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully; and inspected the directly related Ghidra path for unnamed helpers, but no `FUN_*` function on the Fireman2 route met the project evidence bar for renaming. Live progress counts remain `919` total functions, `802` named/documented, and `117` remaining `FUN_*`.
 - On March 23, 2026, reimplemented the ScummVM Fireman1 dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_fireman1` (`0x33850..0x338f6`) and `reset_talk_to_fireman1_state` (`0x33920..0x33937`). The recovered native flow is still itemless, but the old stub in [`engines/harvester/npc/fireman1_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman1_dialogue.cpp) was missing both native one-shot follow-ups: after the first-talk `0x453` or revisit `0x463` bark, Fireman1 now conditionally plays `0x45a[3]` when `STEPH_MIDGAME_PLAYED` is set and `0x46a[2]` when `BOLT_OF_CLOTH_TAKEN` is set, each guarded by Fireman1-local room state. Patched [`engines/harvester/npc/fireman1_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman1_dialogue.cpp), extended Fireman1's save-backed room state in [`engines/harvester/npc/fireman1_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman1_dialogue.h), bumped the Harvester save version to `11` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) for the two new local state bits, and renamed the directly evidenced Fireman1 globals at `0xd2dd4` and `0xd2dd8` to `g_fireman1_steph_midgame_followup_shown` and `g_fireman1_bolt_of_cloth_followup_shown`. No standalone `FUN_*` helper surfaced in this analysis thread, so live progress remains `919` total functions, `802` named/documented, and `117` remaining `FUN_*`.
 - On March 23, 2026, reran the full durable `repair FIREMAN1 -> wait -> save_program -> verify FIREMAN1` cycle against the live `HARVEST.LE` session. `handle_talk_to_fireman1 @ 0x33850` started as a truncated `0x33850..0x33883` body (`52` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x33850..0x338f6` (`167` address-count) with `3` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify FIREMAN1` still reported `status=clean` with both dialogue entry points returning, so Fireman1 is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Buster dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_buster` (`0x2b7c0..0x2c575`) and `reset_talk_to_buster_state` (`0x2c600..0x2c613`). The recovered native flow was much wider than the old stub in [`engines/harvester/npc/buster_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/buster_dialogue.cpp): item handling now matches the native casket-photo follow-up and blackmail response menu, no-item dialogue restores the full `0x163c..0x168e` opener plus the `0x1828..0x185e` / `0x18c5..0x18cd` follow-ups, the looping keyword tree now covers the recovered `dialog.rsp` topic groups `0x28/0x29`, `0x2c/0x2d`, `0x32`, `0x34`, `0x36`, `0x38`, `0x3b`, `0x3e`, `0x40/0x41`, `0x44/0x45/0x46`, `0x48`, and `0x49`, and Buster's fatal branches now run the native `C086.FST` blackout plus a restored game-over screen instead of falling through to short barks. Patched [`engines/harvester/npc/buster_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/buster_dialogue.cpp), extended Buster's save-backed room state in [`engines/harvester/npc/buster_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/buster_dialogue.h), added a reusable dialogue-side game-over helper in [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp) and [`engines/harvester/npc/dialogue_runtime.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dialogue_runtime.h), bumped the Harvester save blob version to `10` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) for the new Buster-local state bit, rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and renamed the directly encountered live Ghidra `FUN_*` helpers to `play_c085_fst_sequence` (`0x387d0`), `play_c086_blackout_fst_sequence` (`0x387e0`), and `play_c058_fst_sequence` (`0x38800`). After saving `HARVEST.LE`, live progress stands at `919` total functions, `802` named/documented, and `117` remaining `FUN_*`.
diff --git a/engines/harvester/npc/fireman2_dialogue.cpp b/engines/harvester/npc/fireman2_dialogue.cpp
index be8783a2ad6..a815e7e25ac 100644
--- a/engines/harvester/npc/fireman2_dialogue.cpp
+++ b/engines/harvester/npc/fireman2_dialogue.cpp
@@ -33,12 +33,13 @@ bool Fireman2DialogueHandler::matchesNpc(const Common::String &npcName) const {
 
 Common::Error Fireman2DialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
-	if (_state.talkStatePending) {
-		_state.talkStatePending = false;
-		return runtime.playDialogueLineWithVariant(0x5b9, "FIREMAN2", 2);
+	int wavId = 0x5c7;
+	if (_state.talkStateBlock) {
+		_state.talkStateBlock = false;
+		wavId = 0x5b9;
 	}
 
-	return runtime.playDialogueLineWithVariant(0x5c7, "FIREMAN2", 2);
+	return runtime.playDialogueLineWithVariant(wavId, "FIREMAN2", 2);
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/fireman2_dialogue.h b/engines/harvester/npc/fireman2_dialogue.h
index c5f7d538159..02e19af4792 100644
--- a/engines/harvester/npc/fireman2_dialogue.h
+++ b/engines/harvester/npc/fireman2_dialogue.h
@@ -29,13 +29,17 @@ namespace Harvester {
 class Fireman2DialogueHandler : public NpcDialogueHandler {
 public:
 	struct Fireman2RoomDialogueState {
-		bool talkStatePending = true;
+		bool talkStateBlock = true;
+		int dialogueStateD2de0 = 0;
+		int dialogueStateD2de4 = 0;
 	};
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = Fireman2RoomDialogueState(); }
 	void syncState(Common::Serializer &s) override {
-		syncDialogueBool(s, _state.talkStatePending);
+		syncDialogueBool(s, _state.talkStateBlock);
+		syncDialogueInt(s, _state.dialogueStateD2de0, 12);
+		syncDialogueInt(s, _state.dialogueStateD2de4, 12);
 	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index e32406c9fb4..0c0946c0005 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -39,7 +39,7 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 }
 
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
-static const uint32 kHarvesterSaveVersion = 11;
+static const uint32 kHarvesterSaveVersion = 12;
 
 static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
 	debugC(1, kDebugGeneral,


Commit: 6f70e4eb145cd87a3ab2856d28cc359325130ce6
    https://github.com/scummvm/scummvm/commit/6f70e4eb145cd87a3ab2856d28cc359325130ce6
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:02+02:00

Commit Message:
HARVESTER: Restore authority dialogue flow

Changed paths:
    TRACKER.md
    engines/harvester/npc/authority_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 2dc8130bab5..53b752a5741 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -4,12 +4,14 @@
 
 - Program: `HARVEST.LE`
 - Total functions: `919`
-- Named/documented: `802`
-- Remaining `FUN_*`: `117`
-- Remaining undocumented total: `117`
+- Named/documented: `805`
+- Remaining `FUN_*`: `114`
+- Remaining undocumented total: `114`
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Authority dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_authority` (`0x2b730..0x2b7b2`). The confirmed source drift in [`engines/harvester/npc/authority_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/authority_dialogue.cpp) was small but real: the old stub collapsed the native `IF_DON_T_EAT_THE_FOOD` branch into a single `0x3992` bark, while the recovered handler actually falls through to a second `0x399b` Authority line when `IF_YOU_EAT_THE_FOOD_AND_DEFEAT_THE_ENEMIES` is also set. Patched the ScummVM handler to preserve the native early `STOP_AUTHOR_TALK` exit, restore that two-line food branch, and keep the `0x398a` versus `0x399b` fallback when the earlier food flag is clear. While auditing the same dialogue helper cluster, also renamed the directly evidenced one-call `play_fst_sequence` wrappers at `0x38700`, `0x38710`, and `0x38720` to `play_c101_fst_sequence`, `play_c021_fst_sequence`, and `play_c110_fst_sequence`, then saved `HARVEST.LE`. Live progress is now `919` total functions, `805` named/documented, and `114` remaining `FUN_*`.
+- On March 23, 2026, reran the full durable `repair AUTHORITY -> wait -> save_program -> verify AUTHORITY` cycle against the live `HARVEST.LE` session because Authority had regressed back to the short body in the saved database. `handle_talk_to_authority @ 0x2b730` started again as a truncated `0x2b730..0x2b7b2` body (`95` address-count) with `2` reachable dialogue calls and `2` stale `CALL_RETURN` gaps. A single repair pass rebuilt it cleanly to `0x2b730..0x2b7b2` (`131` address-count) with `3` reachable dialogue calls, `gapCount=0`, and `4` repair actions. After the normal settle and explicit save, a fresh `verify AUTHORITY` still reported `status=clean` with both dialogue entry points returning, so Authority is now durably clean again in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reran the full durable `repair FIREMAN2 -> wait -> save_program -> verify FIREMAN2` cycle against the live `HARVEST.LE` session. `handle_talk_to_fireman2 @ 0x33940` started as a truncated `0x33940..0x33979` body (`58` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x33940..0x339e9` (`170` address-count) with `3` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify FIREMAN2` still reported `status=clean` with both dialogue entry points returning, so Fireman2 is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Fireman2 dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_fireman2` (`0x33940..0x33979`) and `reset_talk_to_fireman2_state` (`0x33a10..0x33a25`). The native dialogue logic itself is still only a two-line switch: when `g_fireman2_talk_state_block` is nonzero, Fireman2 plays `0x5b9[2]` and clears that block; otherwise the handler plays `0x5c7[2]`. The real source drift in [`engines/harvester/npc/fireman2_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman2_dialogue.cpp) was state fidelity rather than missing branches: native reset clears a three-dword local state block, while ScummVM only modeled the first flag. Patched [`engines/harvester/npc/fireman2_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman2_dialogue.h) and [`engines/harvester/npc/fireman2_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman2_dialogue.cpp) to carry the full local block (`talkStateBlock`, `dialogueStateD2de0`, `dialogueStateD2de4`) and serialize the two additional native ints starting with save version `12`; bumped the Harvester save blob version to `12` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp); rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully; and inspected the directly related Ghidra path for unnamed helpers, but no `FUN_*` function on the Fireman2 route met the project evidence bar for renaming. Live progress counts remain `919` total functions, `802` named/documented, and `117` remaining `FUN_*`.
 - On March 23, 2026, reimplemented the ScummVM Fireman1 dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_fireman1` (`0x33850..0x338f6`) and `reset_talk_to_fireman1_state` (`0x33920..0x33937`). The recovered native flow is still itemless, but the old stub in [`engines/harvester/npc/fireman1_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman1_dialogue.cpp) was missing both native one-shot follow-ups: after the first-talk `0x453` or revisit `0x463` bark, Fireman1 now conditionally plays `0x45a[3]` when `STEPH_MIDGAME_PLAYED` is set and `0x46a[2]` when `BOLT_OF_CLOTH_TAKEN` is set, each guarded by Fireman1-local room state. Patched [`engines/harvester/npc/fireman1_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman1_dialogue.cpp), extended Fireman1's save-backed room state in [`engines/harvester/npc/fireman1_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman1_dialogue.h), bumped the Harvester save version to `11` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) for the two new local state bits, and renamed the directly evidenced Fireman1 globals at `0xd2dd4` and `0xd2dd8` to `g_fireman1_steph_midgame_followup_shown` and `g_fireman1_bolt_of_cloth_followup_shown`. No standalone `FUN_*` helper surfaced in this analysis thread, so live progress remains `919` total functions, `802` named/documented, and `117` remaining `FUN_*`.
@@ -93,6 +95,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Authority validation pass that explicitly covers all recovered native branches: `STOP_AUTHOR_TALK` set to confirm silent early return; `IF_DON_T_EAT_THE_FOOD` alone to confirm only `0x3992`; both food flags set to confirm `0x3992` followed by `0x399b`; `IF_DON_T_EAT_THE_FOOD` clear with only `IF_YOU_EAT_THE_FOOD_AND_DEFEAT_THE_ENEMIES` set to confirm the direct `0x399b` bark; and both food flags clear to confirm the default `0x398a` path.
 - Immediate: run an in-engine Fireman1 validation pass that explicitly covers the recovered native-only follow-ups: first talk with neither flag set to confirm only `0x453`; a revisit with neither flag set to confirm only `0x463`; a talk after `STEPH_MIDGAME_PLAYED` to confirm the appended `0x45a[3]` bark; a separate talk after `BOLT_OF_CLOTH_TAKEN` to confirm the appended `0x46a[2]` bark; and a save/load cycle after each follow-up has fired once to verify both Fireman1-local gates persist and do not replay on the next revisit.
 - Immediate: run an in-engine Sparky validation pass that explicitly hits the corrected native-only paths: `PC_TRIES_TO_TURN_ON_LIGHT`; any non-empty presented item for the `0x3c7[2]` rejection bark; the intro through all three `0x2be` responses to confirm `0x2f7[1]`, `0x2fe[2]`, `0x30d[1]`, the `SPARKY_SPOTS_MEAT` action tag, and the `C135/C098/C096` FST inserts; same-day and later-day revisits to confirm `0x3bb[1]` versus `0x3ac[1] -> 0x3b0 -> 0x3b4`; and the keyword loop starting from the native empty menu so typed topics `0x2c4/0x2c5`, `0x2c7`, `0x2c8`, `0x2ca/0x2cb`, and an unknown topic verify the restored `0x34c[1]`, `0x35f[0]`, `0x36c[0]`, `0x36d[0]`, `0x37a[3]`, `0x38f[4]`, `0x39e[1]`, and `0x3c1[1]` portrait variants plus the non-persistent `0x2c9` topic-list rewrite.
 - Immediate: run an in-engine Phelps validation pass that explicitly hits the corrected native-only portrait flow: first no-item talk to confirm the `0x1b44[1] -> 0x1b48 -> 0x1b4c -> 0x1b52 -> 0x1b56[1] -> 0x1b5a` intro and the delayed `d2f04` write; a later day-1 to day-4 revisit through both `0x22a` responses to confirm `0x1bf4[1]`, `0x1b7b[1]`, `0x1b85[0]`, and `0x1bbc[1] -> 0x1c44[1]`; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, note/checkbook evidence, and `QUARTER` to verify the recovered portrait variants plus `GET_PRN_MAG` / `DEL_DRT_MAG`; and separate revisits for `STEPH_MIDGAME_PLAYED`, day 5, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `PC_ESCAPED_JAIL`, Jimmy death response menu `0x22c`, `KARIN_FOUND_DEAD`, and `KARIN_KIDNAPED` to confirm the restored one-shot bark variants and shared kidnapped-dialogue write.
diff --git a/engines/harvester/npc/authority_dialogue.cpp b/engines/harvester/npc/authority_dialogue.cpp
index 2de5a6c005f..379ef6931cb 100644
--- a/engines/harvester/npc/authority_dialogue.cpp
+++ b/engines/harvester/npc/authority_dialogue.cpp
@@ -27,8 +27,14 @@
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kAuthorityNpc = "AUTHORITY";
+
+} // End of namespace
+
 bool AuthorityDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("AUTHORITY");
+	return npcName.equalsIgnoreCase(kAuthorityNpc);
 }
 
 Common::Error AuthorityDialogueHandler::handleDialogue(DialogueRuntime &runtime,
@@ -36,14 +42,22 @@ Common::Error AuthorityDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (runtime.startupScript().getFlagValue("STOP_AUTHOR_TALK"))
 		return Common::kNoError;
 
-	if (runtime.startupScript().getFlagValue("IF_DON_T_EAT_THE_FOOD"))
-		return runtime.playDialogueLine(0x3992, "AUTHORITY");
+	if (runtime.startupScript().getFlagValue("IF_DON_T_EAT_THE_FOOD")) {
+		Common::Error lineError = runtime.playDialogueLine(0x3992, kAuthorityNpc);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		if (runtime.startupScript().getFlagValue("IF_YOU_EAT_THE_FOOD_AND_DEFEAT_THE_ENEMIES"))
+			return runtime.playDialogueLine(0x399b, kAuthorityNpc);
+
+		return Common::kNoError;
+	}
 
 	return runtime.playDialogueLine(
 		runtime.startupScript().getFlagValue("IF_YOU_EAT_THE_FOOD_AND_DEFEAT_THE_ENEMIES")
 			? 0x399b
 			: 0x398a,
-		"AUTHORITY");
+		kAuthorityNpc);
 }
 
 } // End of namespace Harvester


Commit: 51e762a4e537e9993ef4053dd6de8692dfa3b055
    https://github.com/scummvm/scummvm/commit/51e762a4e537e9993ef4053dd6de8692dfa3b055
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:02+02:00

Commit Message:
HARVESTER: Improve FST format handling

Changed paths:
    engines/harvester/fst_player.cpp


diff --git a/engines/harvester/fst_player.cpp b/engines/harvester/fst_player.cpp
index f1f438b1dc1..c08c0bc61f7 100644
--- a/engines/harvester/fst_player.cpp
+++ b/engines/harvester/fst_player.cpp
@@ -49,7 +49,8 @@ struct FstHeader {
 	uint32 frameCount = 0;
 	uint32 frameRate = 0;
 	uint32 sampleRate = 0;
-	uint32 bitsPerSample = 0;
+	uint16 bitsPerSample = 0;
+	uint16 unknown2 = 0;
 };
 
 struct FstFrameEntry {
@@ -89,6 +90,7 @@ private:
 };
 
 static const uint32 kFstMagic = MKTAG('F', 'S', 'T', '2');
+static const uint32 kFstAltMagic = MKTAG('2', 'T', 'S', 'F');
 static const FstCensorshipToggleEntry kFstCensorshipToggleEntries[] = {
 	{ "C001B.FST", { 170, 369, -1, -1, -1, -1 } },
 	{ "C004.FST", { 23, 174, 860, 995, 1834, -1 } },
@@ -126,6 +128,37 @@ static void convertVgaPalette(const byte *source, byte *dest) {
 		dest[i] = expand6BitColor(source[i]);
 }
 
+static bool isValidFstMagic(uint32 magic) {
+	// The published format docs describe the header as "2TSF", while the
+	// recovered Harvester samples and native notes use the little-endian FST2 tag.
+	return magic == kFstMagic || magic == kFstAltMagic;
+}
+
+static uint32 getTrailingFstAudioFrameSkip(const Common::Array<FstFrameEntry> &frames) {
+	if (frames.size() < 2)
+		return 0;
+
+	const uint16 firstFrameAudioSize = frames[0].audioSize;
+	const uint16 steadyStateAudioSize = frames[1].audioSize;
+	if (firstFrameAudioSize == 0 || steadyStateAudioSize == 0 || firstFrameAudioSize <= steadyStateAudioSize)
+		return 0;
+	if ((firstFrameAudioSize % steadyStateAudioSize) != 0)
+		return 0;
+
+	const uint32 skipFrameCount = firstFrameAudioSize / steadyStateAudioSize - 1;
+	if (skipFrameCount == 0 || skipFrameCount >= frames.size())
+		return 0;
+
+	// FutureVision docs note that some files front-load multiple frames of PCM
+	// into frame 0 and expect the corresponding tail chunks to be ignored.
+	for (uint32 i = frames.size() - skipFrameCount; i < frames.size(); ++i) {
+		if (frames[i].audioSize != steadyStateAudioSize)
+			return 0;
+	}
+
+	return skipFrameCount;
+}
+
 static const FstCensorshipToggleEntry *findCensorshipToggleEntry(const Common::String &path) {
 	const Common::String basename = Common::lastPathComponent(normalizeHarvesterResourcePath(path), '/');
 
@@ -361,9 +394,10 @@ bool FstPlayer::play(const Common::String &path) {
 	header.frameCount = stream->readUint32LE();
 	header.frameRate = stream->readUint32LE();
 	header.sampleRate = stream->readUint32LE();
-	header.bitsPerSample = stream->readUint32LE();
+	header.bitsPerSample = stream->readUint16LE();
+	header.unknown2 = stream->readUint16LE();
 
-	if (header.magic != kFstMagic || header.width == 0 || header.height == 0 || header.frameCount == 0 || header.frameRate == 0) {
+	if (!isValidFstMagic(header.magic) || header.width == 0 || header.height == 0 || header.frameCount == 0 || header.frameRate == 0) {
 		warning("Harvester: invalid FST header in '%s'", path.c_str());
 		return false;
 	}
@@ -419,14 +453,17 @@ bool FstPlayer::play(const Common::String &path) {
 	Audio::SoundHandle audioHandle;
 	Audio::QueuingAudioStream *audioStream = nullptr;
 	const bool hasPcmAudio = header.sampleRate != 0 && header.bitsPerSample == 16;
+	const uint32 trailingAudioFrameSkip = hasPcmAudio ? getTrailingFstAudioFrameSkip(frames) : 0;
 	if (hasPcmAudio)
 		audioStream = Audio::makeQueuingAudioStream(header.sampleRate, false);
+	else if (header.sampleRate != 0 && header.bitsPerSample != 0)
+		warning("Harvester: unsupported FST audio format %u-bit in '%s'", header.bitsPerSample, path.c_str());
 
 	bool audioStarted = false;
 	bool skipRequested = false;
 	bool deferredSkipRequested = false;
 	uint32 nextFrameTime = g_system->getMillis();
-	const uint32 audioByteRate = hasPcmAudio ? header.sampleRate * 2 : 0;
+	const uint32 audioByteRate = hasPcmAudio ? header.sampleRate * (header.bitsPerSample / 8) : 0;
 	const uint32 audioBytesPerFrame = (audioByteRate != 0 && header.frameRate != 0) ? audioByteRate / header.frameRate : 0;
 	uint64 nextAudioSyncBytes = 0;
 	Audio::Mixer *mixer = g_system->getMixer();
@@ -444,7 +481,8 @@ bool FstPlayer::play(const Common::String &path) {
 			break;
 		}
 
-		if (audioStream && frames[frameIndex].audioSize != 0) {
+		if (audioStream && frames[frameIndex].audioSize != 0 &&
+				frameIndex + trailingAudioFrameSkip < header.frameCount) {
 			byte *audioBuffer = (byte *)malloc(frames[frameIndex].audioSize);
 			if (!audioBuffer)
 				error("Harvester: unable to allocate audio buffer");


Commit: b535e1e4f0cdb5bd1d66509dedc58c69b5eb5319
    https://github.com/scummvm/scummvm/commit/b535e1e4f0cdb5bd1d66509dedc58c69b5eb5319
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:02+02:00

Commit Message:
HARVESTER: Recover beggar dialogue flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/beggar_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index eee8400e39f..52f7e079eb4 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -867,6 +867,8 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
     - It writes the matching record's `active` / `visible` bytes, removes the current-room render entity when visibility clears, respawns it through `spawn_npc_entity_from_record` when visibility becomes set, and mirrors the `active` byte onto the live entity state at `+0x52`.
   - `queue_named_npc_death_or_monsterfy_transition` at `0x7c940` is the name-based `KILL_NPC` / `MONSTERFY` helper.
     - It resolves the live NPC render entity when present, translates `BLUDGE` / `SLASH` / `PROJ` into the pending damage-type values `1` / `2` / `4`, queues `queue_npc_death_or_monsterfy_transition`, and preserves the pending damage-type selection on the record when no live entity exists yet.
+  - `queue_live_named_npc_death_or_monsterfy_transition` at `0x7ca90` is the live-entity-only wrapper used by the Beggar talk path.
+    - It resolves the named live NPC render entity and forwards to `queue_npc_death_or_monsterfy_transition` only when that render entity exists, without touching the persisted `NpcRecord`.
   - `is_named_npc_death_type_clear` at `0x7c840` is the talk-handler predicate shared by `handle_talk_to_mom` and `handle_talk_to_dwayne`.
     - It resolves the named `NpcRecord` and returns true only while `death_damage_type` is still zero, so dialogue branches stop treating that NPC as available after a persisted kill / monsterfy transition has been latched.
   - `room_setup` walks the room-matching NPC list after room animations and before the player handoff, which is why `PCLIVRM` needs the `SET_PC_LIV_DAY -> SET_NPC "HANK" "T" "T"` startup chain to materialize Hank in the living room.
diff --git a/TRACKER.md b/TRACKER.md
index 53b752a5741..ed1b55c99bf 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -4,12 +4,15 @@
 
 - Program: `HARVEST.LE`
 - Total functions: `919`
-- Named/documented: `805`
-- Remaining `FUN_*`: `114`
-- Remaining undocumented total: `114`
+- Named/documented: `806`
+- Remaining `FUN_*`: `113`
+- Remaining undocumented total: `113`
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Beggar dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_beggar` (`0x34d40..0x34e29`). The old stub in [`engines/harvester/npc/beggar_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/beggar_dialogue.cpp) collapsed the native flow into the `BEGGAR_DIALOG_3` action-tag dispatch plus a single `0x1276[1]` line, while the recovered handler is a fixed two-response exchange: `0x1276[1] -> 0x127a[2] -> 0x127e[0]`, response line `0x0` selecting `0x128d[1]` or `0x1291[3]`, then `0x1295[0]`, response line `0x1`, and `0x12ab[2]` or `0x12a5[2]`. Patched [`engines/harvester/npc/beggar_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/beggar_dialogue.cpp) to match that control flow, kept the native `BEGGAR_DIALOG_3` action-tag dispatch at the top, and renamed the directly evidenced live-entity tail helper `FUN_0007ca90` at `0x7ca90` to `queue_live_named_npc_death_or_monsterfy_transition`. That helper only resolves the named live NPC entity and queues the already-documented actor state `0x35`, so the current ScummVM port still relies on the action-tag interaction path for room-state mutation while the exact visual tail remains an engine-side follow-up rather than a guessed rewrite. Live progress is now `919` total functions, `806` named/documented, and `113` remaining `FUN_*`.
+- On March 23, 2026, reran the full durable `repair BEGGAR -> wait -> save_program -> verify BEGGAR` cycle against the live `HARVEST.LE` session. `handle_talk_to_beggar @ 0x34d40` started as a truncated `0x34d40..0x34e29` body (`55` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x34d40..0x34e29` (`234` address-count) with `6` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify BEGGAR` still reported `status=clean` with both dialogue entry points returning, so Beggar is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
+- On March 23, 2026, recreated [`OPCODES.md`](/Users/alex/Workspace/scummvm/OPCODES.md) in the repo and expanded it with explicit documentation for the Harvester script command-label model. The new notes distinguish `triggerTag` as the stored `COMMAND` label from the interpreter's runtime `currentTag`, cite the exact lookup and execution sites in `script.cpp`, and add concrete straight-line, branching, and deferred-continuation examples so the command graph is described in terms of how ScummVM actually walks it. Progress counts are unchanged because this was documentation only.
 - On March 23, 2026, reimplemented the ScummVM Authority dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_authority` (`0x2b730..0x2b7b2`). The confirmed source drift in [`engines/harvester/npc/authority_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/authority_dialogue.cpp) was small but real: the old stub collapsed the native `IF_DON_T_EAT_THE_FOOD` branch into a single `0x3992` bark, while the recovered handler actually falls through to a second `0x399b` Authority line when `IF_YOU_EAT_THE_FOOD_AND_DEFEAT_THE_ENEMIES` is also set. Patched the ScummVM handler to preserve the native early `STOP_AUTHOR_TALK` exit, restore that two-line food branch, and keep the `0x398a` versus `0x399b` fallback when the earlier food flag is clear. While auditing the same dialogue helper cluster, also renamed the directly evidenced one-call `play_fst_sequence` wrappers at `0x38700`, `0x38710`, and `0x38720` to `play_c101_fst_sequence`, `play_c021_fst_sequence`, and `play_c110_fst_sequence`, then saved `HARVEST.LE`. Live progress is now `919` total functions, `805` named/documented, and `114` remaining `FUN_*`.
 - On March 23, 2026, reran the full durable `repair AUTHORITY -> wait -> save_program -> verify AUTHORITY` cycle against the live `HARVEST.LE` session because Authority had regressed back to the short body in the saved database. `handle_talk_to_authority @ 0x2b730` started again as a truncated `0x2b730..0x2b7b2` body (`95` address-count) with `2` reachable dialogue calls and `2` stale `CALL_RETURN` gaps. A single repair pass rebuilt it cleanly to `0x2b730..0x2b7b2` (`131` address-count) with `3` reachable dialogue calls, `gapCount=0`, and `4` repair actions. After the normal settle and explicit save, a fresh `verify AUTHORITY` still reported `status=clean` with both dialogue entry points returning, so Authority is now durably clean again in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reran the full durable `repair FIREMAN2 -> wait -> save_program -> verify FIREMAN2` cycle against the live `HARVEST.LE` session. `handle_talk_to_fireman2 @ 0x33940` started as a truncated `0x33940..0x33979` body (`58` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x33940..0x339e9` (`170` address-count) with `3` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify FIREMAN2` still reported `status=clean` with both dialogue entry points returning, so Fireman2 is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
@@ -95,6 +98,8 @@
 
 ## Next Suggested Action
 
+- Immediate: decode one shipped `HARVEST.SCR` sample and lift a few real `COMMAND` clusters into [`OPCODES.md`](/Users/alex/Workspace/scummvm/OPCODES.md) so the new `triggerTag` / `currentTag` section includes native examples instead of illustrative ones. Keep the examples tied to records and continuations that are actually present in data.
+- Immediate: run an in-engine Beggar validation pass that explicitly hits the recovered native-only dialogue tree: trigger the `BEGGAR_INTERRUPT_CONVERSATION` path, take both `dialog.rsp[0]` answers to confirm `0x128d[1]` versus `0x1291[3]`, then both `dialog.rsp[1]` answers to confirm `0x12ab[2]` versus `0x12a5[2]`, and verify that `BEGGAR_DIALOG_3` still produces the intended post-dialogue room mutation / monsterfy result once the conversation exits.
 - Immediate: run an in-engine Authority validation pass that explicitly covers all recovered native branches: `STOP_AUTHOR_TALK` set to confirm silent early return; `IF_DON_T_EAT_THE_FOOD` alone to confirm only `0x3992`; both food flags set to confirm `0x3992` followed by `0x399b`; `IF_DON_T_EAT_THE_FOOD` clear with only `IF_YOU_EAT_THE_FOOD_AND_DEFEAT_THE_ENEMIES` set to confirm the direct `0x399b` bark; and both food flags clear to confirm the default `0x398a` path.
 - Immediate: run an in-engine Fireman1 validation pass that explicitly covers the recovered native-only follow-ups: first talk with neither flag set to confirm only `0x453`; a revisit with neither flag set to confirm only `0x463`; a talk after `STEPH_MIDGAME_PLAYED` to confirm the appended `0x45a[3]` bark; a separate talk after `BOLT_OF_CLOTH_TAKEN` to confirm the appended `0x46a[2]` bark; and a save/load cycle after each follow-up has fired once to verify both Fireman1-local gates persist and do not replay on the next revisit.
 - Immediate: run an in-engine Sparky validation pass that explicitly hits the corrected native-only paths: `PC_TRIES_TO_TURN_ON_LIGHT`; any non-empty presented item for the `0x3c7[2]` rejection bark; the intro through all three `0x2be` responses to confirm `0x2f7[1]`, `0x2fe[2]`, `0x30d[1]`, the `SPARKY_SPOTS_MEAT` action tag, and the `C135/C098/C096` FST inserts; same-day and later-day revisits to confirm `0x3bb[1]` versus `0x3ac[1] -> 0x3b0 -> 0x3b4`; and the keyword loop starting from the native empty menu so typed topics `0x2c4/0x2c5`, `0x2c7`, `0x2c8`, `0x2ca/0x2cb`, and an unknown topic verify the restored `0x34c[1]`, `0x35f[0]`, `0x36c[0]`, `0x36d[0]`, `0x37a[3]`, `0x38f[4]`, `0x39e[1]`, and `0x3c1[1]` portrait variants plus the non-persistent `0x2c9` topic-list rewrite.
diff --git a/engines/harvester/npc/beggar_dialogue.cpp b/engines/harvester/npc/beggar_dialogue.cpp
index b067da6508f..c135d983422 100644
--- a/engines/harvester/npc/beggar_dialogue.cpp
+++ b/engines/harvester/npc/beggar_dialogue.cpp
@@ -29,17 +29,28 @@ namespace Harvester {
 
 namespace {
 
+static const char *const kBeggarNpc = "BEGGAR";
+static const char *const kPcSpeaker = "PC";
 static const char *const kBeggarInterruptFlag = "BEGGAR_INTERRUPT_CONVERSATION";
 static const char *const kBeggarDialog3ActionTag = "BEGGAR_DIALOG_3";
+static const int kBeggarInitialResponseLine = 0x0;
+static const int kBeggarFollowupResponseLine = 0x1;
 
 } // End of namespace
 
 bool BeggarDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("BEGGAR");
+	return npcName.equalsIgnoreCase(kBeggarNpc);
 }
 
 Common::Error BeggarDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
+	auto playBeggarLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kBeggarNpc, headVariant);
+	};
+	auto playPcLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kPcSpeaker, headVariant);
+	};
+
 	if (!runtime.startupScript().getFlagValue(kBeggarInterruptFlag))
 		return Common::kNoError;
 
@@ -49,7 +60,50 @@ Common::Error BeggarDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 	}
 
-	return runtime.playDialogueLineWithVariant(0x1276, "BEGGAR", 1);
+	Common::Error lineError = playBeggarLine(0x1276, 1);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = playPcLine(0x127a, 2);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = playBeggarLine(0x127e);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(
+		kBeggarInitialResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1) {
+		lineError = playBeggarLine(0x128d, 1);
+	} else if (responseIndex == 2) {
+		lineError = playBeggarLine(0x1291, 3);
+	} else {
+		return Common::kNoError;
+	}
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = playBeggarLine(0x1295);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	responseIndex = 0;
+	responseError = runtime.runResponseMenu(
+		kBeggarFollowupResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1)
+		return playBeggarLine(0x12ab, 2);
+	if (responseIndex == 2)
+		return playBeggarLine(0x12a5, 2);
+
+	return Common::kNoError;
 }
 
 } // End of namespace Harvester


Commit: f293221f2034a2c62f76f189b83b1eff7ff3f95e
    https://github.com/scummvm/scummvm/commit/f293221f2034a2c62f76f189b83b1eff7ff3f95e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:03+02:00

Commit Message:
HARVESTER: Reimplement chessmaster dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/chessmaster_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index ed1b55c99bf..1b3d02c59d0 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,15 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `919`
-- Named/documented: `806`
-- Remaining `FUN_*`: `113`
-- Remaining undocumented total: `113`
+- Total functions: `926`
+- Named/documented: `807`
+- Remaining `FUN_*`: `119`
+- Remaining undocumented total: `119`
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Chessmaster dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_chessmaster` (`0x2d900..0x2d9ac`). The old stub in [`engines/harvester/npc/chessmaster_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/chessmaster_dialogue.cpp) only played `0x4115`, while the recovered native flow is `0x4115`, response menu `0x22`, line `0x4122[1]` before a three-stage fullscreen chess puzzle, then either the success tail `CHESS_SOLVED -> C125.FST -> queued Chessmaster slash death` or the failure tail `0x4126[2] -> ADD_CHESS_KING_MONSTER -> KINGGROW.FST`. Patched the ScummVM handler to mirror that control flow, implemented the native `CHOOSE1/2/3.FLC` plus `CHESMOV1/2/3.FLC` puzzle sequence with the confirmed hotspot rectangles from Ghidra, renamed `FUN_0005a550` at `0x5a550` to `run_chessmaster_three_move_puzzle`, saved `HARVEST.LE`, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Live progress is now `926` total functions, `807` named/documented, and `119` remaining `FUN_*`.
+- On March 23, 2026, reran the full durable `repair CHESSMASTER -> wait -> save_program -> verify CHESSMASTER` cycle against the live `HARVEST.LE` session. `handle_talk_to_chessmaster @ 0x2d900` started as a truncated `0x2d900..0x2d914` body (`21` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. The repair needed two in-memory passes: pass 1 expanded the handler to `0x2d900..0x2d9ac` with `3` dialogue calls but left `1` newly exposed gap, and pass 2 finished the rebuild cleanly at `0x2d900..0x2d9ac` (`173` address-count) with `3` reachable dialogue calls, `gapCount=0`, and `6` total repair actions. After the normal settle and explicit save, a fresh `verify CHESSMASTER` still reported `status=clean` with both dialogue entry points returning, so Chessmaster is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Beggar dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_beggar` (`0x34d40..0x34e29`). The old stub in [`engines/harvester/npc/beggar_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/beggar_dialogue.cpp) collapsed the native flow into the `BEGGAR_DIALOG_3` action-tag dispatch plus a single `0x1276[1]` line, while the recovered handler is a fixed two-response exchange: `0x1276[1] -> 0x127a[2] -> 0x127e[0]`, response line `0x0` selecting `0x128d[1]` or `0x1291[3]`, then `0x1295[0]`, response line `0x1`, and `0x12ab[2]` or `0x12a5[2]`. Patched [`engines/harvester/npc/beggar_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/beggar_dialogue.cpp) to match that control flow, kept the native `BEGGAR_DIALOG_3` action-tag dispatch at the top, and renamed the directly evidenced live-entity tail helper `FUN_0007ca90` at `0x7ca90` to `queue_live_named_npc_death_or_monsterfy_transition`. That helper only resolves the named live NPC entity and queues the already-documented actor state `0x35`, so the current ScummVM port still relies on the action-tag interaction path for room-state mutation while the exact visual tail remains an engine-side follow-up rather than a guessed rewrite. Live progress is now `919` total functions, `806` named/documented, and `113` remaining `FUN_*`.
 - On March 23, 2026, reran the full durable `repair BEGGAR -> wait -> save_program -> verify BEGGAR` cycle against the live `HARVEST.LE` session. `handle_talk_to_beggar @ 0x34d40` started as a truncated `0x34d40..0x34e29` body (`55` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x34d40..0x34e29` (`234` address-count) with `6` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify BEGGAR` still reported `status=clean` with both dialogue entry points returning, so Beggar is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, recreated [`OPCODES.md`](/Users/alex/Workspace/scummvm/OPCODES.md) in the repo and expanded it with explicit documentation for the Harvester script command-label model. The new notes distinguish `triggerTag` as the stored `COMMAND` label from the interpreter's runtime `currentTag`, cite the exact lookup and execution sites in `script.cpp`, and add concrete straight-line, branching, and deferred-continuation examples so the command graph is described in terms of how ScummVM actually walks it. Progress counts are unchanged because this was documentation only.
@@ -98,6 +100,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Chessmaster validation pass that explicitly hits both menu responses and all three puzzle outcomes: response `1` with the native correct hotspot on each of `CHOOSE1`, `CHOOSE2`, and `CHOOSE3` to confirm `CHESS_SOLVED`, `C125.FST`, and the queued Chessmaster slash-death transition; response `1` with a wrong hotspot on each stage to confirm the immediate `0x4126[2] -> ADD_CHESS_KING_MONSTER -> KINGGROW.FST` fail path; and response `2` or a canceled menu selection to confirm the puzzle is skipped and the same monster branch fires directly.
 - Immediate: decode one shipped `HARVEST.SCR` sample and lift a few real `COMMAND` clusters into [`OPCODES.md`](/Users/alex/Workspace/scummvm/OPCODES.md) so the new `triggerTag` / `currentTag` section includes native examples instead of illustrative ones. Keep the examples tied to records and continuations that are actually present in data.
 - Immediate: run an in-engine Beggar validation pass that explicitly hits the recovered native-only dialogue tree: trigger the `BEGGAR_INTERRUPT_CONVERSATION` path, take both `dialog.rsp[0]` answers to confirm `0x128d[1]` versus `0x1291[3]`, then both `dialog.rsp[1]` answers to confirm `0x12ab[2]` versus `0x12a5[2]`, and verify that `BEGGAR_DIALOG_3` still produces the intended post-dialogue room mutation / monsterfy result once the conversation exits.
 - Immediate: run an in-engine Authority validation pass that explicitly covers all recovered native branches: `STOP_AUTHOR_TALK` set to confirm silent early return; `IF_DON_T_EAT_THE_FOOD` alone to confirm only `0x3992`; both food flags set to confirm `0x3992` followed by `0x399b`; `IF_DON_T_EAT_THE_FOOD` clear with only `IF_YOU_EAT_THE_FOOD_AND_DEFEAT_THE_ENEMIES` set to confirm the direct `0x399b` bark; and both food flags clear to confirm the default `0x398a` path.
diff --git a/engines/harvester/npc/chessmaster_dialogue.cpp b/engines/harvester/npc/chessmaster_dialogue.cpp
index 9c87a7b9345..0f9fe27e4a8 100644
--- a/engines/harvester/npc/chessmaster_dialogue.cpp
+++ b/engines/harvester/npc/chessmaster_dialogue.cpp
@@ -23,17 +23,428 @@
 
 #include "harvester/npc/chessmaster_dialogue.h"
 
+#include "common/events.h"
+#include "common/system.h"
+#include "graphics/blit.h"
+#include "graphics/framelimiter.h"
+#include "graphics/screen.h"
+#include "harvester/harvester.h"
 #include "harvester/npc/dialogue_runtime.h"
+#include "harvester/resources.h"
+#include "video/flic_decoder.h"
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kChessmasterNpc = "CHESSMASTER";
+static const char *const kChessSolvedFlag = "CHESS_SOLVED";
+static const char *const kChessKingMonsterActionTag = "ADD_CHESS_KING_MONSTER";
+static const int kChessmasterSlashDamageType = 2;
+static const byte kTransparentPaletteIndex = 0;
+static const uint32 kChessmasterSuccessHoldMs = 1000;
+
+struct ChessmasterHotspot {
+	int left;
+	int top;
+	int right;
+	int bottom;
+};
+
+struct ChessmasterPuzzleStage {
+	const char *choiceFlicPath;
+	const char *moveFlicPath;
+	ChessmasterHotspot successHotspot;
+	const ChessmasterHotspot *failureHotspots;
+	uint failureHotspotCount;
+};
+
+struct ChessmasterFlicState {
+	Video::FlicDecoder decoder;
+	const Graphics::Surface *frame = nullptr;
+	uint32 nextFrameTick = 0;
+};
+
+static const ChessmasterHotspot kChessmasterStage1FailureHotspots[] = {
+	{ 0xbc, 0xa0, 0xe8, 0xc3 },
+	{ 0xfd, 0x8c, 0x130, 0xac },
+	{ 0x166, 0x9a, 0x19c, 0xbc },
+	{ 0x194, 0xbe, 0x1ce, 0xe5 },
+	{ 0x17f, 0x106, 0x1c3, 0x13a },
+	{ 0x12c, 0x126, 0x16d, 0x15f },
+	{ 0xac, 0x112, 0xdf, 0x145 }
+};
+
+static const ChessmasterHotspot kChessmasterStage2FailureHotspots[] = {
+	{ 0xab, 0x10f, 0xdf, 0x144 }
+};
+
+static const ChessmasterHotspot kChessmasterStage3FailureHotspots[] = {
+	{ 0x113, 0xd0, 0x14b, 0xf8 },
+	{ 0xeb, 0x11b, 0x127, 0x14f },
+	{ 0x87, 0x9a, 0xb1, 0xbb }
+};
+
+static const ChessmasterPuzzleStage kChessmasterPuzzleStages[] = {
+		{
+		"GRAPHIC/FST/CHOOSE1.FLC",
+		"GRAPHIC/FST/CHESMOV1.FLC",
+		{ 0x8b, 0xe0, 0xc0, 0x10d },
+		kChessmasterStage1FailureHotspots,
+		ARRAYSIZE(kChessmasterStage1FailureHotspots)
+	},
+	{
+		"GRAPHIC/FST/CHOOSE2.FLC",
+		"GRAPHIC/FST/CHESMOV2.FLC",
+		{ 0x2a, 0xfa, 0x5f, 0x12e },
+		kChessmasterStage2FailureHotspots,
+		ARRAYSIZE(kChessmasterStage2FailureHotspots)
+	},
+	{
+		"GRAPHIC/FST/CHOOSE3.FLC",
+		"GRAPHIC/FST/CHESMOV3.FLC",
+		{ 0xee, 0xa7, 0x121, 0xcb },
+		kChessmasterStage3FailureHotspots,
+		ARRAYSIZE(kChessmasterStage3FailureHotspots)
+	}
+};
+
+static bool isPrimaryMouseDown() {
+	return g_system && g_system->getEventManager() &&
+		((g_system->getEventManager()->getButtonState() & 1) != 0);
+}
+
+static bool pointHitsHotspot(const Common::Point &point, const ChessmasterHotspot &hotspot) {
+	return point.x >= hotspot.left && point.x < hotspot.right &&
+		point.y >= hotspot.top && point.y < hotspot.bottom;
+}
+
+static bool pointHitsAnyHotspot(const Common::Point &point, const ChessmasterHotspot *hotspots,
+		uint hotspotCount) {
+	for (uint i = 0; i < hotspotCount; ++i) {
+		if (pointHitsHotspot(point, hotspots[i]))
+			return true;
+	}
+
+	return false;
+}
+
+static void blitTransparentFrame(Graphics::Screen &screen, const Graphics::Surface &frame, int x, int y) {
+	if (frame.w <= 0 || frame.h <= 0)
+		return;
+
+	int destX = x;
+	int destY = y;
+	int srcX = 0;
+	int srcY = 0;
+	int width = frame.w;
+	int height = frame.h;
+
+	if (destX < 0) {
+		srcX = -destX;
+		width += destX;
+		destX = 0;
+	}
+	if (destY < 0) {
+		srcY = -destY;
+		height += destY;
+		destY = 0;
+	}
+	if (destX >= screen.w || destY >= screen.h || width <= 0 || height <= 0)
+		return;
+
+	width = MIN<int>(width, screen.w - destX);
+	height = MIN<int>(height, screen.h - destY);
+	if (width <= 0 || height <= 0)
+		return;
+
+	const byte *src = (const byte *)frame.getBasePtr(srcX, srcY);
+	byte *dst = (byte *)screen.getBasePtr(destX, destY);
+	Graphics::keyBlit(dst, src, screen.pitch, frame.pitch, width, height,
+		screen.format.bytesPerPixel, kTransparentPaletteIndex);
+}
+
+static Common::Error decodeInitialFlicFrame(DialogueRuntime &runtime, const char *path,
+		ChessmasterFlicState &state) {
+	ResourceManager *resources = runtime.engine().getResources();
+	if (!resources || !state.decoder.loadStream(resources->openFile(path)))
+		return Common::kReadingFailed;
+
+	state.frame = state.decoder.decodeNextFrame();
+	if (!state.frame)
+		return Common::kReadingFailed;
+
+	if (state.decoder.getCurFrameDelay() > 0 && g_system)
+		state.nextFrameTick = g_system->getMillis() + (uint32)state.decoder.getCurFrameDelay();
+	else
+		state.nextFrameTick = 0;
+
+	return Common::kNoError;
+}
+
+static Common::Error advanceFlicIfNeeded(ChessmasterFlicState &state, bool loop) {
+	if (!g_system)
+		return Common::kNoError;
+	if (state.nextFrameTick != 0 && (int32)(state.nextFrameTick - g_system->getMillis()) > 0)
+		return Common::kNoError;
+
+	if (loop && state.decoder.endOfVideo())
+		state.decoder.rewind();
+
+	const Graphics::Surface *nextFrame = state.decoder.decodeNextFrame();
+	if (!nextFrame) {
+		if (!loop || !state.decoder.rewind())
+			return Common::kReadingFailed;
+
+		nextFrame = state.decoder.decodeNextFrame();
+		if (!nextFrame)
+			return Common::kReadingFailed;
+	}
+
+	state.frame = nextFrame;
+	const int frameDelay = state.decoder.getCurFrameDelay();
+	state.nextFrameTick = frameDelay > 0 ? g_system->getMillis() + (uint32)frameDelay : 0;
+	return Common::kNoError;
+}
+
+static void drawFlicFrame(Graphics::Screen &screen, ChessmasterFlicState &state) {
+	if (state.decoder.hasDirtyPalette())
+		screen.setPalette(state.decoder.getPalette());
+	if (state.frame)
+		screen.blitFrom(*state.frame);
+}
+
+static Common::Error waitForPointerRelease() {
+	if (!g_system)
+		return Common::kNoError;
+
+	Graphics::FrameLimiter limiter(g_system, 60);
+	while (!SHOULD_QUIT && isPrimaryMouseDown()) {
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			if (event.type == Common::EVENT_QUIT || event.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return Common::kNoError;
+		}
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+static Common::Error runTimedDelay(uint32 durationMs) {
+	if (!g_system || durationMs == 0)
+		return Common::kNoError;
+
+	const uint32 deadline = g_system->getMillis() + durationMs;
+	Graphics::FrameLimiter limiter(g_system, 60);
+	while (!SHOULD_QUIT && (int32)(deadline - g_system->getMillis()) > 0) {
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			if (event.type == Common::EVENT_QUIT || event.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return Common::kNoError;
+		}
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+static Common::Error playOneShotFlic(DialogueRuntime &runtime, const char *path) {
+	Graphics::Screen *screen = runtime.engine().getScreen();
+	if (!screen)
+		return Common::kReadingFailed;
+
+	ChessmasterFlicState video;
+	Common::Error loadError = decodeInitialFlicFrame(runtime, path, video);
+	if (loadError.getCode() != Common::kNoError)
+		return loadError;
+
+	Graphics::FrameLimiter limiter(g_system, 60);
+	for (;;) {
+		Common::Error advanceError = advanceFlicIfNeeded(video, false);
+		if (advanceError.getCode() != Common::kNoError)
+			return advanceError;
+
+		drawFlicFrame(*screen, video);
+		screen->makeAllDirty();
+		screen->update();
+
+		if (video.decoder.endOfVideo())
+			return Common::kNoError;
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			if (event.type == Common::EVENT_QUIT || event.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return Common::kNoError;
+		}
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+}
+
+static Common::Error runChessmasterChoiceStage(DialogueRuntime &runtime,
+		const ChessmasterPuzzleStage &stage, bool &selectedSuccessHotspot) {
+	selectedSuccessHotspot = false;
+
+	Graphics::Screen *screen = runtime.engine().getScreen();
+	ResourceManager *resources = runtime.engine().getResources();
+	if (!screen || !resources || !g_system || !g_system->getEventManager())
+		return Common::kReadingFailed;
+
+	ChessmasterFlicState background;
+	Common::Error loadError = decodeInitialFlicFrame(runtime, stage.choiceFlicPath, background);
+	if (loadError.getCode() != Common::kNoError)
+		return loadError;
+
+	ChessmasterFlicState pointer;
+	loadError = decodeInitialFlicFrame(runtime, "GRAPHIC/ROOMANIM/HARVPNTR.FLC", pointer);
+	if (loadError.getCode() != Common::kNoError)
+		return loadError;
+
+	ChessmasterFlicState operateCursor;
+	loadError = decodeInitialFlicFrame(runtime, "GRAPHIC/ROOMANIM/OPERATE.FLC", operateCursor);
+	if (loadError.getCode() != Common::kNoError)
+		return loadError;
+
+	loadError = waitForPointerRelease();
+	if (loadError.getCode() != Common::kNoError)
+		return loadError;
+
+	Graphics::FrameLimiter limiter(g_system, 60);
+	while (!SHOULD_QUIT) {
+		Common::Error advanceError = advanceFlicIfNeeded(background, true);
+		if (advanceError.getCode() != Common::kNoError)
+			return advanceError;
+		advanceError = advanceFlicIfNeeded(pointer, true);
+		if (advanceError.getCode() != Common::kNoError)
+			return advanceError;
+		advanceError = advanceFlicIfNeeded(operateCursor, true);
+		if (advanceError.getCode() != Common::kNoError)
+			return advanceError;
+
+		const Common::Point mousePos = g_system->getEventManager()->getMousePos();
+		const bool onInteractiveHotspot = pointHitsHotspot(mousePos, stage.successHotspot) ||
+			pointHitsAnyHotspot(mousePos, stage.failureHotspots, stage.failureHotspotCount);
+
+		drawFlicFrame(*screen, background);
+		const ChessmasterFlicState &cursorState = onInteractiveHotspot ? operateCursor : pointer;
+		if (cursorState.frame)
+			blitTransparentFrame(*screen, *cursorState.frame, mousePos.x, mousePos.y);
+		screen->makeAllDirty();
+		screen->update();
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			if (event.type == Common::EVENT_QUIT || event.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return Common::kNoError;
+			if (event.type != Common::EVENT_LBUTTONDOWN)
+				continue;
+
+			if (pointHitsHotspot(event.mouse, stage.successHotspot)) {
+				selectedSuccessHotspot = true;
+				return Common::kNoError;
+			}
+			if (pointHitsAnyHotspot(event.mouse, stage.failureHotspots, stage.failureHotspotCount))
+				return Common::kNoError;
+		}
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+static Common::Error runChessmasterThreeMovePuzzle(DialogueRuntime &runtime, bool &solvedPuzzle) {
+	solvedPuzzle = false;
+
+	for (uint i = 0; i < ARRAYSIZE(kChessmasterPuzzleStages); ++i) {
+		bool selectedSuccessHotspot = false;
+		Common::Error stageError = runChessmasterChoiceStage(
+			runtime, kChessmasterPuzzleStages[i], selectedSuccessHotspot);
+		if (stageError.getCode() != Common::kNoError)
+			return stageError;
+		if (runtime.engine().shouldQuit())
+			return Common::kNoError;
+		if (!selectedSuccessHotspot)
+			return Common::kNoError;
+
+		stageError = playOneShotFlic(runtime, kChessmasterPuzzleStages[i].moveFlicPath);
+		if (stageError.getCode() != Common::kNoError)
+			return stageError;
+		if (runtime.engine().shouldQuit())
+			return Common::kNoError;
+	}
+
+	Common::Error delayError = runTimedDelay(kChessmasterSuccessHoldMs);
+	if (delayError.getCode() != Common::kNoError)
+		return delayError;
+
+	solvedPuzzle = true;
+	return Common::kNoError;
+}
+
+} // End of namespace
+
 bool ChessmasterDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("CHESSMASTER");
+	return npcName.equalsIgnoreCase(kChessmasterNpc);
 }
 
 Common::Error ChessmasterDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
-	return runtime.playDialogueLine(0x4115, "CHESSMASTER");
+	Common::Error lineError = runtime.playDialogueLine(0x4115, kChessmasterNpc);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(0x22, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	bool solvedPuzzle = false;
+	if (responseIndex == 1) {
+		lineError = runtime.playDialogueLineWithVariant(0x4122, kChessmasterNpc, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = runChessmasterThreeMovePuzzle(runtime, solvedPuzzle);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		if (runtime.engine().shouldQuit())
+			return Common::kNoError;
+	}
+
+	if (responseIndex != 1 || !solvedPuzzle) {
+		lineError = runtime.playDialogueLineWithVariant(0x4126, kChessmasterNpc, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		StartupInteractionResult interaction;
+		if (runtime.startupScript().executeActionTag(kChessKingMonsterActionTag, interaction)) {
+			runtime.applyImmediateDialogueInteractionEffects(interaction);
+			runtime.queueDialogueInteractionIfNeeded(interaction);
+		}
+
+		return runtime.playDialogueFst("GRAPHIC/FST/KINGGROW.FST");
+	}
+
+	(void)runtime.startupScript().setRuntimeFlagValue(kChessSolvedFlag, true);
+	lineError = runtime.playDialogueFst("GRAPHIC/FST/C125.FST");
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	StartupInteractionResult interaction;
+	if (runtime.startupScript().triggerRuntimeNpcDeathOrMonsterfy(
+			kChessmasterNpc, kChessmasterSlashDamageType)) {
+		interaction.mutatedRuntimeState = true;
+	}
+	runtime.queueDialogueInteractionIfNeeded(interaction);
+	return Common::kNoError;
 }
 
 } // End of namespace Harvester


Commit: a42bb4f36094c52b8434353ac190de7364e705c7
    https://github.com/scummvm/scummvm/commit/a42bb4f36094c52b8434353ac190de7364e705c7
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:03+02:00

Commit Message:
HARVESTER: Restore Ryder dialogue evidence state

Changed paths:
    TRACKER.md
    engines/harvester/npc/ryder_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 1b3d02c59d0..b031bb3fd77 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,15 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `926`
-- Named/documented: `807`
+- Total functions: `927`
+- Named/documented: `808`
 - Remaining `FUN_*`: `119`
 - Remaining undocumented total: `119`
 
 ## Last Confirmed Action
 
+- On March 23, 2026, re-audited the ScummVM Ryder dialogue handler against the live `HARVEST.LE` Ghidra body at `handle_talk_to_ryder` (`0x38c80..0x391fa`). The existing port already matched Ryder's rebuilt control flow, response menus, and FST sequence order, but the presented-item branches were still missing three native shared-state writes: `PHOTO_OF_WHALEY_HERRILL` only set the startup-script flag instead of also setting `discussedWhaleyHerrillPhoto`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` omitted `discussedCasketPhotoEvidence`, and note/checkbook evidence omitted `discussedNoteCheckbookEvidence`. Patched [`engines/harvester/npc/ryder_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/ryder_dialogue.cpp) to restore those native side effects, confirmed the topic/response lines from `DIALOG.RSP` (`0x29c..0x2aa`) while auditing the keyword loop, renamed the Ryder-only FST dispatcher `FUN_000386b0` at `0x386b0` to `play_ryder_dialogue_fst_sequence`, saved `HARVEST.LE`, and refreshed live progress to `927` total functions, `808` named/documented, and `119` remaining `FUN_*`.
+- On March 23, 2026, reran the full durable `repair RYDER -> wait -> save_program -> verify RYDER` cycle against the live `HARVEST.LE` session. `handle_talk_to_ryder @ 0x38c80` started as a truncated `0x38c80..0x38f59` body (`395` address-count) with `7` reachable dialogue calls and `7` stale `CALL_RETURN` gaps. The repair needed three in-memory passes: pass 1 expanded the handler to `0x38c80..0x391fa` with `23` dialogue calls and `4` remaining gaps, pass 2 kept the same body end but exposed `37` dialogue calls with `1` remaining gap, and pass 3 finished the rebuild cleanly at `0x38c80..0x391fa` (`1403` address-count) with `37` reachable dialogue calls, `gapCount=0`, and `25` total repair actions. After the normal settle and explicit save, a fresh `verify RYDER` still reported `status=clean` with both dialogue entry points returning, so Ryder is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Chessmaster dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_chessmaster` (`0x2d900..0x2d9ac`). The old stub in [`engines/harvester/npc/chessmaster_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/chessmaster_dialogue.cpp) only played `0x4115`, while the recovered native flow is `0x4115`, response menu `0x22`, line `0x4122[1]` before a three-stage fullscreen chess puzzle, then either the success tail `CHESS_SOLVED -> C125.FST -> queued Chessmaster slash death` or the failure tail `0x4126[2] -> ADD_CHESS_KING_MONSTER -> KINGGROW.FST`. Patched the ScummVM handler to mirror that control flow, implemented the native `CHOOSE1/2/3.FLC` plus `CHESMOV1/2/3.FLC` puzzle sequence with the confirmed hotspot rectangles from Ghidra, renamed `FUN_0005a550` at `0x5a550` to `run_chessmaster_three_move_puzzle`, saved `HARVEST.LE`, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Live progress is now `926` total functions, `807` named/documented, and `119` remaining `FUN_*`.
 - On March 23, 2026, reran the full durable `repair CHESSMASTER -> wait -> save_program -> verify CHESSMASTER` cycle against the live `HARVEST.LE` session. `handle_talk_to_chessmaster @ 0x2d900` started as a truncated `0x2d900..0x2d914` body (`21` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. The repair needed two in-memory passes: pass 1 expanded the handler to `0x2d900..0x2d9ac` with `3` dialogue calls but left `1` newly exposed gap, and pass 2 finished the rebuild cleanly at `0x2d900..0x2d9ac` (`173` address-count) with `3` reachable dialogue calls, `gapCount=0`, and `6` total repair actions. After the normal settle and explicit save, a fresh `verify CHESSMASTER` still reported `status=clean` with both dialogue entry points returning, so Chessmaster is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Beggar dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_beggar` (`0x34d40..0x34e29`). The old stub in [`engines/harvester/npc/beggar_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/beggar_dialogue.cpp) collapsed the native flow into the `BEGGAR_DIALOG_3` action-tag dispatch plus a single `0x1276[1]` line, while the recovered handler is a fixed two-response exchange: `0x1276[1] -> 0x127a[2] -> 0x127e[0]`, response line `0x0` selecting `0x128d[1]` or `0x1291[3]`, then `0x1295[0]`, response line `0x1`, and `0x12ab[2]` or `0x12a5[2]`. Patched [`engines/harvester/npc/beggar_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/beggar_dialogue.cpp) to match that control flow, kept the native `BEGGAR_DIALOG_3` action-tag dispatch at the top, and renamed the directly evidenced live-entity tail helper `FUN_0007ca90` at `0x7ca90` to `queue_live_named_npc_death_or_monsterfy_transition`. That helper only resolves the named live NPC entity and queues the already-documented actor state `0x35`, so the current ScummVM port still relies on the action-tag interaction path for room-state mutation while the exact visual tail remains an engine-side follow-up rather than a guessed rewrite. Live progress is now `919` total functions, `806` named/documented, and `113` remaining `FUN_*`.
@@ -100,6 +102,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Ryder validation pass that explicitly hits the recovered native branches: first talk through both `0x2a1` responses to confirm the optional `AUTOGRAPH` award versus `0x4a9 -> 0x4ae`; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, and note/checkbook evidence to confirm the restored shared evidence-state writes now land alongside the existing startup-script flags; the `KARIN_KIDNAPED` tail through both `0x2a3` responses to verify `C063D.FST`, `C063E.FST`, and the autograph alternative; the keyword loop through both `0x2a7` / `0x2a8` interview topics and both `0x2a9` responses to confirm `C101.FST`, `0x511`, `0x521`, and `0x527`; and the `BURNED_TV_STATION` override with and without `BOYLE_BURNED_TV` set to confirm presented items are ignored and the `RYDER_TV_OUT` line chain still short-circuits correctly.
 - Immediate: run an in-engine Chessmaster validation pass that explicitly hits both menu responses and all three puzzle outcomes: response `1` with the native correct hotspot on each of `CHOOSE1`, `CHOOSE2`, and `CHOOSE3` to confirm `CHESS_SOLVED`, `C125.FST`, and the queued Chessmaster slash-death transition; response `1` with a wrong hotspot on each stage to confirm the immediate `0x4126[2] -> ADD_CHESS_KING_MONSTER -> KINGGROW.FST` fail path; and response `2` or a canceled menu selection to confirm the puzzle is skipped and the same monster branch fires directly.
 - Immediate: decode one shipped `HARVEST.SCR` sample and lift a few real `COMMAND` clusters into [`OPCODES.md`](/Users/alex/Workspace/scummvm/OPCODES.md) so the new `triggerTag` / `currentTag` section includes native examples instead of illustrative ones. Keep the examples tied to records and continuations that are actually present in data.
 - Immediate: run an in-engine Beggar validation pass that explicitly hits the recovered native-only dialogue tree: trigger the `BEGGAR_INTERRUPT_CONVERSATION` path, take both `dialog.rsp[0]` answers to confirm `0x128d[1]` versus `0x1291[3]`, then both `dialog.rsp[1]` answers to confirm `0x12ab[2]` versus `0x12a5[2]`, and verify that `BEGGAR_DIALOG_3` still produces the intended post-dialogue room mutation / monsterfy result once the conversation exits.
@@ -119,7 +122,7 @@
 - Immediate: run an in-engine Edna validation pass that explicitly hits the restored native-only branches: `KILLED_KARIN1` to confirm the flag now clears; `DNA_S_SUICIDE_NOTE` with and without `KARIN_FOUND_ALIVE` to confirm `C043.FST` / `C043K.FST`, shared states `d2ea4` / `d2ea8`, and `DNAEXT:SIGNOUT`; direct `K_PURSE` use plus the `KARIN_FOUND_DEAD -> 0xa2 -> 0xa3/0xa4` nested purse menu to verify `REWARD_MONEY` and the `RAH`-hidden purse side effect; the day-5 alive and dead follow-ups; and the keyword loop through the recovered `0xa6`, `0xac/0xad`, `0xb0`, `0xb2`, `0xb6/0xb7`, `0xba`, `0xbc/0xbd/0xbe`, `0xc0`, `0xc2`, typed unknown text, and goodbye branches to confirm the restored menu buffer rewrites and both `C091.FST` / `C070.FST` exits.
 - Immediate: run an in-engine Karin validation pass that explicitly hits the restored native-only branches: the first `KARIN_FOUND_ALIVE` + `IN_CEM10` visit to confirm the full `0x10a5`..`0x10c3` early-return sequence; first and repeat no-item visits before Karin is found alive to confirm the recovered `0x1076`..`0x108b` prelude plus the shared `0x10dc` bark; first and repeat uses of `CASKET_PHOTO` or `PHOTO_OF_WHALEY_HERRILL` to verify the `0x10e7` Edna follow-up, portrait reset, shared evidence-state write, one-shot override, and repeat-photo `CALL_POLICE` escalation; and `INV_MAG` to confirm `0x10ee` / `0x10f2[2]` plus `DNALFT_PERVERT`.
 - Immediate: run an in-engine Dwayne validation pass that explicitly hits the restored native-only branches: `CHECK_EVIDENCE_DOOR` and the first/second `ST_BEDRM` visits to confirm the new flag clears; the sheriff-in-diner intro and revisit to verify Edna `0x3490/0x3496`, the optional `0x34a0/0x34a4` tail, and the later keyword loop; Boyle's button, Karin's purse, note/checkbook photocopy -> original note/checkbook, both `TV_DEED` and `TV_DEED_PHOTOCOPY` response trees plus the override revisit on the real deed, corpse photo with and without shared state `d2ec8`, and both-ledger handoff with and without `SHERIFF_IN_DINER`; `KARIN_KIDNAPED` through response menus `0x7b/0x7c`, `KARIN_FOUND_DEAD` without the purse through `0x7f/0x80`, and the `PC_HAS_GOOJF_CARD` arrest path with and without `PC_KILLED_ANYONE` to confirm the confiscation and bust follow-up state now matches native.
-- Immediate: resume the sequential talk-handler sweep at `LODGE_CHEF`, using the proven single-handler workflow `verify <NPC> -> repair <NPC> -> wait ~10s -> save_program -> verify <NPC>` and immediately rerunning the same NPC once when the first saved verify snaps back. After the durable Sparky retry and the earlier Pastorelli/Parsons work, the remaining not-yet-confirmed handlers in that queue are `LODGE_CHEF`, `RYDER`, and `MEMB_DIR`.
+- Immediate: resume the sequential talk-handler sweep at `LODGE_CHEF`, using the proven single-handler workflow `verify <NPC> -> repair <NPC> -> wait ~10s -> save_program -> verify <NPC>` and immediately rerunning the same NPC once when the first saved verify snaps back. After the durable Sparky retry, the earlier Pastorelli/Parsons work, and the Ryder re-audit, the remaining not-yet-confirmed handlers in that queue are `LODGE_CHEF` and `MEMB_DIR`.
 - Immediate: run an in-engine Johnson validation pass that explicitly hits the restored native-only branches: `OPENING_MANHOLE_DAYTIME` twice to confirm `0xbe0` now clears the flag; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared state `d2ebc`, and `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` to verify the restored `0xb7b`, `0xb80`, `0xb90`, `0xb94`, `0xb99`, `0xba4`, and `0xba8` follow-ups; the first `SCRATCHED_TUCKER` visit with `BOLT_OF_CLOTH_TAKEN` already set to confirm `0xc25` / `0xc2b` / `0xc30` plus `0xc5f` / `0xc69` and that the Johnson intro only replays on the later revisit; and a `KARIN_KIDNAPED` follow-up that later reaches a consumer such as Stephanie to confirm the shared kidnapped-dialogue state now propagates correctly.
 - Immediate: run an in-engine Loomis validation pass that explicitly hits the restored native-only branches: first talk with and without `SHERIFF_IN_DINER` to confirm the `0x1112 -> 0x1118 -> 0x1121 -> response 0x53 -> 0x1130[3]` intro; the second and third no-item visits to confirm the one-shot `0x11a8/0x11ac/0x11b0[3]` follow-up, the later `0x11f6` bark, and the `STEPH_MIDGAME_PLAYED` / day-5 `0x1230[3]` and `0x123c` gates; the keyword loop through `LOOMIS`, `Phelps`, `Man's Needs`, sheriff topics, `0x65`, `0x66`, typed unknown text, and goodbye to verify the restored alias matches, portrait variants, `0x11fc` fallback, and `0x11a1[1]` exit; note/checkbook or TV-deed evidence through both `0x50` responses to confirm `0x1214` / `0x1219` / `0x121f[3]` plus `PC_FRY_DADDY_1`; and `INV_MAG` in the diner branch through both `0x51` responses to confirm `0x11c6[1]`, `0x11cc[3]`, drawer visibility, `GAVE_MAG_TO_LOOMIS_TODAY`, and the `C048.FST` tail.
 - Immediate: run an in-engine Mrs. Potts validation pass that explicitly hits the restored native-only paths: `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `LEDGER` with `HAVE_BOTH_LEDGERS`, and the `GOT_REMAINS_FOR_LODGE` response-1 death branch to confirm the corrected portrait variants; the non-dead revisit gate around `0x281b` / `0x2820` / `0x2825`; and `DINER_BURNED` with and without `KARIN_HUNG`, taking both `0x236` responses to verify the new `0x2914` / `0x2918` / `0x2926` continuation and the response-2 jump into the shared death-in-chair sequence.
diff --git a/engines/harvester/npc/ryder_dialogue.cpp b/engines/harvester/npc/ryder_dialogue.cpp
index c35e507257c..8612322a3fa 100644
--- a/engines/harvester/npc/ryder_dialogue.cpp
+++ b/engines/harvester/npc/ryder_dialogue.cpp
@@ -84,11 +84,13 @@ Common::Error RyderDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	}
 
 	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+		sharedState.discussedWhaleyHerrillPhoto = 1;
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
 		return runtime.playDialogueEntrySequence(kRyderWhaleyPhotoLines, ARRAYSIZE(kRyderWhaleyPhotoLines));
 	}
 	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+		sharedState.discussedCasketPhotoEvidence = 1;
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
 		return playRyderLine(0x54f, 1);
 	}
@@ -96,6 +98,7 @@ Common::Error RyderDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+		sharedState.discussedNoteCheckbookEvidence = 1;
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
 		return runtime.playDialogueEntrySequence(
 			kRyderBlackmailEvidenceLines, ARRAYSIZE(kRyderBlackmailEvidenceLines));


Commit: 1df864cc8f8ba3f17db5212b95e42e0c46bd90e2
    https://github.com/scummvm/scummvm/commit/1df864cc8f8ba3f17db5212b95e42e0c46bd90e2
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:03+02:00

Commit Message:
HARVESTER: Re-audit cloak attendant dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/cloak_atnd_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index b031bb3fd77..af0aedd0989 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Last Confirmed Action
 
+- On March 23, 2026, re-audited the ScummVM Cloak Attendant dialogue handler against the live `HARVEST.LE` Ghidra body at `handle_talk_to_cloak_atnd` (`0x2db80..0x2dbce`). The native handler is still only a two-branch worker: if `g_dialogue_presented_object_name` is set and matches `BARCASHFIVE`, dispatch `CLEAN_CLOTHES`; otherwise, on empty-hand talks, suppress dialogue once `CLEANED_CLOTHES` is set and fall through to `CLOAK_ATND 0x1066` while the flag is clear. The existing ScummVM logic already matched that recovered behavior, so the source patch in [`engines/harvester/npc/cloak_atnd_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/cloak_atnd_dialogue.cpp) was limited to reordering the branches and naming the constants so the handler mirrors the native item-first flow explicitly. I also checked the directly adjacent Ghidra symbols on this path, but no encountered `FUN_*` function met the project confidence bar for renaming. Live progress remains `927` total functions, `808` named/documented, and `119` remaining `FUN_*`.
 - On March 23, 2026, re-audited the ScummVM Ryder dialogue handler against the live `HARVEST.LE` Ghidra body at `handle_talk_to_ryder` (`0x38c80..0x391fa`). The existing port already matched Ryder's rebuilt control flow, response menus, and FST sequence order, but the presented-item branches were still missing three native shared-state writes: `PHOTO_OF_WHALEY_HERRILL` only set the startup-script flag instead of also setting `discussedWhaleyHerrillPhoto`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` omitted `discussedCasketPhotoEvidence`, and note/checkbook evidence omitted `discussedNoteCheckbookEvidence`. Patched [`engines/harvester/npc/ryder_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/ryder_dialogue.cpp) to restore those native side effects, confirmed the topic/response lines from `DIALOG.RSP` (`0x29c..0x2aa`) while auditing the keyword loop, renamed the Ryder-only FST dispatcher `FUN_000386b0` at `0x386b0` to `play_ryder_dialogue_fst_sequence`, saved `HARVEST.LE`, and refreshed live progress to `927` total functions, `808` named/documented, and `119` remaining `FUN_*`.
 - On March 23, 2026, reran the full durable `repair RYDER -> wait -> save_program -> verify RYDER` cycle against the live `HARVEST.LE` session. `handle_talk_to_ryder @ 0x38c80` started as a truncated `0x38c80..0x38f59` body (`395` address-count) with `7` reachable dialogue calls and `7` stale `CALL_RETURN` gaps. The repair needed three in-memory passes: pass 1 expanded the handler to `0x38c80..0x391fa` with `23` dialogue calls and `4` remaining gaps, pass 2 kept the same body end but exposed `37` dialogue calls with `1` remaining gap, and pass 3 finished the rebuild cleanly at `0x38c80..0x391fa` (`1403` address-count) with `37` reachable dialogue calls, `gapCount=0`, and `25` total repair actions. After the normal settle and explicit save, a fresh `verify RYDER` still reported `status=clean` with both dialogue entry points returning, so Ryder is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Chessmaster dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_chessmaster` (`0x2d900..0x2d9ac`). The old stub in [`engines/harvester/npc/chessmaster_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/chessmaster_dialogue.cpp) only played `0x4115`, while the recovered native flow is `0x4115`, response menu `0x22`, line `0x4122[1]` before a three-stage fullscreen chess puzzle, then either the success tail `CHESS_SOLVED -> C125.FST -> queued Chessmaster slash death` or the failure tail `0x4126[2] -> ADD_CHESS_KING_MONSTER -> KINGGROW.FST`. Patched the ScummVM handler to mirror that control flow, implemented the native `CHOOSE1/2/3.FLC` plus `CHESMOV1/2/3.FLC` puzzle sequence with the confirmed hotspot rectangles from Ghidra, renamed `FUN_0005a550` at `0x5a550` to `run_chessmaster_three_move_puzzle`, saved `HARVEST.LE`, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Live progress is now `926` total functions, `807` named/documented, and `119` remaining `FUN_*`.
@@ -102,6 +103,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Cloak Attendant validation pass that covers the full recovered native space: empty-hand talk before `CLEANED_CLOTHES` to confirm only `0x1066`; empty-hand talk after the startup flag is set to confirm silent return; `BARCASHFIVE` presentation to confirm `CLEAN_CLOTHES` still dispatches and applies its room/inventory effects; and one arbitrary non-payment item to confirm the native handler ignores it without falling back to the no-item bark.
 - Immediate: run an in-engine Ryder validation pass that explicitly hits the recovered native branches: first talk through both `0x2a1` responses to confirm the optional `AUTOGRAPH` award versus `0x4a9 -> 0x4ae`; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, and note/checkbook evidence to confirm the restored shared evidence-state writes now land alongside the existing startup-script flags; the `KARIN_KIDNAPED` tail through both `0x2a3` responses to verify `C063D.FST`, `C063E.FST`, and the autograph alternative; the keyword loop through both `0x2a7` / `0x2a8` interview topics and both `0x2a9` responses to confirm `C101.FST`, `0x511`, `0x521`, and `0x527`; and the `BURNED_TV_STATION` override with and without `BOYLE_BURNED_TV` set to confirm presented items are ignored and the `RYDER_TV_OUT` line chain still short-circuits correctly.
 - Immediate: run an in-engine Chessmaster validation pass that explicitly hits both menu responses and all three puzzle outcomes: response `1` with the native correct hotspot on each of `CHOOSE1`, `CHOOSE2`, and `CHOOSE3` to confirm `CHESS_SOLVED`, `C125.FST`, and the queued Chessmaster slash-death transition; response `1` with a wrong hotspot on each stage to confirm the immediate `0x4126[2] -> ADD_CHESS_KING_MONSTER -> KINGGROW.FST` fail path; and response `2` or a canceled menu selection to confirm the puzzle is skipped and the same monster branch fires directly.
 - Immediate: decode one shipped `HARVEST.SCR` sample and lift a few real `COMMAND` clusters into [`OPCODES.md`](/Users/alex/Workspace/scummvm/OPCODES.md) so the new `triggerTag` / `currentTag` section includes native examples instead of illustrative ones. Keep the examples tied to records and continuations that are actually present in data.
diff --git a/engines/harvester/npc/cloak_atnd_dialogue.cpp b/engines/harvester/npc/cloak_atnd_dialogue.cpp
index 43ce25724f3..ceaf8716953 100644
--- a/engines/harvester/npc/cloak_atnd_dialogue.cpp
+++ b/engines/harvester/npc/cloak_atnd_dialogue.cpp
@@ -29,35 +29,37 @@ namespace Harvester {
 
 namespace {
 
+static const char *const kCloakAtndNpc = "CLOAK_ATND";
 static const char *const kCleanedClothesFlag = "CLEANED_CLOTHES";
 static const char *const kCleanClothesActionTag = "CLEAN_CLOTHES";
 static const char *const kPaymentItemName = "BARCASHFIVE";
+static const int kNeedCleaningPaymentLine = 0x1066;
 
 } // End of namespace
 
 bool CloakAtndDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("CLOAK_ATND");
+	return npcName.equalsIgnoreCase(kCloakAtndNpc);
 }
 
 Common::Error CloakAtndDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &) {
-	if (usedItemName.empty()) {
-		if (runtime.startupScript().getFlagValue(kCleanedClothesFlag))
+	if (!usedItemName.empty()) {
+		if (!usedItemName.equalsIgnoreCase(kPaymentItemName))
 			return Common::kNoError;
 
-		return runtime.playDialogueLine(0x1066, "CLOAK_ATND");
-	}
+		StartupInteractionResult interaction;
+		if (runtime.startupScript().executeActionTag(kCleanClothesActionTag, interaction)) {
+			runtime.applyImmediateDialogueInteractionEffects(interaction);
+			runtime.queueDialogueInteractionIfNeeded(interaction);
+		}
 
-	if (!usedItemName.equalsIgnoreCase(kPaymentItemName))
 		return Common::kNoError;
-
-	StartupInteractionResult interaction;
-	if (runtime.startupScript().executeActionTag(kCleanClothesActionTag, interaction)) {
-		runtime.applyImmediateDialogueInteractionEffects(interaction);
-		runtime.queueDialogueInteractionIfNeeded(interaction);
 	}
 
-	return Common::kNoError;
+	if (runtime.startupScript().getFlagValue(kCleanedClothesFlag))
+		return Common::kNoError;
+
+	return runtime.playDialogueLine(kNeedCleaningPaymentLine, kCloakAtndNpc);
 }
 
 } // End of namespace Harvester


Commit: c7e09b71db69baf297c9c3a941f3efd87f3387b4
    https://github.com/scummvm/scummvm/commit/c7e09b71db69baf297c9c3a941f3efd87f3387b4
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:04+02:00

Commit Message:
HARVESTER: Reimplement curator dialogue handler

Changed paths:
    TRACKER.md
    engines/harvester/npc/curator_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index af0aedd0989..8576f1478dc 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,7 +10,10 @@
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Curator dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_curator` (`0x34ca0..0x34d3c`). The old stub in [`engines/harvester/npc/curator_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/curator_dialogue.cpp) only played `0x425[1]`, while the recovered native flow is `0x425[1]`, response menu `0x4a`, branch line `0x430[3]` or `0x435[0]`, then the shared tail `0x43a[0] -> 0x43b[0] -> PC 0x447[0] -> 0x44b[3]`. Patched the ScummVM handler to mirror that exact sequence, and checked the Curator-adjacent Ghidra symbols while auditing the path, but no encountered `FUN_*` function met the project's confidence bar for renaming. Live progress remains `927` total functions, `808` named/documented, and `119` remaining `FUN_*`.
 - On March 23, 2026, re-audited the ScummVM Cloak Attendant dialogue handler against the live `HARVEST.LE` Ghidra body at `handle_talk_to_cloak_atnd` (`0x2db80..0x2dbce`). The native handler is still only a two-branch worker: if `g_dialogue_presented_object_name` is set and matches `BARCASHFIVE`, dispatch `CLEAN_CLOTHES`; otherwise, on empty-hand talks, suppress dialogue once `CLEANED_CLOTHES` is set and fall through to `CLOAK_ATND 0x1066` while the flag is clear. The existing ScummVM logic already matched that recovered behavior, so the source patch in [`engines/harvester/npc/cloak_atnd_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/cloak_atnd_dialogue.cpp) was limited to reordering the branches and naming the constants so the handler mirrors the native item-first flow explicitly. I also checked the directly adjacent Ghidra symbols on this path, but no encountered `FUN_*` function met the project confidence bar for renaming. Live progress remains `927` total functions, `808` named/documented, and `119` remaining `FUN_*`.
+- On March 23, 2026, reran the full durable `repair CURATOR -> wait -> save_program -> verify CURATOR` cycle against the live `HARVEST.LE` session. `handle_talk_to_curator @ 0x34ca0` started as a truncated `0x34ca0..0x34cb5` body (`22` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x34ca0..0x34d3c` (`157` address-count) with `6` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify CURATOR` still reported `status=clean` with both dialogue entry points returning, so Curator is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
+- On March 23, 2026, reran the full durable `repair CLOAK_ATND -> wait -> save_program -> verify CLOAK_ATND` cycle against the live `HARVEST.LE` session. `handle_talk_to_cloak_atnd @ 0x2db80` started as a `0x2db80..0x2dbce` body (`79` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap at the final `play_dialogue_line`. A single repair pass cleared the stale flow metadata in place, leaving the same `79`-address body but restoring `gapCount=0` with `2` repair actions. After the normal settle and explicit save, a fresh `verify CLOAK_ATND` still reported `status=clean` with both dialogue entry points returning, so Cloak Attendant is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body metadata repair rather than a symbol-count change.
 - On March 23, 2026, re-audited the ScummVM Ryder dialogue handler against the live `HARVEST.LE` Ghidra body at `handle_talk_to_ryder` (`0x38c80..0x391fa`). The existing port already matched Ryder's rebuilt control flow, response menus, and FST sequence order, but the presented-item branches were still missing three native shared-state writes: `PHOTO_OF_WHALEY_HERRILL` only set the startup-script flag instead of also setting `discussedWhaleyHerrillPhoto`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` omitted `discussedCasketPhotoEvidence`, and note/checkbook evidence omitted `discussedNoteCheckbookEvidence`. Patched [`engines/harvester/npc/ryder_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/ryder_dialogue.cpp) to restore those native side effects, confirmed the topic/response lines from `DIALOG.RSP` (`0x29c..0x2aa`) while auditing the keyword loop, renamed the Ryder-only FST dispatcher `FUN_000386b0` at `0x386b0` to `play_ryder_dialogue_fst_sequence`, saved `HARVEST.LE`, and refreshed live progress to `927` total functions, `808` named/documented, and `119` remaining `FUN_*`.
 - On March 23, 2026, reran the full durable `repair RYDER -> wait -> save_program -> verify RYDER` cycle against the live `HARVEST.LE` session. `handle_talk_to_ryder @ 0x38c80` started as a truncated `0x38c80..0x38f59` body (`395` address-count) with `7` reachable dialogue calls and `7` stale `CALL_RETURN` gaps. The repair needed three in-memory passes: pass 1 expanded the handler to `0x38c80..0x391fa` with `23` dialogue calls and `4` remaining gaps, pass 2 kept the same body end but exposed `37` dialogue calls with `1` remaining gap, and pass 3 finished the rebuild cleanly at `0x38c80..0x391fa` (`1403` address-count) with `37` reachable dialogue calls, `gapCount=0`, and `25` total repair actions. After the normal settle and explicit save, a fresh `verify RYDER` still reported `status=clean` with both dialogue entry points returning, so Ryder is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Chessmaster dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_chessmaster` (`0x2d900..0x2d9ac`). The old stub in [`engines/harvester/npc/chessmaster_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/chessmaster_dialogue.cpp) only played `0x4115`, while the recovered native flow is `0x4115`, response menu `0x22`, line `0x4122[1]` before a three-stage fullscreen chess puzzle, then either the success tail `CHESS_SOLVED -> C125.FST -> queued Chessmaster slash death` or the failure tail `0x4126[2] -> ADD_CHESS_KING_MONSTER -> KINGGROW.FST`. Patched the ScummVM handler to mirror that control flow, implemented the native `CHOOSE1/2/3.FLC` plus `CHESMOV1/2/3.FLC` puzzle sequence with the confirmed hotspot rectangles from Ghidra, renamed `FUN_0005a550` at `0x5a550` to `run_chessmaster_three_move_puzzle`, saved `HARVEST.LE`, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Live progress is now `926` total functions, `807` named/documented, and `119` remaining `FUN_*`.
@@ -103,6 +106,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Curator validation pass that explicitly covers the recovered native tree: take both `dialog.rsp[0x4a]` choices to confirm `0x430[3]` versus `0x435[0]`, confirm the shared tail `0x43a[0] -> 0x43b[0] -> PC 0x447[0] -> 0x44b[3]` always follows either valid choice, cancel or dismiss the menu once to verify the branch-specific line is skipped while the shared tail still plays, and present one arbitrary item to confirm the native handler ignores used objects.
 - Immediate: run an in-engine Cloak Attendant validation pass that covers the full recovered native space: empty-hand talk before `CLEANED_CLOTHES` to confirm only `0x1066`; empty-hand talk after the startup flag is set to confirm silent return; `BARCASHFIVE` presentation to confirm `CLEAN_CLOTHES` still dispatches and applies its room/inventory effects; and one arbitrary non-payment item to confirm the native handler ignores it without falling back to the no-item bark.
 - Immediate: run an in-engine Ryder validation pass that explicitly hits the recovered native branches: first talk through both `0x2a1` responses to confirm the optional `AUTOGRAPH` award versus `0x4a9 -> 0x4ae`; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, and note/checkbook evidence to confirm the restored shared evidence-state writes now land alongside the existing startup-script flags; the `KARIN_KIDNAPED` tail through both `0x2a3` responses to verify `C063D.FST`, `C063E.FST`, and the autograph alternative; the keyword loop through both `0x2a7` / `0x2a8` interview topics and both `0x2a9` responses to confirm `C101.FST`, `0x511`, `0x521`, and `0x527`; and the `BURNED_TV_STATION` override with and without `BOYLE_BURNED_TV` set to confirm presented items are ignored and the `RYDER_TV_OUT` line chain still short-circuits correctly.
 - Immediate: run an in-engine Chessmaster validation pass that explicitly hits both menu responses and all three puzzle outcomes: response `1` with the native correct hotspot on each of `CHOOSE1`, `CHOOSE2`, and `CHOOSE3` to confirm `CHESS_SOLVED`, `C125.FST`, and the queued Chessmaster slash-death transition; response `1` with a wrong hotspot on each stage to confirm the immediate `0x4126[2] -> ADD_CHESS_KING_MONSTER -> KINGGROW.FST` fail path; and response `2` or a canceled menu selection to confirm the puzzle is skipped and the same monster branch fires directly.
diff --git a/engines/harvester/npc/curator_dialogue.cpp b/engines/harvester/npc/curator_dialogue.cpp
index 30de64c7ea4..88973117124 100644
--- a/engines/harvester/npc/curator_dialogue.cpp
+++ b/engines/harvester/npc/curator_dialogue.cpp
@@ -27,13 +27,47 @@
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kCuratorNpc = "CURATOR";
+static const char *const kPcSpeaker = "PC";
+static const int kCuratorResponseLine = 0x4a;
+
+static const DialogueLineEntry kCuratorTailLines[] = {
+	{ 0x43a, kCuratorNpc, 0 },
+	{ 0x43b, kCuratorNpc, 0 },
+	{ 0x447, kPcSpeaker, 0 },
+	{ 0x44b, kCuratorNpc, 3 }
+};
+
+} // End of namespace
+
 bool CuratorDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("CURATOR");
+	return npcName.equalsIgnoreCase(kCuratorNpc);
 }
 
 Common::Error CuratorDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
-	return runtime.playDialogueLineWithVariant(0x425, "CURATOR", 1);
+	Common::Error lineError = runtime.playDialogueLineWithVariant(0x425, kCuratorNpc, 1);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(kCuratorResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1) {
+		lineError = runtime.playDialogueLineWithVariant(0x430, kCuratorNpc, 3);
+	} else if (responseIndex == 2) {
+		lineError = runtime.playDialogueLineWithVariant(0x435, kCuratorNpc, 0);
+	} else {
+		lineError = Common::kNoError;
+	}
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	return runtime.playDialogueEntrySequence(kCuratorTailLines, ARRAYSIZE(kCuratorTailLines));
 }
 
 } // End of namespace Harvester


Commit: 429f1b5d08e20c11cadb039d981bde16725f0bf5
    https://github.com/scummvm/scummvm/commit/429f1b5d08e20c11cadb039d981bde16725f0bf5
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:04+02:00

Commit Message:
HARVESTER: Restore dark woman dialogue flow

Changed paths:
    TRACKER.md
    engines/harvester/npc/dark_woman_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 8576f1478dc..20f5aafcda8 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,15 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `927`
-- Named/documented: `808`
+- Total functions: `928`
+- Named/documented: `809`
 - Remaining `FUN_*`: `119`
 - Remaining undocumented total: `119`
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Dark Woman dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_dark_woman` (`0x33610..0x33819`). The old stub in [`engines/harvester/npc/dark_woman_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dark_woman_dialogue.cpp) only handled direct `HANDMIRROR` use as `0x4d03[2]` and a single first-talk bark `0x4cd5[1]`, while the recovered native handler is a four-menu tree: `0x4cd5[1]`, response line `0x4b`, and three nested `0x4ce6[4]` follow-ups through response lines `0x4c` and `0x4d`, with response `1` on any menu taking the `0x4ce2[2]` live NPC death-or-monsterfy branch. The fourth menu switches between `dialog.rsp[0x4e]` and `dialog.rsp[0x4f]` based on whether `HANDMIRROR` is already in inventory, then either kills Dark Woman, falls through to `0x4cfc[2]` when the mirror is absent, or runs the shared reveal branch `0x4d03[2] -> C109.FST -> 0x4d0d[3] -> hide HANDMIRROR -> SET_D_W_FLAG`. Patched the handler to mirror that control flow, preserved the native wrong-item fallthrough to the intro gate, renamed the directly evidenced FST wrapper `FUN_00038ac0` at `0x38ac0` to `play_c109_fst_sequence`, saved `HARVEST.LE`, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Live progress is now `928` total functions, `809` named/documented, and `119` remaining `FUN_*`.
+- On March 23, 2026, reran the full durable `repair DARK_WOMAN -> wait -> save_program -> verify DARK_WOMAN` cycle against the live `HARVEST.LE` session. `handle_talk_to_dark_woman @ 0x33610` started as a truncated `0x33610..0x33819` body (`94` address-count) with `2` reachable dialogue calls and `2` stale `CALL_RETURN` gaps. A single repair pass rebuilt it cleanly to `0x33610..0x33819` (`502` address-count) with `10` reachable dialogue calls, `gapCount=0`, and `5` repair actions. After the normal settle and explicit save, a fresh `verify DARK_WOMAN` still reported `status=clean` with both dialogue entry points returning, so Dark Woman is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Curator dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_curator` (`0x34ca0..0x34d3c`). The old stub in [`engines/harvester/npc/curator_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/curator_dialogue.cpp) only played `0x425[1]`, while the recovered native flow is `0x425[1]`, response menu `0x4a`, branch line `0x430[3]` or `0x435[0]`, then the shared tail `0x43a[0] -> 0x43b[0] -> PC 0x447[0] -> 0x44b[3]`. Patched the ScummVM handler to mirror that exact sequence, and checked the Curator-adjacent Ghidra symbols while auditing the path, but no encountered `FUN_*` function met the project's confidence bar for renaming. Live progress remains `927` total functions, `808` named/documented, and `119` remaining `FUN_*`.
 - On March 23, 2026, re-audited the ScummVM Cloak Attendant dialogue handler against the live `HARVEST.LE` Ghidra body at `handle_talk_to_cloak_atnd` (`0x2db80..0x2dbce`). The native handler is still only a two-branch worker: if `g_dialogue_presented_object_name` is set and matches `BARCASHFIVE`, dispatch `CLEAN_CLOTHES`; otherwise, on empty-hand talks, suppress dialogue once `CLEANED_CLOTHES` is set and fall through to `CLOAK_ATND 0x1066` while the flag is clear. The existing ScummVM logic already matched that recovered behavior, so the source patch in [`engines/harvester/npc/cloak_atnd_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/cloak_atnd_dialogue.cpp) was limited to reordering the branches and naming the constants so the handler mirrors the native item-first flow explicitly. I also checked the directly adjacent Ghidra symbols on this path, but no encountered `FUN_*` function met the project confidence bar for renaming. Live progress remains `927` total functions, `808` named/documented, and `119` remaining `FUN_*`.
 - On March 23, 2026, reran the full durable `repair CURATOR -> wait -> save_program -> verify CURATOR` cycle against the live `HARVEST.LE` session. `handle_talk_to_curator @ 0x34ca0` started as a truncated `0x34ca0..0x34cb5` body (`22` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x34ca0..0x34d3c` (`157` address-count) with `6` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify CURATOR` still reported `status=clean` with both dialogue entry points returning, so Curator is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
@@ -106,6 +108,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Dark Woman validation pass that explicitly covers the recovered native tree: direct `HANDMIRROR` use to confirm `0x4d03[2] -> C109.FST -> 0x4d0d[3]`, `HANDMIRROR` removal from inventory, and `SET_D_W_FLAG`; a fresh no-item talk through response lines `0x4b`, `0x4c`, and `0x4d` to confirm response `1` on any menu plays `0x4ce2[2]` and queues the live Dark Woman death-or-monsterfy transition; response `2` through all four menus without the mirror in inventory to confirm `dialog.rsp[0x4f]` and the `0x4cfc[2]` fallback; then the same path with `HANDMIRROR` already owned to confirm `dialog.rsp[0x4e]` and the shared reveal branch even without directly presenting the item. Also present one wrong item on the first visit to confirm the native handler falls through to the intro instead of playing a rejection bark.
 - Immediate: run an in-engine Curator validation pass that explicitly covers the recovered native tree: take both `dialog.rsp[0x4a]` choices to confirm `0x430[3]` versus `0x435[0]`, confirm the shared tail `0x43a[0] -> 0x43b[0] -> PC 0x447[0] -> 0x44b[3]` always follows either valid choice, cancel or dismiss the menu once to verify the branch-specific line is skipped while the shared tail still plays, and present one arbitrary item to confirm the native handler ignores used objects.
 - Immediate: run an in-engine Cloak Attendant validation pass that covers the full recovered native space: empty-hand talk before `CLEANED_CLOTHES` to confirm only `0x1066`; empty-hand talk after the startup flag is set to confirm silent return; `BARCASHFIVE` presentation to confirm `CLEAN_CLOTHES` still dispatches and applies its room/inventory effects; and one arbitrary non-payment item to confirm the native handler ignores it without falling back to the no-item bark.
 - Immediate: run an in-engine Ryder validation pass that explicitly hits the recovered native branches: first talk through both `0x2a1` responses to confirm the optional `AUTOGRAPH` award versus `0x4a9 -> 0x4ae`; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, and note/checkbook evidence to confirm the restored shared evidence-state writes now land alongside the existing startup-script flags; the `KARIN_KIDNAPED` tail through both `0x2a3` responses to verify `C063D.FST`, `C063E.FST`, and the autograph alternative; the keyword loop through both `0x2a7` / `0x2a8` interview topics and both `0x2a9` responses to confirm `C101.FST`, `0x511`, `0x521`, and `0x527`; and the `BURNED_TV_STATION` override with and without `BOYLE_BURNED_TV` set to confirm presented items are ignored and the `RYDER_TV_OUT` line chain still short-circuits correctly.
diff --git a/engines/harvester/npc/dark_woman_dialogue.cpp b/engines/harvester/npc/dark_woman_dialogue.cpp
index 6fde045c2fd..02220cf8cff 100644
--- a/engines/harvester/npc/dark_woman_dialogue.cpp
+++ b/engines/harvester/npc/dark_woman_dialogue.cpp
@@ -29,24 +29,139 @@ namespace Harvester {
 
 namespace {
 
+static const char *const kDarkWomanNpc = "DARK_WOMAN";
 static const char *const kHandMirrorItemName = "HANDMIRROR";
+static const char *const kInventoryOwnerName = "INVENTORY";
+static const char *const kSetDarkWomanFlagActionTag = "SET_D_W_FLAG";
+static const char *const kDialogueC109FstPath = "GRAPHIC/FST/C109.FST";
+static const int kDarkWomanInitialResponseLine = 0x4b;
+static const int kDarkWomanSecondResponseLine = 0x4c;
+static const int kDarkWomanThirdResponseLine = 0x4d;
+static const int kDarkWomanHasMirrorResponseLine = 0x4e;
+static const int kDarkWomanNeedsMirrorResponseLine = 0x4f;
 
 } // End of namespace
 
 bool DarkWomanDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("DARK_WOMAN");
+	return npcName.equalsIgnoreCase(kDarkWomanNpc);
 }
 
 Common::Error DarkWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &) {
+	auto playDarkWomanLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kDarkWomanNpc, headVariant);
+	};
+	auto queueDarkWomanDeathOrMonsterfyTransition = [&]() {
+		StartupInteractionResult interaction;
+		if (runtime.startupScript().triggerRuntimeNpcDeathOrMonsterfy(kDarkWomanNpc))
+			interaction.mutatedRuntimeState = true;
+		runtime.queueDialogueInteractionIfNeeded(interaction);
+	};
+	auto runDarkWomanDeathBranch = [&]() -> Common::Error {
+		Common::Error lineError = playDarkWomanLine(0x4ce2, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		queueDarkWomanDeathOrMonsterfyTransition();
+		return Common::kNoError;
+	};
+	auto runMirrorRevealBranch = [&]() -> Common::Error {
+		Common::Error lineError = playDarkWomanLine(0x4d03, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = runtime.playDialogueFst(kDialogueC109FstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = playDarkWomanLine(0x4d0d, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		const bool changed = runtime.startupScript().setRuntimeObjectVisible(
+			kInventoryOwnerName, kHandMirrorItemName, false);
+		StartupInteractionResult interaction;
+		if (runtime.startupScript().executeActionTag(kSetDarkWomanFlagActionTag, interaction)) {
+			interaction.mutatedRuntimeState |= changed;
+			runtime.applyImmediateDialogueInteractionEffects(interaction);
+		} else {
+			interaction.mutatedRuntimeState = changed;
+		}
+		runtime.queueDialogueInteractionIfNeeded(interaction);
+		return Common::kNoError;
+	};
+
 	if (usedItemName.equalsIgnoreCase(kHandMirrorItemName))
-		return runtime.playDialogueLineWithVariant(0x4d03, "DARK_WOMAN", 2);
+		return runMirrorRevealBranch();
 
 	if (!_state.talkStatePending)
 		return Common::kNoError;
 
 	_state.talkStatePending = false;
-	return runtime.playDialogueLineWithVariant(0x4cd5, "DARK_WOMAN", 1);
+
+	Common::Error lineError = playDarkWomanLine(0x4cd5, 1);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(
+		kDarkWomanInitialResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1)
+		return runDarkWomanDeathBranch();
+	if (responseIndex != 2)
+		return Common::kNoError;
+
+	lineError = playDarkWomanLine(0x4ce6, 4);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	responseIndex = 0;
+	responseError = runtime.runResponseMenu(
+		kDarkWomanSecondResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1)
+		return runDarkWomanDeathBranch();
+	if (responseIndex != 2)
+		return Common::kNoError;
+
+	lineError = playDarkWomanLine(0x4ce6, 4);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	responseIndex = 0;
+	responseError = runtime.runResponseMenu(
+		kDarkWomanThirdResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1)
+		return runDarkWomanDeathBranch();
+	if (responseIndex != 2)
+		return Common::kNoError;
+
+	lineError = playDarkWomanLine(0x4ce6, 4);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	const bool hasHandMirror = runtime.startupScript().isObjectInInventory(kHandMirrorItemName);
+	responseIndex = 0;
+	responseError = runtime.runResponseMenu(
+		hasHandMirror ? kDarkWomanHasMirrorResponseLine : kDarkWomanNeedsMirrorResponseLine,
+		responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1)
+		return runDarkWomanDeathBranch();
+	if (!hasHandMirror)
+		return playDarkWomanLine(0x4cfc, 2);
+
+	return runMirrorRevealBranch();
 }
 
 } // End of namespace Harvester


Commit: 738835a266c57187384b7fb8e0d7a2cf1a29b930
    https://github.com/scummvm/scummvm/commit/738835a266c57187384b7fb8e0d7a2cf1a29b930
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:04+02:00

Commit Message:
HARVESTER: Reimplement gladiator dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/gladiator_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 20f5aafcda8..fab3dfeec36 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,8 @@
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Gladiator dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_gladiator` (`0x2c630..0x2c762`). The old stub in [`engines/harvester/npc/gladiator_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/gladiator_dialogue.cpp) only cleared `GLADIATOR_INTERRUPT_CONVERSATION` and played `0x69c[1]`, while the recovered native handler is a two-menu interrupt conversation: `0x69c[1] -> 0x6a3[0] -> dialog.rsp[0xc3]`, response `1` taking the death tail `0x6af[2] -> 0x6cd[2] -> queue live GLADIATOR death-or-monsterfy`, and response `2` taking `0x6b3[0] -> C110.FST -> dialog.rsp[0xc4]` where response `1` uses the alternate death tail `0x6c3[2] -> 0x6cd[2]` and response `2` plays `0x6c7[1] -> 0x6cd[0] -> START_MERCY_TIMR`. Patched the handler to mirror that exact control flow, confirmed that the only Gladiator-path helpers hit by this audit were already stably named (`play_c110_fst_sequence`, `queue_live_named_npc_death_or_monsterfy_transition`, and the shared room-event dispatcher), rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and left live progress unchanged at `928` total functions, `809` named/documented, and `119` remaining `FUN_*`.
+- On March 23, 2026, reran the full durable `repair GLADIATOR -> wait -> save_program -> verify GLADIATOR` cycle against the live `HARVEST.LE` session. `handle_talk_to_gladiator @ 0x2c630` started as a truncated `0x2c630..0x2c762` body (`57` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x2c630..0x2c762` (`307` address-count) with `9` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify GLADIATOR` still reported `status=clean` with both dialogue entry points returning, so Gladiator is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Dark Woman dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_dark_woman` (`0x33610..0x33819`). The old stub in [`engines/harvester/npc/dark_woman_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dark_woman_dialogue.cpp) only handled direct `HANDMIRROR` use as `0x4d03[2]` and a single first-talk bark `0x4cd5[1]`, while the recovered native handler is a four-menu tree: `0x4cd5[1]`, response line `0x4b`, and three nested `0x4ce6[4]` follow-ups through response lines `0x4c` and `0x4d`, with response `1` on any menu taking the `0x4ce2[2]` live NPC death-or-monsterfy branch. The fourth menu switches between `dialog.rsp[0x4e]` and `dialog.rsp[0x4f]` based on whether `HANDMIRROR` is already in inventory, then either kills Dark Woman, falls through to `0x4cfc[2]` when the mirror is absent, or runs the shared reveal branch `0x4d03[2] -> C109.FST -> 0x4d0d[3] -> hide HANDMIRROR -> SET_D_W_FLAG`. Patched the handler to mirror that control flow, preserved the native wrong-item fallthrough to the intro gate, renamed the directly evidenced FST wrapper `FUN_00038ac0` at `0x38ac0` to `play_c109_fst_sequence`, saved `HARVEST.LE`, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Live progress is now `928` total functions, `809` named/documented, and `119` remaining `FUN_*`.
 - On March 23, 2026, reran the full durable `repair DARK_WOMAN -> wait -> save_program -> verify DARK_WOMAN` cycle against the live `HARVEST.LE` session. `handle_talk_to_dark_woman @ 0x33610` started as a truncated `0x33610..0x33819` body (`94` address-count) with `2` reachable dialogue calls and `2` stale `CALL_RETURN` gaps. A single repair pass rebuilt it cleanly to `0x33610..0x33819` (`502` address-count) with `10` reachable dialogue calls, `gapCount=0`, and `5` repair actions. After the normal settle and explicit save, a fresh `verify DARK_WOMAN` still reported `status=clean` with both dialogue entry points returning, so Dark Woman is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Curator dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_curator` (`0x34ca0..0x34d3c`). The old stub in [`engines/harvester/npc/curator_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/curator_dialogue.cpp) only played `0x425[1]`, while the recovered native flow is `0x425[1]`, response menu `0x4a`, branch line `0x430[3]` or `0x435[0]`, then the shared tail `0x43a[0] -> 0x43b[0] -> PC 0x447[0] -> 0x44b[3]`. Patched the ScummVM handler to mirror that exact sequence, and checked the Curator-adjacent Ghidra symbols while auditing the path, but no encountered `FUN_*` function met the project's confidence bar for renaming. Live progress remains `927` total functions, `808` named/documented, and `119` remaining `FUN_*`.
@@ -108,6 +110,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Gladiator validation pass that explicitly covers the recovered native tree: trigger `GLADIATOR_INTERRUPT_CONVERSATION`, confirm the opening `0x69c[1] -> 0x6a3[0]`, take response `1` on `dialog.rsp[0xc3]` to verify the `0x6af[2] -> 0x6cd[2]` death tail and queued live Gladiator death-or-monsterfy transition, then take response `2` to verify `0x6b3[0] -> C110.FST -> dialog.rsp[0xc4]` and both second-menu exits (`0x6c3[2] -> 0x6cd[2]` death tail versus `0x6c7[1] -> 0x6cd[0] -> START_MERCY_TIMR`). Also dismiss each response menu once to confirm the native handler simply exits without playing an extra fallback bark.
 - Immediate: run an in-engine Dark Woman validation pass that explicitly covers the recovered native tree: direct `HANDMIRROR` use to confirm `0x4d03[2] -> C109.FST -> 0x4d0d[3]`, `HANDMIRROR` removal from inventory, and `SET_D_W_FLAG`; a fresh no-item talk through response lines `0x4b`, `0x4c`, and `0x4d` to confirm response `1` on any menu plays `0x4ce2[2]` and queues the live Dark Woman death-or-monsterfy transition; response `2` through all four menus without the mirror in inventory to confirm `dialog.rsp[0x4f]` and the `0x4cfc[2]` fallback; then the same path with `HANDMIRROR` already owned to confirm `dialog.rsp[0x4e]` and the shared reveal branch even without directly presenting the item. Also present one wrong item on the first visit to confirm the native handler falls through to the intro instead of playing a rejection bark.
 - Immediate: run an in-engine Curator validation pass that explicitly covers the recovered native tree: take both `dialog.rsp[0x4a]` choices to confirm `0x430[3]` versus `0x435[0]`, confirm the shared tail `0x43a[0] -> 0x43b[0] -> PC 0x447[0] -> 0x44b[3]` always follows either valid choice, cancel or dismiss the menu once to verify the branch-specific line is skipped while the shared tail still plays, and present one arbitrary item to confirm the native handler ignores used objects.
 - Immediate: run an in-engine Cloak Attendant validation pass that covers the full recovered native space: empty-hand talk before `CLEANED_CLOTHES` to confirm only `0x1066`; empty-hand talk after the startup flag is set to confirm silent return; `BARCASHFIVE` presentation to confirm `CLEAN_CLOTHES` still dispatches and applies its room/inventory effects; and one arbitrary non-payment item to confirm the native handler ignores it without falling back to the no-item bark.
diff --git a/engines/harvester/npc/gladiator_dialogue.cpp b/engines/harvester/npc/gladiator_dialogue.cpp
index 044f5898a54..631d450c553 100644
--- a/engines/harvester/npc/gladiator_dialogue.cpp
+++ b/engines/harvester/npc/gladiator_dialogue.cpp
@@ -29,21 +29,101 @@ namespace Harvester {
 
 namespace {
 
+static const char *const kGladiatorNpc = "GLADIATOR";
 static const char *const kGladiatorInterruptFlag = "GLADIATOR_INTERRUPT_CONVERSATION";
+static const char *const kStartMercyTimerActionTag = "START_MERCY_TIMR";
+static const char *const kDialogueC110FstPath = "GRAPHIC/FST/C110.FST";
+static const int kGladiatorInitialResponseLine = 0xc3;
+static const int kGladiatorFollowupResponseLine = 0xc4;
 
 } // End of namespace
 
 bool GladiatorDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("GLADIATOR");
+	return npcName.equalsIgnoreCase(kGladiatorNpc);
 }
 
 Common::Error GladiatorDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
+	auto playGladiatorLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kGladiatorNpc, headVariant);
+	};
+	auto queueGladiatorDeathOrMonsterfyTransition = [&]() {
+		StartupInteractionResult interaction;
+		if (runtime.startupScript().triggerRuntimeNpcDeathOrMonsterfy(kGladiatorNpc))
+			interaction.mutatedRuntimeState = true;
+		runtime.queueDialogueInteractionIfNeeded(interaction);
+	};
+	auto runGladiatorDeathBranch = [&](int leadWavId) -> Common::Error {
+		Common::Error lineError = playGladiatorLine(leadWavId, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = playGladiatorLine(0x6cd, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		queueGladiatorDeathOrMonsterfyTransition();
+		return Common::kNoError;
+	};
+
 	if (!runtime.startupScript().getFlagValue(kGladiatorInterruptFlag))
 		return Common::kNoError;
 
 	(void)runtime.startupScript().setRuntimeFlagValue(kGladiatorInterruptFlag, false);
-	return runtime.playDialogueLineWithVariant(0x69c, "GLADIATOR", 1);
+
+	Common::Error lineError = playGladiatorLine(0x69c, 1);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = playGladiatorLine(0x6a3, 0);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(
+		kGladiatorInitialResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1)
+		return runGladiatorDeathBranch(0x6af);
+	if (responseIndex != 2)
+		return Common::kNoError;
+
+	lineError = playGladiatorLine(0x6b3, 0);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = runtime.playDialogueFst(kDialogueC110FstPath);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	responseIndex = 0;
+	responseError = runtime.runResponseMenu(
+		kGladiatorFollowupResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1)
+		return runGladiatorDeathBranch(0x6c3);
+	if (responseIndex != 2)
+		return Common::kNoError;
+
+	lineError = playGladiatorLine(0x6c7, 1);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = playGladiatorLine(0x6cd, 0);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	StartupInteractionResult interaction;
+	if (runtime.startupScript().executeActionTag(kStartMercyTimerActionTag, interaction)) {
+		runtime.applyImmediateDialogueInteractionEffects(interaction);
+		runtime.queueDialogueInteractionIfNeeded(interaction);
+	}
+
+	return Common::kNoError;
 }
 
 } // End of namespace Harvester


Commit: 9c0fd4e9a1118aec96e28e01b4f33ae8b1738509
    https://github.com/scummvm/scummvm/commit/9c0fd4e9a1118aec96e28e01b4f33ae8b1738509
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:05+02:00

Commit Message:
HARVESTER: Restore inquisitor dialogue flow

Changed paths:
    TRACKER.md
    engines/harvester/npc/inquisitor_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index fab3dfeec36..8c749cd0a88 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,15 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `928`
-- Named/documented: `809`
+- Total functions: `929`
+- Named/documented: `810`
 - Remaining `FUN_*`: `119`
 - Remaining undocumented total: `119`
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Inquisitor dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_inquisitor` (`0x2af70..0x2afe8`). The old stub in [`engines/harvester/npc/inquisitor_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/inquisitor_dialogue.cpp) only gated on `PAIN_SOLVED`, cleared the local talk-state bit, and played `0x18e7`, while the recovered native handler is a one-shot straight-line sequence: `0x18e7 -> 0x18e8 -> 0x18e9 -> TORCH2.FST -> 0x18f8 -> START_INQ_TIM`, still gated by `PAIN_SOLVED` and `g_inquisitor_talk_state_block`. Patched the handler to mirror that exact flow, renamed the directly evidenced FST wrapper `FUN_00038810` at `0x38810` to `play_torch2_fst_sequence`, saved `HARVEST.LE`, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Live progress is now `929` total functions, `810` named/documented, and `119` remaining `FUN_*`.
+
 - On March 23, 2026, reimplemented the ScummVM Gladiator dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_gladiator` (`0x2c630..0x2c762`). The old stub in [`engines/harvester/npc/gladiator_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/gladiator_dialogue.cpp) only cleared `GLADIATOR_INTERRUPT_CONVERSATION` and played `0x69c[1]`, while the recovered native handler is a two-menu interrupt conversation: `0x69c[1] -> 0x6a3[0] -> dialog.rsp[0xc3]`, response `1` taking the death tail `0x6af[2] -> 0x6cd[2] -> queue live GLADIATOR death-or-monsterfy`, and response `2` taking `0x6b3[0] -> C110.FST -> dialog.rsp[0xc4]` where response `1` uses the alternate death tail `0x6c3[2] -> 0x6cd[2]` and response `2` plays `0x6c7[1] -> 0x6cd[0] -> START_MERCY_TIMR`. Patched the handler to mirror that exact control flow, confirmed that the only Gladiator-path helpers hit by this audit were already stably named (`play_c110_fst_sequence`, `queue_live_named_npc_death_or_monsterfy_transition`, and the shared room-event dispatcher), rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and left live progress unchanged at `928` total functions, `809` named/documented, and `119` remaining `FUN_*`.
 - On March 23, 2026, reran the full durable `repair GLADIATOR -> wait -> save_program -> verify GLADIATOR` cycle against the live `HARVEST.LE` session. `handle_talk_to_gladiator @ 0x2c630` started as a truncated `0x2c630..0x2c762` body (`57` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x2c630..0x2c762` (`307` address-count) with `9` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify GLADIATOR` still reported `status=clean` with both dialogue entry points returning, so Gladiator is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Dark Woman dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_dark_woman` (`0x33610..0x33819`). The old stub in [`engines/harvester/npc/dark_woman_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dark_woman_dialogue.cpp) only handled direct `HANDMIRROR` use as `0x4d03[2]` and a single first-talk bark `0x4cd5[1]`, while the recovered native handler is a four-menu tree: `0x4cd5[1]`, response line `0x4b`, and three nested `0x4ce6[4]` follow-ups through response lines `0x4c` and `0x4d`, with response `1` on any menu taking the `0x4ce2[2]` live NPC death-or-monsterfy branch. The fourth menu switches between `dialog.rsp[0x4e]` and `dialog.rsp[0x4f]` based on whether `HANDMIRROR` is already in inventory, then either kills Dark Woman, falls through to `0x4cfc[2]` when the mirror is absent, or runs the shared reveal branch `0x4d03[2] -> C109.FST -> 0x4d0d[3] -> hide HANDMIRROR -> SET_D_W_FLAG`. Patched the handler to mirror that control flow, preserved the native wrong-item fallthrough to the intro gate, renamed the directly evidenced FST wrapper `FUN_00038ac0` at `0x38ac0` to `play_c109_fst_sequence`, saved `HARVEST.LE`, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Live progress is now `928` total functions, `809` named/documented, and `119` remaining `FUN_*`.
@@ -110,6 +112,8 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Inquisitor validation pass that explicitly covers the recovered native sequence: talk to `INQUISITOR` with `PAIN_SOLVED` clear and the local talk-state bit freshly reset to confirm `0x18e7 -> 0x18e8 -> 0x18e9 -> TORCH2.FST -> 0x18f8 -> START_INQ_TIM`; talk again in the same eligible state to confirm the cleared local bit suppresses the sequence on repeat visits; then set `PAIN_SOLVED` and confirm the handler returns silently even after a room/reset cycle re-arms the local state.
+
 - Immediate: run an in-engine Gladiator validation pass that explicitly covers the recovered native tree: trigger `GLADIATOR_INTERRUPT_CONVERSATION`, confirm the opening `0x69c[1] -> 0x6a3[0]`, take response `1` on `dialog.rsp[0xc3]` to verify the `0x6af[2] -> 0x6cd[2]` death tail and queued live Gladiator death-or-monsterfy transition, then take response `2` to verify `0x6b3[0] -> C110.FST -> dialog.rsp[0xc4]` and both second-menu exits (`0x6c3[2] -> 0x6cd[2]` death tail versus `0x6c7[1] -> 0x6cd[0] -> START_MERCY_TIMR`). Also dismiss each response menu once to confirm the native handler simply exits without playing an extra fallback bark.
 - Immediate: run an in-engine Dark Woman validation pass that explicitly covers the recovered native tree: direct `HANDMIRROR` use to confirm `0x4d03[2] -> C109.FST -> 0x4d0d[3]`, `HANDMIRROR` removal from inventory, and `SET_D_W_FLAG`; a fresh no-item talk through response lines `0x4b`, `0x4c`, and `0x4d` to confirm response `1` on any menu plays `0x4ce2[2]` and queues the live Dark Woman death-or-monsterfy transition; response `2` through all four menus without the mirror in inventory to confirm `dialog.rsp[0x4f]` and the `0x4cfc[2]` fallback; then the same path with `HANDMIRROR` already owned to confirm `dialog.rsp[0x4e]` and the shared reveal branch even without directly presenting the item. Also present one wrong item on the first visit to confirm the native handler falls through to the intro instead of playing a rejection bark.
 - Immediate: run an in-engine Curator validation pass that explicitly covers the recovered native tree: take both `dialog.rsp[0x4a]` choices to confirm `0x430[3]` versus `0x435[0]`, confirm the shared tail `0x43a[0] -> 0x43b[0] -> PC 0x447[0] -> 0x44b[3]` always follows either valid choice, cancel or dismiss the menu once to verify the branch-specific line is skipped while the shared tail still plays, and present one arbitrary item to confirm the native handler ignores used objects.
diff --git a/engines/harvester/npc/inquisitor_dialogue.cpp b/engines/harvester/npc/inquisitor_dialogue.cpp
index 11d01fd172d..b9837614fb0 100644
--- a/engines/harvester/npc/inquisitor_dialogue.cpp
+++ b/engines/harvester/npc/inquisitor_dialogue.cpp
@@ -27,17 +27,57 @@
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kInquisitorNpc = "INQUISITOR";
+static const char *const kPainSolvedFlag = "PAIN_SOLVED";
+static const char *const kStartInquisitorTimerActionTag = "START_INQ_TIM";
+static const char *const kTorch2FstPath = "GRAPHIC/FST/TORCH2.FST";
+
+} // End of namespace
+
 bool InquisitorDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("INQUISITOR");
+	return npcName.equalsIgnoreCase(kInquisitorNpc);
 }
 
 Common::Error InquisitorDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
-	if (runtime.startupScript().getFlagValue("PAIN_SOLVED") || !_state.talkStatePending)
+	if (runtime.startupScript().getFlagValue(kPainSolvedFlag) || !_state.talkStatePending)
 		return Common::kNoError;
 
 	_state.talkStatePending = false;
-	return runtime.playDialogueLine(0x18e7, "INQUISITOR");
+
+	auto playInquisitorLine = [&](int wavId) -> Common::Error {
+		return runtime.playDialogueLine(wavId, kInquisitorNpc);
+	};
+
+	Common::Error lineError = playInquisitorLine(0x18e7);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = playInquisitorLine(0x18e8);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = playInquisitorLine(0x18e9);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = runtime.playDialogueFst(kTorch2FstPath);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = playInquisitorLine(0x18f8);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	StartupInteractionResult interaction;
+	if (runtime.startupScript().executeActionTag(kStartInquisitorTimerActionTag, interaction)) {
+		runtime.applyImmediateDialogueInteractionEffects(interaction);
+		runtime.queueDialogueInteractionIfNeeded(interaction);
+	}
+
+	return Common::kNoError;
 }
 
 } // End of namespace Harvester


Commit: 5f0be709b1090645de63dfb9657861d0260a9b45
    https://github.com/scummvm/scummvm/commit/5f0be709b1090645de63dfb9657861d0260a9b45
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:05+02:00

Commit Message:
HARVESTER: Reimplement librarian dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/librarian_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 8c749cd0a88..221c605f0af 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,10 @@
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Librarian dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_librarian` (`0x28f40..0x2910a`). The old stub in [`engines/harvester/npc/librarian_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/librarian_dialogue.cpp) collapsed the native flow into a single `0x1d5b` bark or an immediate `CLUE` award, but the recovered handler is a larger three-way split: visible `CAINBOOK` inventory auto-triggers the trade branch `0x1db1[1] -> 0x1db7 -> 0x1dbb -> 0x1dc2[PC4] -> 0x1dc6` plus `CLUE` award and `set_object_visibility_for_owner_or_room("RAH", "CAINBOOK", 0)`; wrong presented items only play `0x1d5b`; and empty-hand talks run `0x1d5b -> 0x1d5f -> 0x1d63[2] -> 0x1d67 -> 0x1d6c -> 0x1d77 -> dialog.rsp[0xfa]`, optionally `0x1d85` or `0x1d8b[4]`, then the shared tail `0x1d8f -> 0x1d94 -> 0x1d98 -> 0x1da1 -> 0x1da5`. Patched the ScummVM handler to mirror that exact control flow, kept the native visible-inventory semantics so the hidden Cainbook no longer counts as owned, rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and audited the directly touched helper chain in Ghidra without finding any encountered `FUN_*` function that met the rename bar. Live progress remains `929` total functions, `810` named/documented, and `119` remaining `FUN_*`.
+- On March 23, 2026, reran the full durable `repair LIBRARIAN -> wait -> save_program -> verify LIBRARIAN` cycle against the live `HARVEST.LE` session. `handle_talk_to_librarian @ 0x28f40` started as a truncated `0x28f40..0x2901d` body (`124` address-count) with `3` reachable dialogue calls and `3` stale `CALL_RETURN` gaps. A single repair pass rebuilt it cleanly to `0x28f40..0x2910a` (`459` address-count) with `18` reachable dialogue calls, `gapCount=0`, and `7` repair actions. After the normal settle and explicit save, a fresh `verify LIBRARIAN` still reported `status=clean` with both dialogue entry points returning, so Librarian is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Inquisitor dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_inquisitor` (`0x2af70..0x2afe8`). The old stub in [`engines/harvester/npc/inquisitor_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/inquisitor_dialogue.cpp) only gated on `PAIN_SOLVED`, cleared the local talk-state bit, and played `0x18e7`, while the recovered native handler is a one-shot straight-line sequence: `0x18e7 -> 0x18e8 -> 0x18e9 -> TORCH2.FST -> 0x18f8 -> START_INQ_TIM`, still gated by `PAIN_SOLVED` and `g_inquisitor_talk_state_block`. Patched the handler to mirror that exact flow, renamed the directly evidenced FST wrapper `FUN_00038810` at `0x38810` to `play_torch2_fst_sequence`, saved `HARVEST.LE`, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Live progress is now `929` total functions, `810` named/documented, and `119` remaining `FUN_*`.
-
+- On March 23, 2026, reran the full durable `repair INQUISITOR -> wait -> save_program -> verify INQUISITOR` cycle against the live `HARVEST.LE` session. `handle_talk_to_inquisitor @ 0x2af70` started as a truncated `0x2af70..0x2afe8` body (`55` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x2af70..0x2afe8` (`121` address-count) with `4` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify INQUISITOR` still reported `status=clean` with both dialogue entry points returning, so Inquisitor is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Gladiator dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_gladiator` (`0x2c630..0x2c762`). The old stub in [`engines/harvester/npc/gladiator_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/gladiator_dialogue.cpp) only cleared `GLADIATOR_INTERRUPT_CONVERSATION` and played `0x69c[1]`, while the recovered native handler is a two-menu interrupt conversation: `0x69c[1] -> 0x6a3[0] -> dialog.rsp[0xc3]`, response `1` taking the death tail `0x6af[2] -> 0x6cd[2] -> queue live GLADIATOR death-or-monsterfy`, and response `2` taking `0x6b3[0] -> C110.FST -> dialog.rsp[0xc4]` where response `1` uses the alternate death tail `0x6c3[2] -> 0x6cd[2]` and response `2` plays `0x6c7[1] -> 0x6cd[0] -> START_MERCY_TIMR`. Patched the handler to mirror that exact control flow, confirmed that the only Gladiator-path helpers hit by this audit were already stably named (`play_c110_fst_sequence`, `queue_live_named_npc_death_or_monsterfy_transition`, and the shared room-event dispatcher), rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and left live progress unchanged at `928` total functions, `809` named/documented, and `119` remaining `FUN_*`.
 - On March 23, 2026, reran the full durable `repair GLADIATOR -> wait -> save_program -> verify GLADIATOR` cycle against the live `HARVEST.LE` session. `handle_talk_to_gladiator @ 0x2c630` started as a truncated `0x2c630..0x2c762` body (`57` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x2c630..0x2c762` (`307` address-count) with `9` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify GLADIATOR` still reported `status=clean` with both dialogue entry points returning, so Gladiator is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Dark Woman dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_dark_woman` (`0x33610..0x33819`). The old stub in [`engines/harvester/npc/dark_woman_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dark_woman_dialogue.cpp) only handled direct `HANDMIRROR` use as `0x4d03[2]` and a single first-talk bark `0x4cd5[1]`, while the recovered native handler is a four-menu tree: `0x4cd5[1]`, response line `0x4b`, and three nested `0x4ce6[4]` follow-ups through response lines `0x4c` and `0x4d`, with response `1` on any menu taking the `0x4ce2[2]` live NPC death-or-monsterfy branch. The fourth menu switches between `dialog.rsp[0x4e]` and `dialog.rsp[0x4f]` based on whether `HANDMIRROR` is already in inventory, then either kills Dark Woman, falls through to `0x4cfc[2]` when the mirror is absent, or runs the shared reveal branch `0x4d03[2] -> C109.FST -> 0x4d0d[3] -> hide HANDMIRROR -> SET_D_W_FLAG`. Patched the handler to mirror that control flow, preserved the native wrong-item fallthrough to the intro gate, renamed the directly evidenced FST wrapper `FUN_00038ac0` at `0x38ac0` to `play_c109_fst_sequence`, saved `HARVEST.LE`, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Live progress is now `928` total functions, `809` named/documented, and `119` remaining `FUN_*`.
@@ -112,8 +114,8 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Librarian validation pass that explicitly covers the recovered native branches: enter the conversation empty-handed and take both `dialog.rsp[0xfa]` responses plus one dismissed menu to confirm `0x1d85` versus `0x1d8b[4]` and the always-present shared tail `0x1d8f -> 0x1d94 -> 0x1d98 -> 0x1da1 -> 0x1da5`; present one arbitrary wrong item to confirm the handler now only plays `0x1d5b`; then present or visibly carry `CAINBOOK` to confirm `CLUE` is awarded, the full `0x1db1[1] -> 0x1dc6` trade sequence plays, and `CAINBOOK` disappears from visible inventory afterward.
 - Immediate: run an in-engine Inquisitor validation pass that explicitly covers the recovered native sequence: talk to `INQUISITOR` with `PAIN_SOLVED` clear and the local talk-state bit freshly reset to confirm `0x18e7 -> 0x18e8 -> 0x18e9 -> TORCH2.FST -> 0x18f8 -> START_INQ_TIM`; talk again in the same eligible state to confirm the cleared local bit suppresses the sequence on repeat visits; then set `PAIN_SOLVED` and confirm the handler returns silently even after a room/reset cycle re-arms the local state.
-
 - Immediate: run an in-engine Gladiator validation pass that explicitly covers the recovered native tree: trigger `GLADIATOR_INTERRUPT_CONVERSATION`, confirm the opening `0x69c[1] -> 0x6a3[0]`, take response `1` on `dialog.rsp[0xc3]` to verify the `0x6af[2] -> 0x6cd[2]` death tail and queued live Gladiator death-or-monsterfy transition, then take response `2` to verify `0x6b3[0] -> C110.FST -> dialog.rsp[0xc4]` and both second-menu exits (`0x6c3[2] -> 0x6cd[2]` death tail versus `0x6c7[1] -> 0x6cd[0] -> START_MERCY_TIMR`). Also dismiss each response menu once to confirm the native handler simply exits without playing an extra fallback bark.
 - Immediate: run an in-engine Dark Woman validation pass that explicitly covers the recovered native tree: direct `HANDMIRROR` use to confirm `0x4d03[2] -> C109.FST -> 0x4d0d[3]`, `HANDMIRROR` removal from inventory, and `SET_D_W_FLAG`; a fresh no-item talk through response lines `0x4b`, `0x4c`, and `0x4d` to confirm response `1` on any menu plays `0x4ce2[2]` and queues the live Dark Woman death-or-monsterfy transition; response `2` through all four menus without the mirror in inventory to confirm `dialog.rsp[0x4f]` and the `0x4cfc[2]` fallback; then the same path with `HANDMIRROR` already owned to confirm `dialog.rsp[0x4e]` and the shared reveal branch even without directly presenting the item. Also present one wrong item on the first visit to confirm the native handler falls through to the intro instead of playing a rejection bark.
 - Immediate: run an in-engine Curator validation pass that explicitly covers the recovered native tree: take both `dialog.rsp[0x4a]` choices to confirm `0x430[3]` versus `0x435[0]`, confirm the shared tail `0x43a[0] -> 0x43b[0] -> PC 0x447[0] -> 0x44b[3]` always follows either valid choice, cancel or dismiss the menu once to verify the branch-specific line is skipped while the shared tail still plays, and present one arbitrary item to confirm the native handler ignores used objects.
diff --git a/engines/harvester/npc/librarian_dialogue.cpp b/engines/harvester/npc/librarian_dialogue.cpp
index 53063b721f4..e8b5d771263 100644
--- a/engines/harvester/npc/librarian_dialogue.cpp
+++ b/engines/harvester/npc/librarian_dialogue.cpp
@@ -28,13 +28,47 @@
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kLibrarianNpc = "LIBRARIAN";
+static const char *const kPcSpeaker = "PC";
+static const char *const kRahRoomName = "RAH";
+static const int kLibrarianResponseLineIndex = 0xfa;
+
+static const DialogueLineEntry kLibrarianCainbookTradeLines[] = {
+	{ 0x1db1, kLibrarianNpc, 1 },
+	{ 0x1db7, kPcSpeaker, 0 },
+	{ 0x1dbb, kLibrarianNpc, 0 },
+	{ 0x1dc2, kPcSpeaker, 4 },
+	{ 0x1dc6, kLibrarianNpc, 0 }
+};
+
+static const DialogueLineEntry kLibrarianIntroLines[] = {
+	{ 0x1d5b, kLibrarianNpc, 0 },
+	{ 0x1d5f, kPcSpeaker, 0 },
+	{ 0x1d63, kLibrarianNpc, 2 },
+	{ 0x1d67, kPcSpeaker, 0 },
+	{ 0x1d6c, kLibrarianNpc, 0 },
+	{ 0x1d77, kLibrarianNpc, 0 }
+};
+
+static const DialogueLineEntry kLibrarianNoItemFollowupLines[] = {
+	{ 0x1d8f, kLibrarianNpc, 0 },
+	{ 0x1d94, kPcSpeaker, 0 },
+	{ 0x1d98, kLibrarianNpc, 0 },
+	{ 0x1da1, kPcSpeaker, 0 },
+	{ 0x1da5, kLibrarianNpc, 0 }
+};
+
+} // End of namespace
+
 bool LibrarianDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("LIBRARIAN");
+	return npcName.equalsIgnoreCase(kLibrarianNpc);
 }
 
 Common::Error LibrarianDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &) {
-	auto hasInventoryItem = [&](const char *objectName) {
+	auto hasVisibleInventoryItem = [&](const char *objectName) {
 		Common::Array<StartupObjectRecord> inventoryObjects;
 		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
 		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
@@ -44,14 +78,52 @@ Common::Error LibrarianDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 		return false;
 	};
+	auto playSequence = [&](const DialogueLineEntry *lines, uint count) {
+		return runtime.playDialogueEntrySequence(lines, count);
+	};
+	auto completeCainbookTrade = [&]() -> Common::Error {
+		(void)runtime.startupScript().addRuntimeObjectToInventory("CLUE");
+
+		Common::Error lineError = playSequence(
+			kLibrarianCainbookTradeLines, ARRAYSIZE(kLibrarianCainbookTradeLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		(void)runtime.startupScript().setRuntimeObjectVisible(
+			kRahRoomName, "CAINBOOK", false);
+		return Common::kNoError;
+	};
+
+	if (!hasVisibleInventoryItem("CLUE") && hasVisibleInventoryItem("CAINBOOK"))
+		return completeCainbookTrade();
+
+	if (!usedItemName.empty()) {
+		if (!usedItemName.equalsIgnoreCase("CAINBOOK"))
+			return runtime.playDialogueLine(0x1d5b, kLibrarianNpc);
+
+		return completeCainbookTrade();
+	}
+
+	Common::Error lineError = playSequence(kLibrarianIntroLines, ARRAYSIZE(kLibrarianIntroLines));
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(kLibrarianResponseLineIndex, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
 
-	if (!hasInventoryItem("CLUE") && !hasInventoryItem("CAINBOOK")) {
-		if (usedItemName.empty() || !usedItemName.equalsIgnoreCase("CAINBOOK"))
-			return runtime.playDialogueLine(0x1d5b, "LIBRARIAN");
+	if (responseIndex == 1) {
+		lineError = runtime.playDialogueLine(0x1d85, kLibrarianNpc);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	} else if (responseIndex == 2) {
+		lineError = runtime.playDialogueLineWithVariant(0x1d8b, kLibrarianNpc, 4);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 
-	(void)runtime.startupScript().addRuntimeObjectToInventory("CLUE");
-	return runtime.playDialogueLine(0x1db1, "LIBRARIAN");
+	return playSequence(kLibrarianNoItemFollowupLines, ARRAYSIZE(kLibrarianNoItemFollowupLines));
 }
 
 } // End of namespace Harvester


Commit: 4564bfb457c28187d3f48c9a85dee59d66a0ec77
    https://github.com/scummvm/scummvm/commit/4564bfb457c28187d3f48c9a85dee59d66a0ec77
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:05+02:00

Commit Message:
HARVESTER: Reimplement madam dialogue flow

Changed paths:
    TRACKER.md
    engines/harvester/npc/madam_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 221c605f0af..691669776db 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,15 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `929`
-- Named/documented: `810`
+- Total functions: `930`
+- Named/documented: `811`
 - Remaining `FUN_*`: `119`
 - Remaining undocumented total: `119`
 
 ## Last Confirmed Action
 
+- On March 23, 2026, reimplemented the ScummVM Madam dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_madam` (`0x2adc0..0x2af64`). The old stub in [`engines/harvester/npc/madam_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/madam_dialogue.cpp) only cleared `MADAM_INTERRUPT_CONVERSATION` and played `0x1537[1]`, while the recovered native handler is a single interrupt-gated two-menu exchange: `0x1537[1] -> dialog.rsp[0xfb]`, any non-`1` top-level response taking `0x1577[0] -> 0x157b[2] -> queue live HOOKER death-or-monsterfy`, and response `1` taking `0x1542[1]` into either the no-cash branch `PC 0x156e[0] -> MADAM 0x1572[0] -> 0x157b[2] -> queue live HOOKER death-or-monsterfy`, the inner `dialog.rsp[0xfc]` response-`1` branch `0x154e[0] -> 0x157b[2] -> queue live HOOKER death-or-monsterfy`, or the paid branch `0x1555[1] -> hide INVENTORY:BARCASH200 -> HOARLOVE.FST -> 0x155a[0] -> 0x1563[0] -> C117.FST -> HURT_PC_0 -> ACTV_LUST_EXIT`. Patched the handler to mirror that exact flow, renamed the directly evidenced FST wrapper `FUN_000387c0` at `0x387c0` to `play_c117_fst_sequence`, saved `HARVEST.LE`, and refreshed live progress to `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
+- On March 23, 2026, reran the full durable `repair MADAM -> wait -> save_program -> verify MADAM` cycle against the live `HARVEST.LE` session. `handle_talk_to_madam @ 0x2adc0` started as a truncated `0x2adc0..0x2af64` body (`57` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x2adc0..0x2af64` (`421` address-count) with `12` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify MADAM` still reported `status=clean` with both dialogue entry points returning, so Madam is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Librarian dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_librarian` (`0x28f40..0x2910a`). The old stub in [`engines/harvester/npc/librarian_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/librarian_dialogue.cpp) collapsed the native flow into a single `0x1d5b` bark or an immediate `CLUE` award, but the recovered handler is a larger three-way split: visible `CAINBOOK` inventory auto-triggers the trade branch `0x1db1[1] -> 0x1db7 -> 0x1dbb -> 0x1dc2[PC4] -> 0x1dc6` plus `CLUE` award and `set_object_visibility_for_owner_or_room("RAH", "CAINBOOK", 0)`; wrong presented items only play `0x1d5b`; and empty-hand talks run `0x1d5b -> 0x1d5f -> 0x1d63[2] -> 0x1d67 -> 0x1d6c -> 0x1d77 -> dialog.rsp[0xfa]`, optionally `0x1d85` or `0x1d8b[4]`, then the shared tail `0x1d8f -> 0x1d94 -> 0x1d98 -> 0x1da1 -> 0x1da5`. Patched the ScummVM handler to mirror that exact control flow, kept the native visible-inventory semantics so the hidden Cainbook no longer counts as owned, rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and audited the directly touched helper chain in Ghidra without finding any encountered `FUN_*` function that met the rename bar. Live progress remains `929` total functions, `810` named/documented, and `119` remaining `FUN_*`.
 - On March 23, 2026, reran the full durable `repair LIBRARIAN -> wait -> save_program -> verify LIBRARIAN` cycle against the live `HARVEST.LE` session. `handle_talk_to_librarian @ 0x28f40` started as a truncated `0x28f40..0x2901d` body (`124` address-count) with `3` reachable dialogue calls and `3` stale `CALL_RETURN` gaps. A single repair pass rebuilt it cleanly to `0x28f40..0x2910a` (`459` address-count) with `18` reachable dialogue calls, `gapCount=0`, and `7` repair actions. After the normal settle and explicit save, a fresh `verify LIBRARIAN` still reported `status=clean` with both dialogue entry points returning, so Librarian is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Inquisitor dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_inquisitor` (`0x2af70..0x2afe8`). The old stub in [`engines/harvester/npc/inquisitor_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/inquisitor_dialogue.cpp) only gated on `PAIN_SOLVED`, cleared the local talk-state bit, and played `0x18e7`, while the recovered native handler is a one-shot straight-line sequence: `0x18e7 -> 0x18e8 -> 0x18e9 -> TORCH2.FST -> 0x18f8 -> START_INQ_TIM`, still gated by `PAIN_SOLVED` and `g_inquisitor_talk_state_block`. Patched the handler to mirror that exact flow, renamed the directly evidenced FST wrapper `FUN_00038810` at `0x38810` to `play_torch2_fst_sequence`, saved `HARVEST.LE`, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Live progress is now `929` total functions, `810` named/documented, and `119` remaining `FUN_*`.
@@ -114,6 +116,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Madam validation pass that explicitly covers the recovered native branches: trigger `MADAM_INTERRUPT_CONVERSATION`, take the non-`1` `dialog.rsp[0xfb]` path once to confirm `0x1577[0] -> 0x157b[2]` and the queued live `HOOKER` death-or-monsterfy transition; then take response `1` without `BARCASH200` in inventory to confirm `PC 0x156e[0] -> MADAM 0x1572[0] -> 0x157b[2]` and the same death transition; finally repeat with visible `BARCASH200` in inventory to confirm both inner `dialog.rsp[0xfc]` exits, including response `1` to `0x154e[0] -> 0x157b[2]` and response `2` to `0x1555[1] -> HOARLOVE.FST -> 0x155a[0] -> 0x1563[0] -> C117.FST`, `BARCASH200` removal from inventory, and the `HURT_PC_0` plus `ACTV_LUST_EXIT` action-tag side effects. Also dismiss `dialog.rsp[0xfc]` once to verify the native handler simply returns without forcing a fallback bark.
 - Immediate: run an in-engine Librarian validation pass that explicitly covers the recovered native branches: enter the conversation empty-handed and take both `dialog.rsp[0xfa]` responses plus one dismissed menu to confirm `0x1d85` versus `0x1d8b[4]` and the always-present shared tail `0x1d8f -> 0x1d94 -> 0x1d98 -> 0x1da1 -> 0x1da5`; present one arbitrary wrong item to confirm the handler now only plays `0x1d5b`; then present or visibly carry `CAINBOOK` to confirm `CLUE` is awarded, the full `0x1db1[1] -> 0x1dc6` trade sequence plays, and `CAINBOOK` disappears from visible inventory afterward.
 - Immediate: run an in-engine Inquisitor validation pass that explicitly covers the recovered native sequence: talk to `INQUISITOR` with `PAIN_SOLVED` clear and the local talk-state bit freshly reset to confirm `0x18e7 -> 0x18e8 -> 0x18e9 -> TORCH2.FST -> 0x18f8 -> START_INQ_TIM`; talk again in the same eligible state to confirm the cleared local bit suppresses the sequence on repeat visits; then set `PAIN_SOLVED` and confirm the handler returns silently even after a room/reset cycle re-arms the local state.
 - Immediate: run an in-engine Gladiator validation pass that explicitly covers the recovered native tree: trigger `GLADIATOR_INTERRUPT_CONVERSATION`, confirm the opening `0x69c[1] -> 0x6a3[0]`, take response `1` on `dialog.rsp[0xc3]` to verify the `0x6af[2] -> 0x6cd[2]` death tail and queued live Gladiator death-or-monsterfy transition, then take response `2` to verify `0x6b3[0] -> C110.FST -> dialog.rsp[0xc4]` and both second-menu exits (`0x6c3[2] -> 0x6cd[2]` death tail versus `0x6c7[1] -> 0x6cd[0] -> START_MERCY_TIMR`). Also dismiss each response menu once to confirm the native handler simply exits without playing an extra fallback bark.
diff --git a/engines/harvester/npc/madam_dialogue.cpp b/engines/harvester/npc/madam_dialogue.cpp
index 1b608d3a15f..9ebc92a1959 100644
--- a/engines/harvester/npc/madam_dialogue.cpp
+++ b/engines/harvester/npc/madam_dialogue.cpp
@@ -29,21 +29,144 @@ namespace Harvester {
 
 namespace {
 
+static const char *const kMadamNpc = "MADAM";
+static const char *const kPcSpeaker = "PC";
+static const char *const kHookerNpc = "HOOKER";
+static const char *const kBarCash200ItemName = "BARCASH200";
+static const char *const kInventoryOwnerName = "INVENTORY";
 static const char *const kMadamInterruptFlag = "MADAM_INTERRUPT_CONVERSATION";
+static const char *const kHurtPcActionTag = "HURT_PC_0";
+static const char *const kActivateLustExitActionTag = "ACTV_LUST_EXIT";
+static const char *const kDialogueHoarloveFstPath = "GRAPHIC/FST/HOARLOVE.FST";
+static const char *const kDialogueC117FstPath = "GRAPHIC/FST/C117.FST";
+static const int kMadamInitialResponseLine = 0xfb;
+static const int kMadamPaymentResponseLine = 0xfc;
 
 } // End of namespace
 
 bool MadamDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("MADAM");
+	return npcName.equalsIgnoreCase(kMadamNpc);
 }
 
 Common::Error MadamDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
+	auto playMadamLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kMadamNpc, headVariant);
+	};
+	auto playPcLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kPcSpeaker, headVariant);
+	};
+	auto queueHookerDeathOrMonsterfyTransition = [&]() {
+		StartupInteractionResult interaction;
+		if (runtime.startupScript().triggerRuntimeNpcDeathOrMonsterfy(kHookerNpc))
+			interaction.mutatedRuntimeState = true;
+		runtime.queueDialogueInteractionIfNeeded(interaction);
+	};
+	auto executeActionTagIfSet = [&](const char *actionTag, bool mutatedRuntimeState = false) {
+		StartupInteractionResult interaction;
+		interaction.mutatedRuntimeState = mutatedRuntimeState;
+		if (runtime.startupScript().executeActionTag(actionTag, interaction)) {
+			runtime.applyImmediateDialogueInteractionEffects(interaction);
+			runtime.queueDialogueInteractionIfNeeded(interaction);
+		} else if (interaction.mutatedRuntimeState) {
+			runtime.queueDialogueInteractionIfNeeded(interaction);
+		}
+	};
+
 	if (!runtime.startupScript().getFlagValue(kMadamInterruptFlag))
 		return Common::kNoError;
 
 	(void)runtime.startupScript().setRuntimeFlagValue(kMadamInterruptFlag, false);
-	return runtime.playDialogueLineWithVariant(0x1537, "MADAM", 1);
+
+	Common::Error lineError = playMadamLine(0x1537, 1);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(kMadamInitialResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex != 1) {
+		lineError = playMadamLine(0x1577);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = playMadamLine(0x157b, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		queueHookerDeathOrMonsterfyTransition();
+		return Common::kNoError;
+	}
+
+	lineError = playMadamLine(0x1542, 1);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	if (!runtime.startupScript().isObjectInInventory(kBarCash200ItemName)) {
+		lineError = playPcLine(0x156e);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = playMadamLine(0x1572);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = playMadamLine(0x157b, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		queueHookerDeathOrMonsterfyTransition();
+		return Common::kNoError;
+	}
+
+	responseIndex = 0;
+	responseError = runtime.runResponseMenu(kMadamPaymentResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1) {
+		lineError = playMadamLine(0x154e);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		lineError = playMadamLine(0x157b, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		queueHookerDeathOrMonsterfyTransition();
+		return Common::kNoError;
+	}
+	if (responseIndex != 2)
+		return Common::kNoError;
+
+	lineError = playMadamLine(0x1555, 1);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	const bool removedBarCash200 = runtime.startupScript().setRuntimeObjectVisible(
+		kInventoryOwnerName, kBarCash200ItemName, false);
+
+	lineError = runtime.playDialogueFst(kDialogueHoarloveFstPath);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = playMadamLine(0x155a);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = playMadamLine(0x1563);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	lineError = runtime.playDialogueFst(kDialogueC117FstPath);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	executeActionTagIfSet(kHurtPcActionTag, removedBarCash200);
+	executeActionTagIfSet(kActivateLustExitActionTag);
+	return Common::kNoError;
 }
 
 } // End of namespace Harvester


Commit: f943278882f90cb00dbb62130b029d71bc60645b
    https://github.com/scummvm/scummvm/commit/f943278882f90cb00dbb62130b029d71bc60645b
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:06+02:00

Commit Message:
HARVESTER: Reimplement lodge chef dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/lodge_chef_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 691669776db..dd978b1b5af 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,8 @@
 
 ## Last Confirmed Action
 
+- On March 24, 2026, reimplemented the ScummVM Lodge Chef dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_lodge_chef` (`0x38170..0x38220`). The old stub in [`engines/harvester/npc/lodge_chef_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/lodge_chef_dialogue.cpp) only played `0x1b10[2]`, while the recovered native handler is a fixed four-line intro `0x1b10[2] -> 0x1b19[0] -> 0x1b1a[3] -> 0x1b1b[0]`, a single `dialog.rsp[0x21]` branch where response `1` plays `0x1b2c[3]` and response `2` plays `0x1b31[0]`, and a fixed tail `PC 0x1b36[0] -> LODGE_CHEF 0x1b3a[3]`. Patched the handler to mirror that exact flow, audited the directly encountered Ghidra callees without finding any `FUN_*` symbol that met the rename bar, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with progress unchanged at `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
+
 - On March 23, 2026, reimplemented the ScummVM Madam dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_madam` (`0x2adc0..0x2af64`). The old stub in [`engines/harvester/npc/madam_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/madam_dialogue.cpp) only cleared `MADAM_INTERRUPT_CONVERSATION` and played `0x1537[1]`, while the recovered native handler is a single interrupt-gated two-menu exchange: `0x1537[1] -> dialog.rsp[0xfb]`, any non-`1` top-level response taking `0x1577[0] -> 0x157b[2] -> queue live HOOKER death-or-monsterfy`, and response `1` taking `0x1542[1]` into either the no-cash branch `PC 0x156e[0] -> MADAM 0x1572[0] -> 0x157b[2] -> queue live HOOKER death-or-monsterfy`, the inner `dialog.rsp[0xfc]` response-`1` branch `0x154e[0] -> 0x157b[2] -> queue live HOOKER death-or-monsterfy`, or the paid branch `0x1555[1] -> hide INVENTORY:BARCASH200 -> HOARLOVE.FST -> 0x155a[0] -> 0x1563[0] -> C117.FST -> HURT_PC_0 -> ACTV_LUST_EXIT`. Patched the handler to mirror that exact flow, renamed the directly evidenced FST wrapper `FUN_000387c0` at `0x387c0` to `play_c117_fst_sequence`, saved `HARVEST.LE`, and refreshed live progress to `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
 - On March 23, 2026, reran the full durable `repair MADAM -> wait -> save_program -> verify MADAM` cycle against the live `HARVEST.LE` session. `handle_talk_to_madam @ 0x2adc0` started as a truncated `0x2adc0..0x2af64` body (`57` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x2adc0..0x2af64` (`421` address-count) with `12` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify MADAM` still reported `status=clean` with both dialogue entry points returning, so Madam is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 23, 2026, reimplemented the ScummVM Librarian dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_librarian` (`0x28f40..0x2910a`). The old stub in [`engines/harvester/npc/librarian_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/librarian_dialogue.cpp) collapsed the native flow into a single `0x1d5b` bark or an immediate `CLUE` award, but the recovered handler is a larger three-way split: visible `CAINBOOK` inventory auto-triggers the trade branch `0x1db1[1] -> 0x1db7 -> 0x1dbb -> 0x1dc2[PC4] -> 0x1dc6` plus `CLUE` award and `set_object_visibility_for_owner_or_room("RAH", "CAINBOOK", 0)`; wrong presented items only play `0x1d5b`; and empty-hand talks run `0x1d5b -> 0x1d5f -> 0x1d63[2] -> 0x1d67 -> 0x1d6c -> 0x1d77 -> dialog.rsp[0xfa]`, optionally `0x1d85` or `0x1d8b[4]`, then the shared tail `0x1d8f -> 0x1d94 -> 0x1d98 -> 0x1da1 -> 0x1da5`. Patched the ScummVM handler to mirror that exact control flow, kept the native visible-inventory semantics so the hidden Cainbook no longer counts as owned, rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and audited the directly touched helper chain in Ghidra without finding any encountered `FUN_*` function that met the rename bar. Live progress remains `929` total functions, `810` named/documented, and `119` remaining `FUN_*`.
@@ -143,7 +145,7 @@
 - Immediate: run an in-engine Edna validation pass that explicitly hits the restored native-only branches: `KILLED_KARIN1` to confirm the flag now clears; `DNA_S_SUICIDE_NOTE` with and without `KARIN_FOUND_ALIVE` to confirm `C043.FST` / `C043K.FST`, shared states `d2ea4` / `d2ea8`, and `DNAEXT:SIGNOUT`; direct `K_PURSE` use plus the `KARIN_FOUND_DEAD -> 0xa2 -> 0xa3/0xa4` nested purse menu to verify `REWARD_MONEY` and the `RAH`-hidden purse side effect; the day-5 alive and dead follow-ups; and the keyword loop through the recovered `0xa6`, `0xac/0xad`, `0xb0`, `0xb2`, `0xb6/0xb7`, `0xba`, `0xbc/0xbd/0xbe`, `0xc0`, `0xc2`, typed unknown text, and goodbye branches to confirm the restored menu buffer rewrites and both `C091.FST` / `C070.FST` exits.
 - Immediate: run an in-engine Karin validation pass that explicitly hits the restored native-only branches: the first `KARIN_FOUND_ALIVE` + `IN_CEM10` visit to confirm the full `0x10a5`..`0x10c3` early-return sequence; first and repeat no-item visits before Karin is found alive to confirm the recovered `0x1076`..`0x108b` prelude plus the shared `0x10dc` bark; first and repeat uses of `CASKET_PHOTO` or `PHOTO_OF_WHALEY_HERRILL` to verify the `0x10e7` Edna follow-up, portrait reset, shared evidence-state write, one-shot override, and repeat-photo `CALL_POLICE` escalation; and `INV_MAG` to confirm `0x10ee` / `0x10f2[2]` plus `DNALFT_PERVERT`.
 - Immediate: run an in-engine Dwayne validation pass that explicitly hits the restored native-only branches: `CHECK_EVIDENCE_DOOR` and the first/second `ST_BEDRM` visits to confirm the new flag clears; the sheriff-in-diner intro and revisit to verify Edna `0x3490/0x3496`, the optional `0x34a0/0x34a4` tail, and the later keyword loop; Boyle's button, Karin's purse, note/checkbook photocopy -> original note/checkbook, both `TV_DEED` and `TV_DEED_PHOTOCOPY` response trees plus the override revisit on the real deed, corpse photo with and without shared state `d2ec8`, and both-ledger handoff with and without `SHERIFF_IN_DINER`; `KARIN_KIDNAPED` through response menus `0x7b/0x7c`, `KARIN_FOUND_DEAD` without the purse through `0x7f/0x80`, and the `PC_HAS_GOOJF_CARD` arrest path with and without `PC_KILLED_ANYONE` to confirm the confiscation and bust follow-up state now matches native.
-- Immediate: resume the sequential talk-handler sweep at `LODGE_CHEF`, using the proven single-handler workflow `verify <NPC> -> repair <NPC> -> wait ~10s -> save_program -> verify <NPC>` and immediately rerunning the same NPC once when the first saved verify snaps back. After the durable Sparky retry, the earlier Pastorelli/Parsons work, and the Ryder re-audit, the remaining not-yet-confirmed handlers in that queue are `LODGE_CHEF` and `MEMB_DIR`.
+- Immediate: resume the sequential talk-handler sweep at `MEMB_DIR`, using the proven single-handler workflow `verify <NPC> -> repair <NPC> -> wait ~10s -> save_program -> verify <NPC>` and immediately rerunning the same NPC once when the first saved verify snaps back. After the durable Lodge Chef pass, the durable Sparky retry, the earlier Pastorelli/Parsons work, and the Ryder re-audit, the remaining not-yet-confirmed handler in that queue is `MEMB_DIR`.
 - Immediate: run an in-engine Johnson validation pass that explicitly hits the restored native-only branches: `OPENING_MANHOLE_DAYTIME` twice to confirm `0xbe0` now clears the flag; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared state `d2ebc`, and `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` to verify the restored `0xb7b`, `0xb80`, `0xb90`, `0xb94`, `0xb99`, `0xba4`, and `0xba8` follow-ups; the first `SCRATCHED_TUCKER` visit with `BOLT_OF_CLOTH_TAKEN` already set to confirm `0xc25` / `0xc2b` / `0xc30` plus `0xc5f` / `0xc69` and that the Johnson intro only replays on the later revisit; and a `KARIN_KIDNAPED` follow-up that later reaches a consumer such as Stephanie to confirm the shared kidnapped-dialogue state now propagates correctly.
 - Immediate: run an in-engine Loomis validation pass that explicitly hits the restored native-only branches: first talk with and without `SHERIFF_IN_DINER` to confirm the `0x1112 -> 0x1118 -> 0x1121 -> response 0x53 -> 0x1130[3]` intro; the second and third no-item visits to confirm the one-shot `0x11a8/0x11ac/0x11b0[3]` follow-up, the later `0x11f6` bark, and the `STEPH_MIDGAME_PLAYED` / day-5 `0x1230[3]` and `0x123c` gates; the keyword loop through `LOOMIS`, `Phelps`, `Man's Needs`, sheriff topics, `0x65`, `0x66`, typed unknown text, and goodbye to verify the restored alias matches, portrait variants, `0x11fc` fallback, and `0x11a1[1]` exit; note/checkbook or TV-deed evidence through both `0x50` responses to confirm `0x1214` / `0x1219` / `0x121f[3]` plus `PC_FRY_DADDY_1`; and `INV_MAG` in the diner branch through both `0x51` responses to confirm `0x11c6[1]`, `0x11cc[3]`, drawer visibility, `GAVE_MAG_TO_LOOMIS_TODAY`, and the `C048.FST` tail.
 - Immediate: run an in-engine Mrs. Potts validation pass that explicitly hits the restored native-only paths: `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `LEDGER` with `HAVE_BOTH_LEDGERS`, and the `GOT_REMAINS_FOR_LODGE` response-1 death branch to confirm the corrected portrait variants; the non-dead revisit gate around `0x281b` / `0x2820` / `0x2825`; and `DINER_BURNED` with and without `KARIN_HUNG`, taking both `0x236` responses to verify the new `0x2914` / `0x2918` / `0x2926` continuation and the response-2 jump into the shared death-in-chair sequence.
diff --git a/engines/harvester/npc/lodge_chef_dialogue.cpp b/engines/harvester/npc/lodge_chef_dialogue.cpp
index 70f2c4da210..f1ff549d646 100644
--- a/engines/harvester/npc/lodge_chef_dialogue.cpp
+++ b/engines/harvester/npc/lodge_chef_dialogue.cpp
@@ -27,13 +27,54 @@
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kLodgeChefNpc = "LODGE_CHEF";
+static const char *const kPcSpeaker = "PC";
+static const int kLodgeChefResponseLine = 0x21;
+
+static const DialogueLineEntry kLodgeChefIntroLines[] = {
+	{ 0x1b10, kLodgeChefNpc, 2 },
+	{ 0x1b19, kLodgeChefNpc, 0 },
+	{ 0x1b1a, kLodgeChefNpc, 3 },
+	{ 0x1b1b, kLodgeChefNpc, 0 }
+};
+
+static const DialogueLineEntry kLodgeChefTailLines[] = {
+	{ 0x1b36, kPcSpeaker, 0 },
+	{ 0x1b3a, kLodgeChefNpc, 3 }
+};
+
+} // End of namespace
+
 bool LodgeChefDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("LODGE_CHEF");
+	return npcName.equalsIgnoreCase(kLodgeChefNpc);
 }
 
 Common::Error LodgeChefDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
-	return runtime.playDialogueLineWithVariant(0x1b10, "LODGE_CHEF", 2);
+	Common::Error lineError = runtime.playDialogueEntrySequence(
+		kLodgeChefIntroLines, ARRAYSIZE(kLodgeChefIntroLines));
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(
+		kLodgeChefResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1) {
+		lineError = runtime.playDialogueLineWithVariant(0x1b2c, kLodgeChefNpc, 3);
+	} else if (responseIndex == 2) {
+		lineError = runtime.playDialogueLineWithVariant(0x1b31, kLodgeChefNpc, 0);
+	} else {
+		lineError = Common::kNoError;
+	}
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	return runtime.playDialogueEntrySequence(kLodgeChefTailLines, ARRAYSIZE(kLodgeChefTailLines));
 }
 
 } // End of namespace Harvester


Commit: 2ea7c8bac0519747c705581f7704ca1734fad124
    https://github.com/scummvm/scummvm/commit/2ea7c8bac0519747c705581f7704ca1734fad124
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:06+02:00

Commit Message:
HARVESTER: Sync maint man dialogue with ghidra

Changed paths:
    TRACKER.md
    engines/harvester/npc/maint_man_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index dd978b1b5af..55fa97a4d7a 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Last Confirmed Action
 
+- On March 24, 2026, reimplemented the ScummVM Maint Man dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_maint_man` (`0x2b6a0..0x2b724`). The native routine only checks `MAINTENANCE_MAN_FIRST_CONVERSATION`, `MAINTENANCE_MAN_THIRD_CONVERSATION`, and `MAINTENANCE_MAN_FOURTH_CONVERSATION`, returning `MAINT_MAN 0xdbc[2]` or `0xdc4[2]` on the latter two flags and otherwise sharing a single tail call that selects either `0xdaf[1]` for the first-conversation bark or `0xdb6[0]` for the default bark. Patched [`engines/harvester/npc/maint_man_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/maint_man_dialogue.cpp) to mirror that recovered control flow, audited the directly encountered Ghidra callees without finding any `FUN_*` symbol that met the rename bar, and verified progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Lodge Chef dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_lodge_chef` (`0x38170..0x38220`). The old stub in [`engines/harvester/npc/lodge_chef_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/lodge_chef_dialogue.cpp) only played `0x1b10[2]`, while the recovered native handler is a fixed four-line intro `0x1b10[2] -> 0x1b19[0] -> 0x1b1a[3] -> 0x1b1b[0]`, a single `dialog.rsp[0x21]` branch where response `1` plays `0x1b2c[3]` and response `2` plays `0x1b31[0]`, and a fixed tail `PC 0x1b36[0] -> LODGE_CHEF 0x1b3a[3]`. Patched the handler to mirror that exact flow, audited the directly encountered Ghidra callees without finding any `FUN_*` symbol that met the rename bar, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with progress unchanged at `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
 
 - On March 23, 2026, reimplemented the ScummVM Madam dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_madam` (`0x2adc0..0x2af64`). The old stub in [`engines/harvester/npc/madam_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/madam_dialogue.cpp) only cleared `MADAM_INTERRUPT_CONVERSATION` and played `0x1537[1]`, while the recovered native handler is a single interrupt-gated two-menu exchange: `0x1537[1] -> dialog.rsp[0xfb]`, any non-`1` top-level response taking `0x1577[0] -> 0x157b[2] -> queue live HOOKER death-or-monsterfy`, and response `1` taking `0x1542[1]` into either the no-cash branch `PC 0x156e[0] -> MADAM 0x1572[0] -> 0x157b[2] -> queue live HOOKER death-or-monsterfy`, the inner `dialog.rsp[0xfc]` response-`1` branch `0x154e[0] -> 0x157b[2] -> queue live HOOKER death-or-monsterfy`, or the paid branch `0x1555[1] -> hide INVENTORY:BARCASH200 -> HOARLOVE.FST -> 0x155a[0] -> 0x1563[0] -> C117.FST -> HURT_PC_0 -> ACTV_LUST_EXIT`. Patched the handler to mirror that exact flow, renamed the directly evidenced FST wrapper `FUN_000387c0` at `0x387c0` to `play_c117_fst_sequence`, saved `HARVEST.LE`, and refreshed live progress to `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
diff --git a/engines/harvester/npc/maint_man_dialogue.cpp b/engines/harvester/npc/maint_man_dialogue.cpp
index 0cfd39911eb..b4e5ceffb7a 100644
--- a/engines/harvester/npc/maint_man_dialogue.cpp
+++ b/engines/harvester/npc/maint_man_dialogue.cpp
@@ -27,22 +27,37 @@
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kMaintManNpc = "MAINT_MAN";
+static const char *const kMaintManFirstConversationFlag = "MAINTENANCE_MAN_FIRST_CONVERSATION";
+static const char *const kMaintManThirdConversationFlag = "MAINTENANCE_MAN_THIRD_CONVERSATION";
+static const char *const kMaintManFourthConversationFlag = "MAINTENANCE_MAN_FOURTH_CONVERSATION";
+
+} // End of namespace
+
 bool MaintManDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("MAINT_MAN");
+	return npcName.equalsIgnoreCase(kMaintManNpc);
 }
 
 Common::Error MaintManDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
-	if (runtime.startupScript().getFlagValue("MAINTENANCE_MAN_FIRST_CONVERSATION"))
-		return runtime.playDialogueLineWithVariant(0xdaf, "MAINT_MAN", 1);
-
-	if (runtime.startupScript().getFlagValue("MAINTENANCE_MAN_THIRD_CONVERSATION"))
-		return runtime.playDialogueLineWithVariant(0xdbc, "MAINT_MAN", 2);
-
-	if (runtime.startupScript().getFlagValue("MAINTENANCE_MAN_FOURTH_CONVERSATION"))
-		return runtime.playDialogueLineWithVariant(0xdc4, "MAINT_MAN", 2);
-
-	return runtime.playDialogueLine(0xdb6, "MAINT_MAN");
+	int wavId = 0xdb6;
+	int headVariant = 0;
+
+	if (runtime.startupScript().getFlagValue(kMaintManFirstConversationFlag)) {
+		wavId = 0xdaf;
+		headVariant = 1;
+	} else if (runtime.startupScript().getFlagValue(kMaintManThirdConversationFlag)) {
+		return runtime.playDialogueLineWithVariant(0xdbc, kMaintManNpc, 2);
+	} else if (runtime.startupScript().getFlagValue(kMaintManFourthConversationFlag)) {
+		return runtime.playDialogueLineWithVariant(0xdc4, kMaintManNpc, 2);
+	}
+
+	if (headVariant != 0)
+		return runtime.playDialogueLineWithVariant(wavId, kMaintManNpc, headVariant);
+
+	return runtime.playDialogueLine(wavId, kMaintManNpc);
 }
 
 } // End of namespace Harvester


Commit: 5bba46ba2e7e66cce281779133392116923f99b4
    https://github.com/scummvm/scummvm/commit/5bba46ba2e7e66cce281779133392116923f99b4
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:06+02:00

Commit Message:
HARVESTER: Restore MCKNIGHT dialogue flow

Changed paths:
    TRACKER.md
    engines/harvester/npc/mcknight_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 55fa97a4d7a..b0b2f70cb20 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,10 @@
 
 ## Last Confirmed Action
 
+- On March 24, 2026, reimplemented the ScummVM Mcknight dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mcknight` (`0x2c770..0x2caf3`). The old stub in [`engines/harvester/npc/mcknight_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mcknight_dialogue.cpp) only played a single `0x1587[2]` no-item bark, simple one-line evidence responses, and never latched the `LOOK_SAFE_1ST` one-shot gate, while the recovered native handler is `LOOK_SAFE_2ND -> 0x15d0[2]`, one-shot `LOOK_SAFE_1ST -> 0x15c9[2]`, first empty-hand talk `0x1587[2] -> PC 0x158b[0] -> 0x158f[0] -> PC 0x1594[0] -> 0x1598[2] -> PC 0x159d[0]` plus shared state `d2f04`, default empty-hand bark `0x15e4`, corpse-photo follow-up `0x15f2` plus either `PC 0x15f8 -> 0x15fd` or `PC 0x1605 -> 0x1609` depending on shared state `d2ebc`, ledgers `0x1611[1]`, and note/checkbook evidence `0x161b[1] -> PC 0x1621[0] -> 0x1626[3] -> PC 0x162b[4] -> 0x1631[0]`, with wrong items still falling back to `0x15de`. Patched [`engines/harvester/npc/mcknight_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mcknight_dialogue.cpp) to mirror that exact control flow, audited the directly encountered Ghidra callees without finding any `FUN_*` symbol that met the rename bar, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
+- On March 24, 2026, reran the full durable `repair MEMB_DIR -> wait -> save_program -> verify MEMB_DIR` cycle against the live `HARVEST.LE` session. `handle_talk_to_memb_dir @ 0x396d0` started as a truncated `0x396d0..0x39781` body (`76` address-count) with `2` reachable dialogue calls and `2` stale `CALL_RETURN` gaps. The rebuild needed two in-memory passes and finished cleanly at `0x396d0..0x39781` (`178` address-count) with `4` reachable dialogue calls, `gapCount=0`, and `7` repair actions. After the normal settle and explicit save, a fresh `verify MEMB_DIR` still reported `status=clean` with both dialogue entry points returning, so Memb Dir is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
+- On March 24, 2026, refactored [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) into a tighter subsystem-first layout without changing any confirmed findings. The file now opens with a concise summary plus an at-a-glance index, and the former flat heading sprawl is grouped under `Runtime And Gameplay`, `Interface And Input`, `Data Types And Helper Layer`, `UI And Inventory Details`, and `Open Questions`. Progress counts remain `930` total functions, `811` named/documented, and `119` remaining `FUN_*` because this was documentation cleanup only.
+- On March 24, 2026, reran the full durable `repair MCKNIGHT -> wait -> save_program -> verify MCKNIGHT` cycle against the live `HARVEST.LE` session. `handle_talk_to_mcknight @ 0x2c770` started as a truncated `0x2c770..0x2caed` body (`472` address-count) with `8` reachable dialogue calls and `8` stale `CALL_RETURN` gaps. The rebuild needed two in-memory passes and finished cleanly at `0x2c770..0x2caf3` (`730` address-count) with `17` reachable dialogue calls, `gapCount=0`, and `20` repair actions. After the normal settle and explicit save, a fresh `verify MCKNIGHT` still reported `status=clean` with both dialogue entry points returning, so McKnight is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 24, 2026, reimplemented the ScummVM Maint Man dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_maint_man` (`0x2b6a0..0x2b724`). The native routine only checks `MAINTENANCE_MAN_FIRST_CONVERSATION`, `MAINTENANCE_MAN_THIRD_CONVERSATION`, and `MAINTENANCE_MAN_FOURTH_CONVERSATION`, returning `MAINT_MAN 0xdbc[2]` or `0xdc4[2]` on the latter two flags and otherwise sharing a single tail call that selects either `0xdaf[1]` for the first-conversation bark or `0xdb6[0]` for the default bark. Patched [`engines/harvester/npc/maint_man_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/maint_man_dialogue.cpp) to mirror that recovered control flow, audited the directly encountered Ghidra callees without finding any `FUN_*` symbol that met the rename bar, and verified progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Lodge Chef dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_lodge_chef` (`0x38170..0x38220`). The old stub in [`engines/harvester/npc/lodge_chef_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/lodge_chef_dialogue.cpp) only played `0x1b10[2]`, while the recovered native handler is a fixed four-line intro `0x1b10[2] -> 0x1b19[0] -> 0x1b1a[3] -> 0x1b1b[0]`, a single `dialog.rsp[0x21]` branch where response `1` plays `0x1b2c[3]` and response `2` plays `0x1b31[0]`, and a fixed tail `PC 0x1b36[0] -> LODGE_CHEF 0x1b3a[3]`. Patched the handler to mirror that exact flow, audited the directly encountered Ghidra callees without finding any `FUN_*` symbol that met the rename bar, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with progress unchanged at `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
 
@@ -119,6 +123,9 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine MCKNIGHT validation pass that explicitly covers the restored native-only branches: `LOOK_SAFE_1ST` twice to confirm `0x15c9[2]` is now one-shot, `LOOK_SAFE_2ND` to confirm `0x15d0[2]`, a first empty-hand talk to confirm the full `0x1587 -> 0x159d` six-line exchange plus the shared `d2f04` write, a repeat empty-hand talk to confirm the fallback `0x15e4`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with shared state `d2ebc` both clear and set to verify the `0x1605 -> 0x1609` versus `0x15f8 -> 0x15fd` follow-ups, note/checkbook evidence to confirm the restored five-line `0x161b -> 0x1631` sequence, and one arbitrary wrong item to confirm the handler still falls back to `0x15de`.
+- Immediate: run the same durable single-handler cycle on `DWAYNE`. A fresh `verify DWAYNE` against the saved `HARVEST.LE` database still reports `handle_talk_to_dwayne @ 0x3a310` as truncated to `0x3a310..0x3c385` (`3375` address-count) with `59` reachable dialogue calls, `59` stale `CALL_RETURN` gaps, and `status=needs_repair`.
+- Follow-up: if `ARCHITECTURE.md` needs another compression pass, move the longest branch-by-branch NPC dialogue traces into a companion note or appendix and keep the main file focused on subsystem structure, stable data types, and engine relationships.
 - Immediate: run an in-engine Madam validation pass that explicitly covers the recovered native branches: trigger `MADAM_INTERRUPT_CONVERSATION`, take the non-`1` `dialog.rsp[0xfb]` path once to confirm `0x1577[0] -> 0x157b[2]` and the queued live `HOOKER` death-or-monsterfy transition; then take response `1` without `BARCASH200` in inventory to confirm `PC 0x156e[0] -> MADAM 0x1572[0] -> 0x157b[2]` and the same death transition; finally repeat with visible `BARCASH200` in inventory to confirm both inner `dialog.rsp[0xfc]` exits, including response `1` to `0x154e[0] -> 0x157b[2]` and response `2` to `0x1555[1] -> HOARLOVE.FST -> 0x155a[0] -> 0x1563[0] -> C117.FST`, `BARCASH200` removal from inventory, and the `HURT_PC_0` plus `ACTV_LUST_EXIT` action-tag side effects. Also dismiss `dialog.rsp[0xfc]` once to verify the native handler simply returns without forcing a fallback bark.
 - Immediate: run an in-engine Librarian validation pass that explicitly covers the recovered native branches: enter the conversation empty-handed and take both `dialog.rsp[0xfa]` responses plus one dismissed menu to confirm `0x1d85` versus `0x1d8b[4]` and the always-present shared tail `0x1d8f -> 0x1d94 -> 0x1d98 -> 0x1da1 -> 0x1da5`; present one arbitrary wrong item to confirm the handler now only plays `0x1d5b`; then present or visibly carry `CAINBOOK` to confirm `CLUE` is awarded, the full `0x1db1[1] -> 0x1dc6` trade sequence plays, and `CAINBOOK` disappears from visible inventory afterward.
 - Immediate: run an in-engine Inquisitor validation pass that explicitly covers the recovered native sequence: talk to `INQUISITOR` with `PAIN_SOLVED` clear and the local talk-state bit freshly reset to confirm `0x18e7 -> 0x18e8 -> 0x18e9 -> TORCH2.FST -> 0x18f8 -> START_INQ_TIM`; talk again in the same eligible state to confirm the cleared local bit suppresses the sequence on repeat visits; then set `PAIN_SOLVED` and confirm the handler returns silently even after a room/reset cycle re-arms the local state.
diff --git a/engines/harvester/npc/mcknight_dialogue.cpp b/engines/harvester/npc/mcknight_dialogue.cpp
index b6c64d8de55..fcd7ba10e5e 100644
--- a/engines/harvester/npc/mcknight_dialogue.cpp
+++ b/engines/harvester/npc/mcknight_dialogue.cpp
@@ -28,26 +28,50 @@
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kMcknightNpc = "MCKNIGHT";
+static const char *const kPcSpeaker = "PC";
+
+} // End of namespace
+
 bool McknightDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("MCKNIGHT");
+	return npcName.equalsIgnoreCase(kMcknightNpc);
 }
 
 Common::Error McknightDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &) {
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	auto playMcknightLine = [&](int wavId, int headVariant = 0) -> Common::Error {
-		return runtime.playDialogueLineWithVariant(wavId, "MCKNIGHT", headVariant);
+		return runtime.playDialogueLineWithVariant(wavId, kMcknightNpc, headVariant);
+	};
+	auto playPcLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kPcSpeaker, headVariant);
+	};
+	auto playSequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
+		return runtime.playDialogueEntrySequence(lines, count);
 	};
 
 	if (runtime.startupScript().getFlagValue("LOOK_SAFE_2ND"))
 		return playMcknightLine(0x15d0, 2);
 
-	if (runtime.startupScript().getFlagValue("LOOK_SAFE_1ST") && !_state.lookSafeFirstLineGate)
+	if (runtime.startupScript().getFlagValue("LOOK_SAFE_1ST") && !_state.lookSafeFirstLineGate) {
+		_state.lookSafeFirstLineGate = true;
 		return playMcknightLine(0x15c9, 2);
+	}
 
 	if (usedItemName.empty()) {
 		if (_state.talkStatePending) {
 			_state.talkStatePending = false;
-			return playMcknightLine(0x1587, 2);
+			sharedState.dialogueStateD2f04 = true;
+			const DialogueLineEntry lines[] = {
+				{ 0x1587, kMcknightNpc, 2 },
+				{ 0x158b, kPcSpeaker, 0 },
+				{ 0x158f, kMcknightNpc, 0 },
+				{ 0x1594, kPcSpeaker, 0 },
+				{ 0x1598, kMcknightNpc, 2 },
+				{ 0x159d, kPcSpeaker, 0 }
+			};
+			return playSequence(lines, sizeof(lines) / sizeof(lines[0]));
 		}
 		return playMcknightLine(0x15e4);
 	}
@@ -59,7 +83,21 @@ Common::Error McknightDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
-		return playMcknightLine(0x15f2);
+		Common::Error lineError = playMcknightLine(0x15f2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		if (sharedState.dialogueStateD2ebc) {
+			lineError = playPcLine(0x15f8);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			return playMcknightLine(0x15fd);
+		}
+
+		lineError = playPcLine(0x1605);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		return playMcknightLine(0x1609);
 	}
 	if ((usedItemName.equalsIgnoreCase("LEDGER") ||
 				usedItemName.equalsIgnoreCase("LEDGER2")) &&
@@ -72,7 +110,14 @@ Common::Error McknightDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
-		return playMcknightLine(0x161b, 1);
+		const DialogueLineEntry lines[] = {
+			{ 0x161b, kMcknightNpc, 1 },
+			{ 0x1621, kPcSpeaker, 0 },
+			{ 0x1626, kMcknightNpc, 3 },
+			{ 0x162b, kPcSpeaker, 4 },
+			{ 0x1631, kMcknightNpc, 0 }
+		};
+		return playSequence(lines, sizeof(lines) / sizeof(lines[0]));
 	}
 
 	return playMcknightLine(0x15de);


Commit: 1b97a80f75679fc1363e2c372c0f9d838ffbedf5
    https://github.com/scummvm/scummvm/commit/1b97a80f75679fc1363e2c372c0f9d838ffbedf5
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:07+02:00

Commit Message:
HARVESTER: Reimplement MEMB_DIR dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/memb_dir_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index b0b2f70cb20..4dc17d080b2 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Last Confirmed Action
 
+- On March 24, 2026, reimplemented the ScummVM Memb Dir dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_memb_dir` (`0x396d0..0x39781`). The old stub in [`engines/harvester/npc/memb_dir_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/memb_dir_dialogue.cpp) only played `0x2b7[1]` before `PC_KILLED_KEWPIE` and a one-shot `0x2d5[1]` after it, while the recovered native handler is the pre-kill bark `0x2b7[1]` into response menu `0xfe` with response `1 -> 0x2c2[0]`, response `2 -> 0x2cb[0]`, and dismissed/other responses returning silently; plus the post-kill one-shot `0x2d5[1]` gated by local state `d2f64`, followed by `TOOK_THE_DOLL -> 0x2e3[0]` or `0x2ea[0]`. Patched [`engines/harvester/npc/memb_dir_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/memb_dir_dialogue.cpp) to mirror that exact control flow, audited the directly encountered Ghidra helpers and reset routine without finding any `FUN_*` symbol that met the rename bar, and kept live progress unchanged at `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Mcknight dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mcknight` (`0x2c770..0x2caf3`). The old stub in [`engines/harvester/npc/mcknight_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mcknight_dialogue.cpp) only played a single `0x1587[2]` no-item bark, simple one-line evidence responses, and never latched the `LOOK_SAFE_1ST` one-shot gate, while the recovered native handler is `LOOK_SAFE_2ND -> 0x15d0[2]`, one-shot `LOOK_SAFE_1ST -> 0x15c9[2]`, first empty-hand talk `0x1587[2] -> PC 0x158b[0] -> 0x158f[0] -> PC 0x1594[0] -> 0x1598[2] -> PC 0x159d[0]` plus shared state `d2f04`, default empty-hand bark `0x15e4`, corpse-photo follow-up `0x15f2` plus either `PC 0x15f8 -> 0x15fd` or `PC 0x1605 -> 0x1609` depending on shared state `d2ebc`, ledgers `0x1611[1]`, and note/checkbook evidence `0x161b[1] -> PC 0x1621[0] -> 0x1626[3] -> PC 0x162b[4] -> 0x1631[0]`, with wrong items still falling back to `0x15de`. Patched [`engines/harvester/npc/mcknight_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mcknight_dialogue.cpp) to mirror that exact control flow, audited the directly encountered Ghidra callees without finding any `FUN_*` symbol that met the rename bar, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, reran the full durable `repair MEMB_DIR -> wait -> save_program -> verify MEMB_DIR` cycle against the live `HARVEST.LE` session. `handle_talk_to_memb_dir @ 0x396d0` started as a truncated `0x396d0..0x39781` body (`76` address-count) with `2` reachable dialogue calls and `2` stale `CALL_RETURN` gaps. The rebuild needed two in-memory passes and finished cleanly at `0x396d0..0x39781` (`178` address-count) with `4` reachable dialogue calls, `gapCount=0`, and `7` repair actions. After the normal settle and explicit save, a fresh `verify MEMB_DIR` still reported `status=clean` with both dialogue entry points returning, so Memb Dir is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 24, 2026, refactored [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) into a tighter subsystem-first layout without changing any confirmed findings. The file now opens with a concise summary plus an at-a-glance index, and the former flat heading sprawl is grouped under `Runtime And Gameplay`, `Interface And Input`, `Data Types And Helper Layer`, `UI And Inventory Details`, and `Open Questions`. Progress counts remain `930` total functions, `811` named/documented, and `119` remaining `FUN_*` because this was documentation cleanup only.
@@ -123,6 +124,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine MEMB_DIR validation pass that explicitly covers the recovered native-only branches: talk before `PC_KILLED_KEWPIE`, take both `dialog.rsp[0xfe]` answers to confirm `0x2c2[0]` versus `0x2cb[0]`, and dismiss the menu once to verify the handler returns silently without a fallback bark; then talk after `PC_KILLED_KEWPIE` with fresh local state to confirm the one-shot `0x2d5[1]` prelude plus `TOOK_THE_DOLL -> 0x2e3[0]` versus no-doll `0x2ea[0]`, and repeat once more to confirm the local post-kill gate suppresses the branch on revisit.
 - Immediate: run an in-engine MCKNIGHT validation pass that explicitly covers the restored native-only branches: `LOOK_SAFE_1ST` twice to confirm `0x15c9[2]` is now one-shot, `LOOK_SAFE_2ND` to confirm `0x15d0[2]`, a first empty-hand talk to confirm the full `0x1587 -> 0x159d` six-line exchange plus the shared `d2f04` write, a repeat empty-hand talk to confirm the fallback `0x15e4`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with shared state `d2ebc` both clear and set to verify the `0x1605 -> 0x1609` versus `0x15f8 -> 0x15fd` follow-ups, note/checkbook evidence to confirm the restored five-line `0x161b -> 0x1631` sequence, and one arbitrary wrong item to confirm the handler still falls back to `0x15de`.
 - Immediate: run the same durable single-handler cycle on `DWAYNE`. A fresh `verify DWAYNE` against the saved `HARVEST.LE` database still reports `handle_talk_to_dwayne @ 0x3a310` as truncated to `0x3a310..0x3c385` (`3375` address-count) with `59` reachable dialogue calls, `59` stale `CALL_RETURN` gaps, and `status=needs_repair`.
 - Follow-up: if `ARCHITECTURE.md` needs another compression pass, move the longest branch-by-branch NPC dialogue traces into a companion note or appendix and keep the main file focused on subsystem structure, stable data types, and engine relationships.
diff --git a/engines/harvester/npc/memb_dir_dialogue.cpp b/engines/harvester/npc/memb_dir_dialogue.cpp
index 2c4b22fdf49..2fe87d8ef3d 100644
--- a/engines/harvester/npc/memb_dir_dialogue.cpp
+++ b/engines/harvester/npc/memb_dir_dialogue.cpp
@@ -27,20 +27,53 @@
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kMembDirNpc = "MEMB_DIR";
+static const int kMembDirResponseLineIndex = 0xfe;
+
+} // End of namespace
+
 bool MembDirDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("MEMB_DIR");
+	return npcName.equalsIgnoreCase(kMembDirNpc);
 }
 
 Common::Error MembDirDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
-	if (!runtime.startupScript().getFlagValue("PC_KILLED_KEWPIE"))
-		return runtime.playDialogueLineWithVariant(0x2b7, "MEMB_DIR", 1);
+	auto playMembDirLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kMembDirNpc, headVariant);
+	};
+
+	if (!runtime.startupScript().getFlagValue("PC_KILLED_KEWPIE")) {
+		Common::Error lineError = playMembDirLine(0x2b7, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		int responseIndex = 0;
+		Common::Error responseError = runtime.runResponseMenu(kMembDirResponseLineIndex, responseIndex);
+		if (responseError.getCode() != Common::kNoError)
+			return responseError;
+
+		if (responseIndex == 1)
+			return playMembDirLine(0x2c2);
+		if (responseIndex == 2)
+			return playMembDirLine(0x2cb);
+
+		return Common::kNoError;
+	}
 
 	if (_state.kewpieKillLinePlayed)
 		return Common::kNoError;
 
 	_state.kewpieKillLinePlayed = true;
-	return runtime.playDialogueLineWithVariant(0x2d5, "MEMB_DIR", 1);
+	Common::Error lineError = playMembDirLine(0x2d5, 1);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	if (runtime.startupScript().getFlagValue("TOOK_THE_DOLL"))
+		return playMembDirLine(0x2e3);
+
+	return playMembDirLine(0x2ea);
 }
 
 } // End of namespace Harvester


Commit: 97a271d9ae282662cc2eb68c38d03a21ae33a6df
    https://github.com/scummvm/scummvm/commit/97a271d9ae282662cc2eb68c38d03a21ae33a6df
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:07+02:00

Commit Message:
HARVESTER: Reimplement priest dialogue handler

Changed paths:
    TRACKER.md
    engines/harvester/npc/priest_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 4dc17d080b2..c16890ce687 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,8 @@
 
 ## Last Confirmed Action
 
+- On March 24, 2026, reimplemented the ScummVM Priest dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_priest` (`0x29730..0x29911`). The old stub in [`engines/harvester/npc/priest_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/priest_dialogue.cpp) only set `PC_TALKED_TO_PRIEST` and played `0xcb2[0]`, while the recovered native handler is a one-shot intro `0xcb2[0]` into response menus `0x27a`, `0x27b`, `0x27c`, `0x27d`, and `0x27e`, with immediate follower-monsterfy exits after `0xcc4[2]` on responses `2`, `2`, `1`, and `2` respectively; deeper branch lines `0xcbf[1]`, `0xccf[1]`, `0xce5[1]`, and `0xcf2[1]`; and a final split where response `4` plays `0xd0e[1]`, clears `PRIEST_FLAG`, and dispatches `RELIG_EXITS_A`, while every other final response plays `0xd13[3]`, sets `PRIEST_FLAG`, and dispatches `FOLOWR_MNSTFY_A`. Patched [`engines/harvester/npc/priest_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/priest_dialogue.cpp) to mirror that control flow, audited the directly encountered Ghidra callees without finding any `FUN_*` symbol that met the rename bar, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
+
 - On March 24, 2026, reimplemented the ScummVM Memb Dir dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_memb_dir` (`0x396d0..0x39781`). The old stub in [`engines/harvester/npc/memb_dir_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/memb_dir_dialogue.cpp) only played `0x2b7[1]` before `PC_KILLED_KEWPIE` and a one-shot `0x2d5[1]` after it, while the recovered native handler is the pre-kill bark `0x2b7[1]` into response menu `0xfe` with response `1 -> 0x2c2[0]`, response `2 -> 0x2cb[0]`, and dismissed/other responses returning silently; plus the post-kill one-shot `0x2d5[1]` gated by local state `d2f64`, followed by `TOOK_THE_DOLL -> 0x2e3[0]` or `0x2ea[0]`. Patched [`engines/harvester/npc/memb_dir_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/memb_dir_dialogue.cpp) to mirror that exact control flow, audited the directly encountered Ghidra helpers and reset routine without finding any `FUN_*` symbol that met the rename bar, and kept live progress unchanged at `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Mcknight dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mcknight` (`0x2c770..0x2caf3`). The old stub in [`engines/harvester/npc/mcknight_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mcknight_dialogue.cpp) only played a single `0x1587[2]` no-item bark, simple one-line evidence responses, and never latched the `LOOK_SAFE_1ST` one-shot gate, while the recovered native handler is `LOOK_SAFE_2ND -> 0x15d0[2]`, one-shot `LOOK_SAFE_1ST -> 0x15c9[2]`, first empty-hand talk `0x1587[2] -> PC 0x158b[0] -> 0x158f[0] -> PC 0x1594[0] -> 0x1598[2] -> PC 0x159d[0]` plus shared state `d2f04`, default empty-hand bark `0x15e4`, corpse-photo follow-up `0x15f2` plus either `PC 0x15f8 -> 0x15fd` or `PC 0x1605 -> 0x1609` depending on shared state `d2ebc`, ledgers `0x1611[1]`, and note/checkbook evidence `0x161b[1] -> PC 0x1621[0] -> 0x1626[3] -> PC 0x162b[4] -> 0x1631[0]`, with wrong items still falling back to `0x15de`. Patched [`engines/harvester/npc/mcknight_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mcknight_dialogue.cpp) to mirror that exact control flow, audited the directly encountered Ghidra callees without finding any `FUN_*` symbol that met the rename bar, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, reran the full durable `repair MEMB_DIR -> wait -> save_program -> verify MEMB_DIR` cycle against the live `HARVEST.LE` session. `handle_talk_to_memb_dir @ 0x396d0` started as a truncated `0x396d0..0x39781` body (`76` address-count) with `2` reachable dialogue calls and `2` stale `CALL_RETURN` gaps. The rebuild needed two in-memory passes and finished cleanly at `0x396d0..0x39781` (`178` address-count) with `4` reachable dialogue calls, `gapCount=0`, and `7` repair actions. After the normal settle and explicit save, a fresh `verify MEMB_DIR` still reported `status=clean` with both dialogue entry points returning, so Memb Dir is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
@@ -124,6 +126,8 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Priest validation pass that explicitly hits the recovered native-only branches: first talk with response `2` at menu `0x27a`; then separate fresh runs taking `1 -> 2` at `0x27b`, `1 -> 1` at `0x27c`, and `1 -> 2` at `0x27d` to confirm all four early `0xcc4[2] -> FOLOWR_MNSTFY_A` exits; plus a full `1 -> 1 -> 2 -> 1` descent to menu `0x27e`, taking response `4` once to verify `0xd0e[1]`, `PRIEST_FLAG` clear, and `RELIG_EXITS_A`, and taking any non-`4` response once to verify `0xd13[3]`, `PRIEST_FLAG` set, and `FOLOWR_MNSTFY_A`. Present one arbitrary inventory item as well to confirm the native handler ignores used objects.
+
 - Immediate: run an in-engine MEMB_DIR validation pass that explicitly covers the recovered native-only branches: talk before `PC_KILLED_KEWPIE`, take both `dialog.rsp[0xfe]` answers to confirm `0x2c2[0]` versus `0x2cb[0]`, and dismiss the menu once to verify the handler returns silently without a fallback bark; then talk after `PC_KILLED_KEWPIE` with fresh local state to confirm the one-shot `0x2d5[1]` prelude plus `TOOK_THE_DOLL -> 0x2e3[0]` versus no-doll `0x2ea[0]`, and repeat once more to confirm the local post-kill gate suppresses the branch on revisit.
 - Immediate: run an in-engine MCKNIGHT validation pass that explicitly covers the restored native-only branches: `LOOK_SAFE_1ST` twice to confirm `0x15c9[2]` is now one-shot, `LOOK_SAFE_2ND` to confirm `0x15d0[2]`, a first empty-hand talk to confirm the full `0x1587 -> 0x159d` six-line exchange plus the shared `d2f04` write, a repeat empty-hand talk to confirm the fallback `0x15e4`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with shared state `d2ebc` both clear and set to verify the `0x1605 -> 0x1609` versus `0x15f8 -> 0x15fd` follow-ups, note/checkbook evidence to confirm the restored five-line `0x161b -> 0x1631` sequence, and one arbitrary wrong item to confirm the handler still falls back to `0x15de`.
 - Immediate: run the same durable single-handler cycle on `DWAYNE`. A fresh `verify DWAYNE` against the saved `HARVEST.LE` database still reports `handle_talk_to_dwayne @ 0x3a310` as truncated to `0x3a310..0x3c385` (`3375` address-count) with `59` reachable dialogue calls, `59` stale `CALL_RETURN` gaps, and `status=needs_repair`.
diff --git a/engines/harvester/npc/priest_dialogue.cpp b/engines/harvester/npc/priest_dialogue.cpp
index 6ce983e09a5..5923f8acff4 100644
--- a/engines/harvester/npc/priest_dialogue.cpp
+++ b/engines/harvester/npc/priest_dialogue.cpp
@@ -29,21 +29,131 @@ namespace Harvester {
 
 namespace {
 
+static const char *const kPriestNpc = "PRIEST";
 static const char *const kPcTalkedToPriestFlag = "PC_TALKED_TO_PRIEST";
+static const char *const kPriestFlag = "PRIEST_FLAG";
+static const char *const kFollowerMonsterfyActionTag = "FOLOWR_MNSTFY_A";
+static const char *const kReligiousExitsActionTag = "RELIG_EXITS_A";
+static const int kPriestInitialResponseLine = 0x27a;
+static const int kPriestSecondResponseLine = 0x27b;
+static const int kPriestThirdResponseLine = 0x27c;
+static const int kPriestFourthResponseLine = 0x27d;
+static const int kPriestFinalResponseLine = 0x27e;
 
 } // End of namespace
 
 bool PriestDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("PRIEST");
+	return npcName.equalsIgnoreCase(kPriestNpc);
 }
 
 Common::Error PriestDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
+	auto playPriestLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kPriestNpc, headVariant);
+	};
+	auto executeDialogueActionTag = [&](const char *tag) {
+		StartupInteractionResult interaction;
+		if (runtime.startupScript().executeActionTag(tag, interaction)) {
+			runtime.applyImmediateDialogueInteractionEffects(interaction);
+			runtime.queueDialogueInteractionIfNeeded(interaction);
+		}
+	};
+	auto runFollowerMonsterfyBranch = [&]() -> Common::Error {
+		Common::Error lineError = playPriestLine(0xcc4, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		executeDialogueActionTag(kFollowerMonsterfyActionTag);
+		return Common::kNoError;
+	};
+
 	if (runtime.startupScript().getFlagValue(kPcTalkedToPriestFlag))
 		return Common::kNoError;
 
 	(void)runtime.startupScript().setRuntimeFlagValue(kPcTalkedToPriestFlag, true);
-	return runtime.playDialogueLine(0xcb2, "PRIEST");
+
+	Common::Error lineError = playPriestLine(0xcb2);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(kPriestInitialResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 2)
+		return runFollowerMonsterfyBranch();
+	if (responseIndex != 1)
+		return Common::kNoError;
+
+	lineError = playPriestLine(0xcbf, 1);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	responseIndex = 0;
+	responseError = runtime.runResponseMenu(kPriestSecondResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 2)
+		return runFollowerMonsterfyBranch();
+	if (responseIndex != 1)
+		return Common::kNoError;
+
+	lineError = playPriestLine(0xccf, 1);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	responseIndex = 0;
+	responseError = runtime.runResponseMenu(kPriestThirdResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1)
+		return runFollowerMonsterfyBranch();
+	if (responseIndex != 2)
+		return Common::kNoError;
+
+	lineError = playPriestLine(0xce5, 1);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	responseIndex = 0;
+	responseError = runtime.runResponseMenu(kPriestFourthResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 2)
+		return runFollowerMonsterfyBranch();
+	if (responseIndex != 1)
+		return Common::kNoError;
+
+	lineError = playPriestLine(0xcf2, 1);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	responseIndex = 0;
+	responseError = runtime.runResponseMenu(kPriestFinalResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 4) {
+		lineError = playPriestLine(0xd0e, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		(void)runtime.startupScript().setRuntimeFlagValue(kPriestFlag, false);
+		executeDialogueActionTag(kReligiousExitsActionTag);
+		return Common::kNoError;
+	}
+
+	lineError = playPriestLine(0xd13, 3);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	(void)runtime.startupScript().setRuntimeFlagValue(kPriestFlag, true);
+	executeDialogueActionTag(kFollowerMonsterfyActionTag);
+	return Common::kNoError;
 }
 
 } // End of namespace Harvester


Commit: da64f040dcce17ea4a393813bda1aa2043611ae1
    https://github.com/scummvm/scummvm/commit/da64f040dcce17ea4a393813bda1aa2043611ae1
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:07+02:00

Commit Message:
HARVESTER: Fix PTA Mom response portrait tail

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/pta_mom_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 52f7e079eb4..d866ab79a83 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -499,7 +499,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `handle_talk_to_pta_mom @ 0x34e30` is now constrained to a compact one-screen exchange.
     - If `PTA_RESPOND_TO_TV` is set, it plays `0x3233` with `PTA_MOM1` head variant `2`, clears that flag, and returns without opening the response menu.
     - Otherwise it chooses one of three random opener lines, `0x31ee`, `0x31f2`, or `0x31f6`, all spoken as `PTA_MOM1` with head variant `1`, then opens zero-based `dialog.rsp` line `0x297`.
-    - Response `1` plays `0x3204` then `0x320b`, both as `PTA_MOM1` with head variant `2`.
+    - Response `1` plays `0x3204` as `PTA_MOM1` with head variant `2`, then `0x320b` as `PTA_MOM1` with head variant `0`.
     - Response `2` plays `0x320f` then `0x3213`, both as `PTA_MOM1` with head variant `0`.
     - Response `3` plays `0x3217`, `0x321c`, `0x3221`, and `0x3226`, where `0x3221` is the only `PC` line and the others stay on `PTA_MOM1` head variant `0`.
   - `handle_talk_to_nude_man @ 0x2d9b0` is now bounded enough for a direct startup-room port.
diff --git a/TRACKER.md b/TRACKER.md
index c16890ce687..0189c2ca6aa 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,8 @@
 
 ## Last Confirmed Action
 
+- On March 24, 2026, rechecked the ScummVM PTA Mom dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_pta_mom` (`0x34e30..0x34f79`). The existing port in [`engines/harvester/npc/pta_mom_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/pta_mom_dialogue.cpp) already matched the native `PTA_RESPOND_TO_TV` early return, the three-opener `0x31ee` / `0x31f2` / `0x31f6` random bark, `dialog.rsp[0x297]`, response `2 -> 0x320f[0] -> 0x3213[0]`, and response `3 -> 0x3217[0] -> 0x321c[0] -> PC 0x3221[0] -> 0x3226[0]`, but the response-`1` tail was one portrait variant off. Native disassembly at `0x34ee0..0x34f70` confirms `0x3204` uses `PTA_MOM1` head variant `2` while `0x320b` falls through the shared tail with head variant `0`, so I patched the handler and corrected the matching architecture note. The directly encountered native callees (`get_flag_value`, `set_flag_value`, `next_random_value`, `load_dialogue_response_line`, `run_dialogue_response_menu`, and `play_dialogue_line`) are all already stably named, so no additional `FUN_*` rename was justified. Live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
+
 - On March 24, 2026, reimplemented the ScummVM Priest dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_priest` (`0x29730..0x29911`). The old stub in [`engines/harvester/npc/priest_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/priest_dialogue.cpp) only set `PC_TALKED_TO_PRIEST` and played `0xcb2[0]`, while the recovered native handler is a one-shot intro `0xcb2[0]` into response menus `0x27a`, `0x27b`, `0x27c`, `0x27d`, and `0x27e`, with immediate follower-monsterfy exits after `0xcc4[2]` on responses `2`, `2`, `1`, and `2` respectively; deeper branch lines `0xcbf[1]`, `0xccf[1]`, `0xce5[1]`, and `0xcf2[1]`; and a final split where response `4` plays `0xd0e[1]`, clears `PRIEST_FLAG`, and dispatches `RELIG_EXITS_A`, while every other final response plays `0xd13[3]`, sets `PRIEST_FLAG`, and dispatches `FOLOWR_MNSTFY_A`. Patched [`engines/harvester/npc/priest_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/priest_dialogue.cpp) to mirror that control flow, audited the directly encountered Ghidra callees without finding any `FUN_*` symbol that met the rename bar, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
 
 - On March 24, 2026, reimplemented the ScummVM Memb Dir dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_memb_dir` (`0x396d0..0x39781`). The old stub in [`engines/harvester/npc/memb_dir_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/memb_dir_dialogue.cpp) only played `0x2b7[1]` before `PC_KILLED_KEWPIE` and a one-shot `0x2d5[1]` after it, while the recovered native handler is the pre-kill bark `0x2b7[1]` into response menu `0xfe` with response `1 -> 0x2c2[0]`, response `2 -> 0x2cb[0]`, and dismissed/other responses returning silently; plus the post-kill one-shot `0x2d5[1]` gated by local state `d2f64`, followed by `TOOK_THE_DOLL -> 0x2e3[0]` or `0x2ea[0]`. Patched [`engines/harvester/npc/memb_dir_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/memb_dir_dialogue.cpp) to mirror that exact control flow, audited the directly encountered Ghidra helpers and reset routine without finding any `FUN_*` symbol that met the rename bar, and kept live progress unchanged at `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
@@ -126,6 +128,8 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine PTA_MOM validation pass that explicitly covers the corrected response-`1` portrait tail and the existing native branches: trigger `PTA_RESPOND_TO_TV` once to confirm the early `0x3233[2]` bark clears the flag and skips `dialog.rsp[0x297]`; then talk repeatedly without the flag until each opener `0x31ee[1]`, `0x31f2[1]`, and `0x31f6[1]` has appeared at least once, taking response `1` to confirm `0x3204[2] -> 0x320b[0]`, response `2` to confirm `0x320f[0] -> 0x3213[0]`, response `3` to confirm `0x3217[0] -> 0x321c[0] -> PC 0x3221[0] -> 0x3226[0]`, and dismissing the menu once to verify the native handler returns silently with no fallback bark.
+
 - Immediate: run an in-engine Priest validation pass that explicitly hits the recovered native-only branches: first talk with response `2` at menu `0x27a`; then separate fresh runs taking `1 -> 2` at `0x27b`, `1 -> 1` at `0x27c`, and `1 -> 2` at `0x27d` to confirm all four early `0xcc4[2] -> FOLOWR_MNSTFY_A` exits; plus a full `1 -> 1 -> 2 -> 1` descent to menu `0x27e`, taking response `4` once to verify `0xd0e[1]`, `PRIEST_FLAG` clear, and `RELIG_EXITS_A`, and taking any non-`4` response once to verify `0xd13[3]`, `PRIEST_FLAG` set, and `FOLOWR_MNSTFY_A`. Present one arbitrary inventory item as well to confirm the native handler ignores used objects.
 
 - Immediate: run an in-engine MEMB_DIR validation pass that explicitly covers the recovered native-only branches: talk before `PC_KILLED_KEWPIE`, take both `dialog.rsp[0xfe]` answers to confirm `0x2c2[0]` versus `0x2cb[0]`, and dismiss the menu once to verify the handler returns silently without a fallback bark; then talk after `PC_KILLED_KEWPIE` with fresh local state to confirm the one-shot `0x2d5[1]` prelude plus `TOOK_THE_DOLL -> 0x2e3[0]` versus no-doll `0x2ea[0]`, and repeat once more to confirm the local post-kill gate suppresses the branch on revisit.
diff --git a/engines/harvester/npc/pta_mom_dialogue.cpp b/engines/harvester/npc/pta_mom_dialogue.cpp
index ccfb5458281..a4d88387a4b 100644
--- a/engines/harvester/npc/pta_mom_dialogue.cpp
+++ b/engines/harvester/npc/pta_mom_dialogue.cpp
@@ -31,11 +31,7 @@ namespace {
 
 static const char *const kPtaRespondToTvFlag = "PTA_RESPOND_TO_TV";
 static const char *const kPtaMomSpeakerId = "PTA_MOM1";
-
-static const DialogueLineEntry kPtaMomResponse1Lines[] = {
-	{ 0x3204, kPtaMomSpeakerId, 2 },
-	{ 0x320b, kPtaMomSpeakerId, 2 }
-};
+static const int kPtaMomResponseLineIndex = 0x297;
 
 static const DialogueLineEntry kPtaMomResponse2Lines[] = {
 	{ 0x320f, kPtaMomSpeakerId, 0 },
@@ -92,13 +88,18 @@ Common::Error PtaMomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return lineError;
 
 	int responseIndex = 0;
-	Common::Error responseError = runtime.runResponseMenu(0x297, responseIndex);
+	Common::Error responseError = runtime.runResponseMenu(kPtaMomResponseLineIndex, responseIndex);
 	if (responseError.getCode() != Common::kNoError)
 		return responseError;
 
 	switch (responseIndex) {
-	case 1:
-		return runtime.playDialogueEntrySequence(kPtaMomResponse1Lines, ARRAYSIZE(kPtaMomResponse1Lines));
+	case 1: {
+		lineError = playPtaMomLine(0x3204, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		return playPtaMomLine(0x320b, 0);
+	}
 	case 2:
 		return runtime.playDialogueEntrySequence(kPtaMomResponse2Lines, ARRAYSIZE(kPtaMomResponse2Lines));
 	case 3:


Commit: 4f5be1dc9a672bc5dc11e68319b835a5e1b87a38
    https://github.com/scummvm/scummvm/commit/4f5be1dc9a672bc5dc11e68319b835a5e1b87a38
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:08+02:00

Commit Message:
HARVESTER: Align swell dialogue with ghidra

Changed paths:
    TRACKER.md
    engines/harvester/npc/swell_dialogue.cpp
    engines/harvester/npc/swell_dialogue.h
    engines/harvester/saveload.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 0189c2ca6aa..0eaf3b2f51c 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,10 +10,14 @@
 
 ## Last Confirmed Action
 
+- On March 24, 2026, reimplemented the ScummVM Swell dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_swell` (`0x29110..0x296d4`) and its reset helper `reset_serialized_talk_state_block_d2c30` (`0x29700..0x2972f`). The old stub in [`engines/harvester/npc/swell_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/swell_dialogue.cpp) only handled the `0xf61` first-talk bark, the `0xfb3` revisit bark, and one-line evidence replies, but the recovered native handler is a much wider flow: item use now includes the full ledger exchange `0xfd4 -> PC 0xfd9 -> 0xfde` and the correct portrait variants on `0xfc6` and `0xfe6`; the first no-item talk now plays the full `0xf61 -> PC 0xf65 -> 0xf69 -> PC 0x3d50 -> 0xf72 -> 0xf7f` intro and seeds keyword buffer `dialog.rsp[0x21d]`; revisit logic now preserves the native one-shot local gates for `STEPH_MIDGAME_PLAYED`, `BARBER_POLE_STOLEN`, `BOLT_OF_CLOTH_TAKEN`, `BURNED_TV_STATION`, and `KARIN_KIDNAPED`; and the keyword loop now matches the recovered topic rewrites and responses for `0x21e` through `0x229`, including the `0xf88`, `0xf95`, `0xfa0`, `0xfaa`, and `0xfba` branches. Patched [`engines/harvester/npc/swell_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/swell_dialogue.cpp) and [`engines/harvester/npc/swell_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/swell_dialogue.h), bumped the Harvester save blob version to `13` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) so the five new Swell-local one-shot bits serialize compatibly, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. The directly encountered native helpers (`get_flag_value`, `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_note_checkbook_evidence`, `load_dialogue_response_line`, `run_dialogue_keyword_menu`, and `reset_serialized_talk_state_block_d2c30`) were already stably named, so no additional `FUN_*` rename was justified. Live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
+- On March 24, 2026, reran the full durable `repair SWELL -> wait -> save_program -> verify SWELL` cycle against the live `HARVEST.LE` session. `handle_talk_to_swell @ 0x29110` started as a truncated `0x29110..0x29378` body (`431` address-count) with `6` reachable dialogue calls and `6` stale `CALL_RETURN` gaps. A single repair pass rebuilt it cleanly to `0x29110..0x296d4` (`1477` address-count) with `26` reachable dialogue calls, `gapCount=0`, and `12` repair actions. After the normal settle and explicit save, a fresh `verify SWELL` still reported `status=clean` with both dialogue entry points returning, so Swell is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 24, 2026, rechecked the ScummVM PTA Mom dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_pta_mom` (`0x34e30..0x34f79`). The existing port in [`engines/harvester/npc/pta_mom_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/pta_mom_dialogue.cpp) already matched the native `PTA_RESPOND_TO_TV` early return, the three-opener `0x31ee` / `0x31f2` / `0x31f6` random bark, `dialog.rsp[0x297]`, response `2 -> 0x320f[0] -> 0x3213[0]`, and response `3 -> 0x3217[0] -> 0x321c[0] -> PC 0x3221[0] -> 0x3226[0]`, but the response-`1` tail was one portrait variant off. Native disassembly at `0x34ee0..0x34f70` confirms `0x3204` uses `PTA_MOM1` head variant `2` while `0x320b` falls through the shared tail with head variant `0`, so I patched the handler and corrected the matching architecture note. The directly encountered native callees (`get_flag_value`, `set_flag_value`, `next_random_value`, `load_dialogue_response_line`, `run_dialogue_response_menu`, and `play_dialogue_line`) are all already stably named, so no additional `FUN_*` rename was justified. Live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
 
 - On March 24, 2026, reimplemented the ScummVM Priest dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_priest` (`0x29730..0x29911`). The old stub in [`engines/harvester/npc/priest_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/priest_dialogue.cpp) only set `PC_TALKED_TO_PRIEST` and played `0xcb2[0]`, while the recovered native handler is a one-shot intro `0xcb2[0]` into response menus `0x27a`, `0x27b`, `0x27c`, `0x27d`, and `0x27e`, with immediate follower-monsterfy exits after `0xcc4[2]` on responses `2`, `2`, `1`, and `2` respectively; deeper branch lines `0xcbf[1]`, `0xccf[1]`, `0xce5[1]`, and `0xcf2[1]`; and a final split where response `4` plays `0xd0e[1]`, clears `PRIEST_FLAG`, and dispatches `RELIG_EXITS_A`, while every other final response plays `0xd13[3]`, sets `PRIEST_FLAG`, and dispatches `FOLOWR_MNSTFY_A`. Patched [`engines/harvester/npc/priest_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/priest_dialogue.cpp) to mirror that control flow, audited the directly encountered Ghidra callees without finding any `FUN_*` symbol that met the rename bar, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
 
+- On March 24, 2026, reran the full durable `repair PTA_MOM -> wait -> save_program -> verify PTA_MOM` cycle against the live `HARVEST.LE` session. `handle_talk_to_pta_mom @ 0x34e30` started as a truncated `0x34e30..0x34f79` body (`222` address-count) with `7` reachable dialogue calls and `7` stale `CALL_RETURN` gaps. The rebuild needed two in-memory passes and finished cleanly at `0x34e30..0x34f79` (`330` address-count) with `11` reachable dialogue calls, `gapCount=0`, and `17` repair actions. After the normal settle and explicit save, a fresh `verify PTA_MOM` still reported `status=clean` with both dialogue entry points returning, so PTA Mom is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
+- On March 24, 2026, reran the full durable `repair PRIEST -> wait -> save_program -> verify PRIEST` cycle against the live `HARVEST.LE` session. `handle_talk_to_priest @ 0x29730` started as a truncated `0x29730..0x29911` body (`57` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x29730..0x29911` (`482` address-count) with `11` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify PRIEST` still reported `status=clean` with both dialogue entry points returning, so Priest is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 24, 2026, reimplemented the ScummVM Memb Dir dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_memb_dir` (`0x396d0..0x39781`). The old stub in [`engines/harvester/npc/memb_dir_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/memb_dir_dialogue.cpp) only played `0x2b7[1]` before `PC_KILLED_KEWPIE` and a one-shot `0x2d5[1]` after it, while the recovered native handler is the pre-kill bark `0x2b7[1]` into response menu `0xfe` with response `1 -> 0x2c2[0]`, response `2 -> 0x2cb[0]`, and dismissed/other responses returning silently; plus the post-kill one-shot `0x2d5[1]` gated by local state `d2f64`, followed by `TOOK_THE_DOLL -> 0x2e3[0]` or `0x2ea[0]`. Patched [`engines/harvester/npc/memb_dir_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/memb_dir_dialogue.cpp) to mirror that exact control flow, audited the directly encountered Ghidra helpers and reset routine without finding any `FUN_*` symbol that met the rename bar, and kept live progress unchanged at `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Mcknight dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mcknight` (`0x2c770..0x2caf3`). The old stub in [`engines/harvester/npc/mcknight_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mcknight_dialogue.cpp) only played a single `0x1587[2]` no-item bark, simple one-line evidence responses, and never latched the `LOOK_SAFE_1ST` one-shot gate, while the recovered native handler is `LOOK_SAFE_2ND -> 0x15d0[2]`, one-shot `LOOK_SAFE_1ST -> 0x15c9[2]`, first empty-hand talk `0x1587[2] -> PC 0x158b[0] -> 0x158f[0] -> PC 0x1594[0] -> 0x1598[2] -> PC 0x159d[0]` plus shared state `d2f04`, default empty-hand bark `0x15e4`, corpse-photo follow-up `0x15f2` plus either `PC 0x15f8 -> 0x15fd` or `PC 0x1605 -> 0x1609` depending on shared state `d2ebc`, ledgers `0x1611[1]`, and note/checkbook evidence `0x161b[1] -> PC 0x1621[0] -> 0x1626[3] -> PC 0x162b[4] -> 0x1631[0]`, with wrong items still falling back to `0x15de`. Patched [`engines/harvester/npc/mcknight_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mcknight_dialogue.cpp) to mirror that exact control flow, audited the directly encountered Ghidra callees without finding any `FUN_*` symbol that met the rename bar, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, reran the full durable `repair MEMB_DIR -> wait -> save_program -> verify MEMB_DIR` cycle against the live `HARVEST.LE` session. `handle_talk_to_memb_dir @ 0x396d0` started as a truncated `0x396d0..0x39781` body (`76` address-count) with `2` reachable dialogue calls and `2` stale `CALL_RETURN` gaps. The rebuild needed two in-memory passes and finished cleanly at `0x396d0..0x39781` (`178` address-count) with `4` reachable dialogue calls, `gapCount=0`, and `7` repair actions. After the normal settle and explicit save, a fresh `verify MEMB_DIR` still reported `status=clean` with both dialogue entry points returning, so Memb Dir is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
@@ -134,6 +138,7 @@
 
 - Immediate: run an in-engine MEMB_DIR validation pass that explicitly covers the recovered native-only branches: talk before `PC_KILLED_KEWPIE`, take both `dialog.rsp[0xfe]` answers to confirm `0x2c2[0]` versus `0x2cb[0]`, and dismiss the menu once to verify the handler returns silently without a fallback bark; then talk after `PC_KILLED_KEWPIE` with fresh local state to confirm the one-shot `0x2d5[1]` prelude plus `TOOK_THE_DOLL -> 0x2e3[0]` versus no-doll `0x2ea[0]`, and repeat once more to confirm the local post-kill gate suppresses the branch on revisit.
 - Immediate: run an in-engine MCKNIGHT validation pass that explicitly covers the restored native-only branches: `LOOK_SAFE_1ST` twice to confirm `0x15c9[2]` is now one-shot, `LOOK_SAFE_2ND` to confirm `0x15d0[2]`, a first empty-hand talk to confirm the full `0x1587 -> 0x159d` six-line exchange plus the shared `d2f04` write, a repeat empty-hand talk to confirm the fallback `0x15e4`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with shared state `d2ebc` both clear and set to verify the `0x1605 -> 0x1609` versus `0x15f8 -> 0x15fd` follow-ups, note/checkbook evidence to confirm the restored five-line `0x161b -> 0x1631` sequence, and one arbitrary wrong item to confirm the handler still falls back to `0x15de`.
+- Immediate: run an in-engine Swell validation pass that explicitly covers the recovered native-only flow: first empty-hand talk to confirm the full `0xf61[1] -> PC 0xf65 -> 0xf69[1] -> PC 0x3d50 -> 0xf72[1] -> 0xf7f[0]` intro and the seeded `dialog.rsp[0x21d]` topic buffer; a repeat empty-hand talk to confirm the default `0xfb3[0]` bark plus one-shot follow-ups for `STEPH_MIDGAME_PLAYED`, `BARBER_POLE_STOLEN`, `BOLT_OF_CLOTH_TAKEN`, `BURNED_TV_STATION`, and `KARIN_KIDNAPED`; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, both ledgers with `HAVE_BOTH_LEDGERS`, and note/checkbook evidence to verify the restored `0xfc6[1]`, `0xfcd[0]`, `0xfd4 -> PC 0xfd9 -> 0xfde`, and `0xfe6[1]` branches; and the keyword loop through `0x21e`, `0x21f/0x220`, `0x222`, `0x224/0x225/0x226`, `0x228`, `0x229`, and one unknown typed topic to confirm the topic-buffer rewrites and the `0xff1[1]`, `0xf88[3]`, `0xf95[0]`, `0xfa0[0]`, `0xfaa[1]`, and `0xfba[2]` responses.
 - Immediate: run the same durable single-handler cycle on `DWAYNE`. A fresh `verify DWAYNE` against the saved `HARVEST.LE` database still reports `handle_talk_to_dwayne @ 0x3a310` as truncated to `0x3a310..0x3c385` (`3375` address-count) with `59` reachable dialogue calls, `59` stale `CALL_RETURN` gaps, and `status=needs_repair`.
 - Follow-up: if `ARCHITECTURE.md` needs another compression pass, move the longest branch-by-branch NPC dialogue traces into a companion note or appendix and keep the main file focused on subsystem structure, stable data types, and engine relationships.
 - Immediate: run an in-engine Madam validation pass that explicitly covers the recovered native branches: trigger `MADAM_INTERRUPT_CONVERSATION`, take the non-`1` `dialog.rsp[0xfb]` path once to confirm `0x1577[0] -> 0x157b[2]` and the queued live `HOOKER` death-or-monsterfy transition; then take response `1` without `BARCASH200` in inventory to confirm `PC 0x156e[0] -> MADAM 0x1572[0] -> 0x157b[2]` and the same death transition; finally repeat with visible `BARCASH200` in inventory to confirm both inner `dialog.rsp[0xfc]` exits, including response `1` to `0x154e[0] -> 0x157b[2]` and response `2` to `0x1555[1] -> HOARLOVE.FST -> 0x155a[0] -> 0x1563[0] -> C117.FST`, `BARCASH200` removal from inventory, and the `HURT_PC_0` plus `ACTV_LUST_EXIT` action-tag side effects. Also dismiss `dialog.rsp[0xfc]` once to verify the native handler simply returns without forcing a fallback bark.
diff --git a/engines/harvester/npc/swell_dialogue.cpp b/engines/harvester/npc/swell_dialogue.cpp
index 415fc058d97..16a7df04625 100644
--- a/engines/harvester/npc/swell_dialogue.cpp
+++ b/engines/harvester/npc/swell_dialogue.cpp
@@ -28,44 +28,194 @@
 
 namespace Harvester {
 
+namespace {
+
+static const char *const kSwellNpc = "SWELL";
+static const char *const kPcSpeaker = "PC";
+static const int kSwellIntroTopicBufferLine = 0x21d;
+static const int kSwellExitTopicLine = 0x21e;
+static const int kSwellTopicBranchAResponseLines[] = { 0x21f, 0x220 };
+static const int kSwellTopicBranchATopicBufferLine = 0x221;
+static const int kSwellTopicBranchBResponseLine = 0x222;
+static const int kSwellTopicBranchBTopicBufferLine = 0x223;
+static const int kSwellTopicBranchCResponseLines[] = { 0x224, 0x225, 0x226 };
+static const int kSwellTopicBranchCTopicBufferLine = 0x227;
+static const int kSwellTopicNoReplyResponseLine = 0x229;
+
+static const DialogueLineEntry kSwellIntroLines[] = {
+	{ 0xf61, kSwellNpc, 1 },
+	{ 0xf65, kPcSpeaker, 0 },
+	{ 0xf69, kSwellNpc, 1 },
+	{ 0x3d50, kPcSpeaker, 0 },
+	{ 0xf72, kSwellNpc, 1 },
+	{ 0xf7f, kSwellNpc, 0 }
+};
+
+static const DialogueLineEntry kSwellLedgerLines[] = {
+	{ 0xfd4, kSwellNpc, 0 },
+	{ 0xfd9, kPcSpeaker, 0 },
+	{ 0xfde, kSwellNpc, 0 }
+};
+
+static const DialogueLineEntry kSwellStephMidgameLines[] = {
+	{ 0xff7, kSwellNpc, 3 },
+	{ 0xffc, kPcSpeaker, 0 },
+	{ 0x1001, kSwellNpc, 3 }
+};
+
+static const DialogueLineEntry kSwellBarberPoleStolenLines[] = {
+	{ 0x100b, kSwellNpc, 3 },
+	{ 0x100c, kSwellNpc, 0 }
+};
+
+} // End of anonymous namespace
+
 bool SwellDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("SWELL");
+	return npcName.equalsIgnoreCase(kSwellNpc);
 }
 
 Common::Error SwellDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &) {
-	if (usedItemName.empty()) {
-		if (_state.talkStatePending) {
-			_state.talkStatePending = false;
-			return runtime.playDialogueLine(0xf61, "SWELL");
-		}
-		return runtime.playDialogueLine(0xfb3, "SWELL");
-	}
+	auto playSwellLine = [&](int wavId, int headVariant = 0) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(wavId, kSwellNpc, headVariant);
+	};
+	auto playSequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
+		return runtime.playDialogueEntrySequence(lines, count);
+	};
+	auto assignSwellTopicBuffer = [&](Common::String &topicBuffer, int &topicBufferLineIndex,
+			int responseLineIndex) {
+		runtime.assignTopicBuffer(topicBuffer, topicBufferLineIndex, responseLineIndex,
+			"Swell topic buffer");
+	};
 
 	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
-		return runtime.playDialogueLine(0xfc6, "SWELL");
+		return playSwellLine(0xfc6, 1);
 	}
 	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
-		return runtime.playDialogueLine(0xfcd, "SWELL");
+		return playSwellLine(0xfcd);
 	}
 	if ((usedItemName.equalsIgnoreCase("LEDGER") ||
 				usedItemName.equalsIgnoreCase("LEDGER2")) &&
 			runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
-		return runtime.playDialogueLine(0xfd4, "SWELL");
+		return playSequence(kSwellLedgerLines, ARRAYSIZE(kSwellLedgerLines));
 	}
 	if (usedItemName.equalsIgnoreCase("NOTE") ||
 			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
-		return runtime.playDialogueLine(0xfe6, "SWELL");
+		return playSwellLine(0xfe6, 1);
+	}
+	if (!usedItemName.empty()) {
+		return playSwellLine(0xfc0);
+	}
+
+	Common::Error lineError = Common::kNoError;
+	Common::String topicBuffer;
+	int topicBufferLineIndex = -1;
+
+	if (_state.talkStatePending) {
+		_state.talkStatePending = false;
+		lineError = playSequence(kSwellIntroLines, ARRAYSIZE(kSwellIntroLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		assignSwellTopicBuffer(topicBuffer, topicBufferLineIndex, kSwellIntroTopicBufferLine);
+	} else {
+		lineError = playSwellLine(0xfb3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 	}
 
-	return runtime.playDialogueLine(0xfc0, "SWELL");
+	if (runtime.startupScript().getFlagValue("STEPH_MIDGAME_PLAYED") && !_state.stephMidgameShown) {
+		_state.stephMidgameShown = true;
+		lineError = playSequence(kSwellStephMidgameLines, ARRAYSIZE(kSwellStephMidgameLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") &&
+			!_state.barberPoleStolenShown) {
+		_state.barberPoleStolenShown = true;
+		lineError = playSequence(kSwellBarberPoleStolenLines, ARRAYSIZE(kSwellBarberPoleStolenLines));
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("BOLT_OF_CLOTH_TAKEN") &&
+			!_state.boltOfClothTakenShown) {
+		_state.boltOfClothTakenShown = true;
+		lineError = playSwellLine(0x1017, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("BURNED_TV_STATION") &&
+			!_state.burnedTvStationShown) {
+		_state.burnedTvStationShown = true;
+		lineError = playSwellLine(0x1021, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") && !_state.karinKidnapedShown) {
+		_state.karinKidnapedShown = true;
+		lineError = playSwellLine(0x1029, 3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	for (;;) {
+		Common::String selectedTopic;
+		Common::Error menuError = runtime.runKeywordMenu(topicBuffer, topicBufferLineIndex, selectedTopic);
+		if (menuError.getCode() != Common::kNoError)
+			return menuError;
+		if (selectedTopic.empty())
+			return Common::kNoError;
+
+		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
+				runtime.matchesResponseLine(selectedTopic, kSwellExitTopicLine))
+			return playSwellLine(0xff1, 1);
+
+		if (runtime.matchesAnyResponseLine(selectedTopic, kSwellTopicBranchAResponseLines,
+					ARRAYSIZE(kSwellTopicBranchAResponseLines))) {
+			lineError = playSwellLine(0xf88, 3);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignSwellTopicBuffer(topicBuffer, topicBufferLineIndex, kSwellTopicBranchATopicBufferLine);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, kSwellTopicBranchBResponseLine)) {
+			lineError = playSwellLine(0xf95);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignSwellTopicBuffer(topicBuffer, topicBufferLineIndex, kSwellTopicBranchBTopicBufferLine);
+			continue;
+		}
+
+		if (runtime.matchesAnyResponseLine(selectedTopic, kSwellTopicBranchCResponseLines,
+					ARRAYSIZE(kSwellTopicBranchCResponseLines))) {
+			lineError = playSwellLine(0xfa0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			assignSwellTopicBuffer(topicBuffer, topicBufferLineIndex, kSwellTopicBranchCTopicBufferLine);
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, 0x228)) {
+			lineError = playSwellLine(0xfaa, 1);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			continue;
+		}
+
+		if (runtime.matchesResponseLine(selectedTopic, kSwellTopicNoReplyResponseLine))
+			continue;
+
+		lineError = playSwellLine(0xfba, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/swell_dialogue.h b/engines/harvester/npc/swell_dialogue.h
index f1a58bbe0e2..e0c2fac5844 100644
--- a/engines/harvester/npc/swell_dialogue.h
+++ b/engines/harvester/npc/swell_dialogue.h
@@ -30,12 +30,22 @@ class SwellDialogueHandler : public NpcDialogueHandler {
 public:
 	struct SwellRoomDialogueState {
 		bool talkStatePending = true;
+		bool stephMidgameShown = false;
+		bool barberPoleStolenShown = false;
+		bool boltOfClothTakenShown = false;
+		bool burnedTvStationShown = false;
+		bool karinKidnapedShown = false;
 	};
 
 	bool matchesNpc(const Common::String &npcName) const override;
 	void resetState() override { _state = SwellRoomDialogueState(); }
 	void syncState(Common::Serializer &s) override {
 		syncDialogueBool(s, _state.talkStatePending);
+		syncDialogueBool(s, _state.stephMidgameShown, 13);
+		syncDialogueBool(s, _state.barberPoleStolenShown, 13);
+		syncDialogueBool(s, _state.boltOfClothTakenShown, 13);
+		syncDialogueBool(s, _state.burnedTvStationShown, 13);
+		syncDialogueBool(s, _state.karinKidnapedShown, 13);
 	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index 0c0946c0005..79b734ecd2a 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -39,7 +39,7 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 }
 
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
-static const uint32 kHarvesterSaveVersion = 12;
+static const uint32 kHarvesterSaveVersion = 13;
 
 static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
 	debugC(1, kDebugGeneral,


Commit: 2b730a5960b94303918183df7cf586ee1ef193d4
    https://github.com/scummvm/scummvm/commit/2b730a5960b94303918183df7cf586ee1ef193d4
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:08+02:00

Commit Message:
HARVESTER: Sync swell dialogue state with native flow

Changed paths:
    TRACKER.md
    engines/harvester/npc/swell_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 0eaf3b2f51c..f0dd25562b6 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Last Confirmed Action
 
+- On March 24, 2026, re-audited the live `HARVEST.LE` Swell handler after the earlier port and confirmed two remaining ScummVM mismatches in [`engines/harvester/npc/swell_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/swell_dialogue.cpp): the native evidence branches call `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, and `get_set_discussed_note_checkbook_evidence`, so the port now also writes the matching shared dialogue-state ints alongside the existing startup-script flags; and the keyword loop exits only on native response line `0x21e`, so the extra `runtime.genericByeTopic()` shortcut was removed to match the recovered `strcmp_ascii_case_insensitive` branch exactly. During the same pass I renamed the proven SWELL room-state reset helper at `0x29700` to `reset_swell_room_dialogue_state` and relabeled its backing globals as `g_swell_intro_pending`, `g_swell_steph_midgame_shown`, `g_swell_barber_pole_stolen_shown`, `g_swell_bolt_of_cloth_taken_shown`, `g_swell_burned_tv_station_shown`, and `g_swell_karin_kidnaped_shown`, then rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully and saved `HARVEST.LE`. No additional `FUN_*` rename was justified, so live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Swell dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_swell` (`0x29110..0x296d4`) and its reset helper `reset_serialized_talk_state_block_d2c30` (`0x29700..0x2972f`). The old stub in [`engines/harvester/npc/swell_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/swell_dialogue.cpp) only handled the `0xf61` first-talk bark, the `0xfb3` revisit bark, and one-line evidence replies, but the recovered native handler is a much wider flow: item use now includes the full ledger exchange `0xfd4 -> PC 0xfd9 -> 0xfde` and the correct portrait variants on `0xfc6` and `0xfe6`; the first no-item talk now plays the full `0xf61 -> PC 0xf65 -> 0xf69 -> PC 0x3d50 -> 0xf72 -> 0xf7f` intro and seeds keyword buffer `dialog.rsp[0x21d]`; revisit logic now preserves the native one-shot local gates for `STEPH_MIDGAME_PLAYED`, `BARBER_POLE_STOLEN`, `BOLT_OF_CLOTH_TAKEN`, `BURNED_TV_STATION`, and `KARIN_KIDNAPED`; and the keyword loop now matches the recovered topic rewrites and responses for `0x21e` through `0x229`, including the `0xf88`, `0xf95`, `0xfa0`, `0xfaa`, and `0xfba` branches. Patched [`engines/harvester/npc/swell_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/swell_dialogue.cpp) and [`engines/harvester/npc/swell_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/swell_dialogue.h), bumped the Harvester save blob version to `13` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) so the five new Swell-local one-shot bits serialize compatibly, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. The directly encountered native helpers (`get_flag_value`, `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_note_checkbook_evidence`, `load_dialogue_response_line`, `run_dialogue_keyword_menu`, and `reset_serialized_talk_state_block_d2c30`) were already stably named, so no additional `FUN_*` rename was justified. Live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, reran the full durable `repair SWELL -> wait -> save_program -> verify SWELL` cycle against the live `HARVEST.LE` session. `handle_talk_to_swell @ 0x29110` started as a truncated `0x29110..0x29378` body (`431` address-count) with `6` reachable dialogue calls and `6` stale `CALL_RETURN` gaps. A single repair pass rebuilt it cleanly to `0x29110..0x296d4` (`1477` address-count) with `26` reachable dialogue calls, `gapCount=0`, and `12` repair actions. After the normal settle and explicit save, a fresh `verify SWELL` still reported `status=clean` with both dialogue entry points returning, so Swell is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 24, 2026, rechecked the ScummVM PTA Mom dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_pta_mom` (`0x34e30..0x34f79`). The existing port in [`engines/harvester/npc/pta_mom_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/pta_mom_dialogue.cpp) already matched the native `PTA_RESPOND_TO_TV` early return, the three-opener `0x31ee` / `0x31f2` / `0x31f6` random bark, `dialog.rsp[0x297]`, response `2 -> 0x320f[0] -> 0x3213[0]`, and response `3 -> 0x3217[0] -> 0x321c[0] -> PC 0x3221[0] -> 0x3226[0]`, but the response-`1` tail was one portrait variant off. Native disassembly at `0x34ee0..0x34f70` confirms `0x3204` uses `PTA_MOM1` head variant `2` while `0x320b` falls through the shared tail with head variant `0`, so I patched the handler and corrected the matching architecture note. The directly encountered native callees (`get_flag_value`, `set_flag_value`, `next_random_value`, `load_dialogue_response_line`, `run_dialogue_response_menu`, and `play_dialogue_line`) are all already stably named, so no additional `FUN_*` rename was justified. Live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
@@ -132,6 +133,7 @@
 
 ## Next Suggested Action
 
+- Immediate: rerun a focused in-engine Swell validation pass that checks the two follow-up corrections from this pass: after presenting `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, both ledgers with `HAVE_BOTH_LEDGERS`, and note/checkbook evidence to Swell, immediately visit at least one downstream consumer that reads the shared evidence-state ints (for example Dwayne, Moynahan, or Karin/Herrill paths already known to key off those discussion markers) to confirm the restored shared-state writes now propagate beyond the local startup-script flags; and in the keyword menu verify that only native topic `dialog.rsp[0x21e]` exits with `0xff1[1]` while `0x229` remains a silent continue and an unrelated typed topic still falls back to `0xfba[2]`.
 - Immediate: run an in-engine PTA_MOM validation pass that explicitly covers the corrected response-`1` portrait tail and the existing native branches: trigger `PTA_RESPOND_TO_TV` once to confirm the early `0x3233[2]` bark clears the flag and skips `dialog.rsp[0x297]`; then talk repeatedly without the flag until each opener `0x31ee[1]`, `0x31f2[1]`, and `0x31f6[1]` has appeared at least once, taking response `1` to confirm `0x3204[2] -> 0x320b[0]`, response `2` to confirm `0x320f[0] -> 0x3213[0]`, response `3` to confirm `0x3217[0] -> 0x321c[0] -> PC 0x3221[0] -> 0x3226[0]`, and dismissing the menu once to verify the native handler returns silently with no fallback bark.
 
 - Immediate: run an in-engine Priest validation pass that explicitly hits the recovered native-only branches: first talk with response `2` at menu `0x27a`; then separate fresh runs taking `1 -> 2` at `0x27b`, `1 -> 1` at `0x27c`, and `1 -> 2` at `0x27d` to confirm all four early `0xcc4[2] -> FOLOWR_MNSTFY_A` exits; plus a full `1 -> 1 -> 2 -> 1` descent to menu `0x27e`, taking response `4` once to verify `0xd0e[1]`, `PRIEST_FLAG` clear, and `RELIG_EXITS_A`, and taking any non-`4` response once to verify `0xd13[3]`, `PRIEST_FLAG` set, and `FOLOWR_MNSTFY_A`. Present one arbitrary inventory item as well to confirm the native handler ignores used objects.
diff --git a/engines/harvester/npc/swell_dialogue.cpp b/engines/harvester/npc/swell_dialogue.cpp
index 16a7df04625..e011b1ee079 100644
--- a/engines/harvester/npc/swell_dialogue.cpp
+++ b/engines/harvester/npc/swell_dialogue.cpp
@@ -75,7 +75,7 @@ bool SwellDialogueHandler::matchesNpc(const Common::String &npcName) const {
 }
 
 Common::Error SwellDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &) {
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	auto playSwellLine = [&](int wavId, int headVariant = 0) -> Common::Error {
 		return runtime.playDialogueLineWithVariant(wavId, kSwellNpc, headVariant);
 	};
@@ -89,17 +89,20 @@ Common::Error SwellDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 
 	if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+		sharedState.discussedWhaleyHerrillPhoto = 1;
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
 		return playSwellLine(0xfc6, 1);
 	}
 	if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 			usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+		sharedState.discussedCasketPhotoEvidence = 1;
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
 		return playSwellLine(0xfcd);
 	}
 	if ((usedItemName.equalsIgnoreCase("LEDGER") ||
 				usedItemName.equalsIgnoreCase("LEDGER2")) &&
 			runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+		sharedState.discussedLedgerEvidence = 1;
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
 		return playSequence(kSwellLedgerLines, ARRAYSIZE(kSwellLedgerLines));
 	}
@@ -107,6 +110,7 @@ Common::Error SwellDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 			usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 			usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+		sharedState.discussedNoteCheckbookEvidence = 1;
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
 		return playSwellLine(0xfe6, 1);
 	}
@@ -172,8 +176,7 @@ Common::Error SwellDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (selectedTopic.empty())
 			return Common::kNoError;
 
-		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
-				runtime.matchesResponseLine(selectedTopic, kSwellExitTopicLine))
+		if (runtime.matchesResponseLine(selectedTopic, kSwellExitTopicLine))
 			return playSwellLine(0xff1, 1);
 
 		if (runtime.matchesAnyResponseLine(selectedTopic, kSwellTopicBranchAResponseLines,


Commit: fb2cf92780acb46c040d2111c6307e13b1a2dbcb
    https://github.com/scummvm/scummvm/commit/fb2cf92780acb46c040d2111c6307e13b1a2dbcb
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:08+02:00

Commit Message:
HARVESTER: Align valet dialogue with native flow

Changed paths:
    TRACKER.md
    engines/harvester/npc/valet_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index f0dd25562b6..44335208337 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,15 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `930`
-- Named/documented: `811`
+- Total functions: `931`
+- Named/documented: `812`
 - Remaining `FUN_*`: `119`
 - Remaining undocumented total: `119`
 
 ## Last Confirmed Action
 
+- On March 24, 2026, re-audited the live `HARVEST.LE` VALET handler after the saved repair and confirmed the ScummVM flow at [`engines/harvester/npc/valet_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/valet_dialogue.cpp) is exactly the native `handle_talk_to_valet @ 0x2eda0`: intro lines `0xf2c[1] -> PC 0xf31[0] -> 0xf35[0] -> PC 0xf39[0]`, the `GRAPHIC/FST/MIDGET02.FST` playback, response menu `0x2fe`, optional reply `1 -> 0xf4a[0]` or `2 -> 0xf50[2]`, and the unconditional exit line `0xf56[0]`. I rewrote the handler around explicit native constants so that control flow matches the recovered body one-for-one, renamed the directly encountered wrapper at `0x38b30` from `FUN_00038b30` to `play_valet_midget02_fst_sequence`, fixed its prototype to `void play_valet_midget02_fst_sequence(void)`, and saved `HARVEST.LE`. The live database now reports `931` total functions, `812` named/documented, and `119` remaining `FUN_*`.
+- On March 24, 2026, reran the full durable `repair VALET -> wait -> save_program -> verify VALET` cycle against the live `HARVEST.LE` session. `handle_talk_to_valet @ 0x2eda0` started as a truncated `0x2eda0..0x2edb5` body (`22` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x2eda0..0x2ee3b` (`156` address-count) with `6` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify VALET` still reported `status=clean` with both dialogue entry points returning, so Valet is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 24, 2026, re-audited the live `HARVEST.LE` Swell handler after the earlier port and confirmed two remaining ScummVM mismatches in [`engines/harvester/npc/swell_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/swell_dialogue.cpp): the native evidence branches call `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, and `get_set_discussed_note_checkbook_evidence`, so the port now also writes the matching shared dialogue-state ints alongside the existing startup-script flags; and the keyword loop exits only on native response line `0x21e`, so the extra `runtime.genericByeTopic()` shortcut was removed to match the recovered `strcmp_ascii_case_insensitive` branch exactly. During the same pass I renamed the proven SWELL room-state reset helper at `0x29700` to `reset_swell_room_dialogue_state` and relabeled its backing globals as `g_swell_intro_pending`, `g_swell_steph_midgame_shown`, `g_swell_barber_pole_stolen_shown`, `g_swell_bolt_of_cloth_taken_shown`, `g_swell_burned_tv_station_shown`, and `g_swell_karin_kidnaped_shown`, then rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully and saved `HARVEST.LE`. No additional `FUN_*` rename was justified, so live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Swell dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_swell` (`0x29110..0x296d4`) and its reset helper `reset_serialized_talk_state_block_d2c30` (`0x29700..0x2972f`). The old stub in [`engines/harvester/npc/swell_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/swell_dialogue.cpp) only handled the `0xf61` first-talk bark, the `0xfb3` revisit bark, and one-line evidence replies, but the recovered native handler is a much wider flow: item use now includes the full ledger exchange `0xfd4 -> PC 0xfd9 -> 0xfde` and the correct portrait variants on `0xfc6` and `0xfe6`; the first no-item talk now plays the full `0xf61 -> PC 0xf65 -> 0xf69 -> PC 0x3d50 -> 0xf72 -> 0xf7f` intro and seeds keyword buffer `dialog.rsp[0x21d]`; revisit logic now preserves the native one-shot local gates for `STEPH_MIDGAME_PLAYED`, `BARBER_POLE_STOLEN`, `BOLT_OF_CLOTH_TAKEN`, `BURNED_TV_STATION`, and `KARIN_KIDNAPED`; and the keyword loop now matches the recovered topic rewrites and responses for `0x21e` through `0x229`, including the `0xf88`, `0xf95`, `0xfa0`, `0xfaa`, and `0xfba` branches. Patched [`engines/harvester/npc/swell_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/swell_dialogue.cpp) and [`engines/harvester/npc/swell_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/swell_dialogue.h), bumped the Harvester save blob version to `13` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) so the five new Swell-local one-shot bits serialize compatibly, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. The directly encountered native helpers (`get_flag_value`, `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_note_checkbook_evidence`, `load_dialogue_response_line`, `run_dialogue_keyword_menu`, and `reset_serialized_talk_state_block_d2c30`) were already stably named, so no additional `FUN_*` rename was justified. Live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, reran the full durable `repair SWELL -> wait -> save_program -> verify SWELL` cycle against the live `HARVEST.LE` session. `handle_talk_to_swell @ 0x29110` started as a truncated `0x29110..0x29378` body (`431` address-count) with `6` reachable dialogue calls and `6` stale `CALL_RETURN` gaps. A single repair pass rebuilt it cleanly to `0x29110..0x296d4` (`1477` address-count) with `26` reachable dialogue calls, `gapCount=0`, and `12` repair actions. After the normal settle and explicit save, a fresh `verify SWELL` still reported `status=clean` with both dialogue entry points returning, so Swell is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
@@ -133,6 +135,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine VALET validation pass that explicitly covers the now-confirmed native-only sequence: enter the conversation empty-handed to confirm the four-line opener `0xf2c[1] -> PC 0xf31[0] -> 0xf35[0] -> PC 0xf39[0]`, the `MIDGET02.FST` playback, response menu `0x2fe`, response `1` producing `0xf4a[0]`, response `2` producing `0xf50[2]`, and menu dismissal falling straight through to the shared exit line `0xf56[0]` with no extra bark or item-specific branch.
 - Immediate: rerun a focused in-engine Swell validation pass that checks the two follow-up corrections from this pass: after presenting `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, both ledgers with `HAVE_BOTH_LEDGERS`, and note/checkbook evidence to Swell, immediately visit at least one downstream consumer that reads the shared evidence-state ints (for example Dwayne, Moynahan, or Karin/Herrill paths already known to key off those discussion markers) to confirm the restored shared-state writes now propagate beyond the local startup-script flags; and in the keyword menu verify that only native topic `dialog.rsp[0x21e]` exits with `0xff1[1]` while `0x229` remains a silent continue and an unrelated typed topic still falls back to `0xfba[2]`.
 - Immediate: run an in-engine PTA_MOM validation pass that explicitly covers the corrected response-`1` portrait tail and the existing native branches: trigger `PTA_RESPOND_TO_TV` once to confirm the early `0x3233[2]` bark clears the flag and skips `dialog.rsp[0x297]`; then talk repeatedly without the flag until each opener `0x31ee[1]`, `0x31f2[1]`, and `0x31f6[1]` has appeared at least once, taking response `1` to confirm `0x3204[2] -> 0x320b[0]`, response `2` to confirm `0x320f[0] -> 0x3213[0]`, response `3` to confirm `0x3217[0] -> 0x321c[0] -> PC 0x3221[0] -> 0x3226[0]`, and dismissing the menu once to verify the native handler returns silently with no fallback bark.
 
diff --git a/engines/harvester/npc/valet_dialogue.cpp b/engines/harvester/npc/valet_dialogue.cpp
index da98537f3bb..561b4e4eaf8 100644
--- a/engines/harvester/npc/valet_dialogue.cpp
+++ b/engines/harvester/npc/valet_dialogue.cpp
@@ -30,15 +30,23 @@ namespace Harvester {
 namespace {
 
 static const char *const kValetSpeakerId = "VALET";
+static const char *const kPcSpeakerId = "PC";
 static const char *const kValetCutscenePath = "GRAPHIC/FST/MIDGET02.FST";
+static const int kValetResponseLineIndex = 0x2fe;
+static const int kValetFirstResponseIndex = 1;
+static const int kValetSecondResponseIndex = 2;
 
 static const DialogueLineEntry kValetIntroLines[] = {
 	{ 0xf2c, kValetSpeakerId, 1 },
-	{ 0xf31, "PC", 0 },
+	{ 0xf31, kPcSpeakerId, 0 },
 	{ 0xf35, kValetSpeakerId, 0 },
-	{ 0xf39, "PC", 0 }
+	{ 0xf39, kPcSpeakerId, 0 }
 };
 
+static const DialogueLineEntry kValetFirstResponseLine = { 0xf4a, kValetSpeakerId, 0 };
+static const DialogueLineEntry kValetSecondResponseLine = { 0xf50, kValetSpeakerId, 2 };
+static const DialogueLineEntry kValetExitLine = { 0xf56, kValetSpeakerId, 0 };
+
 } // End of namespace
 
 bool ValetDialogueHandler::matchesNpc(const Common::String &npcName) const {
@@ -47,6 +55,10 @@ bool ValetDialogueHandler::matchesNpc(const Common::String &npcName) const {
 
 Common::Error ValetDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
+	auto playValetLine = [&](const DialogueLineEntry &line) -> Common::Error {
+		return runtime.playDialogueLineWithVariant(line.wavId, line.speakerId, line.headVariant);
+	};
+
 	Common::Error lineError = runtime.playDialogueEntrySequence(kValetIntroLines, ARRAYSIZE(kValetIntroLines));
 	if (lineError.getCode() != Common::kNoError)
 		return lineError;
@@ -56,19 +68,19 @@ Common::Error ValetDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return lineError;
 
 	int responseIndex = 0;
-	Common::Error responseError = runtime.runResponseMenu(0x2fe, responseIndex);
+	Common::Error responseError = runtime.runResponseMenu(kValetResponseLineIndex, responseIndex);
 	if (responseError.getCode() != Common::kNoError)
 		return responseError;
 
-	if (responseIndex == 1) {
-		lineError = runtime.playDialogueLine(0xf4a, kValetSpeakerId);
-	} else if (responseIndex == 2) {
-		lineError = runtime.playDialogueLineWithVariant(0xf50, kValetSpeakerId, 2);
+	if (responseIndex == kValetFirstResponseIndex) {
+		lineError = playValetLine(kValetFirstResponseLine);
+	} else if (responseIndex == kValetSecondResponseIndex) {
+		lineError = playValetLine(kValetSecondResponseLine);
 	}
 	if (lineError.getCode() != Common::kNoError)
 		return lineError;
 
-	return runtime.playDialogueLine(0xf56, kValetSpeakerId);
+	return playValetLine(kValetExitLine);
 }
 
 } // End of namespace Harvester


Commit: 24bb8e6033fcaf0c883ff4def0337b5afdfcde9a
    https://github.com/scummvm/scummvm/commit/24bb8e6033fcaf0c883ff4def0337b5afdfcde9a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:09+02:00

Commit Message:
HARVESTER: Reimplement vet dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/vet_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 44335208337..a3dbb49e056 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Last Confirmed Action
 
+- On March 24, 2026, reimplemented the ScummVM Vet dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_vet` (`0x2fd20..0x2fdd5`). The old stub in [`engines/harvester/npc/vet_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/vet_dialogue.cpp) only cleared `VET_INTERRUPT` and played `0x6d9`, while the recovered native handler is the full interrupt-gated exchange `0x6d9[0] -> PC 0x447[0] -> 0x6e1[2]`, response menu `dialog.rsp[0x2ff]`, response `1 -> 0x6ec[2]`, response `2 -> 0x6f0[0]`, and shared exit `0x6f4[2]`, with `VET_INTERRUPT` cleared before the dialogue and again on the shared tail. Patched [`engines/harvester/npc/vet_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/vet_dialogue.cpp), rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully from the configured repo-root `make -j4 scummvm` build, and audited the directly encountered native helpers (`get_flag_value`, `set_flag_value`, `load_dialogue_response_line`, `run_dialogue_response_menu`, and `play_dialogue_line`) without finding any `FUN_*` symbol that met the rename bar. Live progress remains `931` total functions, `812` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, re-audited the live `HARVEST.LE` VALET handler after the saved repair and confirmed the ScummVM flow at [`engines/harvester/npc/valet_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/valet_dialogue.cpp) is exactly the native `handle_talk_to_valet @ 0x2eda0`: intro lines `0xf2c[1] -> PC 0xf31[0] -> 0xf35[0] -> PC 0xf39[0]`, the `GRAPHIC/FST/MIDGET02.FST` playback, response menu `0x2fe`, optional reply `1 -> 0xf4a[0]` or `2 -> 0xf50[2]`, and the unconditional exit line `0xf56[0]`. I rewrote the handler around explicit native constants so that control flow matches the recovered body one-for-one, renamed the directly encountered wrapper at `0x38b30` from `FUN_00038b30` to `play_valet_midget02_fst_sequence`, fixed its prototype to `void play_valet_midget02_fst_sequence(void)`, and saved `HARVEST.LE`. The live database now reports `931` total functions, `812` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, reran the full durable `repair VALET -> wait -> save_program -> verify VALET` cycle against the live `HARVEST.LE` session. `handle_talk_to_valet @ 0x2eda0` started as a truncated `0x2eda0..0x2edb5` body (`22` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x2eda0..0x2ee3b` (`156` address-count) with `6` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify VALET` still reported `status=clean` with both dialogue entry points returning, so Valet is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 24, 2026, re-audited the live `HARVEST.LE` Swell handler after the earlier port and confirmed two remaining ScummVM mismatches in [`engines/harvester/npc/swell_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/swell_dialogue.cpp): the native evidence branches call `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, and `get_set_discussed_note_checkbook_evidence`, so the port now also writes the matching shared dialogue-state ints alongside the existing startup-script flags; and the keyword loop exits only on native response line `0x21e`, so the extra `runtime.genericByeTopic()` shortcut was removed to match the recovered `strcmp_ascii_case_insensitive` branch exactly. During the same pass I renamed the proven SWELL room-state reset helper at `0x29700` to `reset_swell_room_dialogue_state` and relabeled its backing globals as `g_swell_intro_pending`, `g_swell_steph_midgame_shown`, `g_swell_barber_pole_stolen_shown`, `g_swell_bolt_of_cloth_taken_shown`, `g_swell_burned_tv_station_shown`, and `g_swell_karin_kidnaped_shown`, then rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully and saved `HARVEST.LE`. No additional `FUN_*` rename was justified, so live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
@@ -134,6 +135,7 @@
 - On March 22, 2026, patched `engines/harvester/npc/stephanie_dialogue.cpp` against those confirmed native Stephanie paths: corrected the recovered Stephanie/PC head variants that were still hardcoded as `0`, removed the bogus unconditional `0x4717` tail after the nested `SCRATCHED_TUCKER` reply, and restored the native early-return behavior on the `BOLT_OF_CLOTH_TAKEN`, `BARBER_POLE_STOLEN`, `DINER_BURNED`, and `0x2ed` keyword-response exits. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 
 ## Next Suggested Action
+- Immediate: run an in-engine VET validation pass that explicitly triggers `VET_INTERRUPT`, confirms the restored `0x6d9[0] -> PC 0x447[0] -> 0x6e1[2]` opener, both `dialog.rsp[0x2ff]` answers (`0x6ec[2]` and `0x6f0[0]`), menu dismissal falling straight through to the shared `0x6f4[2]` exit, and a second talk proving the startup flag was cleared so the interrupt exchange no longer replays.
 
 - Immediate: run an in-engine VALET validation pass that explicitly covers the now-confirmed native-only sequence: enter the conversation empty-handed to confirm the four-line opener `0xf2c[1] -> PC 0xf31[0] -> 0xf35[0] -> PC 0xf39[0]`, the `MIDGET02.FST` playback, response menu `0x2fe`, response `1` producing `0xf4a[0]`, response `2` producing `0xf50[2]`, and menu dismissal falling straight through to the shared exit line `0xf56[0]` with no extra bark or item-specific branch.
 - Immediate: rerun a focused in-engine Swell validation pass that checks the two follow-up corrections from this pass: after presenting `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, both ledgers with `HAVE_BOTH_LEDGERS`, and note/checkbook evidence to Swell, immediately visit at least one downstream consumer that reads the shared evidence-state ints (for example Dwayne, Moynahan, or Karin/Herrill paths already known to key off those discussion markers) to confirm the restored shared-state writes now propagate beyond the local startup-script flags; and in the keyword menu verify that only native topic `dialog.rsp[0x21e]` exits with `0xff1[1]` while `0x229` remains a silent continue and an unrelated typed topic still falls back to `0xfba[2]`.
diff --git a/engines/harvester/npc/vet_dialogue.cpp b/engines/harvester/npc/vet_dialogue.cpp
index 44e06b5563d..48545c66082 100644
--- a/engines/harvester/npc/vet_dialogue.cpp
+++ b/engines/harvester/npc/vet_dialogue.cpp
@@ -29,12 +29,21 @@ namespace Harvester {
 
 namespace {
 
+static const char *const kVetNpc = "VET";
+static const char *const kPcSpeaker = "PC";
 static const char *const kVetInterruptFlag = "VET_INTERRUPT";
+static const int kVetInterruptResponseLine = 0x2ff;
+
+static const DialogueLineEntry kVetInterruptIntroLines[] = {
+	{ 0x6d9, kVetNpc, 0 },
+	{ 0x447, kPcSpeaker, 0 },
+	{ 0x6e1, kVetNpc, 2 }
+};
 
 } // End of namespace
 
 bool VetDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("VET");
+	return npcName.equalsIgnoreCase(kVetNpc);
 }
 
 Common::Error VetDialogueHandler::handleDialogue(DialogueRuntime &runtime,
@@ -43,7 +52,33 @@ Common::Error VetDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return Common::kNoError;
 
 	(void)runtime.startupScript().setRuntimeFlagValue(kVetInterruptFlag, false);
-	return runtime.playDialogueLine(0x6d9, "VET");
+
+	Common::Error lineError = runtime.playDialogueEntrySequence(
+		kVetInterruptIntroLines, ARRAYSIZE(kVetInterruptIntroLines));
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	int responseIndex = 0;
+	Common::Error responseError = runtime.runResponseMenu(kVetInterruptResponseLine, responseIndex);
+	if (responseError.getCode() != Common::kNoError)
+		return responseError;
+
+	if (responseIndex == 1) {
+		lineError = runtime.playDialogueLineWithVariant(0x6ec, kVetNpc, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	} else if (responseIndex == 2) {
+		lineError = runtime.playDialogueLineWithVariant(0x6f0, kVetNpc, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
+
+	lineError = runtime.playDialogueLineWithVariant(0x6f4, kVetNpc, 2);
+	if (lineError.getCode() != Common::kNoError)
+		return lineError;
+
+	(void)runtime.startupScript().setRuntimeFlagValue(kVetInterruptFlag, false);
+	return Common::kNoError;
 }
 
 } // End of namespace Harvester


Commit: da31bf50974e8f7031a1b0f73b13a6ba38ccde26
    https://github.com/scummvm/scummvm/commit/da31bf50974e8f7031a1b0f73b13a6ba38ccde26
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:09+02:00

Commit Message:
HARVESTER: Reimplement wasp woman dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/wasp_woman_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index a3dbb49e056..24571bebffb 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,14 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `931`
-- Named/documented: `812`
+- Total functions: `933`
+- Named/documented: `814`
 - Remaining `FUN_*`: `119`
 - Remaining undocumented total: `119`
 
 ## Last Confirmed Action
 
+- On March 24, 2026, reimplemented the ScummVM Wasp Woman dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_wasp_woman` (`0x2fde0..0x3030b`). The old port in [`engines/harvester/npc/wasp_woman_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/wasp_woman_dialogue.cpp) missed the unconditional `C027B.FST` insert after topic `0x305`, omitted the full topic-`0x30c` branch `0x4c85[0] -> 0x4c8b[1] -> 0x4c8f[2]` plus the `dialog.rsp[0x30d]` buffer rewrite, ended the conversation after the `0x4c9a[0]` fallback instead of looping, and only set startup-script evidence flags instead of the native shared `get_set_discussed_*` state. Patched the handler to mirror the recovered branch structure, topic-buffer rewrites, and shared evidence writes; renamed the directly encountered FST wrapper at `0x38b00` from `FUN_00038b00` to `play_c027b_fst`; fixed its prototype to `void play_c027b_fst(void)`; saved `HARVEST.LE`; and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress is now `933` total functions, `814` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Vet dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_vet` (`0x2fd20..0x2fdd5`). The old stub in [`engines/harvester/npc/vet_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/vet_dialogue.cpp) only cleared `VET_INTERRUPT` and played `0x6d9`, while the recovered native handler is the full interrupt-gated exchange `0x6d9[0] -> PC 0x447[0] -> 0x6e1[2]`, response menu `dialog.rsp[0x2ff]`, response `1 -> 0x6ec[2]`, response `2 -> 0x6f0[0]`, and shared exit `0x6f4[2]`, with `VET_INTERRUPT` cleared before the dialogue and again on the shared tail. Patched [`engines/harvester/npc/vet_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/vet_dialogue.cpp), rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully from the configured repo-root `make -j4 scummvm` build, and audited the directly encountered native helpers (`get_flag_value`, `set_flag_value`, `load_dialogue_response_line`, `run_dialogue_response_menu`, and `play_dialogue_line`) without finding any `FUN_*` symbol that met the rename bar. Live progress remains `931` total functions, `812` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, re-audited the live `HARVEST.LE` VALET handler after the saved repair and confirmed the ScummVM flow at [`engines/harvester/npc/valet_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/valet_dialogue.cpp) is exactly the native `handle_talk_to_valet @ 0x2eda0`: intro lines `0xf2c[1] -> PC 0xf31[0] -> 0xf35[0] -> PC 0xf39[0]`, the `GRAPHIC/FST/MIDGET02.FST` playback, response menu `0x2fe`, optional reply `1 -> 0xf4a[0]` or `2 -> 0xf50[2]`, and the unconditional exit line `0xf56[0]`. I rewrote the handler around explicit native constants so that control flow matches the recovered body one-for-one, renamed the directly encountered wrapper at `0x38b30` from `FUN_00038b30` to `play_valet_midget02_fst_sequence`, fixed its prototype to `void play_valet_midget02_fst_sequence(void)`, and saved `HARVEST.LE`. The live database now reports `931` total functions, `812` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, reran the full durable `repair VALET -> wait -> save_program -> verify VALET` cycle against the live `HARVEST.LE` session. `handle_talk_to_valet @ 0x2eda0` started as a truncated `0x2eda0..0x2edb5` body (`22` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x2eda0..0x2ee3b` (`156` address-count) with `6` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify VALET` still reported `status=clean` with both dialogue entry points returning, so Valet is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
@@ -135,6 +136,8 @@
 - On March 22, 2026, patched `engines/harvester/npc/stephanie_dialogue.cpp` against those confirmed native Stephanie paths: corrected the recovered Stephanie/PC head variants that were still hardcoded as `0`, removed the bogus unconditional `0x4717` tail after the nested `SCRATCHED_TUCKER` reply, and restored the native early-return behavior on the `BOLT_OF_CLOTH_TAKEN`, `BARBER_POLE_STOLEN`, `DINER_BURNED`, and `0x2ed` keyword-response exits. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
 
 ## Next Suggested Action
+
+- Immediate: run an in-engine WASP_WOMAN validation pass that explicitly covers the restored native-only branches: first no-item talk before and after Mom's topic `0x121` sets the shared follow-up gate so the optional `PC 0x4bf2[0]` insert is confirmed; topic `0x302` to verify `C027A.FST` and the rewrite to `dialog.rsp[0x304]`; topic `0x305` with the Mom follow-up gate both clear and set to verify the unconditional `C027B.FST` plus the gated `dialog.rsp[0x307]` menu; topics `0x308`, `0x30a`, and `0x30c` to confirm the rewrites to `dialog.rsp[0x309]`, `dialog.rsp[0x30b]`, and `dialog.rsp[0x30d]`; the initial exit topic to confirm the conversation ends cleanly; and the late `0x30e` plus an unrelated typed topic to verify `0x30e` loops silently while the unmatched late-topic path now plays `0x4c9a[0]` and stays in the conversation. Also present `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO`, and note/checkbook evidence, then immediately visit one downstream consumer of the shared evidence ints to confirm the restored `discussed*` writes propagate beyond Wasp Woman's local startup-script flags.
 - Immediate: run an in-engine VET validation pass that explicitly triggers `VET_INTERRUPT`, confirms the restored `0x6d9[0] -> PC 0x447[0] -> 0x6e1[2]` opener, both `dialog.rsp[0x2ff]` answers (`0x6ec[2]` and `0x6f0[0]`), menu dismissal falling straight through to the shared `0x6f4[2]` exit, and a second talk proving the startup flag was cleared so the interrupt exchange no longer replays.
 
 - Immediate: run an in-engine VALET validation pass that explicitly covers the now-confirmed native-only sequence: enter the conversation empty-handed to confirm the four-line opener `0xf2c[1] -> PC 0xf31[0] -> 0xf35[0] -> PC 0xf39[0]`, the `MIDGET02.FST` playback, response menu `0x2fe`, response `1` producing `0xf4a[0]`, response `2` producing `0xf50[2]`, and menu dismissal falling straight through to the shared exit line `0xf56[0]` with no extra bark or item-specific branch.
diff --git a/engines/harvester/npc/wasp_woman_dialogue.cpp b/engines/harvester/npc/wasp_woman_dialogue.cpp
index 0c0e006c977..4e4acc417f3 100644
--- a/engines/harvester/npc/wasp_woman_dialogue.cpp
+++ b/engines/harvester/npc/wasp_woman_dialogue.cpp
@@ -31,6 +31,20 @@ namespace Harvester {
 namespace {
 
 static const char *const kDialogueC027AFstPath = "GRAPHIC/FST/C027A.FST";
+static const char *const kDialogueC027BFstPath = "GRAPHIC/FST/C027B.FST";
+static const int kWaspWomanInitialTopicBufferResponseLine = 0x300;
+static const int kWaspWomanInitialExitResponseLine = 0x301;
+static const int kWaspWomanTopic302ResponseLine = 0x302;
+static const int kWaspWomanTopic302PostBufferResponseLine = 0x304;
+static const int kWaspWomanTopic305ResponseLine = 0x305;
+static const int kWaspWomanTopic305MenuResponseLine = 0x307;
+static const int kWaspWomanTopic308ResponseLine = 0x308;
+static const int kWaspWomanTopic308PostBufferResponseLine = 0x309;
+static const int kWaspWomanTopic30aResponseLine = 0x30a;
+static const int kWaspWomanTopic30aPostBufferResponseLine = 0x30b;
+static const int kWaspWomanTopic30cResponseLine = 0x30c;
+static const int kWaspWomanTopic30cPostBufferResponseLine = 0x30d;
+static const int kWaspWomanLateSilentLoopResponseLine = 0x30e;
 
 static const DialogueLineEntry kWaspWomanWhaleyLines[] = {
 	{ 0x4ca6, "WASP_WOMAN", 0 },
@@ -66,7 +80,11 @@ static const DialogueLineEntry kWaspWomanTopic30aLines[] = {
 	{ 0x4c7f, "WASP_WOMAN", 1 }
 };
 
-static const int kWaspWomanInitialTopicBufferResponseLine = 0x300;
+static const DialogueLineEntry kWaspWomanTopic30cLines[] = {
+	{ 0x4c85, "WASP_WOMAN", 0 },
+	{ 0x4c8b, "WASP_WOMAN", 1 },
+	{ 0x4c8f, "WASP_WOMAN", 2 }
+};
 
 } // End of namespace
 
@@ -90,12 +108,14 @@ Common::Error WaspWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			sharedState.discussedWhaleyHerrillPhoto = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
 			return runtime.playDialogueEntrySequence(kWaspWomanWhaleyLines,
 				ARRAYSIZE(kWaspWomanWhaleyLines));
 		}
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			sharedState.discussedCasketPhotoEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
 			return playWaspWomanLine(0x4cbd, 0);
 		}
@@ -103,6 +123,7 @@ Common::Error WaspWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			sharedState.discussedNoteCheckbookEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
 			return playWaspWomanLine(0x4cc3, 0);
 		}
@@ -144,13 +165,14 @@ Common::Error WaspWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return menuError;
 		if (selectedTopic.empty())
 			return Common::kNoError;
-		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic())) {
+		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
+				runtime.matchesResponseLine(selectedTopic, kWaspWomanInitialExitResponseLine)) {
 			if (waspWomanTopicBufferLineIndex == kWaspWomanInitialTopicBufferResponseLine)
 				return Common::kNoError;
 			continue;
 		}
 
-		if (runtime.matchesResponseLine(selectedTopic, 0x302)) {
+		if (runtime.matchesResponseLine(selectedTopic, kWaspWomanTopic302ResponseLine)) {
 			Common::Error lineError = runtime.playDialogueEntrySequence(
 				kWaspWomanTopic302PreludeLines, ARRAYSIZE(kWaspWomanTopic302PreludeLines));
 			if (lineError.getCode() != Common::kNoError)
@@ -162,18 +184,22 @@ Common::Error WaspWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				kWaspWomanTopic302PostFstLines, ARRAYSIZE(kWaspWomanTopic302PostFstLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			assignWaspWomanTopicBuffer(0x304);
+			assignWaspWomanTopicBuffer(kWaspWomanTopic302PostBufferResponseLine);
 			continue;
 		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x305)) {
+		if (runtime.matchesResponseLine(selectedTopic, kWaspWomanTopic305ResponseLine)) {
 			Common::Error lineError = playWaspWomanLine(0x4c31, 0);
+			if (lineError.getCode() != Common::kNoError)
+				return lineError;
+			lineError = runtime.playDialogueFst(kDialogueC027BFstPath);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 			if (!sharedState.waspWomanDialogueState)
 				continue;
 
 			int responseIndex = 0;
-			Common::Error responseError = runtime.runResponseMenu(0x307, responseIndex);
+			Common::Error responseError = runtime.runResponseMenu(
+				kWaspWomanTopic305MenuResponseLine, responseIndex);
 			if (responseError.getCode() != Common::kNoError)
 				return responseError;
 
@@ -193,31 +219,34 @@ Common::Error WaspWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				return lineError;
 			continue;
 		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x308)) {
+		if (runtime.matchesResponseLine(selectedTopic, kWaspWomanTopic308ResponseLine)) {
 			Common::Error lineError = playWaspWomanLine(0x4c67, 0);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			assignWaspWomanTopicBuffer(0x309);
+			assignWaspWomanTopicBuffer(kWaspWomanTopic308PostBufferResponseLine);
 			continue;
 		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x30a)) {
+		if (runtime.matchesResponseLine(selectedTopic, kWaspWomanTopic30aResponseLine)) {
 			Common::Error lineError = runtime.playDialogueEntrySequence(
 				kWaspWomanTopic30aLines, ARRAYSIZE(kWaspWomanTopic30aLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			assignWaspWomanTopicBuffer(0x30b);
+			assignWaspWomanTopicBuffer(kWaspWomanTopic30aPostBufferResponseLine);
 			continue;
 		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x30c)) {
-			Common::Error lineError = playWaspWomanLine(0x4c85, 0);
+		if (runtime.matchesResponseLine(selectedTopic, kWaspWomanTopic30cResponseLine)) {
+			Common::Error lineError = runtime.playDialogueEntrySequence(
+				kWaspWomanTopic30cLines, ARRAYSIZE(kWaspWomanTopic30cLines));
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
+			assignWaspWomanTopicBuffer(kWaspWomanTopic30cPostBufferResponseLine);
 			continue;
 		}
+		if (runtime.matchesResponseLine(selectedTopic, kWaspWomanLateSilentLoopResponseLine))
+			continue;
 		Common::Error lineError = playWaspWomanLine(0x4c9a, 0);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		return Common::kNoError;
 	}
 }
 


Commit: ab9a26bd4b18d159ccccaebecb13bf6336e52b59
    https://github.com/scummvm/scummvm/commit/ab9a26bd4b18d159ccccaebecb13bf6336e52b59
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:09+02:00

Commit Message:
HARVESTER: Align Whaley dialogue with native state flow

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/whaley_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index d866ab79a83..9f6ed051d2b 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -569,7 +569,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `get_set_shared_dialogue_state_d2e98` at `0x38230` is a neutral wrapper over `g_shared_dialogue_state_d2e98`; the currently recovered gameplay callers are Boyle's `BOYLES_BUTTON` response-2 branch, which writes it immediately before `SHERIFF_IN_DINER`, and Herrill's `GASCAN` branch at `0x2e056`, where `eax = 1` / `edx = 0` makes it a read-only gate and a zero value collapses the post-`0x30d8` continuation to `PC 0x3123`. Outside those handlers, the backing global is only touched by the shared reset path and save/load serialization.
   - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
   - `get_set_shared_dialogue_state_d2eb0` at `0x382a0` is a neutral wrapper over `g_shared_dialogue_state_d2eb0`; the currently recovered gameplay callers are Boyle's `BOYLES_BUTTON` response-2 branch, which writes it alongside `d2e98`, and the hidden Boyle dialogue branch at `0x2d6e3`, where `eax = 1` / `edx = 0` makes it a read-only gate between `BOYLE 0x10e` and `BOYLE 0x115` after the local item checks. Outside those handlers, the backing global is only touched by the shared reset path and save/load serialization.
-  - `get_set_shared_dialogue_state_d2eb8` at `0x382c0` is another neutral shared wrapper over `g_shared_dialogue_state_d2eb8`; the currently recovered gameplay caller is Mrs. Potts's `STEPHANIE_IS_DEAD` branch, where `eax = 1` / `edx = 0` makes it a read-only gate between the opening `0x283b` response menu and the later room-specific dead-Stephanie branches. A nearby cutscene helper at `0x38930` also writes that same bit while setting `STEPH_MIDGAME_PLAYED`, but the full gameplay-side semantics of the shared state are still not bounded tightly enough for a stronger name.
+  - `get_set_shared_dialogue_state_d2eb8` at `0x382c0` is another neutral shared wrapper over `g_shared_dialogue_state_d2eb8`; the currently recovered gameplay caller is Mrs. Potts's `STEPHANIE_IS_DEAD` branch, where `eax = 1` / `edx = 0` makes it a read-only gate between the opening `0x283b` response menu and the later room-specific dead-Stephanie branches. The adjacent helper `play_c001b_fst_and_mark_steph_midgame_played` at `0x38930` writes that same bit, sets `STEPH_MIDGAME_PLAYED`, clears the screen, and plays `C001B.FST` on the Mr. Potts Stephanie-hall exit path, but the full gameplay-side semantics of the shared state are still not bounded tightly enough for a stronger name.
   - `get_set_shared_dialogue_state_d2ebc` at `0x382d0` is another neutral shared wrapper over `g_shared_dialogue_state_d2ebc`; the currently recovered gameplay caller is Boyle's `CASKET_PHOTO` / `CASKET_PHOTOCOPY` branch, where `eax = 1` / `edx = 0` makes it a read-only gate for the optional `0x253` line between `0x24f` and `0x258`. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path and save/load serialization.
   - `get_set_shared_dialogue_state_d2ec0` at `0x382e0` is a neutral wrapper over `g_shared_dialogue_state_d2ec0`; the currently recovered gameplay writer is Moynahan's day-5 `0x1b2` / `0x1b3` keyword branch, which sets it immediately before line `0x3df5` alongside the already recovered shared `d2ebc` and `d2ec8` bits. No reader is bounded tightly enough yet for a stronger semantic name.
   - `get_set_moynahan_karin_kidnaped_discussion_state` at `0x382f0` is a Moynahan-local follow-up bit written from the `HAVE_BOTH_LEDGERS` response menu when the player takes response `2` after story day `2`, immediately before `0x3f7a`.
diff --git a/TRACKER.md b/TRACKER.md
index 24571bebffb..db9575f0c74 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,18 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `933`
-- Named/documented: `814`
-- Remaining `FUN_*`: `119`
-- Remaining undocumented total: `119`
+- Total functions: `914`
+- Named/documented: `802`
+- Remaining `FUN_*`: `112`
+- Remaining undocumented total: `112`
+
+## Next Suggested Action
+
+- Run an in-engine Whaley validation pass that explicitly hits the restored native-only shared-state edges: `BUSTED_SCREWING_MIDGAME` with and without `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS`, note/checkbook evidence, the `KARIN_KIDNAPED` no-item follow-up, the later shared-photo bark `0x14dd[2]`, and topic `0x259` so Dwayne's Miss Whaley discipline follow-up still triggers after `C052.FST`.
 
 ## Last Confirmed Action
 
+- On March 24, 2026, reimplemented the ScummVM Whaley dialogue handler directly against the saved live `HARVEST.LE` body at `handle_talk_to_whaley` (`0x23ec0..0x249ca`). The earlier port in [`engines/harvester/npc/whaley_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/whaley_dialogue.cpp) already matched most of the recovered control flow, but three native mismatches remained: the casket-photo, dual-ledger, and note/checkbook item branches only wrote startup-script flags instead of the native shared `get_set_discussed_*` evidence state; the later `0x14dd[2]` Whaley/Herrill-photo follow-up was gated on the startup flag instead of shared `get_set_discussed_whaley_herrill_photo`; and the `KARIN_KIDNAPED` no-item branch collapsed `0x13e5..0x13ff` into one sequence instead of setting the shared kidnapped-dialogue state between `0x13e5` and `0x13ee`. Patched the handler to mirror those native state transitions, renamed the directly encountered cutscene helper at `0x38930` from `FUN_00038930` to `play_c001b_fst_and_mark_steph_midgame_played`, fixed its prototype to `void play_c001b_fst_and_mark_steph_midgame_played(void)`, updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) with the confirmed helper semantics, saved `HARVEST.LE`, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Live progress is now `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Wasp Woman dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_wasp_woman` (`0x2fde0..0x3030b`). The old port in [`engines/harvester/npc/wasp_woman_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/wasp_woman_dialogue.cpp) missed the unconditional `C027B.FST` insert after topic `0x305`, omitted the full topic-`0x30c` branch `0x4c85[0] -> 0x4c8b[1] -> 0x4c8f[2]` plus the `dialog.rsp[0x30d]` buffer rewrite, ended the conversation after the `0x4c9a[0]` fallback instead of looping, and only set startup-script evidence flags instead of the native shared `get_set_discussed_*` state. Patched the handler to mirror the recovered branch structure, topic-buffer rewrites, and shared evidence writes; renamed the directly encountered FST wrapper at `0x38b00` from `FUN_00038b00` to `play_c027b_fst`; fixed its prototype to `void play_c027b_fst(void)`; saved `HARVEST.LE`; and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress is now `933` total functions, `814` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Vet dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_vet` (`0x2fd20..0x2fdd5`). The old stub in [`engines/harvester/npc/vet_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/vet_dialogue.cpp) only cleared `VET_INTERRUPT` and played `0x6d9`, while the recovered native handler is the full interrupt-gated exchange `0x6d9[0] -> PC 0x447[0] -> 0x6e1[2]`, response menu `dialog.rsp[0x2ff]`, response `1 -> 0x6ec[2]`, response `2 -> 0x6f0[0]`, and shared exit `0x6f4[2]`, with `VET_INTERRUPT` cleared before the dialogue and again on the shared tail. Patched [`engines/harvester/npc/vet_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/vet_dialogue.cpp), rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully from the configured repo-root `make -j4 scummvm` build, and audited the directly encountered native helpers (`get_flag_value`, `set_flag_value`, `load_dialogue_response_line`, `run_dialogue_response_menu`, and `play_dialogue_line`) without finding any `FUN_*` symbol that met the rename bar. Live progress remains `931` total functions, `812` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, re-audited the live `HARVEST.LE` VALET handler after the saved repair and confirmed the ScummVM flow at [`engines/harvester/npc/valet_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/valet_dialogue.cpp) is exactly the native `handle_talk_to_valet @ 0x2eda0`: intro lines `0xf2c[1] -> PC 0xf31[0] -> 0xf35[0] -> PC 0xf39[0]`, the `GRAPHIC/FST/MIDGET02.FST` playback, response menu `0x2fe`, optional reply `1 -> 0xf4a[0]` or `2 -> 0xf50[2]`, and the unconditional exit line `0xf56[0]`. I rewrote the handler around explicit native constants so that control flow matches the recovered body one-for-one, renamed the directly encountered wrapper at `0x38b30` from `FUN_00038b30` to `play_valet_midget02_fst_sequence`, fixed its prototype to `void play_valet_midget02_fst_sequence(void)`, and saved `HARVEST.LE`. The live database now reports `931` total functions, `812` named/documented, and `119` remaining `FUN_*`.
diff --git a/engines/harvester/npc/whaley_dialogue.cpp b/engines/harvester/npc/whaley_dialogue.cpp
index c9e2783a5d6..d9e4d8a0d12 100644
--- a/engines/harvester/npc/whaley_dialogue.cpp
+++ b/engines/harvester/npc/whaley_dialogue.cpp
@@ -110,14 +110,6 @@ static const DialogueLineEntry kWhaleyDay5Response2Lines[] = {
 	{ 0x13df, "WHALEY", 2 }
 };
 
-static const DialogueLineEntry kWhaleyKarinKidnapedLines[] = {
-	{ 0x13e5, "WHALEY", 0 },
-	{ 0x13ee, "WHALEY", 1 },
-	{ 0x13f3, "PC", 0 },
-	{ 0x13f8, "WHALEY", 0 },
-	{ 0x13ff, "WHALEY", 0 }
-};
-
 static const DialogueLineEntry kWhaleyBurnedTvStationLines[] = {
 	{ 0x14f7, "WHALEY", 1 },
 	{ 0x14fe, "WHALEY", 1 },
@@ -278,6 +270,7 @@ Common::Error WhaleyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			sharedState.discussedCasketPhotoEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
 			Common::Error lineError = playSequence(kWhaleyCasketPhotoLines,
 				ARRAYSIZE(kWhaleyCasketPhotoLines));
@@ -300,6 +293,7 @@ Common::Error WhaleyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
 				usedItemName.equalsIgnoreCase("LEDGER2")) &&
 				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+			sharedState.discussedLedgerEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
 			return playSequence(kWhaleyLedgerLines, ARRAYSIZE(kWhaleyLedgerLines));
 		}
@@ -308,6 +302,7 @@ Common::Error WhaleyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			sharedState.discussedNoteCheckbookEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
 			return playSequence(kWhaleyBlackmailLines, ARRAYSIZE(kWhaleyBlackmailLines));
 		}
@@ -401,15 +396,25 @@ Common::Error WhaleyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (runtime.startupScript().getFlagValue("KARIN_KIDNAPED") &&
 			!state.karinKidnapedShown) {
 		state.karinKidnapedShown = true;
+		Common::Error lineError = playWhaleyLine(0x13e5, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
 		sharedState.karinKidnapedDialogueState = true;
-
-		Common::Error lineError = playSequence(kWhaleyKarinKidnapedLines,
-			ARRAYSIZE(kWhaleyKarinKidnapedLines));
+		lineError = playWhaleyLine(0x13ee, 1);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playPcLine(0x13f3, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playWhaleyLine(0x13f8, 0);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = playWhaleyLine(0x13ff, 0);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
 
-	if (runtime.startupScript().getFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill) &&
+	if (sharedState.discussedWhaleyHerrillPhoto != 0 &&
 			!state.whaleyHerrillPhotoFollowupShown) {
 		state.whaleyHerrillPhotoFollowupShown = true;
 		Common::Error lineError = playWhaleyLine(0x14dd, 2);


Commit: d5d8b1730ffa835ae58f8189221c1308933cda60
    https://github.com/scummvm/scummvm/commit/d5d8b1730ffa835ae58f8189221c1308933cda60
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:10+02:00

Commit Message:
HARVESTER: Reimplement Stephanie dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/stephanie_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index db9575f0c74..792dd74603a 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,20 +3,25 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `914`
-- Named/documented: `802`
+- Total functions: `920`
+- Named/documented: `808`
 - Remaining `FUN_*`: `112`
 - Remaining undocumented total: `112`
 
 ## Next Suggested Action
 
-- Run an in-engine Whaley validation pass that explicitly hits the restored native-only shared-state edges: `BUSTED_SCREWING_MIDGAME` with and without `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS`, note/checkbook evidence, the `KARIN_KIDNAPED` no-item follow-up, the later shared-photo bark `0x14dd[2]`, and topic `0x259` so Dwayne's Miss Whaley discipline follow-up still triggers after `C052.FST`.
+- Run an in-engine Stephanie validation pass that explicitly covers the freshly reimplemented native behavior: first intro and same-day revisit; the `0x45d8 -> 0x45de -> 0x45e2` first-followup tail; note/checkbook, casket-photo, and dual-ledger item use followed immediately by a downstream consumer that reads the shared evidence ints; a same-call stack of `SCRATCHED_TUCKER`, `BOLT_OF_CLOTH_TAKEN`, `BARBER_POLE_STOLEN`, or `DINER_BURNED` state combinations to confirm the handler now falls through the full native one-shot ladder instead of returning early; the `0x2e5/0x2e6` and `0x2ed` keyword branches to confirm they continue through `0x4441` and `0x44a2..0x44af` plus the correct topic-buffer rewrites; and both Karin-alive outcomes with and without the Dwayne follow-up gate so only the gated path plays `0x49c1` before the shared `0x49ea -> 0x49ef` ending.
 
 ## Last Confirmed Action
 
+- On March 24, 2026, reimplemented the ScummVM Stephanie dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_stephanie` (`0x36710..0x37f5d`) as a fresh pass rather than reusing the prior port. The recovered native body confirmed three engine mismatches in [`engines/harvester/npc/stephanie_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/stephanie_dialogue.cpp): the note/checkbook, casket-photo, and dual-ledger item branches write shared dialogue state through `get_set_discussed_note_checkbook_evidence`, `get_set_discussed_casket_photo_evidence`, and `get_set_discussed_ledger_evidence`, so the port now mirrors that by writing `sharedState.discussedNoteCheckbookEvidence`, `sharedState.discussedCasketPhotoEvidence`, and `sharedState.discussedLedgerEvidence` alongside the existing startup-script flags; the native one-shot ladder for `SCRATCHED_TUCKER`, `BOLT_OF_CLOTH_TAKEN`, `BARBER_POLE_STOLEN`, and `DINER_BURNED` falls through into later event checks instead of returning early, so the early C++ returns were removed to match the live control flow; and the keyword branches for `dialog.rsp[0x2e5/0x2e6]` and `dialog.rsp[0x2ed]` continue through the shared `0x4441` and `0x44a2..0x44af` tails with topic-buffer rewrites instead of ending immediately, while Stephanie's `dialog.rsp[0x2f3]` lodge topic only reads the shared lodge-discussion state and does not set it locally. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Direct Stephanie callees were re-audited in Ghidra (`play_c022a_fst`, `play_c022b_fst`, `play_c023a_fst`, `play_c025a_fst`, `play_c025b_fst`, `play_c007_fst`, and the shared-state accessors), but none remained as `FUN_*` or met the rename bar for a new symbol change. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
+- On March 24, 2026, treated `STEPHANIE` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify STEPHANIE` reported `handle_talk_to_stephanie @ 0x36710` as the short `0x36710..0x36cd8` body (`526` address-count) with `9` reachable dialogue calls and `9` stale `CALL_RETURN` gaps. Running `repair STEPHANIE` rebuilt the handler in two passes to `0x36710..0x37f5d` (`6166` address-count, `195` dialogue calls, `gapCount=0`, `29` repair actions); after a settle and explicit `save_program`, a fresh `verify STEPHANIE` still reported `status=clean` with both dialogue entry points returning. A targeted raw-byte audit of the recovered body then found two still-misdecoded in-body call sites at `0x3765c` and `0x3778d`; clearing and re-disassembling those spans restored the missing `CALL 0x7a690` instructions for the optional `0x4970` spyhole line and the shared `0x49ba` Karin-alive call site. Rechecking the repaired bytes confirmed that the live native handler still matches the current ScummVM Stephanie flow in [`engines/harvester/npc/stephanie_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/stephanie_dialogue.cpp), including the `0x45d8/0x45e2` first-followup tail, `0x4709` portrait variant, the `0x4984/0x4989/0x498f` Mrs. Potts alibi followup, and the gated `0x49c1 -> 0x49ea -> 0x49ef` Karin-alive tail, so no engine source patch was needed. Live progress is now `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Whaley dialogue handler directly against the saved live `HARVEST.LE` body at `handle_talk_to_whaley` (`0x23ec0..0x249ca`). The earlier port in [`engines/harvester/npc/whaley_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/whaley_dialogue.cpp) already matched most of the recovered control flow, but three native mismatches remained: the casket-photo, dual-ledger, and note/checkbook item branches only wrote startup-script flags instead of the native shared `get_set_discussed_*` evidence state; the later `0x14dd[2]` Whaley/Herrill-photo follow-up was gated on the startup flag instead of shared `get_set_discussed_whaley_herrill_photo`; and the `KARIN_KIDNAPED` no-item branch collapsed `0x13e5..0x13ff` into one sequence instead of setting the shared kidnapped-dialogue state between `0x13e5` and `0x13ee`. Patched the handler to mirror those native state transitions, renamed the directly encountered cutscene helper at `0x38930` from `FUN_00038930` to `play_c001b_fst_and_mark_steph_midgame_played`, fixed its prototype to `void play_c001b_fst_and_mark_steph_midgame_played(void)`, updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) with the confirmed helper semantics, saved `HARVEST.LE`, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Live progress is now `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
+- On March 24, 2026, reran `repair WHALEY` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Whaley showed the same first-save snapback pattern seen on the broader retry bucket: the first `repair -> wait -> save_program -> verify` cycle rebuilt `handle_talk_to_whaley @ 0x23ec0` from the short `0x23ec0..0x244f5` body (`438` address-count, `7` dialogue calls, `7` stale `CALL_RETURN` gaps) to a clean in-memory `0x23ec0..0x249ca` body (`2808` address-count, `78` dialogue calls, `gapCount=0`), but the first saved verify fell back to the short body and flipped both dialogue entry points back to non-returning. An immediate second full cycle fixed it: the second `repair -> wait -> save_program -> verify` left Whaley durably clean again at `0x23ec0..0x249ca` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 24, 2026, reimplemented the ScummVM Wasp Woman dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_wasp_woman` (`0x2fde0..0x3030b`). The old port in [`engines/harvester/npc/wasp_woman_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/wasp_woman_dialogue.cpp) missed the unconditional `C027B.FST` insert after topic `0x305`, omitted the full topic-`0x30c` branch `0x4c85[0] -> 0x4c8b[1] -> 0x4c8f[2]` plus the `dialog.rsp[0x30d]` buffer rewrite, ended the conversation after the `0x4c9a[0]` fallback instead of looping, and only set startup-script evidence flags instead of the native shared `get_set_discussed_*` state. Patched the handler to mirror the recovered branch structure, topic-buffer rewrites, and shared evidence writes; renamed the directly encountered FST wrapper at `0x38b00` from `FUN_00038b00` to `play_c027b_fst`; fixed its prototype to `void play_c027b_fst(void)`; saved `HARVEST.LE`; and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress is now `933` total functions, `814` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Vet dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_vet` (`0x2fd20..0x2fdd5`). The old stub in [`engines/harvester/npc/vet_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/vet_dialogue.cpp) only cleared `VET_INTERRUPT` and played `0x6d9`, while the recovered native handler is the full interrupt-gated exchange `0x6d9[0] -> PC 0x447[0] -> 0x6e1[2]`, response menu `dialog.rsp[0x2ff]`, response `1 -> 0x6ec[2]`, response `2 -> 0x6f0[0]`, and shared exit `0x6f4[2]`, with `VET_INTERRUPT` cleared before the dialogue and again on the shared tail. Patched [`engines/harvester/npc/vet_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/vet_dialogue.cpp), rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully from the configured repo-root `make -j4 scummvm` build, and audited the directly encountered native helpers (`get_flag_value`, `set_flag_value`, `load_dialogue_response_line`, `run_dialogue_response_menu`, and `play_dialogue_line`) without finding any `FUN_*` symbol that met the rename bar. Live progress remains `931` total functions, `812` named/documented, and `119` remaining `FUN_*`.
+- On March 24, 2026, reran the full durable `repair WASP_WOMAN -> wait -> save_program -> verify WASP_WOMAN` cycle against the live `HARVEST.LE` session. `handle_talk_to_wasp_woman @ 0x2fde0` started as a truncated `0x2fde0..0x2ffd8` body (`291` address-count) with `4` reachable dialogue calls and `4` stale `CALL_RETURN` gaps. A single repair pass rebuilt it cleanly to `0x2fde0..0x3030b` (`1314` address-count) with `28` reachable dialogue calls, `gapCount=0`, and `8` repair actions. After the normal settle and explicit save, a fresh `verify WASP_WOMAN` still reported `status=clean` with both dialogue entry points returning, so Wasp Woman is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
+- On March 24, 2026, reran the full durable `repair VET -> wait -> save_program -> verify VET` cycle against the live `HARVEST.LE` session. `handle_talk_to_vet @ 0x2fd20` started as a truncated `0x2fd20..0x2fdd5` body (`54` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x2fd20..0x2fdd5` (`182` address-count) with `5` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify VET` still reported `status=clean` with both dialogue entry points returning, so Vet is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 24, 2026, re-audited the live `HARVEST.LE` VALET handler after the saved repair and confirmed the ScummVM flow at [`engines/harvester/npc/valet_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/valet_dialogue.cpp) is exactly the native `handle_talk_to_valet @ 0x2eda0`: intro lines `0xf2c[1] -> PC 0xf31[0] -> 0xf35[0] -> PC 0xf39[0]`, the `GRAPHIC/FST/MIDGET02.FST` playback, response menu `0x2fe`, optional reply `1 -> 0xf4a[0]` or `2 -> 0xf50[2]`, and the unconditional exit line `0xf56[0]`. I rewrote the handler around explicit native constants so that control flow matches the recovered body one-for-one, renamed the directly encountered wrapper at `0x38b30` from `FUN_00038b30` to `play_valet_midget02_fst_sequence`, fixed its prototype to `void play_valet_midget02_fst_sequence(void)`, and saved `HARVEST.LE`. The live database now reports `931` total functions, `812` named/documented, and `119` remaining `FUN_*`.
 - On March 24, 2026, reran the full durable `repair VALET -> wait -> save_program -> verify VALET` cycle against the live `HARVEST.LE` session. `handle_talk_to_valet @ 0x2eda0` started as a truncated `0x2eda0..0x2edb5` body (`22` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x2eda0..0x2ee3b` (`156` address-count) with `6` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify VALET` still reported `status=clean` with both dialogue entry points returning, so Valet is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
 - On March 24, 2026, re-audited the live `HARVEST.LE` Swell handler after the earlier port and confirmed two remaining ScummVM mismatches in [`engines/harvester/npc/swell_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/swell_dialogue.cpp): the native evidence branches call `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, and `get_set_discussed_note_checkbook_evidence`, so the port now also writes the matching shared dialogue-state ints alongside the existing startup-script flags; and the keyword loop exits only on native response line `0x21e`, so the extra `runtime.genericByeTopic()` shortcut was removed to match the recovered `strcmp_ascii_case_insensitive` branch exactly. During the same pass I renamed the proven SWELL room-state reset helper at `0x29700` to `reset_swell_room_dialogue_state` and relabeled its backing globals as `g_swell_intro_pending`, `g_swell_steph_midgame_shown`, `g_swell_barber_pole_stolen_shown`, `g_swell_bolt_of_cloth_taken_shown`, `g_swell_burned_tv_station_shown`, and `g_swell_karin_kidnaped_shown`, then rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully and saved `HARVEST.LE`. No additional `FUN_*` rename was justified, so live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
@@ -144,7 +149,6 @@
 
 - Immediate: run an in-engine WASP_WOMAN validation pass that explicitly covers the restored native-only branches: first no-item talk before and after Mom's topic `0x121` sets the shared follow-up gate so the optional `PC 0x4bf2[0]` insert is confirmed; topic `0x302` to verify `C027A.FST` and the rewrite to `dialog.rsp[0x304]`; topic `0x305` with the Mom follow-up gate both clear and set to verify the unconditional `C027B.FST` plus the gated `dialog.rsp[0x307]` menu; topics `0x308`, `0x30a`, and `0x30c` to confirm the rewrites to `dialog.rsp[0x309]`, `dialog.rsp[0x30b]`, and `dialog.rsp[0x30d]`; the initial exit topic to confirm the conversation ends cleanly; and the late `0x30e` plus an unrelated typed topic to verify `0x30e` loops silently while the unmatched late-topic path now plays `0x4c9a[0]` and stays in the conversation. Also present `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO`, and note/checkbook evidence, then immediately visit one downstream consumer of the shared evidence ints to confirm the restored `discussed*` writes propagate beyond Wasp Woman's local startup-script flags.
 - Immediate: run an in-engine VET validation pass that explicitly triggers `VET_INTERRUPT`, confirms the restored `0x6d9[0] -> PC 0x447[0] -> 0x6e1[2]` opener, both `dialog.rsp[0x2ff]` answers (`0x6ec[2]` and `0x6f0[0]`), menu dismissal falling straight through to the shared `0x6f4[2]` exit, and a second talk proving the startup flag was cleared so the interrupt exchange no longer replays.
-
 - Immediate: run an in-engine VALET validation pass that explicitly covers the now-confirmed native-only sequence: enter the conversation empty-handed to confirm the four-line opener `0xf2c[1] -> PC 0xf31[0] -> 0xf35[0] -> PC 0xf39[0]`, the `MIDGET02.FST` playback, response menu `0x2fe`, response `1` producing `0xf4a[0]`, response `2` producing `0xf50[2]`, and menu dismissal falling straight through to the shared exit line `0xf56[0]` with no extra bark or item-specific branch.
 - Immediate: rerun a focused in-engine Swell validation pass that checks the two follow-up corrections from this pass: after presenting `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, both ledgers with `HAVE_BOTH_LEDGERS`, and note/checkbook evidence to Swell, immediately visit at least one downstream consumer that reads the shared evidence-state ints (for example Dwayne, Moynahan, or Karin/Herrill paths already known to key off those discussion markers) to confirm the restored shared-state writes now propagate beyond the local startup-script flags; and in the keyword menu verify that only native topic `dialog.rsp[0x21e]` exits with `0xff1[1]` while `0x229` remains a silent continue and an unrelated typed topic still falls back to `0xfba[2]`.
 - Immediate: run an in-engine PTA_MOM validation pass that explicitly covers the corrected response-`1` portrait tail and the existing native branches: trigger `PTA_RESPOND_TO_TV` once to confirm the early `0x3233[2]` bark clears the flag and skips `dialog.rsp[0x297]`; then talk repeatedly without the flag until each opener `0x31ee[1]`, `0x31f2[1]`, and `0x31f6[1]` has appeared at least once, taking response `1` to confirm `0x3204[2] -> 0x320b[0]`, response `2` to confirm `0x320f[0] -> 0x3213[0]`, response `3` to confirm `0x3217[0] -> 0x321c[0] -> PC 0x3221[0] -> 0x3226[0]`, and dismissing the menu once to verify the native handler returns silently with no fallback bark.
diff --git a/engines/harvester/npc/stephanie_dialogue.cpp b/engines/harvester/npc/stephanie_dialogue.cpp
index a1fba7a1289..d7a6a1c66e0 100644
--- a/engines/harvester/npc/stephanie_dialogue.cpp
+++ b/engines/harvester/npc/stephanie_dialogue.cpp
@@ -85,12 +85,14 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			sharedState.discussedNoteCheckbookEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(
 				DialogueFlags::kShownEvidenceOfBlackmail, true);
 			return playStephanieLineWithVariant(0x466a, 2);
 		}
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			sharedState.discussedCasketPhotoEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
 			Common::Error lineError = playStephanieLineWithVariant(0x4671, 4);
 			if (lineError.getCode() != Common::kNoError)
@@ -116,6 +118,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
 				usedItemName.equalsIgnoreCase("LEDGER2")) &&
 				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+			sharedState.discussedLedgerEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
 			const DialogueLineEntry lines[] = {
 				{ 0x468f, "PC", 0 },
@@ -364,15 +367,16 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 					{ 0x4705, "STEPHANIE", 2 },
 					{ 0x4709, "PC", 2 }
 				};
-				return runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
+				lineError = runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
 			} else if (followupResponseIndex == 2) {
 				const DialogueLineEntry followupLines[] = {
 					{ 0x470e, "STEPHANIE", 2 },
 					{ 0x470f, "PC", 0 }
 				};
-				return runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
+				lineError = runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
+			} else {
+				lineError = playStephanieLineWithVariant(0x4717, 3);
 			}
-			lineError = playStephanieLineWithVariant(0x4717, 3);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		} else if (responseIndex == 2) {
@@ -410,7 +414,6 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		return Common::kNoError;
 	}
 	if (runtime.startupScript().getFlagValue("BARBER_POLE_STOLEN") &&
 			!state.barberPoleStolenShown) {
@@ -455,7 +458,6 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		return Common::kNoError;
 	}
 	if (runtime.startupScript().getFlagValue("DINER_BURNED") &&
 			!state.dinerBurnedShown) {
@@ -495,7 +497,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 					{ 0x486b, "PC", 4 },
 					{ 0x4870, "STEPHANIE", 2 }
 				};
-				return runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
+				lineError = runtime.playDialogueEntrySequence(followupLines, ARRAYSIZE(followupLines));
 			} else if (followupResponseIndex == 2) {
 				const DialogueLineEntry responseTwoLines[] = {
 					{ 0x4877, "STEPHANIE", 3 },
@@ -525,7 +527,6 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			}
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			return Common::kNoError;
 		} else if (responseIndex == 2) {
 			const DialogueLineEntry responseTwoLines[] = {
 				{ 0x48c2, "STEPHANIE", 0 },
@@ -547,7 +548,9 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 					{ 0x48e1, "PC", 0 },
 					{ 0x48e5, "STEPHANIE", 0 }
 				};
-				return runtime.playDialogueEntrySequence(karinAliveLines, ARRAYSIZE(karinAliveLines));
+				lineError = runtime.playDialogueEntrySequence(karinAliveLines, ARRAYSIZE(karinAliveLines));
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
 			}
 		}
 	}
@@ -705,7 +708,9 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 					lineError = playStephanieLine(0x442c);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
-				return runtime.playDialogueLine(0x4430, "PC");
+				lineError = runtime.playDialogueLine(0x4430, "PC");
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
 			}
 			if (responseIndex == 2) {
 				lineError = playStephanieLine(0x4434);
@@ -714,7 +719,9 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				lineError = runtime.playDialogueLine(0x4439, "PC");
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
-				return playStephanieLine(0x443d);
+				lineError = playStephanieLine(0x443d);
+				if (lineError.getCode() != Common::kNoError)
+					return lineError;
 			}
 			lineError = playStephanieLine(0x4441);
 			if (lineError.getCode() != Common::kNoError)
@@ -767,7 +774,6 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				lineError = playStephanieLineWithVariant(0x4495, 2);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
-				return Common::kNoError;
 			} else if (responseIndex == 2) {
 				lineError = playStephanieLine(0x4499);
 				if (lineError.getCode() != Common::kNoError)
@@ -775,7 +781,6 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				lineError = runtime.playDialogueLine(0x449e, "PC");
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
-				return Common::kNoError;
 			}
 			const DialogueLineEntry lines[] = {
 				{ 0x44a2, "PC", 0 },
@@ -816,7 +821,6 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				sharedState.discussedLodgeTopic ? 0x4505 : 0x450a, "PC");
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
-			sharedState.discussedLodgeTopic = true;
 			if (sharedState.dialogueStateD2f00) {
 				lineError = runtime.playDialogueLine(0x450f, "PC");
 				if (lineError.getCode() != Common::kNoError)
@@ -889,8 +893,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				return lineError;
 			continue;
 		}
-		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
-				runtime.matchesResponseLine(selectedTopic, 0x2e4)) {
+		if (runtime.matchesResponseLine(selectedTopic, 0x2e4)) {
 			return playStephanieLine(0x46a4);
 		}
 


Commit: 3a938751c8616573d3d3b2d9dc83f545423ff7bf
    https://github.com/scummvm/scummvm/commit/3a938751c8616573d3d3b2d9dc83f545423ff7bf
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:10+02:00

Commit Message:
HARVESTER: Reimplement Dwayne dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/dwayne_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 792dd74603a..8efd4baca10 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,10 +10,12 @@
 
 ## Next Suggested Action
 
-- Run an in-engine Stephanie validation pass that explicitly covers the freshly reimplemented native behavior: first intro and same-day revisit; the `0x45d8 -> 0x45de -> 0x45e2` first-followup tail; note/checkbook, casket-photo, and dual-ledger item use followed immediately by a downstream consumer that reads the shared evidence ints; a same-call stack of `SCRATCHED_TUCKER`, `BOLT_OF_CLOTH_TAKEN`, `BARBER_POLE_STOLEN`, or `DINER_BURNED` state combinations to confirm the handler now falls through the full native one-shot ladder instead of returning early; the `0x2e5/0x2e6` and `0x2ed` keyword branches to confirm they continue through `0x4441` and `0x44a2..0x44af` plus the correct topic-buffer rewrites; and both Karin-alive outcomes with and without the Dwayne follow-up gate so only the gated path plays `0x49c1` before the shared `0x49ea -> 0x49ef` ending.
+- Run an in-engine Dwayne validation pass against the fresh ScummVM reimplementation: the first normal intro in the sheriff's office and a later revisit to confirm `0x3245..0x3252` no longer falls through into same-visit `0x34d3`; the sheriff-in-diner intro and revisit to confirm `0x3482..0x349b` plus optional `0x34a0/0x34a4` and that the diner revisit only plays `0x34ac`; note/checkbook photocopy -> original note/checkbook plus `TV_DEED` / `TV_DEED_PHOTOCOPY` to verify the shared `0x355f` / `0x3565..0x3573` followup no longer arms directly from Dwayne's own evidence-item branches; `KARIN_FOUND_DEAD` without the purse through `0x7f` response `1` to confirm `0x379d` now returns immediately while response `2` still continues into `0x80`; and the arrest path after earning the GOOJF card, with and without `PC_KILLED_ANYONE`, to confirm the card is always cleared from `RAH` after `0x3814`.
 
 ## Last Confirmed Action
 
+- On March 24, 2026, reimplemented the ScummVM Dwayne dialogue handler directly against the saved live `HARVEST.LE` body at `handle_talk_to_dwayne` (`0x3a310..0x3c385`) as a fresh pass rather than trusting the older port. The recovered native body confirmed four source drifts in [`engines/harvester/npc/dwayne_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dwayne_dialogue.cpp): Dwayne's note/checkbook and TV-deed item branches do not write the shared `get_set_discussed_note_checkbook_evidence` or `get_set_discussed_tv_deed_evidence` accessors, so the port now stops pre-arming `sharedState.discussedNoteCheckbookEvidence` and `sharedState.discussedTvDeedEvidence` from those item presentations; the normal `0x3245..0x3252` introduction, the sheriff-in-diner intro `0x3482..0x349b`, and the diner revisit `0x34ac` all return to the shared followup ladder without same-visit `0x34d3`, so the C++ control flow now suppresses that erroneous fallthrough; the `KARIN_FOUND_DEAD` no-purse response-1 path plays `0x379d` and returns immediately instead of continuing into the later absent-NPC/topic sweep; and the arrest cleanup always hides `GOOJF_CARD` in `RAH`, while the completed Karin-alive followup bit is only raised after the successful `0x3780/0x3786/0x378a` sequence. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Direct Dwayne callees and the additional helper at `0x38240` (`get_set_boyle_gascan_application_state`) were re-audited in Ghidra during the pass, but none remained as `FUN_*` or met the rename bar for a new symbol change. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
+- On March 24, 2026, treated `DWAYNE` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify DWAYNE` reported `handle_talk_to_dwayne @ 0x3a310` in the stale short-body state (`0x3a310..0x3c385`, `3375` address-count) with only `59` reachable dialogue calls and `59` stale `CALL_RETURN` gaps. Running `repair DWAYNE` rebuilt the handler in two passes to the full saved body `0x3a310..0x3c385` (`8219` address-count) with `186` reachable dialogue calls, `gapCount=0`, and `118` repair actions. After a settle and explicit `save_program`, a fresh `verify DWAYNE` still reported `status=clean` with both dialogue entry points returning, so Dwayne is durably clean again in the saved database on the first fresh cycle. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Stephanie dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_stephanie` (`0x36710..0x37f5d`) as a fresh pass rather than reusing the prior port. The recovered native body confirmed three engine mismatches in [`engines/harvester/npc/stephanie_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/stephanie_dialogue.cpp): the note/checkbook, casket-photo, and dual-ledger item branches write shared dialogue state through `get_set_discussed_note_checkbook_evidence`, `get_set_discussed_casket_photo_evidence`, and `get_set_discussed_ledger_evidence`, so the port now mirrors that by writing `sharedState.discussedNoteCheckbookEvidence`, `sharedState.discussedCasketPhotoEvidence`, and `sharedState.discussedLedgerEvidence` alongside the existing startup-script flags; the native one-shot ladder for `SCRATCHED_TUCKER`, `BOLT_OF_CLOTH_TAKEN`, `BARBER_POLE_STOLEN`, and `DINER_BURNED` falls through into later event checks instead of returning early, so the early C++ returns were removed to match the live control flow; and the keyword branches for `dialog.rsp[0x2e5/0x2e6]` and `dialog.rsp[0x2ed]` continue through the shared `0x4441` and `0x44a2..0x44af` tails with topic-buffer rewrites instead of ending immediately, while Stephanie's `dialog.rsp[0x2f3]` lodge topic only reads the shared lodge-discussion state and does not set it locally. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Direct Stephanie callees were re-audited in Ghidra (`play_c022a_fst`, `play_c022b_fst`, `play_c023a_fst`, `play_c025a_fst`, `play_c025b_fst`, `play_c007_fst`, and the shared-state accessors), but none remained as `FUN_*` or met the rename bar for a new symbol change. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, treated `STEPHANIE` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify STEPHANIE` reported `handle_talk_to_stephanie @ 0x36710` as the short `0x36710..0x36cd8` body (`526` address-count) with `9` reachable dialogue calls and `9` stale `CALL_RETURN` gaps. Running `repair STEPHANIE` rebuilt the handler in two passes to `0x36710..0x37f5d` (`6166` address-count, `195` dialogue calls, `gapCount=0`, `29` repair actions); after a settle and explicit `save_program`, a fresh `verify STEPHANIE` still reported `status=clean` with both dialogue entry points returning. A targeted raw-byte audit of the recovered body then found two still-misdecoded in-body call sites at `0x3765c` and `0x3778d`; clearing and re-disassembling those spans restored the missing `CALL 0x7a690` instructions for the optional `0x4970` spyhole line and the shared `0x49ba` Karin-alive call site. Rechecking the repaired bytes confirmed that the live native handler still matches the current ScummVM Stephanie flow in [`engines/harvester/npc/stephanie_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/stephanie_dialogue.cpp), including the `0x45d8/0x45e2` first-followup tail, `0x4709` portrait variant, the `0x4984/0x4989/0x498f` Mrs. Potts alibi followup, and the gated `0x49c1 -> 0x49ea -> 0x49ef` Karin-alive tail, so no engine source patch was needed. Live progress is now `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Whaley dialogue handler directly against the saved live `HARVEST.LE` body at `handle_talk_to_whaley` (`0x23ec0..0x249ca`). The earlier port in [`engines/harvester/npc/whaley_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/whaley_dialogue.cpp) already matched most of the recovered control flow, but three native mismatches remained: the casket-photo, dual-ledger, and note/checkbook item branches only wrote startup-script flags instead of the native shared `get_set_discussed_*` evidence state; the later `0x14dd[2]` Whaley/Herrill-photo follow-up was gated on the startup flag instead of shared `get_set_discussed_whaley_herrill_photo`; and the `KARIN_KIDNAPED` no-item branch collapsed `0x13e5..0x13ff` into one sequence instead of setting the shared kidnapped-dialogue state between `0x13e5` and `0x13ee`. Patched the handler to mirror those native state transitions, renamed the directly encountered cutscene helper at `0x38930` from `FUN_00038930` to `play_c001b_fst_and_mark_steph_midgame_played`, fixed its prototype to `void play_c001b_fst_and_mark_steph_midgame_played(void)`, updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) with the confirmed helper semantics, saved `HARVEST.LE`, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Live progress is now `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
diff --git a/engines/harvester/npc/dwayne_dialogue.cpp b/engines/harvester/npc/dwayne_dialogue.cpp
index 366fc9f9168..662210489bd 100644
--- a/engines/harvester/npc/dwayne_dialogue.cpp
+++ b/engines/harvester/npc/dwayne_dialogue.cpp
@@ -99,8 +99,7 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto clearGoojfCard = [&]() {
 		(void)startupScript.setRuntimeFlagValue("PC_HAS_GOOJF_CARD", false);
-		if (startupScript.isObjectInInventory("GOOJF_CARD"))
-			(void)startupScript.setRuntimeObjectVisible(kRahRoomName, "GOOJF_CARD", false);
+		(void)startupScript.setRuntimeObjectVisible(kRahRoomName, "GOOJF_CARD", false);
 	};
 	const bool sheriffInDiner = startupScript.getFlagValue("SHERIFF_IN_DINER");
 	const int currentStoryDayIndex = startupScript.getCurrentStoryDayIndex();
@@ -265,7 +264,6 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 		if (usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY") ||
 				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY")) {
-			sharedState.discussedNoteCheckbookEvidence = 1;
 			(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
 			Common::Error lineError = playDwayneLine(0x33b9, 2);
 			if (lineError.getCode() != Common::kNoError)
@@ -309,7 +307,6 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		}
 		if (usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 				usedItemName.equalsIgnoreCase("NOTE")) {
-			sharedState.discussedNoteCheckbookEvidence = 1;
 			(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
 			if (state.presentedEvidenceReplyOverride) {
 				const DialogueLineEntry introLines[] = {
@@ -461,7 +458,6 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return Common::kNoError;
 		}
 		if (state.tvDeedReplyOverride && usedItemName.equalsIgnoreCase("TV_DEED")) {
-			sharedState.discussedTvDeedEvidence = 1;
 			(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownEvidenceSheriffOwns, true);
 
 			const DialogueLineEntry introLines[] = {
@@ -499,7 +495,6 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return playDwayneLine(0x3935);
 		}
 
-		sharedState.discussedTvDeedEvidence = 1;
 		(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownEvidenceSheriffOwns, true);
 
 		Common::Error lineError = playDwayneLine(0x3608);
@@ -592,6 +587,7 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return Common::kNoError;
 	}
 
+	bool handledIntroOrRevisit = false;
 	if (state.sheriffInDinerIntroPending && sheriffInDiner) {
 		state.sheriffInDinerIntroPending = false;
 		state.sheriffInDinerIntroPlayed = true;
@@ -624,11 +620,13 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
+		handledIntroOrRevisit = true;
 	}
 	else if (state.sheriffInDinerIntroPlayed && sheriffInDiner) {
 		Common::Error lineError = playDwayneLine(0x34ac);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+		handledIntroOrRevisit = true;
 	} else if (state.pendingInitialConversation && !sheriffInDiner) {
 		state.pendingInitialConversation = false;
 		state.eventFollowupGate = true;
@@ -646,9 +644,10 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		assignDwayneTopicBuffer(0x78);
 		if (sharedState.boyleGascanApplicationState)
 			(void)startupScript.setRuntimeFlagValue("MOVE_SHERIFF", true);
+		handledIntroOrRevisit = true;
 	}
 
-	if (state.eventFollowupGate) {
+	if (!handledIntroOrRevisit && state.eventFollowupGate) {
 		Common::Error lineError = playDwayneLine(0x34d3, 2);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
@@ -816,8 +815,6 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		state.pendingKarinAliveFollowupLinePlayed = true;
 		state.pendingKarinAliveFollowup = false;
 		sharedState.dwaynePendingKarinAliveFollowupState = 0;
-		state.completedKarinAliveFollowup = true;
-		sharedState.dwayneCompletedKarinAliveFollowup = true;
 
 		const DialogueLineEntry lines[] = {
 			{ 0x3780, kDwayneNpc, 0 },
@@ -827,6 +824,8 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		Common::Error lineError = playSequence(lines, ARRAYSIZE(lines));
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
+		state.completedKarinAliveFollowup = true;
+		sharedState.dwayneCompletedKarinAliveFollowup = true;
 	}
 
 	if (startupScript.getFlagValue("KARIN_FOUND_DEAD") &&
@@ -844,9 +843,7 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (responseError.getCode() != Common::kNoError)
 			return responseError;
 		if (responseIndex == 1) {
-			lineError = playDwayneLine(0x379d);
-			if (lineError.getCode() != Common::kNoError)
-				return lineError;
+			return playDwayneLine(0x379d);
 		} else if (responseIndex == 2) {
 			lineError = playDwayneLine(0x37a4, 2);
 			if (lineError.getCode() != Common::kNoError)


Commit: b793ddd343e9c679977e7505101b0f8684957b41
    https://github.com/scummvm/scummvm/commit/b793ddd343e9c679977e7505101b0f8684957b41
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:10+02:00

Commit Message:
HARVESTER: Reimplement Hank dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/hank_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 8efd4baca10..73737647d88 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,10 +10,12 @@
 
 ## Next Suggested Action
 
-- Run an in-engine Dwayne validation pass against the fresh ScummVM reimplementation: the first normal intro in the sheriff's office and a later revisit to confirm `0x3245..0x3252` no longer falls through into same-visit `0x34d3`; the sheriff-in-diner intro and revisit to confirm `0x3482..0x349b` plus optional `0x34a0/0x34a4` and that the diner revisit only plays `0x34ac`; note/checkbook photocopy -> original note/checkbook plus `TV_DEED` / `TV_DEED_PHOTOCOPY` to verify the shared `0x355f` / `0x3565..0x3573` followup no longer arms directly from Dwayne's own evidence-item branches; `KARIN_FOUND_DEAD` without the purse through `0x7f` response `1` to confirm `0x379d` now returns immediately while response `2` still continues into `0x80`; and the arrest path after earning the GOOJF card, with and without `PC_KILLED_ANYONE`, to confirm the card is always cleared from `RAH` after `0x3814`.
+- Run an in-engine Hank validation pass against the fresh ScummVM reimplementation: first talk through response menu `0xc7`, then a same-day revisit and a later-day revisit before and after day 6 to confirm the restored `RANGSHOT.FST` followup paths versus the late `0x8e2` bark; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` and `PHOTO_OF_WHALEY_HERRILL` to verify the shared corpse-photo and Whaley/Herrill-photo state now propagates alongside the existing item rewards; and an unknown typed keyword after the topic loop is seeded to confirm `0xa32` now returns to the menu instead of incorrectly forcing the `0x8dc` goodbye line.
 
 ## Last Confirmed Action
 
+- On March 24, 2026, reimplemented the ScummVM Hank dialogue handler directly against the saved live `HARVEST.LE` body at `handle_talk_to_hank` (`0x33a30..0x34bcb`) as a fresh pass rather than trusting the older port. The recovered native body confirmed three source drifts in [`engines/harvester/npc/hank_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/hank_dialogue.cpp): the same-day and later-day branch driven by `g_hank_rangshot_branch_pending` / `g_hank_rangshot_branch_active` plays `RANGSHOT.FST` via `play_rangshot_fst_sequence`, not `C021.FST`, so the port now uses `GRAPHIC/FST/RANGSHOT.FST` for those two followup paths while keeping `C021.FST` only on the separate TV topic; Hank's `CASKET_PHOTO` / `CASKET_PHOTOCOPY` and `PHOTO_OF_WHALEY_HERRILL` item branches call `get_set_discussed_casket_photo_evidence` and `get_set_discussed_whaley_herrill_photo`, so the port now mirrors that by writing `sharedState.discussedCasketPhotoEvidence` and `sharedState.discussedWhaleyHerrillPhoto` alongside the existing startup-script flags; and the native unknown-keyword fallback plays `0xa32` then loops back to the keyword menu instead of forcing the `0x8dc` goodbye line, so the port now continues after `0xa32` until the actual `0xf2` exit topic is chosen. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Hank's direct Ghidra callees were re-audited during the pass, including `play_c021_fst_sequence` and `play_rangshot_fst_sequence`, but none remained as `FUN_*` or met the rename bar for a new symbol change. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
+- On March 24, 2026, treated `HANK` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify HANK` reported `handle_talk_to_hank @ 0x33a30` in the stale short-body state (`0x33a30..0x34bc2`, `1542` address-count) with only `28` reachable dialogue calls and `28` stale `CALL_RETURN` gaps. Running `repair HANK` rebuilt the handler in two passes to the full saved body `0x33a30..0x34bcb` (`4508` address-count) with `131` reachable dialogue calls, `gapCount=0`, and `58` repair actions. After a settle and explicit `save_program`, a fresh `verify HANK` still reported `status=clean` with both dialogue entry points returning, so Hank is durably clean again in the saved database on the first fresh cycle. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Dwayne dialogue handler directly against the saved live `HARVEST.LE` body at `handle_talk_to_dwayne` (`0x3a310..0x3c385`) as a fresh pass rather than trusting the older port. The recovered native body confirmed four source drifts in [`engines/harvester/npc/dwayne_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dwayne_dialogue.cpp): Dwayne's note/checkbook and TV-deed item branches do not write the shared `get_set_discussed_note_checkbook_evidence` or `get_set_discussed_tv_deed_evidence` accessors, so the port now stops pre-arming `sharedState.discussedNoteCheckbookEvidence` and `sharedState.discussedTvDeedEvidence` from those item presentations; the normal `0x3245..0x3252` introduction, the sheriff-in-diner intro `0x3482..0x349b`, and the diner revisit `0x34ac` all return to the shared followup ladder without same-visit `0x34d3`, so the C++ control flow now suppresses that erroneous fallthrough; the `KARIN_FOUND_DEAD` no-purse response-1 path plays `0x379d` and returns immediately instead of continuing into the later absent-NPC/topic sweep; and the arrest cleanup always hides `GOOJF_CARD` in `RAH`, while the completed Karin-alive followup bit is only raised after the successful `0x3780/0x3786/0x378a` sequence. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Direct Dwayne callees and the additional helper at `0x38240` (`get_set_boyle_gascan_application_state`) were re-audited in Ghidra during the pass, but none remained as `FUN_*` or met the rename bar for a new symbol change. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, treated `DWAYNE` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify DWAYNE` reported `handle_talk_to_dwayne @ 0x3a310` in the stale short-body state (`0x3a310..0x3c385`, `3375` address-count) with only `59` reachable dialogue calls and `59` stale `CALL_RETURN` gaps. Running `repair DWAYNE` rebuilt the handler in two passes to the full saved body `0x3a310..0x3c385` (`8219` address-count) with `186` reachable dialogue calls, `gapCount=0`, and `118` repair actions. After a settle and explicit `save_program`, a fresh `verify DWAYNE` still reported `status=clean` with both dialogue entry points returning, so Dwayne is durably clean again in the saved database on the first fresh cycle. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Stephanie dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_stephanie` (`0x36710..0x37f5d`) as a fresh pass rather than reusing the prior port. The recovered native body confirmed three engine mismatches in [`engines/harvester/npc/stephanie_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/stephanie_dialogue.cpp): the note/checkbook, casket-photo, and dual-ledger item branches write shared dialogue state through `get_set_discussed_note_checkbook_evidence`, `get_set_discussed_casket_photo_evidence`, and `get_set_discussed_ledger_evidence`, so the port now mirrors that by writing `sharedState.discussedNoteCheckbookEvidence`, `sharedState.discussedCasketPhotoEvidence`, and `sharedState.discussedLedgerEvidence` alongside the existing startup-script flags; the native one-shot ladder for `SCRATCHED_TUCKER`, `BOLT_OF_CLOTH_TAKEN`, `BARBER_POLE_STOLEN`, and `DINER_BURNED` falls through into later event checks instead of returning early, so the early C++ returns were removed to match the live control flow; and the keyword branches for `dialog.rsp[0x2e5/0x2e6]` and `dialog.rsp[0x2ed]` continue through the shared `0x4441` and `0x44a2..0x44af` tails with topic-buffer rewrites instead of ending immediately, while Stephanie's `dialog.rsp[0x2f3]` lodge topic only reads the shared lodge-discussion state and does not set it locally. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Direct Stephanie callees were re-audited in Ghidra (`play_c022a_fst`, `play_c022b_fst`, `play_c023a_fst`, `play_c025a_fst`, `play_c025b_fst`, `play_c007_fst`, and the shared-state accessors), but none remained as `FUN_*` or met the rename bar for a new symbol change. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
diff --git a/engines/harvester/npc/hank_dialogue.cpp b/engines/harvester/npc/hank_dialogue.cpp
index 8c8a9a7120a..45ad80694ad 100644
--- a/engines/harvester/npc/hank_dialogue.cpp
+++ b/engines/harvester/npc/hank_dialogue.cpp
@@ -31,6 +31,7 @@ namespace Harvester {
 namespace {
 
 static const char *const kDialogueC021FstPath = "GRAPHIC/FST/C021.FST";
+static const char *const kDialogueRangshotFstPath = "GRAPHIC/FST/RANGSHOT.FST";
 static const char *const kInventoryOwnerName = "INVENTORY";
 
 static const DialogueLineEntry kHankMomTopicLines[] = {
@@ -206,6 +207,7 @@ Common::Error HankDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			sharedState.discussedCasketPhotoEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
 			Common::Error lineError = runtime.playDialogueLine(0xa3e, "HANK");
 			if (lineError.getCode() != Common::kNoError)
@@ -225,6 +227,7 @@ Common::Error HankDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return Common::kNoError;
 		}
 		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			sharedState.discussedWhaleyHerrillPhoto = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
 			Common::Error lineError = runtime.playDialogueLine(0xa53, "HANK");
 			if (lineError.getCode() != Common::kNoError)
@@ -318,7 +321,7 @@ Common::Error HankDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		} else if (state.pendingRangshotSequence) {
 			Common::Error lineError = currentStoryDayIndex > 5
 				? runtime.playDialogueLine(0x8e2, "HANK")
-				: runtime.playDialogueFst(kDialogueC021FstPath);
+				: runtime.playDialogueFst(kDialogueRangshotFstPath);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 		}
@@ -326,7 +329,7 @@ Common::Error HankDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		state.pendingSameDayFollowup = true;
 		Common::Error lineError = currentStoryDayIndex > 5
 			? runtime.playDialogueLine(0x8e2, "HANK")
-			: runtime.playDialogueFst(kDialogueC021FstPath);
+			: runtime.playDialogueFst(kDialogueRangshotFstPath);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 	}
@@ -689,7 +692,7 @@ Common::Error HankDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		Common::Error lineError = playLine(0xa32);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		return playLine(0x8dc);
+		continue;
 	}
 }
 


Commit: 5f360916c6a0c8f2fbb0872214213a16d2b9395f
    https://github.com/scummvm/scummvm/commit/5f360916c6a0c8f2fbb0872214213a16d2b9395f
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:11+02:00

Commit Message:
HARVESTER: Reimplement Jimmy dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/jimmy_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 73737647d88..28e8a5f054b 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,10 +10,12 @@
 
 ## Next Suggested Action
 
-- Run an in-engine Hank validation pass against the fresh ScummVM reimplementation: first talk through response menu `0xc7`, then a same-day revisit and a later-day revisit before and after day 6 to confirm the restored `RANGSHOT.FST` followup paths versus the late `0x8e2` bark; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` and `PHOTO_OF_WHALEY_HERRILL` to verify the shared corpse-photo and Whaley/Herrill-photo state now propagates alongside the existing item rewards; and an unknown typed keyword after the topic loop is seeded to confirm `0xa32` now returns to the menu instead of incorrectly forcing the `0x8dc` goodbye line.
+- Run an in-engine Jimmy validation pass against the fresh ScummVM reimplementation: the first no-item visit through response menus `0xf5`, `0xf6`, and `0xf7`; the later no-item `SNEAKERS` revisit through response menu `0xf8`; first and repeat `NEWSPAPER` handoffs to confirm `0x4a4c` / `0x4a52` plus the `0x4acc` / `0x4a4b` bark ladder still work with the paper being returned to `RAH` before `ACTV_HOUSE_EXIT`; direct `SNEAKERS` use to confirm `BROOMKEY` is still awarded; and the `PHOTO_OF_WHALEY_HERRILL`, `LEDGER` / corpse-photo, and note/checkbook evidence exchanges to verify the restored shared `discussedWhaleyHerrillPhoto`, `discussedLedgerEvidence` / `discussedCasketPhotoEvidence`, and `discussedNoteCheckbookEvidence` state now propagates correctly.
 
 ## Last Confirmed Action
 
+- On March 24, 2026, reimplemented the ScummVM Jimmy dialogue handler directly against the saved live `HARVEST.LE` body at `handle_talk_to_jimmy` (`0x28880..0x28ec1`) as a fresh pass rather than trusting the older port. The recovered native body confirmed two source drifts in [`engines/harvester/npc/jimmy_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/jimmy_dialogue.cpp): Jimmy's `PHOTO_OF_WHALEY_HERRILL`, both-ledger / corpse-photo, and note/checkbook item branches call `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_ledger_evidence` / `get_set_discussed_casket_photo_evidence`, and `get_set_discussed_note_checkbook_evidence`, so the port now mirrors that by writing `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedLedgerEvidence` / `sharedState.discussedCasketPhotoEvidence`, and `sharedState.discussedNoteCheckbookEvidence` instead of the non-native startup-script evidence flags; and the native `NEWSPAPER` handoff restores the paper to `RAH` before dispatching `ACTV_HOUSE_EXIT`, so the port now returns `NEWSPAPER` to room state before running the action tag. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Jimmy's direct Ghidra callees were re-audited during the pass, but none remained as `FUN_*` or met the rename bar for a new symbol change. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
+- On March 24, 2026, treated `JIMMY` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify JIMMY` reported `handle_talk_to_jimmy @ 0x28880` in the stale short-body state (`0x28880..0x28eb9`, `807` address-count) with only `13` reachable dialogue calls and `13` stale `CALL_RETURN` gaps. Running `repair JIMMY` rebuilt the handler in one pass to the full saved body `0x28880..0x28ec1` (`1602` address-count) with `39` reachable dialogue calls, `gapCount=0`, and `27` repair actions. After a settle and explicit `save_program`, a fresh `verify JIMMY` still reported `status=clean` with both dialogue entry points returning, so Jimmy is durably clean again in the saved database on the first fresh cycle. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Hank dialogue handler directly against the saved live `HARVEST.LE` body at `handle_talk_to_hank` (`0x33a30..0x34bcb`) as a fresh pass rather than trusting the older port. The recovered native body confirmed three source drifts in [`engines/harvester/npc/hank_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/hank_dialogue.cpp): the same-day and later-day branch driven by `g_hank_rangshot_branch_pending` / `g_hank_rangshot_branch_active` plays `RANGSHOT.FST` via `play_rangshot_fst_sequence`, not `C021.FST`, so the port now uses `GRAPHIC/FST/RANGSHOT.FST` for those two followup paths while keeping `C021.FST` only on the separate TV topic; Hank's `CASKET_PHOTO` / `CASKET_PHOTOCOPY` and `PHOTO_OF_WHALEY_HERRILL` item branches call `get_set_discussed_casket_photo_evidence` and `get_set_discussed_whaley_herrill_photo`, so the port now mirrors that by writing `sharedState.discussedCasketPhotoEvidence` and `sharedState.discussedWhaleyHerrillPhoto` alongside the existing startup-script flags; and the native unknown-keyword fallback plays `0xa32` then loops back to the keyword menu instead of forcing the `0x8dc` goodbye line, so the port now continues after `0xa32` until the actual `0xf2` exit topic is chosen. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Hank's direct Ghidra callees were re-audited during the pass, including `play_c021_fst_sequence` and `play_rangshot_fst_sequence`, but none remained as `FUN_*` or met the rename bar for a new symbol change. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, treated `HANK` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify HANK` reported `handle_talk_to_hank @ 0x33a30` in the stale short-body state (`0x33a30..0x34bc2`, `1542` address-count) with only `28` reachable dialogue calls and `28` stale `CALL_RETURN` gaps. Running `repair HANK` rebuilt the handler in two passes to the full saved body `0x33a30..0x34bcb` (`4508` address-count) with `131` reachable dialogue calls, `gapCount=0`, and `58` repair actions. After a settle and explicit `save_program`, a fresh `verify HANK` still reported `status=clean` with both dialogue entry points returning, so Hank is durably clean again in the saved database on the first fresh cycle. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Dwayne dialogue handler directly against the saved live `HARVEST.LE` body at `handle_talk_to_dwayne` (`0x3a310..0x3c385`) as a fresh pass rather than trusting the older port. The recovered native body confirmed four source drifts in [`engines/harvester/npc/dwayne_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dwayne_dialogue.cpp): Dwayne's note/checkbook and TV-deed item branches do not write the shared `get_set_discussed_note_checkbook_evidence` or `get_set_discussed_tv_deed_evidence` accessors, so the port now stops pre-arming `sharedState.discussedNoteCheckbookEvidence` and `sharedState.discussedTvDeedEvidence` from those item presentations; the normal `0x3245..0x3252` introduction, the sheriff-in-diner intro `0x3482..0x349b`, and the diner revisit `0x34ac` all return to the shared followup ladder without same-visit `0x34d3`, so the C++ control flow now suppresses that erroneous fallthrough; the `KARIN_FOUND_DEAD` no-purse response-1 path plays `0x379d` and returns immediately instead of continuing into the later absent-NPC/topic sweep; and the arrest cleanup always hides `GOOJF_CARD` in `RAH`, while the completed Karin-alive followup bit is only raised after the successful `0x3780/0x3786/0x378a` sequence. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Direct Dwayne callees and the additional helper at `0x38240` (`get_set_boyle_gascan_application_state`) were re-audited in Ghidra during the pass, but none remained as `FUN_*` or met the rename bar for a new symbol change. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
diff --git a/engines/harvester/npc/jimmy_dialogue.cpp b/engines/harvester/npc/jimmy_dialogue.cpp
index 6c6a583eb46..56c593c6bf7 100644
--- a/engines/harvester/npc/jimmy_dialogue.cpp
+++ b/engines/harvester/npc/jimmy_dialogue.cpp
@@ -24,7 +24,6 @@
 #include "harvester/npc/jimmy_dialogue.h"
 
 #include "common/array.h"
-#include "harvester/npc/dialogue_flags.h"
 #include "harvester/npc/dialogue_runtime.h"
 
 namespace Harvester {
@@ -89,7 +88,7 @@ bool JimmyDialogueHandler::matchesNpc(const Common::String &npcName) const {
 }
 
 Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
-		const Common::String &usedItemName, DialogueSharedState &) {
+		const Common::String &usedItemName, DialogueSharedState &sharedState) {
 	JimmyRoomDialogueState &state = _state;
 	Common::Error lineError = Common::kNoError;
 
@@ -199,8 +198,8 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("NEWSPAPER")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(kGivenPaperTodayFlag, true);
-			executeActionTagIfSet("ACTV_HOUSE_EXIT");
 			restoreItemToRah("NEWSPAPER");
+			executeActionTagIfSet("ACTV_HOUSE_EXIT");
 
 			lineError = playPaperHandoffPreludeIfNeeded();
 			if (lineError.getCode() != Common::kNoError)
@@ -219,7 +218,7 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return Common::kNoError;
 		}
 		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			sharedState.discussedWhaleyHerrillPhoto = 1;
 			return playJimmyLine(0x4af8, 1);
 		}
 		if (((usedItemName.equalsIgnoreCase("LEDGER") ||
@@ -229,9 +228,9 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
 			if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 					usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
-				(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+				sharedState.discussedCasketPhotoEvidence = 1;
 			} else {
-				(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+				sharedState.discussedLedgerEvidence = 1;
 			}
 			return playJimmySequence(kJimmyLedgersAndCorpseEvidenceLines,
 				ARRAYSIZE(kJimmyLedgersAndCorpseEvidenceLines));
@@ -240,7 +239,7 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
-			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+			sharedState.discussedNoteCheckbookEvidence = 1;
 			return playJimmySequence(kJimmyBlackmailEvidenceLines,
 				ARRAYSIZE(kJimmyBlackmailEvidenceLines));
 		}


Commit: 7f1c61e2cfe65e55eba6bb38d51c68336308bb64
    https://github.com/scummvm/scummvm/commit/7f1c61e2cfe65e55eba6bb38d51c68336308bb64
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:11+02:00

Commit Message:
HARVESTER: Reimplement Mom dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/mom_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 28e8a5f054b..1d1c1a66e18 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,10 +10,12 @@
 
 ## Next Suggested Action
 
-- Run an in-engine Jimmy validation pass against the fresh ScummVM reimplementation: the first no-item visit through response menus `0xf5`, `0xf6`, and `0xf7`; the later no-item `SNEAKERS` revisit through response menu `0xf8`; first and repeat `NEWSPAPER` handoffs to confirm `0x4a4c` / `0x4a52` plus the `0x4acc` / `0x4a4b` bark ladder still work with the paper being returned to `RAH` before `ACTV_HOUSE_EXIT`; direct `SNEAKERS` use to confirm `BROOMKEY` is still awarded; and the `PHOTO_OF_WHALEY_HERRILL`, `LEDGER` / corpse-photo, and note/checkbook evidence exchanges to verify the restored shared `discussedWhaleyHerrillPhoto`, `discussedLedgerEvidence` / `discussedCasketPhotoEvidence`, and `discussedNoteCheckbookEvidence` state now propagates correctly.
+- Run an in-engine Mom validation pass against the repaired native handler: present `CASKET_PHOTO`, `PHOTO_OF_WHALEY_HERRILL`, both-ledger / corpse-photo evidence, and note/checkbook evidence to confirm the item branches still match the live `handle_talk_to_mom` flow; then cover `JIMMY_ATTACKED` to verify the `PC 0x265f[2]` reply, `POTTSDAM` response `2` to verify `PC 0x20e3`, `MEAT PLANT` response `1` to verify `0x2106 -> PC 0x210a -> 0x210e`, `FATHER` to confirm the native `momFatherTopicState` latch, and an unrelated typed topic after `0x26c6[1]` to confirm the fallback loops back into the keyword menu instead of forcing goodbye.
 
 ## Last Confirmed Action
 
+- On March 24, 2026, reimplemented the ScummVM Mom dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_mom` (`0x31140..0x33523`) as a fresh pass rather than trusting the older port. The native item-use branches proved one remaining source drift in [`engines/harvester/npc/mom_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mom_dialogue.cpp): Mom's note/checkbook, both-ledger, casket-photo, Whaley/Herrill-photo, and TV-deed evidence branches call the shared `get_set_discussed_note_checkbook_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_whaley_herrill_photo`, and `get_set_discussed_tv_deed_evidence` accessors, so the port now mirrors that by writing `sharedState.discussedNoteCheckbookEvidence`, `sharedState.discussedLedgerEvidence`, `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedWhaleyHerrillPhoto`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script flags. Re-auditing the previously suspicious `JIMMY_ATTACKED`, `POTTSDAM`, `MEAT PLANT`, lodge-topic, and `0x26c6` fallback paths confirmed the current C++ control flow already matches the saved native handler there, so no further source drift was found. During the same Ghidra pass I renamed `FUN_00038270` to `get_set_dialogue_state_d2ea4` and `FUN_00038900` to `play_c010_fst`, saved `HARVEST.LE`, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
+- On March 24, 2026, treated `MOM` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify MOM` reported `handle_talk_to_mom @ 0x31140` in the stale short-body state (`0x31140..0x33523`, `3309` address-count) with only `58` reachable dialogue calls, `58` stale `CALL_RETURN` gaps, and both dialogue entry points flipped back to non-returning. The first `repair MOM` rebuilt the handler cleanly in memory to `0x31140..0x33523` (`8778` address-count, `251` reachable dialogue calls, `gapCount=0`, `116` repair actions), but the first saved `verify MOM` snapped straight back to the stale short body. An immediate second full `repair -> wait -> save_program -> verify` cycle rebuilt the same full body again (`117` repair actions on the retry) and this time the saved verify held with `play_dialogue_line` and `thunk_play_dialogue_line` both returning plus `status=clean`, so Mom is durably clean again in the current saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Jimmy dialogue handler directly against the saved live `HARVEST.LE` body at `handle_talk_to_jimmy` (`0x28880..0x28ec1`) as a fresh pass rather than trusting the older port. The recovered native body confirmed two source drifts in [`engines/harvester/npc/jimmy_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/jimmy_dialogue.cpp): Jimmy's `PHOTO_OF_WHALEY_HERRILL`, both-ledger / corpse-photo, and note/checkbook item branches call `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_ledger_evidence` / `get_set_discussed_casket_photo_evidence`, and `get_set_discussed_note_checkbook_evidence`, so the port now mirrors that by writing `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedLedgerEvidence` / `sharedState.discussedCasketPhotoEvidence`, and `sharedState.discussedNoteCheckbookEvidence` instead of the non-native startup-script evidence flags; and the native `NEWSPAPER` handoff restores the paper to `RAH` before dispatching `ACTV_HOUSE_EXIT`, so the port now returns `NEWSPAPER` to room state before running the action tag. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Jimmy's direct Ghidra callees were re-audited during the pass, but none remained as `FUN_*` or met the rename bar for a new symbol change. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, treated `JIMMY` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify JIMMY` reported `handle_talk_to_jimmy @ 0x28880` in the stale short-body state (`0x28880..0x28eb9`, `807` address-count) with only `13` reachable dialogue calls and `13` stale `CALL_RETURN` gaps. Running `repair JIMMY` rebuilt the handler in one pass to the full saved body `0x28880..0x28ec1` (`1602` address-count) with `39` reachable dialogue calls, `gapCount=0`, and `27` repair actions. After a settle and explicit `save_program`, a fresh `verify JIMMY` still reported `status=clean` with both dialogue entry points returning, so Jimmy is durably clean again in the saved database on the first fresh cycle. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Hank dialogue handler directly against the saved live `HARVEST.LE` body at `handle_talk_to_hank` (`0x33a30..0x34bcb`) as a fresh pass rather than trusting the older port. The recovered native body confirmed three source drifts in [`engines/harvester/npc/hank_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/hank_dialogue.cpp): the same-day and later-day branch driven by `g_hank_rangshot_branch_pending` / `g_hank_rangshot_branch_active` plays `RANGSHOT.FST` via `play_rangshot_fst_sequence`, not `C021.FST`, so the port now uses `GRAPHIC/FST/RANGSHOT.FST` for those two followup paths while keeping `C021.FST` only on the separate TV topic; Hank's `CASKET_PHOTO` / `CASKET_PHOTOCOPY` and `PHOTO_OF_WHALEY_HERRILL` item branches call `get_set_discussed_casket_photo_evidence` and `get_set_discussed_whaley_herrill_photo`, so the port now mirrors that by writing `sharedState.discussedCasketPhotoEvidence` and `sharedState.discussedWhaleyHerrillPhoto` alongside the existing startup-script flags; and the native unknown-keyword fallback plays `0xa32` then loops back to the keyword menu instead of forcing the `0x8dc` goodbye line, so the port now continues after `0xa32` until the actual `0xf2` exit topic is chosen. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Hank's direct Ghidra callees were re-audited during the pass, including `play_c021_fst_sequence` and `play_rangshot_fst_sequence`, but none remained as `FUN_*` or met the rename bar for a new symbol change. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
diff --git a/engines/harvester/npc/mom_dialogue.cpp b/engines/harvester/npc/mom_dialogue.cpp
index 6666630914b..03bde043270 100644
--- a/engines/harvester/npc/mom_dialogue.cpp
+++ b/engines/harvester/npc/mom_dialogue.cpp
@@ -523,26 +523,31 @@ Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			sharedState.discussedNoteCheckbookEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
 			return playMomLine(0x2317, 1);
 		}
 		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
 				usedItemName.equalsIgnoreCase("LEDGER2")) &&
 				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+			sharedState.discussedLedgerEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
 			return playSequence(kMomLedgerLines, ARRAYSIZE(kMomLedgerLines));
 		}
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			sharedState.discussedCasketPhotoEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
 			return playSequence(kMomCasketPhotoLines, ARRAYSIZE(kMomCasketPhotoLines));
 		}
 		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			sharedState.discussedWhaleyHerrillPhoto = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
 			return playSequence(kMomWhaleyPhotoLines, ARRAYSIZE(kMomWhaleyPhotoLines));
 		}
 		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
 				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
+			sharedState.discussedTvDeedEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceSheriffOwns, true);
 			return playMomLine(0x2382, 1);
 		}


Commit: 0e937ead30bc5c58fd7d4970c4e27d0523efcc25
    https://github.com/scummvm/scummvm/commit/0e937ead30bc5c58fd7d4970c4e27d0523efcc25
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:11+02:00

Commit Message:
HARVESTER: Reimplement Mr. Potts dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/mr_potts_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 1d1c1a66e18..cf2e782ae89 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,10 +10,12 @@
 
 ## Next Suggested Action
 
-- Run an in-engine Mom validation pass against the repaired native handler: present `CASKET_PHOTO`, `PHOTO_OF_WHALEY_HERRILL`, both-ledger / corpse-photo evidence, and note/checkbook evidence to confirm the item branches still match the live `handle_talk_to_mom` flow; then cover `JIMMY_ATTACKED` to verify the `PC 0x265f[2]` reply, `POTTSDAM` response `2` to verify `PC 0x20e3`, `MEAT PLANT` response `1` to verify `0x2106 -> PC 0x210a -> 0x210e`, `FATHER` to confirm the native `momFatherTopicState` latch, and an unrelated typed topic after `0x26c6[1]` to confirm the fallback loops back into the keyword menu instead of forcing goodbye.
+- Run an in-engine Mr. Potts validation pass against the repaired native handler: the `HARVEST_BLADE` response menu and `MONSTERFY_POTTS` tail; `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE` to confirm the full `C001B.FST` / `STHLEXIT2B` exit; first and repeat `POTTSDAM_DIGGING`; `POTTSDAM_DIGGING_KARIN`; `MEAT` through both `0x1d4` responses to confirm the reset path; `CASKET_PHOTO`, `PHOTO_OF_WHALEY_HERRILL`, note/checkbook, and TV-deed evidence to verify the restored multi-line exchanges; the later-day Stephanie-alive revisit on day 2 through day 5 to confirm `0x2ae5`; the `KARIN_KIDNAPED` response-2 branch to verify the `STEPH_MIDGAME_PLAYED` gate; the `KARIN_FOUND_DEAD` `0x2b8d..0x2bb6` follow-up; and the topic loop branches for `AMNESIA`, `FATHER`, `SLAUGHTERHOUSE`, `TOILET`, and `MOLEST` to confirm the recovered buffer rewrites and nested menus.
 
 ## Last Confirmed Action
 
+- On March 24, 2026, reimplemented the ScummVM Mr. Potts dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_mr_potts` (`0x26000..0x27d07`) as a fresh pass rather than trusting the older port. The fresh native audit found one remaining source drift in [`engines/harvester/npc/mr_potts_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mr_potts_dialogue.cpp): Mr. Potts' casket-photo, Whaley/Herrill-photo, note/checkbook, and TV-deed item branches call the shared `get_set_discussed_casket_photo_evidence`, `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_note_checkbook_evidence`, and `get_set_discussed_tv_deed_evidence` accessors, so the port now mirrors that by writing `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedNoteCheckbookEvidence`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script flags. Re-auditing the auxiliary intro/revisit day gating, `HARVEST_BLADE`, `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE`, `POTTSDAM_DIGGING`, `POTTSDAM_DIGGING_KARIN`, `KARIN_KIDNAPED`, `KARIN_FOUND_DEAD`, the accusation menus, and the late keyword loop confirmed the current C++ control flow already matches the saved native handler there, so no further source drift was found. The direct live callee set is now fully named (`play_c001b_fst_and_mark_steph_midgame_played`, `play_c016a_fst`, and the shared-state accessors), so no additional `FUN_*` rename was justified during this pass. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
+- On March 24, 2026, treated `MR_POTTS` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify MR_POTTS` reported `handle_talk_to_mr_potts @ 0x26000` in the stale short-body state (`0x26000..0x26915`, `823` address-count) with only `15` reachable dialogue calls and `15` stale `CALL_RETURN` gaps while both dialogue entry points already remained returning. Running `repair MR_POTTS` rebuilt the handler in three passes to the full saved body `0x26000..0x27d07` (`6853` address-count) with `152` reachable dialogue calls, `gapCount=0`, and `81` repair actions. After the normal settle and explicit `save_program`, a fresh `verify MR_POTTS` still reported `status=clean`, so Mr. Potts is durably clean again in the saved database on the first fresh cycle. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Mom dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_mom` (`0x31140..0x33523`) as a fresh pass rather than trusting the older port. The native item-use branches proved one remaining source drift in [`engines/harvester/npc/mom_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mom_dialogue.cpp): Mom's note/checkbook, both-ledger, casket-photo, Whaley/Herrill-photo, and TV-deed evidence branches call the shared `get_set_discussed_note_checkbook_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_whaley_herrill_photo`, and `get_set_discussed_tv_deed_evidence` accessors, so the port now mirrors that by writing `sharedState.discussedNoteCheckbookEvidence`, `sharedState.discussedLedgerEvidence`, `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedWhaleyHerrillPhoto`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script flags. Re-auditing the previously suspicious `JIMMY_ATTACKED`, `POTTSDAM`, `MEAT PLANT`, lodge-topic, and `0x26c6` fallback paths confirmed the current C++ control flow already matches the saved native handler there, so no further source drift was found. During the same Ghidra pass I renamed `FUN_00038270` to `get_set_dialogue_state_d2ea4` and `FUN_00038900` to `play_c010_fst`, saved `HARVEST.LE`, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, treated `MOM` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify MOM` reported `handle_talk_to_mom @ 0x31140` in the stale short-body state (`0x31140..0x33523`, `3309` address-count) with only `58` reachable dialogue calls, `58` stale `CALL_RETURN` gaps, and both dialogue entry points flipped back to non-returning. The first `repair MOM` rebuilt the handler cleanly in memory to `0x31140..0x33523` (`8778` address-count, `251` reachable dialogue calls, `gapCount=0`, `116` repair actions), but the first saved `verify MOM` snapped straight back to the stale short body. An immediate second full `repair -> wait -> save_program -> verify` cycle rebuilt the same full body again (`117` repair actions on the retry) and this time the saved verify held with `play_dialogue_line` and `thunk_play_dialogue_line` both returning plus `status=clean`, so Mom is durably clean again in the current saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Jimmy dialogue handler directly against the saved live `HARVEST.LE` body at `handle_talk_to_jimmy` (`0x28880..0x28ec1`) as a fresh pass rather than trusting the older port. The recovered native body confirmed two source drifts in [`engines/harvester/npc/jimmy_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/jimmy_dialogue.cpp): Jimmy's `PHOTO_OF_WHALEY_HERRILL`, both-ledger / corpse-photo, and note/checkbook item branches call `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_ledger_evidence` / `get_set_discussed_casket_photo_evidence`, and `get_set_discussed_note_checkbook_evidence`, so the port now mirrors that by writing `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedLedgerEvidence` / `sharedState.discussedCasketPhotoEvidence`, and `sharedState.discussedNoteCheckbookEvidence` instead of the non-native startup-script evidence flags; and the native `NEWSPAPER` handoff restores the paper to `RAH` before dispatching `ACTV_HOUSE_EXIT`, so the port now returns `NEWSPAPER` to room state before running the action tag. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Jimmy's direct Ghidra callees were re-audited during the pass, but none remained as `FUN_*` or met the rename bar for a new symbol change. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
diff --git a/engines/harvester/npc/mr_potts_dialogue.cpp b/engines/harvester/npc/mr_potts_dialogue.cpp
index a53af54e28c..c3215d67010 100644
--- a/engines/harvester/npc/mr_potts_dialogue.cpp
+++ b/engines/harvester/npc/mr_potts_dialogue.cpp
@@ -433,6 +433,7 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return Common::kNoError;
 		} else if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			sharedState.discussedCasketPhotoEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
 			lineError = playSequence(kMrPottsCasketPhotoLines, ARRAYSIZE(kMrPottsCasketPhotoLines));
 			if (lineError.getCode() != Common::kNoError)
@@ -442,6 +443,7 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 					kMrPottsCasketPhotoFollowupLines, ARRAYSIZE(kMrPottsCasketPhotoFollowupLines));
 			}
 		} else if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			sharedState.discussedWhaleyHerrillPhoto = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(
 				DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
 			lineError = playSequence(
@@ -468,12 +470,14 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			sharedState.discussedNoteCheckbookEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(
 				DialogueFlags::kShownEvidenceOfBlackmail, true);
 			lineError = playSequence(
 				kMrPottsBlackmailEvidenceLines, ARRAYSIZE(kMrPottsBlackmailEvidenceLines));
 		} else if (usedItemName.equalsIgnoreCase("TV_DEED") ||
 				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
+			sharedState.discussedTvDeedEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(
 				DialogueFlags::kShownEvidenceSheriffOwns, true);
 			lineError = playSequence(


Commit: 4abeef816db2bbc91ffc1ceeb4f101ca055be91e
    https://github.com/scummvm/scummvm/commit/4abeef816db2bbc91ffc1ceeb4f101ca055be91e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:12+02:00

Commit Message:
HARVESTER: Reimplement Mrs. Potts dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/mrs_potts_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index cf2e782ae89..5098079a12a 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,10 +10,12 @@
 
 ## Next Suggested Action
 
-- Run an in-engine Mr. Potts validation pass against the repaired native handler: the `HARVEST_BLADE` response menu and `MONSTERFY_POTTS` tail; `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE` to confirm the full `C001B.FST` / `STHLEXIT2B` exit; first and repeat `POTTSDAM_DIGGING`; `POTTSDAM_DIGGING_KARIN`; `MEAT` through both `0x1d4` responses to confirm the reset path; `CASKET_PHOTO`, `PHOTO_OF_WHALEY_HERRILL`, note/checkbook, and TV-deed evidence to verify the restored multi-line exchanges; the later-day Stephanie-alive revisit on day 2 through day 5 to confirm `0x2ae5`; the `KARIN_KIDNAPED` response-2 branch to verify the `STEPH_MIDGAME_PLAYED` gate; the `KARIN_FOUND_DEAD` `0x2b8d..0x2bb6` follow-up; and the topic loop branches for `AMNESIA`, `FATHER`, `SLAUGHTERHOUSE`, `TOILET`, and `MOLEST` to confirm the recovered buffer rewrites and nested menus.
+- Run an in-engine Mrs. Potts validation pass against the repaired native handler: `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `LEDGER` with `HAVE_BOTH_LEDGERS`, and the `GOT_REMAINS_FOR_LODGE` response-1 death branch to confirm the corrected portrait variants; the non-dead revisit gate around `0x281b` / `0x2820` / `0x2825`; and `DINER_BURNED` with and without `KARIN_HUNG`, taking both `0x236` responses to verify the `0x2914` / `0x2918` / `0x2926` continuation and the response-2 jump into the shared death-in-chair sequence.
 
 ## Last Confirmed Action
 
+- On March 24, 2026, reimplemented the ScummVM Mrs. Potts dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_mrs_potts` (`0x2ee70..0x2f7be`) as a fresh pass rather than trusting the older port. The fresh native audit found one remaining source drift in [`engines/harvester/npc/mrs_potts_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mrs_potts_dialogue.cpp): Mrs. Potts' Whaley/Herrill-photo, casket-photo, both-ledger, note/checkbook, and TV-deed item branches call the shared `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_note_checkbook_evidence`, and `get_set_discussed_tv_deed_evidence` accessors, so the port now mirrors that by writing `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedLedgerEvidence`, `sharedState.discussedNoteCheckbookEvidence`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script flags. Re-auditing the repaired native return-visit flow, `GOT_REMAINS_FOR_LODGE`, `TAKEN_INVITE_TO_SERGEANT`, `KARIN_KIDNAPED`, `DINER_BURNED`, and the late keyword loop confirmed the current C++ control flow already matches the saved handler there, so no further source drift was found. The only direct unnamed callee surfaced during this pass, `FUN_0002fc79 @ 0x2fc79`, is just a tiny zero-return stub used as a dead tail after the `0x294d` item bark and did not meet the rename bar; the meaningful direct helpers (`play_c040_fst`, `get_set_discussed_mrs_potts_tuesday_night_alibi`, `get_set_shared_dialogue_state_d2eb8`, and the other shared-state accessors) were already stably named. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
+- On March 24, 2026, treated `MRS_POTTS` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify MRS_POTTS` reported `handle_talk_to_mrs_potts @ 0x2ee70` in the stale short-body state (`0x2ee70..0x2f7be`, `1156` address-count) with only `19` reachable dialogue calls and `19` stale `CALL_RETURN` gaps while both dialogue entry points already remained returning. Running `repair MRS_POTTS` rebuilt the handler in two passes to a clean in-memory `0x2ee70..0x2fc81` body (`2392` address-count) with `54` reachable dialogue calls, `gapCount=0`, and `40` repair actions. After the normal settle and explicit `save_program`, a fresh `verify MRS_POTTS` still reported `status=clean`; the saved database now presents the stable clean body as `0x2ee70..0x2f7be` (`2383` address-count) with the same `54` dialogue calls and `gapCount=0`, so Mrs. Potts is durably clean again on the first fresh cycle. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Mr. Potts dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_mr_potts` (`0x26000..0x27d07`) as a fresh pass rather than trusting the older port. The fresh native audit found one remaining source drift in [`engines/harvester/npc/mr_potts_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mr_potts_dialogue.cpp): Mr. Potts' casket-photo, Whaley/Herrill-photo, note/checkbook, and TV-deed item branches call the shared `get_set_discussed_casket_photo_evidence`, `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_note_checkbook_evidence`, and `get_set_discussed_tv_deed_evidence` accessors, so the port now mirrors that by writing `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedNoteCheckbookEvidence`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script flags. Re-auditing the auxiliary intro/revisit day gating, `HARVEST_BLADE`, `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE`, `POTTSDAM_DIGGING`, `POTTSDAM_DIGGING_KARIN`, `KARIN_KIDNAPED`, `KARIN_FOUND_DEAD`, the accusation menus, and the late keyword loop confirmed the current C++ control flow already matches the saved native handler there, so no further source drift was found. The direct live callee set is now fully named (`play_c001b_fst_and_mark_steph_midgame_played`, `play_c016a_fst`, and the shared-state accessors), so no additional `FUN_*` rename was justified during this pass. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, treated `MR_POTTS` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify MR_POTTS` reported `handle_talk_to_mr_potts @ 0x26000` in the stale short-body state (`0x26000..0x26915`, `823` address-count) with only `15` reachable dialogue calls and `15` stale `CALL_RETURN` gaps while both dialogue entry points already remained returning. Running `repair MR_POTTS` rebuilt the handler in three passes to the full saved body `0x26000..0x27d07` (`6853` address-count) with `152` reachable dialogue calls, `gapCount=0`, and `81` repair actions. After the normal settle and explicit `save_program`, a fresh `verify MR_POTTS` still reported `status=clean`, so Mr. Potts is durably clean again in the saved database on the first fresh cycle. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Mom dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_mom` (`0x31140..0x33523`) as a fresh pass rather than trusting the older port. The native item-use branches proved one remaining source drift in [`engines/harvester/npc/mom_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mom_dialogue.cpp): Mom's note/checkbook, both-ledger, casket-photo, Whaley/Herrill-photo, and TV-deed evidence branches call the shared `get_set_discussed_note_checkbook_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_whaley_herrill_photo`, and `get_set_discussed_tv_deed_evidence` accessors, so the port now mirrors that by writing `sharedState.discussedNoteCheckbookEvidence`, `sharedState.discussedLedgerEvidence`, `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedWhaleyHerrillPhoto`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script flags. Re-auditing the previously suspicious `JIMMY_ATTACKED`, `POTTSDAM`, `MEAT PLANT`, lodge-topic, and `0x26c6` fallback paths confirmed the current C++ control flow already matches the saved native handler there, so no further source drift was found. During the same Ghidra pass I renamed `FUN_00038270` to `get_set_dialogue_state_d2ea4` and `FUN_00038900` to `play_c010_fst`, saved `HARVEST.LE`, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
diff --git a/engines/harvester/npc/mrs_potts_dialogue.cpp b/engines/harvester/npc/mrs_potts_dialogue.cpp
index 269a07c918b..3e9a7ef7fd8 100644
--- a/engines/harvester/npc/mrs_potts_dialogue.cpp
+++ b/engines/harvester/npc/mrs_potts_dialogue.cpp
@@ -187,18 +187,21 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
+			sharedState.discussedWhaleyHerrillPhoto = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(
 				DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
 			return playSequence(kMrsPottsWhaleyLines, ARRAYSIZE(kMrsPottsWhaleyLines));
 		}
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
+			sharedState.discussedCasketPhotoEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
 			return playMrsPottsLine(0x294d, 3);
 		}
 		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
 				usedItemName.equalsIgnoreCase("LEDGER2")) &&
 				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
+			sharedState.discussedLedgerEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
 			return playMrsPottsLine(0x294d, 3);
 		}
@@ -206,12 +209,14 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				usedItemName.equalsIgnoreCase("NOTE_PHOTOCOPY") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
+			sharedState.discussedNoteCheckbookEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(
 				DialogueFlags::kShownEvidenceOfBlackmail, true);
 			return playSequence(kMrsPottsBlackmailLines, ARRAYSIZE(kMrsPottsBlackmailLines));
 		}
 		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
 				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
+			sharedState.discussedTvDeedEvidence = 1;
 			(void)runtime.startupScript().setRuntimeFlagValue(
 				DialogueFlags::kShownEvidenceSheriffOwns, true);
 			return playSequence(kMrsPottsTvDeedLines, ARRAYSIZE(kMrsPottsTvDeedLines));


Commit: bacdb4627bb61edc4eb9ebce9b18f6ce8e7ea73f
    https://github.com/scummvm/scummvm/commit/bacdb4627bb61edc4eb9ebce9b18f6ce8e7ea73f
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:12+02:00

Commit Message:
HARVESTER: Reimplement Edna dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/edna_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 5098079a12a..85e08c34d6f 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,17 +3,19 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `920`
-- Named/documented: `808`
+- Total functions: `914`
+- Named/documented: `802`
 - Remaining `FUN_*`: `112`
 - Remaining undocumented total: `112`
 
 ## Next Suggested Action
 
-- Run an in-engine Mrs. Potts validation pass against the repaired native handler: `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `LEDGER` with `HAVE_BOTH_LEDGERS`, and the `GOT_REMAINS_FOR_LODGE` response-1 death branch to confirm the corrected portrait variants; the non-dead revisit gate around `0x281b` / `0x2820` / `0x2825`; and `DINER_BURNED` with and without `KARIN_HUNG`, taking both `0x236` responses to verify the `0x2914` / `0x2918` / `0x2926` continuation and the response-2 jump into the shared death-in-chair sequence.
+- Run an in-engine Edna validation pass against the reimplemented native handler: `KILLED_KARIN1` to confirm the flag now clears; `DNA_S_SUICIDE_NOTE` with and without `KARIN_FOUND_ALIVE` to confirm `C043.FST` / `C043K.FST`, shared states `d2ea4` / `d2ea8`, and `DNAEXT:SIGNOUT`; direct `K_PURSE` use plus the `KARIN_FOUND_DEAD -> 0xa2 -> 0xa3/0xa4` nested purse menu to verify `REWARD_MONEY` and the `RAH`-hidden purse side effect; the day-5 alive and dead follow-ups; the `0xa6` keyword branch specifically with `KARIN_FOUND_ALIVE` set to confirm it still takes `0x3ad9 -> C091.FST` rather than the kidnapped/dead ladder; and the rest of the keyword loop through `0xac/0xad`, `0xb0`, `0xb2`, `0xb6/0xb7`, `0xba`, `0xbc/0xbd/0xbe`, `0xc0`, `0xc2`, typed unknown text, and goodbye branches to confirm the restored menu buffer rewrites and the `C070.FST` exit.
 
 ## Last Confirmed Action
 
+- On March 24, 2026, reimplemented the ScummVM Edna dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_edna` (`0x358a0..0x3667b`) as a fresh pass rather than trusting the older port. The fresh native audit found two remaining source drifts in [`engines/harvester/npc/edna_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/edna_dialogue.cpp): Edna's casket-photo, both-ledger, Whaley/Herrill-photo, note/checkbook, and TV-deed item branches call the shared `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_note_checkbook_evidence`, and `get_set_discussed_tv_deed_evidence` accessors, so the port now mirrors that by writing `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedLedgerEvidence`, `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedNoteCheckbookEvidence`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script flags; and the native `dialog.rsp[0xa6]` branch only gates on `KARIN_KIDNAPED` / `KARIN_FOUND_DEAD`, so `KARIN_FOUND_ALIVE` still follows `0x3ad9 -> play_c091_fst_sequence` instead of the `0x3ae3/0x3ae9` ladder. Re-auditing the repaired intro, day-5, purse, lodge-topic, `0xc0`, and goodbye paths confirmed the current C++ control flow already matches the saved handler there, so no further source drift was found. During the same Ghidra pass I renamed the two directly evidenced `play_fst_sequence` wrappers `FUN_00038990` and `FUN_00038ba0` to `play_c091_fst_sequence` and `play_c070_fst_sequence`, saved `HARVEST.LE`, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress in the current database is `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
+- On March 24, 2026, treated `EDNA` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify EDNA` reported `handle_talk_to_edna @ 0x358a0` in the stale short-body state (`0x358a0..0x35d1f`, `712` address-count) with only `11` reachable dialogue calls and `11` stale `CALL_RETURN` gaps while both dialogue entry points initially still remained returning. The first `repair EDNA` rebuilt the handler cleanly in memory to `0x358a0..0x3667b` (`3510` address-count, `65` reachable dialogue calls, `gapCount=0`, `57` repair actions), but the first saved `verify EDNA` snapped straight back to the short body and flipped both dialogue entry points back to non-returning. An immediate second full `repair -> wait -> save_program -> verify` cycle fixed it: the retry rebuilt Edna to the stable saved shape `0x358a0..0x3667b` (`3505` address-count) with `64` reachable dialogue calls, `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Edna remains in the same post-save retry bucket as Stephanie, Mr. Potts, Johnson, Loomis, and similar handlers. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Mrs. Potts dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_mrs_potts` (`0x2ee70..0x2f7be`) as a fresh pass rather than trusting the older port. The fresh native audit found one remaining source drift in [`engines/harvester/npc/mrs_potts_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mrs_potts_dialogue.cpp): Mrs. Potts' Whaley/Herrill-photo, casket-photo, both-ledger, note/checkbook, and TV-deed item branches call the shared `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_note_checkbook_evidence`, and `get_set_discussed_tv_deed_evidence` accessors, so the port now mirrors that by writing `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedLedgerEvidence`, `sharedState.discussedNoteCheckbookEvidence`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script flags. Re-auditing the repaired native return-visit flow, `GOT_REMAINS_FOR_LODGE`, `TAKEN_INVITE_TO_SERGEANT`, `KARIN_KIDNAPED`, `DINER_BURNED`, and the late keyword loop confirmed the current C++ control flow already matches the saved handler there, so no further source drift was found. The only direct unnamed callee surfaced during this pass, `FUN_0002fc79 @ 0x2fc79`, is just a tiny zero-return stub used as a dead tail after the `0x294d` item bark and did not meet the rename bar; the meaningful direct helpers (`play_c040_fst`, `get_set_discussed_mrs_potts_tuesday_night_alibi`, `get_set_shared_dialogue_state_d2eb8`, and the other shared-state accessors) were already stably named. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, treated `MRS_POTTS` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify MRS_POTTS` reported `handle_talk_to_mrs_potts @ 0x2ee70` in the stale short-body state (`0x2ee70..0x2f7be`, `1156` address-count) with only `19` reachable dialogue calls and `19` stale `CALL_RETURN` gaps while both dialogue entry points already remained returning. Running `repair MRS_POTTS` rebuilt the handler in two passes to a clean in-memory `0x2ee70..0x2fc81` body (`2392` address-count) with `54` reachable dialogue calls, `gapCount=0`, and `40` repair actions. After the normal settle and explicit `save_program`, a fresh `verify MRS_POTTS` still reported `status=clean`; the saved database now presents the stable clean body as `0x2ee70..0x2f7be` (`2383` address-count) with the same `54` dialogue calls and `gapCount=0`, so Mrs. Potts is durably clean again on the first fresh cycle. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Mr. Potts dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_mr_potts` (`0x26000..0x27d07`) as a fresh pass rather than trusting the older port. The fresh native audit found one remaining source drift in [`engines/harvester/npc/mr_potts_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mr_potts_dialogue.cpp): Mr. Potts' casket-photo, Whaley/Herrill-photo, note/checkbook, and TV-deed item branches call the shared `get_set_discussed_casket_photo_evidence`, `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_note_checkbook_evidence`, and `get_set_discussed_tv_deed_evidence` accessors, so the port now mirrors that by writing `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedNoteCheckbookEvidence`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script flags. Re-auditing the auxiliary intro/revisit day gating, `HARVEST_BLADE`, `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE`, `POTTSDAM_DIGGING`, `POTTSDAM_DIGGING_KARIN`, `KARIN_KIDNAPED`, `KARIN_FOUND_DEAD`, the accusation menus, and the late keyword loop confirmed the current C++ control flow already matches the saved native handler there, so no further source drift was found. The direct live callee set is now fully named (`play_c001b_fst_and_mark_steph_midgame_played`, `play_c016a_fst`, and the shared-state accessors), so no additional `FUN_*` rename was justified during this pass. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
diff --git a/engines/harvester/npc/edna_dialogue.cpp b/engines/harvester/npc/edna_dialogue.cpp
index a56b416c7fb..ea9c21d1a70 100644
--- a/engines/harvester/npc/edna_dialogue.cpp
+++ b/engines/harvester/npc/edna_dialogue.cpp
@@ -166,16 +166,19 @@ Common::Error EdnaDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
 			(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+			sharedState.discussedCasketPhotoEvidence = 1;
 			return playEdnaLine(0x3bd6);
 		}
 		if ((usedItemName.equalsIgnoreCase("LEDGER") ||
 				usedItemName.equalsIgnoreCase("LEDGER2")) &&
 				startupScript.getFlagValue("HAVE_BOTH_LEDGERS")) {
 			(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+			sharedState.discussedLedgerEvidence = 1;
 			return playEdnaLine(0x3bd6);
 		}
 		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
 			(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			sharedState.discussedWhaleyHerrillPhoto = 1;
 			return playEdnaLine(0x3bde, 2);
 		}
 		if (usedItemName.equalsIgnoreCase("NOTE") ||
@@ -183,11 +186,13 @@ Common::Error EdnaDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
 			(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+			sharedState.discussedNoteCheckbookEvidence = 1;
 			return playEdnaLine(0x3be5, 2);
 		}
 		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
 				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
 			(void)startupScript.setRuntimeFlagValue(DialogueFlags::kShownEvidenceSheriffOwns, true);
+			sharedState.discussedTvDeedEvidence = 1;
 			return playEdnaLine(0x3bee);
 		}
 
@@ -394,8 +399,7 @@ Common::Error EdnaDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 		if (runtime.matchesResponseLine(selectedTopic, 0xa6)) {
 			if (!startupScript.getFlagValue("KARIN_KIDNAPED") &&
-					!startupScript.getFlagValue("KARIN_FOUND_DEAD") &&
-					!startupScript.getFlagValue("KARIN_FOUND_ALIVE")) {
+					!startupScript.getFlagValue("KARIN_FOUND_DEAD")) {
 				Common::Error lineError = playEdnaLine(0x3ad9, 1);
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;


Commit: 739d195e2420b26f033e4a5d869b810efa1beeed
    https://github.com/scummvm/scummvm/commit/739d195e2420b26f033e4a5d869b810efa1beeed
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:12+02:00

Commit Message:
HARVESTER: Audit Karin dialogue

Changed paths:
    TRACKER.md


diff --git a/TRACKER.md b/TRACKER.md
index 85e08c34d6f..8d8c6028dda 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,10 +10,12 @@
 
 ## Next Suggested Action
 
-- Run an in-engine Edna validation pass against the reimplemented native handler: `KILLED_KARIN1` to confirm the flag now clears; `DNA_S_SUICIDE_NOTE` with and without `KARIN_FOUND_ALIVE` to confirm `C043.FST` / `C043K.FST`, shared states `d2ea4` / `d2ea8`, and `DNAEXT:SIGNOUT`; direct `K_PURSE` use plus the `KARIN_FOUND_DEAD -> 0xa2 -> 0xa3/0xa4` nested purse menu to verify `REWARD_MONEY` and the `RAH`-hidden purse side effect; the day-5 alive and dead follow-ups; the `0xa6` keyword branch specifically with `KARIN_FOUND_ALIVE` set to confirm it still takes `0x3ad9 -> C091.FST` rather than the kidnapped/dead ladder; and the rest of the keyword loop through `0xac/0xad`, `0xb0`, `0xb2`, `0xb6/0xb7`, `0xba`, `0xbc/0xbd/0xbe`, `0xc0`, `0xc2`, typed unknown text, and goodbye branches to confirm the restored menu buffer rewrites and the `C070.FST` exit.
+- Run an in-engine Karin validation pass against the confirmed live-matched handler: `KARIN_FOUND_ALIVE + IN_CEM10` to confirm the `0x10a5..0x10c3` exchange only fires once; first no-item talk before Karin is found alive and a repeat talk to confirm the `0x1076..0x108b` opener is one-shot and normal fallback is `0x10dc`; present `CASKET_PHOTO` / `CASKET_PHOTOCOPY` and `PHOTO_OF_WHALEY_HERRILL` twice each to verify the first pass does `0x10e3 -> 0x10e7`, sets the correct shared evidence state, resets Karin's portrait to variant `0`, and the second pass flips into the `DNALFT_PERVERT + CALL_POLICE` path; present `INV_MAG` to confirm the `0x10ee -> 0x10f2` police-dispatch branch; and try an unrelated item to confirm the `0x10d6` fallback.
 
 ## Last Confirmed Action
 
+- On March 24, 2026, re-audited the ScummVM Karin dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_karin` (`0x2ea90..0x2ed7f`) as a fresh pass rather than trusting the older port. The native body and the current source in [`engines/harvester/npc/karin_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/karin_dialogue.cpp) and [`engines/harvester/npc/karin_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/karin_dialogue.h) already match: the `KARIN_FOUND_ALIVE + IN_CEM10` one-shot exchange, the pre-rescue no-item opener and `0x10dc` fallback, the two-stage photo-present branch with the shared `discussedWhaleyHerrillPhoto` / `discussedCasketPhotoEvidence` writes plus Karin portrait reset, the `INV_MAG` police-dispatch branch, and the three persisted room-state bits all align with the saved native handler. The direct live callee set is already fully named (`dispatch_action_tag_if_set`, `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `set_dialogue_head_left_portrait`, and the standard dialogue helpers), so no additional `FUN_*` rename was justified. No engine source patch was needed, no build was run, and live progress is unchanged at `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
+- On March 24, 2026, treated `KARIN` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify KARIN` reported `handle_talk_to_karin @ 0x2ea90` in the stale short-body state (`0x2ea90..0x2ed77`, `378` address-count) with only `7` reachable dialogue calls and `7` stale `CALL_RETURN` gaps while both dialogue entry points already remained returning. Running `repair KARIN` rebuilt the handler in a single pass to `0x2ea90..0x2ed7f` (`752` address-count) with `21` reachable dialogue calls, `gapCount=0`, and `14` repair actions. After the normal settle and explicit `save_program`, a fresh `verify KARIN` still reported `status=clean`, so Karin is durably clean again in the saved database on the first fresh cycle. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Edna dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_edna` (`0x358a0..0x3667b`) as a fresh pass rather than trusting the older port. The fresh native audit found two remaining source drifts in [`engines/harvester/npc/edna_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/edna_dialogue.cpp): Edna's casket-photo, both-ledger, Whaley/Herrill-photo, note/checkbook, and TV-deed item branches call the shared `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_note_checkbook_evidence`, and `get_set_discussed_tv_deed_evidence` accessors, so the port now mirrors that by writing `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedLedgerEvidence`, `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedNoteCheckbookEvidence`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script flags; and the native `dialog.rsp[0xa6]` branch only gates on `KARIN_KIDNAPED` / `KARIN_FOUND_DEAD`, so `KARIN_FOUND_ALIVE` still follows `0x3ad9 -> play_c091_fst_sequence` instead of the `0x3ae3/0x3ae9` ladder. Re-auditing the repaired intro, day-5, purse, lodge-topic, `0xc0`, and goodbye paths confirmed the current C++ control flow already matches the saved handler there, so no further source drift was found. During the same Ghidra pass I renamed the two directly evidenced `play_fst_sequence` wrappers `FUN_00038990` and `FUN_00038ba0` to `play_c091_fst_sequence` and `play_c070_fst_sequence`, saved `HARVEST.LE`, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress in the current database is `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, treated `EDNA` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify EDNA` reported `handle_talk_to_edna @ 0x358a0` in the stale short-body state (`0x358a0..0x35d1f`, `712` address-count) with only `11` reachable dialogue calls and `11` stale `CALL_RETURN` gaps while both dialogue entry points initially still remained returning. The first `repair EDNA` rebuilt the handler cleanly in memory to `0x358a0..0x3667b` (`3510` address-count, `65` reachable dialogue calls, `gapCount=0`, `57` repair actions), but the first saved `verify EDNA` snapped straight back to the short body and flipped both dialogue entry points back to non-returning. An immediate second full `repair -> wait -> save_program -> verify` cycle fixed it: the retry rebuilt Edna to the stable saved shape `0x358a0..0x3667b` (`3505` address-count) with `64` reachable dialogue calls, `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Edna remains in the same post-save retry bucket as Stephanie, Mr. Potts, Johnson, Loomis, and similar handlers. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Mrs. Potts dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_mrs_potts` (`0x2ee70..0x2f7be`) as a fresh pass rather than trusting the older port. The fresh native audit found one remaining source drift in [`engines/harvester/npc/mrs_potts_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mrs_potts_dialogue.cpp): Mrs. Potts' Whaley/Herrill-photo, casket-photo, both-ledger, note/checkbook, and TV-deed item branches call the shared `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_note_checkbook_evidence`, and `get_set_discussed_tv_deed_evidence` accessors, so the port now mirrors that by writing `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedLedgerEvidence`, `sharedState.discussedNoteCheckbookEvidence`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script flags. Re-auditing the repaired native return-visit flow, `GOT_REMAINS_FOR_LODGE`, `TAKEN_INVITE_TO_SERGEANT`, `KARIN_KIDNAPED`, `DINER_BURNED`, and the late keyword loop confirmed the current C++ control flow already matches the saved handler there, so no further source drift was found. The only direct unnamed callee surfaced during this pass, `FUN_0002fc79 @ 0x2fc79`, is just a tiny zero-return stub used as a dead tail after the `0x294d` item bark and did not meet the rename bar; the meaningful direct helpers (`play_c040_fst`, `get_set_discussed_mrs_potts_tuesday_night_alibi`, `get_set_shared_dialogue_state_d2eb8`, and the other shared-state accessors) were already stably named. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.


Commit: 4e5e6dd5ba991885f0cd2c7968421a334ace770d
    https://github.com/scummvm/scummvm/commit/4e5e6dd5ba991885f0cd2c7968421a334ace770d
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:12+02:00

Commit Message:
HARVESTER: Reimplement Loomis dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/loomis_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 8d8c6028dda..971641c813f 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,10 +10,12 @@
 
 ## Next Suggested Action
 
-- Run an in-engine Karin validation pass against the confirmed live-matched handler: `KARIN_FOUND_ALIVE + IN_CEM10` to confirm the `0x10a5..0x10c3` exchange only fires once; first no-item talk before Karin is found alive and a repeat talk to confirm the `0x1076..0x108b` opener is one-shot and normal fallback is `0x10dc`; present `CASKET_PHOTO` / `CASKET_PHOTOCOPY` and `PHOTO_OF_WHALEY_HERRILL` twice each to verify the first pass does `0x10e3 -> 0x10e7`, sets the correct shared evidence state, resets Karin's portrait to variant `0`, and the second pass flips into the `DNALFT_PERVERT + CALL_POLICE` path; present `INV_MAG` to confirm the `0x10ee -> 0x10f2` police-dispatch branch; and try an unrelated item to confirm the `0x10d6` fallback.
+- Run an in-engine Loomis validation pass against the reimplemented native handler: first talk with and without `SHERIFF_IN_DINER` to confirm the `0x1112 -> 0x1118 -> 0x1121 -> response 0x53 -> 0x1130[3]` intro; the second and third no-item visits to confirm the one-shot `0x11a8/0x11ac/0x11b0[3]` follow-up, the later `0x11f6` bark, and the `STEPH_MIDGAME_PLAYED` / day-5 `0x1230[3]` and `0x123c` gates; the keyword loop through `LOOMIS`, `Phelps`, `Man's Needs`, sheriff topics, `0x65`, `0x66`, typed unknown text, and goodbye to verify the restored alias matches, portrait variants, `0x11fc` fallback, and `0x11a1[1]` exit; note/checkbook or TV-deed evidence through both `0x50` responses to confirm `0x1214` / `0x1219` / `0x121f[3]` plus `PC_FRY_DADDY_1` and the shared note/checkbook evidence state; and `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` plus `INV_MAG` in the diner branch through both `0x51` responses to confirm `0x1229`, the shared ledger evidence state, `0x11c6[1]`, `0x11cc[3]`, drawer visibility, `GAVE_MAG_TO_LOOMIS_TODAY`, and the `C048.FST` tail.
 
 ## Last Confirmed Action
 
+- On March 24, 2026, reimplemented the ScummVM Loomis dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_loomis` (`0x34f80..0x35826`) as a fresh pass rather than trusting the older port. The fresh native audit found one remaining source drift in [`engines/harvester/npc/loomis_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/loomis_dialogue.cpp): Loomis's note/checkbook-plus-TV-deed branch and the both-ledgers branch call the shared `get_set_discussed_note_checkbook_evidence` and `get_set_discussed_ledger_evidence` accessors in the live handler, so the port now mirrors that by writing `sharedState.discussedNoteCheckbookEvidence` and `sharedState.discussedLedgerEvidence` alongside the existing startup-script evidence flags. Re-auditing the repaired intro/revisit flow, keyword loop aliases, `INV_MAG` diner and non-diner branches, and the Whaley photo fallback confirmed the current C++ control flow already matches the saved native handler there, so no further source drift was found. The direct live callee set is already fully named (`play_c048_fst_sequence`, `get_current_story_day_index`, `get_set_shared_dialogue_state_d2f04`, `dispatch_action_tag_if_set`, and the standard dialogue helpers), so no additional `FUN_*` rename was justified. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
+- On March 24, 2026, treated `LOOMIS` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify LOOMIS` reported `handle_talk_to_loomis @ 0x34f80` in the stale short-body state (`0x34f80..0x35406`, `639` address-count) with only `9` reachable dialogue calls and `9` stale `CALL_RETURN` gaps while both dialogue entry points initially still remained returning. The first `repair LOOMIS` rebuilt the handler cleanly in memory to `0x34f80..0x35826` (`2071` address-count, `33` reachable dialogue calls, `gapCount=0`, `22` repair actions), but the first saved `verify LOOMIS` snapped straight back to the short body and flipped both dialogue entry points back to non-returning. An immediate second full `repair -> wait -> save_program -> verify` cycle fixed it: the retry rebuilt Loomis to the stable saved shape `0x34f80..0x35826` (`2071` address-count) with `33` reachable dialogue calls, `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Loomis remains in the same post-save retry bucket as Edna, Stephanie, Mr. Potts, Johnson, Dad, and similar handlers. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, re-audited the ScummVM Karin dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_karin` (`0x2ea90..0x2ed7f`) as a fresh pass rather than trusting the older port. The native body and the current source in [`engines/harvester/npc/karin_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/karin_dialogue.cpp) and [`engines/harvester/npc/karin_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/karin_dialogue.h) already match: the `KARIN_FOUND_ALIVE + IN_CEM10` one-shot exchange, the pre-rescue no-item opener and `0x10dc` fallback, the two-stage photo-present branch with the shared `discussedWhaleyHerrillPhoto` / `discussedCasketPhotoEvidence` writes plus Karin portrait reset, the `INV_MAG` police-dispatch branch, and the three persisted room-state bits all align with the saved native handler. The direct live callee set is already fully named (`dispatch_action_tag_if_set`, `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `set_dialogue_head_left_portrait`, and the standard dialogue helpers), so no additional `FUN_*` rename was justified. No engine source patch was needed, no build was run, and live progress is unchanged at `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, treated `KARIN` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify KARIN` reported `handle_talk_to_karin @ 0x2ea90` in the stale short-body state (`0x2ea90..0x2ed77`, `378` address-count) with only `7` reachable dialogue calls and `7` stale `CALL_RETURN` gaps while both dialogue entry points already remained returning. Running `repair KARIN` rebuilt the handler in a single pass to `0x2ea90..0x2ed7f` (`752` address-count) with `21` reachable dialogue calls, `gapCount=0`, and `14` repair actions. After the normal settle and explicit `save_program`, a fresh `verify KARIN` still reported `status=clean`, so Karin is durably clean again in the saved database on the first fresh cycle. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Edna dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_edna` (`0x358a0..0x3667b`) as a fresh pass rather than trusting the older port. The fresh native audit found two remaining source drifts in [`engines/harvester/npc/edna_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/edna_dialogue.cpp): Edna's casket-photo, both-ledger, Whaley/Herrill-photo, note/checkbook, and TV-deed item branches call the shared `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_note_checkbook_evidence`, and `get_set_discussed_tv_deed_evidence` accessors, so the port now mirrors that by writing `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedLedgerEvidence`, `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedNoteCheckbookEvidence`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script flags; and the native `dialog.rsp[0xa6]` branch only gates on `KARIN_KIDNAPED` / `KARIN_FOUND_DEAD`, so `KARIN_FOUND_ALIVE` still follows `0x3ad9 -> play_c091_fst_sequence` instead of the `0x3ae3/0x3ae9` ladder. Re-auditing the repaired intro, day-5, purse, lodge-topic, `0xc0`, and goodbye paths confirmed the current C++ control flow already matches the saved handler there, so no further source drift was found. During the same Ghidra pass I renamed the two directly evidenced `play_fst_sequence` wrappers `FUN_00038990` and `FUN_00038ba0` to `play_c091_fst_sequence` and `play_c070_fst_sequence`, saved `HARVEST.LE`, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress in the current database is `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
diff --git a/engines/harvester/npc/loomis_dialogue.cpp b/engines/harvester/npc/loomis_dialogue.cpp
index 815bd2bd229..bb3534619e3 100644
--- a/engines/harvester/npc/loomis_dialogue.cpp
+++ b/engines/harvester/npc/loomis_dialogue.cpp
@@ -243,6 +243,7 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			usedItemName.equalsIgnoreCase("TV_DEED") ||
 			usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+		sharedState.discussedNoteCheckbookEvidence = 1;
 		Common::Error lineError = playLoomisLine(0x1209);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
@@ -273,6 +274,7 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				usedItemName.equalsIgnoreCase("LEDGER2")) &&
 			runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
 		(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+		sharedState.discussedLedgerEvidence = 1;
 		return playLoomisLine(0x1229);
 	}
 	if (usedItemName.equalsIgnoreCase("INV_MAG")) {


Commit: 14311dff5546b1b5e61292f77052634992f13995
    https://github.com/scummvm/scummvm/commit/14311dff5546b1b5e61292f77052634992f13995
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:13+02:00

Commit Message:
HARVESTER: Reimplement Johnson dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/johnson_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 971641c813f..0730d4f48e6 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,10 +10,12 @@
 
 ## Next Suggested Action
 
-- Run an in-engine Loomis validation pass against the reimplemented native handler: first talk with and without `SHERIFF_IN_DINER` to confirm the `0x1112 -> 0x1118 -> 0x1121 -> response 0x53 -> 0x1130[3]` intro; the second and third no-item visits to confirm the one-shot `0x11a8/0x11ac/0x11b0[3]` follow-up, the later `0x11f6` bark, and the `STEPH_MIDGAME_PLAYED` / day-5 `0x1230[3]` and `0x123c` gates; the keyword loop through `LOOMIS`, `Phelps`, `Man's Needs`, sheriff topics, `0x65`, `0x66`, typed unknown text, and goodbye to verify the restored alias matches, portrait variants, `0x11fc` fallback, and `0x11a1[1]` exit; note/checkbook or TV-deed evidence through both `0x50` responses to confirm `0x1214` / `0x1219` / `0x121f[3]` plus `PC_FRY_DADDY_1` and the shared note/checkbook evidence state; and `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` plus `INV_MAG` in the diner branch through both `0x51` responses to confirm `0x1229`, the shared ledger evidence state, `0x11c6[1]`, `0x11cc[3]`, drawer visibility, `GAVE_MAG_TO_LOOMIS_TODAY`, and the `C048.FST` tail.
+- Run an in-engine Johnson validation pass against the reimplemented native handler: `OPENING_MANHOLE_DAYTIME` twice to confirm `0xbe0` now clears the flag; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared state `d2ebc`, `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS`, plus note/checkbook and TV-deed evidence to verify the restored `0xb7b`, `0xb80`, `0xb90`, `0xb94`, `0xb99`, `0xba4`, and `0xba8` follow-ups and the shared evidence-state writes; the first `SCRATCHED_TUCKER` visit with `BOLT_OF_CLOTH_TAKEN` already set to confirm `0xc25` / `0xc2b` / `0xc30` plus `0xc5f` / `0xc69` and that the Johnson intro only replays on the later revisit; and a `KARIN_KIDNAPED` follow-up that later reaches a consumer such as Stephanie to confirm the shared kidnapped-dialogue state now propagates correctly.
 
 ## Last Confirmed Action
 
+- On March 24, 2026, reimplemented the ScummVM Johnson dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_johnson` (`0x27dd0..0x28802`) as a fresh pass rather than trusting the older port. The fresh native audit found one remaining source drift in [`engines/harvester/npc/johnson_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/johnson_dialogue.cpp): Johnson's Whaley-photo, casket-photo, dual-ledger, note/checkbook, and TV-deed item branches call the shared `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_note_checkbook_evidence`, and `get_set_discussed_tv_deed_evidence` accessors in the live handler, so the port now mirrors that by writing `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedLedgerEvidence`, `sharedState.discussedNoteCheckbookEvidence`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script evidence flags. Re-auditing the repaired intro/revisit flow, the `OPENING_MANHOLE_DAYTIME` clear, the `SCRATCHED_TUCKER` plus `BOLT_OF_CLOTH_TAKEN` re-entry, the `KARIN_KIDNAPED` shared-state write, and the recovered keyword loop confirmed the current C++ control flow already matches the saved native handler there, so no further source drift was found. The direct live callee set is already fully named (`get_set_shared_karin_kidnaped_dialogue_state`, `get_set_shared_dialogue_state_d2ebc`, the shared evidence accessors, and the standard dialogue helpers), so no additional `FUN_*` rename was justified. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
+- On March 24, 2026, treated `JOHNSON` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify JOHNSON` reported `handle_talk_to_johnson @ 0x27dd0` in the stale short-body state (`0x27dd0..0x287f9`, `657` address-count) with only `12` reachable dialogue calls and `12` stale `CALL_RETURN` gaps while both dialogue entry points initially still remained returning. The first `repair JOHNSON` rebuilt the handler cleanly in memory to `0x27dd0..0x28802` (`2539` address-count, `51` reachable dialogue calls, `gapCount=0`, `38` repair actions), but the first saved `verify JOHNSON` snapped straight back to the short body and flipped both dialogue entry points back to non-returning. An immediate second full `repair -> wait -> save_program -> verify` cycle fixed it: the retry rebuilt Johnson to the stable saved shape `0x27dd0..0x28802` (`2539` address-count) with `51` reachable dialogue calls, `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Johnson remains in the same post-save retry bucket as Loomis, Edna, Stephanie, Mr. Potts, Dad, and similar handlers. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Loomis dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_loomis` (`0x34f80..0x35826`) as a fresh pass rather than trusting the older port. The fresh native audit found one remaining source drift in [`engines/harvester/npc/loomis_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/loomis_dialogue.cpp): Loomis's note/checkbook-plus-TV-deed branch and the both-ledgers branch call the shared `get_set_discussed_note_checkbook_evidence` and `get_set_discussed_ledger_evidence` accessors in the live handler, so the port now mirrors that by writing `sharedState.discussedNoteCheckbookEvidence` and `sharedState.discussedLedgerEvidence` alongside the existing startup-script evidence flags. Re-auditing the repaired intro/revisit flow, keyword loop aliases, `INV_MAG` diner and non-diner branches, and the Whaley photo fallback confirmed the current C++ control flow already matches the saved native handler there, so no further source drift was found. The direct live callee set is already fully named (`play_c048_fst_sequence`, `get_current_story_day_index`, `get_set_shared_dialogue_state_d2f04`, `dispatch_action_tag_if_set`, and the standard dialogue helpers), so no additional `FUN_*` rename was justified. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, treated `LOOMIS` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify LOOMIS` reported `handle_talk_to_loomis @ 0x34f80` in the stale short-body state (`0x34f80..0x35406`, `639` address-count) with only `9` reachable dialogue calls and `9` stale `CALL_RETURN` gaps while both dialogue entry points initially still remained returning. The first `repair LOOMIS` rebuilt the handler cleanly in memory to `0x34f80..0x35826` (`2071` address-count, `33` reachable dialogue calls, `gapCount=0`, `22` repair actions), but the first saved `verify LOOMIS` snapped straight back to the short body and flipped both dialogue entry points back to non-returning. An immediate second full `repair -> wait -> save_program -> verify` cycle fixed it: the retry rebuilt Loomis to the stable saved shape `0x34f80..0x35826` (`2071` address-count) with `33` reachable dialogue calls, `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Loomis remains in the same post-save retry bucket as Edna, Stephanie, Mr. Potts, Johnson, Dad, and similar handlers. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, re-audited the ScummVM Karin dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_karin` (`0x2ea90..0x2ed7f`) as a fresh pass rather than trusting the older port. The native body and the current source in [`engines/harvester/npc/karin_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/karin_dialogue.cpp) and [`engines/harvester/npc/karin_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/karin_dialogue.h) already match: the `KARIN_FOUND_ALIVE + IN_CEM10` one-shot exchange, the pre-rescue no-item opener and `0x10dc` fallback, the two-stage photo-present branch with the shared `discussedWhaleyHerrillPhoto` / `discussedCasketPhotoEvidence` writes plus Karin portrait reset, the `INV_MAG` police-dispatch branch, and the three persisted room-state bits all align with the saved native handler. The direct live callee set is already fully named (`dispatch_action_tag_if_set`, `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `set_dialogue_head_left_portrait`, and the standard dialogue helpers), so no additional `FUN_*` rename was justified. No engine source patch was needed, no build was run, and live progress is unchanged at `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
diff --git a/engines/harvester/npc/johnson_dialogue.cpp b/engines/harvester/npc/johnson_dialogue.cpp
index f02822c7382..601dd1c5801 100644
--- a/engines/harvester/npc/johnson_dialogue.cpp
+++ b/engines/harvester/npc/johnson_dialogue.cpp
@@ -131,12 +131,14 @@ Common::Error JohnsonDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (!usedItemName.empty()) {
 		if (usedItemName.equalsIgnoreCase("PHOTO_OF_WHALEY_HERRILL")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfWhaleyHerrill, true);
+			sharedState.discussedWhaleyHerrillPhoto = 1;
 			return runtime.playDialogueEntrySequence(
 				kJohnsonWhaleyPhotoLines, ARRAYSIZE(kJohnsonWhaleyPhotoLines));
 		}
 		if (usedItemName.equalsIgnoreCase("CASKET_PHOTO") ||
 				usedItemName.equalsIgnoreCase("CASKET_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownPhotoOfCorpse, true);
+			sharedState.discussedCasketPhotoEvidence = 1;
 			Common::Error lineError = playJohnsonLine(0xb89, 1);
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
@@ -154,6 +156,7 @@ Common::Error JohnsonDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 					usedItemName.equalsIgnoreCase("LEDGER2")) &&
 				runtime.startupScript().getFlagValue("HAVE_BOTH_LEDGERS")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownLedgersToAnyone, true);
+			sharedState.discussedLedgerEvidence = 1;
 			return runtime.playDialogueEntrySequence(
 				kJohnsonLedgerLines, ARRAYSIZE(kJohnsonLedgerLines));
 		}
@@ -162,11 +165,13 @@ Common::Error JohnsonDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				usedItemName.equalsIgnoreCase("CHECKBOOK") ||
 				usedItemName.equalsIgnoreCase("CHECKBOOK_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceOfBlackmail, true);
+			sharedState.discussedNoteCheckbookEvidence = 1;
 			return playJohnsonLine(0xbb1, 1);
 		}
 		if (usedItemName.equalsIgnoreCase("TV_DEED") ||
 				usedItemName.equalsIgnoreCase("TV_DEED_PHOTOCOPY")) {
 			(void)runtime.startupScript().setRuntimeFlagValue(DialogueFlags::kShownEvidenceSheriffOwns, true);
+			sharedState.discussedTvDeedEvidence = 1;
 			return playJohnsonLine(0xbce, 1);
 		}
 


Commit: e967a266d02cb5120c8b122805c4587a06454a6d
    https://github.com/scummvm/scummvm/commit/e967a266d02cb5120c8b122805c4587a06454a6d
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:13+02:00

Commit Message:
HARVESTER: Route cemetery Karin dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/karin_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 0730d4f48e6..177705eacb3 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,10 +10,11 @@
 
 ## Next Suggested Action
 
-- Run an in-engine Johnson validation pass against the reimplemented native handler: `OPENING_MANHOLE_DAYTIME` twice to confirm `0xbe0` now clears the flag; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared state `d2ebc`, `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS`, plus note/checkbook and TV-deed evidence to verify the restored `0xb7b`, `0xb80`, `0xb90`, `0xb94`, `0xb99`, `0xba4`, and `0xba8` follow-ups and the shared evidence-state writes; the first `SCRATCHED_TUCKER` visit with `BOLT_OF_CLOTH_TAKEN` already set to confirm `0xc25` / `0xc2b` / `0xc30` plus `0xc5f` / `0xc69` and that the Johnson intro only replays on the later revisit; and a `KARIN_KIDNAPED` follow-up that later reaches a consumer such as Stephanie to confirm the shared kidnapped-dialogue state now propagates correctly.
+- Run an in-engine Karin cemetery validation pass on the Wednesday dig-up path: confirm `KARIN_CEM10` now routes through the Karin dialogue handler without logging `unsupported room NPC dialogue handler`, then capture whether the post-`PLACE_KARIN_TIMER` live NPC still renders incorrectly. If the sprite corruption persists, compare the current ScummVM full room refresh against native `SET_NPC -> spawn_npc_entity_from_record` behavior before changing the room-NPC spawn path.
 
 ## Last Confirmed Action
 
+- On March 24, 2026, patched the ScummVM Karin cemetery dialogue routing so [`engines/harvester/npc/karin_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/karin_dialogue.cpp) now accepts both `KARIN` and `KARIN_CEM10`. This fixes the confirmed Wednesday grave-rescue regression where clicking Karin after `PLACE_KARIN_TIMER` fell through to `unsupported room NPC dialogue handler 'KARIN_CEM10'` instead of reusing the native Karin talk logic already implemented for `handle_talk_to_karin`. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Johnson dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_johnson` (`0x27dd0..0x28802`) as a fresh pass rather than trusting the older port. The fresh native audit found one remaining source drift in [`engines/harvester/npc/johnson_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/johnson_dialogue.cpp): Johnson's Whaley-photo, casket-photo, dual-ledger, note/checkbook, and TV-deed item branches call the shared `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_note_checkbook_evidence`, and `get_set_discussed_tv_deed_evidence` accessors in the live handler, so the port now mirrors that by writing `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedLedgerEvidence`, `sharedState.discussedNoteCheckbookEvidence`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script evidence flags. Re-auditing the repaired intro/revisit flow, the `OPENING_MANHOLE_DAYTIME` clear, the `SCRATCHED_TUCKER` plus `BOLT_OF_CLOTH_TAKEN` re-entry, the `KARIN_KIDNAPED` shared-state write, and the recovered keyword loop confirmed the current C++ control flow already matches the saved native handler there, so no further source drift was found. The direct live callee set is already fully named (`get_set_shared_karin_kidnaped_dialogue_state`, `get_set_shared_dialogue_state_d2ebc`, the shared evidence accessors, and the standard dialogue helpers), so no additional `FUN_*` rename was justified. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, treated `JOHNSON` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify JOHNSON` reported `handle_talk_to_johnson @ 0x27dd0` in the stale short-body state (`0x27dd0..0x287f9`, `657` address-count) with only `12` reachable dialogue calls and `12` stale `CALL_RETURN` gaps while both dialogue entry points initially still remained returning. The first `repair JOHNSON` rebuilt the handler cleanly in memory to `0x27dd0..0x28802` (`2539` address-count, `51` reachable dialogue calls, `gapCount=0`, `38` repair actions), but the first saved `verify JOHNSON` snapped straight back to the short body and flipped both dialogue entry points back to non-returning. An immediate second full `repair -> wait -> save_program -> verify` cycle fixed it: the retry rebuilt Johnson to the stable saved shape `0x27dd0..0x28802` (`2539` address-count) with `51` reachable dialogue calls, `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Johnson remains in the same post-save retry bucket as Loomis, Edna, Stephanie, Mr. Potts, Dad, and similar handlers. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Loomis dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_loomis` (`0x34f80..0x35826`) as a fresh pass rather than trusting the older port. The fresh native audit found one remaining source drift in [`engines/harvester/npc/loomis_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/loomis_dialogue.cpp): Loomis's note/checkbook-plus-TV-deed branch and the both-ledgers branch call the shared `get_set_discussed_note_checkbook_evidence` and `get_set_discussed_ledger_evidence` accessors in the live handler, so the port now mirrors that by writing `sharedState.discussedNoteCheckbookEvidence` and `sharedState.discussedLedgerEvidence` alongside the existing startup-script evidence flags. Re-auditing the repaired intro/revisit flow, keyword loop aliases, `INV_MAG` diner and non-diner branches, and the Whaley photo fallback confirmed the current C++ control flow already matches the saved native handler there, so no further source drift was found. The direct live callee set is already fully named (`play_c048_fst_sequence`, `get_current_story_day_index`, `get_set_shared_dialogue_state_d2f04`, `dispatch_action_tag_if_set`, and the standard dialogue helpers), so no additional `FUN_*` rename was justified. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
diff --git a/engines/harvester/npc/karin_dialogue.cpp b/engines/harvester/npc/karin_dialogue.cpp
index 621fb6494fd..b76d7c93682 100644
--- a/engines/harvester/npc/karin_dialogue.cpp
+++ b/engines/harvester/npc/karin_dialogue.cpp
@@ -30,6 +30,7 @@ namespace Harvester {
 namespace {
 
 static const char *const kKarinNpc = "KARIN";
+static const char *const kKarinCem10Npc = "KARIN_CEM10";
 static const char *const kPcSpeaker = "PC";
 static const char *const kEdnaNpc = "EDNA";
 
@@ -66,7 +67,8 @@ static const DialogueLineEntry kKarinInvMagLines[] = {
 } // End of namespace
 
 bool KarinDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase(kKarinNpc);
+	return npcName.equalsIgnoreCase(kKarinNpc) ||
+		npcName.equalsIgnoreCase(kKarinCem10Npc);
 }
 
 Common::Error KarinDialogueHandler::handleDialogue(DialogueRuntime &runtime,


Commit: d92f225c467108a2d16bcbeb9fd58b5e41a47136
    https://github.com/scummvm/scummvm/commit/d92f225c467108a2d16bcbeb9fd58b5e41a47136
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:13+02:00

Commit Message:
HARVESTER: Add KARIN_STANDING dialogue alias

Changed paths:
    engines/harvester/npc/karin_dialogue.cpp


diff --git a/engines/harvester/npc/karin_dialogue.cpp b/engines/harvester/npc/karin_dialogue.cpp
index b76d7c93682..ce8f514af73 100644
--- a/engines/harvester/npc/karin_dialogue.cpp
+++ b/engines/harvester/npc/karin_dialogue.cpp
@@ -30,6 +30,7 @@ namespace Harvester {
 namespace {
 
 static const char *const kKarinNpc = "KARIN";
+static const char *const kKarinStandingNpc = "KARIN_STANDING";
 static const char *const kKarinCem10Npc = "KARIN_CEM10";
 static const char *const kPcSpeaker = "PC";
 static const char *const kEdnaNpc = "EDNA";
@@ -68,6 +69,7 @@ static const DialogueLineEntry kKarinInvMagLines[] = {
 
 bool KarinDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase(kKarinNpc) ||
+		npcName.equalsIgnoreCase(kKarinStandingNpc) ||
 		npcName.equalsIgnoreCase(kKarinCem10Npc);
 }
 


Commit: f67659458e7e11928b9b553b30b0cd149b225368
    https://github.com/scummvm/scummvm/commit/f67659458e7e11928b9b553b30b0cd149b225368
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:14+02:00

Commit Message:
HARVESTER: Add room NPC dialogue aliases

Changed paths:
    engines/harvester/npc/authority_dialogue.cpp
    engines/harvester/npc/dwayne_dialogue.cpp
    engines/harvester/npc/herrill_dialogue.cpp
    engines/harvester/npc/karin_dialogue.cpp


diff --git a/engines/harvester/npc/authority_dialogue.cpp b/engines/harvester/npc/authority_dialogue.cpp
index 379ef6931cb..482cdc1194a 100644
--- a/engines/harvester/npc/authority_dialogue.cpp
+++ b/engines/harvester/npc/authority_dialogue.cpp
@@ -30,11 +30,13 @@ namespace Harvester {
 namespace {
 
 static const char *const kAuthorityNpc = "AUTHORITY";
+static const char *const kAuthority2Npc = "AUTHORITY2";
 
 } // End of namespace
 
 bool AuthorityDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase(kAuthorityNpc);
+	return npcName.equalsIgnoreCase(kAuthorityNpc) ||
+		npcName.equalsIgnoreCase(kAuthority2Npc);
 }
 
 Common::Error AuthorityDialogueHandler::handleDialogue(DialogueRuntime &runtime,
diff --git a/engines/harvester/npc/dwayne_dialogue.cpp b/engines/harvester/npc/dwayne_dialogue.cpp
index 662210489bd..a893109903a 100644
--- a/engines/harvester/npc/dwayne_dialogue.cpp
+++ b/engines/harvester/npc/dwayne_dialogue.cpp
@@ -32,6 +32,8 @@ namespace Harvester {
 namespace {
 
 static const char *const kDwayneNpc = "DWAYNE";
+static const char *const kDwayneDnaLeftNpc = "DWAYNE_DNALFT";
+static const char *const kDwayneStBedroomNpc = "DWAYNE_ST_BEDRM";
 static const char *const kEdnaNpc = "EDNA";
 static const char *const kPcSpeaker = "PC";
 static const char *const kInventoryOwnerName = "INVENTORY";
@@ -43,7 +45,9 @@ static const int kDwayneLoomisTopicResponseLines[] = { 0x8c, 0x8d };
 } // End of namespace
 
 bool DwayneDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase(kDwayneNpc);
+	return npcName.equalsIgnoreCase(kDwayneNpc) ||
+		npcName.equalsIgnoreCase(kDwayneDnaLeftNpc) ||
+		npcName.equalsIgnoreCase(kDwayneStBedroomNpc);
 }
 
 Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
diff --git a/engines/harvester/npc/herrill_dialogue.cpp b/engines/harvester/npc/herrill_dialogue.cpp
index b23684c3b26..864273c6d35 100644
--- a/engines/harvester/npc/herrill_dialogue.cpp
+++ b/engines/harvester/npc/herrill_dialogue.cpp
@@ -57,7 +57,8 @@ static const DialogueLineEntry kHerrillGascanIntroLines[] = {
 } // End of namespace
 
 bool HerrillDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase("HERRILL");
+	return npcName.equalsIgnoreCase(kHerrillNpc) ||
+		npcName.equalsIgnoreCase(kHerrillLogNpc);
 }
 
 Common::Error HerrillDialogueHandler::handleDialogue(DialogueRuntime &runtime,
diff --git a/engines/harvester/npc/karin_dialogue.cpp b/engines/harvester/npc/karin_dialogue.cpp
index ce8f514af73..1d5a894b9c3 100644
--- a/engines/harvester/npc/karin_dialogue.cpp
+++ b/engines/harvester/npc/karin_dialogue.cpp
@@ -32,6 +32,7 @@ namespace {
 static const char *const kKarinNpc = "KARIN";
 static const char *const kKarinStandingNpc = "KARIN_STANDING";
 static const char *const kKarinCem10Npc = "KARIN_CEM10";
+static const char *const kKarinOfficeNpc = "KARIN_OFFICE";
 static const char *const kPcSpeaker = "PC";
 static const char *const kEdnaNpc = "EDNA";
 
@@ -70,7 +71,8 @@ static const DialogueLineEntry kKarinInvMagLines[] = {
 bool KarinDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase(kKarinNpc) ||
 		npcName.equalsIgnoreCase(kKarinStandingNpc) ||
-		npcName.equalsIgnoreCase(kKarinCem10Npc);
+		npcName.equalsIgnoreCase(kKarinCem10Npc) ||
+		npcName.equalsIgnoreCase(kKarinOfficeNpc);
 }
 
 Common::Error KarinDialogueHandler::handleDialogue(DialogueRuntime &runtime,


Commit: 2cba1ce1e2bb5455e0d607ea69453a53ae8e9b3e
    https://github.com/scummvm/scummvm/commit/2cba1ce1e2bb5455e0d607ea69453a53ae8e9b3e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:14+02:00

Commit Message:
HARVESTER: Match native room actor depth anchor

Changed paths:
    TRACKER.md
    engines/harvester/flow.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 177705eacb3..0351c40e66b 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,10 +10,11 @@
 
 ## Next Suggested Action
 
-- Run an in-engine Karin cemetery validation pass on the Wednesday dig-up path: confirm `KARIN_CEM10` now routes through the Karin dialogue handler without logging `unsupported room NPC dialogue handler`, then capture whether the post-`PLACE_KARIN_TIMER` live NPC still renders incorrectly. If the sprite corruption persists, compare the current ScummVM full room refresh against native `SET_NPC -> spawn_npc_entity_from_record` behavior before changing the room-NPC spawn path.
+- Run an in-engine Wednesday cemetery validation pass after the room-actor render-depth fix: confirm `PLACE_KARIN_TIMER` now leaves `KARIN_CEM10` fully in front of the `CEM10_OBJ5` grave mask, confirm talking to her still routes through the Karin handler without `unsupported room NPC dialogue handler`, then spot-check another heavy-occlusion room to make sure the shared `z_extent / 2` actor-anchor correction did not regress player or monster layering.
 
 ## Last Confirmed Action
 
+- On March 24, 2026, corrected ScummVM's room-actor render-depth anchor in [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp) to match the native `spawn_npc_entity_from_record` and `spawn_monster_entity_from_record` paths. Native disassembly at `0x4ce00..0x4ce43` and `0x540b0..0x540f3` shows both actor spawners subtracting half `z_extent` from the record Z before `set_entity_screen_position`; ScummVM was leaving actors at the raw record Z instead, which put Wednesday cemetery Karin (`KARIN_CEM10`, `posZ=16`) one layer behind `CEM10_OBJ5` (`z=15`) in CEM10. The shared room-actor placement helper now applies that native `z_extent / 2` offset before depth scaling and insertion for room NPCs, monsters, and the player. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, patched the ScummVM Karin cemetery dialogue routing so [`engines/harvester/npc/karin_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/karin_dialogue.cpp) now accepts both `KARIN` and `KARIN_CEM10`. This fixes the confirmed Wednesday grave-rescue regression where clicking Karin after `PLACE_KARIN_TIMER` fell through to `unsupported room NPC dialogue handler 'KARIN_CEM10'` instead of reusing the native Karin talk logic already implemented for `handle_talk_to_karin`. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Johnson dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_johnson` (`0x27dd0..0x28802`) as a fresh pass rather than trusting the older port. The fresh native audit found one remaining source drift in [`engines/harvester/npc/johnson_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/johnson_dialogue.cpp): Johnson's Whaley-photo, casket-photo, dual-ledger, note/checkbook, and TV-deed item branches call the shared `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_note_checkbook_evidence`, and `get_set_discussed_tv_deed_evidence` accessors in the live handler, so the port now mirrors that by writing `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedLedgerEvidence`, `sharedState.discussedNoteCheckbookEvidence`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script evidence flags. Re-auditing the repaired intro/revisit flow, the `OPENING_MANHOLE_DAYTIME` clear, the `SCRATCHED_TUCKER` plus `BOLT_OF_CLOTH_TAKEN` re-entry, the `KARIN_KIDNAPED` shared-state write, and the recovered keyword loop confirmed the current C++ control flow already matches the saved native handler there, so no further source drift was found. The direct live callee set is already fully named (`get_set_shared_karin_kidnaped_dialogue_state`, `get_set_shared_dialogue_state_d2ebc`, the shared evidence accessors, and the standard dialogue helpers), so no additional `FUN_*` rename was justified. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, treated `JOHNSON` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify JOHNSON` reported `handle_talk_to_johnson @ 0x27dd0` in the stale short-body state (`0x27dd0..0x287f9`, `657` address-count) with only `12` reachable dialogue calls and `12` stale `CALL_RETURN` gaps while both dialogue entry points initially still remained returning. The first `repair JOHNSON` rebuilt the handler cleanly in memory to `0x27dd0..0x28802` (`2539` address-count, `51` reachable dialogue calls, `gapCount=0`, `38` repair actions), but the first saved `verify JOHNSON` snapped straight back to the short body and flipped both dialogue entry points back to non-returning. An immediate second full `repair -> wait -> save_program -> verify` cycle fixed it: the retry rebuilt Johnson to the stable saved shape `0x27dd0..0x28802` (`2539` address-count) with `51` reachable dialogue calls, `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Johnson remains in the same post-save retry bucket as Loomis, Edna, Stephanie, Mr. Potts, Dad, and similar handlers. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 4a2124bb08c..eeb4317ca93 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -322,6 +322,13 @@ static void setRoomActorScreenPosition(RuntimeEntity &entity, int centerX, int b
 	entity.setPosition(centerX - xOffset - width / 2, bottomY - height - yOffset, z);
 }
 
+static float computeRoomActorRenderZ(float z, const RuntimeEntity &entity) {
+	// Native actor spawns lower the render-list depth anchor by half the z extent
+	// before insertion. Without that adjustment, cemetery Karin lands one layer
+	// too far back and gets occluded by the grave mask in CEM10.
+	return z - floorf(MAX<float>(entity.getZExtent(), 0.0f) * 0.5f);
+}
+
 static bool applyRoomActorPlacementInternal(const StartupRoomSetupState &state, RuntimeEntity &entity,
 		int centerX, int bottomY, float z, const Common::String *entranceName, bool applyDepthScale) {
 	int width = 0;
@@ -331,16 +338,17 @@ static bool applyRoomActorPlacementInternal(const StartupRoomSetupState &state,
 	if (!entity.getCurrentFrameMetrics(width, height, xOffset, yOffset))
 		return false;
 
+	const float renderZ = computeRoomActorRenderZ(z, entity);
 	entity.setAnchorMode(kRuntimeEntityAnchorTopLeft);
-	setRoomActorScreenPosition(entity, centerX, bottomY, z, width, height, xOffset, yOffset);
+	setRoomActorScreenPosition(entity, centerX, bottomY, renderZ, width, height, xOffset, yOffset);
 
 	float depthScale = 1.0f;
 	if (applyDepthScale) {
-		depthScale = Player::computeDepthScale(state, z);
+		depthScale = Player::computeDepthScale(state, renderZ);
 		entity.setDepthScale(depthScale);
 		if (!entity.getCurrentFrameMetrics(width, height, xOffset, yOffset))
 			return false;
-		setRoomActorScreenPosition(entity, centerX, bottomY, z, width, height, xOffset, yOffset);
+		setRoomActorScreenPosition(entity, centerX, bottomY, renderZ, width, height, xOffset, yOffset);
 	}
 
 	if (entranceName) {


Commit: ec65f38aeb8fe33b879810fd6948eaae9d9483d4
    https://github.com/scummvm/scummvm/commit/ec65f38aeb8fe33b879810fd6948eaae9d9483d4
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:14+02:00

Commit Message:
HARVESTER: Add scene debug logging for Karin layering

Changed paths:
    TRACKER.md
    engines/harvester/flow.cpp
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h


diff --git a/TRACKER.md b/TRACKER.md
index 0351c40e66b..33076ffead0 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,10 +10,11 @@
 
 ## Next Suggested Action
 
-- Run an in-engine Wednesday cemetery validation pass after the room-actor render-depth fix: confirm `PLACE_KARIN_TIMER` now leaves `KARIN_CEM10` fully in front of the `CEM10_OBJ5` grave mask, confirm talking to her still routes through the Karin handler without `unsupported room NPC dialogue handler`, then spot-check another heavy-occlusion room to make sure the shared `z_extent / 2` actor-anchor correction did not regress player or monster layering.
+- Re-run the Wednesday cemetery dig-up path with the new scene-debug logs enabled and capture the fresh `actor placement` plus `scene render order` lines for `KARIN_CEM10` and `CEM10_OBJ5`. Those lines now expose the missing evidence from the earlier repro: Karin's actual `z_extent`, computed render Z, current frame, frame metrics, final screen rect, and whether the final render list really places her after the grave mask. If Karin is still visually wrong after that richer log, compare the logged frame rect against the native ambient-frame selection before changing depth math again.
 
 ## Last Confirmed Action
 
+- On March 24, 2026, added targeted room-scene debuggability for the cemetery Karin regression in [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp), [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp), and [`engines/harvester/runtime_entity.h`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h). The latest CEM10 logs prove the current code is applying the actor-depth anchor correction (`KARIN_CEM10` materializes at record `z=16` but the spawned scene NPC lands at `z=15.00`), but they still do not reveal whether the remaining bug is wrong `z_extent`, wrong render ordering, or the wrong ambient frame/rect. The new logs therefore dump per-actor placement inputs and outputs (`source z`, computed `render_z`, `z_extent`, depth scale, current frame, frame metrics, final rect) and the final room render-list order after scene construction so the next repro can distinguish depth from placement/frame issues directly. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, corrected ScummVM's room-actor render-depth anchor in [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp) to match the native `spawn_npc_entity_from_record` and `spawn_monster_entity_from_record` paths. Native disassembly at `0x4ce00..0x4ce43` and `0x540b0..0x540f3` shows both actor spawners subtracting half `z_extent` from the record Z before `set_entity_screen_position`; ScummVM was leaving actors at the raw record Z instead, which put Wednesday cemetery Karin (`KARIN_CEM10`, `posZ=16`) one layer behind `CEM10_OBJ5` (`z=15`) in CEM10. The shared room-actor placement helper now applies that native `z_extent / 2` offset before depth scaling and insertion for room NPCs, monsters, and the player. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, patched the ScummVM Karin cemetery dialogue routing so [`engines/harvester/npc/karin_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/karin_dialogue.cpp) now accepts both `KARIN` and `KARIN_CEM10`. This fixes the confirmed Wednesday grave-rescue regression where clicking Karin after `PLACE_KARIN_TIMER` fell through to `unsupported room NPC dialogue handler 'KARIN_CEM10'` instead of reusing the native Karin talk logic already implemented for `handle_talk_to_karin`. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, reimplemented the ScummVM Johnson dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_johnson` (`0x27dd0..0x28802`) as a fresh pass rather than trusting the older port. The fresh native audit found one remaining source drift in [`engines/harvester/npc/johnson_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/johnson_dialogue.cpp): Johnson's Whaley-photo, casket-photo, dual-ledger, note/checkbook, and TV-deed item branches call the shared `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_note_checkbook_evidence`, and `get_set_discussed_tv_deed_evidence` accessors in the live handler, so the port now mirrors that by writing `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedLedgerEvidence`, `sharedState.discussedNoteCheckbookEvidence`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script evidence flags. Re-auditing the repaired intro/revisit flow, the `OPENING_MANHOLE_DAYTIME` clear, the `SCRATCHED_TUCKER` plus `BOLT_OF_CLOTH_TAKEN` re-entry, the `KARIN_KIDNAPED` shared-state write, and the recovered keyword loop confirmed the current C++ control flow already matches the saved native handler there, so no further source drift was found. The direct live callee set is already fully named (`get_set_shared_karin_kidnaped_dialogue_state`, `get_set_shared_dialogue_state_d2ebc`, the shared evidence accessors, and the standard dialogue helpers), so no additional `FUN_*` rename was justified. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index eeb4317ca93..0b47a1d2f68 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -329,6 +329,23 @@ static float computeRoomActorRenderZ(float z, const RuntimeEntity &entity) {
 	return z - floorf(MAX<float>(entity.getZExtent(), 0.0f) * 0.5f);
 }
 
+static void logRoomActorPlacement(const RuntimeEntity &entity, int centerX, int bottomY,
+		float sourceZ, float renderZ, float depthScale, bool applyDepthScale) {
+	int width = 0;
+	int height = 0;
+	int xOffset = 0;
+	int yOffset = 0;
+	const bool hasMetrics = entity.getCurrentFrameMetrics(width, height, xOffset, yOffset);
+	const Common::Rect rect = entity.getScreenRect();
+	debugC(1, kDebugScene,
+		"Harvester: actor placement entity='%s' class=0x%x source=(center=%d,bottom=%d,z=%.2f) render_z=%.2f z_extent=%.2f apply_depth=%d depth_scale=%.3f pos=(%d,%d,z=%.2f) frame=%d rect=(%d,%d)-(%d,%d) metrics=%s size=%dx%d offsets=(%d,%d)",
+		entity.getName().c_str(), entity.getClassId(), centerX, bottomY, (double)sourceZ, (double)renderZ,
+		(double)entity.getZExtent(), applyDepthScale, (double)depthScale,
+		entity.getX(), entity.getY(), (double)entity.getZ(), entity.getCurrentFrame(),
+		rect.left, rect.top, rect.right, rect.bottom,
+		hasMetrics ? "frame" : "none", width, height, xOffset, yOffset);
+}
+
 static bool applyRoomActorPlacementInternal(const StartupRoomSetupState &state, RuntimeEntity &entity,
 		int centerX, int bottomY, float z, const Common::String *entranceName, bool applyDepthScale) {
 	int width = 0;
@@ -357,6 +374,7 @@ static bool applyRoomActorPlacementInternal(const StartupRoomSetupState &state,
 			entranceName->c_str(), centerX, bottomY, roundToInt(z),
 			entity.getX(), entity.getY(), width, height, xOffset, yOffset, (double)depthScale);
 	}
+	logRoomActorPlacement(entity, centerX, bottomY, z, renderZ, depthScale, applyDepthScale);
 	return true;
 }
 
@@ -1797,6 +1815,7 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 		delete preservedPlayer;
 	}
 
+	runtimeEntities->logSceneEntityOrder(state.roomName.c_str());
 	return true;
 }
 
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index fb436ef760e..ed8ae4fa412 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -923,6 +923,27 @@ void RuntimeEntityManager::drawSceneEntities(Graphics::Screen &screen) const {
 		entity->draw(screen);
 }
 
+void RuntimeEntityManager::logSceneEntityOrder(const char *label) const {
+	for (uint i = 0; i < _sceneEntities.size(); ++i) {
+		const RuntimeEntity *entity = _sceneEntities[i];
+		const Common::Rect rect = entity->getScreenRect();
+		int width = 0;
+		int height = 0;
+		int xOffset = 0;
+		int yOffset = 0;
+		const bool hasMetrics = entity->getCurrentFrameMetrics(width, height, xOffset, yOffset);
+		debugC(1, kDebugScene,
+			"Harvester: scene render order label='%s' index=%u/%u name='%s' class=0x%x visible=%d pos=(%d,%d,z=%.2f) z_extent=%.2f depth_scale=%.3f frame=%d rect=(%d,%d)-(%d,%d) metrics=%s size=%dx%d offsets=(%d,%d)",
+			label ? label : "", i, (uint)_sceneEntities.size(),
+			entity->getName().c_str(), entity->getClassId(), entity->isVisible(),
+			entity->getX(), entity->getY(), (double)entity->getZ(), (double)entity->getZExtent(),
+			(double)entity->getDepthScale(), entity->getCurrentFrame(),
+			rect.left, rect.top, rect.right, rect.bottom,
+			hasMetrics ? "frame" : "none",
+			width, height, xOffset, yOffset);
+	}
+}
+
 const RuntimeEntity *RuntimeEntityManager::findTopSceneEntityAt(const Common::Point &point, int classIdFilter) const {
 	for (int i = (int)_sceneEntities.size() - 1; i >= 0; --i) {
 		const RuntimeEntity *entity = _sceneEntities[(uint)i];
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index 542e4516db5..396872bafb0 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -103,6 +103,7 @@ public:
 	int getBoundsHeight() const { return _boundsHeight; }
 	bool getCurrentFrameMetrics(int &width, int &height, int &xOffset, int &yOffset) const;
 	void setDepthScale(float scale);
+	float getDepthScale() const { return _depthScale; }
 	Common::Rect getScreenRect() const;
 	void configureTimerCountdown(int initialValue, int currentValue, bool enabled, bool looping, bool global);
 	bool tickTimerState(uint32 now, Common::Array<Common::String> &expiredTimerNames);
@@ -201,6 +202,7 @@ public:
 	bool tickSceneEntities();
 	bool syncCursorEntityPosition(const Common::Point &position);
 	void drawSceneEntities(Graphics::Screen &screen) const;
+	void logSceneEntityOrder(const char *label) const;
 	void drawCursor(Graphics::Screen &screen) const;
 	const RuntimeEntity *findTopSceneEntityAt(const Common::Point &point, int classIdFilter = -1) const;
 	const RuntimeEntity *findSceneEntityByName(const Common::String &name) const;


Commit: 2c387ca242b5f5b4e8e11e98aa17a20e74ca5ffa
    https://github.com/scummvm/scummvm/commit/2c387ca242b5f5b4e8e11e98aa17a20e74ca5ffa
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:15+02:00

Commit Message:
HARVESTER: Reinsert room actors after placement

Changed paths:
    TRACKER.md
    engines/harvester/flow.cpp
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h


diff --git a/TRACKER.md b/TRACKER.md
index 33076ffead0..fa1d82be449 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,10 +10,11 @@
 
 ## Next Suggested Action
 
-- Re-run the Wednesday cemetery dig-up path with the new scene-debug logs enabled and capture the fresh `actor placement` plus `scene render order` lines for `KARIN_CEM10` and `CEM10_OBJ5`. Those lines now expose the missing evidence from the earlier repro: Karin's actual `z_extent`, computed render Z, current frame, frame metrics, final screen rect, and whether the final render list really places her after the grave mask. If Karin is still visually wrong after that richer log, compare the logged frame rect against the native ambient-frame selection before changing depth math again.
+- Re-run the Wednesday cemetery dig-up path and capture the fresh `scene render order` lines for `KARIN_CEM10` and `CEM10_OBJ5`. The latest logs proved the real bug was stale render-list ordering: ScummVM inserted actor entities at their pre-placement record Z and never re-sorted them after placement changed the final render Z. Confirm Karin now appears later than `CEM10_OBJ5` in the render list and on screen. If she still looks wrong after that reorder fix, the remaining evidence points at ambient frame selection or frame geometry rather than `z_extent`.
 
 ## Last Confirmed Action
 
+- On March 24, 2026, used the new cemetery debug logs to isolate the remaining Karin layering bug to stale render-list ordering rather than bad `z_extent` math. The captured CEM10 repro showed `spawnSceneActorEntity` inserting `KARIN_CEM10` into the render list at the pre-placement record Z (`16`) while `actor placement` later lowered her final Z to `15.00`; because ScummVM never re-sorted the entity after placement, the final `scene render order` still kept Karin ahead of `CEM10_OBJ5`, which is why the grave mask continued to draw over her even though the placement log already showed the corrected render Z. [`engines/harvester/runtime_entity.h`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h) and [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp) now expose `reinsertSceneEntity`, and [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp) now re-sorts room NPCs, monsters, and newly spawned player actors immediately after placement. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, added targeted room-scene debuggability for the cemetery Karin regression in [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp), [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp), and [`engines/harvester/runtime_entity.h`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h). The latest CEM10 logs prove the current code is applying the actor-depth anchor correction (`KARIN_CEM10` materializes at record `z=16` but the spawned scene NPC lands at `z=15.00`), but they still do not reveal whether the remaining bug is wrong `z_extent`, wrong render ordering, or the wrong ambient frame/rect. The new logs therefore dump per-actor placement inputs and outputs (`source z`, computed `render_z`, `z_extent`, depth scale, current frame, frame metrics, final rect) and the final room render-list order after scene construction so the next repro can distinguish depth from placement/frame issues directly. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, corrected ScummVM's room-actor render-depth anchor in [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp) to match the native `spawn_npc_entity_from_record` and `spawn_monster_entity_from_record` paths. Native disassembly at `0x4ce00..0x4ce43` and `0x540b0..0x540f3` shows both actor spawners subtracting half `z_extent` from the record Z before `set_entity_screen_position`; ScummVM was leaving actors at the raw record Z instead, which put Wednesday cemetery Karin (`KARIN_CEM10`, `posZ=16`) one layer behind `CEM10_OBJ5` (`z=15`) in CEM10. The shared room-actor placement helper now applies that native `z_extent / 2` offset before depth scaling and insertion for room NPCs, monsters, and the player. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, patched the ScummVM Karin cemetery dialogue routing so [`engines/harvester/npc/karin_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/karin_dialogue.cpp) now accepts both `KARIN` and `KARIN_CEM10`. This fixes the confirmed Wednesday grave-rescue regression where clicking Karin after `PLACE_KARIN_TIMER` fell through to `unsupported room NPC dialogue handler 'KARIN_CEM10'` instead of reusing the native Karin talk logic already implemented for `handle_talk_to_karin`. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 0b47a1d2f68..8408a3d3292 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -1735,6 +1735,7 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 			debug(1, "Harvester: unable to apply room npc placement for '%s'",
 				npc.npcName.c_str());
 		}
+		runtimeEntities->reinsertSceneEntity(entity);
 		debugC(1, kDebugScene,
 			"Harvester: scene npc spawned room='%s' npc='%s' class=0x%x pos=(%d,%d,z=%.2f) frame_delay=%d model='%s' active=%d visible=%d",
 			state.roomName.c_str(), npc.npcName.c_str(), entity->getClassId(),
@@ -1758,6 +1759,7 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 			debug(1, "Harvester: unable to apply room monster placement for '%s'",
 				monster.monsterName.c_str());
 		}
+		runtimeEntities->reinsertSceneEntity(entity);
 		debugC(1, kDebugScene,
 			"Harvester: scene monster spawned room='%s' monster='%s' class=0x%x pos=(%d,%d,z=%.2f) facing=%d hp=%d/%d model='%s' active=%d visible=%d",
 			state.roomName.c_str(), monster.monsterName.c_str(), entity->getClassId(),
@@ -1804,6 +1806,8 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 			}
 			if (reusedPlayer)
 				runtimeEntities->adoptSceneEntity(player);
+			else
+				runtimeEntities->reinsertSceneEntity(player);
 			debugC(1, kDebugGeneral,
 				"Harvester: %s startup player actor '%s' entrance='%s' pos=(%d,%d,%d) facing=%d frame=%d",
 				reusedPlayer ? "reused" : "spawned", playerResourcePath.c_str(), state.entranceName.c_str(),
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index ed8ae4fa412..1dbf7cf811e 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -994,6 +994,21 @@ void RuntimeEntityManager::adoptSceneEntity(RuntimeEntity *entity) {
 	insertSceneEntity(entity);
 }
 
+void RuntimeEntityManager::reinsertSceneEntity(RuntimeEntity *entity) {
+	if (!entity)
+		return;
+
+	for (uint i = 0; i < _sceneEntities.size(); ++i) {
+		if (_sceneEntities[i] != entity)
+			continue;
+
+		_sceneEntities.remove_at(i);
+		break;
+	}
+
+	insertSceneEntity(entity);
+}
+
 void RuntimeEntityManager::insertSceneEntity(RuntimeEntity *entity) {
 	uint insertIndex = 0;
 	while (insertIndex < _sceneEntities.size() && _sceneEntities[insertIndex]->getZ() >= entity->getZ())
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index 396872bafb0..b02a1cbbef6 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -209,6 +209,7 @@ public:
 	RuntimeEntity *findSceneEntityByName(const Common::String &name);
 	RuntimeEntity *detachSceneEntityByName(const Common::String &name);
 	void adoptSceneEntity(RuntimeEntity *entity);
+	void reinsertSceneEntity(RuntimeEntity *entity);
 
 private:
 	void insertSceneEntity(RuntimeEntity *entity);


Commit: 768b123451b80ef0f39040440c98073000dcdbf4
    https://github.com/scummvm/scummvm/commit/768b123451b80ef0f39040440c98073000dcdbf4
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:15+02:00

Commit Message:
HARVESTER: Finalize room anim drift fix

Changed paths:
    TRACKER.md
    engines/harvester/flow.cpp
    engines/harvester/room.cpp
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h
    engines/harvester/script.cpp


diff --git a/TRACKER.md b/TRACKER.md
index fa1d82be449..34b2e86f647 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,17 +3,29 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `914`
-- Named/documented: `802`
+- Total functions: `912`
+- Named/documented: `800`
 - Remaining `FUN_*`: `112`
 - Remaining undocumented total: `112`
 
 ## Next Suggested Action
 
-- Re-run the Wednesday cemetery dig-up path and capture the fresh `scene render order` lines for `KARIN_CEM10` and `CEM10_OBJ5`. The latest logs proved the real bug was stale render-list ordering: ScummVM inserted actor entities at their pre-placement record Z and never re-sorted them after placement changed the final render Z. Confirm Karin now appears later than `CEM10_OBJ5` in the render list and on screen. If she still looks wrong after that reorder fix, the remaining evidence points at ambient frame selection or frame geometry rather than `z_extent`.
+- No active engine follow-up is required for the sheriff/service/school door drift fix. If another report appears, rerun those door interactions on a clean build first and compare the visible door layer rather than reintroducing temporary probes.
 
 ## Last Confirmed Action
 
+- On March 25, 2026, removed the temporary Harvester investigation probes from [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp), [`engines/harvester/runtime_entity.h`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp) after confirming the centered room-anim drift fix. Removed the door geometry/frame/draw traces, room click selection probes, actor-placement/render-order dumps, and the now-unused helper they depended on, while keeping the behavioral fixes for same-room live mutations, draw-order-based hotspot resolution, `SET_ANIM` hidden→visible runtime-state reset, and the cached centered screen base for room anims. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `912` total functions, `800` named/documented, and `112` remaining `FUN_*`.
+- On March 25, 2026, fixed a centered room-anim drift bug in [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp) after reconciling the latest sheriff screenshot with native `show_entity_visual` (`0x4c290`). The decisive native detail is that the centered branch calls `set_entity_screen_position` only on the initial show path (`field_0x4f == 0`); later frame advances in `advance_entity_animation_frame` (`0x4c8a0`) update `frame_x_offset/frame_y_offset` and call `show_entity_visual`, but do not recenter from the new frame width each draw. ScummVM now mirrors that by caching a centered anim's initial top-left screen base in `RuntimeEntity` and reusing it across frame advances instead of recomputing `_x - frame.width / 2` every draw. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `912` total functions, `800` named/documented, and `112` remaining `FUN_*`.
+- On March 25, 2026, closed the sheriff-office first-draw timing branch. The new `door anim draw trace` probe in [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp) showed that ScummVM does not draw `SHER_EVIDDOOR` frame `0`; the first actual draw is already frame `1`, then frame `2`. Ghidra confirms native uses the same ordering in `run_harvester_main_loop` (`0x6dc70`): it dispatches `handle_target_interaction()` at `LAB_0006f996`, then immediately walks the live render list at `LAB_0006fa76`, where class-1 room anims call `tick_entity_visual_state()` before the frame is flushed. Combined with the already-correct post-interaction selection probe and doorway geometry logs, this means the sheriff-office mismatch is no longer supported by click ownership, anim anchor, or first-draw timing evidence on the current build. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `912` total functions, `800` named/documented, and `112` remaining `FUN_*`.
+- On March 25, 2026, added a narrow room-anim geometry probe for the affected door visuals in [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp) and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) after the previous native placement patch produced no obvious visual change. The new logs emit the live `pos`, `frame`, `rect`, and frame-header `offsets` for `SHER_EVIDDOOR`, `SERV_EVIDDOOR`, `SCHDOOR`, and `WAYLHERL` at spawn and in-place update time so the next repro can answer a concrete question: whether the patched centered-anim geometry is actually reaching the live entity. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
+- On March 25, 2026, confirmed in Ghidra that native room ANIM placement was still mismatched in ScummVM even after the same-room live-mutation work. `spawn_anim_entity_from_record` (`0x4bbe0`) seeds `z_extent = 3`, and `show_entity_visual` (`0x4c290`) recenters the current decoded bitmap on the anim record's `x/y` pivot while lowering the render-list depth anchor by half `z_extent`; the disassembly around `0x4c548..0x4c595` shows it subtracting half the current bitmap width and height from the stored screen anchor before `set_entity_screen_position`, without adding the frame header `x_offset/y_offset` to that centered anchor. ScummVM now matches that in [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp) by making centered room anims ignore ABM frame offsets in `getDrawOrigin()` and by lowering scene-animation Z by half `z_extent` when spawning, while the same-room live mutation path in [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) now reapplies that native render Z when updating existing room anim entities in place. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
+- On March 25, 2026, widened ScummVM's live-mutation path in [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so every same-room `mutatedRuntimeState` interaction now tries to patch the current room in place before falling back to `refreshCurrentScene(true)`. This closes the gap the sheriff-office `EVKEY -> EVD_DOORHS` log exposed: that path had no `GOFLIC`, so the earlier cutscene-only fix still rebuilt the whole `SHRFOFC` room after `SET_ANIM SHER_EVIDDOOR,T,T`. The helper now reuses the same native-style live update rules for room objects, regions, timers, and room anims across all same-room mutations, while still bailing out conservatively when NPC or monster records are involved because those live semantics are not yet audited. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
+- On March 25, 2026, removed the temporary broom-door probes from [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) and replaced the same-room cutscene pre-refresh with a narrower live mutation path. Native `dispatch_room_event_actions` case `SET_ANIM` at `0x61f55..0x62056` preserves already-visible anim entities, resets saved frame state only on hidden→visible transitions, and spawns or destroys the current-room entity in place without rebuilding the room; ScummVM now mirrors that same-room `GOFLIC` boundary more closely by patching current-room objects, regions, and anims in place before the FST instead of materializing and respawning the whole scene. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
+- On March 25, 2026, reran `BROOMKEY -> SCHL_DOOR_HS` in `SCHLHL` after the same-room `GOFLIC` timing patch and confirmed the new control flow now matches the native boundary we audited earlier. The fresh probe sequence shows `interaction-pre-cutscene-refresh`, a single pre-FST `refreshCurrentScene()` that materializes and spawns both `SCHDOOR` and `WAYLHERL`, then `before-cutscene` and `after-cutscene` with those same two live anims still present and no second room rebuild after `C132.FST`. The post-cutscene frame trace still shows `SCHDOOR` and `WAYLHERL` advancing together from frames `1..4`, but that now happens on the same live entities that were already present before the movie rather than on freshly respawned post-cutscene anims. This closes the refresh-timing mismatch: ScummVM now keeps same-room anim mutations live across the FST boundary instead of replaying them after the cutscene. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
+- On March 25, 2026, updated ScummVM's same-room cutscene handling in [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so `handleInteractionResult()` now applies `mutatedRuntimeState` to the live room before playing a same-room `GOFLIC`, instead of deferring a synthetic `refreshCurrentScene()` until after the movie returns. The new control flow refreshes immediately for same-room runtime mutations, keeps room-transition behavior unchanged, and leaves the targeted broom-door probes active long enough to verify that `WAYLHERL` is already live before `C132.FST` and that no second post-cutscene room rebuild occurs. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
+- On March 25, 2026, compared the native same-room `GOFLIC` path against the ScummVM broom-door traces in live `HARVEST.LE` disassembly. Three native facts now close the loop. First, `dispatch_room_event_actions` case `SET_ANIM` (`0x60ee0`) updates the `AnimRecord` and, if the anim belongs to the current room, immediately destroys or spawns the live render entity; for `USE_BROOMKEY`, that means native spawns `WAYLHERL` as soon as step `7` runs, before `GOFLIC`. Second, `play_fst_sequence` (`0x72550`) only pauses music, swaps to the `320x200` movie surface, runs `run_fst_sequence_player`, then restores the gameplay surface and palette; it does not call `room_setup()` or rebuild the room. Third, `run_harvester_main_loop` only transitions through `room_setup()` when `g_pending_room_name` is queued, while same-room object/use actions just return to the live loop after `dispatch_room_event_actions`. Combined with `room_setup()`'s existing frame-snapshot logic for live anims, that proves the current ScummVM behavior is wrong at the boundary: deferring the broom-door `SET_ANIM` mutations until after `C132.FST` suppresses native pre-cutscene `WAYLHERL` startup and then incorrectly restarts both `SCHDOOR` and `WAYLHERL` from frame `0` in a synthetic room rebuild. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
+- On March 25, 2026, added targeted ScummVM broom-door refresh and frame-trace probes in [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) and replayed `BROOMKEY -> SCHL_DOOR_HS` in `SCHLHL`. The new logs confirmed three facts. First, deferred refresh timing is real but not the visual bug: before and after `GOFLIC GRAPHIC/FST/C132.FST`, the only live door anim is `SCHDOOR`; `SCHDOOR_R` is already gone, so there is no stale reverse-door overlap. Second, `materializeRoomState()` rebuilds the room with `SCHDOOR` and `WAYLHERL` both active and visible, and `refreshCurrentScene()` respawns both live entities. Third, the new post-refresh frame trace shows `SCHDOOR` and `WAYLHERL` both starting at frame `0` and advancing in lockstep over the next `20` centisecond samples (`SCHDOOR` frames `1..4`, `WAYLHERL` frames `1..4`), which means ScummVM is replaying two distinct post-cutscene room anims together rather than accidentally drawing mirrored forward/reverse door variants. The remaining unknown is native intent after the FST: whether both anims should restart from frame `0` on room rebuild or whether one should preserve a later frame / disabled state. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
+- On March 25, 2026, audited the school hallway broom-closet door in both the decoded shipping [`HARVEST.SCR`](/Users/alex/Downloads/Harvester_1996/HARVEST/iso/Harvester/HARVEST.SCR) data and live `HARVEST.LE` disassembly. Confirmed the room defines two `ANIM` records over the same `1:\GRAPHIC\ROOMANIM\HALLDOOR.ABM` asset and pivot `(261,276,71)`: `SCHDOOR` is the forward/open variant, while `SCHDOOR_R` differs only by `backward = T`. Native `dispatch_room_event_actions` case `SET_ANIM` updates the anim record, destroys the current-room render entity when visibility goes false, resets the saved frame when a hidden anim becomes visible, and `spawn_anim_entity_from_record` starts reverse anims from `last_frame_index`; together that means the `USE_BROOMKEY` chain is intentionally clearing `SCHDOOR_R` before activating `SCHDOOR`, so the original game should never keep both door variants live at once. No rename or engine patch was justified from this pass, and progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, used the new cemetery debug logs to isolate the remaining Karin layering bug to stale render-list ordering rather than bad `z_extent` math. The captured CEM10 repro showed `spawnSceneActorEntity` inserting `KARIN_CEM10` into the render list at the pre-placement record Z (`16`) while `actor placement` later lowered her final Z to `15.00`; because ScummVM never re-sorted the entity after placement, the final `scene render order` still kept Karin ahead of `CEM10_OBJ5`, which is why the grave mask continued to draw over her even though the placement log already showed the corrected render Z. [`engines/harvester/runtime_entity.h`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h) and [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp) now expose `reinsertSceneEntity`, and [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp) now re-sorts room NPCs, monsters, and newly spawned player actors immediately after placement. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, added targeted room-scene debuggability for the cemetery Karin regression in [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp), [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp), and [`engines/harvester/runtime_entity.h`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h). The latest CEM10 logs prove the current code is applying the actor-depth anchor correction (`KARIN_CEM10` materializes at record `z=16` but the spawned scene NPC lands at `z=15.00`), but they still do not reveal whether the remaining bug is wrong `z_extent`, wrong render ordering, or the wrong ambient frame/rect. The new logs therefore dump per-actor placement inputs and outputs (`source z`, computed `render_z`, `z_extent`, depth scale, current frame, frame metrics, final rect) and the final room render-list order after scene construction so the next repro can distinguish depth from placement/frame issues directly. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 24, 2026, corrected ScummVM's room-actor render-depth anchor in [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp) to match the native `spawn_npc_entity_from_record` and `spawn_monster_entity_from_record` paths. Native disassembly at `0x4ce00..0x4ce43` and `0x540b0..0x540f3` shows both actor spawners subtracting half `z_extent` from the record Z before `set_entity_screen_position`; ScummVM was leaving actors at the raw record Z instead, which put Wednesday cemetery Karin (`KARIN_CEM10`, `posZ=16`) one layer behind `CEM10_OBJ5` (`z=15`) in CEM10. The shared room-actor placement helper now applies that native `z_extent / 2` offset before depth scaling and insertion for room NPCs, monsters, and the player. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 8408a3d3292..9eba1f2194f 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -106,10 +106,6 @@ static const int kCursorSequenceTransition = 6;
 static const int kCursorSequenceNeutral = 7;
 } // End of anonymous namespace
 
-static int roundToInt(float value) {
-	return value >= 0.0f ? (int)floorf(value + 0.5f) : (int)ceilf(value - 0.5f);
-}
-
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
 	const Text *startupText = engine.getStartupText();
 	if (!startupText || !fontName)
@@ -329,23 +325,6 @@ static float computeRoomActorRenderZ(float z, const RuntimeEntity &entity) {
 	return z - floorf(MAX<float>(entity.getZExtent(), 0.0f) * 0.5f);
 }
 
-static void logRoomActorPlacement(const RuntimeEntity &entity, int centerX, int bottomY,
-		float sourceZ, float renderZ, float depthScale, bool applyDepthScale) {
-	int width = 0;
-	int height = 0;
-	int xOffset = 0;
-	int yOffset = 0;
-	const bool hasMetrics = entity.getCurrentFrameMetrics(width, height, xOffset, yOffset);
-	const Common::Rect rect = entity.getScreenRect();
-	debugC(1, kDebugScene,
-		"Harvester: actor placement entity='%s' class=0x%x source=(center=%d,bottom=%d,z=%.2f) render_z=%.2f z_extent=%.2f apply_depth=%d depth_scale=%.3f pos=(%d,%d,z=%.2f) frame=%d rect=(%d,%d)-(%d,%d) metrics=%s size=%dx%d offsets=(%d,%d)",
-		entity.getName().c_str(), entity.getClassId(), centerX, bottomY, (double)sourceZ, (double)renderZ,
-		(double)entity.getZExtent(), applyDepthScale, (double)depthScale,
-		entity.getX(), entity.getY(), (double)entity.getZ(), entity.getCurrentFrame(),
-		rect.left, rect.top, rect.right, rect.bottom,
-		hasMetrics ? "frame" : "none", width, height, xOffset, yOffset);
-}
-
 static bool applyRoomActorPlacementInternal(const StartupRoomSetupState &state, RuntimeEntity &entity,
 		int centerX, int bottomY, float z, const Common::String *entranceName, bool applyDepthScale) {
 	int width = 0;
@@ -368,13 +347,6 @@ static bool applyRoomActorPlacementInternal(const StartupRoomSetupState &state,
 		setRoomActorScreenPosition(entity, centerX, bottomY, renderZ, width, height, xOffset, yOffset);
 	}
 
-	if (entranceName) {
-		debugC(1, kDebugGeneral,
-			"Harvester: startup player placement entrance='%s' spawn=(%d,%d,%d) screen_base=(%d,%d) size=%dx%d offsets=(%d,%d) scale=%.3f",
-			entranceName->c_str(), centerX, bottomY, roundToInt(z),
-			entity.getX(), entity.getY(), width, height, xOffset, yOffset, (double)depthScale);
-	}
-	logRoomActorPlacement(entity, centerX, bottomY, z, renderZ, depthScale, applyDepthScale);
 	return true;
 }
 
@@ -710,6 +682,7 @@ static const StartupObjectRecord *findRoomObjectAtPoint(HarvesterEngine &engine,
 		return nullptr;
 
 	const RuntimeEntity *topEntity = nullptr;
+	int topDrawIndex = -1;
 	for (const StartupObjectRecord &object : sceneObjects) {
 		if (object.objectName.empty())
 			continue;
@@ -722,8 +695,13 @@ static const StartupObjectRecord *findRoomObjectAtPoint(HarvesterEngine &engine,
 			entity->getClassId() == kRuntimeEntityClassRectHotspot19) {
 			continue;
 		}
-		if (!topEntity || entity->getZ() <= topEntity->getZ())
+		const int drawIndex = runtimeEntities->findSceneEntityDrawIndexByName(entity->getName());
+		if (drawIndex < 0)
+			continue;
+		if (!topEntity || drawIndex > topDrawIndex) {
 			topEntity = entity;
+			topDrawIndex = drawIndex;
+		}
 	}
 	if (!topEntity)
 		return nullptr;
@@ -738,14 +716,20 @@ static const StartupRegionRecord *findRoomRegionAtPoint(HarvesterEngine &engine,
 		return nullptr;
 
 	const RuntimeEntity *topEntity = nullptr;
+	int topDrawIndex = -1;
 	for (const StartupRegionRecord &region : sceneRegions) {
 		const RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(region.regionName);
 		if (!entity || !entity->hitTest(point))
 			continue;
 		if (entity->getClassId() != kRuntimeEntityClassRectHotspot19)
 			continue;
-		if (!topEntity || entity->getZ() <= topEntity->getZ())
+		const int drawIndex = runtimeEntities->findSceneEntityDrawIndexByName(entity->getName());
+		if (drawIndex < 0)
+			continue;
+		if (!topEntity || drawIndex > topDrawIndex) {
 			topEntity = entity;
+			topDrawIndex = drawIndex;
+		}
 	}
 	if (!topEntity)
 		return nullptr;
@@ -896,6 +880,7 @@ StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const Start
 		const Common::Array<StartupNpcRecord> &sceneNpcs,
 		const Common::Array<StartupRegionRecord> &sceneRegions, const Common::Point &mousePos) {
 	StartupRoomHoverState hoverState;
+	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
 	if (const RuntimeEntity *playerEntity = findRoomPlayerAtPoint(engine, mousePos)) {
 		hoverState.playerEntity = playerEntity;
 		hoverState.cursorSequence = kCursorSequenceExamine;
@@ -913,6 +898,14 @@ StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const Start
 		return hoverState;
 	hoverState.object = findRoomObjectAtPoint(engine, sceneObjects, mousePos);
 	hoverState.region = findRoomRegionAtPoint(engine, sceneRegions, mousePos);
+	if (runtimeEntities && hoverState.object && hoverState.region) {
+		const int objectDrawIndex =
+			runtimeEntities->findSceneEntityDrawIndexByName(hoverState.object->objectName);
+		const int regionDrawIndex =
+			runtimeEntities->findSceneEntityDrawIndexByName(hoverState.region->regionName);
+		if (regionDrawIndex >= 0 && objectDrawIndex >= 0 && regionDrawIndex > objectDrawIndex)
+			hoverState.object = nullptr;
+	}
 	if (hoverState.object) {
 		hoverState.cursorSequence = resolveRoomObjectCursorSequence(*hoverState.object, *startupScript);
 		hoverState.promptText = buildRoomObjectPrompt(*hoverState.object, *startupScript, hoverState.cursorSequence);
@@ -1819,7 +1812,6 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 		delete preservedPlayer;
 	}
 
-	runtimeEntities->logSceneEntityOrder(state.roomName.c_str());
 	return true;
 }
 
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index ec91213ae5b..be6754582d9 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -701,11 +701,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			if (ownerOrRoom)
 				sceneObject->currentOwnerOrRoom = *ownerOrRoom;
 		};
-		auto removeSceneObjectEntity = [&](const Common::String &objectName) {
+		auto removeSceneEntityByName = [&](const Common::String &entityName) {
 			if (!runtimeEntities)
 				return;
 
-			RuntimeEntity *entity = runtimeEntities->detachSceneEntityByName(objectName);
+			RuntimeEntity *entity = runtimeEntities->detachSceneEntityByName(entityName);
 			delete entity;
 		};
 		auto resetIdleState = [&]() {
@@ -774,6 +774,276 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				playerState.centerX, playerState.bottomY, (int)playerState.z, facing,
 				_engine.getStartupMusicPath());
 		};
+		auto getSceneObjectBounds = [&](const StartupObjectRecord &object) {
+			if (object.boundsX2 > object.currentX && object.boundsY2 > object.currentY)
+				return Common::Rect(object.currentX, object.currentY, object.boundsX2 + 1, object.boundsY2 + 1);
+			return Common::Rect();
+		};
+		auto resolveSceneObjectSpritePathLocal = [&](const StartupObjectRecord &object) {
+			const bool atInitialPlacement = object.currentX == object.initialX &&
+				object.currentY == object.initialY &&
+				object.currentOwnerOrRoom.equalsIgnoreCase(object.initialOwnerOrRoom);
+			if (!object.altSpritePath.empty() &&
+					(!atInitialPlacement || object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)))
+				return object.altSpritePath;
+			return object.spritePath;
+		};
+		auto isInteractiveSceneHotspot = [&](const StartupObjectRecord &object) {
+			if (object.operatable || !object.actionTag.empty())
+				return true;
+
+			Script *startupScript = _engine.getStartupScript();
+			if (!startupScript)
+				return false;
+			if (startupScript->isPickupObject(object))
+				return true;
+
+			StartupResolvedText inspectText;
+			return startupScript->resolveObjectInspectText(object, inspectText);
+		};
+		auto resolveSceneObjectClassLocal = [&](const StartupObjectRecord &object,
+				const RuntimeEntity *entity) {
+			if (entity) {
+				return object.initialX == 0 && object.initialY == 0 &&
+					entity->getBoundsWidth() == 640 && entity->getBoundsHeight() == 480
+					? kRuntimeEntityClassBackground
+					: kRuntimeEntityClassObject;
+			}
+
+			return isInteractiveSceneHotspot(object)
+				? kRuntimeEntityClassRectHotspot
+				: kRuntimeEntityClassDisabledHotspot;
+		};
+		auto sameRect = [](const Common::Rect &lhs, const Common::Rect &rhs) {
+			return lhs.left == rhs.left && lhs.top == rhs.top &&
+				lhs.right == rhs.right && lhs.bottom == rhs.bottom;
+		};
+		auto spawnSceneObjectEntityFromRecord = [&](const StartupObjectRecord &object) {
+			if (!runtimeEntities)
+				return;
+
+			const Common::String spritePath = resolveSceneObjectSpritePathLocal(object);
+			const Common::Rect hotspotBounds = getSceneObjectBounds(object);
+			RuntimeEntity *entity = nullptr;
+			if (!spritePath.empty() && spritePath.hasSuffixIgnoreCase(".BM")) {
+				entity = runtimeEntities->spawnSceneBitmapEntity(object.objectName, spritePath,
+					Common::Point(object.currentX, object.currentY), (float)object.currentZ);
+			} else if (!hotspotBounds.isEmpty()) {
+				entity = runtimeEntities->spawnSceneHotspotEntity(object.objectName, hotspotBounds,
+					(float)object.currentZ);
+			}
+			if (!entity)
+				return;
+
+			entity->setClassId(resolveSceneObjectClassLocal(
+				object, entity->hasFrames() ? entity : nullptr));
+			entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
+			entity->setZExtent((float)object.zExtent);
+		};
+		auto getSceneRegionBounds = [](const StartupRegionRecord &region) {
+			if (region.right > region.left && region.bottom > region.top)
+				return Common::Rect(region.left, region.top, region.right + 1, region.bottom + 1);
+			return Common::Rect();
+		};
+		auto spawnSceneRegionEntityFromRecord = [&](const StartupRegionRecord &region) {
+			if (!runtimeEntities)
+				return;
+
+			const Common::Rect bounds = getSceneRegionBounds(region);
+			if (bounds.isEmpty())
+				return;
+
+			RuntimeEntity *entity = runtimeEntities->spawnSceneHotspotEntity(
+				region.regionName, bounds, (float)region.minZ);
+			if (!entity)
+				return;
+
+			entity->setClassId(kRuntimeEntityClassRectHotspot19);
+			entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
+			entity->setZExtent((float)MAX(0, region.maxZ - region.minZ));
+			if (!region.cursorEnabled)
+				entity->setHitTestMode(kRuntimeEntityHitTestNone);
+		};
+		auto findRoomAnimByNameConst = [](const Common::Array<StartupAnimRecord> &anims,
+				const Common::String &animName) -> const StartupAnimRecord * {
+			for (const StartupAnimRecord &anim : anims) {
+				if (anim.animName.equalsIgnoreCase(animName))
+					return &anim;
+			}
+			return nullptr;
+		};
+		auto applyCurrentRoomRuntimeMutationsInPlace = [&]() {
+			Script *startupScript = _engine.getStartupScript();
+			ResourceManager *resources = _engine.getResources();
+			if (!startupScript || !resources)
+				return false;
+
+			const Common::Array<StartupAudioCommand> entryAudioCommands = scene.state.audioCommands;
+			StartupRoomSetupState updatedState;
+			if (!startupScript->materializeRoomState(
+					scene.state.entranceName, scene.state.roomName, updatedState, *resources)) {
+				return false;
+			}
+			updatedState.audioCommands = entryAudioCommands;
+
+			// Keep the live patch narrowly scoped to the current same-room cases we
+			// understand well: room objects, regions, timers, and room ANIM entities.
+			// Native NPC/monster live mutation semantics are more involved, so those
+			// interactions still fall back to the existing full refresh path.
+			if (!scene.state.roomNpcs.empty() || !scene.state.roomMonsters.empty() ||
+					!updatedState.roomNpcs.empty() || !updatedState.roomMonsters.empty()) {
+				return false;
+			}
+
+			StartupRoomSceneResources updatedScene;
+			if (!loadRoomSceneResources(updatedState, *resources, updatedScene))
+				return false;
+
+			for (const StartupObjectRecord &object : scene.sceneObjects) {
+				if (!findSceneObjectByName(updatedScene.sceneObjects, object.objectName))
+					removeSceneEntityByName(object.objectName);
+			}
+			for (const StartupObjectRecord &object : updatedScene.sceneObjects) {
+				const StartupObjectRecord *previous = findSceneObjectByName(scene.sceneObjects, object.objectName);
+				const Common::String spritePath = resolveSceneObjectSpritePathLocal(object);
+				const Common::Rect bounds = getSceneObjectBounds(object);
+				const bool changed = !previous ||
+					previous->currentX != object.currentX ||
+					previous->currentY != object.currentY ||
+					previous->currentZ != object.currentZ ||
+					previous->zExtent != object.zExtent ||
+					previous->operatable != object.operatable ||
+					!previous->actionTag.equalsIgnoreCase(object.actionTag) ||
+					!previous->identTextKey.equalsIgnoreCase(object.identTextKey) ||
+					!resolveSceneObjectSpritePathLocal(*previous).equalsIgnoreCase(spritePath) ||
+					!sameRect(getSceneObjectBounds(*previous), bounds);
+				if (!changed)
+					continue;
+
+				removeSceneEntityByName(object.objectName);
+				spawnSceneObjectEntityFromRecord(object);
+			}
+
+			for (const StartupRegionRecord &region : scene.sceneRegions) {
+				if (!findSceneRegionByName(updatedScene.sceneRegions, region.regionName))
+					removeSceneEntityByName(region.regionName);
+			}
+			for (const StartupRegionRecord &region : updatedScene.sceneRegions) {
+				const StartupRegionRecord *previous =
+					findSceneRegionByName(scene.sceneRegions, region.regionName);
+				const bool changed = !previous ||
+					previous->left != region.left ||
+					previous->top != region.top ||
+					previous->right != region.right ||
+					previous->bottom != region.bottom ||
+					previous->minZ != region.minZ ||
+					previous->maxZ != region.maxZ ||
+					previous->cursorEnabled != region.cursorEnabled;
+				if (!changed)
+					continue;
+
+				removeSceneEntityByName(region.regionName);
+				spawnSceneRegionEntityFromRecord(region);
+			}
+
+			for (const StartupTimerRecord &timer : scene.state.roomTimers) {
+				bool found = false;
+				for (const StartupTimerRecord &updatedTimer : updatedState.roomTimers) {
+					if (updatedTimer.timerName.equalsIgnoreCase(timer.timerName)) {
+						found = true;
+						break;
+					}
+				}
+				if (!found)
+					removeSceneEntityByName(timer.timerName);
+			}
+			for (const StartupTimerRecord &timer : updatedState.roomTimers) {
+				const StartupTimerRecord *previous = nullptr;
+				for (const StartupTimerRecord &candidate : scene.state.roomTimers) {
+					if (candidate.timerName.equalsIgnoreCase(timer.timerName)) {
+						previous = &candidate;
+						break;
+					}
+				}
+				const bool changed = !previous ||
+					previous->initialValue != timer.initialValue ||
+					previous->currentValue != timer.currentValue ||
+					previous->enabled != timer.enabled ||
+					previous->looping != timer.looping ||
+					previous->global != timer.global;
+				if (!changed)
+					continue;
+
+				removeSceneEntityByName(timer.timerName);
+				if (runtimeEntities) {
+					(void)runtimeEntities->spawnSceneTimerEntity(
+						timer.timerName, timer.initialValue, timer.currentValue,
+						timer.enabled, timer.looping, timer.global);
+				}
+			}
+
+			for (StartupAnimRecord &anim : updatedState.roomAnimations) {
+				const StartupAnimRecord *previous =
+					findRoomAnimByNameConst(scene.state.roomAnimations, anim.animName);
+				const bool becameVisible = (!previous || !previous->visible) && anim.visible;
+				if (becameVisible)
+					anim.runtimeState = 0;
+
+				RuntimeEntity *entity = runtimeEntities
+					? runtimeEntities->findSceneEntityByName(anim.animName)
+					: nullptr;
+				if (!anim.visible) {
+					removeSceneEntityByName(anim.animName);
+					continue;
+				}
+
+				const bool needsRespawn = !entity || !previous ||
+					!previous->resourcePath.equalsIgnoreCase(anim.resourcePath) ||
+					previous->x != anim.x ||
+					previous->y != anim.y ||
+					previous->z != anim.z ||
+					previous->frameDelay != anim.frameDelay ||
+					previous->looping != anim.looping ||
+					previous->pingPong != anim.pingPong ||
+					previous->backward != anim.backward;
+				if (needsRespawn) {
+					removeSceneEntityByName(anim.animName);
+					if (runtimeEntities) {
+						entity = runtimeEntities->spawnSceneAnimationEntity(
+							anim.animName, anim.resourcePath, Common::Point(anim.x, anim.y),
+							(float)anim.z, anim.frameDelay, anim.active, anim.visible, anim.looping,
+							anim.backward, anim.pingPong, anim.runtimeState);
+					}
+				} else if (entity) {
+					const float renderZ =
+						(float)anim.z - floorf(MAX<float>(entity->getZExtent(), 0.0f) * 0.5f);
+					const bool zChanged = entity->getZ() != renderZ;
+					entity->setPosition(anim.x, anim.y, renderZ);
+					entity->setLooping(anim.looping);
+					entity->setPingPong(anim.pingPong);
+					entity->setPlayBackwards(anim.backward);
+					entity->setAnimationRate(anim.frameDelay);
+					entity->setVisible(anim.visible);
+					entity->setAnimationEnabled(anim.active);
+					if (zChanged)
+						runtimeEntities->reinsertSceneEntity(entity);
+				}
+			}
+
+			for (const StartupAnimRecord &anim : scene.state.roomAnimations) {
+				if (!findRoomAnimByNameConst(updatedState.roomAnimations, anim.animName))
+					removeSceneEntityByName(anim.animName);
+			}
+
+			scene.state = updatedState;
+			scene.sceneObjects = updatedScene.sceneObjects;
+			scene.sceneAnimations = updatedScene.sceneAnimations;
+			scene.sceneRegions = updatedScene.sceneRegions;
+			memcpy(scene.palette, updatedScene.palette, sizeof(scene.palette));
+			scene.targetPaletteBrightness = updatedScene.targetPaletteBrightness;
+			captureCurrentSaveState();
+			return _inventory.refresh();
+		};
 		resetIdleState();
 		auto stopPlayerRegionInteraction = [&]() {
 			moveLeft = false;
@@ -1047,6 +1317,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			Common::String &pendingRegionName;
 			Common::String &pendingRoomChange;
 			decltype(refreshCurrentScene) &refreshCurrentSceneFn;
+			decltype(applyCurrentRoomRuntimeMutationsInPlace) &applyCurrentRoomRuntimeMutationsInPlaceFn;
 			decltype(captureDialogueBackdrop) &captureDialogueBackdropFn;
 			decltype(runRoomExitCommands) &runRoomExitCommandsFn;
 			decltype(applyLightingCommand) &applyLightingCommandFn;
@@ -1057,7 +1328,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			Common::Error handleInteractionResult(const StartupInteractionResult &interaction,
 					bool &didTransition, const Common::String &usedItemName) {
 				didTransition = false;
-				bool deferredSceneRefresh = false;
+
 				playerState.hasMoveTarget = false;
 				playerState.turnActive = false;
 				playerState.turnTargetFacing = -1;
@@ -1127,10 +1398,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 						engine.stopStartupMusic();
 					didTransition = true;
 				} else if (interaction.mutatedRuntimeState) {
-					deferredSceneRefresh = !interaction.cutscenePath.empty();
-					if (!deferredSceneRefresh) {
-						if (!refreshCurrentSceneFn(true))
-							return Common::kReadingFailed;
+					if (!applyCurrentRoomRuntimeMutationsInPlaceFn() &&
+							!refreshCurrentSceneFn(true)) {
+						return Common::kReadingFailed;
 					}
 				}
 
@@ -1150,11 +1420,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					FstPlayer fstPlayer(engine);
 					if (!fstPlayer.play(interaction.cutscenePath))
 						return Common::kReadingFailed;
-					if (deferredSceneRefresh) {
-						if (!refreshCurrentSceneFn(true))
-							return Common::kReadingFailed;
-						deferredSceneRefresh = false;
-					}
 				}
 
 				if (!interaction.modalText.value.empty()) {
@@ -1238,8 +1503,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		};
 		InteractionProcessor interactionProcessor = {
 			_engine, startupFlow, scene, playerState, pendingRegionName, pendingRoomChange,
-			refreshCurrentScene, captureDialogueBackdrop, runRoomExitCommands,
-			applyLightingCommand, applyPlayerGotoXZ, runModalShowText, resetIdleState
+			refreshCurrentScene, applyCurrentRoomRuntimeMutationsInPlace, captureDialogueBackdrop,
+			runRoomExitCommands, applyLightingCommand, applyPlayerGotoXZ, runModalShowText,
+			resetIdleState
 		};
 	auto findRoomNpcRecordByName = [&](const Common::String &npcName) -> StartupNpcRecord * {
 		for (StartupNpcRecord &npc : scene.state.roomNpcs) {
@@ -1873,7 +2139,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		startupScript->addRuntimeObjectToInventory(object.objectName);
 		const Common::String inventoryOwner(kInventoryOwnerName);
 		hideSceneObject(object.objectName, &inventoryOwner);
-		removeSceneObjectEntity(object.objectName);
+		removeSceneEntityByName(object.objectName);
 
 		bool didTransition = false;
 		if (shouldDispatchPickupActionOnDirectInventoryTransfer(object)) {
@@ -1909,7 +2175,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 		startupScript->setRuntimeObjectVisible(object.currentOwnerOrRoom, object.objectName, false);
 		hideSceneObject(object.objectName, nullptr);
-		removeSceneObjectEntity(object.objectName);
+		removeSceneEntityByName(object.objectName);
 
 		bool didTransition = false;
 		if (shouldDispatchPickupActionOnCarryStart(object)) {
@@ -2403,7 +2669,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					hoverState.npc ? hoverState.npc->npcName.c_str() : "",
 					hoverState.region ? hoverState.region->regionName.c_str() : "",
 					hoverState.cursorSequence, hoverState.promptText.c_str());
-				if (hasCarriedRoomItem()) {
+					if (hasCarriedRoomItem()) {
 					if (hoverState.playerEntity && playerState.entity &&
 							hoverState.playerEntity == playerState.entity) {
 						if (!stowCarriedRoomItemToInventory())
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index 1dbf7cf811e..c882c97b464 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -236,9 +236,17 @@ bool RuntimeEntity::loadAbmResource(ResourceManager &resources, const Common::St
 }
 
 void RuntimeEntity::setPosition(int x, int y, float z) {
+	const bool anchorChanged = _x != x || _y != y;
 	_x = x;
 	_y = y;
 	_z = z;
+	if (anchorChanged)
+		updateScreenBaseFromCurrentFrame();
+}
+
+void RuntimeEntity::setAnchorMode(RuntimeEntityAnchorMode anchorMode) {
+	_anchorMode = anchorMode;
+	updateScreenBaseFromCurrentFrame();
 }
 
 void RuntimeEntity::setAnimationRate(int rate) {
@@ -262,6 +270,7 @@ void RuntimeEntity::setCurrentFrame(int frame) {
 		return;
 
 	advanceAnimationFrame(frame);
+	updateScreenBaseFromCurrentFrame();
 }
 
 void RuntimeEntity::setAnimationFrameRange(int firstFrame, int lastFrame, bool looping) {
@@ -316,6 +325,7 @@ void RuntimeEntity::configureHotspotBounds(int width, int height) {
 	_boundsHeight = MAX(height, 1);
 	_hitTestMode = kRuntimeEntityHitTestBounds;
 	_depthScale = 1.0f;
+	updateScreenBaseFromCurrentFrame();
 }
 
 bool RuntimeEntity::getCurrentFrameMetrics(int &width, int &height, int &xOffset, int &yOffset) const {
@@ -359,24 +369,10 @@ bool RuntimeEntity::tickVisualState(uint32 now) {
 Common::Point RuntimeEntity::getDrawOrigin() const {
 	if (!_frames.empty() && _currentFrame >= 0 && (uint)_currentFrame < _frames.size()) {
 		const AbmFrame &frame = _frames[(uint)_currentFrame];
-		int drawX = _x;
-		int drawY = _y;
-		if (_anchorMode == kRuntimeEntityAnchorCentered) {
-			drawX -= (int)frame.width / 2;
-			drawY -= (int)frame.height / 2;
-		}
-
-		return Common::Point(drawX + frame.xOffset, drawY + frame.yOffset);
-	}
-
-	int drawX = _x;
-	int drawY = _y;
-	if (_anchorMode == kRuntimeEntityAnchorCentered) {
-		drawX -= _boundsWidth / 2;
-		drawY -= _boundsHeight / 2;
+		return Common::Point(_screenBaseX + frame.xOffset, _screenBaseY + frame.yOffset);
 	}
 
-	return Common::Point(drawX, drawY);
+	return Common::Point(_screenBaseX, _screenBaseY);
 }
 
 Common::Rect RuntimeEntity::getScreenRect() const {
@@ -666,16 +662,31 @@ void RuntimeEntity::updateBoundsFromCurrentFrame() {
 	_boundsHeight = (int)_frames[(uint)frameIndex].height;
 }
 
+void RuntimeEntity::updateScreenBaseFromCurrentFrame() {
+	int baseX = _x;
+	int baseY = _y;
+
+	if (_anchorMode == kRuntimeEntityAnchorCentered) {
+		baseX -= _boundsWidth / 2;
+		baseY -= _boundsHeight / 2;
+	}
+
+	_screenBaseX = baseX;
+	_screenBaseY = baseY;
+}
+
 void RuntimeEntity::rebuildScaledFrames() {
 	if (_baseFrames.empty()) {
 		_frames.clear();
 		updateBoundsFromCurrentFrame();
+		updateScreenBaseFromCurrentFrame();
 		return;
 	}
 
 	if (fabsf(_depthScale - 1.0f) < 0.0001f) {
 		_frames = _baseFrames;
 		updateBoundsFromCurrentFrame();
+		updateScreenBaseFromCurrentFrame();
 		return;
 	}
 
@@ -692,6 +703,7 @@ void RuntimeEntity::rebuildScaledFrames() {
 	}
 
 	updateBoundsFromCurrentFrame();
+	updateScreenBaseFromCurrentFrame();
 }
 
 RuntimeEntityManager::RuntimeEntityManager(ResourceManager &resources) : _resources(resources) {
@@ -797,11 +809,13 @@ RuntimeEntity *RuntimeEntityManager::spawnSceneAnimationEntity(const Common::Str
 	if (!entity)
 		return nullptr;
 
-	// Native room ANIM entities are positioned around their record x/y pivot.
-	// The runtime centers the current decoded bitmap on that pivot, then applies
-	// the ABM frame header x/y offsets as a separate step.
+	// Native room ANIM entities center the initially shown frame on the record
+	// x/y pivot, lower the render-list depth anchor by half z_extent, and then
+	// keep that cached screen base while later frame ABM x/y offsets shift the
+	// final draw rect.
 	entity->setAnchorMode(kRuntimeEntityAnchorCentered);
 	entity->setZExtent(3.0f);
+	entity->setPosition(position.x, position.y, z - floorf(MAX<float>(entity->getZExtent(), 0.0f) * 0.5f));
 	entity->setVisible(visible);
 	entity->setPlayBackwards(playBackwards);
 	entity->setHitTestMode(kRuntimeEntityHitTestNone);
@@ -813,7 +827,6 @@ RuntimeEntity *RuntimeEntityManager::spawnSceneAnimationEntity(const Common::Str
 
 	if (!active && !visible)
 		entity->setVisible(false);
-
 	insertSceneEntity(entity);
 	return entity;
 }
@@ -923,27 +936,6 @@ void RuntimeEntityManager::drawSceneEntities(Graphics::Screen &screen) const {
 		entity->draw(screen);
 }
 
-void RuntimeEntityManager::logSceneEntityOrder(const char *label) const {
-	for (uint i = 0; i < _sceneEntities.size(); ++i) {
-		const RuntimeEntity *entity = _sceneEntities[i];
-		const Common::Rect rect = entity->getScreenRect();
-		int width = 0;
-		int height = 0;
-		int xOffset = 0;
-		int yOffset = 0;
-		const bool hasMetrics = entity->getCurrentFrameMetrics(width, height, xOffset, yOffset);
-		debugC(1, kDebugScene,
-			"Harvester: scene render order label='%s' index=%u/%u name='%s' class=0x%x visible=%d pos=(%d,%d,z=%.2f) z_extent=%.2f depth_scale=%.3f frame=%d rect=(%d,%d)-(%d,%d) metrics=%s size=%dx%d offsets=(%d,%d)",
-			label ? label : "", i, (uint)_sceneEntities.size(),
-			entity->getName().c_str(), entity->getClassId(), entity->isVisible(),
-			entity->getX(), entity->getY(), (double)entity->getZ(), (double)entity->getZExtent(),
-			(double)entity->getDepthScale(), entity->getCurrentFrame(),
-			rect.left, rect.top, rect.right, rect.bottom,
-			hasMetrics ? "frame" : "none",
-			width, height, xOffset, yOffset);
-	}
-}
-
 const RuntimeEntity *RuntimeEntityManager::findTopSceneEntityAt(const Common::Point &point, int classIdFilter) const {
 	for (int i = (int)_sceneEntities.size() - 1; i >= 0; --i) {
 		const RuntimeEntity *entity = _sceneEntities[(uint)i];
@@ -956,6 +948,16 @@ const RuntimeEntity *RuntimeEntityManager::findTopSceneEntityAt(const Common::Po
 	return nullptr;
 }
 
+int RuntimeEntityManager::findSceneEntityDrawIndexByName(const Common::String &name) const {
+	for (uint i = 0; i < _sceneEntities.size(); ++i) {
+		const RuntimeEntity *entity = _sceneEntities[i];
+		if (entity->getName().equalsIgnoreCase(name))
+			return (int)i;
+	}
+
+	return -1;
+}
+
 const RuntimeEntity *RuntimeEntityManager::findSceneEntityByName(const Common::String &name) const {
 	for (const RuntimeEntity *entity : _sceneEntities) {
 		if (entity->getName().equalsIgnoreCase(name))
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index b02a1cbbef6..1499d9e8aae 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -71,7 +71,7 @@ public:
 
 	void setClassId(int classId) { _classId = classId; }
 	int getClassId() const { return _classId; }
-	void setAnchorMode(RuntimeEntityAnchorMode anchorMode) { _anchorMode = anchorMode; }
+	void setAnchorMode(RuntimeEntityAnchorMode anchorMode);
 	void setZExtent(float zExtent) { _zExtent = zExtent; }
 	float getZExtent() const { return _zExtent; }
 
@@ -131,6 +131,7 @@ private:
 	bool isOpaqueAt(const Common::Point &point) const;
 	void advanceAnimationFrame(int directive);
 	void updateBoundsFromCurrentFrame();
+	void updateScreenBaseFromCurrentFrame();
 	void rebuildScaledFrames();
 
 	Common::String _name;
@@ -141,6 +142,8 @@ private:
 	int _x = 0;
 	int _y = 0;
 	float _z = 0.0f;
+	int _screenBaseX = 0;
+	int _screenBaseY = 0;
 	int _currentFrame = -1;
 	int _firstFrame = -1;
 	int _lastFrame = -1;
@@ -202,9 +205,9 @@ public:
 	bool tickSceneEntities();
 	bool syncCursorEntityPosition(const Common::Point &position);
 	void drawSceneEntities(Graphics::Screen &screen) const;
-	void logSceneEntityOrder(const char *label) const;
 	void drawCursor(Graphics::Screen &screen) const;
 	const RuntimeEntity *findTopSceneEntityAt(const Common::Point &point, int classIdFilter = -1) const;
+	int findSceneEntityDrawIndexByName(const Common::String &name) const;
 	const RuntimeEntity *findSceneEntityByName(const Common::String &name) const;
 	RuntimeEntity *findSceneEntityByName(const Common::String &name);
 	RuntimeEntity *detachSceneEntityByName(const Common::String &name);
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 26ea5c592d9..666777ef14c 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -2230,16 +2230,22 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 				continue;
 			}
 
+			const bool wasVisible = runtimeAnim->visible;
 			const bool active = isTruthy(command->arg2);
 			const bool visible = isTruthy(command->arg3);
+			int runtimeState = runtimeAnim->runtimeState;
+			if (!wasVisible && visible)
+				runtimeState = 0;
 			const bool changed = runtimeAnim->active != active ||
 				runtimeAnim->visible != visible ||
 				runtimeAnim->runtimeActive != active ||
-				runtimeAnim->runtimeVisible != visible;
+				runtimeAnim->runtimeVisible != visible ||
+				runtimeAnim->runtimeState != runtimeState;
 			runtimeAnim->active = active;
 			runtimeAnim->visible = visible;
 			runtimeAnim->runtimeActive = active;
 			runtimeAnim->runtimeVisible = visible;
+			runtimeAnim->runtimeState = runtimeState;
 			noteMutation(changed);
 			currentTag = command->arg4;
 			continue;


Commit: 357141e6f3499d9099fee9644c245de853dd1b61
    https://github.com/scummvm/scummvm/commit/357141e6f3499d9099fee9644c245de853dd1b61
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:15+02:00

Commit Message:
HARVESTER: Fix Stephanie BYE spyhole routing

Changed paths:
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/npc/dialogue_runtime.h
    engines/harvester/npc/stephanie_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 34b2e86f647..5f70f1cee07 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,10 +10,12 @@
 
 ## Next Suggested Action
 
-- No active engine follow-up is required for the sheriff/service/school door drift fix. If another report appears, rerun those door interactions on a clean build first and compare the visible door layer rather than reintroducing temporary probes.
+- Run one focused in-engine Stephanie validation on a clean build: confirm the visible `BYE` from the initial `dialog.rsp[0x2cf]` loop now exits through `0x46a4`, then advance to the `dialog.rsp[0x2f8]` state and verify hidden `Other` topics `Peephole` and typed `BYE` still reach the `0x4a05` spyhole continuation while the generic clicked/ESC `BYE` still exits immediately.
 
 ## Last Confirmed Action
 
+- On March 25, 2026, traced Stephanie's looping `wav=0x4a05` regression to an ambiguity between two different `BYE` entries in the native `DIALOG.RSP`: zero-based `dialog.rsp[0x2e4]` is the generic keyword-menu exit, while zero-based `dialog.rsp[0x2fd]` is the hidden typed-topic alias inside Stephanie's spyhole continuation cluster (`0x2f9..0x2fd`). The shipped resource file confirms `dialog.rsp[0x2cf] = Those People/Wedding/Grounded/Amnesia`, `dialog.rsp[0x2f8] = Lodge/Amnesia`, and the hidden follow-up strings `0x2f9 = Peephole`, `0x2fa = Hole`, `0x2fb = Spy`, `0x2fc = Spyhole`, `0x2fd = BYE`; live Ghidra notes for `handle_talk_to_stephanie @ 0x36710` already bounded that orphan tail as reachable only after the visible buffer advances to `dialog.rsp[0x2f8]`, with the generic `BYE` still exiting through `0x46a4`. ScummVM was losing that distinction because [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp) only returned the selected text, so a clicked generic `BYE` could text-match the hidden `0x2fd` branch in [`engines/harvester/npc/stephanie_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/stephanie_dialogue.cpp). Patched [`engines/harvester/npc/dialogue_runtime.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dialogue_runtime.h) and [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp) so keyword selection metadata now tracks typed `Other` input versus the hardcoded generic `BYE`, and tightened Stephanie's hidden spyhole branch to require both the native `dialog.rsp[0x2f8]` state and typed input before `0x2f9..0x2fd` are considered. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `912` total functions, `800` named/documented, and `112` remaining `FUN_*`.
+- On March 25, 2026, traced the ESC -> `Options` label drift to native `MENU.INI` padding rather than a special-case slider anchor. Ghidra shows `run_main_menu` (`0x67390`) copying `options_menu_1..7` from `MENU.INI` into raw buffers and centering them by measured width; the shipped file keeps trailing spaces in `options_menu_1=SOUND FX           `, `options_menu_2=MUSIC         `, and `options_menu_3=GAMMA         ` so the visible glyphs sit left of center before `VOLUME.BM` / `INDICATR.BM` are drawn. ScummVM now preserves that raw spacing in [`engines/harvester/menu.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/menu.cpp) when loading the option labels, and [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) records the confirmed native layout detail. Progress counts remain `912` total functions, `800` named/documented, and `112` remaining `FUN_*`.
 - On March 25, 2026, removed the temporary Harvester investigation probes from [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp), [`engines/harvester/runtime_entity.h`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp) after confirming the centered room-anim drift fix. Removed the door geometry/frame/draw traces, room click selection probes, actor-placement/render-order dumps, and the now-unused helper they depended on, while keeping the behavioral fixes for same-room live mutations, draw-order-based hotspot resolution, `SET_ANIM` hidden→visible runtime-state reset, and the cached centered screen base for room anims. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `912` total functions, `800` named/documented, and `112` remaining `FUN_*`.
 - On March 25, 2026, fixed a centered room-anim drift bug in [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp) after reconciling the latest sheriff screenshot with native `show_entity_visual` (`0x4c290`). The decisive native detail is that the centered branch calls `set_entity_screen_position` only on the initial show path (`field_0x4f == 0`); later frame advances in `advance_entity_animation_frame` (`0x4c8a0`) update `frame_x_offset/frame_y_offset` and call `show_entity_visual`, but do not recenter from the new frame width each draw. ScummVM now mirrors that by caching a centered anim's initial top-left screen base in `RuntimeEntity` and reusing it across frame advances instead of recomputing `_x - frame.width / 2` every draw. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `912` total functions, `800` named/documented, and `112` remaining `FUN_*`.
 - On March 25, 2026, closed the sheriff-office first-draw timing branch. The new `door anim draw trace` probe in [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp) showed that ScummVM does not draw `SHER_EVIDDOOR` frame `0`; the first actual draw is already frame `1`, then frame `2`. Ghidra confirms native uses the same ordering in `run_harvester_main_loop` (`0x6dc70`): it dispatches `handle_target_interaction()` at `LAB_0006f996`, then immediately walks the live render list at `LAB_0006fa76`, where class-1 room anims call `tick_entity_visual_state()` before the frame is flushed. Combined with the already-correct post-interaction selection probe and doorway geometry logs, this means the sheriff-office mismatch is no longer supported by click ownership, anim anchor, or first-draw timing evidence on the current build. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `912` total functions, `800` named/documented, and `112` remaining `FUN_*`.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index b4805edfe3c..fab3ad7967c 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -1211,8 +1211,10 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 	};
 
 	auto runKeywordMenu = [&](const Common::String &topicBuffer, int topicBufferLineIndex,
-			Common::String &selectedTopic) -> Common::Error {
+			Common::String &selectedTopic,
+			DialogueRuntime::KeywordMenuSelectionState &selection) -> Common::Error {
 		selectedTopic.clear();
+		selection = DialogueRuntime::KeywordMenuSelectionState();
 		Common::Array<Common::String> topics;
 		splitDialogueMenuLine(topicBuffer, topics);
 		logDialogueMenuItems("Keyword menu", topicBufferLineIndex, topicBuffer, topics);
@@ -1252,6 +1254,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 								event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
 							if (!typedTopic.empty()) {
 								selectedTopic = typedTopic;
+								selection.fromTypedInput = true;
 								debugC(1, kDebugDialogue, "Harvester: keyword menu typed topic='%s'",
 									selectedTopic.c_str());
 								if (runtimeEntities)
@@ -1270,6 +1273,8 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 				case Common::EVENT_KEYDOWN:
 					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
 						selectedTopic = genericByeTopic;
+						selection.fromEscape = true;
+						selection.fromGenericBye = true;
 						debugC(1, kDebugDialogue,
 							"Harvester: keyword menu selected default topic via escape '%s'",
 							selectedTopic.c_str());
@@ -1280,6 +1285,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 					const int clickedTopic = getDialogueMenuItemAt(*menuFont, topics.size(), _mousePos);
 					if (clickedTopic >= 0) {
 						selectedTopic = topics[(uint)clickedTopic];
+						selection.fromGenericBye = clickedTopic == (int)topics.size() - 1;
 						debugC(1, kDebugDialogue, "Harvester: keyword menu selected topic[%d]='%s'",
 							clickedTopic + 1, selectedTopic.c_str());
 						return Common::kNoError;
diff --git a/engines/harvester/npc/dialogue_runtime.h b/engines/harvester/npc/dialogue_runtime.h
index 8a446bb4336..00bbb3ea7f5 100644
--- a/engines/harvester/npc/dialogue_runtime.h
+++ b/engines/harvester/npc/dialogue_runtime.h
@@ -21,11 +21,22 @@ struct DialogueLineEntry {
 
 class DialogueRuntime {
 public:
+	struct KeywordMenuSelectionState {
+		KeywordMenuSelectionState()
+			: fromTypedInput(false), fromEscape(false), fromGenericBye(false) {
+		}
+
+		bool fromTypedInput;
+		bool fromEscape;
+		bool fromGenericBye;
+	};
+
 	typedef std::function<Common::Error(int, const Common::String &, int)> PlayDialogueLineWithVariantFn;
 	typedef std::function<Common::Error(int, const Common::String &)> PlayDialogueLineFn;
 	typedef std::function<Common::Error(const DialogueLineEntry *, uint)> PlayDialogueEntrySequenceFn;
 	typedef std::function<Common::Error(const Common::String &)> PlayDialogueFstFn;
-	typedef std::function<Common::Error(const Common::String &, int, Common::String &)> RunKeywordMenuFn;
+	typedef std::function<Common::Error(const Common::String &, int, Common::String &,
+			KeywordMenuSelectionState &)> RunKeywordMenuFn;
 	typedef std::function<Common::Error(int, int &)> RunResponseMenuFn;
 	typedef std::function<Common::Error(const Common::String &, int &)> RunResponseMenuTextFn;
 	typedef std::function<Common::Error()> RunGameOverScreenFn;
@@ -92,7 +103,8 @@ public:
 	Common::Error playDialogueFst(const Common::String &path) const { return _playDialogueFst(path); }
 	Common::Error runKeywordMenu(const Common::String &topicBuffer, int topicBufferLineIndex,
 			Common::String &selectedTopic) const {
-		return _runKeywordMenu(topicBuffer, topicBufferLineIndex, selectedTopic);
+		_lastKeywordSelection = KeywordMenuSelectionState();
+		return _runKeywordMenu(topicBuffer, topicBufferLineIndex, selectedTopic, _lastKeywordSelection);
 	}
 	Common::Error runResponseMenu(int responseLineIndex, int &responseIndex) const {
 		return _runResponseMenu(responseLineIndex, responseIndex);
@@ -112,6 +124,8 @@ public:
 			const int *responseLineIndices, uint responseLineCount) const {
 		return _matchesAnyResponseLine(selectedTopic, responseLineIndices, responseLineCount);
 	}
+	bool lastKeywordSelectionWasTypedInput() const { return _lastKeywordSelection.fromTypedInput; }
+	bool lastKeywordSelectionWasGenericBye() const { return _lastKeywordSelection.fromGenericBye; }
 	void queueDialogueInteractionIfNeeded(const StartupInteractionResult &interaction) const {
 		_queueDialogueInteractionIfNeeded(interaction);
 	}
@@ -141,6 +155,7 @@ private:
 	AssignTopicBufferFn _assignTopicBuffer;
 	MatchesResponseLineFn _matchesResponseLine;
 	MatchesAnyResponseLineFn _matchesAnyResponseLine;
+	mutable KeywordMenuSelectionState _lastKeywordSelection;
 	QueueDialogueInteractionIfNeededFn _queueDialogueInteractionIfNeeded;
 	ApplyImmediateDialogueInteractionEffectsFn _applyImmediateDialogueInteractionEffects;
 	GetRandomNumberFn _getRandomNumber;
diff --git a/engines/harvester/npc/stephanie_dialogue.cpp b/engines/harvester/npc/stephanie_dialogue.cpp
index d7a6a1c66e0..aa9e1e08ec0 100644
--- a/engines/harvester/npc/stephanie_dialogue.cpp
+++ b/engines/harvester/npc/stephanie_dialogue.cpp
@@ -38,6 +38,7 @@ static const char *const kDialogueC023AFstPath = "GRAPHIC/FST/C023A.FST";
 static const char *const kDialogueC025AFstPath = "GRAPHIC/FST/C025A.FST";
 static const char *const kDialogueC025BFstPath = "GRAPHIC/FST/C025B.FST";
 static const char *const kDialogueC007FstPath = "GRAPHIC/FST/C007.FST";
+static const int kStephanieSpyholeTopicLines[] = { 0x2f9, 0x2fa, 0x2fb, 0x2fc, 0x2fd };
 
 } // End of namespace
 
@@ -680,6 +681,8 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return menuError;
 		if (selectedTopic.empty())
 			return Common::kNoError;
+		if (runtime.lastKeywordSelectionWasGenericBye())
+			return playStephanieLine(0x46a4);
 		if (runtime.matchesResponseLine(selectedTopic, 0x2e5) ||
 				runtime.matchesResponseLine(selectedTopic, 0x2e6)) {
 			Common::Error lineError = playStephanieLine(0x4406);
@@ -866,11 +869,13 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			assignStephanieTopicBuffer(0x2f8);
 			continue;
 		}
-		if (runtime.matchesResponseLine(selectedTopic, 0x2f9) ||
-				runtime.matchesResponseLine(selectedTopic, 0x2fa) ||
-				runtime.matchesResponseLine(selectedTopic, 0x2fb) ||
-				runtime.matchesResponseLine(selectedTopic, 0x2fc) ||
-				runtime.matchesResponseLine(selectedTopic, 0x2fd)) {
+		// The spyhole continuation is a hidden typed-topic branch. Native only reaches
+		// it after the visible buffer has advanced to dialog.rsp[0x2f8], while the
+		// generic BYE path remains the immediate 0x46a4 exit.
+		if (stephanieTopicBufferLineIndex == 0x2f8 &&
+				runtime.lastKeywordSelectionWasTypedInput() &&
+				runtime.matchesAnyResponseLine(selectedTopic, kStephanieSpyholeTopicLines,
+					ARRAYSIZE(kStephanieSpyholeTopicLines))) {
 			state.playedSpyholeBranch = true;
 			Common::Error lineError = runtime.playDialogueLine(0x4a05, "PC");
 			if (lineError.getCode() != Common::kNoError)


Commit: 0a3a865ecd8fd231492acd1a939bee328e257d25
    https://github.com/scummvm/scummvm/commit/0a3a865ecd8fd231492acd1a939bee328e257d25
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:16+02:00

Commit Message:
HARVESTER: Preserve options menu label padding

Changed paths:
    engines/harvester/menu.cpp


diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index 4108d3aedc3..2740f62f3ce 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -276,8 +276,11 @@ static bool loadMenuTextConfig(HarvesterEngine &engine, RoomMenuTextConfig &conf
 		return false;
 
 	for (uint i = 0; i < config.optionItems.size(); ++i) {
+		const Common::String key = Common::String::format("options_menu_%u", i + 1);
 		Common::String value;
-		if (menu.getKey(Common::String::format("options_menu_%u", i + 1), kMenuSectionName, value) && !value.empty())
+		if (loadRawMenuValue(data, key.c_str(), value) && !value.empty())
+			config.optionItems[i] = value;
+		else if (menu.getKey(key, kMenuSectionName, value) && !value.empty())
 			config.optionItems[i] = value;
 	}
 


Commit: 1f2eb33598de575842fc2bf21c9fb248f25f86ba
    https://github.com/scummvm/scummvm/commit/1f2eb33598de575842fc2bf21c9fb248f25f86ba
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:16+02:00

Commit Message:
HARVESTER: Close inventory on selected-item mouseout

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 5f70f1cee07..6235248dacf 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,17 +3,18 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `912`
-- Named/documented: `800`
+- Total functions: `914`
+- Named/documented: `802`
 - Remaining `FUN_*`: `112`
 - Remaining undocumented total: `112`
 
 ## Next Suggested Action
 
-- Run one focused in-engine Stephanie validation on a clean build: confirm the visible `BYE` from the initial `dialog.rsp[0x2cf]` loop now exits through `0x46a4`, then advance to the `dialog.rsp[0x2f8]` state and verify hidden `Other` topics `Peephole` and typed `BYE` still reach the `0x4a05` spyhole continuation while the generic clicked/ESC `BYE` still exits immediately.
+- Run one focused in-engine Harvester inventory validation on a clean build: open the inventory, left-click any carryable item, release the button, move the cursor past the native drag-close bounds (`x < 0x45`, `x > 0x238`, `y < 0x4b`, or `y > 0x1b3`) without clicking again, and confirm the overlay hides immediately while the selected item remains active for room use. Also recheck the older click-outside path and one inventory-to-inventory use to ensure the relaxed mouse-move handoff did not regress those flows.
 
 ## Last Confirmed Action
 
+- On March 25, 2026, rechecked Harvester's inventory handoff path directly in live Ghidra before touching source. `run_inventory_screen` at `0x7df10` still contains the native drag-close test at `0x7e364..0x7e38e`, using the recovered bounds `x=0x45..0x238` and `y=0x4b..0x1b3`, and its teardown returns a non-null carried `RenderEntityRuntime *` to `run_harvester_main_loop` when the inventory loop exits with an active item (`lines 1221..1267` in the current decompile). ScummVM's room loop was stricter: [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) only hid the overlay on `EVENT_MOUSEMOVE` while a selected inventory item was active if the primary button was still down, which forced an extra out-of-bounds click after the initial selection. Patched that branch so the latched inventory selection now closes the overlay on mouse move whenever the cursor leaves the recovered native drag-close region, removed the now-unused `isPrimaryMouseDown()` helper, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live Ghidra documentation counts are now `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 25, 2026, traced Stephanie's looping `wav=0x4a05` regression to an ambiguity between two different `BYE` entries in the native `DIALOG.RSP`: zero-based `dialog.rsp[0x2e4]` is the generic keyword-menu exit, while zero-based `dialog.rsp[0x2fd]` is the hidden typed-topic alias inside Stephanie's spyhole continuation cluster (`0x2f9..0x2fd`). The shipped resource file confirms `dialog.rsp[0x2cf] = Those People/Wedding/Grounded/Amnesia`, `dialog.rsp[0x2f8] = Lodge/Amnesia`, and the hidden follow-up strings `0x2f9 = Peephole`, `0x2fa = Hole`, `0x2fb = Spy`, `0x2fc = Spyhole`, `0x2fd = BYE`; live Ghidra notes for `handle_talk_to_stephanie @ 0x36710` already bounded that orphan tail as reachable only after the visible buffer advances to `dialog.rsp[0x2f8]`, with the generic `BYE` still exiting through `0x46a4`. ScummVM was losing that distinction because [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp) only returned the selected text, so a clicked generic `BYE` could text-match the hidden `0x2fd` branch in [`engines/harvester/npc/stephanie_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/stephanie_dialogue.cpp). Patched [`engines/harvester/npc/dialogue_runtime.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dialogue_runtime.h) and [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp) so keyword selection metadata now tracks typed `Other` input versus the hardcoded generic `BYE`, and tightened Stephanie's hidden spyhole branch to require both the native `dialog.rsp[0x2f8]` state and typed input before `0x2f9..0x2fd` are considered. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `912` total functions, `800` named/documented, and `112` remaining `FUN_*`.
 - On March 25, 2026, traced the ESC -> `Options` label drift to native `MENU.INI` padding rather than a special-case slider anchor. Ghidra shows `run_main_menu` (`0x67390`) copying `options_menu_1..7` from `MENU.INI` into raw buffers and centering them by measured width; the shipped file keeps trailing spaces in `options_menu_1=SOUND FX           `, `options_menu_2=MUSIC         `, and `options_menu_3=GAMMA         ` so the visible glyphs sit left of center before `VOLUME.BM` / `INDICATR.BM` are drawn. ScummVM now preserves that raw spacing in [`engines/harvester/menu.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/menu.cpp) when loading the option labels, and [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) records the confirmed native layout detail. Progress counts remain `912` total functions, `800` named/documented, and `112` remaining `FUN_*`.
 - On March 25, 2026, removed the temporary Harvester investigation probes from [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp), [`engines/harvester/runtime_entity.h`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp) after confirming the centered room-anim drift fix. Removed the door geometry/frame/draw traces, room click selection probes, actor-placement/render-order dumps, and the now-unused helper they depended on, while keeping the behavioral fixes for same-room live mutations, draw-order-based hotspot resolution, `SET_ANIM` hidden→visible runtime-state reset, and the cached centered screen base for room anims. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `912` total functions, `800` named/documented, and `112` remaining `FUN_*`.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index be6754582d9..c5f4d9ea6fd 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -120,11 +120,6 @@ static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
 		!roomName.equalsIgnoreCase("BOWLSNTRY1");
 }
 
-static bool isPrimaryMouseDown() {
-	return g_system && g_system->getEventManager() &&
-		((g_system->getEventManager()->getButtonState() & 1) != 0);
-}
-
 static bool isOutsideNativeInventoryDragCloseBounds(const Common::Point &point) {
 	return point.x < kNativeInventoryDragCloseLeft ||
 		point.x >= kNativeInventoryDragCloseRight ||
@@ -2436,8 +2431,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 			switch (event.type) {
 			case Common::EVENT_MOUSEMOVE:
-				if (_inventory.isOpen() && _inventory.hasSelection() && isPrimaryMouseDown()) {
+				if (_inventory.isOpen() && _inventory.hasSelection()) {
 					const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
+					// Native inventory handoff is driven by the latched selected item, not by
+					// a second click after the selection has already been made.
 					if (!inventoryHover && isOutsideNativeInventoryDragCloseBounds(_mousePos)) {
 						if (_inventory.close())
 							needsRedraw = true;


Commit: 28a6c86f9a3feec735173f2f6f194b62eabf9def
    https://github.com/scummvm/scummvm/commit/28a6c86f9a3feec735173f2f6f194b62eabf9def
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:16+02:00

Commit Message:
HARVESTER: Cancel denied pickup carry state

Changed paths:
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index c5f4d9ea6fd..64b59b7eb44 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -59,12 +59,16 @@ static const int kNativeInventoryWeekdayX = 0x1e0;
 static const int kNativeInventoryWeekdayY = 0x18c;
 static const byte kNativeInventoryTooltipColor = 0xf4;
 static const int kRoomMonsterAnimationRate = 17;
+static const int kRoomNpcAmbientLastFrame = 0x3b;
 static const int kDefaultMonsterAttackContactFrameOffset = 2;
 static const int kMuckeyAttackContactFrameOffset = 7;
 static const float kNativeMonsterPursuitZTolerance = 2.0f;
 static const float kNativeMonsterHorizontalWaypointTolerance = 50.0f;
 static const uint32 kNativeKeyboardAttackRepeatTicks = 25;
 static const int kNativeKeyboardAttackSideWindow = 30;
+static const char *const kNativeCombatDamageEffectResourcePath = "blood.abm";
+static const int kNativeCombatDamageEffectAnimationRate = 10;
+static const float kNativeCombatDamageEffectRenderZBias = 0.01f;
 
 static int roundRoomCombatFloat(float value);
 
@@ -114,6 +118,14 @@ struct RoomMonsterCombatState {
 	uint32 nextAttackAllowedTick = 0;
 };
 
+struct RoomCombatEffectState {
+	Common::String entityName;
+	Common::String followTargetName;
+	Common::Point anchorPoint;
+	float renderZ = 0.0f;
+	bool finished = false;
+};
+
 static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
 	return !roomName.equalsIgnoreCase("LAVAPIT") &&
 		!roomName.equalsIgnoreCase("RMNBATH") &&
@@ -661,6 +673,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		playerState.turnTargetFacing = -1;
 		Common::Array<RoomMonsterCombatState> monsterCombatStates;
 		monsterCombatStates.resize(scene.state.roomMonsters.size());
+		Common::Array<RoomCombatEffectState> combatEffectStates;
+		uint nextCombatEffectId = 0;
 		StartupResolvedText inspectText;
 		bool showingInspectText = false;
 		bool inspectCanDismiss = false;
@@ -867,6 +881,189 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			}
 			return nullptr;
 		};
+		auto findRoomNpcByNameConst = [](const Common::Array<StartupNpcRecord> &npcs,
+				const Common::String &npcName) -> const StartupNpcRecord * {
+			for (const StartupNpcRecord &npc : npcs) {
+				if (npc.npcName.equalsIgnoreCase(npcName))
+					return &npc;
+			}
+			return nullptr;
+		};
+		auto findRoomMonsterByNameConst = [](const Common::Array<StartupMonsterRecord> &monsters,
+				const Common::String &monsterName) -> const StartupMonsterRecord * {
+			for (const StartupMonsterRecord &monster : monsters) {
+				if (monster.monsterName.equalsIgnoreCase(monsterName))
+					return &monster;
+			}
+			return nullptr;
+		};
+		auto shouldSpawnRoomNpcEntity = [](const StartupNpcRecord &npc) {
+			return npc.visible && !npc.deathOrMonsterfyFlag;
+		};
+		auto shouldSpawnRoomMonsterEntity = [](const StartupMonsterRecord &monster) {
+			return monster.visible;
+		};
+		auto sameNpcEntityState = [&](const StartupNpcRecord &lhs, const StartupNpcRecord &rhs) {
+			return lhs.posX == rhs.posX &&
+				lhs.posY == rhs.posY &&
+				lhs.posZ == rhs.posZ &&
+				lhs.frameDelay == rhs.frameDelay &&
+				lhs.visible == rhs.visible &&
+				lhs.deathOrMonsterfyFlag == rhs.deathOrMonsterfyFlag &&
+				lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
+		};
+		auto sameMonsterEntityState = [&](const StartupMonsterRecord &lhs, const StartupMonsterRecord &rhs) {
+			return lhs.posX == rhs.posX &&
+				lhs.posY == rhs.posY &&
+				lhs.posZ == rhs.posZ &&
+				lhs.facing == rhs.facing &&
+				lhs.active == rhs.active &&
+				lhs.visible == rhs.visible &&
+				lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
+		};
+		auto applyRoomNpcPlacement = [&](RuntimeEntity &entity, const StartupNpcRecord &npc) {
+			int width = 0;
+			int height = 0;
+			int xOffset = 0;
+			int yOffset = 0;
+			if (!entity.getCurrentFrameMetrics(width, height, xOffset, yOffset))
+				return false;
+
+			const float renderZ =
+				(float)npc.posZ - floorf(MAX<float>(entity.getZExtent(), 0.0f) * 0.5f);
+			entity.setAnchorMode(kRuntimeEntityAnchorTopLeft);
+			entity.setPosition(npc.posX - xOffset - width / 2, npc.posY - height - yOffset, renderZ);
+			return true;
+		};
+		auto spawnSceneNpcEntityFromRecord = [&](const StartupNpcRecord &npc) -> RuntimeEntity * {
+			if (!runtimeEntities || !shouldSpawnRoomNpcEntity(npc))
+				return nullptr;
+
+			RuntimeEntity *entity = runtimeEntities->spawnSceneActorEntity(
+				npc.npcName, npc.modelPath, Common::Point(npc.posX, npc.posY), (float)npc.posZ, 0);
+			if (!entity)
+				return nullptr;
+
+			entity->setClassId(kRuntimeEntityClassNpc);
+			entity->setHitTestMode(kRuntimeEntityHitTestOpaquePixels);
+			entity->setAnimationFrameRange(0, MIN(entity->getLastFrame(), kRoomNpcAmbientLastFrame), true);
+			entity->setAnimationRate(npc.frameDelay > 0 ? npc.frameDelay : 0);
+			entity->setVisible(true);
+			if (!applyRoomNpcPlacement(*entity, npc)) {
+				removeSceneEntityByName(npc.npcName);
+				return nullptr;
+			}
+			runtimeEntities->reinsertSceneEntity(entity);
+			return entity;
+		};
+		auto spawnSceneMonsterEntityFromRecord = [&](const StartupRoomSetupState &roomState,
+				const StartupMonsterRecord &monster) -> RuntimeEntity * {
+			if (!runtimeEntities || !shouldSpawnRoomMonsterEntity(monster))
+				return nullptr;
+
+			RuntimeEntity *entity = runtimeEntities->spawnSceneActorEntity(
+				monster.monsterName, monster.modelPath,
+				Common::Point(monster.posX, monster.posY), (float)monster.posZ,
+				Monster::resolveFacingFrame(monster.facing));
+			if (!entity)
+				return nullptr;
+
+			entity->setClassId(kRuntimeEntityClassMonster);
+			entity->setHitTestMode(kRuntimeEntityHitTestNone);
+			entity->setVisible(monster.visible);
+			Monster::applyAnimation(*entity, monster);
+			if (!applyRoomActorPlacement(roomState, *entity, monster.posX, monster.posY, (float)monster.posZ)) {
+				removeSceneEntityByName(monster.monsterName);
+				return nullptr;
+			}
+			runtimeEntities->reinsertSceneEntity(entity);
+			return entity;
+		};
+		auto resolveCombatEffectAnchor = [&](const RuntimeEntity &targetEntity,
+				Common::Point &anchorPoint, float &renderZ) {
+			const Common::Rect targetRect = targetEntity.getScreenRect();
+			anchorPoint.x = targetRect.left + targetRect.width() / 2;
+			anchorPoint.y = targetRect.top + targetRect.height() / 3;
+			renderZ = targetEntity.getZ() - kNativeCombatDamageEffectRenderZBias;
+		};
+		auto spawnCombatDamageEffect = [&](RuntimeEntity &sourceEntity,
+				const Common::String &followTargetName) {
+			if (!runtimeEntities)
+				return false;
+
+			RoomCombatEffectState effectState;
+			effectState.entityName = Common::String::format("__combat_blood_%u", nextCombatEffectId++);
+			effectState.followTargetName = followTargetName;
+			resolveCombatEffectAnchor(sourceEntity, effectState.anchorPoint, effectState.renderZ);
+
+			RuntimeEntity *effectEntity = runtimeEntities->spawnSceneAnimationEntity(
+				effectState.entityName, kNativeCombatDamageEffectResourcePath, effectState.anchorPoint,
+				effectState.renderZ, kNativeCombatDamageEffectAnimationRate, true, true, false,
+				false, false, 0);
+			if (!effectEntity)
+				return false;
+
+			combatEffectStates.push_back(effectState);
+			return true;
+		};
+		auto syncCombatDamageEffects = [&]() {
+			if (!runtimeEntities)
+				return false;
+
+			bool changed = false;
+			for (RoomCombatEffectState &effectState : combatEffectStates) {
+				RuntimeEntity *effectEntity = runtimeEntities->findSceneEntityByName(effectState.entityName);
+				if (!effectEntity)
+					continue;
+
+				if (!effectState.followTargetName.empty()) {
+					RuntimeEntity *targetEntity =
+						runtimeEntities->findSceneEntityByName(effectState.followTargetName);
+					if (targetEntity && targetEntity->isVisible()) {
+						resolveCombatEffectAnchor(*targetEntity, effectState.anchorPoint, effectState.renderZ);
+					}
+				}
+
+				if (effectEntity->getX() != effectState.anchorPoint.x ||
+						effectEntity->getY() != effectState.anchorPoint.y ||
+						fabsf(effectEntity->getZ() - effectState.renderZ) > 0.0001f) {
+					effectEntity->setPosition(
+						effectState.anchorPoint.x, effectState.anchorPoint.y, effectState.renderZ);
+					runtimeEntities->reinsertSceneEntity(effectEntity);
+					changed = true;
+				}
+			}
+
+			return changed;
+		};
+		auto pruneCombatDamageEffects = [&]() {
+			bool removedAny = false;
+			for (uint i = 0; i < combatEffectStates.size();) {
+				const Common::String effectName = combatEffectStates[i].entityName;
+				RuntimeEntity *effectEntity = runtimeEntities
+					? runtimeEntities->findSceneEntityByName(effectName)
+					: nullptr;
+				if (!effectEntity) {
+					combatEffectStates.remove_at(i);
+					continue;
+				}
+
+				const bool finished =
+					!effectEntity->isAnimationEnabled() &&
+					effectEntity->getCurrentFrame() == effectEntity->getLastFrame();
+				if (finished && combatEffectStates[i].finished) {
+					removeSceneEntityByName(effectName);
+					combatEffectStates.remove_at(i);
+					removedAny = true;
+					continue;
+				}
+
+				combatEffectStates[i].finished = finished;
+				++i;
+			}
+
+			return removedAny;
+		};
 		auto applyCurrentRoomRuntimeMutationsInPlace = [&]() {
 			Script *startupScript = _engine.getStartupScript();
 			ResourceManager *resources = _engine.getResources();
@@ -881,18 +1078,23 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			}
 			updatedState.audioCommands = entryAudioCommands;
 
-			// Keep the live patch narrowly scoped to the current same-room cases we
-			// understand well: room objects, regions, timers, and room ANIM entities.
-			// Native NPC/monster live mutation semantics are more involved, so those
-			// interactions still fall back to the existing full refresh path.
-			if (!scene.state.roomNpcs.empty() || !scene.state.roomMonsters.empty() ||
-					!updatedState.roomNpcs.empty() || !updatedState.roomMonsters.empty()) {
-				return false;
-			}
-
 			StartupRoomSceneResources updatedScene;
 			if (!loadRoomSceneResources(updatedState, *resources, updatedScene))
 				return false;
+			Common::Array<RoomMonsterCombatState> updatedMonsterCombatStates;
+			updatedMonsterCombatStates.resize(updatedState.roomMonsters.size());
+			for (uint i = 0; i < updatedState.roomMonsters.size(); ++i) {
+				const StartupMonsterRecord &updatedMonster = updatedState.roomMonsters[i];
+				for (uint j = 0; j < scene.state.roomMonsters.size() &&
+						j < monsterCombatStates.size(); ++j) {
+					const StartupMonsterRecord &previousMonster = scene.state.roomMonsters[j];
+					if (!previousMonster.monsterName.equalsIgnoreCase(updatedMonster.monsterName))
+						continue;
+					if (sameMonsterEntityState(previousMonster, updatedMonster))
+						updatedMonsterCombatStates[i] = monsterCombatStates[j];
+					break;
+				}
+			}
 
 			for (const StartupObjectRecord &object : scene.sceneObjects) {
 				if (!findSceneObjectByName(updatedScene.sceneObjects, object.objectName))
@@ -1030,7 +1232,66 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					removeSceneEntityByName(anim.animName);
 			}
 
+			for (const StartupNpcRecord &npc : scene.state.roomNpcs) {
+				const StartupNpcRecord *updatedNpc =
+					findRoomNpcByNameConst(updatedState.roomNpcs, npc.npcName);
+				const bool keepEntity =
+					updatedNpc &&
+					shouldSpawnRoomNpcEntity(*updatedNpc) &&
+					sameNpcEntityState(npc, *updatedNpc);
+				if (!keepEntity)
+					removeSceneEntityByName(npc.npcName);
+			}
+			for (const StartupNpcRecord &npc : updatedState.roomNpcs) {
+				if (!shouldSpawnRoomNpcEntity(npc))
+					continue;
+
+				RuntimeEntity *entity = runtimeEntities
+					? runtimeEntities->findSceneEntityByName(npc.npcName)
+					: nullptr;
+				const StartupNpcRecord *previousNpc =
+					findRoomNpcByNameConst(scene.state.roomNpcs, npc.npcName);
+				const bool needsRespawn =
+					!entity || !previousNpc || !sameNpcEntityState(*previousNpc, npc);
+				if (!needsRespawn)
+					continue;
+
+				removeSceneEntityByName(npc.npcName);
+				if (!spawnSceneNpcEntityFromRecord(npc))
+					return false;
+			}
+
+			for (const StartupMonsterRecord &monster : scene.state.roomMonsters) {
+				const StartupMonsterRecord *updatedMonster =
+					findRoomMonsterByNameConst(updatedState.roomMonsters, monster.monsterName);
+				const bool keepEntity =
+					updatedMonster &&
+					shouldSpawnRoomMonsterEntity(*updatedMonster) &&
+					sameMonsterEntityState(monster, *updatedMonster);
+				if (!keepEntity)
+					removeSceneEntityByName(monster.monsterName);
+			}
+			for (const StartupMonsterRecord &monster : updatedState.roomMonsters) {
+				if (!shouldSpawnRoomMonsterEntity(monster))
+					continue;
+
+				RuntimeEntity *entity = runtimeEntities
+					? runtimeEntities->findSceneEntityByName(monster.monsterName)
+					: nullptr;
+				const StartupMonsterRecord *previousMonster =
+					findRoomMonsterByNameConst(scene.state.roomMonsters, monster.monsterName);
+				const bool needsRespawn =
+					!entity || !previousMonster || !sameMonsterEntityState(*previousMonster, monster);
+				if (!needsRespawn)
+					continue;
+
+				removeSceneEntityByName(monster.monsterName);
+				if (!spawnSceneMonsterEntityFromRecord(updatedState, monster))
+					return false;
+			}
+
 			scene.state = updatedState;
+			monsterCombatStates = updatedMonsterCombatStates;
 			scene.sceneObjects = updatedScene.sceneObjects;
 			scene.sceneAnimations = updatedScene.sceneAnimations;
 			scene.sceneRegions = updatedScene.sceneRegions;
@@ -1064,6 +1325,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			refreshedState.audioCommands = entryAudioCommands;
 			if (!loadRoomSceneResources(refreshedState, *_engine.getResources(), scene))
 				return false;
+			combatEffectStates.clear();
+			nextCombatEffectId = 0;
 			if (!startupFlow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
 				return false;
 			if (runtimeEntities) {
@@ -1120,6 +1383,34 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			clearCarriedRoomItem();
 			return _inventory.refresh();
 		};
+		auto shouldCancelDeniedPickup = [&](const StartupInteractionResult &interaction,
+				bool didTransition) {
+			return !didTransition &&
+				!interaction.mutatedRuntimeState &&
+				!interaction.requestPlayerGotoXZ &&
+				interaction.lightingCommand == kStartupLightingCommandNone &&
+				!interaction.requestMainMenu &&
+				interaction.dialogueNpcName.empty() &&
+				interaction.dialogueContinuationTag.empty() &&
+				interaction.continuationTag.empty() &&
+				interaction.nextRoomName.empty() &&
+				interaction.cutscenePath.empty() &&
+				!interaction.modalText.value.empty();
+		};
+		auto restoreDeniedPickup = [&](Script &startupScript,
+				const StartupObjectRecord &savedRuntimeObject, bool clearCarryState) -> Common::Error {
+			if (!startupScript.syncRuntimeObjectRecord(savedRuntimeObject))
+				return Common::kReadingFailed;
+			if (clearCarryState)
+				clearCarriedRoomItem();
+
+			if (!applyCurrentRoomRuntimeMutationsInPlace() && !refreshCurrentScene(true))
+				return Common::kReadingFailed;
+			if (!_inventory.refresh())
+				return Common::kReadingFailed;
+			needsRedraw = true;
+			return Common::kNoError;
+		};
 		auto openInventoryOverlay = [&]() {
 			moveLeft = false;
 			moveRight = false;
@@ -1793,6 +2084,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			RuntimeEntity *npcEntity = npc ? findSceneRuntimeEntity(npc->npcName) : nullptr;
 			if (!npc || !npcEntity || !playerAttackCanReachTarget(npcEntity))
 				npc = Player::isProjectileCombatLoadout(playerState.combatLoadout) ? nullptr : findOverlappingNpcTarget();
+			npcEntity = npc ? findSceneRuntimeEntity(npc->npcName) : nullptr;
 			if (!npc) {
 				debugC(1, kDebugCombat,
 					"Harvester: combat player attack missed class='npc' target='%s' reason='no reachable npc'",
@@ -1810,6 +2102,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				npc->npcName.c_str(), damageType, runtimeChanged, jimmyFlagChanged);
 			if (!runtimeChanged && !jimmyFlagChanged)
 				return Common::kNoError;
+			if (runtimeChanged && npcEntity) {
+				const Common::String followTargetName = npc->monsterfyTargetName;
+				if (spawnCombatDamageEffect(*npcEntity, followTargetName))
+					needsRedraw = true;
+			}
 
 			StartupInteractionResult interaction;
 			interaction.mutatedRuntimeState = runtimeChanged;
@@ -2131,6 +2428,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		if (!startupScript)
 			return Common::kReadingFailed;
 
+		const StartupObjectRecord savedRuntimeObject = object;
+
 		startupScript->addRuntimeObjectToInventory(object.objectName);
 		const Common::String inventoryOwner(kInventoryOwnerName);
 		hideSceneObject(object.objectName, &inventoryOwner);
@@ -2147,6 +2446,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					return interactionError;
 				if (startupFlow.hasPendingMainMenuReturn())
 					return Common::kNoError;
+				if (shouldCancelDeniedPickup(pickupInteraction, didTransition)) {
+					debugC(1, kDebugScene,
+						"Harvester: cancelling denied direct pickup object='%s' action='%s' text='%s'",
+						object.objectName.c_str(), object.actionTag.c_str(),
+						pickupInteraction.modalText.value.c_str());
+					return restoreDeniedPickup(*startupScript, savedRuntimeObject, false);
+				}
 			}
 		}
 
@@ -2161,6 +2467,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		if (!startupScript || !resources)
 			return Common::kReadingFailed;
 
+		const StartupObjectRecord savedRuntimeObject = object;
+
 		clearCarriedRoomItem();
 		carriedRoomItemName = object.objectName;
 		carriedRoomItemLabel = startupScript->resolveObjectLabel(object);
@@ -2183,6 +2491,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					return interactionError;
 				if (startupFlow.hasPendingMainMenuReturn())
 					return Common::kNoError;
+				if (shouldCancelDeniedPickup(pickupInteraction, didTransition)) {
+					debugC(1, kDebugScene,
+						"Harvester: cancelling denied carried pickup object='%s' action='%s' text='%s'",
+						object.objectName.c_str(), object.actionTag.c_str(),
+						pickupInteraction.modalText.value.c_str());
+					return restoreDeniedPickup(*startupScript, savedRuntimeObject, true);
+				}
 			}
 		}
 
@@ -3036,6 +3351,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		combatError = updateRoomMonsterCombat();
 		if (combatError.getCode() != Common::kNoError)
 			return combatError;
+		if (syncCombatDamageEffects())
+			needsRedraw = true;
 		if (startupFlow.hasPendingMainMenuReturn())
 			return Common::kNoError;
 		if (!pendingRoomChange.empty()) {
@@ -3070,6 +3387,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 		if (startupFlow.tickRuntimeEntities())
 			needsRedraw = true;
+		if (pruneCombatDamageEffects())
+			needsRedraw = true;
 		if (runtimeEntities) {
 			Common::Array<Common::String> expiredTimerNames;
 			if (runtimeEntities->takeExpiredTimerNames(expiredTimerNames)) {
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 666777ef14c..96a4fe0bd99 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1819,6 +1819,25 @@ bool Script::addRuntimeObjectToInventory(const Common::String &objectName) {
 	return changed;
 }
 
+bool Script::syncRuntimeObjectRecord(const StartupObjectRecord &object) {
+	if (object.objectName.empty())
+		return false;
+
+	StartupObjectRecord *runtimeObject = findRuntimeObject(Common::String(), object.objectName);
+	if (!runtimeObject)
+		return false;
+
+	const bool changed = runtimeObject->currentX != object.currentX ||
+		runtimeObject->currentY != object.currentY ||
+		runtimeObject->currentZ != object.currentZ ||
+		!runtimeObject->currentOwnerOrRoom.equalsIgnoreCase(object.currentOwnerOrRoom) ||
+		runtimeObject->visible != object.visible ||
+		runtimeObject->runtimeVisible != object.runtimeVisible ||
+		runtimeObject->identShown != object.identShown;
+	*runtimeObject = object;
+	return changed;
+}
+
 bool Script::setRuntimeObjectVisible(const Common::String &ownerOrRoom,
 		const Common::String &objectName, bool visible) {
 	if (objectName.empty())
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 1b3a4b3bfd2..1803f3b52fd 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -374,13 +374,14 @@ public:
 	bool getFlagValue(const Common::String &flagName) const;
 	const StartupNpcRecord *findRuntimeNpcRecord(const Common::String &npcName) const;
 	bool setRuntimeFlagValue(const Common::String &flagName, bool value);
-		bool resetRuntimeObjectToInitialState(const Common::String &objectName);
-		bool addRuntimeObjectToInventory(const Common::String &objectName);
-		bool setRuntimeObjectVisible(const Common::String &ownerOrRoom, const Common::String &objectName,
-			bool visible);
-		bool setRuntimeNpcState(const Common::String &npcName, bool active, bool visible);
-		bool triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType = 0);
-		bool isNamedNpcDeathTypeClear(const Common::String &npcName) const;
+	bool resetRuntimeObjectToInitialState(const Common::String &objectName);
+	bool addRuntimeObjectToInventory(const Common::String &objectName);
+	bool syncRuntimeObjectRecord(const StartupObjectRecord &object);
+	bool setRuntimeObjectVisible(const Common::String &ownerOrRoom, const Common::String &objectName,
+		bool visible);
+	bool setRuntimeNpcState(const Common::String &npcName, bool active, bool visible);
+	bool triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType = 0);
+	bool isNamedNpcDeathTypeClear(const Common::String &npcName) const;
 	int getCurrentStoryDayIndex() const;
 	int getPlayerCurrentHitPoints() const { return _playerCurrentHitPoints; }
 	int getPlayerCombatLoadout() const { return _playerCombatLoadout; }


Commit: 257acc03299b4a28418a04d42923b8ab0806fe0b
    https://github.com/scummvm/scummvm/commit/257acc03299b4a28418a04d42923b8ab0806fe0b
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:17+02:00

Commit Message:
HARVESTER: Preflight blocked pickup tags

Changed paths:
    engines/harvester/flow.cpp
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 9eba1f2194f..5cbf1a135b5 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -810,10 +810,13 @@ static int resolveRoomObjectCursorSequence(const StartupObjectRecord &object, Sc
 
 	StartupResolvedText inspectText;
 	const bool pickupObject = startupScript.isPickupObject(object);
+	const bool pickupBlocked = pickupObject && startupScript.isPickupBlockedByAction(object);
 	if (!object.identShown && unlocksRoomObjectInteractionAfterInitialExamine(object, startupScript))
 		return kCursorSequenceExamine;
-	if (pickupObject)
+	if (pickupObject && !pickupBlocked)
 		return kCursorSequencePickup;
+	if (pickupBlocked)
+		return kCursorSequenceExamine;
 	if (object.operatable)
 		return kCursorSequenceOperate;
 	if (startupScript.resolveObjectInspectText(object, inspectText))
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 64b59b7eb44..ce960c894a5 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -3113,6 +3113,19 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					break;
 				}
 				if (_engine.getStartupScript()->isPickupObject(*clickedObject)) {
+					StartupInteractionResult blockedPickupInteraction;
+					if (_engine.getStartupScript()->isPickupBlockedByAction(
+							*clickedObject, &blockedPickupInteraction)) {
+						bool didTransition = false;
+						Common::Error interactionError =
+							interactionProcessor.handleInteractionResult(
+								blockedPickupInteraction, didTransition, Common::String());
+						if (interactionError.getCode() != Common::kNoError)
+							return interactionError;
+						needsRedraw = true;
+						break;
+					}
+
 					Common::Error pickupError = playerState.entity
 						? beginRoomItemCarry(*clickedObject)
 						: moveRoomItemDirectlyToInventory(*clickedObject);
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 96a4fe0bd99..ca5e5af7001 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1516,6 +1516,20 @@ bool Script::isPickupObject(const StartupObjectRecord &object) const {
 		!object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName);
 }
 
+bool Script::isPickupBlockedByAction(const StartupObjectRecord &object,
+		StartupInteractionResult *result) const {
+	if (result)
+		*result = StartupInteractionResult();
+	if (!isPickupObject(object) || object.actionTag.empty())
+		return false;
+	if (!result) {
+		StartupInteractionResult ignoredResult;
+		return probePickupBlockingCommandChain(object.actionTag, object.objectName, ignoredResult, 0);
+	}
+
+	return probePickupBlockingCommandChain(object.actionTag, object.objectName, *result, 0);
+}
+
 bool Script::hasObjectInteraction(const StartupObjectRecord &object) const {
 	if (isPickupObject(object))
 		return true;
@@ -2552,6 +2566,86 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 	}
 }
 
+bool Script::probePickupBlockingCommandChain(const Common::String &initialTag,
+		const Common::String &contextName, StartupInteractionResult &result, uint recursionDepth) const {
+	if (initialTag.empty() || recursionDepth > 8)
+		return false;
+
+	Common::String currentTag = initialTag;
+	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
+		const StartupCommandRecord *command = findCommandRecord(currentTag);
+		if (!command) {
+			debug(1, "Harvester: unresolved pickup preflight tag '%s' for '%s'",
+				currentTag.c_str(), contextName.c_str());
+			return false;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
+			const StartupFlagRecord *flag = findRuntimeFlag(command->arg1);
+			currentTag = (flag && flag->value) ? command->arg2 : command->arg3;
+			continue;
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("CHECK_PERC"))
+			return false;
+
+		if (command->opcodeName.equalsIgnoreCase("SHOW_TEXT")) {
+			if (!resolveTextRecord(command->arg1, result.modalText))
+				return false;
+			result.continuationTag = command->arg4;
+			return !result.modalText.value.empty();
+		}
+
+		if (command->opcodeName.equalsIgnoreCase("EXEC_LIST")) {
+			const StartupExecListRecord *execList = findExecListRecord(command->arg1);
+			if (!execList)
+				return false;
+
+			for (const Common::String &entry : execList->entries) {
+				if (probePickupBlockingCommandChain(entry, contextName, result, recursionDepth + 1))
+					return true;
+			}
+			return false;
+		}
+
+		Common::Array<StartupAudioCommand> audioCommands;
+		if (appendStartupAudioCommand(*command, audioCommands))
+			return false;
+
+		if (command->opcodeName.equalsIgnoreCase("SET_FLAG") ||
+			command->opcodeName.equalsIgnoreCase("SPOOL_MUSIC") ||
+			command->opcodeName.equalsIgnoreCase("ADD") ||
+			command->opcodeName.equalsIgnoreCase("DELETE") ||
+			command->opcodeName.equalsIgnoreCase("ADD2INV") ||
+			command->opcodeName.equalsIgnoreCase("SET_ANIM") ||
+			command->opcodeName.equalsIgnoreCase("SET_REGION") ||
+			command->opcodeName.equalsIgnoreCase("SET_NPC") ||
+			command->opcodeName.equalsIgnoreCase("SET_MONSTER") ||
+			command->opcodeName.equalsIgnoreCase("SET_TIMER") ||
+			command->opcodeName.equalsIgnoreCase("KILL_TIMER") ||
+			command->opcodeName.equalsIgnoreCase("START_DIALOG") ||
+			command->opcodeName.equalsIgnoreCase("GOFLIC") ||
+			command->opcodeName.equalsIgnoreCase("GODEATHFLIC") ||
+			command->opcodeName.equalsIgnoreCase("KILL_NPC") ||
+			command->opcodeName.equalsIgnoreCase("MONSTERFY") ||
+			command->opcodeName.equalsIgnoreCase("HEAL_PC") ||
+			command->opcodeName.equalsIgnoreCase("ADJ_HP") ||
+			command->opcodeName.equalsIgnoreCase("KILL_PC") ||
+			command->opcodeName.equalsIgnoreCase("PAUSE_PC") ||
+			command->opcodeName.equalsIgnoreCase("RESUME_PC") ||
+			command->opcodeName.equalsIgnoreCase("PC_GOTO_XZ") ||
+			command->opcodeName.equalsIgnoreCase("CHANGE_LIGHTING") ||
+			command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
+			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
+			return false;
+		}
+
+		currentTag = command->arg4;
+	}
+
+	return false;
+}
+
 bool Script::hasActionableCommandChain(const Common::String &initialTag) const {
 	Common::String currentTag = initialTag;
 	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 1803f3b52fd..142725ee455 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -358,6 +358,8 @@ public:
 	bool executeNestedActionTag(const Common::String &tag, StartupInteractionResult &result,
 		bool allowTransitions = true);
 	bool isPickupObject(const StartupObjectRecord &object) const;
+	bool isPickupBlockedByAction(const StartupObjectRecord &object,
+		StartupInteractionResult *result = nullptr) const;
 	bool hasObjectInteraction(const StartupObjectRecord &object) const;
 	bool hasUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target) const;
 	void getVisibleInventoryObjects(Common::Array<StartupObjectRecord> &objects) const;
@@ -427,6 +429,8 @@ private:
 		StartupLightingCommand *lightingCommand, bool *requestPlayerGotoXZ,
 		int *playerGotoX, int *playerGotoZ,
 		bool *mutatedRuntimeState);
+	bool probePickupBlockingCommandChain(const Common::String &initialTag,
+		const Common::String &contextName, StartupInteractionResult &result, uint recursionDepth) const;
 	bool hasActionableCommandChain(const Common::String &initialTag) const;
 	bool setPlayerCurrentHitPoints(int hitPoints);
 	void logRuntimeSaveState(const char *operation) const;


Commit: 1a883b084a100c9c8466542966e50ec3d2a64e37
    https://github.com/scummvm/scummvm/commit/1a883b084a100c9c8466542966e50ec3d2a64e37
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:17+02:00

Commit Message:
HARVESTER: Align combat updates with native animation ticks

Changed paths:
    TRACKER.md
    engines/harvester/player.cpp
    engines/harvester/room.cpp
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h


diff --git a/TRACKER.md b/TRACKER.md
index 6235248dacf..ad307634232 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,10 +10,12 @@
 
 ## Next Suggested Action
 
-- Run one focused in-engine Harvester inventory validation on a clean build: open the inventory, left-click any carryable item, release the button, move the cursor past the native drag-close bounds (`x < 0x45`, `x > 0x238`, `y < 0x4b`, or `y > 0x1b3`) without clicking again, and confirm the overlay hides immediately while the selected item remains active for room use. Also recheck the older click-outside path and one inventory-to-inventory use to ensure the relaxed mouse-move handoff did not regress those flows.
+- Run one focused in-engine `MTPLNTINT` combat validation on a clean build: let `PAT_M` chase and attack Jimmy, confirm post-hit monster locomotion/attack cadence now follows the ABM frame advance instead of the prior over-driven room-loop timing, and verify Jimmy no longer accepts keyboard movement or keyboard attacks after HP reaches `0`. Recheck one nonlethal monster encounter afterward to confirm monsters still resume pursuit/attacks normally when the player survives contact.
 
 ## Last Confirmed Action
 
+- On March 25, 2026, rechecked Harvester's live combat cadence in Ghidra before touching source. `tick_monster_entity_runtime` (`0x54140`) and `update_player_combat_avatar_state` (`0x553a0`) both gate their handoff into `update_actor_runtime_state` (`0x4d750`) on the actor's animation-advanced flag, falling back to `tick_entity_visual_state` when no new frame has advanced; the same player wrapper also short-circuits the live combat path once `current_hit_points < 1`. Patched [`engines/harvester/runtime_entity.h`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h), [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp), [`engines/harvester/player.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/player.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so room movement now keys off the runtime entity's last animation advance once walk animations are active, monster chase updates stop over-driving between frames, and the room loop clears live movement/attack input when Jimmy's HP reaches `0`. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live Ghidra documentation counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
+- On March 25, 2026, deep-dived the native combat runtime around `spawn_npc_entity_from_record` (`0x4cd00`), `spawn_monster_entity_base` (`0x4d140`), and `update_actor_runtime_state` (`0x4d750`) in live `HARVEST.LE`. The new disassembly comments at `0x4cd45`, `0x4d2e7`, and `0x52cba` record the now-confirmed hit-feedback path: both NPC and monster constructors allocate a hidden `"blood.abm"` helper into live actor field `+0x11a8` with animation rate `10`, and the close-range hit-resolution block later forces that helper visible at frame `0` over the struck actor. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so player hits on monsterfying NPCs now spawn a transient `blood.abm` effect, same-room runtime mutation can patch current-room NPC and monster entities in place instead of always falling back to `refreshCurrentScene(true)`, and the spawned replacement monster keeps the live room loop so the existing native-style chase/attack logic can take over immediately. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live Ghidra documentation counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 25, 2026, rechecked Harvester's inventory handoff path directly in live Ghidra before touching source. `run_inventory_screen` at `0x7df10` still contains the native drag-close test at `0x7e364..0x7e38e`, using the recovered bounds `x=0x45..0x238` and `y=0x4b..0x1b3`, and its teardown returns a non-null carried `RenderEntityRuntime *` to `run_harvester_main_loop` when the inventory loop exits with an active item (`lines 1221..1267` in the current decompile). ScummVM's room loop was stricter: [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) only hid the overlay on `EVENT_MOUSEMOVE` while a selected inventory item was active if the primary button was still down, which forced an extra out-of-bounds click after the initial selection. Patched that branch so the latched inventory selection now closes the overlay on mouse move whenever the cursor leaves the recovered native drag-close region, removed the now-unused `isPrimaryMouseDown()` helper, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live Ghidra documentation counts are now `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 25, 2026, traced Stephanie's looping `wav=0x4a05` regression to an ambiguity between two different `BYE` entries in the native `DIALOG.RSP`: zero-based `dialog.rsp[0x2e4]` is the generic keyword-menu exit, while zero-based `dialog.rsp[0x2fd]` is the hidden typed-topic alias inside Stephanie's spyhole continuation cluster (`0x2f9..0x2fd`). The shipped resource file confirms `dialog.rsp[0x2cf] = Those People/Wedding/Grounded/Amnesia`, `dialog.rsp[0x2f8] = Lodge/Amnesia`, and the hidden follow-up strings `0x2f9 = Peephole`, `0x2fa = Hole`, `0x2fb = Spy`, `0x2fc = Spyhole`, `0x2fd = BYE`; live Ghidra notes for `handle_talk_to_stephanie @ 0x36710` already bounded that orphan tail as reachable only after the visible buffer advances to `dialog.rsp[0x2f8]`, with the generic `BYE` still exiting through `0x46a4`. ScummVM was losing that distinction because [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp) only returned the selected text, so a clicked generic `BYE` could text-match the hidden `0x2fd` branch in [`engines/harvester/npc/stephanie_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/stephanie_dialogue.cpp). Patched [`engines/harvester/npc/dialogue_runtime.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dialogue_runtime.h) and [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp) so keyword selection metadata now tracks typed `Other` input versus the hardcoded generic `BYE`, and tightened Stephanie's hidden spyhole branch to require both the native `dialog.rsp[0x2f8]` state and typed input before `0x2f9..0x2fd` are considered. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `912` total functions, `800` named/documented, and `112` remaining `FUN_*`.
 - On March 25, 2026, traced the ESC -> `Options` label drift to native `MENU.INI` padding rather than a special-case slider anchor. Ghidra shows `run_main_menu` (`0x67390`) copying `options_menu_1..7` from `MENU.INI` into raw buffers and centering them by measured width; the shipped file keeps trailing spaces in `options_menu_1=SOUND FX           `, `options_menu_2=MUSIC         `, and `options_menu_3=GAMMA         ` so the visible glyphs sit left of center before `VOLUME.BM` / `INDICATR.BM` are drawn. ScummVM now preserves that raw spacing in [`engines/harvester/menu.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/menu.cpp) when loading the option labels, and [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) records the confirmed native layout detail. Progress counts remain `912` total functions, `800` named/documented, and `112` remaining `FUN_*`.
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 440e54df563..6f087b4c701 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -426,6 +426,13 @@ static uint32 computeAnimationTickInterval(int rate) {
 }
 
 static bool consumePlayerMovementTick(StartupRoomPlayerState &playerState) {
+	if (playerState.entity &&
+			playerState.entity->isAnimationEnabled() &&
+			playerState.entity->getAnimationRate() != 0) {
+		playerState.nextMovementTick = 0;
+		return playerState.entity->didAnimationAdvanceLastTick();
+	}
+
 	const uint32 interval = computeAnimationTickInterval(kRoomPlayerWalkAnimationRate);
 	if (interval == 0) {
 		playerState.nextMovementTick = 0;
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index ce960c894a5..e11ec5d21fe 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -2310,9 +2310,17 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				continue;
 			}
 
-			if (combatState.nextMovementTick != 0 && (int32)(now - combatState.nextMovementTick) < 0)
-				continue;
-			combatState.nextMovementTick = now + moveInterval;
+			const bool animationDrivesMovement =
+				entity->isAnimationEnabled() && entity->getAnimationRate() != 0;
+			if (animationDrivesMovement) {
+				combatState.nextMovementTick = 0;
+				if (!entity->didAnimationAdvanceLastTick())
+					continue;
+			} else {
+				if (combatState.nextMovementTick != 0 && (int32)(now - combatState.nextMovementTick) < 0)
+					continue;
+				combatState.nextMovementTick = now + moveInterval;
+			}
 
 			const int previousX = monster.posX;
 			const int previousY = monster.posY;
@@ -3289,6 +3297,15 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			break;
 		}
 
+		const bool playerCanAct =
+			_engine.getStartupScript() &&
+			_engine.getStartupScript()->getPlayerCurrentHitPoints() > 0;
+		if (!playerCanAct && (moveLeft || moveRight || moveUp || moveDown ||
+				playerState.hasMoveTarget || playerState.turnActive || playerState.attackActive)) {
+			attackModifierHeld = false;
+			stopPlayerRegionInteraction();
+		}
+
 		bool playerAdvancedThisFrame = false;
 		Common::Error combatError = resolvePlayerAttackContact();
 		if (combatError.getCode() != Common::kNoError)
@@ -3305,7 +3322,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		}
 		const bool keyboardAttackRequested =
 			attackModifierHeld && (moveLeft || moveRight || moveUp || moveDown);
-		if (!playerState.attackActive && keyboardAttackRequested &&
+		if (playerCanAct &&
+				!playerState.attackActive && keyboardAttackRequested &&
 				!idleState.active && !idleState.exiting) {
 			const uint32 now = Player::getRuntimeClockTicks();
 			if (nextKeyboardAttackAllowedTick == 0 || (int32)(now - nextKeyboardAttackAllowedTick) >= 0) {
@@ -3332,7 +3350,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			needsRedraw = true;
 		}
 
-		if (!playerState.attackActive && !keyboardAttackRequested && !idleState.active && !idleState.exiting) {
+		if (playerCanAct &&
+				!playerState.attackActive && !keyboardAttackRequested && !idleState.active && !idleState.exiting) {
 			if (Player::stepKeyboardMovement(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
 					playerState, moveLeft, moveRight, moveUp, moveDown)) {
 				playerAdvancedThisFrame = true;
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index c882c97b464..6e9b01216bb 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -356,6 +356,7 @@ void RuntimeEntity::setDepthScale(float scale) {
 }
 
 bool RuntimeEntity::tickVisualState(uint32 now) {
+	_animationAdvancedLastTick = false;
 	if (!_animationEnabled || _currentFrame < 0 || _animationTickInterval == 0)
 		return false;
 	if (now < _nextAnimationTick)
@@ -363,6 +364,7 @@ bool RuntimeEntity::tickVisualState(uint32 now) {
 
 	advanceAnimationFrame(_playBackwards ? -1 : -2);
 	_nextAnimationTick = now + _animationTickInterval;
+	_animationAdvancedLastTick = true;
 	return true;
 }
 
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index 1499d9e8aae..32f47d59b57 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -94,6 +94,7 @@ public:
 	int getCurrentFrame() const { return _currentFrame; }
 	int getLastFrame() const { return _lastFrame; }
 	uint getFrameCount() const { return _frames.size(); }
+	bool didAnimationAdvanceLastTick() const { return _animationAdvancedLastTick; }
 	void setAnimationFrameRange(int firstFrame, int lastFrame, bool looping);
 	void setAnimationSequence(int sequence);
 	int getAnimationSequence() const { return _animationSequence; }
@@ -155,6 +156,7 @@ private:
 	bool _pingPong = false;
 	bool _playBackwards = false;
 	bool _animationEnabled = true;
+	bool _animationAdvancedLastTick = false;
 	bool _visible = true;
 	bool _drawEnabled = true;
 	int _boundsWidth = 0;


Commit: 325f0c125e78011b38d6b3250c869a1b4b197044
    https://github.com/scummvm/scummvm/commit/325f0c125e78011b38d6b3250c869a1b4b197044
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:17+02:00

Commit Message:
HARVESTER: Sync actor runtime naming

Changed paths:
    TRACKER.md
    engines/harvester/monster.h
    engines/harvester/player.cpp
    engines/harvester/room.cpp
    engines/harvester/room_support.h
    engines/harvester/script.cpp


diff --git a/TRACKER.md b/TRACKER.md
index ad307634232..ea858c25ca5 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,10 +10,12 @@
 
 ## Next Suggested Action
 
-- Run one focused in-engine `MTPLNTINT` combat validation on a clean build: let `PAT_M` chase and attack Jimmy, confirm post-hit monster locomotion/attack cadence now follows the ABM frame advance instead of the prior over-driven room-loop timing, and verify Jimmy no longer accepts keyboard movement or keyboard attacks after HP reaches `0`. Recheck one nonlethal monster encounter afterward to confirm monsters still resume pursuit/attacks normally when the player survives contact.
+- Stay in live Ghidra around `update_actor_runtime_state` (`0x4d750`) and its immediate helpers, and only chase the still-unnamed actor-runtime pieces that still have direct engine-facing consequences after the source sync: `+0x112c`, `+0x113c`, `+0x1180`, `+0x10ac`, `+0x10b4`, plus globals `DAT_000d609c` and `DAT_000d60a0`. The next useful confirmation would be a narrow evidence pass on whether `+0x112c` is strictly the melee engage distance, whether `+0x10ac/+0x10b4` are additional knockback accumulators, and whether `DAT_000d609c` / `DAT_000d60a0` are specifically knockback-hit state rather than a broader combat-mode gate.
 
 ## Last Confirmed Action
 
+- On March 25, 2026, synced the Harvester engine source to the latest high-confidence live-Ghidra naming recovered from `update_actor_runtime_state` (`0x4d750`) and related actor-runtime helpers. Patched [`engines/harvester/monster.h`](/Users/alex/Workspace/scummvm/engines/harvester/monster.h), [`engines/harvester/room_support.h`](/Users/alex/Workspace/scummvm/engines/harvester/room_support.h), [`engines/harvester/player.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/player.cpp), [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so the engine now uses the same confirmed terms as the native database: monster spawn bounds are `screenMinXBound` / `screenMaxXBound` to match `g_entity_screen_min_x_bound` / `g_entity_screen_max_x_bound`; the hidden `"blood.abm"` combat helper is consistently described as a hit effect instead of a generic damage effect, matching `hit_effect_entity`; and Jimmy's melee audio timing field is `attackSoundPlaybackFrame`, reflecting that the native slot is a concrete playback/trigger frame rather than a loose state flag. Verified with a clean source sweep for stale identifiers and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4`. Live Ghidra documentation counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
+- On March 25, 2026, performed a focused live-Ghidra naming pass on `update_actor_runtime_state` (`0x4d750`) in `HARVEST.LE` and repaired `RenderEntityRuntime` so the actor-runtime offsets used by the decompile now carry stable names instead of raw `field_0x...` accesses. The high-confidence evidence came from cross-checking `update_actor_runtime_state`, `advance_entity_animation_frame` (`0x4c8a0`), `apply_pending_entity_knockback` (`0x53770`), `spawn_npc_entity_from_record` (`0x4cd00`), and `spawn_monster_entity_from_record` (`0x53a10`): `+0x109c` is the live blocker pointer (`current_blocker_entity`); `+0x10a0/+0x10a4/+0x10a8` are the per-frame movement deltas (`frame_x_step`, `frame_y_step`, `frame_z_step`); `+0x1088` is the one-frame shove slot (`frame_knockback_x_step`); `+0x1198` is the frame-left screen bound (`frame_left_x`); `+0x11a0/+0x11a1` are the active/desired actor state bytes; `+0x11a8` is the hidden hit-effect runtime (`hit_effect_entity`); `+0x11ac/+0x11b0` are the remaining knockback amount and decay step; and `+0x1134..+0x1178` now expose the monster attack/hit/footstep/death sound slots plus their trigger frames. Also renamed globals `0x0c3c08` / `0x0c3c0c` to `g_entity_screen_min_x_bound` / `g_entity_screen_max_x_bound`, because `advance_entity_animation_frame` clamps actor screen movement against those values and monster spawn seeds them from `MonsterRecord.min_x_bound` / `max_x_bound`. Saved the Ghidra program after rebuilding `RenderEntityRuntime` as a fixed-size `replaceAtOffset` layout so the added names stayed at the correct offsets. Live documentation counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 25, 2026, rechecked Harvester's live combat cadence in Ghidra before touching source. `tick_monster_entity_runtime` (`0x54140`) and `update_player_combat_avatar_state` (`0x553a0`) both gate their handoff into `update_actor_runtime_state` (`0x4d750`) on the actor's animation-advanced flag, falling back to `tick_entity_visual_state` when no new frame has advanced; the same player wrapper also short-circuits the live combat path once `current_hit_points < 1`. Patched [`engines/harvester/runtime_entity.h`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h), [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp), [`engines/harvester/player.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/player.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so room movement now keys off the runtime entity's last animation advance once walk animations are active, monster chase updates stop over-driving between frames, and the room loop clears live movement/attack input when Jimmy's HP reaches `0`. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live Ghidra documentation counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 25, 2026, deep-dived the native combat runtime around `spawn_npc_entity_from_record` (`0x4cd00`), `spawn_monster_entity_base` (`0x4d140`), and `update_actor_runtime_state` (`0x4d750`) in live `HARVEST.LE`. The new disassembly comments at `0x4cd45`, `0x4d2e7`, and `0x52cba` record the now-confirmed hit-feedback path: both NPC and monster constructors allocate a hidden `"blood.abm"` helper into live actor field `+0x11a8` with animation rate `10`, and the close-range hit-resolution block later forces that helper visible at frame `0` over the struck actor. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so player hits on monsterfying NPCs now spawn a transient `blood.abm` effect, same-room runtime mutation can patch current-room NPC and monster entities in place instead of always falling back to `refreshCurrentScene(true)`, and the spawned replacement monster keeps the live room loop so the existing native-style chase/attack logic can take over immediately. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live Ghidra documentation counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 25, 2026, rechecked Harvester's inventory handoff path directly in live Ghidra before touching source. `run_inventory_screen` at `0x7df10` still contains the native drag-close test at `0x7e364..0x7e38e`, using the recovered bounds `x=0x45..0x238` and `y=0x4b..0x1b3`, and its teardown returns a non-null carried `RenderEntityRuntime *` to `run_harvester_main_loop` when the inventory loop exits with an active item (`lines 1221..1267` in the current decompile). ScummVM's room loop was stricter: [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) only hid the overlay on `EVENT_MOUSEMOVE` while a selected inventory item was active if the primary button was still down, which forced an extra out-of-bounds click after the initial selection. Patched that branch so the latched inventory selection now closes the overlay on mouse move whenever the cursor leaves the recovered native drag-close region, removed the now-unused `isPrimaryMouseDown()` helper, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live Ghidra documentation counts are now `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
diff --git a/engines/harvester/monster.h b/engines/harvester/monster.h
index 2923132760f..6576b72ea20 100644
--- a/engines/harvester/monster.h
+++ b/engines/harvester/monster.h
@@ -59,8 +59,8 @@ struct StartupMonsterRecord {
 	bool visible = false;
 	bool savedVisible = false;
 	bool runtimeSpawned = false;
-	int minXBound = 0x14;
-	int maxXBound = 0x26b;
+	int screenMinXBound = 0x14;
+	int screenMaxXBound = 0x26b;
 	int attackSoundTriggerFrame = 0;
 	int hitSoundTriggerFrame = 0;
 	int footstepSoundTriggerFrame = 0;
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 6f087b4c701..a9038d3a834 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -326,7 +326,7 @@ static bool startResolvedAttackAnimation(StartupRoomPlayerState &playerState,
 		range.firstFrame + Player::resolveCombatLoadoutContactFrameOffset(playerState.combatLoadout);
 	playerState.attackResumeFacing = range.resumeFacing;
 	playerState.attackSoundPlayed = resolvePlayerAttackSoundSet(playerState.combatLoadout) == nullptr;
-	playerState.attackSoundFrame = playerState.attackSoundPlayed
+	playerState.attackSoundPlaybackFrame = playerState.attackSoundPlayed
 		? -1
 		: (range.firstFrame + kPlayerAttackSoundTriggerFrameOffset);
 	playerState.attackContactResolved = false;
@@ -769,7 +769,7 @@ bool Player::syncCombatLoadoutVisual(HarvesterEngine &engine, const StartupRoomS
 	playerState.attackContactFrame = -1;
 	playerState.attackResumeFacing = -1;
 	playerState.attackSoundPlayed = false;
-	playerState.attackSoundFrame = -1;
+	playerState.attackSoundPlaybackFrame = -1;
 	playerState.attackContactResolved = false;
 	playerState.attackTargetName.clear();
 	playerState.attackTargetClassId = -1;
@@ -838,8 +838,8 @@ bool Player::updateAttackAnimationState(HarvesterEngine &engine,
 	if (!playerState.attackActive || !playerState.entity)
 		return false;
 	if (!playerState.attackSoundPlayed &&
-			playerState.attackSoundFrame >= 0 &&
-			playerState.entity->getCurrentFrame() >= playerState.attackSoundFrame) {
+			playerState.attackSoundPlaybackFrame >= 0 &&
+			playerState.entity->getCurrentFrame() >= playerState.attackSoundPlaybackFrame) {
 		(void)playPlayerAttackSound(engine, playerState.combatLoadout);
 		playerState.attackSoundPlayed = true;
 	}
@@ -855,7 +855,7 @@ bool Player::updateAttackAnimationState(HarvesterEngine &engine,
 	playerState.attackContactFrame = -1;
 	playerState.attackResumeFacing = -1;
 	playerState.attackSoundPlayed = false;
-	playerState.attackSoundFrame = -1;
+	playerState.attackSoundPlaybackFrame = -1;
 	playerState.attackContactResolved = false;
 	playerState.attackTargetName.clear();
 	playerState.attackTargetClassId = -1;
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index e11ec5d21fe..2659dc6637c 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -66,9 +66,9 @@ static const float kNativeMonsterPursuitZTolerance = 2.0f;
 static const float kNativeMonsterHorizontalWaypointTolerance = 50.0f;
 static const uint32 kNativeKeyboardAttackRepeatTicks = 25;
 static const int kNativeKeyboardAttackSideWindow = 30;
-static const char *const kNativeCombatDamageEffectResourcePath = "blood.abm";
-static const int kNativeCombatDamageEffectAnimationRate = 10;
-static const float kNativeCombatDamageEffectRenderZBias = 0.01f;
+static const char *const kNativeHitEffectResourcePath = "blood.abm";
+static const int kNativeHitEffectAnimationRate = 10;
+static const float kNativeHitEffectRenderZBias = 0.01f;
 
 static int roundRoomCombatFloat(float value);
 
@@ -118,7 +118,7 @@ struct RoomMonsterCombatState {
 	uint32 nextAttackAllowedTick = 0;
 };
 
-struct RoomCombatEffectState {
+struct RoomHitEffectState {
 	Common::String entityName;
 	Common::String followTargetName;
 	Common::Point anchorPoint;
@@ -673,7 +673,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		playerState.turnTargetFacing = -1;
 		Common::Array<RoomMonsterCombatState> monsterCombatStates;
 		monsterCombatStates.resize(scene.state.roomMonsters.size());
-		Common::Array<RoomCombatEffectState> combatEffectStates;
+		Common::Array<RoomHitEffectState> hitEffectStates;
 		uint nextCombatEffectId = 0;
 		StartupResolvedText inspectText;
 		bool showingInspectText = false;
@@ -738,7 +738,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			if (!playerState.entity)
 				return false;
 			playerState.attackSoundPlayed = false;
-			playerState.attackSoundFrame = -1;
+			playerState.attackSoundPlaybackFrame = -1;
 			playerState.attackContactResolved = false;
 			playerState.attackTargetName.clear();
 			playerState.attackTargetClassId = -1;
@@ -979,39 +979,39 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			runtimeEntities->reinsertSceneEntity(entity);
 			return entity;
 		};
-		auto resolveCombatEffectAnchor = [&](const RuntimeEntity &targetEntity,
+		auto resolveHitEffectAnchor = [&](const RuntimeEntity &targetEntity,
 				Common::Point &anchorPoint, float &renderZ) {
 			const Common::Rect targetRect = targetEntity.getScreenRect();
 			anchorPoint.x = targetRect.left + targetRect.width() / 2;
 			anchorPoint.y = targetRect.top + targetRect.height() / 3;
-			renderZ = targetEntity.getZ() - kNativeCombatDamageEffectRenderZBias;
+			renderZ = targetEntity.getZ() - kNativeHitEffectRenderZBias;
 		};
-		auto spawnCombatDamageEffect = [&](RuntimeEntity &sourceEntity,
+		auto spawnCombatHitEffect = [&](RuntimeEntity &sourceEntity,
 				const Common::String &followTargetName) {
 			if (!runtimeEntities)
 				return false;
 
-			RoomCombatEffectState effectState;
+			RoomHitEffectState effectState;
 			effectState.entityName = Common::String::format("__combat_blood_%u", nextCombatEffectId++);
 			effectState.followTargetName = followTargetName;
-			resolveCombatEffectAnchor(sourceEntity, effectState.anchorPoint, effectState.renderZ);
+			resolveHitEffectAnchor(sourceEntity, effectState.anchorPoint, effectState.renderZ);
 
 			RuntimeEntity *effectEntity = runtimeEntities->spawnSceneAnimationEntity(
-				effectState.entityName, kNativeCombatDamageEffectResourcePath, effectState.anchorPoint,
-				effectState.renderZ, kNativeCombatDamageEffectAnimationRate, true, true, false,
+				effectState.entityName, kNativeHitEffectResourcePath, effectState.anchorPoint,
+				effectState.renderZ, kNativeHitEffectAnimationRate, true, true, false,
 				false, false, 0);
 			if (!effectEntity)
 				return false;
 
-			combatEffectStates.push_back(effectState);
+			hitEffectStates.push_back(effectState);
 			return true;
 		};
-		auto syncCombatDamageEffects = [&]() {
+		auto syncCombatHitEffects = [&]() {
 			if (!runtimeEntities)
 				return false;
 
 			bool changed = false;
-			for (RoomCombatEffectState &effectState : combatEffectStates) {
+			for (RoomHitEffectState &effectState : hitEffectStates) {
 				RuntimeEntity *effectEntity = runtimeEntities->findSceneEntityByName(effectState.entityName);
 				if (!effectEntity)
 					continue;
@@ -1020,7 +1020,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					RuntimeEntity *targetEntity =
 						runtimeEntities->findSceneEntityByName(effectState.followTargetName);
 					if (targetEntity && targetEntity->isVisible()) {
-						resolveCombatEffectAnchor(*targetEntity, effectState.anchorPoint, effectState.renderZ);
+						resolveHitEffectAnchor(*targetEntity, effectState.anchorPoint, effectState.renderZ);
 					}
 				}
 
@@ -1036,29 +1036,29 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 			return changed;
 		};
-		auto pruneCombatDamageEffects = [&]() {
+		auto pruneCombatHitEffects = [&]() {
 			bool removedAny = false;
-			for (uint i = 0; i < combatEffectStates.size();) {
-				const Common::String effectName = combatEffectStates[i].entityName;
+			for (uint i = 0; i < hitEffectStates.size();) {
+				const Common::String effectName = hitEffectStates[i].entityName;
 				RuntimeEntity *effectEntity = runtimeEntities
 					? runtimeEntities->findSceneEntityByName(effectName)
 					: nullptr;
 				if (!effectEntity) {
-					combatEffectStates.remove_at(i);
+					hitEffectStates.remove_at(i);
 					continue;
 				}
 
 				const bool finished =
 					!effectEntity->isAnimationEnabled() &&
 					effectEntity->getCurrentFrame() == effectEntity->getLastFrame();
-				if (finished && combatEffectStates[i].finished) {
+				if (finished && hitEffectStates[i].finished) {
 					removeSceneEntityByName(effectName);
-					combatEffectStates.remove_at(i);
+					hitEffectStates.remove_at(i);
 					removedAny = true;
 					continue;
 				}
 
-				combatEffectStates[i].finished = finished;
+				hitEffectStates[i].finished = finished;
 				++i;
 			}
 
@@ -1325,7 +1325,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			refreshedState.audioCommands = entryAudioCommands;
 			if (!loadRoomSceneResources(refreshedState, *_engine.getResources(), scene))
 				return false;
-			combatEffectStates.clear();
+			hitEffectStates.clear();
 			nextCombatEffectId = 0;
 			if (!startupFlow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
 				return false;
@@ -1361,7 +1361,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			playerState.attackContactFrame = -1;
 			playerState.attackResumeFacing = -1;
 			playerState.attackSoundPlayed = false;
-			playerState.attackSoundFrame = -1;
+			playerState.attackSoundPlaybackFrame = -1;
 			playerState.attackContactResolved = false;
 			playerState.attackTargetName.clear();
 			playerState.attackTargetClassId = -1;
@@ -2104,7 +2104,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				return Common::kNoError;
 			if (runtimeChanged && npcEntity) {
 				const Common::String followTargetName = npc->monsterfyTargetName;
-				if (spawnCombatDamageEffect(*npcEntity, followTargetName))
+				if (spawnCombatHitEffect(*npcEntity, followTargetName))
 					needsRedraw = true;
 			}
 
@@ -2356,7 +2356,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 						Player::computeDepthScale(scene.state, (float)monster.posZ) * (float)horizontalStepBase));
 					const int desiredLogicalCenterX = desiredLiveCenterX - monsterLiveCenterOffset;
 					monster.posX = stepTowardsRoomCombatInt(monster.posX, desiredLogicalCenterX, step);
-					monster.posX = CLIP<int>(monster.posX, monster.minXBound, monster.maxXBound);
+					monster.posX = CLIP<int>(monster.posX, monster.screenMinXBound, monster.screenMaxXBound);
 				}
 			}
 
@@ -3383,7 +3383,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		combatError = updateRoomMonsterCombat();
 		if (combatError.getCode() != Common::kNoError)
 			return combatError;
-		if (syncCombatDamageEffects())
+		if (syncCombatHitEffects())
 			needsRedraw = true;
 		if (startupFlow.hasPendingMainMenuReturn())
 			return Common::kNoError;
@@ -3419,7 +3419,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 		if (startupFlow.tickRuntimeEntities())
 			needsRedraw = true;
-		if (pruneCombatDamageEffects())
+		if (pruneCombatHitEffects())
 			needsRedraw = true;
 		if (runtimeEntities) {
 			Common::Array<Common::String> expiredTimerNames;
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index ec4e19fddfc..afc68b1db6b 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -73,7 +73,7 @@ struct StartupRoomPlayerState {
 	int attackContactFrame = -1;
 	int attackResumeFacing = -1;
 	bool attackSoundPlayed = false;
-	int attackSoundFrame = -1;
+	int attackSoundPlaybackFrame = -1;
 	bool attackContactResolved = false;
 	Common::String attackTargetName;
 	int attackTargetClassId = -1;
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index ca5e5af7001..797a67f2a03 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -170,8 +170,8 @@ static void syncStartupMonsterRecord(Common::Serializer &s, StartupMonsterRecord
 	s.syncString(record.deathSound);
 	syncBool(s, record.savedVisible);
 	syncBool(s, record.runtimeSpawned);
-	s.syncAsSint32LE(record.minXBound);
-	s.syncAsSint32LE(record.maxXBound);
+	s.syncAsSint32LE(record.screenMinXBound);
+	s.syncAsSint32LE(record.screenMaxXBound);
 	s.syncAsSint32LE(record.attackSoundTriggerFrame);
 	s.syncAsSint32LE(record.hitSoundTriggerFrame);
 	s.syncAsSint32LE(record.footstepSoundTriggerFrame);
@@ -896,9 +896,9 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			monster.visible = tokens[tagIndex + 20].equalsIgnoreCase("T");
 			monster.onDeathActionTag = tokens[tagIndex + 21];
 			if (tokens.size() > tagIndex + 22 && !tokens[tagIndex + 22].empty())
-				monster.minXBound = parseAsciiIntOrZero(tokens[tagIndex + 22]);
+				monster.screenMinXBound = parseAsciiIntOrZero(tokens[tagIndex + 22]);
 			if (tokens.size() > tagIndex + 23 && !tokens[tagIndex + 23].empty())
-				monster.maxXBound = parseAsciiIntOrZero(tokens[tagIndex + 23]);
+				monster.screenMaxXBound = parseAsciiIntOrZero(tokens[tagIndex + 23]);
 			monster.currentHitPoints = monster.initialHitPoints;
 			monster.savedVisible = monster.visible;
 			if (monster.active)
@@ -1228,25 +1228,25 @@ void Script::logRuntimeSaveState(const char *operation) const {
 			: (monster.active ? true : monster.visible);
 		const bool defaultSavedVisible = defaultVisible;
 		const int defaultCurrentHitPoints = baseMonster ? baseMonster->initialHitPoints : monster.initialHitPoints;
-		const int defaultMinXBound = baseMonster ? baseMonster->minXBound : monster.minXBound;
-		const int defaultMaxXBound = baseMonster ? baseMonster->maxXBound : monster.maxXBound;
+		const int defaultScreenMinXBound = baseMonster ? baseMonster->screenMinXBound : monster.screenMinXBound;
+		const int defaultScreenMaxXBound = baseMonster ? baseMonster->screenMaxXBound : monster.screenMaxXBound;
 		if (!baseMonster ||
 				monster.active != defaultActive ||
 				monster.visible != defaultVisible ||
 				monster.savedVisible != defaultSavedVisible ||
 				monster.currentHitPoints != defaultCurrentHitPoints ||
 				monster.runtimeSpawned ||
-				monster.minXBound != defaultMinXBound ||
-				monster.maxXBound != defaultMaxXBound) {
+				monster.screenMinXBound != defaultScreenMinXBound ||
+				monster.screenMaxXBound != defaultScreenMaxXBound) {
 			debugC(1, kDebugGeneral,
-				"Harvester: %s runtime monster '%s' active=%d visible=%d savedVisible=%d spawned=%d hp=%d/%d bounds=[%d,%d] base_active=%d base_visible=%d base_savedVisible=%d base_hp=%d/%d base_bounds=[%d,%d]",
+				"Harvester: %s runtime monster '%s' active=%d visible=%d savedVisible=%d spawned=%d hp=%d/%d screen_bounds=[%d,%d] base_active=%d base_visible=%d base_savedVisible=%d base_hp=%d/%d base_screen_bounds=[%d,%d]",
 				operation, monster.monsterName.c_str(), monster.active, monster.visible,
 				monster.savedVisible, monster.runtimeSpawned,
 				monster.currentHitPoints, monster.initialHitPoints,
-				monster.minXBound, monster.maxXBound,
+				monster.screenMinXBound, monster.screenMaxXBound,
 				defaultActive, defaultVisible, defaultSavedVisible,
 				defaultCurrentHitPoints, baseMonster ? baseMonster->initialHitPoints : monster.initialHitPoints,
-				defaultMinXBound, defaultMaxXBound);
+				defaultScreenMinXBound, defaultScreenMaxXBound);
 		}
 	}
 
@@ -1331,8 +1331,8 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 				runtimeMonster.deathSound = baseMonster->deathSound;
 				runtimeMonster.savedVisible = runtimeMonster.visible;
 				runtimeMonster.runtimeSpawned = false;
-				runtimeMonster.minXBound = baseMonster->minXBound;
-				runtimeMonster.maxXBound = baseMonster->maxXBound;
+				runtimeMonster.screenMinXBound = baseMonster->screenMinXBound;
+				runtimeMonster.screenMaxXBound = baseMonster->screenMaxXBound;
 				runtimeMonster.attackSoundTriggerFrame = baseMonster->attackSoundTriggerFrame;
 				runtimeMonster.hitSoundTriggerFrame = baseMonster->hitSoundTriggerFrame;
 				runtimeMonster.footstepSoundTriggerFrame = baseMonster->footstepSoundTriggerFrame;


Commit: 3d954839db09b15a423f4f1772f27b5723adbd05
    https://github.com/scummvm/scummvm/commit/3d954839db09b15a423f4f1772f27b5723adbd05
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:18+02:00

Commit Message:
HARVESTER: Restore native game-over menu flow

Changed paths:
    engines/harvester/flow.cpp
    engines/harvester/flow.h
    engines/harvester/menu.cpp
    engines/harvester/menu.h
    engines/harvester/room.cpp


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 5cbf1a135b5..d2fcb39197e 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -1175,6 +1175,7 @@ Common::Error Flow::run() {
 		return Common::kReadingFailed;
 
 	clearPendingMainMenuReturn();
+	clearPendingGameOverReturn();
 	clearPendingNewGameRestart();
 	resetRoomNpcDialogueState();
 	Common::Error error = Common::kNoError;
@@ -1556,6 +1557,7 @@ bool Flow::takeQueuedDialogueInteraction(StartupInteractionResult &interaction)
 
 void Flow::prepareForNewGame() {
 	clearPendingMainMenuReturn();
+	clearPendingGameOverReturn();
 	clearPendingNewGameRestart();
 	_engine.clearPendingLoadedStartupSaveRoomState();
 	_engine.clearPendingLoadedDialogueStateBlob();
@@ -1583,6 +1585,25 @@ void Flow::clearPendingNewGameRestart() {
 	_pendingNewGameRestart = false;
 }
 
+void Flow::requestGameOverReturn() {
+	requestMainMenuReturn();
+	_pendingGameOverReturn = true;
+}
+
+bool Flow::hasPendingGameOverReturn() const {
+	return _pendingGameOverReturn;
+}
+
+bool Flow::takePendingGameOverReturn() {
+	const bool requested = _pendingGameOverReturn;
+	_pendingGameOverReturn = false;
+	return requested;
+}
+
+void Flow::clearPendingGameOverReturn() {
+	_pendingGameOverReturn = false;
+}
+
 void Flow::requestMainMenuReturn() {
 	_pendingMainMenuReturn = true;
 	_engine.clearCurrentStartupSaveRoomState();
diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index 19a29da6ebf..34343c66b1d 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -82,14 +82,18 @@ private:
 	bool hasPendingNewGameRestart() const;
 	bool takePendingNewGameRestart();
 	void clearPendingNewGameRestart();
+	void requestGameOverReturn();
+	bool hasPendingGameOverReturn() const;
+	bool takePendingGameOverReturn();
+	void clearPendingGameOverReturn();
 	void requestMainMenuReturn();
 	bool hasPendingMainMenuReturn() const;
 	bool takePendingMainMenuReturn();
 	void clearPendingMainMenuReturn();
 	void resetRoomNpcDialogueState();
-		void resetCursorAnimationSequence();
-		bool tickRuntimeEntities();
-		bool handleSystemEvent(const Common::Event &event, Common::Error &result);
+	void resetCursorAnimationSequence();
+	bool tickRuntimeEntities();
+	bool handleSystemEvent(const Common::Event &event, Common::Error &result);
 
 	HarvesterEngine &_engine;
 	Common::Array<Common::String> _quickTips;
@@ -102,6 +106,7 @@ private:
 	StartupInteractionResult _queuedDialogueInteraction;
 	bool _hasQueuedDialogueInteraction = false;
 	bool _pendingNewGameRestart = false;
+	bool _pendingGameOverReturn = false;
 	bool _pendingMainMenuReturn = false;
 	uint32 _roomSetupTransitionShownTick = 0;
 };
diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index 2740f62f3ce..8ff305742bc 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -71,6 +71,9 @@ static const char *const kLoadGameBitmapPath = "1:/GRAPHIC/OTHER/LOADGAME.BM";
 static const char *const kLoadGamePalettePath = "1:/GRAPHIC/PAL/LOADGAME.PAL";
 static const char *const kSaveGameBitmapPath = "1:/GRAPHIC/OTHER/SAVEGAME.BM";
 static const char *const kSaveGamePalettePath = "1:/GRAPHIC/PAL/SAVEGAME.PAL";
+static const char *const kGameOverBitmapPath = "1:/GRAPHIC/OTHER/GAMEOVER.BM";
+static const char *const kGameOverPalettePath = "1:/GRAPHIC/PAL/GAMEOVER.PAL";
+static const char *const kGameOverMusicPath = "SOUND/MUSIC/ANXIETY.CMP";
 
 static const int kOptionsSliderMinX = 0x16f;
 static const int kOptionsSliderMaxX = 0x22b;
@@ -299,6 +302,17 @@ static bool loadMenuTextConfig(HarvesterEngine &engine, RoomMenuTextConfig &conf
 	return true;
 }
 
+static void buildVisibleMainMenuItems(const Common::Array<Common::String> &source,
+		bool showSessionActions, Common::Array<Common::String> &dest) {
+	dest.clear();
+	for (const Common::String &item : source) {
+		if (!showSessionActions &&
+				(item.equalsIgnoreCase("SAVE GAME") || item.equalsIgnoreCase("LOAD GAME")))
+			continue;
+		dest.push_back(item);
+	}
+}
+
 static bool loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) {
 	Common::Array<byte> data;
 	if (!resources.loadFile(path, data) || data.size() < 12)
@@ -656,11 +670,13 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 
 	Common::Array<Common::String> mainMenuItems;
 	const bool showSessionActions = _engine.hasCurrentStartupSaveRoomState();
-	for (const Common::String &item : _menuItems) {
-		if (!showSessionActions &&
-				(item.equalsIgnoreCase("SAVE GAME") || item.equalsIgnoreCase("LOAD GAME")))
-			continue;
-		mainMenuItems.push_back(item);
+	buildVisibleMainMenuItems(_menuItems, showSessionActions, mainMenuItems);
+	if (startupFlow.takePendingGameOverReturn()) {
+		Common::Error gameOverError = showGameOverBackdrop(startupFlow);
+		if (gameOverError.getCode() != Common::kNoError)
+			return gameOverError;
+	} else {
+		clearMainMenuBackdrop();
 	}
 
 	Graphics::FrameLimiter limiter(g_system, 60);
@@ -669,7 +685,12 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 	bool needsRedraw = true;
 
 	auto captureMenuBackdrop = [&](IndexedBitmap &backdrop) -> bool {
-		renderMainMenuScreen(selectedItem, statusMessage, false, mainMenuItems);
+		if (_hasMainMenuBackdrop) {
+			renderBackdropMenuScreen(_mainMenuBackdrop, _mainMenuBackdropPalette, 1.0f,
+				mainMenuItems, selectedItem);
+		} else {
+			renderMainMenuScreen(selectedItem, statusMessage, false, mainMenuItems);
+		}
 		Graphics::Screen *screen = _engine.getScreen();
 		return screen && captureScreenBackdrop(*screen, backdrop);
 	};
@@ -691,6 +712,13 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 		statusMessage.clear();
 		needsRedraw = true;
 		(void)startupFlow.takePendingMainMenuReturn();
+		if (startupFlow.takePendingGameOverReturn()) {
+			Common::Error gameOverError = showGameOverBackdrop(startupFlow);
+			if (gameOverError.getCode() != Common::kNoError)
+				return gameOverError;
+		} else {
+			clearMainMenuBackdrop();
+		}
 		return Common::kNoError;
 	};
 	auto activateSelectedItem = [&]() -> Common::Error {
@@ -698,7 +726,7 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 			return Common::kNoError;
 
 		const Common::String &item = mainMenuItems[selectedItem];
-		const byte *menuPalette = art->getWaitPalette();
+		const byte *menuPalette = _hasMainMenuBackdrop ? _mainMenuBackdropPalette : art->getWaitPalette();
 		statusMessage.clear();
 
 		if (item.equalsIgnoreCase("NEW GAME")) {
@@ -777,7 +805,10 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
-			renderMainMenuStub(mainMenuItems, selectedItem, statusMessage);
+			if (_hasMainMenuBackdrop)
+				renderBackdropMenuScreen(_mainMenuBackdrop, _mainMenuBackdropPalette, 1.0f, mainMenuItems, selectedItem);
+			else
+				renderMainMenuStub(mainMenuItems, selectedItem, statusMessage);
 			needsRedraw = false;
 		}
 
@@ -789,7 +820,9 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 
 			switch (event.type) {
 			case Common::EVENT_MOUSEMOVE: {
-				const int hoveredItem = getMenuItemAt(_mousePos, mainMenuItems);
+				const int hoveredItem = _hasMainMenuBackdrop
+					? getBackdropMenuItemAt(_mousePos, mainMenuItems)
+					: getMenuItemAt(_mousePos, mainMenuItems);
 				if (hoveredItem != -1 && hoveredItem != selectedItem) {
 					selectedItem = hoveredItem;
 					needsRedraw = true;
@@ -820,7 +853,9 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 				if (mainMenuItems.empty())
 					break;
 
-				selectedItem = getMenuItemAt(_mousePos, mainMenuItems);
+				selectedItem = _hasMainMenuBackdrop
+					? getBackdropMenuItemAt(_mousePos, mainMenuItems)
+					: getMenuItemAt(_mousePos, mainMenuItems);
 				if (selectedItem == -1)
 					break;
 
@@ -844,6 +879,76 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 	return Common::kNoError;
 }
 
+Common::Error MenuSystem::showGameOverBackdrop(Flow &startupFlow) {
+	ResourceManager *resources = _engine.getResources();
+	Graphics::Screen *screen = _engine.getScreen();
+	if (!resources || !screen)
+		return Common::kReadingFailed;
+
+	IndexedBitmap backdrop;
+	byte palette[256 * 3];
+	if (!loadBitmapResource(*resources, kGameOverBitmapPath, backdrop) ||
+			!loadPaletteResource(*resources, kGameOverPalettePath, palette)) {
+		return Common::kReadingFailed;
+	}
+
+	_mainMenuBackdrop = backdrop;
+	memcpy(_mainMenuBackdropPalette, palette, sizeof(_mainMenuBackdropPalette));
+	_hasMainMenuBackdrop = true;
+	startupFlow.resetCursorAnimationSequence();
+	(void)_engine.playStartupMusic(kGameOverMusicPath);
+
+	bool needsRedraw = true;
+	Graphics::FrameLimiter limiter(g_system, 60);
+	while (!_engine.shouldQuit()) {
+		if (needsRedraw) {
+			applyMenuPalette(*screen, _engine, _mainMenuBackdropPalette, 1.0f);
+			screen->fillRect(screen->getBounds(), 0);
+			blitBitmap(*screen, _mainMenuBackdrop, 0, 0);
+			if (_engine.getRuntimeEntities())
+				_engine.getRuntimeEntities()->drawCursor(*screen);
+			screen->makeAllDirty();
+			screen->update();
+			needsRedraw = false;
+		}
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (startupFlow.handleSystemEvent(event, result))
+				return result;
+
+			switch (event.type) {
+			case Common::EVENT_MOUSEMOVE:
+				needsRedraw = true;
+				break;
+			case Common::EVENT_LBUTTONDOWN:
+			case Common::EVENT_RBUTTONDOWN:
+			case Common::EVENT_KEYDOWN:
+				return Common::kNoError;
+			default:
+				break;
+			}
+		}
+
+		if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities()) {
+			if (runtimeEntities->syncCursorEntityPosition(_mousePos))
+				needsRedraw = true;
+		}
+
+		limiter.delayBeforeSwap();
+		limiter.startFrame();
+	}
+
+	return Common::kNoError;
+}
+
+void MenuSystem::clearMainMenuBackdrop() {
+	_mainMenuBackdrop = IndexedBitmap();
+	memset(_mainMenuBackdropPalette, 0, sizeof(_mainMenuBackdropPalette));
+	_hasMainMenuBackdrop = false;
+}
+
 Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, Flow &startupFlow) {
 	Graphics::FrameLimiter limiter(g_system, 60);
@@ -923,7 +1028,7 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
-			renderRoomMenuStub(backdrop, palette, paletteBrightness, selectedItem);
+			renderBackdropMenuScreen(backdrop, palette, paletteBrightness, _menuItems, selectedItem);
 			needsRedraw = false;
 		}
 
@@ -935,7 +1040,7 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 
 			switch (event.type) {
 			case Common::EVENT_MOUSEMOVE: {
-				const int hoveredItem = getRoomMenuItemAt(_mousePos);
+				const int hoveredItem = getBackdropMenuItemAt(_mousePos, _menuItems);
 				if (hoveredItem != -1 && hoveredItem != selectedItem) {
 					selectedItem = hoveredItem;
 					needsRedraw = true;
@@ -969,7 +1074,7 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 				if (_menuItems.empty())
 					break;
 
-				selectedItem = getRoomMenuItemAt(_mousePos);
+				selectedItem = getBackdropMenuItemAt(_mousePos, _menuItems);
 				if (selectedItem == -1)
 					break;
 
@@ -1985,8 +2090,9 @@ void MenuSystem::renderMainMenuScreen(int selectedItem, const Common::String &st
 	screen->update();
 }
 
-void MenuSystem::renderRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, int selectedItem) const {
+void MenuSystem::renderBackdropMenuScreen(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, const Common::Array<Common::String> &menuItems,
+		int selectedItem) const {
 	Graphics::Screen *screen = _engine.getScreen();
 	const Art *art = _engine.getStartupArt();
 	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
@@ -2004,14 +2110,14 @@ void MenuSystem::renderRoomMenuStub(const IndexedBitmap &backdrop, const byte *p
 	blitTransparentBitmap(*screen, art->getLogoBitmap(), kLogoX, kLogoY);
 
 	const int lineSpacing = getNativeRoomMenuLineHeight(selectedFont);
-	for (uint i = 0; i < _menuItems.size(); ++i) {
+	for (uint i = 0; i < menuItems.size(); ++i) {
 		const Graphics::Font *font = ((int)i == selectedItem)
 			? static_cast<const Graphics::Font *>(&selectedFont)
 			: static_cast<const Graphics::Font *>(&unselectedFont);
-		const int width = font->getStringWidth(_menuItems[i]);
+		const int width = font->getStringWidth(menuItems[i]);
 		const int x = (screen->w - width) / 2;
 		const int y = kMenuStartY + (int)i * lineSpacing;
-		font->drawString(screen, _menuItems[i], x, y, width, 0);
+		font->drawString(screen, menuItems[i], x, y, width, 0);
 	}
 
 	if (_engine.getRuntimeEntities())
@@ -2038,16 +2144,17 @@ int MenuSystem::getMenuItemAt(const Common::Point &mousePos,
 	return -1;
 }
 
-int MenuSystem::getRoomMenuItemAt(const Common::Point &mousePos) const {
+int MenuSystem::getBackdropMenuItemAt(const Common::Point &mousePos,
+		const Common::Array<Common::String> &menuItems) const {
 	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
 	if (!selectedFontResource)
-		return getMenuItemAt(mousePos, _menuItems);
+		return getMenuItemAt(mousePos, menuItems);
 
 	HarvesterCftFont selectedFont(*selectedFontResource);
 	if (!selectedFont.isValid())
-		return getMenuItemAt(mousePos, _menuItems);
+		return getMenuItemAt(mousePos, menuItems);
 
-	return getNativeRoomMenuSelectionFromMouse(selectedFont, _menuItems.size(), mousePos);
+	return getNativeRoomMenuSelectionFromMouse(selectedFont, menuItems.size(), mousePos);
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/menu.h b/engines/harvester/menu.h
index 2dcddee3fcb..e1f730fc5f1 100644
--- a/engines/harvester/menu.h
+++ b/engines/harvester/menu.h
@@ -26,6 +26,7 @@
 #include "common/error.h"
 #include "common/rect.h"
 #include "common/str.h"
+#include "harvester/art.h"
 
 namespace Graphics {
 class Font;
@@ -35,7 +36,6 @@ namespace Harvester {
 
 class HarvesterEngine;
 class Flow;
-struct IndexedBitmap;
 
 class MenuSystem {
 public:
@@ -58,18 +58,25 @@ private:
 		bool &confirmed);
 	Common::Error runQuitGameConfirm(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, Flow &startupFlow);
+	Common::Error showGameOverBackdrop(Flow &startupFlow);
+	void clearMainMenuBackdrop();
 	void renderMainMenuStub(const Common::Array<Common::String> &menuItems, int selectedItem,
 		const Common::String &statusMessage) const;
 	void renderMainMenuScreen(int selectedItem, const Common::String &statusMessage,
 		bool drawCursor, const Common::Array<Common::String> &menuItems) const;
-	void renderRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, int selectedItem) const;
+	void renderBackdropMenuScreen(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, const Common::Array<Common::String> &menuItems,
+		int selectedItem) const;
 	int getMenuItemAt(const Common::Point &mousePos, const Common::Array<Common::String> &menuItems) const;
-	int getRoomMenuItemAt(const Common::Point &mousePos) const;
+	int getBackdropMenuItemAt(const Common::Point &mousePos,
+		const Common::Array<Common::String> &menuItems) const;
 
 	HarvesterEngine &_engine;
 	Common::Point &_mousePos;
 	const Common::Array<Common::String> &_menuItems;
+	IndexedBitmap _mainMenuBackdrop;
+	byte _mainMenuBackdropPalette[256 * 3] = { 0 };
+	bool _hasMainMenuBackdrop = false;
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 2659dc6637c..6bd3605238f 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1629,7 +1629,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 							return Common::kReadingFailed;
 					}
 
-					startupFlow.requestMainMenuReturn();
+					if (!interaction.deathFlicPath.empty())
+						startupFlow.requestGameOverReturn();
+					else
+						startupFlow.requestMainMenuReturn();
 					return Common::kNoError;
 				}
 


Commit: a84ecf6eb3ed2b843bf19c8e95e68978bb3237b6
    https://github.com/scummvm/scummvm/commit/a84ecf6eb3ed2b843bf19c8e95e68978bb3237b6
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:18+02:00

Commit Message:
HARVESTER: Refactor combat distance checks

- Replaced direct calculations for determining if the player and monster
  are close enough for an attack with a new helper function,
  areCombatantsWithinNativeCloseRange. This improves readability and
  encapsulates the logic for combatant proximity.

- Introduced horizontalGap to calculate the distance between the player
  and monster, enhancing clarity in the combat logic.

- Updated conditions to use horizontalGap instead of absLiveCenterDx for
  attack cooldown checks, ensuring consistent use of the new distance
  metric.

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 9f6ed051d2b..9d016bf7b76 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,10 +1,23 @@
-# HARVEST.LE Reverse Engineering Notes
+# HARVEST.LE Architecture
 
-## Scope
+## Summary
 
-This file captures preliminary reverse-engineering findings for `HARVEST.LE` from the entry point through the main startup path and into room/event transitions. Names below reflect what has already been applied in Ghidra when confidence was high.
+- This file records confirmed architecture and stable symbol recovery for `HARVEST.LE`.
+- The emphasis is subsystem shape, trusted names, and engine relationships already justified in Ghidra.
+- Detailed branch notes remain where they materially explain shared engine behavior, save/load semantics, or subsystem boundaries.
 
-## Startup Chain
+## At A Glance
+
+- Startup flow is now bounded from the LE loader through Watcom CRT setup, `startup_main`, XFILE initialization, town-script bootstrap, and `run_harvester_main_loop`.
+- Resource loading is split cleanly between numbered archive sets (`*.DAT` + `INDEX.00N`) and direct CD-ROM file paths under the shared XFILE abstraction.
+- The main loop owns room handoff consumption, player/world interaction dispatch, startup presentation, timer expiry, and shutdown.
+- World data is script-driven: rooms, entrances, commands, text, timers, NPCs, monsters, objects, and regions all parse into stable linked-list registries.
+- Dialogue, inventory, map travel, room menus, and save/load now read as one coherent UI layer rather than isolated helper clusters.
+- Platform/runtime coverage includes DPMI helpers, VESA/VGA backends, IRQ1 keyboard handling, `int 33h` mouse integration, joystick polling, and MSCDEX/CD checks.
+
+## Runtime And Gameplay
+
+### Startup Chain
 
 - `_entry` at `0x810e8` is only an LE loader thunk.
 - `watcom_le_crt_startup` at `0x81160` is Watcom DOS-extender CRT setup. It captures the command tail and environment block, counts `NO87=` overrides, clears startup/BSS state, runs init callbacks, and hands off to C startup.
@@ -21,7 +34,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `run_quick_tips_screen` at `0x6c890` is the startup quick-tips overlay shown on top of the already loaded `START -> PCROOM` room state.
   - It spawns the bottom action labels through `spawn_text_entity`, renders all three actions in color `0xc3`, and resolves the toggle through the `TEXT` records so `Show_Tips_ON` / `Show_Tips_OFF` display their text values rather than the raw keys.
 
-## Resource Layer
+### Resource Layer
 
 - `initialize_extended_file_system` at `0x1c640` performs one-time setup for the game's extended/XFILE resource system.
 - The routine copies startup/config path strings, resolves the configured `CD_ROM` root, and builds in-memory entry lists for numbered resource sets.
@@ -102,7 +115,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `prompt_for_cdrom_disc_change` at `0x488a0` is the user-facing disc-swap / drive-not-ready path reached from menus and scripted transitions.
   - It stops the active music stream, frees dialogue/XFILE cache state, shows the `CD-ROM DRIVE NOT READY!` / `PLEASE TRY AGAIN...` prompt, then reruns `shutdown_extended_file_system`, `initialize_extended_file_system`, and the `dialogue.idx` reopen path once the requested disc becomes available.
 
-## Town Script Runtime
+### Town Script Runtime
 
 - `initialize_town_script_runtime` at `0x42fc0` bootstraps the game's town script system.
 - It builds a `cold(%s)` startup expression from the configured `TOWN` value.
@@ -125,7 +138,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `g_town_script_runtime_state` at `0xd3050` is the 0x20-byte base state block zeroed by `reset_town_script_runtime_state` at `0x48000`.
   - The mixed Borland runtime descriptor block still binds a no-op paired callback above it, but the owning state object itself is now explicit from the `initialize_town_script_runtime` and `run_town_script_interpreter` xrefs.
 
-## Confirmed Cold-Start Resource Order
+### Confirmed Cold-Start Resource Order
 
 - `startup_main` at `0x10010` enters the normal game boot path in this order:
   - `initialize_extended_file_system`
@@ -153,7 +166,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `1:\graphic\other\harvlogo.bm`
 - Only after the FST playback does the main-loop startup open direct-path dialogue/font assets such as `dialogue.idx`, `graphic\font\harvfont.cft`, `graphic\font\harvfnt2.cft`, `graphic\font\textfont.cft`, `graphic\font\textfnt2.cft`, `graphic\font\medfont1.cft`, and `graphic\font\medfont2.cft`.
 
-## Main Loop
+### Main Loop
 
 - `run_harvester_main_loop` at `0x6dc70` is the primary Harvester boot and gameplay loop.
 - It prints the `Running Harvester v1.0` banner.
@@ -208,44 +221,18 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `blit_vesa_banked_rect` is the shared rectangle blitter used by the VESA mode records.
   - `select_vesa_bank_for_scanline` adjusts the active bank/window based on a scanline index.
 
-## Render Entity Runtime
-
-**Confidence:** High
+### Render Entity Runtime
 
-**Evidence**
-- `spawn_scaled_abm_entity_from_resource`, `attach_abm_resource_to_entity`, `show_entity_visual`, `hide_entity_visual`, `rescale_entity_sprite_for_depth`, `tick_entity_visual_state`, and `update_actor_runtime_state` all operate on the same `0x11bc` runtime record.
-- Recovered helper types now line up with the code paths that materialize and animate those entities:
-  - `BitmapBuffer` is the 12-byte `{ width, height, pixels }` bitmap header allocated by `attach_abm_resource_to_entity` and reused by `show_entity_visual` / `rescale_entity_sprite_for_depth`.
-  - `AbmFrameHeader` matches the packed per-frame ABM prefix consumed by `attach_abm_resource_to_entity` and `show_entity_visual`.
-  - `DirtyRectNode` is the 20-byte linked-list node used by the render-list dirty-rect merge path.
-  - `ActorWaypoint[10]` is the fixed 10-entry waypoint array starting at `RenderEntityRuntime + 0x10ac`.
-- Switching the simple entity helpers to Borland `__fastcall` makes the shared entity pointer recover cleanly in decompilation, which confirms that:
-  - `tick_entity_visual_state` writes the current opaque overlap blocker at `+0x109c`
-  - `update_actor_runtime_state` uses four directional blocker-history slots at `+0x108c`, `+0x1090`, `+0x1094`, and `+0x1098`
-  - `attach_abm_resource_to_entity` builds a `1024`-entry frame-header table at `+0x5c`
+- `Confidence:` High.
+- `Evidence:` `spawn_scaled_abm_entity_from_resource`, `attach_abm_resource_to_entity`, `show_entity_visual`, `hide_entity_visual`, `rescale_entity_sprite_for_depth`, `tick_entity_visual_state`, and `update_actor_runtime_state` all operate on the same `0x11bc` runtime record.
+- `Evidence:` recovered helper types line up with the entity materialization path: `BitmapBuffer` is the 12-byte `{ width, height, pixels }` bitmap header, `AbmFrameHeader` is the packed per-frame ABM prefix, `DirtyRectNode` is the 20-byte dirty-rect node, and `ActorWaypoint[10]` is the fixed array at `RenderEntityRuntime + 0x10ac`.
+- `Evidence:` switching the simple entity helpers to Borland `__fastcall` confirms that `tick_entity_visual_state` writes the current opaque overlap blocker at `+0x109c`, `update_actor_runtime_state` uses directional blocker-history slots at `+0x108c`, `+0x1090`, `+0x1094`, and `+0x1098`, and `attach_abm_resource_to_entity` builds a `1024`-entry frame-header table at `+0x5c`.
+- `Key Functions:` `spawn_scaled_abm_entity_from_resource`, `attach_abm_resource_to_entity`, `show_entity_visual`, `hide_entity_visual`, `rescale_entity_sprite_for_depth`, `tick_entity_visual_state`, `update_actor_runtime_state`.
+- `Key Data:` `RenderEntityRuntime`, `BitmapBuffer`, `AbmFrameHeader`, `DirtyRectNode`, `ActorWaypoint[10]`, `g_render_entity_list`.
+- `Notes:` the current typed runtime record cleanly covers render placement, animation timing, waypoint storage, and blocker-history detours.
+- `Notes:` the actor-only tail from `+0x1134` through `+0x118e` is still only partially typed, but it now clearly contains combat/use-state sound slots and facing-bank flags rather than generic render state.
 
-**Key Functions**
-- `spawn_scaled_abm_entity_from_resource`
-- `attach_abm_resource_to_entity`
-- `show_entity_visual`
-- `hide_entity_visual`
-- `rescale_entity_sprite_for_depth`
-- `tick_entity_visual_state`
-- `update_actor_runtime_state`
-
-**Key Data**
-- `RenderEntityRuntime`
-- `BitmapBuffer`
-- `AbmFrameHeader`
-- `DirtyRectNode`
-- `ActorWaypoint[10]`
-- `g_render_entity_list`
-
-**Notes**
-- The current typed runtime record cleanly covers render placement, animation timing, waypoint storage, and blocker-history detours.
-- The actor-only tail from `+0x1134` through `+0x118e` is still only partially typed, but it now clearly contains combat/use-state sound slots and facing-bank flags rather than generic render state.
-
-## Room And Event Flow
+### Room And Event Flow
 
 - `room_setup` at `0x73540` is confirmed by in-binary debug strings:
   - `room_setup(): no START entrance`
@@ -295,7 +282,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - Combat/stat side effects such as damage-type selection and player stat increments
   - Save-game handoff through the `SAVE_GAME` marker
 
-## World Record Parsers
+### World Record Parsers
 
 - `register_world_record_parsers` at `0x60350` registers parser callbacks for the script/world record tags:
   - `ANIM`
@@ -342,7 +329,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `parse_useitem_record` at `0x602c0`
 - This parser registry is the clearest anchor for the global linked-list heads used by `room_setup`, `dispatch_room_event_actions`, inventory handling, and dialogue.
 
-## Record Lists
+### Record Lists
 
 - `g_command_records` at `0xd5a94` is the `COMMAND` list consumed by `dispatch_room_event_actions`.
 - `g_room_entrances` at `0xd5a98` is the `ENTRANCE` list. Each record carries at least:
@@ -379,7 +366,9 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `run_load_game_menu` sets it before applying a slot.
   - `run_game_over_screen` clears it until the main menu starts or loads a new session again.
 
-## Menus And UI States
+## Interface And Input
+
+### Menus And UI States
 
 - `run_main_menu` at `0x67390` is the main-title/menu state.
   - It loads `main_menu_1` through `main_menu_6` strings from the text system.
@@ -407,7 +396,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - Callers pass the target text-entry entity and CFT font explicitly; the initial string and maximum length arrive through hidden register arguments in this build.
   - Input polling uses `g_buffered_console_char` plus DOS console-status queries through `is_console_char_available`.
 
-## Map And Dialogue Helpers
+### Map And Dialogue Helpers
 
 - `resolve_room_entrance` at `0x62d80` first searches `g_room_entrances` by entrance tag, then falls back to `g_town_map_entrypoints`.
   - On direct entrance matches it updates `g_pending_room_name`.
@@ -707,7 +696,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - Immediately before `spawn_text_entity`, it loads `EBX` from `g_textfont_cft @ 0xd5bc4`, so IDENT overlays use `TEXTFONT.CFT` atlas pixels rather than a generic single-color font path.
   - The text spawn is padded relative to that panel anchor: native adds `+10` on X and `+5` on Y before creating the IDENT entity, and passes `panel_width - 2` as the wrap-width argument.
 
-## Keyboard Input
+### Keyboard Input
 
 - `keyboard_irq1_handler` at `0x48460` is the custom IRQ1 keyboard handler.
   - It reads scan codes from port `0x60`, updates `g_last_keyboard_scancode` plus the `g_keyboard_scancode_pressed` table, clears key-release events by masking bit `0x80`, and acknowledges the PIC with `out 0x20, 0x20`.
@@ -721,7 +710,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `g_player_attack_pressed` at `0xd5929` is the scan-code `0x1d` slot consumed by the player-combat attack path.
   - With `g_player_attack_pressed` held, `update_player_combat_avatar_state` uses the directional scan-code slots as a keyboard-only attack selector: right picks state `0x1a` / frames `0x78..0x81`, left picks `0x17` / `0x5a..0x63`, and up/down only choose the upper/lower side-attack banks (`0x1b` / `0x6e..0x77`, `0x18` / `0x50..0x59`, `0x19` / `0x82..0x8b`, `0x16` / `0x64..0x6d`) when the current facing family is already right or left.
 
-## Pointer / Cursor Input
+### Pointer / Cursor Input
 
 - `g_mouse_driver_present` at `0xd5971` gates all observed `int 33h` mouse-service calls.
   - `run_harvester_main_loop`, `select_town_map_destination`, `run_save_game_menu`, `run_load_game_menu`, `run_main_menu`, and `FUN_00072550` all test it before setting mouse bounds or pushing a new cursor position through `int 33h`.
@@ -754,7 +743,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - For the normal sprite classes, palette index `0` fails that hitmask test and therefore behaves as transparent.
   - Class `3` bypasses the cursor bounds/mask checks and acts as the room-background fallback entity when nothing higher in the render list matches.
 
-## Joystick Input
+### Joystick Input
 
 - `initialize_joystick_input_state` at `0x14490` is the startup callback for the joystick/game-port path.
   - It registers a runtime-module record, then probes BIOS joystick support through `probe_joystick_presence_via_bios`.
@@ -768,7 +757,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `current_x`, `current_y`
   - `x_min_raw`, `x_max_raw`, `y_min_raw`, `y_max_raw`
 
-## Idle Animation Timing
+### Idle Animation Timing
 
 - `g_idle_animation_activity_timestamp` at `0xd5968` is one side of the main-loop idle timer input.
   - `keyboard_irq1_handler` updates it from the shared tick source on IRQ1 activity.
@@ -782,7 +771,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
 - `show_pause_message` at `0x797c0` renders the `HARVESTER IS PAUSED` overlay and backdates both idle timestamps by `0xc1c`, allowing the idle-animation gate to become eligible again immediately after pause dismissal.
   - It reuses `g_box3_panel_entity`, while `show_target_ident_text` selects among `g_box1_panel_entity` .. `g_box4_panel_entity` by matching `TextRecord.panel_id` against the `BOX1` / `BOX2` / `BOX3` / `BOX4` strings.
 
-## CD-ROM Media Check
+### CD-ROM Media Check
 
 - `startup_main` now verifies MSCDEX directly before the rest of game startup:
   - `probe_mscdex_installation_status` is the initial install-state probe.
@@ -792,7 +781,9 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - When it is nonzero, the function raises the `CD-ROM DRIVE NOT READY!` and `PLEASE TRY AGAIN...` messages.
   - The callback registered at `0x48890` is the only recovered producer-side writer so far.
 
-## Struct Recovery
+## Data Types And Helper Layer
+
+### Struct Recovery
 
 - The startup/menu configuration state is now bounded:
   - `g_config_environment` at `0xe2438` is the global linked-list store used by `startup_main`, the main menu toggles, and the quick-tips path.
@@ -803,6 +794,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `initialize_entity_common_fields` at `0x4ab70` seeds the common runtime entity header from register/stack arguments for `name`, `x`, `y`, `z`, a class/type argument, and one extra init argument.
   - `spawn_abm_entity_base` at `0x4b890` calls `initialize_entity_common_fields`, loads an ABM/bitmap resource through XFILE, decodes its frames, and seeds common animation state.
   - `spawn_monster_entity_base` at `0x4d140` layers monster-specific blood/effect and combat/AI state on top of `spawn_abm_entity_base`.
+  - Both `spawn_monster_entity_base` and `spawn_npc_entity_from_record` allocate a hidden `"blood.abm"` helper into runtime actor field `+0x11a8` through `spawn_abm_entity_from_resource`, with `EBX = 0x0a` seeding animation rate `10`.
   - `advance_entity_animation_frame` at `0x4c8a0` advances or forces the active frame/sequence, applies loop/ping-pong behavior, updates motion, and clamps X movement to the current active bounds.
 - The constructor helpers called by `room_setup` are now named:
   - `spawn_object_entity_from_record` at `0x4aee0`
@@ -953,7 +945,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - parser order is now explicit: after `room_name` and `music_path`, the parser reads three additional strings into `reserved_string_38`, `reserved_string_3c`, and `reserved_string_40`, then reads the separately validated `palette_path`, then the `dimmable` token, then `on_enter_tag` / `on_exit_tag`.
   - `free_loaded_world_data` frees `palette_path`, `reserved_string_38`, `reserved_string_3c`, and `reserved_string_40` as room-owned strings, which confirms that the old `runtime_40` interpretation was wrong. In the sampled shipping `HARVEST.SCR` data all three reserved strings are empty.
 
-## Helper Functions
+### Helper Functions
 
 - `strcmp_ascii` at `0x86430` is a fast `strcmp`-style helper that returns `0` on equality.
 - `atoi_decimal` at `0x869d4` is a small decimal parser used heavily by the room event system.
@@ -1054,6 +1046,7 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - `update_actor_runtime_state` at `0x4d750` is the shared per-frame actor state machine for live actor entities.
   - It is called from `run_harvester_main_loop`, the dialogue/response/keyword modal loops, and the player-combat wrapper, so actor motion/combat continues advancing while blocking UI is active.
   - It handles class-4/5/6 actor entities, advances animation/state transitions, maintains pursuit spacing against the player using `engage_distance`, applies room Z bounds and vertical motion, fires frame-timed sound hooks, resolves hit damage through `damage_amount`, and returns `0` when the caller should remove the actor entity from the world list.
+  - The close-range hit-resolution block reuses the hidden helper at live actor field `+0x11a8`: it links attacker/target runtime pointers, forces the helper back to frame `0`, repositions it over the struck actor, and shows it when the helper was still hidden.
   - For class-6 monsters specifically, pursuit is not a free-running room-script heuristic: the native state machine first closes base Z to within `g_player_combat_avatar->z +/- 2.0`, then compares center-to-center X against `engage_distance`, and only arms the close-range attack picker after that spacing gate is satisfied.
   - The same class-6 path derives its horizontal chase waypoint from the player's live left edge plus the current player/monster frame widths and treats that waypoint as satisfied within `50.0 * depth_scale` pixels, so native monster pursuit does not require exact center equality before it can stop walking and attack.
   - Once that close-range gate is satisfied and the per-attack cooldown allows it, the class-6 branch stores `g_player_combat_avatar` into live actor field `+0x11a4` before entering the close-range attack family; the later contact block resolves against that linked target instead of re-running the coarse engage-distance spacing test.
@@ -1182,6 +1175,21 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
         - representative entries pair `set_vesa_video_mode_3` / `reset_video_surface_context` with `g_video_surface_context` and size `0x10`, `FUN_00047ff0` / `reset_town_script_runtime_state` with `g_town_script_runtime_state` and size `0x20`, and `remove_keyboard_irq1_handler_state` / `install_keyboard_irq1_handler_state` with `g_keyboard_irq1_handler_state` and size `0x8`
       - the first music-facing entry at `0xc0c90` is an extended variant of that same packed format: it binds `release_selected_sound_bank_resources` to `g_sound_driver_callback_context` and `release_music_stream_buffers` at `0x1a440` to `g_music_stream_state`, but the remaining packed size/order fields still do not type cleanly enough to formalize as a struct
     - `release_sound_bank_resources` at `0x84873` is the shared bank teardown helper beneath that callback layer; it frees the bank's transient DOS buffer when present, clears per-bank tables, and may trigger the additional DPMI unlock/free path depending on the hidden caller flag.
+    - `initialize_sound_bank_resources` at `0x83fb3` is the matching per-bank setup path used by `initialize_sound_driver_from_sound_cfg_or_autodetect`.
+      - It scans five allocator-visible bank slots (`idx < 5`) for a free slot, loads or reuses the HMIDRV386 flag-`0x80` and optional flag-`0x40` records for that slot, allocates the transient DOS buffer through `allocate_sound_bank_temp_dos_buffer` when the selected config requests one, captures the callback-provided control-entry far pointers, runs the HMI callback init sequence, and then copies the selected driver config into the per-slot runtime tables.
+      - Those runtime tables are now partially typed in Ghidra: the live flag-`0x80` and flag-`0x40` selector pairs resolve as `g_sound_bank_flag80_primary_far_ptrs`, `g_sound_bank_flag80_secondary_far_ptrs`, `g_sound_bank_flag40_primary_far_ptrs`, and `g_sound_bank_flag40_secondary_far_ptrs`; the transient DOS buffer far pointers resolve as `g_sound_bank_temp_dos_buffer_far_ptrs`; the refreshed `0x6a` callback payload sits in `g_sound_bank_hmi_callback_records`; and the caller-provided config snapshot copied after successful init sits in `g_sound_bank_config_snapshots`.
+      - Within `g_sound_bank_hmi_callback_records`, `temp_buffer_fill_mode_code` controls the transient DOS-buffer fill pattern used during setup, `callback_flags` bit `0x02` triggers the extra op11->op12 handshake, and `flag40_record_id` supplies the fallback record ID for `load_hmidrv386_flag40_record_into_selector_pair` when the caller record leaves its explicit override dword at zero.
+      - The upstream `g_sound_bank_setup_source_record` at `0xc2d2c` is the static bank-setup source block passed into `initialize_sound_bank_resources`. Within `g_sound_bank_config_snapshots`, `requested_mix_rate_hz` is now confirmed from the caller side: `initialize_sound_driver_from_sound_cfg_or_autodetect` seeds that source record with `0x5622` (`22050`) immediately before bank setup, and `initialize_sound_bank_resources` forwards the same dword into `call_hmi_driver_callback_op2` as the only payload register on that path.
+      - The source-record dwords at `+0x14` and `+0x18` remain unresolved. In the shipped image both are zero-initialized, `+0x18` is preserved into `g_sound_bank_config_snapshots.reserved_setup_dword`, and no independent producer or consumer was confirmed beyond the opaque `call_hmi_driver_callback_op0` setup path.
+      - There is no confirmed reserved sixth bank slot behind these slabs. The flag-`0x80`, flag-`0x40`, and temp-buffer tables are packed as five 6-byte entries each, and the only normal producer of a bank index is `initialize_sound_bank_resources`, which scans `idx = 0..4`; the looser `< 6` checks in lower helpers are therefore not evidence of a live slot `5`.
+      - `refresh_hmidrv386_flag80_record_from_callback` at `0x8aa3a` is the small guard/helper immediately above that path; it verifies the per-bank flag-`0x80` selector pair exists and then refreshes the live `0x6a`-byte callback record through `copy_hmi_driver_record_from_callback_op8`.
+    - The HMI detection/bootstrap layer beneath that callback object is now partially explicit:
+      - `initialize_hmidet386_context` opens `hmidet.386`, allocates a DPMI-backed transfer block plus a cloned selector pair, locks the mapped linear region, and reuses that setup across later detection passes.
+      - `find_first_detected_hmidet_driver_record`, `find_next_detected_hmidet_driver_record`, and `load_hmidet_driver_record_by_device_id` walk the `hmidet.386` record table, read candidate records through the DPMI transfer buffer, and cache the selected file offset for later initialization.
+      - When the candidate record advertises callback-driven environment setup, the bootstrap path pulls the callback-supplied environment-variable name/value tables, resolves those names against the process environment, writes the resolved far pointers back into the callback table, and only then runs the detect callback.
+      - The staging block at `0xca2d8` is now typed as `g_sound_selected_hardware_config`: `device_port`, `device_irq`, and `device_dma` are shared between the manual `SOUND.CFG` parse path and the HMIDET auto-detect path. The fourth dword currently remains only as `callback_op1_dx_word`, because it is populated from the detect callback's `DX` result and no higher-level meaning is yet confirmed.
+      - The helpers `copy_hmi_driver_record_from_callback_op8[_raw]`, `call_hmi_driver_callback_op0`, `call_hmi_driver_callback_op1[_capture_result]`, `call_hmi_driver_callback_op2`, `call_hmi_driver_callback_op3_*`, `call_hmi_driver_callback_op4`, `call_hmi_driver_callback_op5`, `call_hmi_driver_callback_op10_capture_ptr_triplet`, and `chain_hmi_driver_callback_op11_into_op12` are now bounded as thin numeric-opcode wrappers around that far HMI callback interface rather than independent leaf algorithms.
+      - `query_windows_enhanced_mode_status` and `query_windows_enhanced_mode_signature_raw` are the paired `int 2F, AX=1600` probes used to gate the Windows 386 / `int 4B` compatibility path during HMI driver setup and teardown.
     - The special teardown branch inside `release_sound_bank_resources` is now bounded as an `int 0x4B` service path:
       - `call_int4b_service_8108_with_temp_dos_block` at `0x84c87` frees an existing LDT selector, allocates a temporary 32-paragraph DOS block via DPMI `int 31h, ax=0100`, copies the 16-byte `g_int4b_service_8108_request_template` into that block, simulates interrupt `0x4B` with `AX=0x8108` through `g_int4b_real_mode_registers`, and then frees the DOS block again
       - `simulate_int4b_service_8107_and_test_status_flag` at `0x874f0` and `simulate_int4b_service_8108` at `0x8755c` are the paired simulated-real-mode wrappers for services `0x8107` and `0x8108`; the exact driver semantics behind those services are still unresolved
@@ -1260,207 +1268,110 @@ This file captures preliminary reverse-engineering findings for `HARVEST.LE` fro
   - Choosing a visible keyword copies that literal on-screen topic text into `g_dialogue_selected_topic_text`; the `Other` path copies the typed text there as well. There is no separate hidden topic-id remap layer behind `dialog.rsp`.
   - ESC does not silently cancel the keyword menu; it writes that default topic into `g_dialogue_selected_topic_text` and returns through the normal handler path.
 
-## Room Menu Options UI
-
-**Confidence:** High
-
-**Evidence**
-- `run_main_menu` handles the `OPTIONS` row inline as case `3` and loads `options_menu_1` through `options_menu_7` from `MENU.INI`.
-- It spawns three `VOLUME.BM` bars and three `INDICATR.BM` sliders, with the active positions driven by `g_fx_volume_level_index`, `g_music_volume_level_index`, and `g_gamma_level_index`.
-- Moving the FX slider previews `1:\SOUND\EFFECTS\WHIP2.WAV`; moving the music slider updates `g_music_stream_state`; moving the gamma slider calls `update_gamma_brightness_scale` and reuploads `g_current_palette_buffer`.
-- The text / gore / password rows append inline ` - ...` suffixes through `format_main_menu_option_status_suffix`.
-- The `QUICK TIPS` row opens the same `TIPS.BM` overlay used by the startup quick-tips path, fed by lines from `ADJHEAD.RCS`.
-- The password row calls `prompt_for_password`, which in turn uses the shared `run_text_entry_dialog`; the stored string lives in `g_parental_password_buffer`.
-
-**Key Functions**
-- `run_main_menu`
-- `format_main_menu_option_status_suffix`
-- `update_gamma_brightness_scale`
-- `prompt_for_password`
-- `run_text_entry_dialog`
-
-**Key Data**
-- `g_fx_volume_level_index`
-- `g_music_volume_level_index`
-- `g_gamma_level_index`
-- `g_text_option_mode`
-- `g_gore_enabled`
-- `g_parental_password_buffer`
-
-**Notes**
-- The native submenu persists `GAMMA`, `FX_VOLUME`, and `MUSIC_VOLUME` on exit and updates the string-backed `TEXT`, `GORE`, `QUICK_TIPS`, and password state inline while the submenu is active.
-- The options text entities are spawned before the `VOLUME.BM` bars and `INDICATR.BM` sliders are added to the render list, so the first three labels sit underneath the bar art where their trailing edge overlaps that UI.
-- `prompt_for_password` does not open a separate boxed dialog. It overlays the current options menu with centered `ENTER PASSWORD` text rendered in `g_harvfont_cft`, spawns a `TEXT_ENTRY` entity at `(0xdc, 0xdc)` using `g_harvfnt2_cft`, hides the cursor entity while typing, and calls `run_text_entry_dialog` with hidden max length `8`.
-
-## Room Menu Help Screen
-
-**Confidence:** High
-
-**Evidence**
-- `run_controls_help_screen` at `0x6c3e0` is the dedicated two-page controls help viewer, and `run_main_menu` also contains the same behavior inline for the `HELP` row.
-- Page `0` loads `graphic\other\moushelp.bm` with `1:\graphic\pal\moushelp.pal`; page `1` loads `graphic\other\keyshelp.bm` with `1:\graphic\pal\keyshelp.pal`.
-- Each page is a fullscreen bitmap entity shown at `(0, 0)` after clearing the screen and uploading the page palette.
-- The loop waits for input, exits on `ESC` or secondary mouse, and toggles pages on primary mouse or any other key.
-- On exit it reuploads `g_current_palette_buffer`, clears the screen, reuploads the room palette again, and resumes the paused timer countdowns.
-
-**Key Functions**
-- `run_controls_help_screen`
-- `run_main_menu`
-
-**Key Data**
-- `g_current_palette_buffer`
-
-**Notes**
-- The help viewer is palette-driven and does not reuse the room backdrop while active; the room/menu image only returns after the fullscreen help page is dismissed.
-
-## Room Menu Quit Confirmation
-
-**Confidence:** High
-
-**Evidence**
-- `run_main_menu` handles the `QUIT GAME` row inline by spawning `textbox4.bm` at `(167, 200)` over the current room/menu backdrop and reusing the live room palette.
-- The prompt text comes from the `quitgame` key in `MENU.INI`, and the native string parser treats `/` as an inline line break for the confirmation message.
-- The YES/NO actions use fixed hit regions: YES at `x=0xd2..0x104`, `y=0xee..0x108`; NO at `x=0x172..0x19a`, `y=0xee..0x108`.
-- The loop confirms quit on the `Y` key path and cancels on `N`, `ESC`, or secondary mouse.
-- A confirmed quit returns the menu sentinel that unwinds the room loop into shutdown rather than restoring gameplay.
-
-**Key Functions**
-- `run_main_menu`
-
-**Key Data**
-- `MENU.INI:quitgame`
-- `textbox4.bm`
-
-**Notes**
-- Unlike the fullscreen help viewer or save UI, the quit confirm is an inline modal layered over the current room/menu frame.
-- The native branch is fully self-contained inside `run_main_menu`; no separate helper crossed the rename-confidence threshold during this pass.
-
-## Player Health And Inventory Status Portrait
-
-**Confidence:** High
-
-**Evidence**
-- `run_inventory_screen` reads `RenderEntityRuntime.current_hit_points` from the live player combat-avatar runtime and selects `INV_STAT1`, `INV_STAT2`, `INV_STAT3`, or `INV_STAT4` for the portrait in the lower-left of the inventory panel.
-- The thresholds are explicit in the inventory screen logic: `23..30 -> INV_STAT1`, `15..22 -> INV_STAT2`, `8..14 -> INV_STAT3`, `0..7 -> INV_STAT4`.
-- `run_harvester_main_loop` initializes `g_player_current_hit_points` to `30`, `spawn_player_combat_avatar` copies that global into `RenderEntityRuntime.current_hit_points`, and `teardown_player_combat_avatar` writes the runtime value back to the same global.
-- `dispatch_room_event_actions` handles `HEAL_PC`, `ADJ_HP`, and `KILL_PC` against that player HP state; `HEAL_PC` and `ADJ_HP` add to the current value and clamp to `30`, while `KILL_PC` zeros it.
-- `run_save_game_menu` and `run_load_game_menu` persist the same player combat-avatar HP field.
-
-**Key Functions**
-- `run_inventory_screen`
-- `dispatch_room_event_actions`
-- `spawn_player_combat_avatar`
-- `teardown_player_combat_avatar`
-- `run_save_game_menu`
-- `run_load_game_menu`
-
-**Key Data**
-- `g_player_current_hit_points`
-- `RenderEntityRuntime.current_hit_points`
-
-**Notes**
-- The inventory status portrait is a presentation of the persistent player HP state, not a separate UI-only counter.
-- The portrait is recalculated inside the inventory loop, so native healing or damage that lands while the inventory is open updates the selected `INV_STAT*` image immediately.
-- The portrait is materialized through `spawn_object_entity_from_record` and the shared render-entity path, so the rotated photo depends on palette-index-0 transparency rather than an opaque rectangular bitmap blit.
-
-## Inventory Item Carry / Use Handoff
-
-**Confidence:** High
-
-**Evidence**
-- `run_inventory_screen` sets the active-item latch when a carryable inventory object is selected, clears the previous interaction-text overlay, and rebuilds the centered `USING_ON_ID` prompt from `Use %s on ...`.
-- While that latch is set, `run_inventory_screen` treats the selected inventory object as the live dragged entity: it probes overlap against other inventory entities for `USEITEM` / dialogue routing, and if the dragged item leaves the inventory panel rect (`x=0x49..0x234`, `y=0x73..0x19b`) it reassigns the object's owner/room to the current room and returns that entity to the caller.
-- `run_harvester_main_loop` consumes that returned entity as the active selected item. Each frame it repositions the entity under the mouse, rebuilds the same centered `USING_ON_ID` overlay with `g_medfont1_cft`, and routes left-click use through `handle_target_interaction`.
-- `spawn_player_combat_avatar` seeds the live player entity with class `5` and the label string `"your inventory"`, and `handle_target_interaction` special-cases overlap with that target to move a non-inventory carried room item into `INVENTORY`.
-- `run_harvester_main_loop` branches pickup handling on the room-player presence flag at `0xd596c`: when the player is present, selecting a carryable room object enters the same cursor-follow carry state; when the player is absent, the loop moves the object straight into `INVENTORY`, removes its live render entity, and only then considers its action tag.
-
-**Key Functions**
-- `run_inventory_screen`
-- `run_harvester_main_loop`
-- `handle_target_interaction`
-
-**Key Data**
-- `g_useitem_records`
-- `ObjectRecord.current_owner_or_room`
-- the active-item latch at `0xd60b8`
-
-**Notes**
-- The standalone inventory UI does not use room-region exits while an item is active; the active item must first return to the room loop.
-- Native bottom-of-screen carry prompts are not drawn with the fallback GUI font path; they are materialized as the `USING_ON_ID` text-entry overlay using `MEDFONT1.CFT` at y `0x1ce`.
-- Plain pickup does not rerun full room materialization. Native mutates the live scene in place for both carry-start and direct-to-inventory pickup, and only action-driven runtime mutations or room transitions trigger the heavier refresh paths.
-
-## Inventory Secondary-Click Actions
-
-**Confidence:** High
-
-**Evidence**
-- In `run_inventory_screen`, the right-click branch first maps the weapon subset (`CLEAVER` through `POOLSTICK`) to fixed combat-loadout ids `1..20`, then calls `set_player_combat_loadout` and refreshes the inventory status state without leaving the inventory loop.
-- The same branch maps a separate hardcoded document/photo subset to fixed action tags rather than object-record metadata: `NOTE_PHOTOCOPY -> GO_BOYLCOPYCU`, `NOTE -> GO_BOYLNOTECU`, `CHECKBOOK -> GO_REGISTERCU`, `CHECKBOOK_PHOTOCOPY -> GO_RGSTRCPYCU`, `TV_DEED -> GO_TVDEED1CU`, `TV_DEED_PHOTOCOPY -> GO_TVDEED2CU`, `CLUE -> GOTO_CLUE_CU`, `AUTOGRAPH -> GO_AUTOGRPHCU`, plus the corresponding Whaley photo, casket photo, permit, lodge-application, safebook, patrol-schedule, and invite entries.
-- After dispatching one of those hardcoded closeup action tags, `run_inventory_screen` tears down the inventory render state and returns to the caller instead of remaining inside the inventory loop.
-- Only the six consumable/self-use items `SANDWICH`, `SANDWICH2`, `SYRINGE`, `ST_ASPRIN`, `ST_COUGHM`, and `ST_VITAMN` fall back to the clicked object's own `action_tag`; that branch stays inside the inventory loop so HP/status changes are visible immediately.
-
-**Key Functions**
-- `run_inventory_screen`
-- `dispatch_room_event_actions`
-- `set_player_combat_loadout`
-
-**Key Data**
-- the weapon-name to loadout-id mapping inside `run_inventory_screen`
-- the document/photo action-tag table at `0xc40c8`
-- `ObjectRecord.action_tag`
-
-**Notes**
-- Native inventory secondary-click is not a generic inspect path. It is a hardcoded dispatcher with three distinct behaviors: weapon loadout toggle, document/photo closeup, and object-defined consumable self-use.
-- The document/photo closeups are driven by global action tags rather than per-object metadata, which is why the ScummVM inventory overlay needs its own recovered mapping table for parity.
-
-## Inventory Hover Tooltip
-
-**Confidence:** High
-
-**Evidence**
-- In `run_inventory_screen`, the plain hover branch only runs when no inventory item is actively selected and neither mouse button is pressed. It walks `g_text_records`, matches the hovered inventory object's `inventory_text_key`, and rebuilds the hover label only when the resolved text changes.
-- The rebuilt label is created through `spawn_text_entity(..., "INV_TEXT", 0xbc, 0x19e, 0xfffffff4, 0)`, which confirms a fixed left-aligned tooltip anchor at `(188, 414)` with color `-12` rather than the centered bottom-of-screen interaction prompt path.
-- The callsite loads `EBX` from `g_textfont_cft @ 0xd5bc4` immediately before `spawn_text_entity`, so the inventory hover label uses `TEXTFONT.CFT`, not `MEDFONT1.CFT`.
-- The separate carry/use branch still measures text with `g_medfont1_cft` and spawns the centered `USING_ON_ID` overlay at y `0x1ce`, so the inventory tooltip and the carry prompt are distinct native UI elements.
-
-**Key Functions**
-- `run_inventory_screen`
-- `spawn_text_entity`
-- `spawn_text_entry_entity`
-
-**Key Data**
-- `ObjectRecord.inventory_text_key`
-- `g_textfont_cft`
-- `g_medfont1_cft`
-
-**Notes**
-- Native inventory hover text is not centered and does not reuse the room hover prompt renderer.
-- The inventory exit entity is handled separately; the fixed `INV_TEXT` hover path applies to inventory objects and the `INV_STAT*` status entities that resolve through `inventory_text_key`.
-
-## Inventory Weekday Label
-
-**Confidence:** High
-
-**Evidence**
-- Early in `run_inventory_screen`, the setup branch calls `is_object_in_inventory("HARVEST_BLADE")` and only spawns the weekday label when that check is false.
-- The same branch calls `get_current_story_day_index`, indexes the pointer table at `0xc4110`, and resolves the visible strings `Monday` through `Saturday` from that table.
-- The resulting text entity is spawned through the inventory text-font path with text id `DAYS_TEXT` at `(0x1e0, 0x18c)` and color `0xfffffff4`, which matches the recovered `TEXTFONT.CFT` inventory-label styling rather than the centered bottom prompt path.
-
-**Key Functions**
-- `run_inventory_screen`
-- `get_current_story_day_index`
-- `is_object_in_inventory`
-
-**Key Data**
-- the weekday string table at `0xc4110`
-- `g_textfont_cft`
-- `DAYS_TEXT`
-
-**Notes**
-- Native inventory weekday display is a persistent overlay element, not part of the hover-tooltip or carry-prompt branches.
-- The visible label is explicitly suppressed once `HARVEST_BLADE` has entered inventory.
-
-## Current Blockers
+## UI And Inventory Details
+
+### Room Menu Options UI
+
+- `Confidence:` High.
+- `Evidence:` `run_main_menu` handles the `OPTIONS` row inline as case `3` and loads `options_menu_1` through `options_menu_7` from `MENU.INI`.
+- `Evidence:` the shipped `MENU.INI` preserves trailing spaces in `options_menu_1..3` (`SOUND FX           `, `MUSIC         `, `GAMMA         `), and `run_main_menu` copies those raw bytes into the centered text buffers before measuring them, so the visible glyphs sit left of the mathematical center to leave room for the slider art.
+- `Evidence:` it spawns three `VOLUME.BM` bars and three `INDICATR.BM` sliders, with positions driven by `g_fx_volume_level_index`, `g_music_volume_level_index`, and `g_gamma_level_index`.
+- `Evidence:` moving the FX slider previews `1:\SOUND\EFFECTS\WHIP2.WAV`, moving the music slider updates `g_music_stream_state`, and moving the gamma slider calls `update_gamma_brightness_scale` and reuploads `g_current_palette_buffer`.
+- `Evidence:` the text, gore, and password rows append inline ` - ...` suffixes through `format_main_menu_option_status_suffix`; `QUICK TIPS` opens the same `TIPS.BM` overlay used by startup; the password row calls `prompt_for_password` and stores the result in `g_parental_password_buffer`.
+- `Key Functions:` `run_main_menu`, `format_main_menu_option_status_suffix`, `update_gamma_brightness_scale`, `prompt_for_password`, `run_text_entry_dialog`.
+- `Key Data:` `g_fx_volume_level_index`, `g_music_volume_level_index`, `g_gamma_level_index`, `g_text_option_mode`, `g_gore_enabled`, `g_parental_password_buffer`.
+- `Notes:` the native submenu persists `GAMMA`, `FX_VOLUME`, and `MUSIC_VOLUME` on exit and updates `TEXT`, `GORE`, `QUICK_TIPS`, and password state inline while active.
+- `Notes:` the options text entities are spawned before the `VOLUME.BM` bars and `INDICATR.BM` sliders, so the first three labels sit underneath the bar art where their trailing edge overlaps.
+- `Notes:` `prompt_for_password` overlays the current options menu with centered `ENTER PASSWORD` text, spawns `TEXT_ENTRY` at `(0xdc, 0xdc)` using `g_harvfnt2_cft`, hides the cursor entity while typing, and calls `run_text_entry_dialog` with hidden max length `8`.
+
+### Room Menu Help Screen
+
+- `Confidence:` High.
+- `Evidence:` `run_controls_help_screen` at `0x6c3e0` is the dedicated two-page controls help viewer, and `run_main_menu` also contains the same behavior inline for the `HELP` row.
+- `Evidence:` page `0` loads `graphic\other\moushelp.bm` with `1:\graphic\pal\moushelp.pal`; page `1` loads `graphic\other\keyshelp.bm` with `1:\graphic\pal\keyshelp.pal`.
+- `Evidence:` each page is a fullscreen bitmap entity shown at `(0, 0)` after clearing the screen and uploading the page palette; the loop exits on `ESC` or secondary mouse and toggles pages on primary mouse or any other key.
+- `Evidence:` on exit it reuploads `g_current_palette_buffer`, clears the screen, reuploads the room palette again, and resumes paused timer countdowns.
+- `Key Functions:` `run_controls_help_screen`, `run_main_menu`.
+- `Key Data:` `g_current_palette_buffer`.
+- `Notes:` the help viewer is palette-driven and does not reuse the room backdrop while active; the room/menu image only returns after the fullscreen help page is dismissed.
+
+### Room Menu Quit Confirmation
+
+- `Confidence:` High.
+- `Evidence:` `run_main_menu` handles the `QUIT GAME` row inline by spawning `textbox4.bm` at `(167, 200)` over the current room/menu backdrop and reusing the live room palette.
+- `Evidence:` the prompt text comes from the `quitgame` key in `MENU.INI`, and the native string parser treats `/` as an inline line break for the confirmation message.
+- `Evidence:` the YES/NO actions use fixed hit regions: YES at `x=0xd2..0x104`, `y=0xee..0x108`; NO at `x=0x172..0x19a`, `y=0xee..0x108`.
+- `Evidence:` the loop confirms quit on `Y` and cancels on `N`, `ESC`, or secondary mouse; a confirmed quit returns the menu sentinel that unwinds the room loop into shutdown.
+- `Key Functions:` `run_main_menu`.
+- `Key Data:` `MENU.INI:quitgame`, `textbox4.bm`.
+- `Notes:` unlike the fullscreen help viewer or save UI, the quit confirm is an inline modal layered over the current room/menu frame.
+- `Notes:` the native branch is fully self-contained inside `run_main_menu`; no separate helper crossed the rename-confidence threshold during this pass.
+
+### Player Health And Inventory Status Portrait
+
+- `Confidence:` High.
+- `Evidence:` `run_inventory_screen` reads `RenderEntityRuntime.current_hit_points` from the live player combat-avatar runtime and selects `INV_STAT1`, `INV_STAT2`, `INV_STAT3`, or `INV_STAT4` for the lower-left portrait.
+- `Evidence:` the thresholds are explicit: `23..30 -> INV_STAT1`, `15..22 -> INV_STAT2`, `8..14 -> INV_STAT3`, `0..7 -> INV_STAT4`.
+- `Evidence:` `run_harvester_main_loop` initializes `g_player_current_hit_points` to `30`, `spawn_player_combat_avatar` copies that global into `RenderEntityRuntime.current_hit_points`, and `teardown_player_combat_avatar` writes the runtime value back.
+- `Evidence:` `dispatch_room_event_actions` handles `HEAL_PC`, `ADJ_HP`, and `KILL_PC` against the same HP state, and `run_save_game_menu` / `run_load_game_menu` persist it.
+- `Key Functions:` `run_inventory_screen`, `dispatch_room_event_actions`, `spawn_player_combat_avatar`, `teardown_player_combat_avatar`, `run_save_game_menu`, `run_load_game_menu`.
+- `Key Data:` `g_player_current_hit_points`, `RenderEntityRuntime.current_hit_points`.
+- `Notes:` the inventory status portrait is a presentation of persistent player HP, not a separate UI-only counter.
+- `Notes:` the portrait is recalculated inside the inventory loop, so healing or damage that lands while inventory is open updates the selected `INV_STAT*` image immediately.
+- `Notes:` the portrait is materialized through `spawn_object_entity_from_record` and the shared render-entity path, so the rotated photo depends on palette-index-0 transparency rather than an opaque rectangular blit.
+
+### Inventory Item Carry / Use Handoff
+
+- `Confidence:` High.
+- `Evidence:` `run_inventory_screen` sets the active-item latch when a carryable inventory object is selected, clears the previous interaction-text overlay, and rebuilds the centered `USING_ON_ID` prompt from `Use %s on ...`.
+- `Evidence:` while that latch is set, `run_inventory_screen` treats the selected inventory object as the live dragged entity, probes overlap for `USEITEM` or dialogue routing, and returns the entity to the caller if it leaves the inventory panel rect `x=0x49..0x234`, `y=0x73..0x19b`.
+- `Evidence:` `run_harvester_main_loop` consumes that returned entity as the active selected item, repositions it under the mouse, rebuilds the same centered `USING_ON_ID` overlay with `g_medfont1_cft`, and routes left-click use through `handle_target_interaction`.
+- `Evidence:` `spawn_player_combat_avatar` seeds the live player entity with class `5` and label `"your inventory"`, and `handle_target_interaction` special-cases overlap with that target to move a non-inventory carried room item into `INVENTORY`.
+- `Evidence:` `run_harvester_main_loop` branches pickup handling on the room-player presence flag at `0xd596c`: player-present pickup enters the same cursor-follow carry state, while player-absent pickup moves the object straight into `INVENTORY`, removes its live render entity, and only then considers its action tag.
+- `Key Functions:` `run_inventory_screen`, `run_harvester_main_loop`, `handle_target_interaction`.
+- `Key Data:` `g_useitem_records`, `ObjectRecord.current_owner_or_room`, the active-item latch at `0xd60b8`.
+- `Notes:` the standalone inventory UI does not use room-region exits while an item is active; the active item must first return to the room loop.
+- `Notes:` native bottom-of-screen carry prompts are materialized as the `USING_ON_ID` text-entry overlay using `MEDFONT1.CFT` at y `0x1ce`, not the fallback GUI font path.
+- `Notes:` plain pickup does not rerun full room materialization; native mutates the live scene in place, and only action-driven runtime mutations or room transitions trigger the heavier refresh paths.
+
+### Inventory Secondary-Click Actions
+
+- `Confidence:` High.
+- `Evidence:` in `run_inventory_screen`, the right-click branch first maps the weapon subset (`CLEAVER` through `POOLSTICK`) to fixed combat-loadout ids `1..20`, then calls `set_player_combat_loadout` and refreshes the inventory status state without leaving the inventory loop.
+- `Evidence:` the same branch maps a separate hardcoded document/photo subset to fixed action tags rather than object-record metadata: `NOTE_PHOTOCOPY -> GO_BOYLCOPYCU`, `NOTE -> GO_BOYLNOTECU`, `CHECKBOOK -> GO_REGISTERCU`, `CHECKBOOK_PHOTOCOPY -> GO_RGSTRCPYCU`, `TV_DEED -> GO_TVDEED1CU`, `TV_DEED_PHOTOCOPY -> GO_TVDEED2CU`, `CLUE -> GOTO_CLUE_CU`, `AUTOGRAPH -> GO_AUTOGRPHCU`, plus the corresponding Whaley photo, casket photo, permit, lodge-application, safebook, patrol-schedule, and invite entries.
+- `Evidence:` after dispatching one of those hardcoded closeup tags, `run_inventory_screen` tears down the inventory render state and returns to the caller instead of staying in the loop.
+- `Evidence:` only the six consumable or self-use items `SANDWICH`, `SANDWICH2`, `SYRINGE`, `ST_ASPRIN`, `ST_COUGHM`, and `ST_VITAMN` fall back to the clicked object's own `action_tag`; that branch stays inside the inventory loop so HP and status changes remain visible.
+- `Key Functions:` `run_inventory_screen`, `dispatch_room_event_actions`, `set_player_combat_loadout`.
+- `Key Data:` the weapon-name to loadout-id mapping inside `run_inventory_screen`, the document/photo action-tag table at `0xc40c8`, `ObjectRecord.action_tag`.
+- `Notes:` native inventory secondary-click is not a generic inspect path; it is a hardcoded dispatcher with three distinct behaviors: weapon loadout toggle, document/photo closeup, and object-defined consumable self-use.
+- `Notes:` the document/photo closeups are driven by global action tags rather than per-object metadata, which is why the ScummVM inventory overlay needs its own recovered mapping table for parity.
+
+### Inventory Hover Tooltip
+
+- `Confidence:` High.
+- `Evidence:` in `run_inventory_screen`, the plain hover branch only runs when no inventory item is actively selected and neither mouse button is pressed; it walks `g_text_records`, matches the hovered inventory object's `inventory_text_key`, and rebuilds the hover label only when the resolved text changes.
+- `Evidence:` the rebuilt label is created through `spawn_text_entity(..., "INV_TEXT", 0xbc, 0x19e, 0xfffffff4, 0)`, which confirms a fixed left-aligned tooltip anchor at `(188, 414)` with color `-12` rather than the centered bottom-of-screen interaction prompt path.
+- `Evidence:` the callsite loads `EBX` from `g_textfont_cft @ 0xd5bc4` immediately before `spawn_text_entity`, so the inventory hover label uses `TEXTFONT.CFT`, not `MEDFONT1.CFT`.
+- `Evidence:` the separate carry or use branch still measures text with `g_medfont1_cft` and spawns the centered `USING_ON_ID` overlay at y `0x1ce`, so the inventory tooltip and carry prompt are distinct native UI elements.
+- `Key Functions:` `run_inventory_screen`, `spawn_text_entity`, `spawn_text_entry_entity`.
+- `Key Data:` `ObjectRecord.inventory_text_key`, `g_textfont_cft`, `g_medfont1_cft`.
+- `Notes:` native inventory hover text is not centered and does not reuse the room hover prompt renderer.
+- `Notes:` the inventory exit entity is handled separately; the fixed `INV_TEXT` hover path applies to inventory objects and the `INV_STAT*` status entities that resolve through `inventory_text_key`.
+
+### Inventory Weekday Label
+
+- `Confidence:` High.
+- `Evidence:` early in `run_inventory_screen`, the setup branch calls `is_object_in_inventory("HARVEST_BLADE")` and only spawns the weekday label when that check is false.
+- `Evidence:` the same branch calls `get_current_story_day_index`, indexes the pointer table at `0xc4110`, and resolves the visible strings `Monday` through `Saturday` from that table.
+- `Evidence:` the resulting text entity is spawned through the inventory text-font path with text id `DAYS_TEXT` at `(0x1e0, 0x18c)` and color `0xfffffff4`, which matches the recovered `TEXTFONT.CFT` inventory-label styling rather than the centered bottom prompt path.
+- `Key Functions:` `run_inventory_screen`, `get_current_story_day_index`, `is_object_in_inventory`.
+- `Key Data:` the weekday string table at `0xc4110`, `g_textfont_cft`, `DAYS_TEXT`.
+- `Notes:` native inventory weekday display is a persistent overlay element, not part of the hover-tooltip or carry-prompt branches.
+- `Notes:` the visible label is explicitly suppressed once `HARVEST_BLADE` has entered inventory.
+
+## Open Questions
+
+### Current Blockers
 
 - The remaining work is no longer list recovery. The blocker is semantic naming for fields whose shape is clear but whose gameplay meaning is still ambiguous.
 - The main unresolved clusters are:
diff --git a/TRACKER.md b/TRACKER.md
index ea858c25ca5..c9d3ade9320 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,17 +3,36 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `914`
-- Named/documented: `802`
-- Remaining `FUN_*`: `112`
-- Remaining undocumented total: `112`
+- Total functions: `871`
+- Named/documented: `819`
+- Remaining `FUN_*`: `52`
+- Remaining undocumented total: `52`
 
 ## Next Suggested Action
 
-- Stay in live Ghidra around `update_actor_runtime_state` (`0x4d750`) and its immediate helpers, and only chase the still-unnamed actor-runtime pieces that still have direct engine-facing consequences after the source sync: `+0x112c`, `+0x113c`, `+0x1180`, `+0x10ac`, `+0x10b4`, plus globals `DAT_000d609c` and `DAT_000d60a0`. The next useful confirmation would be a narrow evidence pass on whether `+0x112c` is strictly the melee engage distance, whether `+0x10ac/+0x10b4` are additional knockback accumulators, and whether `DAT_000d609c` / `DAT_000d60a0` are specifically knockback-hit state rather than a broader combat-mode gate.
+- Immediate: run an in-engine Harvester validation pass on at least one `GODEATHFLIC` branch such as Buster's fatal outcomes or the `PCHOUSE` `PC_WINDOW` police death. Confirm the death FST still plays to completion, the new `GRAPHIC/OTHER/GAMEOVER.BM` + `GRAPHIC/PAL/GAMEOVER.PAL` screen appears with `SOUND/MUSIC/ANXIETY.CMP`, the first click/key dismisses into the menu overlay on top of that backdrop instead of the generic stub, and `NEW GAME`, `LOAD GAME`, `OPTIONS`, `HELP`, and `QUIT GAME` all still behave correctly from the post-death menu.
+- Stay in live Ghidra on `call_hmi_driver_callback_op0` and its caller frame inside `initialize_sound_bank_resources`. The upstream source record is now labeled as `g_sound_bank_setup_source_record`, the callback-capture block at `0xca2d8` is typed as `g_sound_selected_hardware_config`, and the first three fields there are confirmed as `device_port`, `device_irq`, and `device_dma`. The remaining useful confirmation is whether the mixed far-pointer / stack-cleanup oddity in `call_hmi_driver_callback_op0` can be modeled well enough to expose what, if anything, the unresolved source dwords at `g_sound_bank_setup_source_record + 0x14` and `+ 0x18` contribute to the callback.
+- Before trusting `ClearTalkHandlerDialogueOverrides.java` as a full replacement for `InspectCallTermination.java`, keep validating it against single known-dirty handlers and immediately follow with `InspectCallTermination.java verify`. `CLOAK_ATND` showed the hardened bulk-clear script can safely remove a stale `CALL_RETURN`, but it also showed that already-cleared fallthroughs with `nextInst=null` may still need the explicit `InspectCallTermination.java repair` logic afterward. The next useful confirmation is another one-gap handler of the same shape, or a script update that directly repairs `missing_next_instruction` fallthroughs after the override is already clear.
+- Before using the bulk dialogue cleanup path in the live database again, validate the now-hardened `/Users/alex/ghidra_scripts/ClearTalkHandlerDialogueOverrides.java` on a disposable copy or a single known-dirty talk handler via the new NPC filter. The script still clears each stale `CALL_RETURN` override and hands the fallthrough address to `ClearFlowAndRepairCmd`, but it now aborts on the first repair failure and rolls the whole transaction back on cancel instead of committing a partial sweep. The next useful confirmation is whether a single-handler run such as `MOM` or `MR_POTTS` repairs cleanly and durably with the broader Ghidra repair path.
 
 ## Last Confirmed Action
 
+- On March 26, 2026, confirmed the native Harvester `GODEATHFLIC` path in live `HARVEST.LE` and patched the ScummVM port to match it instead of dropping straight into the generic main-menu stub. Ghidra showed `dispatch_room_event_actions` case `0x1c` calling `play_fst_sequence(arg1, 0)` and then `run_game_over_screen` (`0x7c540`), and `run_game_over_screen` in turn loading `1:\GRAPHIC\OTHER\GAMEOVER.BM` plus `1:\GRAPHIC\PAL\GAMEOVER.PAL`, starting `SOUND\MUSIC\ANXIETY.CMP`, waiting for dismissal input, and only then entering `run_main_menu` over that backdrop. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), [`engines/harvester/flow.h`](/Users/alex/Workspace/scummvm/engines/harvester/flow.h), [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp), [`engines/harvester/menu.h`](/Users/alex/Workspace/scummvm/engines/harvester/menu.h), and [`engines/harvester/menu.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/menu.cpp) so death FSTs now request a dedicated game-over return, the menu system stages the native `GAMEOVER` bitmap/palette plus `ANXIETY.CMP`, and the post-death menu overlays onto that backdrop instead of the generic wait-screen panel. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
+- On March 26, 2026, completed the upstream HMI source-record follow-up on `g_sound_bank_setup_source_record` (`0xc2d2c`) and the callback-capture block at `0xca2d8`, reran `/Users/alex/ghidra_scripts/RefineSoundBankDataLayouts.java`, and saved the updated type information into `HARVEST.LE`. The pass improved the live disassembly without overclaiming new semantics: it relabeled the static source block as `g_sound_bank_setup_source_record`, typed `0xca2d8` as `g_sound_selected_hardware_config`, and confirmed that the shared low fields are `device_port`, `device_irq`, and `device_dma` because both the manual `SOUND.CFG` path and the HMIDET auto-detect path populate those exact slots before bank setup. The fourth staging dword remains only `callback_op1_dx_word`, because it is copied from the detect callback's `DX` result and no higher-level meaning is confirmed. The source-side dwords at `+0x14` and `+0x18` remain unresolved: in the shipped image they are zero-initialized, `+0x18` is still only observed as the archived `reserved_setup_dword`, and no independent producer or consumer was confirmed beyond the opaque `call_hmi_driver_callback_op0` path. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
+- On March 26, 2026, completed the consumer-side HMI bank follow-up on `g_sound_bank_config_snapshots` and `g_sound_bank_hmi_callback_records`, reran `/Users/alex/ghidra_scripts/RefineSoundBankDataLayouts.java`, and saved the updated type information into `HARVEST.LE`. The pass replaced `field_24_code` with `temp_buffer_fill_mode_code`, because the only observed consumer is the setup path that checks for mode `8` before filling the transient DOS buffer with either `0x80` bytes or zeroes. It replaced `field_11` with `requested_mix_rate_hz`, because the caller seeds the source record with `0x5622` (`22050`) immediately before bank setup and the bank setup path forwards that dword into `call_hmi_driver_callback_op2` as the only payload register. The former `field_19` remains `reserved_setup_dword`: it is copied into the snapshot, but the only observed callback wrapper on that path (`call_hmi_driver_callback_op0`) ignores the stack slot where it is passed, and no other direct consumers were found. The same pass also closes the slot-index question conservatively: the live flag-`0x80`, flag-`0x40`, and temp-buffer slabs are packed as five 6-byte entries each, and the only normal producer of a bank index is `initialize_sound_bank_resources`, which scans `idx = 0..4`; the looser `< 6` checks in lower helpers therefore do not justify a reserved sixth bank slot. Updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) with the refined field names and the slot-geometry note. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
+- On March 26, 2026, ran `InspectCallTermination.java repair authority` in the live `HARVEST.LE` database and confirmed the saved result. The pre-check showed `handle_talk_to_authority @ 0x2b730` in a short dirty state (`0x2b730..0x2b7b2`, `95` bytes) with `dialogueCalls=2`, `gapCount=2`, and stale `CALL_RETURN` gaps at `0x2b75a` and `0x2b7a9`, including one missing fallthrough instruction at `0x2b75f`. The repair completed in one pass, expanded the handler body to `131` bytes while preserving the same entry/end range, recovered one additional reachable dialogue call, and finished with `gapCount=0`, `status=clean`, and `repairActions=4`. An initial parallel save attempt hit the expected active-transaction lock while the script was still unwinding, but the immediate retry saved `HARVEST.LE` successfully, and a fresh `verify authority` still reports `dialogueCalls=3`, `gapCount=0`, and `status=clean`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
+- On March 26, 2026, ran `InspectCallTermination.java repair CLOAK_ATND` in the live `HARVEST.LE` database after the filtered `ClearTalkHandlerDialogueOverrides.java` pass had removed the stale `CALL_RETURN` but left the fallthrough byte at `0x2dbca` undefined. The repair completed in a single pass on `handle_talk_to_cloak_atnd @ 0x2db80`: the pre-pass state was a 79-byte body with `dialogueCalls=1`, `gapCount=1`, and one remaining gap at `0x2dbc5`; the repair applied `2` actions, preserved the handler body bounds `0x2db80..0x2dbce`, and finished with `gapCount=0`, `status=clean`. Saved `HARVEST.LE` successfully and confirmed the clean state with a fresh `verify CLOAK_ATND`, which still reports `gapCount=0` and both dialogue entry points returning. This closes the residual `CLOAK_ATND` cleanup noted by the earlier filtered bulk-clear experiment and confirms that `InspectCallTermination.java` remains the reliable fallback for already-cleared fallthroughs whose next instruction is still undefined. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
+- On March 26, 2026, completed the sound-bank data-layout pass behind `initialize_sound_bank_resources` in live Ghidra and saved it as the rerunnable helper script `/Users/alex/ghidra_scripts/RefineSoundBankDataLayouts.java`. The pass proved that the allocator loop scans five bank slots (`idx < 5`) even though several helper routines still accept indices up to `5`, promoted `FUN_00084a8b` to `allocate_sound_bank_temp_dos_buffer`, and replaced the key raw slabs with typed names: `g_sound_bank_flag80_primary_far_ptrs`, `g_sound_bank_flag80_secondary_far_ptrs`, `g_sound_bank_flag40_primary_far_ptrs`, `g_sound_bank_flag40_secondary_far_ptrs`, `g_sound_bank_temp_dos_buffer_far_ptrs`, `g_sound_bank_hmi_callback_records`, and `g_sound_bank_config_snapshots`. The new types `FarPtr48`, `FarPtrSlot48`, `HmiDriverCallbackRecord`, and `SoundBankConfigSnapshot` now surface the confirmed field geometry directly in decompilation: the callback record exposes `field_24_code`, `callback_flags`, and the fallback `flag40_record_id`, while the config snapshot preserves the copied temp-buffer size, the control-entry far pointer, the live flag-`0x80` / flag-`0x40` far-pointer slots, and the explicit flag-`0x40` override dword. Updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) with the corrected slot count and named slabs. Live documentation counts are now `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
+- On March 26, 2026, ran the hardened `/Users/alex/ghidra_scripts/ClearTalkHandlerDialogueOverrides.java` against the single handler filter `handle_talk_to_cloak_atnd` in the live `HARVEST.LE` database. The pre-check via `InspectCallTermination.java verify CLOAK_ATND` showed a 79-byte body at `0x2db80..0x2dbce` with one stale dialogue gap at `0x2dbc5` (`flowType=CALL_TERMINATOR`, `override=CALL_RETURN`, `fallthrough=null`, `next=0x2dbca`, `nextInst=XOR EAX,EAX`). The filtered cleanup run reported `CLEARED=1`, `REPAIRED=1`, and saved successfully after an initial parallel save attempt hit the expected active-transaction lock. A fresh post-save verify confirmed that the stale override is gone and the call is now `UNCONDITIONAL_CALL` with `override=NONE` and `fallthrough=0x2dbca`, but `nextInst` at `0x2dbca` is still undefined, so the handler remains `status=needs_repair` with `gapCount=1`. This confirms the hardened script is safe enough to clear and save a single handler, but it does not yet fully replace the explicit fallthrough repair logic in `InspectCallTermination.java`. Documentation counts remain `871` total functions, `818` named/documented, and `53` remaining `FUN_*`.
+- On March 26, 2026, hardened the Ghidra helper script `/Users/alex/ghidra_scripts/ClearTalkHandlerDialogueOverrides.java` after the first `ClearFlowAndRepairCmd` upgrade so it is safer to test against the talk-handler cleanup problem. The script now accepts an optional exact NPC filter (`MOM` or `handle_talk_to_mom`), still clears each instruction-level `CALL_RETURN` override before invoking `ClearFlowAndRepairCmd` at the fallthrough address, aborts immediately on the first repair failure instead of logging-and-continuing, and uses `monitor.checkCancelled()` so canceling the run rolls the entire transaction back instead of committing a partial batch. Also retained the corrected `ghidra.program.model.listing.FlowOverride` import and re-verified the edited script with a compile-only `javac` pass against the local Ghidra 12.0.3 jars. No live `HARVEST.LE` database changes were made, so documentation counts remain `871` total functions, `818` named/documented, and `53` remaining `FUN_*`.
+- On March 26, 2026, finished the parent-layer HMI pass around the newly named callback wrappers and promoted the two remaining orchestrators. `FUN_0008aa3a` is now `refresh_hmidrv386_flag80_record_from_callback`, because it is only called from the bank-setup path after the flag-`0x80` selector pair is loaded or restored and its only work is to validate that selector pair and refresh the live `0x6a`-byte callback record through `copy_hmi_driver_record_from_callback_op8`. `FUN_00083fb3` is now `initialize_sound_bank_resources`, because its only caller is `initialize_sound_driver_from_sound_cfg_or_autodetect`, its teardown pair is `release_sound_bank_resources`, and the function itself chooses a free bank slot, loads/reuses the HMIDRV386 flag-`0x80` and optional flag-`0x40` records, allocates the transient DOS buffer when requested, captures callback control-entry pointers, runs the callback init sequence, and copies the selected driver config into the per-slot runtime tables. Added matching plate comments in Ghidra, updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) with the confirmed per-bank setup note, and saved `HARVEST.LE`. Live documentation counts remain `871` total functions, `818` named/documented, and `53` remaining `FUN_*`.
+
+- On March 26, 2026, completed a conservative naming pass over the real-RAM HMI callback helper cluster at `0x87005..0x87439` and documented the resulting subsystem facts. The new Ghidra script `/Users/alex/ghidra_scripts/RefineHmiCallbackHelpers.java` renamed 14 wrappers and attached plate comments that preserve the exact observed opcode/register behavior instead of guessing higher-level semantics: the `0x6a`-byte record copiers are now `copy_hmi_driver_record_from_callback_op8[_raw]`; the callback op wrappers are now `call_hmi_driver_callback_op0`, `call_hmi_driver_callback_op1[_capture_result]`, `call_hmi_driver_callback_op2`, `call_hmi_driver_callback_op3_*`, `call_hmi_driver_callback_op4`, `call_hmi_driver_callback_op5`, `call_hmi_driver_callback_op10_capture_ptr_triplet`, and `chain_hmi_driver_callback_op11_into_op12`; and the direct interrupt helpers are now `query_windows_enhanced_mode_signature_raw` and `query_dpmi_service_0500_global`. The evidence came from the surrounding HMIDET/HMIDRV call graph: `initialize_hmidet386_context`, `find_first_detected_hmidet_driver_record`, `find_next_detected_hmidet_driver_record`, `load_hmidet_driver_record_by_device_id`, `initialize_hmidet_driver_from_selected_record`, `release_sound_bank_resources`, `FUN_00083fb3`, the `hmidet.386` / `hmidrv.386` strings, the `int 2F AX=1600` Windows-enhanced-mode probes, and the DPMI / `int 4B` setup-teardown path. Also updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) with the confirmed HMI bootstrap notes. Live documentation counts remain `871` total functions, `816` named/documented, and `55` remaining `FUN_*`.
+
+- On March 26, 2026, created and ran the new Ghidra helper script `CleanupImagePseudoFunctions.java` in `/Users/alex/ghidra_scripts`, then applied it to `HARVEST.LE` and saved the program. The script maps `.image::` pseudo-function entries into normal RAM with the confirmed delta `loaded = .image - 0x1c9b0`, classifies each target as a real function entry or a mid-function offset, and supports dry-run/report versus apply/delete cleanup. The dry run resolved all 43 candidates cleanly: 42 were exact duplicates of real RAM functions and 1 (`.image::00079b9d`) was a false function start inside `update_render_entity_screen_position` at `0x5d1ed`. The apply pass deleted all 43 `.image` pseudo-functions, and a follow-up symbol search confirmed there are no remaining `FUN_.image__*` functions in the database. Live documentation counts are now `871` total functions, `802` named/documented, and `69` remaining `FUN_*`.
+
+- On March 26, 2026, performed a focused live-Ghidra investigation of the stray `FUN_.image_*` symbols in `HARVEST.LE` and confirmed they are analysis artifacts in the separate `.image` address space, not a coherent subsystem and not trustworthy standalone functions. Every sampled `.image` symbol had a one-byte body, zero decoded instructions, no callers, and decompiled only as `halt_baddata()`, even though raw bytes at those addresses are valid x86 code. Cross-checks showed the bytes map back into normal RAM-space code at a stable delta of `loaded = .image - 0x1c9b0`: `.image::000a3789` and `.image::000a37c6` map to the real DPMI wrappers `lock_linear_region_via_dpmi` (`0x86dd9`) and `unlock_linear_region_via_dpmi` (`0x86e16`); `.image::000a7b5d` maps to the IRQ/PIT helper cluster around `program_irq0_pit_divisor` (`0x8b1ad`); `.image::0004c1ad` maps to the dialogue handler `FUN_0002f7fd`; `.image::0004ad64` maps to the dialogue handler `FUN_0002e3b4`; and `.image::00079b9d` maps into the middle of `update_render_entity_screen_position` (`0x5d1ed`), proving that some `.image` labels are false function starts. No rename or architecture update was justified from this pass. Live documentation counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
+
 - On March 25, 2026, synced the Harvester engine source to the latest high-confidence live-Ghidra naming recovered from `update_actor_runtime_state` (`0x4d750`) and related actor-runtime helpers. Patched [`engines/harvester/monster.h`](/Users/alex/Workspace/scummvm/engines/harvester/monster.h), [`engines/harvester/room_support.h`](/Users/alex/Workspace/scummvm/engines/harvester/room_support.h), [`engines/harvester/player.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/player.cpp), [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so the engine now uses the same confirmed terms as the native database: monster spawn bounds are `screenMinXBound` / `screenMaxXBound` to match `g_entity_screen_min_x_bound` / `g_entity_screen_max_x_bound`; the hidden `"blood.abm"` combat helper is consistently described as a hit effect instead of a generic damage effect, matching `hit_effect_entity`; and Jimmy's melee audio timing field is `attackSoundPlaybackFrame`, reflecting that the native slot is a concrete playback/trigger frame rather than a loose state flag. Verified with a clean source sweep for stale identifiers and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4`. Live Ghidra documentation counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 25, 2026, performed a focused live-Ghidra naming pass on `update_actor_runtime_state` (`0x4d750`) in `HARVEST.LE` and repaired `RenderEntityRuntime` so the actor-runtime offsets used by the decompile now carry stable names instead of raw `field_0x...` accesses. The high-confidence evidence came from cross-checking `update_actor_runtime_state`, `advance_entity_animation_frame` (`0x4c8a0`), `apply_pending_entity_knockback` (`0x53770`), `spawn_npc_entity_from_record` (`0x4cd00`), and `spawn_monster_entity_from_record` (`0x53a10`): `+0x109c` is the live blocker pointer (`current_blocker_entity`); `+0x10a0/+0x10a4/+0x10a8` are the per-frame movement deltas (`frame_x_step`, `frame_y_step`, `frame_z_step`); `+0x1088` is the one-frame shove slot (`frame_knockback_x_step`); `+0x1198` is the frame-left screen bound (`frame_left_x`); `+0x11a0/+0x11a1` are the active/desired actor state bytes; `+0x11a8` is the hidden hit-effect runtime (`hit_effect_entity`); `+0x11ac/+0x11b0` are the remaining knockback amount and decay step; and `+0x1134..+0x1178` now expose the monster attack/hit/footstep/death sound slots plus their trigger frames. Also renamed globals `0x0c3c08` / `0x0c3c0c` to `g_entity_screen_min_x_bound` / `g_entity_screen_max_x_bound`, because `advance_entity_animation_frame` clamps actor screen movement against those values and monster spawn seeds them from `MonsterRecord.min_x_bound` / `max_x_bound`. Saved the Ghidra program after rebuilding `RenderEntityRuntime` as a fixed-size `replaceAtOffset` layout so the added names stayed at the correct offsets. Live documentation counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
 - On March 25, 2026, rechecked Harvester's live combat cadence in Ghidra before touching source. `tick_monster_entity_runtime` (`0x54140`) and `update_player_combat_avatar_state` (`0x553a0`) both gate their handoff into `update_actor_runtime_state` (`0x4d750`) on the actor's animation-advanced flag, falling back to `tick_entity_visual_state` when no new frame has advanced; the same player wrapper also short-circuits the live combat path once `current_hit_points < 1`. Patched [`engines/harvester/runtime_entity.h`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h), [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp), [`engines/harvester/player.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/player.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so room movement now keys off the runtime entity's last animation advance once walk animations are active, monster chase updates stop over-driving between frames, and the room loop clears live movement/attack input when Jimmy's HP reaches `0`. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live Ghidra documentation counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 6bd3605238f..885966b2327 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -2334,6 +2334,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			const int monsterCenterX = monsterRect.left + monsterRect.width() / 2;
 			const int liveCenterDx = playerCenterX - monsterCenterX;
 			const int absLiveCenterDx = ABS(liveCenterDx);
+			const int horizontalGap = computeRuntimeEntityHorizontalGap(*entity, *playerState.entity);
 			const int monsterLiveCenterOffset = monsterCenterX - monster.posX;
 			const int playerTargetZ = roundRoomCombatFloat(playerState.z);
 			const float zDelta = playerState.z - (float)monster.posZ;
@@ -2391,14 +2392,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			}
 
 			needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
-			const bool closeEnoughForAttack =
-				absZDelta <= kNativeMonsterPursuitZTolerance &&
-				absLiveCenterDx <= engageDistance;
+			const bool closeEnoughForAttack = areCombatantsWithinNativeCloseRange(scene.state,
+				*entity, (float)monster.posZ, *playerState.entity, playerState.z, engageDistance);
 			const bool attackCooldownExpired =
 				combatState.nextAttackAllowedTick == 0 || (int32)(now - combatState.nextAttackAllowedTick) >= 0;
 			if (!closeEnoughForAttack)
 				continue;
-			if (!attackCooldownExpired && absLiveCenterDx > MAX(1, engageDistance / 3))
+			if (!attackCooldownExpired && horizontalGap > MAX(1, engageDistance / 3))
 				continue;
 
 			RoomAttackAnimationRange range;


Commit: 7f2f464af40c1a7b185c622568b56a1ddcb5f121
    https://github.com/scummvm/scummvm/commit/7f2f464af40c1a7b185c622568b56a1ddcb5f121
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:18+02:00

Commit Message:
HARVESTER: Cache overlays and trigger game over

Cache inventory overlays and trigger combat game-over handling.

Changed paths:
    TRACKER.md
    engines/harvester/inventory.cpp
    engines/harvester/inventory.h
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index c9d3ade9320..71da270dda4 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,13 +10,14 @@
 
 ## Next Suggested Action
 
-- Immediate: run an in-engine Harvester validation pass on at least one `GODEATHFLIC` branch such as Buster's fatal outcomes or the `PCHOUSE` `PC_WINDOW` police death. Confirm the death FST still plays to completion, the new `GRAPHIC/OTHER/GAMEOVER.BM` + `GRAPHIC/PAL/GAMEOVER.PAL` screen appears with `SOUND/MUSIC/ANXIETY.CMP`, the first click/key dismisses into the menu overlay on top of that backdrop instead of the generic stub, and `NEW GAME`, `LOAD GAME`, `OPTIONS`, `HELP`, and `QUIT GAME` all still behave correctly from the post-death menu.
+- Immediate: run an in-engine Harvester validation pass in `PCHOUSE` against Jimmy's `MONSTERFY` combat branch. Confirm the monster still enters combat at the same native locomotion rate, keeping the room loop redraw-only behavior during chase/attack; confirm an open inventory no longer reopens every item bitmap from disk when HP changes or status items refresh; and confirm a lethal Jimmy hit now exits the room loop through the native `GAMEOVER.BM` / `GAMEOVER.PAL` + `ANXIETY.CMP` backdrop path instead of leaving the player dead in-room.
 - Stay in live Ghidra on `call_hmi_driver_callback_op0` and its caller frame inside `initialize_sound_bank_resources`. The upstream source record is now labeled as `g_sound_bank_setup_source_record`, the callback-capture block at `0xca2d8` is typed as `g_sound_selected_hardware_config`, and the first three fields there are confirmed as `device_port`, `device_irq`, and `device_dma`. The remaining useful confirmation is whether the mixed far-pointer / stack-cleanup oddity in `call_hmi_driver_callback_op0` can be modeled well enough to expose what, if anything, the unresolved source dwords at `g_sound_bank_setup_source_record + 0x14` and `+ 0x18` contribute to the callback.
 - Before trusting `ClearTalkHandlerDialogueOverrides.java` as a full replacement for `InspectCallTermination.java`, keep validating it against single known-dirty handlers and immediately follow with `InspectCallTermination.java verify`. `CLOAK_ATND` showed the hardened bulk-clear script can safely remove a stale `CALL_RETURN`, but it also showed that already-cleared fallthroughs with `nextInst=null` may still need the explicit `InspectCallTermination.java repair` logic afterward. The next useful confirmation is another one-gap handler of the same shape, or a script update that directly repairs `missing_next_instruction` fallthroughs after the override is already clear.
 - Before using the bulk dialogue cleanup path in the live database again, validate the now-hardened `/Users/alex/ghidra_scripts/ClearTalkHandlerDialogueOverrides.java` on a disposable copy or a single known-dirty talk handler via the new NPC filter. The script still clears each stale `CALL_RETURN` override and hands the fallthrough address to `ClearFlowAndRepairCmd`, but it now aborts on the first repair failure and rolls the whole transaction back on cancel instead of committing a partial sweep. The next useful confirmation is whether a single-handler run such as `MOM` or `MR_POTTS` repairs cleanly and durably with the broader Ghidra repair path.
 
 ## Last Confirmed Action
 
+- On March 26, 2026, verified in live `HARVEST.LE` that native actor animation timing still uses `animation_tick_interval_ms = 100 / animation_rate`, with the room-combat locomotion path seeding `animation_rate = 0x11` inside `update_actor_runtime_state` (`0x4d750`), so the ScummVM combat-speed complaint was not evidence for changing the raw room-monster animation constant. Patched [`engines/harvester/inventory.h`](/Users/alex/Workspace/scummvm/engines/harvester/inventory.h), [`engines/harvester/inventory.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/inventory.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so inventory overlay refreshes now cache decoded bitmap resources by path instead of reopening every icon file on HP-driven refresh, and lethal room-combat monster hits now request the existing native game-over return path instead of leaving the player at `HP=0` inside the live room loop. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, confirmed the native Harvester `GODEATHFLIC` path in live `HARVEST.LE` and patched the ScummVM port to match it instead of dropping straight into the generic main-menu stub. Ghidra showed `dispatch_room_event_actions` case `0x1c` calling `play_fst_sequence(arg1, 0)` and then `run_game_over_screen` (`0x7c540`), and `run_game_over_screen` in turn loading `1:\GRAPHIC\OTHER\GAMEOVER.BM` plus `1:\GRAPHIC\PAL\GAMEOVER.PAL`, starting `SOUND\MUSIC\ANXIETY.CMP`, waiting for dismissal input, and only then entering `run_main_menu` over that backdrop. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), [`engines/harvester/flow.h`](/Users/alex/Workspace/scummvm/engines/harvester/flow.h), [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp), [`engines/harvester/menu.h`](/Users/alex/Workspace/scummvm/engines/harvester/menu.h), and [`engines/harvester/menu.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/menu.cpp) so death FSTs now request a dedicated game-over return, the menu system stages the native `GAMEOVER` bitmap/palette plus `ANXIETY.CMP`, and the post-death menu overlays onto that backdrop instead of the generic wait-screen panel. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, completed the upstream HMI source-record follow-up on `g_sound_bank_setup_source_record` (`0xc2d2c`) and the callback-capture block at `0xca2d8`, reran `/Users/alex/ghidra_scripts/RefineSoundBankDataLayouts.java`, and saved the updated type information into `HARVEST.LE`. The pass improved the live disassembly without overclaiming new semantics: it relabeled the static source block as `g_sound_bank_setup_source_record`, typed `0xca2d8` as `g_sound_selected_hardware_config`, and confirmed that the shared low fields are `device_port`, `device_irq`, and `device_dma` because both the manual `SOUND.CFG` path and the HMIDET auto-detect path populate those exact slots before bank setup. The fourth staging dword remains only `callback_op1_dx_word`, because it is copied from the detect callback's `DX` result and no higher-level meaning is confirmed. The source-side dwords at `+0x14` and `+0x18` remain unresolved: in the shipped image they are zero-initialized, `+0x18` is still only observed as the archived `reserved_setup_dword`, and no independent producer or consumer was confirmed beyond the opaque `call_hmi_driver_callback_op0` path. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, completed the consumer-side HMI bank follow-up on `g_sound_bank_config_snapshots` and `g_sound_bank_hmi_callback_records`, reran `/Users/alex/ghidra_scripts/RefineSoundBankDataLayouts.java`, and saved the updated type information into `HARVEST.LE`. The pass replaced `field_24_code` with `temp_buffer_fill_mode_code`, because the only observed consumer is the setup path that checks for mode `8` before filling the transient DOS buffer with either `0x80` bytes or zeroes. It replaced `field_11` with `requested_mix_rate_hz`, because the caller seeds the source record with `0x5622` (`22050`) immediately before bank setup and the bank setup path forwards that dword into `call_hmi_driver_callback_op2` as the only payload register. The former `field_19` remains `reserved_setup_dword`: it is copied into the snapshot, but the only observed callback wrapper on that path (`call_hmi_driver_callback_op0`) ignores the stack slot where it is passed, and no other direct consumers were found. The same pass also closes the slot-index question conservatively: the live flag-`0x80`, flag-`0x40`, and temp-buffer slabs are packed as five 6-byte entries each, and the only normal producer of a bank index is `initialize_sound_bank_resources`, which scans `idx = 0..4`; the looser `< 6` checks in lower helpers therefore do not justify a reserved sixth bank slot. Updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) with the refined field names and the slot-geometry note. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
diff --git a/engines/harvester/inventory.cpp b/engines/harvester/inventory.cpp
index 2bf255a6265..91fa3be7985 100644
--- a/engines/harvester/inventory.cpp
+++ b/engines/harvester/inventory.cpp
@@ -268,6 +268,24 @@ static void debugLogInventoryVisual(const StartupInventoryVisual &visual, const
 InventorySystem::InventorySystem(HarvesterEngine &engine) : _engine(engine) {
 }
 
+bool InventorySystem::loadBitmap(const Common::String &path, IndexedBitmap &bitmap) {
+	bitmap = IndexedBitmap();
+	if (path.empty())
+		return false;
+
+	if (_bitmapCache.contains(path)) {
+		bitmap = _bitmapCache[path];
+		return bitmap.isValid();
+	}
+
+	ResourceManager *resources = _engine.getResources();
+	if (!resources || !loadBitmapResource(*resources, path, bitmap))
+		return false;
+
+	_bitmapCache[path] = bitmap;
+	return true;
+}
+
 bool InventorySystem::refresh() {
 	_items.clear();
 
@@ -297,7 +315,7 @@ bool InventorySystem::refresh() {
 		}
 
 		const Common::String spritePath = resolveSceneObjectSpritePath(inventoryObject);
-		if (!spritePath.empty() && loadBitmapResource(*resources, spritePath, visual.bitmap)) {
+		if (!spritePath.empty() && loadBitmap(spritePath, visual.bitmap)) {
 			visual.hasBitmap = true;
 			if (isStatusObject(inventoryObject)) {
 				visual.bounds = Common::Rect(visual.object.currentX, visual.object.currentY,
diff --git a/engines/harvester/inventory.h b/engines/harvester/inventory.h
index 2af4e5c61ca..02f6154d9d2 100644
--- a/engines/harvester/inventory.h
+++ b/engines/harvester/inventory.h
@@ -23,6 +23,8 @@
 #define HARVESTER_INVENTORY_H
 
 #include "common/array.h"
+#include "common/hash-str.h"
+#include "common/hashmap.h"
 #include "common/rect.h"
 #include "common/str.h"
 #include "harvester/art.h"
@@ -77,7 +79,11 @@ public:
 	static bool isStatusObject(const StartupObjectRecord &object);
 
 private:
+	bool loadBitmap(const Common::String &path, IndexedBitmap &bitmap);
+
 	Common::Array<StartupInventoryVisual> _items;
+	Common::HashMap<Common::String, IndexedBitmap,
+		Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> _bitmapCache;
 	bool _open = false;
 	int _lastPlayerHitPoints = -1;
 	int _lastStoryDayIndex = -1;
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 885966b2327..769011fc4a8 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1902,12 +1902,29 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			return "none";
 		}
 	};
+	auto requestPlayerGameOver = [&](const char *reason, const Common::String &sourceName) {
+		if (startupFlow.hasPendingMainMenuReturn())
+			return;
+
+		debugC(1, kDebugCombat,
+			"Harvester: player defeat queued reason='%s' source='%s' hp=%d",
+			reason ? reason : "", sourceName.c_str(),
+			_engine.getStartupScript()
+				? _engine.getStartupScript()->getPlayerCurrentHitPoints()
+				: 0);
+		startupFlow.requestGameOverReturn();
+	};
 	auto handleCombatInteraction = [&](StartupInteractionResult interaction) -> Common::Error {
 		bool didTransition = false;
 		Common::Error interactionError =
 			interactionProcessor.handleInteractionResult(interaction, didTransition, Common::String());
 		if (interactionError.getCode() != Common::kNoError)
 			return interactionError;
+		if (!startupFlow.hasPendingMainMenuReturn() &&
+				_engine.getStartupScript() &&
+				_engine.getStartupScript()->getPlayerCurrentHitPoints() <= 0) {
+			requestPlayerGameOver("combat_interaction", Common::String());
+		}
 
 		needsRedraw = true;
 		return Common::kNoError;
@@ -2302,6 +2319,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 						if (changed && playerHitPointsAfter <= 0) {
 							playerAlive = false;
 							stopPlayerRegionInteraction();
+							requestPlayerGameOver("combat_monster_hit", monster.monsterName);
+							return Common::kNoError;
 						}
 					}
 					needsRedraw = true;


Commit: 79e5f89454f12de51393bb262587a46aad71974c
    https://github.com/scummvm/scummvm/commit/79e5f89454f12de51393bb262587a46aad71974c
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:19+02:00

Commit Message:
HARVESTER: Preserve native main menu slots

Changed paths:
    TRACKER.md
    engines/harvester/menu.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 71da270dda4..ba6f3d31e67 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Harvester validation pass that covers both the startup title menu and the post-death `GAMEOVER.BM` overlay. Confirm the menu now preserves the native six-row layout with `main_menu_2` (`SAVE GAME`) blanked when no live room/session state exists, `main_menu_3` (`LOAD GAME`) still visible and functional from both entry points, and the `OPTIONS` / `HELP` / `QUIT GAME` rows retaining their original vertical spacing instead of collapsing upward.
 - Immediate: run an in-engine Harvester validation pass in `PCHOUSE` against Jimmy's `MONSTERFY` combat branch. Confirm the monster still enters combat at the same native locomotion rate, keeping the room loop redraw-only behavior during chase/attack; confirm an open inventory no longer reopens every item bitmap from disk when HP changes or status items refresh; and confirm a lethal Jimmy hit now exits the room loop through the native `GAMEOVER.BM` / `GAMEOVER.PAL` + `ANXIETY.CMP` backdrop path instead of leaving the player dead in-room.
 - Stay in live Ghidra on `call_hmi_driver_callback_op0` and its caller frame inside `initialize_sound_bank_resources`. The upstream source record is now labeled as `g_sound_bank_setup_source_record`, the callback-capture block at `0xca2d8` is typed as `g_sound_selected_hardware_config`, and the first three fields there are confirmed as `device_port`, `device_irq`, and `device_dma`. The remaining useful confirmation is whether the mixed far-pointer / stack-cleanup oddity in `call_hmi_driver_callback_op0` can be modeled well enough to expose what, if anything, the unresolved source dwords at `g_sound_bank_setup_source_record + 0x14` and `+ 0x18` contribute to the callback.
 - Before trusting `ClearTalkHandlerDialogueOverrides.java` as a full replacement for `InspectCallTermination.java`, keep validating it against single known-dirty handlers and immediately follow with `InspectCallTermination.java verify`. `CLOAK_ATND` showed the hardened bulk-clear script can safely remove a stale `CALL_RETURN`, but it also showed that already-cleared fallthroughs with `nextInst=null` may still need the explicit `InspectCallTermination.java repair` logic afterward. The next useful confirmation is another one-gap handler of the same shape, or a script update that directly repairs `missing_next_instruction` fallthroughs after the override is already clear.
@@ -17,6 +18,7 @@
 
 ## Last Confirmed Action
 
+- On March 26, 2026, rechecked the native `run_main_menu` / `run_game_over_screen` path in live `HARVEST.LE` before touching the ScummVM menu layout. Ghidra confirmed that `run_game_over_screen` clears `g_game_session_active`, leaves `run_main_menu` responsible for the overlay, and that `run_main_menu` preserves all six `main_menu_*` slots while blanking `main_menu_2` (`SAVE GAME`) when no live room/session state exists instead of collapsing the list. Patched [`engines/harvester/menu.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/menu.cpp) so the ScummVM startup/game-over menu now mirrors that native policy: `SAVE GAME` is blanked in place when unavailable, `LOAD GAME` remains visible when loading is allowed, and the downstream rows keep the original spacing. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, verified in live `HARVEST.LE` that native actor animation timing still uses `animation_tick_interval_ms = 100 / animation_rate`, with the room-combat locomotion path seeding `animation_rate = 0x11` inside `update_actor_runtime_state` (`0x4d750`), so the ScummVM combat-speed complaint was not evidence for changing the raw room-monster animation constant. Patched [`engines/harvester/inventory.h`](/Users/alex/Workspace/scummvm/engines/harvester/inventory.h), [`engines/harvester/inventory.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/inventory.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so inventory overlay refreshes now cache decoded bitmap resources by path instead of reopening every icon file on HP-driven refresh, and lethal room-combat monster hits now request the existing native game-over return path instead of leaving the player at `HP=0` inside the live room loop. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, confirmed the native Harvester `GODEATHFLIC` path in live `HARVEST.LE` and patched the ScummVM port to match it instead of dropping straight into the generic main-menu stub. Ghidra showed `dispatch_room_event_actions` case `0x1c` calling `play_fst_sequence(arg1, 0)` and then `run_game_over_screen` (`0x7c540`), and `run_game_over_screen` in turn loading `1:\GRAPHIC\OTHER\GAMEOVER.BM` plus `1:\GRAPHIC\PAL\GAMEOVER.PAL`, starting `SOUND\MUSIC\ANXIETY.CMP`, waiting for dismissal input, and only then entering `run_main_menu` over that backdrop. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), [`engines/harvester/flow.h`](/Users/alex/Workspace/scummvm/engines/harvester/flow.h), [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp), [`engines/harvester/menu.h`](/Users/alex/Workspace/scummvm/engines/harvester/menu.h), and [`engines/harvester/menu.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/menu.cpp) so death FSTs now request a dedicated game-over return, the menu system stages the native `GAMEOVER` bitmap/palette plus `ANXIETY.CMP`, and the post-death menu overlays onto that backdrop instead of the generic wait-screen panel. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, completed the upstream HMI source-record follow-up on `g_sound_bank_setup_source_record` (`0xc2d2c`) and the callback-capture block at `0xca2d8`, reran `/Users/alex/ghidra_scripts/RefineSoundBankDataLayouts.java`, and saved the updated type information into `HARVEST.LE`. The pass improved the live disassembly without overclaiming new semantics: it relabeled the static source block as `g_sound_bank_setup_source_record`, typed `0xca2d8` as `g_sound_selected_hardware_config`, and confirmed that the shared low fields are `device_port`, `device_irq`, and `device_dma` because both the manual `SOUND.CFG` path and the HMIDET auto-detect path populate those exact slots before bank setup. The fourth staging dword remains only `callback_op1_dx_word`, because it is copied from the detect callback's `DX` result and no higher-level meaning is confirmed. The source-side dwords at `+0x14` and `+0x18` remain unresolved: in the shipped image they are zero-initialized, `+0x18` is still only observed as the archived `reserved_setup_dword`, and no independent producer or consumer was confirmed beyond the opaque `call_hmi_driver_callback_op0` path. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index 8ff305742bc..77fbe50502f 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -302,15 +302,15 @@ static bool loadMenuTextConfig(HarvesterEngine &engine, RoomMenuTextConfig &conf
 	return true;
 }
 
-static void buildVisibleMainMenuItems(const Common::Array<Common::String> &source,
-		bool showSessionActions, Common::Array<Common::String> &dest) {
-	dest.clear();
-	for (const Common::String &item : source) {
-		if (!showSessionActions &&
-				(item.equalsIgnoreCase("SAVE GAME") || item.equalsIgnoreCase("LOAD GAME")))
-			continue;
-		dest.push_back(item);
-	}
+static void buildDisplayMainMenuItems(const Common::Array<Common::String> &source,
+		bool canSaveGame, bool canLoadGame, Common::Array<Common::String> &dest) {
+	static const char *const kBlankMenuSlot = " ";
+
+	dest = source;
+	if (dest.size() > 1 && !canSaveGame)
+		dest[1] = kBlankMenuSlot;
+	if (dest.size() > 2 && !canLoadGame)
+		dest[2] = kBlankMenuSlot;
 }
 
 static bool loadBitmapResource(ResourceManager &resources, const Common::String &path, IndexedBitmap &bitmap) {
@@ -669,8 +669,10 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 	ScopedSceneTimerPause pausedTimers(_engine);
 
 	Common::Array<Common::String> mainMenuItems;
-	const bool showSessionActions = _engine.hasCurrentStartupSaveRoomState();
-	buildVisibleMainMenuItems(_menuItems, showSessionActions, mainMenuItems);
+	buildDisplayMainMenuItems(_menuItems,
+		_engine.canSaveGameStateCurrently(),
+		_engine.canLoadGameStateCurrently(),
+		mainMenuItems);
 	if (startupFlow.takePendingGameOverReturn()) {
 		Common::Error gameOverError = showGameOverBackdrop(startupFlow);
 		if (gameOverError.getCode() != Common::kNoError)


Commit: b658febca6e6ecb1dfc16427062d3406a4f8ef60
    https://github.com/scummvm/scummvm/commit/b658febca6e6ecb1dfc16427062d3406a4f8ef60
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:19+02:00

Commit Message:
HARVESTER: Match combat death flow to native

Changed paths:
    TRACKER.md
    engines/harvester/player.cpp
    engines/harvester/player.h
    engines/harvester/room.cpp
    engines/harvester/room_support.h


diff --git a/TRACKER.md b/TRACKER.md
index ba6f3d31e67..060cf065626 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -11,13 +11,14 @@
 ## Next Suggested Action
 
 - Immediate: run an in-engine Harvester validation pass that covers both the startup title menu and the post-death `GAMEOVER.BM` overlay. Confirm the menu now preserves the native six-row layout with `main_menu_2` (`SAVE GAME`) blanked when no live room/session state exists, `main_menu_3` (`LOAD GAME`) still visible and functional from both entry points, and the `OPTIONS` / `HELP` / `QUIT GAME` rows retaining their original vertical spacing instead of collapsing upward.
-- Immediate: run an in-engine Harvester validation pass in `PCHOUSE` against Jimmy's `MONSTERFY` combat branch. Confirm the monster still enters combat at the same native locomotion rate, keeping the room loop redraw-only behavior during chase/attack; confirm an open inventory no longer reopens every item bitmap from disk when HP changes or status items refresh; and confirm a lethal Jimmy hit now exits the room loop through the native `GAMEOVER.BM` / `GAMEOVER.PAL` + `ANXIETY.CMP` backdrop path instead of leaving the player dead in-room.
+- Immediate: run an in-engine Harvester validation pass in `PCHOUSE` against Jimmy's `MONSTERFY` combat branch. Confirm the monster still enters combat at the same native locomotion rate, keeping the room loop redraw-only behavior during chase/attack; confirm an open inventory no longer reopens every item bitmap from disk when HP changes or status items refresh; and confirm a lethal Jimmy hit now plays the recovered player death bank before the room loop exits through the native `GAMEOVER.BM` / `GAMEOVER.PAL` + `ANXIETY.CMP` backdrop path instead of jumping directly out on `HP=0`.
 - Stay in live Ghidra on `call_hmi_driver_callback_op0` and its caller frame inside `initialize_sound_bank_resources`. The upstream source record is now labeled as `g_sound_bank_setup_source_record`, the callback-capture block at `0xca2d8` is typed as `g_sound_selected_hardware_config`, and the first three fields there are confirmed as `device_port`, `device_irq`, and `device_dma`. The remaining useful confirmation is whether the mixed far-pointer / stack-cleanup oddity in `call_hmi_driver_callback_op0` can be modeled well enough to expose what, if anything, the unresolved source dwords at `g_sound_bank_setup_source_record + 0x14` and `+ 0x18` contribute to the callback.
 - Before trusting `ClearTalkHandlerDialogueOverrides.java` as a full replacement for `InspectCallTermination.java`, keep validating it against single known-dirty handlers and immediately follow with `InspectCallTermination.java verify`. `CLOAK_ATND` showed the hardened bulk-clear script can safely remove a stale `CALL_RETURN`, but it also showed that already-cleared fallthroughs with `nextInst=null` may still need the explicit `InspectCallTermination.java repair` logic afterward. The next useful confirmation is another one-gap handler of the same shape, or a script update that directly repairs `missing_next_instruction` fallthroughs after the override is already clear.
 - Before using the bulk dialogue cleanup path in the live database again, validate the now-hardened `/Users/alex/ghidra_scripts/ClearTalkHandlerDialogueOverrides.java` on a disposable copy or a single known-dirty talk handler via the new NPC filter. The script still clears each stale `CALL_RETURN` override and hands the fallthrough address to `ClearFlowAndRepairCmd`, but it now aborts on the first repair failure and rolls the whole transaction back on cancel instead of committing a partial sweep. The next useful confirmation is whether a single-handler run such as `MOM` or `MR_POTTS` repairs cleanly and durably with the broader Ghidra repair path.
 
 ## Last Confirmed Action
 
+- On March 26, 2026, rechecked the native room-combat death path in live `HARVEST.LE` and corrected the ScummVM player defeat flow to match it more closely. Ghidra confirmed that `update_player_combat_avatar_state` does not hand control to `run_game_over_screen` on raw `HP <= 0`; it only returns true once the player combat avatar reaches terminal state `0x38`, after `update_actor_runtime_state` has already selected and played a concrete death-animation bank from the live combat state. The recovered death-bank selection block at `0x4e5a3..0x4e7ea` plus the state handlers at `0x5045a..0x50991` bounded the exact player ranges (`0xc4..0xff` for vertical deaths, `0xb0..0xc3` and `0x100..0x127` for horizontal deaths) and showed the death animation inherits the native hit-reaction cadence rather than the normal walk rate. Patched [`engines/harvester/player.h`](/Users/alex/Workspace/scummvm/engines/harvester/player.h), [`engines/harvester/player.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/player.cpp), [`engines/harvester/room_support.h`](/Users/alex/Workspace/scummvm/engines/harvester/room_support.h), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so lethal room-combat hits now start the recovered player death bank, suppress further region activation while that animation runs, and only queue the existing game-over return once the death sequence completes. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, rechecked the native `run_main_menu` / `run_game_over_screen` path in live `HARVEST.LE` before touching the ScummVM menu layout. Ghidra confirmed that `run_game_over_screen` clears `g_game_session_active`, leaves `run_main_menu` responsible for the overlay, and that `run_main_menu` preserves all six `main_menu_*` slots while blanking `main_menu_2` (`SAVE GAME`) when no live room/session state exists instead of collapsing the list. Patched [`engines/harvester/menu.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/menu.cpp) so the ScummVM startup/game-over menu now mirrors that native policy: `SAVE GAME` is blanked in place when unavailable, `LOAD GAME` remains visible when loading is allowed, and the downstream rows keep the original spacing. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, verified in live `HARVEST.LE` that native actor animation timing still uses `animation_tick_interval_ms = 100 / animation_rate`, with the room-combat locomotion path seeding `animation_rate = 0x11` inside `update_actor_runtime_state` (`0x4d750`), so the ScummVM combat-speed complaint was not evidence for changing the raw room-monster animation constant. Patched [`engines/harvester/inventory.h`](/Users/alex/Workspace/scummvm/engines/harvester/inventory.h), [`engines/harvester/inventory.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/inventory.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so inventory overlay refreshes now cache decoded bitmap resources by path instead of reopening every icon file on HP-driven refresh, and lethal room-combat monster hits now request the existing native game-over return path instead of leaving the player at `HP=0` inside the live room loop. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, confirmed the native Harvester `GODEATHFLIC` path in live `HARVEST.LE` and patched the ScummVM port to match it instead of dropping straight into the generic main-menu stub. Ghidra showed `dispatch_room_event_actions` case `0x1c` calling `play_fst_sequence(arg1, 0)` and then `run_game_over_screen` (`0x7c540`), and `run_game_over_screen` in turn loading `1:\GRAPHIC\OTHER\GAMEOVER.BM` plus `1:\GRAPHIC\PAL\GAMEOVER.PAL`, starting `SOUND\MUSIC\ANXIETY.CMP`, waiting for dismissal input, and only then entering `run_main_menu` over that backdrop. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), [`engines/harvester/flow.h`](/Users/alex/Workspace/scummvm/engines/harvester/flow.h), [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp), [`engines/harvester/menu.h`](/Users/alex/Workspace/scummvm/engines/harvester/menu.h), and [`engines/harvester/menu.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/menu.cpp) so death FSTs now request a dedicated game-over return, the menu system stages the native `GAMEOVER` bitmap/palette plus `ANXIETY.CMP`, and the post-death menu overlays onto that backdrop instead of the generic wait-screen panel. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index a9038d3a834..704b778f4e4 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -40,6 +40,7 @@ static const int kRoomRegionTargetXBias = 10;
 static const float kRoomDepthCompareEpsilon = 0.01f;
 static const int kRoomPlayerWalkAnimationRate = 17;
 static const int kRoomPlayerAttackAnimationRate = kRoomPlayerWalkAnimationRate;
+static const int kRoomPlayerDeathAnimationRate = 4;
 static const int kRoomPlayerMinOpaqueLeftX = 4;
 static const int kRoomPlayerMaxOpaqueRightX = 0x27c;
 static const float kRoomPlayerAttackUpperYOffset = 144.44f;
@@ -85,6 +86,15 @@ struct PlayerAttackAnimationRange {
 	int resumeFacing = 0;
 };
 
+struct PlayerDeathAnimationRange {
+	PlayerDeathAnimationRange() {}
+	PlayerDeathAnimationRange(int firstFrame, int lastFrame)
+		: firstFrame(firstFrame), lastFrame(lastFrame) {}
+
+	int firstFrame = -1;
+	int lastFrame = -1;
+};
+
 struct PlayerAttackSoundSet {
 	uint soundCount;
 	const char *soundPaths[3];
@@ -236,6 +246,95 @@ static bool playPlayerAttackSound(HarvesterEngine &engine, int loadout) {
 	return soundPath && engine.playStartupSound(soundPath);
 }
 
+static bool runtimeEntityHasFrameRange(const RuntimeEntity &entity, int firstFrame, int lastFrame) {
+	if (!entity.hasFrames() || firstFrame < 0 || lastFrame < firstFrame)
+		return false;
+
+	return (uint)lastFrame < entity.getFrameCount();
+}
+
+static bool choosePlayerDeathAnimationRange(const RuntimeEntity &entity, int firstFrame, int lastFrame,
+		PlayerDeathAnimationRange &range) {
+	if (!runtimeEntityHasFrameRange(entity, firstFrame, lastFrame))
+		return false;
+
+	range = PlayerDeathAnimationRange(firstFrame, lastFrame);
+	return true;
+}
+
+static bool resolvePlayerDeathAnimationRange(const StartupRoomPlayerState &playerState,
+		int damageType, bool goreEnabled, PlayerDeathAnimationRange &range) {
+	if (!playerState.entity)
+		return false;
+
+	// Native update_actor_runtime_state selects the player death bank from the
+	// live combat state family before the terminal 0x38 game-over handoff.
+	enum PlayerDeathBankFamily {
+		kPlayerDeathBankHorizontalRight,
+		kPlayerDeathBankHorizontalLeft,
+		kPlayerDeathBankVerticalDown,
+		kPlayerDeathBankVerticalUp
+	};
+
+	PlayerDeathBankFamily family = kPlayerDeathBankHorizontalLeft;
+	if (playerState.attackActive) {
+		if (playerState.attackFirstFrame >= 0x6e && playerState.attackFirstFrame <= 0x8b)
+			family = kPlayerDeathBankHorizontalRight;
+		else
+			family = kPlayerDeathBankHorizontalLeft;
+	} else if (playerState.turnActive) {
+		if (playerState.turnFirstFrame >= 0x28)
+			family = kPlayerDeathBankHorizontalRight;
+		else
+			family = kPlayerDeathBankHorizontalLeft;
+	} else {
+		switch (playerState.facing) {
+		case 0:
+			family = kPlayerDeathBankVerticalDown;
+			break;
+		case 2:
+			family = kPlayerDeathBankHorizontalRight;
+			break;
+		case 3:
+			family = kPlayerDeathBankVerticalUp;
+			break;
+		case 1:
+		default:
+			family = kPlayerDeathBankHorizontalLeft;
+			break;
+		}
+	}
+
+	const auto selectByFamily = [&](int bludgeFirst, int bludgeLast, int slashFirst, int slashLast,
+			int projectileFirst, int projectileLast) {
+		if ((!goreEnabled || damageType == 1) &&
+				choosePlayerDeathAnimationRange(*playerState.entity, bludgeFirst, bludgeLast, range))
+			return true;
+		if (damageType == 4 &&
+				choosePlayerDeathAnimationRange(*playerState.entity, projectileFirst, projectileLast, range))
+			return true;
+		if (damageType == 2 &&
+				choosePlayerDeathAnimationRange(*playerState.entity, slashFirst, slashLast, range))
+			return true;
+
+		return choosePlayerDeathAnimationRange(*playerState.entity, bludgeFirst, bludgeLast, range) ||
+			choosePlayerDeathAnimationRange(*playerState.entity, slashFirst, slashLast, range) ||
+			choosePlayerDeathAnimationRange(*playerState.entity, projectileFirst, projectileLast, range);
+	};
+
+	switch (family) {
+	case kPlayerDeathBankHorizontalRight:
+		return selectByFamily(0xb0, 0xb9, 0x100, 0x109, 0x114, 0x11d);
+	case kPlayerDeathBankVerticalDown:
+		return selectByFamily(0xc4, 0xcd, 0xd8, 0xe1, 0xec, 0xf5);
+	case kPlayerDeathBankVerticalUp:
+		return selectByFamily(0xce, 0xd7, 0xe2, 0xeb, 0xf6, 0xff);
+	case kPlayerDeathBankHorizontalLeft:
+	default:
+		return selectByFamily(0xba, 0xc3, 0x10a, 0x113, 0x11e, 0x127);
+	}
+}
+
 static bool resolvePlayerAttackAnimationRange(const StartupRoomSetupState &state,
 		const StartupRoomPlayerState &playerState, const Common::Point &mousePos,
 		PlayerAttackAnimationRange &range) {
@@ -862,6 +961,59 @@ bool Player::updateAttackAnimationState(HarvesterEngine &engine,
 	return setIdleAnimation(playerState, resumeFacing);
 }
 
+bool Player::startDeathAnimation(StartupRoomPlayerState &playerState, int damageType, bool goreEnabled) {
+	if (!playerState.entity || playerState.deathActive)
+		return false;
+
+	PlayerDeathAnimationRange range;
+	if (!resolvePlayerDeathAnimationRange(playerState, damageType, goreEnabled, range))
+		return false;
+
+	playerState.hasMoveTarget = false;
+	playerState.turnActive = false;
+	playerState.turnTargetFacing = -1;
+	playerState.turnFirstFrame = -1;
+	playerState.turnLastFrame = -1;
+	playerState.turnEndFrame = -1;
+	playerState.turnPlayBackwards = false;
+	playerState.attackActive = false;
+	playerState.attackFirstFrame = -1;
+	playerState.attackLastFrame = -1;
+	playerState.attackContactFrame = -1;
+	playerState.attackResumeFacing = -1;
+	playerState.attackSoundPlayed = false;
+	playerState.attackSoundPlaybackFrame = -1;
+	playerState.attackContactResolved = false;
+	playerState.attackTargetName.clear();
+	playerState.attackTargetClassId = -1;
+	playerState.nextMovementTick = 0;
+	playerState.deathActive = true;
+	playerState.deathFirstFrame = range.firstFrame;
+	playerState.deathLastFrame = range.lastFrame;
+	playerState.deathDamageType = damageType;
+	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
+	playerState.entity->setAnimationRate(kRoomPlayerDeathAnimationRate);
+	playerState.entity->setCurrentFrame(range.firstFrame);
+	playerState.entity->setVisible(true);
+	debugC(1, kDebugCombat,
+		"Harvester: player death animation start damage_type=%d gore=%d facing=%d frames=%d..%d",
+		damageType, goreEnabled, playerState.facing, range.firstFrame, range.lastFrame);
+	return true;
+}
+
+bool Player::updateDeathAnimationState(StartupRoomPlayerState &playerState) {
+	if (!playerState.deathActive || !playerState.entity)
+		return false;
+	if (playerState.entity->getCurrentFrame() < playerState.deathLastFrame)
+		return false;
+
+	playerState.deathActive = false;
+	debugC(1, kDebugCombat,
+		"Harvester: player death animation complete damage_type=%d frame=%d",
+		playerState.deathDamageType, playerState.entity->getCurrentFrame());
+	return true;
+}
+
 bool Player::startTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing) {
 	if (!playerState.entity || playerState.facing < 0 || playerState.facing == targetFacing)
 		return false;
diff --git a/engines/harvester/player.h b/engines/harvester/player.h
index 60311e4c053..f9b0d45e889 100644
--- a/engines/harvester/player.h
+++ b/engines/harvester/player.h
@@ -54,6 +54,8 @@ public:
 	static bool startKeyboardAttackAnimation(const StartupRoomSetupState &state,
 		StartupRoomPlayerState &playerState, bool attackLeft, bool attackRight, bool attackUp, bool attackDown);
 	static bool updateAttackAnimationState(HarvesterEngine &engine, StartupRoomPlayerState &playerState);
+	static bool startDeathAnimation(StartupRoomPlayerState &playerState, int damageType, bool goreEnabled);
+	static bool updateDeathAnimationState(StartupRoomPlayerState &playerState);
 	static bool startTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing);
 	static bool updateTurnAnimationState(StartupRoomPlayerState &playerState);
 	static bool stepMoveTarget(HarvesterEngine &engine, const StartupRoomSetupState &state,
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 769011fc4a8..f787961ef2f 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1907,13 +1907,47 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			return;
 
 		debugC(1, kDebugCombat,
-			"Harvester: player defeat queued reason='%s' source='%s' hp=%d",
+			"Harvester: player game over queued reason='%s' source='%s' hp=%d",
 			reason ? reason : "", sourceName.c_str(),
 			_engine.getStartupScript()
 				? _engine.getStartupScript()->getPlayerCurrentHitPoints()
 				: 0);
 		startupFlow.requestGameOverReturn();
 	};
+	auto startPlayerDefeatSequence = [&](const char *reason, const Common::String &sourceName,
+			int damageType) {
+		if (startupFlow.hasPendingMainMenuReturn() || playerState.deathActive)
+			return;
+
+		if (idleState.entity) {
+			idleState.entity->setVisible(false);
+			idleState.entity->setAnimationRate(0);
+		}
+		idleState.active = false;
+		idleState.loopStarted = false;
+		idleState.exiting = false;
+		idleState.restoreFacing = -1;
+		if (playerState.entity)
+			playerState.entity->setVisible(true);
+
+		if (!Player::startDeathAnimation(playerState, damageType, _engine.isGoreEnabled())) {
+			debugC(1, kDebugCombat,
+				"Harvester: player defeat fallback reason='%s' source='%s' damage_type=%d hp=%d",
+				reason ? reason : "", sourceName.c_str(), damageType,
+				_engine.getStartupScript()
+					? _engine.getStartupScript()->getPlayerCurrentHitPoints()
+					: 0);
+			requestPlayerGameOver(reason, sourceName);
+			return;
+		}
+
+		debugC(1, kDebugCombat,
+			"Harvester: player defeat started reason='%s' source='%s' damage_type=%d hp=%d",
+			reason ? reason : "", sourceName.c_str(), damageType,
+			_engine.getStartupScript()
+				? _engine.getStartupScript()->getPlayerCurrentHitPoints()
+				: 0);
+	};
 	auto handleCombatInteraction = [&](StartupInteractionResult interaction) -> Common::Error {
 		bool didTransition = false;
 		Common::Error interactionError =
@@ -1923,7 +1957,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		if (!startupFlow.hasPendingMainMenuReturn() &&
 				_engine.getStartupScript() &&
 				_engine.getStartupScript()->getPlayerCurrentHitPoints() <= 0) {
-			requestPlayerGameOver("combat_interaction", Common::String());
+			stopPlayerRegionInteraction();
+			startPlayerDefeatSequence("combat_interaction", Common::String(), 1);
 		}
 
 		needsRedraw = true;
@@ -2319,7 +2354,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 						if (changed && playerHitPointsAfter <= 0) {
 							playerAlive = false;
 							stopPlayerRegionInteraction();
-							requestPlayerGameOver("combat_monster_hit", monster.monsterName);
+							startPlayerDefeatSequence(
+								"combat_monster_hit", monster.monsterName, monster.damageType);
 							return Common::kNoError;
 						}
 					}
@@ -3415,7 +3451,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			break;
 		}
 		Common::Error pendingRegionError =
-			playerState.attackActive ? Common::kNoError : tryActivatePendingRegion();
+			(playerState.attackActive || playerState.deathActive)
+				? Common::kNoError
+				: tryActivatePendingRegion();
 		if (pendingRegionError.getCode() != Common::kNoError)
 			return pendingRegionError;
 		if (startupFlow.hasPendingMainMenuReturn())
@@ -3426,7 +3464,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			break;
 		}
 		pendingRegionError =
-			(playerAdvancedThisFrame && !playerState.attackActive)
+			(playerAdvancedThisFrame && !playerState.attackActive && !playerState.deathActive)
 				? tryActivateOverlappedRegion()
 				: Common::kNoError;
 		if (pendingRegionError.getCode() != Common::kNoError)
@@ -3441,6 +3479,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 		if (startupFlow.tickRuntimeEntities())
 			needsRedraw = true;
+		if (Player::updateDeathAnimationState(playerState)) {
+			requestPlayerGameOver("combat_player_death_complete", Common::String());
+			needsRedraw = true;
+		}
+		if (startupFlow.hasPendingMainMenuReturn())
+			return Common::kNoError;
 		if (pruneCombatHitEffects())
 			needsRedraw = true;
 		if (runtimeEntities) {
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index afc68b1db6b..4c902dea08f 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -77,6 +77,10 @@ struct StartupRoomPlayerState {
 	bool attackContactResolved = false;
 	Common::String attackTargetName;
 	int attackTargetClassId = -1;
+	bool deathActive = false;
+	int deathFirstFrame = -1;
+	int deathLastFrame = -1;
+	int deathDamageType = 0;
 };
 
 struct StartupRoomHoverState {


Commit: d519f8e8e1b601975585f2f2fb16b0419f2220e8
    https://github.com/scummvm/scummvm/commit/d519f8e8e1b601975585f2f2fb16b0419f2220e8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:19+02:00

Commit Message:
HARVESTER: Split cursor, room, and player debug channels

Changed paths:
    engines/harvester/detection.cpp
    engines/harvester/detection.h
    engines/harvester/detection_tables.h
    engines/harvester/dialogue.cpp
    engines/harvester/flow.cpp
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/metaengine.cpp
    engines/harvester/player.cpp
    engines/harvester/room.cpp
    engines/harvester/runtime_entity.cpp
    engines/harvester/saveload.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/engines/harvester/detection.cpp b/engines/harvester/detection.cpp
index e6e6980daaa..539be6f0fec 100644
--- a/engines/harvester/detection.cpp
+++ b/engines/harvester/detection.cpp
@@ -26,9 +26,12 @@
 
 const DebugChannelDef HarvesterMetaEngineDetection::debugFlagList[] = {
 	{ Harvester::kDebugGeneral, "general", "General debug level" },
+	{ Harvester::kDebugCursor, "cursor", "Cursor animation, hover, and placement debug" },
+	{ Harvester::kDebugRoom, "room", "Room setup, scene population, and interaction debug" },
+	{ Harvester::kDebugPlayer, "player", "Player animation, movement, and spawn debug" },
 	{ Harvester::kDebugCombat, "combat", "Room combat pursuit, attack, and damage debug" },
 	{ Harvester::kDebugResources, "resources", "Resource loading" },
-	{ Harvester::kDebugScene, "scene", "Startup room, hotspot, and actor debug" },
+	{ Harvester::kDebugScene, "scene", "Scene object selection and startup script debug" },
 	{ Harvester::kDebugDialogue, "dialogue", "Startup dialogue and NPC interaction debug" },
 	{ Harvester::kDebugInventory, "inventory", "Inventory hover, click, and secondary-action debug" },
 	DEBUG_CHANNEL_END
diff --git a/engines/harvester/detection.h b/engines/harvester/detection.h
index c8fc434cc68..3dd08970e76 100644
--- a/engines/harvester/detection.h
+++ b/engines/harvester/detection.h
@@ -25,6 +25,7 @@
 #include "engines/advancedDetector.h"
 
 #define GAMEOPTION_GORE GUIO_GAMEOPTIONS1
+#define GAMEOPTION_SHOW_CD_CHANGE_PROMPTS GUIO_GAMEOPTIONS2
 
 namespace Harvester {
 
@@ -34,7 +35,10 @@ enum HarvesterDebugChannels {
 	kDebugResources = 1 << 2,
 	kDebugScene     = 1 << 3,
 	kDebugDialogue  = 1 << 4,
-	kDebugInventory = 1 << 5
+	kDebugInventory = 1 << 5,
+	kDebugCursor    = 1 << 6,
+	kDebugRoom      = 1 << 7,
+	kDebugPlayer    = 1 << 8
 };
 
 extern const PlainGameDescriptor harvesterGames[];
diff --git a/engines/harvester/detection_tables.h b/engines/harvester/detection_tables.h
index 986a8e6f33c..8d5dd56ca85 100644
--- a/engines/harvester/detection_tables.h
+++ b/engines/harvester/detection_tables.h
@@ -34,7 +34,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::EN_ANY,
 		Common::kPlatformDOS,
 		ADGF_UNSTABLE,
-		GUIO1(GAMEOPTION_GORE)
+		GUIO2(GAMEOPTION_GORE, GAMEOPTION_SHOW_CD_CHANGE_PROMPTS)
 	},
 
 	AD_TABLE_END_MARKER
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index fab3ad7967c..b10295c9e44 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -942,6 +942,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 				!interaction.musicPath.empty() || !interaction.nextRoomName.empty() ||
 				!interaction.cutscenePath.empty() ||
 				!interaction.deathFlicPath.empty() || interaction.requestMainMenu ||
+				interaction.cdChangeDisc > 0 ||
 				!interaction.dialogueNpcName.empty() ||
 				!interaction.dialogueContinuationTag.empty() ||
 				!interaction.continuationTag.empty() ||
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index d2fcb39197e..38039c42dbd 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -386,7 +386,7 @@ void logScenePaletteSummary(const char *label, const StartupRoomSceneResources &
 	byte maxValue = 0;
 	getPaletteByteRange(palette, minValue, maxValue);
 
-	debugC(1, kDebugGeneral,
+	debugC(1, kDebugRoom,
 		"Harvester: %s room='%s' palette='%s' bytes=%u range=[%u,%u] brightness=%.2f hash=%08x idx0=(%u,%u,%u) idx1=(%u,%u,%u) idx127=(%u,%u,%u) idx255=(%u,%u,%u)",
 		label, scene.state.roomName.c_str(), scene.state.palettePath.c_str(), 256U * 3U, minValue, maxValue,
 		(double)brightness, hashPalette(palette),
@@ -479,7 +479,7 @@ static bool loadPaletteResource(ResourceManager &resources, const Common::String
 	byte minValue = 0;
 	byte maxValue = 0;
 	getPaletteByteRange(dest, minValue, maxValue);
-	debugC(1, kDebugGeneral,
+	debugC(1, kDebugRoom,
 		"Harvester: decoded room palette '%s' bytes=%u range=[%u,%u] idx0=(%u,%u,%u) idx255=(%u,%u,%u)",
 		path.c_str(), (uint)data.size(), minValue, maxValue,
 		dest[0], dest[1], dest[2],
@@ -611,7 +611,7 @@ bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceManager
 	}
 	scene.sceneRegions = state.roomRegions;
 
-	debugC(1, kDebugGeneral,
+	debugC(1, kDebugRoom,
 		"Harvester: loadRoomSceneResources room='%s' palette='%s' background='%s' brightness=%.2f objects=%u activeObjects=%u sceneObjects=%u anims=%u visibleAnims=%u npcs=%u monsters=%u regions=%u",
 		state.roomName.c_str(), state.palettePath.c_str(), state.backgroundPath.c_str(), (double)state.paletteBrightness,
 		(uint)state.roomObjects.size(), (uint)state.activeObjects.size(), (uint)scene.sceneObjects.size(),
@@ -982,7 +982,7 @@ void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResource
 		uint32 transparentPixels = 0;
 		uint32 preservedPixels = 0;
 		if (cursor->measureCurrentFrameTransparency(framePixels, transparentPixels, preservedPixels)) {
-			debugC(1, kDebugGeneral,
+			debugC(1, kDebugCursor,
 				"Harvester: startup probe cursor sequence=%d frame=%d pixels=%u transparent_pixels=%u preserved_pixels=%u",
 				cursor->getAnimationSequence(), cursor->getCurrentFrame(), framePixels, transparentPixels, preservedPixels);
 		}
@@ -995,7 +995,7 @@ void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResource
 		(void)player->measureCurrentFrameTransparency(framePixels, transparentPixels, preservedPixels);
 		const Common::Rect playerRect = player->getScreenRect();
 		const Common::Rect screenRect(0, 0, 640, 480);
-		debugC(1, kDebugGeneral,
+		debugC(1, kDebugPlayer,
 			"Harvester: startup probe player room='%s' entrance='%s' frame=%d rect=(%d,%d)-(%d,%d) visible=%d intersects_screen=%d opaque_pixels=%u",
 			scene.state.roomName.c_str(), entranceName.c_str(), player->getCurrentFrame(),
 			playerRect.left, playerRect.top, playerRect.right, playerRect.bottom,
@@ -1021,7 +1021,7 @@ void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResource
 			engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions, probePoint);
 		const bool hasInteraction = startupScript->hasObjectInteraction(*hoveredObject);
 		const bool hasInspectText = startupScript->resolveObjectInspectText(*hoveredObject, inspectText);
-		debugC(1, kDebugGeneral,
+		debugC(1, kDebugRoom,
 			"Harvester: startup probe hotspot room='%s' object='%s' point=(%d,%d) label='%s' prompt='%s' cursor_sequence=%d action_tag='%s' interaction=%d next_room='%s' inspect=%d",
 			scene.state.roomName.c_str(), hoveredObject->objectName.c_str(), probePoint.x, probePoint.y,
 			objectLabel.c_str(), hoverState.promptText.c_str(), hoverState.cursorSequence,
@@ -1048,7 +1048,7 @@ void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResource
 				? resolveRoomHoverState(engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
 					scene.sceneRegions, floorProbe)
 				: StartupRoomHoverState();
-			debugC(1, kDebugGeneral,
+			debugC(1, kDebugRoom,
 				"Harvester: startup probe floor room='%s' point=(%d,%d) found=%d cursor_sequence=%d prompt='%s'",
 				scene.state.roomName.c_str(), floorProbe.x, floorProbe.y, foundFloorProbe,
 				floorHover.cursorSequence, floorHover.promptText.c_str());
@@ -1067,7 +1067,7 @@ void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResource
 						engine, scene.state, scene.sceneObjects, scene.sceneAnimations, probePlayer);
 
 				const Common::Rect movedRect = player->getScreenRect();
-				debugC(1, kDebugGeneral,
+				debugC(1, kDebugPlayer,
 					"Harvester: startup probe movement room='%s' target=(%d,%d) final_rect=(%d,%d)-(%d,%d) z=%.2f",
 					scene.state.roomName.c_str(), floorProbe.x, floorProbe.y,
 					movedRect.left, movedRect.top, movedRect.right, movedRect.bottom, (double)probePlayer.z);
@@ -1080,7 +1080,7 @@ void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResource
 		return;
 	}
 
-	debugC(1, kDebugGeneral, "Harvester: startup probe found no interactive hotspot in room '%s'",
+	debugC(1, kDebugRoom, "Harvester: startup probe found no interactive hotspot in room '%s'",
 		scene.state.roomName.c_str());
 }
 
@@ -1654,7 +1654,7 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 		RuntimeEntity *entity = runtimeEntities->spawnSceneHotspotEntity(
 			region.regionName, regionBounds, (float)region.minZ);
 		if (!entity) {
-			debugC(1, kDebugScene,
+			debugC(1, kDebugRoom,
 				"Harvester: scene region skipped room='%s' region='%s' bounds=(%d,%d)-(%d,%d) z=[%d,%d]",
 				state.roomName.c_str(), region.regionName.c_str(),
 				regionBounds.left, regionBounds.top, regionBounds.right, regionBounds.bottom,
@@ -1667,7 +1667,7 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 		entity->setZExtent((float)MAX(0, region.maxZ - region.minZ));
 		if (!region.cursorEnabled)
 			entity->setHitTestMode(kRuntimeEntityHitTestNone);
-		debugC(1, kDebugScene,
+		debugC(1, kDebugRoom,
 			"Harvester: scene region spawned room='%s' region='%s' class=0x%x bounds=(%d,%d)-(%d,%d) z=[%d,%d] facing=%d cursor=%d action='%s'",
 			state.roomName.c_str(), region.regionName.c_str(), entity->getClassId(),
 			regionBounds.left, regionBounds.top, regionBounds.right, regionBounds.bottom,
@@ -1681,7 +1681,7 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 			continue;
 		}
 
-		debugC(1, kDebugScene,
+		debugC(1, kDebugRoom,
 			"Harvester: scene timer spawned room='%s' timer='%s' current=%d initial=%d enabled=%d loop=%d global=%d",
 			state.roomName.c_str(), timer.timerName.c_str(), timer.currentValue, timer.initialValue,
 			timer.enabled, timer.looping, timer.global);
@@ -1699,7 +1699,7 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 		}
 
 		if (!entity) {
-			debugC(1, kDebugScene,
+			debugC(1, kDebugRoom,
 				"Harvester: scene entity skipped room='%s' object='%s' resolved='%s' bounds=(%d,%d)-(%d,%d) reason='%s'",
 				state.roomName.c_str(), object.objectName.c_str(), spritePath.c_str(),
 				hotspotBounds.left, hotspotBounds.top, hotspotBounds.right, hotspotBounds.bottom,
@@ -1715,7 +1715,7 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 		entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
 		entity->setZExtent((float)object.zExtent);
 		const Common::Rect entityRect = entity->getScreenRect();
-		debugC(1, kDebugScene,
+		debugC(1, kDebugRoom,
 			"Harvester: scene entity spawned room='%s' object='%s' type='%s' class=0x%x pos=(%d,%d,z=%.2f) rect=(%d,%d)-(%d,%d) sprite='%s' action='%s' operatable=%d ident='%s'",
 			state.roomName.c_str(), object.objectName.c_str(),
 			entity->hasFrames() ? "bitmap" : "hotspot", entity->getClassId(),
@@ -1753,7 +1753,7 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 				npc.npcName.c_str());
 		}
 		runtimeEntities->reinsertSceneEntity(entity);
-		debugC(1, kDebugScene,
+		debugC(1, kDebugRoom,
 			"Harvester: scene npc spawned room='%s' npc='%s' class=0x%x pos=(%d,%d,z=%.2f) frame_delay=%d model='%s' active=%d visible=%d",
 			state.roomName.c_str(), npc.npcName.c_str(), entity->getClassId(),
 			entity->getX(), entity->getY(), (double)entity->getZ(),
@@ -1777,7 +1777,7 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 				monster.monsterName.c_str());
 		}
 		runtimeEntities->reinsertSceneEntity(entity);
-		debugC(1, kDebugScene,
+		debugC(1, kDebugRoom,
 			"Harvester: scene monster spawned room='%s' monster='%s' class=0x%x pos=(%d,%d,z=%.2f) facing=%d hp=%d/%d model='%s' active=%d visible=%d",
 			state.roomName.c_str(), monster.monsterName.c_str(), entity->getClassId(),
 			entity->getX(), entity->getY(), (double)entity->getZ(), monster.facing,
@@ -1825,7 +1825,7 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 				runtimeEntities->adoptSceneEntity(player);
 			else
 				runtimeEntities->reinsertSceneEntity(player);
-			debugC(1, kDebugGeneral,
+			debugC(1, kDebugPlayer,
 				"Harvester: %s startup player actor '%s' entrance='%s' pos=(%d,%d,%d) facing=%d frame=%d",
 				reusedPlayer ? "reused" : "spawned", playerResourcePath.c_str(), state.entranceName.c_str(),
 				state.playerSpawnX, state.playerSpawnY, state.playerSpawnZ,
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 3422f508276..669547664ca 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -99,6 +99,10 @@ bool HarvesterEngine::isGoreEnabled() const {
 	return !ConfMan.hasKey("gore") || ConfMan.getBool("gore");
 }
 
+bool HarvesterEngine::shouldShowCdChangePrompts() const {
+	return ConfMan.hasKey("show_cd_change_prompts") && ConfMan.getBool("show_cd_change_prompts");
+}
+
 int HarvesterEngine::getStartupFxVolumeLevel() const {
 	return _startupScript ? _startupScript->getFxVolumeLevel() : 9;
 }
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index a227f1d3ec6..5b181b412e6 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -86,6 +86,7 @@ public:
 	Text *getStartupText() const;
 	void setDisplayMode(int width, int height);
 	bool isGoreEnabled() const;
+	bool shouldShowCdChangePrompts() const;
 	int getStartupFxVolumeLevel() const;
 	int getStartupMusicVolumeLevel() const;
 	int getStartupGammaLevel() const;
diff --git a/engines/harvester/metaengine.cpp b/engines/harvester/metaengine.cpp
index 9d03547bc85..58f4a4b2053 100644
--- a/engines/harvester/metaengine.cpp
+++ b/engines/harvester/metaengine.cpp
@@ -38,6 +38,17 @@ static const ADExtraGuiOptionsMap gameGuiOptions[] = {
 			0
 		}
 	},
+	{
+		GAMEOPTION_SHOW_CD_CHANGE_PROMPTS,
+		{
+			_s("Show CD change prompts"),
+			_s("Display the original CD swap image and wait for a left click when the script requests disc 2 or 3."),
+			"show_cd_change_prompts",
+			false,
+			0,
+			0
+		}
+	},
 
 	AD_EXTRA_GUI_OPTIONS_TERMINATOR
 };
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 704b778f4e4..6db8b80c60a 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -653,7 +653,7 @@ static bool setPlayerWalkAnimation(StartupRoomPlayerState &playerState, int faci
 	playerState.facing = facing;
 	playerState.entity->setAnimationFrameRange(range.walkFirstFrame, range.walkLastFrame, true);
 	playerState.entity->setAnimationRate(kRoomPlayerWalkAnimationRate);
-	debugC(1, kDebugScene,
+	debugC(1, kDebugPlayer,
 		"Harvester: player walk animation facing=%d frames=%d..%d rate=%d",
 		facing, range.walkFirstFrame, range.walkLastFrame, kRoomPlayerWalkAnimationRate);
 	return true;
@@ -690,7 +690,7 @@ static RuntimeEntity *ensurePlayerIdleAnimationEntity(HarvesterEngine &engine,
 
 	positionPlayerIdleAnimationEntity(state, playerState, *idleState.entity);
 	idleState.entity->setVisible(false);
-	debugC(1, kDebugScene,
+	debugC(1, kDebugPlayer,
 		"Harvester: spawned player idle animation '%s' room='%s' frames=0..%d",
 		kPlayerIdleAnimationResourcePath, state.roomName.c_str(), idleState.entity->getLastFrame());
 	return idleState.entity;
@@ -718,7 +718,7 @@ static bool finishPlayerIdleAnimation(const StartupRoomSetupState &state, Startu
 	idleState.exiting = false;
 	idleState.restoreFacing = -1;
 	notePlayerIdleReset(idleState);
-	debugC(1, kDebugScene,
+	debugC(1, kDebugPlayer,
 		"Harvester: player idle animation finished room='%s' facing=%d",
 		state.roomName.c_str(), playerState.facing);
 	return true;
@@ -814,7 +814,7 @@ void Player::setMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayer
 	playerState.nextMovementTick = 0;
 	playerState.targetX = clampPlayerCenterXToNativeBounds(playerState, targetX);
 	playerState.targetZ = clampRoomDepth(state, targetZ);
-	debugC(1, kDebugScene,
+	debugC(1, kDebugPlayer,
 		"Harvester: player move target room='%s' current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f)",
 		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
 		playerState.targetX, mapRoomDepthToScreenY(state, playerState.targetZ), (double)playerState.targetZ);
@@ -875,7 +875,7 @@ bool Player::syncCombatLoadoutVisual(HarvesterEngine &engine, const StartupRoomS
 	(void)setIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
 	(void)applyRoomActorPlacement(state, *playerState.entity,
 		playerState.centerX, playerState.bottomY, playerState.z);
-	debugC(1, kDebugScene,
+	debugC(1, kDebugPlayer,
 		"Harvester: player combat loadout visual loadout=%d resource='%s' facing=%d pos=(%d,%d,z=%.2f)",
 		playerState.combatLoadout, resourcePath.c_str(), playerState.facing,
 		playerState.centerX, playerState.bottomY, (double)playerState.z);
@@ -896,7 +896,7 @@ bool Player::setIdleAnimation(StartupRoomPlayerState &playerState, int facing) {
 	playerState.entity->setAnimationFrameRange(range.idleFrame, range.idleFrame, false);
 	playerState.entity->setCurrentFrame(range.idleFrame);
 	if (changed) {
-		debugC(1, kDebugScene,
+		debugC(1, kDebugPlayer,
 			"Harvester: player idle animation facing=%d frame=%d",
 			facing, range.idleFrame);
 	}
@@ -910,7 +910,7 @@ bool Player::startAttackAnimation(const StartupRoomSetupState &state,
 		return false;
 	if (!startResolvedAttackAnimation(playerState, range))
 		return false;
-	debugC(1, kDebugScene,
+	debugC(1, kDebugPlayer,
 		"Harvester: player attack animation frames=%d..%d resume_facing=%d cursor=(%d,%d)",
 		range.firstFrame, range.lastFrame, range.resumeFacing, mousePos.x, mousePos.y);
 	return true;
@@ -925,7 +925,7 @@ bool Player::startKeyboardAttackAnimation(const StartupRoomSetupState &state,
 		return false;
 	if (!startResolvedAttackAnimation(playerState, range))
 		return false;
-	debugC(1, kDebugScene,
+	debugC(1, kDebugPlayer,
 		"Harvester: player keyboard attack animation frames=%d..%d resume_facing=%d input=(L=%d R=%d U=%d D=%d) facing=%d",
 		range.firstFrame, range.lastFrame, range.resumeFacing,
 		attackLeft, attackRight, attackUp, attackDown, playerState.facing);
@@ -1033,7 +1033,7 @@ bool Player::startTurnAnimation(StartupRoomPlayerState &playerState, int targetF
 	playerState.entity->setPlayBackwards(range.playBackwards);
 	playerState.entity->setAnimationRate(kRoomPlayerWalkAnimationRate);
 	playerState.entity->setCurrentFrame(range.playBackwards ? range.lastFrame : range.firstFrame);
-	debugC(1, kDebugScene,
+	debugC(1, kDebugPlayer,
 		"Harvester: player turn animation from=%d to=%d frames=%d..%d backwards=%d rate=%d",
 		playerState.facing, targetFacing, range.firstFrame, range.lastFrame,
 		range.playBackwards, kRoomPlayerWalkAnimationRate);
@@ -1054,7 +1054,7 @@ bool Player::updateTurnAnimationState(StartupRoomPlayerState &playerState) {
 	playerState.turnEndFrame = -1;
 	playerState.turnPlayBackwards = false;
 	playerState.entity->setAnimationRate(0);
-	debugC(1, kDebugScene,
+	debugC(1, kDebugPlayer,
 		"Harvester: player turn complete facing=%d frame=%d",
 		playerState.facing, playerState.entity->getCurrentFrame());
 	return true;
@@ -1110,7 +1110,7 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const StartupRoomSetupState
 		playerState.hasMoveTarget = false;
 		(void)setIdleAnimation(playerState, actualFacing);
 	}
-	debugC(playerState.hasMoveTarget ? 2 : 1, kDebugScene,
+	debugC(playerState.hasMoveTarget ? 2 : 1, kDebugPlayer,
 		"Harvester: player move step room='%s' pos=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f) facing=%d frame=%d active=%d moved=%d",
 		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
 		playerState.targetX, mapRoomDepthToScreenY(state, playerState.targetZ), (double)playerState.targetZ,
@@ -1186,7 +1186,7 @@ bool Player::stepKeyboardMovement(HarvesterEngine &engine, const StartupRoomSetu
 	const int actualFacing = resolveFacingFromRoomMovement(
 		state, previousCenterX, previousZ, playerState.centerX, playerState.z);
 	(void)setPlayerWalkAnimation(playerState, actualFacing);
-	debugC(2, kDebugScene,
+	debugC(2, kDebugPlayer,
 		"Harvester: player keyboard move room='%s' input=(%d,%d) pos=(%d,%d,z=%.2f) facing=%d frame=%d moved=%d",
 		state.roomName.c_str(), horizontalInput, verticalInput,
 		playerState.centerX, playerState.bottomY, (double)playerState.z,
@@ -1210,7 +1210,7 @@ bool Player::requestIdleAnimationExit(const StartupRoomSetupState &state,
 	idleState.entity->setAnimationFrameRange(exitStartFrame, exitLastFrame, false);
 	idleState.entity->setCurrentFrame(exitStartFrame);
 	idleState.entity->setAnimationRate(kRoomPlayerIdleAnimationRate);
-	debugC(1, kDebugScene,
+	debugC(1, kDebugPlayer,
 		"Harvester: player idle animation exit room='%s' frames=%d..%d rate=%d",
 		state.roomName.c_str(), exitStartFrame, exitLastFrame, kRoomPlayerIdleAnimationRate);
 	return true;
@@ -1240,7 +1240,7 @@ bool Player::startIdleAnimation(HarvesterEngine &engine, const StartupRoomSetupS
 	playerState.turnActive = false;
 	playerState.turnTargetFacing = -1;
 	playerState.entity->setVisible(false);
-	debugC(1, kDebugScene,
+	debugC(1, kDebugPlayer,
 		"Harvester: player idle animation start room='%s' facing=%d frames=%d..%d rate=%d trigger_tick=%u",
 		state.roomName.c_str(), idleState.restoreFacing, 0, loopLastFrame,
 		kRoomPlayerIdleAnimationRate, idleState.triggerTick);
@@ -1263,7 +1263,7 @@ bool Player::updateIdleAnimation(const StartupRoomSetupState &state,
 			idleState.entity->setCurrentFrame(loopStartFrame);
 			idleState.entity->setAnimationRate(kRoomPlayerIdleAnimationRate);
 		}
-		debugC(1, kDebugScene,
+		debugC(1, kDebugPlayer,
 			"Harvester: player idle animation loop room='%s' frames=%d..%d rate=%d",
 			state.roomName.c_str(), loopStartFrame, loopLastFrame, kRoomPlayerIdleAnimationRate);
 		return true;
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index f787961ef2f..17057cf9f60 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -69,6 +69,7 @@ static const int kNativeKeyboardAttackSideWindow = 30;
 static const char *const kNativeHitEffectResourcePath = "blood.abm";
 static const int kNativeHitEffectAnimationRate = 10;
 static const float kNativeHitEffectRenderZBias = 0.01f;
+static const char *const kCdChangePromptPalettePath = "1:/GRAPHIC/PAL/CD1.PAL";
 
 static int roundRoomCombatFloat(float value);
 
@@ -248,6 +249,38 @@ static bool loadBitmapResource(ResourceManager &resources, const Common::String
 	return true;
 }
 
+static bool loadPaletteResource(ResourceManager &resources, const Common::String &path, byte *palette) {
+	Common::Array<byte> data;
+	if (!resources.loadFile(path, data) || data.size() < 256 * 3)
+		return false;
+
+	memcpy(palette, data.data(), 256 * 3);
+	return true;
+}
+
+static void applyRoomPalette(Graphics::Screen &screen, const HarvesterEngine &engine,
+		const byte *palette, float brightness) {
+	byte scaledPalette[256 * 3];
+	buildHarvesterDisplayPalette(palette, brightness * engine.getStartupGammaBrightnessScale(), scaledPalette);
+	screen.setPalette(scaledPalette);
+}
+
+static void renderCdChangePromptScreen(HarvesterEngine &engine, const IndexedBitmap &bitmap,
+		const byte *palette) {
+	Graphics::Screen *screen = engine.getScreen();
+	if (!screen)
+		return;
+
+	applyRoomPalette(*screen, engine, palette, 1.0f);
+	screen->fillRect(screen->getBounds(), 0);
+	if (bitmap.isValid())
+		screen->copyRectToSurface(bitmap.pixels.data(), bitmap.width, 0, 0, bitmap.width, bitmap.height);
+	if (engine.getRuntimeEntities())
+		engine.getRuntimeEntities()->drawCursor(*screen);
+	screen->makeAllDirty();
+	screen->update();
+}
+
 static bool shouldDispatchPickupActionOnCarryStart(const StartupObjectRecord &object) {
 	if (object.actionTag.empty())
 		return false;
@@ -566,7 +599,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				_engine.clearPendingLoadedDialogueStateBlob();
 			}
 			const StartupSaveRoomState &loadedState = _engine.getPendingLoadedStartupSaveRoomState();
-			debugC(1, kDebugGeneral,
+			debugC(1, kDebugRoom,
 				"Harvester: applying pending loaded room state entrance='%s' room='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
 				loadedState.entranceName.c_str(), loadedState.roomName.c_str(),
 				loadedState.playerX, loadedState.playerY, loadedState.playerZ,
@@ -1433,6 +1466,78 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
 			return captureScreenBackdrop(*activeScreen, dialogueBackdrop);
 		};
+		auto showCdChangePrompt = [&](int discNumber) -> Common::Error {
+			if (discNumber <= 0 || !_engine.shouldShowCdChangePrompts())
+				return Common::kNoError;
+
+			ResourceManager *resources = _engine.getResources();
+			Graphics::Screen *screen = _engine.getScreen();
+			if (!resources || !screen)
+				return Common::kReadingFailed;
+
+			const Common::String bitmapPath = Common::String::format("1:/GRAPHIC/OTHER/CD%d.BM", discNumber);
+			IndexedBitmap promptBitmap;
+			byte promptPalette[256 * 3];
+			if (!loadBitmapResource(*resources, bitmapPath, promptBitmap) ||
+					!loadPaletteResource(*resources, kCdChangePromptPalettePath, promptPalette)) {
+				warning("Harvester: unable to load CD change prompt assets for disc %d", discNumber);
+				return Common::kReadingFailed;
+			}
+
+			debugC(1, kDebugScene, "Harvester: showing CD change prompt for disc %d", discNumber);
+			_engine.stopStartupMusic();
+
+			Common::Event event;
+			while (g_system->getEventManager()->pollEvent(event)) {
+				Common::Error result = Common::kNoError;
+				if (startupFlow.handleSystemEvent(event, result))
+					return result;
+				if (event.type == Common::EVENT_MOUSEMOVE)
+					_mousePos = event.mouse;
+			}
+
+			bool waitingForRelease = false;
+			bool needsRedraw = true;
+			Graphics::FrameLimiter limiter(g_system, 60);
+
+			while (!_engine.shouldQuit()) {
+				if (needsRedraw) {
+					renderCdChangePromptScreen(_engine, promptBitmap, promptPalette);
+					needsRedraw = false;
+				}
+
+				while (g_system->getEventManager()->pollEvent(event)) {
+					Common::Error result = Common::kNoError;
+					if (startupFlow.handleSystemEvent(event, result))
+						return result;
+
+					switch (event.type) {
+					case Common::EVENT_MOUSEMOVE:
+						_mousePos = event.mouse;
+						needsRedraw = true;
+						break;
+					case Common::EVENT_LBUTTONDOWN:
+						waitingForRelease = true;
+						break;
+					case Common::EVENT_LBUTTONUP:
+						if (waitingForRelease)
+							return Common::kNoError;
+						break;
+					default:
+						break;
+					}
+				}
+
+				RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+				if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
+					needsRedraw = true;
+
+				limiter.delayBeforeSwap();
+				limiter.startFrame();
+			}
+
+			return Common::kNoError;
+		};
 		auto runRoomExitCommands = [&]() -> Common::Error {
 			StartupInteractionResult exitInteraction;
 			if (!_engine.getStartupScript()->executeRoomExitCommands(scene.state.roomName, exitInteraction))
@@ -1442,6 +1547,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				if (!exitInteraction.musicPath.empty())
 					(void)_engine.playStartupMusic(exitInteraction.musicPath);
 				startupFlow.executeStartupAudioCommands(exitInteraction.audioCommands);
+				if (exitInteraction.cdChangeDisc > 0) {
+					Common::Error cdPromptError = showCdChangePrompt(exitInteraction.cdChangeDisc);
+					if (cdPromptError.getCode() != Common::kNoError)
+						return cdPromptError;
+				}
 
 				if (!exitInteraction.cutscenePath.empty()) {
 					FstPlayer fstPlayer(_engine);
@@ -1454,7 +1564,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 						!exitInteraction.modalText.value.empty() ||
 						exitInteraction.lightingCommand != kStartupLightingCommandNone ||
 						exitInteraction.requestPlayerGotoXZ) {
-					debugC(1, kDebugScene,
+					debugC(1, kDebugRoom,
 						"Harvester: room exit command for '%s' produced unsupported deferred output; preserving accumulated state",
 						scene.state.roomName.c_str());
 				}
@@ -1504,7 +1614,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				return Common::kNoError;
 			}
 			case kStartupLightingCommandFadeIn:
-				debugC(1, kDebugScene,
+				debugC(1, kDebugRoom,
 					"Harvester: CHANGE_LIGHTING FADE_IN has no direct room-side equivalent yet; preserving control flow");
 				return Common::kNoError;
 			}
@@ -1605,6 +1715,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			decltype(refreshCurrentScene) &refreshCurrentSceneFn;
 			decltype(applyCurrentRoomRuntimeMutationsInPlace) &applyCurrentRoomRuntimeMutationsInPlaceFn;
 			decltype(captureDialogueBackdrop) &captureDialogueBackdropFn;
+			decltype(showCdChangePrompt) &showCdChangePromptFn;
 			decltype(runRoomExitCommands) &runRoomExitCommandsFn;
 			decltype(applyLightingCommand) &applyLightingCommandFn;
 			decltype(applyPlayerGotoXZ) &applyPlayerGotoXZFn;
@@ -1642,6 +1753,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					restoreMusicPath = engine.getStartupMusicPath();
 				}
 				startupFlow.executeStartupAudioCommands(interaction.audioCommands);
+				if (interaction.cdChangeDisc > 0) {
+					Common::Error cdPromptError = showCdChangePromptFn(interaction.cdChangeDisc);
+					if (cdPromptError.getCode() != Common::kNoError)
+						return cdPromptError;
+				}
 
 				StartupRoomTransitionKind roomTransition = interaction.roomTransition;
 				if (roomTransition == kStartupRoomTransitionNone && !interaction.nextRoomName.empty())
@@ -1793,6 +1909,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		InteractionProcessor interactionProcessor = {
 			_engine, startupFlow, scene, playerState, pendingRegionName, pendingRoomChange,
 			refreshCurrentScene, applyCurrentRoomRuntimeMutationsInPlace, captureDialogueBackdrop,
+			showCdChangePrompt,
 			runRoomExitCommands, applyLightingCommand, applyPlayerGotoXZ, runModalShowText,
 			resetIdleState
 		};
@@ -2513,7 +2630,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				if (startupFlow.hasPendingMainMenuReturn())
 					return Common::kNoError;
 				if (shouldCancelDeniedPickup(pickupInteraction, didTransition)) {
-					debugC(1, kDebugScene,
+					debugC(1, kDebugRoom,
 						"Harvester: cancelling denied direct pickup object='%s' action='%s' text='%s'",
 						object.objectName.c_str(), object.actionTag.c_str(),
 						pickupInteraction.modalText.value.c_str());
@@ -2558,7 +2675,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				if (startupFlow.hasPendingMainMenuReturn())
 					return Common::kNoError;
 				if (shouldCancelDeniedPickup(pickupInteraction, didTransition)) {
-					debugC(1, kDebugScene,
+					debugC(1, kDebugRoom,
 						"Harvester: cancelling denied carried pickup object='%s' action='%s' text='%s'",
 						object.objectName.c_str(), object.actionTag.c_str(),
 						pickupInteraction.modalText.value.c_str());
@@ -3040,7 +3157,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 				const StartupRoomHoverState hoverState = resolveRoomHoverState(
 					_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions, _mousePos);
-				debugC(1, kDebugScene,
+				debugC(1, kDebugRoom,
 					"Harvester: room click room='%s' mouse=(%d,%d) object='%s' npc='%s' region='%s' cursor_sequence=%d prompt='%s'",
 					scene.state.roomName.c_str(), _mousePos.x, _mousePos.y,
 					hoverState.object ? hoverState.object->objectName.c_str() : "",
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index 6e9b01216bb..e4d7ea7220f 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -307,7 +307,7 @@ void RuntimeEntity::setAnimationSequence(int sequence) {
 	advanceAnimationFrame(_firstFrame);
 
 	if (_classId == kRuntimeEntityClassCursor) {
-		debugC(1, kDebugGeneral,
+		debugC(1, kDebugCursor,
 			"Harvester: cursor animation sequence=%d frames=%d..%d current=%d",
 			_animationSequence, _firstFrame, _lastFrame, _currentFrame);
 	}
@@ -774,7 +774,7 @@ RuntimeEntity *RuntimeEntityManager::spawnCursorEntity(const Common::Point &posi
 	if (_cursorEntity) {
 		const uint32 animationInterval = _cursorEntity->getAnimationRate() == 0 ? 0 :
 			(100U / (uint32)_cursorEntity->getAnimationRate());
-		debugC(1, kDebugGeneral,
+		debugC(1, kDebugCursor,
 			"Harvester: spawned cursor entity rate=%d intervalTicks=%u clock=centiseconds frame=%d..%d pos=(%d,%d)",
 			_cursorEntity->getAnimationRate(), animationInterval, _cursorEntity->getCurrentFrame(),
 			_cursorEntity->getLastFrame(), position.x, position.y);
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index 79b734ecd2a..85cff7dd33d 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -42,7 +42,7 @@ static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
 static const uint32 kHarvesterSaveVersion = 13;
 
 static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
-	debugC(1, kDebugGeneral,
+	debugC(1, kDebugRoom,
 		"Harvester: %s startup save room state valid=%d entrance='%s' room='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
 		operation, state.valid, state.entranceName.c_str(), state.roomName.c_str(),
 		state.playerX, state.playerY, state.playerZ, state.playerFacing, state.musicPath.c_str());
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 797a67f2a03..a4f25885dfc 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1055,9 +1055,24 @@ bool Script::resolveRoomSetupState(const Common::String &entranceName, StartupRo
 	Common::String musicPath;
 	Common::Array<StartupAudioCommand> audioCommands;
 	bool mutatedRuntimeState = false;
-	executeCommandChain(room->onEnterCommand, "room setup command", room->roomName, false,
-		&musicPath, &audioCommands, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
-		nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
+	executeCommandChain(
+		room->onEnterCommand, "room setup command", room->roomName, false,
+		&musicPath,
+		&audioCommands,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
 		&mutatedRuntimeState);
 
 	if (!buildRuntimeRoomState(*room, entrance, resources, state))
@@ -1067,7 +1082,7 @@ bool Script::resolveRoomSetupState(const Common::String &entranceName, StartupRo
 	if (!musicPath.empty())
 		state.musicPath = musicPath;
 
-	debugC(1, kDebugGeneral,
+	debugC(1, kDebugRoom,
 		"Harvester: resolveRoomSetupState('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u roomNpcs=%u roomMonsters=%u roomTimers=%u roomRegions=%u mutated=%d",
 		entranceName.c_str(), state.roomName.c_str(), state.entranceName.c_str(),
 		state.playerSpawnX, state.playerSpawnY, state.playerSpawnZ, state.playerFacing,
@@ -1376,7 +1391,7 @@ bool Script::executeRoomExitCommands(const Common::String &roomName,
 
 	executeCommandChain(room->onExitCommand, "room exit command", room->roomName, false,
 		&result.musicPath, &result.audioCommands, nullptr, nullptr, &result.cutscenePath,
-		nullptr, nullptr, &result.dialogueNpcName, &result.dialogueContinuationTag,
+		nullptr, nullptr, &result.cdChangeDisc, &result.dialogueNpcName, &result.dialogueContinuationTag,
 		&result.continuationTag, &result.modalText, &result.lightingCommand,
 		&result.requestPlayerGotoXZ, &result.playerGotoX, &result.playerGotoZ,
 		&result.mutatedRuntimeState);
@@ -1400,7 +1415,7 @@ bool Script::resolveObjectInteraction(const StartupObjectRecord &object, Startup
 
 	executeCommandChain(object.actionTag, "interaction command", object.objectName, true,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
-		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu,
+		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu, &result.cdChangeDisc,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ, &result.mutatedRuntimeState);
@@ -1408,7 +1423,7 @@ bool Script::resolveObjectInteraction(const StartupObjectRecord &object, Startup
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
 		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
-		!result.continuationTag.empty() || !result.modalText.value.empty() ||
+		result.cdChangeDisc > 0 || !result.continuationTag.empty() || !result.modalText.value.empty() ||
 		result.lightingCommand != kStartupLightingCommandNone || result.requestPlayerGotoXZ ||
 		result.mutatedRuntimeState || hasActionableCommandChain(object.actionTag);
 }
@@ -1420,14 +1435,14 @@ bool Script::resolveRegionInteraction(const StartupRegionRecord &region, Startup
 
 	executeCommandChain(region.actionTag, "region command", region.regionName, true,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
-		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu,
+		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu, &result.cdChangeDisc,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ, &result.mutatedRuntimeState);
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
 		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
-		!result.continuationTag.empty() || !result.modalText.value.empty() ||
+		result.cdChangeDisc > 0 || !result.continuationTag.empty() || !result.modalText.value.empty() ||
 		result.lightingCommand != kStartupLightingCommandNone || result.requestPlayerGotoXZ ||
 		result.mutatedRuntimeState || hasActionableCommandChain(region.actionTag);
 }
@@ -1443,7 +1458,7 @@ bool Script::resolveUseItemInteraction(const Common::String &itemName, const Sta
 	executeCommandChain(useItem->actionTag, "useitem command",
 		Common::String::format("%s -> %s", itemName.c_str(), target.objectName.c_str()), true,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
-		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu,
+		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu, &result.cdChangeDisc,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ, &result.mutatedRuntimeState);
@@ -1458,7 +1473,7 @@ bool Script::executeActionTag(const Common::String &tag, StartupInteractionResul
 
 	executeCommandChain(tag, "action tag", tag, allowTransitions,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
-		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu,
+		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu, &result.cdChangeDisc,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ, &result.mutatedRuntimeState);
@@ -1466,7 +1481,7 @@ bool Script::executeActionTag(const Common::String &tag, StartupInteractionResul
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
 		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
-		!result.continuationTag.empty() || !result.modalText.value.empty() ||
+		result.cdChangeDisc > 0 || !result.continuationTag.empty() || !result.modalText.value.empty() ||
 		result.lightingCommand != kStartupLightingCommandNone || result.requestPlayerGotoXZ ||
 		result.mutatedRuntimeState || hasActionableCommandChain(tag);
 }
@@ -1489,7 +1504,7 @@ bool Script::executeTimerAction(const Common::String &timerName, StartupInteract
 
 	executeCommandChain(timer->arg2, "timer command", timer->timerName, allowTransitions,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
-		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu,
+		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu, &result.cdChangeDisc,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ, &result.mutatedRuntimeState);
@@ -1497,7 +1512,7 @@ bool Script::executeTimerAction(const Common::String &timerName, StartupInteract
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
 		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
-		!result.continuationTag.empty() || !result.modalText.value.empty() ||
+		result.cdChangeDisc > 0 || !result.continuationTag.empty() || !result.modalText.value.empty() ||
 		result.lightingCommand != kStartupLightingCommandNone || result.requestPlayerGotoXZ ||
 		result.mutatedRuntimeState || hasActionableCommandChain(timer->arg2);
 }
@@ -2048,7 +2063,7 @@ bool Script::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupE
 		if (!npc.roomName.equalsIgnoreCase(room.roomName) || !npc.visible)
 			continue;
 		if (npc.deathOrMonsterfyFlag) {
-			debugC(1, kDebugScene,
+			debugC(1, kDebugRoom,
 				"Harvester: suppressed room npc room='%s' npc='%s' death_or_monsterfy=%d damage_type=%d model='%s' monsterfy_target='%s'",
 				room.roomName.c_str(), npc.npcName.c_str(), npc.deathOrMonsterfyFlag, npc.deathDamageType,
 				npc.modelPath.c_str(), npc.monsterfyTargetName.c_str());
@@ -2080,7 +2095,7 @@ bool Script::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupE
 		: kDefaultPaletteBrightness;
 
 	for (const StartupObjectRecord &object : state.roomObjects) {
-		debugC(1, kDebugScene,
+		debugC(1, kDebugRoom,
 			"Harvester: materialized room object room='%s' object='%s' owner='%s' visible=%d runtimeVisible=%d sprite='%s' alt='%s' pos=(%d,%d,%d) bounds=(%d,%d)-(%d,%d) action='%s'",
 			state.roomName.c_str(), object.objectName.c_str(), object.currentOwnerOrRoom.c_str(),
 			object.visible, object.runtimeVisible, object.spritePath.c_str(), object.altSpritePath.c_str(),
@@ -2088,14 +2103,14 @@ bool Script::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupE
 			object.currentX, object.currentY, object.boundsX2, object.boundsY2, object.actionTag.c_str());
 	}
 	for (const StartupNpcRecord &npc : state.roomNpcs) {
-		debugC(1, kDebugScene,
+		debugC(1, kDebugRoom,
 			"Harvester: materialized room npc room='%s' npc='%s' visible=%d active=%d pos=(%d,%d,%d) frame_delay=%d model='%s' on_death='%s' audio='%s'",
 			state.roomName.c_str(), npc.npcName.c_str(), npc.visible, npc.active,
 			npc.posX, npc.posY, npc.posZ, npc.frameDelay,
 			npc.modelPath.c_str(), npc.onDeathActionTag.c_str(), npc.audioPath.c_str());
 	}
 	for (const StartupMonsterRecord &monster : state.roomMonsters) {
-		debugC(1, kDebugScene,
+		debugC(1, kDebugRoom,
 			"Harvester: materialized room monster room='%s' monster='%s' visible=%d active=%d pos=(%d,%d,%d) facing=%d hp=%d/%d model='%s' on_death='%s'",
 			state.roomName.c_str(), monster.monsterName.c_str(), monster.visible, monster.active,
 			monster.posX, monster.posY, monster.posZ, monster.facing,
@@ -2103,13 +2118,13 @@ bool Script::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupE
 			monster.modelPath.c_str(), monster.onDeathActionTag.c_str());
 	}
 	for (const StartupTimerRecord &timer : state.roomTimers) {
-		debugC(1, kDebugScene,
+		debugC(1, kDebugRoom,
 			"Harvester: materialized room timer room='%s' timer='%s' current=%d initial=%d enabled=%d loop=%d global=%d",
 			state.roomName.c_str(), timer.timerName.c_str(), timer.currentValue, timer.initialValue,
 			timer.enabled, timer.looping, timer.global);
 	}
 
-	debugC(1, kDebugGeneral,
+	debugC(1, kDebugRoom,
 		"Harvester: materializeRoomState room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u roomAnims=%u roomNpcs=%u roomMonsters=%u roomTimers=%u roomRegions=%u",
 		state.roomName.c_str(), state.entranceName.c_str(), state.playerSpawnX, state.playerSpawnY,
 		state.playerSpawnZ, state.playerFacing, state.palettePath.c_str(), state.backgroundPath.c_str(),
@@ -2126,6 +2141,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		Common::Array<StartupAudioCommand> *audioCommands, Common::String *nextRoomName,
 		StartupRoomTransitionKind *roomTransition,
 		Common::String *cutscenePath, Common::String *deathFlicPath, bool *requestMainMenu,
+		int *cdChangeDisc,
 		Common::String *dialogueNpcName, Common::String *dialogueContinuationTag,
 		Common::String *continuationTag, StartupResolvedText *modalText,
 		StartupLightingCommand *lightingCommand, bool *requestPlayerGotoXZ,
@@ -2365,7 +2381,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			for (const Common::String &entry : execList->entries) {
 				executeCommandChain(entry, "exec list entry", execList->listName, allowTransitions,
 					musicPath, audioCommands, nextRoomName, roomTransition, cutscenePath,
-					deathFlicPath, requestMainMenu, dialogueNpcName, dialogueContinuationTag,
+					deathFlicPath, requestMainMenu, cdChangeDisc, dialogueNpcName, dialogueContinuationTag,
 					continuationTag, modalText, lightingCommand, requestPlayerGotoXZ,
 					playerGotoX, playerGotoZ, mutatedRuntimeState);
 				if (hasDeferredInteractionOutputs())
@@ -2544,6 +2560,24 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			return;
 		}
 
+		if (command->opcodeName.equalsIgnoreCase("CHANGE_CD")) {
+			if (!cdChangeDisc) {
+				debug(1, "Harvester: deferred startup command '%s' for %s '%s' has no CD-change context",
+					command->opcodeName.c_str(), contextLabel, contextName.c_str());
+				currentTag = command->arg4;
+				continue;
+			}
+
+			const int requestedDisc = parseAsciiIntOrZero(command->arg1);
+			if (requestedDisc > 0)
+				*cdChangeDisc = requestedDisc;
+			else
+				debug(1, "Harvester: invalid CHANGE_CD target '%s' for %s '%s'",
+					command->arg1.c_str(), contextLabel, contextName.c_str());
+			currentTag = command->arg4;
+			continue;
+		}
+
 		if (command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
 			if (allowTransitions && nextRoomName) {
@@ -2557,7 +2591,8 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 				debugC(1, kDebugScene, "Harvester: skipped transition opcode '%s' while processing %s '%s'",
 					command->opcodeName.c_str(), contextLabel, contextName.c_str());
 			}
-			return;
+			currentTag = command->arg4;
+			continue;
 		}
 
 		debug(1, "Harvester: unsupported startup command '%s' for %s '%s', continuing",
@@ -2633,6 +2668,7 @@ bool Script::probePickupBlockingCommandChain(const Common::String &initialTag,
 			command->opcodeName.equalsIgnoreCase("KILL_PC") ||
 			command->opcodeName.equalsIgnoreCase("PAUSE_PC") ||
 			command->opcodeName.equalsIgnoreCase("RESUME_PC") ||
+			command->opcodeName.equalsIgnoreCase("CHANGE_CD") ||
 			command->opcodeName.equalsIgnoreCase("PC_GOTO_XZ") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_LIGHTING") ||
 			command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
@@ -2689,6 +2725,7 @@ bool Script::hasActionableCommandChain(const Common::String &initialTag) const {
 			command->opcodeName.equalsIgnoreCase("KILL_PC") ||
 			command->opcodeName.equalsIgnoreCase("PAUSE_PC") ||
 			command->opcodeName.equalsIgnoreCase("RESUME_PC") ||
+			command->opcodeName.equalsIgnoreCase("CHANGE_CD") ||
 			command->opcodeName.equalsIgnoreCase("PC_GOTO_XZ") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_LIGHTING") ||
 			command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 142725ee455..54616ead658 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -294,6 +294,7 @@ struct StartupInteractionResult {
 	Common::Array<StartupAudioCommand> audioCommands;
 	StartupRoomTransitionKind roomTransition = kStartupRoomTransitionNone;
 	StartupLightingCommand lightingCommand = kStartupLightingCommandNone;
+	int cdChangeDisc = 0;
 	int playerGotoX = 0;
 	int playerGotoZ = 0;
 	bool requestPlayerGotoXZ = false;
@@ -424,6 +425,7 @@ private:
 		Common::Array<StartupAudioCommand> *audioCommands, Common::String *nextRoomName,
 		StartupRoomTransitionKind *roomTransition,
 		Common::String *cutscenePath, Common::String *deathFlicPath, bool *requestMainMenu,
+		int *cdChangeDisc,
 		Common::String *dialogueNpcName, Common::String *dialogueContinuationTag,
 		Common::String *continuationTag, StartupResolvedText *modalText,
 		StartupLightingCommand *lightingCommand, bool *requestPlayerGotoXZ,


Commit: 18634c751a660e50de8c69144c8bb0ff89f05e74
    https://github.com/scummvm/scummvm/commit/18634c751a660e50de8c69144c8bb0ff89f05e74
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:20+02:00

Commit Message:
HARVESTER: Add Alex Bevilacqua to Harvester credits

Changed paths:
    TRACKER.md
    engines/harvester/credits.pl


diff --git a/TRACKER.md b/TRACKER.md
index 060cf065626..e68836fff96 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Harvester validation pass around the day-5 overnight CD handoff and the hidden warp-to-CD3 path. With launcher option `Show CD change prompts` enabled, trigger `CHANGE_TO_CD2_A -> CHANGE_TO_CD2_B -> CHANGE_TO_CD2_C` and confirm the native `CD2.BM` prompt appears after `OVERNITE.FST`, waits for a fresh left-click press/release cycle, and then continues into `PC_ROOM_HOUSE_START`; also trigger `WARP_TO_LODGE_A -> WARP_TO_LODGE_B` and confirm the `CD3.BM` prompt appears after `C076.FST`. Repeat the same paths with the option disabled and confirm the prompt is skipped while the room handoff still completes.
 - Immediate: run an in-engine Harvester validation pass that covers both the startup title menu and the post-death `GAMEOVER.BM` overlay. Confirm the menu now preserves the native six-row layout with `main_menu_2` (`SAVE GAME`) blanked when no live room/session state exists, `main_menu_3` (`LOAD GAME`) still visible and functional from both entry points, and the `OPTIONS` / `HELP` / `QUIT GAME` rows retaining their original vertical spacing instead of collapsing upward.
 - Immediate: run an in-engine Harvester validation pass in `PCHOUSE` against Jimmy's `MONSTERFY` combat branch. Confirm the monster still enters combat at the same native locomotion rate, keeping the room loop redraw-only behavior during chase/attack; confirm an open inventory no longer reopens every item bitmap from disk when HP changes or status items refresh; and confirm a lethal Jimmy hit now plays the recovered player death bank before the room loop exits through the native `GAMEOVER.BM` / `GAMEOVER.PAL` + `ANXIETY.CMP` backdrop path instead of jumping directly out on `HP=0`.
 - Stay in live Ghidra on `call_hmi_driver_callback_op0` and its caller frame inside `initialize_sound_bank_resources`. The upstream source record is now labeled as `g_sound_bank_setup_source_record`, the callback-capture block at `0xca2d8` is typed as `g_sound_selected_hardware_config`, and the first three fields there are confirmed as `device_port`, `device_irq`, and `device_dma`. The remaining useful confirmation is whether the mixed far-pointer / stack-cleanup oddity in `call_hmi_driver_callback_op0` can be modeled well enough to expose what, if anything, the unresolved source dwords at `g_sound_bank_setup_source_record + 0x14` and `+ 0x18` contribute to the callback.
@@ -18,6 +19,7 @@
 
 ## Last Confirmed Action
 
+- On March 26, 2026, rechecked Harvester's native CD-swap script path in live `HARVEST.LE` before patching the ScummVM port. Ghidra confirmed that `parse_command_record` maps `CHANGE_CD` to opcode `0x23`, that `dispatch_room_event_actions` keeps executing through `CHANGE_ROOM` and only then calls `prompt_for_cdrom_disc_change`, and that `prompt_for_cdrom_disc_change` formats `1:\graphic\other\CD%i.BM`, loads `1:\graphic\pal\cd1.pal`, and waits for a fresh left-click press/release cycle before continuing. The decoded `HARVEST.SCR` chain around the day-5 handoff also matched that native control flow exactly: `CHANGE_TO_CD2_A` plays `GRAPHIC\FST\OVERNITE.FST`, `CHANGE_TO_CD2_B` queues `CHANGE_ROOM PC_ROOM_HOUSE_START`, and `CHANGE_TO_CD2_C` issues `CHANGE_CD 2`; the only other transition continuations in the shipped script are the `WARP_TO_CD2_*`, `STLEXIT2_*`, and `TO_CRYPT_TOP` tails. Patched [`engines/harvester/metaengine.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/metaengine.cpp), [`engines/harvester/detection.h`](/Users/alex/Workspace/scummvm/engines/harvester/detection.h), [`engines/harvester/detection_tables.h`](/Users/alex/Workspace/scummvm/engines/harvester/detection_tables.h), [`engines/harvester/harvester.h`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.h), [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp), [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp) so Harvester now exposes a launcher `Show CD change prompts` option, preserves `CHANGE_CD` after `GOFLIC` / `CHANGE_ROOM` continuations, and renders the native CD prompt image gated behind a fresh left click when the option is enabled. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, rechecked the native room-combat death path in live `HARVEST.LE` and corrected the ScummVM player defeat flow to match it more closely. Ghidra confirmed that `update_player_combat_avatar_state` does not hand control to `run_game_over_screen` on raw `HP <= 0`; it only returns true once the player combat avatar reaches terminal state `0x38`, after `update_actor_runtime_state` has already selected and played a concrete death-animation bank from the live combat state. The recovered death-bank selection block at `0x4e5a3..0x4e7ea` plus the state handlers at `0x5045a..0x50991` bounded the exact player ranges (`0xc4..0xff` for vertical deaths, `0xb0..0xc3` and `0x100..0x127` for horizontal deaths) and showed the death animation inherits the native hit-reaction cadence rather than the normal walk rate. Patched [`engines/harvester/player.h`](/Users/alex/Workspace/scummvm/engines/harvester/player.h), [`engines/harvester/player.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/player.cpp), [`engines/harvester/room_support.h`](/Users/alex/Workspace/scummvm/engines/harvester/room_support.h), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so lethal room-combat hits now start the recovered player death bank, suppress further region activation while that animation runs, and only queue the existing game-over return once the death sequence completes. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, rechecked the native `run_main_menu` / `run_game_over_screen` path in live `HARVEST.LE` before touching the ScummVM menu layout. Ghidra confirmed that `run_game_over_screen` clears `g_game_session_active`, leaves `run_main_menu` responsible for the overlay, and that `run_main_menu` preserves all six `main_menu_*` slots while blanking `main_menu_2` (`SAVE GAME`) when no live room/session state exists instead of collapsing the list. Patched [`engines/harvester/menu.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/menu.cpp) so the ScummVM startup/game-over menu now mirrors that native policy: `SAVE GAME` is blanked in place when unavailable, `LOAD GAME` remains visible when loading is allowed, and the downstream rows keep the original spacing. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, verified in live `HARVEST.LE` that native actor animation timing still uses `animation_tick_interval_ms = 100 / animation_rate`, with the room-combat locomotion path seeding `animation_rate = 0x11` inside `update_actor_runtime_state` (`0x4d750`), so the ScummVM combat-speed complaint was not evidence for changing the raw room-monster animation constant. Patched [`engines/harvester/inventory.h`](/Users/alex/Workspace/scummvm/engines/harvester/inventory.h), [`engines/harvester/inventory.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/inventory.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so inventory overlay refreshes now cache decoded bitmap resources by path instead of reopening every icon file on HP-driven refresh, and lethal room-combat monster hits now request the existing native game-over return path instead of leaving the player at `HP=0` inside the live room loop. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
diff --git a/engines/harvester/credits.pl b/engines/harvester/credits.pl
index 9cd5cfff20c..c70596d094f 100644
--- a/engines/harvester/credits.pl
+++ b/engines/harvester/credits.pl
@@ -1,3 +1,3 @@
 begin_section("Harvester");
-	# TODO: add engine maintainers here once the scaffold has an owner.
+	add_person("Alex Bevilacqua", "alexbevi", "");
 end_section();


Commit: 585314c68edba2932dc6bdab8e9aebb2de692b71
    https://github.com/scummvm/scummvm/commit/585314c68edba2932dc6bdab8e9aebb2de692b71
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:20+02:00

Commit Message:
HARVESTER: Fix Moynahan embalming-room dialogue alias

Changed paths:
    TRACKER.md
    engines/harvester/npc/moynahan_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index e68836fff96..1e2e94ae381 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Harvester validation pass in `MBLM` around the glue interaction branches. With Moynahan alive (`MOYNAHAN_IS_DEAD=0`), click the room `GLUE` hotspot and confirm `MBLMGLUE_COM -> START_DIALOG MOYNAHAN_MBLM` now lands in the restored Moynahan handler instead of logging `unsupported room NPC dialogue handler 'MOYNAHAN_MBLM'`; confirm that this interrupt path only plays the native glue-refusal dialogue and does not award inventory prematurely. Then validate both confirmed native acquisition paths still work: present `CASKET_PHOTO` / `CASKET_PHOTOCOPY` to Moynahan and take the glue-granting response at `dialog.rsp[0x18d]`, and on day 5 follow the `0x1bf..0x1c2` glue topic through response menu `0x1c3` to confirm `GLUE` plus `JUST_GOT_GLUE`. Finally, recheck the direct room-click pickup with `MOYNAHAN_IS_DEAD=1` to confirm the script-side `ADD_GLUE` branch still bypasses dialogue and awards the object cleanly.
 - Immediate: run an in-engine Harvester validation pass around the day-5 overnight CD handoff and the hidden warp-to-CD3 path. With launcher option `Show CD change prompts` enabled, trigger `CHANGE_TO_CD2_A -> CHANGE_TO_CD2_B -> CHANGE_TO_CD2_C` and confirm the native `CD2.BM` prompt appears after `OVERNITE.FST`, waits for a fresh left-click press/release cycle, and then continues into `PC_ROOM_HOUSE_START`; also trigger `WARP_TO_LODGE_A -> WARP_TO_LODGE_B` and confirm the `CD3.BM` prompt appears after `C076.FST`. Repeat the same paths with the option disabled and confirm the prompt is skipped while the room handoff still completes.
 - Immediate: run an in-engine Harvester validation pass that covers both the startup title menu and the post-death `GAMEOVER.BM` overlay. Confirm the menu now preserves the native six-row layout with `main_menu_2` (`SAVE GAME`) blanked when no live room/session state exists, `main_menu_3` (`LOAD GAME`) still visible and functional from both entry points, and the `OPTIONS` / `HELP` / `QUIT GAME` rows retaining their original vertical spacing instead of collapsing upward.
 - Immediate: run an in-engine Harvester validation pass in `PCHOUSE` against Jimmy's `MONSTERFY` combat branch. Confirm the monster still enters combat at the same native locomotion rate, keeping the room loop redraw-only behavior during chase/attack; confirm an open inventory no longer reopens every item bitmap from disk when HP changes or status items refresh; and confirm a lethal Jimmy hit now plays the recovered player death bank before the room loop exits through the native `GAMEOVER.BM` / `GAMEOVER.PAL` + `ANXIETY.CMP` backdrop path instead of jumping directly out on `HP=0`.
@@ -19,6 +20,7 @@
 
 ## Last Confirmed Action
 
+- On March 26, 2026, traced the `MBLM` glue regression to an alias mismatch between the room script and the restored Moynahan dialogue handler. The native room action `MBLMGLUE_COM` already dispatches `START_DIALOG MOYNAHAN_MBLM` whenever `MOYNAHAN_IS_DEAD` is clear, but [`engines/harvester/npc/moynahan_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/moynahan_dialogue.cpp) was still matching only `MOYNAHAN`, so live room clicks fell through to `unsupported room NPC dialogue handler 'MOYNAHAN_MBLM'` instead of entering the confirmed `IF_TRY_TO_TAKE_THE_GLUE` interruption path. Patched the handler to accept both speaker ids and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, rechecked Harvester's native CD-swap script path in live `HARVEST.LE` before patching the ScummVM port. Ghidra confirmed that `parse_command_record` maps `CHANGE_CD` to opcode `0x23`, that `dispatch_room_event_actions` keeps executing through `CHANGE_ROOM` and only then calls `prompt_for_cdrom_disc_change`, and that `prompt_for_cdrom_disc_change` formats `1:\graphic\other\CD%i.BM`, loads `1:\graphic\pal\cd1.pal`, and waits for a fresh left-click press/release cycle before continuing. The decoded `HARVEST.SCR` chain around the day-5 handoff also matched that native control flow exactly: `CHANGE_TO_CD2_A` plays `GRAPHIC\FST\OVERNITE.FST`, `CHANGE_TO_CD2_B` queues `CHANGE_ROOM PC_ROOM_HOUSE_START`, and `CHANGE_TO_CD2_C` issues `CHANGE_CD 2`; the only other transition continuations in the shipped script are the `WARP_TO_CD2_*`, `STLEXIT2_*`, and `TO_CRYPT_TOP` tails. Patched [`engines/harvester/metaengine.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/metaengine.cpp), [`engines/harvester/detection.h`](/Users/alex/Workspace/scummvm/engines/harvester/detection.h), [`engines/harvester/detection_tables.h`](/Users/alex/Workspace/scummvm/engines/harvester/detection_tables.h), [`engines/harvester/harvester.h`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.h), [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp), [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp) so Harvester now exposes a launcher `Show CD change prompts` option, preserves `CHANGE_CD` after `GOFLIC` / `CHANGE_ROOM` continuations, and renders the native CD prompt image gated behind a fresh left click when the option is enabled. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, rechecked the native room-combat death path in live `HARVEST.LE` and corrected the ScummVM player defeat flow to match it more closely. Ghidra confirmed that `update_player_combat_avatar_state` does not hand control to `run_game_over_screen` on raw `HP <= 0`; it only returns true once the player combat avatar reaches terminal state `0x38`, after `update_actor_runtime_state` has already selected and played a concrete death-animation bank from the live combat state. The recovered death-bank selection block at `0x4e5a3..0x4e7ea` plus the state handlers at `0x5045a..0x50991` bounded the exact player ranges (`0xc4..0xff` for vertical deaths, `0xb0..0xc3` and `0x100..0x127` for horizontal deaths) and showed the death animation inherits the native hit-reaction cadence rather than the normal walk rate. Patched [`engines/harvester/player.h`](/Users/alex/Workspace/scummvm/engines/harvester/player.h), [`engines/harvester/player.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/player.cpp), [`engines/harvester/room_support.h`](/Users/alex/Workspace/scummvm/engines/harvester/room_support.h), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so lethal room-combat hits now start the recovered player death bank, suppress further region activation while that animation runs, and only queue the existing game-over return once the death sequence completes. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, rechecked the native `run_main_menu` / `run_game_over_screen` path in live `HARVEST.LE` before touching the ScummVM menu layout. Ghidra confirmed that `run_game_over_screen` clears `g_game_session_active`, leaves `run_main_menu` responsible for the overlay, and that `run_main_menu` preserves all six `main_menu_*` slots while blanking `main_menu_2` (`SAVE GAME`) when no live room/session state exists instead of collapsing the list. Patched [`engines/harvester/menu.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/menu.cpp) so the ScummVM startup/game-over menu now mirrors that native policy: `SAVE GAME` is blanked in place when unavailable, `LOAD GAME` remains visible when loading is allowed, and the downstream rows keep the original spacing. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
diff --git a/engines/harvester/npc/moynahan_dialogue.cpp b/engines/harvester/npc/moynahan_dialogue.cpp
index 8aa4d8260dd..f1b7086b0b5 100644
--- a/engines/harvester/npc/moynahan_dialogue.cpp
+++ b/engines/harvester/npc/moynahan_dialogue.cpp
@@ -198,7 +198,8 @@ static Common::Error playDialogueSequence(DialogueRuntime &runtime,
 } // End of namespace
 
 bool MoynahanDialogueHandler::matchesNpc(const Common::String &npcName) const {
-	return npcName.equalsIgnoreCase(kMoynahanNpc);
+	return npcName.equalsIgnoreCase(kMoynahanNpc) ||
+		npcName.equalsIgnoreCase(kMoynahanEmbalmingPortrait);
 }
 
 Common::Error MoynahanDialogueHandler::handleDialogue(DialogueRuntime &runtime,


Commit: 4220bd786225feea4bf236181f0c196ca213746b
    https://github.com/scummvm/scummvm/commit/4220bd786225feea4bf236181f0c196ca213746b
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:20+02:00

Commit Message:
HARVESTER: Restore denied glue pickup after dialogue

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 1e2e94ae381..34629a07238 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,7 +10,7 @@
 
 ## Next Suggested Action
 
-- Immediate: run an in-engine Harvester validation pass in `MBLM` around the glue interaction branches. With Moynahan alive (`MOYNAHAN_IS_DEAD=0`), click the room `GLUE` hotspot and confirm `MBLMGLUE_COM -> START_DIALOG MOYNAHAN_MBLM` now lands in the restored Moynahan handler instead of logging `unsupported room NPC dialogue handler 'MOYNAHAN_MBLM'`; confirm that this interrupt path only plays the native glue-refusal dialogue and does not award inventory prematurely. Then validate both confirmed native acquisition paths still work: present `CASKET_PHOTO` / `CASKET_PHOTOCOPY` to Moynahan and take the glue-granting response at `dialog.rsp[0x18d]`, and on day 5 follow the `0x1bf..0x1c2` glue topic through response menu `0x1c3` to confirm `GLUE` plus `JUST_GOT_GLUE`. Finally, recheck the direct room-click pickup with `MOYNAHAN_IS_DEAD=1` to confirm the script-side `ADD_GLUE` branch still bypasses dialogue and awards the object cleanly.
+- Immediate: rerun the in-engine `MBLM` glue validation pass with a live player entity after the carry-state rollback fix. With Moynahan alive (`MOYNAHAN_IS_DEAD=0`), click the room `GLUE` hotspot and confirm `MBLMGLUE_COM -> START_DIALOG MOYNAHAN_MBLM` still lands in the restored Moynahan handler, the native refusal lines `0x3e4d/0x3e52/0x3e56 -> 0x3e76/0x3e7a` play, and the denied pickup no longer survives as a carried room item that later gets stowed into inventory when the inventory overlay, room refresh, or another stash path runs. Then validate both confirmed native acquisition paths still work: present `CASKET_PHOTO` / `CASKET_PHOTOCOPY` to Moynahan and take the glue-granting response at `dialog.rsp[0x18d]`, and on day 5 follow the `0x1bf..0x1c2` glue topic through response menu `0x1c3` to confirm `GLUE` plus `JUST_GOT_GLUE`. Finally, recheck the direct room-click pickup with `MOYNAHAN_IS_DEAD=1` to confirm the script-side `ADD_GLUE` branch still bypasses dialogue and awards the object cleanly.
 - Immediate: run an in-engine Harvester validation pass around the day-5 overnight CD handoff and the hidden warp-to-CD3 path. With launcher option `Show CD change prompts` enabled, trigger `CHANGE_TO_CD2_A -> CHANGE_TO_CD2_B -> CHANGE_TO_CD2_C` and confirm the native `CD2.BM` prompt appears after `OVERNITE.FST`, waits for a fresh left-click press/release cycle, and then continues into `PC_ROOM_HOUSE_START`; also trigger `WARP_TO_LODGE_A -> WARP_TO_LODGE_B` and confirm the `CD3.BM` prompt appears after `C076.FST`. Repeat the same paths with the option disabled and confirm the prompt is skipped while the room handoff still completes.
 - Immediate: run an in-engine Harvester validation pass that covers both the startup title menu and the post-death `GAMEOVER.BM` overlay. Confirm the menu now preserves the native six-row layout with `main_menu_2` (`SAVE GAME`) blanked when no live room/session state exists, `main_menu_3` (`LOAD GAME`) still visible and functional from both entry points, and the `OPTIONS` / `HELP` / `QUIT GAME` rows retaining their original vertical spacing instead of collapsing upward.
 - Immediate: run an in-engine Harvester validation pass in `PCHOUSE` against Jimmy's `MONSTERFY` combat branch. Confirm the monster still enters combat at the same native locomotion rate, keeping the room loop redraw-only behavior during chase/attack; confirm an open inventory no longer reopens every item bitmap from disk when HP changes or status items refresh; and confirm a lethal Jimmy hit now plays the recovered player death bank before the room loop exits through the native `GAMEOVER.BM` / `GAMEOVER.PAL` + `ANXIETY.CMP` backdrop path instead of jumping directly out on `HP=0`.
@@ -20,6 +20,7 @@
 
 ## Last Confirmed Action
 
+- On March 26, 2026, traced the remaining `MBLM` glue regression to the room-item carry path rather than the Moynahan dialogue handler. The alias fix was already working: live logs showed `MBLMGLUE_COM` reaching `MOYNAHAN_MBLM` and playing the native refusal lines `0x3e4d` / `0x3e52` / `0x3e56` / `0x3e76` / `0x3e7a`, but [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) was still leaving the denied `GLUE` as a carried room item because the carry-start rollback only handled modal-text denials. Patched the carried-pickup branch so, after a pickup action opens dialogue, it restores the original room object and clears the transient carry state unless that dialogue path explicitly moved the object into inventory; rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, traced the `MBLM` glue regression to an alias mismatch between the room script and the restored Moynahan dialogue handler. The native room action `MBLMGLUE_COM` already dispatches `START_DIALOG MOYNAHAN_MBLM` whenever `MOYNAHAN_IS_DEAD` is clear, but [`engines/harvester/npc/moynahan_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/moynahan_dialogue.cpp) was still matching only `MOYNAHAN`, so live room clicks fell through to `unsupported room NPC dialogue handler 'MOYNAHAN_MBLM'` instead of entering the confirmed `IF_TRY_TO_TAKE_THE_GLUE` interruption path. Patched the handler to accept both speaker ids and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, rechecked Harvester's native CD-swap script path in live `HARVEST.LE` before patching the ScummVM port. Ghidra confirmed that `parse_command_record` maps `CHANGE_CD` to opcode `0x23`, that `dispatch_room_event_actions` keeps executing through `CHANGE_ROOM` and only then calls `prompt_for_cdrom_disc_change`, and that `prompt_for_cdrom_disc_change` formats `1:\graphic\other\CD%i.BM`, loads `1:\graphic\pal\cd1.pal`, and waits for a fresh left-click press/release cycle before continuing. The decoded `HARVEST.SCR` chain around the day-5 handoff also matched that native control flow exactly: `CHANGE_TO_CD2_A` plays `GRAPHIC\FST\OVERNITE.FST`, `CHANGE_TO_CD2_B` queues `CHANGE_ROOM PC_ROOM_HOUSE_START`, and `CHANGE_TO_CD2_C` issues `CHANGE_CD 2`; the only other transition continuations in the shipped script are the `WARP_TO_CD2_*`, `STLEXIT2_*`, and `TO_CRYPT_TOP` tails. Patched [`engines/harvester/metaengine.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/metaengine.cpp), [`engines/harvester/detection.h`](/Users/alex/Workspace/scummvm/engines/harvester/detection.h), [`engines/harvester/detection_tables.h`](/Users/alex/Workspace/scummvm/engines/harvester/detection_tables.h), [`engines/harvester/harvester.h`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.h), [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp), [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp) so Harvester now exposes a launcher `Show CD change prompts` option, preserves `CHANGE_CD` after `GOFLIC` / `CHANGE_ROOM` continuations, and renders the native CD prompt image gated behind a fresh left click when the option is enabled. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, rechecked the native room-combat death path in live `HARVEST.LE` and corrected the ScummVM player defeat flow to match it more closely. Ghidra confirmed that `update_player_combat_avatar_state` does not hand control to `run_game_over_screen` on raw `HP <= 0`; it only returns true once the player combat avatar reaches terminal state `0x38`, after `update_actor_runtime_state` has already selected and played a concrete death-animation bank from the live combat state. The recovered death-bank selection block at `0x4e5a3..0x4e7ea` plus the state handlers at `0x5045a..0x50991` bounded the exact player ranges (`0xc4..0xff` for vertical deaths, `0xb0..0xc3` and `0x100..0x127` for horizontal deaths) and showed the death animation inherits the native hit-reaction cadence rather than the normal walk rate. Patched [`engines/harvester/player.h`](/Users/alex/Workspace/scummvm/engines/harvester/player.h), [`engines/harvester/player.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/player.cpp), [`engines/harvester/room_support.h`](/Users/alex/Workspace/scummvm/engines/harvester/room_support.h), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so lethal room-combat hits now start the recovered player death bank, suppress further region activation while that animation runs, and only queue the existing game-over return once the death sequence completes. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 17057cf9f60..be872c1fef8 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -2681,6 +2681,15 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 						pickupInteraction.modalText.value.c_str());
 					return restoreDeniedPickup(*startupScript, savedRuntimeObject, true);
 				}
+				if (!didTransition &&
+						!pickupInteraction.dialogueNpcName.empty() &&
+						!startupScript->isObjectInInventory(object.objectName)) {
+					debugC(1, kDebugRoom,
+						"Harvester: restoring carried pickup after dialogue object='%s' action='%s' npc='%s'",
+						object.objectName.c_str(), object.actionTag.c_str(),
+						pickupInteraction.dialogueNpcName.c_str());
+					return restoreDeniedPickup(*startupScript, savedRuntimeObject, true);
+				}
 			}
 		}
 


Commit: b7549e8d30a0ae233e1bd581dbb20239880fa8df
    https://github.com/scummvm/scummvm/commit/b7549e8d30a0ae233e1bd581dbb20239880fa8df
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:21+02:00

Commit Message:
HARVESTER: Fix glue pickup rollback crash

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 34629a07238..7154f36c416 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,7 +10,7 @@
 
 ## Next Suggested Action
 
-- Immediate: rerun the in-engine `MBLM` glue validation pass with a live player entity after the carry-state rollback fix. With Moynahan alive (`MOYNAHAN_IS_DEAD=0`), click the room `GLUE` hotspot and confirm `MBLMGLUE_COM -> START_DIALOG MOYNAHAN_MBLM` still lands in the restored Moynahan handler, the native refusal lines `0x3e4d/0x3e52/0x3e56 -> 0x3e76/0x3e7a` play, and the denied pickup no longer survives as a carried room item that later gets stowed into inventory when the inventory overlay, room refresh, or another stash path runs. Then validate both confirmed native acquisition paths still work: present `CASKET_PHOTO` / `CASKET_PHOTOCOPY` to Moynahan and take the glue-granting response at `dialog.rsp[0x18d]`, and on day 5 follow the `0x1bf..0x1c2` glue topic through response menu `0x1c3` to confirm `GLUE` plus `JUST_GOT_GLUE`. Finally, recheck the direct room-click pickup with `MOYNAHAN_IS_DEAD=1` to confirm the script-side `ADD_GLUE` branch still bypasses dialogue and awards the object cleanly.
+- Immediate: rerun the in-engine `MBLM` glue validation pass after the stale-reference crash fix. With Moynahan alive (`MOYNAHAN_IS_DEAD=0`), click the room `GLUE` hotspot and confirm `MBLMGLUE_COM -> START_DIALOG MOYNAHAN_MBLM` still lands in the restored Moynahan handler, the native refusal lines `0x3e4d/0x3e52/0x3e56 -> 0x3e76/0x3e7a` play, the denied pickup still rolls back out of carry/inventory state, and dismissing the dialogue no longer trips the main-thread assert in `Common::String::compareToIgnoreCase` from the post-dialogue rollback check. Then validate both confirmed native acquisition paths still work: present `CASKET_PHOTO` / `CASKET_PHOTOCOPY` to Moynahan and take the glue-granting response at `dialog.rsp[0x18d]`, and on day 5 follow the `0x1bf..0x1c2` glue topic through response menu `0x1c3` to confirm `GLUE` plus `JUST_GOT_GLUE`. Finally, recheck the direct room-click pickup with `MOYNAHAN_IS_DEAD=1` to confirm the script-side `ADD_GLUE` branch still bypasses dialogue and awards the object cleanly.
 - Immediate: run an in-engine Harvester validation pass around the day-5 overnight CD handoff and the hidden warp-to-CD3 path. With launcher option `Show CD change prompts` enabled, trigger `CHANGE_TO_CD2_A -> CHANGE_TO_CD2_B -> CHANGE_TO_CD2_C` and confirm the native `CD2.BM` prompt appears after `OVERNITE.FST`, waits for a fresh left-click press/release cycle, and then continues into `PC_ROOM_HOUSE_START`; also trigger `WARP_TO_LODGE_A -> WARP_TO_LODGE_B` and confirm the `CD3.BM` prompt appears after `C076.FST`. Repeat the same paths with the option disabled and confirm the prompt is skipped while the room handoff still completes.
 - Immediate: run an in-engine Harvester validation pass that covers both the startup title menu and the post-death `GAMEOVER.BM` overlay. Confirm the menu now preserves the native six-row layout with `main_menu_2` (`SAVE GAME`) blanked when no live room/session state exists, `main_menu_3` (`LOAD GAME`) still visible and functional from both entry points, and the `OPTIONS` / `HELP` / `QUIT GAME` rows retaining their original vertical spacing instead of collapsing upward.
 - Immediate: run an in-engine Harvester validation pass in `PCHOUSE` against Jimmy's `MONSTERFY` combat branch. Confirm the monster still enters combat at the same native locomotion rate, keeping the room loop redraw-only behavior during chase/attack; confirm an open inventory no longer reopens every item bitmap from disk when HP changes or status items refresh; and confirm a lethal Jimmy hit now plays the recovered player death bank before the room loop exits through the native `GAMEOVER.BM` / `GAMEOVER.PAL` + `ANXIETY.CMP` backdrop path instead of jumping directly out on `HP=0`.
@@ -20,6 +20,7 @@
 
 ## Last Confirmed Action
 
+- On March 26, 2026, traced the `MBLM` glue assert to a stale `StartupObjectRecord` reference in the room pickup loop, not to `Common::String` itself. The post-dialogue carry rollback added in [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) was still reading `object.objectName` and `object.actionTag` after `handleInteractionResult()`, but that interaction can rebuild the room scene and invalidate the clicked-object reference. Patched both room pickup branches to use the pre-saved object copy for all post-interaction logging and rollback checks, including the `isObjectInInventory()` probe that had been asserting in `Common::String::compareToIgnoreCase`. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, traced the remaining `MBLM` glue regression to the room-item carry path rather than the Moynahan dialogue handler. The alias fix was already working: live logs showed `MBLMGLUE_COM` reaching `MOYNAHAN_MBLM` and playing the native refusal lines `0x3e4d` / `0x3e52` / `0x3e56` / `0x3e76` / `0x3e7a`, but [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) was still leaving the denied `GLUE` as a carried room item because the carry-start rollback only handled modal-text denials. Patched the carried-pickup branch so, after a pickup action opens dialogue, it restores the original room object and clears the transient carry state unless that dialogue path explicitly moved the object into inventory; rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, traced the `MBLM` glue regression to an alias mismatch between the room script and the restored Moynahan dialogue handler. The native room action `MBLMGLUE_COM` already dispatches `START_DIALOG MOYNAHAN_MBLM` whenever `MOYNAHAN_IS_DEAD` is clear, but [`engines/harvester/npc/moynahan_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/moynahan_dialogue.cpp) was still matching only `MOYNAHAN`, so live room clicks fell through to `unsupported room NPC dialogue handler 'MOYNAHAN_MBLM'` instead of entering the confirmed `IF_TRY_TO_TAKE_THE_GLUE` interruption path. Patched the handler to accept both speaker ids and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, rechecked Harvester's native CD-swap script path in live `HARVEST.LE` before patching the ScummVM port. Ghidra confirmed that `parse_command_record` maps `CHANGE_CD` to opcode `0x23`, that `dispatch_room_event_actions` keeps executing through `CHANGE_ROOM` and only then calls `prompt_for_cdrom_disc_change`, and that `prompt_for_cdrom_disc_change` formats `1:\graphic\other\CD%i.BM`, loads `1:\graphic\pal\cd1.pal`, and waits for a fresh left-click press/release cycle before continuing. The decoded `HARVEST.SCR` chain around the day-5 handoff also matched that native control flow exactly: `CHANGE_TO_CD2_A` plays `GRAPHIC\FST\OVERNITE.FST`, `CHANGE_TO_CD2_B` queues `CHANGE_ROOM PC_ROOM_HOUSE_START`, and `CHANGE_TO_CD2_C` issues `CHANGE_CD 2`; the only other transition continuations in the shipped script are the `WARP_TO_CD2_*`, `STLEXIT2_*`, and `TO_CRYPT_TOP` tails. Patched [`engines/harvester/metaengine.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/metaengine.cpp), [`engines/harvester/detection.h`](/Users/alex/Workspace/scummvm/engines/harvester/detection.h), [`engines/harvester/detection_tables.h`](/Users/alex/Workspace/scummvm/engines/harvester/detection_tables.h), [`engines/harvester/harvester.h`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.h), [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp), [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp) so Harvester now exposes a launcher `Show CD change prompts` option, preserves `CHANGE_CD` after `GOFLIC` / `CHANGE_ROOM` continuations, and renders the native CD prompt image gated behind a fresh left click when the option is enabled. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index be872c1fef8..1d130247f30 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -2632,7 +2632,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				if (shouldCancelDeniedPickup(pickupInteraction, didTransition)) {
 					debugC(1, kDebugRoom,
 						"Harvester: cancelling denied direct pickup object='%s' action='%s' text='%s'",
-						object.objectName.c_str(), object.actionTag.c_str(),
+						savedRuntimeObject.objectName.c_str(), savedRuntimeObject.actionTag.c_str(),
 						pickupInteraction.modalText.value.c_str());
 					return restoreDeniedPickup(*startupScript, savedRuntimeObject, false);
 				}
@@ -2677,16 +2677,16 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				if (shouldCancelDeniedPickup(pickupInteraction, didTransition)) {
 					debugC(1, kDebugRoom,
 						"Harvester: cancelling denied carried pickup object='%s' action='%s' text='%s'",
-						object.objectName.c_str(), object.actionTag.c_str(),
+						savedRuntimeObject.objectName.c_str(), savedRuntimeObject.actionTag.c_str(),
 						pickupInteraction.modalText.value.c_str());
 					return restoreDeniedPickup(*startupScript, savedRuntimeObject, true);
 				}
 				if (!didTransition &&
 						!pickupInteraction.dialogueNpcName.empty() &&
-						!startupScript->isObjectInInventory(object.objectName)) {
+						!startupScript->isObjectInInventory(savedRuntimeObject.objectName)) {
 					debugC(1, kDebugRoom,
 						"Harvester: restoring carried pickup after dialogue object='%s' action='%s' npc='%s'",
-						object.objectName.c_str(), object.actionTag.c_str(),
+						savedRuntimeObject.objectName.c_str(), savedRuntimeObject.actionTag.c_str(),
 						pickupInteraction.dialogueNpcName.c_str());
 					return restoreDeniedPickup(*startupScript, savedRuntimeObject, true);
 				}


Commit: 36a0fbef2dd156bdc0bc293150b19158c05a578c
    https://github.com/scummvm/scummvm/commit/36a0fbef2dd156bdc0bc293150b19158c05a578c
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:21+02:00

Commit Message:
HARVESTER: Add combat debug overlay

Changed paths:
    engines/harvester/console.cpp
    engines/harvester/console.h
    engines/harvester/flow.cpp
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/engines/harvester/console.cpp b/engines/harvester/console.cpp
index 5ec6a02d8fb..82fcc61b64c 100644
--- a/engines/harvester/console.cpp
+++ b/engines/harvester/console.cpp
@@ -26,6 +26,7 @@ namespace Harvester {
 
 Console::Console() : GUI::Debugger() {
 	registerCmd("about", WRAP_METHOD(Console, Cmd_about));
+	registerCmd("DEBUG_COMBAT", WRAP_METHOD(Console, Cmd_debugCombat));
 	registerCmd("DEBUG_ROOM", WRAP_METHOD(Console, Cmd_debugRoom));
 }
 
@@ -37,6 +38,22 @@ bool Console::Cmd_about(int argc, const char **argv) {
 	return true;
 }
 
+bool Console::Cmd_debugCombat(int argc, const char **argv) {
+	if (argc != 1) {
+		debugPrintf("Usage: DEBUG_COMBAT\n");
+		return true;
+	}
+
+	if (!g_engine) {
+		debugPrintf("Harvester engine is not active\n");
+		return true;
+	}
+
+	const bool enabled = g_engine->toggleCombatDebugEnabled();
+	debugPrintf("Combat debug overlay %s\n", enabled ? "enabled" : "disabled");
+	return true;
+}
+
 bool Console::Cmd_debugRoom(int argc, const char **argv) {
 	if (argc != 1) {
 		debugPrintf("Usage: DEBUG_ROOM\n");
diff --git a/engines/harvester/console.h b/engines/harvester/console.h
index 076c77b8b96..c0410666700 100644
--- a/engines/harvester/console.h
+++ b/engines/harvester/console.h
@@ -30,6 +30,7 @@ namespace Harvester {
 class Console : public GUI::Debugger {
 private:
 	bool Cmd_about(int argc, const char **argv);
+	bool Cmd_debugCombat(int argc, const char **argv);
 	bool Cmd_debugRoom(int argc, const char **argv);
 public:
 	Console();
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 38039c42dbd..383c87c2484 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -160,6 +160,16 @@ static Common::String resolveRoomDebugNpcLabel(const StartupNpcRecord &npc) {
 	return label;
 }
 
+static Common::String resolveCombatDebugLabel(const Common::String &name, int currentHitPoints, int maxHitPoints) {
+	Common::String label = name;
+	for (uint i = 0; i < label.size(); ++i) {
+		if (label[i] == '_')
+			label.setChar(' ', i);
+	}
+
+	return Common::String::format("%s (%d/%d)", label.c_str(), currentHitPoints, maxHitPoints);
+}
+
 static void drawRoomDebugLabel(Graphics::Screen &screen, const Graphics::Font &font,
 		const Common::String &text, int x, int y, byte textColor, byte backgroundColor) {
 	if (text.empty())
@@ -228,6 +238,47 @@ static void drawRoomDebugOverlay(HarvesterEngine &engine, Graphics::Screen &scre
 	}
 }
 
+static void drawCombatDebugOverlay(HarvesterEngine &engine, Graphics::Screen &screen,
+		const StartupRoomSceneResources &scene) {
+	if (!engine.isCombatDebugEnabled())
+		return;
+
+	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
+	Script *startupScript = engine.getStartupScript();
+	if (!font || !runtimeEntities)
+		return;
+
+	byte displayPalette[256 * 3];
+	screen.getPalette(displayPalette);
+	const byte black = findNearestPaletteColor(displayPalette, 0x00, 0x00, 0x00);
+	const byte white = findNearestPaletteColor(displayPalette, 0xff, 0xff, 0xff);
+	const byte red = findNearestPaletteColor(displayPalette, 0xff, 0x00, 0x00);
+
+	const RuntimeEntity *playerEntity = runtimeEntities->findSceneEntityByName(kPlayerActorEntityName);
+	if (startupScript && playerEntity &&
+			playerEntity->getClassId() == kRuntimeEntityClassPlayer &&
+			playerEntity->isVisible()) {
+		const Common::Rect playerRect = playerEntity->getScreenRect();
+		drawRoomDebugLabel(screen, *font,
+			resolveCombatDebugLabel(playerEntity->getName(),
+				startupScript->getPlayerCurrentHitPoints(), Script::kDefaultPlayerHitPoints),
+			playerRect.left, playerRect.top, white, red);
+	}
+
+	for (const StartupMonsterRecord &monster : scene.state.roomMonsters) {
+		const RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(monster.monsterName);
+		if (!entity || entity->getClassId() != kRuntimeEntityClassMonster || !entity->isVisible())
+			continue;
+
+		const Common::Rect monsterRect = entity->getScreenRect();
+		drawRoomDebugLabel(screen, *font,
+			resolveCombatDebugLabel(monster.monsterName,
+				monster.currentHitPoints, monster.initialHitPoints),
+			monsterRect.left, monsterRect.top, white, black);
+	}
+}
+
 static int clampTownMapPanelIndex(int panelIndex) {
 	return CLIP<int>(panelIndex, 0, ARRAYSIZE(kTownMapBitmapPaths) - 1);
 }
@@ -633,6 +684,7 @@ void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const Star
 	if (engine.getRuntimeEntities())
 		engine.getRuntimeEntities()->drawSceneEntities(screen);
 	drawRoomDebugOverlay(engine, screen, scene);
+	drawCombatDebugOverlay(engine, screen, scene);
 }
 
 const StartupObjectRecord *findSceneObjectByName(const Common::Array<StartupObjectRecord> &objects,
@@ -1778,10 +1830,11 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 		}
 		runtimeEntities->reinsertSceneEntity(entity);
 		debugC(1, kDebugRoom,
-			"Harvester: scene monster spawned room='%s' monster='%s' class=0x%x pos=(%d,%d,z=%.2f) facing=%d hp=%d/%d model='%s' active=%d visible=%d",
+			"Harvester: scene monster spawned room='%s' monster='%s' class=0x%x pos=(%d,%d,z=%.2f) facing=%d hp=%d/%d damage=%d engage=%d damage_type='%s' model='%s' active=%d visible=%d",
 			state.roomName.c_str(), monster.monsterName.c_str(), entity->getClassId(),
 			entity->getX(), entity->getY(), (double)entity->getZ(), monster.facing,
 			monster.currentHitPoints, monster.initialHitPoints,
+			monster.damageAmount, monster.engageDistance, Player::describeCombatDamageType(monster.damageType),
 			monster.modelPath.c_str(), monster.active, monster.visible);
 	}
 	if (state.hasEntrance) {
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 669547664ca..4f384be598d 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -221,6 +221,11 @@ void HarvesterEngine::stopStartupSound() {
 		_media->stopSound();
 }
 
+bool HarvesterEngine::toggleCombatDebugEnabled() {
+	_combatDebugEnabled = !_combatDebugEnabled;
+	return _combatDebugEnabled;
+}
+
 bool HarvesterEngine::toggleRoomDebugEnabled() {
 	_roomDebugEnabled = !_roomDebugEnabled;
 	return _roomDebugEnabled;
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 5b181b412e6..e14115dd1b7 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -111,6 +111,8 @@ public:
 	bool playStartupLoadedSound(int slot);
 	bool deleteStartupLoadedSound(int slot);
 	void stopStartupSound();
+	bool isCombatDebugEnabled() const { return _combatDebugEnabled; }
+	bool toggleCombatDebugEnabled();
 	bool isRoomDebugEnabled() const { return _roomDebugEnabled; }
 	bool toggleRoomDebugEnabled();
 	void captureCurrentStartupSaveRoomState(const Common::String &entranceName,
@@ -144,6 +146,7 @@ private:
 	Common::Array<byte> _pendingLoadedDialogueStateBlob;
 	uint32 _pendingLoadedDialogueStateBlobVersion = 0;
 	Flow *_activeFlow = nullptr;
+	bool _combatDebugEnabled = false;
 	bool _roomDebugEnabled = false;
 };
 
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 1d130247f30..8330dc51e31 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -69,6 +69,9 @@ static const int kNativeKeyboardAttackSideWindow = 30;
 static const char *const kNativeHitEffectResourcePath = "blood.abm";
 static const int kNativeHitEffectAnimationRate = 10;
 static const float kNativeHitEffectRenderZBias = 0.01f;
+static const uint32 kCombatDebugDamagePopupDurationMs = 750;
+static const int kCombatDebugDamagePopupRisePixels = 18;
+static const int kCombatDebugDamagePopupVerticalGap = 6;
 static const char *const kCdChangePromptPalettePath = "1:/GRAPHIC/PAL/CD1.PAL";
 
 static int roundRoomCombatFloat(float value);
@@ -127,6 +130,13 @@ struct RoomHitEffectState {
 	bool finished = false;
 };
 
+struct RoomCombatDamagePopupState {
+	Common::String followTargetName;
+	Common::Point anchorPoint;
+	uint32 startTick = 0;
+	int damageAmount = 0;
+};
+
 static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
 	return !roomName.equalsIgnoreCase("LAVAPIT") &&
 		!roomName.equalsIgnoreCase("RMNBATH") &&
@@ -331,6 +341,37 @@ static void drawInventoryWeekday(Graphics::Screen &screen, const Graphics::Font
 		font.getStringWidth(weekdayText), kNativeInventoryTooltipColor);
 }
 
+static byte findNearestPaletteColor(const byte *palette, byte red, byte green, byte blue) {
+	if (!palette)
+		return 0;
+
+	byte bestIndex = 0;
+	uint32 bestDistance = 0xffffffffu;
+	for (int i = 0; i < 256; ++i) {
+		const int paletteOffset = i * 3;
+		const int dr = (int)palette[paletteOffset] - red;
+		const int dg = (int)palette[paletteOffset + 1] - green;
+		const int db = (int)palette[paletteOffset + 2] - blue;
+		const uint32 distance = (uint32)(dr * dr + dg * dg + db * db);
+		if (distance < bestDistance) {
+			bestDistance = distance;
+			bestIndex = (byte)i;
+		}
+	}
+
+	return bestIndex;
+}
+
+static void drawShadowedRoomText(Graphics::Screen &screen, const Graphics::Font &font,
+		const Common::String &text, int x, int y, byte textColor, byte shadowColor) {
+	if (text.empty())
+		return;
+
+	const int width = font.getStringWidth(text);
+	font.drawString(&screen, text, x + 1, y + 1, width, shadowColor);
+	font.drawString(&screen, text, x, y, width, textColor);
+}
+
 static void setScaledRoomPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
 	byte scaledPalette[256 * 3];
 	const float gammaBrightness = g_engine ? g_engine->getStartupGammaBrightnessScale() : 1.0f;
@@ -707,6 +748,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		Common::Array<RoomMonsterCombatState> monsterCombatStates;
 		monsterCombatStates.resize(scene.state.roomMonsters.size());
 		Common::Array<RoomHitEffectState> hitEffectStates;
+		Common::Array<RoomCombatDamagePopupState> damagePopupStates;
 		uint nextCombatEffectId = 0;
 		StartupResolvedText inspectText;
 		bool showingInspectText = false;
@@ -1019,6 +1061,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			anchorPoint.y = targetRect.top + targetRect.height() / 3;
 			renderZ = targetEntity.getZ() - kNativeHitEffectRenderZBias;
 		};
+		auto resolveCombatDamagePopupAnchor = [&](const RuntimeEntity &targetEntity,
+				Common::Point &anchorPoint) {
+			const Common::Rect targetRect = targetEntity.getScreenRect();
+			anchorPoint.x = targetRect.left + targetRect.width() / 2;
+			anchorPoint.y = targetRect.top - kCombatDebugDamagePopupVerticalGap;
+		};
 		auto spawnCombatHitEffect = [&](RuntimeEntity &sourceEntity,
 				const Common::String &followTargetName) {
 			if (!runtimeEntities)
@@ -1039,6 +1087,18 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			hitEffectStates.push_back(effectState);
 			return true;
 		};
+		auto spawnCombatDamagePopup = [&](RuntimeEntity &targetEntity,
+				const Common::String &followTargetName, int damageAmount) {
+			if (damageAmount <= 0)
+				return;
+
+			RoomCombatDamagePopupState popupState;
+			popupState.followTargetName = followTargetName;
+			popupState.startTick = Player::getRuntimeClockTicks();
+			popupState.damageAmount = damageAmount;
+			resolveCombatDamagePopupAnchor(targetEntity, popupState.anchorPoint);
+			damagePopupStates.push_back(popupState);
+		};
 		auto syncCombatHitEffects = [&]() {
 			if (!runtimeEntities)
 				return false;
@@ -1069,6 +1129,42 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 			return changed;
 		};
+		auto drawCombatDamagePopups = [&](Graphics::Screen &screen) {
+			if (!_engine.isCombatDebugEnabled() || damagePopupStates.empty())
+				return;
+
+			const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+			if (!font)
+				return;
+
+			byte displayPalette[256 * 3];
+			screen.getPalette(displayPalette);
+			const byte white = findNearestPaletteColor(displayPalette, 0xff, 0xff, 0xff);
+			const byte black = findNearestPaletteColor(displayPalette, 0x00, 0x00, 0x00);
+			const uint32 now = Player::getRuntimeClockTicks();
+			for (RoomCombatDamagePopupState &popupState : damagePopupStates) {
+				if (runtimeEntities && !popupState.followTargetName.empty()) {
+					RuntimeEntity *targetEntity =
+						runtimeEntities->findSceneEntityByName(popupState.followTargetName);
+					if (targetEntity && targetEntity->isVisible())
+						resolveCombatDamagePopupAnchor(*targetEntity, popupState.anchorPoint);
+				}
+
+				const uint32 elapsed = now - popupState.startTick;
+				const uint32 clampedElapsed = MIN<uint32>(elapsed, kCombatDebugDamagePopupDurationMs);
+				const int rise = (int)(
+					clampedElapsed * (uint32)kCombatDebugDamagePopupRisePixels /
+					kCombatDebugDamagePopupDurationMs);
+				const Common::String popupText = Common::String::format("+%d", popupState.damageAmount);
+				const int textWidth = font->getStringWidth(popupText);
+				const int drawX = CLIP<int>(popupState.anchorPoint.x - textWidth / 2,
+					0, MAX(0, screen.w - textWidth));
+				const int drawY = CLIP<int>(
+					popupState.anchorPoint.y - font->getFontHeight() - rise,
+					0, MAX(0, screen.h - font->getFontHeight()));
+				drawShadowedRoomText(screen, *font, popupText, drawX, drawY, white, black);
+			}
+		};
 		auto pruneCombatHitEffects = [&]() {
 			bool removedAny = false;
 			for (uint i = 0; i < hitEffectStates.size();) {
@@ -1097,6 +1193,21 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 
 			return removedAny;
 		};
+		auto pruneCombatDamagePopups = [&]() {
+			const uint32 now = Player::getRuntimeClockTicks();
+			bool removedAny = false;
+			for (uint i = 0; i < damagePopupStates.size();) {
+				if (now - damagePopupStates[i].startTick < kCombatDebugDamagePopupDurationMs) {
+					++i;
+					continue;
+				}
+
+				damagePopupStates.remove_at(i);
+				removedAny = true;
+			}
+
+			return removedAny;
+		};
 		auto applyCurrentRoomRuntimeMutationsInPlace = [&]() {
 			Script *startupScript = _engine.getStartupScript();
 			ResourceManager *resources = _engine.getResources();
@@ -2236,15 +2347,18 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		}
 
 		playerState.attackContactResolved = true;
+		const int damageType = Player::resolveCombatLoadoutDamageType(playerState.combatLoadout);
+		const int damageAmount = Player::resolveCombatLoadoutDamageAmount(playerState.combatLoadout);
 		debugC(1, kDebugCombat,
-			"Harvester: combat player attack contact frame=%d loadout=%d target_class='%s' target='%s'",
+			"Harvester: combat player attack contact frame=%d loadout=%d weapon='%s' damage=%d damage_type='%s' target_class='%s' target='%s'",
 			playerState.entity->getCurrentFrame(), playerState.combatLoadout,
+			Player::describeCombatLoadout(playerState.combatLoadout), damageAmount,
+			Player::describeCombatDamageType(damageType),
 			describeCombatTargetClass(playerState.attackTargetClassId), playerState.attackTargetName.c_str());
 		Script *startupScript = _engine.getStartupScript();
 		if (!startupScript)
 			return Common::kNoError;
 
-		const int damageType = Player::resolveCombatLoadoutDamageType(playerState.combatLoadout);
 		if (playerState.attackTargetClassId < 0 && !Player::isProjectileCombatLoadout(playerState.combatLoadout)) {
 			if (StartupNpcRecord *fallbackNpc = findOverlappingNpcTarget()) {
 				playerState.attackTargetName = fallbackNpc->npcName;
@@ -2311,13 +2425,17 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			return Common::kNoError;
 		}
 
-		const int damageAmount = Player::resolveCombatLoadoutDamageAmount(playerState.combatLoadout);
 		const int hitPointsBefore = monster->currentHitPoints;
 		monster->currentHitPoints = MAX(0,
 			monster->currentHitPoints - damageAmount);
+		const int damageLanded = hitPointsBefore - monster->currentHitPoints;
 		debugC(1, kDebugCombat,
-			"Harvester: combat player monster hit target='%s' damage=%d hp=%d->%d reachable=1",
-			monster->monsterName.c_str(), damageAmount, hitPointsBefore, monster->currentHitPoints);
+			"Harvester: combat player monster hit target='%s' loadout=%d weapon='%s' damage=%d damage_type='%s' hp=%d->%d reachable=1",
+			monster->monsterName.c_str(), playerState.combatLoadout,
+			Player::describeCombatLoadout(playerState.combatLoadout), damageAmount,
+			Player::describeCombatDamageType(damageType), hitPointsBefore, monster->currentHitPoints);
+		if (damageLanded > 0)
+			spawnCombatDamagePopup(*monsterEntity, monster->monsterName, damageLanded);
 		if (monster->currentHitPoints > 0) {
 			(void)startupScript->syncRuntimeMonsterRecord(*monster);
 			needsRedraw = true;
@@ -2464,10 +2582,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 						const int playerHitPointsBefore = startupScript->getPlayerCurrentHitPoints();
 						const bool changed = startupScript->adjustPlayerCurrentHitPoints(-monster.damageAmount);
 						const int playerHitPointsAfter = startupScript->getPlayerCurrentHitPoints();
+						const int damageLanded = playerHitPointsBefore - playerHitPointsAfter;
 						debugC(1, kDebugCombat,
-							"Harvester: combat monster attack hit monster='%s' frame=%d damage=%d player_hp=%d->%d changed=%d",
+							"Harvester: combat monster attack hit monster='%s' frame=%d damage=%d damage_type='%s' player_hp=%d->%d changed=%d",
 							monster.monsterName.c_str(), currentFrame, monster.damageAmount,
+							Player::describeCombatDamageType(monster.damageType),
 							playerHitPointsBefore, playerHitPointsAfter, changed);
+						if (damageLanded > 0 && playerState.entity)
+							spawnCombatDamagePopup(*playerState.entity, playerState.entity->getName(), damageLanded);
 						if (changed && playerHitPointsAfter <= 0) {
 							playerAlive = false;
 							stopPlayerRegionInteraction();
@@ -2903,6 +3025,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			}
 
 			drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
+			drawCombatDamagePopups(*activeScreen);
 			if (_inventory.isOpen()) {
 				_inventory.drawOverlay(*activeScreen);
 				drawInventoryWeekday(*activeScreen, *inventoryTooltipFont, _inventory.resolveWeekdayLabel());
@@ -3613,6 +3736,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			return Common::kNoError;
 		if (pruneCombatHitEffects())
 			needsRedraw = true;
+		if (pruneCombatDamagePopups())
+			needsRedraw = true;
 		if (runtimeEntities) {
 			Common::Array<Common::String> expiredTimerNames;
 			if (runtimeEntities->takeExpiredTimerNames(expiredTimerNames)) {
@@ -3638,6 +3763,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		}
 		if (Player::updateIdleAnimation(scene.state, playerState, idleState))
 			needsRedraw = true;
+		if (_engine.isCombatDebugEnabled() && !damagePopupStates.empty())
+			needsRedraw = true;
 
 		limiter.delayBeforeSwap();
 		limiter.startFrame();
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index a4f25885dfc..f90c8971582 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -31,6 +31,7 @@
 #include "harvester/detection.h"
 #include "harvester/harvester.h"
 #include "harvester/parse_utils.h"
+#include "harvester/player.h"
 #include "harvester/resources.h"
 
 namespace Harvester {
@@ -43,7 +44,6 @@ static const char *const kDefaultVoicePath = "SOUND/VOICE/";
 static const byte kTownScriptXorKey = 0xaa;
 static const float kDefaultPaletteBrightness = 1.0f;
 static const float kDimmedPaletteBrightness = 0.6f;
-static const int kDefaultPlayerHitPoints = 30;
 static const int kDefaultPlayerCombatLoadout = 0;
 static const int kMaxPlayerCombatLoadout = 0x14;
 static const int kMaxStartupOptionLevel = 9;
@@ -217,8 +217,8 @@ static int clampStartupOptionLevel(int level) {
 static int clampPlayerHitPoints(int hitPoints) {
 	if (hitPoints < 0)
 		return 0;
-	if (hitPoints > kDefaultPlayerHitPoints)
-		return kDefaultPlayerHitPoints;
+	if (hitPoints > Script::kDefaultPlayerHitPoints)
+		return Script::kDefaultPlayerHitPoints;
 
 	return hitPoints;
 }
@@ -431,7 +431,7 @@ bool Script::load(ResourceManager &resources) {
 	_runtimeMonsters.clear();
 	_runtimeTimers.clear();
 	_runtimeRegions.clear();
-	_playerCurrentHitPoints = kDefaultPlayerHitPoints;
+	_playerCurrentHitPoints = Script::kDefaultPlayerHitPoints;
 	_playerCombatLoadout = kDefaultPlayerCombatLoadout;
 	_playerControlPaused = false;
 	_fxVolumeLevel = 9;
@@ -1103,7 +1103,7 @@ void Script::resetRuntimeState() {
 	_runtimeNpcs = _npcs;
 	_runtimeMonsters = _monsters;
 	_runtimeTimers = _timers;
-	_playerCurrentHitPoints = kDefaultPlayerHitPoints;
+	_playerCurrentHitPoints = Script::kDefaultPlayerHitPoints;
 	_playerCombatLoadout = kDefaultPlayerCombatLoadout;
 	_playerControlPaused = false;
 
@@ -2111,10 +2111,11 @@ bool Script::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupE
 	}
 	for (const StartupMonsterRecord &monster : state.roomMonsters) {
 		debugC(1, kDebugRoom,
-			"Harvester: materialized room monster room='%s' monster='%s' visible=%d active=%d pos=(%d,%d,%d) facing=%d hp=%d/%d model='%s' on_death='%s'",
+			"Harvester: materialized room monster room='%s' monster='%s' visible=%d active=%d pos=(%d,%d,%d) facing=%d hp=%d/%d damage=%d engage=%d damage_type='%s' model='%s' on_death='%s'",
 			state.roomName.c_str(), monster.monsterName.c_str(), monster.visible, monster.active,
 			monster.posX, monster.posY, monster.posZ, monster.facing,
 			monster.currentHitPoints, monster.initialHitPoints,
+			monster.damageAmount, monster.engageDistance, Player::describeCombatDamageType(monster.damageType),
 			monster.modelPath.c_str(), monster.onDeathActionTag.c_str());
 	}
 	for (const StartupTimerRecord &timer : state.roomTimers) {
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 54616ead658..f398a392c1e 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -305,6 +305,8 @@ struct StartupInteractionResult {
 
 class Script {
 public:
+	static const int kDefaultPlayerHitPoints = 30;
+
 	bool load(ResourceManager &resources);
 
 	const Common::String &getPath() const { return _path; }


Commit: 65338671d9b7fb16f1e04340b67dab578f472b2a
    https://github.com/scummvm/scummvm/commit/65338671d9b7fb16f1e04340b67dab578f472b2a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:21+02:00

Commit Message:
HARVESTER: Fix native combat loadout damage tuning

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/inventory.cpp
    engines/harvester/player.cpp
    engines/harvester/player.h
    engines/harvester/room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 9d016bf7b76..f74776a0038 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1026,6 +1026,7 @@
   - It is called by the inventory UI when the player changes weapons and by save/load restoration when the saved selection differs from the current live one.
   - It tears down the current attack sound bank, loads the new weapon-dependent attack sounds, reloads the `pc*.abm` actor art, and refreshes the player combat avatar's attack tuning.
   - The tuning it refreshes matches the spawn path exactly: `+0x1134` attack sample count, `+0x1148/+0x114c/+0x1150` attack sample slots, `+0x116c` attack-sound trigger frame offset, `+0x113c` attack contact frame offset, and `+0x1180` per-hit damage amount.
+  - The confirmed native per-hit damage table is `0:1`, `1:4`, `2:2`, `3:7`, `4:6`, `5:6`, `6:3`, `7:3`, `8:3`, `9:3`, `10:3`, `11:3`, `12:4`, `13:5`, `14:8`, `15:3`, `16:3`, `17:3`, `18:3`, `19:3`, `20:2`; in particular, `CHAINSAW` is the high `8`-damage loadout while `HARVEST_BLADE` stays at `3`.
   - The ABM path table it indexes is now labeled `g_player_combat_loadout_abm_paths` at `0xc3eb4`.
   - That table is now bounded as a direct 21-entry loadout map from ids `0..0x14` to `1:\\graphic\\monsters\\pc\\pc0.abm` through `1:\\graphic\\monsters\\pc\\pc20.abm`.
 - `sync_player_combat_weapon_resource_icons` at `0x792c0` updates the HUD resource-icon strip/count pair for the current player combat loadout.
@@ -1059,6 +1060,7 @@
     - it plays the selected attack sample when `current_frame == anim_base + +0x116c`
     - it waits until `current_frame >= anim_base + +0x113c` before resolving contact
     - on a confirmed hit it subtracts attacker `+0x1180` from victim HP
+    - no additional post-hit multiplier is present in the recovered native path; both player and monster hits use the per-loadout or per-record value that was already seeded into `+0x1180`
     - for keyboard-driven player attacks, the same contact path picks the nearest live target on the chosen side; upper/lower side attacks also require the target center to stay within a `30`-pixel horizontal side window and to overlap the attacker's live Z/depth span
   - Runtime dword `+0x118c` is now bounded as a mixed combat/capability mask rather than as a pure damage-type field.
     - its low bits carry the attack damage type (`1 = BLUDGE`, `2 = SLASH`, `4 = PROJ`)
diff --git a/TRACKER.md b/TRACKER.md
index 7154f36c416..76c0c9844d9 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Harvester combat validation pass with one low-band melee loadout and one high-band weapon while `combat` logging is enabled. Recheck `FIREAXE` in `CRYPT` to confirm the new logs show the native `loadout=17 weapon='FIREAXE' damage=3`, then switch to a higher-damage weapon such as `SHOTGUN` (`loadout=3 damage=7`) or `CHAINSAW` (`loadout=14 damage=8`) and confirm the contact log changes accordingly. During the same pass, verify `WOLFIE` continues to report its seeded per-hit monster damage in the spawn and attack logs so we can tell whether the remaining difficulty complaint is native data/cadence rather than duplicate hit application.
 - Immediate: rerun the in-engine `MBLM` glue validation pass after the stale-reference crash fix. With Moynahan alive (`MOYNAHAN_IS_DEAD=0`), click the room `GLUE` hotspot and confirm `MBLMGLUE_COM -> START_DIALOG MOYNAHAN_MBLM` still lands in the restored Moynahan handler, the native refusal lines `0x3e4d/0x3e52/0x3e56 -> 0x3e76/0x3e7a` play, the denied pickup still rolls back out of carry/inventory state, and dismissing the dialogue no longer trips the main-thread assert in `Common::String::compareToIgnoreCase` from the post-dialogue rollback check. Then validate both confirmed native acquisition paths still work: present `CASKET_PHOTO` / `CASKET_PHOTOCOPY` to Moynahan and take the glue-granting response at `dialog.rsp[0x18d]`, and on day 5 follow the `0x1bf..0x1c2` glue topic through response menu `0x1c3` to confirm `GLUE` plus `JUST_GOT_GLUE`. Finally, recheck the direct room-click pickup with `MOYNAHAN_IS_DEAD=1` to confirm the script-side `ADD_GLUE` branch still bypasses dialogue and awards the object cleanly.
 - Immediate: run an in-engine Harvester validation pass around the day-5 overnight CD handoff and the hidden warp-to-CD3 path. With launcher option `Show CD change prompts` enabled, trigger `CHANGE_TO_CD2_A -> CHANGE_TO_CD2_B -> CHANGE_TO_CD2_C` and confirm the native `CD2.BM` prompt appears after `OVERNITE.FST`, waits for a fresh left-click press/release cycle, and then continues into `PC_ROOM_HOUSE_START`; also trigger `WARP_TO_LODGE_A -> WARP_TO_LODGE_B` and confirm the `CD3.BM` prompt appears after `C076.FST`. Repeat the same paths with the option disabled and confirm the prompt is skipped while the room handoff still completes.
 - Immediate: run an in-engine Harvester validation pass that covers both the startup title menu and the post-death `GAMEOVER.BM` overlay. Confirm the menu now preserves the native six-row layout with `main_menu_2` (`SAVE GAME`) blanked when no live room/session state exists, `main_menu_3` (`LOAD GAME`) still visible and functional from both entry points, and the `OPTIONS` / `HELP` / `QUIT GAME` rows retaining their original vertical spacing instead of collapsing upward.
@@ -20,6 +21,7 @@
 
 ## Last Confirmed Action
 
+- On March 26, 2026, rechecked Harvester's native combat damage seeding in live `HARVEST.LE` before changing the ScummVM formulas. Ghidra confirmed that `spawn_monster_entity_from_record` copies the monster record's `damage_amount` straight into live actor field `+0x1180`, and that `update_actor_runtime_state` subtracts that field directly on a confirmed hit with no extra post-hit multiplier. The same pass over `set_player_combat_loadout` also exposed a real player-weapon tuning bug in ScummVM: the native table uses `CHAINSAW -> loadout 14 -> damage 8` and `HARVEST_BLADE -> loadout 15 -> damage 3`, but the engine had those two loadouts swapped. Patched [`engines/harvester/player.h`](/Users/alex/Workspace/scummvm/engines/harvester/player.h), [`engines/harvester/player.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/player.cpp), [`engines/harvester/inventory.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/inventory.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) so the loadout table now matches native, and combat logs now print the resolved weapon/loadout label plus the native per-hit damage values during inventory toggles and player/monster hit resolution. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, traced the `MBLM` glue assert to a stale `StartupObjectRecord` reference in the room pickup loop, not to `Common::String` itself. The post-dialogue carry rollback added in [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) was still reading `object.objectName` and `object.actionTag` after `handleInteractionResult()`, but that interaction can rebuild the room scene and invalidate the clicked-object reference. Patched both room pickup branches to use the pre-saved object copy for all post-interaction logging and rollback checks, including the `isObjectInInventory()` probe that had been asserting in `Common::String::compareToIgnoreCase`. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, traced the remaining `MBLM` glue regression to the room-item carry path rather than the Moynahan dialogue handler. The alias fix was already working: live logs showed `MBLMGLUE_COM` reaching `MOYNAHAN_MBLM` and playing the native refusal lines `0x3e4d` / `0x3e52` / `0x3e56` / `0x3e76` / `0x3e7a`, but [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) was still leaving the denied `GLUE` as a carried room item because the carry-start rollback only handled modal-text denials. Patched the carried-pickup branch so, after a pickup action opens dialogue, it restores the original room object and clears the transient carry state unless that dialogue path explicitly moved the object into inventory; rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, traced the `MBLM` glue regression to an alias mismatch between the room script and the restored Moynahan dialogue handler. The native room action `MBLMGLUE_COM` already dispatches `START_DIALOG MOYNAHAN_MBLM` whenever `MOYNAHAN_IS_DEAD` is clear, but [`engines/harvester/npc/moynahan_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/moynahan_dialogue.cpp) was still matching only `MOYNAHAN`, so live room clicks fell through to `unsupported room NPC dialogue handler 'MOYNAHAN_MBLM'` instead of entering the confirmed `IF_TRY_TO_TAKE_THE_GLUE` interruption path. Patched the handler to accept both speaker ids and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
diff --git a/engines/harvester/inventory.cpp b/engines/harvester/inventory.cpp
index 91fa3be7985..23cf52698e3 100644
--- a/engines/harvester/inventory.cpp
+++ b/engines/harvester/inventory.cpp
@@ -28,6 +28,7 @@
 #include "graphics/screen.h"
 #include "harvester/detection.h"
 #include "harvester/harvester.h"
+#include "harvester/player.h"
 #include "harvester/resources.h"
 #include "harvester/art.h"
 
@@ -459,11 +460,18 @@ bool InventorySystem::toggleCombatLoadout(const StartupObjectRecord &object, int
 	const int activeLoadout = currentLoadout >= 0 ? currentLoadout : savedLoadout;
 	const int nextLoadout = activeLoadout == loadoutId ? 0 : loadoutId;
 	const bool scriptChanged = startupScript->setPlayerCombatLoadout(nextLoadout);
+	const int resultingLoadout = startupScript->getPlayerCombatLoadout();
 	changed = activeLoadout != nextLoadout;
 	debugC(1, kDebugInventory,
-		"Harvester: inventory combat toggle object='%s' active_loadout=%d saved_loadout=%d requested_loadout=%d changed=%d script_changed=%d resulting_loadout=%d",
-		object.objectName.c_str(), activeLoadout, savedLoadout, nextLoadout, changed,
-		scriptChanged, startupScript->getPlayerCombatLoadout());
+		"Harvester: inventory combat toggle object='%s' active_loadout=%d('%s') saved_loadout=%d('%s') requested_loadout=%d('%s') changed=%d script_changed=%d resulting_loadout=%d('%s') damage=%d damage_type='%s'",
+		object.objectName.c_str(),
+		activeLoadout, Player::describeCombatLoadout(activeLoadout),
+		savedLoadout, Player::describeCombatLoadout(savedLoadout),
+		nextLoadout, Player::describeCombatLoadout(nextLoadout),
+		changed, scriptChanged,
+		resultingLoadout, Player::describeCombatLoadout(resultingLoadout),
+		Player::resolveCombatLoadoutDamageAmount(resultingLoadout),
+		Player::describeCombatDamageType(Player::resolveCombatLoadoutDamageType(resultingLoadout)));
 	return true;
 }
 
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 6db8b80c60a..4e8fad476f9 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -101,6 +101,7 @@ struct PlayerAttackSoundSet {
 };
 
 struct PlayerCombatLoadoutTuning {
+	const char *label;
 	int damageType;
 	int damageAmount;
 	int contactFrameOffset;
@@ -134,27 +135,27 @@ static const PlayerAttackSoundSet kPlayerAttackSoundSets[] = {
 };
 
 static const PlayerCombatLoadoutTuning kPlayerCombatLoadoutTunings[] = {
-	{ 1, 1, 5 },
-	{ 2, 4, 2 },
-	{ 4, 2, 2 },
-	{ 4, 7, 2 },
-	{ 4, 6, 2 },
-	{ 4, 6, 2 },
-	{ 2, 3, 2 },
-	{ 2, 3, 2 },
-	{ 2, 3, 2 },
-	{ 2, 3, 2 },
-	{ 1, 3, 2 },
-	{ 2, 3, 2 },
-	{ 2, 4, 2 },
-	{ 2, 5, 2 },
-	{ 2, 4, 2 },
-	{ 2, 8, 2 },
-	{ 1, 3, 2 },
-	{ 2, 3, 2 },
-	{ 1, 3, 2 },
-	{ 2, 3, 2 },
-	{ 1, 2, 2 }
+	{ "FISTS", 1, 1, 5 },
+	{ "CLEAVER", 2, 4, 2 },
+	{ "NAILGUN", 4, 2, 2 },
+	{ "SHOTGUN", 4, 7, 2 },
+	{ "9GUN", 4, 6, 2 },
+	{ "38GUN", 4, 6, 2 },
+	{ "TOMAHAWK", 2, 3, 2 },
+	{ "KNIFE", 2, 3, 2 },
+	{ "FLAIL", 2, 3, 2 },
+	{ "HANDAXE", 2, 3, 2 },
+	{ "WRENCH", 1, 3, 2 },
+	{ "PITCHFORK", 2, 3, 2 },
+	{ "SCYTHE", 2, 4, 2 },
+	{ "SWORD", 2, 5, 2 },
+	{ "CHAINSAW", 2, 8, 2 },
+	{ "HARVEST_BLADE", 2, 3, 2 },
+	{ "SHOVEL", 1, 3, 2 },
+	{ "FIREAXE", 2, 3, 2 },
+	{ "BAT", 1, 3, 2 },
+	{ "RAZOR", 2, 3, 2 },
+	{ "POOLSTICK", 1, 2, 2 }
 };
 
 static int roundToInt(float value) {
@@ -225,7 +226,7 @@ static const PlayerAttackSoundSet *resolvePlayerAttackSoundSet(int loadout) {
 }
 
 static const PlayerCombatLoadoutTuning &resolvePlayerCombatLoadoutTuning(int loadout) {
-	static const PlayerCombatLoadoutTuning kDefaultTuning = { 1, 1, 2 };
+	static const PlayerCombatLoadoutTuning kDefaultTuning = { "FISTS", 1, 1, 5 };
 
 	if (loadout < 0 || loadout >= (int)ARRAYSIZE(kPlayerCombatLoadoutTunings))
 		return kDefaultTuning;
@@ -759,6 +760,10 @@ Common::String Player::resolveCombatLoadoutResourcePath(int loadout) {
 	return Common::String::format("1:/GRAPHIC/MONSTERS/PC/PC%02d.ABM", loadout);
 }
 
+const char *Player::describeCombatLoadout(int loadout) {
+	return resolvePlayerCombatLoadoutTuning(loadout).label;
+}
+
 int Player::resolveCombatLoadoutDamageAmount(int loadout) {
 	return resolvePlayerCombatLoadoutTuning(loadout).damageAmount;
 }
@@ -767,6 +772,19 @@ int Player::resolveCombatLoadoutDamageType(int loadout) {
 	return resolvePlayerCombatLoadoutTuning(loadout).damageType;
 }
 
+const char *Player::describeCombatDamageType(int damageType) {
+	switch (damageType) {
+	case 1:
+		return "BLUDGE";
+	case 2:
+		return "SLASH";
+	case 4:
+		return "PROJ";
+	default:
+		return "UNKNOWN";
+	}
+}
+
 int Player::resolveCombatLoadoutContactFrameOffset(int loadout) {
 	return resolvePlayerCombatLoadoutTuning(loadout).contactFrameOffset;
 }
@@ -876,8 +894,11 @@ bool Player::syncCombatLoadoutVisual(HarvesterEngine &engine, const StartupRoomS
 	(void)applyRoomActorPlacement(state, *playerState.entity,
 		playerState.centerX, playerState.bottomY, playerState.z);
 	debugC(1, kDebugPlayer,
-		"Harvester: player combat loadout visual loadout=%d resource='%s' facing=%d pos=(%d,%d,z=%.2f)",
-		playerState.combatLoadout, resourcePath.c_str(), playerState.facing,
+		"Harvester: player combat loadout visual loadout=%d weapon='%s' damage=%d damage_type='%s' contact=%d resource='%s' facing=%d pos=(%d,%d,z=%.2f)",
+		playerState.combatLoadout, describeCombatLoadout(playerState.combatLoadout),
+		resolveCombatLoadoutDamageAmount(playerState.combatLoadout),
+		describeCombatDamageType(resolveCombatLoadoutDamageType(playerState.combatLoadout)),
+		resolveCombatLoadoutContactFrameOffset(playerState.combatLoadout), resourcePath.c_str(), playerState.facing,
 		playerState.centerX, playerState.bottomY, (double)playerState.z);
 	return true;
 }
diff --git a/engines/harvester/player.h b/engines/harvester/player.h
index f9b0d45e889..bb622fb8c5c 100644
--- a/engines/harvester/player.h
+++ b/engines/harvester/player.h
@@ -30,6 +30,8 @@ class Player {
 public:
 	static float computeDepthScale(const StartupRoomSetupState &state, float z);
 	static int resolveFacingFrame(int facing);
+	static const char *describeCombatLoadout(int loadout);
+	static const char *describeCombatDamageType(int damageType);
 	static Common::String resolveCombatLoadoutResourcePath(int loadout);
 	static int resolveCombatLoadoutDamageAmount(int loadout);
 	static int resolveCombatLoadoutDamageType(int loadout);
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 8330dc51e31..01b4cbe0fcb 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -3196,8 +3196,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					capturePlayerAttackTarget();
 					if (Player::startAttackAnimation(scene.state, playerState, _mousePos)) {
 						debugC(1, kDebugCombat,
-							"Harvester: combat player attack start loadout=%d target_class='%s' target='%s' cursor=(%d,%d)",
+							"Harvester: combat player attack start loadout=%d weapon='%s' damage=%d damage_type='%s' target_class='%s' target='%s' cursor=(%d,%d)",
 							playerState.combatLoadout,
+							Player::describeCombatLoadout(playerState.combatLoadout),
+							Player::resolveCombatLoadoutDamageAmount(playerState.combatLoadout),
+							Player::describeCombatDamageType(Player::resolveCombatLoadoutDamageType(playerState.combatLoadout)),
 							describeCombatTargetClass(playerState.attackTargetClassId),
 							playerState.attackTargetName.c_str(), _mousePos.x, _mousePos.y);
 						needsRedraw = true;
@@ -3640,8 +3643,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					nextKeyboardAttackAllowedTick = now + kNativeKeyboardAttackRepeatTicks;
 					notePlayerActivity();
 					debugC(1, kDebugCombat,
-						"Harvester: combat player keyboard attack start loadout=%d target_class='%s' target='%s' input=(L=%d R=%d U=%d D=%d) facing=%d",
+						"Harvester: combat player keyboard attack start loadout=%d weapon='%s' damage=%d damage_type='%s' target_class='%s' target='%s' input=(L=%d R=%d U=%d D=%d) facing=%d",
 						playerState.combatLoadout,
+						Player::describeCombatLoadout(playerState.combatLoadout),
+						Player::resolveCombatLoadoutDamageAmount(playerState.combatLoadout),
+						Player::describeCombatDamageType(Player::resolveCombatLoadoutDamageType(playerState.combatLoadout)),
 						describeCombatTargetClass(playerState.attackTargetClassId),
 						playerState.attackTargetName.c_str(),
 						moveLeft, moveRight, moveUp, moveDown, playerState.facing);


Commit: bba1bc31aecabb4d13a79916898b676ab7b0cbb7
    https://github.com/scummvm/scummvm/commit/bba1bc31aecabb4d13a79916898b676ab7b0cbb7
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:22+02:00

Commit Message:
HARVESTER: Add combat debug kill hotkey

Changed paths:
    engines/harvester/room.cpp


diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 01b4cbe0fcb..4dbfa41c85a 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -2192,6 +2192,145 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		needsRedraw = true;
 		return Common::kNoError;
 	};
+	auto killDebugActiveMonster = [&]() -> Common::Error {
+		if (!_engine.isCombatDebugEnabled())
+			return Common::kNoError;
+
+		auto isKillableMonster = [&](const StartupMonsterRecord *monster) {
+			return monster && monster->active && monster->visible && monster->currentHitPoints > 0;
+		};
+		auto selectMonsterEntity = [&](StartupMonsterRecord *monster) -> RuntimeEntity * {
+			RuntimeEntity *entity = monster ? findSceneRuntimeEntity(monster->monsterName) : nullptr;
+			return (entity && entity->isVisible()) ? entity : nullptr;
+		};
+		auto resolveKillTarget = [&]() -> StartupMonsterRecord * {
+			if (playerState.attackTargetClassId == kRuntimeEntityClassMonster) {
+				StartupMonsterRecord *monster = findRoomMonsterRecordByName(playerState.attackTargetName);
+				if (isKillableMonster(monster) && selectMonsterEntity(monster))
+					return monster;
+			}
+
+			if (StartupMonsterRecord *monster = findMonsterTargetAtPoint(_mousePos)) {
+				if (isKillableMonster(monster) && selectMonsterEntity(monster))
+					return monster;
+			}
+
+			if (playerState.entity) {
+				for (uint i = 0; i < scene.state.roomMonsters.size() && i < monsterCombatStates.size(); ++i) {
+					StartupMonsterRecord &monster = scene.state.roomMonsters[i];
+					const RoomMonsterCombatState &combatState = monsterCombatStates[i];
+					if (!combatState.attackActive || !isKillableMonster(&monster) || !selectMonsterEntity(&monster))
+						continue;
+					if (!combatState.attackTargetName.empty() &&
+							playerState.entity->getName().equalsIgnoreCase(combatState.attackTargetName)) {
+						return &monster;
+					}
+				}
+			}
+
+			if (!playerState.entity)
+				return nullptr;
+
+			const Common::Rect playerRect = playerState.entity->getScreenRect();
+			const int playerCenterX = playerRect.left + playerRect.width() / 2;
+			StartupMonsterRecord *bestMonster = nullptr;
+			int bestScore = 0x7fffffff;
+			for (StartupMonsterRecord &monster : scene.state.roomMonsters) {
+				if (!isKillableMonster(&monster))
+					continue;
+
+				RuntimeEntity *entity = selectMonsterEntity(&monster);
+				if (!entity)
+					continue;
+
+				const Common::Rect monsterRect = entity->getScreenRect();
+				const int monsterCenterX = monsterRect.left + monsterRect.width() / 2;
+				const int score = ABS(monsterCenterX - playerCenterX) +
+					ABS(monster.posZ - roundRoomCombatFloat(playerState.z)) * 8;
+				if (!bestMonster || score < bestScore) {
+					bestMonster = &monster;
+					bestScore = score;
+				}
+			}
+
+			return bestMonster;
+		};
+
+		StartupMonsterRecord *monster = resolveKillTarget();
+		RuntimeEntity *monsterEntity = selectMonsterEntity(monster);
+		if (!monster || !monsterEntity) {
+			debugC(1, kDebugCombat,
+				"Harvester: debug combat kill skipped reason='no active monster' cursor=(%d,%d)",
+				_mousePos.x, _mousePos.y);
+			return Common::kNoError;
+		}
+
+		Script *startupScript = _engine.getStartupScript();
+		if (!startupScript) {
+			debugC(1, kDebugCombat,
+				"Harvester: debug combat kill skipped target='%s' reason='no startup script'",
+				monster->monsterName.c_str());
+			return Common::kNoError;
+		}
+
+		const int debugDamageType = Player::resolveCombatLoadoutDamageType(playerState.combatLoadout);
+		const int damageLanded = monster->currentHitPoints;
+		monster->currentHitPoints = 0;
+		if (damageLanded > 0)
+			spawnCombatDamagePopup(*monsterEntity, monster->monsterName, damageLanded);
+
+		if (playerState.attackTargetClassId == kRuntimeEntityClassMonster &&
+				playerState.attackTargetName.equalsIgnoreCase(monster->monsterName)) {
+			playerState.attackTargetName.clear();
+			playerState.attackTargetClassId = -1;
+		}
+
+		debugC(1, kDebugCombat,
+			"Harvester: debug combat kill target='%s' damage=%d damage_type=%d cursor=(%d,%d)",
+			monster->monsterName.c_str(), damageLanded, debugDamageType, _mousePos.x, _mousePos.y);
+
+		const int monsterIndex = findRoomMonsterIndexByName(monster->monsterName);
+		RoomMonsterCombatState *combatState =
+			monsterIndex >= 0 && monsterIndex < (int)monsterCombatStates.size()
+				? &monsterCombatStates[(uint)monsterIndex]
+				: nullptr;
+		if (combatState)
+			clearRoomMonsterCombatState(*combatState);
+		(void)startupScript->syncRuntimeMonsterRecord(*monster);
+
+		RoomDeathAnimationRange deathRange;
+		if (combatState && resolveMonsterDeathAnimationRange(*monsterEntity, monster->facing,
+				debugDamageType, _engine.isGoreEnabled(), deathRange)) {
+			combatState->deathActive = true;
+			combatState->deathFirstFrame = deathRange.firstFrame;
+			combatState->deathLastFrame = deathRange.lastFrame;
+			combatState->deathDamageType = debugDamageType;
+			combatState->nextMovementTick = 0;
+			combatState->nextAttackAllowedTick = 0;
+			monsterEntity->setAnimationFrameRange(deathRange.firstFrame, deathRange.lastFrame, false);
+			monsterEntity->setAnimationRate(kRoomMonsterAnimationRate);
+			monsterEntity->setAnimationEnabled(true);
+			monsterEntity->setCurrentFrame(deathRange.firstFrame);
+			if (!monster->deathSound.empty())
+				(void)_engine.playStartupSound(monster->deathSound);
+			needsRedraw = true;
+			return Common::kNoError;
+		}
+
+		monster->active = false;
+		monster->visible = false;
+		StartupInteractionResult interaction;
+		interaction.mutatedRuntimeState = true;
+		if (!monster->onDeathActionTag.empty()) {
+			StartupInteractionResult deathInteraction;
+			if (startupScript->executeActionTag(monster->onDeathActionTag, deathInteraction)) {
+				interaction = deathInteraction;
+				interaction.mutatedRuntimeState = true;
+			}
+		}
+
+		return handleCombatInteraction(interaction);
+	};
 	auto capturePlayerAttackTarget = [&]() {
 		playerState.attackTargetName.clear();
 		playerState.attackTargetClassId = -1;
@@ -3530,6 +3669,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				if (event.kbd.keycode == Common::KEYCODE_LCTRL ||
 						event.kbd.keycode == Common::KEYCODE_RCTRL) {
 					attackModifierHeld = true;
+				} else if (event.kbd.keycode == Common::KEYCODE_k && _engine.isCombatDebugEnabled()) {
+					Common::Error debugKillError = killDebugActiveMonster();
+					if (debugKillError.getCode() != Common::kNoError)
+						return debugKillError;
+					break;
 				} else if (event.kbd.keycode == Common::KEYCODE_LEFT)
 					moveLeft = true;
 				else if (event.kbd.keycode == Common::KEYCODE_RIGHT)


Commit: d8b72cc63e77a829c65f21858510362034513a5e
    https://github.com/scummvm/scummvm/commit/d8b72cc63e77a829c65f21858510362034513a5e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:22+02:00

Commit Message:
HARVESTER: Restore sergeant remains tail parity

Changed paths:
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/npc/dialogue_runtime.h
    engines/harvester/npc/sergeant_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 76c0c9844d9..6ec92afd595 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,8 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Harvester validation pass on the Sergeant remains/lodge handoff with `Show CD change prompts` enabled. Recreate the `GOT_REMAINS_FOR_LODGE` branch so `0x4333 -> C150.FST -> 0x433f..0x4385 -> C076.FST` fires, confirm line `0x434f` now uses the PC portrait, and verify the native black-screen + `CD3.BM` prompt appears after `C076.FST` before returning control. Then retry the lodge approach to determine whether the still-unopened lodge entry is a separate room/map-script issue rather than a remaining `handle_talk_to_sergeant` parity bug.
+- Immediate: run an in-engine Harvester validation pass focused on the `WOLFIE` crypt kill after the newly confirmed native parity check. Keep `combat` logging enabled, kill `WOLFIE`, and confirm the death bank still plays through to completion before the monster disappears, that `WOLFIE_DEATHCOM` only opens the crypt hotspots without leaving a clickable or visible monster residue, and that no stale monster entity survives in the live room after the `CRYPT` state refresh.
 - Immediate: run an in-engine Harvester combat validation pass with one low-band melee loadout and one high-band weapon while `combat` logging is enabled. Recheck `FIREAXE` in `CRYPT` to confirm the new logs show the native `loadout=17 weapon='FIREAXE' damage=3`, then switch to a higher-damage weapon such as `SHOTGUN` (`loadout=3 damage=7`) or `CHAINSAW` (`loadout=14 damage=8`) and confirm the contact log changes accordingly. During the same pass, verify `WOLFIE` continues to report its seeded per-hit monster damage in the spawn and attack logs so we can tell whether the remaining difficulty complaint is native data/cadence rather than duplicate hit application.
 - Immediate: rerun the in-engine `MBLM` glue validation pass after the stale-reference crash fix. With Moynahan alive (`MOYNAHAN_IS_DEAD=0`), click the room `GLUE` hotspot and confirm `MBLMGLUE_COM -> START_DIALOG MOYNAHAN_MBLM` still lands in the restored Moynahan handler, the native refusal lines `0x3e4d/0x3e52/0x3e56 -> 0x3e76/0x3e7a` play, the denied pickup still rolls back out of carry/inventory state, and dismissing the dialogue no longer trips the main-thread assert in `Common::String::compareToIgnoreCase` from the post-dialogue rollback check. Then validate both confirmed native acquisition paths still work: present `CASKET_PHOTO` / `CASKET_PHOTOCOPY` to Moynahan and take the glue-granting response at `dialog.rsp[0x18d]`, and on day 5 follow the `0x1bf..0x1c2` glue topic through response menu `0x1c3` to confirm `GLUE` plus `JUST_GOT_GLUE`. Finally, recheck the direct room-click pickup with `MOYNAHAN_IS_DEAD=1` to confirm the script-side `ADD_GLUE` branch still bypasses dialogue and awards the object cleanly.
 - Immediate: run an in-engine Harvester validation pass around the day-5 overnight CD handoff and the hidden warp-to-CD3 path. With launcher option `Show CD change prompts` enabled, trigger `CHANGE_TO_CD2_A -> CHANGE_TO_CD2_B -> CHANGE_TO_CD2_C` and confirm the native `CD2.BM` prompt appears after `OVERNITE.FST`, waits for a fresh left-click press/release cycle, and then continues into `PC_ROOM_HOUSE_START`; also trigger `WARP_TO_LODGE_A -> WARP_TO_LODGE_B` and confirm the `CD3.BM` prompt appears after `C076.FST`. Repeat the same paths with the option disabled and confirm the prompt is skipped while the room handoff still completes.
@@ -21,6 +23,8 @@
 
 ## Last Confirmed Action
 
+- On March 26, 2026, rechecked `handle_talk_to_sergeant` against live `HARVEST.LE` after the user-reported lodge-entry regression. `InspectCallTermination.java verify SERGEANT` showed the saved handler had regressed to a truncated `0x30340..0x30fca` body with only `12` reachable dialogue calls and `12` stale `CALL_RETURN` gaps; a filtered `/Users/alex/ghidra_scripts/ClearTalkHandlerDialogueOverrides.java SERGEANT` pass cleared all `12` stale overrides, `InspectCallTermination.java repair SERGEANT` finished the remaining three fallthrough repairs, and a saved re-verify restored the full clean body `0x30340..0x30fd2` with `70` dialogue calls and `gapCount=0`. The cleaned native body confirmed there is no extra lodge-opening flag/object mutation in the Sergeant handler; the concrete engine drifts were narrower: ScummVM had misattributed line `0x434f` to `SERGEANT` instead of `PC`, and the remains branch was missing the native post-`C076.FST` black-screen plus `prompt_for_cdrom_disc_change(3, 0)` tail. Patched [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), [`engines/harvester/npc/dialogue_runtime.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dialogue_runtime.h), and [`engines/harvester/npc/sergeant_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.cpp) to mirror that native behavior and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
+- On March 26, 2026, rechecked Harvester's native `WOLFIE`-style room-monster death completion in live `HARVEST.LE` to answer whether post-animation disappearance is original behavior. Ghidra confirmed that native class-6 monsters do not vanish on the lethal hit itself: `update_actor_runtime_state` first pushes them through the facing-specific death families `0x28..0x33`, then waits for the last death frame before entering terminal state `0x38` and dispatching the monster record's `on_death_action_tag`. The same live pass also reconfirmed that `room_setup` only respawns monsters whose records still satisfy `(active != 0 || visible != 0) && runtime_spawned == 0`, so a monster that has completed its death/remove path is not rebuilt into the room on the next same-room setup pass. That makes the current ScummVM behavior of letting `WOLFIE` finish the death bank and then disappear from the crypt a native-correct result rather than a `debug_combat`-only bug. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, rechecked Harvester's native combat damage seeding in live `HARVEST.LE` before changing the ScummVM formulas. Ghidra confirmed that `spawn_monster_entity_from_record` copies the monster record's `damage_amount` straight into live actor field `+0x1180`, and that `update_actor_runtime_state` subtracts that field directly on a confirmed hit with no extra post-hit multiplier. The same pass over `set_player_combat_loadout` also exposed a real player-weapon tuning bug in ScummVM: the native table uses `CHAINSAW -> loadout 14 -> damage 8` and `HARVEST_BLADE -> loadout 15 -> damage 3`, but the engine had those two loadouts swapped. Patched [`engines/harvester/player.h`](/Users/alex/Workspace/scummvm/engines/harvester/player.h), [`engines/harvester/player.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/player.cpp), [`engines/harvester/inventory.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/inventory.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) so the loadout table now matches native, and combat logs now print the resolved weapon/loadout label plus the native per-hit damage values during inventory toggles and player/monster hit resolution. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, traced the `MBLM` glue assert to a stale `StartupObjectRecord` reference in the room pickup loop, not to `Common::String` itself. The post-dialogue carry rollback added in [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) was still reading `object.objectName` and `object.actionTag` after `handleInteractionResult()`, but that interaction can rebuild the room scene and invalidate the clicked-object reference. Patched both room pickup branches to use the pre-saved object copy for all post-interaction logging and rollback checks, including the `isObjectInInventory()` probe that had been asserting in `Common::String::compareToIgnoreCase`. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, traced the remaining `MBLM` glue regression to the room-item carry path rather than the Moynahan dialogue handler. The alias fix was already working: live logs showed `MBLMGLUE_COM` reaching `MOYNAHAN_MBLM` and playing the native refusal lines `0x3e4d` / `0x3e52` / `0x3e56` / `0x3e76` / `0x3e7a`, but [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) was still leaving the denied `GLUE` as a carried room item because the carry-start rollback only handled modal-text denials. Patched the carried-pickup branch so, after a pickup action opens dialogue, it restores the original room object and clears the transient carry state unless that dialogue path explicitly moved the object into inventory; rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index b10295c9e44..cb365dc40a3 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -118,6 +118,7 @@ static const int kDialogueTopicEndX = 482;
 static const int kDialogueOtherStartY = 170;
 static const int kDialogueOtherEndY = 193;
 static const int kDialogueGenericByeResponseIndex = 13;
+static const char *const kCdChangePromptPalettePath = "1:/GRAPHIC/PAL/CD1.PAL";
 static const char *const kDialogueKeywordBitmapPath = "1:/GRAPHIC/OTHER/KEYWORD.BM";
 static const char *const kDialogueGameOverBitmapPath = "1:/GRAPHIC/OTHER/GAMEOVER.BM";
 static const char *const kDialogueGameOverPalettePath = "1:/GRAPHIC/PAL/GAMEOVER.PAL";
@@ -870,6 +871,96 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 
 		return Common::kNoError;
 	};
+	auto clearScreenToBlack = [&]() -> Common::Error {
+		Graphics::Screen *screen = _engine.getScreen();
+		if (!screen)
+			return Common::kReadingFailed;
+
+		byte blackPalette[256 * 3];
+		memset(blackPalette, 0, sizeof(blackPalette));
+		screen->fillRect(screen->getBounds(), 0);
+		setScaledPalette(*screen, blackPalette, 1.0f);
+		screen->makeAllDirty();
+		screen->update();
+		return Common::kNoError;
+	};
+	auto showCdChangePrompt = [&](int discNumber) -> Common::Error {
+		if (discNumber <= 0 || !_engine.shouldShowCdChangePrompts())
+			return Common::kNoError;
+
+		ResourceManager *resources = _engine.getResources();
+		Graphics::Screen *screen = _engine.getScreen();
+		if (!resources || !screen)
+			return Common::kReadingFailed;
+
+		const Common::String bitmapPath =
+			Common::String::format("1:/GRAPHIC/OTHER/CD%d.BM", discNumber);
+		IndexedBitmap promptBitmap;
+		byte promptPalette[256 * 3];
+		if (!loadBitmapResource(*resources, bitmapPath, promptBitmap) ||
+				!loadPaletteResource(*resources, kCdChangePromptPalettePath, promptPalette)) {
+			warning("Harvester: unable to load CD change prompt assets for disc %d", discNumber);
+			return Common::kReadingFailed;
+		}
+
+		_engine.stopStartupMusic();
+
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			Common::Error result = Common::kNoError;
+			if (startupFlow.handleSystemEvent(event, result))
+				return result;
+			if (event.type == Common::EVENT_MOUSEMOVE)
+				_mousePos = event.mouse;
+		}
+
+		bool waitingForRelease = false;
+		bool needsRedraw = true;
+		Graphics::FrameLimiter limiter(g_system, 60);
+
+		while (!_engine.shouldQuit()) {
+			if (needsRedraw) {
+				screen->fillRect(screen->getBounds(), 0);
+				blitBitmap(*screen, promptBitmap, 0, 0);
+				setScaledPalette(*screen, promptPalette, 1.0f);
+				if (runtimeEntities)
+					runtimeEntities->drawCursor(*screen);
+				screen->makeAllDirty();
+				screen->update();
+				needsRedraw = false;
+			}
+
+			while (g_system->getEventManager()->pollEvent(event)) {
+				Common::Error result = Common::kNoError;
+				if (startupFlow.handleSystemEvent(event, result))
+					return result;
+
+				switch (event.type) {
+				case Common::EVENT_MOUSEMOVE:
+					_mousePos = event.mouse;
+					needsRedraw = true;
+					break;
+				case Common::EVENT_LBUTTONDOWN:
+					waitingForRelease = true;
+					break;
+				case Common::EVENT_LBUTTONUP:
+					if (waitingForRelease)
+						return Common::kNoError;
+					break;
+				default:
+					break;
+				}
+			}
+
+			if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
+				needsRedraw = true;
+
+			limiter.delayBeforeSwap();
+			limiter.startFrame();
+		}
+
+		return Common::kNoError;
+	};
 	auto runGameOverScreen = [&]() -> Common::Error {
 		Graphics::Screen *screen = _engine.getScreen();
 		ResourceManager *resources = _engine.getResources();
@@ -1340,7 +1431,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 	DialogueRuntime runtime(
 		_engine, *startupScript, *startupText, startupFlow, npc.roomName, genericByeTopic,
 		playDialogueLineWithVariant, playDialogueLine, playDialogueEntrySequence,
-		playDialogueFst, runKeywordMenu, runResponseMenu,
+		playDialogueFst, clearScreenToBlack, showCdChangePrompt, runKeywordMenu, runResponseMenu,
 		[&](const Common::String &responseLine, int &selectedIndex) {
 			return runResponseMenuText(responseLine, -1, selectedIndex);
 		},
diff --git a/engines/harvester/npc/dialogue_runtime.h b/engines/harvester/npc/dialogue_runtime.h
index 00bbb3ea7f5..7d532dca2f6 100644
--- a/engines/harvester/npc/dialogue_runtime.h
+++ b/engines/harvester/npc/dialogue_runtime.h
@@ -35,6 +35,8 @@ public:
 	typedef std::function<Common::Error(int, const Common::String &)> PlayDialogueLineFn;
 	typedef std::function<Common::Error(const DialogueLineEntry *, uint)> PlayDialogueEntrySequenceFn;
 	typedef std::function<Common::Error(const Common::String &)> PlayDialogueFstFn;
+	typedef std::function<Common::Error()> ClearScreenToBlackFn;
+	typedef std::function<Common::Error(int)> ShowCdChangePromptFn;
 	typedef std::function<Common::Error(const Common::String &, int, Common::String &,
 			KeywordMenuSelectionState &)> RunKeywordMenuFn;
 	typedef std::function<Common::Error(int, int &)> RunResponseMenuFn;
@@ -55,6 +57,8 @@ public:
 			const PlayDialogueLineFn &playDialogueLine,
 			const PlayDialogueEntrySequenceFn &playDialogueEntrySequence,
 			const PlayDialogueFstFn &playDialogueFst,
+			const ClearScreenToBlackFn &clearScreenToBlack,
+			const ShowCdChangePromptFn &showCdChangePrompt,
 			const RunKeywordMenuFn &runKeywordMenu,
 			const RunResponseMenuFn &runResponseMenu,
 			const RunResponseMenuTextFn &runResponseMenuText,
@@ -71,7 +75,8 @@ public:
 		  _genericByeTopic(genericByeTopic),
 		  _playDialogueLineWithVariant(playDialogueLineWithVariant),
 		  _playDialogueLine(playDialogueLine), _playDialogueEntrySequence(playDialogueEntrySequence),
-		  _playDialogueFst(playDialogueFst), _runKeywordMenu(runKeywordMenu),
+		  _playDialogueFst(playDialogueFst), _clearScreenToBlack(clearScreenToBlack),
+		  _showCdChangePrompt(showCdChangePrompt), _runKeywordMenu(runKeywordMenu),
 		  _runResponseMenu(runResponseMenu), _runResponseMenuText(runResponseMenuText),
 		  _runGameOverScreen(runGameOverScreen),
 		  _assignTopicBuffer(assignTopicBuffer),
@@ -101,6 +106,8 @@ public:
 		return _playDialogueEntrySequence(lines, count);
 	}
 	Common::Error playDialogueFst(const Common::String &path) const { return _playDialogueFst(path); }
+	Common::Error clearScreenToBlack() const { return _clearScreenToBlack(); }
+	Common::Error showCdChangePrompt(int discNumber) const { return _showCdChangePrompt(discNumber); }
 	Common::Error runKeywordMenu(const Common::String &topicBuffer, int topicBufferLineIndex,
 			Common::String &selectedTopic) const {
 		_lastKeywordSelection = KeywordMenuSelectionState();
@@ -148,6 +155,8 @@ private:
 	PlayDialogueLineFn _playDialogueLine;
 	PlayDialogueEntrySequenceFn _playDialogueEntrySequence;
 	PlayDialogueFstFn _playDialogueFst;
+	ClearScreenToBlackFn _clearScreenToBlack;
+	ShowCdChangePromptFn _showCdChangePrompt;
 	RunKeywordMenuFn _runKeywordMenu;
 	RunResponseMenuFn _runResponseMenu;
 	RunResponseMenuTextFn _runResponseMenuText;
diff --git a/engines/harvester/npc/sergeant_dialogue.cpp b/engines/harvester/npc/sergeant_dialogue.cpp
index 890de03ca6d..7625cee50e2 100644
--- a/engines/harvester/npc/sergeant_dialogue.cpp
+++ b/engines/harvester/npc/sergeant_dialogue.cpp
@@ -137,7 +137,7 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			{ 0x433f, "SERGEANT", 0 },
 			{ 0x4344, "PC", 2 },
 			{ 0x4348, "SERGEANT", 0 },
-			{ 0x434f, "SERGEANT", 0 },
+			{ 0x434f, "PC", 0 },
 			{ 0x4353, "SERGEANT", 1 }
 		};
 		lineError = playSequence(remainsIntroLines, ARRAYSIZE(remainsIntroLines));
@@ -171,8 +171,13 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
-		// Native blacks the screen and then prompts for the next disc after this FST chain.
-		return runtime.playDialogueFst(kDialogueC076FstPath);
+		lineError = runtime.clearScreenToBlack();
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		lineError = runtime.playDialogueFst(kDialogueC076FstPath);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+		return runtime.showCdChangePrompt(3);
 	};
 	auto handleInviteBranch = [&]() -> Common::Error {
 		sharedState.dialogueStateD2f08 = true;


Commit: 4f65e4b8b52fcb8e7f11940ebe85cdb76799b982
    https://github.com/scummvm/scummvm/commit/4f65e4b8b52fcb8e7f11940ebe85cdb76799b982
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:22+02:00

Commit Message:
HARVESTER: Restart room after Sergeant CD3 prompt

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/npc/sergeant_dialogue.cpp
    engines/harvester/room.cpp
    engines/harvester/script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index f74776a0038..34431773798 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -114,6 +114,8 @@
   - `get_mscdex_version_bcd` uses `int 2Fh AX=150Ch` and returns the MSCDEX version number from `g_dpmi_real_mode_call_structure.ebx`.
 - `prompt_for_cdrom_disc_change` at `0x488a0` is the user-facing disc-swap / drive-not-ready path reached from menus and scripted transitions.
   - It stops the active music stream, frees dialogue/XFILE cache state, shows the `CD-ROM DRIVE NOT READY!` / `PLEASE TRY AGAIN...` prompt, then reruns `shutdown_extended_file_system`, `initialize_extended_file_system`, and the `dialogue.idx` reopen path once the requested disc becomes available.
+  - It also updates the active-disc dword at `0xc0fa8`. On disc `3`, it sets `0xd607c = 1`, and when the second parameter is `0` it additionally sets the one-shot latch at `0xc7e49 = 1`.
+  - `run_harvester_main_loop` checks that `0xc7e49` latch after interactions; when set, it clears the byte, calls `reload_town_world_from_script`, and immediately reruns `room_setup` on the pending room target. `dispatch_room_event_actions` case `CHANGE_CD` mirrors the same disc-3 path inline by calling `reload_town_world_from_script`, clearing `0xc7e49`, and then calling `room_setup`.
 
 ### Town Script Runtime
 
diff --git a/TRACKER.md b/TRACKER.md
index 6ec92afd595..f63266ed9d9 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,10 +3,10 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `871`
-- Named/documented: `819`
-- Remaining `FUN_*`: `52`
-- Remaining undocumented total: `52`
+- Total functions: `877`
+- Named/documented: `823`
+- Remaining `FUN_*`: `54`
+- Remaining undocumented total: `54`
 
 ## Next Suggested Action
 
@@ -23,6 +23,9 @@
 
 ## Last Confirmed Action
 
+- On March 26, 2026, dug into native `prompt_for_cdrom_disc_change` after the user raised the missing lodge-transition suspicion. Live Ghidra confirmed the Sergeant remains tail is not just cosmetic: `prompt_for_cdrom_disc_change(3, 0)` sets the active-disc dword `0xc0fa8 = 3`, raises `0xd607c = 1`, and, because the second parameter is `0`, latches `0xc7e49 = 1`. `run_harvester_main_loop` checks that latch immediately after interactions; when it is set, the loop clears it, calls `reload_town_world_from_script`, and reruns `room_setup` on the pending room target. The scripted `CHANGE_CD` room-event opcode mirrors the same disc-3 reload path inline, but the ScummVM Sergeant port previously only showed `CD3.BM` and returned. Patched [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/npc/sergeant_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.cpp) so the Sergeant `C076.FST -> CD3` tail now queues a native-style current-room restart after the prompt, which causes the room loop to rebuild the live room instead of falling straight back into the stale pre-CD3 scene. Updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) with the confirmed prompt-side latch behavior. Progress counts remain `877` total functions, `823` named/documented, and `54` remaining `FUN_*`.
+- On March 26, 2026, confirmed the user-reported stale `CALL_RETURN` regression was real but belonged to Stephanie rather than Sergeant. The cited site `0x37b26` is inside `handle_talk_to_stephanie`, not `handle_talk_to_sergeant`, and a fresh `InspectCallTermination.java verify STEPHANIE` showed the handler had fallen back to the truncated `0x36710..0x36cd8` body with only `9` reachable dialogue calls and `9` stale `CALL_RETURN` gaps. Running `repair STEPHANIE` in the live `HARVEST.LE` session converged in two passes, expanding the handler back to `0x36710..0x37f5d` with `198` reachable dialogue calls and `gapCount=0`; an explicit `save_program` plus fresh `verify STEPHANIE` confirmed the repaired state persists on disk. A raw assembly spot-check now shows the `0x37b26` `CALL 0x0007a690` falling through normally to `0x37b2b` inside the rebuilt Stephanie handler. Live progress now stands at `877` total functions, `823` named/documented, and `54` remaining `FUN_*`.
+- On March 26, 2026, reran the live `InspectCallTermination.java verify -> repair -> verify` sequence on `SERGEANT` in `HARVEST.LE`. The first `verify SERGEANT` already showed `handle_talk_to_sergeant @ 0x30340` clean with body `0x30340..0x30fd2` (`3219` bytes), `70` reachable dialogue calls, both `play_dialogue_line` entry points returning, and `gapCount=0`. `repair SERGEANT` immediately reported `No remaining repair issues` with `repairActions=0`, and the final `verify SERGEANT` reproduced the same clean state, so no further handler repair was required. Live progress now stands at `873` total functions, `819` named/documented, and `54` remaining `FUN_*`.
 - On March 26, 2026, rechecked `handle_talk_to_sergeant` against live `HARVEST.LE` after the user-reported lodge-entry regression. `InspectCallTermination.java verify SERGEANT` showed the saved handler had regressed to a truncated `0x30340..0x30fca` body with only `12` reachable dialogue calls and `12` stale `CALL_RETURN` gaps; a filtered `/Users/alex/ghidra_scripts/ClearTalkHandlerDialogueOverrides.java SERGEANT` pass cleared all `12` stale overrides, `InspectCallTermination.java repair SERGEANT` finished the remaining three fallthrough repairs, and a saved re-verify restored the full clean body `0x30340..0x30fd2` with `70` dialogue calls and `gapCount=0`. The cleaned native body confirmed there is no extra lodge-opening flag/object mutation in the Sergeant handler; the concrete engine drifts were narrower: ScummVM had misattributed line `0x434f` to `SERGEANT` instead of `PC`, and the remains branch was missing the native post-`C076.FST` black-screen plus `prompt_for_cdrom_disc_change(3, 0)` tail. Patched [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), [`engines/harvester/npc/dialogue_runtime.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dialogue_runtime.h), and [`engines/harvester/npc/sergeant_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.cpp) to mirror that native behavior and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, rechecked Harvester's native `WOLFIE`-style room-monster death completion in live `HARVEST.LE` to answer whether post-animation disappearance is original behavior. Ghidra confirmed that native class-6 monsters do not vanish on the lethal hit itself: `update_actor_runtime_state` first pushes them through the facing-specific death families `0x28..0x33`, then waits for the last death frame before entering terminal state `0x38` and dispatching the monster record's `on_death_action_tag`. The same live pass also reconfirmed that `room_setup` only respawns monsters whose records still satisfy `(active != 0 || visible != 0) && runtime_spawned == 0`, so a monster that has completed its death/remove path is not rebuilt into the room on the next same-room setup pass. That makes the current ScummVM behavior of letting `WOLFIE` finish the death bank and then disappear from the crypt a native-correct result rather than a `debug_combat`-only bug. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
 - On March 26, 2026, rechecked Harvester's native combat damage seeding in live `HARVEST.LE` before changing the ScummVM formulas. Ghidra confirmed that `spawn_monster_entity_from_record` copies the monster record's `damage_amount` straight into live actor field `+0x1180`, and that `update_actor_runtime_state` subtracts that field directly on a confirmed hit with no extra post-hit multiplier. The same pass over `set_player_combat_loadout` also exposed a real player-weapon tuning bug in ScummVM: the native table uses `CHAINSAW -> loadout 14 -> damage 8` and `HARVEST_BLADE -> loadout 15 -> damage 3`, but the engine had those two loadouts swapped. Patched [`engines/harvester/player.h`](/Users/alex/Workspace/scummvm/engines/harvester/player.h), [`engines/harvester/player.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/player.cpp), [`engines/harvester/inventory.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/inventory.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) so the loadout table now matches native, and combat logs now print the resolved weapon/loadout label plus the native per-hit damage values during inventory toggles and player/monster hit resolution. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index cb365dc40a3..2f52630454c 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -1033,6 +1033,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 				!interaction.musicPath.empty() || !interaction.nextRoomName.empty() ||
 				!interaction.cutscenePath.empty() ||
 				!interaction.deathFlicPath.empty() || interaction.requestMainMenu ||
+				interaction.requestRoomRestart ||
 				interaction.cdChangeDisc > 0 ||
 				!interaction.dialogueNpcName.empty() ||
 				!interaction.dialogueContinuationTag.empty() ||
diff --git a/engines/harvester/npc/sergeant_dialogue.cpp b/engines/harvester/npc/sergeant_dialogue.cpp
index 7625cee50e2..730e4e22b3e 100644
--- a/engines/harvester/npc/sergeant_dialogue.cpp
+++ b/engines/harvester/npc/sergeant_dialogue.cpp
@@ -177,7 +177,15 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		lineError = runtime.playDialogueFst(kDialogueC076FstPath);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
-		return runtime.showCdChangePrompt(3);
+		lineError = runtime.showCdChangePrompt(3);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+
+		// Native prompt_for_cdrom_disc_change(3, 0) latches a main-loop room rebuild.
+		StartupInteractionResult interaction;
+		interaction.requestRoomRestart = true;
+		runtime.queueDialogueInteractionIfNeeded(interaction);
+		return Common::kNoError;
 	};
 	auto handleInviteBranch = [&]() -> Common::Error {
 		sharedState.dialogueStateD2f08 = true;
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 4dbfa41c85a..6db5e38e2e4 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1869,6 +1869,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					if (cdPromptError.getCode() != Common::kNoError)
 						return cdPromptError;
 				}
+				if (interaction.requestRoomRestart) {
+					pendingRoomChange = !scene.state.entranceName.empty()
+						? scene.state.entranceName
+						: scene.state.roomName;
+					didTransition = !pendingRoomChange.empty();
+					return didTransition ? Common::kNoError : Common::kReadingFailed;
+				}
 
 				StartupRoomTransitionKind roomTransition = interaction.roomTransition;
 				if (roomTransition == kStartupRoomTransitionNone && !interaction.nextRoomName.empty())
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index f398a392c1e..c296b802f3e 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -298,6 +298,7 @@ struct StartupInteractionResult {
 	int playerGotoX = 0;
 	int playerGotoZ = 0;
 	bool requestPlayerGotoXZ = false;
+	bool requestRoomRestart = false;
 	bool requestMainMenu = false;
 	bool abortRemainingCommandChain = false;
 	bool mutatedRuntimeState = false;


Commit: b94cfb3c7c597d1f288d1c3326018a3a7216f412
    https://github.com/scummvm/scummvm/commit/b94cfb3c7c597d1f288d1c3326018a3a7216f412
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:23+02:00

Commit Message:
HARVESTER: Honor room blocker and hotspot objects

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/flow.cpp
    engines/harvester/room_support.h
    engines/harvester/runtime_entity.cpp
    engines/harvester/script.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 34431773798..5467f69cad1 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -246,6 +246,8 @@
   - Before rebuilding the wait transition, `room_setup` uses `ramp_palette_brightness` to fade the outgoing palette down to black when a current room palette is active; only the no-current-room startup case skips directly to a black palette upload.
   - It then reloads `WAIT.PAL` and `WAIT.ABM`, hides the cursor entity, flushes the wait transition while the cursor is hidden, re-shows the cursor, uploads the wait palette, builds the room entities, and only after that uploads the room palette from `g_current_palette_buffer`.
   - Once the wait transition is flushed, `room_setup` rebuilds the room render list in this order: matching enabled regions, room timers, visible object records whose `current_owner_or_room` matches the room, then room `ANIM` records whose `active` or `visible` state is set.
+  - Decoding `HARVEST.SCR` confirms that room `OBJECT` records are not all named sprites. The data uses unnamed `OBJECT` records as blocker rectangles in many rooms, and it also uses hidden no-sprite `OBJECT` records with action tags as live hotspots. Concrete examples include the four anonymous `SERGEANTRM` `{// BLOCKS }` entries plus hidden action hotspots such as `PCHOUSE / PC_WINDOW` and `MR_JOHNS / MANHOLE`.
+  - The same decoded script confirms the Sergeant remains CD3 handoff is data-authored to rebuild `SERGEANTRM` itself. `WARP_TO_LODGE -> WARP_TO_LODGE_A -> WARP_TO_LODGE_B` ends at `CHANGE_CD 3`, and `SERGEANTRM` only defines the hidden `LODGE_DOOR` mask plus region `LOD_X1 -> EXIT_LODGE`; there is no second script-side lodge-room transition chained after the CD prompt.
   - On the player-present branch it then re-adds the stored live player actor from `DAT_000d5bd4`, places it from `g_current_room_entrance`, and seeds exact facing frames `0x3b`, `0x0e`, `0x2c`, and `0x28` for entrance-facing values `0`, `1`, `2`, and `3`.
   - Before the live room flush it explicitly resets the cursor entity to animation sequence `7`.
   - The final room palette/upload order is now explicit: after optional room-enter handlers, `room_setup` uploads `g_current_palette_buffer` and then calls `flush_dirty_rects_to_screen`.
diff --git a/TRACKER.md b/TRACKER.md
index f63266ed9d9..76abf7c9f8c 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,7 +10,7 @@
 
 ## Next Suggested Action
 
-- Immediate: run an in-engine Harvester validation pass on the Sergeant remains/lodge handoff with `Show CD change prompts` enabled. Recreate the `GOT_REMAINS_FOR_LODGE` branch so `0x4333 -> C150.FST -> 0x433f..0x4385 -> C076.FST` fires, confirm line `0x434f` now uses the PC portrait, and verify the native black-screen + `CD3.BM` prompt appears after `C076.FST` before returning control. Then retry the lodge approach to determine whether the still-unopened lodge entry is a separate room/map-script issue rather than a remaining `handle_talk_to_sergeant` parity bug.
+- Immediate: rerun the in-engine Sergeant remains/lodge handoff after the room-object semantics fix. Recreate the `GOT_REMAINS_FOR_LODGE` branch so `0x4333 -> C150.FST -> 0x433f..0x4385 -> C076.FST -> CHANGE_CD 3` lands in `SERGEANTRM`, confirm the room now materializes its authored blocker geometry correctly, and verify that the post-CD room still exposes only the scripted `LOD_X1 -> EXIT_LODGE` transition. If the expected deeper lodge entry is still absent, treat that as a script/data expectation mismatch rather than a remaining Sergeant CD-prompt bug.
 - Immediate: run an in-engine Harvester validation pass focused on the `WOLFIE` crypt kill after the newly confirmed native parity check. Keep `combat` logging enabled, kill `WOLFIE`, and confirm the death bank still plays through to completion before the monster disappears, that `WOLFIE_DEATHCOM` only opens the crypt hotspots without leaving a clickable or visible monster residue, and that no stale monster entity survives in the live room after the `CRYPT` state refresh.
 - Immediate: run an in-engine Harvester combat validation pass with one low-band melee loadout and one high-band weapon while `combat` logging is enabled. Recheck `FIREAXE` in `CRYPT` to confirm the new logs show the native `loadout=17 weapon='FIREAXE' damage=3`, then switch to a higher-damage weapon such as `SHOTGUN` (`loadout=3 damage=7`) or `CHAINSAW` (`loadout=14 damage=8`) and confirm the contact log changes accordingly. During the same pass, verify `WOLFIE` continues to report its seeded per-hit monster damage in the spawn and attack logs so we can tell whether the remaining difficulty complaint is native data/cadence rather than duplicate hit application.
 - Immediate: rerun the in-engine `MBLM` glue validation pass after the stale-reference crash fix. With Moynahan alive (`MOYNAHAN_IS_DEAD=0`), click the room `GLUE` hotspot and confirm `MBLMGLUE_COM -> START_DIALOG MOYNAHAN_MBLM` still lands in the restored Moynahan handler, the native refusal lines `0x3e4d/0x3e52/0x3e56 -> 0x3e76/0x3e7a` play, the denied pickup still rolls back out of carry/inventory state, and dismissing the dialogue no longer trips the main-thread assert in `Common::String::compareToIgnoreCase` from the post-dialogue rollback check. Then validate both confirmed native acquisition paths still work: present `CASKET_PHOTO` / `CASKET_PHOTOCOPY` to Moynahan and take the glue-granting response at `dialog.rsp[0x18d]`, and on day 5 follow the `0x1bf..0x1c2` glue topic through response menu `0x1c3` to confirm `GLUE` plus `JUST_GOT_GLUE`. Finally, recheck the direct room-click pickup with `MOYNAHAN_IS_DEAD=1` to confirm the script-side `ADD_GLUE` branch still bypasses dialogue and awards the object cleanly.
@@ -23,6 +23,7 @@
 
 ## Last Confirmed Action
 
+- On March 26, 2026, dumped and decoded the authored lodge-entry data after the user questioned why the post-Sergeant CD3 handoff was not entering a deeper lodge room. `HARVEST.SCR` confirms the data-driven path stops at `COMMAND "WARP_TO_LODGE_B" "CHANGE_CD" "3"` and then rebuilds `SERGEANTRM`; that room contains one hidden mask object, `LODGE_DOOR`, and exactly one region, `LOD_X1 -> EXIT_LODGE`, so there is no second script-authored lodge-room transition to trigger automatically after `C076.FST`. The same audit exposed a real ScummVM room-runtime gap instead: the loader was discarding unnamed `OBJECT` records entirely, which drops native blocker rectangles such as the four `SERGEANTRM` `{// BLOCKS }` entries, and the scene builder was also skipping hidden no-sprite action hotspots such as `PCHOUSE / PC_WINDOW` and `MR_JOHNS / MANHOLE`. Patched [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp), [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp), and [`engines/harvester/room_support.h`](/Users/alex/Workspace/scummvm/engines/harvester/room_support.h) so anonymous room objects receive stable internal ids, hidden no-sprite action hotspots remain materialized, and visible disabled hotspot rectangles now participate in room collision as blockers. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `877` total functions, `823` named/documented, and `54` remaining `FUN_*`.
 - On March 26, 2026, dug into native `prompt_for_cdrom_disc_change` after the user raised the missing lodge-transition suspicion. Live Ghidra confirmed the Sergeant remains tail is not just cosmetic: `prompt_for_cdrom_disc_change(3, 0)` sets the active-disc dword `0xc0fa8 = 3`, raises `0xd607c = 1`, and, because the second parameter is `0`, latches `0xc7e49 = 1`. `run_harvester_main_loop` checks that latch immediately after interactions; when it is set, the loop clears it, calls `reload_town_world_from_script`, and reruns `room_setup` on the pending room target. The scripted `CHANGE_CD` room-event opcode mirrors the same disc-3 reload path inline, but the ScummVM Sergeant port previously only showed `CD3.BM` and returned. Patched [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/npc/sergeant_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.cpp) so the Sergeant `C076.FST -> CD3` tail now queues a native-style current-room restart after the prompt, which causes the room loop to rebuild the live room instead of falling straight back into the stale pre-CD3 scene. Updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) with the confirmed prompt-side latch behavior. Progress counts remain `877` total functions, `823` named/documented, and `54` remaining `FUN_*`.
 - On March 26, 2026, confirmed the user-reported stale `CALL_RETURN` regression was real but belonged to Stephanie rather than Sergeant. The cited site `0x37b26` is inside `handle_talk_to_stephanie`, not `handle_talk_to_sergeant`, and a fresh `InspectCallTermination.java verify STEPHANIE` showed the handler had fallen back to the truncated `0x36710..0x36cd8` body with only `9` reachable dialogue calls and `9` stale `CALL_RETURN` gaps. Running `repair STEPHANIE` in the live `HARVEST.LE` session converged in two passes, expanding the handler back to `0x36710..0x37f5d` with `198` reachable dialogue calls and `gapCount=0`; an explicit `save_program` plus fresh `verify STEPHANIE` confirmed the repaired state persists on disk. A raw assembly spot-check now shows the `0x37b26` `CALL 0x0007a690` falling through normally to `0x37b2b` inside the rebuilt Stephanie handler. Live progress now stands at `877` total functions, `823` named/documented, and `54` remaining `FUN_*`.
 - On March 26, 2026, reran the live `InspectCallTermination.java verify -> repair -> verify` sequence on `SERGEANT` in `HARVEST.LE`. The first `verify SERGEANT` already showed `handle_talk_to_sergeant @ 0x30340` clean with body `0x30340..0x30fd2` (`3219` bytes), `70` reachable dialogue calls, both `play_dialogue_line` entry points returning, and `gapCount=0`. `repair SERGEANT` immediately reported `No remaining repair issues` with `repairActions=0`, and the final `verify SERGEANT` reproduced the same clean state, so no further handler repair was required. Live progress now stands at `873` total functions, `819` named/documented, and `54` remaining `FUN_*`.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 383c87c2484..926730abc99 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -539,7 +539,7 @@ static bool loadPaletteResource(ResourceManager &resources, const Common::String
 	return true;
 }
 
-Common::Rect getHotspotBounds(const StartupObjectRecord &object) {
+Common::Rect getRoomObjectHotspotBounds(const StartupObjectRecord &object) {
 	if (object.boundsX2 > object.currentX && object.boundsY2 > object.currentY)
 		return Common::Rect(object.currentX, object.currentY, object.boundsX2 + 1, object.boundsY2 + 1);
 
@@ -583,7 +583,7 @@ bool loadBitmapResource(ResourceManager &resources, const Common::String &path,
 
 static void logSceneObjectSelection(const char *decision, const char *source, const StartupObjectRecord &object,
 		const Common::String &detail = Common::String()) {
-	const Common::Rect hotspotBounds = getHotspotBounds(object);
+	const Common::Rect hotspotBounds = getRoomObjectHotspotBounds(object);
 	const Common::String resolvedSpritePath = resolveSceneObjectSpritePath(object);
 	debugC(1, kDebugScene,
 		"Harvester: scene object %s source='%s' object='%s' owner='%s' visible=%d runtimeVisible=%d sprite='%s' alt='%s' resolved='%s' pos=(%d,%d,%d) bounds=(%d,%d)-(%d,%d) action='%s' detail='%s'",
@@ -622,10 +622,20 @@ static int resolveSceneObjectClass(const StartupObjectRecord &object, const Runt
 		: kRuntimeEntityClassDisabledHotspot;
 }
 
-static void queueVisibleSceneObject(const char *source, const StartupObjectRecord &object,
+static bool shouldQueueSceneObject(const StartupObjectRecord &object) {
+	if (object.visible)
+		return true;
+
+	// Hidden no-sprite hotspots still drive room actions in the authored script data.
+	return resolveSceneObjectSpritePath(object).empty() &&
+		!getRoomObjectHotspotBounds(object).isEmpty() &&
+		(object.operatable || !object.actionTag.empty());
+}
+
+static void queueSceneObject(const char *source, const StartupObjectRecord &object,
 		Common::Array<StartupObjectRecord> &sceneObjects) {
-	if (!object.visible) {
-		logSceneObjectSelection("skipped", source, object, "visible=0");
+	if (!shouldQueueSceneObject(object)) {
+		logSceneObjectSelection("skipped", source, object, object.visible ? "inactive" : "visible=0");
 		return;
 	}
 
@@ -651,9 +661,9 @@ bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceManager
 
 	Common::Array<StartupObjectRecord> sceneObjects;
 	for (const StartupObjectRecord &object : state.roomObjects)
-		queueVisibleSceneObject("room", object, sceneObjects);
+		queueSceneObject("room", object, sceneObjects);
 	for (const StartupObjectRecord &object : state.activeObjects)
-		queueVisibleSceneObject("active", object, sceneObjects);
+		queueSceneObject("active", object, sceneObjects);
 
 	scene.sceneObjects = sceneObjects;
 	for (const StartupAnimRecord &anim : state.roomAnimations) {
@@ -986,7 +996,7 @@ StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const Start
 
 static bool findRoomObjectProbePoint(HarvesterEngine &engine, const Common::Array<StartupObjectRecord> &sceneObjects,
 		const StartupObjectRecord &object, Common::Point &probePoint) {
-	const Common::Rect bounds = getHotspotBounds(object);
+	const Common::Rect bounds = getRoomObjectHotspotBounds(object);
 	if (bounds.isEmpty())
 		return false;
 
@@ -1055,7 +1065,7 @@ void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResource
 	}
 
 	for (const StartupObjectRecord &object : scene.sceneObjects) {
-		if (getHotspotBounds(object).isEmpty())
+		if (getRoomObjectHotspotBounds(object).isEmpty())
 			continue;
 
 		Common::Point probePoint;
@@ -1741,7 +1751,7 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 	for (const StartupObjectRecord &object : drawableObjects) {
 		RuntimeEntity *entity = nullptr;
 		const Common::String spritePath = resolveSceneObjectSpritePath(object);
-		const Common::Rect hotspotBounds = getHotspotBounds(object);
+		const Common::Rect hotspotBounds = getRoomObjectHotspotBounds(object);
 		if (!spritePath.empty() && spritePath.hasSuffixIgnoreCase(".BM")) {
 			entity = runtimeEntities->spawnSceneBitmapEntity(object.objectName, spritePath,
 				Common::Point(object.currentX, object.currentY), (float)object.currentZ);
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index 4c902dea08f..ba60c880367 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -107,6 +107,7 @@ bool applyRoomActorPlacement(const StartupRoomSetupState &state, RuntimeEntity &
 	int centerX, int bottomY, float z, const Common::String *entranceName = nullptr);
 void logScenePaletteSummary(const char *label, const StartupRoomSceneResources &scene, float brightness);
 bool captureScreenBackdrop(const Graphics::Screen &screen, IndexedBitmap &bitmap);
+Common::Rect getRoomObjectHotspotBounds(const StartupObjectRecord &object);
 bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceManager &resources, StartupRoomSceneResources &scene);
 bool shouldRunStartupRoomProbe();
 void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const StartupRoomSceneResources &scene,
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index e4d7ea7220f..b387846e1d9 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -534,8 +534,6 @@ bool RuntimeEntity::hitTest(const Common::Point &point) const {
 bool RuntimeEntity::overlapsEntity(const RuntimeEntity &other) const {
 	if (this == &other || !_visible || !other._visible)
 		return false;
-	if (!hasOpaqueFrame() || !other.hasOpaqueFrame())
-		return false;
 	if (_classId == kRuntimeEntityClassCursor || _classId == kRuntimeEntityClassBackground ||
 		_classId == kRuntimeEntityClassRectHotspot || _classId == kRuntimeEntityClassRectHotspot19 ||
 		other._classId == kRuntimeEntityClassCursor || other._classId == kRuntimeEntityClassBackground ||
@@ -556,6 +554,12 @@ bool RuntimeEntity::overlapsEntity(const RuntimeEntity &other) const {
 	const int bottom = MIN(thisRect.bottom, otherRect.bottom);
 	if (left >= right || top >= bottom)
 		return false;
+	if (_classId == kRuntimeEntityClassDisabledHotspot ||
+			other._classId == kRuntimeEntityClassDisabledHotspot) {
+		return true;
+	}
+	if (!hasOpaqueFrame() || !other.hasOpaqueFrame())
+		return false;
 
 	for (int y = top; y < bottom; ++y) {
 		for (int x = left; x < right; ++x) {
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index f90c8971582..68870d885bd 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -613,6 +613,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 	_heads.clear();
 	_useItems.clear();
 
+	uint32 anonymousObjectId = 0;
 	auto parseLine = [&](const Common::String &rawLine) {
 		const Common::String line = trimAsciiLine(rawLine);
 		if (line.empty() || line[0] == '{')
@@ -1010,7 +1011,11 @@ void Script::parseTownRecords(ResourceManager &resources) {
 		object.currentOwnerOrRoom = object.initialOwnerOrRoom;
 		object.runtimeVisible = object.visible;
 		object.identShown = object.identTextKey.empty();
-		if (!object.initialOwnerOrRoom.empty() && !object.objectName.empty())
+		if (object.objectName.empty()) {
+			// Native room scripts use unnamed OBJECT entries as blocker rectangles.
+			object.objectName = Common::String::format("__ANON_OBJECT_%u", anonymousObjectId++);
+		}
+		if (!object.initialOwnerOrRoom.empty())
 			_objects.push_back(object);
 		};
 


Commit: 2567641eec42118f7f2b8d08d7a99a5156082ac8
    https://github.com/scummvm/scummvm/commit/2567641eec42118f7f2b8d08d7a99a5156082ac8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:23+02:00

Commit Message:
HARVESTER: Add GOTO_ROOM debug command

Changed paths:
    engines/harvester/console.cpp
    engines/harvester/console.h
    engines/harvester/flow.cpp
    engines/harvester/flow.h
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/room.cpp
    engines/harvester/room.h
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/engines/harvester/console.cpp b/engines/harvester/console.cpp
index 82fcc61b64c..6f7719252fc 100644
--- a/engines/harvester/console.cpp
+++ b/engines/harvester/console.cpp
@@ -20,14 +20,61 @@
  */
 
 #include "harvester/console.h"
+
+#include "common/algorithm.h"
 #include "harvester/harvester.h"
 
 namespace Harvester {
 
+namespace {
+
+static Script *getActiveStartupScript() {
+	return g_engine ? g_engine->getStartupScript() : nullptr;
+}
+
+static void collectSortedRoomNames(const Script &script, Common::Array<Common::String> &roomNames) {
+	roomNames.clear();
+
+	for (const StartupRoomRecord &room : script.getRooms()) {
+		bool alreadyPresent = false;
+		for (const Common::String &existingName : roomNames) {
+			if (existingName.equalsIgnoreCase(room.roomName)) {
+				alreadyPresent = true;
+				break;
+			}
+		}
+		if (!alreadyPresent)
+			roomNames.push_back(room.roomName);
+	}
+
+	Common::sort(roomNames.begin(), roomNames.end(), [](const Common::String &lhs, const Common::String &rhs) {
+		Common::String upperLhs = lhs;
+		Common::String upperRhs = rhs;
+		upperLhs.toUppercase();
+		upperRhs.toUppercase();
+		return upperLhs < upperRhs;
+	});
+}
+
+static bool findRoomName(const Script &script, const Common::String &candidate, Common::String &roomName) {
+	for (const StartupRoomRecord &room : script.getRooms()) {
+		if (!room.roomName.equalsIgnoreCase(candidate))
+			continue;
+
+		roomName = room.roomName;
+		return true;
+	}
+
+	return false;
+}
+
+} // End of anonymous namespace
+
 Console::Console() : GUI::Debugger() {
 	registerCmd("about", WRAP_METHOD(Console, Cmd_about));
 	registerCmd("DEBUG_COMBAT", WRAP_METHOD(Console, Cmd_debugCombat));
 	registerCmd("DEBUG_ROOM", WRAP_METHOD(Console, Cmd_debugRoom));
+	registerCmd("GOTO_ROOM", WRAP_METHOD(Console, Cmd_gotoRoom));
 }
 
 Console::~Console() {
@@ -70,4 +117,50 @@ bool Console::Cmd_debugRoom(int argc, const char **argv) {
 	return true;
 }
 
+bool Console::Cmd_gotoRoom(int argc, const char **argv) {
+	if (argc > 2) {
+		debugPrintf("Usage: GOTO_ROOM [room_name]\n");
+		return true;
+	}
+
+	Script *startupScript = getActiveStartupScript();
+	if (!startupScript) {
+		debugPrintf("Harvester engine is not active\n");
+		return true;
+	}
+
+	Common::Array<Common::String> roomNames;
+	collectSortedRoomNames(*startupScript, roomNames);
+	if (roomNames.empty()) {
+		debugPrintf("No room targets are available\n");
+		return true;
+	}
+
+	if (argc == 1) {
+		debugPrintf("Available room targets:\n");
+		for (const Common::String &roomName : roomNames)
+			debugPrintf("  %s\n", roomName.c_str());
+		return true;
+	}
+
+	if (!g_engine || !g_engine->hasCurrentStartupSaveRoomState()) {
+		debugPrintf("GOTO_ROOM is only available while a room is active\n");
+		return true;
+	}
+
+	Common::String roomName;
+	if (!findRoomName(*startupScript, argv[1], roomName)) {
+		debugPrintf("Unknown room target '%s'\n", argv[1]);
+		return true;
+	}
+
+	if (!g_engine->requestDebugRoomChange(roomName)) {
+		debugPrintf("Unable to queue room change to '%s'\n", roomName.c_str());
+		return true;
+	}
+
+	debugPrintf("Queued room change to %s\n", roomName.c_str());
+	return true;
+}
+
 } // End of namespace Harvester
diff --git a/engines/harvester/console.h b/engines/harvester/console.h
index c0410666700..14638d100db 100644
--- a/engines/harvester/console.h
+++ b/engines/harvester/console.h
@@ -32,6 +32,7 @@ private:
 	bool Cmd_about(int argc, const char **argv);
 	bool Cmd_debugCombat(int argc, const char **argv);
 	bool Cmd_debugRoom(int argc, const char **argv);
+	bool Cmd_gotoRoom(int argc, const char **argv);
 public:
 	Console();
 	~Console() override;
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 926730abc99..c9c823eaa41 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -1621,6 +1621,7 @@ void Flow::prepareForNewGame() {
 	clearPendingMainMenuReturn();
 	clearPendingGameOverReturn();
 	clearPendingNewGameRestart();
+	_pendingDebugRoomName.clear();
 	_engine.clearPendingLoadedStartupSaveRoomState();
 	_engine.clearPendingLoadedDialogueStateBlob();
 	_engine.clearCurrentStartupSaveRoomState();
@@ -1685,8 +1686,44 @@ void Flow::clearPendingMainMenuReturn() {
 	_pendingMainMenuReturn = false;
 }
 
+bool Flow::requestDebugRoomChange(const Common::String &roomName) {
+	if (roomName.empty())
+		return false;
+
+	_pendingDebugRoomName = roomName;
+	return true;
+}
+
+Common::String Flow::takePendingDebugRoomChange() {
+	const Common::String roomName = _pendingDebugRoomName;
+	_pendingDebugRoomName.clear();
+	return roomName;
+}
+
 Common::Error Flow::runRoomLoop(const Common::String &entranceName) {
-	return _room.runRoomLoop(*this, entranceName);
+	const bool nestedRoomLoop = _roomLoopDepth != 0;
+	Common::String targetName = entranceName;
+	bool targetIsRoomName = false;
+
+	++_roomLoopDepth;
+	while (true) {
+		const Common::Error error = _room.runRoomLoop(*this, targetName, targetIsRoomName);
+		if (error.getCode() != Common::kNoError) {
+			--_roomLoopDepth;
+			return error;
+		}
+		if (!hasPendingDebugRoomChange()) {
+			--_roomLoopDepth;
+			return Common::kNoError;
+		}
+		if (nestedRoomLoop) {
+			--_roomLoopDepth;
+			return Common::kNoError;
+		}
+
+		targetName = takePendingDebugRoomChange();
+		targetIsRoomName = true;
+	}
 }
 
 bool Flow::ensureCursorEntity() {
diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index 34343c66b1d..37aa0d9ce16 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -51,6 +51,7 @@ public:
 	Common::Error run();
 
 private:
+	friend class HarvesterEngine;
 	friend class DialogueSystem;
 	friend class InventorySystem;
 	friend class MenuSystem;
@@ -90,6 +91,9 @@ private:
 	bool hasPendingMainMenuReturn() const;
 	bool takePendingMainMenuReturn();
 	void clearPendingMainMenuReturn();
+	bool requestDebugRoomChange(const Common::String &roomName);
+	bool hasPendingDebugRoomChange() const { return !_pendingDebugRoomName.empty(); }
+	Common::String takePendingDebugRoomChange();
 	void resetRoomNpcDialogueState();
 	void resetCursorAnimationSequence();
 	bool tickRuntimeEntities();
@@ -108,6 +112,8 @@ private:
 	bool _pendingNewGameRestart = false;
 	bool _pendingGameOverReturn = false;
 	bool _pendingMainMenuReturn = false;
+	Common::String _pendingDebugRoomName;
+	uint _roomLoopDepth = 0;
 	uint32 _roomSetupTransitionShownTick = 0;
 };
 
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 4f384be598d..bab663c5522 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -231,6 +231,12 @@ bool HarvesterEngine::toggleRoomDebugEnabled() {
 	return _roomDebugEnabled;
 }
 
+bool HarvesterEngine::requestDebugRoomChange(const Common::String &roomName) {
+	return _activeFlow &&
+		_currentStartupSaveRoomState.valid &&
+		_activeFlow->requestDebugRoomChange(roomName);
+}
+
 void HarvesterEngine::setDisplayMode(int width, int height) {
 	initGraphics(width, height);
 	if (_media)
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index e14115dd1b7..01b7569e6b2 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -115,6 +115,7 @@ public:
 	bool toggleCombatDebugEnabled();
 	bool isRoomDebugEnabled() const { return _roomDebugEnabled; }
 	bool toggleRoomDebugEnabled();
+	bool requestDebugRoomChange(const Common::String &roomName);
 	void captureCurrentStartupSaveRoomState(const Common::String &entranceName,
 		const Common::String &roomName, int playerX, int playerY, int playerZ, int playerFacing,
 		const Common::String &musicPath);
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 6db5e38e2e4..d4ba75d1634 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -623,11 +623,13 @@ RoomSystem::RoomSystem(HarvesterEngine &engine, Common::Point &mousePos,
 	: _engine(engine), _mousePos(mousePos), _inventory(inventory) {
 }
 
-Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &entranceName) {
+Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &targetName,
+		bool targetIsRoomName) {
 	if (startupFlow.hasPendingMainMenuReturn())
 		return Common::kNoError;
 
-	Common::String currentRoomTarget = entranceName;
+	Common::String currentRoomTarget = targetName;
+	bool currentTargetIsRoomName = targetIsRoomName;
 	while (!currentRoomTarget.empty()) {
 		StartupRoomSetupState state;
 		if (_engine.hasPendingLoadedStartupSaveRoomState()) {
@@ -657,8 +659,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 			state.musicPath = loadedState.musicPath;
 			currentRoomTarget = !loadedState.entranceName.empty() ? loadedState.entranceName : loadedState.roomName;
 			_engine.clearPendingLoadedStartupSaveRoomState();
-		} else if (!_engine.getStartupScript()->resolveRoomSetupState(
-				currentRoomTarget, state, *_engine.getResources())) {
+			currentTargetIsRoomName = false;
+		} else if ((currentTargetIsRoomName
+				? !_engine.getStartupScript()->resolveRoomSetupStateByRoomName(
+					currentRoomTarget, state, *_engine.getResources())
+				: !_engine.getStartupScript()->resolveRoomSetupState(
+					currentRoomTarget, state, *_engine.getResources()))) {
 			return Common::kReadingFailed;
 		}
 
@@ -1906,6 +1912,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 					Common::Error roomError = startupFlow.runRoomLoop(interaction.nextRoomName);
 					if (startupFlow.hasPendingMainMenuReturn())
 						return Common::kNoError;
+					if (startupFlow.hasPendingDebugRoomChange()) {
+						didTransition = true;
+						return Common::kNoError;
+					}
 					if (roomError.getCode() != Common::kReadingFailed &&
 						roomError.getCode() != Common::kNoError) {
 						return roomError;
@@ -3091,6 +3101,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 				return Common::kReadingFailed;
 			break;
 		}
+		if (startupFlow.hasPendingDebugRoomChange()) {
+			if (!stowCarriedRoomItemToInventory())
+				return Common::kReadingFailed;
+			return Common::kNoError;
+		}
 		if (!pendingRoomChange.empty()) {
 			if (!stowCarriedRoomItemToInventory())
 				return Common::kReadingFailed;
@@ -3934,6 +3949,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &e
 		return Common::kNoError;
 
 	currentRoomTarget = pendingRoomChange;
+	currentTargetIsRoomName = false;
 	}
 
 	return Common::kNoError;
diff --git a/engines/harvester/room.h b/engines/harvester/room.h
index 47848b8ddf8..20603093eb9 100644
--- a/engines/harvester/room.h
+++ b/engines/harvester/room.h
@@ -36,7 +36,7 @@ class RoomSystem {
 public:
 	RoomSystem(HarvesterEngine &engine, Common::Point &mousePos, InventorySystem &inventory);
 
-	Common::Error runRoomLoop(Flow &startupFlow, const Common::String &entranceName);
+	Common::Error runRoomLoop(Flow &startupFlow, const Common::String &targetName, bool targetIsRoomName);
 
 private:
 	HarvesterEngine &_engine;
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 68870d885bd..7a9d2624c81 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1100,6 +1100,68 @@ bool Script::resolveRoomSetupState(const Common::String &entranceName, StartupRo
 	return true;
 }
 
+bool Script::resolveRoomSetupStateByRoomName(const Common::String &roomName, StartupRoomSetupState &state,
+		ResourceManager &resources) {
+	state = StartupRoomSetupState();
+
+	const StartupRoomRecord *room = findRoomRecord(roomName);
+	if (!room) {
+		warning("Harvester: unresolved room setup room '%s'", roomName.c_str());
+		return false;
+	}
+
+	const StartupEntranceRecord *entrance = nullptr;
+	for (const StartupEntranceRecord &candidate : _entrances) {
+		if (!candidate.roomName.equalsIgnoreCase(room->roomName))
+			continue;
+
+		entrance = &candidate;
+		break;
+	}
+
+	Common::String musicPath;
+	Common::Array<StartupAudioCommand> audioCommands;
+	bool mutatedRuntimeState = false;
+	executeCommandChain(
+		room->onEnterCommand, "room setup command", room->roomName, false,
+		&musicPath,
+		&audioCommands,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		nullptr,
+		&mutatedRuntimeState);
+
+	if (!buildRuntimeRoomState(*room, entrance, resources, state))
+		return false;
+
+	state.audioCommands = audioCommands;
+	if (!musicPath.empty())
+		state.musicPath = musicPath;
+
+	debugC(1, kDebugRoom,
+		"Harvester: resolveRoomSetupStateByRoomName('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u roomNpcs=%u roomMonsters=%u roomTimers=%u roomRegions=%u mutated=%d",
+		roomName.c_str(), state.roomName.c_str(), state.entranceName.c_str(),
+		state.playerSpawnX, state.playerSpawnY, state.playerSpawnZ, state.playerFacing,
+		state.palettePath.c_str(), state.backgroundPath.c_str(), state.musicPath.c_str(),
+		(double)state.paletteBrightness, (uint)state.roomObjects.size(),
+		(uint)state.activeObjects.size(), (uint)state.roomAnimations.size(), (uint)state.roomNpcs.size(),
+		(uint)state.roomMonsters.size(), (uint)state.roomTimers.size(),
+		(uint)state.roomRegions.size(), mutatedRuntimeState);
+
+	return true;
+}
+
 void Script::resetRuntimeState() {
 	_runtimeFlags = _flags;
 	_runtimeObjects = _objects;
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index c296b802f3e..02baccae496 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -348,6 +348,8 @@ public:
 	void syncRuntimeSaveState(Common::Serializer &s);
 	bool resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
 		ResourceManager &resources);
+	bool resolveRoomSetupStateByRoomName(const Common::String &roomName, StartupRoomSetupState &state,
+		ResourceManager &resources);
 	bool materializeRoomState(const Common::String &entranceName, const Common::String &roomName,
 		StartupRoomSetupState &state, ResourceManager &resources) const;
 	bool executeRoomExitCommands(const Common::String &roomName, StartupInteractionResult &result);


Commit: 05ed09c06ada2e614c4c998006fa05b5ccbd3cfa
    https://github.com/scummvm/scummvm/commit/05ed09c06ada2e614c4c998006fa05b5ccbd3cfa
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:23+02:00

Commit Message:
HARVESTER: Support prefixed multi-cd archives

Changed paths:
    engines/harvester/dialogue.cpp
    engines/harvester/resources.cpp
    engines/harvester/resources.h
    engines/harvester/room.cpp
    engines/harvester/saveload.cpp


diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 2f52630454c..a0cfc55914f 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -885,78 +885,89 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		return Common::kNoError;
 	};
 	auto showCdChangePrompt = [&](int discNumber) -> Common::Error {
-		if (discNumber <= 0 || !_engine.shouldShowCdChangePrompts())
+		if (discNumber <= 0)
 			return Common::kNoError;
 
 		ResourceManager *resources = _engine.getResources();
-		Graphics::Screen *screen = _engine.getScreen();
-		if (!resources || !screen)
-			return Common::kReadingFailed;
-
-		const Common::String bitmapPath =
-			Common::String::format("1:/GRAPHIC/OTHER/CD%d.BM", discNumber);
-		IndexedBitmap promptBitmap;
-		byte promptPalette[256 * 3];
-		if (!loadBitmapResource(*resources, bitmapPath, promptBitmap) ||
-				!loadPaletteResource(*resources, kCdChangePromptPalettePath, promptPalette)) {
-			warning("Harvester: unable to load CD change prompt assets for disc %d", discNumber);
+		if (!resources)
 			return Common::kReadingFailed;
-		}
-
-		_engine.stopStartupMusic();
 
-		Common::Event event;
-		while (g_system->getEventManager()->pollEvent(event)) {
-			Common::Error result = Common::kNoError;
-			if (startupFlow.handleSystemEvent(event, result))
-				return result;
-			if (event.type == Common::EVENT_MOUSEMOVE)
-				_mousePos = event.mouse;
-		}
-
-		bool waitingForRelease = false;
-		bool needsRedraw = true;
-		Graphics::FrameLimiter limiter(g_system, 60);
-
-		while (!_engine.shouldQuit()) {
-			if (needsRedraw) {
-				screen->fillRect(screen->getBounds(), 0);
-				blitBitmap(*screen, promptBitmap, 0, 0);
-				setScaledPalette(*screen, promptPalette, 1.0f);
-				if (runtimeEntities)
-					runtimeEntities->drawCursor(*screen);
-				screen->makeAllDirty();
-				screen->update();
-				needsRedraw = false;
+		if (_engine.shouldShowCdChangePrompts()) {
+			Graphics::Screen *screen = _engine.getScreen();
+			if (!screen)
+				return Common::kReadingFailed;
+
+			const Common::String bitmapPath =
+				Common::String::format("1:/GRAPHIC/OTHER/CD%d.BM", discNumber);
+			IndexedBitmap promptBitmap;
+			byte promptPalette[256 * 3];
+			if (!loadBitmapResource(*resources, bitmapPath, promptBitmap) ||
+					!loadPaletteResource(*resources, kCdChangePromptPalettePath, promptPalette)) {
+				warning("Harvester: unable to load CD change prompt assets for disc %d", discNumber);
+				return Common::kReadingFailed;
 			}
 
+			_engine.stopStartupMusic();
+
+			Common::Event event;
 			while (g_system->getEventManager()->pollEvent(event)) {
 				Common::Error result = Common::kNoError;
 				if (startupFlow.handleSystemEvent(event, result))
 					return result;
-
-				switch (event.type) {
-				case Common::EVENT_MOUSEMOVE:
+				if (event.type == Common::EVENT_MOUSEMOVE)
 					_mousePos = event.mouse;
-					needsRedraw = true;
-					break;
-				case Common::EVENT_LBUTTONDOWN:
-					waitingForRelease = true;
-					break;
-				case Common::EVENT_LBUTTONUP:
-					if (waitingForRelease)
-						return Common::kNoError;
-					break;
-				default:
-					break;
-				}
 			}
 
-			if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
-				needsRedraw = true;
+			bool waitingForRelease = false;
+			bool needsRedraw = true;
+			bool activateRequestedDisc = false;
+			Graphics::FrameLimiter limiter(g_system, 60);
+
+			while (!_engine.shouldQuit() && !activateRequestedDisc) {
+				if (needsRedraw) {
+					screen->fillRect(screen->getBounds(), 0);
+					blitBitmap(*screen, promptBitmap, 0, 0);
+					setScaledPalette(*screen, promptPalette, 1.0f);
+					if (runtimeEntities)
+						runtimeEntities->drawCursor(*screen);
+					screen->makeAllDirty();
+					screen->update();
+					needsRedraw = false;
+				}
 
-			limiter.delayBeforeSwap();
-			limiter.startFrame();
+				while (g_system->getEventManager()->pollEvent(event)) {
+					Common::Error result = Common::kNoError;
+					if (startupFlow.handleSystemEvent(event, result))
+						return result;
+
+					switch (event.type) {
+					case Common::EVENT_MOUSEMOVE:
+						_mousePos = event.mouse;
+						needsRedraw = true;
+						break;
+					case Common::EVENT_LBUTTONDOWN:
+						waitingForRelease = true;
+						break;
+					case Common::EVENT_LBUTTONUP:
+						if (waitingForRelease)
+							activateRequestedDisc = true;
+						break;
+					default:
+						break;
+					}
+				}
+
+				if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
+					needsRedraw = true;
+
+				limiter.delayBeforeSwap();
+				limiter.startFrame();
+			}
+		}
+
+		if (!resources->setCurrentDisc(discNumber)) {
+			warning("Harvester: unable to activate disc %d resources", discNumber);
+			return Common::kReadingFailed;
 		}
 
 		return Common::kNoError;
diff --git a/engines/harvester/resources.cpp b/engines/harvester/resources.cpp
index a208e714972..80a91ac875b 100644
--- a/engines/harvester/resources.cpp
+++ b/engines/harvester/resources.cpp
@@ -28,6 +28,90 @@
 
 namespace Harvester {
 
+namespace {
+
+struct ArchiveSpec {
+	char archiveSetId;
+	const char *indexPath;
+	const char *dataPath;
+	int priority;
+};
+
+static const ArchiveSpec kArchiveSpecs[] = {
+	{ '1', "INDEX.001", "HARVEST.DAT", 30 },
+	{ '2', "INDEX.002", "SOUND.DAT", 29 },
+	{ '3', "INDEX.003", "HARVEST2.DAT", 28 }
+};
+
+static const int kFirstDiscNumber = 1;
+static const int kLastDiscNumber = 3;
+
+static bool hasArchiveSetPrefix(const Common::String &path) {
+	return path.size() >= 3 &&
+		path[0] >= '0' && path[0] <= '9' &&
+		path[1] == ':' && path[2] == '/';
+}
+
+static const ArchiveSpec *findArchiveSpec(char archiveSetId) {
+	for (const ArchiveSpec &spec : kArchiveSpecs) {
+		if (spec.archiveSetId == archiveSetId)
+			return &spec;
+	}
+
+	return nullptr;
+}
+
+static Common::String stripLeadingSlashes(const Common::String &path) {
+	Common::String normalized = path;
+	while (!normalized.empty() && normalized[0] == '/')
+		normalized.erase(0, 1);
+	return normalized;
+}
+
+static Common::String buildDiscArchiveName(int discNumber, char archiveSetId) {
+	return Common::String::format("harvester-disc-%d-xfile-%c", discNumber, archiveSetId);
+}
+
+static Common::String buildDiscPrefixedFilePath(int discNumber, const char *path) {
+	return Common::String::format("%d%s", discNumber, path);
+}
+
+static Common::String resolveDiscArchiveStoragePath(int discNumber, const char *path) {
+	const Common::String prefixedPath = buildDiscPrefixedFilePath(discNumber, path);
+	if (SearchMan.hasFile(Common::Path(prefixedPath, '/')))
+		return prefixedPath;
+	if (discNumber == kFirstDiscNumber && SearchMan.hasFile(Common::Path(path, '/')))
+		return path;
+	return Common::String();
+}
+
+static Common::String normalizeHarvesterLookupPath(const Common::String &path) {
+	Common::String normalized(path);
+
+	for (uint i = 0; i < normalized.size(); ++i) {
+		if (normalized[i] == '\\')
+			normalized.setChar('/', i);
+	}
+
+	while (normalized.hasPrefix("./"))
+		normalized.erase(0, 2);
+
+	if (hasArchiveSetPrefix(normalized)) {
+		const char archiveSetId = normalized[0];
+		Common::String memberPath = stripLeadingSlashes(normalized.substr(3));
+		return memberPath.empty()
+			? Common::String::format("%c:/", archiveSetId)
+			: Common::String::format("%c:/%s", archiveSetId, memberPath.c_str());
+	}
+
+	if (normalized.size() >= 3 && normalized[1] == ':' && normalized[2] == '/')
+		normalized.erase(0, 3);
+
+	return stripLeadingSlashes(normalized);
+}
+
+} // End of anonymous namespace
+
 Common::String normalizeHarvesterResourcePath(const Common::String &path) {
 	Common::String normalized(path);
 
@@ -58,45 +142,137 @@ ResourceManager::~ResourceManager() {
 
 void ResourceManager::reset() {
 	_search.clear();
+	_currentDisc = 1;
 	_search.add("harvester-loose-files", &SearchMan, 0, false);
 }
 
 Common::String ResourceManager::normalizeResourcePath(const Common::String &path) const {
-	return normalizeHarvesterResourcePath(path);
+	return normalizeHarvesterLookupPath(path);
 }
 
 bool ResourceManager::mountStartupArchives() {
-	static const struct {
-		const char *archiveName;
-		const char *indexPath;
-		const char *dataPath;
-		int priority;
-	} kArchiveSpecs[] = {
-		{ "harvester-xfile-1", "INDEX.001", "HARVEST.DAT", 30 },
-		{ "harvester-xfile-2", "INDEX.002", "SOUND.DAT", 29 },
-		{ "harvester-xfile-3", "INDEX.003", "HARVEST2.DAT", 28 }
-	};
+	bool mountedAny = false;
+	for (int discNumber = kFirstDiscNumber; discNumber <= kLastDiscNumber; ++discNumber) {
+		if (ensureDiscMounted(discNumber))
+			mountedAny = true;
+	}
+
+	return mountedAny;
+}
+
+bool ResourceManager::setCurrentDisc(int discNumber) {
+	if (discNumber <= 0)
+		return false;
+	if (!ensureDiscMounted(discNumber))
+		return false;
+
+	_currentDisc = discNumber;
+	debugC(1, kDebugResources, "Harvester: switched active disc to %d", discNumber);
+	return true;
+}
+
+bool ResourceManager::ensureDiscMounted(int discNumber) {
+	if (discNumber <= 0)
+		return false;
 
 	bool mountedAny = false;
-	for (const auto &spec : kArchiveSpecs) {
+	for (const ArchiveSpec &spec : kArchiveSpecs) {
+		if (getMountedDiscArchive(discNumber, spec.archiveSetId)) {
+			mountedAny = true;
+			continue;
+		}
+
+		const Common::String indexPath = resolveDiscArchiveStoragePath(discNumber, spec.indexPath);
+		const Common::String dataPath = resolveDiscArchiveStoragePath(discNumber, spec.dataPath);
+		if (indexPath.empty() || dataPath.empty()) {
+			debugC(1, kDebugResources,
+				"Harvester: missing archive files for disc %d set %c (%s + %s)",
+				discNumber, spec.archiveSetId, spec.indexPath, spec.dataPath);
+			continue;
+		}
+
 		XFileArchive *archive = new XFileArchive();
-		if (!archive->open(spec.indexPath, spec.dataPath)) {
-			debugC(1, kDebugResources, "Harvester: failed to mount %s + %s", spec.indexPath, spec.dataPath);
+		if (!archive->open(indexPath, dataPath)) {
+			debugC(1, kDebugResources,
+				"Harvester: failed to mount disc %d set %c from %s + %s",
+				discNumber, spec.archiveSetId, indexPath.c_str(), dataPath.c_str());
 			delete archive;
 			continue;
 		}
 
-		mountArchive(spec.archiveName, archive, spec.priority, true);
-		debugC(1, kDebugResources, "Harvester: mounted %s + %s", spec.indexPath, spec.dataPath);
+		mountArchive(buildDiscArchiveName(discNumber, spec.archiveSetId), archive, spec.priority, true);
+		debugC(1, kDebugResources,
+			"Harvester: mounted disc %d set %c from %s + %s",
+			discNumber, spec.archiveSetId, indexPath.c_str(), dataPath.c_str());
 		mountedAny = true;
 	}
 
 	return mountedAny;
 }
 
+Common::Archive *ResourceManager::getMountedDiscArchive(int discNumber, char archiveSetId) const {
+	return _search.getArchive(buildDiscArchiveName(discNumber, archiveSetId));
+}
+
+Common::Archive *ResourceManager::findArchiveForMember(char archiveSetId, const Common::Path &memberPath) const {
+	Common::Archive *archive = getMountedDiscArchive(_currentDisc, archiveSetId);
+	if (archive && archive->hasFile(memberPath))
+		return archive;
+
+	for (int discNumber = kFirstDiscNumber; discNumber <= kLastDiscNumber; ++discNumber) {
+		if (discNumber == _currentDisc)
+			continue;
+
+		archive = getMountedDiscArchive(discNumber, archiveSetId);
+		if (archive && archive->hasFile(memberPath))
+			return archive;
+	}
+
+	return nullptr;
+}
+
+bool ResourceManager::hasInMountedArchives(const Common::Path &memberPath) const {
+	for (const ArchiveSpec &spec : kArchiveSpecs) {
+		if (findArchiveForMember(spec.archiveSetId, memberPath))
+			return true;
+	}
+
+	return false;
+}
+
+Common::SeekableReadStream *ResourceManager::openFromMountedArchives(const Common::Path &memberPath) const {
+	for (const ArchiveSpec &spec : kArchiveSpecs) {
+		Common::Archive *archive = findArchiveForMember(spec.archiveSetId, memberPath);
+		if (!archive)
+			continue;
+
+		Common::SeekableReadStream *stream = archive->createReadStreamForMember(memberPath);
+		if (stream)
+			return stream;
+	}
+
+	return nullptr;
+}
+
 bool ResourceManager::hasFile(const Common::String &path) const {
 	const Common::String normalized = normalizeResourcePath(path);
-	return !normalized.empty() && _search.hasFile(Common::Path(normalized, '/'));
+	if (normalized.empty())
+		return false;
+
+	if (hasArchiveSetPrefix(normalized)) {
+		const ArchiveSpec *spec = findArchiveSpec(normalized[0]);
+		const Common::String memberPath = stripLeadingSlashes(normalized.substr(3));
+		if (!spec || memberPath.empty())
+			return false;
+
+		return findArchiveForMember(spec->archiveSetId, Common::Path(memberPath, '/')) != nullptr;
+	}
+
+	const Common::Path memberPath(normalized, '/');
+	if (hasInMountedArchives(memberPath))
+		return true;
+
+	return SearchMan.hasFile(memberPath);
 }
 
 Common::SeekableReadStream *ResourceManager::openFile(const Common::String &path) const {
@@ -104,7 +280,20 @@ Common::SeekableReadStream *ResourceManager::openFile(const Common::String &path
 	if (normalized.empty())
 		return nullptr;
 
-	Common::SeekableReadStream *stream = _search.createReadStreamForMember(Common::Path(normalized, '/'));
+	Common::SeekableReadStream *stream = nullptr;
+	if (hasArchiveSetPrefix(normalized)) {
+		const ArchiveSpec *spec = findArchiveSpec(normalized[0]);
+		const Common::String memberPath = stripLeadingSlashes(normalized.substr(3));
+		Common::Archive *archive = spec ? findArchiveForMember(spec->archiveSetId, Common::Path(memberPath, '/')) : nullptr;
+		if (archive && !memberPath.empty())
+			stream = archive->createReadStreamForMember(Common::Path(memberPath, '/'));
+	} else {
+		const Common::Path memberPath(normalized, '/');
+		stream = openFromMountedArchives(memberPath);
+		if (!stream)
+			stream = SearchMan.createReadStreamForMember(memberPath);
+	}
+
 	debugC(3, kDebugResources, "Harvester: openFile('%s' -> '%s') %s", path.c_str(), normalized.c_str(), stream ? "hit" : "miss");
 	return stream;
 }
diff --git a/engines/harvester/resources.h b/engines/harvester/resources.h
index dc79c4735e6..df51959ae96 100644
--- a/engines/harvester/resources.h
+++ b/engines/harvester/resources.h
@@ -36,6 +36,8 @@ public:
 
 	void reset();
 	bool mountStartupArchives();
+	bool setCurrentDisc(int discNumber);
+	int getCurrentDisc() const { return _currentDisc; }
 
 	bool hasFile(const Common::String &path) const;
 	Common::SeekableReadStream *openFile(const Common::String &path) const;
@@ -45,7 +47,13 @@ public:
 	void mountArchive(const Common::String &name, Common::Archive *archive, int priority = 10, bool autoFree = true);
 
 private:
+	bool ensureDiscMounted(int discNumber);
+	Common::Archive *getMountedDiscArchive(int discNumber, char archiveSetId) const;
+	Common::Archive *findArchiveForMember(char archiveSetId, const Common::Path &memberPath) const;
+	bool hasInMountedArchives(const Common::Path &memberPath) const;
+	Common::SeekableReadStream *openFromMountedArchives(const Common::Path &memberPath) const;
 	Common::SearchSet _search;
+	int _currentDisc = 1;
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index d4ba75d1634..e5051caf4d3 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1584,73 +1584,84 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			return captureScreenBackdrop(*activeScreen, dialogueBackdrop);
 		};
 		auto showCdChangePrompt = [&](int discNumber) -> Common::Error {
-			if (discNumber <= 0 || !_engine.shouldShowCdChangePrompts())
+			if (discNumber <= 0)
 				return Common::kNoError;
 
 			ResourceManager *resources = _engine.getResources();
-			Graphics::Screen *screen = _engine.getScreen();
-			if (!resources || !screen)
+			if (!resources)
 				return Common::kReadingFailed;
 
-			const Common::String bitmapPath = Common::String::format("1:/GRAPHIC/OTHER/CD%d.BM", discNumber);
-			IndexedBitmap promptBitmap;
-			byte promptPalette[256 * 3];
-			if (!loadBitmapResource(*resources, bitmapPath, promptBitmap) ||
-					!loadPaletteResource(*resources, kCdChangePromptPalettePath, promptPalette)) {
-				warning("Harvester: unable to load CD change prompt assets for disc %d", discNumber);
-				return Common::kReadingFailed;
-			}
-
-			debugC(1, kDebugScene, "Harvester: showing CD change prompt for disc %d", discNumber);
-			_engine.stopStartupMusic();
-
-			Common::Event event;
-			while (g_system->getEventManager()->pollEvent(event)) {
-				Common::Error result = Common::kNoError;
-				if (startupFlow.handleSystemEvent(event, result))
-					return result;
-				if (event.type == Common::EVENT_MOUSEMOVE)
-					_mousePos = event.mouse;
-			}
-
-			bool waitingForRelease = false;
-			bool needsRedraw = true;
-			Graphics::FrameLimiter limiter(g_system, 60);
+			if (_engine.shouldShowCdChangePrompts()) {
+				Graphics::Screen *screen = _engine.getScreen();
+				if (!screen)
+					return Common::kReadingFailed;
 
-			while (!_engine.shouldQuit()) {
-				if (needsRedraw) {
-					renderCdChangePromptScreen(_engine, promptBitmap, promptPalette);
-					needsRedraw = false;
+				const Common::String bitmapPath = Common::String::format("1:/GRAPHIC/OTHER/CD%d.BM", discNumber);
+				IndexedBitmap promptBitmap;
+				byte promptPalette[256 * 3];
+				if (!loadBitmapResource(*resources, bitmapPath, promptBitmap) ||
+						!loadPaletteResource(*resources, kCdChangePromptPalettePath, promptPalette)) {
+					warning("Harvester: unable to load CD change prompt assets for disc %d", discNumber);
+					return Common::kReadingFailed;
 				}
 
+				debugC(1, kDebugScene, "Harvester: showing CD change prompt for disc %d", discNumber);
+				_engine.stopStartupMusic();
+
+				Common::Event event;
 				while (g_system->getEventManager()->pollEvent(event)) {
 					Common::Error result = Common::kNoError;
 					if (startupFlow.handleSystemEvent(event, result))
 						return result;
-
-					switch (event.type) {
-					case Common::EVENT_MOUSEMOVE:
+					if (event.type == Common::EVENT_MOUSEMOVE)
 						_mousePos = event.mouse;
-						needsRedraw = true;
-						break;
-					case Common::EVENT_LBUTTONDOWN:
-						waitingForRelease = true;
-						break;
-					case Common::EVENT_LBUTTONUP:
-						if (waitingForRelease)
-							return Common::kNoError;
-						break;
-					default:
-						break;
-					}
 				}
 
-				RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
-				if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
-					needsRedraw = true;
+				bool waitingForRelease = false;
+				bool needsRedraw = true;
+				bool activateRequestedDisc = false;
+				Graphics::FrameLimiter limiter(g_system, 60);
 
-				limiter.delayBeforeSwap();
-				limiter.startFrame();
+				while (!_engine.shouldQuit() && !activateRequestedDisc) {
+					if (needsRedraw) {
+						renderCdChangePromptScreen(_engine, promptBitmap, promptPalette);
+						needsRedraw = false;
+					}
+
+					while (g_system->getEventManager()->pollEvent(event)) {
+						Common::Error result = Common::kNoError;
+						if (startupFlow.handleSystemEvent(event, result))
+							return result;
+
+						switch (event.type) {
+						case Common::EVENT_MOUSEMOVE:
+							_mousePos = event.mouse;
+							needsRedraw = true;
+							break;
+						case Common::EVENT_LBUTTONDOWN:
+							waitingForRelease = true;
+							break;
+						case Common::EVENT_LBUTTONUP:
+							if (waitingForRelease)
+								activateRequestedDisc = true;
+							break;
+						default:
+							break;
+						}
+					}
+
+					RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+					if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
+						needsRedraw = true;
+
+					limiter.delayBeforeSwap();
+					limiter.startFrame();
+				}
+			}
+
+			if (!resources->setCurrentDisc(discNumber)) {
+				warning("Harvester: unable to activate disc %d resources", discNumber);
+				return Common::kReadingFailed;
 			}
 
 			return Common::kNoError;
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index 85cff7dd33d..0cb474286f2 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -39,7 +39,7 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 }
 
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
-static const uint32 kHarvesterSaveVersion = 13;
+static const uint32 kHarvesterSaveVersion = 14;
 
 static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
 	debugC(1, kDebugRoom,
@@ -95,6 +95,9 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 	if (!s.syncVersion(kHarvesterSaveVersion))
 		return Common::kReadingFailed;
 
+	int32 currentDisc = (_resources && _resources->getCurrentDisc() > 0) ? _resources->getCurrentDisc() : 1;
+	s.syncAsSint32LE(currentDisc, 14);
+
 	StartupSaveRoomState roomState = s.isLoading()
 		? StartupSaveRoomState()
 		: _currentStartupSaveRoomState;
@@ -128,6 +131,8 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 			return s.isLoading() ? Common::kReadingFailed : Common::kWritingFailed;
 
 	if (s.isLoading()) {
+		if (_resources && !(_resources->setCurrentDisc(currentDisc > 0 ? currentDisc : 1)))
+			return Common::kReadingFailed;
 		if (!roomState.valid || roomState.roomName.empty())
 			return Common::kReadingFailed;
 		_currentStartupSaveRoomState = roomState;


Commit: 0e411cbd7182bca46feb0276aea669fcbd2aac77
    https://github.com/scummvm/scummvm/commit/0e411cbd7182bca46feb0276aea669fcbd2aac77
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:24+02:00

Commit Message:
HARVESTER: Add DEBUG_ACTIONS console command

Changed paths:
    engines/harvester/console.cpp
    engines/harvester/console.h
    engines/harvester/harvester.h


diff --git a/engines/harvester/console.cpp b/engines/harvester/console.cpp
index 6f7719252fc..4d88c63ed1d 100644
--- a/engines/harvester/console.cpp
+++ b/engines/harvester/console.cpp
@@ -23,15 +23,252 @@
 
 #include "common/algorithm.h"
 #include "harvester/harvester.h"
+#include "harvester/resources.h"
 
 namespace Harvester {
 
 namespace {
 
+static const uint kMaxDebugActionChainSteps = 128;
+static const uint kMaxDebugActionChainLines = 256;
+static const uint kMaxDebugActionBranchDepth = 16;
+
 static Script *getActiveStartupScript() {
 	return g_engine ? g_engine->getStartupScript() : nullptr;
 }
 
+static const StartupCommandRecord *findCommandRecord(const Script &script, const Common::String &tag) {
+	if (tag.empty())
+		return nullptr;
+
+	for (const StartupCommandRecord &command : script.getCommands()) {
+		if (command.triggerTag.equalsIgnoreCase(tag))
+			return &command;
+	}
+
+	return nullptr;
+}
+
+static const StartupExecListRecord *findExecListRecord(const Script &script, const Common::String &listName) {
+	if (listName.empty())
+		return nullptr;
+
+	for (const StartupExecListRecord &execList : script.getExecLists()) {
+		if (execList.listName.equalsIgnoreCase(listName))
+			return &execList;
+	}
+
+	return nullptr;
+}
+
+static bool appendStartupAudioCommandLabel(const StartupCommandRecord &command, Common::String &label) {
+	if (command.opcodeName.equalsIgnoreCase("START_WAV")) {
+		label = Common::String::format("path='%s'", command.arg1.c_str());
+		return true;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("START_SINGLE_WAV")) {
+		label = Common::String::format("path='%s'", command.arg1.c_str());
+		return true;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("LOAD_WAV")) {
+		label = Common::String::format("path='%s' slot='%s'", command.arg1.c_str(), command.arg2.c_str());
+		return true;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("PLAY_WAV")) {
+		label = Common::String::format("slot='%s'", command.arg1.c_str());
+		return true;
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("DELETE_WAV")) {
+		label = Common::String::format("slot='%s'", command.arg1.c_str());
+		return true;
+	}
+
+	return false;
+}
+
+static Common::String makeIndent(uint depth) {
+	Common::String indent;
+	for (uint i = 0; i < depth; ++i)
+		indent += "  ";
+
+	return indent;
+}
+
+static void appendDebugActionLine(Common::Array<Common::String> &lines, bool &truncated,
+		uint depth, const Common::String &line) {
+	if (truncated)
+		return;
+
+	if (lines.size() >= kMaxDebugActionChainLines) {
+		lines.push_back(makeIndent(depth) + "... command chain truncated ...");
+		truncated = true;
+		return;
+	}
+
+	lines.push_back(makeIndent(depth) + line);
+}
+
+static bool containsTagIgnoreCase(const Common::Array<Common::String> &tags, const Common::String &tag) {
+	for (const Common::String &candidate : tags) {
+		if (candidate.equalsIgnoreCase(tag))
+			return true;
+	}
+
+	return false;
+}
+
+static Common::String buildCommandDetail(const StartupCommandRecord &command) {
+	if (command.opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
+		return Common::String::format("flag='%s' true='%s' false='%s'",
+			command.arg1.c_str(), command.arg2.c_str(), command.arg3.c_str());
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("CHECK_PERC")) {
+		return Common::String::format("threshold='%s' success='%s' failure='%s'",
+			command.arg1.c_str(), command.arg2.c_str(), command.arg3.c_str());
+	}
+
+	if (command.opcodeName.equalsIgnoreCase("EXEC_LIST"))
+		return Common::String::format("list='%s' next='%s'", command.arg1.c_str(), command.arg4.c_str());
+
+	Common::String audioDetail;
+	if (appendStartupAudioCommandLabel(command, audioDetail))
+		return audioDetail + Common::String::format(" next='%s'", command.arg4.c_str());
+
+	if (!command.arg1.empty() || !command.arg2.empty() || !command.arg3.empty() || !command.arg4.empty()) {
+		return Common::String::format("args=['%s','%s','%s','%s']",
+			command.arg1.c_str(), command.arg2.c_str(), command.arg3.c_str(), command.arg4.c_str());
+	}
+
+	return Common::String();
+}
+
+static bool isTerminalDebugCommand(const StartupCommandRecord &command) {
+	return command.opcodeName.equalsIgnoreCase("START_DIALOG") ||
+		command.opcodeName.equalsIgnoreCase("GOFLIC") ||
+		command.opcodeName.equalsIgnoreCase("GODEATHFLIC") ||
+		command.opcodeName.equalsIgnoreCase("SHOW_TEXT") ||
+		command.opcodeName.equalsIgnoreCase("PC_GOTO_XZ") ||
+		command.opcodeName.equalsIgnoreCase("CHANGE_LIGHTING");
+}
+
+static void appendCommandChain(Common::Array<Common::String> &lines, bool &truncated,
+		const Script &script, const Common::String &tag, uint depth,
+		Common::Array<Common::String> &activeTags, uint &remainingSteps) {
+	if (truncated || tag.empty())
+		return;
+
+	if (remainingSteps == 0) {
+		appendDebugActionLine(lines, truncated, depth,
+			Common::String::format("tag '%s': step limit reached", tag.c_str()));
+		return;
+	}
+
+	if (depth > kMaxDebugActionBranchDepth) {
+		appendDebugActionLine(lines, truncated, depth,
+			Common::String::format("tag '%s': branch depth limit reached", tag.c_str()));
+		return;
+	}
+
+	if (containsTagIgnoreCase(activeTags, tag)) {
+		appendDebugActionLine(lines, truncated, depth,
+			Common::String::format("tag '%s': loop detected", tag.c_str()));
+		return;
+	}
+
+	activeTags.push_back(tag);
+	--remainingSteps;
+
+	const StartupCommandRecord *command = findCommandRecord(script, tag);
+	if (!command) {
+		appendDebugActionLine(lines, truncated, depth,
+			Common::String::format("tag '%s': unresolved", tag.c_str()));
+		activeTags.pop_back();
+		return;
+	}
+
+	Common::String line = Common::String::format("tag '%s': %s", tag.c_str(), command->opcodeName.c_str());
+	const Common::String detail = buildCommandDetail(*command);
+	if (!detail.empty())
+		line += " " + detail;
+	if (isTerminalDebugCommand(*command))
+		line += " [terminal]";
+	appendDebugActionLine(lines, truncated, depth, line);
+
+	if (command->opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
+		const bool flagValue = script.getFlagValue(command->arg1);
+		const Common::String &nextTag = flagValue ? command->arg2 : command->arg3;
+		appendDebugActionLine(lines, truncated, depth + 1,
+			Common::String::format("resolved flag '%s'=%d -> '%s'",
+				command->arg1.c_str(), flagValue, nextTag.c_str()));
+		appendCommandChain(lines, truncated, script, nextTag, depth + 1, activeTags, remainingSteps);
+		activeTags.pop_back();
+		return;
+	}
+
+	if (command->opcodeName.equalsIgnoreCase("CHECK_PERC")) {
+		if (!command->arg2.empty()) {
+			appendDebugActionLine(lines, truncated, depth + 1,
+				Common::String::format("success branch -> '%s'", command->arg2.c_str()));
+			appendCommandChain(lines, truncated, script, command->arg2, depth + 2, activeTags, remainingSteps);
+		}
+		if (!command->arg3.empty()) {
+			appendDebugActionLine(lines, truncated, depth + 1,
+				Common::String::format("failure branch -> '%s'", command->arg3.c_str()));
+			appendCommandChain(lines, truncated, script, command->arg3, depth + 2, activeTags, remainingSteps);
+		}
+		activeTags.pop_back();
+		return;
+	}
+
+	if (command->opcodeName.equalsIgnoreCase("EXEC_LIST")) {
+		const StartupExecListRecord *execList = findExecListRecord(script, command->arg1);
+		if (!execList) {
+			appendDebugActionLine(lines, truncated, depth + 1,
+				Common::String::format("exec list '%s': unresolved", command->arg1.c_str()));
+		} else if (execList->entries.empty()) {
+			appendDebugActionLine(lines, truncated, depth + 1,
+				Common::String::format("exec list '%s': empty", execList->listName.c_str()));
+		} else {
+			for (uint i = 0; i < execList->entries.size(); ++i) {
+				const Common::String &entry = execList->entries[i];
+				appendDebugActionLine(lines, truncated, depth + 1,
+					Common::String::format("entry[%u] -> '%s'", i, entry.c_str()));
+				appendCommandChain(lines, truncated, script, entry, depth + 2, activeTags, remainingSteps);
+			}
+		}
+
+		if (!command->arg4.empty())
+			appendCommandChain(lines, truncated, script, command->arg4, depth, activeTags, remainingSteps);
+		activeTags.pop_back();
+		return;
+	}
+
+	if (!isTerminalDebugCommand(*command) && !command->arg4.empty())
+		appendCommandChain(lines, truncated, script, command->arg4, depth, activeTags, remainingSteps);
+
+	activeTags.pop_back();
+}
+
+static void buildActionChainLines(const Script &script, const Common::String &tag,
+		Common::Array<Common::String> &lines) {
+	lines.clear();
+
+	if (tag.empty()) {
+		lines.push_back("  <no action tag>");
+		return;
+	}
+
+	bool truncated = false;
+	uint remainingSteps = kMaxDebugActionChainSteps;
+	Common::Array<Common::String> activeTags;
+	appendCommandChain(lines, truncated, script, tag, 1, activeTags, remainingSteps);
+}
+
 static void collectSortedRoomNames(const Script &script, Common::Array<Common::String> &roomNames) {
 	roomNames.clear();
 
@@ -73,6 +310,7 @@ static bool findRoomName(const Script &script, const Common::String &candidate,
 Console::Console() : GUI::Debugger() {
 	registerCmd("about", WRAP_METHOD(Console, Cmd_about));
 	registerCmd("DEBUG_COMBAT", WRAP_METHOD(Console, Cmd_debugCombat));
+	registerCmd("DEBUG_ACTIONS", WRAP_METHOD(Console, Cmd_debugActions));
 	registerCmd("DEBUG_ROOM", WRAP_METHOD(Console, Cmd_debugRoom));
 	registerCmd("GOTO_ROOM", WRAP_METHOD(Console, Cmd_gotoRoom));
 }
@@ -117,6 +355,72 @@ bool Console::Cmd_debugRoom(int argc, const char **argv) {
 	return true;
 }
 
+bool Console::Cmd_debugActions(int argc, const char **argv) {
+	if (argc != 1) {
+		debugPrintf("Usage: DEBUG_ACTIONS\n");
+		return true;
+	}
+
+	if (!g_engine) {
+		debugPrintf("Harvester engine is not active\n");
+		return true;
+	}
+
+	Script *startupScript = g_engine->getStartupScript();
+	if (!startupScript || !g_engine->hasCurrentStartupSaveRoomState()) {
+		debugPrintf("DEBUG_ACTIONS is only available while a room is active\n");
+		return true;
+	}
+
+	ResourceManager *resources = g_engine->getResources();
+	if (!resources) {
+		debugPrintf("Harvester resources are not available\n");
+		return true;
+	}
+
+	const StartupSaveRoomState &roomState = g_engine->getCurrentStartupSaveRoomState();
+	StartupRoomSetupState materializedState;
+	if (!startupScript->materializeRoomState(roomState.entranceName, roomState.roomName,
+			materializedState, *resources)) {
+		debugPrintf("Unable to materialize room state for '%s'\n", roomState.roomName.c_str());
+		return true;
+	}
+
+	uint actionObjectCount = 0;
+	for (const StartupObjectRecord &object : materializedState.roomObjects) {
+		if (!object.actionTag.empty())
+			++actionObjectCount;
+	}
+
+	debugPrintf("Room '%s' action tags on %u/%u objects\n",
+		materializedState.roomName.c_str(), actionObjectCount, (uint)materializedState.roomObjects.size());
+	if (actionObjectCount == 0) {
+		debugPrintf("No room objects in '%s' have action tags\n", materializedState.roomName.c_str());
+		return true;
+	}
+
+	for (const StartupObjectRecord &object : materializedState.roomObjects) {
+		if (object.actionTag.empty())
+			continue;
+
+		const Common::String label = startupScript->resolveObjectLabel(object);
+		Common::String objectLine = Common::String::format(
+			"Object '%s' owner='%s' visible=%d runtimeVisible=%d operatable=%d action='%s'",
+			object.objectName.c_str(), object.currentOwnerOrRoom.c_str(),
+			object.visible, object.runtimeVisible, object.operatable, object.actionTag.c_str());
+		if (!label.empty())
+			objectLine += Common::String::format(" label='%s'", label.c_str());
+		debugPrintf("%s\n", objectLine.c_str());
+
+		Common::Array<Common::String> chainLines;
+		buildActionChainLines(*startupScript, object.actionTag, chainLines);
+		for (const Common::String &line : chainLines)
+			debugPrintf("%s\n", line.c_str());
+	}
+
+	return true;
+}
+
 bool Console::Cmd_gotoRoom(int argc, const char **argv) {
 	if (argc > 2) {
 		debugPrintf("Usage: GOTO_ROOM [room_name]\n");
diff --git a/engines/harvester/console.h b/engines/harvester/console.h
index 14638d100db..0c8aea1b1e4 100644
--- a/engines/harvester/console.h
+++ b/engines/harvester/console.h
@@ -32,6 +32,7 @@ private:
 	bool Cmd_about(int argc, const char **argv);
 	bool Cmd_debugCombat(int argc, const char **argv);
 	bool Cmd_debugRoom(int argc, const char **argv);
+	bool Cmd_debugActions(int argc, const char **argv);
 	bool Cmd_gotoRoom(int argc, const char **argv);
 public:
 	Console();
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 01b7569e6b2..40e1be35eae 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -121,6 +121,9 @@ public:
 		const Common::String &musicPath);
 	void clearCurrentStartupSaveRoomState();
 	bool hasCurrentStartupSaveRoomState() const { return _currentStartupSaveRoomState.valid; }
+	const StartupSaveRoomState &getCurrentStartupSaveRoomState() const {
+		return _currentStartupSaveRoomState;
+	}
 	bool hasPendingLoadedStartupSaveRoomState() const { return _pendingLoadedStartupSaveRoomState.valid; }
 	const StartupSaveRoomState &getPendingLoadedStartupSaveRoomState() const {
 		return _pendingLoadedStartupSaveRoomState;


Commit: 3c4a05fc8a693a71f92e38a0ffcf851cf4655def
    https://github.com/scummvm/scummvm/commit/3c4a05fc8a693a71f92e38a0ffcf851cf4655def
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:24+02:00

Commit Message:
HARVESTER: Restore room-entry FST playback

Changed paths:
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index e5051caf4d3..e7b28f1fa58 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -632,6 +632,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 	bool currentTargetIsRoomName = targetIsRoomName;
 	while (!currentRoomTarget.empty()) {
 		StartupRoomSetupState state;
+		StartupInteractionResult roomEntryInteraction;
 		if (_engine.hasPendingLoadedStartupSaveRoomState()) {
 			startupFlow.resetRoomNpcDialogueState();
 			if (_engine.hasPendingLoadedDialogueStateBlob()) {
@@ -662,9 +663,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			currentTargetIsRoomName = false;
 		} else if ((currentTargetIsRoomName
 				? !_engine.getStartupScript()->resolveRoomSetupStateByRoomName(
-					currentRoomTarget, state, *_engine.getResources())
+					currentRoomTarget, state, *_engine.getResources(), &roomEntryInteraction)
 				: !_engine.getStartupScript()->resolveRoomSetupState(
-					currentRoomTarget, state, *_engine.getResources()))) {
+					currentRoomTarget, state, *_engine.getResources(), &roomEntryInteraction))) {
 			return Common::kReadingFailed;
 		}
 
@@ -2052,101 +2053,130 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			runRoomExitCommands, applyLightingCommand, applyPlayerGotoXZ, runModalShowText,
 			resetIdleState
 		};
-	auto findRoomNpcRecordByName = [&](const Common::String &npcName) -> StartupNpcRecord * {
-		for (StartupNpcRecord &npc : scene.state.roomNpcs) {
-			if (npc.npcName.equalsIgnoreCase(npcName))
-				return &npc;
-		}
+		auto hasDeferredRoomEntryInteraction = [](const StartupInteractionResult &interaction) {
+			return interaction.cdChangeDisc > 0 ||
+				!interaction.cutscenePath.empty() ||
+				!interaction.deathFlicPath.empty() ||
+				!interaction.dialogueNpcName.empty() ||
+				!interaction.dialogueContinuationTag.empty() ||
+				!interaction.continuationTag.empty() ||
+				!interaction.modalText.value.empty() ||
+				interaction.lightingCommand != kStartupLightingCommandNone ||
+				interaction.requestPlayerGotoXZ ||
+				interaction.requestMainMenu;
+		};
+		if (hasDeferredRoomEntryInteraction(roomEntryInteraction)) {
+			StartupInteractionResult deferredRoomEntryInteraction = roomEntryInteraction;
+			deferredRoomEntryInteraction.musicPath.clear();
+			deferredRoomEntryInteraction.audioCommands.clear();
+			deferredRoomEntryInteraction.mutatedRuntimeState = false;
+
+			bool didEntryTransition = false;
+			Common::Error entryInteractionError = interactionProcessor.handleInteractionResult(
+				deferredRoomEntryInteraction, didEntryTransition, Common::String());
+			if (entryInteractionError.getCode() != Common::kNoError)
+				return entryInteractionError;
+			if (startupFlow.hasPendingMainMenuReturn() || didEntryTransition)
+				return Common::kNoError;
 
-		return nullptr;
-	};
-	auto findRoomMonsterRecordByName = [&](const Common::String &monsterName) -> StartupMonsterRecord * {
-		for (StartupMonsterRecord &monster : scene.state.roomMonsters) {
-			if (monster.monsterName.equalsIgnoreCase(monsterName))
-				return &monster;
+			startupFlow.resetCursorAnimationSequence();
+			resetIdleState();
 		}
+		auto findRoomNpcRecordByName = [&](const Common::String &npcName) -> StartupNpcRecord * {
+			for (StartupNpcRecord &npc : scene.state.roomNpcs) {
+				if (npc.npcName.equalsIgnoreCase(npcName))
+					return &npc;
+			}
 
-		return nullptr;
-	};
-	auto findRoomMonsterIndexByName = [&](const Common::String &monsterName) -> int {
-		for (uint i = 0; i < scene.state.roomMonsters.size(); ++i) {
-			if (scene.state.roomMonsters[i].monsterName.equalsIgnoreCase(monsterName))
-				return (int)i;
-		}
+			return nullptr;
+		};
+		auto findRoomMonsterRecordByName = [&](const Common::String &monsterName) -> StartupMonsterRecord * {
+			for (StartupMonsterRecord &monster : scene.state.roomMonsters) {
+				if (monster.monsterName.equalsIgnoreCase(monsterName))
+					return &monster;
+			}
 
-		return -1;
-	};
-	auto findSceneRuntimeEntity = [&](const Common::String &entityName) -> RuntimeEntity * {
-		return runtimeEntities ? runtimeEntities->findSceneEntityByName(entityName) : nullptr;
-	};
-	auto findMonsterTargetAtPoint = [&](const Common::Point &point) -> StartupMonsterRecord * {
-		StartupMonsterRecord *bestMonster = nullptr;
-		int bestZ = -0x7fffffff;
-		int bestTop = -0x7fffffff;
-		for (StartupMonsterRecord &monster : scene.state.roomMonsters) {
-			if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
-				continue;
+			return nullptr;
+		};
+		auto findRoomMonsterIndexByName = [&](const Common::String &monsterName) -> int {
+			for (uint i = 0; i < scene.state.roomMonsters.size(); ++i) {
+				if (scene.state.roomMonsters[i].monsterName.equalsIgnoreCase(monsterName))
+					return (int)i;
+			}
 
-			RuntimeEntity *entity = findSceneRuntimeEntity(monster.monsterName);
-			if (!entity || !entity->isVisible())
-				continue;
+			return -1;
+		};
+		auto findSceneRuntimeEntity = [&](const Common::String &entityName) -> RuntimeEntity * {
+			return runtimeEntities ? runtimeEntities->findSceneEntityByName(entityName) : nullptr;
+		};
+		auto findMonsterTargetAtPoint = [&](const Common::Point &point) -> StartupMonsterRecord * {
+			StartupMonsterRecord *bestMonster = nullptr;
+			int bestZ = -0x7fffffff;
+			int bestTop = -0x7fffffff;
+			for (StartupMonsterRecord &monster : scene.state.roomMonsters) {
+				if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
+					continue;
 
-			const Common::Rect entityRect = entity->getScreenRect();
-			if (!entityRect.contains(point))
-				continue;
+				RuntimeEntity *entity = findSceneRuntimeEntity(monster.monsterName);
+				if (!entity || !entity->isVisible())
+					continue;
 
-			if (!bestMonster || monster.posZ > bestZ ||
-					(monster.posZ == bestZ && entityRect.top > bestTop)) {
-				bestMonster = &monster;
-				bestZ = monster.posZ;
-				bestTop = entityRect.top;
+				const Common::Rect entityRect = entity->getScreenRect();
+				if (!entityRect.contains(point))
+					continue;
+
+				if (!bestMonster || monster.posZ > bestZ ||
+						(monster.posZ == bestZ && entityRect.top > bestTop)) {
+					bestMonster = &monster;
+					bestZ = monster.posZ;
+					bestTop = entityRect.top;
+				}
 			}
-		}
 
-		return bestMonster;
-	};
-	auto findOverlappingMonsterTarget = [&]() -> StartupMonsterRecord * {
-		if (!playerState.entity)
-			return nullptr;
+			return bestMonster;
+		};
+		auto findOverlappingMonsterTarget = [&]() -> StartupMonsterRecord * {
+			if (!playerState.entity)
+				return nullptr;
 
-		for (StartupMonsterRecord &monster : scene.state.roomMonsters) {
-			if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
-				continue;
+			for (StartupMonsterRecord &monster : scene.state.roomMonsters) {
+				if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
+					continue;
 
-			RuntimeEntity *entity = findSceneRuntimeEntity(monster.monsterName);
-			if (entity && areCombatantsWithinNativeCloseRange(scene.state,
-					*playerState.entity, playerState.z, *entity, (float)monster.posZ, monster.engageDistance))
-				return &monster;
-		}
+				RuntimeEntity *entity = findSceneRuntimeEntity(monster.monsterName);
+				if (entity && areCombatantsWithinNativeCloseRange(scene.state,
+						*playerState.entity, playerState.z, *entity, (float)monster.posZ, monster.engageDistance))
+					return &monster;
+			}
 
-		return nullptr;
-	};
-	auto findOverlappingNpcTarget = [&]() -> StartupNpcRecord * {
-		if (!playerState.entity)
 			return nullptr;
+		};
+		auto findOverlappingNpcTarget = [&]() -> StartupNpcRecord * {
+			if (!playerState.entity)
+				return nullptr;
 
-		for (StartupNpcRecord &npc : scene.state.roomNpcs) {
-			if (!npc.visible || npc.deathOrMonsterfyFlag)
-				continue;
+			for (StartupNpcRecord &npc : scene.state.roomNpcs) {
+				if (!npc.visible || npc.deathOrMonsterfyFlag)
+					continue;
 
-			RuntimeEntity *entity = findSceneRuntimeEntity(npc.npcName);
-			if (entity && areCombatantsWithinNativeCloseRange(scene.state,
-					*playerState.entity, playerState.z, *entity, entity->getZ(), 0))
-				return &npc;
-		}
+				RuntimeEntity *entity = findSceneRuntimeEntity(npc.npcName);
+				if (entity && areCombatantsWithinNativeCloseRange(scene.state,
+						*playerState.entity, playerState.z, *entity, entity->getZ(), 0))
+					return &npc;
+			}
 
-		return nullptr;
-	};
-	auto playerAttackCanReachTarget = [&](RuntimeEntity *targetEntity, int engageDistance = 0) {
-		if (!playerState.entity || !targetEntity)
-			return false;
-		if (Player::isProjectileCombatLoadout(playerState.combatLoadout))
-			return true;
+			return nullptr;
+		};
+		auto playerAttackCanReachTarget = [&](RuntimeEntity *targetEntity, int engageDistance = 0) {
+			if (!playerState.entity || !targetEntity)
+				return false;
+			if (Player::isProjectileCombatLoadout(playerState.combatLoadout))
+				return true;
 
-		return areCombatantsWithinNativeCloseRange(scene.state,
-			*playerState.entity, playerState.z, *targetEntity, targetEntity->getZ(), engageDistance);
-	};
-	auto describeCombatTargetClass = [](int classId) -> const char * {
+			return areCombatantsWithinNativeCloseRange(scene.state,
+				*playerState.entity, playerState.z, *targetEntity, targetEntity->getZ(), engageDistance);
+		};
+		auto describeCombatTargetClass = [](int classId) -> const char * {
 		switch (classId) {
 		case kRuntimeEntityClassNpc:
 			return "npc";
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 7a9d2624c81..1bdec5f01cb 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1045,7 +1045,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 }
 
 bool Script::resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
-		ResourceManager &resources) {
+		ResourceManager &resources, StartupInteractionResult *entryInteraction) {
 	state = StartupRoomSetupState();
 
 	const StartupEntranceRecord *entrance = findEntranceRecord(entranceName);
@@ -1057,35 +1057,35 @@ bool Script::resolveRoomSetupState(const Common::String &entranceName, StartupRo
 		return false;
 	}
 
-	Common::String musicPath;
-	Common::Array<StartupAudioCommand> audioCommands;
-	bool mutatedRuntimeState = false;
+	StartupInteractionResult onEnterInteraction;
 	executeCommandChain(
 		room->onEnterCommand, "room setup command", room->roomName, false,
-		&musicPath,
-		&audioCommands,
-		nullptr,
-		nullptr,
-		nullptr,
-		nullptr,
-		nullptr,
-		nullptr,
-		nullptr,
-		nullptr,
-		nullptr,
-		nullptr,
-		nullptr,
-		nullptr,
+		&onEnterInteraction.musicPath,
+		&onEnterInteraction.audioCommands,
 		nullptr,
 		nullptr,
-		&mutatedRuntimeState);
+		entryInteraction ? &onEnterInteraction.cutscenePath : nullptr,
+		entryInteraction ? &onEnterInteraction.deathFlicPath : nullptr,
+		entryInteraction ? &onEnterInteraction.requestMainMenu : nullptr,
+		entryInteraction ? &onEnterInteraction.cdChangeDisc : nullptr,
+		entryInteraction ? &onEnterInteraction.dialogueNpcName : nullptr,
+		entryInteraction ? &onEnterInteraction.dialogueContinuationTag : nullptr,
+		entryInteraction ? &onEnterInteraction.continuationTag : nullptr,
+		entryInteraction ? &onEnterInteraction.modalText : nullptr,
+		entryInteraction ? &onEnterInteraction.lightingCommand : nullptr,
+		entryInteraction ? &onEnterInteraction.requestPlayerGotoXZ : nullptr,
+		entryInteraction ? &onEnterInteraction.playerGotoX : nullptr,
+		entryInteraction ? &onEnterInteraction.playerGotoZ : nullptr,
+		&onEnterInteraction.mutatedRuntimeState);
 
 	if (!buildRuntimeRoomState(*room, entrance, resources, state))
 		return false;
 
-	state.audioCommands = audioCommands;
-	if (!musicPath.empty())
-		state.musicPath = musicPath;
+	state.audioCommands = onEnterInteraction.audioCommands;
+	if (!onEnterInteraction.musicPath.empty())
+		state.musicPath = onEnterInteraction.musicPath;
+	if (entryInteraction)
+		*entryInteraction = onEnterInteraction;
 
 	debugC(1, kDebugRoom,
 		"Harvester: resolveRoomSetupState('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u roomNpcs=%u roomMonsters=%u roomTimers=%u roomRegions=%u mutated=%d",
@@ -1095,13 +1095,13 @@ bool Script::resolveRoomSetupState(const Common::String &entranceName, StartupRo
 		(double)state.paletteBrightness, (uint)state.roomObjects.size(),
 		(uint)state.activeObjects.size(), (uint)state.roomAnimations.size(), (uint)state.roomNpcs.size(),
 		(uint)state.roomMonsters.size(), (uint)state.roomTimers.size(),
-		(uint)state.roomRegions.size(), mutatedRuntimeState);
+		(uint)state.roomRegions.size(), onEnterInteraction.mutatedRuntimeState);
 
 	return true;
 }
 
 bool Script::resolveRoomSetupStateByRoomName(const Common::String &roomName, StartupRoomSetupState &state,
-		ResourceManager &resources) {
+		ResourceManager &resources, StartupInteractionResult *entryInteraction) {
 	state = StartupRoomSetupState();
 
 	const StartupRoomRecord *room = findRoomRecord(roomName);
@@ -1119,35 +1119,35 @@ bool Script::resolveRoomSetupStateByRoomName(const Common::String &roomName, Sta
 		break;
 	}
 
-	Common::String musicPath;
-	Common::Array<StartupAudioCommand> audioCommands;
-	bool mutatedRuntimeState = false;
+	StartupInteractionResult onEnterInteraction;
 	executeCommandChain(
 		room->onEnterCommand, "room setup command", room->roomName, false,
-		&musicPath,
-		&audioCommands,
-		nullptr,
-		nullptr,
-		nullptr,
-		nullptr,
-		nullptr,
-		nullptr,
-		nullptr,
-		nullptr,
-		nullptr,
-		nullptr,
-		nullptr,
-		nullptr,
+		&onEnterInteraction.musicPath,
+		&onEnterInteraction.audioCommands,
 		nullptr,
 		nullptr,
-		&mutatedRuntimeState);
+		entryInteraction ? &onEnterInteraction.cutscenePath : nullptr,
+		entryInteraction ? &onEnterInteraction.deathFlicPath : nullptr,
+		entryInteraction ? &onEnterInteraction.requestMainMenu : nullptr,
+		entryInteraction ? &onEnterInteraction.cdChangeDisc : nullptr,
+		entryInteraction ? &onEnterInteraction.dialogueNpcName : nullptr,
+		entryInteraction ? &onEnterInteraction.dialogueContinuationTag : nullptr,
+		entryInteraction ? &onEnterInteraction.continuationTag : nullptr,
+		entryInteraction ? &onEnterInteraction.modalText : nullptr,
+		entryInteraction ? &onEnterInteraction.lightingCommand : nullptr,
+		entryInteraction ? &onEnterInteraction.requestPlayerGotoXZ : nullptr,
+		entryInteraction ? &onEnterInteraction.playerGotoX : nullptr,
+		entryInteraction ? &onEnterInteraction.playerGotoZ : nullptr,
+		&onEnterInteraction.mutatedRuntimeState);
 
 	if (!buildRuntimeRoomState(*room, entrance, resources, state))
 		return false;
 
-	state.audioCommands = audioCommands;
-	if (!musicPath.empty())
-		state.musicPath = musicPath;
+	state.audioCommands = onEnterInteraction.audioCommands;
+	if (!onEnterInteraction.musicPath.empty())
+		state.musicPath = onEnterInteraction.musicPath;
+	if (entryInteraction)
+		*entryInteraction = onEnterInteraction;
 
 	debugC(1, kDebugRoom,
 		"Harvester: resolveRoomSetupStateByRoomName('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u roomNpcs=%u roomMonsters=%u roomTimers=%u roomRegions=%u mutated=%d",
@@ -1157,7 +1157,7 @@ bool Script::resolveRoomSetupStateByRoomName(const Common::String &roomName, Sta
 		(double)state.paletteBrightness, (uint)state.roomObjects.size(),
 		(uint)state.activeObjects.size(), (uint)state.roomAnimations.size(), (uint)state.roomNpcs.size(),
 		(uint)state.roomMonsters.size(), (uint)state.roomTimers.size(),
-		(uint)state.roomRegions.size(), mutatedRuntimeState);
+		(uint)state.roomRegions.size(), onEnterInteraction.mutatedRuntimeState);
 
 	return true;
 }
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 02baccae496..4eb73fd8a2e 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -347,9 +347,9 @@ public:
 	void resetRuntimeState();
 	void syncRuntimeSaveState(Common::Serializer &s);
 	bool resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
-		ResourceManager &resources);
+		ResourceManager &resources, StartupInteractionResult *entryInteraction = nullptr);
 	bool resolveRoomSetupStateByRoomName(const Common::String &roomName, StartupRoomSetupState &state,
-		ResourceManager &resources);
+		ResourceManager &resources, StartupInteractionResult *entryInteraction = nullptr);
 	bool materializeRoomState(const Common::String &entranceName, const Common::String &roomName,
 		StartupRoomSetupState &state, ResourceManager &resources) const;
 	bool executeRoomExitCommands(const Common::String &roomName, StartupInteractionResult &result);


Commit: 374e02ab310f8753fdbfdc35bac6bdefccdc1ba8
    https://github.com/scummvm/scummvm/commit/374e02ab310f8753fdbfdc35bac6bdefccdc1ba8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:24+02:00

Commit Message:
HARVESTER: Fix room-entry command timing

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index 76abf7c9f8c..efcdab64b6a 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,13 +3,15 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `877`
+- Total functions: `927`
 - Named/documented: `823`
-- Remaining `FUN_*`: `54`
-- Remaining undocumented total: `54`
+- Remaining `FUN_*`: `104`
+- Remaining undocumented total: `104`
 
 ## Next Suggested Action
 
+- Immediate: rerun the in-engine `DNAEXT` sheriff-office door validation after the room-entry command split. Reproduce `DNAEXT_QDOOR_HS_COM -> SET_FLAG DNA_S_SUICIDE_NOTE -> START_DIALOG EDNA`, confirm `C043.FST` / `C043K.FST` now fire only when the live Edna branch actually reaches the suicide-note path, verify the post-branch room state matches the authored data (`SIGNOUT` visibility plus any hotspot swap away from `DNAEXT_QDOOR_HS`), and recheck that using an inventory item on the door no longer replays the cutscene/dialogue path.
+- Immediate: inspect the orphan cheat-handler clone that starts somewhere in the `0x742xx..0x74bxx` range. The live path is already confirmed inline inside `run_harvester_main_loop`, and `FUN_00078920` has no callers, so the next useful question is whether the second clone is another dead duplicate, an alternate build path, or code that should be merged back into a recovered function boundary.
 - Immediate: rerun the in-engine Sergeant remains/lodge handoff after the room-object semantics fix. Recreate the `GOT_REMAINS_FOR_LODGE` branch so `0x4333 -> C150.FST -> 0x433f..0x4385 -> C076.FST -> CHANGE_CD 3` lands in `SERGEANTRM`, confirm the room now materializes its authored blocker geometry correctly, and verify that the post-CD room still exposes only the scripted `LOD_X1 -> EXIT_LODGE` transition. If the expected deeper lodge entry is still absent, treat that as a script/data expectation mismatch rather than a remaining Sergeant CD-prompt bug.
 - Immediate: run an in-engine Harvester validation pass focused on the `WOLFIE` crypt kill after the newly confirmed native parity check. Keep `combat` logging enabled, kill `WOLFIE`, and confirm the death bank still plays through to completion before the monster disappears, that `WOLFIE_DEATHCOM` only opens the crypt hotspots without leaving a clickable or visible monster residue, and that no stale monster entity survives in the live room after the `CRYPT` state refresh.
 - Immediate: run an in-engine Harvester combat validation pass with one low-band melee loadout and one high-band weapon while `combat` logging is enabled. Recheck `FIREAXE` in `CRYPT` to confirm the new logs show the native `loadout=17 weapon='FIREAXE' damage=3`, then switch to a higher-damage weapon such as `SHOTGUN` (`loadout=3 damage=7`) or `CHAINSAW` (`loadout=14 damage=8`) and confirm the contact log changes accordingly. During the same pass, verify `WOLFIE` continues to report its seeded per-hit monster damage in the spawn and attack logs so we can tell whether the remaining difficulty complaint is native data/cadence rather than duplicate hit application.
@@ -23,6 +25,9 @@
 
 ## Last Confirmed Action
 
+- On March 28, 2026, re-audited Harvester's native room enter/exit sequencing in live Ghidra after the March 26 room-entry FST regression. `room_setup` (`0x73540`) runs `dispatch_room_event_actions(g_current_room_def->on_exit_tag)` inline before switching away from the current room and runs `dispatch_room_event_actions(pRVar5->on_enter_tag)` only after the new room has been instantiated; clicked hotspot actions still go straight through the same dispatcher via `handle_target_interaction`, so the original executable does not precompute a deferred interaction result during room-state resolution. Patched [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so `resolveRoomSetupState*()` only materialize room state again, room-enter commands are executed once from the live room loop through the new `executeRoomEnterCommands()` path, and the March 26 early-mutation/deferred-replay hybrid is removed. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts now match the current live Ghidra database: `927` total functions, `823` documented, and `104` remaining `FUN_*`.
+- On March 27, 2026, traced `FUN_00078920` backward to identify its live entry path. Direct Ghidra queries still show no callers and no xrefs to `0x78920`, and a raw byte-pattern search found no hidden immediate reference to that entry address. The decisive evidence came from its data usage instead: the top of `FUN_00078920` matches the code at `0x6e7c0` inside `run_harvester_main_loop`, using the same keyboard-state globals (`0xd5908`, `0xd5b58`, `0xd61c8`, `0xd61cc`), the same debug/format string `cheat_index_1 = %i  cheat_index_2 = %i  key=%i` at `0xbe3e8`, the same item-string constants, and the same case-specific action tags (`FOUNTAIN_ART1`, `3RD_2_3RDNTRYR`, `L2_M1`, `WARP_TO_LODGE`). The same string xrefs also expose a third uncalled clone in the `0x742xx..0x74bxx` range. Current conclusion: `FUN_00078920` is not the live cheat entry point; the active implementation runs inline in `run_harvester_main_loop`, while `FUN_00078920` and the `0x742xx..0x74bxx` body appear to be orphaned duplicate copies. Progress counts remain `878` total functions, `823` named/documented, and `55` remaining `FUN_*`.
+- On March 26, 2026, audited the undefined code targets referenced from the dword table at `0x788fc`. Each entry in the table (`0x78a65`, `0x78ae2`, `0x78c2e`, `0x78de4`, `0x78e73`, `0x78f87`, `0x79098`, `0x7918e`) has a `COMPUTED_JUMP` xref from `0x792a8`, proving they are switch-case destinations rather than standalone function entries. The bytes immediately after the table form a real function prologue at `0x78920`, so I created and saved `FUN_00078920` with body `0x78920..0x792be`, and all eight former undefined targets now resolve inside that function. Applied the neutral table label `switch_table_00078920` at `0x788fc`. No semantic rename was made because the routine's behavior is still not confirmed.
 - On March 26, 2026, dumped and decoded the authored lodge-entry data after the user questioned why the post-Sergeant CD3 handoff was not entering a deeper lodge room. `HARVEST.SCR` confirms the data-driven path stops at `COMMAND "WARP_TO_LODGE_B" "CHANGE_CD" "3"` and then rebuilds `SERGEANTRM`; that room contains one hidden mask object, `LODGE_DOOR`, and exactly one region, `LOD_X1 -> EXIT_LODGE`, so there is no second script-authored lodge-room transition to trigger automatically after `C076.FST`. The same audit exposed a real ScummVM room-runtime gap instead: the loader was discarding unnamed `OBJECT` records entirely, which drops native blocker rectangles such as the four `SERGEANTRM` `{// BLOCKS }` entries, and the scene builder was also skipping hidden no-sprite action hotspots such as `PCHOUSE / PC_WINDOW` and `MR_JOHNS / MANHOLE`. Patched [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp), [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp), and [`engines/harvester/room_support.h`](/Users/alex/Workspace/scummvm/engines/harvester/room_support.h) so anonymous room objects receive stable internal ids, hidden no-sprite action hotspots remain materialized, and visible disabled hotspot rectangles now participate in room collision as blockers. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `877` total functions, `823` named/documented, and `54` remaining `FUN_*`.
 - On March 26, 2026, dug into native `prompt_for_cdrom_disc_change` after the user raised the missing lodge-transition suspicion. Live Ghidra confirmed the Sergeant remains tail is not just cosmetic: `prompt_for_cdrom_disc_change(3, 0)` sets the active-disc dword `0xc0fa8 = 3`, raises `0xd607c = 1`, and, because the second parameter is `0`, latches `0xc7e49 = 1`. `run_harvester_main_loop` checks that latch immediately after interactions; when it is set, the loop clears it, calls `reload_town_world_from_script`, and reruns `room_setup` on the pending room target. The scripted `CHANGE_CD` room-event opcode mirrors the same disc-3 reload path inline, but the ScummVM Sergeant port previously only showed `CD3.BM` and returned. Patched [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/npc/sergeant_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.cpp) so the Sergeant `C076.FST -> CD3` tail now queues a native-style current-room restart after the prompt, which causes the room loop to rebuild the live room instead of falling straight back into the stale pre-CD3 scene. Updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) with the confirmed prompt-side latch behavior. Progress counts remain `877` total functions, `823` named/documented, and `54` remaining `FUN_*`.
 - On March 26, 2026, confirmed the user-reported stale `CALL_RETURN` regression was real but belonged to Stephanie rather than Sergeant. The cited site `0x37b26` is inside `handle_talk_to_stephanie`, not `handle_talk_to_sergeant`, and a fresh `InspectCallTermination.java verify STEPHANIE` showed the handler had fallen back to the truncated `0x36710..0x36cd8` body with only `9` reachable dialogue calls and `9` stale `CALL_RETURN` gaps. Running `repair STEPHANIE` in the live `HARVEST.LE` session converged in two passes, expanding the handler back to `0x36710..0x37f5d` with `198` reachable dialogue calls and `gapCount=0`; an explicit `save_program` plus fresh `verify STEPHANIE` confirmed the repaired state persists on disk. A raw assembly spot-check now shows the `0x37b26` `CALL 0x0007a690` falling through normally to `0x37b2b` inside the rebuilt Stephanie handler. Live progress now stands at `877` total functions, `823` named/documented, and `54` remaining `FUN_*`.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index e7b28f1fa58..2fd5108ba9c 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -632,7 +632,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 	bool currentTargetIsRoomName = targetIsRoomName;
 	while (!currentRoomTarget.empty()) {
 		StartupRoomSetupState state;
-		StartupInteractionResult roomEntryInteraction;
+		bool shouldRunRoomEntryCommands = false;
 		if (_engine.hasPendingLoadedStartupSaveRoomState()) {
 			startupFlow.resetRoomNpcDialogueState();
 			if (_engine.hasPendingLoadedDialogueStateBlob()) {
@@ -661,12 +661,15 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			currentRoomTarget = !loadedState.entranceName.empty() ? loadedState.entranceName : loadedState.roomName;
 			_engine.clearPendingLoadedStartupSaveRoomState();
 			currentTargetIsRoomName = false;
+			shouldRunRoomEntryCommands = false;
 		} else if ((currentTargetIsRoomName
 				? !_engine.getStartupScript()->resolveRoomSetupStateByRoomName(
-					currentRoomTarget, state, *_engine.getResources(), &roomEntryInteraction)
+					currentRoomTarget, state, *_engine.getResources())
 				: !_engine.getStartupScript()->resolveRoomSetupState(
-					currentRoomTarget, state, *_engine.getResources(), &roomEntryInteraction))) {
+					currentRoomTarget, state, *_engine.getResources()))) {
 			return Common::kReadingFailed;
+		} else {
+			shouldRunRoomEntryCommands = true;
 		}
 
 		Common::Error transitionError = startupFlow.beginRoomSetupTransition();
@@ -2053,31 +2056,37 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			runRoomExitCommands, applyLightingCommand, applyPlayerGotoXZ, runModalShowText,
 			resetIdleState
 		};
-		auto hasDeferredRoomEntryInteraction = [](const StartupInteractionResult &interaction) {
-			return interaction.cdChangeDisc > 0 ||
+		auto hasRoomEntryInteraction = [](const StartupInteractionResult &interaction) {
+			return !interaction.nextRoomName.empty() ||
+				!interaction.musicPath.empty() ||
+				!interaction.audioCommands.empty() ||
+				interaction.cdChangeDisc > 0 ||
 				!interaction.cutscenePath.empty() ||
 				!interaction.deathFlicPath.empty() ||
+				interaction.requestMainMenu ||
 				!interaction.dialogueNpcName.empty() ||
 				!interaction.dialogueContinuationTag.empty() ||
 				!interaction.continuationTag.empty() ||
 				!interaction.modalText.value.empty() ||
 				interaction.lightingCommand != kStartupLightingCommandNone ||
 				interaction.requestPlayerGotoXZ ||
-				interaction.requestMainMenu;
-		};
-		if (hasDeferredRoomEntryInteraction(roomEntryInteraction)) {
-			StartupInteractionResult deferredRoomEntryInteraction = roomEntryInteraction;
-			deferredRoomEntryInteraction.musicPath.clear();
-			deferredRoomEntryInteraction.audioCommands.clear();
-			deferredRoomEntryInteraction.mutatedRuntimeState = false;
-
-			bool didEntryTransition = false;
-			Common::Error entryInteractionError = interactionProcessor.handleInteractionResult(
-				deferredRoomEntryInteraction, didEntryTransition, Common::String());
-			if (entryInteractionError.getCode() != Common::kNoError)
-				return entryInteractionError;
-			if (startupFlow.hasPendingMainMenuReturn() || didEntryTransition)
-				return Common::kNoError;
+				interaction.mutatedRuntimeState;
+		};
+		if (shouldRunRoomEntryCommands) {
+			StartupInteractionResult roomEntryInteraction;
+			if (!_engine.getStartupScript()->executeRoomEnterCommands(
+					scene.state.roomName, roomEntryInteraction)) {
+				return Common::kReadingFailed;
+			}
+			if (hasRoomEntryInteraction(roomEntryInteraction)) {
+				bool didEntryTransition = false;
+				Common::Error entryInteractionError = interactionProcessor.handleInteractionResult(
+					roomEntryInteraction, didEntryTransition, Common::String());
+				if (entryInteractionError.getCode() != Common::kNoError)
+					return entryInteractionError;
+				if (startupFlow.hasPendingMainMenuReturn() || didEntryTransition)
+					return Common::kNoError;
+			}
 
 			startupFlow.resetCursorAnimationSequence();
 			resetIdleState();
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 1bdec5f01cb..7f720b7b247 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1045,7 +1045,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 }
 
 bool Script::resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
-		ResourceManager &resources, StartupInteractionResult *entryInteraction) {
+		ResourceManager &resources) {
 	state = StartupRoomSetupState();
 
 	const StartupEntranceRecord *entrance = findEntranceRecord(entranceName);
@@ -1057,51 +1057,24 @@ bool Script::resolveRoomSetupState(const Common::String &entranceName, StartupRo
 		return false;
 	}
 
-	StartupInteractionResult onEnterInteraction;
-	executeCommandChain(
-		room->onEnterCommand, "room setup command", room->roomName, false,
-		&onEnterInteraction.musicPath,
-		&onEnterInteraction.audioCommands,
-		nullptr,
-		nullptr,
-		entryInteraction ? &onEnterInteraction.cutscenePath : nullptr,
-		entryInteraction ? &onEnterInteraction.deathFlicPath : nullptr,
-		entryInteraction ? &onEnterInteraction.requestMainMenu : nullptr,
-		entryInteraction ? &onEnterInteraction.cdChangeDisc : nullptr,
-		entryInteraction ? &onEnterInteraction.dialogueNpcName : nullptr,
-		entryInteraction ? &onEnterInteraction.dialogueContinuationTag : nullptr,
-		entryInteraction ? &onEnterInteraction.continuationTag : nullptr,
-		entryInteraction ? &onEnterInteraction.modalText : nullptr,
-		entryInteraction ? &onEnterInteraction.lightingCommand : nullptr,
-		entryInteraction ? &onEnterInteraction.requestPlayerGotoXZ : nullptr,
-		entryInteraction ? &onEnterInteraction.playerGotoX : nullptr,
-		entryInteraction ? &onEnterInteraction.playerGotoZ : nullptr,
-		&onEnterInteraction.mutatedRuntimeState);
-
 	if (!buildRuntimeRoomState(*room, entrance, resources, state))
 		return false;
 
-	state.audioCommands = onEnterInteraction.audioCommands;
-	if (!onEnterInteraction.musicPath.empty())
-		state.musicPath = onEnterInteraction.musicPath;
-	if (entryInteraction)
-		*entryInteraction = onEnterInteraction;
-
 	debugC(1, kDebugRoom,
-		"Harvester: resolveRoomSetupState('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u roomNpcs=%u roomMonsters=%u roomTimers=%u roomRegions=%u mutated=%d",
+		"Harvester: resolveRoomSetupState('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u roomNpcs=%u roomMonsters=%u roomTimers=%u roomRegions=%u",
 		entranceName.c_str(), state.roomName.c_str(), state.entranceName.c_str(),
 		state.playerSpawnX, state.playerSpawnY, state.playerSpawnZ, state.playerFacing,
 		state.palettePath.c_str(), state.backgroundPath.c_str(), state.musicPath.c_str(),
 		(double)state.paletteBrightness, (uint)state.roomObjects.size(),
 		(uint)state.activeObjects.size(), (uint)state.roomAnimations.size(), (uint)state.roomNpcs.size(),
 		(uint)state.roomMonsters.size(), (uint)state.roomTimers.size(),
-		(uint)state.roomRegions.size(), onEnterInteraction.mutatedRuntimeState);
+		(uint)state.roomRegions.size());
 
 	return true;
 }
 
 bool Script::resolveRoomSetupStateByRoomName(const Common::String &roomName, StartupRoomSetupState &state,
-		ResourceManager &resources, StartupInteractionResult *entryInteraction) {
+		ResourceManager &resources) {
 	state = StartupRoomSetupState();
 
 	const StartupRoomRecord *room = findRoomRecord(roomName);
@@ -1119,45 +1092,18 @@ bool Script::resolveRoomSetupStateByRoomName(const Common::String &roomName, Sta
 		break;
 	}
 
-	StartupInteractionResult onEnterInteraction;
-	executeCommandChain(
-		room->onEnterCommand, "room setup command", room->roomName, false,
-		&onEnterInteraction.musicPath,
-		&onEnterInteraction.audioCommands,
-		nullptr,
-		nullptr,
-		entryInteraction ? &onEnterInteraction.cutscenePath : nullptr,
-		entryInteraction ? &onEnterInteraction.deathFlicPath : nullptr,
-		entryInteraction ? &onEnterInteraction.requestMainMenu : nullptr,
-		entryInteraction ? &onEnterInteraction.cdChangeDisc : nullptr,
-		entryInteraction ? &onEnterInteraction.dialogueNpcName : nullptr,
-		entryInteraction ? &onEnterInteraction.dialogueContinuationTag : nullptr,
-		entryInteraction ? &onEnterInteraction.continuationTag : nullptr,
-		entryInteraction ? &onEnterInteraction.modalText : nullptr,
-		entryInteraction ? &onEnterInteraction.lightingCommand : nullptr,
-		entryInteraction ? &onEnterInteraction.requestPlayerGotoXZ : nullptr,
-		entryInteraction ? &onEnterInteraction.playerGotoX : nullptr,
-		entryInteraction ? &onEnterInteraction.playerGotoZ : nullptr,
-		&onEnterInteraction.mutatedRuntimeState);
-
 	if (!buildRuntimeRoomState(*room, entrance, resources, state))
 		return false;
 
-	state.audioCommands = onEnterInteraction.audioCommands;
-	if (!onEnterInteraction.musicPath.empty())
-		state.musicPath = onEnterInteraction.musicPath;
-	if (entryInteraction)
-		*entryInteraction = onEnterInteraction;
-
 	debugC(1, kDebugRoom,
-		"Harvester: resolveRoomSetupStateByRoomName('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u roomNpcs=%u roomMonsters=%u roomTimers=%u roomRegions=%u mutated=%d",
+		"Harvester: resolveRoomSetupStateByRoomName('%s') -> room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d palette='%s' background='%s' music='%s' brightness=%.2f roomObjects=%u activeObjects=%u roomAnims=%u roomNpcs=%u roomMonsters=%u roomTimers=%u roomRegions=%u",
 		roomName.c_str(), state.roomName.c_str(), state.entranceName.c_str(),
 		state.playerSpawnX, state.playerSpawnY, state.playerSpawnZ, state.playerFacing,
 		state.palettePath.c_str(), state.backgroundPath.c_str(), state.musicPath.c_str(),
 		(double)state.paletteBrightness, (uint)state.roomObjects.size(),
 		(uint)state.activeObjects.size(), (uint)state.roomAnimations.size(), (uint)state.roomNpcs.size(),
 		(uint)state.roomMonsters.size(), (uint)state.roomTimers.size(),
-		(uint)state.roomRegions.size(), onEnterInteraction.mutatedRuntimeState);
+		(uint)state.roomRegions.size());
 
 	return true;
 }
@@ -1448,6 +1394,23 @@ bool Script::materializeRoomState(const Common::String &entranceName, const Comm
 	return buildRuntimeRoomState(*room, entrance, resources, state);
 }
 
+bool Script::executeRoomEnterCommands(const Common::String &roomName,
+		StartupInteractionResult &result) {
+	result = StartupInteractionResult();
+
+	const StartupRoomRecord *room = findRoomRecord(roomName);
+	if (!room)
+		return false;
+
+	executeCommandChain(room->onEnterCommand, "room entry command", room->roomName, true,
+		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
+		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu, &result.cdChangeDisc,
+		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
+		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
+		&result.playerGotoX, &result.playerGotoZ, &result.mutatedRuntimeState);
+	return true;
+}
+
 bool Script::executeRoomExitCommands(const Common::String &roomName,
 		StartupInteractionResult &result) {
 	result = StartupInteractionResult();
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 4eb73fd8a2e..c46e7422b8c 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -347,11 +347,12 @@ public:
 	void resetRuntimeState();
 	void syncRuntimeSaveState(Common::Serializer &s);
 	bool resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
-		ResourceManager &resources, StartupInteractionResult *entryInteraction = nullptr);
+		ResourceManager &resources);
 	bool resolveRoomSetupStateByRoomName(const Common::String &roomName, StartupRoomSetupState &state,
-		ResourceManager &resources, StartupInteractionResult *entryInteraction = nullptr);
+		ResourceManager &resources);
 	bool materializeRoomState(const Common::String &entranceName, const Common::String &roomName,
 		StartupRoomSetupState &state, ResourceManager &resources) const;
+	bool executeRoomEnterCommands(const Common::String &roomName, StartupInteractionResult &result);
 	bool executeRoomExitCommands(const Common::String &roomName, StartupInteractionResult &result);
 	bool resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result);
 	bool resolveRegionInteraction(const StartupRegionRecord &region, StartupInteractionResult &result);


Commit: e9c911e4be8ef4a41589e190dfc996ac2c6ddc1c
    https://github.com/scummvm/scummvm/commit/e9c911e4be8ef4a41589e190dfc996ac2c6ddc1c
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:25+02:00

Commit Message:
HARVESTER: Fix DNAEXT interaction regressions

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index efcdab64b6a..78ab4d1c101 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,7 +10,8 @@
 
 ## Next Suggested Action
 
-- Immediate: rerun the in-engine `DNAEXT` sheriff-office door validation after the room-entry command split. Reproduce `DNAEXT_QDOOR_HS_COM -> SET_FLAG DNA_S_SUICIDE_NOTE -> START_DIALOG EDNA`, confirm `C043.FST` / `C043K.FST` now fire only when the live Edna branch actually reaches the suicide-note path, verify the post-branch room state matches the authored data (`SIGNOUT` visibility plus any hotspot swap away from `DNAEXT_QDOOR_HS`), and recheck that using an inventory item on the door no longer replays the cutscene/dialogue path.
+- Immediate: rerun the burned-diner `DNAEXT_X1` one-shot validation. The native `SET_REGION` opcode persists on the global region record, and the authored day-6 chain `DNAEXTEXIT1 -> HANG_ANIM_A -> C131.FST -> C043K.FST -> SET_DNA_EXT_OFF` ends by disabling `DNAEXT_X1`; confirm the live log now shows `roomRegions=3` immediately after `SET_DNA_EXT_OFF` and still `roomRegions=3` on the next fresh `MAP_2_DNA` entry, with no replay of `C043K.FST` unless a later script path explicitly re-enables that region.
+- Immediate: rerun the in-engine `DNAEXT` diner-door validation after the selected-item target remap. The authored `HARVEST.SCR` data uses `USEITEM "INV_TAPE" "DNAEXT" "DINERDOOR" "ADD_TAPE_DNA"` and the break-tool follow-up on `DINTAPGLS_HS`, while the live click logs had been resolving the cursor to `DNAEXT_QDOOR_HS`; confirm selected-item clicks now remap from the examine hotspot to the matching use-item target (`DINERDOOR` first, `DINTAPGLS_HS` after taping), that the new `inventory target remap` / `inventory target miss` logs behave as expected, and then recheck whether direct examine clicks still replay `DNAEXT_QDOOR_HS_COM -> START_DIALOG EDNA -> C043K.FST` more often than native.
 - Immediate: inspect the orphan cheat-handler clone that starts somewhere in the `0x742xx..0x74bxx` range. The live path is already confirmed inline inside `run_harvester_main_loop`, and `FUN_00078920` has no callers, so the next useful question is whether the second clone is another dead duplicate, an alternate build path, or code that should be merged back into a recovered function boundary.
 - Immediate: rerun the in-engine Sergeant remains/lodge handoff after the room-object semantics fix. Recreate the `GOT_REMAINS_FOR_LODGE` branch so `0x4333 -> C150.FST -> 0x433f..0x4385 -> C076.FST -> CHANGE_CD 3` lands in `SERGEANTRM`, confirm the room now materializes its authored blocker geometry correctly, and verify that the post-CD room still exposes only the scripted `LOD_X1 -> EXIT_LODGE` transition. If the expected deeper lodge entry is still absent, treat that as a script/data expectation mismatch rather than a remaining Sergeant CD-prompt bug.
 - Immediate: run an in-engine Harvester validation pass focused on the `WOLFIE` crypt kill after the newly confirmed native parity check. Keep `combat` logging enabled, kill `WOLFIE`, and confirm the death bank still plays through to completion before the monster disappears, that `WOLFIE_DEATHCOM` only opens the crypt hotspots without leaving a clickable or visible monster residue, and that no stale monster entity survives in the live room after the `CRYPT` state refresh.
@@ -25,6 +26,8 @@
 
 ## Last Confirmed Action
 
+- On March 28, 2026, re-audited the burned-diner `C043K.FST` replay path in live Ghidra and the ScummVM room loop. The native `dispatch_room_event_actions` `SET_REGION` case persists directly on the global region record, and the authored day-6 `DNAEXTEXIT1 -> HANG_ANIM_A -> GOFLIC C131.FST -> GOFLIC C043K.FST -> SET_DNA_EXT_OFF` chain disables `DNAEXT_X1` as part of that persistent runtime state, not as a transient scene-only toggle. The existing ScummVM in-place mutation path was already rebuilding `DNAEXT` to `roomRegions=3` after `SET_DNA_EXT_OFF`, so the remaining regression was the handoff back into stored runtime state on later revisits. Patched [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) to add `setRuntimeRegionEnabled()` and synchronize live room-region presence back into `_runtimeRegions` during room-state capture, so one-shot region disables such as `DNAEXT_X1` do not resurrect on the next room entry. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
+- On March 28, 2026, decoded the authored `HARVEST.SCR` door-use data to separate the remaining `DNAEXT` regression from the earlier room-entry timing bug. The script data confirms the initial tape path is authored on `DINERDOOR` (`USEITEM "INV_TAPE" "DNAEXT" "DINERDOOR" "ADD_TAPE_DNA"`), the taped break path is authored on `DINTAPGLS_HS` (`USEITEM "FIREAXE" "DNAEXT" "DINTAPGLS_HS" "DNA_BREAKF"` plus the other tools), and the generic examine hotspot `DNAEXT_QDOOR_HS` only owns `DNAEXT_QDOOR_HS_COM -> SET_FLAG DNA_S_SUICIDE_NOTE -> START_DIALOG EDNA`. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so selected inventory clicks now retarget to the topmost room object under the cursor that actually has a matching `USEITEM` record, and added explicit `inventory target remap` / `inventory target miss` debug logs instead of silently discarding those clicks when the hovered hotspot has no authored use-item action. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, re-audited Harvester's native room enter/exit sequencing in live Ghidra after the March 26 room-entry FST regression. `room_setup` (`0x73540`) runs `dispatch_room_event_actions(g_current_room_def->on_exit_tag)` inline before switching away from the current room and runs `dispatch_room_event_actions(pRVar5->on_enter_tag)` only after the new room has been instantiated; clicked hotspot actions still go straight through the same dispatcher via `handle_target_interaction`, so the original executable does not precompute a deferred interaction result during room-state resolution. Patched [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so `resolveRoomSetupState*()` only materialize room state again, room-enter commands are executed once from the live room loop through the new `executeRoomEnterCommands()` path, and the March 26 early-mutation/deferred-replay hybrid is removed. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts now match the current live Ghidra database: `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 27, 2026, traced `FUN_00078920` backward to identify its live entry path. Direct Ghidra queries still show no callers and no xrefs to `0x78920`, and a raw byte-pattern search found no hidden immediate reference to that entry address. The decisive evidence came from its data usage instead: the top of `FUN_00078920` matches the code at `0x6e7c0` inside `run_harvester_main_loop`, using the same keyboard-state globals (`0xd5908`, `0xd5b58`, `0xd61c8`, `0xd61cc`), the same debug/format string `cheat_index_1 = %i  cheat_index_2 = %i  key=%i` at `0xbe3e8`, the same item-string constants, and the same case-specific action tags (`FOUNTAIN_ART1`, `3RD_2_3RDNTRYR`, `L2_M1`, `WARP_TO_LODGE`). The same string xrefs also expose a third uncalled clone in the `0x742xx..0x74bxx` range. Current conclusion: `FUN_00078920` is not the live cheat entry point; the active implementation runs inline in `run_harvester_main_loop`, while `FUN_00078920` and the `0x742xx..0x74bxx` body appear to be orphaned duplicate copies. Progress counts remain `878` total functions, `823` named/documented, and `55` remaining `FUN_*`.
 - On March 26, 2026, audited the undefined code targets referenced from the dword table at `0x788fc`. Each entry in the table (`0x78a65`, `0x78ae2`, `0x78c2e`, `0x78de4`, `0x78e73`, `0x78f87`, `0x79098`, `0x7918e`) has a `COMPUTED_JUMP` xref from `0x792a8`, proving they are switch-case destinations rather than standalone function entries. The bytes immediately after the table form a real function prologue at `0x78920`, so I created and saved `FUN_00078920` with body `0x78920..0x792be`, and all eight former undefined targets now resolve inside that function. Applied the neutral table label `switch_table_00078920` at `0x788fc`. No semantic rename was made because the routine's behavior is still not confirmed.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 2fd5108ba9c..2315fb388ba 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -838,6 +838,17 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			if (!startupScript)
 				return;
 
+			// Region enable state is a persistent script mutation; keep the runtime record in lockstep
+			// with the currently materialized scene so one-shot regions do not resurrect on revisit.
+			for (const StartupRegionRecord &baseRegion : startupScript->getRegions()) {
+				if (!baseRegion.roomName.equalsIgnoreCase(scene.state.roomName))
+					continue;
+
+				const bool enabled =
+					findSceneRegionByName(scene.sceneRegions, baseRegion.regionName) != nullptr;
+				(void)startupScript->setRuntimeRegionEnabled(baseRegion.regionName, enabled);
+			}
+
 			if (runtimeEntities) {
 				for (StartupAnimRecord &anim : scene.state.roomAnimations) {
 					RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(anim.animName);
@@ -3039,8 +3050,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		StartupInteractionResult interaction;
 		const bool handled = _engine.getStartupScript()->resolveUseItemInteraction(
 			selectedItemName, target, interaction);
-		if (!handled)
+		if (!handled) {
+			debugC(1, kDebugInventory,
+				"Harvester: inventory target miss selected='%s' target='%s' owner='%s' action='%s'",
+				selectedItemName.c_str(), target.objectName.c_str(),
+				target.currentOwnerOrRoom.c_str(), target.actionTag.c_str());
 			return Common::kNoError;
+		}
 
 		bool didTransition = false;
 		Common::Error interactionError =
@@ -3056,6 +3072,44 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		needsRedraw = true;
 		return Common::kNoError;
 	};
+	auto findSelectedInventoryRoomTarget = [&](const Common::Point &point) -> StartupObjectRecord * {
+		if (!_inventory.hasSelection())
+			return nullptr;
+
+		RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+		Script *startupScript = _engine.getStartupScript();
+		if (!runtimeEntities || !startupScript)
+			return nullptr;
+
+		const Common::String selectedItemName = _inventory.getSelectedItemName();
+		StartupObjectRecord *bestTarget = nullptr;
+		int bestDrawIndex = -1;
+		for (StartupObjectRecord &candidate : scene.sceneObjects) {
+			if (candidate.objectName.empty() ||
+					!startupScript->hasUseItemInteraction(selectedItemName, candidate)) {
+				continue;
+			}
+
+			const RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(candidate.objectName);
+			if (!entity || !entity->hitTest(point))
+				continue;
+			if (entity->getClassId() == kRuntimeEntityClassBackground ||
+					entity->getClassId() == kRuntimeEntityClassPlayer ||
+					entity->getClassId() == kRuntimeEntityClassRectHotspot19) {
+				continue;
+			}
+
+			const int drawIndex = runtimeEntities->findSceneEntityDrawIndexByName(entity->getName());
+			if (drawIndex < 0)
+				continue;
+			if (!bestTarget || drawIndex > bestDrawIndex) {
+				bestTarget = &candidate;
+				bestDrawIndex = drawIndex;
+			}
+		}
+
+		return bestTarget;
+	};
 	auto queueRegionInteraction = [&](const StartupRegionRecord &region) {
 		pendingRegionName = region.regionName;
 		if (!playerState.entity)
@@ -3182,6 +3236,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				? StartupRoomHoverState()
 				: resolveRoomHoverState(_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
 					scene.sceneRegions, _mousePos);
+			if (!suppressHover && inventorySelectionActive && !hoverState.npc) {
+				if (StartupObjectRecord *selectedTarget = findSelectedInventoryRoomTarget(_mousePos))
+					hoverState.object = selectedTarget;
+			}
 			Common::String promptText;
 			Common::String inventoryTooltipText;
 			auto resolveCarryTargetLabel = [&]() {
@@ -3503,16 +3561,31 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 				const StartupRoomHoverState hoverState = resolveRoomHoverState(
 					_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions, _mousePos);
+				StartupObjectRecord *selectedRoomTarget = nullptr;
+				if (_inventory.hasSelection() && !hoverState.npc)
+					selectedRoomTarget = findSelectedInventoryRoomTarget(_mousePos);
+				if (selectedRoomTarget &&
+						(!hoverState.object ||
+						 !selectedRoomTarget->objectName.equalsIgnoreCase(hoverState.object->objectName))) {
+					debugC(1, kDebugInventory,
+						"Harvester: inventory target remap selected='%s' hovered='%s' remapped='%s'",
+						_inventory.getSelectedItemName().c_str(),
+						hoverState.object ? hoverState.object->objectName.c_str() : "",
+						selectedRoomTarget->objectName.c_str());
+				}
+				StartupRoomHoverState clickHoverState = hoverState;
+				if (selectedRoomTarget)
+					clickHoverState.object = selectedRoomTarget;
 				debugC(1, kDebugRoom,
 					"Harvester: room click room='%s' mouse=(%d,%d) object='%s' npc='%s' region='%s' cursor_sequence=%d prompt='%s'",
 					scene.state.roomName.c_str(), _mousePos.x, _mousePos.y,
-					hoverState.object ? hoverState.object->objectName.c_str() : "",
-					hoverState.npc ? hoverState.npc->npcName.c_str() : "",
-					hoverState.region ? hoverState.region->regionName.c_str() : "",
-					hoverState.cursorSequence, hoverState.promptText.c_str());
+					clickHoverState.object ? clickHoverState.object->objectName.c_str() : "",
+					clickHoverState.npc ? clickHoverState.npc->npcName.c_str() : "",
+					clickHoverState.region ? clickHoverState.region->regionName.c_str() : "",
+					clickHoverState.cursorSequence, clickHoverState.promptText.c_str());
 					if (hasCarriedRoomItem()) {
-					if (hoverState.playerEntity && playerState.entity &&
-							hoverState.playerEntity == playerState.entity) {
+					if (clickHoverState.playerEntity && playerState.entity &&
+							clickHoverState.playerEntity == playerState.entity) {
 						if (!stowCarriedRoomItemToInventory())
 							return Common::kReadingFailed;
 						needsRedraw = true;
@@ -3521,10 +3594,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				}
 				if (_inventory.hasSelection()) {
 					const Common::String selectedItemName = _inventory.getSelectedItemName();
-					if (hoverState.npc) {
+					if (clickHoverState.npc) {
 						bool didTransition = false;
 						Common::Error dialogueError = interactionProcessor.runScriptedDialogue(
-							hoverState.npc->npcName, selectedItemName, Common::String(), didTransition);
+							clickHoverState.npc->npcName, selectedItemName, Common::String(), didTransition);
 						if (dialogueError.getCode() != Common::kNoError)
 							return dialogueError;
 						if (startupFlow.hasPendingMainMenuReturn())
@@ -3534,8 +3607,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 						break;
 					}
 
-					StartupObjectRecord *roomTarget = hoverState.object
-						? findSceneObjectByName(scene.sceneObjects, hoverState.object->objectName)
+					StartupObjectRecord *roomTarget = clickHoverState.object
+						? findSceneObjectByName(scene.sceneObjects, clickHoverState.object->objectName)
 						: nullptr;
 					if (roomTarget) {
 						Common::Error interactionError =
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 7f720b7b247..613a391d414 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1990,6 +1990,16 @@ bool Script::syncRuntimeTimerRecord(const StartupTimerRecord &timer) {
 	return true;
 }
 
+bool Script::setRuntimeRegionEnabled(const Common::String &regionName, bool enabled) {
+	StartupRegionRecord *runtimeRegion = findRuntimeRegion(regionName);
+	if (!runtimeRegion)
+		return false;
+
+	const bool changed = runtimeRegion->startEnabled != enabled;
+	runtimeRegion->startEnabled = enabled;
+	return changed;
+}
+
 bool Script::setRuntimeTimerEnabled(const Common::String &timerName, bool enabled) {
 	StartupTimerRecord *runtimeTimer = findRuntimeTimer(timerName);
 	if (!runtimeTimer)
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index c46e7422b8c..1a79f29f9d7 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -401,6 +401,7 @@ public:
 	bool syncRuntimeAnimState(const Common::String &animName, bool active, bool visible, int currentFrame);
 	bool syncRuntimeMonsterRecord(const StartupMonsterRecord &monster);
 	bool syncRuntimeTimerRecord(const StartupTimerRecord &timer);
+	bool setRuntimeRegionEnabled(const Common::String &regionName, bool enabled);
 	bool setRuntimeTimerEnabled(const Common::String &timerName, bool enabled);
 
 private:


Commit: b42e77a90b54277e93f13e51b3c20ba4a817cfce
    https://github.com/scummvm/scummvm/commit/b42e77a90b54277e93f13e51b3c20ba4a817cfce
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:25+02:00

Commit Message:
HARVESTER: Prefer visible room object hits

Changed paths:
    engines/harvester/flow.cpp


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index c9c823eaa41..a1e178f1b19 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -744,6 +744,7 @@ static const StartupObjectRecord *findRoomObjectAtPoint(HarvesterEngine &engine,
 		return nullptr;
 
 	const RuntimeEntity *topEntity = nullptr;
+	const StartupObjectRecord *topObject = nullptr;
 	int topDrawIndex = -1;
 	for (const StartupObjectRecord &object : sceneObjects) {
 		if (object.objectName.empty())
@@ -760,15 +761,20 @@ static const StartupObjectRecord *findRoomObjectAtPoint(HarvesterEngine &engine,
 		const int drawIndex = runtimeEntities->findSceneEntityDrawIndexByName(entity->getName());
 		if (drawIndex < 0)
 			continue;
-		if (!topEntity || drawIndex > topDrawIndex) {
+		const bool objectVisible = object.visible && object.runtimeVisible;
+		const bool topObjectVisible = topObject && topObject->visible && topObject->runtimeVisible;
+		if (!topEntity ||
+			(objectVisible && !topObjectVisible) ||
+			(objectVisible == topObjectVisible && drawIndex > topDrawIndex)) {
 			topEntity = entity;
+			topObject = &object;
 			topDrawIndex = drawIndex;
 		}
 	}
 	if (!topEntity)
 		return nullptr;
 
-	return findSceneObjectByName(sceneObjects, topEntity->getName());
+	return topObject;
 }
 
 static const StartupRegionRecord *findRoomRegionAtPoint(HarvesterEngine &engine,


Commit: d45ee94be8470e1205e92f68d9488a51fe92788c
    https://github.com/scummvm/scummvm/commit/d45ee94be8470e1205e92f68d9488a51fe92788c
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:25+02:00

Commit Message:
HARVESTER: Apply room-entry mutations before first draw

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 5467f69cad1..d8852b9996b 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -251,6 +251,7 @@
   - On the player-present branch it then re-adds the stored live player actor from `DAT_000d5bd4`, places it from `g_current_room_entrance`, and seeds exact facing frames `0x3b`, `0x0e`, `0x2c`, and `0x28` for entrance-facing values `0`, `1`, `2`, and `3`.
   - Before the live room flush it explicitly resets the cursor entity to animation sequence `7`.
   - The final room palette/upload order is now explicit: after optional room-enter handlers, `room_setup` uploads `g_current_palette_buffer` and then calls `flush_dirty_rects_to_screen`.
+  - Because those room-enter handlers run before the final room flush, a room-entry `SET_NPC ... F F` removes the current-room NPC before the first visible frame. DNALFT's `SET_NPC "EDNA" "F" "F"` path depends on that ordering.
   - `flush_dirty_rects_to_screen` begins by calling `sync_cursor_entity_position(g_cursor_entity)`, so the first cursor sync / animation tick after room construction happens under the room palette rather than before that upload.
   - After the initial black upload, `room_setup` uses `ramp_palette_brightness` at `0x23a30` to fade `g_current_palette_buffer` in from black. The helper steps brightness in `0.1` increments against the shared centisecond clock from `get_elapsed_milliseconds()` while also driving the room dimming path when the room is marked dimmable and `DAY_FLAG` is clear.
 - `upload_palette_to_vga` at `0x22770` writes a caller-supplied RGB palette to the VGA DAC, forces palette index 0 to black, and applies a caller-supplied brightness scalar before clamping each channel to the hardware `0x3f` range.
diff --git a/TRACKER.md b/TRACKER.md
index 78ab4d1c101..62496d8366e 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Next Suggested Action
 
+- Immediate: rerun the in-engine `DNA_2_DNALFT` entry with room/cursor logging enabled after the room-entry sequencing patch. Confirm `EDNA` no longer appears on the first visible DNALFT frame, that the room still fades in normally, and that follow-on entry side effects such as music, dialogue, cutscenes, and continuation tags still trigger in the same order after the fade.
 - Immediate: rerun the burned-diner `DNAEXT_X1` one-shot validation. The native `SET_REGION` opcode persists on the global region record, and the authored day-6 chain `DNAEXTEXIT1 -> HANG_ANIM_A -> C131.FST -> C043K.FST -> SET_DNA_EXT_OFF` ends by disabling `DNAEXT_X1`; confirm the live log now shows `roomRegions=3` immediately after `SET_DNA_EXT_OFF` and still `roomRegions=3` on the next fresh `MAP_2_DNA` entry, with no replay of `C043K.FST` unless a later script path explicitly re-enables that region.
 - Immediate: rerun the in-engine `DNAEXT` diner-door validation after the selected-item target remap. The authored `HARVEST.SCR` data uses `USEITEM "INV_TAPE" "DNAEXT" "DINERDOOR" "ADD_TAPE_DNA"` and the break-tool follow-up on `DINTAPGLS_HS`, while the live click logs had been resolving the cursor to `DNAEXT_QDOOR_HS`; confirm selected-item clicks now remap from the examine hotspot to the matching use-item target (`DINERDOOR` first, `DINTAPGLS_HS` after taping), that the new `inventory target remap` / `inventory target miss` logs behave as expected, and then recheck whether direct examine clicks still replay `DNAEXT_QDOOR_HS_COM -> START_DIALOG EDNA -> C043K.FST` more often than native.
 - Immediate: inspect the orphan cheat-handler clone that starts somewhere in the `0x742xx..0x74bxx` range. The live path is already confirmed inline inside `run_harvester_main_loop`, and `FUN_00078920` has no callers, so the next useful question is whether the second clone is another dead duplicate, an alternate build path, or code that should be merged back into a recovered function boundary.
@@ -28,6 +29,8 @@
 
 - On March 28, 2026, re-audited the burned-diner `C043K.FST` replay path in live Ghidra and the ScummVM room loop. The native `dispatch_room_event_actions` `SET_REGION` case persists directly on the global region record, and the authored day-6 `DNAEXTEXIT1 -> HANG_ANIM_A -> GOFLIC C131.FST -> GOFLIC C043K.FST -> SET_DNA_EXT_OFF` chain disables `DNAEXT_X1` as part of that persistent runtime state, not as a transient scene-only toggle. The existing ScummVM in-place mutation path was already rebuilding `DNAEXT` to `roomRegions=3` after `SET_DNA_EXT_OFF`, so the remaining regression was the handoff back into stored runtime state on later revisits. Patched [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) to add `setRuntimeRegionEnabled()` and synchronize live room-region presence back into `_runtimeRegions` during room-state capture, so one-shot region disables such as `DNAEXT_X1` do not resurrect on the next room entry. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, decoded the authored `HARVEST.SCR` door-use data to separate the remaining `DNAEXT` regression from the earlier room-entry timing bug. The script data confirms the initial tape path is authored on `DINERDOOR` (`USEITEM "INV_TAPE" "DNAEXT" "DINERDOOR" "ADD_TAPE_DNA"`), the taped break path is authored on `DINTAPGLS_HS` (`USEITEM "FIREAXE" "DNAEXT" "DINTAPGLS_HS" "DNA_BREAKF"` plus the other tools), and the generic examine hotspot `DNAEXT_QDOOR_HS` only owns `DNAEXT_QDOOR_HS_COM -> SET_FLAG DNA_S_SUICIDE_NOTE -> START_DIALOG EDNA`. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so selected inventory clicks now retarget to the topmost room object under the cursor that actually has a matching `USEITEM` record, and added explicit `inventory target remap` / `inventory target miss` debug logs instead of silently discarding those clicks when the hovered hotspot has no authored use-item action. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
+- On March 28, 2026, verified the native DNALFT room-entry draw ordering in live Ghidra to explain the one-frame `EDNA` flash. `room_setup` (`0x73540`) spawns room NPCs, executes `dispatch_room_event_actions(g_current_room_def->on_enter_tag)`, and only afterward calls `upload_palette_to_vga(g_current_palette_buffer, 0x100)` plus `flush_dirty_rects_to_screen`, so room-entry `SET_NPC "EDNA" "F" "F"` runs before the first visible room frame. The `SET_NPC` implementation in `dispatch_room_event_actions` case `0x15` and `set_npc_active_visible_state` (`0x7cab0`) writes the record's `active` / `visible` bytes and immediately removes the live current-room render entity when visibility clears. This confirms the flash is a ScummVM ordering artifact: [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) currently populates and draws the room before calling `executeRoomEnterCommands`, then refreshes only after an entry command reports `mutatedRuntimeState`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
+- On March 28, 2026, patched ScummVM's DNALFT-style room-entry timing to materialize entry-time runtime mutations before the first visible room frame. [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) now caches the initial `roomEntryInteraction`, applies `applyCurrentRoomRuntimeMutationsInPlace()` / `refreshCurrentScene(true)` before `waitForRoomSetupTransitionHold -> drawRoomScene -> fadeInRoomScene` when `executeRoomEnterCommands` reports `mutatedRuntimeState`, clears that one-shot mutation flag, and only then runs the remaining entry side effects through `handleInteractionResult`. This preserves the existing post-fade dialogue/cutscene handling while matching the native requirement that DNALFT's `SET_NPC "EDNA" "F" "F"` land before the first visible room frame. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, re-audited Harvester's native room enter/exit sequencing in live Ghidra after the March 26 room-entry FST regression. `room_setup` (`0x73540`) runs `dispatch_room_event_actions(g_current_room_def->on_exit_tag)` inline before switching away from the current room and runs `dispatch_room_event_actions(pRVar5->on_enter_tag)` only after the new room has been instantiated; clicked hotspot actions still go straight through the same dispatcher via `handle_target_interaction`, so the original executable does not precompute a deferred interaction result during room-state resolution. Patched [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so `resolveRoomSetupState*()` only materialize room state again, room-enter commands are executed once from the live room loop through the new `executeRoomEnterCommands()` path, and the March 26 early-mutation/deferred-replay hybrid is removed. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts now match the current live Ghidra database: `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 27, 2026, traced `FUN_00078920` backward to identify its live entry path. Direct Ghidra queries still show no callers and no xrefs to `0x78920`, and a raw byte-pattern search found no hidden immediate reference to that entry address. The decisive evidence came from its data usage instead: the top of `FUN_00078920` matches the code at `0x6e7c0` inside `run_harvester_main_loop`, using the same keyboard-state globals (`0xd5908`, `0xd5b58`, `0xd61c8`, `0xd61cc`), the same debug/format string `cheat_index_1 = %i  cheat_index_2 = %i  key=%i` at `0xbe3e8`, the same item-string constants, and the same case-specific action tags (`FOUNTAIN_ART1`, `3RD_2_3RDNTRYR`, `L2_M1`, `WARP_TO_LODGE`). The same string xrefs also expose a third uncalled clone in the `0x742xx..0x74bxx` range. Current conclusion: `FUN_00078920` is not the live cheat entry point; the active implementation runs inline in `run_harvester_main_loop`, while `FUN_00078920` and the `0x742xx..0x74bxx` body appear to be orphaned duplicate copies. Progress counts remain `878` total functions, `823` named/documented, and `55` remaining `FUN_*`.
 - On March 26, 2026, audited the undefined code targets referenced from the dword table at `0x788fc`. Each entry in the table (`0x78a65`, `0x78ae2`, `0x78c2e`, `0x78de4`, `0x78e73`, `0x78f87`, `0x79098`, `0x7918e`) has a `COMPUTED_JUMP` xref from `0x792a8`, proving they are switch-case destinations rather than standalone function entries. The bytes immediately after the table form a real function prologue at `0x78920`, so I created and saved `FUN_00078920` with body `0x78920..0x792be`, and all eight former undefined targets now resolve inside that function. Applied the neutral table label `switch_table_00078920` at `0x788fc`. No semantic rename was made because the routine's behavior is still not confirmed.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 2315fb388ba..d1eaa2023f9 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -725,22 +725,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			runtimeEntities->pauseTimerCountdowns();
 		}
 
-		transitionError = startupFlow.waitForRoomSetupTransitionHold();
-		if (transitionError.getCode() != Common::kNoError)
-			return transitionError;
-
-		logScenePaletteSummary("room setup stub palette", scene, 0.0f);
-		drawRoomScene(_engine, *screen, scene, 0.0f);
-		screen->makeAllDirty();
-		screen->update();
-
-		logScenePaletteSummary("room setup fade target", scene, scene.targetPaletteBrightness);
-		transitionError = startupFlow.fadeInRoomScene(scene.palette, scene.targetPaletteBrightness);
-		if (transitionError.getCode() != Common::kNoError)
-			return transitionError;
-		if (runtimeEntities)
-			runtimeEntities->resumeTimerCountdowns();
-
 		startupFlow.resetCursorAnimationSequence();
 		startupFlow.executeStartupAudioCommands(scene.state.audioCommands);
 		if (!scene.state.musicPath.empty())
@@ -2083,13 +2067,43 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				interaction.requestPlayerGotoXZ ||
 				interaction.mutatedRuntimeState;
 		};
+		StartupInteractionResult roomEntryInteraction;
+		bool hasPendingRoomEntryInteraction = false;
 		if (shouldRunRoomEntryCommands) {
-			StartupInteractionResult roomEntryInteraction;
 			if (!_engine.getStartupScript()->executeRoomEnterCommands(
 					scene.state.roomName, roomEntryInteraction)) {
 				return Common::kReadingFailed;
 			}
-			if (hasRoomEntryInteraction(roomEntryInteraction)) {
+
+			// Native room_setup applies entry-time state changes before the first visible room frame.
+			if (roomEntryInteraction.mutatedRuntimeState) {
+				if (!applyCurrentRoomRuntimeMutationsInPlace() &&
+						!refreshCurrentScene(true)) {
+					return Common::kReadingFailed;
+				}
+				roomEntryInteraction.mutatedRuntimeState = false;
+			}
+			hasPendingRoomEntryInteraction = hasRoomEntryInteraction(roomEntryInteraction);
+		}
+
+		transitionError = startupFlow.waitForRoomSetupTransitionHold();
+		if (transitionError.getCode() != Common::kNoError)
+			return transitionError;
+
+		logScenePaletteSummary("room setup stub palette", scene, 0.0f);
+		drawRoomScene(_engine, *screen, scene, 0.0f);
+		screen->makeAllDirty();
+		screen->update();
+
+		logScenePaletteSummary("room setup fade target", scene, scene.targetPaletteBrightness);
+		transitionError = startupFlow.fadeInRoomScene(scene.palette, scene.targetPaletteBrightness);
+		if (transitionError.getCode() != Common::kNoError)
+			return transitionError;
+		if (runtimeEntities)
+			runtimeEntities->resumeTimerCountdowns();
+
+		if (shouldRunRoomEntryCommands) {
+			if (hasPendingRoomEntryInteraction) {
 				bool didEntryTransition = false;
 				Common::Error entryInteractionError = interactionProcessor.handleInteractionResult(
 					roomEntryInteraction, didEntryTransition, Common::String());


Commit: 5f9328c2675bd9e0b6080108e9795ddb41b3089f
    https://github.com/scummvm/scummvm/commit/5f9328c2675bd9e0b6080108e9795ddb41b3089f
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:26+02:00

Commit Message:
HARVESTER: Reload state after startup disc switches

Changed paths:
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/room.cpp
    engines/harvester/saveload.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 62496d8366e..61e66852207 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Harvester validation pass around both scripted and dialogue-driven CD swaps after the startup disc-activation ordering patch. With `Show CD change prompts` enabled, recheck the overnight `CHANGE_TO_CD2_*` chain and the Sergeant `WARP_TO_LODGE_*` handoff, and confirm the prompt now switches discs before follow-on music/audio, room setup, or continuation work resumes. Pay particular attention to same-room post-prompt cases: verify the live room redraws with the target disc active, that stale loaded sounds from the previous disc do not survive the handoff, and that save-load restore on a non-default disc still lands in the correct room without needing an extra prompt.
 - Immediate: rerun the in-engine `DNA_2_DNALFT` entry with room/cursor logging enabled after the room-entry sequencing patch. Confirm `EDNA` no longer appears on the first visible DNALFT frame, that the room still fades in normally, and that follow-on entry side effects such as music, dialogue, cutscenes, and continuation tags still trigger in the same order after the fade.
 - Immediate: rerun the burned-diner `DNAEXT_X1` one-shot validation. The native `SET_REGION` opcode persists on the global region record, and the authored day-6 chain `DNAEXTEXIT1 -> HANG_ANIM_A -> C131.FST -> C043K.FST -> SET_DNA_EXT_OFF` ends by disabling `DNAEXT_X1`; confirm the live log now shows `roomRegions=3` immediately after `SET_DNA_EXT_OFF` and still `roomRegions=3` on the next fresh `MAP_2_DNA` entry, with no replay of `C043K.FST` unless a later script path explicitly re-enables that region.
 - Immediate: rerun the in-engine `DNAEXT` diner-door validation after the selected-item target remap. The authored `HARVEST.SCR` data uses `USEITEM "INV_TAPE" "DNAEXT" "DINERDOOR" "ADD_TAPE_DNA"` and the break-tool follow-up on `DINTAPGLS_HS`, while the live click logs had been resolving the cursor to `DNAEXT_QDOOR_HS`; confirm selected-item clicks now remap from the examine hotspot to the matching use-item target (`DINERDOOR` first, `DINTAPGLS_HS` after taping), that the new `inventory target remap` / `inventory target miss` logs behave as expected, and then recheck whether direct examine clicks still replay `DNAEXT_QDOOR_HS_COM -> START_DIALOG EDNA -> C043K.FST` more often than native.
@@ -27,6 +28,7 @@
 
 ## Last Confirmed Action
 
+- On March 28, 2026, tightened ScummVM's Harvester CD-switch sequencing so startup interactions now activate the target disc before follow-on music/audio or room-side continuation work resumes. Patched [`engines/harvester/harvester.h`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.h), [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp), [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) to route all startup disc changes through a new engine helper that flushes transient media state when the active disc actually changes, to move room-exit and room-interaction CD prompts ahead of the same interaction's music/audio side effects, and to force a current-room scene rebuild when a CD swap completes but control stays in the same room. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`; tracker progress counts remain `927` total functions, `823` named/documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, re-audited the burned-diner `C043K.FST` replay path in live Ghidra and the ScummVM room loop. The native `dispatch_room_event_actions` `SET_REGION` case persists directly on the global region record, and the authored day-6 `DNAEXTEXIT1 -> HANG_ANIM_A -> GOFLIC C131.FST -> GOFLIC C043K.FST -> SET_DNA_EXT_OFF` chain disables `DNAEXT_X1` as part of that persistent runtime state, not as a transient scene-only toggle. The existing ScummVM in-place mutation path was already rebuilding `DNAEXT` to `roomRegions=3` after `SET_DNA_EXT_OFF`, so the remaining regression was the handoff back into stored runtime state on later revisits. Patched [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) to add `setRuntimeRegionEnabled()` and synchronize live room-region presence back into `_runtimeRegions` during room-state capture, so one-shot region disables such as `DNAEXT_X1` do not resurrect on the next room entry. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, decoded the authored `HARVEST.SCR` door-use data to separate the remaining `DNAEXT` regression from the earlier room-entry timing bug. The script data confirms the initial tape path is authored on `DINERDOOR` (`USEITEM "INV_TAPE" "DNAEXT" "DINERDOOR" "ADD_TAPE_DNA"`), the taped break path is authored on `DINTAPGLS_HS` (`USEITEM "FIREAXE" "DNAEXT" "DINTAPGLS_HS" "DNA_BREAKF"` plus the other tools), and the generic examine hotspot `DNAEXT_QDOOR_HS` only owns `DNAEXT_QDOOR_HS_COM -> SET_FLAG DNA_S_SUICIDE_NOTE -> START_DIALOG EDNA`. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so selected inventory clicks now retarget to the topmost room object under the cursor that actually has a matching `USEITEM` record, and added explicit `inventory target remap` / `inventory target miss` debug logs instead of silently discarding those clicks when the hovered hotspot has no authored use-item action. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, verified the native DNALFT room-entry draw ordering in live Ghidra to explain the one-frame `EDNA` flash. `room_setup` (`0x73540`) spawns room NPCs, executes `dispatch_room_event_actions(g_current_room_def->on_enter_tag)`, and only afterward calls `upload_palette_to_vga(g_current_palette_buffer, 0x100)` plus `flush_dirty_rects_to_screen`, so room-entry `SET_NPC "EDNA" "F" "F"` runs before the first visible room frame. The `SET_NPC` implementation in `dispatch_room_event_actions` case `0x15` and `set_npc_active_visible_state` (`0x7cab0`) writes the record's `active` / `visible` bytes and immediately removes the live current-room render entity when visibility clears. This confirms the flash is a ScummVM ordering artifact: [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) currently populates and draws the room before calling `executeRoomEnterCommands`, then refreshes only after an entry command reports `mutatedRuntimeState`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index a0cfc55914f..9eb326e7a28 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -965,7 +965,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			}
 		}
 
-		if (!resources->setCurrentDisc(discNumber)) {
+		if (!_engine.activateStartupDisc(discNumber)) {
 			warning("Harvester: unable to activate disc %d resources", discNumber);
 			return Common::kReadingFailed;
 		}
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index bab663c5522..46b0820439d 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -221,6 +221,25 @@ void HarvesterEngine::stopStartupSound() {
 		_media->stopSound();
 }
 
+bool HarvesterEngine::activateStartupDisc(int discNumber) {
+	if (!_resources || discNumber <= 0)
+		return false;
+
+	const int previousDisc = _resources->getCurrentDisc();
+	if (!_resources->setCurrentDisc(discNumber))
+		return false;
+
+	if (_media && previousDisc != discNumber) {
+		_media->stopMusic();
+		_media->stopSound();
+		debugC(1, kDebugResources,
+			"Harvester: flushed transient startup media state after disc switch %d -> %d",
+			previousDisc, discNumber);
+	}
+
+	return true;
+}
+
 bool HarvesterEngine::toggleCombatDebugEnabled() {
 	_combatDebugEnabled = !_combatDebugEnabled;
 	return _combatDebugEnabled;
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 40e1be35eae..01827c3d164 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -111,6 +111,7 @@ public:
 	bool playStartupLoadedSound(int slot);
 	bool deleteStartupLoadedSound(int slot);
 	void stopStartupSound();
+	bool activateStartupDisc(int discNumber);
 	bool isCombatDebugEnabled() const { return _combatDebugEnabled; }
 	bool toggleCombatDebugEnabled();
 	bool isRoomDebugEnabled() const { return _roomDebugEnabled; }
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index d1eaa2023f9..7f8ffb5da67 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1658,7 +1658,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				}
 			}
 
-			if (!resources->setCurrentDisc(discNumber)) {
+			if (!_engine.activateStartupDisc(discNumber)) {
 				warning("Harvester: unable to activate disc %d resources", discNumber);
 				return Common::kReadingFailed;
 			}
@@ -1671,14 +1671,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				return Common::kReadingFailed;
 
 			for (uint exitStep = 0; exitStep < 128; ++exitStep) {
-				if (!exitInteraction.musicPath.empty())
-					(void)_engine.playStartupMusic(exitInteraction.musicPath);
-				startupFlow.executeStartupAudioCommands(exitInteraction.audioCommands);
 				if (exitInteraction.cdChangeDisc > 0) {
 					Common::Error cdPromptError = showCdChangePrompt(exitInteraction.cdChangeDisc);
 					if (cdPromptError.getCode() != Common::kNoError)
 						return cdPromptError;
 				}
+				if (!exitInteraction.musicPath.empty())
+					(void)_engine.playStartupMusic(exitInteraction.musicPath);
+				startupFlow.executeStartupAudioCommands(exitInteraction.audioCommands);
 
 				if (!exitInteraction.cutscenePath.empty()) {
 					FstPlayer fstPlayer(_engine);
@@ -1875,16 +1875,21 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				}
 
 				Common::String restoreMusicPath = engine.getStartupMusicPath();
-				if (!interaction.musicPath.empty()) {
-					(void)engine.playStartupMusic(interaction.musicPath);
-					restoreMusicPath = engine.getStartupMusicPath();
-				}
-				startupFlow.executeStartupAudioCommands(interaction.audioCommands);
+				bool discChanged = false;
 				if (interaction.cdChangeDisc > 0) {
+					ResourceManager *resources = engine.getResources();
+					const int previousDisc = resources ? resources->getCurrentDisc() : 0;
 					Common::Error cdPromptError = showCdChangePromptFn(interaction.cdChangeDisc);
 					if (cdPromptError.getCode() != Common::kNoError)
 						return cdPromptError;
+					discChanged = resources && previousDisc > 0 &&
+						previousDisc != resources->getCurrentDisc();
 				}
+				if (!interaction.musicPath.empty()) {
+					(void)engine.playStartupMusic(interaction.musicPath);
+					restoreMusicPath = engine.getStartupMusicPath();
+				}
+				startupFlow.executeStartupAudioCommands(interaction.audioCommands);
 				if (interaction.requestRoomRestart) {
 					pendingRoomChange = !scene.state.entranceName.empty()
 						? scene.state.entranceName
@@ -1897,6 +1902,19 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				if (roomTransition == kStartupRoomTransitionNone && !interaction.nextRoomName.empty())
 					roomTransition = kStartupRoomTransitionCloseup;
 
+				const bool needsCurrentRoomReload =
+					discChanged &&
+					!interaction.requestRoomRestart &&
+					interaction.nextRoomName.empty() &&
+					!interaction.mutatedRuntimeState;
+				if (needsCurrentRoomReload) {
+					if (!refreshCurrentSceneFn(true))
+						return Common::kReadingFailed;
+					startupFlow.executeStartupAudioCommands(scene.state.audioCommands);
+					if (!restoreMusicPath.empty())
+						(void)engine.playStartupMusic(restoreMusicPath);
+				}
+
 				if (!interaction.nextRoomName.empty() &&
 						roomTransition == kStartupRoomTransitionChangeRoom) {
 					Common::String resolvedTransitionTarget = interaction.nextRoomName;
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index 0cb474286f2..71337d4f8b8 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -131,7 +131,7 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 			return s.isLoading() ? Common::kReadingFailed : Common::kWritingFailed;
 
 	if (s.isLoading()) {
-		if (_resources && !(_resources->setCurrentDisc(currentDisc > 0 ? currentDisc : 1)))
+		if (!activateStartupDisc(currentDisc > 0 ? currentDisc : 1))
 			return Common::kReadingFailed;
 		if (!roomState.valid || roomState.roomName.empty())
 			return Common::kReadingFailed;


Commit: e4bcd0a69b708c66e869e3125cb0d982a728082c
    https://github.com/scummvm/scummvm/commit/e4bcd0a69b708c66e869e3125cb0d982a728082c
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:26+02:00

Commit Message:
HARVESTER: Add documented debug command console path

Changed paths:
    engines/harvester/console.cpp
    engines/harvester/console.h
    engines/harvester/flow.cpp
    engines/harvester/flow.h
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/engines/harvester/console.cpp b/engines/harvester/console.cpp
index 4d88c63ed1d..8ac02113889 100644
--- a/engines/harvester/console.cpp
+++ b/engines/harvester/console.cpp
@@ -305,12 +305,73 @@ static bool findRoomName(const Script &script, const Common::String &candidate,
 	return false;
 }
 
+static void printDebugCommandUsage(Console &console) {
+	console.debugPrintf("Usage: DEBUG_COMMAND <opcode> [arg1] [arg2] [arg3] [arg4]\n");
+	console.debugPrintf("   or: DEBUG_COMMAND <opcode> [arg1] [arg2] [arg3] CHAIN <action_tag>\n");
+}
+
+static bool parseDebugCommandArgs(int argc, const char **argv, StartupCommandRecord &command,
+		Common::String &errorMessage) {
+	command = StartupCommandRecord();
+	if (argc < 2) {
+		errorMessage = "Missing opcode";
+		return false;
+	}
+
+	command.opcodeName = argv[1];
+	uint positionalArgIndex = 0;
+	bool sawChain = false;
+	for (int i = 2; i < argc; ++i) {
+		const Common::String token = argv[i];
+		if (token.equalsIgnoreCase("CHAIN")) {
+			if (sawChain) {
+				errorMessage = "CHAIN may only be specified once";
+				return false;
+			}
+			if (i + 1 >= argc) {
+				errorMessage = "CHAIN requires an action tag";
+				return false;
+			}
+			if (i + 2 != argc) {
+				errorMessage = "CHAIN must be the final option";
+				return false;
+			}
+			command.arg4 = argv[i + 1];
+			sawChain = true;
+			break;
+		}
+
+		// Positional args map directly onto the startup command fields, with arg4 also
+		// serving as the optional continuation tag when CHAIN is not used explicitly.
+		switch (positionalArgIndex++) {
+		case 0:
+			command.arg1 = token;
+			break;
+		case 1:
+			command.arg2 = token;
+			break;
+		case 2:
+			command.arg3 = token;
+			break;
+		case 3:
+			command.arg4 = token;
+			break;
+		default:
+			errorMessage = "Too many command arguments";
+			return false;
+		}
+	}
+
+	return true;
+}
+
 } // End of anonymous namespace
 
 Console::Console() : GUI::Debugger() {
 	registerCmd("about", WRAP_METHOD(Console, Cmd_about));
 	registerCmd("DEBUG_COMBAT", WRAP_METHOD(Console, Cmd_debugCombat));
 	registerCmd("DEBUG_ACTIONS", WRAP_METHOD(Console, Cmd_debugActions));
+	registerCmd("DEBUG_COMMAND", WRAP_METHOD(Console, Cmd_debugCommand));
 	registerCmd("DEBUG_ROOM", WRAP_METHOD(Console, Cmd_debugRoom));
 	registerCmd("GOTO_ROOM", WRAP_METHOD(Console, Cmd_gotoRoom));
 }
@@ -421,6 +482,40 @@ bool Console::Cmd_debugActions(int argc, const char **argv) {
 	return true;
 }
 
+bool Console::Cmd_debugCommand(int argc, const char **argv) {
+	if (!g_engine) {
+		debugPrintf("Harvester engine is not active\n");
+		return true;
+	}
+
+	if (!g_engine->getStartupScript() || !g_engine->hasCurrentStartupSaveRoomState()) {
+		debugPrintf("DEBUG_COMMAND is only available while a room is active\n");
+		return true;
+	}
+
+	StartupCommandRecord command;
+	Common::String errorMessage;
+	if (!parseDebugCommandArgs(argc, argv, command, errorMessage)) {
+		if (!errorMessage.empty())
+			debugPrintf("%s\n", errorMessage.c_str());
+		printDebugCommandUsage(*this);
+		return true;
+	}
+
+	if (!g_engine->requestDebugCommand(command)) {
+		debugPrintf("Unable to queue debug command '%s'\n", command.opcodeName.c_str());
+		return true;
+	}
+
+	// The command only queues work here; the active room loop applies the interaction on its next tick.
+	Common::String commandLine = Common::String::format("Queued debug command %s", command.opcodeName.c_str());
+	const Common::String detail = buildCommandDetail(command);
+	if (!detail.empty())
+		commandLine += Common::String::format(" %s", detail.c_str());
+	debugPrintf("%s\n", commandLine.c_str());
+	return true;
+}
+
 bool Console::Cmd_gotoRoom(int argc, const char **argv) {
 	if (argc > 2) {
 		debugPrintf("Usage: GOTO_ROOM [room_name]\n");
diff --git a/engines/harvester/console.h b/engines/harvester/console.h
index 0c8aea1b1e4..219ce8cbbcc 100644
--- a/engines/harvester/console.h
+++ b/engines/harvester/console.h
@@ -33,6 +33,7 @@ private:
 	bool Cmd_debugCombat(int argc, const char **argv);
 	bool Cmd_debugRoom(int argc, const char **argv);
 	bool Cmd_debugActions(int argc, const char **argv);
+	bool Cmd_debugCommand(int argc, const char **argv);
 	bool Cmd_gotoRoom(int argc, const char **argv);
 public:
 	Console();
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index a1e178f1b19..3f42169c8e7 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -1623,11 +1623,34 @@ bool Flow::takeQueuedDialogueInteraction(StartupInteractionResult &interaction)
 	return true;
 }
 
+bool Flow::queueDebugInteraction(const StartupInteractionResult &interaction) {
+	if (_hasQueuedDebugInteraction)
+		return false;
+
+	// Console-triggered commands are consumed by the room loop so they follow the same
+	// transition, dialogue, and continuation handling as in-world interactions.
+	_queuedDebugInteraction = interaction;
+	_hasQueuedDebugInteraction = true;
+	return true;
+}
+
+bool Flow::takeQueuedDebugInteraction(StartupInteractionResult &interaction) {
+	if (!_hasQueuedDebugInteraction)
+		return false;
+
+	interaction = _queuedDebugInteraction;
+	_queuedDebugInteraction = StartupInteractionResult();
+	_hasQueuedDebugInteraction = false;
+	return true;
+}
+
 void Flow::prepareForNewGame() {
 	clearPendingMainMenuReturn();
 	clearPendingGameOverReturn();
 	clearPendingNewGameRestart();
 	_pendingDebugRoomName.clear();
+	_queuedDebugInteraction = StartupInteractionResult();
+	_hasQueuedDebugInteraction = false;
 	_engine.clearPendingLoadedStartupSaveRoomState();
 	_engine.clearPendingLoadedDialogueStateBlob();
 	_engine.clearCurrentStartupSaveRoomState();
diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index 37aa0d9ce16..381beea1f20 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -78,6 +78,9 @@ private:
 	void executeStartupAudioCommands(const Common::Array<StartupAudioCommand> &commands);
 	void queueDialogueInteraction(const StartupInteractionResult &interaction);
 	bool takeQueuedDialogueInteraction(StartupInteractionResult &interaction);
+	bool hasQueuedDebugInteraction() const { return _hasQueuedDebugInteraction; }
+	bool queueDebugInteraction(const StartupInteractionResult &interaction);
+	bool takeQueuedDebugInteraction(StartupInteractionResult &interaction);
 	void prepareForNewGame();
 	void requestNewGameRestart();
 	bool hasPendingNewGameRestart() const;
@@ -109,6 +112,8 @@ private:
 	RoomSystem _room;
 	StartupInteractionResult _queuedDialogueInteraction;
 	bool _hasQueuedDialogueInteraction = false;
+	StartupInteractionResult _queuedDebugInteraction;
+	bool _hasQueuedDebugInteraction = false;
 	bool _pendingNewGameRestart = false;
 	bool _pendingGameOverReturn = false;
 	bool _pendingMainMenuReturn = false;
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 46b0820439d..f12556f785e 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -250,6 +250,20 @@ bool HarvesterEngine::toggleRoomDebugEnabled() {
 	return _roomDebugEnabled;
 }
 
+bool HarvesterEngine::requestDebugCommand(const StartupCommandRecord &command) {
+	if (!_activeFlow || !_startupScript || !_currentStartupSaveRoomState.valid)
+		return false;
+	if (_activeFlow->hasQueuedDebugInteraction())
+		return false;
+
+	StartupInteractionResult interaction;
+	if (!_startupScript->executeDebugCommand(command, interaction))
+		return false;
+
+	// Defer application to the active room loop instead of mutating state from the console thread.
+	return _activeFlow->queueDebugInteraction(interaction);
+}
+
 bool HarvesterEngine::requestDebugRoomChange(const Common::String &roomName) {
 	return _activeFlow &&
 		_currentStartupSaveRoomState.valid &&
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 01827c3d164..3677f07584f 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -116,6 +116,7 @@ public:
 	bool toggleCombatDebugEnabled();
 	bool isRoomDebugEnabled() const { return _roomDebugEnabled; }
 	bool toggleRoomDebugEnabled();
+	bool requestDebugCommand(const StartupCommandRecord &command);
 	bool requestDebugRoomChange(const Common::String &roomName);
 	void captureCurrentStartupSaveRoomState(const Common::String &entranceName,
 		const Common::String &roomName, int playerX, int playerY, int playerZ, int playerFacing,
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 7f8ffb5da67..4dbb6164fda 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -3247,6 +3247,24 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				return Common::kReadingFailed;
 			break;
 		}
+		StartupInteractionResult queuedDebugInteraction;
+		if (startupFlow.takeQueuedDebugInteraction(queuedDebugInteraction)) {
+			// Run console-injected commands through the normal room interaction handler so
+			// CHANGE_ROOM, modal text, dialogue, and chained tags behave exactly like script-driven actions.
+			bool didDebugTransition = false;
+			Common::Error debugInteractionError = interactionProcessor.handleInteractionResult(
+				queuedDebugInteraction, didDebugTransition, Common::String());
+			if (debugInteractionError.getCode() != Common::kNoError)
+				return debugInteractionError;
+			if (startupFlow.hasPendingMainMenuReturn())
+				return Common::kNoError;
+			if (!pendingRoomChange.empty()) {
+				if (!stowCarriedRoomItemToInventory())
+					return Common::kReadingFailed;
+				break;
+			}
+			needsRedraw = true;
+		}
 		captureCurrentSaveState();
 		if (_inventory.isOpen() && _inventory.refreshIfRuntimeStateChanged())
 			needsRedraw = true;
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 613a391d414..75d075404a1 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1495,6 +1495,29 @@ bool Script::resolveUseItemInteraction(const Common::String &itemName, const Sta
 	return true;
 }
 
+bool Script::executeDebugCommand(const StartupCommandRecord &command,
+		StartupInteractionResult &result, bool allowTransitions) {
+	result = StartupInteractionResult();
+	if (command.opcodeName.empty())
+		return false;
+
+	// Reuse the normal action-tag executor by injecting a one-off command node and
+	// then removing it once the synthetic debug command has been evaluated.
+	Common::String debugTag;
+	for (uint suffix = 0;; ++suffix) {
+		debugTag = Common::String::format("__DEBUG_COMMAND_%u", suffix);
+		if (!findCommandRecord(debugTag))
+			break;
+	}
+
+	StartupCommandRecord debugCommand = command;
+	debugCommand.triggerTag = debugTag;
+	_commands.push_back(debugCommand);
+	const bool handled = executeActionTag(debugTag, result, allowTransitions);
+	_commands.pop_back();
+	return handled;
+}
+
 bool Script::executeActionTag(const Common::String &tag, StartupInteractionResult &result,
 		bool allowTransitions) {
 	result = StartupInteractionResult();
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 1a79f29f9d7..76b73578187 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -358,6 +358,8 @@ public:
 	bool resolveRegionInteraction(const StartupRegionRecord &region, StartupInteractionResult &result);
 	bool resolveUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target,
 		StartupInteractionResult &result);
+	bool executeDebugCommand(const StartupCommandRecord &command, StartupInteractionResult &result,
+		bool allowTransitions = true);
 	bool executeActionTag(const Common::String &tag, StartupInteractionResult &result,
 		bool allowTransitions = true);
 	bool executeTimerAction(const Common::String &timerName, StartupInteractionResult &result,


Commit: d643de25b925509bd9f406070f09e2a5caf78a05
    https://github.com/scummvm/scummvm/commit/d643de25b925509bd9f406070f09e2a5caf78a05
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:26+02:00

Commit Message:
HARVESTER: Restore saved discs and list rooms

Changed paths:
    engines/harvester/console.cpp
    engines/harvester/harvester.h
    engines/harvester/room.cpp
    engines/harvester/saveload.cpp


diff --git a/engines/harvester/console.cpp b/engines/harvester/console.cpp
index 8ac02113889..c4729a9a599 100644
--- a/engines/harvester/console.cpp
+++ b/engines/harvester/console.cpp
@@ -273,6 +273,9 @@ static void collectSortedRoomNames(const Script &script, Common::Array<Common::S
 	roomNames.clear();
 
 	for (const StartupRoomRecord &room : script.getRooms()) {
+		if (room.roomName.empty())
+			continue;
+
 		bool alreadyPresent = false;
 		for (const Common::String &existingName : roomNames) {
 			if (existingName.equalsIgnoreCase(room.roomName)) {
@@ -531,12 +534,12 @@ bool Console::Cmd_gotoRoom(int argc, const char **argv) {
 	Common::Array<Common::String> roomNames;
 	collectSortedRoomNames(*startupScript, roomNames);
 	if (roomNames.empty()) {
-		debugPrintf("No room targets are available\n");
+		debugPrintf("No rooms were found in HARVEST.SCR\n");
 		return true;
 	}
 
 	if (argc == 1) {
-		debugPrintf("Available room targets:\n");
+		debugPrintf("Rooms from HARVEST.SCR (%u):\n", (uint)roomNames.size());
 		for (const Common::String &roomName : roomNames)
 			debugPrintf("  %s\n", roomName.c_str());
 		return true;
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 3677f07584f..b0b6f1dc0ac 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -130,6 +130,7 @@ public:
 	const StartupSaveRoomState &getPendingLoadedStartupSaveRoomState() const {
 		return _pendingLoadedStartupSaveRoomState;
 	}
+	int getPendingLoadedStartupDisc() const { return _pendingLoadedStartupDisc; }
 	void clearPendingLoadedStartupSaveRoomState();
 	bool hasPendingLoadedDialogueStateBlob() const { return !_pendingLoadedDialogueStateBlob.empty(); }
 	const Common::Array<byte> &getPendingLoadedDialogueStateBlob() const { return _pendingLoadedDialogueStateBlob; }
@@ -149,6 +150,7 @@ private:
 	Script *_startupScript = nullptr;
 	StartupSaveRoomState _currentStartupSaveRoomState;
 	StartupSaveRoomState _pendingLoadedStartupSaveRoomState;
+	int _pendingLoadedStartupDisc = 0;
 	Common::Array<byte> _pendingLoadedDialogueStateBlob;
 	uint32 _pendingLoadedDialogueStateBlobVersion = 0;
 	Flow *_activeFlow = nullptr;
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 4dbb6164fda..98e3ef91dce 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -642,6 +642,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					return Common::kReadingFailed;
 				_engine.clearPendingLoadedDialogueStateBlob();
 			}
+			if (_engine.getPendingLoadedStartupDisc() > 0 &&
+					!_engine.activateStartupDisc(_engine.getPendingLoadedStartupDisc())) {
+				return Common::kReadingFailed;
+			}
 			const StartupSaveRoomState &loadedState = _engine.getPendingLoadedStartupSaveRoomState();
 			debugC(1, kDebugRoom,
 				"Harvester: applying pending loaded room state entrance='%s' room='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index 71337d4f8b8..9e1378213c7 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -81,13 +81,16 @@ void HarvesterEngine::clearCurrentStartupSaveRoomState() {
 
 void HarvesterEngine::clearPendingLoadedStartupSaveRoomState() {
 	_pendingLoadedStartupSaveRoomState.clear();
+	_pendingLoadedStartupDisc = 0;
 }
 
 Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 	if (!_startupScript)
 		return s.isLoading() ? Common::kReadingFailed : Common::kWritingFailed;
-	if (s.isLoading())
+	if (s.isLoading()) {
+		clearPendingLoadedStartupSaveRoomState();
 		clearPendingLoadedDialogueStateBlob();
+	}
 	if (s.isSaving() && !_currentStartupSaveRoomState.valid)
 		return Common::kWritingFailed;
 	if (!s.matchBytes(kHarvesterSaveMagic, sizeof(kHarvesterSaveMagic)))
@@ -95,8 +98,9 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 	if (!s.syncVersion(kHarvesterSaveVersion))
 		return Common::kReadingFailed;
 
-	int32 currentDisc = (_resources && _resources->getCurrentDisc() > 0) ? _resources->getCurrentDisc() : 1;
-	s.syncAsSint32LE(currentDisc, 14);
+	int32 serializedDisc = (_resources && _resources->getCurrentDisc() > 0) ? _resources->getCurrentDisc() : 1;
+	s.syncAsSint32LE(serializedDisc, 14);
+	const int restoredDisc = serializedDisc > 0 ? serializedDisc : 1;
 
 	StartupSaveRoomState roomState = s.isLoading()
 		? StartupSaveRoomState()
@@ -131,12 +135,13 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 			return s.isLoading() ? Common::kReadingFailed : Common::kWritingFailed;
 
 	if (s.isLoading()) {
-		if (!activateStartupDisc(currentDisc > 0 ? currentDisc : 1))
-			return Common::kReadingFailed;
 		if (!roomState.valid || roomState.roomName.empty())
 			return Common::kReadingFailed;
+		if (!activateStartupDisc(restoredDisc))
+			return Common::kReadingFailed;
 		_currentStartupSaveRoomState = roomState;
 		_pendingLoadedStartupSaveRoomState = roomState;
+		_pendingLoadedStartupDisc = restoredDisc;
 		logStartupSaveRoomState("loaded", roomState);
 	}
 	return Common::kNoError;


Commit: 436c3b586561c74532570d561f8d2994c33d5dc0
    https://github.com/scummvm/scummvm/commit/436c3b586561c74532570d561f8d2994c33d5dc0
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:27+02:00

Commit Message:
HARVESTER: Improve disc switch resources

Improve disc management and resource handling during disc switches.

Changed paths:
    engines/harvester/harvester.cpp
    engines/harvester/resources.cpp
    engines/harvester/resources.h
    engines/harvester/room.cpp


diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index f12556f785e..0dd2277a731 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -226,14 +226,26 @@ bool HarvesterEngine::activateStartupDisc(int discNumber) {
 		return false;
 
 	const int previousDisc = _resources->getCurrentDisc();
+	const bool discChanged = previousDisc > 0 && previousDisc != discNumber;
+	if (_media && discChanged) {
+		// Loaded audio streams can still be backed by the currently mounted archive.
+		// Stop them before the resource manager swaps the active disc out.
+		_media->stopMusic();
+		_media->stopSound();
+	}
+
 	if (!_resources->setCurrentDisc(discNumber))
 		return false;
 
-	if (_media && previousDisc != discNumber) {
-		_media->stopMusic();
-		_media->stopSound();
+	if (_media && discChanged) {
+		if (!_media->loadText()) {
+			warning("Harvester: unable to reload text resources after disc switch %d -> %d",
+				previousDisc, discNumber);
+			return false;
+		}
+
 		debugC(1, kDebugResources,
-			"Harvester: flushed transient startup media state after disc switch %d -> %d",
+			"Harvester: flushed transient startup media state and reloaded text after disc switch %d -> %d",
 			previousDisc, discNumber);
 	}
 
diff --git a/engines/harvester/resources.cpp b/engines/harvester/resources.cpp
index 80a91ac875b..0bacf04904b 100644
--- a/engines/harvester/resources.cpp
+++ b/engines/harvester/resources.cpp
@@ -151,13 +151,7 @@ Common::String ResourceManager::normalizeResourcePath(const Common::String &path
 }
 
 bool ResourceManager::mountStartupArchives() {
-	bool mountedAny = false;
-	for (int discNumber = kFirstDiscNumber; discNumber <= kLastDiscNumber; ++discNumber) {
-		if (ensureDiscMounted(discNumber))
-			mountedAny = true;
-	}
-
-	return mountedAny;
+	return setCurrentDisc(_currentDisc);
 }
 
 bool ResourceManager::setCurrentDisc(int discNumber) {
@@ -166,6 +160,7 @@ bool ResourceManager::setCurrentDisc(int discNumber) {
 	if (!ensureDiscMounted(discNumber))
 		return false;
 
+	unmountOtherDiscArchives(discNumber);
 	_currentDisc = discNumber;
 	debugC(1, kDebugResources, "Harvester: switched active disc to %d", discNumber);
 	return true;
@@ -210,6 +205,23 @@ bool ResourceManager::ensureDiscMounted(int discNumber) {
 	return mountedAny;
 }
 
+void ResourceManager::unmountOtherDiscArchives(int keepDiscNumber) {
+	for (int discNumber = kFirstDiscNumber; discNumber <= kLastDiscNumber; ++discNumber) {
+		if (discNumber == keepDiscNumber)
+			continue;
+
+		for (const ArchiveSpec &spec : kArchiveSpecs) {
+			const Common::String archiveName = buildDiscArchiveName(discNumber, spec.archiveSetId);
+			if (_search.hasArchive(archiveName)) {
+				debugC(1, kDebugResources,
+					"Harvester: unmounted disc %d set %c",
+					discNumber, spec.archiveSetId);
+				_search.remove(archiveName);
+			}
+		}
+	}
+}
+
 Common::Archive *ResourceManager::getMountedDiscArchive(int discNumber, char archiveSetId) const {
 	return _search.getArchive(buildDiscArchiveName(discNumber, archiveSetId));
 }
@@ -219,15 +231,6 @@ Common::Archive *ResourceManager::findArchiveForMember(char archiveSetId, const
 	if (archive && archive->hasFile(memberPath))
 		return archive;
 
-	for (int discNumber = kFirstDiscNumber; discNumber <= kLastDiscNumber; ++discNumber) {
-		if (discNumber == _currentDisc)
-			continue;
-
-		archive = getMountedDiscArchive(discNumber, archiveSetId);
-		if (archive && archive->hasFile(memberPath))
-			return archive;
-	}
-
 	return nullptr;
 }
 
@@ -294,7 +297,8 @@ Common::SeekableReadStream *ResourceManager::openFile(const Common::String &path
 			stream = SearchMan.createReadStreamForMember(memberPath);
 	}
 
-	debugC(3, kDebugResources, "Harvester: openFile('%s' -> '%s') %s", path.c_str(), normalized.c_str(), stream ? "hit" : "miss");
+	debugC(3, kDebugResources, "Harvester: openFile(disc=%d, '%s' -> '%s') %s",
+		_currentDisc, path.c_str(), normalized.c_str(), stream ? "hit" : "miss");
 	return stream;
 }
 
diff --git a/engines/harvester/resources.h b/engines/harvester/resources.h
index df51959ae96..e952adb88ae 100644
--- a/engines/harvester/resources.h
+++ b/engines/harvester/resources.h
@@ -48,6 +48,7 @@ public:
 
 private:
 	bool ensureDiscMounted(int discNumber);
+	void unmountOtherDiscArchives(int keepDiscNumber);
 	Common::Archive *getMountedDiscArchive(int discNumber, char archiveSetId) const;
 	Common::Archive *findArchiveForMember(char archiveSetId, const Common::Path &memberPath) const;
 	bool hasInMountedArchives(const Common::Path &memberPath) const;
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 98e3ef91dce..9bd441daf93 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1906,12 +1906,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				if (roomTransition == kStartupRoomTransitionNone && !interaction.nextRoomName.empty())
 					roomTransition = kStartupRoomTransitionCloseup;
 
-				const bool needsCurrentRoomReload =
+				const bool needsDisc3RoomReload =
 					discChanged &&
+					interaction.cdChangeDisc == 3 &&
 					!interaction.requestRoomRestart &&
 					interaction.nextRoomName.empty() &&
 					!interaction.mutatedRuntimeState;
-				if (needsCurrentRoomReload) {
+				if (needsDisc3RoomReload) {
 					if (!refreshCurrentSceneFn(true))
 						return Common::kReadingFailed;
 					startupFlow.executeStartupAudioCommands(scene.state.audioCommands);


Commit: 57a63deb394b0897e26d9750cbb3a55239572cd5
    https://github.com/scummvm/scummvm/commit/57a63deb394b0897e26d9750cbb3a55239572cd5
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:27+02:00

Commit Message:
HARVESTER: Implement FCMP warmup handling for audio streams

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/media_manager.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index d8852b9996b..1a7f941102d 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -114,8 +114,8 @@
   - `get_mscdex_version_bcd` uses `int 2Fh AX=150Ch` and returns the MSCDEX version number from `g_dpmi_real_mode_call_structure.ebx`.
 - `prompt_for_cdrom_disc_change` at `0x488a0` is the user-facing disc-swap / drive-not-ready path reached from menus and scripted transitions.
   - It stops the active music stream, frees dialogue/XFILE cache state, shows the `CD-ROM DRIVE NOT READY!` / `PLEASE TRY AGAIN...` prompt, then reruns `shutdown_extended_file_system`, `initialize_extended_file_system`, and the `dialogue.idx` reopen path once the requested disc becomes available.
-  - It also updates the active-disc dword at `0xc0fa8`. On disc `3`, it sets `0xd607c = 1`, and when the second parameter is `0` it additionally sets the one-shot latch at `0xc7e49 = 1`.
-  - `run_harvester_main_loop` checks that `0xc7e49` latch after interactions; when set, it clears the byte, calls `reload_town_world_from_script`, and immediately reruns `room_setup` on the pending room target. `dispatch_room_event_actions` case `CHANGE_CD` mirrors the same disc-3 path inline by calling `reload_town_world_from_script`, clearing `0xc7e49`, and then calling `room_setup`.
+  - It also updates `g_active_game_disc` at `0xc0fa8`. On disc `3`, it sets `g_disc3_mode_flag` at `0xd607c`, and when the second parameter is `0` it additionally sets the one-shot latch `g_pending_disc3_room_restart` at `0xc7e49`.
+  - `run_harvester_main_loop` checks `g_pending_disc3_room_restart` after interactions; when set, it clears the byte, calls `reload_town_world_from_script`, and immediately reruns `room_setup` on the name stored in `g_room_setup_target_name_buffer` at `0xd5b80`. `dispatch_room_event_actions` case `CHANGE_CD` mirrors the same disc-3 path inline by calling `reload_town_world_from_script`, clearing `g_pending_disc3_room_restart`, and then calling `room_setup`.
 
 ### Town Script Runtime
 
@@ -155,6 +155,7 @@
 - `xfile_read` and `xfile_size` use two distinct path modes:
   - `<digit>:\...` paths are archive-backed lookups through the numbered sets mounted by `initialize_extended_file_system`
   - bare relative paths are opened directly from the configured `CD_ROM` root
+- Spot-checking the Sergeant room resource cluster across the disc-prefixed `INDEX.001` files shows the current-disc archive contents are not identical. `1INDEX.001` and `2INDEX.001` both carry `1:\GRAPHIC\ROOMS\NTRYHALL.BM`, `1:\GRAPHIC\PAL\NTRYHALL.PAL`, `1:\GRAPHIC\MASKS\NTRYDORS.BM`, `1:\GRAPHIC\ROOMANIM\LDGEYES.ABM`, `1:\GRAPHIC\ROOMANIM\LDGEDRIP.ABM`, and `1:\GRAPHIC\CHAR\SERGENT.ABM`, while `3INDEX.001` only carries `1:\GRAPHIC\ROOMANIM\LDGEDRIP.ABM` and `1:\GRAPHIC\CHAR\SERGENT.ABM`. No alternate `NTRYHALL.BM` entry has been recovered on disc 3.
 - `initialize_town_script_runtime` next opens `HARVEST.SCR`, loads the full script blob through XFILE, and XOR-deobfuscates it in memory before registering the script vocabulary.
 - `run_harvester_main_loop` begins its presentation bootstrap by playing three direct-path FST sequences in this exact order:
   - `GRAPHIC\FST\VIRGLOGO.FST`
@@ -248,6 +249,7 @@
   - Once the wait transition is flushed, `room_setup` rebuilds the room render list in this order: matching enabled regions, room timers, visible object records whose `current_owner_or_room` matches the room, then room `ANIM` records whose `active` or `visible` state is set.
   - Decoding `HARVEST.SCR` confirms that room `OBJECT` records are not all named sprites. The data uses unnamed `OBJECT` records as blocker rectangles in many rooms, and it also uses hidden no-sprite `OBJECT` records with action tags as live hotspots. Concrete examples include the four anonymous `SERGEANTRM` `{// BLOCKS }` entries plus hidden action hotspots such as `PCHOUSE / PC_WINDOW` and `MR_JOHNS / MANHOLE`.
   - The same decoded script confirms the Sergeant remains CD3 handoff is data-authored to rebuild `SERGEANTRM` itself. `WARP_TO_LODGE -> WARP_TO_LODGE_A -> WARP_TO_LODGE_B` ends at `CHANGE_CD 3`, and `SERGEANTRM` only defines the hidden `LODGE_DOOR` mask plus region `LOD_X1 -> EXIT_LODGE`; there is no second script-side lodge-room transition chained after the CD prompt.
+  - Rechecking the live script against the ScummVM room/map resolver narrows the next authored step after that rebuild: `EXIT_LODGE_2` issues `CHANGE_ROOM "LODGE_2_MAP"`, and `LODGE_2_MAP` is a `MAP_ENTRANCE`, so the authored follow-up is the town-map selector handoff rather than an automatic jump into a deeper lodge interior.
   - On the player-present branch it then re-adds the stored live player actor from `DAT_000d5bd4`, places it from `g_current_room_entrance`, and seeds exact facing frames `0x3b`, `0x0e`, `0x2c`, and `0x28` for entrance-facing values `0`, `1`, `2`, and `3`.
   - Before the live room flush it explicitly resets the cursor entity to animation sequence `7`.
   - The final room palette/upload order is now explicit: after optional room-enter handlers, `room_setup` uploads `g_current_palette_buffer` and then calls `flush_dirty_rects_to_screen`.
@@ -370,6 +372,9 @@
   - `run_save_game_menu` returns immediately when it is clear.
   - `run_load_game_menu` sets it before applying a slot.
   - `run_game_over_screen` clears it until the main menu starts or loads a new session again.
+- `g_loading_saved_game` at `0xd607d` is the one-shot load-restore guard.
+  - `run_load_game_menu` sets it before returning to the room loop.
+  - `room_setup` consumes it to skip outgoing room-animation state capture, suppress room `on_enter` handlers, preserve the restored music path, and then clears the byte before returning.
 
 ## Interface And Input
 
@@ -1138,6 +1143,7 @@
 - `run_game_over_screen` at `0x7c540` is the death/game-over UI branch reached from the main loop after the player combat avatar enters its terminal failure path.
 - The low-level PCM/sample helpers are now bounded:
   - `load_sound_sample` at `0x18470` loads a one-shot sample through XFILE, accepting raw `WAVE` data or `FCMP`-compressed audio, and seeds the sample format/rate metadata used by the audio backend.
+    - On `FCMP` inputs it decodes the full payload but queues playback from `decoded_pcm + 0x1f4`, so the first `500` decoded bytes are discarded before the ring buffer fill.
   - `start_sound_state_playback` at `0x197b0` allocates a backend voice slot for an initialized `PcmSoundState` and records the active playback token in that state.
   - `is_sound_state_playing` at `0x198e0` polls whether the recorded voice/token pair for a `PcmSoundState` is still active.
   - `stop_sound_state_playback` at `0x19840` stops the active backend voice for a `PcmSoundState` without freeing the sample data.
@@ -1212,6 +1218,7 @@
       - The recovered fields are the source/destination far pointers, compressed and decoded byte counts, nibble index, predictor, delta, cached source byte, current nibble, step size, step index, and output bits per sample.
   - The stream-layer helpers are now named:
     - `start_music_stream` at `0x1bfc0` swaps to a new room/menu music file, opens the backing XFILE stream, primes the first audio chunk, and allocates a backend voice slot.
+      - On `FCMP` music it zeroes the first `0x64` decoded bytes of the primed chunk before queuing it, matching a `50`-sample warmup mute on the common 16-bit streams.
     - `update_music_stream` at `0x1b9a0` is the per-frame pump used by menus, dialogue waits, room logic, and the main loop; it refills stream buffers and applies pending volume ramps.
     - `release_music_stream_buffers` at `0x1a440` frees the stream's decode/ring buffers, unlocks their linear regions, and stops the active voice slot if that slot/token still belongs to the stream.
     - `pause_music_stream` at `0x1a620` stops the active voice without closing the underlying stream.
@@ -1232,6 +1239,7 @@
       - the separate driver callback object at `0xca194` reuses that same low-level `set_driver_control_entry_value` helper through `set_selected_sound_driver_control_value`
       - the exact gameplay meaning of that control value is still unresolved, but the indirection itself is confirmed
   - `load_dialogue_voice_sample` at `0x191d0` is the `play_dialogue_line`-specific loader that accepts either `WAVE` or `FCMP` voice assets and prepares them for one-shot playback through the shared sample backend.
+    - Its `FCMP` path matches `load_sound_sample`: after full decode it queues from `decoded_pcm + 0x1f4`, discarding the first `500` decoded bytes before playback begins.
   - `stop_sound_state_and_close_stream_file` at `0x1c4e0` is the shared cleanup path that stops active backend playback for a sound state and closes its backing stream/file handle at offset `+0x88`.
   - `run_fst_sequence_player` at `0x12b00` is the inner FST decoder/player reached from `play_fst_sequence`.
     - `FstFileHeader` is now a confirmed 0x20-byte header with fields `magic`, `width`, `height`, `max_frame_size`, `frame_count`, `frame_rate`, `sample_rate`, and `bits_per_sample`.
diff --git a/TRACKER.md b/TRACKER.md
index 61e66852207..2f8e817a622 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,12 +10,14 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Harvester audio validation pass on `FCMP`-backed music and dialogue after the startup warmup patch. Recheck at least one looping music start such as `SOUND/MUSIC/MENACE.CMP` or `SOUND/MUSIC/ANXIETY.CMP` plus a few voiced dialogue lines, confirm the clip-start pop is reduced, and watch for any new truncation of music attacks or first spoken phonemes. If a transient remains, compare it against the native split we confirmed in Ghidra: streamed music mutes the first `0x64` decoded bytes, while one-shot/dialogue `FCMP` playback skips the first `0x1f4` decoded bytes entirely.
+- Immediate: run an in-engine Harvester disc-swap validation pass after the current-disc-only resource remount change. With resource debug logging enabled, recheck both `CHANGE_TO_CD2_*` and `WARP_TO_LODGE_*`, confirm the previous disc's archives are unmounted before follow-on loads from the requested disc, verify loaded sound slots and streaming audio do not survive the swap, and confirm save-load restore on a non-default disc still reloads dialogue/text from the active disc instead of silently falling back to another mounted archive.
 - Immediate: run an in-engine Harvester validation pass around both scripted and dialogue-driven CD swaps after the startup disc-activation ordering patch. With `Show CD change prompts` enabled, recheck the overnight `CHANGE_TO_CD2_*` chain and the Sergeant `WARP_TO_LODGE_*` handoff, and confirm the prompt now switches discs before follow-on music/audio, room setup, or continuation work resumes. Pay particular attention to same-room post-prompt cases: verify the live room redraws with the target disc active, that stale loaded sounds from the previous disc do not survive the handoff, and that save-load restore on a non-default disc still lands in the correct room without needing an extra prompt.
 - Immediate: rerun the in-engine `DNA_2_DNALFT` entry with room/cursor logging enabled after the room-entry sequencing patch. Confirm `EDNA` no longer appears on the first visible DNALFT frame, that the room still fades in normally, and that follow-on entry side effects such as music, dialogue, cutscenes, and continuation tags still trigger in the same order after the fade.
 - Immediate: rerun the burned-diner `DNAEXT_X1` one-shot validation. The native `SET_REGION` opcode persists on the global region record, and the authored day-6 chain `DNAEXTEXIT1 -> HANG_ANIM_A -> C131.FST -> C043K.FST -> SET_DNA_EXT_OFF` ends by disabling `DNAEXT_X1`; confirm the live log now shows `roomRegions=3` immediately after `SET_DNA_EXT_OFF` and still `roomRegions=3` on the next fresh `MAP_2_DNA` entry, with no replay of `C043K.FST` unless a later script path explicitly re-enables that region.
 - Immediate: rerun the in-engine `DNAEXT` diner-door validation after the selected-item target remap. The authored `HARVEST.SCR` data uses `USEITEM "INV_TAPE" "DNAEXT" "DINERDOOR" "ADD_TAPE_DNA"` and the break-tool follow-up on `DINTAPGLS_HS`, while the live click logs had been resolving the cursor to `DNAEXT_QDOOR_HS`; confirm selected-item clicks now remap from the examine hotspot to the matching use-item target (`DINERDOOR` first, `DINTAPGLS_HS` after taping), that the new `inventory target remap` / `inventory target miss` logs behave as expected, and then recheck whether direct examine clicks still replay `DNAEXT_QDOOR_HS_COM -> START_DIALOG EDNA -> C043K.FST` more often than native.
 - Immediate: inspect the orphan cheat-handler clone that starts somewhere in the `0x742xx..0x74bxx` range. The live path is already confirmed inline inside `run_harvester_main_loop`, and `FUN_00078920` has no callers, so the next useful question is whether the second clone is another dead duplicate, an alternate build path, or code that should be merged back into a recovered function boundary.
-- Immediate: rerun the in-engine Sergeant remains/lodge handoff after the room-object semantics fix. Recreate the `GOT_REMAINS_FOR_LODGE` branch so `0x4333 -> C150.FST -> 0x433f..0x4385 -> C076.FST -> CHANGE_CD 3` lands in `SERGEANTRM`, confirm the room now materializes its authored blocker geometry correctly, and verify that the post-CD room still exposes only the scripted `LOD_X1 -> EXIT_LODGE` transition. If the expected deeper lodge entry is still absent, treat that as a script/data expectation mismatch rather than a remaining Sergeant CD-prompt bug.
+- Immediate: rerun the in-engine Sergeant remains/lodge handoff after the room-object semantics fix. Recreate the `GOT_REMAINS_FOR_LODGE` branch so `0x4333 -> C150.FST -> 0x433f..0x4385 -> C076.FST -> CHANGE_CD 3` lands in `SERGEANTRM`, confirm the room now materializes its authored blocker geometry correctly, and verify that the post-CD room still exposes only the scripted `LOD_X1 -> EXIT_LODGE` transition. When you click that region, confirm the native-authored follow-up is the town-map selector entry `LODGE_2_MAP` rather than any hidden deeper-lodge auto-transition. If the expected deeper lodge entry is still absent, treat that as a script/data expectation mismatch rather than a remaining Sergeant CD-prompt bug.
 - Immediate: run an in-engine Harvester validation pass focused on the `WOLFIE` crypt kill after the newly confirmed native parity check. Keep `combat` logging enabled, kill `WOLFIE`, and confirm the death bank still plays through to completion before the monster disappears, that `WOLFIE_DEATHCOM` only opens the crypt hotspots without leaving a clickable or visible monster residue, and that no stale monster entity survives in the live room after the `CRYPT` state refresh.
 - Immediate: run an in-engine Harvester combat validation pass with one low-band melee loadout and one high-band weapon while `combat` logging is enabled. Recheck `FIREAXE` in `CRYPT` to confirm the new logs show the native `loadout=17 weapon='FIREAXE' damage=3`, then switch to a higher-damage weapon such as `SHOTGUN` (`loadout=3 damage=7`) or `CHAINSAW` (`loadout=14 damage=8`) and confirm the contact log changes accordingly. During the same pass, verify `WOLFIE` continues to report its seeded per-hit monster damage in the spawn and attack logs so we can tell whether the remaining difficulty complaint is native data/cadence rather than duplicate hit application.
 - Immediate: rerun the in-engine `MBLM` glue validation pass after the stale-reference crash fix. With Moynahan alive (`MOYNAHAN_IS_DEAD=0`), click the room `GLUE` hotspot and confirm `MBLMGLUE_COM -> START_DIALOG MOYNAHAN_MBLM` still lands in the restored Moynahan handler, the native refusal lines `0x3e4d/0x3e52/0x3e56 -> 0x3e76/0x3e7a` play, the denied pickup still rolls back out of carry/inventory state, and dismissing the dialogue no longer trips the main-thread assert in `Common::String::compareToIgnoreCase` from the post-dialogue rollback check. Then validate both confirmed native acquisition paths still work: present `CASKET_PHOTO` / `CASKET_PHOTOCOPY` to Moynahan and take the glue-granting response at `dialog.rsp[0x18d]`, and on day 5 follow the `0x1bf..0x1c2` glue topic through response menu `0x1c3` to confirm `GLUE` plus `JUST_GOT_GLUE`. Finally, recheck the direct room-click pickup with `MOYNAHAN_IS_DEAD=1` to confirm the script-side `ADD_GLUE` branch still bypasses dialogue and awards the object cleanly.
@@ -28,7 +30,12 @@
 
 ## Last Confirmed Action
 
+- On March 29, 2026, audited `HARVEST.LE`'s `FCMP` warmup behavior in live Ghidra after investigating startup audio pops. Confirmed `start_music_stream` (`0x1bfc0`) decodes the first `FCMP` chunk and then zeroes the first `0x64` decoded bytes before queueing, while both `load_sound_sample` (`0x18470`) and `load_dialogue_voice_sample` (`0x191d0`) queue from `decoded_pcm + 0x1f4`, discarding the first `500` decoded bytes of one-shot/dialogue `FCMP` playback. Patched [`engines/harvester/media_manager.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/media_manager.cpp) to mirror that native split for startup music versus startup speech/other `FCMP` audio, updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) and [`TRACKER.md`](/Users/alex/Workspace/scummvm/TRACKER.md), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
+- On March 29, 2026, changed the Harvester startup resource path to enforce current-disc-only archive semantics during `CHANGE_CD` and save-load disc activation. Patched [`engines/harvester/resources.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/resources.cpp) and [`engines/harvester/resources.h`](/Users/alex/Workspace/scummvm/engines/harvester/resources.h) so startup now mounts only the active disc, `setCurrentDisc()` unmounts every other disc after the target disc is available, and archive-backed lookups no longer fall back across non-active discs. Patched [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp) so `activateStartupDisc()` stops any archive-backed audio before the swap and reloads the cached text tables afterward, which keeps `DIALOGUE.IDX` / `DIALOG.RSP` from surviving accidentally across a disc change. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
+- On March 28, 2026, traced `prompt_for_cdrom_disc_change` through `dispatch_room_event_actions`, `run_harvester_main_loop`, `room_setup`, `run_load_game_menu`, and the shipped `HARVEST.SCR` disc-swap chains. In live Ghidra, renamed `g_active_game_disc` (`0xc0fa8`), `g_disc3_mode_flag` (`0xd607c`), `g_pending_disc3_room_restart` (`0xc7e49`), `g_loading_saved_game` (`0xd607d`), and `g_room_setup_target_name_buffer` (`0xd5b80`), and updated `prompt_for_cdrom_disc_change` to `void prompt_for_cdrom_disc_change(uint requested_disc, char suppress_disc3_room_restart)`. The shipped script confirms the disc-3 Sergeant handoff is the only authored same-room implicit rebuild (`WARP_TO_LODGE_A -> WARP_TO_LODGE_B -> CHANGE_CD 3`), while the day-5 overnight disc-2 chains explicitly `CHANGE_ROOM` before `CHANGE_CD`; the extracted `DIALOGUE.IDX` files on discs 1, 2, and 3 are also byte-identical. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so the automatic same-room scene rebuild only happens after a successful disc-3 swap with no explicit room restart or other runtime mutation, updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) and [`TRACKER.md`](/Users/alex/Workspace/scummvm/TRACKER.md), saved `HARVEST.LE`, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
+- On March 28, 2026, revisited the Sergeant post-CD3 room-art question in live Ghidra, the shipped `HARVEST.SCR`, and the extracted disc-prefixed XFILE indices. Native `open_xfile_stream @ 0x1e530` and `xfile_read @ 0x1cde0` still distinguish archive-backed `<digit>:\...` paths from bare direct-CD paths, and the decoded `SERGEANTRM` records in `HARVEST.SCR` still author the room background, palette, mask, and most room art as `1:\...` archive-backed resources rather than as bare direct paths. Querying `1INDEX.001`, `2INDEX.001`, and `3INDEX.001` then showed there is no alternate Sergeant background on disc 3: discs 1 and 2 both contain `1:\GRAPHIC\ROOMS\NTRYHALL.BM`, `1:\GRAPHIC\PAL\NTRYHALL.PAL`, `1:\GRAPHIC\MASKS\NTRYDORS.BM`, `1:\GRAPHIC\ROOMANIM\LDGEYES.ABM`, `1:\GRAPHIC\ROOMANIM\LDGEDRIP.ABM`, and `1:\GRAPHIC\CHAR\SERGENT.ABM`, while disc 3 only contains `1:\GRAPHIC\ROOMANIM\LDGEDRIP.ABM` and `1:\GRAPHIC\CHAR\SERGENT.ABM` from that cluster. The current ScummVM source adds one plausible divergence on top: [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp) normalizes those script-authored `1:\...` fields to bare paths during parse, and [`engines/harvester/resources.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/resources.cpp) then lets bare archive lookups fall back across all mounted discs. Current conclusion: no alternate `BM` has been recovered for this handoff, and the strongest remaining engine-side question is whether stripping the archive-set prefix from script-authored resource paths is too aggressive. No code patch was applied from this audit; progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, tightened ScummVM's Harvester CD-switch sequencing so startup interactions now activate the target disc before follow-on music/audio or room-side continuation work resumes. Patched [`engines/harvester/harvester.h`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.h), [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp), [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) to route all startup disc changes through a new engine helper that flushes transient media state when the active disc actually changes, to move room-exit and room-interaction CD prompts ahead of the same interaction's music/audio side effects, and to force a current-room scene rebuild when a CD swap completes but control stays in the same room. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`; tracker progress counts remain `927` total functions, `823` named/documented, and `104` remaining `FUN_*`.
+- On March 28, 2026, rechecked the Sergeant remains CD3 handoff directly against live `HARVEST.LE`, the shipped `HARVEST.SCR`, and the current ScummVM room/map resolver after the user questioned whether the post-prompt target should be a different room. Native `handle_talk_to_sergeant @ 0x30340` still ends the remains branch with `C150.FST`, lines `0x433f..0x4385`, `C076.FST`, and `prompt_for_cdrom_disc_change(3, 0)` with no explicit room target change. Live `prompt_for_cdrom_disc_change @ 0x488a0` still sets the disc-3 reload latch `0xc7e49` when called with second arg `0`, so the main loop rebuilds the current pending room target rather than jumping to a new one. Decoding the shipped `HARVEST.SCR` confirmed the authored chain `WARP_TO_LODGE -> WARP_TO_LODGE_A -> WARP_TO_LODGE_B` stops at `CHANGE_CD 3`, and the rebuilt `SERGEANTRM` only exposes region `LOD_X1 -> EXIT_LODGE -> EXIT_LODGE_2 -> CHANGE_ROOM "LODGE_2_MAP"`. The current ScummVM `Flow::resolveRoomTransitionTarget()` / `runTownMapSelector()` path confirms `LODGE_2_MAP` is a `MAP_ENTRANCE` town-map handoff, not a hidden deeper lodge room. No code patch was needed from this re-audit; progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, re-audited the burned-diner `C043K.FST` replay path in live Ghidra and the ScummVM room loop. The native `dispatch_room_event_actions` `SET_REGION` case persists directly on the global region record, and the authored day-6 `DNAEXTEXIT1 -> HANG_ANIM_A -> GOFLIC C131.FST -> GOFLIC C043K.FST -> SET_DNA_EXT_OFF` chain disables `DNAEXT_X1` as part of that persistent runtime state, not as a transient scene-only toggle. The existing ScummVM in-place mutation path was already rebuilding `DNAEXT` to `roomRegions=3` after `SET_DNA_EXT_OFF`, so the remaining regression was the handoff back into stored runtime state on later revisits. Patched [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) to add `setRuntimeRegionEnabled()` and synchronize live room-region presence back into `_runtimeRegions` during room-state capture, so one-shot region disables such as `DNAEXT_X1` do not resurrect on the next room entry. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, decoded the authored `HARVEST.SCR` door-use data to separate the remaining `DNAEXT` regression from the earlier room-entry timing bug. The script data confirms the initial tape path is authored on `DINERDOOR` (`USEITEM "INV_TAPE" "DNAEXT" "DINERDOOR" "ADD_TAPE_DNA"`), the taped break path is authored on `DINTAPGLS_HS` (`USEITEM "FIREAXE" "DNAEXT" "DINTAPGLS_HS" "DNA_BREAKF"` plus the other tools), and the generic examine hotspot `DNAEXT_QDOOR_HS` only owns `DNAEXT_QDOOR_HS_COM -> SET_FLAG DNA_S_SUICIDE_NOTE -> START_DIALOG EDNA`. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so selected inventory clicks now retarget to the topmost room object under the cursor that actually has a matching `USEITEM` record, and added explicit `inventory target remap` / `inventory target miss` debug logs instead of silently discarding those clicks when the hovered hotspot has no authored use-item action. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, verified the native DNALFT room-entry draw ordering in live Ghidra to explain the one-frame `EDNA` flash. `room_setup` (`0x73540`) spawns room NPCs, executes `dispatch_room_event_actions(g_current_room_def->on_enter_tag)`, and only afterward calls `upload_palette_to_vga(g_current_palette_buffer, 0x100)` plus `flush_dirty_rects_to_screen`, so room-entry `SET_NPC "EDNA" "F" "F"` runs before the first visible room frame. The `SET_NPC` implementation in `dispatch_room_event_actions` case `0x15` and `set_npc_active_visible_state` (`0x7cab0`) writes the record's `active` / `visible` bytes and immediately removes the live current-room render entity when visibility clears. This confirms the flash is a ScummVM ordering artifact: [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) currently populates and draws the room before calling `executeRoomEnterCommands`, then refreshes only after an entry command reports `mutatedRuntimeState`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
diff --git a/engines/harvester/media_manager.cpp b/engines/harvester/media_manager.cpp
index 691863468fa..e953c0226cb 100644
--- a/engines/harvester/media_manager.cpp
+++ b/engines/harvester/media_manager.cpp
@@ -41,6 +41,11 @@ namespace Harvester {
 
 namespace {
 
+enum class FcmpWarmupMode {
+	kMusic,
+	kSample
+};
+
 static const int8 kHarvesterImaIndexAdjustTable[16] = {
 	-1, -1, -1, -1, 2, 4, 6, 8,
 	-1, -1, -1, -1, 2, 4, 6, 8
@@ -119,7 +124,29 @@ static uint32 decodeHarvesterFcmp(byte *dest, const byte *src, uint32 srcSize, u
 	return decodedSize;
 }
 
-static Audio::SeekableAudioStream *decodeStartupAudioStream(Common::SeekableReadStream *stream) {
+static void applyStartupFcmpWarmup(byte *decodedPcm, uint32 &decodedSize, FcmpWarmupMode warmupMode) {
+	if (!decodedPcm || decodedSize == 0)
+		return;
+
+	if (warmupMode == FcmpWarmupMode::kMusic) {
+		// Native start_music_stream zeros the first 0x64 decoded bytes of the first FCMP chunk.
+		memset(decodedPcm, 0, MIN<uint32>(decodedSize, 100));
+		return;
+	}
+
+	// Native load_sound_sample/load_dialogue_voice_sample queue from decoded_pcm + 0x1f4.
+	const uint32 trimBytes = MIN<uint32>(decodedSize, 500);
+	if (trimBytes == decodedSize) {
+		memset(decodedPcm, 0, decodedSize);
+		return;
+	}
+
+	memmove(decodedPcm, decodedPcm + trimBytes, decodedSize - trimBytes);
+	decodedSize -= trimBytes;
+}
+
+static Audio::SeekableAudioStream *decodeStartupAudioStream(Common::SeekableReadStream *stream,
+		FcmpWarmupMode warmupMode) {
 	if (!stream)
 		return nullptr;
 
@@ -156,7 +183,7 @@ static Audio::SeekableAudioStream *decodeStartupAudioStream(Common::SeekableRead
 				return nullptr;
 			}
 
-			const uint32 decodedSize = payloadSize * (bitsPerSample >> 2);
+			uint32 decodedSize = payloadSize * (bitsPerSample >> 2);
 			byte *decodedPcm = (byte *)malloc(decodedSize);
 			if (!decodedPcm) {
 				free(compressedPayload);
@@ -165,6 +192,7 @@ static Audio::SeekableAudioStream *decodeStartupAudioStream(Common::SeekableRead
 			}
 
 			decodeHarvesterFcmp(decodedPcm, compressedPayload, payloadSize, bitsPerSample);
+			applyStartupFcmpWarmup(decodedPcm, decodedSize, warmupMode);
 			free(compressedPayload);
 			delete stream;
 			return Audio::makeRawStream(decodedPcm, decodedSize, sampleRate,
@@ -177,8 +205,9 @@ static Audio::SeekableAudioStream *decodeStartupAudioStream(Common::SeekableRead
 	return Audio::makeWAVStream(stream, DisposeAfterUse::YES);
 }
 
-static Audio::SeekableAudioStream *openStartupAudioStream(ResourceManager &resources, const Common::String &path) {
-	return decodeStartupAudioStream(resources.openFile(path));
+static Audio::SeekableAudioStream *openStartupAudioStream(ResourceManager &resources,
+		const Common::String &path, FcmpWarmupMode warmupMode) {
+	return decodeStartupAudioStream(resources.openFile(path), warmupMode);
 }
 
 } // End of anonymous namespace
@@ -268,7 +297,8 @@ bool MediaManager::playMusic(const Common::String &path) {
 		return true;
 	}
 
-	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(_resources, path);
+	Audio::SeekableAudioStream *audioStream =
+		openStartupAudioStream(_resources, path, FcmpWarmupMode::kMusic);
 	if (!audioStream) {
 		warning("Harvester: unable to decode startup music '%s'", path.c_str());
 		return false;
@@ -323,7 +353,8 @@ bool MediaManager::playSound(const Common::String &path) {
 	_soundSlotIndex = (_soundSlotIndex + 1) % ARRAYSIZE(_soundHandles);
 	stopSoundHandle(_soundHandles[_soundSlotIndex]);
 
-	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(_resources, path);
+	Audio::SeekableAudioStream *audioStream =
+		openStartupAudioStream(_resources, path, FcmpWarmupMode::kSample);
 	if (!audioStream) {
 		warning("Harvester: unable to decode startup sound '%s'", path.c_str());
 		return false;
@@ -340,7 +371,8 @@ bool MediaManager::playSingleSound(const Common::String &path) {
 		return false;
 
 	stopSoundHandle(_singleSoundHandle);
-	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(_resources, path);
+	Audio::SeekableAudioStream *audioStream =
+		openStartupAudioStream(_resources, path, FcmpWarmupMode::kSample);
 	if (!audioStream) {
 		warning("Harvester: unable to decode startup sound '%s'", path.c_str());
 		return false;
@@ -364,7 +396,8 @@ bool MediaManager::playSpeech(const Common::String &path) {
 		return false;
 
 	stopSoundHandle(_speechHandle);
-	Audio::SeekableAudioStream *audioStream = openStartupAudioStream(_resources, path);
+	Audio::SeekableAudioStream *audioStream =
+		openStartupAudioStream(_resources, path, FcmpWarmupMode::kSample);
 	if (!audioStream) {
 		warning("Harvester: unable to decode startup speech '%s'", path.c_str());
 		return false;
@@ -410,7 +443,7 @@ bool MediaManager::playLoadedSound(int slot) {
 
 	Common::SeekableReadStream *stream = new Common::MemoryReadStream(
 		_loadedSoundData[slot].data(), _loadedSoundData[slot].size(), DisposeAfterUse::NO);
-	Audio::SeekableAudioStream *audioStream = decodeStartupAudioStream(stream);
+	Audio::SeekableAudioStream *audioStream = decodeStartupAudioStream(stream, FcmpWarmupMode::kSample);
 	if (!audioStream) {
 		warning("Harvester: unable to decode startup sound slot %d ('%s')",
 			slot, _loadedSoundPaths[slot].c_str());


Commit: 6b2a618d0a89861aac30b54a72d2e9dab8af9a5f
    https://github.com/scummvm/scummvm/commit/6b2a618d0a89861aac30b54a72d2e9dab8af9a5f
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:27+02:00

Commit Message:
HARVESTER: Load resource sets from CD folders

Changed paths:
    engines/harvester/resources.cpp


diff --git a/engines/harvester/resources.cpp b/engines/harvester/resources.cpp
index 0bacf04904b..6b562925b34 100644
--- a/engines/harvester/resources.cpp
+++ b/engines/harvester/resources.cpp
@@ -72,16 +72,37 @@ static Common::String buildDiscArchiveName(int discNumber, char archiveSetId) {
 	return Common::String::format("harvester-disc-%d-xfile-%c", discNumber, archiveSetId);
 }
 
-static Common::String buildDiscPrefixedFilePath(int discNumber, const char *path) {
-	return Common::String::format("%d%s", discNumber, path);
+static Common::String buildDiscDirectoryFilePath(int discNumber, const Common::String &path) {
+	return Common::String::format("CD%d/%s", discNumber, stripLeadingSlashes(path).c_str());
 }
 
-static Common::String resolveDiscArchiveStoragePath(int discNumber, const char *path) {
-	const Common::String prefixedPath = buildDiscPrefixedFilePath(discNumber, path);
-	if (SearchMan.hasFile(Common::Path(prefixedPath, '/')))
-		return prefixedPath;
-	if (discNumber == kFirstDiscNumber && SearchMan.hasFile(Common::Path(path, '/')))
-		return path;
+static Common::String resolveDiscArchiveStoragePath(int discNumber, const Common::String &path) {
+	const Common::String normalized = stripLeadingSlashes(path);
+	if (normalized.empty())
+		return Common::String();
+
+	const Common::String discPath = buildDiscDirectoryFilePath(discNumber, normalized);
+	if (SearchMan.hasFile(Common::Path(discPath, '/')))
+		return discPath;
+
+	if (discNumber == kFirstDiscNumber && SearchMan.hasFile(Common::Path(normalized, '/')))
+		return normalized;
+
+	return Common::String();
+}
+
+static Common::String resolveDiscLooseResourcePath(int discNumber, const Common::String &path) {
+	const Common::String normalized = stripLeadingSlashes(path);
+	if (normalized.empty())
+		return Common::String();
+
+	const Common::String discPath = buildDiscDirectoryFilePath(discNumber, normalized);
+	if (SearchMan.hasFile(Common::Path(discPath, '/')))
+		return discPath;
+
+	if (SearchMan.hasFile(Common::Path(normalized, '/')))
+		return normalized;
+
 	return Common::String();
 }
 
@@ -268,14 +289,17 @@ bool ResourceManager::hasFile(const Common::String &path) const {
 		if (!spec || memberPath.empty())
 			return false;
 
-		return findArchiveForMember(spec->archiveSetId, Common::Path(memberPath, '/')) != nullptr;
+		if (findArchiveForMember(spec->archiveSetId, Common::Path(memberPath, '/')) != nullptr)
+			return true;
+
+		return !resolveDiscArchiveStoragePath(_currentDisc, memberPath).empty();
 	}
 
 	const Common::Path memberPath(normalized, '/');
 	if (hasInMountedArchives(memberPath))
 		return true;
 
-	return SearchMan.hasFile(memberPath);
+	return !resolveDiscLooseResourcePath(_currentDisc, normalized).empty();
 }
 
 Common::SeekableReadStream *ResourceManager::openFile(const Common::String &path) const {
@@ -290,11 +314,19 @@ Common::SeekableReadStream *ResourceManager::openFile(const Common::String &path
 		Common::Archive *archive = spec ? findArchiveForMember(spec->archiveSetId, Common::Path(memberPath, '/')) : nullptr;
 		if (archive && !memberPath.empty())
 			stream = archive->createReadStreamForMember(Common::Path(memberPath, '/'));
+		if (!stream && !memberPath.empty()) {
+			const Common::String loosePath = resolveDiscArchiveStoragePath(_currentDisc, memberPath);
+			if (!loosePath.empty())
+				stream = SearchMan.createReadStreamForMember(Common::Path(loosePath, '/'));
+		}
 	} else {
 		const Common::Path memberPath(normalized, '/');
 		stream = openFromMountedArchives(memberPath);
-		if (!stream)
-			stream = SearchMan.createReadStreamForMember(memberPath);
+		if (!stream) {
+			const Common::String loosePath = resolveDiscLooseResourcePath(_currentDisc, normalized);
+			if (!loosePath.empty())
+				stream = SearchMan.createReadStreamForMember(Common::Path(loosePath, '/'));
+		}
 	}
 
 	debugC(3, kDebugResources, "Harvester: openFile(disc=%d, '%s' -> '%s') %s",


Commit: c4d4672f3d64843eac1a5fc833e5992af0e7ca88
    https://github.com/scummvm/scummvm/commit/c4d4672f3d64843eac1a5fc833e5992af0e7ca88
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:28+02:00

Commit Message:
HARVESTER: Implement town script reloading after disc changes

Changed paths:
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index 2f8e817a622..286ac9a65ad 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Next Suggested Action
 
+- Immediate: rerun the in-engine Sergeant remains/lodge handoff and at least one save-load restore on disc 3 after the town-script reload patch. With resource/general debug logging enabled, confirm `activateStartupDisc(3)` now logs a `HARVEST.SCR` reload, that the post-`C076.FST` handoff re-enters `SERGEANTRM` through a full room restart rather than a shallow scene refresh, and that any room-entry scripts authored in the disc-3 `HARVEST.SCR` are processed on that rebuild.
 - Immediate: run an in-engine Harvester audio validation pass on `FCMP`-backed music and dialogue after the startup warmup patch. Recheck at least one looping music start such as `SOUND/MUSIC/MENACE.CMP` or `SOUND/MUSIC/ANXIETY.CMP` plus a few voiced dialogue lines, confirm the clip-start pop is reduced, and watch for any new truncation of music attacks or first spoken phonemes. If a transient remains, compare it against the native split we confirmed in Ghidra: streamed music mutes the first `0x64` decoded bytes, while one-shot/dialogue `FCMP` playback skips the first `0x1f4` decoded bytes entirely.
 - Immediate: run an in-engine Harvester disc-swap validation pass after the current-disc-only resource remount change. With resource debug logging enabled, recheck both `CHANGE_TO_CD2_*` and `WARP_TO_LODGE_*`, confirm the previous disc's archives are unmounted before follow-on loads from the requested disc, verify loaded sound slots and streaming audio do not survive the swap, and confirm save-load restore on a non-default disc still reloads dialogue/text from the active disc instead of silently falling back to another mounted archive.
 - Immediate: run an in-engine Harvester validation pass around both scripted and dialogue-driven CD swaps after the startup disc-activation ordering patch. With `Show CD change prompts` enabled, recheck the overnight `CHANGE_TO_CD2_*` chain and the Sergeant `WARP_TO_LODGE_*` handoff, and confirm the prompt now switches discs before follow-on music/audio, room setup, or continuation work resumes. Pay particular attention to same-room post-prompt cases: verify the live room redraws with the target disc active, that stale loaded sounds from the previous disc do not survive the handoff, and that save-load restore on a non-default disc still lands in the correct room without needing an extra prompt.
@@ -32,6 +33,7 @@
 
 - On March 29, 2026, audited `HARVEST.LE`'s `FCMP` warmup behavior in live Ghidra after investigating startup audio pops. Confirmed `start_music_stream` (`0x1bfc0`) decodes the first `FCMP` chunk and then zeroes the first `0x64` decoded bytes before queueing, while both `load_sound_sample` (`0x18470`) and `load_dialogue_voice_sample` (`0x191d0`) queue from `decoded_pcm + 0x1f4`, discarding the first `500` decoded bytes of one-shot/dialogue `FCMP` playback. Patched [`engines/harvester/media_manager.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/media_manager.cpp) to mirror that native split for startup music versus startup speech/other `FCMP` audio, updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) and [`TRACKER.md`](/Users/alex/Workspace/scummvm/TRACKER.md), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 29, 2026, changed the Harvester startup resource path to enforce current-disc-only archive semantics during `CHANGE_CD` and save-load disc activation. Patched [`engines/harvester/resources.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/resources.cpp) and [`engines/harvester/resources.h`](/Users/alex/Workspace/scummvm/engines/harvester/resources.h) so startup now mounts only the active disc, `setCurrentDisc()` unmounts every other disc after the target disc is available, and archive-backed lookups no longer fall back across non-active discs. Patched [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp) so `activateStartupDisc()` stops any archive-backed audio before the swap and reloads the cached text tables afterward, which keeps `DIALOGUE.IDX` / `DIALOG.RSP` from surviving accidentally across a disc change. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
+- On March 30, 2026, revisited the Harvester `prompt_for_cdrom_disc_change` path in the ScummVM port after re-reading the existing Ghidra notes for native `reload_town_world_from_script`. Patched [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp), [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so the `HARVEST.SCR` reload now stays out of generic `activateStartupDisc()` and instead fires only from the confirmed disc-3 prompt / `CHANGE_CD` control-flow sites after the active disc swap completes. The implicit same-room disc-3 follow-up still queues a full room restart instead of a shallow `refreshCurrentScene(true)` pass, keeping the visible room rebuild aligned with the native `prompt_for_cdrom_disc_change -> reload_town_world_from_script -> room_setup` shape while avoiding unconfirmed save-load/menu-side town-script reloads. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, traced `prompt_for_cdrom_disc_change` through `dispatch_room_event_actions`, `run_harvester_main_loop`, `room_setup`, `run_load_game_menu`, and the shipped `HARVEST.SCR` disc-swap chains. In live Ghidra, renamed `g_active_game_disc` (`0xc0fa8`), `g_disc3_mode_flag` (`0xd607c`), `g_pending_disc3_room_restart` (`0xc7e49`), `g_loading_saved_game` (`0xd607d`), and `g_room_setup_target_name_buffer` (`0xd5b80`), and updated `prompt_for_cdrom_disc_change` to `void prompt_for_cdrom_disc_change(uint requested_disc, char suppress_disc3_room_restart)`. The shipped script confirms the disc-3 Sergeant handoff is the only authored same-room implicit rebuild (`WARP_TO_LODGE_A -> WARP_TO_LODGE_B -> CHANGE_CD 3`), while the day-5 overnight disc-2 chains explicitly `CHANGE_ROOM` before `CHANGE_CD`; the extracted `DIALOGUE.IDX` files on discs 1, 2, and 3 are also byte-identical. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so the automatic same-room scene rebuild only happens after a successful disc-3 swap with no explicit room restart or other runtime mutation, updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) and [`TRACKER.md`](/Users/alex/Workspace/scummvm/TRACKER.md), saved `HARVEST.LE`, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, revisited the Sergeant post-CD3 room-art question in live Ghidra, the shipped `HARVEST.SCR`, and the extracted disc-prefixed XFILE indices. Native `open_xfile_stream @ 0x1e530` and `xfile_read @ 0x1cde0` still distinguish archive-backed `<digit>:\...` paths from bare direct-CD paths, and the decoded `SERGEANTRM` records in `HARVEST.SCR` still author the room background, palette, mask, and most room art as `1:\...` archive-backed resources rather than as bare direct paths. Querying `1INDEX.001`, `2INDEX.001`, and `3INDEX.001` then showed there is no alternate Sergeant background on disc 3: discs 1 and 2 both contain `1:\GRAPHIC\ROOMS\NTRYHALL.BM`, `1:\GRAPHIC\PAL\NTRYHALL.PAL`, `1:\GRAPHIC\MASKS\NTRYDORS.BM`, `1:\GRAPHIC\ROOMANIM\LDGEYES.ABM`, `1:\GRAPHIC\ROOMANIM\LDGEDRIP.ABM`, and `1:\GRAPHIC\CHAR\SERGENT.ABM`, while disc 3 only contains `1:\GRAPHIC\ROOMANIM\LDGEDRIP.ABM` and `1:\GRAPHIC\CHAR\SERGENT.ABM` from that cluster. The current ScummVM source adds one plausible divergence on top: [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp) normalizes those script-authored `1:\...` fields to bare paths during parse, and [`engines/harvester/resources.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/resources.cpp) then lets bare archive lookups fall back across all mounted discs. Current conclusion: no alternate `BM` has been recovered for this handoff, and the strongest remaining engine-side question is whether stripping the archive-set prefix from script-authored resource paths is too aggressive. No code patch was applied from this audit; progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, tightened ScummVM's Harvester CD-switch sequencing so startup interactions now activate the target disc before follow-on music/audio or room-side continuation work resumes. Patched [`engines/harvester/harvester.h`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.h), [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp), [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) to route all startup disc changes through a new engine helper that flushes transient media state when the active disc actually changes, to move room-exit and room-interaction CD prompts ahead of the same interaction's music/audio side effects, and to force a current-room scene rebuild when a CD swap completes but control stays in the same room. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`; tracker progress counts remain `927` total functions, `823` named/documented, and `104` remaining `FUN_*`.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 9eb326e7a28..07f764c9e03 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -891,6 +891,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		ResourceManager *resources = _engine.getResources();
 		if (!resources)
 			return Common::kReadingFailed;
+		const int previousDisc = resources->getCurrentDisc();
 
 		if (_engine.shouldShowCdChangePrompts()) {
 			Graphics::Screen *screen = _engine.getScreen();
@@ -969,6 +970,14 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			warning("Harvester: unable to activate disc %d resources", discNumber);
 			return Common::kReadingFailed;
 		}
+		if (discNumber == 3 && previousDisc > 0 && previousDisc != resources->getCurrentDisc()) {
+			Script *startupScript = _engine.getStartupScript();
+			if (!startupScript || !startupScript->reloadTownWorld(*resources)) {
+				warning("Harvester: unable to reload town script after disc prompt %d -> %d",
+					previousDisc, discNumber);
+				return Common::kReadingFailed;
+			}
+		}
 
 		return Common::kNoError;
 	};
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 9bd441daf93..090d9bf02ee 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1593,6 +1593,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			ResourceManager *resources = _engine.getResources();
 			if (!resources)
 				return Common::kReadingFailed;
+			const int previousDisc = resources->getCurrentDisc();
 
 			if (_engine.shouldShowCdChangePrompts()) {
 				Graphics::Screen *screen = _engine.getScreen();
@@ -1666,6 +1667,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				warning("Harvester: unable to activate disc %d resources", discNumber);
 				return Common::kReadingFailed;
 			}
+			if (discNumber == 3 && previousDisc > 0 && previousDisc != resources->getCurrentDisc()) {
+				Script *startupScript = _engine.getStartupScript();
+				if (!startupScript || !startupScript->reloadTownWorld(*resources)) {
+					warning("Harvester: unable to reload town script after disc prompt %d -> %d",
+						previousDisc, discNumber);
+					return Common::kReadingFailed;
+				}
+			}
 
 			return Common::kNoError;
 		};
@@ -1913,11 +1922,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					interaction.nextRoomName.empty() &&
 					!interaction.mutatedRuntimeState;
 				if (needsDisc3RoomReload) {
-					if (!refreshCurrentSceneFn(true))
-						return Common::kReadingFailed;
-					startupFlow.executeStartupAudioCommands(scene.state.audioCommands);
-					if (!restoreMusicPath.empty())
-						(void)engine.playStartupMusic(restoreMusicPath);
+					pendingRoomChange = !scene.state.entranceName.empty()
+						? scene.state.entranceName
+						: scene.state.roomName;
+					didTransition = !pendingRoomChange.empty();
+					return didTransition ? Common::kNoError : Common::kReadingFailed;
 				}
 
 				if (!interaction.nextRoomName.empty() &&
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 75d075404a1..aa8b863c67c 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -463,6 +463,128 @@ bool Script::load(ResourceManager &resources) {
 	return true;
 }
 
+bool Script::reloadTownWorld(ResourceManager &resources) {
+	Common::Array<byte> reloadedData;
+	if (!resources.loadFile(_path, reloadedData)) {
+		warning("Harvester: unable to reload town script '%s'", _path.c_str());
+		return false;
+	}
+
+	const Common::Array<StartupFlagRecord> runtimeFlags = _runtimeFlags;
+	const Common::Array<StartupObjectRecord> runtimeObjects = _runtimeObjects;
+	const Common::Array<StartupAnimRecord> runtimeAnimations = _runtimeAnimations;
+	const Common::Array<StartupRegionRecord> runtimeRegions = _runtimeRegions;
+	const Common::Array<StartupNpcRecord> runtimeNpcs = _runtimeNpcs;
+	const Common::Array<StartupMonsterRecord> runtimeMonsters = _runtimeMonsters;
+	const Common::Array<StartupTimerRecord> runtimeTimers = _runtimeTimers;
+	const int playerCurrentHitPoints = _playerCurrentHitPoints;
+	const int playerCombatLoadout = _playerCombatLoadout;
+	const bool playerControlPaused = _playerControlPaused;
+
+	_data = reloadedData;
+	decode();
+	parseTownRecords(resources);
+	resetRuntimeState();
+
+	for (const StartupFlagRecord &flag : runtimeFlags) {
+		StartupFlagRecord *runtimeFlag = findRuntimeFlag(flag.name);
+		if (runtimeFlag) {
+			runtimeFlag->value = flag.value;
+			continue;
+		}
+
+		_runtimeFlags.push_back(flag);
+	}
+
+	for (const StartupObjectRecord &object : runtimeObjects) {
+		StartupObjectRecord *runtimeObject = findRuntimeObject(object.initialOwnerOrRoom, object.objectName);
+		if (!runtimeObject)
+			runtimeObject = findRuntimeObject(Common::String(), object.objectName);
+		if (!runtimeObject)
+			continue;
+
+		runtimeObject->currentX = object.currentX;
+		runtimeObject->currentY = object.currentY;
+		runtimeObject->currentZ = object.currentZ;
+		runtimeObject->currentOwnerOrRoom = object.currentOwnerOrRoom;
+		runtimeObject->visible = object.visible;
+		runtimeObject->runtimeVisible = object.runtimeVisible;
+		runtimeObject->identShown = object.identShown;
+	}
+
+	for (const StartupAnimRecord &anim : runtimeAnimations) {
+		StartupAnimRecord *runtimeAnim = findRuntimeAnim(anim.animName);
+		if (!runtimeAnim)
+			continue;
+
+		runtimeAnim->active = anim.active;
+		runtimeAnim->visible = anim.visible;
+		runtimeAnim->runtimeActive = anim.runtimeActive;
+		runtimeAnim->runtimeVisible = anim.runtimeVisible;
+		runtimeAnim->runtimeState = anim.runtimeState;
+	}
+
+	for (const StartupRegionRecord &region : runtimeRegions) {
+		StartupRegionRecord *runtimeRegion = findRuntimeRegion(region.regionName);
+		if (!runtimeRegion)
+			continue;
+
+		runtimeRegion->startEnabled = region.startEnabled;
+	}
+
+	for (const StartupNpcRecord &npc : runtimeNpcs) {
+		StartupNpcRecord *runtimeNpc = findRuntimeNpc(npc.npcName);
+		if (!runtimeNpc)
+			continue;
+
+		runtimeNpc->active = npc.active;
+		runtimeNpc->visible = npc.visible;
+		runtimeNpc->savedVisible = npc.savedVisible;
+		runtimeNpc->runtimeSpawned = npc.runtimeSpawned;
+		runtimeNpc->deathOrMonsterfyFlag = npc.deathOrMonsterfyFlag;
+		runtimeNpc->deathDamageType = npc.deathDamageType;
+	}
+
+	for (const StartupMonsterRecord &monster : runtimeMonsters) {
+		StartupMonsterRecord *runtimeMonster = findRuntimeMonster(monster.monsterName);
+		if (!runtimeMonster)
+			continue;
+
+		runtimeMonster->posX = monster.posX;
+		runtimeMonster->posY = monster.posY;
+		runtimeMonster->posZ = monster.posZ;
+		runtimeMonster->facing = monster.facing;
+		runtimeMonster->active = monster.active;
+		runtimeMonster->visible = monster.visible;
+		runtimeMonster->savedVisible = monster.savedVisible;
+		runtimeMonster->runtimeSpawned = monster.runtimeSpawned;
+		runtimeMonster->currentHitPoints = monster.currentHitPoints;
+		runtimeMonster->screenMinXBound = monster.screenMinXBound;
+		runtimeMonster->screenMaxXBound = monster.screenMaxXBound;
+	}
+
+	for (const StartupTimerRecord &timer : runtimeTimers) {
+		StartupTimerRecord *runtimeTimer = findRuntimeTimer(timer.timerName);
+		if (!runtimeTimer)
+			continue;
+
+		runtimeTimer->initialValue = timer.initialValue;
+		runtimeTimer->currentValue = timer.currentValue;
+		runtimeTimer->enabled = timer.enabled;
+		runtimeTimer->looping = timer.looping;
+		runtimeTimer->global = timer.global;
+	}
+
+	_playerCurrentHitPoints = clampPlayerHitPoints(playerCurrentHitPoints);
+	_playerCombatLoadout = clampPlayerCombatLoadout(playerCombatLoadout);
+	_playerControlPaused = playerControlPaused;
+
+	debugC(1, kDebugGeneral,
+		"Harvester: reloaded town script '%s' for disc %d while preserving runtime state",
+		_path.c_str(), resources.getCurrentDisc());
+	return true;
+}
+
 bool Script::loadConfig(ResourceManager &resources) {
 	Common::Array<byte> configData;
 	if (!resources.loadFile(kStartupConfigPath, configData)) {
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 76b73578187..44df79b1a88 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -309,6 +309,7 @@ public:
 	static const int kDefaultPlayerHitPoints = 30;
 
 	bool load(ResourceManager &resources);
+	bool reloadTownWorld(ResourceManager &resources);
 
 	const Common::String &getPath() const { return _path; }
 	const Common::Array<byte> &getData() const { return _data; }


Commit: 4a4f0a9e6ebf899d1984a32d38a86fc0bd26202f
    https://github.com/scummvm/scummvm/commit/4a4f0a9e6ebf899d1984a32d38a86fc0bd26202f
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:28+02:00

Commit Message:
HARVESTER: Restart disc 3 reloads from START

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 1a7f941102d..2916a492c2f 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -248,7 +248,7 @@
   - It then reloads `WAIT.PAL` and `WAIT.ABM`, hides the cursor entity, flushes the wait transition while the cursor is hidden, re-shows the cursor, uploads the wait palette, builds the room entities, and only after that uploads the room palette from `g_current_palette_buffer`.
   - Once the wait transition is flushed, `room_setup` rebuilds the room render list in this order: matching enabled regions, room timers, visible object records whose `current_owner_or_room` matches the room, then room `ANIM` records whose `active` or `visible` state is set.
   - Decoding `HARVEST.SCR` confirms that room `OBJECT` records are not all named sprites. The data uses unnamed `OBJECT` records as blocker rectangles in many rooms, and it also uses hidden no-sprite `OBJECT` records with action tags as live hotspots. Concrete examples include the four anonymous `SERGEANTRM` `{// BLOCKS }` entries plus hidden action hotspots such as `PCHOUSE / PC_WINDOW` and `MR_JOHNS / MANHOLE`.
-  - The same decoded script confirms the Sergeant remains CD3 handoff is data-authored to rebuild `SERGEANTRM` itself. `WARP_TO_LODGE -> WARP_TO_LODGE_A -> WARP_TO_LODGE_B` ends at `CHANGE_CD 3`, and `SERGEANTRM` only defines the hidden `LODGE_DOOR` mask plus region `LOD_X1 -> EXIT_LODGE`; there is no second script-side lodge-room transition chained after the CD prompt.
+  - Rechecking the live CD3 handoff against `handle_talk_to_sergeant`, `prompt_for_cdrom_disc_change`, `run_harvester_main_loop`, and the decoded disc-specific `HARVEST.SCR` changed the earlier conclusion about the Sergeant remains rebuild target. Native Sergeant dialogue still ends with `C076.FST` plus `prompt_for_cdrom_disc_change(3, 0)` and no explicit `CHANGE_ROOM`, but the disc-3 branch in `run_harvester_main_loop` then calls `reload_town_world_from_script(); room_setup(g_room_setup_target_name_buffer);`. The same Ghidra xrefs show `g_room_setup_target_name_buffer` is initialized to `START` at boot and is not rewritten by ordinary room transitions, so the post-prompt CD3 rebuild re-enters the disc-3 `START` entrance rather than the pre-swap Sergeant entrance. Decoding `CD3/HARVEST.SCR` then shows that `START` resolves to `RECEPTION`, whose room-enter chain owns the lodge fall / reception sequence.
   - Rechecking the live script against the ScummVM room/map resolver narrows the next authored step after that rebuild: `EXIT_LODGE_2` issues `CHANGE_ROOM "LODGE_2_MAP"`, and `LODGE_2_MAP` is a `MAP_ENTRANCE`, so the authored follow-up is the town-map selector handoff rather than an automatic jump into a deeper lodge interior.
   - On the player-present branch it then re-adds the stored live player actor from `DAT_000d5bd4`, places it from `g_current_room_entrance`, and seeds exact facing frames `0x3b`, `0x0e`, `0x2c`, and `0x28` for entrance-facing values `0`, `1`, `2`, and `3`.
   - Before the live room flush it explicitly resets the cursor entity to animation sequence `7`.
diff --git a/TRACKER.md b/TRACKER.md
index 286ac9a65ad..d1fd7d6b86c 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,7 +10,7 @@
 
 ## Next Suggested Action
 
-- Immediate: rerun the in-engine Sergeant remains/lodge handoff and at least one save-load restore on disc 3 after the town-script reload patch. With resource/general debug logging enabled, confirm `activateStartupDisc(3)` now logs a `HARVEST.SCR` reload, that the post-`C076.FST` handoff re-enters `SERGEANTRM` through a full room restart rather than a shallow scene refresh, and that any room-entry scripts authored in the disc-3 `HARVEST.SCR` are processed on that rebuild.
+- Immediate: rerun the in-engine Sergeant remains/lodge handoff and at least one save-load restore on disc 3 after the town-script reload patch. With resource/general debug logging enabled, confirm the post-`C076.FST` handoff reloads `CD3/HARVEST.SCR`, re-enters through the native-style `START` restart target, lands in `RECEPTION` instead of the stale CD2 `MAP_2_SERGEANT` entrance, and processes the disc-3 room-entry script on that rebuild.
 - Immediate: run an in-engine Harvester audio validation pass on `FCMP`-backed music and dialogue after the startup warmup patch. Recheck at least one looping music start such as `SOUND/MUSIC/MENACE.CMP` or `SOUND/MUSIC/ANXIETY.CMP` plus a few voiced dialogue lines, confirm the clip-start pop is reduced, and watch for any new truncation of music attacks or first spoken phonemes. If a transient remains, compare it against the native split we confirmed in Ghidra: streamed music mutes the first `0x64` decoded bytes, while one-shot/dialogue `FCMP` playback skips the first `0x1f4` decoded bytes entirely.
 - Immediate: run an in-engine Harvester disc-swap validation pass after the current-disc-only resource remount change. With resource debug logging enabled, recheck both `CHANGE_TO_CD2_*` and `WARP_TO_LODGE_*`, confirm the previous disc's archives are unmounted before follow-on loads from the requested disc, verify loaded sound slots and streaming audio do not survive the swap, and confirm save-load restore on a non-default disc still reloads dialogue/text from the active disc instead of silently falling back to another mounted archive.
 - Immediate: run an in-engine Harvester validation pass around both scripted and dialogue-driven CD swaps after the startup disc-activation ordering patch. With `Show CD change prompts` enabled, recheck the overnight `CHANGE_TO_CD2_*` chain and the Sergeant `WARP_TO_LODGE_*` handoff, and confirm the prompt now switches discs before follow-on music/audio, room setup, or continuation work resumes. Pay particular attention to same-room post-prompt cases: verify the live room redraws with the target disc active, that stale loaded sounds from the previous disc do not survive the handoff, and that save-load restore on a non-default disc still lands in the correct room without needing an extra prompt.
@@ -34,6 +34,7 @@
 - On March 29, 2026, audited `HARVEST.LE`'s `FCMP` warmup behavior in live Ghidra after investigating startup audio pops. Confirmed `start_music_stream` (`0x1bfc0`) decodes the first `FCMP` chunk and then zeroes the first `0x64` decoded bytes before queueing, while both `load_sound_sample` (`0x18470`) and `load_dialogue_voice_sample` (`0x191d0`) queue from `decoded_pcm + 0x1f4`, discarding the first `500` decoded bytes of one-shot/dialogue `FCMP` playback. Patched [`engines/harvester/media_manager.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/media_manager.cpp) to mirror that native split for startup music versus startup speech/other `FCMP` audio, updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) and [`TRACKER.md`](/Users/alex/Workspace/scummvm/TRACKER.md), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 29, 2026, changed the Harvester startup resource path to enforce current-disc-only archive semantics during `CHANGE_CD` and save-load disc activation. Patched [`engines/harvester/resources.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/resources.cpp) and [`engines/harvester/resources.h`](/Users/alex/Workspace/scummvm/engines/harvester/resources.h) so startup now mounts only the active disc, `setCurrentDisc()` unmounts every other disc after the target disc is available, and archive-backed lookups no longer fall back across non-active discs. Patched [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp) so `activateStartupDisc()` stops any archive-backed audio before the swap and reloads the cached text tables afterward, which keeps `DIALOGUE.IDX` / `DIALOG.RSP` from surviving accidentally across a disc change. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 30, 2026, revisited the Harvester `prompt_for_cdrom_disc_change` path in the ScummVM port after re-reading the existing Ghidra notes for native `reload_town_world_from_script`. Patched [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp), [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so the `HARVEST.SCR` reload now stays out of generic `activateStartupDisc()` and instead fires only from the confirmed disc-3 prompt / `CHANGE_CD` control-flow sites after the active disc swap completes. The implicit same-room disc-3 follow-up still queues a full room restart instead of a shallow `refreshCurrentScene(true)` pass, keeping the visible room rebuild aligned with the native `prompt_for_cdrom_disc_change -> reload_town_world_from_script -> room_setup` shape while avoiding unconfirmed save-load/menu-side town-script reloads. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
+- On March 30, 2026, re-audited the Sergeant CD3 handoff after the disc-3 script reload started failing on stale `MAP_2_SERGEANT` resolution. Decoding `CD2/HARVEST.SCR` and `CD3/HARVEST.SCR` confirmed `MAP_2_SERGEANT` only exists on CD2 while CD3 instead exposes `ROOM "RECEPTION"` with `ENTRANCE "BACK" "RECEPTION" "START"`. Live Ghidra then showed the native Sergeant path still ends at `prompt_for_cdrom_disc_change(3, 0)` with no explicit room target change, but the main-loop disc-3 branch consumes `g_pending_disc3_room_restart` via `reload_town_world_from_script(); room_setup(g_room_setup_target_name_buffer);`. Because `g_room_setup_target_name_buffer` is initialized to `START` at boot and ordinary room transitions queue `g_pending_room_name` instead, the native post-prompt rebuild restarts through `START`, not the old Sergeant entrance, which explains the CD3 `RECEPTION` landing. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so both implicit disc-3 same-room restart paths now queue `START` instead of `scene.state.entranceName`, and corrected the matching note in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md). Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, traced `prompt_for_cdrom_disc_change` through `dispatch_room_event_actions`, `run_harvester_main_loop`, `room_setup`, `run_load_game_menu`, and the shipped `HARVEST.SCR` disc-swap chains. In live Ghidra, renamed `g_active_game_disc` (`0xc0fa8`), `g_disc3_mode_flag` (`0xd607c`), `g_pending_disc3_room_restart` (`0xc7e49`), `g_loading_saved_game` (`0xd607d`), and `g_room_setup_target_name_buffer` (`0xd5b80`), and updated `prompt_for_cdrom_disc_change` to `void prompt_for_cdrom_disc_change(uint requested_disc, char suppress_disc3_room_restart)`. The shipped script confirms the disc-3 Sergeant handoff is the only authored same-room implicit rebuild (`WARP_TO_LODGE_A -> WARP_TO_LODGE_B -> CHANGE_CD 3`), while the day-5 overnight disc-2 chains explicitly `CHANGE_ROOM` before `CHANGE_CD`; the extracted `DIALOGUE.IDX` files on discs 1, 2, and 3 are also byte-identical. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so the automatic same-room scene rebuild only happens after a successful disc-3 swap with no explicit room restart or other runtime mutation, updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) and [`TRACKER.md`](/Users/alex/Workspace/scummvm/TRACKER.md), saved `HARVEST.LE`, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, revisited the Sergeant post-CD3 room-art question in live Ghidra, the shipped `HARVEST.SCR`, and the extracted disc-prefixed XFILE indices. Native `open_xfile_stream @ 0x1e530` and `xfile_read @ 0x1cde0` still distinguish archive-backed `<digit>:\...` paths from bare direct-CD paths, and the decoded `SERGEANTRM` records in `HARVEST.SCR` still author the room background, palette, mask, and most room art as `1:\...` archive-backed resources rather than as bare direct paths. Querying `1INDEX.001`, `2INDEX.001`, and `3INDEX.001` then showed there is no alternate Sergeant background on disc 3: discs 1 and 2 both contain `1:\GRAPHIC\ROOMS\NTRYHALL.BM`, `1:\GRAPHIC\PAL\NTRYHALL.PAL`, `1:\GRAPHIC\MASKS\NTRYDORS.BM`, `1:\GRAPHIC\ROOMANIM\LDGEYES.ABM`, `1:\GRAPHIC\ROOMANIM\LDGEDRIP.ABM`, and `1:\GRAPHIC\CHAR\SERGENT.ABM`, while disc 3 only contains `1:\GRAPHIC\ROOMANIM\LDGEDRIP.ABM` and `1:\GRAPHIC\CHAR\SERGENT.ABM` from that cluster. The current ScummVM source adds one plausible divergence on top: [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp) normalizes those script-authored `1:\...` fields to bare paths during parse, and [`engines/harvester/resources.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/resources.cpp) then lets bare archive lookups fall back across all mounted discs. Current conclusion: no alternate `BM` has been recovered for this handoff, and the strongest remaining engine-side question is whether stripping the archive-set prefix from script-authored resource paths is too aggressive. No code patch was applied from this audit; progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, tightened ScummVM's Harvester CD-switch sequencing so startup interactions now activate the target disc before follow-on music/audio or room-side continuation work resumes. Patched [`engines/harvester/harvester.h`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.h), [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp), [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) to route all startup disc changes through a new engine helper that flushes transient media state when the active disc actually changes, to move room-exit and room-interaction CD prompts ahead of the same interaction's music/audio side effects, and to force a current-room scene rebuild when a CD swap completes but control stays in the same room. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`; tracker progress counts remain `927` total functions, `823` named/documented, and `104` remaining `FUN_*`.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 090d9bf02ee..d09df96ea55 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1903,12 +1903,20 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					restoreMusicPath = engine.getStartupMusicPath();
 				}
 				startupFlow.executeStartupAudioCommands(interaction.audioCommands);
-				if (interaction.requestRoomRestart) {
-					pendingRoomChange = !scene.state.entranceName.empty()
-						? scene.state.entranceName
-						: scene.state.roomName;
+				auto queueImplicitRoomRestart = [&]() -> Common::Error {
+					// Native disc-3 prompt reloads re-enter room_setup through the startup target
+					// buffer, which remains "START" during ordinary play and resolves to CD3 RECEPTION.
+					pendingRoomChange =
+						discChanged && interaction.cdChangeDisc == 3
+							? Common::String("START")
+							: (!scene.state.entranceName.empty()
+								? scene.state.entranceName
+								: scene.state.roomName);
 					didTransition = !pendingRoomChange.empty();
 					return didTransition ? Common::kNoError : Common::kReadingFailed;
+				};
+				if (interaction.requestRoomRestart) {
+					return queueImplicitRoomRestart();
 				}
 
 				StartupRoomTransitionKind roomTransition = interaction.roomTransition;
@@ -1922,11 +1930,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					interaction.nextRoomName.empty() &&
 					!interaction.mutatedRuntimeState;
 				if (needsDisc3RoomReload) {
-					pendingRoomChange = !scene.state.entranceName.empty()
-						? scene.state.entranceName
-						: scene.state.roomName;
-					didTransition = !pendingRoomChange.empty();
-					return didTransition ? Common::kNoError : Common::kReadingFailed;
+					return queueImplicitRoomRestart();
 				}
 
 				if (!interaction.nextRoomName.empty() &&


Commit: 7688fc3abf88ef71cea3db3e548d0ca1e3228ec9
    https://github.com/scummvm/scummvm/commit/7688fc3abf88ef71cea3db3e548d0ca1e3228ec9
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:28+02:00

Commit Message:
HARVESTER: Preserve START on sergeant disc swap

Changed paths:
    TRACKER.md
    engines/harvester/npc/sergeant_dialogue.cpp
    engines/harvester/room.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index d1fd7d6b86c..5c80f9a0032 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,7 +10,7 @@
 
 ## Next Suggested Action
 
-- Immediate: rerun the in-engine Sergeant remains/lodge handoff and at least one save-load restore on disc 3 after the town-script reload patch. With resource/general debug logging enabled, confirm the post-`C076.FST` handoff reloads `CD3/HARVEST.SCR`, re-enters through the native-style `START` restart target, lands in `RECEPTION` instead of the stale CD2 `MAP_2_SERGEANT` entrance, and processes the disc-3 room-entry script on that rebuild.
+- Immediate: rerun the in-engine Sergeant remains/lodge handoff and at least one save-load restore on disc 3 after the explicit restart-target patch. With room/resource/general debug logging enabled, confirm the new `implicit room restart target=` log reports `target='START' explicit='START'` after `C076.FST`, that the handoff reloads `CD3/HARVEST.SCR`, lands in `RECEPTION` instead of the stale CD2 `MAP_2_SERGEANT` entrance, and processes the disc-3 room-entry script on that rebuild.
 - Immediate: run an in-engine Harvester audio validation pass on `FCMP`-backed music and dialogue after the startup warmup patch. Recheck at least one looping music start such as `SOUND/MUSIC/MENACE.CMP` or `SOUND/MUSIC/ANXIETY.CMP` plus a few voiced dialogue lines, confirm the clip-start pop is reduced, and watch for any new truncation of music attacks or first spoken phonemes. If a transient remains, compare it against the native split we confirmed in Ghidra: streamed music mutes the first `0x64` decoded bytes, while one-shot/dialogue `FCMP` playback skips the first `0x1f4` decoded bytes entirely.
 - Immediate: run an in-engine Harvester disc-swap validation pass after the current-disc-only resource remount change. With resource debug logging enabled, recheck both `CHANGE_TO_CD2_*` and `WARP_TO_LODGE_*`, confirm the previous disc's archives are unmounted before follow-on loads from the requested disc, verify loaded sound slots and streaming audio do not survive the swap, and confirm save-load restore on a non-default disc still reloads dialogue/text from the active disc instead of silently falling back to another mounted archive.
 - Immediate: run an in-engine Harvester validation pass around both scripted and dialogue-driven CD swaps after the startup disc-activation ordering patch. With `Show CD change prompts` enabled, recheck the overnight `CHANGE_TO_CD2_*` chain and the Sergeant `WARP_TO_LODGE_*` handoff, and confirm the prompt now switches discs before follow-on music/audio, room setup, or continuation work resumes. Pay particular attention to same-room post-prompt cases: verify the live room redraws with the target disc active, that stale loaded sounds from the previous disc do not survive the handoff, and that save-load restore on a non-default disc still lands in the correct room without needing an extra prompt.
@@ -35,6 +35,7 @@
 - On March 29, 2026, changed the Harvester startup resource path to enforce current-disc-only archive semantics during `CHANGE_CD` and save-load disc activation. Patched [`engines/harvester/resources.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/resources.cpp) and [`engines/harvester/resources.h`](/Users/alex/Workspace/scummvm/engines/harvester/resources.h) so startup now mounts only the active disc, `setCurrentDisc()` unmounts every other disc after the target disc is available, and archive-backed lookups no longer fall back across non-active discs. Patched [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp) so `activateStartupDisc()` stops any archive-backed audio before the swap and reloads the cached text tables afterward, which keeps `DIALOGUE.IDX` / `DIALOG.RSP` from surviving accidentally across a disc change. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 30, 2026, revisited the Harvester `prompt_for_cdrom_disc_change` path in the ScummVM port after re-reading the existing Ghidra notes for native `reload_town_world_from_script`. Patched [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp), [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so the `HARVEST.SCR` reload now stays out of generic `activateStartupDisc()` and instead fires only from the confirmed disc-3 prompt / `CHANGE_CD` control-flow sites after the active disc swap completes. The implicit same-room disc-3 follow-up still queues a full room restart instead of a shallow `refreshCurrentScene(true)` pass, keeping the visible room rebuild aligned with the native `prompt_for_cdrom_disc_change -> reload_town_world_from_script -> room_setup` shape while avoiding unconfirmed save-load/menu-side town-script reloads. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 30, 2026, re-audited the Sergeant CD3 handoff after the disc-3 script reload started failing on stale `MAP_2_SERGEANT` resolution. Decoding `CD2/HARVEST.SCR` and `CD3/HARVEST.SCR` confirmed `MAP_2_SERGEANT` only exists on CD2 while CD3 instead exposes `ROOM "RECEPTION"` with `ENTRANCE "BACK" "RECEPTION" "START"`. Live Ghidra then showed the native Sergeant path still ends at `prompt_for_cdrom_disc_change(3, 0)` with no explicit room target change, but the main-loop disc-3 branch consumes `g_pending_disc3_room_restart` via `reload_town_world_from_script(); room_setup(g_room_setup_target_name_buffer);`. Because `g_room_setup_target_name_buffer` is initialized to `START` at boot and ordinary room transitions queue `g_pending_room_name` instead, the native post-prompt rebuild restarts through `START`, not the old Sergeant entrance, which explains the CD3 `RECEPTION` landing. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so both implicit disc-3 same-room restart paths now queue `START` instead of `scene.state.entranceName`, and corrected the matching note in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md). Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
+- On March 30, 2026, followed up on the still-stale `MAP_2_SERGEANT` warning after the first `START` restart patch and found the missing link in the queued dialogue interaction itself. The Sergeant remains branch in [`engines/harvester/npc/sergeant_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.cpp) was still queueing only `requestRoomRestart = true` after `showCdChangePrompt(3)`, so the later room-loop handler no longer knew it was supposed to mirror the native disc-3 startup-target restart and fell back to `scene.state.entranceName`. Patched [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/npc/sergeant_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) to carry an explicit `roomRestartTargetName`, seed it to `START` on the Sergeant CD3 path, and log the selected implicit restart target with disc/scene context in the room loop. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, traced `prompt_for_cdrom_disc_change` through `dispatch_room_event_actions`, `run_harvester_main_loop`, `room_setup`, `run_load_game_menu`, and the shipped `HARVEST.SCR` disc-swap chains. In live Ghidra, renamed `g_active_game_disc` (`0xc0fa8`), `g_disc3_mode_flag` (`0xd607c`), `g_pending_disc3_room_restart` (`0xc7e49`), `g_loading_saved_game` (`0xd607d`), and `g_room_setup_target_name_buffer` (`0xd5b80`), and updated `prompt_for_cdrom_disc_change` to `void prompt_for_cdrom_disc_change(uint requested_disc, char suppress_disc3_room_restart)`. The shipped script confirms the disc-3 Sergeant handoff is the only authored same-room implicit rebuild (`WARP_TO_LODGE_A -> WARP_TO_LODGE_B -> CHANGE_CD 3`), while the day-5 overnight disc-2 chains explicitly `CHANGE_ROOM` before `CHANGE_CD`; the extracted `DIALOGUE.IDX` files on discs 1, 2, and 3 are also byte-identical. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so the automatic same-room scene rebuild only happens after a successful disc-3 swap with no explicit room restart or other runtime mutation, updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) and [`TRACKER.md`](/Users/alex/Workspace/scummvm/TRACKER.md), saved `HARVEST.LE`, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, revisited the Sergeant post-CD3 room-art question in live Ghidra, the shipped `HARVEST.SCR`, and the extracted disc-prefixed XFILE indices. Native `open_xfile_stream @ 0x1e530` and `xfile_read @ 0x1cde0` still distinguish archive-backed `<digit>:\...` paths from bare direct-CD paths, and the decoded `SERGEANTRM` records in `HARVEST.SCR` still author the room background, palette, mask, and most room art as `1:\...` archive-backed resources rather than as bare direct paths. Querying `1INDEX.001`, `2INDEX.001`, and `3INDEX.001` then showed there is no alternate Sergeant background on disc 3: discs 1 and 2 both contain `1:\GRAPHIC\ROOMS\NTRYHALL.BM`, `1:\GRAPHIC\PAL\NTRYHALL.PAL`, `1:\GRAPHIC\MASKS\NTRYDORS.BM`, `1:\GRAPHIC\ROOMANIM\LDGEYES.ABM`, `1:\GRAPHIC\ROOMANIM\LDGEDRIP.ABM`, and `1:\GRAPHIC\CHAR\SERGENT.ABM`, while disc 3 only contains `1:\GRAPHIC\ROOMANIM\LDGEDRIP.ABM` and `1:\GRAPHIC\CHAR\SERGENT.ABM` from that cluster. The current ScummVM source adds one plausible divergence on top: [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp) normalizes those script-authored `1:\...` fields to bare paths during parse, and [`engines/harvester/resources.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/resources.cpp) then lets bare archive lookups fall back across all mounted discs. Current conclusion: no alternate `BM` has been recovered for this handoff, and the strongest remaining engine-side question is whether stripping the archive-set prefix from script-authored resource paths is too aggressive. No code patch was applied from this audit; progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, tightened ScummVM's Harvester CD-switch sequencing so startup interactions now activate the target disc before follow-on music/audio or room-side continuation work resumes. Patched [`engines/harvester/harvester.h`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.h), [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp), [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) to route all startup disc changes through a new engine helper that flushes transient media state when the active disc actually changes, to move room-exit and room-interaction CD prompts ahead of the same interaction's music/audio side effects, and to force a current-room scene rebuild when a CD swap completes but control stays in the same room. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`; tracker progress counts remain `927` total functions, `823` named/documented, and `104` remaining `FUN_*`.
diff --git a/engines/harvester/npc/sergeant_dialogue.cpp b/engines/harvester/npc/sergeant_dialogue.cpp
index 730e4e22b3e..9b05e5a7fc5 100644
--- a/engines/harvester/npc/sergeant_dialogue.cpp
+++ b/engines/harvester/npc/sergeant_dialogue.cpp
@@ -184,6 +184,7 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		// Native prompt_for_cdrom_disc_change(3, 0) latches a main-loop room rebuild.
 		StartupInteractionResult interaction;
 		interaction.requestRoomRestart = true;
+		interaction.roomRestartTargetName = "START";
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 		return Common::kNoError;
 	};
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index d09df96ea55..041e080defd 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1904,15 +1904,23 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				}
 				startupFlow.executeStartupAudioCommands(interaction.audioCommands);
 				auto queueImplicitRoomRestart = [&]() -> Common::Error {
-					// Native disc-3 prompt reloads re-enter room_setup through the startup target
-					// buffer, which remains "START" during ordinary play and resolves to CD3 RECEPTION.
-					pendingRoomChange =
-						discChanged && interaction.cdChangeDisc == 3
-							? Common::String("START")
-							: (!scene.state.entranceName.empty()
-								? scene.state.entranceName
-								: scene.state.roomName);
+					if (!interaction.roomRestartTargetName.empty()) {
+						pendingRoomChange = interaction.roomRestartTargetName;
+					} else if (discChanged && interaction.cdChangeDisc == 3) {
+						// Native disc-3 prompt reloads re-enter room_setup through the startup target
+						// buffer, which remains "START" during ordinary play and resolves to CD3 RECEPTION.
+						pendingRoomChange = "START";
+					} else if (!scene.state.entranceName.empty()) {
+						pendingRoomChange = scene.state.entranceName;
+					} else {
+						pendingRoomChange = scene.state.roomName;
+					}
 					didTransition = !pendingRoomChange.empty();
+					debugC(1, kDebugRoom,
+						"Harvester: implicit room restart target='%s' explicit='%s' discChanged=%d cdChangeDisc=%d sceneEntrance='%s' sceneRoom='%s'",
+						pendingRoomChange.c_str(), interaction.roomRestartTargetName.c_str(),
+						discChanged ? 1 : 0, interaction.cdChangeDisc,
+						scene.state.entranceName.c_str(), scene.state.roomName.c_str());
 					return didTransition ? Common::kNoError : Common::kReadingFailed;
 				};
 				if (interaction.requestRoomRestart) {
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 44df79b1a88..d1e8eaf0ed5 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -299,6 +299,7 @@ struct StartupInteractionResult {
 	int playerGotoZ = 0;
 	bool requestPlayerGotoXZ = false;
 	bool requestRoomRestart = false;
+	Common::String roomRestartTargetName;
 	bool requestMainMenu = false;
 	bool abortRemainingCommandChain = false;
 	bool mutatedRuntimeState = false;


Commit: 4d006e43dcd6ae7e2d612f3b88483849efde9df7
    https://github.com/scummvm/scummvm/commit/4d006e43dcd6ae7e2d612f3b88483849efde9df7
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:29+02:00

Commit Message:
HARVESTER: Reload town script on save restore

Changed paths:
    TRACKER.md
    engines/harvester/saveload.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 5c80f9a0032..ec8c1f5c641 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,7 +10,7 @@
 
 ## Next Suggested Action
 
-- Immediate: rerun the in-engine Sergeant remains/lodge handoff and at least one save-load restore on disc 3 after the explicit restart-target patch. With room/resource/general debug logging enabled, confirm the new `implicit room restart target=` log reports `target='START' explicit='START'` after `C076.FST`, that the handoff reloads `CD3/HARVEST.SCR`, lands in `RECEPTION` instead of the stale CD2 `MAP_2_SERGEANT` entrance, and processes the disc-3 room-entry script on that rebuild.
+- Immediate: rerun the in-engine Sergeant remains/lodge handoff and at least one save-load restore on disc 3 after the explicit restart-target and save-restore town-script reload patches. With room/resource/general debug logging enabled, confirm the new `implicit room restart target=` log reports `target='START' explicit='START'` after `C076.FST`, that both the handoff and save restore reload `CD3/HARVEST.SCR`, that the handoff lands in `RECEPTION` instead of the stale CD2 `MAP_2_SERGEANT` entrance, and that the save restore no longer warns `unresolved materialized room target entrance='START' room='RECEPTION'!`.
 - Immediate: run an in-engine Harvester audio validation pass on `FCMP`-backed music and dialogue after the startup warmup patch. Recheck at least one looping music start such as `SOUND/MUSIC/MENACE.CMP` or `SOUND/MUSIC/ANXIETY.CMP` plus a few voiced dialogue lines, confirm the clip-start pop is reduced, and watch for any new truncation of music attacks or first spoken phonemes. If a transient remains, compare it against the native split we confirmed in Ghidra: streamed music mutes the first `0x64` decoded bytes, while one-shot/dialogue `FCMP` playback skips the first `0x1f4` decoded bytes entirely.
 - Immediate: run an in-engine Harvester disc-swap validation pass after the current-disc-only resource remount change. With resource debug logging enabled, recheck both `CHANGE_TO_CD2_*` and `WARP_TO_LODGE_*`, confirm the previous disc's archives are unmounted before follow-on loads from the requested disc, verify loaded sound slots and streaming audio do not survive the swap, and confirm save-load restore on a non-default disc still reloads dialogue/text from the active disc instead of silently falling back to another mounted archive.
 - Immediate: run an in-engine Harvester validation pass around both scripted and dialogue-driven CD swaps after the startup disc-activation ordering patch. With `Show CD change prompts` enabled, recheck the overnight `CHANGE_TO_CD2_*` chain and the Sergeant `WARP_TO_LODGE_*` handoff, and confirm the prompt now switches discs before follow-on music/audio, room setup, or continuation work resumes. Pay particular attention to same-room post-prompt cases: verify the live room redraws with the target disc active, that stale loaded sounds from the previous disc do not survive the handoff, and that save-load restore on a non-default disc still lands in the correct room without needing an extra prompt.
@@ -36,6 +36,7 @@
 - On March 30, 2026, revisited the Harvester `prompt_for_cdrom_disc_change` path in the ScummVM port after re-reading the existing Ghidra notes for native `reload_town_world_from_script`. Patched [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp), [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so the `HARVEST.SCR` reload now stays out of generic `activateStartupDisc()` and instead fires only from the confirmed disc-3 prompt / `CHANGE_CD` control-flow sites after the active disc swap completes. The implicit same-room disc-3 follow-up still queues a full room restart instead of a shallow `refreshCurrentScene(true)` pass, keeping the visible room rebuild aligned with the native `prompt_for_cdrom_disc_change -> reload_town_world_from_script -> room_setup` shape while avoiding unconfirmed save-load/menu-side town-script reloads. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 30, 2026, re-audited the Sergeant CD3 handoff after the disc-3 script reload started failing on stale `MAP_2_SERGEANT` resolution. Decoding `CD2/HARVEST.SCR` and `CD3/HARVEST.SCR` confirmed `MAP_2_SERGEANT` only exists on CD2 while CD3 instead exposes `ROOM "RECEPTION"` with `ENTRANCE "BACK" "RECEPTION" "START"`. Live Ghidra then showed the native Sergeant path still ends at `prompt_for_cdrom_disc_change(3, 0)` with no explicit room target change, but the main-loop disc-3 branch consumes `g_pending_disc3_room_restart` via `reload_town_world_from_script(); room_setup(g_room_setup_target_name_buffer);`. Because `g_room_setup_target_name_buffer` is initialized to `START` at boot and ordinary room transitions queue `g_pending_room_name` instead, the native post-prompt rebuild restarts through `START`, not the old Sergeant entrance, which explains the CD3 `RECEPTION` landing. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so both implicit disc-3 same-room restart paths now queue `START` instead of `scene.state.entranceName`, and corrected the matching note in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md). Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 30, 2026, followed up on the still-stale `MAP_2_SERGEANT` warning after the first `START` restart patch and found the missing link in the queued dialogue interaction itself. The Sergeant remains branch in [`engines/harvester/npc/sergeant_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.cpp) was still queueing only `requestRoomRestart = true` after `showCdChangePrompt(3)`, so the later room-loop handler no longer knew it was supposed to mirror the native disc-3 startup-target restart and fell back to `scene.state.entranceName`. Patched [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/npc/sergeant_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) to carry an explicit `roomRestartTargetName`, seed it to `START` on the Sergeant CD3 path, and log the selected implicit restart target with disc/scene context in the room loop. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
+- On March 30, 2026, investigated a separate save-load failure on lodge saves after the Sergeant CD3 handoff was corrected. The load log showed disc 3 mounting and dialogue/text reloading, but the room restore then failed in `materializeRoomState()` with `unresolved materialized room target entrance='START' room='RECEPTION'!`, which meant the save-load path was still resolving the room against the pre-swap town script. Patched [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) so a restored disc switch now immediately calls `reloadTownWorld()` and logs `reloaded town script after save restore disc switch ...` before the pending room state is consumed. `rtk make -C build-vscode-harvester-debug -j4 scummvm` still needs to be rerun after this patch; progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, traced `prompt_for_cdrom_disc_change` through `dispatch_room_event_actions`, `run_harvester_main_loop`, `room_setup`, `run_load_game_menu`, and the shipped `HARVEST.SCR` disc-swap chains. In live Ghidra, renamed `g_active_game_disc` (`0xc0fa8`), `g_disc3_mode_flag` (`0xd607c`), `g_pending_disc3_room_restart` (`0xc7e49`), `g_loading_saved_game` (`0xd607d`), and `g_room_setup_target_name_buffer` (`0xd5b80`), and updated `prompt_for_cdrom_disc_change` to `void prompt_for_cdrom_disc_change(uint requested_disc, char suppress_disc3_room_restart)`. The shipped script confirms the disc-3 Sergeant handoff is the only authored same-room implicit rebuild (`WARP_TO_LODGE_A -> WARP_TO_LODGE_B -> CHANGE_CD 3`), while the day-5 overnight disc-2 chains explicitly `CHANGE_ROOM` before `CHANGE_CD`; the extracted `DIALOGUE.IDX` files on discs 1, 2, and 3 are also byte-identical. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so the automatic same-room scene rebuild only happens after a successful disc-3 swap with no explicit room restart or other runtime mutation, updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) and [`TRACKER.md`](/Users/alex/Workspace/scummvm/TRACKER.md), saved `HARVEST.LE`, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, revisited the Sergeant post-CD3 room-art question in live Ghidra, the shipped `HARVEST.SCR`, and the extracted disc-prefixed XFILE indices. Native `open_xfile_stream @ 0x1e530` and `xfile_read @ 0x1cde0` still distinguish archive-backed `<digit>:\...` paths from bare direct-CD paths, and the decoded `SERGEANTRM` records in `HARVEST.SCR` still author the room background, palette, mask, and most room art as `1:\...` archive-backed resources rather than as bare direct paths. Querying `1INDEX.001`, `2INDEX.001`, and `3INDEX.001` then showed there is no alternate Sergeant background on disc 3: discs 1 and 2 both contain `1:\GRAPHIC\ROOMS\NTRYHALL.BM`, `1:\GRAPHIC\PAL\NTRYHALL.PAL`, `1:\GRAPHIC\MASKS\NTRYDORS.BM`, `1:\GRAPHIC\ROOMANIM\LDGEYES.ABM`, `1:\GRAPHIC\ROOMANIM\LDGEDRIP.ABM`, and `1:\GRAPHIC\CHAR\SERGENT.ABM`, while disc 3 only contains `1:\GRAPHIC\ROOMANIM\LDGEDRIP.ABM` and `1:\GRAPHIC\CHAR\SERGENT.ABM` from that cluster. The current ScummVM source adds one plausible divergence on top: [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp) normalizes those script-authored `1:\...` fields to bare paths during parse, and [`engines/harvester/resources.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/resources.cpp) then lets bare archive lookups fall back across all mounted discs. Current conclusion: no alternate `BM` has been recovered for this handoff, and the strongest remaining engine-side question is whether stripping the archive-set prefix from script-authored resource paths is too aggressive. No code patch was applied from this audit; progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
 - On March 28, 2026, tightened ScummVM's Harvester CD-switch sequencing so startup interactions now activate the target disc before follow-on music/audio or room-side continuation work resumes. Patched [`engines/harvester/harvester.h`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.h), [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp), [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) to route all startup disc changes through a new engine helper that flushes transient media state when the active disc actually changes, to move room-exit and room-interaction CD prompts ahead of the same interaction's music/audio side effects, and to force a current-room scene rebuild when a CD swap completes but control stays in the same room. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`; tracker progress counts remain `927` total functions, `823` named/documented, and `104` remaining `FUN_*`.
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index 9e1378213c7..cc66ec30f72 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -137,8 +137,22 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 	if (s.isLoading()) {
 		if (!roomState.valid || roomState.roomName.empty())
 			return Common::kReadingFailed;
+		const int previousDisc = (_resources && _resources->getCurrentDisc() > 0)
+			? _resources->getCurrentDisc()
+			: 0;
 		if (!activateStartupDisc(restoredDisc))
 			return Common::kReadingFailed;
+		if (previousDisc > 0 && previousDisc != restoredDisc) {
+			if (!_startupScript->reloadTownWorld(*_resources)) {
+				warning("Harvester: unable to reload town script after save restore disc switch %d -> %d",
+					previousDisc, restoredDisc);
+				return Common::kReadingFailed;
+			}
+
+			debugC(1, kDebugResources,
+				"Harvester: reloaded town script after save restore disc switch %d -> %d",
+				previousDisc, restoredDisc);
+		}
 		_currentStartupSaveRoomState = roomState;
 		_pendingLoadedStartupSaveRoomState = roomState;
 		_pendingLoadedStartupDisc = restoredDisc;


Commit: 2952e0eb110caeca0deb77ae48b460b820678d99
    https://github.com/scummvm/scummvm/commit/2952e0eb110caeca0deb77ae48b460b820678d99
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:29+02:00

Commit Message:
HARVESTER: Fix monster combat field order

Changed paths:
    FILE_FORMATS.md
    TRACKER.md
    engines/harvester/monster.h
    engines/harvester/saveload.cpp
    engines/harvester/script.cpp


diff --git a/FILE_FORMATS.md b/FILE_FORMATS.md
index b24136661cf..b2b9bac80b6 100644
--- a/FILE_FORMATS.md
+++ b/FILE_FORMATS.md
@@ -211,7 +211,7 @@ python3 -c 'import sys,pathlib,signal; signal.signal(signal.SIGPIPE, signal.SIG_
 | `OBJECT` | `initialX initialY boundsX2 boundsY2 initialZ zExtent` | `initialOwnerOrRoom objectName spritePath altSpritePath reservedString40 inventoryTextKey reservedXFlag identTextKey operatable visible actionTag interactionLabel` |
 | `ANIM` | `x y z frameDelay` | `roomName resourcePath animName active visible looping backward pingPong remove` |
 | `NPC` | `x y z frameDelay` | `roomName modelPath npcName monsterfyTargetName active visible onDeathActionTag audioPath entityInitArg` |
-| `MONSTER` | `x y z ...` | Sample data and native analysis confirm sound-trigger timing columns for attack, hit, footstep, and death. The intermediate string columns at offsets `0x38`, `0x3c`, `0x44`, and `0x48` remain reserved in current data and have no recovered read-side consumers. |
+| `MONSTER` | `x y z engageDistance initialHitPoints damageAmount attackSoundTriggerFrame hitSoundTriggerFrame footstepSoundTriggerFrame deathSoundTriggerFrame` | Sample data and native analysis confirm those first six combat columns map to `MonsterRecord.engage_distance`, `initial_hit_points`, and `damage_amount`, followed by the four sound-trigger timing columns. The intermediate string columns at offsets `0x38`, `0x3c`, `0x44`, and `0x48` remain reserved in current data and have no recovered read-side consumers. |
 | `REGION` | `left top right bottom minZ maxZ` | `regionName direction roomName actionTag startEnabled cursorEnabled` |
 | `FLAG` | none | `name value` |
 | `COMMAND` | none | `triggerTag opcodeName arg1 arg2 arg3 [arg4]` |
diff --git a/TRACKER.md b/TRACKER.md
index ec8c1f5c641..edb31793b9d 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -8,8 +8,15 @@
 - Remaining `FUN_*`: `104`
 - Remaining undocumented total: `104`
 
+## Last Confirmed Action
+
+- On March 30, 2026, rechecked native Harvester timing semantics in live `HARVEST.LE` against the ScummVM Harvester room-combat loop. Ghidra confirmed `get_elapsed_milliseconds` at `0x82102` is a centisecond DOS clock wrapper over `int 21h AH=2Ch`, `set_entity_animation_rate` stores `100 / rate` into the entity tick interval, and both `update_player_combat_avatar_state` and `tick_monster_entity_runtime` only fall through into `update_actor_runtime_state` after `animation_advanced_this_tick` is set. That confirms native player walk, room-NPC animation, and monster locomotion are all cadence-locked to the shared animation timer, so the reported `~2x` speed issue is not currently supported by timing semantics alone.
+- On March 30, 2026, rechecked Harvester's native `MONSTER` record layout in live `HARVEST.LE` after the user-reported over-tuned room combat. Ghidra confirmed `MonsterRecord` stores the leading combat numbers as `engage_distance` at `+0x0c`, `initial_hit_points` at `+0x10`, `current_hit_points` at `+0x14`, and `damage_amount` at `+0x18`; `spawn_monster_entity_from_record` copies that `damage_amount` field directly into live actor `+0x1180`, and `update_actor_runtime_state` subtracts it on a confirmed hit with no extra player-side mitigation. The decoded shipped `HARVEST.SCR` line for `DOORKNOB2` (`379 376 12 95 14 2 MONSTER ...`) therefore means `engage=95`, `hp=14`, and `damage=2`, proving ScummVM had swapped those columns locally. Patched the Harvester script parser to match the native numeric order, added a version-15 save migration for legacy runtime monster HP/damage/engage values, and updated the local format notes.
+
 ## Next Suggested Action
 
+- Immediate: instrument and compare native-versus-ScummVM movement distance per animation advance instead of clock cadence. Focus on player horizontal/depth step size, monster chase waypoint tolerance (`50.0 * depth_scale` in native), and any state transitions that may be applying more than one positional step per animation advance.
+- Immediate: rerun in-engine combat in `BOWLS4` with `combat` logging enabled and at least one legacy save-load restore. Confirm `DOORKNOB2` now logs `hp=14/14 damage=2 engage=95`, verify existing pre-fix saves migrate to the same values after load, and then spot-check one second monster record with very different combat numbers so we know the parser fix generalizes beyond this room.
 - Immediate: rerun the in-engine Sergeant remains/lodge handoff and at least one save-load restore on disc 3 after the explicit restart-target and save-restore town-script reload patches. With room/resource/general debug logging enabled, confirm the new `implicit room restart target=` log reports `target='START' explicit='START'` after `C076.FST`, that both the handoff and save restore reload `CD3/HARVEST.SCR`, that the handoff lands in `RECEPTION` instead of the stale CD2 `MAP_2_SERGEANT` entrance, and that the save restore no longer warns `unresolved materialized room target entrance='START' room='RECEPTION'!`.
 - Immediate: run an in-engine Harvester audio validation pass on `FCMP`-backed music and dialogue after the startup warmup patch. Recheck at least one looping music start such as `SOUND/MUSIC/MENACE.CMP` or `SOUND/MUSIC/ANXIETY.CMP` plus a few voiced dialogue lines, confirm the clip-start pop is reduced, and watch for any new truncation of music attacks or first spoken phonemes. If a transient remains, compare it against the native split we confirmed in Ghidra: streamed music mutes the first `0x64` decoded bytes, while one-shot/dialogue `FCMP` playback skips the first `0x1f4` decoded bytes entirely.
 - Immediate: run an in-engine Harvester disc-swap validation pass after the current-disc-only resource remount change. With resource debug logging enabled, recheck both `CHANGE_TO_CD2_*` and `WARP_TO_LODGE_*`, confirm the previous disc's archives are unmounted before follow-on loads from the requested disc, verify loaded sound slots and streaming audio do not survive the swap, and confirm save-load restore on a non-default disc still reloads dialogue/text from the active disc instead of silently falling back to another mounted archive.
diff --git a/engines/harvester/monster.h b/engines/harvester/monster.h
index 6576b72ea20..c38f1785cbf 100644
--- a/engines/harvester/monster.h
+++ b/engines/harvester/monster.h
@@ -32,10 +32,11 @@ struct StartupMonsterRecord {
 	int posX = 0;
 	int posY = 0;
 	int posZ = 0;
+	// Native MONSTER numeric fields after z are engage distance, initial HP, and damage.
+	int engageDistance = 0;
 	int initialHitPoints = 0;
 	int currentHitPoints = 0;
 	int damageAmount = 0;
-	int engageDistance = 0;
 	int damageType = 0;
 	int facing = -1;
 	Common::String roomName;
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index cc66ec30f72..bb708147bac 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -39,7 +39,7 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 }
 
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
-static const uint32 kHarvesterSaveVersion = 14;
+static const uint32 kHarvesterSaveVersion = 15;
 
 static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
 	debugC(1, kDebugRoom,
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index aa8b863c67c..73e13de2f17 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -178,6 +178,18 @@ static void syncStartupMonsterRecord(Common::Serializer &s, StartupMonsterRecord
 	s.syncAsSint32LE(record.deathSoundTriggerFrame);
 }
 
+static void migrateLegacyMonsterCombatFields(const StartupMonsterRecord &baseMonster,
+		StartupMonsterRecord &runtimeMonster) {
+	const int legacyInitialHitPoints = MAX(0, runtimeMonster.initialHitPoints);
+	const int legacyCurrentHitPoints = CLIP<int>(runtimeMonster.currentHitPoints, 0, legacyInitialHitPoints);
+	const int damageTaken = legacyInitialHitPoints - legacyCurrentHitPoints;
+
+	runtimeMonster.engageDistance = baseMonster.engageDistance;
+	runtimeMonster.initialHitPoints = baseMonster.initialHitPoints;
+	runtimeMonster.currentHitPoints = MAX(0, baseMonster.initialHitPoints - damageTaken);
+	runtimeMonster.damageAmount = baseMonster.damageAmount;
+}
+
 static void syncStartupTimerRecord(Common::Serializer &s, StartupTimerRecord &record) {
 	s.syncAsSint32LE(record.initialValue);
 	s.syncAsSint32LE(record.currentValue);
@@ -987,9 +999,10 @@ void Script::parseTownRecords(ResourceManager &resources) {
 				monster.posX = parseAsciiIntOrZero(tokens[0]);
 				monster.posY = parseAsciiIntOrZero(tokens[1]);
 				monster.posZ = parseAsciiIntOrZero(tokens[2]);
-				monster.initialHitPoints = parseAsciiIntOrZero(tokens[3]);
-				monster.damageAmount = parseAsciiIntOrZero(tokens[4]);
-				monster.engageDistance = parseAsciiIntOrZero(tokens[5]);
+				// Native MonsterRecord numeric order is engage distance, initial HP, then damage.
+				monster.engageDistance = parseAsciiIntOrZero(tokens[3]);
+				monster.initialHitPoints = parseAsciiIntOrZero(tokens[4]);
+				monster.damageAmount = parseAsciiIntOrZero(tokens[5]);
 			}
 			if (tagIndex >= 10) {
 				monster.attackSoundTriggerFrame = parseAsciiIntOrZero(tokens[6]);
@@ -1448,6 +1461,21 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 	if (s.isLoading()) {
 		_playerCurrentHitPoints = clampPlayerHitPoints(_playerCurrentHitPoints);
 		_playerCombatLoadout = clampPlayerCombatLoadout(_playerCombatLoadout);
+		if (s.getVersion() >= 2 && s.getVersion() < 15) {
+			for (StartupMonsterRecord &runtimeMonster : _runtimeMonsters) {
+				const StartupMonsterRecord *baseMonster = nullptr;
+				for (const StartupMonsterRecord &monster : _monsters) {
+					if (monster.monsterName.equalsIgnoreCase(runtimeMonster.monsterName)) {
+						baseMonster = &monster;
+						break;
+					}
+				}
+				if (!baseMonster)
+					continue;
+
+				migrateLegacyMonsterCombatFields(*baseMonster, runtimeMonster);
+			}
+		}
 		if (s.getVersion() < 2) {
 			_runtimeTimers = _timers;
 			for (StartupMonsterRecord &runtimeMonster : _runtimeMonsters) {


Commit: 239ad973e7dc6d347ee36c3f6f9562c73f31400a
    https://github.com/scummvm/scummvm/commit/239ad973e7dc6d347ee36c3f6f9562c73f31400a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:29+02:00

Commit Message:
HARVESTER: Tighten monster melee entry range

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 2916a492c2f..406e837fbc8 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -746,6 +746,7 @@
   - `+0x1068` stores the caller-facing animation rate value
   - `+0x106c` / `+0x1078` store the active sequence end/start frame indices
   - `+0x112c` stores the active sequence index
+  - `update_player_combat_avatar_state` and `tick_monster_entity_runtime` both gate locomotion/combat on that same animation path: when `animation_advanced_this_tick` is clear they only call `tick_entity_visual_state`, and only an animation-advance tick falls through into `update_actor_runtime_state`. Native player walk and monster chase therefore use the shared centisecond `100 / rate` cadence rather than a separate faster movement timer.
 - `is_cursor_within_entity_bounds` at `0x4b670` is the rectangle-only cursor test for runtime entities.
 - `is_cursor_over_entity_hitmask` at `0x4b5b0` adds the per-pixel transparency test used by the main loop before interaction dispatch.
   - Class `0x16` skips the cursor gate entirely.
@@ -950,7 +951,7 @@
   - `max_z_screen_y` / `min_z_screen_y` define the screen-Y band that maps cursor position to room depth between `max_z` and `min_z`
   - `full_scale_z` is the room Z depth where entities remain at 100% sprite scale.
   - `max_z_scale_percent` is the sprite scale percentage at `max_z`; the parser derives `perspective_scale` from `(100 - max_z_scale_percent) / (max_z - full_scale_z)`, and the runtime applies it with the additional `0.01` factor stored at `0xbed80`.
-  - `z_velocity_step` is the room-specific vertical movement increment used by the main actor state machine
+  - `parse_room_record` initializes `z_velocity_step` to `1.0` after parsing; it is a live runtime field, but it is not currently derived from an additional room-script token.
   - the trailing `reserved_string_38`, `reserved_string_3c`, and `reserved_string_40` slots are confirmed `char *` fields.
   - parser order is now explicit: after `room_name` and `music_path`, the parser reads three additional strings into `reserved_string_38`, `reserved_string_3c`, and `reserved_string_40`, then reads the separately validated `palette_path`, then the `dimmable` token, then `on_enter_tag` / `on_exit_tag`.
   - `free_loaded_world_data` frees `palette_path`, `reserved_string_38`, `reserved_string_3c`, and `reserved_string_40` as room-owned strings, which confirms that the old `runtime_40` interpretation was wrong. In the sampled shipping `HARVEST.SCR` data all three reserved strings are empty.
@@ -1020,6 +1021,7 @@
   - `destroy_entity_list` and `dispatch_room_event_actions` use it only on class-6 monster removal flows.
 - `tick_monster_entity_runtime` at `0x54140` is the class-6 monster wrapper around `update_actor_runtime_state`.
   - `run_harvester_main_loop` uses it for live monster entities, and the wrapper tears down the runtime object when the shared actor state machine reports removal.
+  - When `animation_advanced_this_tick` is still clear, the wrapper only refreshes the entity through `tick_entity_visual_state` and does not run the locomotion/combat state machine yet.
 - `spawn_player_combat_avatar` at `0x54220` reuses the monster runtime entity base for the player during combat / weapon-view mode.
   - It seeds player HP/state and installs the weapon-dependent attack / hit / footstep / death sounds.
   - The same setup now bounds four live actor runtime fields tightly enough to treat them as confirmed:
@@ -1054,10 +1056,11 @@
   - On success it returns the matching `RegionRecord *` so the main loop can dispatch the region's `action_tag`.
 - `reset_player_combat_avatar` at `0x56a40` is the player-avatar reset helper used on teardown / restart paths.
   - It zeroes transient combat/runtime state, restores the default health/state baseline, and if the current loadout is non-default it rebuilds the player combat avatar back through loadout `0`.
-  - `update_actor_runtime_state` at `0x4d750` is the shared per-frame actor state machine for live actor entities.
+- `update_actor_runtime_state` at `0x4d750` is the shared per-frame actor state machine for live actor entities.
   - It is called from `run_harvester_main_loop`, the dialogue/response/keyword modal loops, and the player-combat wrapper, so actor motion/combat continues advancing while blocking UI is active.
   - It handles class-4/5/6 actor entities, advances animation/state transitions, maintains pursuit spacing against the player using `engage_distance`, applies room Z bounds and vertical motion, fires frame-timed sound hooks, resolves hit damage through `damage_amount`, and returns `0` when the caller should remove the actor entity from the world list.
   - The close-range hit-resolution block reuses the hidden helper at live actor field `+0x11a8`: it links attacker/target runtime pointers, forces the helper back to frame `0`, repositions it over the struck actor, and shows it when the helper was still hidden.
+  - The native locomotion constants now read cleanly out of the state machine: left/right walk uses `depth_scale * 8.0`, the reverse-step families use `depth_scale * 16.0`, the class-6 monster pursue band is `g_player_combat_avatar->z +/- 2.0`, and the horizontal chase waypoint slack is `depth_scale * 50.0`.
   - For class-6 monsters specifically, pursuit is not a free-running room-script heuristic: the native state machine first closes base Z to within `g_player_combat_avatar->z +/- 2.0`, then compares center-to-center X against `engage_distance`, and only arms the close-range attack picker after that spacing gate is satisfied.
   - The same class-6 path derives its horizontal chase waypoint from the player's live left edge plus the current player/monster frame widths and treats that waypoint as satisfied within `50.0 * depth_scale` pixels, so native monster pursuit does not require exact center equality before it can stop walking and attack.
   - Once that close-range gate is satisfied and the per-attack cooldown allows it, the class-6 branch stores `g_player_combat_avatar` into live actor field `+0x11a4` before entering the close-range attack family; the later contact block resolves against that linked target instead of re-running the coarse engage-distance spacing test.
@@ -1119,6 +1122,7 @@
   - The helper copies that pending value into the live shove slot at `+0x1088` when idle, then decays the pending value toward zero.
 - `update_player_combat_avatar_state` at `0x553a0` is the player-side wrapper around `update_actor_runtime_state`.
   - It reads combat input globals, translates them into desired player actor states, updates the player combat avatar, and then delegates the shared animation/combat resolution to `update_actor_runtime_state`.
+  - It timestamps live activity with `get_elapsed_milliseconds`, and like the monster wrapper it only falls through into `update_actor_runtime_state` after the shared animation path has reported `animation_advanced_this_tick`; otherwise it only calls `tick_entity_visual_state`.
   - Shared input globals recovered from both the town-map UI and player-combat wrapper are now named:
     - `g_input_up_pressed` at `0xd5954`
     - `g_input_left_pressed` at `0xd5957`
diff --git a/TRACKER.md b/TRACKER.md
index edb31793b9d..052ea54a3df 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,12 +10,15 @@
 
 ## Last Confirmed Action
 
+- On March 30, 2026, patched ScummVM's Harvester room-combat gate to match the confirmed native class-6 melee-entry split. [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) now keeps the broader overlap/edge-gap helper only for room-combat reach checks such as player target selection, while monster attack startup uses a dedicated native gate that requires base Z within `+/- 2.0` and live center-to-center X within `engage_distance`. This removes the prior local behavior where monster melee could start as soon as the chase waypoint slack (`50.0 * depth_scale`) or simple sprite overlap reported "close enough", even though native reserves that wider band for chase-waypoint satisfaction.
+- On March 30, 2026, rechecked the native class-6 close-range attack gate in live `HARVEST.LE` against ScummVM's room-combat helper. Ghidra confirmed `update_actor_runtime_state` only arms monster melee after base Z is within `g_player_combat_avatar->z +/- 2.0` and the live center-to-center X delta is inside `engage_distance`; the `50.0 * depth_scale` band belongs to horizontal waypoint satisfaction while walking toward the player, not to the final attack-range check. That means the current ScummVM helper `areCombatantsWithinNativeCloseRange` is still broader than native because it accepts simple overlap or edge-gap within `max(engageDistance, 50 * depth_scale)`, so the remaining likely pacing/aggression mismatch is attack-entry geometry rather than raw movement cadence.
+- On March 30, 2026, audited native Harvester movement step size and chase geometry in live `HARVEST.LE`. Ghidra confirmed the shared actor state machine uses `depth_scale * 8.0` for left/right walk, `depth_scale * 16.0` for the short reverse-step families, a class-6 monster pursue band of `player.z +/- 2.0`, and horizontal chase waypoint slack of `depth_scale * 50.0`. `parse_room_record` also hardcodes `RoomRecord.z_velocity_step = 1.0`, so the native vertical locomotion increment is not room-script data-driven. The current ScummVM room loop already matches the native `8 / 1 / 50 / 2` movement constants, and this audit did not find a confirmed extra-position-step path that would explain an across-the-board `~2x` movement speedup.
 - On March 30, 2026, rechecked native Harvester timing semantics in live `HARVEST.LE` against the ScummVM Harvester room-combat loop. Ghidra confirmed `get_elapsed_milliseconds` at `0x82102` is a centisecond DOS clock wrapper over `int 21h AH=2Ch`, `set_entity_animation_rate` stores `100 / rate` into the entity tick interval, and both `update_player_combat_avatar_state` and `tick_monster_entity_runtime` only fall through into `update_actor_runtime_state` after `animation_advanced_this_tick` is set. That confirms native player walk, room-NPC animation, and monster locomotion are all cadence-locked to the shared animation timer, so the reported `~2x` speed issue is not currently supported by timing semantics alone.
 - On March 30, 2026, rechecked Harvester's native `MONSTER` record layout in live `HARVEST.LE` after the user-reported over-tuned room combat. Ghidra confirmed `MonsterRecord` stores the leading combat numbers as `engage_distance` at `+0x0c`, `initial_hit_points` at `+0x10`, `current_hit_points` at `+0x14`, and `damage_amount` at `+0x18`; `spawn_monster_entity_from_record` copies that `damage_amount` field directly into live actor `+0x1180`, and `update_actor_runtime_state` subtracts it on a confirmed hit with no extra player-side mitigation. The decoded shipped `HARVEST.SCR` line for `DOORKNOB2` (`379 376 12 95 14 2 MONSTER ...`) therefore means `engage=95`, `hp=14`, and `damage=2`, proving ScummVM had swapped those columns locally. Patched the Harvester script parser to match the native numeric order, added a version-15 save migration for legacy runtime monster HP/damage/engage values, and updated the local format notes.
 
 ## Next Suggested Action
 
-- Immediate: instrument and compare native-versus-ScummVM movement distance per animation advance instead of clock cadence. Focus on player horizontal/depth step size, monster chase waypoint tolerance (`50.0 * depth_scale` in native), and any state transitions that may be applying more than one positional step per animation advance.
+- Immediate: rerun one in-engine `BOWLS4` or `PCHOUSE` combat capture with `combat` logging enabled after the stricter monster attack-entry patch. Confirm monsters now keep walking until `abs(center_dx) <= engage_distance` after the `+/- 2.0` Z gate, and compare whether the perceived over-fast aggression drops without changing locomotion cadence.
 - Immediate: rerun in-engine combat in `BOWLS4` with `combat` logging enabled and at least one legacy save-load restore. Confirm `DOORKNOB2` now logs `hp=14/14 damage=2 engage=95`, verify existing pre-fix saves migrate to the same values after load, and then spot-check one second monster record with very different combat numbers so we know the parser fix generalizes beyond this room.
 - Immediate: rerun the in-engine Sergeant remains/lodge handoff and at least one save-load restore on disc 3 after the explicit restart-target and save-restore town-script reload patches. With room/resource/general debug logging enabled, confirm the new `implicit room restart target=` log reports `target='START' explicit='START'` after `C076.FST`, that both the handoff and save restore reload `CD3/HARVEST.SCR`, that the handoff lands in `RECEPTION` instead of the stale CD2 `MAP_2_SERGEANT` entrance, and that the save restore no longer warns `unresolved materialized room target entrance='START' room='RECEPTION'!`.
 - Immediate: run an in-engine Harvester audio validation pass on `FCMP`-backed music and dialogue after the startup warmup patch. Recheck at least one looping music start such as `SOUND/MUSIC/MENACE.CMP` or `SOUND/MUSIC/ANXIETY.CMP` plus a few voiced dialogue lines, confirm the clip-start pop is reduced, and watch for any new truncation of music attacks or first spoken phonemes. If a transient remains, compare it against the native split we confirmed in Ghidra: streamed music mutes the first `0x64` decoded bytes, while one-shot/dialogue `FCMP` playback skips the first `0x1f4` decoded bytes entirely.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 041e080defd..d10bb5f8d32 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -524,7 +524,7 @@ static bool doRuntimeEntityDepthExtentsOverlap(const RuntimeEntity &first, const
 	return !(firstZMax < secondZMin || secondZMax < firstZMin);
 }
 
-static bool areCombatantsWithinNativeCloseRange(const StartupRoomSetupState &state,
+static bool areCombatantsWithinRoomCombatReach(const StartupRoomSetupState &state,
 		const RuntimeEntity &attacker, float attackerZ,
 		const RuntimeEntity &target, float targetZ, int engageDistance) {
 	if (attacker.overlapsEntity(target))
@@ -540,6 +540,21 @@ static bool areCombatantsWithinNativeCloseRange(const StartupRoomSetupState &sta
 	return computeRuntimeEntityHorizontalGap(attacker, target) <= MAX(MAX(0, engageDistance), nativeTolerance);
 }
 
+// Native class-6 melee only arms after the chase path has already collapsed the
+// wider waypoint band down to a direct center-to-center engage-distance check.
+static bool isWithinNativeMonsterAttackEntryRange(const RuntimeEntity &monster, float monsterZ,
+		const RuntimeEntity &player, float playerZ, int engageDistance) {
+	const float zDelta = monsterZ >= playerZ ? monsterZ - playerZ : playerZ - monsterZ;
+	if (zDelta > kNativeMonsterPursuitZTolerance)
+		return false;
+
+	const Common::Rect monsterRect = monster.getScreenRect();
+	const Common::Rect playerRect = player.getScreenRect();
+	const int monsterCenterX = monsterRect.left + monsterRect.width() / 2;
+	const int playerCenterX = playerRect.left + playerRect.width() / 2;
+	return ABS(playerCenterX - monsterCenterX) <= MAX(0, engageDistance);
+}
+
 static bool playRandomRoomAttackSound(HarvesterEngine &engine, const Common::String &sound1,
 		const Common::String &sound2, const Common::String &sound3) {
 	const Common::String *availableSounds[3];
@@ -2222,7 +2237,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					continue;
 
 				RuntimeEntity *entity = findSceneRuntimeEntity(monster.monsterName);
-				if (entity && areCombatantsWithinNativeCloseRange(scene.state,
+				if (entity && areCombatantsWithinRoomCombatReach(scene.state,
 						*playerState.entity, playerState.z, *entity, (float)monster.posZ, monster.engageDistance))
 					return &monster;
 			}
@@ -2238,7 +2253,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					continue;
 
 				RuntimeEntity *entity = findSceneRuntimeEntity(npc.npcName);
-				if (entity && areCombatantsWithinNativeCloseRange(scene.state,
+				if (entity && areCombatantsWithinRoomCombatReach(scene.state,
 						*playerState.entity, playerState.z, *entity, entity->getZ(), 0))
 					return &npc;
 			}
@@ -2251,7 +2266,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			if (Player::isProjectileCombatLoadout(playerState.combatLoadout))
 				return true;
 
-			return areCombatantsWithinNativeCloseRange(scene.state,
+			return areCombatantsWithinRoomCombatReach(scene.state,
 				*playerState.entity, playerState.z, *targetEntity, targetEntity->getZ(), engageDistance);
 		};
 		auto describeCombatTargetClass = [](int classId) -> const char * {
@@ -2961,7 +2976,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			}
 
 			needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
-			const bool closeEnoughForAttack = areCombatantsWithinNativeCloseRange(scene.state,
+			const bool closeEnoughForAttack = isWithinNativeMonsterAttackEntryRange(
 				*entity, (float)monster.posZ, *playerState.entity, playerState.z, engageDistance);
 			const bool attackCooldownExpired =
 				combatState.nextAttackAllowedTick == 0 || (int32)(now - combatState.nextAttackAllowedTick) >= 0;


Commit: d05f32d1ccd1f5cb677e2ef2fa8147401f1931d7
    https://github.com/scummvm/scummvm/commit/d05f32d1ccd1f5cb677e2ef2fa8147401f1931d7
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:29+02:00

Commit Message:
HARVESTER: Restore native monster melee cadence

Ghidra confirmed class-6 monsters use the short melee banks 0x8c..0x9d
at animation rate 4, with the existing contact offset field and no
separate randomized monster cooldown in the native attack path. Update
the room combat loop to use those banks and rate instead of the player
attack banks and local cooldown gate, and record the confirmed finding
in TRACKER.md.

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 052ea54a3df..670b7f16daf 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Last Confirmed Action
 
+- On March 30, 2026, rechecked native class-6 melee cadence in live `HARVEST.LE` before adjusting ScummVM's Harvester combat loop. Ghidra confirmed `update_actor_runtime_state` at `0x4d750` uses the short monster-only attack banks `0x8c..0x94` (right-facing) and `0x95..0x9d` (left-facing), not the player attack banks `0x50..0x8b`; those states are entered with attack rate `4`, and `set_entity_animation_rate` stores `100 / rate`, so native monster melee advances one frame every `25` centiseconds for a roughly `75` centisecond full attack cycle. The same audit confirmed the default monster contact offset stays at `+0x113c = 2` (with the existing `MUCKEY` exception at `7`) and did not find a separate randomized monster attack cooldown in the class-6 attack path. The only confirmed cooldown counter from this pass was the player combat avatar field `+0x119c`, which `reset_player_combat_avatar` seeds to `0x46` and the main loop decrements by `4`. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) to use the native monster attack banks, native attack rate `4`, and to remove the ScummVM-only randomized monster cooldown gate.
 - On March 30, 2026, patched ScummVM's Harvester room-combat gate to match the confirmed native class-6 melee-entry split. [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) now keeps the broader overlap/edge-gap helper only for room-combat reach checks such as player target selection, while monster attack startup uses a dedicated native gate that requires base Z within `+/- 2.0` and live center-to-center X within `engage_distance`. This removes the prior local behavior where monster melee could start as soon as the chase waypoint slack (`50.0 * depth_scale`) or simple sprite overlap reported "close enough", even though native reserves that wider band for chase-waypoint satisfaction.
 - On March 30, 2026, rechecked the native class-6 close-range attack gate in live `HARVEST.LE` against ScummVM's room-combat helper. Ghidra confirmed `update_actor_runtime_state` only arms monster melee after base Z is within `g_player_combat_avatar->z +/- 2.0` and the live center-to-center X delta is inside `engage_distance`; the `50.0 * depth_scale` band belongs to horizontal waypoint satisfaction while walking toward the player, not to the final attack-range check. That means the current ScummVM helper `areCombatantsWithinNativeCloseRange` is still broader than native because it accepts simple overlap or edge-gap within `max(engageDistance, 50 * depth_scale)`, so the remaining likely pacing/aggression mismatch is attack-entry geometry rather than raw movement cadence.
 - On March 30, 2026, audited native Harvester movement step size and chase geometry in live `HARVEST.LE`. Ghidra confirmed the shared actor state machine uses `depth_scale * 8.0` for left/right walk, `depth_scale * 16.0` for the short reverse-step families, a class-6 monster pursue band of `player.z +/- 2.0`, and horizontal chase waypoint slack of `depth_scale * 50.0`. `parse_room_record` also hardcodes `RoomRecord.z_velocity_step = 1.0`, so the native vertical locomotion increment is not room-script data-driven. The current ScummVM room loop already matches the native `8 / 1 / 50 / 2` movement constants, and this audit did not find a confirmed extra-position-step path that would explain an across-the-board `~2x` movement speedup.
@@ -18,6 +19,7 @@
 
 ## Next Suggested Action
 
+- Immediate: rerun in-engine combat in `BOWLS4` with `combat` logging enabled after the native monster attack-bank/rate patch. Confirm class-6 attack-start logs now stay inside the native short banks (`140..142`, `143..145`, or `146..148` for right-facing; `149..151`, `152..154`, or `155..157` for left-facing), report `rate=4`, and land hits on the existing native contact offset without the old randomized cooldown behavior. If the cadence now feels correct, capture one short before/after note in the tracker and close out monster melee pacing.
 - Immediate: rerun one in-engine `BOWLS4` or `PCHOUSE` combat capture with `combat` logging enabled after the stricter monster attack-entry patch. Confirm monsters now keep walking until `abs(center_dx) <= engage_distance` after the `+/- 2.0` Z gate, and compare whether the perceived over-fast aggression drops without changing locomotion cadence.
 - Immediate: rerun in-engine combat in `BOWLS4` with `combat` logging enabled and at least one legacy save-load restore. Confirm `DOORKNOB2` now logs `hp=14/14 damage=2 engage=95`, verify existing pre-fix saves migrate to the same values after load, and then spot-check one second monster record with very different combat numbers so we know the parser fix generalizes beyond this room.
 - Immediate: rerun the in-engine Sergeant remains/lodge handoff and at least one save-load restore on disc 3 after the explicit restart-target and save-restore town-script reload patches. With room/resource/general debug logging enabled, confirm the new `implicit room restart target=` log reports `target='START' explicit='START'` after `C076.FST`, that both the handoff and save restore reload `CD3/HARVEST.SCR`, that the handoff lands in `RECEPTION` instead of the stale CD2 `MAP_2_SERGEANT` entrance, and that the save restore no longer warns `unresolved materialized room target entrance='START' room='RECEPTION'!`.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index d10bb5f8d32..ff1c81b1dc7 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -59,6 +59,7 @@ static const int kNativeInventoryWeekdayX = 0x1e0;
 static const int kNativeInventoryWeekdayY = 0x18c;
 static const byte kNativeInventoryTooltipColor = 0xf4;
 static const int kRoomMonsterAnimationRate = 17;
+static const int kNativeMonsterAttackAnimationRate = 4;
 static const int kRoomNpcAmbientLastFrame = 0x3b;
 static const int kDefaultMonsterAttackContactFrameOffset = 2;
 static const int kMuckeyAttackContactFrameOffset = 7;
@@ -119,7 +120,6 @@ struct RoomMonsterCombatState {
 	int deathLastFrame = -1;
 	int deathDamageType = 0;
 	uint32 nextMovementTick = 0;
-	uint32 nextAttackAllowedTick = 0;
 };
 
 struct RoomHitEffectState {
@@ -426,14 +426,14 @@ static bool resolveMonsterAttackAnimationRange(HarvesterEngine &engine,
 		const RuntimeEntity &entity, int actorCenterX, int targetCenterX,
 		RoomAttackAnimationRange &range) {
 	static const RoomAttackAnimationRange kLeftAttackRanges[3] = {
-		RoomAttackAnimationRange(0x50, 0x59, 1),
-		RoomAttackAnimationRange(0x5a, 0x63, 1),
-		RoomAttackAnimationRange(0x64, 0x6d, 1)
+		RoomAttackAnimationRange(0x95, 0x97, 1),
+		RoomAttackAnimationRange(0x98, 0x9a, 1),
+		RoomAttackAnimationRange(0x9b, 0x9d, 1)
 	};
 	static const RoomAttackAnimationRange kRightAttackRanges[3] = {
-		RoomAttackAnimationRange(0x6e, 0x77, 2),
-		RoomAttackAnimationRange(0x78, 0x81, 2),
-		RoomAttackAnimationRange(0x82, 0x8b, 2)
+		RoomAttackAnimationRange(0x8c, 0x8e, 2),
+		RoomAttackAnimationRange(0x8f, 0x91, 2),
+		RoomAttackAnimationRange(0x92, 0x94, 2)
 	};
 
 	const RoomAttackAnimationRange *candidates =
@@ -542,6 +542,8 @@ static bool areCombatantsWithinRoomCombatReach(const StartupRoomSetupState &stat
 
 // Native class-6 melee only arms after the chase path has already collapsed the
 // wider waypoint band down to a direct center-to-center engage-distance check.
+// The DOS runtime does not add a separate randomized monster attack cooldown;
+// cadence comes from the short attack banks themselves.
 static bool isWithinNativeMonsterAttackEntryRange(const RuntimeEntity &monster, float monsterZ,
 		const RuntimeEntity &player, float playerZ, int engageDistance) {
 	const float zDelta = monsterZ >= playerZ ? monsterZ - playerZ : playerZ - monsterZ;
@@ -2457,7 +2459,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			combatState->deathLastFrame = deathRange.lastFrame;
 			combatState->deathDamageType = debugDamageType;
 			combatState->nextMovementTick = 0;
-			combatState->nextAttackAllowedTick = 0;
 			monsterEntity->setAnimationFrameRange(deathRange.firstFrame, deathRange.lastFrame, false);
 			monsterEntity->setAnimationRate(kRoomMonsterAnimationRate);
 			monsterEntity->setAnimationEnabled(true);
@@ -2749,7 +2750,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			combatState->deathLastFrame = deathRange.lastFrame;
 			combatState->deathDamageType = damageType;
 			combatState->nextMovementTick = 0;
-			combatState->nextAttackAllowedTick = 0;
 			monsterEntity->setAnimationFrameRange(deathRange.firstFrame, deathRange.lastFrame, false);
 			monsterEntity->setAnimationRate(kRoomMonsterAnimationRate);
 			monsterEntity->setAnimationEnabled(true);
@@ -2918,7 +2918,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			const int monsterCenterX = monsterRect.left + monsterRect.width() / 2;
 			const int liveCenterDx = playerCenterX - monsterCenterX;
 			const int absLiveCenterDx = ABS(liveCenterDx);
-			const int horizontalGap = computeRuntimeEntityHorizontalGap(*entity, *playerState.entity);
 			const int monsterLiveCenterOffset = monsterCenterX - monster.posX;
 			const int playerTargetZ = roundRoomCombatFloat(playerState.z);
 			const float zDelta = playerState.z - (float)monster.posZ;
@@ -2978,12 +2977,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
 			const bool closeEnoughForAttack = isWithinNativeMonsterAttackEntryRange(
 				*entity, (float)monster.posZ, *playerState.entity, playerState.z, engageDistance);
-			const bool attackCooldownExpired =
-				combatState.nextAttackAllowedTick == 0 || (int32)(now - combatState.nextAttackAllowedTick) >= 0;
 			if (!closeEnoughForAttack)
 				continue;
-			if (!attackCooldownExpired && horizontalGap > MAX(1, engageDistance / 3))
-				continue;
 
 			RoomAttackAnimationRange range;
 			if (!resolveMonsterAttackAnimationRange(_engine, *entity, monsterCenterX, playerCenterX, range)) {
@@ -3002,17 +2997,16 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				monster.attackSound1.empty() && monster.attackSound2.empty() && monster.attackSound3.empty();
 			combatState.attackContactResolved = false;
 			combatState.attackTargetName = playerState.entity->getName();
-			combatState.nextAttackAllowedTick = now + 50 + _engine.getRandomNumber(99);
 			monster.facing = range.resumeFacing;
 			entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
-			entity->setAnimationRate(kRoomMonsterAnimationRate);
+			entity->setAnimationRate(kNativeMonsterAttackAnimationRate);
 			entity->setAnimationEnabled(true);
 			entity->setCurrentFrame(range.firstFrame);
 			debugC(1, kDebugCombat,
-				"Harvester: combat monster attack start monster='%s' target='%s' frames=%d..%d contact=%d resume_facing=%d live_center_dx=%d z_delta=%.2f engage=%d next_attack_tick=%u",
+				"Harvester: combat monster attack start monster='%s' target='%s' frames=%d..%d contact=%d resume_facing=%d live_center_dx=%d z_delta=%.2f engage=%d rate=%d",
 				monster.monsterName.c_str(), combatState.attackTargetName.c_str(), range.firstFrame, range.lastFrame,
 				combatState.attackContactFrame, range.resumeFacing, liveCenterDx, (double)zDelta,
-				engageDistance, combatState.nextAttackAllowedTick);
+				engageDistance, kNativeMonsterAttackAnimationRate);
 			needsRedraw = true;
 		}
 


Commit: 14dd767d711e91c8efdabf168c2f4cf10699cf27
    https://github.com/scummvm/scummvm/commit/14dd767d711e91c8efdabf168c2f4cf10699cf27
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:30+02:00

Commit Message:
HARVESTER: Restore native monster melee cooldown gate

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 670b7f16daf..0773feef756 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,7 +10,7 @@
 
 ## Last Confirmed Action
 
-- On March 30, 2026, rechecked native class-6 melee cadence in live `HARVEST.LE` before adjusting ScummVM's Harvester combat loop. Ghidra confirmed `update_actor_runtime_state` at `0x4d750` uses the short monster-only attack banks `0x8c..0x94` (right-facing) and `0x95..0x9d` (left-facing), not the player attack banks `0x50..0x8b`; those states are entered with attack rate `4`, and `set_entity_animation_rate` stores `100 / rate`, so native monster melee advances one frame every `25` centiseconds for a roughly `75` centisecond full attack cycle. The same audit confirmed the default monster contact offset stays at `+0x113c = 2` (with the existing `MUCKEY` exception at `7`) and did not find a separate randomized monster attack cooldown in the class-6 attack path. The only confirmed cooldown counter from this pass was the player combat avatar field `+0x119c`, which `reset_player_combat_avatar` seeds to `0x46` and the main loop decrements by `4`. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) to use the native monster attack banks, native attack rate `4`, and to remove the ScummVM-only randomized monster cooldown gate.
+- On March 30, 2026, re-audited native class-6 melee re-entry in live `HARVEST.LE` after a fresh `BOWLS4` log showed the new short-bank/rate-4 patch was still chaining hits too quickly. Ghidra confirmed `update_actor_runtime_state` at `0x4d750` still uses the short monster-only attack banks `0x8c..0x94` (right-facing) and `0x95..0x9d` (left-facing) at attack rate `4`, with the default contact offset still coming from `+0x113c = 2` (`MUCKEY` remains the known `7`-frame exception). The missed native gate is the monster-local timestamp at `entity+0x1144`: `spawn_monster_entity_base` seeds it from `DAT_000c7e44` on spawn, melee entry refreshes it to `DAT_000c7e44 + (next_random_value() % 100)`, and attack startup is blocked until that stored tick is at least `0x32` centiseconds old unless the live center-to-center X delta is already inside `engage_distance / 3`. `DAT_000c7e44` itself is written from `get_elapsed_milliseconds()` in `run_harvester_main_loop`, and that helper is the same centisecond DOS clock wrapper already mirrored by ScummVM's `Player::getRuntimeClockTicks()`. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) to restore that native cooldown gate, seed the initial post-spawn delay, keep the confirmed short attack banks and rate `4`, and use the native close-range bypass check based on live center delta instead of the broader overlap helper.
 - On March 30, 2026, patched ScummVM's Harvester room-combat gate to match the confirmed native class-6 melee-entry split. [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) now keeps the broader overlap/edge-gap helper only for room-combat reach checks such as player target selection, while monster attack startup uses a dedicated native gate that requires base Z within `+/- 2.0` and live center-to-center X within `engage_distance`. This removes the prior local behavior where monster melee could start as soon as the chase waypoint slack (`50.0 * depth_scale`) or simple sprite overlap reported "close enough", even though native reserves that wider band for chase-waypoint satisfaction.
 - On March 30, 2026, rechecked the native class-6 close-range attack gate in live `HARVEST.LE` against ScummVM's room-combat helper. Ghidra confirmed `update_actor_runtime_state` only arms monster melee after base Z is within `g_player_combat_avatar->z +/- 2.0` and the live center-to-center X delta is inside `engage_distance`; the `50.0 * depth_scale` band belongs to horizontal waypoint satisfaction while walking toward the player, not to the final attack-range check. That means the current ScummVM helper `areCombatantsWithinNativeCloseRange` is still broader than native because it accepts simple overlap or edge-gap within `max(engageDistance, 50 * depth_scale)`, so the remaining likely pacing/aggression mismatch is attack-entry geometry rather than raw movement cadence.
 - On March 30, 2026, audited native Harvester movement step size and chase geometry in live `HARVEST.LE`. Ghidra confirmed the shared actor state machine uses `depth_scale * 8.0` for left/right walk, `depth_scale * 16.0` for the short reverse-step families, a class-6 monster pursue band of `player.z +/- 2.0`, and horizontal chase waypoint slack of `depth_scale * 50.0`. `parse_room_record` also hardcodes `RoomRecord.z_velocity_step = 1.0`, so the native vertical locomotion increment is not room-script data-driven. The current ScummVM room loop already matches the native `8 / 1 / 50 / 2` movement constants, and this audit did not find a confirmed extra-position-step path that would explain an across-the-board `~2x` movement speedup.
@@ -19,7 +19,7 @@
 
 ## Next Suggested Action
 
-- Immediate: rerun in-engine combat in `BOWLS4` with `combat` logging enabled after the native monster attack-bank/rate patch. Confirm class-6 attack-start logs now stay inside the native short banks (`140..142`, `143..145`, or `146..148` for right-facing; `149..151`, `152..154`, or `155..157` for left-facing), report `rate=4`, and land hits on the existing native contact offset without the old randomized cooldown behavior. If the cadence now feels correct, capture one short before/after note in the tracker and close out monster melee pacing.
+- Immediate: rerun in-engine combat in `BOWLS4` with `combat` logging enabled after the restored native melee cooldown gate. Confirm class-6 attack-start logs still stay inside the short native banks (`140..142`, `143..145`, or `146..148` for right-facing; `149..151`, `152..154`, or `155..157` for left-facing), still report `rate=4`, and now show materially wider spacing between attack starts unless the player is already inside the native `engage_distance / 3` close-bypass band. If the cadence now feels correct, capture one short note in the tracker and close out monster melee pacing.
 - Immediate: rerun one in-engine `BOWLS4` or `PCHOUSE` combat capture with `combat` logging enabled after the stricter monster attack-entry patch. Confirm monsters now keep walking until `abs(center_dx) <= engage_distance` after the `+/- 2.0` Z gate, and compare whether the perceived over-fast aggression drops without changing locomotion cadence.
 - Immediate: rerun in-engine combat in `BOWLS4` with `combat` logging enabled and at least one legacy save-load restore. Confirm `DOORKNOB2` now logs `hp=14/14 damage=2 engage=95`, verify existing pre-fix saves migrate to the same values after load, and then spot-check one second monster record with very different combat numbers so we know the parser fix generalizes beyond this room.
 - Immediate: rerun the in-engine Sergeant remains/lodge handoff and at least one save-load restore on disc 3 after the explicit restart-target and save-restore town-script reload patches. With room/resource/general debug logging enabled, confirm the new `implicit room restart target=` log reports `target='START' explicit='START'` after `C076.FST`, that both the handoff and save restore reload `CD3/HARVEST.SCR`, that the handoff lands in `RECEPTION` instead of the stale CD2 `MAP_2_SERGEANT` entrance, and that the save restore no longer warns `unresolved materialized room target entrance='START' room='RECEPTION'!`.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index ff1c81b1dc7..e36c3e54209 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -60,6 +60,7 @@ static const int kNativeInventoryWeekdayY = 0x18c;
 static const byte kNativeInventoryTooltipColor = 0xf4;
 static const int kRoomMonsterAnimationRate = 17;
 static const int kNativeMonsterAttackAnimationRate = 4;
+static const uint32 kNativeMonsterAttackCooldownBaseTicks = 50;
 static const int kRoomNpcAmbientLastFrame = 0x3b;
 static const int kDefaultMonsterAttackContactFrameOffset = 2;
 static const int kMuckeyAttackContactFrameOffset = 7;
@@ -120,6 +121,7 @@ struct RoomMonsterCombatState {
 	int deathLastFrame = -1;
 	int deathDamageType = 0;
 	uint32 nextMovementTick = 0;
+	uint32 nextAttackAllowedTick = 0;
 };
 
 struct RoomHitEffectState {
@@ -2841,6 +2843,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
 				continue;
 			}
+			if (combatState.nextAttackAllowedTick == 0)
+				combatState.nextAttackAllowedTick = now + kNativeMonsterAttackCooldownBaseTicks;
 
 			if (combatState.attackActive) {
 				const int currentFrame = entity->getCurrentFrame();
@@ -2977,8 +2981,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
 			const bool closeEnoughForAttack = isWithinNativeMonsterAttackEntryRange(
 				*entity, (float)monster.posZ, *playerState.entity, playerState.z, engageDistance);
+			const bool attackCooldownExpired = (int32)(now - combatState.nextAttackAllowedTick) > 0;
+			const int closeRangeBypassDistance = MAX(1, engageDistance / 3);
 			if (!closeEnoughForAttack)
 				continue;
+			if (!attackCooldownExpired && absLiveCenterDx > closeRangeBypassDistance)
+				continue;
 
 			RoomAttackAnimationRange range;
 			if (!resolveMonsterAttackAnimationRange(_engine, *entity, monsterCenterX, playerCenterX, range)) {
@@ -2997,16 +3005,19 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				monster.attackSound1.empty() && monster.attackSound2.empty() && monster.attackSound3.empty();
 			combatState.attackContactResolved = false;
 			combatState.attackTargetName = playerState.entity->getName();
+			combatState.nextAttackAllowedTick = now + kNativeMonsterAttackCooldownBaseTicks +
+				_engine.getRandomNumber(99);
 			monster.facing = range.resumeFacing;
 			entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
 			entity->setAnimationRate(kNativeMonsterAttackAnimationRate);
 			entity->setAnimationEnabled(true);
 			entity->setCurrentFrame(range.firstFrame);
 			debugC(1, kDebugCombat,
-				"Harvester: combat monster attack start monster='%s' target='%s' frames=%d..%d contact=%d resume_facing=%d live_center_dx=%d z_delta=%.2f engage=%d rate=%d",
+				"Harvester: combat monster attack start monster='%s' target='%s' frames=%d..%d contact=%d resume_facing=%d live_center_dx=%d z_delta=%.2f engage=%d rate=%d next_attack_tick=%u close_bypass=%d",
 				monster.monsterName.c_str(), combatState.attackTargetName.c_str(), range.firstFrame, range.lastFrame,
 				combatState.attackContactFrame, range.resumeFacing, liveCenterDx, (double)zDelta,
-				engageDistance, kNativeMonsterAttackAnimationRate);
+				engageDistance, kNativeMonsterAttackAnimationRate, combatState.nextAttackAllowedTick,
+				closeRangeBypassDistance);
 			needsRedraw = true;
 		}
 


Commit: 8af7c77c384558724c39b582d5b3ba363fc329c3
    https://github.com/scummvm/scummvm/commit/8af7c77c384558724c39b582d5b3ba363fc329c3
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:30+02:00

Commit Message:
HARVESTER: Repair legacy monster hp restore

Changed paths:
    TRACKER.md
    engines/harvester/script.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 0773feef756..e206512d6c4 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Last Confirmed Action
 
+- On March 30, 2026, rechecked Harvester's room-combat save-restore path after a fresh `BOWLS4` validation still showed `DOORKNOB2` materializing as `hp=95/14 damage=2 engage=95`. The runtime log proved the room script parse was already correct and only the restored runtime monster HP was stale. Source inspection in [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp) showed why: `reloadTownWorld()` preserves only `currentHitPoints` from the loaded runtime monster state, while `engageDistance`, `initialHitPoints`, and `damageAmount` come back from the freshly reparsed script. That means a legacy save with the old swapped monster combat fields turns into the exact mixed state seen in the log. Patched [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp) so load-time monster migration no longer depends only on `saveVersion < 15`; it now also detects any runtime monster whose serialized `initialHitPoints`, `damageAmount`, or `engageDistance` do not match the current script base record and recalculates `currentHitPoints` from the legacy damage taken before the disc reload/preserve pass.
 - On March 30, 2026, re-audited native class-6 melee re-entry in live `HARVEST.LE` after a fresh `BOWLS4` log showed the new short-bank/rate-4 patch was still chaining hits too quickly. Ghidra confirmed `update_actor_runtime_state` at `0x4d750` still uses the short monster-only attack banks `0x8c..0x94` (right-facing) and `0x95..0x9d` (left-facing) at attack rate `4`, with the default contact offset still coming from `+0x113c = 2` (`MUCKEY` remains the known `7`-frame exception). The missed native gate is the monster-local timestamp at `entity+0x1144`: `spawn_monster_entity_base` seeds it from `DAT_000c7e44` on spawn, melee entry refreshes it to `DAT_000c7e44 + (next_random_value() % 100)`, and attack startup is blocked until that stored tick is at least `0x32` centiseconds old unless the live center-to-center X delta is already inside `engage_distance / 3`. `DAT_000c7e44` itself is written from `get_elapsed_milliseconds()` in `run_harvester_main_loop`, and that helper is the same centisecond DOS clock wrapper already mirrored by ScummVM's `Player::getRuntimeClockTicks()`. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) to restore that native cooldown gate, seed the initial post-spawn delay, keep the confirmed short attack banks and rate `4`, and use the native close-range bypass check based on live center delta instead of the broader overlap helper.
 - On March 30, 2026, patched ScummVM's Harvester room-combat gate to match the confirmed native class-6 melee-entry split. [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) now keeps the broader overlap/edge-gap helper only for room-combat reach checks such as player target selection, while monster attack startup uses a dedicated native gate that requires base Z within `+/- 2.0` and live center-to-center X within `engage_distance`. This removes the prior local behavior where monster melee could start as soon as the chase waypoint slack (`50.0 * depth_scale`) or simple sprite overlap reported "close enough", even though native reserves that wider band for chase-waypoint satisfaction.
 - On March 30, 2026, rechecked the native class-6 close-range attack gate in live `HARVEST.LE` against ScummVM's room-combat helper. Ghidra confirmed `update_actor_runtime_state` only arms monster melee after base Z is within `g_player_combat_avatar->z +/- 2.0` and the live center-to-center X delta is inside `engage_distance`; the `50.0 * depth_scale` band belongs to horizontal waypoint satisfaction while walking toward the player, not to the final attack-range check. That means the current ScummVM helper `areCombatantsWithinNativeCloseRange` is still broader than native because it accepts simple overlap or edge-gap within `max(engageDistance, 50 * depth_scale)`, so the remaining likely pacing/aggression mismatch is attack-entry geometry rather than raw movement cadence.
@@ -19,6 +20,7 @@
 
 ## Next Suggested Action
 
+- Immediate: reload the same legacy `BOWLS4` save and confirm the early runtime logs now show `loaded runtime monster 'DOORKNOB2' ... hp=14/14` and `materialized room monster ... hp=14/14 damage=2 engage=95` before any combat begins. If that passes, the remaining `DOORKNOB2` difficulty gap is raw native data plus melee cadence rather than stale save-state corruption.
 - Immediate: rerun in-engine combat in `BOWLS4` with `combat` logging enabled after the restored native melee cooldown gate. Confirm class-6 attack-start logs still stay inside the short native banks (`140..142`, `143..145`, or `146..148` for right-facing; `149..151`, `152..154`, or `155..157` for left-facing), still report `rate=4`, and now show materially wider spacing between attack starts unless the player is already inside the native `engage_distance / 3` close-bypass band. If the cadence now feels correct, capture one short note in the tracker and close out monster melee pacing.
 - Immediate: rerun one in-engine `BOWLS4` or `PCHOUSE` combat capture with `combat` logging enabled after the stricter monster attack-entry patch. Confirm monsters now keep walking until `abs(center_dx) <= engage_distance` after the `+/- 2.0` Z gate, and compare whether the perceived over-fast aggression drops without changing locomotion cadence.
 - Immediate: rerun in-engine combat in `BOWLS4` with `combat` logging enabled and at least one legacy save-load restore. Confirm `DOORKNOB2` now logs `hp=14/14 damage=2 engage=95`, verify existing pre-fix saves migrate to the same values after load, and then spot-check one second monster record with very different combat numbers so we know the parser fix generalizes beyond this room.
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 73e13de2f17..121f60dae92 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -190,6 +190,13 @@ static void migrateLegacyMonsterCombatFields(const StartupMonsterRecord &baseMon
 	runtimeMonster.damageAmount = baseMonster.damageAmount;
 }
 
+static bool hasLegacyMonsterCombatFieldLayout(const StartupMonsterRecord &baseMonster,
+		const StartupMonsterRecord &runtimeMonster) {
+	return runtimeMonster.initialHitPoints != baseMonster.initialHitPoints ||
+		runtimeMonster.damageAmount != baseMonster.damageAmount ||
+		runtimeMonster.engageDistance != baseMonster.engageDistance;
+}
+
 static void syncStartupTimerRecord(Common::Serializer &s, StartupTimerRecord &record) {
 	s.syncAsSint32LE(record.initialValue);
 	s.syncAsSint32LE(record.currentValue);
@@ -1461,7 +1468,7 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 	if (s.isLoading()) {
 		_playerCurrentHitPoints = clampPlayerHitPoints(_playerCurrentHitPoints);
 		_playerCombatLoadout = clampPlayerCombatLoadout(_playerCombatLoadout);
-		if (s.getVersion() >= 2 && s.getVersion() < 15) {
+		if (s.getVersion() >= 2) {
 			for (StartupMonsterRecord &runtimeMonster : _runtimeMonsters) {
 				const StartupMonsterRecord *baseMonster = nullptr;
 				for (const StartupMonsterRecord &monster : _monsters) {
@@ -1472,6 +1479,8 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 				}
 				if (!baseMonster)
 					continue;
+				if (!hasLegacyMonsterCombatFieldLayout(*baseMonster, runtimeMonster))
+					continue;
 
 				migrateLegacyMonsterCombatFields(*baseMonster, runtimeMonster);
 			}


Commit: ba9ae7f38f883e9f0629635f1d04d53ccec6d1c4
    https://github.com/scummvm/scummvm/commit/ba9ae7f38f883e9f0629635f1d04d53ccec6d1c4
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:31+02:00

Commit Message:
HARVESTER: Clear invalid loadouts after script reloads

Reject equipped weapons that are no longer present after a town-script
reload or save-state load on a different disc.

Changed paths:
    engines/harvester/script.cpp


diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 121f60dae92..f78a768af96 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -48,6 +48,34 @@ static const int kDefaultPlayerCombatLoadout = 0;
 static const int kMaxPlayerCombatLoadout = 0x14;
 static const int kMaxStartupOptionLevel = 9;
 
+struct InventoryCombatLoadoutEntry {
+	int loadoutId;
+	const char *objectName;
+};
+
+static const InventoryCombatLoadoutEntry kInventoryCombatLoadoutMap[] = {
+	{ 1, "CLEAVER" },
+	{ 2, "NAILGUN" },
+	{ 3, "SHOTGUN" },
+	{ 4, "9GUN" },
+	{ 5, "38GUN" },
+	{ 6, "TOMAHAWK" },
+	{ 7, "KNIFE" },
+	{ 8, "FLAIL" },
+	{ 9, "HANDAXE" },
+	{ 10, "WRENCH" },
+	{ 11, "PITCHFORK" },
+	{ 12, "SCYTHE" },
+	{ 13, "SWORD" },
+	{ 14, "CHAINSAW" },
+	{ 15, "HARVEST_BLADE" },
+	{ 16, "SHOVEL" },
+	{ 17, "FIREAXE" },
+	{ 18, "BAT" },
+	{ 19, "RAZOR" },
+	{ 20, "POOLSTICK" }
+};
+
 static void syncBool(Common::Serializer &s, bool &value) {
 	byte serialized = value ? 1 : 0;
 	s.syncAsByte(serialized);
@@ -246,6 +274,38 @@ static int clampPlayerCombatLoadout(int loadout) {
 	return CLIP<int>(loadout, 0, kMaxPlayerCombatLoadout);
 }
 
+static const char *resolveCombatLoadoutInventoryObjectName(int loadout) {
+	for (const InventoryCombatLoadoutEntry &entry : kInventoryCombatLoadoutMap) {
+		if (entry.loadoutId == loadout)
+			return entry.objectName;
+	}
+
+	return nullptr;
+}
+
+static int validatePlayerCombatLoadoutAgainstInventory(const Common::Array<StartupObjectRecord> &runtimeObjects,
+		int loadout, const char *contextLabel) {
+	const int clampedLoadout = clampPlayerCombatLoadout(loadout);
+	if (clampedLoadout == kDefaultPlayerCombatLoadout)
+		return clampedLoadout;
+
+	const char *requiredObjectName = resolveCombatLoadoutInventoryObjectName(clampedLoadout);
+	if (!requiredObjectName)
+		return clampedLoadout;
+
+	for (const StartupObjectRecord &object : runtimeObjects) {
+		if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName) &&
+				object.objectName.equalsIgnoreCase(requiredObjectName))
+			return clampedLoadout;
+	}
+
+	debugC(1, kDebugGeneral,
+		"Harvester: cleared player combat loadout %d('%s') after %s because inventory object '%s' is absent",
+		clampedLoadout, Player::describeCombatLoadout(clampedLoadout),
+		contextLabel ? contextLabel : "runtime restore", requiredObjectName);
+	return kDefaultPlayerCombatLoadout;
+}
+
 static const char *resolveInventoryStatusObjectName(int hitPoints) {
 	if (hitPoints < 8)
 		return "INV_STAT4";
@@ -595,7 +655,8 @@ bool Script::reloadTownWorld(ResourceManager &resources) {
 	}
 
 	_playerCurrentHitPoints = clampPlayerHitPoints(playerCurrentHitPoints);
-	_playerCombatLoadout = clampPlayerCombatLoadout(playerCombatLoadout);
+	_playerCombatLoadout = validatePlayerCombatLoadoutAgainstInventory(
+		_runtimeObjects, playerCombatLoadout, "town reload");
 	_playerControlPaused = playerControlPaused;
 
 	debugC(1, kDebugGeneral,
@@ -1528,6 +1589,8 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 			_playerCombatLoadout = kDefaultPlayerCombatLoadout;
 			_playerControlPaused = false;
 		}
+		_playerCombatLoadout = validatePlayerCombatLoadoutAgainstInventory(
+			_runtimeObjects, _playerCombatLoadout, "save load");
 		logRuntimeSaveState("loaded");
 	}
 }


Commit: 5d4c60591bda8010dfc8722d634e5b1036e5aa4a
    https://github.com/scummvm/scummvm/commit/5d4c60591bda8010dfc8722d634e5b1036e5aa4a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:31+02:00

Commit Message:
HARVESTER: Preserve monster corpses after death

Changed paths:
    engines/harvester/monster.cpp
    engines/harvester/monster.h
    engines/harvester/room.cpp
    engines/harvester/saveload.cpp
    engines/harvester/script.cpp


diff --git a/engines/harvester/monster.cpp b/engines/harvester/monster.cpp
index 064422e7fd4..14cf98444ce 100644
--- a/engines/harvester/monster.cpp
+++ b/engines/harvester/monster.cpp
@@ -65,6 +65,22 @@ void Monster::applyAnimation(RuntimeEntity &entity, const StartupMonsterRecord &
 	if (!monster.visible)
 		return;
 
+	// Native monsters keep the last death frame on screen after the death bank
+	// completes instead of snapping back to an idle facing frame.
+	if (!monster.active &&
+			monster.currentHitPoints <= 0 &&
+			monster.runtimeSpawned &&
+			monster.runtimeState >= 0) {
+		int corpseFrame = monster.runtimeState;
+		if (corpseFrame > entity.getLastFrame())
+			corpseFrame = entity.getLastFrame();
+		entity.setAnimationRate(0);
+		entity.setAnimationFrameRange(corpseFrame, corpseFrame, false);
+		entity.setCurrentFrame(corpseFrame);
+		entity.setAnimationEnabled(false);
+		return;
+	}
+
 	const MonsterAnimationRange range = resolveAnimationRange(monster.facing);
 	if (monster.active) {
 		entity.setAnimationRate(kRoomMonsterAnimationRate);
diff --git a/engines/harvester/monster.h b/engines/harvester/monster.h
index c38f1785cbf..6663f627703 100644
--- a/engines/harvester/monster.h
+++ b/engines/harvester/monster.h
@@ -60,6 +60,7 @@ struct StartupMonsterRecord {
 	bool visible = false;
 	bool savedVisible = false;
 	bool runtimeSpawned = false;
+	int runtimeState = -1;
 	int screenMinXBound = 0x14;
 	int screenMaxXBound = 0x26b;
 	int attackSoundTriggerFrame = 0;
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index e36c3e54209..58d62c221c2 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1022,6 +1022,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				lhs.facing == rhs.facing &&
 				lhs.active == rhs.active &&
 				lhs.visible == rhs.visible &&
+				lhs.runtimeSpawned == rhs.runtimeSpawned &&
+				lhs.runtimeState == rhs.runtimeState &&
 				lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
 		};
 		auto applyRoomNpcPlacement = [&](RuntimeEntity &entity, const StartupNpcRecord &npc) {
@@ -2811,11 +2813,18 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				if (entity->getCurrentFrame() == combatState.deathLastFrame) {
 					combatState.deathActive = false;
 					monster.active = false;
-					monster.visible = false;
+					monster.visible = true;
+					monster.runtimeSpawned = true;
+					monster.runtimeState = entity->getCurrentFrame();
+					entity->setAnimationRate(0);
+					entity->setAnimationFrameRange(monster.runtimeState, monster.runtimeState, false);
+					entity->setCurrentFrame(monster.runtimeState);
+					entity->setAnimationEnabled(false);
 					(void)startupScript->syncRuntimeMonsterRecord(monster);
 					debugC(1, kDebugCombat,
-						"Harvester: combat monster death complete target='%s' damage_type=%d on_death='%s'",
+						"Harvester: combat monster death complete target='%s' damage_type=%d corpse_frame=%d on_death='%s'",
 						monster.monsterName.c_str(), combatState.deathDamageType,
+						monster.runtimeState,
 						monster.onDeathActionTag.c_str());
 					clearRoomMonsterCombatState(combatState);
 
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index bb708147bac..7e7582b059b 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -39,7 +39,7 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 }
 
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
-static const uint32 kHarvesterSaveVersion = 15;
+static const uint32 kHarvesterSaveVersion = 16;
 
 static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
 	debugC(1, kDebugRoom,
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index f78a768af96..066e1c20afb 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -204,6 +204,8 @@ static void syncStartupMonsterRecord(Common::Serializer &s, StartupMonsterRecord
 	s.syncAsSint32LE(record.hitSoundTriggerFrame);
 	s.syncAsSint32LE(record.footstepSoundTriggerFrame);
 	s.syncAsSint32LE(record.deathSoundTriggerFrame);
+	if (s.getVersion() >= 16)
+		s.syncAsSint32LE(record.runtimeState);
 }
 
 static void migrateLegacyMonsterCombatFields(const StartupMonsterRecord &baseMonster,
@@ -637,6 +639,7 @@ bool Script::reloadTownWorld(ResourceManager &resources) {
 		runtimeMonster->visible = monster.visible;
 		runtimeMonster->savedVisible = monster.savedVisible;
 		runtimeMonster->runtimeSpawned = monster.runtimeSpawned;
+		runtimeMonster->runtimeState = monster.runtimeState;
 		runtimeMonster->currentHitPoints = monster.currentHitPoints;
 		runtimeMonster->screenMinXBound = monster.screenMinXBound;
 		runtimeMonster->screenMaxXBound = monster.screenMaxXBound;
@@ -1346,6 +1349,7 @@ void Script::resetRuntimeState() {
 	for (StartupMonsterRecord &monster : _runtimeMonsters) {
 		monster.currentHitPoints = monster.initialHitPoints;
 		monster.runtimeSpawned = false;
+		monster.runtimeState = -1;
 		monster.savedVisible = monster.visible;
 		if (monster.active)
 			monster.visible = true;
@@ -1467,15 +1471,16 @@ void Script::logRuntimeSaveState(const char *operation) const {
 				monster.savedVisible != defaultSavedVisible ||
 				monster.currentHitPoints != defaultCurrentHitPoints ||
 				monster.runtimeSpawned ||
+				monster.runtimeState != -1 ||
 				monster.screenMinXBound != defaultScreenMinXBound ||
 				monster.screenMaxXBound != defaultScreenMaxXBound) {
 			debugC(1, kDebugGeneral,
-				"Harvester: %s runtime monster '%s' active=%d visible=%d savedVisible=%d spawned=%d hp=%d/%d screen_bounds=[%d,%d] base_active=%d base_visible=%d base_savedVisible=%d base_hp=%d/%d base_screen_bounds=[%d,%d]",
+				"Harvester: %s runtime monster '%s' active=%d visible=%d savedVisible=%d spawned=%d runtimeState=%d hp=%d/%d screen_bounds=[%d,%d] base_active=%d base_visible=%d base_savedVisible=%d base_runtimeState=%d base_hp=%d/%d base_screen_bounds=[%d,%d]",
 				operation, monster.monsterName.c_str(), monster.active, monster.visible,
-				monster.savedVisible, monster.runtimeSpawned,
+				monster.savedVisible, monster.runtimeSpawned, monster.runtimeState,
 				monster.currentHitPoints, monster.initialHitPoints,
 				monster.screenMinXBound, monster.screenMaxXBound,
-				defaultActive, defaultVisible, defaultSavedVisible,
+				defaultActive, defaultVisible, defaultSavedVisible, -1,
 				defaultCurrentHitPoints, baseMonster ? baseMonster->initialHitPoints : monster.initialHitPoints,
 				defaultScreenMinXBound, defaultScreenMaxXBound);
 		}
@@ -1579,6 +1584,7 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 				runtimeMonster.deathSound = baseMonster->deathSound;
 				runtimeMonster.savedVisible = runtimeMonster.visible;
 				runtimeMonster.runtimeSpawned = false;
+				runtimeMonster.runtimeState = -1;
 				runtimeMonster.screenMinXBound = baseMonster->screenMinXBound;
 				runtimeMonster.screenMaxXBound = baseMonster->screenMaxXBound;
 				runtimeMonster.attackSoundTriggerFrame = baseMonster->attackSoundTriggerFrame;
@@ -2273,6 +2279,8 @@ bool Script::triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, in
 			monsterChanged = !runtimeMonster->active || !runtimeMonster->visible;
 			runtimeMonster->active = true;
 			runtimeMonster->visible = true;
+			runtimeMonster->runtimeSpawned = false;
+			runtimeMonster->runtimeState = -1;
 			if (runtimeMonster->currentHitPoints <= 0)
 				runtimeMonster->currentHitPoints = runtimeMonster->initialHitPoints;
 		} else {
@@ -2396,8 +2404,9 @@ bool Script::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupE
 	}
 	for (const StartupMonsterRecord &monster : state.roomMonsters) {
 		debugC(1, kDebugRoom,
-			"Harvester: materialized room monster room='%s' monster='%s' visible=%d active=%d pos=(%d,%d,%d) facing=%d hp=%d/%d damage=%d engage=%d damage_type='%s' model='%s' on_death='%s'",
+			"Harvester: materialized room monster room='%s' monster='%s' visible=%d active=%d spawned=%d runtimeState=%d pos=(%d,%d,%d) facing=%d hp=%d/%d damage=%d engage=%d damage_type='%s' model='%s' on_death='%s'",
 			state.roomName.c_str(), monster.monsterName.c_str(), monster.visible, monster.active,
+			monster.runtimeSpawned, monster.runtimeState,
 			monster.posX, monster.posY, monster.posZ, monster.facing,
 			monster.currentHitPoints, monster.initialHitPoints,
 			monster.damageAmount, monster.engageDistance, Player::describeCombatDamageType(monster.damageType),
@@ -2636,6 +2645,14 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			const bool changed = runtimeMonster->active != active || runtimeMonster->visible != visible;
 			runtimeMonster->active = active;
 			runtimeMonster->visible = active ? true : visible;
+			if (active) {
+				runtimeMonster->runtimeSpawned = false;
+				runtimeMonster->runtimeState = -1;
+			}
+			if (!runtimeMonster->visible) {
+				runtimeMonster->runtimeSpawned = false;
+				runtimeMonster->runtimeState = -1;
+			}
 			if (active && runtimeMonster->currentHitPoints <= 0)
 				runtimeMonster->currentHitPoints = runtimeMonster->initialHitPoints;
 			noteMutation(changed);
@@ -2742,6 +2759,8 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 					monsterChanged = !runtimeMonster->active || !runtimeMonster->visible;
 					runtimeMonster->active = true;
 					runtimeMonster->visible = true;
+					runtimeMonster->runtimeSpawned = false;
+					runtimeMonster->runtimeState = -1;
 					if (runtimeMonster->currentHitPoints <= 0)
 						runtimeMonster->currentHitPoints = runtimeMonster->initialHitPoints;
 				} else {


Commit: df96046a64c82a7a7b5816132aff4102b148bd9b
    https://github.com/scummvm/scummvm/commit/df96046a64c82a7a7b5816132aff4102b148bd9b
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:31+02:00

Commit Message:
HARVESTER: Update monster state save version

Update save versioning for monster state serialization.

Changed paths:
    engines/harvester/saveload.cpp
    engines/harvester/script.cpp


diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index 7e7582b059b..5e12c991c12 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -39,6 +39,8 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 }
 
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
+// FIXME: This pre-release version-16 bump exists only to carry monster corpse
+// runtimeState. Fold it into the final Harvester save layout before release.
 static const uint32 kHarvesterSaveVersion = 16;
 
 static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
@@ -99,6 +101,8 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 		return Common::kReadingFailed;
 
 	int32 serializedDisc = (_resources && _resources->getCurrentDisc() > 0) ? _resources->getCurrentDisc() : 1;
+	// FIXME: Remove the pre-release version-14 fallback once Harvester ships and only
+	// clean release-era saves remain.
 	s.syncAsSint32LE(serializedDisc, 14);
 	const int restoredDisc = serializedDisc > 0 ? serializedDisc : 1;
 
@@ -109,15 +113,17 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 		logStartupSaveRoomState("saving", roomState);
 	syncStartupSaveRoomState(s, roomState);
 	_startupScript->syncRuntimeSaveState(s);
-		if (s.getVersion() >= 3) {
-			Common::Array<byte> dialogueStateBlob;
-			uint32 dialogueStateSize = 0;
-			if (s.isSaving()) {
-				if (!_activeFlow || !_activeFlow->buildDialogueSaveStateBlob(dialogueStateBlob, s.getVersion()))
-					return Common::kWritingFailed;
-				dialogueStateSize = dialogueStateBlob.size();
-				if (dialogueStateSize == 0)
-					return Common::kWritingFailed;
+	// FIXME: Drop the pre-release version-3 dialogue blob fallback after release; clean
+	// Harvester saves will always carry dialogue state.
+	if (s.getVersion() >= 3) {
+		Common::Array<byte> dialogueStateBlob;
+		uint32 dialogueStateSize = 0;
+		if (s.isSaving()) {
+			if (!_activeFlow || !_activeFlow->buildDialogueSaveStateBlob(dialogueStateBlob, s.getVersion()))
+				return Common::kWritingFailed;
+			dialogueStateSize = dialogueStateBlob.size();
+			if (dialogueStateSize == 0)
+				return Common::kWritingFailed;
 		}
 		s.syncAsUint32LE(dialogueStateSize);
 		if (s.isLoading()) {
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 066e1c20afb..026a810fc22 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -175,6 +175,8 @@ static void syncStartupMonsterRecord(Common::Serializer &s, StartupMonsterRecord
 	s.syncString(record.onDeathActionTag);
 	syncBool(s, record.active);
 	syncBool(s, record.visible);
+	// FIXME: Remove the pre-v2 monster save-layout fallback after release; clean
+	// Harvester saves will always serialize the full combat payload.
 	if (s.getVersion() < 2)
 		return;
 
@@ -204,10 +206,14 @@ static void syncStartupMonsterRecord(Common::Serializer &s, StartupMonsterRecord
 	s.syncAsSint32LE(record.hitSoundTriggerFrame);
 	s.syncAsSint32LE(record.footstepSoundTriggerFrame);
 	s.syncAsSint32LE(record.deathSoundTriggerFrame);
+	// FIXME: Remove this version-16 gate before release; clean Harvester saves
+	// should always serialize monster runtimeState.
 	if (s.getVersion() >= 16)
 		s.syncAsSint32LE(record.runtimeState);
 }
 
+// FIXME: Remove this pre-release monster combat migration after Harvester ships and
+// only clean release-era saves remain.
 static void migrateLegacyMonsterCombatFields(const StartupMonsterRecord &baseMonster,
 		StartupMonsterRecord &runtimeMonster) {
 	const int legacyInitialHitPoints = MAX(0, runtimeMonster.initialHitPoints);
@@ -1526,6 +1532,8 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 	syncRecordArray(s, _runtimeNpcs, syncStartupNpcRecord);
 	syncRecordArray(s, _runtimeMonsters, syncStartupMonsterRecord);
 	s.syncAsSint32LE(_playerCurrentHitPoints);
+	// FIXME: Remove this pre-v2 save-layout gate after release; clean Harvester
+	// saves always carry timer state, combat loadout, and paused-state fields.
 	if (s.getVersion() >= 2) {
 		syncRecordArray(s, _runtimeTimers, syncStartupTimerRecord);
 		s.syncAsSint32LE(_playerCombatLoadout);
@@ -1534,6 +1542,8 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 	if (s.isLoading()) {
 		_playerCurrentHitPoints = clampPlayerHitPoints(_playerCurrentHitPoints);
 		_playerCombatLoadout = clampPlayerCombatLoadout(_playerCombatLoadout);
+		// FIXME: Drop this compatibility pass after release; it exists only to repair
+		// pre-release saves written before the monster combat field layout stabilized.
 		if (s.getVersion() >= 2) {
 			for (StartupMonsterRecord &runtimeMonster : _runtimeMonsters) {
 				const StartupMonsterRecord *baseMonster = nullptr;
@@ -1551,6 +1561,8 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 				migrateLegacyMonsterCombatFields(*baseMonster, runtimeMonster);
 			}
 		}
+		// FIXME: Remove the pre-v2 save fallback after release; clean Harvester saves will
+		// always serialize timer state and the full monster combat payload.
 		if (s.getVersion() < 2) {
 			_runtimeTimers = _timers;
 			for (StartupMonsterRecord &runtimeMonster : _runtimeMonsters) {


Commit: 0fcdd156babb8e6870a877e3c1ebb18837826a6a
    https://github.com/scummvm/scummvm/commit/0fcdd156babb8e6870a877e3c1ebb18837826a6a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:32+02:00

Commit Message:
HARVESTER: Restore room after in-game save

Changed paths:
    TRACKER.md
    engines/harvester/menu.cpp
    engines/harvester/menu.h


diff --git a/TRACKER.md b/TRACKER.md
index e206512d6c4..c0e4d54c23e 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Last Confirmed Action
 
+- On March 30, 2026, checked Harvester's in-room save exit path against `HARVEST.LE` in Ghidra after a live `BOWLS1` save still returned to the pause menu. Native `run_main_menu` calls `run_save_game_menu()`, then exits the ESC menu and ramps the live room palette back in instead of leaving the player in the menu. Patched [`engines/harvester/menu.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/menu.cpp) and [`engines/harvester/menu.h`](/Users/alex/Workspace/scummvm/engines/harvester/menu.h) so a successful room save now closes the room menu, fades the save screen to black, restores the captured room backdrop, and fades back to the room view. Verified by rebuilding [`menu.o`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/engines/harvester/menu.o) and relinking [`libharvester.a`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/engines/harvester/libharvester.a).
 - On March 30, 2026, rechecked Harvester's room-combat save-restore path after a fresh `BOWLS4` validation still showed `DOORKNOB2` materializing as `hp=95/14 damage=2 engage=95`. The runtime log proved the room script parse was already correct and only the restored runtime monster HP was stale. Source inspection in [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp) showed why: `reloadTownWorld()` preserves only `currentHitPoints` from the loaded runtime monster state, while `engageDistance`, `initialHitPoints`, and `damageAmount` come back from the freshly reparsed script. That means a legacy save with the old swapped monster combat fields turns into the exact mixed state seen in the log. Patched [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp) so load-time monster migration no longer depends only on `saveVersion < 15`; it now also detects any runtime monster whose serialized `initialHitPoints`, `damageAmount`, or `engageDistance` do not match the current script base record and recalculates `currentHitPoints` from the legacy damage taken before the disc reload/preserve pass.
 - On March 30, 2026, re-audited native class-6 melee re-entry in live `HARVEST.LE` after a fresh `BOWLS4` log showed the new short-bank/rate-4 patch was still chaining hits too quickly. Ghidra confirmed `update_actor_runtime_state` at `0x4d750` still uses the short monster-only attack banks `0x8c..0x94` (right-facing) and `0x95..0x9d` (left-facing) at attack rate `4`, with the default contact offset still coming from `+0x113c = 2` (`MUCKEY` remains the known `7`-frame exception). The missed native gate is the monster-local timestamp at `entity+0x1144`: `spawn_monster_entity_base` seeds it from `DAT_000c7e44` on spawn, melee entry refreshes it to `DAT_000c7e44 + (next_random_value() % 100)`, and attack startup is blocked until that stored tick is at least `0x32` centiseconds old unless the live center-to-center X delta is already inside `engage_distance / 3`. `DAT_000c7e44` itself is written from `get_elapsed_milliseconds()` in `run_harvester_main_loop`, and that helper is the same centisecond DOS clock wrapper already mirrored by ScummVM's `Player::getRuntimeClockTicks()`. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) to restore that native cooldown gate, seed the initial post-spawn delay, keep the confirmed short attack banks and rate `4`, and use the native close-range bypass check based on live center delta instead of the broader overlap helper.
 - On March 30, 2026, patched ScummVM's Harvester room-combat gate to match the confirmed native class-6 melee-entry split. [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) now keeps the broader overlap/edge-gap helper only for room-combat reach checks such as player target selection, while monster attack startup uses a dedicated native gate that requires base Z within `+/- 2.0` and live center-to-center X within `engage_distance`. This removes the prior local behavior where monster melee could start as soon as the chase waypoint slack (`50.0 * depth_scale`) or simple sprite overlap reported "close enough", even though native reserves that wider band for chase-waypoint satisfaction.
@@ -20,6 +21,7 @@
 
 ## Next Suggested Action
 
+- Immediate: run an in-engine Harvester save validation from an active room such as `BOWLS1`: open the ESC menu, save into a slot, and confirm the screen now fades out of `SAVEGAME.BM`, returns directly to the live room instead of the pause menu, and leaves subsequent ESC re-entry, save cancel, and load-from-room behavior intact.
 - Immediate: reload the same legacy `BOWLS4` save and confirm the early runtime logs now show `loaded runtime monster 'DOORKNOB2' ... hp=14/14` and `materialized room monster ... hp=14/14 damage=2 engage=95` before any combat begins. If that passes, the remaining `DOORKNOB2` difficulty gap is raw native data plus melee cadence rather than stale save-state corruption.
 - Immediate: rerun in-engine combat in `BOWLS4` with `combat` logging enabled after the restored native melee cooldown gate. Confirm class-6 attack-start logs still stay inside the short native banks (`140..142`, `143..145`, or `146..148` for right-facing; `149..151`, `152..154`, or `155..157` for left-facing), still report `rate=4`, and now show materially wider spacing between attack starts unless the player is already inside the native `engage_distance / 3` close-bypass band. If the cadence now feels correct, capture one short note in the tracker and close out monster melee pacing.
 - Immediate: rerun one in-engine `BOWLS4` or `PCHOUSE` combat capture with `combat` logging enabled after the stricter monster attack-entry patch. Confirm monsters now keep walking until `abs(center_dx) <= engage_distance` after the `+/- 2.0` Z gate, and compare whether the perceived over-fast aggression drops without changing locomotion cadence.
diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index 77fbe50502f..bebed6c1545 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -109,6 +109,9 @@ static const int kPasswordEntryX = 0xdc;
 static const int kPasswordEntryY = 0xdc;
 static const int kPasswordEntryWidth = 0x226;
 static const int kPasswordMaxCharacters = 8;
+static const uint32 kPaletteFadeTickMs = 4;
+static const float kPaletteFadeStep = 0.1f;
+static const float kPaletteBrightnessBlack = 0.0f;
 
 struct RoomMenuTextConfig {
 	Common::Array<Common::String> optionItems;
@@ -773,7 +776,9 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 		}
 
 		if (item.equalsIgnoreCase("SAVE GAME")) {
-			Common::Error saveError = runSaveGameMenu(menuPalette, 1.0f, startupFlow);
+			bool savedGame = false;
+			Common::Error saveError = runSaveGameMenu(menuPalette, 1.0f, startupFlow, savedGame);
+			(void)savedGame;
 			needsRedraw = true;
 			return saveError;
 		}
@@ -1009,9 +1014,19 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 		}
 
 		if (item.equalsIgnoreCase("SAVE GAME")) {
-			Common::Error saveError = runSaveGameMenu(palette, paletteBrightness, startupFlow);
+			bool savedGame = false;
+			Common::Error saveError = runSaveGameMenu(palette, paletteBrightness, startupFlow, savedGame);
 			if (saveError.getCode() != Common::kNoError)
 				return saveError;
+			if (savedGame) {
+				// Native exits the in-room ESC menu after a successful save and restores the room view.
+				Common::Error restoreError = restoreRoomBackdropAfterSave(
+					backdrop, palette, paletteBrightness, startupFlow);
+				if (restoreError.getCode() != Common::kNoError)
+					return restoreError;
+				shouldCloseMenu = true;
+				return Common::kNoError;
+			}
 			needsRedraw = true;
 			return Common::kNoError;
 		}
@@ -1264,9 +1279,10 @@ Common::Error MenuSystem::runLoadGameMenu(const byte *palette, float paletteBrig
 }
 
 Common::Error MenuSystem::runSaveGameMenu(const byte *palette, float paletteBrightness,
-		Flow &startupFlow) {
+		Flow &startupFlow, bool &savedGame) {
 	(void)palette;
 	(void)paletteBrightness;
+	savedGame = false;
 	ResourceManager *resources = _engine.getResources();
 	MetaEngine *metaEngine = _engine.getMetaEngine();
 	const CftFontResource *slotNameFontResource = findStartupFontByName(_engine, "MEDFONT1");
@@ -1435,16 +1451,20 @@ Common::Error MenuSystem::runSaveGameMenu(const byte *palette, float paletteBrig
 					Common::Error saveError = activateSelectedSlot(saved);
 					if (saveError.getCode() != Common::kNoError)
 						return saveError;
-					if (saved)
+					if (saved) {
+						savedGame = true;
 						return Common::kNoError;
+					}
 					break;
 				}
 				if (saveConfirmRect().contains(_mousePos)) {
 					Common::Error saveError = activateSelectedSlot(saved);
 					if (saveError.getCode() != Common::kNoError)
 						return saveError;
-					if (saved)
+					if (saved) {
+						savedGame = true;
 						return Common::kNoError;
+					}
 				} else if (saveCancelRect().contains(_mousePos)) {
 					return Common::kNoError;
 				}
@@ -1469,8 +1489,10 @@ Common::Error MenuSystem::runSaveGameMenu(const byte *palette, float paletteBrig
 					Common::Error saveError = activateSelectedSlot(saved);
 					if (saveError.getCode() != Common::kNoError)
 						return saveError;
-					if (saved)
+					if (saved) {
+						savedGame = true;
 						return Common::kNoError;
+					}
 					needsRedraw = true;
 				}
 				break;
@@ -1489,6 +1511,59 @@ Common::Error MenuSystem::runSaveGameMenu(const byte *palette, float paletteBrig
 	return Common::kNoError;
 }
 
+Common::Error MenuSystem::restoreRoomBackdropAfterSave(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, Flow &startupFlow) const {
+	Graphics::Screen *screen = _engine.getScreen();
+	if (!screen || !backdrop.isValid())
+		return Common::kNoError;
+
+	byte displayPalette[256 * 3];
+	screen->getPalette(displayPalette);
+
+	bool hasVisiblePalette = false;
+	for (byte value : displayPalette) {
+		if (value != 0) {
+			hasVisiblePalette = true;
+			break;
+		}
+	}
+
+	if (hasVisiblePalette) {
+		for (float brightness = 1.0f - kPaletteFadeStep;
+				brightness > kPaletteBrightnessBlack; brightness -= kPaletteFadeStep) {
+			byte fadedPalette[256 * 3];
+			buildHarvesterDisplayPalette(displayPalette, brightness, fadedPalette);
+			screen->setPalette(fadedPalette);
+			screen->makeAllDirty();
+			screen->update();
+
+			const uint32 nextTick = g_system->getMillis() + kPaletteFadeTickMs;
+			while ((int32)(nextTick - g_system->getMillis()) > 0) {
+				Common::Event event;
+				while (g_system->getEventManager()->pollEvent(event)) {
+					Common::Error result = Common::kNoError;
+					if (startupFlow.handleSystemEvent(event, result))
+						return result;
+				}
+				g_system->delayMillis(1);
+			}
+		}
+
+		byte blackPalette[256 * 3] = { 0 };
+		screen->setPalette(blackPalette);
+		screen->makeAllDirty();
+		screen->update();
+	}
+
+	screen->fillRect(screen->getBounds(), 0);
+	blitBitmap(*screen, backdrop, 0, 0);
+	applyMenuPalette(*screen, _engine, palette, kPaletteBrightnessBlack);
+	screen->makeAllDirty();
+	screen->update();
+
+	return startupFlow.fadeInRoomScene(palette, paletteBrightness);
+}
+
 Common::Error MenuSystem::runConfirmPrompt(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, Flow &startupFlow, const Common::String &promptText,
 		bool &confirmed) {
diff --git a/engines/harvester/menu.h b/engines/harvester/menu.h
index e1f730fc5f1..74a51d88228 100644
--- a/engines/harvester/menu.h
+++ b/engines/harvester/menu.h
@@ -52,7 +52,10 @@ private:
 	Common::Error runOptionsMenu(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, Flow &startupFlow);
 	Common::Error runHelpScreen(const byte *palette, float paletteBrightness, Flow &startupFlow);
-	Common::Error runSaveGameMenu(const byte *palette, float paletteBrightness, Flow &startupFlow);
+	Common::Error runSaveGameMenu(const byte *palette, float paletteBrightness,
+		Flow &startupFlow, bool &savedGame);
+	Common::Error restoreRoomBackdropAfterSave(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, Flow &startupFlow) const;
 	Common::Error runConfirmPrompt(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, Flow &startupFlow, const Common::String &promptText,
 		bool &confirmed);


Commit: 5dbfd40e3f3d778bb7a7bf1f88461898cf22f13a
    https://github.com/scummvm/scummvm/commit/5dbfd40e3f3d778bb7a7bf1f88461898cf22f13a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:32+02:00

Commit Message:
HARVESTER: Fix blocked room entry movement

Changed paths:
    engines/harvester/flow.cpp
    engines/harvester/flow.h
    engines/harvester/player.cpp
    engines/harvester/player.h
    engines/harvester/room.cpp


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 3f42169c8e7..803bbd69bb7 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -1765,7 +1765,7 @@ bool Flow::ensureCursorEntity() {
 	return runtimeEntities->spawnCursorEntity(_mousePos) != nullptr;
 }
 
-bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
+bool Flow::populateRoomSceneEntities(StartupRoomSetupState &state,
 		const Common::Array<StartupObjectRecord> &drawableObjects,
 		const Common::Array<StartupAnimRecord> &drawableAnimations) {
 	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
@@ -1950,6 +1950,18 @@ bool Flow::populateRoomSceneEntities(const StartupRoomSetupState &state,
 				playerState.combatLoadout = -1;
 				(void)Player::syncCombatLoadoutVisual(_engine, state, playerState, playerCombatLoadout);
 			}
+			StartupRoomPlayerState startupPlayerState;
+			startupPlayerState.entity = player;
+			startupPlayerState.centerX = state.playerSpawnX;
+			startupPlayerState.bottomY = state.playerSpawnY;
+			startupPlayerState.z = (float)state.playerSpawnZ;
+			startupPlayerState.facing = state.playerFacing;
+			if (Player::resolveBlockedStartupSpawn(
+					_engine, state, drawableObjects, drawableAnimations, startupPlayerState)) {
+				state.playerSpawnX = startupPlayerState.centerX;
+				state.playerSpawnY = startupPlayerState.bottomY;
+				state.playerSpawnZ = (int)startupPlayerState.z;
+			}
 			if (reusedPlayer)
 				runtimeEntities->adoptSceneEntity(player);
 			else
diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index 381beea1f20..8d1b7b386b1 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -68,7 +68,7 @@ private:
 	Common::Error runRoomLoop(const Common::String &entranceName);
 	Common::Error resolveRoomTransitionTarget(const Common::String &targetName, Common::String &resolvedTargetName);
 	bool ensureCursorEntity();
-	bool populateRoomSceneEntities(const StartupRoomSetupState &state,
+	bool populateRoomSceneEntities(StartupRoomSetupState &state,
 		const Common::Array<StartupObjectRecord> &drawableObjects,
 		const Common::Array<StartupAnimRecord> &drawableAnimations);
 	Common::Error beginRoomSetupTransition();
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 4e8fad476f9..4b2853707f9 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -844,6 +844,57 @@ void Player::setMoveTargetFromScreenPoint(const StartupRoomSetupState &state,
 		mapRoomScreenYToDepth(state, clampRoomMovementY(state, targetBottomY)));
 }
 
+bool Player::resolveBlockedStartupSpawn(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		const Common::Array<StartupObjectRecord> &sceneObjects,
+		const Common::Array<StartupAnimRecord> &sceneAnimations,
+		StartupRoomPlayerState &playerState) {
+	if (!playerState.entity)
+		return false;
+
+	if (!isPlayerMovementBlocked(engine, state, sceneObjects, sceneAnimations,
+			playerState, playerState.centerX, playerState.z)) {
+		return false;
+	}
+
+	const int originalCenterX = playerState.centerX;
+	const int preferredDirection = playerState.facing == 1 ? -1 : 1;
+	for (int distance = 1; distance < 640; ++distance) {
+		int previousCandidateX = originalCenterX;
+		for (int pass = 0; pass < 2; ++pass) {
+			const int direction = pass == 0 ? preferredDirection : -preferredDirection;
+			const int candidateX = clampPlayerCenterXToNativeBounds(
+				playerState, originalCenterX + direction * distance);
+			if (candidateX == originalCenterX || candidateX == previousCandidateX)
+				continue;
+			previousCandidateX = candidateX;
+			if (isPlayerMovementBlocked(engine, state, sceneObjects, sceneAnimations,
+					playerState, candidateX, playerState.z)) {
+				continue;
+			}
+
+			playerState.centerX = candidateX;
+			playerState.bottomY = mapRoomDepthToScreenY(state, playerState.z);
+			if (!applyRoomActorPlacement(state, *playerState.entity,
+					playerState.centerX, playerState.bottomY, playerState.z)) {
+				return false;
+			}
+
+			debugC(1, kDebugPlayer,
+				"Harvester: adjusted blocked startup spawn room='%s' facing=%d from=(%d,%d,z=%.2f) to=(%d,%d,z=%.2f)",
+				state.roomName.c_str(), playerState.facing,
+				originalCenterX, mapRoomDepthToScreenY(state, playerState.z), (double)playerState.z,
+				playerState.centerX, playerState.bottomY, (double)playerState.z);
+			return true;
+		}
+	}
+
+	debugC(1, kDebugPlayer,
+		"Harvester: startup spawn remains blocked room='%s' pos=(%d,%d,z=%.2f)",
+		state.roomName.c_str(), playerState.centerX, mapRoomDepthToScreenY(state, playerState.z),
+		(double)playerState.z);
+	return false;
+}
+
 int Player::resolveRegionTargetX(const StartupRegionRecord &region,
 		const StartupRoomPlayerState &playerState) {
 	const Common::Rect bounds(region.left, region.top, region.right + 1, region.bottom + 1);
diff --git a/engines/harvester/player.h b/engines/harvester/player.h
index bb622fb8c5c..7e32aa21ee8 100644
--- a/engines/harvester/player.h
+++ b/engines/harvester/player.h
@@ -45,6 +45,10 @@ public:
 		int targetX, float targetZ);
 	static void setMoveTargetFromScreenPoint(const StartupRoomSetupState &state,
 		StartupRoomPlayerState &playerState, int targetX, int targetBottomY);
+	static bool resolveBlockedStartupSpawn(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		const Common::Array<StartupObjectRecord> &sceneObjects,
+		const Common::Array<StartupAnimRecord> &sceneAnimations,
+		StartupRoomPlayerState &playerState);
 	static int resolveRegionTargetX(const StartupRegionRecord &region,
 		const StartupRoomPlayerState &playerState);
 	static float resolveRegionTargetZ(const StartupRegionRecord &region);
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 58d62c221c2..ff48908bd01 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -754,10 +754,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			(void)_engine.playStartupMusic(scene.state.musicPath);
 		StartupRoomPlayerState playerState;
 		playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName("PLAYER") : nullptr;
-		playerState.centerX = state.playerSpawnX;
-		playerState.bottomY = state.playerSpawnY;
-		playerState.z = (float)state.playerSpawnZ;
-		playerState.facing = state.playerFacing;
+		playerState.centerX = scene.state.playerSpawnX;
+		playerState.bottomY = scene.state.playerSpawnY;
+		playerState.z = (float)scene.state.playerSpawnZ;
+		playerState.facing = scene.state.playerFacing;
 		playerState.combatLoadout =
 			_engine.getStartupScript() ? _engine.getStartupScript()->getPlayerCombatLoadout() : 0;
 		playerState.turnActive = false;


Commit: 71adee78af2901a3133d0f2a2226d221387e2e53
    https://github.com/scummvm/scummvm/commit/71adee78af2901a3133d0f2a2226d221387e2e53
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:32+02:00

Commit Message:
HARVESTER: Refresh live room hotspots after mutations

Changed paths:
    engines/harvester/room.cpp


diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index ff48908bd01..c8683a9cbda 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1026,6 +1026,33 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				lhs.runtimeState == rhs.runtimeState &&
 				lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
 		};
+		auto sameSceneObjectState = [&](const StartupObjectRecord &lhs, const StartupObjectRecord &rhs) {
+			return lhs.currentX == rhs.currentX &&
+				lhs.currentY == rhs.currentY &&
+				lhs.currentZ == rhs.currentZ &&
+				lhs.zExtent == rhs.zExtent &&
+				lhs.operatable == rhs.operatable &&
+				lhs.visible == rhs.visible &&
+				lhs.runtimeVisible == rhs.runtimeVisible &&
+				lhs.actionTag.equalsIgnoreCase(rhs.actionTag) &&
+				lhs.identTextKey.equalsIgnoreCase(rhs.identTextKey) &&
+				lhs.interactionLabel.equalsIgnoreCase(rhs.interactionLabel) &&
+				lhs.currentOwnerOrRoom.equalsIgnoreCase(rhs.currentOwnerOrRoom) &&
+				resolveSceneObjectSpritePathLocal(lhs).equalsIgnoreCase(resolveSceneObjectSpritePathLocal(rhs)) &&
+				sameRect(getSceneObjectBounds(lhs), getSceneObjectBounds(rhs));
+		};
+		auto sameSceneRegionState = [&](const StartupRegionRecord &lhs, const StartupRegionRecord &rhs) {
+			return lhs.left == rhs.left &&
+				lhs.top == rhs.top &&
+				lhs.right == rhs.right &&
+				lhs.bottom == rhs.bottom &&
+				lhs.minZ == rhs.minZ &&
+				lhs.maxZ == rhs.maxZ &&
+				lhs.startEnabled == rhs.startEnabled &&
+				lhs.cursorEnabled == rhs.cursorEnabled &&
+				lhs.desiredFacing == rhs.desiredFacing &&
+				lhs.actionTag.equalsIgnoreCase(rhs.actionTag);
+		};
 		auto applyRoomNpcPlacement = [&](RuntimeEntity &entity, const StartupNpcRecord &npc) {
 			int width = 0;
 			int height = 0;
@@ -1276,18 +1303,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			}
 			for (const StartupObjectRecord &object : updatedScene.sceneObjects) {
 				const StartupObjectRecord *previous = findSceneObjectByName(scene.sceneObjects, object.objectName);
-				const Common::String spritePath = resolveSceneObjectSpritePathLocal(object);
-				const Common::Rect bounds = getSceneObjectBounds(object);
-				const bool changed = !previous ||
-					previous->currentX != object.currentX ||
-					previous->currentY != object.currentY ||
-					previous->currentZ != object.currentZ ||
-					previous->zExtent != object.zExtent ||
-					previous->operatable != object.operatable ||
-					!previous->actionTag.equalsIgnoreCase(object.actionTag) ||
-					!previous->identTextKey.equalsIgnoreCase(object.identTextKey) ||
-					!resolveSceneObjectSpritePathLocal(*previous).equalsIgnoreCase(spritePath) ||
-					!sameRect(getSceneObjectBounds(*previous), bounds);
+				const bool changed = !previous || !sameSceneObjectState(*previous, object);
 				if (!changed)
 					continue;
 
@@ -1302,14 +1318,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			for (const StartupRegionRecord &region : updatedScene.sceneRegions) {
 				const StartupRegionRecord *previous =
 					findSceneRegionByName(scene.sceneRegions, region.regionName);
-				const bool changed = !previous ||
-					previous->left != region.left ||
-					previous->top != region.top ||
-					previous->right != region.right ||
-					previous->bottom != region.bottom ||
-					previous->minZ != region.minZ ||
-					previous->maxZ != region.maxZ ||
-					previous->cursorEnabled != region.cursorEnabled;
+				const bool changed = !previous || !sameSceneRegionState(*previous, region);
 				if (!changed)
 					continue;
 


Commit: 1d29d5d0f8916d44425a46bf942eb9fecf2e4015
    https://github.com/scummvm/scummvm/commit/1d29d5d0f8916d44425a46bf942eb9fecf2e4015
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:33+02:00

Commit Message:
HARVESTER: Add debug logging for spawned room regions

Changed paths:
    engines/harvester/room.cpp


diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index c8683a9cbda..b3627883b2e 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -975,6 +975,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			entity->setZExtent((float)MAX(0, region.maxZ - region.minZ));
 			if (!region.cursorEnabled)
 				entity->setHitTestMode(kRuntimeEntityHitTestNone);
+			debugC(1, kDebugRoom,
+				"Harvester: scene region spawned room='%s' region='%s' class=0x%x bounds=(%d,%d)-(%d,%d) z=[%d,%d] facing=%d cursor=%d action='%s'",
+				region.roomName.c_str(), region.regionName.c_str(), entity->getClassId(),
+				bounds.left, bounds.top, bounds.right, bounds.bottom,
+				region.minZ, region.maxZ, region.desiredFacing, region.cursorEnabled, region.actionTag.c_str());
 		};
 		auto findRoomAnimByNameConst = [](const Common::Array<StartupAnimRecord> &anims,
 				const Common::String &animName) -> const StartupAnimRecord * {


Commit: a98a8642a1b6381a2c266f70c994876da03217c4
    https://github.com/scummvm/scummvm/commit/a98a8642a1b6381a2c266f70c994876da03217c4
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:33+02:00

Commit Message:
HARVESTER: Match native frame advance class gate

Changed paths:
    TRACKER.md
    engines/harvester/runtime_entity.cpp


diff --git a/TRACKER.md b/TRACKER.md
index c0e4d54c23e..9a2b9bb0199 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,6 +10,7 @@
 
 ## Last Confirmed Action
 
+- On March 31, 2026, re-audited `advance_entity_animation_frame` in live `HARVEST.LE` after noticing the hardcoded `BIG_EYE` name check in Ghidra. Native `spawn_npc_entity_from_record` sets room NPC entities to class `4`, and the recovered `advance_entity_animation_frame` at `0x4c8a0` only reaches the `strcmp("MOUSE")` / `strcmp("BIG_EYE")` horizontal-clamp exception block when `entity_class != 1 && entity_class != 4`, so the `BIG_EYE` compare is not part of the normal room-NPC path. The actual source drift was the non-looping frame-boundary rule: native clears `animation_enabled` only for class-`1` animation entities when a non-looping bank hits its endpoint, while ScummVM had been disabling animation for every non-looping entity. Patched [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp) so [`RuntimeEntity::advanceAnimationFrame`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp#L607) now clears `_animationEnabled` only for [`kRuntimeEntityClassAnimation`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h#L40), matching the native class gate. Verified with `rtk make -j4 engines/harvester/runtime_entity.o` and `rtk git diff --check`.
 - On March 30, 2026, checked Harvester's in-room save exit path against `HARVEST.LE` in Ghidra after a live `BOWLS1` save still returned to the pause menu. Native `run_main_menu` calls `run_save_game_menu()`, then exits the ESC menu and ramps the live room palette back in instead of leaving the player in the menu. Patched [`engines/harvester/menu.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/menu.cpp) and [`engines/harvester/menu.h`](/Users/alex/Workspace/scummvm/engines/harvester/menu.h) so a successful room save now closes the room menu, fades the save screen to black, restores the captured room backdrop, and fades back to the room view. Verified by rebuilding [`menu.o`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/engines/harvester/menu.o) and relinking [`libharvester.a`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/engines/harvester/libharvester.a).
 - On March 30, 2026, rechecked Harvester's room-combat save-restore path after a fresh `BOWLS4` validation still showed `DOORKNOB2` materializing as `hp=95/14 damage=2 engage=95`. The runtime log proved the room script parse was already correct and only the restored runtime monster HP was stale. Source inspection in [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp) showed why: `reloadTownWorld()` preserves only `currentHitPoints` from the loaded runtime monster state, while `engageDistance`, `initialHitPoints`, and `damageAmount` come back from the freshly reparsed script. That means a legacy save with the old swapped monster combat fields turns into the exact mixed state seen in the log. Patched [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp) so load-time monster migration no longer depends only on `saveVersion < 15`; it now also detects any runtime monster whose serialized `initialHitPoints`, `damageAmount`, or `engageDistance` do not match the current script base record and recalculates `currentHitPoints` from the legacy damage taken before the disc reload/preserve pass.
 - On March 30, 2026, re-audited native class-6 melee re-entry in live `HARVEST.LE` after a fresh `BOWLS4` log showed the new short-bank/rate-4 patch was still chaining hits too quickly. Ghidra confirmed `update_actor_runtime_state` at `0x4d750` still uses the short monster-only attack banks `0x8c..0x94` (right-facing) and `0x95..0x9d` (left-facing) at attack rate `4`, with the default contact offset still coming from `+0x113c = 2` (`MUCKEY` remains the known `7`-frame exception). The missed native gate is the monster-local timestamp at `entity+0x1144`: `spawn_monster_entity_base` seeds it from `DAT_000c7e44` on spawn, melee entry refreshes it to `DAT_000c7e44 + (next_random_value() % 100)`, and attack startup is blocked until that stored tick is at least `0x32` centiseconds old unless the live center-to-center X delta is already inside `engage_distance / 3`. `DAT_000c7e44` itself is written from `get_elapsed_milliseconds()` in `run_harvester_main_loop`, and that helper is the same centisecond DOS clock wrapper already mirrored by ScummVM's `Player::getRuntimeClockTicks()`. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) to restore that native cooldown gate, seed the initial post-spawn delay, keep the confirmed short attack banks and rate `4`, and use the native close-range bypass check based on live center delta instead of the broader overlap helper.
@@ -21,6 +22,7 @@
 
 ## Next Suggested Action
 
+- Immediate: rerun the in-engine `EYEHALL` repro with `combat` logging enabled after the class-1-only non-looping animation fix. Confirm the eye-hit sequence still logs `SET_REGION EYE_MAIN`, `DELETE EYEHALL BIGEYE`, and `roomRegions=2`, and watch whether any room NPC or other class-4 actor now behaves differently at non-looping bank endpoints. If the live behavior is unchanged apart from endpoint bookkeeping, the next useful Ghidra comparison is the native room-NPC state-transition path that selects the `0x34` passive bank versus the death/reaction banks above `0x3b`.
 - Immediate: run an in-engine Harvester save validation from an active room such as `BOWLS1`: open the ESC menu, save into a slot, and confirm the screen now fades out of `SAVEGAME.BM`, returns directly to the live room instead of the pause menu, and leaves subsequent ESC re-entry, save cancel, and load-from-room behavior intact.
 - Immediate: reload the same legacy `BOWLS4` save and confirm the early runtime logs now show `loaded runtime monster 'DOORKNOB2' ... hp=14/14` and `materialized room monster ... hp=14/14 damage=2 engage=95` before any combat begins. If that passes, the remaining `DOORKNOB2` difficulty gap is raw native data plus melee cadence rather than stale save-state corruption.
 - Immediate: rerun in-engine combat in `BOWLS4` with `combat` logging enabled after the restored native melee cooldown gate. Confirm class-6 attack-start logs still stay inside the short native banks (`140..142`, `143..145`, or `146..148` for right-facing; `149..151`, `152..154`, or `155..157` for left-facing), still report `rate=4`, and now show materially wider spacing between attack starts unless the player is already inside the native `engage_distance / 3` close-bypass band. If the cadence now feels correct, capture one short note in the tracker and close out monster melee pacing.
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index b387846e1d9..f732f9744c6 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -615,7 +615,8 @@ void RuntimeEntity::advanceAnimationFrame(int directive) {
 
 		if (!_looping) {
 			_currentFrame = _firstFrame;
-			_animationEnabled = false;
+			if (_classId == kRuntimeEntityClassAnimation)
+				_animationEnabled = false;
 			goto done;
 		}
 
@@ -636,7 +637,8 @@ void RuntimeEntity::advanceAnimationFrame(int directive) {
 
 		if (!_looping) {
 			_currentFrame = _lastFrame;
-			_animationEnabled = false;
+			if (_classId == kRuntimeEntityClassAnimation)
+				_animationEnabled = false;
 			goto done;
 		}
 


Commit: a4e8831efbcce61546bbfb5325d9b90d5ddef08f
    https://github.com/scummvm/scummvm/commit/a4e8831efbcce61546bbfb5325d9b90d5ddef08f
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:33+02:00

Commit Message:
HARVESTER: Defer room NPC death transitions

Changed paths:
    AGENTS.md
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/dialogue.h
    engines/harvester/flow.cpp
    engines/harvester/npc/chessmaster_dialogue.cpp
    engines/harvester/npc/dark_woman_dialogue.cpp
    engines/harvester/npc/gladiator_dialogue.cpp
    engines/harvester/npc/herrill_dialogue.cpp
    engines/harvester/npc/madam_dialogue.cpp
    engines/harvester/npc/mr_potts_dialogue.cpp
    engines/harvester/npc/sergeant_dialogue.cpp
    engines/harvester/room.cpp
    engines/harvester/room_support.h
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/AGENTS.md b/AGENTS.md
index 9581e51fba8..8eec1040794 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -511,6 +511,7 @@ Track data flow carefully.
 Rename only with high confidence.
 Record only confirmed architecture.
 Record the last confirmed action we completed, and the next suggested action in TRACKER.md
-Record high level progress indicator that summarizes how many total functions are in Ghidra, and how want are still not renamed (ex: FUN_*) in TRACKER.md
+Record high level progress indicator that summarizes how many total functions are in Ghidra, and how many are still not renamed (ex: FUN_*) in TRACKER.md
+Ensure TRACKER.md only contains the *last* action take and the *next suggested* action
 
 Never guess.
\ No newline at end of file
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 406e837fbc8..f9701d4bbc6 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -437,6 +437,7 @@
 - `get_flag_value` at `0x7c790` and `set_flag_value` at `0x7c7e0` are the core `FLAG` accessors used throughout startup, room setup, and event dispatch.
 - `run_npc_dialogue` at `0x79e50` is the conversation/head UI for named NPCs.
   - It validates the NPC through `g_npc_records`.
+  - It refuses NPC records whose persisted `death_damage_type` is already non-zero, so a `KILL_NPC` / `MONSTERFY` transition removes that NPC from the talk path as soon as the pending death type is latched, even before the live class-4 death bank finishes.
   - It uses `g_head_records` to resolve portrait variants and keyword/head assets.
   - It drives the textbox / keyword / voice interaction loop until the exchange completes or is interrupted.
   - Before dispatching the per-NPC talk handler, it clears `g_dialogue_keyword_list_spec` and `g_dialogue_selected_topic_text`, spawns `g_dialogue_keyword_panel_entity` plus `g_dialogue_textbox1_entity` through `g_dialogue_textbox8_entity`, preloads the right PC portrait from the first matching `PC0`..`PC5` `HEAD` record, and preloads the left NPC portrait from `<NPC>0`.
@@ -870,6 +871,7 @@
     - It writes the matching record's `active` / `visible` bytes, removes the current-room render entity when visibility clears, respawns it through `spawn_npc_entity_from_record` when visibility becomes set, and mirrors the `active` byte onto the live entity state at `+0x52`.
   - `queue_named_npc_death_or_monsterfy_transition` at `0x7c940` is the name-based `KILL_NPC` / `MONSTERFY` helper.
     - It resolves the live NPC render entity when present, translates `BLUDGE` / `SLASH` / `PROJ` into the pending damage-type values `1` / `2` / `4`, queues `queue_npc_death_or_monsterfy_transition`, and preserves the pending damage-type selection on the record when no live entity exists yet.
+    - With a live class-4 actor present it does not immediately suppress the `NpcRecord` or dispatch `on_death_action_tag`; it only latches `death_damage_type` and desired state `0x35`, leaving `update_actor_runtime_state` to play the death bank first.
   - `queue_live_named_npc_death_or_monsterfy_transition` at `0x7ca90` is the live-entity-only wrapper used by the Beggar talk path.
     - It resolves the named live NPC render entity and forwards to `queue_npc_death_or_monsterfy_transition` only when that render entity exists, without touching the persisted `NpcRecord`.
   - `is_named_npc_death_type_clear` at `0x7c840` is the talk-handler predicate shared by `handle_talk_to_mom` and `handle_talk_to_dwayne`.
@@ -1017,6 +1019,7 @@
   - `parse_command_record` now confirms the room-event callers are `KILL_NPC` and `MONSTERFY`.
   - It is also shared by the close-range NPC hit path inside `update_actor_runtime_state`.
   - It clears live byte `+0x50`, preserves the current animation bank, and reseeds the current animation frame after forcing state `0x35`.
+  - It does not set `NpcRecord.death_or_monsterfy_flag` on its own; that record-level suppression is deferred until the class-4 death bank reaches its last frame inside `update_actor_runtime_state`.
 - `teardown_monster_entity_runtime_state` at `0x541c0` is the monster-specific wrapper over the shared runtime-entity teardown path.
   - `destroy_entity_list` and `dispatch_room_event_actions` use it only on class-6 monster removal flows.
 - `tick_monster_entity_runtime` at `0x54140` is the class-6 monster wrapper around `update_actor_runtime_state`.
@@ -1090,6 +1093,10 @@
     - bit `0x01` is the third hit-reaction gate used by states `0x1e/0x21`
     - bits `0x02`, `0x04`, and `0x08` gate the death-state families `0x28/0x29/0x2e/0x2f`, `0x2a/0x2b/0x30/0x31`, and `0x2c/0x2d/0x32/0x33`
   - Once `current_hit_points < 1`, native class-6 monsters do not disappear immediately. `update_actor_runtime_state` pushes them into facing-specific death states `0x28/0x29`, `0x2a/0x2b`, or `0x2c/0x2d` according to gore/damage-type availability, then leaves desired state `0x38` latched until the last death frame dispatches the monster record's `on_death_action_tag`.
+  - The same state machine also owns the terminal class-4 NPC death flow after desired state `0x35` has been queued.
+    - If the NPC has a monsterfy target, or if gore is enabled and the latched damage type is not `BLUDGE`, it picks death bank `0x3c..0x45`; otherwise it picks `0x46..0x4f`.
+    - The NPC remains live and untalkable during that bank because `death_damage_type` has already been latched while `death_or_monsterfy_flag` is still clear.
+    - Only after the last class-4 death frame does it set `NpcRecord.death_or_monsterfy_flag`, optionally activate the replacement monster, and dispatch the NPC record's `on_death_action_tag`.
   - Runtime byte `+0x11b4` is now bounded for the player-combat path:
     - `parse_command_record` maps room-event commands `PAUSE_PC` / `RESUME_PC` to action cases `0x25` / `0x26`, and those cases set and clear `g_player + 0x11b4`
     - `update_actor_runtime_state` consults that byte only for class-5 player combat-avatar updates and returns early while it is set
diff --git a/TRACKER.md b/TRACKER.md
index 9a2b9bb0199..5aa5db4e251 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,322 +10,8 @@
 
 ## Last Confirmed Action
 
-- On March 31, 2026, re-audited `advance_entity_animation_frame` in live `HARVEST.LE` after noticing the hardcoded `BIG_EYE` name check in Ghidra. Native `spawn_npc_entity_from_record` sets room NPC entities to class `4`, and the recovered `advance_entity_animation_frame` at `0x4c8a0` only reaches the `strcmp("MOUSE")` / `strcmp("BIG_EYE")` horizontal-clamp exception block when `entity_class != 1 && entity_class != 4`, so the `BIG_EYE` compare is not part of the normal room-NPC path. The actual source drift was the non-looping frame-boundary rule: native clears `animation_enabled` only for class-`1` animation entities when a non-looping bank hits its endpoint, while ScummVM had been disabling animation for every non-looping entity. Patched [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp) so [`RuntimeEntity::advanceAnimationFrame`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp#L607) now clears `_animationEnabled` only for [`kRuntimeEntityClassAnimation`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h#L40), matching the native class gate. Verified with `rtk make -j4 engines/harvester/runtime_entity.o` and `rtk git diff --check`.
-- On March 30, 2026, checked Harvester's in-room save exit path against `HARVEST.LE` in Ghidra after a live `BOWLS1` save still returned to the pause menu. Native `run_main_menu` calls `run_save_game_menu()`, then exits the ESC menu and ramps the live room palette back in instead of leaving the player in the menu. Patched [`engines/harvester/menu.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/menu.cpp) and [`engines/harvester/menu.h`](/Users/alex/Workspace/scummvm/engines/harvester/menu.h) so a successful room save now closes the room menu, fades the save screen to black, restores the captured room backdrop, and fades back to the room view. Verified by rebuilding [`menu.o`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/engines/harvester/menu.o) and relinking [`libharvester.a`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/engines/harvester/libharvester.a).
-- On March 30, 2026, rechecked Harvester's room-combat save-restore path after a fresh `BOWLS4` validation still showed `DOORKNOB2` materializing as `hp=95/14 damage=2 engage=95`. The runtime log proved the room script parse was already correct and only the restored runtime monster HP was stale. Source inspection in [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp) showed why: `reloadTownWorld()` preserves only `currentHitPoints` from the loaded runtime monster state, while `engageDistance`, `initialHitPoints`, and `damageAmount` come back from the freshly reparsed script. That means a legacy save with the old swapped monster combat fields turns into the exact mixed state seen in the log. Patched [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp) so load-time monster migration no longer depends only on `saveVersion < 15`; it now also detects any runtime monster whose serialized `initialHitPoints`, `damageAmount`, or `engageDistance` do not match the current script base record and recalculates `currentHitPoints` from the legacy damage taken before the disc reload/preserve pass.
-- On March 30, 2026, re-audited native class-6 melee re-entry in live `HARVEST.LE` after a fresh `BOWLS4` log showed the new short-bank/rate-4 patch was still chaining hits too quickly. Ghidra confirmed `update_actor_runtime_state` at `0x4d750` still uses the short monster-only attack banks `0x8c..0x94` (right-facing) and `0x95..0x9d` (left-facing) at attack rate `4`, with the default contact offset still coming from `+0x113c = 2` (`MUCKEY` remains the known `7`-frame exception). The missed native gate is the monster-local timestamp at `entity+0x1144`: `spawn_monster_entity_base` seeds it from `DAT_000c7e44` on spawn, melee entry refreshes it to `DAT_000c7e44 + (next_random_value() % 100)`, and attack startup is blocked until that stored tick is at least `0x32` centiseconds old unless the live center-to-center X delta is already inside `engage_distance / 3`. `DAT_000c7e44` itself is written from `get_elapsed_milliseconds()` in `run_harvester_main_loop`, and that helper is the same centisecond DOS clock wrapper already mirrored by ScummVM's `Player::getRuntimeClockTicks()`. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) to restore that native cooldown gate, seed the initial post-spawn delay, keep the confirmed short attack banks and rate `4`, and use the native close-range bypass check based on live center delta instead of the broader overlap helper.
-- On March 30, 2026, patched ScummVM's Harvester room-combat gate to match the confirmed native class-6 melee-entry split. [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) now keeps the broader overlap/edge-gap helper only for room-combat reach checks such as player target selection, while monster attack startup uses a dedicated native gate that requires base Z within `+/- 2.0` and live center-to-center X within `engage_distance`. This removes the prior local behavior where monster melee could start as soon as the chase waypoint slack (`50.0 * depth_scale`) or simple sprite overlap reported "close enough", even though native reserves that wider band for chase-waypoint satisfaction.
-- On March 30, 2026, rechecked the native class-6 close-range attack gate in live `HARVEST.LE` against ScummVM's room-combat helper. Ghidra confirmed `update_actor_runtime_state` only arms monster melee after base Z is within `g_player_combat_avatar->z +/- 2.0` and the live center-to-center X delta is inside `engage_distance`; the `50.0 * depth_scale` band belongs to horizontal waypoint satisfaction while walking toward the player, not to the final attack-range check. That means the current ScummVM helper `areCombatantsWithinNativeCloseRange` is still broader than native because it accepts simple overlap or edge-gap within `max(engageDistance, 50 * depth_scale)`, so the remaining likely pacing/aggression mismatch is attack-entry geometry rather than raw movement cadence.
-- On March 30, 2026, audited native Harvester movement step size and chase geometry in live `HARVEST.LE`. Ghidra confirmed the shared actor state machine uses `depth_scale * 8.0` for left/right walk, `depth_scale * 16.0` for the short reverse-step families, a class-6 monster pursue band of `player.z +/- 2.0`, and horizontal chase waypoint slack of `depth_scale * 50.0`. `parse_room_record` also hardcodes `RoomRecord.z_velocity_step = 1.0`, so the native vertical locomotion increment is not room-script data-driven. The current ScummVM room loop already matches the native `8 / 1 / 50 / 2` movement constants, and this audit did not find a confirmed extra-position-step path that would explain an across-the-board `~2x` movement speedup.
-- On March 30, 2026, rechecked native Harvester timing semantics in live `HARVEST.LE` against the ScummVM Harvester room-combat loop. Ghidra confirmed `get_elapsed_milliseconds` at `0x82102` is a centisecond DOS clock wrapper over `int 21h AH=2Ch`, `set_entity_animation_rate` stores `100 / rate` into the entity tick interval, and both `update_player_combat_avatar_state` and `tick_monster_entity_runtime` only fall through into `update_actor_runtime_state` after `animation_advanced_this_tick` is set. That confirms native player walk, room-NPC animation, and monster locomotion are all cadence-locked to the shared animation timer, so the reported `~2x` speed issue is not currently supported by timing semantics alone.
-- On March 30, 2026, rechecked Harvester's native `MONSTER` record layout in live `HARVEST.LE` after the user-reported over-tuned room combat. Ghidra confirmed `MonsterRecord` stores the leading combat numbers as `engage_distance` at `+0x0c`, `initial_hit_points` at `+0x10`, `current_hit_points` at `+0x14`, and `damage_amount` at `+0x18`; `spawn_monster_entity_from_record` copies that `damage_amount` field directly into live actor `+0x1180`, and `update_actor_runtime_state` subtracts it on a confirmed hit with no extra player-side mitigation. The decoded shipped `HARVEST.SCR` line for `DOORKNOB2` (`379 376 12 95 14 2 MONSTER ...`) therefore means `engage=95`, `hp=14`, and `damage=2`, proving ScummVM had swapped those columns locally. Patched the Harvester script parser to match the native numeric order, added a version-15 save migration for legacy runtime monster HP/damage/engage values, and updated the local format notes.
+- On March 31, 2026, confirmed via Ghidra that native class-`4` room NPCs queue a live death state first, play a death bank (`0x3c..0x45` for gore/monsterfy or `0x46..0x4f` for bludge), and only then finalize suppression and run the NPC `onDeathActionTag`. Patched ScummVM so BIG_EYE and other class-`4` NPCs now use that queued death flow, and gated room-NPC talk hover/dialogue on both `deathDamageType == 0` and an installed dialogue handler. Verified with `make -j4 engines/harvester/flow.o engines/harvester/room.o engines/harvester/dialogue.o engines/harvester/script.o` and `git diff --check`.
 
 ## Next Suggested Action
 
-- Immediate: rerun the in-engine `EYEHALL` repro with `combat` logging enabled after the class-1-only non-looping animation fix. Confirm the eye-hit sequence still logs `SET_REGION EYE_MAIN`, `DELETE EYEHALL BIGEYE`, and `roomRegions=2`, and watch whether any room NPC or other class-4 actor now behaves differently at non-looping bank endpoints. If the live behavior is unchanged apart from endpoint bookkeeping, the next useful Ghidra comparison is the native room-NPC state-transition path that selects the `0x34` passive bank versus the death/reaction banks above `0x3b`.
-- Immediate: run an in-engine Harvester save validation from an active room such as `BOWLS1`: open the ESC menu, save into a slot, and confirm the screen now fades out of `SAVEGAME.BM`, returns directly to the live room instead of the pause menu, and leaves subsequent ESC re-entry, save cancel, and load-from-room behavior intact.
-- Immediate: reload the same legacy `BOWLS4` save and confirm the early runtime logs now show `loaded runtime monster 'DOORKNOB2' ... hp=14/14` and `materialized room monster ... hp=14/14 damage=2 engage=95` before any combat begins. If that passes, the remaining `DOORKNOB2` difficulty gap is raw native data plus melee cadence rather than stale save-state corruption.
-- Immediate: rerun in-engine combat in `BOWLS4` with `combat` logging enabled after the restored native melee cooldown gate. Confirm class-6 attack-start logs still stay inside the short native banks (`140..142`, `143..145`, or `146..148` for right-facing; `149..151`, `152..154`, or `155..157` for left-facing), still report `rate=4`, and now show materially wider spacing between attack starts unless the player is already inside the native `engage_distance / 3` close-bypass band. If the cadence now feels correct, capture one short note in the tracker and close out monster melee pacing.
-- Immediate: rerun one in-engine `BOWLS4` or `PCHOUSE` combat capture with `combat` logging enabled after the stricter monster attack-entry patch. Confirm monsters now keep walking until `abs(center_dx) <= engage_distance` after the `+/- 2.0` Z gate, and compare whether the perceived over-fast aggression drops without changing locomotion cadence.
-- Immediate: rerun in-engine combat in `BOWLS4` with `combat` logging enabled and at least one legacy save-load restore. Confirm `DOORKNOB2` now logs `hp=14/14 damage=2 engage=95`, verify existing pre-fix saves migrate to the same values after load, and then spot-check one second monster record with very different combat numbers so we know the parser fix generalizes beyond this room.
-- Immediate: rerun the in-engine Sergeant remains/lodge handoff and at least one save-load restore on disc 3 after the explicit restart-target and save-restore town-script reload patches. With room/resource/general debug logging enabled, confirm the new `implicit room restart target=` log reports `target='START' explicit='START'` after `C076.FST`, that both the handoff and save restore reload `CD3/HARVEST.SCR`, that the handoff lands in `RECEPTION` instead of the stale CD2 `MAP_2_SERGEANT` entrance, and that the save restore no longer warns `unresolved materialized room target entrance='START' room='RECEPTION'!`.
-- Immediate: run an in-engine Harvester audio validation pass on `FCMP`-backed music and dialogue after the startup warmup patch. Recheck at least one looping music start such as `SOUND/MUSIC/MENACE.CMP` or `SOUND/MUSIC/ANXIETY.CMP` plus a few voiced dialogue lines, confirm the clip-start pop is reduced, and watch for any new truncation of music attacks or first spoken phonemes. If a transient remains, compare it against the native split we confirmed in Ghidra: streamed music mutes the first `0x64` decoded bytes, while one-shot/dialogue `FCMP` playback skips the first `0x1f4` decoded bytes entirely.
-- Immediate: run an in-engine Harvester disc-swap validation pass after the current-disc-only resource remount change. With resource debug logging enabled, recheck both `CHANGE_TO_CD2_*` and `WARP_TO_LODGE_*`, confirm the previous disc's archives are unmounted before follow-on loads from the requested disc, verify loaded sound slots and streaming audio do not survive the swap, and confirm save-load restore on a non-default disc still reloads dialogue/text from the active disc instead of silently falling back to another mounted archive.
-- Immediate: run an in-engine Harvester validation pass around both scripted and dialogue-driven CD swaps after the startup disc-activation ordering patch. With `Show CD change prompts` enabled, recheck the overnight `CHANGE_TO_CD2_*` chain and the Sergeant `WARP_TO_LODGE_*` handoff, and confirm the prompt now switches discs before follow-on music/audio, room setup, or continuation work resumes. Pay particular attention to same-room post-prompt cases: verify the live room redraws with the target disc active, that stale loaded sounds from the previous disc do not survive the handoff, and that save-load restore on a non-default disc still lands in the correct room without needing an extra prompt.
-- Immediate: rerun the in-engine `DNA_2_DNALFT` entry with room/cursor logging enabled after the room-entry sequencing patch. Confirm `EDNA` no longer appears on the first visible DNALFT frame, that the room still fades in normally, and that follow-on entry side effects such as music, dialogue, cutscenes, and continuation tags still trigger in the same order after the fade.
-- Immediate: rerun the burned-diner `DNAEXT_X1` one-shot validation. The native `SET_REGION` opcode persists on the global region record, and the authored day-6 chain `DNAEXTEXIT1 -> HANG_ANIM_A -> C131.FST -> C043K.FST -> SET_DNA_EXT_OFF` ends by disabling `DNAEXT_X1`; confirm the live log now shows `roomRegions=3` immediately after `SET_DNA_EXT_OFF` and still `roomRegions=3` on the next fresh `MAP_2_DNA` entry, with no replay of `C043K.FST` unless a later script path explicitly re-enables that region.
-- Immediate: rerun the in-engine `DNAEXT` diner-door validation after the selected-item target remap. The authored `HARVEST.SCR` data uses `USEITEM "INV_TAPE" "DNAEXT" "DINERDOOR" "ADD_TAPE_DNA"` and the break-tool follow-up on `DINTAPGLS_HS`, while the live click logs had been resolving the cursor to `DNAEXT_QDOOR_HS`; confirm selected-item clicks now remap from the examine hotspot to the matching use-item target (`DINERDOOR` first, `DINTAPGLS_HS` after taping), that the new `inventory target remap` / `inventory target miss` logs behave as expected, and then recheck whether direct examine clicks still replay `DNAEXT_QDOOR_HS_COM -> START_DIALOG EDNA -> C043K.FST` more often than native.
-- Immediate: inspect the orphan cheat-handler clone that starts somewhere in the `0x742xx..0x74bxx` range. The live path is already confirmed inline inside `run_harvester_main_loop`, and `FUN_00078920` has no callers, so the next useful question is whether the second clone is another dead duplicate, an alternate build path, or code that should be merged back into a recovered function boundary.
-- Immediate: rerun the in-engine Sergeant remains/lodge handoff after the room-object semantics fix. Recreate the `GOT_REMAINS_FOR_LODGE` branch so `0x4333 -> C150.FST -> 0x433f..0x4385 -> C076.FST -> CHANGE_CD 3` lands in `SERGEANTRM`, confirm the room now materializes its authored blocker geometry correctly, and verify that the post-CD room still exposes only the scripted `LOD_X1 -> EXIT_LODGE` transition. When you click that region, confirm the native-authored follow-up is the town-map selector entry `LODGE_2_MAP` rather than any hidden deeper-lodge auto-transition. If the expected deeper lodge entry is still absent, treat that as a script/data expectation mismatch rather than a remaining Sergeant CD-prompt bug.
-- Immediate: run an in-engine Harvester validation pass focused on the `WOLFIE` crypt kill after the newly confirmed native parity check. Keep `combat` logging enabled, kill `WOLFIE`, and confirm the death bank still plays through to completion before the monster disappears, that `WOLFIE_DEATHCOM` only opens the crypt hotspots without leaving a clickable or visible monster residue, and that no stale monster entity survives in the live room after the `CRYPT` state refresh.
-- Immediate: run an in-engine Harvester combat validation pass with one low-band melee loadout and one high-band weapon while `combat` logging is enabled. Recheck `FIREAXE` in `CRYPT` to confirm the new logs show the native `loadout=17 weapon='FIREAXE' damage=3`, then switch to a higher-damage weapon such as `SHOTGUN` (`loadout=3 damage=7`) or `CHAINSAW` (`loadout=14 damage=8`) and confirm the contact log changes accordingly. During the same pass, verify `WOLFIE` continues to report its seeded per-hit monster damage in the spawn and attack logs so we can tell whether the remaining difficulty complaint is native data/cadence rather than duplicate hit application.
-- Immediate: rerun the in-engine `MBLM` glue validation pass after the stale-reference crash fix. With Moynahan alive (`MOYNAHAN_IS_DEAD=0`), click the room `GLUE` hotspot and confirm `MBLMGLUE_COM -> START_DIALOG MOYNAHAN_MBLM` still lands in the restored Moynahan handler, the native refusal lines `0x3e4d/0x3e52/0x3e56 -> 0x3e76/0x3e7a` play, the denied pickup still rolls back out of carry/inventory state, and dismissing the dialogue no longer trips the main-thread assert in `Common::String::compareToIgnoreCase` from the post-dialogue rollback check. Then validate both confirmed native acquisition paths still work: present `CASKET_PHOTO` / `CASKET_PHOTOCOPY` to Moynahan and take the glue-granting response at `dialog.rsp[0x18d]`, and on day 5 follow the `0x1bf..0x1c2` glue topic through response menu `0x1c3` to confirm `GLUE` plus `JUST_GOT_GLUE`. Finally, recheck the direct room-click pickup with `MOYNAHAN_IS_DEAD=1` to confirm the script-side `ADD_GLUE` branch still bypasses dialogue and awards the object cleanly.
-- Immediate: run an in-engine Harvester validation pass around the day-5 overnight CD handoff and the hidden warp-to-CD3 path. With launcher option `Show CD change prompts` enabled, trigger `CHANGE_TO_CD2_A -> CHANGE_TO_CD2_B -> CHANGE_TO_CD2_C` and confirm the native `CD2.BM` prompt appears after `OVERNITE.FST`, waits for a fresh left-click press/release cycle, and then continues into `PC_ROOM_HOUSE_START`; also trigger `WARP_TO_LODGE_A -> WARP_TO_LODGE_B` and confirm the `CD3.BM` prompt appears after `C076.FST`. Repeat the same paths with the option disabled and confirm the prompt is skipped while the room handoff still completes.
-- Immediate: run an in-engine Harvester validation pass that covers both the startup title menu and the post-death `GAMEOVER.BM` overlay. Confirm the menu now preserves the native six-row layout with `main_menu_2` (`SAVE GAME`) blanked when no live room/session state exists, `main_menu_3` (`LOAD GAME`) still visible and functional from both entry points, and the `OPTIONS` / `HELP` / `QUIT GAME` rows retaining their original vertical spacing instead of collapsing upward.
-- Immediate: run an in-engine Harvester validation pass in `PCHOUSE` against Jimmy's `MONSTERFY` combat branch. Confirm the monster still enters combat at the same native locomotion rate, keeping the room loop redraw-only behavior during chase/attack; confirm an open inventory no longer reopens every item bitmap from disk when HP changes or status items refresh; and confirm a lethal Jimmy hit now plays the recovered player death bank before the room loop exits through the native `GAMEOVER.BM` / `GAMEOVER.PAL` + `ANXIETY.CMP` backdrop path instead of jumping directly out on `HP=0`.
-- Stay in live Ghidra on `call_hmi_driver_callback_op0` and its caller frame inside `initialize_sound_bank_resources`. The upstream source record is now labeled as `g_sound_bank_setup_source_record`, the callback-capture block at `0xca2d8` is typed as `g_sound_selected_hardware_config`, and the first three fields there are confirmed as `device_port`, `device_irq`, and `device_dma`. The remaining useful confirmation is whether the mixed far-pointer / stack-cleanup oddity in `call_hmi_driver_callback_op0` can be modeled well enough to expose what, if anything, the unresolved source dwords at `g_sound_bank_setup_source_record + 0x14` and `+ 0x18` contribute to the callback.
-- Before trusting `ClearTalkHandlerDialogueOverrides.java` as a full replacement for `InspectCallTermination.java`, keep validating it against single known-dirty handlers and immediately follow with `InspectCallTermination.java verify`. `CLOAK_ATND` showed the hardened bulk-clear script can safely remove a stale `CALL_RETURN`, but it also showed that already-cleared fallthroughs with `nextInst=null` may still need the explicit `InspectCallTermination.java repair` logic afterward. The next useful confirmation is another one-gap handler of the same shape, or a script update that directly repairs `missing_next_instruction` fallthroughs after the override is already clear.
-- Before using the bulk dialogue cleanup path in the live database again, validate the now-hardened `/Users/alex/ghidra_scripts/ClearTalkHandlerDialogueOverrides.java` on a disposable copy or a single known-dirty talk handler via the new NPC filter. The script still clears each stale `CALL_RETURN` override and hands the fallthrough address to `ClearFlowAndRepairCmd`, but it now aborts on the first repair failure and rolls the whole transaction back on cancel instead of committing a partial sweep. The next useful confirmation is whether a single-handler run such as `MOM` or `MR_POTTS` repairs cleanly and durably with the broader Ghidra repair path.
-
-## Last Confirmed Action
-
-- On March 29, 2026, audited `HARVEST.LE`'s `FCMP` warmup behavior in live Ghidra after investigating startup audio pops. Confirmed `start_music_stream` (`0x1bfc0`) decodes the first `FCMP` chunk and then zeroes the first `0x64` decoded bytes before queueing, while both `load_sound_sample` (`0x18470`) and `load_dialogue_voice_sample` (`0x191d0`) queue from `decoded_pcm + 0x1f4`, discarding the first `500` decoded bytes of one-shot/dialogue `FCMP` playback. Patched [`engines/harvester/media_manager.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/media_manager.cpp) to mirror that native split for startup music versus startup speech/other `FCMP` audio, updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) and [`TRACKER.md`](/Users/alex/Workspace/scummvm/TRACKER.md), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
-- On March 29, 2026, changed the Harvester startup resource path to enforce current-disc-only archive semantics during `CHANGE_CD` and save-load disc activation. Patched [`engines/harvester/resources.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/resources.cpp) and [`engines/harvester/resources.h`](/Users/alex/Workspace/scummvm/engines/harvester/resources.h) so startup now mounts only the active disc, `setCurrentDisc()` unmounts every other disc after the target disc is available, and archive-backed lookups no longer fall back across non-active discs. Patched [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp) so `activateStartupDisc()` stops any archive-backed audio before the swap and reloads the cached text tables afterward, which keeps `DIALOGUE.IDX` / `DIALOG.RSP` from surviving accidentally across a disc change. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
-- On March 30, 2026, revisited the Harvester `prompt_for_cdrom_disc_change` path in the ScummVM port after re-reading the existing Ghidra notes for native `reload_town_world_from_script`. Patched [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp), [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so the `HARVEST.SCR` reload now stays out of generic `activateStartupDisc()` and instead fires only from the confirmed disc-3 prompt / `CHANGE_CD` control-flow sites after the active disc swap completes. The implicit same-room disc-3 follow-up still queues a full room restart instead of a shallow `refreshCurrentScene(true)` pass, keeping the visible room rebuild aligned with the native `prompt_for_cdrom_disc_change -> reload_town_world_from_script -> room_setup` shape while avoiding unconfirmed save-load/menu-side town-script reloads. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
-- On March 30, 2026, re-audited the Sergeant CD3 handoff after the disc-3 script reload started failing on stale `MAP_2_SERGEANT` resolution. Decoding `CD2/HARVEST.SCR` and `CD3/HARVEST.SCR` confirmed `MAP_2_SERGEANT` only exists on CD2 while CD3 instead exposes `ROOM "RECEPTION"` with `ENTRANCE "BACK" "RECEPTION" "START"`. Live Ghidra then showed the native Sergeant path still ends at `prompt_for_cdrom_disc_change(3, 0)` with no explicit room target change, but the main-loop disc-3 branch consumes `g_pending_disc3_room_restart` via `reload_town_world_from_script(); room_setup(g_room_setup_target_name_buffer);`. Because `g_room_setup_target_name_buffer` is initialized to `START` at boot and ordinary room transitions queue `g_pending_room_name` instead, the native post-prompt rebuild restarts through `START`, not the old Sergeant entrance, which explains the CD3 `RECEPTION` landing. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so both implicit disc-3 same-room restart paths now queue `START` instead of `scene.state.entranceName`, and corrected the matching note in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md). Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
-- On March 30, 2026, followed up on the still-stale `MAP_2_SERGEANT` warning after the first `START` restart patch and found the missing link in the queued dialogue interaction itself. The Sergeant remains branch in [`engines/harvester/npc/sergeant_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.cpp) was still queueing only `requestRoomRestart = true` after `showCdChangePrompt(3)`, so the later room-loop handler no longer knew it was supposed to mirror the native disc-3 startup-target restart and fell back to `scene.state.entranceName`. Patched [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/npc/sergeant_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) to carry an explicit `roomRestartTargetName`, seed it to `START` on the Sergeant CD3 path, and log the selected implicit restart target with disc/scene context in the room loop. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
-- On March 30, 2026, investigated a separate save-load failure on lodge saves after the Sergeant CD3 handoff was corrected. The load log showed disc 3 mounting and dialogue/text reloading, but the room restore then failed in `materializeRoomState()` with `unresolved materialized room target entrance='START' room='RECEPTION'!`, which meant the save-load path was still resolving the room against the pre-swap town script. Patched [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) so a restored disc switch now immediately calls `reloadTownWorld()` and logs `reloaded town script after save restore disc switch ...` before the pending room state is consumed. `rtk make -C build-vscode-harvester-debug -j4 scummvm` still needs to be rerun after this patch; progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
-- On March 28, 2026, traced `prompt_for_cdrom_disc_change` through `dispatch_room_event_actions`, `run_harvester_main_loop`, `room_setup`, `run_load_game_menu`, and the shipped `HARVEST.SCR` disc-swap chains. In live Ghidra, renamed `g_active_game_disc` (`0xc0fa8`), `g_disc3_mode_flag` (`0xd607c`), `g_pending_disc3_room_restart` (`0xc7e49`), `g_loading_saved_game` (`0xd607d`), and `g_room_setup_target_name_buffer` (`0xd5b80`), and updated `prompt_for_cdrom_disc_change` to `void prompt_for_cdrom_disc_change(uint requested_disc, char suppress_disc3_room_restart)`. The shipped script confirms the disc-3 Sergeant handoff is the only authored same-room implicit rebuild (`WARP_TO_LODGE_A -> WARP_TO_LODGE_B -> CHANGE_CD 3`), while the day-5 overnight disc-2 chains explicitly `CHANGE_ROOM` before `CHANGE_CD`; the extracted `DIALOGUE.IDX` files on discs 1, 2, and 3 are also byte-identical. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so the automatic same-room scene rebuild only happens after a successful disc-3 swap with no explicit room restart or other runtime mutation, updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) and [`TRACKER.md`](/Users/alex/Workspace/scummvm/TRACKER.md), saved `HARVEST.LE`, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
-- On March 28, 2026, revisited the Sergeant post-CD3 room-art question in live Ghidra, the shipped `HARVEST.SCR`, and the extracted disc-prefixed XFILE indices. Native `open_xfile_stream @ 0x1e530` and `xfile_read @ 0x1cde0` still distinguish archive-backed `<digit>:\...` paths from bare direct-CD paths, and the decoded `SERGEANTRM` records in `HARVEST.SCR` still author the room background, palette, mask, and most room art as `1:\...` archive-backed resources rather than as bare direct paths. Querying `1INDEX.001`, `2INDEX.001`, and `3INDEX.001` then showed there is no alternate Sergeant background on disc 3: discs 1 and 2 both contain `1:\GRAPHIC\ROOMS\NTRYHALL.BM`, `1:\GRAPHIC\PAL\NTRYHALL.PAL`, `1:\GRAPHIC\MASKS\NTRYDORS.BM`, `1:\GRAPHIC\ROOMANIM\LDGEYES.ABM`, `1:\GRAPHIC\ROOMANIM\LDGEDRIP.ABM`, and `1:\GRAPHIC\CHAR\SERGENT.ABM`, while disc 3 only contains `1:\GRAPHIC\ROOMANIM\LDGEDRIP.ABM` and `1:\GRAPHIC\CHAR\SERGENT.ABM` from that cluster. The current ScummVM source adds one plausible divergence on top: [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp) normalizes those script-authored `1:\...` fields to bare paths during parse, and [`engines/harvester/resources.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/resources.cpp) then lets bare archive lookups fall back across all mounted discs. Current conclusion: no alternate `BM` has been recovered for this handoff, and the strongest remaining engine-side question is whether stripping the archive-set prefix from script-authored resource paths is too aggressive. No code patch was applied from this audit; progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
-- On March 28, 2026, tightened ScummVM's Harvester CD-switch sequencing so startup interactions now activate the target disc before follow-on music/audio or room-side continuation work resumes. Patched [`engines/harvester/harvester.h`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.h), [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp), [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) to route all startup disc changes through a new engine helper that flushes transient media state when the active disc actually changes, to move room-exit and room-interaction CD prompts ahead of the same interaction's music/audio side effects, and to force a current-room scene rebuild when a CD swap completes but control stays in the same room. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`; tracker progress counts remain `927` total functions, `823` named/documented, and `104` remaining `FUN_*`.
-- On March 28, 2026, rechecked the Sergeant remains CD3 handoff directly against live `HARVEST.LE`, the shipped `HARVEST.SCR`, and the current ScummVM room/map resolver after the user questioned whether the post-prompt target should be a different room. Native `handle_talk_to_sergeant @ 0x30340` still ends the remains branch with `C150.FST`, lines `0x433f..0x4385`, `C076.FST`, and `prompt_for_cdrom_disc_change(3, 0)` with no explicit room target change. Live `prompt_for_cdrom_disc_change @ 0x488a0` still sets the disc-3 reload latch `0xc7e49` when called with second arg `0`, so the main loop rebuilds the current pending room target rather than jumping to a new one. Decoding the shipped `HARVEST.SCR` confirmed the authored chain `WARP_TO_LODGE -> WARP_TO_LODGE_A -> WARP_TO_LODGE_B` stops at `CHANGE_CD 3`, and the rebuilt `SERGEANTRM` only exposes region `LOD_X1 -> EXIT_LODGE -> EXIT_LODGE_2 -> CHANGE_ROOM "LODGE_2_MAP"`. The current ScummVM `Flow::resolveRoomTransitionTarget()` / `runTownMapSelector()` path confirms `LODGE_2_MAP` is a `MAP_ENTRANCE` town-map handoff, not a hidden deeper lodge room. No code patch was needed from this re-audit; progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
-- On March 28, 2026, re-audited the burned-diner `C043K.FST` replay path in live Ghidra and the ScummVM room loop. The native `dispatch_room_event_actions` `SET_REGION` case persists directly on the global region record, and the authored day-6 `DNAEXTEXIT1 -> HANG_ANIM_A -> GOFLIC C131.FST -> GOFLIC C043K.FST -> SET_DNA_EXT_OFF` chain disables `DNAEXT_X1` as part of that persistent runtime state, not as a transient scene-only toggle. The existing ScummVM in-place mutation path was already rebuilding `DNAEXT` to `roomRegions=3` after `SET_DNA_EXT_OFF`, so the remaining regression was the handoff back into stored runtime state on later revisits. Patched [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) to add `setRuntimeRegionEnabled()` and synchronize live room-region presence back into `_runtimeRegions` during room-state capture, so one-shot region disables such as `DNAEXT_X1` do not resurrect on the next room entry. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
-- On March 28, 2026, decoded the authored `HARVEST.SCR` door-use data to separate the remaining `DNAEXT` regression from the earlier room-entry timing bug. The script data confirms the initial tape path is authored on `DINERDOOR` (`USEITEM "INV_TAPE" "DNAEXT" "DINERDOOR" "ADD_TAPE_DNA"`), the taped break path is authored on `DINTAPGLS_HS` (`USEITEM "FIREAXE" "DNAEXT" "DINTAPGLS_HS" "DNA_BREAKF"` plus the other tools), and the generic examine hotspot `DNAEXT_QDOOR_HS` only owns `DNAEXT_QDOOR_HS_COM -> SET_FLAG DNA_S_SUICIDE_NOTE -> START_DIALOG EDNA`. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so selected inventory clicks now retarget to the topmost room object under the cursor that actually has a matching `USEITEM` record, and added explicit `inventory target remap` / `inventory target miss` debug logs instead of silently discarding those clicks when the hovered hotspot has no authored use-item action. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
-- On March 28, 2026, verified the native DNALFT room-entry draw ordering in live Ghidra to explain the one-frame `EDNA` flash. `room_setup` (`0x73540`) spawns room NPCs, executes `dispatch_room_event_actions(g_current_room_def->on_enter_tag)`, and only afterward calls `upload_palette_to_vga(g_current_palette_buffer, 0x100)` plus `flush_dirty_rects_to_screen`, so room-entry `SET_NPC "EDNA" "F" "F"` runs before the first visible room frame. The `SET_NPC` implementation in `dispatch_room_event_actions` case `0x15` and `set_npc_active_visible_state` (`0x7cab0`) writes the record's `active` / `visible` bytes and immediately removes the live current-room render entity when visibility clears. This confirms the flash is a ScummVM ordering artifact: [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) currently populates and draws the room before calling `executeRoomEnterCommands`, then refreshes only after an entry command reports `mutatedRuntimeState`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
-- On March 28, 2026, patched ScummVM's DNALFT-style room-entry timing to materialize entry-time runtime mutations before the first visible room frame. [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) now caches the initial `roomEntryInteraction`, applies `applyCurrentRoomRuntimeMutationsInPlace()` / `refreshCurrentScene(true)` before `waitForRoomSetupTransitionHold -> drawRoomScene -> fadeInRoomScene` when `executeRoomEnterCommands` reports `mutatedRuntimeState`, clears that one-shot mutation flag, and only then runs the remaining entry side effects through `handleInteractionResult`. This preserves the existing post-fade dialogue/cutscene handling while matching the native requirement that DNALFT's `SET_NPC "EDNA" "F" "F"` land before the first visible room frame. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts remain `927` total functions, `823` documented, and `104` remaining `FUN_*`.
-- On March 28, 2026, re-audited Harvester's native room enter/exit sequencing in live Ghidra after the March 26 room-entry FST regression. `room_setup` (`0x73540`) runs `dispatch_room_event_actions(g_current_room_def->on_exit_tag)` inline before switching away from the current room and runs `dispatch_room_event_actions(pRVar5->on_enter_tag)` only after the new room has been instantiated; clicked hotspot actions still go straight through the same dispatcher via `handle_target_interaction`, so the original executable does not precompute a deferred interaction result during room-state resolution. Patched [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so `resolveRoomSetupState*()` only materialize room state again, room-enter commands are executed once from the live room loop through the new `executeRoomEnterCommands()` path, and the March 26 early-mutation/deferred-replay hybrid is removed. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -C build-vscode-harvester-debug -j4 scummvm`. Progress counts now match the current live Ghidra database: `927` total functions, `823` documented, and `104` remaining `FUN_*`.
-- On March 27, 2026, traced `FUN_00078920` backward to identify its live entry path. Direct Ghidra queries still show no callers and no xrefs to `0x78920`, and a raw byte-pattern search found no hidden immediate reference to that entry address. The decisive evidence came from its data usage instead: the top of `FUN_00078920` matches the code at `0x6e7c0` inside `run_harvester_main_loop`, using the same keyboard-state globals (`0xd5908`, `0xd5b58`, `0xd61c8`, `0xd61cc`), the same debug/format string `cheat_index_1 = %i  cheat_index_2 = %i  key=%i` at `0xbe3e8`, the same item-string constants, and the same case-specific action tags (`FOUNTAIN_ART1`, `3RD_2_3RDNTRYR`, `L2_M1`, `WARP_TO_LODGE`). The same string xrefs also expose a third uncalled clone in the `0x742xx..0x74bxx` range. Current conclusion: `FUN_00078920` is not the live cheat entry point; the active implementation runs inline in `run_harvester_main_loop`, while `FUN_00078920` and the `0x742xx..0x74bxx` body appear to be orphaned duplicate copies. Progress counts remain `878` total functions, `823` named/documented, and `55` remaining `FUN_*`.
-- On March 26, 2026, audited the undefined code targets referenced from the dword table at `0x788fc`. Each entry in the table (`0x78a65`, `0x78ae2`, `0x78c2e`, `0x78de4`, `0x78e73`, `0x78f87`, `0x79098`, `0x7918e`) has a `COMPUTED_JUMP` xref from `0x792a8`, proving they are switch-case destinations rather than standalone function entries. The bytes immediately after the table form a real function prologue at `0x78920`, so I created and saved `FUN_00078920` with body `0x78920..0x792be`, and all eight former undefined targets now resolve inside that function. Applied the neutral table label `switch_table_00078920` at `0x788fc`. No semantic rename was made because the routine's behavior is still not confirmed.
-- On March 26, 2026, dumped and decoded the authored lodge-entry data after the user questioned why the post-Sergeant CD3 handoff was not entering a deeper lodge room. `HARVEST.SCR` confirms the data-driven path stops at `COMMAND "WARP_TO_LODGE_B" "CHANGE_CD" "3"` and then rebuilds `SERGEANTRM`; that room contains one hidden mask object, `LODGE_DOOR`, and exactly one region, `LOD_X1 -> EXIT_LODGE`, so there is no second script-authored lodge-room transition to trigger automatically after `C076.FST`. The same audit exposed a real ScummVM room-runtime gap instead: the loader was discarding unnamed `OBJECT` records entirely, which drops native blocker rectangles such as the four `SERGEANTRM` `{// BLOCKS }` entries, and the scene builder was also skipping hidden no-sprite action hotspots such as `PCHOUSE / PC_WINDOW` and `MR_JOHNS / MANHOLE`. Patched [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp), [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp), and [`engines/harvester/room_support.h`](/Users/alex/Workspace/scummvm/engines/harvester/room_support.h) so anonymous room objects receive stable internal ids, hidden no-sprite action hotspots remain materialized, and visible disabled hotspot rectangles now participate in room collision as blockers. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `877` total functions, `823` named/documented, and `54` remaining `FUN_*`.
-- On March 26, 2026, dug into native `prompt_for_cdrom_disc_change` after the user raised the missing lodge-transition suspicion. Live Ghidra confirmed the Sergeant remains tail is not just cosmetic: `prompt_for_cdrom_disc_change(3, 0)` sets the active-disc dword `0xc0fa8 = 3`, raises `0xd607c = 1`, and, because the second parameter is `0`, latches `0xc7e49 = 1`. `run_harvester_main_loop` checks that latch immediately after interactions; when it is set, the loop clears it, calls `reload_town_world_from_script`, and reruns `room_setup` on the pending room target. The scripted `CHANGE_CD` room-event opcode mirrors the same disc-3 reload path inline, but the ScummVM Sergeant port previously only showed `CD3.BM` and returned. Patched [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/npc/sergeant_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.cpp) so the Sergeant `C076.FST -> CD3` tail now queues a native-style current-room restart after the prompt, which causes the room loop to rebuild the live room instead of falling straight back into the stale pre-CD3 scene. Updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) with the confirmed prompt-side latch behavior. Progress counts remain `877` total functions, `823` named/documented, and `54` remaining `FUN_*`.
-- On March 26, 2026, confirmed the user-reported stale `CALL_RETURN` regression was real but belonged to Stephanie rather than Sergeant. The cited site `0x37b26` is inside `handle_talk_to_stephanie`, not `handle_talk_to_sergeant`, and a fresh `InspectCallTermination.java verify STEPHANIE` showed the handler had fallen back to the truncated `0x36710..0x36cd8` body with only `9` reachable dialogue calls and `9` stale `CALL_RETURN` gaps. Running `repair STEPHANIE` in the live `HARVEST.LE` session converged in two passes, expanding the handler back to `0x36710..0x37f5d` with `198` reachable dialogue calls and `gapCount=0`; an explicit `save_program` plus fresh `verify STEPHANIE` confirmed the repaired state persists on disk. A raw assembly spot-check now shows the `0x37b26` `CALL 0x0007a690` falling through normally to `0x37b2b` inside the rebuilt Stephanie handler. Live progress now stands at `877` total functions, `823` named/documented, and `54` remaining `FUN_*`.
-- On March 26, 2026, reran the live `InspectCallTermination.java verify -> repair -> verify` sequence on `SERGEANT` in `HARVEST.LE`. The first `verify SERGEANT` already showed `handle_talk_to_sergeant @ 0x30340` clean with body `0x30340..0x30fd2` (`3219` bytes), `70` reachable dialogue calls, both `play_dialogue_line` entry points returning, and `gapCount=0`. `repair SERGEANT` immediately reported `No remaining repair issues` with `repairActions=0`, and the final `verify SERGEANT` reproduced the same clean state, so no further handler repair was required. Live progress now stands at `873` total functions, `819` named/documented, and `54` remaining `FUN_*`.
-- On March 26, 2026, rechecked `handle_talk_to_sergeant` against live `HARVEST.LE` after the user-reported lodge-entry regression. `InspectCallTermination.java verify SERGEANT` showed the saved handler had regressed to a truncated `0x30340..0x30fca` body with only `12` reachable dialogue calls and `12` stale `CALL_RETURN` gaps; a filtered `/Users/alex/ghidra_scripts/ClearTalkHandlerDialogueOverrides.java SERGEANT` pass cleared all `12` stale overrides, `InspectCallTermination.java repair SERGEANT` finished the remaining three fallthrough repairs, and a saved re-verify restored the full clean body `0x30340..0x30fd2` with `70` dialogue calls and `gapCount=0`. The cleaned native body confirmed there is no extra lodge-opening flag/object mutation in the Sergeant handler; the concrete engine drifts were narrower: ScummVM had misattributed line `0x434f` to `SERGEANT` instead of `PC`, and the remains branch was missing the native post-`C076.FST` black-screen plus `prompt_for_cdrom_disc_change(3, 0)` tail. Patched [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), [`engines/harvester/npc/dialogue_runtime.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dialogue_runtime.h), and [`engines/harvester/npc/sergeant_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.cpp) to mirror that native behavior and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
-- On March 26, 2026, rechecked Harvester's native `WOLFIE`-style room-monster death completion in live `HARVEST.LE` to answer whether post-animation disappearance is original behavior. Ghidra confirmed that native class-6 monsters do not vanish on the lethal hit itself: `update_actor_runtime_state` first pushes them through the facing-specific death families `0x28..0x33`, then waits for the last death frame before entering terminal state `0x38` and dispatching the monster record's `on_death_action_tag`. The same live pass also reconfirmed that `room_setup` only respawns monsters whose records still satisfy `(active != 0 || visible != 0) && runtime_spawned == 0`, so a monster that has completed its death/remove path is not rebuilt into the room on the next same-room setup pass. That makes the current ScummVM behavior of letting `WOLFIE` finish the death bank and then disappear from the crypt a native-correct result rather than a `debug_combat`-only bug. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
-- On March 26, 2026, rechecked Harvester's native combat damage seeding in live `HARVEST.LE` before changing the ScummVM formulas. Ghidra confirmed that `spawn_monster_entity_from_record` copies the monster record's `damage_amount` straight into live actor field `+0x1180`, and that `update_actor_runtime_state` subtracts that field directly on a confirmed hit with no extra post-hit multiplier. The same pass over `set_player_combat_loadout` also exposed a real player-weapon tuning bug in ScummVM: the native table uses `CHAINSAW -> loadout 14 -> damage 8` and `HARVEST_BLADE -> loadout 15 -> damage 3`, but the engine had those two loadouts swapped. Patched [`engines/harvester/player.h`](/Users/alex/Workspace/scummvm/engines/harvester/player.h), [`engines/harvester/player.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/player.cpp), [`engines/harvester/inventory.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/inventory.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) so the loadout table now matches native, and combat logs now print the resolved weapon/loadout label plus the native per-hit damage values during inventory toggles and player/monster hit resolution. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
-- On March 26, 2026, traced the `MBLM` glue assert to a stale `StartupObjectRecord` reference in the room pickup loop, not to `Common::String` itself. The post-dialogue carry rollback added in [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) was still reading `object.objectName` and `object.actionTag` after `handleInteractionResult()`, but that interaction can rebuild the room scene and invalidate the clicked-object reference. Patched both room pickup branches to use the pre-saved object copy for all post-interaction logging and rollback checks, including the `isObjectInInventory()` probe that had been asserting in `Common::String::compareToIgnoreCase`. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
-- On March 26, 2026, traced the remaining `MBLM` glue regression to the room-item carry path rather than the Moynahan dialogue handler. The alias fix was already working: live logs showed `MBLMGLUE_COM` reaching `MOYNAHAN_MBLM` and playing the native refusal lines `0x3e4d` / `0x3e52` / `0x3e56` / `0x3e76` / `0x3e7a`, but [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) was still leaving the denied `GLUE` as a carried room item because the carry-start rollback only handled modal-text denials. Patched the carried-pickup branch so, after a pickup action opens dialogue, it restores the original room object and clears the transient carry state unless that dialogue path explicitly moved the object into inventory; rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
-- On March 26, 2026, traced the `MBLM` glue regression to an alias mismatch between the room script and the restored Moynahan dialogue handler. The native room action `MBLMGLUE_COM` already dispatches `START_DIALOG MOYNAHAN_MBLM` whenever `MOYNAHAN_IS_DEAD` is clear, but [`engines/harvester/npc/moynahan_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/moynahan_dialogue.cpp) was still matching only `MOYNAHAN`, so live room clicks fell through to `unsupported room NPC dialogue handler 'MOYNAHAN_MBLM'` instead of entering the confirmed `IF_TRY_TO_TAKE_THE_GLUE` interruption path. Patched the handler to accept both speaker ids and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
-- On March 26, 2026, rechecked Harvester's native CD-swap script path in live `HARVEST.LE` before patching the ScummVM port. Ghidra confirmed that `parse_command_record` maps `CHANGE_CD` to opcode `0x23`, that `dispatch_room_event_actions` keeps executing through `CHANGE_ROOM` and only then calls `prompt_for_cdrom_disc_change`, and that `prompt_for_cdrom_disc_change` formats `1:\graphic\other\CD%i.BM`, loads `1:\graphic\pal\cd1.pal`, and waits for a fresh left-click press/release cycle before continuing. The decoded `HARVEST.SCR` chain around the day-5 handoff also matched that native control flow exactly: `CHANGE_TO_CD2_A` plays `GRAPHIC\FST\OVERNITE.FST`, `CHANGE_TO_CD2_B` queues `CHANGE_ROOM PC_ROOM_HOUSE_START`, and `CHANGE_TO_CD2_C` issues `CHANGE_CD 2`; the only other transition continuations in the shipped script are the `WARP_TO_CD2_*`, `STLEXIT2_*`, and `TO_CRYPT_TOP` tails. Patched [`engines/harvester/metaengine.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/metaengine.cpp), [`engines/harvester/detection.h`](/Users/alex/Workspace/scummvm/engines/harvester/detection.h), [`engines/harvester/detection_tables.h`](/Users/alex/Workspace/scummvm/engines/harvester/detection_tables.h), [`engines/harvester/harvester.h`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.h), [`engines/harvester/harvester.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/harvester.cpp), [`engines/harvester/script.h`](/Users/alex/Workspace/scummvm/engines/harvester/script.h), [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp) so Harvester now exposes a launcher `Show CD change prompts` option, preserves `CHANGE_CD` after `GOFLIC` / `CHANGE_ROOM` continuations, and renders the native CD prompt image gated behind a fresh left click when the option is enabled. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
-- On March 26, 2026, rechecked the native room-combat death path in live `HARVEST.LE` and corrected the ScummVM player defeat flow to match it more closely. Ghidra confirmed that `update_player_combat_avatar_state` does not hand control to `run_game_over_screen` on raw `HP <= 0`; it only returns true once the player combat avatar reaches terminal state `0x38`, after `update_actor_runtime_state` has already selected and played a concrete death-animation bank from the live combat state. The recovered death-bank selection block at `0x4e5a3..0x4e7ea` plus the state handlers at `0x5045a..0x50991` bounded the exact player ranges (`0xc4..0xff` for vertical deaths, `0xb0..0xc3` and `0x100..0x127` for horizontal deaths) and showed the death animation inherits the native hit-reaction cadence rather than the normal walk rate. Patched [`engines/harvester/player.h`](/Users/alex/Workspace/scummvm/engines/harvester/player.h), [`engines/harvester/player.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/player.cpp), [`engines/harvester/room_support.h`](/Users/alex/Workspace/scummvm/engines/harvester/room_support.h), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so lethal room-combat hits now start the recovered player death bank, suppress further region activation while that animation runs, and only queue the existing game-over return once the death sequence completes. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
-- On March 26, 2026, rechecked the native `run_main_menu` / `run_game_over_screen` path in live `HARVEST.LE` before touching the ScummVM menu layout. Ghidra confirmed that `run_game_over_screen` clears `g_game_session_active`, leaves `run_main_menu` responsible for the overlay, and that `run_main_menu` preserves all six `main_menu_*` slots while blanking `main_menu_2` (`SAVE GAME`) when no live room/session state exists instead of collapsing the list. Patched [`engines/harvester/menu.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/menu.cpp) so the ScummVM startup/game-over menu now mirrors that native policy: `SAVE GAME` is blanked in place when unavailable, `LOAD GAME` remains visible when loading is allowed, and the downstream rows keep the original spacing. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
-- On March 26, 2026, verified in live `HARVEST.LE` that native actor animation timing still uses `animation_tick_interval_ms = 100 / animation_rate`, with the room-combat locomotion path seeding `animation_rate = 0x11` inside `update_actor_runtime_state` (`0x4d750`), so the ScummVM combat-speed complaint was not evidence for changing the raw room-monster animation constant. Patched [`engines/harvester/inventory.h`](/Users/alex/Workspace/scummvm/engines/harvester/inventory.h), [`engines/harvester/inventory.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/inventory.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so inventory overlay refreshes now cache decoded bitmap resources by path instead of reopening every icon file on HP-driven refresh, and lethal room-combat monster hits now request the existing native game-over return path instead of leaving the player at `HP=0` inside the live room loop. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
-- On March 26, 2026, confirmed the native Harvester `GODEATHFLIC` path in live `HARVEST.LE` and patched the ScummVM port to match it instead of dropping straight into the generic main-menu stub. Ghidra showed `dispatch_room_event_actions` case `0x1c` calling `play_fst_sequence(arg1, 0)` and then `run_game_over_screen` (`0x7c540`), and `run_game_over_screen` in turn loading `1:\GRAPHIC\OTHER\GAMEOVER.BM` plus `1:\GRAPHIC\PAL\GAMEOVER.PAL`, starting `SOUND\MUSIC\ANXIETY.CMP`, waiting for dismissal input, and only then entering `run_main_menu` over that backdrop. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), [`engines/harvester/flow.h`](/Users/alex/Workspace/scummvm/engines/harvester/flow.h), [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp), [`engines/harvester/menu.h`](/Users/alex/Workspace/scummvm/engines/harvester/menu.h), and [`engines/harvester/menu.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/menu.cpp) so death FSTs now request a dedicated game-over return, the menu system stages the native `GAMEOVER` bitmap/palette plus `ANXIETY.CMP`, and the post-death menu overlays onto that backdrop instead of the generic wait-screen panel. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
-- On March 26, 2026, completed the upstream HMI source-record follow-up on `g_sound_bank_setup_source_record` (`0xc2d2c`) and the callback-capture block at `0xca2d8`, reran `/Users/alex/ghidra_scripts/RefineSoundBankDataLayouts.java`, and saved the updated type information into `HARVEST.LE`. The pass improved the live disassembly without overclaiming new semantics: it relabeled the static source block as `g_sound_bank_setup_source_record`, typed `0xca2d8` as `g_sound_selected_hardware_config`, and confirmed that the shared low fields are `device_port`, `device_irq`, and `device_dma` because both the manual `SOUND.CFG` path and the HMIDET auto-detect path populate those exact slots before bank setup. The fourth staging dword remains only `callback_op1_dx_word`, because it is copied from the detect callback's `DX` result and no higher-level meaning is confirmed. The source-side dwords at `+0x14` and `+0x18` remain unresolved: in the shipped image they are zero-initialized, `+0x18` is still only observed as the archived `reserved_setup_dword`, and no independent producer or consumer was confirmed beyond the opaque `call_hmi_driver_callback_op0` path. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
-- On March 26, 2026, completed the consumer-side HMI bank follow-up on `g_sound_bank_config_snapshots` and `g_sound_bank_hmi_callback_records`, reran `/Users/alex/ghidra_scripts/RefineSoundBankDataLayouts.java`, and saved the updated type information into `HARVEST.LE`. The pass replaced `field_24_code` with `temp_buffer_fill_mode_code`, because the only observed consumer is the setup path that checks for mode `8` before filling the transient DOS buffer with either `0x80` bytes or zeroes. It replaced `field_11` with `requested_mix_rate_hz`, because the caller seeds the source record with `0x5622` (`22050`) immediately before bank setup and the bank setup path forwards that dword into `call_hmi_driver_callback_op2` as the only payload register. The former `field_19` remains `reserved_setup_dword`: it is copied into the snapshot, but the only observed callback wrapper on that path (`call_hmi_driver_callback_op0`) ignores the stack slot where it is passed, and no other direct consumers were found. The same pass also closes the slot-index question conservatively: the live flag-`0x80`, flag-`0x40`, and temp-buffer slabs are packed as five 6-byte entries each, and the only normal producer of a bank index is `initialize_sound_bank_resources`, which scans `idx = 0..4`; the looser `< 6` checks in lower helpers therefore do not justify a reserved sixth bank slot. Updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) with the refined field names and the slot-geometry note. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
-- On March 26, 2026, ran `InspectCallTermination.java repair authority` in the live `HARVEST.LE` database and confirmed the saved result. The pre-check showed `handle_talk_to_authority @ 0x2b730` in a short dirty state (`0x2b730..0x2b7b2`, `95` bytes) with `dialogueCalls=2`, `gapCount=2`, and stale `CALL_RETURN` gaps at `0x2b75a` and `0x2b7a9`, including one missing fallthrough instruction at `0x2b75f`. The repair completed in one pass, expanded the handler body to `131` bytes while preserving the same entry/end range, recovered one additional reachable dialogue call, and finished with `gapCount=0`, `status=clean`, and `repairActions=4`. An initial parallel save attempt hit the expected active-transaction lock while the script was still unwinding, but the immediate retry saved `HARVEST.LE` successfully, and a fresh `verify authority` still reports `dialogueCalls=3`, `gapCount=0`, and `status=clean`. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
-- On March 26, 2026, ran `InspectCallTermination.java repair CLOAK_ATND` in the live `HARVEST.LE` database after the filtered `ClearTalkHandlerDialogueOverrides.java` pass had removed the stale `CALL_RETURN` but left the fallthrough byte at `0x2dbca` undefined. The repair completed in a single pass on `handle_talk_to_cloak_atnd @ 0x2db80`: the pre-pass state was a 79-byte body with `dialogueCalls=1`, `gapCount=1`, and one remaining gap at `0x2dbc5`; the repair applied `2` actions, preserved the handler body bounds `0x2db80..0x2dbce`, and finished with `gapCount=0`, `status=clean`. Saved `HARVEST.LE` successfully and confirmed the clean state with a fresh `verify CLOAK_ATND`, which still reports `gapCount=0` and both dialogue entry points returning. This closes the residual `CLOAK_ATND` cleanup noted by the earlier filtered bulk-clear experiment and confirms that `InspectCallTermination.java` remains the reliable fallback for already-cleared fallthroughs whose next instruction is still undefined. Documentation counts remain `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
-- On March 26, 2026, completed the sound-bank data-layout pass behind `initialize_sound_bank_resources` in live Ghidra and saved it as the rerunnable helper script `/Users/alex/ghidra_scripts/RefineSoundBankDataLayouts.java`. The pass proved that the allocator loop scans five bank slots (`idx < 5`) even though several helper routines still accept indices up to `5`, promoted `FUN_00084a8b` to `allocate_sound_bank_temp_dos_buffer`, and replaced the key raw slabs with typed names: `g_sound_bank_flag80_primary_far_ptrs`, `g_sound_bank_flag80_secondary_far_ptrs`, `g_sound_bank_flag40_primary_far_ptrs`, `g_sound_bank_flag40_secondary_far_ptrs`, `g_sound_bank_temp_dos_buffer_far_ptrs`, `g_sound_bank_hmi_callback_records`, and `g_sound_bank_config_snapshots`. The new types `FarPtr48`, `FarPtrSlot48`, `HmiDriverCallbackRecord`, and `SoundBankConfigSnapshot` now surface the confirmed field geometry directly in decompilation: the callback record exposes `field_24_code`, `callback_flags`, and the fallback `flag40_record_id`, while the config snapshot preserves the copied temp-buffer size, the control-entry far pointer, the live flag-`0x80` / flag-`0x40` far-pointer slots, and the explicit flag-`0x40` override dword. Updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) with the corrected slot count and named slabs. Live documentation counts are now `871` total functions, `819` named/documented, and `52` remaining `FUN_*`.
-- On March 26, 2026, ran the hardened `/Users/alex/ghidra_scripts/ClearTalkHandlerDialogueOverrides.java` against the single handler filter `handle_talk_to_cloak_atnd` in the live `HARVEST.LE` database. The pre-check via `InspectCallTermination.java verify CLOAK_ATND` showed a 79-byte body at `0x2db80..0x2dbce` with one stale dialogue gap at `0x2dbc5` (`flowType=CALL_TERMINATOR`, `override=CALL_RETURN`, `fallthrough=null`, `next=0x2dbca`, `nextInst=XOR EAX,EAX`). The filtered cleanup run reported `CLEARED=1`, `REPAIRED=1`, and saved successfully after an initial parallel save attempt hit the expected active-transaction lock. A fresh post-save verify confirmed that the stale override is gone and the call is now `UNCONDITIONAL_CALL` with `override=NONE` and `fallthrough=0x2dbca`, but `nextInst` at `0x2dbca` is still undefined, so the handler remains `status=needs_repair` with `gapCount=1`. This confirms the hardened script is safe enough to clear and save a single handler, but it does not yet fully replace the explicit fallthrough repair logic in `InspectCallTermination.java`. Documentation counts remain `871` total functions, `818` named/documented, and `53` remaining `FUN_*`.
-- On March 26, 2026, hardened the Ghidra helper script `/Users/alex/ghidra_scripts/ClearTalkHandlerDialogueOverrides.java` after the first `ClearFlowAndRepairCmd` upgrade so it is safer to test against the talk-handler cleanup problem. The script now accepts an optional exact NPC filter (`MOM` or `handle_talk_to_mom`), still clears each instruction-level `CALL_RETURN` override before invoking `ClearFlowAndRepairCmd` at the fallthrough address, aborts immediately on the first repair failure instead of logging-and-continuing, and uses `monitor.checkCancelled()` so canceling the run rolls the entire transaction back instead of committing a partial batch. Also retained the corrected `ghidra.program.model.listing.FlowOverride` import and re-verified the edited script with a compile-only `javac` pass against the local Ghidra 12.0.3 jars. No live `HARVEST.LE` database changes were made, so documentation counts remain `871` total functions, `818` named/documented, and `53` remaining `FUN_*`.
-- On March 26, 2026, finished the parent-layer HMI pass around the newly named callback wrappers and promoted the two remaining orchestrators. `FUN_0008aa3a` is now `refresh_hmidrv386_flag80_record_from_callback`, because it is only called from the bank-setup path after the flag-`0x80` selector pair is loaded or restored and its only work is to validate that selector pair and refresh the live `0x6a`-byte callback record through `copy_hmi_driver_record_from_callback_op8`. `FUN_00083fb3` is now `initialize_sound_bank_resources`, because its only caller is `initialize_sound_driver_from_sound_cfg_or_autodetect`, its teardown pair is `release_sound_bank_resources`, and the function itself chooses a free bank slot, loads/reuses the HMIDRV386 flag-`0x80` and optional flag-`0x40` records, allocates the transient DOS buffer when requested, captures callback control-entry pointers, runs the callback init sequence, and copies the selected driver config into the per-slot runtime tables. Added matching plate comments in Ghidra, updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) with the confirmed per-bank setup note, and saved `HARVEST.LE`. Live documentation counts remain `871` total functions, `818` named/documented, and `53` remaining `FUN_*`.
-
-- On March 26, 2026, completed a conservative naming pass over the real-RAM HMI callback helper cluster at `0x87005..0x87439` and documented the resulting subsystem facts. The new Ghidra script `/Users/alex/ghidra_scripts/RefineHmiCallbackHelpers.java` renamed 14 wrappers and attached plate comments that preserve the exact observed opcode/register behavior instead of guessing higher-level semantics: the `0x6a`-byte record copiers are now `copy_hmi_driver_record_from_callback_op8[_raw]`; the callback op wrappers are now `call_hmi_driver_callback_op0`, `call_hmi_driver_callback_op1[_capture_result]`, `call_hmi_driver_callback_op2`, `call_hmi_driver_callback_op3_*`, `call_hmi_driver_callback_op4`, `call_hmi_driver_callback_op5`, `call_hmi_driver_callback_op10_capture_ptr_triplet`, and `chain_hmi_driver_callback_op11_into_op12`; and the direct interrupt helpers are now `query_windows_enhanced_mode_signature_raw` and `query_dpmi_service_0500_global`. The evidence came from the surrounding HMIDET/HMIDRV call graph: `initialize_hmidet386_context`, `find_first_detected_hmidet_driver_record`, `find_next_detected_hmidet_driver_record`, `load_hmidet_driver_record_by_device_id`, `initialize_hmidet_driver_from_selected_record`, `release_sound_bank_resources`, `FUN_00083fb3`, the `hmidet.386` / `hmidrv.386` strings, the `int 2F AX=1600` Windows-enhanced-mode probes, and the DPMI / `int 4B` setup-teardown path. Also updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) with the confirmed HMI bootstrap notes. Live documentation counts remain `871` total functions, `816` named/documented, and `55` remaining `FUN_*`.
-
-- On March 26, 2026, created and ran the new Ghidra helper script `CleanupImagePseudoFunctions.java` in `/Users/alex/ghidra_scripts`, then applied it to `HARVEST.LE` and saved the program. The script maps `.image::` pseudo-function entries into normal RAM with the confirmed delta `loaded = .image - 0x1c9b0`, classifies each target as a real function entry or a mid-function offset, and supports dry-run/report versus apply/delete cleanup. The dry run resolved all 43 candidates cleanly: 42 were exact duplicates of real RAM functions and 1 (`.image::00079b9d`) was a false function start inside `update_render_entity_screen_position` at `0x5d1ed`. The apply pass deleted all 43 `.image` pseudo-functions, and a follow-up symbol search confirmed there are no remaining `FUN_.image__*` functions in the database. Live documentation counts are now `871` total functions, `802` named/documented, and `69` remaining `FUN_*`.
-
-- On March 26, 2026, performed a focused live-Ghidra investigation of the stray `FUN_.image_*` symbols in `HARVEST.LE` and confirmed they are analysis artifacts in the separate `.image` address space, not a coherent subsystem and not trustworthy standalone functions. Every sampled `.image` symbol had a one-byte body, zero decoded instructions, no callers, and decompiled only as `halt_baddata()`, even though raw bytes at those addresses are valid x86 code. Cross-checks showed the bytes map back into normal RAM-space code at a stable delta of `loaded = .image - 0x1c9b0`: `.image::000a3789` and `.image::000a37c6` map to the real DPMI wrappers `lock_linear_region_via_dpmi` (`0x86dd9`) and `unlock_linear_region_via_dpmi` (`0x86e16`); `.image::000a7b5d` maps to the IRQ/PIT helper cluster around `program_irq0_pit_divisor` (`0x8b1ad`); `.image::0004c1ad` maps to the dialogue handler `FUN_0002f7fd`; `.image::0004ad64` maps to the dialogue handler `FUN_0002e3b4`; and `.image::00079b9d` maps into the middle of `update_render_entity_screen_position` (`0x5d1ed`), proving that some `.image` labels are false function starts. No rename or architecture update was justified from this pass. Live documentation counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-
-- On March 25, 2026, synced the Harvester engine source to the latest high-confidence live-Ghidra naming recovered from `update_actor_runtime_state` (`0x4d750`) and related actor-runtime helpers. Patched [`engines/harvester/monster.h`](/Users/alex/Workspace/scummvm/engines/harvester/monster.h), [`engines/harvester/room_support.h`](/Users/alex/Workspace/scummvm/engines/harvester/room_support.h), [`engines/harvester/player.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/player.cpp), [`engines/harvester/script.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/script.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so the engine now uses the same confirmed terms as the native database: monster spawn bounds are `screenMinXBound` / `screenMaxXBound` to match `g_entity_screen_min_x_bound` / `g_entity_screen_max_x_bound`; the hidden `"blood.abm"` combat helper is consistently described as a hit effect instead of a generic damage effect, matching `hit_effect_entity`; and Jimmy's melee audio timing field is `attackSoundPlaybackFrame`, reflecting that the native slot is a concrete playback/trigger frame rather than a loose state flag. Verified with a clean source sweep for stale identifiers and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4`. Live Ghidra documentation counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 25, 2026, performed a focused live-Ghidra naming pass on `update_actor_runtime_state` (`0x4d750`) in `HARVEST.LE` and repaired `RenderEntityRuntime` so the actor-runtime offsets used by the decompile now carry stable names instead of raw `field_0x...` accesses. The high-confidence evidence came from cross-checking `update_actor_runtime_state`, `advance_entity_animation_frame` (`0x4c8a0`), `apply_pending_entity_knockback` (`0x53770`), `spawn_npc_entity_from_record` (`0x4cd00`), and `spawn_monster_entity_from_record` (`0x53a10`): `+0x109c` is the live blocker pointer (`current_blocker_entity`); `+0x10a0/+0x10a4/+0x10a8` are the per-frame movement deltas (`frame_x_step`, `frame_y_step`, `frame_z_step`); `+0x1088` is the one-frame shove slot (`frame_knockback_x_step`); `+0x1198` is the frame-left screen bound (`frame_left_x`); `+0x11a0/+0x11a1` are the active/desired actor state bytes; `+0x11a8` is the hidden hit-effect runtime (`hit_effect_entity`); `+0x11ac/+0x11b0` are the remaining knockback amount and decay step; and `+0x1134..+0x1178` now expose the monster attack/hit/footstep/death sound slots plus their trigger frames. Also renamed globals `0x0c3c08` / `0x0c3c0c` to `g_entity_screen_min_x_bound` / `g_entity_screen_max_x_bound`, because `advance_entity_animation_frame` clamps actor screen movement against those values and monster spawn seeds them from `MonsterRecord.min_x_bound` / `max_x_bound`. Saved the Ghidra program after rebuilding `RenderEntityRuntime` as a fixed-size `replaceAtOffset` layout so the added names stayed at the correct offsets. Live documentation counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 25, 2026, rechecked Harvester's live combat cadence in Ghidra before touching source. `tick_monster_entity_runtime` (`0x54140`) and `update_player_combat_avatar_state` (`0x553a0`) both gate their handoff into `update_actor_runtime_state` (`0x4d750`) on the actor's animation-advanced flag, falling back to `tick_entity_visual_state` when no new frame has advanced; the same player wrapper also short-circuits the live combat path once `current_hit_points < 1`. Patched [`engines/harvester/runtime_entity.h`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h), [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp), [`engines/harvester/player.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/player.cpp), and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so room movement now keys off the runtime entity's last animation advance once walk animations are active, monster chase updates stop over-driving between frames, and the room loop clears live movement/attack input when Jimmy's HP reaches `0`. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live Ghidra documentation counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 25, 2026, deep-dived the native combat runtime around `spawn_npc_entity_from_record` (`0x4cd00`), `spawn_monster_entity_base` (`0x4d140`), and `update_actor_runtime_state` (`0x4d750`) in live `HARVEST.LE`. The new disassembly comments at `0x4cd45`, `0x4d2e7`, and `0x52cba` record the now-confirmed hit-feedback path: both NPC and monster constructors allocate a hidden `"blood.abm"` helper into live actor field `+0x11a8` with animation rate `10`, and the close-range hit-resolution block later forces that helper visible at frame `0` over the struck actor. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so player hits on monsterfying NPCs now spawn a transient `blood.abm` effect, same-room runtime mutation can patch current-room NPC and monster entities in place instead of always falling back to `refreshCurrentScene(true)`, and the spawned replacement monster keeps the live room loop so the existing native-style chase/attack logic can take over immediately. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live Ghidra documentation counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 25, 2026, rechecked Harvester's inventory handoff path directly in live Ghidra before touching source. `run_inventory_screen` at `0x7df10` still contains the native drag-close test at `0x7e364..0x7e38e`, using the recovered bounds `x=0x45..0x238` and `y=0x4b..0x1b3`, and its teardown returns a non-null carried `RenderEntityRuntime *` to `run_harvester_main_loop` when the inventory loop exits with an active item (`lines 1221..1267` in the current decompile). ScummVM's room loop was stricter: [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) only hid the overlay on `EVENT_MOUSEMOVE` while a selected inventory item was active if the primary button was still down, which forced an extra out-of-bounds click after the initial selection. Patched that branch so the latched inventory selection now closes the overlay on mouse move whenever the cursor leaves the recovered native drag-close region, removed the now-unused `isPrimaryMouseDown()` helper, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live Ghidra documentation counts are now `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 25, 2026, traced Stephanie's looping `wav=0x4a05` regression to an ambiguity between two different `BYE` entries in the native `DIALOG.RSP`: zero-based `dialog.rsp[0x2e4]` is the generic keyword-menu exit, while zero-based `dialog.rsp[0x2fd]` is the hidden typed-topic alias inside Stephanie's spyhole continuation cluster (`0x2f9..0x2fd`). The shipped resource file confirms `dialog.rsp[0x2cf] = Those People/Wedding/Grounded/Amnesia`, `dialog.rsp[0x2f8] = Lodge/Amnesia`, and the hidden follow-up strings `0x2f9 = Peephole`, `0x2fa = Hole`, `0x2fb = Spy`, `0x2fc = Spyhole`, `0x2fd = BYE`; live Ghidra notes for `handle_talk_to_stephanie @ 0x36710` already bounded that orphan tail as reachable only after the visible buffer advances to `dialog.rsp[0x2f8]`, with the generic `BYE` still exiting through `0x46a4`. ScummVM was losing that distinction because [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp) only returned the selected text, so a clicked generic `BYE` could text-match the hidden `0x2fd` branch in [`engines/harvester/npc/stephanie_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/stephanie_dialogue.cpp). Patched [`engines/harvester/npc/dialogue_runtime.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dialogue_runtime.h) and [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp) so keyword selection metadata now tracks typed `Other` input versus the hardcoded generic `BYE`, and tightened Stephanie's hidden spyhole branch to require both the native `dialog.rsp[0x2f8]` state and typed input before `0x2f9..0x2fd` are considered. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `912` total functions, `800` named/documented, and `112` remaining `FUN_*`.
-- On March 25, 2026, traced the ESC -> `Options` label drift to native `MENU.INI` padding rather than a special-case slider anchor. Ghidra shows `run_main_menu` (`0x67390`) copying `options_menu_1..7` from `MENU.INI` into raw buffers and centering them by measured width; the shipped file keeps trailing spaces in `options_menu_1=SOUND FX           `, `options_menu_2=MUSIC         `, and `options_menu_3=GAMMA         ` so the visible glyphs sit left of center before `VOLUME.BM` / `INDICATR.BM` are drawn. ScummVM now preserves that raw spacing in [`engines/harvester/menu.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/menu.cpp) when loading the option labels, and [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) records the confirmed native layout detail. Progress counts remain `912` total functions, `800` named/documented, and `112` remaining `FUN_*`.
-- On March 25, 2026, removed the temporary Harvester investigation probes from [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp), [`engines/harvester/runtime_entity.h`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h), [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp), and [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp) after confirming the centered room-anim drift fix. Removed the door geometry/frame/draw traces, room click selection probes, actor-placement/render-order dumps, and the now-unused helper they depended on, while keeping the behavioral fixes for same-room live mutations, draw-order-based hotspot resolution, `SET_ANIM` hidden→visible runtime-state reset, and the cached centered screen base for room anims. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `912` total functions, `800` named/documented, and `112` remaining `FUN_*`.
-- On March 25, 2026, fixed a centered room-anim drift bug in [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp) after reconciling the latest sheriff screenshot with native `show_entity_visual` (`0x4c290`). The decisive native detail is that the centered branch calls `set_entity_screen_position` only on the initial show path (`field_0x4f == 0`); later frame advances in `advance_entity_animation_frame` (`0x4c8a0`) update `frame_x_offset/frame_y_offset` and call `show_entity_visual`, but do not recenter from the new frame width each draw. ScummVM now mirrors that by caching a centered anim's initial top-left screen base in `RuntimeEntity` and reusing it across frame advances instead of recomputing `_x - frame.width / 2` every draw. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `912` total functions, `800` named/documented, and `112` remaining `FUN_*`.
-- On March 25, 2026, closed the sheriff-office first-draw timing branch. The new `door anim draw trace` probe in [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp) showed that ScummVM does not draw `SHER_EVIDDOOR` frame `0`; the first actual draw is already frame `1`, then frame `2`. Ghidra confirms native uses the same ordering in `run_harvester_main_loop` (`0x6dc70`): it dispatches `handle_target_interaction()` at `LAB_0006f996`, then immediately walks the live render list at `LAB_0006fa76`, where class-1 room anims call `tick_entity_visual_state()` before the frame is flushed. Combined with the already-correct post-interaction selection probe and doorway geometry logs, this means the sheriff-office mismatch is no longer supported by click ownership, anim anchor, or first-draw timing evidence on the current build. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `912` total functions, `800` named/documented, and `112` remaining `FUN_*`.
-- On March 25, 2026, added a narrow room-anim geometry probe for the affected door visuals in [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp) and [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) after the previous native placement patch produced no obvious visual change. The new logs emit the live `pos`, `frame`, `rect`, and frame-header `offsets` for `SHER_EVIDDOOR`, `SERV_EVIDDOOR`, `SCHDOOR`, and `WAYLHERL` at spawn and in-place update time so the next repro can answer a concrete question: whether the patched centered-anim geometry is actually reaching the live entity. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 25, 2026, confirmed in Ghidra that native room ANIM placement was still mismatched in ScummVM even after the same-room live-mutation work. `spawn_anim_entity_from_record` (`0x4bbe0`) seeds `z_extent = 3`, and `show_entity_visual` (`0x4c290`) recenters the current decoded bitmap on the anim record's `x/y` pivot while lowering the render-list depth anchor by half `z_extent`; the disassembly around `0x4c548..0x4c595` shows it subtracting half the current bitmap width and height from the stored screen anchor before `set_entity_screen_position`, without adding the frame header `x_offset/y_offset` to that centered anchor. ScummVM now matches that in [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp) by making centered room anims ignore ABM frame offsets in `getDrawOrigin()` and by lowering scene-animation Z by half `z_extent` when spawning, while the same-room live mutation path in [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) now reapplies that native render Z when updating existing room anim entities in place. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 25, 2026, widened ScummVM's live-mutation path in [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so every same-room `mutatedRuntimeState` interaction now tries to patch the current room in place before falling back to `refreshCurrentScene(true)`. This closes the gap the sheriff-office `EVKEY -> EVD_DOORHS` log exposed: that path had no `GOFLIC`, so the earlier cutscene-only fix still rebuilt the whole `SHRFOFC` room after `SET_ANIM SHER_EVIDDOOR,T,T`. The helper now reuses the same native-style live update rules for room objects, regions, timers, and room anims across all same-room mutations, while still bailing out conservatively when NPC or monster records are involved because those live semantics are not yet audited. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 25, 2026, removed the temporary broom-door probes from [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) and replaced the same-room cutscene pre-refresh with a narrower live mutation path. Native `dispatch_room_event_actions` case `SET_ANIM` at `0x61f55..0x62056` preserves already-visible anim entities, resets saved frame state only on hidden→visible transitions, and spawns or destroys the current-room entity in place without rebuilding the room; ScummVM now mirrors that same-room `GOFLIC` boundary more closely by patching current-room objects, regions, and anims in place before the FST instead of materializing and respawning the whole scene. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 25, 2026, reran `BROOMKEY -> SCHL_DOOR_HS` in `SCHLHL` after the same-room `GOFLIC` timing patch and confirmed the new control flow now matches the native boundary we audited earlier. The fresh probe sequence shows `interaction-pre-cutscene-refresh`, a single pre-FST `refreshCurrentScene()` that materializes and spawns both `SCHDOOR` and `WAYLHERL`, then `before-cutscene` and `after-cutscene` with those same two live anims still present and no second room rebuild after `C132.FST`. The post-cutscene frame trace still shows `SCHDOOR` and `WAYLHERL` advancing together from frames `1..4`, but that now happens on the same live entities that were already present before the movie rather than on freshly respawned post-cutscene anims. This closes the refresh-timing mismatch: ScummVM now keeps same-room anim mutations live across the FST boundary instead of replaying them after the cutscene. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 25, 2026, updated ScummVM's same-room cutscene handling in [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) so `handleInteractionResult()` now applies `mutatedRuntimeState` to the live room before playing a same-room `GOFLIC`, instead of deferring a synthetic `refreshCurrentScene()` until after the movie returns. The new control flow refreshes immediately for same-room runtime mutations, keeps room-transition behavior unchanged, and leaves the targeted broom-door probes active long enough to verify that `WAYLHERL` is already live before `C132.FST` and that no second post-cutscene room rebuild occurs. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 25, 2026, compared the native same-room `GOFLIC` path against the ScummVM broom-door traces in live `HARVEST.LE` disassembly. Three native facts now close the loop. First, `dispatch_room_event_actions` case `SET_ANIM` (`0x60ee0`) updates the `AnimRecord` and, if the anim belongs to the current room, immediately destroys or spawns the live render entity; for `USE_BROOMKEY`, that means native spawns `WAYLHERL` as soon as step `7` runs, before `GOFLIC`. Second, `play_fst_sequence` (`0x72550`) only pauses music, swaps to the `320x200` movie surface, runs `run_fst_sequence_player`, then restores the gameplay surface and palette; it does not call `room_setup()` or rebuild the room. Third, `run_harvester_main_loop` only transitions through `room_setup()` when `g_pending_room_name` is queued, while same-room object/use actions just return to the live loop after `dispatch_room_event_actions`. Combined with `room_setup()`'s existing frame-snapshot logic for live anims, that proves the current ScummVM behavior is wrong at the boundary: deferring the broom-door `SET_ANIM` mutations until after `C132.FST` suppresses native pre-cutscene `WAYLHERL` startup and then incorrectly restarts both `SCHDOOR` and `WAYLHERL` from frame `0` in a synthetic room rebuild. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 25, 2026, added targeted ScummVM broom-door refresh and frame-trace probes in [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) and replayed `BROOMKEY -> SCHL_DOOR_HS` in `SCHLHL`. The new logs confirmed three facts. First, deferred refresh timing is real but not the visual bug: before and after `GOFLIC GRAPHIC/FST/C132.FST`, the only live door anim is `SCHDOOR`; `SCHDOOR_R` is already gone, so there is no stale reverse-door overlap. Second, `materializeRoomState()` rebuilds the room with `SCHDOOR` and `WAYLHERL` both active and visible, and `refreshCurrentScene()` respawns both live entities. Third, the new post-refresh frame trace shows `SCHDOOR` and `WAYLHERL` both starting at frame `0` and advancing in lockstep over the next `20` centisecond samples (`SCHDOOR` frames `1..4`, `WAYLHERL` frames `1..4`), which means ScummVM is replaying two distinct post-cutscene room anims together rather than accidentally drawing mirrored forward/reverse door variants. The remaining unknown is native intent after the FST: whether both anims should restart from frame `0` on room rebuild or whether one should preserve a later frame / disabled state. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 25, 2026, audited the school hallway broom-closet door in both the decoded shipping [`HARVEST.SCR`](/Users/alex/Downloads/Harvester_1996/HARVEST/iso/Harvester/HARVEST.SCR) data and live `HARVEST.LE` disassembly. Confirmed the room defines two `ANIM` records over the same `1:\GRAPHIC\ROOMANIM\HALLDOOR.ABM` asset and pivot `(261,276,71)`: `SCHDOOR` is the forward/open variant, while `SCHDOOR_R` differs only by `backward = T`. Native `dispatch_room_event_actions` case `SET_ANIM` updates the anim record, destroys the current-room render entity when visibility goes false, resets the saved frame when a hidden anim becomes visible, and `spawn_anim_entity_from_record` starts reverse anims from `last_frame_index`; together that means the `USE_BROOMKEY` chain is intentionally clearing `SCHDOOR_R` before activating `SCHDOOR`, so the original game should never keep both door variants live at once. No rename or engine patch was justified from this pass, and progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, used the new cemetery debug logs to isolate the remaining Karin layering bug to stale render-list ordering rather than bad `z_extent` math. The captured CEM10 repro showed `spawnSceneActorEntity` inserting `KARIN_CEM10` into the render list at the pre-placement record Z (`16`) while `actor placement` later lowered her final Z to `15.00`; because ScummVM never re-sorted the entity after placement, the final `scene render order` still kept Karin ahead of `CEM10_OBJ5`, which is why the grave mask continued to draw over her even though the placement log already showed the corrected render Z. [`engines/harvester/runtime_entity.h`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h) and [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp) now expose `reinsertSceneEntity`, and [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp) now re-sorts room NPCs, monsters, and newly spawned player actors immediately after placement. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, added targeted room-scene debuggability for the cemetery Karin regression in [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp), [`engines/harvester/runtime_entity.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.cpp), and [`engines/harvester/runtime_entity.h`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h). The latest CEM10 logs prove the current code is applying the actor-depth anchor correction (`KARIN_CEM10` materializes at record `z=16` but the spawned scene NPC lands at `z=15.00`), but they still do not reveal whether the remaining bug is wrong `z_extent`, wrong render ordering, or the wrong ambient frame/rect. The new logs therefore dump per-actor placement inputs and outputs (`source z`, computed `render_z`, `z_extent`, depth scale, current frame, frame metrics, final rect) and the final room render-list order after scene construction so the next repro can distinguish depth from placement/frame issues directly. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, corrected ScummVM's room-actor render-depth anchor in [`engines/harvester/flow.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/flow.cpp) to match the native `spawn_npc_entity_from_record` and `spawn_monster_entity_from_record` paths. Native disassembly at `0x4ce00..0x4ce43` and `0x540b0..0x540f3` shows both actor spawners subtracting half `z_extent` from the record Z before `set_entity_screen_position`; ScummVM was leaving actors at the raw record Z instead, which put Wednesday cemetery Karin (`KARIN_CEM10`, `posZ=16`) one layer behind `CEM10_OBJ5` (`z=15`) in CEM10. The shared room-actor placement helper now applies that native `z_extent / 2` offset before depth scaling and insertion for room NPCs, monsters, and the player. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, patched the ScummVM Karin cemetery dialogue routing so [`engines/harvester/npc/karin_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/karin_dialogue.cpp) now accepts both `KARIN` and `KARIN_CEM10`. This fixes the confirmed Wednesday grave-rescue regression where clicking Karin after `PLACE_KARIN_TIMER` fell through to `unsupported room NPC dialogue handler 'KARIN_CEM10'` instead of reusing the native Karin talk logic already implemented for `handle_talk_to_karin`. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Progress counts remain `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, reimplemented the ScummVM Johnson dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_johnson` (`0x27dd0..0x28802`) as a fresh pass rather than trusting the older port. The fresh native audit found one remaining source drift in [`engines/harvester/npc/johnson_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/johnson_dialogue.cpp): Johnson's Whaley-photo, casket-photo, dual-ledger, note/checkbook, and TV-deed item branches call the shared `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_note_checkbook_evidence`, and `get_set_discussed_tv_deed_evidence` accessors in the live handler, so the port now mirrors that by writing `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedLedgerEvidence`, `sharedState.discussedNoteCheckbookEvidence`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script evidence flags. Re-auditing the repaired intro/revisit flow, the `OPENING_MANHOLE_DAYTIME` clear, the `SCRATCHED_TUCKER` plus `BOLT_OF_CLOTH_TAKEN` re-entry, the `KARIN_KIDNAPED` shared-state write, and the recovered keyword loop confirmed the current C++ control flow already matches the saved native handler there, so no further source drift was found. The direct live callee set is already fully named (`get_set_shared_karin_kidnaped_dialogue_state`, `get_set_shared_dialogue_state_d2ebc`, the shared evidence accessors, and the standard dialogue helpers), so no additional `FUN_*` rename was justified. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, treated `JOHNSON` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify JOHNSON` reported `handle_talk_to_johnson @ 0x27dd0` in the stale short-body state (`0x27dd0..0x287f9`, `657` address-count) with only `12` reachable dialogue calls and `12` stale `CALL_RETURN` gaps while both dialogue entry points initially still remained returning. The first `repair JOHNSON` rebuilt the handler cleanly in memory to `0x27dd0..0x28802` (`2539` address-count, `51` reachable dialogue calls, `gapCount=0`, `38` repair actions), but the first saved `verify JOHNSON` snapped straight back to the short body and flipped both dialogue entry points back to non-returning. An immediate second full `repair -> wait -> save_program -> verify` cycle fixed it: the retry rebuilt Johnson to the stable saved shape `0x27dd0..0x28802` (`2539` address-count) with `51` reachable dialogue calls, `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Johnson remains in the same post-save retry bucket as Loomis, Edna, Stephanie, Mr. Potts, Dad, and similar handlers. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, reimplemented the ScummVM Loomis dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_loomis` (`0x34f80..0x35826`) as a fresh pass rather than trusting the older port. The fresh native audit found one remaining source drift in [`engines/harvester/npc/loomis_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/loomis_dialogue.cpp): Loomis's note/checkbook-plus-TV-deed branch and the both-ledgers branch call the shared `get_set_discussed_note_checkbook_evidence` and `get_set_discussed_ledger_evidence` accessors in the live handler, so the port now mirrors that by writing `sharedState.discussedNoteCheckbookEvidence` and `sharedState.discussedLedgerEvidence` alongside the existing startup-script evidence flags. Re-auditing the repaired intro/revisit flow, keyword loop aliases, `INV_MAG` diner and non-diner branches, and the Whaley photo fallback confirmed the current C++ control flow already matches the saved native handler there, so no further source drift was found. The direct live callee set is already fully named (`play_c048_fst_sequence`, `get_current_story_day_index`, `get_set_shared_dialogue_state_d2f04`, `dispatch_action_tag_if_set`, and the standard dialogue helpers), so no additional `FUN_*` rename was justified. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, treated `LOOMIS` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify LOOMIS` reported `handle_talk_to_loomis @ 0x34f80` in the stale short-body state (`0x34f80..0x35406`, `639` address-count) with only `9` reachable dialogue calls and `9` stale `CALL_RETURN` gaps while both dialogue entry points initially still remained returning. The first `repair LOOMIS` rebuilt the handler cleanly in memory to `0x34f80..0x35826` (`2071` address-count, `33` reachable dialogue calls, `gapCount=0`, `22` repair actions), but the first saved `verify LOOMIS` snapped straight back to the short body and flipped both dialogue entry points back to non-returning. An immediate second full `repair -> wait -> save_program -> verify` cycle fixed it: the retry rebuilt Loomis to the stable saved shape `0x34f80..0x35826` (`2071` address-count) with `33` reachable dialogue calls, `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Loomis remains in the same post-save retry bucket as Edna, Stephanie, Mr. Potts, Johnson, Dad, and similar handlers. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, re-audited the ScummVM Karin dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_karin` (`0x2ea90..0x2ed7f`) as a fresh pass rather than trusting the older port. The native body and the current source in [`engines/harvester/npc/karin_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/karin_dialogue.cpp) and [`engines/harvester/npc/karin_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/karin_dialogue.h) already match: the `KARIN_FOUND_ALIVE + IN_CEM10` one-shot exchange, the pre-rescue no-item opener and `0x10dc` fallback, the two-stage photo-present branch with the shared `discussedWhaleyHerrillPhoto` / `discussedCasketPhotoEvidence` writes plus Karin portrait reset, the `INV_MAG` police-dispatch branch, and the three persisted room-state bits all align with the saved native handler. The direct live callee set is already fully named (`dispatch_action_tag_if_set`, `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `set_dialogue_head_left_portrait`, and the standard dialogue helpers), so no additional `FUN_*` rename was justified. No engine source patch was needed, no build was run, and live progress is unchanged at `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, treated `KARIN` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify KARIN` reported `handle_talk_to_karin @ 0x2ea90` in the stale short-body state (`0x2ea90..0x2ed77`, `378` address-count) with only `7` reachable dialogue calls and `7` stale `CALL_RETURN` gaps while both dialogue entry points already remained returning. Running `repair KARIN` rebuilt the handler in a single pass to `0x2ea90..0x2ed7f` (`752` address-count) with `21` reachable dialogue calls, `gapCount=0`, and `14` repair actions. After the normal settle and explicit `save_program`, a fresh `verify KARIN` still reported `status=clean`, so Karin is durably clean again in the saved database on the first fresh cycle. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, reimplemented the ScummVM Edna dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_edna` (`0x358a0..0x3667b`) as a fresh pass rather than trusting the older port. The fresh native audit found two remaining source drifts in [`engines/harvester/npc/edna_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/edna_dialogue.cpp): Edna's casket-photo, both-ledger, Whaley/Herrill-photo, note/checkbook, and TV-deed item branches call the shared `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_note_checkbook_evidence`, and `get_set_discussed_tv_deed_evidence` accessors, so the port now mirrors that by writing `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedLedgerEvidence`, `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedNoteCheckbookEvidence`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script flags; and the native `dialog.rsp[0xa6]` branch only gates on `KARIN_KIDNAPED` / `KARIN_FOUND_DEAD`, so `KARIN_FOUND_ALIVE` still follows `0x3ad9 -> play_c091_fst_sequence` instead of the `0x3ae3/0x3ae9` ladder. Re-auditing the repaired intro, day-5, purse, lodge-topic, `0xc0`, and goodbye paths confirmed the current C++ control flow already matches the saved handler there, so no further source drift was found. During the same Ghidra pass I renamed the two directly evidenced `play_fst_sequence` wrappers `FUN_00038990` and `FUN_00038ba0` to `play_c091_fst_sequence` and `play_c070_fst_sequence`, saved `HARVEST.LE`, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress in the current database is `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, treated `EDNA` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify EDNA` reported `handle_talk_to_edna @ 0x358a0` in the stale short-body state (`0x358a0..0x35d1f`, `712` address-count) with only `11` reachable dialogue calls and `11` stale `CALL_RETURN` gaps while both dialogue entry points initially still remained returning. The first `repair EDNA` rebuilt the handler cleanly in memory to `0x358a0..0x3667b` (`3510` address-count, `65` reachable dialogue calls, `gapCount=0`, `57` repair actions), but the first saved `verify EDNA` snapped straight back to the short body and flipped both dialogue entry points back to non-returning. An immediate second full `repair -> wait -> save_program -> verify` cycle fixed it: the retry rebuilt Edna to the stable saved shape `0x358a0..0x3667b` (`3505` address-count) with `64` reachable dialogue calls, `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Edna remains in the same post-save retry bucket as Stephanie, Mr. Potts, Johnson, Loomis, and similar handlers. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, reimplemented the ScummVM Mrs. Potts dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_mrs_potts` (`0x2ee70..0x2f7be`) as a fresh pass rather than trusting the older port. The fresh native audit found one remaining source drift in [`engines/harvester/npc/mrs_potts_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mrs_potts_dialogue.cpp): Mrs. Potts' Whaley/Herrill-photo, casket-photo, both-ledger, note/checkbook, and TV-deed item branches call the shared `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_note_checkbook_evidence`, and `get_set_discussed_tv_deed_evidence` accessors, so the port now mirrors that by writing `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedLedgerEvidence`, `sharedState.discussedNoteCheckbookEvidence`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script flags. Re-auditing the repaired native return-visit flow, `GOT_REMAINS_FOR_LODGE`, `TAKEN_INVITE_TO_SERGEANT`, `KARIN_KIDNAPED`, `DINER_BURNED`, and the late keyword loop confirmed the current C++ control flow already matches the saved handler there, so no further source drift was found. The only direct unnamed callee surfaced during this pass, `FUN_0002fc79 @ 0x2fc79`, is just a tiny zero-return stub used as a dead tail after the `0x294d` item bark and did not meet the rename bar; the meaningful direct helpers (`play_c040_fst`, `get_set_discussed_mrs_potts_tuesday_night_alibi`, `get_set_shared_dialogue_state_d2eb8`, and the other shared-state accessors) were already stably named. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, treated `MRS_POTTS` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify MRS_POTTS` reported `handle_talk_to_mrs_potts @ 0x2ee70` in the stale short-body state (`0x2ee70..0x2f7be`, `1156` address-count) with only `19` reachable dialogue calls and `19` stale `CALL_RETURN` gaps while both dialogue entry points already remained returning. Running `repair MRS_POTTS` rebuilt the handler in two passes to a clean in-memory `0x2ee70..0x2fc81` body (`2392` address-count) with `54` reachable dialogue calls, `gapCount=0`, and `40` repair actions. After the normal settle and explicit `save_program`, a fresh `verify MRS_POTTS` still reported `status=clean`; the saved database now presents the stable clean body as `0x2ee70..0x2f7be` (`2383` address-count) with the same `54` dialogue calls and `gapCount=0`, so Mrs. Potts is durably clean again on the first fresh cycle. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, reimplemented the ScummVM Mr. Potts dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_mr_potts` (`0x26000..0x27d07`) as a fresh pass rather than trusting the older port. The fresh native audit found one remaining source drift in [`engines/harvester/npc/mr_potts_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mr_potts_dialogue.cpp): Mr. Potts' casket-photo, Whaley/Herrill-photo, note/checkbook, and TV-deed item branches call the shared `get_set_discussed_casket_photo_evidence`, `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_note_checkbook_evidence`, and `get_set_discussed_tv_deed_evidence` accessors, so the port now mirrors that by writing `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedNoteCheckbookEvidence`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script flags. Re-auditing the auxiliary intro/revisit day gating, `HARVEST_BLADE`, `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE`, `POTTSDAM_DIGGING`, `POTTSDAM_DIGGING_KARIN`, `KARIN_KIDNAPED`, `KARIN_FOUND_DEAD`, the accusation menus, and the late keyword loop confirmed the current C++ control flow already matches the saved native handler there, so no further source drift was found. The direct live callee set is now fully named (`play_c001b_fst_and_mark_steph_midgame_played`, `play_c016a_fst`, and the shared-state accessors), so no additional `FUN_*` rename was justified during this pass. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, treated `MR_POTTS` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify MR_POTTS` reported `handle_talk_to_mr_potts @ 0x26000` in the stale short-body state (`0x26000..0x26915`, `823` address-count) with only `15` reachable dialogue calls and `15` stale `CALL_RETURN` gaps while both dialogue entry points already remained returning. Running `repair MR_POTTS` rebuilt the handler in three passes to the full saved body `0x26000..0x27d07` (`6853` address-count) with `152` reachable dialogue calls, `gapCount=0`, and `81` repair actions. After the normal settle and explicit `save_program`, a fresh `verify MR_POTTS` still reported `status=clean`, so Mr. Potts is durably clean again in the saved database on the first fresh cycle. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, reimplemented the ScummVM Mom dialogue handler directly against the repaired live `HARVEST.LE` body at `handle_talk_to_mom` (`0x31140..0x33523`) as a fresh pass rather than trusting the older port. The native item-use branches proved one remaining source drift in [`engines/harvester/npc/mom_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mom_dialogue.cpp): Mom's note/checkbook, both-ledger, casket-photo, Whaley/Herrill-photo, and TV-deed evidence branches call the shared `get_set_discussed_note_checkbook_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_whaley_herrill_photo`, and `get_set_discussed_tv_deed_evidence` accessors, so the port now mirrors that by writing `sharedState.discussedNoteCheckbookEvidence`, `sharedState.discussedLedgerEvidence`, `sharedState.discussedCasketPhotoEvidence`, `sharedState.discussedWhaleyHerrillPhoto`, and `sharedState.discussedTvDeedEvidence` alongside the existing startup-script flags. Re-auditing the previously suspicious `JIMMY_ATTACKED`, `POTTSDAM`, `MEAT PLANT`, lodge-topic, and `0x26c6` fallback paths confirmed the current C++ control flow already matches the saved native handler there, so no further source drift was found. During the same Ghidra pass I renamed `FUN_00038270` to `get_set_dialogue_state_d2ea4` and `FUN_00038900` to `play_c010_fst`, saved `HARVEST.LE`, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, treated `MOM` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify MOM` reported `handle_talk_to_mom @ 0x31140` in the stale short-body state (`0x31140..0x33523`, `3309` address-count) with only `58` reachable dialogue calls, `58` stale `CALL_RETURN` gaps, and both dialogue entry points flipped back to non-returning. The first `repair MOM` rebuilt the handler cleanly in memory to `0x31140..0x33523` (`8778` address-count, `251` reachable dialogue calls, `gapCount=0`, `116` repair actions), but the first saved `verify MOM` snapped straight back to the stale short body. An immediate second full `repair -> wait -> save_program -> verify` cycle rebuilt the same full body again (`117` repair actions on the retry) and this time the saved verify held with `play_dialogue_line` and `thunk_play_dialogue_line` both returning plus `status=clean`, so Mom is durably clean again in the current saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, reimplemented the ScummVM Jimmy dialogue handler directly against the saved live `HARVEST.LE` body at `handle_talk_to_jimmy` (`0x28880..0x28ec1`) as a fresh pass rather than trusting the older port. The recovered native body confirmed two source drifts in [`engines/harvester/npc/jimmy_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/jimmy_dialogue.cpp): Jimmy's `PHOTO_OF_WHALEY_HERRILL`, both-ledger / corpse-photo, and note/checkbook item branches call `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_ledger_evidence` / `get_set_discussed_casket_photo_evidence`, and `get_set_discussed_note_checkbook_evidence`, so the port now mirrors that by writing `sharedState.discussedWhaleyHerrillPhoto`, `sharedState.discussedLedgerEvidence` / `sharedState.discussedCasketPhotoEvidence`, and `sharedState.discussedNoteCheckbookEvidence` instead of the non-native startup-script evidence flags; and the native `NEWSPAPER` handoff restores the paper to `RAH` before dispatching `ACTV_HOUSE_EXIT`, so the port now returns `NEWSPAPER` to room state before running the action tag. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Jimmy's direct Ghidra callees were re-audited during the pass, but none remained as `FUN_*` or met the rename bar for a new symbol change. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, treated `JIMMY` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify JIMMY` reported `handle_talk_to_jimmy @ 0x28880` in the stale short-body state (`0x28880..0x28eb9`, `807` address-count) with only `13` reachable dialogue calls and `13` stale `CALL_RETURN` gaps. Running `repair JIMMY` rebuilt the handler in one pass to the full saved body `0x28880..0x28ec1` (`1602` address-count) with `39` reachable dialogue calls, `gapCount=0`, and `27` repair actions. After a settle and explicit `save_program`, a fresh `verify JIMMY` still reported `status=clean` with both dialogue entry points returning, so Jimmy is durably clean again in the saved database on the first fresh cycle. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, reimplemented the ScummVM Hank dialogue handler directly against the saved live `HARVEST.LE` body at `handle_talk_to_hank` (`0x33a30..0x34bcb`) as a fresh pass rather than trusting the older port. The recovered native body confirmed three source drifts in [`engines/harvester/npc/hank_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/hank_dialogue.cpp): the same-day and later-day branch driven by `g_hank_rangshot_branch_pending` / `g_hank_rangshot_branch_active` plays `RANGSHOT.FST` via `play_rangshot_fst_sequence`, not `C021.FST`, so the port now uses `GRAPHIC/FST/RANGSHOT.FST` for those two followup paths while keeping `C021.FST` only on the separate TV topic; Hank's `CASKET_PHOTO` / `CASKET_PHOTOCOPY` and `PHOTO_OF_WHALEY_HERRILL` item branches call `get_set_discussed_casket_photo_evidence` and `get_set_discussed_whaley_herrill_photo`, so the port now mirrors that by writing `sharedState.discussedCasketPhotoEvidence` and `sharedState.discussedWhaleyHerrillPhoto` alongside the existing startup-script flags; and the native unknown-keyword fallback plays `0xa32` then loops back to the keyword menu instead of forcing the `0x8dc` goodbye line, so the port now continues after `0xa32` until the actual `0xf2` exit topic is chosen. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Hank's direct Ghidra callees were re-audited during the pass, including `play_c021_fst_sequence` and `play_rangshot_fst_sequence`, but none remained as `FUN_*` or met the rename bar for a new symbol change. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, treated `HANK` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify HANK` reported `handle_talk_to_hank @ 0x33a30` in the stale short-body state (`0x33a30..0x34bc2`, `1542` address-count) with only `28` reachable dialogue calls and `28` stale `CALL_RETURN` gaps. Running `repair HANK` rebuilt the handler in two passes to the full saved body `0x33a30..0x34bcb` (`4508` address-count) with `131` reachable dialogue calls, `gapCount=0`, and `58` repair actions. After a settle and explicit `save_program`, a fresh `verify HANK` still reported `status=clean` with both dialogue entry points returning, so Hank is durably clean again in the saved database on the first fresh cycle. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, reimplemented the ScummVM Dwayne dialogue handler directly against the saved live `HARVEST.LE` body at `handle_talk_to_dwayne` (`0x3a310..0x3c385`) as a fresh pass rather than trusting the older port. The recovered native body confirmed four source drifts in [`engines/harvester/npc/dwayne_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dwayne_dialogue.cpp): Dwayne's note/checkbook and TV-deed item branches do not write the shared `get_set_discussed_note_checkbook_evidence` or `get_set_discussed_tv_deed_evidence` accessors, so the port now stops pre-arming `sharedState.discussedNoteCheckbookEvidence` and `sharedState.discussedTvDeedEvidence` from those item presentations; the normal `0x3245..0x3252` introduction, the sheriff-in-diner intro `0x3482..0x349b`, and the diner revisit `0x34ac` all return to the shared followup ladder without same-visit `0x34d3`, so the C++ control flow now suppresses that erroneous fallthrough; the `KARIN_FOUND_DEAD` no-purse response-1 path plays `0x379d` and returns immediately instead of continuing into the later absent-NPC/topic sweep; and the arrest cleanup always hides `GOOJF_CARD` in `RAH`, while the completed Karin-alive followup bit is only raised after the successful `0x3780/0x3786/0x378a` sequence. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Direct Dwayne callees and the additional helper at `0x38240` (`get_set_boyle_gascan_application_state`) were re-audited in Ghidra during the pass, but none remained as `FUN_*` or met the rename bar for a new symbol change. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, treated `DWAYNE` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify DWAYNE` reported `handle_talk_to_dwayne @ 0x3a310` in the stale short-body state (`0x3a310..0x3c385`, `3375` address-count) with only `59` reachable dialogue calls and `59` stale `CALL_RETURN` gaps. Running `repair DWAYNE` rebuilt the handler in two passes to the full saved body `0x3a310..0x3c385` (`8219` address-count) with `186` reachable dialogue calls, `gapCount=0`, and `118` repair actions. After a settle and explicit `save_program`, a fresh `verify DWAYNE` still reported `status=clean` with both dialogue entry points returning, so Dwayne is durably clean again in the saved database on the first fresh cycle. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change: `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, reimplemented the ScummVM Stephanie dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_stephanie` (`0x36710..0x37f5d`) as a fresh pass rather than reusing the prior port. The recovered native body confirmed three engine mismatches in [`engines/harvester/npc/stephanie_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/stephanie_dialogue.cpp): the note/checkbook, casket-photo, and dual-ledger item branches write shared dialogue state through `get_set_discussed_note_checkbook_evidence`, `get_set_discussed_casket_photo_evidence`, and `get_set_discussed_ledger_evidence`, so the port now mirrors that by writing `sharedState.discussedNoteCheckbookEvidence`, `sharedState.discussedCasketPhotoEvidence`, and `sharedState.discussedLedgerEvidence` alongside the existing startup-script flags; the native one-shot ladder for `SCRATCHED_TUCKER`, `BOLT_OF_CLOTH_TAKEN`, `BARBER_POLE_STOLEN`, and `DINER_BURNED` falls through into later event checks instead of returning early, so the early C++ returns were removed to match the live control flow; and the keyword branches for `dialog.rsp[0x2e5/0x2e6]` and `dialog.rsp[0x2ed]` continue through the shared `0x4441` and `0x44a2..0x44af` tails with topic-buffer rewrites instead of ending immediately, while Stephanie's `dialog.rsp[0x2f3]` lodge topic only reads the shared lodge-discussion state and does not set it locally. Rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Direct Stephanie callees were re-audited in Ghidra (`play_c022a_fst`, `play_c022b_fst`, `play_c023a_fst`, `play_c025a_fst`, `play_c025b_fst`, `play_c007_fst`, and the shared-state accessors), but none remained as `FUN_*` or met the rename bar for a new symbol change. Live progress remains `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, treated `STEPHANIE` as a fresh `InspectCallTermination.java` repair in the live `HARVEST.LE` session. A fresh `verify STEPHANIE` reported `handle_talk_to_stephanie @ 0x36710` as the short `0x36710..0x36cd8` body (`526` address-count) with `9` reachable dialogue calls and `9` stale `CALL_RETURN` gaps. Running `repair STEPHANIE` rebuilt the handler in two passes to `0x36710..0x37f5d` (`6166` address-count, `195` dialogue calls, `gapCount=0`, `29` repair actions); after a settle and explicit `save_program`, a fresh `verify STEPHANIE` still reported `status=clean` with both dialogue entry points returning. A targeted raw-byte audit of the recovered body then found two still-misdecoded in-body call sites at `0x3765c` and `0x3778d`; clearing and re-disassembling those spans restored the missing `CALL 0x7a690` instructions for the optional `0x4970` spyhole line and the shared `0x49ba` Karin-alive call site. Rechecking the repaired bytes confirmed that the live native handler still matches the current ScummVM Stephanie flow in [`engines/harvester/npc/stephanie_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/stephanie_dialogue.cpp), including the `0x45d8/0x45e2` first-followup tail, `0x4709` portrait variant, the `0x4984/0x4989/0x498f` Mrs. Potts alibi followup, and the gated `0x49c1 -> 0x49ea -> 0x49ef` Karin-alive tail, so no engine source patch was needed. Live progress is now `920` total functions, `808` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, reimplemented the ScummVM Whaley dialogue handler directly against the saved live `HARVEST.LE` body at `handle_talk_to_whaley` (`0x23ec0..0x249ca`). The earlier port in [`engines/harvester/npc/whaley_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/whaley_dialogue.cpp) already matched most of the recovered control flow, but three native mismatches remained: the casket-photo, dual-ledger, and note/checkbook item branches only wrote startup-script flags instead of the native shared `get_set_discussed_*` evidence state; the later `0x14dd[2]` Whaley/Herrill-photo follow-up was gated on the startup flag instead of shared `get_set_discussed_whaley_herrill_photo`; and the `KARIN_KIDNAPED` no-item branch collapsed `0x13e5..0x13ff` into one sequence instead of setting the shared kidnapped-dialogue state between `0x13e5` and `0x13ee`. Patched the handler to mirror those native state transitions, renamed the directly encountered cutscene helper at `0x38930` from `FUN_00038930` to `play_c001b_fst_and_mark_steph_midgame_played`, fixed its prototype to `void play_c001b_fst_and_mark_steph_midgame_played(void)`, updated [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) with the confirmed helper semantics, saved `HARVEST.LE`, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Live progress is now `914` total functions, `802` named/documented, and `112` remaining `FUN_*`.
-- On March 24, 2026, reran `repair WHALEY` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Whaley showed the same first-save snapback pattern seen on the broader retry bucket: the first `repair -> wait -> save_program -> verify` cycle rebuilt `handle_talk_to_whaley @ 0x23ec0` from the short `0x23ec0..0x244f5` body (`438` address-count, `7` dialogue calls, `7` stale `CALL_RETURN` gaps) to a clean in-memory `0x23ec0..0x249ca` body (`2808` address-count, `78` dialogue calls, `gapCount=0`), but the first saved verify fell back to the short body and flipped both dialogue entry points back to non-returning. An immediate second full cycle fixed it: the second `repair -> wait -> save_program -> verify` left Whaley durably clean again at `0x23ec0..0x249ca` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 24, 2026, reimplemented the ScummVM Wasp Woman dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_wasp_woman` (`0x2fde0..0x3030b`). The old port in [`engines/harvester/npc/wasp_woman_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/wasp_woman_dialogue.cpp) missed the unconditional `C027B.FST` insert after topic `0x305`, omitted the full topic-`0x30c` branch `0x4c85[0] -> 0x4c8b[1] -> 0x4c8f[2]` plus the `dialog.rsp[0x30d]` buffer rewrite, ended the conversation after the `0x4c9a[0]` fallback instead of looping, and only set startup-script evidence flags instead of the native shared `get_set_discussed_*` state. Patched the handler to mirror the recovered branch structure, topic-buffer rewrites, and shared evidence writes; renamed the directly encountered FST wrapper at `0x38b00` from `FUN_00038b00` to `play_c027b_fst`; fixed its prototype to `void play_c027b_fst(void)`; saved `HARVEST.LE`; and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with `make -j4 scummvm`. Live progress is now `933` total functions, `814` named/documented, and `119` remaining `FUN_*`.
-- On March 24, 2026, reimplemented the ScummVM Vet dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_vet` (`0x2fd20..0x2fdd5`). The old stub in [`engines/harvester/npc/vet_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/vet_dialogue.cpp) only cleared `VET_INTERRUPT` and played `0x6d9`, while the recovered native handler is the full interrupt-gated exchange `0x6d9[0] -> PC 0x447[0] -> 0x6e1[2]`, response menu `dialog.rsp[0x2ff]`, response `1 -> 0x6ec[2]`, response `2 -> 0x6f0[0]`, and shared exit `0x6f4[2]`, with `VET_INTERRUPT` cleared before the dialogue and again on the shared tail. Patched [`engines/harvester/npc/vet_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/vet_dialogue.cpp), rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully from the configured repo-root `make -j4 scummvm` build, and audited the directly encountered native helpers (`get_flag_value`, `set_flag_value`, `load_dialogue_response_line`, `run_dialogue_response_menu`, and `play_dialogue_line`) without finding any `FUN_*` symbol that met the rename bar. Live progress remains `931` total functions, `812` named/documented, and `119` remaining `FUN_*`.
-- On March 24, 2026, reran the full durable `repair WASP_WOMAN -> wait -> save_program -> verify WASP_WOMAN` cycle against the live `HARVEST.LE` session. `handle_talk_to_wasp_woman @ 0x2fde0` started as a truncated `0x2fde0..0x2ffd8` body (`291` address-count) with `4` reachable dialogue calls and `4` stale `CALL_RETURN` gaps. A single repair pass rebuilt it cleanly to `0x2fde0..0x3030b` (`1314` address-count) with `28` reachable dialogue calls, `gapCount=0`, and `8` repair actions. After the normal settle and explicit save, a fresh `verify WASP_WOMAN` still reported `status=clean` with both dialogue entry points returning, so Wasp Woman is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 24, 2026, reran the full durable `repair VET -> wait -> save_program -> verify VET` cycle against the live `HARVEST.LE` session. `handle_talk_to_vet @ 0x2fd20` started as a truncated `0x2fd20..0x2fdd5` body (`54` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x2fd20..0x2fdd5` (`182` address-count) with `5` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify VET` still reported `status=clean` with both dialogue entry points returning, so Vet is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 24, 2026, re-audited the live `HARVEST.LE` VALET handler after the saved repair and confirmed the ScummVM flow at [`engines/harvester/npc/valet_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/valet_dialogue.cpp) is exactly the native `handle_talk_to_valet @ 0x2eda0`: intro lines `0xf2c[1] -> PC 0xf31[0] -> 0xf35[0] -> PC 0xf39[0]`, the `GRAPHIC/FST/MIDGET02.FST` playback, response menu `0x2fe`, optional reply `1 -> 0xf4a[0]` or `2 -> 0xf50[2]`, and the unconditional exit line `0xf56[0]`. I rewrote the handler around explicit native constants so that control flow matches the recovered body one-for-one, renamed the directly encountered wrapper at `0x38b30` from `FUN_00038b30` to `play_valet_midget02_fst_sequence`, fixed its prototype to `void play_valet_midget02_fst_sequence(void)`, and saved `HARVEST.LE`. The live database now reports `931` total functions, `812` named/documented, and `119` remaining `FUN_*`.
-- On March 24, 2026, reran the full durable `repair VALET -> wait -> save_program -> verify VALET` cycle against the live `HARVEST.LE` session. `handle_talk_to_valet @ 0x2eda0` started as a truncated `0x2eda0..0x2edb5` body (`22` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x2eda0..0x2ee3b` (`156` address-count) with `6` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify VALET` still reported `status=clean` with both dialogue entry points returning, so Valet is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 24, 2026, re-audited the live `HARVEST.LE` Swell handler after the earlier port and confirmed two remaining ScummVM mismatches in [`engines/harvester/npc/swell_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/swell_dialogue.cpp): the native evidence branches call `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, and `get_set_discussed_note_checkbook_evidence`, so the port now also writes the matching shared dialogue-state ints alongside the existing startup-script flags; and the keyword loop exits only on native response line `0x21e`, so the extra `runtime.genericByeTopic()` shortcut was removed to match the recovered `strcmp_ascii_case_insensitive` branch exactly. During the same pass I renamed the proven SWELL room-state reset helper at `0x29700` to `reset_swell_room_dialogue_state` and relabeled its backing globals as `g_swell_intro_pending`, `g_swell_steph_midgame_shown`, `g_swell_barber_pole_stolen_shown`, `g_swell_bolt_of_cloth_taken_shown`, `g_swell_burned_tv_station_shown`, and `g_swell_karin_kidnaped_shown`, then rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully and saved `HARVEST.LE`. No additional `FUN_*` rename was justified, so live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
-- On March 24, 2026, reimplemented the ScummVM Swell dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_swell` (`0x29110..0x296d4`) and its reset helper `reset_serialized_talk_state_block_d2c30` (`0x29700..0x2972f`). The old stub in [`engines/harvester/npc/swell_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/swell_dialogue.cpp) only handled the `0xf61` first-talk bark, the `0xfb3` revisit bark, and one-line evidence replies, but the recovered native handler is a much wider flow: item use now includes the full ledger exchange `0xfd4 -> PC 0xfd9 -> 0xfde` and the correct portrait variants on `0xfc6` and `0xfe6`; the first no-item talk now plays the full `0xf61 -> PC 0xf65 -> 0xf69 -> PC 0x3d50 -> 0xf72 -> 0xf7f` intro and seeds keyword buffer `dialog.rsp[0x21d]`; revisit logic now preserves the native one-shot local gates for `STEPH_MIDGAME_PLAYED`, `BARBER_POLE_STOLEN`, `BOLT_OF_CLOTH_TAKEN`, `BURNED_TV_STATION`, and `KARIN_KIDNAPED`; and the keyword loop now matches the recovered topic rewrites and responses for `0x21e` through `0x229`, including the `0xf88`, `0xf95`, `0xfa0`, `0xfaa`, and `0xfba` branches. Patched [`engines/harvester/npc/swell_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/swell_dialogue.cpp) and [`engines/harvester/npc/swell_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/swell_dialogue.h), bumped the Harvester save blob version to `13` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) so the five new Swell-local one-shot bits serialize compatibly, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. The directly encountered native helpers (`get_flag_value`, `get_set_discussed_whaley_herrill_photo`, `get_set_discussed_casket_photo_evidence`, `get_set_discussed_ledger_evidence`, `get_set_discussed_note_checkbook_evidence`, `load_dialogue_response_line`, `run_dialogue_keyword_menu`, and `reset_serialized_talk_state_block_d2c30`) were already stably named, so no additional `FUN_*` rename was justified. Live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
-- On March 24, 2026, reran the full durable `repair SWELL -> wait -> save_program -> verify SWELL` cycle against the live `HARVEST.LE` session. `handle_talk_to_swell @ 0x29110` started as a truncated `0x29110..0x29378` body (`431` address-count) with `6` reachable dialogue calls and `6` stale `CALL_RETURN` gaps. A single repair pass rebuilt it cleanly to `0x29110..0x296d4` (`1477` address-count) with `26` reachable dialogue calls, `gapCount=0`, and `12` repair actions. After the normal settle and explicit save, a fresh `verify SWELL` still reported `status=clean` with both dialogue entry points returning, so Swell is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 24, 2026, rechecked the ScummVM PTA Mom dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_pta_mom` (`0x34e30..0x34f79`). The existing port in [`engines/harvester/npc/pta_mom_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/pta_mom_dialogue.cpp) already matched the native `PTA_RESPOND_TO_TV` early return, the three-opener `0x31ee` / `0x31f2` / `0x31f6` random bark, `dialog.rsp[0x297]`, response `2 -> 0x320f[0] -> 0x3213[0]`, and response `3 -> 0x3217[0] -> 0x321c[0] -> PC 0x3221[0] -> 0x3226[0]`, but the response-`1` tail was one portrait variant off. Native disassembly at `0x34ee0..0x34f70` confirms `0x3204` uses `PTA_MOM1` head variant `2` while `0x320b` falls through the shared tail with head variant `0`, so I patched the handler and corrected the matching architecture note. The directly encountered native callees (`get_flag_value`, `set_flag_value`, `next_random_value`, `load_dialogue_response_line`, `run_dialogue_response_menu`, and `play_dialogue_line`) are all already stably named, so no additional `FUN_*` rename was justified. Live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
-
-- On March 24, 2026, reimplemented the ScummVM Priest dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_priest` (`0x29730..0x29911`). The old stub in [`engines/harvester/npc/priest_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/priest_dialogue.cpp) only set `PC_TALKED_TO_PRIEST` and played `0xcb2[0]`, while the recovered native handler is a one-shot intro `0xcb2[0]` into response menus `0x27a`, `0x27b`, `0x27c`, `0x27d`, and `0x27e`, with immediate follower-monsterfy exits after `0xcc4[2]` on responses `2`, `2`, `1`, and `2` respectively; deeper branch lines `0xcbf[1]`, `0xccf[1]`, `0xce5[1]`, and `0xcf2[1]`; and a final split where response `4` plays `0xd0e[1]`, clears `PRIEST_FLAG`, and dispatches `RELIG_EXITS_A`, while every other final response plays `0xd13[3]`, sets `PRIEST_FLAG`, and dispatches `FOLOWR_MNSTFY_A`. Patched [`engines/harvester/npc/priest_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/priest_dialogue.cpp) to mirror that control flow, audited the directly encountered Ghidra callees without finding any `FUN_*` symbol that met the rename bar, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
-
-- On March 24, 2026, reran the full durable `repair PTA_MOM -> wait -> save_program -> verify PTA_MOM` cycle against the live `HARVEST.LE` session. `handle_talk_to_pta_mom @ 0x34e30` started as a truncated `0x34e30..0x34f79` body (`222` address-count) with `7` reachable dialogue calls and `7` stale `CALL_RETURN` gaps. The rebuild needed two in-memory passes and finished cleanly at `0x34e30..0x34f79` (`330` address-count) with `11` reachable dialogue calls, `gapCount=0`, and `17` repair actions. After the normal settle and explicit save, a fresh `verify PTA_MOM` still reported `status=clean` with both dialogue entry points returning, so PTA Mom is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 24, 2026, reran the full durable `repair PRIEST -> wait -> save_program -> verify PRIEST` cycle against the live `HARVEST.LE` session. `handle_talk_to_priest @ 0x29730` started as a truncated `0x29730..0x29911` body (`57` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x29730..0x29911` (`482` address-count) with `11` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify PRIEST` still reported `status=clean` with both dialogue entry points returning, so Priest is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 24, 2026, reimplemented the ScummVM Memb Dir dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_memb_dir` (`0x396d0..0x39781`). The old stub in [`engines/harvester/npc/memb_dir_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/memb_dir_dialogue.cpp) only played `0x2b7[1]` before `PC_KILLED_KEWPIE` and a one-shot `0x2d5[1]` after it, while the recovered native handler is the pre-kill bark `0x2b7[1]` into response menu `0xfe` with response `1 -> 0x2c2[0]`, response `2 -> 0x2cb[0]`, and dismissed/other responses returning silently; plus the post-kill one-shot `0x2d5[1]` gated by local state `d2f64`, followed by `TOOK_THE_DOLL -> 0x2e3[0]` or `0x2ea[0]`. Patched [`engines/harvester/npc/memb_dir_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/memb_dir_dialogue.cpp) to mirror that exact control flow, audited the directly encountered Ghidra helpers and reset routine without finding any `FUN_*` symbol that met the rename bar, and kept live progress unchanged at `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
-- On March 24, 2026, reimplemented the ScummVM Mcknight dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mcknight` (`0x2c770..0x2caf3`). The old stub in [`engines/harvester/npc/mcknight_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mcknight_dialogue.cpp) only played a single `0x1587[2]` no-item bark, simple one-line evidence responses, and never latched the `LOOK_SAFE_1ST` one-shot gate, while the recovered native handler is `LOOK_SAFE_2ND -> 0x15d0[2]`, one-shot `LOOK_SAFE_1ST -> 0x15c9[2]`, first empty-hand talk `0x1587[2] -> PC 0x158b[0] -> 0x158f[0] -> PC 0x1594[0] -> 0x1598[2] -> PC 0x159d[0]` plus shared state `d2f04`, default empty-hand bark `0x15e4`, corpse-photo follow-up `0x15f2` plus either `PC 0x15f8 -> 0x15fd` or `PC 0x1605 -> 0x1609` depending on shared state `d2ebc`, ledgers `0x1611[1]`, and note/checkbook evidence `0x161b[1] -> PC 0x1621[0] -> 0x1626[3] -> PC 0x162b[4] -> 0x1631[0]`, with wrong items still falling back to `0x15de`. Patched [`engines/harvester/npc/mcknight_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mcknight_dialogue.cpp) to mirror that exact control flow, audited the directly encountered Ghidra callees without finding any `FUN_*` symbol that met the rename bar, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Live progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
-- On March 24, 2026, reran the full durable `repair MEMB_DIR -> wait -> save_program -> verify MEMB_DIR` cycle against the live `HARVEST.LE` session. `handle_talk_to_memb_dir @ 0x396d0` started as a truncated `0x396d0..0x39781` body (`76` address-count) with `2` reachable dialogue calls and `2` stale `CALL_RETURN` gaps. The rebuild needed two in-memory passes and finished cleanly at `0x396d0..0x39781` (`178` address-count) with `4` reachable dialogue calls, `gapCount=0`, and `7` repair actions. After the normal settle and explicit save, a fresh `verify MEMB_DIR` still reported `status=clean` with both dialogue entry points returning, so Memb Dir is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 24, 2026, refactored [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md) into a tighter subsystem-first layout without changing any confirmed findings. The file now opens with a concise summary plus an at-a-glance index, and the former flat heading sprawl is grouped under `Runtime And Gameplay`, `Interface And Input`, `Data Types And Helper Layer`, `UI And Inventory Details`, and `Open Questions`. Progress counts remain `930` total functions, `811` named/documented, and `119` remaining `FUN_*` because this was documentation cleanup only.
-- On March 24, 2026, reran the full durable `repair MCKNIGHT -> wait -> save_program -> verify MCKNIGHT` cycle against the live `HARVEST.LE` session. `handle_talk_to_mcknight @ 0x2c770` started as a truncated `0x2c770..0x2caed` body (`472` address-count) with `8` reachable dialogue calls and `8` stale `CALL_RETURN` gaps. The rebuild needed two in-memory passes and finished cleanly at `0x2c770..0x2caf3` (`730` address-count) with `17` reachable dialogue calls, `gapCount=0`, and `20` repair actions. After the normal settle and explicit save, a fresh `verify MCKNIGHT` still reported `status=clean` with both dialogue entry points returning, so McKnight is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 24, 2026, reimplemented the ScummVM Maint Man dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_maint_man` (`0x2b6a0..0x2b724`). The native routine only checks `MAINTENANCE_MAN_FIRST_CONVERSATION`, `MAINTENANCE_MAN_THIRD_CONVERSATION`, and `MAINTENANCE_MAN_FOURTH_CONVERSATION`, returning `MAINT_MAN 0xdbc[2]` or `0xdc4[2]` on the latter two flags and otherwise sharing a single tail call that selects either `0xdaf[1]` for the first-conversation bark or `0xdb6[0]` for the default bark. Patched [`engines/harvester/npc/maint_man_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/maint_man_dialogue.cpp) to mirror that recovered control flow, audited the directly encountered Ghidra callees without finding any `FUN_*` symbol that met the rename bar, and verified progress remains `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
-- On March 24, 2026, reimplemented the ScummVM Lodge Chef dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_lodge_chef` (`0x38170..0x38220`). The old stub in [`engines/harvester/npc/lodge_chef_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/lodge_chef_dialogue.cpp) only played `0x1b10[2]`, while the recovered native handler is a fixed four-line intro `0x1b10[2] -> 0x1b19[0] -> 0x1b1a[3] -> 0x1b1b[0]`, a single `dialog.rsp[0x21]` branch where response `1` plays `0x1b2c[3]` and response `2` plays `0x1b31[0]`, and a fixed tail `PC 0x1b36[0] -> LODGE_CHEF 0x1b3a[3]`. Patched the handler to mirror that exact flow, audited the directly encountered Ghidra callees without finding any `FUN_*` symbol that met the rename bar, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully with progress unchanged at `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
-
-- On March 23, 2026, reimplemented the ScummVM Madam dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_madam` (`0x2adc0..0x2af64`). The old stub in [`engines/harvester/npc/madam_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/madam_dialogue.cpp) only cleared `MADAM_INTERRUPT_CONVERSATION` and played `0x1537[1]`, while the recovered native handler is a single interrupt-gated two-menu exchange: `0x1537[1] -> dialog.rsp[0xfb]`, any non-`1` top-level response taking `0x1577[0] -> 0x157b[2] -> queue live HOOKER death-or-monsterfy`, and response `1` taking `0x1542[1]` into either the no-cash branch `PC 0x156e[0] -> MADAM 0x1572[0] -> 0x157b[2] -> queue live HOOKER death-or-monsterfy`, the inner `dialog.rsp[0xfc]` response-`1` branch `0x154e[0] -> 0x157b[2] -> queue live HOOKER death-or-monsterfy`, or the paid branch `0x1555[1] -> hide INVENTORY:BARCASH200 -> HOARLOVE.FST -> 0x155a[0] -> 0x1563[0] -> C117.FST -> HURT_PC_0 -> ACTV_LUST_EXIT`. Patched the handler to mirror that exact flow, renamed the directly evidenced FST wrapper `FUN_000387c0` at `0x387c0` to `play_c117_fst_sequence`, saved `HARVEST.LE`, and refreshed live progress to `930` total functions, `811` named/documented, and `119` remaining `FUN_*`.
-- On March 23, 2026, reran the full durable `repair MADAM -> wait -> save_program -> verify MADAM` cycle against the live `HARVEST.LE` session. `handle_talk_to_madam @ 0x2adc0` started as a truncated `0x2adc0..0x2af64` body (`57` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x2adc0..0x2af64` (`421` address-count) with `12` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify MADAM` still reported `status=clean` with both dialogue entry points returning, so Madam is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 23, 2026, reimplemented the ScummVM Librarian dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_librarian` (`0x28f40..0x2910a`). The old stub in [`engines/harvester/npc/librarian_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/librarian_dialogue.cpp) collapsed the native flow into a single `0x1d5b` bark or an immediate `CLUE` award, but the recovered handler is a larger three-way split: visible `CAINBOOK` inventory auto-triggers the trade branch `0x1db1[1] -> 0x1db7 -> 0x1dbb -> 0x1dc2[PC4] -> 0x1dc6` plus `CLUE` award and `set_object_visibility_for_owner_or_room("RAH", "CAINBOOK", 0)`; wrong presented items only play `0x1d5b`; and empty-hand talks run `0x1d5b -> 0x1d5f -> 0x1d63[2] -> 0x1d67 -> 0x1d6c -> 0x1d77 -> dialog.rsp[0xfa]`, optionally `0x1d85` or `0x1d8b[4]`, then the shared tail `0x1d8f -> 0x1d94 -> 0x1d98 -> 0x1da1 -> 0x1da5`. Patched the ScummVM handler to mirror that exact control flow, kept the native visible-inventory semantics so the hidden Cainbook no longer counts as owned, rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and audited the directly touched helper chain in Ghidra without finding any encountered `FUN_*` function that met the rename bar. Live progress remains `929` total functions, `810` named/documented, and `119` remaining `FUN_*`.
-- On March 23, 2026, reran the full durable `repair LIBRARIAN -> wait -> save_program -> verify LIBRARIAN` cycle against the live `HARVEST.LE` session. `handle_talk_to_librarian @ 0x28f40` started as a truncated `0x28f40..0x2901d` body (`124` address-count) with `3` reachable dialogue calls and `3` stale `CALL_RETURN` gaps. A single repair pass rebuilt it cleanly to `0x28f40..0x2910a` (`459` address-count) with `18` reachable dialogue calls, `gapCount=0`, and `7` repair actions. After the normal settle and explicit save, a fresh `verify LIBRARIAN` still reported `status=clean` with both dialogue entry points returning, so Librarian is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 23, 2026, reimplemented the ScummVM Inquisitor dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_inquisitor` (`0x2af70..0x2afe8`). The old stub in [`engines/harvester/npc/inquisitor_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/inquisitor_dialogue.cpp) only gated on `PAIN_SOLVED`, cleared the local talk-state bit, and played `0x18e7`, while the recovered native handler is a one-shot straight-line sequence: `0x18e7 -> 0x18e8 -> 0x18e9 -> TORCH2.FST -> 0x18f8 -> START_INQ_TIM`, still gated by `PAIN_SOLVED` and `g_inquisitor_talk_state_block`. Patched the handler to mirror that exact flow, renamed the directly evidenced FST wrapper `FUN_00038810` at `0x38810` to `play_torch2_fst_sequence`, saved `HARVEST.LE`, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Live progress is now `929` total functions, `810` named/documented, and `119` remaining `FUN_*`.
-- On March 23, 2026, reran the full durable `repair INQUISITOR -> wait -> save_program -> verify INQUISITOR` cycle against the live `HARVEST.LE` session. `handle_talk_to_inquisitor @ 0x2af70` started as a truncated `0x2af70..0x2afe8` body (`55` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x2af70..0x2afe8` (`121` address-count) with `4` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify INQUISITOR` still reported `status=clean` with both dialogue entry points returning, so Inquisitor is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 23, 2026, reimplemented the ScummVM Gladiator dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_gladiator` (`0x2c630..0x2c762`). The old stub in [`engines/harvester/npc/gladiator_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/gladiator_dialogue.cpp) only cleared `GLADIATOR_INTERRUPT_CONVERSATION` and played `0x69c[1]`, while the recovered native handler is a two-menu interrupt conversation: `0x69c[1] -> 0x6a3[0] -> dialog.rsp[0xc3]`, response `1` taking the death tail `0x6af[2] -> 0x6cd[2] -> queue live GLADIATOR death-or-monsterfy`, and response `2` taking `0x6b3[0] -> C110.FST -> dialog.rsp[0xc4]` where response `1` uses the alternate death tail `0x6c3[2] -> 0x6cd[2]` and response `2` plays `0x6c7[1] -> 0x6cd[0] -> START_MERCY_TIMR`. Patched the handler to mirror that exact control flow, confirmed that the only Gladiator-path helpers hit by this audit were already stably named (`play_c110_fst_sequence`, `queue_live_named_npc_death_or_monsterfy_transition`, and the shared room-event dispatcher), rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and left live progress unchanged at `928` total functions, `809` named/documented, and `119` remaining `FUN_*`.
-- On March 23, 2026, reran the full durable `repair GLADIATOR -> wait -> save_program -> verify GLADIATOR` cycle against the live `HARVEST.LE` session. `handle_talk_to_gladiator @ 0x2c630` started as a truncated `0x2c630..0x2c762` body (`57` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x2c630..0x2c762` (`307` address-count) with `9` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify GLADIATOR` still reported `status=clean` with both dialogue entry points returning, so Gladiator is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 23, 2026, reimplemented the ScummVM Dark Woman dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_dark_woman` (`0x33610..0x33819`). The old stub in [`engines/harvester/npc/dark_woman_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dark_woman_dialogue.cpp) only handled direct `HANDMIRROR` use as `0x4d03[2]` and a single first-talk bark `0x4cd5[1]`, while the recovered native handler is a four-menu tree: `0x4cd5[1]`, response line `0x4b`, and three nested `0x4ce6[4]` follow-ups through response lines `0x4c` and `0x4d`, with response `1` on any menu taking the `0x4ce2[2]` live NPC death-or-monsterfy branch. The fourth menu switches between `dialog.rsp[0x4e]` and `dialog.rsp[0x4f]` based on whether `HANDMIRROR` is already in inventory, then either kills Dark Woman, falls through to `0x4cfc[2]` when the mirror is absent, or runs the shared reveal branch `0x4d03[2] -> C109.FST -> 0x4d0d[3] -> hide HANDMIRROR -> SET_D_W_FLAG`. Patched the handler to mirror that control flow, preserved the native wrong-item fallthrough to the intro gate, renamed the directly evidenced FST wrapper `FUN_00038ac0` at `0x38ac0` to `play_c109_fst_sequence`, saved `HARVEST.LE`, and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Live progress is now `928` total functions, `809` named/documented, and `119` remaining `FUN_*`.
-- On March 23, 2026, reran the full durable `repair DARK_WOMAN -> wait -> save_program -> verify DARK_WOMAN` cycle against the live `HARVEST.LE` session. `handle_talk_to_dark_woman @ 0x33610` started as a truncated `0x33610..0x33819` body (`94` address-count) with `2` reachable dialogue calls and `2` stale `CALL_RETURN` gaps. A single repair pass rebuilt it cleanly to `0x33610..0x33819` (`502` address-count) with `10` reachable dialogue calls, `gapCount=0`, and `5` repair actions. After the normal settle and explicit save, a fresh `verify DARK_WOMAN` still reported `status=clean` with both dialogue entry points returning, so Dark Woman is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 23, 2026, reimplemented the ScummVM Curator dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_curator` (`0x34ca0..0x34d3c`). The old stub in [`engines/harvester/npc/curator_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/curator_dialogue.cpp) only played `0x425[1]`, while the recovered native flow is `0x425[1]`, response menu `0x4a`, branch line `0x430[3]` or `0x435[0]`, then the shared tail `0x43a[0] -> 0x43b[0] -> PC 0x447[0] -> 0x44b[3]`. Patched the ScummVM handler to mirror that exact sequence, and checked the Curator-adjacent Ghidra symbols while auditing the path, but no encountered `FUN_*` function met the project's confidence bar for renaming. Live progress remains `927` total functions, `808` named/documented, and `119` remaining `FUN_*`.
-- On March 23, 2026, re-audited the ScummVM Cloak Attendant dialogue handler against the live `HARVEST.LE` Ghidra body at `handle_talk_to_cloak_atnd` (`0x2db80..0x2dbce`). The native handler is still only a two-branch worker: if `g_dialogue_presented_object_name` is set and matches `BARCASHFIVE`, dispatch `CLEAN_CLOTHES`; otherwise, on empty-hand talks, suppress dialogue once `CLEANED_CLOTHES` is set and fall through to `CLOAK_ATND 0x1066` while the flag is clear. The existing ScummVM logic already matched that recovered behavior, so the source patch in [`engines/harvester/npc/cloak_atnd_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/cloak_atnd_dialogue.cpp) was limited to reordering the branches and naming the constants so the handler mirrors the native item-first flow explicitly. I also checked the directly adjacent Ghidra symbols on this path, but no encountered `FUN_*` function met the project confidence bar for renaming. Live progress remains `927` total functions, `808` named/documented, and `119` remaining `FUN_*`.
-- On March 23, 2026, reran the full durable `repair CURATOR -> wait -> save_program -> verify CURATOR` cycle against the live `HARVEST.LE` session. `handle_talk_to_curator @ 0x34ca0` started as a truncated `0x34ca0..0x34cb5` body (`22` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x34ca0..0x34d3c` (`157` address-count) with `6` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify CURATOR` still reported `status=clean` with both dialogue entry points returning, so Curator is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 23, 2026, reran the full durable `repair CLOAK_ATND -> wait -> save_program -> verify CLOAK_ATND` cycle against the live `HARVEST.LE` session. `handle_talk_to_cloak_atnd @ 0x2db80` started as a `0x2db80..0x2dbce` body (`79` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap at the final `play_dialogue_line`. A single repair pass cleared the stale flow metadata in place, leaving the same `79`-address body but restoring `gapCount=0` with `2` repair actions. After the normal settle and explicit save, a fresh `verify CLOAK_ATND` still reported `status=clean` with both dialogue entry points returning, so Cloak Attendant is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body metadata repair rather than a symbol-count change.
-- On March 23, 2026, re-audited the ScummVM Ryder dialogue handler against the live `HARVEST.LE` Ghidra body at `handle_talk_to_ryder` (`0x38c80..0x391fa`). The existing port already matched Ryder's rebuilt control flow, response menus, and FST sequence order, but the presented-item branches were still missing three native shared-state writes: `PHOTO_OF_WHALEY_HERRILL` only set the startup-script flag instead of also setting `discussedWhaleyHerrillPhoto`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` omitted `discussedCasketPhotoEvidence`, and note/checkbook evidence omitted `discussedNoteCheckbookEvidence`. Patched [`engines/harvester/npc/ryder_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/ryder_dialogue.cpp) to restore those native side effects, confirmed the topic/response lines from `DIALOG.RSP` (`0x29c..0x2aa`) while auditing the keyword loop, renamed the Ryder-only FST dispatcher `FUN_000386b0` at `0x386b0` to `play_ryder_dialogue_fst_sequence`, saved `HARVEST.LE`, and refreshed live progress to `927` total functions, `808` named/documented, and `119` remaining `FUN_*`.
-- On March 23, 2026, reran the full durable `repair RYDER -> wait -> save_program -> verify RYDER` cycle against the live `HARVEST.LE` session. `handle_talk_to_ryder @ 0x38c80` started as a truncated `0x38c80..0x38f59` body (`395` address-count) with `7` reachable dialogue calls and `7` stale `CALL_RETURN` gaps. The repair needed three in-memory passes: pass 1 expanded the handler to `0x38c80..0x391fa` with `23` dialogue calls and `4` remaining gaps, pass 2 kept the same body end but exposed `37` dialogue calls with `1` remaining gap, and pass 3 finished the rebuild cleanly at `0x38c80..0x391fa` (`1403` address-count) with `37` reachable dialogue calls, `gapCount=0`, and `25` total repair actions. After the normal settle and explicit save, a fresh `verify RYDER` still reported `status=clean` with both dialogue entry points returning, so Ryder is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 23, 2026, reimplemented the ScummVM Chessmaster dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_chessmaster` (`0x2d900..0x2d9ac`). The old stub in [`engines/harvester/npc/chessmaster_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/chessmaster_dialogue.cpp) only played `0x4115`, while the recovered native flow is `0x4115`, response menu `0x22`, line `0x4122[1]` before a three-stage fullscreen chess puzzle, then either the success tail `CHESS_SOLVED -> C125.FST -> queued Chessmaster slash death` or the failure tail `0x4126[2] -> ADD_CHESS_KING_MONSTER -> KINGGROW.FST`. Patched the ScummVM handler to mirror that control flow, implemented the native `CHOOSE1/2/3.FLC` plus `CHESMOV1/2/3.FLC` puzzle sequence with the confirmed hotspot rectangles from Ghidra, renamed `FUN_0005a550` at `0x5a550` to `run_chessmaster_three_move_puzzle`, saved `HARVEST.LE`, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Live progress is now `926` total functions, `807` named/documented, and `119` remaining `FUN_*`.
-- On March 23, 2026, reran the full durable `repair CHESSMASTER -> wait -> save_program -> verify CHESSMASTER` cycle against the live `HARVEST.LE` session. `handle_talk_to_chessmaster @ 0x2d900` started as a truncated `0x2d900..0x2d914` body (`21` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. The repair needed two in-memory passes: pass 1 expanded the handler to `0x2d900..0x2d9ac` with `3` dialogue calls but left `1` newly exposed gap, and pass 2 finished the rebuild cleanly at `0x2d900..0x2d9ac` (`173` address-count) with `3` reachable dialogue calls, `gapCount=0`, and `6` total repair actions. After the normal settle and explicit save, a fresh `verify CHESSMASTER` still reported `status=clean` with both dialogue entry points returning, so Chessmaster is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 23, 2026, reimplemented the ScummVM Beggar dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_beggar` (`0x34d40..0x34e29`). The old stub in [`engines/harvester/npc/beggar_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/beggar_dialogue.cpp) collapsed the native flow into the `BEGGAR_DIALOG_3` action-tag dispatch plus a single `0x1276[1]` line, while the recovered handler is a fixed two-response exchange: `0x1276[1] -> 0x127a[2] -> 0x127e[0]`, response line `0x0` selecting `0x128d[1]` or `0x1291[3]`, then `0x1295[0]`, response line `0x1`, and `0x12ab[2]` or `0x12a5[2]`. Patched [`engines/harvester/npc/beggar_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/beggar_dialogue.cpp) to match that control flow, kept the native `BEGGAR_DIALOG_3` action-tag dispatch at the top, and renamed the directly evidenced live-entity tail helper `FUN_0007ca90` at `0x7ca90` to `queue_live_named_npc_death_or_monsterfy_transition`. That helper only resolves the named live NPC entity and queues the already-documented actor state `0x35`, so the current ScummVM port still relies on the action-tag interaction path for room-state mutation while the exact visual tail remains an engine-side follow-up rather than a guessed rewrite. Live progress is now `919` total functions, `806` named/documented, and `113` remaining `FUN_*`.
-- On March 23, 2026, reran the full durable `repair BEGGAR -> wait -> save_program -> verify BEGGAR` cycle against the live `HARVEST.LE` session. `handle_talk_to_beggar @ 0x34d40` started as a truncated `0x34d40..0x34e29` body (`55` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x34d40..0x34e29` (`234` address-count) with `6` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify BEGGAR` still reported `status=clean` with both dialogue entry points returning, so Beggar is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 23, 2026, recreated [`OPCODES.md`](/Users/alex/Workspace/scummvm/OPCODES.md) in the repo and expanded it with explicit documentation for the Harvester script command-label model. The new notes distinguish `triggerTag` as the stored `COMMAND` label from the interpreter's runtime `currentTag`, cite the exact lookup and execution sites in `script.cpp`, and add concrete straight-line, branching, and deferred-continuation examples so the command graph is described in terms of how ScummVM actually walks it. Progress counts are unchanged because this was documentation only.
-- On March 23, 2026, reimplemented the ScummVM Authority dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_authority` (`0x2b730..0x2b7b2`). The confirmed source drift in [`engines/harvester/npc/authority_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/authority_dialogue.cpp) was small but real: the old stub collapsed the native `IF_DON_T_EAT_THE_FOOD` branch into a single `0x3992` bark, while the recovered handler actually falls through to a second `0x399b` Authority line when `IF_YOU_EAT_THE_FOOD_AND_DEFEAT_THE_ENEMIES` is also set. Patched the ScummVM handler to preserve the native early `STOP_AUTHOR_TALK` exit, restore that two-line food branch, and keep the `0x398a` versus `0x399b` fallback when the earlier food flag is clear. While auditing the same dialogue helper cluster, also renamed the directly evidenced one-call `play_fst_sequence` wrappers at `0x38700`, `0x38710`, and `0x38720` to `play_c101_fst_sequence`, `play_c021_fst_sequence`, and `play_c110_fst_sequence`, then saved `HARVEST.LE`. Live progress is now `919` total functions, `805` named/documented, and `114` remaining `FUN_*`.
-- On March 23, 2026, reran the full durable `repair AUTHORITY -> wait -> save_program -> verify AUTHORITY` cycle against the live `HARVEST.LE` session because Authority had regressed back to the short body in the saved database. `handle_talk_to_authority @ 0x2b730` started again as a truncated `0x2b730..0x2b7b2` body (`95` address-count) with `2` reachable dialogue calls and `2` stale `CALL_RETURN` gaps. A single repair pass rebuilt it cleanly to `0x2b730..0x2b7b2` (`131` address-count) with `3` reachable dialogue calls, `gapCount=0`, and `4` repair actions. After the normal settle and explicit save, a fresh `verify AUTHORITY` still reported `status=clean` with both dialogue entry points returning, so Authority is now durably clean again in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 23, 2026, reran the full durable `repair FIREMAN2 -> wait -> save_program -> verify FIREMAN2` cycle against the live `HARVEST.LE` session. `handle_talk_to_fireman2 @ 0x33940` started as a truncated `0x33940..0x33979` body (`58` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x33940..0x339e9` (`170` address-count) with `3` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify FIREMAN2` still reported `status=clean` with both dialogue entry points returning, so Fireman2 is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 23, 2026, reimplemented the ScummVM Fireman2 dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_fireman2` (`0x33940..0x33979`) and `reset_talk_to_fireman2_state` (`0x33a10..0x33a25`). The native dialogue logic itself is still only a two-line switch: when `g_fireman2_talk_state_block` is nonzero, Fireman2 plays `0x5b9[2]` and clears that block; otherwise the handler plays `0x5c7[2]`. The real source drift in [`engines/harvester/npc/fireman2_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman2_dialogue.cpp) was state fidelity rather than missing branches: native reset clears a three-dword local state block, while ScummVM only modeled the first flag. Patched [`engines/harvester/npc/fireman2_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman2_dialogue.h) and [`engines/harvester/npc/fireman2_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman2_dialogue.cpp) to carry the full local block (`talkStateBlock`, `dialogueStateD2de0`, `dialogueStateD2de4`) and serialize the two additional native ints starting with save version `12`; bumped the Harvester save blob version to `12` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp); rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully; and inspected the directly related Ghidra path for unnamed helpers, but no `FUN_*` function on the Fireman2 route met the project evidence bar for renaming. Live progress counts remain `919` total functions, `802` named/documented, and `117` remaining `FUN_*`.
-- On March 23, 2026, reimplemented the ScummVM Fireman1 dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_fireman1` (`0x33850..0x338f6`) and `reset_talk_to_fireman1_state` (`0x33920..0x33937`). The recovered native flow is still itemless, but the old stub in [`engines/harvester/npc/fireman1_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman1_dialogue.cpp) was missing both native one-shot follow-ups: after the first-talk `0x453` or revisit `0x463` bark, Fireman1 now conditionally plays `0x45a[3]` when `STEPH_MIDGAME_PLAYED` is set and `0x46a[2]` when `BOLT_OF_CLOTH_TAKEN` is set, each guarded by Fireman1-local room state. Patched [`engines/harvester/npc/fireman1_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman1_dialogue.cpp), extended Fireman1's save-backed room state in [`engines/harvester/npc/fireman1_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/fireman1_dialogue.h), bumped the Harvester save version to `11` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) for the two new local state bits, and renamed the directly evidenced Fireman1 globals at `0xd2dd4` and `0xd2dd8` to `g_fireman1_steph_midgame_followup_shown` and `g_fireman1_bolt_of_cloth_followup_shown`. No standalone `FUN_*` helper surfaced in this analysis thread, so live progress remains `919` total functions, `802` named/documented, and `117` remaining `FUN_*`.
-- On March 23, 2026, reran the full durable `repair FIREMAN1 -> wait -> save_program -> verify FIREMAN1` cycle against the live `HARVEST.LE` session. `handle_talk_to_fireman1 @ 0x33850` started as a truncated `0x33850..0x33883` body (`52` address-count) with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap. A single repair pass rebuilt it cleanly to `0x33850..0x338f6` (`167` address-count) with `3` reachable dialogue calls, `gapCount=0`, and `3` repair actions. After the normal settle and explicit save, a fresh `verify FIREMAN1` still reported `status=clean` with both dialogue entry points returning, so Fireman1 is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 23, 2026, reimplemented the ScummVM Buster dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_buster` (`0x2b7c0..0x2c575`) and `reset_talk_to_buster_state` (`0x2c600..0x2c613`). The recovered native flow was much wider than the old stub in [`engines/harvester/npc/buster_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/buster_dialogue.cpp): item handling now matches the native casket-photo follow-up and blackmail response menu, no-item dialogue restores the full `0x163c..0x168e` opener plus the `0x1828..0x185e` / `0x18c5..0x18cd` follow-ups, the looping keyword tree now covers the recovered `dialog.rsp` topic groups `0x28/0x29`, `0x2c/0x2d`, `0x32`, `0x34`, `0x36`, `0x38`, `0x3b`, `0x3e`, `0x40/0x41`, `0x44/0x45/0x46`, `0x48`, and `0x49`, and Buster's fatal branches now run the native `C086.FST` blackout plus a restored game-over screen instead of falling through to short barks. Patched [`engines/harvester/npc/buster_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/buster_dialogue.cpp), extended Buster's save-backed room state in [`engines/harvester/npc/buster_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/buster_dialogue.h), added a reusable dialogue-side game-over helper in [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp) and [`engines/harvester/npc/dialogue_runtime.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dialogue_runtime.h), bumped the Harvester save blob version to `10` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) for the new Buster-local state bit, rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and renamed the directly encountered live Ghidra `FUN_*` helpers to `play_c085_fst_sequence` (`0x387d0`), `play_c086_blackout_fst_sequence` (`0x387e0`), and `play_c058_fst_sequence` (`0x38800`). After saving `HARVEST.LE`, live progress stands at `919` total functions, `802` named/documented, and `117` remaining `FUN_*`.
-- On March 23, 2026, reran the full durable `repair BUSTER -> wait -> save_program -> verify BUSTER` cycle against the live `HARVEST.LE` session. `handle_talk_to_buster @ 0x2b7c0` started as a truncated `0x2b7c0..0x2c56c` body (`449` address-count) with `8` reachable dialogue calls and `8` stale `CALL_RETURN` gaps. The repair needed two in-memory passes: pass 1 expanded the handler to `0x2b7c0..0x2c575` with `95` dialogue calls but left `1` newly exposed gap, and pass 2 finished the rebuild cleanly at `0x2b7c0..0x2c575` (`3510` address-count) with `95` reachable dialogue calls, `gapCount=0`, and `19` total repair actions. After the normal settle and explicit save, a fresh `verify BUSTER` still reported `status=clean` with both dialogue entry points returning, so Buster is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 23, 2026, re-audited the ScummVM Sparky dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_sparky` (`0x39240..0x39652`) and `reset_talk_to_sparky_state` (`0x39690..0x396c5`). The native body showed two concrete source drifts in [`engines/harvester/npc/sparky_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sparky_dialogue.cpp): most of Sparky's talk branches were still using the wrong portrait/head variants, including the light-switch bark, item rejection, intro exchange, return-visit lines, and the `0x34c`, `0x37a`, `0x38f`, `0x39e`, and `0x3c1` keyword replies; and the ScummVM port was incorrectly seeding a persistent keyword buffer from `dialog.rsp[0x2c3]`, while the native handler starts each conversation with an empty keyword list and only rewrites that list to `dialog.rsp[0x2c9]` after topic `0x2c8`. Patched [`engines/harvester/npc/sparky_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sparky_dialogue.cpp) to match the recovered variants and one-call topic rewrite, rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and checked the directly encountered `FUN_*` symbols surfaced during the keyword-menu trace. Those `FUN_*` hits (`FUN_0002e3b4`, `FUN_0002e41b`, and `FUN_0002f7fd`) are overlapping mid-body dialogue entries inside larger Herrill and Mrs. Potts handlers rather than clean standalone helpers, so no new rename met the project evidence bar without first repairing the underlying function boundaries. Live progress now reflects the current saved database totals of `916` functions, `799` documented, and `117` remaining `FUN_*`.
-- On March 23, 2026, reran the full durable `repair SPARKY -> wait -> save_program -> verify SPARKY` cycle against the live `HARVEST.LE` session. `handle_talk_to_sparky @ 0x39240` started as a truncated `0x39240..0x39649` body (`559` address-count) with `10` reachable dialogue calls and `10` stale `CALL_RETURN` gaps. The first repair pass rebuilt Sparky cleanly in-session to `0x39240..0x39652` (`860` address-count, `18` dialogue calls, `20` repair actions), but the first saved verify snapped back to the old body and also flipped both dialogue entry points back to non-returning. An immediate second full cycle fixed it: the retry repair rebuilt `handle_talk_to_sparky` cleanly again (`21` repair actions on the retry pass), and after the second settle and explicit save a fresh `verify SPARKY` held at `0x39240..0x39652` with `gapCount=0`, `status=clean`, and both dialogue entry points returning. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 23, 2026, re-audited the ScummVM Phelps dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_phelps` (`0x29920..0x2a0ef`) and `reset_talk_to_phelps_state` (`0x2a110..0x2a139`). The branch structure in [`engines/harvester/npc/phelps_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/phelps_dialogue.cpp) was already close, but the native body showed two concrete source drifts: most of Phelps's talk branches were still using the wrong portrait/head variants, including the intro, default revisit, Whaley photo, casket photo, quarter, blackmail evidence, Jimmy-death follow-up, and the one-shot event barks for `STEPH_MIDGAME_PLAYED`, day 5, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `PC_ESCAPED_JAIL`, `GOT_REMAINS_FOR_LODGE`, Butcher death, Moynahan death, and both Karin outcomes; and the engine was setting shared dialogue state `d2f04` before the entire intro instead of after native line `0x1b56` and before the final `PC 0x1b5a` line. Patched [`engines/harvester/npc/phelps_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/phelps_dialogue.cpp) to match the recovered variants and intro ordering, rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and checked the directly related Ghidra callees; no new `FUN_*` rename in this thread met the project evidence bar, so live progress remains `913` total functions, `796` named/documented, and `117` remaining `FUN_*`.
-- On March 23, 2026, reran the full durable `repair PHELPS -> wait -> save_program -> verify PHELPS` cycle against the live `HARVEST.LE` session. `handle_talk_to_phelps @ 0x29920` started as a truncated `0x29920..0x29cac` body (`501` address-count) with `8` reachable dialogue calls and `8` stale `CALL_RETURN` gaps. The repair needed two in-memory passes: pass 1 expanded the handler to `0x29920..0x2a0ef` with `43` dialogue calls but left `2` newly exposed gaps, and pass 2 finished the rebuild cleanly at `0x29920..0x2a0ef` (`1849` address-count) with `43` reachable dialogue calls, `gapCount=0`, and `20` total repair actions. After the normal settle and explicit save, a fresh `verify PHELPS` still reported `status=clean` with both dialogue entry points returning, so Phelps is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 23, 2026, re-audited the live `HARVEST.LE` Nude Man path in Ghidra instead of treating the existing ScummVM port as settled. `handle_talk_to_nude_man` (`0x2d9b0..0x2db3e`) still matched the repaired native dialogue body closely, but the Ghidra helper call at `0x2da7d` showed the second response menu is gated by `get_set_sergeant_completed_first_task_state @ 0x384e0`, not by the broader Sergeant shared bit `d2f08` that the engine was using. Patched [`engines/harvester/npc/nude_man_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/nude_man_dialogue.cpp) to read the dedicated `sergeantCompletedFirstTaskState`, updated [`engines/harvester/npc/sergeant_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.cpp) to set that state on the native `SCRATCHED_TUCKER` completion branch, and added a load-time shared-state migration hook in [`engines/harvester/dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/dialogue.cpp), [`engines/harvester/npc/dialogue_handler.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dialogue_handler.h), and [`engines/harvester/npc/sergeant_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/sergeant_dialogue.h) so older ScummVM saves that only persisted Sergeant's legacy local bool still unlock Nude Man correctly after load. No new `FUN_*` helpers were touched in this thread, so live Ghidra progress remains unchanged at `913` functions, `796` named/documented, and `117` remaining `FUN_*`.
-- On March 23, 2026, reran the full durable `repair NUDE_MAN -> wait -> save_program -> verify NUDE_MAN` cycle against the live `HARVEST.LE` session. `handle_talk_to_nude_man @ 0x2d9b0` started as a truncated `0x2d9b0..0x2db39` body (`96` address-count) with `3` reachable dialogue calls and `3` stale `CALL_RETURN` gaps. A single repair pass rebuilt it cleanly to `0x2d9b0..0x2db3e` (`399` address-count) with `14` reachable dialogue calls, `gapCount=0`, and `6` repair actions. After the normal settle and explicit save, a fresh `verify NUDE_MAN` still reported `status=clean` with both dialogue entry points returning, so Nude Man is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 23, 2026, reimplemented the ScummVM Parsons dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_parsons` (`0x397c0..0x39f1b`) and `reset_talk_to_parsons_state` (`0x39f40..0x39f69`). The recovered native flow was much wider than the old evidence stub: no-item dialogue now runs the full `0xdcc/0xdd2/0xdd6/0xde3` intro with Swell, the one-shot event barks for `STEPH_MIDGAME_PLAYED`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `DINER_BURNED`, `BURNED_TV_STATION`, and `KARIN_KIDNAPED`, and the looping `dialog.rsp` keyword branches for `Pole`, `Alarm System`, and the `Aliens` / `Pastorelli` group with the native `C093.FST` and `C094.FST` inserts. The item logic also had three native continuations the stub was missing: `CASKET_PHOTO` / `CASKET_PHOTOCOPY` optionally extends through `PC 0xe84 -> PARSONS 0xe88` when shared state `d2ebc` is already set, note/checkbook evidence is the three-line `0xe95 -> 0xe99 -> 0xe9d` exchange rather than a single bark, and all native portrait variants now match the live assembly. Patched [`engines/harvester/npc/parsons_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/parsons_dialogue.cpp), extended Parsons's save-backed room state in [`engines/harvester/npc/parsons_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/parsons_dialogue.h), bumped the Harvester save blob version to `9` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) so the six new Parsons-local one-shot bits persist cleanly, and renamed the two directly referenced `FUN_*` wrappers in the live Ghidra database to `play_c093_fst_sequence` (`0x38730`) and `play_c094_fst_sequence` (`0x38740`) because each is a single-call wrapper around the exact `GRAPHIC/FST/C093.FST` / `GRAPHIC/FST/C094.FST` assets. Progress now aligns with the saved live database totals (`913` functions, `796` documented, `117` remaining `FUN_*`).
-- On March 23, 2026, reran the full durable `repair PARSONS -> wait -> save_program -> verify PARSONS` cycle against the live `HARVEST.LE` session. `handle_talk_to_parsons @ 0x397c0` started as a truncated `0x397c0..0x39a7a` body (`468` address-count) with `7` reachable dialogue calls and `7` stale `CALL_RETURN` gaps. The repair needed two in-memory passes: pass 1 expanded the handler to `0x397c0..0x39f1b` with `32` dialogue calls but left `7` newly exposed gaps, and pass 2 finished the rebuild cleanly at `0x397c0..0x39f1b` (`1884` address-count) with `43` reachable dialogue calls, `gapCount=0`, and `28` total repair actions. After the normal settle and explicit save, a fresh `verify PARSONS` still reported `status=clean` with both dialogue entry points returning, so Parsons is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 23, 2026, reimplemented the ScummVM Mother dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mother` (`0x2b5b0..0x2b66f`) plus `reset_talk_to_mother_state` (`0x2b690..0x2b69a`). The recovered native flow was not the old one-line bark: it gates on a single local talk-state bit, plays `0x26d6[3]`, runs `C072.FST`, plays `0x26e9[0]`, and opens response menu `0x173`; response `1` exits through `0x26f5[3] -> C073.FST`, response `2` exits through `C073.FST -> 0x2709[3] -> C072.FST -> 0x2714[0] -> 0x2719[3]`, and any other menu result falls through silently after the first menu. Patched [`engines/harvester/npc/mother_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mother_dialogue.cpp), rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and renamed the two directly referenced `FUN_*` wrappers in the live Ghidra database to `play_dialogue_c072_fst_sequence` (`0x38ae0`) and `play_dialogue_c073_fst_sequence` (`0x38910`) because each is a single-call wrapper around the exact `GRAPHIC/FST/C072.FST` / `GRAPHIC/FST/C073.FST` assets. After saving `HARVEST.LE`, progress now aligns with the live database totals (`913` functions, `794` documented, `119` remaining `FUN_*`).
-- On March 23, 2026, reran the full durable `repair MOTHER -> wait -> save_program -> verify MOTHER` cycle against the live `HARVEST.LE` session. `handle_talk_to_mother @ 0x2b5b0` started as a truncated `0x2b5b0..0x2b66f` stub with `1` reachable dialogue call and `1` stale `CALL_RETURN` gap at `0x2b5d6`, then rebuilt cleanly on the first pass to `0x2b5b0..0x2b66f` (`192` address-count, `6` dialogue calls, `3` repair actions). After the normal settle and explicit save, a fresh `verify MOTHER` still reported `gapCount=0`, `status=clean`, and both dialogue entry points returning, so Mother is now durably clean in the saved database. Progress counts are unchanged because this was a handler-body recovery rather than a symbol-count change.
-- On March 23, 2026, fixed [`InspectCallTermination.java`](/Users/alex/ghidra_scripts/InspectCallTermination.java) to recognize exact `dispatch_action_tag_if_set` talk stubs as valid data-driven handlers instead of false `noDialogueCalls` repair failures. `handle_talk_to_pastorelli @ 0x38160` is one of those cases: the live `HARVEST.LE` body is an exact 13-byte stub (`0x38160..0x3816c`) that loads `PASTORELLI_TALK`, calls `dispatch_action_tag_if_set`, and returns, while the real dialogue work is deferred into `dispatch_room_event_actions`, whose opcode `0x1a` path runs NPC dialogue from room-command data. After the script patch, both `repair PASTORELLI` and a fresh `verify PASTORELLI` report `dispatchOnly=true`, `gapCount=0`, and `status=clean` with no database repair actions required. Progress counts are unchanged because the Ghidra program state did not change; this was a verifier classification fix.
-- On March 23, 2026, reimplemented the ScummVM Boyle dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_boyle` (`0x2cb20..0x2d885`) plus `reset_talk_to_boyle_state` (`0x2d8a0..0x2d8b7`). The confirmed source drift was concentrated in five native areas the earlier port had flattened or miswired: the handler was assigning the wrong speakers on Boyle's intro, keyword, button, gascan, blackmail, and burned-TV exchanges, especially the native PC turns at `0xb`, `0x15a`, `0x179`, `0x1a9`, `0x202`, `0x28e`, and the `0x8c` / `0x94` / `0x9d` / `0xee` / `0xf6` / `0x12c` keyword branches; the port was missing Boyle's sixth save-backed local bit at `0xd2cbc`, so intro response menu `0x7` choice `2` was incorrectly writing shared `d2ebc` instead of a Boyle-local evidence-reply gate that later changes the `BOYLES_BUTTON` and note/checkbook continuations; the item paths were mutating the wrong runtime state by setting generic evidence flags that native never touches here, by resetting `BOYLES_BUTTON` / `GASCAN` to their initial startup records instead of simply hiding them from inventory, and by clearing the Boyle gascan-application bit plus reasserting `SHERIFF_IN_DINER` during the gascan reward even though native only hides `GASCAN`, awards `LODGE_APPLICATION`, and sets `HAVE_LODGE_APP`; the post-intro no-item evidence follow-up was gated off the wrong ScummVM flag instead of native shared state `discussedNoteCheckbookEvidence`; and the intro response-menu `0x5` cancel path was incorrectly seeding the button keyword buffer even though native only seeds `0xa` after the two handled intro branches. Patched [`engines/harvester/npc/boyle_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/boyle_dialogue.cpp), extended Boyle's save-backed room state in [`engines/harvester/npc/boyle_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/boyle_dialogue.h), bumped the Harvester save blob version to `8` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) so the new Boyle-local bit persists cleanly, corrected the confirmed Boyle wrapper notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts remain aligned with the current live Ghidra database totals (`912` functions, `120` remaining `FUN_*`).
-- On March 23, 2026, reran the full durable `repair BOYLE -> wait -> save_program -> verify BOYLE` cycle against the live `HARVEST.LE` session to confirm Boyle still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_boyle` rebuilt to `0x2cb20..0x2d885` (`3430` address-count, `85` dialogue calls, `52` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_boyle` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Boyle was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
-- On March 23, 2026, reimplemented the ScummVM Moynahan dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_moynahan` (`0x24a20..0x25f43`). The confirmed source drift was concentrated in five native areas the earlier port had flattened or miswired: the `IF_TRY_TO_TAKE_THE_GLUE` interrupt and several presented-item branches were using wrong head variants and were missing the native response-menu continuations for `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, both ledgers, note/checkbook evidence, and `WED_MATCHES`; the handler was writing the wrong shared state for Whaley-photo and blackmail evidence, rearming the intro off the wrong corpse-photo state, and omitting the `KARIN_KIDNAPED` tail's `0x4041` line plus the shared kidnapped-discussion writes; the late no-item bark chain after `0x3fd1` still had truncated control flow and non-native portrait variants on the slash/body/day-5/Butcher/Jimmy/Karin-dead branches; the typed keyword loop was incorrectly treated as mostly terminal single-line replies instead of a looping menu that rewrites the live topic buffer through `0x19c`, `0x1a1`, `0x1a6` / `0x1a7` / `0x1a8`, `0x1ad`, `0x1b0` / `0x1b1`, and `0x1b6`; and the day-5 glue-topic path `0x1bf..0x1c2` plus response menu `0x1c3` was missing its native `GLUE` inventory / `JUST_GOT_GLUE` side effects. Patched [`engines/harvester/npc/moynahan_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/moynahan_dialogue.cpp), preserved the existing save layout in [`engines/harvester/npc/moynahan_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/moynahan_dialogue.h), rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully, and refreshed the tracker progress totals from the current live Ghidra database (`912` functions, `120` remaining `FUN_*`).
-- On March 23, 2026, reran the full durable `repair MOYNAHAN -> wait -> save_program -> verify MOYNAHAN` cycle against the live `HARVEST.LE` session to confirm Moynahan still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_moynahan` rebuilt to `0x24a20..0x25f43` (`5254` address-count, `117` dialogue calls, `59` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_moynahan` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Moynahan was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
-- On March 23, 2026, reimplemented the remaining confirmed Herrill dialogue mismatches in ScummVM directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_herrill` (`0x2dc00..0x2e987`). The confirmed source drift was concentrated in four native areas the earlier port had flattened or misread: the `HERRILL_IN_LODGE` branch was truncated to a single `0x31ca` bark and was missing the native `0x31ce` / `0x31d2` / `0x31da` / `0x31de` / optional `0x31e0` / `0x31e2` sequence plus the `GRAND_MUCK` slash-transition and `PC_TALKED_TO_HERRILL` write; the `PHOTO_OF_WHALEY_HERRILL` item branch was incorrectly writing the shared Whaley/Herrill-photo runtime flag even though native only plays `0x309f`; the steady-state `KARIN_FOUND_ALIVE` / `KARIN_FOUND_DEAD` followups had non-native extra lines (`0x3050` / `0x3057` and `PC 0x461b`) that are absent from the real handler; and the keyword loop mishandled `The Lodge`, the second `BYE` line at `0x293`, and the unmatched typed-topic fallback that should run `0x3099` before `0x2ff4`. Patched [`engines/harvester/npc/herrill_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/herrill_dialogue.cpp), corrected the confirmed Herrill notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and refreshed the tracker progress totals from the current live Ghidra database (`912` functions, `120` remaining `FUN_*`).
-- On March 23, 2026, reran `repair HERRILL` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Herrill showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_herrill` cleanly to `0x2dc00..0x2e987` (`764` address-count, `17` dialogue calls under the current tail-jump-aware verifier) but the fresh verify fell back to the short `0x2dc00..0x2e987` body with only `4` reachable dialogue calls, `3` stale dialogue gaps, and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Herrill durably clean again at `0x2dc00..0x2e987` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Herrill now behaves like the broader post-save retry class rather than the old verifier-only anomaly.
-- On March 23, 2026, reimplemented the ScummVM Dad dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_dad` (`0x2b020..0x2b581`) plus `reset_talk_to_dad_state` (`0x2b5a0..0x2b5a6`) and the shared Dad permission helper at `get_set_dad_meat_permission_state` (`0x38400..0x3840f`). The confirmed source drift was concentrated in four native areas the earlier port had flattened or miswired: the `TAKING_BONDAGE` branch was missing Dad's second `0x3a6b` line and the intervening `C011.FST` tail after `0x3a61`; the item branches were writing the wrong ScummVM runtime evidence flags, missing Dad's multi-line Whaley-photo and note/checkbook exchanges, and omitting the `MEAT_PERMISSION0` conversion path that plays `0x39f2` / `0x39f7`, runs `C112S4.FST`, sets shared state `d2eb4`, adds `MEAT_PERMISSION`, and restores `MEAT_PERMISSION0` visibility in inventory; the first no-item visit was missing Dad's full `0x39a5` / `0x39a9` / `0x39ad` / `0x39b1[PC2]` / `0x39b5` intro plus the shared `d2f04` write; and the revisit path was incorrectly ending after `0x3a17` instead of running Dad's native Other-only keyword loop, which leaves the visible topic list empty, only special-cases typed `Lodge` / `The Lodge` through `0x3a7d` / `0x3a83` / `0x3a88`, and otherwise falls back to `0x3a2c`. Patched [`engines/harvester/npc/dad_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dad_dialogue.cpp), preserved the existing save layout in [`engines/harvester/npc/dad_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dad_dialogue.h), rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully, and refreshed the tracker progress totals from the current live Ghidra database (`915` functions, `123` remaining `FUN_*`).
-- On March 23, 2026, reran `repair DAD` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Dad showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_dad` cleanly to `0x2b020..0x2b581` (`954` address-count, `20` dialogue calls) but the fresh verify fell back to the short `0x2b020..0x2b2e9` body with `7` stale dialogue gaps and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Dad durably clean again at `0x2b020..0x2b581` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Dad belongs in the same post-save retry class as Stephanie, Mr. Potts, Johnson, Loomis, and similar handlers.
-- On March 23, 2026, reimplemented the ScummVM Butcher dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_butcher` (`0x2a180..0x2ad7c`) plus `reset_talk_to_butcher_state` (`0x2ada0..0x2adb0`). The confirmed source drift was concentrated in five native areas the earlier stub omitted: the first no-item visit was missing Butcher's full `0x1902..0x1921` intro sequence, the `dialogueStateD2ed0` conditional on `0x190f` vs `0x1913`, and the correct initial topic buffer; the revisit path was missing the native topic-menu gating on `MEAT_PERMISSION0` / `MEAT_PERMISSION` ownership and story day; the handler had no recovered keyword loop for topics `0x263`, `0x267`, `0x26b`, `0x26e`, `0x270`, `0x273/0x274`, `0x277`, and the `0x279` no-op path, including the `C119.FST`, `C060.FST`, `C119A.FST`, `C119B.FST`, `C123.FST`, `C123A.FST`, `C123B.FST`, and `C119C.FST` tails plus the menu-driven topic-buffer rewrites; the item branches for Whaley's photo, the corpse photo, both ledgers, note/checkbook evidence, and presented `MEAT_PERMISSION` were missing their native multi-line exchanges, the `C060A.FST` reward tail, and the `MEAT` inventory / `GOT_MEAT` side effects; and the native cross-handler state writes into `dad_meat_permission_state`, `dialogueStateD2ed0`, and `discussedLodgeTopic` were absent. Patched [`engines/harvester/npc/butcher_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/butcher_dialogue.cpp), extended Butcher's save-backed room state in [`engines/harvester/npc/butcher_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/butcher_dialogue.h), fixed Dad's two native shared-state dependencies in [`engines/harvester/npc/dad_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dad_dialogue.cpp), bumped the Harvester save format to `7` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts now align with the current live Ghidra database totals (`924` functions, `132` remaining `FUN_*`).
-- On March 23, 2026, reran the full durable `repair BUTCHER -> wait -> save_program -> verify BUTCHER` cycle against the live `HARVEST.LE` session to confirm Butcher still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_butcher` rebuilt to `0x2a180..0x2ad7c` (`2936` address-count, `60` dialogue calls, `42` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_butcher` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Butcher was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
-- On March 23, 2026, reimplemented the ScummVM Edna dialogue handler directly against the repaired live `HARVEST.LE` Ghidra body at `handle_talk_to_edna` (`0x358a0..0x3667b`) plus `reset_talk_to_edna_state` (`0x366c0..0x366e8`). The confirmed source drift was concentrated in four native areas the earlier port had truncated or flattened: the top-level `KILLED_KARIN1`, `DNA_S_SUICIDE_NOTE`, purse handoff, and `BRING_KARIN_TO_SHERIFF` branches were missing native flag clears plus the reward-money / `K_PURSE` room-state side effects; the first no-item visit was missing Edna's full `0x3a95` intro flow, the `0x3aa1..0x3aae` / `0x3ab2..0x3ac2` response branches, the `0x3ac6..0x3acf` shared tail, and the native `d2f04` write; the revisit path after `0x3bf8` / `0x3bff` was missing every recovered one-shot story branch for day 5, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `BOLT_OF_CLOTH_TAKEN`, `PC_ESCAPED_JAIL`, `BURNED_TV_STATION`, and the full `KARIN_FOUND_DEAD` nested response tree including the purse-dependent `0xa3` / `0xa4` menu split; and the entire keyword loop from `0xa5` through `0xc2` was absent, including the `C091.FST` and `C070.FST` topic exits plus the shared lodge / kidnapped-dialogue state writes. Patched [`engines/harvester/npc/edna_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/edna_dialogue.cpp), extended Edna's save-backed room flags in [`engines/harvester/npc/edna_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/edna_dialogue.h), bumped the Harvester save blob version in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp) so the new Edna state persists cleanly, and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Progress counts now align with the current live Ghidra database totals (`914` functions, `122` remaining `FUN_*`).
-- On March 23, 2026, reimplemented the ScummVM Karin dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_karin` (`0x2ea90..0x2ed7f`). The confirmed source drift was concentrated in four native paths the earlier port had flattened: the `KARIN_FOUND_ALIVE` + `IN_CEM10` branch is an eight-line early-return sequence (`0x10a5`..`0x10c3`) rather than a one-line opener that falls back into normal handling; the first no-item visit was missing Karin's full `0x1076`..`0x108b` exchange before the shared `0x10dc` tail; the photo evidence branch was missing Edna's `0x10e7` follow-up, the portrait reset, the one-shot local override bit, and the repeat-photo `DNALFT_PERVERT` / `CALL_POLICE` escalation while also incorrectly writing the broader runtime photo flags instead of Karin's native shared dialogue-state ints; and `INV_MAG` was missing Edna `0x10f2[2]` plus the same police side effects. Patched [`engines/harvester/npc/karin_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/karin_dialogue.cpp), corrected the confirmed Karin handler notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Progress counts remain aligned with the current live Ghidra database totals (`912` functions, `120` remaining `FUN_*`).
-- On March 23, 2026, reran `repair EDNA` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Edna showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_edna` cleanly to `0x358a0..0x3667b` (`3505` address-count, `64` dialogue calls) but the fresh verify fell back to the short `0x358a0..0x35d1f` body with `11` stale dialogue gaps and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Edna durably clean again at `0x358a0..0x3667b` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Edna belongs in the same post-save retry class as Stephanie, Mr. Potts, Johnson, Loomis, and similar handlers.
-- On March 23, 2026, reimplemented the ScummVM Dwayne dialogue handler directly against the repaired live `HARVEST.LE` Ghidra body at `handle_talk_to_dwayne` (`0x3a310..0x3c385`) plus `reset_talk_to_dwayne_state` (`0x3c490..0x3c531`). The confirmed source drift was concentrated in five native areas the earlier port had flattened or inverted: the top-level `ARREST_FLAG`, `CHECK_EVIDENCE_DOOR`, `ST_BEDRM`, and sheriff-in-diner intro/revisit flow was returning too early and missing native flag clears plus Edna/PC lines; the evidence-item branches for Boyle's button, Karin's purse, note/checkbook originals vs photocopies, ledgers, corpse photo, Whaley photo, and especially `TV_DEED` / `TV_DEED_PHOTOCOPY` were missing native menus, PC turns, confiscation side effects, and the real-deed vs photocopy split; the note/checkbook and TV-deed follow-up state was not wired to the native save-backed shared fields, so Dwayne's later `0x355f..0x3573` reaction and the Karin-purse gate were wrong after save/load; the one-shot event branches for `BARBER_POLE_STOLEN`, `DINER_BURNED`, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, and `KARIN_FOUND_DEAD` were truncated and skipped their recovered response menus; and the keyword loop still had native speaker/portrait drift on the Loomis, Stephanie-midgame, lodge, and `0x98` topic continuations. Patched [`engines/harvester/npc/dwayne_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/dwayne_dialogue.cpp) to match those confirmed branches and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts remain aligned with the current live Ghidra totals (`912` functions, `120` remaining `FUN_*`).
-- On March 23, 2026, reran the full durable `repair KARIN -> wait -> save_program -> verify KARIN` cycle against the live `HARVEST.LE` session to confirm Karin still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_karin` rebuilt to `0x2ea90..0x2ed7f` (`752` address-count, `21` dialogue calls, `14` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_karin` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Karin was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
-- On March 22, 2026, repaired `DWAYNE` durably in the live `HARVEST.LE` Ghidra database with the current `InspectCallTermination.java` workflow. The first `repair DWAYNE` pass expanded `handle_talk_to_dwayne` from the short `0x3a310..0x3c385` body with `59` reachable dialogue calls to `185` calls, but it stalled on one real fallthrough hole at `0x3baa7 -> 0x3baac` where the raw bytes were valid code and Ghidra had misaligned the instruction stream. Cleared and re-disassembled `0x3baac..0x3bb0b`, which restored the missing fallthrough head as `MOV EDX,0xb8c8e`, then reran `repair DWAYNE`; that final pass rebuilt `handle_talk_to_dwayne` cleanly to `0x3a310..0x3c385 (8219 bytes)` with `186` reachable dialogue calls, `gapCount=0`, and both `play_dialogue_line` entry points marked returning. After a short settle, saved `HARVEST.LE` and re-ran `verify DWAYNE`; the fresh verify still reported `status=clean`, so Dwayne is now confirmed durable in the saved database. Progress counts remain aligned with the current live Ghidra totals (`912` functions, `120` remaining `FUN_*`).
-- On March 22, 2026, reimplemented the remaining confirmed Johnson dialogue mismatches in ScummVM directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_johnson` (`0x27dd0..0x28802`). The confirmed source drift was concentrated in four native areas the earlier port had flattened or miswired: the Whaley-photo, casket-photo, and dual-ledger item branches were missing their native follow-up lines and PC speaker turns (`0xb7b`, `0xb80`, `0xb90`, `0xba4`, `0xba8`, plus the conditional `0xb94` / `0xb99` casket tail behind shared state `d2ebc`); the `OPENING_MANHOLE_DAYTIME` short-circuit was not clearing its runtime flag after `0xbe0`; the `SCRATCHED_TUCKER` re-entry was incorrectly dropping straight back into the intro instead of playing the native `0xc25` / `0xc2b` / `0xc30` sequence, preserving the re-armed intro gate for a later visit, and conditionally running Johnson's local `BOLT_OF_CLOTH_TAKEN` one-shot `0xc5f` / `0xc69`; and the `KARIN_KIDNAPED` branch was writing the wrong shared state, using `d2f04` instead of the real shared kidnapped-dialogue bit. Patched [`engines/harvester/npc/johnson_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/johnson_dialogue.cpp), added the missing Johnson-local save bit with versioned serialization in [`engines/harvester/npc/johnson_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/johnson_dialogue.h), bumped Harvester save format version to `5` in [`engines/harvester/saveload.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/saveload.cpp), updated the confirmed Johnson notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts remain aligned with the current live Ghidra database totals (`912` functions, `120` remaining `FUN_*`).
-- On March 22, 2026, reimplemented the ScummVM Loomis dialogue handler directly against the live `HARVEST.LE` Ghidra bodies at `handle_talk_to_loomis` (`0x34f80..0x35826`) and `reset_talk_to_loomis_state` (`0x35870..0x3588d`). The confirmed source drift was concentrated in four native areas the earlier port had flattened: the no-item intro path was still treating `0x1112` as unconditional, substituting a nonexistent `0x111e` for native `0x1118`, and skipping response menu `0x53` plus Loomis `0x1130[3]`; revisit conversations were returning before the native keyword loop and therefore missed the `STEPH_MIDGAME_PLAYED` one-shot `0x1230[3]`, the day-5 bark `0x123c`, and the corrected `0x11b0[3]` follow-up; the keyword loop still used the wrong exit/fallback flow (`0x113a` instead of `0x11a1[1]`, no `0x11fc` unknown-topic bark, and no native aliases for `LOOMIS`, `Phelps`, or `Man's Needs`) along with the wrong portrait variants on `0x114f..0x1153`, `0x1162..0x1163`, `0x1170`, and `0x1196`; and the evidence / dirty-magazine item paths were missing the native response menu tails (`0x50 -> 0x1214/0x1219 -> 0x121f[3]` plus `PC_FRY_DADDY_1`) and the corrected `INV_MAG` response variants and room-state mutations. Patched [`engines/harvester/npc/loomis_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/loomis_dialogue.cpp) and [`engines/harvester/npc/loomis_dialogue.h`](/Users/alex/Workspace/scummvm/engines/harvester/npc/loomis_dialogue.h), and rebuilt [`scummvm`](/Users/alex/Workspace/scummvm/scummvm) successfully. Progress counts now reflect the current live Ghidra database totals (`912` functions, `120` remaining `FUN_*`).
-- On March 22, 2026, reran `repair JOHNSON` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Johnson showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_johnson` cleanly to `0x27dd0..0x28802` (`2539` address-count, `51` dialogue calls) but the fresh verify fell back to the short `0x27dd0..0x287f9` body with `12` stale dialogue gaps and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Johnson durably clean again at `0x27dd0..0x28802` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Johnson belongs in the same post-save retry class as Stephanie, Mr. Potts, Loomis, Butcher, and similar handlers.
-- On March 22, 2026, reran `repair LOOMIS` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Loomis showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_loomis` cleanly to `0x34f80..0x35826` (`2071` address-count, `33` dialogue calls) but the fresh verify fell back to the short `0x34f80..0x35406` body with `9` stale dialogue gaps and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Loomis durably clean again at `0x34f80..0x35826` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Loomis belongs in the same post-save retry class as Stephanie, Mr. Potts, Johnson, Butcher, and similar handlers.
-- On March 22, 2026, reimplemented the remaining confirmed Mrs. Potts dialogue mismatches in ScummVM directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mrs_potts` (`0x2ee70..0x2f7be`) plus its overlapping keyword-tail region at `FUN_0002f7fd` (`0x2f7d8..0x2fc78`). The confirmed source drift was concentrated in two native areas the earlier port had flattened: several visible `play_dialogue_line` calls still used the wrong portrait variants (`0x2726`, `0x281b`, `0x2891`, the `0x294d` evidence bark, the `0x293c..0x2945` Whaley photo sequence, and the `0x289e..0x28c1` remains branches), and the `DINER_BURNED` one-shot was missing the rest of the native continuation (`0x2914` behind `KARIN_HUNG`, `0x2918`, response menu `0x236`, `0x2926`, and the response-2 jump back into the shared death-in-chair tail). Patched [`engines/harvester/npc/mrs_potts_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mrs_potts_dialogue.cpp), updated the confirmed Mrs. Potts notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts remain unchanged at `923` total functions and `124` remaining `FUN_*`.
-- On March 22, 2026, reran the full durable `repair MRS_POTTS -> wait -> save_program -> verify MRS_POTTS` cycle against the live `HARVEST.LE` session to confirm Mrs. Potts still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_mrs_potts` rebuilt to `0x2ee70..0x2fc81` (`2392` address-count during repair; fresh verify reports the saved body as `0x2ee70..0x2f7be` with `54` dialogue calls) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_mrs_potts` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Mrs. Potts was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
-- On March 22, 2026, reimplemented the ScummVM Mr. Potts dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mr_potts` (`0x26000..0x27d07`). The confirmed source drift was concentrated in native-only control flow that the earlier port had collapsed: the `HARVEST_BLADE` interrupt was missing the response menu plus `MONSTERFY_POTTS` tail, the `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE` interrupt was missing the full five-line exchange, `STEPH_MIDGAME_PLAYED` / `d2eb8` mutation, `C001B.FST`, and `STHLEXIT2B`, the `POTTSDAM_DIGGING` / `POTTSDAM_DIGGING_KARIN` branches were still truncated, every presented-item path was incorrectly falling through into the no-item conversation, the later-day Stephanie-alive revisit had the native day-2 through day-5 `0x2ae5` gate inverted, the `KARIN_KIDNAPED` response-2 path was checking `DAY_6` instead of `STEPH_MIDGAME_PLAYED`, the `KARIN_FOUND_DEAD` follow-up at `0x2b8d..0x2bb6` still used the wrong flag/speakers, and the hidden keyword-loop rewrites and response menus for `AMNESIA`, `MARRIAGE`, `COMMITMENT`, `FATHER`, `SLAUGHTERHOUSE`, `MEAT`, `STEPHANIE`, `LODGE`, `NEWSPAPER`, `MOYNAHAN`, `TOILET`, and `MOLEST` were still over-simplified. Patched [`engines/harvester/npc/mr_potts_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mr_potts_dialogue.cpp) to match those confirmed native branches and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts remain unchanged at `923` total functions and `124` remaining `FUN_*`.
-- On March 22, 2026, reran `repair MR_POTTS` with the current `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Mr. Potts showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_mr_potts` cleanly to `0x26000..0x27d07` (`6853` address-count, `152` dialogue calls) but the fresh verify fell back to the short `0x26000..0x26915` body with `15` stale dialogue gaps and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Mr. Potts durably clean again at `0x26000..0x27d07` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Mr. Potts belongs in the same post-save retry class as Stephanie, Johnson, Butcher, Dad, and similar handlers.
-- On March 22, 2026, reimplemented the ScummVM Jimmy dialogue handler directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_jimmy` (`0x28880..0x28ec1`). The confirmed source drift was all in native control-flow details: the first paper-handoff gate was missing `PC 0x4a52`, later no-item visits with `SNEAKERS` but no `BROOMKEY` were missing the native response menu `0xf8` and shared `0x4a86` / `0x4a8a` / `0x4a8e` branch, direct `SNEAKERS` use failed to return the shoes to `RAH` and award `BROOMKEY`, and the `0x4b00` corpse/ledger plus `0x4b21` blackmail evidence branches had been collapsed to single-line barks instead of the native multi-line exchanges. Patched [`engines/harvester/npc/jimmy_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/jimmy_dialogue.cpp), updated the confirmed Jimmy notes in [`ARCHITECTURE.md`](/Users/alex/Workspace/scummvm/ARCHITECTURE.md), and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts are unchanged at `923` total functions and `124` remaining `FUN_*`.
-- On March 22, 2026, reran the full durable `repair JIMMY -> wait -> save_program -> verify JIMMY` cycle against the live `HARVEST.LE` session to confirm Jimmy still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_jimmy` rebuilt to `0x28880..0x28ec1` (`1602` address-count, `39` dialogue calls, `27` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_jimmy` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Jimmy was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
-- On March 22, 2026, reimplemented the remaining confirmed Mom dialogue mismatches in ScummVM directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_mom` (`0x31140..0x33523`). Raw instruction windows over the native item and hidden-topic tails confirmed that two evidence branches were still over-ported (`CASKET_PHOTO` is only `PC 0x233b`; `PHOTO_OF_WHALEY_HERRILL` is only `MOM 0x239c`), `JIMMY_ATTACKED` uses `PC 0x265f` with head variant `2`, the `POTTSDAM` second-response branch uses `PC 0x20e3` rather than Mom, the `MEAT PLANT` first response is `MOM 0x2106 -> PC 0x210a -> MOM 0x210e`, the `FATHER` keyword sets the native `momFatherTopicState`, and the fallback `0x26c6` path loops back into the keyword menu instead of forcing an immediate goodbye. Patched [`engines/harvester/npc/mom_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/mom_dialogue.cpp) to match those confirmed native branches and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully. Progress counts now reflect the current live Ghidra database totals (`923` functions, `124` remaining `FUN_*`).
-- On March 22, 2026, reran the full durable `repair MOM -> wait -> save_program -> verify MOM` cycle against the live `HARVEST.LE` session to confirm Mom still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_mom` rebuilt to `0x31140..0x33523` (`8778` address-count, `251` dialogue calls, `116` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_mom` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Mom was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
-- On March 22, 2026, reran the full durable `repair HANK -> wait -> save_program -> verify HANK` cycle against the live `HARVEST.LE` session to confirm Hank still survives cleanly with the current `InspectCallTermination.java` behavior. `handle_talk_to_hank` rebuilt to `0x33a30..0x34bcb` (`4508` address-count, `131` dialogue calls, `58` repair actions) and the first post-save verify held without needing a retry: both dialogue entry points still verify as returning and `handle_talk_to_hank` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Hank was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer verifier logic.
-- On March 22, 2026, rechecked `handle_talk_to_sergeant` in the live `HARVEST.LE` Ghidra body at `0x30340..0x30fd2` and found two native continuations still missing from ScummVM despite the earlier broad Sergeant port. The confirmed gaps were the post-`DINER_BURNED` second response menu at `0x2b0` (`0x42de` / `0x42e3` / `0x42e9` / `0x42ef` / `0x42f3`) plus the room-state mutations that re-show `INVITE` and `STEFSKULL`, reactivate `DWAYNE_ST_BEDRM`, and set `STEPHANIE_IS_DEAD` / `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE`, and the post-`BARBER_POLE` continuation at `0x4299` with response menu `0x2b2` and the optional `PC_KILLED_ANYONE` tail `0x42a4 -> 0x42a8` / `0x42ad`. Patched `engines/harvester/npc/sergeant_dialogue.cpp` to match those branches, updated `ARCHITECTURE.md`, and rebuilt the Harvester debug target successfully. Progress counts reflect the current Ghidra database totals (`920` functions, `122` remaining `FUN_*`).
-- On March 22, 2026, reran the full durable `repair SERGEANT -> wait -> save_program -> verify SERGEANT` cycle against the live `HARVEST.LE` session to confirm Sergeant still survives cleanly with the updated verifier and current save behavior. `handle_talk_to_sergeant` rebuilt to `0x30340..0x30fd2` (`3219` address-count, `70` dialogue calls, `27` repair actions) and the first post-save verify held without needing a retry: both `play_dialogue_line` entry points still verify as returning and `handle_talk_to_sergeant` reports `gapCount=0` / `status=clean` in the saved database. Progress is unchanged because Sergeant was already in the confirmed-clean set, but this revalidation confirms it is not currently regressing under the newer `InspectCallTermination.java` logic.
-- On March 22, 2026, reimplemented the remaining Stephanie dialogue mismatches in ScummVM directly against the live `HARVEST.LE` Ghidra body at `handle_talk_to_stephanie` (`0x36710..0x37f5d`). A forced redecompile plus spot disassembly confirmed the still-wrong native details in the current port: intro variants `0x43eb -> STEPHANIE[2]` and `0x43f5 -> STEPHANIE[3]`, item/evidence variants `0x4684 -> STEPHANIE[2]` and `0x469e -> STEPHANIE[2]`, first-followup tail `0x45d8 -> STEPHANIE[2]` / `0x45e2 -> STEPHANIE[3]`, second-followup variants on `0x460e`, `0x4612`, `0x4616`, `0x461b`, `0x4649`, and `0x464e`, the nested `SCRATCHED_TUCKER` reply `0x4709 -> PC[2]`, and the Mrs. Potts alibi tail `0x4989 -> STEPHANIE[0]` rather than PC. The refreshed late-tail listing around `0x377a3..0x37820` also resolved the Karin-alive control flow: after `0x49ba`, Stephanie only reaches `0x49c1` plus the shared `0x49ea/0x49ef` return when the Dwayne Karin-alive follow-up gate is set; otherwise the conversation falls back into the keyword menu immediately. Patched [`engines/harvester/npc/stephanie_dialogue.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/npc/stephanie_dialogue.cpp) to match those native branches and rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully.
-- On March 22, 2026, reran `repair STEPHANIE` with the updated `InspectCallTermination.java` verifier in the live `HARVEST.LE` session. Stephanie showed the same first-save snapback pattern seen on other retry-bucket NPCs: the first `repair -> wait -> save -> verify` cycle rebuilt `handle_talk_to_stephanie` cleanly to `0x36710..0x37f5d` (`6166` address-count, `195` dialogue calls) but the fresh verify fell back to the short `0x36710..0x36cd8` body with `9` stale dialogue gaps and both dialogue entry points marked non-returning again. An immediate second full cycle fixed it: the second `repair -> wait -> save -> verify` left Stephanie durably clean again at `0x36710..0x37f5d` with `gapCount=0`, `play_dialogue_line` and `thunk_play_dialogue_line` both returning, and `status=clean`. This confirms Stephanie belongs in the same post-save retry class as Johnson, Butcher, Dad, Loomis, and similar handlers.
-- On March 22, 2026, closed out the stubborn `HERRILL` case. The saved `HARVEST.LE` database already held the widened `handle_talk_to_herrill` body (`0x2dc00..0x2e987`, `1755` address-count) from the earlier repair/save pass, but `InspectCallTermination.java` kept falsely flagging `0x2dda1` as a missing-fallthrough dialogue call. Direct inspection showed the live listing at `0x2dda1` is actually `JMP 0x2e97a` with stale `CALL_RETURN` metadata, so the real remaining defect was verifier-side rather than a truncated handler body. Patched `~/ghidra_scripts/InspectCallTermination.java` so tail `JMP` transfers to `play_dialogue_line` / `thunk_play_dialogue_line` are counted as terminal dialogue transfers instead of bogus repair gaps; a fresh `verify HERRILL` now reports `status=clean` with `dialogueCalls=39` and `gapCount=0`. This raises the current durable/confirmed sweep count to `40/48`.
-- On March 22, 2026, resumed the one-at-a-time `InspectCallTermination.java` NPC sweep against the live `HARVEST.LE` session using the durable workflow `repair <NPC> -> wait -> save_program -> fresh verify <NPC>`. Including the already-confirmed earlier `STEPHANIE` repair in the same saved database, this pass has now confirmed the following talk handlers as durably clean: `WHALEY`, `MOYNAHAN`, `MR_POTTS`, `JOHNSON`, `JIMMY`, `LIBRARIAN`, `SWELL`, `PRIEST`, `PHELPS`, `BUTCHER`, `MADAM`, `INQUISITOR`, `DAD`, `MOTHER`, `MAINT_MAN`, `AUTHORITY`, `BUSTER`, `GLADIATOR`, `MCKNIGHT`, `BOYLE`, `CHESSMASTER`, `NUDE_MAN`, `CLOAK_ATND`, `KARIN`, `VALET`, `MRS_POTTS`, `VET`, `WASP_WOMAN`, `SERGEANT`, `MOM`, `DARK_WOMAN`, `FIREMAN1`, `FIREMAN2`, `HANK`, `CURATOR`, `BEGGAR`, `PTA_MOM`, `LOOMIS`, and `STEPHANIE` (`39/48`). The sweep also exposed three real outlier patterns beyond the simple retry-after-save snapback: missing immediate fallthrough instructions that require clearing/disassembling the exact continuation address before rerunning repair (seen in Boyle and Wasp Woman), tiny stray tail functions that must be deleted so the main talk handler can absorb the last continuation block (seen in Mom), and a save-time reappearing `CALL_RETURN` override that still leaves `handle_talk_to_herrill` one call short even after direct clearing at `0x2dda1`. The current active retry is `EDNA`, which reverted to its short body after the first save/verify cycle and still needs the standard second repair pass.
-- On March 22, 2026, reran `repair STEPHANIE` through the live `HARVEST.LE` Ghidra session with `~/ghidra_scripts/InspectCallTermination.java`, then immediately called `save_program` and re-ran `verify STEPHANIE` in a fresh script invocation. The repair converged in two passes, expanding `handle_talk_to_stephanie` from `0x20e` bytes (`0x36710..0x36cd8`) with `9` fallthrough gaps to a clean `0x1816`-byte body (`0x36710..0x37f5d`) with `195` reachable dialogue calls, `0` remaining gaps, and `27` repair actions. The explicit save succeeded and the fresh verify still reported `status=clean`, so Stephanie's repaired handler is now confirmed durable in the saved `HARVEST.LE` database.
-- On March 22, 2026, attempted to run `repair STEPHANIE` again with `~/ghidra_scripts/InspectCallTermination.java`, but the live Ghidra plugin server was still wedged: the bridge layer was gone after cleanup, direct HTTP probes to `http://127.0.0.1:8089/check_connection` timed out even though the Java process still owned the port, and the fallback headless check showed the only on-disk project at `~/.ghidra-projects/harvest_prelim` contains `HARVEST.EXE` rather than the active `HARVEST.LE` program database. No Stephanie repair was applied in this attempt because there is currently no responsive path into the `HARVEST.LE` state without restarting the live Ghidra/plugin session.
-- On March 22, 2026, reworked `~/ghidra_scripts/InspectCallTermination.java` again to debug why per-NPC `repair` runs looked successful but reverted after later saves. Confirmed the original nested/manual transaction model only preserved repairs transiently inside the active script invocation. Switched the script to end the runner-owned transaction up front, use an explicit per-handler program transaction inside `repairHandler`, and keep the `repair <NPC>` / `verify <NPC>` argv workflow intact. With that model, `repair JIMMY` followed by a fresh `verify JIMMY` now survives across separate script invocations in the active `HARVEST.LE` session. The remaining blocker is durable save of dirty handlers: once a handler actually changes, both in-script `saveProgram()` and MCP `save_program` still hit Ghidra-side locking/analysis problems (`Unable to lock due to active transaction`, then `AutoAnalysisManager.scheduleWorker may not be invoked from Swing thread`), and the final dirty-save experiment left the MCP/Ghidra session unresponsive. Do not trust `repairrange` for persistence; the only confirmed good path so far is single-NPC repair/verify while the session stays alive.
-- On March 22, 2026, rechecked Harvester's native room-combat melee gating in Ghidra after ScummVM still logged `reason='no native attack bank'` for Johnson's `MRJ_M`. Confirmed from `update_actor_runtime_state` (`0x4d750`) that the native class-6 attack picker is state-driven and side-based, then verified from the shipped XFILE archives that `GRAPHIC/MONSTERS/MRJ.ABM` actually contains `296` frames, so ScummVM's failure was local rather than asset-driven. Patched [`engines/harvester/room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) and [`engines/harvester/runtime_entity.h`](/Users/alex/Workspace/scummvm/engines/harvester/runtime_entity.h) so monster attack/death bank checks validate against the full ABM frame count instead of the entity's currently active animation subrange. Rebuilt [`build-vscode-harvester-debug/scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm) successfully.
-- On March 22, 2026, re-audited Harvester's native save/load dialogue tail in Ghidra instead of relying on the earlier three-block summary. Confirmed `run_save_game_menu` / `run_load_game_menu` serialize the full post-`TIMER` talk-state inventory, including the standalone `g_serialized_talk_state_flag_d2cd8 @ 0xd2cd8`, the shared dialogue dword range `0xd2e98..0xd2f30`, and the dedicated NPC-local blocks for Boyle, Dwayne, Loomis, Mom, Hank, Dad, Mr. Potts aux/main, Stephanie, Johnson, Swell, Parsons, Mcknight, Edna, Moynahan, Phelps, Herrill, Wasp Woman, Sparky, Fireman 1/2, Nude Man, Butcher, Ryder, Buster, Dark Woman, Memb Dir, Mother, Mrs. Potts, and Inquisitor.
-- On March 22, 2026, patched ScummVM's Harvester save schema again so the dialogue blob carries the outer save version, allowing new dialogue-state fields to be appended without breaking version-3 blobs. Added the unresolved native shared-talk slots still present in `0xd2cd8` and `0xd2e98..0xd2f30` to `DialogueSharedState`, bumped the Harvester save version to `4`, and rebuilt `build-vscode-harvester-debug/scummvm` successfully.
-- On March 22, 2026, ran `repair AUTHORITY` through the generalized `InspectCallTermination.java` workflow and re-verified the saved `HARVEST.LE` state. The script cleared Authority's `2` stale dialogue-call fallthrough gaps in one pass, expanding `handle_talk_to_authority` from `0x5f` bytes (`0x2b730..0x2b7b2`) to a clean `0x83`-byte body with `3` reachable dialogue calls and `0` remaining gaps while keeping both `play_dialogue_line` entry points marked returning.
-- On March 22, 2026, ran `repair JIMMY` through the generalized `InspectCallTermination.java` workflow and re-verified the result in the saved `HARVEST.LE` database. The script cleared Jimmy's `13` stale dialogue-call fallthrough gaps in one pass, expanding `handle_talk_to_jimmy` from `0x327` bytes (`0x28880..0x28eb9`) to a clean `0x642`-byte body (`0x28880..0x28ec1`) with `39` reachable dialogue calls and `0` remaining gaps while keeping both `play_dialogue_line` entry points marked returning.
-- On March 22, 2026, batch-verified all `48` named `handle_talk_to_*` entries with the repaired `InspectCallTermination.java` workflow. The current active `HARVEST.LE` state now reports `play_dialogue_line` and `thunk_play_dialogue_line` as returning, `handle_talk_to_stephanie` as the only fully clean talk handler, and `47` remaining handlers still needing iterative fallthrough repair. The heaviest confirmed remaining truncation clusters are `handle_talk_to_mom` (`61` gaps), `handle_talk_to_dwayne` (`59` gaps), and `handle_talk_to_hank` (`28` gaps).
-- On March 22, 2026, repaired `~/ghidra_scripts/InspectCallTermination.java` so it now accepts argv-driven NPC commands (`list`, `verify <NPC>`, `repair <NPC>`, plus `verify ALL`) instead of being hardwired to one handler and failing to load under Ghidra's Java script provider. The real loader breakage was a bad `FlowOverride` import; after correcting that and switching the rebuild step back to GhidraScript's auto-body `createFunction(entry, name)` helper, the script now validates the immediate fallthrough byte after every `play_dialogue_line` / `thunk_play_dialogue_line` call, confirms both dialogue callees are marked returning, clears stale call overrides, disassembles newly reachable fallthrough bytes, and iteratively recreates the selected `handle_talk_to_*` body until the interaction graph is clean.
-- On March 22, 2026, verified the generalized script live against multiple NPCs in `HARVEST.LE`: `verify HANK` now reports Hank's stale `CALL_RETURN` dialogue exits generically, while `repair STEPHANIE` completed in two passes, expanding `handle_talk_to_stephanie` from `0x20e` bytes (`0x36710..0x36cd8`) to a clean `0x1816`-byte body (`0x36710..0x37f5d`) with `195` reachable dialogue calls and `0` remaining fallthrough gaps. Saved the updated `HARVEST.LE` database after the successful Stephanie repair.
-- On March 22, 2026, rechecked Harvester's native room-combat monster attack path in Ghidra after ScummVM monsters could pursue but kept logging `reason='out of range'` at contact. Confirmed from `update_actor_runtime_state` (`0x4d750`) that class-6 monsters arm melee by storing `g_player_combat_avatar` into live actor field `+0x11a4`, pick a random side-appropriate close-range attack family (`0x16..0x18` on the left, `0x19..0x1b` on the right) instead of using a target-Y band mapping, and resolve contact against that linked target after the `+0x113c` contact frame instead of re-running the coarse engage-distance spacing test. Patched [`room.cpp`](/Users/alex/Workspace/scummvm/engines/harvester/room.cpp) to retain the scheduled player target through the swing, resolve monster contact once against that linked target, and randomize native-style left/right attack bank selection from the ABM frame ranges. Verified by rebuilding [`room.o`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/engines/harvester/room.o) and relinking [`scummvm`](/Users/alex/Workspace/scummvm/build-vscode-harvester-debug/scummvm).
-- On March 22, 2026, audited native save/load persistence in Ghidra before changing the engine-side dialogue save schema. `run_save_game_menu` / `run_load_game_menu` restore dedicated talk-state blocks in addition to the world/script records, including Sergeant's `0x28`-byte local block at `g_sergeant_talk_state_block @ 0xd2d4c`, Stephanie's `0x4c`-byte block at `g_stephanie_talk_state_block @ 0xd2e4c`, and the shared discussion / follow-up block at `0xd2e98` with size `0x9c`.
-- On March 22, 2026, patched ScummVM's Harvester save/load path to preserve and reload those dialogue branches. `HarvesterEngine` now carries a pending dialogue-state blob for launcher/runtime loads, `Flow` serializes and restores the active `DialogueSystem`, `DialogueSystem` syncs shared plus per-handler local talk state, and room-load handoff reapplies the blob immediately after the normal dialogue reset. Bumped the Harvester save version to `3` and rebuilt `build-vscode-harvester-debug/scummvm` successfully.
-- On March 22, 2026, re-opened Sergeant's `SCRATCHED_TUCKER` no-item branch against the shipped `DIALOGUE.IDX` and `DIALOG.RSP` assets after ScummVM stopped at Sergeant line `0x420d` without the expected Day 2 response menu.
-- Confirmed the missing continuation is asset-backed: Sergeant should continue with `0x421e`, open zero-based `dialog.rsp` line `0x2b4`, and then branch to `0x422a` or `0x422f` / `0x4239`. Patched `engines/harvester/npc/sergeant_dialogue.cpp`, updated the Sergeant dialogue debug dump and `ARCHITECTURE.md`, and rebuilt `build-vscode-harvester-debug/scummvm` successfully.
-- On March 22, 2026, cleaned up the native Sergeant room handler in Ghidra by rebuilding `handle_talk_to_sergeant` at `0x30340` from a broken one-byte stub into its real front-half body, setting an explicit `void __stdcall` prototype, and renaming the Sergeant-local `0xd2d50..0xd2d70` state slots to stable labels. The only remaining decompiler noise in that front half is the known register-lift artifact on `ASSIGNED_DNA_TASK` and the odd extra register shown on the `BOLTCLTH` branch.
-- On March 22, 2026, recovered the remaining in-line Sergeant continuation heads from native control flow instead of raw asset chasing: labeled and commented `sergeant_completed_application_followup` (`0x30c42`), `sergeant_intro_or_revisit_dispatch` (`0x30d7e`), `sergeant_first_contact_intro_sequence` (`0x30d8b`), `sergeant_revisit_reminder_sequence` (`0x30eff`), and the shared `sergeant_day5_exit_check` / `sergeant_day5_exit_branch` (`0x30f99` / `0x30fa7`). The plate comment on `handle_talk_to_sergeant` now points directly at those continuation blocks.
-- On March 22, 2026, reapplied the recovered Sergeant `SCRATCHED_TUCKER` continuation to ScummVM: after the `0x2b4` response menu, both the `0x422a` accept tail and the `0x422f` / `0x4239` pushback tail now fall through a one-shot local `dialogueStateD2d58` gate that dispatches `GO_DAY_3`, matching the native `0x30b84..0x30ba6` action-tag handoff. Updated `engines/harvester/npc/sergeant_dialogue.cpp`, `engines/harvester/dialogue_debug.cpp`, `ARCHITECTURE.md`, and rebuilt `build-vscode-harvester-debug/scummvm` successfully.
-- On March 22, 2026, added a batch Ghidra audit script at `~/ghidra_scripts/RecoverTalkHandlerReachability.java` to walk `g_talk_to_handler_entries` in one pass, verify every NPC talk-handler pointer resolves to a real function entry, flag any table target that lands inside an existing function instead of at its entry point, and scan each recovered `handle_talk_to_*` body for off-function direct `CALL` or `JMP` targets. Ran the script against `HARVEST.LE` in report mode and confirmed the current database is clean: `48` table handlers, `0` missing table targets, `0` mid-function table targets, `0` missing call targets, and `0` missing jump-only continuation blocks.
-- On March 22, 2026, confirmed the remaining orphan NPC talk-handler code is not a bad `g_talk_to_handler_entries` pointer-table issue. The real failure mode is Ghidra analysis around `play_dialogue_line`: many talk handlers had dialogue call sites incorrectly marked `CALL_RETURN`, and `play_dialogue_line` itself kept being rediscovered as `noreturn`, which truncated handler bodies and left the next code blocks unmapped. Added `~/ghidra_scripts/AuditTalkHandlerDialogueFallthroughs.java` and `~/ghidra_scripts/InspectCallTermination.java` to prove the pattern and verify call-site state.
-- On March 22, 2026, re-ran the working manual recovery sequence on live handlers: force `play_dialogue_line` back to returning, clear the bad flow overrides at the affected dialogue call sites, disassemble the fallthrough bytes immediately after those calls, then delete/recreate the handler entry. That sequence cleanly reattached Sergeant's `0x30b28` continuation back under `handle_talk_to_sergeant`, and also expanded `handle_talk_to_stephanie` from its truncated `0x526`-byte body to a wider `0x668`-byte body. The problem is iterative rather than one-shot: once additional continuation blocks are absorbed, newly reachable `play_dialogue_line` sites inside those blocks need the same treatment on the next pass.
-- On March 22, 2026, pushed the iterative Sergeant recovery through two more dialogue-call waves. `play_dialogue_line` and `thunk_play_dialogue_line` now remain marked returning in the active database, `handle_talk_to_sergeant` rebuilt cleanly to a `0x8a1`-byte body (`0x30340..0x30fd2`), and the decompile now reaches the native `SCRATCHED_TUCKER` Day 3 handoff (`dispatch_action_tag_if_set("GO_DAY_3")`) plus the shared Day 5 exit gate at `0x30f99`. The remaining `CALL_RETURN` sites visible in the Sergeant handler are now narrow tail cases rather than the missing mid-function continuations that were previously orphaning the core logic.
-- On March 22, 2026, checked the unmapped bytes immediately after Sergeant's terminal `RET` at `0x30fd2`. That region is recoverable, but it is not additional `handle_talk_to_sergeant` continuation logic: the bytes starting at `0x30fe0`, `0x31000`, `0x31020`, `0x31040`, `0x31060`, `0x31080`, `0x310a0`, and `0x310c0` form a compact cluster of standalone Sergeant-local getter/setter helpers over globals `0xd2d4c..0xd2d6c`, using the same `if (param_1 == 0) global = param_2; return global;` pattern as the already-defined `get_set_sergeant_*` wrappers elsewhere in the binary. The hard `RET` at `0x30fd2` is therefore a real function end for the talk handler.
-- On March 22, 2026, defined the post-`0x30fd2` Sergeant accessor cluster as standalone functions instead of leaving it as unmapped code: `get_set_sergeant_talk_state_block` (`0x30fe0`), `get_set_sergeant_dialogue_state_d2d50` (`0x31000`), `get_set_sergeant_post_application_briefing_completed` (`0x31020`), `get_set_sergeant_dialogue_state_d2d58` (`0x31040`), `get_set_sergeant_dialogue_state_d2d5c` (`0x31060`), `get_set_sergeant_dialogue_state_d2d60` (`0x31080`), `get_set_sergeant_dialogue_state_d2d64` (`0x310a0`), `get_set_sergeant_dialogue_state_d2d68` (`0x310c0`), and `get_set_sergeant_dialogue_state_d2d6c` (`0x310e0`). Applied matching two-parameter `get/set` prototypes and removed the stale temporary labels that overlapped the new helper bodies.
-- On March 22, 2026, reimplemented `engines/harvester/npc/sergeant_dialogue.cpp` against the current recovered native Sergeant state instead of the earlier partial stub logic. The engine now mirrors the helper-backed local state block (`d2d4c`, `d2d50`, `d2d54`, `d2d58`, `d2d5c`, `d2d60`, `d2d64`, `d2d68`, `d2d6c`, `d2d70`), uses the recovered menu/FST tails for `REMAINS`, `INVITE`, `DINER_BURNED`, `BARBER_POLE`, `BOLTCLTH`, `SCRATCHED_TUCKER`, and the intro/revisit/application branches, and carries over the native Day 5 exit gate and `BOLTCLTH` slash-transition side effects. Verified by building `engines/harvester/npc/sergeant_dialogue.o` and `engines/harvester/dialogue.o` with `make`.
-- On March 22, 2026, re-ran the Stephanie talk-handler recovery using the stricter `InspectCallTermination` approach that checks the exact fallthrough byte after each `play_dialogue_line` call instead of relying on `inst.getNext()`. Clearing those stale `CALL_RETURN` overrides and disassembling the immediate fallthrough bytes recovered the Stephanie handler far enough to confirm the day-five, `BURNED_TV_STATION`, `SCRATCHED_TUCKER`, `BOLT_OF_CLOTH_TAKEN`, `BARBER_POLE_STOLEN`, `DINER_BURNED`, jail, and keyword-menu branch layouts plus the small orphan Stephanie continuations at `0x37650` and `0x37e78`.
-- On March 22, 2026, patched `engines/harvester/npc/stephanie_dialogue.cpp` against those confirmed native Stephanie paths: corrected the recovered Stephanie/PC head variants that were still hardcoded as `0`, removed the bogus unconditional `0x4717` tail after the nested `SCRATCHED_TUCKER` reply, and restored the native early-return behavior on the `BOLT_OF_CLOTH_TAKEN`, `BARBER_POLE_STOLEN`, `DINER_BURNED`, and `0x2ed` keyword-response exits. Rebuilt `build-vscode-harvester-debug/scummvm` successfully.
-
-## Next Suggested Action
-
-- Immediate: run an in-engine WASP_WOMAN validation pass that explicitly covers the restored native-only branches: first no-item talk before and after Mom's topic `0x121` sets the shared follow-up gate so the optional `PC 0x4bf2[0]` insert is confirmed; topic `0x302` to verify `C027A.FST` and the rewrite to `dialog.rsp[0x304]`; topic `0x305` with the Mom follow-up gate both clear and set to verify the unconditional `C027B.FST` plus the gated `dialog.rsp[0x307]` menu; topics `0x308`, `0x30a`, and `0x30c` to confirm the rewrites to `dialog.rsp[0x309]`, `dialog.rsp[0x30b]`, and `dialog.rsp[0x30d]`; the initial exit topic to confirm the conversation ends cleanly; and the late `0x30e` plus an unrelated typed topic to verify `0x30e` loops silently while the unmatched late-topic path now plays `0x4c9a[0]` and stays in the conversation. Also present `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO`, and note/checkbook evidence, then immediately visit one downstream consumer of the shared evidence ints to confirm the restored `discussed*` writes propagate beyond Wasp Woman's local startup-script flags.
-- Immediate: run an in-engine VET validation pass that explicitly triggers `VET_INTERRUPT`, confirms the restored `0x6d9[0] -> PC 0x447[0] -> 0x6e1[2]` opener, both `dialog.rsp[0x2ff]` answers (`0x6ec[2]` and `0x6f0[0]`), menu dismissal falling straight through to the shared `0x6f4[2]` exit, and a second talk proving the startup flag was cleared so the interrupt exchange no longer replays.
-- Immediate: run an in-engine VALET validation pass that explicitly covers the now-confirmed native-only sequence: enter the conversation empty-handed to confirm the four-line opener `0xf2c[1] -> PC 0xf31[0] -> 0xf35[0] -> PC 0xf39[0]`, the `MIDGET02.FST` playback, response menu `0x2fe`, response `1` producing `0xf4a[0]`, response `2` producing `0xf50[2]`, and menu dismissal falling straight through to the shared exit line `0xf56[0]` with no extra bark or item-specific branch.
-- Immediate: rerun a focused in-engine Swell validation pass that checks the two follow-up corrections from this pass: after presenting `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, both ledgers with `HAVE_BOTH_LEDGERS`, and note/checkbook evidence to Swell, immediately visit at least one downstream consumer that reads the shared evidence-state ints (for example Dwayne, Moynahan, or Karin/Herrill paths already known to key off those discussion markers) to confirm the restored shared-state writes now propagate beyond the local startup-script flags; and in the keyword menu verify that only native topic `dialog.rsp[0x21e]` exits with `0xff1[1]` while `0x229` remains a silent continue and an unrelated typed topic still falls back to `0xfba[2]`.
-- Immediate: run an in-engine PTA_MOM validation pass that explicitly covers the corrected response-`1` portrait tail and the existing native branches: trigger `PTA_RESPOND_TO_TV` once to confirm the early `0x3233[2]` bark clears the flag and skips `dialog.rsp[0x297]`; then talk repeatedly without the flag until each opener `0x31ee[1]`, `0x31f2[1]`, and `0x31f6[1]` has appeared at least once, taking response `1` to confirm `0x3204[2] -> 0x320b[0]`, response `2` to confirm `0x320f[0] -> 0x3213[0]`, response `3` to confirm `0x3217[0] -> 0x321c[0] -> PC 0x3221[0] -> 0x3226[0]`, and dismissing the menu once to verify the native handler returns silently with no fallback bark.
-
-- Immediate: run an in-engine Priest validation pass that explicitly hits the recovered native-only branches: first talk with response `2` at menu `0x27a`; then separate fresh runs taking `1 -> 2` at `0x27b`, `1 -> 1` at `0x27c`, and `1 -> 2` at `0x27d` to confirm all four early `0xcc4[2] -> FOLOWR_MNSTFY_A` exits; plus a full `1 -> 1 -> 2 -> 1` descent to menu `0x27e`, taking response `4` once to verify `0xd0e[1]`, `PRIEST_FLAG` clear, and `RELIG_EXITS_A`, and taking any non-`4` response once to verify `0xd13[3]`, `PRIEST_FLAG` set, and `FOLOWR_MNSTFY_A`. Present one arbitrary inventory item as well to confirm the native handler ignores used objects.
-
-- Immediate: run an in-engine MEMB_DIR validation pass that explicitly covers the recovered native-only branches: talk before `PC_KILLED_KEWPIE`, take both `dialog.rsp[0xfe]` answers to confirm `0x2c2[0]` versus `0x2cb[0]`, and dismiss the menu once to verify the handler returns silently without a fallback bark; then talk after `PC_KILLED_KEWPIE` with fresh local state to confirm the one-shot `0x2d5[1]` prelude plus `TOOK_THE_DOLL -> 0x2e3[0]` versus no-doll `0x2ea[0]`, and repeat once more to confirm the local post-kill gate suppresses the branch on revisit.
-- Immediate: run an in-engine MCKNIGHT validation pass that explicitly covers the restored native-only branches: `LOOK_SAFE_1ST` twice to confirm `0x15c9[2]` is now one-shot, `LOOK_SAFE_2ND` to confirm `0x15d0[2]`, a first empty-hand talk to confirm the full `0x1587 -> 0x159d` six-line exchange plus the shared `d2f04` write, a repeat empty-hand talk to confirm the fallback `0x15e4`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with shared state `d2ebc` both clear and set to verify the `0x1605 -> 0x1609` versus `0x15f8 -> 0x15fd` follow-ups, note/checkbook evidence to confirm the restored five-line `0x161b -> 0x1631` sequence, and one arbitrary wrong item to confirm the handler still falls back to `0x15de`.
-- Immediate: run an in-engine Swell validation pass that explicitly covers the recovered native-only flow: first empty-hand talk to confirm the full `0xf61[1] -> PC 0xf65 -> 0xf69[1] -> PC 0x3d50 -> 0xf72[1] -> 0xf7f[0]` intro and the seeded `dialog.rsp[0x21d]` topic buffer; a repeat empty-hand talk to confirm the default `0xfb3[0]` bark plus one-shot follow-ups for `STEPH_MIDGAME_PLAYED`, `BARBER_POLE_STOLEN`, `BOLT_OF_CLOTH_TAKEN`, `BURNED_TV_STATION`, and `KARIN_KIDNAPED`; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, both ledgers with `HAVE_BOTH_LEDGERS`, and note/checkbook evidence to verify the restored `0xfc6[1]`, `0xfcd[0]`, `0xfd4 -> PC 0xfd9 -> 0xfde`, and `0xfe6[1]` branches; and the keyword loop through `0x21e`, `0x21f/0x220`, `0x222`, `0x224/0x225/0x226`, `0x228`, `0x229`, and one unknown typed topic to confirm the topic-buffer rewrites and the `0xff1[1]`, `0xf88[3]`, `0xf95[0]`, `0xfa0[0]`, `0xfaa[1]`, and `0xfba[2]` responses.
-- Immediate: run the same durable single-handler cycle on `DWAYNE`. A fresh `verify DWAYNE` against the saved `HARVEST.LE` database still reports `handle_talk_to_dwayne @ 0x3a310` as truncated to `0x3a310..0x3c385` (`3375` address-count) with `59` reachable dialogue calls, `59` stale `CALL_RETURN` gaps, and `status=needs_repair`.
-- Follow-up: if `ARCHITECTURE.md` needs another compression pass, move the longest branch-by-branch NPC dialogue traces into a companion note or appendix and keep the main file focused on subsystem structure, stable data types, and engine relationships.
-- Immediate: run an in-engine Madam validation pass that explicitly covers the recovered native branches: trigger `MADAM_INTERRUPT_CONVERSATION`, take the non-`1` `dialog.rsp[0xfb]` path once to confirm `0x1577[0] -> 0x157b[2]` and the queued live `HOOKER` death-or-monsterfy transition; then take response `1` without `BARCASH200` in inventory to confirm `PC 0x156e[0] -> MADAM 0x1572[0] -> 0x157b[2]` and the same death transition; finally repeat with visible `BARCASH200` in inventory to confirm both inner `dialog.rsp[0xfc]` exits, including response `1` to `0x154e[0] -> 0x157b[2]` and response `2` to `0x1555[1] -> HOARLOVE.FST -> 0x155a[0] -> 0x1563[0] -> C117.FST`, `BARCASH200` removal from inventory, and the `HURT_PC_0` plus `ACTV_LUST_EXIT` action-tag side effects. Also dismiss `dialog.rsp[0xfc]` once to verify the native handler simply returns without forcing a fallback bark.
-- Immediate: run an in-engine Librarian validation pass that explicitly covers the recovered native branches: enter the conversation empty-handed and take both `dialog.rsp[0xfa]` responses plus one dismissed menu to confirm `0x1d85` versus `0x1d8b[4]` and the always-present shared tail `0x1d8f -> 0x1d94 -> 0x1d98 -> 0x1da1 -> 0x1da5`; present one arbitrary wrong item to confirm the handler now only plays `0x1d5b`; then present or visibly carry `CAINBOOK` to confirm `CLUE` is awarded, the full `0x1db1[1] -> 0x1dc6` trade sequence plays, and `CAINBOOK` disappears from visible inventory afterward.
-- Immediate: run an in-engine Inquisitor validation pass that explicitly covers the recovered native sequence: talk to `INQUISITOR` with `PAIN_SOLVED` clear and the local talk-state bit freshly reset to confirm `0x18e7 -> 0x18e8 -> 0x18e9 -> TORCH2.FST -> 0x18f8 -> START_INQ_TIM`; talk again in the same eligible state to confirm the cleared local bit suppresses the sequence on repeat visits; then set `PAIN_SOLVED` and confirm the handler returns silently even after a room/reset cycle re-arms the local state.
-- Immediate: run an in-engine Gladiator validation pass that explicitly covers the recovered native tree: trigger `GLADIATOR_INTERRUPT_CONVERSATION`, confirm the opening `0x69c[1] -> 0x6a3[0]`, take response `1` on `dialog.rsp[0xc3]` to verify the `0x6af[2] -> 0x6cd[2]` death tail and queued live Gladiator death-or-monsterfy transition, then take response `2` to verify `0x6b3[0] -> C110.FST -> dialog.rsp[0xc4]` and both second-menu exits (`0x6c3[2] -> 0x6cd[2]` death tail versus `0x6c7[1] -> 0x6cd[0] -> START_MERCY_TIMR`). Also dismiss each response menu once to confirm the native handler simply exits without playing an extra fallback bark.
-- Immediate: run an in-engine Dark Woman validation pass that explicitly covers the recovered native tree: direct `HANDMIRROR` use to confirm `0x4d03[2] -> C109.FST -> 0x4d0d[3]`, `HANDMIRROR` removal from inventory, and `SET_D_W_FLAG`; a fresh no-item talk through response lines `0x4b`, `0x4c`, and `0x4d` to confirm response `1` on any menu plays `0x4ce2[2]` and queues the live Dark Woman death-or-monsterfy transition; response `2` through all four menus without the mirror in inventory to confirm `dialog.rsp[0x4f]` and the `0x4cfc[2]` fallback; then the same path with `HANDMIRROR` already owned to confirm `dialog.rsp[0x4e]` and the shared reveal branch even without directly presenting the item. Also present one wrong item on the first visit to confirm the native handler falls through to the intro instead of playing a rejection bark.
-- Immediate: run an in-engine Curator validation pass that explicitly covers the recovered native tree: take both `dialog.rsp[0x4a]` choices to confirm `0x430[3]` versus `0x435[0]`, confirm the shared tail `0x43a[0] -> 0x43b[0] -> PC 0x447[0] -> 0x44b[3]` always follows either valid choice, cancel or dismiss the menu once to verify the branch-specific line is skipped while the shared tail still plays, and present one arbitrary item to confirm the native handler ignores used objects.
-- Immediate: run an in-engine Cloak Attendant validation pass that covers the full recovered native space: empty-hand talk before `CLEANED_CLOTHES` to confirm only `0x1066`; empty-hand talk after the startup flag is set to confirm silent return; `BARCASHFIVE` presentation to confirm `CLEAN_CLOTHES` still dispatches and applies its room/inventory effects; and one arbitrary non-payment item to confirm the native handler ignores it without falling back to the no-item bark.
-- Immediate: run an in-engine Ryder validation pass that explicitly hits the recovered native branches: first talk through both `0x2a1` responses to confirm the optional `AUTOGRAPH` award versus `0x4a9 -> 0x4ae`; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, and note/checkbook evidence to confirm the restored shared evidence-state writes now land alongside the existing startup-script flags; the `KARIN_KIDNAPED` tail through both `0x2a3` responses to verify `C063D.FST`, `C063E.FST`, and the autograph alternative; the keyword loop through both `0x2a7` / `0x2a8` interview topics and both `0x2a9` responses to confirm `C101.FST`, `0x511`, `0x521`, and `0x527`; and the `BURNED_TV_STATION` override with and without `BOYLE_BURNED_TV` set to confirm presented items are ignored and the `RYDER_TV_OUT` line chain still short-circuits correctly.
-- Immediate: run an in-engine Chessmaster validation pass that explicitly hits both menu responses and all three puzzle outcomes: response `1` with the native correct hotspot on each of `CHOOSE1`, `CHOOSE2`, and `CHOOSE3` to confirm `CHESS_SOLVED`, `C125.FST`, and the queued Chessmaster slash-death transition; response `1` with a wrong hotspot on each stage to confirm the immediate `0x4126[2] -> ADD_CHESS_KING_MONSTER -> KINGGROW.FST` fail path; and response `2` or a canceled menu selection to confirm the puzzle is skipped and the same monster branch fires directly.
-- Immediate: decode one shipped `HARVEST.SCR` sample and lift a few real `COMMAND` clusters into [`OPCODES.md`](/Users/alex/Workspace/scummvm/OPCODES.md) so the new `triggerTag` / `currentTag` section includes native examples instead of illustrative ones. Keep the examples tied to records and continuations that are actually present in data.
-- Immediate: run an in-engine Beggar validation pass that explicitly hits the recovered native-only dialogue tree: trigger the `BEGGAR_INTERRUPT_CONVERSATION` path, take both `dialog.rsp[0]` answers to confirm `0x128d[1]` versus `0x1291[3]`, then both `dialog.rsp[1]` answers to confirm `0x12ab[2]` versus `0x12a5[2]`, and verify that `BEGGAR_DIALOG_3` still produces the intended post-dialogue room mutation / monsterfy result once the conversation exits.
-- Immediate: run an in-engine Authority validation pass that explicitly covers all recovered native branches: `STOP_AUTHOR_TALK` set to confirm silent early return; `IF_DON_T_EAT_THE_FOOD` alone to confirm only `0x3992`; both food flags set to confirm `0x3992` followed by `0x399b`; `IF_DON_T_EAT_THE_FOOD` clear with only `IF_YOU_EAT_THE_FOOD_AND_DEFEAT_THE_ENEMIES` set to confirm the direct `0x399b` bark; and both food flags clear to confirm the default `0x398a` path.
-- Immediate: run an in-engine Fireman1 validation pass that explicitly covers the recovered native-only follow-ups: first talk with neither flag set to confirm only `0x453`; a revisit with neither flag set to confirm only `0x463`; a talk after `STEPH_MIDGAME_PLAYED` to confirm the appended `0x45a[3]` bark; a separate talk after `BOLT_OF_CLOTH_TAKEN` to confirm the appended `0x46a[2]` bark; and a save/load cycle after each follow-up has fired once to verify both Fireman1-local gates persist and do not replay on the next revisit.
-- Immediate: run an in-engine Sparky validation pass that explicitly hits the corrected native-only paths: `PC_TRIES_TO_TURN_ON_LIGHT`; any non-empty presented item for the `0x3c7[2]` rejection bark; the intro through all three `0x2be` responses to confirm `0x2f7[1]`, `0x2fe[2]`, `0x30d[1]`, the `SPARKY_SPOTS_MEAT` action tag, and the `C135/C098/C096` FST inserts; same-day and later-day revisits to confirm `0x3bb[1]` versus `0x3ac[1] -> 0x3b0 -> 0x3b4`; and the keyword loop starting from the native empty menu so typed topics `0x2c4/0x2c5`, `0x2c7`, `0x2c8`, `0x2ca/0x2cb`, and an unknown topic verify the restored `0x34c[1]`, `0x35f[0]`, `0x36c[0]`, `0x36d[0]`, `0x37a[3]`, `0x38f[4]`, `0x39e[1]`, and `0x3c1[1]` portrait variants plus the non-persistent `0x2c9` topic-list rewrite.
-- Immediate: run an in-engine Phelps validation pass that explicitly hits the corrected native-only portrait flow: first no-item talk to confirm the `0x1b44[1] -> 0x1b48 -> 0x1b4c -> 0x1b52 -> 0x1b56[1] -> 0x1b5a` intro and the delayed `d2f04` write; a later day-1 to day-4 revisit through both `0x22a` responses to confirm `0x1bf4[1]`, `0x1b7b[1]`, `0x1b85[0]`, and `0x1bbc[1] -> 0x1c44[1]`; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, note/checkbook evidence, and `QUARTER` to verify the recovered portrait variants plus `GET_PRN_MAG` / `DEL_DRT_MAG`; and separate revisits for `STEPH_MIDGAME_PLAYED`, day 5, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `PC_ESCAPED_JAIL`, Jimmy death response menu `0x22c`, `KARIN_FOUND_DEAD`, and `KARIN_KIDNAPED` to confirm the restored one-shot bark variants and shared kidnapped-dialogue write.
-- Immediate: run an in-engine Sergeant and Nude Man validation pass against both a fresh playthrough state and an older ScummVM save created before this fix. Confirm Nude Man now stops after `0x628[1]` until the Sergeant `SCRATCHED_TUCKER` branch has actually set the native first-task completion state, then verify the second menu `0x25e` unlocks correctly after that branch and still unlocks after loading a pre-fix save where only Sergeant's legacy local completion bit was persisted.
-- Immediate: run an in-engine Parsons validation pass that explicitly hits the restored native-only flow: first no-item talk to confirm the full `0xdcc[1] -> 0xdd2[0] -> 0xdd6[0] -> 0xde3[1]` intro plus the initial `Pole/Alarm System/Pastorelli` topic buffer; `STEPH_MIDGAME_PLAYED`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `DINER_BURNED`, `BURNED_TV_STATION`, and `KARIN_KIDNAPED` on separate revisits to verify each Parsons-local one-shot gate now persists across save/load and that the Swell exchanges reset the active portrait back to Parsons; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared state `d2ebc` to confirm the optional `PC 0xe84 -> PARSONS 0xe88` continuation; note/checkbook and TV-deed evidence to verify the restored multi-line evidence replies and portrait variants; and the keyword loop through `Pole`, `Alarm System`, `Aliens`, `Pastorelli`, `barber`, and `Mr. Pastorelli` plus both visible `BYE` exits to confirm the `C093.FST` / `C094.FST` inserts, topic-buffer rewrites, and common `0xe5f` exit line.
-- Immediate: run an in-engine Mother validation pass that explicitly hits the recovered native-only flow: first talk with response `1` to confirm `0x26d6[3] -> C072.FST -> 0x26e9[0] -> menu 0x173 -> 0x26f5[3] -> C073.FST`; restart or reload and take response `2` to confirm `C073.FST -> 0x2709[3] -> C072.FST -> 0x2714[0] -> 0x2719[3]`; then cancel the menu once to verify the native silent fallthrough and confirm a second talk in the same room returns immediately because the local talk-state bit is cleared after the first entry.
-- Immediate: run an in-engine Boyle validation pass that explicitly hits the corrected native-only paths: intro response menu `0x5 -> 0x7 -> 2` to confirm the new Boyle-local evidence-reply bit persists and later changes the `BOYLES_BUTTON` and note/checkbook continuations; `BOYLES_BUTTON` through both `0x2` responses to verify `PC 0x179`, the optional `PC 0x186 -> BOYLE 0x18b` tail, the `d2e98` / `d2eb0` writes, and simple inventory hiding instead of object reset; note/checkbook evidence through response menus `0x3` and `0x4` with and without `HAVE_LODGE_APP` to confirm the restored `PC 0x141` / `0x14a`, `PC 0x1a9`, optional `PC 0x186 -> BOYLE 0x1b8`, and the no-item follow-up staying gated off shared note/checkbook state rather than the local Boyle branch; `GASCAN` after Boyle has accepted the application deal to confirm only `GASCAN` hides and `LODGE_APPLICATION` is awarded; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared `d2ebc` to verify the optional `PC 0x253`; and keyword topics `0xe`, `0x17`, `0x18..0x1b`, typed `Arsonist`, and both visible `BYE` entries to confirm the restored PC/Boyle speaker split and native exit flow.
-- Immediate: run an in-engine Moynahan validation pass that explicitly hits the corrected native-only branches: `IF_TRY_TO_TAKE_THE_GLUE` before and after the slashes-on-body topic unlock to confirm the short `0x3e76` / `0x3e7a` versus extended `0x3e5b..0x3e71` interrupt; presented `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, both ledgers, note/checkbook evidence, `PHOTO_OF_WHALEY_HERRILL`, and `WED_MATCHES` to verify the restored response menus, shared-state writes, portrait variants, and both glue handoff paths; a no-item revisit after shared corpse-photo evidence to confirm `0x3f5b` re-arms the intro; day 5, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, Butcher death, Jimmy death, and `KARIN_FOUND_DEAD` to verify the one-shot bark chain plus the shared kidnapped-discussion writes; and the keyword loop through `0x19a/0x19b`, `0x19d..0x1a0`, `0x1a2/0x1a3`, `0x1a9..0x1ab`, `0x1b2/0x1b3`, `0x1b4/0x1b5` in `MBLM`, `0x1b8..0x1be`, `0x1bf..0x1c2`, typed unknown text, and both visible `BYE` entries to confirm the restored topic-buffer rewrites and fallback flow.
-- Immediate: run an in-engine Buster validation pass that explicitly hits the restored native-only paths: `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, and note/checkbook evidence through response menu `0x23` to confirm the recovered shared-state writes and both blackmail continuations; the first three no-item visits to verify the full `0x163c..0x168e`, `0x1828..0x185e`, and `0x18c5..0x18cd` sequences plus the saved `secondIntroResponseTwoSeen` gate; the keyword loop through topic groups `0x28/0x29`, `0x2c/0x2d`, `0x32`, `0x34`, `0x36`, `0x38`, `0x3b`, `0x3e`, `0x40/0x41`, `0x44/0x45/0x46`, `0x48`, and `0x49` to confirm the restored topic-buffer rewrites and `C058.FST` / `C085.FST` inserts; and at least one fatal branch (`0x183a`, `0x1850`, `0x16d0`, `0x16ef`, `0x1702`, `0x1804`, or `0x18df`) to confirm `C086.FST` plus the recovered game-over screen now hand back to the main menu correctly.
-- Immediate: run an in-engine Herrill validation pass that explicitly hits the corrected native-only paths: `HERRILL_IN_LODGE` before `PC_TALKED_TO_HERRILL` to confirm the full `0x31ca` / `0x31ce[4]` / `0x31d2` / `0x31da` / `0x31de[2]` sequence, the optional `0x31e0[2]` / `0x31e2[2]` insert when the shared Whaley/Herrill-photo flag is already set, the `GRAND_MUCK` monsterfy transition, and the one-shot `PC_TALKED_TO_HERRILL` gate; `PHOTO_OF_WHALEY_HERRILL` followed by a no-item revisit to confirm Herrill no longer self-arms the shared photo followup; `KARIN_FOUND_ALIVE` and `KARIN_FOUND_DEAD` to verify the non-native `0x3050` / `0x3057` and `PC 0x461b` lines are gone; `Lodge`, `The Lodge`, and an unrelated typed topic through the keyword loop to confirm `0x2fdd` on both lodge aliases versus the `0x3099 -> 0x2ff4` unknown-topic fallback; and both visible `BYE` entries to confirm they now share the direct `0x2ff4` exit.
-- Immediate: run an in-engine Dad validation pass that explicitly hits the newly restored native-only paths: `TAKING_BONDAGE` to confirm the `0x3a61 -> C011.FST -> 0x3a6b` tail; first no-item talk to confirm the full `0x39a5` / `0x39a9` / `0x39ad` / `0x39b1[PC2]` / `0x39b5` intro and the shared `d2ed0` / `d2f04` writes; no-item revisits with and without shared state `dadMeatPermissionState` to confirm the automatic `C112S4.FST` permission-conversion branch and the empty visible keyword menu; typed `Lodge`, `The Lodge`, and an unrelated free-text topic through Dad's Other input path to verify the restored `0x3a7d` / `0x3a83` / `0x3a88` special case vs the generic `0x3a2c` fallback; and presented `PHOTO_OF_WHALEY_HERRILL`, note/checkbook evidence, and `MEAT_PERMISSION0` to confirm the corrected shared dialogue-state writes and inventory side effects.
-- Immediate: run an in-engine Butcher and Dad validation pass that explicitly hits the recovered native-only paths: first Butcher talk before and after talking to Dad once to confirm `0x190f` vs `0x1913`; the revisit bark on day 1 with and without `MEAT_PERMISSION0` / `MEAT_PERMISSION` to confirm the `0x261` topic buffer disappears once permission is owned or after day 4; topics `0x263`, `0x267`, `0x26b`, `0x26e`, `0x270`, `0x273/0x274`, `0x277`, and the `0x279` no-op path to verify the restored menu rewrites plus `C119/C060/C119A/C119B/C123/C123A` FST tails; presented `MEAT_PERMISSION` to confirm `MEAT` is awarded and `GOT_MEAT` is set; and then Dad with `MEAT_PERMISSION0` both before and after Butcher grants it to confirm Dad now reads the shared permission state and the first Dad intro seeds `dialogueStateD2ed0`.
-- Immediate: run an in-engine Edna validation pass that explicitly hits the restored native-only branches: `KILLED_KARIN1` to confirm the flag now clears; `DNA_S_SUICIDE_NOTE` with and without `KARIN_FOUND_ALIVE` to confirm `C043.FST` / `C043K.FST`, shared states `d2ea4` / `d2ea8`, and `DNAEXT:SIGNOUT`; direct `K_PURSE` use plus the `KARIN_FOUND_DEAD -> 0xa2 -> 0xa3/0xa4` nested purse menu to verify `REWARD_MONEY` and the `RAH`-hidden purse side effect; the day-5 alive and dead follow-ups; and the keyword loop through the recovered `0xa6`, `0xac/0xad`, `0xb0`, `0xb2`, `0xb6/0xb7`, `0xba`, `0xbc/0xbd/0xbe`, `0xc0`, `0xc2`, typed unknown text, and goodbye branches to confirm the restored menu buffer rewrites and both `C091.FST` / `C070.FST` exits.
-- Immediate: run an in-engine Karin validation pass that explicitly hits the restored native-only branches: the first `KARIN_FOUND_ALIVE` + `IN_CEM10` visit to confirm the full `0x10a5`..`0x10c3` early-return sequence; first and repeat no-item visits before Karin is found alive to confirm the recovered `0x1076`..`0x108b` prelude plus the shared `0x10dc` bark; first and repeat uses of `CASKET_PHOTO` or `PHOTO_OF_WHALEY_HERRILL` to verify the `0x10e7` Edna follow-up, portrait reset, shared evidence-state write, one-shot override, and repeat-photo `CALL_POLICE` escalation; and `INV_MAG` to confirm `0x10ee` / `0x10f2[2]` plus `DNALFT_PERVERT`.
-- Immediate: run an in-engine Dwayne validation pass that explicitly hits the restored native-only branches: `CHECK_EVIDENCE_DOOR` and the first/second `ST_BEDRM` visits to confirm the new flag clears; the sheriff-in-diner intro and revisit to verify Edna `0x3490/0x3496`, the optional `0x34a0/0x34a4` tail, and the later keyword loop; Boyle's button, Karin's purse, note/checkbook photocopy -> original note/checkbook, both `TV_DEED` and `TV_DEED_PHOTOCOPY` response trees plus the override revisit on the real deed, corpse photo with and without shared state `d2ec8`, and both-ledger handoff with and without `SHERIFF_IN_DINER`; `KARIN_KIDNAPED` through response menus `0x7b/0x7c`, `KARIN_FOUND_DEAD` without the purse through `0x7f/0x80`, and the `PC_HAS_GOOJF_CARD` arrest path with and without `PC_KILLED_ANYONE` to confirm the confiscation and bust follow-up state now matches native.
-- Immediate: resume the sequential talk-handler sweep at `MEMB_DIR`, using the proven single-handler workflow `verify <NPC> -> repair <NPC> -> wait ~10s -> save_program -> verify <NPC>` and immediately rerunning the same NPC once when the first saved verify snaps back. After the durable Lodge Chef pass, the durable Sparky retry, the earlier Pastorelli/Parsons work, and the Ryder re-audit, the remaining not-yet-confirmed handler in that queue is `MEMB_DIR`.
-- Immediate: run an in-engine Johnson validation pass that explicitly hits the restored native-only branches: `OPENING_MANHOLE_DAYTIME` twice to confirm `0xbe0` now clears the flag; `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` with and without shared state `d2ebc`, and `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` to verify the restored `0xb7b`, `0xb80`, `0xb90`, `0xb94`, `0xb99`, `0xba4`, and `0xba8` follow-ups; the first `SCRATCHED_TUCKER` visit with `BOLT_OF_CLOTH_TAKEN` already set to confirm `0xc25` / `0xc2b` / `0xc30` plus `0xc5f` / `0xc69` and that the Johnson intro only replays on the later revisit; and a `KARIN_KIDNAPED` follow-up that later reaches a consumer such as Stephanie to confirm the shared kidnapped-dialogue state now propagates correctly.
-- Immediate: run an in-engine Loomis validation pass that explicitly hits the restored native-only branches: first talk with and without `SHERIFF_IN_DINER` to confirm the `0x1112 -> 0x1118 -> 0x1121 -> response 0x53 -> 0x1130[3]` intro; the second and third no-item visits to confirm the one-shot `0x11a8/0x11ac/0x11b0[3]` follow-up, the later `0x11f6` bark, and the `STEPH_MIDGAME_PLAYED` / day-5 `0x1230[3]` and `0x123c` gates; the keyword loop through `LOOMIS`, `Phelps`, `Man's Needs`, sheriff topics, `0x65`, `0x66`, typed unknown text, and goodbye to verify the restored alias matches, portrait variants, `0x11fc` fallback, and `0x11a1[1]` exit; note/checkbook or TV-deed evidence through both `0x50` responses to confirm `0x1214` / `0x1219` / `0x121f[3]` plus `PC_FRY_DADDY_1`; and `INV_MAG` in the diner branch through both `0x51` responses to confirm `0x11c6[1]`, `0x11cc[3]`, drawer visibility, `GAVE_MAG_TO_LOOMIS_TODAY`, and the `C048.FST` tail.
-- Immediate: run an in-engine Mrs. Potts validation pass that explicitly hits the restored native-only paths: `PHOTO_OF_WHALEY_HERRILL`, `CASKET_PHOTO` / `LEDGER` with `HAVE_BOTH_LEDGERS`, and the `GOT_REMAINS_FOR_LODGE` response-1 death branch to confirm the corrected portrait variants; the non-dead revisit gate around `0x281b` / `0x2820` / `0x2825`; and `DINER_BURNED` with and without `KARIN_HUNG`, taking both `0x236` responses to verify the new `0x2914` / `0x2918` / `0x2926` continuation and the response-2 jump into the shared death-in-chair sequence.
-- Immediate: run an in-engine Mr. Potts validation pass that explicitly hits the restored native-only branches: the `HARVEST_BLADE` response menu and `MONSTERFY_POTTS` tail; `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE` to confirm the full `C001B.FST` / `STHLEXIT2B` exit; first and repeat `POTTSDAM_DIGGING`; `POTTSDAM_DIGGING_KARIN`; `MEAT` through both `0x1d4` responses to confirm the reset path; `CASKET_PHOTO`, `PHOTO_OF_WHALEY_HERRILL`, note/checkbook, and TV-deed evidence to verify the restored multi-line exchanges; the later-day Stephanie-alive revisit on day 2 through day 5 to confirm `0x2ae5`; the `KARIN_KIDNAPED` response-2 branch to verify the `STEPH_MIDGAME_PLAYED` gate; the `KARIN_FOUND_DEAD` `0x2b8d..0x2bb6` follow-up; and the topic loop branches for `AMNESIA`, `FATHER`, `SLAUGHTERHOUSE`, `TOILET`, and `MOLEST` to confirm the recovered buffer rewrites and nested menus.
-- Immediate: run an in-engine Jimmy validation pass that explicitly hits the corrected native-only paths: the first no-item visit through response menus `0xf5`, `0xf6`, and `0xf7`; the later no-item `SNEAKERS` revisit through response menu `0xf8`; first and repeat `NEWSPAPER` handoffs to confirm `0x4a4c` / `0x4a52` and the `0x4acc` / `0x4a4b` random bark; direct `SNEAKERS` use to confirm `BROOMKEY` is awarded; and the `LEDGER` / corpse-photo plus note/checkbook evidence exchanges to verify the restored `0x4b19` and `0x4b31` tails.
-- Immediate: run an in-engine Mom validation pass that explicitly hits the now-corrected native-only paths: `CASKET_PHOTO`, `PHOTO_OF_WHALEY_HERRILL`, the `JIMMY_ATTACKED` branch, the `POTTSDAM` second response that reaches `0x20e3`, the `MEAT PLANT` first response (`0x2106 -> 0x210a -> 0x210e`), the `FATHER` topic to confirm `momFatherTopicState` persists through save/load, and the fallback `0x26c6` bark to verify it returns to the keyword loop instead of ending the conversation.
-- Immediate: run an in-engine Sergeant validation pass that explicitly covers the newly restored native tails: `DINER_BURNED` through both `0x2af` and `0x2b0` menus to confirm the `INVITE` / `STEFSKULL` / `DWAYNE_ST_BEDRM` world-state mutations, and `BARBER_POLE` through response menu `0x2b2` with and without `PC_KILLED_ANYONE` set to verify the `0x42a4` / `0x42a8` / `0x42ad` control flow and the day-5 exit fallthrough.
-- Immediate: run an in-engine Stephanie validation pass that hits the corrected native-only branches: first intro/revisit, the `0x45d8/0x45e2` first-followup tail, `SCRATCHED_TUCKER` response `1 -> 1` to confirm `0x4709` keeps the PC portrait on variant `2`, the Mrs. Potts alibi tail around `0x4984/0x4989/0x498f`, and both Karin-alive outcomes with and without the Dwayne follow-up gate to verify that only the gated path plays `0x49c1` plus `0x49ea/0x49ef`.
-- Follow-up: after the sweep finishes, fold the reusable gap-fix logic back into `InspectCallTermination.java` itself so it can automatically clear/disassemble exact fallthrough ranges and optionally absorb tiny tail functions instead of requiring ad hoc helper-script intervention for cases like Boyle, Wasp Woman, and Mom.
-- Follow-up: keep an eye out for any additional tail `JMP` dialogue transfers when finishing the remaining NPCs. `HERRILL` proved that some apparent one-gap leftovers are verifier false positives caused by stale `CALL_RETURN` metadata on jump-based dialogue tails rather than missing handler continuations.
-- Immediate: continue the NPC talk-handler sweep one handler at a time with top-level `verify <NPC>` followed by `repair <NPC>` only when needed, and after each successful repair call `save_program` plus a fresh `verify <NPC>` to confirm the saved `HARVEST.LE` state actually held. Keep using single-handler passes; do not resume `repairrange`.
-- Immediate: restart the live Ghidra GUI/plugin session that owns `HARVEST.LE`, then verify the plugin server is responding on `127.0.0.1:8089` before retrying `repair STEPHANIE`. The current server process is listening but not answering, so no further MCP/script work against `HARVEST.LE` is possible until that restart happens.
-- Immediate: restart the Ghidra GUI / MCP bridge cleanly before attempting any more NPC repairs, because the last dirty-handler save experiment left `run_ghidra_script`, `save_program`, and even `check_connection` timing out. After restart, reopen `HARVEST.LE`, rerun `verify JIMMY` first to confirm whether the saved single-handler repair survived restart, then continue with top-level `repair <NPC>` one handler at a time from `BUSTER` onward.
-- Follow-up: once the fresh session is up, finish isolating the dirty-save blocker inside `InspectCallTermination.java`. The most likely next experiments are: wait/poll for auto-analysis completion without calling `analyzeChanges()` from the Swing thread, or temporarily disable/suppress auto-analysis around handler recreation so `saveProgram()` can complete after a dirty repair. Do not resume `repairrange` until a dirty single-handler save has been proven durable on disk.
-- Immediate: run an in-engine Harvester combat pass in `MR_JOHNS` to confirm Johnson's monster now enters melee instead of looping `reason='no native attack bank'`, and verify at least one lethal monster hit still plays a death bank now that full-frame-range checks are used.
-- Follow-up: if melee now triggers but the chase still feels too aggressive, capture one short native-vs-ScummVM comparison clip and re-audit the class-6 locomotion step sizing around `update_actor_runtime_state` before changing movement cadence; the current Ghidra evidence does not justify slowing monsters yet.
+- Re-run the in-engine `EYEHALL` repro against DOSBox and confirm the live BIG_EYE kill now shows the native death bank before `EYEDOOR`, that the hover falls back to the `BIGEYE` examine hotspot instead of `Talk to big eye`, and that no other class-`4` NPCs regress under the new handler/death-type talk gate.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 07f764c9e03..28703de27eb 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -474,6 +474,15 @@ DialogueSystem::~DialogueSystem() {
 		delete _npcHandlers[i];
 }
 
+bool DialogueSystem::hasRoomNpcHandler(const Common::String &npcName) const {
+	for (uint i = 0; i < _npcHandlers.size(); ++i) {
+		if (_npcHandlers[i]->matchesNpc(npcName))
+			return true;
+	}
+
+	return false;
+}
+
 void DialogueSystem::registerNpcHandlers() {
 	_npcHandlers.push_back(new AuthorityDialogueHandler());
 	_npcHandlers.push_back(new BeggarDialogueHandler());
@@ -561,6 +570,19 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 	const Graphics::Font *fallbackFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
 	if (!screen || !startupScript || !startupText || !startupArt || !fallbackFont || !backdrop.isValid())
 		return Common::kReadingFailed;
+	if (!hasRoomNpcHandler(npc.npcName)) {
+		debugC(1, kDebugDialogue,
+			"Harvester: blocked room NPC dialogue npc='%s' reason='no handler'",
+			npc.npcName.c_str());
+		return Common::kNoError;
+	}
+	if (!startupScript->isNamedNpcDeathTypeClear(npc.npcName)) {
+		const StartupNpcRecord *runtimeNpc = startupScript->findRuntimeNpcRecord(npc.npcName);
+		debugC(1, kDebugDialogue,
+			"Harvester: blocked room NPC dialogue npc='%s' reason='death queued' damage_type=%d",
+			npc.npcName.c_str(), runtimeNpc ? runtimeNpc->deathDamageType : -1);
+		return Common::kNoError;
+	}
 
 	const CftFontResource *subtitleFontResource = findStartupFontByName(_engine, "TEXTFONT");
 	const CftFontResource *menuFontResource = findStartupFontByName(_engine, "TEXTFNT2");
diff --git a/engines/harvester/dialogue.h b/engines/harvester/dialogue.h
index b44a9216564..3957ee144fc 100644
--- a/engines/harvester/dialogue.h
+++ b/engines/harvester/dialogue.h
@@ -46,6 +46,7 @@ public:
 	DialogueSystem(HarvesterEngine &engine, Common::Point &mousePos);
 	~DialogueSystem();
 
+	bool hasRoomNpcHandler(const Common::String &npcName) const;
 	void resetRoomNpcDialogueState();
 	void syncRuntimeSaveState(Common::Serializer &s);
 	Common::Error runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 803bbd69bb7..256b4c7e3dc 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -727,6 +727,15 @@ static const StartupNpcRecord *findSceneNpcByName(const Common::Array<StartupNpc
 	return nullptr;
 }
 
+static bool canTalkToRoomNpc(HarvesterEngine &engine, const StartupNpcRecord &npc,
+		const DialogueSystem *dialogue) {
+	Script *startupScript = engine.getStartupScript();
+	if (!startupScript || !startupScript->isNamedNpcDeathTypeClear(npc.npcName))
+		return false;
+
+	return dialogue && dialogue->hasRoomNpcHandler(npc.npcName);
+}
+
 StartupObjectRecord *findSceneObjectByName(Common::Array<StartupObjectRecord> &objects,
 		const Common::String &objectName) {
 	for (StartupObjectRecord &object : objects) {
@@ -818,7 +827,8 @@ static const RuntimeEntity *findRoomPlayerAtPoint(HarvesterEngine &engine, const
 }
 
 static const StartupNpcRecord *findRoomNpcAtPoint(HarvesterEngine &engine,
-		const Common::Array<StartupNpcRecord> &sceneNpcs, const Common::Point &point) {
+		const Common::Array<StartupNpcRecord> &sceneNpcs, const Common::Point &point,
+		const DialogueSystem *dialogue) {
 	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
 	if (!runtimeEntities)
 		return nullptr;
@@ -827,7 +837,11 @@ static const StartupNpcRecord *findRoomNpcAtPoint(HarvesterEngine &engine,
 	if (!entity || entity->getClassId() != kRuntimeEntityClassNpc)
 		return nullptr;
 
-	return findSceneNpcByName(sceneNpcs, entity->getName());
+	const StartupNpcRecord *npc = findSceneNpcByName(sceneNpcs, entity->getName());
+	if (!npc || !canTalkToRoomNpc(engine, *npc, dialogue))
+		return nullptr;
+
+	return npc;
 }
 
 const IndexedBitmap *resolveInspectTextboxBitmap(const Art &art, const StartupResolvedText &text) {
@@ -949,7 +963,8 @@ bool doesPlayerOverlapRegion(const RuntimeEntity &playerEntity, const StartupReg
 StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const StartupRoomSetupState &state,
 		const Common::Array<StartupObjectRecord> &sceneObjects,
 		const Common::Array<StartupNpcRecord> &sceneNpcs,
-		const Common::Array<StartupRegionRecord> &sceneRegions, const Common::Point &mousePos) {
+		const Common::Array<StartupRegionRecord> &sceneRegions, const Common::Point &mousePos,
+		const DialogueSystem *dialogue) {
 	StartupRoomHoverState hoverState;
 	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
 	if (const RuntimeEntity *playerEntity = findRoomPlayerAtPoint(engine, mousePos)) {
@@ -957,7 +972,7 @@ StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const Start
 		hoverState.cursorSequence = kCursorSequenceExamine;
 		return hoverState;
 	}
-	if (const StartupNpcRecord *npc = findRoomNpcAtPoint(engine, sceneNpcs, mousePos)) {
+	if (const StartupNpcRecord *npc = findRoomNpcAtPoint(engine, sceneNpcs, mousePos, dialogue)) {
 		hoverState.npc = npc;
 		hoverState.cursorSequence = kCursorSequenceTalk;
 		hoverState.promptText = buildRoomNpcPrompt(*npc);
diff --git a/engines/harvester/npc/chessmaster_dialogue.cpp b/engines/harvester/npc/chessmaster_dialogue.cpp
index 0f9fe27e4a8..50591658487 100644
--- a/engines/harvester/npc/chessmaster_dialogue.cpp
+++ b/engines/harvester/npc/chessmaster_dialogue.cpp
@@ -439,7 +439,7 @@ Common::Error ChessmasterDialogueHandler::handleDialogue(DialogueRuntime &runtim
 		return lineError;
 
 	StartupInteractionResult interaction;
-	if (runtime.startupScript().triggerRuntimeNpcDeathOrMonsterfy(
+	if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(
 			kChessmasterNpc, kChessmasterSlashDamageType)) {
 		interaction.mutatedRuntimeState = true;
 	}
diff --git a/engines/harvester/npc/dark_woman_dialogue.cpp b/engines/harvester/npc/dark_woman_dialogue.cpp
index 02220cf8cff..3fa597927f8 100644
--- a/engines/harvester/npc/dark_woman_dialogue.cpp
+++ b/engines/harvester/npc/dark_woman_dialogue.cpp
@@ -53,7 +53,7 @@ Common::Error DarkWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto queueDarkWomanDeathOrMonsterfyTransition = [&]() {
 		StartupInteractionResult interaction;
-		if (runtime.startupScript().triggerRuntimeNpcDeathOrMonsterfy(kDarkWomanNpc))
+		if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(kDarkWomanNpc))
 			interaction.mutatedRuntimeState = true;
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 	};
diff --git a/engines/harvester/npc/gladiator_dialogue.cpp b/engines/harvester/npc/gladiator_dialogue.cpp
index 631d450c553..92ae99b1b10 100644
--- a/engines/harvester/npc/gladiator_dialogue.cpp
+++ b/engines/harvester/npc/gladiator_dialogue.cpp
@@ -49,7 +49,7 @@ Common::Error GladiatorDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto queueGladiatorDeathOrMonsterfyTransition = [&]() {
 		StartupInteractionResult interaction;
-		if (runtime.startupScript().triggerRuntimeNpcDeathOrMonsterfy(kGladiatorNpc))
+		if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(kGladiatorNpc))
 			interaction.mutatedRuntimeState = true;
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 	};
diff --git a/engines/harvester/npc/herrill_dialogue.cpp b/engines/harvester/npc/herrill_dialogue.cpp
index 864273c6d35..982355f9e97 100644
--- a/engines/harvester/npc/herrill_dialogue.cpp
+++ b/engines/harvester/npc/herrill_dialogue.cpp
@@ -85,7 +85,7 @@ Common::Error HerrillDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto queueNpcSlashTransition = [&](const char *npcName) {
 		StartupInteractionResult interaction;
-		if (runtime.startupScript().triggerRuntimeNpcDeathOrMonsterfy(
+		if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(
 				npcName, kSlashDeathDamageType)) {
 			interaction.mutatedRuntimeState = true;
 		}
diff --git a/engines/harvester/npc/madam_dialogue.cpp b/engines/harvester/npc/madam_dialogue.cpp
index 9ebc92a1959..49cda39a419 100644
--- a/engines/harvester/npc/madam_dialogue.cpp
+++ b/engines/harvester/npc/madam_dialogue.cpp
@@ -58,7 +58,7 @@ Common::Error MadamDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto queueHookerDeathOrMonsterfyTransition = [&]() {
 		StartupInteractionResult interaction;
-		if (runtime.startupScript().triggerRuntimeNpcDeathOrMonsterfy(kHookerNpc))
+		if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(kHookerNpc))
 			interaction.mutatedRuntimeState = true;
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 	};
diff --git a/engines/harvester/npc/mr_potts_dialogue.cpp b/engines/harvester/npc/mr_potts_dialogue.cpp
index c3215d67010..a5e065e6570 100644
--- a/engines/harvester/npc/mr_potts_dialogue.cpp
+++ b/engines/harvester/npc/mr_potts_dialogue.cpp
@@ -313,7 +313,7 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto queueNpcDeathOrMonsterfyTransition = [&](const char *npcName) {
 		StartupInteractionResult interaction;
-		if (runtime.startupScript().triggerRuntimeNpcDeathOrMonsterfy(npcName))
+		if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(npcName))
 			interaction.mutatedRuntimeState = true;
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 	};
diff --git a/engines/harvester/npc/sergeant_dialogue.cpp b/engines/harvester/npc/sergeant_dialogue.cpp
index 9b05e5a7fc5..fe1d79a19a7 100644
--- a/engines/harvester/npc/sergeant_dialogue.cpp
+++ b/engines/harvester/npc/sergeant_dialogue.cpp
@@ -100,7 +100,7 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto queueNpcSlashTransition = [&](const char *npcName) {
 		StartupInteractionResult interaction;
-		if (runtime.startupScript().triggerRuntimeNpcDeathOrMonsterfy(
+		if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(
 				npcName, kSlashDeathDamageType)) {
 			interaction.mutatedRuntimeState = true;
 		}
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index b3627883b2e..ec3541cdd58 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -124,6 +124,13 @@ struct RoomMonsterCombatState {
 	uint32 nextAttackAllowedTick = 0;
 };
 
+struct RoomNpcCombatState {
+	bool deathActive = false;
+	int deathFirstFrame = -1;
+	int deathLastFrame = -1;
+	int deathDamageType = 0;
+};
+
 struct RoomHitEffectState {
 	Common::String entityName;
 	Common::String followTargetName;
@@ -496,6 +503,27 @@ static bool resolveMonsterDeathAnimationRange(const RuntimeEntity &entity, int f
 		chooseIfAvailable(fallbackSlash) || chooseIfAvailable(fallbackProjectile);
 }
 
+static bool resolveNpcDeathAnimationRange(const RuntimeEntity &entity, bool hasMonsterfyTarget,
+		int deathDamageType, bool goreEnabled, RoomDeathAnimationRange &range) {
+	const RoomDeathAnimationRange goreBank(0x3c, 0x45);
+	const RoomDeathAnimationRange bludgeBank(0x46, 0x4f);
+	const bool preferGoreBank = hasMonsterfyTarget || (goreEnabled && deathDamageType != 1);
+
+	auto chooseIfAvailable = [&](const RoomDeathAnimationRange &candidate) {
+		if (!runtimeEntityHasFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
+			return false;
+		range = candidate;
+		return true;
+	};
+
+	if (preferGoreBank && chooseIfAvailable(goreBank))
+		return true;
+	if (chooseIfAvailable(bludgeBank))
+		return true;
+
+	return chooseIfAvailable(goreBank);
+}
+
 static int stepTowardsRoomCombatInt(int current, int target, int step) {
 	if (step <= 0 || current == target)
 		return current;
@@ -626,6 +654,10 @@ static void clearRoomMonsterCombatState(RoomMonsterCombatState &combatState) {
 	combatState = RoomMonsterCombatState();
 }
 
+static void clearRoomNpcCombatState(RoomNpcCombatState &combatState) {
+	combatState = RoomNpcCombatState();
+}
+
 static void clearRoomMonsterAttackState(RoomMonsterCombatState &combatState) {
 	combatState.attackActive = false;
 	combatState.attackFirstFrame = -1;
@@ -764,6 +796,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		playerState.turnTargetFacing = -1;
 		Common::Array<RoomMonsterCombatState> monsterCombatStates;
 		monsterCombatStates.resize(scene.state.roomMonsters.size());
+		Common::Array<RoomNpcCombatState> npcCombatStates;
+		npcCombatStates.resize(scene.state.roomNpcs.size());
 		Common::Array<RoomHitEffectState> hitEffectStates;
 		Common::Array<RoomCombatDamagePopupState> damagePopupStates;
 		uint nextCombatEffectId = 0;
@@ -2203,6 +2237,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 			return nullptr;
 		};
+		auto findRoomNpcIndexByName = [&](const Common::String &npcName) -> int {
+			for (uint i = 0; i < scene.state.roomNpcs.size(); ++i) {
+				if (scene.state.roomNpcs[i].npcName.equalsIgnoreCase(npcName))
+					return (int)i;
+			}
+
+			return -1;
+		};
 		auto findRoomMonsterRecordByName = [&](const Common::String &monsterName) -> StartupMonsterRecord * {
 			for (StartupMonsterRecord &monster : scene.state.roomMonsters) {
 				if (monster.monsterName.equalsIgnoreCase(monsterName))
@@ -2269,7 +2311,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				return nullptr;
 
 			for (StartupNpcRecord &npc : scene.state.roomNpcs) {
-				if (!npc.visible || npc.deathOrMonsterfyFlag)
+				if (!npc.visible || npc.deathOrMonsterfyFlag || npc.deathDamageType != 0)
 					continue;
 
 				RuntimeEntity *entity = findSceneRuntimeEntity(npc.npcName);
@@ -2363,6 +2405,58 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		needsRedraw = true;
 		return Common::kNoError;
 	};
+	auto beginNpcDeathTransition = [&](StartupNpcRecord &npc, RuntimeEntity &npcEntity,
+			RoomNpcCombatState &combatState, int deathDamageType) {
+		RoomDeathAnimationRange deathRange;
+		if (!resolveNpcDeathAnimationRange(npcEntity, !npc.monsterfyTargetName.empty(),
+				deathDamageType, _engine.isGoreEnabled(), deathRange))
+			return false;
+
+		combatState.deathActive = true;
+		combatState.deathFirstFrame = deathRange.firstFrame;
+		combatState.deathLastFrame = deathRange.lastFrame;
+		combatState.deathDamageType = deathDamageType;
+		npc.deathDamageType = deathDamageType;
+		npcEntity.setAnimationFrameRange(deathRange.firstFrame, deathRange.lastFrame, false);
+		npcEntity.setAnimationRate(npc.frameDelay > 0 ? npc.frameDelay : 0);
+		npcEntity.setAnimationEnabled(true);
+		npcEntity.setCurrentFrame(deathRange.firstFrame);
+		if (!npc.audioPath.empty())
+			(void)_engine.playStartupSound(npc.audioPath);
+		debugC(1, kDebugCombat,
+			"Harvester: combat npc death start target='%s' damage_type=%d frames=%d..%d monsterfy='%s' on_death='%s'",
+			npc.npcName.c_str(), deathDamageType, deathRange.firstFrame, deathRange.lastFrame,
+			npc.monsterfyTargetName.c_str(), npc.onDeathActionTag.c_str());
+		needsRedraw = true;
+		return true;
+	};
+	auto finalizeNpcDeathTransition = [&](StartupNpcRecord &npc,
+			RoomNpcCombatState &combatState) -> Common::Error {
+		Script *runtimeScript = _engine.getStartupScript();
+		npc.deathOrMonsterfyFlag = true;
+		if (combatState.deathDamageType != 0)
+			npc.deathDamageType = combatState.deathDamageType;
+		const bool runtimeChanged = runtimeScript
+			? runtimeScript->finalizeRuntimeNpcDeathOrMonsterfy(npc.npcName, npc.deathDamageType)
+			: false;
+		debugC(1, kDebugCombat,
+			"Harvester: combat npc death complete target='%s' damage_type=%d last_frame=%d monsterfy='%s' on_death='%s'",
+			npc.npcName.c_str(), combatState.deathDamageType, combatState.deathLastFrame,
+			npc.monsterfyTargetName.c_str(), npc.onDeathActionTag.c_str());
+		clearRoomNpcCombatState(combatState);
+
+		StartupInteractionResult interaction;
+		interaction.mutatedRuntimeState = runtimeChanged;
+		if (runtimeScript && !npc.onDeathActionTag.empty()) {
+			StartupInteractionResult deathInteraction;
+			if (runtimeScript->executeActionTag(npc.onDeathActionTag, deathInteraction)) {
+				interaction = deathInteraction;
+				interaction.mutatedRuntimeState = true;
+			}
+		}
+
+		return handleCombatInteraction(interaction);
+	};
 	auto killDebugActiveMonster = [&]() -> Common::Error {
 		if (!_engine.isCombatDebugEnabled())
 			return Common::kNoError;
@@ -2502,19 +2596,20 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		return handleCombatInteraction(interaction);
 	};
 	auto capturePlayerAttackTarget = [&]() {
-		playerState.attackTargetName.clear();
-		playerState.attackTargetClassId = -1;
+			playerState.attackTargetName.clear();
+			playerState.attackTargetClassId = -1;
 
-		const StartupRoomHoverState hoverState = resolveRoomHoverState(
-			_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions, _mousePos);
-		if (hoverState.npc) {
-			playerState.attackTargetName = hoverState.npc->npcName;
-			playerState.attackTargetClassId = kRuntimeEntityClassNpc;
-			debugC(1, kDebugCombat,
-				"Harvester: combat player target capture class='npc' name='%s' cursor=(%d,%d)",
-				playerState.attackTargetName.c_str(), _mousePos.x, _mousePos.y);
-			return;
-		}
+			const StartupRoomHoverState hoverState = resolveRoomHoverState(
+				_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions,
+				_mousePos, &startupFlow._dialogue);
+			if (hoverState.npc) {
+				playerState.attackTargetName = hoverState.npc->npcName;
+				playerState.attackTargetClassId = kRuntimeEntityClassNpc;
+				debugC(1, kDebugCombat,
+					"Harvester: combat player target capture class='npc' name='%s' cursor=(%d,%d)",
+					playerState.attackTargetName.c_str(), _mousePos.x, _mousePos.y);
+				return;
+			}
 
 		if (StartupMonsterRecord *monster = findMonsterTargetAtPoint(_mousePos)) {
 			playerState.attackTargetName = monster->monsterName;
@@ -2622,7 +2717,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		};
 
 		for (StartupNpcRecord &npc : scene.state.roomNpcs) {
-			if (!npc.visible || npc.deathOrMonsterfyFlag)
+			if (!npc.visible || npc.deathOrMonsterfyFlag || npc.deathDamageType != 0)
 				continue;
 			considerTarget(findSceneRuntimeEntity(npc.npcName), npc.npcName, kRuntimeEntityClassNpc);
 		}
@@ -2688,10 +2783,20 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			}
 
 			const bool runtimeChanged =
-				startupScript->triggerRuntimeNpcDeathOrMonsterfy(npc->npcName, damageType);
+				startupScript->queueRuntimeNpcDeathOrMonsterfy(npc->npcName, damageType);
 			const bool jimmyFlagChanged = npc->npcName.equalsIgnoreCase("JIMMY")
 				? startupScript->setRuntimeFlagValue("JIMMY_ATTACKED", true)
 				: false;
+			const int npcIndex = findRoomNpcIndexByName(npc->npcName);
+			RoomNpcCombatState *combatState =
+				npcIndex >= 0 && npcIndex < (int)npcCombatStates.size()
+					? &npcCombatStates[(uint)npcIndex]
+					: nullptr;
+			if (runtimeChanged) {
+				npc->deathDamageType = damageType;
+				if (combatState)
+					clearRoomNpcCombatState(*combatState);
+			}
 			debugC(1, kDebugCombat,
 				"Harvester: combat player npc hit target='%s' damage_type=%d runtime_changed=%d jimmy_flag=%d",
 				npc->npcName.c_str(), damageType, runtimeChanged, jimmyFlagChanged);
@@ -2702,18 +2807,19 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				if (spawnCombatHitEffect(*npcEntity, followTargetName))
 					needsRedraw = true;
 			}
-
-			StartupInteractionResult interaction;
-			interaction.mutatedRuntimeState = runtimeChanged;
-			if (npc->monsterfyTargetName.empty() && !npc->onDeathActionTag.empty()) {
-				StartupInteractionResult deathInteraction;
-				if (startupScript->executeActionTag(npc->onDeathActionTag, deathInteraction)) {
-					interaction = deathInteraction;
-					interaction.mutatedRuntimeState = true;
-				}
+			if (runtimeChanged && combatState && npcEntity &&
+					beginNpcDeathTransition(*npc, *npcEntity, *combatState, damageType))
+				return Common::kNoError;
+			if (runtimeChanged) {
+				debugC(1, kDebugCombat,
+					"Harvester: combat npc defeated target='%s' damage_type=%d on_death='%s' fallback='no death bank'",
+					npc->npcName.c_str(), damageType, npc->onDeathActionTag.c_str());
+				RoomNpcCombatState fallbackState;
+				fallbackState.deathDamageType = damageType;
+				return finalizeNpcDeathTransition(*npc, combatState ? *combatState : fallbackState);
 			}
 
-			return handleCombatInteraction(interaction);
+			return Common::kNoError;
 		}
 
 		StartupMonsterRecord *monster = nullptr;
@@ -2812,9 +2918,31 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			scene.state.roomZVelocityStep > 0.0f ? scene.state.roomZVelocityStep : 1.0f));
 		bool playerAlive = startupScript && startupScript->getPlayerCurrentHitPoints() > 0;
 
+		if (npcCombatStates.size() != scene.state.roomNpcs.size())
+			npcCombatStates.resize(scene.state.roomNpcs.size());
 		if (monsterCombatStates.size() != scene.state.roomMonsters.size())
 			monsterCombatStates.resize(scene.state.roomMonsters.size());
 
+		for (uint i = 0; i < scene.state.roomNpcs.size(); ++i) {
+			StartupNpcRecord &npc = scene.state.roomNpcs[i];
+			RoomNpcCombatState &combatState = npcCombatStates[i];
+			RuntimeEntity *entity = findSceneRuntimeEntity(npc.npcName);
+			if (!entity || !npc.visible || npc.deathOrMonsterfyFlag) {
+				clearRoomNpcCombatState(combatState);
+				continue;
+			}
+			if (!combatState.deathActive)
+				continue;
+			if (entity->getCurrentFrame() != combatState.deathLastFrame)
+				continue;
+
+			entity->setAnimationRate(0);
+			entity->setAnimationFrameRange(combatState.deathLastFrame, combatState.deathLastFrame, false);
+			entity->setCurrentFrame(combatState.deathLastFrame);
+			entity->setAnimationEnabled(false);
+			return finalizeNpcDeathTransition(npc, combatState);
+		}
+
 		for (uint i = 0; i < scene.state.roomMonsters.size(); ++i) {
 			StartupMonsterRecord &monster = scene.state.roomMonsters[i];
 			RoomMonsterCombatState &combatState = monsterCombatStates[i];
@@ -3354,7 +3482,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			StartupRoomHoverState hoverState = suppressHover
 				? StartupRoomHoverState()
 				: resolveRoomHoverState(_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
-					scene.sceneRegions, _mousePos);
+					scene.sceneRegions, _mousePos, &startupFlow._dialogue);
 			if (!suppressHover && inventorySelectionActive && !hoverState.npc) {
 				if (StartupObjectRecord *selectedTarget = findSelectedInventoryRoomTarget(_mousePos))
 					hoverState.object = selectedTarget;
@@ -3679,7 +3807,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				}
 
 				const StartupRoomHoverState hoverState = resolveRoomHoverState(
-					_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions, _mousePos);
+					_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions,
+					_mousePos, &startupFlow._dialogue);
 				StartupObjectRecord *selectedRoomTarget = nullptr;
 				if (_inventory.hasSelection() && !hoverState.npc)
 					selectedRoomTarget = findSelectedInventoryRoomTarget(_mousePos);
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index ba60c880367..bd9821ea12e 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -39,6 +39,7 @@ class HarvesterEngine;
 class ResourceManager;
 class RuntimeEntity;
 class Art;
+class DialogueSystem;
 struct IndexedBitmap;
 
 struct StartupRoomSceneResources {
@@ -129,7 +130,7 @@ StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const Start
 	const Common::Array<StartupObjectRecord> &sceneObjects,
 	const Common::Array<StartupNpcRecord> &npcs,
 	const Common::Array<StartupRegionRecord> &regions,
-	const Common::Point &mousePos);
+	const Common::Point &mousePos, const DialogueSystem *dialogue = nullptr);
 void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResources &scene,
 	const Common::String &entranceName, Common::Point &mousePos);
 
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 026a810fc22..3b0fd1fa8bf 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -2276,7 +2276,22 @@ bool Script::setRuntimeTimerEnabled(const Common::String &timerName, bool enable
 	return changed;
 }
 
-bool Script::triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType) {
+bool Script::queueRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType) {
+	if (npcName.empty())
+		return false;
+
+	StartupNpcRecord *runtimeNpc = findRuntimeNpc(npcName);
+	if (!runtimeNpc || runtimeNpc->deathOrMonsterfyFlag)
+		return false;
+
+	if (deathDamageType == 0 || runtimeNpc->deathDamageType == deathDamageType)
+		return false;
+
+	runtimeNpc->deathDamageType = deathDamageType;
+	return true;
+}
+
+bool Script::finalizeRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType) {
 	if (npcName.empty())
 		return false;
 
@@ -2764,30 +2779,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			}
 
 			const int deathDamageType = parseNpcDeathDamageType(command->arg2);
-			bool monsterChanged = false;
-			if (command->opcodeName.equalsIgnoreCase("MONSTERFY") && !runtimeNpc->monsterfyTargetName.empty()) {
-				StartupMonsterRecord *runtimeMonster = findRuntimeMonster(runtimeNpc->monsterfyTargetName);
-				if (runtimeMonster) {
-					monsterChanged = !runtimeMonster->active || !runtimeMonster->visible;
-					runtimeMonster->active = true;
-					runtimeMonster->visible = true;
-					runtimeMonster->runtimeSpawned = false;
-					runtimeMonster->runtimeState = -1;
-					if (runtimeMonster->currentHitPoints <= 0)
-						runtimeMonster->currentHitPoints = runtimeMonster->initialHitPoints;
-				} else {
-					debug(1, "Harvester: unresolved monsterfy target for %s '%s' npc='%s' target='%s'",
-						contextLabel, contextName.c_str(), command->arg1.c_str(),
-						runtimeNpc->monsterfyTargetName.c_str());
-				}
-			}
-
-			const bool changed = !runtimeNpc->deathOrMonsterfyFlag ||
-				(deathDamageType != 0 && runtimeNpc->deathDamageType != deathDamageType);
-			runtimeNpc->deathOrMonsterfyFlag = true;
-			if (deathDamageType != 0)
-				runtimeNpc->deathDamageType = deathDamageType;
-			noteMutation(changed || monsterChanged);
+			noteMutation(finalizeRuntimeNpcDeathOrMonsterfy(command->arg1, deathDamageType));
 			currentTag = command->arg4;
 			continue;
 		}
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index d1e8eaf0ed5..f7939271c3e 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -393,7 +393,8 @@ public:
 	bool setRuntimeObjectVisible(const Common::String &ownerOrRoom, const Common::String &objectName,
 		bool visible);
 	bool setRuntimeNpcState(const Common::String &npcName, bool active, bool visible);
-	bool triggerRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType = 0);
+	bool queueRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType = 0);
+	bool finalizeRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType = 0);
 	bool isNamedNpcDeathTypeClear(const Common::String &npcName) const;
 	int getCurrentStoryDayIndex() const;
 	int getPlayerCurrentHitPoints() const { return _playerCurrentHitPoints; }


Commit: d837c65c72c73dd19142fa8d44935392b20104e6
    https://github.com/scummvm/scummvm/commit/d837c65c72c73dd19142fa8d44935392b20104e6
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:33+02:00

Commit Message:
HARVESTER: Finalize room npc death removal

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp
    engines/harvester/script.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 5aa5db4e251..ee8c9a168cd 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On March 31, 2026, confirmed via Ghidra that native class-`4` room NPCs queue a live death state first, play a death bank (`0x3c..0x45` for gore/monsterfy or `0x46..0x4f` for bludge), and only then finalize suppression and run the NPC `onDeathActionTag`. Patched ScummVM so BIG_EYE and other class-`4` NPCs now use that queued death flow, and gated room-NPC talk hover/dialogue on both `deathDamageType == 0` and an installed dialogue handler. Verified with `make -j4 engines/harvester/flow.o engines/harvester/room.o engines/harvester/dialogue.o engines/harvester/script.o` and `git diff --check`.
+- On March 31, 2026, patched Harvester’s class-`4` NPC death finalization so a completed NPC kill now clears the authoritative runtime NPC record (`active=0`, `visible=0`, `savedVisible=0`, `deathOrMonsterfy=1`) and immediately removes the live scene actor before the `onDeathActionTag` refresh path runs. This closes the gap where BIG_EYE could finish its death bank yet remain eligible for redisplay during the post-death room update. Verified with `make -j4 scummvm`.
 
 ## Next Suggested Action
 
-- Re-run the in-engine `EYEHALL` repro against DOSBox and confirm the live BIG_EYE kill now shows the native death bank before `EYEDOOR`, that the hover falls back to the `BIGEYE` examine hotspot instead of `Talk to big eye`, and that no other class-`4` NPCs regress under the new handler/death-type talk gate.
+- Re-run the in-engine `EYEHALL` repro and confirm the BIG_EYE death bank ends with the actor removed, `EYEDOOR` still enables `EYE_MAIN`, and no other class-`4` NPC deaths regress by disappearing too early or failing monsterfy transitions.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index ec3541cdd58..96bca876d98 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -2433,6 +2433,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 	auto finalizeNpcDeathTransition = [&](StartupNpcRecord &npc,
 			RoomNpcCombatState &combatState) -> Common::Error {
 		Script *runtimeScript = _engine.getStartupScript();
+		removeSceneEntityByName(npc.npcName);
+		npc.active = false;
+		npc.visible = false;
 		npc.deathOrMonsterfyFlag = true;
 		if (combatState.deathDamageType != 0)
 			npc.deathDamageType = combatState.deathDamageType;
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 3b0fd1fa8bf..dc0be63beb1 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -2317,7 +2317,12 @@ bool Script::finalizeRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, i
 	}
 
 	const bool changed = !runtimeNpc->deathOrMonsterfyFlag ||
+		runtimeNpc->active ||
+		runtimeNpc->visible ||
 		(deathDamageType != 0 && runtimeNpc->deathDamageType != deathDamageType);
+	runtimeNpc->active = false;
+	runtimeNpc->visible = false;
+	runtimeNpc->savedVisible = false;
 	runtimeNpc->deathOrMonsterfyFlag = true;
 	if (deathDamageType != 0)
 		runtimeNpc->deathDamageType = deathDamageType;


Commit: e3f20c8fb0fc36472872599384f0bc249d2adc01
    https://github.com/scummvm/scummvm/commit/e3f20c8fb0fc36472872599384f0bc249d2adc01
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:34+02:00

Commit Message:
HARVESTER: Refine combat data docs

Refine combat-related data structures and HARVEST.LE notes.

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/monster.h
    engines/harvester/player.cpp
    engines/harvester/room_support.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index f9701d4bbc6..fbe0402046a 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -227,13 +227,14 @@
 ### Render Entity Runtime
 
 - `Confidence:` High.
-- `Evidence:` `spawn_scaled_abm_entity_from_resource`, `attach_abm_resource_to_entity`, `show_entity_visual`, `hide_entity_visual`, `rescale_entity_sprite_for_depth`, `tick_entity_visual_state`, and `update_actor_runtime_state` all operate on the same `0x11bc` runtime record.
+- `Evidence:` `spawn_scaled_abm_entity_from_resource`, `attach_abm_resource_to_entity`, `show_entity_visual`, `hide_entity_visual`, `rescale_entity_sprite_for_depth`, `tick_entity_visual_state`, and `update_actor_runtime_state` all operate on the same `0x11d0` runtime record.
 - `Evidence:` recovered helper types line up with the entity materialization path: `BitmapBuffer` is the 12-byte `{ width, height, pixels }` bitmap header, `AbmFrameHeader` is the packed per-frame ABM prefix, `DirtyRectNode` is the 20-byte dirty-rect node, and `ActorWaypoint[10]` is the fixed array at `RenderEntityRuntime + 0x10ac`.
 - `Evidence:` switching the simple entity helpers to Borland `__fastcall` confirms that `tick_entity_visual_state` writes the current opaque overlap blocker at `+0x109c`, `update_actor_runtime_state` uses directional blocker-history slots at `+0x108c`, `+0x1090`, `+0x1094`, and `+0x1098`, and `attach_abm_resource_to_entity` builds a `1024`-entry frame-header table at `+0x5c`.
 - `Key Functions:` `spawn_scaled_abm_entity_from_resource`, `attach_abm_resource_to_entity`, `show_entity_visual`, `hide_entity_visual`, `rescale_entity_sprite_for_depth`, `tick_entity_visual_state`, `update_actor_runtime_state`.
 - `Key Data:` `RenderEntityRuntime`, `BitmapBuffer`, `AbmFrameHeader`, `DirtyRectNode`, `ActorWaypoint[10]`, `g_render_entity_list`.
 - `Notes:` the current typed runtime record cleanly covers render placement, animation timing, waypoint storage, and blocker-history detours.
 - `Notes:` the actor-only tail from `+0x1134` through `+0x118e` is still only partially typed, but it now clearly contains combat/use-state sound slots and facing-bank flags rather than generic render state.
+- `Notes:` confirmed actor-tail roles now include `+0x112c = engage_distance`, `+0x113c = attack_contact_frame_offset`, `+0x1180 = attack_damage_amount`, `+0x1188 = last_hit_combat_flags`, `+0x118c = combat_capability_flags`, `+0x11a4 = combat_target_entity`, `+0x11b8 = collision_partner_entity`, and `+0x11bc = player_combat_loadout_id`.
 
 ### Room And Event Flow
 
diff --git a/TRACKER.md b/TRACKER.md
index ee8c9a168cd..afb1edbfadb 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On March 31, 2026, patched Harvester’s class-`4` NPC death finalization so a completed NPC kill now clears the authoritative runtime NPC record (`active=0`, `visible=0`, `savedVisible=0`, `deathOrMonsterfy=1`) and immediately removes the live scene actor before the `onDeathActionTag` refresh path runs. This closes the gap where BIG_EYE could finish its death bank yet remain eligible for redisplay during the post-death room update. Verified with `make -j4 scummvm`.
+- On March 31, 2026, rechecked `RenderEntityRuntime` against `spawn_monster_entity_from_record`, `spawn_player_combat_avatar`, `set_player_combat_loadout`, `spawn_npc_entity_from_record`, and `update_actor_runtime_state`; confirmed `+0x11b8` is the reciprocal shove/collision partner pointer and renamed it to `collision_partner_entity`, renamed `+0x11bc` to `player_combat_loadout_id`, and documented the additional confirmed actor-tail roles (`+0x112c engage_distance`, `+0x113c attack_contact_frame_offset`, `+0x1180 attack_damage_amount`, `+0x1188 last_hit_combat_flags`, `+0x118c combat_capability_flags`, `+0x11a4 combat_target_entity`) in ScummVM comments and `ARCHITECTURE.md`. Verified with `git diff --check`.
 
 ## Next Suggested Action
 
-- Re-run the in-engine `EYEHALL` repro and confirm the BIG_EYE death bank ends with the actor removed, `EYEDOOR` still enables `EYE_MAIN`, and no other class-`4` NPC deaths regress by disappearing too early or failing monsterfy transitions.
+- Isolate the remaining unresolved `RenderEntityRuntime` tail/shared-header slots around `+0x119c`, `+0x11cc`, and `+0x4f/+0x50/+0x51/+0x53/+0x54` with save/load or DOSBox combat traces before renaming them, because the current evidence is still strong enough to classify behavior but not to assign stable semantic names.
diff --git a/engines/harvester/monster.h b/engines/harvester/monster.h
index 6663f627703..1cc298bec68 100644
--- a/engines/harvester/monster.h
+++ b/engines/harvester/monster.h
@@ -32,7 +32,8 @@ struct StartupMonsterRecord {
 	int posX = 0;
 	int posY = 0;
 	int posZ = 0;
-	// Native MONSTER numeric fields after z are engage distance, initial HP, and damage.
+	// Native MONSTER numeric fields after z seed the combat-runtime engage distance,
+	// initial HP, and attack damage fields.
 	int engageDistance = 0;
 	int initialHitPoints = 0;
 	int currentHitPoints = 0;
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 4b2853707f9..2c224d89a30 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -107,6 +107,8 @@ struct PlayerCombatLoadoutTuning {
 	int contactFrameOffset;
 };
 
+// The live player avatar seeds these from RenderEntityRuntime.player_combat_loadout_id (+0x11bc)
+// into attack_damage_amount (+0x1180) and attack_contact_frame_offset (+0x113c).
 // Recovered from spawn_player_combat_avatar and update_actor_runtime_state:
 // the native room-combat path commits player attack audio on first_frame + 1.
 static const int kPlayerAttackSoundTriggerFrameOffset = 1;
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index bd9821ea12e..1dba8d99f94 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -57,6 +57,7 @@ struct StartupRoomPlayerState {
 	int bottomY = 0;
 	float z = 0.0f;
 	int facing = -1;
+	// Mirrors native RenderEntityRuntime.player_combat_loadout_id (+0x11bc).
 	int combatLoadout = 0;
 	uint32 nextMovementTick = 0;
 	bool hasMoveTarget = false;
@@ -76,6 +77,8 @@ struct StartupRoomPlayerState {
 	bool attackSoundPlayed = false;
 	int attackSoundPlaybackFrame = -1;
 	bool attackContactResolved = false;
+	// Native room combat tracks the live chase/attack focus through
+	// RenderEntityRuntime.combat_target_entity (+0x11a4).
 	Common::String attackTargetName;
 	int attackTargetClassId = -1;
 	bool deathActive = false;


Commit: d2ebf72855303161e484020b71b58c73c179d701
    https://github.com/scummvm/scummvm/commit/d2ebf72855303161e484020b71b58c73c179d701
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:34+02:00

Commit Message:
HARVESTER: Restore player hit reactions in combat

Changed paths:
    TRACKER.md
    engines/harvester/player.cpp
    engines/harvester/player.h
    engines/harvester/room.cpp
    engines/harvester/room_support.h


diff --git a/TRACKER.md b/TRACKER.md
index afb1edbfadb..6eeb1914aab 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On March 31, 2026, rechecked `RenderEntityRuntime` against `spawn_monster_entity_from_record`, `spawn_player_combat_avatar`, `set_player_combat_loadout`, `spawn_npc_entity_from_record`, and `update_actor_runtime_state`; confirmed `+0x11b8` is the reciprocal shove/collision partner pointer and renamed it to `collision_partner_entity`, renamed `+0x11bc` to `player_combat_loadout_id`, and documented the additional confirmed actor-tail roles (`+0x112c engage_distance`, `+0x113c attack_contact_frame_offset`, `+0x1180 attack_damage_amount`, `+0x1188 last_hit_combat_flags`, `+0x118c combat_capability_flags`, `+0x11a4 combat_target_entity`) in ScummVM comments and `ARCHITECTURE.md`. Verified with `git diff --check`.
+- On March 31, 2026, rechecked `spawn_player_combat_avatar`, `update_actor_runtime_state`, and `update_player_combat_avatar_state` in Ghidra and confirmed the native room-combat hit path: monsters enter attack states `0x16..0x1b` (`0x50..0x8b` frame banks), a landed hit assigns player knockback through `remaining_knockback_x`/`knockback_x_decay_step` (`+0x11ac`/`+0x11b0`), switches the player into hit-react states `0x1c..0x21` (`0x8c..0x9d`), and plays one of `PC0_hit0/1/2.wav`. Applied the matching engine changes in ScummVM so monster melee uses the native attack banks and the player now enters the confirmed hit-reaction animation/sound path with horizontal recoil. Verified with `git diff --check` and a successful `make -C build-vscode-harvester-debug -j4 engines/harvester/libharvester.a`.
 
 ## Next Suggested Action
 
-- Isolate the remaining unresolved `RenderEntityRuntime` tail/shared-header slots around `+0x119c`, `+0x11cc`, and `+0x4f/+0x50/+0x51/+0x53/+0x54` with save/load or DOSBox combat traces before renaming them, because the current evidence is still strong enough to classify behavior but not to assign stable semantic names.
+- Replay at least one more melee monster besides `DOORKNOB2` and one projectile-capable encounter against the updated engine, then compare the animation/sound timing against the corresponding Ghidra states to confirm whether any monster-specific contact offsets or player hit/death edge cases still need native-special handling.
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 2c224d89a30..fec20e4248d 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -40,6 +40,7 @@ static const int kRoomRegionTargetXBias = 10;
 static const float kRoomDepthCompareEpsilon = 0.01f;
 static const int kRoomPlayerWalkAnimationRate = 17;
 static const int kRoomPlayerAttackAnimationRate = kRoomPlayerWalkAnimationRate;
+static const int kRoomPlayerHitAnimationRate = 4;
 static const int kRoomPlayerDeathAnimationRate = 4;
 static const int kRoomPlayerMinOpaqueLeftX = 4;
 static const int kRoomPlayerMaxOpaqueRightX = 0x27c;
@@ -49,6 +50,8 @@ static const char *const kPlayerIdleAnimationEntityName = "IDLE_ANIM";
 static const char *const kPlayerIdleAnimationResourcePath = "1:/GRAPHIC/ROOMANIM/PCLOUN02.ABM";
 static const uint32 kRuntimeClockDivisorMs = 10;
 static const uint32 kRoomPlayerIdleDelayTicks = 3000;
+static const int kPlayerHitKnockbackDistance = 18;
+static const int kPlayerHitKnockbackDecayStep = 3;
 // Native run_harvester_main_loop sets EBX=0x0e before spawning PCLOUN02.ABM.
 static const int kRoomPlayerIdleAnimationRate = 14;
 static const int kRoomPlayerIdleLoopStartFrame = 0x0f;
@@ -95,6 +98,17 @@ struct PlayerDeathAnimationRange {
 	int lastFrame = -1;
 };
 
+struct PlayerHitAnimationRange {
+	PlayerHitAnimationRange() {}
+	PlayerHitAnimationRange(int firstFrame, int lastFrame, int resumeFacing, int knockbackX)
+		: firstFrame(firstFrame), lastFrame(lastFrame), resumeFacing(resumeFacing), knockbackX(knockbackX) {}
+
+	int firstFrame = -1;
+	int lastFrame = -1;
+	int resumeFacing = -1;
+	int knockbackX = 0;
+};
+
 struct PlayerAttackSoundSet {
 	uint soundCount;
 	const char *soundPaths[3];
@@ -136,6 +150,12 @@ static const PlayerAttackSoundSet kPlayerAttackSoundSets[] = {
 	{ 3, { "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh2.wav", "2:/SOUND/EFFECTS/swoosh3.wav" } }
 };
 
+static const char *const kPlayerHitSoundPaths[] = {
+	"1:/sound/effects/PC0_hit0.wav",
+	"1:/sound/effects/PC0_hit1.wav",
+	"1:/sound/effects/PC0_hit2.wav"
+};
+
 static const PlayerCombatLoadoutTuning kPlayerCombatLoadoutTunings[] = {
 	{ "FISTS", 1, 1, 5 },
 	{ "CLEAVER", 2, 4, 2 },
@@ -249,6 +269,13 @@ static bool playPlayerAttackSound(HarvesterEngine &engine, int loadout) {
 	return soundPath && engine.playStartupSound(soundPath);
 }
 
+static bool playPlayerHitSound(HarvesterEngine &engine) {
+	const uint soundIndex = ARRAYSIZE(kPlayerHitSoundPaths) > 1
+		? engine.getRandomNumber(ARRAYSIZE(kPlayerHitSoundPaths) - 1)
+		: 0;
+	return engine.playStartupSound(kPlayerHitSoundPaths[soundIndex]);
+}
+
 static bool runtimeEntityHasFrameRange(const RuntimeEntity &entity, int firstFrame, int lastFrame) {
 	if (!entity.hasFrames() || firstFrame < 0 || lastFrame < firstFrame)
 		return false;
@@ -409,9 +436,42 @@ static bool resolveKeyboardAttackAnimationRange(const StartupRoomPlayerState &pl
 	return false;
 }
 
+static bool resolvePlayerHitAnimationRange(const RuntimeEntity &entity,
+		int monsterAttackFirstFrame, PlayerHitAnimationRange &range) {
+	PlayerHitAnimationRange candidate;
+	switch (monsterAttackFirstFrame) {
+	case 0x64:
+		candidate = PlayerHitAnimationRange(0x92, 0x94, 2, -kPlayerHitKnockbackDistance);
+		break;
+	case 0x5a:
+		candidate = PlayerHitAnimationRange(0x8f, 0x91, 2, -kPlayerHitKnockbackDistance);
+		break;
+	case 0x50:
+		candidate = PlayerHitAnimationRange(0x8c, 0x8e, 2, -kPlayerHitKnockbackDistance);
+		break;
+	case 0x82:
+		candidate = PlayerHitAnimationRange(0x9b, 0x9d, 1, kPlayerHitKnockbackDistance);
+		break;
+	case 0x78:
+		candidate = PlayerHitAnimationRange(0x98, 0x9a, 1, kPlayerHitKnockbackDistance);
+		break;
+	case 0x6e:
+		candidate = PlayerHitAnimationRange(0x95, 0x97, 1, kPlayerHitKnockbackDistance);
+		break;
+	default:
+		return false;
+	}
+
+	if (!runtimeEntityHasFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
+		return false;
+
+	range = candidate;
+	return true;
+}
+
 static bool startResolvedAttackAnimation(StartupRoomPlayerState &playerState,
 		const PlayerAttackAnimationRange &range) {
-	if (!playerState.entity || playerState.attackActive)
+	if (!playerState.entity || playerState.attackActive || playerState.hitActive)
 		return false;
 
 	playerState.hasMoveTarget = false;
@@ -943,6 +1003,12 @@ bool Player::syncCombatLoadoutVisual(HarvesterEngine &engine, const StartupRoomS
 	playerState.attackContactResolved = false;
 	playerState.attackTargetName.clear();
 	playerState.attackTargetClassId = -1;
+	playerState.hitActive = false;
+	playerState.hitFirstFrame = -1;
+	playerState.hitLastFrame = -1;
+	playerState.hitResumeFacing = -1;
+	playerState.hitKnockbackRemainingX = 0;
+	playerState.hitKnockbackDecayStep = 0;
 	(void)setIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
 	(void)applyRoomActorPlacement(state, *playerState.entity,
 		playerState.centerX, playerState.bottomY, playerState.z);
@@ -1035,6 +1101,91 @@ bool Player::updateAttackAnimationState(HarvesterEngine &engine,
 	return setIdleAnimation(playerState, resumeFacing);
 }
 
+bool Player::startHitAnimation(HarvesterEngine &engine, StartupRoomPlayerState &playerState,
+		int monsterAttackFirstFrame) {
+	if (!playerState.entity || playerState.deathActive)
+		return false;
+
+	PlayerHitAnimationRange range;
+	if (!resolvePlayerHitAnimationRange(*playerState.entity, monsterAttackFirstFrame, range))
+		return false;
+
+	playerState.hasMoveTarget = false;
+	playerState.turnActive = false;
+	playerState.turnTargetFacing = -1;
+	playerState.turnFirstFrame = -1;
+	playerState.turnLastFrame = -1;
+	playerState.turnEndFrame = -1;
+	playerState.turnPlayBackwards = false;
+	playerState.attackActive = false;
+	playerState.attackFirstFrame = -1;
+	playerState.attackLastFrame = -1;
+	playerState.attackContactFrame = -1;
+	playerState.attackResumeFacing = -1;
+	playerState.attackSoundPlayed = false;
+	playerState.attackSoundPlaybackFrame = -1;
+	playerState.attackContactResolved = false;
+	playerState.attackTargetName.clear();
+	playerState.attackTargetClassId = -1;
+	playerState.nextMovementTick = 0;
+	playerState.hitActive = true;
+	playerState.hitFirstFrame = range.firstFrame;
+	playerState.hitLastFrame = range.lastFrame;
+	playerState.hitResumeFacing = range.resumeFacing;
+	playerState.hitKnockbackRemainingX = range.knockbackX;
+	playerState.hitKnockbackDecayStep = kPlayerHitKnockbackDecayStep;
+	playerState.entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
+	playerState.entity->setAnimationRate(kRoomPlayerHitAnimationRate);
+	playerState.entity->setCurrentFrame(range.firstFrame);
+	playerState.entity->setAnimationEnabled(true);
+	playerState.entity->setVisible(true);
+	(void)playPlayerHitSound(engine);
+	debugC(1, kDebugCombat,
+		"Harvester: player hit animation attack_frame=%d frames=%d..%d resume_facing=%d knockback=%d rate=%d",
+		monsterAttackFirstFrame, range.firstFrame, range.lastFrame, range.resumeFacing,
+		range.knockbackX, kRoomPlayerHitAnimationRate);
+	return true;
+}
+
+bool Player::updateHitAnimationState(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		const Common::Array<StartupObjectRecord> &sceneObjects,
+		const Common::Array<StartupAnimRecord> &sceneAnimations,
+		StartupRoomPlayerState &playerState) {
+	if (!playerState.hitActive || !playerState.entity)
+		return false;
+
+	bool changed = false;
+	if (playerState.hitKnockbackRemainingX != 0) {
+		const int step = MAX(1, ABS(playerState.hitKnockbackDecayStep));
+		const int knockbackStep = playerState.hitKnockbackRemainingX > 0
+			? MIN(playerState.hitKnockbackRemainingX, step)
+			: MAX(playerState.hitKnockbackRemainingX, -step);
+		if (knockbackStep != 0) {
+			if (!tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+					playerState, playerState.centerX + knockbackStep, playerState.z)) {
+				playerState.hitKnockbackRemainingX = 0;
+			} else {
+				changed = true;
+				playerState.hitKnockbackRemainingX -= knockbackStep;
+			}
+		}
+	}
+
+	if (playerState.entity->getCurrentFrame() != playerState.hitLastFrame)
+		return changed;
+
+	const int resumeFacing = playerState.hitResumeFacing >= 0
+		? playerState.hitResumeFacing
+		: (playerState.facing >= 0 ? playerState.facing : 0);
+	playerState.hitActive = false;
+	playerState.hitFirstFrame = -1;
+	playerState.hitLastFrame = -1;
+	playerState.hitResumeFacing = -1;
+	playerState.hitKnockbackRemainingX = 0;
+	playerState.hitKnockbackDecayStep = 0;
+	return setIdleAnimation(playerState, resumeFacing) || changed;
+}
+
 bool Player::startDeathAnimation(StartupRoomPlayerState &playerState, int damageType, bool goreEnabled) {
 	if (!playerState.entity || playerState.deathActive)
 		return false;
@@ -1060,6 +1211,12 @@ bool Player::startDeathAnimation(StartupRoomPlayerState &playerState, int damage
 	playerState.attackContactResolved = false;
 	playerState.attackTargetName.clear();
 	playerState.attackTargetClassId = -1;
+	playerState.hitActive = false;
+	playerState.hitFirstFrame = -1;
+	playerState.hitLastFrame = -1;
+	playerState.hitResumeFacing = -1;
+	playerState.hitKnockbackRemainingX = 0;
+	playerState.hitKnockbackDecayStep = 0;
 	playerState.nextMovementTick = 0;
 	playerState.deathActive = true;
 	playerState.deathFirstFrame = range.firstFrame;
@@ -1089,7 +1246,8 @@ bool Player::updateDeathAnimationState(StartupRoomPlayerState &playerState) {
 }
 
 bool Player::startTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing) {
-	if (!playerState.entity || playerState.facing < 0 || playerState.facing == targetFacing)
+	if (!playerState.entity || playerState.hitActive ||
+			playerState.facing < 0 || playerState.facing == targetFacing)
 		return false;
 
 	PlayerTurnAnimationRange range;
@@ -1138,7 +1296,7 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const StartupRoomSetupState
 		const Common::Array<StartupObjectRecord> &sceneObjects,
 		const Common::Array<StartupAnimRecord> &sceneAnimations,
 		StartupRoomPlayerState &playerState) {
-	if (!playerState.entity || !playerState.hasMoveTarget || playerState.turnActive)
+	if (!playerState.entity || !playerState.hasMoveTarget || playerState.turnActive || playerState.hitActive)
 		return false;
 
 	if (playerState.centerX == playerState.targetX &&
@@ -1203,7 +1361,7 @@ bool Player::stepKeyboardMovement(HarvesterEngine &engine, const StartupRoomSetu
 	const int verticalInput = (moveDown ? 1 : 0) - (moveUp ? 1 : 0);
 	if (horizontalInput == 0 && verticalInput == 0)
 		return false;
-	if (playerState.turnActive)
+	if (playerState.turnActive || playerState.hitActive)
 		return false;
 
 	const int previousCenterX = playerState.centerX;
diff --git a/engines/harvester/player.h b/engines/harvester/player.h
index 7e32aa21ee8..77ea0be0421 100644
--- a/engines/harvester/player.h
+++ b/engines/harvester/player.h
@@ -60,6 +60,12 @@ public:
 	static bool startKeyboardAttackAnimation(const StartupRoomSetupState &state,
 		StartupRoomPlayerState &playerState, bool attackLeft, bool attackRight, bool attackUp, bool attackDown);
 	static bool updateAttackAnimationState(HarvesterEngine &engine, StartupRoomPlayerState &playerState);
+	static bool startHitAnimation(HarvesterEngine &engine, StartupRoomPlayerState &playerState,
+		int monsterAttackFirstFrame);
+	static bool updateHitAnimationState(HarvesterEngine &engine, const StartupRoomSetupState &state,
+		const Common::Array<StartupObjectRecord> &sceneObjects,
+		const Common::Array<StartupAnimRecord> &sceneAnimations,
+		StartupRoomPlayerState &playerState);
 	static bool startDeathAnimation(StartupRoomPlayerState &playerState, int damageType, bool goreEnabled);
 	static bool updateDeathAnimationState(StartupRoomPlayerState &playerState);
 	static bool startTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing);
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 96bca876d98..aabd39fe2dc 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -434,15 +434,17 @@ static bool runtimeEntityHasFrameRange(const RuntimeEntity &entity, int firstFra
 static bool resolveMonsterAttackAnimationRange(HarvesterEngine &engine,
 		const RuntimeEntity &entity, int actorCenterX, int targetCenterX,
 		RoomAttackAnimationRange &range) {
+	// Native class-6 attackers pick one of the 0x16..0x1b attack states, which map to
+	// the 0x50..0x8b strike banks. The 0x8c..0x9d banks are hit reactions, not attacks.
 	static const RoomAttackAnimationRange kLeftAttackRanges[3] = {
-		RoomAttackAnimationRange(0x95, 0x97, 1),
-		RoomAttackAnimationRange(0x98, 0x9a, 1),
-		RoomAttackAnimationRange(0x9b, 0x9d, 1)
+		RoomAttackAnimationRange(0x64, 0x6d, 1),
+		RoomAttackAnimationRange(0x5a, 0x63, 1),
+		RoomAttackAnimationRange(0x50, 0x59, 1)
 	};
 	static const RoomAttackAnimationRange kRightAttackRanges[3] = {
-		RoomAttackAnimationRange(0x8c, 0x8e, 2),
-		RoomAttackAnimationRange(0x8f, 0x91, 2),
-		RoomAttackAnimationRange(0x92, 0x94, 2)
+		RoomAttackAnimationRange(0x82, 0x8b, 2),
+		RoomAttackAnimationRange(0x78, 0x81, 2),
+		RoomAttackAnimationRange(0x6e, 0x77, 2)
 	};
 
 	const RoomAttackAnimationRange *candidates =
@@ -853,6 +855,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			idleState.activityTick = Player::getRuntimeClockTicks();
 			Player::updateIdleTrigger(idleState);
 		};
+		auto isPlayerCombatLocked = [&]() {
+			return playerState.attackActive || playerState.hitActive || playerState.deathActive;
+		};
 		auto cancelPlayerAttackAnimation = [&]() {
 			if (!playerState.attackActive)
 				return false;
@@ -1587,6 +1592,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			playerState.attackContactResolved = false;
 			playerState.attackTargetName.clear();
 			playerState.attackTargetClassId = -1;
+			playerState.hitActive = false;
+			playerState.hitFirstFrame = -1;
+			playerState.hitLastFrame = -1;
+			playerState.hitResumeFacing = -1;
+			playerState.hitKnockbackRemainingX = 0;
+			playerState.hitKnockbackDecayStep = 0;
 			playerState.combatLoadout =
 				_engine.getStartupScript() ? _engine.getStartupScript()->getPlayerCombatLoadout() : 0;
 			monsterCombatStates.clear();
@@ -3036,8 +3047,22 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 							monster.monsterName.c_str(), currentFrame, monster.damageAmount,
 							Player::describeCombatDamageType(monster.damageType),
 							playerHitPointsBefore, playerHitPointsAfter, changed);
-						if (damageLanded > 0 && playerState.entity)
+						if (damageLanded > 0 && playerState.entity) {
 							spawnCombatDamagePopup(*playerState.entity, playerState.entity->getName(), damageLanded);
+							if (idleState.entity) {
+								idleState.entity->setVisible(false);
+								idleState.entity->setAnimationRate(0);
+							}
+							idleState.active = false;
+							idleState.loopStarted = false;
+							idleState.exiting = false;
+							idleState.restoreFacing = -1;
+							playerState.entity->setVisible(true);
+							if (playerHitPointsAfter > 0 &&
+									Player::startHitAnimation(_engine, playerState, combatState.attackFirstFrame)) {
+								notePlayerActivity();
+							}
+						}
 						if (changed && playerHitPointsAfter <= 0) {
 							playerAlive = false;
 							stopPlayerRegionInteraction();
@@ -3480,7 +3505,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			const bool activeCarry = inventorySelectionActive || roomCarryActive;
 			const bool inventoryPanelContainsMouse = _inventory.isOpen() && inventoryPanelBounds.contains(_mousePos);
 			const bool suppressHover = showingInspectText || idleState.active || idleState.exiting ||
-				playerState.attackActive ||
+				isPlayerCombatLocked() ||
 				(_inventory.isOpen() && (inventoryPanelContainsMouse || !inventorySelectionActive));
 			StartupRoomHoverState hoverState = suppressHover
 				? StartupRoomHoverState()
@@ -3537,7 +3562,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			if (RuntimeEntity *cursor = runtimeEntities ? runtimeEntities->getCursorEntity() : nullptr) {
 				cursor->setAnimationSequence(
 					(showingInspectText || idleState.active || idleState.exiting ||
-						playerState.attackActive ||
+						isPlayerCombatLocked() ||
 						_inventory.isOpen() || activeCarry)
 						? 7
 						: hoverState.cursorSequence);
@@ -3598,7 +3623,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 						needsRedraw = true;
 					break;
 				}
-				if (playerState.attackActive)
+				if (isPlayerCombatLocked())
 					break;
 				pendingRegionName.clear();
 				if (_inventory.isOpen()) {
@@ -3701,7 +3726,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					break;
 				}
 				Script *startupScript = _engine.getStartupScript();
-				if (!showingInspectText && startupScript && playerState.entity &&
+				if (!showingInspectText && !isPlayerCombatLocked() && startupScript && playerState.entity &&
 						startupScript->getPlayerCurrentHitPoints() > 0) {
 					playerState.hasMoveTarget = false;
 					playerState.turnActive = false;
@@ -3756,7 +3781,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					}
 					break;
 				}
-				if (playerState.attackActive)
+				if (isPlayerCombatLocked())
 					break;
 
 				const Common::Rect inventoryPanelBounds = _inventory.getPanelBounds();
@@ -4061,6 +4086,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 						break;
 					}
 				}
+				if (isPlayerCombatLocked())
+					break;
 
 				if (event.kbd.keycode == Common::KEYCODE_LCTRL ||
 						event.kbd.keycode == Common::KEYCODE_RCTRL) {
@@ -4151,7 +4178,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			_engine.getStartupScript() &&
 			_engine.getStartupScript()->getPlayerCurrentHitPoints() > 0;
 		if (!playerCanAct && (moveLeft || moveRight || moveUp || moveDown ||
-				playerState.hasMoveTarget || playerState.turnActive || playerState.attackActive)) {
+				playerState.hasMoveTarget || playerState.turnActive ||
+				playerState.attackActive || playerState.hitActive)) {
 			attackModifierHeld = false;
 			stopPlayerRegionInteraction();
 		}
@@ -4170,10 +4198,15 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		if (Player::updateAttackAnimationState(_engine, playerState)) {
 			needsRedraw = true;
 		}
+		if (Player::updateHitAnimationState(
+				_engine, scene.state, scene.sceneObjects, scene.sceneAnimations, playerState)) {
+			playerAdvancedThisFrame = true;
+			needsRedraw = true;
+		}
 		const bool keyboardAttackRequested =
 			attackModifierHeld && (moveLeft || moveRight || moveUp || moveDown);
 		if (playerCanAct &&
-				!playerState.attackActive && keyboardAttackRequested &&
+				!playerState.attackActive && !playerState.hitActive && keyboardAttackRequested &&
 				!idleState.active && !idleState.exiting) {
 			const uint32 now = Player::getRuntimeClockTicks();
 			if (nextKeyboardAttackAllowedTick == 0 || (int32)(now - nextKeyboardAttackAllowedTick) >= 0) {
@@ -4198,13 +4231,15 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				}
 			}
 		}
-		if (!playerState.attackActive && Player::updateTurnAnimationState(playerState)) {
+		if (!playerState.attackActive && !playerState.hitActive &&
+				Player::updateTurnAnimationState(playerState)) {
 			playerAdvancedThisFrame = true;
 			needsRedraw = true;
 		}
 
 		if (playerCanAct &&
-				!playerState.attackActive && !keyboardAttackRequested && !idleState.active && !idleState.exiting) {
+				!playerState.attackActive && !playerState.hitActive &&
+				!keyboardAttackRequested && !idleState.active && !idleState.exiting) {
 			if (Player::stepKeyboardMovement(_engine, scene.state, scene.sceneObjects, scene.sceneAnimations,
 					playerState, moveLeft, moveRight, moveUp, moveDown)) {
 				playerAdvancedThisFrame = true;
@@ -4217,7 +4252,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				notePlayerActivity();
 				needsRedraw = true;
 			} else if (!moveLeft && !moveRight && !moveUp && !moveDown && !playerState.hasMoveTarget &&
-					!playerState.turnActive &&
+					!playerState.turnActive && !playerState.hitActive &&
 					playerState.entity && playerState.facing >= 0 &&
 					Player::setIdleAnimation(playerState, playerState.facing)) {
 				needsRedraw = true;
@@ -4225,7 +4260,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 			if (!showingInspectText && !keyboardAttackRequested &&
 					!moveLeft && !moveRight && !moveUp && !moveDown &&
-					!playerState.hasMoveTarget && !playerState.turnActive &&
+					!playerState.hasMoveTarget && !playerState.turnActive && !playerState.hitActive &&
 					playerState.entity && playerState.facing >= 0 &&
 					!Player::isIdleAnimationExcludedRoom(scene.state.roomName) &&
 					Player::getRuntimeClockTicks() > idleState.triggerTick &&
@@ -4246,7 +4281,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			break;
 		}
 		Common::Error pendingRegionError =
-			(playerState.attackActive || playerState.deathActive)
+			isPlayerCombatLocked()
 				? Common::kNoError
 				: tryActivatePendingRegion();
 		if (pendingRegionError.getCode() != Common::kNoError)
@@ -4259,7 +4294,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			break;
 		}
 		pendingRegionError =
-			(playerAdvancedThisFrame && !playerState.attackActive && !playerState.deathActive)
+			(playerAdvancedThisFrame && !isPlayerCombatLocked())
 				? tryActivateOverlappedRegion()
 				: Common::kNoError;
 		if (pendingRegionError.getCode() != Common::kNoError)
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index 1dba8d99f94..55dd737c7c0 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -81,6 +81,12 @@ struct StartupRoomPlayerState {
 	// RenderEntityRuntime.combat_target_entity (+0x11a4).
 	Common::String attackTargetName;
 	int attackTargetClassId = -1;
+	bool hitActive = false;
+	int hitFirstFrame = -1;
+	int hitLastFrame = -1;
+	int hitResumeFacing = -1;
+	int hitKnockbackRemainingX = 0;
+	int hitKnockbackDecayStep = 0;
 	bool deathActive = false;
 	int deathFirstFrame = -1;
 	int deathLastFrame = -1;


Commit: c83a422c4f130d3d7158d810071d419c17ab0f38
    https://github.com/scummvm/scummvm/commit/c83a422c4f130d3d7158d810071d419c17ab0f38
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:34+02:00

Commit Message:
HARVESTER: Restore native monster hit feedback

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 6eeb1914aab..f0cd67c6e8d 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On March 31, 2026, rechecked `spawn_player_combat_avatar`, `update_actor_runtime_state`, and `update_player_combat_avatar_state` in Ghidra and confirmed the native room-combat hit path: monsters enter attack states `0x16..0x1b` (`0x50..0x8b` frame banks), a landed hit assigns player knockback through `remaining_knockback_x`/`knockback_x_decay_step` (`+0x11ac`/`+0x11b0`), switches the player into hit-react states `0x1c..0x21` (`0x8c..0x9d`), and plays one of `PC0_hit0/1/2.wav`. Applied the matching engine changes in ScummVM so monster melee uses the native attack banks and the player now enters the confirmed hit-reaction animation/sound path with horizontal recoil. Verified with `git diff --check` and a successful `make -C build-vscode-harvester-debug -j4 engines/harvester/libharvester.a`.
+- On March 31, 2026, rechecked `spawn_player_combat_avatar`, `spawn_monster_entity_base`, `spawn_monster_entity_from_record`, and the close-range hit branch in `update_actor_runtime_state` in Ghidra. Confirmed that the player combat avatar has no separate `blood.abm` helper and relies on hit-react banks plus `PC0_hit0/1/2.wav`, while monsters and NPCs do carry a cached `blood.abm` hit effect entity and dedicated monster hit-sound slots (`hit_sound_state_1..3`). Applied the matching engine changes in ScummVM so player hits on monsters now trigger the native blood effect and monster hit sounds, and surviving monsters enter the confirmed hit-react branch with brief horizontal knockback. Verified with `git diff --check` and a successful `make -C build-vscode-harvester-debug -j4 engines/harvester/libharvester.a`.
 
 ## Next Suggested Action
 
-- Replay at least one more melee monster besides `DOORKNOB2` and one projectile-capable encounter against the updated engine, then compare the animation/sound timing against the corresponding Ghidra states to confirm whether any monster-specific contact offsets or player hit/death edge cases still need native-special handling.
+- Replay at least one more melee monster besides `DOORKNOB2` and one ranged or special-case encounter, then compare the monster hit blood/sound timing against the corresponding Ghidra runtime states to confirm whether any monsters need per-record hit-trigger handling or different reaction-bank fallback when a model is missing some `0x1c..0x21` hit banks.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index aabd39fe2dc..ca16cc8f122 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -60,6 +60,7 @@ static const int kNativeInventoryWeekdayY = 0x18c;
 static const byte kNativeInventoryTooltipColor = 0xf4;
 static const int kRoomMonsterAnimationRate = 17;
 static const int kNativeMonsterAttackAnimationRate = 4;
+static const int kNativeMonsterHitAnimationRate = 5;
 static const uint32 kNativeMonsterAttackCooldownBaseTicks = 50;
 static const int kRoomNpcAmbientLastFrame = 0x3b;
 static const int kDefaultMonsterAttackContactFrameOffset = 2;
@@ -68,6 +69,8 @@ static const float kNativeMonsterPursuitZTolerance = 2.0f;
 static const float kNativeMonsterHorizontalWaypointTolerance = 50.0f;
 static const uint32 kNativeKeyboardAttackRepeatTicks = 25;
 static const int kNativeKeyboardAttackSideWindow = 30;
+static const int kNativeCombatHitKnockbackDistance = 18;
+static const int kNativeCombatHitKnockbackDecayStep = 3;
 static const char *const kNativeHitEffectResourcePath = "blood.abm";
 static const int kNativeHitEffectAnimationRate = 10;
 static const float kNativeHitEffectRenderZBias = 0.01f;
@@ -107,6 +110,18 @@ struct RoomDeathAnimationRange {
 	int lastFrame = -1;
 };
 
+struct RoomHitAnimationRange {
+	RoomHitAnimationRange() {}
+	RoomHitAnimationRange(int firstFrame, int lastFrame, int resumeFacing, int knockbackX)
+		: firstFrame(firstFrame), lastFrame(lastFrame), resumeFacing(resumeFacing),
+		  knockbackX(knockbackX) {}
+
+	int firstFrame = -1;
+	int lastFrame = -1;
+	int resumeFacing = -1;
+	int knockbackX = 0;
+};
+
 struct RoomMonsterCombatState {
 	bool attackActive = false;
 	int attackFirstFrame = -1;
@@ -116,6 +131,13 @@ struct RoomMonsterCombatState {
 	bool attackSoundPlayed = false;
 	bool attackContactResolved = false;
 	Common::String attackTargetName;
+	bool hitActive = false;
+	int hitFirstFrame = -1;
+	int hitLastFrame = -1;
+	int hitResumeFacing = -1;
+	int hitKnockbackRemainingX = 0;
+	int hitKnockbackDecayStep = 0;
+	bool hitSoundPlayed = false;
 	bool deathActive = false;
 	int deathFirstFrame = -1;
 	int deathLastFrame = -1;
@@ -608,6 +630,39 @@ static bool playRandomRoomAttackSound(HarvesterEngine &engine, const Common::Str
 	return engine.playStartupSound(*availableSounds[soundIndex]);
 }
 
+static bool resolveRoomMonsterHitAnimationRange(const RuntimeEntity &entity,
+		int attackerAttackFirstFrame, RoomHitAnimationRange &range) {
+	RoomHitAnimationRange candidate;
+	switch (attackerAttackFirstFrame) {
+	case 0x64:
+		candidate = RoomHitAnimationRange(0x92, 0x94, 2, -kNativeCombatHitKnockbackDistance);
+		break;
+	case 0x5a:
+		candidate = RoomHitAnimationRange(0x8f, 0x91, 2, -kNativeCombatHitKnockbackDistance);
+		break;
+	case 0x50:
+		candidate = RoomHitAnimationRange(0x8c, 0x8e, 2, -kNativeCombatHitKnockbackDistance);
+		break;
+	case 0x82:
+		candidate = RoomHitAnimationRange(0x9b, 0x9d, 1, kNativeCombatHitKnockbackDistance);
+		break;
+	case 0x78:
+		candidate = RoomHitAnimationRange(0x98, 0x9a, 1, kNativeCombatHitKnockbackDistance);
+		break;
+	case 0x6e:
+		candidate = RoomHitAnimationRange(0x95, 0x97, 1, kNativeCombatHitKnockbackDistance);
+		break;
+	default:
+		return false;
+	}
+
+	if (!runtimeEntityHasFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
+		return false;
+
+	range = candidate;
+	return true;
+}
+
 static float clampRoomDepthForEvent(const StartupRoomSetupState &state, float z) {
 	return CLIP<float>(z,
 		(float)MIN(state.roomMinZ, state.roomMaxZ),
@@ -671,6 +726,16 @@ static void clearRoomMonsterAttackState(RoomMonsterCombatState &combatState) {
 	combatState.attackTargetName.clear();
 }
 
+static void clearRoomMonsterHitState(RoomMonsterCombatState &combatState) {
+	combatState.hitActive = false;
+	combatState.hitFirstFrame = -1;
+	combatState.hitLastFrame = -1;
+	combatState.hitResumeFacing = -1;
+	combatState.hitKnockbackRemainingX = 0;
+	combatState.hitKnockbackDecayStep = 0;
+	combatState.hitSoundPlayed = false;
+}
+
 RoomSystem::RoomSystem(HarvesterEngine &engine, Common::Point &mousePos,
 		InventorySystem &inventory)
 	: _engine(engine), _mousePos(mousePos), _inventory(inventory) {
@@ -2756,8 +2821,56 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			attackLeft, attackRight, attackUp, attackDown, playerState.facing,
 			bestEntity->getScreenRect().left, bestEntity->getScreenRect().top,
 			bestEntity->getScreenRect().right, bestEntity->getScreenRect().bottom);
-	};
-	auto resolvePlayerAttackContact = [&]() -> Common::Error {
+		};
+		auto startMonsterHitReaction = [&](StartupMonsterRecord &monster, RuntimeEntity &monsterEntity,
+				RoomMonsterCombatState *combatState, int attackerAttackFirstFrame) {
+			if (spawnCombatHitEffect(monsterEntity, monster.monsterName))
+				needsRedraw = true;
+
+			if (!combatState) {
+				(void)playRandomRoomAttackSound(_engine,
+					monster.hitSound1, monster.hitSound2, monster.hitSound3);
+				return;
+			}
+
+			RoomHitAnimationRange range;
+			if (!resolveRoomMonsterHitAnimationRange(monsterEntity, attackerAttackFirstFrame, range)) {
+				(void)playRandomRoomAttackSound(_engine,
+					monster.hitSound1, monster.hitSound2, monster.hitSound3);
+				debugC(1, kDebugCombat,
+					"Harvester: combat monster hit reaction skipped target='%s' attack_frame=%d reason='no native hit bank'",
+					monster.monsterName.c_str(), attackerAttackFirstFrame);
+				return;
+			}
+
+			clearRoomMonsterAttackState(*combatState);
+			clearRoomMonsterHitState(*combatState);
+			combatState->hitActive = true;
+			combatState->hitFirstFrame = range.firstFrame;
+			combatState->hitLastFrame = range.lastFrame;
+			combatState->hitResumeFacing = range.resumeFacing;
+			combatState->hitKnockbackRemainingX = range.knockbackX;
+			combatState->hitKnockbackDecayStep = kNativeCombatHitKnockbackDecayStep;
+			combatState->hitSoundPlayed =
+				monster.hitSound1.empty() && monster.hitSound2.empty() && monster.hitSound3.empty();
+			combatState->nextMovementTick = 0;
+			monster.facing = range.resumeFacing;
+			monsterEntity.setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
+			monsterEntity.setAnimationRate(kNativeMonsterHitAnimationRate);
+			monsterEntity.setAnimationEnabled(true);
+			monsterEntity.setCurrentFrame(range.firstFrame);
+			if (!combatState->hitSoundPlayed && monster.hitSoundTriggerFrame <= 0) {
+				(void)playRandomRoomAttackSound(_engine,
+					monster.hitSound1, monster.hitSound2, monster.hitSound3);
+				combatState->hitSoundPlayed = true;
+			}
+			debugC(1, kDebugCombat,
+				"Harvester: combat monster hit reaction target='%s' attack_frame=%d frames=%d..%d resume_facing=%d knockback=%d rate=%d",
+				monster.monsterName.c_str(), attackerAttackFirstFrame, range.firstFrame, range.lastFrame,
+				range.resumeFacing, range.knockbackX, kNativeMonsterHitAnimationRate);
+			needsRedraw = true;
+		};
+		auto resolvePlayerAttackContact = [&]() -> Common::Error {
 		if (!playerState.attackActive || playerState.attackContactResolved || !playerState.entity ||
 				playerState.attackContactFrame < 0 ||
 				playerState.entity->getCurrentFrame() < playerState.attackContactFrame) {
@@ -2854,30 +2967,31 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			return Common::kNoError;
 		}
 
-		const int hitPointsBefore = monster->currentHitPoints;
-		monster->currentHitPoints = MAX(0,
-			monster->currentHitPoints - damageAmount);
-		const int damageLanded = hitPointsBefore - monster->currentHitPoints;
-		debugC(1, kDebugCombat,
-			"Harvester: combat player monster hit target='%s' loadout=%d weapon='%s' damage=%d damage_type='%s' hp=%d->%d reachable=1",
-			monster->monsterName.c_str(), playerState.combatLoadout,
-			Player::describeCombatLoadout(playerState.combatLoadout), damageAmount,
-			Player::describeCombatDamageType(damageType), hitPointsBefore, monster->currentHitPoints);
-		if (damageLanded > 0)
-			spawnCombatDamagePopup(*monsterEntity, monster->monsterName, damageLanded);
-		if (monster->currentHitPoints > 0) {
-			(void)startupScript->syncRuntimeMonsterRecord(*monster);
-			needsRedraw = true;
-			return Common::kNoError;
-		}
-
-		const int monsterIndex = findRoomMonsterIndexByName(monster->monsterName);
-		RoomMonsterCombatState *combatState =
-			monsterIndex >= 0 && monsterIndex < (int)monsterCombatStates.size()
-				? &monsterCombatStates[(uint)monsterIndex]
-				: nullptr;
-		if (combatState)
-			clearRoomMonsterCombatState(*combatState);
+			const int hitPointsBefore = monster->currentHitPoints;
+			monster->currentHitPoints = MAX(0,
+				monster->currentHitPoints - damageAmount);
+			const int damageLanded = hitPointsBefore - monster->currentHitPoints;
+			const int monsterIndex = findRoomMonsterIndexByName(monster->monsterName);
+			RoomMonsterCombatState *combatState =
+				monsterIndex >= 0 && monsterIndex < (int)monsterCombatStates.size()
+					? &monsterCombatStates[(uint)monsterIndex]
+					: nullptr;
+			debugC(1, kDebugCombat,
+				"Harvester: combat player monster hit target='%s' loadout=%d weapon='%s' damage=%d damage_type='%s' hp=%d->%d reachable=1",
+				monster->monsterName.c_str(), playerState.combatLoadout,
+				Player::describeCombatLoadout(playerState.combatLoadout), damageAmount,
+				Player::describeCombatDamageType(damageType), hitPointsBefore, monster->currentHitPoints);
+			if (damageLanded > 0) {
+				spawnCombatDamagePopup(*monsterEntity, monster->monsterName, damageLanded);
+				startMonsterHitReaction(*monster, *monsterEntity, combatState, playerState.attackFirstFrame);
+			}
+			if (monster->currentHitPoints > 0) {
+				(void)startupScript->syncRuntimeMonsterRecord(*monster);
+				needsRedraw = true;
+				return Common::kNoError;
+			}
+			if (combatState)
+				clearRoomMonsterCombatState(*combatState);
 		(void)startupScript->syncRuntimeMonsterRecord(*monster);
 
 		RoomDeathAnimationRange deathRange;
@@ -3008,11 +3122,49 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
 				continue;
 			}
-			if (combatState.nextAttackAllowedTick == 0)
-				combatState.nextAttackAllowedTick = now + kNativeMonsterAttackCooldownBaseTicks;
+				if (combatState.nextAttackAllowedTick == 0)
+					combatState.nextAttackAllowedTick = now + kNativeMonsterAttackCooldownBaseTicks;
+
+				if (combatState.hitActive) {
+					const int currentFrame = entity->getCurrentFrame();
+					if (!combatState.hitSoundPlayed &&
+							monster.hitSoundTriggerFrame >= 0 &&
+							currentFrame >= combatState.hitFirstFrame + monster.hitSoundTriggerFrame) {
+						(void)playRandomRoomAttackSound(_engine,
+							monster.hitSound1, monster.hitSound2, monster.hitSound3);
+						combatState.hitSoundPlayed = true;
+					}
+					if (combatState.hitKnockbackRemainingX != 0) {
+						const int step = MAX(1, ABS(combatState.hitKnockbackDecayStep));
+						const int knockbackStep = combatState.hitKnockbackRemainingX > 0
+							? MIN(combatState.hitKnockbackRemainingX, step)
+							: MAX(combatState.hitKnockbackRemainingX, -step);
+						const int previousX = monster.posX;
+						monster.posX = CLIP<int>(
+							monster.posX + knockbackStep, monster.screenMinXBound, monster.screenMaxXBound);
+						if (monster.posX != previousX) {
+							(void)applyRoomActorPlacement(
+								scene.state, *entity, monster.posX, monster.posY, (float)monster.posZ);
+							(void)startupScript->syncRuntimeMonsterRecord(monster);
+							debugC(1, kDebugCombat,
+								"Harvester: combat monster hit knockback target='%s' from_x=%d to_x=%d remaining=%d",
+								monster.monsterName.c_str(), previousX, monster.posX,
+								combatState.hitKnockbackRemainingX - (monster.posX - previousX));
+							needsRedraw = true;
+							combatState.hitKnockbackRemainingX -= monster.posX - previousX;
+						} else {
+							combatState.hitKnockbackRemainingX = 0;
+						}
+					}
+					if (currentFrame == combatState.hitLastFrame) {
+						clearRoomMonsterHitState(combatState);
+						needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
+					}
+					continue;
+				}
 
-			if (combatState.attackActive) {
-				const int currentFrame = entity->getCurrentFrame();
+				if (combatState.attackActive) {
+					const int currentFrame = entity->getCurrentFrame();
 				if (!combatState.attackSoundPlayed &&
 						monster.attackSoundTriggerFrame >= 0 &&
 						currentFrame >= combatState.attackFirstFrame + monster.attackSoundTriggerFrame) {


Commit: e3e6caf1b7b0e0e7e0648b2de3344ccf290f7a9a
    https://github.com/scummvm/scummvm/commit/e3e6caf1b7b0e0e7e0648b2de3344ccf290f7a9a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:35+02:00

Commit Message:
HARVESTER: Support closeup sprite backgrounds

Changed paths:
    TRACKER.md
    engines/harvester/flow.cpp
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index f0cd67c6e8d..2803ca69b3f 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On March 31, 2026, rechecked `spawn_player_combat_avatar`, `spawn_monster_entity_base`, `spawn_monster_entity_from_record`, and the close-range hit branch in `update_actor_runtime_state` in Ghidra. Confirmed that the player combat avatar has no separate `blood.abm` helper and relies on hit-react banks plus `PC0_hit0/1/2.wav`, while monsters and NPCs do carry a cached `blood.abm` hit effect entity and dedicated monster hit-sound slots (`hit_sound_state_1..3`). Applied the matching engine changes in ScummVM so player hits on monsters now trigger the native blood effect and monster hit sounds, and surviving monsters enter the confirmed hit-react branch with brief horizontal knockback. Verified with `git diff --check` and a successful `make -C build-vscode-harvester-debug -j4 engines/harvester/libharvester.a`.
+- On March 31, 2026, decoded the authored CD1/CD2 `HARVEST.SCR` records for `GO_BROOMPICCU` and confirmed that `BROOMPICCU` is a valid closeup room with a single visible sprite-backed object at `(0,0)` named `BROOMPIC_CU`. Updated Harvester room materialization so it still prefers true `640x480` backgrounds, but now falls back to the explicit origin sprite-backed room object when no fullscreen match exists, and carries that chosen object name into scene-entity classification so closeup backdrops remain non-interactive. Verified with `git diff --check` and a successful `make -C build-vscode-harvester-debug -j4 engines/harvester/libharvester.a`.
 
 ## Next Suggested Action
 
-- Replay at least one more melee monster besides `DOORKNOB2` and one ranged or special-case encounter, then compare the monster hit blood/sound timing against the corresponding Ghidra runtime states to confirm whether any monsters need per-record hit-trigger handling or different reaction-bank fallback when a model is missing some `0x1c..0x21` hit banks.
+- Replay the remaining inventory document/photo closeups such as `GO_BOYLCOPYCU`, `GO_CASKTPICCU`, `GO_REGISTERCU`, and `GO_TVDEED1CU` to confirm the fallback background selection covers every authored closeup room and that clicks only land on the intended `EXIT_BM` / `EXIT_HS` hotspots.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 256b4c7e3dc..f3042d0d1d2 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -595,7 +595,13 @@ static void logSceneObjectSelection(const char *decision, const char *source, co
 		object.actionTag.c_str(), detail.c_str());
 }
 
-static bool isBackgroundSceneObject(const StartupObjectRecord &object, const RuntimeEntity &entity) {
+static bool isBackgroundSceneObject(const StartupRoomSetupState &state,
+		const StartupObjectRecord &object, const RuntimeEntity &entity) {
+	if (!state.backgroundObjectName.empty() &&
+			object.objectName.equalsIgnoreCase(state.backgroundObjectName)) {
+		return true;
+	}
+
 	return object.initialX == 0 && object.initialY == 0 &&
 		entity.getBoundsWidth() == 640 && entity.getBoundsHeight() == 480;
 }
@@ -612,10 +618,12 @@ static bool isInteractiveSceneHotspot(const StartupObjectRecord &object, Script
 	return startupScript->resolveObjectInspectText(object, inspectText);
 }
 
-static int resolveSceneObjectClass(const StartupObjectRecord &object, const RuntimeEntity *entity,
-		Script *startupScript) {
+static int resolveSceneObjectClass(const StartupRoomSetupState &state,
+		const StartupObjectRecord &object, const RuntimeEntity *entity, Script *startupScript) {
 	if (entity)
-		return isBackgroundSceneObject(object, *entity) ? kRuntimeEntityClassBackground : kRuntimeEntityClassObject;
+		return isBackgroundSceneObject(state, object, *entity)
+			? kRuntimeEntityClassBackground
+			: kRuntimeEntityClassObject;
 
 	return isInteractiveSceneHotspot(object, startupScript)
 		? kRuntimeEntityClassRectHotspot
@@ -1854,7 +1862,8 @@ bool Flow::populateRoomSceneEntities(StartupRoomSetupState &state,
 			continue;
 		}
 
-		entity->setClassId(resolveSceneObjectClass(object, entity->hasFrames() ? entity : nullptr, _engine.getStartupScript()));
+		entity->setClassId(resolveSceneObjectClass(
+			state, object, entity->hasFrames() ? entity : nullptr, _engine.getStartupScript()));
 		entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
 		entity->setZExtent((float)object.zExtent);
 		const Common::Rect entityRect = entity->getScreenRect();
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index ca16cc8f122..76fbecb0e23 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1020,8 +1020,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		auto resolveSceneObjectClassLocal = [&](const StartupObjectRecord &object,
 				const RuntimeEntity *entity) {
 			if (entity) {
-				return object.initialX == 0 && object.initialY == 0 &&
-					entity->getBoundsWidth() == 640 && entity->getBoundsHeight() == 480
+				return ((!scene.state.backgroundObjectName.empty() &&
+						object.objectName.equalsIgnoreCase(scene.state.backgroundObjectName)) ||
+					(object.initialX == 0 && object.initialY == 0 &&
+						entity->getBoundsWidth() == 640 && entity->getBoundsHeight() == 480))
 					? kRuntimeEntityClassBackground
 					: kRuntimeEntityClassObject;
 			}
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index dc0be63beb1..491bf92edd5 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -449,14 +449,22 @@ static bool loadBitmapDimensions(ResourceManager &resources, const Common::Strin
 	return true;
 }
 
-static bool isRoomBackgroundObject(const StartupObjectRecord &candidate, const StartupRoomRecord &room,
-		ResourceManager &resources) {
+static bool isRoomBackgroundSpriteCandidate(const StartupObjectRecord &candidate,
+		const StartupRoomRecord &room) {
 	if (!candidate.currentOwnerOrRoom.equalsIgnoreCase(room.roomName) ||
 			candidate.spritePath.empty() ||
 			candidate.initialX != 0 || candidate.initialY != 0) {
 		return false;
 	}
 
+	return true;
+}
+
+static bool isFullscreenRoomBackgroundObject(const StartupObjectRecord &candidate,
+		ResourceManager &resources) {
+	if (candidate.spritePath.empty())
+		return false;
+
 	uint32 width = 0;
 	uint32 height = 0;
 	if (!loadBitmapDimensions(resources, candidate.spritePath, width, height))
@@ -2334,18 +2342,35 @@ bool Script::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupE
 	state = StartupRoomSetupState();
 
 	const StartupObjectRecord *background = nullptr;
+	const StartupObjectRecord *backgroundFallback = nullptr;
+	bool usedBackgroundFallback = false;
 	for (const StartupObjectRecord &candidate : _runtimeObjects) {
-		if (isRoomBackgroundObject(candidate, room, resources)) {
+		if (!isRoomBackgroundSpriteCandidate(candidate, room))
+			continue;
+
+		if (!backgroundFallback)
+			backgroundFallback = &candidate;
+		if (isFullscreenRoomBackgroundObject(candidate, resources)) {
 			background = &candidate;
 			break;
 		}
 	}
+	if (!background && backgroundFallback) {
+		background = backgroundFallback;
+		usedBackgroundFallback = true;
+	}
 	if (!background) {
-		warning("Harvester: no fullscreen background object found for room '%s'", room.roomName.c_str());
+		warning("Harvester: no room background object found for room '%s'", room.roomName.c_str());
 		return false;
 	}
+	if (usedBackgroundFallback) {
+		debugC(1, kDebugRoom,
+			"Harvester: using origin room sprite as background room='%s' object='%s' sprite='%s'",
+			room.roomName.c_str(), background->objectName.c_str(), background->spritePath.c_str());
+	}
 
 	state.roomName = room.roomName;
+	state.backgroundObjectName = background->objectName;
 	state.palettePath = room.palettePath;
 	state.backgroundPath = background->spritePath;
 	state.roomMinZ = room.minZ;
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index f7939271c3e..b6e12eed5d2 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -236,6 +236,7 @@ struct StartupAudioCommand {
 struct StartupRoomSetupState {
 	Common::String entranceName;
 	Common::String roomName;
+	Common::String backgroundObjectName;
 	Common::String palettePath;
 	Common::String backgroundPath;
 	Common::String musicPath;


Commit: b91783deb0cbdfc409e5ced0e87963bd15628dc0
    https://github.com/scummvm/scummvm/commit/b91783deb0cbdfc409e5ced0e87963bd15628dc0
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:35+02:00

Commit Message:
HARVESTER: Rename startup record structures

- Renamed all `Startup*Record` structures to `*Record` for simplicity.

- Updated member variable types in `RoomSetupState` and other related
  structures to reflect the new naming convention.

- Adjusted function signatures and return types to align with the
  renamed structures.

- Ensured all references to the old structure names were updated
  throughout the codebase.

Changed paths:
    engines/harvester/console.cpp
    engines/harvester/dialogue.cpp
    engines/harvester/dialogue.h
    engines/harvester/flow.cpp
    engines/harvester/flow.h
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/inventory.cpp
    engines/harvester/inventory.h
    engines/harvester/media_manager.cpp
    engines/harvester/media_manager.h
    engines/harvester/menu.cpp
    engines/harvester/monster.cpp
    engines/harvester/monster.h
    engines/harvester/npc/beggar_dialogue.cpp
    engines/harvester/npc/boyle_dialogue.cpp
    engines/harvester/npc/chessmaster_dialogue.cpp
    engines/harvester/npc/cloak_atnd_dialogue.cpp
    engines/harvester/npc/dark_woman_dialogue.cpp
    engines/harvester/npc/dialogue_runtime.h
    engines/harvester/npc/dwayne_dialogue.cpp
    engines/harvester/npc/gladiator_dialogue.cpp
    engines/harvester/npc/herrill_dialogue.cpp
    engines/harvester/npc/inquisitor_dialogue.cpp
    engines/harvester/npc/jimmy_dialogue.cpp
    engines/harvester/npc/karin_dialogue.cpp
    engines/harvester/npc/librarian_dialogue.cpp
    engines/harvester/npc/loomis_dialogue.cpp
    engines/harvester/npc/madam_dialogue.cpp
    engines/harvester/npc/mom_dialogue.cpp
    engines/harvester/npc/mr_potts_dialogue.cpp
    engines/harvester/npc/mrs_potts_dialogue.cpp
    engines/harvester/npc/pastorelli_dialogue.cpp
    engines/harvester/npc/phelps_dialogue.cpp
    engines/harvester/npc/priest_dialogue.cpp
    engines/harvester/npc/sergeant_dialogue.cpp
    engines/harvester/npc/sparky_dialogue.cpp
    engines/harvester/npc/stephanie_dialogue.cpp
    engines/harvester/npc/whaley_dialogue.cpp
    engines/harvester/player.cpp
    engines/harvester/player.h
    engines/harvester/room.cpp
    engines/harvester/room_support.h
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h
    engines/harvester/saveload.cpp
    engines/harvester/saveload.h
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/engines/harvester/console.cpp b/engines/harvester/console.cpp
index c4729a9a599..a6752ff0c9b 100644
--- a/engines/harvester/console.cpp
+++ b/engines/harvester/console.cpp
@@ -37,11 +37,11 @@ static Script *getActiveStartupScript() {
 	return g_engine ? g_engine->getStartupScript() : nullptr;
 }
 
-static const StartupCommandRecord *findCommandRecord(const Script &script, const Common::String &tag) {
+static const CommandRecord *findCommandRecord(const Script &script, const Common::String &tag) {
 	if (tag.empty())
 		return nullptr;
 
-	for (const StartupCommandRecord &command : script.getCommands()) {
+	for (const CommandRecord &command : script.getCommands()) {
 		if (command.triggerTag.equalsIgnoreCase(tag))
 			return &command;
 	}
@@ -49,11 +49,11 @@ static const StartupCommandRecord *findCommandRecord(const Script &script, const
 	return nullptr;
 }
 
-static const StartupExecListRecord *findExecListRecord(const Script &script, const Common::String &listName) {
+static const ExecListRecord *findExecListRecord(const Script &script, const Common::String &listName) {
 	if (listName.empty())
 		return nullptr;
 
-	for (const StartupExecListRecord &execList : script.getExecLists()) {
+	for (const ExecListRecord &execList : script.getExecLists()) {
 		if (execList.listName.equalsIgnoreCase(listName))
 			return &execList;
 	}
@@ -61,7 +61,7 @@ static const StartupExecListRecord *findExecListRecord(const Script &script, con
 	return nullptr;
 }
 
-static bool appendStartupAudioCommandLabel(const StartupCommandRecord &command, Common::String &label) {
+static bool appendStartupAudioCommandLabel(const CommandRecord &command, Common::String &label) {
 	if (command.opcodeName.equalsIgnoreCase("START_WAV")) {
 		label = Common::String::format("path='%s'", command.arg1.c_str());
 		return true;
@@ -121,7 +121,7 @@ static bool containsTagIgnoreCase(const Common::Array<Common::String> &tags, con
 	return false;
 }
 
-static Common::String buildCommandDetail(const StartupCommandRecord &command) {
+static Common::String buildCommandDetail(const CommandRecord &command) {
 	if (command.opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
 		return Common::String::format("flag='%s' true='%s' false='%s'",
 			command.arg1.c_str(), command.arg2.c_str(), command.arg3.c_str());
@@ -147,7 +147,7 @@ static Common::String buildCommandDetail(const StartupCommandRecord &command) {
 	return Common::String();
 }
 
-static bool isTerminalDebugCommand(const StartupCommandRecord &command) {
+static bool isTerminalDebugCommand(const CommandRecord &command) {
 	return command.opcodeName.equalsIgnoreCase("START_DIALOG") ||
 		command.opcodeName.equalsIgnoreCase("GOFLIC") ||
 		command.opcodeName.equalsIgnoreCase("GODEATHFLIC") ||
@@ -183,7 +183,7 @@ static void appendCommandChain(Common::Array<Common::String> &lines, bool &trunc
 	activeTags.push_back(tag);
 	--remainingSteps;
 
-	const StartupCommandRecord *command = findCommandRecord(script, tag);
+	const CommandRecord *command = findCommandRecord(script, tag);
 	if (!command) {
 		appendDebugActionLine(lines, truncated, depth,
 			Common::String::format("tag '%s': unresolved", tag.c_str()));
@@ -226,7 +226,7 @@ static void appendCommandChain(Common::Array<Common::String> &lines, bool &trunc
 	}
 
 	if (command->opcodeName.equalsIgnoreCase("EXEC_LIST")) {
-		const StartupExecListRecord *execList = findExecListRecord(script, command->arg1);
+		const ExecListRecord *execList = findExecListRecord(script, command->arg1);
 		if (!execList) {
 			appendDebugActionLine(lines, truncated, depth + 1,
 				Common::String::format("exec list '%s': unresolved", command->arg1.c_str()));
@@ -272,7 +272,7 @@ static void buildActionChainLines(const Script &script, const Common::String &ta
 static void collectSortedRoomNames(const Script &script, Common::Array<Common::String> &roomNames) {
 	roomNames.clear();
 
-	for (const StartupRoomRecord &room : script.getRooms()) {
+	for (const RoomRecord &room : script.getRooms()) {
 		if (room.roomName.empty())
 			continue;
 
@@ -297,7 +297,7 @@ static void collectSortedRoomNames(const Script &script, Common::Array<Common::S
 }
 
 static bool findRoomName(const Script &script, const Common::String &candidate, Common::String &roomName) {
-	for (const StartupRoomRecord &room : script.getRooms()) {
+	for (const RoomRecord &room : script.getRooms()) {
 		if (!room.roomName.equalsIgnoreCase(candidate))
 			continue;
 
@@ -313,9 +313,9 @@ static void printDebugCommandUsage(Console &console) {
 	console.debugPrintf("   or: DEBUG_COMMAND <opcode> [arg1] [arg2] [arg3] CHAIN <action_tag>\n");
 }
 
-static bool parseDebugCommandArgs(int argc, const char **argv, StartupCommandRecord &command,
+static bool parseDebugCommandArgs(int argc, const char **argv, CommandRecord &command,
 		Common::String &errorMessage) {
-	command = StartupCommandRecord();
+	command = CommandRecord();
 	if (argc < 2) {
 		errorMessage = "Missing opcode";
 		return false;
@@ -442,8 +442,8 @@ bool Console::Cmd_debugActions(int argc, const char **argv) {
 		return true;
 	}
 
-	const StartupSaveRoomState &roomState = g_engine->getCurrentStartupSaveRoomState();
-	StartupRoomSetupState materializedState;
+	const SaveRoomState &roomState = g_engine->getCurrentStartupSaveRoomState();
+	RoomSetupState materializedState;
 	if (!startupScript->materializeRoomState(roomState.entranceName, roomState.roomName,
 			materializedState, *resources)) {
 		debugPrintf("Unable to materialize room state for '%s'\n", roomState.roomName.c_str());
@@ -451,7 +451,7 @@ bool Console::Cmd_debugActions(int argc, const char **argv) {
 	}
 
 	uint actionObjectCount = 0;
-	for (const StartupObjectRecord &object : materializedState.roomObjects) {
+	for (const ObjectRecord &object : materializedState.roomObjects) {
 		if (!object.actionTag.empty())
 			++actionObjectCount;
 	}
@@ -463,7 +463,7 @@ bool Console::Cmd_debugActions(int argc, const char **argv) {
 		return true;
 	}
 
-	for (const StartupObjectRecord &object : materializedState.roomObjects) {
+	for (const ObjectRecord &object : materializedState.roomObjects) {
 		if (object.actionTag.empty())
 			continue;
 
@@ -496,7 +496,7 @@ bool Console::Cmd_debugCommand(int argc, const char **argv) {
 		return true;
 	}
 
-	StartupCommandRecord command;
+	CommandRecord command;
 	Common::String errorMessage;
 	if (!parseDebugCommandArgs(argc, argv, command, errorMessage)) {
 		if (!errorMessage.empty())
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 28703de27eb..850ca12eacd 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -430,7 +430,7 @@ static bool loadDialogueHeadBitmap(HarvesterEngine &engine, const Common::String
 		return false;
 
 	const Common::String headId = buildDialogueHeadId(speakerId, headVariant);
-	const StartupHeadRecord *head = startupScript->findHeadRecord(headId);
+	const HeadRecord *head = startupScript->findHeadRecord(headId);
 	if (!head)
 		return false;
 
@@ -553,7 +553,7 @@ void DialogueSystem::syncRuntimeSaveState(Common::Serializer &s) {
 }
 
 Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, const StartupNpcRecord &npc, const Common::String &usedItemName,
+		float paletteBrightness, const NpcRecord &npc, const Common::String &usedItemName,
 		Flow &startupFlow) {
 	struct DialogueResponseOptionLayout {
 		Common::String text;
@@ -566,7 +566,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 	Script *startupScript = _engine.getStartupScript();
 	Text *startupText = _engine.getStartupText();
 	Art *startupArt = _engine.getStartupArt();
-	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+	EntityManager *runtimeEntities = _engine.getRuntimeEntities();
 	const Graphics::Font *fallbackFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
 	if (!screen || !startupScript || !startupText || !startupArt || !fallbackFont || !backdrop.isValid())
 		return Common::kReadingFailed;
@@ -577,7 +577,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		return Common::kNoError;
 	}
 	if (!startupScript->isNamedNpcDeathTypeClear(npc.npcName)) {
-		const StartupNpcRecord *runtimeNpc = startupScript->findRuntimeNpcRecord(npc.npcName);
+		const NpcRecord *runtimeNpc = startupScript->findRuntimeNpcRecord(npc.npcName);
 		debugC(1, kDebugDialogue,
 			"Harvester: blocked room NPC dialogue npc='%s' reason='death queued' damage_type=%d",
 			npc.npcName.c_str(), runtimeNpc ? runtimeNpc->deathDamageType : -1);
@@ -1070,7 +1070,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		return Common::kNoError;
 	};
 
-	auto queueDialogueInteractionIfNeeded = [&](const StartupInteractionResult &interaction) {
+	auto queueDialogueInteractionIfNeeded = [&](const InteractionResult &interaction) {
 		if (interaction.abortRemainingCommandChain || interaction.mutatedRuntimeState ||
 				!interaction.musicPath.empty() || !interaction.nextRoomName.empty() ||
 				!interaction.cutscenePath.empty() ||
@@ -1087,7 +1087,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			startupFlow.queueDialogueInteraction(interaction);
 		}
 	};
-	auto applyImmediateDialogueInteractionEffects = [&](StartupInteractionResult &interaction) {
+	auto applyImmediateDialogueInteractionEffects = [&](InteractionResult &interaction) {
 		if (!interaction.musicPath.empty())
 			(void)_engine.playStartupMusic(interaction.musicPath);
 		if (!interaction.audioCommands.empty())
diff --git a/engines/harvester/dialogue.h b/engines/harvester/dialogue.h
index 3957ee144fc..b3e63cb7e7b 100644
--- a/engines/harvester/dialogue.h
+++ b/engines/harvester/dialogue.h
@@ -50,7 +50,7 @@ public:
 	void resetRoomNpcDialogueState();
 	void syncRuntimeSaveState(Common::Serializer &s);
 	Common::Error runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, const StartupNpcRecord &npc, const Common::String &usedItemName,
+		float paletteBrightness, const NpcRecord &npc, const Common::String &usedItemName,
 		Flow &startupFlow);
 
 private:
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index f3042d0d1d2..7020b6c4757 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -140,7 +140,7 @@ static byte findNearestPaletteColor(const byte *palette, byte red, byte green, b
 	return bestIndex;
 }
 
-static Common::String resolveRoomDebugObjectLabel(HarvesterEngine &engine, const StartupObjectRecord &object) {
+static Common::String resolveRoomDebugObjectLabel(HarvesterEngine &engine, const ObjectRecord &object) {
 	if (Script *startupScript = engine.getStartupScript()) {
 		const Common::String resolvedLabel = startupScript->resolveObjectLabel(object);
 		if (!resolvedLabel.empty())
@@ -150,7 +150,7 @@ static Common::String resolveRoomDebugObjectLabel(HarvesterEngine &engine, const
 	return object.objectName;
 }
 
-static Common::String resolveRoomDebugNpcLabel(const StartupNpcRecord &npc) {
+static Common::String resolveRoomDebugNpcLabel(const NpcRecord &npc) {
 	Common::String label = !npc.entityInitArg.empty() ? npc.entityInitArg : npc.npcName;
 	for (uint i = 0; i < label.size(); ++i) {
 		if (label[i] == '_')
@@ -190,7 +190,7 @@ static void drawRoomDebugLabel(Graphics::Screen &screen, const Graphics::Font &f
 }
 
 static void drawRoomDebugOverlay(HarvesterEngine &engine, Graphics::Screen &screen,
-		const StartupRoomSceneResources &scene) {
+		const RoomSceneResources &scene) {
 	if (!engine.isRoomDebugEnabled())
 		return;
 
@@ -210,12 +210,12 @@ static void drawRoomDebugOverlay(HarvesterEngine &engine, Graphics::Screen &scre
 	drawRoomDebugLabel(screen, *font, roomName,
 		kRoomDebugRoomNameX, kRoomDebugRoomNameY, black, white);
 
-	for (const StartupRegionRecord &region : scene.sceneRegions)
+	for (const RegionRecord &region : scene.sceneRegions)
 		drawRoomDebugLabel(screen, *font, region.regionName, region.left, region.top, white, darkGray);
 
-	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
-	for (const StartupObjectRecord &object : scene.sceneObjects) {
-		const RuntimeEntity *entity = runtimeEntities
+	EntityManager *runtimeEntities = engine.getRuntimeEntities();
+	for (const ObjectRecord &object : scene.sceneObjects) {
+		const Entity *entity = runtimeEntities
 			? runtimeEntities->findSceneEntityByName(object.objectName)
 			: nullptr;
 		if (entity && entity->getClassId() == kRuntimeEntityClassBackground)
@@ -225,8 +225,8 @@ static void drawRoomDebugOverlay(HarvesterEngine &engine, Graphics::Screen &scre
 			object.currentX, object.currentY, white, black);
 	}
 
-	for (const StartupNpcRecord &npc : scene.state.roomNpcs) {
-		const RuntimeEntity *entity = runtimeEntities
+	for (const NpcRecord &npc : scene.state.roomNpcs) {
+		const Entity *entity = runtimeEntities
 			? runtimeEntities->findSceneEntityByName(npc.npcName)
 			: nullptr;
 		if (!entity || entity->getClassId() != kRuntimeEntityClassNpc || !entity->isVisible())
@@ -239,12 +239,12 @@ static void drawRoomDebugOverlay(HarvesterEngine &engine, Graphics::Screen &scre
 }
 
 static void drawCombatDebugOverlay(HarvesterEngine &engine, Graphics::Screen &screen,
-		const StartupRoomSceneResources &scene) {
+		const RoomSceneResources &scene) {
 	if (!engine.isCombatDebugEnabled())
 		return;
 
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
+	EntityManager *runtimeEntities = engine.getRuntimeEntities();
 	Script *startupScript = engine.getStartupScript();
 	if (!font || !runtimeEntities)
 		return;
@@ -255,7 +255,7 @@ static void drawCombatDebugOverlay(HarvesterEngine &engine, Graphics::Screen &sc
 	const byte white = findNearestPaletteColor(displayPalette, 0xff, 0xff, 0xff);
 	const byte red = findNearestPaletteColor(displayPalette, 0xff, 0x00, 0x00);
 
-	const RuntimeEntity *playerEntity = runtimeEntities->findSceneEntityByName(kPlayerActorEntityName);
+	const Entity *playerEntity = runtimeEntities->findSceneEntityByName(kPlayerActorEntityName);
 	if (startupScript && playerEntity &&
 			playerEntity->getClassId() == kRuntimeEntityClassPlayer &&
 			playerEntity->isVisible()) {
@@ -266,8 +266,8 @@ static void drawCombatDebugOverlay(HarvesterEngine &engine, Graphics::Screen &sc
 			playerRect.left, playerRect.top, white, red);
 	}
 
-	for (const StartupMonsterRecord &monster : scene.state.roomMonsters) {
-		const RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(monster.monsterName);
+	for (const MonsterRecord &monster : scene.state.roomMonsters) {
+		const Entity *entity = runtimeEntities->findSceneEntityByName(monster.monsterName);
 		if (!entity || entity->getClassId() != kRuntimeEntityClassMonster || !entity->isVisible())
 			continue;
 
@@ -342,7 +342,7 @@ static int resolveTownMapKeyPanel(int currentPanel, Common::KeyCode keycode) {
 	return currentPanel;
 }
 
-static bool isTownMapLocationHovered(const StartupMapLocationRecord &location, int currentPanel,
+static bool isTownMapLocationHovered(const MapLocationRecord &location, int currentPanel,
 		const Common::Point &mousePos) {
 	if (location.panelIndex != currentPanel)
 		return false;
@@ -353,10 +353,10 @@ static bool isTownMapLocationHovered(const StartupMapLocationRecord &location, i
 		mousePos.y <= location.maxY && cursorBottom >= location.minY;
 }
 
-static const StartupMapLocationRecord *findTownMapLocationAt(
-		const Common::Array<StartupMapLocationRecord> &locations, int currentPanel,
+static const MapLocationRecord *findTownMapLocationAt(
+		const Common::Array<MapLocationRecord> &locations, int currentPanel,
 		const Common::Point &mousePos) {
-	for (const StartupMapLocationRecord &location : locations) {
+	for (const MapLocationRecord &location : locations) {
 		if (isTownMapLocationHovered(location, currentPanel, mousePos))
 			return &location;
 	}
@@ -364,19 +364,19 @@ static const StartupMapLocationRecord *findTownMapLocationAt(
 	return nullptr;
 }
 
-static void setRoomActorScreenPosition(RuntimeEntity &entity, int centerX, int bottomY, float z,
+static void setRoomActorScreenPosition(Entity &entity, int centerX, int bottomY, float z,
 		int width, int height, int xOffset, int yOffset) {
 	entity.setPosition(centerX - xOffset - width / 2, bottomY - height - yOffset, z);
 }
 
-static float computeRoomActorRenderZ(float z, const RuntimeEntity &entity) {
+static float computeRoomActorRenderZ(float z, const Entity &entity) {
 	// Native actor spawns lower the render-list depth anchor by half the z extent
 	// before insertion. Without that adjustment, cemetery Karin lands one layer
 	// too far back and gets occluded by the grave mask in CEM10.
 	return z - floorf(MAX<float>(entity.getZExtent(), 0.0f) * 0.5f);
 }
 
-static bool applyRoomActorPlacementInternal(const StartupRoomSetupState &state, RuntimeEntity &entity,
+static bool applyRoomActorPlacementInternal(const RoomSetupState &state, Entity &entity,
 		int centerX, int bottomY, float z, const Common::String *entranceName, bool applyDepthScale) {
 	int width = 0;
 	int height = 0;
@@ -401,12 +401,12 @@ static bool applyRoomActorPlacementInternal(const StartupRoomSetupState &state,
 	return true;
 }
 
-bool applyRoomActorPlacement(const StartupRoomSetupState &state, RuntimeEntity &entity,
+bool applyRoomActorPlacement(const RoomSetupState &state, Entity &entity,
 		int centerX, int bottomY, float z, const Common::String *entranceName) {
 	return applyRoomActorPlacementInternal(state, entity, centerX, bottomY, z, entranceName, true);
 }
 
-static bool applyStartupActorPlacement(const StartupRoomSetupState &state, RuntimeEntity &entity) {
+static bool applyStartupActorPlacement(const RoomSetupState &state, Entity &entity) {
 	return applyRoomActorPlacement(state, entity,
 		state.playerSpawnX, state.playerSpawnY, (float)state.playerSpawnZ, &state.entranceName);
 }
@@ -431,7 +431,7 @@ static uint32 hashPalette(const byte *palette) {
 	return hash;
 }
 
-void logScenePaletteSummary(const char *label, const StartupRoomSceneResources &scene, float brightness) {
+void logScenePaletteSummary(const char *label, const RoomSceneResources &scene, float brightness) {
 	const byte *palette = scene.palette;
 	byte minValue = 0;
 	byte maxValue = 0;
@@ -539,21 +539,21 @@ static bool loadPaletteResource(ResourceManager &resources, const Common::String
 	return true;
 }
 
-Common::Rect getRoomObjectHotspotBounds(const StartupObjectRecord &object) {
+Common::Rect getRoomObjectHotspotBounds(const ObjectRecord &object) {
 	if (object.boundsX2 > object.currentX && object.boundsY2 > object.currentY)
 		return Common::Rect(object.currentX, object.currentY, object.boundsX2 + 1, object.boundsY2 + 1);
 
 	return Common::Rect();
 }
 
-static Common::Rect getRegionBounds(const StartupRegionRecord &region) {
+static Common::Rect getRegionBounds(const RegionRecord &region) {
 	if (region.right > region.left && region.bottom > region.top)
 		return Common::Rect(region.left, region.top, region.right + 1, region.bottom + 1);
 
 	return Common::Rect();
 }
 
-Common::String resolveSceneObjectSpritePath(const StartupObjectRecord &object) {
+Common::String resolveSceneObjectSpritePath(const ObjectRecord &object) {
 	const bool atInitialPlacement = object.currentX == object.initialX &&
 		object.currentY == object.initialY &&
 		object.currentOwnerOrRoom.equalsIgnoreCase(object.initialOwnerOrRoom);
@@ -581,7 +581,7 @@ bool loadBitmapResource(ResourceManager &resources, const Common::String &path,
 	return true;
 }
 
-static void logSceneObjectSelection(const char *decision, const char *source, const StartupObjectRecord &object,
+static void logSceneObjectSelection(const char *decision, const char *source, const ObjectRecord &object,
 		const Common::String &detail = Common::String()) {
 	const Common::Rect hotspotBounds = getRoomObjectHotspotBounds(object);
 	const Common::String resolvedSpritePath = resolveSceneObjectSpritePath(object);
@@ -595,8 +595,8 @@ static void logSceneObjectSelection(const char *decision, const char *source, co
 		object.actionTag.c_str(), detail.c_str());
 }
 
-static bool isBackgroundSceneObject(const StartupRoomSetupState &state,
-		const StartupObjectRecord &object, const RuntimeEntity &entity) {
+static bool isBackgroundSceneObject(const RoomSetupState &state,
+		const ObjectRecord &object, const Entity &entity) {
 	if (!state.backgroundObjectName.empty() &&
 			object.objectName.equalsIgnoreCase(state.backgroundObjectName)) {
 		return true;
@@ -606,7 +606,7 @@ static bool isBackgroundSceneObject(const StartupRoomSetupState &state,
 		entity.getBoundsWidth() == 640 && entity.getBoundsHeight() == 480;
 }
 
-static bool isInteractiveSceneHotspot(const StartupObjectRecord &object, Script *startupScript) {
+static bool isInteractiveSceneHotspot(const ObjectRecord &object, Script *startupScript) {
 	if (object.operatable || !object.actionTag.empty())
 		return true;
 	if (!startupScript)
@@ -614,12 +614,12 @@ static bool isInteractiveSceneHotspot(const StartupObjectRecord &object, Script
 	if (startupScript->isPickupObject(object))
 		return true;
 
-	StartupResolvedText inspectText;
+	ResolvedText inspectText;
 	return startupScript->resolveObjectInspectText(object, inspectText);
 }
 
-static int resolveSceneObjectClass(const StartupRoomSetupState &state,
-		const StartupObjectRecord &object, const RuntimeEntity *entity, Script *startupScript) {
+static int resolveSceneObjectClass(const RoomSetupState &state,
+		const ObjectRecord &object, const Entity *entity, Script *startupScript) {
 	if (entity)
 		return isBackgroundSceneObject(state, object, *entity)
 			? kRuntimeEntityClassBackground
@@ -630,7 +630,7 @@ static int resolveSceneObjectClass(const StartupRoomSetupState &state,
 		: kRuntimeEntityClassDisabledHotspot;
 }
 
-static bool shouldQueueSceneObject(const StartupObjectRecord &object) {
+static bool shouldQueueSceneObject(const ObjectRecord &object) {
 	if (object.visible)
 		return true;
 
@@ -640,14 +640,14 @@ static bool shouldQueueSceneObject(const StartupObjectRecord &object) {
 		(object.operatable || !object.actionTag.empty());
 }
 
-static void queueSceneObject(const char *source, const StartupObjectRecord &object,
-		Common::Array<StartupObjectRecord> &sceneObjects) {
+static void queueSceneObject(const char *source, const ObjectRecord &object,
+		Common::Array<ObjectRecord> &sceneObjects) {
 	if (!shouldQueueSceneObject(object)) {
 		logSceneObjectSelection("skipped", source, object, object.visible ? "inactive" : "visible=0");
 		return;
 	}
 
-	for (const StartupObjectRecord &sceneObject : sceneObjects) {
+	for (const ObjectRecord &sceneObject : sceneObjects) {
 		if (sceneObject.currentOwnerOrRoom.equalsIgnoreCase(object.currentOwnerOrRoom) &&
 			sceneObject.objectName.equalsIgnoreCase(object.objectName)) {
 			logSceneObjectSelection("skipped", source, object, "duplicate owner/object");
@@ -659,22 +659,22 @@ static void queueSceneObject(const char *source, const StartupObjectRecord &obje
 	logSceneObjectSelection("queued", source, object);
 }
 
-bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceManager &resources, StartupRoomSceneResources &scene) {
-	scene = StartupRoomSceneResources();
+bool loadRoomSceneResources(const RoomSetupState &state, ResourceManager &resources, RoomSceneResources &scene) {
+	scene = RoomSceneResources();
 	scene.state = state;
 	scene.targetPaletteBrightness = state.paletteBrightness;
 	if (!loadPaletteResource(resources, state.palettePath, scene.palette)) {
 		return false;
 	}
 
-	Common::Array<StartupObjectRecord> sceneObjects;
-	for (const StartupObjectRecord &object : state.roomObjects)
+	Common::Array<ObjectRecord> sceneObjects;
+	for (const ObjectRecord &object : state.roomObjects)
 		queueSceneObject("room", object, sceneObjects);
-	for (const StartupObjectRecord &object : state.activeObjects)
+	for (const ObjectRecord &object : state.activeObjects)
 		queueSceneObject("active", object, sceneObjects);
 
 	scene.sceneObjects = sceneObjects;
-	for (const StartupAnimRecord &anim : state.roomAnimations) {
+	for (const AnimRecord &anim : state.roomAnimations) {
 		if (anim.active || anim.visible)
 			scene.sceneAnimations.push_back(anim);
 	}
@@ -695,7 +695,7 @@ bool shouldRunStartupRoomProbe() {
 		ConfMan.getBool("harvester_debug_probe_startup_room");
 }
 
-void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const StartupRoomSceneResources &scene,
+void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const RoomSceneResources &scene,
 		float brightness) {
 	setScaledPalette(screen, scene.palette, brightness);
 	screen.fillRect(screen.getBounds(), 0);
@@ -705,9 +705,9 @@ void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const Star
 	drawCombatDebugOverlay(engine, screen, scene);
 }
 
-const StartupObjectRecord *findSceneObjectByName(const Common::Array<StartupObjectRecord> &objects,
+const ObjectRecord *findSceneObjectByName(const Common::Array<ObjectRecord> &objects,
 		const Common::String &objectName) {
-	for (const StartupObjectRecord &object : objects) {
+	for (const ObjectRecord &object : objects) {
 		if (object.objectName.equalsIgnoreCase(objectName))
 			return &object;
 	}
@@ -715,9 +715,9 @@ const StartupObjectRecord *findSceneObjectByName(const Common::Array<StartupObje
 	return nullptr;
 }
 
-const StartupRegionRecord *findSceneRegionByName(const Common::Array<StartupRegionRecord> &regions,
+const RegionRecord *findSceneRegionByName(const Common::Array<RegionRecord> &regions,
 		const Common::String &regionName) {
-	for (const StartupRegionRecord &region : regions) {
+	for (const RegionRecord &region : regions) {
 		if (region.regionName.equalsIgnoreCase(regionName))
 			return ®ion;
 	}
@@ -725,9 +725,9 @@ const StartupRegionRecord *findSceneRegionByName(const Common::Array<StartupRegi
 	return nullptr;
 }
 
-static const StartupNpcRecord *findSceneNpcByName(const Common::Array<StartupNpcRecord> &npcs,
+static const NpcRecord *findSceneNpcByName(const Common::Array<NpcRecord> &npcs,
 		const Common::String &npcName) {
-	for (const StartupNpcRecord &npc : npcs) {
+	for (const NpcRecord &npc : npcs) {
 		if (npc.npcName.equalsIgnoreCase(npcName))
 			return &npc;
 	}
@@ -735,7 +735,7 @@ static const StartupNpcRecord *findSceneNpcByName(const Common::Array<StartupNpc
 	return nullptr;
 }
 
-static bool canTalkToRoomNpc(HarvesterEngine &engine, const StartupNpcRecord &npc,
+static bool canTalkToRoomNpc(HarvesterEngine &engine, const NpcRecord &npc,
 		const DialogueSystem *dialogue) {
 	Script *startupScript = engine.getStartupScript();
 	if (!startupScript || !startupScript->isNamedNpcDeathTypeClear(npc.npcName))
@@ -744,9 +744,9 @@ static bool canTalkToRoomNpc(HarvesterEngine &engine, const StartupNpcRecord &np
 	return dialogue && dialogue->hasRoomNpcHandler(npc.npcName);
 }
 
-StartupObjectRecord *findSceneObjectByName(Common::Array<StartupObjectRecord> &objects,
+ObjectRecord *findSceneObjectByName(Common::Array<ObjectRecord> &objects,
 		const Common::String &objectName) {
-	for (StartupObjectRecord &object : objects) {
+	for (ObjectRecord &object : objects) {
 		if (object.objectName.equalsIgnoreCase(objectName))
 			return &object;
 	}
@@ -754,20 +754,20 @@ StartupObjectRecord *findSceneObjectByName(Common::Array<StartupObjectRecord> &o
 	return nullptr;
 }
 
-static const StartupObjectRecord *findRoomObjectAtPoint(HarvesterEngine &engine,
-		const Common::Array<StartupObjectRecord> &sceneObjects, const Common::Point &point) {
-	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
+static const ObjectRecord *findRoomObjectAtPoint(HarvesterEngine &engine,
+		const Common::Array<ObjectRecord> &sceneObjects, const Common::Point &point) {
+	EntityManager *runtimeEntities = engine.getRuntimeEntities();
 	if (!runtimeEntities)
 		return nullptr;
 
-	const RuntimeEntity *topEntity = nullptr;
-	const StartupObjectRecord *topObject = nullptr;
+	const Entity *topEntity = nullptr;
+	const ObjectRecord *topObject = nullptr;
 	int topDrawIndex = -1;
-	for (const StartupObjectRecord &object : sceneObjects) {
+	for (const ObjectRecord &object : sceneObjects) {
 		if (object.objectName.empty())
 			continue;
 
-		const RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(object.objectName);
+		const Entity *entity = runtimeEntities->findSceneEntityByName(object.objectName);
 		if (!entity || !entity->hitTest(point))
 			continue;
 		if (entity->getClassId() == kRuntimeEntityClassBackground ||
@@ -794,16 +794,16 @@ static const StartupObjectRecord *findRoomObjectAtPoint(HarvesterEngine &engine,
 	return topObject;
 }
 
-static const StartupRegionRecord *findRoomRegionAtPoint(HarvesterEngine &engine,
-		const Common::Array<StartupRegionRecord> &sceneRegions, const Common::Point &point) {
-	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
+static const RegionRecord *findRoomRegionAtPoint(HarvesterEngine &engine,
+		const Common::Array<RegionRecord> &sceneRegions, const Common::Point &point) {
+	EntityManager *runtimeEntities = engine.getRuntimeEntities();
 	if (!runtimeEntities)
 		return nullptr;
 
-	const RuntimeEntity *topEntity = nullptr;
+	const Entity *topEntity = nullptr;
 	int topDrawIndex = -1;
-	for (const StartupRegionRecord &region : sceneRegions) {
-		const RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(region.regionName);
+	for (const RegionRecord &region : sceneRegions) {
+		const Entity *entity = runtimeEntities->findSceneEntityByName(region.regionName);
 		if (!entity || !entity->hitTest(point))
 			continue;
 		if (entity->getClassId() != kRuntimeEntityClassRectHotspot19)
@@ -822,37 +822,37 @@ static const StartupRegionRecord *findRoomRegionAtPoint(HarvesterEngine &engine,
 	return findSceneRegionByName(sceneRegions, topEntity->getName());
 }
 
-static const RuntimeEntity *findRoomPlayerAtPoint(HarvesterEngine &engine, const Common::Point &point) {
-	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
+static const Entity *findRoomPlayerAtPoint(HarvesterEngine &engine, const Common::Point &point) {
+	EntityManager *runtimeEntities = engine.getRuntimeEntities();
 	if (!runtimeEntities)
 		return nullptr;
 
-	const RuntimeEntity *entity = runtimeEntities->findTopSceneEntityAt(point);
+	const Entity *entity = runtimeEntities->findTopSceneEntityAt(point);
 	if (!entity || entity->getClassId() != kRuntimeEntityClassPlayer)
 		return nullptr;
 
 	return entity;
 }
 
-static const StartupNpcRecord *findRoomNpcAtPoint(HarvesterEngine &engine,
-		const Common::Array<StartupNpcRecord> &sceneNpcs, const Common::Point &point,
+static const NpcRecord *findRoomNpcAtPoint(HarvesterEngine &engine,
+		const Common::Array<NpcRecord> &sceneNpcs, const Common::Point &point,
 		const DialogueSystem *dialogue) {
-	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
+	EntityManager *runtimeEntities = engine.getRuntimeEntities();
 	if (!runtimeEntities)
 		return nullptr;
 
-	const RuntimeEntity *entity = runtimeEntities->findTopSceneEntityAt(point);
+	const Entity *entity = runtimeEntities->findTopSceneEntityAt(point);
 	if (!entity || entity->getClassId() != kRuntimeEntityClassNpc)
 		return nullptr;
 
-	const StartupNpcRecord *npc = findSceneNpcByName(sceneNpcs, entity->getName());
+	const NpcRecord *npc = findSceneNpcByName(sceneNpcs, entity->getName());
 	if (!npc || !canTalkToRoomNpc(engine, *npc, dialogue))
 		return nullptr;
 
 	return npc;
 }
 
-const IndexedBitmap *resolveInspectTextboxBitmap(const Art &art, const StartupResolvedText &text) {
+const IndexedBitmap *resolveInspectTextboxBitmap(const Art &art, const ResolvedText &text) {
 	if (text.boxName.equalsIgnoreCase("BOX1"))
 		return art.getTextboxBitmap(0);
 	if (text.boxName.equalsIgnoreCase("BOX2"))
@@ -866,7 +866,7 @@ const IndexedBitmap *resolveInspectTextboxBitmap(const Art &art, const StartupRe
 }
 
 void drawRoomInspectText(Graphics::Screen &screen, const Art &art, const Graphics::Font &font,
-		const StartupResolvedText &inspectText, bool useNativeFont) {
+		const ResolvedText &inspectText, bool useNativeFont) {
 	const IndexedBitmap *textbox = resolveInspectTextboxBitmap(art, inspectText);
 	if (!textbox || !textbox->isValid())
 		return;
@@ -889,16 +889,16 @@ void drawRoomInspectText(Graphics::Screen &screen, const Art &art, const Graphic
 		kIdentTextColor);
 }
 
-bool unlocksRoomObjectInteractionAfterInitialExamine(const StartupObjectRecord &object,
+bool unlocksRoomObjectInteractionAfterInitialExamine(const ObjectRecord &object,
 		Script &startupScript) {
 	return object.operatable || startupScript.isPickupObject(object);
 }
 
-static int resolveRoomObjectCursorSequence(const StartupObjectRecord &object, Script &startupScript) {
+static int resolveRoomObjectCursorSequence(const ObjectRecord &object, Script &startupScript) {
 	if (object.objectName.equalsIgnoreCase("EXIT_BM") || object.objectName.equalsIgnoreCase("EXIT_HS"))
 		return kCursorSequenceTransition;
 
-	StartupResolvedText inspectText;
+	ResolvedText inspectText;
 	const bool pickupObject = startupScript.isPickupObject(object);
 	const bool pickupBlocked = pickupObject && startupScript.isPickupBlockedByAction(object);
 	if (!object.identShown && unlocksRoomObjectInteractionAfterInitialExamine(object, startupScript))
@@ -918,7 +918,7 @@ static int resolveRoomObjectCursorSequence(const StartupObjectRecord &object, Sc
 	return kCursorSequenceNeutral;
 }
 
-static Common::String buildRoomObjectPrompt(const StartupObjectRecord &object, Script &startupScript,
+static Common::String buildRoomObjectPrompt(const ObjectRecord &object, Script &startupScript,
 		int cursorSequence) {
 	// Native room prompts come from explicit interaction metadata. Neutral scene sprites
 	// should not surface synthetic "Examine <object id>" prompts or steal hover/click focus.
@@ -941,7 +941,7 @@ static Common::String buildRoomObjectPrompt(const StartupObjectRecord &object, S
 	return Common::String::format("Examine %s", label.c_str());
 }
 
-static Common::String buildRoomNpcPrompt(const StartupNpcRecord &npc) {
+static Common::String buildRoomNpcPrompt(const NpcRecord &npc) {
 	Common::String label = !npc.entityInitArg.empty() ? npc.entityInitArg : npc.npcName;
 	for (uint i = 0; i < label.size(); ++i) {
 		if (label[i] == '_')
@@ -953,11 +953,11 @@ static Common::String buildRoomNpcPrompt(const StartupNpcRecord &npc) {
 	return Common::String::format("Talk to %s", label.c_str());
 }
 
-bool doesPlayerFacingMatchRegion(int playerFacing, const StartupRegionRecord &region) {
+bool doesPlayerFacingMatchRegion(int playerFacing, const RegionRecord &region) {
 	return region.desiredFacing < 0 || playerFacing == region.desiredFacing;
 }
 
-bool doesPlayerOverlapRegion(const RuntimeEntity &playerEntity, const StartupRegionRecord &region) {
+bool doesPlayerOverlapRegion(const Entity &playerEntity, const RegionRecord &region) {
 	const Common::Rect regionBounds = getRegionBounds(region);
 	if (regionBounds.isEmpty())
 		return false;
@@ -968,19 +968,19 @@ bool doesPlayerOverlapRegion(const RuntimeEntity &playerEntity, const StartupReg
 	return playerMaxZ >= (float)region.minZ && (float)region.maxZ >= playerEntity.getZ();
 }
 
-StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		const Common::Array<StartupObjectRecord> &sceneObjects,
-		const Common::Array<StartupNpcRecord> &sceneNpcs,
-		const Common::Array<StartupRegionRecord> &sceneRegions, const Common::Point &mousePos,
+RoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const RoomSetupState &state,
+		const Common::Array<ObjectRecord> &sceneObjects,
+		const Common::Array<NpcRecord> &sceneNpcs,
+		const Common::Array<RegionRecord> &sceneRegions, const Common::Point &mousePos,
 		const DialogueSystem *dialogue) {
-	StartupRoomHoverState hoverState;
-	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
-	if (const RuntimeEntity *playerEntity = findRoomPlayerAtPoint(engine, mousePos)) {
+	RoomHoverState hoverState;
+	EntityManager *runtimeEntities = engine.getRuntimeEntities();
+	if (const Entity *playerEntity = findRoomPlayerAtPoint(engine, mousePos)) {
 		hoverState.playerEntity = playerEntity;
 		hoverState.cursorSequence = kCursorSequenceExamine;
 		return hoverState;
 	}
-	if (const StartupNpcRecord *npc = findRoomNpcAtPoint(engine, sceneNpcs, mousePos, dialogue)) {
+	if (const NpcRecord *npc = findRoomNpcAtPoint(engine, sceneNpcs, mousePos, dialogue)) {
 		hoverState.npc = npc;
 		hoverState.cursorSequence = kCursorSequenceTalk;
 		hoverState.promptText = buildRoomNpcPrompt(*npc);
@@ -1023,8 +1023,8 @@ StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const Start
 	return hoverState;
 }
 
-static bool findRoomObjectProbePoint(HarvesterEngine &engine, const Common::Array<StartupObjectRecord> &sceneObjects,
-		const StartupObjectRecord &object, Common::Point &probePoint) {
+static bool findRoomObjectProbePoint(HarvesterEngine &engine, const Common::Array<ObjectRecord> &sceneObjects,
+		const ObjectRecord &object, Common::Point &probePoint) {
 	const Common::Rect bounds = getRoomObjectHotspotBounds(object);
 	if (bounds.isEmpty())
 		return false;
@@ -1038,7 +1038,7 @@ static bool findRoomObjectProbePoint(HarvesterEngine &engine, const Common::Arra
 		Common::Point(bounds.right - 1, bounds.bottom - 1)
 	};
 	for (const Common::Point &candidate : corners) {
-		const StartupObjectRecord *hit = findRoomObjectAtPoint(engine, sceneObjects, candidate);
+		const ObjectRecord *hit = findRoomObjectAtPoint(engine, sceneObjects, candidate);
 		if (hit && hit->objectName.equalsIgnoreCase(object.objectName)) {
 			probePoint = candidate;
 			return true;
@@ -1050,7 +1050,7 @@ static bool findRoomObjectProbePoint(HarvesterEngine &engine, const Common::Arra
 	for (int y = bounds.top; y < bounds.bottom; y += yStep) {
 		for (int x = bounds.left; x < bounds.right; x += xStep) {
 			const Common::Point candidate(x, y);
-			const StartupObjectRecord *hit = findRoomObjectAtPoint(engine, sceneObjects, candidate);
+			const ObjectRecord *hit = findRoomObjectAtPoint(engine, sceneObjects, candidate);
 			if (hit && hit->objectName.equalsIgnoreCase(object.objectName)) {
 				probePoint = candidate;
 				return true;
@@ -1061,14 +1061,14 @@ static bool findRoomObjectProbePoint(HarvesterEngine &engine, const Common::Arra
 	return false;
 }
 
-void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResources &scene,
+void logStartupRoomProbe(HarvesterEngine &engine, const RoomSceneResources &scene,
 		const Common::String &entranceName, Common::Point &mousePos) {
-	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
+	EntityManager *runtimeEntities = engine.getRuntimeEntities();
 	Script *startupScript = engine.getStartupScript();
 	if (!runtimeEntities || !startupScript)
 		return;
 
-	if (const RuntimeEntity *cursor = runtimeEntities->getCursorEntity()) {
+	if (const Entity *cursor = runtimeEntities->getCursorEntity()) {
 		uint32 framePixels = 0;
 		uint32 transparentPixels = 0;
 		uint32 preservedPixels = 0;
@@ -1079,7 +1079,7 @@ void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResource
 		}
 	}
 
-	if (const RuntimeEntity *player = runtimeEntities->findSceneEntityByName(kPlayerActorEntityName)) {
+	if (const Entity *player = runtimeEntities->findSceneEntityByName(kPlayerActorEntityName)) {
 		uint32 framePixels = 0;
 		uint32 transparentPixels = 0;
 		uint32 preservedPixels = 0;
@@ -1093,7 +1093,7 @@ void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResource
 			player->isVisible(), playerRect.intersects(screenRect), framePixels - transparentPixels);
 	}
 
-	for (const StartupObjectRecord &object : scene.sceneObjects) {
+	for (const ObjectRecord &object : scene.sceneObjects) {
 		if (getRoomObjectHotspotBounds(object).isEmpty())
 			continue;
 
@@ -1101,14 +1101,14 @@ void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResource
 		if (!findRoomObjectProbePoint(engine, scene.sceneObjects, object, probePoint))
 			continue;
 
-		const StartupObjectRecord *hoveredObject = findRoomObjectAtPoint(engine, scene.sceneObjects, probePoint);
+		const ObjectRecord *hoveredObject = findRoomObjectAtPoint(engine, scene.sceneObjects, probePoint);
 		if (!hoveredObject)
 			continue;
 
 		mousePos = probePoint;
 		const Common::String objectLabel = startupScript->resolveObjectLabel(*hoveredObject);
-		StartupResolvedText inspectText;
-		const StartupRoomHoverState hoverState = resolveRoomHoverState(
+		ResolvedText inspectText;
+		const RoomHoverState hoverState = resolveRoomHoverState(
 			engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions, probePoint);
 		const bool hasInteraction = startupScript->hasObjectInteraction(*hoveredObject);
 		const bool hasInspectText = startupScript->resolveObjectInspectText(*hoveredObject, inspectText);
@@ -1124,7 +1124,7 @@ void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResource
 			bool foundFloorProbe = false;
 			for (int y = scene.state.roomMaxZScreenY; y <= scene.state.roomMinZScreenY && !foundFloorProbe; y += 12) {
 				for (int x = 48; x < 592; x += 16) {
-					const StartupRoomHoverState candidateHover = resolveRoomHoverState(
+					const RoomHoverState candidateHover = resolveRoomHoverState(
 						engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions,
 						Common::Point(x, y));
 					if (candidateHover.cursorSequence == kCursorSequenceWalk) {
@@ -1135,18 +1135,18 @@ void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResource
 				}
 			}
 
-			const StartupRoomHoverState floorHover = foundFloorProbe
+			const RoomHoverState floorHover = foundFloorProbe
 				? resolveRoomHoverState(engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
 					scene.sceneRegions, floorProbe)
-				: StartupRoomHoverState();
+				: RoomHoverState();
 			debugC(1, kDebugRoom,
 				"Harvester: startup probe floor room='%s' point=(%d,%d) found=%d cursor_sequence=%d prompt='%s'",
 				scene.state.roomName.c_str(), floorProbe.x, floorProbe.y, foundFloorProbe,
 				floorHover.cursorSequence, floorHover.promptText.c_str());
 
-			RuntimeEntity *player = runtimeEntities->findSceneEntityByName(kPlayerActorEntityName);
+			Entity *player = runtimeEntities->findSceneEntityByName(kPlayerActorEntityName);
 			if (player && foundFloorProbe && floorHover.cursorSequence == kCursorSequenceWalk) {
-				StartupRoomPlayerState probePlayer;
+				RoomPlayerState probePlayer;
 				probePlayer.entity = player;
 				probePlayer.centerX = scene.state.playerSpawnX;
 				probePlayer.bottomY = scene.state.playerSpawnY;
@@ -1187,8 +1187,8 @@ static Common::String trimAsciiLine(const Common::String &value) {
 	return value.substr(start, end - start);
 }
 
-static bool loadQuickTipsScene(HarvesterEngine &engine, StartupRoomSceneResources &scene) {
-	StartupRoomSetupState state;
+static bool loadQuickTipsScene(HarvesterEngine &engine, RoomSceneResources &scene) {
+	RoomSetupState state;
 	// The original startup path enters the START room first and overlays quick tips on top of it.
 	if (!engine.getStartupScript()->resolveRoomSetupState("START", state, *engine.getResources()))
 		return false;
@@ -1196,7 +1196,7 @@ static bool loadQuickTipsScene(HarvesterEngine &engine, StartupRoomSceneResource
 	return loadRoomSceneResources(state, *engine.getResources(), scene);
 }
 
-static void renderQuickTipsScreen(HarvesterEngine &engine, const StartupRoomSceneResources &scene,
+static void renderQuickTipsScreen(HarvesterEngine &engine, const RoomSceneResources &scene,
 		const Common::Point &mousePos, const Common::String &tipText) {
 	Graphics::Screen *screen = engine.getScreen();
 	const Art *art = engine.getStartupArt();
@@ -1368,7 +1368,7 @@ Common::Error Flow::runQuickTips() {
 	if (transitionError.getCode() != Common::kNoError)
 		return transitionError;
 
-	StartupRoomSceneResources scene;
+	RoomSceneResources scene;
 	if (!loadQuickTipsScene(_engine, scene))
 		return Common::kReadingFailed;
 
@@ -1459,8 +1459,8 @@ Common::Error Flow::runRoomMenuStub(const IndexedBitmap &backdrop, const byte *p
 }
 
 Common::Error Flow::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, const StartupNpcRecord &npc, const Common::String &usedItemName) {
-	_queuedDialogueInteraction = StartupInteractionResult();
+		float paletteBrightness, const NpcRecord &npc, const Common::String &usedItemName) {
+	_queuedDialogueInteraction = InteractionResult();
 	_hasQueuedDialogueInteraction = false;
 	return _dialogue.runRoomNpcDialogue(backdrop, palette, paletteBrightness, npc, usedItemName, *this);
 }
@@ -1484,7 +1484,7 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 	if (!startupScript || !resources || !screen || !font)
 		return Common::kReadingFailed;
 
-	const StartupMapEntranceRecord *mapEntrance = startupScript->findMapEntranceRecord(mapEntryName);
+	const MapEntranceRecord *mapEntrance = startupScript->findMapEntranceRecord(mapEntryName);
 	if (!mapEntrance) {
 		warning("Harvester: unresolved town map entry '%s'", mapEntryName.c_str());
 		return Common::kReadingFailed;
@@ -1513,7 +1513,7 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 
 	if (_mousePos.x < 0 || _mousePos.y < 0 || _mousePos.x >= screen->w || _mousePos.y >= screen->h)
 		_mousePos = Common::Point(screen->w / 2, screen->h / 2);
-	if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities())
+	if (EntityManager *runtimeEntities = _engine.getRuntimeEntities())
 		(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
 
 	int currentPanel = clampTownMapPanelIndex(mapEntrance->initialPanelIndex);
@@ -1522,7 +1522,7 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 
 	auto centerCursor = [&]() {
 		_mousePos = Common::Point(screen->w / 2, screen->h / 2);
-		if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities())
+		if (EntityManager *runtimeEntities = _engine.getRuntimeEntities())
 			(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
 	};
 	auto restorePreviousMusic = [&]() {
@@ -1542,7 +1542,7 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 			needsRedraw = true;
 		}
 
-		const StartupMapLocationRecord *hoveredLocation =
+		const MapLocationRecord *hoveredLocation =
 			findTownMapLocationAt(startupScript->getMapLocations(), currentPanel, _mousePos);
 		if (needsRedraw) {
 			setScaledPalette(*screen, palette, paletteBrightness);
@@ -1552,7 +1552,7 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 				drawShadowedString(*screen, *font, hoveredLocation->labelText,
 					hoveredLocation->labelX, hoveredLocation->labelY, screen->w, kTownMapLabelColor);
 			}
-			if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities())
+			if (EntityManager *runtimeEntities = _engine.getRuntimeEntities())
 				runtimeEntities->drawCursor(*screen);
 			screen->makeAllDirty();
 			screen->update();
@@ -1572,12 +1572,12 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 				needsRedraw = true;
 				break;
 			case Common::EVENT_LBUTTONDOWN: {
-				const StartupMapLocationRecord *clickedLocation =
+				const MapLocationRecord *clickedLocation =
 					findTownMapLocationAt(startupScript->getMapLocations(), currentPanel, _mousePos);
 				if (!clickedLocation)
 					break;
 
-				const StartupEntranceRecord *destinationEntrance =
+				const EntranceRecord *destinationEntrance =
 					startupScript->findEntranceRecord(clickedLocation->destinationEntranceName);
 				if (!destinationEntrance) {
 					warning("Harvester: unresolved town map destination '%s' from '%s'",
@@ -1631,22 +1631,22 @@ Common::Error Flow::resolveRoomTransitionTarget(const Common::String &targetName
 	return Common::kNoError;
 }
 
-void Flow::queueDialogueInteraction(const StartupInteractionResult &interaction) {
+void Flow::queueDialogueInteraction(const InteractionResult &interaction) {
 	_queuedDialogueInteraction = interaction;
 	_hasQueuedDialogueInteraction = true;
 }
 
-bool Flow::takeQueuedDialogueInteraction(StartupInteractionResult &interaction) {
+bool Flow::takeQueuedDialogueInteraction(InteractionResult &interaction) {
 	if (!_hasQueuedDialogueInteraction)
 		return false;
 
 	interaction = _queuedDialogueInteraction;
-	_queuedDialogueInteraction = StartupInteractionResult();
+	_queuedDialogueInteraction = InteractionResult();
 	_hasQueuedDialogueInteraction = false;
 	return true;
 }
 
-bool Flow::queueDebugInteraction(const StartupInteractionResult &interaction) {
+bool Flow::queueDebugInteraction(const InteractionResult &interaction) {
 	if (_hasQueuedDebugInteraction)
 		return false;
 
@@ -1657,12 +1657,12 @@ bool Flow::queueDebugInteraction(const StartupInteractionResult &interaction) {
 	return true;
 }
 
-bool Flow::takeQueuedDebugInteraction(StartupInteractionResult &interaction) {
+bool Flow::takeQueuedDebugInteraction(InteractionResult &interaction) {
 	if (!_hasQueuedDebugInteraction)
 		return false;
 
 	interaction = _queuedDebugInteraction;
-	_queuedDebugInteraction = StartupInteractionResult();
+	_queuedDebugInteraction = InteractionResult();
 	_hasQueuedDebugInteraction = false;
 	return true;
 }
@@ -1672,7 +1672,7 @@ void Flow::prepareForNewGame() {
 	clearPendingGameOverReturn();
 	clearPendingNewGameRestart();
 	_pendingDebugRoomName.clear();
-	_queuedDebugInteraction = StartupInteractionResult();
+	_queuedDebugInteraction = InteractionResult();
 	_hasQueuedDebugInteraction = false;
 	_engine.clearPendingLoadedStartupSaveRoomState();
 	_engine.clearPendingLoadedDialogueStateBlob();
@@ -1779,7 +1779,7 @@ Common::Error Flow::runRoomLoop(const Common::String &entranceName) {
 }
 
 bool Flow::ensureCursorEntity() {
-	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+	EntityManager *runtimeEntities = _engine.getRuntimeEntities();
 	if (!runtimeEntities)
 		return false;
 	if (runtimeEntities->getCursorEntity())
@@ -1788,21 +1788,21 @@ bool Flow::ensureCursorEntity() {
 	return runtimeEntities->spawnCursorEntity(_mousePos) != nullptr;
 }
 
-bool Flow::populateRoomSceneEntities(StartupRoomSetupState &state,
-		const Common::Array<StartupObjectRecord> &drawableObjects,
-		const Common::Array<StartupAnimRecord> &drawableAnimations) {
-	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+bool Flow::populateRoomSceneEntities(RoomSetupState &state,
+		const Common::Array<ObjectRecord> &drawableObjects,
+		const Common::Array<AnimRecord> &drawableAnimations) {
+	EntityManager *runtimeEntities = _engine.getRuntimeEntities();
 	if (!runtimeEntities)
 		return false;
 
-	RuntimeEntity *preservedPlayer = runtimeEntities->detachSceneEntityByName(kPlayerActorEntityName);
+	Entity *preservedPlayer = runtimeEntities->detachSceneEntityByName(kPlayerActorEntityName);
 	runtimeEntities->clearSceneEntities();
-	for (const StartupRegionRecord &region : state.roomRegions) {
+	for (const RegionRecord &region : state.roomRegions) {
 		const Common::Rect regionBounds = getRegionBounds(region);
 		if (regionBounds.isEmpty())
 			continue;
 
-		RuntimeEntity *entity = runtimeEntities->spawnSceneHotspotEntity(
+		Entity *entity = runtimeEntities->spawnSceneHotspotEntity(
 			region.regionName, regionBounds, (float)region.minZ);
 		if (!entity) {
 			debugC(1, kDebugRoom,
@@ -1824,7 +1824,7 @@ bool Flow::populateRoomSceneEntities(StartupRoomSetupState &state,
 			regionBounds.left, regionBounds.top, regionBounds.right, regionBounds.bottom,
 			region.minZ, region.maxZ, region.desiredFacing, region.cursorEnabled, region.actionTag.c_str());
 	}
-	for (const StartupTimerRecord &timer : state.roomTimers) {
+	for (const TimerRecord &timer : state.roomTimers) {
 		if (!runtimeEntities->spawnSceneTimerEntity(timer.timerName,
 				timer.initialValue, timer.currentValue, timer.enabled, timer.looping, timer.global)) {
 			debug(1, "Harvester: unable to spawn room timer entity '%s'",
@@ -1837,8 +1837,8 @@ bool Flow::populateRoomSceneEntities(StartupRoomSetupState &state,
 			state.roomName.c_str(), timer.timerName.c_str(), timer.currentValue, timer.initialValue,
 			timer.enabled, timer.looping, timer.global);
 	}
-	for (const StartupObjectRecord &object : drawableObjects) {
-		RuntimeEntity *entity = nullptr;
+	for (const ObjectRecord &object : drawableObjects) {
+		Entity *entity = nullptr;
 		const Common::String spritePath = resolveSceneObjectSpritePath(object);
 		const Common::Rect hotspotBounds = getRoomObjectHotspotBounds(object);
 		if (!spritePath.empty() && spritePath.hasSuffixIgnoreCase(".BM")) {
@@ -1875,7 +1875,7 @@ bool Flow::populateRoomSceneEntities(StartupRoomSetupState &state,
 			entityRect.left, entityRect.top, entityRect.right, entityRect.bottom,
 			spritePath.c_str(), object.actionTag.c_str(), object.operatable, object.identTextKey.c_str());
 	}
-	for (const StartupAnimRecord &anim : drawableAnimations) {
+	for (const AnimRecord &anim : drawableAnimations) {
 		if (!runtimeEntities->spawnSceneAnimationEntity(anim.animName, anim.resourcePath,
 				Common::Point(anim.x, anim.y), (float)anim.z, anim.frameDelay, anim.active, anim.visible,
 				anim.looping, anim.backward, anim.pingPong, anim.runtimeState)) {
@@ -1883,8 +1883,8 @@ bool Flow::populateRoomSceneEntities(StartupRoomSetupState &state,
 				anim.animName.c_str(), anim.resourcePath.c_str());
 		}
 	}
-	for (const StartupNpcRecord &npc : state.roomNpcs) {
-		RuntimeEntity *entity = runtimeEntities->spawnSceneActorEntity(npc.npcName,
+	for (const NpcRecord &npc : state.roomNpcs) {
+		Entity *entity = runtimeEntities->spawnSceneActorEntity(npc.npcName,
 			npc.modelPath, Common::Point(npc.posX, npc.posY), (float)npc.posZ, 0);
 		if (!entity) {
 			debug(1, "Harvester: unable to spawn room npc entity '%s' from '%s'",
@@ -1911,8 +1911,8 @@ bool Flow::populateRoomSceneEntities(StartupRoomSetupState &state,
 			entity->getX(), entity->getY(), (double)entity->getZ(),
 			npc.frameDelay, npc.modelPath.c_str(), npc.active, npc.visible);
 	}
-	for (const StartupMonsterRecord &monster : state.roomMonsters) {
-		RuntimeEntity *entity = runtimeEntities->spawnSceneActorEntity(monster.monsterName,
+	for (const MonsterRecord &monster : state.roomMonsters) {
+		Entity *entity = runtimeEntities->spawnSceneActorEntity(monster.monsterName,
 			monster.modelPath, Common::Point(monster.posX, monster.posY), (float)monster.posZ,
 			Monster::resolveFacingFrame(monster.facing));
 		if (!entity) {
@@ -1944,7 +1944,7 @@ bool Flow::populateRoomSceneEntities(StartupRoomSetupState &state,
 		const Common::String playerResourcePath =
 			Player::resolveCombatLoadoutResourcePath(playerCombatLoadout);
 		const bool reusedPlayer = preservedPlayer != nullptr;
-		RuntimeEntity *player = preservedPlayer;
+		Entity *player = preservedPlayer;
 		if (!reusedPlayer) {
 			player = runtimeEntities->spawnSceneActorEntity(kPlayerActorEntityName,
 				playerResourcePath, Common::Point(state.playerSpawnX, state.playerSpawnY),
@@ -1965,7 +1965,7 @@ bool Flow::populateRoomSceneEntities(StartupRoomSetupState &state,
 					state.entranceName.c_str());
 			}
 			if (reusedPlayer) {
-				StartupRoomPlayerState playerState;
+				RoomPlayerState playerState;
 				playerState.entity = player;
 				playerState.centerX = state.playerSpawnX;
 				playerState.bottomY = state.playerSpawnY;
@@ -1974,7 +1974,7 @@ bool Flow::populateRoomSceneEntities(StartupRoomSetupState &state,
 				playerState.combatLoadout = -1;
 				(void)Player::syncCombatLoadoutVisual(_engine, state, playerState, playerCombatLoadout);
 			}
-			StartupRoomPlayerState startupPlayerState;
+			RoomPlayerState startupPlayerState;
 			startupPlayerState.entity = player;
 			startupPlayerState.centerX = state.playerSpawnX;
 			startupPlayerState.bottomY = state.playerSpawnY;
@@ -2105,8 +2105,8 @@ bool Flow::pumpTransitionEvents(Common::Error &result) {
 	return false;
 }
 
-void Flow::executeStartupAudioCommands(const Common::Array<StartupAudioCommand> &commands) {
-	for (const StartupAudioCommand &command : commands)
+void Flow::executeStartupAudioCommands(const Common::Array<AudioCommand> &commands) {
+	for (const AudioCommand &command : commands)
 		(void)_engine.executeStartupAudioCommand(command);
 }
 
@@ -2118,7 +2118,7 @@ void Flow::resetCursorAnimationSequence() {
 	if (!ensureCursorEntity())
 		return;
 
-	RuntimeEntity *cursor = _engine.getRuntimeEntities()->getCursorEntity();
+	Entity *cursor = _engine.getRuntimeEntities()->getCursorEntity();
 	if (!cursor)
 		return;
 
@@ -2128,7 +2128,7 @@ void Flow::resetCursorAnimationSequence() {
 }
 
 bool Flow::tickRuntimeEntities() {
-	RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+	EntityManager *runtimeEntities = _engine.getRuntimeEntities();
 	if (!runtimeEntities)
 		return false;
 	return runtimeEntities->tickSceneEntities() || runtimeEntities->syncCursorEntityPosition(_mousePos);
diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index 8d1b7b386b1..a059149226f 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -63,24 +63,24 @@ private:
 	Common::Error runMainMenuStub();
 	Common::Error runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette, float paletteBrightness);
 	Common::Error runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, const StartupNpcRecord &npc, const Common::String &usedItemName);
+		float paletteBrightness, const NpcRecord &npc, const Common::String &usedItemName);
 	Common::Error runTownMapSelector(const Common::String &mapEntryName, Common::String &destinationEntranceName);
 	Common::Error runRoomLoop(const Common::String &entranceName);
 	Common::Error resolveRoomTransitionTarget(const Common::String &targetName, Common::String &resolvedTargetName);
 	bool ensureCursorEntity();
-	bool populateRoomSceneEntities(StartupRoomSetupState &state,
-		const Common::Array<StartupObjectRecord> &drawableObjects,
-		const Common::Array<StartupAnimRecord> &drawableAnimations);
+	bool populateRoomSceneEntities(RoomSetupState &state,
+		const Common::Array<ObjectRecord> &drawableObjects,
+		const Common::Array<AnimRecord> &drawableAnimations);
 	Common::Error beginRoomSetupTransition();
 	Common::Error waitForRoomSetupTransitionHold();
 	Common::Error fadeInRoomScene(const byte *palette, float targetBrightness);
 	bool pumpTransitionEvents(Common::Error &result);
-	void executeStartupAudioCommands(const Common::Array<StartupAudioCommand> &commands);
-	void queueDialogueInteraction(const StartupInteractionResult &interaction);
-	bool takeQueuedDialogueInteraction(StartupInteractionResult &interaction);
+	void executeStartupAudioCommands(const Common::Array<AudioCommand> &commands);
+	void queueDialogueInteraction(const InteractionResult &interaction);
+	bool takeQueuedDialogueInteraction(InteractionResult &interaction);
 	bool hasQueuedDebugInteraction() const { return _hasQueuedDebugInteraction; }
-	bool queueDebugInteraction(const StartupInteractionResult &interaction);
-	bool takeQueuedDebugInteraction(StartupInteractionResult &interaction);
+	bool queueDebugInteraction(const InteractionResult &interaction);
+	bool takeQueuedDebugInteraction(InteractionResult &interaction);
 	void prepareForNewGame();
 	void requestNewGameRestart();
 	bool hasPendingNewGameRestart() const;
@@ -110,9 +110,9 @@ private:
 	InventorySystem _inventory;
 	MenuSystem _menu;
 	RoomSystem _room;
-	StartupInteractionResult _queuedDialogueInteraction;
+	InteractionResult _queuedDialogueInteraction;
 	bool _hasQueuedDialogueInteraction = false;
-	StartupInteractionResult _queuedDebugInteraction;
+	InteractionResult _queuedDebugInteraction;
 	bool _hasQueuedDebugInteraction = false;
 	bool _pendingNewGameRestart = false;
 	bool _pendingGameOverReturn = false;
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 0dd2277a731..4ee192016a5 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -68,7 +68,7 @@ bool HarvesterEngine::canSaveGameStateCurrently(Common::U32String *) {
 	return _startupScript != nullptr && _currentStartupSaveRoomState.valid;
 }
 
-RuntimeEntityManager *HarvesterEngine::getRuntimeEntities() const {
+EntityManager *HarvesterEngine::getRuntimeEntities() const {
 	return _media ? _media->getRuntimeEntities() : nullptr;
 }
 
@@ -170,7 +170,7 @@ void HarvesterEngine::stopStartupMusic() {
 		_media->stopMusic();
 }
 
-bool HarvesterEngine::executeStartupAudioCommand(const StartupAudioCommand &command) {
+bool HarvesterEngine::executeStartupAudioCommand(const AudioCommand &command) {
 	return _media && _media->executeAudioCommand(command);
 }
 
@@ -262,13 +262,13 @@ bool HarvesterEngine::toggleRoomDebugEnabled() {
 	return _roomDebugEnabled;
 }
 
-bool HarvesterEngine::requestDebugCommand(const StartupCommandRecord &command) {
+bool HarvesterEngine::requestDebugCommand(const CommandRecord &command) {
 	if (!_activeFlow || !_startupScript || !_currentStartupSaveRoomState.valid)
 		return false;
 	if (_activeFlow->hasQueuedDebugInteraction())
 		return false;
 
-	StartupInteractionResult interaction;
+	InteractionResult interaction;
 	if (!_startupScript->executeDebugCommand(command, interaction))
 		return false;
 
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index b0b6f1dc0ac..731fd1f31a1 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -37,7 +37,7 @@ struct ADGameDescription;
 namespace Harvester {
 
 class ResourceManager;
-class RuntimeEntityManager;
+class EntityManager;
 class MediaManager;
 class Script;
 class Art;
@@ -77,7 +77,7 @@ public:
 
 	ResourceManager *getResources() const { return _resources; }
 	MediaManager *getMedia() const { return _media; }
-	RuntimeEntityManager *getRuntimeEntities() const;
+	EntityManager *getRuntimeEntities() const;
 	Graphics::Screen *getScreen() const;
 	int getDisplayWidth() const;
 	int getDisplayHeight() const;
@@ -99,7 +99,7 @@ public:
 	bool playStartupMusic(const Common::String &path);
 	void pauseStartupMusic(bool paused);
 	void stopStartupMusic();
-	bool executeStartupAudioCommand(const StartupAudioCommand &command);
+	bool executeStartupAudioCommand(const AudioCommand &command);
 	bool playStartupSound(const Common::String &path);
 	bool playStartupSingleSound(const Common::String &path);
 	void stopStartupSingleSound();
@@ -116,18 +116,18 @@ public:
 	bool toggleCombatDebugEnabled();
 	bool isRoomDebugEnabled() const { return _roomDebugEnabled; }
 	bool toggleRoomDebugEnabled();
-	bool requestDebugCommand(const StartupCommandRecord &command);
+	bool requestDebugCommand(const CommandRecord &command);
 	bool requestDebugRoomChange(const Common::String &roomName);
 	void captureCurrentStartupSaveRoomState(const Common::String &entranceName,
 		const Common::String &roomName, int playerX, int playerY, int playerZ, int playerFacing,
 		const Common::String &musicPath);
 	void clearCurrentStartupSaveRoomState();
 	bool hasCurrentStartupSaveRoomState() const { return _currentStartupSaveRoomState.valid; }
-	const StartupSaveRoomState &getCurrentStartupSaveRoomState() const {
+	const SaveRoomState &getCurrentStartupSaveRoomState() const {
 		return _currentStartupSaveRoomState;
 	}
 	bool hasPendingLoadedStartupSaveRoomState() const { return _pendingLoadedStartupSaveRoomState.valid; }
-	const StartupSaveRoomState &getPendingLoadedStartupSaveRoomState() const {
+	const SaveRoomState &getPendingLoadedStartupSaveRoomState() const {
 		return _pendingLoadedStartupSaveRoomState;
 	}
 	int getPendingLoadedStartupDisc() const { return _pendingLoadedStartupDisc; }
@@ -148,8 +148,8 @@ private:
 	ResourceManager *_resources = nullptr;
 	MediaManager *_media = nullptr;
 	Script *_startupScript = nullptr;
-	StartupSaveRoomState _currentStartupSaveRoomState;
-	StartupSaveRoomState _pendingLoadedStartupSaveRoomState;
+	SaveRoomState _currentStartupSaveRoomState;
+	SaveRoomState _pendingLoadedStartupSaveRoomState;
 	int _pendingLoadedStartupDisc = 0;
 	Common::Array<byte> _pendingLoadedDialogueStateBlob;
 	uint32 _pendingLoadedDialogueStateBlobVersion = 0;
diff --git a/engines/harvester/inventory.cpp b/engines/harvester/inventory.cpp
index 23cf52698e3..907f498e882 100644
--- a/engines/harvester/inventory.cpp
+++ b/engines/harvester/inventory.cpp
@@ -156,14 +156,14 @@ static Common::String buildUseItemPrompt(const Common::String &itemLabel, const
 	return Common::String::format("Use %s on %s", itemLabel.c_str(), targetLabel.c_str());
 }
 
-static Common::Rect getHotspotBounds(const StartupObjectRecord &object) {
+static Common::Rect getHotspotBounds(const ObjectRecord &object) {
 	if (object.boundsX2 > object.currentX && object.boundsY2 > object.currentY)
 		return Common::Rect(object.currentX, object.currentY, object.boundsX2 + 1, object.boundsY2 + 1);
 
 	return Common::Rect();
 }
 
-static Common::String resolveSceneObjectSpritePath(const StartupObjectRecord &object) {
+static Common::String resolveSceneObjectSpritePath(const ObjectRecord &object) {
 	const bool atInitialPlacement = object.currentX == object.initialX &&
 		object.currentY == object.initialY &&
 		object.currentOwnerOrRoom.equalsIgnoreCase(object.initialOwnerOrRoom);
@@ -255,7 +255,7 @@ static Common::String resolveInventoryWeekdayLabel(int storyDayIndex) {
 	}
 }
 
-static void debugLogInventoryVisual(const StartupInventoryVisual &visual, const Common::String &spritePath) {
+static void debugLogInventoryVisual(const InventoryVisual &visual, const Common::String &spritePath) {
 	debugC(1, kDebugInventory,
 		"Harvester: inventory visual object='%s' sprite='%s' alt='%s' chosen='%s' bounds=(%d,%d)-(%d,%d) action='%s' owner='%s' text='%s'",
 		visual.object.objectName.c_str(), visual.object.spritePath.c_str(), visual.object.altSpritePath.c_str(),
@@ -299,14 +299,14 @@ bool InventorySystem::refresh() {
 	_lastStoryDayIndex = startupScript->getCurrentStoryDayIndex();
 	_lastHasHarvestBlade = startupScript->isObjectInInventory(kHarvestBladeObjectName);
 
-	Common::Array<StartupObjectRecord> inventoryObjects;
+	Common::Array<ObjectRecord> inventoryObjects;
 	startupScript->getVisibleInventoryObjects(inventoryObjects);
 	int nextX = kInventoryItemStartX;
 	int nextY = kInventoryItemStartY;
 	int rowHeight = 0;
 
-	for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
-		StartupInventoryVisual visual;
+	for (const ObjectRecord &inventoryObject : inventoryObjects) {
+		InventoryVisual visual;
 		visual.object = inventoryObject;
 
 		if (isExitObject(inventoryObject)) {
@@ -353,7 +353,7 @@ bool InventorySystem::refresh() {
 	if (_selectedItemName.empty())
 		return true;
 
-	for (const StartupInventoryVisual &item : _items) {
+	for (const InventoryVisual &item : _items) {
 		if (item.object.objectName.equalsIgnoreCase(_selectedItemName))
 			return true;
 	}
@@ -420,14 +420,14 @@ Common::String InventorySystem::resolveSelectedLabel() const {
 	if (!startupScript || _selectedItemName.empty())
 		return Common::String();
 
-	for (const StartupInventoryVisual &item : _items) {
+	for (const InventoryVisual &item : _items) {
 		if (item.object.objectName.equalsIgnoreCase(_selectedItemName))
 			return startupScript->resolveObjectLabel(item.object);
 	}
 
-	Common::Array<StartupObjectRecord> inventoryObjects;
+	Common::Array<ObjectRecord> inventoryObjects;
 	startupScript->getVisibleInventoryObjects(inventoryObjects);
-	for (const StartupObjectRecord &item : inventoryObjects) {
+	for (const ObjectRecord &item : inventoryObjects) {
 		if (item.objectName.equalsIgnoreCase(_selectedItemName))
 			return startupScript->resolveObjectLabel(item);
 	}
@@ -443,7 +443,7 @@ void InventorySystem::selectItem(const Common::String &objectName) {
 	_selectedItemName = objectName;
 }
 
-bool InventorySystem::toggleCombatLoadout(const StartupObjectRecord &object, int currentLoadout,
+bool InventorySystem::toggleCombatLoadout(const ObjectRecord &object, int currentLoadout,
 		bool &changed) {
 	changed = false;
 
@@ -475,7 +475,7 @@ bool InventorySystem::toggleCombatLoadout(const StartupObjectRecord &object, int
 	return true;
 }
 
-bool InventorySystem::resolveSecondaryAction(const StartupObjectRecord &object,
+bool InventorySystem::resolveSecondaryAction(const ObjectRecord &object,
 		InventorySecondaryAction &action) const {
 	if (resolveInventorySecondaryActionEntry(object.objectName, action)) {
 		debugC(1, kDebugInventory,
@@ -516,7 +516,7 @@ Common::String InventorySystem::resolveWeekdayLabel() const {
 	return resolveInventoryWeekdayLabel(startupScript->getCurrentStoryDayIndex());
 }
 
-const StartupInventoryVisual *InventorySystem::findItemAtPoint(const Common::Point &point) const {
+const InventoryVisual *InventorySystem::findItemAtPoint(const Common::Point &point) const {
 	for (int i = (int)_items.size() - 1; i >= 0; --i) {
 		if (_items[i].bounds.contains(point))
 			return &_items[i];
@@ -539,7 +539,7 @@ void InventorySystem::drawSelectedDragItem(Graphics::Screen &screen, const Commo
 	if (_selectedItemName.empty())
 		return;
 
-	for (const StartupInventoryVisual &item : _items) {
+	for (const InventoryVisual &item : _items) {
 		if (!item.object.objectName.equalsIgnoreCase(_selectedItemName))
 			continue;
 		if (!item.hasBitmap || !item.bitmap.isValid())
@@ -559,7 +559,7 @@ void InventorySystem::drawOverlay(Graphics::Screen &screen) const {
 
 	blitBitmap(screen, art->getInventoryBitmap(), kInventoryX, kInventoryY);
 
-	for (const StartupInventoryVisual &item : _items) {
+	for (const InventoryVisual &item : _items) {
 		if (!_selectedItemName.empty() && item.object.objectName.equalsIgnoreCase(_selectedItemName))
 			continue;
 		if (item.hasBitmap && item.bitmap.isValid())
@@ -567,11 +567,11 @@ void InventorySystem::drawOverlay(Graphics::Screen &screen) const {
 	}
 }
 
-bool InventorySystem::isExitObject(const StartupObjectRecord &object) {
+bool InventorySystem::isExitObject(const ObjectRecord &object) {
 	return object.objectName.equalsIgnoreCase("INV_EXIT");
 }
 
-bool InventorySystem::isStatusObject(const StartupObjectRecord &object) {
+bool InventorySystem::isStatusObject(const ObjectRecord &object) {
 	return object.objectName.hasPrefixIgnoreCase("INV_STAT");
 }
 
diff --git a/engines/harvester/inventory.h b/engines/harvester/inventory.h
index 02f6154d9d2..70fba736bf6 100644
--- a/engines/harvester/inventory.h
+++ b/engines/harvester/inventory.h
@@ -38,8 +38,8 @@ namespace Harvester {
 
 class HarvesterEngine;
 
-struct StartupInventoryVisual {
-	StartupObjectRecord object;
+struct InventoryVisual {
+	ObjectRecord object;
 	IndexedBitmap bitmap;
 	Common::Rect bounds;
 	bool hasBitmap = false;
@@ -65,23 +65,23 @@ public:
 	Common::String resolveSelectedLabel() const;
 	Common::String buildSelectedPrompt(const Common::String &targetLabel) const;
 	void selectItem(const Common::String &objectName);
-	bool toggleCombatLoadout(const StartupObjectRecord &object, int currentLoadout, bool &changed);
-	bool resolveSecondaryAction(const StartupObjectRecord &object, InventorySecondaryAction &action) const;
+	bool toggleCombatLoadout(const ObjectRecord &object, int currentLoadout, bool &changed);
+	bool resolveSecondaryAction(const ObjectRecord &object, InventorySecondaryAction &action) const;
 	void setPromptText(const Common::String &promptText);
 	const Common::String &getPromptText() const;
 	Common::String resolveWeekdayLabel() const;
-	const StartupInventoryVisual *findItemAtPoint(const Common::Point &point) const;
+	const InventoryVisual *findItemAtPoint(const Common::Point &point) const;
 	Common::Rect getPanelBounds() const;
 	void drawOverlay(Graphics::Screen &screen) const;
 	void drawSelectedDragItem(Graphics::Screen &screen, const Common::Point &point) const;
 
-	static bool isExitObject(const StartupObjectRecord &object);
-	static bool isStatusObject(const StartupObjectRecord &object);
+	static bool isExitObject(const ObjectRecord &object);
+	static bool isStatusObject(const ObjectRecord &object);
 
 private:
 	bool loadBitmap(const Common::String &path, IndexedBitmap &bitmap);
 
-	Common::Array<StartupInventoryVisual> _items;
+	Common::Array<InventoryVisual> _items;
 	Common::HashMap<Common::String, IndexedBitmap,
 		Common::IgnoreCase_Hash, Common::IgnoreCase_EqualTo> _bitmapCache;
 	bool _open = false;
diff --git a/engines/harvester/media_manager.cpp b/engines/harvester/media_manager.cpp
index e953c0226cb..48034239e20 100644
--- a/engines/harvester/media_manager.cpp
+++ b/engines/harvester/media_manager.cpp
@@ -213,7 +213,7 @@ static Audio::SeekableAudioStream *openStartupAudioStream(ResourceManager &resou
 } // End of anonymous namespace
 
 MediaManager::MediaManager(ResourceManager &resources) : _resources(resources) {
-	_runtimeEntities = new RuntimeEntityManager(_resources);
+	_runtimeEntities = new EntityManager(_resources);
 }
 
 MediaManager::~MediaManager() {
@@ -321,7 +321,7 @@ void MediaManager::stopMusic() {
 	_musicPath.clear();
 }
 
-bool MediaManager::executeAudioCommand(const StartupAudioCommand &command) {
+bool MediaManager::executeAudioCommand(const AudioCommand &command) {
 	switch (command.type) {
 	case kStartupAudioCommandStartWav:
 		return playSound(command.path);
diff --git a/engines/harvester/media_manager.h b/engines/harvester/media_manager.h
index 19691ef12ae..02b125e6a93 100644
--- a/engines/harvester/media_manager.h
+++ b/engines/harvester/media_manager.h
@@ -34,8 +34,8 @@ namespace Harvester {
 
 class Art;
 class ResourceManager;
-class RuntimeEntityManager;
-struct StartupAudioCommand;
+class EntityManager;
+struct AudioCommand;
 class Text;
 
 class MediaManager {
@@ -43,7 +43,7 @@ public:
 	explicit MediaManager(ResourceManager &resources);
 	~MediaManager();
 
-	RuntimeEntityManager *getRuntimeEntities() const { return _runtimeEntities; }
+	EntityManager *getRuntimeEntities() const { return _runtimeEntities; }
 	Graphics::Screen *getScreen() const { return _screen; }
 	int getDisplayWidth() const { return _displayWidth; }
 	int getDisplayHeight() const { return _displayHeight; }
@@ -63,7 +63,7 @@ public:
 	bool playMusic(const Common::String &path);
 	void pauseMusic(bool paused);
 	void stopMusic();
-	bool executeAudioCommand(const StartupAudioCommand &command);
+	bool executeAudioCommand(const AudioCommand &command);
 	bool playSound(const Common::String &path);
 	bool playSingleSound(const Common::String &path);
 	void stopSingleSound();
@@ -81,7 +81,7 @@ private:
 	bool validateLoadedSoundSlot(int slot) const;
 
 	ResourceManager &_resources;
-	RuntimeEntityManager *_runtimeEntities = nullptr;
+	EntityManager *_runtimeEntities = nullptr;
 	Graphics::Screen *_screen = nullptr;
 	int _displayWidth = 0;
 	int _displayHeight = 0;
diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index bebed6c1545..9eb86d83d5f 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -135,7 +135,7 @@ public:
 	void pause() {
 		if (_paused)
 			return;
-		if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities()) {
+		if (EntityManager *runtimeEntities = _engine.getRuntimeEntities()) {
 			runtimeEntities->pauseTimerCountdowns();
 			_paused = true;
 		}
@@ -144,7 +144,7 @@ public:
 	void resume() {
 		if (!_paused)
 			return;
-		if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities())
+		if (EntityManager *runtimeEntities = _engine.getRuntimeEntities())
 			runtimeEntities->resumeTimerCountdowns();
 		_paused = false;
 	}
@@ -938,7 +938,7 @@ Common::Error MenuSystem::showGameOverBackdrop(Flow &startupFlow) {
 			}
 		}
 
-		if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities()) {
+		if (EntityManager *runtimeEntities = _engine.getRuntimeEntities()) {
 			if (runtimeEntities->syncCursorEntityPosition(_mousePos))
 				needsRedraw = true;
 		}
@@ -1108,7 +1108,7 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 			}
 		}
 
-		RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+		EntityManager *runtimeEntities = _engine.getRuntimeEntities();
 		if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
 			needsRedraw = true;
 
@@ -1268,7 +1268,7 @@ Common::Error MenuSystem::runLoadGameMenu(const byte *palette, float paletteBrig
 			}
 		}
 
-		if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities())
+		if (EntityManager *runtimeEntities = _engine.getRuntimeEntities())
 			(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
 
 		limiter.delayBeforeSwap();
@@ -1383,7 +1383,7 @@ Common::Error MenuSystem::runSaveGameMenu(const byte *palette, float paletteBrig
 				}
 			}
 
-			if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities())
+			if (EntityManager *runtimeEntities = _engine.getRuntimeEntities())
 				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
 
 			entryLimiter.delayBeforeSwap();
@@ -1501,7 +1501,7 @@ Common::Error MenuSystem::runSaveGameMenu(const byte *palette, float paletteBrig
 			}
 		}
 
-		if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities())
+		if (EntityManager *runtimeEntities = _engine.getRuntimeEntities())
 			(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
 
 		limiter.delayBeforeSwap();
@@ -1637,7 +1637,7 @@ Common::Error MenuSystem::runConfirmPrompt(const IndexedBitmap &backdrop, const
 			}
 		}
 
-		if (RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities())
+		if (EntityManager *runtimeEntities = _engine.getRuntimeEntities())
 			(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
 
 		limiter.delayBeforeSwap();
@@ -2035,7 +2035,7 @@ Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const by
 			}
 		}
 
-		RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+		EntityManager *runtimeEntities = _engine.getRuntimeEntities();
 		if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
 			needsRedraw = true;
 
@@ -2109,7 +2109,7 @@ Common::Error MenuSystem::runHelpScreen(const byte *palette, float paletteBright
 			}
 		}
 
-		RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+		EntityManager *runtimeEntities = _engine.getRuntimeEntities();
 		if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
 			needsRedraw = true;
 
diff --git a/engines/harvester/monster.cpp b/engines/harvester/monster.cpp
index 14cf98444ce..7c7ff52589f 100644
--- a/engines/harvester/monster.cpp
+++ b/engines/harvester/monster.cpp
@@ -60,7 +60,7 @@ int Monster::resolveFacingFrame(int facing) {
 	return resolveAnimationRange(facing).idleFrame;
 }
 
-void Monster::applyAnimation(RuntimeEntity &entity, const StartupMonsterRecord &monster) {
+void Monster::applyAnimation(Entity &entity, const MonsterRecord &monster) {
 	entity.setVisible(monster.visible);
 	if (!monster.visible)
 		return;
diff --git a/engines/harvester/monster.h b/engines/harvester/monster.h
index 1cc298bec68..54ab7b98c5b 100644
--- a/engines/harvester/monster.h
+++ b/engines/harvester/monster.h
@@ -26,9 +26,9 @@
 
 namespace Harvester {
 
-class RuntimeEntity;
+class Entity;
 
-struct StartupMonsterRecord {
+struct MonsterRecord {
 	int posX = 0;
 	int posY = 0;
 	int posZ = 0;
@@ -73,7 +73,7 @@ struct StartupMonsterRecord {
 class Monster {
 public:
 	static int resolveFacingFrame(int facing);
-	static void applyAnimation(RuntimeEntity &entity, const StartupMonsterRecord &monster);
+	static void applyAnimation(Entity &entity, const MonsterRecord &monster);
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/beggar_dialogue.cpp b/engines/harvester/npc/beggar_dialogue.cpp
index c135d983422..601406a853b 100644
--- a/engines/harvester/npc/beggar_dialogue.cpp
+++ b/engines/harvester/npc/beggar_dialogue.cpp
@@ -54,7 +54,7 @@ Common::Error BeggarDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (!runtime.startupScript().getFlagValue(kBeggarInterruptFlag))
 		return Common::kNoError;
 
-	StartupInteractionResult interaction;
+	InteractionResult interaction;
 	if (runtime.startupScript().executeActionTag(kBeggarDialog3ActionTag, interaction)) {
 		runtime.applyImmediateDialogueInteractionEffects(interaction);
 		runtime.queueDialogueInteractionIfNeeded(interaction);
diff --git a/engines/harvester/npc/boyle_dialogue.cpp b/engines/harvester/npc/boyle_dialogue.cpp
index bc0ce9256e7..b0c2bb1dfe7 100644
--- a/engines/harvester/npc/boyle_dialogue.cpp
+++ b/engines/harvester/npc/boyle_dialogue.cpp
@@ -165,9 +165,9 @@ Common::Error BoyleDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			kInventoryOwnerName, objectName, false);
 	};
 	auto hasInventoryItem = [&](const char *objectName) {
-		Common::Array<StartupObjectRecord> inventoryObjects;
+		Common::Array<ObjectRecord> inventoryObjects;
 		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
-		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+		for (const ObjectRecord &inventoryObject : inventoryObjects) {
 			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
 				return true;
 		}
diff --git a/engines/harvester/npc/chessmaster_dialogue.cpp b/engines/harvester/npc/chessmaster_dialogue.cpp
index 50591658487..d70478818ab 100644
--- a/engines/harvester/npc/chessmaster_dialogue.cpp
+++ b/engines/harvester/npc/chessmaster_dialogue.cpp
@@ -424,7 +424,7 @@ Common::Error ChessmasterDialogueHandler::handleDialogue(DialogueRuntime &runtim
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (runtime.startupScript().executeActionTag(kChessKingMonsterActionTag, interaction)) {
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
@@ -438,7 +438,7 @@ Common::Error ChessmasterDialogueHandler::handleDialogue(DialogueRuntime &runtim
 	if (lineError.getCode() != Common::kNoError)
 		return lineError;
 
-	StartupInteractionResult interaction;
+	InteractionResult interaction;
 	if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(
 			kChessmasterNpc, kChessmasterSlashDamageType)) {
 		interaction.mutatedRuntimeState = true;
diff --git a/engines/harvester/npc/cloak_atnd_dialogue.cpp b/engines/harvester/npc/cloak_atnd_dialogue.cpp
index ceaf8716953..be81d134113 100644
--- a/engines/harvester/npc/cloak_atnd_dialogue.cpp
+++ b/engines/harvester/npc/cloak_atnd_dialogue.cpp
@@ -47,7 +47,7 @@ Common::Error CloakAtndDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (!usedItemName.equalsIgnoreCase(kPaymentItemName))
 			return Common::kNoError;
 
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (runtime.startupScript().executeActionTag(kCleanClothesActionTag, interaction)) {
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
diff --git a/engines/harvester/npc/dark_woman_dialogue.cpp b/engines/harvester/npc/dark_woman_dialogue.cpp
index 3fa597927f8..8c1c76ff277 100644
--- a/engines/harvester/npc/dark_woman_dialogue.cpp
+++ b/engines/harvester/npc/dark_woman_dialogue.cpp
@@ -52,7 +52,7 @@ Common::Error DarkWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.playDialogueLineWithVariant(wavId, kDarkWomanNpc, headVariant);
 	};
 	auto queueDarkWomanDeathOrMonsterfyTransition = [&]() {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(kDarkWomanNpc))
 			interaction.mutatedRuntimeState = true;
 		runtime.queueDialogueInteractionIfNeeded(interaction);
@@ -80,7 +80,7 @@ Common::Error DarkWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 		const bool changed = runtime.startupScript().setRuntimeObjectVisible(
 			kInventoryOwnerName, kHandMirrorItemName, false);
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (runtime.startupScript().executeActionTag(kSetDarkWomanFlagActionTag, interaction)) {
 			interaction.mutatedRuntimeState |= changed;
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
diff --git a/engines/harvester/npc/dialogue_runtime.h b/engines/harvester/npc/dialogue_runtime.h
index 7d532dca2f6..e54b7bf72b4 100644
--- a/engines/harvester/npc/dialogue_runtime.h
+++ b/engines/harvester/npc/dialogue_runtime.h
@@ -45,8 +45,8 @@ public:
 	typedef std::function<void(Common::String &, int &, int, const char *)> AssignTopicBufferFn;
 	typedef std::function<bool(const Common::String &, int)> MatchesResponseLineFn;
 	typedef std::function<bool(const Common::String &, const int *, uint)> MatchesAnyResponseLineFn;
-	typedef std::function<void(const StartupInteractionResult &)> QueueDialogueInteractionIfNeededFn;
-	typedef std::function<void(StartupInteractionResult &)> ApplyImmediateDialogueInteractionEffectsFn;
+	typedef std::function<void(const InteractionResult &)> QueueDialogueInteractionIfNeededFn;
+	typedef std::function<void(InteractionResult &)> ApplyImmediateDialogueInteractionEffectsFn;
 	typedef std::function<int(int)> GetRandomNumberFn;
 	typedef std::function<void(const Common::String &, int)> SetActiveSpeakerPortraitFn;
 
@@ -133,10 +133,10 @@ public:
 	}
 	bool lastKeywordSelectionWasTypedInput() const { return _lastKeywordSelection.fromTypedInput; }
 	bool lastKeywordSelectionWasGenericBye() const { return _lastKeywordSelection.fromGenericBye; }
-	void queueDialogueInteractionIfNeeded(const StartupInteractionResult &interaction) const {
+	void queueDialogueInteractionIfNeeded(const InteractionResult &interaction) const {
 		_queueDialogueInteractionIfNeeded(interaction);
 	}
-	void applyImmediateDialogueInteractionEffects(StartupInteractionResult &interaction) const {
+	void applyImmediateDialogueInteractionEffects(InteractionResult &interaction) const {
 		_applyImmediateDialogueInteractionEffects(interaction);
 	}
 	int getRandomNumber(int maxValue) const { return _getRandomNumber(maxValue); }
diff --git a/engines/harvester/npc/dwayne_dialogue.cpp b/engines/harvester/npc/dwayne_dialogue.cpp
index a893109903a..7e56d58249a 100644
--- a/engines/harvester/npc/dwayne_dialogue.cpp
+++ b/engines/harvester/npc/dwayne_dialogue.cpp
@@ -86,7 +86,7 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		(void)startupScript.setRuntimeFlagValue("PC_HAS_GOOJF_CARD", true);
 	};
 	auto executeActionTagIfSet = [&](const char *tag) {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (!startupScript.executeActionTag(tag, interaction))
 			return;
 
diff --git a/engines/harvester/npc/gladiator_dialogue.cpp b/engines/harvester/npc/gladiator_dialogue.cpp
index 92ae99b1b10..c4026cff0a0 100644
--- a/engines/harvester/npc/gladiator_dialogue.cpp
+++ b/engines/harvester/npc/gladiator_dialogue.cpp
@@ -48,7 +48,7 @@ Common::Error GladiatorDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.playDialogueLineWithVariant(wavId, kGladiatorNpc, headVariant);
 	};
 	auto queueGladiatorDeathOrMonsterfyTransition = [&]() {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(kGladiatorNpc))
 			interaction.mutatedRuntimeState = true;
 		runtime.queueDialogueInteractionIfNeeded(interaction);
@@ -117,7 +117,7 @@ Common::Error GladiatorDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (lineError.getCode() != Common::kNoError)
 		return lineError;
 
-	StartupInteractionResult interaction;
+	InteractionResult interaction;
 	if (runtime.startupScript().executeActionTag(kStartMercyTimerActionTag, interaction)) {
 		runtime.applyImmediateDialogueInteractionEffects(interaction);
 		runtime.queueDialogueInteractionIfNeeded(interaction);
diff --git a/engines/harvester/npc/herrill_dialogue.cpp b/engines/harvester/npc/herrill_dialogue.cpp
index 982355f9e97..34f4d91dca0 100644
--- a/engines/harvester/npc/herrill_dialogue.cpp
+++ b/engines/harvester/npc/herrill_dialogue.cpp
@@ -84,7 +84,7 @@ Common::Error HerrillDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.playDialogueLineWithVariant(wavId, kGrandMuckNpc, headVariant);
 	};
 	auto queueNpcSlashTransition = [&](const char *npcName) {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(
 				npcName, kSlashDeathDamageType)) {
 			interaction.mutatedRuntimeState = true;
@@ -92,9 +92,9 @@ Common::Error HerrillDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 	};
 	auto hasInventoryItem = [&](const char *objectName) {
-		Common::Array<StartupObjectRecord> inventoryObjects;
+		Common::Array<ObjectRecord> inventoryObjects;
 		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
-		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+		for (const ObjectRecord &inventoryObject : inventoryObjects) {
 			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
 				return true;
 		}
diff --git a/engines/harvester/npc/inquisitor_dialogue.cpp b/engines/harvester/npc/inquisitor_dialogue.cpp
index b9837614fb0..74e85227e09 100644
--- a/engines/harvester/npc/inquisitor_dialogue.cpp
+++ b/engines/harvester/npc/inquisitor_dialogue.cpp
@@ -71,7 +71,7 @@ Common::Error InquisitorDialogueHandler::handleDialogue(DialogueRuntime &runtime
 	if (lineError.getCode() != Common::kNoError)
 		return lineError;
 
-	StartupInteractionResult interaction;
+	InteractionResult interaction;
 	if (runtime.startupScript().executeActionTag(kStartInquisitorTimerActionTag, interaction)) {
 		runtime.applyImmediateDialogueInteractionEffects(interaction);
 		runtime.queueDialogueInteractionIfNeeded(interaction);
diff --git a/engines/harvester/npc/jimmy_dialogue.cpp b/engines/harvester/npc/jimmy_dialogue.cpp
index 56c593c6bf7..6f42e4a1b27 100644
--- a/engines/harvester/npc/jimmy_dialogue.cpp
+++ b/engines/harvester/npc/jimmy_dialogue.cpp
@@ -102,7 +102,7 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.startupScript().isObjectInInventory(objectName);
 	};
 	auto executeActionTagIfSet = [&](const char *tag) {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (!runtime.startupScript().executeActionTag(tag, interaction))
 			return;
 
diff --git a/engines/harvester/npc/karin_dialogue.cpp b/engines/harvester/npc/karin_dialogue.cpp
index 1d5a894b9c3..3c413373bc8 100644
--- a/engines/harvester/npc/karin_dialogue.cpp
+++ b/engines/harvester/npc/karin_dialogue.cpp
@@ -85,7 +85,7 @@ Common::Error KarinDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.playDialogueEntrySequence(lines, count);
 	};
 	auto executeActionTagIfSet = [&](const char *tag) {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (!runtime.startupScript().executeActionTag(tag, interaction))
 			return;
 
diff --git a/engines/harvester/npc/librarian_dialogue.cpp b/engines/harvester/npc/librarian_dialogue.cpp
index e8b5d771263..fe6fec9c3b2 100644
--- a/engines/harvester/npc/librarian_dialogue.cpp
+++ b/engines/harvester/npc/librarian_dialogue.cpp
@@ -69,9 +69,9 @@ bool LibrarianDialogueHandler::matchesNpc(const Common::String &npcName) const {
 Common::Error LibrarianDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &) {
 	auto hasVisibleInventoryItem = [&](const char *objectName) {
-		Common::Array<StartupObjectRecord> inventoryObjects;
+		Common::Array<ObjectRecord> inventoryObjects;
 		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
-		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+		for (const ObjectRecord &inventoryObject : inventoryObjects) {
 			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
 				return true;
 		}
diff --git a/engines/harvester/npc/loomis_dialogue.cpp b/engines/harvester/npc/loomis_dialogue.cpp
index bb3534619e3..5b1ccd684af 100644
--- a/engines/harvester/npc/loomis_dialogue.cpp
+++ b/engines/harvester/npc/loomis_dialogue.cpp
@@ -88,7 +88,7 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.playDialogueLineWithVariant(wavId, speakerId, headVariant);
 	};
 	auto executeLoomisActionTag = [&](const char *tag) {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (!runtime.startupScript().executeActionTag(tag, interaction))
 			return;
 
@@ -308,7 +308,7 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			if (lineError.getCode() != Common::kNoError)
 				return lineError;
 
-			StartupInteractionResult interaction;
+			InteractionResult interaction;
 			interaction.mutatedRuntimeState = changed;
 			runtime.queueDialogueInteractionIfNeeded(interaction);
 			return Common::kNoError;
diff --git a/engines/harvester/npc/madam_dialogue.cpp b/engines/harvester/npc/madam_dialogue.cpp
index 49cda39a419..47209be6683 100644
--- a/engines/harvester/npc/madam_dialogue.cpp
+++ b/engines/harvester/npc/madam_dialogue.cpp
@@ -57,13 +57,13 @@ Common::Error MadamDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.playDialogueLineWithVariant(wavId, kPcSpeaker, headVariant);
 	};
 	auto queueHookerDeathOrMonsterfyTransition = [&]() {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(kHookerNpc))
 			interaction.mutatedRuntimeState = true;
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 	};
 	auto executeActionTagIfSet = [&](const char *actionTag, bool mutatedRuntimeState = false) {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		interaction.mutatedRuntimeState = mutatedRuntimeState;
 		if (runtime.startupScript().executeActionTag(actionTag, interaction)) {
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
diff --git a/engines/harvester/npc/mom_dialogue.cpp b/engines/harvester/npc/mom_dialogue.cpp
index 03bde043270..c2e46eb39a8 100644
--- a/engines/harvester/npc/mom_dialogue.cpp
+++ b/engines/harvester/npc/mom_dialogue.cpp
@@ -494,16 +494,16 @@ Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.playDialogueEntrySequence(lines, count);
 	};
 	auto executeActionTagIfSet = [&](const char *actionTag) {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (runtime.startupScript().executeActionTag(actionTag, interaction)) {
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
 		}
 	};
 	auto hasInventoryObject = [&](const char *objectName) -> bool {
-		Common::Array<StartupObjectRecord> inventoryObjects;
+		Common::Array<ObjectRecord> inventoryObjects;
 		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
-		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+		for (const ObjectRecord &inventoryObject : inventoryObjects) {
 			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
 				return true;
 		}
diff --git a/engines/harvester/npc/mr_potts_dialogue.cpp b/engines/harvester/npc/mr_potts_dialogue.cpp
index a5e065e6570..cdc6fa55506 100644
--- a/engines/harvester/npc/mr_potts_dialogue.cpp
+++ b/engines/harvester/npc/mr_potts_dialogue.cpp
@@ -304,7 +304,7 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.startupScript().isObjectInInventory(objectName);
 	};
 	auto executeActionTagIfSet = [&](const char *tag) {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (!runtime.startupScript().executeActionTag(tag, interaction))
 			return;
 
@@ -312,7 +312,7 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 	};
 	auto queueNpcDeathOrMonsterfyTransition = [&](const char *npcName) {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(npcName))
 			interaction.mutatedRuntimeState = true;
 		runtime.queueDialogueInteractionIfNeeded(interaction);
diff --git a/engines/harvester/npc/mrs_potts_dialogue.cpp b/engines/harvester/npc/mrs_potts_dialogue.cpp
index 3e9a7ef7fd8..60e54a5de32 100644
--- a/engines/harvester/npc/mrs_potts_dialogue.cpp
+++ b/engines/harvester/npc/mrs_potts_dialogue.cpp
@@ -156,7 +156,7 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.playDialogueEntrySequence(lines, count);
 	};
 	auto executeDialogueActionTag = [&](const char *tag) {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (runtime.startupScript().executeActionTag(tag, interaction)) {
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
diff --git a/engines/harvester/npc/pastorelli_dialogue.cpp b/engines/harvester/npc/pastorelli_dialogue.cpp
index b57dbe5f088..90e215ae8e3 100644
--- a/engines/harvester/npc/pastorelli_dialogue.cpp
+++ b/engines/harvester/npc/pastorelli_dialogue.cpp
@@ -39,7 +39,7 @@ bool PastorelliDialogueHandler::matchesNpc(const Common::String &npcName) const
 
 Common::Error PastorelliDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
-	StartupInteractionResult interaction;
+	InteractionResult interaction;
 	if (runtime.startupScript().executeActionTag(kPastorelliTalkActionTag, interaction)) {
 		runtime.applyImmediateDialogueInteractionEffects(interaction);
 		runtime.queueDialogueInteractionIfNeeded(interaction);
diff --git a/engines/harvester/npc/phelps_dialogue.cpp b/engines/harvester/npc/phelps_dialogue.cpp
index 611e7f9ea6a..00eb6d80eca 100644
--- a/engines/harvester/npc/phelps_dialogue.cpp
+++ b/engines/harvester/npc/phelps_dialogue.cpp
@@ -88,7 +88,7 @@ Common::Error PhelpsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	PhelpsRoomDialogueState &state = _state;
 
 	auto executeDialogueActionTag = [&](const char *tag) {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (runtime.startupScript().executeActionTag(tag, interaction)) {
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
diff --git a/engines/harvester/npc/priest_dialogue.cpp b/engines/harvester/npc/priest_dialogue.cpp
index 5923f8acff4..c8ec77e229e 100644
--- a/engines/harvester/npc/priest_dialogue.cpp
+++ b/engines/harvester/npc/priest_dialogue.cpp
@@ -52,7 +52,7 @@ Common::Error PriestDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.playDialogueLineWithVariant(wavId, kPriestNpc, headVariant);
 	};
 	auto executeDialogueActionTag = [&](const char *tag) {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (runtime.startupScript().executeActionTag(tag, interaction)) {
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
diff --git a/engines/harvester/npc/sergeant_dialogue.cpp b/engines/harvester/npc/sergeant_dialogue.cpp
index fe1d79a19a7..ed8f0f583cb 100644
--- a/engines/harvester/npc/sergeant_dialogue.cpp
+++ b/engines/harvester/npc/sergeant_dialogue.cpp
@@ -73,9 +73,9 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.playDialogueEntrySequence(lines, count);
 	};
 	auto hasInventoryItem = [&](const char *objectName) {
-		Common::Array<StartupObjectRecord> inventoryObjects;
+		Common::Array<ObjectRecord> inventoryObjects;
 		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
-		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+		for (const ObjectRecord &inventoryObject : inventoryObjects) {
 			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
 				return true;
 		}
@@ -90,7 +90,7 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		resetAndSetObjectVisible(kRahRoomName, objectName, visible);
 	};
 	auto executeActionTagIfSet = [&](const char *actionTag) -> Common::Error {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (!runtime.startupScript().executeActionTag(actionTag, interaction))
 			return Common::kNoError;
 
@@ -99,7 +99,7 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return Common::kNoError;
 	};
 	auto queueNpcSlashTransition = [&](const char *npcName) {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(
 				npcName, kSlashDeathDamageType)) {
 			interaction.mutatedRuntimeState = true;
@@ -182,7 +182,7 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return lineError;
 
 		// Native prompt_for_cdrom_disc_change(3, 0) latches a main-loop room rebuild.
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		interaction.requestRoomRestart = true;
 		interaction.roomRestartTargetName = "START";
 		runtime.queueDialogueInteractionIfNeeded(interaction);
diff --git a/engines/harvester/npc/sparky_dialogue.cpp b/engines/harvester/npc/sparky_dialogue.cpp
index c0549eb7b68..77c869b6574 100644
--- a/engines/harvester/npc/sparky_dialogue.cpp
+++ b/engines/harvester/npc/sparky_dialogue.cpp
@@ -58,7 +58,7 @@ Common::Error SparkyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.playDialogueLineWithVariant(wavId, "PC", headVariant);
 	};
 	auto executeDialogueActionTag = [&](const char *tag) {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (runtime.startupScript().executeActionTag(tag, interaction)) {
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
diff --git a/engines/harvester/npc/stephanie_dialogue.cpp b/engines/harvester/npc/stephanie_dialogue.cpp
index aa9e1e08ec0..e235ad59223 100644
--- a/engines/harvester/npc/stephanie_dialogue.cpp
+++ b/engines/harvester/npc/stephanie_dialogue.cpp
@@ -66,7 +66,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.playDialogueLineWithVariant(wavId, "STEPHANIE", headVariant);
 	};
 	auto executeDialogueActionTag = [&](const char *tag) {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (runtime.startupScript().executeActionTag(tag, interaction)) {
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
diff --git a/engines/harvester/npc/whaley_dialogue.cpp b/engines/harvester/npc/whaley_dialogue.cpp
index d9e4d8a0d12..00b6e237405 100644
--- a/engines/harvester/npc/whaley_dialogue.cpp
+++ b/engines/harvester/npc/whaley_dialogue.cpp
@@ -162,16 +162,16 @@ Common::Error WhaleyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return runtime.playDialogueEntrySequence(lines, count);
 	};
 	auto executeActionTagIfSet = [&](const char *actionTag) {
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		if (runtime.startupScript().executeActionTag(actionTag, interaction)) {
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
 		}
 	};
 	auto hasInventoryItem = [&](const char *objectName) {
-		Common::Array<StartupObjectRecord> inventoryObjects;
+		Common::Array<ObjectRecord> inventoryObjects;
 		runtime.startupScript().getVisibleInventoryObjects(inventoryObjects);
-		for (const StartupObjectRecord &inventoryObject : inventoryObjects) {
+		for (const ObjectRecord &inventoryObject : inventoryObjects) {
 			if (inventoryObject.objectName.equalsIgnoreCase(objectName))
 				return true;
 		}
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index fec20e4248d..3a8e4c52393 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -276,14 +276,14 @@ static bool playPlayerHitSound(HarvesterEngine &engine) {
 	return engine.playStartupSound(kPlayerHitSoundPaths[soundIndex]);
 }
 
-static bool runtimeEntityHasFrameRange(const RuntimeEntity &entity, int firstFrame, int lastFrame) {
+static bool runtimeEntityHasFrameRange(const Entity &entity, int firstFrame, int lastFrame) {
 	if (!entity.hasFrames() || firstFrame < 0 || lastFrame < firstFrame)
 		return false;
 
 	return (uint)lastFrame < entity.getFrameCount();
 }
 
-static bool choosePlayerDeathAnimationRange(const RuntimeEntity &entity, int firstFrame, int lastFrame,
+static bool choosePlayerDeathAnimationRange(const Entity &entity, int firstFrame, int lastFrame,
 		PlayerDeathAnimationRange &range) {
 	if (!runtimeEntityHasFrameRange(entity, firstFrame, lastFrame))
 		return false;
@@ -292,7 +292,7 @@ static bool choosePlayerDeathAnimationRange(const RuntimeEntity &entity, int fir
 	return true;
 }
 
-static bool resolvePlayerDeathAnimationRange(const StartupRoomPlayerState &playerState,
+static bool resolvePlayerDeathAnimationRange(const RoomPlayerState &playerState,
 		int damageType, bool goreEnabled, PlayerDeathAnimationRange &range) {
 	if (!playerState.entity)
 		return false;
@@ -365,8 +365,8 @@ static bool resolvePlayerDeathAnimationRange(const StartupRoomPlayerState &playe
 	}
 }
 
-static bool resolvePlayerAttackAnimationRange(const StartupRoomSetupState &state,
-		const StartupRoomPlayerState &playerState, const Common::Point &mousePos,
+static bool resolvePlayerAttackAnimationRange(const RoomSetupState &state,
+		const RoomPlayerState &playerState, const Common::Point &mousePos,
 		PlayerAttackAnimationRange &range) {
 	if (!playerState.entity)
 		return false;
@@ -396,7 +396,7 @@ static bool resolvePlayerAttackAnimationRange(const StartupRoomSetupState &state
 	return true;
 }
 
-static bool resolveKeyboardAttackAnimationRange(const StartupRoomPlayerState &playerState,
+static bool resolveKeyboardAttackAnimationRange(const RoomPlayerState &playerState,
 		bool attackLeft, bool attackRight, bool attackUp, bool attackDown,
 		PlayerAttackAnimationRange &range) {
 	// Native update_player_combat_avatar_state uses scan-code 0x1d as the attack modifier:
@@ -436,7 +436,7 @@ static bool resolveKeyboardAttackAnimationRange(const StartupRoomPlayerState &pl
 	return false;
 }
 
-static bool resolvePlayerHitAnimationRange(const RuntimeEntity &entity,
+static bool resolvePlayerHitAnimationRange(const Entity &entity,
 		int monsterAttackFirstFrame, PlayerHitAnimationRange &range) {
 	PlayerHitAnimationRange candidate;
 	switch (monsterAttackFirstFrame) {
@@ -469,7 +469,7 @@ static bool resolvePlayerHitAnimationRange(const RuntimeEntity &entity,
 	return true;
 }
 
-static bool startResolvedAttackAnimation(StartupRoomPlayerState &playerState,
+static bool startResolvedAttackAnimation(RoomPlayerState &playerState,
 		const PlayerAttackAnimationRange &range) {
 	if (!playerState.entity || playerState.attackActive || playerState.hitActive)
 		return false;
@@ -499,14 +499,14 @@ static bool startResolvedAttackAnimation(StartupRoomPlayerState &playerState,
 	return true;
 }
 
-static int clampRoomMovementY(const StartupRoomSetupState &state, int screenY) {
+static int clampRoomMovementY(const RoomSetupState &state, int screenY) {
 	if (!Player::supportsMovementBand(state))
 		return screenY;
 
 	return CLIP<int>(screenY, state.roomMaxZScreenY, state.roomMinZScreenY);
 }
 
-static float mapRoomScreenYToDepth(const StartupRoomSetupState &state, int screenY) {
+static float mapRoomScreenYToDepth(const RoomSetupState &state, int screenY) {
 	if (!Player::supportsMovementBand(state))
 		return (float)state.playerSpawnZ;
 
@@ -520,7 +520,7 @@ static float mapRoomScreenYToDepth(const StartupRoomSetupState &state, int scree
 		(state.roomMinZScreenY - state.roomMaxZScreenY));
 }
 
-static float clampRoomDepth(const StartupRoomSetupState &state, float z) {
+static float clampRoomDepth(const RoomSetupState &state, float z) {
 	if (!Player::supportsMovementBand(state))
 		return z;
 
@@ -529,7 +529,7 @@ static float clampRoomDepth(const StartupRoomSetupState &state, float z) {
 		(float)MAX(state.roomMinZ, state.roomMaxZ));
 }
 
-static int mapRoomDepthToScreenY(const StartupRoomSetupState &state, float z) {
+static int mapRoomDepthToScreenY(const RoomSetupState &state, float z) {
 	if (!Player::supportsMovementBand(state))
 		return state.playerSpawnY;
 
@@ -551,22 +551,22 @@ static int resolveFacingFromMovementDelta(int dx, int dy) {
 	return 0;
 }
 
-static int resolveFacingFromRoomMovement(const StartupRoomSetupState &state,
+static int resolveFacingFromRoomMovement(const RoomSetupState &state,
 		int fromCenterX, float fromZ, int toCenterX, float toZ) {
 	return resolveFacingFromMovementDelta(
 		toCenterX - fromCenterX,
 		mapRoomDepthToScreenY(state, toZ) - mapRoomDepthToScreenY(state, fromZ));
 }
 
-static int computeRoomPlayerHorizontalStep(const StartupRoomSetupState &state, float z) {
+static int computeRoomPlayerHorizontalStep(const RoomSetupState &state, float z) {
 	return MAX<int>(1, roundToInt(Player::computeDepthScale(state, z) * kRoomPlayerHorizontalMoveBase));
 }
 
-static float computeRoomPlayerDepthStep(const StartupRoomSetupState &state) {
+static float computeRoomPlayerDepthStep(const RoomSetupState &state) {
 	return state.roomZVelocityStep > 0.0f ? state.roomZVelocityStep : 1.0f;
 }
 
-static int clampPlayerCenterXToNativeBounds(const StartupRoomPlayerState &playerState, int centerX) {
+static int clampPlayerCenterXToNativeBounds(const RoomPlayerState &playerState, int centerX) {
 	if (!playerState.entity)
 		return CLIP<int>(centerX, 0, 639);
 
@@ -587,7 +587,7 @@ static uint32 computeAnimationTickInterval(int rate) {
 	return rate > 0 ? MAX<uint32>(1, 100U / (uint32)rate) : 0;
 }
 
-static bool consumePlayerMovementTick(StartupRoomPlayerState &playerState) {
+static bool consumePlayerMovementTick(RoomPlayerState &playerState) {
 	if (playerState.entity &&
 			playerState.entity->isAnimationEnabled() &&
 			playerState.entity->getAnimationRate() != 0) {
@@ -628,20 +628,20 @@ static float stepTowardsFloat(float current, float target, float step) {
 }
 
 static bool doesPlayerOverlapRoomBlocker(HarvesterEngine &engine,
-		const Common::Array<StartupObjectRecord> &sceneObjects,
-		const Common::Array<StartupAnimRecord> &sceneAnimations, const RuntimeEntity &playerEntity) {
-	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
+		const Common::Array<ObjectRecord> &sceneObjects,
+		const Common::Array<AnimRecord> &sceneAnimations, const Entity &playerEntity) {
+	EntityManager *runtimeEntities = engine.getRuntimeEntities();
 	if (!runtimeEntities)
 		return false;
 
-	for (const StartupObjectRecord &object : sceneObjects) {
-		const RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(object.objectName);
+	for (const ObjectRecord &object : sceneObjects) {
+		const Entity *entity = runtimeEntities->findSceneEntityByName(object.objectName);
 		if (entity && playerEntity.overlapsEntity(*entity))
 			return true;
 	}
 
-	for (const StartupAnimRecord &anim : sceneAnimations) {
-		const RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(anim.animName);
+	for (const AnimRecord &anim : sceneAnimations) {
+		const Entity *entity = runtimeEntities->findSceneEntityByName(anim.animName);
 		if (entity && playerEntity.overlapsEntity(*entity))
 			return true;
 	}
@@ -649,10 +649,10 @@ static bool doesPlayerOverlapRoomBlocker(HarvesterEngine &engine,
 	return false;
 }
 
-static bool isPlayerMovementBlocked(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		const Common::Array<StartupObjectRecord> &sceneObjects,
-		const Common::Array<StartupAnimRecord> &sceneAnimations,
-		StartupRoomPlayerState &playerState, int candidateCenterX, float candidateZ) {
+static bool isPlayerMovementBlocked(HarvesterEngine &engine, const RoomSetupState &state,
+		const Common::Array<ObjectRecord> &sceneObjects,
+		const Common::Array<AnimRecord> &sceneAnimations,
+		RoomPlayerState &playerState, int candidateCenterX, float candidateZ) {
 	if (!playerState.entity)
 		return true;
 
@@ -671,10 +671,10 @@ static bool isPlayerMovementBlocked(HarvesterEngine &engine, const StartupRoomSe
 	return blocked;
 }
 
-static bool tryApplyPlayerMovement(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		const Common::Array<StartupObjectRecord> &sceneObjects,
-		const Common::Array<StartupAnimRecord> &sceneAnimations,
-		StartupRoomPlayerState &playerState, int candidateCenterX, float candidateZ) {
+static bool tryApplyPlayerMovement(HarvesterEngine &engine, const RoomSetupState &state,
+		const Common::Array<ObjectRecord> &sceneObjects,
+		const Common::Array<AnimRecord> &sceneAnimations,
+		RoomPlayerState &playerState, int candidateCenterX, float candidateZ) {
 	if (!playerState.entity)
 		return false;
 
@@ -696,12 +696,12 @@ static bool tryApplyPlayerMovement(HarvesterEngine &engine, const StartupRoomSet
 		playerState.centerX, playerState.bottomY, playerState.z);
 }
 
-static void notePlayerIdleReset(StartupRoomIdleAnimationState &idleState) {
+static void notePlayerIdleReset(RoomIdleAnimationState &idleState) {
 	idleState.resetTick = Player::getRuntimeClockTicks();
 	Player::updateIdleTrigger(idleState);
 }
 
-static bool setPlayerWalkAnimation(StartupRoomPlayerState &playerState, int facing) {
+static bool setPlayerWalkAnimation(RoomPlayerState &playerState, int facing) {
 	if (!playerState.entity)
 		return false;
 
@@ -722,8 +722,8 @@ static bool setPlayerWalkAnimation(StartupRoomPlayerState &playerState, int faci
 	return true;
 }
 
-static void positionPlayerIdleAnimationEntity(const StartupRoomSetupState &state,
-		const StartupRoomPlayerState &playerState, RuntimeEntity &idleEntity) {
+static void positionPlayerIdleAnimationEntity(const RoomSetupState &state,
+		const RoomPlayerState &playerState, Entity &idleEntity) {
 	if (!playerState.entity)
 		return;
 
@@ -733,13 +733,13 @@ static void positionPlayerIdleAnimationEntity(const StartupRoomSetupState &state
 		playerState.entity->getY() + kRoomPlayerIdleYOffset, playerState.z);
 }
 
-static RuntimeEntity *ensurePlayerIdleAnimationEntity(HarvesterEngine &engine,
-		const StartupRoomSetupState &state, const StartupRoomPlayerState &playerState,
-		StartupRoomIdleAnimationState &idleState) {
+static Entity *ensurePlayerIdleAnimationEntity(HarvesterEngine &engine,
+		const RoomSetupState &state, const RoomPlayerState &playerState,
+		RoomIdleAnimationState &idleState) {
 	if (idleState.entity || !playerState.entity)
 		return idleState.entity;
 
-	RuntimeEntityManager *runtimeEntities = engine.getRuntimeEntities();
+	EntityManager *runtimeEntities = engine.getRuntimeEntities();
 	if (!runtimeEntities)
 		return nullptr;
 
@@ -759,8 +759,8 @@ static RuntimeEntity *ensurePlayerIdleAnimationEntity(HarvesterEngine &engine,
 	return idleState.entity;
 }
 
-static bool finishPlayerIdleAnimation(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
-		StartupRoomIdleAnimationState &idleState) {
+static bool finishPlayerIdleAnimation(const RoomSetupState &state, RoomPlayerState &playerState,
+		RoomIdleAnimationState &idleState) {
 	if (!idleState.active)
 		return false;
 
@@ -789,7 +789,7 @@ static bool finishPlayerIdleAnimation(const StartupRoomSetupState &state, Startu
 
 } // End of anonymous namespace
 
-float Player::computeDepthScale(const StartupRoomSetupState &state, float z) {
+float Player::computeDepthScale(const RoomSetupState &state, float z) {
 	float scale = 1.0f;
 	if (state.roomPerspectiveScale != 0.0f) {
 		scale -= (z - (float)state.roomFullScaleZ) * state.roomPerspectiveScale;
@@ -855,7 +855,7 @@ bool Player::isProjectileCombatLoadout(int loadout) {
 	return resolveCombatLoadoutDamageType(loadout) == 4;
 }
 
-bool Player::supportsMovementBand(const StartupRoomSetupState &state) {
+bool Player::supportsMovementBand(const RoomSetupState &state) {
 	return state.roomMaxZScreenY >= 0 &&
 		state.roomMinZScreenY >= state.roomMaxZScreenY;
 }
@@ -884,11 +884,11 @@ bool Player::isIdleAnimationExcludedRoom(const Common::String &roomName) {
 	return false;
 }
 
-void Player::updateIdleTrigger(StartupRoomIdleAnimationState &idleState) {
+void Player::updateIdleTrigger(RoomIdleAnimationState &idleState) {
 	idleState.triggerTick = MAX(idleState.activityTick, idleState.resetTick) + kRoomPlayerIdleDelayTicks;
 }
 
-void Player::setMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
+void Player::setMoveTarget(const RoomSetupState &state, RoomPlayerState &playerState,
 		int targetX, float targetZ) {
 	playerState.hasMoveTarget = true;
 	playerState.nextMovementTick = 0;
@@ -900,16 +900,16 @@ void Player::setMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayer
 		playerState.targetX, mapRoomDepthToScreenY(state, playerState.targetZ), (double)playerState.targetZ);
 }
 
-void Player::setMoveTargetFromScreenPoint(const StartupRoomSetupState &state,
-		StartupRoomPlayerState &playerState, int targetX, int targetBottomY) {
+void Player::setMoveTargetFromScreenPoint(const RoomSetupState &state,
+		RoomPlayerState &playerState, int targetX, int targetBottomY) {
 	setMoveTarget(state, playerState, targetX,
 		mapRoomScreenYToDepth(state, clampRoomMovementY(state, targetBottomY)));
 }
 
-bool Player::resolveBlockedStartupSpawn(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		const Common::Array<StartupObjectRecord> &sceneObjects,
-		const Common::Array<StartupAnimRecord> &sceneAnimations,
-		StartupRoomPlayerState &playerState) {
+bool Player::resolveBlockedStartupSpawn(HarvesterEngine &engine, const RoomSetupState &state,
+		const Common::Array<ObjectRecord> &sceneObjects,
+		const Common::Array<AnimRecord> &sceneAnimations,
+		RoomPlayerState &playerState) {
 	if (!playerState.entity)
 		return false;
 
@@ -957,8 +957,8 @@ bool Player::resolveBlockedStartupSpawn(HarvesterEngine &engine, const StartupRo
 	return false;
 }
 
-int Player::resolveRegionTargetX(const StartupRegionRecord &region,
-		const StartupRoomPlayerState &playerState) {
+int Player::resolveRegionTargetX(const RegionRecord &region,
+		const RoomPlayerState &playerState) {
 	const Common::Rect bounds(region.left, region.top, region.right + 1, region.bottom + 1);
 	int targetX = bounds.left + bounds.width() / 2;
 	if (playerState.entity && targetX + kRoomRegionTargetXBias < playerState.entity->getScreenRect().right)
@@ -966,7 +966,7 @@ int Player::resolveRegionTargetX(const StartupRegionRecord &region,
 	return CLIP<int>(targetX, 0, 639);
 }
 
-float Player::resolveRegionTargetZ(const StartupRegionRecord &region) {
+float Player::resolveRegionTargetZ(const RegionRecord &region) {
 	if (region.desiredFacing == 0)
 		return (float)region.maxZ;
 	if (region.desiredFacing == 3)
@@ -975,8 +975,8 @@ float Player::resolveRegionTargetZ(const StartupRegionRecord &region) {
 	return (float)(region.minZ + (region.maxZ - region.minZ) / 2);
 }
 
-bool Player::syncCombatLoadoutVisual(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		StartupRoomPlayerState &playerState, int loadout) {
+bool Player::syncCombatLoadoutVisual(HarvesterEngine &engine, const RoomSetupState &state,
+		RoomPlayerState &playerState, int loadout) {
 	if (!playerState.entity)
 		return false;
 
@@ -1022,7 +1022,7 @@ bool Player::syncCombatLoadoutVisual(HarvesterEngine &engine, const StartupRoomS
 	return true;
 }
 
-bool Player::setIdleAnimation(StartupRoomPlayerState &playerState, int facing) {
+bool Player::setIdleAnimation(RoomPlayerState &playerState, int facing) {
 	if (!playerState.entity)
 		return false;
 
@@ -1043,8 +1043,8 @@ bool Player::setIdleAnimation(StartupRoomPlayerState &playerState, int facing) {
 	return changed;
 }
 
-bool Player::startAttackAnimation(const StartupRoomSetupState &state,
-		StartupRoomPlayerState &playerState, const Common::Point &mousePos) {
+bool Player::startAttackAnimation(const RoomSetupState &state,
+		RoomPlayerState &playerState, const Common::Point &mousePos) {
 	PlayerAttackAnimationRange range;
 	if (!resolvePlayerAttackAnimationRange(state, playerState, mousePos, range))
 		return false;
@@ -1056,8 +1056,8 @@ bool Player::startAttackAnimation(const StartupRoomSetupState &state,
 	return true;
 }
 
-bool Player::startKeyboardAttackAnimation(const StartupRoomSetupState &state,
-		StartupRoomPlayerState &playerState, bool attackLeft, bool attackRight, bool attackUp, bool attackDown) {
+bool Player::startKeyboardAttackAnimation(const RoomSetupState &state,
+		RoomPlayerState &playerState, bool attackLeft, bool attackRight, bool attackUp, bool attackDown) {
 	(void)state;
 
 	PlayerAttackAnimationRange range;
@@ -1073,7 +1073,7 @@ bool Player::startKeyboardAttackAnimation(const StartupRoomSetupState &state,
 }
 
 bool Player::updateAttackAnimationState(HarvesterEngine &engine,
-		StartupRoomPlayerState &playerState) {
+		RoomPlayerState &playerState) {
 	if (!playerState.attackActive || !playerState.entity)
 		return false;
 	if (!playerState.attackSoundPlayed &&
@@ -1101,7 +1101,7 @@ bool Player::updateAttackAnimationState(HarvesterEngine &engine,
 	return setIdleAnimation(playerState, resumeFacing);
 }
 
-bool Player::startHitAnimation(HarvesterEngine &engine, StartupRoomPlayerState &playerState,
+bool Player::startHitAnimation(HarvesterEngine &engine, RoomPlayerState &playerState,
 		int monsterAttackFirstFrame) {
 	if (!playerState.entity || playerState.deathActive)
 		return false;
@@ -1147,10 +1147,10 @@ bool Player::startHitAnimation(HarvesterEngine &engine, StartupRoomPlayerState &
 	return true;
 }
 
-bool Player::updateHitAnimationState(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		const Common::Array<StartupObjectRecord> &sceneObjects,
-		const Common::Array<StartupAnimRecord> &sceneAnimations,
-		StartupRoomPlayerState &playerState) {
+bool Player::updateHitAnimationState(HarvesterEngine &engine, const RoomSetupState &state,
+		const Common::Array<ObjectRecord> &sceneObjects,
+		const Common::Array<AnimRecord> &sceneAnimations,
+		RoomPlayerState &playerState) {
 	if (!playerState.hitActive || !playerState.entity)
 		return false;
 
@@ -1186,7 +1186,7 @@ bool Player::updateHitAnimationState(HarvesterEngine &engine, const StartupRoomS
 	return setIdleAnimation(playerState, resumeFacing) || changed;
 }
 
-bool Player::startDeathAnimation(StartupRoomPlayerState &playerState, int damageType, bool goreEnabled) {
+bool Player::startDeathAnimation(RoomPlayerState &playerState, int damageType, bool goreEnabled) {
 	if (!playerState.entity || playerState.deathActive)
 		return false;
 
@@ -1232,7 +1232,7 @@ bool Player::startDeathAnimation(StartupRoomPlayerState &playerState, int damage
 	return true;
 }
 
-bool Player::updateDeathAnimationState(StartupRoomPlayerState &playerState) {
+bool Player::updateDeathAnimationState(RoomPlayerState &playerState) {
 	if (!playerState.deathActive || !playerState.entity)
 		return false;
 	if (playerState.entity->getCurrentFrame() < playerState.deathLastFrame)
@@ -1245,7 +1245,7 @@ bool Player::updateDeathAnimationState(StartupRoomPlayerState &playerState) {
 	return true;
 }
 
-bool Player::startTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing) {
+bool Player::startTurnAnimation(RoomPlayerState &playerState, int targetFacing) {
 	if (!playerState.entity || playerState.hitActive ||
 			playerState.facing < 0 || playerState.facing == targetFacing)
 		return false;
@@ -1272,7 +1272,7 @@ bool Player::startTurnAnimation(StartupRoomPlayerState &playerState, int targetF
 	return true;
 }
 
-bool Player::updateTurnAnimationState(StartupRoomPlayerState &playerState) {
+bool Player::updateTurnAnimationState(RoomPlayerState &playerState) {
 	if (!playerState.turnActive || !playerState.entity)
 		return false;
 	if (playerState.entity->getCurrentFrame() != playerState.turnEndFrame)
@@ -1292,10 +1292,10 @@ bool Player::updateTurnAnimationState(StartupRoomPlayerState &playerState) {
 	return true;
 }
 
-bool Player::stepMoveTarget(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		const Common::Array<StartupObjectRecord> &sceneObjects,
-		const Common::Array<StartupAnimRecord> &sceneAnimations,
-		StartupRoomPlayerState &playerState) {
+bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state,
+		const Common::Array<ObjectRecord> &sceneObjects,
+		const Common::Array<AnimRecord> &sceneAnimations,
+		RoomPlayerState &playerState) {
 	if (!playerState.entity || !playerState.hasMoveTarget || playerState.turnActive || playerState.hitActive)
 		return false;
 
@@ -1350,10 +1350,10 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const StartupRoomSetupState
 	return true;
 }
 
-bool Player::stepKeyboardMovement(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		const Common::Array<StartupObjectRecord> &sceneObjects,
-		const Common::Array<StartupAnimRecord> &sceneAnimations,
-		StartupRoomPlayerState &playerState, bool moveLeft, bool moveRight, bool moveUp, bool moveDown) {
+bool Player::stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState &state,
+		const Common::Array<ObjectRecord> &sceneObjects,
+		const Common::Array<AnimRecord> &sceneAnimations,
+		RoomPlayerState &playerState, bool moveLeft, bool moveRight, bool moveUp, bool moveDown) {
 	if (!playerState.entity || !supportsMovementBand(state))
 		return false;
 
@@ -1426,8 +1426,8 @@ bool Player::stepKeyboardMovement(HarvesterEngine &engine, const StartupRoomSetu
 	return true;
 }
 
-bool Player::requestIdleAnimationExit(const StartupRoomSetupState &state,
-		StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState) {
+bool Player::requestIdleAnimationExit(const RoomSetupState &state,
+		RoomPlayerState &playerState, RoomIdleAnimationState &idleState) {
 	if (!idleState.active || idleState.exiting || !idleState.entity)
 		return false;
 
@@ -1448,12 +1448,12 @@ bool Player::requestIdleAnimationExit(const StartupRoomSetupState &state,
 	return true;
 }
 
-bool Player::startIdleAnimation(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState) {
+bool Player::startIdleAnimation(HarvesterEngine &engine, const RoomSetupState &state,
+		RoomPlayerState &playerState, RoomIdleAnimationState &idleState) {
 	if (!playerState.entity || idleState.active || isIdleAnimationExcludedRoom(state.roomName))
 		return false;
 
-	RuntimeEntity *idleEntity = ensurePlayerIdleAnimationEntity(engine, state, playerState, idleState);
+	Entity *idleEntity = ensurePlayerIdleAnimationEntity(engine, state, playerState, idleState);
 	if (!idleEntity)
 		return false;
 
@@ -1479,8 +1479,8 @@ bool Player::startIdleAnimation(HarvesterEngine &engine, const StartupRoomSetupS
 	return true;
 }
 
-bool Player::updateIdleAnimation(const StartupRoomSetupState &state,
-		StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState) {
+bool Player::updateIdleAnimation(const RoomSetupState &state,
+		RoomPlayerState &playerState, RoomIdleAnimationState &idleState) {
 	if (!idleState.active || !idleState.entity)
 		return false;
 
diff --git a/engines/harvester/player.h b/engines/harvester/player.h
index 77ea0be0421..27f7a92b247 100644
--- a/engines/harvester/player.h
+++ b/engines/harvester/player.h
@@ -28,7 +28,7 @@ namespace Harvester {
 
 class Player {
 public:
-	static float computeDepthScale(const StartupRoomSetupState &state, float z);
+	static float computeDepthScale(const RoomSetupState &state, float z);
 	static int resolveFacingFrame(int facing);
 	static const char *describeCombatLoadout(int loadout);
 	static const char *describeCombatDamageType(int damageType);
@@ -37,53 +37,53 @@ public:
 	static int resolveCombatLoadoutDamageType(int loadout);
 	static int resolveCombatLoadoutContactFrameOffset(int loadout);
 	static bool isProjectileCombatLoadout(int loadout);
-	static bool supportsMovementBand(const StartupRoomSetupState &state);
+	static bool supportsMovementBand(const RoomSetupState &state);
 	static uint32 getRuntimeClockTicks();
 	static bool isIdleAnimationExcludedRoom(const Common::String &roomName);
-	static void updateIdleTrigger(StartupRoomIdleAnimationState &idleState);
-	static void setMoveTarget(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
+	static void updateIdleTrigger(RoomIdleAnimationState &idleState);
+	static void setMoveTarget(const RoomSetupState &state, RoomPlayerState &playerState,
 		int targetX, float targetZ);
-	static void setMoveTargetFromScreenPoint(const StartupRoomSetupState &state,
-		StartupRoomPlayerState &playerState, int targetX, int targetBottomY);
-	static bool resolveBlockedStartupSpawn(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		const Common::Array<StartupObjectRecord> &sceneObjects,
-		const Common::Array<StartupAnimRecord> &sceneAnimations,
-		StartupRoomPlayerState &playerState);
-	static int resolveRegionTargetX(const StartupRegionRecord &region,
-		const StartupRoomPlayerState &playerState);
-	static float resolveRegionTargetZ(const StartupRegionRecord &region);
-	static bool syncCombatLoadoutVisual(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		StartupRoomPlayerState &playerState, int loadout);
-	static bool setIdleAnimation(StartupRoomPlayerState &playerState, int facing);
-	static bool startAttackAnimation(const StartupRoomSetupState &state,
-		StartupRoomPlayerState &playerState, const Common::Point &mousePos);
-	static bool startKeyboardAttackAnimation(const StartupRoomSetupState &state,
-		StartupRoomPlayerState &playerState, bool attackLeft, bool attackRight, bool attackUp, bool attackDown);
-	static bool updateAttackAnimationState(HarvesterEngine &engine, StartupRoomPlayerState &playerState);
-	static bool startHitAnimation(HarvesterEngine &engine, StartupRoomPlayerState &playerState,
+	static void setMoveTargetFromScreenPoint(const RoomSetupState &state,
+		RoomPlayerState &playerState, int targetX, int targetBottomY);
+	static bool resolveBlockedStartupSpawn(HarvesterEngine &engine, const RoomSetupState &state,
+		const Common::Array<ObjectRecord> &sceneObjects,
+		const Common::Array<AnimRecord> &sceneAnimations,
+		RoomPlayerState &playerState);
+	static int resolveRegionTargetX(const RegionRecord &region,
+		const RoomPlayerState &playerState);
+	static float resolveRegionTargetZ(const RegionRecord &region);
+	static bool syncCombatLoadoutVisual(HarvesterEngine &engine, const RoomSetupState &state,
+		RoomPlayerState &playerState, int loadout);
+	static bool setIdleAnimation(RoomPlayerState &playerState, int facing);
+	static bool startAttackAnimation(const RoomSetupState &state,
+		RoomPlayerState &playerState, const Common::Point &mousePos);
+	static bool startKeyboardAttackAnimation(const RoomSetupState &state,
+		RoomPlayerState &playerState, bool attackLeft, bool attackRight, bool attackUp, bool attackDown);
+	static bool updateAttackAnimationState(HarvesterEngine &engine, RoomPlayerState &playerState);
+	static bool startHitAnimation(HarvesterEngine &engine, RoomPlayerState &playerState,
 		int monsterAttackFirstFrame);
-	static bool updateHitAnimationState(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		const Common::Array<StartupObjectRecord> &sceneObjects,
-		const Common::Array<StartupAnimRecord> &sceneAnimations,
-		StartupRoomPlayerState &playerState);
-	static bool startDeathAnimation(StartupRoomPlayerState &playerState, int damageType, bool goreEnabled);
-	static bool updateDeathAnimationState(StartupRoomPlayerState &playerState);
-	static bool startTurnAnimation(StartupRoomPlayerState &playerState, int targetFacing);
-	static bool updateTurnAnimationState(StartupRoomPlayerState &playerState);
-	static bool stepMoveTarget(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		const Common::Array<StartupObjectRecord> &sceneObjects,
-		const Common::Array<StartupAnimRecord> &sceneAnimations,
-		StartupRoomPlayerState &playerState);
-	static bool stepKeyboardMovement(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		const Common::Array<StartupObjectRecord> &sceneObjects,
-		const Common::Array<StartupAnimRecord> &sceneAnimations,
-		StartupRoomPlayerState &playerState, bool moveLeft, bool moveRight, bool moveUp, bool moveDown);
-	static bool requestIdleAnimationExit(const StartupRoomSetupState &state,
-		StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState);
-	static bool startIdleAnimation(HarvesterEngine &engine, const StartupRoomSetupState &state,
-		StartupRoomPlayerState &playerState, StartupRoomIdleAnimationState &idleState);
-	static bool updateIdleAnimation(const StartupRoomSetupState &state, StartupRoomPlayerState &playerState,
-		StartupRoomIdleAnimationState &idleState);
+	static bool updateHitAnimationState(HarvesterEngine &engine, const RoomSetupState &state,
+		const Common::Array<ObjectRecord> &sceneObjects,
+		const Common::Array<AnimRecord> &sceneAnimations,
+		RoomPlayerState &playerState);
+	static bool startDeathAnimation(RoomPlayerState &playerState, int damageType, bool goreEnabled);
+	static bool updateDeathAnimationState(RoomPlayerState &playerState);
+	static bool startTurnAnimation(RoomPlayerState &playerState, int targetFacing);
+	static bool updateTurnAnimationState(RoomPlayerState &playerState);
+	static bool stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state,
+		const Common::Array<ObjectRecord> &sceneObjects,
+		const Common::Array<AnimRecord> &sceneAnimations,
+		RoomPlayerState &playerState);
+	static bool stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState &state,
+		const Common::Array<ObjectRecord> &sceneObjects,
+		const Common::Array<AnimRecord> &sceneAnimations,
+		RoomPlayerState &playerState, bool moveLeft, bool moveRight, bool moveUp, bool moveDown);
+	static bool requestIdleAnimationExit(const RoomSetupState &state,
+		RoomPlayerState &playerState, RoomIdleAnimationState &idleState);
+	static bool startIdleAnimation(HarvesterEngine &engine, const RoomSetupState &state,
+		RoomPlayerState &playerState, RoomIdleAnimationState &idleState);
+	static bool updateIdleAnimation(const RoomSetupState &state, RoomPlayerState &playerState,
+		RoomIdleAnimationState &idleState);
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 76fbecb0e23..e617970ad61 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -182,7 +182,7 @@ static bool isOutsideNativeInventoryDragCloseBounds(const Common::Point &point)
 }
 
 static void debugLogInventoryClick(const char *buttonLabel, const Common::Point &point,
-		const StartupInventoryVisual *inventoryHover) {
+		const InventoryVisual *inventoryHover) {
 	if (!inventoryHover) {
 		debugC(1, kDebugInventory,
 			"Harvester: inventory %s click at (%d,%d) hit no item", buttonLabel, point.x, point.y);
@@ -257,7 +257,7 @@ static Common::String buildUseItemPrompt(const Common::String &itemLabel, const
 	return Common::String::format("Use %s on %s", itemLabel.c_str(), targetLabel.c_str());
 }
 
-static Common::String resolveStartupNpcLabel(const StartupNpcRecord &npc) {
+static Common::String resolveStartupNpcLabel(const NpcRecord &npc) {
 	Common::String label = !npc.entityInitArg.empty() ? npc.entityInitArg : npc.npcName;
 	for (uint i = 0; i < label.size(); ++i) {
 		if (label[i] == '_')
@@ -267,7 +267,7 @@ static Common::String resolveStartupNpcLabel(const StartupNpcRecord &npc) {
 	return label;
 }
 
-static Common::String resolveCarriedObjectSpritePath(const StartupObjectRecord &object) {
+static Common::String resolveCarriedObjectSpritePath(const ObjectRecord &object) {
 	if (!object.altSpritePath.empty())
 		return object.altSpritePath;
 	return object.spritePath;
@@ -322,7 +322,7 @@ static void renderCdChangePromptScreen(HarvesterEngine &engine, const IndexedBit
 	screen->update();
 }
 
-static bool shouldDispatchPickupActionOnCarryStart(const StartupObjectRecord &object) {
+static bool shouldDispatchPickupActionOnCarryStart(const ObjectRecord &object) {
 	if (object.actionTag.empty())
 		return false;
 
@@ -331,7 +331,7 @@ static bool shouldDispatchPickupActionOnCarryStart(const StartupObjectRecord &ob
 		!object.objectName.equalsIgnoreCase("SYRINGE");
 }
 
-static bool shouldDispatchPickupActionOnDirectInventoryTransfer(const StartupObjectRecord &object) {
+static bool shouldDispatchPickupActionOnDirectInventoryTransfer(const ObjectRecord &object) {
 	if (object.actionTag.empty())
 		return false;
 
@@ -425,7 +425,7 @@ static RoomMonsterFacingAnimationRange resolveRoomMonsterFacingAnimationRange(in
 	}
 }
 
-static bool setRoomMonsterAnimation(RuntimeEntity &entity, int facing, bool walking) {
+static bool setRoomMonsterAnimation(Entity &entity, int facing, bool walking) {
 	const RoomMonsterFacingAnimationRange range = resolveRoomMonsterFacingAnimationRange(facing);
 	if (walking) {
 		const bool changed = entity.getAnimationRate() != kRoomMonsterAnimationRate ||
@@ -446,7 +446,7 @@ static bool setRoomMonsterAnimation(RuntimeEntity &entity, int facing, bool walk
 	return changed;
 }
 
-static bool runtimeEntityHasFrameRange(const RuntimeEntity &entity, int firstFrame, int lastFrame) {
+static bool runtimeEntityHasFrameRange(const Entity &entity, int firstFrame, int lastFrame) {
 	if (!entity.hasFrames() || firstFrame < 0 || lastFrame < firstFrame)
 		return false;
 
@@ -454,7 +454,7 @@ static bool runtimeEntityHasFrameRange(const RuntimeEntity &entity, int firstFra
 }
 
 static bool resolveMonsterAttackAnimationRange(HarvesterEngine &engine,
-		const RuntimeEntity &entity, int actorCenterX, int targetCenterX,
+		const Entity &entity, int actorCenterX, int targetCenterX,
 		RoomAttackAnimationRange &range) {
 	// Native class-6 attackers pick one of the 0x16..0x1b attack states, which map to
 	// the 0x50..0x8b strike banks. The 0x8c..0x9d banks are hit reactions, not attacks.
@@ -488,7 +488,7 @@ static bool resolveMonsterAttackAnimationRange(HarvesterEngine &engine,
 	return true;
 }
 
-static bool resolveMonsterDeathAnimationRange(const RuntimeEntity &entity, int facing,
+static bool resolveMonsterDeathAnimationRange(const Entity &entity, int facing,
 		int deathDamageType, bool goreEnabled, RoomDeathAnimationRange &range) {
 	const bool preferLeftBank = facing == 1;
 	const RoomDeathAnimationRange rightBludge(0xb0, 0xb9);
@@ -527,7 +527,7 @@ static bool resolveMonsterDeathAnimationRange(const RuntimeEntity &entity, int f
 		chooseIfAvailable(fallbackSlash) || chooseIfAvailable(fallbackProjectile);
 }
 
-static bool resolveNpcDeathAnimationRange(const RuntimeEntity &entity, bool hasMonsterfyTarget,
+static bool resolveNpcDeathAnimationRange(const Entity &entity, bool hasMonsterfyTarget,
 		int deathDamageType, bool goreEnabled, RoomDeathAnimationRange &range) {
 	const RoomDeathAnimationRange goreBank(0x3c, 0x45);
 	const RoomDeathAnimationRange bludgeBank(0x46, 0x4f);
@@ -564,13 +564,13 @@ static int computeRectAxisGap(int minA, int maxA, int minB, int maxB) {
 	return 0;
 }
 
-static int computeRuntimeEntityHorizontalGap(const RuntimeEntity &left, const RuntimeEntity &right) {
+static int computeRuntimeEntityHorizontalGap(const Entity &left, const Entity &right) {
 	const Common::Rect leftRect = left.getScreenRect();
 	const Common::Rect rightRect = right.getScreenRect();
 	return computeRectAxisGap(leftRect.left, leftRect.right, rightRect.left, rightRect.right);
 }
 
-static bool doRuntimeEntityDepthExtentsOverlap(const RuntimeEntity &first, const RuntimeEntity &second) {
+static bool doRuntimeEntityDepthExtentsOverlap(const Entity &first, const Entity &second) {
 	const float firstZMin = first.getZ();
 	const float firstZMax = firstZMin + first.getZExtent();
 	const float secondZMin = second.getZ();
@@ -578,9 +578,9 @@ static bool doRuntimeEntityDepthExtentsOverlap(const RuntimeEntity &first, const
 	return !(firstZMax < secondZMin || secondZMax < firstZMin);
 }
 
-static bool areCombatantsWithinRoomCombatReach(const StartupRoomSetupState &state,
-		const RuntimeEntity &attacker, float attackerZ,
-		const RuntimeEntity &target, float targetZ, int engageDistance) {
+static bool areCombatantsWithinRoomCombatReach(const RoomSetupState &state,
+		const Entity &attacker, float attackerZ,
+		const Entity &target, float targetZ, int engageDistance) {
 	if (attacker.overlapsEntity(target))
 		return true;
 
@@ -598,8 +598,8 @@ static bool areCombatantsWithinRoomCombatReach(const StartupRoomSetupState &stat
 // wider waypoint band down to a direct center-to-center engage-distance check.
 // The DOS runtime does not add a separate randomized monster attack cooldown;
 // cadence comes from the short attack banks themselves.
-static bool isWithinNativeMonsterAttackEntryRange(const RuntimeEntity &monster, float monsterZ,
-		const RuntimeEntity &player, float playerZ, int engageDistance) {
+static bool isWithinNativeMonsterAttackEntryRange(const Entity &monster, float monsterZ,
+		const Entity &player, float playerZ, int engageDistance) {
 	const float zDelta = monsterZ >= playerZ ? monsterZ - playerZ : playerZ - monsterZ;
 	if (zDelta > kNativeMonsterPursuitZTolerance)
 		return false;
@@ -630,7 +630,7 @@ static bool playRandomRoomAttackSound(HarvesterEngine &engine, const Common::Str
 	return engine.playStartupSound(*availableSounds[soundIndex]);
 }
 
-static bool resolveRoomMonsterHitAnimationRange(const RuntimeEntity &entity,
+static bool resolveRoomMonsterHitAnimationRange(const Entity &entity,
 		int attackerAttackFirstFrame, RoomHitAnimationRange &range) {
 	RoomHitAnimationRange candidate;
 	switch (attackerAttackFirstFrame) {
@@ -663,13 +663,13 @@ static bool resolveRoomMonsterHitAnimationRange(const RuntimeEntity &entity,
 	return true;
 }
 
-static float clampRoomDepthForEvent(const StartupRoomSetupState &state, float z) {
+static float clampRoomDepthForEvent(const RoomSetupState &state, float z) {
 	return CLIP<float>(z,
 		(float)MIN(state.roomMinZ, state.roomMaxZ),
 		(float)MAX(state.roomMinZ, state.roomMaxZ));
 }
 
-static int mapRoomDepthToScreenYForEvent(const StartupRoomSetupState &state, float z, int fallbackY) {
+static int mapRoomDepthToScreenYForEvent(const RoomSetupState &state, float z, int fallbackY) {
 	if (state.roomMaxZScreenY < 0 || state.roomMinZScreenY < state.roomMaxZScreenY)
 		return fallbackY;
 	if (state.roomMaxZ == state.roomMinZ)
@@ -687,7 +687,7 @@ static int roundRoomCombatFloat(float value) {
 	return value >= 0.0f ? (int)(value + 0.5f) : (int)(value - 0.5f);
 }
 
-static int mapRoomDepthToScreenYForCombat(const StartupRoomSetupState &state, float z, int fallbackY) {
+static int mapRoomDepthToScreenYForCombat(const RoomSetupState &state, float z, int fallbackY) {
 	if (state.roomMaxZScreenY < 0 || state.roomMinZScreenY < state.roomMaxZScreenY)
 		return fallbackY;
 	if (state.roomMaxZ == state.roomMinZ)
@@ -701,7 +701,7 @@ static int mapRoomDepthToScreenYForCombat(const StartupRoomSetupState &state, fl
 		state.roomMaxZScreenY, state.roomMinZScreenY);
 }
 
-static int resolveMonsterAttackContactFrameOffset(const StartupMonsterRecord &monster) {
+static int resolveMonsterAttackContactFrameOffset(const MonsterRecord &monster) {
 	return monster.monsterName.equalsIgnoreCase("MUCKEY")
 		? kMuckeyAttackContactFrameOffset
 		: kDefaultMonsterAttackContactFrameOffset;
@@ -749,7 +749,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 	Common::String currentRoomTarget = targetName;
 	bool currentTargetIsRoomName = targetIsRoomName;
 	while (!currentRoomTarget.empty()) {
-		StartupRoomSetupState state;
+		RoomSetupState state;
 		bool shouldRunRoomEntryCommands = false;
 		if (_engine.hasPendingLoadedStartupSaveRoomState()) {
 			startupFlow.resetRoomNpcDialogueState();
@@ -764,7 +764,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					!_engine.activateStartupDisc(_engine.getPendingLoadedStartupDisc())) {
 				return Common::kReadingFailed;
 			}
-			const StartupSaveRoomState &loadedState = _engine.getPendingLoadedStartupSaveRoomState();
+			const SaveRoomState &loadedState = _engine.getPendingLoadedStartupSaveRoomState();
 			debugC(1, kDebugRoom,
 				"Harvester: applying pending loaded room state entrance='%s' room='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
 				loadedState.entranceName.c_str(), loadedState.roomName.c_str(),
@@ -798,7 +798,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		if (transitionError.getCode() != Common::kNoError)
 			return transitionError;
 
-		StartupRoomSceneResources scene;
+		RoomSceneResources scene;
 		if (!loadRoomSceneResources(state, *_engine.getResources(), scene))
 			return Common::kReadingFailed;
 
@@ -834,13 +834,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		}
 		if (!screen || !art || !bodyFont || !promptFont || !inventoryTooltipFont || !inspectFont)
 			return Common::kNoError;
-		RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+		EntityManager *runtimeEntities = _engine.getRuntimeEntities();
 
 		if (!startupFlow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
 			return Common::kReadingFailed;
 		if (runtimeEntities) {
-			for (const StartupMonsterRecord &monster : scene.state.roomMonsters) {
-				RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(monster.monsterName);
+			for (const MonsterRecord &monster : scene.state.roomMonsters) {
+				Entity *entity = runtimeEntities->findSceneEntityByName(monster.monsterName);
 				if (entity)
 					Monster::applyAnimation(*entity, monster);
 			}
@@ -851,7 +851,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		startupFlow.executeStartupAudioCommands(scene.state.audioCommands);
 		if (!scene.state.musicPath.empty())
 			(void)_engine.playStartupMusic(scene.state.musicPath);
-		StartupRoomPlayerState playerState;
+		RoomPlayerState playerState;
 		playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName("PLAYER") : nullptr;
 		playerState.centerX = scene.state.playerSpawnX;
 		playerState.bottomY = scene.state.playerSpawnY;
@@ -868,7 +868,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		Common::Array<RoomHitEffectState> hitEffectStates;
 		Common::Array<RoomCombatDamagePopupState> damagePopupStates;
 		uint nextCombatEffectId = 0;
-		StartupResolvedText inspectText;
+		ResolvedText inspectText;
 		bool showingInspectText = false;
 		bool inspectCanDismiss = false;
 		bool moveLeft = false;
@@ -880,7 +880,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		Common::String pendingRoomChange;
 		uint32 lastLeftButtonReleaseTick = 0;
 		uint32 nextKeyboardAttackAllowedTick = 0;
-		StartupRoomIdleAnimationState idleState;
+		RoomIdleAnimationState idleState;
 		bool needsRedraw = true;
 		Common::String carriedRoomItemName;
 		Common::String carriedRoomItemLabel;
@@ -894,7 +894,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			carriedRoomItemBitmap = IndexedBitmap();
 		};
 		auto hideSceneObject = [&](const Common::String &objectName, const Common::String *ownerOrRoom) {
-			StartupObjectRecord *sceneObject = findSceneObjectByName(scene.sceneObjects, objectName);
+			ObjectRecord *sceneObject = findSceneObjectByName(scene.sceneObjects, objectName);
 			if (!sceneObject)
 				return;
 
@@ -907,11 +907,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			if (!runtimeEntities)
 				return;
 
-			RuntimeEntity *entity = runtimeEntities->detachSceneEntityByName(entityName);
+			Entity *entity = runtimeEntities->detachSceneEntityByName(entityName);
 			delete entity;
 		};
 		auto resetIdleState = [&]() {
-			idleState = StartupRoomIdleAnimationState();
+			idleState = RoomIdleAnimationState();
 			idleState.activityTick = Player::getRuntimeClockTicks();
 			idleState.resetTick = idleState.activityTick;
 			Player::updateIdleTrigger(idleState);
@@ -951,7 +951,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 			// Region enable state is a persistent script mutation; keep the runtime record in lockstep
 			// with the currently materialized scene so one-shot regions do not resurrect on revisit.
-			for (const StartupRegionRecord &baseRegion : startupScript->getRegions()) {
+			for (const RegionRecord &baseRegion : startupScript->getRegions()) {
 				if (!baseRegion.roomName.equalsIgnoreCase(scene.state.roomName))
 					continue;
 
@@ -961,21 +961,21 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			}
 
 			if (runtimeEntities) {
-				for (StartupAnimRecord &anim : scene.state.roomAnimations) {
-					RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(anim.animName);
+				for (AnimRecord &anim : scene.state.roomAnimations) {
+					Entity *entity = runtimeEntities->findSceneEntityByName(anim.animName);
 					if (!entity)
 						continue;
 					(void)startupScript->syncRuntimeAnimState(anim.animName,
 						entity->isAnimationEnabled(), entity->isVisible(), entity->getCurrentFrame());
 				}
 			}
-			for (const StartupMonsterRecord &monster : scene.state.roomMonsters)
+			for (const MonsterRecord &monster : scene.state.roomMonsters)
 				(void)startupScript->syncRuntimeMonsterRecord(monster);
 			if (!runtimeEntities)
 				return;
 
-			for (StartupTimerRecord &timer : scene.state.roomTimers) {
-				RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(timer.timerName);
+			for (TimerRecord &timer : scene.state.roomTimers) {
+				Entity *entity = runtimeEntities->findSceneEntityByName(timer.timerName);
 				if (!entity)
 					continue;
 				timer.currentValue = entity->getTimerCurrentValue();
@@ -990,12 +990,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				playerState.centerX, playerState.bottomY, (int)playerState.z, facing,
 				_engine.getStartupMusicPath());
 		};
-		auto getSceneObjectBounds = [&](const StartupObjectRecord &object) {
+		auto getSceneObjectBounds = [&](const ObjectRecord &object) {
 			if (object.boundsX2 > object.currentX && object.boundsY2 > object.currentY)
 				return Common::Rect(object.currentX, object.currentY, object.boundsX2 + 1, object.boundsY2 + 1);
 			return Common::Rect();
 		};
-		auto resolveSceneObjectSpritePathLocal = [&](const StartupObjectRecord &object) {
+		auto resolveSceneObjectSpritePathLocal = [&](const ObjectRecord &object) {
 			const bool atInitialPlacement = object.currentX == object.initialX &&
 				object.currentY == object.initialY &&
 				object.currentOwnerOrRoom.equalsIgnoreCase(object.initialOwnerOrRoom);
@@ -1004,7 +1004,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				return object.altSpritePath;
 			return object.spritePath;
 		};
-		auto isInteractiveSceneHotspot = [&](const StartupObjectRecord &object) {
+		auto isInteractiveSceneHotspot = [&](const ObjectRecord &object) {
 			if (object.operatable || !object.actionTag.empty())
 				return true;
 
@@ -1014,11 +1014,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			if (startupScript->isPickupObject(object))
 				return true;
 
-			StartupResolvedText inspectText;
+			ResolvedText inspectText;
 			return startupScript->resolveObjectInspectText(object, inspectText);
 		};
-		auto resolveSceneObjectClassLocal = [&](const StartupObjectRecord &object,
-				const RuntimeEntity *entity) {
+		auto resolveSceneObjectClassLocal = [&](const ObjectRecord &object,
+				const Entity *entity) {
 			if (entity) {
 				return ((!scene.state.backgroundObjectName.empty() &&
 						object.objectName.equalsIgnoreCase(scene.state.backgroundObjectName)) ||
@@ -1036,13 +1036,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			return lhs.left == rhs.left && lhs.top == rhs.top &&
 				lhs.right == rhs.right && lhs.bottom == rhs.bottom;
 		};
-		auto spawnSceneObjectEntityFromRecord = [&](const StartupObjectRecord &object) {
+		auto spawnSceneObjectEntityFromRecord = [&](const ObjectRecord &object) {
 			if (!runtimeEntities)
 				return;
 
 			const Common::String spritePath = resolveSceneObjectSpritePathLocal(object);
 			const Common::Rect hotspotBounds = getSceneObjectBounds(object);
-			RuntimeEntity *entity = nullptr;
+			Entity *entity = nullptr;
 			if (!spritePath.empty() && spritePath.hasSuffixIgnoreCase(".BM")) {
 				entity = runtimeEntities->spawnSceneBitmapEntity(object.objectName, spritePath,
 					Common::Point(object.currentX, object.currentY), (float)object.currentZ);
@@ -1058,12 +1058,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
 			entity->setZExtent((float)object.zExtent);
 		};
-		auto getSceneRegionBounds = [](const StartupRegionRecord &region) {
+		auto getSceneRegionBounds = [](const RegionRecord &region) {
 			if (region.right > region.left && region.bottom > region.top)
 				return Common::Rect(region.left, region.top, region.right + 1, region.bottom + 1);
 			return Common::Rect();
 		};
-		auto spawnSceneRegionEntityFromRecord = [&](const StartupRegionRecord &region) {
+		auto spawnSceneRegionEntityFromRecord = [&](const RegionRecord &region) {
 			if (!runtimeEntities)
 				return;
 
@@ -1071,7 +1071,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			if (bounds.isEmpty())
 				return;
 
-			RuntimeEntity *entity = runtimeEntities->spawnSceneHotspotEntity(
+			Entity *entity = runtimeEntities->spawnSceneHotspotEntity(
 				region.regionName, bounds, (float)region.minZ);
 			if (!entity)
 				return;
@@ -1087,37 +1087,37 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				bounds.left, bounds.top, bounds.right, bounds.bottom,
 				region.minZ, region.maxZ, region.desiredFacing, region.cursorEnabled, region.actionTag.c_str());
 		};
-		auto findRoomAnimByNameConst = [](const Common::Array<StartupAnimRecord> &anims,
-				const Common::String &animName) -> const StartupAnimRecord * {
-			for (const StartupAnimRecord &anim : anims) {
+		auto findRoomAnimByNameConst = [](const Common::Array<AnimRecord> &anims,
+				const Common::String &animName) -> const AnimRecord * {
+			for (const AnimRecord &anim : anims) {
 				if (anim.animName.equalsIgnoreCase(animName))
 					return &anim;
 			}
 			return nullptr;
 		};
-		auto findRoomNpcByNameConst = [](const Common::Array<StartupNpcRecord> &npcs,
-				const Common::String &npcName) -> const StartupNpcRecord * {
-			for (const StartupNpcRecord &npc : npcs) {
+		auto findRoomNpcByNameConst = [](const Common::Array<NpcRecord> &npcs,
+				const Common::String &npcName) -> const NpcRecord * {
+			for (const NpcRecord &npc : npcs) {
 				if (npc.npcName.equalsIgnoreCase(npcName))
 					return &npc;
 			}
 			return nullptr;
 		};
-		auto findRoomMonsterByNameConst = [](const Common::Array<StartupMonsterRecord> &monsters,
-				const Common::String &monsterName) -> const StartupMonsterRecord * {
-			for (const StartupMonsterRecord &monster : monsters) {
+		auto findRoomMonsterByNameConst = [](const Common::Array<MonsterRecord> &monsters,
+				const Common::String &monsterName) -> const MonsterRecord * {
+			for (const MonsterRecord &monster : monsters) {
 				if (monster.monsterName.equalsIgnoreCase(monsterName))
 					return &monster;
 			}
 			return nullptr;
 		};
-		auto shouldSpawnRoomNpcEntity = [](const StartupNpcRecord &npc) {
+		auto shouldSpawnRoomNpcEntity = [](const NpcRecord &npc) {
 			return npc.visible && !npc.deathOrMonsterfyFlag;
 		};
-		auto shouldSpawnRoomMonsterEntity = [](const StartupMonsterRecord &monster) {
+		auto shouldSpawnRoomMonsterEntity = [](const MonsterRecord &monster) {
 			return monster.visible;
 		};
-		auto sameNpcEntityState = [&](const StartupNpcRecord &lhs, const StartupNpcRecord &rhs) {
+		auto sameNpcEntityState = [&](const NpcRecord &lhs, const NpcRecord &rhs) {
 			return lhs.posX == rhs.posX &&
 				lhs.posY == rhs.posY &&
 				lhs.posZ == rhs.posZ &&
@@ -1126,7 +1126,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				lhs.deathOrMonsterfyFlag == rhs.deathOrMonsterfyFlag &&
 				lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
 		};
-		auto sameMonsterEntityState = [&](const StartupMonsterRecord &lhs, const StartupMonsterRecord &rhs) {
+		auto sameMonsterEntityState = [&](const MonsterRecord &lhs, const MonsterRecord &rhs) {
 			return lhs.posX == rhs.posX &&
 				lhs.posY == rhs.posY &&
 				lhs.posZ == rhs.posZ &&
@@ -1137,7 +1137,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				lhs.runtimeState == rhs.runtimeState &&
 				lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
 		};
-		auto sameSceneObjectState = [&](const StartupObjectRecord &lhs, const StartupObjectRecord &rhs) {
+		auto sameSceneObjectState = [&](const ObjectRecord &lhs, const ObjectRecord &rhs) {
 			return lhs.currentX == rhs.currentX &&
 				lhs.currentY == rhs.currentY &&
 				lhs.currentZ == rhs.currentZ &&
@@ -1152,7 +1152,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				resolveSceneObjectSpritePathLocal(lhs).equalsIgnoreCase(resolveSceneObjectSpritePathLocal(rhs)) &&
 				sameRect(getSceneObjectBounds(lhs), getSceneObjectBounds(rhs));
 		};
-		auto sameSceneRegionState = [&](const StartupRegionRecord &lhs, const StartupRegionRecord &rhs) {
+		auto sameSceneRegionState = [&](const RegionRecord &lhs, const RegionRecord &rhs) {
 			return lhs.left == rhs.left &&
 				lhs.top == rhs.top &&
 				lhs.right == rhs.right &&
@@ -1164,7 +1164,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				lhs.desiredFacing == rhs.desiredFacing &&
 				lhs.actionTag.equalsIgnoreCase(rhs.actionTag);
 		};
-		auto applyRoomNpcPlacement = [&](RuntimeEntity &entity, const StartupNpcRecord &npc) {
+		auto applyRoomNpcPlacement = [&](Entity &entity, const NpcRecord &npc) {
 			int width = 0;
 			int height = 0;
 			int xOffset = 0;
@@ -1178,11 +1178,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			entity.setPosition(npc.posX - xOffset - width / 2, npc.posY - height - yOffset, renderZ);
 			return true;
 		};
-		auto spawnSceneNpcEntityFromRecord = [&](const StartupNpcRecord &npc) -> RuntimeEntity * {
+		auto spawnSceneNpcEntityFromRecord = [&](const NpcRecord &npc) -> Entity * {
 			if (!runtimeEntities || !shouldSpawnRoomNpcEntity(npc))
 				return nullptr;
 
-			RuntimeEntity *entity = runtimeEntities->spawnSceneActorEntity(
+			Entity *entity = runtimeEntities->spawnSceneActorEntity(
 				npc.npcName, npc.modelPath, Common::Point(npc.posX, npc.posY), (float)npc.posZ, 0);
 			if (!entity)
 				return nullptr;
@@ -1199,12 +1199,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			runtimeEntities->reinsertSceneEntity(entity);
 			return entity;
 		};
-		auto spawnSceneMonsterEntityFromRecord = [&](const StartupRoomSetupState &roomState,
-				const StartupMonsterRecord &monster) -> RuntimeEntity * {
+		auto spawnSceneMonsterEntityFromRecord = [&](const RoomSetupState &roomState,
+				const MonsterRecord &monster) -> Entity * {
 			if (!runtimeEntities || !shouldSpawnRoomMonsterEntity(monster))
 				return nullptr;
 
-			RuntimeEntity *entity = runtimeEntities->spawnSceneActorEntity(
+			Entity *entity = runtimeEntities->spawnSceneActorEntity(
 				monster.monsterName, monster.modelPath,
 				Common::Point(monster.posX, monster.posY), (float)monster.posZ,
 				Monster::resolveFacingFrame(monster.facing));
@@ -1222,20 +1222,20 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			runtimeEntities->reinsertSceneEntity(entity);
 			return entity;
 		};
-		auto resolveHitEffectAnchor = [&](const RuntimeEntity &targetEntity,
+		auto resolveHitEffectAnchor = [&](const Entity &targetEntity,
 				Common::Point &anchorPoint, float &renderZ) {
 			const Common::Rect targetRect = targetEntity.getScreenRect();
 			anchorPoint.x = targetRect.left + targetRect.width() / 2;
 			anchorPoint.y = targetRect.top + targetRect.height() / 3;
 			renderZ = targetEntity.getZ() - kNativeHitEffectRenderZBias;
 		};
-		auto resolveCombatDamagePopupAnchor = [&](const RuntimeEntity &targetEntity,
+		auto resolveCombatDamagePopupAnchor = [&](const Entity &targetEntity,
 				Common::Point &anchorPoint) {
 			const Common::Rect targetRect = targetEntity.getScreenRect();
 			anchorPoint.x = targetRect.left + targetRect.width() / 2;
 			anchorPoint.y = targetRect.top - kCombatDebugDamagePopupVerticalGap;
 		};
-		auto spawnCombatHitEffect = [&](RuntimeEntity &sourceEntity,
+		auto spawnCombatHitEffect = [&](Entity &sourceEntity,
 				const Common::String &followTargetName) {
 			if (!runtimeEntities)
 				return false;
@@ -1245,7 +1245,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			effectState.followTargetName = followTargetName;
 			resolveHitEffectAnchor(sourceEntity, effectState.anchorPoint, effectState.renderZ);
 
-			RuntimeEntity *effectEntity = runtimeEntities->spawnSceneAnimationEntity(
+			Entity *effectEntity = runtimeEntities->spawnSceneAnimationEntity(
 				effectState.entityName, kNativeHitEffectResourcePath, effectState.anchorPoint,
 				effectState.renderZ, kNativeHitEffectAnimationRate, true, true, false,
 				false, false, 0);
@@ -1255,7 +1255,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			hitEffectStates.push_back(effectState);
 			return true;
 		};
-		auto spawnCombatDamagePopup = [&](RuntimeEntity &targetEntity,
+		auto spawnCombatDamagePopup = [&](Entity &targetEntity,
 				const Common::String &followTargetName, int damageAmount) {
 			if (damageAmount <= 0)
 				return;
@@ -1273,12 +1273,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 			bool changed = false;
 			for (RoomHitEffectState &effectState : hitEffectStates) {
-				RuntimeEntity *effectEntity = runtimeEntities->findSceneEntityByName(effectState.entityName);
+				Entity *effectEntity = runtimeEntities->findSceneEntityByName(effectState.entityName);
 				if (!effectEntity)
 					continue;
 
 				if (!effectState.followTargetName.empty()) {
-					RuntimeEntity *targetEntity =
+					Entity *targetEntity =
 						runtimeEntities->findSceneEntityByName(effectState.followTargetName);
 					if (targetEntity && targetEntity->isVisible()) {
 						resolveHitEffectAnchor(*targetEntity, effectState.anchorPoint, effectState.renderZ);
@@ -1312,7 +1312,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			const uint32 now = Player::getRuntimeClockTicks();
 			for (RoomCombatDamagePopupState &popupState : damagePopupStates) {
 				if (runtimeEntities && !popupState.followTargetName.empty()) {
-					RuntimeEntity *targetEntity =
+					Entity *targetEntity =
 						runtimeEntities->findSceneEntityByName(popupState.followTargetName);
 					if (targetEntity && targetEntity->isVisible())
 						resolveCombatDamagePopupAnchor(*targetEntity, popupState.anchorPoint);
@@ -1337,7 +1337,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			bool removedAny = false;
 			for (uint i = 0; i < hitEffectStates.size();) {
 				const Common::String effectName = hitEffectStates[i].entityName;
-				RuntimeEntity *effectEntity = runtimeEntities
+				Entity *effectEntity = runtimeEntities
 					? runtimeEntities->findSceneEntityByName(effectName)
 					: nullptr;
 				if (!effectEntity) {
@@ -1382,24 +1382,24 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			if (!startupScript || !resources)
 				return false;
 
-			const Common::Array<StartupAudioCommand> entryAudioCommands = scene.state.audioCommands;
-			StartupRoomSetupState updatedState;
+			const Common::Array<AudioCommand> entryAudioCommands = scene.state.audioCommands;
+			RoomSetupState updatedState;
 			if (!startupScript->materializeRoomState(
 					scene.state.entranceName, scene.state.roomName, updatedState, *resources)) {
 				return false;
 			}
 			updatedState.audioCommands = entryAudioCommands;
 
-			StartupRoomSceneResources updatedScene;
+			RoomSceneResources updatedScene;
 			if (!loadRoomSceneResources(updatedState, *resources, updatedScene))
 				return false;
 			Common::Array<RoomMonsterCombatState> updatedMonsterCombatStates;
 			updatedMonsterCombatStates.resize(updatedState.roomMonsters.size());
 			for (uint i = 0; i < updatedState.roomMonsters.size(); ++i) {
-				const StartupMonsterRecord &updatedMonster = updatedState.roomMonsters[i];
+				const MonsterRecord &updatedMonster = updatedState.roomMonsters[i];
 				for (uint j = 0; j < scene.state.roomMonsters.size() &&
 						j < monsterCombatStates.size(); ++j) {
-					const StartupMonsterRecord &previousMonster = scene.state.roomMonsters[j];
+					const MonsterRecord &previousMonster = scene.state.roomMonsters[j];
 					if (!previousMonster.monsterName.equalsIgnoreCase(updatedMonster.monsterName))
 						continue;
 					if (sameMonsterEntityState(previousMonster, updatedMonster))
@@ -1408,12 +1408,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				}
 			}
 
-			for (const StartupObjectRecord &object : scene.sceneObjects) {
+			for (const ObjectRecord &object : scene.sceneObjects) {
 				if (!findSceneObjectByName(updatedScene.sceneObjects, object.objectName))
 					removeSceneEntityByName(object.objectName);
 			}
-			for (const StartupObjectRecord &object : updatedScene.sceneObjects) {
-				const StartupObjectRecord *previous = findSceneObjectByName(scene.sceneObjects, object.objectName);
+			for (const ObjectRecord &object : updatedScene.sceneObjects) {
+				const ObjectRecord *previous = findSceneObjectByName(scene.sceneObjects, object.objectName);
 				const bool changed = !previous || !sameSceneObjectState(*previous, object);
 				if (!changed)
 					continue;
@@ -1422,12 +1422,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				spawnSceneObjectEntityFromRecord(object);
 			}
 
-			for (const StartupRegionRecord &region : scene.sceneRegions) {
+			for (const RegionRecord &region : scene.sceneRegions) {
 				if (!findSceneRegionByName(updatedScene.sceneRegions, region.regionName))
 					removeSceneEntityByName(region.regionName);
 			}
-			for (const StartupRegionRecord &region : updatedScene.sceneRegions) {
-				const StartupRegionRecord *previous =
+			for (const RegionRecord &region : updatedScene.sceneRegions) {
+				const RegionRecord *previous =
 					findSceneRegionByName(scene.sceneRegions, region.regionName);
 				const bool changed = !previous || !sameSceneRegionState(*previous, region);
 				if (!changed)
@@ -1437,9 +1437,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				spawnSceneRegionEntityFromRecord(region);
 			}
 
-			for (const StartupTimerRecord &timer : scene.state.roomTimers) {
+			for (const TimerRecord &timer : scene.state.roomTimers) {
 				bool found = false;
-				for (const StartupTimerRecord &updatedTimer : updatedState.roomTimers) {
+				for (const TimerRecord &updatedTimer : updatedState.roomTimers) {
 					if (updatedTimer.timerName.equalsIgnoreCase(timer.timerName)) {
 						found = true;
 						break;
@@ -1448,9 +1448,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				if (!found)
 					removeSceneEntityByName(timer.timerName);
 			}
-			for (const StartupTimerRecord &timer : updatedState.roomTimers) {
-				const StartupTimerRecord *previous = nullptr;
-				for (const StartupTimerRecord &candidate : scene.state.roomTimers) {
+			for (const TimerRecord &timer : updatedState.roomTimers) {
+				const TimerRecord *previous = nullptr;
+				for (const TimerRecord &candidate : scene.state.roomTimers) {
 					if (candidate.timerName.equalsIgnoreCase(timer.timerName)) {
 						previous = &candidate;
 						break;
@@ -1473,14 +1473,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				}
 			}
 
-			for (StartupAnimRecord &anim : updatedState.roomAnimations) {
-				const StartupAnimRecord *previous =
+			for (AnimRecord &anim : updatedState.roomAnimations) {
+				const AnimRecord *previous =
 					findRoomAnimByNameConst(scene.state.roomAnimations, anim.animName);
 				const bool becameVisible = (!previous || !previous->visible) && anim.visible;
 				if (becameVisible)
 					anim.runtimeState = 0;
 
-				RuntimeEntity *entity = runtimeEntities
+				Entity *entity = runtimeEntities
 					? runtimeEntities->findSceneEntityByName(anim.animName)
 					: nullptr;
 				if (!anim.visible) {
@@ -1521,13 +1521,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				}
 			}
 
-			for (const StartupAnimRecord &anim : scene.state.roomAnimations) {
+			for (const AnimRecord &anim : scene.state.roomAnimations) {
 				if (!findRoomAnimByNameConst(updatedState.roomAnimations, anim.animName))
 					removeSceneEntityByName(anim.animName);
 			}
 
-			for (const StartupNpcRecord &npc : scene.state.roomNpcs) {
-				const StartupNpcRecord *updatedNpc =
+			for (const NpcRecord &npc : scene.state.roomNpcs) {
+				const NpcRecord *updatedNpc =
 					findRoomNpcByNameConst(updatedState.roomNpcs, npc.npcName);
 				const bool keepEntity =
 					updatedNpc &&
@@ -1536,14 +1536,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				if (!keepEntity)
 					removeSceneEntityByName(npc.npcName);
 			}
-			for (const StartupNpcRecord &npc : updatedState.roomNpcs) {
+			for (const NpcRecord &npc : updatedState.roomNpcs) {
 				if (!shouldSpawnRoomNpcEntity(npc))
 					continue;
 
-				RuntimeEntity *entity = runtimeEntities
+				Entity *entity = runtimeEntities
 					? runtimeEntities->findSceneEntityByName(npc.npcName)
 					: nullptr;
-				const StartupNpcRecord *previousNpc =
+				const NpcRecord *previousNpc =
 					findRoomNpcByNameConst(scene.state.roomNpcs, npc.npcName);
 				const bool needsRespawn =
 					!entity || !previousNpc || !sameNpcEntityState(*previousNpc, npc);
@@ -1555,8 +1555,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					return false;
 			}
 
-			for (const StartupMonsterRecord &monster : scene.state.roomMonsters) {
-				const StartupMonsterRecord *updatedMonster =
+			for (const MonsterRecord &monster : scene.state.roomMonsters) {
+				const MonsterRecord *updatedMonster =
 					findRoomMonsterByNameConst(updatedState.roomMonsters, monster.monsterName);
 				const bool keepEntity =
 					updatedMonster &&
@@ -1565,14 +1565,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				if (!keepEntity)
 					removeSceneEntityByName(monster.monsterName);
 			}
-			for (const StartupMonsterRecord &monster : updatedState.roomMonsters) {
+			for (const MonsterRecord &monster : updatedState.roomMonsters) {
 				if (!shouldSpawnRoomMonsterEntity(monster))
 					continue;
 
-				RuntimeEntity *entity = runtimeEntities
+				Entity *entity = runtimeEntities
 					? runtimeEntities->findSceneEntityByName(monster.monsterName)
 					: nullptr;
-				const StartupMonsterRecord *previousMonster =
+				const MonsterRecord *previousMonster =
 					findRoomMonsterByNameConst(scene.state.roomMonsters, monster.monsterName);
 				const bool needsRespawn =
 					!entity || !previousMonster || !sameMonsterEntityState(*previousMonster, monster);
@@ -1609,8 +1609,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				(void)Player::setIdleAnimation(playerState, playerState.facing);
 		};
 		auto refreshCurrentScene = [&](bool preservePlayerPlacement) {
-			const Common::Array<StartupAudioCommand> entryAudioCommands = scene.state.audioCommands;
-			StartupRoomSetupState refreshedState;
+			const Common::Array<AudioCommand> entryAudioCommands = scene.state.audioCommands;
+			RoomSetupState refreshedState;
 			if (!_engine.getStartupScript()->materializeRoomState(
 					scene.state.entranceName, scene.state.roomName, refreshedState, *_engine.getResources())) {
 				return false;
@@ -1624,8 +1624,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			if (!startupFlow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
 				return false;
 			if (runtimeEntities) {
-				for (const StartupMonsterRecord &monster : scene.state.roomMonsters) {
-					RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(monster.monsterName);
+				for (const MonsterRecord &monster : scene.state.roomMonsters) {
+					Entity *entity = runtimeEntities->findSceneEntityByName(monster.monsterName);
 					if (entity)
 						Monster::applyAnimation(*entity, monster);
 				}
@@ -1683,7 +1683,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			clearCarriedRoomItem();
 			return _inventory.refresh();
 		};
-		auto shouldCancelDeniedPickup = [&](const StartupInteractionResult &interaction,
+		auto shouldCancelDeniedPickup = [&](const InteractionResult &interaction,
 				bool didTransition) {
 			return !didTransition &&
 				!interaction.mutatedRuntimeState &&
@@ -1698,7 +1698,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				!interaction.modalText.value.empty();
 		};
 		auto restoreDeniedPickup = [&](Script &startupScript,
-				const StartupObjectRecord &savedRuntimeObject, bool clearCarryState) -> Common::Error {
+				const ObjectRecord &savedRuntimeObject, bool clearCarryState) -> Common::Error {
 			if (!startupScript.syncRuntimeObjectRecord(savedRuntimeObject))
 				return Common::kReadingFailed;
 			if (clearCarryState)
@@ -1801,7 +1801,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 						}
 					}
 
-					RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+					EntityManager *runtimeEntities = _engine.getRuntimeEntities();
 					if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
 						needsRedraw = true;
 
@@ -1826,7 +1826,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			return Common::kNoError;
 		};
 		auto runRoomExitCommands = [&]() -> Common::Error {
-			StartupInteractionResult exitInteraction;
+			InteractionResult exitInteraction;
 			if (!_engine.getStartupScript()->executeRoomExitCommands(scene.state.roomName, exitInteraction))
 				return Common::kReadingFailed;
 
@@ -1859,7 +1859,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				if (exitInteraction.continuationTag.empty())
 					return Common::kNoError;
 
-				StartupInteractionResult continuationInteraction;
+				InteractionResult continuationInteraction;
 				if (!_engine.getStartupScript()->executeActionTag(
 						exitInteraction.continuationTag, continuationInteraction, false)) {
 					return Common::kNoError;
@@ -1926,7 +1926,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			resetIdleState();
 			captureCurrentSaveState();
 		};
-		auto runModalShowText = [&](const StartupResolvedText &modalText) -> Common::Error {
+		auto runModalShowText = [&](const ResolvedText &modalText) -> Common::Error {
 			Graphics::Screen *activeScreen = getActiveScreen();
 			if (!activeScreen || !art || !inspectFont)
 				return Common::kNoError;
@@ -1995,8 +1995,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		struct InteractionProcessor {
 			HarvesterEngine &engine;
 			Flow &startupFlow;
-			StartupRoomSceneResources &scene;
-			StartupRoomPlayerState &playerState;
+			RoomSceneResources &scene;
+			RoomPlayerState &playerState;
 			Common::String &pendingRegionName;
 			Common::String &pendingRoomChange;
 			decltype(refreshCurrentScene) &refreshCurrentSceneFn;
@@ -2009,7 +2009,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			decltype(runModalShowText) &runModalShowTextFn;
 			decltype(resetIdleState) &resetIdleStateFn;
 
-			Common::Error handleInteractionResult(const StartupInteractionResult &interaction,
+			Common::Error handleInteractionResult(const InteractionResult &interaction,
 					bool &didTransition, const Common::String &usedItemName) {
 				didTransition = false;
 
@@ -2174,7 +2174,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					return Common::kNoError;
 
 				if (!interaction.continuationTag.empty()) {
-					StartupInteractionResult continuationInteraction;
+					InteractionResult continuationInteraction;
 					if (engine.getStartupScript()->executeActionTag(
 							interaction.continuationTag, continuationInteraction)) {
 						Common::Error interactionError =
@@ -2191,7 +2191,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					const Common::String &continuationTag, bool &didTransition) {
 				didTransition = false;
 
-				const StartupNpcRecord *dialogueNpc = engine.getStartupScript()->findRuntimeNpcRecord(npcName);
+				const NpcRecord *dialogueNpc = engine.getStartupScript()->findRuntimeNpcRecord(npcName);
 				if (dialogueNpc) {
 					IndexedBitmap dialogueBackdrop;
 					if (!captureDialogueBackdropFn(dialogueBackdrop))
@@ -2207,7 +2207,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 						npcName.c_str());
 				}
 
-				StartupInteractionResult dialogueInteraction;
+				InteractionResult dialogueInteraction;
 				bool abortRemainingCommandChain = false;
 				if (startupFlow.takeQueuedDialogueInteraction(dialogueInteraction)) {
 					abortRemainingCommandChain = dialogueInteraction.abortRemainingCommandChain;
@@ -2219,7 +2219,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 						return Common::kNoError;
 				}
 				if (!didTransition && !abortRemainingCommandChain && !continuationTag.empty()) {
-					StartupInteractionResult continuationInteraction;
+					InteractionResult continuationInteraction;
 					if (engine.getStartupScript()->executeActionTag(continuationTag, continuationInteraction)) {
 						Common::Error interactionError =
 							handleInteractionResult(continuationInteraction, didTransition, usedItemName);
@@ -2242,7 +2242,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			runRoomExitCommands, applyLightingCommand, applyPlayerGotoXZ, runModalShowText,
 			resetIdleState
 		};
-		auto hasRoomEntryInteraction = [](const StartupInteractionResult &interaction) {
+		auto hasRoomEntryInteraction = [](const InteractionResult &interaction) {
 			return !interaction.nextRoomName.empty() ||
 				!interaction.musicPath.empty() ||
 				!interaction.audioCommands.empty() ||
@@ -2258,7 +2258,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				interaction.requestPlayerGotoXZ ||
 				interaction.mutatedRuntimeState;
 		};
-		StartupInteractionResult roomEntryInteraction;
+		InteractionResult roomEntryInteraction;
 		bool hasPendingRoomEntryInteraction = false;
 		if (shouldRunRoomEntryCommands) {
 			if (!_engine.getStartupScript()->executeRoomEnterCommands(
@@ -2307,8 +2307,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			startupFlow.resetCursorAnimationSequence();
 			resetIdleState();
 		}
-		auto findRoomNpcRecordByName = [&](const Common::String &npcName) -> StartupNpcRecord * {
-			for (StartupNpcRecord &npc : scene.state.roomNpcs) {
+		auto findRoomNpcRecordByName = [&](const Common::String &npcName) -> NpcRecord * {
+			for (NpcRecord &npc : scene.state.roomNpcs) {
 				if (npc.npcName.equalsIgnoreCase(npcName))
 					return &npc;
 			}
@@ -2323,8 +2323,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 			return -1;
 		};
-		auto findRoomMonsterRecordByName = [&](const Common::String &monsterName) -> StartupMonsterRecord * {
-			for (StartupMonsterRecord &monster : scene.state.roomMonsters) {
+		auto findRoomMonsterRecordByName = [&](const Common::String &monsterName) -> MonsterRecord * {
+			for (MonsterRecord &monster : scene.state.roomMonsters) {
 				if (monster.monsterName.equalsIgnoreCase(monsterName))
 					return &monster;
 			}
@@ -2339,18 +2339,18 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 			return -1;
 		};
-		auto findSceneRuntimeEntity = [&](const Common::String &entityName) -> RuntimeEntity * {
+		auto findSceneRuntimeEntity = [&](const Common::String &entityName) -> Entity * {
 			return runtimeEntities ? runtimeEntities->findSceneEntityByName(entityName) : nullptr;
 		};
-		auto findMonsterTargetAtPoint = [&](const Common::Point &point) -> StartupMonsterRecord * {
-			StartupMonsterRecord *bestMonster = nullptr;
+		auto findMonsterTargetAtPoint = [&](const Common::Point &point) -> MonsterRecord * {
+			MonsterRecord *bestMonster = nullptr;
 			int bestZ = -0x7fffffff;
 			int bestTop = -0x7fffffff;
-			for (StartupMonsterRecord &monster : scene.state.roomMonsters) {
+			for (MonsterRecord &monster : scene.state.roomMonsters) {
 				if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
 					continue;
 
-				RuntimeEntity *entity = findSceneRuntimeEntity(monster.monsterName);
+				Entity *entity = findSceneRuntimeEntity(monster.monsterName);
 				if (!entity || !entity->isVisible())
 					continue;
 
@@ -2368,15 +2368,15 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 			return bestMonster;
 		};
-		auto findOverlappingMonsterTarget = [&]() -> StartupMonsterRecord * {
+		auto findOverlappingMonsterTarget = [&]() -> MonsterRecord * {
 			if (!playerState.entity)
 				return nullptr;
 
-			for (StartupMonsterRecord &monster : scene.state.roomMonsters) {
+			for (MonsterRecord &monster : scene.state.roomMonsters) {
 				if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
 					continue;
 
-				RuntimeEntity *entity = findSceneRuntimeEntity(monster.monsterName);
+				Entity *entity = findSceneRuntimeEntity(monster.monsterName);
 				if (entity && areCombatantsWithinRoomCombatReach(scene.state,
 						*playerState.entity, playerState.z, *entity, (float)monster.posZ, monster.engageDistance))
 					return &monster;
@@ -2384,15 +2384,15 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 			return nullptr;
 		};
-		auto findOverlappingNpcTarget = [&]() -> StartupNpcRecord * {
+		auto findOverlappingNpcTarget = [&]() -> NpcRecord * {
 			if (!playerState.entity)
 				return nullptr;
 
-			for (StartupNpcRecord &npc : scene.state.roomNpcs) {
+			for (NpcRecord &npc : scene.state.roomNpcs) {
 				if (!npc.visible || npc.deathOrMonsterfyFlag || npc.deathDamageType != 0)
 					continue;
 
-				RuntimeEntity *entity = findSceneRuntimeEntity(npc.npcName);
+				Entity *entity = findSceneRuntimeEntity(npc.npcName);
 				if (entity && areCombatantsWithinRoomCombatReach(scene.state,
 						*playerState.entity, playerState.z, *entity, entity->getZ(), 0))
 					return &npc;
@@ -2400,7 +2400,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 			return nullptr;
 		};
-		auto playerAttackCanReachTarget = [&](RuntimeEntity *targetEntity, int engageDistance = 0) {
+		auto playerAttackCanReachTarget = [&](Entity *targetEntity, int engageDistance = 0) {
 			if (!playerState.entity || !targetEntity)
 				return false;
 			if (Player::isProjectileCombatLoadout(playerState.combatLoadout))
@@ -2467,7 +2467,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				? _engine.getStartupScript()->getPlayerCurrentHitPoints()
 				: 0);
 	};
-	auto handleCombatInteraction = [&](StartupInteractionResult interaction) -> Common::Error {
+	auto handleCombatInteraction = [&](InteractionResult interaction) -> Common::Error {
 		bool didTransition = false;
 		Common::Error interactionError =
 			interactionProcessor.handleInteractionResult(interaction, didTransition, Common::String());
@@ -2483,7 +2483,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		needsRedraw = true;
 		return Common::kNoError;
 	};
-	auto beginNpcDeathTransition = [&](StartupNpcRecord &npc, RuntimeEntity &npcEntity,
+	auto beginNpcDeathTransition = [&](NpcRecord &npc, Entity &npcEntity,
 			RoomNpcCombatState &combatState, int deathDamageType) {
 		RoomDeathAnimationRange deathRange;
 		if (!resolveNpcDeathAnimationRange(npcEntity, !npc.monsterfyTargetName.empty(),
@@ -2508,7 +2508,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		needsRedraw = true;
 		return true;
 	};
-	auto finalizeNpcDeathTransition = [&](StartupNpcRecord &npc,
+	auto finalizeNpcDeathTransition = [&](NpcRecord &npc,
 			RoomNpcCombatState &combatState) -> Common::Error {
 		Script *runtimeScript = _engine.getStartupScript();
 		removeSceneEntityByName(npc.npcName);
@@ -2526,10 +2526,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			npc.monsterfyTargetName.c_str(), npc.onDeathActionTag.c_str());
 		clearRoomNpcCombatState(combatState);
 
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		interaction.mutatedRuntimeState = runtimeChanged;
 		if (runtimeScript && !npc.onDeathActionTag.empty()) {
-			StartupInteractionResult deathInteraction;
+			InteractionResult deathInteraction;
 			if (runtimeScript->executeActionTag(npc.onDeathActionTag, deathInteraction)) {
 				interaction = deathInteraction;
 				interaction.mutatedRuntimeState = true;
@@ -2542,28 +2542,28 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		if (!_engine.isCombatDebugEnabled())
 			return Common::kNoError;
 
-		auto isKillableMonster = [&](const StartupMonsterRecord *monster) {
+		auto isKillableMonster = [&](const MonsterRecord *monster) {
 			return monster && monster->active && monster->visible && monster->currentHitPoints > 0;
 		};
-		auto selectMonsterEntity = [&](StartupMonsterRecord *monster) -> RuntimeEntity * {
-			RuntimeEntity *entity = monster ? findSceneRuntimeEntity(monster->monsterName) : nullptr;
+		auto selectMonsterEntity = [&](MonsterRecord *monster) -> Entity * {
+			Entity *entity = monster ? findSceneRuntimeEntity(monster->monsterName) : nullptr;
 			return (entity && entity->isVisible()) ? entity : nullptr;
 		};
-		auto resolveKillTarget = [&]() -> StartupMonsterRecord * {
+		auto resolveKillTarget = [&]() -> MonsterRecord * {
 			if (playerState.attackTargetClassId == kRuntimeEntityClassMonster) {
-				StartupMonsterRecord *monster = findRoomMonsterRecordByName(playerState.attackTargetName);
+				MonsterRecord *monster = findRoomMonsterRecordByName(playerState.attackTargetName);
 				if (isKillableMonster(monster) && selectMonsterEntity(monster))
 					return monster;
 			}
 
-			if (StartupMonsterRecord *monster = findMonsterTargetAtPoint(_mousePos)) {
+			if (MonsterRecord *monster = findMonsterTargetAtPoint(_mousePos)) {
 				if (isKillableMonster(monster) && selectMonsterEntity(monster))
 					return monster;
 			}
 
 			if (playerState.entity) {
 				for (uint i = 0; i < scene.state.roomMonsters.size() && i < monsterCombatStates.size(); ++i) {
-					StartupMonsterRecord &monster = scene.state.roomMonsters[i];
+					MonsterRecord &monster = scene.state.roomMonsters[i];
 					const RoomMonsterCombatState &combatState = monsterCombatStates[i];
 					if (!combatState.attackActive || !isKillableMonster(&monster) || !selectMonsterEntity(&monster))
 						continue;
@@ -2579,13 +2579,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 			const Common::Rect playerRect = playerState.entity->getScreenRect();
 			const int playerCenterX = playerRect.left + playerRect.width() / 2;
-			StartupMonsterRecord *bestMonster = nullptr;
+			MonsterRecord *bestMonster = nullptr;
 			int bestScore = 0x7fffffff;
-			for (StartupMonsterRecord &monster : scene.state.roomMonsters) {
+			for (MonsterRecord &monster : scene.state.roomMonsters) {
 				if (!isKillableMonster(&monster))
 					continue;
 
-				RuntimeEntity *entity = selectMonsterEntity(&monster);
+				Entity *entity = selectMonsterEntity(&monster);
 				if (!entity)
 					continue;
 
@@ -2602,8 +2602,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			return bestMonster;
 		};
 
-		StartupMonsterRecord *monster = resolveKillTarget();
-		RuntimeEntity *monsterEntity = selectMonsterEntity(monster);
+		MonsterRecord *monster = resolveKillTarget();
+		Entity *monsterEntity = selectMonsterEntity(monster);
 		if (!monster || !monsterEntity) {
 			debugC(1, kDebugCombat,
 				"Harvester: debug combat kill skipped reason='no active monster' cursor=(%d,%d)",
@@ -2664,10 +2664,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 		monster->active = false;
 		monster->visible = false;
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		interaction.mutatedRuntimeState = true;
 		if (!monster->onDeathActionTag.empty()) {
-			StartupInteractionResult deathInteraction;
+			InteractionResult deathInteraction;
 			if (startupScript->executeActionTag(monster->onDeathActionTag, deathInteraction)) {
 				interaction = deathInteraction;
 				interaction.mutatedRuntimeState = true;
@@ -2680,7 +2680,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			playerState.attackTargetName.clear();
 			playerState.attackTargetClassId = -1;
 
-			const StartupRoomHoverState hoverState = resolveRoomHoverState(
+			const RoomHoverState hoverState = resolveRoomHoverState(
 				_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions,
 				_mousePos, &startupFlow._dialogue);
 			if (hoverState.npc) {
@@ -2692,7 +2692,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				return;
 			}
 
-		if (StartupMonsterRecord *monster = findMonsterTargetAtPoint(_mousePos)) {
+		if (MonsterRecord *monster = findMonsterTargetAtPoint(_mousePos)) {
 			playerState.attackTargetName = monster->monsterName;
 			playerState.attackTargetClassId = kRuntimeEntityClassMonster;
 			debugC(1, kDebugCombat,
@@ -2758,12 +2758,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		const int playerLeft = playerRect.left;
 		const int playerRight = playerRect.right;
 		const int playerCenterX = playerState.centerX;
-		RuntimeEntity *bestEntity = nullptr;
+		Entity *bestEntity = nullptr;
 		Common::String bestName;
 		int bestClassId = -1;
 		int bestLeadingEdge = attackSide == kKeyboardAttackSideLeft ? -0x7fffffff : 0x7fffffff;
 
-		auto considerTarget = [&](RuntimeEntity *targetEntity, const Common::String &targetName, int classId) {
+		auto considerTarget = [&](Entity *targetEntity, const Common::String &targetName, int classId) {
 			if (!targetEntity || !targetEntity->isVisible() ||
 					!doRuntimeEntityDepthExtentsOverlap(*playerState.entity, *targetEntity)) {
 				return;
@@ -2797,12 +2797,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			bestLeadingEdge = targetLeft;
 		};
 
-		for (StartupNpcRecord &npc : scene.state.roomNpcs) {
+		for (NpcRecord &npc : scene.state.roomNpcs) {
 			if (!npc.visible || npc.deathOrMonsterfyFlag || npc.deathDamageType != 0)
 				continue;
 			considerTarget(findSceneRuntimeEntity(npc.npcName), npc.npcName, kRuntimeEntityClassNpc);
 		}
-		for (StartupMonsterRecord &monster : scene.state.roomMonsters) {
+		for (MonsterRecord &monster : scene.state.roomMonsters) {
 			if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
 				continue;
 			considerTarget(findSceneRuntimeEntity(monster.monsterName), monster.monsterName, kRuntimeEntityClassMonster);
@@ -2824,7 +2824,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			bestEntity->getScreenRect().left, bestEntity->getScreenRect().top,
 			bestEntity->getScreenRect().right, bestEntity->getScreenRect().bottom);
 		};
-		auto startMonsterHitReaction = [&](StartupMonsterRecord &monster, RuntimeEntity &monsterEntity,
+		auto startMonsterHitReaction = [&](MonsterRecord &monster, Entity &monsterEntity,
 				RoomMonsterCombatState *combatState, int attackerAttackFirstFrame) {
 			if (spawnCombatHitEffect(monsterEntity, monster.monsterName))
 				needsRedraw = true;
@@ -2893,14 +2893,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			return Common::kNoError;
 
 		if (playerState.attackTargetClassId < 0 && !Player::isProjectileCombatLoadout(playerState.combatLoadout)) {
-			if (StartupNpcRecord *fallbackNpc = findOverlappingNpcTarget()) {
+			if (NpcRecord *fallbackNpc = findOverlappingNpcTarget()) {
 				playerState.attackTargetName = fallbackNpc->npcName;
 				playerState.attackTargetClassId = kRuntimeEntityClassNpc;
 			}
 		}
 		if (playerState.attackTargetClassId == kRuntimeEntityClassNpc) {
-			StartupNpcRecord *npc = findRoomNpcRecordByName(playerState.attackTargetName);
-			RuntimeEntity *npcEntity = npc ? findSceneRuntimeEntity(npc->npcName) : nullptr;
+			NpcRecord *npc = findRoomNpcRecordByName(playerState.attackTargetName);
+			Entity *npcEntity = npc ? findSceneRuntimeEntity(npc->npcName) : nullptr;
 			if (!npc || !npcEntity || !playerAttackCanReachTarget(npcEntity))
 				npc = Player::isProjectileCombatLoadout(playerState.combatLoadout) ? nullptr : findOverlappingNpcTarget();
 			npcEntity = npc ? findSceneRuntimeEntity(npc->npcName) : nullptr;
@@ -2951,10 +2951,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			return Common::kNoError;
 		}
 
-		StartupMonsterRecord *monster = nullptr;
+		MonsterRecord *monster = nullptr;
 		if (playerState.attackTargetClassId == kRuntimeEntityClassMonster)
 			monster = findRoomMonsterRecordByName(playerState.attackTargetName);
-		RuntimeEntity *monsterEntity = monster ? findSceneRuntimeEntity(monster->monsterName) : nullptr;
+		Entity *monsterEntity = monster ? findSceneRuntimeEntity(monster->monsterName) : nullptr;
 		if (!monster || !monsterEntity || !playerAttackCanReachTarget(monsterEntity, monster ? monster->engageDistance : 0)) {
 			if (!Player::isProjectileCombatLoadout(playerState.combatLoadout))
 				monster = findOverlappingMonsterTarget();
@@ -3024,10 +3024,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		debugC(1, kDebugCombat,
 			"Harvester: combat monster defeated target='%s' on_death='%s' fallback='no death bank'",
 			monster->monsterName.c_str(), monster->onDeathActionTag.c_str());
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		interaction.mutatedRuntimeState = true;
 		if (!monster->onDeathActionTag.empty()) {
-			StartupInteractionResult deathInteraction;
+			InteractionResult deathInteraction;
 			if (startupScript->executeActionTag(monster->onDeathActionTag, deathInteraction)) {
 				interaction = deathInteraction;
 				interaction.mutatedRuntimeState = true;
@@ -3054,9 +3054,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			monsterCombatStates.resize(scene.state.roomMonsters.size());
 
 		for (uint i = 0; i < scene.state.roomNpcs.size(); ++i) {
-			StartupNpcRecord &npc = scene.state.roomNpcs[i];
+			NpcRecord &npc = scene.state.roomNpcs[i];
 			RoomNpcCombatState &combatState = npcCombatStates[i];
-			RuntimeEntity *entity = findSceneRuntimeEntity(npc.npcName);
+			Entity *entity = findSceneRuntimeEntity(npc.npcName);
 			if (!entity || !npc.visible || npc.deathOrMonsterfyFlag) {
 				clearRoomNpcCombatState(combatState);
 				continue;
@@ -3074,9 +3074,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		}
 
 		for (uint i = 0; i < scene.state.roomMonsters.size(); ++i) {
-			StartupMonsterRecord &monster = scene.state.roomMonsters[i];
+			MonsterRecord &monster = scene.state.roomMonsters[i];
 			RoomMonsterCombatState &combatState = monsterCombatStates[i];
-			RuntimeEntity *entity = findSceneRuntimeEntity(monster.monsterName);
+			Entity *entity = findSceneRuntimeEntity(monster.monsterName);
 			if (!entity || !monster.visible) {
 				clearRoomMonsterCombatState(combatState);
 				continue;
@@ -3100,10 +3100,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 						monster.onDeathActionTag.c_str());
 					clearRoomMonsterCombatState(combatState);
 
-					StartupInteractionResult interaction;
+					InteractionResult interaction;
 					interaction.mutatedRuntimeState = true;
 					if (startupScript && !monster.onDeathActionTag.empty()) {
-						StartupInteractionResult deathInteraction;
+						InteractionResult deathInteraction;
 						if (startupScript->executeActionTag(monster.onDeathActionTag, deathInteraction)) {
 							interaction = deathInteraction;
 							interaction.mutatedRuntimeState = true;
@@ -3356,12 +3356,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 		return Common::kNoError;
 	};
-	auto moveRoomItemDirectlyToInventory = [&](const StartupObjectRecord &object) -> Common::Error {
+	auto moveRoomItemDirectlyToInventory = [&](const ObjectRecord &object) -> Common::Error {
 		Script *startupScript = _engine.getStartupScript();
 		if (!startupScript)
 			return Common::kReadingFailed;
 
-		const StartupObjectRecord savedRuntimeObject = object;
+		const ObjectRecord savedRuntimeObject = object;
 
 		startupScript->addRuntimeObjectToInventory(object.objectName);
 		const Common::String inventoryOwner(kInventoryOwnerName);
@@ -3370,7 +3370,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 		bool didTransition = false;
 		if (shouldDispatchPickupActionOnDirectInventoryTransfer(object)) {
-			StartupInteractionResult pickupInteraction;
+			InteractionResult pickupInteraction;
 			if (startupScript->executeActionTag(object.actionTag, pickupInteraction)) {
 				Common::Error interactionError =
 					interactionProcessor.handleInteractionResult(
@@ -3394,13 +3394,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		needsRedraw = true;
 		return Common::kNoError;
 	};
-	auto beginRoomItemCarry = [&](const StartupObjectRecord &object) -> Common::Error {
+	auto beginRoomItemCarry = [&](const ObjectRecord &object) -> Common::Error {
 		Script *startupScript = _engine.getStartupScript();
 		ResourceManager *resources = _engine.getResources();
 		if (!startupScript || !resources)
 			return Common::kReadingFailed;
 
-		const StartupObjectRecord savedRuntimeObject = object;
+		const ObjectRecord savedRuntimeObject = object;
 
 		clearCarriedRoomItem();
 		carriedRoomItemName = object.objectName;
@@ -3415,7 +3415,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 		bool didTransition = false;
 		if (shouldDispatchPickupActionOnCarryStart(object)) {
-			StartupInteractionResult pickupInteraction;
+			InteractionResult pickupInteraction;
 			if (startupScript->executeActionTag(object.actionTag, pickupInteraction)) {
 				Common::Error interactionError =
 					interactionProcessor.handleInteractionResult(
@@ -3453,12 +3453,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		needsRedraw = true;
 		return Common::kNoError;
 	};
-	auto handleInventoryTargetInteraction = [&](const StartupObjectRecord &target) -> Common::Error {
+	auto handleInventoryTargetInteraction = [&](const ObjectRecord &target) -> Common::Error {
 		if (!_inventory.hasSelection())
 			return Common::kNoError;
 
 		const Common::String selectedItemName = _inventory.getSelectedItemName();
-		StartupInteractionResult interaction;
+		InteractionResult interaction;
 		const bool handled = _engine.getStartupScript()->resolveUseItemInteraction(
 			selectedItemName, target, interaction);
 		if (!handled) {
@@ -3483,25 +3483,25 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		needsRedraw = true;
 		return Common::kNoError;
 	};
-	auto findSelectedInventoryRoomTarget = [&](const Common::Point &point) -> StartupObjectRecord * {
+	auto findSelectedInventoryRoomTarget = [&](const Common::Point &point) -> ObjectRecord * {
 		if (!_inventory.hasSelection())
 			return nullptr;
 
-		RuntimeEntityManager *runtimeEntities = _engine.getRuntimeEntities();
+		EntityManager *runtimeEntities = _engine.getRuntimeEntities();
 		Script *startupScript = _engine.getStartupScript();
 		if (!runtimeEntities || !startupScript)
 			return nullptr;
 
 		const Common::String selectedItemName = _inventory.getSelectedItemName();
-		StartupObjectRecord *bestTarget = nullptr;
+		ObjectRecord *bestTarget = nullptr;
 		int bestDrawIndex = -1;
-		for (StartupObjectRecord &candidate : scene.sceneObjects) {
+		for (ObjectRecord &candidate : scene.sceneObjects) {
 			if (candidate.objectName.empty() ||
 					!startupScript->hasUseItemInteraction(selectedItemName, candidate)) {
 				continue;
 			}
 
-			const RuntimeEntity *entity = runtimeEntities->findSceneEntityByName(candidate.objectName);
+			const Entity *entity = runtimeEntities->findSceneEntityByName(candidate.objectName);
 			if (!entity || !entity->hitTest(point))
 				continue;
 			if (entity->getClassId() == kRuntimeEntityClassBackground ||
@@ -3521,7 +3521,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 		return bestTarget;
 	};
-	auto queueRegionInteraction = [&](const StartupRegionRecord &region) {
+	auto queueRegionInteraction = [&](const RegionRecord &region) {
 		pendingRegionName = region.regionName;
 		if (!playerState.entity)
 			return;
@@ -3533,8 +3533,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			Player::resolveRegionTargetX(region, playerState),
 			Player::resolveRegionTargetZ(region));
 	};
-	auto runRegionInteraction = [&](const StartupRegionRecord &region) -> Common::Error {
-		StartupInteractionResult interaction;
+	auto runRegionInteraction = [&](const RegionRecord &region) -> Common::Error {
+		InteractionResult interaction;
 		if (!_engine.getStartupScript()->resolveRegionInteraction(region, interaction))
 			return Common::kNoError;
 
@@ -3556,7 +3556,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		if (pendingRegionName.empty() || !playerState.entity)
 			return Common::kNoError;
 
-		const StartupRegionRecord *region = findSceneRegionByName(scene.sceneRegions, pendingRegionName);
+		const RegionRecord *region = findSceneRegionByName(scene.sceneRegions, pendingRegionName);
 		if (!region || !region->startEnabled) {
 			pendingRegionName.clear();
 			return Common::kNoError;
@@ -3576,7 +3576,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			if (!playerState.entity)
 				return Common::kNoError;
 
-		for (const StartupRegionRecord &region : scene.sceneRegions) {
+		for (const RegionRecord &region : scene.sceneRegions) {
 			if (!region.startEnabled)
 				continue;
 			if (!doesPlayerOverlapRegion(*playerState.entity, region))
@@ -3626,7 +3626,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				return Common::kReadingFailed;
 			break;
 		}
-		StartupInteractionResult queuedDebugInteraction;
+		InteractionResult queuedDebugInteraction;
 		if (startupFlow.takeQueuedDebugInteraction(queuedDebugInteraction)) {
 			// Run console-injected commands through the normal room interaction handler so
 			// CHANGE_ROOM, modal text, dialogue, and chained tags behave exactly like script-driven actions.
@@ -3661,12 +3661,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			const bool suppressHover = showingInspectText || idleState.active || idleState.exiting ||
 				isPlayerCombatLocked() ||
 				(_inventory.isOpen() && (inventoryPanelContainsMouse || !inventorySelectionActive));
-			StartupRoomHoverState hoverState = suppressHover
-				? StartupRoomHoverState()
+			RoomHoverState hoverState = suppressHover
+				? RoomHoverState()
 				: resolveRoomHoverState(_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
 					scene.sceneRegions, _mousePos, &startupFlow._dialogue);
 			if (!suppressHover && inventorySelectionActive && !hoverState.npc) {
-				if (StartupObjectRecord *selectedTarget = findSelectedInventoryRoomTarget(_mousePos))
+				if (ObjectRecord *selectedTarget = findSelectedInventoryRoomTarget(_mousePos))
 					hoverState.object = selectedTarget;
 			}
 			Common::String promptText;
@@ -3682,7 +3682,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			};
 			if (activeCarry) {
 				Common::String targetLabel;
-				const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
+				const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
 				if (_inventory.isOpen()) {
 					if (inventoryHover && !InventorySystem::isExitObject(inventoryHover->object) &&
 							!InventorySystem::isStatusObject(inventoryHover->object) &&
@@ -3702,7 +3702,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				}
 				hoverState.cursorSequence = 7;
 			} else if (_inventory.isOpen()) {
-				const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
+				const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
 				if (inventoryHover &&
 						!InventorySystem::isExitObject(inventoryHover->object)) {
 					inventoryTooltipText =
@@ -3713,7 +3713,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			} else {
 				promptText = hoverState.promptText;
 			}
-			if (RuntimeEntity *cursor = runtimeEntities ? runtimeEntities->getCursorEntity() : nullptr) {
+			if (Entity *cursor = runtimeEntities ? runtimeEntities->getCursorEntity() : nullptr) {
 				cursor->setAnimationSequence(
 					(showingInspectText || idleState.active || idleState.exiting ||
 						isPlayerCombatLocked() ||
@@ -3760,7 +3760,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			switch (event.type) {
 			case Common::EVENT_MOUSEMOVE:
 				if (_inventory.isOpen() && _inventory.hasSelection()) {
-					const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
+					const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
 					// Native inventory handoff is driven by the latched selected item, not by
 					// a second click after the selection has already been made.
 					if (!inventoryHover && isOutsideNativeInventoryDragCloseBounds(_mousePos)) {
@@ -3781,7 +3781,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					break;
 				pendingRegionName.clear();
 				if (_inventory.isOpen()) {
-					const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
+					const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
 					debugLogInventoryClick("right", _mousePos, inventoryHover);
 					if (_inventory.hasSelection()) {
 						debugC(1, kDebugInventory,
@@ -3825,7 +3825,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 							if (secondaryAction.closeInventory)
 								(void)_inventory.close();
 
-							StartupInteractionResult interaction;
+							InteractionResult interaction;
 							bool didTransition = false;
 							const bool executedActionTag = _engine.getStartupScript()->executeActionTag(
 									secondaryAction.actionTag, interaction);
@@ -3930,7 +3930,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					if (inspectCanDismiss) {
 						showingInspectText = false;
 						inspectCanDismiss = false;
-						inspectText = StartupResolvedText();
+						inspectText = ResolvedText();
 						needsRedraw = true;
 					}
 					break;
@@ -3940,7 +3940,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 				const Common::Rect inventoryPanelBounds = _inventory.getPanelBounds();
 				if (_inventory.isOpen()) {
-					const StartupInventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
+					const InventoryVisual *inventoryHover = _inventory.findItemAtPoint(_mousePos);
 					debugLogInventoryClick("left", _mousePos, inventoryHover);
 					if (inventoryHover) {
 						if (InventorySystem::isExitObject(inventoryHover->object)) {
@@ -3988,10 +3988,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					break;
 				}
 
-				const StartupRoomHoverState hoverState = resolveRoomHoverState(
+				const RoomHoverState hoverState = resolveRoomHoverState(
 					_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions,
 					_mousePos, &startupFlow._dialogue);
-				StartupObjectRecord *selectedRoomTarget = nullptr;
+				ObjectRecord *selectedRoomTarget = nullptr;
 				if (_inventory.hasSelection() && !hoverState.npc)
 					selectedRoomTarget = findSelectedInventoryRoomTarget(_mousePos);
 				if (selectedRoomTarget &&
@@ -4003,7 +4003,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 						hoverState.object ? hoverState.object->objectName.c_str() : "",
 						selectedRoomTarget->objectName.c_str());
 				}
-				StartupRoomHoverState clickHoverState = hoverState;
+				RoomHoverState clickHoverState = hoverState;
 				if (selectedRoomTarget)
 					clickHoverState.object = selectedRoomTarget;
 				debugC(1, kDebugRoom,
@@ -4037,7 +4037,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 						break;
 					}
 
-					StartupObjectRecord *roomTarget = clickHoverState.object
+					ObjectRecord *roomTarget = clickHoverState.object
 						? findSceneObjectByName(scene.sceneObjects, clickHoverState.object->objectName)
 						: nullptr;
 					if (roomTarget) {
@@ -4100,7 +4100,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					needsRedraw = true;
 					break;
 				}
-				StartupObjectRecord *clickedObject = hoverState.object
+				ObjectRecord *clickedObject = hoverState.object
 					? findSceneObjectByName(scene.sceneObjects, hoverState.object->objectName)
 					: nullptr;
 				if (!clickedObject) {
@@ -4120,7 +4120,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					return Common::kNoError;
 				}
 
-				StartupResolvedText resolvedInspectText;
+				ResolvedText resolvedInspectText;
 				const bool hasInspectText =
 					_engine.getStartupScript()->resolveObjectInspectText(*clickedObject, resolvedInspectText);
 				const bool unlocksAfterInitialExamine =
@@ -4145,7 +4145,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					break;
 				}
 				if (_engine.getStartupScript()->isPickupObject(*clickedObject)) {
-					StartupInteractionResult blockedPickupInteraction;
+					InteractionResult blockedPickupInteraction;
 					if (_engine.getStartupScript()->isPickupBlockedByAction(
 							*clickedObject, &blockedPickupInteraction)) {
 						bool didTransition = false;
@@ -4166,7 +4166,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					break;
 				}
 
-				StartupInteractionResult interaction;
+				InteractionResult interaction;
 				if (!_engine.getStartupScript()->resolveObjectInteraction(*clickedObject, interaction)) {
 					if (canShowInspectText) {
 						inspectText = resolvedInspectText;
@@ -4196,7 +4196,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
 						showingInspectText = false;
 						inspectCanDismiss = false;
-						inspectText = StartupResolvedText();
+						inspectText = ResolvedText();
 						needsRedraw = true;
 					}
 					break;
@@ -4478,7 +4478,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			if (runtimeEntities->takeExpiredTimerNames(expiredTimerNames)) {
 				syncCurrentRoomRuntimeState();
 				for (const Common::String &timerName : expiredTimerNames) {
-					StartupInteractionResult timerInteraction;
+					InteractionResult timerInteraction;
 					if (!_engine.getStartupScript()->executeTimerAction(timerName, timerInteraction))
 						continue;
 
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index 55dd737c7c0..f7b50dfb2dc 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -37,22 +37,22 @@ namespace Harvester {
 
 class HarvesterEngine;
 class ResourceManager;
-class RuntimeEntity;
+class Entity;
 class Art;
 class DialogueSystem;
 struct IndexedBitmap;
 
-struct StartupRoomSceneResources {
-	StartupRoomSetupState state;
+struct RoomSceneResources {
+	RoomSetupState state;
 	byte palette[256 * 3] = { 0 };
-	Common::Array<StartupObjectRecord> sceneObjects;
-	Common::Array<StartupAnimRecord> sceneAnimations;
-	Common::Array<StartupRegionRecord> sceneRegions;
+	Common::Array<ObjectRecord> sceneObjects;
+	Common::Array<AnimRecord> sceneAnimations;
+	Common::Array<RegionRecord> sceneRegions;
 	float targetPaletteBrightness = 1.0f;
 };
 
-struct StartupRoomPlayerState {
-	RuntimeEntity *entity = nullptr;
+struct RoomPlayerState {
+	Entity *entity = nullptr;
 	int centerX = 0;
 	int bottomY = 0;
 	float z = 0.0f;
@@ -93,17 +93,17 @@ struct StartupRoomPlayerState {
 	int deathDamageType = 0;
 };
 
-struct StartupRoomHoverState {
-	const StartupObjectRecord *object = nullptr;
-	const StartupNpcRecord *npc = nullptr;
-	const StartupRegionRecord *region = nullptr;
-	const RuntimeEntity *playerEntity = nullptr;
+struct RoomHoverState {
+	const ObjectRecord *object = nullptr;
+	const NpcRecord *npc = nullptr;
+	const RegionRecord *region = nullptr;
+	const Entity *playerEntity = nullptr;
 	Common::String promptText;
 	int cursorSequence = 7;
 };
 
-struct StartupRoomIdleAnimationState {
-	RuntimeEntity *entity = nullptr;
+struct RoomIdleAnimationState {
+	Entity *entity = nullptr;
 	int restoreFacing = -1;
 	uint32 activityTick = 0;
 	uint32 resetTick = 0;
@@ -113,34 +113,34 @@ struct StartupRoomIdleAnimationState {
 	bool exiting = false;
 };
 
-bool applyRoomActorPlacement(const StartupRoomSetupState &state, RuntimeEntity &entity,
+bool applyRoomActorPlacement(const RoomSetupState &state, Entity &entity,
 	int centerX, int bottomY, float z, const Common::String *entranceName = nullptr);
-void logScenePaletteSummary(const char *label, const StartupRoomSceneResources &scene, float brightness);
+void logScenePaletteSummary(const char *label, const RoomSceneResources &scene, float brightness);
 bool captureScreenBackdrop(const Graphics::Screen &screen, IndexedBitmap &bitmap);
-Common::Rect getRoomObjectHotspotBounds(const StartupObjectRecord &object);
-bool loadRoomSceneResources(const StartupRoomSetupState &state, ResourceManager &resources, StartupRoomSceneResources &scene);
+Common::Rect getRoomObjectHotspotBounds(const ObjectRecord &object);
+bool loadRoomSceneResources(const RoomSetupState &state, ResourceManager &resources, RoomSceneResources &scene);
 bool shouldRunStartupRoomProbe();
-void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const StartupRoomSceneResources &scene,
+void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const RoomSceneResources &scene,
 	float brightness);
-const StartupObjectRecord *findSceneObjectByName(const Common::Array<StartupObjectRecord> &objects,
+const ObjectRecord *findSceneObjectByName(const Common::Array<ObjectRecord> &objects,
 	const Common::String &objectName);
-const StartupRegionRecord *findSceneRegionByName(const Common::Array<StartupRegionRecord> &regions,
+const RegionRecord *findSceneRegionByName(const Common::Array<RegionRecord> &regions,
 	const Common::String &regionName);
-StartupObjectRecord *findSceneObjectByName(Common::Array<StartupObjectRecord> &objects,
+ObjectRecord *findSceneObjectByName(Common::Array<ObjectRecord> &objects,
 	const Common::String &objectName);
-const IndexedBitmap *resolveInspectTextboxBitmap(const Art &art, const StartupResolvedText &text);
+const IndexedBitmap *resolveInspectTextboxBitmap(const Art &art, const ResolvedText &text);
 void drawRoomInspectText(Graphics::Screen &screen, const Art &art, const Graphics::Font &font,
-	const StartupResolvedText &inspectText, bool useNativeFont);
-bool unlocksRoomObjectInteractionAfterInitialExamine(const StartupObjectRecord &object,
+	const ResolvedText &inspectText, bool useNativeFont);
+bool unlocksRoomObjectInteractionAfterInitialExamine(const ObjectRecord &object,
 	Script &startupScript);
-bool doesPlayerFacingMatchRegion(int playerFacing, const StartupRegionRecord &region);
-bool doesPlayerOverlapRegion(const RuntimeEntity &playerEntity, const StartupRegionRecord &region);
-StartupRoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const StartupRoomSetupState &state,
-	const Common::Array<StartupObjectRecord> &sceneObjects,
-	const Common::Array<StartupNpcRecord> &npcs,
-	const Common::Array<StartupRegionRecord> &regions,
+bool doesPlayerFacingMatchRegion(int playerFacing, const RegionRecord &region);
+bool doesPlayerOverlapRegion(const Entity &playerEntity, const RegionRecord &region);
+RoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const RoomSetupState &state,
+	const Common::Array<ObjectRecord> &sceneObjects,
+	const Common::Array<NpcRecord> &npcs,
+	const Common::Array<RegionRecord> &regions,
 	const Common::Point &mousePos, const DialogueSystem *dialogue = nullptr);
-void logStartupRoomProbe(HarvesterEngine &engine, const StartupRoomSceneResources &scene,
+void logStartupRoomProbe(HarvesterEngine &engine, const RoomSceneResources &scene,
 	const Common::String &entranceName, Common::Point &mousePos);
 
 } // End of namespace Harvester
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index f732f9744c6..966895f6bcf 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -149,7 +149,7 @@ static bool isRectangleOnlyHitClass(int classId) {
 		classId == kRuntimeEntityClassRectHotspot19;
 }
 
-bool RuntimeEntity::loadBitmapResource(ResourceManager &resources, const Common::String &path) {
+bool Entity::loadBitmapResource(ResourceManager &resources, const Common::String &path) {
 	Common::Array<byte> data;
 	if (!resources.loadFile(path, data) || data.size() < 12) {
 		warning("Harvester: unable to load runtime entity bitmap '%s'", path.c_str());
@@ -183,7 +183,7 @@ bool RuntimeEntity::loadBitmapResource(ResourceManager &resources, const Common:
 	return true;
 }
 
-bool RuntimeEntity::loadAbmResource(ResourceManager &resources, const Common::String &path) {
+bool Entity::loadAbmResource(ResourceManager &resources, const Common::String &path) {
 	Common::Array<byte> data;
 	if (!resources.loadFile(path, data) || data.size() < 8) {
 		warning("Harvester: unable to load runtime entity animation '%s'", path.c_str());
@@ -235,7 +235,7 @@ bool RuntimeEntity::loadAbmResource(ResourceManager &resources, const Common::St
 	return true;
 }
 
-void RuntimeEntity::setPosition(int x, int y, float z) {
+void Entity::setPosition(int x, int y, float z) {
 	const bool anchorChanged = _x != x || _y != y;
 	_x = x;
 	_y = y;
@@ -244,12 +244,12 @@ void RuntimeEntity::setPosition(int x, int y, float z) {
 		updateScreenBaseFromCurrentFrame();
 }
 
-void RuntimeEntity::setAnchorMode(RuntimeEntityAnchorMode anchorMode) {
+void Entity::setAnchorMode(RuntimeEntityAnchorMode anchorMode) {
 	_anchorMode = anchorMode;
 	updateScreenBaseFromCurrentFrame();
 }
 
-void RuntimeEntity::setAnimationRate(int rate) {
+void Entity::setAnimationRate(int rate) {
 	if (rate == 0)
 		_animationTickInterval = 0;
 	else
@@ -261,11 +261,11 @@ void RuntimeEntity::setAnimationRate(int rate) {
 	}
 }
 
-void RuntimeEntity::setAnimationEnabled(bool enabled) {
+void Entity::setAnimationEnabled(bool enabled) {
 	_animationEnabled = enabled && !_frames.empty() && _currentFrame >= 0;
 }
 
-void RuntimeEntity::setCurrentFrame(int frame) {
+void Entity::setCurrentFrame(int frame) {
 	if (_frames.empty())
 		return;
 
@@ -273,7 +273,7 @@ void RuntimeEntity::setCurrentFrame(int frame) {
 	updateScreenBaseFromCurrentFrame();
 }
 
-void RuntimeEntity::setAnimationFrameRange(int firstFrame, int lastFrame, bool looping) {
+void Entity::setAnimationFrameRange(int firstFrame, int lastFrame, bool looping) {
 	if (_frames.empty())
 		return;
 
@@ -294,7 +294,7 @@ void RuntimeEntity::setAnimationFrameRange(int firstFrame, int lastFrame, bool l
 		updateBoundsFromCurrentFrame();
 }
 
-void RuntimeEntity::setAnimationSequence(int sequence) {
+void Entity::setAnimationSequence(int sequence) {
 	if (_frames.empty() || sequence == _animationSequence)
 		return;
 
@@ -313,7 +313,7 @@ void RuntimeEntity::setAnimationSequence(int sequence) {
 	}
 }
 
-void RuntimeEntity::configureHotspotBounds(int width, int height) {
+void Entity::configureHotspotBounds(int width, int height) {
 	_frames.clear();
 	_baseFrames.clear();
 	_currentFrame = -1;
@@ -328,7 +328,7 @@ void RuntimeEntity::configureHotspotBounds(int width, int height) {
 	updateScreenBaseFromCurrentFrame();
 }
 
-bool RuntimeEntity::getCurrentFrameMetrics(int &width, int &height, int &xOffset, int &yOffset) const {
+bool Entity::getCurrentFrameMetrics(int &width, int &height, int &xOffset, int &yOffset) const {
 	if (_frames.empty() || _currentFrame < 0 || (uint)_currentFrame >= _frames.size())
 		return false;
 
@@ -340,7 +340,7 @@ bool RuntimeEntity::getCurrentFrameMetrics(int &width, int &height, int &xOffset
 	return true;
 }
 
-void RuntimeEntity::setDepthScale(float scale) {
+void Entity::setDepthScale(float scale) {
 	if (_frames.empty())
 		return;
 
@@ -355,7 +355,7 @@ void RuntimeEntity::setDepthScale(float scale) {
 	rebuildScaledFrames();
 }
 
-bool RuntimeEntity::tickVisualState(uint32 now) {
+bool Entity::tickVisualState(uint32 now) {
 	_animationAdvancedLastTick = false;
 	if (!_animationEnabled || _currentFrame < 0 || _animationTickInterval == 0)
 		return false;
@@ -368,7 +368,7 @@ bool RuntimeEntity::tickVisualState(uint32 now) {
 	return true;
 }
 
-Common::Point RuntimeEntity::getDrawOrigin() const {
+Common::Point Entity::getDrawOrigin() const {
 	if (!_frames.empty() && _currentFrame >= 0 && (uint)_currentFrame < _frames.size()) {
 		const AbmFrame &frame = _frames[(uint)_currentFrame];
 		return Common::Point(_screenBaseX + frame.xOffset, _screenBaseY + frame.yOffset);
@@ -377,11 +377,11 @@ Common::Point RuntimeEntity::getDrawOrigin() const {
 	return Common::Point(_screenBaseX, _screenBaseY);
 }
 
-Common::Rect RuntimeEntity::getScreenRect() const {
+Common::Rect Entity::getScreenRect() const {
 	return getFrameRect();
 }
 
-void RuntimeEntity::configureTimerCountdown(int initialValue, int currentValue,
+void Entity::configureTimerCountdown(int initialValue, int currentValue,
 		bool enabled, bool looping, bool global) {
 	_timerInitialValue = MAX(initialValue, 0);
 	_timerCurrentValue = MAX(currentValue, 0);
@@ -399,7 +399,7 @@ void RuntimeEntity::configureTimerCountdown(int initialValue, int currentValue,
 	setTimerEnabled(enabled);
 }
 
-void RuntimeEntity::setTimerEnabled(bool enabled) {
+void Entity::setTimerEnabled(bool enabled) {
 	if (_classId != kRuntimeEntityClassTimer)
 		return;
 
@@ -422,7 +422,7 @@ void RuntimeEntity::setTimerEnabled(bool enabled) {
 	_timerNextFireTick = now + (uint32)_timerCurrentValue * 100U;
 }
 
-bool RuntimeEntity::tickTimerState(uint32 now, Common::Array<Common::String> &expiredTimerNames) {
+bool Entity::tickTimerState(uint32 now, Common::Array<Common::String> &expiredTimerNames) {
 	if (_classId != kRuntimeEntityClassTimer || !_timerEnabled || _timerPaused)
 		return false;
 
@@ -447,7 +447,7 @@ bool RuntimeEntity::tickTimerState(uint32 now, Common::Array<Common::String> &ex
 	return false;
 }
 
-void RuntimeEntity::pauseTimerCountdown(uint32 now) {
+void Entity::pauseTimerCountdown(uint32 now) {
 	if (_classId != kRuntimeEntityClassTimer || !_timerEnabled || _timerPaused)
 		return;
 
@@ -462,7 +462,7 @@ void RuntimeEntity::pauseTimerCountdown(uint32 now) {
 	_timerPauseTick = now;
 }
 
-void RuntimeEntity::resumeTimerCountdown(uint32 now) {
+void Entity::resumeTimerCountdown(uint32 now) {
 	if (_classId != kRuntimeEntityClassTimer || !_timerEnabled || !_timerPaused)
 		return;
 
@@ -472,7 +472,7 @@ void RuntimeEntity::resumeTimerCountdown(uint32 now) {
 	_timerPauseTick = 0;
 }
 
-void RuntimeEntity::draw(Graphics::Screen &screen) const {
+void Entity::draw(Graphics::Screen &screen) const {
 	if (!_visible || !_drawEnabled || _currentFrame < 0)
 		return;
 
@@ -480,7 +480,7 @@ void RuntimeEntity::draw(Graphics::Screen &screen) const {
 	blitAnimationFrame(screen, _frames, _currentFrame, drawOrigin.x, drawOrigin.y);
 }
 
-Common::Rect RuntimeEntity::getFrameRect() const {
+Common::Rect Entity::getFrameRect() const {
 	const Common::Point drawOrigin = getDrawOrigin();
 	if (!_frames.empty() && _currentFrame >= 0 && (uint)_currentFrame < _frames.size()) {
 		const AbmFrame &frame = _frames[(uint)_currentFrame];
@@ -491,11 +491,11 @@ Common::Rect RuntimeEntity::getFrameRect() const {
 	return Common::Rect(drawOrigin.x, drawOrigin.y, drawOrigin.x + _boundsWidth, drawOrigin.y + _boundsHeight);
 }
 
-bool RuntimeEntity::hasOpaqueFrame() const {
+bool Entity::hasOpaqueFrame() const {
 	return !_frames.empty() && _currentFrame >= 0 && (uint)_currentFrame < _frames.size();
 }
 
-bool RuntimeEntity::isOpaqueAt(const Common::Point &point) const {
+bool Entity::isOpaqueAt(const Common::Point &point) const {
 	if (!hasOpaqueFrame())
 		return false;
 
@@ -512,7 +512,7 @@ bool RuntimeEntity::isOpaqueAt(const Common::Point &point) const {
 	return frame.pixels[(uint)relativeY * frame.width + (uint)relativeX] != 0;
 }
 
-bool RuntimeEntity::hitTest(const Common::Point &point) const {
+bool Entity::hitTest(const Common::Point &point) const {
 	if (!_visible)
 		return false;
 	if (_classId == kRuntimeEntityClassDisabledHotspot)
@@ -531,7 +531,7 @@ bool RuntimeEntity::hitTest(const Common::Point &point) const {
 	return isOpaqueAt(point);
 }
 
-bool RuntimeEntity::overlapsEntity(const RuntimeEntity &other) const {
+bool Entity::overlapsEntity(const Entity &other) const {
 	if (this == &other || !_visible || !other._visible)
 		return false;
 	if (_classId == kRuntimeEntityClassCursor || _classId == kRuntimeEntityClassBackground ||
@@ -572,7 +572,7 @@ bool RuntimeEntity::overlapsEntity(const RuntimeEntity &other) const {
 	return false;
 }
 
-bool RuntimeEntity::measureCurrentFrameTransparency(uint32 &framePixels, uint32 &transparentPixels,
+bool Entity::measureCurrentFrameTransparency(uint32 &framePixels, uint32 &transparentPixels,
 		uint32 &preservedPixels) const {
 	framePixels = 0;
 	transparentPixels = 0;
@@ -604,7 +604,7 @@ bool RuntimeEntity::measureCurrentFrameTransparency(uint32 &framePixels, uint32
 	return true;
 }
 
-void RuntimeEntity::advanceAnimationFrame(int directive) {
+void Entity::advanceAnimationFrame(int directive) {
 	if (_frames.empty())
 		return;
 
@@ -658,7 +658,7 @@ done:
 	updateBoundsFromCurrentFrame();
 }
 
-void RuntimeEntity::updateBoundsFromCurrentFrame() {
+void Entity::updateBoundsFromCurrentFrame() {
 	if (_frames.empty()) {
 		_boundsWidth = 0;
 		_boundsHeight = 0;
@@ -670,7 +670,7 @@ void RuntimeEntity::updateBoundsFromCurrentFrame() {
 	_boundsHeight = (int)_frames[(uint)frameIndex].height;
 }
 
-void RuntimeEntity::updateScreenBaseFromCurrentFrame() {
+void Entity::updateScreenBaseFromCurrentFrame() {
 	int baseX = _x;
 	int baseY = _y;
 
@@ -683,7 +683,7 @@ void RuntimeEntity::updateScreenBaseFromCurrentFrame() {
 	_screenBaseY = baseY;
 }
 
-void RuntimeEntity::rebuildScaledFrames() {
+void Entity::rebuildScaledFrames() {
 	if (_baseFrames.empty()) {
 		_frames.clear();
 		updateBoundsFromCurrentFrame();
@@ -714,31 +714,31 @@ void RuntimeEntity::rebuildScaledFrames() {
 	updateScreenBaseFromCurrentFrame();
 }
 
-RuntimeEntityManager::RuntimeEntityManager(ResourceManager &resources) : _resources(resources) {
+EntityManager::EntityManager(ResourceManager &resources) : _resources(resources) {
 }
 
-RuntimeEntityManager::~RuntimeEntityManager() {
+EntityManager::~EntityManager() {
 	clear();
 }
 
-void RuntimeEntityManager::clear() {
+void EntityManager::clear() {
 	clearSceneEntities();
 	delete _cursorEntity;
 	_cursorEntity = nullptr;
 }
 
-void RuntimeEntityManager::clearSceneEntities() {
-	for (RuntimeEntity *entity : _sceneEntities)
+void EntityManager::clearSceneEntities() {
+	for (Entity *entity : _sceneEntities)
 		delete entity;
 	_sceneEntities.clear();
 	_expiredTimerNames.clear();
 	_timerPauseDepth = 0;
 }
 
-RuntimeEntity *RuntimeEntityManager::spawnAbmEntityFromResource(const Common::String &name,
+Entity *EntityManager::spawnAbmEntityFromResource(const Common::String &name,
 		const Common::String &resourcePath, int classId, const Common::Point &position, float z,
 		int animationRate, bool looping, bool pingPong) {
-	RuntimeEntity *entity = new RuntimeEntity();
+	Entity *entity = new Entity();
 	if (!entity->loadAbmResource(_resources, resourcePath)) {
 		delete entity;
 		return nullptr;
@@ -753,9 +753,9 @@ RuntimeEntity *RuntimeEntityManager::spawnAbmEntityFromResource(const Common::St
 	return entity;
 }
 
-RuntimeEntity *RuntimeEntityManager::spawnBitmapEntityFromResource(const Common::String &name,
+Entity *EntityManager::spawnBitmapEntityFromResource(const Common::String &name,
 		const Common::String &resourcePath, int classId, const Common::Point &position, float z) {
-	RuntimeEntity *entity = new RuntimeEntity();
+	Entity *entity = new Entity();
 	if (!entity->loadBitmapResource(_resources, resourcePath)) {
 		delete entity;
 		return nullptr;
@@ -767,7 +767,7 @@ RuntimeEntity *RuntimeEntityManager::spawnBitmapEntityFromResource(const Common:
 	return entity;
 }
 
-RuntimeEntity *RuntimeEntityManager::spawnCursorEntity(const Common::Point &position) {
+Entity *EntityManager::spawnCursorEntity(const Common::Point &position) {
 	if (_cursorEntity)
 		return _cursorEntity;
 
@@ -789,18 +789,18 @@ RuntimeEntity *RuntimeEntityManager::spawnCursorEntity(const Common::Point &posi
 	return _cursorEntity;
 }
 
-RuntimeEntity *RuntimeEntityManager::spawnSceneBitmapEntity(const Common::String &name,
+Entity *EntityManager::spawnSceneBitmapEntity(const Common::String &name,
 		const Common::String &resourcePath, const Common::Point &position, float z) {
-	RuntimeEntity *entity = spawnBitmapEntityFromResource(name, resourcePath, kRuntimeEntityClassObject,
+	Entity *entity = spawnBitmapEntityFromResource(name, resourcePath, kRuntimeEntityClassObject,
 		position, z);
 	if (entity)
 		insertSceneEntity(entity);
 	return entity;
 }
 
-RuntimeEntity *RuntimeEntityManager::spawnSceneHotspotEntity(const Common::String &name,
+Entity *EntityManager::spawnSceneHotspotEntity(const Common::String &name,
 		const Common::Rect &bounds, float z) {
-	RuntimeEntity *entity = new RuntimeEntity();
+	Entity *entity = new Entity();
 	entity->setName(name);
 	entity->setClassId(kRuntimeEntityClassObject);
 	entity->setPosition(bounds.left, bounds.top, z);
@@ -809,10 +809,10 @@ RuntimeEntity *RuntimeEntityManager::spawnSceneHotspotEntity(const Common::Strin
 	return entity;
 }
 
-RuntimeEntity *RuntimeEntityManager::spawnSceneAnimationEntity(const Common::String &name,
+Entity *EntityManager::spawnSceneAnimationEntity(const Common::String &name,
 		const Common::String &resourcePath, const Common::Point &position, float z, int animationRate,
 		bool active, bool visible, bool looping, bool playBackwards, bool pingPong, int initialFrame) {
-	RuntimeEntity *entity = spawnAbmEntityFromResource(name, resourcePath, kRuntimeEntityClassAnimation,
+	Entity *entity = spawnAbmEntityFromResource(name, resourcePath, kRuntimeEntityClassAnimation,
 		position, z, animationRate, looping, pingPong);
 	if (!entity)
 		return nullptr;
@@ -839,9 +839,9 @@ RuntimeEntity *RuntimeEntityManager::spawnSceneAnimationEntity(const Common::Str
 	return entity;
 }
 
-RuntimeEntity *RuntimeEntityManager::spawnSceneActorEntity(const Common::String &name,
+Entity *EntityManager::spawnSceneActorEntity(const Common::String &name,
 		const Common::String &resourcePath, const Common::Point &position, float z, int initialFrame) {
-	RuntimeEntity *entity = spawnAbmEntityFromResource(name, resourcePath, kRuntimeEntityClassObject,
+	Entity *entity = spawnAbmEntityFromResource(name, resourcePath, kRuntimeEntityClassObject,
 		position, z, 0, false, false);
 	if (!entity)
 		return nullptr;
@@ -859,9 +859,9 @@ RuntimeEntity *RuntimeEntityManager::spawnSceneActorEntity(const Common::String
 	return entity;
 }
 
-RuntimeEntity *RuntimeEntityManager::spawnSceneTimerEntity(const Common::String &name,
+Entity *EntityManager::spawnSceneTimerEntity(const Common::String &name,
 		int initialValue, int currentValue, bool enabled, bool looping, bool global) {
-	RuntimeEntity *entity = new RuntimeEntity();
+	Entity *entity = new Entity();
 	entity->setName(name);
 	entity->setClassId(kRuntimeEntityClassTimer);
 	entity->configureTimerCountdown(initialValue, currentValue, enabled, looping, global);
@@ -869,37 +869,37 @@ RuntimeEntity *RuntimeEntityManager::spawnSceneTimerEntity(const Common::String
 	return entity;
 }
 
-void RuntimeEntityManager::hideCursor() {
+void EntityManager::hideCursor() {
 	if (_cursorEntity)
 		_cursorEntity->setVisible(false);
 }
 
-void RuntimeEntityManager::showCursor() {
+void EntityManager::showCursor() {
 	if (_cursorEntity)
 		_cursorEntity->setVisible(true);
 }
 
-void RuntimeEntityManager::pauseTimerCountdowns() {
+void EntityManager::pauseTimerCountdowns() {
 	if (_timerPauseDepth++ > 0)
 		return;
 
 	const uint32 now = getAnimationClockTicks();
-	for (RuntimeEntity *entity : _sceneEntities)
+	for (Entity *entity : _sceneEntities)
 		entity->pauseTimerCountdown(now);
 }
 
-void RuntimeEntityManager::resumeTimerCountdowns() {
+void EntityManager::resumeTimerCountdowns() {
 	if (_timerPauseDepth == 0)
 		return;
 	if (--_timerPauseDepth > 0)
 		return;
 
 	const uint32 now = getAnimationClockTicks();
-	for (RuntimeEntity *entity : _sceneEntities)
+	for (Entity *entity : _sceneEntities)
 		entity->resumeTimerCountdown(now);
 }
 
-bool RuntimeEntityManager::takeExpiredTimerNames(Common::Array<Common::String> &expiredTimerNames) {
+bool EntityManager::takeExpiredTimerNames(Common::Array<Common::String> &expiredTimerNames) {
 	if (_expiredTimerNames.empty())
 		return false;
 
@@ -908,11 +908,11 @@ bool RuntimeEntityManager::takeExpiredTimerNames(Common::Array<Common::String> &
 	return true;
 }
 
-bool RuntimeEntityManager::tickSceneEntities() {
+bool EntityManager::tickSceneEntities() {
 	const uint32 now = getAnimationClockTicks();
 	bool changed = false;
 
-	for (RuntimeEntity *entity : _sceneEntities) {
+	for (Entity *entity : _sceneEntities) {
 		if (entity->getClassId() == kRuntimeEntityClassTimer) {
 			if (_timerPauseDepth == 0)
 				(void)entity->tickTimerState(now, _expiredTimerNames);
@@ -925,7 +925,7 @@ bool RuntimeEntityManager::tickSceneEntities() {
 	return changed;
 }
 
-bool RuntimeEntityManager::syncCursorEntityPosition(const Common::Point &position) {
+bool EntityManager::syncCursorEntityPosition(const Common::Point &position) {
 	if (!_cursorEntity)
 		return false;
 
@@ -934,19 +934,19 @@ bool RuntimeEntityManager::syncCursorEntityPosition(const Common::Point &positio
 	return _cursorEntity->tickVisualState(getAnimationClockTicks()) || moved;
 }
 
-void RuntimeEntityManager::drawCursor(Graphics::Screen &screen) const {
+void EntityManager::drawCursor(Graphics::Screen &screen) const {
 	if (_cursorEntity)
 		_cursorEntity->draw(screen);
 }
 
-void RuntimeEntityManager::drawSceneEntities(Graphics::Screen &screen) const {
-	for (RuntimeEntity *entity : _sceneEntities)
+void EntityManager::drawSceneEntities(Graphics::Screen &screen) const {
+	for (Entity *entity : _sceneEntities)
 		entity->draw(screen);
 }
 
-const RuntimeEntity *RuntimeEntityManager::findTopSceneEntityAt(const Common::Point &point, int classIdFilter) const {
+const Entity *EntityManager::findTopSceneEntityAt(const Common::Point &point, int classIdFilter) const {
 	for (int i = (int)_sceneEntities.size() - 1; i >= 0; --i) {
-		const RuntimeEntity *entity = _sceneEntities[(uint)i];
+		const Entity *entity = _sceneEntities[(uint)i];
 		if (classIdFilter >= 0 && entity->getClassId() != classIdFilter)
 			continue;
 		if (entity->hitTest(point))
@@ -956,9 +956,9 @@ const RuntimeEntity *RuntimeEntityManager::findTopSceneEntityAt(const Common::Po
 	return nullptr;
 }
 
-int RuntimeEntityManager::findSceneEntityDrawIndexByName(const Common::String &name) const {
+int EntityManager::findSceneEntityDrawIndexByName(const Common::String &name) const {
 	for (uint i = 0; i < _sceneEntities.size(); ++i) {
-		const RuntimeEntity *entity = _sceneEntities[i];
+		const Entity *entity = _sceneEntities[i];
 		if (entity->getName().equalsIgnoreCase(name))
 			return (int)i;
 	}
@@ -966,8 +966,8 @@ int RuntimeEntityManager::findSceneEntityDrawIndexByName(const Common::String &n
 	return -1;
 }
 
-const RuntimeEntity *RuntimeEntityManager::findSceneEntityByName(const Common::String &name) const {
-	for (const RuntimeEntity *entity : _sceneEntities) {
+const Entity *EntityManager::findSceneEntityByName(const Common::String &name) const {
+	for (const Entity *entity : _sceneEntities) {
 		if (entity->getName().equalsIgnoreCase(name))
 			return entity;
 	}
@@ -975,8 +975,8 @@ const RuntimeEntity *RuntimeEntityManager::findSceneEntityByName(const Common::S
 	return nullptr;
 }
 
-RuntimeEntity *RuntimeEntityManager::findSceneEntityByName(const Common::String &name) {
-	for (RuntimeEntity *entity : _sceneEntities) {
+Entity *EntityManager::findSceneEntityByName(const Common::String &name) {
+	for (Entity *entity : _sceneEntities) {
 		if (entity->getName().equalsIgnoreCase(name))
 			return entity;
 	}
@@ -984,9 +984,9 @@ RuntimeEntity *RuntimeEntityManager::findSceneEntityByName(const Common::String
 	return nullptr;
 }
 
-RuntimeEntity *RuntimeEntityManager::detachSceneEntityByName(const Common::String &name) {
+Entity *EntityManager::detachSceneEntityByName(const Common::String &name) {
 	for (uint i = 0; i < _sceneEntities.size(); ++i) {
-		RuntimeEntity *entity = _sceneEntities[i];
+		Entity *entity = _sceneEntities[i];
 		if (!entity->getName().equalsIgnoreCase(name))
 			continue;
 
@@ -997,14 +997,14 @@ RuntimeEntity *RuntimeEntityManager::detachSceneEntityByName(const Common::Strin
 	return nullptr;
 }
 
-void RuntimeEntityManager::adoptSceneEntity(RuntimeEntity *entity) {
+void EntityManager::adoptSceneEntity(Entity *entity) {
 	if (!entity)
 		return;
 
 	insertSceneEntity(entity);
 }
 
-void RuntimeEntityManager::reinsertSceneEntity(RuntimeEntity *entity) {
+void EntityManager::reinsertSceneEntity(Entity *entity) {
 	if (!entity)
 		return;
 
@@ -1019,7 +1019,7 @@ void RuntimeEntityManager::reinsertSceneEntity(RuntimeEntity *entity) {
 	insertSceneEntity(entity);
 }
 
-void RuntimeEntityManager::insertSceneEntity(RuntimeEntity *entity) {
+void EntityManager::insertSceneEntity(Entity *entity) {
 	uint insertIndex = 0;
 	while (insertIndex < _sceneEntities.size() && _sceneEntities[insertIndex]->getZ() >= entity->getZ())
 		++insertIndex;
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index 32f47d59b57..fbf65fab6d5 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -61,7 +61,7 @@ enum RuntimeEntityAnchorMode {
 	kRuntimeEntityAnchorCentered = 1
 };
 
-class RuntimeEntity {
+class Entity {
 public:
 	bool loadBitmapResource(ResourceManager &resources, const Common::String &path);
 	bool loadAbmResource(ResourceManager &resources, const Common::String &path);
@@ -121,7 +121,7 @@ public:
 	bool tickVisualState(uint32 now);
 	void draw(Graphics::Screen &screen) const;
 	bool hitTest(const Common::Point &point) const;
-	bool overlapsEntity(const RuntimeEntity &other) const;
+	bool overlapsEntity(const Entity &other) const;
 	bool measureCurrentFrameTransparency(uint32 &framePixels, uint32 &transparentPixels,
 		uint32 &preservedPixels) const;
 
@@ -176,29 +176,29 @@ private:
 	bool _timerPaused = false;
 };
 
-class RuntimeEntityManager {
+class EntityManager {
 public:
-	explicit RuntimeEntityManager(ResourceManager &resources);
-	~RuntimeEntityManager();
+	explicit EntityManager(ResourceManager &resources);
+	~EntityManager();
 
 	void clear();
 	void clearSceneEntities();
-	RuntimeEntity *spawnAbmEntityFromResource(const Common::String &name, const Common::String &resourcePath,
+	Entity *spawnAbmEntityFromResource(const Common::String &name, const Common::String &resourcePath,
 		int classId, const Common::Point &position, float z, int animationRate, bool looping, bool pingPong);
-	RuntimeEntity *spawnBitmapEntityFromResource(const Common::String &name, const Common::String &resourcePath,
+	Entity *spawnBitmapEntityFromResource(const Common::String &name, const Common::String &resourcePath,
 		int classId, const Common::Point &position, float z);
-	RuntimeEntity *spawnCursorEntity(const Common::Point &position);
-	RuntimeEntity *spawnSceneBitmapEntity(const Common::String &name, const Common::String &resourcePath,
+	Entity *spawnCursorEntity(const Common::Point &position);
+	Entity *spawnSceneBitmapEntity(const Common::String &name, const Common::String &resourcePath,
 		const Common::Point &position, float z);
-	RuntimeEntity *spawnSceneHotspotEntity(const Common::String &name, const Common::Rect &bounds, float z);
-	RuntimeEntity *spawnSceneAnimationEntity(const Common::String &name, const Common::String &resourcePath,
+	Entity *spawnSceneHotspotEntity(const Common::String &name, const Common::Rect &bounds, float z);
+	Entity *spawnSceneAnimationEntity(const Common::String &name, const Common::String &resourcePath,
 		const Common::Point &position, float z, int animationRate, bool active, bool visible, bool looping,
 		bool playBackwards, bool pingPong, int initialFrame = -1);
-	RuntimeEntity *spawnSceneActorEntity(const Common::String &name, const Common::String &resourcePath,
+	Entity *spawnSceneActorEntity(const Common::String &name, const Common::String &resourcePath,
 		const Common::Point &position, float z, int initialFrame);
-	RuntimeEntity *spawnSceneTimerEntity(const Common::String &name, int initialValue, int currentValue,
+	Entity *spawnSceneTimerEntity(const Common::String &name, int initialValue, int currentValue,
 		bool enabled, bool looping, bool global);
-	RuntimeEntity *getCursorEntity() const { return _cursorEntity; }
+	Entity *getCursorEntity() const { return _cursorEntity; }
 	void hideCursor();
 	void showCursor();
 	void pauseTimerCountdowns();
@@ -208,21 +208,21 @@ public:
 	bool syncCursorEntityPosition(const Common::Point &position);
 	void drawSceneEntities(Graphics::Screen &screen) const;
 	void drawCursor(Graphics::Screen &screen) const;
-	const RuntimeEntity *findTopSceneEntityAt(const Common::Point &point, int classIdFilter = -1) const;
+	const Entity *findTopSceneEntityAt(const Common::Point &point, int classIdFilter = -1) const;
 	int findSceneEntityDrawIndexByName(const Common::String &name) const;
-	const RuntimeEntity *findSceneEntityByName(const Common::String &name) const;
-	RuntimeEntity *findSceneEntityByName(const Common::String &name);
-	RuntimeEntity *detachSceneEntityByName(const Common::String &name);
-	void adoptSceneEntity(RuntimeEntity *entity);
-	void reinsertSceneEntity(RuntimeEntity *entity);
+	const Entity *findSceneEntityByName(const Common::String &name) const;
+	Entity *findSceneEntityByName(const Common::String &name);
+	Entity *detachSceneEntityByName(const Common::String &name);
+	void adoptSceneEntity(Entity *entity);
+	void reinsertSceneEntity(Entity *entity);
 
 private:
-	void insertSceneEntity(RuntimeEntity *entity);
+	void insertSceneEntity(Entity *entity);
 
 	ResourceManager &_resources;
-	Common::Array<RuntimeEntity *> _sceneEntities;
+	Common::Array<Entity *> _sceneEntities;
 	Common::Array<Common::String> _expiredTimerNames;
-	RuntimeEntity *_cursorEntity = nullptr;
+	Entity *_cursorEntity = nullptr;
 	int _timerPauseDepth = 0;
 };
 
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index 5e12c991c12..6d517a81b0b 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -43,14 +43,14 @@ static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
 // runtimeState. Fold it into the final Harvester save layout before release.
 static const uint32 kHarvesterSaveVersion = 16;
 
-static void logStartupSaveRoomState(const char *operation, const StartupSaveRoomState &state) {
+static void logStartupSaveRoomState(const char *operation, const SaveRoomState &state) {
 	debugC(1, kDebugRoom,
 		"Harvester: %s startup save room state valid=%d entrance='%s' room='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
 		operation, state.valid, state.entranceName.c_str(), state.roomName.c_str(),
 		state.playerX, state.playerY, state.playerZ, state.playerFacing, state.musicPath.c_str());
 }
 
-static void syncStartupSaveRoomState(Common::Serializer &s, StartupSaveRoomState &state) {
+static void syncStartupSaveRoomState(Common::Serializer &s, SaveRoomState &state) {
 	syncSerializedBool(s, state.valid);
 	s.syncString(state.entranceName);
 	s.syncString(state.roomName);
@@ -106,8 +106,8 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 	s.syncAsSint32LE(serializedDisc, 14);
 	const int restoredDisc = serializedDisc > 0 ? serializedDisc : 1;
 
-	StartupSaveRoomState roomState = s.isLoading()
-		? StartupSaveRoomState()
+	SaveRoomState roomState = s.isLoading()
+		? SaveRoomState()
 		: _currentStartupSaveRoomState;
 	if (s.isSaving())
 		logStartupSaveRoomState("saving", roomState);
diff --git a/engines/harvester/saveload.h b/engines/harvester/saveload.h
index e5563b5a270..5167b05457e 100644
--- a/engines/harvester/saveload.h
+++ b/engines/harvester/saveload.h
@@ -26,7 +26,7 @@
 
 namespace Harvester {
 
-struct StartupSaveRoomState {
+struct SaveRoomState {
 	Common::String entranceName;
 	Common::String roomName;
 	Common::String musicPath;
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 491bf92edd5..7a45479fd02 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -93,12 +93,12 @@ static void syncRecordArray(Common::Serializer &s, Common::Array<RecordType> &re
 		syncFn(s, records[i]);
 }
 
-static void syncStartupFlagRecord(Common::Serializer &s, StartupFlagRecord &record) {
+static void syncStartupFlagRecord(Common::Serializer &s, FlagRecord &record) {
 	s.syncString(record.name);
 	syncBool(s, record.value);
 }
 
-static void syncStartupObjectRecord(Common::Serializer &s, StartupObjectRecord &record) {
+static void syncStartupObjectRecord(Common::Serializer &s, ObjectRecord &record) {
 	s.syncAsSint32LE(record.initialX);
 	s.syncAsSint32LE(record.initialY);
 	s.syncAsSint32LE(record.initialZ);
@@ -125,7 +125,7 @@ static void syncStartupObjectRecord(Common::Serializer &s, StartupObjectRecord &
 	syncBool(s, record.identShown);
 }
 
-static void syncStartupAnimRecord(Common::Serializer &s, StartupAnimRecord &record) {
+static void syncStartupAnimRecord(Common::Serializer &s, AnimRecord &record) {
 	s.syncAsSint32LE(record.x);
 	s.syncAsSint32LE(record.y);
 	s.syncAsSint32LE(record.z);
@@ -144,7 +144,7 @@ static void syncStartupAnimRecord(Common::Serializer &s, StartupAnimRecord &reco
 	s.syncAsSint32LE(record.runtimeState);
 }
 
-static void syncStartupNpcRecord(Common::Serializer &s, StartupNpcRecord &record) {
+static void syncStartupNpcRecord(Common::Serializer &s, NpcRecord &record) {
 	s.syncAsSint32LE(record.posX);
 	s.syncAsSint32LE(record.posY);
 	s.syncAsSint32LE(record.posZ);
@@ -164,7 +164,7 @@ static void syncStartupNpcRecord(Common::Serializer &s, StartupNpcRecord &record
 	s.syncString(record.entityInitArg);
 }
 
-static void syncStartupMonsterRecord(Common::Serializer &s, StartupMonsterRecord &record) {
+static void syncStartupMonsterRecord(Common::Serializer &s, MonsterRecord &record) {
 	s.syncAsSint32LE(record.posX);
 	s.syncAsSint32LE(record.posY);
 	s.syncAsSint32LE(record.posZ);
@@ -214,8 +214,8 @@ static void syncStartupMonsterRecord(Common::Serializer &s, StartupMonsterRecord
 
 // FIXME: Remove this pre-release monster combat migration after Harvester ships and
 // only clean release-era saves remain.
-static void migrateLegacyMonsterCombatFields(const StartupMonsterRecord &baseMonster,
-		StartupMonsterRecord &runtimeMonster) {
+static void migrateLegacyMonsterCombatFields(const MonsterRecord &baseMonster,
+		MonsterRecord &runtimeMonster) {
 	const int legacyInitialHitPoints = MAX(0, runtimeMonster.initialHitPoints);
 	const int legacyCurrentHitPoints = CLIP<int>(runtimeMonster.currentHitPoints, 0, legacyInitialHitPoints);
 	const int damageTaken = legacyInitialHitPoints - legacyCurrentHitPoints;
@@ -226,14 +226,14 @@ static void migrateLegacyMonsterCombatFields(const StartupMonsterRecord &baseMon
 	runtimeMonster.damageAmount = baseMonster.damageAmount;
 }
 
-static bool hasLegacyMonsterCombatFieldLayout(const StartupMonsterRecord &baseMonster,
-		const StartupMonsterRecord &runtimeMonster) {
+static bool hasLegacyMonsterCombatFieldLayout(const MonsterRecord &baseMonster,
+		const MonsterRecord &runtimeMonster) {
 	return runtimeMonster.initialHitPoints != baseMonster.initialHitPoints ||
 		runtimeMonster.damageAmount != baseMonster.damageAmount ||
 		runtimeMonster.engageDistance != baseMonster.engageDistance;
 }
 
-static void syncStartupTimerRecord(Common::Serializer &s, StartupTimerRecord &record) {
+static void syncStartupTimerRecord(Common::Serializer &s, TimerRecord &record) {
 	s.syncAsSint32LE(record.initialValue);
 	s.syncAsSint32LE(record.currentValue);
 	s.syncString(record.timerName);
@@ -244,7 +244,7 @@ static void syncStartupTimerRecord(Common::Serializer &s, StartupTimerRecord &re
 	syncBool(s, record.global);
 }
 
-static void syncStartupRegionRecord(Common::Serializer &s, StartupRegionRecord &record) {
+static void syncStartupRegionRecord(Common::Serializer &s, RegionRecord &record) {
 	s.syncAsSint32LE(record.left);
 	s.syncAsSint32LE(record.top);
 	s.syncAsSint32LE(record.right);
@@ -291,7 +291,7 @@ static const char *resolveCombatLoadoutInventoryObjectName(int loadout) {
 	return nullptr;
 }
 
-static int validatePlayerCombatLoadoutAgainstInventory(const Common::Array<StartupObjectRecord> &runtimeObjects,
+static int validatePlayerCombatLoadoutAgainstInventory(const Common::Array<ObjectRecord> &runtimeObjects,
 		int loadout, const char *contextLabel) {
 	const int clampedLoadout = clampPlayerCombatLoadout(loadout);
 	if (clampedLoadout == kDefaultPlayerCombatLoadout)
@@ -301,7 +301,7 @@ static int validatePlayerCombatLoadoutAgainstInventory(const Common::Array<Start
 	if (!requiredObjectName)
 		return clampedLoadout;
 
-	for (const StartupObjectRecord &object : runtimeObjects) {
+	for (const ObjectRecord &object : runtimeObjects) {
 		if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName) &&
 				object.objectName.equalsIgnoreCase(requiredObjectName))
 			return clampedLoadout;
@@ -393,9 +393,9 @@ static int parseNpcDeathDamageType(const Common::String &value) {
 	return 0;
 }
 
-static bool appendStartupAudioCommand(const StartupCommandRecord &command, Common::Array<StartupAudioCommand> &commands) {
+static bool appendStartupAudioCommand(const CommandRecord &command, Common::Array<AudioCommand> &commands) {
 	if (command.opcodeName.equalsIgnoreCase("START_WAV")) {
-		StartupAudioCommand audioCommand;
+		AudioCommand audioCommand;
 		audioCommand.type = kStartupAudioCommandStartWav;
 		audioCommand.path = command.arg1;
 		commands.push_back(audioCommand);
@@ -403,7 +403,7 @@ static bool appendStartupAudioCommand(const StartupCommandRecord &command, Commo
 	}
 
 	if (command.opcodeName.equalsIgnoreCase("START_SINGLE_WAV")) {
-		StartupAudioCommand audioCommand;
+		AudioCommand audioCommand;
 		audioCommand.type = kStartupAudioCommandStartSingleWav;
 		audioCommand.path = command.arg1;
 		commands.push_back(audioCommand);
@@ -411,7 +411,7 @@ static bool appendStartupAudioCommand(const StartupCommandRecord &command, Commo
 	}
 
 	if (command.opcodeName.equalsIgnoreCase("LOAD_WAV")) {
-		StartupAudioCommand audioCommand;
+		AudioCommand audioCommand;
 		audioCommand.type = kStartupAudioCommandLoadWav;
 		audioCommand.path = command.arg1;
 		audioCommand.slot = command.arg2.empty() ? -1 : parseAsciiIntOrZero(command.arg2);
@@ -420,7 +420,7 @@ static bool appendStartupAudioCommand(const StartupCommandRecord &command, Commo
 	}
 
 	if (command.opcodeName.equalsIgnoreCase("PLAY_WAV")) {
-		StartupAudioCommand audioCommand;
+		AudioCommand audioCommand;
 		audioCommand.type = kStartupAudioCommandPlayWav;
 		audioCommand.slot = command.arg1.empty() ? -1 : parseAsciiIntOrZero(command.arg1);
 		commands.push_back(audioCommand);
@@ -428,7 +428,7 @@ static bool appendStartupAudioCommand(const StartupCommandRecord &command, Commo
 	}
 
 	if (command.opcodeName.equalsIgnoreCase("DELETE_WAV")) {
-		StartupAudioCommand audioCommand;
+		AudioCommand audioCommand;
 		audioCommand.type = kStartupAudioCommandDeleteWav;
 		audioCommand.slot = command.arg1.empty() ? -1 : parseAsciiIntOrZero(command.arg1);
 		commands.push_back(audioCommand);
@@ -449,8 +449,8 @@ static bool loadBitmapDimensions(ResourceManager &resources, const Common::Strin
 	return true;
 }
 
-static bool isRoomBackgroundSpriteCandidate(const StartupObjectRecord &candidate,
-		const StartupRoomRecord &room) {
+static bool isRoomBackgroundSpriteCandidate(const ObjectRecord &candidate,
+		const RoomRecord &room) {
 	if (!candidate.currentOwnerOrRoom.equalsIgnoreCase(room.roomName) ||
 			candidate.spritePath.empty() ||
 			candidate.initialX != 0 || candidate.initialY != 0) {
@@ -460,7 +460,7 @@ static bool isRoomBackgroundSpriteCandidate(const StartupObjectRecord &candidate
 	return true;
 }
 
-static bool isFullscreenRoomBackgroundObject(const StartupObjectRecord &candidate,
+static bool isFullscreenRoomBackgroundObject(const ObjectRecord &candidate,
 		ResourceManager &resources) {
 	if (candidate.spritePath.empty())
 		return false;
@@ -565,13 +565,13 @@ bool Script::reloadTownWorld(ResourceManager &resources) {
 		return false;
 	}
 
-	const Common::Array<StartupFlagRecord> runtimeFlags = _runtimeFlags;
-	const Common::Array<StartupObjectRecord> runtimeObjects = _runtimeObjects;
-	const Common::Array<StartupAnimRecord> runtimeAnimations = _runtimeAnimations;
-	const Common::Array<StartupRegionRecord> runtimeRegions = _runtimeRegions;
-	const Common::Array<StartupNpcRecord> runtimeNpcs = _runtimeNpcs;
-	const Common::Array<StartupMonsterRecord> runtimeMonsters = _runtimeMonsters;
-	const Common::Array<StartupTimerRecord> runtimeTimers = _runtimeTimers;
+	const Common::Array<FlagRecord> runtimeFlags = _runtimeFlags;
+	const Common::Array<ObjectRecord> runtimeObjects = _runtimeObjects;
+	const Common::Array<AnimRecord> runtimeAnimations = _runtimeAnimations;
+	const Common::Array<RegionRecord> runtimeRegions = _runtimeRegions;
+	const Common::Array<NpcRecord> runtimeNpcs = _runtimeNpcs;
+	const Common::Array<MonsterRecord> runtimeMonsters = _runtimeMonsters;
+	const Common::Array<TimerRecord> runtimeTimers = _runtimeTimers;
 	const int playerCurrentHitPoints = _playerCurrentHitPoints;
 	const int playerCombatLoadout = _playerCombatLoadout;
 	const bool playerControlPaused = _playerControlPaused;
@@ -581,8 +581,8 @@ bool Script::reloadTownWorld(ResourceManager &resources) {
 	parseTownRecords(resources);
 	resetRuntimeState();
 
-	for (const StartupFlagRecord &flag : runtimeFlags) {
-		StartupFlagRecord *runtimeFlag = findRuntimeFlag(flag.name);
+	for (const FlagRecord &flag : runtimeFlags) {
+		FlagRecord *runtimeFlag = findRuntimeFlag(flag.name);
 		if (runtimeFlag) {
 			runtimeFlag->value = flag.value;
 			continue;
@@ -591,8 +591,8 @@ bool Script::reloadTownWorld(ResourceManager &resources) {
 		_runtimeFlags.push_back(flag);
 	}
 
-	for (const StartupObjectRecord &object : runtimeObjects) {
-		StartupObjectRecord *runtimeObject = findRuntimeObject(object.initialOwnerOrRoom, object.objectName);
+	for (const ObjectRecord &object : runtimeObjects) {
+		ObjectRecord *runtimeObject = findRuntimeObject(object.initialOwnerOrRoom, object.objectName);
 		if (!runtimeObject)
 			runtimeObject = findRuntimeObject(Common::String(), object.objectName);
 		if (!runtimeObject)
@@ -607,8 +607,8 @@ bool Script::reloadTownWorld(ResourceManager &resources) {
 		runtimeObject->identShown = object.identShown;
 	}
 
-	for (const StartupAnimRecord &anim : runtimeAnimations) {
-		StartupAnimRecord *runtimeAnim = findRuntimeAnim(anim.animName);
+	for (const AnimRecord &anim : runtimeAnimations) {
+		AnimRecord *runtimeAnim = findRuntimeAnim(anim.animName);
 		if (!runtimeAnim)
 			continue;
 
@@ -619,16 +619,16 @@ bool Script::reloadTownWorld(ResourceManager &resources) {
 		runtimeAnim->runtimeState = anim.runtimeState;
 	}
 
-	for (const StartupRegionRecord &region : runtimeRegions) {
-		StartupRegionRecord *runtimeRegion = findRuntimeRegion(region.regionName);
+	for (const RegionRecord &region : runtimeRegions) {
+		RegionRecord *runtimeRegion = findRuntimeRegion(region.regionName);
 		if (!runtimeRegion)
 			continue;
 
 		runtimeRegion->startEnabled = region.startEnabled;
 	}
 
-	for (const StartupNpcRecord &npc : runtimeNpcs) {
-		StartupNpcRecord *runtimeNpc = findRuntimeNpc(npc.npcName);
+	for (const NpcRecord &npc : runtimeNpcs) {
+		NpcRecord *runtimeNpc = findRuntimeNpc(npc.npcName);
 		if (!runtimeNpc)
 			continue;
 
@@ -640,8 +640,8 @@ bool Script::reloadTownWorld(ResourceManager &resources) {
 		runtimeNpc->deathDamageType = npc.deathDamageType;
 	}
 
-	for (const StartupMonsterRecord &monster : runtimeMonsters) {
-		StartupMonsterRecord *runtimeMonster = findRuntimeMonster(monster.monsterName);
+	for (const MonsterRecord &monster : runtimeMonsters) {
+		MonsterRecord *runtimeMonster = findRuntimeMonster(monster.monsterName);
 		if (!runtimeMonster)
 			continue;
 
@@ -659,8 +659,8 @@ bool Script::reloadTownWorld(ResourceManager &resources) {
 		runtimeMonster->screenMaxXBound = monster.screenMaxXBound;
 	}
 
-	for (const StartupTimerRecord &timer : runtimeTimers) {
-		StartupTimerRecord *runtimeTimer = findRuntimeTimer(timer.timerName);
+	for (const TimerRecord &timer : runtimeTimers) {
+		TimerRecord *runtimeTimer = findRuntimeTimer(timer.timerName);
 		if (!runtimeTimer)
 			continue;
 
@@ -863,7 +863,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			if (tokens.size() < tagIndex + 3)
 				return;
 
-			StartupFlagRecord flag;
+			FlagRecord flag;
 			flag.name = tokens[tagIndex + 1];
 			flag.value = tokens[tagIndex + 2].equalsIgnoreCase("T");
 			if (!flag.name.empty())
@@ -875,7 +875,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			if (tokens.size() < tagIndex + 6)
 				return;
 
-			StartupCommandRecord command;
+			CommandRecord command;
 			command.triggerTag = tokens[tagIndex + 1];
 			command.opcodeName = tokens[tagIndex + 2];
 			command.arg1 = resources.normalizeResourcePath(tokens[tagIndex + 3]);
@@ -901,7 +901,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			if (tokens.size() < tagIndex + 2)
 				return;
 
-			StartupExecListRecord execList;
+			ExecListRecord execList;
 			execList.listName = tokens[tagIndex + 1];
 			for (uint i = tagIndex + 2; i < tokens.size(); ++i) {
 				if (tokens[i].empty())
@@ -917,7 +917,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			if (tokens.size() < tagIndex + 4)
 				return;
 
-			StartupTextRecord textRecord;
+			TextRecord textRecord;
 			textRecord.key = tokens[tagIndex + 1];
 			textRecord.boxName = tokens[tagIndex + 2];
 			textRecord.value = tokens[tagIndex + 3];
@@ -934,7 +934,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			if (tokens.size() < tagIndex + 3)
 				return;
 
-			StartupHeadRecord head;
+			HeadRecord head;
 			head.headId = tokens[tagIndex + 1];
 			head.portraitPath = resources.normalizeResourcePath(tokens[tagIndex + 2]);
 			if (!head.headId.empty() && !head.portraitPath.empty())
@@ -946,7 +946,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			if (tokens.size() < tagIndex + 5)
 				return;
 
-			StartupUseItemRecord useItem;
+			UseItemRecord useItem;
 			useItem.itemName = tokens[tagIndex + 1];
 			useItem.ownerOrRoom = tokens[tagIndex + 2];
 			useItem.targetName = tokens[tagIndex + 3];
@@ -960,7 +960,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			if (tokens.size() < tagIndex + 4)
 				return;
 
-			StartupEntranceRecord entrance;
+			EntranceRecord entrance;
 			if (tagIndex >= 3) {
 				entrance.posX = parseAsciiIntOrZero(tokens[0]);
 				entrance.posY = parseAsciiIntOrZero(tokens[1]);
@@ -979,7 +979,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			if (tokens.size() < tagIndex + 2)
 				return;
 
-			StartupMapEntranceRecord mapEntrance;
+			MapEntranceRecord mapEntrance;
 			if (tagIndex >= 3) {
 				mapEntrance.mapX = parseAsciiIntOrZero(tokens[0]);
 				mapEntrance.mapY = parseAsciiIntOrZero(tokens[1]);
@@ -995,7 +995,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			if (tokens.size() < tagIndex + 3)
 				return;
 
-			StartupMapLocationRecord mapLocation;
+			MapLocationRecord mapLocation;
 			if (tagIndex >= 7) {
 				mapLocation.minX = parseAsciiIntOrZero(tokens[0]);
 				mapLocation.minY = parseAsciiIntOrZero(tokens[1]);
@@ -1020,7 +1020,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			if (tokens.size() < tagIndex + 10)
 				return;
 
-			StartupRoomRecord room;
+			RoomRecord room;
 			if (tagIndex >= 6) {
 				room.minZ = parseAsciiIntOrZero(tokens[0]);
 				room.maxZ = parseAsciiIntOrZero(tokens[1]);
@@ -1051,7 +1051,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			if (tokens.size() < tagIndex + 10)
 				return;
 
-			StartupNpcRecord npc;
+			NpcRecord npc;
 			if (tagIndex >= 4) {
 				npc.posX = parseAsciiIntOrZero(tokens[0]);
 				npc.posY = parseAsciiIntOrZero(tokens[1]);
@@ -1079,7 +1079,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			if (tokens.size() < tagIndex + 24)
 				return;
 
-			StartupMonsterRecord monster;
+			MonsterRecord monster;
 			if (tagIndex >= 6) {
 				monster.posX = parseAsciiIntOrZero(tokens[0]);
 				monster.posY = parseAsciiIntOrZero(tokens[1]);
@@ -1133,7 +1133,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			if (tokens.size() < tagIndex + 7)
 				return;
 
-			StartupTimerRecord timer;
+			TimerRecord timer;
 			if (tagIndex >= 1) {
 				timer.initialValue = parseAsciiIntOrZero(tokens[0]);
 				timer.currentValue = timer.initialValue;
@@ -1153,7 +1153,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			if (tokens.size() < tagIndex + 7)
 				return;
 
-			StartupRegionRecord region;
+			RegionRecord region;
 			if (tagIndex >= 6) {
 				region.left = parseAsciiIntOrZero(tokens[0]);
 				region.top = parseAsciiIntOrZero(tokens[1]);
@@ -1178,7 +1178,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			if (tokens.size() < tagIndex + 10)
 				return;
 
-			StartupAnimRecord anim;
+			AnimRecord anim;
 			if (tagIndex >= 4) {
 				anim.x = parseAsciiIntOrZero(tokens[0]);
 				anim.y = parseAsciiIntOrZero(tokens[1]);
@@ -1204,7 +1204,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 		if (tokens.size() < tagIndex + 13)
 			return;
 
-		StartupObjectRecord object;
+		ObjectRecord object;
 		if (tagIndex >= 6) {
 			object.initialX = parseAsciiIntOrZero(tokens[0]);
 			object.initialY = parseAsciiIntOrZero(tokens[1]);
@@ -1264,13 +1264,13 @@ void Script::parseTownRecords(ResourceManager &resources) {
 		(uint)_useItems.size(), _path.c_str());
 }
 
-bool Script::resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
+bool Script::resolveRoomSetupState(const Common::String &entranceName, RoomSetupState &state,
 		ResourceManager &resources) {
-	state = StartupRoomSetupState();
+	state = RoomSetupState();
 
-	const StartupEntranceRecord *entrance = findEntranceRecord(entranceName);
+	const EntranceRecord *entrance = findEntranceRecord(entranceName);
 	const Common::String resolvedRoomName = entrance ? entrance->roomName : entranceName;
-	const StartupRoomRecord *room = findRoomRecord(resolvedRoomName);
+	const RoomRecord *room = findRoomRecord(resolvedRoomName);
 	if (!room) {
 		warning("Harvester: unresolved room setup target '%s' (resolved room '%s')",
 			entranceName.c_str(), resolvedRoomName.c_str());
@@ -1293,18 +1293,18 @@ bool Script::resolveRoomSetupState(const Common::String &entranceName, StartupRo
 	return true;
 }
 
-bool Script::resolveRoomSetupStateByRoomName(const Common::String &roomName, StartupRoomSetupState &state,
+bool Script::resolveRoomSetupStateByRoomName(const Common::String &roomName, RoomSetupState &state,
 		ResourceManager &resources) {
-	state = StartupRoomSetupState();
+	state = RoomSetupState();
 
-	const StartupRoomRecord *room = findRoomRecord(roomName);
+	const RoomRecord *room = findRoomRecord(roomName);
 	if (!room) {
 		warning("Harvester: unresolved room setup room '%s'", roomName.c_str());
 		return false;
 	}
 
-	const StartupEntranceRecord *entrance = nullptr;
-	for (const StartupEntranceRecord &candidate : _entrances) {
+	const EntranceRecord *entrance = nullptr;
+	for (const EntranceRecord &candidate : _entrances) {
 		if (!candidate.roomName.equalsIgnoreCase(room->roomName))
 			continue;
 
@@ -1340,7 +1340,7 @@ void Script::resetRuntimeState() {
 	_playerCombatLoadout = kDefaultPlayerCombatLoadout;
 	_playerControlPaused = false;
 
-	for (StartupObjectRecord &object : _runtimeObjects) {
+	for (ObjectRecord &object : _runtimeObjects) {
 		object.currentX = object.initialX;
 		object.currentY = object.initialY;
 		object.currentZ = object.initialZ;
@@ -1349,18 +1349,18 @@ void Script::resetRuntimeState() {
 		object.identShown = object.identTextKey.empty();
 	}
 
-	for (StartupAnimRecord &anim : _runtimeAnimations) {
+	for (AnimRecord &anim : _runtimeAnimations) {
 		anim.runtimeActive = anim.active;
 		anim.runtimeVisible = anim.visible;
 		anim.runtimeState = -1;
 	}
 
-	for (StartupNpcRecord &npc : _runtimeNpcs) {
+	for (NpcRecord &npc : _runtimeNpcs) {
 		npc.runtimeSpawned = false;
 		npc.savedVisible = npc.visible;
 	}
 
-	for (StartupMonsterRecord &monster : _runtimeMonsters) {
+	for (MonsterRecord &monster : _runtimeMonsters) {
 		monster.currentHitPoints = monster.initialHitPoints;
 		monster.runtimeSpawned = false;
 		monster.runtimeState = -1;
@@ -1369,7 +1369,7 @@ void Script::resetRuntimeState() {
 			monster.visible = true;
 	}
 
-	for (StartupTimerRecord &timer : _runtimeTimers)
+	for (TimerRecord &timer : _runtimeTimers)
 		timer.currentValue = timer.initialValue;
 }
 
@@ -1382,9 +1382,9 @@ void Script::logRuntimeSaveState(const char *operation) const {
 		(uint)_runtimeTimers.size(), _playerCurrentHitPoints, _playerCombatLoadout,
 		_playerControlPaused);
 
-	for (const StartupFlagRecord &flag : _runtimeFlags) {
-		const StartupFlagRecord *baseFlag = nullptr;
-		for (const StartupFlagRecord &candidate : _flags) {
+	for (const FlagRecord &flag : _runtimeFlags) {
+		const FlagRecord *baseFlag = nullptr;
+		for (const FlagRecord &candidate : _flags) {
 			if (candidate.name.equalsIgnoreCase(flag.name)) {
 				baseFlag = &candidate;
 				break;
@@ -1398,9 +1398,9 @@ void Script::logRuntimeSaveState(const char *operation) const {
 		}
 	}
 
-	for (const StartupObjectRecord &object : _runtimeObjects) {
-		const StartupObjectRecord *baseObject = nullptr;
-		for (const StartupObjectRecord &candidate : _objects) {
+	for (const ObjectRecord &object : _runtimeObjects) {
+		const ObjectRecord *baseObject = nullptr;
+		for (const ObjectRecord &candidate : _objects) {
 			if (candidate.objectName.equalsIgnoreCase(object.objectName)) {
 				baseObject = &candidate;
 				break;
@@ -1432,9 +1432,9 @@ void Script::logRuntimeSaveState(const char *operation) const {
 		}
 	}
 
-	for (const StartupNpcRecord &npc : _runtimeNpcs) {
-		const StartupNpcRecord *baseNpc = nullptr;
-		for (const StartupNpcRecord &candidate : _npcs) {
+	for (const NpcRecord &npc : _runtimeNpcs) {
+		const NpcRecord *baseNpc = nullptr;
+		for (const NpcRecord &candidate : _npcs) {
 			if (candidate.npcName.equalsIgnoreCase(npc.npcName)) {
 				baseNpc = &candidate;
 				break;
@@ -1462,9 +1462,9 @@ void Script::logRuntimeSaveState(const char *operation) const {
 		}
 	}
 
-	for (const StartupMonsterRecord &monster : _runtimeMonsters) {
-		const StartupMonsterRecord *baseMonster = nullptr;
-		for (const StartupMonsterRecord &candidate : _monsters) {
+	for (const MonsterRecord &monster : _runtimeMonsters) {
+		const MonsterRecord *baseMonster = nullptr;
+		for (const MonsterRecord &candidate : _monsters) {
 			if (candidate.monsterName.equalsIgnoreCase(monster.monsterName)) {
 				baseMonster = &candidate;
 				break;
@@ -1500,9 +1500,9 @@ void Script::logRuntimeSaveState(const char *operation) const {
 		}
 	}
 
-	for (const StartupTimerRecord &timer : _runtimeTimers) {
-		const StartupTimerRecord *baseTimer = nullptr;
-		for (const StartupTimerRecord &candidate : _timers) {
+	for (const TimerRecord &timer : _runtimeTimers) {
+		const TimerRecord *baseTimer = nullptr;
+		for (const TimerRecord &candidate : _timers) {
 			if (candidate.timerName.equalsIgnoreCase(timer.timerName)) {
 				baseTimer = &candidate;
 				break;
@@ -1553,9 +1553,9 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 		// FIXME: Drop this compatibility pass after release; it exists only to repair
 		// pre-release saves written before the monster combat field layout stabilized.
 		if (s.getVersion() >= 2) {
-			for (StartupMonsterRecord &runtimeMonster : _runtimeMonsters) {
-				const StartupMonsterRecord *baseMonster = nullptr;
-				for (const StartupMonsterRecord &monster : _monsters) {
+			for (MonsterRecord &runtimeMonster : _runtimeMonsters) {
+				const MonsterRecord *baseMonster = nullptr;
+				for (const MonsterRecord &monster : _monsters) {
 					if (monster.monsterName.equalsIgnoreCase(runtimeMonster.monsterName)) {
 						baseMonster = &monster;
 						break;
@@ -1573,9 +1573,9 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 		// always serialize timer state and the full monster combat payload.
 		if (s.getVersion() < 2) {
 			_runtimeTimers = _timers;
-			for (StartupMonsterRecord &runtimeMonster : _runtimeMonsters) {
-				const StartupMonsterRecord *baseMonster = nullptr;
-				for (const StartupMonsterRecord &monster : _monsters) {
+			for (MonsterRecord &runtimeMonster : _runtimeMonsters) {
+				const MonsterRecord *baseMonster = nullptr;
+				for (const MonsterRecord &monster : _monsters) {
 					if (monster.monsterName.equalsIgnoreCase(runtimeMonster.monsterName)) {
 						baseMonster = &monster;
 						break;
@@ -1622,17 +1622,17 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 }
 
 bool Script::materializeRoomState(const Common::String &entranceName, const Common::String &roomName,
-		StartupRoomSetupState &state, ResourceManager &resources) const {
-	state = StartupRoomSetupState();
+		RoomSetupState &state, ResourceManager &resources) const {
+	state = RoomSetupState();
 
-	const StartupEntranceRecord *entrance = findEntranceRecord(entranceName);
+	const EntranceRecord *entrance = findEntranceRecord(entranceName);
 	const Common::String resolvedRoomName = !roomName.empty()
 		? roomName
 		: (entrance ? entrance->roomName : Common::String());
 	if (resolvedRoomName.empty())
 		return false;
 
-	const StartupRoomRecord *room = findRoomRecord(resolvedRoomName);
+	const RoomRecord *room = findRoomRecord(resolvedRoomName);
 	if (!room) {
 		warning("Harvester: unresolved materialized room target entrance='%s' room='%s'",
 			entranceName.c_str(), resolvedRoomName.c_str());
@@ -1643,10 +1643,10 @@ bool Script::materializeRoomState(const Common::String &entranceName, const Comm
 }
 
 bool Script::executeRoomEnterCommands(const Common::String &roomName,
-		StartupInteractionResult &result) {
-	result = StartupInteractionResult();
+		InteractionResult &result) {
+	result = InteractionResult();
 
-	const StartupRoomRecord *room = findRoomRecord(roomName);
+	const RoomRecord *room = findRoomRecord(roomName);
 	if (!room)
 		return false;
 
@@ -1660,10 +1660,10 @@ bool Script::executeRoomEnterCommands(const Common::String &roomName,
 }
 
 bool Script::executeRoomExitCommands(const Common::String &roomName,
-		StartupInteractionResult &result) {
-	result = StartupInteractionResult();
+		InteractionResult &result) {
+	result = InteractionResult();
 
-	const StartupRoomRecord *room = findRoomRecord(roomName);
+	const RoomRecord *room = findRoomRecord(roomName);
 	if (!room)
 		return false;
 
@@ -1676,11 +1676,11 @@ bool Script::executeRoomExitCommands(const Common::String &roomName,
 	return true;
 }
 
-bool Script::resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result) {
-	result = StartupInteractionResult();
+bool Script::resolveObjectInteraction(const ObjectRecord &object, InteractionResult &result) {
+	result = InteractionResult();
 
 	if (isPickupObject(object)) {
-		if (StartupObjectRecord *runtimeObject = findRuntimeObject(object.currentOwnerOrRoom, object.objectName)) {
+		if (ObjectRecord *runtimeObject = findRuntimeObject(object.currentOwnerOrRoom, object.objectName)) {
 			if (!runtimeObject->currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)) {
 				runtimeObject->currentOwnerOrRoom = kInventoryOwnerName;
 				runtimeObject->identShown = true;
@@ -1706,8 +1706,8 @@ bool Script::resolveObjectInteraction(const StartupObjectRecord &object, Startup
 		result.mutatedRuntimeState || hasActionableCommandChain(object.actionTag);
 }
 
-bool Script::resolveRegionInteraction(const StartupRegionRecord &region, StartupInteractionResult &result) {
-	result = StartupInteractionResult();
+bool Script::resolveRegionInteraction(const RegionRecord &region, InteractionResult &result) {
+	result = InteractionResult();
 	if (region.actionTag.empty())
 		return false;
 
@@ -1725,11 +1725,11 @@ bool Script::resolveRegionInteraction(const StartupRegionRecord &region, Startup
 		result.mutatedRuntimeState || hasActionableCommandChain(region.actionTag);
 }
 
-bool Script::resolveUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target,
-		StartupInteractionResult &result) {
-	result = StartupInteractionResult();
+bool Script::resolveUseItemInteraction(const Common::String &itemName, const ObjectRecord &target,
+		InteractionResult &result) {
+	result = InteractionResult();
 
-	const StartupUseItemRecord *useItem = findUseItemRecord(itemName, target);
+	const UseItemRecord *useItem = findUseItemRecord(itemName, target);
 	if (!useItem)
 		return false;
 
@@ -1743,9 +1743,9 @@ bool Script::resolveUseItemInteraction(const Common::String &itemName, const Sta
 	return true;
 }
 
-bool Script::executeDebugCommand(const StartupCommandRecord &command,
-		StartupInteractionResult &result, bool allowTransitions) {
-	result = StartupInteractionResult();
+bool Script::executeDebugCommand(const CommandRecord &command,
+		InteractionResult &result, bool allowTransitions) {
+	result = InteractionResult();
 	if (command.opcodeName.empty())
 		return false;
 
@@ -1758,7 +1758,7 @@ bool Script::executeDebugCommand(const StartupCommandRecord &command,
 			break;
 	}
 
-	StartupCommandRecord debugCommand = command;
+	CommandRecord debugCommand = command;
 	debugCommand.triggerTag = debugTag;
 	_commands.push_back(debugCommand);
 	const bool handled = executeActionTag(debugTag, result, allowTransitions);
@@ -1766,9 +1766,9 @@ bool Script::executeDebugCommand(const StartupCommandRecord &command,
 	return handled;
 }
 
-bool Script::executeActionTag(const Common::String &tag, StartupInteractionResult &result,
+bool Script::executeActionTag(const Common::String &tag, InteractionResult &result,
 		bool allowTransitions) {
-	result = StartupInteractionResult();
+	result = InteractionResult();
 	if (tag.empty())
 		return false;
 
@@ -1787,13 +1787,13 @@ bool Script::executeActionTag(const Common::String &tag, StartupInteractionResul
 		result.mutatedRuntimeState || hasActionableCommandChain(tag);
 }
 
-bool Script::executeTimerAction(const Common::String &timerName, StartupInteractionResult &result,
+bool Script::executeTimerAction(const Common::String &timerName, InteractionResult &result,
 		bool allowTransitions) {
-	result = StartupInteractionResult();
+	result = InteractionResult();
 	if (timerName.empty())
 		return false;
 
-	const StartupTimerRecord *timer = findRuntimeTimer(timerName);
+	const TimerRecord *timer = findRuntimeTimer(timerName);
 	if (!timer) {
 		debug(1, "Harvester: unresolved timer record '%s'", timerName.c_str());
 		return false;
@@ -1818,7 +1818,7 @@ bool Script::executeTimerAction(const Common::String &timerName, StartupInteract
 		result.mutatedRuntimeState || hasActionableCommandChain(timer->arg2);
 }
 
-bool Script::executeNestedActionTag(const Common::String &tag, StartupInteractionResult &result,
+bool Script::executeNestedActionTag(const Common::String &tag, InteractionResult &result,
 		bool allowTransitions) {
 	const bool handled = executeActionTag(tag, result, allowTransitions);
 	if (handled)
@@ -1827,26 +1827,26 @@ bool Script::executeNestedActionTag(const Common::String &tag, StartupInteractio
 	return handled;
 }
 
-bool Script::isPickupObject(const StartupObjectRecord &object) const {
+bool Script::isPickupObject(const ObjectRecord &object) const {
 	return !object.altSpritePath.empty() &&
 		!object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName);
 }
 
-bool Script::isPickupBlockedByAction(const StartupObjectRecord &object,
-		StartupInteractionResult *result) const {
+bool Script::isPickupBlockedByAction(const ObjectRecord &object,
+		InteractionResult *result) const {
 	if (result)
-		*result = StartupInteractionResult();
+		*result = InteractionResult();
 	if (!isPickupObject(object) || object.actionTag.empty())
 		return false;
 	if (!result) {
-		StartupInteractionResult ignoredResult;
+		InteractionResult ignoredResult;
 		return probePickupBlockingCommandChain(object.actionTag, object.objectName, ignoredResult, 0);
 	}
 
 	return probePickupBlockingCommandChain(object.actionTag, object.objectName, *result, 0);
 }
 
-bool Script::hasObjectInteraction(const StartupObjectRecord &object) const {
+bool Script::hasObjectInteraction(const ObjectRecord &object) const {
 	if (isPickupObject(object))
 		return true;
 	if (object.actionTag.empty())
@@ -1855,16 +1855,16 @@ bool Script::hasObjectInteraction(const StartupObjectRecord &object) const {
 	return hasActionableCommandChain(object.actionTag);
 }
 
-bool Script::hasUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target) const {
+bool Script::hasUseItemInteraction(const Common::String &itemName, const ObjectRecord &target) const {
 	return findUseItemRecord(itemName, target) != nullptr;
 }
 
-void Script::getVisibleInventoryObjects(Common::Array<StartupObjectRecord> &objects) const {
+void Script::getVisibleInventoryObjects(Common::Array<ObjectRecord> &objects) const {
 	objects.clear();
 	const char *const statusObjectName = resolveInventoryStatusObjectName(_playerCurrentHitPoints);
-	const StartupObjectRecord *statusObject = nullptr;
+	const ObjectRecord *statusObject = nullptr;
 
-	for (const StartupObjectRecord &object : _runtimeObjects) {
+	for (const ObjectRecord &object : _runtimeObjects) {
 		if (object.objectName.hasPrefixIgnoreCase("INV_STAT")) {
 			if (object.objectName.equalsIgnoreCase(statusObjectName))
 				statusObject = &object;
@@ -1878,7 +1878,7 @@ void Script::getVisibleInventoryObjects(Common::Array<StartupObjectRecord> &obje
 	if (!statusObject)
 		return;
 
-	StartupObjectRecord activeStatusObject = *statusObject;
+	ObjectRecord activeStatusObject = *statusObject;
 	activeStatusObject.currentOwnerOrRoom = kInventoryOwnerName;
 	activeStatusObject.visible = true;
 	activeStatusObject.runtimeVisible = true;
@@ -1890,7 +1890,7 @@ bool Script::isObjectInInventory(const Common::String &objectName) const {
 	if (objectName.empty())
 		return false;
 
-	for (const StartupObjectRecord &object : _runtimeObjects) {
+	for (const ObjectRecord &object : _runtimeObjects) {
 		if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName) &&
 				object.objectName.equalsIgnoreCase(objectName))
 			return true;
@@ -1899,16 +1899,16 @@ bool Script::isObjectInInventory(const Common::String &objectName) const {
 	return false;
 }
 
-void Script::markObjectIdentShown(const StartupObjectRecord &object) {
-	if (StartupObjectRecord *runtimeObject = findRuntimeObject(object.currentOwnerOrRoom, object.objectName))
+void Script::markObjectIdentShown(const ObjectRecord &object) {
+	if (ObjectRecord *runtimeObject = findRuntimeObject(object.currentOwnerOrRoom, object.objectName))
 		runtimeObject->identShown = true;
 }
 
-const StartupEntranceRecord *Script::findEntranceRecord(const Common::String &entranceName) const {
+const EntranceRecord *Script::findEntranceRecord(const Common::String &entranceName) const {
 	if (entranceName.empty())
 		return nullptr;
 
-	for (const StartupEntranceRecord &entrance : _entrances) {
+	for (const EntranceRecord &entrance : _entrances) {
 		if (entrance.entranceName.equalsIgnoreCase(entranceName))
 			return &entrance;
 	}
@@ -1916,11 +1916,11 @@ const StartupEntranceRecord *Script::findEntranceRecord(const Common::String &en
 	return nullptr;
 }
 
-const StartupMapEntranceRecord *Script::findMapEntranceRecord(const Common::String &entryName) const {
+const MapEntranceRecord *Script::findMapEntranceRecord(const Common::String &entryName) const {
 	if (entryName.empty())
 		return nullptr;
 
-	for (const StartupMapEntranceRecord &mapEntrance : _mapEntrances) {
+	for (const MapEntranceRecord &mapEntrance : _mapEntrances) {
 		if (mapEntrance.entryName.equalsIgnoreCase(entryName))
 			return &mapEntrance;
 	}
@@ -1928,12 +1928,12 @@ const StartupMapEntranceRecord *Script::findMapEntranceRecord(const Common::Stri
 	return nullptr;
 }
 
-const StartupUseItemRecord *Script::findUseItemRecord(const Common::String &itemName,
-		const StartupObjectRecord &target) const {
+const UseItemRecord *Script::findUseItemRecord(const Common::String &itemName,
+		const ObjectRecord &target) const {
 	if (itemName.empty() || target.objectName.empty())
 		return nullptr;
 
-	for (const StartupUseItemRecord &useItem : _useItems) {
+	for (const UseItemRecord &useItem : _useItems) {
 		if (!useItem.itemName.equalsIgnoreCase(itemName) ||
 			!useItem.targetName.equalsIgnoreCase(target.objectName)) {
 			continue;
@@ -1950,11 +1950,11 @@ const StartupUseItemRecord *Script::findUseItemRecord(const Common::String &item
 	return nullptr;
 }
 
-const StartupRoomRecord *Script::findRoomRecord(const Common::String &roomName) const {
+const RoomRecord *Script::findRoomRecord(const Common::String &roomName) const {
 	if (roomName.empty())
 		return nullptr;
 
-	for (const StartupRoomRecord &room : _rooms) {
+	for (const RoomRecord &room : _rooms) {
 		if (room.roomName.equalsIgnoreCase(roomName))
 			return &room;
 	}
@@ -1962,11 +1962,11 @@ const StartupRoomRecord *Script::findRoomRecord(const Common::String &roomName)
 	return nullptr;
 }
 
-const StartupCommandRecord *Script::findCommandRecord(const Common::String &tag) const {
+const CommandRecord *Script::findCommandRecord(const Common::String &tag) const {
 	if (tag.empty())
 		return nullptr;
 
-	for (const StartupCommandRecord &command : _commands) {
+	for (const CommandRecord &command : _commands) {
 		if (command.triggerTag.equalsIgnoreCase(tag))
 			return &command;
 	}
@@ -1974,11 +1974,11 @@ const StartupCommandRecord *Script::findCommandRecord(const Common::String &tag)
 	return nullptr;
 }
 
-const StartupExecListRecord *Script::findExecListRecord(const Common::String &name) const {
+const ExecListRecord *Script::findExecListRecord(const Common::String &name) const {
 	if (name.empty())
 		return nullptr;
 
-	for (const StartupExecListRecord &execList : _execLists) {
+	for (const ExecListRecord &execList : _execLists) {
 		if (execList.listName.equalsIgnoreCase(name))
 			return &execList;
 	}
@@ -1986,11 +1986,11 @@ const StartupExecListRecord *Script::findExecListRecord(const Common::String &na
 	return nullptr;
 }
 
-const StartupFlagRecord *Script::findRuntimeFlag(const Common::String &flagName) const {
+const FlagRecord *Script::findRuntimeFlag(const Common::String &flagName) const {
 	if (flagName.empty())
 		return nullptr;
 
-	for (const StartupFlagRecord &flag : _runtimeFlags) {
+	for (const FlagRecord &flag : _runtimeFlags) {
 		if (flag.name.equalsIgnoreCase(flagName))
 			return &flag;
 	}
@@ -1998,11 +1998,11 @@ const StartupFlagRecord *Script::findRuntimeFlag(const Common::String &flagName)
 	return nullptr;
 }
 
-StartupFlagRecord *Script::findRuntimeFlag(const Common::String &flagName) {
+FlagRecord *Script::findRuntimeFlag(const Common::String &flagName) {
 	if (flagName.empty())
 		return nullptr;
 
-	for (StartupFlagRecord &flag : _runtimeFlags) {
+	for (FlagRecord &flag : _runtimeFlags) {
 		if (flag.name.equalsIgnoreCase(flagName))
 			return &flag;
 	}
@@ -2010,13 +2010,13 @@ StartupFlagRecord *Script::findRuntimeFlag(const Common::String &flagName) {
 	return nullptr;
 }
 
-StartupObjectRecord *Script::findRuntimeObject(const Common::String &ownerOrRoom,
+ObjectRecord *Script::findRuntimeObject(const Common::String &ownerOrRoom,
 		const Common::String &objectName) {
 	if (objectName.empty())
 		return nullptr;
 
-	StartupObjectRecord *fallback = nullptr;
-	for (StartupObjectRecord &object : _runtimeObjects) {
+	ObjectRecord *fallback = nullptr;
+	for (ObjectRecord &object : _runtimeObjects) {
 		if (!object.objectName.equalsIgnoreCase(objectName))
 			continue;
 
@@ -2035,11 +2035,11 @@ StartupObjectRecord *Script::findRuntimeObject(const Common::String &ownerOrRoom
 	return fallback;
 }
 
-StartupAnimRecord *Script::findRuntimeAnim(const Common::String &animName) {
+AnimRecord *Script::findRuntimeAnim(const Common::String &animName) {
 	if (animName.empty())
 		return nullptr;
 
-	for (StartupAnimRecord &anim : _runtimeAnimations) {
+	for (AnimRecord &anim : _runtimeAnimations) {
 		if (anim.animName.equalsIgnoreCase(animName))
 			return &anim;
 	}
@@ -2047,11 +2047,11 @@ StartupAnimRecord *Script::findRuntimeAnim(const Common::String &animName) {
 	return nullptr;
 }
 
-StartupRegionRecord *Script::findRuntimeRegion(const Common::String &regionName) {
+RegionRecord *Script::findRuntimeRegion(const Common::String &regionName) {
 	if (regionName.empty())
 		return nullptr;
 
-	for (StartupRegionRecord &region : _runtimeRegions) {
+	for (RegionRecord &region : _runtimeRegions) {
 		if (region.regionName.equalsIgnoreCase(regionName))
 			return ®ion;
 	}
@@ -2059,11 +2059,11 @@ StartupRegionRecord *Script::findRuntimeRegion(const Common::String &regionName)
 	return nullptr;
 }
 
-StartupNpcRecord *Script::findRuntimeNpc(const Common::String &npcName) {
+NpcRecord *Script::findRuntimeNpc(const Common::String &npcName) {
 	if (npcName.empty())
 		return nullptr;
 
-	for (StartupNpcRecord &npc : _runtimeNpcs) {
+	for (NpcRecord &npc : _runtimeNpcs) {
 		if (npc.npcName.equalsIgnoreCase(npcName))
 			return &npc;
 	}
@@ -2071,11 +2071,11 @@ StartupNpcRecord *Script::findRuntimeNpc(const Common::String &npcName) {
 	return nullptr;
 }
 
-StartupMonsterRecord *Script::findRuntimeMonster(const Common::String &monsterName) {
+MonsterRecord *Script::findRuntimeMonster(const Common::String &monsterName) {
 	if (monsterName.empty())
 		return nullptr;
 
-	for (StartupMonsterRecord &monster : _runtimeMonsters) {
+	for (MonsterRecord &monster : _runtimeMonsters) {
 		if (monster.monsterName.equalsIgnoreCase(monsterName))
 			return &monster;
 	}
@@ -2083,11 +2083,11 @@ StartupMonsterRecord *Script::findRuntimeMonster(const Common::String &monsterNa
 	return nullptr;
 }
 
-StartupTimerRecord *Script::findRuntimeTimer(const Common::String &timerName) {
+TimerRecord *Script::findRuntimeTimer(const Common::String &timerName) {
 	if (timerName.empty())
 		return nullptr;
 
-	for (StartupTimerRecord &timer : _runtimeTimers) {
+	for (TimerRecord &timer : _runtimeTimers) {
 		if (timer.timerName.equalsIgnoreCase(timerName))
 			return &timer;
 	}
@@ -2095,11 +2095,11 @@ StartupTimerRecord *Script::findRuntimeTimer(const Common::String &timerName) {
 	return nullptr;
 }
 
-const StartupNpcRecord *Script::findRuntimeNpc(const Common::String &npcName) const {
+const NpcRecord *Script::findRuntimeNpc(const Common::String &npcName) const {
 	if (npcName.empty())
 		return nullptr;
 
-	for (const StartupNpcRecord &npc : _runtimeNpcs) {
+	for (const NpcRecord &npc : _runtimeNpcs) {
 		if (npc.npcName.equalsIgnoreCase(npcName))
 			return &npc;
 	}
@@ -2107,11 +2107,11 @@ const StartupNpcRecord *Script::findRuntimeNpc(const Common::String &npcName) co
 	return nullptr;
 }
 
-const StartupMonsterRecord *Script::findRuntimeMonster(const Common::String &monsterName) const {
+const MonsterRecord *Script::findRuntimeMonster(const Common::String &monsterName) const {
 	if (monsterName.empty())
 		return nullptr;
 
-	for (const StartupMonsterRecord &monster : _runtimeMonsters) {
+	for (const MonsterRecord &monster : _runtimeMonsters) {
 		if (monster.monsterName.equalsIgnoreCase(monsterName))
 			return &monster;
 	}
@@ -2119,11 +2119,11 @@ const StartupMonsterRecord *Script::findRuntimeMonster(const Common::String &mon
 	return nullptr;
 }
 
-const StartupTimerRecord *Script::findRuntimeTimer(const Common::String &timerName) const {
+const TimerRecord *Script::findRuntimeTimer(const Common::String &timerName) const {
 	if (timerName.empty())
 		return nullptr;
 
-	for (const StartupTimerRecord &timer : _runtimeTimers) {
+	for (const TimerRecord &timer : _runtimeTimers) {
 		if (timer.timerName.equalsIgnoreCase(timerName))
 			return &timer;
 	}
@@ -2131,12 +2131,12 @@ const StartupTimerRecord *Script::findRuntimeTimer(const Common::String &timerNa
 	return nullptr;
 }
 
-const StartupNpcRecord *Script::findRuntimeNpcRecord(const Common::String &npcName) const {
+const NpcRecord *Script::findRuntimeNpcRecord(const Common::String &npcName) const {
 	return findRuntimeNpc(npcName);
 }
 
 bool Script::addRuntimeObjectToInventory(const Common::String &objectName) {
-	StartupObjectRecord *runtimeObject = findRuntimeObject(Common::String(), objectName);
+	ObjectRecord *runtimeObject = findRuntimeObject(Common::String(), objectName);
 	if (!runtimeObject)
 		return false;
 
@@ -2149,11 +2149,11 @@ bool Script::addRuntimeObjectToInventory(const Common::String &objectName) {
 	return changed;
 }
 
-bool Script::syncRuntimeObjectRecord(const StartupObjectRecord &object) {
+bool Script::syncRuntimeObjectRecord(const ObjectRecord &object) {
 	if (object.objectName.empty())
 		return false;
 
-	StartupObjectRecord *runtimeObject = findRuntimeObject(Common::String(), object.objectName);
+	ObjectRecord *runtimeObject = findRuntimeObject(Common::String(), object.objectName);
 	if (!runtimeObject)
 		return false;
 
@@ -2173,7 +2173,7 @@ bool Script::setRuntimeObjectVisible(const Common::String &ownerOrRoom,
 	if (objectName.empty())
 		return false;
 
-	StartupObjectRecord *runtimeObject = findRuntimeObject(ownerOrRoom, objectName);
+	ObjectRecord *runtimeObject = findRuntimeObject(ownerOrRoom, objectName);
 	if (!runtimeObject)
 		return false;
 
@@ -2191,7 +2191,7 @@ bool Script::setRuntimeNpcState(const Common::String &npcName, bool active, bool
 	if (npcName.empty())
 		return false;
 
-	StartupNpcRecord *runtimeNpc = findRuntimeNpc(npcName);
+	NpcRecord *runtimeNpc = findRuntimeNpc(npcName);
 	if (!runtimeNpc)
 		return false;
 
@@ -2226,7 +2226,7 @@ bool Script::setPlayerControlPaused(bool paused) {
 }
 
 bool Script::syncRuntimeAnimState(const Common::String &animName, bool active, bool visible, int currentFrame) {
-	StartupAnimRecord *runtimeAnim = findRuntimeAnim(animName);
+	AnimRecord *runtimeAnim = findRuntimeAnim(animName);
 	if (!runtimeAnim)
 		return false;
 
@@ -2243,8 +2243,8 @@ bool Script::syncRuntimeAnimState(const Common::String &animName, bool active, b
 	return changed;
 }
 
-bool Script::syncRuntimeMonsterRecord(const StartupMonsterRecord &monster) {
-	StartupMonsterRecord *runtimeMonster = findRuntimeMonster(monster.monsterName);
+bool Script::syncRuntimeMonsterRecord(const MonsterRecord &monster) {
+	MonsterRecord *runtimeMonster = findRuntimeMonster(monster.monsterName);
 	if (!runtimeMonster)
 		return false;
 
@@ -2252,8 +2252,8 @@ bool Script::syncRuntimeMonsterRecord(const StartupMonsterRecord &monster) {
 	return true;
 }
 
-bool Script::syncRuntimeTimerRecord(const StartupTimerRecord &timer) {
-	StartupTimerRecord *runtimeTimer = findRuntimeTimer(timer.timerName);
+bool Script::syncRuntimeTimerRecord(const TimerRecord &timer) {
+	TimerRecord *runtimeTimer = findRuntimeTimer(timer.timerName);
 	if (!runtimeTimer)
 		return false;
 
@@ -2262,7 +2262,7 @@ bool Script::syncRuntimeTimerRecord(const StartupTimerRecord &timer) {
 }
 
 bool Script::setRuntimeRegionEnabled(const Common::String &regionName, bool enabled) {
-	StartupRegionRecord *runtimeRegion = findRuntimeRegion(regionName);
+	RegionRecord *runtimeRegion = findRuntimeRegion(regionName);
 	if (!runtimeRegion)
 		return false;
 
@@ -2272,7 +2272,7 @@ bool Script::setRuntimeRegionEnabled(const Common::String &regionName, bool enab
 }
 
 bool Script::setRuntimeTimerEnabled(const Common::String &timerName, bool enabled) {
-	StartupTimerRecord *runtimeTimer = findRuntimeTimer(timerName);
+	TimerRecord *runtimeTimer = findRuntimeTimer(timerName);
 	if (!runtimeTimer)
 		return false;
 
@@ -2288,7 +2288,7 @@ bool Script::queueRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int
 	if (npcName.empty())
 		return false;
 
-	StartupNpcRecord *runtimeNpc = findRuntimeNpc(npcName);
+	NpcRecord *runtimeNpc = findRuntimeNpc(npcName);
 	if (!runtimeNpc || runtimeNpc->deathOrMonsterfyFlag)
 		return false;
 
@@ -2303,13 +2303,13 @@ bool Script::finalizeRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, i
 	if (npcName.empty())
 		return false;
 
-	StartupNpcRecord *runtimeNpc = findRuntimeNpc(npcName);
+	NpcRecord *runtimeNpc = findRuntimeNpc(npcName);
 	if (!runtimeNpc)
 		return false;
 
 	bool monsterChanged = false;
 	if (!runtimeNpc->monsterfyTargetName.empty()) {
-		StartupMonsterRecord *runtimeMonster = findRuntimeMonster(runtimeNpc->monsterfyTargetName);
+		MonsterRecord *runtimeMonster = findRuntimeMonster(runtimeNpc->monsterfyTargetName);
 		if (runtimeMonster) {
 			monsterChanged = !runtimeMonster->active || !runtimeMonster->visible;
 			runtimeMonster->active = true;
@@ -2337,14 +2337,14 @@ bool Script::finalizeRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, i
 	return changed || monsterChanged;
 }
 
-bool Script::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupEntranceRecord *entrance,
-		ResourceManager &resources, StartupRoomSetupState &state) const {
-	state = StartupRoomSetupState();
+bool Script::buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord *entrance,
+		ResourceManager &resources, RoomSetupState &state) const {
+	state = RoomSetupState();
 
-	const StartupObjectRecord *background = nullptr;
-	const StartupObjectRecord *backgroundFallback = nullptr;
+	const ObjectRecord *background = nullptr;
+	const ObjectRecord *backgroundFallback = nullptr;
 	bool usedBackgroundFallback = false;
-	for (const StartupObjectRecord &candidate : _runtimeObjects) {
+	for (const ObjectRecord &candidate : _runtimeObjects) {
 		if (!isRoomBackgroundSpriteCandidate(candidate, room))
 			continue;
 
@@ -2390,14 +2390,14 @@ bool Script::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupE
 		state.playerSpawnZ = entrance->posZ;
 		state.playerFacing = entrance->facing;
 	}
-	for (const StartupObjectRecord &object : _runtimeObjects) {
+	for (const ObjectRecord &object : _runtimeObjects) {
 		if (object.currentOwnerOrRoom.equalsIgnoreCase(room.roomName))
 			state.roomObjects.push_back(object);
 	}
 	if (!state.hasEntrance) {
 		static const char *const kCloseupExitObjects[] = { "EXIT_BM", "EXIT_HS" };
 		for (const char *name : kCloseupExitObjects) {
-			for (const StartupObjectRecord &object : _runtimeObjects) {
+			for (const ObjectRecord &object : _runtimeObjects) {
 				if (object.objectName.equalsIgnoreCase(name)) {
 					state.roomObjects.push_back(object);
 					break;
@@ -2405,11 +2405,11 @@ bool Script::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupE
 			}
 		}
 	}
-	for (const StartupAnimRecord &anim : _runtimeAnimations) {
+	for (const AnimRecord &anim : _runtimeAnimations) {
 		if (anim.roomName.equalsIgnoreCase(room.roomName))
 			state.roomAnimations.push_back(anim);
 	}
-	for (const StartupNpcRecord &npc : _runtimeNpcs) {
+	for (const NpcRecord &npc : _runtimeNpcs) {
 		if (!npc.roomName.equalsIgnoreCase(room.roomName) || !npc.visible)
 			continue;
 		if (npc.deathOrMonsterfyFlag) {
@@ -2422,7 +2422,7 @@ bool Script::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupE
 
 		state.roomNpcs.push_back(npc);
 	}
-	for (const StartupMonsterRecord &monster : _runtimeMonsters) {
+	for (const MonsterRecord &monster : _runtimeMonsters) {
 		if (!monster.roomName.equalsIgnoreCase(room.roomName))
 			continue;
 		if (!monster.active && !monster.visible)
@@ -2430,21 +2430,21 @@ bool Script::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupE
 
 		state.roomMonsters.push_back(monster);
 	}
-	for (const StartupTimerRecord &timer : _runtimeTimers) {
+	for (const TimerRecord &timer : _runtimeTimers) {
 		if (timer.arg1.equalsIgnoreCase(room.roomName))
 			state.roomTimers.push_back(timer);
 	}
-	for (const StartupRegionRecord &region : _runtimeRegions) {
+	for (const RegionRecord &region : _runtimeRegions) {
 		if (region.roomName.equalsIgnoreCase(room.roomName) && region.startEnabled)
 			state.roomRegions.push_back(region);
 	}
 
-	const StartupFlagRecord *dayFlag = findRuntimeFlag("DAY_FLAG");
+	const FlagRecord *dayFlag = findRuntimeFlag("DAY_FLAG");
 	state.paletteBrightness = (room.dimmable && (!dayFlag || !dayFlag->value))
 		? kDimmedPaletteBrightness
 		: kDefaultPaletteBrightness;
 
-	for (const StartupObjectRecord &object : state.roomObjects) {
+	for (const ObjectRecord &object : state.roomObjects) {
 		debugC(1, kDebugRoom,
 			"Harvester: materialized room object room='%s' object='%s' owner='%s' visible=%d runtimeVisible=%d sprite='%s' alt='%s' pos=(%d,%d,%d) bounds=(%d,%d)-(%d,%d) action='%s'",
 			state.roomName.c_str(), object.objectName.c_str(), object.currentOwnerOrRoom.c_str(),
@@ -2452,14 +2452,14 @@ bool Script::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupE
 			object.currentX, object.currentY, object.currentZ,
 			object.currentX, object.currentY, object.boundsX2, object.boundsY2, object.actionTag.c_str());
 	}
-	for (const StartupNpcRecord &npc : state.roomNpcs) {
+	for (const NpcRecord &npc : state.roomNpcs) {
 		debugC(1, kDebugRoom,
 			"Harvester: materialized room npc room='%s' npc='%s' visible=%d active=%d pos=(%d,%d,%d) frame_delay=%d model='%s' on_death='%s' audio='%s'",
 			state.roomName.c_str(), npc.npcName.c_str(), npc.visible, npc.active,
 			npc.posX, npc.posY, npc.posZ, npc.frameDelay,
 			npc.modelPath.c_str(), npc.onDeathActionTag.c_str(), npc.audioPath.c_str());
 	}
-	for (const StartupMonsterRecord &monster : state.roomMonsters) {
+	for (const MonsterRecord &monster : state.roomMonsters) {
 		debugC(1, kDebugRoom,
 			"Harvester: materialized room monster room='%s' monster='%s' visible=%d active=%d spawned=%d runtimeState=%d pos=(%d,%d,%d) facing=%d hp=%d/%d damage=%d engage=%d damage_type='%s' model='%s' on_death='%s'",
 			state.roomName.c_str(), monster.monsterName.c_str(), monster.visible, monster.active,
@@ -2469,7 +2469,7 @@ bool Script::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupE
 			monster.damageAmount, monster.engageDistance, Player::describeCombatDamageType(monster.damageType),
 			monster.modelPath.c_str(), monster.onDeathActionTag.c_str());
 	}
-	for (const StartupTimerRecord &timer : state.roomTimers) {
+	for (const TimerRecord &timer : state.roomTimers) {
 		debugC(1, kDebugRoom,
 			"Harvester: materialized room timer room='%s' timer='%s' current=%d initial=%d enabled=%d loop=%d global=%d",
 			state.roomName.c_str(), timer.timerName.c_str(), timer.currentValue, timer.initialValue,
@@ -2490,12 +2490,12 @@ bool Script::buildRuntimeRoomState(const StartupRoomRecord &room, const StartupE
 
 void Script::executeCommandChain(const Common::String &initialTag, const char *contextLabel,
 		const Common::String &contextName, bool allowTransitions, Common::String *musicPath,
-		Common::Array<StartupAudioCommand> *audioCommands, Common::String *nextRoomName,
+		Common::Array<AudioCommand> *audioCommands, Common::String *nextRoomName,
 		StartupRoomTransitionKind *roomTransition,
 		Common::String *cutscenePath, Common::String *deathFlicPath, bool *requestMainMenu,
 		int *cdChangeDisc,
 		Common::String *dialogueNpcName, Common::String *dialogueContinuationTag,
-		Common::String *continuationTag, StartupResolvedText *modalText,
+		Common::String *continuationTag, ResolvedText *modalText,
 		StartupLightingCommand *lightingCommand, bool *requestPlayerGotoXZ,
 		int *playerGotoX, int *playerGotoZ,
 		bool *mutatedRuntimeState) {
@@ -2520,7 +2520,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 
 	Common::String currentTag = initialTag;
 	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
-		const StartupCommandRecord *command = findCommandRecord(currentTag);
+		const CommandRecord *command = findCommandRecord(currentTag);
 		if (!command) {
 			debug(1, "Harvester: unresolved %s tag '%s' for '%s'",
 				contextLabel, currentTag.c_str(), contextName.c_str());
@@ -2533,7 +2533,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			command->arg1.c_str(), command->arg2.c_str(), command->arg3.c_str(), command->arg4.c_str());
 
 		if (command->opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
-			const StartupFlagRecord *flag = findRuntimeFlag(command->arg1);
+			const FlagRecord *flag = findRuntimeFlag(command->arg1);
 			const bool flagValue = flag && flag->value;
 			debugC(1, kDebugScene, "Harvester: %s '%s' flag '%s' -> %d",
 				contextLabel, contextName.c_str(), command->arg1.c_str(), flagValue);
@@ -2550,7 +2550,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 
 		if (command->opcodeName.equalsIgnoreCase("SET_FLAG")) {
 			const bool flagValue = isTruthy(command->arg2);
-			StartupFlagRecord *flag = findRuntimeFlag(command->arg1);
+			FlagRecord *flag = findRuntimeFlag(command->arg1);
 			bool changed = false;
 			bool oldValue = false;
 			const bool existed = flag != nullptr;
@@ -2559,7 +2559,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 				changed = flag->value != flagValue;
 				flag->value = flagValue;
 			} else {
-				StartupFlagRecord newFlag;
+				FlagRecord newFlag;
 				newFlag.name = command->arg1;
 				newFlag.value = flagValue;
 				_runtimeFlags.push_back(newFlag);
@@ -2587,7 +2587,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 				continue;
 			}
 		} else {
-			Common::Array<StartupAudioCommand> ignoredAudioCommands;
+			Common::Array<AudioCommand> ignoredAudioCommands;
 			if (appendStartupAudioCommand(*command, ignoredAudioCommands)) {
 				currentTag = command->arg4;
 				continue;
@@ -2596,7 +2596,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 
 		if (command->opcodeName.equalsIgnoreCase("ADD") ||
 			command->opcodeName.equalsIgnoreCase("DELETE")) {
-			StartupObjectRecord *runtimeObject = findRuntimeObject(command->arg1, command->arg2);
+			ObjectRecord *runtimeObject = findRuntimeObject(command->arg1, command->arg2);
 			if (!runtimeObject) {
 				debug(1, "Harvester: unresolved object for %s '%s' owner='%s' object='%s'",
 					contextLabel, contextName.c_str(), command->arg1.c_str(), command->arg2.c_str());
@@ -2623,7 +2623,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("SET_ANIM")) {
-			StartupAnimRecord *runtimeAnim = findRuntimeAnim(command->arg1);
+			AnimRecord *runtimeAnim = findRuntimeAnim(command->arg1);
 			if (!runtimeAnim) {
 				debug(1, "Harvester: unresolved anim for %s '%s' anim='%s'",
 					contextLabel, contextName.c_str(), command->arg1.c_str());
@@ -2653,7 +2653,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("SET_REGION")) {
-			StartupRegionRecord *runtimeRegion = findRuntimeRegion(command->arg1);
+			RegionRecord *runtimeRegion = findRuntimeRegion(command->arg1);
 			if (!runtimeRegion) {
 				debug(1, "Harvester: unresolved region for %s '%s' region='%s'",
 					contextLabel, contextName.c_str(), command->arg1.c_str());
@@ -2670,7 +2670,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("SET_NPC")) {
-			StartupNpcRecord *runtimeNpc = findRuntimeNpc(command->arg1);
+			NpcRecord *runtimeNpc = findRuntimeNpc(command->arg1);
 			if (!runtimeNpc) {
 				debug(1, "Harvester: unresolved npc for %s '%s' npc='%s'",
 					contextLabel, contextName.c_str(), command->arg1.c_str());
@@ -2689,7 +2689,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("SET_MONSTER")) {
-			StartupMonsterRecord *runtimeMonster = findRuntimeMonster(command->arg1);
+			MonsterRecord *runtimeMonster = findRuntimeMonster(command->arg1);
 			if (!runtimeMonster) {
 				debug(1, "Harvester: unresolved monster for %s '%s' monster='%s'",
 					contextLabel, contextName.c_str(), command->arg1.c_str());
@@ -2730,7 +2730,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("EXEC_LIST")) {
-			const StartupExecListRecord *execList = findExecListRecord(command->arg1);
+			const ExecListRecord *execList = findExecListRecord(command->arg1);
 			if (!execList) {
 				debug(1, "Harvester: unresolved exec list for %s '%s' list='%s'",
 					contextLabel, contextName.c_str(), command->arg1.c_str());
@@ -2800,7 +2800,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 
 		if (command->opcodeName.equalsIgnoreCase("KILL_NPC") ||
 				command->opcodeName.equalsIgnoreCase("MONSTERFY")) {
-			StartupNpcRecord *runtimeNpc = findRuntimeNpc(command->arg1);
+			NpcRecord *runtimeNpc = findRuntimeNpc(command->arg1);
 			if (!runtimeNpc) {
 				debug(1, "Harvester: unresolved npc for %s '%s' npc='%s'",
 					contextLabel, contextName.c_str(), command->arg1.c_str());
@@ -2941,13 +2941,13 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 }
 
 bool Script::probePickupBlockingCommandChain(const Common::String &initialTag,
-		const Common::String &contextName, StartupInteractionResult &result, uint recursionDepth) const {
+		const Common::String &contextName, InteractionResult &result, uint recursionDepth) const {
 	if (initialTag.empty() || recursionDepth > 8)
 		return false;
 
 	Common::String currentTag = initialTag;
 	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
-		const StartupCommandRecord *command = findCommandRecord(currentTag);
+		const CommandRecord *command = findCommandRecord(currentTag);
 		if (!command) {
 			debug(1, "Harvester: unresolved pickup preflight tag '%s' for '%s'",
 				currentTag.c_str(), contextName.c_str());
@@ -2955,7 +2955,7 @@ bool Script::probePickupBlockingCommandChain(const Common::String &initialTag,
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
-			const StartupFlagRecord *flag = findRuntimeFlag(command->arg1);
+			const FlagRecord *flag = findRuntimeFlag(command->arg1);
 			currentTag = (flag && flag->value) ? command->arg2 : command->arg3;
 			continue;
 		}
@@ -2971,7 +2971,7 @@ bool Script::probePickupBlockingCommandChain(const Common::String &initialTag,
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("EXEC_LIST")) {
-			const StartupExecListRecord *execList = findExecListRecord(command->arg1);
+			const ExecListRecord *execList = findExecListRecord(command->arg1);
 			if (!execList)
 				return false;
 
@@ -2982,7 +2982,7 @@ bool Script::probePickupBlockingCommandChain(const Common::String &initialTag,
 			return false;
 		}
 
-		Common::Array<StartupAudioCommand> audioCommands;
+		Common::Array<AudioCommand> audioCommands;
 		if (appendStartupAudioCommand(*command, audioCommands))
 			return false;
 
@@ -3024,14 +3024,14 @@ bool Script::probePickupBlockingCommandChain(const Common::String &initialTag,
 bool Script::hasActionableCommandChain(const Common::String &initialTag) const {
 	Common::String currentTag = initialTag;
 	for (uint step = 0; step < 128 && !currentTag.empty(); ++step) {
-		const StartupCommandRecord *command = findCommandRecord(currentTag);
+		const CommandRecord *command = findCommandRecord(currentTag);
 		if (!command) {
 			debug(1, "Harvester: unresolved interaction probe tag '%s'", currentTag.c_str());
 			break;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("CHECK_FLAG")) {
-			const StartupFlagRecord *flag = findRuntimeFlag(command->arg1);
+			const FlagRecord *flag = findRuntimeFlag(command->arg1);
 			currentTag = (flag && flag->value) ? command->arg2 : command->arg3;
 			continue;
 		}
@@ -3072,7 +3072,7 @@ bool Script::hasActionableCommandChain(const Common::String &initialTag) const {
 			return true;
 		}
 
-		Common::Array<StartupAudioCommand> audioCommands;
+		Common::Array<AudioCommand> audioCommands;
 		if (appendStartupAudioCommand(*command, audioCommands))
 			return true;
 
@@ -3082,11 +3082,11 @@ bool Script::hasActionableCommandChain(const Common::String &initialTag) const {
 	return false;
 }
 
-const StartupTextRecord *Script::findTextRecord(const Common::String &key) const {
+const TextRecord *Script::findTextRecord(const Common::String &key) const {
 	if (key.empty())
 		return nullptr;
 
-	for (const StartupTextRecord &textRecord : _texts) {
+	for (const TextRecord &textRecord : _texts) {
 		if (textRecord.key.equalsIgnoreCase(key))
 			return &textRecord;
 	}
@@ -3094,10 +3094,10 @@ const StartupTextRecord *Script::findTextRecord(const Common::String &key) const
 	return nullptr;
 }
 
-bool Script::resolveObjectInspectText(const StartupObjectRecord &object, StartupResolvedText &text) const {
-	text = StartupResolvedText();
+bool Script::resolveObjectInspectText(const ObjectRecord &object, ResolvedText &text) const {
+	text = ResolvedText();
 
-	const StartupTextRecord *textRecord = findTextRecord(object.identTextKey);
+	const TextRecord *textRecord = findTextRecord(object.identTextKey);
 	if (!textRecord)
 		return false;
 
@@ -3106,10 +3106,10 @@ bool Script::resolveObjectInspectText(const StartupObjectRecord &object, Startup
 	return !text.value.empty();
 }
 
-bool Script::resolveTextRecord(const Common::String &key, StartupResolvedText &text) const {
-	text = StartupResolvedText();
+bool Script::resolveTextRecord(const Common::String &key, ResolvedText &text) const {
+	text = ResolvedText();
 
-	const StartupTextRecord *textRecord = findTextRecord(key);
+	const TextRecord *textRecord = findTextRecord(key);
 	if (!textRecord)
 		return false;
 
@@ -3118,15 +3118,15 @@ bool Script::resolveTextRecord(const Common::String &key, StartupResolvedText &t
 	return !text.value.empty();
 }
 
-Common::String Script::resolveInventoryTooltipText(const StartupObjectRecord &object) const {
-	const StartupTextRecord *textRecord = findTextRecord(object.inventoryTextKey);
+Common::String Script::resolveInventoryTooltipText(const ObjectRecord &object) const {
+	const TextRecord *textRecord = findTextRecord(object.inventoryTextKey);
 	if (textRecord && !textRecord->value.empty())
 		return textRecord->value;
 
 	return Common::String();
 }
 
-Common::String Script::resolveObjectLabel(const StartupObjectRecord &object) const {
+Common::String Script::resolveObjectLabel(const ObjectRecord &object) const {
 	if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)) {
 		const Common::String inventoryLabel = resolveInventoryTooltipText(object);
 		if (!inventoryLabel.empty())
@@ -3138,7 +3138,7 @@ Common::String Script::resolveObjectLabel(const StartupObjectRecord &object) con
 	if (object.interactionLabel.equalsIgnoreCase("NULL_ID"))
 		return Common::String();
 
-	const StartupTextRecord *textRecord = findTextRecord(object.identTextKey);
+	const TextRecord *textRecord = findTextRecord(object.identTextKey);
 	if (textRecord && !textRecord->value.empty())
 		return textRecord->value;
 
@@ -3150,18 +3150,18 @@ Common::String Script::resolveObjectLabel(const StartupObjectRecord &object) con
 }
 
 Common::String Script::resolveTextValue(const Common::String &key) const {
-	const StartupTextRecord *textRecord = findTextRecord(key);
+	const TextRecord *textRecord = findTextRecord(key);
 	if (textRecord && !textRecord->value.empty())
 		return textRecord->value;
 
 	return normalizeInteractionLabel(key);
 }
 
-const StartupHeadRecord *Script::findHeadRecord(const Common::String &headId) const {
+const HeadRecord *Script::findHeadRecord(const Common::String &headId) const {
 	if (headId.empty())
 		return nullptr;
 
-	for (const StartupHeadRecord &head : _heads) {
+	for (const HeadRecord &head : _heads) {
 		if (head.headId.equalsIgnoreCase(headId))
 			return &head;
 	}
@@ -3170,7 +3170,7 @@ const StartupHeadRecord *Script::findHeadRecord(const Common::String &headId) co
 }
 
 bool Script::getFlagValue(const Common::String &flagName) const {
-	const StartupFlagRecord *flag = findRuntimeFlag(flagName);
+	const FlagRecord *flag = findRuntimeFlag(flagName);
 	return flag && flag->value;
 }
 
@@ -3178,7 +3178,7 @@ bool Script::setRuntimeFlagValue(const Common::String &flagName, bool value) {
 	if (flagName.empty())
 		return false;
 
-	StartupFlagRecord *flag = findRuntimeFlag(flagName);
+	FlagRecord *flag = findRuntimeFlag(flagName);
 	if (flag) {
 		const bool changed = flag->value != value;
 		const bool oldValue = flag->value;
@@ -3189,7 +3189,7 @@ bool Script::setRuntimeFlagValue(const Common::String &flagName, bool value) {
 		return changed;
 	}
 
-	StartupFlagRecord newFlag;
+	FlagRecord newFlag;
 	newFlag.name = flagName;
 	newFlag.value = value;
 	_runtimeFlags.push_back(newFlag);
@@ -3203,12 +3203,12 @@ bool Script::resetRuntimeObjectToInitialState(const Common::String &objectName)
 	if (objectName.empty())
 		return false;
 
-	StartupObjectRecord *runtimeObject = findRuntimeObject(Common::String(), objectName);
+	ObjectRecord *runtimeObject = findRuntimeObject(Common::String(), objectName);
 	if (!runtimeObject)
 		return false;
 
-	const StartupObjectRecord *baseObject = nullptr;
-	for (const StartupObjectRecord &object : _objects) {
+	const ObjectRecord *baseObject = nullptr;
+	for (const ObjectRecord &object : _objects) {
 		if (object.objectName.equalsIgnoreCase(objectName)) {
 			baseObject = &object;
 			break;
@@ -3236,7 +3236,7 @@ bool Script::resetRuntimeObjectToInitialState(const Common::String &objectName)
 }
 
 bool Script::isNamedNpcDeathTypeClear(const Common::String &npcName) const {
-	const StartupNpcRecord *npc = findRuntimeNpc(npcName);
+	const NpcRecord *npc = findRuntimeNpc(npcName);
 	return npc && npc->deathDamageType == 0;
 }
 
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index b6e12eed5d2..682af707bb3 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -34,7 +34,7 @@ namespace Harvester {
 
 class ResourceManager;
 
-struct StartupEntranceRecord {
+struct EntranceRecord {
 	int posX = 0;
 	int posY = 0;
 	int posZ = 0;
@@ -44,14 +44,14 @@ struct StartupEntranceRecord {
 	Common::String entranceName;
 };
 
-struct StartupMapEntranceRecord {
+struct MapEntranceRecord {
 	int mapX = 0;
 	int mapY = 0;
 	int initialPanelIndex = 0;
 	Common::String entryName;
 };
 
-struct StartupMapLocationRecord {
+struct MapLocationRecord {
 	int minX = 0;
 	int minY = 0;
 	int maxX = 0;
@@ -63,7 +63,7 @@ struct StartupMapLocationRecord {
 	Common::String destinationEntranceName;
 };
 
-struct StartupRoomRecord {
+struct RoomRecord {
 	int minZ = 0;
 	int maxZ = 0;
 	int maxZScreenY = 0;
@@ -83,7 +83,7 @@ struct StartupRoomRecord {
 	bool dimmable = false;
 };
 
-struct StartupObjectRecord {
+struct ObjectRecord {
 	int initialX = 0;
 	int initialY = 0;
 	int initialZ = 0;
@@ -110,7 +110,7 @@ struct StartupObjectRecord {
 	bool identShown = false;
 };
 
-struct StartupAnimRecord {
+struct AnimRecord {
 	int x = 0;
 	int y = 0;
 	int z = 0;
@@ -129,7 +129,7 @@ struct StartupAnimRecord {
 	int runtimeState = -1;
 };
 
-struct StartupNpcRecord {
+struct NpcRecord {
 	int posX = 0;
 	int posY = 0;
 	int posZ = 0;
@@ -149,7 +149,7 @@ struct StartupNpcRecord {
 	Common::String entityInitArg;
 };
 
-struct StartupTimerRecord {
+struct TimerRecord {
 	int initialValue = 0;
 	int currentValue = 0;
 	Common::String timerName;
@@ -160,7 +160,7 @@ struct StartupTimerRecord {
 	bool global = false;
 };
 
-struct StartupRegionRecord {
+struct RegionRecord {
 	int left = 0;
 	int top = 0;
 	int right = 0;
@@ -176,12 +176,12 @@ struct StartupRegionRecord {
 	bool cursorEnabled = false;
 };
 
-struct StartupFlagRecord {
+struct FlagRecord {
 	Common::String name;
 	bool value = false;
 };
 
-struct StartupCommandRecord {
+struct CommandRecord {
 	Common::String triggerTag;
 	Common::String opcodeName;
 	Common::String arg1;
@@ -190,23 +190,23 @@ struct StartupCommandRecord {
 	Common::String arg4;
 };
 
-struct StartupExecListRecord {
+struct ExecListRecord {
 	Common::String listName;
 	Common::Array<Common::String> entries;
 };
 
-struct StartupTextRecord {
+struct TextRecord {
 	Common::String key;
 	Common::String boxName;
 	Common::String value;
 };
 
-struct StartupHeadRecord {
+struct HeadRecord {
 	Common::String headId;
 	Common::String portraitPath;
 };
 
-struct StartupUseItemRecord {
+struct UseItemRecord {
 	Common::String itemName;
 	Common::String ownerOrRoom;
 	Common::String targetName;
@@ -227,13 +227,13 @@ enum StartupAudioCommandType {
 	kStartupAudioCommandDeleteWav
 };
 
-struct StartupAudioCommand {
+struct AudioCommand {
 	StartupAudioCommandType type = kStartupAudioCommandStartWav;
 	Common::String path;
 	int slot = -1;
 };
 
-struct StartupRoomSetupState {
+struct RoomSetupState {
 	Common::String entranceName;
 	Common::String roomName;
 	Common::String backgroundObjectName;
@@ -254,14 +254,14 @@ struct StartupRoomSetupState {
 	int playerSpawnZ = 0;
 	int playerFacing = -1;
 	float paletteBrightness = 1.0f;
-	Common::Array<StartupObjectRecord> activeObjects;
-	Common::Array<StartupObjectRecord> roomObjects;
-	Common::Array<StartupAnimRecord> roomAnimations;
-	Common::Array<StartupNpcRecord> roomNpcs;
-	Common::Array<StartupMonsterRecord> roomMonsters;
-	Common::Array<StartupTimerRecord> roomTimers;
-	Common::Array<StartupRegionRecord> roomRegions;
-	Common::Array<StartupAudioCommand> audioCommands;
+	Common::Array<ObjectRecord> activeObjects;
+	Common::Array<ObjectRecord> roomObjects;
+	Common::Array<AnimRecord> roomAnimations;
+	Common::Array<NpcRecord> roomNpcs;
+	Common::Array<MonsterRecord> roomMonsters;
+	Common::Array<TimerRecord> roomTimers;
+	Common::Array<RegionRecord> roomRegions;
+	Common::Array<AudioCommand> audioCommands;
 };
 
 enum StartupRoomTransitionKind {
@@ -278,12 +278,12 @@ enum StartupLightingCommand {
 	kStartupLightingCommandFadeIn
 };
 
-struct StartupResolvedText {
+struct ResolvedText {
 	Common::String boxName;
 	Common::String value;
 };
 
-struct StartupInteractionResult {
+struct InteractionResult {
 	Common::String musicPath;
 	Common::String nextRoomName;
 	Common::String cutscenePath;
@@ -291,8 +291,8 @@ struct StartupInteractionResult {
 	Common::String dialogueNpcName;
 	Common::String dialogueContinuationTag;
 	Common::String continuationTag;
-	StartupResolvedText modalText;
-	Common::Array<StartupAudioCommand> audioCommands;
+	ResolvedText modalText;
+	Common::Array<AudioCommand> audioCommands;
 	StartupRoomTransitionKind roomTransition = kStartupRoomTransitionNone;
 	StartupLightingCommand lightingCommand = kStartupLightingCommandNone;
 	int cdChangeDisc = 0;
@@ -315,22 +315,22 @@ public:
 
 	const Common::String &getPath() const { return _path; }
 	const Common::Array<byte> &getData() const { return _data; }
-	const Common::Array<StartupEntranceRecord> &getEntrances() const { return _entrances; }
-	const Common::Array<StartupMapEntranceRecord> &getMapEntrances() const { return _mapEntrances; }
-	const Common::Array<StartupMapLocationRecord> &getMapLocations() const { return _mapLocations; }
-	const Common::Array<StartupRoomRecord> &getRooms() const { return _rooms; }
-	const Common::Array<StartupObjectRecord> &getObjects() const { return _objects; }
-	const Common::Array<StartupAnimRecord> &getAnimations() const { return _animations; }
-	const Common::Array<StartupNpcRecord> &getNpcs() const { return _npcs; }
-	const Common::Array<StartupMonsterRecord> &getMonsters() const { return _monsters; }
-	const Common::Array<StartupTimerRecord> &getTimers() const { return _timers; }
-	const Common::Array<StartupRegionRecord> &getRegions() const { return _regions; }
-	const Common::Array<StartupFlagRecord> &getFlags() const { return _flags; }
-	const Common::Array<StartupCommandRecord> &getCommands() const { return _commands; }
-	const Common::Array<StartupExecListRecord> &getExecLists() const { return _execLists; }
-	const Common::Array<StartupTextRecord> &getTexts() const { return _texts; }
-	const Common::Array<StartupHeadRecord> &getHeads() const { return _heads; }
-	const Common::Array<StartupUseItemRecord> &getUseItems() const { return _useItems; }
+	const Common::Array<EntranceRecord> &getEntrances() const { return _entrances; }
+	const Common::Array<MapEntranceRecord> &getMapEntrances() const { return _mapEntrances; }
+	const Common::Array<MapLocationRecord> &getMapLocations() const { return _mapLocations; }
+	const Common::Array<RoomRecord> &getRooms() const { return _rooms; }
+	const Common::Array<ObjectRecord> &getObjects() const { return _objects; }
+	const Common::Array<AnimRecord> &getAnimations() const { return _animations; }
+	const Common::Array<NpcRecord> &getNpcs() const { return _npcs; }
+	const Common::Array<MonsterRecord> &getMonsters() const { return _monsters; }
+	const Common::Array<TimerRecord> &getTimers() const { return _timers; }
+	const Common::Array<RegionRecord> &getRegions() const { return _regions; }
+	const Common::Array<FlagRecord> &getFlags() const { return _flags; }
+	const Common::Array<CommandRecord> &getCommands() const { return _commands; }
+	const Common::Array<ExecListRecord> &getExecLists() const { return _execLists; }
+	const Common::Array<TextRecord> &getTexts() const { return _texts; }
+	const Common::Array<HeadRecord> &getHeads() const { return _heads; }
+	const Common::Array<UseItemRecord> &getUseItems() const { return _useItems; }
 	bool isQuickTipsEnabled() const { return _quickTipsEnabled; }
 	void setQuickTipsEnabled(bool enabled) { _quickTipsEnabled = enabled; }
 	const Common::String &getVoicePath() const { return _voicePath; }
@@ -349,48 +349,48 @@ public:
 	bool saveConfig() const;
 	void resetRuntimeState();
 	void syncRuntimeSaveState(Common::Serializer &s);
-	bool resolveRoomSetupState(const Common::String &entranceName, StartupRoomSetupState &state,
+	bool resolveRoomSetupState(const Common::String &entranceName, RoomSetupState &state,
 		ResourceManager &resources);
-	bool resolveRoomSetupStateByRoomName(const Common::String &roomName, StartupRoomSetupState &state,
+	bool resolveRoomSetupStateByRoomName(const Common::String &roomName, RoomSetupState &state,
 		ResourceManager &resources);
 	bool materializeRoomState(const Common::String &entranceName, const Common::String &roomName,
-		StartupRoomSetupState &state, ResourceManager &resources) const;
-	bool executeRoomEnterCommands(const Common::String &roomName, StartupInteractionResult &result);
-	bool executeRoomExitCommands(const Common::String &roomName, StartupInteractionResult &result);
-	bool resolveObjectInteraction(const StartupObjectRecord &object, StartupInteractionResult &result);
-	bool resolveRegionInteraction(const StartupRegionRecord &region, StartupInteractionResult &result);
-	bool resolveUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target,
-		StartupInteractionResult &result);
-	bool executeDebugCommand(const StartupCommandRecord &command, StartupInteractionResult &result,
+		RoomSetupState &state, ResourceManager &resources) const;
+	bool executeRoomEnterCommands(const Common::String &roomName, InteractionResult &result);
+	bool executeRoomExitCommands(const Common::String &roomName, InteractionResult &result);
+	bool resolveObjectInteraction(const ObjectRecord &object, InteractionResult &result);
+	bool resolveRegionInteraction(const RegionRecord &region, InteractionResult &result);
+	bool resolveUseItemInteraction(const Common::String &itemName, const ObjectRecord &target,
+		InteractionResult &result);
+	bool executeDebugCommand(const CommandRecord &command, InteractionResult &result,
 		bool allowTransitions = true);
-	bool executeActionTag(const Common::String &tag, StartupInteractionResult &result,
+	bool executeActionTag(const Common::String &tag, InteractionResult &result,
 		bool allowTransitions = true);
-	bool executeTimerAction(const Common::String &timerName, StartupInteractionResult &result,
+	bool executeTimerAction(const Common::String &timerName, InteractionResult &result,
 		bool allowTransitions = true);
-	bool executeNestedActionTag(const Common::String &tag, StartupInteractionResult &result,
+	bool executeNestedActionTag(const Common::String &tag, InteractionResult &result,
 		bool allowTransitions = true);
-	bool isPickupObject(const StartupObjectRecord &object) const;
-	bool isPickupBlockedByAction(const StartupObjectRecord &object,
-		StartupInteractionResult *result = nullptr) const;
-	bool hasObjectInteraction(const StartupObjectRecord &object) const;
-	bool hasUseItemInteraction(const Common::String &itemName, const StartupObjectRecord &target) const;
-	void getVisibleInventoryObjects(Common::Array<StartupObjectRecord> &objects) const;
+	bool isPickupObject(const ObjectRecord &object) const;
+	bool isPickupBlockedByAction(const ObjectRecord &object,
+		InteractionResult *result = nullptr) const;
+	bool hasObjectInteraction(const ObjectRecord &object) const;
+	bool hasUseItemInteraction(const Common::String &itemName, const ObjectRecord &target) const;
+	void getVisibleInventoryObjects(Common::Array<ObjectRecord> &objects) const;
 	bool isObjectInInventory(const Common::String &objectName) const;
-	void markObjectIdentShown(const StartupObjectRecord &object);
-	bool resolveObjectInspectText(const StartupObjectRecord &object, StartupResolvedText &text) const;
-	bool resolveTextRecord(const Common::String &key, StartupResolvedText &text) const;
-	Common::String resolveInventoryTooltipText(const StartupObjectRecord &object) const;
-	Common::String resolveObjectLabel(const StartupObjectRecord &object) const;
+	void markObjectIdentShown(const ObjectRecord &object);
+	bool resolveObjectInspectText(const ObjectRecord &object, ResolvedText &text) const;
+	bool resolveTextRecord(const Common::String &key, ResolvedText &text) const;
+	Common::String resolveInventoryTooltipText(const ObjectRecord &object) const;
+	Common::String resolveObjectLabel(const ObjectRecord &object) const;
 	Common::String resolveTextValue(const Common::String &key) const;
-	const StartupHeadRecord *findHeadRecord(const Common::String &headId) const;
-	const StartupEntranceRecord *findEntranceRecord(const Common::String &entranceName) const;
-	const StartupMapEntranceRecord *findMapEntranceRecord(const Common::String &entryName) const;
+	const HeadRecord *findHeadRecord(const Common::String &headId) const;
+	const EntranceRecord *findEntranceRecord(const Common::String &entranceName) const;
+	const MapEntranceRecord *findMapEntranceRecord(const Common::String &entryName) const;
 	bool getFlagValue(const Common::String &flagName) const;
-	const StartupNpcRecord *findRuntimeNpcRecord(const Common::String &npcName) const;
+	const NpcRecord *findRuntimeNpcRecord(const Common::String &npcName) const;
 	bool setRuntimeFlagValue(const Common::String &flagName, bool value);
 	bool resetRuntimeObjectToInitialState(const Common::String &objectName);
 	bool addRuntimeObjectToInventory(const Common::String &objectName);
-	bool syncRuntimeObjectRecord(const StartupObjectRecord &object);
+	bool syncRuntimeObjectRecord(const ObjectRecord &object);
 	bool setRuntimeObjectVisible(const Common::String &ownerOrRoom, const Common::String &objectName,
 		bool visible);
 	bool setRuntimeNpcState(const Common::String &npcName, bool active, bool visible);
@@ -405,8 +405,8 @@ public:
 	bool setPlayerCombatLoadout(int loadout);
 	bool setPlayerControlPaused(bool paused);
 	bool syncRuntimeAnimState(const Common::String &animName, bool active, bool visible, int currentFrame);
-	bool syncRuntimeMonsterRecord(const StartupMonsterRecord &monster);
-	bool syncRuntimeTimerRecord(const StartupTimerRecord &timer);
+	bool syncRuntimeMonsterRecord(const MonsterRecord &monster);
+	bool syncRuntimeTimerRecord(const TimerRecord &timer);
 	bool setRuntimeRegionEnabled(const Common::String &regionName, bool enabled);
 	bool setRuntimeTimerEnabled(const Common::String &timerName, bool enabled);
 
@@ -414,67 +414,67 @@ private:
 	bool loadConfig(ResourceManager &resources);
 	void decode();
 	void parseTownRecords(ResourceManager &resources);
-	const StartupRoomRecord *findRoomRecord(const Common::String &roomName) const;
-	const StartupCommandRecord *findCommandRecord(const Common::String &tag) const;
-	const StartupExecListRecord *findExecListRecord(const Common::String &name) const;
-	const StartupTextRecord *findTextRecord(const Common::String &key) const;
-	const StartupUseItemRecord *findUseItemRecord(const Common::String &itemName,
-		const StartupObjectRecord &target) const;
-	const StartupFlagRecord *findRuntimeFlag(const Common::String &flagName) const;
-	StartupFlagRecord *findRuntimeFlag(const Common::String &flagName);
-	StartupObjectRecord *findRuntimeObject(const Common::String &ownerOrRoom, const Common::String &objectName);
-	StartupAnimRecord *findRuntimeAnim(const Common::String &animName);
-	StartupRegionRecord *findRuntimeRegion(const Common::String &regionName);
-	StartupNpcRecord *findRuntimeNpc(const Common::String &npcName);
-	const StartupNpcRecord *findRuntimeNpc(const Common::String &npcName) const;
-	StartupMonsterRecord *findRuntimeMonster(const Common::String &monsterName);
-	const StartupMonsterRecord *findRuntimeMonster(const Common::String &monsterName) const;
-	StartupTimerRecord *findRuntimeTimer(const Common::String &timerName);
-	const StartupTimerRecord *findRuntimeTimer(const Common::String &timerName) const;
-	bool buildRuntimeRoomState(const StartupRoomRecord &room, const StartupEntranceRecord *entrance,
-		ResourceManager &resources, StartupRoomSetupState &state) const;
+	const RoomRecord *findRoomRecord(const Common::String &roomName) const;
+	const CommandRecord *findCommandRecord(const Common::String &tag) const;
+	const ExecListRecord *findExecListRecord(const Common::String &name) const;
+	const TextRecord *findTextRecord(const Common::String &key) const;
+	const UseItemRecord *findUseItemRecord(const Common::String &itemName,
+		const ObjectRecord &target) const;
+	const FlagRecord *findRuntimeFlag(const Common::String &flagName) const;
+	FlagRecord *findRuntimeFlag(const Common::String &flagName);
+	ObjectRecord *findRuntimeObject(const Common::String &ownerOrRoom, const Common::String &objectName);
+	AnimRecord *findRuntimeAnim(const Common::String &animName);
+	RegionRecord *findRuntimeRegion(const Common::String &regionName);
+	NpcRecord *findRuntimeNpc(const Common::String &npcName);
+	const NpcRecord *findRuntimeNpc(const Common::String &npcName) const;
+	MonsterRecord *findRuntimeMonster(const Common::String &monsterName);
+	const MonsterRecord *findRuntimeMonster(const Common::String &monsterName) const;
+	TimerRecord *findRuntimeTimer(const Common::String &timerName);
+	const TimerRecord *findRuntimeTimer(const Common::String &timerName) const;
+	bool buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord *entrance,
+		ResourceManager &resources, RoomSetupState &state) const;
 	void executeCommandChain(const Common::String &initialTag, const char *contextLabel,
 		const Common::String &contextName, bool allowTransitions, Common::String *musicPath,
-		Common::Array<StartupAudioCommand> *audioCommands, Common::String *nextRoomName,
+		Common::Array<AudioCommand> *audioCommands, Common::String *nextRoomName,
 		StartupRoomTransitionKind *roomTransition,
 		Common::String *cutscenePath, Common::String *deathFlicPath, bool *requestMainMenu,
 		int *cdChangeDisc,
 		Common::String *dialogueNpcName, Common::String *dialogueContinuationTag,
-		Common::String *continuationTag, StartupResolvedText *modalText,
+		Common::String *continuationTag, ResolvedText *modalText,
 		StartupLightingCommand *lightingCommand, bool *requestPlayerGotoXZ,
 		int *playerGotoX, int *playerGotoZ,
 		bool *mutatedRuntimeState);
 	bool probePickupBlockingCommandChain(const Common::String &initialTag,
-		const Common::String &contextName, StartupInteractionResult &result, uint recursionDepth) const;
+		const Common::String &contextName, InteractionResult &result, uint recursionDepth) const;
 	bool hasActionableCommandChain(const Common::String &initialTag) const;
 	bool setPlayerCurrentHitPoints(int hitPoints);
 	void logRuntimeSaveState(const char *operation) const;
 
 	Common::String _path;
 	Common::Array<byte> _data;
-	Common::Array<StartupEntranceRecord> _entrances;
-	Common::Array<StartupMapEntranceRecord> _mapEntrances;
-	Common::Array<StartupMapLocationRecord> _mapLocations;
-	Common::Array<StartupRoomRecord> _rooms;
-	Common::Array<StartupObjectRecord> _objects;
-	Common::Array<StartupAnimRecord> _animations;
-	Common::Array<StartupNpcRecord> _npcs;
-	Common::Array<StartupMonsterRecord> _monsters;
-	Common::Array<StartupTimerRecord> _timers;
-	Common::Array<StartupRegionRecord> _regions;
-	Common::Array<StartupFlagRecord> _flags;
-	Common::Array<StartupCommandRecord> _commands;
-	Common::Array<StartupExecListRecord> _execLists;
-	Common::Array<StartupTextRecord> _texts;
-	Common::Array<StartupHeadRecord> _heads;
-	Common::Array<StartupUseItemRecord> _useItems;
-	Common::Array<StartupFlagRecord> _runtimeFlags;
-	Common::Array<StartupObjectRecord> _runtimeObjects;
-	Common::Array<StartupAnimRecord> _runtimeAnimations;
-	Common::Array<StartupRegionRecord> _runtimeRegions;
-	Common::Array<StartupNpcRecord> _runtimeNpcs;
-	Common::Array<StartupMonsterRecord> _runtimeMonsters;
-	Common::Array<StartupTimerRecord> _runtimeTimers;
+	Common::Array<EntranceRecord> _entrances;
+	Common::Array<MapEntranceRecord> _mapEntrances;
+	Common::Array<MapLocationRecord> _mapLocations;
+	Common::Array<RoomRecord> _rooms;
+	Common::Array<ObjectRecord> _objects;
+	Common::Array<AnimRecord> _animations;
+	Common::Array<NpcRecord> _npcs;
+	Common::Array<MonsterRecord> _monsters;
+	Common::Array<TimerRecord> _timers;
+	Common::Array<RegionRecord> _regions;
+	Common::Array<FlagRecord> _flags;
+	Common::Array<CommandRecord> _commands;
+	Common::Array<ExecListRecord> _execLists;
+	Common::Array<TextRecord> _texts;
+	Common::Array<HeadRecord> _heads;
+	Common::Array<UseItemRecord> _useItems;
+	Common::Array<FlagRecord> _runtimeFlags;
+	Common::Array<ObjectRecord> _runtimeObjects;
+	Common::Array<AnimRecord> _runtimeAnimations;
+	Common::Array<RegionRecord> _runtimeRegions;
+	Common::Array<NpcRecord> _runtimeNpcs;
+	Common::Array<MonsterRecord> _runtimeMonsters;
+	Common::Array<TimerRecord> _runtimeTimers;
 	int _playerCurrentHitPoints = 30;
 	int _playerCombatLoadout = 0;
 	bool _playerControlPaused = false;


Commit: e18fb73545084175e511b5890f65b424afadf375
    https://github.com/scummvm/scummvm/commit/e18fb73545084175e511b5890f65b424afadf375
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:35+02:00

Commit Message:
HARVESTER: Refactor engine script and resource management

- Replaced instances of `getStartupScript()` with `getScript()` across
  multiple files to unify script access.

- Updated resource management calls to remove references to startup
  resources, replacing them with general resource methods.

- Adjusted sound playback methods to use `playSound()` instead of
  `playStartupSound()`.

- Modified save/load state methods to remove "startup" prefix for
  clarity and consistency.

- Ensured that all relevant areas of the codebase reflect these changes,
  improving maintainability and readability.

Changed paths:
    engines/harvester/console.cpp
    engines/harvester/dialogue.cpp
    engines/harvester/flow.cpp
    engines/harvester/fst_player.cpp
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/inventory.cpp
    engines/harvester/menu.cpp
    engines/harvester/player.cpp
    engines/harvester/room.cpp
    engines/harvester/saveload.cpp


diff --git a/engines/harvester/console.cpp b/engines/harvester/console.cpp
index a6752ff0c9b..17a9b65740c 100644
--- a/engines/harvester/console.cpp
+++ b/engines/harvester/console.cpp
@@ -34,7 +34,7 @@ static const uint kMaxDebugActionChainLines = 256;
 static const uint kMaxDebugActionBranchDepth = 16;
 
 static Script *getActiveStartupScript() {
-	return g_engine ? g_engine->getStartupScript() : nullptr;
+	return g_engine ? g_engine->getScript() : nullptr;
 }
 
 static const CommandRecord *findCommandRecord(const Script &script, const Common::String &tag) {
@@ -430,8 +430,8 @@ bool Console::Cmd_debugActions(int argc, const char **argv) {
 		return true;
 	}
 
-	Script *startupScript = g_engine->getStartupScript();
-	if (!startupScript || !g_engine->hasCurrentStartupSaveRoomState()) {
+	Script *startupScript = g_engine->getScript();
+	if (!startupScript || !g_engine->hasCurrentSaveRoomState()) {
 		debugPrintf("DEBUG_ACTIONS is only available while a room is active\n");
 		return true;
 	}
@@ -442,7 +442,7 @@ bool Console::Cmd_debugActions(int argc, const char **argv) {
 		return true;
 	}
 
-	const SaveRoomState &roomState = g_engine->getCurrentStartupSaveRoomState();
+	const SaveRoomState &roomState = g_engine->getCurrentSaveRoomState();
 	RoomSetupState materializedState;
 	if (!startupScript->materializeRoomState(roomState.entranceName, roomState.roomName,
 			materializedState, *resources)) {
@@ -491,7 +491,7 @@ bool Console::Cmd_debugCommand(int argc, const char **argv) {
 		return true;
 	}
 
-	if (!g_engine->getStartupScript() || !g_engine->hasCurrentStartupSaveRoomState()) {
+	if (!g_engine->getScript() || !g_engine->hasCurrentSaveRoomState()) {
 		debugPrintf("DEBUG_COMMAND is only available while a room is active\n");
 		return true;
 	}
@@ -545,7 +545,7 @@ bool Console::Cmd_gotoRoom(int argc, const char **argv) {
 		return true;
 	}
 
-	if (!g_engine || !g_engine->hasCurrentStartupSaveRoomState()) {
+	if (!g_engine || !g_engine->hasCurrentSaveRoomState()) {
 		debugPrintf("GOTO_ROOM is only available while a room is active\n");
 		return true;
 	}
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 850ca12eacd..fc669ff2303 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -173,7 +173,7 @@ static void syncDialogueSharedState(Common::Serializer &s, DialogueSharedState &
 }
 
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
-	const Text *startupText = engine.getStartupText();
+	const Text *startupText = engine.getText();
 	if (!startupText || !fontName)
 		return nullptr;
 
@@ -235,7 +235,7 @@ static void drawShadowedString(Graphics::Screen &screen, const Graphics::Font &f
 
 static void setScaledPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
 	byte scaledPalette[256 * 3];
-	const float gammaBrightness = g_engine ? g_engine->getStartupGammaBrightnessScale() : 1.0f;
+	const float gammaBrightness = g_engine ? g_engine->getGammaBrightnessScale() : 1.0f;
 	buildHarvesterDisplayPalette(palette, brightness * gammaBrightness, scaledPalette);
 	screen.setPalette(scaledPalette);
 }
@@ -424,7 +424,7 @@ static Common::String buildDialogueHeadId(const Common::String &speakerId, int h
 
 static bool loadDialogueHeadBitmap(HarvesterEngine &engine, const Common::String &speakerId, int headVariant,
 		IndexedBitmap &bitmap) {
-	Script *startupScript = engine.getStartupScript();
+	Script *startupScript = engine.getScript();
 	ResourceManager *resources = engine.getResources();
 	if (!startupScript || !resources || speakerId.empty())
 		return false;
@@ -563,9 +563,9 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 	};
 
 	Graphics::Screen *screen = _engine.getScreen();
-	Script *startupScript = _engine.getStartupScript();
-	Text *startupText = _engine.getStartupText();
-	Art *startupArt = _engine.getStartupArt();
+	Script *startupScript = _engine.getScript();
+	Text *startupText = _engine.getText();
+	Art *startupArt = _engine.getArt();
 	EntityManager *runtimeEntities = _engine.getRuntimeEntities();
 	const Graphics::Font *fallbackFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
 	if (!screen || !startupScript || !startupText || !startupArt || !fallbackFont || !backdrop.isValid())
@@ -768,14 +768,14 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		}
 
 		const Common::String voicePath = buildDialogueVoicePath(*startupScript, wavId);
-		const bool voiceStarted = !voicePath.empty() && _engine.playStartupSpeech(voicePath);
+		const bool voiceStarted = !voicePath.empty() && _engine.playSpeech(voicePath);
 		debugC(2, kDebugDialogue,
 			"Harvester: dialogue line wav=0x%x speaker='%s' headVariant=%d voice='%s' subtitle='%s'",
 			wavId, speakerId.c_str(), headVariant, voicePath.c_str(),
 			textEnabled ? subtitleText.c_str() : "");
 		Common::Error releaseError = waitForPointerRelease();
 		if (releaseError.getCode() != Common::kNoError) {
-			_engine.stopStartupSpeech();
+			_engine.stopSpeech();
 			return releaseError;
 		}
 
@@ -788,7 +788,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			while (g_system->getEventManager()->pollEvent(event)) {
 				Common::Error result = Common::kNoError;
 				if (startupFlow.handleSystemEvent(event, result)) {
-					_engine.stopStartupSpeech();
+					_engine.stopSpeech();
 					return result;
 				}
 
@@ -812,14 +812,14 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 
 			if (runtimeEntities)
 				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
-			if (interrupted || (!voiceStarted || !_engine.isStartupSpeechPlaying()))
+			if (interrupted || (!voiceStarted || !_engine.isSpeechPlaying()))
 				break;
 
 			limiter.delayBeforeSwap();
 			limiter.startFrame();
 		}
 
-		_engine.stopStartupSpeech();
+		_engine.stopSpeech();
 		if (interrupted) {
 			Common::Error releaseResult = waitForPointerRelease();
 			if (releaseResult.getCode() != Common::kNoError)
@@ -930,7 +930,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 				return Common::kReadingFailed;
 			}
 
-			_engine.stopStartupMusic();
+			_engine.stopMusic();
 
 			Common::Event event;
 			while (g_system->getEventManager()->pollEvent(event)) {
@@ -988,12 +988,12 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			}
 		}
 
-		if (!_engine.activateStartupDisc(discNumber)) {
+		if (!_engine.activateDisc(discNumber)) {
 			warning("Harvester: unable to activate disc %d resources", discNumber);
 			return Common::kReadingFailed;
 		}
 		if (discNumber == 3 && previousDisc > 0 && previousDisc != resources->getCurrentDisc()) {
-			Script *startupScript = _engine.getStartupScript();
+			Script *startupScript = _engine.getScript();
 			if (!startupScript || !startupScript->reloadTownWorld(*resources)) {
 				warning("Harvester: unable to reload town script after disc prompt %d -> %d",
 					previousDisc, discNumber);
@@ -1016,9 +1016,9 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			return Common::kReadingFailed;
 		}
 
-		_engine.stopStartupSpeech();
-		_engine.stopStartupMusic();
-		_engine.stopStartupSound();
+		_engine.stopSpeech();
+		_engine.stopMusic();
+		_engine.stopSound();
 
 		screen->fillRect(screen->getBounds(), 0);
 		blitBitmap(*screen, gameOverBitmap, 0, 0);
@@ -1026,7 +1026,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		screen->makeAllDirty();
 		screen->update();
 
-		(void)_engine.playStartupMusic(kDialogueGameOverMusicPath);
+		(void)_engine.playMusic(kDialogueGameOverMusicPath);
 
 		bool dismissed = false;
 		Graphics::FrameLimiter limiter(g_system, 60);
@@ -1035,7 +1035,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			while (g_system->getEventManager()->pollEvent(event)) {
 				Common::Error result = Common::kNoError;
 				if (startupFlow.handleSystemEvent(event, result)) {
-					_engine.stopStartupMusic();
+					_engine.stopMusic();
 					return result;
 				}
 
@@ -1059,7 +1059,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			limiter.startFrame();
 		}
 
-		_engine.stopStartupMusic();
+		_engine.stopMusic();
 		if (dismissed) {
 			Common::Error releaseError = waitForPointerRelease();
 			if (releaseError.getCode() != Common::kNoError)
@@ -1089,7 +1089,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 	};
 	auto applyImmediateDialogueInteractionEffects = [&](InteractionResult &interaction) {
 		if (!interaction.musicPath.empty())
-			(void)_engine.playStartupMusic(interaction.musicPath);
+			(void)_engine.playMusic(interaction.musicPath);
 		if (!interaction.audioCommands.empty())
 			startupFlow.executeStartupAudioCommands(interaction.audioCommands);
 		interaction.musicPath.clear();
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 7020b6c4757..7d394013ba6 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -107,7 +107,7 @@ static const int kCursorSequenceNeutral = 7;
 } // End of anonymous namespace
 
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
-	const Text *startupText = engine.getStartupText();
+	const Text *startupText = engine.getText();
 	if (!startupText || !fontName)
 		return nullptr;
 
@@ -141,7 +141,7 @@ static byte findNearestPaletteColor(const byte *palette, byte red, byte green, b
 }
 
 static Common::String resolveRoomDebugObjectLabel(HarvesterEngine &engine, const ObjectRecord &object) {
-	if (Script *startupScript = engine.getStartupScript()) {
+	if (Script *startupScript = engine.getScript()) {
 		const Common::String resolvedLabel = startupScript->resolveObjectLabel(object);
 		if (!resolvedLabel.empty())
 			return resolvedLabel;
@@ -245,7 +245,7 @@ static void drawCombatDebugOverlay(HarvesterEngine &engine, Graphics::Screen &sc
 
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
 	EntityManager *runtimeEntities = engine.getRuntimeEntities();
-	Script *startupScript = engine.getStartupScript();
+	Script *startupScript = engine.getScript();
 	if (!font || !runtimeEntities)
 		return;
 
@@ -507,7 +507,7 @@ static void drawWrappedText(Graphics::Screen &screen, const Graphics::Font &font
 
 static void setScaledPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
 	byte scaledPalette[256 * 3];
-	const float gammaBrightness = g_engine ? g_engine->getStartupGammaBrightnessScale() : 1.0f;
+	const float gammaBrightness = g_engine ? g_engine->getGammaBrightnessScale() : 1.0f;
 	buildHarvesterDisplayPalette(palette, brightness * gammaBrightness, scaledPalette);
 	screen.setPalette(scaledPalette);
 }
@@ -737,7 +737,7 @@ static const NpcRecord *findSceneNpcByName(const Common::Array<NpcRecord> &npcs,
 
 static bool canTalkToRoomNpc(HarvesterEngine &engine, const NpcRecord &npc,
 		const DialogueSystem *dialogue) {
-	Script *startupScript = engine.getStartupScript();
+	Script *startupScript = engine.getScript();
 	if (!startupScript || !startupScript->isNamedNpcDeathTypeClear(npc.npcName))
 		return false;
 
@@ -987,7 +987,7 @@ RoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const RoomSetupSta
 		return hoverState;
 	}
 
-	Script *startupScript = engine.getStartupScript();
+	Script *startupScript = engine.getScript();
 	if (!startupScript)
 		return hoverState;
 	hoverState.object = findRoomObjectAtPoint(engine, sceneObjects, mousePos);
@@ -1064,7 +1064,7 @@ static bool findRoomObjectProbePoint(HarvesterEngine &engine, const Common::Arra
 void logStartupRoomProbe(HarvesterEngine &engine, const RoomSceneResources &scene,
 		const Common::String &entranceName, Common::Point &mousePos) {
 	EntityManager *runtimeEntities = engine.getRuntimeEntities();
-	Script *startupScript = engine.getStartupScript();
+	Script *startupScript = engine.getScript();
 	if (!runtimeEntities || !startupScript)
 		return;
 
@@ -1190,7 +1190,7 @@ static Common::String trimAsciiLine(const Common::String &value) {
 static bool loadQuickTipsScene(HarvesterEngine &engine, RoomSceneResources &scene) {
 	RoomSetupState state;
 	// The original startup path enters the START room first and overlays quick tips on top of it.
-	if (!engine.getStartupScript()->resolveRoomSetupState("START", state, *engine.getResources()))
+	if (!engine.getScript()->resolveRoomSetupState("START", state, *engine.getResources()))
 		return false;
 
 	return loadRoomSceneResources(state, *engine.getResources(), scene);
@@ -1199,9 +1199,9 @@ static bool loadQuickTipsScene(HarvesterEngine &engine, RoomSceneResources &scen
 static void renderQuickTipsScreen(HarvesterEngine &engine, const RoomSceneResources &scene,
 		const Common::Point &mousePos, const Common::String &tipText) {
 	Graphics::Screen *screen = engine.getScreen();
-	const Art *art = engine.getStartupArt();
+	const Art *art = engine.getArt();
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	Script *startupScript = engine.getStartupScript();
+	Script *startupScript = engine.getScript();
 	if (!screen || !art || !font || !startupScript)
 		return;
 
@@ -1270,21 +1270,21 @@ Common::Error Flow::run() {
 	clearPendingNewGameRestart();
 	resetRoomNpcDialogueState();
 	Common::Error error = Common::kNoError;
-	if (_engine.hasPendingLoadedStartupSaveRoomState()) {
-		Common::String initialTarget = _engine.getPendingLoadedStartupSaveRoomState().entranceName;
+	if (_engine.hasPendingLoadedSaveRoomState()) {
+		Common::String initialTarget = _engine.getPendingLoadedSaveRoomState().entranceName;
 		if (initialTarget.empty())
-			initialTarget = _engine.getPendingLoadedStartupSaveRoomState().roomName;
+			initialTarget = _engine.getPendingLoadedSaveRoomState().roomName;
 		error = runRoomLoop(initialTarget);
 	} else {
-		_engine.clearCurrentStartupSaveRoomState();
+		_engine.clearCurrentSaveRoomState();
 		_engine.clearPendingLoadedDialogueStateBlob();
-		_engine.getStartupScript()->resetRuntimeState();
+		_engine.getScript()->resetRuntimeState();
 		error = runQuickTips();
 		if (error.getCode() != Common::kNoError)
 			return error;
 
 		clearPendingMainMenuReturn();
-		_engine.getStartupScript()->resetRuntimeState();
+		_engine.getScript()->resetRuntimeState();
 		error = runRoomLoop("START");
 	}
 	if (error.getCode() != Common::kNoError)
@@ -1292,8 +1292,8 @@ Common::Error Flow::run() {
 	if (!takePendingMainMenuReturn())
 		return Common::kNoError;
 
-	_engine.stopStartupMusic();
-	_engine.stopStartupSound();
+	_engine.stopMusic();
+	_engine.stopSound();
 	return runMainMenuStub();
 }
 
@@ -1361,7 +1361,7 @@ bool Flow::loadMenuItems() {
 }
 
 Common::Error Flow::runQuickTips() {
-	if (!_engine.getStartupScript()->isQuickTipsEnabled() || _quickTips.empty())
+	if (!_engine.getScript()->isQuickTipsEnabled() || _quickTips.empty())
 		return Common::kNoError;
 
 	Common::Error transitionError = beginRoomSetupTransition();
@@ -1396,8 +1396,8 @@ Common::Error Flow::runQuickTips() {
 
 	debugC(1, kDebugGeneral,
 		"Harvester: quick tips labels exit='Exit' next='Next' toggle='%s'",
-		_engine.getStartupScript()->resolveTextValue(
-			_engine.getStartupScript()->isQuickTipsEnabled() ? "Show_Tips_ON" : "Show_Tips_OFF").c_str());
+		_engine.getScript()->resolveTextValue(
+			_engine.getScript()->isQuickTipsEnabled() ? "Show_Tips_ON" : "Show_Tips_OFF").c_str());
 
 	uint tipIndex = _engine.getRandomNumber(_quickTips.size() - 1);
 	bool needsRedraw = true;
@@ -1427,7 +1427,7 @@ Common::Error Flow::runQuickTips() {
 					tipIndex = (tipIndex + 1) % _quickTips.size();
 					needsRedraw = true;
 				} else if (quickTipsToggleRect().contains(_mousePos)) {
-					_engine.getStartupScript()->setQuickTipsEnabled(!_engine.getStartupScript()->isQuickTipsEnabled());
+					_engine.getScript()->setQuickTipsEnabled(!_engine.getScript()->isQuickTipsEnabled());
 					needsRedraw = true;
 				}
 				break;
@@ -1469,7 +1469,7 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 		Common::String &destinationEntranceName) {
 	destinationEntranceName.clear();
 
-	Script *startupScript = _engine.getStartupScript();
+	Script *startupScript = _engine.getScript();
 	ResourceManager *resources = _engine.getResources();
 	Graphics::Screen *screen = _engine.getScreen();
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
@@ -1504,8 +1504,8 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 			return Common::kReadingFailed;
 	}
 
-	const Common::String previousMusicPath = _engine.getStartupMusicPath();
-	(void)_engine.playStartupMusic(kTownMapMusicPath);
+	const Common::String previousMusicPath = _engine.getMusicPath();
+	(void)_engine.playMusic(kTownMapMusicPath);
 
 	if (!ensureCursorEntity())
 		return Common::kReadingFailed;
@@ -1529,9 +1529,9 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 		if (!destinationEntranceName.empty())
 			return;
 		if (!previousMusicPath.empty())
-			(void)_engine.playStartupMusic(previousMusicPath);
+			(void)_engine.playMusic(previousMusicPath);
 		else
-			_engine.stopStartupMusic();
+			_engine.stopMusic();
 	};
 
 	while (!_engine.shouldQuit()) {
@@ -1621,7 +1621,7 @@ Common::Error Flow::resolveRoomTransitionTarget(const Common::String &targetName
 		Common::String &resolvedTargetName) {
 	resolvedTargetName = targetName;
 
-	Script *startupScript = _engine.getStartupScript();
+	Script *startupScript = _engine.getScript();
 	if (!startupScript || targetName.empty())
 		return Common::kNoError;
 
@@ -1674,11 +1674,11 @@ void Flow::prepareForNewGame() {
 	_pendingDebugRoomName.clear();
 	_queuedDebugInteraction = InteractionResult();
 	_hasQueuedDebugInteraction = false;
-	_engine.clearPendingLoadedStartupSaveRoomState();
+	_engine.clearPendingLoadedSaveRoomState();
 	_engine.clearPendingLoadedDialogueStateBlob();
-	_engine.clearCurrentStartupSaveRoomState();
-	if (_engine.getStartupScript())
-		_engine.getStartupScript()->resetRuntimeState();
+	_engine.clearCurrentSaveRoomState();
+	if (_engine.getScript())
+		_engine.getScript()->resetRuntimeState();
 	resetRoomNpcDialogueState();
 }
 
@@ -1721,7 +1721,7 @@ void Flow::clearPendingGameOverReturn() {
 
 void Flow::requestMainMenuReturn() {
 	_pendingMainMenuReturn = true;
-	_engine.clearCurrentStartupSaveRoomState();
+	_engine.clearCurrentSaveRoomState();
 }
 
 bool Flow::hasPendingMainMenuReturn() const {
@@ -1863,7 +1863,7 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 		}
 
 		entity->setClassId(resolveSceneObjectClass(
-			state, object, entity->hasFrames() ? entity : nullptr, _engine.getStartupScript()));
+			state, object, entity->hasFrames() ? entity : nullptr, _engine.getScript()));
 		entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
 		entity->setZExtent((float)object.zExtent);
 		const Common::Rect entityRect = entity->getScreenRect();
@@ -1939,7 +1939,7 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 	}
 	if (state.hasEntrance) {
 		const int playerFrame = Player::resolveFacingFrame(state.playerFacing);
-		Script *startupScript = _engine.getStartupScript();
+		Script *startupScript = _engine.getScript();
 		const int playerCombatLoadout = startupScript ? startupScript->getPlayerCombatLoadout() : 0;
 		const Common::String playerResourcePath =
 			Player::resolveCombatLoadoutResourcePath(playerCombatLoadout);
@@ -2042,7 +2042,7 @@ Common::Error Flow::beginRoomSetupTransition() {
 	if (_engine.getRuntimeEntities())
 		_engine.getRuntimeEntities()->hideCursor();
 
-	const Art *art = _engine.getStartupArt();
+	const Art *art = _engine.getArt();
 	if (art && screen)
 		art->drawWaitFrame(*screen);
 	_roomSetupTransitionShownTick = g_system ? g_system->getMillis() : 0;
@@ -2107,7 +2107,7 @@ bool Flow::pumpTransitionEvents(Common::Error &result) {
 
 void Flow::executeStartupAudioCommands(const Common::Array<AudioCommand> &commands) {
 	for (const AudioCommand &command : commands)
-		(void)_engine.executeStartupAudioCommand(command);
+		(void)_engine.executeAudioCommand(command);
 }
 
 void Flow::resetRoomNpcDialogueState() {
diff --git a/engines/harvester/fst_player.cpp b/engines/harvester/fst_player.cpp
index c08c0bc61f7..7baad0704d5 100644
--- a/engines/harvester/fst_player.cpp
+++ b/engines/harvester/fst_player.cpp
@@ -434,9 +434,9 @@ bool FstPlayer::play(const Common::String &path) {
 	if (shouldSwitchToMovieDisplay)
 		_vm.setDisplayMode(320, 200);
 
-	const bool resumeMusicAfterPlayback = _vm.isStartupMusicPlaying();
+	const bool resumeMusicAfterPlayback = _vm.isMusicPlaying();
 	if (resumeMusicAfterPlayback)
-		_vm.pauseStartupMusic(true);
+		_vm.pauseMusic(true);
 
 	const bool censorshipEnabled = !_vm.isGoreEnabled();
 	const FstCensorshipToggleEntry *censorshipEntry = censorshipEnabled ? findCensorshipToggleEntry(path) : nullptr;
@@ -579,7 +579,7 @@ bool FstPlayer::play(const Common::String &path) {
 		mixer->stopHandle(audioHandle);
 	delete audioStream;
 	if (resumeMusicAfterPlayback)
-		_vm.pauseStartupMusic(false);
+		_vm.pauseMusic(false);
 	if (shouldSwitchToMovieDisplay)
 		_vm.setDisplayMode(previousDisplayWidth, previousDisplayHeight);
 
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 4ee192016a5..d9fbc0afb23 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -35,7 +35,7 @@ namespace Harvester {
 
 namespace {
 
-static bool shouldSkipStartupMoviesForDebug() {
+static bool shouldSkipIntroMoviesForDebug() {
 	return ConfMan.hasKey("harvester_debug_skip_startup_movies") &&
 		ConfMan.getBool("harvester_debug_skip_startup_movies");
 }
@@ -84,11 +84,11 @@ int HarvesterEngine::getDisplayHeight() const {
 	return _media ? _media->getDisplayHeight() : 0;
 }
 
-Art *HarvesterEngine::getStartupArt() const {
+Art *HarvesterEngine::getArt() const {
 	return _media ? _media->getArt() : nullptr;
 }
 
-Text *HarvesterEngine::getStartupText() const {
+Text *HarvesterEngine::getText() const {
 	return _media ? _media->getText() : nullptr;
 }
 
@@ -103,125 +103,125 @@ bool HarvesterEngine::shouldShowCdChangePrompts() const {
 	return ConfMan.hasKey("show_cd_change_prompts") && ConfMan.getBool("show_cd_change_prompts");
 }
 
-int HarvesterEngine::getStartupFxVolumeLevel() const {
+int HarvesterEngine::getFxVolumeLevel() const {
 	return _startupScript ? _startupScript->getFxVolumeLevel() : 9;
 }
 
-int HarvesterEngine::getStartupMusicVolumeLevel() const {
+int HarvesterEngine::getMusicVolumeLevel() const {
 	return _startupScript ? _startupScript->getMusicVolumeLevel() : 9;
 }
 
-int HarvesterEngine::getStartupGammaLevel() const {
+int HarvesterEngine::getGammaLevel() const {
 	return _startupScript ? _startupScript->getGammaLevel() : 0;
 }
 
-float HarvesterEngine::getStartupGammaBrightnessScale() const {
-	return MediaManager::mapGammaLevelToBrightnessScale(getStartupGammaLevel());
+float HarvesterEngine::getGammaBrightnessScale() const {
+	return MediaManager::mapGammaLevelToBrightnessScale(getGammaLevel());
 }
 
-void HarvesterEngine::applyStartupMixerLevels() {
+void HarvesterEngine::applyMixerLevels() {
 	if (_media)
-		_media->applyMixerLevels(getStartupFxVolumeLevel(), getStartupMusicVolumeLevel());
+		_media->applyMixerLevels(getFxVolumeLevel(), getMusicVolumeLevel());
 }
 
-void HarvesterEngine::setStartupFxVolumeLevel(int level) {
+void HarvesterEngine::setFxVolumeLevel(int level) {
 	if (_startupScript)
 		_startupScript->setFxVolumeLevel(level);
-	applyStartupMixerLevels();
+	applyMixerLevels();
 }
 
-void HarvesterEngine::setStartupMusicVolumeLevel(int level) {
+void HarvesterEngine::setMusicVolumeLevel(int level) {
 	if (_startupScript)
 		_startupScript->setMusicVolumeLevel(level);
-	applyStartupMixerLevels();
+	applyMixerLevels();
 }
 
-void HarvesterEngine::setStartupGammaLevel(int level) {
+void HarvesterEngine::setGammaLevel(int level) {
 	if (_startupScript)
 		_startupScript->setGammaLevel(level);
 }
 
-const Common::String &HarvesterEngine::getStartupMusicPath() const {
+const Common::String &HarvesterEngine::getMusicPath() const {
 	static const Common::String kEmptyMusicPath;
 	return _media ? _media->getMusicPath() : kEmptyMusicPath;
 }
 
-bool HarvesterEngine::isStartupMusicPlaying() const {
+bool HarvesterEngine::isMusicPlaying() const {
 	return _media && _media->isMusicPlaying();
 }
 
-bool HarvesterEngine::playStartupMusic(const Common::String &path) {
+bool HarvesterEngine::playMusic(const Common::String &path) {
 	if (!_media)
 		return false;
 
 	const bool started = _media->playMusic(path);
 	if (started)
-		applyStartupMixerLevels();
+		applyMixerLevels();
 	return started;
 }
 
-void HarvesterEngine::pauseStartupMusic(bool paused) {
+void HarvesterEngine::pauseMusic(bool paused) {
 	if (_media)
 		_media->pauseMusic(paused);
 }
 
-void HarvesterEngine::stopStartupMusic() {
+void HarvesterEngine::stopMusic() {
 	if (_media)
 		_media->stopMusic();
 }
 
-bool HarvesterEngine::executeStartupAudioCommand(const AudioCommand &command) {
+bool HarvesterEngine::executeAudioCommand(const AudioCommand &command) {
 	return _media && _media->executeAudioCommand(command);
 }
 
-bool HarvesterEngine::playStartupSound(const Common::String &path) {
+bool HarvesterEngine::playSound(const Common::String &path) {
 	return _media && _media->playSound(path);
 }
 
-bool HarvesterEngine::playStartupSingleSound(const Common::String &path) {
+bool HarvesterEngine::playSingleSound(const Common::String &path) {
 	return _media && _media->playSingleSound(path);
 }
 
-void HarvesterEngine::stopStartupSingleSound() {
+void HarvesterEngine::stopSingleSound() {
 	if (_media)
 		_media->stopSingleSound();
 }
 
-bool HarvesterEngine::isStartupSingleSoundPlaying() const {
+bool HarvesterEngine::isSingleSoundPlaying() const {
 	return _media && _media->isSingleSoundPlaying();
 }
 
-bool HarvesterEngine::playStartupSpeech(const Common::String &path) {
+bool HarvesterEngine::playSpeech(const Common::String &path) {
 	return _media && _media->playSpeech(path);
 }
 
-void HarvesterEngine::stopStartupSpeech() {
+void HarvesterEngine::stopSpeech() {
 	if (_media)
 		_media->stopSpeech();
 }
 
-bool HarvesterEngine::isStartupSpeechPlaying() const {
+bool HarvesterEngine::isSpeechPlaying() const {
 	return _media && _media->isSpeechPlaying();
 }
 
-bool HarvesterEngine::loadStartupSound(int slot, const Common::String &path) {
+bool HarvesterEngine::loadSound(int slot, const Common::String &path) {
 	return _media && _media->loadSound(slot, path);
 }
 
-bool HarvesterEngine::playStartupLoadedSound(int slot) {
+bool HarvesterEngine::playLoadedSound(int slot) {
 	return _media && _media->playLoadedSound(slot);
 }
 
-bool HarvesterEngine::deleteStartupLoadedSound(int slot) {
+bool HarvesterEngine::deleteLoadedSound(int slot) {
 	return _media && _media->deleteLoadedSound(slot);
 }
 
-void HarvesterEngine::stopStartupSound() {
+void HarvesterEngine::stopSound() {
 	if (_media)
 		_media->stopSound();
 }
 
-bool HarvesterEngine::activateStartupDisc(int discNumber) {
+bool HarvesterEngine::activateDisc(int discNumber) {
 	if (!_resources || discNumber <= 0)
 		return false;
 
@@ -306,7 +306,7 @@ Common::Error HarvesterEngine::run() {
 	_startupScript = new Script();
 	if (!_startupScript->load(*_resources))
 		return Common::kReadingFailed;
-	applyStartupMixerLevels();
+	applyMixerLevels();
 
 	// The intro FST files play on the narrower startup movie surface.
 	setDisplayMode(320, 200);
@@ -314,7 +314,7 @@ Common::Error HarvesterEngine::run() {
 	// Set the engine's debugger console
 	setDebugger(new Console());
 
-	if (shouldSkipStartupMoviesForDebug()) {
+	if (shouldSkipIntroMoviesForDebug()) {
 		debugC(1, kDebugGeneral, "Harvester: debug skip enabled for startup intro FST playback");
 	} else {
 		FstPlayer fstPlayer(*this);
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 731fd1f31a1..8c5b5a7e150 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -81,57 +81,57 @@ public:
 	Graphics::Screen *getScreen() const;
 	int getDisplayWidth() const;
 	int getDisplayHeight() const;
-	Script *getStartupScript() const { return _startupScript; }
-	Art *getStartupArt() const;
-	Text *getStartupText() const;
+	Script *getScript() const { return _startupScript; }
+	Art *getArt() const;
+	Text *getText() const;
 	void setDisplayMode(int width, int height);
 	bool isGoreEnabled() const;
 	bool shouldShowCdChangePrompts() const;
-	int getStartupFxVolumeLevel() const;
-	int getStartupMusicVolumeLevel() const;
-	int getStartupGammaLevel() const;
-	float getStartupGammaBrightnessScale() const;
-	void setStartupFxVolumeLevel(int level);
-	void setStartupMusicVolumeLevel(int level);
-	void setStartupGammaLevel(int level);
-	const Common::String &getStartupMusicPath() const;
-	bool isStartupMusicPlaying() const;
-	bool playStartupMusic(const Common::String &path);
-	void pauseStartupMusic(bool paused);
-	void stopStartupMusic();
-	bool executeStartupAudioCommand(const AudioCommand &command);
-	bool playStartupSound(const Common::String &path);
-	bool playStartupSingleSound(const Common::String &path);
-	void stopStartupSingleSound();
-	bool isStartupSingleSoundPlaying() const;
-	bool playStartupSpeech(const Common::String &path);
-	void stopStartupSpeech();
-	bool isStartupSpeechPlaying() const;
-	bool loadStartupSound(int slot, const Common::String &path);
-	bool playStartupLoadedSound(int slot);
-	bool deleteStartupLoadedSound(int slot);
-	void stopStartupSound();
-	bool activateStartupDisc(int discNumber);
+	int getFxVolumeLevel() const;
+	int getMusicVolumeLevel() const;
+	int getGammaLevel() const;
+	float getGammaBrightnessScale() const;
+	void setFxVolumeLevel(int level);
+	void setMusicVolumeLevel(int level);
+	void setGammaLevel(int level);
+	const Common::String &getMusicPath() const;
+	bool isMusicPlaying() const;
+	bool playMusic(const Common::String &path);
+	void pauseMusic(bool paused);
+	void stopMusic();
+	bool executeAudioCommand(const AudioCommand &command);
+	bool playSound(const Common::String &path);
+	bool playSingleSound(const Common::String &path);
+	void stopSingleSound();
+	bool isSingleSoundPlaying() const;
+	bool playSpeech(const Common::String &path);
+	void stopSpeech();
+	bool isSpeechPlaying() const;
+	bool loadSound(int slot, const Common::String &path);
+	bool playLoadedSound(int slot);
+	bool deleteLoadedSound(int slot);
+	void stopSound();
+	bool activateDisc(int discNumber);
 	bool isCombatDebugEnabled() const { return _combatDebugEnabled; }
 	bool toggleCombatDebugEnabled();
 	bool isRoomDebugEnabled() const { return _roomDebugEnabled; }
 	bool toggleRoomDebugEnabled();
 	bool requestDebugCommand(const CommandRecord &command);
 	bool requestDebugRoomChange(const Common::String &roomName);
-	void captureCurrentStartupSaveRoomState(const Common::String &entranceName,
+	void captureCurrentSaveRoomState(const Common::String &entranceName,
 		const Common::String &roomName, int playerX, int playerY, int playerZ, int playerFacing,
 		const Common::String &musicPath);
-	void clearCurrentStartupSaveRoomState();
-	bool hasCurrentStartupSaveRoomState() const { return _currentStartupSaveRoomState.valid; }
-	const SaveRoomState &getCurrentStartupSaveRoomState() const {
+	void clearCurrentSaveRoomState();
+	bool hasCurrentSaveRoomState() const { return _currentStartupSaveRoomState.valid; }
+	const SaveRoomState &getCurrentSaveRoomState() const {
 		return _currentStartupSaveRoomState;
 	}
-	bool hasPendingLoadedStartupSaveRoomState() const { return _pendingLoadedStartupSaveRoomState.valid; }
-	const SaveRoomState &getPendingLoadedStartupSaveRoomState() const {
+	bool hasPendingLoadedSaveRoomState() const { return _pendingLoadedStartupSaveRoomState.valid; }
+	const SaveRoomState &getPendingLoadedSaveRoomState() const {
 		return _pendingLoadedStartupSaveRoomState;
 	}
-	int getPendingLoadedStartupDisc() const { return _pendingLoadedStartupDisc; }
-	void clearPendingLoadedStartupSaveRoomState();
+	int getPendingLoadedDisc() const { return _pendingLoadedStartupDisc; }
+	void clearPendingLoadedSaveRoomState();
 	bool hasPendingLoadedDialogueStateBlob() const { return !_pendingLoadedDialogueStateBlob.empty(); }
 	const Common::Array<byte> &getPendingLoadedDialogueStateBlob() const { return _pendingLoadedDialogueStateBlob; }
 	uint32 getPendingLoadedDialogueStateBlobVersion() const { return _pendingLoadedDialogueStateBlobVersion; }
@@ -141,7 +141,7 @@ public:
 	}
 
 private:
-	void applyStartupMixerLevels();
+	void applyMixerLevels();
 
 	const ADGameDescription *const _gameDescription;
 	Common::RandomSource _randomSource;
diff --git a/engines/harvester/inventory.cpp b/engines/harvester/inventory.cpp
index 907f498e882..5838bacd2cc 100644
--- a/engines/harvester/inventory.cpp
+++ b/engines/harvester/inventory.cpp
@@ -290,7 +290,7 @@ bool InventorySystem::loadBitmap(const Common::String &path, IndexedBitmap &bitm
 bool InventorySystem::refresh() {
 	_items.clear();
 
-	Script *startupScript = _engine.getStartupScript();
+	Script *startupScript = _engine.getScript();
 	ResourceManager *resources = _engine.getResources();
 	if (!startupScript || !resources)
 		return false;
@@ -388,7 +388,7 @@ bool InventorySystem::clearSelection() {
 }
 
 bool InventorySystem::refreshIfRuntimeStateChanged() {
-	Script *startupScript = _engine.getStartupScript();
+	Script *startupScript = _engine.getScript();
 	if (!startupScript)
 		return false;
 
@@ -416,7 +416,7 @@ const Common::String &InventorySystem::getSelectedItemName() const {
 }
 
 Common::String InventorySystem::resolveSelectedLabel() const {
-	Script *startupScript = _engine.getStartupScript();
+	Script *startupScript = _engine.getScript();
 	if (!startupScript || _selectedItemName.empty())
 		return Common::String();
 
@@ -451,7 +451,7 @@ bool InventorySystem::toggleCombatLoadout(const ObjectRecord &object, int curren
 	if (!resolveInventoryCombatLoadoutId(object.objectName, loadoutId))
 		return false;
 
-	Script *startupScript = _engine.getStartupScript();
+	Script *startupScript = _engine.getScript();
 	if (!startupScript)
 		return false;
 
@@ -509,7 +509,7 @@ const Common::String &InventorySystem::getPromptText() const {
 }
 
 Common::String InventorySystem::resolveWeekdayLabel() const {
-	Script *startupScript = _engine.getStartupScript();
+	Script *startupScript = _engine.getScript();
 	if (!startupScript || startupScript->isObjectInInventory(kHarvestBladeObjectName))
 		return Common::String();
 
@@ -526,7 +526,7 @@ const InventoryVisual *InventorySystem::findItemAtPoint(const Common::Point &poi
 }
 
 Common::Rect InventorySystem::getPanelBounds() const {
-	const Art *art = _engine.getStartupArt();
+	const Art *art = _engine.getArt();
 	if (!art)
 		return Common::Rect();
 
@@ -553,7 +553,7 @@ void InventorySystem::drawSelectedDragItem(Graphics::Screen &screen, const Commo
 }
 
 void InventorySystem::drawOverlay(Graphics::Screen &screen) const {
-	const Art *art = _engine.getStartupArt();
+	const Art *art = _engine.getArt();
 	if (!art)
 		return;
 
diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index 9eb86d83d5f..cd78008754e 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -158,7 +158,7 @@ static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, in
 static int getNativeRoomMenuLineHeight(const Graphics::Font &selectedFont);
 
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
-	const Text *startupText = engine.getStartupText();
+	const Text *startupText = engine.getText();
 	if (!startupText || !fontName)
 		return nullptr;
 
@@ -347,7 +347,7 @@ static void applyMenuPalette(Graphics::Screen &screen, const HarvesterEngine &en
 		return;
 
 	byte displayPalette[256 * 3];
-	buildHarvesterDisplayPalette(palette, brightness * engine.getStartupGammaBrightnessScale(), displayPalette);
+	buildHarvesterDisplayPalette(palette, brightness * engine.getGammaBrightnessScale(), displayPalette);
 	screen.setPalette(displayPalette);
 }
 
@@ -500,7 +500,7 @@ static void renderOptionsMenuScreen(HarvesterEngine &engine, const IndexedBitmap
 		const IndexedBitmap &volumeBar, const IndexedBitmap &indicator, int selectedItem,
 		bool drawCursor = true) {
 	Graphics::Screen *screen = engine.getScreen();
-	Script *startupScript = engine.getStartupScript();
+	Script *startupScript = engine.getScript();
 	if (!screen || !startupScript)
 		return;
 
@@ -544,9 +544,9 @@ static void renderQuickTipsOverlay(HarvesterEngine &engine, const IndexedBitmap
 		const byte *palette, float paletteBrightness,
 		const Common::String &tipText) {
 	Graphics::Screen *screen = engine.getScreen();
-	const Art *art = engine.getStartupArt();
+	const Art *art = engine.getArt();
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	Script *startupScript = engine.getStartupScript();
+	Script *startupScript = engine.getScript();
 	if (!screen || !art || !font || !startupScript)
 		return;
 
@@ -615,7 +615,7 @@ static void renderConfirmPromptScreen(HarvesterEngine &engine, const IndexedBitm
 		const IndexedBitmap &textbox, const Common::String &promptText,
 		const RoomMenuTextConfig &config) {
 	Graphics::Screen *screen = engine.getScreen();
-	const Art *art = engine.getStartupArt();
+	const Art *art = engine.getArt();
 	if (!screen || !art)
 		return;
 
@@ -666,7 +666,7 @@ MenuSystem::MenuSystem(HarvesterEngine &engine, Common::Point &mousePos,
 }
 
 Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
-	const Art *art = _engine.getStartupArt();
+	const Art *art = _engine.getArt();
 	if (!art)
 		return Common::kReadingFailed;
 	ScopedSceneTimerPause pausedTimers(_engine);
@@ -703,8 +703,8 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 		pausedTimers.resume();
 		Common::Error roomError = startupFlow.runRoomLoop(targetName);
 		pausedTimers.pause();
-		_engine.stopStartupMusic();
-		_engine.stopStartupSound();
+		_engine.stopMusic();
+		_engine.stopSound();
 		if (roomError.getCode() == Common::kReadingFailed) {
 			statusMessage = Common::String::format(
 				"Unable to resolve %s room setup from HARVEST.SCR.", targetName.c_str());
@@ -764,9 +764,9 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 			if (!loadedGame)
 				return Common::kNoError;
 
-			Common::String targetName = _engine.getPendingLoadedStartupSaveRoomState().entranceName;
+			Common::String targetName = _engine.getPendingLoadedSaveRoomState().entranceName;
 			if (targetName.empty())
-				targetName = _engine.getPendingLoadedStartupSaveRoomState().roomName;
+				targetName = _engine.getPendingLoadedSaveRoomState().roomName;
 			if (targetName.empty()) {
 				statusMessage = "Loaded save is missing its room target.";
 				return Common::kNoError;
@@ -903,7 +903,7 @@ Common::Error MenuSystem::showGameOverBackdrop(Flow &startupFlow) {
 	memcpy(_mainMenuBackdropPalette, palette, sizeof(_mainMenuBackdropPalette));
 	_hasMainMenuBackdrop = true;
 	startupFlow.resetCursorAnimationSequence();
-	(void)_engine.playStartupMusic(kGameOverMusicPath);
+	(void)_engine.playMusic(kGameOverMusicPath);
 
 	bool needsRedraw = true;
 	Graphics::FrameLimiter limiter(g_system, 60);
@@ -1567,7 +1567,7 @@ Common::Error MenuSystem::restoreRoomBackdropAfterSave(const IndexedBitmap &back
 Common::Error MenuSystem::runConfirmPrompt(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, Flow &startupFlow, const Common::String &promptText,
 		bool &confirmed) {
-	const Art *art = _engine.getStartupArt();
+	const Art *art = _engine.getArt();
 	const CftFontResource *promptFontResource = findStartupFontByName(_engine, "MEDFONT1");
 	const CftFontResource *choiceFontResource = findStartupFontByName(_engine, "MEDFONT2");
 	if (!art || !promptFontResource || !choiceFontResource)
@@ -1660,16 +1660,16 @@ Common::Error MenuSystem::runQuitGameConfirm(const IndexedBitmap &backdrop, cons
 	if (!confirmed)
 		return Common::kNoError;
 
-	_engine.stopStartupMusic();
-	_engine.stopStartupSound();
+	_engine.stopMusic();
+	_engine.stopSound();
 	_engine.quitGame();
 	return Common::kNoError;
 }
 
 Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, Flow &startupFlow) {
-	const Art *art = _engine.getStartupArt();
-	Script *startupScript = _engine.getStartupScript();
+	const Art *art = _engine.getArt();
+	Script *startupScript = _engine.getScript();
 	ResourceManager *resources = _engine.getResources();
 	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
 	const CftFontResource *unselectedFontResource = findStartupFontByName(_engine, "HARVFNT2");
@@ -1707,22 +1707,22 @@ Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const by
 		switch (sliderIndex) {
 		case 0:
 			if (newLevel != startupScript->getFxVolumeLevel()) {
-				_engine.setStartupFxVolumeLevel(newLevel);
-				_engine.playStartupSingleSound(kOptionsPreviewSoundPath);
+				_engine.setFxVolumeLevel(newLevel);
+				_engine.playSingleSound(kOptionsPreviewSoundPath);
 				persistConfig();
 				needsRedraw = true;
 			}
 			break;
 		case 1:
 			if (newLevel != startupScript->getMusicVolumeLevel()) {
-				_engine.setStartupMusicVolumeLevel(newLevel);
+				_engine.setMusicVolumeLevel(newLevel);
 				persistConfig();
 				needsRedraw = true;
 			}
 			break;
 		case 2:
 			if (newLevel != startupScript->getGammaLevel()) {
-				_engine.setStartupGammaLevel(newLevel);
+				_engine.setGammaLevel(newLevel);
 				persistConfig();
 				needsRedraw = true;
 			}
@@ -2128,7 +2128,7 @@ void MenuSystem::renderMainMenuStub(const Common::Array<Common::String> &menuIte
 void MenuSystem::renderMainMenuScreen(int selectedItem, const Common::String &statusMessage,
 		bool drawCursor, const Common::Array<Common::String> &menuItems) const {
 	Graphics::Screen *screen = _engine.getScreen();
-	const Art *art = _engine.getStartupArt();
+	const Art *art = _engine.getArt();
 	const Graphics::Font *titleFont = FontMan.getFontByUsage(Graphics::FontManager::kBigGUIFont);
 	const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
 	if (!screen || !art || !titleFont || !bodyFont)
@@ -2171,7 +2171,7 @@ void MenuSystem::renderBackdropMenuScreen(const IndexedBitmap &backdrop, const b
 		float paletteBrightness, const Common::Array<Common::String> &menuItems,
 		int selectedItem) const {
 	Graphics::Screen *screen = _engine.getScreen();
-	const Art *art = _engine.getStartupArt();
+	const Art *art = _engine.getArt();
 	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
 	const CftFontResource *unselectedFontResource = findStartupFontByName(_engine, "HARVFNT2");
 	if (!screen || !art || !selectedFontResource || !unselectedFontResource || !backdrop.isValid())
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 3a8e4c52393..a934acc914b 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -266,14 +266,14 @@ static bool playPlayerAttackSound(HarvesterEngine &engine, int loadout) {
 		soundIndex = engine.getRandomNumber(soundSet->soundCount - 1);
 
 	const char *soundPath = soundSet->soundPaths[soundIndex];
-	return soundPath && engine.playStartupSound(soundPath);
+	return soundPath && engine.playSound(soundPath);
 }
 
 static bool playPlayerHitSound(HarvesterEngine &engine) {
 	const uint soundIndex = ARRAYSIZE(kPlayerHitSoundPaths) > 1
 		? engine.getRandomNumber(ARRAYSIZE(kPlayerHitSoundPaths) - 1)
 		: 0;
-	return engine.playStartupSound(kPlayerHitSoundPaths[soundIndex]);
+	return engine.playSound(kPlayerHitSoundPaths[soundIndex]);
 }
 
 static bool runtimeEntityHasFrameRange(const Entity &entity, int firstFrame, int lastFrame) {
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index e617970ad61..3873b885ade 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -236,7 +236,7 @@ static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, in
 }
 
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
-	const Text *startupText = engine.getStartupText();
+	const Text *startupText = engine.getText();
 	if (!startupText || !fontName)
 		return nullptr;
 
@@ -302,7 +302,7 @@ static bool loadPaletteResource(ResourceManager &resources, const Common::String
 static void applyRoomPalette(Graphics::Screen &screen, const HarvesterEngine &engine,
 		const byte *palette, float brightness) {
 	byte scaledPalette[256 * 3];
-	buildHarvesterDisplayPalette(palette, brightness * engine.getStartupGammaBrightnessScale(), scaledPalette);
+	buildHarvesterDisplayPalette(palette, brightness * engine.getGammaBrightnessScale(), scaledPalette);
 	screen.setPalette(scaledPalette);
 }
 
@@ -405,7 +405,7 @@ static void drawShadowedRoomText(Graphics::Screen &screen, const Graphics::Font
 
 static void setScaledRoomPalette(Graphics::Screen &screen, const byte *palette, float brightness) {
 	byte scaledPalette[256 * 3];
-	const float gammaBrightness = g_engine ? g_engine->getStartupGammaBrightnessScale() : 1.0f;
+	const float gammaBrightness = g_engine ? g_engine->getGammaBrightnessScale() : 1.0f;
 	buildHarvesterDisplayPalette(palette, brightness * gammaBrightness, scaledPalette);
 	screen.setPalette(scaledPalette);
 }
@@ -627,7 +627,7 @@ static bool playRandomRoomAttackSound(HarvesterEngine &engine, const Common::Str
 	const uint soundIndex = availableCount > 1
 		? engine.getRandomNumber(availableCount - 1)
 		: 0;
-	return engine.playStartupSound(*availableSounds[soundIndex]);
+	return engine.playSound(*availableSounds[soundIndex]);
 }
 
 static bool resolveRoomMonsterHitAnimationRange(const Entity &entity,
@@ -751,7 +751,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 	while (!currentRoomTarget.empty()) {
 		RoomSetupState state;
 		bool shouldRunRoomEntryCommands = false;
-		if (_engine.hasPendingLoadedStartupSaveRoomState()) {
+		if (_engine.hasPendingLoadedSaveRoomState()) {
 			startupFlow.resetRoomNpcDialogueState();
 			if (_engine.hasPendingLoadedDialogueStateBlob()) {
 				if (!startupFlow.loadDialogueSaveStateBlob(
@@ -760,17 +760,17 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					return Common::kReadingFailed;
 				_engine.clearPendingLoadedDialogueStateBlob();
 			}
-			if (_engine.getPendingLoadedStartupDisc() > 0 &&
-					!_engine.activateStartupDisc(_engine.getPendingLoadedStartupDisc())) {
+			if (_engine.getPendingLoadedDisc() > 0 &&
+					!_engine.activateDisc(_engine.getPendingLoadedDisc())) {
 				return Common::kReadingFailed;
 			}
-			const SaveRoomState &loadedState = _engine.getPendingLoadedStartupSaveRoomState();
+			const SaveRoomState &loadedState = _engine.getPendingLoadedSaveRoomState();
 			debugC(1, kDebugRoom,
 				"Harvester: applying pending loaded room state entrance='%s' room='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
 				loadedState.entranceName.c_str(), loadedState.roomName.c_str(),
 				loadedState.playerX, loadedState.playerY, loadedState.playerZ,
 				loadedState.playerFacing, loadedState.musicPath.c_str());
-			if (!_engine.getStartupScript()->materializeRoomState(
+			if (!_engine.getScript()->materializeRoomState(
 					loadedState.entranceName, loadedState.roomName, state, *_engine.getResources())) {
 				return Common::kReadingFailed;
 			}
@@ -781,13 +781,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			state.playerFacing = loadedState.playerFacing;
 			state.musicPath = loadedState.musicPath;
 			currentRoomTarget = !loadedState.entranceName.empty() ? loadedState.entranceName : loadedState.roomName;
-			_engine.clearPendingLoadedStartupSaveRoomState();
+			_engine.clearPendingLoadedSaveRoomState();
 			currentTargetIsRoomName = false;
 			shouldRunRoomEntryCommands = false;
 		} else if ((currentTargetIsRoomName
-				? !_engine.getStartupScript()->resolveRoomSetupStateByRoomName(
+				? !_engine.getScript()->resolveRoomSetupStateByRoomName(
 					currentRoomTarget, state, *_engine.getResources())
-				: !_engine.getStartupScript()->resolveRoomSetupState(
+				: !_engine.getScript()->resolveRoomSetupState(
 					currentRoomTarget, state, *_engine.getResources()))) {
 			return Common::kReadingFailed;
 		} else {
@@ -806,7 +806,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			return _engine.getScreen();
 		};
 		Graphics::Screen *screen = getActiveScreen();
-		const Art *art = _engine.getStartupArt();
+		const Art *art = _engine.getArt();
 		const Graphics::Font *bodyFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
 		const CftFontResource *promptFontResource = findStartupFontByName(_engine, "MEDFONT1");
 		const CftFontResource *inventoryTooltipFontResource = findStartupFontByName(_engine, "TEXTFONT");
@@ -850,7 +850,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		startupFlow.resetCursorAnimationSequence();
 		startupFlow.executeStartupAudioCommands(scene.state.audioCommands);
 		if (!scene.state.musicPath.empty())
-			(void)_engine.playStartupMusic(scene.state.musicPath);
+			(void)_engine.playMusic(scene.state.musicPath);
 		RoomPlayerState playerState;
 		playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName("PLAYER") : nullptr;
 		playerState.centerX = scene.state.playerSpawnX;
@@ -858,7 +858,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		playerState.z = (float)scene.state.playerSpawnZ;
 		playerState.facing = scene.state.playerFacing;
 		playerState.combatLoadout =
-			_engine.getStartupScript() ? _engine.getStartupScript()->getPlayerCombatLoadout() : 0;
+			_engine.getScript() ? _engine.getScript()->getPlayerCombatLoadout() : 0;
 		playerState.turnActive = false;
 		playerState.turnTargetFacing = -1;
 		Common::Array<RoomMonsterCombatState> monsterCombatStates;
@@ -945,7 +945,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			return Player::setIdleAnimation(playerState, resumeFacing);
 		};
 		auto syncCurrentRoomRuntimeState = [&]() {
-			Script *startupScript = _engine.getStartupScript();
+			Script *startupScript = _engine.getScript();
 			if (!startupScript)
 				return;
 
@@ -986,9 +986,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		auto captureCurrentSaveState = [&]() {
 			syncCurrentRoomRuntimeState();
 			const int facing = playerState.facing >= 0 ? playerState.facing : scene.state.playerFacing;
-			_engine.captureCurrentStartupSaveRoomState(scene.state.entranceName, scene.state.roomName,
+			_engine.captureCurrentSaveRoomState(scene.state.entranceName, scene.state.roomName,
 				playerState.centerX, playerState.bottomY, (int)playerState.z, facing,
-				_engine.getStartupMusicPath());
+				_engine.getMusicPath());
 		};
 		auto getSceneObjectBounds = [&](const ObjectRecord &object) {
 			if (object.boundsX2 > object.currentX && object.boundsY2 > object.currentY)
@@ -1008,7 +1008,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			if (object.operatable || !object.actionTag.empty())
 				return true;
 
-			Script *startupScript = _engine.getStartupScript();
+			Script *startupScript = _engine.getScript();
 			if (!startupScript)
 				return false;
 			if (startupScript->isPickupObject(object))
@@ -1377,7 +1377,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			return removedAny;
 		};
 		auto applyCurrentRoomRuntimeMutationsInPlace = [&]() {
-			Script *startupScript = _engine.getStartupScript();
+			Script *startupScript = _engine.getScript();
 			ResourceManager *resources = _engine.getResources();
 			if (!startupScript || !resources)
 				return false;
@@ -1611,7 +1611,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		auto refreshCurrentScene = [&](bool preservePlayerPlacement) {
 			const Common::Array<AudioCommand> entryAudioCommands = scene.state.audioCommands;
 			RoomSetupState refreshedState;
-			if (!_engine.getStartupScript()->materializeRoomState(
+			if (!_engine.getScript()->materializeRoomState(
 					scene.state.entranceName, scene.state.roomName, refreshedState, *_engine.getResources())) {
 				return false;
 			}
@@ -1666,7 +1666,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			playerState.hitKnockbackRemainingX = 0;
 			playerState.hitKnockbackDecayStep = 0;
 			playerState.combatLoadout =
-				_engine.getStartupScript() ? _engine.getStartupScript()->getPlayerCombatLoadout() : 0;
+				_engine.getScript() ? _engine.getScript()->getPlayerCombatLoadout() : 0;
 			monsterCombatStates.clear();
 			monsterCombatStates.resize(scene.state.roomMonsters.size());
 			pendingRegionName.clear();
@@ -1679,7 +1679,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			if (!hasCarriedRoomItem())
 				return true;
 
-			_engine.getStartupScript()->addRuntimeObjectToInventory(carriedRoomItemName);
+			_engine.getScript()->addRuntimeObjectToInventory(carriedRoomItemName);
 			clearCarriedRoomItem();
 			return _inventory.refresh();
 		};
@@ -1757,7 +1757,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				}
 
 				debugC(1, kDebugScene, "Harvester: showing CD change prompt for disc %d", discNumber);
-				_engine.stopStartupMusic();
+				_engine.stopMusic();
 
 				Common::Event event;
 				while (g_system->getEventManager()->pollEvent(event)) {
@@ -1810,12 +1810,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				}
 			}
 
-			if (!_engine.activateStartupDisc(discNumber)) {
+			if (!_engine.activateDisc(discNumber)) {
 				warning("Harvester: unable to activate disc %d resources", discNumber);
 				return Common::kReadingFailed;
 			}
 			if (discNumber == 3 && previousDisc > 0 && previousDisc != resources->getCurrentDisc()) {
-				Script *startupScript = _engine.getStartupScript();
+				Script *startupScript = _engine.getScript();
 				if (!startupScript || !startupScript->reloadTownWorld(*resources)) {
 					warning("Harvester: unable to reload town script after disc prompt %d -> %d",
 						previousDisc, discNumber);
@@ -1827,7 +1827,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		};
 		auto runRoomExitCommands = [&]() -> Common::Error {
 			InteractionResult exitInteraction;
-			if (!_engine.getStartupScript()->executeRoomExitCommands(scene.state.roomName, exitInteraction))
+			if (!_engine.getScript()->executeRoomExitCommands(scene.state.roomName, exitInteraction))
 				return Common::kReadingFailed;
 
 			for (uint exitStep = 0; exitStep < 128; ++exitStep) {
@@ -1837,7 +1837,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 						return cdPromptError;
 				}
 				if (!exitInteraction.musicPath.empty())
-					(void)_engine.playStartupMusic(exitInteraction.musicPath);
+					(void)_engine.playMusic(exitInteraction.musicPath);
 				startupFlow.executeStartupAudioCommands(exitInteraction.audioCommands);
 
 				if (!exitInteraction.cutscenePath.empty()) {
@@ -1860,7 +1860,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					return Common::kNoError;
 
 				InteractionResult continuationInteraction;
-				if (!_engine.getStartupScript()->executeActionTag(
+				if (!_engine.getScript()->executeActionTag(
 						exitInteraction.continuationTag, continuationInteraction, false)) {
 					return Common::kNoError;
 				}
@@ -2019,8 +2019,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				pendingRegionName.clear();
 
 				if (interaction.requestMainMenu) {
-					engine.stopStartupMusic();
-					engine.stopStartupSound();
+					engine.stopMusic();
+					engine.stopSound();
 					if (!interaction.deathFlicPath.empty()) {
 						FstPlayer fstPlayer(engine);
 						if (!fstPlayer.play(interaction.deathFlicPath))
@@ -2034,7 +2034,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					return Common::kNoError;
 				}
 
-				Common::String restoreMusicPath = engine.getStartupMusicPath();
+				Common::String restoreMusicPath = engine.getMusicPath();
 				bool discChanged = false;
 				if (interaction.cdChangeDisc > 0) {
 					ResourceManager *resources = engine.getResources();
@@ -2046,8 +2046,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 						previousDisc != resources->getCurrentDisc();
 				}
 				if (!interaction.musicPath.empty()) {
-					(void)engine.playStartupMusic(interaction.musicPath);
-					restoreMusicPath = engine.getStartupMusicPath();
+					(void)engine.playMusic(interaction.musicPath);
+					restoreMusicPath = engine.getMusicPath();
 				}
 				startupFlow.executeStartupAudioCommands(interaction.audioCommands);
 				auto queueImplicitRoomRestart = [&]() -> Common::Error {
@@ -2127,9 +2127,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 					startupFlow.executeStartupAudioCommands(scene.state.audioCommands);
 					if (!restoreMusicPath.empty())
-						(void)engine.playStartupMusic(restoreMusicPath);
+						(void)engine.playMusic(restoreMusicPath);
 					else
-						engine.stopStartupMusic();
+						engine.stopMusic();
 					didTransition = true;
 				} else if (interaction.mutatedRuntimeState) {
 					if (!applyCurrentRoomRuntimeMutationsInPlaceFn() &&
@@ -2175,7 +2175,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 				if (!interaction.continuationTag.empty()) {
 					InteractionResult continuationInteraction;
-					if (engine.getStartupScript()->executeActionTag(
+					if (engine.getScript()->executeActionTag(
 							interaction.continuationTag, continuationInteraction)) {
 						Common::Error interactionError =
 							handleInteractionResult(continuationInteraction, didTransition, usedItemName);
@@ -2191,7 +2191,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					const Common::String &continuationTag, bool &didTransition) {
 				didTransition = false;
 
-				const NpcRecord *dialogueNpc = engine.getStartupScript()->findRuntimeNpcRecord(npcName);
+				const NpcRecord *dialogueNpc = engine.getScript()->findRuntimeNpcRecord(npcName);
 				if (dialogueNpc) {
 					IndexedBitmap dialogueBackdrop;
 					if (!captureDialogueBackdropFn(dialogueBackdrop))
@@ -2220,7 +2220,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				}
 				if (!didTransition && !abortRemainingCommandChain && !continuationTag.empty()) {
 					InteractionResult continuationInteraction;
-					if (engine.getStartupScript()->executeActionTag(continuationTag, continuationInteraction)) {
+					if (engine.getScript()->executeActionTag(continuationTag, continuationInteraction)) {
 						Common::Error interactionError =
 							handleInteractionResult(continuationInteraction, didTransition, usedItemName);
 						if (interactionError.getCode() != Common::kNoError)
@@ -2261,7 +2261,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		InteractionResult roomEntryInteraction;
 		bool hasPendingRoomEntryInteraction = false;
 		if (shouldRunRoomEntryCommands) {
-			if (!_engine.getStartupScript()->executeRoomEnterCommands(
+			if (!_engine.getScript()->executeRoomEnterCommands(
 					scene.state.roomName, roomEntryInteraction)) {
 				return Common::kReadingFailed;
 			}
@@ -2428,8 +2428,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		debugC(1, kDebugCombat,
 			"Harvester: player game over queued reason='%s' source='%s' hp=%d",
 			reason ? reason : "", sourceName.c_str(),
-			_engine.getStartupScript()
-				? _engine.getStartupScript()->getPlayerCurrentHitPoints()
+			_engine.getScript()
+				? _engine.getScript()->getPlayerCurrentHitPoints()
 				: 0);
 		startupFlow.requestGameOverReturn();
 	};
@@ -2453,8 +2453,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			debugC(1, kDebugCombat,
 				"Harvester: player defeat fallback reason='%s' source='%s' damage_type=%d hp=%d",
 				reason ? reason : "", sourceName.c_str(), damageType,
-				_engine.getStartupScript()
-					? _engine.getStartupScript()->getPlayerCurrentHitPoints()
+				_engine.getScript()
+					? _engine.getScript()->getPlayerCurrentHitPoints()
 					: 0);
 			requestPlayerGameOver(reason, sourceName);
 			return;
@@ -2463,8 +2463,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		debugC(1, kDebugCombat,
 			"Harvester: player defeat started reason='%s' source='%s' damage_type=%d hp=%d",
 			reason ? reason : "", sourceName.c_str(), damageType,
-			_engine.getStartupScript()
-				? _engine.getStartupScript()->getPlayerCurrentHitPoints()
+			_engine.getScript()
+				? _engine.getScript()->getPlayerCurrentHitPoints()
 				: 0);
 	};
 	auto handleCombatInteraction = [&](InteractionResult interaction) -> Common::Error {
@@ -2474,8 +2474,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		if (interactionError.getCode() != Common::kNoError)
 			return interactionError;
 		if (!startupFlow.hasPendingMainMenuReturn() &&
-				_engine.getStartupScript() &&
-				_engine.getStartupScript()->getPlayerCurrentHitPoints() <= 0) {
+				_engine.getScript() &&
+				_engine.getScript()->getPlayerCurrentHitPoints() <= 0) {
 			stopPlayerRegionInteraction();
 			startPlayerDefeatSequence("combat_interaction", Common::String(), 1);
 		}
@@ -2500,7 +2500,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		npcEntity.setAnimationEnabled(true);
 		npcEntity.setCurrentFrame(deathRange.firstFrame);
 		if (!npc.audioPath.empty())
-			(void)_engine.playStartupSound(npc.audioPath);
+			(void)_engine.playSound(npc.audioPath);
 		debugC(1, kDebugCombat,
 			"Harvester: combat npc death start target='%s' damage_type=%d frames=%d..%d monsterfy='%s' on_death='%s'",
 			npc.npcName.c_str(), deathDamageType, deathRange.firstFrame, deathRange.lastFrame,
@@ -2510,7 +2510,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 	};
 	auto finalizeNpcDeathTransition = [&](NpcRecord &npc,
 			RoomNpcCombatState &combatState) -> Common::Error {
-		Script *runtimeScript = _engine.getStartupScript();
+		Script *runtimeScript = _engine.getScript();
 		removeSceneEntityByName(npc.npcName);
 		npc.active = false;
 		npc.visible = false;
@@ -2611,7 +2611,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			return Common::kNoError;
 		}
 
-		Script *startupScript = _engine.getStartupScript();
+		Script *startupScript = _engine.getScript();
 		if (!startupScript) {
 			debugC(1, kDebugCombat,
 				"Harvester: debug combat kill skipped target='%s' reason='no startup script'",
@@ -2657,7 +2657,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			monsterEntity->setAnimationEnabled(true);
 			monsterEntity->setCurrentFrame(deathRange.firstFrame);
 			if (!monster->deathSound.empty())
-				(void)_engine.playStartupSound(monster->deathSound);
+				(void)_engine.playSound(monster->deathSound);
 			needsRedraw = true;
 			return Common::kNoError;
 		}
@@ -2888,7 +2888,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			Player::describeCombatLoadout(playerState.combatLoadout), damageAmount,
 			Player::describeCombatDamageType(damageType),
 			describeCombatTargetClass(playerState.attackTargetClassId), playerState.attackTargetName.c_str());
-		Script *startupScript = _engine.getStartupScript();
+		Script *startupScript = _engine.getScript();
 		if (!startupScript)
 			return Common::kNoError;
 
@@ -3009,7 +3009,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			monsterEntity->setAnimationEnabled(true);
 			monsterEntity->setCurrentFrame(deathRange.firstFrame);
 			if (!monster->deathSound.empty())
-				(void)_engine.playStartupSound(monster->deathSound);
+				(void)_engine.playSound(monster->deathSound);
 			debugC(1, kDebugCombat,
 				"Harvester: combat monster death start target='%s' damage_type=%d frames=%d..%d gore=%d on_death='%s'",
 				monster->monsterName.c_str(), damageType,
@@ -3040,7 +3040,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		if (!runtimeEntities)
 			return Common::kNoError;
 
-		Script *startupScript = _engine.getStartupScript();
+		Script *startupScript = _engine.getScript();
 		const uint32 now = Player::getRuntimeClockTicks();
 		const uint32 moveInterval = MAX<uint32>(1, 100U / (uint32)kRoomMonsterAnimationRate);
 		const int horizontalStepBase = 8;
@@ -3357,7 +3357,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		return Common::kNoError;
 	};
 	auto moveRoomItemDirectlyToInventory = [&](const ObjectRecord &object) -> Common::Error {
-		Script *startupScript = _engine.getStartupScript();
+		Script *startupScript = _engine.getScript();
 		if (!startupScript)
 			return Common::kReadingFailed;
 
@@ -3395,7 +3395,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		return Common::kNoError;
 	};
 	auto beginRoomItemCarry = [&](const ObjectRecord &object) -> Common::Error {
-		Script *startupScript = _engine.getStartupScript();
+		Script *startupScript = _engine.getScript();
 		ResourceManager *resources = _engine.getResources();
 		if (!startupScript || !resources)
 			return Common::kReadingFailed;
@@ -3459,7 +3459,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 		const Common::String selectedItemName = _inventory.getSelectedItemName();
 		InteractionResult interaction;
-		const bool handled = _engine.getStartupScript()->resolveUseItemInteraction(
+		const bool handled = _engine.getScript()->resolveUseItemInteraction(
 			selectedItemName, target, interaction);
 		if (!handled) {
 			debugC(1, kDebugInventory,
@@ -3488,7 +3488,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			return nullptr;
 
 		EntityManager *runtimeEntities = _engine.getRuntimeEntities();
-		Script *startupScript = _engine.getStartupScript();
+		Script *startupScript = _engine.getScript();
 		if (!runtimeEntities || !startupScript)
 			return nullptr;
 
@@ -3535,7 +3535,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 	};
 	auto runRegionInteraction = [&](const RegionRecord &region) -> Common::Error {
 		InteractionResult interaction;
-		if (!_engine.getStartupScript()->resolveRegionInteraction(region, interaction))
+		if (!_engine.getScript()->resolveRegionInteraction(region, interaction))
 			return Common::kNoError;
 
 		bool didTransition = false;
@@ -3608,10 +3608,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				return Common::kReadingFailed;
 			break;
 		}
-		if (_engine.hasPendingLoadedStartupSaveRoomState()) {
-			pendingRoomChange = _engine.getPendingLoadedStartupSaveRoomState().entranceName;
+		if (_engine.hasPendingLoadedSaveRoomState()) {
+			pendingRoomChange = _engine.getPendingLoadedSaveRoomState().entranceName;
 			if (pendingRoomChange.empty())
-				pendingRoomChange = _engine.getPendingLoadedStartupSaveRoomState().roomName;
+				pendingRoomChange = _engine.getPendingLoadedSaveRoomState().roomName;
 			if (!stowCarriedRoomItemToInventory())
 				return Common::kReadingFailed;
 			break;
@@ -3675,7 +3675,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				if (hoverState.playerEntity && playerState.entity && hoverState.playerEntity == playerState.entity)
 					return Common::String(kPlayerInventoryLabel);
 				if (hoverState.object)
-					return _engine.getStartupScript()->resolveObjectLabel(*hoverState.object);
+					return _engine.getScript()->resolveObjectLabel(*hoverState.object);
 				if (hoverState.npc)
 					return resolveStartupNpcLabel(*hoverState.npc);
 				return Common::String();
@@ -3687,7 +3687,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					if (inventoryHover && !InventorySystem::isExitObject(inventoryHover->object) &&
 							!InventorySystem::isStatusObject(inventoryHover->object) &&
 							!inventoryHover->object.objectName.equalsIgnoreCase(_inventory.getSelectedItemName())) {
-						targetLabel = _engine.getStartupScript()->resolveObjectLabel(inventoryHover->object);
+						targetLabel = _engine.getScript()->resolveObjectLabel(inventoryHover->object);
 					} else if (!inventoryPanelContainsMouse) {
 						targetLabel = resolveCarryTargetLabel();
 					}
@@ -3706,7 +3706,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				if (inventoryHover &&
 						!InventorySystem::isExitObject(inventoryHover->object)) {
 					inventoryTooltipText =
-						_engine.getStartupScript()->resolveInventoryTooltipText(inventoryHover->object);
+						_engine.getScript()->resolveInventoryTooltipText(inventoryHover->object);
 				}
 				_inventory.setPromptText(Common::String());
 				hoverState.cursorSequence = 7;
@@ -3804,10 +3804,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 								captureCurrentSaveState();
 								if (playerState.entity) {
 									(void)Player::syncCombatLoadoutVisual(_engine, scene.state, playerState,
-										_engine.getStartupScript()->getPlayerCombatLoadout());
-								} else if (_engine.getStartupScript()) {
+										_engine.getScript()->getPlayerCombatLoadout());
+								} else if (_engine.getScript()) {
 									playerState.combatLoadout =
-										_engine.getStartupScript()->getPlayerCombatLoadout();
+										_engine.getScript()->getPlayerCombatLoadout();
 								}
 								if (!_inventory.refresh())
 									return Common::kReadingFailed;
@@ -3827,7 +3827,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 							InteractionResult interaction;
 							bool didTransition = false;
-							const bool executedActionTag = _engine.getStartupScript()->executeActionTag(
+							const bool executedActionTag = _engine.getScript()->executeActionTag(
 									secondaryAction.actionTag, interaction);
 							debugC(1, kDebugInventory,
 								"Harvester: inventory right click executeActionTag('%s') -> %d music='%s' cutscene='%s' nextRoom='%s'",
@@ -3879,7 +3879,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					needsRedraw = true;
 					break;
 				}
-				Script *startupScript = _engine.getStartupScript();
+				Script *startupScript = _engine.getScript();
 				if (!showingInspectText && !isPlayerCombatLocked() && startupScript && playerState.entity &&
 						startupScript->getPlayerCurrentHitPoints() > 0) {
 					playerState.hasMoveTarget = false;
@@ -4122,14 +4122,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 				ResolvedText resolvedInspectText;
 				const bool hasInspectText =
-					_engine.getStartupScript()->resolveObjectInspectText(*clickedObject, resolvedInspectText);
+					_engine.getScript()->resolveObjectInspectText(*clickedObject, resolvedInspectText);
 				const bool unlocksAfterInitialExamine =
-					unlocksRoomObjectInteractionAfterInitialExamine(*clickedObject, *_engine.getStartupScript());
+					unlocksRoomObjectInteractionAfterInitialExamine(*clickedObject, *_engine.getScript());
 				const bool canShowInspectText =
 					hasInspectText && resolveInspectTextboxBitmap(*art, resolvedInspectText);
 				if (!clickedObject->identShown && unlocksAfterInitialExamine) {
 					clickedObject->identShown = true;
-					_engine.getStartupScript()->markObjectIdentShown(*clickedObject);
+					_engine.getScript()->markObjectIdentShown(*clickedObject);
 					if (canShowInspectText) {
 						inspectText = resolvedInspectText;
 						showingInspectText = true;
@@ -4144,9 +4144,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					needsRedraw = true;
 					break;
 				}
-				if (_engine.getStartupScript()->isPickupObject(*clickedObject)) {
+				if (_engine.getScript()->isPickupObject(*clickedObject)) {
 					InteractionResult blockedPickupInteraction;
-					if (_engine.getStartupScript()->isPickupBlockedByAction(
+					if (_engine.getScript()->isPickupBlockedByAction(
 							*clickedObject, &blockedPickupInteraction)) {
 						bool didTransition = false;
 						Common::Error interactionError =
@@ -4167,7 +4167,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				}
 
 				InteractionResult interaction;
-				if (!_engine.getStartupScript()->resolveObjectInteraction(*clickedObject, interaction)) {
+				if (!_engine.getScript()->resolveObjectInteraction(*clickedObject, interaction)) {
 					if (canShowInspectText) {
 						inspectText = resolvedInspectText;
 						showingInspectText = true;
@@ -4329,8 +4329,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		}
 
 		const bool playerCanAct =
-			_engine.getStartupScript() &&
-			_engine.getStartupScript()->getPlayerCurrentHitPoints() > 0;
+			_engine.getScript() &&
+			_engine.getScript()->getPlayerCurrentHitPoints() > 0;
 		if (!playerCanAct && (moveLeft || moveRight || moveUp || moveDown ||
 				playerState.hasMoveTarget || playerState.turnActive ||
 				playerState.attackActive || playerState.hitActive)) {
@@ -4479,7 +4479,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				syncCurrentRoomRuntimeState();
 				for (const Common::String &timerName : expiredTimerNames) {
 					InteractionResult timerInteraction;
-					if (!_engine.getStartupScript()->executeTimerAction(timerName, timerInteraction))
+					if (!_engine.getScript()->executeTimerAction(timerName, timerInteraction))
 						continue;
 
 					bool didTransition = false;
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index 6d517a81b0b..b6ce531db43 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -63,7 +63,7 @@ static void syncStartupSaveRoomState(Common::Serializer &s, SaveRoomState &state
 
 } // End of anonymous namespace
 
-void HarvesterEngine::captureCurrentStartupSaveRoomState(const Common::String &entranceName,
+void HarvesterEngine::captureCurrentSaveRoomState(const Common::String &entranceName,
 		const Common::String &roomName, int playerX, int playerY, int playerZ, int playerFacing,
 		const Common::String &musicPath) {
 	_currentStartupSaveRoomState.entranceName = entranceName;
@@ -77,11 +77,11 @@ void HarvesterEngine::captureCurrentStartupSaveRoomState(const Common::String &e
 	_currentStartupSaveRoomState.valid = !roomName.empty();
 }
 
-void HarvesterEngine::clearCurrentStartupSaveRoomState() {
+void HarvesterEngine::clearCurrentSaveRoomState() {
 	_currentStartupSaveRoomState.clear();
 }
 
-void HarvesterEngine::clearPendingLoadedStartupSaveRoomState() {
+void HarvesterEngine::clearPendingLoadedSaveRoomState() {
 	_pendingLoadedStartupSaveRoomState.clear();
 	_pendingLoadedStartupDisc = 0;
 }
@@ -90,7 +90,7 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 	if (!_startupScript)
 		return s.isLoading() ? Common::kReadingFailed : Common::kWritingFailed;
 	if (s.isLoading()) {
-		clearPendingLoadedStartupSaveRoomState();
+		clearPendingLoadedSaveRoomState();
 		clearPendingLoadedDialogueStateBlob();
 	}
 	if (s.isSaving() && !_currentStartupSaveRoomState.valid)
@@ -146,7 +146,7 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 		const int previousDisc = (_resources && _resources->getCurrentDisc() > 0)
 			? _resources->getCurrentDisc()
 			: 0;
-		if (!activateStartupDisc(restoredDisc))
+		if (!activateDisc(restoredDisc))
 			return Common::kReadingFailed;
 		if (previousDisc > 0 && previousDisc != restoredDisc) {
 			if (!_startupScript->reloadTownWorld(*_resources)) {


Commit: 24adbb4213729e9b7ae054fbb053673840510465
    https://github.com/scummvm/scummvm/commit/24adbb4213729e9b7ae054fbb053673840510465
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:36+02:00

Commit Message:
HARVESTER: Rename runtime record arrays

- Renamed runtimeFlags to currentFlags

- Renamed runtimeObjects to currentObjects

- Renamed runtimeAnimations to currentAnimations

- Renamed runtimeRegions to currentRegions

- Renamed runtimeNpcs to currentNpcs

- Renamed runtimeMonsters to currentMonsters

- Renamed runtimeTimers to currentTimers

This change improves clarity and consistency in naming conventions.

Changed paths:
    engines/harvester/console.cpp
    engines/harvester/dialogue.cpp
    engines/harvester/dialogue.h
    engines/harvester/flow.cpp
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/inventory.cpp
    engines/harvester/media_manager.cpp
    engines/harvester/media_manager.h
    engines/harvester/menu.cpp
    engines/harvester/menu.h
    engines/harvester/npc/dialogue_runtime.h
    engines/harvester/player.cpp
    engines/harvester/room.cpp
    engines/harvester/room.h
    engines/harvester/room_support.h
    engines/harvester/saveload.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/engines/harvester/console.cpp b/engines/harvester/console.cpp
index 17a9b65740c..85417325db3 100644
--- a/engines/harvester/console.cpp
+++ b/engines/harvester/console.cpp
@@ -430,8 +430,8 @@ bool Console::Cmd_debugActions(int argc, const char **argv) {
 		return true;
 	}
 
-	Script *startupScript = g_engine->getScript();
-	if (!startupScript || !g_engine->hasCurrentSaveRoomState()) {
+	Script *script = g_engine->getScript();
+	if (!script || !g_engine->hasCurrentSaveRoomState()) {
 		debugPrintf("DEBUG_ACTIONS is only available while a room is active\n");
 		return true;
 	}
@@ -444,7 +444,7 @@ bool Console::Cmd_debugActions(int argc, const char **argv) {
 
 	const SaveRoomState &roomState = g_engine->getCurrentSaveRoomState();
 	RoomSetupState materializedState;
-	if (!startupScript->materializeRoomState(roomState.entranceName, roomState.roomName,
+	if (!script->materializeRoomState(roomState.entranceName, roomState.roomName,
 			materializedState, *resources)) {
 		debugPrintf("Unable to materialize room state for '%s'\n", roomState.roomName.c_str());
 		return true;
@@ -467,7 +467,7 @@ bool Console::Cmd_debugActions(int argc, const char **argv) {
 		if (object.actionTag.empty())
 			continue;
 
-		const Common::String label = startupScript->resolveObjectLabel(object);
+		const Common::String label = script->resolveObjectLabel(object);
 		Common::String objectLine = Common::String::format(
 			"Object '%s' owner='%s' visible=%d runtimeVisible=%d operatable=%d action='%s'",
 			object.objectName.c_str(), object.currentOwnerOrRoom.c_str(),
@@ -477,7 +477,7 @@ bool Console::Cmd_debugActions(int argc, const char **argv) {
 		debugPrintf("%s\n", objectLine.c_str());
 
 		Common::Array<Common::String> chainLines;
-		buildActionChainLines(*startupScript, object.actionTag, chainLines);
+		buildActionChainLines(*script, object.actionTag, chainLines);
 		for (const Common::String &line : chainLines)
 			debugPrintf("%s\n", line.c_str());
 	}
@@ -525,14 +525,14 @@ bool Console::Cmd_gotoRoom(int argc, const char **argv) {
 		return true;
 	}
 
-	Script *startupScript = getActiveStartupScript();
-	if (!startupScript) {
+	Script *script = getActiveStartupScript();
+	if (!script) {
 		debugPrintf("Harvester engine is not active\n");
 		return true;
 	}
 
 	Common::Array<Common::String> roomNames;
-	collectSortedRoomNames(*startupScript, roomNames);
+	collectSortedRoomNames(*script, roomNames);
 	if (roomNames.empty()) {
 		debugPrintf("No rooms were found in HARVEST.SCR\n");
 		return true;
@@ -551,7 +551,7 @@ bool Console::Cmd_gotoRoom(int argc, const char **argv) {
 	}
 
 	Common::String roomName;
-	if (!findRoomName(*startupScript, argv[1], roomName)) {
+	if (!findRoomName(*script, argv[1], roomName)) {
 		debugPrintf("Unknown room target '%s'\n", argv[1]);
 		return true;
 	}
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index fc669ff2303..84caa383687 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -173,11 +173,11 @@ static void syncDialogueSharedState(Common::Serializer &s, DialogueSharedState &
 }
 
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
-	const Text *startupText = engine.getText();
-	if (!startupText || !fontName)
+	const Text *text = engine.getText();
+	if (!text || !fontName)
 		return nullptr;
 
-	for (const CftFontResource &font : startupText->getFonts()) {
+	for (const CftFontResource &font : text->getFonts()) {
 		if (font.name.equalsIgnoreCase(fontName))
 			return &font;
 	}
@@ -424,24 +424,24 @@ static Common::String buildDialogueHeadId(const Common::String &speakerId, int h
 
 static bool loadDialogueHeadBitmap(HarvesterEngine &engine, const Common::String &speakerId, int headVariant,
 		IndexedBitmap &bitmap) {
-	Script *startupScript = engine.getScript();
+	Script *script = engine.getScript();
 	ResourceManager *resources = engine.getResources();
-	if (!startupScript || !resources || speakerId.empty())
+	if (!script || !resources || speakerId.empty())
 		return false;
 
 	const Common::String headId = buildDialogueHeadId(speakerId, headVariant);
-	const HeadRecord *head = startupScript->findHeadRecord(headId);
+	const HeadRecord *head = script->findHeadRecord(headId);
 	if (!head)
 		return false;
 
 	return loadBitmapResource(*resources, head->portraitPath + ".BM", bitmap);
 }
 
-static Common::String buildDialogueVoicePath(const Script &startupScript, int wavId) {
+static Common::String buildDialogueVoicePath(const Script &script, int wavId) {
 	if (wavId <= 0)
 		return Common::String();
 
-	return Common::String::format("%s%d.CMP", startupScript.getVoicePath().c_str(), wavId);
+	return Common::String::format("%s%d.CMP", script.getVoicePath().c_str(), wavId);
 }
 
 static int getDialogueMenuItemAt(const Graphics::Font &font, uint itemCount, const Common::Point &mousePos) {
@@ -554,7 +554,7 @@ void DialogueSystem::syncRuntimeSaveState(Common::Serializer &s) {
 
 Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, const NpcRecord &npc, const Common::String &usedItemName,
-		Flow &startupFlow) {
+		Flow &flow) {
 	struct DialogueResponseOptionLayout {
 		Common::String text;
 		Common::Array<Common::String> wrappedLines;
@@ -563,12 +563,12 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 	};
 
 	Graphics::Screen *screen = _engine.getScreen();
-	Script *startupScript = _engine.getScript();
-	Text *startupText = _engine.getText();
-	Art *startupArt = _engine.getArt();
-	EntityManager *runtimeEntities = _engine.getRuntimeEntities();
+	Script *script = _engine.getScript();
+	Text *text = _engine.getText();
+	Art *art = _engine.getArt();
+	EntityManager *entityManager = _engine.getRuntimeEntities();
 	const Graphics::Font *fallbackFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	if (!screen || !startupScript || !startupText || !startupArt || !fallbackFont || !backdrop.isValid())
+	if (!screen || !script || !text || !art || !fallbackFont || !backdrop.isValid())
 		return Common::kReadingFailed;
 	if (!hasRoomNpcHandler(npc.npcName)) {
 		debugC(1, kDebugDialogue,
@@ -576,8 +576,8 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			npc.npcName.c_str());
 		return Common::kNoError;
 	}
-	if (!startupScript->isNamedNpcDeathTypeClear(npc.npcName)) {
-		const NpcRecord *runtimeNpc = startupScript->findRuntimeNpcRecord(npc.npcName);
+	if (!script->isNamedNpcDeathTypeClear(npc.npcName)) {
+		const NpcRecord *runtimeNpc = script->findRuntimeNpcRecord(npc.npcName);
 		debugC(1, kDebugDialogue,
 			"Harvester: blocked room NPC dialogue npc='%s' reason='death queued' damage_type=%d",
 			npc.npcName.c_str(), runtimeNpc ? runtimeNpc->deathDamageType : -1);
@@ -610,7 +610,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 	if (!loadBitmapResource(*_engine.getResources(), kDialogueKeywordBitmapPath, keywordBitmap))
 		return Common::kReadingFailed;
 
-	Common::String genericByeTopic = startupText->getDialogueResponseLine(kDialogueGenericByeResponseIndex);
+	Common::String genericByeTopic = text->getDialogueResponseLine(kDialogueGenericByeResponseIndex);
 	if (genericByeTopic.empty())
 		genericByeTopic = "BYE";
 
@@ -635,12 +635,12 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			Common::Event event;
 			while (g_system->getEventManager()->pollEvent(event)) {
 				Common::Error result = Common::kNoError;
-				if (startupFlow.handleSystemEvent(event, result))
+				if (flow.handleSystemEvent(event, result))
 					return result;
 			}
 
-			if (runtimeEntities)
-				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+			if (entityManager)
+				(void)entityManager->syncCursorEntityPosition(_mousePos);
 			limiter.delayBeforeSwap();
 			limiter.startFrame();
 		}
@@ -747,8 +747,8 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 				kDialogueKeywordTextWidth - 30, kTextColorNormal);
 		}
 
-		if (runtimeEntities)
-			runtimeEntities->drawCursor(*activeScreen);
+		if (entityManager)
+			entityManager->drawCursor(*activeScreen);
 		activeScreen->makeAllDirty();
 		activeScreen->update();
 	};
@@ -757,17 +757,17 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		setActiveSpeakerPortrait(speakerId, headVariant);
 
 		Common::String subtitleText;
-		const bool textEnabled = startupScript->getDialogueTextMode() != kStartupDialogueTextNone &&
-			startupText->resolveDialogueSubtitle(wavId, subtitleText);
+		const bool textEnabled = script->getDialogueTextMode() != kStartupDialogueTextNone &&
+			text->resolveDialogueSubtitle(wavId, subtitleText);
 		Common::Array<Common::String> subtitleLines;
 		const IndexedBitmap *textboxBitmap = nullptr;
 		if (textEnabled) {
 			wrapDialogueTextLikeNative(*subtitleFont, subtitleFontUsesCft,
 				subtitleText, kDialogueSubtitleTextWidth, subtitleLines);
-			textboxBitmap = startupArt->getTextboxBitmap(resolveDialogueTextboxIndex(subtitleLines.size()));
+			textboxBitmap = art->getTextboxBitmap(resolveDialogueTextboxIndex(subtitleLines.size()));
 		}
 
-		const Common::String voicePath = buildDialogueVoicePath(*startupScript, wavId);
+		const Common::String voicePath = buildDialogueVoicePath(*script, wavId);
 		const bool voiceStarted = !voicePath.empty() && _engine.playSpeech(voicePath);
 		debugC(2, kDebugDialogue,
 			"Harvester: dialogue line wav=0x%x speaker='%s' headVariant=%d voice='%s' subtitle='%s'",
@@ -787,7 +787,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			Common::Event event;
 			while (g_system->getEventManager()->pollEvent(event)) {
 				Common::Error result = Common::kNoError;
-				if (startupFlow.handleSystemEvent(event, result)) {
+				if (flow.handleSystemEvent(event, result)) {
 					_engine.stopSpeech();
 					return result;
 				}
@@ -810,8 +810,8 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 				}
 			}
 
-			if (runtimeEntities)
-				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+			if (entityManager)
+				(void)entityManager->syncCursorEntityPosition(_mousePos);
 			if (interrupted || (!voiceStarted || !_engine.isSpeechPlaying()))
 				break;
 
@@ -824,7 +824,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			Common::Error releaseResult = waitForPointerRelease();
 			if (releaseResult.getCode() != Common::kNoError)
 				return releaseResult;
-		} else if (textEnabled && startupScript->getDialogueTextMode() == kStartupDialogueTextClick) {
+		} else if (textEnabled && script->getDialogueTextMode() == kStartupDialogueTextClick) {
 			Graphics::FrameLimiter clickLimiter(g_system, 60);
 			for (;;) {
 				drawDialogueOverlay(textboxBitmap, &subtitleLines, nullptr, -1, false, nullptr);
@@ -833,7 +833,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 				Common::Event event;
 				while (g_system->getEventManager()->pollEvent(event)) {
 					Common::Error result = Common::kNoError;
-					if (startupFlow.handleSystemEvent(event, result))
+					if (flow.handleSystemEvent(event, result))
 						return result;
 
 					switch (event.type) {
@@ -854,8 +854,8 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 					}
 				}
 
-				if (runtimeEntities)
-					(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+				if (entityManager)
+					(void)entityManager->syncCursorEntityPosition(_mousePos);
 				if (continuePressed)
 					break;
 
@@ -935,7 +935,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			Common::Event event;
 			while (g_system->getEventManager()->pollEvent(event)) {
 				Common::Error result = Common::kNoError;
-				if (startupFlow.handleSystemEvent(event, result))
+				if (flow.handleSystemEvent(event, result))
 					return result;
 				if (event.type == Common::EVENT_MOUSEMOVE)
 					_mousePos = event.mouse;
@@ -951,8 +951,8 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 					screen->fillRect(screen->getBounds(), 0);
 					blitBitmap(*screen, promptBitmap, 0, 0);
 					setScaledPalette(*screen, promptPalette, 1.0f);
-					if (runtimeEntities)
-						runtimeEntities->drawCursor(*screen);
+					if (entityManager)
+						entityManager->drawCursor(*screen);
 					screen->makeAllDirty();
 					screen->update();
 					needsRedraw = false;
@@ -960,7 +960,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 
 				while (g_system->getEventManager()->pollEvent(event)) {
 					Common::Error result = Common::kNoError;
-					if (startupFlow.handleSystemEvent(event, result))
+					if (flow.handleSystemEvent(event, result))
 						return result;
 
 					switch (event.type) {
@@ -980,7 +980,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 					}
 				}
 
-				if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
+				if (entityManager && entityManager->syncCursorEntityPosition(_mousePos))
 					needsRedraw = true;
 
 				limiter.delayBeforeSwap();
@@ -993,8 +993,8 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			return Common::kReadingFailed;
 		}
 		if (discNumber == 3 && previousDisc > 0 && previousDisc != resources->getCurrentDisc()) {
-			Script *startupScript = _engine.getScript();
-			if (!startupScript || !startupScript->reloadTownWorld(*resources)) {
+			Script *script = _engine.getScript();
+			if (!script || !script->reloadTownWorld(*resources)) {
 				warning("Harvester: unable to reload town script after disc prompt %d -> %d",
 					previousDisc, discNumber);
 				return Common::kReadingFailed;
@@ -1034,7 +1034,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			Common::Event event;
 			while (g_system->getEventManager()->pollEvent(event)) {
 				Common::Error result = Common::kNoError;
-				if (startupFlow.handleSystemEvent(event, result)) {
+				if (flow.handleSystemEvent(event, result)) {
 					_engine.stopMusic();
 					return result;
 				}
@@ -1050,8 +1050,8 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 				}
 			}
 
-			if (runtimeEntities)
-				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+			if (entityManager)
+				(void)entityManager->syncCursorEntityPosition(_mousePos);
 			if (dismissed)
 				break;
 
@@ -1066,7 +1066,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 				return releaseError;
 		}
 
-		startupFlow.requestMainMenuReturn();
+		flow.requestMainMenuReturn();
 		return Common::kNoError;
 	};
 
@@ -1084,14 +1084,14 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 				interaction.lightingCommand != kStartupLightingCommandNone ||
 				interaction.requestPlayerGotoXZ ||
 				!interaction.audioCommands.empty()) {
-			startupFlow.queueDialogueInteraction(interaction);
+			flow.queueDialogueInteraction(interaction);
 		}
 	};
 	auto applyImmediateDialogueInteractionEffects = [&](InteractionResult &interaction) {
 		if (!interaction.musicPath.empty())
 			(void)_engine.playMusic(interaction.musicPath);
 		if (!interaction.audioCommands.empty())
-			startupFlow.executeStartupAudioCommands(interaction.audioCommands);
+			flow.executeStartupAudioCommands(interaction.audioCommands);
 		interaction.musicPath.clear();
 		interaction.audioCommands.clear();
 	};
@@ -1236,8 +1236,8 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			}
 		}
 
-		if (runtimeEntities)
-			runtimeEntities->drawCursor(*activeScreen);
+		if (entityManager)
+			entityManager->drawCursor(*activeScreen);
 		activeScreen->makeAllDirty();
 		activeScreen->update();
 	};
@@ -1274,7 +1274,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			optionTexts.push_back(option.text);
 		logDialogueMenuItems("Response menu", responseLineIndex, responseLine, optionTexts);
 
-		const IndexedBitmap *textboxBitmap = startupArt->getTextboxBitmap(resolveDialogueResponseTextboxIndex(totalRows));
+		const IndexedBitmap *textboxBitmap = art->getTextboxBitmap(resolveDialogueResponseTextboxIndex(totalRows));
 		Common::Error releaseError = waitForPointerRelease();
 		if (releaseError.getCode() != Common::kNoError)
 			return releaseError;
@@ -1287,7 +1287,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			Common::Event event;
 			while (g_system->getEventManager()->pollEvent(event)) {
 				Common::Error result = Common::kNoError;
-				if (startupFlow.handleSystemEvent(event, result))
+				if (flow.handleSystemEvent(event, result))
 					return result;
 
 				if (event.type == Common::EVENT_KEYDOWN) {
@@ -1333,15 +1333,15 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 				}
 			}
 
-			if (runtimeEntities)
-				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+			if (entityManager)
+				(void)entityManager->syncCursorEntityPosition(_mousePos);
 			limiter.delayBeforeSwap();
 			limiter.startFrame();
 		}
 	};
 
 	auto runResponseMenu = [&](int responseLineIndex, int &selectedIndex) -> Common::Error {
-		const Common::String responseLine = startupText->getDialogueResponseLine(responseLineIndex);
+		const Common::String responseLine = text->getDialogueResponseLine(responseLineIndex);
 		return runResponseMenuText(responseLine, responseLineIndex, selectedIndex);
 	};
 
@@ -1371,7 +1371,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			Common::Event event;
 			while (g_system->getEventManager()->pollEvent(event)) {
 				Common::Error result = Common::kNoError;
-				if (startupFlow.handleSystemEvent(event, result))
+				if (flow.handleSystemEvent(event, result))
 					return result;
 
 				if (editingOther) {
@@ -1380,8 +1380,8 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 							typedTopic.clear();
 							editingOther = false;
 							debugC(1, kDebugDialogue, "Harvester: keyword menu cancelled Other input");
-							if (runtimeEntities)
-								runtimeEntities->showCursor();
+							if (entityManager)
+								entityManager->showCursor();
 						} else if (event.kbd.keycode == Common::KEYCODE_BACKSPACE) {
 							if (!typedTopic.empty())
 								typedTopic.deleteLastChar();
@@ -1392,8 +1392,8 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 								selection.fromTypedInput = true;
 								debugC(1, kDebugDialogue, "Harvester: keyword menu typed topic='%s'",
 									selectedTopic.c_str());
-								if (runtimeEntities)
-									runtimeEntities->showCursor();
+								if (entityManager)
+									entityManager->showCursor();
 								return Common::kNoError;
 							}
 						} else if (event.kbd.ascii >= 0x20 && event.kbd.ascii <= 0x7e &&
@@ -1429,8 +1429,8 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 						typedTopic.clear();
 						editingOther = true;
 						debugC(1, kDebugDialogue, "Harvester: keyword menu entered Other input");
-						if (runtimeEntities)
-							runtimeEntities->hideCursor();
+						if (entityManager)
+							entityManager->hideCursor();
 					}
 					break;
 				}
@@ -1439,15 +1439,15 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 				}
 			}
 
-			if (runtimeEntities)
-				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+			if (entityManager)
+				(void)entityManager->syncCursorEntityPosition(_mousePos);
 			limiter.delayBeforeSwap();
 			limiter.startFrame();
 		}
 	};
 
 	auto matchesResponseLine = [&](const Common::String &selectedTopic, int responseLineIndex) {
-		const Common::String topicText = startupText->getDialogueResponseLine(responseLineIndex);
+		const Common::String topicText = text->getDialogueResponseLine(responseLineIndex);
 		return !topicText.empty() && selectedTopic.equalsIgnoreCase(topicText);
 	};
 
@@ -1463,7 +1463,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 
 	auto assignTopicBuffer = [&](Common::String &topicBuffer, int &topicBufferLineIndex,
 			int responseLineIndex, const char *label) {
-		topicBuffer = startupText->getDialogueResponseLine(responseLineIndex);
+		topicBuffer = text->getDialogueResponseLine(responseLineIndex);
 		topicBufferLineIndex = responseLineIndex;
 
 		Common::Array<Common::String> topics;
@@ -1472,7 +1472,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 	};
 
 	DialogueRuntime runtime(
-		_engine, *startupScript, *startupText, startupFlow, npc.roomName, genericByeTopic,
+		_engine, *script, *text, flow, npc.roomName, genericByeTopic,
 		playDialogueLineWithVariant, playDialogueLine, playDialogueEntrySequence,
 		playDialogueFst, clearScreenToBlack, showCdChangePrompt, runKeywordMenu, runResponseMenu,
 		[&](const Common::String &responseLine, int &selectedIndex) {
diff --git a/engines/harvester/dialogue.h b/engines/harvester/dialogue.h
index b3e63cb7e7b..f0a0680eee6 100644
--- a/engines/harvester/dialogue.h
+++ b/engines/harvester/dialogue.h
@@ -51,7 +51,7 @@ public:
 	void syncRuntimeSaveState(Common::Serializer &s);
 	Common::Error runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, const NpcRecord &npc, const Common::String &usedItemName,
-		Flow &startupFlow);
+		Flow &flow);
 
 private:
 	void registerNpcHandlers();
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 7d394013ba6..da6aafc76b7 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -107,11 +107,11 @@ static const int kCursorSequenceNeutral = 7;
 } // End of anonymous namespace
 
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
-	const Text *startupText = engine.getText();
-	if (!startupText || !fontName)
+	const Text *text = engine.getText();
+	if (!text || !fontName)
 		return nullptr;
 
-	for (const CftFontResource &font : startupText->getFonts()) {
+	for (const CftFontResource &font : text->getFonts()) {
 		if (font.name.equalsIgnoreCase(fontName))
 			return &font;
 	}
@@ -141,8 +141,8 @@ static byte findNearestPaletteColor(const byte *palette, byte red, byte green, b
 }
 
 static Common::String resolveRoomDebugObjectLabel(HarvesterEngine &engine, const ObjectRecord &object) {
-	if (Script *startupScript = engine.getScript()) {
-		const Common::String resolvedLabel = startupScript->resolveObjectLabel(object);
+	if (Script *script = engine.getScript()) {
+		const Common::String resolvedLabel = script->resolveObjectLabel(object);
 		if (!resolvedLabel.empty())
 			return resolvedLabel;
 	}
@@ -213,10 +213,10 @@ static void drawRoomDebugOverlay(HarvesterEngine &engine, Graphics::Screen &scre
 	for (const RegionRecord &region : scene.sceneRegions)
 		drawRoomDebugLabel(screen, *font, region.regionName, region.left, region.top, white, darkGray);
 
-	EntityManager *runtimeEntities = engine.getRuntimeEntities();
+	EntityManager *entityManager = engine.getRuntimeEntities();
 	for (const ObjectRecord &object : scene.sceneObjects) {
-		const Entity *entity = runtimeEntities
-			? runtimeEntities->findSceneEntityByName(object.objectName)
+		const Entity *entity = entityManager
+			? entityManager->findSceneEntityByName(object.objectName)
 			: nullptr;
 		if (entity && entity->getClassId() == kRuntimeEntityClassBackground)
 			continue;
@@ -226,8 +226,8 @@ static void drawRoomDebugOverlay(HarvesterEngine &engine, Graphics::Screen &scre
 	}
 
 	for (const NpcRecord &npc : scene.state.roomNpcs) {
-		const Entity *entity = runtimeEntities
-			? runtimeEntities->findSceneEntityByName(npc.npcName)
+		const Entity *entity = entityManager
+			? entityManager->findSceneEntityByName(npc.npcName)
 			: nullptr;
 		if (!entity || entity->getClassId() != kRuntimeEntityClassNpc || !entity->isVisible())
 			continue;
@@ -244,9 +244,9 @@ static void drawCombatDebugOverlay(HarvesterEngine &engine, Graphics::Screen &sc
 		return;
 
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	EntityManager *runtimeEntities = engine.getRuntimeEntities();
-	Script *startupScript = engine.getScript();
-	if (!font || !runtimeEntities)
+	EntityManager *entityManager = engine.getRuntimeEntities();
+	Script *script = engine.getScript();
+	if (!font || !entityManager)
 		return;
 
 	byte displayPalette[256 * 3];
@@ -255,19 +255,19 @@ static void drawCombatDebugOverlay(HarvesterEngine &engine, Graphics::Screen &sc
 	const byte white = findNearestPaletteColor(displayPalette, 0xff, 0xff, 0xff);
 	const byte red = findNearestPaletteColor(displayPalette, 0xff, 0x00, 0x00);
 
-	const Entity *playerEntity = runtimeEntities->findSceneEntityByName(kPlayerActorEntityName);
-	if (startupScript && playerEntity &&
+	const Entity *playerEntity = entityManager->findSceneEntityByName(kPlayerActorEntityName);
+	if (script && playerEntity &&
 			playerEntity->getClassId() == kRuntimeEntityClassPlayer &&
 			playerEntity->isVisible()) {
 		const Common::Rect playerRect = playerEntity->getScreenRect();
 		drawRoomDebugLabel(screen, *font,
 			resolveCombatDebugLabel(playerEntity->getName(),
-				startupScript->getPlayerCurrentHitPoints(), Script::kDefaultPlayerHitPoints),
+				script->getPlayerCurrentHitPoints(), Script::kDefaultPlayerHitPoints),
 			playerRect.left, playerRect.top, white, red);
 	}
 
 	for (const MonsterRecord &monster : scene.state.roomMonsters) {
-		const Entity *entity = runtimeEntities->findSceneEntityByName(monster.monsterName);
+		const Entity *entity = entityManager->findSceneEntityByName(monster.monsterName);
 		if (!entity || entity->getClassId() != kRuntimeEntityClassMonster || !entity->isVisible())
 			continue;
 
@@ -606,26 +606,26 @@ static bool isBackgroundSceneObject(const RoomSetupState &state,
 		entity.getBoundsWidth() == 640 && entity.getBoundsHeight() == 480;
 }
 
-static bool isInteractiveSceneHotspot(const ObjectRecord &object, Script *startupScript) {
+static bool isInteractiveSceneHotspot(const ObjectRecord &object, Script *script) {
 	if (object.operatable || !object.actionTag.empty())
 		return true;
-	if (!startupScript)
+	if (!script)
 		return false;
-	if (startupScript->isPickupObject(object))
+	if (script->isPickupObject(object))
 		return true;
 
 	ResolvedText inspectText;
-	return startupScript->resolveObjectInspectText(object, inspectText);
+	return script->resolveObjectInspectText(object, inspectText);
 }
 
 static int resolveSceneObjectClass(const RoomSetupState &state,
-		const ObjectRecord &object, const Entity *entity, Script *startupScript) {
+		const ObjectRecord &object, const Entity *entity, Script *script) {
 	if (entity)
 		return isBackgroundSceneObject(state, object, *entity)
 			? kRuntimeEntityClassBackground
 			: kRuntimeEntityClassObject;
 
-	return isInteractiveSceneHotspot(object, startupScript)
+	return isInteractiveSceneHotspot(object, script)
 		? kRuntimeEntityClassRectHotspot
 		: kRuntimeEntityClassDisabledHotspot;
 }
@@ -737,8 +737,8 @@ static const NpcRecord *findSceneNpcByName(const Common::Array<NpcRecord> &npcs,
 
 static bool canTalkToRoomNpc(HarvesterEngine &engine, const NpcRecord &npc,
 		const DialogueSystem *dialogue) {
-	Script *startupScript = engine.getScript();
-	if (!startupScript || !startupScript->isNamedNpcDeathTypeClear(npc.npcName))
+	Script *script = engine.getScript();
+	if (!script || !script->isNamedNpcDeathTypeClear(npc.npcName))
 		return false;
 
 	return dialogue && dialogue->hasRoomNpcHandler(npc.npcName);
@@ -756,8 +756,8 @@ ObjectRecord *findSceneObjectByName(Common::Array<ObjectRecord> &objects,
 
 static const ObjectRecord *findRoomObjectAtPoint(HarvesterEngine &engine,
 		const Common::Array<ObjectRecord> &sceneObjects, const Common::Point &point) {
-	EntityManager *runtimeEntities = engine.getRuntimeEntities();
-	if (!runtimeEntities)
+	EntityManager *entityManager = engine.getRuntimeEntities();
+	if (!entityManager)
 		return nullptr;
 
 	const Entity *topEntity = nullptr;
@@ -767,7 +767,7 @@ static const ObjectRecord *findRoomObjectAtPoint(HarvesterEngine &engine,
 		if (object.objectName.empty())
 			continue;
 
-		const Entity *entity = runtimeEntities->findSceneEntityByName(object.objectName);
+		const Entity *entity = entityManager->findSceneEntityByName(object.objectName);
 		if (!entity || !entity->hitTest(point))
 			continue;
 		if (entity->getClassId() == kRuntimeEntityClassBackground ||
@@ -775,7 +775,7 @@ static const ObjectRecord *findRoomObjectAtPoint(HarvesterEngine &engine,
 			entity->getClassId() == kRuntimeEntityClassRectHotspot19) {
 			continue;
 		}
-		const int drawIndex = runtimeEntities->findSceneEntityDrawIndexByName(entity->getName());
+		const int drawIndex = entityManager->findSceneEntityDrawIndexByName(entity->getName());
 		if (drawIndex < 0)
 			continue;
 		const bool objectVisible = object.visible && object.runtimeVisible;
@@ -796,19 +796,19 @@ static const ObjectRecord *findRoomObjectAtPoint(HarvesterEngine &engine,
 
 static const RegionRecord *findRoomRegionAtPoint(HarvesterEngine &engine,
 		const Common::Array<RegionRecord> &sceneRegions, const Common::Point &point) {
-	EntityManager *runtimeEntities = engine.getRuntimeEntities();
-	if (!runtimeEntities)
+	EntityManager *entityManager = engine.getRuntimeEntities();
+	if (!entityManager)
 		return nullptr;
 
 	const Entity *topEntity = nullptr;
 	int topDrawIndex = -1;
 	for (const RegionRecord &region : sceneRegions) {
-		const Entity *entity = runtimeEntities->findSceneEntityByName(region.regionName);
+		const Entity *entity = entityManager->findSceneEntityByName(region.regionName);
 		if (!entity || !entity->hitTest(point))
 			continue;
 		if (entity->getClassId() != kRuntimeEntityClassRectHotspot19)
 			continue;
-		const int drawIndex = runtimeEntities->findSceneEntityDrawIndexByName(entity->getName());
+		const int drawIndex = entityManager->findSceneEntityDrawIndexByName(entity->getName());
 		if (drawIndex < 0)
 			continue;
 		if (!topEntity || drawIndex > topDrawIndex) {
@@ -823,11 +823,11 @@ static const RegionRecord *findRoomRegionAtPoint(HarvesterEngine &engine,
 }
 
 static const Entity *findRoomPlayerAtPoint(HarvesterEngine &engine, const Common::Point &point) {
-	EntityManager *runtimeEntities = engine.getRuntimeEntities();
-	if (!runtimeEntities)
+	EntityManager *entityManager = engine.getRuntimeEntities();
+	if (!entityManager)
 		return nullptr;
 
-	const Entity *entity = runtimeEntities->findTopSceneEntityAt(point);
+	const Entity *entity = entityManager->findTopSceneEntityAt(point);
 	if (!entity || entity->getClassId() != kRuntimeEntityClassPlayer)
 		return nullptr;
 
@@ -837,11 +837,11 @@ static const Entity *findRoomPlayerAtPoint(HarvesterEngine &engine, const Common
 static const NpcRecord *findRoomNpcAtPoint(HarvesterEngine &engine,
 		const Common::Array<NpcRecord> &sceneNpcs, const Common::Point &point,
 		const DialogueSystem *dialogue) {
-	EntityManager *runtimeEntities = engine.getRuntimeEntities();
-	if (!runtimeEntities)
+	EntityManager *entityManager = engine.getRuntimeEntities();
+	if (!entityManager)
 		return nullptr;
 
-	const Entity *entity = runtimeEntities->findTopSceneEntityAt(point);
+	const Entity *entity = entityManager->findTopSceneEntityAt(point);
 	if (!entity || entity->getClassId() != kRuntimeEntityClassNpc)
 		return nullptr;
 
@@ -890,18 +890,18 @@ void drawRoomInspectText(Graphics::Screen &screen, const Art &art, const Graphic
 }
 
 bool unlocksRoomObjectInteractionAfterInitialExamine(const ObjectRecord &object,
-		Script &startupScript) {
-	return object.operatable || startupScript.isPickupObject(object);
+		Script &script) {
+	return object.operatable || script.isPickupObject(object);
 }
 
-static int resolveRoomObjectCursorSequence(const ObjectRecord &object, Script &startupScript) {
+static int resolveRoomObjectCursorSequence(const ObjectRecord &object, Script &script) {
 	if (object.objectName.equalsIgnoreCase("EXIT_BM") || object.objectName.equalsIgnoreCase("EXIT_HS"))
 		return kCursorSequenceTransition;
 
 	ResolvedText inspectText;
-	const bool pickupObject = startupScript.isPickupObject(object);
-	const bool pickupBlocked = pickupObject && startupScript.isPickupBlockedByAction(object);
-	if (!object.identShown && unlocksRoomObjectInteractionAfterInitialExamine(object, startupScript))
+	const bool pickupObject = script.isPickupObject(object);
+	const bool pickupBlocked = pickupObject && script.isPickupBlockedByAction(object);
+	if (!object.identShown && unlocksRoomObjectInteractionAfterInitialExamine(object, script))
 		return kCursorSequenceExamine;
 	if (pickupObject && !pickupBlocked)
 		return kCursorSequencePickup;
@@ -909,23 +909,23 @@ static int resolveRoomObjectCursorSequence(const ObjectRecord &object, Script &s
 		return kCursorSequenceExamine;
 	if (object.operatable)
 		return kCursorSequenceOperate;
-	if (startupScript.resolveObjectInspectText(object, inspectText))
+	if (script.resolveObjectInspectText(object, inspectText))
 		return kCursorSequenceExamine;
 
-	if (startupScript.hasObjectInteraction(object))
+	if (script.hasObjectInteraction(object))
 		return kCursorSequenceExamine;
 
 	return kCursorSequenceNeutral;
 }
 
-static Common::String buildRoomObjectPrompt(const ObjectRecord &object, Script &startupScript,
+static Common::String buildRoomObjectPrompt(const ObjectRecord &object, Script &script,
 		int cursorSequence) {
 	// Native room prompts come from explicit interaction metadata. Neutral scene sprites
 	// should not surface synthetic "Examine <object id>" prompts or steal hover/click focus.
 	if (cursorSequence == kCursorSequenceNeutral)
 		return Common::String();
 
-	const Common::String label = startupScript.resolveObjectLabel(object);
+	const Common::String label = script.resolveObjectLabel(object);
 	if (label.empty())
 		return Common::String();
 
@@ -974,7 +974,7 @@ RoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const RoomSetupSta
 		const Common::Array<RegionRecord> &sceneRegions, const Common::Point &mousePos,
 		const DialogueSystem *dialogue) {
 	RoomHoverState hoverState;
-	EntityManager *runtimeEntities = engine.getRuntimeEntities();
+	EntityManager *entityManager = engine.getRuntimeEntities();
 	if (const Entity *playerEntity = findRoomPlayerAtPoint(engine, mousePos)) {
 		hoverState.playerEntity = playerEntity;
 		hoverState.cursorSequence = kCursorSequenceExamine;
@@ -987,22 +987,22 @@ RoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const RoomSetupSta
 		return hoverState;
 	}
 
-	Script *startupScript = engine.getScript();
-	if (!startupScript)
+	Script *script = engine.getScript();
+	if (!script)
 		return hoverState;
 	hoverState.object = findRoomObjectAtPoint(engine, sceneObjects, mousePos);
 	hoverState.region = findRoomRegionAtPoint(engine, sceneRegions, mousePos);
-	if (runtimeEntities && hoverState.object && hoverState.region) {
+	if (entityManager && hoverState.object && hoverState.region) {
 		const int objectDrawIndex =
-			runtimeEntities->findSceneEntityDrawIndexByName(hoverState.object->objectName);
+			entityManager->findSceneEntityDrawIndexByName(hoverState.object->objectName);
 		const int regionDrawIndex =
-			runtimeEntities->findSceneEntityDrawIndexByName(hoverState.region->regionName);
+			entityManager->findSceneEntityDrawIndexByName(hoverState.region->regionName);
 		if (regionDrawIndex >= 0 && objectDrawIndex >= 0 && regionDrawIndex > objectDrawIndex)
 			hoverState.object = nullptr;
 	}
 	if (hoverState.object) {
-		hoverState.cursorSequence = resolveRoomObjectCursorSequence(*hoverState.object, *startupScript);
-		hoverState.promptText = buildRoomObjectPrompt(*hoverState.object, *startupScript, hoverState.cursorSequence);
+		hoverState.cursorSequence = resolveRoomObjectCursorSequence(*hoverState.object, *script);
+		hoverState.promptText = buildRoomObjectPrompt(*hoverState.object, *script, hoverState.cursorSequence);
 		if (hoverState.cursorSequence != kCursorSequenceNeutral || !hoverState.promptText.empty())
 			return hoverState;
 		hoverState.object = nullptr;
@@ -1063,12 +1063,12 @@ static bool findRoomObjectProbePoint(HarvesterEngine &engine, const Common::Arra
 
 void logStartupRoomProbe(HarvesterEngine &engine, const RoomSceneResources &scene,
 		const Common::String &entranceName, Common::Point &mousePos) {
-	EntityManager *runtimeEntities = engine.getRuntimeEntities();
-	Script *startupScript = engine.getScript();
-	if (!runtimeEntities || !startupScript)
+	EntityManager *entityManager = engine.getRuntimeEntities();
+	Script *script = engine.getScript();
+	if (!entityManager || !script)
 		return;
 
-	if (const Entity *cursor = runtimeEntities->getCursorEntity()) {
+	if (const Entity *cursor = entityManager->getCursorEntity()) {
 		uint32 framePixels = 0;
 		uint32 transparentPixels = 0;
 		uint32 preservedPixels = 0;
@@ -1079,7 +1079,7 @@ void logStartupRoomProbe(HarvesterEngine &engine, const RoomSceneResources &scen
 		}
 	}
 
-	if (const Entity *player = runtimeEntities->findSceneEntityByName(kPlayerActorEntityName)) {
+	if (const Entity *player = entityManager->findSceneEntityByName(kPlayerActorEntityName)) {
 		uint32 framePixels = 0;
 		uint32 transparentPixels = 0;
 		uint32 preservedPixels = 0;
@@ -1106,12 +1106,12 @@ void logStartupRoomProbe(HarvesterEngine &engine, const RoomSceneResources &scen
 			continue;
 
 		mousePos = probePoint;
-		const Common::String objectLabel = startupScript->resolveObjectLabel(*hoveredObject);
+		const Common::String objectLabel = script->resolveObjectLabel(*hoveredObject);
 		ResolvedText inspectText;
 		const RoomHoverState hoverState = resolveRoomHoverState(
 			engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions, probePoint);
-		const bool hasInteraction = startupScript->hasObjectInteraction(*hoveredObject);
-		const bool hasInspectText = startupScript->resolveObjectInspectText(*hoveredObject, inspectText);
+		const bool hasInteraction = script->hasObjectInteraction(*hoveredObject);
+		const bool hasInspectText = script->resolveObjectInspectText(*hoveredObject, inspectText);
 		debugC(1, kDebugRoom,
 			"Harvester: startup probe hotspot room='%s' object='%s' point=(%d,%d) label='%s' prompt='%s' cursor_sequence=%d action_tag='%s' interaction=%d next_room='%s' inspect=%d",
 			scene.state.roomName.c_str(), hoveredObject->objectName.c_str(), probePoint.x, probePoint.y,
@@ -1144,7 +1144,7 @@ void logStartupRoomProbe(HarvesterEngine &engine, const RoomSceneResources &scen
 				scene.state.roomName.c_str(), floorProbe.x, floorProbe.y, foundFloorProbe,
 				floorHover.cursorSequence, floorHover.promptText.c_str());
 
-			Entity *player = runtimeEntities->findSceneEntityByName(kPlayerActorEntityName);
+			Entity *player = entityManager->findSceneEntityByName(kPlayerActorEntityName);
 			if (player && foundFloorProbe && floorHover.cursorSequence == kCursorSequenceWalk) {
 				RoomPlayerState probePlayer;
 				probePlayer.entity = player;
@@ -1201,8 +1201,8 @@ static void renderQuickTipsScreen(HarvesterEngine &engine, const RoomSceneResour
 	Graphics::Screen *screen = engine.getScreen();
 	const Art *art = engine.getArt();
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	Script *startupScript = engine.getScript();
-	if (!screen || !art || !font || !startupScript)
+	Script *script = engine.getScript();
+	if (!screen || !art || !font || !script)
 		return;
 
 	drawRoomScene(engine, *screen, scene, scene.targetPaletteBrightness);
@@ -1212,8 +1212,8 @@ static void renderQuickTipsScreen(HarvesterEngine &engine, const RoomSceneResour
 	const Common::Rect exitRect = quickTipsExitRect();
 	const Common::Rect nextRect = quickTipsNextRect();
 	const Common::Rect toggleRect = quickTipsToggleRect();
-	const Common::String toggleLabel = startupScript->resolveTextValue(
-		startupScript->isQuickTipsEnabled() ? "Show_Tips_ON" : "Show_Tips_OFF");
+	const Common::String toggleLabel = script->resolveTextValue(
+		script->isQuickTipsEnabled() ? "Show_Tips_ON" : "Show_Tips_OFF");
 	drawShadowedString(*screen, *font, "Exit", exitRect.left, exitRect.top, exitRect.width(),
 		kQuickTipActionColor);
 	drawShadowedString(*screen, *font, "Next", nextRect.left, nextRect.top, nextRect.width(),
@@ -1469,7 +1469,7 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 		Common::String &destinationEntranceName) {
 	destinationEntranceName.clear();
 
-	Script *startupScript = _engine.getScript();
+	Script *script = _engine.getScript();
 	ResourceManager *resources = _engine.getResources();
 	Graphics::Screen *screen = _engine.getScreen();
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
@@ -1481,15 +1481,15 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 		else
 			townMapFont.reset();
 	}
-	if (!startupScript || !resources || !screen || !font)
+	if (!script || !resources || !screen || !font)
 		return Common::kReadingFailed;
 
-	const MapEntranceRecord *mapEntrance = startupScript->findMapEntranceRecord(mapEntryName);
+	const MapEntranceRecord *mapEntrance = script->findMapEntranceRecord(mapEntryName);
 	if (!mapEntrance) {
 		warning("Harvester: unresolved town map entry '%s'", mapEntryName.c_str());
 		return Common::kReadingFailed;
 	}
-	const float paletteBrightness = startupScript->getFlagValue("DAY_FLAG")
+	const float paletteBrightness = script->getFlagValue("DAY_FLAG")
 		? 1.0f
 		: kTownMapNightPaletteBrightness;
 
@@ -1513,8 +1513,8 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 
 	if (_mousePos.x < 0 || _mousePos.y < 0 || _mousePos.x >= screen->w || _mousePos.y >= screen->h)
 		_mousePos = Common::Point(screen->w / 2, screen->h / 2);
-	if (EntityManager *runtimeEntities = _engine.getRuntimeEntities())
-		(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+	if (EntityManager *entityManager = _engine.getRuntimeEntities())
+		(void)entityManager->syncCursorEntityPosition(_mousePos);
 
 	int currentPanel = clampTownMapPanelIndex(mapEntrance->initialPanelIndex);
 	bool needsRedraw = true;
@@ -1522,8 +1522,8 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 
 	auto centerCursor = [&]() {
 		_mousePos = Common::Point(screen->w / 2, screen->h / 2);
-		if (EntityManager *runtimeEntities = _engine.getRuntimeEntities())
-			(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+		if (EntityManager *entityManager = _engine.getRuntimeEntities())
+			(void)entityManager->syncCursorEntityPosition(_mousePos);
 	};
 	auto restorePreviousMusic = [&]() {
 		if (!destinationEntranceName.empty())
@@ -1543,7 +1543,7 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 		}
 
 		const MapLocationRecord *hoveredLocation =
-			findTownMapLocationAt(startupScript->getMapLocations(), currentPanel, _mousePos);
+			findTownMapLocationAt(script->getMapLocations(), currentPanel, _mousePos);
 		if (needsRedraw) {
 			setScaledPalette(*screen, palette, paletteBrightness);
 			screen->fillRect(screen->getBounds(), 0);
@@ -1552,8 +1552,8 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 				drawShadowedString(*screen, *font, hoveredLocation->labelText,
 					hoveredLocation->labelX, hoveredLocation->labelY, screen->w, kTownMapLabelColor);
 			}
-			if (EntityManager *runtimeEntities = _engine.getRuntimeEntities())
-				runtimeEntities->drawCursor(*screen);
+			if (EntityManager *entityManager = _engine.getRuntimeEntities())
+				entityManager->drawCursor(*screen);
 			screen->makeAllDirty();
 			screen->update();
 			needsRedraw = false;
@@ -1573,12 +1573,12 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 				break;
 			case Common::EVENT_LBUTTONDOWN: {
 				const MapLocationRecord *clickedLocation =
-					findTownMapLocationAt(startupScript->getMapLocations(), currentPanel, _mousePos);
+					findTownMapLocationAt(script->getMapLocations(), currentPanel, _mousePos);
 				if (!clickedLocation)
 					break;
 
 				const EntranceRecord *destinationEntrance =
-					startupScript->findEntranceRecord(clickedLocation->destinationEntranceName);
+					script->findEntranceRecord(clickedLocation->destinationEntranceName);
 				if (!destinationEntrance) {
 					warning("Harvester: unresolved town map destination '%s' from '%s'",
 						clickedLocation->destinationEntranceName.c_str(), mapEntryName.c_str());
@@ -1621,11 +1621,11 @@ Common::Error Flow::resolveRoomTransitionTarget(const Common::String &targetName
 		Common::String &resolvedTargetName) {
 	resolvedTargetName = targetName;
 
-	Script *startupScript = _engine.getScript();
-	if (!startupScript || targetName.empty())
+	Script *script = _engine.getScript();
+	if (!script || targetName.empty())
 		return Common::kNoError;
 
-	if (startupScript->findMapEntranceRecord(targetName))
+	if (script->findMapEntranceRecord(targetName))
 		return runTownMapSelector(targetName, resolvedTargetName);
 
 	return Common::kNoError;
@@ -1779,30 +1779,30 @@ Common::Error Flow::runRoomLoop(const Common::String &entranceName) {
 }
 
 bool Flow::ensureCursorEntity() {
-	EntityManager *runtimeEntities = _engine.getRuntimeEntities();
-	if (!runtimeEntities)
+	EntityManager *entityManager = _engine.getRuntimeEntities();
+	if (!entityManager)
 		return false;
-	if (runtimeEntities->getCursorEntity())
+	if (entityManager->getCursorEntity())
 		return true;
 
-	return runtimeEntities->spawnCursorEntity(_mousePos) != nullptr;
+	return entityManager->spawnCursorEntity(_mousePos) != nullptr;
 }
 
 bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 		const Common::Array<ObjectRecord> &drawableObjects,
 		const Common::Array<AnimRecord> &drawableAnimations) {
-	EntityManager *runtimeEntities = _engine.getRuntimeEntities();
-	if (!runtimeEntities)
+	EntityManager *entityManager = _engine.getRuntimeEntities();
+	if (!entityManager)
 		return false;
 
-	Entity *preservedPlayer = runtimeEntities->detachSceneEntityByName(kPlayerActorEntityName);
-	runtimeEntities->clearSceneEntities();
+	Entity *preservedPlayer = entityManager->detachSceneEntityByName(kPlayerActorEntityName);
+	entityManager->clearSceneEntities();
 	for (const RegionRecord &region : state.roomRegions) {
 		const Common::Rect regionBounds = getRegionBounds(region);
 		if (regionBounds.isEmpty())
 			continue;
 
-		Entity *entity = runtimeEntities->spawnSceneHotspotEntity(
+		Entity *entity = entityManager->spawnSceneHotspotEntity(
 			region.regionName, regionBounds, (float)region.minZ);
 		if (!entity) {
 			debugC(1, kDebugRoom,
@@ -1825,7 +1825,7 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 			region.minZ, region.maxZ, region.desiredFacing, region.cursorEnabled, region.actionTag.c_str());
 	}
 	for (const TimerRecord &timer : state.roomTimers) {
-		if (!runtimeEntities->spawnSceneTimerEntity(timer.timerName,
+		if (!entityManager->spawnSceneTimerEntity(timer.timerName,
 				timer.initialValue, timer.currentValue, timer.enabled, timer.looping, timer.global)) {
 			debug(1, "Harvester: unable to spawn room timer entity '%s'",
 				timer.timerName.c_str());
@@ -1842,11 +1842,11 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 		const Common::String spritePath = resolveSceneObjectSpritePath(object);
 		const Common::Rect hotspotBounds = getRoomObjectHotspotBounds(object);
 		if (!spritePath.empty() && spritePath.hasSuffixIgnoreCase(".BM")) {
-			entity = runtimeEntities->spawnSceneBitmapEntity(object.objectName, spritePath,
+			entity = entityManager->spawnSceneBitmapEntity(object.objectName, spritePath,
 				Common::Point(object.currentX, object.currentY), (float)object.currentZ);
 		} else {
 			if (!hotspotBounds.isEmpty())
-				entity = runtimeEntities->spawnSceneHotspotEntity(object.objectName, hotspotBounds, (float)object.currentZ);
+				entity = entityManager->spawnSceneHotspotEntity(object.objectName, hotspotBounds, (float)object.currentZ);
 		}
 
 		if (!entity) {
@@ -1876,7 +1876,7 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 			spritePath.c_str(), object.actionTag.c_str(), object.operatable, object.identTextKey.c_str());
 	}
 	for (const AnimRecord &anim : drawableAnimations) {
-		if (!runtimeEntities->spawnSceneAnimationEntity(anim.animName, anim.resourcePath,
+		if (!entityManager->spawnSceneAnimationEntity(anim.animName, anim.resourcePath,
 				Common::Point(anim.x, anim.y), (float)anim.z, anim.frameDelay, anim.active, anim.visible,
 				anim.looping, anim.backward, anim.pingPong, anim.runtimeState)) {
 			debug(1, "Harvester: unable to spawn room anim entity '%s' from '%s'",
@@ -1884,7 +1884,7 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 		}
 	}
 	for (const NpcRecord &npc : state.roomNpcs) {
-		Entity *entity = runtimeEntities->spawnSceneActorEntity(npc.npcName,
+		Entity *entity = entityManager->spawnSceneActorEntity(npc.npcName,
 			npc.modelPath, Common::Point(npc.posX, npc.posY), (float)npc.posZ, 0);
 		if (!entity) {
 			debug(1, "Harvester: unable to spawn room npc entity '%s' from '%s'",
@@ -1904,7 +1904,7 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 			debug(1, "Harvester: unable to apply room npc placement for '%s'",
 				npc.npcName.c_str());
 		}
-		runtimeEntities->reinsertSceneEntity(entity);
+		entityManager->reinsertSceneEntity(entity);
 		debugC(1, kDebugRoom,
 			"Harvester: scene npc spawned room='%s' npc='%s' class=0x%x pos=(%d,%d,z=%.2f) frame_delay=%d model='%s' active=%d visible=%d",
 			state.roomName.c_str(), npc.npcName.c_str(), entity->getClassId(),
@@ -1912,7 +1912,7 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 			npc.frameDelay, npc.modelPath.c_str(), npc.active, npc.visible);
 	}
 	for (const MonsterRecord &monster : state.roomMonsters) {
-		Entity *entity = runtimeEntities->spawnSceneActorEntity(monster.monsterName,
+		Entity *entity = entityManager->spawnSceneActorEntity(monster.monsterName,
 			monster.modelPath, Common::Point(monster.posX, monster.posY), (float)monster.posZ,
 			Monster::resolveFacingFrame(monster.facing));
 		if (!entity) {
@@ -1928,7 +1928,7 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 			debug(1, "Harvester: unable to apply room monster placement for '%s'",
 				monster.monsterName.c_str());
 		}
-		runtimeEntities->reinsertSceneEntity(entity);
+		entityManager->reinsertSceneEntity(entity);
 		debugC(1, kDebugRoom,
 			"Harvester: scene monster spawned room='%s' monster='%s' class=0x%x pos=(%d,%d,z=%.2f) facing=%d hp=%d/%d damage=%d engage=%d damage_type='%s' model='%s' active=%d visible=%d",
 			state.roomName.c_str(), monster.monsterName.c_str(), entity->getClassId(),
@@ -1939,14 +1939,14 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 	}
 	if (state.hasEntrance) {
 		const int playerFrame = Player::resolveFacingFrame(state.playerFacing);
-		Script *startupScript = _engine.getScript();
-		const int playerCombatLoadout = startupScript ? startupScript->getPlayerCombatLoadout() : 0;
+		Script *script = _engine.getScript();
+		const int playerCombatLoadout = script ? script->getPlayerCombatLoadout() : 0;
 		const Common::String playerResourcePath =
 			Player::resolveCombatLoadoutResourcePath(playerCombatLoadout);
 		const bool reusedPlayer = preservedPlayer != nullptr;
 		Entity *player = preservedPlayer;
 		if (!reusedPlayer) {
-			player = runtimeEntities->spawnSceneActorEntity(kPlayerActorEntityName,
+			player = entityManager->spawnSceneActorEntity(kPlayerActorEntityName,
 				playerResourcePath, Common::Point(state.playerSpawnX, state.playerSpawnY),
 				(float)state.playerSpawnZ, playerFrame);
 		}
@@ -1974,22 +1974,22 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 				playerState.combatLoadout = -1;
 				(void)Player::syncCombatLoadoutVisual(_engine, state, playerState, playerCombatLoadout);
 			}
-			RoomPlayerState startupPlayerState;
-			startupPlayerState.entity = player;
-			startupPlayerState.centerX = state.playerSpawnX;
-			startupPlayerState.bottomY = state.playerSpawnY;
-			startupPlayerState.z = (float)state.playerSpawnZ;
-			startupPlayerState.facing = state.playerFacing;
+			RoomPlayerState playerSpawnState;
+			playerSpawnState.entity = player;
+			playerSpawnState.centerX = state.playerSpawnX;
+			playerSpawnState.bottomY = state.playerSpawnY;
+			playerSpawnState.z = (float)state.playerSpawnZ;
+			playerSpawnState.facing = state.playerFacing;
 			if (Player::resolveBlockedStartupSpawn(
-					_engine, state, drawableObjects, drawableAnimations, startupPlayerState)) {
-				state.playerSpawnX = startupPlayerState.centerX;
-				state.playerSpawnY = startupPlayerState.bottomY;
-				state.playerSpawnZ = (int)startupPlayerState.z;
+					_engine, state, drawableObjects, drawableAnimations, playerSpawnState)) {
+				state.playerSpawnX = playerSpawnState.centerX;
+				state.playerSpawnY = playerSpawnState.bottomY;
+				state.playerSpawnZ = (int)playerSpawnState.z;
 			}
 			if (reusedPlayer)
-				runtimeEntities->adoptSceneEntity(player);
+				entityManager->adoptSceneEntity(player);
 			else
-				runtimeEntities->reinsertSceneEntity(player);
+				entityManager->reinsertSceneEntity(player);
 			debugC(1, kDebugPlayer,
 				"Harvester: %s startup player actor '%s' entrance='%s' pos=(%d,%d,%d) facing=%d frame=%d",
 				reusedPlayer ? "reused" : "spawned", playerResourcePath.c_str(), state.entranceName.c_str(),
@@ -2128,10 +2128,10 @@ void Flow::resetCursorAnimationSequence() {
 }
 
 bool Flow::tickRuntimeEntities() {
-	EntityManager *runtimeEntities = _engine.getRuntimeEntities();
-	if (!runtimeEntities)
+	EntityManager *entityManager = _engine.getRuntimeEntities();
+	if (!entityManager)
 		return false;
-	return runtimeEntities->tickSceneEntities() || runtimeEntities->syncCursorEntityPosition(_mousePos);
+	return entityManager->tickSceneEntities() || entityManager->syncCursorEntityPosition(_mousePos);
 }
 
 bool Flow::handleSystemEvent(const Common::Event &event, Common::Error &result) {
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index d9fbc0afb23..021c59699f9 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -51,7 +51,7 @@ HarvesterEngine::HarvesterEngine(OSystem *syst, const ADGameDescription *gameDes
 
 HarvesterEngine::~HarvesterEngine() {
 	delete _media;
-	delete _startupScript;
+	delete _script;
 	delete _resources;
 	g_engine = nullptr;
 }
@@ -61,11 +61,11 @@ Common::String HarvesterEngine::getGameId() const {
 }
 
 bool HarvesterEngine::canLoadGameStateCurrently(Common::U32String *) {
-	return _startupScript != nullptr;
+	return _script != nullptr;
 }
 
 bool HarvesterEngine::canSaveGameStateCurrently(Common::U32String *) {
-	return _startupScript != nullptr && _currentStartupSaveRoomState.valid;
+	return _script != nullptr && _currentSaveRoomState.valid;
 }
 
 EntityManager *HarvesterEngine::getRuntimeEntities() const {
@@ -93,8 +93,8 @@ Text *HarvesterEngine::getText() const {
 }
 
 bool HarvesterEngine::isGoreEnabled() const {
-	if (_startupScript)
-		return _startupScript->isGoreEnabled();
+	if (_script)
+		return _script->isGoreEnabled();
 
 	return !ConfMan.hasKey("gore") || ConfMan.getBool("gore");
 }
@@ -104,15 +104,15 @@ bool HarvesterEngine::shouldShowCdChangePrompts() const {
 }
 
 int HarvesterEngine::getFxVolumeLevel() const {
-	return _startupScript ? _startupScript->getFxVolumeLevel() : 9;
+	return _script ? _script->getFxVolumeLevel() : 9;
 }
 
 int HarvesterEngine::getMusicVolumeLevel() const {
-	return _startupScript ? _startupScript->getMusicVolumeLevel() : 9;
+	return _script ? _script->getMusicVolumeLevel() : 9;
 }
 
 int HarvesterEngine::getGammaLevel() const {
-	return _startupScript ? _startupScript->getGammaLevel() : 0;
+	return _script ? _script->getGammaLevel() : 0;
 }
 
 float HarvesterEngine::getGammaBrightnessScale() const {
@@ -125,20 +125,20 @@ void HarvesterEngine::applyMixerLevels() {
 }
 
 void HarvesterEngine::setFxVolumeLevel(int level) {
-	if (_startupScript)
-		_startupScript->setFxVolumeLevel(level);
+	if (_script)
+		_script->setFxVolumeLevel(level);
 	applyMixerLevels();
 }
 
 void HarvesterEngine::setMusicVolumeLevel(int level) {
-	if (_startupScript)
-		_startupScript->setMusicVolumeLevel(level);
+	if (_script)
+		_script->setMusicVolumeLevel(level);
 	applyMixerLevels();
 }
 
 void HarvesterEngine::setGammaLevel(int level) {
-	if (_startupScript)
-		_startupScript->setGammaLevel(level);
+	if (_script)
+		_script->setGammaLevel(level);
 }
 
 const Common::String &HarvesterEngine::getMusicPath() const {
@@ -263,13 +263,13 @@ bool HarvesterEngine::toggleRoomDebugEnabled() {
 }
 
 bool HarvesterEngine::requestDebugCommand(const CommandRecord &command) {
-	if (!_activeFlow || !_startupScript || !_currentStartupSaveRoomState.valid)
+	if (!_activeFlow || !_script || !_currentSaveRoomState.valid)
 		return false;
 	if (_activeFlow->hasQueuedDebugInteraction())
 		return false;
 
 	InteractionResult interaction;
-	if (!_startupScript->executeDebugCommand(command, interaction))
+	if (!_script->executeDebugCommand(command, interaction))
 		return false;
 
 	// Defer application to the active room loop instead of mutating state from the console thread.
@@ -278,7 +278,7 @@ bool HarvesterEngine::requestDebugCommand(const CommandRecord &command) {
 
 bool HarvesterEngine::requestDebugRoomChange(const Common::String &roomName) {
 	return _activeFlow &&
-		_currentStartupSaveRoomState.valid &&
+		_currentSaveRoomState.valid &&
 		_activeFlow->requestDebugRoomChange(roomName);
 }
 
@@ -303,8 +303,8 @@ Common::Error HarvesterEngine::run() {
 	_resources = new ResourceManager();
 	_resources->mountStartupArchives();
 	_media = new MediaManager(*_resources);
-	_startupScript = new Script();
-	if (!_startupScript->load(*_resources))
+	_script = new Script();
+	if (!_script->load(*_resources))
 		return Common::kReadingFailed;
 	applyMixerLevels();
 
@@ -342,14 +342,14 @@ Common::Error HarvesterEngine::run() {
 	if (saveSlot != -1)
 		(void)loadGameState(saveSlot);
 
-	Flow startupFlow(*this);
-	_activeFlow = &startupFlow;
-	if (!startupFlow.load()) {
+	Flow flow(*this);
+	_activeFlow = &flow;
+	if (!flow.load()) {
 		_activeFlow = nullptr;
 		return Common::kReadingFailed;
 	}
 
-	const Common::Error error = startupFlow.run();
+	const Common::Error error = flow.run();
 	_activeFlow = nullptr;
 	return error;
 }
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 8c5b5a7e150..f3d90e97c07 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -81,7 +81,7 @@ public:
 	Graphics::Screen *getScreen() const;
 	int getDisplayWidth() const;
 	int getDisplayHeight() const;
-	Script *getScript() const { return _startupScript; }
+	Script *getScript() const { return _script; }
 	Art *getArt() const;
 	Text *getText() const;
 	void setDisplayMode(int width, int height);
@@ -122,15 +122,15 @@ public:
 		const Common::String &roomName, int playerX, int playerY, int playerZ, int playerFacing,
 		const Common::String &musicPath);
 	void clearCurrentSaveRoomState();
-	bool hasCurrentSaveRoomState() const { return _currentStartupSaveRoomState.valid; }
+	bool hasCurrentSaveRoomState() const { return _currentSaveRoomState.valid; }
 	const SaveRoomState &getCurrentSaveRoomState() const {
-		return _currentStartupSaveRoomState;
+		return _currentSaveRoomState;
 	}
-	bool hasPendingLoadedSaveRoomState() const { return _pendingLoadedStartupSaveRoomState.valid; }
+	bool hasPendingLoadedSaveRoomState() const { return _pendingLoadedSaveRoomState.valid; }
 	const SaveRoomState &getPendingLoadedSaveRoomState() const {
-		return _pendingLoadedStartupSaveRoomState;
+		return _pendingLoadedSaveRoomState;
 	}
-	int getPendingLoadedDisc() const { return _pendingLoadedStartupDisc; }
+	int getPendingLoadedDisc() const { return _pendingLoadedDisc; }
 	void clearPendingLoadedSaveRoomState();
 	bool hasPendingLoadedDialogueStateBlob() const { return !_pendingLoadedDialogueStateBlob.empty(); }
 	const Common::Array<byte> &getPendingLoadedDialogueStateBlob() const { return _pendingLoadedDialogueStateBlob; }
@@ -147,10 +147,10 @@ private:
 	Common::RandomSource _randomSource;
 	ResourceManager *_resources = nullptr;
 	MediaManager *_media = nullptr;
-	Script *_startupScript = nullptr;
-	SaveRoomState _currentStartupSaveRoomState;
-	SaveRoomState _pendingLoadedStartupSaveRoomState;
-	int _pendingLoadedStartupDisc = 0;
+	Script *_script = nullptr;
+	SaveRoomState _currentSaveRoomState;
+	SaveRoomState _pendingLoadedSaveRoomState;
+	int _pendingLoadedDisc = 0;
 	Common::Array<byte> _pendingLoadedDialogueStateBlob;
 	uint32 _pendingLoadedDialogueStateBlobVersion = 0;
 	Flow *_activeFlow = nullptr;
diff --git a/engines/harvester/inventory.cpp b/engines/harvester/inventory.cpp
index 5838bacd2cc..b71767acec9 100644
--- a/engines/harvester/inventory.cpp
+++ b/engines/harvester/inventory.cpp
@@ -290,17 +290,17 @@ bool InventorySystem::loadBitmap(const Common::String &path, IndexedBitmap &bitm
 bool InventorySystem::refresh() {
 	_items.clear();
 
-	Script *startupScript = _engine.getScript();
+	Script *script = _engine.getScript();
 	ResourceManager *resources = _engine.getResources();
-	if (!startupScript || !resources)
+	if (!script || !resources)
 		return false;
 
-	_lastPlayerHitPoints = startupScript->getPlayerCurrentHitPoints();
-	_lastStoryDayIndex = startupScript->getCurrentStoryDayIndex();
-	_lastHasHarvestBlade = startupScript->isObjectInInventory(kHarvestBladeObjectName);
+	_lastPlayerHitPoints = script->getPlayerCurrentHitPoints();
+	_lastStoryDayIndex = script->getCurrentStoryDayIndex();
+	_lastHasHarvestBlade = script->isObjectInInventory(kHarvestBladeObjectName);
 
 	Common::Array<ObjectRecord> inventoryObjects;
-	startupScript->getVisibleInventoryObjects(inventoryObjects);
+	script->getVisibleInventoryObjects(inventoryObjects);
 	int nextX = kInventoryItemStartX;
 	int nextY = kInventoryItemStartY;
 	int rowHeight = 0;
@@ -388,13 +388,13 @@ bool InventorySystem::clearSelection() {
 }
 
 bool InventorySystem::refreshIfRuntimeStateChanged() {
-	Script *startupScript = _engine.getScript();
-	if (!startupScript)
+	Script *script = _engine.getScript();
+	if (!script)
 		return false;
 
-	const int currentHitPoints = startupScript->getPlayerCurrentHitPoints();
-	const int currentStoryDayIndex = startupScript->getCurrentStoryDayIndex();
-	const bool hasHarvestBlade = startupScript->isObjectInInventory(kHarvestBladeObjectName);
+	const int currentHitPoints = script->getPlayerCurrentHitPoints();
+	const int currentStoryDayIndex = script->getCurrentStoryDayIndex();
+	const bool hasHarvestBlade = script->isObjectInInventory(kHarvestBladeObjectName);
 	if (currentHitPoints == _lastPlayerHitPoints &&
 			currentStoryDayIndex == _lastStoryDayIndex &&
 			hasHarvestBlade == _lastHasHarvestBlade)
@@ -416,20 +416,20 @@ const Common::String &InventorySystem::getSelectedItemName() const {
 }
 
 Common::String InventorySystem::resolveSelectedLabel() const {
-	Script *startupScript = _engine.getScript();
-	if (!startupScript || _selectedItemName.empty())
+	Script *script = _engine.getScript();
+	if (!script || _selectedItemName.empty())
 		return Common::String();
 
 	for (const InventoryVisual &item : _items) {
 		if (item.object.objectName.equalsIgnoreCase(_selectedItemName))
-			return startupScript->resolveObjectLabel(item.object);
+			return script->resolveObjectLabel(item.object);
 	}
 
 	Common::Array<ObjectRecord> inventoryObjects;
-	startupScript->getVisibleInventoryObjects(inventoryObjects);
+	script->getVisibleInventoryObjects(inventoryObjects);
 	for (const ObjectRecord &item : inventoryObjects) {
 		if (item.objectName.equalsIgnoreCase(_selectedItemName))
-			return startupScript->resolveObjectLabel(item);
+			return script->resolveObjectLabel(item);
 	}
 
 	return normalizeHarvesterResourcePath(_selectedItemName);
@@ -451,16 +451,16 @@ bool InventorySystem::toggleCombatLoadout(const ObjectRecord &object, int curren
 	if (!resolveInventoryCombatLoadoutId(object.objectName, loadoutId))
 		return false;
 
-	Script *startupScript = _engine.getScript();
-	if (!startupScript)
+	Script *script = _engine.getScript();
+	if (!script)
 		return false;
 
 	// Native run_inventory_screen compares the clicked weapon against the live combat avatar loadout.
-	const int savedLoadout = startupScript->getPlayerCombatLoadout();
+	const int savedLoadout = script->getPlayerCombatLoadout();
 	const int activeLoadout = currentLoadout >= 0 ? currentLoadout : savedLoadout;
 	const int nextLoadout = activeLoadout == loadoutId ? 0 : loadoutId;
-	const bool scriptChanged = startupScript->setPlayerCombatLoadout(nextLoadout);
-	const int resultingLoadout = startupScript->getPlayerCombatLoadout();
+	const bool scriptChanged = script->setPlayerCombatLoadout(nextLoadout);
+	const int resultingLoadout = script->getPlayerCombatLoadout();
 	changed = activeLoadout != nextLoadout;
 	debugC(1, kDebugInventory,
 		"Harvester: inventory combat toggle object='%s' active_loadout=%d('%s') saved_loadout=%d('%s') requested_loadout=%d('%s') changed=%d script_changed=%d resulting_loadout=%d('%s') damage=%d damage_type='%s'",
@@ -509,11 +509,11 @@ const Common::String &InventorySystem::getPromptText() const {
 }
 
 Common::String InventorySystem::resolveWeekdayLabel() const {
-	Script *startupScript = _engine.getScript();
-	if (!startupScript || startupScript->isObjectInInventory(kHarvestBladeObjectName))
+	Script *script = _engine.getScript();
+	if (!script || script->isObjectInInventory(kHarvestBladeObjectName))
 		return Common::String();
 
-	return resolveInventoryWeekdayLabel(startupScript->getCurrentStoryDayIndex());
+	return resolveInventoryWeekdayLabel(script->getCurrentStoryDayIndex());
 }
 
 const InventoryVisual *InventorySystem::findItemAtPoint(const Common::Point &point) const {
diff --git a/engines/harvester/media_manager.cpp b/engines/harvester/media_manager.cpp
index 48034239e20..cf25a87c487 100644
--- a/engines/harvester/media_manager.cpp
+++ b/engines/harvester/media_manager.cpp
@@ -213,7 +213,7 @@ static Audio::SeekableAudioStream *openStartupAudioStream(ResourceManager &resou
 } // End of anonymous namespace
 
 MediaManager::MediaManager(ResourceManager &resources) : _resources(resources) {
-	_runtimeEntities = new EntityManager(_resources);
+	_entityManager = new EntityManager(_resources);
 }
 
 MediaManager::~MediaManager() {
@@ -222,7 +222,7 @@ MediaManager::~MediaManager() {
 	delete _text;
 	delete _art;
 	delete _screen;
-	delete _runtimeEntities;
+	delete _entityManager;
 }
 
 float MediaManager::mapGammaLevelToBrightnessScale(int level) {
diff --git a/engines/harvester/media_manager.h b/engines/harvester/media_manager.h
index 02b125e6a93..552ad72ff00 100644
--- a/engines/harvester/media_manager.h
+++ b/engines/harvester/media_manager.h
@@ -43,7 +43,7 @@ public:
 	explicit MediaManager(ResourceManager &resources);
 	~MediaManager();
 
-	EntityManager *getRuntimeEntities() const { return _runtimeEntities; }
+	EntityManager *getRuntimeEntities() const { return _entityManager; }
 	Graphics::Screen *getScreen() const { return _screen; }
 	int getDisplayWidth() const { return _displayWidth; }
 	int getDisplayHeight() const { return _displayHeight; }
@@ -81,7 +81,7 @@ private:
 	bool validateLoadedSoundSlot(int slot) const;
 
 	ResourceManager &_resources;
-	EntityManager *_runtimeEntities = nullptr;
+	EntityManager *_entityManager = nullptr;
 	Graphics::Screen *_screen = nullptr;
 	int _displayWidth = 0;
 	int _displayHeight = 0;
diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index cd78008754e..cfbe1d65244 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -135,8 +135,8 @@ public:
 	void pause() {
 		if (_paused)
 			return;
-		if (EntityManager *runtimeEntities = _engine.getRuntimeEntities()) {
-			runtimeEntities->pauseTimerCountdowns();
+		if (EntityManager *entityManager = _engine.getRuntimeEntities()) {
+			entityManager->pauseTimerCountdowns();
 			_paused = true;
 		}
 	}
@@ -144,8 +144,8 @@ public:
 	void resume() {
 		if (!_paused)
 			return;
-		if (EntityManager *runtimeEntities = _engine.getRuntimeEntities())
-			runtimeEntities->resumeTimerCountdowns();
+		if (EntityManager *entityManager = _engine.getRuntimeEntities())
+			entityManager->resumeTimerCountdowns();
 		_paused = false;
 	}
 
@@ -158,11 +158,11 @@ static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, in
 static int getNativeRoomMenuLineHeight(const Graphics::Font &selectedFont);
 
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
-	const Text *startupText = engine.getText();
-	if (!startupText || !fontName)
+	const Text *text = engine.getText();
+	if (!text || !fontName)
 		return nullptr;
 
-	for (const CftFontResource &font : startupText->getFonts()) {
+	for (const CftFontResource &font : text->getFonts()) {
 		if (font.name.equalsIgnoreCase(fontName))
 			return &font;
 	}
@@ -365,8 +365,8 @@ static void renderHelpScreen(HarvesterEngine &engine, const IndexedBitmap &bitma
 	screen->update();
 }
 
-static Common::String buildTextModeSuffix(const Script &startupScript, const RoomMenuTextConfig &config) {
-	switch (startupScript.getDialogueTextMode()) {
+static Common::String buildTextModeSuffix(const Script &script, const RoomMenuTextConfig &config) {
+	switch (script.getDialogueTextMode()) {
 	case kStartupDialogueTextNone:
 		return Common::String::format(" - %s", config.noLabel.c_str());
 	case kStartupDialogueTextClick:
@@ -377,18 +377,18 @@ static Common::String buildTextModeSuffix(const Script &startupScript, const Roo
 	}
 }
 
-static Common::String buildOptionsMenuItemLabel(const Script &startupScript,
+static Common::String buildOptionsMenuItemLabel(const Script &script,
 		const RoomMenuTextConfig &config, int index) {
 	if (index < 0 || index >= (int)config.optionItems.size())
 		return Common::String();
 
 	switch (index) {
 	case 3:
-		return config.optionItems[index] + buildTextModeSuffix(startupScript, config);
+		return config.optionItems[index] + buildTextModeSuffix(script, config);
 	case 4:
-		return config.optionItems[index] + (startupScript.isGoreEnabled() ? " - On" : " - Off");
+		return config.optionItems[index] + (script.isGoreEnabled() ? " - On" : " - Off");
 	case 6:
-		return config.optionItems[index] + (startupScript.getParentalPassword().empty() ? " - Off" : " - On");
+		return config.optionItems[index] + (script.getParentalPassword().empty() ? " - Off" : " - On");
 	default:
 		return config.optionItems[index];
 	}
@@ -500,8 +500,8 @@ static void renderOptionsMenuScreen(HarvesterEngine &engine, const IndexedBitmap
 		const IndexedBitmap &volumeBar, const IndexedBitmap &indicator, int selectedItem,
 		bool drawCursor = true) {
 	Graphics::Screen *screen = engine.getScreen();
-	Script *startupScript = engine.getScript();
-	if (!screen || !startupScript)
+	Script *script = engine.getScript();
+	if (!screen || !script)
 		return;
 
 	applyMenuPalette(*screen, engine, palette, paletteBrightness);
@@ -510,7 +510,7 @@ static void renderOptionsMenuScreen(HarvesterEngine &engine, const IndexedBitmap
 
 	const int lineHeight = getNativeRoomMenuLineHeight(selectedFont);
 	for (int i = 0; i < (int)config.optionItems.size(); ++i) {
-		const Common::String label = buildOptionsMenuItemLabel(*startupScript, config, i);
+		const Common::String label = buildOptionsMenuItemLabel(*script, config, i);
 		const Graphics::Font &font = (i == selectedItem) ? selectedFont : unselectedFont;
 		const int width = font.getStringWidth(label);
 		const int x = (screen->w - width) / 2;
@@ -525,9 +525,9 @@ static void renderOptionsMenuScreen(HarvesterEngine &engine, const IndexedBitmap
 		blitTransparentBitmap(*screen, volumeBar, kOptionsVolumeBitmapX, kOptionsVolumeBitmapY + i * lineHeight);
 
 	const int levels[3] = {
-		startupScript->getFxVolumeLevel(),
-		startupScript->getMusicVolumeLevel(),
-		startupScript->getGammaLevel()
+		script->getFxVolumeLevel(),
+		script->getMusicVolumeLevel(),
+		script->getGammaLevel()
 	};
 	for (int i = 0; i < 3; ++i) {
 		blitTransparentBitmap(*screen, indicator, kOptionsSliderMinX + levels[i] * kOptionsSliderStep,
@@ -546,8 +546,8 @@ static void renderQuickTipsOverlay(HarvesterEngine &engine, const IndexedBitmap
 	Graphics::Screen *screen = engine.getScreen();
 	const Art *art = engine.getArt();
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	Script *startupScript = engine.getScript();
-	if (!screen || !art || !font || !startupScript)
+	Script *script = engine.getScript();
+	if (!screen || !art || !font || !script)
 		return;
 
 	applyMenuPalette(*screen, engine, palette, paletteBrightness);
@@ -557,8 +557,8 @@ static void renderQuickTipsOverlay(HarvesterEngine &engine, const IndexedBitmap
 
 	drawWrappedShadowedText(*screen, *font, tipText, kQuickTipTextX, kQuickTipTextY, kQuickTipTextWidth,
 		kTextColorNormal);
-	const Common::String toggleLabel = startupScript->resolveTextValue(
-		startupScript->isQuickTipsEnabled() ? "Show_Tips_ON" : "Show_Tips_OFF");
+	const Common::String toggleLabel = script->resolveTextValue(
+		script->isQuickTipsEnabled() ? "Show_Tips_ON" : "Show_Tips_OFF");
 	drawShadowedString(*screen, *font, "Exit", quickTipsExitRect().left, quickTipsExitRect().top,
 		quickTipsExitRect().width(), kQuickTipActionColor);
 	drawShadowedString(*screen, *font, "Next", quickTipsNextRect().left, quickTipsNextRect().top,
@@ -665,7 +665,7 @@ MenuSystem::MenuSystem(HarvesterEngine &engine, Common::Point &mousePos,
 	: _engine(engine), _mousePos(mousePos), _menuItems(menuItems) {
 }
 
-Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
+Common::Error MenuSystem::runMainMenuStub(Flow &flow) {
 	const Art *art = _engine.getArt();
 	if (!art)
 		return Common::kReadingFailed;
@@ -676,8 +676,8 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 		_engine.canSaveGameStateCurrently(),
 		_engine.canLoadGameStateCurrently(),
 		mainMenuItems);
-	if (startupFlow.takePendingGameOverReturn()) {
-		Common::Error gameOverError = showGameOverBackdrop(startupFlow);
+	if (flow.takePendingGameOverReturn()) {
+		Common::Error gameOverError = showGameOverBackdrop(flow);
 		if (gameOverError.getCode() != Common::kNoError)
 			return gameOverError;
 	} else {
@@ -701,7 +701,7 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 	};
 	auto runSelectedRoomLoop = [&](const Common::String &targetName) -> Common::Error {
 		pausedTimers.resume();
-		Common::Error roomError = startupFlow.runRoomLoop(targetName);
+		Common::Error roomError = flow.runRoomLoop(targetName);
 		pausedTimers.pause();
 		_engine.stopMusic();
 		_engine.stopSound();
@@ -716,9 +716,9 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 
 		statusMessage.clear();
 		needsRedraw = true;
-		(void)startupFlow.takePendingMainMenuReturn();
-		if (startupFlow.takePendingGameOverReturn()) {
-			Common::Error gameOverError = showGameOverBackdrop(startupFlow);
+		(void)flow.takePendingMainMenuReturn();
+		if (flow.takePendingGameOverReturn()) {
+			Common::Error gameOverError = showGameOverBackdrop(flow);
 			if (gameOverError.getCode() != Common::kNoError)
 				return gameOverError;
 		} else {
@@ -744,20 +744,20 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 
 			bool confirmed = false;
 			Common::Error confirmError = runConfirmPrompt(
-				menuBackdrop, menuPalette, 1.0f, startupFlow, config.newGamePrompt, confirmed);
+				menuBackdrop, menuPalette, 1.0f, flow, config.newGamePrompt, confirmed);
 			if (confirmError.getCode() != Common::kNoError)
 				return confirmError;
 			needsRedraw = true;
 			if (!confirmed)
 				return Common::kNoError;
 
-			startupFlow.prepareForNewGame();
+			flow.prepareForNewGame();
 			return runSelectedRoomLoop("START");
 		}
 
 		if (item.equalsIgnoreCase("LOAD GAME")) {
 			bool loadedGame = false;
-			Common::Error loadError = runLoadGameMenu(menuPalette, 1.0f, startupFlow, loadedGame);
+			Common::Error loadError = runLoadGameMenu(menuPalette, 1.0f, flow, loadedGame);
 			if (loadError.getCode() != Common::kNoError)
 				return loadError;
 			needsRedraw = true;
@@ -777,7 +777,7 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 
 		if (item.equalsIgnoreCase("SAVE GAME")) {
 			bool savedGame = false;
-			Common::Error saveError = runSaveGameMenu(menuPalette, 1.0f, startupFlow, savedGame);
+			Common::Error saveError = runSaveGameMenu(menuPalette, 1.0f, flow, savedGame);
 			(void)savedGame;
 			needsRedraw = true;
 			return saveError;
@@ -787,13 +787,13 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 			IndexedBitmap menuBackdrop;
 			if (!captureMenuBackdrop(menuBackdrop))
 				return Common::kReadingFailed;
-			Common::Error optionsError = runOptionsMenu(menuBackdrop, menuPalette, 1.0f, startupFlow);
+			Common::Error optionsError = runOptionsMenu(menuBackdrop, menuPalette, 1.0f, flow);
 			needsRedraw = true;
 			return optionsError;
 		}
 
 		if (item.equalsIgnoreCase("HELP")) {
-			Common::Error helpError = runHelpScreen(menuPalette, 1.0f, startupFlow);
+			Common::Error helpError = runHelpScreen(menuPalette, 1.0f, flow);
 			needsRedraw = true;
 			return helpError;
 		}
@@ -802,7 +802,7 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 			IndexedBitmap menuBackdrop;
 			if (!captureMenuBackdrop(menuBackdrop))
 				return Common::kReadingFailed;
-			Common::Error quitError = runQuitGameConfirm(menuBackdrop, menuPalette, 1.0f, startupFlow);
+			Common::Error quitError = runQuitGameConfirm(menuBackdrop, menuPalette, 1.0f, flow);
 			needsRedraw = true;
 			return quitError;
 		}
@@ -822,7 +822,7 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 		Common::Event event;
 		while (g_system->getEventManager()->pollEvent(event)) {
 			Common::Error result = Common::kNoError;
-			if (startupFlow.handleSystemEvent(event, result))
+			if (flow.handleSystemEvent(event, result))
 				return result;
 
 			switch (event.type) {
@@ -876,7 +876,7 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 			}
 		}
 
-		if (startupFlow.tickRuntimeEntities())
+		if (flow.tickRuntimeEntities())
 			needsRedraw = true;
 
 		limiter.delayBeforeSwap();
@@ -886,7 +886,7 @@ Common::Error MenuSystem::runMainMenuStub(Flow &startupFlow) {
 	return Common::kNoError;
 }
 
-Common::Error MenuSystem::showGameOverBackdrop(Flow &startupFlow) {
+Common::Error MenuSystem::showGameOverBackdrop(Flow &flow) {
 	ResourceManager *resources = _engine.getResources();
 	Graphics::Screen *screen = _engine.getScreen();
 	if (!resources || !screen)
@@ -902,7 +902,7 @@ Common::Error MenuSystem::showGameOverBackdrop(Flow &startupFlow) {
 	_mainMenuBackdrop = backdrop;
 	memcpy(_mainMenuBackdropPalette, palette, sizeof(_mainMenuBackdropPalette));
 	_hasMainMenuBackdrop = true;
-	startupFlow.resetCursorAnimationSequence();
+	flow.resetCursorAnimationSequence();
 	(void)_engine.playMusic(kGameOverMusicPath);
 
 	bool needsRedraw = true;
@@ -922,7 +922,7 @@ Common::Error MenuSystem::showGameOverBackdrop(Flow &startupFlow) {
 		Common::Event event;
 		while (g_system->getEventManager()->pollEvent(event)) {
 			Common::Error result = Common::kNoError;
-			if (startupFlow.handleSystemEvent(event, result))
+			if (flow.handleSystemEvent(event, result))
 				return result;
 
 			switch (event.type) {
@@ -938,8 +938,8 @@ Common::Error MenuSystem::showGameOverBackdrop(Flow &startupFlow) {
 			}
 		}
 
-		if (EntityManager *runtimeEntities = _engine.getRuntimeEntities()) {
-			if (runtimeEntities->syncCursorEntityPosition(_mousePos))
+		if (EntityManager *entityManager = _engine.getRuntimeEntities()) {
+			if (entityManager->syncCursorEntityPosition(_mousePos))
 				needsRedraw = true;
 		}
 
@@ -957,12 +957,12 @@ void MenuSystem::clearMainMenuBackdrop() {
 }
 
 Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, Flow &startupFlow) {
+		float paletteBrightness, Flow &flow) {
 	Graphics::FrameLimiter limiter(g_system, 60);
 	int selectedItem = _menuItems.empty() ? -1 : 0;
 	bool needsRedraw = true;
 	bool shouldCloseMenu = false;
-	startupFlow.resetCursorAnimationSequence();
+	flow.resetCursorAnimationSequence();
 
 	auto activateSelectedItem = [&]() -> Common::Error {
 		if (selectedItem < 0 || selectedItem >= (int)_menuItems.size())
@@ -975,12 +975,12 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 
 			bool confirmed = false;
 			Common::Error confirmError = runConfirmPrompt(
-				backdrop, palette, paletteBrightness, startupFlow, config.newGamePrompt, confirmed);
+				backdrop, palette, paletteBrightness, flow, config.newGamePrompt, confirmed);
 			if (confirmError.getCode() != Common::kNoError)
 				return confirmError;
 			needsRedraw = true;
 			if (confirmed) {
-				startupFlow.requestNewGameRestart();
+				flow.requestNewGameRestart();
 				shouldCloseMenu = true;
 			}
 			return Common::kNoError;
@@ -988,7 +988,7 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 
 		if (item.equalsIgnoreCase("LOAD GAME")) {
 			bool loadedGame = false;
-			Common::Error loadError = runLoadGameMenu(palette, paletteBrightness, startupFlow, loadedGame);
+			Common::Error loadError = runLoadGameMenu(palette, paletteBrightness, flow, loadedGame);
 			if (loadError.getCode() != Common::kNoError)
 				return loadError;
 			needsRedraw = true;
@@ -998,7 +998,7 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 		}
 
 		if (item.equalsIgnoreCase("OPTIONS")) {
-			Common::Error optionsError = runOptionsMenu(backdrop, palette, paletteBrightness, startupFlow);
+			Common::Error optionsError = runOptionsMenu(backdrop, palette, paletteBrightness, flow);
 			if (optionsError.getCode() != Common::kNoError)
 				return optionsError;
 			needsRedraw = true;
@@ -1006,7 +1006,7 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 		}
 
 		if (item.equalsIgnoreCase("HELP")) {
-			Common::Error helpError = runHelpScreen(palette, paletteBrightness, startupFlow);
+			Common::Error helpError = runHelpScreen(palette, paletteBrightness, flow);
 			if (helpError.getCode() != Common::kNoError)
 				return helpError;
 			needsRedraw = true;
@@ -1015,13 +1015,13 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 
 		if (item.equalsIgnoreCase("SAVE GAME")) {
 			bool savedGame = false;
-			Common::Error saveError = runSaveGameMenu(palette, paletteBrightness, startupFlow, savedGame);
+			Common::Error saveError = runSaveGameMenu(palette, paletteBrightness, flow, savedGame);
 			if (saveError.getCode() != Common::kNoError)
 				return saveError;
 			if (savedGame) {
 				// Native exits the in-room ESC menu after a successful save and restores the room view.
 				Common::Error restoreError = restoreRoomBackdropAfterSave(
-					backdrop, palette, paletteBrightness, startupFlow);
+					backdrop, palette, paletteBrightness, flow);
 				if (restoreError.getCode() != Common::kNoError)
 					return restoreError;
 				shouldCloseMenu = true;
@@ -1032,7 +1032,7 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 		}
 
 		if (item.equalsIgnoreCase("QUIT GAME")) {
-			Common::Error quitError = runQuitGameConfirm(backdrop, palette, paletteBrightness, startupFlow);
+			Common::Error quitError = runQuitGameConfirm(backdrop, palette, paletteBrightness, flow);
 			if (quitError.getCode() != Common::kNoError)
 				return quitError;
 			needsRedraw = true;
@@ -1052,7 +1052,7 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 		Common::Event event;
 		while (g_system->getEventManager()->pollEvent(event)) {
 			Common::Error result = Common::kNoError;
-			if (startupFlow.handleSystemEvent(event, result))
+			if (flow.handleSystemEvent(event, result))
 				return result;
 
 			switch (event.type) {
@@ -1108,8 +1108,8 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 			}
 		}
 
-		EntityManager *runtimeEntities = _engine.getRuntimeEntities();
-		if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
+		EntityManager *entityManager = _engine.getRuntimeEntities();
+		if (entityManager && entityManager->syncCursorEntityPosition(_mousePos))
 			needsRedraw = true;
 
 		limiter.delayBeforeSwap();
@@ -1120,7 +1120,7 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 }
 
 Common::Error MenuSystem::runLoadGameMenu(const byte *palette, float paletteBrightness,
-		Flow &startupFlow, bool &loadedGame) {
+		Flow &flow, bool &loadedGame) {
 	(void)palette;
 	(void)paletteBrightness;
 	loadedGame = false;
@@ -1177,7 +1177,7 @@ Common::Error MenuSystem::runLoadGameMenu(const byte *palette, float paletteBrig
 
 	Common::String statusMessage;
 	bool needsRedraw = true;
-	startupFlow.resetCursorAnimationSequence();
+	flow.resetCursorAnimationSequence();
 	Graphics::FrameLimiter limiter(g_system, 60);
 
 	auto activateSelectedSlot = [&]() -> Common::Error {
@@ -1205,7 +1205,7 @@ Common::Error MenuSystem::runLoadGameMenu(const byte *palette, float paletteBrig
 		Common::Event event;
 		while (g_system->getEventManager()->pollEvent(event)) {
 			Common::Error result = Common::kNoError;
-			if (startupFlow.handleSystemEvent(event, result))
+			if (flow.handleSystemEvent(event, result))
 				return result;
 
 			switch (event.type) {
@@ -1268,8 +1268,8 @@ Common::Error MenuSystem::runLoadGameMenu(const byte *palette, float paletteBrig
 			}
 		}
 
-		if (EntityManager *runtimeEntities = _engine.getRuntimeEntities())
-			(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+		if (EntityManager *entityManager = _engine.getRuntimeEntities())
+			(void)entityManager->syncCursorEntityPosition(_mousePos);
 
 		limiter.delayBeforeSwap();
 		limiter.startFrame();
@@ -1279,7 +1279,7 @@ Common::Error MenuSystem::runLoadGameMenu(const byte *palette, float paletteBrig
 }
 
 Common::Error MenuSystem::runSaveGameMenu(const byte *palette, float paletteBrightness,
-		Flow &startupFlow, bool &savedGame) {
+		Flow &flow, bool &savedGame) {
 	(void)palette;
 	(void)paletteBrightness;
 	savedGame = false;
@@ -1331,7 +1331,7 @@ Common::Error MenuSystem::runSaveGameMenu(const byte *palette, float paletteBrig
 			bool &confirmed) -> Common::Error {
 		confirmed = false;
 		bool needsRedraw = true;
-		startupFlow.resetCursorAnimationSequence();
+		flow.resetCursorAnimationSequence();
 		Graphics::FrameLimiter entryLimiter(g_system, 60);
 
 		while (!_engine.shouldQuit()) {
@@ -1345,7 +1345,7 @@ Common::Error MenuSystem::runSaveGameMenu(const byte *palette, float paletteBrig
 			Common::Event event;
 			while (g_system->getEventManager()->pollEvent(event)) {
 				Common::Error result = Common::kNoError;
-				if (startupFlow.handleSystemEvent(event, result))
+				if (flow.handleSystemEvent(event, result))
 					return result;
 
 				switch (event.type) {
@@ -1383,8 +1383,8 @@ Common::Error MenuSystem::runSaveGameMenu(const byte *palette, float paletteBrig
 				}
 			}
 
-			if (EntityManager *runtimeEntities = _engine.getRuntimeEntities())
-				(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+			if (EntityManager *entityManager = _engine.getRuntimeEntities())
+				(void)entityManager->syncCursorEntityPosition(_mousePos);
 
 			entryLimiter.delayBeforeSwap();
 			entryLimiter.startFrame();
@@ -1396,7 +1396,7 @@ Common::Error MenuSystem::runSaveGameMenu(const byte *palette, float paletteBrig
 	int activeSlot = 0;
 	Common::String statusMessage;
 	bool needsRedraw = true;
-	startupFlow.resetCursorAnimationSequence();
+	flow.resetCursorAnimationSequence();
 	Graphics::FrameLimiter limiter(g_system, 60);
 
 	auto activateSelectedSlot = [&](bool &saved) -> Common::Error {
@@ -1431,7 +1431,7 @@ Common::Error MenuSystem::runSaveGameMenu(const byte *palette, float paletteBrig
 		Common::Event event;
 		while (g_system->getEventManager()->pollEvent(event)) {
 			Common::Error result = Common::kNoError;
-			if (startupFlow.handleSystemEvent(event, result))
+			if (flow.handleSystemEvent(event, result))
 				return result;
 
 			switch (event.type) {
@@ -1501,8 +1501,8 @@ Common::Error MenuSystem::runSaveGameMenu(const byte *palette, float paletteBrig
 			}
 		}
 
-		if (EntityManager *runtimeEntities = _engine.getRuntimeEntities())
-			(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+		if (EntityManager *entityManager = _engine.getRuntimeEntities())
+			(void)entityManager->syncCursorEntityPosition(_mousePos);
 
 		limiter.delayBeforeSwap();
 		limiter.startFrame();
@@ -1512,7 +1512,7 @@ Common::Error MenuSystem::runSaveGameMenu(const byte *palette, float paletteBrig
 }
 
 Common::Error MenuSystem::restoreRoomBackdropAfterSave(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, Flow &startupFlow) const {
+		float paletteBrightness, Flow &flow) const {
 	Graphics::Screen *screen = _engine.getScreen();
 	if (!screen || !backdrop.isValid())
 		return Common::kNoError;
@@ -1542,7 +1542,7 @@ Common::Error MenuSystem::restoreRoomBackdropAfterSave(const IndexedBitmap &back
 				Common::Event event;
 				while (g_system->getEventManager()->pollEvent(event)) {
 					Common::Error result = Common::kNoError;
-					if (startupFlow.handleSystemEvent(event, result))
+					if (flow.handleSystemEvent(event, result))
 						return result;
 				}
 				g_system->delayMillis(1);
@@ -1561,11 +1561,11 @@ Common::Error MenuSystem::restoreRoomBackdropAfterSave(const IndexedBitmap &back
 	screen->makeAllDirty();
 	screen->update();
 
-	return startupFlow.fadeInRoomScene(palette, paletteBrightness);
+	return flow.fadeInRoomScene(palette, paletteBrightness);
 }
 
 Common::Error MenuSystem::runConfirmPrompt(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, Flow &startupFlow, const Common::String &promptText,
+		float paletteBrightness, Flow &flow, const Common::String &promptText,
 		bool &confirmed) {
 	const Art *art = _engine.getArt();
 	const CftFontResource *promptFontResource = findStartupFontByName(_engine, "MEDFONT1");
@@ -1585,7 +1585,7 @@ Common::Error MenuSystem::runConfirmPrompt(const IndexedBitmap &backdrop, const
 		return Common::kReadingFailed;
 
 	confirmed = false;
-	startupFlow.resetCursorAnimationSequence();
+	flow.resetCursorAnimationSequence();
 	bool needsRedraw = true;
 	Graphics::FrameLimiter limiter(g_system, 60);
 
@@ -1605,7 +1605,7 @@ Common::Error MenuSystem::runConfirmPrompt(const IndexedBitmap &backdrop, const
 		Common::Event event;
 		while (g_system->getEventManager()->pollEvent(event)) {
 			Common::Error result = Common::kNoError;
-			if (startupFlow.handleSystemEvent(event, result))
+			if (flow.handleSystemEvent(event, result))
 				return result;
 
 			switch (event.type) {
@@ -1637,8 +1637,8 @@ Common::Error MenuSystem::runConfirmPrompt(const IndexedBitmap &backdrop, const
 			}
 		}
 
-		if (EntityManager *runtimeEntities = _engine.getRuntimeEntities())
-			(void)runtimeEntities->syncCursorEntityPosition(_mousePos);
+		if (EntityManager *entityManager = _engine.getRuntimeEntities())
+			(void)entityManager->syncCursorEntityPosition(_mousePos);
 
 		limiter.delayBeforeSwap();
 		limiter.startFrame();
@@ -1648,13 +1648,13 @@ Common::Error MenuSystem::runConfirmPrompt(const IndexedBitmap &backdrop, const
 }
 
 Common::Error MenuSystem::runQuitGameConfirm(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, Flow &startupFlow) {
+		float paletteBrightness, Flow &flow) {
 	RoomMenuTextConfig config;
 	(void)loadMenuTextConfig(_engine, config);
 
 	bool confirmed = false;
 	Common::Error confirmError = runConfirmPrompt(
-		backdrop, palette, paletteBrightness, startupFlow, config.quitGamePrompt, confirmed);
+		backdrop, palette, paletteBrightness, flow, config.quitGamePrompt, confirmed);
 	if (confirmError.getCode() != Common::kNoError)
 		return confirmError;
 	if (!confirmed)
@@ -1667,13 +1667,13 @@ Common::Error MenuSystem::runQuitGameConfirm(const IndexedBitmap &backdrop, cons
 }
 
 Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, Flow &startupFlow) {
+		float paletteBrightness, Flow &flow) {
 	const Art *art = _engine.getArt();
-	Script *startupScript = _engine.getScript();
+	Script *script = _engine.getScript();
 	ResourceManager *resources = _engine.getResources();
 	const CftFontResource *selectedFontResource = findStartupFontByName(_engine, "HARVFONT");
 	const CftFontResource *unselectedFontResource = findStartupFontByName(_engine, "HARVFNT2");
-	if (!art || !startupScript || !resources || !selectedFontResource || !unselectedFontResource)
+	if (!art || !script || !resources || !selectedFontResource || !unselectedFontResource)
 		return Common::kReadingFailed;
 
 	HarvesterCftFont selectedFont(*selectedFontResource);
@@ -1696,17 +1696,17 @@ Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const by
 	int capturedSlider = -1;
 	bool showingQuickTips = false;
 	bool needsRedraw = true;
-	uint quickTipIndex = startupFlow._quickTips.empty() ? 0 : _engine.getRandomNumber(startupFlow._quickTips.size() - 1);
+	uint quickTipIndex = flow._quickTips.empty() ? 0 : _engine.getRandomNumber(flow._quickTips.size() - 1);
 
 	auto persistConfig = [&]() {
-		(void)startupScript->saveConfig();
+		(void)script->saveConfig();
 	};
 
 	auto updateSlider = [&](int sliderIndex) {
 		const int newLevel = resolveOptionsSliderIndexFromMouseX(_mousePos.x);
 		switch (sliderIndex) {
 		case 0:
-			if (newLevel != startupScript->getFxVolumeLevel()) {
+			if (newLevel != script->getFxVolumeLevel()) {
 				_engine.setFxVolumeLevel(newLevel);
 				_engine.playSingleSound(kOptionsPreviewSoundPath);
 				persistConfig();
@@ -1714,14 +1714,14 @@ Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const by
 			}
 			break;
 		case 1:
-			if (newLevel != startupScript->getMusicVolumeLevel()) {
+			if (newLevel != script->getMusicVolumeLevel()) {
 				_engine.setMusicVolumeLevel(newLevel);
 				persistConfig();
 				needsRedraw = true;
 			}
 			break;
 		case 2:
-			if (newLevel != startupScript->getGammaLevel()) {
+			if (newLevel != script->getGammaLevel()) {
 				_engine.setGammaLevel(newLevel);
 				persistConfig();
 				needsRedraw = true;
@@ -1733,16 +1733,16 @@ Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const by
 	};
 
 	auto cycleTextMode = [&]() {
-		switch (startupScript->getDialogueTextMode()) {
+		switch (script->getDialogueTextMode()) {
 		case kStartupDialogueTextNone:
-			startupScript->setDialogueTextMode(kStartupDialogueTextYes);
+			script->setDialogueTextMode(kStartupDialogueTextYes);
 			break;
 		case kStartupDialogueTextYes:
-			startupScript->setDialogueTextMode(kStartupDialogueTextClick);
+			script->setDialogueTextMode(kStartupDialogueTextClick);
 			break;
 		case kStartupDialogueTextClick:
 		default:
-			startupScript->setDialogueTextMode(kStartupDialogueTextNone);
+			script->setDialogueTextMode(kStartupDialogueTextNone);
 			break;
 		}
 		persistConfig();
@@ -1750,8 +1750,8 @@ Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const by
 	};
 
 	auto toggleGore = [&]() {
-		startupScript->setGoreEnabled(!startupScript->isGoreEnabled());
-		ConfMan.setBool("gore", startupScript->isGoreEnabled());
+		script->setGoreEnabled(!script->isGoreEnabled());
+		ConfMan.setBool("gore", script->isGoreEnabled());
 		persistConfig();
 		needsRedraw = true;
 	};
@@ -1789,7 +1789,7 @@ Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const by
 			Common::Event event;
 			while (g_system->getEventManager()->pollEvent(event)) {
 				Common::Error result = Common::kNoError;
-				if (startupFlow.handleSystemEvent(event, result))
+				if (flow.handleSystemEvent(event, result))
 					return Common::String();
 
 				switch (event.type) {
@@ -1833,15 +1833,15 @@ Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const by
 	};
 
 	auto togglePassword = [&]() {
-		if (startupScript->getParentalPassword().empty()) {
+		if (script->getParentalPassword().empty()) {
 			const Common::String password = runPasswordPrompt();
 			if (!password.empty()) {
-				startupScript->setParentalPassword(password);
+				script->setParentalPassword(password);
 				persistConfig();
 				needsRedraw = true;
 			}
 		} else {
-			startupScript->setParentalPassword(Common::String());
+			script->setParentalPassword(Common::String());
 			persistConfig();
 			needsRedraw = true;
 		}
@@ -1856,7 +1856,7 @@ Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const by
 			toggleGore();
 			break;
 		case 5:
-			if (!startupFlow._quickTips.empty()) {
+			if (!flow._quickTips.empty()) {
 				showingQuickTips = true;
 				needsRedraw = true;
 			}
@@ -1876,7 +1876,7 @@ Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const by
 		if (needsRedraw) {
 			if (showingQuickTips) {
 				renderQuickTipsOverlay(_engine, backdrop, palette, paletteBrightness,
-					startupFlow._quickTips[quickTipIndex]);
+					flow._quickTips[quickTipIndex]);
 			} else {
 				renderOptionsMenuScreen(_engine, backdrop, palette, paletteBrightness,
 					selectedFont, unselectedFont, *art, config, volumeBar, indicator, selectedItem);
@@ -1887,7 +1887,7 @@ Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const by
 		Common::Event event;
 		while (g_system->getEventManager()->pollEvent(event)) {
 			Common::Error result = Common::kNoError;
-			if (startupFlow.handleSystemEvent(event, result))
+			if (flow.handleSystemEvent(event, result))
 				return result;
 
 			if (showingQuickTips) {
@@ -1900,10 +1900,10 @@ Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const by
 						showingQuickTips = false;
 						needsRedraw = true;
 					} else if (quickTipsNextRect().contains(_mousePos)) {
-						quickTipIndex = (quickTipIndex + 1) % startupFlow._quickTips.size();
+						quickTipIndex = (quickTipIndex + 1) % flow._quickTips.size();
 						needsRedraw = true;
 					} else if (quickTipsToggleRect().contains(_mousePos)) {
-						startupScript->setQuickTipsEnabled(!startupScript->isQuickTipsEnabled());
+						script->setQuickTipsEnabled(!script->isQuickTipsEnabled());
 						persistConfig();
 						needsRedraw = true;
 					}
@@ -2017,7 +2017,7 @@ Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const by
 					break;
 				case Common::KEYCODE_q:
 					selectedItem = 5;
-					if (!startupFlow._quickTips.empty()) {
+					if (!flow._quickTips.empty()) {
 						showingQuickTips = true;
 						needsRedraw = true;
 					}
@@ -2035,8 +2035,8 @@ Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const by
 			}
 		}
 
-		EntityManager *runtimeEntities = _engine.getRuntimeEntities();
-		if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
+		EntityManager *entityManager = _engine.getRuntimeEntities();
+		if (entityManager && entityManager->syncCursorEntityPosition(_mousePos))
 			needsRedraw = true;
 
 		limiter.delayBeforeSwap();
@@ -2046,7 +2046,7 @@ Common::Error MenuSystem::runOptionsMenu(const IndexedBitmap &backdrop, const by
 	return Common::kNoError;
 }
 
-Common::Error MenuSystem::runHelpScreen(const byte *palette, float paletteBrightness, Flow &startupFlow) {
+Common::Error MenuSystem::runHelpScreen(const byte *palette, float paletteBrightness, Flow &flow) {
 	ResourceManager *resources = _engine.getResources();
 	if (!resources)
 		return Common::kReadingFailed;
@@ -2076,7 +2076,7 @@ Common::Error MenuSystem::runHelpScreen(const byte *palette, float paletteBright
 		Common::Event event;
 		while (g_system->getEventManager()->pollEvent(event)) {
 			Common::Error result = Common::kNoError;
-			if (startupFlow.handleSystemEvent(event, result))
+			if (flow.handleSystemEvent(event, result))
 				return result;
 
 			switch (event.type) {
@@ -2109,8 +2109,8 @@ Common::Error MenuSystem::runHelpScreen(const byte *palette, float paletteBright
 			}
 		}
 
-		EntityManager *runtimeEntities = _engine.getRuntimeEntities();
-		if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
+		EntityManager *entityManager = _engine.getRuntimeEntities();
+		if (entityManager && entityManager->syncCursorEntityPosition(_mousePos))
 			needsRedraw = true;
 
 		limiter.delayBeforeSwap();
diff --git a/engines/harvester/menu.h b/engines/harvester/menu.h
index 74a51d88228..58c512a36c0 100644
--- a/engines/harvester/menu.h
+++ b/engines/harvester/menu.h
@@ -42,26 +42,26 @@ public:
 	MenuSystem(HarvesterEngine &engine, Common::Point &mousePos,
 		const Common::Array<Common::String> &menuItems);
 
-	Common::Error runMainMenuStub(Flow &startupFlow);
+	Common::Error runMainMenuStub(Flow &flow);
 	Common::Error runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, Flow &startupFlow);
+		float paletteBrightness, Flow &flow);
 
 private:
 	Common::Error runLoadGameMenu(const byte *palette, float paletteBrightness,
-		Flow &startupFlow, bool &loadedGame);
+		Flow &flow, bool &loadedGame);
 	Common::Error runOptionsMenu(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, Flow &startupFlow);
-	Common::Error runHelpScreen(const byte *palette, float paletteBrightness, Flow &startupFlow);
+		float paletteBrightness, Flow &flow);
+	Common::Error runHelpScreen(const byte *palette, float paletteBrightness, Flow &flow);
 	Common::Error runSaveGameMenu(const byte *palette, float paletteBrightness,
-		Flow &startupFlow, bool &savedGame);
+		Flow &flow, bool &savedGame);
 	Common::Error restoreRoomBackdropAfterSave(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, Flow &startupFlow) const;
+		float paletteBrightness, Flow &flow) const;
 	Common::Error runConfirmPrompt(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, Flow &startupFlow, const Common::String &promptText,
+		float paletteBrightness, Flow &flow, const Common::String &promptText,
 		bool &confirmed);
 	Common::Error runQuitGameConfirm(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, Flow &startupFlow);
-	Common::Error showGameOverBackdrop(Flow &startupFlow);
+		float paletteBrightness, Flow &flow);
+	Common::Error showGameOverBackdrop(Flow &flow);
 	void clearMainMenuBackdrop();
 	void renderMainMenuStub(const Common::Array<Common::String> &menuItems, int selectedItem,
 		const Common::String &statusMessage) const;
diff --git a/engines/harvester/npc/dialogue_runtime.h b/engines/harvester/npc/dialogue_runtime.h
index e54b7bf72b4..322ed0f121c 100644
--- a/engines/harvester/npc/dialogue_runtime.h
+++ b/engines/harvester/npc/dialogue_runtime.h
@@ -50,8 +50,8 @@ public:
 	typedef std::function<int(int)> GetRandomNumberFn;
 	typedef std::function<void(const Common::String &, int)> SetActiveSpeakerPortraitFn;
 
-	DialogueRuntime(HarvesterEngine &engine, Script &startupScript, Text &startupText,
-			Flow &startupFlow, const Common::String &currentRoomName,
+	DialogueRuntime(HarvesterEngine &engine, Script &script, Text &text,
+			Flow &flow, const Common::String &currentRoomName,
 			const Common::String &genericByeTopic,
 			const PlayDialogueLineWithVariantFn &playDialogueLineWithVariant,
 			const PlayDialogueLineFn &playDialogueLine,
@@ -70,8 +70,8 @@ public:
 			const ApplyImmediateDialogueInteractionEffectsFn &applyImmediateDialogueInteractionEffects,
 			const GetRandomNumberFn &getRandomNumber,
 			const SetActiveSpeakerPortraitFn &setActiveSpeakerPortrait)
-		: _engine(engine), _startupScript(startupScript), _startupText(startupText),
-		  _startupFlow(startupFlow), _currentRoomName(currentRoomName),
+		: _engine(engine), _script(script), _text(text),
+		  _flow(flow), _currentRoomName(currentRoomName),
 		  _genericByeTopic(genericByeTopic),
 		  _playDialogueLineWithVariant(playDialogueLineWithVariant),
 		  _playDialogueLine(playDialogueLine), _playDialogueEntrySequence(playDialogueEntrySequence),
@@ -89,9 +89,9 @@ public:
 	}
 
 	HarvesterEngine &engine() const { return _engine; }
-	Script &startupScript() const { return _startupScript; }
-	Text &startupText() const { return _startupText; }
-	Flow &startupFlow() const { return _startupFlow; }
+	Script &startupScript() const { return _script; }
+	Text &startupText() const { return _text; }
+	Flow &startupFlow() const { return _flow; }
 	const Common::String &currentRoomName() const { return _currentRoomName; }
 	const Common::String &genericByeTopic() const { return _genericByeTopic; }
 
@@ -146,9 +146,9 @@ public:
 
 private:
 	HarvesterEngine &_engine;
-	Script &_startupScript;
-	Text &_startupText;
-	Flow &_startupFlow;
+	Script &_script;
+	Text &_text;
+	Flow &_flow;
 	const Common::String &_currentRoomName;
 	const Common::String &_genericByeTopic;
 	PlayDialogueLineWithVariantFn _playDialogueLineWithVariant;
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index a934acc914b..7ef717e4f34 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -630,18 +630,18 @@ static float stepTowardsFloat(float current, float target, float step) {
 static bool doesPlayerOverlapRoomBlocker(HarvesterEngine &engine,
 		const Common::Array<ObjectRecord> &sceneObjects,
 		const Common::Array<AnimRecord> &sceneAnimations, const Entity &playerEntity) {
-	EntityManager *runtimeEntities = engine.getRuntimeEntities();
-	if (!runtimeEntities)
+	EntityManager *entityManager = engine.getRuntimeEntities();
+	if (!entityManager)
 		return false;
 
 	for (const ObjectRecord &object : sceneObjects) {
-		const Entity *entity = runtimeEntities->findSceneEntityByName(object.objectName);
+		const Entity *entity = entityManager->findSceneEntityByName(object.objectName);
 		if (entity && playerEntity.overlapsEntity(*entity))
 			return true;
 	}
 
 	for (const AnimRecord &anim : sceneAnimations) {
-		const Entity *entity = runtimeEntities->findSceneEntityByName(anim.animName);
+		const Entity *entity = entityManager->findSceneEntityByName(anim.animName);
 		if (entity && playerEntity.overlapsEntity(*entity))
 			return true;
 	}
@@ -739,11 +739,11 @@ static Entity *ensurePlayerIdleAnimationEntity(HarvesterEngine &engine,
 	if (idleState.entity || !playerState.entity)
 		return idleState.entity;
 
-	EntityManager *runtimeEntities = engine.getRuntimeEntities();
-	if (!runtimeEntities)
+	EntityManager *entityManager = engine.getRuntimeEntities();
+	if (!entityManager)
 		return nullptr;
 
-	idleState.entity = runtimeEntities->spawnSceneAnimationEntity(
+	idleState.entity = entityManager->spawnSceneAnimationEntity(
 		kPlayerIdleAnimationEntityName, kPlayerIdleAnimationResourcePath,
 		Common::Point(playerState.entity->getX(),
 			playerState.entity->getY() + kRoomPlayerIdleYOffset),
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 3873b885ade..909ab010f13 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -236,11 +236,11 @@ static void blitBitmap(Graphics::Screen &screen, const IndexedBitmap &bitmap, in
 }
 
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
-	const Text *startupText = engine.getText();
-	if (!startupText || !fontName)
+	const Text *text = engine.getText();
+	if (!text || !fontName)
 		return nullptr;
 
-	for (const CftFontResource &font : startupText->getFonts()) {
+	for (const CftFontResource &font : text->getFonts()) {
 		if (font.name.equalsIgnoreCase(fontName))
 			return &font;
 	}
@@ -741,9 +741,9 @@ RoomSystem::RoomSystem(HarvesterEngine &engine, Common::Point &mousePos,
 	: _engine(engine), _mousePos(mousePos), _inventory(inventory) {
 }
 
-Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &targetName,
+Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetName,
 		bool targetIsRoomName) {
-	if (startupFlow.hasPendingMainMenuReturn())
+	if (flow.hasPendingMainMenuReturn())
 		return Common::kNoError;
 
 	Common::String currentRoomTarget = targetName;
@@ -752,9 +752,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		RoomSetupState state;
 		bool shouldRunRoomEntryCommands = false;
 		if (_engine.hasPendingLoadedSaveRoomState()) {
-			startupFlow.resetRoomNpcDialogueState();
+			flow.resetRoomNpcDialogueState();
 			if (_engine.hasPendingLoadedDialogueStateBlob()) {
-				if (!startupFlow.loadDialogueSaveStateBlob(
+				if (!flow.loadDialogueSaveStateBlob(
 						_engine.getPendingLoadedDialogueStateBlob(),
 						_engine.getPendingLoadedDialogueStateBlobVersion()))
 					return Common::kReadingFailed;
@@ -794,7 +794,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			shouldRunRoomEntryCommands = true;
 		}
 
-		Common::Error transitionError = startupFlow.beginRoomSetupTransition();
+		Common::Error transitionError = flow.beginRoomSetupTransition();
 		if (transitionError.getCode() != Common::kNoError)
 			return transitionError;
 
@@ -834,25 +834,25 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		}
 		if (!screen || !art || !bodyFont || !promptFont || !inventoryTooltipFont || !inspectFont)
 			return Common::kNoError;
-		EntityManager *runtimeEntities = _engine.getRuntimeEntities();
+		EntityManager *entityManager = _engine.getRuntimeEntities();
 
-		if (!startupFlow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
+		if (!flow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
 			return Common::kReadingFailed;
-		if (runtimeEntities) {
+		if (entityManager) {
 			for (const MonsterRecord &monster : scene.state.roomMonsters) {
-				Entity *entity = runtimeEntities->findSceneEntityByName(monster.monsterName);
+				Entity *entity = entityManager->findSceneEntityByName(monster.monsterName);
 				if (entity)
 					Monster::applyAnimation(*entity, monster);
 			}
-			runtimeEntities->pauseTimerCountdowns();
+			entityManager->pauseTimerCountdowns();
 		}
 
-		startupFlow.resetCursorAnimationSequence();
-		startupFlow.executeStartupAudioCommands(scene.state.audioCommands);
+		flow.resetCursorAnimationSequence();
+		flow.executeStartupAudioCommands(scene.state.audioCommands);
 		if (!scene.state.musicPath.empty())
 			(void)_engine.playMusic(scene.state.musicPath);
 		RoomPlayerState playerState;
-		playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName("PLAYER") : nullptr;
+		playerState.entity = entityManager ? entityManager->findSceneEntityByName("PLAYER") : nullptr;
 		playerState.centerX = scene.state.playerSpawnX;
 		playerState.bottomY = scene.state.playerSpawnY;
 		playerState.z = (float)scene.state.playerSpawnZ;
@@ -904,10 +904,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				sceneObject->currentOwnerOrRoom = *ownerOrRoom;
 		};
 		auto removeSceneEntityByName = [&](const Common::String &entityName) {
-			if (!runtimeEntities)
+			if (!entityManager)
 				return;
 
-			Entity *entity = runtimeEntities->detachSceneEntityByName(entityName);
+			Entity *entity = entityManager->detachSceneEntityByName(entityName);
 			delete entity;
 		};
 		auto resetIdleState = [&]() {
@@ -945,42 +945,42 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			return Player::setIdleAnimation(playerState, resumeFacing);
 		};
 		auto syncCurrentRoomRuntimeState = [&]() {
-			Script *startupScript = _engine.getScript();
-			if (!startupScript)
+			Script *script = _engine.getScript();
+			if (!script)
 				return;
 
 			// Region enable state is a persistent script mutation; keep the runtime record in lockstep
 			// with the currently materialized scene so one-shot regions do not resurrect on revisit.
-			for (const RegionRecord &baseRegion : startupScript->getRegions()) {
+			for (const RegionRecord &baseRegion : script->getRegions()) {
 				if (!baseRegion.roomName.equalsIgnoreCase(scene.state.roomName))
 					continue;
 
 				const bool enabled =
 					findSceneRegionByName(scene.sceneRegions, baseRegion.regionName) != nullptr;
-				(void)startupScript->setRuntimeRegionEnabled(baseRegion.regionName, enabled);
+				(void)script->setRuntimeRegionEnabled(baseRegion.regionName, enabled);
 			}
 
-			if (runtimeEntities) {
+			if (entityManager) {
 				for (AnimRecord &anim : scene.state.roomAnimations) {
-					Entity *entity = runtimeEntities->findSceneEntityByName(anim.animName);
+					Entity *entity = entityManager->findSceneEntityByName(anim.animName);
 					if (!entity)
 						continue;
-					(void)startupScript->syncRuntimeAnimState(anim.animName,
+					(void)script->syncRuntimeAnimState(anim.animName,
 						entity->isAnimationEnabled(), entity->isVisible(), entity->getCurrentFrame());
 				}
 			}
 			for (const MonsterRecord &monster : scene.state.roomMonsters)
-				(void)startupScript->syncRuntimeMonsterRecord(monster);
-			if (!runtimeEntities)
+				(void)script->syncRuntimeMonsterRecord(monster);
+			if (!entityManager)
 				return;
 
 			for (TimerRecord &timer : scene.state.roomTimers) {
-				Entity *entity = runtimeEntities->findSceneEntityByName(timer.timerName);
+				Entity *entity = entityManager->findSceneEntityByName(timer.timerName);
 				if (!entity)
 					continue;
 				timer.currentValue = entity->getTimerCurrentValue();
 				timer.enabled = entity->isTimerEnabled();
-				(void)startupScript->syncRuntimeTimerRecord(timer);
+				(void)script->syncRuntimeTimerRecord(timer);
 			}
 		};
 		auto captureCurrentSaveState = [&]() {
@@ -1008,14 +1008,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			if (object.operatable || !object.actionTag.empty())
 				return true;
 
-			Script *startupScript = _engine.getScript();
-			if (!startupScript)
+			Script *script = _engine.getScript();
+			if (!script)
 				return false;
-			if (startupScript->isPickupObject(object))
+			if (script->isPickupObject(object))
 				return true;
 
 			ResolvedText inspectText;
-			return startupScript->resolveObjectInspectText(object, inspectText);
+			return script->resolveObjectInspectText(object, inspectText);
 		};
 		auto resolveSceneObjectClassLocal = [&](const ObjectRecord &object,
 				const Entity *entity) {
@@ -1037,17 +1037,17 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				lhs.right == rhs.right && lhs.bottom == rhs.bottom;
 		};
 		auto spawnSceneObjectEntityFromRecord = [&](const ObjectRecord &object) {
-			if (!runtimeEntities)
+			if (!entityManager)
 				return;
 
 			const Common::String spritePath = resolveSceneObjectSpritePathLocal(object);
 			const Common::Rect hotspotBounds = getSceneObjectBounds(object);
 			Entity *entity = nullptr;
 			if (!spritePath.empty() && spritePath.hasSuffixIgnoreCase(".BM")) {
-				entity = runtimeEntities->spawnSceneBitmapEntity(object.objectName, spritePath,
+				entity = entityManager->spawnSceneBitmapEntity(object.objectName, spritePath,
 					Common::Point(object.currentX, object.currentY), (float)object.currentZ);
 			} else if (!hotspotBounds.isEmpty()) {
-				entity = runtimeEntities->spawnSceneHotspotEntity(object.objectName, hotspotBounds,
+				entity = entityManager->spawnSceneHotspotEntity(object.objectName, hotspotBounds,
 					(float)object.currentZ);
 			}
 			if (!entity)
@@ -1064,14 +1064,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			return Common::Rect();
 		};
 		auto spawnSceneRegionEntityFromRecord = [&](const RegionRecord &region) {
-			if (!runtimeEntities)
+			if (!entityManager)
 				return;
 
 			const Common::Rect bounds = getSceneRegionBounds(region);
 			if (bounds.isEmpty())
 				return;
 
-			Entity *entity = runtimeEntities->spawnSceneHotspotEntity(
+			Entity *entity = entityManager->spawnSceneHotspotEntity(
 				region.regionName, bounds, (float)region.minZ);
 			if (!entity)
 				return;
@@ -1179,10 +1179,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			return true;
 		};
 		auto spawnSceneNpcEntityFromRecord = [&](const NpcRecord &npc) -> Entity * {
-			if (!runtimeEntities || !shouldSpawnRoomNpcEntity(npc))
+			if (!entityManager || !shouldSpawnRoomNpcEntity(npc))
 				return nullptr;
 
-			Entity *entity = runtimeEntities->spawnSceneActorEntity(
+			Entity *entity = entityManager->spawnSceneActorEntity(
 				npc.npcName, npc.modelPath, Common::Point(npc.posX, npc.posY), (float)npc.posZ, 0);
 			if (!entity)
 				return nullptr;
@@ -1196,15 +1196,15 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				removeSceneEntityByName(npc.npcName);
 				return nullptr;
 			}
-			runtimeEntities->reinsertSceneEntity(entity);
+			entityManager->reinsertSceneEntity(entity);
 			return entity;
 		};
 		auto spawnSceneMonsterEntityFromRecord = [&](const RoomSetupState &roomState,
 				const MonsterRecord &monster) -> Entity * {
-			if (!runtimeEntities || !shouldSpawnRoomMonsterEntity(monster))
+			if (!entityManager || !shouldSpawnRoomMonsterEntity(monster))
 				return nullptr;
 
-			Entity *entity = runtimeEntities->spawnSceneActorEntity(
+			Entity *entity = entityManager->spawnSceneActorEntity(
 				monster.monsterName, monster.modelPath,
 				Common::Point(monster.posX, monster.posY), (float)monster.posZ,
 				Monster::resolveFacingFrame(monster.facing));
@@ -1219,7 +1219,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				removeSceneEntityByName(monster.monsterName);
 				return nullptr;
 			}
-			runtimeEntities->reinsertSceneEntity(entity);
+			entityManager->reinsertSceneEntity(entity);
 			return entity;
 		};
 		auto resolveHitEffectAnchor = [&](const Entity &targetEntity,
@@ -1237,7 +1237,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		};
 		auto spawnCombatHitEffect = [&](Entity &sourceEntity,
 				const Common::String &followTargetName) {
-			if (!runtimeEntities)
+			if (!entityManager)
 				return false;
 
 			RoomHitEffectState effectState;
@@ -1245,7 +1245,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			effectState.followTargetName = followTargetName;
 			resolveHitEffectAnchor(sourceEntity, effectState.anchorPoint, effectState.renderZ);
 
-			Entity *effectEntity = runtimeEntities->spawnSceneAnimationEntity(
+			Entity *effectEntity = entityManager->spawnSceneAnimationEntity(
 				effectState.entityName, kNativeHitEffectResourcePath, effectState.anchorPoint,
 				effectState.renderZ, kNativeHitEffectAnimationRate, true, true, false,
 				false, false, 0);
@@ -1268,18 +1268,18 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			damagePopupStates.push_back(popupState);
 		};
 		auto syncCombatHitEffects = [&]() {
-			if (!runtimeEntities)
+			if (!entityManager)
 				return false;
 
 			bool changed = false;
 			for (RoomHitEffectState &effectState : hitEffectStates) {
-				Entity *effectEntity = runtimeEntities->findSceneEntityByName(effectState.entityName);
+				Entity *effectEntity = entityManager->findSceneEntityByName(effectState.entityName);
 				if (!effectEntity)
 					continue;
 
 				if (!effectState.followTargetName.empty()) {
 					Entity *targetEntity =
-						runtimeEntities->findSceneEntityByName(effectState.followTargetName);
+						entityManager->findSceneEntityByName(effectState.followTargetName);
 					if (targetEntity && targetEntity->isVisible()) {
 						resolveHitEffectAnchor(*targetEntity, effectState.anchorPoint, effectState.renderZ);
 					}
@@ -1290,7 +1290,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 						fabsf(effectEntity->getZ() - effectState.renderZ) > 0.0001f) {
 					effectEntity->setPosition(
 						effectState.anchorPoint.x, effectState.anchorPoint.y, effectState.renderZ);
-					runtimeEntities->reinsertSceneEntity(effectEntity);
+					entityManager->reinsertSceneEntity(effectEntity);
 					changed = true;
 				}
 			}
@@ -1311,9 +1311,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			const byte black = findNearestPaletteColor(displayPalette, 0x00, 0x00, 0x00);
 			const uint32 now = Player::getRuntimeClockTicks();
 			for (RoomCombatDamagePopupState &popupState : damagePopupStates) {
-				if (runtimeEntities && !popupState.followTargetName.empty()) {
+				if (entityManager && !popupState.followTargetName.empty()) {
 					Entity *targetEntity =
-						runtimeEntities->findSceneEntityByName(popupState.followTargetName);
+						entityManager->findSceneEntityByName(popupState.followTargetName);
 					if (targetEntity && targetEntity->isVisible())
 						resolveCombatDamagePopupAnchor(*targetEntity, popupState.anchorPoint);
 				}
@@ -1337,8 +1337,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			bool removedAny = false;
 			for (uint i = 0; i < hitEffectStates.size();) {
 				const Common::String effectName = hitEffectStates[i].entityName;
-				Entity *effectEntity = runtimeEntities
-					? runtimeEntities->findSceneEntityByName(effectName)
+				Entity *effectEntity = entityManager
+					? entityManager->findSceneEntityByName(effectName)
 					: nullptr;
 				if (!effectEntity) {
 					hitEffectStates.remove_at(i);
@@ -1377,14 +1377,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			return removedAny;
 		};
 		auto applyCurrentRoomRuntimeMutationsInPlace = [&]() {
-			Script *startupScript = _engine.getScript();
+			Script *script = _engine.getScript();
 			ResourceManager *resources = _engine.getResources();
-			if (!startupScript || !resources)
+			if (!script || !resources)
 				return false;
 
 			const Common::Array<AudioCommand> entryAudioCommands = scene.state.audioCommands;
 			RoomSetupState updatedState;
-			if (!startupScript->materializeRoomState(
+			if (!script->materializeRoomState(
 					scene.state.entranceName, scene.state.roomName, updatedState, *resources)) {
 				return false;
 			}
@@ -1466,8 +1466,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					continue;
 
 				removeSceneEntityByName(timer.timerName);
-				if (runtimeEntities) {
-					(void)runtimeEntities->spawnSceneTimerEntity(
+				if (entityManager) {
+					(void)entityManager->spawnSceneTimerEntity(
 						timer.timerName, timer.initialValue, timer.currentValue,
 						timer.enabled, timer.looping, timer.global);
 				}
@@ -1480,8 +1480,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				if (becameVisible)
 					anim.runtimeState = 0;
 
-				Entity *entity = runtimeEntities
-					? runtimeEntities->findSceneEntityByName(anim.animName)
+				Entity *entity = entityManager
+					? entityManager->findSceneEntityByName(anim.animName)
 					: nullptr;
 				if (!anim.visible) {
 					removeSceneEntityByName(anim.animName);
@@ -1499,8 +1499,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					previous->backward != anim.backward;
 				if (needsRespawn) {
 					removeSceneEntityByName(anim.animName);
-					if (runtimeEntities) {
-						entity = runtimeEntities->spawnSceneAnimationEntity(
+					if (entityManager) {
+						entity = entityManager->spawnSceneAnimationEntity(
 							anim.animName, anim.resourcePath, Common::Point(anim.x, anim.y),
 							(float)anim.z, anim.frameDelay, anim.active, anim.visible, anim.looping,
 							anim.backward, anim.pingPong, anim.runtimeState);
@@ -1517,7 +1517,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					entity->setVisible(anim.visible);
 					entity->setAnimationEnabled(anim.active);
 					if (zChanged)
-						runtimeEntities->reinsertSceneEntity(entity);
+						entityManager->reinsertSceneEntity(entity);
 				}
 			}
 
@@ -1540,8 +1540,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				if (!shouldSpawnRoomNpcEntity(npc))
 					continue;
 
-				Entity *entity = runtimeEntities
-					? runtimeEntities->findSceneEntityByName(npc.npcName)
+				Entity *entity = entityManager
+					? entityManager->findSceneEntityByName(npc.npcName)
 					: nullptr;
 				const NpcRecord *previousNpc =
 					findRoomNpcByNameConst(scene.state.roomNpcs, npc.npcName);
@@ -1569,8 +1569,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				if (!shouldSpawnRoomMonsterEntity(monster))
 					continue;
 
-				Entity *entity = runtimeEntities
-					? runtimeEntities->findSceneEntityByName(monster.monsterName)
+				Entity *entity = entityManager
+					? entityManager->findSceneEntityByName(monster.monsterName)
 					: nullptr;
 				const MonsterRecord *previousMonster =
 					findRoomMonsterByNameConst(scene.state.roomMonsters, monster.monsterName);
@@ -1621,17 +1621,17 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				return false;
 			hitEffectStates.clear();
 			nextCombatEffectId = 0;
-			if (!startupFlow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
+			if (!flow.populateRoomSceneEntities(scene.state, scene.sceneObjects, scene.sceneAnimations))
 				return false;
-			if (runtimeEntities) {
+			if (entityManager) {
 				for (const MonsterRecord &monster : scene.state.roomMonsters) {
-					Entity *entity = runtimeEntities->findSceneEntityByName(monster.monsterName);
+					Entity *entity = entityManager->findSceneEntityByName(monster.monsterName);
 					if (entity)
 						Monster::applyAnimation(*entity, monster);
 				}
 			}
 
-			playerState.entity = runtimeEntities ? runtimeEntities->findSceneEntityByName("PLAYER") : nullptr;
+			playerState.entity = entityManager ? entityManager->findSceneEntityByName("PLAYER") : nullptr;
 			if (playerState.entity) {
 				if (!preservePlayerPlacement || playerState.facing < 0) {
 					playerState.centerX = scene.state.playerSpawnX;
@@ -1671,7 +1671,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			monsterCombatStates.resize(scene.state.roomMonsters.size());
 			pendingRegionName.clear();
 			resetIdleState();
-			startupFlow.resetCursorAnimationSequence();
+			flow.resetCursorAnimationSequence();
 			captureCurrentSaveState();
 			return _inventory.refresh();
 		};
@@ -1697,9 +1697,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				interaction.cutscenePath.empty() &&
 				!interaction.modalText.value.empty();
 		};
-		auto restoreDeniedPickup = [&](Script &startupScript,
-				const ObjectRecord &savedRuntimeObject, bool clearCarryState) -> Common::Error {
-			if (!startupScript.syncRuntimeObjectRecord(savedRuntimeObject))
+		auto restoreDeniedPickup = [&](Script &script,
+				const ObjectRecord &savedObject, bool clearCarryState) -> Common::Error {
+			if (!script.syncRuntimeObjectRecord(savedObject))
 				return Common::kReadingFailed;
 			if (clearCarryState)
 				clearCarriedRoomItem();
@@ -1762,7 +1762,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				Common::Event event;
 				while (g_system->getEventManager()->pollEvent(event)) {
 					Common::Error result = Common::kNoError;
-					if (startupFlow.handleSystemEvent(event, result))
+					if (flow.handleSystemEvent(event, result))
 						return result;
 					if (event.type == Common::EVENT_MOUSEMOVE)
 						_mousePos = event.mouse;
@@ -1781,7 +1781,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 					while (g_system->getEventManager()->pollEvent(event)) {
 						Common::Error result = Common::kNoError;
-						if (startupFlow.handleSystemEvent(event, result))
+						if (flow.handleSystemEvent(event, result))
 							return result;
 
 						switch (event.type) {
@@ -1801,8 +1801,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 						}
 					}
 
-					EntityManager *runtimeEntities = _engine.getRuntimeEntities();
-					if (runtimeEntities && runtimeEntities->syncCursorEntityPosition(_mousePos))
+					EntityManager *entityManager = _engine.getRuntimeEntities();
+					if (entityManager && entityManager->syncCursorEntityPosition(_mousePos))
 						needsRedraw = true;
 
 					limiter.delayBeforeSwap();
@@ -1815,8 +1815,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				return Common::kReadingFailed;
 			}
 			if (discNumber == 3 && previousDisc > 0 && previousDisc != resources->getCurrentDisc()) {
-				Script *startupScript = _engine.getScript();
-				if (!startupScript || !startupScript->reloadTownWorld(*resources)) {
+				Script *script = _engine.getScript();
+				if (!script || !script->reloadTownWorld(*resources)) {
 					warning("Harvester: unable to reload town script after disc prompt %d -> %d",
 						previousDisc, discNumber);
 					return Common::kReadingFailed;
@@ -1838,7 +1838,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				}
 				if (!exitInteraction.musicPath.empty())
 					(void)_engine.playMusic(exitInteraction.musicPath);
-				startupFlow.executeStartupAudioCommands(exitInteraction.audioCommands);
+				flow.executeStartupAudioCommands(exitInteraction.audioCommands);
 
 				if (!exitInteraction.cutscenePath.empty()) {
 					FstPlayer fstPlayer(_engine);
@@ -1949,7 +1949,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				Common::Event event;
 				Common::Error result = Common::kNoError;
 				while (g_system->getEventManager()->pollEvent(event)) {
-					if (startupFlow.handleSystemEvent(event, result))
+					if (flow.handleSystemEvent(event, result))
 						return result;
 
 					if (!initialInputReleased) {
@@ -1994,7 +1994,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		};
 		struct InteractionProcessor {
 			HarvesterEngine &engine;
-			Flow &startupFlow;
+			Flow &flow;
 			RoomSceneResources &scene;
 			RoomPlayerState &playerState;
 			Common::String &pendingRegionName;
@@ -2028,9 +2028,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					}
 
 					if (!interaction.deathFlicPath.empty())
-						startupFlow.requestGameOverReturn();
+						flow.requestGameOverReturn();
 					else
-						startupFlow.requestMainMenuReturn();
+						flow.requestMainMenuReturn();
 					return Common::kNoError;
 				}
 
@@ -2049,7 +2049,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					(void)engine.playMusic(interaction.musicPath);
 					restoreMusicPath = engine.getMusicPath();
 				}
-				startupFlow.executeStartupAudioCommands(interaction.audioCommands);
+				flow.executeStartupAudioCommands(interaction.audioCommands);
 				auto queueImplicitRoomRestart = [&]() -> Common::Error {
 					if (!interaction.roomRestartTargetName.empty()) {
 						pendingRoomChange = interaction.roomRestartTargetName;
@@ -2091,7 +2091,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				if (!interaction.nextRoomName.empty() &&
 						roomTransition == kStartupRoomTransitionChangeRoom) {
 					Common::String resolvedTransitionTarget = interaction.nextRoomName;
-					Common::Error resolveError = startupFlow.resolveRoomTransitionTarget(
+					Common::Error resolveError = flow.resolveRoomTransitionTarget(
 						interaction.nextRoomName, resolvedTransitionTarget);
 					if (resolveError.getCode() != Common::kNoError)
 						return resolveError;
@@ -2110,10 +2110,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					if (exitError.getCode() != Common::kNoError)
 						return exitError;
 
-					Common::Error roomError = startupFlow.runRoomLoop(interaction.nextRoomName);
-					if (startupFlow.hasPendingMainMenuReturn())
+					Common::Error roomError = flow.runRoomLoop(interaction.nextRoomName);
+					if (flow.hasPendingMainMenuReturn())
 						return Common::kNoError;
-					if (startupFlow.hasPendingDebugRoomChange()) {
+					if (flow.hasPendingDebugRoomChange()) {
 						didTransition = true;
 						return Common::kNoError;
 					}
@@ -2125,7 +2125,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					if (!refreshCurrentSceneFn(true))
 						return Common::kReadingFailed;
 
-					startupFlow.executeStartupAudioCommands(scene.state.audioCommands);
+					flow.executeStartupAudioCommands(scene.state.audioCommands);
 					if (!restoreMusicPath.empty())
 						(void)engine.playMusic(restoreMusicPath);
 					else
@@ -2197,7 +2197,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					if (!captureDialogueBackdropFn(dialogueBackdrop))
 						return Common::kReadingFailed;
 
-					Common::Error dialogueError = startupFlow.runRoomNpcDialogue(
+					Common::Error dialogueError = flow.runRoomNpcDialogue(
 						dialogueBackdrop, scene.palette, scene.targetPaletteBrightness, *dialogueNpc,
 						usedItemName);
 					if (dialogueError.getCode() != Common::kNoError)
@@ -2209,13 +2209,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 				InteractionResult dialogueInteraction;
 				bool abortRemainingCommandChain = false;
-				if (startupFlow.takeQueuedDialogueInteraction(dialogueInteraction)) {
+				if (flow.takeQueuedDialogueInteraction(dialogueInteraction)) {
 					abortRemainingCommandChain = dialogueInteraction.abortRemainingCommandChain;
 					Common::Error interactionError =
 						handleInteractionResult(dialogueInteraction, didTransition, usedItemName);
 					if (interactionError.getCode() != Common::kNoError)
 						return interactionError;
-					if (startupFlow.hasPendingMainMenuReturn())
+					if (flow.hasPendingMainMenuReturn())
 						return Common::kNoError;
 				}
 				if (!didTransition && !abortRemainingCommandChain && !continuationTag.empty()) {
@@ -2225,18 +2225,18 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 							handleInteractionResult(continuationInteraction, didTransition, usedItemName);
 						if (interactionError.getCode() != Common::kNoError)
 							return interactionError;
-						if (startupFlow.hasPendingMainMenuReturn())
+						if (flow.hasPendingMainMenuReturn())
 							return Common::kNoError;
 					}
 				}
 
-				startupFlow.resetCursorAnimationSequence();
+				flow.resetCursorAnimationSequence();
 				resetIdleStateFn();
 				return Common::kNoError;
 			}
 		};
 		InteractionProcessor interactionProcessor = {
-			_engine, startupFlow, scene, playerState, pendingRegionName, pendingRoomChange,
+			_engine, flow, scene, playerState, pendingRegionName, pendingRoomChange,
 			refreshCurrentScene, applyCurrentRoomRuntimeMutationsInPlace, captureDialogueBackdrop,
 			showCdChangePrompt,
 			runRoomExitCommands, applyLightingCommand, applyPlayerGotoXZ, runModalShowText,
@@ -2277,7 +2277,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			hasPendingRoomEntryInteraction = hasRoomEntryInteraction(roomEntryInteraction);
 		}
 
-		transitionError = startupFlow.waitForRoomSetupTransitionHold();
+		transitionError = flow.waitForRoomSetupTransitionHold();
 		if (transitionError.getCode() != Common::kNoError)
 			return transitionError;
 
@@ -2287,11 +2287,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		screen->update();
 
 		logScenePaletteSummary("room setup fade target", scene, scene.targetPaletteBrightness);
-		transitionError = startupFlow.fadeInRoomScene(scene.palette, scene.targetPaletteBrightness);
+		transitionError = flow.fadeInRoomScene(scene.palette, scene.targetPaletteBrightness);
 		if (transitionError.getCode() != Common::kNoError)
 			return transitionError;
-		if (runtimeEntities)
-			runtimeEntities->resumeTimerCountdowns();
+		if (entityManager)
+			entityManager->resumeTimerCountdowns();
 
 		if (shouldRunRoomEntryCommands) {
 			if (hasPendingRoomEntryInteraction) {
@@ -2300,11 +2300,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					roomEntryInteraction, didEntryTransition, Common::String());
 				if (entryInteractionError.getCode() != Common::kNoError)
 					return entryInteractionError;
-				if (startupFlow.hasPendingMainMenuReturn() || didEntryTransition)
+				if (flow.hasPendingMainMenuReturn() || didEntryTransition)
 					return Common::kNoError;
 			}
 
-			startupFlow.resetCursorAnimationSequence();
+			flow.resetCursorAnimationSequence();
 			resetIdleState();
 		}
 		auto findRoomNpcRecordByName = [&](const Common::String &npcName) -> NpcRecord * {
@@ -2340,7 +2340,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			return -1;
 		};
 		auto findSceneRuntimeEntity = [&](const Common::String &entityName) -> Entity * {
-			return runtimeEntities ? runtimeEntities->findSceneEntityByName(entityName) : nullptr;
+			return entityManager ? entityManager->findSceneEntityByName(entityName) : nullptr;
 		};
 		auto findMonsterTargetAtPoint = [&](const Common::Point &point) -> MonsterRecord * {
 			MonsterRecord *bestMonster = nullptr;
@@ -2422,7 +2422,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		}
 	};
 	auto requestPlayerGameOver = [&](const char *reason, const Common::String &sourceName) {
-		if (startupFlow.hasPendingMainMenuReturn())
+		if (flow.hasPendingMainMenuReturn())
 			return;
 
 		debugC(1, kDebugCombat,
@@ -2431,11 +2431,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			_engine.getScript()
 				? _engine.getScript()->getPlayerCurrentHitPoints()
 				: 0);
-		startupFlow.requestGameOverReturn();
+		flow.requestGameOverReturn();
 	};
 	auto startPlayerDefeatSequence = [&](const char *reason, const Common::String &sourceName,
 			int damageType) {
-		if (startupFlow.hasPendingMainMenuReturn() || playerState.deathActive)
+		if (flow.hasPendingMainMenuReturn() || playerState.deathActive)
 			return;
 
 		if (idleState.entity) {
@@ -2473,7 +2473,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			interactionProcessor.handleInteractionResult(interaction, didTransition, Common::String());
 		if (interactionError.getCode() != Common::kNoError)
 			return interactionError;
-		if (!startupFlow.hasPendingMainMenuReturn() &&
+		if (!flow.hasPendingMainMenuReturn() &&
 				_engine.getScript() &&
 				_engine.getScript()->getPlayerCurrentHitPoints() <= 0) {
 			stopPlayerRegionInteraction();
@@ -2510,15 +2510,15 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 	};
 	auto finalizeNpcDeathTransition = [&](NpcRecord &npc,
 			RoomNpcCombatState &combatState) -> Common::Error {
-		Script *runtimeScript = _engine.getScript();
+		Script *script = _engine.getScript();
 		removeSceneEntityByName(npc.npcName);
 		npc.active = false;
 		npc.visible = false;
 		npc.deathOrMonsterfyFlag = true;
 		if (combatState.deathDamageType != 0)
 			npc.deathDamageType = combatState.deathDamageType;
-		const bool runtimeChanged = runtimeScript
-			? runtimeScript->finalizeRuntimeNpcDeathOrMonsterfy(npc.npcName, npc.deathDamageType)
+		const bool runtimeChanged = script
+			? script->finalizeRuntimeNpcDeathOrMonsterfy(npc.npcName, npc.deathDamageType)
 			: false;
 		debugC(1, kDebugCombat,
 			"Harvester: combat npc death complete target='%s' damage_type=%d last_frame=%d monsterfy='%s' on_death='%s'",
@@ -2528,9 +2528,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 		InteractionResult interaction;
 		interaction.mutatedRuntimeState = runtimeChanged;
-		if (runtimeScript && !npc.onDeathActionTag.empty()) {
+		if (script && !npc.onDeathActionTag.empty()) {
 			InteractionResult deathInteraction;
-			if (runtimeScript->executeActionTag(npc.onDeathActionTag, deathInteraction)) {
+			if (script->executeActionTag(npc.onDeathActionTag, deathInteraction)) {
 				interaction = deathInteraction;
 				interaction.mutatedRuntimeState = true;
 			}
@@ -2611,8 +2611,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			return Common::kNoError;
 		}
 
-		Script *startupScript = _engine.getScript();
-		if (!startupScript) {
+		Script *script = _engine.getScript();
+		if (!script) {
 			debugC(1, kDebugCombat,
 				"Harvester: debug combat kill skipped target='%s' reason='no startup script'",
 				monster->monsterName.c_str());
@@ -2642,7 +2642,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				: nullptr;
 		if (combatState)
 			clearRoomMonsterCombatState(*combatState);
-		(void)startupScript->syncRuntimeMonsterRecord(*monster);
+		(void)script->syncRuntimeMonsterRecord(*monster);
 
 		RoomDeathAnimationRange deathRange;
 		if (combatState && resolveMonsterDeathAnimationRange(*monsterEntity, monster->facing,
@@ -2668,7 +2668,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		interaction.mutatedRuntimeState = true;
 		if (!monster->onDeathActionTag.empty()) {
 			InteractionResult deathInteraction;
-			if (startupScript->executeActionTag(monster->onDeathActionTag, deathInteraction)) {
+			if (script->executeActionTag(monster->onDeathActionTag, deathInteraction)) {
 				interaction = deathInteraction;
 				interaction.mutatedRuntimeState = true;
 			}
@@ -2682,7 +2682,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 			const RoomHoverState hoverState = resolveRoomHoverState(
 				_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions,
-				_mousePos, &startupFlow._dialogue);
+				_mousePos, &flow._dialogue);
 			if (hoverState.npc) {
 				playerState.attackTargetName = hoverState.npc->npcName;
 				playerState.attackTargetClassId = kRuntimeEntityClassNpc;
@@ -2888,8 +2888,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			Player::describeCombatLoadout(playerState.combatLoadout), damageAmount,
 			Player::describeCombatDamageType(damageType),
 			describeCombatTargetClass(playerState.attackTargetClassId), playerState.attackTargetName.c_str());
-		Script *startupScript = _engine.getScript();
-		if (!startupScript)
+		Script *script = _engine.getScript();
+		if (!script)
 			return Common::kNoError;
 
 		if (playerState.attackTargetClassId < 0 && !Player::isProjectileCombatLoadout(playerState.combatLoadout)) {
@@ -2912,9 +2912,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			}
 
 			const bool runtimeChanged =
-				startupScript->queueRuntimeNpcDeathOrMonsterfy(npc->npcName, damageType);
+				script->queueRuntimeNpcDeathOrMonsterfy(npc->npcName, damageType);
 			const bool jimmyFlagChanged = npc->npcName.equalsIgnoreCase("JIMMY")
-				? startupScript->setRuntimeFlagValue("JIMMY_ATTACKED", true)
+				? script->setRuntimeFlagValue("JIMMY_ATTACKED", true)
 				: false;
 			const int npcIndex = findRoomNpcIndexByName(npc->npcName);
 			RoomNpcCombatState *combatState =
@@ -2988,13 +2988,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				startMonsterHitReaction(*monster, *monsterEntity, combatState, playerState.attackFirstFrame);
 			}
 			if (monster->currentHitPoints > 0) {
-				(void)startupScript->syncRuntimeMonsterRecord(*monster);
+				(void)script->syncRuntimeMonsterRecord(*monster);
 				needsRedraw = true;
 				return Common::kNoError;
 			}
 			if (combatState)
 				clearRoomMonsterCombatState(*combatState);
-		(void)startupScript->syncRuntimeMonsterRecord(*monster);
+		(void)script->syncRuntimeMonsterRecord(*monster);
 
 		RoomDeathAnimationRange deathRange;
 		if (combatState && resolveMonsterDeathAnimationRange(*monsterEntity, monster->facing,
@@ -3028,7 +3028,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		interaction.mutatedRuntimeState = true;
 		if (!monster->onDeathActionTag.empty()) {
 			InteractionResult deathInteraction;
-			if (startupScript->executeActionTag(monster->onDeathActionTag, deathInteraction)) {
+			if (script->executeActionTag(monster->onDeathActionTag, deathInteraction)) {
 				interaction = deathInteraction;
 				interaction.mutatedRuntimeState = true;
 			}
@@ -3037,16 +3037,16 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		return handleCombatInteraction(interaction);
 	};
 	auto updateRoomMonsterCombat = [&]() -> Common::Error {
-		if (!runtimeEntities)
+		if (!entityManager)
 			return Common::kNoError;
 
-		Script *startupScript = _engine.getScript();
+		Script *script = _engine.getScript();
 		const uint32 now = Player::getRuntimeClockTicks();
 		const uint32 moveInterval = MAX<uint32>(1, 100U / (uint32)kRoomMonsterAnimationRate);
 		const int horizontalStepBase = 8;
 		const int depthStep = MAX<int>(1, roundRoomCombatFloat(
 			scene.state.roomZVelocityStep > 0.0f ? scene.state.roomZVelocityStep : 1.0f));
-		bool playerAlive = startupScript && startupScript->getPlayerCurrentHitPoints() > 0;
+		bool playerAlive = script && script->getPlayerCurrentHitPoints() > 0;
 
 		if (npcCombatStates.size() != scene.state.roomNpcs.size())
 			npcCombatStates.resize(scene.state.roomNpcs.size());
@@ -3092,7 +3092,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					entity->setAnimationFrameRange(monster.runtimeState, monster.runtimeState, false);
 					entity->setCurrentFrame(monster.runtimeState);
 					entity->setAnimationEnabled(false);
-					(void)startupScript->syncRuntimeMonsterRecord(monster);
+					(void)script->syncRuntimeMonsterRecord(monster);
 					debugC(1, kDebugCombat,
 						"Harvester: combat monster death complete target='%s' damage_type=%d corpse_frame=%d on_death='%s'",
 						monster.monsterName.c_str(), combatState.deathDamageType,
@@ -3102,9 +3102,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 					InteractionResult interaction;
 					interaction.mutatedRuntimeState = true;
-					if (startupScript && !monster.onDeathActionTag.empty()) {
+					if (script && !monster.onDeathActionTag.empty()) {
 						InteractionResult deathInteraction;
-						if (startupScript->executeActionTag(monster.onDeathActionTag, deathInteraction)) {
+						if (script->executeActionTag(monster.onDeathActionTag, deathInteraction)) {
 							interaction = deathInteraction;
 							interaction.mutatedRuntimeState = true;
 						}
@@ -3147,7 +3147,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 						if (monster.posX != previousX) {
 							(void)applyRoomActorPlacement(
 								scene.state, *entity, monster.posX, monster.posY, (float)monster.posZ);
-							(void)startupScript->syncRuntimeMonsterRecord(monster);
+							(void)script->syncRuntimeMonsterRecord(monster);
 							debugC(1, kDebugCombat,
 								"Harvester: combat monster hit knockback target='%s' from_x=%d to_x=%d remaining=%d",
 								monster.monsterName.c_str(), previousX, monster.posX,
@@ -3183,18 +3183,18 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 						playerState.entity &&
 						!combatState.attackTargetName.empty() &&
 						playerState.entity->getName().equalsIgnoreCase(combatState.attackTargetName);
-					if (!startupScript) {
+					if (!script) {
 						debugC(1, kDebugCombat,
 							"Harvester: combat monster attack contact monster='%s' frame=%d skipped reason='no startup script'",
 							monster.monsterName.c_str(), currentFrame);
 					} else if (!playerAlive || !hasTrackedPlayerTarget) {
 						debugC(1, kDebugCombat,
 							"Harvester: combat monster attack miss monster='%s' frame=%d player_hp=%d reason='no live target'",
-							monster.monsterName.c_str(), currentFrame, startupScript->getPlayerCurrentHitPoints());
+							monster.monsterName.c_str(), currentFrame, script->getPlayerCurrentHitPoints());
 					} else {
-						const int playerHitPointsBefore = startupScript->getPlayerCurrentHitPoints();
-						const bool changed = startupScript->adjustPlayerCurrentHitPoints(-monster.damageAmount);
-						const int playerHitPointsAfter = startupScript->getPlayerCurrentHitPoints();
+						const int playerHitPointsBefore = script->getPlayerCurrentHitPoints();
+						const bool changed = script->adjustPlayerCurrentHitPoints(-monster.damageAmount);
+						const int playerHitPointsAfter = script->getPlayerCurrentHitPoints();
 						const int damageLanded = playerHitPointsBefore - playerHitPointsAfter;
 						debugC(1, kDebugCombat,
 							"Harvester: combat monster attack hit monster='%s' frame=%d damage=%d damage_type='%s' player_hp=%d->%d changed=%d",
@@ -3293,8 +3293,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				else if (movedY != 0)
 					monster.facing = movedY < 0 ? 3 : 0;
 				(void)applyRoomActorPlacement(scene.state, *entity, monster.posX, monster.posY, (float)monster.posZ);
-				if (startupScript)
-					(void)startupScript->syncRuntimeMonsterRecord(monster);
+				if (script)
+					(void)script->syncRuntimeMonsterRecord(monster);
 				debugC(1, kDebugCombat,
 					"Harvester: combat monster chase move monster='%s' live_center_dx=%d z_delta=%.2f engage=%d waypoint_tol=%d from=(%d,%d,z=%d) to=(%d,%d,z=%d) facing=%d",
 					monster.monsterName.c_str(), liveCenterDx, (double)zDelta, engageDistance, liveWaypointTolerance,
@@ -3357,13 +3357,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		return Common::kNoError;
 	};
 	auto moveRoomItemDirectlyToInventory = [&](const ObjectRecord &object) -> Common::Error {
-		Script *startupScript = _engine.getScript();
-		if (!startupScript)
+		Script *script = _engine.getScript();
+		if (!script)
 			return Common::kReadingFailed;
 
-		const ObjectRecord savedRuntimeObject = object;
+		const ObjectRecord savedObject = object;
 
-		startupScript->addRuntimeObjectToInventory(object.objectName);
+		script->addRuntimeObjectToInventory(object.objectName);
 		const Common::String inventoryOwner(kInventoryOwnerName);
 		hideSceneObject(object.objectName, &inventoryOwner);
 		removeSceneEntityByName(object.objectName);
@@ -3371,20 +3371,20 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		bool didTransition = false;
 		if (shouldDispatchPickupActionOnDirectInventoryTransfer(object)) {
 			InteractionResult pickupInteraction;
-			if (startupScript->executeActionTag(object.actionTag, pickupInteraction)) {
+			if (script->executeActionTag(object.actionTag, pickupInteraction)) {
 				Common::Error interactionError =
 					interactionProcessor.handleInteractionResult(
 						pickupInteraction, didTransition, Common::String());
 				if (interactionError.getCode() != Common::kNoError)
 					return interactionError;
-				if (startupFlow.hasPendingMainMenuReturn())
+				if (flow.hasPendingMainMenuReturn())
 					return Common::kNoError;
 				if (shouldCancelDeniedPickup(pickupInteraction, didTransition)) {
 					debugC(1, kDebugRoom,
 						"Harvester: cancelling denied direct pickup object='%s' action='%s' text='%s'",
-						savedRuntimeObject.objectName.c_str(), savedRuntimeObject.actionTag.c_str(),
+						savedObject.objectName.c_str(), savedObject.actionTag.c_str(),
 						pickupInteraction.modalText.value.c_str());
-					return restoreDeniedPickup(*startupScript, savedRuntimeObject, false);
+					return restoreDeniedPickup(*script, savedObject, false);
 				}
 			}
 		}
@@ -3395,50 +3395,50 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		return Common::kNoError;
 	};
 	auto beginRoomItemCarry = [&](const ObjectRecord &object) -> Common::Error {
-		Script *startupScript = _engine.getScript();
+		Script *script = _engine.getScript();
 		ResourceManager *resources = _engine.getResources();
-		if (!startupScript || !resources)
+		if (!script || !resources)
 			return Common::kReadingFailed;
 
-		const ObjectRecord savedRuntimeObject = object;
+		const ObjectRecord savedObject = object;
 
 		clearCarriedRoomItem();
 		carriedRoomItemName = object.objectName;
-		carriedRoomItemLabel = startupScript->resolveObjectLabel(object);
+		carriedRoomItemLabel = script->resolveObjectLabel(object);
 		const Common::String spritePath = resolveCarriedObjectSpritePath(object);
 		if (!spritePath.empty())
 			loadBitmapResource(*resources, spritePath, carriedRoomItemBitmap);
 
-		startupScript->setRuntimeObjectVisible(object.currentOwnerOrRoom, object.objectName, false);
+		script->setRuntimeObjectVisible(object.currentOwnerOrRoom, object.objectName, false);
 		hideSceneObject(object.objectName, nullptr);
 		removeSceneEntityByName(object.objectName);
 
 		bool didTransition = false;
 		if (shouldDispatchPickupActionOnCarryStart(object)) {
 			InteractionResult pickupInteraction;
-			if (startupScript->executeActionTag(object.actionTag, pickupInteraction)) {
+			if (script->executeActionTag(object.actionTag, pickupInteraction)) {
 				Common::Error interactionError =
 					interactionProcessor.handleInteractionResult(
 						pickupInteraction, didTransition, Common::String());
 				if (interactionError.getCode() != Common::kNoError)
 					return interactionError;
-				if (startupFlow.hasPendingMainMenuReturn())
+				if (flow.hasPendingMainMenuReturn())
 					return Common::kNoError;
 				if (shouldCancelDeniedPickup(pickupInteraction, didTransition)) {
 					debugC(1, kDebugRoom,
 						"Harvester: cancelling denied carried pickup object='%s' action='%s' text='%s'",
-						savedRuntimeObject.objectName.c_str(), savedRuntimeObject.actionTag.c_str(),
+						savedObject.objectName.c_str(), savedObject.actionTag.c_str(),
 						pickupInteraction.modalText.value.c_str());
-					return restoreDeniedPickup(*startupScript, savedRuntimeObject, true);
+					return restoreDeniedPickup(*script, savedObject, true);
 				}
 				if (!didTransition &&
 						!pickupInteraction.dialogueNpcName.empty() &&
-						!startupScript->isObjectInInventory(savedRuntimeObject.objectName)) {
+						!script->isObjectInInventory(savedObject.objectName)) {
 					debugC(1, kDebugRoom,
 						"Harvester: restoring carried pickup after dialogue object='%s' action='%s' npc='%s'",
-						savedRuntimeObject.objectName.c_str(), savedRuntimeObject.actionTag.c_str(),
+						savedObject.objectName.c_str(), savedObject.actionTag.c_str(),
 						pickupInteraction.dialogueNpcName.c_str());
-					return restoreDeniedPickup(*startupScript, savedRuntimeObject, true);
+					return restoreDeniedPickup(*script, savedObject, true);
 				}
 			}
 		}
@@ -3475,7 +3475,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		if (interactionError.getCode() != Common::kNoError)
 			return interactionError;
 		_inventory.clearSelection();
-		if (startupFlow.hasPendingMainMenuReturn())
+		if (flow.hasPendingMainMenuReturn())
 			return Common::kNoError;
 		if (!_inventory.refresh())
 			return Common::kReadingFailed;
@@ -3487,9 +3487,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		if (!_inventory.hasSelection())
 			return nullptr;
 
-		EntityManager *runtimeEntities = _engine.getRuntimeEntities();
-		Script *startupScript = _engine.getScript();
-		if (!runtimeEntities || !startupScript)
+		EntityManager *entityManager = _engine.getRuntimeEntities();
+		Script *script = _engine.getScript();
+		if (!entityManager || !script)
 			return nullptr;
 
 		const Common::String selectedItemName = _inventory.getSelectedItemName();
@@ -3497,11 +3497,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		int bestDrawIndex = -1;
 		for (ObjectRecord &candidate : scene.sceneObjects) {
 			if (candidate.objectName.empty() ||
-					!startupScript->hasUseItemInteraction(selectedItemName, candidate)) {
+					!script->hasUseItemInteraction(selectedItemName, candidate)) {
 				continue;
 			}
 
-			const Entity *entity = runtimeEntities->findSceneEntityByName(candidate.objectName);
+			const Entity *entity = entityManager->findSceneEntityByName(candidate.objectName);
 			if (!entity || !entity->hitTest(point))
 				continue;
 			if (entity->getClassId() == kRuntimeEntityClassBackground ||
@@ -3510,7 +3510,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				continue;
 			}
 
-			const int drawIndex = runtimeEntities->findSceneEntityDrawIndexByName(entity->getName());
+			const int drawIndex = entityManager->findSceneEntityDrawIndexByName(entity->getName());
 			if (drawIndex < 0)
 				continue;
 			if (!bestTarget || drawIndex > bestDrawIndex) {
@@ -3544,7 +3544,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				interaction, didTransition, Common::String());
 		if (interactionError.getCode() != Common::kNoError)
 			return interactionError;
-		if (startupFlow.hasPendingMainMenuReturn())
+		if (flow.hasPendingMainMenuReturn())
 			return Common::kNoError;
 
 		if (!_inventory.refresh())
@@ -3599,10 +3599,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		logStartupRoomProbe(_engine, scene, currentRoomTarget, _mousePos);
 
 	while (!_engine.shouldQuit()) {
-		if (startupFlow.hasPendingMainMenuReturn())
+		if (flow.hasPendingMainMenuReturn())
 			return Common::kNoError;
-		if (startupFlow.takePendingNewGameRestart()) {
-			startupFlow.prepareForNewGame();
+		if (flow.takePendingNewGameRestart()) {
+			flow.prepareForNewGame();
 			pendingRoomChange = "START";
 			if (!stowCarriedRoomItemToInventory())
 				return Common::kReadingFailed;
@@ -3616,7 +3616,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				return Common::kReadingFailed;
 			break;
 		}
-		if (startupFlow.hasPendingDebugRoomChange()) {
+		if (flow.hasPendingDebugRoomChange()) {
 			if (!stowCarriedRoomItemToInventory())
 				return Common::kReadingFailed;
 			return Common::kNoError;
@@ -3627,7 +3627,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			break;
 		}
 		InteractionResult queuedDebugInteraction;
-		if (startupFlow.takeQueuedDebugInteraction(queuedDebugInteraction)) {
+		if (flow.takeQueuedDebugInteraction(queuedDebugInteraction)) {
 			// Run console-injected commands through the normal room interaction handler so
 			// CHANGE_ROOM, modal text, dialogue, and chained tags behave exactly like script-driven actions.
 			bool didDebugTransition = false;
@@ -3635,7 +3635,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				queuedDebugInteraction, didDebugTransition, Common::String());
 			if (debugInteractionError.getCode() != Common::kNoError)
 				return debugInteractionError;
-			if (startupFlow.hasPendingMainMenuReturn())
+			if (flow.hasPendingMainMenuReturn())
 				return Common::kNoError;
 			if (!pendingRoomChange.empty()) {
 				if (!stowCarriedRoomItemToInventory())
@@ -3664,7 +3664,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			RoomHoverState hoverState = suppressHover
 				? RoomHoverState()
 				: resolveRoomHoverState(_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
-					scene.sceneRegions, _mousePos, &startupFlow._dialogue);
+					scene.sceneRegions, _mousePos, &flow._dialogue);
 			if (!suppressHover && inventorySelectionActive && !hoverState.npc) {
 				if (ObjectRecord *selectedTarget = findSelectedInventoryRoomTarget(_mousePos))
 					hoverState.object = selectedTarget;
@@ -3713,7 +3713,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			} else {
 				promptText = hoverState.promptText;
 			}
-			if (Entity *cursor = runtimeEntities ? runtimeEntities->getCursorEntity() : nullptr) {
+			if (Entity *cursor = entityManager ? entityManager->getCursorEntity() : nullptr) {
 				cursor->setAnimationSequence(
 					(showingInspectText || idleState.active || idleState.exiting ||
 						isPlayerCombatLocked() ||
@@ -3744,8 +3744,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				drawRoomPrompt(*activeScreen, *promptFont, promptText, useNativePromptFont);
 			}
 
-			if (runtimeEntities)
-				runtimeEntities->drawCursor(*activeScreen);
+			if (entityManager)
+				entityManager->drawCursor(*activeScreen);
 			activeScreen->makeAllDirty();
 			activeScreen->update();
 			needsRedraw = false;
@@ -3754,7 +3754,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		Common::Event event;
 		while (g_system->getEventManager()->pollEvent(event)) {
 			Common::Error result = Common::kNoError;
-			if (startupFlow.handleSystemEvent(event, result))
+			if (flow.handleSystemEvent(event, result))
 				return result;
 
 			switch (event.type) {
@@ -3841,10 +3841,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 								debugC(1, kDebugInventory,
 									"Harvester: inventory right click action='%s' interaction result error=%d transitioned=%d pendingMainMenu=%d",
 									secondaryAction.actionTag.c_str(), interactionError.getCode(),
-									didTransition, startupFlow.hasPendingMainMenuReturn());
+									didTransition, flow.hasPendingMainMenuReturn());
 								if (interactionError.getCode() != Common::kNoError)
 									return interactionError;
-								if (startupFlow.hasPendingMainMenuReturn())
+								if (flow.hasPendingMainMenuReturn())
 									return Common::kNoError;
 							}
 
@@ -3879,9 +3879,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					needsRedraw = true;
 					break;
 				}
-				Script *startupScript = _engine.getScript();
-				if (!showingInspectText && !isPlayerCombatLocked() && startupScript && playerState.entity &&
-						startupScript->getPlayerCurrentHitPoints() > 0) {
+				Script *script = _engine.getScript();
+				if (!showingInspectText && !isPlayerCombatLocked() && script && playerState.entity &&
+						script->getPlayerCurrentHitPoints() > 0) {
 					playerState.hasMoveTarget = false;
 					playerState.turnActive = false;
 					playerState.turnTargetFacing = -1;
@@ -3890,7 +3890,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					playerState.turnEndFrame = -1;
 					playerState.turnPlayBackwards = false;
 					(void)Player::syncCombatLoadoutVisual(_engine, scene.state, playerState,
-						startupScript->getPlayerCombatLoadout());
+						script->getPlayerCombatLoadout());
 					capturePlayerAttackTarget();
 					if (Player::startAttackAnimation(scene.state, playerState, _mousePos)) {
 						debugC(1, kDebugCombat,
@@ -3990,7 +3990,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 
 				const RoomHoverState hoverState = resolveRoomHoverState(
 					_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs, scene.sceneRegions,
-					_mousePos, &startupFlow._dialogue);
+					_mousePos, &flow._dialogue);
 				ObjectRecord *selectedRoomTarget = nullptr;
 				if (_inventory.hasSelection() && !hoverState.npc)
 					selectedRoomTarget = findSelectedInventoryRoomTarget(_mousePos);
@@ -4030,7 +4030,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 							clickHoverState.npc->npcName, selectedItemName, Common::String(), didTransition);
 						if (dialogueError.getCode() != Common::kNoError)
 							return dialogueError;
-						if (startupFlow.hasPendingMainMenuReturn())
+						if (flow.hasPendingMainMenuReturn())
 							return Common::kNoError;
 						if (_inventory.clearSelection())
 							needsRedraw = true;
@@ -4075,7 +4075,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 						hoverState.npc->npcName, Common::String(), Common::String(), didTransition);
 					if (dialogueError.getCode() != Common::kNoError)
 						return dialogueError;
-					if (startupFlow.hasPendingMainMenuReturn())
+					if (flow.hasPendingMainMenuReturn())
 						return Common::kNoError;
 					needsRedraw = true;
 					break;
@@ -4088,7 +4088,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 						Common::Error interactionError = runRegionInteraction(*hoverState.region);
 						if (interactionError.getCode() != Common::kNoError)
 							return interactionError;
-						if (startupFlow.hasPendingMainMenuReturn())
+						if (flow.hasPendingMainMenuReturn())
 							return Common::kNoError;
 						needsRedraw = true;
 						break;
@@ -4186,7 +4186,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 						interaction, didTransition, Common::String());
 				if (interactionError.getCode() != Common::kNoError)
 					return interactionError;
-				if (startupFlow.hasPendingMainMenuReturn())
+				if (flow.hasPendingMainMenuReturn())
 					return Common::kNoError;
 				needsRedraw = true;
 				break;
@@ -4285,11 +4285,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 					drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
 					if (!captureScreenBackdrop(*activeScreen, roomMenuBackdrop))
 						return Common::kReadingFailed;
-					Common::Error menuError = startupFlow.runRoomMenuStub(
+					Common::Error menuError = flow.runRoomMenuStub(
 						roomMenuBackdrop, scene.palette, scene.targetPaletteBrightness);
 					if (menuError.getCode() != Common::kNoError)
 						return menuError;
-					startupFlow.resetCursorAnimationSequence();
+					flow.resetCursorAnimationSequence();
 					resetIdleState();
 					needsRedraw = true;
 					break;
@@ -4342,7 +4342,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 		Common::Error combatError = resolvePlayerAttackContact();
 		if (combatError.getCode() != Common::kNoError)
 			return combatError;
-		if (startupFlow.hasPendingMainMenuReturn())
+		if (flow.hasPendingMainMenuReturn())
 			return Common::kNoError;
 		if (!pendingRoomChange.empty()) {
 			if (!stowCarriedRoomItemToInventory())
@@ -4427,7 +4427,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			return combatError;
 		if (syncCombatHitEffects())
 			needsRedraw = true;
-		if (startupFlow.hasPendingMainMenuReturn())
+		if (flow.hasPendingMainMenuReturn())
 			return Common::kNoError;
 		if (!pendingRoomChange.empty()) {
 			if (!stowCarriedRoomItemToInventory())
@@ -4440,7 +4440,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				: tryActivatePendingRegion();
 		if (pendingRegionError.getCode() != Common::kNoError)
 			return pendingRegionError;
-		if (startupFlow.hasPendingMainMenuReturn())
+		if (flow.hasPendingMainMenuReturn())
 			return Common::kNoError;
 		if (!pendingRoomChange.empty()) {
 			if (!stowCarriedRoomItemToInventory())
@@ -4453,7 +4453,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 				: Common::kNoError;
 		if (pendingRegionError.getCode() != Common::kNoError)
 			return pendingRegionError;
-		if (startupFlow.hasPendingMainMenuReturn())
+		if (flow.hasPendingMainMenuReturn())
 			return Common::kNoError;
 		if (!pendingRoomChange.empty()) {
 			if (!stowCarriedRoomItemToInventory())
@@ -4461,21 +4461,21 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 			break;
 		}
 
-		if (startupFlow.tickRuntimeEntities())
+		if (flow.tickRuntimeEntities())
 			needsRedraw = true;
 		if (Player::updateDeathAnimationState(playerState)) {
 			requestPlayerGameOver("combat_player_death_complete", Common::String());
 			needsRedraw = true;
 		}
-		if (startupFlow.hasPendingMainMenuReturn())
+		if (flow.hasPendingMainMenuReturn())
 			return Common::kNoError;
 		if (pruneCombatHitEffects())
 			needsRedraw = true;
 		if (pruneCombatDamagePopups())
 			needsRedraw = true;
-		if (runtimeEntities) {
+		if (entityManager) {
 			Common::Array<Common::String> expiredTimerNames;
-			if (runtimeEntities->takeExpiredTimerNames(expiredTimerNames)) {
+			if (entityManager->takeExpiredTimerNames(expiredTimerNames)) {
 				syncCurrentRoomRuntimeState();
 				for (const Common::String &timerName : expiredTimerNames) {
 					InteractionResult timerInteraction;
@@ -4488,7 +4488,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 							timerInteraction, didTransition, Common::String());
 					if (interactionError.getCode() != Common::kNoError)
 						return interactionError;
-					if (startupFlow.hasPendingMainMenuReturn())
+					if (flow.hasPendingMainMenuReturn())
 						return Common::kNoError;
 					needsRedraw = true;
 					if (!pendingRoomChange.empty())
@@ -4506,7 +4506,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &startupFlow, const Common::String &t
 	}
 	if (_engine.shouldQuit())
 		return Common::kNoError;
-	if (startupFlow.hasPendingMainMenuReturn())
+	if (flow.hasPendingMainMenuReturn())
 		return Common::kNoError;
 	if (pendingRoomChange.empty())
 		return Common::kNoError;
diff --git a/engines/harvester/room.h b/engines/harvester/room.h
index 20603093eb9..3585a04f4e2 100644
--- a/engines/harvester/room.h
+++ b/engines/harvester/room.h
@@ -36,7 +36,7 @@ class RoomSystem {
 public:
 	RoomSystem(HarvesterEngine &engine, Common::Point &mousePos, InventorySystem &inventory);
 
-	Common::Error runRoomLoop(Flow &startupFlow, const Common::String &targetName, bool targetIsRoomName);
+	Common::Error runRoomLoop(Flow &flow, const Common::String &targetName, bool targetIsRoomName);
 
 private:
 	HarvesterEngine &_engine;
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index f7b50dfb2dc..c4b221eb6d9 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -132,7 +132,7 @@ const IndexedBitmap *resolveInspectTextboxBitmap(const Art &art, const ResolvedT
 void drawRoomInspectText(Graphics::Screen &screen, const Art &art, const Graphics::Font &font,
 	const ResolvedText &inspectText, bool useNativeFont);
 bool unlocksRoomObjectInteractionAfterInitialExamine(const ObjectRecord &object,
-	Script &startupScript);
+	Script &script);
 bool doesPlayerFacingMatchRegion(int playerFacing, const RegionRecord &region);
 bool doesPlayerOverlapRegion(const Entity &playerEntity, const RegionRecord &region);
 RoomHoverState resolveRoomHoverState(HarvesterEngine &engine, const RoomSetupState &state,
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index b6ce531db43..d131523b866 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -66,34 +66,34 @@ static void syncStartupSaveRoomState(Common::Serializer &s, SaveRoomState &state
 void HarvesterEngine::captureCurrentSaveRoomState(const Common::String &entranceName,
 		const Common::String &roomName, int playerX, int playerY, int playerZ, int playerFacing,
 		const Common::String &musicPath) {
-	_currentStartupSaveRoomState.entranceName = entranceName;
-	_currentStartupSaveRoomState.roomName = roomName;
-	_currentStartupSaveRoomState.musicPath =
+	_currentSaveRoomState.entranceName = entranceName;
+	_currentSaveRoomState.roomName = roomName;
+	_currentSaveRoomState.musicPath =
 		(_resources && !musicPath.empty()) ? _resources->normalizeResourcePath(musicPath) : musicPath;
-	_currentStartupSaveRoomState.playerX = playerX;
-	_currentStartupSaveRoomState.playerY = playerY;
-	_currentStartupSaveRoomState.playerZ = playerZ;
-	_currentStartupSaveRoomState.playerFacing = playerFacing;
-	_currentStartupSaveRoomState.valid = !roomName.empty();
+	_currentSaveRoomState.playerX = playerX;
+	_currentSaveRoomState.playerY = playerY;
+	_currentSaveRoomState.playerZ = playerZ;
+	_currentSaveRoomState.playerFacing = playerFacing;
+	_currentSaveRoomState.valid = !roomName.empty();
 }
 
 void HarvesterEngine::clearCurrentSaveRoomState() {
-	_currentStartupSaveRoomState.clear();
+	_currentSaveRoomState.clear();
 }
 
 void HarvesterEngine::clearPendingLoadedSaveRoomState() {
-	_pendingLoadedStartupSaveRoomState.clear();
-	_pendingLoadedStartupDisc = 0;
+	_pendingLoadedSaveRoomState.clear();
+	_pendingLoadedDisc = 0;
 }
 
 Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
-	if (!_startupScript)
+	if (!_script)
 		return s.isLoading() ? Common::kReadingFailed : Common::kWritingFailed;
 	if (s.isLoading()) {
 		clearPendingLoadedSaveRoomState();
 		clearPendingLoadedDialogueStateBlob();
 	}
-	if (s.isSaving() && !_currentStartupSaveRoomState.valid)
+	if (s.isSaving() && !_currentSaveRoomState.valid)
 		return Common::kWritingFailed;
 	if (!s.matchBytes(kHarvesterSaveMagic, sizeof(kHarvesterSaveMagic)))
 		return Common::kReadingFailed;
@@ -108,11 +108,11 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 
 	SaveRoomState roomState = s.isLoading()
 		? SaveRoomState()
-		: _currentStartupSaveRoomState;
+		: _currentSaveRoomState;
 	if (s.isSaving())
 		logStartupSaveRoomState("saving", roomState);
 	syncStartupSaveRoomState(s, roomState);
-	_startupScript->syncRuntimeSaveState(s);
+	_script->syncRuntimeSaveState(s);
 	// FIXME: Drop the pre-release version-3 dialogue blob fallback after release; clean
 	// Harvester saves will always carry dialogue state.
 	if (s.getVersion() >= 3) {
@@ -149,7 +149,7 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 		if (!activateDisc(restoredDisc))
 			return Common::kReadingFailed;
 		if (previousDisc > 0 && previousDisc != restoredDisc) {
-			if (!_startupScript->reloadTownWorld(*_resources)) {
+			if (!_script->reloadTownWorld(*_resources)) {
 				warning("Harvester: unable to reload town script after save restore disc switch %d -> %d",
 					previousDisc, restoredDisc);
 				return Common::kReadingFailed;
@@ -159,9 +159,9 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 				"Harvester: reloaded town script after save restore disc switch %d -> %d",
 				previousDisc, restoredDisc);
 		}
-		_currentStartupSaveRoomState = roomState;
-		_pendingLoadedStartupSaveRoomState = roomState;
-		_pendingLoadedStartupDisc = restoredDisc;
+		_currentSaveRoomState = roomState;
+		_pendingLoadedSaveRoomState = roomState;
+		_pendingLoadedDisc = restoredDisc;
 		logStartupSaveRoomState("loaded", roomState);
 	}
 	return Common::kNoError;
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 7a45479fd02..ebf9e58f808 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -215,22 +215,22 @@ static void syncStartupMonsterRecord(Common::Serializer &s, MonsterRecord &recor
 // FIXME: Remove this pre-release monster combat migration after Harvester ships and
 // only clean release-era saves remain.
 static void migrateLegacyMonsterCombatFields(const MonsterRecord &baseMonster,
-		MonsterRecord &runtimeMonster) {
-	const int legacyInitialHitPoints = MAX(0, runtimeMonster.initialHitPoints);
-	const int legacyCurrentHitPoints = CLIP<int>(runtimeMonster.currentHitPoints, 0, legacyInitialHitPoints);
+		MonsterRecord &currentMonster) {
+	const int legacyInitialHitPoints = MAX(0, currentMonster.initialHitPoints);
+	const int legacyCurrentHitPoints = CLIP<int>(currentMonster.currentHitPoints, 0, legacyInitialHitPoints);
 	const int damageTaken = legacyInitialHitPoints - legacyCurrentHitPoints;
 
-	runtimeMonster.engageDistance = baseMonster.engageDistance;
-	runtimeMonster.initialHitPoints = baseMonster.initialHitPoints;
-	runtimeMonster.currentHitPoints = MAX(0, baseMonster.initialHitPoints - damageTaken);
-	runtimeMonster.damageAmount = baseMonster.damageAmount;
+	currentMonster.engageDistance = baseMonster.engageDistance;
+	currentMonster.initialHitPoints = baseMonster.initialHitPoints;
+	currentMonster.currentHitPoints = MAX(0, baseMonster.initialHitPoints - damageTaken);
+	currentMonster.damageAmount = baseMonster.damageAmount;
 }
 
 static bool hasLegacyMonsterCombatFieldLayout(const MonsterRecord &baseMonster,
-		const MonsterRecord &runtimeMonster) {
-	return runtimeMonster.initialHitPoints != baseMonster.initialHitPoints ||
-		runtimeMonster.damageAmount != baseMonster.damageAmount ||
-		runtimeMonster.engageDistance != baseMonster.engageDistance;
+		const MonsterRecord &currentMonster) {
+	return currentMonster.initialHitPoints != baseMonster.initialHitPoints ||
+		currentMonster.damageAmount != baseMonster.damageAmount ||
+		currentMonster.engageDistance != baseMonster.engageDistance;
 }
 
 static void syncStartupTimerRecord(Common::Serializer &s, TimerRecord &record) {
@@ -291,7 +291,7 @@ static const char *resolveCombatLoadoutInventoryObjectName(int loadout) {
 	return nullptr;
 }
 
-static int validatePlayerCombatLoadoutAgainstInventory(const Common::Array<ObjectRecord> &runtimeObjects,
+static int validatePlayerCombatLoadoutAgainstInventory(const Common::Array<ObjectRecord> &currentObjects,
 		int loadout, const char *contextLabel) {
 	const int clampedLoadout = clampPlayerCombatLoadout(loadout);
 	if (clampedLoadout == kDefaultPlayerCombatLoadout)
@@ -301,7 +301,7 @@ static int validatePlayerCombatLoadoutAgainstInventory(const Common::Array<Objec
 	if (!requiredObjectName)
 		return clampedLoadout;
 
-	for (const ObjectRecord &object : runtimeObjects) {
+	for (const ObjectRecord &object : currentObjects) {
 		if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName) &&
 				object.objectName.equalsIgnoreCase(requiredObjectName))
 			return clampedLoadout;
@@ -519,13 +519,13 @@ bool Script::load(ResourceManager &resources) {
 	_texts.clear();
 	_heads.clear();
 	_useItems.clear();
-	_runtimeFlags.clear();
-	_runtimeObjects.clear();
-	_runtimeAnimations.clear();
-	_runtimeNpcs.clear();
-	_runtimeMonsters.clear();
-	_runtimeTimers.clear();
-	_runtimeRegions.clear();
+	_currentFlags.clear();
+	_currentObjects.clear();
+	_currentAnimations.clear();
+	_currentNpcs.clear();
+	_currentMonsters.clear();
+	_currentTimers.clear();
+	_currentRegions.clear();
 	_playerCurrentHitPoints = Script::kDefaultPlayerHitPoints;
 	_playerCombatLoadout = kDefaultPlayerCombatLoadout;
 	_playerControlPaused = false;
@@ -565,13 +565,13 @@ bool Script::reloadTownWorld(ResourceManager &resources) {
 		return false;
 	}
 
-	const Common::Array<FlagRecord> runtimeFlags = _runtimeFlags;
-	const Common::Array<ObjectRecord> runtimeObjects = _runtimeObjects;
-	const Common::Array<AnimRecord> runtimeAnimations = _runtimeAnimations;
-	const Common::Array<RegionRecord> runtimeRegions = _runtimeRegions;
-	const Common::Array<NpcRecord> runtimeNpcs = _runtimeNpcs;
-	const Common::Array<MonsterRecord> runtimeMonsters = _runtimeMonsters;
-	const Common::Array<TimerRecord> runtimeTimers = _runtimeTimers;
+	const Common::Array<FlagRecord> currentFlags = _currentFlags;
+	const Common::Array<ObjectRecord> currentObjects = _currentObjects;
+	const Common::Array<AnimRecord> currentAnimations = _currentAnimations;
+	const Common::Array<RegionRecord> currentRegions = _currentRegions;
+	const Common::Array<NpcRecord> currentNpcs = _currentNpcs;
+	const Common::Array<MonsterRecord> currentMonsters = _currentMonsters;
+	const Common::Array<TimerRecord> currentTimers = _currentTimers;
 	const int playerCurrentHitPoints = _playerCurrentHitPoints;
 	const int playerCombatLoadout = _playerCombatLoadout;
 	const bool playerControlPaused = _playerControlPaused;
@@ -581,99 +581,99 @@ bool Script::reloadTownWorld(ResourceManager &resources) {
 	parseTownRecords(resources);
 	resetRuntimeState();
 
-	for (const FlagRecord &flag : runtimeFlags) {
-		FlagRecord *runtimeFlag = findRuntimeFlag(flag.name);
-		if (runtimeFlag) {
-			runtimeFlag->value = flag.value;
+	for (const FlagRecord &flag : currentFlags) {
+		FlagRecord *currentFlag = findRuntimeFlag(flag.name);
+		if (currentFlag) {
+			currentFlag->value = flag.value;
 			continue;
 		}
 
-		_runtimeFlags.push_back(flag);
+		_currentFlags.push_back(flag);
 	}
 
-	for (const ObjectRecord &object : runtimeObjects) {
-		ObjectRecord *runtimeObject = findRuntimeObject(object.initialOwnerOrRoom, object.objectName);
-		if (!runtimeObject)
-			runtimeObject = findRuntimeObject(Common::String(), object.objectName);
-		if (!runtimeObject)
+	for (const ObjectRecord &object : currentObjects) {
+		ObjectRecord *currentObject = findRuntimeObject(object.initialOwnerOrRoom, object.objectName);
+		if (!currentObject)
+			currentObject = findRuntimeObject(Common::String(), object.objectName);
+		if (!currentObject)
 			continue;
 
-		runtimeObject->currentX = object.currentX;
-		runtimeObject->currentY = object.currentY;
-		runtimeObject->currentZ = object.currentZ;
-		runtimeObject->currentOwnerOrRoom = object.currentOwnerOrRoom;
-		runtimeObject->visible = object.visible;
-		runtimeObject->runtimeVisible = object.runtimeVisible;
-		runtimeObject->identShown = object.identShown;
+		currentObject->currentX = object.currentX;
+		currentObject->currentY = object.currentY;
+		currentObject->currentZ = object.currentZ;
+		currentObject->currentOwnerOrRoom = object.currentOwnerOrRoom;
+		currentObject->visible = object.visible;
+		currentObject->runtimeVisible = object.runtimeVisible;
+		currentObject->identShown = object.identShown;
 	}
 
-	for (const AnimRecord &anim : runtimeAnimations) {
-		AnimRecord *runtimeAnim = findRuntimeAnim(anim.animName);
-		if (!runtimeAnim)
+	for (const AnimRecord &anim : currentAnimations) {
+		AnimRecord *currentAnimation = findRuntimeAnim(anim.animName);
+		if (!currentAnimation)
 			continue;
 
-		runtimeAnim->active = anim.active;
-		runtimeAnim->visible = anim.visible;
-		runtimeAnim->runtimeActive = anim.runtimeActive;
-		runtimeAnim->runtimeVisible = anim.runtimeVisible;
-		runtimeAnim->runtimeState = anim.runtimeState;
+		currentAnimation->active = anim.active;
+		currentAnimation->visible = anim.visible;
+		currentAnimation->runtimeActive = anim.runtimeActive;
+		currentAnimation->runtimeVisible = anim.runtimeVisible;
+		currentAnimation->runtimeState = anim.runtimeState;
 	}
 
-	for (const RegionRecord &region : runtimeRegions) {
-		RegionRecord *runtimeRegion = findRuntimeRegion(region.regionName);
-		if (!runtimeRegion)
+	for (const RegionRecord &region : currentRegions) {
+		RegionRecord *currentRegion = findRuntimeRegion(region.regionName);
+		if (!currentRegion)
 			continue;
 
-		runtimeRegion->startEnabled = region.startEnabled;
+		currentRegion->startEnabled = region.startEnabled;
 	}
 
-	for (const NpcRecord &npc : runtimeNpcs) {
-		NpcRecord *runtimeNpc = findRuntimeNpc(npc.npcName);
-		if (!runtimeNpc)
+	for (const NpcRecord &npc : currentNpcs) {
+		NpcRecord *currentNpc = findRuntimeNpc(npc.npcName);
+		if (!currentNpc)
 			continue;
 
-		runtimeNpc->active = npc.active;
-		runtimeNpc->visible = npc.visible;
-		runtimeNpc->savedVisible = npc.savedVisible;
-		runtimeNpc->runtimeSpawned = npc.runtimeSpawned;
-		runtimeNpc->deathOrMonsterfyFlag = npc.deathOrMonsterfyFlag;
-		runtimeNpc->deathDamageType = npc.deathDamageType;
+		currentNpc->active = npc.active;
+		currentNpc->visible = npc.visible;
+		currentNpc->savedVisible = npc.savedVisible;
+		currentNpc->runtimeSpawned = npc.runtimeSpawned;
+		currentNpc->deathOrMonsterfyFlag = npc.deathOrMonsterfyFlag;
+		currentNpc->deathDamageType = npc.deathDamageType;
 	}
 
-	for (const MonsterRecord &monster : runtimeMonsters) {
-		MonsterRecord *runtimeMonster = findRuntimeMonster(monster.monsterName);
-		if (!runtimeMonster)
+	for (const MonsterRecord &monster : currentMonsters) {
+		MonsterRecord *currentMonster = findRuntimeMonster(monster.monsterName);
+		if (!currentMonster)
 			continue;
 
-		runtimeMonster->posX = monster.posX;
-		runtimeMonster->posY = monster.posY;
-		runtimeMonster->posZ = monster.posZ;
-		runtimeMonster->facing = monster.facing;
-		runtimeMonster->active = monster.active;
-		runtimeMonster->visible = monster.visible;
-		runtimeMonster->savedVisible = monster.savedVisible;
-		runtimeMonster->runtimeSpawned = monster.runtimeSpawned;
-		runtimeMonster->runtimeState = monster.runtimeState;
-		runtimeMonster->currentHitPoints = monster.currentHitPoints;
-		runtimeMonster->screenMinXBound = monster.screenMinXBound;
-		runtimeMonster->screenMaxXBound = monster.screenMaxXBound;
-	}
-
-	for (const TimerRecord &timer : runtimeTimers) {
-		TimerRecord *runtimeTimer = findRuntimeTimer(timer.timerName);
-		if (!runtimeTimer)
+		currentMonster->posX = monster.posX;
+		currentMonster->posY = monster.posY;
+		currentMonster->posZ = monster.posZ;
+		currentMonster->facing = monster.facing;
+		currentMonster->active = monster.active;
+		currentMonster->visible = monster.visible;
+		currentMonster->savedVisible = monster.savedVisible;
+		currentMonster->runtimeSpawned = monster.runtimeSpawned;
+		currentMonster->runtimeState = monster.runtimeState;
+		currentMonster->currentHitPoints = monster.currentHitPoints;
+		currentMonster->screenMinXBound = monster.screenMinXBound;
+		currentMonster->screenMaxXBound = monster.screenMaxXBound;
+	}
+
+	for (const TimerRecord &timer : currentTimers) {
+		TimerRecord *currentTimer = findRuntimeTimer(timer.timerName);
+		if (!currentTimer)
 			continue;
 
-		runtimeTimer->initialValue = timer.initialValue;
-		runtimeTimer->currentValue = timer.currentValue;
-		runtimeTimer->enabled = timer.enabled;
-		runtimeTimer->looping = timer.looping;
-		runtimeTimer->global = timer.global;
+		currentTimer->initialValue = timer.initialValue;
+		currentTimer->currentValue = timer.currentValue;
+		currentTimer->enabled = timer.enabled;
+		currentTimer->looping = timer.looping;
+		currentTimer->global = timer.global;
 	}
 
 	_playerCurrentHitPoints = clampPlayerHitPoints(playerCurrentHitPoints);
 	_playerCombatLoadout = validatePlayerCombatLoadoutAgainstInventory(
-		_runtimeObjects, playerCombatLoadout, "town reload");
+		_currentObjects, playerCombatLoadout, "town reload");
 	_playerControlPaused = playerControlPaused;
 
 	debugC(1, kDebugGeneral,
@@ -1329,18 +1329,18 @@ bool Script::resolveRoomSetupStateByRoomName(const Common::String &roomName, Roo
 }
 
 void Script::resetRuntimeState() {
-	_runtimeFlags = _flags;
-	_runtimeObjects = _objects;
-	_runtimeAnimations = _animations;
-	_runtimeRegions = _regions;
-	_runtimeNpcs = _npcs;
-	_runtimeMonsters = _monsters;
-	_runtimeTimers = _timers;
+	_currentFlags = _flags;
+	_currentObjects = _objects;
+	_currentAnimations = _animations;
+	_currentRegions = _regions;
+	_currentNpcs = _npcs;
+	_currentMonsters = _monsters;
+	_currentTimers = _timers;
 	_playerCurrentHitPoints = Script::kDefaultPlayerHitPoints;
 	_playerCombatLoadout = kDefaultPlayerCombatLoadout;
 	_playerControlPaused = false;
 
-	for (ObjectRecord &object : _runtimeObjects) {
+	for (ObjectRecord &object : _currentObjects) {
 		object.currentX = object.initialX;
 		object.currentY = object.initialY;
 		object.currentZ = object.initialZ;
@@ -1349,18 +1349,18 @@ void Script::resetRuntimeState() {
 		object.identShown = object.identTextKey.empty();
 	}
 
-	for (AnimRecord &anim : _runtimeAnimations) {
+	for (AnimRecord &anim : _currentAnimations) {
 		anim.runtimeActive = anim.active;
 		anim.runtimeVisible = anim.visible;
 		anim.runtimeState = -1;
 	}
 
-	for (NpcRecord &npc : _runtimeNpcs) {
+	for (NpcRecord &npc : _currentNpcs) {
 		npc.runtimeSpawned = false;
 		npc.savedVisible = npc.visible;
 	}
 
-	for (MonsterRecord &monster : _runtimeMonsters) {
+	for (MonsterRecord &monster : _currentMonsters) {
 		monster.currentHitPoints = monster.initialHitPoints;
 		monster.runtimeSpawned = false;
 		monster.runtimeState = -1;
@@ -1369,20 +1369,20 @@ void Script::resetRuntimeState() {
 			monster.visible = true;
 	}
 
-	for (TimerRecord &timer : _runtimeTimers)
+	for (TimerRecord &timer : _currentTimers)
 		timer.currentValue = timer.initialValue;
 }
 
 void Script::logRuntimeSaveState(const char *operation) const {
 	debugC(1, kDebugGeneral,
 		"Harvester: %s runtime save state flags=%u objects=%u anims=%u regions=%u npcs=%u monsters=%u timers=%u hp=%d loadout=%d paused=%d",
-		operation, (uint)_runtimeFlags.size(), (uint)_runtimeObjects.size(),
-		(uint)_runtimeAnimations.size(), (uint)_runtimeRegions.size(),
-		(uint)_runtimeNpcs.size(), (uint)_runtimeMonsters.size(),
-		(uint)_runtimeTimers.size(), _playerCurrentHitPoints, _playerCombatLoadout,
+		operation, (uint)_currentFlags.size(), (uint)_currentObjects.size(),
+		(uint)_currentAnimations.size(), (uint)_currentRegions.size(),
+		(uint)_currentNpcs.size(), (uint)_currentMonsters.size(),
+		(uint)_currentTimers.size(), _playerCurrentHitPoints, _playerCombatLoadout,
 		_playerControlPaused);
 
-	for (const FlagRecord &flag : _runtimeFlags) {
+	for (const FlagRecord &flag : _currentFlags) {
 		const FlagRecord *baseFlag = nullptr;
 		for (const FlagRecord &candidate : _flags) {
 			if (candidate.name.equalsIgnoreCase(flag.name)) {
@@ -1398,7 +1398,7 @@ void Script::logRuntimeSaveState(const char *operation) const {
 		}
 	}
 
-	for (const ObjectRecord &object : _runtimeObjects) {
+	for (const ObjectRecord &object : _currentObjects) {
 		const ObjectRecord *baseObject = nullptr;
 		for (const ObjectRecord &candidate : _objects) {
 			if (candidate.objectName.equalsIgnoreCase(object.objectName)) {
@@ -1432,7 +1432,7 @@ void Script::logRuntimeSaveState(const char *operation) const {
 		}
 	}
 
-	for (const NpcRecord &npc : _runtimeNpcs) {
+	for (const NpcRecord &npc : _currentNpcs) {
 		const NpcRecord *baseNpc = nullptr;
 		for (const NpcRecord &candidate : _npcs) {
 			if (candidate.npcName.equalsIgnoreCase(npc.npcName)) {
@@ -1462,7 +1462,7 @@ void Script::logRuntimeSaveState(const char *operation) const {
 		}
 	}
 
-	for (const MonsterRecord &monster : _runtimeMonsters) {
+	for (const MonsterRecord &monster : _currentMonsters) {
 		const MonsterRecord *baseMonster = nullptr;
 		for (const MonsterRecord &candidate : _monsters) {
 			if (candidate.monsterName.equalsIgnoreCase(monster.monsterName)) {
@@ -1500,7 +1500,7 @@ void Script::logRuntimeSaveState(const char *operation) const {
 		}
 	}
 
-	for (const TimerRecord &timer : _runtimeTimers) {
+	for (const TimerRecord &timer : _currentTimers) {
 		const TimerRecord *baseTimer = nullptr;
 		for (const TimerRecord &candidate : _timers) {
 			if (candidate.timerName.equalsIgnoreCase(timer.timerName)) {
@@ -1533,17 +1533,17 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 	if (s.isSaving())
 		logRuntimeSaveState("saving");
 
-	syncRecordArray(s, _runtimeFlags, syncStartupFlagRecord);
-	syncRecordArray(s, _runtimeObjects, syncStartupObjectRecord);
-	syncRecordArray(s, _runtimeAnimations, syncStartupAnimRecord);
-	syncRecordArray(s, _runtimeRegions, syncStartupRegionRecord);
-	syncRecordArray(s, _runtimeNpcs, syncStartupNpcRecord);
-	syncRecordArray(s, _runtimeMonsters, syncStartupMonsterRecord);
+	syncRecordArray(s, _currentFlags, syncStartupFlagRecord);
+	syncRecordArray(s, _currentObjects, syncStartupObjectRecord);
+	syncRecordArray(s, _currentAnimations, syncStartupAnimRecord);
+	syncRecordArray(s, _currentRegions, syncStartupRegionRecord);
+	syncRecordArray(s, _currentNpcs, syncStartupNpcRecord);
+	syncRecordArray(s, _currentMonsters, syncStartupMonsterRecord);
 	s.syncAsSint32LE(_playerCurrentHitPoints);
 	// FIXME: Remove this pre-v2 save-layout gate after release; clean Harvester
 	// saves always carry timer state, combat loadout, and paused-state fields.
 	if (s.getVersion() >= 2) {
-		syncRecordArray(s, _runtimeTimers, syncStartupTimerRecord);
+		syncRecordArray(s, _currentTimers, syncStartupTimerRecord);
 		s.syncAsSint32LE(_playerCombatLoadout);
 		syncBool(s, _playerControlPaused);
 	}
@@ -1553,30 +1553,30 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 		// FIXME: Drop this compatibility pass after release; it exists only to repair
 		// pre-release saves written before the monster combat field layout stabilized.
 		if (s.getVersion() >= 2) {
-			for (MonsterRecord &runtimeMonster : _runtimeMonsters) {
+			for (MonsterRecord &currentMonster : _currentMonsters) {
 				const MonsterRecord *baseMonster = nullptr;
 				for (const MonsterRecord &monster : _monsters) {
-					if (monster.monsterName.equalsIgnoreCase(runtimeMonster.monsterName)) {
+					if (monster.monsterName.equalsIgnoreCase(currentMonster.monsterName)) {
 						baseMonster = &monster;
 						break;
 					}
 				}
 				if (!baseMonster)
 					continue;
-				if (!hasLegacyMonsterCombatFieldLayout(*baseMonster, runtimeMonster))
+				if (!hasLegacyMonsterCombatFieldLayout(*baseMonster, currentMonster))
 					continue;
 
-				migrateLegacyMonsterCombatFields(*baseMonster, runtimeMonster);
+				migrateLegacyMonsterCombatFields(*baseMonster, currentMonster);
 			}
 		}
 		// FIXME: Remove the pre-v2 save fallback after release; clean Harvester saves will
 		// always serialize timer state and the full monster combat payload.
 		if (s.getVersion() < 2) {
-			_runtimeTimers = _timers;
-			for (MonsterRecord &runtimeMonster : _runtimeMonsters) {
+			_currentTimers = _timers;
+			for (MonsterRecord &currentMonster : _currentMonsters) {
 				const MonsterRecord *baseMonster = nullptr;
 				for (const MonsterRecord &monster : _monsters) {
-					if (monster.monsterName.equalsIgnoreCase(runtimeMonster.monsterName)) {
+					if (monster.monsterName.equalsIgnoreCase(currentMonster.monsterName)) {
 						baseMonster = &monster;
 						break;
 					}
@@ -1584,39 +1584,39 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 				if (!baseMonster)
 					continue;
 
-				runtimeMonster.initialHitPoints = baseMonster->initialHitPoints;
-				runtimeMonster.currentHitPoints = runtimeMonster.initialHitPoints;
-				runtimeMonster.damageAmount = baseMonster->damageAmount;
-				runtimeMonster.engageDistance = baseMonster->engageDistance;
-				runtimeMonster.damageType = baseMonster->damageType;
-				runtimeMonster.reservedString38 = baseMonster->reservedString38;
-				runtimeMonster.reservedString3c = baseMonster->reservedString3c;
-				runtimeMonster.reservedString44 = baseMonster->reservedString44;
-				runtimeMonster.reservedString48 = baseMonster->reservedString48;
-				runtimeMonster.attackSound1 = baseMonster->attackSound1;
-				runtimeMonster.attackSound2 = baseMonster->attackSound2;
-				runtimeMonster.attackSound3 = baseMonster->attackSound3;
-				runtimeMonster.hitSound1 = baseMonster->hitSound1;
-				runtimeMonster.hitSound2 = baseMonster->hitSound2;
-				runtimeMonster.hitSound3 = baseMonster->hitSound3;
-				runtimeMonster.footstepSoundLeft = baseMonster->footstepSoundLeft;
-				runtimeMonster.footstepSoundRight = baseMonster->footstepSoundRight;
-				runtimeMonster.deathSound = baseMonster->deathSound;
-				runtimeMonster.savedVisible = runtimeMonster.visible;
-				runtimeMonster.runtimeSpawned = false;
-				runtimeMonster.runtimeState = -1;
-				runtimeMonster.screenMinXBound = baseMonster->screenMinXBound;
-				runtimeMonster.screenMaxXBound = baseMonster->screenMaxXBound;
-				runtimeMonster.attackSoundTriggerFrame = baseMonster->attackSoundTriggerFrame;
-				runtimeMonster.hitSoundTriggerFrame = baseMonster->hitSoundTriggerFrame;
-				runtimeMonster.footstepSoundTriggerFrame = baseMonster->footstepSoundTriggerFrame;
-				runtimeMonster.deathSoundTriggerFrame = baseMonster->deathSoundTriggerFrame;
+				currentMonster.initialHitPoints = baseMonster->initialHitPoints;
+				currentMonster.currentHitPoints = currentMonster.initialHitPoints;
+				currentMonster.damageAmount = baseMonster->damageAmount;
+				currentMonster.engageDistance = baseMonster->engageDistance;
+				currentMonster.damageType = baseMonster->damageType;
+				currentMonster.reservedString38 = baseMonster->reservedString38;
+				currentMonster.reservedString3c = baseMonster->reservedString3c;
+				currentMonster.reservedString44 = baseMonster->reservedString44;
+				currentMonster.reservedString48 = baseMonster->reservedString48;
+				currentMonster.attackSound1 = baseMonster->attackSound1;
+				currentMonster.attackSound2 = baseMonster->attackSound2;
+				currentMonster.attackSound3 = baseMonster->attackSound3;
+				currentMonster.hitSound1 = baseMonster->hitSound1;
+				currentMonster.hitSound2 = baseMonster->hitSound2;
+				currentMonster.hitSound3 = baseMonster->hitSound3;
+				currentMonster.footstepSoundLeft = baseMonster->footstepSoundLeft;
+				currentMonster.footstepSoundRight = baseMonster->footstepSoundRight;
+				currentMonster.deathSound = baseMonster->deathSound;
+				currentMonster.savedVisible = currentMonster.visible;
+				currentMonster.runtimeSpawned = false;
+				currentMonster.runtimeState = -1;
+				currentMonster.screenMinXBound = baseMonster->screenMinXBound;
+				currentMonster.screenMaxXBound = baseMonster->screenMaxXBound;
+				currentMonster.attackSoundTriggerFrame = baseMonster->attackSoundTriggerFrame;
+				currentMonster.hitSoundTriggerFrame = baseMonster->hitSoundTriggerFrame;
+				currentMonster.footstepSoundTriggerFrame = baseMonster->footstepSoundTriggerFrame;
+				currentMonster.deathSoundTriggerFrame = baseMonster->deathSoundTriggerFrame;
 			}
 			_playerCombatLoadout = kDefaultPlayerCombatLoadout;
 			_playerControlPaused = false;
 		}
 		_playerCombatLoadout = validatePlayerCombatLoadoutAgainstInventory(
-			_runtimeObjects, _playerCombatLoadout, "save load");
+			_currentObjects, _playerCombatLoadout, "save load");
 		logRuntimeSaveState("loaded");
 	}
 }
@@ -1680,10 +1680,10 @@ bool Script::resolveObjectInteraction(const ObjectRecord &object, InteractionRes
 	result = InteractionResult();
 
 	if (isPickupObject(object)) {
-		if (ObjectRecord *runtimeObject = findRuntimeObject(object.currentOwnerOrRoom, object.objectName)) {
-			if (!runtimeObject->currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)) {
-				runtimeObject->currentOwnerOrRoom = kInventoryOwnerName;
-				runtimeObject->identShown = true;
+		if (ObjectRecord *currentObject = findRuntimeObject(object.currentOwnerOrRoom, object.objectName)) {
+			if (!currentObject->currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)) {
+				currentObject->currentOwnerOrRoom = kInventoryOwnerName;
+				currentObject->identShown = true;
 				result.mutatedRuntimeState = true;
 			}
 		}
@@ -1864,7 +1864,7 @@ void Script::getVisibleInventoryObjects(Common::Array<ObjectRecord> &objects) co
 	const char *const statusObjectName = resolveInventoryStatusObjectName(_playerCurrentHitPoints);
 	const ObjectRecord *statusObject = nullptr;
 
-	for (const ObjectRecord &object : _runtimeObjects) {
+	for (const ObjectRecord &object : _currentObjects) {
 		if (object.objectName.hasPrefixIgnoreCase("INV_STAT")) {
 			if (object.objectName.equalsIgnoreCase(statusObjectName))
 				statusObject = &object;
@@ -1890,7 +1890,7 @@ bool Script::isObjectInInventory(const Common::String &objectName) const {
 	if (objectName.empty())
 		return false;
 
-	for (const ObjectRecord &object : _runtimeObjects) {
+	for (const ObjectRecord &object : _currentObjects) {
 		if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName) &&
 				object.objectName.equalsIgnoreCase(objectName))
 			return true;
@@ -1900,8 +1900,8 @@ bool Script::isObjectInInventory(const Common::String &objectName) const {
 }
 
 void Script::markObjectIdentShown(const ObjectRecord &object) {
-	if (ObjectRecord *runtimeObject = findRuntimeObject(object.currentOwnerOrRoom, object.objectName))
-		runtimeObject->identShown = true;
+	if (ObjectRecord *currentObject = findRuntimeObject(object.currentOwnerOrRoom, object.objectName))
+		currentObject->identShown = true;
 }
 
 const EntranceRecord *Script::findEntranceRecord(const Common::String &entranceName) const {
@@ -1990,7 +1990,7 @@ const FlagRecord *Script::findRuntimeFlag(const Common::String &flagName) const
 	if (flagName.empty())
 		return nullptr;
 
-	for (const FlagRecord &flag : _runtimeFlags) {
+	for (const FlagRecord &flag : _currentFlags) {
 		if (flag.name.equalsIgnoreCase(flagName))
 			return &flag;
 	}
@@ -2002,7 +2002,7 @@ FlagRecord *Script::findRuntimeFlag(const Common::String &flagName) {
 	if (flagName.empty())
 		return nullptr;
 
-	for (FlagRecord &flag : _runtimeFlags) {
+	for (FlagRecord &flag : _currentFlags) {
 		if (flag.name.equalsIgnoreCase(flagName))
 			return &flag;
 	}
@@ -2016,7 +2016,7 @@ ObjectRecord *Script::findRuntimeObject(const Common::String &ownerOrRoom,
 		return nullptr;
 
 	ObjectRecord *fallback = nullptr;
-	for (ObjectRecord &object : _runtimeObjects) {
+	for (ObjectRecord &object : _currentObjects) {
 		if (!object.objectName.equalsIgnoreCase(objectName))
 			continue;
 
@@ -2039,7 +2039,7 @@ AnimRecord *Script::findRuntimeAnim(const Common::String &animName) {
 	if (animName.empty())
 		return nullptr;
 
-	for (AnimRecord &anim : _runtimeAnimations) {
+	for (AnimRecord &anim : _currentAnimations) {
 		if (anim.animName.equalsIgnoreCase(animName))
 			return &anim;
 	}
@@ -2051,7 +2051,7 @@ RegionRecord *Script::findRuntimeRegion(const Common::String &regionName) {
 	if (regionName.empty())
 		return nullptr;
 
-	for (RegionRecord &region : _runtimeRegions) {
+	for (RegionRecord &region : _currentRegions) {
 		if (region.regionName.equalsIgnoreCase(regionName))
 			return ®ion;
 	}
@@ -2063,7 +2063,7 @@ NpcRecord *Script::findRuntimeNpc(const Common::String &npcName) {
 	if (npcName.empty())
 		return nullptr;
 
-	for (NpcRecord &npc : _runtimeNpcs) {
+	for (NpcRecord &npc : _currentNpcs) {
 		if (npc.npcName.equalsIgnoreCase(npcName))
 			return &npc;
 	}
@@ -2075,7 +2075,7 @@ MonsterRecord *Script::findRuntimeMonster(const Common::String &monsterName) {
 	if (monsterName.empty())
 		return nullptr;
 
-	for (MonsterRecord &monster : _runtimeMonsters) {
+	for (MonsterRecord &monster : _currentMonsters) {
 		if (monster.monsterName.equalsIgnoreCase(monsterName))
 			return &monster;
 	}
@@ -2087,7 +2087,7 @@ TimerRecord *Script::findRuntimeTimer(const Common::String &timerName) {
 	if (timerName.empty())
 		return nullptr;
 
-	for (TimerRecord &timer : _runtimeTimers) {
+	for (TimerRecord &timer : _currentTimers) {
 		if (timer.timerName.equalsIgnoreCase(timerName))
 			return &timer;
 	}
@@ -2099,7 +2099,7 @@ const NpcRecord *Script::findRuntimeNpc(const Common::String &npcName) const {
 	if (npcName.empty())
 		return nullptr;
 
-	for (const NpcRecord &npc : _runtimeNpcs) {
+	for (const NpcRecord &npc : _currentNpcs) {
 		if (npc.npcName.equalsIgnoreCase(npcName))
 			return &npc;
 	}
@@ -2111,7 +2111,7 @@ const MonsterRecord *Script::findRuntimeMonster(const Common::String &monsterNam
 	if (monsterName.empty())
 		return nullptr;
 
-	for (const MonsterRecord &monster : _runtimeMonsters) {
+	for (const MonsterRecord &monster : _currentMonsters) {
 		if (monster.monsterName.equalsIgnoreCase(monsterName))
 			return &monster;
 	}
@@ -2123,7 +2123,7 @@ const TimerRecord *Script::findRuntimeTimer(const Common::String &timerName) con
 	if (timerName.empty())
 		return nullptr;
 
-	for (const TimerRecord &timer : _runtimeTimers) {
+	for (const TimerRecord &timer : _currentTimers) {
 		if (timer.timerName.equalsIgnoreCase(timerName))
 			return &timer;
 	}
@@ -2136,16 +2136,16 @@ const NpcRecord *Script::findRuntimeNpcRecord(const Common::String &npcName) con
 }
 
 bool Script::addRuntimeObjectToInventory(const Common::String &objectName) {
-	ObjectRecord *runtimeObject = findRuntimeObject(Common::String(), objectName);
-	if (!runtimeObject)
+	ObjectRecord *currentObject = findRuntimeObject(Common::String(), objectName);
+	if (!currentObject)
 		return false;
 
-	const bool changed = !runtimeObject->currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName) ||
-		!runtimeObject->visible || !runtimeObject->runtimeVisible || !runtimeObject->identShown;
-	runtimeObject->currentOwnerOrRoom = kInventoryOwnerName;
-	runtimeObject->visible = true;
-	runtimeObject->runtimeVisible = true;
-	runtimeObject->identShown = true;
+	const bool changed = !currentObject->currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName) ||
+		!currentObject->visible || !currentObject->runtimeVisible || !currentObject->identShown;
+	currentObject->currentOwnerOrRoom = kInventoryOwnerName;
+	currentObject->visible = true;
+	currentObject->runtimeVisible = true;
+	currentObject->identShown = true;
 	return changed;
 }
 
@@ -2153,18 +2153,18 @@ bool Script::syncRuntimeObjectRecord(const ObjectRecord &object) {
 	if (object.objectName.empty())
 		return false;
 
-	ObjectRecord *runtimeObject = findRuntimeObject(Common::String(), object.objectName);
-	if (!runtimeObject)
+	ObjectRecord *currentObject = findRuntimeObject(Common::String(), object.objectName);
+	if (!currentObject)
 		return false;
 
-	const bool changed = runtimeObject->currentX != object.currentX ||
-		runtimeObject->currentY != object.currentY ||
-		runtimeObject->currentZ != object.currentZ ||
-		!runtimeObject->currentOwnerOrRoom.equalsIgnoreCase(object.currentOwnerOrRoom) ||
-		runtimeObject->visible != object.visible ||
-		runtimeObject->runtimeVisible != object.runtimeVisible ||
-		runtimeObject->identShown != object.identShown;
-	*runtimeObject = object;
+	const bool changed = currentObject->currentX != object.currentX ||
+		currentObject->currentY != object.currentY ||
+		currentObject->currentZ != object.currentZ ||
+		!currentObject->currentOwnerOrRoom.equalsIgnoreCase(object.currentOwnerOrRoom) ||
+		currentObject->visible != object.visible ||
+		currentObject->runtimeVisible != object.runtimeVisible ||
+		currentObject->identShown != object.identShown;
+	*currentObject = object;
 	return changed;
 }
 
@@ -2173,16 +2173,16 @@ bool Script::setRuntimeObjectVisible(const Common::String &ownerOrRoom,
 	if (objectName.empty())
 		return false;
 
-	ObjectRecord *runtimeObject = findRuntimeObject(ownerOrRoom, objectName);
-	if (!runtimeObject)
+	ObjectRecord *currentObject = findRuntimeObject(ownerOrRoom, objectName);
+	if (!currentObject)
 		return false;
 
-	const bool changed = runtimeObject->visible != visible ||
-		runtimeObject->runtimeVisible != visible;
-	runtimeObject->visible = visible;
-	runtimeObject->runtimeVisible = visible;
-	if (visible && runtimeObject->currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName))
-		runtimeObject->identShown = true;
+	const bool changed = currentObject->visible != visible ||
+		currentObject->runtimeVisible != visible;
+	currentObject->visible = visible;
+	currentObject->runtimeVisible = visible;
+	if (visible && currentObject->currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName))
+		currentObject->identShown = true;
 
 	return changed;
 }
@@ -2191,13 +2191,13 @@ bool Script::setRuntimeNpcState(const Common::String &npcName, bool active, bool
 	if (npcName.empty())
 		return false;
 
-	NpcRecord *runtimeNpc = findRuntimeNpc(npcName);
-	if (!runtimeNpc)
+	NpcRecord *currentNpc = findRuntimeNpc(npcName);
+	if (!currentNpc)
 		return false;
 
-	const bool changed = runtimeNpc->active != active || runtimeNpc->visible != visible;
-	runtimeNpc->active = active;
-	runtimeNpc->visible = visible;
+	const bool changed = currentNpc->active != active || currentNpc->visible != visible;
+	currentNpc->active = active;
+	currentNpc->visible = visible;
 	return changed;
 }
 
@@ -2226,61 +2226,61 @@ bool Script::setPlayerControlPaused(bool paused) {
 }
 
 bool Script::syncRuntimeAnimState(const Common::String &animName, bool active, bool visible, int currentFrame) {
-	AnimRecord *runtimeAnim = findRuntimeAnim(animName);
-	if (!runtimeAnim)
+	AnimRecord *currentAnimation = findRuntimeAnim(animName);
+	if (!currentAnimation)
 		return false;
 
-	const bool changed = runtimeAnim->active != active ||
-		runtimeAnim->visible != visible ||
-		runtimeAnim->runtimeActive != active ||
-		runtimeAnim->runtimeVisible != visible ||
-		runtimeAnim->runtimeState != currentFrame;
-	runtimeAnim->active = active;
-	runtimeAnim->visible = visible;
-	runtimeAnim->runtimeActive = active;
-	runtimeAnim->runtimeVisible = visible;
-	runtimeAnim->runtimeState = currentFrame;
+	const bool changed = currentAnimation->active != active ||
+		currentAnimation->visible != visible ||
+		currentAnimation->runtimeActive != active ||
+		currentAnimation->runtimeVisible != visible ||
+		currentAnimation->runtimeState != currentFrame;
+	currentAnimation->active = active;
+	currentAnimation->visible = visible;
+	currentAnimation->runtimeActive = active;
+	currentAnimation->runtimeVisible = visible;
+	currentAnimation->runtimeState = currentFrame;
 	return changed;
 }
 
 bool Script::syncRuntimeMonsterRecord(const MonsterRecord &monster) {
-	MonsterRecord *runtimeMonster = findRuntimeMonster(monster.monsterName);
-	if (!runtimeMonster)
+	MonsterRecord *currentMonster = findRuntimeMonster(monster.monsterName);
+	if (!currentMonster)
 		return false;
 
-	*runtimeMonster = monster;
+	*currentMonster = monster;
 	return true;
 }
 
 bool Script::syncRuntimeTimerRecord(const TimerRecord &timer) {
-	TimerRecord *runtimeTimer = findRuntimeTimer(timer.timerName);
-	if (!runtimeTimer)
+	TimerRecord *currentTimer = findRuntimeTimer(timer.timerName);
+	if (!currentTimer)
 		return false;
 
-	*runtimeTimer = timer;
+	*currentTimer = timer;
 	return true;
 }
 
 bool Script::setRuntimeRegionEnabled(const Common::String &regionName, bool enabled) {
-	RegionRecord *runtimeRegion = findRuntimeRegion(regionName);
-	if (!runtimeRegion)
+	RegionRecord *currentRegion = findRuntimeRegion(regionName);
+	if (!currentRegion)
 		return false;
 
-	const bool changed = runtimeRegion->startEnabled != enabled;
-	runtimeRegion->startEnabled = enabled;
+	const bool changed = currentRegion->startEnabled != enabled;
+	currentRegion->startEnabled = enabled;
 	return changed;
 }
 
 bool Script::setRuntimeTimerEnabled(const Common::String &timerName, bool enabled) {
-	TimerRecord *runtimeTimer = findRuntimeTimer(timerName);
-	if (!runtimeTimer)
+	TimerRecord *currentTimer = findRuntimeTimer(timerName);
+	if (!currentTimer)
 		return false;
 
-	const bool changed = runtimeTimer->enabled != enabled ||
-		(enabled && runtimeTimer->currentValue != runtimeTimer->initialValue);
-	runtimeTimer->enabled = enabled;
+	const bool changed = currentTimer->enabled != enabled ||
+		(enabled && currentTimer->currentValue != currentTimer->initialValue);
+	currentTimer->enabled = enabled;
 	if (enabled)
-		runtimeTimer->currentValue = runtimeTimer->initialValue;
+		currentTimer->currentValue = currentTimer->initialValue;
 	return changed;
 }
 
@@ -2288,14 +2288,14 @@ bool Script::queueRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int
 	if (npcName.empty())
 		return false;
 
-	NpcRecord *runtimeNpc = findRuntimeNpc(npcName);
-	if (!runtimeNpc || runtimeNpc->deathOrMonsterfyFlag)
+	NpcRecord *currentNpc = findRuntimeNpc(npcName);
+	if (!currentNpc || currentNpc->deathOrMonsterfyFlag)
 		return false;
 
-	if (deathDamageType == 0 || runtimeNpc->deathDamageType == deathDamageType)
+	if (deathDamageType == 0 || currentNpc->deathDamageType == deathDamageType)
 		return false;
 
-	runtimeNpc->deathDamageType = deathDamageType;
+	currentNpc->deathDamageType = deathDamageType;
 	return true;
 }
 
@@ -2303,37 +2303,37 @@ bool Script::finalizeRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, i
 	if (npcName.empty())
 		return false;
 
-	NpcRecord *runtimeNpc = findRuntimeNpc(npcName);
-	if (!runtimeNpc)
+	NpcRecord *currentNpc = findRuntimeNpc(npcName);
+	if (!currentNpc)
 		return false;
 
 	bool monsterChanged = false;
-	if (!runtimeNpc->monsterfyTargetName.empty()) {
-		MonsterRecord *runtimeMonster = findRuntimeMonster(runtimeNpc->monsterfyTargetName);
-		if (runtimeMonster) {
-			monsterChanged = !runtimeMonster->active || !runtimeMonster->visible;
-			runtimeMonster->active = true;
-			runtimeMonster->visible = true;
-			runtimeMonster->runtimeSpawned = false;
-			runtimeMonster->runtimeState = -1;
-			if (runtimeMonster->currentHitPoints <= 0)
-				runtimeMonster->currentHitPoints = runtimeMonster->initialHitPoints;
+	if (!currentNpc->monsterfyTargetName.empty()) {
+		MonsterRecord *currentMonster = findRuntimeMonster(currentNpc->monsterfyTargetName);
+		if (currentMonster) {
+			monsterChanged = !currentMonster->active || !currentMonster->visible;
+			currentMonster->active = true;
+			currentMonster->visible = true;
+			currentMonster->runtimeSpawned = false;
+			currentMonster->runtimeState = -1;
+			if (currentMonster->currentHitPoints <= 0)
+				currentMonster->currentHitPoints = currentMonster->initialHitPoints;
 		} else {
 			debug(1, "Harvester: unresolved monsterfy target for dialogue npc='%s' target='%s'",
-				runtimeNpc->npcName.c_str(), runtimeNpc->monsterfyTargetName.c_str());
+				currentNpc->npcName.c_str(), currentNpc->monsterfyTargetName.c_str());
 		}
 	}
 
-	const bool changed = !runtimeNpc->deathOrMonsterfyFlag ||
-		runtimeNpc->active ||
-		runtimeNpc->visible ||
-		(deathDamageType != 0 && runtimeNpc->deathDamageType != deathDamageType);
-	runtimeNpc->active = false;
-	runtimeNpc->visible = false;
-	runtimeNpc->savedVisible = false;
-	runtimeNpc->deathOrMonsterfyFlag = true;
+	const bool changed = !currentNpc->deathOrMonsterfyFlag ||
+		currentNpc->active ||
+		currentNpc->visible ||
+		(deathDamageType != 0 && currentNpc->deathDamageType != deathDamageType);
+	currentNpc->active = false;
+	currentNpc->visible = false;
+	currentNpc->savedVisible = false;
+	currentNpc->deathOrMonsterfyFlag = true;
 	if (deathDamageType != 0)
-		runtimeNpc->deathDamageType = deathDamageType;
+		currentNpc->deathDamageType = deathDamageType;
 	return changed || monsterChanged;
 }
 
@@ -2344,7 +2344,7 @@ bool Script::buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord
 	const ObjectRecord *background = nullptr;
 	const ObjectRecord *backgroundFallback = nullptr;
 	bool usedBackgroundFallback = false;
-	for (const ObjectRecord &candidate : _runtimeObjects) {
+	for (const ObjectRecord &candidate : _currentObjects) {
 		if (!isRoomBackgroundSpriteCandidate(candidate, room))
 			continue;
 
@@ -2390,14 +2390,14 @@ bool Script::buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord
 		state.playerSpawnZ = entrance->posZ;
 		state.playerFacing = entrance->facing;
 	}
-	for (const ObjectRecord &object : _runtimeObjects) {
+	for (const ObjectRecord &object : _currentObjects) {
 		if (object.currentOwnerOrRoom.equalsIgnoreCase(room.roomName))
 			state.roomObjects.push_back(object);
 	}
 	if (!state.hasEntrance) {
 		static const char *const kCloseupExitObjects[] = { "EXIT_BM", "EXIT_HS" };
 		for (const char *name : kCloseupExitObjects) {
-			for (const ObjectRecord &object : _runtimeObjects) {
+			for (const ObjectRecord &object : _currentObjects) {
 				if (object.objectName.equalsIgnoreCase(name)) {
 					state.roomObjects.push_back(object);
 					break;
@@ -2405,11 +2405,11 @@ bool Script::buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord
 			}
 		}
 	}
-	for (const AnimRecord &anim : _runtimeAnimations) {
+	for (const AnimRecord &anim : _currentAnimations) {
 		if (anim.roomName.equalsIgnoreCase(room.roomName))
 			state.roomAnimations.push_back(anim);
 	}
-	for (const NpcRecord &npc : _runtimeNpcs) {
+	for (const NpcRecord &npc : _currentNpcs) {
 		if (!npc.roomName.equalsIgnoreCase(room.roomName) || !npc.visible)
 			continue;
 		if (npc.deathOrMonsterfyFlag) {
@@ -2422,7 +2422,7 @@ bool Script::buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord
 
 		state.roomNpcs.push_back(npc);
 	}
-	for (const MonsterRecord &monster : _runtimeMonsters) {
+	for (const MonsterRecord &monster : _currentMonsters) {
 		if (!monster.roomName.equalsIgnoreCase(room.roomName))
 			continue;
 		if (!monster.active && !monster.visible)
@@ -2430,11 +2430,11 @@ bool Script::buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord
 
 		state.roomMonsters.push_back(monster);
 	}
-	for (const TimerRecord &timer : _runtimeTimers) {
+	for (const TimerRecord &timer : _currentTimers) {
 		if (timer.arg1.equalsIgnoreCase(room.roomName))
 			state.roomTimers.push_back(timer);
 	}
-	for (const RegionRecord &region : _runtimeRegions) {
+	for (const RegionRecord &region : _currentRegions) {
 		if (region.roomName.equalsIgnoreCase(room.roomName) && region.startEnabled)
 			state.roomRegions.push_back(region);
 	}
@@ -2562,7 +2562,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 				FlagRecord newFlag;
 				newFlag.name = command->arg1;
 				newFlag.value = flagValue;
-				_runtimeFlags.push_back(newFlag);
+				_currentFlags.push_back(newFlag);
 				changed = true;
 			}
 			debugC(1, kDebugGeneral,
@@ -2596,8 +2596,8 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 
 		if (command->opcodeName.equalsIgnoreCase("ADD") ||
 			command->opcodeName.equalsIgnoreCase("DELETE")) {
-			ObjectRecord *runtimeObject = findRuntimeObject(command->arg1, command->arg2);
-			if (!runtimeObject) {
+			ObjectRecord *currentObject = findRuntimeObject(command->arg1, command->arg2);
+			if (!currentObject) {
 				debug(1, "Harvester: unresolved object for %s '%s' owner='%s' object='%s'",
 					contextLabel, contextName.c_str(), command->arg1.c_str(), command->arg2.c_str());
 				currentTag = command->arg4;
@@ -2605,12 +2605,12 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			}
 
 			const bool visible = command->opcodeName.equalsIgnoreCase("ADD");
-			const bool changed = runtimeObject->visible != visible ||
-				runtimeObject->runtimeVisible != visible;
-			runtimeObject->visible = visible;
-			runtimeObject->runtimeVisible = visible;
-			if (visible && runtimeObject->currentOwnerOrRoom.equalsIgnoreCase("INVENTORY"))
-				runtimeObject->identShown = true;
+			const bool changed = currentObject->visible != visible ||
+				currentObject->runtimeVisible != visible;
+			currentObject->visible = visible;
+			currentObject->runtimeVisible = visible;
+			if (visible && currentObject->currentOwnerOrRoom.equalsIgnoreCase("INVENTORY"))
+				currentObject->identShown = true;
 			noteMutation(changed);
 			currentTag = command->arg4;
 			continue;
@@ -2623,38 +2623,38 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("SET_ANIM")) {
-			AnimRecord *runtimeAnim = findRuntimeAnim(command->arg1);
-			if (!runtimeAnim) {
+			AnimRecord *currentAnimation = findRuntimeAnim(command->arg1);
+			if (!currentAnimation) {
 				debug(1, "Harvester: unresolved anim for %s '%s' anim='%s'",
 					contextLabel, contextName.c_str(), command->arg1.c_str());
 				currentTag = command->arg4;
 				continue;
 			}
 
-			const bool wasVisible = runtimeAnim->visible;
+			const bool wasVisible = currentAnimation->visible;
 			const bool active = isTruthy(command->arg2);
 			const bool visible = isTruthy(command->arg3);
-			int runtimeState = runtimeAnim->runtimeState;
+			int runtimeState = currentAnimation->runtimeState;
 			if (!wasVisible && visible)
 				runtimeState = 0;
-			const bool changed = runtimeAnim->active != active ||
-				runtimeAnim->visible != visible ||
-				runtimeAnim->runtimeActive != active ||
-				runtimeAnim->runtimeVisible != visible ||
-				runtimeAnim->runtimeState != runtimeState;
-			runtimeAnim->active = active;
-			runtimeAnim->visible = visible;
-			runtimeAnim->runtimeActive = active;
-			runtimeAnim->runtimeVisible = visible;
-			runtimeAnim->runtimeState = runtimeState;
+			const bool changed = currentAnimation->active != active ||
+				currentAnimation->visible != visible ||
+				currentAnimation->runtimeActive != active ||
+				currentAnimation->runtimeVisible != visible ||
+				currentAnimation->runtimeState != runtimeState;
+			currentAnimation->active = active;
+			currentAnimation->visible = visible;
+			currentAnimation->runtimeActive = active;
+			currentAnimation->runtimeVisible = visible;
+			currentAnimation->runtimeState = runtimeState;
 			noteMutation(changed);
 			currentTag = command->arg4;
 			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("SET_REGION")) {
-			RegionRecord *runtimeRegion = findRuntimeRegion(command->arg1);
-			if (!runtimeRegion) {
+			RegionRecord *currentRegion = findRuntimeRegion(command->arg1);
+			if (!currentRegion) {
 				debug(1, "Harvester: unresolved region for %s '%s' region='%s'",
 					contextLabel, contextName.c_str(), command->arg1.c_str());
 				currentTag = command->arg4;
@@ -2662,16 +2662,16 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			}
 
 			const bool enabled = !command->arg2.equalsIgnoreCase("F");
-			const bool changed = runtimeRegion->startEnabled != enabled;
-			runtimeRegion->startEnabled = enabled;
+			const bool changed = currentRegion->startEnabled != enabled;
+			currentRegion->startEnabled = enabled;
 			noteMutation(changed);
 			currentTag = command->arg4;
 			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("SET_NPC")) {
-			NpcRecord *runtimeNpc = findRuntimeNpc(command->arg1);
-			if (!runtimeNpc) {
+			NpcRecord *currentNpc = findRuntimeNpc(command->arg1);
+			if (!currentNpc) {
 				debug(1, "Harvester: unresolved npc for %s '%s' npc='%s'",
 					contextLabel, contextName.c_str(), command->arg1.c_str());
 				currentTag = command->arg4;
@@ -2680,17 +2680,17 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 
 			const bool active = isTruthy(command->arg2);
 			const bool visible = isTruthy(command->arg3);
-			const bool changed = runtimeNpc->active != active || runtimeNpc->visible != visible;
-			runtimeNpc->active = active;
-			runtimeNpc->visible = visible;
+			const bool changed = currentNpc->active != active || currentNpc->visible != visible;
+			currentNpc->active = active;
+			currentNpc->visible = visible;
 			noteMutation(changed);
 			currentTag = command->arg4;
 			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("SET_MONSTER")) {
-			MonsterRecord *runtimeMonster = findRuntimeMonster(command->arg1);
-			if (!runtimeMonster) {
+			MonsterRecord *currentMonster = findRuntimeMonster(command->arg1);
+			if (!currentMonster) {
 				debug(1, "Harvester: unresolved monster for %s '%s' monster='%s'",
 					contextLabel, contextName.c_str(), command->arg1.c_str());
 				currentTag = command->arg4;
@@ -2699,19 +2699,19 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 
 			const bool active = isTruthy(command->arg2);
 			const bool visible = isTruthy(command->arg3);
-			const bool changed = runtimeMonster->active != active || runtimeMonster->visible != visible;
-			runtimeMonster->active = active;
-			runtimeMonster->visible = active ? true : visible;
+			const bool changed = currentMonster->active != active || currentMonster->visible != visible;
+			currentMonster->active = active;
+			currentMonster->visible = active ? true : visible;
 			if (active) {
-				runtimeMonster->runtimeSpawned = false;
-				runtimeMonster->runtimeState = -1;
+				currentMonster->runtimeSpawned = false;
+				currentMonster->runtimeState = -1;
 			}
-			if (!runtimeMonster->visible) {
-				runtimeMonster->runtimeSpawned = false;
-				runtimeMonster->runtimeState = -1;
+			if (!currentMonster->visible) {
+				currentMonster->runtimeSpawned = false;
+				currentMonster->runtimeState = -1;
 			}
-			if (active && runtimeMonster->currentHitPoints <= 0)
-				runtimeMonster->currentHitPoints = runtimeMonster->initialHitPoints;
+			if (active && currentMonster->currentHitPoints <= 0)
+				currentMonster->currentHitPoints = currentMonster->initialHitPoints;
 			noteMutation(changed);
 			currentTag = command->arg4;
 			continue;
@@ -2800,8 +2800,8 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 
 		if (command->opcodeName.equalsIgnoreCase("KILL_NPC") ||
 				command->opcodeName.equalsIgnoreCase("MONSTERFY")) {
-			NpcRecord *runtimeNpc = findRuntimeNpc(command->arg1);
-			if (!runtimeNpc) {
+			NpcRecord *currentNpc = findRuntimeNpc(command->arg1);
+			if (!currentNpc) {
 				debug(1, "Harvester: unresolved npc for %s '%s' npc='%s'",
 					contextLabel, contextName.c_str(), command->arg1.c_str());
 				currentTag = command->arg4;
@@ -3192,7 +3192,7 @@ bool Script::setRuntimeFlagValue(const Common::String &flagName, bool value) {
 	FlagRecord newFlag;
 	newFlag.name = flagName;
 	newFlag.value = value;
-	_runtimeFlags.push_back(newFlag);
+	_currentFlags.push_back(newFlag);
 	debugC(1, kDebugGeneral,
 		"Harvester: direct runtime flag '%s' %d -> %d existed=0 changed=1",
 		flagName.c_str(), 0, value);
@@ -3203,8 +3203,8 @@ bool Script::resetRuntimeObjectToInitialState(const Common::String &objectName)
 	if (objectName.empty())
 		return false;
 
-	ObjectRecord *runtimeObject = findRuntimeObject(Common::String(), objectName);
-	if (!runtimeObject)
+	ObjectRecord *currentObject = findRuntimeObject(Common::String(), objectName);
+	if (!currentObject)
 		return false;
 
 	const ObjectRecord *baseObject = nullptr;
@@ -3217,21 +3217,21 @@ bool Script::resetRuntimeObjectToInitialState(const Common::String &objectName)
 	if (!baseObject)
 		return false;
 
-	const bool changed = runtimeObject->currentX != runtimeObject->initialX ||
-		runtimeObject->currentY != runtimeObject->initialY ||
-		runtimeObject->currentZ != runtimeObject->initialZ ||
-		!runtimeObject->currentOwnerOrRoom.equalsIgnoreCase(runtimeObject->initialOwnerOrRoom) ||
-		runtimeObject->visible != baseObject->visible ||
-		runtimeObject->runtimeVisible != baseObject->visible ||
-		runtimeObject->identShown != baseObject->identShown;
-
-	runtimeObject->currentX = runtimeObject->initialX;
-	runtimeObject->currentY = runtimeObject->initialY;
-	runtimeObject->currentZ = runtimeObject->initialZ;
-	runtimeObject->currentOwnerOrRoom = runtimeObject->initialOwnerOrRoom;
-	runtimeObject->visible = baseObject->visible;
-	runtimeObject->runtimeVisible = baseObject->visible;
-	runtimeObject->identShown = baseObject->identShown;
+	const bool changed = currentObject->currentX != currentObject->initialX ||
+		currentObject->currentY != currentObject->initialY ||
+		currentObject->currentZ != currentObject->initialZ ||
+		!currentObject->currentOwnerOrRoom.equalsIgnoreCase(currentObject->initialOwnerOrRoom) ||
+		currentObject->visible != baseObject->visible ||
+		currentObject->runtimeVisible != baseObject->visible ||
+		currentObject->identShown != baseObject->identShown;
+
+	currentObject->currentX = currentObject->initialX;
+	currentObject->currentY = currentObject->initialY;
+	currentObject->currentZ = currentObject->initialZ;
+	currentObject->currentOwnerOrRoom = currentObject->initialOwnerOrRoom;
+	currentObject->visible = baseObject->visible;
+	currentObject->runtimeVisible = baseObject->visible;
+	currentObject->identShown = baseObject->identShown;
 	return changed;
 }
 
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 682af707bb3..c2540ef94d6 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -468,13 +468,13 @@ private:
 	Common::Array<TextRecord> _texts;
 	Common::Array<HeadRecord> _heads;
 	Common::Array<UseItemRecord> _useItems;
-	Common::Array<FlagRecord> _runtimeFlags;
-	Common::Array<ObjectRecord> _runtimeObjects;
-	Common::Array<AnimRecord> _runtimeAnimations;
-	Common::Array<RegionRecord> _runtimeRegions;
-	Common::Array<NpcRecord> _runtimeNpcs;
-	Common::Array<MonsterRecord> _runtimeMonsters;
-	Common::Array<TimerRecord> _runtimeTimers;
+	Common::Array<FlagRecord> _currentFlags;
+	Common::Array<ObjectRecord> _currentObjects;
+	Common::Array<AnimRecord> _currentAnimations;
+	Common::Array<RegionRecord> _currentRegions;
+	Common::Array<NpcRecord> _currentNpcs;
+	Common::Array<MonsterRecord> _currentMonsters;
+	Common::Array<TimerRecord> _currentTimers;
 	int _playerCurrentHitPoints = 30;
 	int _playerCombatLoadout = 0;
 	bool _playerControlPaused = false;


Commit: 2ead9a0182ffba1003fd82ec6246622ebac6a691
    https://github.com/scummvm/scummvm/commit/2ead9a0182ffba1003fd82ec6246622ebac6a691
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:36+02:00

Commit Message:
HARVESTER: Preserve room combat state

Preserve runtime room mutations across script reloads and keep room
combat NPC corpses in place after death.

Changed paths:
    TRACKER.md
    engines/harvester/flow.cpp
    engines/harvester/room.cpp
    engines/harvester/saveload.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index 2803ca69b3f..2235ba40960 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On March 31, 2026, decoded the authored CD1/CD2 `HARVEST.SCR` records for `GO_BROOMPICCU` and confirmed that `BROOMPICCU` is a valid closeup room with a single visible sprite-backed object at `(0,0)` named `BROOMPIC_CU`. Updated Harvester room materialization so it still prefers true `640x480` backgrounds, but now falls back to the explicit origin sprite-backed room object when no fullscreen match exists, and carries that chosen object name into scene-entity classification so closeup backdrops remain non-interactive. Verified with `git diff --check` and a successful `make -C build-vscode-harvester-debug -j4 engines/harvester/libharvester.a`.
+- On March 31, 2026, traced Harvester room-combat NPC death handling around the `CLOAK_ATND` regression and confirmed combat deaths always finalized through the same removal path used by scripted `KILL_NPC` / `MONSTERFY`, so dead room NPCs were dropped from room materialization instead of persisting as corpses. Added persisted room-NPC corpse runtime state, preserved the last death frame for non-monsterfy combat deaths, and taught room-scene spawning to restore those corpses as non-interactive actors while leaving scripted removals unchanged. Verified with `git diff --check` and a successful `make -C build-vscode-harvester-debug -j4 engines/harvester/libharvester.a`.
 
 ## Next Suggested Action
 
-- Replay the remaining inventory document/photo closeups such as `GO_BOYLCOPYCU`, `GO_CASKTPICCU`, `GO_REGISTERCU`, and `GO_TVDEED1CU` to confirm the fallback background selection covers every authored closeup room and that clicks only land on the intended `EXIT_BM` / `EXIT_HS` hotspots.
+- Reproduce both the `CLOAKROOM` and `EYEHALL` kill-flows across immediate room refresh, room re-entry, disc reload, and save/load to confirm `CLOAK_ATND` now stays behind as a corpse while `BIG_EYE` still remains in the correct defeated state.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index da6aafc76b7..3959d2ff298 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -1893,11 +1893,23 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 		}
 
 		entity->setClassId(kRuntimeEntityClassNpc);
-		entity->setHitTestMode(kRuntimeEntityHitTestOpaquePixels);
-		// Native spawn_npc_entity_from_record seeds the passive room-NPC loop as frames 0..0x3b.
-		entity->setAnimationFrameRange(0, MIN(entity->getLastFrame(), kRoomNpcAmbientLastFrame), true);
-		if (npc.frameDelay > 0)
-			entity->setAnimationRate(npc.frameDelay);
+		const bool isCorpse = !npc.active &&
+			npc.runtimeSpawned &&
+			npc.deathDamageType != 0 &&
+			npc.runtimeState >= 0;
+		entity->setHitTestMode(isCorpse ? kRuntimeEntityHitTestNone : kRuntimeEntityHitTestOpaquePixels);
+		if (isCorpse) {
+			const int corpseFrame = MIN(entity->getLastFrame(), npc.runtimeState);
+			entity->setAnimationFrameRange(corpseFrame, corpseFrame, false);
+			entity->setAnimationRate(0);
+			entity->setCurrentFrame(corpseFrame);
+			entity->setAnimationEnabled(false);
+		} else {
+			// Native spawn_npc_entity_from_record seeds the passive room-NPC loop as frames 0..0x3b.
+			entity->setAnimationFrameRange(0, MIN(entity->getLastFrame(), kRoomNpcAmbientLastFrame), true);
+			if (npc.frameDelay > 0)
+				entity->setAnimationRate(npc.frameDelay);
+		}
 		// Native room NPCs come from spawn_abm_entity_base, which leaves the depth-scale flag cleared.
 		if (!applyRoomActorPlacementInternal(state, *entity,
 				npc.posX, npc.posY, (float)npc.posZ, nullptr, false)) {
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 909ab010f13..0377490ff73 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1122,7 +1122,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				lhs.posY == rhs.posY &&
 				lhs.posZ == rhs.posZ &&
 				lhs.frameDelay == rhs.frameDelay &&
+				lhs.active == rhs.active &&
 				lhs.visible == rhs.visible &&
+				lhs.runtimeSpawned == rhs.runtimeSpawned &&
+				lhs.runtimeState == rhs.runtimeState &&
+				lhs.deathDamageType == rhs.deathDamageType &&
 				lhs.deathOrMonsterfyFlag == rhs.deathOrMonsterfyFlag &&
 				lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
 		};
@@ -1188,9 +1192,21 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				return nullptr;
 
 			entity->setClassId(kRuntimeEntityClassNpc);
-			entity->setHitTestMode(kRuntimeEntityHitTestOpaquePixels);
-			entity->setAnimationFrameRange(0, MIN(entity->getLastFrame(), kRoomNpcAmbientLastFrame), true);
-			entity->setAnimationRate(npc.frameDelay > 0 ? npc.frameDelay : 0);
+			const bool isCorpse = !npc.active &&
+				npc.runtimeSpawned &&
+				npc.deathDamageType != 0 &&
+				npc.runtimeState >= 0;
+			entity->setHitTestMode(isCorpse ? kRuntimeEntityHitTestNone : kRuntimeEntityHitTestOpaquePixels);
+			if (isCorpse) {
+				const int corpseFrame = MIN(entity->getLastFrame(), npc.runtimeState);
+				entity->setAnimationFrameRange(corpseFrame, corpseFrame, false);
+				entity->setAnimationRate(0);
+				entity->setCurrentFrame(corpseFrame);
+				entity->setAnimationEnabled(false);
+			} else {
+				entity->setAnimationFrameRange(0, MIN(entity->getLastFrame(), kRoomNpcAmbientLastFrame), true);
+				entity->setAnimationRate(npc.frameDelay > 0 ? npc.frameDelay : 0);
+			}
 			entity->setVisible(true);
 			if (!applyRoomNpcPlacement(*entity, npc)) {
 				removeSceneEntityByName(npc.npcName);
@@ -2511,18 +2527,26 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 	auto finalizeNpcDeathTransition = [&](NpcRecord &npc,
 			RoomNpcCombatState &combatState) -> Common::Error {
 		Script *script = _engine.getScript();
+		const bool preserveCorpse =
+			npc.monsterfyTargetName.empty() && combatState.deathLastFrame >= 0;
+		const int corpseFrame = preserveCorpse ? combatState.deathLastFrame : -1;
 		removeSceneEntityByName(npc.npcName);
 		npc.active = false;
-		npc.visible = false;
-		npc.deathOrMonsterfyFlag = true;
+		npc.visible = preserveCorpse;
+		npc.savedVisible = preserveCorpse;
+		npc.runtimeSpawned = preserveCorpse;
+		npc.runtimeState = corpseFrame;
+		npc.deathOrMonsterfyFlag = !preserveCorpse;
 		if (combatState.deathDamageType != 0)
 			npc.deathDamageType = combatState.deathDamageType;
 		const bool runtimeChanged = script
-			? script->finalizeRuntimeNpcDeathOrMonsterfy(npc.npcName, npc.deathDamageType)
+			? script->finalizeRuntimeNpcDeathOrMonsterfy(
+				npc.npcName, npc.deathDamageType, preserveCorpse, corpseFrame)
 			: false;
 		debugC(1, kDebugCombat,
-			"Harvester: combat npc death complete target='%s' damage_type=%d last_frame=%d monsterfy='%s' on_death='%s'",
+			"Harvester: combat npc death complete target='%s' damage_type=%d last_frame=%d preserve_corpse=%d monsterfy='%s' on_death='%s'",
 			npc.npcName.c_str(), combatState.deathDamageType, combatState.deathLastFrame,
+			preserveCorpse,
 			npc.monsterfyTargetName.c_str(), npc.onDeathActionTag.c_str());
 		clearRoomNpcCombatState(combatState);
 
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index d131523b866..77bfc488c57 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -39,9 +39,10 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 }
 
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
-// FIXME: This pre-release version-16 bump exists only to carry monster corpse
-// runtimeState. Fold it into the final Harvester save layout before release.
-static const uint32 kHarvesterSaveVersion = 16;
+// FIXME: This pre-release version-17 bump exists only to carry room-NPC corpse
+// runtimeState alongside the earlier monster corpse state. Fold it into the
+// final Harvester save layout before release.
+static const uint32 kHarvesterSaveVersion = 17;
 
 static void logStartupSaveRoomState(const char *operation, const SaveRoomState &state) {
 	debugC(1, kDebugRoom,
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index ebf9e58f808..cc59294c91e 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -156,6 +156,10 @@ static void syncStartupNpcRecord(Common::Serializer &s, NpcRecord &record) {
 	s.syncString(record.roomName);
 	syncBool(s, record.deathOrMonsterfyFlag);
 	syncBool(s, record.runtimeSpawned);
+	// FIXME: Remove this pre-release version-17 gate once Harvester ships and all
+	// saves carry room-NPC corpse frame state.
+	if (s.getVersion() >= 17)
+		s.syncAsSint32LE(record.runtimeState);
 	syncBool(s, record.active);
 	syncBool(s, record.visible);
 	syncBool(s, record.savedVisible);
@@ -595,8 +599,10 @@ bool Script::reloadTownWorld(ResourceManager &resources) {
 		ObjectRecord *currentObject = findRuntimeObject(object.initialOwnerOrRoom, object.objectName);
 		if (!currentObject)
 			currentObject = findRuntimeObject(Common::String(), object.objectName);
-		if (!currentObject)
+		if (!currentObject) {
+			_currentObjects.push_back(object);
 			continue;
+		}
 
 		currentObject->currentX = object.currentX;
 		currentObject->currentY = object.currentY;
@@ -609,8 +615,10 @@ bool Script::reloadTownWorld(ResourceManager &resources) {
 
 	for (const AnimRecord &anim : currentAnimations) {
 		AnimRecord *currentAnimation = findRuntimeAnim(anim.animName);
-		if (!currentAnimation)
+		if (!currentAnimation) {
+			_currentAnimations.push_back(anim);
 			continue;
+		}
 
 		currentAnimation->active = anim.active;
 		currentAnimation->visible = anim.visible;
@@ -621,29 +629,36 @@ bool Script::reloadTownWorld(ResourceManager &resources) {
 
 	for (const RegionRecord &region : currentRegions) {
 		RegionRecord *currentRegion = findRuntimeRegion(region.regionName);
-		if (!currentRegion)
+		if (!currentRegion) {
+			_currentRegions.push_back(region);
 			continue;
+		}
 
 		currentRegion->startEnabled = region.startEnabled;
 	}
 
 	for (const NpcRecord &npc : currentNpcs) {
 		NpcRecord *currentNpc = findRuntimeNpc(npc.npcName);
-		if (!currentNpc)
+		if (!currentNpc) {
+			_currentNpcs.push_back(npc);
 			continue;
+		}
 
 		currentNpc->active = npc.active;
 		currentNpc->visible = npc.visible;
 		currentNpc->savedVisible = npc.savedVisible;
 		currentNpc->runtimeSpawned = npc.runtimeSpawned;
+		currentNpc->runtimeState = npc.runtimeState;
 		currentNpc->deathOrMonsterfyFlag = npc.deathOrMonsterfyFlag;
 		currentNpc->deathDamageType = npc.deathDamageType;
 	}
 
 	for (const MonsterRecord &monster : currentMonsters) {
 		MonsterRecord *currentMonster = findRuntimeMonster(monster.monsterName);
-		if (!currentMonster)
+		if (!currentMonster) {
+			_currentMonsters.push_back(monster);
 			continue;
+		}
 
 		currentMonster->posX = monster.posX;
 		currentMonster->posY = monster.posY;
@@ -661,8 +676,10 @@ bool Script::reloadTownWorld(ResourceManager &resources) {
 
 	for (const TimerRecord &timer : currentTimers) {
 		TimerRecord *currentTimer = findRuntimeTimer(timer.timerName);
-		if (!currentTimer)
+		if (!currentTimer) {
+			_currentTimers.push_back(timer);
 			continue;
+		}
 
 		currentTimer->initialValue = timer.initialValue;
 		currentTimer->currentValue = timer.currentValue;
@@ -1357,6 +1374,7 @@ void Script::resetRuntimeState() {
 
 	for (NpcRecord &npc : _currentNpcs) {
 		npc.runtimeSpawned = false;
+		npc.runtimeState = -1;
 		npc.savedVisible = npc.visible;
 	}
 
@@ -1446,19 +1464,21 @@ void Script::logRuntimeSaveState(const char *operation) const {
 		const bool defaultSavedVisible = baseNpc ? baseNpc->visible : npc.visible;
 		const bool defaultDeathOrMonsterfy = baseNpc ? baseNpc->deathOrMonsterfyFlag : npc.deathOrMonsterfyFlag;
 		const int defaultDeathDamageType = baseNpc ? baseNpc->deathDamageType : npc.deathDamageType;
+		const int defaultRuntimeState = -1;
 		if (!baseNpc ||
 				npc.active != defaultActive ||
 				npc.visible != defaultVisible ||
 				npc.savedVisible != defaultSavedVisible ||
 				npc.deathOrMonsterfyFlag != defaultDeathOrMonsterfy ||
 				npc.deathDamageType != defaultDeathDamageType ||
-				npc.runtimeSpawned) {
+				npc.runtimeSpawned ||
+				npc.runtimeState != defaultRuntimeState) {
 			debugC(1, kDebugGeneral,
-				"Harvester: %s runtime npc '%s' active=%d visible=%d savedVisible=%d spawned=%d deathOrMonsterfy=%d damageType=%d base_active=%d base_visible=%d base_savedVisible=%d base_deathOrMonsterfy=%d base_damageType=%d",
+				"Harvester: %s runtime npc '%s' active=%d visible=%d savedVisible=%d spawned=%d runtimeState=%d deathOrMonsterfy=%d damageType=%d base_active=%d base_visible=%d base_savedVisible=%d base_spawned=%d base_runtimeState=%d base_deathOrMonsterfy=%d base_damageType=%d",
 				operation, npc.npcName.c_str(), npc.active, npc.visible, npc.savedVisible,
-				npc.runtimeSpawned, npc.deathOrMonsterfyFlag, npc.deathDamageType,
+				npc.runtimeSpawned, npc.runtimeState, npc.deathOrMonsterfyFlag, npc.deathDamageType,
 				defaultActive, defaultVisible, defaultSavedVisible,
-				defaultDeathOrMonsterfy, defaultDeathDamageType);
+				0, defaultRuntimeState, defaultDeathOrMonsterfy, defaultDeathDamageType);
 		}
 	}
 
@@ -2299,7 +2319,8 @@ bool Script::queueRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int
 	return true;
 }
 
-bool Script::finalizeRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType) {
+bool Script::finalizeRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType,
+		bool preserveCorpse, int corpseFrame) {
 	if (npcName.empty())
 		return false;
 
@@ -2327,11 +2348,15 @@ bool Script::finalizeRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, i
 	const bool changed = !currentNpc->deathOrMonsterfyFlag ||
 		currentNpc->active ||
 		currentNpc->visible ||
+		currentNpc->runtimeSpawned ||
+		currentNpc->runtimeState != (preserveCorpse && corpseFrame >= 0 ? corpseFrame : -1) ||
 		(deathDamageType != 0 && currentNpc->deathDamageType != deathDamageType);
 	currentNpc->active = false;
-	currentNpc->visible = false;
-	currentNpc->savedVisible = false;
-	currentNpc->deathOrMonsterfyFlag = true;
+	currentNpc->visible = preserveCorpse && corpseFrame >= 0 && currentNpc->monsterfyTargetName.empty();
+	currentNpc->savedVisible = currentNpc->visible;
+	currentNpc->deathOrMonsterfyFlag = !currentNpc->visible;
+	currentNpc->runtimeSpawned = currentNpc->visible;
+	currentNpc->runtimeState = currentNpc->visible ? corpseFrame : -1;
 	if (deathDamageType != 0)
 		currentNpc->deathDamageType = deathDamageType;
 	return changed || monsterChanged;
@@ -2454,8 +2479,9 @@ bool Script::buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord
 	}
 	for (const NpcRecord &npc : state.roomNpcs) {
 		debugC(1, kDebugRoom,
-			"Harvester: materialized room npc room='%s' npc='%s' visible=%d active=%d pos=(%d,%d,%d) frame_delay=%d model='%s' on_death='%s' audio='%s'",
+			"Harvester: materialized room npc room='%s' npc='%s' visible=%d active=%d spawned=%d runtimeState=%d damage_type=%d pos=(%d,%d,%d) frame_delay=%d model='%s' on_death='%s' audio='%s'",
 			state.roomName.c_str(), npc.npcName.c_str(), npc.visible, npc.active,
+			npc.runtimeSpawned, npc.runtimeState, npc.deathDamageType,
 			npc.posX, npc.posY, npc.posZ, npc.frameDelay,
 			npc.modelPath.c_str(), npc.onDeathActionTag.c_str(), npc.audioPath.c_str());
 	}
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index c2540ef94d6..0511f10433e 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -141,6 +141,7 @@ struct NpcRecord {
 	Common::String roomName;
 	bool deathOrMonsterfyFlag = false;
 	bool runtimeSpawned = false;
+	int runtimeState = -1;
 	bool active = false;
 	bool visible = false;
 	bool savedVisible = false;
@@ -395,7 +396,8 @@ public:
 		bool visible);
 	bool setRuntimeNpcState(const Common::String &npcName, bool active, bool visible);
 	bool queueRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType = 0);
-	bool finalizeRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType = 0);
+	bool finalizeRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType = 0,
+		bool preserveCorpse = false, int corpseFrame = -1);
 	bool isNamedNpcDeathTypeClear(const Common::String &npcName) const;
 	int getCurrentStoryDayIndex() const;
 	int getPlayerCurrentHitPoints() const { return _playerCurrentHitPoints; }


Commit: 11c23d93f184a1a9f9f9b0528a3eab5731e6e720
    https://github.com/scummvm/scummvm/commit/11c23d93f184a1a9f9f9b0528a3eab5731e6e720
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:36+02:00

Commit Message:
HARVESTER: Align with ScummVM engine conventions

Changed paths:
    AGENTS.md
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/flow.cpp
    engines/harvester/flow.h
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/npc/authority_dialogue.cpp
    engines/harvester/npc/beggar_dialogue.cpp
    engines/harvester/npc/boyle_dialogue.cpp
    engines/harvester/npc/buster_dialogue.cpp
    engines/harvester/npc/butcher_dialogue.cpp
    engines/harvester/npc/chessmaster_dialogue.cpp
    engines/harvester/npc/cloak_atnd_dialogue.cpp
    engines/harvester/npc/curator_dialogue.cpp
    engines/harvester/npc/dad_dialogue.cpp
    engines/harvester/npc/dark_woman_dialogue.cpp
    engines/harvester/npc/dialogue_runtime.h
    engines/harvester/npc/dwayne_dialogue.cpp
    engines/harvester/npc/edna_dialogue.cpp
    engines/harvester/npc/fireman1_dialogue.cpp
    engines/harvester/npc/fireman2_dialogue.cpp
    engines/harvester/npc/gladiator_dialogue.cpp
    engines/harvester/npc/hank_dialogue.cpp
    engines/harvester/npc/herrill_dialogue.cpp
    engines/harvester/npc/inquisitor_dialogue.cpp
    engines/harvester/npc/jimmy_dialogue.cpp
    engines/harvester/npc/johnson_dialogue.cpp
    engines/harvester/npc/karin_dialogue.cpp
    engines/harvester/npc/librarian_dialogue.cpp
    engines/harvester/npc/lodge_chef_dialogue.cpp
    engines/harvester/npc/loomis_dialogue.cpp
    engines/harvester/npc/madam_dialogue.cpp
    engines/harvester/npc/maint_man_dialogue.cpp
    engines/harvester/npc/mcknight_dialogue.cpp
    engines/harvester/npc/memb_dir_dialogue.cpp
    engines/harvester/npc/mom_dialogue.cpp
    engines/harvester/npc/mother_dialogue.cpp
    engines/harvester/npc/moynahan_dialogue.cpp
    engines/harvester/npc/mr_potts_dialogue.cpp
    engines/harvester/npc/mrs_potts_dialogue.cpp
    engines/harvester/npc/nude_man_dialogue.cpp
    engines/harvester/npc/parsons_dialogue.cpp
    engines/harvester/npc/pastorelli_dialogue.cpp
    engines/harvester/npc/phelps_dialogue.cpp
    engines/harvester/npc/priest_dialogue.cpp
    engines/harvester/npc/pta_mom_dialogue.cpp
    engines/harvester/npc/ryder_dialogue.cpp
    engines/harvester/npc/sergeant_dialogue.cpp
    engines/harvester/npc/sparky_dialogue.cpp
    engines/harvester/npc/stephanie_dialogue.cpp
    engines/harvester/npc/swell_dialogue.cpp
    engines/harvester/npc/valet_dialogue.cpp
    engines/harvester/npc/vet_dialogue.cpp
    engines/harvester/npc/wasp_woman_dialogue.cpp
    engines/harvester/npc/whaley_dialogue.cpp
    engines/harvester/script.cpp


diff --git a/AGENTS.md b/AGENTS.md
index 8eec1040794..e76483bc4b3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -514,4 +514,57 @@ Record the last confirmed action we completed, and the next suggested action in
 Record high level progress indicator that summarizes how many total functions are in Ghidra, and how many are still not renamed (ex: FUN_*) in TRACKER.md
 Ensure TRACKER.md only contains the *last* action take and the *next suggested* action
 
-Never guess.
\ No newline at end of file
+Never guess.
+
+---
+
+## ScummVM Engine Conventions
+
+When editing ScummVM engine code in this repository, including `engines/harvester`, follow these rules in addition to the reverse-engineering workflow above.
+
+### Portability and language subset
+
+- Use ScummVM/common types and utilities in engine code instead of `std::` library types.
+- Prefer `Common::String`, `Common::Array`, `Common::HashMap`, `Common::ScopedPtr`, `Common::SharedPtr`, and other `common/` facilities.
+- If code is being ported from an external codebase and genuinely needs an STL-style wrapper, include the relevant `common/std/...` header and use `Std::`, never `std::`.
+- Do not use C++ exceptions.
+- Do not add global or function-local static objects that require constructors.
+- Non-const static locals inside function bodies are forbidden.
+- If a non-const global is unavoidable, document why it exists and where it is reset when the engine starts or shuts down.
+
+### Endianness, serialization, and struct layout
+
+- Never rely on host endianness for persisted data, imported assets, or binary parsing.
+- Use `Common::Serializer`, stream endian helpers, or `READ_/WRITE_` macros from `common/endian.h`.
+- Do not serialize raw structs directly.
+- Do not assume native struct packing or layout.
+- If packed structs are unavoidable, use `common/pack-start.h`, `PACKED_STRUCT`, and `common/pack-end.h`.
+
+### File access
+
+- Do not use `fopen`, `fread`, `fwrite`, `open`, `close`, or other raw C/POSIX file APIs in engine code.
+- Use ScummVM file APIs such as `Common::File`, `Common::DumpFile`, `Common::FSNode`, `SearchMan`, and archive streams.
+- Use `File::open("relative/name")` only for SearchMan-relative lookups.
+- Do not pass absolute paths to `File::open()`.
+- If a path comes from config or another filesystem source, first build a `Common::FSNode`, then open via `File::open(node)`, `DumpFile::open(node)`, `node.createReadStream()`, or `node.createWriteStream()`.
+- When traversing directories or handling platform-native filesystem locations, prefer `Common::FSNode` operations such as `getChild()`, `getParent()`, and `getPath()`.
+
+### Formatting and naming
+
+- Apply common sense, but default to ScummVM formatting conventions.
+- Use tabs for indentation with a tab width of 4.
+- Use hugging braces: `if (...) {` / `} else {`.
+- Avoid composite one-liners; prefer one statement per line.
+- Use braces for nested conditionals and for `if`/`else` chains when omission would reduce clarity.
+- Put spaces around operators, after commas, after comment markers, and between reserved words and `(`.
+- Use `char *ptr` and `int &ref` style spacing.
+- Keep namespace contents unindented.
+- Keep preprocessor directives starting in column 1.
+- For intentional `switch` fallthrough, use the exact comment `// fall through`.
+- Add vertical space between logical blocks when it improves readability.
+- Use ScummVM naming conventions: types in `UpperCamelCase`, methods/functions/locals in `lowerCamelCase`, member variables in `_lowerCamelCase`, globals with `g_` prefix, and constants either as `kCamelCase` or `ALL_CAPS_WITH_UNDERSCORES`.
+
+### Comments and documentation
+
+- Use `FIXME`, `TODO`, `WORKAROUND`, and `I18N:` comments only with specific explanatory text.
+- Prefer JavaDoc-style Doxygen comments for public APIs when new documentation is warranted.
diff --git a/TRACKER.md b/TRACKER.md
index 2235ba40960..b0f459a0a98 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On March 31, 2026, traced Harvester room-combat NPC death handling around the `CLOAK_ATND` regression and confirmed combat deaths always finalized through the same removal path used by scripted `KILL_NPC` / `MONSTERFY`, so dead room NPCs were dropped from room materialization instead of persisting as corpses. Added persisted room-NPC corpse runtime state, preserved the last death frame for non-monsterfy combat deaths, and taught room-scene spawning to restore those corpses as non-interactive actors while leaving scripted removals unchanged. Verified with `git diff --check` and a successful `make -C build-vscode-harvester-debug -j4 engines/harvester/libharvester.a`.
+- On March 31, 2026, aligned the Harvester engine with the documented ScummVM portability and formatting conventions by replacing remaining `std::function`/`std::unique_ptr` usage with ScummVM-native patterns, removing hidden function-local static object state, documenting the `g_engine` global reset behavior, switching startup-config writes to `FSNode`-based file opening, removing leftover `<functional>` includes from the NPC dialogue sources, and codifying the ScummVM engine rules in `AGENTS.md`. Verified with `git diff --check` and a successful `make -C build-vscode-harvester-debug -j4 engines/harvester/libharvester.a`.
 
 ## Next Suggested Action
 
-- Reproduce both the `CLOAKROOM` and `EYEHALL` kill-flows across immediate room refresh, room re-entry, disc reload, and save/load to confirm `CLOAK_ATND` now stays behind as a corpse while `BIG_EYE` still remains in the correct defeated state.
+- Do a runtime smoke test that exercises room-NPC dialogue, response menus, keyword entry, and startup config persistence to confirm the refactored dialogue session plumbing and `FSNode`-based config writes behave identically in-game.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 84caa383687..30b034c453a 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -19,14 +19,12 @@
  *
  */
 
-#include <functional>
-#include <memory>
-
 #include "harvester/dialogue.h"
 
 #include "common/debug.h"
 #include "common/endian.h"
 #include "common/events.h"
+#include "common/ptr.h"
 #include "common/system.h"
 #include "graphics/blit.h"
 #include "graphics/font.h"
@@ -95,6 +93,25 @@
 
 namespace Harvester {
 
+class DialogueFlowAccess {
+public:
+	static bool handleSystemEvent(Flow &flow, const Common::Event &event, Common::Error &result) {
+		return flow.handleSystemEvent(event, result);
+	}
+
+	static void executeStartupAudioCommands(Flow &flow, const Common::Array<AudioCommand> &commands) {
+		flow.executeStartupAudioCommands(commands);
+	}
+
+	static void queueDialogueInteraction(Flow &flow, const InteractionResult &interaction) {
+		flow.queueDialogueInteraction(interaction);
+	}
+
+	static void requestMainMenuReturn(Flow &flow) {
+		flow.requestMainMenuReturn();
+	}
+};
+
 namespace {
 
 static const int kDialogueOverlayX = 167;
@@ -462,99 +479,8 @@ static bool isDialogueOtherHit(const Common::Point &mousePos) {
 		mousePos.y >= kDialogueOtherStartY && mousePos.y <= kDialogueOtherEndY;
 }
 
-} // End of anonymous namespace
-
-DialogueSystem::DialogueSystem(HarvesterEngine &engine, Common::Point &mousePos)
-	: _engine(engine), _mousePos(mousePos) {
-	registerNpcHandlers();
-}
-
-DialogueSystem::~DialogueSystem() {
-	for (uint i = 0; i < _npcHandlers.size(); ++i)
-		delete _npcHandlers[i];
-}
-
-bool DialogueSystem::hasRoomNpcHandler(const Common::String &npcName) const {
-	for (uint i = 0; i < _npcHandlers.size(); ++i) {
-		if (_npcHandlers[i]->matchesNpc(npcName))
-			return true;
-	}
-
-	return false;
-}
-
-void DialogueSystem::registerNpcHandlers() {
-	_npcHandlers.push_back(new AuthorityDialogueHandler());
-	_npcHandlers.push_back(new BeggarDialogueHandler());
-	_npcHandlers.push_back(new BoyleDialogueHandler());
-	_npcHandlers.push_back(new BusterDialogueHandler());
-	_npcHandlers.push_back(new ButcherDialogueHandler());
-	_npcHandlers.push_back(new DwayneDialogueHandler());
-	_npcHandlers.push_back(new EdnaDialogueHandler());
-	_npcHandlers.push_back(new Fireman1DialogueHandler());
-	_npcHandlers.push_back(new Fireman2DialogueHandler());
-	_npcHandlers.push_back(new ChessmasterDialogueHandler());
-	_npcHandlers.push_back(new CloakAtndDialogueHandler());
-	_npcHandlers.push_back(new CuratorDialogueHandler());
-	_npcHandlers.push_back(new DadDialogueHandler());
-	_npcHandlers.push_back(new DarkWomanDialogueHandler());
-	_npcHandlers.push_back(new JimmyDialogueHandler());
-	_npcHandlers.push_back(new InquisitorDialogueHandler());
-	_npcHandlers.push_back(new KarinDialogueHandler());
-	_npcHandlers.push_back(new WaspWomanDialogueHandler());
-	_npcHandlers.push_back(new GladiatorDialogueHandler());
-	_npcHandlers.push_back(new LibrarianDialogueHandler());
-	_npcHandlers.push_back(new LoomisDialogueHandler());
-	_npcHandlers.push_back(new McknightDialogueHandler());
-	_npcHandlers.push_back(new MembDirDialogueHandler());
-	_npcHandlers.push_back(new MaintManDialogueHandler());
-	_npcHandlers.push_back(new MadamDialogueHandler());
-	_npcHandlers.push_back(new MomDialogueHandler());
-	_npcHandlers.push_back(new MotherDialogueHandler());
-	_npcHandlers.push_back(new MoynahanDialogueHandler());
-	_npcHandlers.push_back(new MrPottsDialogueHandler());
-	_npcHandlers.push_back(new MrsPottsDialogueHandler());
-	_npcHandlers.push_back(new LodgeChefDialogueHandler());
-	_npcHandlers.push_back(new NudeManDialogueHandler());
-	_npcHandlers.push_back(new ParsonsDialogueHandler());
-	_npcHandlers.push_back(new PastorelliDialogueHandler());
-	_npcHandlers.push_back(new PhelpsDialogueHandler());
-	_npcHandlers.push_back(new PriestDialogueHandler());
-	_npcHandlers.push_back(new RyderDialogueHandler());
-	_npcHandlers.push_back(new ValetDialogueHandler());
-	_npcHandlers.push_back(new VetDialogueHandler());
-	_npcHandlers.push_back(new SparkyDialogueHandler());
-	_npcHandlers.push_back(new StephanieDialogueHandler());
-	_npcHandlers.push_back(new SwellDialogueHandler());
-	_npcHandlers.push_back(new WhaleyDialogueHandler());
-	_npcHandlers.push_back(new HankDialogueHandler());
-	_npcHandlers.push_back(new HerrillDialogueHandler());
-	_npcHandlers.push_back(new JohnsonDialogueHandler());
-	_npcHandlers.push_back(new PtaMomDialogueHandler());
-
-	_npcHandlers.push_back(new SergeantDialogueHandler());
-	_npcHandlers.push_back(new StubNpcDialogueHandler("PTA_MOM"));
-}
-
-void DialogueSystem::resetRoomNpcDialogueState() {
-	_sharedDialogueState = DialogueSharedState();
-	for (uint i = 0; i < _npcHandlers.size(); ++i)
-		_npcHandlers[i]->resetState();
-}
-
-void DialogueSystem::syncRuntimeSaveState(Common::Serializer &s) {
-	syncDialogueSharedState(s, _sharedDialogueState);
-	for (uint i = 0; i < _npcHandlers.size(); ++i)
-		_npcHandlers[i]->syncState(s);
-	if (s.isLoading()) {
-		for (uint i = 0; i < _npcHandlers.size(); ++i)
-			_npcHandlers[i]->migrateSharedState(_sharedDialogueState);
-	}
-}
-
-Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, const NpcRecord &npc, const Common::String &usedItemName,
-		Flow &flow) {
+class RoomNpcDialogueSession : public DialogueRuntimeCallbacks {
+public:
 	struct DialogueResponseOptionLayout {
 		Common::String text;
 		Common::Array<Common::String> wrappedLines;
@@ -562,212 +488,86 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		int rowCount = 0;
 	};
 
-	Graphics::Screen *screen = _engine.getScreen();
-	Script *script = _engine.getScript();
-	Text *text = _engine.getText();
-	Art *art = _engine.getArt();
-	EntityManager *entityManager = _engine.getRuntimeEntities();
-	const Graphics::Font *fallbackFont = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	if (!screen || !script || !text || !art || !fallbackFont || !backdrop.isValid())
-		return Common::kReadingFailed;
-	if (!hasRoomNpcHandler(npc.npcName)) {
-		debugC(1, kDebugDialogue,
-			"Harvester: blocked room NPC dialogue npc='%s' reason='no handler'",
-			npc.npcName.c_str());
-		return Common::kNoError;
-	}
-	if (!script->isNamedNpcDeathTypeClear(npc.npcName)) {
-		const NpcRecord *runtimeNpc = script->findRuntimeNpcRecord(npc.npcName);
-		debugC(1, kDebugDialogue,
-			"Harvester: blocked room NPC dialogue npc='%s' reason='death queued' damage_type=%d",
-			npc.npcName.c_str(), runtimeNpc ? runtimeNpc->deathDamageType : -1);
-		return Common::kNoError;
-	}
-
-	const CftFontResource *subtitleFontResource = findStartupFontByName(_engine, "TEXTFONT");
-	const CftFontResource *menuFontResource = findStartupFontByName(_engine, "TEXTFNT2");
-	std::unique_ptr<HarvesterCftFont> subtitleCftFont;
-	std::unique_ptr<HarvesterCftFont> menuCftFont;
-	if (subtitleFontResource) {
-		subtitleCftFont.reset(new HarvesterCftFont(*subtitleFontResource));
-		if (!subtitleCftFont->isValid())
-			subtitleCftFont.reset();
-	}
-	if (menuFontResource) {
-		menuCftFont.reset(new HarvesterCftFont(*menuFontResource));
-		if (!menuCftFont->isValid())
-			menuCftFont.reset();
-	}
-
-	const Graphics::Font *subtitleFont = subtitleCftFont ? static_cast<const Graphics::Font *>(subtitleCftFont.get()) : fallbackFont;
-	const Graphics::Font *menuFont = menuCftFont ? static_cast<const Graphics::Font *>(menuCftFont.get()) : subtitleFont;
-	const Graphics::Font *highlightFont = subtitleFont;
-	const bool subtitleFontUsesCft = subtitleCftFont.get() != nullptr;
-	const bool menuFontUsesCft = menuCftFont.get() != nullptr;
-	const bool highlightFontUsesCft = subtitleFontUsesCft;
-
-	IndexedBitmap keywordBitmap;
-	if (!loadBitmapResource(*_engine.getResources(), kDialogueKeywordBitmapPath, keywordBitmap))
-		return Common::kReadingFailed;
-
-	Common::String genericByeTopic = text->getDialogueResponseLine(kDialogueGenericByeResponseIndex);
-	if (genericByeTopic.empty())
-		genericByeTopic = "BYE";
-
-	auto getActiveScreen = [&]() -> Graphics::Screen * {
-		return _engine.getScreen();
-	};
-
-	IndexedBitmap leftHeadBitmap;
-	IndexedBitmap rightHeadBitmap;
-	Common::String leftHeadSpeakerId = buildDialogueHeadId(npc.npcName, 0);
-	Common::String rightHeadSpeakerId = buildDialogueHeadId("PC", 0);
-	bool leftHeadVisible = false;
-	bool rightHeadVisible = false;
-	(void)loadDialogueHeadBitmap(_engine, npc.npcName, 0, leftHeadBitmap);
-	(void)loadDialogueHeadBitmap(_engine, "PC", 0, rightHeadBitmap);
-	leftHeadVisible = leftHeadBitmap.isValid();
-	rightHeadVisible = rightHeadBitmap.isValid();
-
-	auto waitForPointerRelease = [&]() -> Common::Error {
-		Graphics::FrameLimiter limiter(g_system, 60);
-		while (!_engine.shouldQuit() && (isPrimaryMouseDown() || isSecondaryMouseDown())) {
-			Common::Event event;
-			while (g_system->getEventManager()->pollEvent(event)) {
-				Common::Error result = Common::kNoError;
-				if (flow.handleSystemEvent(event, result))
-					return result;
-			}
-
-			if (entityManager)
-				(void)entityManager->syncCursorEntityPosition(_mousePos);
-			limiter.delayBeforeSwap();
-			limiter.startFrame();
-		}
-
-		return Common::kNoError;
-	};
-
-	auto drawFontString = [&](const Graphics::Font &font, bool usesCft, const Common::String &text,
-			int x, int y, int width, byte color) {
-		Graphics::Screen *activeScreen = getActiveScreen();
-		if (!activeScreen)
-			return;
-
-		if (usesCft)
-			font.drawString(activeScreen, text, x, y, width, 0);
-		else
-			drawShadowedString(*activeScreen, font, text, x, y, width, color);
-	};
-
-	auto ensureSpeakerPortrait = [&](const Common::String &speakerId, int headVariant) {
-		if (speakerId.empty())
-			return;
-
-		const Common::String headId = buildDialogueHeadId(speakerId, headVariant);
-		IndexedBitmap *targetBitmap = speakerId.equalsIgnoreCase("PC") ? &rightHeadBitmap : &leftHeadBitmap;
-		Common::String *targetSpeakerId = speakerId.equalsIgnoreCase("PC") ? &rightHeadSpeakerId : &leftHeadSpeakerId;
-		if (targetBitmap->isValid() && targetSpeakerId->equalsIgnoreCase(headId))
+	RoomNpcDialogueSession(HarvesterEngine &engine, Common::Point &mousePos, Flow &flow,
+			const IndexedBitmap &backdrop, const byte *palette, float paletteBrightness,
+			const NpcRecord &npc)
+		: _engine(engine), _mousePos(mousePos), _flow(flow), _backdrop(backdrop),
+		  _palette(palette), _paletteBrightness(paletteBrightness), _npc(npc),
+		  _script(engine.getScript()), _text(engine.getText()), _art(engine.getArt()),
+		  _entityManager(engine.getRuntimeEntities()),
+		  _fallbackFont(FontMan.getFontByUsage(Graphics::FontManager::kGUIFont)),
+		  _subtitleFont(nullptr), _menuFont(nullptr), _highlightFont(nullptr),
+		  _subtitleFontUsesCft(false), _menuFontUsesCft(false),
+		  _highlightFontUsesCft(false), _leftHeadVisible(false),
+		  _rightHeadVisible(false) {
+		ResourceManager *resources = _engine.getResources();
+		if (!_script || !_text || !_art || !_fallbackFont || !_backdrop.isValid() ||
+				!_palette || !resources) {
 			return;
-
-		IndexedBitmap updatedBitmap;
-		if (loadDialogueHeadBitmap(_engine, speakerId, headVariant, updatedBitmap)) {
-			*targetBitmap = updatedBitmap;
-			*targetSpeakerId = headId;
-		} else {
-			warning("Harvester: unable to load dialogue head for '%s'", headId.c_str());
 		}
-	};
-
-	auto setActiveSpeakerPortrait = [&](const Common::String &speakerId, int headVariant) {
-		if (speakerId.empty() || speakerId.equalsIgnoreCase("SPEECH"))
-			return;
 
-		ensureSpeakerPortrait(speakerId, headVariant);
-		if (speakerId.equalsIgnoreCase("PC")) {
-			leftHeadVisible = false;
-			rightHeadVisible = rightHeadBitmap.isValid();
-		} else {
-			leftHeadVisible = leftHeadBitmap.isValid();
-			rightHeadVisible = false;
+		if (const CftFontResource *subtitleFontResource = findStartupFontByName(_engine, "TEXTFONT")) {
+			_subtitleCftFont.reset(new HarvesterCftFont(*subtitleFontResource));
+			if (!_subtitleCftFont->isValid())
+				_subtitleCftFont.reset();
+		}
+		if (const CftFontResource *menuFontResource = findStartupFontByName(_engine, "TEXTFNT2")) {
+			_menuCftFont.reset(new HarvesterCftFont(*menuFontResource));
+			if (!_menuCftFont->isValid())
+				_menuCftFont.reset();
 		}
-	};
 
-	auto drawDialogueOverlay = [&](const IndexedBitmap *overlayBitmap,
-			const Common::Array<Common::String> *subtitleLines, const Common::Array<Common::String> *topics,
-			int hoveredTopicIndex, bool hoverOther, const Common::String *textEntryValue) {
-		Graphics::Screen *activeScreen = getActiveScreen();
-		if (!activeScreen)
+		_subtitleFont = _subtitleCftFont
+			? static_cast<const Graphics::Font *>(_subtitleCftFont.get())
+			: _fallbackFont;
+		_menuFont = _menuCftFont
+			? static_cast<const Graphics::Font *>(_menuCftFont.get())
+			: _subtitleFont;
+		_highlightFont = _subtitleFont;
+		_subtitleFontUsesCft = _subtitleCftFont.get() != nullptr;
+		_menuFontUsesCft = _menuCftFont.get() != nullptr;
+		_highlightFontUsesCft = _subtitleFontUsesCft;
+
+		if (!loadBitmapResource(*resources, kDialogueKeywordBitmapPath, _keywordBitmap))
 			return;
 
-		setScaledPalette(*activeScreen, palette, paletteBrightness);
-		blitBitmap(*activeScreen, backdrop, 0, 0);
-		if (leftHeadVisible && leftHeadBitmap.isValid())
-			blitTransparentBitmap(*activeScreen, leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
-		if (rightHeadVisible && rightHeadBitmap.isValid())
-			blitTransparentBitmap(*activeScreen, rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
-		if (overlayBitmap && overlayBitmap->isValid())
-			blitTransparentBitmap(*activeScreen, *overlayBitmap, kDialogueOverlayX, kDialogueOverlayY);
-
-		if (subtitleLines) {
-			if (subtitleFontUsesCft)
-				drawDialogueTextLines(*activeScreen, *subtitleFont, *subtitleLines,
-					kDialogueSubtitleTextX, kDialogueSubtitleTextY, kDialogueSubtitleTextWidth);
-			else {
-				const int lineHeight = getDialogueTextLineHeight(*subtitleFont);
-				for (uint i = 0; i < subtitleLines->size(); ++i) {
-					drawShadowedString(*activeScreen, *subtitleFont, (*subtitleLines)[i],
-						kDialogueSubtitleTextX,
-						kDialogueSubtitleTextY + (int)i * lineHeight,
-						kDialogueSubtitleTextWidth, kTextColorNormal);
-				}
-			}
-		}
-
-		if (topics) {
-			const int lineHeight = getDialogueTextLineHeight(*menuFont);
-			for (uint i = 0; i < topics->size(); ++i) {
-				const bool highlighted = (int)i == hoveredTopicIndex;
-				const Graphics::Font &font = highlighted ? *highlightFont : *menuFont;
-				const bool usesCft = highlighted ? highlightFontUsesCft : menuFontUsesCft;
-				drawFontString(font, usesCft, (*topics)[i], kDialogueKeywordTextX,
-					kDialogueKeywordTextY + (int)i * lineHeight, kDialogueKeywordTextWidth,
-					highlighted ? kTextColorNormal : kTextColorHover);
-			}
+		_genericByeTopic = _text->getDialogueResponseLine(kDialogueGenericByeResponseIndex);
+		if (_genericByeTopic.empty())
+			_genericByeTopic = "BYE";
 
-			const Graphics::Font &otherFont = hoverOther ? *highlightFont : *menuFont;
-			const bool otherUsesCft = hoverOther ? highlightFontUsesCft : menuFontUsesCft;
-			drawFontString(otherFont, otherUsesCft, "Other", kDialogueOtherX, kDialogueOtherY,
-				kDialogueOtherWidth, hoverOther ? kTextColorNormal : kTextColorHover);
-		}
+		_leftHeadSpeakerId = buildDialogueHeadId(_npc.npcName, 0);
+		_rightHeadSpeakerId = buildDialogueHeadId("PC", 0);
+		(void)loadDialogueHeadBitmap(_engine, _npc.npcName, 0, _leftHeadBitmap);
+		(void)loadDialogueHeadBitmap(_engine, "PC", 0, _rightHeadBitmap);
+		_leftHeadVisible = _leftHeadBitmap.isValid();
+		_rightHeadVisible = _rightHeadBitmap.isValid();
+	}
 
-		if (textEntryValue) {
-			drawFontString(*highlightFont, highlightFontUsesCft,
-				*textEntryValue + "_", kDialogueTextEntryX, kDialogueTextEntryY,
-				kDialogueKeywordTextWidth - 30, kTextColorNormal);
-		}
+	bool isValid() const {
+		return _script && _text && _art && _fallbackFont && _backdrop.isValid() &&
+			_palette && _keywordBitmap.isValid();
+	}
 
-		if (entityManager)
-			entityManager->drawCursor(*activeScreen);
-		activeScreen->makeAllDirty();
-		activeScreen->update();
-	};
+	const Common::String &getGenericByeTopic() const {
+		return _genericByeTopic;
+	}
 
-	auto playDialogueLineWithVariant = [&](int wavId, const Common::String &speakerId, int headVariant) -> Common::Error {
+	Common::Error playDialogueLineWithVariant(int wavId, const Common::String &speakerId,
+			int headVariant) override {
 		setActiveSpeakerPortrait(speakerId, headVariant);
 
 		Common::String subtitleText;
-		const bool textEnabled = script->getDialogueTextMode() != kStartupDialogueTextNone &&
-			text->resolveDialogueSubtitle(wavId, subtitleText);
+		const bool textEnabled = _script->getDialogueTextMode() != kStartupDialogueTextNone &&
+			_text->resolveDialogueSubtitle(wavId, subtitleText);
 		Common::Array<Common::String> subtitleLines;
 		const IndexedBitmap *textboxBitmap = nullptr;
 		if (textEnabled) {
-			wrapDialogueTextLikeNative(*subtitleFont, subtitleFontUsesCft,
+			wrapDialogueTextLikeNative(*_subtitleFont, _subtitleFontUsesCft,
 				subtitleText, kDialogueSubtitleTextWidth, subtitleLines);
-			textboxBitmap = art->getTextboxBitmap(resolveDialogueTextboxIndex(subtitleLines.size()));
+			textboxBitmap = _art->getTextboxBitmap(resolveDialogueTextboxIndex(subtitleLines.size()));
 		}
 
-		const Common::String voicePath = buildDialogueVoicePath(*script, wavId);
+		const Common::String voicePath = buildDialogueVoicePath(*_script, wavId);
 		const bool voiceStarted = !voicePath.empty() && _engine.playSpeech(voicePath);
 		debugC(2, kDebugDialogue,
 			"Harvester: dialogue line wav=0x%x speaker='%s' headVariant=%d voice='%s' subtitle='%s'",
@@ -787,7 +587,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			Common::Event event;
 			while (g_system->getEventManager()->pollEvent(event)) {
 				Common::Error result = Common::kNoError;
-				if (flow.handleSystemEvent(event, result)) {
+				if (DialogueFlowAccess::handleSystemEvent(_flow, event, result)) {
 					_engine.stopSpeech();
 					return result;
 				}
@@ -810,8 +610,8 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 				}
 			}
 
-			if (entityManager)
-				(void)entityManager->syncCursorEntityPosition(_mousePos);
+			if (_entityManager)
+				(void)_entityManager->syncCursorEntityPosition(_mousePos);
 			if (interrupted || (!voiceStarted || !_engine.isSpeechPlaying()))
 				break;
 
@@ -824,7 +624,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			Common::Error releaseResult = waitForPointerRelease();
 			if (releaseResult.getCode() != Common::kNoError)
 				return releaseResult;
-		} else if (textEnabled && script->getDialogueTextMode() == kStartupDialogueTextClick) {
+		} else if (textEnabled && _script->getDialogueTextMode() == kStartupDialogueTextClick) {
 			Graphics::FrameLimiter clickLimiter(g_system, 60);
 			for (;;) {
 				drawDialogueOverlay(textboxBitmap, &subtitleLines, nullptr, -1, false, nullptr);
@@ -833,7 +633,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 				Common::Event event;
 				while (g_system->getEventManager()->pollEvent(event)) {
 					Common::Error result = Common::kNoError;
-					if (flow.handleSystemEvent(event, result))
+					if (DialogueFlowAccess::handleSystemEvent(_flow, event, result))
 						return result;
 
 					switch (event.type) {
@@ -854,8 +654,8 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 					}
 				}
 
-				if (entityManager)
-					(void)entityManager->syncCursorEntityPosition(_mousePos);
+				if (_entityManager)
+					(void)_entityManager->syncCursorEntityPosition(_mousePos);
 				if (continuePressed)
 					break;
 
@@ -869,13 +669,13 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		}
 
 		return Common::kNoError;
-	};
+	}
 
-	auto playDialogueLine = [&](int wavId, const Common::String &speakerId) -> Common::Error {
+	Common::Error playDialogueLine(int wavId, const Common::String &speakerId) override {
 		return playDialogueLineWithVariant(wavId, speakerId, 0);
-	};
+	}
 
-	auto playDialogueEntrySequence = [&](const DialogueLineEntry *lines, uint count) -> Common::Error {
+	Common::Error playDialogueEntrySequence(const DialogueLineEntry *lines, uint count) override {
 		for (uint i = 0; i < count; ++i) {
 			Common::Error lineError = playDialogueLineWithVariant(
 				lines[i].wavId, lines[i].speakerId, lines[i].headVariant);
@@ -884,16 +684,17 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		}
 
 		return Common::kNoError;
-	};
+	}
 
-	auto playDialogueFst = [&](const Common::String &path) -> Common::Error {
+	Common::Error playDialogueFst(const Common::String &path) override {
 		FstPlayer fstPlayer(_engine);
 		if (!fstPlayer.play(path))
 			return Common::kReadingFailed;
 
 		return Common::kNoError;
-	};
-	auto clearScreenToBlack = [&]() -> Common::Error {
+	}
+
+	Common::Error clearScreenToBlack() override {
 		Graphics::Screen *screen = _engine.getScreen();
 		if (!screen)
 			return Common::kReadingFailed;
@@ -905,8 +706,9 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		screen->makeAllDirty();
 		screen->update();
 		return Common::kNoError;
-	};
-	auto showCdChangePrompt = [&](int discNumber) -> Common::Error {
+	}
+
+	Common::Error showCdChangePrompt(int discNumber) override {
 		if (discNumber <= 0)
 			return Common::kNoError;
 
@@ -935,7 +737,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			Common::Event event;
 			while (g_system->getEventManager()->pollEvent(event)) {
 				Common::Error result = Common::kNoError;
-				if (flow.handleSystemEvent(event, result))
+				if (DialogueFlowAccess::handleSystemEvent(_flow, event, result))
 					return result;
 				if (event.type == Common::EVENT_MOUSEMOVE)
 					_mousePos = event.mouse;
@@ -951,8 +753,8 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 					screen->fillRect(screen->getBounds(), 0);
 					blitBitmap(*screen, promptBitmap, 0, 0);
 					setScaledPalette(*screen, promptPalette, 1.0f);
-					if (entityManager)
-						entityManager->drawCursor(*screen);
+					if (_entityManager)
+						_entityManager->drawCursor(*screen);
 					screen->makeAllDirty();
 					screen->update();
 					needsRedraw = false;
@@ -960,7 +762,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 
 				while (g_system->getEventManager()->pollEvent(event)) {
 					Common::Error result = Common::kNoError;
-					if (flow.handleSystemEvent(event, result))
+					if (DialogueFlowAccess::handleSystemEvent(_flow, event, result))
 						return result;
 
 					switch (event.type) {
@@ -980,7 +782,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 					}
 				}
 
-				if (entityManager && entityManager->syncCursorEntityPosition(_mousePos))
+				if (_entityManager && _entityManager->syncCursorEntityPosition(_mousePos))
 					needsRedraw = true;
 
 				limiter.delayBeforeSwap();
@@ -1002,23 +804,134 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		}
 
 		return Common::kNoError;
-	};
-	auto runGameOverScreen = [&]() -> Common::Error {
-		Graphics::Screen *screen = _engine.getScreen();
-		ResourceManager *resources = _engine.getResources();
-		if (!screen || !resources)
-			return Common::kReadingFailed;
+	}
 
-		IndexedBitmap gameOverBitmap;
-		byte gameOverPalette[256 * 3];
-		if (!loadBitmapResource(*resources, kDialogueGameOverBitmapPath, gameOverBitmap) ||
-				!loadPaletteResource(*resources, kDialogueGameOverPalettePath, gameOverPalette)) {
-			return Common::kReadingFailed;
-		}
+	Common::Error runKeywordMenu(const Common::String &topicBuffer, int topicBufferLineIndex,
+			Common::String &selectedTopic,
+			DialogueKeywordMenuSelectionState &selection) override {
+		selectedTopic.clear();
+		selection = DialogueKeywordMenuSelectionState();
+		Common::Array<Common::String> topics;
+		splitDialogueMenuLine(topicBuffer, topics);
+		logDialogueMenuItems("Keyword menu", topicBufferLineIndex, topicBuffer, topics);
+		topics.push_back(_genericByeTopic);
+		debugC(1, kDebugDialogue, "Harvester: keyword menu default topic='%s'", _genericByeTopic.c_str());
+		bool editingOther = false;
+		Common::String typedTopic;
+		Common::Error releaseError = waitForPointerRelease();
+		if (releaseError.getCode() != Common::kNoError)
+			return releaseError;
 
-		_engine.stopSpeech();
-		_engine.stopMusic();
-		_engine.stopSound();
+		Graphics::FrameLimiter limiter(g_system, 60);
+		for (;;) {
+			const int hoveredTopicIndex = editingOther ? -1 : getDialogueMenuItemAt(*_menuFont, topics.size(), _mousePos);
+			const bool hoverOther = !editingOther && isDialogueOtherHit(_mousePos);
+			drawDialogueOverlay(&_keywordBitmap, nullptr, &topics, hoveredTopicIndex, hoverOther,
+				editingOther ? &typedTopic : nullptr);
+
+			Common::Event event;
+			while (g_system->getEventManager()->pollEvent(event)) {
+				Common::Error result = Common::kNoError;
+				if (DialogueFlowAccess::handleSystemEvent(_flow, event, result))
+					return result;
+
+				if (editingOther) {
+					if (event.type == Common::EVENT_KEYDOWN) {
+						if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+							typedTopic.clear();
+							editingOther = false;
+							debugC(1, kDebugDialogue, "Harvester: keyword menu cancelled Other input");
+							if (_entityManager)
+								_entityManager->showCursor();
+						} else if (event.kbd.keycode == Common::KEYCODE_BACKSPACE) {
+							if (!typedTopic.empty())
+								typedTopic.deleteLastChar();
+						} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+								event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
+							if (!typedTopic.empty()) {
+								selectedTopic = typedTopic;
+								selection.fromTypedInput = true;
+								debugC(1, kDebugDialogue, "Harvester: keyword menu typed topic='%s'",
+									selectedTopic.c_str());
+								if (_entityManager)
+									_entityManager->showCursor();
+								return Common::kNoError;
+							}
+						} else if (event.kbd.ascii >= 0x20 && event.kbd.ascii <= 0x7e &&
+								typedTopic.size() < 79) {
+							typedTopic += (char)event.kbd.ascii;
+						}
+					}
+					continue;
+				}
+
+				switch (event.type) {
+				case Common::EVENT_KEYDOWN:
+					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+						selectedTopic = _genericByeTopic;
+						selection.fromEscape = true;
+						selection.fromGenericBye = true;
+						debugC(1, kDebugDialogue,
+							"Harvester: keyword menu selected default topic via escape '%s'",
+							selectedTopic.c_str());
+						return Common::kNoError;
+					}
+					break;
+				case Common::EVENT_LBUTTONDOWN: {
+					const int clickedTopic = getDialogueMenuItemAt(*_menuFont, topics.size(), _mousePos);
+					if (clickedTopic >= 0) {
+						selectedTopic = topics[(uint)clickedTopic];
+						selection.fromGenericBye = clickedTopic == (int)topics.size() - 1;
+						debugC(1, kDebugDialogue, "Harvester: keyword menu selected topic[%d]='%s'",
+							clickedTopic + 1, selectedTopic.c_str());
+						return Common::kNoError;
+					}
+					if (isDialogueOtherHit(_mousePos)) {
+						typedTopic.clear();
+						editingOther = true;
+						debugC(1, kDebugDialogue, "Harvester: keyword menu entered Other input");
+						if (_entityManager)
+							_entityManager->hideCursor();
+					}
+					break;
+				}
+				default:
+					break;
+				}
+			}
+
+			if (_entityManager)
+				(void)_entityManager->syncCursorEntityPosition(_mousePos);
+			limiter.delayBeforeSwap();
+			limiter.startFrame();
+		}
+	}
+
+	Common::Error runResponseMenu(int responseLineIndex, int &selectedIndex) override {
+		const Common::String responseLine = _text->getDialogueResponseLine(responseLineIndex);
+		return runResponseMenuTextInternal(responseLine, responseLineIndex, selectedIndex);
+	}
+
+	Common::Error runResponseMenuText(const Common::String &responseLine, int &selectedIndex) override {
+		return runResponseMenuTextInternal(responseLine, -1, selectedIndex);
+	}
+
+	Common::Error runGameOverScreen() override {
+		Graphics::Screen *screen = _engine.getScreen();
+		ResourceManager *resources = _engine.getResources();
+		if (!screen || !resources)
+			return Common::kReadingFailed;
+
+		IndexedBitmap gameOverBitmap;
+		byte gameOverPalette[256 * 3];
+		if (!loadBitmapResource(*resources, kDialogueGameOverBitmapPath, gameOverBitmap) ||
+				!loadPaletteResource(*resources, kDialogueGameOverPalettePath, gameOverPalette)) {
+			return Common::kReadingFailed;
+		}
+
+		_engine.stopSpeech();
+		_engine.stopMusic();
+		_engine.stopSound();
 
 		screen->fillRect(screen->getBounds(), 0);
 		blitBitmap(*screen, gameOverBitmap, 0, 0);
@@ -1034,7 +947,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			Common::Event event;
 			while (g_system->getEventManager()->pollEvent(event)) {
 				Common::Error result = Common::kNoError;
-				if (flow.handleSystemEvent(event, result)) {
+				if (DialogueFlowAccess::handleSystemEvent(_flow, event, result)) {
 					_engine.stopMusic();
 					return result;
 				}
@@ -1050,8 +963,8 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 				}
 			}
 
-			if (entityManager)
-				(void)entityManager->syncCursorEntityPosition(_mousePos);
+			if (_entityManager)
+				(void)_entityManager->syncCursorEntityPosition(_mousePos);
 			if (dismissed)
 				break;
 
@@ -1066,11 +979,36 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 				return releaseError;
 		}
 
-		flow.requestMainMenuReturn();
+		DialogueFlowAccess::requestMainMenuReturn(_flow);
 		return Common::kNoError;
-	};
+	}
+
+	void assignTopicBuffer(Common::String &topicBuffer, int &topicBufferLineIndex,
+			int responseLineIndex, const char *label) override {
+		topicBuffer = _text->getDialogueResponseLine(responseLineIndex);
+		topicBufferLineIndex = responseLineIndex;
+
+		Common::Array<Common::String> topics;
+		splitDialogueMenuLine(topicBuffer, topics);
+		logDialogueMenuItems(label, responseLineIndex, topicBuffer, topics);
+	}
+
+	bool matchesResponseLine(const Common::String &selectedTopic, int responseLineIndex) override {
+		const Common::String topicText = _text->getDialogueResponseLine(responseLineIndex);
+		return !topicText.empty() && selectedTopic.equalsIgnoreCase(topicText);
+	}
+
+	bool matchesAnyResponseLine(const Common::String &selectedTopic,
+			const int *responseLineIndices, uint responseLineCount) override {
+		for (uint i = 0; i < responseLineCount; ++i) {
+			if (matchesResponseLine(selectedTopic, responseLineIndices[i]))
+				return true;
+		}
+
+		return false;
+	}
 
-	auto queueDialogueInteractionIfNeeded = [&](const InteractionResult &interaction) {
+	void queueDialogueInteractionIfNeeded(const InteractionResult &interaction) override {
 		if (interaction.abortRemainingCommandChain || interaction.mutatedRuntimeState ||
 				!interaction.musicPath.empty() || !interaction.nextRoomName.empty() ||
 				!interaction.cutscenePath.empty() ||
@@ -1084,19 +1022,153 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 				interaction.lightingCommand != kStartupLightingCommandNone ||
 				interaction.requestPlayerGotoXZ ||
 				!interaction.audioCommands.empty()) {
-			flow.queueDialogueInteraction(interaction);
+			DialogueFlowAccess::queueDialogueInteraction(_flow, interaction);
 		}
-	};
-	auto applyImmediateDialogueInteractionEffects = [&](InteractionResult &interaction) {
+	}
+
+	void applyImmediateDialogueInteractionEffects(InteractionResult &interaction) override {
 		if (!interaction.musicPath.empty())
 			(void)_engine.playMusic(interaction.musicPath);
 		if (!interaction.audioCommands.empty())
-			flow.executeStartupAudioCommands(interaction.audioCommands);
+			DialogueFlowAccess::executeStartupAudioCommands(_flow, interaction.audioCommands);
 		interaction.musicPath.clear();
 		interaction.audioCommands.clear();
-	};
+	}
+
+	int getRandomNumber(int maxValue) override {
+		return _engine.getRandomNumber(maxValue);
+	}
+
+	void setActiveSpeakerPortrait(const Common::String &speakerId, int headVariant) override {
+		if (speakerId.empty() || speakerId.equalsIgnoreCase("SPEECH"))
+			return;
+
+		ensureSpeakerPortrait(speakerId, headVariant);
+		if (speakerId.equalsIgnoreCase("PC")) {
+			_leftHeadVisible = false;
+			_rightHeadVisible = _rightHeadBitmap.isValid();
+		} else {
+			_leftHeadVisible = _leftHeadBitmap.isValid();
+			_rightHeadVisible = false;
+		}
+	}
+
+private:
+	Graphics::Screen *getActiveScreen() const {
+		return _engine.getScreen();
+	}
+
+	Common::Error waitForPointerRelease() {
+		Graphics::FrameLimiter limiter(g_system, 60);
+		while (!_engine.shouldQuit() && (isPrimaryMouseDown() || isSecondaryMouseDown())) {
+			Common::Event event;
+			while (g_system->getEventManager()->pollEvent(event)) {
+				Common::Error result = Common::kNoError;
+				if (DialogueFlowAccess::handleSystemEvent(_flow, event, result))
+					return result;
+			}
+
+			if (_entityManager)
+				(void)_entityManager->syncCursorEntityPosition(_mousePos);
+			limiter.delayBeforeSwap();
+			limiter.startFrame();
+		}
+
+		return Common::kNoError;
+	}
+
+	void drawFontString(const Graphics::Font &font, bool usesCft, const Common::String &text,
+			int x, int y, int width, byte color) {
+		Graphics::Screen *activeScreen = getActiveScreen();
+		if (!activeScreen)
+			return;
+
+		if (usesCft)
+			font.drawString(activeScreen, text, x, y, width, 0);
+		else
+			drawShadowedString(*activeScreen, font, text, x, y, width, color);
+	}
+
+	void ensureSpeakerPortrait(const Common::String &speakerId, int headVariant) {
+		if (speakerId.empty())
+			return;
+
+		const Common::String headId = buildDialogueHeadId(speakerId, headVariant);
+		IndexedBitmap *targetBitmap = speakerId.equalsIgnoreCase("PC") ? &_rightHeadBitmap : &_leftHeadBitmap;
+		Common::String *targetSpeakerId = speakerId.equalsIgnoreCase("PC") ? &_rightHeadSpeakerId : &_leftHeadSpeakerId;
+		if (targetBitmap->isValid() && targetSpeakerId->equalsIgnoreCase(headId))
+			return;
+
+		IndexedBitmap updatedBitmap;
+		if (loadDialogueHeadBitmap(_engine, speakerId, headVariant, updatedBitmap)) {
+			*targetBitmap = updatedBitmap;
+			*targetSpeakerId = headId;
+		} else {
+			warning("Harvester: unable to load dialogue head for '%s'", headId.c_str());
+		}
+	}
+
+	void drawDialogueOverlay(const IndexedBitmap *overlayBitmap,
+			const Common::Array<Common::String> *subtitleLines, const Common::Array<Common::String> *topics,
+			int hoveredTopicIndex, bool hoverOther, const Common::String *textEntryValue) {
+		Graphics::Screen *activeScreen = getActiveScreen();
+		if (!activeScreen)
+			return;
+
+		setScaledPalette(*activeScreen, _palette, _paletteBrightness);
+		blitBitmap(*activeScreen, _backdrop, 0, 0);
+		if (_leftHeadVisible && _leftHeadBitmap.isValid())
+			blitTransparentBitmap(*activeScreen, _leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
+		if (_rightHeadVisible && _rightHeadBitmap.isValid())
+			blitTransparentBitmap(*activeScreen, _rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
+		if (overlayBitmap && overlayBitmap->isValid())
+			blitTransparentBitmap(*activeScreen, *overlayBitmap, kDialogueOverlayX, kDialogueOverlayY);
+
+		if (subtitleLines) {
+			if (_subtitleFontUsesCft) {
+				drawDialogueTextLines(*activeScreen, *_subtitleFont, *subtitleLines,
+					kDialogueSubtitleTextX, kDialogueSubtitleTextY, kDialogueSubtitleTextWidth);
+			} else {
+				const int lineHeight = getDialogueTextLineHeight(*_subtitleFont);
+				for (uint i = 0; i < subtitleLines->size(); ++i) {
+					drawShadowedString(*activeScreen, *_subtitleFont, (*subtitleLines)[i],
+						kDialogueSubtitleTextX,
+						kDialogueSubtitleTextY + (int)i * lineHeight,
+						kDialogueSubtitleTextWidth, kTextColorNormal);
+				}
+			}
+		}
+
+		if (topics) {
+			const int lineHeight = getDialogueTextLineHeight(*_menuFont);
+			for (uint i = 0; i < topics->size(); ++i) {
+				const bool highlighted = (int)i == hoveredTopicIndex;
+				const Graphics::Font &font = highlighted ? *_highlightFont : *_menuFont;
+				const bool usesCft = highlighted ? _highlightFontUsesCft : _menuFontUsesCft;
+				drawFontString(font, usesCft, (*topics)[i], kDialogueKeywordTextX,
+					kDialogueKeywordTextY + (int)i * lineHeight, kDialogueKeywordTextWidth,
+					highlighted ? kTextColorNormal : kTextColorHover);
+			}
+
+			const Graphics::Font &otherFont = hoverOther ? *_highlightFont : *_menuFont;
+			const bool otherUsesCft = hoverOther ? _highlightFontUsesCft : _menuFontUsesCft;
+			drawFontString(otherFont, otherUsesCft, "Other", kDialogueOtherX, kDialogueOtherY,
+				kDialogueOtherWidth, hoverOther ? kTextColorNormal : kTextColorHover);
+		}
+
+		if (textEntryValue) {
+			drawFontString(*_highlightFont, _highlightFontUsesCft,
+				*textEntryValue + "_", kDialogueTextEntryX, kDialogueTextEntryY,
+				kDialogueKeywordTextWidth - 30, kTextColorNormal);
+		}
+
+		if (_entityManager)
+			_entityManager->drawCursor(*activeScreen);
+		activeScreen->makeAllDirty();
+		activeScreen->update();
+	}
 
-	auto parseResponseMenuLine = [&](const Common::String &responseLine,
+	void parseResponseMenuLine(const Common::String &responseLine,
 			Common::Array<DialogueResponseOptionLayout> &options, uint &totalRows) {
 		options.clear();
 		totalRows = 0;
@@ -1116,7 +1188,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			option.rowStart = (int)totalRows;
 			for (const Common::String &explicitLine : explicitLines) {
 				Common::Array<Common::String> wrappedExplicitLines;
-				wrapDialogueTextLikeNative(*menuFont, menuFontUsesCft,
+				wrapDialogueTextLikeNative(*_menuFont, _menuFontUsesCft,
 					explicitLine, kDialogueSubtitleTextWidth, wrappedExplicitLines);
 				if (wrappedExplicitLines.empty())
 					wrappedExplicitLines.push_back(explicitLine);
@@ -1171,21 +1243,16 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 
 		consumeSegment(segment);
 		flushOption();
-	};
-
-	auto buildResponseMenuLayout = [&](const Common::String &responseLine,
-			Common::Array<DialogueResponseOptionLayout> &options, uint &totalRows) {
-		parseResponseMenuLine(responseLine, options, totalRows);
-	};
+	}
 
-	auto getResponseMenuItemAt = [&](const Common::Array<DialogueResponseOptionLayout> &options,
-			uint totalRows, const Common::Point &mousePos) -> int {
+	int getResponseMenuItemAt(const Common::Array<DialogueResponseOptionLayout> &options,
+			uint totalRows, const Common::Point &mousePos) {
 		if (mousePos.x < kDialogueTopicStartX || mousePos.x > kDialogueTopicEndX)
 			return -1;
 		if (mousePos.y < kDialogueKeywordTextY)
 			return -1;
 
-		const int lineHeight = getDialogueTextLineHeight(*menuFont);
+		const int lineHeight = getDialogueTextLineHeight(*_menuFont);
 		const int row = (mousePos.y - kDialogueKeywordTextY) / MAX<int>(1, lineHeight);
 		if (row < 0 || row >= (int)totalRows)
 			return -1;
@@ -1197,36 +1264,36 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 		}
 
 		return -1;
-	};
+	}
 
-	auto drawDialogueResponseMenu = [&](const IndexedBitmap *textboxBitmap,
+	void drawDialogueResponseMenu(const IndexedBitmap *textboxBitmap,
 			const Common::Array<DialogueResponseOptionLayout> &options, int hoveredOptionIndex) {
 		Graphics::Screen *activeScreen = getActiveScreen();
 		if (!activeScreen)
 			return;
 
-		setScaledPalette(*activeScreen, palette, paletteBrightness);
-		blitBitmap(*activeScreen, backdrop, 0, 0);
-		if (leftHeadVisible && leftHeadBitmap.isValid())
-			blitTransparentBitmap(*activeScreen, leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
-		if (rightHeadVisible && rightHeadBitmap.isValid())
-			blitTransparentBitmap(*activeScreen, rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
+		setScaledPalette(*activeScreen, _palette, _paletteBrightness);
+		blitBitmap(*activeScreen, _backdrop, 0, 0);
+		if (_leftHeadVisible && _leftHeadBitmap.isValid())
+			blitTransparentBitmap(*activeScreen, _leftHeadBitmap, kDialogueLeftHeadX, kDialogueHeadY);
+		if (_rightHeadVisible && _rightHeadBitmap.isValid())
+			blitTransparentBitmap(*activeScreen, _rightHeadBitmap, kDialogueRightHeadX, kDialogueHeadY);
 		if (textboxBitmap && textboxBitmap->isValid())
 			blitTransparentBitmap(*activeScreen, *textboxBitmap, kDialogueOverlayX, kDialogueOverlayY);
 
 		const Common::String title = "Responses";
-		const Graphics::Font &titleFont = *highlightFont;
-		const bool titleUsesCft = highlightFontUsesCft;
+		const Graphics::Font &titleFont = *_highlightFont;
+		const bool titleUsesCft = _highlightFontUsesCft;
 		const int titleWidth = titleFont.getStringWidth(title);
 		const int titleX = kDialogueSubtitleTextX + MAX<int>(0, (kDialogueSubtitleTextWidth - titleWidth) / 2);
 		drawFontString(titleFont, titleUsesCft, title, titleX, 11, titleWidth, kTextColorNormal);
 
-		const int lineHeight = getDialogueTextLineHeight(*menuFont);
+		const int lineHeight = getDialogueTextLineHeight(*_menuFont);
 		int drawY = kDialogueKeywordTextY;
 		for (uint i = 0; i < options.size(); ++i) {
 			const bool highlighted = (int)i == hoveredOptionIndex;
-			const Graphics::Font &font = highlighted ? *highlightFont : *menuFont;
-			const bool usesCft = highlighted ? highlightFontUsesCft : menuFontUsesCft;
+			const Graphics::Font &font = highlighted ? *_highlightFont : *_menuFont;
+			const bool usesCft = highlighted ? _highlightFontUsesCft : _menuFontUsesCft;
 			const byte color = highlighted ? kTextColorNormal : kTextColorHover;
 
 			for (const Common::String &wrappedLine : options[i].wrappedLines) {
@@ -1236,14 +1303,14 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			}
 		}
 
-		if (entityManager)
-			entityManager->drawCursor(*activeScreen);
+		if (_entityManager)
+			_entityManager->drawCursor(*activeScreen);
 		activeScreen->makeAllDirty();
 		activeScreen->update();
-	};
+	}
 
-	auto runResponseMenuText = [&](const Common::String &responseLine, int responseLineIndex,
-			int &selectedIndex) -> Common::Error {
+	Common::Error runResponseMenuTextInternal(const Common::String &responseLine, int responseLineIndex,
+			int &selectedIndex) {
 		selectedIndex = 0;
 
 		if (responseLine.empty()) {
@@ -1258,7 +1325,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 
 		Common::Array<DialogueResponseOptionLayout> options;
 		uint totalRows = 0;
-		buildResponseMenuLayout(responseLine, options, totalRows);
+		parseResponseMenuLine(responseLine, options, totalRows);
 		if (options.empty()) {
 			if (responseLineIndex >= 0) {
 				debugC(1, kDebugDialogue, "Harvester: response menu line 0x%x (%d) produced no options",
@@ -1274,7 +1341,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			optionTexts.push_back(option.text);
 		logDialogueMenuItems("Response menu", responseLineIndex, responseLine, optionTexts);
 
-		const IndexedBitmap *textboxBitmap = art->getTextboxBitmap(resolveDialogueResponseTextboxIndex(totalRows));
+		const IndexedBitmap *textboxBitmap = _art->getTextboxBitmap(resolveDialogueResponseTextboxIndex(totalRows));
 		Common::Error releaseError = waitForPointerRelease();
 		if (releaseError.getCode() != Common::kNoError)
 			return releaseError;
@@ -1287,7 +1354,7 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 			Common::Event event;
 			while (g_system->getEventManager()->pollEvent(event)) {
 				Common::Error result = Common::kNoError;
-				if (flow.handleSystemEvent(event, result))
+				if (DialogueFlowAccess::handleSystemEvent(_flow, event, result))
 					return result;
 
 				if (event.type == Common::EVENT_KEYDOWN) {
@@ -1333,157 +1400,159 @@ Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop,
 				}
 			}
 
-			if (entityManager)
-				(void)entityManager->syncCursorEntityPosition(_mousePos);
+			if (_entityManager)
+				(void)_entityManager->syncCursorEntityPosition(_mousePos);
 			limiter.delayBeforeSwap();
 			limiter.startFrame();
 		}
-	};
-
-	auto runResponseMenu = [&](int responseLineIndex, int &selectedIndex) -> Common::Error {
-		const Common::String responseLine = text->getDialogueResponseLine(responseLineIndex);
-		return runResponseMenuText(responseLine, responseLineIndex, selectedIndex);
-	};
+	}
 
-	auto runKeywordMenu = [&](const Common::String &topicBuffer, int topicBufferLineIndex,
-			Common::String &selectedTopic,
-			DialogueRuntime::KeywordMenuSelectionState &selection) -> Common::Error {
-		selectedTopic.clear();
-		selection = DialogueRuntime::KeywordMenuSelectionState();
-		Common::Array<Common::String> topics;
-		splitDialogueMenuLine(topicBuffer, topics);
-		logDialogueMenuItems("Keyword menu", topicBufferLineIndex, topicBuffer, topics);
-		topics.push_back(genericByeTopic);
-		debugC(1, kDebugDialogue, "Harvester: keyword menu default topic='%s'", genericByeTopic.c_str());
-		bool editingOther = false;
-		Common::String typedTopic;
-		Common::Error releaseError = waitForPointerRelease();
-		if (releaseError.getCode() != Common::kNoError)
-			return releaseError;
+	HarvesterEngine &_engine;
+	Common::Point &_mousePos;
+	Flow &_flow;
+	const IndexedBitmap &_backdrop;
+	const byte *_palette;
+	const float _paletteBrightness;
+	const NpcRecord &_npc;
+	Script *_script;
+	Text *_text;
+	Art *_art;
+	EntityManager *_entityManager;
+	const Graphics::Font *_fallbackFont;
+	Common::ScopedPtr<HarvesterCftFont> _subtitleCftFont;
+	Common::ScopedPtr<HarvesterCftFont> _menuCftFont;
+	const Graphics::Font *_subtitleFont;
+	const Graphics::Font *_menuFont;
+	const Graphics::Font *_highlightFont;
+	bool _subtitleFontUsesCft;
+	bool _menuFontUsesCft;
+	bool _highlightFontUsesCft;
+	IndexedBitmap _keywordBitmap;
+	Common::String _genericByeTopic;
+	IndexedBitmap _leftHeadBitmap;
+	IndexedBitmap _rightHeadBitmap;
+	Common::String _leftHeadSpeakerId;
+	Common::String _rightHeadSpeakerId;
+	bool _leftHeadVisible;
+	bool _rightHeadVisible;
+};
 
-		Graphics::FrameLimiter limiter(g_system, 60);
-		for (;;) {
-			const int hoveredTopicIndex = editingOther ? -1 : getDialogueMenuItemAt(*menuFont, topics.size(), _mousePos);
-			const bool hoverOther = !editingOther && isDialogueOtherHit(_mousePos);
-			drawDialogueOverlay(&keywordBitmap, nullptr, &topics, hoveredTopicIndex, hoverOther,
-				editingOther ? &typedTopic : nullptr);
+} // End of anonymous namespace
 
-			Common::Event event;
-			while (g_system->getEventManager()->pollEvent(event)) {
-				Common::Error result = Common::kNoError;
-				if (flow.handleSystemEvent(event, result))
-					return result;
+DialogueSystem::DialogueSystem(HarvesterEngine &engine, Common::Point &mousePos)
+	: _engine(engine), _mousePos(mousePos) {
+	registerNpcHandlers();
+}
 
-				if (editingOther) {
-					if (event.type == Common::EVENT_KEYDOWN) {
-						if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-							typedTopic.clear();
-							editingOther = false;
-							debugC(1, kDebugDialogue, "Harvester: keyword menu cancelled Other input");
-							if (entityManager)
-								entityManager->showCursor();
-						} else if (event.kbd.keycode == Common::KEYCODE_BACKSPACE) {
-							if (!typedTopic.empty())
-								typedTopic.deleteLastChar();
-						} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
-								event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
-							if (!typedTopic.empty()) {
-								selectedTopic = typedTopic;
-								selection.fromTypedInput = true;
-								debugC(1, kDebugDialogue, "Harvester: keyword menu typed topic='%s'",
-									selectedTopic.c_str());
-								if (entityManager)
-									entityManager->showCursor();
-								return Common::kNoError;
-							}
-						} else if (event.kbd.ascii >= 0x20 && event.kbd.ascii <= 0x7e &&
-								typedTopic.size() < 79) {
-							typedTopic += (char)event.kbd.ascii;
-						}
-					}
-					continue;
-				}
+DialogueSystem::~DialogueSystem() {
+	for (uint i = 0; i < _npcHandlers.size(); ++i)
+		delete _npcHandlers[i];
+}
 
-				switch (event.type) {
-				case Common::EVENT_KEYDOWN:
-					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-						selectedTopic = genericByeTopic;
-						selection.fromEscape = true;
-						selection.fromGenericBye = true;
-						debugC(1, kDebugDialogue,
-							"Harvester: keyword menu selected default topic via escape '%s'",
-							selectedTopic.c_str());
-						return Common::kNoError;
-					}
-					break;
-				case Common::EVENT_LBUTTONDOWN: {
-					const int clickedTopic = getDialogueMenuItemAt(*menuFont, topics.size(), _mousePos);
-					if (clickedTopic >= 0) {
-						selectedTopic = topics[(uint)clickedTopic];
-						selection.fromGenericBye = clickedTopic == (int)topics.size() - 1;
-						debugC(1, kDebugDialogue, "Harvester: keyword menu selected topic[%d]='%s'",
-							clickedTopic + 1, selectedTopic.c_str());
-						return Common::kNoError;
-					}
-					if (isDialogueOtherHit(_mousePos)) {
-						typedTopic.clear();
-						editingOther = true;
-						debugC(1, kDebugDialogue, "Harvester: keyword menu entered Other input");
-						if (entityManager)
-							entityManager->hideCursor();
-					}
-					break;
-				}
-				default:
-					break;
-				}
-			}
+bool DialogueSystem::hasRoomNpcHandler(const Common::String &npcName) const {
+	for (uint i = 0; i < _npcHandlers.size(); ++i) {
+		if (_npcHandlers[i]->matchesNpc(npcName))
+			return true;
+	}
 
-			if (entityManager)
-				(void)entityManager->syncCursorEntityPosition(_mousePos);
-			limiter.delayBeforeSwap();
-			limiter.startFrame();
-		}
-	};
+	return false;
+}
 
-	auto matchesResponseLine = [&](const Common::String &selectedTopic, int responseLineIndex) {
-		const Common::String topicText = text->getDialogueResponseLine(responseLineIndex);
-		return !topicText.empty() && selectedTopic.equalsIgnoreCase(topicText);
-	};
+void DialogueSystem::registerNpcHandlers() {
+	_npcHandlers.push_back(new AuthorityDialogueHandler());
+	_npcHandlers.push_back(new BeggarDialogueHandler());
+	_npcHandlers.push_back(new BoyleDialogueHandler());
+	_npcHandlers.push_back(new BusterDialogueHandler());
+	_npcHandlers.push_back(new ButcherDialogueHandler());
+	_npcHandlers.push_back(new DwayneDialogueHandler());
+	_npcHandlers.push_back(new EdnaDialogueHandler());
+	_npcHandlers.push_back(new Fireman1DialogueHandler());
+	_npcHandlers.push_back(new Fireman2DialogueHandler());
+	_npcHandlers.push_back(new ChessmasterDialogueHandler());
+	_npcHandlers.push_back(new CloakAtndDialogueHandler());
+	_npcHandlers.push_back(new CuratorDialogueHandler());
+	_npcHandlers.push_back(new DadDialogueHandler());
+	_npcHandlers.push_back(new DarkWomanDialogueHandler());
+	_npcHandlers.push_back(new JimmyDialogueHandler());
+	_npcHandlers.push_back(new InquisitorDialogueHandler());
+	_npcHandlers.push_back(new KarinDialogueHandler());
+	_npcHandlers.push_back(new WaspWomanDialogueHandler());
+	_npcHandlers.push_back(new GladiatorDialogueHandler());
+	_npcHandlers.push_back(new LibrarianDialogueHandler());
+	_npcHandlers.push_back(new LoomisDialogueHandler());
+	_npcHandlers.push_back(new McknightDialogueHandler());
+	_npcHandlers.push_back(new MembDirDialogueHandler());
+	_npcHandlers.push_back(new MaintManDialogueHandler());
+	_npcHandlers.push_back(new MadamDialogueHandler());
+	_npcHandlers.push_back(new MomDialogueHandler());
+	_npcHandlers.push_back(new MotherDialogueHandler());
+	_npcHandlers.push_back(new MoynahanDialogueHandler());
+	_npcHandlers.push_back(new MrPottsDialogueHandler());
+	_npcHandlers.push_back(new MrsPottsDialogueHandler());
+	_npcHandlers.push_back(new LodgeChefDialogueHandler());
+	_npcHandlers.push_back(new NudeManDialogueHandler());
+	_npcHandlers.push_back(new ParsonsDialogueHandler());
+	_npcHandlers.push_back(new PastorelliDialogueHandler());
+	_npcHandlers.push_back(new PhelpsDialogueHandler());
+	_npcHandlers.push_back(new PriestDialogueHandler());
+	_npcHandlers.push_back(new RyderDialogueHandler());
+	_npcHandlers.push_back(new ValetDialogueHandler());
+	_npcHandlers.push_back(new VetDialogueHandler());
+	_npcHandlers.push_back(new SparkyDialogueHandler());
+	_npcHandlers.push_back(new StephanieDialogueHandler());
+	_npcHandlers.push_back(new SwellDialogueHandler());
+	_npcHandlers.push_back(new WhaleyDialogueHandler());
+	_npcHandlers.push_back(new HankDialogueHandler());
+	_npcHandlers.push_back(new HerrillDialogueHandler());
+	_npcHandlers.push_back(new JohnsonDialogueHandler());
+	_npcHandlers.push_back(new PtaMomDialogueHandler());
 
-	auto matchesAnyResponseLine = [&](const Common::String &selectedTopic, const int *responseLineIndices,
-			uint responseLineCount) {
-		for (uint i = 0; i < responseLineCount; ++i) {
-			if (matchesResponseLine(selectedTopic, responseLineIndices[i]))
-				return true;
-		}
+	_npcHandlers.push_back(new SergeantDialogueHandler());
+	_npcHandlers.push_back(new StubNpcDialogueHandler("PTA_MOM"));
+}
 
-		return false;
-	};
+void DialogueSystem::resetRoomNpcDialogueState() {
+	_sharedDialogueState = DialogueSharedState();
+	for (uint i = 0; i < _npcHandlers.size(); ++i)
+		_npcHandlers[i]->resetState();
+}
 
-	auto assignTopicBuffer = [&](Common::String &topicBuffer, int &topicBufferLineIndex,
-			int responseLineIndex, const char *label) {
-		topicBuffer = text->getDialogueResponseLine(responseLineIndex);
-		topicBufferLineIndex = responseLineIndex;
+void DialogueSystem::syncRuntimeSaveState(Common::Serializer &s) {
+	syncDialogueSharedState(s, _sharedDialogueState);
+	for (uint i = 0; i < _npcHandlers.size(); ++i)
+		_npcHandlers[i]->syncState(s);
+	if (s.isLoading()) {
+		for (uint i = 0; i < _npcHandlers.size(); ++i)
+			_npcHandlers[i]->migrateSharedState(_sharedDialogueState);
+	}
+}
 
-		Common::Array<Common::String> topics;
-		splitDialogueMenuLine(topicBuffer, topics);
-		logDialogueMenuItems(label, responseLineIndex, topicBuffer, topics);
-	};
+Common::Error DialogueSystem::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, const NpcRecord &npc, const Common::String &usedItemName,
+		Flow &flow) {
+	Script *script = _engine.getScript();
+	Text *text = _engine.getText();
+	if (!script || !text || !backdrop.isValid())
+		return Common::kReadingFailed;
+	if (!hasRoomNpcHandler(npc.npcName)) {
+		debugC(1, kDebugDialogue,
+			"Harvester: blocked room NPC dialogue npc='%s' reason='no handler'",
+			npc.npcName.c_str());
+		return Common::kNoError;
+	}
+	if (!script->isNamedNpcDeathTypeClear(npc.npcName)) {
+		const NpcRecord *runtimeNpc = script->findRuntimeNpcRecord(npc.npcName);
+		debugC(1, kDebugDialogue,
+			"Harvester: blocked room NPC dialogue npc='%s' reason='death queued' damage_type=%d",
+			npc.npcName.c_str(), runtimeNpc ? runtimeNpc->deathDamageType : -1);
+		return Common::kNoError;
+	}
 
+	RoomNpcDialogueSession session(_engine, _mousePos, flow, backdrop, palette, paletteBrightness, npc);
+	if (!session.isValid())
+		return Common::kReadingFailed;
 	DialogueRuntime runtime(
-		_engine, *script, *text, flow, npc.roomName, genericByeTopic,
-		playDialogueLineWithVariant, playDialogueLine, playDialogueEntrySequence,
-		playDialogueFst, clearScreenToBlack, showCdChangePrompt, runKeywordMenu, runResponseMenu,
-		[&](const Common::String &responseLine, int &selectedIndex) {
-			return runResponseMenuText(responseLine, -1, selectedIndex);
-		},
-		runGameOverScreen,
-		assignTopicBuffer,
-		matchesResponseLine, matchesAnyResponseLine, queueDialogueInteractionIfNeeded,
-		applyImmediateDialogueInteractionEffects,
-		[&](int maxValue) { return _engine.getRandomNumber(maxValue); },
-		setActiveSpeakerPortrait);
+		_engine, *script, *text, flow, npc.roomName, session.getGenericByeTopic(), session);
 
 	for (uint i = 0; i < _npcHandlers.size(); ++i) {
 		if (_npcHandlers[i]->matchesNpc(npc.npcName))
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 3959d2ff298..06ca9406937 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -20,7 +20,6 @@
  */
 
 #include <math.h>
-#include <memory>
 
 #include "harvester/flow.h"
 
@@ -30,6 +29,7 @@
 #include "common/events.h"
 #include "common/formats/ini-file.h"
 #include "common/memstream.h"
+#include "common/ptr.h"
 #include "common/serializer.h"
 #include "common/system.h"
 #include "graphics/blit.h"
@@ -1473,7 +1473,7 @@ Common::Error Flow::runTownMapSelector(const Common::String &mapEntryName,
 	ResourceManager *resources = _engine.getResources();
 	Graphics::Screen *screen = _engine.getScreen();
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
-	std::unique_ptr<HarvesterCftFont> townMapFont;
+	Common::ScopedPtr<HarvesterCftFont> townMapFont;
 	if (const CftFontResource *townMapFontResource = findStartupFontByName(_engine, "TEXTFONT")) {
 		townMapFont.reset(new HarvesterCftFont(*townMapFontResource));
 		if (townMapFont->isValid())
diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index a059149226f..4c406f0aacb 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -39,6 +39,7 @@ struct Event;
 namespace Harvester {
 
 class HarvesterEngine;
+class DialogueFlowAccess;
 struct IndexedBitmap;
 
 class Flow {
@@ -52,6 +53,7 @@ public:
 
 private:
 	friend class HarvesterEngine;
+	friend class DialogueFlowAccess;
 	friend class DialogueSystem;
 	friend class InventorySystem;
 	friend class MenuSystem;
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 021c59699f9..40cf9dc06a3 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -142,8 +142,7 @@ void HarvesterEngine::setGammaLevel(int level) {
 }
 
 const Common::String &HarvesterEngine::getMusicPath() const {
-	static const Common::String kEmptyMusicPath;
-	return _media ? _media->getMusicPath() : kEmptyMusicPath;
+	return _media ? _media->getMusicPath() : _emptyMusicPath;
 }
 
 bool HarvesterEngine::isMusicPlaying() const {
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index f3d90e97c07..a6b51da29cb 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -156,8 +156,12 @@ private:
 	Flow *_activeFlow = nullptr;
 	bool _combatDebugEnabled = false;
 	bool _roomDebugEnabled = false;
+	Common::String _emptyMusicPath;
 };
 
+// Global engine hook used by existing Harvester helpers and macros.
+// It is assigned in HarvesterEngine::HarvesterEngine() and cleared in
+// HarvesterEngine::~HarvesterEngine() so restart-to-launcher resets it.
 extern HarvesterEngine *g_engine;
 #define SHOULD_QUIT ::Harvester::g_engine->shouldQuit()
 
diff --git a/engines/harvester/npc/authority_dialogue.cpp b/engines/harvester/npc/authority_dialogue.cpp
index 482cdc1194a..075dd31aaa0 100644
--- a/engines/harvester/npc/authority_dialogue.cpp
+++ b/engines/harvester/npc/authority_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/authority_dialogue.h"
 
diff --git a/engines/harvester/npc/beggar_dialogue.cpp b/engines/harvester/npc/beggar_dialogue.cpp
index 601406a853b..d1ac87abc74 100644
--- a/engines/harvester/npc/beggar_dialogue.cpp
+++ b/engines/harvester/npc/beggar_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/beggar_dialogue.h"
 
diff --git a/engines/harvester/npc/boyle_dialogue.cpp b/engines/harvester/npc/boyle_dialogue.cpp
index b0c2bb1dfe7..5f6946ad439 100644
--- a/engines/harvester/npc/boyle_dialogue.cpp
+++ b/engines/harvester/npc/boyle_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/boyle_dialogue.h"
 
diff --git a/engines/harvester/npc/buster_dialogue.cpp b/engines/harvester/npc/buster_dialogue.cpp
index 4ae09aad0b1..d3f2e85fe71 100644
--- a/engines/harvester/npc/buster_dialogue.cpp
+++ b/engines/harvester/npc/buster_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "graphics/screen.h"
 #include "harvester/harvester.h"
diff --git a/engines/harvester/npc/butcher_dialogue.cpp b/engines/harvester/npc/butcher_dialogue.cpp
index 4f92bb10e60..e1f76666d85 100644
--- a/engines/harvester/npc/butcher_dialogue.cpp
+++ b/engines/harvester/npc/butcher_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/butcher_dialogue.h"
 
diff --git a/engines/harvester/npc/chessmaster_dialogue.cpp b/engines/harvester/npc/chessmaster_dialogue.cpp
index d70478818ab..56b617d8c5e 100644
--- a/engines/harvester/npc/chessmaster_dialogue.cpp
+++ b/engines/harvester/npc/chessmaster_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/chessmaster_dialogue.h"
 
diff --git a/engines/harvester/npc/cloak_atnd_dialogue.cpp b/engines/harvester/npc/cloak_atnd_dialogue.cpp
index be81d134113..3f7bf65f54d 100644
--- a/engines/harvester/npc/cloak_atnd_dialogue.cpp
+++ b/engines/harvester/npc/cloak_atnd_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/cloak_atnd_dialogue.h"
 
diff --git a/engines/harvester/npc/curator_dialogue.cpp b/engines/harvester/npc/curator_dialogue.cpp
index 88973117124..571809353aa 100644
--- a/engines/harvester/npc/curator_dialogue.cpp
+++ b/engines/harvester/npc/curator_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/curator_dialogue.h"
 
diff --git a/engines/harvester/npc/dad_dialogue.cpp b/engines/harvester/npc/dad_dialogue.cpp
index 31b5bc2d947..3bfd5cab4ef 100644
--- a/engines/harvester/npc/dad_dialogue.cpp
+++ b/engines/harvester/npc/dad_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/dad_dialogue.h"
 
diff --git a/engines/harvester/npc/dark_woman_dialogue.cpp b/engines/harvester/npc/dark_woman_dialogue.cpp
index 8c1c76ff277..6f45d92b5dd 100644
--- a/engines/harvester/npc/dark_woman_dialogue.cpp
+++ b/engines/harvester/npc/dark_woman_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/dark_woman_dialogue.h"
 
diff --git a/engines/harvester/npc/dialogue_runtime.h b/engines/harvester/npc/dialogue_runtime.h
index 322ed0f121c..1549e601573 100644
--- a/engines/harvester/npc/dialogue_runtime.h
+++ b/engines/harvester/npc/dialogue_runtime.h
@@ -1,8 +1,6 @@
 #ifndef HARVESTER_NPC_DIALOGUE_RUNTIME_H
 #define HARVESTER_NPC_DIALOGUE_RUNTIME_H
 
-#include <functional>
-
 #include "common/error.h"
 #include "common/str.h"
 #include "harvester/script.h"
@@ -19,73 +17,55 @@ struct DialogueLineEntry {
 	int headVariant;
 };
 
-class DialogueRuntime {
+struct DialogueKeywordMenuSelectionState {
+	DialogueKeywordMenuSelectionState()
+		: fromTypedInput(false), fromEscape(false), fromGenericBye(false) {
+	}
+
+	bool fromTypedInput;
+	bool fromEscape;
+	bool fromGenericBye;
+};
+
+class DialogueRuntimeCallbacks {
 public:
-	struct KeywordMenuSelectionState {
-		KeywordMenuSelectionState()
-			: fromTypedInput(false), fromEscape(false), fromGenericBye(false) {
-		}
+	virtual ~DialogueRuntimeCallbacks() {}
 
-		bool fromTypedInput;
-		bool fromEscape;
-		bool fromGenericBye;
-	};
+	virtual Common::Error playDialogueLineWithVariant(int wavId, const Common::String &speakerId,
+		int headVariant) = 0;
+	virtual Common::Error playDialogueLine(int wavId, const Common::String &speakerId) = 0;
+	virtual Common::Error playDialogueEntrySequence(const DialogueLineEntry *lines, uint count) = 0;
+	virtual Common::Error playDialogueFst(const Common::String &path) = 0;
+	virtual Common::Error clearScreenToBlack() = 0;
+	virtual Common::Error showCdChangePrompt(int discNumber) = 0;
+	virtual Common::Error runKeywordMenu(const Common::String &topicBuffer, int topicBufferLineIndex,
+		Common::String &selectedTopic, DialogueKeywordMenuSelectionState &selection) = 0;
+	virtual Common::Error runResponseMenu(int responseLineIndex, int &responseIndex) = 0;
+	virtual Common::Error runResponseMenuText(const Common::String &responseLine, int &responseIndex) = 0;
+	virtual Common::Error runGameOverScreen() = 0;
+	virtual void assignTopicBuffer(Common::String &topicBuffer, int &topicBufferLineIndex,
+		int responseLineIndex, const char *label) = 0;
+	virtual bool matchesResponseLine(const Common::String &selectedTopic, int responseLineIndex) = 0;
+	virtual bool matchesAnyResponseLine(const Common::String &selectedTopic,
+		const int *responseLineIndices, uint responseLineCount) = 0;
+	virtual void queueDialogueInteractionIfNeeded(const InteractionResult &interaction) = 0;
+	virtual void applyImmediateDialogueInteractionEffects(InteractionResult &interaction) = 0;
+	virtual int getRandomNumber(int maxValue) = 0;
+	virtual void setActiveSpeakerPortrait(const Common::String &speakerId, int headVariant) = 0;
+};
 
-	typedef std::function<Common::Error(int, const Common::String &, int)> PlayDialogueLineWithVariantFn;
-	typedef std::function<Common::Error(int, const Common::String &)> PlayDialogueLineFn;
-	typedef std::function<Common::Error(const DialogueLineEntry *, uint)> PlayDialogueEntrySequenceFn;
-	typedef std::function<Common::Error(const Common::String &)> PlayDialogueFstFn;
-	typedef std::function<Common::Error()> ClearScreenToBlackFn;
-	typedef std::function<Common::Error(int)> ShowCdChangePromptFn;
-	typedef std::function<Common::Error(const Common::String &, int, Common::String &,
-			KeywordMenuSelectionState &)> RunKeywordMenuFn;
-	typedef std::function<Common::Error(int, int &)> RunResponseMenuFn;
-	typedef std::function<Common::Error(const Common::String &, int &)> RunResponseMenuTextFn;
-	typedef std::function<Common::Error()> RunGameOverScreenFn;
-	typedef std::function<void(Common::String &, int &, int, const char *)> AssignTopicBufferFn;
-	typedef std::function<bool(const Common::String &, int)> MatchesResponseLineFn;
-	typedef std::function<bool(const Common::String &, const int *, uint)> MatchesAnyResponseLineFn;
-	typedef std::function<void(const InteractionResult &)> QueueDialogueInteractionIfNeededFn;
-	typedef std::function<void(InteractionResult &)> ApplyImmediateDialogueInteractionEffectsFn;
-	typedef std::function<int(int)> GetRandomNumberFn;
-	typedef std::function<void(const Common::String &, int)> SetActiveSpeakerPortraitFn;
+class DialogueRuntime {
+public:
+	typedef DialogueKeywordMenuSelectionState KeywordMenuSelectionState;
 
 	DialogueRuntime(HarvesterEngine &engine, Script &script, Text &text,
 			Flow &flow, const Common::String &currentRoomName,
 			const Common::String &genericByeTopic,
-			const PlayDialogueLineWithVariantFn &playDialogueLineWithVariant,
-			const PlayDialogueLineFn &playDialogueLine,
-			const PlayDialogueEntrySequenceFn &playDialogueEntrySequence,
-			const PlayDialogueFstFn &playDialogueFst,
-			const ClearScreenToBlackFn &clearScreenToBlack,
-			const ShowCdChangePromptFn &showCdChangePrompt,
-			const RunKeywordMenuFn &runKeywordMenu,
-			const RunResponseMenuFn &runResponseMenu,
-			const RunResponseMenuTextFn &runResponseMenuText,
-			const RunGameOverScreenFn &runGameOverScreen,
-			const AssignTopicBufferFn &assignTopicBuffer,
-			const MatchesResponseLineFn &matchesResponseLine,
-			const MatchesAnyResponseLineFn &matchesAnyResponseLine,
-			const QueueDialogueInteractionIfNeededFn &queueDialogueInteractionIfNeeded,
-			const ApplyImmediateDialogueInteractionEffectsFn &applyImmediateDialogueInteractionEffects,
-			const GetRandomNumberFn &getRandomNumber,
-			const SetActiveSpeakerPortraitFn &setActiveSpeakerPortrait)
+			DialogueRuntimeCallbacks &callbacks)
 		: _engine(engine), _script(script), _text(text),
 		  _flow(flow), _currentRoomName(currentRoomName),
 		  _genericByeTopic(genericByeTopic),
-		  _playDialogueLineWithVariant(playDialogueLineWithVariant),
-		  _playDialogueLine(playDialogueLine), _playDialogueEntrySequence(playDialogueEntrySequence),
-		  _playDialogueFst(playDialogueFst), _clearScreenToBlack(clearScreenToBlack),
-		  _showCdChangePrompt(showCdChangePrompt), _runKeywordMenu(runKeywordMenu),
-		  _runResponseMenu(runResponseMenu), _runResponseMenuText(runResponseMenuText),
-		  _runGameOverScreen(runGameOverScreen),
-		  _assignTopicBuffer(assignTopicBuffer),
-		  _matchesResponseLine(matchesResponseLine),
-		  _matchesAnyResponseLine(matchesAnyResponseLine),
-		  _queueDialogueInteractionIfNeeded(queueDialogueInteractionIfNeeded),
-		  _applyImmediateDialogueInteractionEffects(applyImmediateDialogueInteractionEffects),
-		  _getRandomNumber(getRandomNumber),
-		  _setActiveSpeakerPortrait(setActiveSpeakerPortrait) {
+		  _callbacks(callbacks) {
 	}
 
 	HarvesterEngine &engine() const { return _engine; }
@@ -97,51 +77,51 @@ public:
 
 	Common::Error playDialogueLineWithVariant(int wavId, const Common::String &speakerId,
 			int headVariant) const {
-		return _playDialogueLineWithVariant(wavId, speakerId, headVariant);
+		return _callbacks.playDialogueLineWithVariant(wavId, speakerId, headVariant);
 	}
 	Common::Error playDialogueLine(int wavId, const Common::String &speakerId) const {
-		return _playDialogueLine(wavId, speakerId);
+		return _callbacks.playDialogueLine(wavId, speakerId);
 	}
 	Common::Error playDialogueEntrySequence(const DialogueLineEntry *lines, uint count) const {
-		return _playDialogueEntrySequence(lines, count);
+		return _callbacks.playDialogueEntrySequence(lines, count);
 	}
-	Common::Error playDialogueFst(const Common::String &path) const { return _playDialogueFst(path); }
-	Common::Error clearScreenToBlack() const { return _clearScreenToBlack(); }
-	Common::Error showCdChangePrompt(int discNumber) const { return _showCdChangePrompt(discNumber); }
+	Common::Error playDialogueFst(const Common::String &path) const { return _callbacks.playDialogueFst(path); }
+	Common::Error clearScreenToBlack() const { return _callbacks.clearScreenToBlack(); }
+	Common::Error showCdChangePrompt(int discNumber) const { return _callbacks.showCdChangePrompt(discNumber); }
 	Common::Error runKeywordMenu(const Common::String &topicBuffer, int topicBufferLineIndex,
 			Common::String &selectedTopic) const {
 		_lastKeywordSelection = KeywordMenuSelectionState();
-		return _runKeywordMenu(topicBuffer, topicBufferLineIndex, selectedTopic, _lastKeywordSelection);
+		return _callbacks.runKeywordMenu(topicBuffer, topicBufferLineIndex, selectedTopic, _lastKeywordSelection);
 	}
 	Common::Error runResponseMenu(int responseLineIndex, int &responseIndex) const {
-		return _runResponseMenu(responseLineIndex, responseIndex);
+		return _callbacks.runResponseMenu(responseLineIndex, responseIndex);
 	}
 	Common::Error runResponseMenuText(const Common::String &responseLine, int &responseIndex) const {
-		return _runResponseMenuText(responseLine, responseIndex);
+		return _callbacks.runResponseMenuText(responseLine, responseIndex);
 	}
-	Common::Error runGameOverScreen() const { return _runGameOverScreen(); }
+	Common::Error runGameOverScreen() const { return _callbacks.runGameOverScreen(); }
 	void assignTopicBuffer(Common::String &topicBuffer, int &topicBufferLineIndex,
 			int responseLineIndex, const char *label) const {
-		_assignTopicBuffer(topicBuffer, topicBufferLineIndex, responseLineIndex, label);
+		_callbacks.assignTopicBuffer(topicBuffer, topicBufferLineIndex, responseLineIndex, label);
 	}
 	bool matchesResponseLine(const Common::String &selectedTopic, int responseLineIndex) const {
-		return _matchesResponseLine(selectedTopic, responseLineIndex);
+		return _callbacks.matchesResponseLine(selectedTopic, responseLineIndex);
 	}
 	bool matchesAnyResponseLine(const Common::String &selectedTopic,
 			const int *responseLineIndices, uint responseLineCount) const {
-		return _matchesAnyResponseLine(selectedTopic, responseLineIndices, responseLineCount);
+		return _callbacks.matchesAnyResponseLine(selectedTopic, responseLineIndices, responseLineCount);
 	}
 	bool lastKeywordSelectionWasTypedInput() const { return _lastKeywordSelection.fromTypedInput; }
 	bool lastKeywordSelectionWasGenericBye() const { return _lastKeywordSelection.fromGenericBye; }
 	void queueDialogueInteractionIfNeeded(const InteractionResult &interaction) const {
-		_queueDialogueInteractionIfNeeded(interaction);
+		_callbacks.queueDialogueInteractionIfNeeded(interaction);
 	}
 	void applyImmediateDialogueInteractionEffects(InteractionResult &interaction) const {
-		_applyImmediateDialogueInteractionEffects(interaction);
+		_callbacks.applyImmediateDialogueInteractionEffects(interaction);
 	}
-	int getRandomNumber(int maxValue) const { return _getRandomNumber(maxValue); }
+	int getRandomNumber(int maxValue) const { return _callbacks.getRandomNumber(maxValue); }
 	void setActiveSpeakerPortrait(const Common::String &speakerId, int headVariant) const {
-		_setActiveSpeakerPortrait(speakerId, headVariant);
+		_callbacks.setActiveSpeakerPortrait(speakerId, headVariant);
 	}
 
 private:
@@ -151,24 +131,8 @@ private:
 	Flow &_flow;
 	const Common::String &_currentRoomName;
 	const Common::String &_genericByeTopic;
-	PlayDialogueLineWithVariantFn _playDialogueLineWithVariant;
-	PlayDialogueLineFn _playDialogueLine;
-	PlayDialogueEntrySequenceFn _playDialogueEntrySequence;
-	PlayDialogueFstFn _playDialogueFst;
-	ClearScreenToBlackFn _clearScreenToBlack;
-	ShowCdChangePromptFn _showCdChangePrompt;
-	RunKeywordMenuFn _runKeywordMenu;
-	RunResponseMenuFn _runResponseMenu;
-	RunResponseMenuTextFn _runResponseMenuText;
-	RunGameOverScreenFn _runGameOverScreen;
-	AssignTopicBufferFn _assignTopicBuffer;
-	MatchesResponseLineFn _matchesResponseLine;
-	MatchesAnyResponseLineFn _matchesAnyResponseLine;
+	DialogueRuntimeCallbacks &_callbacks;
 	mutable KeywordMenuSelectionState _lastKeywordSelection;
-	QueueDialogueInteractionIfNeededFn _queueDialogueInteractionIfNeeded;
-	ApplyImmediateDialogueInteractionEffectsFn _applyImmediateDialogueInteractionEffects;
-	GetRandomNumberFn _getRandomNumber;
-	SetActiveSpeakerPortraitFn _setActiveSpeakerPortrait;
 };
 
 } // End of namespace Harvester
diff --git a/engines/harvester/npc/dwayne_dialogue.cpp b/engines/harvester/npc/dwayne_dialogue.cpp
index 7e56d58249a..f24b50c7bf6 100644
--- a/engines/harvester/npc/dwayne_dialogue.cpp
+++ b/engines/harvester/npc/dwayne_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/dwayne_dialogue.h"
 
diff --git a/engines/harvester/npc/edna_dialogue.cpp b/engines/harvester/npc/edna_dialogue.cpp
index ea9c21d1a70..8c569f376d4 100644
--- a/engines/harvester/npc/edna_dialogue.cpp
+++ b/engines/harvester/npc/edna_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/edna_dialogue.h"
 
diff --git a/engines/harvester/npc/fireman1_dialogue.cpp b/engines/harvester/npc/fireman1_dialogue.cpp
index 6504c030823..f6812ff0ee8 100644
--- a/engines/harvester/npc/fireman1_dialogue.cpp
+++ b/engines/harvester/npc/fireman1_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/fireman1_dialogue.h"
 
diff --git a/engines/harvester/npc/fireman2_dialogue.cpp b/engines/harvester/npc/fireman2_dialogue.cpp
index a815e7e25ac..ea4c588fae2 100644
--- a/engines/harvester/npc/fireman2_dialogue.cpp
+++ b/engines/harvester/npc/fireman2_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/fireman2_dialogue.h"
 
diff --git a/engines/harvester/npc/gladiator_dialogue.cpp b/engines/harvester/npc/gladiator_dialogue.cpp
index c4026cff0a0..aeeeb1df8dd 100644
--- a/engines/harvester/npc/gladiator_dialogue.cpp
+++ b/engines/harvester/npc/gladiator_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/gladiator_dialogue.h"
 
diff --git a/engines/harvester/npc/hank_dialogue.cpp b/engines/harvester/npc/hank_dialogue.cpp
index 45ad80694ad..9a6ce05d072 100644
--- a/engines/harvester/npc/hank_dialogue.cpp
+++ b/engines/harvester/npc/hank_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/hank_dialogue.h"
 
diff --git a/engines/harvester/npc/herrill_dialogue.cpp b/engines/harvester/npc/herrill_dialogue.cpp
index 34f4d91dca0..885837f37c9 100644
--- a/engines/harvester/npc/herrill_dialogue.cpp
+++ b/engines/harvester/npc/herrill_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/herrill_dialogue.h"
 
diff --git a/engines/harvester/npc/inquisitor_dialogue.cpp b/engines/harvester/npc/inquisitor_dialogue.cpp
index 74e85227e09..71d5ab08fdb 100644
--- a/engines/harvester/npc/inquisitor_dialogue.cpp
+++ b/engines/harvester/npc/inquisitor_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/inquisitor_dialogue.h"
 
diff --git a/engines/harvester/npc/jimmy_dialogue.cpp b/engines/harvester/npc/jimmy_dialogue.cpp
index 6f42e4a1b27..12fe745085c 100644
--- a/engines/harvester/npc/jimmy_dialogue.cpp
+++ b/engines/harvester/npc/jimmy_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/jimmy_dialogue.h"
 
diff --git a/engines/harvester/npc/johnson_dialogue.cpp b/engines/harvester/npc/johnson_dialogue.cpp
index 601dd1c5801..02829539650 100644
--- a/engines/harvester/npc/johnson_dialogue.cpp
+++ b/engines/harvester/npc/johnson_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/johnson_dialogue.h"
 
diff --git a/engines/harvester/npc/karin_dialogue.cpp b/engines/harvester/npc/karin_dialogue.cpp
index 3c413373bc8..a7c071f2628 100644
--- a/engines/harvester/npc/karin_dialogue.cpp
+++ b/engines/harvester/npc/karin_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/karin_dialogue.h"
 
diff --git a/engines/harvester/npc/librarian_dialogue.cpp b/engines/harvester/npc/librarian_dialogue.cpp
index fe6fec9c3b2..3de15fb9664 100644
--- a/engines/harvester/npc/librarian_dialogue.cpp
+++ b/engines/harvester/npc/librarian_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/librarian_dialogue.h"
 
diff --git a/engines/harvester/npc/lodge_chef_dialogue.cpp b/engines/harvester/npc/lodge_chef_dialogue.cpp
index f1ff549d646..b385e4dc225 100644
--- a/engines/harvester/npc/lodge_chef_dialogue.cpp
+++ b/engines/harvester/npc/lodge_chef_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/lodge_chef_dialogue.h"
 
diff --git a/engines/harvester/npc/loomis_dialogue.cpp b/engines/harvester/npc/loomis_dialogue.cpp
index 5b1ccd684af..73388a0360b 100644
--- a/engines/harvester/npc/loomis_dialogue.cpp
+++ b/engines/harvester/npc/loomis_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/loomis_dialogue.h"
 
diff --git a/engines/harvester/npc/madam_dialogue.cpp b/engines/harvester/npc/madam_dialogue.cpp
index 47209be6683..4833ed9fad1 100644
--- a/engines/harvester/npc/madam_dialogue.cpp
+++ b/engines/harvester/npc/madam_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/madam_dialogue.h"
 
diff --git a/engines/harvester/npc/maint_man_dialogue.cpp b/engines/harvester/npc/maint_man_dialogue.cpp
index b4e5ceffb7a..d95720e643c 100644
--- a/engines/harvester/npc/maint_man_dialogue.cpp
+++ b/engines/harvester/npc/maint_man_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/maint_man_dialogue.h"
 
diff --git a/engines/harvester/npc/mcknight_dialogue.cpp b/engines/harvester/npc/mcknight_dialogue.cpp
index fcd7ba10e5e..920e80308b3 100644
--- a/engines/harvester/npc/mcknight_dialogue.cpp
+++ b/engines/harvester/npc/mcknight_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/mcknight_dialogue.h"
 
diff --git a/engines/harvester/npc/memb_dir_dialogue.cpp b/engines/harvester/npc/memb_dir_dialogue.cpp
index 2fe87d8ef3d..83db47e0b09 100644
--- a/engines/harvester/npc/memb_dir_dialogue.cpp
+++ b/engines/harvester/npc/memb_dir_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/memb_dir_dialogue.h"
 
diff --git a/engines/harvester/npc/mom_dialogue.cpp b/engines/harvester/npc/mom_dialogue.cpp
index c2e46eb39a8..608b1ee9434 100644
--- a/engines/harvester/npc/mom_dialogue.cpp
+++ b/engines/harvester/npc/mom_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/mom_dialogue.h"
 
diff --git a/engines/harvester/npc/mother_dialogue.cpp b/engines/harvester/npc/mother_dialogue.cpp
index f6c4c85bc3f..e21c70bcb19 100644
--- a/engines/harvester/npc/mother_dialogue.cpp
+++ b/engines/harvester/npc/mother_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/mother_dialogue.h"
 
diff --git a/engines/harvester/npc/moynahan_dialogue.cpp b/engines/harvester/npc/moynahan_dialogue.cpp
index f1b7086b0b5..b421ddf7b14 100644
--- a/engines/harvester/npc/moynahan_dialogue.cpp
+++ b/engines/harvester/npc/moynahan_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/moynahan_dialogue.h"
 
diff --git a/engines/harvester/npc/mr_potts_dialogue.cpp b/engines/harvester/npc/mr_potts_dialogue.cpp
index cdc6fa55506..fa6f30cf0e7 100644
--- a/engines/harvester/npc/mr_potts_dialogue.cpp
+++ b/engines/harvester/npc/mr_potts_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/mr_potts_dialogue.h"
 
diff --git a/engines/harvester/npc/mrs_potts_dialogue.cpp b/engines/harvester/npc/mrs_potts_dialogue.cpp
index 60e54a5de32..1f3555828d9 100644
--- a/engines/harvester/npc/mrs_potts_dialogue.cpp
+++ b/engines/harvester/npc/mrs_potts_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/mrs_potts_dialogue.h"
 
diff --git a/engines/harvester/npc/nude_man_dialogue.cpp b/engines/harvester/npc/nude_man_dialogue.cpp
index 5a95cd64ff6..3c922087f8d 100644
--- a/engines/harvester/npc/nude_man_dialogue.cpp
+++ b/engines/harvester/npc/nude_man_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/nude_man_dialogue.h"
 
diff --git a/engines/harvester/npc/parsons_dialogue.cpp b/engines/harvester/npc/parsons_dialogue.cpp
index 34df03988e2..6397cf60cae 100644
--- a/engines/harvester/npc/parsons_dialogue.cpp
+++ b/engines/harvester/npc/parsons_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/parsons_dialogue.h"
 
diff --git a/engines/harvester/npc/pastorelli_dialogue.cpp b/engines/harvester/npc/pastorelli_dialogue.cpp
index 90e215ae8e3..087b9687367 100644
--- a/engines/harvester/npc/pastorelli_dialogue.cpp
+++ b/engines/harvester/npc/pastorelli_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/pastorelli_dialogue.h"
 
diff --git a/engines/harvester/npc/phelps_dialogue.cpp b/engines/harvester/npc/phelps_dialogue.cpp
index 00eb6d80eca..d9f8f17843b 100644
--- a/engines/harvester/npc/phelps_dialogue.cpp
+++ b/engines/harvester/npc/phelps_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/phelps_dialogue.h"
 
diff --git a/engines/harvester/npc/priest_dialogue.cpp b/engines/harvester/npc/priest_dialogue.cpp
index c8ec77e229e..714b4e5358c 100644
--- a/engines/harvester/npc/priest_dialogue.cpp
+++ b/engines/harvester/npc/priest_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/priest_dialogue.h"
 
diff --git a/engines/harvester/npc/pta_mom_dialogue.cpp b/engines/harvester/npc/pta_mom_dialogue.cpp
index a4d88387a4b..38e4f864ddd 100644
--- a/engines/harvester/npc/pta_mom_dialogue.cpp
+++ b/engines/harvester/npc/pta_mom_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/pta_mom_dialogue.h"
 
diff --git a/engines/harvester/npc/ryder_dialogue.cpp b/engines/harvester/npc/ryder_dialogue.cpp
index 8612322a3fa..bf8d73b8c26 100644
--- a/engines/harvester/npc/ryder_dialogue.cpp
+++ b/engines/harvester/npc/ryder_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/ryder_dialogue.h"
 
diff --git a/engines/harvester/npc/sergeant_dialogue.cpp b/engines/harvester/npc/sergeant_dialogue.cpp
index ed8f0f583cb..147c0191c26 100644
--- a/engines/harvester/npc/sergeant_dialogue.cpp
+++ b/engines/harvester/npc/sergeant_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/sergeant_dialogue.h"
 
diff --git a/engines/harvester/npc/sparky_dialogue.cpp b/engines/harvester/npc/sparky_dialogue.cpp
index 77c869b6574..bff936c16f4 100644
--- a/engines/harvester/npc/sparky_dialogue.cpp
+++ b/engines/harvester/npc/sparky_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/sparky_dialogue.h"
 
diff --git a/engines/harvester/npc/stephanie_dialogue.cpp b/engines/harvester/npc/stephanie_dialogue.cpp
index e235ad59223..48a799c48ec 100644
--- a/engines/harvester/npc/stephanie_dialogue.cpp
+++ b/engines/harvester/npc/stephanie_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/stephanie_dialogue.h"
 
diff --git a/engines/harvester/npc/swell_dialogue.cpp b/engines/harvester/npc/swell_dialogue.cpp
index e011b1ee079..8283577709a 100644
--- a/engines/harvester/npc/swell_dialogue.cpp
+++ b/engines/harvester/npc/swell_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/swell_dialogue.h"
 
diff --git a/engines/harvester/npc/valet_dialogue.cpp b/engines/harvester/npc/valet_dialogue.cpp
index 561b4e4eaf8..bfe9052d083 100644
--- a/engines/harvester/npc/valet_dialogue.cpp
+++ b/engines/harvester/npc/valet_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/valet_dialogue.h"
 
diff --git a/engines/harvester/npc/vet_dialogue.cpp b/engines/harvester/npc/vet_dialogue.cpp
index 48545c66082..f9060f0b72f 100644
--- a/engines/harvester/npc/vet_dialogue.cpp
+++ b/engines/harvester/npc/vet_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/vet_dialogue.h"
 
diff --git a/engines/harvester/npc/wasp_woman_dialogue.cpp b/engines/harvester/npc/wasp_woman_dialogue.cpp
index 4e4acc417f3..b575717a507 100644
--- a/engines/harvester/npc/wasp_woman_dialogue.cpp
+++ b/engines/harvester/npc/wasp_woman_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/wasp_woman_dialogue.h"
 
diff --git a/engines/harvester/npc/whaley_dialogue.cpp b/engines/harvester/npc/whaley_dialogue.cpp
index 00b6e237405..06edc5d1c10 100644
--- a/engines/harvester/npc/whaley_dialogue.cpp
+++ b/engines/harvester/npc/whaley_dialogue.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include <functional>
 
 #include "harvester/npc/whaley_dialogue.h"
 
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index cc59294c91e..8288ae6b8df 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -775,9 +775,11 @@ bool Script::loadConfig(ResourceManager &resources) {
 
 bool Script::saveConfig() const {
 	Common::DumpFile file;
-	const Common::Path configPath = ConfMan.getPath("path").join(kStartupConfigPath, Common::Path::kNoSeparator);
-	if (!file.open(configPath)) {
-		warning("Harvester: unable to save startup config '%s'", configPath.toString(Common::Path::kNativeSeparator).c_str());
+	const Common::FSNode gamePathNode(ConfMan.getPath("path"));
+	const Common::FSNode configNode = gamePathNode.getChild(kStartupConfigPath);
+	if (!file.open(configNode)) {
+		warning("Harvester: unable to save startup config '%s'",
+			configNode.getPath().toString(Common::Path::kNativeSeparator).c_str());
 		return false;
 	}
 


Commit: d6ad122ced33cea3f00e99b2b36370d41665aad5
    https://github.com/scummvm/scummvm/commit/d6ad122ced33cea3f00e99b2b36370d41665aad5
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:37+02:00

Commit Message:
HARVESTER: Restore active room animations on entry

Changed paths:
    TRACKER.md
    engines/harvester/flow.cpp
    engines/harvester/room.cpp
    engines/harvester/room_support.h


diff --git a/TRACKER.md b/TRACKER.md
index b0f459a0a98..0f6a3d12021 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On March 31, 2026, aligned the Harvester engine with the documented ScummVM portability and formatting conventions by replacing remaining `std::function`/`std::unique_ptr` usage with ScummVM-native patterns, removing hidden function-local static object state, documenting the `g_engine` global reset behavior, switching startup-config writes to `FSNode`-based file opening, removing leftover `<functional>` includes from the NPC dialogue sources, and codifying the ScummVM engine rules in `AGENTS.md`. Verified with `git diff --check` and a successful `make -C build-vscode-harvester-debug -j4 engines/harvester/libharvester.a`.
+- On April 1, 2026, corrected room-animation spawning so active Harvester room animations are displayed even when the authored script leaves their raw visibility flag cleared, which restores the missing `DRAWFIRE` fireplace animation on entry to `DRAWROOM` without rewriting script state. Verified with a successful `make -C build-vscode-harvester-debug -j4 engines/harvester/flow.o engines/harvester/room.o`.
 
 ## Next Suggested Action
 
-- Do a runtime smoke test that exercises room-NPC dialogue, response menus, keyword entry, and startup config persistence to confirm the refactored dialogue session plumbing and `FSNode`-based config writes behave identically in-game.
+- Do a runtime smoke test that enters `DRAWROOM` from `MAINHALL`, confirms `DRAWFIRE` is visible before interaction, and checks a few other authored active-only room animations to make sure the renderer now matches the original script semantics.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 06ca9406937..1201d5f877f 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -640,6 +640,10 @@ static bool shouldQueueSceneObject(const ObjectRecord &object) {
 		(object.operatable || !object.actionTag.empty());
 }
 
+bool shouldDisplaySceneAnimation(const AnimRecord &anim) {
+	return anim.active || anim.visible;
+}
+
 static void queueSceneObject(const char *source, const ObjectRecord &object,
 		Common::Array<ObjectRecord> &sceneObjects) {
 	if (!shouldQueueSceneObject(object)) {
@@ -675,7 +679,7 @@ bool loadRoomSceneResources(const RoomSetupState &state, ResourceManager &resour
 
 	scene.sceneObjects = sceneObjects;
 	for (const AnimRecord &anim : state.roomAnimations) {
-		if (anim.active || anim.visible)
+		if (shouldDisplaySceneAnimation(anim))
 			scene.sceneAnimations.push_back(anim);
 	}
 	scene.sceneRegions = state.roomRegions;
@@ -1876,8 +1880,9 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 			spritePath.c_str(), object.actionTag.c_str(), object.operatable, object.identTextKey.c_str());
 	}
 	for (const AnimRecord &anim : drawableAnimations) {
+		const bool displayVisible = shouldDisplaySceneAnimation(anim);
 		if (!entityManager->spawnSceneAnimationEntity(anim.animName, anim.resourcePath,
-				Common::Point(anim.x, anim.y), (float)anim.z, anim.frameDelay, anim.active, anim.visible,
+				Common::Point(anim.x, anim.y), (float)anim.z, anim.frameDelay, anim.active, displayVisible,
 				anim.looping, anim.backward, anim.pingPong, anim.runtimeState)) {
 			debug(1, "Harvester: unable to spawn room anim entity '%s' from '%s'",
 				anim.animName.c_str(), anim.resourcePath.c_str());
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 0377490ff73..493db54e3c5 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -966,7 +966,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					if (!entity)
 						continue;
 					(void)script->syncRuntimeAnimState(anim.animName,
-						entity->isAnimationEnabled(), entity->isVisible(), entity->getCurrentFrame());
+						entity->isAnimationEnabled(), anim.visible, entity->getCurrentFrame());
 				}
 			}
 			for (const MonsterRecord &monster : scene.state.roomMonsters)
@@ -1492,14 +1492,16 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			for (AnimRecord &anim : updatedState.roomAnimations) {
 				const AnimRecord *previous =
 					findRoomAnimByNameConst(scene.state.roomAnimations, anim.animName);
-				const bool becameVisible = (!previous || !previous->visible) && anim.visible;
+				const bool wasDisplayed = previous && shouldDisplaySceneAnimation(*previous);
+				const bool isDisplayed = shouldDisplaySceneAnimation(anim);
+				const bool becameVisible = !wasDisplayed && isDisplayed;
 				if (becameVisible)
 					anim.runtimeState = 0;
 
 				Entity *entity = entityManager
 					? entityManager->findSceneEntityByName(anim.animName)
 					: nullptr;
-				if (!anim.visible) {
+				if (!isDisplayed) {
 					removeSceneEntityByName(anim.animName);
 					continue;
 				}
@@ -1518,7 +1520,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					if (entityManager) {
 						entity = entityManager->spawnSceneAnimationEntity(
 							anim.animName, anim.resourcePath, Common::Point(anim.x, anim.y),
-							(float)anim.z, anim.frameDelay, anim.active, anim.visible, anim.looping,
+							(float)anim.z, anim.frameDelay, anim.active, isDisplayed, anim.looping,
 							anim.backward, anim.pingPong, anim.runtimeState);
 					}
 				} else if (entity) {
@@ -1530,7 +1532,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					entity->setPingPong(anim.pingPong);
 					entity->setPlayBackwards(anim.backward);
 					entity->setAnimationRate(anim.frameDelay);
-					entity->setVisible(anim.visible);
+					entity->setVisible(isDisplayed);
 					entity->setAnimationEnabled(anim.active);
 					if (zChanged)
 						entityManager->reinsertSceneEntity(entity);
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index c4b221eb6d9..e661016ecbb 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -118,6 +118,7 @@ bool applyRoomActorPlacement(const RoomSetupState &state, Entity &entity,
 void logScenePaletteSummary(const char *label, const RoomSceneResources &scene, float brightness);
 bool captureScreenBackdrop(const Graphics::Screen &screen, IndexedBitmap &bitmap);
 Common::Rect getRoomObjectHotspotBounds(const ObjectRecord &object);
+bool shouldDisplaySceneAnimation(const AnimRecord &anim);
 bool loadRoomSceneResources(const RoomSetupState &state, ResourceManager &resources, RoomSceneResources &scene);
 bool shouldRunStartupRoomProbe();
 void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const RoomSceneResources &scene,


Commit: 4108f4b8b1ff268480a1d3bd74a20313e0b51ee7
    https://github.com/scummvm/scummvm/commit/4108f4b8b1ff268480a1d3bd74a20313e0b51ee7
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:37+02:00

Commit Message:
HARVESTER: Match closeup exit flow to the original game

Changed paths:
    TRACKER.md
    engines/harvester/dialogue.cpp
    engines/harvester/flow.cpp
    engines/harvester/flow.h
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index 0f6a3d12021..42c07d08718 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 1, 2026, corrected room-animation spawning so active Harvester room animations are displayed even when the authored script leaves their raw visibility flag cleared, which restores the missing `DRAWFIRE` fireplace animation on entry to `DRAWROOM` without rewriting script state. Verified with a successful `make -C build-vscode-harvester-debug -j4 engines/harvester/flow.o engines/harvester/room.o`.
+- On April 1, 2026, aligned the Harvester close-up unwind path with the original binary by preserving the parent room save snapshot before nested close-ups and reapplying that parent state on `EXIT_CLOSEUP`, instead of restarting from the nested close-up save state. Verified with a successful `make -j4 engines/harvester/script.o engines/harvester/dialogue.o engines/harvester/flow.o engines/harvester/room.o`. Also decoded CD3 `HARVEST.SCR` and confirmed the BAR strong-beer chain: `DEATV_BAR_DOORS` disables `BAR_DINING`/`BAR_FOUNT`, sets `BARANIM_FLAG`, and exits the close-up; on BAR re-entry, room on-enter tag `CHECK_MONST` checks `BARANIM_FLAG`, arms `BAR_MONSTZ_T` and `BAR_TIMER`, starts `EVIL_PC_ANIM` plus `magic2.wav` after 3 ticks, and spawns monster `EVILPC` after 8 ticks.
 
 ## Next Suggested Action
 
-- Do a runtime smoke test that enters `DRAWROOM` from `MAINHALL`, confirms `DRAWFIRE` is visible before interaction, and checks a few other authored active-only room animations to make sure the renderer now matches the original script semantics.
+- Do a runtime smoke test for the CD3 strong-beer path: enter `BAR`, open `BARCU`, operate `BARCUXXBEER`, confirm the close-up exits back to `BAR`, BAR room-entry runs `CHECK_MONST`, `BAR_MONSTZ_T` starts `EVIL_PC_ANIM` with `magic2.wav`, and `BAR_TIMER` then spawns `EVILPC`.
diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 30b034c453a..f8d39fdd833 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -1014,6 +1014,7 @@ public:
 				!interaction.cutscenePath.empty() ||
 				!interaction.deathFlicPath.empty() || interaction.requestMainMenu ||
 				interaction.requestRoomRestart ||
+				interaction.requestCloseupExit ||
 				interaction.cdChangeDisc > 0 ||
 				!interaction.dialogueNpcName.empty() ||
 				!interaction.dialogueContinuationTag.empty() ||
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 1201d5f877f..90b6fe325c0 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -1272,6 +1272,7 @@ Common::Error Flow::run() {
 	clearPendingMainMenuReturn();
 	clearPendingGameOverReturn();
 	clearPendingNewGameRestart();
+	clearPendingCloseupParentRestart();
 	resetRoomNpcDialogueState();
 	Common::Error error = Common::kNoError;
 	if (_engine.hasPendingLoadedSaveRoomState()) {
@@ -1742,6 +1743,24 @@ void Flow::clearPendingMainMenuReturn() {
 	_pendingMainMenuReturn = false;
 }
 
+void Flow::requestCloseupParentRestart() {
+	_pendingCloseupParentRestart = true;
+}
+
+bool Flow::hasPendingCloseupParentRestart() const {
+	return _pendingCloseupParentRestart;
+}
+
+bool Flow::takePendingCloseupParentRestart() {
+	const bool requested = _pendingCloseupParentRestart;
+	_pendingCloseupParentRestart = false;
+	return requested;
+}
+
+void Flow::clearPendingCloseupParentRestart() {
+	_pendingCloseupParentRestart = false;
+}
+
 bool Flow::requestDebugRoomChange(const Common::String &roomName) {
 	if (roomName.empty())
 		return false;
diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index 4c406f0aacb..b02778ac334 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -96,6 +96,10 @@ private:
 	bool hasPendingMainMenuReturn() const;
 	bool takePendingMainMenuReturn();
 	void clearPendingMainMenuReturn();
+	void requestCloseupParentRestart();
+	bool hasPendingCloseupParentRestart() const;
+	bool takePendingCloseupParentRestart();
+	void clearPendingCloseupParentRestart();
 	bool requestDebugRoomChange(const Common::String &roomName);
 	bool hasPendingDebugRoomChange() const { return !_pendingDebugRoomName.empty(); }
 	Common::String takePendingDebugRoomChange();
@@ -119,6 +123,7 @@ private:
 	bool _pendingNewGameRestart = false;
 	bool _pendingGameOverReturn = false;
 	bool _pendingMainMenuReturn = false;
+	bool _pendingCloseupParentRestart = false;
 	Common::String _pendingDebugRoomName;
 	uint _roomLoopDepth = 0;
 	uint32 _roomSetupTransitionShownTick = 0;
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 493db54e3c5..e9a0a117284 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -78,6 +78,7 @@ static const uint32 kCombatDebugDamagePopupDurationMs = 750;
 static const int kCombatDebugDamagePopupRisePixels = 18;
 static const int kCombatDebugDamagePopupVerticalGap = 6;
 static const char *const kCdChangePromptPalettePath = "1:/GRAPHIC/PAL/CD1.PAL";
+static const char *const kExitCloseupPendingRoomChange = "__EXIT_CLOSEUP__";
 
 static int roundRoomCombatFloat(float value);
 
@@ -748,6 +749,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 	Common::String currentRoomTarget = targetName;
 	bool currentTargetIsRoomName = targetIsRoomName;
+	bool currentTargetUsesSavedRoomState = false;
+	SaveRoomState currentTargetSavedRoomState;
 	while (!currentRoomTarget.empty()) {
 		RoomSetupState state;
 		bool shouldRunRoomEntryCommands = false;
@@ -793,6 +796,22 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		} else {
 			shouldRunRoomEntryCommands = true;
 		}
+		if (shouldRunRoomEntryCommands && currentTargetUsesSavedRoomState &&
+				currentTargetSavedRoomState.valid) {
+			const SaveRoomState &savedState = currentTargetSavedRoomState;
+			state.entranceName = savedState.entranceName;
+			state.playerSpawnX = savedState.playerX;
+			state.playerSpawnY = savedState.playerY;
+			state.playerSpawnZ = savedState.playerZ;
+			state.playerFacing = savedState.playerFacing;
+			if (!savedState.musicPath.empty())
+				state.musicPath = savedState.musicPath;
+			debugC(1, kDebugRoom,
+				"Harvester: applying saved room restart state entrance='%s' room='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
+				savedState.entranceName.c_str(), savedState.roomName.c_str(),
+				savedState.playerX, savedState.playerY, savedState.playerZ,
+				savedState.playerFacing, savedState.musicPath.c_str());
+		}
 
 		Common::Error transitionError = flow.beginRoomSetupTransition();
 		if (transitionError.getCode() != Common::kNoError)
@@ -878,6 +897,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		bool attackModifierHeld = false;
 		Common::String pendingRegionName;
 		Common::String pendingRoomChange;
+		bool pendingRoomChangeIsRoomName = false;
+		bool pendingRoomChangeUsesSavedRoomState = false;
+		SaveRoomState pendingRoomChangeSavedRoomState;
+		const bool canExitCloseupToParent = flow._roomLoopDepth > 1;
 		uint32 lastLeftButtonReleaseTick = 0;
 		uint32 nextKeyboardAttackAllowedTick = 0;
 		RoomIdleAnimationState idleState;
@@ -2017,6 +2040,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			RoomPlayerState &playerState;
 			Common::String &pendingRegionName;
 			Common::String &pendingRoomChange;
+			bool &pendingRoomChangeIsRoomName;
+			bool &pendingRoomChangeUsesSavedRoomState;
+			SaveRoomState &pendingRoomChangeSavedRoomState;
+			const bool canExitCloseupToParent;
 			decltype(refreshCurrentScene) &refreshCurrentSceneFn;
 			decltype(applyCurrentRoomRuntimeMutationsInPlace) &applyCurrentRoomRuntimeMutationsInPlaceFn;
 			decltype(captureDialogueBackdrop) &captureDialogueBackdropFn;
@@ -2069,6 +2096,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				}
 				flow.executeStartupAudioCommands(interaction.audioCommands);
 				auto queueImplicitRoomRestart = [&]() -> Common::Error {
+					pendingRoomChangeIsRoomName = false;
+					pendingRoomChangeUsesSavedRoomState = false;
+					pendingRoomChangeSavedRoomState.clear();
 					if (!interaction.roomRestartTargetName.empty()) {
 						pendingRoomChange = interaction.roomRestartTargetName;
 					} else if (discChanged && interaction.cdChangeDisc == 3) {
@@ -2079,6 +2109,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 						pendingRoomChange = scene.state.entranceName;
 					} else {
 						pendingRoomChange = scene.state.roomName;
+						pendingRoomChangeIsRoomName = true;
 					}
 					didTransition = !pendingRoomChange.empty();
 					debugC(1, kDebugRoom,
@@ -2088,9 +2119,40 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 						scene.state.entranceName.c_str(), scene.state.roomName.c_str());
 					return didTransition ? Common::kNoError : Common::kReadingFailed;
 				};
+				auto queueSavedRoomRestart = [&](const SaveRoomState &savedState) -> Common::Error {
+					pendingRoomChange.clear();
+					pendingRoomChangeIsRoomName = false;
+					pendingRoomChangeUsesSavedRoomState = false;
+					pendingRoomChangeSavedRoomState.clear();
+					pendingRoomChange = !savedState.entranceName.empty()
+						? savedState.entranceName
+						: savedState.roomName;
+					pendingRoomChangeIsRoomName = savedState.entranceName.empty();
+					pendingRoomChangeUsesSavedRoomState = !pendingRoomChange.empty();
+					pendingRoomChangeSavedRoomState = savedState;
+					debugC(1, kDebugRoom,
+						"Harvester: queued saved room restart target='%s' room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
+						pendingRoomChange.c_str(), savedState.roomName.c_str(),
+						savedState.entranceName.c_str(), savedState.playerX, savedState.playerY,
+						savedState.playerZ, savedState.playerFacing, savedState.musicPath.c_str());
+					didTransition = !pendingRoomChange.empty();
+					return didTransition ? Common::kNoError : Common::kReadingFailed;
+				};
 				if (interaction.requestRoomRestart) {
 					return queueImplicitRoomRestart();
 				}
+				if (interaction.requestCloseupExit) {
+					if (!canExitCloseupToParent) {
+						debugC(1, kDebugRoom,
+							"Harvester: EXIT_CLOSEUP ignored for room='%s' without parent room loop",
+							scene.state.roomName.c_str());
+						return Common::kNoError;
+					}
+
+					pendingRoomChange = kExitCloseupPendingRoomChange;
+					didTransition = true;
+					return Common::kNoError;
+				}
 
 				StartupRoomTransitionKind roomTransition = interaction.roomTransition;
 				if (roomTransition == kStartupRoomTransitionNone && !interaction.nextRoomName.empty())
@@ -2128,9 +2190,18 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					if (exitError.getCode() != Common::kNoError)
 						return exitError;
 
+					SaveRoomState parentRoomState;
+					const bool hasParentRoomState = engine.hasCurrentSaveRoomState();
+					if (hasParentRoomState)
+						parentRoomState = engine.getCurrentSaveRoomState();
 					Common::Error roomError = flow.runRoomLoop(interaction.nextRoomName);
 					if (flow.hasPendingMainMenuReturn())
 						return Common::kNoError;
+					if (flow.takePendingCloseupParentRestart()) {
+						if (hasParentRoomState && parentRoomState.valid)
+							return queueSavedRoomRestart(parentRoomState);
+						return queueImplicitRoomRestart();
+					}
 					if (flow.hasPendingDebugRoomChange()) {
 						didTransition = true;
 						return Common::kNoError;
@@ -2255,6 +2326,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		};
 		InteractionProcessor interactionProcessor = {
 			_engine, flow, scene, playerState, pendingRegionName, pendingRoomChange,
+			pendingRoomChangeIsRoomName, pendingRoomChangeUsesSavedRoomState,
+			pendingRoomChangeSavedRoomState,
+			canExitCloseupToParent,
 			refreshCurrentScene, applyCurrentRoomRuntimeMutationsInPlace, captureDialogueBackdrop,
 			showCdChangePrompt,
 			runRoomExitCommands, applyLightingCommand, applyPlayerGotoXZ, runModalShowText,
@@ -2274,6 +2348,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				!interaction.modalText.value.empty() ||
 				interaction.lightingCommand != kStartupLightingCommandNone ||
 				interaction.requestPlayerGotoXZ ||
+				interaction.requestCloseupExit ||
 				interaction.mutatedRuntimeState;
 		};
 		InteractionResult roomEntryInteraction;
@@ -3630,14 +3705,22 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		if (flow.takePendingNewGameRestart()) {
 			flow.prepareForNewGame();
 			pendingRoomChange = "START";
+			pendingRoomChangeIsRoomName = false;
+			pendingRoomChangeUsesSavedRoomState = false;
+			pendingRoomChangeSavedRoomState.clear();
 			if (!stowCarriedRoomItemToInventory())
 				return Common::kReadingFailed;
 			break;
 		}
 		if (_engine.hasPendingLoadedSaveRoomState()) {
 			pendingRoomChange = _engine.getPendingLoadedSaveRoomState().entranceName;
-			if (pendingRoomChange.empty())
+			pendingRoomChangeIsRoomName = false;
+			pendingRoomChangeUsesSavedRoomState = false;
+			pendingRoomChangeSavedRoomState.clear();
+			if (pendingRoomChange.empty()) {
 				pendingRoomChange = _engine.getPendingLoadedSaveRoomState().roomName;
+				pendingRoomChangeIsRoomName = true;
+			}
 			if (!stowCarriedRoomItemToInventory())
 				return Common::kReadingFailed;
 			break;
@@ -4536,9 +4619,15 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		return Common::kNoError;
 	if (pendingRoomChange.empty())
 		return Common::kNoError;
+	if (pendingRoomChange.equals(kExitCloseupPendingRoomChange)) {
+		flow.requestCloseupParentRestart();
+		return Common::kNoError;
+	}
 
 	currentRoomTarget = pendingRoomChange;
-	currentTargetIsRoomName = false;
+	currentTargetIsRoomName = pendingRoomChangeIsRoomName;
+	currentTargetUsesSavedRoomState = pendingRoomChangeUsesSavedRoomState;
+	currentTargetSavedRoomState = pendingRoomChangeSavedRoomState;
 	}
 
 	return Common::kNoError;
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 8288ae6b8df..42223e8d3cb 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1677,7 +1677,8 @@ bool Script::executeRoomEnterCommands(const Common::String &roomName,
 		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu, &result.cdChangeDisc,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
-		&result.playerGotoX, &result.playerGotoZ, &result.mutatedRuntimeState);
+		&result.playerGotoX, &result.playerGotoZ,
+		&result.mutatedRuntimeState, &result.requestCloseupExit);
 	return true;
 }
 
@@ -1694,7 +1695,7 @@ bool Script::executeRoomExitCommands(const Common::String &roomName,
 		nullptr, nullptr, &result.cdChangeDisc, &result.dialogueNpcName, &result.dialogueContinuationTag,
 		&result.continuationTag, &result.modalText, &result.lightingCommand,
 		&result.requestPlayerGotoXZ, &result.playerGotoX, &result.playerGotoZ,
-		&result.mutatedRuntimeState);
+		&result.mutatedRuntimeState, &result.requestCloseupExit);
 	return true;
 }
 
@@ -1718,14 +1719,16 @@ bool Script::resolveObjectInteraction(const ObjectRecord &object, InteractionRes
 		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu, &result.cdChangeDisc,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
-		&result.playerGotoX, &result.playerGotoZ, &result.mutatedRuntimeState);
+		&result.playerGotoX, &result.playerGotoZ,
+		&result.mutatedRuntimeState, &result.requestCloseupExit);
 
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
 		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
 		result.cdChangeDisc > 0 || !result.continuationTag.empty() || !result.modalText.value.empty() ||
 		result.lightingCommand != kStartupLightingCommandNone || result.requestPlayerGotoXZ ||
-		result.mutatedRuntimeState || hasActionableCommandChain(object.actionTag);
+		result.requestCloseupExit || result.mutatedRuntimeState ||
+		hasActionableCommandChain(object.actionTag);
 }
 
 bool Script::resolveRegionInteraction(const RegionRecord &region, InteractionResult &result) {
@@ -1738,13 +1741,15 @@ bool Script::resolveRegionInteraction(const RegionRecord &region, InteractionRes
 		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu, &result.cdChangeDisc,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
-		&result.playerGotoX, &result.playerGotoZ, &result.mutatedRuntimeState);
+		&result.playerGotoX, &result.playerGotoZ,
+		&result.mutatedRuntimeState, &result.requestCloseupExit);
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
 		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
 		result.cdChangeDisc > 0 || !result.continuationTag.empty() || !result.modalText.value.empty() ||
 		result.lightingCommand != kStartupLightingCommandNone || result.requestPlayerGotoXZ ||
-		result.mutatedRuntimeState || hasActionableCommandChain(region.actionTag);
+		result.requestCloseupExit || result.mutatedRuntimeState ||
+		hasActionableCommandChain(region.actionTag);
 }
 
 bool Script::resolveUseItemInteraction(const Common::String &itemName, const ObjectRecord &target,
@@ -1761,7 +1766,8 @@ bool Script::resolveUseItemInteraction(const Common::String &itemName, const Obj
 		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu, &result.cdChangeDisc,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
-		&result.playerGotoX, &result.playerGotoZ, &result.mutatedRuntimeState);
+		&result.playerGotoX, &result.playerGotoZ,
+		&result.mutatedRuntimeState, &result.requestCloseupExit);
 	return true;
 }
 
@@ -1799,14 +1805,16 @@ bool Script::executeActionTag(const Common::String &tag, InteractionResult &resu
 		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu, &result.cdChangeDisc,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
-		&result.playerGotoX, &result.playerGotoZ, &result.mutatedRuntimeState);
+		&result.playerGotoX, &result.playerGotoZ,
+		&result.mutatedRuntimeState, &result.requestCloseupExit);
 
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
 		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
 		result.cdChangeDisc > 0 || !result.continuationTag.empty() || !result.modalText.value.empty() ||
 		result.lightingCommand != kStartupLightingCommandNone || result.requestPlayerGotoXZ ||
-		result.mutatedRuntimeState || hasActionableCommandChain(tag);
+		result.requestCloseupExit || result.mutatedRuntimeState ||
+		hasActionableCommandChain(tag);
 }
 
 bool Script::executeTimerAction(const Common::String &timerName, InteractionResult &result,
@@ -1830,14 +1838,16 @@ bool Script::executeTimerAction(const Common::String &timerName, InteractionResu
 		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu, &result.cdChangeDisc,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
-		&result.playerGotoX, &result.playerGotoZ, &result.mutatedRuntimeState);
+		&result.playerGotoX, &result.playerGotoZ,
+		&result.mutatedRuntimeState, &result.requestCloseupExit);
 
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
 		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
 		result.cdChangeDisc > 0 || !result.continuationTag.empty() || !result.modalText.value.empty() ||
 		result.lightingCommand != kStartupLightingCommandNone || result.requestPlayerGotoXZ ||
-		result.mutatedRuntimeState || hasActionableCommandChain(timer->arg2);
+		result.requestCloseupExit || result.mutatedRuntimeState ||
+		hasActionableCommandChain(timer->arg2);
 }
 
 bool Script::executeNestedActionTag(const Common::String &tag, InteractionResult &result,
@@ -2526,7 +2536,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		Common::String *continuationTag, ResolvedText *modalText,
 		StartupLightingCommand *lightingCommand, bool *requestPlayerGotoXZ,
 		int *playerGotoX, int *playerGotoZ,
-		bool *mutatedRuntimeState) {
+		bool *mutatedRuntimeState, bool *requestCloseupExit) {
 	auto isTruthy = [](const Common::String &value) {
 		return value.equalsIgnoreCase("T") || value.equalsIgnoreCase("ON") || value.equalsIgnoreCase("TRUE");
 	};
@@ -2543,7 +2553,8 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			(continuationTag && !continuationTag->empty()) ||
 			(modalText && !modalText->value.empty()) ||
 			(lightingCommand && *lightingCommand != kStartupLightingCommandNone) ||
-			(requestPlayerGotoXZ && *requestPlayerGotoXZ);
+			(requestPlayerGotoXZ && *requestPlayerGotoXZ) ||
+			(requestCloseupExit && *requestCloseupExit);
 	};
 
 	Common::String currentTag = initialTag;
@@ -2771,7 +2782,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 					musicPath, audioCommands, nextRoomName, roomTransition, cutscenePath,
 					deathFlicPath, requestMainMenu, cdChangeDisc, dialogueNpcName, dialogueContinuationTag,
 					continuationTag, modalText, lightingCommand, requestPlayerGotoXZ,
-					playerGotoX, playerGotoZ, mutatedRuntimeState);
+					playerGotoX, playerGotoZ, mutatedRuntimeState, requestCloseupExit);
 				if (hasDeferredInteractionOutputs())
 					return;
 			}
@@ -2962,6 +2973,17 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			continue;
 		}
 
+		if (command->opcodeName.equalsIgnoreCase("EXIT_CLOSEUP")) {
+			if (requestCloseupExit) {
+				*requestCloseupExit = true;
+			} else {
+				debug(1, "Harvester: unsupported startup command '%s' for %s '%s' without closeup-exit context",
+					command->opcodeName.c_str(), contextLabel, contextName.c_str());
+			}
+			currentTag = command->arg4;
+			continue;
+		}
+
 		debug(1, "Harvester: unsupported startup command '%s' for %s '%s', continuing",
 			command->opcodeName.c_str(), contextLabel, contextName.c_str());
 		currentTag = command->arg4;
@@ -3038,6 +3060,7 @@ bool Script::probePickupBlockingCommandChain(const Common::String &initialTag,
 			command->opcodeName.equalsIgnoreCase("CHANGE_CD") ||
 			command->opcodeName.equalsIgnoreCase("PC_GOTO_XZ") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_LIGHTING") ||
+			command->opcodeName.equalsIgnoreCase("EXIT_CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
 			return false;
@@ -3095,6 +3118,7 @@ bool Script::hasActionableCommandChain(const Common::String &initialTag) const {
 			command->opcodeName.equalsIgnoreCase("CHANGE_CD") ||
 			command->opcodeName.equalsIgnoreCase("PC_GOTO_XZ") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_LIGHTING") ||
+			command->opcodeName.equalsIgnoreCase("EXIT_CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CLOSEUP") ||
 			command->opcodeName.equalsIgnoreCase("CHANGE_ROOM")) {
 			return true;
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 0511f10433e..70251387d5b 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -302,6 +302,7 @@ struct InteractionResult {
 	bool requestPlayerGotoXZ = false;
 	bool requestRoomRestart = false;
 	Common::String roomRestartTargetName;
+	bool requestCloseupExit = false;
 	bool requestMainMenu = false;
 	bool abortRemainingCommandChain = false;
 	bool mutatedRuntimeState = false;
@@ -445,7 +446,7 @@ private:
 		Common::String *continuationTag, ResolvedText *modalText,
 		StartupLightingCommand *lightingCommand, bool *requestPlayerGotoXZ,
 		int *playerGotoX, int *playerGotoZ,
-		bool *mutatedRuntimeState);
+		bool *mutatedRuntimeState, bool *requestCloseupExit);
 	bool probePickupBlockingCommandChain(const Common::String &initialTag,
 		const Common::String &contextName, InteractionResult &result, uint recursionDepth) const;
 	bool hasActionableCommandChain(const Common::String &initialTag) const;


Commit: f7488c2e1ba0409da6ed4d6f5dce80492beb365c
    https://github.com/scummvm/scummvm/commit/f7488c2e1ba0409da6ed4d6f5dce80492beb365c
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:37+02:00

Commit Message:
HARVESTER: Match native room walk y tracking

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/player.cpp
    engines/harvester/room_support.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index fbe0402046a..d8e17e6bcf4 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1065,6 +1065,11 @@
   - It handles class-4/5/6 actor entities, advances animation/state transitions, maintains pursuit spacing against the player using `engage_distance`, applies room Z bounds and vertical motion, fires frame-timed sound hooks, resolves hit damage through `damage_amount`, and returns `0` when the caller should remove the actor entity from the world list.
   - The close-range hit-resolution block reuses the hidden helper at live actor field `+0x11a8`: it links attacker/target runtime pointers, forces the helper back to frame `0`, repositions it over the struck actor, and shows it when the helper was still hidden.
   - The native locomotion constants now read cleanly out of the state machine: left/right walk uses `depth_scale * 8.0`, the reverse-step families use `depth_scale * 16.0`, the class-6 monster pursue band is `g_player_combat_avatar->z +/- 2.0`, and the horizontal chase waypoint slack is `depth_scale * 50.0`.
+  - Native player room walking keeps screen-space Y separate from depth:
+    - `spawn_player_combat_avatar` seeds live actor field `+0x1140` with `1`
+    - the vertical walk states copy `abs(+0x1140)` into `frame_y_step`
+    - they apply `+/- g_current_room_def->z_velocity_step` separately through `frame_z_step`
+    - the later `set_entity_screen_position` handoff commits `screen_y` and `z` independently, so native walk updates do not derive screen Y from depth each tick
   - For class-6 monsters specifically, pursuit is not a free-running room-script heuristic: the native state machine first closes base Z to within `g_player_combat_avatar->z +/- 2.0`, then compares center-to-center X against `engage_distance`, and only arms the close-range attack picker after that spacing gate is satisfied.
   - The same class-6 path derives its horizontal chase waypoint from the player's live left edge plus the current player/monster frame widths and treats that waypoint as satisfied within `50.0 * depth_scale` pixels, so native monster pursuit does not require exact center equality before it can stop walking and attack.
   - Once that close-range gate is satisfied and the per-attack cooldown allows it, the class-6 branch stores `g_player_combat_avatar` into live actor field `+0x11a4` before entering the close-range attack family; the later contact block resolves against that linked target instead of re-running the coarse engage-distance spacing test.
diff --git a/TRACKER.md b/TRACKER.md
index 42c07d08718..f3fc6bc4ee1 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 1, 2026, aligned the Harvester close-up unwind path with the original binary by preserving the parent room save snapshot before nested close-ups and reapplying that parent state on `EXIT_CLOSEUP`, instead of restarting from the nested close-up save state. Verified with a successful `make -j4 engines/harvester/script.o engines/harvester/dialogue.o engines/harvester/flow.o engines/harvester/room.o`. Also decoded CD3 `HARVEST.SCR` and confirmed the BAR strong-beer chain: `DEATV_BAR_DOORS` disables `BAR_DINING`/`BAR_FOUNT`, sets `BARANIM_FLAG`, and exits the close-up; on BAR re-entry, room on-enter tag `CHECK_MONST` checks `BARANIM_FLAG`, arms `BAR_MONSTZ_T` and `BAR_TIMER`, starts `EVIL_PC_ANIM` plus `magic2.wav` after 3 ticks, and spawns monster `EVILPC` after 8 ticks.
+- On April 1, 2026, validated the native room-walk update path in Ghidra: `spawn_player_combat_avatar` seeds player runtime field `+0x1140` with `1`, and `update_actor_runtime_state` copies `abs(+0x1140)` into `frame_y_step` for vertical walk states while applying `+/- g_current_room_def->z_velocity_step` separately through `frame_z_step` before `set_entity_screen_position` commits `screen_y` and `z` independently. Updated the ScummVM Harvester room player movement to keep `bottomY` as live state, step vertical movement in screen space, and preserve explicit move-target Y instead of remapping it from `z` every tick. Verified with a successful `make -j4 engines/harvester/player.o engines/harvester/room.o engines/harvester/flow.o engines/harvester/script.o`.
 
 ## Next Suggested Action
 
-- Do a runtime smoke test for the CD3 strong-beer path: enter `BAR`, open `BARCU`, operate `BARCUXXBEER`, confirm the close-up exits back to `BAR`, BAR room-entry runs `CHECK_MONST`, `BAR_MONSTZ_T` starts `EVIL_PC_ANIM` with `magic2.wav`, and `BAR_TIMER` then spawns `EVILPC`.
+- Runtime-smoke BAR room movement against the original DOS binary: compare keyboard up/down stepping, floor-click move targets near the walk band edges, and region/entrance transitions (`BAR_DINING`, `BAR_FOUNT`, `DININGROM_2_BAR`, `FOUNTAIN_2_BAR`) to confirm the new independent `bottomY` tracking matches native room walking and no longer snaps between `y=393` and `y=332`.
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 7ef717e4f34..7a8dcf2508c 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -39,6 +39,7 @@ static const float kRoomPlayerHorizontalMoveBase = 8.0f;
 static const int kRoomRegionTargetXBias = 10;
 static const float kRoomDepthCompareEpsilon = 0.01f;
 static const int kRoomPlayerWalkAnimationRate = 17;
+static const int kRoomPlayerVerticalScreenStep = 1;
 static const int kRoomPlayerAttackAnimationRate = kRoomPlayerWalkAnimationRate;
 static const int kRoomPlayerHitAnimationRate = 4;
 static const int kRoomPlayerDeathAnimationRate = 4;
@@ -551,11 +552,11 @@ static int resolveFacingFromMovementDelta(int dx, int dy) {
 	return 0;
 }
 
-static int resolveFacingFromRoomMovement(const RoomSetupState &state,
-		int fromCenterX, float fromZ, int toCenterX, float toZ) {
+static int resolveFacingFromRoomMovement(
+		int fromCenterX, int fromBottomY, int toCenterX, int toBottomY) {
 	return resolveFacingFromMovementDelta(
 		toCenterX - fromCenterX,
-		mapRoomDepthToScreenY(state, toZ) - mapRoomDepthToScreenY(state, fromZ));
+		toBottomY - fromBottomY);
 }
 
 static int computeRoomPlayerHorizontalStep(const RoomSetupState &state, float z) {
@@ -566,6 +567,13 @@ static float computeRoomPlayerDepthStep(const RoomSetupState &state) {
 	return state.roomZVelocityStep > 0.0f ? state.roomZVelocityStep : 1.0f;
 }
 
+static int computeRoomPlayerVerticalScreenStep() {
+	// Native spawn_player_combat_avatar seeds player runtime +0x1140 with 1,
+	// and update_actor_runtime_state copies abs(+0x1140) into frame_y_step for
+	// the vertical walk states instead of remapping screen Y from depth.
+	return kRoomPlayerVerticalScreenStep;
+}
+
 static int clampPlayerCenterXToNativeBounds(const RoomPlayerState &playerState, int centerX) {
 	if (!playerState.entity)
 		return CLIP<int>(centerX, 0, 639);
@@ -652,14 +660,14 @@ static bool doesPlayerOverlapRoomBlocker(HarvesterEngine &engine,
 static bool isPlayerMovementBlocked(HarvesterEngine &engine, const RoomSetupState &state,
 		const Common::Array<ObjectRecord> &sceneObjects,
 		const Common::Array<AnimRecord> &sceneAnimations,
-		RoomPlayerState &playerState, int candidateCenterX, float candidateZ) {
+		RoomPlayerState &playerState, int candidateCenterX, int candidateBottomY, float candidateZ) {
 	if (!playerState.entity)
 		return true;
 
 	const int oldCenterX = playerState.centerX;
 	const int oldBottomY = playerState.bottomY;
 	const float oldZ = playerState.z;
-	const int candidateBottomY = mapRoomDepthToScreenY(state, candidateZ);
+	candidateBottomY = clampRoomMovementY(state, candidateBottomY);
 	if (!applyRoomActorPlacement(state, *playerState.entity,
 			candidateCenterX, candidateBottomY, candidateZ)) {
 		(void)applyRoomActorPlacement(state, *playerState.entity, oldCenterX, oldBottomY, oldZ);
@@ -674,28 +682,77 @@ static bool isPlayerMovementBlocked(HarvesterEngine &engine, const RoomSetupStat
 static bool tryApplyPlayerMovement(HarvesterEngine &engine, const RoomSetupState &state,
 		const Common::Array<ObjectRecord> &sceneObjects,
 		const Common::Array<AnimRecord> &sceneAnimations,
-		RoomPlayerState &playerState, int candidateCenterX, float candidateZ) {
+		RoomPlayerState &playerState, int candidateCenterX, int candidateBottomY, float candidateZ) {
 	if (!playerState.entity)
 		return false;
 
 	candidateCenterX = clampPlayerCenterXToNativeBounds(playerState, candidateCenterX);
+	candidateBottomY = clampRoomMovementY(state, candidateBottomY);
 	candidateZ = clampRoomDepth(state, candidateZ);
 	if (candidateCenterX == playerState.centerX &&
+			candidateBottomY == playerState.bottomY &&
 			fabsf(candidateZ - playerState.z) <= kRoomDepthCompareEpsilon) {
 		return false;
 	}
 	if (isPlayerMovementBlocked(engine, state, sceneObjects, sceneAnimations,
-			playerState, candidateCenterX, candidateZ)) {
+			playerState, candidateCenterX, candidateBottomY, candidateZ)) {
 		return false;
 	}
 
 	playerState.centerX = candidateCenterX;
+	playerState.bottomY = candidateBottomY;
 	playerState.z = candidateZ;
-	playerState.bottomY = mapRoomDepthToScreenY(state, candidateZ);
 	return applyRoomActorPlacement(state, *playerState.entity,
 		playerState.centerX, playerState.bottomY, playerState.z);
 }
 
+static bool tryApplyPlayerMovementWithFallbacks(HarvesterEngine &engine, const RoomSetupState &state,
+		const Common::Array<ObjectRecord> &sceneObjects,
+		const Common::Array<AnimRecord> &sceneAnimations,
+		RoomPlayerState &playerState,
+		int previousCenterX, int previousBottomY, float previousZ,
+		int candidateCenterX, int candidateBottomY, float candidateZ) {
+	if (tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+			playerState, candidateCenterX, candidateBottomY, candidateZ)) {
+		return true;
+	}
+	if (tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+			playerState, candidateCenterX, previousBottomY, candidateZ)) {
+		return true;
+	}
+	if (tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+			playerState, candidateCenterX, candidateBottomY, previousZ)) {
+		return true;
+	}
+	if (tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+			playerState, candidateCenterX, previousBottomY, previousZ)) {
+		return true;
+	}
+	if (tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+			playerState, previousCenterX, candidateBottomY, candidateZ)) {
+		return true;
+	}
+	if (tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+			playerState, previousCenterX, candidateBottomY, previousZ)) {
+		return true;
+	}
+	return tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+		playerState, previousCenterX, previousBottomY, candidateZ);
+}
+
+static void setMoveTargetInternal(const RoomSetupState &state, RoomPlayerState &playerState,
+		int targetX, float targetZ, int targetBottomY) {
+	playerState.hasMoveTarget = true;
+	playerState.nextMovementTick = 0;
+	playerState.targetX = clampPlayerCenterXToNativeBounds(playerState, targetX);
+	playerState.targetBottomY = clampRoomMovementY(state, targetBottomY);
+	playerState.targetZ = clampRoomDepth(state, targetZ);
+	debugC(1, kDebugPlayer,
+		"Harvester: player move target room='%s' current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f)",
+		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
+		playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ);
+}
+
 static void notePlayerIdleReset(RoomIdleAnimationState &idleState) {
 	idleState.resetTick = Player::getRuntimeClockTicks();
 	Player::updateIdleTrigger(idleState);
@@ -890,20 +947,15 @@ void Player::updateIdleTrigger(RoomIdleAnimationState &idleState) {
 
 void Player::setMoveTarget(const RoomSetupState &state, RoomPlayerState &playerState,
 		int targetX, float targetZ) {
-	playerState.hasMoveTarget = true;
-	playerState.nextMovementTick = 0;
-	playerState.targetX = clampPlayerCenterXToNativeBounds(playerState, targetX);
-	playerState.targetZ = clampRoomDepth(state, targetZ);
-	debugC(1, kDebugPlayer,
-		"Harvester: player move target room='%s' current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f)",
-		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
-		playerState.targetX, mapRoomDepthToScreenY(state, playerState.targetZ), (double)playerState.targetZ);
+	setMoveTargetInternal(state, playerState, targetX, targetZ,
+		mapRoomDepthToScreenY(state, targetZ));
 }
 
 void Player::setMoveTargetFromScreenPoint(const RoomSetupState &state,
 		RoomPlayerState &playerState, int targetX, int targetBottomY) {
-	setMoveTarget(state, playerState, targetX,
-		mapRoomScreenYToDepth(state, clampRoomMovementY(state, targetBottomY)));
+	const int clampedTargetBottomY = clampRoomMovementY(state, targetBottomY);
+	setMoveTargetInternal(state, playerState, targetX,
+		mapRoomScreenYToDepth(state, clampedTargetBottomY), clampedTargetBottomY);
 }
 
 bool Player::resolveBlockedStartupSpawn(HarvesterEngine &engine, const RoomSetupState &state,
@@ -914,11 +966,12 @@ bool Player::resolveBlockedStartupSpawn(HarvesterEngine &engine, const RoomSetup
 		return false;
 
 	if (!isPlayerMovementBlocked(engine, state, sceneObjects, sceneAnimations,
-			playerState, playerState.centerX, playerState.z)) {
+			playerState, playerState.centerX, playerState.bottomY, playerState.z)) {
 		return false;
 	}
 
 	const int originalCenterX = playerState.centerX;
+	const int originalBottomY = playerState.bottomY;
 	const int preferredDirection = playerState.facing == 1 ? -1 : 1;
 	for (int distance = 1; distance < 640; ++distance) {
 		int previousCandidateX = originalCenterX;
@@ -930,12 +983,11 @@ bool Player::resolveBlockedStartupSpawn(HarvesterEngine &engine, const RoomSetup
 				continue;
 			previousCandidateX = candidateX;
 			if (isPlayerMovementBlocked(engine, state, sceneObjects, sceneAnimations,
-					playerState, candidateX, playerState.z)) {
+					playerState, candidateX, playerState.bottomY, playerState.z)) {
 				continue;
 			}
 
 			playerState.centerX = candidateX;
-			playerState.bottomY = mapRoomDepthToScreenY(state, playerState.z);
 			if (!applyRoomActorPlacement(state, *playerState.entity,
 					playerState.centerX, playerState.bottomY, playerState.z)) {
 				return false;
@@ -944,7 +996,7 @@ bool Player::resolveBlockedStartupSpawn(HarvesterEngine &engine, const RoomSetup
 			debugC(1, kDebugPlayer,
 				"Harvester: adjusted blocked startup spawn room='%s' facing=%d from=(%d,%d,z=%.2f) to=(%d,%d,z=%.2f)",
 				state.roomName.c_str(), playerState.facing,
-				originalCenterX, mapRoomDepthToScreenY(state, playerState.z), (double)playerState.z,
+				originalCenterX, originalBottomY, (double)playerState.z,
 				playerState.centerX, playerState.bottomY, (double)playerState.z);
 			return true;
 		}
@@ -952,7 +1004,7 @@ bool Player::resolveBlockedStartupSpawn(HarvesterEngine &engine, const RoomSetup
 
 	debugC(1, kDebugPlayer,
 		"Harvester: startup spawn remains blocked room='%s' pos=(%d,%d,z=%.2f)",
-		state.roomName.c_str(), playerState.centerX, mapRoomDepthToScreenY(state, playerState.z),
+		state.roomName.c_str(), playerState.centerX, playerState.bottomY,
 		(double)playerState.z);
 	return false;
 }
@@ -1162,7 +1214,7 @@ bool Player::updateHitAnimationState(HarvesterEngine &engine, const RoomSetupSta
 			: MAX(playerState.hitKnockbackRemainingX, -step);
 		if (knockbackStep != 0) {
 			if (!tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-					playerState, playerState.centerX + knockbackStep, playerState.z)) {
+					playerState, playerState.centerX + knockbackStep, playerState.bottomY, playerState.z)) {
 				playerState.hitKnockbackRemainingX = 0;
 			} else {
 				changed = true;
@@ -1300,44 +1352,41 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 		return false;
 
 	if (playerState.centerX == playerState.targetX &&
+			playerState.bottomY == playerState.targetBottomY &&
 			fabsf(playerState.z - playerState.targetZ) <= kRoomDepthCompareEpsilon) {
 		playerState.hasMoveTarget = false;
 		return setIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
 	}
 
 	const int previousCenterX = playerState.centerX;
+	const int previousBottomY = playerState.bottomY;
 	const float previousZ = playerState.z;
-	const int targetBottomY = mapRoomDepthToScreenY(state, playerState.targetZ);
 	const int moveFacing = resolveFacingFromMovementDelta(
-		playerState.targetX - playerState.centerX, targetBottomY - playerState.bottomY);
+		playerState.targetX - playerState.centerX, playerState.targetBottomY - playerState.bottomY);
 	if (!consumePlayerMovementTick(playerState))
 		return false;
 	const int horizontalStep = computeRoomPlayerHorizontalStep(state, playerState.z);
+	const int verticalStep = computeRoomPlayerVerticalScreenStep();
 	const float depthStep = computeRoomPlayerDepthStep(state);
 	const int candidateCenterX = stepTowardsInt(playerState.centerX, playerState.targetX, horizontalStep);
+	const int candidateBottomY = stepTowardsInt(playerState.bottomY, playerState.targetBottomY, verticalStep);
 	const float candidateZ = stepTowardsFloat(playerState.z, playerState.targetZ, depthStep);
 
-	bool moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-		playerState, candidateCenterX, candidateZ);
-	if (!moved && candidateCenterX != previousCenterX) {
-		moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-			playerState, candidateCenterX, previousZ);
-	}
-	if (!moved && fabsf(candidateZ - previousZ) > kRoomDepthCompareEpsilon) {
-		moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-			playerState, previousCenterX, candidateZ);
-	}
+	bool moved = tryApplyPlayerMovementWithFallbacks(engine, state, sceneObjects, sceneAnimations,
+		playerState, previousCenterX, previousBottomY, previousZ,
+		candidateCenterX, candidateBottomY, candidateZ);
 	if (!moved) {
 		playerState.hasMoveTarget = false;
 		return setIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : moveFacing);
 	}
 
 	const int actualFacing = resolveFacingFromRoomMovement(
-		state, previousCenterX, previousZ, playerState.centerX, playerState.z);
+		previousCenterX, previousBottomY, playerState.centerX, playerState.bottomY);
 	(void)setPlayerWalkAnimation(playerState, actualFacing);
 	if (playerState.centerX == playerState.targetX &&
+			playerState.bottomY == playerState.targetBottomY &&
 			fabsf(playerState.z - playerState.targetZ) <= kRoomDepthCompareEpsilon) {
-		playerState.bottomY = mapRoomDepthToScreenY(state, playerState.targetZ);
+		playerState.bottomY = playerState.targetBottomY;
 		playerState.z = playerState.targetZ;
 		playerState.hasMoveTarget = false;
 		(void)setIdleAnimation(playerState, actualFacing);
@@ -1345,7 +1394,7 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 	debugC(playerState.hasMoveTarget ? 2 : 1, kDebugPlayer,
 		"Harvester: player move step room='%s' pos=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f) facing=%d frame=%d active=%d moved=%d",
 		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
-		playerState.targetX, mapRoomDepthToScreenY(state, playerState.targetZ), (double)playerState.targetZ,
+		playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ,
 		playerState.facing, playerState.entity->getCurrentFrame(), playerState.hasMoveTarget, moved);
 	return true;
 }
@@ -1365,23 +1414,28 @@ bool Player::stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState
 		return false;
 
 	const int previousCenterX = playerState.centerX;
+	const int previousBottomY = playerState.bottomY;
 	const float previousZ = playerState.z;
 	const int horizontalStep = computeRoomPlayerHorizontalStep(state, playerState.z);
+	const int verticalStep = computeRoomPlayerVerticalScreenStep();
 	const float depthStep = computeRoomPlayerDepthStep(state);
 	int candidateCenterX = clampPlayerCenterXToNativeBounds(
 		playerState, playerState.centerX + horizontalInput * horizontalStep);
+	int candidateBottomY = playerState.bottomY;
 	float candidateZ = playerState.z;
 	if (verticalInput != 0) {
+		candidateBottomY = clampRoomMovementY(state,
+			playerState.bottomY + verticalInput * verticalStep);
 		const float positiveZ = clampRoomDepth(state, playerState.z + depthStep);
 		const float negativeZ = clampRoomDepth(state, playerState.z - depthStep);
 		const int positiveY = mapRoomDepthToScreenY(state, positiveZ);
 		const int negativeY = mapRoomDepthToScreenY(state, negativeZ);
-		if (verticalInput < 0) {
+		if (candidateBottomY < playerState.bottomY) {
 			if (positiveY < playerState.bottomY)
 				candidateZ = positiveZ;
 			else if (negativeY < playerState.bottomY)
 				candidateZ = negativeZ;
-		} else {
+		} else if (candidateBottomY > playerState.bottomY) {
 			if (positiveY > playerState.bottomY)
 				candidateZ = positiveZ;
 			else if (negativeY > playerState.bottomY)
@@ -1389,12 +1443,13 @@ bool Player::stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState
 		}
 	}
 	if (candidateCenterX == playerState.centerX &&
+			candidateBottomY == playerState.bottomY &&
 			fabsf(candidateZ - playerState.z) <= kRoomDepthCompareEpsilon) {
 		return false;
 	}
 
 	const int desiredFacing = resolveFacingFromRoomMovement(
-		state, playerState.centerX, playerState.z, candidateCenterX, candidateZ);
+		playerState.centerX, playerState.bottomY, candidateCenterX, candidateBottomY);
 	if (desiredFacing != playerState.facing && startTurnAnimation(playerState, desiredFacing))
 		return true;
 
@@ -1402,21 +1457,14 @@ bool Player::stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState
 		return false;
 
 	playerState.hasMoveTarget = false;
-	bool moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-		playerState, candidateCenterX, candidateZ);
-	if (!moved && candidateCenterX != previousCenterX) {
-		moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-			playerState, candidateCenterX, previousZ);
-	}
-	if (!moved && fabsf(candidateZ - previousZ) > kRoomDepthCompareEpsilon) {
-		moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-			playerState, previousCenterX, candidateZ);
-	}
+	bool moved = tryApplyPlayerMovementWithFallbacks(engine, state, sceneObjects, sceneAnimations,
+		playerState, previousCenterX, previousBottomY, previousZ,
+		candidateCenterX, candidateBottomY, candidateZ);
 	if (!moved)
 		return false;
 
 	const int actualFacing = resolveFacingFromRoomMovement(
-		state, previousCenterX, previousZ, playerState.centerX, playerState.z);
+		previousCenterX, previousBottomY, playerState.centerX, playerState.bottomY);
 	(void)setPlayerWalkAnimation(playerState, actualFacing);
 	debugC(2, kDebugPlayer,
 		"Harvester: player keyboard move room='%s' input=(%d,%d) pos=(%d,%d,z=%.2f) facing=%d frame=%d moved=%d",
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index e661016ecbb..b5cdf39a093 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -62,6 +62,7 @@ struct RoomPlayerState {
 	uint32 nextMovementTick = 0;
 	bool hasMoveTarget = false;
 	int targetX = 0;
+	int targetBottomY = 0;
 	float targetZ = 0.0f;
 	bool turnActive = false;
 	int turnTargetFacing = -1;


Commit: c356829a1ebdd0d827400627307b6e124249f262
    https://github.com/scummvm/scummvm/commit/c356829a1ebdd0d827400627307b6e124249f262
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:38+02:00

Commit Message:
HARVESTER: Clear held item on closeup use

Changed paths:
    engines/harvester/room.cpp


diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index e9a0a117284..fa2c456467b 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -3570,6 +3570,17 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			return Common::kNoError;
 		}
 
+		StartupRoomTransitionKind roomTransition = interaction.roomTransition;
+		if (roomTransition == kStartupRoomTransitionNone && !interaction.nextRoomName.empty())
+			roomTransition = kStartupRoomTransitionCloseup;
+
+		// Flow shares one inventory system across nested room loops. Clear the held item
+		// before entering a closeup so it is not still carried while the child room loop
+		// is active.
+		if (!interaction.nextRoomName.empty() &&
+				roomTransition != kStartupRoomTransitionChangeRoom)
+			_inventory.clearSelection();
+
 		bool didTransition = false;
 		Common::Error interactionError =
 			interactionProcessor.handleInteractionResult(interaction, didTransition, selectedItemName);


Commit: 6a61e8904634deee6d88ce109f5c0c8333ac26f5
    https://github.com/scummvm/scummvm/commit/6a61e8904634deee6d88ce109f5c0c8333ac26f5
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:38+02:00

Commit Message:
HARVESTER: Disambiguate runtime monster state by room

Changed paths:
    TRACKER.md
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index f3fc6bc4ee1..fbe8d9177c2 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 1, 2026, validated the native room-walk update path in Ghidra: `spawn_player_combat_avatar` seeds player runtime field `+0x1140` with `1`, and `update_actor_runtime_state` copies `abs(+0x1140)` into `frame_y_step` for vertical walk states while applying `+/- g_current_room_def->z_velocity_step` separately through `frame_z_step` before `set_entity_screen_position` commits `screen_y` and `z` independently. Updated the ScummVM Harvester room player movement to keep `bottomY` as live state, step vertical movement in screen space, and preserve explicit move-target Y instead of remapping it from `z` every tick. Verified with a successful `make -j4 engines/harvester/player.o engines/harvester/room.o engines/harvester/flow.o engines/harvester/script.o`.
+- On April 1, 2026, confirmed that reused monster names can alias runtime combat state across rooms: Harvester matched `_currentMonsters` and base monster definitions by `monsterName` alone during town reload, save migration, runtime sync, and NPC monsterfy resolution, so killing `WOLFIE` in one room could materialize a different-room `WOLFIE` as an already-dead corpse. Updated Harvester script runtime monster matching to prefer `(roomName, monsterName)` identity in those state-management paths while preserving the existing name-only fallback for script opcodes that still address monsters by bare name. Verified with a successful `make -j4 engines/harvester/script.o`.
 
 ## Next Suggested Action
 
-- Runtime-smoke BAR room movement against the original DOS binary: compare keyboard up/down stepping, floor-click move targets near the walk band edges, and region/entrance transitions (`BAR_DINING`, `BAR_FOUNT`, `DININGROM_2_BAR`, `FOUNTAIN_2_BAR`) to confirm the new independent `bottomY` tracking matches native room walking and no longer snaps between `y=393` and `y=332`.
+- Runtime-smoke monster state separation with a reused monster name: kill the CRYPT `WOLFIE`, then transition into DINING and confirm its `WOLFIE` still materializes alive with full hit points. If any scripted `SET_MONSTER` path still cross-talks across rooms, extend opcode-time monster lookup to accept room context instead of falling back to bare `monsterName`.
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 42223e8d3cb..407d528829d 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -237,6 +237,13 @@ static bool hasLegacyMonsterCombatFieldLayout(const MonsterRecord &baseMonster,
 		currentMonster.engageDistance != baseMonster.engageDistance;
 }
 
+static bool matchesMonsterIdentity(const MonsterRecord &candidate, const MonsterRecord &monster) {
+	if (!candidate.monsterName.equalsIgnoreCase(monster.monsterName))
+		return false;
+
+	return monster.roomName.empty() || candidate.roomName.equalsIgnoreCase(monster.roomName);
+}
+
 static void syncStartupTimerRecord(Common::Serializer &s, TimerRecord &record) {
 	s.syncAsSint32LE(record.initialValue);
 	s.syncAsSint32LE(record.currentValue);
@@ -654,7 +661,7 @@ bool Script::reloadTownWorld(ResourceManager &resources) {
 	}
 
 	for (const MonsterRecord &monster : currentMonsters) {
-		MonsterRecord *currentMonster = findRuntimeMonster(monster.monsterName);
+		MonsterRecord *currentMonster = findRuntimeMonster(monster);
 		if (!currentMonster) {
 			_currentMonsters.push_back(monster);
 			continue;
@@ -1485,13 +1492,7 @@ void Script::logRuntimeSaveState(const char *operation) const {
 	}
 
 	for (const MonsterRecord &monster : _currentMonsters) {
-		const MonsterRecord *baseMonster = nullptr;
-		for (const MonsterRecord &candidate : _monsters) {
-			if (candidate.monsterName.equalsIgnoreCase(monster.monsterName)) {
-				baseMonster = &candidate;
-				break;
-			}
-		}
+		const MonsterRecord *baseMonster = findBaseMonster(monster);
 
 		const bool defaultActive = baseMonster ? baseMonster->active : monster.active;
 		const bool defaultVisible = baseMonster
@@ -1576,13 +1577,7 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 		// pre-release saves written before the monster combat field layout stabilized.
 		if (s.getVersion() >= 2) {
 			for (MonsterRecord &currentMonster : _currentMonsters) {
-				const MonsterRecord *baseMonster = nullptr;
-				for (const MonsterRecord &monster : _monsters) {
-					if (monster.monsterName.equalsIgnoreCase(currentMonster.monsterName)) {
-						baseMonster = &monster;
-						break;
-					}
-				}
+				const MonsterRecord *baseMonster = findBaseMonster(currentMonster);
 				if (!baseMonster)
 					continue;
 				if (!hasLegacyMonsterCombatFieldLayout(*baseMonster, currentMonster))
@@ -1596,13 +1591,7 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 		if (s.getVersion() < 2) {
 			_currentTimers = _timers;
 			for (MonsterRecord &currentMonster : _currentMonsters) {
-				const MonsterRecord *baseMonster = nullptr;
-				for (const MonsterRecord &monster : _monsters) {
-					if (monster.monsterName.equalsIgnoreCase(currentMonster.monsterName)) {
-						baseMonster = &monster;
-						break;
-					}
-				}
+				const MonsterRecord *baseMonster = findBaseMonster(currentMonster);
 				if (!baseMonster)
 					continue;
 
@@ -2115,6 +2104,23 @@ MonsterRecord *Script::findRuntimeMonster(const Common::String &monsterName) {
 	return nullptr;
 }
 
+MonsterRecord *Script::findRuntimeMonster(const MonsterRecord &monster) {
+	if (monster.monsterName.empty())
+		return nullptr;
+
+	MonsterRecord *fallback = nullptr;
+	for (MonsterRecord &candidate : _currentMonsters) {
+		if (!candidate.monsterName.equalsIgnoreCase(monster.monsterName))
+			continue;
+		if (matchesMonsterIdentity(candidate, monster))
+			return &candidate;
+		if (!fallback)
+			fallback = &candidate;
+	}
+
+	return fallback;
+}
+
 TimerRecord *Script::findRuntimeTimer(const Common::String &timerName) {
 	if (timerName.empty())
 		return nullptr;
@@ -2151,6 +2157,40 @@ const MonsterRecord *Script::findRuntimeMonster(const Common::String &monsterNam
 	return nullptr;
 }
 
+const MonsterRecord *Script::findRuntimeMonster(const MonsterRecord &monster) const {
+	if (monster.monsterName.empty())
+		return nullptr;
+
+	const MonsterRecord *fallback = nullptr;
+	for (const MonsterRecord &candidate : _currentMonsters) {
+		if (!candidate.monsterName.equalsIgnoreCase(monster.monsterName))
+			continue;
+		if (matchesMonsterIdentity(candidate, monster))
+			return &candidate;
+		if (!fallback)
+			fallback = &candidate;
+	}
+
+	return fallback;
+}
+
+const MonsterRecord *Script::findBaseMonster(const MonsterRecord &monster) const {
+	if (monster.monsterName.empty())
+		return nullptr;
+
+	const MonsterRecord *fallback = nullptr;
+	for (const MonsterRecord &candidate : _monsters) {
+		if (!candidate.monsterName.equalsIgnoreCase(monster.monsterName))
+			continue;
+		if (matchesMonsterIdentity(candidate, monster))
+			return &candidate;
+		if (!fallback)
+			fallback = &candidate;
+	}
+
+	return fallback;
+}
+
 const TimerRecord *Script::findRuntimeTimer(const Common::String &timerName) const {
 	if (timerName.empty())
 		return nullptr;
@@ -2276,7 +2316,7 @@ bool Script::syncRuntimeAnimState(const Common::String &animName, bool active, b
 }
 
 bool Script::syncRuntimeMonsterRecord(const MonsterRecord &monster) {
-	MonsterRecord *currentMonster = findRuntimeMonster(monster.monsterName);
+	MonsterRecord *currentMonster = findRuntimeMonster(monster);
 	if (!currentMonster)
 		return false;
 
@@ -2342,7 +2382,10 @@ bool Script::finalizeRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, i
 
 	bool monsterChanged = false;
 	if (!currentNpc->monsterfyTargetName.empty()) {
-		MonsterRecord *currentMonster = findRuntimeMonster(currentNpc->monsterfyTargetName);
+		MonsterRecord monsterLookup;
+		monsterLookup.roomName = currentNpc->roomName;
+		monsterLookup.monsterName = currentNpc->monsterfyTargetName;
+		MonsterRecord *currentMonster = findRuntimeMonster(monsterLookup);
 		if (currentMonster) {
 			monsterChanged = !currentMonster->active || !currentMonster->visible;
 			currentMonster->active = true;
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 70251387d5b..06e3b3bc24d 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -432,6 +432,9 @@ private:
 	const NpcRecord *findRuntimeNpc(const Common::String &npcName) const;
 	MonsterRecord *findRuntimeMonster(const Common::String &monsterName);
 	const MonsterRecord *findRuntimeMonster(const Common::String &monsterName) const;
+	MonsterRecord *findRuntimeMonster(const MonsterRecord &monster);
+	const MonsterRecord *findRuntimeMonster(const MonsterRecord &monster) const;
+	const MonsterRecord *findBaseMonster(const MonsterRecord &monster) const;
 	TimerRecord *findRuntimeTimer(const Common::String &timerName);
 	const TimerRecord *findRuntimeTimer(const Common::String &timerName) const;
 	bool buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord *entrance,


Commit: f76cea59c07d641e7a93344fd97b2ad17b803085
    https://github.com/scummvm/scummvm/commit/f76cea59c07d641e7a93344fd97b2ad17b803085
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:38+02:00

Commit Message:
HARVESTER: Scope action dispatch by current room

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index fbe8d9177c2..29cf26f89cd 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 1, 2026, confirmed that reused monster names can alias runtime combat state across rooms: Harvester matched `_currentMonsters` and base monster definitions by `monsterName` alone during town reload, save migration, runtime sync, and NPC monsterfy resolution, so killing `WOLFIE` in one room could materialize a different-room `WOLFIE` as an already-dead corpse. Updated Harvester script runtime monster matching to prefer `(roomName, monsterName)` identity in those state-management paths while preserving the existing name-only fallback for script opcodes that still address monsters by bare name. Verified with a successful `make -j4 engines/harvester/script.o`.
+- On April 1, 2026, rechecked the native room-action dispatcher in Ghidra before touching the remaining `SET_MONSTER` path: `dispatch_room_event_actions` at `0x60ee0` resolves monster records by `monster_name`, then gates the live spawn/remove work on `record->room_name == g_current_room_def->room_name`. Updated the ScummVM Harvester action-dispatch paths to carry explicit current-room context into room entry/exit commands, continuations, timers, pickup actions, inventory secondary actions, and monster/NPC death tags so `SET_MONSTER` now prefers the current-room runtime record before falling back to name-only resolution. Verified with a successful `make -j4 engines/harvester/script.o engines/harvester/room.o`.
 
 ## Next Suggested Action
 
-- Runtime-smoke monster state separation with a reused monster name: kill the CRYPT `WOLFIE`, then transition into DINING and confirm its `WOLFIE` still materializes alive with full hit points. If any scripted `SET_MONSTER` path still cross-talks across rooms, extend opcode-time monster lookup to accept room context instead of falling back to bare `monsterName`.
+- Runtime-smoke the exact regression path again: kill the CRYPT `WOLFIE`, transition into DINING, and confirm its `WOLFIE` materializes alive with full hit points. If the issue still reproduces, inspect the remaining room-agnostic `executeActionTag` callers outside the room loop (for example dialogue helper files) and confirm whether any duplicate `WOLFIE` action tags still enter the dispatcher without an explicit room context.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index fa2c456467b..47c4f0382f9 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1902,7 +1902,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 				InteractionResult continuationInteraction;
 				if (!_engine.getScript()->executeActionTag(
-						exitInteraction.continuationTag, continuationInteraction, false)) {
+						exitInteraction.continuationTag, continuationInteraction, false,
+						scene.state.roomName)) {
 					return Common::kNoError;
 				}
 
@@ -2265,7 +2266,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				if (!interaction.continuationTag.empty()) {
 					InteractionResult continuationInteraction;
 					if (engine.getScript()->executeActionTag(
-							interaction.continuationTag, continuationInteraction)) {
+							interaction.continuationTag, continuationInteraction, true,
+							scene.state.roomName)) {
 						Common::Error interactionError =
 							handleInteractionResult(continuationInteraction, didTransition, usedItemName);
 						if (interactionError.getCode() != Common::kNoError)
@@ -2309,7 +2311,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				}
 				if (!didTransition && !abortRemainingCommandChain && !continuationTag.empty()) {
 					InteractionResult continuationInteraction;
-					if (engine.getScript()->executeActionTag(continuationTag, continuationInteraction)) {
+					if (engine.getScript()->executeActionTag(
+							continuationTag, continuationInteraction, true, scene.state.roomName)) {
 						Common::Error interactionError =
 							handleInteractionResult(continuationInteraction, didTransition, usedItemName);
 						if (interactionError.getCode() != Common::kNoError)
@@ -2631,7 +2634,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		interaction.mutatedRuntimeState = runtimeChanged;
 		if (script && !npc.onDeathActionTag.empty()) {
 			InteractionResult deathInteraction;
-			if (script->executeActionTag(npc.onDeathActionTag, deathInteraction)) {
+			if (script->executeActionTag(npc.onDeathActionTag, deathInteraction, true, npc.roomName)) {
 				interaction = deathInteraction;
 				interaction.mutatedRuntimeState = true;
 			}
@@ -2769,7 +2772,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		interaction.mutatedRuntimeState = true;
 		if (!monster->onDeathActionTag.empty()) {
 			InteractionResult deathInteraction;
-			if (script->executeActionTag(monster->onDeathActionTag, deathInteraction)) {
+			if (script->executeActionTag(
+					monster->onDeathActionTag, deathInteraction, true, monster->roomName)) {
 				interaction = deathInteraction;
 				interaction.mutatedRuntimeState = true;
 			}
@@ -3129,7 +3133,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		interaction.mutatedRuntimeState = true;
 		if (!monster->onDeathActionTag.empty()) {
 			InteractionResult deathInteraction;
-			if (script->executeActionTag(monster->onDeathActionTag, deathInteraction)) {
+			if (script->executeActionTag(
+					monster->onDeathActionTag, deathInteraction, true, monster->roomName)) {
 				interaction = deathInteraction;
 				interaction.mutatedRuntimeState = true;
 			}
@@ -3205,7 +3210,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					interaction.mutatedRuntimeState = true;
 					if (script && !monster.onDeathActionTag.empty()) {
 						InteractionResult deathInteraction;
-						if (script->executeActionTag(monster.onDeathActionTag, deathInteraction)) {
+						if (script->executeActionTag(
+								monster.onDeathActionTag, deathInteraction, true, monster.roomName)) {
 							interaction = deathInteraction;
 							interaction.mutatedRuntimeState = true;
 						}
@@ -3472,7 +3478,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		bool didTransition = false;
 		if (shouldDispatchPickupActionOnDirectInventoryTransfer(object)) {
 			InteractionResult pickupInteraction;
-			if (script->executeActionTag(object.actionTag, pickupInteraction)) {
+			if (script->executeActionTag(
+					object.actionTag, pickupInteraction, true, scene.state.roomName)) {
 				Common::Error interactionError =
 					interactionProcessor.handleInteractionResult(
 						pickupInteraction, didTransition, Common::String());
@@ -3517,7 +3524,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		bool didTransition = false;
 		if (shouldDispatchPickupActionOnCarryStart(object)) {
 			InteractionResult pickupInteraction;
-			if (script->executeActionTag(object.actionTag, pickupInteraction)) {
+			if (script->executeActionTag(
+					object.actionTag, pickupInteraction, true, scene.state.roomName)) {
 				Common::Error interactionError =
 					interactionProcessor.handleInteractionResult(
 						pickupInteraction, didTransition, Common::String());
@@ -3561,7 +3569,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		const Common::String selectedItemName = _inventory.getSelectedItemName();
 		InteractionResult interaction;
 		const bool handled = _engine.getScript()->resolveUseItemInteraction(
-			selectedItemName, target, interaction);
+			selectedItemName, target, interaction, scene.state.roomName);
 		if (!handled) {
 			debugC(1, kDebugInventory,
 				"Harvester: inventory target miss selected='%s' target='%s' owner='%s' action='%s'",
@@ -3647,7 +3655,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 	};
 	auto runRegionInteraction = [&](const RegionRecord &region) -> Common::Error {
 		InteractionResult interaction;
-		if (!_engine.getScript()->resolveRegionInteraction(region, interaction))
+		if (!_engine.getScript()->resolveRegionInteraction(region, interaction, scene.state.roomName))
 			return Common::kNoError;
 
 		bool didTransition = false;
@@ -3948,7 +3956,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 							InteractionResult interaction;
 							bool didTransition = false;
 							const bool executedActionTag = _engine.getScript()->executeActionTag(
-									secondaryAction.actionTag, interaction);
+									secondaryAction.actionTag, interaction, true, scene.state.roomName);
 							debugC(1, kDebugInventory,
 								"Harvester: inventory right click executeActionTag('%s') -> %d music='%s' cutscene='%s' nextRoom='%s'",
 								secondaryAction.actionTag.c_str(), executedActionTag,
@@ -4287,7 +4295,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				}
 
 				InteractionResult interaction;
-				if (!_engine.getScript()->resolveObjectInteraction(*clickedObject, interaction)) {
+				if (!_engine.getScript()->resolveObjectInteraction(
+						*clickedObject, interaction, scene.state.roomName)) {
 					if (canShowInspectText) {
 						inspectText = resolvedInspectText;
 						showingInspectText = true;
@@ -4599,7 +4608,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				syncCurrentRoomRuntimeState();
 				for (const Common::String &timerName : expiredTimerNames) {
 					InteractionResult timerInteraction;
-					if (!_engine.getScript()->executeTimerAction(timerName, timerInteraction))
+					if (!_engine.getScript()->executeTimerAction(
+							timerName, timerInteraction, true, scene.state.roomName))
 						continue;
 
 					bool didTransition = false;
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 407d528829d..a751eb599d1 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1661,7 +1661,8 @@ bool Script::executeRoomEnterCommands(const Common::String &roomName,
 	if (!room)
 		return false;
 
-	executeCommandChain(room->onEnterCommand, "room entry command", room->roomName, true,
+	executeCommandChain(room->onEnterCommand, "room entry command", room->roomName,
+		room->roomName, true,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
 		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu, &result.cdChangeDisc,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
@@ -1679,7 +1680,8 @@ bool Script::executeRoomExitCommands(const Common::String &roomName,
 	if (!room)
 		return false;
 
-	executeCommandChain(room->onExitCommand, "room exit command", room->roomName, false,
+	executeCommandChain(room->onExitCommand, "room exit command", room->roomName,
+		room->roomName, false,
 		&result.musicPath, &result.audioCommands, nullptr, nullptr, &result.cutscenePath,
 		nullptr, nullptr, &result.cdChangeDisc, &result.dialogueNpcName, &result.dialogueContinuationTag,
 		&result.continuationTag, &result.modalText, &result.lightingCommand,
@@ -1688,8 +1690,15 @@ bool Script::executeRoomExitCommands(const Common::String &roomName,
 	return true;
 }
 
-bool Script::resolveObjectInteraction(const ObjectRecord &object, InteractionResult &result) {
+bool Script::resolveObjectInteraction(const ObjectRecord &object, InteractionResult &result,
+		const Common::String &roomName) {
 	result = InteractionResult();
+	Common::String commandRoomName = roomName;
+	if (commandRoomName.empty() &&
+			!object.currentOwnerOrRoom.empty() &&
+			!object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)) {
+		commandRoomName = object.currentOwnerOrRoom;
+	}
 
 	if (isPickupObject(object)) {
 		if (ObjectRecord *currentObject = findRuntimeObject(object.currentOwnerOrRoom, object.objectName)) {
@@ -1703,7 +1712,8 @@ bool Script::resolveObjectInteraction(const ObjectRecord &object, InteractionRes
 	if (object.actionTag.empty())
 		return result.mutatedRuntimeState;
 
-	executeCommandChain(object.actionTag, "interaction command", object.objectName, true,
+	executeCommandChain(object.actionTag, "interaction command", object.objectName,
+		commandRoomName, true,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
 		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu, &result.cdChangeDisc,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
@@ -1720,12 +1730,15 @@ bool Script::resolveObjectInteraction(const ObjectRecord &object, InteractionRes
 		hasActionableCommandChain(object.actionTag);
 }
 
-bool Script::resolveRegionInteraction(const RegionRecord &region, InteractionResult &result) {
+bool Script::resolveRegionInteraction(const RegionRecord &region, InteractionResult &result,
+		const Common::String &roomName) {
 	result = InteractionResult();
 	if (region.actionTag.empty())
 		return false;
+	const Common::String &commandRoomName = roomName.empty() ? region.roomName : roomName;
 
-	executeCommandChain(region.actionTag, "region command", region.regionName, true,
+	executeCommandChain(region.actionTag, "region command", region.regionName,
+		commandRoomName, true,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
 		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu, &result.cdChangeDisc,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
@@ -1742,15 +1755,22 @@ bool Script::resolveRegionInteraction(const RegionRecord &region, InteractionRes
 }
 
 bool Script::resolveUseItemInteraction(const Common::String &itemName, const ObjectRecord &target,
-		InteractionResult &result) {
+		InteractionResult &result, const Common::String &roomName) {
 	result = InteractionResult();
 
 	const UseItemRecord *useItem = findUseItemRecord(itemName, target);
 	if (!useItem)
 		return false;
+	Common::String commandRoomName = roomName;
+	if (commandRoomName.empty() &&
+			!target.currentOwnerOrRoom.empty() &&
+			!target.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)) {
+		commandRoomName = target.currentOwnerOrRoom;
+	}
 
 	executeCommandChain(useItem->actionTag, "useitem command",
-		Common::String::format("%s -> %s", itemName.c_str(), target.objectName.c_str()), true,
+		Common::String::format("%s -> %s", itemName.c_str(), target.objectName.c_str()),
+		commandRoomName, true,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
 		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu, &result.cdChangeDisc,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
@@ -1784,12 +1804,12 @@ bool Script::executeDebugCommand(const CommandRecord &command,
 }
 
 bool Script::executeActionTag(const Common::String &tag, InteractionResult &result,
-		bool allowTransitions) {
+		bool allowTransitions, const Common::String &roomName) {
 	result = InteractionResult();
 	if (tag.empty())
 		return false;
 
-	executeCommandChain(tag, "action tag", tag, allowTransitions,
+	executeCommandChain(tag, "action tag", tag, roomName, allowTransitions,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
 		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu, &result.cdChangeDisc,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
@@ -1807,7 +1827,7 @@ bool Script::executeActionTag(const Common::String &tag, InteractionResult &resu
 }
 
 bool Script::executeTimerAction(const Common::String &timerName, InteractionResult &result,
-		bool allowTransitions) {
+		bool allowTransitions, const Common::String &roomName) {
 	result = InteractionResult();
 	if (timerName.empty())
 		return false;
@@ -1821,8 +1841,10 @@ bool Script::executeTimerAction(const Common::String &timerName, InteractionResu
 		debug(1, "Harvester: timer '%s' has no action tag", timerName.c_str());
 		return false;
 	}
+	const Common::String &commandRoomName = roomName.empty() ? timer->arg1 : roomName;
 
-	executeCommandChain(timer->arg2, "timer command", timer->timerName, allowTransitions,
+	executeCommandChain(timer->arg2, "timer command", timer->timerName, commandRoomName,
+		allowTransitions,
 		&result.musicPath, &result.audioCommands, &result.nextRoomName, &result.roomTransition,
 		&result.cutscenePath, &result.deathFlicPath, &result.requestMainMenu, &result.cdChangeDisc,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
@@ -2570,7 +2592,8 @@ bool Script::buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord
 }
 
 void Script::executeCommandChain(const Common::String &initialTag, const char *contextLabel,
-		const Common::String &contextName, bool allowTransitions, Common::String *musicPath,
+		const Common::String &contextName, const Common::String &contextRoomName,
+		bool allowTransitions, Common::String *musicPath,
 		Common::Array<AudioCommand> *audioCommands, Common::String *nextRoomName,
 		StartupRoomTransitionKind *roomTransition,
 		Common::String *cutscenePath, Common::String *deathFlicPath, bool *requestMainMenu,
@@ -2771,7 +2794,10 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("SET_MONSTER")) {
-			MonsterRecord *currentMonster = findRuntimeMonster(command->arg1);
+			MonsterRecord monsterLookup;
+			monsterLookup.roomName = contextRoomName;
+			monsterLookup.monsterName = command->arg1;
+			MonsterRecord *currentMonster = findRuntimeMonster(monsterLookup);
 			if (!currentMonster) {
 				debug(1, "Harvester: unresolved monster for %s '%s' monster='%s'",
 					contextLabel, contextName.c_str(), command->arg1.c_str());
@@ -2821,7 +2847,8 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			}
 
 			for (const Common::String &entry : execList->entries) {
-				executeCommandChain(entry, "exec list entry", execList->listName, allowTransitions,
+				executeCommandChain(entry, "exec list entry", execList->listName,
+					contextRoomName, allowTransitions,
 					musicPath, audioCommands, nextRoomName, roomTransition, cutscenePath,
 					deathFlicPath, requestMainMenu, cdChangeDisc, dialogueNpcName, dialogueContinuationTag,
 					continuationTag, modalText, lightingCommand, requestPlayerGotoXZ,
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 06e3b3bc24d..47fd5102496 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -359,16 +359,18 @@ public:
 		RoomSetupState &state, ResourceManager &resources) const;
 	bool executeRoomEnterCommands(const Common::String &roomName, InteractionResult &result);
 	bool executeRoomExitCommands(const Common::String &roomName, InteractionResult &result);
-	bool resolveObjectInteraction(const ObjectRecord &object, InteractionResult &result);
-	bool resolveRegionInteraction(const RegionRecord &region, InteractionResult &result);
+	bool resolveObjectInteraction(const ObjectRecord &object, InteractionResult &result,
+		const Common::String &roomName = Common::String());
+	bool resolveRegionInteraction(const RegionRecord &region, InteractionResult &result,
+		const Common::String &roomName = Common::String());
 	bool resolveUseItemInteraction(const Common::String &itemName, const ObjectRecord &target,
-		InteractionResult &result);
+		InteractionResult &result, const Common::String &roomName = Common::String());
 	bool executeDebugCommand(const CommandRecord &command, InteractionResult &result,
 		bool allowTransitions = true);
 	bool executeActionTag(const Common::String &tag, InteractionResult &result,
-		bool allowTransitions = true);
+		bool allowTransitions = true, const Common::String &roomName = Common::String());
 	bool executeTimerAction(const Common::String &timerName, InteractionResult &result,
-		bool allowTransitions = true);
+		bool allowTransitions = true, const Common::String &roomName = Common::String());
 	bool executeNestedActionTag(const Common::String &tag, InteractionResult &result,
 		bool allowTransitions = true);
 	bool isPickupObject(const ObjectRecord &object) const;
@@ -440,7 +442,8 @@ private:
 	bool buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord *entrance,
 		ResourceManager &resources, RoomSetupState &state) const;
 	void executeCommandChain(const Common::String &initialTag, const char *contextLabel,
-		const Common::String &contextName, bool allowTransitions, Common::String *musicPath,
+		const Common::String &contextName, const Common::String &contextRoomName,
+		bool allowTransitions, Common::String *musicPath,
 		Common::Array<AudioCommand> *audioCommands, Common::String *nextRoomName,
 		StartupRoomTransitionKind *roomTransition,
 		Common::String *cutscenePath, Common::String *deathFlicPath, bool *requestMainMenu,


Commit: 50e30ec129b41c02005b6d2793edaec254f5c381
    https://github.com/scummvm/scummvm/commit/50e30ec129b41c02005b6d2793edaec254f5c381
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:39+02:00

Commit Message:
HARVESTER: Tighten monster room-entry combat

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index d8e17e6bcf4..0572f387777 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1073,6 +1073,7 @@
   - For class-6 monsters specifically, pursuit is not a free-running room-script heuristic: the native state machine first closes base Z to within `g_player_combat_avatar->z +/- 2.0`, then compares center-to-center X against `engage_distance`, and only arms the close-range attack picker after that spacing gate is satisfied.
   - The same class-6 path derives its horizontal chase waypoint from the player's live left edge plus the current player/monster frame widths and treats that waypoint as satisfied within `50.0 * depth_scale` pixels, so native monster pursuit does not require exact center equality before it can stop walking and attack.
   - Once that close-range gate is satisfied and the per-attack cooldown allows it, the class-6 branch stores `g_player_combat_avatar` into live actor field `+0x11a4` before entering the close-range attack family; the later contact block resolves against that linked target instead of re-running the coarse engage-distance spacing test.
+  - The class-6 cooldown latch is also now bounded: runtime dword `+0x1144` stores `current_tick + random(99)` when an attack is armed, and the state machine only re-arms once `stored_tick + 0x32 < current_tick` unless the target is already within `engage_distance / 3`.
   - Fresh monster spawns do not wait in a neutral idle loop: `spawn_monster_entity_from_record` immediately seeds desired state `0x07` or `0x0e` from the monster's placement relative to the player, which is the native locomotion handoff into left/right pursuit.
   - It also contains the confirmed NPC-to-monster replacement path used by the `change2monster` flow.
   - The attack-state reader side is now bounded directly from states `0x16` through `0x1b`:
diff --git a/TRACKER.md b/TRACKER.md
index 29cf26f89cd..bef5b046828 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 1, 2026, rechecked the native room-action dispatcher in Ghidra before touching the remaining `SET_MONSTER` path: `dispatch_room_event_actions` at `0x60ee0` resolves monster records by `monster_name`, then gates the live spawn/remove work on `record->room_name == g_current_room_def->room_name`. Updated the ScummVM Harvester action-dispatch paths to carry explicit current-room context into room entry/exit commands, continuations, timers, pickup actions, inventory secondary actions, and monster/NPC death tags so `SET_MONSTER` now prefers the current-room runtime record before falling back to name-only resolution. Verified with a successful `make -j4 engines/harvester/script.o engines/harvester/room.o`.
+- On April 1, 2026, rechecked the native room-entry monster wake-up path in Ghidra before tightening CAINS pursuit timing: `spawn_monster_entity_from_record` at `0x53a10` seeds the live monster desired state byte to `0x07` or `0x0e` immediately from the player's relative X position, so fresh class-6 spawns are primed for pursuit as soon as the room loop starts. Updated the ScummVM Harvester room-combat shim to allow one immediate pursue evaluation for newly entered active monsters instead of waiting for the first animation-driven movement tick, which tightens room-entry aggro without inventing extra AI semantics. Verified with a successful `make -j4 engines/harvester/room.o`.
 
 ## Next Suggested Action
 
-- Runtime-smoke the exact regression path again: kill the CRYPT `WOLFIE`, transition into DINING, and confirm its `WOLFIE` materializes alive with full hit points. If the issue still reproduces, inspect the remaining room-agnostic `executeActionTag` callers outside the room loop (for example dialogue helper files) and confirm whether any duplicate `WOLFIE` action tags still enter the dispatcher without an explicit room context.
+- Runtime-smoke the CAINS room entry again without touching movement keys: enter `CAINS`, wait one second, and confirm the log now emits `Harvester: combat monster chase move ...` before the first player input. If CAIN still stays planted until input, capture the exact first second of post-entry combat logs so we can compare the room-loop ordering against native `run_harvester_main_loop -> tick_monster_entity_runtime`.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 47c4f0382f9..0bf11dfc319 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -143,8 +143,9 @@ struct RoomMonsterCombatState {
 	int deathFirstFrame = -1;
 	int deathLastFrame = -1;
 	int deathDamageType = 0;
+	bool allowInitialPursuitStep = true;
 	uint32 nextMovementTick = 0;
-	uint32 nextAttackAllowedTick = 0;
+	uint32 attackCooldownSeedTick = 0;
 };
 
 struct RoomNpcCombatState {
@@ -3231,49 +3232,47 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
 				continue;
 			}
-				if (combatState.nextAttackAllowedTick == 0)
-					combatState.nextAttackAllowedTick = now + kNativeMonsterAttackCooldownBaseTicks;
-
-				if (combatState.hitActive) {
-					const int currentFrame = entity->getCurrentFrame();
-					if (!combatState.hitSoundPlayed &&
-							monster.hitSoundTriggerFrame >= 0 &&
-							currentFrame >= combatState.hitFirstFrame + monster.hitSoundTriggerFrame) {
-						(void)playRandomRoomAttackSound(_engine,
-							monster.hitSound1, monster.hitSound2, monster.hitSound3);
-						combatState.hitSoundPlayed = true;
-					}
-					if (combatState.hitKnockbackRemainingX != 0) {
-						const int step = MAX(1, ABS(combatState.hitKnockbackDecayStep));
-						const int knockbackStep = combatState.hitKnockbackRemainingX > 0
-							? MIN(combatState.hitKnockbackRemainingX, step)
-							: MAX(combatState.hitKnockbackRemainingX, -step);
-						const int previousX = monster.posX;
-						monster.posX = CLIP<int>(
-							monster.posX + knockbackStep, monster.screenMinXBound, monster.screenMaxXBound);
-						if (monster.posX != previousX) {
-							(void)applyRoomActorPlacement(
-								scene.state, *entity, monster.posX, monster.posY, (float)monster.posZ);
-							(void)script->syncRuntimeMonsterRecord(monster);
-							debugC(1, kDebugCombat,
-								"Harvester: combat monster hit knockback target='%s' from_x=%d to_x=%d remaining=%d",
-								monster.monsterName.c_str(), previousX, monster.posX,
-								combatState.hitKnockbackRemainingX - (monster.posX - previousX));
-							needsRedraw = true;
-							combatState.hitKnockbackRemainingX -= monster.posX - previousX;
-						} else {
-							combatState.hitKnockbackRemainingX = 0;
-						}
-					}
-					if (currentFrame == combatState.hitLastFrame) {
-						clearRoomMonsterHitState(combatState);
-						needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
+
+			if (combatState.hitActive) {
+				const int currentFrame = entity->getCurrentFrame();
+				if (!combatState.hitSoundPlayed &&
+						monster.hitSoundTriggerFrame >= 0 &&
+						currentFrame >= combatState.hitFirstFrame + monster.hitSoundTriggerFrame) {
+					(void)playRandomRoomAttackSound(_engine,
+						monster.hitSound1, monster.hitSound2, monster.hitSound3);
+					combatState.hitSoundPlayed = true;
+				}
+				if (combatState.hitKnockbackRemainingX != 0) {
+					const int step = MAX(1, ABS(combatState.hitKnockbackDecayStep));
+					const int knockbackStep = combatState.hitKnockbackRemainingX > 0
+						? MIN(combatState.hitKnockbackRemainingX, step)
+						: MAX(combatState.hitKnockbackRemainingX, -step);
+					const int previousX = monster.posX;
+					monster.posX = CLIP<int>(
+						monster.posX + knockbackStep, monster.screenMinXBound, monster.screenMaxXBound);
+					if (monster.posX != previousX) {
+						(void)applyRoomActorPlacement(
+							scene.state, *entity, monster.posX, monster.posY, (float)monster.posZ);
+						(void)script->syncRuntimeMonsterRecord(monster);
+						debugC(1, kDebugCombat,
+							"Harvester: combat monster hit knockback target='%s' from_x=%d to_x=%d remaining=%d",
+							monster.monsterName.c_str(), previousX, monster.posX,
+							combatState.hitKnockbackRemainingX - (monster.posX - previousX));
+						needsRedraw = true;
+						combatState.hitKnockbackRemainingX -= monster.posX - previousX;
+					} else {
+						combatState.hitKnockbackRemainingX = 0;
 					}
-					continue;
 				}
+				if (currentFrame == combatState.hitLastFrame) {
+					clearRoomMonsterHitState(combatState);
+					needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
+				}
+				continue;
+			}
 
-				if (combatState.attackActive) {
-					const int currentFrame = entity->getCurrentFrame();
+			if (combatState.attackActive) {
+				const int currentFrame = entity->getCurrentFrame();
 				if (!combatState.attackSoundPlayed &&
 						monster.attackSoundTriggerFrame >= 0 &&
 						currentFrame >= combatState.attackFirstFrame + monster.attackSoundTriggerFrame) {
@@ -3345,13 +3344,18 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				entity->isAnimationEnabled() && entity->getAnimationRate() != 0;
 			if (animationDrivesMovement) {
 				combatState.nextMovementTick = 0;
-				if (!entity->didAnimationAdvanceLastTick())
+				// Native room-entry monster spawns seed a left/right pursue state immediately
+				// from the player's relative X position. Our room-combat shim does not model
+				// those actor-state bytes directly, so allow one immediate pursue evaluation
+				// before the first animation-driven movement tick lands.
+				if (!combatState.allowInitialPursuitStep && !entity->didAnimationAdvanceLastTick())
 					continue;
 			} else {
 				if (combatState.nextMovementTick != 0 && (int32)(now - combatState.nextMovementTick) < 0)
 					continue;
 				combatState.nextMovementTick = now + moveInterval;
 			}
+			combatState.allowInitialPursuitStep = false;
 
 			const int previousX = monster.posX;
 			const int previousY = monster.posY;
@@ -3421,7 +3425,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			needsRedraw = setRoomMonsterAnimation(*entity, monster.facing, false) || needsRedraw;
 			const bool closeEnoughForAttack = isWithinNativeMonsterAttackEntryRange(
 				*entity, (float)monster.posZ, *playerState.entity, playerState.z, engageDistance);
-			const bool attackCooldownExpired = (int32)(now - combatState.nextAttackAllowedTick) > 0;
+			// Native class-6 monsters store `now + random(99)` when they arm an attack, then
+			// apply the fixed 0x32-tick gate when deciding whether a later strike may start.
+			// A fresh zeroed spawn therefore attacks immediately once it reaches engage range.
+			const uint32 attackCooldownDeadline =
+				combatState.attackCooldownSeedTick + kNativeMonsterAttackCooldownBaseTicks;
+			const bool attackCooldownExpired = combatState.attackCooldownSeedTick == 0 ||
+				(int32)(now - attackCooldownDeadline) > 0;
 			const int closeRangeBypassDistance = MAX(1, engageDistance / 3);
 			if (!closeEnoughForAttack)
 				continue;
@@ -3445,18 +3455,17 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				monster.attackSound1.empty() && monster.attackSound2.empty() && monster.attackSound3.empty();
 			combatState.attackContactResolved = false;
 			combatState.attackTargetName = playerState.entity->getName();
-			combatState.nextAttackAllowedTick = now + kNativeMonsterAttackCooldownBaseTicks +
-				_engine.getRandomNumber(99);
+			combatState.attackCooldownSeedTick = now + _engine.getRandomNumber(99);
 			monster.facing = range.resumeFacing;
 			entity->setAnimationFrameRange(range.firstFrame, range.lastFrame, false);
 			entity->setAnimationRate(kNativeMonsterAttackAnimationRate);
 			entity->setAnimationEnabled(true);
 			entity->setCurrentFrame(range.firstFrame);
 			debugC(1, kDebugCombat,
-				"Harvester: combat monster attack start monster='%s' target='%s' frames=%d..%d contact=%d resume_facing=%d live_center_dx=%d z_delta=%.2f engage=%d rate=%d next_attack_tick=%u close_bypass=%d",
+				"Harvester: combat monster attack start monster='%s' target='%s' frames=%d..%d contact=%d resume_facing=%d live_center_dx=%d z_delta=%.2f engage=%d rate=%d attack_seed_tick=%u close_bypass=%d",
 				monster.monsterName.c_str(), combatState.attackTargetName.c_str(), range.firstFrame, range.lastFrame,
 				combatState.attackContactFrame, range.resumeFacing, liveCenterDx, (double)zDelta,
-				engageDistance, kNativeMonsterAttackAnimationRate, combatState.nextAttackAllowedTick,
+				engageDistance, kNativeMonsterAttackAnimationRate, combatState.attackCooldownSeedTick,
 				closeRangeBypassDistance);
 			needsRedraw = true;
 		}


Commit: c8f8f38d74c8a4bcf7c787cb52b44849d6e4b072
    https://github.com/scummvm/scummvm/commit/c8f8f38d74c8a4bcf7c787cb52b44849d6e4b072
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:39+02:00

Commit Message:
HARVESTER: Restore inventory combat hud feedback

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/art.h
    engines/harvester/room.cpp
    engines/harvester/saveload.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 0572f387777..9e4c97c42dd 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1050,9 +1050,10 @@
     - `2`: nailgun
     - `3`: shotgun shells
     - `4`: `9GUN` bullet strip (`MMBULLET%i`, `bullet.bm`)
-    - `5`: `38GUN` shotshell strip (`SHOTSHEL%i`, `shotshel.bm`)
+    - `5`: `38GUN` bullet strip (`bullet.bm`)
     - `0xe`: chainsaw fuel
   - The corresponding globals are now named `g_nailgun_ammo_icon_strip`, `g_shotgun_shell_icon_strip`, `g_9gun_bullet_icon_strip`, `g_38gun_shotshell_icon_strip`, `g_chainsaw_fuel_icon_strip`, their matching count fields, and `g_visible_weapon_resource_icon_count`.
+  - `run_harvester_main_loop` pre-spawns those icon-strip entities at `x = 10 + 15 * i`, `y = 30` with capacities `16` nails, `16` shotgun shells, `8` `9GUN` bullets, `6` `.38` bullets, and `16` gas cans.
 - `teardown_player_combat_avatar` at `0x55010` removes the live player combat avatar and frees its weapon-dependent runtime resources before a rebuild or shutdown path.
 - `teardown_entity_runtime_state` at `0x55030` is the shared runtime-entity cleanup layer under the combat-avatar teardown/rebuild paths.
   - It clears the linked spawned actor at `+0x11a8`, frees the runtime sound/sample fields from `+0x1148` through `+0x1168`, resets the entity to a hidden offscreen idle state, and frees the current bitmap buffers without freeing the outer runtime-entity allocation.
@@ -1158,6 +1159,7 @@
 - `dispatch_action_tag_if_set` at `0x7cb80` is a tiny guard thunk that jumps into `dispatch_room_event_actions` only when the supplied action-tag pointer is non-null.
 - `run_inventory_screen` at `0x7df10` is the inventory UI loop.
   - It lays out carried objects, shows `inventory_text_key` strings from `TEXT` records, handles use-on-target dispatch, and swaps the status panel object based on current health.
+  - The weapon right-click branch maps the weapon subset (`CLEAVER` through `POOLSTICK`) to loadout ids `1..20`, removes any visible weapon-resource strip, formats `Disarming %s...` or `Arming %s, %i ...` inside `BOX1`, then calls `set_player_combat_loadout`.
 - `run_game_over_screen` at `0x7c540` is the death/game-over UI branch reached from the main loop after the player combat avatar enters its terminal failure path.
 - The low-level PCM/sample helpers are now bounded:
   - `load_sound_sample` at `0x18470` loads a one-shot sample through XFILE, accepting raw `WAVE` data or `FCMP`-compressed audio, and seeds the sample format/rate metadata used by the audio backend.
diff --git a/TRACKER.md b/TRACKER.md
index bef5b046828..f8ca5eb2f8d 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 1, 2026, rechecked the native room-entry monster wake-up path in Ghidra before tightening CAINS pursuit timing: `spawn_monster_entity_from_record` at `0x53a10` seeds the live monster desired state byte to `0x07` or `0x0e` immediately from the player's relative X position, so fresh class-6 spawns are primed for pursuit as soon as the room loop starts. Updated the ScummVM Harvester room-combat shim to allow one immediate pursue evaluation for newly entered active monsters instead of waiting for the first animation-driven movement tick, which tightens room-entry aggro without inventing extra AI semantics. Verified with a successful `make -j4 engines/harvester/room.o`.
+- On April 1, 2026, rechecked the native inventory/combat HUD path in Ghidra before wiring ScummVM's Harvester inventory toggle feedback: `run_inventory_screen` at `0x7df10` removes any visible combat-resource strip, formats `Disarming %s...` or loadout-specific `Arming %s, %i ...` text inside `BOX1`, then calls `set_player_combat_loadout`, while `run_harvester_main_loop` pre-spawns the ammo/fuel icon strips at `x = 10 + 15 * i`, `y = 30` with capacities `16/16/8/6/16` for nails, shells, `9GUN` bullets, `.38` bullets, and gas. Updated the ScummVM Harvester runtime to preserve those combat resource counts, execute the confirmed `ADD_SHOTGUN_SHELLS` / `ADD_NAILS` / `ADD_GASCANS` script opcodes, draw the matching HUD strip, and show the native-style arming/disarming status box on inventory weapon toggles.
 
 ## Next Suggested Action
 
-- Runtime-smoke the CAINS room entry again without touching movement keys: enter `CAINS`, wait one second, and confirm the log now emits `Harvester: combat monster chase move ...` before the first player input. If CAIN still stays planted until input, capture the exact first second of post-entry combat logs so we can compare the room-loop ordering against native `run_harvester_main_loop -> tick_monster_entity_runtime`.
+- Runtime-smoke the inventory combat path with shells in hand: right-click `SHOTSHELL` to run `ADD12SHELLS`, right-click `SHOTGUN`, and confirm the room view now shows both the `BOX1` arming/disarming message and a shell strip at the native top-left HUD position. If the shell count still fails to change after `ADD12SHELLS`, capture the corresponding `Harvester: action tag` / `inventory secondary action` log lines so we can compare the command chain against native `dispatch_room_event_actions`.
diff --git a/engines/harvester/art.h b/engines/harvester/art.h
index cdca733cbbe..807ddb8dd55 100644
--- a/engines/harvester/art.h
+++ b/engines/harvester/art.h
@@ -58,6 +58,7 @@ public:
 	const IndexedBitmap &getInventoryBitmap() const { return _inventoryBitmap; }
 	const IndexedBitmap &getLogoBitmap() const { return _logoBitmap; }
 	const IndexedBitmap &getTipsBitmap() const { return _tipsBitmap; }
+	const Common::Array<IndexedBitmap> &getAmmoIcons() const { return _ammoIcons; }
 	const IndexedBitmap *getTextboxBitmap(uint index) const {
 		return index < _textboxes.size() ? &_textboxes[index] : nullptr;
 	}
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 0bf11dfc319..03c825df04e 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -58,6 +58,10 @@ static const int kNativeInventoryTooltipY = 0x19e;
 static const int kNativeInventoryWeekdayX = 0x1e0;
 static const int kNativeInventoryWeekdayY = 0x18c;
 static const byte kNativeInventoryTooltipColor = 0xf4;
+static const uint32 kCombatLoadoutStatusDisplayTicks = 75;
+static const int kNativeCombatResourceIconsX = 10;
+static const int kNativeCombatResourceIconsY = 30;
+static const int kNativeCombatResourceIconSpacing = 15;
 static const int kRoomMonsterAnimationRate = 17;
 static const int kNativeMonsterAttackAnimationRate = 4;
 static const int kNativeMonsterHitAnimationRate = 5;
@@ -170,6 +174,12 @@ struct RoomCombatDamagePopupState {
 	int damageAmount = 0;
 };
 
+struct CombatLoadoutHudInfo {
+	uint iconIndex = 0;
+	int maxVisibleCount = 0;
+	const char *unitLabel = nullptr;
+};
+
 static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
 	return !roomName.equalsIgnoreCase("LAVAPIT") &&
 		!roomName.equalsIgnoreCase("RMNBATH") &&
@@ -374,6 +384,89 @@ static void drawInventoryWeekday(Graphics::Screen &screen, const Graphics::Font
 		font.getStringWidth(weekdayText), kNativeInventoryTooltipColor);
 }
 
+static bool resolveCombatLoadoutHudInfo(int loadout, CombatLoadoutHudInfo &info) {
+	switch (loadout) {
+	case 2:
+		info.iconIndex = 2;
+		info.maxVisibleCount = 16;
+		info.unitLabel = "nails";
+		return true;
+	case 3:
+		info.iconIndex = 1;
+		info.maxVisibleCount = 16;
+		info.unitLabel = "shells";
+		return true;
+	case 4:
+		info.iconIndex = 0;
+		info.maxVisibleCount = 8;
+		info.unitLabel = "bullets";
+		return true;
+	case 5:
+		info.iconIndex = 0;
+		info.maxVisibleCount = 6;
+		info.unitLabel = "bullets";
+		return true;
+	case 14:
+		info.iconIndex = 3;
+		info.maxVisibleCount = 16;
+		info.unitLabel = "gas";
+		return true;
+	default:
+		return false;
+	}
+}
+
+static Common::String resolveCombatLoadoutStatusLabel(Script &script, const ObjectRecord &object) {
+	Common::String label = script.resolveObjectLabel(object);
+	if (!label.empty())
+		return label;
+
+	label = object.objectName;
+	for (uint i = 0; i < label.size(); ++i) {
+		if (label[i] == '_')
+			label.setChar(' ', i);
+	}
+
+	return label;
+}
+
+static Common::String buildCombatLoadoutStatusMessage(Script &script, const ObjectRecord &object,
+		int previousLoadout, int currentLoadout) {
+	const Common::String label = resolveCombatLoadoutStatusLabel(script, object);
+	if (label.empty())
+		return Common::String();
+
+	if (currentLoadout == 0 && previousLoadout != 0)
+		return Common::String::format("Disarming %s...", label.c_str());
+
+	CombatLoadoutHudInfo info;
+	if (resolveCombatLoadoutHudInfo(currentLoadout, info))
+		return Common::String::format("Arming %s, %d %s...", label.c_str(),
+			script.getPlayerCombatResourceCount(currentLoadout), info.unitLabel);
+
+	return Common::String::format("Arming %s...", label.c_str());
+}
+
+static void drawCombatLoadoutResourceIcons(Graphics::Screen &screen, const Art &art,
+		const Script &script, int loadout) {
+	CombatLoadoutHudInfo info;
+	if (!resolveCombatLoadoutHudInfo(loadout, info))
+		return;
+
+	const Common::Array<IndexedBitmap> &ammoIcons = art.getAmmoIcons();
+	if (info.iconIndex >= ammoIcons.size())
+		return;
+
+	const IndexedBitmap &icon = ammoIcons[info.iconIndex];
+	if (!icon.isValid())
+		return;
+
+	const int iconCount = CLIP<int>(script.getPlayerCombatResourceCount(loadout), 0, info.maxVisibleCount);
+	for (int i = 0; i < iconCount; ++i)
+		blitBitmap(screen, icon, kNativeCombatResourceIconsX + i * kNativeCombatResourceIconSpacing,
+			kNativeCombatResourceIconsY);
+}
+
 static byte findNearestPaletteColor(const byte *palette, byte red, byte green, byte blue) {
 	if (!palette)
 		return 0;
@@ -891,6 +984,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		ResolvedText inspectText;
 		bool showingInspectText = false;
 		bool inspectCanDismiss = false;
+		ResolvedText combatLoadoutStatusText;
+		uint32 combatLoadoutStatusHideTick = 0;
 		bool moveLeft = false;
 		bool moveRight = false;
 		bool moveUp = false;
@@ -917,6 +1012,22 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			carriedRoomItemLabel.clear();
 			carriedRoomItemBitmap = IndexedBitmap();
 		};
+		auto queueCombatLoadoutStatusMessage = [&](const Common::String &message) {
+			if (message.empty()) {
+				const bool hadMessage = !combatLoadoutStatusText.value.empty();
+				combatLoadoutStatusText = ResolvedText();
+				combatLoadoutStatusHideTick = 0;
+				if (hadMessage)
+					needsRedraw = true;
+				return;
+			}
+
+			combatLoadoutStatusText.boxName = "BOX1";
+			combatLoadoutStatusText.value = message;
+			combatLoadoutStatusHideTick =
+				Player::getRuntimeClockTicks() + kCombatLoadoutStatusDisplayTicks;
+			needsRedraw = true;
+		};
 		auto hideSceneObject = [&](const Common::String &objectName, const Common::String *ownerOrRoom) {
 			ObjectRecord *sceneObject = findSceneObjectByName(scene.sceneObjects, objectName);
 			if (!sceneObject)
@@ -3784,6 +3895,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		captureCurrentSaveState();
 		if (_inventory.isOpen() && _inventory.refreshIfRuntimeStateChanged())
 			needsRedraw = true;
+		if (!combatLoadoutStatusText.value.empty() &&
+				combatLoadoutStatusHideTick != 0 &&
+				(int32)(Player::getRuntimeClockTicks() - combatLoadoutStatusHideTick) >= 0) {
+			combatLoadoutStatusText = ResolvedText();
+			combatLoadoutStatusHideTick = 0;
+			needsRedraw = true;
+		}
 
 		if (needsRedraw) {
 			Graphics::Screen *activeScreen = getActiveScreen();
@@ -3861,6 +3979,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 			drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
 			drawCombatDamagePopups(*activeScreen);
+			if (_engine.getScript() && playerState.entity) {
+				drawCombatLoadoutResourceIcons(*activeScreen, *art, *_engine.getScript(),
+					_engine.getScript()->getPlayerCombatLoadout());
+			}
 			if (_inventory.isOpen()) {
 				_inventory.drawOverlay(*activeScreen);
 				drawInventoryWeekday(*activeScreen, *inventoryTooltipFont, _inventory.resolveWeekdayLabel());
@@ -3875,6 +3997,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 			if (showingInspectText) {
 				drawRoomInspectText(*activeScreen, *art, *inspectFont, inspectText, useNativeInspectFont);
+			} else if (!combatLoadoutStatusText.value.empty()) {
+				drawRoomInspectText(*activeScreen, *art, *inspectFont, combatLoadoutStatusText,
+					useNativeInspectFont);
 			} else if (!inventoryTooltipText.empty()) {
 				drawInventoryTooltip(*activeScreen, *inventoryTooltipFont, inventoryTooltipText);
 			} else if (!promptText.empty()) {
@@ -3931,6 +4056,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					if (inventoryHover &&
 							!InventorySystem::isExitObject(inventoryHover->object) &&
 							!InventorySystem::isStatusObject(inventoryHover->object)) {
+						Script *script = _engine.getScript();
+						const int previousLoadout = playerState.combatLoadout >= 0
+							? playerState.combatLoadout
+							: (script ? script->getPlayerCombatLoadout() : 0);
 						bool loadoutChanged = false;
 						if (_inventory.toggleCombatLoadout(inventoryHover->object,
 								playerState.combatLoadout, loadoutChanged)) {
@@ -3938,6 +4067,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 								"Harvester: inventory right click handled as combat toggle object='%s' changed=%d",
 								inventoryHover->object.objectName.c_str(), loadoutChanged);
 							if (loadoutChanged) {
+								if (script) {
+									const Common::String statusMessage = buildCombatLoadoutStatusMessage(
+										*script, inventoryHover->object, previousLoadout,
+										script->getPlayerCombatLoadout());
+									queueCombatLoadoutStatusMessage(statusMessage);
+								}
 								captureCurrentSaveState();
 								if (playerState.entity) {
 									(void)Player::syncCombatLoadoutVisual(_engine, scene.state, playerState,
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index 77bfc488c57..11803909b0c 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -39,10 +39,10 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 }
 
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
-// FIXME: This pre-release version-17 bump exists only to carry room-NPC corpse
-// runtimeState alongside the earlier monster corpse state. Fold it into the
-// final Harvester save layout before release.
-static const uint32 kHarvesterSaveVersion = 17;
+// FIXME: These pre-release save-version bumps exist only to stabilize the
+// Harvester room-runtime payload before release. Fold them into the final
+// shipped save layout once the engine lands.
+static const uint32 kHarvesterSaveVersion = 18;
 
 static void logStartupSaveRoomState(const char *operation, const SaveRoomState &state) {
 	debugC(1, kDebugRoom,
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index a751eb599d1..2f864c56f1e 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -293,6 +293,60 @@ static int clampPlayerCombatLoadout(int loadout) {
 	return CLIP<int>(loadout, 0, kMaxPlayerCombatLoadout);
 }
 
+static int resolveCombatResourceDisplayMax(int loadout) {
+	switch (loadout) {
+	case 2:
+	case 3:
+	case 14:
+		return 16;
+	case 4:
+		return 8;
+	case 5:
+		return 6;
+	default:
+		return 0;
+	}
+}
+
+static int clampCombatResourceCount(int loadout, int count) {
+	const int maxCount = resolveCombatResourceDisplayMax(loadout);
+	if (maxCount <= 0)
+		return 0;
+
+	return CLIP<int>(count, 0, maxCount);
+}
+
+static int resolveCombatResourcePickupMax(int loadout) {
+	switch (loadout) {
+	case 2:
+	case 3:
+	case 14:
+		return 15;
+	case 4:
+		return 7;
+	case 5:
+		return 5;
+	default:
+		return 0;
+	}
+}
+
+static int resolveCombatResourceLoadoutForOpcode(const Common::String &opcodeName) {
+	// Confirmed from dispatch_room_event_actions and HARVEST.SCR.
+	if (opcodeName.equalsIgnoreCase("ADD_NAILS"))
+		return 2;
+	if (opcodeName.equalsIgnoreCase("ADD_SHOTGUN_SHELLS"))
+		return 3;
+	if (opcodeName.equalsIgnoreCase("ADD_GASCANS"))
+		return 14;
+
+	return 0;
+}
+
+static bool isCombatResourceOpcode(const Common::String &opcodeName) {
+	return resolveCombatResourceLoadoutForOpcode(opcodeName) != 0;
+}
+
 static const char *resolveCombatLoadoutInventoryObjectName(int loadout) {
 	for (const InventoryCombatLoadoutEntry &entry : kInventoryCombatLoadoutMap) {
 		if (entry.loadoutId == loadout)
@@ -583,6 +637,11 @@ bool Script::reloadTownWorld(ResourceManager &resources) {
 	const Common::Array<NpcRecord> currentNpcs = _currentNpcs;
 	const Common::Array<MonsterRecord> currentMonsters = _currentMonsters;
 	const Common::Array<TimerRecord> currentTimers = _currentTimers;
+	const int nailgunAmmoCount = _nailgunAmmoCount;
+	const int shotgunShellCount = _shotgunShellCount;
+	const int nineGunBulletCount = _nineGunBulletCount;
+	const int thirtyEightGunBulletCount = _thirtyEightGunBulletCount;
+	const int chainsawFuelCount = _chainsawFuelCount;
 	const int playerCurrentHitPoints = _playerCurrentHitPoints;
 	const int playerCombatLoadout = _playerCombatLoadout;
 	const bool playerControlPaused = _playerControlPaused;
@@ -696,6 +755,11 @@ bool Script::reloadTownWorld(ResourceManager &resources) {
 	}
 
 	_playerCurrentHitPoints = clampPlayerHitPoints(playerCurrentHitPoints);
+	_nailgunAmmoCount = clampCombatResourceCount(2, nailgunAmmoCount);
+	_shotgunShellCount = clampCombatResourceCount(3, shotgunShellCount);
+	_nineGunBulletCount = clampCombatResourceCount(4, nineGunBulletCount);
+	_thirtyEightGunBulletCount = clampCombatResourceCount(5, thirtyEightGunBulletCount);
+	_chainsawFuelCount = clampCombatResourceCount(14, chainsawFuelCount);
 	_playerCombatLoadout = validatePlayerCombatLoadoutAgainstInventory(
 		_currentObjects, playerCombatLoadout, "town reload");
 	_playerControlPaused = playerControlPaused;
@@ -1362,6 +1426,11 @@ void Script::resetRuntimeState() {
 	_currentNpcs = _npcs;
 	_currentMonsters = _monsters;
 	_currentTimers = _timers;
+	_nailgunAmmoCount = 0;
+	_shotgunShellCount = 0;
+	_nineGunBulletCount = 0;
+	_thirtyEightGunBulletCount = 0;
+	_chainsawFuelCount = 0;
 	_playerCurrentHitPoints = Script::kDefaultPlayerHitPoints;
 	_playerCombatLoadout = kDefaultPlayerCombatLoadout;
 	_playerControlPaused = false;
@@ -1402,12 +1471,13 @@ void Script::resetRuntimeState() {
 
 void Script::logRuntimeSaveState(const char *operation) const {
 	debugC(1, kDebugGeneral,
-		"Harvester: %s runtime save state flags=%u objects=%u anims=%u regions=%u npcs=%u monsters=%u timers=%u hp=%d loadout=%d paused=%d",
+		"Harvester: %s runtime save state flags=%u objects=%u anims=%u regions=%u npcs=%u monsters=%u timers=%u hp=%d loadout=%d paused=%d ammo=[nails:%d shells:%d bullets9:%d bullets38:%d gas:%d]",
 		operation, (uint)_currentFlags.size(), (uint)_currentObjects.size(),
 		(uint)_currentAnimations.size(), (uint)_currentRegions.size(),
 		(uint)_currentNpcs.size(), (uint)_currentMonsters.size(),
 		(uint)_currentTimers.size(), _playerCurrentHitPoints, _playerCombatLoadout,
-		_playerControlPaused);
+		_playerControlPaused, _nailgunAmmoCount, _shotgunShellCount,
+		_nineGunBulletCount, _thirtyEightGunBulletCount, _chainsawFuelCount);
 
 	for (const FlagRecord &flag : _currentFlags) {
 		const FlagRecord *baseFlag = nullptr;
@@ -1569,10 +1639,32 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 		syncRecordArray(s, _currentTimers, syncStartupTimerRecord);
 		s.syncAsSint32LE(_playerCombatLoadout);
 		syncBool(s, _playerControlPaused);
+		// FIXME: Remove this pre-release version-18 gate once Harvester ships and all
+		// saves carry combat resource counts for the room HUD and ranged weapons.
+		if (s.getVersion() >= 18) {
+			s.syncAsSint32LE(_nailgunAmmoCount);
+			s.syncAsSint32LE(_shotgunShellCount);
+			s.syncAsSint32LE(_nineGunBulletCount);
+			s.syncAsSint32LE(_thirtyEightGunBulletCount);
+			s.syncAsSint32LE(_chainsawFuelCount);
+		}
 	}
 	if (s.isLoading()) {
 		_playerCurrentHitPoints = clampPlayerHitPoints(_playerCurrentHitPoints);
 		_playerCombatLoadout = clampPlayerCombatLoadout(_playerCombatLoadout);
+		if (s.getVersion() >= 18) {
+			_nailgunAmmoCount = clampCombatResourceCount(2, _nailgunAmmoCount);
+			_shotgunShellCount = clampCombatResourceCount(3, _shotgunShellCount);
+			_nineGunBulletCount = clampCombatResourceCount(4, _nineGunBulletCount);
+			_thirtyEightGunBulletCount = clampCombatResourceCount(5, _thirtyEightGunBulletCount);
+			_chainsawFuelCount = clampCombatResourceCount(14, _chainsawFuelCount);
+		} else {
+			_nailgunAmmoCount = 0;
+			_shotgunShellCount = 0;
+			_nineGunBulletCount = 0;
+			_thirtyEightGunBulletCount = 0;
+			_chainsawFuelCount = 0;
+		}
 		// FIXME: Drop this compatibility pass after release; it exists only to repair
 		// pre-release saves written before the monster combat field layout stabilized.
 		if (s.getVersion() >= 2) {
@@ -2306,6 +2398,61 @@ bool Script::adjustPlayerCurrentHitPoints(int delta) {
 	return setPlayerCurrentHitPoints(_playerCurrentHitPoints + delta);
 }
 
+int Script::getPlayerCombatResourceCount(int loadout) const {
+	const int *count = getPlayerCombatResourceCountPtr(loadout);
+	return count ? *count : 0;
+}
+
+int *Script::getPlayerCombatResourceCountPtr(int loadout) {
+	switch (loadout) {
+	case 2:
+		return &_nailgunAmmoCount;
+	case 3:
+		return &_shotgunShellCount;
+	case 4:
+		return &_nineGunBulletCount;
+	case 5:
+		return &_thirtyEightGunBulletCount;
+	case 14:
+		return &_chainsawFuelCount;
+	default:
+		return nullptr;
+	}
+}
+
+const int *Script::getPlayerCombatResourceCountPtr(int loadout) const {
+	switch (loadout) {
+	case 2:
+		return &_nailgunAmmoCount;
+	case 3:
+		return &_shotgunShellCount;
+	case 4:
+		return &_nineGunBulletCount;
+	case 5:
+		return &_thirtyEightGunBulletCount;
+	case 14:
+		return &_chainsawFuelCount;
+	default:
+		return nullptr;
+	}
+}
+
+bool Script::adjustPlayerCombatResourceCount(int loadout, int delta, int maxCount,
+		const Common::String &reason) {
+	int *count = getPlayerCombatResourceCountPtr(loadout);
+	if (!count || maxCount <= 0)
+		return false;
+
+	const int oldCount = *count;
+	const int newCount = CLIP<int>(oldCount + delta, 0, maxCount);
+	*count = newCount;
+	debugC(1, kDebugGeneral,
+		"Harvester: %s player combat resource loadout=%d('%s') delta=%d count=%d->%d max=%d changed=%d",
+		reason.c_str(), loadout, Player::describeCombatLoadout(loadout),
+		delta, oldCount, newCount, maxCount, oldCount != newCount);
+	return oldCount != newCount;
+}
+
 bool Script::setPlayerCombatLoadout(int loadout) {
 	const int clampedLoadout = clampPlayerCombatLoadout(loadout);
 	const bool changed = _playerCombatLoadout != clampedLoadout;
@@ -2950,6 +3097,16 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			continue;
 		}
 
+		const int combatResourceLoadout = resolveCombatResourceLoadoutForOpcode(command->opcodeName);
+		if (combatResourceLoadout != 0) {
+			noteMutation(adjustPlayerCombatResourceCount(combatResourceLoadout,
+				parseAsciiIntOrZero(command->arg1),
+				resolveCombatResourcePickupMax(combatResourceLoadout),
+				command->opcodeName));
+			currentTag = command->arg4;
+			continue;
+		}
+
 		if (command->opcodeName.equalsIgnoreCase("KILL_PC")) {
 			noteMutation(setPlayerCurrentHitPoints(0));
 			currentTag = command->arg4;
@@ -3111,6 +3268,7 @@ bool Script::probePickupBlockingCommandChain(const Common::String &initialTag,
 			command->opcodeName.equalsIgnoreCase("ADD") ||
 			command->opcodeName.equalsIgnoreCase("DELETE") ||
 			command->opcodeName.equalsIgnoreCase("ADD2INV") ||
+			isCombatResourceOpcode(command->opcodeName) ||
 			command->opcodeName.equalsIgnoreCase("SET_ANIM") ||
 			command->opcodeName.equalsIgnoreCase("SET_REGION") ||
 			command->opcodeName.equalsIgnoreCase("SET_NPC") ||
@@ -3167,6 +3325,7 @@ bool Script::hasActionableCommandChain(const Common::String &initialTag) const {
 			command->opcodeName.equalsIgnoreCase("ADD") ||
 			command->opcodeName.equalsIgnoreCase("DELETE") ||
 			command->opcodeName.equalsIgnoreCase("ADD2INV") ||
+			isCombatResourceOpcode(command->opcodeName) ||
 			command->opcodeName.equalsIgnoreCase("SET_ANIM") ||
 			command->opcodeName.equalsIgnoreCase("SET_REGION") ||
 			command->opcodeName.equalsIgnoreCase("SET_NPC") ||
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 47fd5102496..bb9c2b0c099 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -405,6 +405,7 @@ public:
 	int getCurrentStoryDayIndex() const;
 	int getPlayerCurrentHitPoints() const { return _playerCurrentHitPoints; }
 	int getPlayerCombatLoadout() const { return _playerCombatLoadout; }
+	int getPlayerCombatResourceCount(int loadout) const;
 	bool isPlayerControlPaused() const { return _playerControlPaused; }
 	bool adjustPlayerCurrentHitPoints(int delta);
 	bool setPlayerCombatLoadout(int loadout);
@@ -457,6 +458,10 @@ private:
 		const Common::String &contextName, InteractionResult &result, uint recursionDepth) const;
 	bool hasActionableCommandChain(const Common::String &initialTag) const;
 	bool setPlayerCurrentHitPoints(int hitPoints);
+	int *getPlayerCombatResourceCountPtr(int loadout);
+	const int *getPlayerCombatResourceCountPtr(int loadout) const;
+	bool adjustPlayerCombatResourceCount(int loadout, int delta, int maxCount,
+		const Common::String &reason);
 	void logRuntimeSaveState(const char *operation) const;
 
 	Common::String _path;
@@ -484,6 +489,11 @@ private:
 	Common::Array<NpcRecord> _currentNpcs;
 	Common::Array<MonsterRecord> _currentMonsters;
 	Common::Array<TimerRecord> _currentTimers;
+	int _nailgunAmmoCount = 0;
+	int _shotgunShellCount = 0;
+	int _nineGunBulletCount = 0;
+	int _thirtyEightGunBulletCount = 0;
+	int _chainsawFuelCount = 0;
 	int _playerCurrentHitPoints = 30;
 	int _playerCombatLoadout = 0;
 	bool _playerControlPaused = false;


Commit: 1994e879f37bea9d617aae028c4b139b021ab726
    https://github.com/scummvm/scummvm/commit/1994e879f37bea9d617aae028c4b139b021ab726
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:39+02:00

Commit Message:
HARVESTER: Recover native shotgun shell counts

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/room.cpp
    engines/harvester/script.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 9e4c97c42dd..87c5d6ad9d5 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1054,6 +1054,7 @@
     - `0xe`: chainsaw fuel
   - The corresponding globals are now named `g_nailgun_ammo_icon_strip`, `g_shotgun_shell_icon_strip`, `g_9gun_bullet_icon_strip`, `g_38gun_shotshell_icon_strip`, `g_chainsaw_fuel_icon_strip`, their matching count fields, and `g_visible_weapon_resource_icon_count`.
   - `run_harvester_main_loop` pre-spawns those icon-strip entities at `x = 10 + 15 * i`, `y = 30` with capacities `16` nails, `16` shotgun shells, `8` `9GUN` bullets, `6` `.38` bullets, and `16` gas cans.
+  - Decoded `HARVEST.SCR` confirms the story-script shell progression: `CLOAK_ADD -> ADD2INV SHOTGUN -> ADD_SHOTGUN_SHELLS 2`, while the `SHOTSHELL` pickup object in `SECRETROOM` uses `ADD12SHELLS -> ADD_SHOTGUN_SHELLS 14`. Combined with the native `dispatch_room_event_actions` clamp at `15`, that yields the expected `2` shells on shotgun pickup and `15` after the box.
 - `teardown_player_combat_avatar` at `0x55010` removes the live player combat avatar and frees its weapon-dependent runtime resources before a rebuild or shutdown path.
 - `teardown_entity_runtime_state` at `0x55030` is the shared runtime-entity cleanup layer under the combat-avatar teardown/rebuild paths.
   - It clears the linked spawned actor at `+0x11a8`, frees the runtime sound/sample fields from `+0x1148` through `+0x1168`, resets the entity to a hidden offscreen idle state, and frees the current bitmap buffers without freeing the outer runtime-entity allocation.
@@ -1160,6 +1161,8 @@
 - `run_inventory_screen` at `0x7df10` is the inventory UI loop.
   - It lays out carried objects, shows `inventory_text_key` strings from `TEXT` records, handles use-on-target dispatch, and swaps the status panel object based on current health.
   - The weapon right-click branch maps the weapon subset (`CLEAVER` through `POOLSTICK`) to loadout ids `1..20`, removes any visible weapon-resource strip, formats `Disarming %s...` or `Arming %s, %i ...` inside `BOX1`, then calls `set_player_combat_loadout`.
+  - The formatted `%s` weapon label is not the full `_STEXT` tooltip payload. Decoded town-script data shows entries like `SHOTGUN_STEXT = "shotgun,_right_click_to_arm/disarm"` and `NAILGUN_STEXT = "nailgun;_right_click_to_arm/disarm"`, so the native inventory message path is using the base label up to the first comma or semicolon.
+  - The same native right-click branch does not expose a generic shell-box use path: after the hardcoded document/photo table it falls through only for `SANDWICH`, `SANDWICH2`, `SYRINGE`, `ST_ASPRIN`, `ST_COUGHM`, and `ST_VITAMN`. `SHOTSHELL` ammunition therefore has to be granted by the pickup action, not by later inventory use.
 - `run_game_over_screen` at `0x7c540` is the death/game-over UI branch reached from the main loop after the player combat avatar enters its terminal failure path.
 - The low-level PCM/sample helpers are now bounded:
   - `load_sound_sample` at `0x18470` loads a one-shot sample through XFILE, accepting raw `WAVE` data or `FCMP`-compressed audio, and seeds the sample format/rate metadata used by the audio backend.
diff --git a/TRACKER.md b/TRACKER.md
index f8ca5eb2f8d..9b1722044dc 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 1, 2026, rechecked the native inventory/combat HUD path in Ghidra before wiring ScummVM's Harvester inventory toggle feedback: `run_inventory_screen` at `0x7df10` removes any visible combat-resource strip, formats `Disarming %s...` or loadout-specific `Arming %s, %i ...` text inside `BOX1`, then calls `set_player_combat_loadout`, while `run_harvester_main_loop` pre-spawns the ammo/fuel icon strips at `x = 10 + 15 * i`, `y = 30` with capacities `16/16/8/6/16` for nails, shells, `9GUN` bullets, `.38` bullets, and gas. Updated the ScummVM Harvester runtime to preserve those combat resource counts, execute the confirmed `ADD_SHOTGUN_SHELLS` / `ADD_NAILS` / `ADD_GASCANS` script opcodes, draw the matching HUD strip, and show the native-style arming/disarming status box on inventory weapon toggles.
+- On April 1, 2026, rechecked the native shotgun-shell progression in Ghidra after seeing zero shells on a legacy ScummVM save: decoded `HARVEST.SCR` confirms `CLOAK_ADD -> ADD2INV SHOTGUN -> ADD_SHOTGUN_SHELLS 2`, the `SHOTSHELL` pickup object uses `ADD12SHELLS -> ADD_SHOTGUN_SHELLS 14`, and `run_inventory_screen` does not provide a native shell-box use path beyond the six consumables (`SANDWICH`, `SANDWICH2`, `SYRINGE`, `ST_ASPRIN`, `ST_COUGHM`, `ST_VITAMN`). Updated the ScummVM Harvester pre-v18 save fallback to infer the confirmed shotgun shell baseline from legacy inventory objects, so old saves with `SHOTGUN` start at `2` and old saves carrying `SHOTSHELL` recover to the native `15`-shell cap.
 
 ## Next Suggested Action
 
-- Runtime-smoke the inventory combat path with shells in hand: right-click `SHOTSHELL` to run `ADD12SHELLS`, right-click `SHOTGUN`, and confirm the room view now shows both the `BOX1` arming/disarming message and a shell strip at the native top-left HUD position. If the shell count still fails to change after `ADD12SHELLS`, capture the corresponding `Harvester: action tag` / `inventory secondary action` log lines so we can compare the command chain against native `dispatch_room_event_actions`.
+- Load the same legacy save again and open inventory without repicking anything: confirm the shotgun now arms with `2` shells, then check the same save after the `SHOTSHELL` box is already in inventory and confirm the recovered count is `15` without needing a right-click use action. If either value still disagrees, capture the `Harvester: inferred legacy pre-v18 combat resources ...` log line from load plus the next `inventory combat toggle` log line.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 03c825df04e..ac786176a95 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -417,7 +417,28 @@ static bool resolveCombatLoadoutHudInfo(int loadout, CombatLoadoutHudInfo &info)
 }
 
 static Common::String resolveCombatLoadoutStatusLabel(Script &script, const ObjectRecord &object) {
-	Common::String label = script.resolveObjectLabel(object);
+	Common::String label = script.resolveInventoryTooltipText(object);
+	if (!label.empty()) {
+		for (uint i = 0; i < label.size(); ++i) {
+			if (label[i] == '_')
+				label.setChar(' ', i);
+		}
+
+		uint trimAt = label.size();
+		for (uint i = 0; i < label.size(); ++i) {
+			if (label[i] == ',' || label[i] == ';') {
+				trimAt = i;
+				break;
+			}
+		}
+		label = label.substr(0, trimAt);
+		while (!label.empty() && label.lastChar() == ' ')
+			label.deleteLastChar();
+		if (!label.empty())
+			return label;
+	}
+
+	label = script.resolveObjectLabel(object);
 	if (!label.empty())
 		return label;
 
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 2f864c56f1e..9b889469b18 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1664,6 +1664,23 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 			_nineGunBulletCount = 0;
 			_thirtyEightGunBulletCount = 0;
 			_chainsawFuelCount = 0;
+
+			// Native HARVEST.SCR couples the cloak-room shotgun pickup to
+			// CLOAK_ADD -> ADD2INV SHOTGUN -> ADD_SHOTGUN_SHELLS 2, and the
+			// shell-box pickup to ADD12SHELLS -> ADD_SHOTGUN_SHELLS 14 with a
+			// native cap of 15. Pre-v18 ScummVM saves had no serialized combat
+			// resource counts, so infer the confirmed shotgun baseline from the
+			// persisted inventory objects rather than dropping legacy saves to 0.
+			if (isObjectInInventory("SHOTGUN"))
+				_shotgunShellCount = 2;
+			if (isObjectInInventory("SHOTSHELL")) {
+				_shotgunShellCount = CLIP<int>(_shotgunShellCount + 14, 0,
+					resolveCombatResourcePickupMax(3));
+			}
+			debugC(1, kDebugGeneral,
+				"Harvester: inferred legacy pre-v18 combat resources shells=%d shotgun=%d shell_box=%d",
+				_shotgunShellCount, isObjectInInventory("SHOTGUN"),
+				isObjectInInventory("SHOTSHELL"));
 		}
 		// FIXME: Drop this compatibility pass after release; it exists only to repair
 		// pre-release saves written before the monster combat field layout stabilized.


Commit: 7a77709a7d5b4d6088da41aa187424dcc16c92b3
    https://github.com/scummvm/scummvm/commit/7a77709a7d5b4d6088da41aa187424dcc16c92b3
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:40+02:00

Commit Message:
HARVESTER: Consume resources for player attacks

Update inventory behavior for player attack resource use.

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/player.cpp
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 87c5d6ad9d5..6bf948a9b01 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1065,6 +1065,7 @@
 - `update_actor_runtime_state` at `0x4d750` is the shared per-frame actor state machine for live actor entities.
   - It is called from `run_harvester_main_loop`, the dialogue/response/keyword modal loops, and the player-combat wrapper, so actor motion/combat continues advancing while blocking UI is active.
   - It handles class-4/5/6 actor entities, advances animation/state transitions, maintains pursuit spacing against the player using `engage_distance`, applies room Z bounds and vertical motion, fires frame-timed sound hooks, resolves hit damage through `damage_amount`, and returns `0` when the caller should remove the actor entity from the world list.
+  - For player resource-backed loadouts `2`, `3`, `4`, `5`, and `0xe`, the player-attack branch calls `consume_weapon_ammo_and_play_fire_or_empty_sound` on the first-frame-plus-one attack commit. That helper decrements the corresponding HUD/resource count before the later contact frame and falls back to the empty-click sample when the count is already `0`.
   - The close-range hit-resolution block reuses the hidden helper at live actor field `+0x11a8`: it links attacker/target runtime pointers, forces the helper back to frame `0`, repositions it over the struck actor, and shows it when the helper was still hidden.
   - The native locomotion constants now read cleanly out of the state machine: left/right walk uses `depth_scale * 8.0`, the reverse-step families use `depth_scale * 16.0`, the class-6 monster pursue band is `g_player_combat_avatar->z +/- 2.0`, and the horizontal chase waypoint slack is `depth_scale * 50.0`.
   - Native player room walking keeps screen-space Y separate from depth:
@@ -1161,6 +1162,7 @@
 - `run_inventory_screen` at `0x7df10` is the inventory UI loop.
   - It lays out carried objects, shows `inventory_text_key` strings from `TEXT` records, handles use-on-target dispatch, and swaps the status panel object based on current health.
   - The weapon right-click branch maps the weapon subset (`CLEAVER` through `POOLSTICK`) to loadout ids `1..20`, removes any visible weapon-resource strip, formats `Disarming %s...` or `Arming %s, %i ...` inside `BOX1`, then calls `set_player_combat_loadout`.
+  - After that `BOX1` weapon-toggle path, native clears the temporary text/panel state, sets the inventory-loop exit flag, and returns to the caller instead of remaining in the inventory loop.
   - The formatted `%s` weapon label is not the full `_STEXT` tooltip payload. Decoded town-script data shows entries like `SHOTGUN_STEXT = "shotgun,_right_click_to_arm/disarm"` and `NAILGUN_STEXT = "nailgun;_right_click_to_arm/disarm"`, so the native inventory message path is using the base label up to the first comma or semicolon.
   - The same native right-click branch does not expose a generic shell-box use path: after the hardcoded document/photo table it falls through only for `SANDWICH`, `SANDWICH2`, `SYRINGE`, `ST_ASPRIN`, `ST_COUGHM`, and `ST_VITAMN`. `SHOTSHELL` ammunition therefore has to be granted by the pickup action, not by later inventory use.
 - `run_game_over_screen` at `0x7c540` is the death/game-over UI branch reached from the main loop after the player combat avatar enters its terminal failure path.
@@ -1375,7 +1377,7 @@
 ### Inventory Secondary-Click Actions
 
 - `Confidence:` High.
-- `Evidence:` in `run_inventory_screen`, the right-click branch first maps the weapon subset (`CLEAVER` through `POOLSTICK`) to fixed combat-loadout ids `1..20`, then calls `set_player_combat_loadout` and refreshes the inventory status state without leaving the inventory loop.
+- `Evidence:` in `run_inventory_screen`, the right-click branch first maps the weapon subset (`CLEAVER` through `POOLSTICK`) to fixed combat-loadout ids `1..20`, shows the `BOX1` arming/disarming message, then calls `set_player_combat_loadout`, tears down the temporary `BOX1` state, and exits the inventory loop.
 - `Evidence:` the same branch maps a separate hardcoded document/photo subset to fixed action tags rather than object-record metadata: `NOTE_PHOTOCOPY -> GO_BOYLCOPYCU`, `NOTE -> GO_BOYLNOTECU`, `CHECKBOOK -> GO_REGISTERCU`, `CHECKBOOK_PHOTOCOPY -> GO_RGSTRCPYCU`, `TV_DEED -> GO_TVDEED1CU`, `TV_DEED_PHOTOCOPY -> GO_TVDEED2CU`, `CLUE -> GOTO_CLUE_CU`, `AUTOGRAPH -> GO_AUTOGRPHCU`, plus the corresponding Whaley photo, casket photo, permit, lodge-application, safebook, patrol-schedule, and invite entries.
 - `Evidence:` after dispatching one of those hardcoded closeup tags, `run_inventory_screen` tears down the inventory render state and returns to the caller instead of staying in the loop.
 - `Evidence:` only the six consumable or self-use items `SANDWICH`, `SANDWICH2`, `SYRINGE`, `ST_ASPRIN`, `ST_COUGHM`, and `ST_VITAMN` fall back to the clicked object's own `action_tag`; that branch stays inside the inventory loop so HP and status changes remain visible.
diff --git a/TRACKER.md b/TRACKER.md
index 9b1722044dc..b0b56f20a2e 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 1, 2026, rechecked the native shotgun-shell progression in Ghidra after seeing zero shells on a legacy ScummVM save: decoded `HARVEST.SCR` confirms `CLOAK_ADD -> ADD2INV SHOTGUN -> ADD_SHOTGUN_SHELLS 2`, the `SHOTSHELL` pickup object uses `ADD12SHELLS -> ADD_SHOTGUN_SHELLS 14`, and `run_inventory_screen` does not provide a native shell-box use path beyond the six consumables (`SANDWICH`, `SANDWICH2`, `SYRINGE`, `ST_ASPRIN`, `ST_COUGHM`, `ST_VITAMN`). Updated the ScummVM Harvester pre-v18 save fallback to infer the confirmed shotgun shell baseline from legacy inventory objects, so old saves with `SHOTGUN` start at `2` and old saves carrying `SHOTSHELL` recover to the native `15`-shell cap.
+- On April 1, 2026, rechecked the native player-attack and weapon-toggle paths in Ghidra after seeing infinite shotgun shells and a persistent inventory overlay in ScummVM: `update_actor_runtime_state` calls `consume_weapon_ammo_and_play_fire_or_empty_sound` on the player attack's first-frame-plus-one commit for loadouts `2/3/4/5/0xe`, so ranged/fuel attacks spend resource before the later contact frame, and `run_inventory_screen` exits its loop immediately after the `BOX1` arming/disarming message path tears down. Updated the ScummVM Harvester room/player/script flow so resource-backed attacks consume one unit from the persisted HUD counts on each committed shot and weapon toggles close inventory after the native-style status message finishes.
 
 ## Next Suggested Action
 
-- Load the same legacy save again and open inventory without repicking anything: confirm the shotgun now arms with `2` shells, then check the same save after the `SHOTSHELL` box is already in inventory and confirm the recovered count is `15` without needing a right-click use action. If either value still disagrees, capture the `Harvester: inferred legacy pre-v18 combat resources ...` log line from load plus the next `inventory combat toggle` log line.
+- In-engine, arm and fire the shotgun until empty, confirming the on-screen shell strip drops by `1` on every committed shot including misses and that the final dry-fire no longer lands damage; then right-click arm and disarm a weapon from inventory and confirm the panel closes as soon as the `BOX1` status message finishes. If either behavior still disagrees, capture the next `combat player attack start/contact` lines plus the next `PLAYER_ATTACK player combat resource ...` line and the surrounding `inventory combat toggle` lines.
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 7a8dcf2508c..e1d709e9849 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -270,6 +270,19 @@ static bool playPlayerAttackSound(HarvesterEngine &engine, int loadout) {
 	return soundPath && engine.playSound(soundPath);
 }
 
+static bool loadoutUsesConsumableCombatResource(int loadout) {
+	switch (loadout) {
+	case 2:
+	case 3:
+	case 4:
+	case 5:
+	case 14:
+		return true;
+	default:
+		return false;
+	}
+}
+
 static bool playPlayerHitSound(HarvesterEngine &engine) {
 	const uint soundIndex = ARRAYSIZE(kPlayerHitSoundPaths) > 1
 		? engine.getRandomNumber(ARRAYSIZE(kPlayerHitSoundPaths) - 1)
@@ -1128,14 +1141,30 @@ bool Player::updateAttackAnimationState(HarvesterEngine &engine,
 		RoomPlayerState &playerState) {
 	if (!playerState.attackActive || !playerState.entity)
 		return false;
+	bool changed = false;
 	if (!playerState.attackSoundPlayed &&
 			playerState.attackSoundPlaybackFrame >= 0 &&
 			playerState.entity->getCurrentFrame() >= playerState.attackSoundPlaybackFrame) {
-		(void)playPlayerAttackSound(engine, playerState.combatLoadout);
+		if (loadoutUsesConsumableCombatResource(playerState.combatLoadout)) {
+			Script *script = engine.getScript();
+			if (!script || !script->consumePlayerCombatResourceUnit(playerState.combatLoadout)) {
+				debugC(1, kDebugCombat,
+					"Harvester: combat player attack dry fire loadout=%d weapon='%s'",
+					playerState.combatLoadout,
+					describeCombatLoadout(playerState.combatLoadout));
+				playerState.attackTargetName.clear();
+				playerState.attackTargetClassId = -1;
+			} else {
+				changed = true;
+				(void)playPlayerAttackSound(engine, playerState.combatLoadout);
+			}
+		} else {
+			(void)playPlayerAttackSound(engine, playerState.combatLoadout);
+		}
 		playerState.attackSoundPlayed = true;
 	}
 	if (playerState.entity->getCurrentFrame() != playerState.attackLastFrame)
-		return false;
+		return changed;
 
 	const int resumeFacing = playerState.attackResumeFacing >= 0
 		? playerState.attackResumeFacing
@@ -1150,7 +1179,7 @@ bool Player::updateAttackAnimationState(HarvesterEngine &engine,
 	playerState.attackContactResolved = false;
 	playerState.attackTargetName.clear();
 	playerState.attackTargetClassId = -1;
-	return setIdleAnimation(playerState, resumeFacing);
+	return setIdleAnimation(playerState, resumeFacing) || changed;
 }
 
 bool Player::startHitAnimation(HarvesterEngine &engine, RoomPlayerState &playerState,
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index ac786176a95..aa34fb01e30 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1007,6 +1007,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		bool inspectCanDismiss = false;
 		ResolvedText combatLoadoutStatusText;
 		uint32 combatLoadoutStatusHideTick = 0;
+		bool closeInventoryAfterCombatLoadoutStatus = false;
 		bool moveLeft = false;
 		bool moveRight = false;
 		bool moveUp = false;
@@ -3921,6 +3922,11 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				(int32)(Player::getRuntimeClockTicks() - combatLoadoutStatusHideTick) >= 0) {
 			combatLoadoutStatusText = ResolvedText();
 			combatLoadoutStatusHideTick = 0;
+			if (closeInventoryAfterCombatLoadoutStatus) {
+				closeInventoryAfterCombatLoadoutStatus = false;
+				if (_inventory.close())
+					needsRedraw = true;
+			}
 			needsRedraw = true;
 		}
 
@@ -4093,6 +4099,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 										*script, inventoryHover->object, previousLoadout,
 										script->getPlayerCombatLoadout());
 									queueCombatLoadoutStatusMessage(statusMessage);
+									closeInventoryAfterCombatLoadoutStatus = true;
 								}
 								captureCurrentSaveState();
 								if (playerState.entity) {
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 9b889469b18..9ce89b7a78e 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -2420,6 +2420,21 @@ int Script::getPlayerCombatResourceCount(int loadout) const {
 	return count ? *count : 0;
 }
 
+bool Script::consumePlayerCombatResourceUnit(int loadout) {
+	const int maxCount = resolveCombatResourcePickupMax(loadout);
+	if (maxCount <= 0)
+		return true;
+
+	if (getPlayerCombatResourceCount(loadout) < 1) {
+		debugC(1, kDebugGeneral,
+			"Harvester: PLAYER_ATTACK player combat resource loadout=%d('%s') delta=0 count=0->0 max=%d changed=0",
+			loadout, Player::describeCombatLoadout(loadout), maxCount);
+		return false;
+	}
+
+	return adjustPlayerCombatResourceCount(loadout, -1, maxCount, "PLAYER_ATTACK");
+}
+
 int *Script::getPlayerCombatResourceCountPtr(int loadout) {
 	switch (loadout) {
 	case 2:
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index bb9c2b0c099..c8e4544ff0c 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -406,6 +406,7 @@ public:
 	int getPlayerCurrentHitPoints() const { return _playerCurrentHitPoints; }
 	int getPlayerCombatLoadout() const { return _playerCombatLoadout; }
 	int getPlayerCombatResourceCount(int loadout) const;
+	bool consumePlayerCombatResourceUnit(int loadout);
 	bool isPlayerControlPaused() const { return _playerControlPaused; }
 	bool adjustPlayerCurrentHitPoints(int delta);
 	bool setPlayerCombatLoadout(int loadout);


Commit: 1cca90edd0b36e62a3685f009d73d01076328696
    https://github.com/scummvm/scummvm/commit/1cca90edd0b36e62a3685f009d73d01076328696
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:40+02:00

Commit Message:
HARVESTER: Stop dry fire from reaching contact

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/player.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 6bf948a9b01..a2eed897aba 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1065,7 +1065,7 @@
 - `update_actor_runtime_state` at `0x4d750` is the shared per-frame actor state machine for live actor entities.
   - It is called from `run_harvester_main_loop`, the dialogue/response/keyword modal loops, and the player-combat wrapper, so actor motion/combat continues advancing while blocking UI is active.
   - It handles class-4/5/6 actor entities, advances animation/state transitions, maintains pursuit spacing against the player using `engage_distance`, applies room Z bounds and vertical motion, fires frame-timed sound hooks, resolves hit damage through `damage_amount`, and returns `0` when the caller should remove the actor entity from the world list.
-  - For player resource-backed loadouts `2`, `3`, `4`, `5`, and `0xe`, the player-attack branch calls `consume_weapon_ammo_and_play_fire_or_empty_sound` on the first-frame-plus-one attack commit. That helper decrements the corresponding HUD/resource count before the later contact frame and falls back to the empty-click sample when the count is already `0`.
+  - For player resource-backed loadouts `2`, `3`, `4`, `5`, and `0xe`, the player-attack branch calls `consume_weapon_ammo_and_play_fire_or_empty_sound` on the zero-ammo and fire-commit checkpoints. That helper decrements the corresponding HUD/resource count when ammo is present, falls back to the empty-click sample when the count is already `0`, and the empty-ammo path returns before the later contact-resolution branch.
   - The close-range hit-resolution block reuses the hidden helper at live actor field `+0x11a8`: it links attacker/target runtime pointers, forces the helper back to frame `0`, repositions it over the struck actor, and shows it when the helper was still hidden.
   - The native locomotion constants now read cleanly out of the state machine: left/right walk uses `depth_scale * 8.0`, the reverse-step families use `depth_scale * 16.0`, the class-6 monster pursue band is `g_player_combat_avatar->z +/- 2.0`, and the horizontal chase waypoint slack is `depth_scale * 50.0`.
   - Native player room walking keeps screen-space Y separate from depth:
diff --git a/TRACKER.md b/TRACKER.md
index b0b56f20a2e..185498da8b2 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 1, 2026, rechecked the native player-attack and weapon-toggle paths in Ghidra after seeing infinite shotgun shells and a persistent inventory overlay in ScummVM: `update_actor_runtime_state` calls `consume_weapon_ammo_and_play_fire_or_empty_sound` on the player attack's first-frame-plus-one commit for loadouts `2/3/4/5/0xe`, so ranged/fuel attacks spend resource before the later contact frame, and `run_inventory_screen` exits its loop immediately after the `BOX1` arming/disarming message path tears down. Updated the ScummVM Harvester room/player/script flow so resource-backed attacks consume one unit from the persisted HUD counts on each committed shot and weapon toggles close inventory after the native-style status message finishes.
+- On April 1, 2026, rechecked the native zero-ammo player-attack path in Ghidra after seeing ScummVM dry-fire still reach the attack-contact log: `update_actor_runtime_state` reads the live ammo/fuel counters inside the player attack branch, calls `consume_weapon_ammo_and_play_fire_or_empty_sound`, and when the count is already `0` it returns before the later contact-resolution block instead of letting the empty shot proceed as a miss. Updated the ScummVM Harvester player attack flow so dry-fire invalidates the pending contact frame/target after the native empty-click path.
 
 ## Next Suggested Action
 
-- In-engine, arm and fire the shotgun until empty, confirming the on-screen shell strip drops by `1` on every committed shot including misses and that the final dry-fire no longer lands damage; then right-click arm and disarm a weapon from inventory and confirm the panel closes as soon as the `BOX1` status message finishes. If either behavior still disagrees, capture the next `combat player attack start/contact` lines plus the next `PLAYER_ATTACK player combat resource ...` line and the surrounding `inventory combat toggle` lines.
+- In-engine, arm and fire the shotgun down to `0` again and confirm the empty-click path no longer emits the `combat player attack contact ...` line at all. If the muzzle-flash frames still appear after that, capture a short video or exact frame sequence: current Ghidra evidence says there is no separate zero-ammo shotgun ABM/loadout path, so any remaining flash would likely be baked into the native attack frames rather than a ScummVM-specific contact bug.
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index e1d709e9849..4c4a2d2c533 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -1152,6 +1152,8 @@ bool Player::updateAttackAnimationState(HarvesterEngine &engine,
 					"Harvester: combat player attack dry fire loadout=%d weapon='%s'",
 					playerState.combatLoadout,
 					describeCombatLoadout(playerState.combatLoadout));
+				playerState.attackContactResolved = true;
+				playerState.attackContactFrame = -1;
 				playerState.attackTargetName.clear();
 				playerState.attackTargetClassId = -1;
 			} else {


Commit: 86c52dbac550d4b94d44b2aeb4f4feec879ddae6
    https://github.com/scummvm/scummvm/commit/86c52dbac550d4b94d44b2aeb4f4feec879ddae6
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:40+02:00

Commit Message:
HARVESTER: Fix live npc death and combat depth stepping

Changed paths:
    TRACKER.md
    engines/harvester/player.cpp
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index 185498da8b2..c03e3279217 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 1, 2026, rechecked the native zero-ammo player-attack path in Ghidra after seeing ScummVM dry-fire still reach the attack-contact log: `update_actor_runtime_state` reads the live ammo/fuel counters inside the player attack branch, calls `consume_weapon_ammo_and_play_fire_or_empty_sound`, and when the count is already `0` it returns before the later contact-resolution block instead of letting the empty shot proceed as a miss. Updated the ScummVM Harvester player attack flow so dry-fire invalidates the pending contact frame/target after the native empty-click path.
+- On April 1, 2026, verified two live-room combat behaviors in Ghidra before changing the Harvester engine. Native `queue_named_npc_death_or_monsterfy_transition` only queues desired state `0x35` for a live class-4 NPC and leaves record suppression until `update_actor_runtime_state` reaches the death-bank terminal frame, while `update_player_combat_avatar_state` and `update_actor_runtime_state` drive room-combat vertical walking with an independent signed `z_velocity_step` instead of inferring depth direction from screen-Y remapping. Updated the ScummVM room loop and script path to defer live `KILL_NPC` / `MONSTERFY` transitions until the in-room death bank completes, preserve the live NPC entity across the intermediate runtime-state refresh, and switch keyboard vertical combat movement to the native signed depth step.
 
 ## Next Suggested Action
 
-- In-engine, arm and fire the shotgun down to `0` again and confirm the empty-click path no longer emits the `combat player attack contact ...` line at all. If the muzzle-flash frames still appear after that, capture a short video or exact frame sequence: current Ghidra evidence says there is no separate zero-ammo shotgun ABM/loadout path, so any remaining flash would likely be baked into the native attack frames rather than a ScummVM-specific contact bug.
+- In-engine, replay the Chessmaster combat branch from `ADD_CHESS_KING_MONSTER` through `CHESSKING_DCOM2` and confirm three outcomes together: CHESSKING remains targetable once the player walks onto his depth plane, the Chessmaster dies through his in-room corpse bank instead of disappearing immediately, and the post-death `ADD_CHESS_CU` interaction becomes available so the key can be collected. If any step still diverges, capture the exact action tag and frame/state transition from the debug log for another Ghidra comparison.
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 4c4a2d2c533..7546fe73639 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -1457,21 +1457,9 @@ bool Player::stepKeyboardMovement(HarvesterEngine &engine, const RoomSetupState
 	if (verticalInput != 0) {
 		candidateBottomY = clampRoomMovementY(state,
 			playerState.bottomY + verticalInput * verticalStep);
-		const float positiveZ = clampRoomDepth(state, playerState.z + depthStep);
-		const float negativeZ = clampRoomDepth(state, playerState.z - depthStep);
-		const int positiveY = mapRoomDepthToScreenY(state, positiveZ);
-		const int negativeY = mapRoomDepthToScreenY(state, negativeZ);
-		if (candidateBottomY < playerState.bottomY) {
-			if (positiveY < playerState.bottomY)
-				candidateZ = positiveZ;
-			else if (negativeY < playerState.bottomY)
-				candidateZ = negativeZ;
-		} else if (candidateBottomY > playerState.bottomY) {
-			if (positiveY > playerState.bottomY)
-				candidateZ = positiveZ;
-			else if (negativeY > playerState.bottomY)
-				candidateZ = negativeZ;
-		}
+		// Native room-combat movement advances screen Y and room depth independently:
+		// down-walk states apply -z_velocity_step while up-walk states apply +z_velocity_step.
+		candidateZ = clampRoomDepth(state, playerState.z - verticalInput * depthStep);
 	}
 	if (candidateCenterX == playerState.centerX &&
 			candidateBottomY == playerState.bottomY &&
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index aa34fb01e30..2d28b049257 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -180,6 +180,20 @@ struct CombatLoadoutHudInfo {
 	const char *unitLabel = nullptr;
 };
 
+struct ScopedDeferredLiveNpcDeathTransitions {
+	ScopedDeferredLiveNpcDeathTransitions(Script *script) : _script(script) {
+		if (_script)
+			_script->pushDeferredLiveNpcDeathTransitions();
+	}
+
+	~ScopedDeferredLiveNpcDeathTransitions() {
+		if (_script)
+			_script->popDeferredLiveNpcDeathTransitions();
+	}
+
+	Script *_script;
+};
+
 static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
 	return !roomName.equalsIgnoreCase("LAVAPIT") &&
 		!roomName.equalsIgnoreCase("RMNBATH") &&
@@ -1287,6 +1301,25 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				lhs.deathOrMonsterfyFlag == rhs.deathOrMonsterfyFlag &&
 				lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
 		};
+		auto isQueuedLiveNpcDeathMutation = [&](const NpcRecord &lhs, const NpcRecord &rhs) {
+			return lhs.posX == rhs.posX &&
+				lhs.posY == rhs.posY &&
+				lhs.posZ == rhs.posZ &&
+				lhs.frameDelay == rhs.frameDelay &&
+				lhs.active &&
+				rhs.active &&
+				lhs.visible &&
+				rhs.visible &&
+				!lhs.runtimeSpawned &&
+				!rhs.runtimeSpawned &&
+				lhs.runtimeState < 0 &&
+				rhs.runtimeState < 0 &&
+				!lhs.deathOrMonsterfyFlag &&
+				!rhs.deathOrMonsterfyFlag &&
+				lhs.deathDamageType == 0 &&
+				rhs.deathDamageType != 0 &&
+				lhs.modelPath.equalsIgnoreCase(rhs.modelPath);
+		};
 		auto sameMonsterEntityState = [&](const MonsterRecord &lhs, const MonsterRecord &rhs) {
 			return lhs.posX == rhs.posX &&
 				lhs.posY == rhs.posY &&
@@ -1707,7 +1740,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				const bool keepEntity =
 					updatedNpc &&
 					shouldSpawnRoomNpcEntity(*updatedNpc) &&
-					sameNpcEntityState(npc, *updatedNpc);
+					(sameNpcEntityState(npc, *updatedNpc) ||
+						isQueuedLiveNpcDeathMutation(npc, *updatedNpc));
 				if (!keepEntity)
 					removeSceneEntityByName(npc.npcName);
 			}
@@ -1720,6 +1754,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					: nullptr;
 				const NpcRecord *previousNpc =
 					findRoomNpcByNameConst(scene.state.roomNpcs, npc.npcName);
+				const bool keepQueuedDeathEntity =
+					entity &&
+					previousNpc &&
+					isQueuedLiveNpcDeathMutation(*previousNpc, npc);
+				if (keepQueuedDeathEntity)
+					continue;
 				const bool needsRespawn =
 					!entity || !previousNpc || !sameNpcEntityState(*previousNpc, npc);
 				if (!needsRespawn)
@@ -1850,6 +1890,28 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			captureCurrentSaveState();
 			return _inventory.refresh();
 		};
+		auto syncAnimatedRoomActorPlacement = [&]() {
+			if (playerState.entity) {
+				(void)applyRoomActorPlacement(scene.state, *playerState.entity,
+					playerState.centerX, playerState.bottomY, playerState.z);
+			}
+			if (!entityManager)
+				return;
+
+			for (const NpcRecord &npc : scene.state.roomNpcs) {
+				Entity *entity = entityManager->findSceneEntityByName(npc.npcName);
+				if (!entity)
+					continue;
+				(void)applyRoomNpcPlacement(*entity, npc);
+			}
+			for (const MonsterRecord &monster : scene.state.roomMonsters) {
+				Entity *entity = entityManager->findSceneEntityByName(monster.monsterName);
+				if (!entity)
+					continue;
+				(void)applyRoomActorPlacement(scene.state, *entity,
+					monster.posX, monster.posY, (float)monster.posZ);
+			}
+		};
 		auto stowCarriedRoomItemToInventory = [&]() {
 			if (!hasCarriedRoomItem())
 				return true;
@@ -2537,6 +2599,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			flow.resetCursorAnimationSequence();
 			resetIdleState();
 		}
+		ScopedDeferredLiveNpcDeathTransitions deferredLiveNpcDeathTransitions(
+			_engine.getScript());
 		auto findRoomNpcRecordByName = [&](const Common::String &npcName) -> NpcRecord * {
 			for (NpcRecord &npc : scene.state.roomNpcs) {
 				if (npc.npcName.equalsIgnoreCase(npcName))
@@ -3301,6 +3365,17 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				clearRoomNpcCombatState(combatState);
 				continue;
 			}
+			if (!combatState.deathActive && npc.active && npc.deathDamageType != 0) {
+				if (beginNpcDeathTransition(npc, *entity, combatState, npc.deathDamageType))
+					continue;
+
+				debugC(1, kDebugCombat,
+					"Harvester: room npc death fallback target='%s' damage_type=%d on_death='%s' reason='no death bank'",
+					npc.npcName.c_str(), npc.deathDamageType, npc.onDeathActionTag.c_str());
+				combatState.deathDamageType = npc.deathDamageType;
+				combatState.deathLastFrame = entity->getCurrentFrame();
+				return finalizeNpcDeathTransition(npc, combatState);
+			}
 			if (!combatState.deathActive)
 				continue;
 			if (entity->getCurrentFrame() != combatState.deathLastFrame)
@@ -4764,6 +4839,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 		if (flow.tickRuntimeEntities())
 			needsRedraw = true;
+		syncAnimatedRoomActorPlacement();
 		if (Player::updateDeathAnimationState(playerState)) {
 			requestPlayerGameOver("combat_player_death_complete", Common::String());
 			needsRedraw = true;
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 9ce89b7a78e..7344b2402e4 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -2618,6 +2618,15 @@ bool Script::finalizeRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, i
 	return changed || monsterChanged;
 }
 
+void Script::pushDeferredLiveNpcDeathTransitions() {
+	++_deferredLiveNpcDeathTransitionDepth;
+}
+
+void Script::popDeferredLiveNpcDeathTransitions() {
+	if (_deferredLiveNpcDeathTransitionDepth > 0)
+		--_deferredLiveNpcDeathTransitionDepth;
+}
+
 bool Script::buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord *entrance,
 		ResourceManager &resources, RoomSetupState &state) const {
 	state = RoomSetupState();
@@ -3097,7 +3106,17 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			}
 
 			const int deathDamageType = parseNpcDeathDamageType(command->arg2);
-			noteMutation(finalizeRuntimeNpcDeathOrMonsterfy(command->arg1, deathDamageType));
+			const bool deferLiveNpcDeathTransition =
+				_deferredLiveNpcDeathTransitionDepth > 0 &&
+				!contextRoomName.empty() &&
+				currentNpc->roomName.equalsIgnoreCase(contextRoomName) &&
+				currentNpc->active &&
+				currentNpc->visible &&
+				!currentNpc->deathOrMonsterfyFlag &&
+				currentNpc->runtimeState < 0;
+			noteMutation(deferLiveNpcDeathTransition
+				? queueRuntimeNpcDeathOrMonsterfy(command->arg1, deathDamageType)
+				: finalizeRuntimeNpcDeathOrMonsterfy(command->arg1, deathDamageType));
 			currentTag = command->arg4;
 			continue;
 		}
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index c8e4544ff0c..700a48af615 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -401,6 +401,8 @@ public:
 	bool queueRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType = 0);
 	bool finalizeRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType = 0,
 		bool preserveCorpse = false, int corpseFrame = -1);
+	void pushDeferredLiveNpcDeathTransitions();
+	void popDeferredLiveNpcDeathTransitions();
 	bool isNamedNpcDeathTypeClear(const Common::String &npcName) const;
 	int getCurrentStoryDayIndex() const;
 	int getPlayerCurrentHitPoints() const { return _playerCurrentHitPoints; }
@@ -512,6 +514,7 @@ private:
 	Common::String _drive2Path;
 	Common::String _drive3Path;
 	StartupDialogueTextMode _dialogueTextMode = kStartupDialogueTextYes;
+	int _deferredLiveNpcDeathTransitionDepth = 0;
 };
 
 } // End of namespace Harvester


Commit: cd139cdbd870dbbd1d00ac56fd57b22bad5071a7
    https://github.com/scummvm/scummvm/commit/cd139cdbd870dbbd1d00ac56fd57b22bad5071a7
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:41+02:00

Commit Message:
HARVESTER: Fix Chessmaster death transition

Changed paths:
    TRACKER.md
    engines/harvester/npc/chessmaster_dialogue.cpp
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index c03e3279217..b29176d198a 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,15 +3,15 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `927`
-- Named/documented: `823`
+- Total functions: `928`
+- Named/documented: `824`
 - Remaining `FUN_*`: `104`
 - Remaining undocumented total: `104`
 
 ## Last Confirmed Action
 
-- On April 1, 2026, verified two live-room combat behaviors in Ghidra before changing the Harvester engine. Native `queue_named_npc_death_or_monsterfy_transition` only queues desired state `0x35` for a live class-4 NPC and leaves record suppression until `update_actor_runtime_state` reaches the death-bank terminal frame, while `update_player_combat_avatar_state` and `update_actor_runtime_state` drive room-combat vertical walking with an independent signed `z_velocity_step` instead of inferring depth direction from screen-Y remapping. Updated the ScummVM room loop and script path to defer live `KILL_NPC` / `MONSTERFY` transitions until the in-room death bank completes, preserve the live NPC entity across the intermediate runtime-state refresh, and switch keyboard vertical combat movement to the native signed depth step.
+- On April 11, 2026, verified the solved Chessmaster dialogue branch in Ghidra. Native `handle_talk_to_chessmaster` sets `CHESS_SOLVED`, plays `graphic\fst\c125.fst`, then calls `queue_named_npc_death_or_monsterfy_transition("CHESSMASTER", "SLASH")`; it does not immediately finalize or hide the NPC record. The decoded `HARVEST.SCR` confirms `CHESSMASTER` has `on_death='ADD_CHESS_CU'`, and that action reveals `CHESSM` before skipping king growth when `CHESS_SOLVED` is true. Updated the ScummVM solved-puzzle shortcut to queue the live NPC death transition instead of finalizing it immediately, so the room loop can play the death animation and dispatch `ADD_CHESS_CU` at the terminal frame.
 
 ## Next Suggested Action
 
-- In-engine, replay the Chessmaster combat branch from `ADD_CHESS_KING_MONSTER` through `CHESSKING_DCOM2` and confirm three outcomes together: CHESSKING remains targetable once the player walks onto his depth plane, the Chessmaster dies through his in-room corpse bank instead of disappearing immediately, and the post-death `ADD_CHESS_CU` interaction becomes available so the key can be collected. If any step still diverges, capture the exact action tag and frame/state transition from the debug log for another Ghidra comparison.
+- In-engine, replay the solved Chessmaster dialogue path from `CHESS_SOLVED` through `C125.FST` and the queued NPC death transition. Confirm that `combat npc death start`/`complete` logs appear for `CHESSMASTER`, `ADD_CHESS_CU` reveals the `CHESSM` body/hotspot, the live `CHESSMASTER` actor does not respawn, and examining the body reaches the `CHESSHEAD` closeup/key interaction.
diff --git a/engines/harvester/npc/chessmaster_dialogue.cpp b/engines/harvester/npc/chessmaster_dialogue.cpp
index 56b617d8c5e..8b68f1a63e8 100644
--- a/engines/harvester/npc/chessmaster_dialogue.cpp
+++ b/engines/harvester/npc/chessmaster_dialogue.cpp
@@ -438,7 +438,7 @@ Common::Error ChessmasterDialogueHandler::handleDialogue(DialogueRuntime &runtim
 		return lineError;
 
 	InteractionResult interaction;
-	if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(
+	if (runtime.startupScript().queueRuntimeNpcDeathOrMonsterfy(
 			kChessmasterNpc, kChessmasterSlashDamageType)) {
 		interaction.mutatedRuntimeState = true;
 	}
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 2d28b049257..b52a1ae6fc6 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1376,27 +1376,25 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			if (!entityManager || !shouldSpawnRoomNpcEntity(npc))
 				return nullptr;
 
+			// Native state 0x35 suppresses class-4 NPC corpses after the terminal death frame.
+			// If an older runtime/save preserved one, skip respawning it and let the room object
+			// or on-death action own the post-death presentation.
+			const bool hasSuppressedCorpseState = !npc.active &&
+				npc.runtimeSpawned &&
+				npc.deathDamageType != 0 &&
+				npc.runtimeState >= 0;
+			if (hasSuppressedCorpseState)
+				return nullptr;
+
 			Entity *entity = entityManager->spawnSceneActorEntity(
 				npc.npcName, npc.modelPath, Common::Point(npc.posX, npc.posY), (float)npc.posZ, 0);
 			if (!entity)
 				return nullptr;
 
 			entity->setClassId(kRuntimeEntityClassNpc);
-			const bool isCorpse = !npc.active &&
-				npc.runtimeSpawned &&
-				npc.deathDamageType != 0 &&
-				npc.runtimeState >= 0;
-			entity->setHitTestMode(isCorpse ? kRuntimeEntityHitTestNone : kRuntimeEntityHitTestOpaquePixels);
-			if (isCorpse) {
-				const int corpseFrame = MIN(entity->getLastFrame(), npc.runtimeState);
-				entity->setAnimationFrameRange(corpseFrame, corpseFrame, false);
-				entity->setAnimationRate(0);
-				entity->setCurrentFrame(corpseFrame);
-				entity->setAnimationEnabled(false);
-			} else {
-				entity->setAnimationFrameRange(0, MIN(entity->getLastFrame(), kRoomNpcAmbientLastFrame), true);
-				entity->setAnimationRate(npc.frameDelay > 0 ? npc.frameDelay : 0);
-			}
+			entity->setHitTestMode(kRuntimeEntityHitTestOpaquePixels);
+			entity->setAnimationFrameRange(0, MIN(entity->getLastFrame(), kRoomNpcAmbientLastFrame), true);
+			entity->setAnimationRate(npc.frameDelay > 0 ? npc.frameDelay : 0);
 			entity->setVisible(true);
 			if (!applyRoomNpcPlacement(*entity, npc)) {
 				removeSceneEntityByName(npc.npcName);
@@ -2805,26 +2803,22 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 	auto finalizeNpcDeathTransition = [&](NpcRecord &npc,
 			RoomNpcCombatState &combatState) -> Common::Error {
 		Script *script = _engine.getScript();
-		const bool preserveCorpse =
-			npc.monsterfyTargetName.empty() && combatState.deathLastFrame >= 0;
-		const int corpseFrame = preserveCorpse ? combatState.deathLastFrame : -1;
 		removeSceneEntityByName(npc.npcName);
 		npc.active = false;
-		npc.visible = preserveCorpse;
-		npc.savedVisible = preserveCorpse;
-		npc.runtimeSpawned = preserveCorpse;
-		npc.runtimeState = corpseFrame;
-		npc.deathOrMonsterfyFlag = !preserveCorpse;
+		npc.visible = false;
+		npc.savedVisible = false;
+		npc.runtimeSpawned = false;
+		npc.runtimeState = -1;
+		npc.deathOrMonsterfyFlag = true;
 		if (combatState.deathDamageType != 0)
 			npc.deathDamageType = combatState.deathDamageType;
 		const bool runtimeChanged = script
 			? script->finalizeRuntimeNpcDeathOrMonsterfy(
-				npc.npcName, npc.deathDamageType, preserveCorpse, corpseFrame)
+				npc.npcName, npc.deathDamageType, false, -1)
 			: false;
 		debugC(1, kDebugCombat,
-			"Harvester: combat npc death complete target='%s' damage_type=%d last_frame=%d preserve_corpse=%d monsterfy='%s' on_death='%s'",
+			"Harvester: combat npc death complete target='%s' damage_type=%d last_frame=%d suppress_runtime_actor=1 monsterfy='%s' on_death='%s'",
 			npc.npcName.c_str(), combatState.deathDamageType, combatState.deathLastFrame,
-			preserveCorpse,
 			npc.monsterfyTargetName.c_str(), npc.onDeathActionTag.c_str());
 		clearRoomNpcCombatState(combatState);
 


Commit: de293f4569cf8bfad96c34e348be21079fbf9dc7
    https://github.com/scummvm/scummvm/commit/de293f4569cf8bfad96c34e348be21079fbf9dc7
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:41+02:00

Commit Message:
HARVESTER: Hide save in closeup menus

Changed paths:
    TRACKER.md
    engines/harvester/flow.cpp
    engines/harvester/flow.h
    engines/harvester/menu.cpp
    engines/harvester/menu.h
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index b29176d198a..1c89b32ca26 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 11, 2026, verified the solved Chessmaster dialogue branch in Ghidra. Native `handle_talk_to_chessmaster` sets `CHESS_SOLVED`, plays `graphic\fst\c125.fst`, then calls `queue_named_npc_death_or_monsterfy_transition("CHESSMASTER", "SLASH")`; it does not immediately finalize or hide the NPC record. The decoded `HARVEST.SCR` confirms `CHESSMASTER` has `on_death='ADD_CHESS_CU'`, and that action reveals `CHESSM` before skipping king growth when `CHESS_SOLVED` is true. Updated the ScummVM solved-puzzle shortcut to queue the live NPC death transition instead of finalizing it immediately, so the room loop can play the death animation and dispatch `ADD_CHESS_CU` at the terminal frame.
+- On April 11, 2026, verified native ESC menu filtering in Ghidra. `run_main_menu` loads `main_menu_2` into the Save slot, then replaces that slot with a single blank string when `g_player_present_in_room == 0`; `room_setup` uses that no-player path for closeup rooms that spawn `EXIT_BM` and `EXIT_HS` instead of the player. Updated the ScummVM in-room ESC menu to build a displayed item list and blank Save when the current `RoomSetupState` has no entrance/player, matching the closeup behavior without changing the raw `MENU.INI` item list.
 
 ## Next Suggested Action
 
-- In-engine, replay the solved Chessmaster dialogue path from `CHESS_SOLVED` through `C125.FST` and the queued NPC death transition. Confirm that `combat npc death start`/`complete` logs appear for `CHESSMASTER`, `ADD_CHESS_CU` reveals the `CHESSM` body/hotspot, the live `CHESSMASTER` actor does not respawn, and examining the body reaches the `CHESSHEAD` closeup/key interaction.
+- In-engine, enter the `CHESSHEAD` closeup from `CHESSM`, press Escape, and confirm the Save row is blank/unselectable while Load, Options, Help, and Quit still behave normally. Then exit the closeup and confirm Save reappears in the ordinary `CHESSROOM` ESC menu.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 90b6fe325c0..bdc471e4741 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -1459,8 +1459,9 @@ Common::Error Flow::runMainMenuStub() {
 	return _menu.runMainMenuStub(*this);
 }
 
-Common::Error Flow::runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette, float paletteBrightness) {
-	return _menu.runRoomMenuStub(backdrop, palette, paletteBrightness, *this);
+Common::Error Flow::runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, bool canSaveGame) {
+	return _menu.runRoomMenuStub(backdrop, palette, paletteBrightness, *this, canSaveGame);
 }
 
 Common::Error Flow::runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index b02778ac334..cf621ccdef3 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -63,7 +63,8 @@ private:
 	bool loadMenuItems();
 	Common::Error runQuickTips();
 	Common::Error runMainMenuStub();
-	Common::Error runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette, float paletteBrightness);
+	Common::Error runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
+		float paletteBrightness, bool canSaveGame);
 	Common::Error runRoomNpcDialogue(const IndexedBitmap &backdrop, const byte *palette,
 		float paletteBrightness, const NpcRecord &npc, const Common::String &usedItemName);
 	Common::Error runTownMapSelector(const Common::String &mapEntryName, Common::String &destinationEntranceName);
diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index cfbe1d65244..b430da98144 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -957,18 +957,23 @@ void MenuSystem::clearMainMenuBackdrop() {
 }
 
 Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, Flow &flow) {
+		float paletteBrightness, Flow &flow, bool canSaveGame) {
 	Graphics::FrameLimiter limiter(g_system, 60);
-	int selectedItem = _menuItems.empty() ? -1 : 0;
+	Common::Array<Common::String> roomMenuItems;
+	buildDisplayMainMenuItems(_menuItems,
+		canSaveGame && _engine.canSaveGameStateCurrently(),
+		_engine.canLoadGameStateCurrently(),
+		roomMenuItems);
+	int selectedItem = roomMenuItems.empty() ? -1 : 0;
 	bool needsRedraw = true;
 	bool shouldCloseMenu = false;
 	flow.resetCursorAnimationSequence();
 
 	auto activateSelectedItem = [&]() -> Common::Error {
-		if (selectedItem < 0 || selectedItem >= (int)_menuItems.size())
+		if (selectedItem < 0 || selectedItem >= (int)roomMenuItems.size())
 			return Common::kNoError;
 
-		const Common::String &item = _menuItems[selectedItem];
+		const Common::String &item = roomMenuItems[selectedItem];
 		if (item.equalsIgnoreCase("NEW GAME")) {
 			RoomMenuTextConfig config;
 			(void)loadMenuTextConfig(_engine, config);
@@ -1045,7 +1050,7 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 
 	while (!_engine.shouldQuit()) {
 		if (needsRedraw) {
-			renderBackdropMenuScreen(backdrop, palette, paletteBrightness, _menuItems, selectedItem);
+			renderBackdropMenuScreen(backdrop, palette, paletteBrightness, roomMenuItems, selectedItem);
 			needsRedraw = false;
 		}
 
@@ -1057,7 +1062,7 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 
 			switch (event.type) {
 			case Common::EVENT_MOUSEMOVE: {
-				const int hoveredItem = getBackdropMenuItemAt(_mousePos, _menuItems);
+				const int hoveredItem = getBackdropMenuItemAt(_mousePos, roomMenuItems);
 				if (hoveredItem != -1 && hoveredItem != selectedItem) {
 					selectedItem = hoveredItem;
 					needsRedraw = true;
@@ -1068,14 +1073,14 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
 					return Common::kNoError;
 
-				if (_menuItems.empty())
+				if (roomMenuItems.empty())
 					break;
 
 				if (event.kbd.keycode == Common::KEYCODE_UP) {
-					selectedItem = (selectedItem + _menuItems.size() - 1) % _menuItems.size();
+					selectedItem = (selectedItem + roomMenuItems.size() - 1) % roomMenuItems.size();
 					needsRedraw = true;
 				} else if (event.kbd.keycode == Common::KEYCODE_DOWN) {
-					selectedItem = (selectedItem + 1) % _menuItems.size();
+					selectedItem = (selectedItem + 1) % roomMenuItems.size();
 					needsRedraw = true;
 				} else if (event.kbd.keycode == Common::KEYCODE_RETURN ||
 						event.kbd.keycode == Common::KEYCODE_KP_ENTER) {
@@ -1088,10 +1093,10 @@ Common::Error MenuSystem::runRoomMenuStub(const IndexedBitmap &backdrop, const b
 				}
 				break;
 			case Common::EVENT_LBUTTONDOWN: {
-				if (_menuItems.empty())
+				if (roomMenuItems.empty())
 					break;
 
-				selectedItem = getBackdropMenuItemAt(_mousePos, _menuItems);
+				selectedItem = getBackdropMenuItemAt(_mousePos, roomMenuItems);
 				if (selectedItem == -1)
 					break;
 
diff --git a/engines/harvester/menu.h b/engines/harvester/menu.h
index 58c512a36c0..2f4239f9290 100644
--- a/engines/harvester/menu.h
+++ b/engines/harvester/menu.h
@@ -44,7 +44,7 @@ public:
 
 	Common::Error runMainMenuStub(Flow &flow);
 	Common::Error runRoomMenuStub(const IndexedBitmap &backdrop, const byte *palette,
-		float paletteBrightness, Flow &flow);
+		float paletteBrightness, Flow &flow, bool canSaveGame);
 
 private:
 	Common::Error runLoadGameMenu(const byte *palette, float paletteBrightness,
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index b52a1ae6fc6..5c7ed3ba26d 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -4656,7 +4656,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					if (!captureScreenBackdrop(*activeScreen, roomMenuBackdrop))
 						return Common::kReadingFailed;
 					Common::Error menuError = flow.runRoomMenuStub(
-						roomMenuBackdrop, scene.palette, scene.targetPaletteBrightness);
+						roomMenuBackdrop, scene.palette, scene.targetPaletteBrightness,
+						scene.state.hasEntrance);
 					if (menuError.getCode() != Common::kNoError)
 						return menuError;
 					flow.resetCursorAnimationSequence();


Commit: 4f022d04e06e284b58cdc520a346b19a15e6d584
    https://github.com/scummvm/scummvm/commit/4f022d04e06e284b58cdc520a346b19a15e6d584
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:41+02:00

Commit Message:
HARVESTER: Match native click movement

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/player.cpp
    engines/harvester/room_support.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index a2eed897aba..369894508af 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1073,6 +1073,11 @@
     - the vertical walk states copy `abs(+0x1140)` into `frame_y_step`
     - they apply `+/- g_current_room_def->z_velocity_step` separately through `frame_z_step`
     - the later `set_entity_screen_position` handoff commits `screen_y` and `z` independently, so native walk updates do not derive screen Y from depth each tick
+  - Empty-floor click targets are also component-based rather than diagonal:
+    - `run_harvester_main_loop` stores cursor `x` in live actor `+0x10ac`, stores the mapped target depth in `+0x10b4`, and seeds the target/waypoint sentinel at `waypoints[8].target_z`
+    - `update_actor_runtime_state` treats horizontal target satisfaction as `frame_left_x` within `depth_scale * 50.0` of the stored target `x`, then clears that component
+    - depth target satisfaction uses the native `+/- 8.0` Z band; vertical walk advances screen Y by the `+0x1140` step and depth by `g_current_room_def->z_velocity_step`
+    - because the horizontal branch is evaluated after the depth branch and can override the desired state while X is still pending, a click that needs both rightward and downward movement walks right first, then switches to the vertical/down walk once X is satisfied
   - For class-6 monsters specifically, pursuit is not a free-running room-script heuristic: the native state machine first closes base Z to within `g_player_combat_avatar->z +/- 2.0`, then compares center-to-center X against `engage_distance`, and only arms the close-range attack picker after that spacing gate is satisfied.
   - The same class-6 path derives its horizontal chase waypoint from the player's live left edge plus the current player/monster frame widths and treats that waypoint as satisfied within `50.0 * depth_scale` pixels, so native monster pursuit does not require exact center equality before it can stop walking and attack.
   - Once that close-range gate is satisfied and the per-attack cooldown allows it, the class-6 branch stores `g_player_combat_avatar` into live actor field `+0x11a4` before entering the close-range attack family; the later contact block resolves against that linked target instead of re-running the coarse engage-distance spacing test.
diff --git a/TRACKER.md b/TRACKER.md
index 1c89b32ca26..7365aae1bf2 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 11, 2026, verified native ESC menu filtering in Ghidra. `run_main_menu` loads `main_menu_2` into the Save slot, then replaces that slot with a single blank string when `g_player_present_in_room == 0`; `room_setup` uses that no-player path for closeup rooms that spawn `EXIT_BM` and `EXIT_HS` instead of the player. Updated the ScummVM in-room ESC menu to build a displayed item list and blank Save when the current `RoomSetupState` has no entrance/player, matching the closeup behavior without changing the raw `MENU.INI` item list.
+- On April 11, 2026, verified native empty-floor click movement in Ghidra. `run_harvester_main_loop` stores cursor `x` and mapped target depth separately, while `update_actor_runtime_state` satisfies horizontal movement within `depth_scale * 50.0`, satisfies depth within `+/- 8.0`, and lets pending horizontal movement override pending vertical/depth movement. Updated ScummVM target walking to move horizontally first, then vertical/depth, with per-component target latches so frame-width changes do not restart horizontal chasing after it has been satisfied.
 
 ## Next Suggested Action
 
-- In-engine, enter the `CHESSHEAD` closeup from `CHESSM`, press Escape, and confirm the Save row is blank/unselectable while Load, Options, Help, and Quit still behave normally. Then exit the closeup and confirm Save reappears in the ordinary `CHESSROOM` ESC menu.
+- In-engine, click the empty floor in `CHESSROOM` around `(514,422)` from the logged start position and confirm the player walks right first, then switches to the down-walk/depth movement instead of stopping after the horizontal segment.
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 7546fe73639..cdcc7545251 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -36,6 +36,8 @@ namespace {
 
 static const char *const kPlayerActorResourcePath = "1:/GRAPHIC/MONSTERS/PC/PC0.ABM";
 static const float kRoomPlayerHorizontalMoveBase = 8.0f;
+static const float kRoomPlayerHorizontalTargetSlackBase = 50.0f;
+static const float kRoomPlayerDepthTargetSlack = 8.0f;
 static const int kRoomRegionTargetXBias = 10;
 static const float kRoomDepthCompareEpsilon = 0.01f;
 static const int kRoomPlayerWalkAnimationRate = 17;
@@ -587,6 +589,58 @@ static int computeRoomPlayerVerticalScreenStep() {
 	return kRoomPlayerVerticalScreenStep;
 }
 
+static int computePlayerFrameLeftX(const RoomPlayerState &playerState) {
+	if (!playerState.entity)
+		return playerState.centerX;
+
+	int width = 0;
+	int height = 0;
+	int xOffset = 0;
+	int yOffset = 0;
+	if (!playerState.entity->getCurrentFrameMetrics(width, height, xOffset, yOffset))
+		return playerState.centerX;
+
+	return playerState.centerX - width / 2;
+}
+
+static int resolveMoveTargetHorizontalDirection(const RoomSetupState &state,
+		const RoomPlayerState &playerState) {
+	const float slack = Player::computeDepthScale(state, playerState.z) *
+		kRoomPlayerHorizontalTargetSlackBase;
+	const float frameLeftX = (float)computePlayerFrameLeftX(playerState);
+	if (frameLeftX < (float)playerState.targetX - slack)
+		return 1;
+	if ((float)playerState.targetX + slack < frameLeftX)
+		return -1;
+
+	return 0;
+}
+
+static int resolveMoveTargetDepthDirection(const RoomPlayerState &playerState) {
+	if (playerState.targetZ < playerState.z - kRoomPlayerDepthTargetSlack)
+		return 1;
+	if (playerState.z + kRoomPlayerDepthTargetSlack < playerState.targetZ)
+		return -1;
+
+	return 0;
+}
+
+static bool hasPlayerReachedMoveTarget(const RoomPlayerState &playerState) {
+	return playerState.moveTargetXReached && playerState.moveTargetZReached;
+}
+
+static void updatePlayerMoveTargetProgress(const RoomSetupState &state,
+		RoomPlayerState &playerState) {
+	if (!playerState.moveTargetXReached &&
+			resolveMoveTargetHorizontalDirection(state, playerState) == 0) {
+		playerState.moveTargetXReached = true;
+	}
+	if (!playerState.moveTargetZReached &&
+			resolveMoveTargetDepthDirection(playerState) == 0) {
+		playerState.moveTargetZReached = true;
+	}
+}
+
 static int clampPlayerCenterXToNativeBounds(const RoomPlayerState &playerState, int centerX) {
 	if (!playerState.entity)
 		return CLIP<int>(centerX, 0, 639);
@@ -632,14 +686,6 @@ static bool consumePlayerMovementTick(RoomPlayerState &playerState) {
 	return true;
 }
 
-static int stepTowardsInt(int current, int target, int step) {
-	if (step <= 0 || current == target)
-		return current;
-	if (current < target)
-		return MIN(current + step, target);
-	return MAX(current - step, target);
-}
-
 static float stepTowardsFloat(float current, float target, float step) {
 	if (step <= 0.0f || fabsf(target - current) <= kRoomDepthCompareEpsilon)
 		return target;
@@ -757,9 +803,11 @@ static void setMoveTargetInternal(const RoomSetupState &state, RoomPlayerState &
 		int targetX, float targetZ, int targetBottomY) {
 	playerState.hasMoveTarget = true;
 	playerState.nextMovementTick = 0;
-	playerState.targetX = clampPlayerCenterXToNativeBounds(playerState, targetX);
+	playerState.targetX = CLIP<int>(targetX, 0, 639);
 	playerState.targetBottomY = clampRoomMovementY(state, targetBottomY);
 	playerState.targetZ = clampRoomDepth(state, targetZ);
+	playerState.moveTargetXReached = false;
+	playerState.moveTargetZReached = false;
 	debugC(1, kDebugPlayer,
 		"Harvester: player move target room='%s' current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f)",
 		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
@@ -1382,43 +1430,57 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 	if (!playerState.entity || !playerState.hasMoveTarget || playerState.turnActive || playerState.hitActive)
 		return false;
 
-	if (playerState.centerX == playerState.targetX &&
-			playerState.bottomY == playerState.targetBottomY &&
-			fabsf(playerState.z - playerState.targetZ) <= kRoomDepthCompareEpsilon) {
+	updatePlayerMoveTargetProgress(state, playerState);
+	if (hasPlayerReachedMoveTarget(playerState)) {
 		playerState.hasMoveTarget = false;
 		return setIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
 	}
 
 	const int previousCenterX = playerState.centerX;
 	const int previousBottomY = playerState.bottomY;
-	const float previousZ = playerState.z;
-	const int moveFacing = resolveFacingFromMovementDelta(
-		playerState.targetX - playerState.centerX, playerState.targetBottomY - playerState.bottomY);
+	const int horizontalDirection = playerState.moveTargetXReached
+		? 0
+		: resolveMoveTargetHorizontalDirection(state, playerState);
+	const int depthDirection = horizontalDirection == 0 && !playerState.moveTargetZReached
+		? resolveMoveTargetDepthDirection(playerState)
+		: 0;
 	if (!consumePlayerMovementTick(playerState))
 		return false;
-	const int horizontalStep = computeRoomPlayerHorizontalStep(state, playerState.z);
-	const int verticalStep = computeRoomPlayerVerticalScreenStep();
-	const float depthStep = computeRoomPlayerDepthStep(state);
-	const int candidateCenterX = stepTowardsInt(playerState.centerX, playerState.targetX, horizontalStep);
-	const int candidateBottomY = stepTowardsInt(playerState.bottomY, playerState.targetBottomY, verticalStep);
-	const float candidateZ = stepTowardsFloat(playerState.z, playerState.targetZ, depthStep);
 
-	bool moved = tryApplyPlayerMovementWithFallbacks(engine, state, sceneObjects, sceneAnimations,
-		playerState, previousCenterX, previousBottomY, previousZ,
-		candidateCenterX, candidateBottomY, candidateZ);
+	int candidateCenterX = playerState.centerX;
+	int candidateBottomY = playerState.bottomY;
+	float candidateZ = playerState.z;
+	if (horizontalDirection != 0) {
+		const int horizontalStep = computeRoomPlayerHorizontalStep(state, playerState.z);
+		candidateCenterX = clampPlayerCenterXToNativeBounds(
+			playerState, playerState.centerX + horizontalDirection * horizontalStep);
+	} else if (depthDirection != 0) {
+		const int verticalStep = computeRoomPlayerVerticalScreenStep();
+		const float depthStep = computeRoomPlayerDepthStep(state);
+		candidateBottomY = clampRoomMovementY(state,
+			playerState.bottomY + depthDirection * verticalStep);
+		candidateZ = stepTowardsFloat(playerState.z, playerState.targetZ, depthStep);
+	}
+
+	bool moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
+		playerState, candidateCenterX, candidateBottomY, candidateZ);
 	if (!moved) {
 		playerState.hasMoveTarget = false;
-		return setIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : moveFacing);
+		int idleFacing = playerState.facing;
+		if (idleFacing < 0) {
+			if (horizontalDirection != 0)
+				idleFacing = horizontalDirection < 0 ? 1 : 2;
+			else
+				idleFacing = depthDirection < 0 ? 3 : 0;
+		}
+		return setIdleAnimation(playerState, idleFacing);
 	}
 
 	const int actualFacing = resolveFacingFromRoomMovement(
 		previousCenterX, previousBottomY, playerState.centerX, playerState.bottomY);
 	(void)setPlayerWalkAnimation(playerState, actualFacing);
-	if (playerState.centerX == playerState.targetX &&
-			playerState.bottomY == playerState.targetBottomY &&
-			fabsf(playerState.z - playerState.targetZ) <= kRoomDepthCompareEpsilon) {
-		playerState.bottomY = playerState.targetBottomY;
-		playerState.z = playerState.targetZ;
+	updatePlayerMoveTargetProgress(state, playerState);
+	if (hasPlayerReachedMoveTarget(playerState)) {
 		playerState.hasMoveTarget = false;
 		(void)setIdleAnimation(playerState, actualFacing);
 	}
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index b5cdf39a093..e43b6da7b4b 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -64,6 +64,8 @@ struct RoomPlayerState {
 	int targetX = 0;
 	int targetBottomY = 0;
 	float targetZ = 0.0f;
+	bool moveTargetXReached = false;
+	bool moveTargetZReached = false;
 	bool turnActive = false;
 	int turnTargetFacing = -1;
 	int turnFirstFrame = -1;


Commit: 611aa2b02804deec314889e218a55c0d61886ce6
    https://github.com/scummvm/scummvm/commit/611aa2b02804deec314889e218a55c0d61886ce6
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:42+02:00

Commit Message:
HARVESTER: Route click movement around blockers

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/player.cpp
    engines/harvester/room_support.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 369894508af..10b5f53f782 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1078,6 +1078,8 @@
     - `update_actor_runtime_state` treats horizontal target satisfaction as `frame_left_x` within `depth_scale * 50.0` of the stored target `x`, then clears that component
     - depth target satisfaction uses the native `+/- 8.0` Z band; vertical walk advances screen Y by the `+0x1140` step and depth by `g_current_room_def->z_velocity_step`
     - because the horizontal branch is evaluated after the depth branch and can override the desired state while X is still pending, a click that needs both rightward and downward movement walks right first, then switches to the vertical/down walk once X is satisfied
+    - when a pending horizontal click component is blocked, the state machine pushes a perpendicular Z waypoint around the blocking entity using `blocker.z - actor.z_extent - 1` or `blocker.z + blocker.z_extent + 1`, clears the pending X component, and records the blocker in the matching directional history slot
+    - when a pending vertical/depth component is blocked, it pushes a perpendicular X waypoint using `blocker.left - actor.frame_width - 0x28` or `blocker.right + 0x28`, clears the pending Z component, and uses the blocker-history slots to avoid immediately rejecting the detour step against the same blocker
   - For class-6 monsters specifically, pursuit is not a free-running room-script heuristic: the native state machine first closes base Z to within `g_player_combat_avatar->z +/- 2.0`, then compares center-to-center X against `engage_distance`, and only arms the close-range attack picker after that spacing gate is satisfied.
   - The same class-6 path derives its horizontal chase waypoint from the player's live left edge plus the current player/monster frame widths and treats that waypoint as satisfied within `50.0 * depth_scale` pixels, so native monster pursuit does not require exact center equality before it can stop walking and attack.
   - Once that close-range gate is satisfied and the per-attack cooldown allows it, the class-6 branch stores `g_player_combat_avatar` into live actor field `+0x11a4` before entering the close-range attack family; the later contact block resolves against that linked target instead of re-running the coarse engage-distance spacing test.
diff --git a/TRACKER.md b/TRACKER.md
index 7365aae1bf2..0fd7e314c0a 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 11, 2026, verified native empty-floor click movement in Ghidra. `run_harvester_main_loop` stores cursor `x` and mapped target depth separately, while `update_actor_runtime_state` satisfies horizontal movement within `depth_scale * 50.0`, satisfies depth within `+/- 8.0`, and lets pending horizontal movement override pending vertical/depth movement. Updated ScummVM target walking to move horizontally first, then vertical/depth, with per-component target latches so frame-width changes do not restart horizontal chasing after it has been satisfied.
+- On April 11, 2026, verified native blocked click-path handling in Ghidra. `update_actor_runtime_state` pushes perpendicular waypoints around the current blocker: horizontal blockers create a Z detour from the blocker depth span, vertical/depth blockers create an X detour from the blocker screen span, and directional blocker-history slots let the detour proceed past the same blocker. Updated ScummVM click walking to queue those detours instead of clearing the target on the first blocked step.
 
 ## Next Suggested Action
 
-- In-engine, click the empty floor in `CHESSROOM` around `(514,422)` from the logged start position and confirm the player walks right first, then switches to the down-walk/depth movement instead of stopping after the horizontal segment.
+- In-engine, click the empty floor in `CHESSROOM` around `(494,415)` from the `FOYER7_2_CHESS` spawn and confirm the player walks right, queues a depth detour around the room blocker, then resumes toward the clicked floor position.
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index cdcc7545251..a864348f4f6 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -38,6 +38,9 @@ static const char *const kPlayerActorResourcePath = "1:/GRAPHIC/MONSTERS/PC/PC0.
 static const float kRoomPlayerHorizontalMoveBase = 8.0f;
 static const float kRoomPlayerHorizontalTargetSlackBase = 50.0f;
 static const float kRoomPlayerDepthTargetSlack = 8.0f;
+static const int kRoomPlayerMaxMoveWaypoints = 8;
+static const int kRoomPlayerBlockerHorizontalPad = 0x28;
+static const float kRoomPlayerBlockerDepthPad = 1.0f;
 static const int kRoomRegionTargetXBias = 10;
 static const float kRoomDepthCompareEpsilon = 0.01f;
 static const int kRoomPlayerWalkAnimationRate = 17;
@@ -603,23 +606,49 @@ static int computePlayerFrameLeftX(const RoomPlayerState &playerState) {
 	return playerState.centerX - width / 2;
 }
 
+static RoomMoveWaypoint getActiveMoveTarget(const RoomPlayerState &playerState) {
+	if (!playerState.moveWaypoints.empty())
+		return playerState.moveWaypoints[0];
+
+	RoomMoveWaypoint target;
+	target.targetX = playerState.targetX;
+	target.targetBottomY = playerState.targetBottomY;
+	target.targetZ = playerState.targetZ;
+	target.hasTargetX = true;
+	target.hasTargetZ = true;
+	return target;
+}
+
+static void resetPlayerMoveTargetProgress(RoomPlayerState &playerState) {
+	const RoomMoveWaypoint target = getActiveMoveTarget(playerState);
+	playerState.moveTargetXReached = !target.hasTargetX;
+	playerState.moveTargetZReached = !target.hasTargetZ;
+}
+
 static int resolveMoveTargetHorizontalDirection(const RoomSetupState &state,
-		const RoomPlayerState &playerState) {
+		const RoomPlayerState &playerState, const RoomMoveWaypoint &target) {
+	if (!target.hasTargetX)
+		return 0;
+
 	const float slack = Player::computeDepthScale(state, playerState.z) *
 		kRoomPlayerHorizontalTargetSlackBase;
 	const float frameLeftX = (float)computePlayerFrameLeftX(playerState);
-	if (frameLeftX < (float)playerState.targetX - slack)
+	if (frameLeftX < (float)target.targetX - slack)
 		return 1;
-	if ((float)playerState.targetX + slack < frameLeftX)
+	if ((float)target.targetX + slack < frameLeftX)
 		return -1;
 
 	return 0;
 }
 
-static int resolveMoveTargetDepthDirection(const RoomPlayerState &playerState) {
-	if (playerState.targetZ < playerState.z - kRoomPlayerDepthTargetSlack)
+static int resolveMoveTargetDepthDirection(const RoomPlayerState &playerState,
+		const RoomMoveWaypoint &target) {
+	if (!target.hasTargetZ)
+		return 0;
+
+	if (target.targetZ < playerState.z - kRoomPlayerDepthTargetSlack)
 		return 1;
-	if (playerState.z + kRoomPlayerDepthTargetSlack < playerState.targetZ)
+	if (playerState.z + kRoomPlayerDepthTargetSlack < target.targetZ)
 		return -1;
 
 	return 0;
@@ -630,13 +659,13 @@ static bool hasPlayerReachedMoveTarget(const RoomPlayerState &playerState) {
 }
 
 static void updatePlayerMoveTargetProgress(const RoomSetupState &state,
-		RoomPlayerState &playerState) {
+		RoomPlayerState &playerState, const RoomMoveWaypoint &target) {
 	if (!playerState.moveTargetXReached &&
-			resolveMoveTargetHorizontalDirection(state, playerState) == 0) {
+			resolveMoveTargetHorizontalDirection(state, playerState, target) == 0) {
 		playerState.moveTargetXReached = true;
 	}
 	if (!playerState.moveTargetZReached &&
-			resolveMoveTargetDepthDirection(playerState) == 0) {
+			resolveMoveTargetDepthDirection(playerState, target) == 0) {
 		playerState.moveTargetZReached = true;
 	}
 }
@@ -694,35 +723,49 @@ static float stepTowardsFloat(float current, float target, float step) {
 	return MAX(current - step, target);
 }
 
-static bool doesPlayerOverlapRoomBlocker(HarvesterEngine &engine,
+static bool shouldIgnoreMovementBlocker(const Entity &entity, const Common::String *ignoredBlockerName) {
+	return ignoredBlockerName && !ignoredBlockerName->empty() &&
+		entity.getName().equalsIgnoreCase(*ignoredBlockerName);
+}
+
+static const Entity *findPlayerRoomBlocker(HarvesterEngine &engine,
 		const Common::Array<ObjectRecord> &sceneObjects,
-		const Common::Array<AnimRecord> &sceneAnimations, const Entity &playerEntity) {
+		const Common::Array<AnimRecord> &sceneAnimations, const Entity &playerEntity,
+		const Common::String *ignoredBlockerName) {
 	EntityManager *entityManager = engine.getRuntimeEntities();
 	if (!entityManager)
-		return false;
+		return nullptr;
 
 	for (const ObjectRecord &object : sceneObjects) {
 		const Entity *entity = entityManager->findSceneEntityByName(object.objectName);
-		if (entity && playerEntity.overlapsEntity(*entity))
-			return true;
+		if (entity && !shouldIgnoreMovementBlocker(*entity, ignoredBlockerName) &&
+				playerEntity.overlapsEntity(*entity)) {
+			return entity;
+		}
 	}
 
 	for (const AnimRecord &anim : sceneAnimations) {
 		const Entity *entity = entityManager->findSceneEntityByName(anim.animName);
-		if (entity && playerEntity.overlapsEntity(*entity))
-			return true;
+		if (entity && !shouldIgnoreMovementBlocker(*entity, ignoredBlockerName) &&
+				playerEntity.overlapsEntity(*entity)) {
+			return entity;
+		}
 	}
 
-	return false;
+	return nullptr;
 }
 
 static bool isPlayerMovementBlocked(HarvesterEngine &engine, const RoomSetupState &state,
 		const Common::Array<ObjectRecord> &sceneObjects,
 		const Common::Array<AnimRecord> &sceneAnimations,
-		RoomPlayerState &playerState, int candidateCenterX, int candidateBottomY, float candidateZ) {
+		RoomPlayerState &playerState, int candidateCenterX, int candidateBottomY, float candidateZ,
+		const Common::String *ignoredBlockerName = nullptr, const Entity **blockerOut = nullptr) {
 	if (!playerState.entity)
 		return true;
 
+	if (blockerOut)
+		*blockerOut = nullptr;
+
 	const int oldCenterX = playerState.centerX;
 	const int oldBottomY = playerState.bottomY;
 	const float oldZ = playerState.z;
@@ -733,18 +776,25 @@ static bool isPlayerMovementBlocked(HarvesterEngine &engine, const RoomSetupStat
 		return true;
 	}
 
-	const bool blocked = doesPlayerOverlapRoomBlocker(engine, sceneObjects, sceneAnimations, *playerState.entity);
+	const Entity *blocker = findPlayerRoomBlocker(engine, sceneObjects, sceneAnimations,
+		*playerState.entity, ignoredBlockerName);
 	(void)applyRoomActorPlacement(state, *playerState.entity, oldCenterX, oldBottomY, oldZ);
-	return blocked;
+	if (blockerOut)
+		*blockerOut = blocker;
+	return blocker != nullptr;
 }
 
 static bool tryApplyPlayerMovement(HarvesterEngine &engine, const RoomSetupState &state,
 		const Common::Array<ObjectRecord> &sceneObjects,
 		const Common::Array<AnimRecord> &sceneAnimations,
-		RoomPlayerState &playerState, int candidateCenterX, int candidateBottomY, float candidateZ) {
+		RoomPlayerState &playerState, int candidateCenterX, int candidateBottomY, float candidateZ,
+		const Common::String *ignoredBlockerName = nullptr, const Entity **blockerOut = nullptr) {
 	if (!playerState.entity)
 		return false;
 
+	if (blockerOut)
+		*blockerOut = nullptr;
+
 	candidateCenterX = clampPlayerCenterXToNativeBounds(playerState, candidateCenterX);
 	candidateBottomY = clampRoomMovementY(state, candidateBottomY);
 	candidateZ = clampRoomDepth(state, candidateZ);
@@ -754,7 +804,8 @@ static bool tryApplyPlayerMovement(HarvesterEngine &engine, const RoomSetupState
 		return false;
 	}
 	if (isPlayerMovementBlocked(engine, state, sceneObjects, sceneAnimations,
-			playerState, candidateCenterX, candidateBottomY, candidateZ)) {
+			playerState, candidateCenterX, candidateBottomY, candidateZ,
+			ignoredBlockerName, blockerOut)) {
 		return false;
 	}
 
@@ -799,6 +850,142 @@ static bool tryApplyPlayerMovementWithFallbacks(HarvesterEngine &engine, const R
 		playerState, previousCenterX, previousBottomY, candidateZ);
 }
 
+static bool getPlayerFrameWidth(const RoomPlayerState &playerState, int &width) {
+	if (!playerState.entity)
+		return false;
+
+	int height = 0;
+	int xOffset = 0;
+	int yOffset = 0;
+	return playerState.entity->getCurrentFrameMetrics(width, height, xOffset, yOffset);
+}
+
+static bool isSameMoveWaypoint(const RoomMoveWaypoint &lhs, const RoomMoveWaypoint &rhs) {
+	if (lhs.hasTargetX != rhs.hasTargetX || lhs.hasTargetZ != rhs.hasTargetZ)
+		return false;
+	if (lhs.hasTargetX && lhs.targetX != rhs.targetX)
+		return false;
+	if (lhs.hasTargetZ &&
+			fabsf(lhs.targetZ - rhs.targetZ) > kRoomDepthCompareEpsilon) {
+		return false;
+	}
+
+	return lhs.ignoredBlockerName.equalsIgnoreCase(rhs.ignoredBlockerName);
+}
+
+static bool prependMoveWaypoint(RoomPlayerState &playerState, const RoomMoveWaypoint &waypoint) {
+	if (playerState.moveWaypoints.size() >= kRoomPlayerMaxMoveWaypoints)
+		return false;
+
+	if (!playerState.moveWaypoints.empty() &&
+			isSameMoveWaypoint(playerState.moveWaypoints[0], waypoint)) {
+		return false;
+	}
+
+	playerState.moveWaypoints.insert_at(0, waypoint);
+	resetPlayerMoveTargetProgress(playerState);
+	playerState.nextMovementTick = 0;
+	return true;
+}
+
+static bool queueDepthAvoidanceWaypoint(const RoomSetupState &state, RoomPlayerState &playerState,
+		const RoomMoveWaypoint &activeTarget, const Entity &blocker) {
+	const float blockerZ = blocker.getZ();
+	const float blockerZMax = blockerZ + blocker.getZExtent();
+	const float blockerMidZ = blockerZ + blocker.getZExtent() * 0.5f;
+	const float referenceTargetZ = activeTarget.hasTargetZ
+		? activeTarget.targetZ
+		: playerState.targetZ;
+	const float playerZExtent = playerState.entity
+		? MAX<float>(0.0f, playerState.entity->getZExtent())
+		: 0.0f;
+
+	float candidates[2];
+	if (referenceTargetZ < blockerMidZ) {
+		candidates[0] = blockerZ - playerZExtent - kRoomPlayerBlockerDepthPad;
+		candidates[1] = blockerZMax + kRoomPlayerBlockerDepthPad;
+	} else {
+		candidates[0] = blockerZMax + kRoomPlayerBlockerDepthPad;
+		candidates[1] = blockerZ - playerZExtent - kRoomPlayerBlockerDepthPad;
+	}
+
+	for (uint i = 0; i < ARRAYSIZE(candidates); ++i) {
+		const float targetZ = clampRoomDepth(state, candidates[i]);
+		if (fabsf(targetZ - playerState.z) <= kRoomPlayerDepthTargetSlack)
+			continue;
+
+		RoomMoveWaypoint waypoint;
+		waypoint.targetZ = targetZ;
+		waypoint.targetBottomY = mapRoomDepthToScreenY(state, targetZ);
+		waypoint.hasTargetZ = true;
+		waypoint.ignoredBlockerName = blocker.getName();
+		if (prependMoveWaypoint(playerState, waypoint))
+			return true;
+	}
+
+	return false;
+}
+
+static bool queueHorizontalAvoidanceWaypoint(const RoomSetupState &state, RoomPlayerState &playerState,
+		const RoomMoveWaypoint &activeTarget, const Entity &blocker) {
+	int playerWidth = 0;
+	if (!getPlayerFrameWidth(playerState, playerWidth))
+		return false;
+
+	const Common::Rect blockerRect = blocker.getScreenRect();
+	const int blockerMidX = blockerRect.left + blockerRect.width() / 2;
+	const int referenceTargetX = activeTarget.hasTargetX
+		? activeTarget.targetX
+		: playerState.targetX;
+
+	int candidates[2];
+	if (referenceTargetX < blockerMidX) {
+		candidates[0] = blockerRect.left - playerWidth - kRoomPlayerBlockerHorizontalPad;
+		candidates[1] = blockerRect.right + kRoomPlayerBlockerHorizontalPad;
+	} else {
+		candidates[0] = blockerRect.right + kRoomPlayerBlockerHorizontalPad;
+		candidates[1] = blockerRect.left - playerWidth - kRoomPlayerBlockerHorizontalPad;
+	}
+
+	for (uint i = 0; i < ARRAYSIZE(candidates); ++i) {
+		RoomMoveWaypoint waypoint;
+		waypoint.targetX = CLIP<int>(candidates[i], 0, 639);
+		waypoint.hasTargetX = true;
+		waypoint.ignoredBlockerName = blocker.getName();
+		if (resolveMoveTargetHorizontalDirection(state, playerState, waypoint) == 0)
+			continue;
+		if (prependMoveWaypoint(playerState, waypoint))
+			return true;
+	}
+
+	return false;
+}
+
+static bool queueBlockedMoveAvoidanceWaypoint(const RoomSetupState &state,
+		RoomPlayerState &playerState, const RoomMoveWaypoint &activeTarget,
+		int horizontalDirection, int depthDirection, const Entity &blocker) {
+	bool queued = false;
+	const char *axis = nullptr;
+	if (horizontalDirection != 0) {
+		queued = queueDepthAvoidanceWaypoint(state, playerState, activeTarget, blocker);
+		axis = "depth";
+	} else if (depthDirection != 0) {
+		queued = queueHorizontalAvoidanceWaypoint(state, playerState, activeTarget, blocker);
+		axis = "horizontal";
+	}
+
+	if (queued) {
+		const RoomMoveWaypoint waypoint = getActiveMoveTarget(playerState);
+		debugC(1, kDebugPlayer,
+			"Harvester: player move detour room='%s' blocker='%s' axis='%s' waypoint=(x=%d,z=%.2f,hasX=%d,hasZ=%d)",
+			state.roomName.c_str(), blocker.getName().c_str(), axis,
+			waypoint.targetX, (double)waypoint.targetZ,
+			waypoint.hasTargetX, waypoint.hasTargetZ);
+	}
+
+	return queued;
+}
+
 static void setMoveTargetInternal(const RoomSetupState &state, RoomPlayerState &playerState,
 		int targetX, float targetZ, int targetBottomY) {
 	playerState.hasMoveTarget = true;
@@ -806,8 +993,8 @@ static void setMoveTargetInternal(const RoomSetupState &state, RoomPlayerState &
 	playerState.targetX = CLIP<int>(targetX, 0, 639);
 	playerState.targetBottomY = clampRoomMovementY(state, targetBottomY);
 	playerState.targetZ = clampRoomDepth(state, targetZ);
-	playerState.moveTargetXReached = false;
-	playerState.moveTargetZReached = false;
+	playerState.moveWaypoints.clear();
+	resetPlayerMoveTargetProgress(playerState);
 	debugC(1, kDebugPlayer,
 		"Harvester: player move target room='%s' current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f)",
 		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
@@ -1430,19 +1617,27 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 	if (!playerState.entity || !playerState.hasMoveTarget || playerState.turnActive || playerState.hitActive)
 		return false;
 
-	updatePlayerMoveTargetProgress(state, playerState);
-	if (hasPlayerReachedMoveTarget(playerState)) {
-		playerState.hasMoveTarget = false;
-		return setIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
+	RoomMoveWaypoint activeTarget = getActiveMoveTarget(playerState);
+	updatePlayerMoveTargetProgress(state, playerState, activeTarget);
+	while (hasPlayerReachedMoveTarget(playerState)) {
+		if (playerState.moveWaypoints.empty()) {
+			playerState.hasMoveTarget = false;
+			return setIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
+		}
+
+		playerState.moveWaypoints.remove_at(0);
+		resetPlayerMoveTargetProgress(playerState);
+		activeTarget = getActiveMoveTarget(playerState);
+		updatePlayerMoveTargetProgress(state, playerState, activeTarget);
 	}
 
 	const int previousCenterX = playerState.centerX;
 	const int previousBottomY = playerState.bottomY;
 	const int horizontalDirection = playerState.moveTargetXReached
 		? 0
-		: resolveMoveTargetHorizontalDirection(state, playerState);
+		: resolveMoveTargetHorizontalDirection(state, playerState, activeTarget);
 	const int depthDirection = horizontalDirection == 0 && !playerState.moveTargetZReached
-		? resolveMoveTargetDepthDirection(playerState)
+		? resolveMoveTargetDepthDirection(playerState, activeTarget)
 		: 0;
 	if (!consumePlayerMovementTick(playerState))
 		return false;
@@ -1459,12 +1654,22 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 		const float depthStep = computeRoomPlayerDepthStep(state);
 		candidateBottomY = clampRoomMovementY(state,
 			playerState.bottomY + depthDirection * verticalStep);
-		candidateZ = stepTowardsFloat(playerState.z, playerState.targetZ, depthStep);
+		candidateZ = stepTowardsFloat(playerState.z, activeTarget.targetZ, depthStep);
 	}
 
+	const Common::String *ignoredBlockerName = activeTarget.ignoredBlockerName.empty()
+		? nullptr
+		: &activeTarget.ignoredBlockerName;
+	const Entity *blocker = nullptr;
 	bool moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
-		playerState, candidateCenterX, candidateBottomY, candidateZ);
+		playerState, candidateCenterX, candidateBottomY, candidateZ,
+		ignoredBlockerName, &blocker);
 	if (!moved) {
+		if (blocker && queueBlockedMoveAvoidanceWaypoint(state, playerState, activeTarget,
+				horizontalDirection, depthDirection, *blocker)) {
+			return true;
+		}
+
 		playerState.hasMoveTarget = false;
 		int idleFacing = playerState.facing;
 		if (idleFacing < 0) {
@@ -1479,16 +1684,26 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 	const int actualFacing = resolveFacingFromRoomMovement(
 		previousCenterX, previousBottomY, playerState.centerX, playerState.bottomY);
 	(void)setPlayerWalkAnimation(playerState, actualFacing);
-	updatePlayerMoveTargetProgress(state, playerState);
-	if (hasPlayerReachedMoveTarget(playerState)) {
-		playerState.hasMoveTarget = false;
-		(void)setIdleAnimation(playerState, actualFacing);
+	activeTarget = getActiveMoveTarget(playerState);
+	updatePlayerMoveTargetProgress(state, playerState, activeTarget);
+	while (hasPlayerReachedMoveTarget(playerState)) {
+		if (playerState.moveWaypoints.empty()) {
+			playerState.hasMoveTarget = false;
+			(void)setIdleAnimation(playerState, actualFacing);
+			break;
+		}
+
+		playerState.moveWaypoints.remove_at(0);
+		resetPlayerMoveTargetProgress(playerState);
+		activeTarget = getActiveMoveTarget(playerState);
+		updatePlayerMoveTargetProgress(state, playerState, activeTarget);
 	}
 	debugC(playerState.hasMoveTarget ? 2 : 1, kDebugPlayer,
-		"Harvester: player move step room='%s' pos=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f) facing=%d frame=%d active=%d moved=%d",
+		"Harvester: player move step room='%s' pos=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f) waypoints=%u facing=%d frame=%d active=%d moved=%d",
 		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
 		playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ,
-		playerState.facing, playerState.entity->getCurrentFrame(), playerState.hasMoveTarget, moved);
+		playerState.moveWaypoints.size(), playerState.facing,
+		playerState.entity->getCurrentFrame(), playerState.hasMoveTarget, moved);
 	return true;
 }
 
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index e43b6da7b4b..68b140e21e3 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -51,6 +51,15 @@ struct RoomSceneResources {
 	float targetPaletteBrightness = 1.0f;
 };
 
+struct RoomMoveWaypoint {
+	int targetX = 0;
+	int targetBottomY = 0;
+	float targetZ = 0.0f;
+	bool hasTargetX = false;
+	bool hasTargetZ = false;
+	Common::String ignoredBlockerName;
+};
+
 struct RoomPlayerState {
 	Entity *entity = nullptr;
 	int centerX = 0;
@@ -66,6 +75,7 @@ struct RoomPlayerState {
 	float targetZ = 0.0f;
 	bool moveTargetXReached = false;
 	bool moveTargetZReached = false;
+	Common::Array<RoomMoveWaypoint> moveWaypoints;
 	bool turnActive = false;
 	int turnTargetFacing = -1;
 	int turnFirstFrame = -1;


Commit: 85362ba6f63f073dd957337bb2ab42fa0b308238
    https://github.com/scummvm/scummvm/commit/85362ba6f63f073dd957337bb2ab42fa0b308238
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:42+02:00

Commit Message:
HARVESTER: Fix scripted monsterfy transitions

Changed paths:
    TRACKER.md
    engines/harvester/script.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 0fd7e314c0a..f2af3c26903 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 11, 2026, verified native blocked click-path handling in Ghidra. `update_actor_runtime_state` pushes perpendicular waypoints around the current blocker: horizontal blockers create a Z detour from the blocker depth span, vertical/depth blockers create an X detour from the blocker screen span, and directional blocker-history slots let the detour proceed past the same blocker. Updated ScummVM click walking to queue those detours instead of clearing the target on the first blocked step.
+- On April 11, 2026, verified native `MONSTERFY` handling in Ghidra. `dispatch_room_event_actions` case `0x0e` queues live NPC actor state `0x35` through `queue_npc_death_or_monsterfy_transition` without requiring a damage argument. Updated ScummVM's deferred live NPC death/monsterfy queue so blank scripted damage arguments still queue the transition using the bludge animation selector.
 
 ## Next Suggested Action
 
-- In-engine, click the empty floor in `CHESSROOM` around `(494,415)` from the `FOYER7_2_CHESS` spawn and confirm the player walks right, queues a depth detour around the room blocker, then resumes toward the clicked floor position.
+- In-engine, trigger the `BALLROM1` maintenance man fourth warning and confirm that after `"All right, buddy... you axed for it!"`, the `MONSTERFY MAINT_MAN` action starts the NPC death/monsterfy animation and spawns the configured monster target.
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 7344b2402e4..d4dd3ff19d4 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -2565,10 +2565,13 @@ bool Script::queueRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int
 	if (!currentNpc || currentNpc->deathOrMonsterfyFlag)
 		return false;
 
-	if (deathDamageType == 0 || currentNpc->deathDamageType == deathDamageType)
+	// Native live MONSTERFY queues actor state 0x35 without a damage argument.
+	// Use bludge as the queued animation selector when scripts leave it blank.
+	const int queuedDeathDamageType = deathDamageType != 0 ? deathDamageType : 1;
+	if (currentNpc->deathDamageType == queuedDeathDamageType)
 		return false;
 
-	currentNpc->deathDamageType = deathDamageType;
+	currentNpc->deathDamageType = queuedDeathDamageType;
 	return true;
 }
 


Commit: d9a28b5e39500f08e8b9b6dcb9beba253e3f15a9
    https://github.com/scummvm/scummvm/commit/d9a28b5e39500f08e8b9b6dcb9beba253e3f15a9
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:42+02:00

Commit Message:
HARVESTER: Place monster corpses from saved frame

Changed paths:
    TRACKER.md
    engines/harvester/flow.cpp


diff --git a/TRACKER.md b/TRACKER.md
index f2af3c26903..383a09f8792 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 11, 2026, verified native `MONSTERFY` handling in Ghidra. `dispatch_room_event_actions` case `0x0e` queues live NPC actor state `0x35` through `queue_npc_death_or_monsterfy_transition` without requiring a damage argument. Updated ScummVM's deferred live NPC death/monsterfy queue so blank scripted damage arguments still queue the transition using the bludge animation selector.
+- On April 11, 2026, verified native monster room-spawn ordering in Ghidra. `spawn_monster_entity_from_record` restores a saved monster corpse frame from the record and advances the entity frame before calling `set_entity_screen_position`. Updated ScummVM room population to apply monster animation state before calculating actor placement, so re-entered monster corpses start in the correct screen position.
 
 ## Next Suggested Action
 
-- In-engine, trigger the `BALLROM1` maintenance man fourth warning and confirm that after `"All right, buddy... you axed for it!"`, the `MONSTERFY MAINT_MAN` action starts the NPC death/monsterfy animation and spawns the configured monster target.
+- In-engine, re-enter `BALLROM1` from `BALLROM3_2_BALLROM1` after killing `MAINT_MAN_MNST` and confirm the corpse is drawn at its final location immediately, without a first-frame shift.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index bdc471e4741..2fe41c385fc 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -1961,6 +1961,8 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 		entity->setClassId(kRuntimeEntityClassMonster);
 		entity->setHitTestMode(kRuntimeEntityHitTestNone);
 		entity->setVisible(monster.visible);
+		// Native restores a saved monster corpse frame before set_entity_screen_position().
+		Monster::applyAnimation(*entity, monster);
 		if (!applyRoomActorPlacement(state, *entity, monster.posX, monster.posY, (float)monster.posZ)) {
 			debug(1, "Harvester: unable to apply room monster placement for '%s'",
 				monster.monsterName.c_str());


Commit: 444deaf5fa371add8a67efe758f79dfa0633133a
    https://github.com/scummvm/scummvm/commit/444deaf5fa371add8a67efe758f79dfa0633133a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:43+02:00

Commit Message:
HARVESTER: Match native PC_GOTO_XZ placement

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 383a09f8792..e4d5979ce25 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 11, 2026, verified native monster room-spawn ordering in Ghidra. `spawn_monster_entity_from_record` restores a saved monster corpse frame from the record and advances the entity frame before calling `set_entity_screen_position`. Updated ScummVM room population to apply monster animation state before calculating actor placement, so re-entered monster corpses start in the correct screen position.
+- On April 12, 2026, verified native `PC_GOTO_XZ` handling in Ghidra. `dispatch_room_event_actions` opcode `0x1e` removes the player combat avatar, parses arg2 as Z and arg1 as X, calls `set_entity_screen_position`, and reinserts the avatar; this changes X/Z while preserving the current screen Y and sprite scale. Updated ScummVM `PC_GOTO_XZ` handling to match that behavior for the CHESSKING battle setup.
 
 ## Next Suggested Action
 
-- In-engine, re-enter `BALLROM1` from `BALLROM3_2_BALLROM1` after killing `MAINT_MAN_MNST` and confirm the corpse is drawn at its final location immediately, without a first-frame shift.
+- In-engine, replay the CHESSMASTER refusal path into `ADD_CHESS_KING_MONSTER` and confirm `PC_GOTO_XZ 30,20` leaves Steve at the native screen Y while CHESSKING starts at logical Z 20 and can be targeted/hit with the shotgun.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 5c7ed3ba26d..372d7cb5416 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -799,20 +799,6 @@ static float clampRoomDepthForEvent(const RoomSetupState &state, float z) {
 		(float)MAX(state.roomMinZ, state.roomMaxZ));
 }
 
-static int mapRoomDepthToScreenYForEvent(const RoomSetupState &state, float z, int fallbackY) {
-	if (state.roomMaxZScreenY < 0 || state.roomMinZScreenY < state.roomMaxZScreenY)
-		return fallbackY;
-	if (state.roomMaxZ == state.roomMinZ)
-		return CLIP<int>(fallbackY, state.roomMaxZScreenY, state.roomMinZScreenY);
-
-	const float clampedZ = clampRoomDepthForEvent(state, z);
-	const float offset = ((float)state.roomMaxZ - clampedZ) *
-		(float)(state.roomMinZScreenY - state.roomMaxZScreenY) /
-		(float)(state.roomMaxZ - state.roomMinZ);
-	return CLIP<int>(state.roomMaxZScreenY + (int)(offset >= 0.0f ? offset + 0.5f : offset - 0.5f),
-		state.roomMaxZScreenY, state.roomMinZScreenY);
-}
-
 static int roundRoomCombatFloat(float value) {
 	return value >= 0.0f ? (int)(value + 0.5f) : (int)(value - 0.5f);
 }
@@ -2145,20 +2131,34 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			return Common::kNoError;
 		};
 		auto applyPlayerGotoXZ = [&](int x, int z) {
-			(void)cancelPlayerAttackAnimation();
 			playerState.hasMoveTarget = false;
 			playerState.turnActive = false;
 			playerState.turnTargetFacing = -1;
 			playerState.centerX = x;
-			playerState.z = clampRoomDepthForEvent(scene.state, (float)z);
-			playerState.bottomY = mapRoomDepthToScreenYForEvent(scene.state, playerState.z, playerState.bottomY);
+			playerState.z = (float)z;
 			if (playerState.entity) {
-				const int facing = playerState.facing >= 0 ? playerState.facing : scene.state.playerFacing;
-				if (facing >= 0)
-					(void)Player::setIdleAnimation(playerState, facing);
-				(void)applyRoomActorPlacement(scene.state, *playerState.entity,
-					playerState.centerX, playerState.bottomY, playerState.z);
+				int width = 0;
+				int height = 0;
+				int xOffset = 0;
+				int yOffset = 0;
+				if (playerState.entity->getCurrentFrameMetrics(width, height, xOffset, yOffset)) {
+					const int preservedScreenY = playerState.entity->getY();
+					// Native PC_GOTO_XZ calls set_entity_screen_position directly:
+					// update X and Z, but preserve screen Y and the current scale.
+					playerState.entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
+					playerState.entity->setPosition(
+						x - xOffset - width / 2, preservedScreenY, playerState.z);
+					playerState.bottomY = preservedScreenY + height + yOffset;
+					if (entityManager)
+						entityManager->reinsertSceneEntity(playerState.entity);
+				} else {
+					(void)applyRoomActorPlacement(scene.state, *playerState.entity,
+						playerState.centerX, playerState.bottomY, playerState.z);
+				}
 			}
+			debugC(1, kDebugPlayer,
+				"Harvester: PC_GOTO_XZ applied x=%d z=%d pos=(%d,%d,z=%.2f)",
+				x, z, playerState.centerX, playerState.bottomY, (double)playerState.z);
 			resetIdleState();
 			captureCurrentSaveState();
 		};


Commit: 87b5c1e18300927b89f4f2d279f6d34990a1d2a6
    https://github.com/scummvm/scummvm/commit/87b5c1e18300927b89f4f2d279f6d34990a1d2a6
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:43+02:00

Commit Message:
HARVESTER: Match native chess battle sequencing

Changed paths:
    TRACKER.md
    engines/harvester/npc/chessmaster_dialogue.cpp
    engines/harvester/room.cpp
    engines/harvester/script.cpp


diff --git a/TRACKER.md b/TRACKER.md
index e4d5979ce25..9bfb577b6c9 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 12, 2026, verified native `PC_GOTO_XZ` handling in Ghidra. `dispatch_room_event_actions` opcode `0x1e` removes the player combat avatar, parses arg2 as Z and arg1 as X, calls `set_entity_screen_position`, and reinserts the avatar; this changes X/Z while preserving the current screen Y and sprite scale. Updated ScummVM `PC_GOTO_XZ` handling to match that behavior for the CHESSKING battle setup.
+- On April 12, 2026, verified native CHESSMASTER refusal sequencing in Ghidra: `handle_talk_to_chessmaster` dispatches `ADD_CHESS_KING_MONSTER` before playing `graphic\\fst\\kinggrow.fst`, and native `PC_GOTO_XZ` continues to arg4 instead of ending the command chain. Updated ScummVM script/dialogue handling so `SET_MONSTER`, `PC_GOTO_XZ 30,20`, and `CHESS_MOVE_PC2` resolve before `KINGGROW.FST`.
 
 ## Next Suggested Action
 
-- In-engine, replay the CHESSMASTER refusal path into `ADD_CHESS_KING_MONSTER` and confirm `PC_GOTO_XZ 30,20` leaves Steve at the native screen Y while CHESSKING starts at logical Z 20 and can be targeted/hit with the shotgun.
+- In-engine, replay the CHESSMASTER refusal path and confirm the trace now applies `PC_GOTO_XZ 30,20` and `CHESS_MOVE_PC2` before `KINGGROW.FST`, then verify CHESSKING can be targeted/hit with the shotgun at logical Z 20.
diff --git a/engines/harvester/npc/chessmaster_dialogue.cpp b/engines/harvester/npc/chessmaster_dialogue.cpp
index 8b68f1a63e8..452b62c255e 100644
--- a/engines/harvester/npc/chessmaster_dialogue.cpp
+++ b/engines/harvester/npc/chessmaster_dialogue.cpp
@@ -425,8 +425,10 @@ Common::Error ChessmasterDialogueHandler::handleDialogue(DialogueRuntime &runtim
 
 		InteractionResult interaction;
 		if (runtime.startupScript().executeActionTag(kChessKingMonsterActionTag, interaction)) {
+			interaction.cutscenePath = "GRAPHIC/FST/KINGGROW.FST";
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
+			return Common::kNoError;
 		}
 
 		return runtime.playDialogueFst("GRAPHIC/FST/KINGGROW.FST");
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 372d7cb5416..daa1c04c557 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -2136,6 +2136,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			playerState.turnTargetFacing = -1;
 			playerState.centerX = x;
 			playerState.z = (float)z;
+			Common::Rect screenRect;
+			bool hasScreenRect = false;
+			float depthScale = 0.0f;
 			if (playerState.entity) {
 				int width = 0;
 				int height = 0;
@@ -2155,10 +2158,16 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					(void)applyRoomActorPlacement(scene.state, *playerState.entity,
 						playerState.centerX, playerState.bottomY, playerState.z);
 				}
+				screenRect = playerState.entity->getScreenRect();
+				hasScreenRect = true;
+				depthScale = playerState.entity->getDepthScale();
 			}
 			debugC(1, kDebugPlayer,
-				"Harvester: PC_GOTO_XZ applied x=%d z=%d pos=(%d,%d,z=%.2f)",
-				x, z, playerState.centerX, playerState.bottomY, (double)playerState.z);
+				"Harvester: PC_GOTO_XZ applied x=%d z=%d pos=(%d,%d,z=%.2f) screen_rect=(%d,%d)-(%d,%d) depth_scale=%.3f",
+				x, z, playerState.centerX, playerState.bottomY, (double)playerState.z,
+				hasScreenRect ? screenRect.left : 0, hasScreenRect ? screenRect.top : 0,
+				hasScreenRect ? screenRect.right : 0, hasScreenRect ? screenRect.bottom : 0,
+				(double)depthScale);
 			resetIdleState();
 			captureCurrentSaveState();
 		};
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index d4dd3ff19d4..67c98d5c6d3 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -2810,7 +2810,6 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			(continuationTag && !continuationTag->empty()) ||
 			(modalText && !modalText->value.empty()) ||
 			(lightingCommand && *lightingCommand != kStartupLightingCommandNone) ||
-			(requestPlayerGotoXZ && *requestPlayerGotoXZ) ||
 			(requestCloseupExit && *requestCloseupExit);
 	};
 
@@ -3190,9 +3189,8 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			*requestPlayerGotoXZ = true;
 			*playerGotoX = parseAsciiIntOrZero(command->arg1);
 			*playerGotoZ = parseAsciiIntOrZero(command->arg2);
-			if (continuationTag)
-				*continuationTag = command->arg4;
-			return;
+			currentTag = command->arg4;
+			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("CHANGE_LIGHTING")) {


Commit: eb2d98a7c76afb1b0c0f0b281c8bcc6b9e791a3a
    https://github.com/scummvm/scummvm/commit/eb2d98a7c76afb1b0c0f0b281c8bcc6b9e791a3a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:43+02:00

Commit Message:
HARVESTER: Add pathfinding debug overlay

Changed paths:
    TRACKER.md
    engines/harvester/console.cpp
    engines/harvester/console.h
    engines/harvester/detection.cpp
    engines/harvester/detection.h
    engines/harvester/flow.cpp
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h
    engines/harvester/player.cpp
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 9bfb577b6c9..7df019f4129 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 12, 2026, verified native CHESSMASTER refusal sequencing in Ghidra: `handle_talk_to_chessmaster` dispatches `ADD_CHESS_KING_MONSTER` before playing `graphic\\fst\\kinggrow.fst`, and native `PC_GOTO_XZ` continues to arg4 instead of ending the command chain. Updated ScummVM script/dialogue handling so `SET_MONSTER`, `PC_GOTO_XZ 30,20`, and `CHESS_MOVE_PC2` resolve before `KINGGROW.FST`.
+- On April 12, 2026, added the in-engine `DEBUG_PATHFINDING` toggle. When enabled, it overlays the room movement band, outlines scene entities that can affect player pathfinding, and gates verbose click/target/blocker movement logging behind the `pathfinding` debug channel.
 
 ## Next Suggested Action
 
-- In-engine, replay the CHESSMASTER refusal path and confirm the trace now applies `PC_GOTO_XZ 30,20` and `CHESS_MOVE_PC2` before `KINGGROW.FST`, then verify CHESSKING can be targeted/hit with the shotgun at logical Z 20.
+- Run Harvester with `--debugflags=pathfinding`, toggle `DEBUG_PATHFINDING` in the debugger console, then click through rooms with suspicious placement to compare the blue movement band, green blocker outlines, and emitted click/step/blocker logs.
diff --git a/engines/harvester/console.cpp b/engines/harvester/console.cpp
index 85417325db3..7ac394b0bba 100644
--- a/engines/harvester/console.cpp
+++ b/engines/harvester/console.cpp
@@ -373,6 +373,7 @@ static bool parseDebugCommandArgs(int argc, const char **argv, CommandRecord &co
 Console::Console() : GUI::Debugger() {
 	registerCmd("about", WRAP_METHOD(Console, Cmd_about));
 	registerCmd("DEBUG_COMBAT", WRAP_METHOD(Console, Cmd_debugCombat));
+	registerCmd("DEBUG_PATHFINDING", WRAP_METHOD(Console, Cmd_debugPathfinding));
 	registerCmd("DEBUG_ACTIONS", WRAP_METHOD(Console, Cmd_debugActions));
 	registerCmd("DEBUG_COMMAND", WRAP_METHOD(Console, Cmd_debugCommand));
 	registerCmd("DEBUG_ROOM", WRAP_METHOD(Console, Cmd_debugRoom));
@@ -419,6 +420,22 @@ bool Console::Cmd_debugRoom(int argc, const char **argv) {
 	return true;
 }
 
+bool Console::Cmd_debugPathfinding(int argc, const char **argv) {
+	if (argc != 1) {
+		debugPrintf("Usage: DEBUG_PATHFINDING\n");
+		return true;
+	}
+
+	if (!g_engine) {
+		debugPrintf("Harvester engine is not active\n");
+		return true;
+	}
+
+	const bool enabled = g_engine->togglePathfindingDebugEnabled();
+	debugPrintf("Pathfinding debug overlay %s\n", enabled ? "enabled" : "disabled");
+	return true;
+}
+
 bool Console::Cmd_debugActions(int argc, const char **argv) {
 	if (argc != 1) {
 		debugPrintf("Usage: DEBUG_ACTIONS\n");
diff --git a/engines/harvester/console.h b/engines/harvester/console.h
index 219ce8cbbcc..3bb7d333430 100644
--- a/engines/harvester/console.h
+++ b/engines/harvester/console.h
@@ -32,6 +32,7 @@ private:
 	bool Cmd_about(int argc, const char **argv);
 	bool Cmd_debugCombat(int argc, const char **argv);
 	bool Cmd_debugRoom(int argc, const char **argv);
+	bool Cmd_debugPathfinding(int argc, const char **argv);
 	bool Cmd_debugActions(int argc, const char **argv);
 	bool Cmd_debugCommand(int argc, const char **argv);
 	bool Cmd_gotoRoom(int argc, const char **argv);
diff --git a/engines/harvester/detection.cpp b/engines/harvester/detection.cpp
index 539be6f0fec..b4bd28a326b 100644
--- a/engines/harvester/detection.cpp
+++ b/engines/harvester/detection.cpp
@@ -29,6 +29,7 @@ const DebugChannelDef HarvesterMetaEngineDetection::debugFlagList[] = {
 	{ Harvester::kDebugCursor, "cursor", "Cursor animation, hover, and placement debug" },
 	{ Harvester::kDebugRoom, "room", "Room setup, scene population, and interaction debug" },
 	{ Harvester::kDebugPlayer, "player", "Player animation, movement, and spawn debug" },
+	{ Harvester::kDebugPathfinding, "pathfinding", "Player pathfinding target, blocker, and movement-band debug" },
 	{ Harvester::kDebugCombat, "combat", "Room combat pursuit, attack, and damage debug" },
 	{ Harvester::kDebugResources, "resources", "Resource loading" },
 	{ Harvester::kDebugScene, "scene", "Scene object selection and startup script debug" },
diff --git a/engines/harvester/detection.h b/engines/harvester/detection.h
index 3dd08970e76..151295bdc26 100644
--- a/engines/harvester/detection.h
+++ b/engines/harvester/detection.h
@@ -38,7 +38,8 @@ enum HarvesterDebugChannels {
 	kDebugInventory = 1 << 5,
 	kDebugCursor    = 1 << 6,
 	kDebugRoom      = 1 << 7,
-	kDebugPlayer    = 1 << 8
+	kDebugPlayer      = 1 << 8,
+	kDebugPathfinding = 1 << 9
 };
 
 extern const PlainGameDescriptor harvesterGames[];
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 2fe41c385fc..9dc46c9f11a 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -279,6 +279,87 @@ static void drawCombatDebugOverlay(HarvesterEngine &engine, Graphics::Screen &sc
 	}
 }
 
+static Common::Rect getPathfindingMovementBounds(const Graphics::Screen &screen,
+		const RoomSetupState &state) {
+	if (!Player::supportsMovementBand(state))
+		return Common::Rect();
+
+	return Common::Rect(0, state.roomMaxZScreenY, screen.w, state.roomMinZScreenY + 1);
+}
+
+static void drawClippedDebugFrame(Graphics::Screen &screen, Common::Rect rect, byte color) {
+	rect.clip(screen.getBounds());
+	if (rect.isEmpty())
+		return;
+
+	screen.frameRect(rect, color);
+}
+
+static bool canEntityAffectPlayerPathfinding(const Entity &entity) {
+	if (!entity.isVisible())
+		return false;
+
+	switch (entity.getClassId()) {
+	case kRuntimeEntityClassCursor:
+	case kRuntimeEntityClassBackground:
+	case kRuntimeEntityClassPlayer:
+	case kRuntimeEntityClassRectHotspot:
+	case kRuntimeEntityClassRectHotspot19:
+	case kRuntimeEntityClassTimer:
+		return false;
+	default:
+		break;
+	}
+
+	return entity.getClassId() == kRuntimeEntityClassDisabledHotspot || entity.hasFrames();
+}
+
+static void drawPathfindingBlockerOutline(Graphics::Screen &screen, EntityManager &entityManager,
+		const Common::String &entityName, const Common::Rect &movementBounds, byte color) {
+	const Entity *entity = entityManager.findSceneEntityByName(entityName);
+	if (!entity || !canEntityAffectPlayerPathfinding(*entity))
+		return;
+
+	Common::Rect rect = entity->getScreenRect();
+	if (rect.isEmpty())
+		return;
+
+	Common::Rect overlap = rect;
+	overlap.clip(movementBounds);
+	if (overlap.isEmpty())
+		return;
+
+	drawClippedDebugFrame(screen, rect, color);
+}
+
+static void drawPathfindingDebugOverlay(HarvesterEngine &engine, Graphics::Screen &screen,
+		const RoomSceneResources &scene) {
+	if (!engine.isPathfindingDebugEnabled())
+		return;
+
+	byte displayPalette[256 * 3];
+	screen.getPalette(displayPalette);
+	const byte blue = findNearestPaletteColor(displayPalette, 0x00, 0x40, 0xff);
+	const byte green = findNearestPaletteColor(displayPalette, 0x00, 0xff, 0x00);
+	const Common::Rect movementBounds = getPathfindingMovementBounds(screen, scene.state);
+	if (movementBounds.isEmpty())
+		return;
+
+	drawClippedDebugFrame(screen, movementBounds, blue);
+
+	EntityManager *entityManager = engine.getRuntimeEntities();
+	if (!entityManager)
+		return;
+
+	for (const ObjectRecord &object : scene.sceneObjects)
+		drawPathfindingBlockerOutline(screen, *entityManager,
+			object.objectName, movementBounds, green);
+
+	for (const AnimRecord &anim : scene.sceneAnimations)
+		drawPathfindingBlockerOutline(screen, *entityManager,
+			anim.animName, movementBounds, green);
+}
+
 static int clampTownMapPanelIndex(int panelIndex) {
 	return CLIP<int>(panelIndex, 0, ARRAYSIZE(kTownMapBitmapPaths) - 1);
 }
@@ -707,6 +788,7 @@ void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const Room
 		engine.getRuntimeEntities()->drawSceneEntities(screen);
 	drawRoomDebugOverlay(engine, screen, scene);
 	drawCombatDebugOverlay(engine, screen, scene);
+	drawPathfindingDebugOverlay(engine, screen, scene);
 }
 
 const ObjectRecord *findSceneObjectByName(const Common::Array<ObjectRecord> &objects,
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index 40cf9dc06a3..aa296b2fa7c 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -261,6 +261,11 @@ bool HarvesterEngine::toggleRoomDebugEnabled() {
 	return _roomDebugEnabled;
 }
 
+bool HarvesterEngine::togglePathfindingDebugEnabled() {
+	_pathfindingDebugEnabled = !_pathfindingDebugEnabled;
+	return _pathfindingDebugEnabled;
+}
+
 bool HarvesterEngine::requestDebugCommand(const CommandRecord &command) {
 	if (!_activeFlow || !_script || !_currentSaveRoomState.valid)
 		return false;
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index a6b51da29cb..56bc86daa2c 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -116,6 +116,8 @@ public:
 	bool toggleCombatDebugEnabled();
 	bool isRoomDebugEnabled() const { return _roomDebugEnabled; }
 	bool toggleRoomDebugEnabled();
+	bool isPathfindingDebugEnabled() const { return _pathfindingDebugEnabled; }
+	bool togglePathfindingDebugEnabled();
 	bool requestDebugCommand(const CommandRecord &command);
 	bool requestDebugRoomChange(const Common::String &roomName);
 	void captureCurrentSaveRoomState(const Common::String &entranceName,
@@ -156,6 +158,7 @@ private:
 	Flow *_activeFlow = nullptr;
 	bool _combatDebugEnabled = false;
 	bool _roomDebugEnabled = false;
+	bool _pathfindingDebugEnabled = false;
 	Common::String _emptyMusicPath;
 };
 
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index a864348f4f6..86af1a01bd7 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -888,6 +888,10 @@ static bool prependMoveWaypoint(RoomPlayerState &playerState, const RoomMoveWayp
 	return true;
 }
 
+static bool isPathfindingDebugEnabled() {
+	return g_engine && g_engine->isPathfindingDebugEnabled();
+}
+
 static bool queueDepthAvoidanceWaypoint(const RoomSetupState &state, RoomPlayerState &playerState,
 		const RoomMoveWaypoint &activeTarget, const Entity &blocker) {
 	const float blockerZ = blocker.getZ();
@@ -999,6 +1003,15 @@ static void setMoveTargetInternal(const RoomSetupState &state, RoomPlayerState &
 		"Harvester: player move target room='%s' current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f)",
 		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
 		playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ);
+	if (isPathfindingDebugEnabled()) {
+		debugC(1, kDebugPathfinding,
+			"Harvester: pathfinding target set room='%s' movement_band=(y=%d..%d,z=%d..%d) current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f) waypoints=%u",
+			state.roomName.c_str(), state.roomMaxZScreenY, state.roomMinZScreenY,
+			state.roomMinZ, state.roomMaxZ,
+			playerState.centerX, playerState.bottomY, (double)playerState.z,
+			playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ,
+			playerState.moveWaypoints.size());
+	}
 }
 
 static void notePlayerIdleReset(RoomIdleAnimationState &idleState) {
@@ -1657,6 +1670,19 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 		candidateZ = stepTowardsFloat(playerState.z, activeTarget.targetZ, depthStep);
 	}
 
+	if (engine.isPathfindingDebugEnabled()) {
+		debugC(1, kDebugPathfinding,
+			"Harvester: pathfinding step room='%s' current=(%d,%d,z=%.2f) active_target=(x=%d,y=%d,z=%.2f,hasX=%d,hasZ=%d,ignore='%s') progress=(x=%d,z=%d) direction=(x=%d,z=%d) candidate=(%d,%d,z=%.2f) waypoints=%u",
+			state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
+			activeTarget.targetX, activeTarget.targetBottomY, (double)activeTarget.targetZ,
+			activeTarget.hasTargetX, activeTarget.hasTargetZ,
+			activeTarget.ignoredBlockerName.c_str(),
+			playerState.moveTargetXReached, playerState.moveTargetZReached,
+			horizontalDirection, depthDirection,
+			candidateCenterX, candidateBottomY, (double)candidateZ,
+			playerState.moveWaypoints.size());
+	}
+
 	const Common::String *ignoredBlockerName = activeTarget.ignoredBlockerName.empty()
 		? nullptr
 		: &activeTarget.ignoredBlockerName;
@@ -1665,6 +1691,18 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 		playerState, candidateCenterX, candidateBottomY, candidateZ,
 		ignoredBlockerName, &blocker);
 	if (!moved) {
+		if (engine.isPathfindingDebugEnabled()) {
+			const Common::Rect blockerRect = blocker ? blocker->getScreenRect() : Common::Rect();
+			debugC(1, kDebugPathfinding,
+				"Harvester: pathfinding blocked room='%s' candidate=(%d,%d,z=%.2f) blocker='%s' class=0x%x rect=(%d,%d)-(%d,%d) z=%.2f z_extent=%.2f ignored='%s'",
+				state.roomName.c_str(), candidateCenterX, candidateBottomY, (double)candidateZ,
+				blocker ? blocker->getName().c_str() : "",
+				blocker ? blocker->getClassId() : -1,
+				blockerRect.left, blockerRect.top, blockerRect.right, blockerRect.bottom,
+				blocker ? (double)blocker->getZ() : 0.0,
+				blocker ? (double)blocker->getZExtent() : 0.0,
+				ignoredBlockerName ? ignoredBlockerName->c_str() : "");
+		}
 		if (blocker && queueBlockedMoveAvoidanceWaypoint(state, playerState, activeTarget,
 				horizontalDirection, depthDirection, *blocker)) {
 			return true;
@@ -1684,6 +1722,13 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 	const int actualFacing = resolveFacingFromRoomMovement(
 		previousCenterX, previousBottomY, playerState.centerX, playerState.bottomY);
 	(void)setPlayerWalkAnimation(playerState, actualFacing);
+	if (engine.isPathfindingDebugEnabled()) {
+		debugC(1, kDebugPathfinding,
+			"Harvester: pathfinding applied room='%s' previous=(%d,%d) current=(%d,%d,z=%.2f) facing=%d",
+			state.roomName.c_str(), previousCenterX, previousBottomY,
+			playerState.centerX, playerState.bottomY, (double)playerState.z,
+			actualFacing);
+	}
 	activeTarget = getActiveMoveTarget(playerState);
 	updatePlayerMoveTargetProgress(state, playerState, activeTarget);
 	while (hasPlayerReachedMoveTarget(playerState)) {
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index daa1c04c557..40337abd38d 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -3866,12 +3866,33 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		if (!playerState.entity)
 			return;
 
-		if (doesPlayerOverlapRegion(*playerState.entity, region))
+		const bool playerOverlapsRegion = doesPlayerOverlapRegion(*playerState.entity, region);
+		if (_engine.isPathfindingDebugEnabled()) {
+			const Common::Rect playerRect = playerState.entity->getScreenRect();
+			const Common::Rect regionBounds(region.left, region.top, region.right + 1, region.bottom + 1);
+			debugC(1, kDebugPathfinding,
+				"Harvester: pathfinding region click room='%s' mouse=(%d,%d) region='%s' bounds=(%d,%d)-(%d,%d) z=[%d,%d] start=%d cursor=%d desired_facing=%d player=(%d,%d,z=%.2f) player_rect=(%d,%d)-(%d,%d) overlaps=%d action='%s'",
+				scene.state.roomName.c_str(), _mousePos.x, _mousePos.y,
+				region.regionName.c_str(),
+				regionBounds.left, regionBounds.top, regionBounds.right, regionBounds.bottom,
+				region.minZ, region.maxZ, region.startEnabled, region.cursorEnabled,
+				region.desiredFacing,
+				playerState.centerX, playerState.bottomY, (double)playerState.z,
+				playerRect.left, playerRect.top, playerRect.right, playerRect.bottom,
+				playerOverlapsRegion, region.actionTag.c_str());
+		}
+		if (playerOverlapsRegion)
 			return;
 
 		Player::setMoveTarget(scene.state, playerState,
 			Player::resolveRegionTargetX(region, playerState),
 			Player::resolveRegionTargetZ(region));
+		if (_engine.isPathfindingDebugEnabled()) {
+			debugC(1, kDebugPathfinding,
+				"Harvester: pathfinding region target room='%s' region='%s' target=(%d,%d,z=%.2f)",
+				scene.state.roomName.c_str(), region.regionName.c_str(),
+				playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ);
+		}
 	};
 	auto runRegionInteraction = [&](const RegionRecord &region) -> Common::Error {
 		InteractionResult interaction;
@@ -4484,7 +4505,23 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				if (!clickedObject) {
 					pendingRegionName.clear();
 					if (hoverState.cursorSequence == 0 && playerState.entity) {
+						if (_engine.isPathfindingDebugEnabled()) {
+							const Common::Rect playerRect = playerState.entity->getScreenRect();
+							debugC(1, kDebugPathfinding,
+								"Harvester: pathfinding click accepted room='%s' mouse=(%d,%d) movement_band=(y=%d..%d,z=%d..%d) player=(%d,%d,z=%.2f) player_rect=(%d,%d)-(%d,%d)",
+								scene.state.roomName.c_str(), _mousePos.x, _mousePos.y,
+								scene.state.roomMaxZScreenY, scene.state.roomMinZScreenY,
+								scene.state.roomMinZ, scene.state.roomMaxZ,
+								playerState.centerX, playerState.bottomY, (double)playerState.z,
+								playerRect.left, playerRect.top, playerRect.right, playerRect.bottom);
+						}
 						Player::setMoveTargetFromScreenPoint(scene.state, playerState, _mousePos.x, _mousePos.y);
+						if (_engine.isPathfindingDebugEnabled()) {
+							debugC(1, kDebugPathfinding,
+								"Harvester: pathfinding click target room='%s' mouse=(%d,%d) target=(%d,%d,z=%.2f)",
+								scene.state.roomName.c_str(), _mousePos.x, _mousePos.y,
+								playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ);
+						}
 						needsRedraw = true;
 					}
 					break;


Commit: 48d053d8d7129ef56ca7cf2f98b46bf307355f36
    https://github.com/scummvm/scummvm/commit/48d053d8d7129ef56ca7cf2f98b46bf307355f36
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:44+02:00

Commit Message:
HARVESTER: Refine player blocker avoidance

Changed paths:
    TRACKER.md
    engines/harvester/player.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 7df019f4129..f1ab6c021c2 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 12, 2026, added the in-engine `DEBUG_PATHFINDING` toggle. When enabled, it overlays the room movement band, outlines scene entities that can affect player pathfinding, and gates verbose click/target/blocker movement logging behind the `pathfinding` debug channel.
+- On April 12, 2026, checked native `update_actor_runtime_state` waypoint handling in Ghidra and refined ScummVM player blocker avoidance so detour waypoints must clear the blocker span and use tighter completion slack than ordinary click targets.
 
 ## Next Suggested Action
 
-- Run Harvester with `--debugflags=pathfinding`, toggle `DEBUG_PATHFINDING` in the debugger console, then click through rooms with suspicious placement to compare the blue movement band, green blocker outlines, and emitted click/step/blocker logs.
+- Re-test the `CHESSROOM` click toward `(457,410)` with `--debugflags=pathfinding` and `DEBUG_PATHFINDING` enabled, confirming the player reaches the front-side z detour instead of alternating between blocker-depth waypoints.
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 86af1a01bd7..78411074ee9 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -37,7 +37,9 @@ namespace {
 static const char *const kPlayerActorResourcePath = "1:/GRAPHIC/MONSTERS/PC/PC0.ABM";
 static const float kRoomPlayerHorizontalMoveBase = 8.0f;
 static const float kRoomPlayerHorizontalTargetSlackBase = 50.0f;
+static const float kRoomPlayerAvoidanceHorizontalTargetSlackBase = 8.0f;
 static const float kRoomPlayerDepthTargetSlack = 8.0f;
+static const float kRoomPlayerAvoidanceDepthTargetSlack = 0.5f;
 static const int kRoomPlayerMaxMoveWaypoints = 8;
 static const int kRoomPlayerBlockerHorizontalPad = 0x28;
 static const float kRoomPlayerBlockerDepthPad = 1.0f;
@@ -630,8 +632,10 @@ static int resolveMoveTargetHorizontalDirection(const RoomSetupState &state,
 	if (!target.hasTargetX)
 		return 0;
 
-	const float slack = Player::computeDepthScale(state, playerState.z) *
-		kRoomPlayerHorizontalTargetSlackBase;
+	const float slackBase = target.ignoredBlockerName.empty()
+		? kRoomPlayerHorizontalTargetSlackBase
+		: kRoomPlayerAvoidanceHorizontalTargetSlackBase;
+	const float slack = Player::computeDepthScale(state, playerState.z) * slackBase;
 	const float frameLeftX = (float)computePlayerFrameLeftX(playerState);
 	if (frameLeftX < (float)target.targetX - slack)
 		return 1;
@@ -646,9 +650,12 @@ static int resolveMoveTargetDepthDirection(const RoomPlayerState &playerState,
 	if (!target.hasTargetZ)
 		return 0;
 
-	if (target.targetZ < playerState.z - kRoomPlayerDepthTargetSlack)
+	const float slack = target.ignoredBlockerName.empty()
+		? kRoomPlayerDepthTargetSlack
+		: kRoomPlayerAvoidanceDepthTargetSlack;
+	if (target.targetZ < playerState.z - slack)
 		return 1;
-	if (playerState.z + kRoomPlayerDepthTargetSlack < target.targetZ)
+	if (playerState.z + slack < target.targetZ)
 		return -1;
 
 	return 0;
@@ -860,6 +867,32 @@ static bool getPlayerFrameWidth(const RoomPlayerState &playerState, int &width)
 	return playerState.entity->getCurrentFrameMetrics(width, height, xOffset, yOffset);
 }
 
+static float getPlayerZExtent(const RoomPlayerState &playerState) {
+	return playerState.entity
+		? MAX<float>(0.0f, playerState.entity->getZExtent())
+		: 0.0f;
+}
+
+static bool doesPlayerDepthClearBlockerAt(const RoomPlayerState &playerState,
+		float targetZ, const Entity &blocker) {
+	const float playerZMax = targetZ + getPlayerZExtent(playerState);
+	const float blockerZ = blocker.getZ();
+	const float blockerZMax = blockerZ + blocker.getZExtent();
+
+	return playerZMax < blockerZ || blockerZMax < targetZ;
+}
+
+static bool doesPlayerFrameClearBlockerHorizontallyAt(const RoomPlayerState &playerState,
+		int targetFrameLeftX, const Entity &blocker) {
+	int playerWidth = 0;
+	if (!getPlayerFrameWidth(playerState, playerWidth))
+		return false;
+
+	const Common::Rect blockerRect = blocker.getScreenRect();
+	return targetFrameLeftX + playerWidth <= blockerRect.left ||
+		blockerRect.right <= targetFrameLeftX;
+}
+
 static bool isSameMoveWaypoint(const RoomMoveWaypoint &lhs, const RoomMoveWaypoint &rhs) {
 	if (lhs.hasTargetX != rhs.hasTargetX || lhs.hasTargetZ != rhs.hasTargetZ)
 		return false;
@@ -900,9 +933,7 @@ static bool queueDepthAvoidanceWaypoint(const RoomSetupState &state, RoomPlayerS
 	const float referenceTargetZ = activeTarget.hasTargetZ
 		? activeTarget.targetZ
 		: playerState.targetZ;
-	const float playerZExtent = playerState.entity
-		? MAX<float>(0.0f, playerState.entity->getZExtent())
-		: 0.0f;
+	const float playerZExtent = getPlayerZExtent(playerState);
 
 	float candidates[2];
 	if (referenceTargetZ < blockerMidZ) {
@@ -915,7 +946,9 @@ static bool queueDepthAvoidanceWaypoint(const RoomSetupState &state, RoomPlayerS
 
 	for (uint i = 0; i < ARRAYSIZE(candidates); ++i) {
 		const float targetZ = clampRoomDepth(state, candidates[i]);
-		if (fabsf(targetZ - playerState.z) <= kRoomPlayerDepthTargetSlack)
+		if (fabsf(targetZ - playerState.z) <= kRoomPlayerAvoidanceDepthTargetSlack)
+			continue;
+		if (!doesPlayerDepthClearBlockerAt(playerState, targetZ, blocker))
 			continue;
 
 		RoomMoveWaypoint waypoint;
@@ -956,6 +989,8 @@ static bool queueHorizontalAvoidanceWaypoint(const RoomSetupState &state, RoomPl
 		waypoint.targetX = CLIP<int>(candidates[i], 0, 639);
 		waypoint.hasTargetX = true;
 		waypoint.ignoredBlockerName = blocker.getName();
+		if (!doesPlayerFrameClearBlockerHorizontallyAt(playerState, waypoint.targetX, blocker))
+			continue;
 		if (resolveMoveTargetHorizontalDirection(state, playerState, waypoint) == 0)
 			continue;
 		if (prependMoveWaypoint(playerState, waypoint))
@@ -985,6 +1020,10 @@ static bool queueBlockedMoveAvoidanceWaypoint(const RoomSetupState &state,
 			state.roomName.c_str(), blocker.getName().c_str(), axis,
 			waypoint.targetX, (double)waypoint.targetZ,
 			waypoint.hasTargetX, waypoint.hasTargetZ);
+	} else if (isPathfindingDebugEnabled()) {
+		debugC(1, kDebugPathfinding,
+			"Harvester: pathfinding detour rejected room='%s' blocker='%s' axis='%s'",
+			state.roomName.c_str(), blocker.getName().c_str(), axis ? axis : "");
 	}
 
 	return queued;


Commit: 234b21c133bb849b9f4c04238ae678dc48f63411
    https://github.com/scummvm/scummvm/commit/234b21c133bb849b9f4c04238ae678dc48f63411
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:44+02:00

Commit Message:
HARVESTER: Validate pathfinding detours

Changed paths:
    TRACKER.md
    engines/harvester/player.cpp


diff --git a/TRACKER.md b/TRACKER.md
index f1ab6c021c2..23290698194 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 12, 2026, checked native `update_actor_runtime_state` waypoint handling in Ghidra and refined ScummVM player blocker avoidance so detour waypoints must clear the blocker span and use tighter completion slack than ordinary click targets.
+- On April 12, 2026, used the `CHESSROOM` pathfinding logs to refine blocker avoidance again: detour waypoints now sample their route against other room blockers before being accepted, and horizontal movement can fall back to a side waypoint when a depth detour would immediately collide with another blocker.
 
 ## Next Suggested Action
 
-- Re-test the `CHESSROOM` click toward `(457,410)` with `--debugflags=pathfinding` and `DEBUG_PATHFINDING` enabled, confirming the player reaches the front-side z detour instead of alternating between blocker-depth waypoints.
+- Re-test the `CHESSROOM` click toward `(464,422)` with `--debugflags=pathfinding` and `DEBUG_PATHFINDING` enabled, checking for `detour candidate blocked` lines and confirming the player routes around `__ANON_OBJECT_35` without being sent back into `__ANON_OBJECT_33`.
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 78411074ee9..9eefc0cb575 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -893,6 +893,74 @@ static bool doesPlayerFrameClearBlockerHorizontallyAt(const RoomPlayerState &pla
 		blockerRect.right <= targetFrameLeftX;
 }
 
+static bool isPathToMoveWaypointClear(HarvesterEngine &engine, const RoomSetupState &state,
+		const Common::Array<ObjectRecord> &sceneObjects,
+		const Common::Array<AnimRecord> &sceneAnimations,
+		RoomPlayerState &playerState, const RoomMoveWaypoint &waypoint,
+		const Common::String &ignoredBlockerName, const Entity **blockerOut = nullptr) {
+	if (blockerOut)
+		*blockerOut = nullptr;
+	if (!playerState.entity)
+		return false;
+
+	const Common::String *ignoredBlockerNamePtr = ignoredBlockerName.empty()
+		? nullptr
+		: &ignoredBlockerName;
+	int sampleCenterX = playerState.centerX;
+	int sampleBottomY = playerState.bottomY;
+	float sampleZ = playerState.z;
+
+	for (int i = 0; i < 1024; ++i) {
+		bool changed = false;
+		if (waypoint.hasTargetX) {
+			const float slack = Player::computeDepthScale(state, sampleZ) *
+				kRoomPlayerAvoidanceHorizontalTargetSlackBase;
+			const float frameLeftX = (float)(sampleCenterX -
+				(playerState.centerX - computePlayerFrameLeftX(playerState)));
+			int horizontalDirection = 0;
+			if (frameLeftX < (float)waypoint.targetX - slack) {
+				horizontalDirection = 1;
+			} else if ((float)waypoint.targetX + slack < frameLeftX) {
+				horizontalDirection = -1;
+			}
+			if (horizontalDirection != 0) {
+				sampleCenterX = clampPlayerCenterXToNativeBounds(playerState,
+					sampleCenterX + horizontalDirection * computeRoomPlayerHorizontalStep(state, sampleZ));
+				changed = true;
+			}
+		}
+		if (!changed && waypoint.hasTargetZ) {
+			const float slack = kRoomPlayerAvoidanceDepthTargetSlack;
+			int depthDirection = 0;
+			if (waypoint.targetZ < sampleZ - slack) {
+				depthDirection = 1;
+			} else if (sampleZ + slack < waypoint.targetZ) {
+				depthDirection = -1;
+			}
+			if (depthDirection != 0) {
+				sampleBottomY = clampRoomMovementY(state,
+					sampleBottomY + depthDirection * computeRoomPlayerVerticalScreenStep());
+				sampleZ = stepTowardsFloat(sampleZ, waypoint.targetZ,
+					computeRoomPlayerDepthStep(state));
+				changed = true;
+			}
+		}
+		if (!changed)
+			return true;
+
+		const Entity *blocker = nullptr;
+		if (isPlayerMovementBlocked(engine, state, sceneObjects, sceneAnimations,
+				playerState, sampleCenterX, sampleBottomY, sampleZ,
+				ignoredBlockerNamePtr, &blocker)) {
+			if (blockerOut)
+				*blockerOut = blocker;
+			return false;
+		}
+	}
+
+	return false;
+}
+
 static bool isSameMoveWaypoint(const RoomMoveWaypoint &lhs, const RoomMoveWaypoint &rhs) {
 	if (lhs.hasTargetX != rhs.hasTargetX || lhs.hasTargetZ != rhs.hasTargetZ)
 		return false;
@@ -926,6 +994,8 @@ static bool isPathfindingDebugEnabled() {
 }
 
 static bool queueDepthAvoidanceWaypoint(const RoomSetupState &state, RoomPlayerState &playerState,
+		HarvesterEngine &engine, const Common::Array<ObjectRecord> &sceneObjects,
+		const Common::Array<AnimRecord> &sceneAnimations,
 		const RoomMoveWaypoint &activeTarget, const Entity &blocker) {
 	const float blockerZ = blocker.getZ();
 	const float blockerZMax = blockerZ + blocker.getZExtent();
@@ -956,6 +1026,17 @@ static bool queueDepthAvoidanceWaypoint(const RoomSetupState &state, RoomPlayerS
 		waypoint.targetBottomY = mapRoomDepthToScreenY(state, targetZ);
 		waypoint.hasTargetZ = true;
 		waypoint.ignoredBlockerName = blocker.getName();
+		const Entity *pathBlocker = nullptr;
+		if (!isPathToMoveWaypointClear(engine, state, sceneObjects, sceneAnimations,
+				playerState, waypoint, waypoint.ignoredBlockerName, &pathBlocker)) {
+			if (isPathfindingDebugEnabled()) {
+				debugC(1, kDebugPathfinding,
+					"Harvester: pathfinding detour candidate blocked room='%s' blocker='%s' axis='depth' waypoint=(z=%.2f) path_blocker='%s'",
+					state.roomName.c_str(), blocker.getName().c_str(), (double)waypoint.targetZ,
+					pathBlocker ? pathBlocker->getName().c_str() : "");
+			}
+			continue;
+		}
 		if (prependMoveWaypoint(playerState, waypoint))
 			return true;
 	}
@@ -964,6 +1045,8 @@ static bool queueDepthAvoidanceWaypoint(const RoomSetupState &state, RoomPlayerS
 }
 
 static bool queueHorizontalAvoidanceWaypoint(const RoomSetupState &state, RoomPlayerState &playerState,
+		HarvesterEngine &engine, const Common::Array<ObjectRecord> &sceneObjects,
+		const Common::Array<AnimRecord> &sceneAnimations,
 		const RoomMoveWaypoint &activeTarget, const Entity &blocker) {
 	int playerWidth = 0;
 	if (!getPlayerFrameWidth(playerState, playerWidth))
@@ -993,6 +1076,17 @@ static bool queueHorizontalAvoidanceWaypoint(const RoomSetupState &state, RoomPl
 			continue;
 		if (resolveMoveTargetHorizontalDirection(state, playerState, waypoint) == 0)
 			continue;
+		const Entity *pathBlocker = nullptr;
+		if (!isPathToMoveWaypointClear(engine, state, sceneObjects, sceneAnimations,
+				playerState, waypoint, waypoint.ignoredBlockerName, &pathBlocker)) {
+			if (isPathfindingDebugEnabled()) {
+				debugC(1, kDebugPathfinding,
+					"Harvester: pathfinding detour candidate blocked room='%s' blocker='%s' axis='horizontal' waypoint=(x=%d) path_blocker='%s'",
+					state.roomName.c_str(), blocker.getName().c_str(), waypoint.targetX,
+					pathBlocker ? pathBlocker->getName().c_str() : "");
+			}
+			continue;
+		}
 		if (prependMoveWaypoint(playerState, waypoint))
 			return true;
 	}
@@ -1001,16 +1095,25 @@ static bool queueHorizontalAvoidanceWaypoint(const RoomSetupState &state, RoomPl
 }
 
 static bool queueBlockedMoveAvoidanceWaypoint(const RoomSetupState &state,
+		HarvesterEngine &engine, const Common::Array<ObjectRecord> &sceneObjects,
+		const Common::Array<AnimRecord> &sceneAnimations,
 		RoomPlayerState &playerState, const RoomMoveWaypoint &activeTarget,
 		int horizontalDirection, int depthDirection, const Entity &blocker) {
 	bool queued = false;
 	const char *axis = nullptr;
 	if (horizontalDirection != 0) {
-		queued = queueDepthAvoidanceWaypoint(state, playerState, activeTarget, blocker);
 		axis = "depth";
+		queued = queueDepthAvoidanceWaypoint(state, playerState, engine, sceneObjects,
+			sceneAnimations, activeTarget, blocker);
+		if (!queued) {
+			axis = "horizontal";
+			queued = queueHorizontalAvoidanceWaypoint(state, playerState, engine, sceneObjects,
+				sceneAnimations, activeTarget, blocker);
+		}
 	} else if (depthDirection != 0) {
-		queued = queueHorizontalAvoidanceWaypoint(state, playerState, activeTarget, blocker);
 		axis = "horizontal";
+		queued = queueHorizontalAvoidanceWaypoint(state, playerState, engine, sceneObjects,
+			sceneAnimations, activeTarget, blocker);
 	}
 
 	if (queued) {
@@ -1742,8 +1845,9 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 				blocker ? (double)blocker->getZExtent() : 0.0,
 				ignoredBlockerName ? ignoredBlockerName->c_str() : "");
 		}
-		if (blocker && queueBlockedMoveAvoidanceWaypoint(state, playerState, activeTarget,
-				horizontalDirection, depthDirection, *blocker)) {
+		if (blocker && queueBlockedMoveAvoidanceWaypoint(state, engine, sceneObjects,
+				sceneAnimations, playerState, activeTarget, horizontalDirection,
+				depthDirection, *blocker)) {
 			return true;
 		}
 


Commit: b1002674621c80bbe9ef3996de60889726ee89db
    https://github.com/scummvm/scummvm/commit/b1002674621c80bbe9ef3996de60889726ee89db
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:44+02:00

Commit Message:
HARVESTER: Match native pathfinding detours

Changed paths:
    TRACKER.md
    engines/harvester/player.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 23290698194..4a15cb3ab97 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 12, 2026, used the `CHESSROOM` pathfinding logs to refine blocker avoidance again: detour waypoints now sample their route against other room blockers before being accepted, and horizontal movement can fall back to a side waypoint when a depth detour would immediately collide with another blocker.
+- On April 12, 2026, rechecked native `update_actor_runtime_state` and `run_harvester_main_loop` in Ghidra, then removed non-native route sampling, alternate detour candidates, clamped avoidance targets, and tighter avoidance-only waypoint slack from the ScummVM player pathfinding code.
 
 ## Next Suggested Action
 
-- Re-test the `CHESSROOM` click toward `(464,422)` with `--debugflags=pathfinding` and `DEBUG_PATHFINDING` enabled, checking for `detour candidate blocked` lines and confirming the player routes around `__ANON_OBJECT_35` without being sent back into `__ANON_OBJECT_33`.
+- Re-test `CHESSROOM` movement with `--debugflags=pathfinding` and `DEBUG_PATHFINDING` enabled, comparing each emitted detour against Ghidra's native rule: horizontal blocks insert one depth waypoint from the blocker z span, and depth blocks insert one horizontal waypoint beside the blocker.
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 9eefc0cb575..2d877707173 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -37,9 +37,7 @@ namespace {
 static const char *const kPlayerActorResourcePath = "1:/GRAPHIC/MONSTERS/PC/PC0.ABM";
 static const float kRoomPlayerHorizontalMoveBase = 8.0f;
 static const float kRoomPlayerHorizontalTargetSlackBase = 50.0f;
-static const float kRoomPlayerAvoidanceHorizontalTargetSlackBase = 8.0f;
 static const float kRoomPlayerDepthTargetSlack = 8.0f;
-static const float kRoomPlayerAvoidanceDepthTargetSlack = 0.5f;
 static const int kRoomPlayerMaxMoveWaypoints = 8;
 static const int kRoomPlayerBlockerHorizontalPad = 0x28;
 static const float kRoomPlayerBlockerDepthPad = 1.0f;
@@ -632,10 +630,8 @@ static int resolveMoveTargetHorizontalDirection(const RoomSetupState &state,
 	if (!target.hasTargetX)
 		return 0;
 
-	const float slackBase = target.ignoredBlockerName.empty()
-		? kRoomPlayerHorizontalTargetSlackBase
-		: kRoomPlayerAvoidanceHorizontalTargetSlackBase;
-	const float slack = Player::computeDepthScale(state, playerState.z) * slackBase;
+	const float slack = Player::computeDepthScale(state, playerState.z) *
+		kRoomPlayerHorizontalTargetSlackBase;
 	const float frameLeftX = (float)computePlayerFrameLeftX(playerState);
 	if (frameLeftX < (float)target.targetX - slack)
 		return 1;
@@ -650,12 +646,9 @@ static int resolveMoveTargetDepthDirection(const RoomPlayerState &playerState,
 	if (!target.hasTargetZ)
 		return 0;
 
-	const float slack = target.ignoredBlockerName.empty()
-		? kRoomPlayerDepthTargetSlack
-		: kRoomPlayerAvoidanceDepthTargetSlack;
-	if (target.targetZ < playerState.z - slack)
+	if (target.targetZ < playerState.z - kRoomPlayerDepthTargetSlack)
 		return 1;
-	if (playerState.z + slack < target.targetZ)
+	if (playerState.z + kRoomPlayerDepthTargetSlack < target.targetZ)
 		return -1;
 
 	return 0;
@@ -873,94 +866,6 @@ static float getPlayerZExtent(const RoomPlayerState &playerState) {
 		: 0.0f;
 }
 
-static bool doesPlayerDepthClearBlockerAt(const RoomPlayerState &playerState,
-		float targetZ, const Entity &blocker) {
-	const float playerZMax = targetZ + getPlayerZExtent(playerState);
-	const float blockerZ = blocker.getZ();
-	const float blockerZMax = blockerZ + blocker.getZExtent();
-
-	return playerZMax < blockerZ || blockerZMax < targetZ;
-}
-
-static bool doesPlayerFrameClearBlockerHorizontallyAt(const RoomPlayerState &playerState,
-		int targetFrameLeftX, const Entity &blocker) {
-	int playerWidth = 0;
-	if (!getPlayerFrameWidth(playerState, playerWidth))
-		return false;
-
-	const Common::Rect blockerRect = blocker.getScreenRect();
-	return targetFrameLeftX + playerWidth <= blockerRect.left ||
-		blockerRect.right <= targetFrameLeftX;
-}
-
-static bool isPathToMoveWaypointClear(HarvesterEngine &engine, const RoomSetupState &state,
-		const Common::Array<ObjectRecord> &sceneObjects,
-		const Common::Array<AnimRecord> &sceneAnimations,
-		RoomPlayerState &playerState, const RoomMoveWaypoint &waypoint,
-		const Common::String &ignoredBlockerName, const Entity **blockerOut = nullptr) {
-	if (blockerOut)
-		*blockerOut = nullptr;
-	if (!playerState.entity)
-		return false;
-
-	const Common::String *ignoredBlockerNamePtr = ignoredBlockerName.empty()
-		? nullptr
-		: &ignoredBlockerName;
-	int sampleCenterX = playerState.centerX;
-	int sampleBottomY = playerState.bottomY;
-	float sampleZ = playerState.z;
-
-	for (int i = 0; i < 1024; ++i) {
-		bool changed = false;
-		if (waypoint.hasTargetX) {
-			const float slack = Player::computeDepthScale(state, sampleZ) *
-				kRoomPlayerAvoidanceHorizontalTargetSlackBase;
-			const float frameLeftX = (float)(sampleCenterX -
-				(playerState.centerX - computePlayerFrameLeftX(playerState)));
-			int horizontalDirection = 0;
-			if (frameLeftX < (float)waypoint.targetX - slack) {
-				horizontalDirection = 1;
-			} else if ((float)waypoint.targetX + slack < frameLeftX) {
-				horizontalDirection = -1;
-			}
-			if (horizontalDirection != 0) {
-				sampleCenterX = clampPlayerCenterXToNativeBounds(playerState,
-					sampleCenterX + horizontalDirection * computeRoomPlayerHorizontalStep(state, sampleZ));
-				changed = true;
-			}
-		}
-		if (!changed && waypoint.hasTargetZ) {
-			const float slack = kRoomPlayerAvoidanceDepthTargetSlack;
-			int depthDirection = 0;
-			if (waypoint.targetZ < sampleZ - slack) {
-				depthDirection = 1;
-			} else if (sampleZ + slack < waypoint.targetZ) {
-				depthDirection = -1;
-			}
-			if (depthDirection != 0) {
-				sampleBottomY = clampRoomMovementY(state,
-					sampleBottomY + depthDirection * computeRoomPlayerVerticalScreenStep());
-				sampleZ = stepTowardsFloat(sampleZ, waypoint.targetZ,
-					computeRoomPlayerDepthStep(state));
-				changed = true;
-			}
-		}
-		if (!changed)
-			return true;
-
-		const Entity *blocker = nullptr;
-		if (isPlayerMovementBlocked(engine, state, sceneObjects, sceneAnimations,
-				playerState, sampleCenterX, sampleBottomY, sampleZ,
-				ignoredBlockerNamePtr, &blocker)) {
-			if (blockerOut)
-				*blockerOut = blocker;
-			return false;
-		}
-	}
-
-	return false;
-}
-
 static bool isSameMoveWaypoint(const RoomMoveWaypoint &lhs, const RoomMoveWaypoint &rhs) {
 	if (lhs.hasTargetX != rhs.hasTargetX || lhs.hasTargetZ != rhs.hasTargetZ)
 		return false;
@@ -994,59 +899,30 @@ static bool isPathfindingDebugEnabled() {
 }
 
 static bool queueDepthAvoidanceWaypoint(const RoomSetupState &state, RoomPlayerState &playerState,
-		HarvesterEngine &engine, const Common::Array<ObjectRecord> &sceneObjects,
-		const Common::Array<AnimRecord> &sceneAnimations,
 		const RoomMoveWaypoint &activeTarget, const Entity &blocker) {
 	const float blockerZ = blocker.getZ();
-	const float blockerZMax = blockerZ + blocker.getZExtent();
 	const float blockerMidZ = blockerZ + blocker.getZExtent() * 0.5f;
 	const float referenceTargetZ = activeTarget.hasTargetZ
 		? activeTarget.targetZ
 		: playerState.targetZ;
 	const float playerZExtent = getPlayerZExtent(playerState);
 
-	float candidates[2];
+	float targetZ = 0.0f;
 	if (referenceTargetZ < blockerMidZ) {
-		candidates[0] = blockerZ - playerZExtent - kRoomPlayerBlockerDepthPad;
-		candidates[1] = blockerZMax + kRoomPlayerBlockerDepthPad;
+		targetZ = blockerZ - playerZExtent - kRoomPlayerBlockerDepthPad;
 	} else {
-		candidates[0] = blockerZMax + kRoomPlayerBlockerDepthPad;
-		candidates[1] = blockerZ - playerZExtent - kRoomPlayerBlockerDepthPad;
+		targetZ = blockerZ + blocker.getZExtent() + kRoomPlayerBlockerDepthPad;
 	}
 
-	for (uint i = 0; i < ARRAYSIZE(candidates); ++i) {
-		const float targetZ = clampRoomDepth(state, candidates[i]);
-		if (fabsf(targetZ - playerState.z) <= kRoomPlayerAvoidanceDepthTargetSlack)
-			continue;
-		if (!doesPlayerDepthClearBlockerAt(playerState, targetZ, blocker))
-			continue;
-
-		RoomMoveWaypoint waypoint;
-		waypoint.targetZ = targetZ;
-		waypoint.targetBottomY = mapRoomDepthToScreenY(state, targetZ);
-		waypoint.hasTargetZ = true;
-		waypoint.ignoredBlockerName = blocker.getName();
-		const Entity *pathBlocker = nullptr;
-		if (!isPathToMoveWaypointClear(engine, state, sceneObjects, sceneAnimations,
-				playerState, waypoint, waypoint.ignoredBlockerName, &pathBlocker)) {
-			if (isPathfindingDebugEnabled()) {
-				debugC(1, kDebugPathfinding,
-					"Harvester: pathfinding detour candidate blocked room='%s' blocker='%s' axis='depth' waypoint=(z=%.2f) path_blocker='%s'",
-					state.roomName.c_str(), blocker.getName().c_str(), (double)waypoint.targetZ,
-					pathBlocker ? pathBlocker->getName().c_str() : "");
-			}
-			continue;
-		}
-		if (prependMoveWaypoint(playerState, waypoint))
-			return true;
-	}
-
-	return false;
+	RoomMoveWaypoint waypoint;
+	waypoint.targetZ = targetZ;
+	waypoint.targetBottomY = mapRoomDepthToScreenY(state, targetZ);
+	waypoint.hasTargetZ = true;
+	waypoint.ignoredBlockerName = blocker.getName();
+	return prependMoveWaypoint(playerState, waypoint);
 }
 
 static bool queueHorizontalAvoidanceWaypoint(const RoomSetupState &state, RoomPlayerState &playerState,
-		HarvesterEngine &engine, const Common::Array<ObjectRecord> &sceneObjects,
-		const Common::Array<AnimRecord> &sceneAnimations,
 		const RoomMoveWaypoint &activeTarget, const Entity &blocker) {
 	int playerWidth = 0;
 	if (!getPlayerFrameWidth(playerState, playerWidth))
@@ -1058,62 +934,31 @@ static bool queueHorizontalAvoidanceWaypoint(const RoomSetupState &state, RoomPl
 		? activeTarget.targetX
 		: playerState.targetX;
 
-	int candidates[2];
+	int targetX = 0;
 	if (referenceTargetX < blockerMidX) {
-		candidates[0] = blockerRect.left - playerWidth - kRoomPlayerBlockerHorizontalPad;
-		candidates[1] = blockerRect.right + kRoomPlayerBlockerHorizontalPad;
+		targetX = blockerRect.left - playerWidth - kRoomPlayerBlockerHorizontalPad;
 	} else {
-		candidates[0] = blockerRect.right + kRoomPlayerBlockerHorizontalPad;
-		candidates[1] = blockerRect.left - playerWidth - kRoomPlayerBlockerHorizontalPad;
-	}
-
-	for (uint i = 0; i < ARRAYSIZE(candidates); ++i) {
-		RoomMoveWaypoint waypoint;
-		waypoint.targetX = CLIP<int>(candidates[i], 0, 639);
-		waypoint.hasTargetX = true;
-		waypoint.ignoredBlockerName = blocker.getName();
-		if (!doesPlayerFrameClearBlockerHorizontallyAt(playerState, waypoint.targetX, blocker))
-			continue;
-		if (resolveMoveTargetHorizontalDirection(state, playerState, waypoint) == 0)
-			continue;
-		const Entity *pathBlocker = nullptr;
-		if (!isPathToMoveWaypointClear(engine, state, sceneObjects, sceneAnimations,
-				playerState, waypoint, waypoint.ignoredBlockerName, &pathBlocker)) {
-			if (isPathfindingDebugEnabled()) {
-				debugC(1, kDebugPathfinding,
-					"Harvester: pathfinding detour candidate blocked room='%s' blocker='%s' axis='horizontal' waypoint=(x=%d) path_blocker='%s'",
-					state.roomName.c_str(), blocker.getName().c_str(), waypoint.targetX,
-					pathBlocker ? pathBlocker->getName().c_str() : "");
-			}
-			continue;
-		}
-		if (prependMoveWaypoint(playerState, waypoint))
-			return true;
+		targetX = blockerRect.right + kRoomPlayerBlockerHorizontalPad;
 	}
 
-	return false;
+	RoomMoveWaypoint waypoint;
+	waypoint.targetX = targetX;
+	waypoint.hasTargetX = true;
+	waypoint.ignoredBlockerName = blocker.getName();
+	return prependMoveWaypoint(playerState, waypoint);
 }
 
 static bool queueBlockedMoveAvoidanceWaypoint(const RoomSetupState &state,
-		HarvesterEngine &engine, const Common::Array<ObjectRecord> &sceneObjects,
-		const Common::Array<AnimRecord> &sceneAnimations,
 		RoomPlayerState &playerState, const RoomMoveWaypoint &activeTarget,
 		int horizontalDirection, int depthDirection, const Entity &blocker) {
 	bool queued = false;
 	const char *axis = nullptr;
 	if (horizontalDirection != 0) {
+		queued = queueDepthAvoidanceWaypoint(state, playerState, activeTarget, blocker);
 		axis = "depth";
-		queued = queueDepthAvoidanceWaypoint(state, playerState, engine, sceneObjects,
-			sceneAnimations, activeTarget, blocker);
-		if (!queued) {
-			axis = "horizontal";
-			queued = queueHorizontalAvoidanceWaypoint(state, playerState, engine, sceneObjects,
-				sceneAnimations, activeTarget, blocker);
-		}
 	} else if (depthDirection != 0) {
+		queued = queueHorizontalAvoidanceWaypoint(state, playerState, activeTarget, blocker);
 		axis = "horizontal";
-		queued = queueHorizontalAvoidanceWaypoint(state, playerState, engine, sceneObjects,
-			sceneAnimations, activeTarget, blocker);
 	}
 
 	if (queued) {
@@ -1845,9 +1690,8 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 				blocker ? (double)blocker->getZExtent() : 0.0,
 				ignoredBlockerName ? ignoredBlockerName->c_str() : "");
 		}
-		if (blocker && queueBlockedMoveAvoidanceWaypoint(state, engine, sceneObjects,
-				sceneAnimations, playerState, activeTarget, horizontalDirection,
-				depthDirection, *blocker)) {
+		if (blocker && queueBlockedMoveAvoidanceWaypoint(state, playerState, activeTarget,
+				horizontalDirection, depthDirection, *blocker)) {
 			return true;
 		}
 


Commit: ff8e7864a183a7ed7789de3de50e3b9d89eb6efb
    https://github.com/scummvm/scummvm/commit/ff8e7864a183a7ed7789de3de50e3b9d89eb6efb
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:45+02:00

Commit Message:
HARVESTER: Match native actor movement state

Changed paths:
    TRACKER.md
    engines/harvester/player.cpp
    engines/harvester/room_support.h


diff --git a/TRACKER.md b/TRACKER.md
index 4a15cb3ab97..13ef38875bd 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 12, 2026, rechecked native `update_actor_runtime_state` and `run_harvester_main_loop` in Ghidra, then removed non-native route sampling, alternate detour candidates, clamped avoidance targets, and tighter avoidance-only waypoint slack from the ScummVM player pathfinding code.
+- On April 12, 2026, confirmed in Ghidra that native `update_render_entity_screen_position` removes and reinserts actors in `g_render_entity_list` when their z anchor changes, saved that note on `update_actor_runtime_state`, and updated ScummVM player placement to reinsert the player scene entity after accepted movement or combat-loadout placement changes.
 
 ## Next Suggested Action
 
-- Re-test `CHESSROOM` movement with `--debugflags=pathfinding` and `DEBUG_PATHFINDING` enabled, comparing each emitted detour against Ghidra's native rule: horizontal blocks insert one depth waypoint from the blocker z span, and depth blocks insert one horizontal waypoint beside the blocker.
+- Re-test `CHESSROOM` movement and keyboard movement through the chessmaster area, confirming the player draws in front at low z/depth while blocker-history logs still avoid repeated synthetic detours before mapping the separate desired-state 0x39/0x3a target-field behavior.
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 2d877707173..a8663d40fad 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -38,9 +38,6 @@ static const char *const kPlayerActorResourcePath = "1:/GRAPHIC/MONSTERS/PC/PC0.
 static const float kRoomPlayerHorizontalMoveBase = 8.0f;
 static const float kRoomPlayerHorizontalTargetSlackBase = 50.0f;
 static const float kRoomPlayerDepthTargetSlack = 8.0f;
-static const int kRoomPlayerMaxMoveWaypoints = 8;
-static const int kRoomPlayerBlockerHorizontalPad = 0x28;
-static const float kRoomPlayerBlockerDepthPad = 1.0f;
 static const int kRoomRegionTargetXBias = 10;
 static const float kRoomDepthCompareEpsilon = 0.01f;
 static const int kRoomPlayerWalkAnimationRate = 17;
@@ -606,49 +603,28 @@ static int computePlayerFrameLeftX(const RoomPlayerState &playerState) {
 	return playerState.centerX - width / 2;
 }
 
-static RoomMoveWaypoint getActiveMoveTarget(const RoomPlayerState &playerState) {
-	if (!playerState.moveWaypoints.empty())
-		return playerState.moveWaypoints[0];
-
-	RoomMoveWaypoint target;
-	target.targetX = playerState.targetX;
-	target.targetBottomY = playerState.targetBottomY;
-	target.targetZ = playerState.targetZ;
-	target.hasTargetX = true;
-	target.hasTargetZ = true;
-	return target;
-}
-
 static void resetPlayerMoveTargetProgress(RoomPlayerState &playerState) {
-	const RoomMoveWaypoint target = getActiveMoveTarget(playerState);
-	playerState.moveTargetXReached = !target.hasTargetX;
-	playerState.moveTargetZReached = !target.hasTargetZ;
+	playerState.moveTargetXReached = false;
+	playerState.moveTargetZReached = false;
 }
 
 static int resolveMoveTargetHorizontalDirection(const RoomSetupState &state,
-		const RoomPlayerState &playerState, const RoomMoveWaypoint &target) {
-	if (!target.hasTargetX)
-		return 0;
-
+		const RoomPlayerState &playerState) {
 	const float slack = Player::computeDepthScale(state, playerState.z) *
 		kRoomPlayerHorizontalTargetSlackBase;
 	const float frameLeftX = (float)computePlayerFrameLeftX(playerState);
-	if (frameLeftX < (float)target.targetX - slack)
+	if (frameLeftX < (float)playerState.targetX - slack)
 		return 1;
-	if ((float)target.targetX + slack < frameLeftX)
+	if ((float)playerState.targetX + slack < frameLeftX)
 		return -1;
 
 	return 0;
 }
 
-static int resolveMoveTargetDepthDirection(const RoomPlayerState &playerState,
-		const RoomMoveWaypoint &target) {
-	if (!target.hasTargetZ)
-		return 0;
-
-	if (target.targetZ < playerState.z - kRoomPlayerDepthTargetSlack)
+static int resolveMoveTargetDepthDirection(const RoomPlayerState &playerState) {
+	if (playerState.targetZ < playerState.z - kRoomPlayerDepthTargetSlack)
 		return 1;
-	if (playerState.z + kRoomPlayerDepthTargetSlack < target.targetZ)
+	if (playerState.z + kRoomPlayerDepthTargetSlack < playerState.targetZ)
 		return -1;
 
 	return 0;
@@ -659,13 +635,13 @@ static bool hasPlayerReachedMoveTarget(const RoomPlayerState &playerState) {
 }
 
 static void updatePlayerMoveTargetProgress(const RoomSetupState &state,
-		RoomPlayerState &playerState, const RoomMoveWaypoint &target) {
+		RoomPlayerState &playerState) {
 	if (!playerState.moveTargetXReached &&
-			resolveMoveTargetHorizontalDirection(state, playerState, target) == 0) {
+			resolveMoveTargetHorizontalDirection(state, playerState) == 0) {
 		playerState.moveTargetXReached = true;
 	}
 	if (!playerState.moveTargetZReached &&
-			resolveMoveTargetDepthDirection(playerState, target) == 0) {
+			resolveMoveTargetDepthDirection(playerState) == 0) {
 		playerState.moveTargetZReached = true;
 	}
 }
@@ -728,6 +704,27 @@ static bool shouldIgnoreMovementBlocker(const Entity &entity, const Common::Stri
 		entity.getName().equalsIgnoreCase(*ignoredBlockerName);
 }
 
+static bool isSameBlockerName(const Common::String &history, const Entity &blocker) {
+	return !history.empty() && history.equalsIgnoreCase(blocker.getName());
+}
+
+static void clearPlayerBlockerHistory(RoomPlayerState &playerState) {
+	playerState.currentBlockerName.clear();
+	playerState.upwardBlockerHistory.clear();
+	playerState.downwardBlockerHistory.clear();
+	playerState.leftwardBlockerHistory.clear();
+	playerState.rightwardBlockerHistory.clear();
+}
+
+static void setPlayerCurrentBlocker(RoomPlayerState &playerState, const Entity *blocker) {
+	if (blocker) {
+		playerState.currentBlockerName = blocker->getName();
+		return;
+	}
+
+	clearPlayerBlockerHistory(playerState);
+}
+
 static const Entity *findPlayerRoomBlocker(HarvesterEngine &engine,
 		const Common::Array<ObjectRecord> &sceneObjects,
 		const Common::Array<AnimRecord> &sceneAnimations, const Entity &playerEntity,
@@ -755,6 +752,30 @@ static const Entity *findPlayerRoomBlocker(HarvesterEngine &engine,
 	return nullptr;
 }
 
+static void refreshPlayerCurrentBlocker(HarvesterEngine &engine,
+		const Common::Array<ObjectRecord> &sceneObjects,
+		const Common::Array<AnimRecord> &sceneAnimations,
+		RoomPlayerState &playerState) {
+	if (!playerState.entity) {
+		clearPlayerBlockerHistory(playerState);
+		return;
+	}
+
+	setPlayerCurrentBlocker(playerState, findPlayerRoomBlocker(engine, sceneObjects,
+		sceneAnimations, *playerState.entity, nullptr));
+}
+
+static void reinsertPlayerSceneEntity(HarvesterEngine &engine, const RoomPlayerState &playerState) {
+	if (!playerState.entity)
+		return;
+
+	EntityManager *entityManager = engine.getRuntimeEntities();
+	if (entityManager &&
+			entityManager->findSceneEntityByName(playerState.entity->getName()) == playerState.entity) {
+		entityManager->reinsertSceneEntity(playerState.entity);
+	}
+}
+
 static bool isPlayerMovementBlocked(HarvesterEngine &engine, const RoomSetupState &state,
 		const Common::Array<ObjectRecord> &sceneObjects,
 		const Common::Array<AnimRecord> &sceneAnimations,
@@ -812,8 +833,13 @@ static bool tryApplyPlayerMovement(HarvesterEngine &engine, const RoomSetupState
 	playerState.centerX = candidateCenterX;
 	playerState.bottomY = candidateBottomY;
 	playerState.z = candidateZ;
-	return applyRoomActorPlacement(state, *playerState.entity,
-		playerState.centerX, playerState.bottomY, playerState.z);
+	if (!applyRoomActorPlacement(state, *playerState.entity,
+			playerState.centerX, playerState.bottomY, playerState.z)) {
+		return false;
+	}
+
+	reinsertPlayerSceneEntity(engine, playerState);
+	return true;
 }
 
 static bool tryApplyPlayerMovementWithFallbacks(HarvesterEngine &engine, const RoomSetupState &state,
@@ -850,131 +876,130 @@ static bool tryApplyPlayerMovementWithFallbacks(HarvesterEngine &engine, const R
 		playerState, previousCenterX, previousBottomY, candidateZ);
 }
 
-static bool getPlayerFrameWidth(const RoomPlayerState &playerState, int &width) {
-	if (!playerState.entity)
-		return false;
-
-	int height = 0;
-	int xOffset = 0;
-	int yOffset = 0;
-	return playerState.entity->getCurrentFrameMetrics(width, height, xOffset, yOffset);
+static bool isPathfindingDebugEnabled() {
+	return g_engine && g_engine->isPathfindingDebugEnabled();
 }
 
-static float getPlayerZExtent(const RoomPlayerState &playerState) {
-	return playerState.entity
-		? MAX<float>(0.0f, playerState.entity->getZExtent())
-		: 0.0f;
+static bool isHorizontalDirectionBlockedByHistory(const RoomPlayerState &playerState,
+		int horizontalDirection) {
+	if (horizontalDirection < 0)
+		return !playerState.leftwardBlockerHistory.empty();
+	if (horizontalDirection > 0)
+		return !playerState.rightwardBlockerHistory.empty();
+	return false;
 }
 
-static bool isSameMoveWaypoint(const RoomMoveWaypoint &lhs, const RoomMoveWaypoint &rhs) {
-	if (lhs.hasTargetX != rhs.hasTargetX || lhs.hasTargetZ != rhs.hasTargetZ)
-		return false;
-	if (lhs.hasTargetX && lhs.targetX != rhs.targetX)
-		return false;
-	if (lhs.hasTargetZ &&
-			fabsf(lhs.targetZ - rhs.targetZ) > kRoomDepthCompareEpsilon) {
-		return false;
-	}
-
-	return lhs.ignoredBlockerName.equalsIgnoreCase(rhs.ignoredBlockerName);
+static bool isDepthDirectionBlockedByHistory(const RoomPlayerState &playerState,
+		int depthDirection) {
+	if (depthDirection > 0)
+		return !playerState.upwardBlockerHistory.empty();
+	if (depthDirection < 0)
+		return !playerState.downwardBlockerHistory.empty();
+	return false;
 }
 
-static bool prependMoveWaypoint(RoomPlayerState &playerState, const RoomMoveWaypoint &waypoint) {
-	if (playerState.moveWaypoints.size() >= kRoomPlayerMaxMoveWaypoints)
-		return false;
-
-	if (!playerState.moveWaypoints.empty() &&
-			isSameMoveWaypoint(playerState.moveWaypoints[0], waypoint)) {
-		return false;
-	}
-
-	playerState.moveWaypoints.insert_at(0, waypoint);
-	resetPlayerMoveTargetProgress(playerState);
-	playerState.nextMovementTick = 0;
-	return true;
+static const char *describeBlockedDirection(int horizontalDirection, int depthDirection) {
+	if (horizontalDirection < 0)
+		return "left";
+	if (horizontalDirection > 0)
+		return "right";
+	if (depthDirection > 0)
+		return "upward";
+	if (depthDirection < 0)
+		return "downward";
+	return "";
 }
 
-static bool isPathfindingDebugEnabled() {
-	return g_engine && g_engine->isPathfindingDebugEnabled();
+static int resolveFacingFromMoveDirection(int horizontalDirection, int depthDirection,
+		int fallbackFacing) {
+	if (horizontalDirection < 0)
+		return 1;
+	if (horizontalDirection > 0)
+		return 2;
+	if (depthDirection < 0)
+		return 3;
+	if (depthDirection > 0)
+		return 0;
+	return fallbackFacing >= 0 ? fallbackFacing : 0;
 }
 
-static bool queueDepthAvoidanceWaypoint(const RoomSetupState &state, RoomPlayerState &playerState,
-		const RoomMoveWaypoint &activeTarget, const Entity &blocker) {
-	const float blockerZ = blocker.getZ();
-	const float blockerMidZ = blockerZ + blocker.getZExtent() * 0.5f;
-	const float referenceTargetZ = activeTarget.hasTargetZ
-		? activeTarget.targetZ
-		: playerState.targetZ;
-	const float playerZExtent = getPlayerZExtent(playerState);
-
-	float targetZ = 0.0f;
-	if (referenceTargetZ < blockerMidZ) {
-		targetZ = blockerZ - playerZExtent - kRoomPlayerBlockerDepthPad;
-	} else {
-		targetZ = blockerZ + blocker.getZExtent() + kRoomPlayerBlockerDepthPad;
-	}
+static void logPlayerBlockerHistory(const RoomSetupState &state,
+		const RoomPlayerState &playerState, const Entity &blocker,
+		const char *direction) {
+	if (!isPathfindingDebugEnabled())
+		return;
 
-	RoomMoveWaypoint waypoint;
-	waypoint.targetZ = targetZ;
-	waypoint.targetBottomY = mapRoomDepthToScreenY(state, targetZ);
-	waypoint.hasTargetZ = true;
-	waypoint.ignoredBlockerName = blocker.getName();
-	return prependMoveWaypoint(playerState, waypoint);
-}
+	debugC(1, kDebugPathfinding,
+		"Harvester: pathfinding blocker history room='%s' blocker='%s' direction='%s' histories=(up='%s',down='%s',left='%s',right='%s')",
+		state.roomName.c_str(), blocker.getName().c_str(), direction,
+		playerState.upwardBlockerHistory.c_str(),
+		playerState.downwardBlockerHistory.c_str(),
+		playerState.leftwardBlockerHistory.c_str(),
+		playerState.rightwardBlockerHistory.c_str());
+}
+
+static bool recordNativePrimaryWalkBlockerHistory(const RoomSetupState &state,
+		RoomPlayerState &playerState, const Entity &blocker,
+		int horizontalDirection, int depthDirection) {
+	if (horizontalDirection < 0) {
+		const int frameLeftX = computePlayerFrameLeftX(playerState);
+		const Common::Rect blockerRect = blocker.getScreenRect();
+		if (isSameBlockerName(playerState.rightwardBlockerHistory, blocker) ||
+				isSameBlockerName(playerState.upwardBlockerHistory, blocker) ||
+				isSameBlockerName(playerState.downwardBlockerHistory, blocker) ||
+				frameLeftX <= blockerRect.left) {
+			return false;
+		}
 
-static bool queueHorizontalAvoidanceWaypoint(const RoomSetupState &state, RoomPlayerState &playerState,
-		const RoomMoveWaypoint &activeTarget, const Entity &blocker) {
-	int playerWidth = 0;
-	if (!getPlayerFrameWidth(playerState, playerWidth))
-		return false;
+		playerState.leftwardBlockerHistory = blocker.getName();
+		playerState.moveTargetXReached = true;
+		logPlayerBlockerHistory(state, playerState, blocker, "left");
+		return true;
+	}
 
-	const Common::Rect blockerRect = blocker.getScreenRect();
-	const int blockerMidX = blockerRect.left + blockerRect.width() / 2;
-	const int referenceTargetX = activeTarget.hasTargetX
-		? activeTarget.targetX
-		: playerState.targetX;
+	if (horizontalDirection > 0) {
+		const int frameLeftX = computePlayerFrameLeftX(playerState);
+		const Common::Rect blockerRect = blocker.getScreenRect();
+		if (isSameBlockerName(playerState.leftwardBlockerHistory, blocker) ||
+				isSameBlockerName(playerState.upwardBlockerHistory, blocker) ||
+				isSameBlockerName(playerState.downwardBlockerHistory, blocker) ||
+				blockerRect.left <= frameLeftX) {
+			return false;
+		}
 
-	int targetX = 0;
-	if (referenceTargetX < blockerMidX) {
-		targetX = blockerRect.left - playerWidth - kRoomPlayerBlockerHorizontalPad;
-	} else {
-		targetX = blockerRect.right + kRoomPlayerBlockerHorizontalPad;
+		playerState.rightwardBlockerHistory = blocker.getName();
+		playerState.moveTargetXReached = true;
+		logPlayerBlockerHistory(state, playerState, blocker, "right");
+		return true;
 	}
 
-	RoomMoveWaypoint waypoint;
-	waypoint.targetX = targetX;
-	waypoint.hasTargetX = true;
-	waypoint.ignoredBlockerName = blocker.getName();
-	return prependMoveWaypoint(playerState, waypoint);
-}
+	if (depthDirection > 0) {
+		if (isSameBlockerName(playerState.rightwardBlockerHistory, blocker) ||
+				isSameBlockerName(playerState.leftwardBlockerHistory, blocker) ||
+				isSameBlockerName(playerState.downwardBlockerHistory, blocker)) {
+			return false;
+		}
 
-static bool queueBlockedMoveAvoidanceWaypoint(const RoomSetupState &state,
-		RoomPlayerState &playerState, const RoomMoveWaypoint &activeTarget,
-		int horizontalDirection, int depthDirection, const Entity &blocker) {
-	bool queued = false;
-	const char *axis = nullptr;
-	if (horizontalDirection != 0) {
-		queued = queueDepthAvoidanceWaypoint(state, playerState, activeTarget, blocker);
-		axis = "depth";
-	} else if (depthDirection != 0) {
-		queued = queueHorizontalAvoidanceWaypoint(state, playerState, activeTarget, blocker);
-		axis = "horizontal";
+		playerState.upwardBlockerHistory = blocker.getName();
+		playerState.moveTargetZReached = true;
+		logPlayerBlockerHistory(state, playerState, blocker, "upward");
+		return true;
 	}
 
-	if (queued) {
-		const RoomMoveWaypoint waypoint = getActiveMoveTarget(playerState);
-		debugC(1, kDebugPlayer,
-			"Harvester: player move detour room='%s' blocker='%s' axis='%s' waypoint=(x=%d,z=%.2f,hasX=%d,hasZ=%d)",
-			state.roomName.c_str(), blocker.getName().c_str(), axis,
-			waypoint.targetX, (double)waypoint.targetZ,
-			waypoint.hasTargetX, waypoint.hasTargetZ);
-	} else if (isPathfindingDebugEnabled()) {
-		debugC(1, kDebugPathfinding,
-			"Harvester: pathfinding detour rejected room='%s' blocker='%s' axis='%s'",
-			state.roomName.c_str(), blocker.getName().c_str(), axis ? axis : "");
+	if (depthDirection < 0) {
+		if (isSameBlockerName(playerState.rightwardBlockerHistory, blocker) ||
+				isSameBlockerName(playerState.upwardBlockerHistory, blocker) ||
+				isSameBlockerName(playerState.leftwardBlockerHistory, blocker)) {
+			return false;
+		}
+
+		playerState.downwardBlockerHistory = blocker.getName();
+		playerState.moveTargetZReached = true;
+		logPlayerBlockerHistory(state, playerState, blocker, "downward");
+		return true;
 	}
 
-	return queued;
+	return false;
 }
 
 static void setMoveTargetInternal(const RoomSetupState &state, RoomPlayerState &playerState,
@@ -984,7 +1009,6 @@ static void setMoveTargetInternal(const RoomSetupState &state, RoomPlayerState &
 	playerState.targetX = CLIP<int>(targetX, 0, 639);
 	playerState.targetBottomY = clampRoomMovementY(state, targetBottomY);
 	playerState.targetZ = clampRoomDepth(state, targetZ);
-	playerState.moveWaypoints.clear();
 	resetPlayerMoveTargetProgress(playerState);
 	debugC(1, kDebugPlayer,
 		"Harvester: player move target room='%s' current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f)",
@@ -992,12 +1016,11 @@ static void setMoveTargetInternal(const RoomSetupState &state, RoomPlayerState &
 		playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ);
 	if (isPathfindingDebugEnabled()) {
 		debugC(1, kDebugPathfinding,
-			"Harvester: pathfinding target set room='%s' movement_band=(y=%d..%d,z=%d..%d) current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f) waypoints=%u",
+			"Harvester: pathfinding target set room='%s' movement_band=(y=%d..%d,z=%d..%d) current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f)",
 			state.roomName.c_str(), state.roomMaxZScreenY, state.roomMinZScreenY,
 			state.roomMinZ, state.roomMaxZ,
 			playerState.centerX, playerState.bottomY, (double)playerState.z,
-			playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ,
-			playerState.moveWaypoints.size());
+			playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ);
 	}
 }
 
@@ -1240,6 +1263,7 @@ bool Player::resolveBlockedStartupSpawn(HarvesterEngine &engine, const RoomSetup
 					playerState.centerX, playerState.bottomY, playerState.z)) {
 				return false;
 			}
+			reinsertPlayerSceneEntity(engine, playerState);
 
 			debugC(1, kDebugPlayer,
 				"Harvester: adjusted blocked startup spawn room='%s' facing=%d from=(%d,%d,z=%.2f) to=(%d,%d,z=%.2f)",
@@ -1310,8 +1334,10 @@ bool Player::syncCombatLoadoutVisual(HarvesterEngine &engine, const RoomSetupSta
 	playerState.hitKnockbackRemainingX = 0;
 	playerState.hitKnockbackDecayStep = 0;
 	(void)setIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
-	(void)applyRoomActorPlacement(state, *playerState.entity,
-		playerState.centerX, playerState.bottomY, playerState.z);
+	if (applyRoomActorPlacement(state, *playerState.entity,
+			playerState.centerX, playerState.bottomY, playerState.z)) {
+		reinsertPlayerSceneEntity(engine, playerState);
+	}
 	debugC(1, kDebugPlayer,
 		"Harvester: player combat loadout visual loadout=%d weapon='%s' damage=%d damage_type='%s' contact=%d resource='%s' facing=%d pos=(%d,%d,z=%.2f)",
 		playerState.combatLoadout, describeCombatLoadout(playerState.combatLoadout),
@@ -1617,28 +1643,50 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 	if (!playerState.entity || !playerState.hasMoveTarget || playerState.turnActive || playerState.hitActive)
 		return false;
 
-	RoomMoveWaypoint activeTarget = getActiveMoveTarget(playerState);
-	updatePlayerMoveTargetProgress(state, playerState, activeTarget);
-	while (hasPlayerReachedMoveTarget(playerState)) {
-		if (playerState.moveWaypoints.empty()) {
-			playerState.hasMoveTarget = false;
-			return setIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
-		}
+	refreshPlayerCurrentBlocker(engine, sceneObjects, sceneAnimations, playerState);
 
-		playerState.moveWaypoints.remove_at(0);
-		resetPlayerMoveTargetProgress(playerState);
-		activeTarget = getActiveMoveTarget(playerState);
-		updatePlayerMoveTargetProgress(state, playerState, activeTarget);
+	updatePlayerMoveTargetProgress(state, playerState);
+	if (hasPlayerReachedMoveTarget(playerState)) {
+		playerState.hasMoveTarget = false;
+		return setIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
 	}
 
 	const int previousCenterX = playerState.centerX;
 	const int previousBottomY = playerState.bottomY;
-	const int horizontalDirection = playerState.moveTargetXReached
+	int horizontalDirection = playerState.moveTargetXReached
 		? 0
-		: resolveMoveTargetHorizontalDirection(state, playerState, activeTarget);
-	const int depthDirection = horizontalDirection == 0 && !playerState.moveTargetZReached
-		? resolveMoveTargetDepthDirection(playerState, activeTarget)
+		: resolveMoveTargetHorizontalDirection(state, playerState);
+	if (horizontalDirection != 0 && isHorizontalDirectionBlockedByHistory(playerState, horizontalDirection)) {
+		if (engine.isPathfindingDebugEnabled()) {
+			debugC(1, kDebugPathfinding,
+				"Harvester: pathfinding direction held by blocker history room='%s' direction='%s' history='%s'",
+				state.roomName.c_str(),
+				describeBlockedDirection(horizontalDirection, 0),
+				horizontalDirection < 0
+					? playerState.leftwardBlockerHistory.c_str()
+					: playerState.rightwardBlockerHistory.c_str());
+		}
+		horizontalDirection = 0;
+	}
+	int depthDirection = horizontalDirection == 0 && !playerState.moveTargetZReached
+		? resolveMoveTargetDepthDirection(playerState)
 		: 0;
+	if (depthDirection != 0 && isDepthDirectionBlockedByHistory(playerState, depthDirection)) {
+		if (engine.isPathfindingDebugEnabled()) {
+			debugC(1, kDebugPathfinding,
+				"Harvester: pathfinding direction held by blocker history room='%s' direction='%s' history='%s'",
+				state.roomName.c_str(),
+				describeBlockedDirection(0, depthDirection),
+				depthDirection > 0
+					? playerState.upwardBlockerHistory.c_str()
+					: playerState.downwardBlockerHistory.c_str());
+		}
+		depthDirection = 0;
+	}
+	if (horizontalDirection == 0 && depthDirection == 0) {
+		playerState.hasMoveTarget = false;
+		return setIdleAnimation(playerState, playerState.facing >= 0 ? playerState.facing : 0);
+	}
 	if (!consumePlayerMovementTick(playerState))
 		return false;
 
@@ -1654,45 +1702,41 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 		const float depthStep = computeRoomPlayerDepthStep(state);
 		candidateBottomY = clampRoomMovementY(state,
 			playerState.bottomY + depthDirection * verticalStep);
-		candidateZ = stepTowardsFloat(playerState.z, activeTarget.targetZ, depthStep);
+		candidateZ = stepTowardsFloat(playerState.z, playerState.targetZ, depthStep);
 	}
 
 	if (engine.isPathfindingDebugEnabled()) {
 		debugC(1, kDebugPathfinding,
-			"Harvester: pathfinding step room='%s' current=(%d,%d,z=%.2f) active_target=(x=%d,y=%d,z=%.2f,hasX=%d,hasZ=%d,ignore='%s') progress=(x=%d,z=%d) direction=(x=%d,z=%d) candidate=(%d,%d,z=%.2f) waypoints=%u",
+			"Harvester: pathfinding step room='%s' current=(%d,%d,z=%.2f) target=(x=%d,y=%d,z=%.2f) progress=(x=%d,z=%d) direction=(x=%d,z=%d) candidate=(%d,%d,z=%.2f)",
 			state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
-			activeTarget.targetX, activeTarget.targetBottomY, (double)activeTarget.targetZ,
-			activeTarget.hasTargetX, activeTarget.hasTargetZ,
-			activeTarget.ignoredBlockerName.c_str(),
+			playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ,
 			playerState.moveTargetXReached, playerState.moveTargetZReached,
 			horizontalDirection, depthDirection,
-			candidateCenterX, candidateBottomY, (double)candidateZ,
-			playerState.moveWaypoints.size());
+			candidateCenterX, candidateBottomY, (double)candidateZ);
 	}
 
-	const Common::String *ignoredBlockerName = activeTarget.ignoredBlockerName.empty()
-		? nullptr
-		: &activeTarget.ignoredBlockerName;
 	const Entity *blocker = nullptr;
 	bool moved = tryApplyPlayerMovement(engine, state, sceneObjects, sceneAnimations,
 		playerState, candidateCenterX, candidateBottomY, candidateZ,
-		ignoredBlockerName, &blocker);
+		nullptr, &blocker);
 	if (!moved) {
 		if (engine.isPathfindingDebugEnabled()) {
 			const Common::Rect blockerRect = blocker ? blocker->getScreenRect() : Common::Rect();
 			debugC(1, kDebugPathfinding,
-				"Harvester: pathfinding blocked room='%s' candidate=(%d,%d,z=%.2f) blocker='%s' class=0x%x rect=(%d,%d)-(%d,%d) z=%.2f z_extent=%.2f ignored='%s'",
+				"Harvester: pathfinding blocked room='%s' candidate=(%d,%d,z=%.2f) blocker='%s' class=0x%x rect=(%d,%d)-(%d,%d) z=%.2f z_extent=%.2f",
 				state.roomName.c_str(), candidateCenterX, candidateBottomY, (double)candidateZ,
 				blocker ? blocker->getName().c_str() : "",
 				blocker ? blocker->getClassId() : -1,
 				blockerRect.left, blockerRect.top, blockerRect.right, blockerRect.bottom,
 				blocker ? (double)blocker->getZ() : 0.0,
-				blocker ? (double)blocker->getZExtent() : 0.0,
-				ignoredBlockerName ? ignoredBlockerName->c_str() : "");
+				blocker ? (double)blocker->getZExtent() : 0.0);
 		}
-		if (blocker && queueBlockedMoveAvoidanceWaypoint(state, playerState, activeTarget,
-				horizontalDirection, depthDirection, *blocker)) {
-			return true;
+		setPlayerCurrentBlocker(playerState, blocker);
+		if (blocker && recordNativePrimaryWalkBlockerHistory(state, playerState, *blocker,
+				horizontalDirection, depthDirection)) {
+			return setIdleAnimation(playerState,
+				resolveFacingFromMoveDirection(horizontalDirection, depthDirection,
+					playerState.facing));
 		}
 
 		playerState.hasMoveTarget = false;
@@ -1709,6 +1753,7 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 	const int actualFacing = resolveFacingFromRoomMovement(
 		previousCenterX, previousBottomY, playerState.centerX, playerState.bottomY);
 	(void)setPlayerWalkAnimation(playerState, actualFacing);
+	refreshPlayerCurrentBlocker(engine, sceneObjects, sceneAnimations, playerState);
 	if (engine.isPathfindingDebugEnabled()) {
 		debugC(1, kDebugPathfinding,
 			"Harvester: pathfinding applied room='%s' previous=(%d,%d) current=(%d,%d,z=%.2f) facing=%d",
@@ -1716,26 +1761,17 @@ bool Player::stepMoveTarget(HarvesterEngine &engine, const RoomSetupState &state
 			playerState.centerX, playerState.bottomY, (double)playerState.z,
 			actualFacing);
 	}
-	activeTarget = getActiveMoveTarget(playerState);
-	updatePlayerMoveTargetProgress(state, playerState, activeTarget);
-	while (hasPlayerReachedMoveTarget(playerState)) {
-		if (playerState.moveWaypoints.empty()) {
-			playerState.hasMoveTarget = false;
-			(void)setIdleAnimation(playerState, actualFacing);
-			break;
-		}
-
-		playerState.moveWaypoints.remove_at(0);
-		resetPlayerMoveTargetProgress(playerState);
-		activeTarget = getActiveMoveTarget(playerState);
-		updatePlayerMoveTargetProgress(state, playerState, activeTarget);
+	updatePlayerMoveTargetProgress(state, playerState);
+	if (hasPlayerReachedMoveTarget(playerState)) {
+		playerState.hasMoveTarget = false;
+		(void)setIdleAnimation(playerState, actualFacing);
 	}
 	debugC(playerState.hasMoveTarget ? 2 : 1, kDebugPlayer,
-		"Harvester: player move step room='%s' pos=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f) waypoints=%u facing=%d frame=%d active=%d moved=%d",
+		"Harvester: player move step room='%s' pos=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f) facing=%d frame=%d active=%d moved=%d",
 		state.roomName.c_str(), playerState.centerX, playerState.bottomY, (double)playerState.z,
 		playerState.targetX, playerState.targetBottomY, (double)playerState.targetZ,
-		playerState.moveWaypoints.size(), playerState.facing,
-		playerState.entity->getCurrentFrame(), playerState.hasMoveTarget, moved);
+		playerState.facing, playerState.entity->getCurrentFrame(),
+		playerState.hasMoveTarget, moved);
 	return true;
 }
 
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index 68b140e21e3..7c52d9df7f7 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -51,15 +51,6 @@ struct RoomSceneResources {
 	float targetPaletteBrightness = 1.0f;
 };
 
-struct RoomMoveWaypoint {
-	int targetX = 0;
-	int targetBottomY = 0;
-	float targetZ = 0.0f;
-	bool hasTargetX = false;
-	bool hasTargetZ = false;
-	Common::String ignoredBlockerName;
-};
-
 struct RoomPlayerState {
 	Entity *entity = nullptr;
 	int centerX = 0;
@@ -75,7 +66,14 @@ struct RoomPlayerState {
 	float targetZ = 0.0f;
 	bool moveTargetXReached = false;
 	bool moveTargetZReached = false;
-	Common::Array<RoomMoveWaypoint> moveWaypoints;
+	Common::String currentBlockerName;
+	// Native RenderEntityRuntime keeps these directional blocker-history
+	// pointers at +0x108c..+0x1098 and clears them when no current
+	// opaque blocker overlaps the actor.
+	Common::String upwardBlockerHistory;
+	Common::String downwardBlockerHistory;
+	Common::String leftwardBlockerHistory;
+	Common::String rightwardBlockerHistory;
 	bool turnActive = false;
 	int turnTargetFacing = -1;
 	int turnFirstFrame = -1;


Commit: e1a300e5adb3881d18f0dd1609659d949392819a
    https://github.com/scummvm/scummvm/commit/e1a300e5adb3881d18f0dd1609659d949392819a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:45+02:00

Commit Message:
HARVESTER: Fix passive room region triggers

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 13ef38875bd..b3997cfc5c8 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 12, 2026, confirmed in Ghidra that native `update_render_entity_screen_position` removes and reinserts actors in `g_render_entity_list` when their z anchor changes, saved that note on `update_actor_runtime_state`, and updated ScummVM player placement to reinsert the player scene entity after accepted movement or combat-loadout placement changes.
+- On April 12, 2026, refined the Ghidra note for `check_player_region_interaction`: cursor-enabled regions are candidate-selected by cursor/click handling, while cursor-disabled region entities can still act as passive screen/Z overlap triggers. Updated ScummVM post-move activation to sweep only cursor-disabled passive regions and separately test the current hovered region.
 
 ## Next Suggested Action
 
-- Re-test `CHESSROOM` movement and keyboard movement through the chessmaster area, confirming the player draws in front at low z/depth while blocker-history logs still avoid repeated synthetic detours before mapping the separate desired-state 0x39/0x3a target-field behavior.
+- Re-test `BALLROM1` maintenance warning regions, then `FOYER6_SMOKIN` and `FOYER6_FOYER5`, confirming cursor-disabled passive warnings still fire while cursor-enabled exit regions no longer trigger from unrelated sprite overlap.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 40337abd38d..873da6555e5 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -3933,12 +3933,29 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		stopPlayerRegionInteraction();
 		return runRegionInteraction(*region);
 	};
-		auto tryActivateOverlappedRegion = [&]() -> Common::Error {
-			if (!playerState.entity)
-				return Common::kNoError;
+	auto tryActivateHoveredRegion = [&]() -> Common::Error {
+		if (!playerState.entity)
+			return Common::kNoError;
+
+		const RoomHoverState hoverState = resolveRoomHoverState(
+			_engine, scene.state, scene.sceneObjects, scene.state.roomNpcs,
+			scene.sceneRegions, _mousePos, &flow._dialogue);
+		if (!hoverState.region || !hoverState.region->startEnabled)
+			return Common::kNoError;
+		if (!doesPlayerOverlapRegion(*playerState.entity, *hoverState.region))
+			return Common::kNoError;
+		if (!doesPlayerFacingMatchRegion(playerState.facing, *hoverState.region))
+			return Common::kNoError;
+
+		stopPlayerRegionInteraction();
+		return runRegionInteraction(*hoverState.region);
+	};
+	auto tryActivatePassiveRegion = [&]() -> Common::Error {
+		if (!playerState.entity)
+			return Common::kNoError;
 
 		for (const RegionRecord &region : scene.sceneRegions) {
-			if (!region.startEnabled)
+			if (!region.startEnabled || region.cursorEnabled)
 				continue;
 			if (!doesPlayerOverlapRegion(*playerState.entity, region))
 				continue;
@@ -3949,12 +3966,12 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			return runRegionInteraction(region);
 		}
 
-			return Common::kNoError;
-		};
-		if (!_inventory.refresh())
-			return Common::kReadingFailed;
-		Graphics::FrameLimiter limiter(g_system, 60);
-		captureCurrentSaveState();
+		return Common::kNoError;
+	};
+	if (!_inventory.refresh())
+		return Common::kReadingFailed;
+	Graphics::FrameLimiter limiter(g_system, 60);
+	captureCurrentSaveState();
 
 	if (shouldRunStartupRoomProbe())
 		logStartupRoomProbe(_engine, scene, currentRoomTarget, _mousePos);
@@ -4864,18 +4881,28 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				return Common::kReadingFailed;
 			break;
 		}
-		pendingRegionError =
-			(playerAdvancedThisFrame && !isPlayerCombatLocked())
-				? tryActivateOverlappedRegion()
-				: Common::kNoError;
-		if (pendingRegionError.getCode() != Common::kNoError)
-			return pendingRegionError;
-		if (flow.hasPendingMainMenuReturn())
-			return Common::kNoError;
-		if (!pendingRoomChange.empty()) {
-			if (!stowCarriedRoomItemToInventory())
-				return Common::kReadingFailed;
-			break;
+		if (playerAdvancedThisFrame && !isPlayerCombatLocked()) {
+			pendingRegionError = tryActivatePassiveRegion();
+			if (pendingRegionError.getCode() != Common::kNoError)
+				return pendingRegionError;
+			if (flow.hasPendingMainMenuReturn())
+				return Common::kNoError;
+			if (!pendingRoomChange.empty()) {
+				if (!stowCarriedRoomItemToInventory())
+					return Common::kReadingFailed;
+				break;
+			}
+
+			pendingRegionError = tryActivateHoveredRegion();
+			if (pendingRegionError.getCode() != Common::kNoError)
+				return pendingRegionError;
+			if (flow.hasPendingMainMenuReturn())
+				return Common::kNoError;
+			if (!pendingRoomChange.empty()) {
+				if (!stowCarriedRoomItemToInventory())
+					return Common::kReadingFailed;
+				break;
+			}
 		}
 
 		if (flow.tickRuntimeEntities())


Commit: 0038a206f40e1164ebdb524b6e0a1b1a03ae0425
    https://github.com/scummvm/scummvm/commit/0038a206f40e1164ebdb524b6e0a1b1a03ae0425
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:45+02:00

Commit Message:
HARVESTER: Match native USEITEM dispatch

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/script.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 10b5f53f782..6b5a6a58be1 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -365,6 +365,8 @@
 - `g_text_records` at `0xd5acc` is the `TEXT` list.
 - `g_timer_records` at `0xd5ad0` is the `TIMER` list.
 - `g_useitem_records` at `0xd5ad4` is the `USEITEM` list.
+  - `UseItemRecord.owner_or_group` is the parsed second quoted field.
+  - Native dispatch stores the second quoted field, but `run_inventory_screen` and `handle_target_interaction` do not compare it when resolving a use action; they match the carried item name and overlapped target entity name, then dispatch the action tag. This is required for CD3 foyer script rows such as `KEWPIE_KEY` with owner/group `FOYER` targeting `THEATRE_DOOR` in room `FOYER1`.
   - The list is not limited to object hotspots. The decoded script contains three confirmed NPC-targeted entries: sheriff-office `INV_MAG -> LOOMIS -> GO_LOOMISA`, which sets `GAVE_MAG_TO_LOOMIS_TODAY` and immediately hides Loomis with `SET_NPC "LOOMIS" "F" "F"`, plus post-office Boyle entries for `BOYLES_BUTTON` and `GASCAN`.
   - Native handler comparison shows the two Boyle branches are already covered directly inside `handle_talk_to_boyle`, while Loomis was the one script-side room-state gap that ScummVM had to mirror explicitly.
 - `g_pending_room_name` at `0xd60a4` is the pending next-room string consumed by the main loop.
diff --git a/TRACKER.md b/TRACKER.md
index b3997cfc5c8..098fe965641 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 12, 2026, refined the Ghidra note for `check_player_region_interaction`: cursor-enabled regions are candidate-selected by cursor/click handling, while cursor-disabled region entities can still act as passive screen/Z overlap triggers. Updated ScummVM post-move activation to sweep only cursor-disabled passive regions and separately test the current hovered region.
+- On April 13, 2026, confirmed from CD3 `HARVEST.SCR` and Ghidra that native `USEITEM` dispatch ignores the parsed owner/group field and matches only carried item name plus overlapped target entity name. Annotated `UseItemRecord.owner_or_group` in Ghidra and updated ScummVM lookup so `KEWPIE_KEY` can trigger `UNLOCK_THEATRE_DOOR` on `THEATRE_DOOR` in `FOYER1` despite the script row using owner/group `FOYER`.
 
 ## Next Suggested Action
 
-- Re-test `BALLROM1` maintenance warning regions, then `FOYER6_SMOKIN` and `FOYER6_FOYER5`, confirming cursor-disabled passive warnings still fire while cursor-enabled exit regions no longer trigger from unrelated sprite overlap.
+- Re-test using `KEWPIE_KEY` on `FOYER1` `THEATRE_DOOR`: confirm the door hotspot is deleted, `FOYR1_THEATRE1` is enabled, `KEWPIE_KEY` is removed from inventory, `FOYRDOOR_ANIM` plays, and the left theatre entrance changes rooms.
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 67c98d5c6d3..dc4270f12d3 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -2091,11 +2091,8 @@ const UseItemRecord *Script::findUseItemRecord(const Common::String &itemName,
 			continue;
 		}
 
-		if (!useItem.ownerOrRoom.empty() &&
-			!useItem.ownerOrRoom.equalsIgnoreCase(target.currentOwnerOrRoom)) {
-			continue;
-		}
-
+		// Native dispatch ignores the parsed owner/group string and matches only
+		// the carried item name plus the overlapped target entity name.
 		return &useItem;
 	}
 


Commit: 212cf3e46af84256d8460bd80ca43ce8a5a89072
    https://github.com/scummvm/scummvm/commit/212cf3e46af84256d8460bd80ca43ce8a5a89072
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:46+02:00

Commit Message:
HARVESTER: Match native monster pursuit waypoint

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 098fe965641..c02c0f453f2 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 13, 2026, confirmed from CD3 `HARVEST.SCR` and Ghidra that native `USEITEM` dispatch ignores the parsed owner/group field and matches only carried item name plus overlapped target entity name. Annotated `UseItemRecord.owner_or_group` in Ghidra and updated ScummVM lookup so `KEWPIE_KEY` can trigger `UNLOCK_THEATRE_DOOR` on `THEATRE_DOOR` in `FOYER1` despite the script row using owner/group `FOYER`.
+- On April 13, 2026, rechecked Ghidra `update_actor_runtime_state` class-6 pursuit and confirmed native horizontal pursuit stores a target frame-left X derived from the player's edge, the MONSTER engage field, and monster frame width. Updated ScummVM `RoomSystem` monster chase logic to use that native frame-left waypoint instead of the previous center target, which could leave `BURNTCREATURE` stationary inside the 50-pixel waypoint tolerance while still outside engage range.
 
 ## Next Suggested Action
 
-- Re-test using `KEWPIE_KEY` on `FOYER1` `THEATRE_DOOR`: confirm the door hotspot is deleted, `FOYR1_THEATRE1` is enabled, `KEWPIE_KEY` is removed from inventory, `FOYRDOOR_ANIM` plays, and the left theatre entrance changes rooms.
+- Re-test entering `SMOKING` from `BAKSTAG2`: confirm `BURNTCREATURE` emits immediate `combat monster chase move` logs while outside `engage=100`, keeps advancing on animation ticks, and still starts attacks once `abs(live_center_dx) <= 100`.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 873da6555e5..cd5b2a89afe 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -3577,7 +3577,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			const int monsterCenterX = monsterRect.left + monsterRect.width() / 2;
 			const int liveCenterDx = playerCenterX - monsterCenterX;
 			const int absLiveCenterDx = ABS(liveCenterDx);
-			const int monsterLiveCenterOffset = monsterCenterX - monster.posX;
 			const int playerTargetZ = roundRoomCombatFloat(playerState.z);
 			const float zDelta = playerState.z - (float)monster.posZ;
 			const float absZDelta = zDelta >= 0.0f ? zDelta : -zDelta;
@@ -3593,14 +3592,16 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				monster.posZ = (int)clampRoomDepthForEvent(scene.state, (float)monster.posZ);
 				monster.posY = mapRoomDepthToScreenYForCombat(scene.state, (float)monster.posZ, monster.posY);
 			} else if (absLiveCenterDx > engageDistance) {
-				const int desiredLiveCenterX = monsterCenterX < playerCenterX
-					? playerRect.left - engageDistance + monsterRect.width() / 2
-					: playerRect.right + engageDistance - monsterRect.width() / 2;
-				if (ABS(monsterCenterX - desiredLiveCenterX) > liveWaypointTolerance) {
+				// Native class-6 pursuit stores a target frame-left X, not a center X.
+				const int desiredFrameLeftX = monsterCenterX < playerCenterX
+					? playerRect.right + engageDistance - monsterRect.width() / 2
+					: playerRect.left - engageDistance + monsterRect.width() / 2;
+				if (ABS(monsterRect.left - desiredFrameLeftX) > liveWaypointTolerance) {
 					attemptedMoveX = true;
 					const int step = MAX<int>(1, roundRoomCombatFloat(
 						Player::computeDepthScale(scene.state, (float)monster.posZ) * (float)horizontalStepBase));
-					const int desiredLogicalCenterX = desiredLiveCenterX - monsterLiveCenterOffset;
+					const int monsterFrameLeftOffset = monsterRect.left - monster.posX;
+					const int desiredLogicalCenterX = desiredFrameLeftX - monsterFrameLeftOffset;
 					monster.posX = stepTowardsRoomCombatInt(monster.posX, desiredLogicalCenterX, step);
 					monster.posX = CLIP<int>(monster.posX, monster.screenMinXBound, monster.screenMaxXBound);
 				}


Commit: 0fd95b8723649421a13845a5aae4169d59d359fd
    https://github.com/scummvm/scummvm/commit/0fd95b8723649421a13845a5aae4169d59d359fd
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:46+02:00

Commit Message:
HARVESTER: Add projectile contact target fallback

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index c02c0f453f2..bcdabf407bd 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 13, 2026, rechecked Ghidra `update_actor_runtime_state` class-6 pursuit and confirmed native horizontal pursuit stores a target frame-left X derived from the player's edge, the MONSTER engage field, and monster frame width. Updated ScummVM `RoomSystem` monster chase logic to use that native frame-left waypoint instead of the previous center target, which could leave `BURNTCREATURE` stationary inside the 50-pixel waypoint tolerance while still outside engage range.
+- On April 13, 2026, added a ScummVM `RoomSystem` projectile contact fallback based on Ghidra `update_actor_runtime_state`: when a projectile attack reaches its contact frame with no captured cursor target, it scans active visible room monsters using live Z-overlap and attack-direction geometry, assigns the selected monster as the attack target, and then applies the existing monster damage path.
 
 ## Next Suggested Action
 
-- Re-test entering `SMOKING` from `BAKSTAG2`: confirm `BURNTCREATURE` emits immediate `combat monster chase move` logs while outside `engage=100`, keeps advancing on animation ticks, and still starts attacks once `abs(live_center_dx) <= 100`.
+- Re-test shotgun attacks against `BURNTCREATURE` in `SMOKING`: confirm empty-cursor shots log `combat player projectile contact fallback target='BURNTCREATURE'`, then `combat player monster hit`, and that shells still dry-fire once depleted.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index cd5b2a89afe..0ae8f9964c6 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -72,7 +72,7 @@ static const int kMuckeyAttackContactFrameOffset = 7;
 static const float kNativeMonsterPursuitZTolerance = 2.0f;
 static const float kNativeMonsterHorizontalWaypointTolerance = 50.0f;
 static const uint32 kNativeKeyboardAttackRepeatTicks = 25;
-static const int kNativeKeyboardAttackSideWindow = 30;
+static const int kNativeAttackSideWindow = 30;
 static const int kNativeCombatHitKnockbackDistance = 18;
 static const int kNativeCombatHitKnockbackDecayStep = 3;
 static const char *const kNativeHitEffectResourcePath = "blood.abm";
@@ -2710,6 +2710,115 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			return areCombatantsWithinRoomCombatReach(scene.state,
 				*playerState.entity, playerState.z, *targetEntity, targetEntity->getZ(), engageDistance);
 		};
+		enum NativeAttackSide {
+			kNativeAttackSideNone,
+			kNativeAttackSideLeft,
+			kNativeAttackSideRight
+		};
+		enum NativeAttackBand {
+			kNativeAttackBandMid,
+			kNativeAttackBandUpper,
+			kNativeAttackBandLower
+		};
+		auto resolveNativePlayerAttackGeometry = [](int attackFirstFrame,
+				NativeAttackSide &attackSide, NativeAttackBand &attackBand) {
+			attackSide = kNativeAttackSideNone;
+			attackBand = kNativeAttackBandMid;
+
+			switch (attackFirstFrame) {
+			case 0x50:
+				attackSide = kNativeAttackSideLeft;
+				attackBand = kNativeAttackBandUpper;
+				return true;
+			case 0x5a:
+				attackSide = kNativeAttackSideLeft;
+				attackBand = kNativeAttackBandMid;
+				return true;
+			case 0x64:
+				attackSide = kNativeAttackSideLeft;
+				attackBand = kNativeAttackBandLower;
+				return true;
+			case 0x6e:
+				attackSide = kNativeAttackSideRight;
+				attackBand = kNativeAttackBandUpper;
+				return true;
+			case 0x78:
+				attackSide = kNativeAttackSideRight;
+				attackBand = kNativeAttackBandMid;
+				return true;
+			case 0x82:
+				attackSide = kNativeAttackSideRight;
+				attackBand = kNativeAttackBandLower;
+				return true;
+			default:
+				return false;
+			}
+		};
+		auto findProjectileContactMonsterTarget = [&]() -> MonsterRecord * {
+			if (!playerState.entity)
+				return nullptr;
+
+			NativeAttackSide attackSide = kNativeAttackSideNone;
+			NativeAttackBand attackBand = kNativeAttackBandMid;
+			if (!resolveNativePlayerAttackGeometry(playerState.attackFirstFrame, attackSide, attackBand))
+				return nullptr;
+
+			const Common::Rect playerRect = playerState.entity->getScreenRect();
+			const int playerLeft = playerRect.left;
+			const int playerRight = playerRect.right;
+			const int playerCenterX = playerLeft + playerRect.width() / 2;
+			MonsterRecord *bestMonster = nullptr;
+			Entity *bestEntity = nullptr;
+			int bestLeadingEdge = attackSide == kNativeAttackSideLeft ? -0x7fffffff : 0x7fffffff;
+
+			for (MonsterRecord &monster : scene.state.roomMonsters) {
+				if (!monster.active || !monster.visible || monster.currentHitPoints <= 0)
+					continue;
+
+				Entity *entity = findSceneRuntimeEntity(monster.monsterName);
+				if (!entity || !entity->isVisible() ||
+						!doRuntimeEntityDepthExtentsOverlap(*playerState.entity, *entity)) {
+					continue;
+				}
+
+				const Common::Rect targetRect = entity->getScreenRect();
+				const int targetLeft = targetRect.left;
+				const int targetCenterX = targetLeft + targetRect.width() / 2;
+				bool matchesDirection = false;
+				if (attackSide == kNativeAttackSideLeft) {
+					if (attackBand == kNativeAttackBandMid)
+						matchesDirection = targetLeft < playerCenterX;
+					else
+						matchesDirection =
+							targetLeft < playerCenterX && targetCenterX > playerLeft - kNativeAttackSideWindow;
+					if (!matchesDirection || targetLeft <= bestLeadingEdge)
+						continue;
+				} else {
+					if (attackBand == kNativeAttackBandMid)
+						matchesDirection = playerCenterX < targetLeft;
+					else
+						matchesDirection =
+							playerCenterX < targetLeft && targetCenterX < playerRight + kNativeAttackSideWindow;
+					if (!matchesDirection || targetLeft >= bestLeadingEdge)
+						continue;
+				}
+
+				bestMonster = &monster;
+				bestEntity = entity;
+				bestLeadingEdge = targetLeft;
+			}
+
+			if (bestMonster && bestEntity) {
+				debugC(1, kDebugCombat,
+					"Harvester: combat player projectile contact fallback target='%s' attack_frame=%d player_rect=(%d,%d)-(%d,%d) target_rect=(%d,%d)-(%d,%d)",
+					bestMonster->monsterName.c_str(), playerState.attackFirstFrame,
+					playerRect.left, playerRect.top, playerRect.right, playerRect.bottom,
+					bestEntity->getScreenRect().left, bestEntity->getScreenRect().top,
+					bestEntity->getScreenRect().right, bestEntity->getScreenRect().bottom);
+			}
+
+			return bestMonster;
+		};
 		auto describeCombatTargetClass = [](int classId) -> const char * {
 		switch (classId) {
 		case kRuntimeEntityClassNpc:
@@ -3017,43 +3126,32 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		if (!playerState.entity)
 			return;
 
-		enum KeyboardAttackSide {
-			kKeyboardAttackSideNone,
-			kKeyboardAttackSideLeft,
-			kKeyboardAttackSideRight
-		};
-		enum KeyboardAttackBand {
-			kKeyboardAttackBandMid,
-			kKeyboardAttackBandUpper,
-			kKeyboardAttackBandLower
-		};
-
-		KeyboardAttackSide attackSide = kKeyboardAttackSideNone;
-		KeyboardAttackBand attackBand = kKeyboardAttackBandMid;
+		NativeAttackSide attackSide = kNativeAttackSideNone;
+		NativeAttackBand attackBand = kNativeAttackBandMid;
 		if (attackRight) {
-			attackSide = kKeyboardAttackSideRight;
-			attackBand = kKeyboardAttackBandMid;
+			attackSide = kNativeAttackSideRight;
+			attackBand = kNativeAttackBandMid;
 		} else if (attackLeft) {
-			attackSide = kKeyboardAttackSideLeft;
-			attackBand = kKeyboardAttackBandMid;
+			attackSide = kNativeAttackSideLeft;
+			attackBand = kNativeAttackBandMid;
 		} else if (attackUp) {
 			if (playerState.facing == 2) {
-				attackSide = kKeyboardAttackSideRight;
-				attackBand = kKeyboardAttackBandUpper;
+				attackSide = kNativeAttackSideRight;
+				attackBand = kNativeAttackBandUpper;
 			} else if (playerState.facing == 1) {
-				attackSide = kKeyboardAttackSideLeft;
-				attackBand = kKeyboardAttackBandUpper;
+				attackSide = kNativeAttackSideLeft;
+				attackBand = kNativeAttackBandUpper;
 			}
 		} else if (attackDown) {
 			if (playerState.facing == 2) {
-				attackSide = kKeyboardAttackSideRight;
-				attackBand = kKeyboardAttackBandLower;
+				attackSide = kNativeAttackSideRight;
+				attackBand = kNativeAttackBandLower;
 			} else if (playerState.facing == 1) {
-				attackSide = kKeyboardAttackSideLeft;
-				attackBand = kKeyboardAttackBandLower;
+				attackSide = kNativeAttackSideLeft;
+				attackBand = kNativeAttackBandLower;
 			}
 		}
-		if (attackSide == kKeyboardAttackSideNone) {
+		if (attackSide == kNativeAttackSideNone) {
 			debugC(1, kDebugCombat,
 				"Harvester: combat player keyboard target capture class='none' input=(L=%d R=%d U=%d D=%d) facing=%d reason='no native attack family'",
 				attackLeft, attackRight, attackUp, attackDown, playerState.facing);
@@ -3067,7 +3165,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		Entity *bestEntity = nullptr;
 		Common::String bestName;
 		int bestClassId = -1;
-		int bestLeadingEdge = attackSide == kKeyboardAttackSideLeft ? -0x7fffffff : 0x7fffffff;
+		int bestLeadingEdge = attackSide == kNativeAttackSideLeft ? -0x7fffffff : 0x7fffffff;
 
 		auto considerTarget = [&](Entity *targetEntity, const Common::String &targetName, int classId) {
 			if (!targetEntity || !targetEntity->isVisible() ||
@@ -3079,20 +3177,20 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			const int targetLeft = targetRect.left;
 			const int targetCenterX = targetLeft + (targetRect.width() / 2);
 			bool matchesDirection = false;
-			if (attackSide == kKeyboardAttackSideLeft) {
-				if (attackBand == kKeyboardAttackBandMid)
+			if (attackSide == kNativeAttackSideLeft) {
+				if (attackBand == kNativeAttackBandMid)
 					matchesDirection = targetLeft < playerCenterX;
 				else
 					matchesDirection =
-						targetLeft < playerCenterX && targetCenterX > playerLeft - kNativeKeyboardAttackSideWindow;
+						targetLeft < playerCenterX && targetCenterX > playerLeft - kNativeAttackSideWindow;
 				if (!matchesDirection || targetLeft <= bestLeadingEdge)
 					return;
 			} else {
-				if (attackBand == kKeyboardAttackBandMid)
+				if (attackBand == kNativeAttackBandMid)
 					matchesDirection = playerCenterX < targetLeft;
 				else
 					matchesDirection =
-						playerCenterX < targetLeft && targetCenterX < playerRight + kNativeKeyboardAttackSideWindow;
+						playerCenterX < targetLeft && targetCenterX < playerRight + kNativeAttackSideWindow;
 				if (!matchesDirection || targetLeft >= bestLeadingEdge)
 					return;
 			}
@@ -3262,10 +3360,17 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			monster = findRoomMonsterRecordByName(playerState.attackTargetName);
 		Entity *monsterEntity = monster ? findSceneRuntimeEntity(monster->monsterName) : nullptr;
 		if (!monster || !monsterEntity || !playerAttackCanReachTarget(monsterEntity, monster ? monster->engageDistance : 0)) {
-			if (!Player::isProjectileCombatLoadout(playerState.combatLoadout))
+			if (!Player::isProjectileCombatLoadout(playerState.combatLoadout)) {
 				monster = findOverlappingMonsterTarget();
-			else
+			} else if (playerState.attackTargetClassId < 0) {
+				monster = findProjectileContactMonsterTarget();
+				if (monster) {
+					playerState.attackTargetName = monster->monsterName;
+					playerState.attackTargetClassId = kRuntimeEntityClassMonster;
+				}
+			} else {
 				monster = nullptr;
+			}
 			monsterEntity = monster ? findSceneRuntimeEntity(monster->monsterName) : nullptr;
 		}
 		if (!monster || !monsterEntity) {


Commit: cf6a23d96cb6c03d6a213e9daee7c81cbfe3286e
    https://github.com/scummvm/scummvm/commit/cf6a23d96cb6c03d6a213e9daee7c81cbfe3286e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:46+02:00

Commit Message:
HARVESTER: Fix chess king combat placement

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h


diff --git a/TRACKER.md b/TRACKER.md
index bcdabf407bd..329f1b8e509 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 13, 2026, added a ScummVM `RoomSystem` projectile contact fallback based on Ghidra `update_actor_runtime_state`: when a projectile attack reaches its contact frame with no captured cursor target, it scans active visible room monsters using live Z-overlap and attack-direction geometry, assigns the selected monster as the attack target, and then applies the existing monster damage path.
+- On April 13, 2026, fixed CHESSKING combat startup rendering in ScummVM: `PC_GOTO_XZ` now maps the requested Z depth back to the room screen Y before placing the player, and monster combat animation selection now rejects fully transparent placeholder frame banks so `KING.ABM` does not choose blank attack/death ranges.
 
 ## Next Suggested Action
 
-- Re-test shotgun attacks against `BURNTCREATURE` in `SMOKING`: confirm empty-cursor shots log `combat player projectile contact fallback target='BURNTCREATURE'`, then `combat player monster hit`, and that shells still dry-fire once depleted.
+- Re-test the Chessmaster fight path: choose "No...", confirm `PC_GOTO_XZ applied` places the player near CHESSKING's depth line, monster attacks use drawable frame ranges `80..89` or `110..119` instead of blank `90..109`/`120..139`, and projectile death uses a visible death bank rather than corpse frame `295`.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 0ae8f9964c6..a1b9859f09d 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -583,6 +583,11 @@ static bool runtimeEntityHasFrameRange(const Entity &entity, int firstFrame, int
 	return (uint)lastFrame < entity.getFrameCount();
 }
 
+static bool runtimeEntityHasDrawableFrameRange(const Entity &entity, int firstFrame, int lastFrame) {
+	return runtimeEntityHasFrameRange(entity, firstFrame, lastFrame) &&
+		entity.hasOpaqueFramesInRange(firstFrame, lastFrame);
+}
+
 static bool resolveMonsterAttackAnimationRange(HarvesterEngine &engine,
 		const Entity &entity, int actorCenterX, int targetCenterX,
 		RoomAttackAnimationRange &range) {
@@ -604,7 +609,7 @@ static bool resolveMonsterAttackAnimationRange(HarvesterEngine &engine,
 	int availableCandidateIndices[3];
 	int availableCandidateCount = 0;
 	for (int i = 0; i < 3; ++i) {
-		if (!runtimeEntityHasFrameRange(entity, candidates[i].firstFrame, candidates[i].lastFrame))
+		if (!runtimeEntityHasDrawableFrameRange(entity, candidates[i].firstFrame, candidates[i].lastFrame))
 			continue;
 		availableCandidateIndices[availableCandidateCount++] = i;
 	}
@@ -636,7 +641,7 @@ static bool resolveMonsterDeathAnimationRange(const Entity &entity, int facing,
 	const RoomDeathAnimationRange fallbackProjectile = preferLeftBank ? rightProjectile : leftProjectile;
 
 	auto chooseIfAvailable = [&](const RoomDeathAnimationRange &candidate) {
-		if (!runtimeEntityHasFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
+		if (!runtimeEntityHasDrawableFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
 			return false;
 		range = candidate;
 		return true;
@@ -786,7 +791,7 @@ static bool resolveRoomMonsterHitAnimationRange(const Entity &entity,
 		return false;
 	}
 
-	if (!runtimeEntityHasFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
+	if (!runtimeEntityHasDrawableFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
 		return false;
 
 	range = candidate;
@@ -2136,28 +2141,16 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			playerState.turnTargetFacing = -1;
 			playerState.centerX = x;
 			playerState.z = (float)z;
+			playerState.bottomY = mapRoomDepthToScreenYForCombat(
+				scene.state, playerState.z, playerState.bottomY);
 			Common::Rect screenRect;
 			bool hasScreenRect = false;
 			float depthScale = 0.0f;
 			if (playerState.entity) {
-				int width = 0;
-				int height = 0;
-				int xOffset = 0;
-				int yOffset = 0;
-				if (playerState.entity->getCurrentFrameMetrics(width, height, xOffset, yOffset)) {
-					const int preservedScreenY = playerState.entity->getY();
-					// Native PC_GOTO_XZ calls set_entity_screen_position directly:
-					// update X and Z, but preserve screen Y and the current scale.
-					playerState.entity->setAnchorMode(kRuntimeEntityAnchorTopLeft);
-					playerState.entity->setPosition(
-						x - xOffset - width / 2, preservedScreenY, playerState.z);
-					playerState.bottomY = preservedScreenY + height + yOffset;
-					if (entityManager)
-						entityManager->reinsertSceneEntity(playerState.entity);
-				} else {
-					(void)applyRoomActorPlacement(scene.state, *playerState.entity,
-						playerState.centerX, playerState.bottomY, playerState.z);
-				}
+				(void)applyRoomActorPlacement(scene.state, *playerState.entity,
+					playerState.centerX, playerState.bottomY, playerState.z);
+				if (entityManager)
+					entityManager->reinsertSceneEntity(playerState.entity);
 				screenRect = playerState.entity->getScreenRect();
 				hasScreenRect = true;
 				depthScale = playerState.entity->getDepthScale();
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index 966895f6bcf..d1f009975ca 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -340,6 +340,29 @@ bool Entity::getCurrentFrameMetrics(int &width, int &height, int &xOffset, int &
 	return true;
 }
 
+bool Entity::hasOpaqueFramesInRange(int firstFrame, int lastFrame) const {
+	const Common::Array<AbmFrame> &frames = !_baseFrames.empty() ? _baseFrames : _frames;
+	if (frames.empty() || firstFrame < 0 || lastFrame < firstFrame ||
+			(uint)lastFrame >= frames.size()) {
+		return false;
+	}
+
+	for (int frameIndex = firstFrame; frameIndex <= lastFrame; ++frameIndex) {
+		const AbmFrame &frame = frames[(uint)frameIndex];
+		bool hasOpaquePixel = false;
+		for (uint i = 0; i < frame.pixels.size(); ++i) {
+			if (frame.pixels[i] != kTransparentPaletteIndex) {
+				hasOpaquePixel = true;
+				break;
+			}
+		}
+		if (!hasOpaquePixel)
+			return false;
+	}
+
+	return true;
+}
+
 void Entity::setDepthScale(float scale) {
 	if (_frames.empty())
 		return;
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index fbf65fab6d5..47c1305a311 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -103,6 +103,7 @@ public:
 	int getBoundsWidth() const { return _boundsWidth; }
 	int getBoundsHeight() const { return _boundsHeight; }
 	bool getCurrentFrameMetrics(int &width, int &height, int &xOffset, int &yOffset) const;
+	bool hasOpaqueFramesInRange(int firstFrame, int lastFrame) const;
 	void setDepthScale(float scale);
 	float getDepthScale() const { return _depthScale; }
 	Common::Rect getScreenRect() const;


Commit: 4b0e0a5a3580d51372ecf57cb6058b16dda57ac8
    https://github.com/scummvm/scummvm/commit/4b0e0a5a3580d51372ecf57cb6058b16dda57ac8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:47+02:00

Commit Message:
HARVESTER: Add timer debug overlay

Changed paths:
    TRACKER.md
    engines/harvester/console.cpp
    engines/harvester/console.h
    engines/harvester/flow.cpp
    engines/harvester/harvester.cpp
    engines/harvester/harvester.h


diff --git a/TRACKER.md b/TRACKER.md
index 329f1b8e509..8c182cbfa3e 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 13, 2026, fixed CHESSKING combat startup rendering in ScummVM: `PC_GOTO_XZ` now maps the requested Z depth back to the room screen Y before placing the player, and monster combat animation selection now rejects fully transparent placeholder frame banks so `KING.ABM` does not choose blank attack/death ranges.
+- On April 13, 2026, added the Harvester `DEBUG_TIMERS` console command. When enabled, the room scene overlay lists enabled timer entities near the middle-left of the screen with timer name, starting value, current value, and expiration action tag as white text on black backgrounds.
 
 ## Next Suggested Action
 
-- Re-test the Chessmaster fight path: choose "No...", confirm `PC_GOTO_XZ applied` places the player near CHESSKING's depth line, monster attacks use drawable frame ranges `80..89` or `110..119` instead of blank `90..109`/`120..139`, and projectile death uses a visible death bank rather than corpse frame `295`.
+- Run Harvester, enter a room with active timers, toggle `DEBUG_TIMERS`, and verify multiple timers stack vertically with live countdown values and the expected action tags.
diff --git a/engines/harvester/console.cpp b/engines/harvester/console.cpp
index 7ac394b0bba..e70946b77be 100644
--- a/engines/harvester/console.cpp
+++ b/engines/harvester/console.cpp
@@ -374,6 +374,7 @@ Console::Console() : GUI::Debugger() {
 	registerCmd("about", WRAP_METHOD(Console, Cmd_about));
 	registerCmd("DEBUG_COMBAT", WRAP_METHOD(Console, Cmd_debugCombat));
 	registerCmd("DEBUG_PATHFINDING", WRAP_METHOD(Console, Cmd_debugPathfinding));
+	registerCmd("DEBUG_TIMERS", WRAP_METHOD(Console, Cmd_debugTimers));
 	registerCmd("DEBUG_ACTIONS", WRAP_METHOD(Console, Cmd_debugActions));
 	registerCmd("DEBUG_COMMAND", WRAP_METHOD(Console, Cmd_debugCommand));
 	registerCmd("DEBUG_ROOM", WRAP_METHOD(Console, Cmd_debugRoom));
@@ -436,6 +437,22 @@ bool Console::Cmd_debugPathfinding(int argc, const char **argv) {
 	return true;
 }
 
+bool Console::Cmd_debugTimers(int argc, const char **argv) {
+	if (argc != 1) {
+		debugPrintf("Usage: DEBUG_TIMERS\n");
+		return true;
+	}
+
+	if (!g_engine) {
+		debugPrintf("Harvester engine is not active\n");
+		return true;
+	}
+
+	const bool enabled = g_engine->toggleTimerDebugEnabled();
+	debugPrintf("Timer debug overlay %s\n", enabled ? "enabled" : "disabled");
+	return true;
+}
+
 bool Console::Cmd_debugActions(int argc, const char **argv) {
 	if (argc != 1) {
 		debugPrintf("Usage: DEBUG_ACTIONS\n");
diff --git a/engines/harvester/console.h b/engines/harvester/console.h
index 3bb7d333430..54fd0682548 100644
--- a/engines/harvester/console.h
+++ b/engines/harvester/console.h
@@ -33,6 +33,7 @@ private:
 	bool Cmd_debugCombat(int argc, const char **argv);
 	bool Cmd_debugRoom(int argc, const char **argv);
 	bool Cmd_debugPathfinding(int argc, const char **argv);
+	bool Cmd_debugTimers(int argc, const char **argv);
 	bool Cmd_debugActions(int argc, const char **argv);
 	bool Cmd_debugCommand(int argc, const char **argv);
 	bool Cmd_gotoRoom(int argc, const char **argv);
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 9dc46c9f11a..1189243c087 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -96,6 +96,8 @@ static const int kRoomDebugLabelPaddingX = 3;
 static const int kRoomDebugLabelPaddingY = 2;
 static const int kRoomDebugRoomNameX = 4;
 static const int kRoomDebugRoomNameY = 4;
+static const int kTimerDebugOverlayXDivisor = 8;
+static const int kTimerDebugOverlayLineSpacing = 1;
 
 static const int kCursorSequenceWalk = 0;
 static const int kCursorSequenceExamine = 1;
@@ -360,6 +362,51 @@ static void drawPathfindingDebugOverlay(HarvesterEngine &engine, Graphics::Scree
 			anim.animName, movementBounds, green);
 }
 
+static Common::String resolveTimerDebugLabel(const TimerRecord &timer, const Entity &entity) {
+	const Common::String actionTag = timer.arg2.empty() ? Common::String("<none>") : timer.arg2;
+
+	return Common::String::format("%s start=%d current=%d action=%s",
+		timer.timerName.c_str(), entity.getTimerInitialValue(),
+		entity.getTimerCurrentValue(), actionTag.c_str());
+}
+
+static void drawTimerDebugOverlay(HarvesterEngine &engine, Graphics::Screen &screen,
+		const RoomSceneResources &scene) {
+	if (!engine.isTimerDebugEnabled())
+		return;
+
+	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
+	EntityManager *entityManager = engine.getRuntimeEntities();
+	if (!font || !entityManager)
+		return;
+
+	Common::Array<Common::String> labels;
+	for (const TimerRecord &timer : scene.state.roomTimers) {
+		const Entity *entity = entityManager->findSceneEntityByName(timer.timerName);
+		if (!entity || entity->getClassId() != kRuntimeEntityClassTimer || !entity->isTimerEnabled())
+			continue;
+
+		labels.push_back(resolveTimerDebugLabel(timer, *entity));
+	}
+	if (labels.empty())
+		return;
+
+	byte displayPalette[256 * 3];
+	screen.getPalette(displayPalette);
+	const byte black = findNearestPaletteColor(displayPalette, 0x00, 0x00, 0x00);
+	const byte white = findNearestPaletteColor(displayPalette, 0xff, 0xff, 0xff);
+
+	const int labelHeight = font->getFontHeight() + kRoomDebugLabelPaddingY * 2;
+	const int lineHeight = labelHeight + kTimerDebugOverlayLineSpacing;
+	const int totalHeight = (int)labels.size() * labelHeight +
+		(int)(labels.size() - 1) * kTimerDebugOverlayLineSpacing;
+	const int x = screen.w / kTimerDebugOverlayXDivisor;
+	const int y = CLIP<int>((screen.h - totalHeight) / 2, 0, MAX(0, screen.h - totalHeight));
+
+	for (uint i = 0; i < labels.size(); ++i)
+		drawRoomDebugLabel(screen, *font, labels[i], x, y + (int)i * lineHeight, white, black);
+}
+
 static int clampTownMapPanelIndex(int panelIndex) {
 	return CLIP<int>(panelIndex, 0, ARRAYSIZE(kTownMapBitmapPaths) - 1);
 }
@@ -789,6 +836,7 @@ void drawRoomScene(HarvesterEngine &engine, Graphics::Screen &screen, const Room
 	drawRoomDebugOverlay(engine, screen, scene);
 	drawCombatDebugOverlay(engine, screen, scene);
 	drawPathfindingDebugOverlay(engine, screen, scene);
+	drawTimerDebugOverlay(engine, screen, scene);
 }
 
 const ObjectRecord *findSceneObjectByName(const Common::Array<ObjectRecord> &objects,
diff --git a/engines/harvester/harvester.cpp b/engines/harvester/harvester.cpp
index aa296b2fa7c..4cf399cd993 100644
--- a/engines/harvester/harvester.cpp
+++ b/engines/harvester/harvester.cpp
@@ -266,6 +266,11 @@ bool HarvesterEngine::togglePathfindingDebugEnabled() {
 	return _pathfindingDebugEnabled;
 }
 
+bool HarvesterEngine::toggleTimerDebugEnabled() {
+	_timerDebugEnabled = !_timerDebugEnabled;
+	return _timerDebugEnabled;
+}
+
 bool HarvesterEngine::requestDebugCommand(const CommandRecord &command) {
 	if (!_activeFlow || !_script || !_currentSaveRoomState.valid)
 		return false;
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 56bc86daa2c..997dad3001a 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -118,6 +118,8 @@ public:
 	bool toggleRoomDebugEnabled();
 	bool isPathfindingDebugEnabled() const { return _pathfindingDebugEnabled; }
 	bool togglePathfindingDebugEnabled();
+	bool isTimerDebugEnabled() const { return _timerDebugEnabled; }
+	bool toggleTimerDebugEnabled();
 	bool requestDebugCommand(const CommandRecord &command);
 	bool requestDebugRoomChange(const Common::String &roomName);
 	void captureCurrentSaveRoomState(const Common::String &entranceName,
@@ -159,6 +161,7 @@ private:
 	bool _combatDebugEnabled = false;
 	bool _roomDebugEnabled = false;
 	bool _pathfindingDebugEnabled = false;
+	bool _timerDebugEnabled = false;
 	Common::String _emptyMusicPath;
 };
 


Commit: bbde52a4f09b452e2c1be391ba0a46acd70b524a
    https://github.com/scummvm/scummvm/commit/bbde52a4f09b452e2c1be391ba0a46acd70b524a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:47+02:00

Commit Message:
HARVESTER: Match native object delete materialization

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/flow.cpp
    engines/harvester/script.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 6b5a6a58be1..740bee45099 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -248,7 +248,7 @@
   - Before rebuilding the wait transition, `room_setup` uses `ramp_palette_brightness` to fade the outgoing palette down to black when a current room palette is active; only the no-current-room startup case skips directly to a black palette upload.
   - It then reloads `WAIT.PAL` and `WAIT.ABM`, hides the cursor entity, flushes the wait transition while the cursor is hidden, re-shows the cursor, uploads the wait palette, builds the room entities, and only after that uploads the room palette from `g_current_palette_buffer`.
   - Once the wait transition is flushed, `room_setup` rebuilds the room render list in this order: matching enabled regions, room timers, visible object records whose `current_owner_or_room` matches the room, then room `ANIM` records whose `active` or `visible` state is set.
-  - Decoding `HARVEST.SCR` confirms that room `OBJECT` records are not all named sprites. The data uses unnamed `OBJECT` records as blocker rectangles in many rooms, and it also uses hidden no-sprite `OBJECT` records with action tags as live hotspots. Concrete examples include the four anonymous `SERGEANTRM` `{// BLOCKS }` entries plus hidden action hotspots such as `PCHOUSE / PC_WINDOW` and `MR_JOHNS / MANHOLE`.
+  - Decoding `HARVEST.SCR` confirms that room `OBJECT` records are not all named sprites. The data uses unnamed visible `OBJECT` records as blocker rectangles in many rooms, and visible no-sprite `OBJECT` records as live hotspots. Hidden no-sprite action hotspots such as `PCHOUSE / PC_WINDOW` and `MR_JOHNS / MANHOLE` are not materialized until an `ADD` opcode sets their `visible` byte.
   - Rechecking the live CD3 handoff against `handle_talk_to_sergeant`, `prompt_for_cdrom_disc_change`, `run_harvester_main_loop`, and the decoded disc-specific `HARVEST.SCR` changed the earlier conclusion about the Sergeant remains rebuild target. Native Sergeant dialogue still ends with `C076.FST` plus `prompt_for_cdrom_disc_change(3, 0)` and no explicit `CHANGE_ROOM`, but the disc-3 branch in `run_harvester_main_loop` then calls `reload_town_world_from_script(); room_setup(g_room_setup_target_name_buffer);`. The same Ghidra xrefs show `g_room_setup_target_name_buffer` is initialized to `START` at boot and is not rewritten by ordinary room transitions, so the post-prompt CD3 rebuild re-enters the disc-3 `START` entrance rather than the pre-swap Sergeant entrance. Decoding `CD3/HARVEST.SCR` then shows that `START` resolves to `RECEPTION`, whose room-enter chain owns the lodge fall / reception sequence.
   - Rechecking the live script against the ScummVM room/map resolver narrows the next authored step after that rebuild: `EXIT_LODGE_2` issues `CHANGE_ROOM "LODGE_2_MAP"`, and `LODGE_2_MAP` is a `MAP_ENTRANCE`, so the authored follow-up is the town-map selector handoff rather than an automatic jump into a deeper lodge interior.
   - On the player-present branch it then re-adds the stored live player actor from `DAT_000d5bd4`, places it from `g_current_room_entrance`, and seeds exact facing frames `0x3b`, `0x0e`, `0x2c`, and `0x28` for entrance-facing values `0`, `1`, `2`, and `3`.
diff --git a/TRACKER.md b/TRACKER.md
index 8c182cbfa3e..efc9750a6fe 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 13, 2026, added the Harvester `DEBUG_TIMERS` console command. When enabled, the room scene overlay lists enabled timer entities near the middle-left of the screen with timer name, starting value, current value, and expiration action tag as white text on black backgrounds.
+- On April 13, 2026, verified native `ADD` / `DELETE` object visibility behavior in `HARVEST.LE` via Ghidra (`dispatch_room_event_actions`, `set_object_visibility_for_owner_or_room`, and `room_setup`) and patched ScummVM room materialization so hidden/deleted room objects remain in script runtime state but do not re-enter `RoomSetupState` or the live scene until an `ADD` makes them visible again.
 
 ## Next Suggested Action
 
-- Run Harvester, enter a room with active timers, toggle `DEBUG_TIMERS`, and verify multiple timers stack vertically with live countdown values and the expected action tags.
+- Re-run the `EYEHALL` combat case, kill `BIG_EYE`, and confirm `EYEDOOR2 DELETE EYEHALL BIGEYE` leaves `BIGEYE` out of the materialized room object list and scene after the runtime refresh while `EYE_MAIN` remains enabled.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 1189243c087..1d5edc63442 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -759,13 +759,7 @@ static int resolveSceneObjectClass(const RoomSetupState &state,
 }
 
 static bool shouldQueueSceneObject(const ObjectRecord &object) {
-	if (object.visible)
-		return true;
-
-	// Hidden no-sprite hotspots still drive room actions in the authored script data.
-	return resolveSceneObjectSpritePath(object).empty() &&
-		!getRoomObjectHotspotBounds(object).isEmpty() &&
-		(object.operatable || !object.actionTag.empty());
+	return object.visible;
 }
 
 bool shouldDisplaySceneAnimation(const AnimRecord &anim) {
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index dc4270f12d3..cbfd5f1014e 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -2681,7 +2681,7 @@ bool Script::buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord
 		state.playerFacing = entrance->facing;
 	}
 	for (const ObjectRecord &object : _currentObjects) {
-		if (object.currentOwnerOrRoom.equalsIgnoreCase(room.roomName))
+		if (object.visible && object.currentOwnerOrRoom.equalsIgnoreCase(room.roomName))
 			state.roomObjects.push_back(object);
 	}
 	if (!state.hasEntrance) {


Commit: c4b568bfd63d345cae383009604c79cfe552e2c2
    https://github.com/scummvm/scummvm/commit/c4b568bfd63d345cae383009604c79cfe552e2c2
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:47+02:00

Commit Message:
HARVESTER: Preserve global timers across rooms

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/flow.cpp
    engines/harvester/room.cpp
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 740bee45099..187e8ab7f4d 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -248,6 +248,8 @@
   - Before rebuilding the wait transition, `room_setup` uses `ramp_palette_brightness` to fade the outgoing palette down to black when a current room palette is active; only the no-current-room startup case skips directly to a black palette upload.
   - It then reloads `WAIT.PAL` and `WAIT.ABM`, hides the cursor entity, flushes the wait transition while the cursor is hidden, re-shows the cursor, uploads the wait palette, builds the room entities, and only after that uploads the room palette from `g_current_palette_buffer`.
   - Once the wait transition is flushed, `room_setup` rebuilds the room render list in this order: matching enabled regions, room timers, visible object records whose `current_owner_or_room` matches the room, then room `ANIM` records whose `active` or `visible` state is set.
+  - Timer setup walks the global `g_timer_records` list but only materializes a class `0x17` timer entity when `TimerRecord.arg1` matches the resolved room name. It also skips spawning when a live entity with the same timer name already exists.
+  - Cross-room timer persistence is carried by live class `0x17` entities, not by `room_setup` selecting global timers in every room. `destroy_entity_list` at `0x5c9e0` preserves a timer entity instead of removing/freeing it when the entity global byte copied from `TimerRecord.global` is set.
   - Decoding `HARVEST.SCR` confirms that room `OBJECT` records are not all named sprites. The data uses unnamed visible `OBJECT` records as blocker rectangles in many rooms, and visible no-sprite `OBJECT` records as live hotspots. Hidden no-sprite action hotspots such as `PCHOUSE / PC_WINDOW` and `MR_JOHNS / MANHOLE` are not materialized until an `ADD` opcode sets their `visible` byte.
   - Rechecking the live CD3 handoff against `handle_talk_to_sergeant`, `prompt_for_cdrom_disc_change`, `run_harvester_main_loop`, and the decoded disc-specific `HARVEST.SCR` changed the earlier conclusion about the Sergeant remains rebuild target. Native Sergeant dialogue still ends with `C076.FST` plus `prompt_for_cdrom_disc_change(3, 0)` and no explicit `CHANGE_ROOM`, but the disc-3 branch in `run_harvester_main_loop` then calls `reload_town_world_from_script(); room_setup(g_room_setup_target_name_buffer);`. The same Ghidra xrefs show `g_room_setup_target_name_buffer` is initialized to `START` at boot and is not rewritten by ordinary room transitions, so the post-prompt CD3 rebuild re-enters the disc-3 `START` entrance rather than the pre-swap Sergeant entrance. Decoding `CD3/HARVEST.SCR` then shows that `START` resolves to `RECEPTION`, whose room-enter chain owns the lodge fall / reception sequence.
   - Rechecking the live script against the ScummVM room/map resolver narrows the next authored step after that rebuild: `EXIT_LODGE_2` issues `CHANGE_ROOM "LODGE_2_MAP"`, and `LODGE_2_MAP` is a `MAP_ENTRANCE`, so the authored follow-up is the town-map selector handoff rather than an automatic jump into a deeper lodge interior.
@@ -276,6 +278,7 @@
   - At the top of each gameplay iteration it checks for a queued handoff, clears any selected carried object / interaction text state that would leak across the boundary, and then calls `room_setup(g_pending_room_name)`.
   - The same loop also owns the live room right-click attack selector when no carried item is active and the player is alive.
   - When a live class `0x17` timer entity expires, the same loop resolves the backing `TimerRecord` by `timer_name` and dispatches `dispatch_room_event_actions(timer->arg2)`; the timer name is only the lookup key, not the action tag.
+  - On expiry, one-shot timer entities clear their live enabled byte and mirror that disabled state back into the matching `TimerRecord`; looping timers reseed their next-fire tick from the current centisecond clock.
   - It sets `DAT_000d61d0 = 3`, compares cursor X against the player midpoint, and then compares cursor Y against `player_bottom - depth_scale * 144.44` and `player_bottom - depth_scale * 75.36`.
   - Those thresholds choose the six directional attack states `0x18/0x17/0x16` on the left side and `0x1b/0x1a/0x19` on the right side.
   - `update_actor_runtime_state` maps those states to frame banks `0x50..0x59`, `0x5a..0x63`, `0x64..0x6d`, `0x6e..0x77`, `0x78..0x81`, and `0x82..0x8b`.
@@ -289,6 +292,8 @@
   - Object, hotspot, overlay, and room actor visibility or active-state changes
   - Combat/stat side effects such as damage-type selection and player stat increments
   - Save-game handoff through the `SAVE_GAME` marker
+- `SET_TIMER` and `KILL_TIMER` first resolve a live class `0x17` timer entity by name. If no live timer entity is present, native prints `WARNING: Timer %s not in list` and does not update `g_timer_records`.
+  - When the live entity is present, the dispatcher toggles the entity enabled byte and mirrors that enabled state into the matching `TimerRecord`.
 
 ### World Record Parsers
 
@@ -332,6 +337,8 @@
   - `KILL_NPC -> 0x0c`
   - `KILL_PC -> 0x0d`
   - `MONSTERFY -> 0x0e`
+  - `SET_TIMER -> 0x17`
+  - `KILL_TIMER -> 0x24`
   - `PAUSE_PC -> 0x25`
   - `RESUME_PC -> 0x26`
 - `parse_useitem_record` at `0x602c0`
@@ -947,6 +954,7 @@
   - `initial_value`, `current_value`, `timer_name`, `arg1`, `arg2`, `enabled`, `next`
   - parser booleans are `loop` and `global`
   - `room_setup` treats `arg1` as the room/scope key for room-local timers and only materializes timer entities when it matches the resolved room name.
+  - `global` is copied into the live timer entity and controls whether `destroy_entity_list` preserves that entity across room transitions. It does not make `room_setup` spawn the timer in unrelated rooms.
   - `arg2` is the action-tag target executed when the timer expires; native main-loop expiry first resolves the record by `timer_name`, then dispatches `dispatch_room_event_actions(arg2)`.
   - `initialize_timer_entity_common_fields` at `0x4ab10` zeroes the lightweight timer-entity header before `spawn_timer_entity_from_record` installs the countdown fields and adds the entity to `g_render_entity_list`.
 - `UseItemRecord` is stable:
diff --git a/TRACKER.md b/TRACKER.md
index efc9750a6fe..3ba14459517 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 13, 2026, verified native `ADD` / `DELETE` object visibility behavior in `HARVEST.LE` via Ghidra (`dispatch_room_event_actions`, `set_object_visibility_for_owner_or_room`, and `room_setup`) and patched ScummVM room materialization so hidden/deleted room objects remain in script runtime state but do not re-enter `RoomSetupState` or the live scene until an `ADD` makes them visible again.
+- On April 13, 2026, verified native `SET_TIMER` / `KILL_TIMER` and class `0x17` timer persistence behavior in `HARVEST.LE`: `g_timer_records` is the global timer record list, `room_setup` only spawns timers whose `arg1` matches the resolved room, and global timer persistence is implemented by `destroy_entity_list` preserving live timer entities whose copied global flag is set. Patched ScummVM to preserve live global timer entities across room scene clears, avoid duplicate timer spawns on return to the owning room, and make timer debug/runtime sync paths include preserved live timers.
 
 ## Next Suggested Action
 
-- Re-run the `EYEHALL` combat case, kill `BIG_EYE`, and confirm `EYEDOOR2 DELETE EYEHALL BIGEYE` leaves `BIGEYE` out of the materialized room object list and scene after the runtime refresh while `EYE_MAIN` remains enabled.
+- Re-run a script path that starts a global timer, leave its owning room, enable `DEBUG_TIMERS`, and confirm the live timer remains visible/counting across the transition and still fires its `TimerRecord.arg2` action.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 1d5edc63442..094331f61c5 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -371,22 +371,24 @@ static Common::String resolveTimerDebugLabel(const TimerRecord &timer, const Ent
 }
 
 static void drawTimerDebugOverlay(HarvesterEngine &engine, Graphics::Screen &screen,
-		const RoomSceneResources &scene) {
+		const RoomSceneResources &) {
 	if (!engine.isTimerDebugEnabled())
 		return;
 
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kGUIFont);
 	EntityManager *entityManager = engine.getRuntimeEntities();
-	if (!font || !entityManager)
+	Script *script = engine.getScript();
+	if (!font || !entityManager || !script)
 		return;
 
 	Common::Array<Common::String> labels;
-	for (const TimerRecord &timer : scene.state.roomTimers) {
+	for (const TimerRecord &timer : script->getTimers()) {
 		const Entity *entity = entityManager->findSceneEntityByName(timer.timerName);
 		if (!entity || entity->getClassId() != kRuntimeEntityClassTimer || !entity->isTimerEnabled())
 			continue;
 
-		labels.push_back(resolveTimerDebugLabel(timer, *entity));
+		const TimerRecord *runtimeTimer = script->findRuntimeTimerRecord(timer.timerName);
+		labels.push_back(resolveTimerDebugLabel(runtimeTimer ? *runtimeTimer : timer, *entity));
 	}
 	if (labels.empty())
 		return;
@@ -1944,7 +1946,7 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 		return false;
 
 	Entity *preservedPlayer = entityManager->detachSceneEntityByName(kPlayerActorEntityName);
-	entityManager->clearSceneEntities();
+	entityManager->clearSceneEntities(true);
 	for (const RegionRecord &region : state.roomRegions) {
 		const Common::Rect regionBounds = getRegionBounds(region);
 		if (regionBounds.isEmpty())
@@ -1973,6 +1975,16 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 			region.minZ, region.maxZ, region.desiredFacing, region.cursorEnabled, region.actionTag.c_str());
 	}
 	for (const TimerRecord &timer : state.roomTimers) {
+		Entity *existingTimer = entityManager->findSceneEntityByName(timer.timerName);
+		if (existingTimer && existingTimer->getClassId() == kRuntimeEntityClassTimer) {
+			debugC(1, kDebugRoom,
+				"Harvester: scene timer reused room='%s' timer='%s' current=%d initial=%d enabled=%d loop=%d global=%d",
+				state.roomName.c_str(), timer.timerName.c_str(), existingTimer->getTimerCurrentValue(),
+				existingTimer->getTimerInitialValue(), existingTimer->isTimerEnabled(),
+				existingTimer->isTimerLooping(), existingTimer->isTimerGlobal());
+			continue;
+		}
+
 		if (!entityManager->spawnSceneTimerEntity(timer.timerName,
 				timer.initialValue, timer.currentValue, timer.enabled, timer.looping, timer.global)) {
 			debug(1, "Harvester: unable to spawn room timer entity '%s'",
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index a1b9859f09d..85665366915 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1136,12 +1136,17 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			if (!entityManager)
 				return;
 
-			for (TimerRecord &timer : scene.state.roomTimers) {
-				Entity *entity = entityManager->findSceneEntityByName(timer.timerName);
-				if (!entity)
+			for (const TimerRecord &baseTimer : script->getTimers()) {
+				Entity *entity = entityManager->findSceneEntityByName(baseTimer.timerName);
+				if (!entity || entity->getClassId() != kRuntimeEntityClassTimer)
 					continue;
+
+				const TimerRecord *runtimeTimer = script->findRuntimeTimerRecord(baseTimer.timerName);
+				TimerRecord timer = runtimeTimer ? *runtimeTimer : baseTimer;
 				timer.currentValue = entity->getTimerCurrentValue();
 				timer.enabled = entity->isTimerEnabled();
+				timer.looping = entity->isTimerLooping();
+				timer.global = entity->isTimerGlobal();
 				(void)script->syncRuntimeTimerRecord(timer);
 			}
 		};
@@ -1585,6 +1590,39 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 			updatedState.audioCommands = entryAudioCommands;
 
+			if (entityManager) {
+				for (const TimerRecord &baseTimer : script->getTimers()) {
+					Entity *entity = entityManager->findSceneEntityByName(baseTimer.timerName);
+					if (!entity || entity->getClassId() != kRuntimeEntityClassTimer)
+						continue;
+
+					bool materializedInCurrentRoom = false;
+					for (const TimerRecord &updatedTimer : updatedState.roomTimers) {
+						if (updatedTimer.timerName.equalsIgnoreCase(baseTimer.timerName)) {
+							materializedInCurrentRoom = true;
+							break;
+						}
+					}
+					if (materializedInCurrentRoom)
+						continue;
+
+					const TimerRecord *runtimeTimer = script->findRuntimeTimerRecord(baseTimer.timerName);
+					if (!runtimeTimer || !runtimeTimer->global)
+						continue;
+					if (entity->getTimerInitialValue() == runtimeTimer->initialValue &&
+							entity->getTimerCurrentValue() == runtimeTimer->currentValue &&
+							entity->isTimerEnabled() == runtimeTimer->enabled &&
+							entity->isTimerLooping() == runtimeTimer->looping &&
+							entity->isTimerGlobal() == runtimeTimer->global) {
+						continue;
+					}
+
+					entity->configureTimerCountdown(runtimeTimer->initialValue,
+						runtimeTimer->currentValue, runtimeTimer->enabled,
+						runtimeTimer->looping, runtimeTimer->global);
+				}
+			}
+
 			RoomSceneResources updatedScene;
 			if (!loadRoomSceneResources(updatedState, *resources, updatedScene))
 				return false;
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index d1f009975ca..834a0d55867 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -750,10 +750,19 @@ void EntityManager::clear() {
 	_cursorEntity = nullptr;
 }
 
-void EntityManager::clearSceneEntities() {
-	for (Entity *entity : _sceneEntities)
+void EntityManager::clearSceneEntities(bool preserveGlobalTimers) {
+	Common::Array<Entity *> preservedEntities;
+	for (Entity *entity : _sceneEntities) {
+		if (preserveGlobalTimers &&
+				entity->getClassId() == kRuntimeEntityClassTimer &&
+				entity->isTimerGlobal()) {
+			preservedEntities.push_back(entity);
+			continue;
+		}
+
 		delete entity;
-	_sceneEntities.clear();
+	}
+	_sceneEntities = preservedEntities;
 	_expiredTimerNames.clear();
 	_timerPauseDepth = 0;
 }
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index 47c1305a311..d758ee49aca 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -183,7 +183,7 @@ public:
 	~EntityManager();
 
 	void clear();
-	void clearSceneEntities();
+	void clearSceneEntities(bool preserveGlobalTimers = false);
 	Entity *spawnAbmEntityFromResource(const Common::String &name, const Common::String &resourcePath,
 		int classId, const Common::Point &position, float z, int animationRate, bool looping, bool pingPong);
 	Entity *spawnBitmapEntityFromResource(const Common::String &name, const Common::String &resourcePath,
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index cbfd5f1014e..b0313b16140 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -2331,6 +2331,10 @@ const TimerRecord *Script::findRuntimeTimer(const Common::String &timerName) con
 	return nullptr;
 }
 
+const TimerRecord *Script::findRuntimeTimerRecord(const Common::String &timerName) const {
+	return findRuntimeTimer(timerName);
+}
+
 const NpcRecord *Script::findRuntimeNpcRecord(const Common::String &npcName) const {
 	return findRuntimeNpc(npcName);
 }
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 700a48af615..701e2e5217a 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -390,6 +390,7 @@ public:
 	const EntranceRecord *findEntranceRecord(const Common::String &entranceName) const;
 	const MapEntranceRecord *findMapEntranceRecord(const Common::String &entryName) const;
 	bool getFlagValue(const Common::String &flagName) const;
+	const TimerRecord *findRuntimeTimerRecord(const Common::String &timerName) const;
 	const NpcRecord *findRuntimeNpcRecord(const Common::String &npcName) const;
 	bool setRuntimeFlagValue(const Common::String &flagName, bool value);
 	bool resetRuntimeObjectToInitialState(const Common::String &objectName);


Commit: de0b23c9791357cf1bfc1030b4a31256600b8625
    https://github.com/scummvm/scummvm/commit/de0b23c9791357cf1bfc1030b4a31256600b8625
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:48+02:00

Commit Message:
HARVESTER: Avoid resetting active global timers

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/room.cpp
    engines/harvester/script.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 187e8ab7f4d..aa500862816 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -294,6 +294,7 @@
   - Save-game handoff through the `SAVE_GAME` marker
 - `SET_TIMER` and `KILL_TIMER` first resolve a live class `0x17` timer entity by name. If no live timer entity is present, native prints `WARNING: Timer %s not in list` and does not update `g_timer_records`.
   - When the live entity is present, the dispatcher toggles the entity enabled byte and mirrors that enabled state into the matching `TimerRecord`.
+  - The `SET_TIMER ... ON` branch only reseeds the live countdown when the live timer entity was disabled. Repeating `SET_TIMER ... ON` for an already-enabled global timer does not restart its countdown.
 
 ### World Record Parsers
 
diff --git a/TRACKER.md b/TRACKER.md
index 3ba14459517..f7c450f25e7 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 13, 2026, verified native `SET_TIMER` / `KILL_TIMER` and class `0x17` timer persistence behavior in `HARVEST.LE`: `g_timer_records` is the global timer record list, `room_setup` only spawns timers whose `arg1` matches the resolved room, and global timer persistence is implemented by `destroy_entity_list` preserving live timer entities whose copied global flag is set. Patched ScummVM to preserve live global timer entities across room scene clears, avoid duplicate timer spawns on return to the owning room, and make timer debug/runtime sync paths include preserved live timers.
+- On April 13, 2026, verified native repeated `SET_TIMER ... ON` behavior in `HARVEST.LE` and patched ScummVM so an already-enabled global timer keeps its live countdown when a room-entry command enables it again; preserved live global timers are no longer reconfigured only because the script record's saved `currentValue` is stale.
 
 ## Next Suggested Action
 
-- Re-run a script path that starts a global timer, leave its owning room, enable `DEBUG_TIMERS`, and confirm the live timer remains visible/counting across the transition and still fires its `TimerRecord.arg2` action.
+- Re-run the Dining/Mainhall acid timer path with `DEBUG_TIMERS`: start the global acid timers, leave and re-enter Mainhall, and confirm `ACID_TIMER2` / `ACID_TIMER3` keep counting instead of resetting.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 85665366915..1c113c98098 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1610,7 +1610,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					if (!runtimeTimer || !runtimeTimer->global)
 						continue;
 					if (entity->getTimerInitialValue() == runtimeTimer->initialValue &&
-							entity->getTimerCurrentValue() == runtimeTimer->currentValue &&
 							entity->isTimerEnabled() == runtimeTimer->enabled &&
 							entity->isTimerLooping() == runtimeTimer->looping &&
 							entity->isTimerGlobal() == runtimeTimer->global) {
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index b0313b16140..de2264d6d2e 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -2550,10 +2550,11 @@ bool Script::setRuntimeTimerEnabled(const Common::String &timerName, bool enable
 	if (!currentTimer)
 		return false;
 
-	const bool changed = currentTimer->enabled != enabled ||
-		(enabled && currentTimer->currentValue != currentTimer->initialValue);
+	const bool wasEnabled = currentTimer->enabled;
+	const bool changed = wasEnabled != enabled ||
+		(enabled && !wasEnabled && currentTimer->currentValue != currentTimer->initialValue);
 	currentTimer->enabled = enabled;
-	if (enabled)
+	if (enabled && !wasEnabled)
 		currentTimer->currentValue = currentTimer->initialValue;
 	return changed;
 }


Commit: e2ba03e2145b199c1f0b997ae199d0ec120a1912
    https://github.com/scummvm/scummvm/commit/e2ba03e2145b199c1f0b997ae199d0ec120a1912
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:48+02:00

Commit Message:
HARVESTER: Keep duplicate monster records distinct

Changed paths:
    TRACKER.md
    engines/harvester/monster.h
    engines/harvester/script.cpp


diff --git a/TRACKER.md b/TRACKER.md
index f7c450f25e7..f7b2f0a2428 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 13, 2026, verified native repeated `SET_TIMER ... ON` behavior in `HARVEST.LE` and patched ScummVM so an already-enabled global timer keeps its live countdown when a room-entry command enables it again; preserved live global timers are no longer reconfigured only because the script record's saved `currentValue` is stale.
+- On April 13, 2026, checked `HARVEST.LE` monster record handling in Ghidra and updated ScummVM Harvester monster runtime matching so parsed monster records keep a stable `recordIndex`; room-qualified monster sync no longer falls back to the first same-name record, preventing a dead duplicate like `WOLFIE` from contaminating another room's monster state.
 
 ## Next Suggested Action
 
-- Re-run the Dining/Mainhall acid timer path with `DEBUG_TIMERS`: start the global acid timers, leave and re-enter Mainhall, and confirm `ACID_TIMER2` / `ACID_TIMER3` keep counting instead of resetting.
+- Re-run the duplicate `WOLFIE` path with `--debugflags=room,combat` from a clean pre-kill state and confirm the DINING monster materializes with its own `hp=20/20`, `spawned=0`, and `runtimeState=-1` unless the DINING record itself has been killed.
diff --git a/engines/harvester/monster.h b/engines/harvester/monster.h
index 54ab7b98c5b..62f21dc06a4 100644
--- a/engines/harvester/monster.h
+++ b/engines/harvester/monster.h
@@ -57,6 +57,7 @@ struct MonsterRecord {
 	Common::String footstepSoundRight;
 	Common::String deathSound;
 	Common::String onDeathActionTag;
+	int recordIndex = -1;
 	bool active = false;
 	bool visible = false;
 	bool savedVisible = false;
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index de2264d6d2e..f3c7fb5eb40 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -241,7 +241,36 @@ static bool matchesMonsterIdentity(const MonsterRecord &candidate, const Monster
 	if (!candidate.monsterName.equalsIgnoreCase(monster.monsterName))
 		return false;
 
-	return monster.roomName.empty() || candidate.roomName.equalsIgnoreCase(monster.roomName);
+	if (candidate.recordIndex >= 0 && monster.recordIndex >= 0)
+		return candidate.recordIndex == monster.recordIndex;
+
+	if (!monster.roomName.empty())
+		return candidate.roomName.equalsIgnoreCase(monster.roomName);
+
+	return true;
+}
+
+static void restoreMonsterRecordIndices(const Common::Array<MonsterRecord> &baseMonsters,
+		Common::Array<MonsterRecord> &runtimeMonsters) {
+	for (uint i = 0; i < runtimeMonsters.size(); ++i) {
+		if (i < baseMonsters.size() &&
+				baseMonsters[i].monsterName.equalsIgnoreCase(runtimeMonsters[i].monsterName) &&
+				baseMonsters[i].roomName.equalsIgnoreCase(runtimeMonsters[i].roomName)) {
+			runtimeMonsters[i].recordIndex = baseMonsters[i].recordIndex;
+			continue;
+		}
+
+		runtimeMonsters[i].recordIndex = -1;
+		for (const MonsterRecord &baseMonster : baseMonsters) {
+			if (!baseMonster.monsterName.equalsIgnoreCase(runtimeMonsters[i].monsterName) ||
+					!baseMonster.roomName.equalsIgnoreCase(runtimeMonsters[i].roomName)) {
+				continue;
+			}
+
+			runtimeMonsters[i].recordIndex = baseMonster.recordIndex;
+			break;
+		}
+	}
 }
 
 static void syncStartupTimerRecord(Common::Serializer &s, TimerRecord &record) {
@@ -1214,8 +1243,10 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			monster.savedVisible = monster.visible;
 			if (monster.active)
 				monster.visible = true;
-			if (!monster.roomName.empty() && !monster.monsterName.empty() && !monster.modelPath.empty())
+			if (!monster.roomName.empty() && !monster.monsterName.empty() && !monster.modelPath.empty()) {
+				monster.recordIndex = (int)_monsters.size();
 				_monsters.push_back(monster);
+			}
 			return;
 		}
 
@@ -1632,6 +1663,8 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 	syncRecordArray(s, _currentRegions, syncStartupRegionRecord);
 	syncRecordArray(s, _currentNpcs, syncStartupNpcRecord);
 	syncRecordArray(s, _currentMonsters, syncStartupMonsterRecord);
+	if (s.isLoading())
+		restoreMonsterRecordIndices(_monsters, _currentMonsters);
 	s.syncAsSint32LE(_playerCurrentHitPoints);
 	// FIXME: Remove this pre-v2 save-layout gate after release; clean Harvester
 	// saves always carry timer state, combat loadout, and paused-state fields.
@@ -2242,7 +2275,7 @@ MonsterRecord *Script::findRuntimeMonster(const MonsterRecord &monster) {
 			continue;
 		if (matchesMonsterIdentity(candidate, monster))
 			return &candidate;
-		if (!fallback)
+		if (monster.roomName.empty() && monster.recordIndex < 0 && !fallback)
 			fallback = &candidate;
 	}
 
@@ -2295,7 +2328,7 @@ const MonsterRecord *Script::findRuntimeMonster(const MonsterRecord &monster) co
 			continue;
 		if (matchesMonsterIdentity(candidate, monster))
 			return &candidate;
-		if (!fallback)
+		if (monster.roomName.empty() && monster.recordIndex < 0 && !fallback)
 			fallback = &candidate;
 	}
 
@@ -2312,7 +2345,7 @@ const MonsterRecord *Script::findBaseMonster(const MonsterRecord &monster) const
 			continue;
 		if (matchesMonsterIdentity(candidate, monster))
 			return &candidate;
-		if (!fallback)
+		if (monster.roomName.empty() && monster.recordIndex < 0 && !fallback)
 			fallback = &candidate;
 	}
 


Commit: c3f76d362210f18682662ab312a792f09f372996
    https://github.com/scummvm/scummvm/commit/c3f76d362210f18682662ab312a792f09f372996
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:48+02:00

Commit Message:
HARVESTER: Preserve Big Eye death frame

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index f7b2f0a2428..05344c6dbbc 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 13, 2026, checked `HARVEST.LE` monster record handling in Ghidra and updated ScummVM Harvester monster runtime matching so parsed monster records keep a stable `recordIndex`; room-qualified monster sync no longer falls back to the first same-name record, preventing a dead duplicate like `WOLFIE` from contaminating another room's monster state.
+- On April 13, 2026, removed the temporary focused `BIGEYE` / `BIG_EYE` debug flag, lifecycle logging, and post-detach overlap audit while keeping the `BIG_EYE` / `EYEDOOR` terminal death-frame preservation fix.
 
 ## Next Suggested Action
 
-- Re-run the duplicate `WOLFIE` path with `--debugflags=room,combat` from a clean pre-kill state and confirm the DINING monster materializes with its own `hp=20/20`, `spawned=0`, and `runtimeState=-1` unless the DINING record itself has been killed.
+- Re-run the EYEHALL combat case with `--debugflags=room,scene,combat`, kill `BIG_EYE`, and confirm the death-complete line reports `preserve_runtime_actor=1` and that the final frame remains over the baked background while `BIGEYE` hotspot deletion still enables `EYE_MAIN`.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 1c113c98098..590a69eaca3 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -84,6 +84,12 @@ static const int kCombatDebugDamagePopupVerticalGap = 6;
 static const char *const kCdChangePromptPalettePath = "1:/GRAPHIC/PAL/CD1.PAL";
 static const char *const kExitCloseupPendingRoomChange = "__EXIT_CLOSEUP__";
 
+static bool shouldPreserveBigEyeDeathFrame(const NpcRecord &npc) {
+	return npc.npcName.equalsIgnoreCase("BIG_EYE") &&
+		npc.onDeathActionTag.equalsIgnoreCase("EYEDOOR") &&
+		npc.monsterfyTargetName.empty();
+}
+
 static int roundRoomCombatFloat(float value);
 
 struct RoomMonsterFacingAnimationRange {
@@ -1369,28 +1375,32 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			return true;
 		};
 		auto spawnSceneNpcEntityFromRecord = [&](const NpcRecord &npc) -> Entity * {
-			if (!entityManager || !shouldSpawnRoomNpcEntity(npc))
-				return nullptr;
-
-			// Native state 0x35 suppresses class-4 NPC corpses after the terminal death frame.
-			// If an older runtime/save preserved one, skip respawning it and let the room object
-			// or on-death action own the post-death presentation.
-			const bool hasSuppressedCorpseState = !npc.active &&
+			const bool shouldSpawnNpc = shouldSpawnRoomNpcEntity(npc);
+			const bool preservedCorpse = !npc.active &&
 				npc.runtimeSpawned &&
 				npc.deathDamageType != 0 &&
 				npc.runtimeState >= 0;
-			if (hasSuppressedCorpseState)
+			if (!entityManager || !shouldSpawnNpc)
 				return nullptr;
 
+			const int initialFrame = preservedCorpse ? npc.runtimeState : 0;
 			Entity *entity = entityManager->spawnSceneActorEntity(
-				npc.npcName, npc.modelPath, Common::Point(npc.posX, npc.posY), (float)npc.posZ, 0);
+				npc.npcName, npc.modelPath, Common::Point(npc.posX, npc.posY), (float)npc.posZ, initialFrame);
 			if (!entity)
 				return nullptr;
 
 			entity->setClassId(kRuntimeEntityClassNpc);
-			entity->setHitTestMode(kRuntimeEntityHitTestOpaquePixels);
-			entity->setAnimationFrameRange(0, MIN(entity->getLastFrame(), kRoomNpcAmbientLastFrame), true);
-			entity->setAnimationRate(npc.frameDelay > 0 ? npc.frameDelay : 0);
+			entity->setHitTestMode(preservedCorpse ? kRuntimeEntityHitTestNone : kRuntimeEntityHitTestOpaquePixels);
+			if (preservedCorpse) {
+				const int corpseFrame = MIN(entity->getLastFrame(), npc.runtimeState);
+				entity->setAnimationFrameRange(corpseFrame, corpseFrame, false);
+				entity->setAnimationRate(0);
+				entity->setCurrentFrame(corpseFrame);
+				entity->setAnimationEnabled(false);
+			} else {
+				entity->setAnimationFrameRange(0, MIN(entity->getLastFrame(), kRoomNpcAmbientLastFrame), true);
+				entity->setAnimationRate(npc.frameDelay > 0 ? npc.frameDelay : 0);
+			}
 			entity->setVisible(true);
 			if (!applyRoomNpcPlacement(*entity, npc)) {
 				removeSceneEntityByName(npc.npcName);
@@ -2951,23 +2961,35 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 	auto finalizeNpcDeathTransition = [&](NpcRecord &npc,
 			RoomNpcCombatState &combatState) -> Common::Error {
 		Script *script = _engine.getScript();
-		removeSceneEntityByName(npc.npcName);
+		Entity *npcEntity = findSceneRuntimeEntity(npc.npcName);
+		const bool preserveDeathFrame =
+			shouldPreserveBigEyeDeathFrame(npc) && combatState.deathLastFrame >= 0 && npcEntity;
+		if (preserveDeathFrame) {
+			npcEntity->setAnimationRate(0);
+			npcEntity->setAnimationFrameRange(combatState.deathLastFrame, combatState.deathLastFrame, false);
+			npcEntity->setCurrentFrame(combatState.deathLastFrame);
+			npcEntity->setAnimationEnabled(false);
+			npcEntity->setHitTestMode(kRuntimeEntityHitTestNone);
+			npcEntity->setVisible(true);
+		} else {
+			removeSceneEntityByName(npc.npcName);
+		}
 		npc.active = false;
-		npc.visible = false;
-		npc.savedVisible = false;
-		npc.runtimeSpawned = false;
-		npc.runtimeState = -1;
-		npc.deathOrMonsterfyFlag = true;
+		npc.visible = preserveDeathFrame;
+		npc.savedVisible = preserveDeathFrame;
+		npc.runtimeSpawned = preserveDeathFrame;
+		npc.runtimeState = preserveDeathFrame ? combatState.deathLastFrame : -1;
+		npc.deathOrMonsterfyFlag = !preserveDeathFrame;
 		if (combatState.deathDamageType != 0)
 			npc.deathDamageType = combatState.deathDamageType;
 		const bool runtimeChanged = script
 			? script->finalizeRuntimeNpcDeathOrMonsterfy(
-				npc.npcName, npc.deathDamageType, false, -1)
+				npc.npcName, npc.deathDamageType, preserveDeathFrame, npc.runtimeState)
 			: false;
 		debugC(1, kDebugCombat,
-			"Harvester: combat npc death complete target='%s' damage_type=%d last_frame=%d suppress_runtime_actor=1 monsterfy='%s' on_death='%s'",
+			"Harvester: combat npc death complete target='%s' damage_type=%d last_frame=%d preserve_runtime_actor=%d monsterfy='%s' on_death='%s'",
 			npc.npcName.c_str(), combatState.deathDamageType, combatState.deathLastFrame,
-			npc.monsterfyTargetName.c_str(), npc.onDeathActionTag.c_str());
+			preserveDeathFrame, npc.monsterfyTargetName.c_str(), npc.onDeathActionTag.c_str());
 		clearRoomNpcCombatState(combatState);
 
 		InteractionResult interaction;


Commit: 7ad436bb24ac46e907b1935afd9c9f384525f9a7
    https://github.com/scummvm/scummvm/commit/7ad436bb24ac46e907b1935afd9c9f384525f9a7
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:49+02:00

Commit Message:
HARVESTER: Avoid premature palette refresh after flags

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index aa500862816..9b29f8a90b9 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -292,6 +292,10 @@
   - Object, hotspot, overlay, and room actor visibility or active-state changes
   - Combat/stat side effects such as damage-type selection and player stat increments
   - Save-game handoff through the `SAVE_GAME` marker
+- Action dispatch ordering is confirmed for deferred text/movie chains:
+  - `SET_FLAG` mutates only the matching `FlagRecord.value`; it does not reread the current room palette, rebuild the room, or call a palette ramp by itself.
+  - `SHOW_TEXT` creates a text entity over the existing render list and repeatedly calls `flush_dirty_rects_to_screen` / `update_music_stream` until input dismissal.
+  - `CHANGE_LIGHTING NONE` clears the palette/screen before a following `GOFLIC`, and `GOFLIC` immediately calls `play_fst_sequence` without an intermediate same-room `room_setup`.
 - `SET_TIMER` and `KILL_TIMER` first resolve a live class `0x17` timer entity by name. If no live timer entity is present, native prints `WARNING: Timer %s not in list` and does not update `g_timer_records`.
   - When the live entity is present, the dispatcher toggles the entity enabled byte and mirrors that enabled state into the matching `TimerRecord`.
   - The `SET_TIMER ... ON` branch only reseeds the live countdown when the live timer entity was disabled. Repeating `SET_TIMER ... ON` for an already-enabled global timer does not restart its countdown.
diff --git a/TRACKER.md b/TRACKER.md
index 05344c6dbbc..eb350163ff0 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 13, 2026, removed the temporary focused `BIGEYE` / `BIG_EYE` debug flag, lifecycle logging, and post-detach overlap audit while keeping the `BIG_EYE` / `EYEDOOR` terminal death-frame preservation fix.
+- On April 13, 2026, confirmed in Ghidra that `dispatch_room_event_actions` applies `SET_FLAG` without rebuilding the room or rereading the palette; `SHOW_TEXT` draws over the existing room palette, `CHANGE_LIGHTING NONE` clears to black, and `GOFLIC` starts the FST without an intermediate same-room `room_setup`. Updated the engine to refresh the live room only for current-room visual mutations, not flag-only or off-room runtime mutations.
 
 ## Next Suggested Action
 
-- Re-run the EYEHALL combat case with `--debugflags=room,scene,combat`, kill `BIG_EYE`, and confirm the death-complete line reports `preserve_runtime_actor=1` and that the final frame remains over the baked background while `BIGEYE` hotspot deletion still enables `EYE_MAIN`.
+- Re-run the DNAEXT night-5 fire exit path with `--debugflags=room,scene,general` and confirm the `SET_FIRE_TEXT1` modal remains on the night/dim palette until `CHANGE_LIGHTING NONE` blacks out before `GRAPHIC/FST/OVERNITE.FST`.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 590a69eaca3..a5e7dbe5979 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -2464,7 +2464,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					else
 						engine.stopMusic();
 					didTransition = true;
-				} else if (interaction.mutatedRuntimeState) {
+				} else if (interaction.visualRuntimeStateChanged) {
 					if (!applyCurrentRoomRuntimeMutationsInPlaceFn() &&
 							!refreshCurrentSceneFn(true)) {
 						return Common::kReadingFailed;
@@ -2612,6 +2612,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					return Common::kReadingFailed;
 				}
 				roomEntryInteraction.mutatedRuntimeState = false;
+				roomEntryInteraction.visualRuntimeStateChanged = false;
 			}
 			hasPendingRoomEntryInteraction = hasRoomEntryInteraction(roomEntryInteraction);
 		}
@@ -2994,11 +2995,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 		InteractionResult interaction;
 		interaction.mutatedRuntimeState = runtimeChanged;
+		interaction.visualRuntimeStateChanged = runtimeChanged;
 		if (script && !npc.onDeathActionTag.empty()) {
 			InteractionResult deathInteraction;
 			if (script->executeActionTag(npc.onDeathActionTag, deathInteraction, true, npc.roomName)) {
 				interaction = deathInteraction;
 				interaction.mutatedRuntimeState = true;
+				interaction.visualRuntimeStateChanged = true;
 			}
 		}
 
@@ -3132,12 +3135,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		monster->visible = false;
 		InteractionResult interaction;
 		interaction.mutatedRuntimeState = true;
+		interaction.visualRuntimeStateChanged = true;
 		if (!monster->onDeathActionTag.empty()) {
 			InteractionResult deathInteraction;
 			if (script->executeActionTag(
 					monster->onDeathActionTag, deathInteraction, true, monster->roomName)) {
 				interaction = deathInteraction;
 				interaction.mutatedRuntimeState = true;
+				interaction.visualRuntimeStateChanged = true;
 			}
 		}
 
@@ -3489,12 +3494,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			monster->monsterName.c_str(), monster->onDeathActionTag.c_str());
 		InteractionResult interaction;
 		interaction.mutatedRuntimeState = true;
+		interaction.visualRuntimeStateChanged = true;
 		if (!monster->onDeathActionTag.empty()) {
 			InteractionResult deathInteraction;
 			if (script->executeActionTag(
 					monster->onDeathActionTag, deathInteraction, true, monster->roomName)) {
 				interaction = deathInteraction;
 				interaction.mutatedRuntimeState = true;
+				interaction.visualRuntimeStateChanged = true;
 			}
 		}
 
@@ -3577,12 +3584,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 					InteractionResult interaction;
 					interaction.mutatedRuntimeState = true;
+					interaction.visualRuntimeStateChanged = true;
 					if (script && !monster.onDeathActionTag.empty()) {
 						InteractionResult deathInteraction;
 						if (script->executeActionTag(
 								monster.onDeathActionTag, deathInteraction, true, monster.roomName)) {
 							interaction = deathInteraction;
 							interaction.mutatedRuntimeState = true;
+							interaction.visualRuntimeStateChanged = true;
 						}
 					}
 
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index f3c7fb5eb40..2109eb5af68 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1810,7 +1810,8 @@ bool Script::executeRoomEnterCommands(const Common::String &roomName,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
-		&result.mutatedRuntimeState, &result.requestCloseupExit);
+		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
+		&result.requestCloseupExit);
 	return true;
 }
 
@@ -1828,7 +1829,8 @@ bool Script::executeRoomExitCommands(const Common::String &roomName,
 		nullptr, nullptr, &result.cdChangeDisc, &result.dialogueNpcName, &result.dialogueContinuationTag,
 		&result.continuationTag, &result.modalText, &result.lightingCommand,
 		&result.requestPlayerGotoXZ, &result.playerGotoX, &result.playerGotoZ,
-		&result.mutatedRuntimeState, &result.requestCloseupExit);
+		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
+		&result.requestCloseupExit);
 	return true;
 }
 
@@ -1845,9 +1847,12 @@ bool Script::resolveObjectInteraction(const ObjectRecord &object, InteractionRes
 	if (isPickupObject(object)) {
 		if (ObjectRecord *currentObject = findRuntimeObject(object.currentOwnerOrRoom, object.objectName)) {
 			if (!currentObject->currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)) {
+				const bool affectsCurrentRoom = !commandRoomName.empty() &&
+					currentObject->currentOwnerOrRoom.equalsIgnoreCase(commandRoomName);
 				currentObject->currentOwnerOrRoom = kInventoryOwnerName;
 				currentObject->identShown = true;
 				result.mutatedRuntimeState = true;
+				result.visualRuntimeStateChanged = affectsCurrentRoom;
 			}
 		}
 	}
@@ -1861,7 +1866,8 @@ bool Script::resolveObjectInteraction(const ObjectRecord &object, InteractionRes
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
-		&result.mutatedRuntimeState, &result.requestCloseupExit);
+		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
+		&result.requestCloseupExit);
 
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
@@ -1886,7 +1892,8 @@ bool Script::resolveRegionInteraction(const RegionRecord &region, InteractionRes
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
-		&result.mutatedRuntimeState, &result.requestCloseupExit);
+		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
+		&result.requestCloseupExit);
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
 		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
@@ -1918,7 +1925,8 @@ bool Script::resolveUseItemInteraction(const Common::String &itemName, const Obj
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
-		&result.mutatedRuntimeState, &result.requestCloseupExit);
+		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
+		&result.requestCloseupExit);
 	return true;
 }
 
@@ -1957,7 +1965,8 @@ bool Script::executeActionTag(const Common::String &tag, InteractionResult &resu
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
-		&result.mutatedRuntimeState, &result.requestCloseupExit);
+		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
+		&result.requestCloseupExit);
 
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
@@ -1992,7 +2001,8 @@ bool Script::executeTimerAction(const Common::String &timerName, InteractionResu
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
-		&result.mutatedRuntimeState, &result.requestCloseupExit);
+		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
+		&result.requestCloseupExit);
 
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
@@ -2828,7 +2838,8 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		Common::String *continuationTag, ResolvedText *modalText,
 		StartupLightingCommand *lightingCommand, bool *requestPlayerGotoXZ,
 		int *playerGotoX, int *playerGotoZ,
-		bool *mutatedRuntimeState, bool *requestCloseupExit) {
+		bool *mutatedRuntimeState, bool *visualRuntimeStateChanged,
+		bool *requestCloseupExit) {
 	auto isTruthy = [](const Common::String &value) {
 		return value.equalsIgnoreCase("T") || value.equalsIgnoreCase("ON") || value.equalsIgnoreCase("TRUE");
 	};
@@ -2836,6 +2847,14 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		if (changed && mutatedRuntimeState)
 			*mutatedRuntimeState = true;
 	};
+	auto noteCurrentRoomVisualMutation = [&](bool changed, bool affectsCurrentRoom) {
+		noteMutation(changed);
+		if (changed && affectsCurrentRoom && visualRuntimeStateChanged)
+			*visualRuntimeStateChanged = true;
+	};
+	auto belongsToContextRoom = [&](const Common::String &roomName) {
+		return !contextRoomName.empty() && roomName.equalsIgnoreCase(contextRoomName);
+	};
 	auto hasDeferredInteractionOutputs = [&]() {
 		return (nextRoomName && !nextRoomName->empty()) ||
 			(cutscenePath && !cutscenePath->empty()) ||
@@ -2935,19 +2954,24 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			}
 
 			const bool visible = command->opcodeName.equalsIgnoreCase("ADD");
+			const bool affectsCurrentRoom = belongsToContextRoom(currentObject->currentOwnerOrRoom);
 			const bool changed = currentObject->visible != visible ||
 				currentObject->runtimeVisible != visible;
 			currentObject->visible = visible;
 			currentObject->runtimeVisible = visible;
 			if (visible && currentObject->currentOwnerOrRoom.equalsIgnoreCase("INVENTORY"))
 				currentObject->identShown = true;
-			noteMutation(changed);
+			noteCurrentRoomVisualMutation(changed, affectsCurrentRoom);
 			currentTag = command->arg4;
 			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("ADD2INV")) {
-			noteMutation(addRuntimeObjectToInventory(command->arg1));
+			const ObjectRecord *currentObject = findRuntimeObject(Common::String(), command->arg1);
+			const bool affectsCurrentRoom = currentObject &&
+				belongsToContextRoom(currentObject->currentOwnerOrRoom);
+			noteCurrentRoomVisualMutation(
+				addRuntimeObjectToInventory(command->arg1), affectsCurrentRoom);
 			currentTag = command->arg4;
 			continue;
 		}
@@ -2977,7 +3001,8 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			currentAnimation->runtimeActive = active;
 			currentAnimation->runtimeVisible = visible;
 			currentAnimation->runtimeState = runtimeState;
-			noteMutation(changed);
+			noteCurrentRoomVisualMutation(changed,
+				belongsToContextRoom(currentAnimation->roomName));
 			currentTag = command->arg4;
 			continue;
 		}
@@ -2994,7 +3019,8 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			const bool enabled = !command->arg2.equalsIgnoreCase("F");
 			const bool changed = currentRegion->startEnabled != enabled;
 			currentRegion->startEnabled = enabled;
-			noteMutation(changed);
+			noteCurrentRoomVisualMutation(changed,
+				belongsToContextRoom(currentRegion->roomName));
 			currentTag = command->arg4;
 			continue;
 		}
@@ -3013,7 +3039,8 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			const bool changed = currentNpc->active != active || currentNpc->visible != visible;
 			currentNpc->active = active;
 			currentNpc->visible = visible;
-			noteMutation(changed);
+			noteCurrentRoomVisualMutation(changed,
+				belongsToContextRoom(currentNpc->roomName));
 			currentTag = command->arg4;
 			continue;
 		}
@@ -3045,19 +3072,26 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			}
 			if (active && currentMonster->currentHitPoints <= 0)
 				currentMonster->currentHitPoints = currentMonster->initialHitPoints;
-			noteMutation(changed);
+			noteCurrentRoomVisualMutation(changed,
+				belongsToContextRoom(currentMonster->roomName));
 			currentTag = command->arg4;
 			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("SET_TIMER")) {
-			noteMutation(setRuntimeTimerEnabled(command->arg1, command->arg2.equalsIgnoreCase("ON")));
+			const TimerRecord *currentTimer = findRuntimeTimer(command->arg1);
+			noteCurrentRoomVisualMutation(
+				setRuntimeTimerEnabled(command->arg1, command->arg2.equalsIgnoreCase("ON")),
+				currentTimer && belongsToContextRoom(currentTimer->arg1));
 			currentTag = command->arg4;
 			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("KILL_TIMER")) {
-			noteMutation(setRuntimeTimerEnabled(command->arg1, false));
+			const TimerRecord *currentTimer = findRuntimeTimer(command->arg1);
+			noteCurrentRoomVisualMutation(
+				setRuntimeTimerEnabled(command->arg1, false),
+				currentTimer && belongsToContextRoom(currentTimer->arg1));
 			currentTag = command->arg4;
 			continue;
 		}
@@ -3077,7 +3111,8 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 					musicPath, audioCommands, nextRoomName, roomTransition, cutscenePath,
 					deathFlicPath, requestMainMenu, cdChangeDisc, dialogueNpcName, dialogueContinuationTag,
 					continuationTag, modalText, lightingCommand, requestPlayerGotoXZ,
-					playerGotoX, playerGotoZ, mutatedRuntimeState, requestCloseupExit);
+					playerGotoX, playerGotoZ, mutatedRuntimeState, visualRuntimeStateChanged,
+					requestCloseupExit);
 				if (hasDeferredInteractionOutputs())
 					return;
 			}
@@ -3151,9 +3186,10 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 				currentNpc->visible &&
 				!currentNpc->deathOrMonsterfyFlag &&
 				currentNpc->runtimeState < 0;
-			noteMutation(deferLiveNpcDeathTransition
+			noteCurrentRoomVisualMutation(deferLiveNpcDeathTransition
 				? queueRuntimeNpcDeathOrMonsterfy(command->arg1, deathDamageType)
-				: finalizeRuntimeNpcDeathOrMonsterfy(command->arg1, deathDamageType));
+				: finalizeRuntimeNpcDeathOrMonsterfy(command->arg1, deathDamageType),
+				belongsToContextRoom(currentNpc->roomName));
 			currentTag = command->arg4;
 			continue;
 		}
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 701e2e5217a..6e983176a67 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -306,6 +306,7 @@ struct InteractionResult {
 	bool requestMainMenu = false;
 	bool abortRemainingCommandChain = false;
 	bool mutatedRuntimeState = false;
+	bool visualRuntimeStateChanged = false;
 };
 
 class Script {
@@ -457,7 +458,8 @@ private:
 		Common::String *continuationTag, ResolvedText *modalText,
 		StartupLightingCommand *lightingCommand, bool *requestPlayerGotoXZ,
 		int *playerGotoX, int *playerGotoZ,
-		bool *mutatedRuntimeState, bool *requestCloseupExit);
+		bool *mutatedRuntimeState, bool *visualRuntimeStateChanged,
+		bool *requestCloseupExit);
 	bool probePickupBlockingCommandChain(const Common::String &initialTag,
 		const Common::String &contextName, InteractionResult &result, uint recursionDepth) const;
 	bool hasActionableCommandChain(const Common::String &initialTag) const;


Commit: 9f860f0714e583d4f76701d1c677fe51c8ddb768
    https://github.com/scummvm/scummvm/commit/9f860f0714e583d4f76701d1c677fe51c8ddb768
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:49+02:00

Commit Message:
HARVESTER: Clear timers when loading saves

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 9b29f8a90b9..4a7764bd232 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -402,6 +402,7 @@
   - It loads `loadgame.pal` and `loadgame.bm`.
   - It enumerates `GAME_01` through `GAME_25` slots and reads slot names from the save file.
   - When a slot is accepted it restores the save header fields that drive room handoff: `g_player_present_in_room`, `g_pending_room_name`, `g_player_room_name`, and the saved `SAVE_GAME` entrance position/facing.
+  - Load restore differs from ordinary room transitions for timers: `run_load_game_menu` calls `destroy_entity_list(&g_render_entity_list)`, then `free_loaded_world_data`; while freeing old `TimerRecord` nodes, `free_loaded_world_data` removes any preserved live global timer entity with the same name. The loaded slot then restores each `TIMER` record by name and only `room_setup` re-materializes timers whose `TimerRecord.arg1` matches the loaded room.
 - `run_save_game_menu` at `0x632c0` is the matching save-game UI.
   - It loads `savegame.pal` and `savegame.bm`.
   - It enumerates the same 25 save slots and supports text entry for save names.
@@ -412,7 +413,7 @@
   - Entering slot-name edit mode spawns `TEXT_ENTRY` at `(0x50, activeSlot * 0x11 + 0x19)`, temporarily moves the mouse to `(0x14, 0x1ce)`, constrains mouse travel to the bottom confirm/cancel band, and then calls `run_text_entry_dialog` with hidden max length `0xf6`.
   - It first calls `sync_current_room_runtime_state_before_save_load`, which copies current-room monster runtime positions/facing back into `MonsterRecord` fields and refreshes the `SAVE_GAME` entrance from `g_player_combat_avatar`.
   - The save header is now explicit: current disc id, `g_player_present_in_room`, `g_game_clock_tick_remainder_ms`, `g_current_room_def->room_name`, and `g_player_room_name`.
-  - The remainder of the save blob persists the player combat-avatar state, record lists (`ANIM`, `FLAG`, `MONSTER`, `NPC`, `OBJECT`, `REGION`, `TIMER`), the talk-state blocks, ammo counters, and `g_current_music_path`.
+  - The remainder of the save blob persists the player combat-avatar state, record lists (`ANIM`, `FLAG`, `MONSTER`, `NPC`, `OBJECT`, `REGION`, `TIMER`), the talk-state blocks, ammo counters, and `g_current_music_path`; the timer portion writes each `timer_name`, `enabled`, and `current_value`.
   - The dialogue-state write sizes are now explicit from the save/load callsites: Sergeant writes `0x28` bytes from `g_sergeant_talk_state_block @ 0xd2d4c`, Stephanie writes `0x4c` bytes from `g_stephanie_talk_state_block @ 0xd2e4c`, and the shared discussion / follow-up block writes `0x9c` bytes from `0xd2e98`, ending immediately before `g_ryder_talk_state_block @ 0xd2f34`.
   - Native save/load also serializes the wider talk-state inventory in dedicated block groups after the `TIMER` records: Boyle, Dwayne, Loomis, Mom, Hank, Dad, Mr. Potts aux/main, Stephanie, Johnson, Swell (`g_serialized_talk_state_block_d2c30`), Parsons, Mcknight, Edna, Moynahan, Phelps, Herrill, Wasp Woman, Sparky, Fireman 1/2, Nude Man, Butcher, Ryder, Buster, Dark Woman, Memb Dir, Mother, Mrs. Potts, Inquisitor, the standalone `g_serialized_talk_state_flag_d2cd8 @ 0xd2cd8`, and the shared dialogue dword range `0xd2e98..0xd2f30`.
   - The object-state portion of the slot format persists only `object_name`, `current_x`, `current_y`, `current_z`, `current_owner_or_room`, `visible`, and `ident_shown`; the other object heap strings are reloaded from world data rather than restored from the save blob.
diff --git a/TRACKER.md b/TRACKER.md
index eb350163ff0..8c5dc25e5bd 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 13, 2026, confirmed in Ghidra that `dispatch_room_event_actions` applies `SET_FLAG` without rebuilding the room or rereading the palette; `SHOW_TEXT` draws over the existing room palette, `CHANGE_LIGHTING NONE` clears to black, and `GOFLIC` starts the FST without an intermediate same-room `room_setup`. Updated the engine to refresh the live room only for current-room visual mutations, not flag-only or off-room runtime mutations.
+- On April 14, 2026, confirmed in Ghidra that native load restore destroys live render entities, removes preserved global timer entities while freeing old `TimerRecord` nodes, restores saved timer records by name, and lets `room_setup` materialize only timers whose `TimerRecord.arg1` matches the loaded room. Updated ScummVM load-room handoff to clear live scene entities without preserving global timers before applying a pending loaded save.
 
 ## Next Suggested Action
 
-- Re-run the DNAEXT night-5 fire exit path with `--debugflags=room,scene,general` and confirm the `SET_FIRE_TEXT1` modal remains on the night/dim palette until `CHANGE_LIGHTING NONE` blacks out before `GRAPHIC/FST/OVERNITE.FST`.
+- Run with `debug_timers` enabled, start an active global timer in one room, load a save targeting a different room, and confirm the overlay only shows timers materialized for the loaded room.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index a5e7dbe5979..13fd5b91f83 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -881,6 +881,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		RoomSetupState state;
 		bool shouldRunRoomEntryCommands = false;
 		if (_engine.hasPendingLoadedSaveRoomState()) {
+			// Native load destroys live render entities and frees preserved global timers
+			// before room_setup rebuilds the loaded room from restored TIMER records.
+			if (EntityManager *entityManager = _engine.getRuntimeEntities())
+				entityManager->clearSceneEntities(false);
 			flow.resetRoomNpcDialogueState();
 			if (_engine.hasPendingLoadedDialogueStateBlob()) {
 				if (!flow.loadDialogueSaveStateBlob(


Commit: e97d13f490ccff2448d06c1a229a310129d397fb
    https://github.com/scummvm/scummvm/commit/e97d13f490ccff2448d06c1a229a310129d397fb
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:49+02:00

Commit Message:
HARVESTER: Tick offscreen global timers

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp
    engines/harvester/runtime_entity.cpp
    engines/harvester/runtime_entity.h
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index 8c5dc25e5bd..c659a2ec050 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 14, 2026, confirmed in Ghidra that native load restore destroys live render entities, removes preserved global timer entities while freeing old `TimerRecord` nodes, restores saved timer records by name, and lets `room_setup` materialize only timers whose `TimerRecord.arg1` matches the loaded room. Updated ScummVM load-room handoff to clear live scene entities without preserving global timers before applying a pending loaded save.
+- On April 14, 2026, fixed ScummVM Harvester global timer runtime handling so offscreen global timers touched by `SET_TIMER`/`KILL_TIMER` are synchronized as hidden timer entities after non-visual script mutations, including command chains that immediately transition rooms. Timer reconfiguration now also honors the entity manager pause depth.
 
 ## Next Suggested Action
 
-- Run with `debug_timers` enabled, start an active global timer in one room, load a save targeting a different room, and confirm the overlay only shows timers materialized for the loaded room.
+- Re-run the EYEHALL acid path without depending on `debug_timers`: kill `BIG_EYE`, leave to `MAINHALL`, let `ACID_TRIG_TIMER` start `ACID_TIMER`, `ACID_TIMER2`, and `ACID_TIMER3`, then re-enter EYEHALL and confirm the global acid timers have already decremented instead of restarting from their maximum values.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 13fd5b91f83..bb4df275903 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1590,6 +1590,74 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 			return removedAny;
 		};
+		auto isTimerMaterializedInCurrentRoom = [&](const Common::String &timerName) {
+			for (const TimerRecord &timer : scene.state.roomTimers) {
+				if (timer.timerName.equalsIgnoreCase(timerName))
+					return true;
+			}
+
+			return false;
+		};
+		auto isTimerNameInList = [&](const Common::Array<Common::String> &timerNames,
+				const Common::String &timerName) {
+			for (const Common::String &candidate : timerNames) {
+				if (candidate.equalsIgnoreCase(timerName))
+					return true;
+			}
+
+			return false;
+		};
+		auto syncOffscreenGlobalTimerEntities = [&](const Common::Array<Common::String> &timerNames) {
+			Script *script = _engine.getScript();
+			if (!script || !entityManager || timerNames.empty())
+				return;
+
+			for (const TimerRecord &baseTimer : script->getTimers()) {
+				const TimerRecord *runtimeTimer = script->findRuntimeTimerRecord(baseTimer.timerName);
+				if (!isTimerNameInList(timerNames, baseTimer.timerName) ||
+						!runtimeTimer || !runtimeTimer->global ||
+						isTimerMaterializedInCurrentRoom(runtimeTimer->timerName)) {
+					continue;
+				}
+
+				Entity *entity = entityManager->findSceneEntityByName(runtimeTimer->timerName);
+				if (!runtimeTimer->enabled && !entity)
+					continue;
+				if (entity && entity->getClassId() != kRuntimeEntityClassTimer)
+					continue;
+
+				if (!entity) {
+					(void)entityManager->spawnSceneTimerEntity(
+						runtimeTimer->timerName, runtimeTimer->initialValue,
+						runtimeTimer->currentValue, runtimeTimer->enabled,
+						runtimeTimer->looping, runtimeTimer->global);
+					debugC(1, kDebugRoom,
+						"Harvester: offscreen global timer spawned current_room='%s' timer='%s' owner_room='%s' current=%d initial=%d enabled=%d loop=%d",
+						scene.state.roomName.c_str(), runtimeTimer->timerName.c_str(),
+						runtimeTimer->arg1.c_str(), runtimeTimer->currentValue,
+						runtimeTimer->initialValue, runtimeTimer->enabled,
+						runtimeTimer->looping);
+					continue;
+				}
+
+				if (entity->getTimerInitialValue() == runtimeTimer->initialValue &&
+						entity->isTimerEnabled() == runtimeTimer->enabled &&
+						entity->isTimerLooping() == runtimeTimer->looping &&
+						entity->isTimerGlobal() == runtimeTimer->global) {
+					continue;
+				}
+
+				entityManager->configureSceneTimerEntity(*entity,
+					runtimeTimer->initialValue, runtimeTimer->currentValue,
+					runtimeTimer->enabled, runtimeTimer->looping, runtimeTimer->global);
+				debugC(1, kDebugRoom,
+					"Harvester: offscreen global timer synced current_room='%s' timer='%s' owner_room='%s' current=%d initial=%d enabled=%d loop=%d",
+					scene.state.roomName.c_str(), runtimeTimer->timerName.c_str(),
+					runtimeTimer->arg1.c_str(), runtimeTimer->currentValue,
+					runtimeTimer->initialValue, runtimeTimer->enabled,
+					runtimeTimer->looping);
+			}
+		};
 		auto applyCurrentRoomRuntimeMutationsInPlace = [&]() {
 			Script *script = _engine.getScript();
 			ResourceManager *resources = _engine.getResources();
@@ -1604,38 +1672,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			}
 			updatedState.audioCommands = entryAudioCommands;
 
-			if (entityManager) {
-				for (const TimerRecord &baseTimer : script->getTimers()) {
-					Entity *entity = entityManager->findSceneEntityByName(baseTimer.timerName);
-					if (!entity || entity->getClassId() != kRuntimeEntityClassTimer)
-						continue;
-
-					bool materializedInCurrentRoom = false;
-					for (const TimerRecord &updatedTimer : updatedState.roomTimers) {
-						if (updatedTimer.timerName.equalsIgnoreCase(baseTimer.timerName)) {
-							materializedInCurrentRoom = true;
-							break;
-						}
-					}
-					if (materializedInCurrentRoom)
-						continue;
-
-					const TimerRecord *runtimeTimer = script->findRuntimeTimerRecord(baseTimer.timerName);
-					if (!runtimeTimer || !runtimeTimer->global)
-						continue;
-					if (entity->getTimerInitialValue() == runtimeTimer->initialValue &&
-							entity->isTimerEnabled() == runtimeTimer->enabled &&
-							entity->isTimerLooping() == runtimeTimer->looping &&
-							entity->isTimerGlobal() == runtimeTimer->global) {
-						continue;
-					}
-
-					entity->configureTimerCountdown(runtimeTimer->initialValue,
-						runtimeTimer->currentValue, runtimeTimer->enabled,
-						runtimeTimer->looping, runtimeTimer->global);
-				}
-			}
-
 			RoomSceneResources updatedScene;
 			if (!loadRoomSceneResources(updatedState, *resources, updatedScene))
 				return false;
@@ -2116,6 +2152,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				if (!exitInteraction.musicPath.empty())
 					(void)_engine.playMusic(exitInteraction.musicPath);
 				flow.executeStartupAudioCommands(exitInteraction.audioCommands);
+				if (exitInteraction.mutatedRuntimeState)
+					syncOffscreenGlobalTimerEntities(exitInteraction.mutatedTimerNames);
 
 				if (!exitInteraction.cutscenePath.empty()) {
 					FstPlayer fstPlayer(_engine);
@@ -2294,6 +2332,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			const bool canExitCloseupToParent;
 			decltype(refreshCurrentScene) &refreshCurrentSceneFn;
 			decltype(applyCurrentRoomRuntimeMutationsInPlace) &applyCurrentRoomRuntimeMutationsInPlaceFn;
+			decltype(syncOffscreenGlobalTimerEntities) &syncOffscreenGlobalTimerEntitiesFn;
 			decltype(captureDialogueBackdrop) &captureDialogueBackdropFn;
 			decltype(showCdChangePrompt) &showCdChangePromptFn;
 			decltype(runRoomExitCommands) &runRoomExitCommandsFn;
@@ -2343,6 +2382,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					restoreMusicPath = engine.getMusicPath();
 				}
 				flow.executeStartupAudioCommands(interaction.audioCommands);
+				if (interaction.mutatedRuntimeState)
+					syncOffscreenGlobalTimerEntitiesFn(interaction.mutatedTimerNames);
 				auto queueImplicitRoomRestart = [&]() -> Common::Error {
 					pendingRoomChangeIsRoomName = false;
 					pendingRoomChangeUsesSavedRoomState = false;
@@ -2579,8 +2620,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			pendingRoomChangeIsRoomName, pendingRoomChangeUsesSavedRoomState,
 			pendingRoomChangeSavedRoomState,
 			canExitCloseupToParent,
-			refreshCurrentScene, applyCurrentRoomRuntimeMutationsInPlace, captureDialogueBackdrop,
-			showCdChangePrompt,
+			refreshCurrentScene, applyCurrentRoomRuntimeMutationsInPlace,
+			syncOffscreenGlobalTimerEntities, captureDialogueBackdrop, showCdChangePrompt,
 			runRoomExitCommands, applyLightingCommand, applyPlayerGotoXZ, runModalShowText,
 			resetIdleState
 		};
@@ -2611,6 +2652,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 			// Native room_setup applies entry-time state changes before the first visible room frame.
 			if (roomEntryInteraction.mutatedRuntimeState) {
+				syncOffscreenGlobalTimerEntities(roomEntryInteraction.mutatedTimerNames);
 				if (!applyCurrentRoomRuntimeMutationsInPlace() &&
 						!refreshCurrentScene(true)) {
 					return Common::kReadingFailed;
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index 834a0d55867..c5e48878d4d 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -896,11 +896,18 @@ Entity *EntityManager::spawnSceneTimerEntity(const Common::String &name,
 	Entity *entity = new Entity();
 	entity->setName(name);
 	entity->setClassId(kRuntimeEntityClassTimer);
-	entity->configureTimerCountdown(initialValue, currentValue, enabled, looping, global);
+	configureSceneTimerEntity(*entity, initialValue, currentValue, enabled, looping, global);
 	insertSceneEntity(entity);
 	return entity;
 }
 
+void EntityManager::configureSceneTimerEntity(Entity &entity, int initialValue, int currentValue,
+		bool enabled, bool looping, bool global) {
+	entity.configureTimerCountdown(initialValue, currentValue, enabled, looping, global);
+	if (_timerPauseDepth > 0)
+		entity.pauseTimerCountdown(getAnimationClockTicks());
+}
+
 void EntityManager::hideCursor() {
 	if (_cursorEntity)
 		_cursorEntity->setVisible(false);
diff --git a/engines/harvester/runtime_entity.h b/engines/harvester/runtime_entity.h
index d758ee49aca..3b1f0a1125b 100644
--- a/engines/harvester/runtime_entity.h
+++ b/engines/harvester/runtime_entity.h
@@ -199,6 +199,8 @@ public:
 		const Common::Point &position, float z, int initialFrame);
 	Entity *spawnSceneTimerEntity(const Common::String &name, int initialValue, int currentValue,
 		bool enabled, bool looping, bool global);
+	void configureSceneTimerEntity(Entity &entity, int initialValue, int currentValue,
+		bool enabled, bool looping, bool global);
 	Entity *getCursorEntity() const { return _cursorEntity; }
 	void hideCursor();
 	void showCursor();
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 2109eb5af68..be53ecfd38b 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1811,7 +1811,7 @@ bool Script::executeRoomEnterCommands(const Common::String &roomName,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.requestCloseupExit);
+		&result.mutatedTimerNames, &result.requestCloseupExit);
 	return true;
 }
 
@@ -1830,7 +1830,7 @@ bool Script::executeRoomExitCommands(const Common::String &roomName,
 		&result.continuationTag, &result.modalText, &result.lightingCommand,
 		&result.requestPlayerGotoXZ, &result.playerGotoX, &result.playerGotoZ,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.requestCloseupExit);
+		&result.mutatedTimerNames, &result.requestCloseupExit);
 	return true;
 }
 
@@ -1867,7 +1867,7 @@ bool Script::resolveObjectInteraction(const ObjectRecord &object, InteractionRes
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.requestCloseupExit);
+		&result.mutatedTimerNames, &result.requestCloseupExit);
 
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
@@ -1893,7 +1893,7 @@ bool Script::resolveRegionInteraction(const RegionRecord &region, InteractionRes
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.requestCloseupExit);
+		&result.mutatedTimerNames, &result.requestCloseupExit);
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
 		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
@@ -1926,7 +1926,7 @@ bool Script::resolveUseItemInteraction(const Common::String &itemName, const Obj
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.requestCloseupExit);
+		&result.mutatedTimerNames, &result.requestCloseupExit);
 	return true;
 }
 
@@ -1966,7 +1966,7 @@ bool Script::executeActionTag(const Common::String &tag, InteractionResult &resu
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.requestCloseupExit);
+		&result.mutatedTimerNames, &result.requestCloseupExit);
 
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
@@ -2002,7 +2002,7 @@ bool Script::executeTimerAction(const Common::String &timerName, InteractionResu
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.requestCloseupExit);
+		&result.mutatedTimerNames, &result.requestCloseupExit);
 
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
@@ -2839,7 +2839,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		StartupLightingCommand *lightingCommand, bool *requestPlayerGotoXZ,
 		int *playerGotoX, int *playerGotoZ,
 		bool *mutatedRuntimeState, bool *visualRuntimeStateChanged,
-		bool *requestCloseupExit) {
+		Common::Array<Common::String> *mutatedTimerNames, bool *requestCloseupExit) {
 	auto isTruthy = [](const Common::String &value) {
 		return value.equalsIgnoreCase("T") || value.equalsIgnoreCase("ON") || value.equalsIgnoreCase("TRUE");
 	};
@@ -2852,6 +2852,17 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		if (changed && affectsCurrentRoom && visualRuntimeStateChanged)
 			*visualRuntimeStateChanged = true;
 	};
+	auto noteTimerMutation = [&](const Common::String &timerName, bool changed) {
+		if (!changed || !mutatedTimerNames || timerName.empty())
+			return;
+
+		for (const Common::String &mutatedTimerName : *mutatedTimerNames) {
+			if (mutatedTimerName.equalsIgnoreCase(timerName))
+				return;
+		}
+
+		mutatedTimerNames->push_back(timerName);
+	};
 	auto belongsToContextRoom = [&](const Common::String &roomName) {
 		return !contextRoomName.empty() && roomName.equalsIgnoreCase(contextRoomName);
 	};
@@ -3080,18 +3091,21 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 
 		if (command->opcodeName.equalsIgnoreCase("SET_TIMER")) {
 			const TimerRecord *currentTimer = findRuntimeTimer(command->arg1);
-			noteCurrentRoomVisualMutation(
-				setRuntimeTimerEnabled(command->arg1, command->arg2.equalsIgnoreCase("ON")),
+			const bool changed =
+				setRuntimeTimerEnabled(command->arg1, command->arg2.equalsIgnoreCase("ON"));
+			noteCurrentRoomVisualMutation(changed,
 				currentTimer && belongsToContextRoom(currentTimer->arg1));
+			noteTimerMutation(command->arg1, changed);
 			currentTag = command->arg4;
 			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("KILL_TIMER")) {
 			const TimerRecord *currentTimer = findRuntimeTimer(command->arg1);
-			noteCurrentRoomVisualMutation(
-				setRuntimeTimerEnabled(command->arg1, false),
+			const bool changed = setRuntimeTimerEnabled(command->arg1, false);
+			noteCurrentRoomVisualMutation(changed,
 				currentTimer && belongsToContextRoom(currentTimer->arg1));
+			noteTimerMutation(command->arg1, changed);
 			currentTag = command->arg4;
 			continue;
 		}
@@ -3112,7 +3126,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 					deathFlicPath, requestMainMenu, cdChangeDisc, dialogueNpcName, dialogueContinuationTag,
 					continuationTag, modalText, lightingCommand, requestPlayerGotoXZ,
 					playerGotoX, playerGotoZ, mutatedRuntimeState, visualRuntimeStateChanged,
-					requestCloseupExit);
+					mutatedTimerNames, requestCloseupExit);
 				if (hasDeferredInteractionOutputs())
 					return;
 			}
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 6e983176a67..c14edbce050 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -294,6 +294,7 @@ struct InteractionResult {
 	Common::String continuationTag;
 	ResolvedText modalText;
 	Common::Array<AudioCommand> audioCommands;
+	Common::Array<Common::String> mutatedTimerNames;
 	StartupRoomTransitionKind roomTransition = kStartupRoomTransitionNone;
 	StartupLightingCommand lightingCommand = kStartupLightingCommandNone;
 	int cdChangeDisc = 0;
@@ -459,7 +460,7 @@ private:
 		StartupLightingCommand *lightingCommand, bool *requestPlayerGotoXZ,
 		int *playerGotoX, int *playerGotoZ,
 		bool *mutatedRuntimeState, bool *visualRuntimeStateChanged,
-		bool *requestCloseupExit);
+		Common::Array<Common::String> *mutatedTimerNames, bool *requestCloseupExit);
 	bool probePickupBlockingCommandChain(const Common::String &initialTag,
 		const Common::String &contextName, InteractionResult &result, uint recursionDepth) const;
 	bool hasActionableCommandChain(const Common::String &initialTag) const;


Commit: df0f4c67f15f9ff31a99ac70bdc42b527dee0476
    https://github.com/scummvm/scummvm/commit/df0f4c67f15f9ff31a99ac70bdc42b527dee0476
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:50+02:00

Commit Message:
HARVESTER: Match native timer lookup

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index c659a2ec050..f380ba7a5f1 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,7 +10,7 @@
 
 ## Last Confirmed Action
 
-- On April 14, 2026, fixed ScummVM Harvester global timer runtime handling so offscreen global timers touched by `SET_TIMER`/`KILL_TIMER` are synchronized as hidden timer entities after non-visual script mutations, including command chains that immediately transition rooms. Timer reconfiguration now also honors the entity manager pause depth.
+- On April 14, 2026, rechecked native timer handling in Ghidra: `dispatch_room_event_actions` resolves `SET_TIMER`/`KILL_TIMER` through a live class `0x17` timer entity first, mirrors enabled state into `TimerRecord` only when that entity exists, and otherwise only logs `WARNING: Timer %s not in list`; `room_setup` materializes timers only for the resolved room while `destroy_entity_list` preserves global live timer entities across room changes. Tightened the ScummVM fix so missing offscreen live timer entities restore the prior `TimerRecord` instead of being synthesized.
 
 ## Next Suggested Action
 
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index bb4df275903..2d331b4d793 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1598,45 +1598,30 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 			return false;
 		};
-		auto isTimerNameInList = [&](const Common::Array<Common::String> &timerNames,
-				const Common::String &timerName) {
-			for (const Common::String &candidate : timerNames) {
-				if (candidate.equalsIgnoreCase(timerName))
-					return true;
-			}
-
-			return false;
-		};
-		auto syncOffscreenGlobalTimerEntities = [&](const Common::Array<Common::String> &timerNames) {
+		auto syncOffscreenGlobalTimerEntities =
+				[&](const Common::Array<TimerRecord> &previousTimerRecords) {
 			Script *script = _engine.getScript();
-			if (!script || !entityManager || timerNames.empty())
+			if (!script || !entityManager || previousTimerRecords.empty())
 				return;
 
-			for (const TimerRecord &baseTimer : script->getTimers()) {
-				const TimerRecord *runtimeTimer = script->findRuntimeTimerRecord(baseTimer.timerName);
-				if (!isTimerNameInList(timerNames, baseTimer.timerName) ||
-						!runtimeTimer || !runtimeTimer->global ||
+			for (const TimerRecord &previousTimer : previousTimerRecords) {
+				const TimerRecord *runtimeTimer =
+					script->findRuntimeTimerRecord(previousTimer.timerName);
+				if (!runtimeTimer || !runtimeTimer->global ||
 						isTimerMaterializedInCurrentRoom(runtimeTimer->timerName)) {
 					continue;
 				}
 
 				Entity *entity = entityManager->findSceneEntityByName(runtimeTimer->timerName);
-				if (!runtimeTimer->enabled && !entity)
-					continue;
 				if (entity && entity->getClassId() != kRuntimeEntityClassTimer)
 					continue;
 
 				if (!entity) {
-					(void)entityManager->spawnSceneTimerEntity(
-						runtimeTimer->timerName, runtimeTimer->initialValue,
-						runtimeTimer->currentValue, runtimeTimer->enabled,
-						runtimeTimer->looping, runtimeTimer->global);
+					(void)script->syncRuntimeTimerRecord(previousTimer);
 					debugC(1, kDebugRoom,
-						"Harvester: offscreen global timer spawned current_room='%s' timer='%s' owner_room='%s' current=%d initial=%d enabled=%d loop=%d",
-						scene.state.roomName.c_str(), runtimeTimer->timerName.c_str(),
-						runtimeTimer->arg1.c_str(), runtimeTimer->currentValue,
-						runtimeTimer->initialValue, runtimeTimer->enabled,
-						runtimeTimer->looping);
+						"Harvester: offscreen global timer mutation ignored current_room='%s' timer='%s' owner_room='%s' reason='missing live timer entity'",
+						scene.state.roomName.c_str(), previousTimer.timerName.c_str(),
+						previousTimer.arg1.c_str());
 					continue;
 				}
 
@@ -2153,7 +2138,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					(void)_engine.playMusic(exitInteraction.musicPath);
 				flow.executeStartupAudioCommands(exitInteraction.audioCommands);
 				if (exitInteraction.mutatedRuntimeState)
-					syncOffscreenGlobalTimerEntities(exitInteraction.mutatedTimerNames);
+					syncOffscreenGlobalTimerEntities(exitInteraction.previousTimerRecords);
 
 				if (!exitInteraction.cutscenePath.empty()) {
 					FstPlayer fstPlayer(_engine);
@@ -2383,7 +2368,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				}
 				flow.executeStartupAudioCommands(interaction.audioCommands);
 				if (interaction.mutatedRuntimeState)
-					syncOffscreenGlobalTimerEntitiesFn(interaction.mutatedTimerNames);
+					syncOffscreenGlobalTimerEntitiesFn(interaction.previousTimerRecords);
 				auto queueImplicitRoomRestart = [&]() -> Common::Error {
 					pendingRoomChangeIsRoomName = false;
 					pendingRoomChangeUsesSavedRoomState = false;
@@ -2652,7 +2637,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 			// Native room_setup applies entry-time state changes before the first visible room frame.
 			if (roomEntryInteraction.mutatedRuntimeState) {
-				syncOffscreenGlobalTimerEntities(roomEntryInteraction.mutatedTimerNames);
+				syncOffscreenGlobalTimerEntities(roomEntryInteraction.previousTimerRecords);
 				if (!applyCurrentRoomRuntimeMutationsInPlace() &&
 						!refreshCurrentScene(true)) {
 					return Common::kReadingFailed;
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index be53ecfd38b..c1e3e8988f0 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1811,7 +1811,7 @@ bool Script::executeRoomEnterCommands(const Common::String &roomName,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.mutatedTimerNames, &result.requestCloseupExit);
+		&result.previousTimerRecords, &result.requestCloseupExit);
 	return true;
 }
 
@@ -1830,7 +1830,7 @@ bool Script::executeRoomExitCommands(const Common::String &roomName,
 		&result.continuationTag, &result.modalText, &result.lightingCommand,
 		&result.requestPlayerGotoXZ, &result.playerGotoX, &result.playerGotoZ,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.mutatedTimerNames, &result.requestCloseupExit);
+		&result.previousTimerRecords, &result.requestCloseupExit);
 	return true;
 }
 
@@ -1867,7 +1867,7 @@ bool Script::resolveObjectInteraction(const ObjectRecord &object, InteractionRes
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.mutatedTimerNames, &result.requestCloseupExit);
+		&result.previousTimerRecords, &result.requestCloseupExit);
 
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
@@ -1893,7 +1893,7 @@ bool Script::resolveRegionInteraction(const RegionRecord &region, InteractionRes
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.mutatedTimerNames, &result.requestCloseupExit);
+		&result.previousTimerRecords, &result.requestCloseupExit);
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
 		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
@@ -1926,7 +1926,7 @@ bool Script::resolveUseItemInteraction(const Common::String &itemName, const Obj
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.mutatedTimerNames, &result.requestCloseupExit);
+		&result.previousTimerRecords, &result.requestCloseupExit);
 	return true;
 }
 
@@ -1966,7 +1966,7 @@ bool Script::executeActionTag(const Common::String &tag, InteractionResult &resu
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.mutatedTimerNames, &result.requestCloseupExit);
+		&result.previousTimerRecords, &result.requestCloseupExit);
 
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
@@ -2002,7 +2002,7 @@ bool Script::executeTimerAction(const Common::String &timerName, InteractionResu
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
-		&result.mutatedTimerNames, &result.requestCloseupExit);
+		&result.previousTimerRecords, &result.requestCloseupExit);
 
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
 		!result.deathFlicPath.empty() || result.requestMainMenu ||
@@ -2839,7 +2839,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		StartupLightingCommand *lightingCommand, bool *requestPlayerGotoXZ,
 		int *playerGotoX, int *playerGotoZ,
 		bool *mutatedRuntimeState, bool *visualRuntimeStateChanged,
-		Common::Array<Common::String> *mutatedTimerNames, bool *requestCloseupExit) {
+		Common::Array<TimerRecord> *previousTimerRecords, bool *requestCloseupExit) {
 	auto isTruthy = [](const Common::String &value) {
 		return value.equalsIgnoreCase("T") || value.equalsIgnoreCase("ON") || value.equalsIgnoreCase("TRUE");
 	};
@@ -2852,16 +2852,18 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		if (changed && affectsCurrentRoom && visualRuntimeStateChanged)
 			*visualRuntimeStateChanged = true;
 	};
-	auto noteTimerMutation = [&](const Common::String &timerName, bool changed) {
-		if (!changed || !mutatedTimerNames || timerName.empty())
+	auto noteTimerMutation = [&](const TimerRecord *previousTimer, bool changed) {
+		if (!changed || !previousTimerRecords || !previousTimer ||
+				previousTimer->timerName.empty()) {
 			return;
+		}
 
-		for (const Common::String &mutatedTimerName : *mutatedTimerNames) {
-			if (mutatedTimerName.equalsIgnoreCase(timerName))
+		for (const TimerRecord &record : *previousTimerRecords) {
+			if (record.timerName.equalsIgnoreCase(previousTimer->timerName))
 				return;
 		}
 
-		mutatedTimerNames->push_back(timerName);
+		previousTimerRecords->push_back(*previousTimer);
 	};
 	auto belongsToContextRoom = [&](const Common::String &roomName) {
 		return !contextRoomName.empty() && roomName.equalsIgnoreCase(contextRoomName);
@@ -3091,21 +3093,27 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 
 		if (command->opcodeName.equalsIgnoreCase("SET_TIMER")) {
 			const TimerRecord *currentTimer = findRuntimeTimer(command->arg1);
+			TimerRecord previousTimer;
+			if (currentTimer)
+				previousTimer = *currentTimer;
 			const bool changed =
 				setRuntimeTimerEnabled(command->arg1, command->arg2.equalsIgnoreCase("ON"));
 			noteCurrentRoomVisualMutation(changed,
 				currentTimer && belongsToContextRoom(currentTimer->arg1));
-			noteTimerMutation(command->arg1, changed);
+			noteTimerMutation(currentTimer ? &previousTimer : nullptr, changed);
 			currentTag = command->arg4;
 			continue;
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("KILL_TIMER")) {
 			const TimerRecord *currentTimer = findRuntimeTimer(command->arg1);
+			TimerRecord previousTimer;
+			if (currentTimer)
+				previousTimer = *currentTimer;
 			const bool changed = setRuntimeTimerEnabled(command->arg1, false);
 			noteCurrentRoomVisualMutation(changed,
 				currentTimer && belongsToContextRoom(currentTimer->arg1));
-			noteTimerMutation(command->arg1, changed);
+			noteTimerMutation(currentTimer ? &previousTimer : nullptr, changed);
 			currentTag = command->arg4;
 			continue;
 		}
@@ -3126,7 +3134,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 					deathFlicPath, requestMainMenu, cdChangeDisc, dialogueNpcName, dialogueContinuationTag,
 					continuationTag, modalText, lightingCommand, requestPlayerGotoXZ,
 					playerGotoX, playerGotoZ, mutatedRuntimeState, visualRuntimeStateChanged,
-					mutatedTimerNames, requestCloseupExit);
+					previousTimerRecords, requestCloseupExit);
 				if (hasDeferredInteractionOutputs())
 					return;
 			}
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index c14edbce050..51d698ee9de 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -294,7 +294,7 @@ struct InteractionResult {
 	Common::String continuationTag;
 	ResolvedText modalText;
 	Common::Array<AudioCommand> audioCommands;
-	Common::Array<Common::String> mutatedTimerNames;
+	Common::Array<TimerRecord> previousTimerRecords;
 	StartupRoomTransitionKind roomTransition = kStartupRoomTransitionNone;
 	StartupLightingCommand lightingCommand = kStartupLightingCommandNone;
 	int cdChangeDisc = 0;
@@ -460,7 +460,7 @@ private:
 		StartupLightingCommand *lightingCommand, bool *requestPlayerGotoXZ,
 		int *playerGotoX, int *playerGotoZ,
 		bool *mutatedRuntimeState, bool *visualRuntimeStateChanged,
-		Common::Array<Common::String> *mutatedTimerNames, bool *requestCloseupExit);
+		Common::Array<TimerRecord> *previousTimerRecords, bool *requestCloseupExit);
 	bool probePickupBlockingCommandChain(const Common::String &initialTag,
 		const Common::String &contextName, InteractionResult &result, uint recursionDepth) const;
 	bool hasActionableCommandChain(const Common::String &initialTag) const;


Commit: 1e3c6493e2f0c43203f185007f064690856814de
    https://github.com/scummvm/scummvm/commit/1e3c6493e2f0c43203f185007f064690856814de
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:50+02:00

Commit Message:
HARVESTER: Defer triggered room animations

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp
    engines/harvester/runtime_entity.cpp
    engines/harvester/script.cpp


diff --git a/TRACKER.md b/TRACKER.md
index f380ba7a5f1..86c4d6fe3f9 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 14, 2026, rechecked native timer handling in Ghidra: `dispatch_room_event_actions` resolves `SET_TIMER`/`KILL_TIMER` through a live class `0x17` timer entity first, mirrors enabled state into `TimerRecord` only when that entity exists, and otherwise only logs `WARNING: Timer %s not in list`; `room_setup` materializes timers only for the resolved room while `destroy_entity_list` preserves global live timer entities across room changes. Tightened the ScummVM fix so missing offscreen live timer entities restore the prior `TimerRecord` instead of being synthesized.
+- On April 15, 2026, confirmed from `HARVEST.SCR` that `SUPLYDOOR_ANIM` is explicitly started by the `OPEN_SUP_DOOR1` use-item chain after `SUPLY2_BOILKEY` is used on `SUPPLY1_DOOR`. Adjusted room animation runtime handling so non-looping visible room animations do not auto-advance on room entry before a `SET_ANIM` command starts them.
 
 ## Next Suggested Action
 
-- Re-run the EYEHALL acid path without depending on `debug_timers`: kill `BIG_EYE`, leave to `MAINHALL`, let `ACID_TRIG_TIMER` start `ACID_TIMER`, `ACID_TIMER2`, and `ACID_TIMER3`, then re-enter EYEHALL and confirm the global acid timers have already decremented instead of restarting from their maximum values.
+- Re-test the supply-room boiler path: enter `SUPPLY1` from `SUPPLY2_2_SUPPLY1` before using `SUPLY2_BOILKEY` and verify `SUPLYDOOR_ANIM` remains on its first frame, then use the boiler key on `SUPPLY1_DOOR` and verify the door animation starts only from the `OPEN_SUP_DOOR1` command chain.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 2d331b4d793..6faf1433cf7 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1784,6 +1784,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					entity->setPlayBackwards(anim.backward);
 					entity->setAnimationRate(anim.frameDelay);
 					entity->setVisible(isDisplayed);
+					if (anim.active && anim.runtimeState >= 0 && !entity->isAnimationEnabled())
+						entity->setCurrentFrame(anim.runtimeState);
 					entity->setAnimationEnabled(anim.active);
 					if (zChanged)
 						entityManager->reinsertSceneEntity(entity);
diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index c5e48878d4d..410ef4cde09 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -863,7 +863,9 @@ Entity *EntityManager::spawnSceneAnimationEntity(const Common::String &name,
 	if (!playBackwards && initialFrame >= 0)
 		startFrame = CLIP<int>(initialFrame, 0, entity->getLastFrame());
 	entity->setCurrentFrame(startFrame);
-	entity->setAnimationEnabled(active);
+	// Non-looping room ANIM records can be authored visible but are started by SET_ANIM.
+	const bool shouldAdvance = active && (looping || initialFrame >= 0);
+	entity->setAnimationEnabled(shouldAdvance);
 
 	if (!active && !visible)
 		entity->setVisible(false);
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index c1e3e8988f0..323aaa77b09 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -3002,8 +3002,10 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 			const bool active = isTruthy(command->arg2);
 			const bool visible = isTruthy(command->arg3);
 			int runtimeState = currentAnimation->runtimeState;
-			if (!wasVisible && visible)
+			if (active && visible &&
+					(!wasVisible || !currentAnimation->runtimeActive || runtimeState < 0)) {
 				runtimeState = 0;
+			}
 			const bool changed = currentAnimation->active != active ||
 				currentAnimation->visible != visible ||
 				currentAnimation->runtimeActive != active ||


Commit: b3e824cf72baf9de4df52f322da1de430f16c2e8
    https://github.com/scummvm/scummvm/commit/b3e824cf72baf9de4df52f322da1de430f16c2e8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:50+02:00

Commit Message:
HARVESTER: Handle script-driven player death

Carry KILL_PC damage type through script interaction results and start
the normal player defeat sequence for script-driven HP-zero events,
matching native dispatch behavior observed in Ghidra.

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index 86c4d6fe3f9..42fb2073631 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 15, 2026, confirmed from `HARVEST.SCR` that `SUPLYDOOR_ANIM` is explicitly started by the `OPEN_SUP_DOOR1` use-item chain after `SUPLY2_BOILKEY` is used on `SUPPLY1_DOOR`. Adjusted room animation runtime handling so non-looping visible room animations do not auto-advance on room entry before a `SET_ANIM` command starts them.
+- On April 16, 2026, confirmed in Ghidra `dispatch_room_event_actions` that native `KILL_PC` maps arg1 damage strings such as `SLASH` onto the player actor death damage type, then sets player HP to zero. Updated script interaction results and room interaction handling so script-driven `KILL_PC` starts the player defeat sequence after timer/object runtime mutations are applied.
 
 ## Next Suggested Action
 
-- Re-test the supply-room boiler path: enter `SUPPLY1` from `SUPPLY2_2_SUPPLY1` before using `SUPLY2_BOILKEY` and verify `SUPLYDOOR_ANIM` remains on its first frame, then use the boiler key on `SUPPLY1_DOOR` and verify the door animation starts only from the `OPEN_SUP_DOOR1` command chain.
+- Re-test the PIT/JAWS roof timer chain through `JAWROOF10T` and verify `BODYCRSH.WAV` plays, `JAWROOF10` is materialized, the player uses the `SLASH` death animation bank, and the game-over return happens after the death animation completes.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 6faf1433cf7..42c2eb0c0ee 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -2306,6 +2306,52 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 			return Common::kNoError;
 		};
+		auto requestPlayerGameOver = [&](const char *reason, const Common::String &sourceName) {
+			if (flow.hasPendingMainMenuReturn())
+				return;
+
+			debugC(1, kDebugCombat,
+				"Harvester: player game over queued reason='%s' source='%s' hp=%d",
+				reason ? reason : "", sourceName.c_str(),
+				_engine.getScript()
+					? _engine.getScript()->getPlayerCurrentHitPoints()
+					: 0);
+			flow.requestGameOverReturn();
+		};
+		auto startPlayerDefeatSequence = [&](const char *reason, const Common::String &sourceName,
+				int damageType) {
+			if (flow.hasPendingMainMenuReturn() || playerState.deathActive)
+				return;
+
+			if (idleState.entity) {
+				idleState.entity->setVisible(false);
+				idleState.entity->setAnimationRate(0);
+			}
+			idleState.active = false;
+			idleState.loopStarted = false;
+			idleState.exiting = false;
+			idleState.restoreFacing = -1;
+			if (playerState.entity)
+				playerState.entity->setVisible(true);
+
+			if (!Player::startDeathAnimation(playerState, damageType, _engine.isGoreEnabled())) {
+				debugC(1, kDebugCombat,
+					"Harvester: player defeat fallback reason='%s' source='%s' damage_type=%d hp=%d",
+					reason ? reason : "", sourceName.c_str(), damageType,
+					_engine.getScript()
+						? _engine.getScript()->getPlayerCurrentHitPoints()
+						: 0);
+				requestPlayerGameOver(reason, sourceName);
+				return;
+			}
+
+			debugC(1, kDebugCombat,
+				"Harvester: player defeat started reason='%s' source='%s' damage_type=%d hp=%d",
+				reason ? reason : "", sourceName.c_str(), damageType,
+				_engine.getScript()
+					? _engine.getScript()->getPlayerCurrentHitPoints()
+					: 0);
+		};
 		struct InteractionProcessor {
 			HarvesterEngine &engine;
 			Flow &flow;
@@ -2327,6 +2373,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			decltype(applyPlayerGotoXZ) &applyPlayerGotoXZFn;
 			decltype(runModalShowText) &runModalShowTextFn;
 			decltype(resetIdleState) &resetIdleStateFn;
+			decltype(stopPlayerRegionInteraction) &stopPlayerRegionInteractionFn;
+			decltype(startPlayerDefeatSequence) &startPlayerDefeatSequenceFn;
 
 			Common::Error handleInteractionResult(const InteractionResult &interaction,
 					bool &didTransition, const Common::String &usedItemName) {
@@ -2506,6 +2554,14 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				if (didTransition)
 					return Common::kNoError;
 
+				if (interaction.requestPlayerDeath &&
+						engine.getScript() &&
+						engine.getScript()->getPlayerCurrentHitPoints() <= 0) {
+					stopPlayerRegionInteractionFn();
+					startPlayerDefeatSequenceFn("script_kill_pc", Common::String(),
+						interaction.playerDeathDamageType);
+				}
+
 				if (interaction.requestPlayerGotoXZ)
 					applyPlayerGotoXZFn(interaction.playerGotoX, interaction.playerGotoZ);
 
@@ -2610,7 +2666,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			refreshCurrentScene, applyCurrentRoomRuntimeMutationsInPlace,
 			syncOffscreenGlobalTimerEntities, captureDialogueBackdrop, showCdChangePrompt,
 			runRoomExitCommands, applyLightingCommand, applyPlayerGotoXZ, runModalShowText,
-			resetIdleState
+			resetIdleState, stopPlayerRegionInteraction, startPlayerDefeatSequence
 		};
 		auto hasRoomEntryInteraction = [](const InteractionResult &interaction) {
 			return !interaction.nextRoomName.empty() ||
@@ -2626,6 +2682,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				!interaction.modalText.value.empty() ||
 				interaction.lightingCommand != kStartupLightingCommandNone ||
 				interaction.requestPlayerGotoXZ ||
+				interaction.requestPlayerDeath ||
 				interaction.requestCloseupExit ||
 				interaction.mutatedRuntimeState;
 		};
@@ -2905,52 +2962,6 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			return "none";
 		}
 	};
-	auto requestPlayerGameOver = [&](const char *reason, const Common::String &sourceName) {
-		if (flow.hasPendingMainMenuReturn())
-			return;
-
-		debugC(1, kDebugCombat,
-			"Harvester: player game over queued reason='%s' source='%s' hp=%d",
-			reason ? reason : "", sourceName.c_str(),
-			_engine.getScript()
-				? _engine.getScript()->getPlayerCurrentHitPoints()
-				: 0);
-		flow.requestGameOverReturn();
-	};
-	auto startPlayerDefeatSequence = [&](const char *reason, const Common::String &sourceName,
-			int damageType) {
-		if (flow.hasPendingMainMenuReturn() || playerState.deathActive)
-			return;
-
-		if (idleState.entity) {
-			idleState.entity->setVisible(false);
-			idleState.entity->setAnimationRate(0);
-		}
-		idleState.active = false;
-		idleState.loopStarted = false;
-		idleState.exiting = false;
-		idleState.restoreFacing = -1;
-		if (playerState.entity)
-			playerState.entity->setVisible(true);
-
-		if (!Player::startDeathAnimation(playerState, damageType, _engine.isGoreEnabled())) {
-			debugC(1, kDebugCombat,
-				"Harvester: player defeat fallback reason='%s' source='%s' damage_type=%d hp=%d",
-				reason ? reason : "", sourceName.c_str(), damageType,
-				_engine.getScript()
-					? _engine.getScript()->getPlayerCurrentHitPoints()
-					: 0);
-			requestPlayerGameOver(reason, sourceName);
-			return;
-		}
-
-		debugC(1, kDebugCombat,
-			"Harvester: player defeat started reason='%s' source='%s' damage_type=%d hp=%d",
-			reason ? reason : "", sourceName.c_str(), damageType,
-			_engine.getScript()
-				? _engine.getScript()->getPlayerCurrentHitPoints()
-				: 0);
-	};
 	auto handleCombatInteraction = [&](InteractionResult interaction) -> Common::Error {
 		bool didTransition = false;
 		Common::Error interactionError =
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 323aaa77b09..d235bff2364 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -476,7 +476,7 @@ static int parseEntranceFacing(const Common::String &direction) {
 	return -1;
 }
 
-static int parseNpcDeathDamageType(const Common::String &value) {
+static int parseDeathDamageType(const Common::String &value) {
 	if (value.equalsIgnoreCase("BLUDGE"))
 		return 1;
 	if (value.equalsIgnoreCase("SLASH"))
@@ -1222,7 +1222,7 @@ void Script::parseTownRecords(ResourceManager &resources) {
 			monster.reservedString38 = tokens[tagIndex + 6];
 			monster.reservedString3c = tokens[tagIndex + 7];
 			monster.facing = parseEntranceFacing(tokens[tagIndex + 8]);
-			monster.damageType = parseNpcDeathDamageType(tokens[tagIndex + 9]);
+			monster.damageType = parseDeathDamageType(tokens[tagIndex + 9]);
 			monster.attackSound1 = resources.normalizeResourcePath(tokens[tagIndex + 10]);
 			monster.attackSound2 = resources.normalizeResourcePath(tokens[tagIndex + 11]);
 			monster.attackSound3 = resources.normalizeResourcePath(tokens[tagIndex + 12]);
@@ -1810,6 +1810,7 @@ bool Script::executeRoomEnterCommands(const Common::String &roomName,
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
+		&result.requestPlayerDeath, &result.playerDeathDamageType,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
 		&result.previousTimerRecords, &result.requestCloseupExit);
 	return true;
@@ -1829,6 +1830,7 @@ bool Script::executeRoomExitCommands(const Common::String &roomName,
 		nullptr, nullptr, &result.cdChangeDisc, &result.dialogueNpcName, &result.dialogueContinuationTag,
 		&result.continuationTag, &result.modalText, &result.lightingCommand,
 		&result.requestPlayerGotoXZ, &result.playerGotoX, &result.playerGotoZ,
+		&result.requestPlayerDeath, &result.playerDeathDamageType,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
 		&result.previousTimerRecords, &result.requestCloseupExit);
 	return true;
@@ -1866,6 +1868,7 @@ bool Script::resolveObjectInteraction(const ObjectRecord &object, InteractionRes
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
+		&result.requestPlayerDeath, &result.playerDeathDamageType,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
 		&result.previousTimerRecords, &result.requestCloseupExit);
 
@@ -1874,6 +1877,7 @@ bool Script::resolveObjectInteraction(const ObjectRecord &object, InteractionRes
 		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
 		result.cdChangeDisc > 0 || !result.continuationTag.empty() || !result.modalText.value.empty() ||
 		result.lightingCommand != kStartupLightingCommandNone || result.requestPlayerGotoXZ ||
+		result.requestPlayerDeath ||
 		result.requestCloseupExit || result.mutatedRuntimeState ||
 		hasActionableCommandChain(object.actionTag);
 }
@@ -1892,6 +1896,7 @@ bool Script::resolveRegionInteraction(const RegionRecord &region, InteractionRes
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
+		&result.requestPlayerDeath, &result.playerDeathDamageType,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
 		&result.previousTimerRecords, &result.requestCloseupExit);
 	return !result.nextRoomName.empty() || !result.cutscenePath.empty() ||
@@ -1899,6 +1904,7 @@ bool Script::resolveRegionInteraction(const RegionRecord &region, InteractionRes
 		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
 		result.cdChangeDisc > 0 || !result.continuationTag.empty() || !result.modalText.value.empty() ||
 		result.lightingCommand != kStartupLightingCommandNone || result.requestPlayerGotoXZ ||
+		result.requestPlayerDeath ||
 		result.requestCloseupExit || result.mutatedRuntimeState ||
 		hasActionableCommandChain(region.actionTag);
 }
@@ -1925,6 +1931,7 @@ bool Script::resolveUseItemInteraction(const Common::String &itemName, const Obj
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
+		&result.requestPlayerDeath, &result.playerDeathDamageType,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
 		&result.previousTimerRecords, &result.requestCloseupExit);
 	return true;
@@ -1965,6 +1972,7 @@ bool Script::executeActionTag(const Common::String &tag, InteractionResult &resu
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
+		&result.requestPlayerDeath, &result.playerDeathDamageType,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
 		&result.previousTimerRecords, &result.requestCloseupExit);
 
@@ -1973,6 +1981,7 @@ bool Script::executeActionTag(const Common::String &tag, InteractionResult &resu
 		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
 		result.cdChangeDisc > 0 || !result.continuationTag.empty() || !result.modalText.value.empty() ||
 		result.lightingCommand != kStartupLightingCommandNone || result.requestPlayerGotoXZ ||
+		result.requestPlayerDeath ||
 		result.requestCloseupExit || result.mutatedRuntimeState ||
 		hasActionableCommandChain(tag);
 }
@@ -2001,6 +2010,7 @@ bool Script::executeTimerAction(const Common::String &timerName, InteractionResu
 		&result.dialogueNpcName, &result.dialogueContinuationTag, &result.continuationTag,
 		&result.modalText, &result.lightingCommand, &result.requestPlayerGotoXZ,
 		&result.playerGotoX, &result.playerGotoZ,
+		&result.requestPlayerDeath, &result.playerDeathDamageType,
 		&result.mutatedRuntimeState, &result.visualRuntimeStateChanged,
 		&result.previousTimerRecords, &result.requestCloseupExit);
 
@@ -2009,6 +2019,7 @@ bool Script::executeTimerAction(const Common::String &timerName, InteractionResu
 		!result.dialogueNpcName.empty() || !result.musicPath.empty() || !result.audioCommands.empty() ||
 		result.cdChangeDisc > 0 || !result.continuationTag.empty() || !result.modalText.value.empty() ||
 		result.lightingCommand != kStartupLightingCommandNone || result.requestPlayerGotoXZ ||
+		result.requestPlayerDeath ||
 		result.requestCloseupExit || result.mutatedRuntimeState ||
 		hasActionableCommandChain(timer->arg2);
 }
@@ -2838,6 +2849,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		Common::String *continuationTag, ResolvedText *modalText,
 		StartupLightingCommand *lightingCommand, bool *requestPlayerGotoXZ,
 		int *playerGotoX, int *playerGotoZ,
+		bool *requestPlayerDeath, int *playerDeathDamageType,
 		bool *mutatedRuntimeState, bool *visualRuntimeStateChanged,
 		Common::Array<TimerRecord> *previousTimerRecords, bool *requestCloseupExit) {
 	auto isTruthy = [](const Common::String &value) {
@@ -3135,7 +3147,8 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 					musicPath, audioCommands, nextRoomName, roomTransition, cutscenePath,
 					deathFlicPath, requestMainMenu, cdChangeDisc, dialogueNpcName, dialogueContinuationTag,
 					continuationTag, modalText, lightingCommand, requestPlayerGotoXZ,
-					playerGotoX, playerGotoZ, mutatedRuntimeState, visualRuntimeStateChanged,
+					playerGotoX, playerGotoZ, requestPlayerDeath, playerDeathDamageType,
+					mutatedRuntimeState, visualRuntimeStateChanged,
 					previousTimerRecords, requestCloseupExit);
 				if (hasDeferredInteractionOutputs())
 					return;
@@ -3201,7 +3214,7 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 				continue;
 			}
 
-			const int deathDamageType = parseNpcDeathDamageType(command->arg2);
+			const int deathDamageType = parseDeathDamageType(command->arg2);
 			const bool deferLiveNpcDeathTransition =
 				_deferredLiveNpcDeathTransitionDepth > 0 &&
 				!contextRoomName.empty() &&
@@ -3256,6 +3269,10 @@ void Script::executeCommandChain(const Common::String &initialTag, const char *c
 		}
 
 		if (command->opcodeName.equalsIgnoreCase("KILL_PC")) {
+			if (requestPlayerDeath)
+				*requestPlayerDeath = true;
+			if (playerDeathDamageType)
+				*playerDeathDamageType = parseDeathDamageType(command->arg1);
 			noteMutation(setPlayerCurrentHitPoints(0));
 			currentTag = command->arg4;
 			continue;
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 51d698ee9de..c2e90761208 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -298,8 +298,10 @@ struct InteractionResult {
 	StartupRoomTransitionKind roomTransition = kStartupRoomTransitionNone;
 	StartupLightingCommand lightingCommand = kStartupLightingCommandNone;
 	int cdChangeDisc = 0;
+	int playerDeathDamageType = 0;
 	int playerGotoX = 0;
 	int playerGotoZ = 0;
+	bool requestPlayerDeath = false;
 	bool requestPlayerGotoXZ = false;
 	bool requestRoomRestart = false;
 	Common::String roomRestartTargetName;
@@ -459,6 +461,7 @@ private:
 		Common::String *continuationTag, ResolvedText *modalText,
 		StartupLightingCommand *lightingCommand, bool *requestPlayerGotoXZ,
 		int *playerGotoX, int *playerGotoZ,
+		bool *requestPlayerDeath, int *playerDeathDamageType,
 		bool *mutatedRuntimeState, bool *visualRuntimeStateChanged,
 		Common::Array<TimerRecord> *previousTimerRecords, bool *requestCloseupExit);
 	bool probePickupBlockingCommandChain(const Common::String &initialTag,


Commit: d99af5510a8d6c5fec6d20b29c006b80ab66e14a
    https://github.com/scummvm/scummvm/commit/d99af5510a8d6c5fec6d20b29c006b80ab66e14a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:51+02:00

Commit Message:
HARVESTER: Preserve dialogue action room context

Changed paths:
    engines/harvester/npc/beggar_dialogue.cpp
    engines/harvester/npc/chessmaster_dialogue.cpp
    engines/harvester/npc/cloak_atnd_dialogue.cpp
    engines/harvester/npc/dark_woman_dialogue.cpp
    engines/harvester/npc/dialogue_runtime.h
    engines/harvester/npc/dwayne_dialogue.cpp
    engines/harvester/npc/gladiator_dialogue.cpp
    engines/harvester/npc/inquisitor_dialogue.cpp
    engines/harvester/npc/jimmy_dialogue.cpp
    engines/harvester/npc/karin_dialogue.cpp
    engines/harvester/npc/loomis_dialogue.cpp
    engines/harvester/npc/madam_dialogue.cpp
    engines/harvester/npc/mom_dialogue.cpp
    engines/harvester/npc/mr_potts_dialogue.cpp
    engines/harvester/npc/mrs_potts_dialogue.cpp
    engines/harvester/npc/pastorelli_dialogue.cpp
    engines/harvester/npc/phelps_dialogue.cpp
    engines/harvester/npc/priest_dialogue.cpp
    engines/harvester/npc/sergeant_dialogue.cpp
    engines/harvester/npc/sparky_dialogue.cpp
    engines/harvester/npc/stephanie_dialogue.cpp
    engines/harvester/npc/whaley_dialogue.cpp


diff --git a/engines/harvester/npc/beggar_dialogue.cpp b/engines/harvester/npc/beggar_dialogue.cpp
index d1ac87abc74..5890eb68fd9 100644
--- a/engines/harvester/npc/beggar_dialogue.cpp
+++ b/engines/harvester/npc/beggar_dialogue.cpp
@@ -54,7 +54,7 @@ Common::Error BeggarDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return Common::kNoError;
 
 	InteractionResult interaction;
-	if (runtime.startupScript().executeActionTag(kBeggarDialog3ActionTag, interaction)) {
+	if (runtime.executeActionTag(kBeggarDialog3ActionTag, interaction)) {
 		runtime.applyImmediateDialogueInteractionEffects(interaction);
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 	}
diff --git a/engines/harvester/npc/chessmaster_dialogue.cpp b/engines/harvester/npc/chessmaster_dialogue.cpp
index 452b62c255e..27bd80362aa 100644
--- a/engines/harvester/npc/chessmaster_dialogue.cpp
+++ b/engines/harvester/npc/chessmaster_dialogue.cpp
@@ -424,7 +424,7 @@ Common::Error ChessmasterDialogueHandler::handleDialogue(DialogueRuntime &runtim
 			return lineError;
 
 		InteractionResult interaction;
-		if (runtime.startupScript().executeActionTag(kChessKingMonsterActionTag, interaction)) {
+		if (runtime.executeActionTag(kChessKingMonsterActionTag, interaction)) {
 			interaction.cutscenePath = "GRAPHIC/FST/KINGGROW.FST";
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
diff --git a/engines/harvester/npc/cloak_atnd_dialogue.cpp b/engines/harvester/npc/cloak_atnd_dialogue.cpp
index 3f7bf65f54d..d8a6103cb67 100644
--- a/engines/harvester/npc/cloak_atnd_dialogue.cpp
+++ b/engines/harvester/npc/cloak_atnd_dialogue.cpp
@@ -47,7 +47,7 @@ Common::Error CloakAtndDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 			return Common::kNoError;
 
 		InteractionResult interaction;
-		if (runtime.startupScript().executeActionTag(kCleanClothesActionTag, interaction)) {
+		if (runtime.executeActionTag(kCleanClothesActionTag, interaction)) {
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
 		}
diff --git a/engines/harvester/npc/dark_woman_dialogue.cpp b/engines/harvester/npc/dark_woman_dialogue.cpp
index 6f45d92b5dd..ea9eaf25d42 100644
--- a/engines/harvester/npc/dark_woman_dialogue.cpp
+++ b/engines/harvester/npc/dark_woman_dialogue.cpp
@@ -80,7 +80,7 @@ Common::Error DarkWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const bool changed = runtime.startupScript().setRuntimeObjectVisible(
 			kInventoryOwnerName, kHandMirrorItemName, false);
 		InteractionResult interaction;
-		if (runtime.startupScript().executeActionTag(kSetDarkWomanFlagActionTag, interaction)) {
+		if (runtime.executeActionTag(kSetDarkWomanFlagActionTag, interaction)) {
 			interaction.mutatedRuntimeState |= changed;
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 		} else {
diff --git a/engines/harvester/npc/dialogue_runtime.h b/engines/harvester/npc/dialogue_runtime.h
index 1549e601573..e04c53ab5f9 100644
--- a/engines/harvester/npc/dialogue_runtime.h
+++ b/engines/harvester/npc/dialogue_runtime.h
@@ -74,6 +74,10 @@ public:
 	Flow &startupFlow() const { return _flow; }
 	const Common::String &currentRoomName() const { return _currentRoomName; }
 	const Common::String &genericByeTopic() const { return _genericByeTopic; }
+	bool executeActionTag(const Common::String &tag, InteractionResult &result,
+			bool allowTransitions = true) const {
+		return _script.executeActionTag(tag, result, allowTransitions, _currentRoomName);
+	}
 
 	Common::Error playDialogueLineWithVariant(int wavId, const Common::String &speakerId,
 			int headVariant) const {
diff --git a/engines/harvester/npc/dwayne_dialogue.cpp b/engines/harvester/npc/dwayne_dialogue.cpp
index f24b50c7bf6..042a83e3b03 100644
--- a/engines/harvester/npc/dwayne_dialogue.cpp
+++ b/engines/harvester/npc/dwayne_dialogue.cpp
@@ -86,7 +86,7 @@ Common::Error DwayneDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto executeActionTagIfSet = [&](const char *tag) {
 		InteractionResult interaction;
-		if (!startupScript.executeActionTag(tag, interaction))
+		if (!runtime.executeActionTag(tag, interaction))
 			return;
 
 		runtime.applyImmediateDialogueInteractionEffects(interaction);
diff --git a/engines/harvester/npc/gladiator_dialogue.cpp b/engines/harvester/npc/gladiator_dialogue.cpp
index aeeeb1df8dd..b53ae7049a8 100644
--- a/engines/harvester/npc/gladiator_dialogue.cpp
+++ b/engines/harvester/npc/gladiator_dialogue.cpp
@@ -117,7 +117,7 @@ Common::Error GladiatorDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return lineError;
 
 	InteractionResult interaction;
-	if (runtime.startupScript().executeActionTag(kStartMercyTimerActionTag, interaction)) {
+	if (runtime.executeActionTag(kStartMercyTimerActionTag, interaction)) {
 		runtime.applyImmediateDialogueInteractionEffects(interaction);
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 	}
diff --git a/engines/harvester/npc/inquisitor_dialogue.cpp b/engines/harvester/npc/inquisitor_dialogue.cpp
index 71d5ab08fdb..7e7e50a739a 100644
--- a/engines/harvester/npc/inquisitor_dialogue.cpp
+++ b/engines/harvester/npc/inquisitor_dialogue.cpp
@@ -71,7 +71,7 @@ Common::Error InquisitorDialogueHandler::handleDialogue(DialogueRuntime &runtime
 		return lineError;
 
 	InteractionResult interaction;
-	if (runtime.startupScript().executeActionTag(kStartInquisitorTimerActionTag, interaction)) {
+	if (runtime.executeActionTag(kStartInquisitorTimerActionTag, interaction)) {
 		runtime.applyImmediateDialogueInteractionEffects(interaction);
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 	}
diff --git a/engines/harvester/npc/jimmy_dialogue.cpp b/engines/harvester/npc/jimmy_dialogue.cpp
index 12fe745085c..a1bde6cfb31 100644
--- a/engines/harvester/npc/jimmy_dialogue.cpp
+++ b/engines/harvester/npc/jimmy_dialogue.cpp
@@ -102,7 +102,7 @@ Common::Error JimmyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto executeActionTagIfSet = [&](const char *tag) {
 		InteractionResult interaction;
-		if (!runtime.startupScript().executeActionTag(tag, interaction))
+		if (!runtime.executeActionTag(tag, interaction))
 			return;
 
 		runtime.applyImmediateDialogueInteractionEffects(interaction);
diff --git a/engines/harvester/npc/karin_dialogue.cpp b/engines/harvester/npc/karin_dialogue.cpp
index a7c071f2628..d9d2fae9bf6 100644
--- a/engines/harvester/npc/karin_dialogue.cpp
+++ b/engines/harvester/npc/karin_dialogue.cpp
@@ -85,7 +85,7 @@ Common::Error KarinDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto executeActionTagIfSet = [&](const char *tag) {
 		InteractionResult interaction;
-		if (!runtime.startupScript().executeActionTag(tag, interaction))
+		if (!runtime.executeActionTag(tag, interaction))
 			return;
 
 		runtime.applyImmediateDialogueInteractionEffects(interaction);
diff --git a/engines/harvester/npc/loomis_dialogue.cpp b/engines/harvester/npc/loomis_dialogue.cpp
index 73388a0360b..9bad6396d42 100644
--- a/engines/harvester/npc/loomis_dialogue.cpp
+++ b/engines/harvester/npc/loomis_dialogue.cpp
@@ -88,7 +88,7 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto executeLoomisActionTag = [&](const char *tag) {
 		InteractionResult interaction;
-		if (!runtime.startupScript().executeActionTag(tag, interaction))
+		if (!runtime.executeActionTag(tag, interaction))
 			return;
 
 		runtime.applyImmediateDialogueInteractionEffects(interaction);
diff --git a/engines/harvester/npc/madam_dialogue.cpp b/engines/harvester/npc/madam_dialogue.cpp
index 4833ed9fad1..45dc543b9c3 100644
--- a/engines/harvester/npc/madam_dialogue.cpp
+++ b/engines/harvester/npc/madam_dialogue.cpp
@@ -64,7 +64,7 @@ Common::Error MadamDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	auto executeActionTagIfSet = [&](const char *actionTag, bool mutatedRuntimeState = false) {
 		InteractionResult interaction;
 		interaction.mutatedRuntimeState = mutatedRuntimeState;
-		if (runtime.startupScript().executeActionTag(actionTag, interaction)) {
+		if (runtime.executeActionTag(actionTag, interaction)) {
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
 		} else if (interaction.mutatedRuntimeState) {
diff --git a/engines/harvester/npc/mom_dialogue.cpp b/engines/harvester/npc/mom_dialogue.cpp
index 608b1ee9434..2d33edcedf4 100644
--- a/engines/harvester/npc/mom_dialogue.cpp
+++ b/engines/harvester/npc/mom_dialogue.cpp
@@ -494,7 +494,7 @@ Common::Error MomDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto executeActionTagIfSet = [&](const char *actionTag) {
 		InteractionResult interaction;
-		if (runtime.startupScript().executeActionTag(actionTag, interaction)) {
+		if (runtime.executeActionTag(actionTag, interaction)) {
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
 		}
diff --git a/engines/harvester/npc/mr_potts_dialogue.cpp b/engines/harvester/npc/mr_potts_dialogue.cpp
index fa6f30cf0e7..4c558a262ae 100644
--- a/engines/harvester/npc/mr_potts_dialogue.cpp
+++ b/engines/harvester/npc/mr_potts_dialogue.cpp
@@ -304,7 +304,7 @@ Common::Error MrPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto executeActionTagIfSet = [&](const char *tag) {
 		InteractionResult interaction;
-		if (!runtime.startupScript().executeActionTag(tag, interaction))
+		if (!runtime.executeActionTag(tag, interaction))
 			return;
 
 		runtime.applyImmediateDialogueInteractionEffects(interaction);
diff --git a/engines/harvester/npc/mrs_potts_dialogue.cpp b/engines/harvester/npc/mrs_potts_dialogue.cpp
index 1f3555828d9..a7e0463006a 100644
--- a/engines/harvester/npc/mrs_potts_dialogue.cpp
+++ b/engines/harvester/npc/mrs_potts_dialogue.cpp
@@ -156,7 +156,7 @@ Common::Error MrsPottsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto executeDialogueActionTag = [&](const char *tag) {
 		InteractionResult interaction;
-		if (runtime.startupScript().executeActionTag(tag, interaction)) {
+		if (runtime.executeActionTag(tag, interaction)) {
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
 		}
diff --git a/engines/harvester/npc/pastorelli_dialogue.cpp b/engines/harvester/npc/pastorelli_dialogue.cpp
index 087b9687367..eea0d2affd6 100644
--- a/engines/harvester/npc/pastorelli_dialogue.cpp
+++ b/engines/harvester/npc/pastorelli_dialogue.cpp
@@ -39,7 +39,7 @@ bool PastorelliDialogueHandler::matchesNpc(const Common::String &npcName) const
 Common::Error PastorelliDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		const Common::String &, DialogueSharedState &) {
 	InteractionResult interaction;
-	if (runtime.startupScript().executeActionTag(kPastorelliTalkActionTag, interaction)) {
+	if (runtime.executeActionTag(kPastorelliTalkActionTag, interaction)) {
 		runtime.applyImmediateDialogueInteractionEffects(interaction);
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 	}
diff --git a/engines/harvester/npc/phelps_dialogue.cpp b/engines/harvester/npc/phelps_dialogue.cpp
index d9f8f17843b..eaaa0f70313 100644
--- a/engines/harvester/npc/phelps_dialogue.cpp
+++ b/engines/harvester/npc/phelps_dialogue.cpp
@@ -88,7 +88,7 @@ Common::Error PhelpsDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 	auto executeDialogueActionTag = [&](const char *tag) {
 		InteractionResult interaction;
-		if (runtime.startupScript().executeActionTag(tag, interaction)) {
+		if (runtime.executeActionTag(tag, interaction)) {
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
 		}
diff --git a/engines/harvester/npc/priest_dialogue.cpp b/engines/harvester/npc/priest_dialogue.cpp
index 714b4e5358c..b8c41052590 100644
--- a/engines/harvester/npc/priest_dialogue.cpp
+++ b/engines/harvester/npc/priest_dialogue.cpp
@@ -52,7 +52,7 @@ Common::Error PriestDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto executeDialogueActionTag = [&](const char *tag) {
 		InteractionResult interaction;
-		if (runtime.startupScript().executeActionTag(tag, interaction)) {
+		if (runtime.executeActionTag(tag, interaction)) {
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
 		}
diff --git a/engines/harvester/npc/sergeant_dialogue.cpp b/engines/harvester/npc/sergeant_dialogue.cpp
index 147c0191c26..538a3e83357 100644
--- a/engines/harvester/npc/sergeant_dialogue.cpp
+++ b/engines/harvester/npc/sergeant_dialogue.cpp
@@ -90,7 +90,7 @@ Common::Error SergeantDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto executeActionTagIfSet = [&](const char *actionTag) -> Common::Error {
 		InteractionResult interaction;
-		if (!runtime.startupScript().executeActionTag(actionTag, interaction))
+		if (!runtime.executeActionTag(actionTag, interaction))
 			return Common::kNoError;
 
 		runtime.applyImmediateDialogueInteractionEffects(interaction);
diff --git a/engines/harvester/npc/sparky_dialogue.cpp b/engines/harvester/npc/sparky_dialogue.cpp
index bff936c16f4..0ff70b3d841 100644
--- a/engines/harvester/npc/sparky_dialogue.cpp
+++ b/engines/harvester/npc/sparky_dialogue.cpp
@@ -58,7 +58,7 @@ Common::Error SparkyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto executeDialogueActionTag = [&](const char *tag) {
 		InteractionResult interaction;
-		if (runtime.startupScript().executeActionTag(tag, interaction)) {
+		if (runtime.executeActionTag(tag, interaction)) {
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
 		}
diff --git a/engines/harvester/npc/stephanie_dialogue.cpp b/engines/harvester/npc/stephanie_dialogue.cpp
index 48a799c48ec..081fd8d8b1b 100644
--- a/engines/harvester/npc/stephanie_dialogue.cpp
+++ b/engines/harvester/npc/stephanie_dialogue.cpp
@@ -66,7 +66,7 @@ Common::Error StephanieDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto executeDialogueActionTag = [&](const char *tag) {
 		InteractionResult interaction;
-		if (runtime.startupScript().executeActionTag(tag, interaction)) {
+		if (runtime.executeActionTag(tag, interaction)) {
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
 		}
diff --git a/engines/harvester/npc/whaley_dialogue.cpp b/engines/harvester/npc/whaley_dialogue.cpp
index 06edc5d1c10..192a454662f 100644
--- a/engines/harvester/npc/whaley_dialogue.cpp
+++ b/engines/harvester/npc/whaley_dialogue.cpp
@@ -162,7 +162,7 @@ Common::Error WhaleyDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	};
 	auto executeActionTagIfSet = [&](const char *actionTag) {
 		InteractionResult interaction;
-		if (runtime.startupScript().executeActionTag(actionTag, interaction)) {
+		if (runtime.executeActionTag(actionTag, interaction)) {
 			runtime.applyImmediateDialogueInteractionEffects(interaction);
 			runtime.queueDialogueInteractionIfNeeded(interaction);
 		}


Commit: 9b1541d3a971fd428fbb31b827757c4851e55e5f
    https://github.com/scummvm/scummvm/commit/9b1541d3a971fd428fbb31b827757c4851e55e5f
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:51+02:00

Commit Message:
HARVESTER: Route Potts flesh NPC dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/mr_potts_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 42fb2073631..466e9326ea9 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 16, 2026, confirmed in Ghidra `dispatch_room_event_actions` that native `KILL_PC` maps arg1 damage strings such as `SLASH` onto the player actor death damage type, then sets player HP to zero. Updated script interaction results and room interaction handling so script-driven `KILL_PC` starts the player defeat sequence after timer/object runtime mutations are applied.
+- On April 16, 2026, confirmed `HARVEST.SCR` room `FLESH` enters through `D2_2_F1`, enables `TALK_HERPOTT_T`, and issues `START_DIALOG "POTTS_FLESH"` when `PC_TALKED_TO_POTTS` is false. Confirmed in Ghidra that native dialogue dispatch table entry `POTTS_FLESH` points to `handle_talk_to_mr_potts` like the `MR_POTTS` variants, and patched `MrPottsDialogueHandler::matchesNpc()` to accept `POTTS_FLESH`.
 
 ## Next Suggested Action
 
-- Re-test the PIT/JAWS roof timer chain through `JAWROOF10T` and verify `BODYCRSH.WAV` plays, `JAWROOF10` is materialized, the player uses the `SLASH` death animation bank, and the game-over return happens after the death animation completes.
+- Re-test entering `FLESH` from `D2_2_F1` with `HARVEST_BLADE` in inventory and verify the `POTTS_FLESH` dialogue starts automatically, `PC_TALKED_TO_POTTS` is set, `MONSTERFY_POTTS` runs after the dialogue branch, and `POTTS_FLESH` can also be clicked/used as a talkable room NPC.
diff --git a/engines/harvester/npc/mr_potts_dialogue.cpp b/engines/harvester/npc/mr_potts_dialogue.cpp
index 4c558a262ae..25727c75791 100644
--- a/engines/harvester/npc/mr_potts_dialogue.cpp
+++ b/engines/harvester/npc/mr_potts_dialogue.cpp
@@ -262,6 +262,7 @@ static const int kMrPottsPeepholeTopicResponseLines[] = {
 
 bool MrPottsDialogueHandler::matchesNpc(const Common::String &npcName) const {
 	return npcName.equalsIgnoreCase("MR_POTTS") ||
+		npcName.equalsIgnoreCase("POTTS_FLESH") ||
 		npcName.equalsIgnoreCase("MR_POTTS_CEM10") ||
 		npcName.equalsIgnoreCase("MR_POTTS_HALL");
 }


Commit: 75486a167ded527cb6738714ee7a48bf5b302d45
    https://github.com/scummvm/scummvm/commit/75486a167ded527cb6738714ee7a48bf5b302d45
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:51+02:00

Commit Message:
HARVESTER: Queue Dark Woman monsterfy dialogue

Changed paths:
    TRACKER.md
    engines/harvester/npc/dark_woman_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 466e9326ea9..d40449a4793 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 16, 2026, confirmed `HARVEST.SCR` room `FLESH` enters through `D2_2_F1`, enables `TALK_HERPOTT_T`, and issues `START_DIALOG "POTTS_FLESH"` when `PC_TALKED_TO_POTTS` is false. Confirmed in Ghidra that native dialogue dispatch table entry `POTTS_FLESH` points to `handle_talk_to_mr_potts` like the `MR_POTTS` variants, and patched `MrPottsDialogueHandler::matchesNpc()` to accept `POTTS_FLESH`.
+- On April 16, 2026, confirmed `HARVEST.SCR` room `NARCISSISM` spawns NPC `DARK_WOMAN` with monsterfy target `DARK_WOMAN_MONSTER`, and confirmed in Ghidra that `handle_talk_to_dark_woman` plays insult response line `0x4ce2` then calls `queue_live_named_npc_death_or_monsterfy_transition("DARK_WOMAN")`. Updated the Dark Woman insult branches to queue the live NPC monsterfy transition and mark the current room visual state dirty.
 
 ## Next Suggested Action
 
-- Re-test entering `FLESH` from `D2_2_F1` with `HARVEST_BLADE` in inventory and verify the `POTTS_FLESH` dialogue starts automatically, `PC_TALKED_TO_POTTS` is set, `MONSTERFY_POTTS` runs after the dialogue branch, and `POTTS_FLESH` can also be clicked/used as a talkable room NPC.
+- Re-test entering `NARCISSISM` from `C1_2_G2`, choose "Uh, you're not that hot...", and verify `DARK_WOMAN` begins the monsterfy animation, `DARK_WOMAN_MONSTER` appears active afterward, and the `SET_D_W_FLAG` death command opens exits `G_1` and `G_3` after the monster is defeated.
diff --git a/engines/harvester/npc/dark_woman_dialogue.cpp b/engines/harvester/npc/dark_woman_dialogue.cpp
index ea9eaf25d42..87683429d43 100644
--- a/engines/harvester/npc/dark_woman_dialogue.cpp
+++ b/engines/harvester/npc/dark_woman_dialogue.cpp
@@ -50,18 +50,20 @@ Common::Error DarkWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	auto playDarkWomanLine = [&](int wavId, int headVariant = 0) -> Common::Error {
 		return runtime.playDialogueLineWithVariant(wavId, kDarkWomanNpc, headVariant);
 	};
-	auto queueDarkWomanDeathOrMonsterfyTransition = [&]() {
+	auto queueDarkWomanMonsterfyTransition = [&]() {
 		InteractionResult interaction;
-		if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(kDarkWomanNpc))
+		if (runtime.startupScript().queueRuntimeNpcDeathOrMonsterfy(kDarkWomanNpc)) {
 			interaction.mutatedRuntimeState = true;
+			interaction.visualRuntimeStateChanged = true;
+		}
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 	};
-	auto runDarkWomanDeathBranch = [&]() -> Common::Error {
+	auto runDarkWomanMonsterfyBranch = [&]() -> Common::Error {
 		Common::Error lineError = playDarkWomanLine(0x4ce2, 2);
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
-		queueDarkWomanDeathOrMonsterfyTransition();
+		queueDarkWomanMonsterfyTransition();
 		return Common::kNoError;
 	};
 	auto runMirrorRevealBranch = [&]() -> Common::Error {
@@ -109,7 +111,7 @@ Common::Error DarkWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return responseError;
 
 	if (responseIndex == 1)
-		return runDarkWomanDeathBranch();
+		return runDarkWomanMonsterfyBranch();
 	if (responseIndex != 2)
 		return Common::kNoError;
 
@@ -124,7 +126,7 @@ Common::Error DarkWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return responseError;
 
 	if (responseIndex == 1)
-		return runDarkWomanDeathBranch();
+		return runDarkWomanMonsterfyBranch();
 	if (responseIndex != 2)
 		return Common::kNoError;
 
@@ -139,7 +141,7 @@ Common::Error DarkWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return responseError;
 
 	if (responseIndex == 1)
-		return runDarkWomanDeathBranch();
+		return runDarkWomanMonsterfyBranch();
 	if (responseIndex != 2)
 		return Common::kNoError;
 
@@ -156,7 +158,7 @@ Common::Error DarkWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		return responseError;
 
 	if (responseIndex == 1)
-		return runDarkWomanDeathBranch();
+		return runDarkWomanMonsterfyBranch();
 	if (!hasHandMirror)
 		return playDarkWomanLine(0x4cfc, 2);
 


Commit: 5975cf10ebd751128bdae4564d51c4d4479a0019
    https://github.com/scummvm/scummvm/commit/5975cf10ebd751128bdae4564d51c4d4479a0019
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:52+02:00

Commit Message:
HARVESTER: Queue live monsterfy dialogue transitions

Changed paths:
    TRACKER.md
    engines/harvester/npc/beggar_dialogue.cpp
    engines/harvester/npc/gladiator_dialogue.cpp
    engines/harvester/npc/madam_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index d40449a4793..fc58f0922d4 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 16, 2026, confirmed `HARVEST.SCR` room `NARCISSISM` spawns NPC `DARK_WOMAN` with monsterfy target `DARK_WOMAN_MONSTER`, and confirmed in Ghidra that `handle_talk_to_dark_woman` plays insult response line `0x4ce2` then calls `queue_live_named_npc_death_or_monsterfy_transition("DARK_WOMAN")`. Updated the Dark Woman insult branches to queue the live NPC monsterfy transition and mark the current room visual state dirty.
+- On April 16, 2026, checked Inquisitor, Gladiator, and Beggar monsterfy dialogue paths against `HARVEST.SCR` and Ghidra. Confirmed `handle_talk_to_gladiator` calls `queue_live_named_npc_death_or_monsterfy_transition("GLADIATOR")` after hostile branches, and `handle_talk_to_beggar` calls `queue_live_named_npc_death_or_monsterfy_transition("BEGGAR")` at dialogue completion. Updated both handlers to queue the live monsterfy transition and mark current room visual state dirty. Confirmed Inquisitor dialogue only dispatches `START_INQ_TIM`; its later `MONSTERFY "INQUISITOR"` remains script/timer-driven.
 
 ## Next Suggested Action
 
-- Re-test entering `NARCISSISM` from `C1_2_G2`, choose "Uh, you're not that hot...", and verify `DARK_WOMAN` begins the monsterfy animation, `DARK_WOMAN_MONSTER` appears active afterward, and the `SET_D_W_FLAG` death command opens exits `G_1` and `G_3` after the monster is defeated.
+- Re-test `MERCY` hostile Gladiator branches and `CHARITY` Beggar dialogue completion, and verify each live NPC begins the monsterfy animation before its monster record becomes active. Also re-test `PAIN` long enough for `INQUIST_ATTACK_TIMER` to fire and verify Inquisitor still monsterfies through the script timer path.
diff --git a/engines/harvester/npc/beggar_dialogue.cpp b/engines/harvester/npc/beggar_dialogue.cpp
index 5890eb68fd9..05f7de92c01 100644
--- a/engines/harvester/npc/beggar_dialogue.cpp
+++ b/engines/harvester/npc/beggar_dialogue.cpp
@@ -49,6 +49,14 @@ Common::Error BeggarDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	auto playPcLine = [&](int wavId, int headVariant = 0) -> Common::Error {
 		return runtime.playDialogueLineWithVariant(wavId, kPcSpeaker, headVariant);
 	};
+	auto queueBeggarMonsterfyTransition = [&]() {
+		InteractionResult interaction;
+		if (runtime.startupScript().queueRuntimeNpcDeathOrMonsterfy(kBeggarNpc)) {
+			interaction.mutatedRuntimeState = true;
+			interaction.visualRuntimeStateChanged = true;
+		}
+		runtime.queueDialogueInteractionIfNeeded(interaction);
+	};
 
 	if (!runtime.startupScript().getFlagValue(kBeggarInterruptFlag))
 		return Common::kNoError;
@@ -82,6 +90,7 @@ Common::Error BeggarDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	} else if (responseIndex == 2) {
 		lineError = playBeggarLine(0x1291, 3);
 	} else {
+		queueBeggarMonsterfyTransition();
 		return Common::kNoError;
 	}
 	if (lineError.getCode() != Common::kNoError)
@@ -97,11 +106,17 @@ Common::Error BeggarDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	if (responseError.getCode() != Common::kNoError)
 		return responseError;
 
-	if (responseIndex == 1)
-		return playBeggarLine(0x12ab, 2);
-	if (responseIndex == 2)
-		return playBeggarLine(0x12a5, 2);
+	if (responseIndex == 1) {
+		lineError = playBeggarLine(0x12ab, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	} else if (responseIndex == 2) {
+		lineError = playBeggarLine(0x12a5, 2);
+		if (lineError.getCode() != Common::kNoError)
+			return lineError;
+	}
 
+	queueBeggarMonsterfyTransition();
 	return Common::kNoError;
 }
 
diff --git a/engines/harvester/npc/gladiator_dialogue.cpp b/engines/harvester/npc/gladiator_dialogue.cpp
index b53ae7049a8..2a1b5926c4b 100644
--- a/engines/harvester/npc/gladiator_dialogue.cpp
+++ b/engines/harvester/npc/gladiator_dialogue.cpp
@@ -46,10 +46,12 @@ Common::Error GladiatorDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	auto playGladiatorLine = [&](int wavId, int headVariant = 0) -> Common::Error {
 		return runtime.playDialogueLineWithVariant(wavId, kGladiatorNpc, headVariant);
 	};
-	auto queueGladiatorDeathOrMonsterfyTransition = [&]() {
+	auto queueGladiatorMonsterfyTransition = [&]() {
 		InteractionResult interaction;
-		if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(kGladiatorNpc))
+		if (runtime.startupScript().queueRuntimeNpcDeathOrMonsterfy(kGladiatorNpc)) {
 			interaction.mutatedRuntimeState = true;
+			interaction.visualRuntimeStateChanged = true;
+		}
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 	};
 	auto runGladiatorDeathBranch = [&](int leadWavId) -> Common::Error {
@@ -61,7 +63,7 @@ Common::Error GladiatorDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
-		queueGladiatorDeathOrMonsterfyTransition();
+		queueGladiatorMonsterfyTransition();
 		return Common::kNoError;
 	};
 
diff --git a/engines/harvester/npc/madam_dialogue.cpp b/engines/harvester/npc/madam_dialogue.cpp
index 45dc543b9c3..cc6870879b0 100644
--- a/engines/harvester/npc/madam_dialogue.cpp
+++ b/engines/harvester/npc/madam_dialogue.cpp
@@ -55,10 +55,12 @@ Common::Error MadamDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	auto playPcLine = [&](int wavId, int headVariant = 0) -> Common::Error {
 		return runtime.playDialogueLineWithVariant(wavId, kPcSpeaker, headVariant);
 	};
-	auto queueHookerDeathOrMonsterfyTransition = [&]() {
+	auto queueHookerMonsterfyTransition = [&]() {
 		InteractionResult interaction;
-		if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(kHookerNpc))
+		if (runtime.startupScript().queueRuntimeNpcDeathOrMonsterfy(kHookerNpc)) {
 			interaction.mutatedRuntimeState = true;
+			interaction.visualRuntimeStateChanged = true;
+		}
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 	};
 	auto executeActionTagIfSet = [&](const char *actionTag, bool mutatedRuntimeState = false) {
@@ -95,7 +97,7 @@ Common::Error MadamDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
-		queueHookerDeathOrMonsterfyTransition();
+		queueHookerMonsterfyTransition();
 		return Common::kNoError;
 	}
 
@@ -116,7 +118,7 @@ Common::Error MadamDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
-		queueHookerDeathOrMonsterfyTransition();
+		queueHookerMonsterfyTransition();
 		return Common::kNoError;
 	}
 
@@ -134,7 +136,7 @@ Common::Error MadamDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (lineError.getCode() != Common::kNoError)
 			return lineError;
 
-		queueHookerDeathOrMonsterfyTransition();
+		queueHookerMonsterfyTransition();
 		return Common::kNoError;
 	}
 	if (responseIndex != 2)


Commit: fd7652e627109dcdf2a7cf38e02834f6a179d7ee
    https://github.com/scummvm/scummvm/commit/fd7652e627109dcdf2a7cf38e02834f6a179d7ee
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:52+02:00

Commit Message:
HARVESTER: Match native monsterfy actor placement

Changed paths:
    TRACKER.md
    engines/harvester/flow.cpp
    engines/harvester/monster.cpp
    engines/harvester/room.cpp
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/TRACKER.md b/TRACKER.md
index fc58f0922d4..d523c7616c0 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,15 +3,15 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `928`
-- Named/documented: `824`
+- Total functions: `929`
+- Named/documented: `825`
 - Remaining `FUN_*`: `104`
 - Remaining undocumented total: `104`
 
 ## Last Confirmed Action
 
-- On April 16, 2026, checked Inquisitor, Gladiator, and Beggar monsterfy dialogue paths against `HARVEST.SCR` and Ghidra. Confirmed `handle_talk_to_gladiator` calls `queue_live_named_npc_death_or_monsterfy_transition("GLADIATOR")` after hostile branches, and `handle_talk_to_beggar` calls `queue_live_named_npc_death_or_monsterfy_transition("BEGGAR")` at dialogue completion. Updated both handlers to queue the live monsterfy transition and mark current room visual state dirty. Confirmed Inquisitor dialogue only dispatches `START_INQ_TIM`; its later `MONSTERFY "INQUISITOR"` remains script/timer-driven.
+- On April 16, 2026, checked the Inquisitor live `MONSTERFY` spawn path against Ghidra. Confirmed native `update_actor_runtime_state` copies the dying NPC entity render Z into the target monster record before spawning it, native NPC/monster records spawn with z extent `5`, and native active monster spawn seeds a standing facing frame rather than a walking loop. Updated the Harvester shim to mirror those three behaviors.
 
 ## Next Suggested Action
 
-- Re-test `MERCY` hostile Gladiator branches and `CHARITY` Beggar dialogue completion, and verify each live NPC begins the monsterfy animation before its monster record becomes active. Also re-test `PAIN` long enough for `INQUIST_ATTACK_TIMER` to fire and verify Inquisitor still monsterfies through the script timer path.
+- Re-test `PAIN` through `INQUIST_ATTACK_TIMER` and Inquisitor defeat. Verify the monster appears without a screen-depth jump, remains visually stable while standing, then uses the walking bank only while actually chasing.
diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 094331f61c5..4b65669bd2f 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -84,6 +84,7 @@ static const float kTownMapNightPaletteBrightness = 0.6f;
 // Native keeps WAIT_BM visible while room construction runs; fast local loads need a short floor.
 static const uint32 kTransitionWaitFrameMinMs = 120;
 static const int kRoomNpcAmbientLastFrame = 0x3b;
+static const float kNativeNpcMonsterZExtent = 5.0f;
 static const int kTownMapEdgeThreshold = 9;
 static const int kTownMapCursorHitExtent = 5;
 
@@ -2054,6 +2055,7 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 		}
 
 		entity->setClassId(kRuntimeEntityClassNpc);
+		entity->setZExtent(kNativeNpcMonsterZExtent);
 		const bool isCorpse = !npc.active &&
 			npc.runtimeSpawned &&
 			npc.deathDamageType != 0 &&
@@ -2095,6 +2097,7 @@ bool Flow::populateRoomSceneEntities(RoomSetupState &state,
 		}
 
 		entity->setClassId(kRuntimeEntityClassMonster);
+		entity->setZExtent(kNativeNpcMonsterZExtent);
 		entity->setHitTestMode(kRuntimeEntityHitTestNone);
 		entity->setVisible(monster.visible);
 		// Native restores a saved monster corpse frame before set_entity_screen_position().
diff --git a/engines/harvester/monster.cpp b/engines/harvester/monster.cpp
index 7c7ff52589f..50be328beaf 100644
--- a/engines/harvester/monster.cpp
+++ b/engines/harvester/monster.cpp
@@ -27,8 +27,6 @@ namespace Harvester {
 
 namespace {
 
-static const int kRoomMonsterAnimationRate = 17;
-
 struct MonsterAnimationRange {
 	MonsterAnimationRange() {}
 	MonsterAnimationRange(int walkFirstFrame, int walkLastFrame, int idleFrame)
@@ -54,6 +52,14 @@ static MonsterAnimationRange resolveAnimationRange(int facing) {
 	}
 }
 
+static int resolveStandingFrame(const Entity &entity, const MonsterAnimationRange &range) {
+	if (entity.hasOpaqueFramesInRange(range.idleFrame, range.idleFrame))
+		return range.idleFrame;
+	if (entity.hasOpaqueFramesInRange(range.walkFirstFrame, range.walkFirstFrame))
+		return range.walkFirstFrame;
+	return CLIP<int>(range.idleFrame, 0, MAX(0, entity.getLastFrame()));
+}
+
 } // End of anonymous namespace
 
 int Monster::resolveFacingFrame(int facing) {
@@ -82,16 +88,12 @@ void Monster::applyAnimation(Entity &entity, const MonsterRecord &monster) {
 	}
 
 	const MonsterAnimationRange range = resolveAnimationRange(monster.facing);
-	if (monster.active) {
-		entity.setAnimationRate(kRoomMonsterAnimationRate);
-		entity.setAnimationFrameRange(range.walkFirstFrame, range.walkLastFrame, true);
-		entity.setAnimationEnabled(true);
-	} else {
-		entity.setAnimationRate(0);
-		entity.setAnimationFrameRange(range.idleFrame, range.idleFrame, false);
-		entity.setCurrentFrame(range.idleFrame);
-		entity.setAnimationEnabled(false);
-	}
+	// Native active monster spawn starts on a standing facing frame; chase code switches to walking.
+	const int standingFrame = resolveStandingFrame(entity, range);
+	entity.setAnimationRate(0);
+	entity.setAnimationFrameRange(standingFrame, standingFrame, false);
+	entity.setCurrentFrame(standingFrame);
+	entity.setAnimationEnabled(false);
 }
 
 } // End of namespace Harvester
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 42c2eb0c0ee..0aafd6b1ea1 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -66,6 +66,7 @@ static const int kRoomMonsterAnimationRate = 17;
 static const int kNativeMonsterAttackAnimationRate = 4;
 static const int kNativeMonsterHitAnimationRate = 5;
 static const uint32 kNativeMonsterAttackCooldownBaseTicks = 50;
+static const float kNativeNpcMonsterZExtent = 5.0f;
 static const int kRoomNpcAmbientLastFrame = 0x3b;
 static const int kDefaultMonsterAttackContactFrameOffset = 2;
 static const int kMuckeyAttackContactFrameOffset = 7;
@@ -573,11 +574,14 @@ static bool setRoomMonsterAnimation(Entity &entity, int facing, bool walking) {
 		return changed;
 	}
 
+	const int standingFrame = entity.hasOpaqueFramesInRange(range.idleFrame, range.idleFrame)
+		? range.idleFrame
+		: range.walkFirstFrame;
 	const bool changed = entity.getAnimationRate() != 0 ||
-		entity.getCurrentFrame() != range.idleFrame;
+		entity.getCurrentFrame() != standingFrame;
 	entity.setAnimationRate(0);
-	entity.setAnimationFrameRange(range.idleFrame, range.idleFrame, false);
-	entity.setCurrentFrame(range.idleFrame);
+	entity.setAnimationFrameRange(standingFrame, standingFrame, false);
+	entity.setCurrentFrame(standingFrame);
 	entity.setAnimationEnabled(false);
 	return changed;
 }
@@ -1394,6 +1398,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				return nullptr;
 
 			entity->setClassId(kRuntimeEntityClassNpc);
+			entity->setZExtent(kNativeNpcMonsterZExtent);
 			entity->setHitTestMode(preservedCorpse ? kRuntimeEntityHitTestNone : kRuntimeEntityHitTestOpaquePixels);
 			if (preservedCorpse) {
 				const int corpseFrame = MIN(entity->getLastFrame(), npc.runtimeState);
@@ -1426,6 +1431,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				return nullptr;
 
 			entity->setClassId(kRuntimeEntityClassMonster);
+			entity->setZExtent(kNativeNpcMonsterZExtent);
 			entity->setHitTestMode(kRuntimeEntityHitTestNone);
 			entity->setVisible(monster.visible);
 			Monster::applyAnimation(*entity, monster);
@@ -3009,6 +3015,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		Entity *npcEntity = findSceneRuntimeEntity(npc.npcName);
 		const bool preserveDeathFrame =
 			shouldPreserveBigEyeDeathFrame(npc) && combatState.deathLastFrame >= 0 && npcEntity;
+		const int monsterfyPosZ = (!npc.monsterfyTargetName.empty() && npcEntity)
+			? roundRoomCombatFloat(npcEntity->getZ())
+			: Script::kNoMonsterfyPosZOverride;
 		if (preserveDeathFrame) {
 			npcEntity->setAnimationRate(0);
 			npcEntity->setAnimationFrameRange(combatState.deathLastFrame, combatState.deathLastFrame, false);
@@ -3029,7 +3038,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			npc.deathDamageType = combatState.deathDamageType;
 		const bool runtimeChanged = script
 			? script->finalizeRuntimeNpcDeathOrMonsterfy(
-				npc.npcName, npc.deathDamageType, preserveDeathFrame, npc.runtimeState)
+				npc.npcName, npc.deathDamageType, preserveDeathFrame, npc.runtimeState, monsterfyPosZ)
 			: false;
 		debugC(1, kDebugCombat,
 			"Harvester: combat npc death complete target='%s' damage_type=%d last_frame=%d preserve_runtime_actor=%d monsterfy='%s' on_death='%s'",
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index d235bff2364..8e84815ac79 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -2632,7 +2632,7 @@ bool Script::queueRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int
 }
 
 bool Script::finalizeRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType,
-		bool preserveCorpse, int corpseFrame) {
+		bool preserveCorpse, int corpseFrame, int monsterfyPosZ) {
 	if (npcName.empty())
 		return false;
 
@@ -2647,7 +2647,11 @@ bool Script::finalizeRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, i
 		monsterLookup.monsterName = currentNpc->monsterfyTargetName;
 		MonsterRecord *currentMonster = findRuntimeMonster(monsterLookup);
 		if (currentMonster) {
-			monsterChanged = !currentMonster->active || !currentMonster->visible;
+			const bool overrideMonsterPosZ = monsterfyPosZ != kNoMonsterfyPosZOverride;
+			monsterChanged = !currentMonster->active || !currentMonster->visible ||
+				(overrideMonsterPosZ && currentMonster->posZ != monsterfyPosZ);
+			if (overrideMonsterPosZ)
+				currentMonster->posZ = monsterfyPosZ;
 			currentMonster->active = true;
 			currentMonster->visible = true;
 			currentMonster->runtimeSpawned = false;
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index c2e90761208..27c768b0ae5 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -404,8 +404,10 @@ public:
 		bool visible);
 	bool setRuntimeNpcState(const Common::String &npcName, bool active, bool visible);
 	bool queueRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType = 0);
+	enum { kNoMonsterfyPosZOverride = 0x7fffffff };
 	bool finalizeRuntimeNpcDeathOrMonsterfy(const Common::String &npcName, int deathDamageType = 0,
-		bool preserveCorpse = false, int corpseFrame = -1);
+		bool preserveCorpse = false, int corpseFrame = -1,
+		int monsterfyPosZ = kNoMonsterfyPosZOverride);
 	void pushDeferredLiveNpcDeathTransitions();
 	void popDeferredLiveNpcDeathTransitions();
 	bool isNamedNpcDeathTypeClear(const Common::String &npcName) const;


Commit: ba716a4db77ef4e77d9c8312051fd3bec5709d4d
    https://github.com/scummvm/scummvm/commit/ba716a4db77ef4e77d9c8312051fd3bec5709d4d
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:52+02:00

Commit Message:
HARVESTER: Queue Grand Muck live transition

Changed paths:
    TRACKER.md
    engines/harvester/npc/herrill_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index d523c7616c0..79ad14bb8d3 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 16, 2026, checked the Inquisitor live `MONSTERFY` spawn path against Ghidra. Confirmed native `update_actor_runtime_state` copies the dying NPC entity render Z into the target monster record before spawning it, native NPC/monster records spawn with z extent `5`, and native active monster spawn seeds a standing facing frame rather than a walking loop. Updated the Harvester shim to mirror those three behaviors.
+- On April 16, 2026, checked the Inner Sanctum Herrill/Grand Muck dialogue branch against Ghidra. Confirmed the native branch plays the logged dialogue, then calls `queue_live_named_npc_death_or_monsterfy_transition` with `GRAND_MUCK` before setting `PC_TALKED_TO_HERRILL`. Updated the Harvester dialogue shim to queue the live Grand Muck NPC transition instead of finalizing the runtime NPC immediately.
 
 ## Next Suggested Action
 
-- Re-test `PAIN` through `INQUIST_ATTACK_TIMER` and Inquisitor defeat. Verify the monster appears without a screen-depth jump, remains visually stable while standing, then uses the walking bank only while actually chasing.
+- Re-test `INNERSANCTUM` from entrance `L2_2_M1`. Verify the Herrill intro dialogue ends by visibly transitioning Grand Muck in-place, and that `PC_TALKED_TO_HERRILL` prevents the intro from replaying on re-entry.
diff --git a/engines/harvester/npc/herrill_dialogue.cpp b/engines/harvester/npc/herrill_dialogue.cpp
index 885837f37c9..7ffcadd4c84 100644
--- a/engines/harvester/npc/herrill_dialogue.cpp
+++ b/engines/harvester/npc/herrill_dialogue.cpp
@@ -34,7 +34,7 @@ static const char *const kHerrillNpc = "HERRILL";
 static const char *const kHerrillLogNpc = "HERRILL_LOG";
 static const char *const kPcSpeaker = "PC";
 static const char *const kGrandMuckNpc = "GRAND_MUCK";
-static const int kSlashDeathDamageType = 1;
+static const int kGrandMuckDeathDamageType = 1;
 
 static const int kHerrillGascanResponseLine = 0x280;
 static const int kHerrillTopicBufferResponseLine = 0x281;
@@ -82,11 +82,12 @@ Common::Error HerrillDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	auto playGrandMuckLine = [&](int wavId, int headVariant = 0) -> Common::Error {
 		return runtime.playDialogueLineWithVariant(wavId, kGrandMuckNpc, headVariant);
 	};
-	auto queueNpcSlashTransition = [&](const char *npcName) {
+	auto queueLiveNpcDeathTransition = [&](const char *npcName) {
 		InteractionResult interaction;
-		if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(
-				npcName, kSlashDeathDamageType)) {
+		if (runtime.startupScript().queueRuntimeNpcDeathOrMonsterfy(
+				npcName, kGrandMuckDeathDamageType)) {
 			interaction.mutatedRuntimeState = true;
+			interaction.visualRuntimeStateChanged = true;
 		}
 		runtime.queueDialogueInteractionIfNeeded(interaction);
 	};
@@ -138,7 +139,7 @@ Common::Error HerrillDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 					return lineError;
 			}
 
-			queueNpcSlashTransition(kGrandMuckNpc);
+			queueLiveNpcDeathTransition(kGrandMuckNpc);
 			(void)runtime.startupScript().setRuntimeFlagValue("PC_TALKED_TO_HERRILL", true);
 		}
 		return Common::kNoError;


Commit: b4dfa20d31ba586ba24401438cd336e47465f954
    https://github.com/scummvm/scummvm/commit/b4dfa20d31ba586ba24401438cd336e47465f954
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:53+02:00

Commit Message:
HARVESTER: Keep NPC death sprites in current room

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 79ad14bb8d3..ede52866fd0 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 16, 2026, checked the Inner Sanctum Herrill/Grand Muck dialogue branch against Ghidra. Confirmed the native branch plays the logged dialogue, then calls `queue_live_named_npc_death_or_monsterfy_transition` with `GRAND_MUCK` before setting `PC_TALKED_TO_HERRILL`. Updated the Harvester dialogue shim to queue the live Grand Muck NPC transition instead of finalizing the runtime NPC immediately.
+- On April 16, 2026, checked native class-4 NPC death handling in `HARVEST.LE` against Ghidra. Confirmed `update_actor_runtime_state` keeps a completed non-monsterfied NPC death as current-room actor state `0x38` while setting `NpcRecord.death_or_monsterfy_flag` for later room setup suppression. Updated ScummVM room combat handling so MERCY old man/old woman death sprites remain in the current room after their on-death script refresh.
 
 ## Next Suggested Action
 
-- Re-test `INNERSANCTUM` from entrance `L2_2_M1`. Verify the Herrill intro dialogue ends by visibly transitioning Grand Muck in-place, and that `PC_TALKED_TO_HERRILL` prevents the intro from replaying on re-entry.
+- Re-test `MERCY` from entrance `K1_2_I2`. Kill `OLDMAN` and `OLDWOMAN`, verify both death sprites remain visible after `OLD_MAN_COM` / `OLD_WOMAN_COM` actions refresh the room, and verify leaving/re-entering still suppresses the killed NPCs.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 0aafd6b1ea1..34637930993 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -85,10 +85,15 @@ static const int kCombatDebugDamagePopupVerticalGap = 6;
 static const char *const kCdChangePromptPalettePath = "1:/GRAPHIC/PAL/CD1.PAL";
 static const char *const kExitCloseupPendingRoomChange = "__EXIT_CLOSEUP__";
 
-static bool shouldPreserveBigEyeDeathFrame(const NpcRecord &npc) {
-	return npc.npcName.equalsIgnoreCase("BIG_EYE") &&
-		npc.onDeathActionTag.equalsIgnoreCase("EYEDOOR") &&
-		npc.monsterfyTargetName.empty();
+static bool shouldRetainNpcDeathEntityInCurrentRoom(const NpcRecord &npc) {
+	return npc.monsterfyTargetName.empty();
+}
+
+static bool isRetainedCurrentRoomNpcDeathRecord(const NpcRecord &npc) {
+	return npc.deathOrMonsterfyFlag &&
+		npc.monsterfyTargetName.empty() &&
+		npc.deathDamageType != 0 &&
+		npc.runtimeState >= 0;
 }
 
 static int roundRoomCombatFloat(float value);
@@ -1806,11 +1811,22 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			for (const NpcRecord &npc : scene.state.roomNpcs) {
 				const NpcRecord *updatedNpc =
 					findRoomNpcByNameConst(updatedState.roomNpcs, npc.npcName);
+				const bool keepCurrentRoomDeathEntity =
+					!updatedNpc &&
+					isRetainedCurrentRoomNpcDeathRecord(npc) &&
+					entityManager &&
+					entityManager->findSceneEntityByName(npc.npcName);
 				const bool keepEntity =
 					updatedNpc &&
 					shouldSpawnRoomNpcEntity(*updatedNpc) &&
 					(sameNpcEntityState(npc, *updatedNpc) ||
 						isQueuedLiveNpcDeathMutation(npc, *updatedNpc));
+				if (keepCurrentRoomDeathEntity) {
+					debugC(1, kDebugCombat,
+						"Harvester: retained current-room npc death entity npc='%s' damage_type=%d frame=%d",
+						npc.npcName.c_str(), npc.deathDamageType, npc.runtimeState);
+					continue;
+				}
 				if (!keepEntity)
 					removeSceneEntityByName(npc.npcName);
 			}
@@ -3013,12 +3029,16 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			RoomNpcCombatState &combatState) -> Common::Error {
 		Script *script = _engine.getScript();
 		Entity *npcEntity = findSceneRuntimeEntity(npc.npcName);
-		const bool preserveDeathFrame =
-			shouldPreserveBigEyeDeathFrame(npc) && combatState.deathLastFrame >= 0 && npcEntity;
+		// Native state 0x35 leaves non-monsterfied NPCs as a dead state-0x38 actor
+		// in the current room, while the NpcRecord death flag suppresses later setup.
+		const bool retainDeathEntity =
+			shouldRetainNpcDeathEntityInCurrentRoom(npc) &&
+			combatState.deathLastFrame >= 0 &&
+			npcEntity;
 		const int monsterfyPosZ = (!npc.monsterfyTargetName.empty() && npcEntity)
 			? roundRoomCombatFloat(npcEntity->getZ())
 			: Script::kNoMonsterfyPosZOverride;
-		if (preserveDeathFrame) {
+		if (retainDeathEntity) {
 			npcEntity->setAnimationRate(0);
 			npcEntity->setAnimationFrameRange(combatState.deathLastFrame, combatState.deathLastFrame, false);
 			npcEntity->setCurrentFrame(combatState.deathLastFrame);
@@ -3029,21 +3049,21 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			removeSceneEntityByName(npc.npcName);
 		}
 		npc.active = false;
-		npc.visible = preserveDeathFrame;
-		npc.savedVisible = preserveDeathFrame;
-		npc.runtimeSpawned = preserveDeathFrame;
-		npc.runtimeState = preserveDeathFrame ? combatState.deathLastFrame : -1;
-		npc.deathOrMonsterfyFlag = !preserveDeathFrame;
+		npc.visible = false;
+		npc.savedVisible = false;
+		npc.runtimeSpawned = retainDeathEntity;
+		npc.runtimeState = retainDeathEntity ? combatState.deathLastFrame : -1;
+		npc.deathOrMonsterfyFlag = true;
 		if (combatState.deathDamageType != 0)
 			npc.deathDamageType = combatState.deathDamageType;
 		const bool runtimeChanged = script
 			? script->finalizeRuntimeNpcDeathOrMonsterfy(
-				npc.npcName, npc.deathDamageType, preserveDeathFrame, npc.runtimeState, monsterfyPosZ)
+				npc.npcName, npc.deathDamageType, false, -1, monsterfyPosZ)
 			: false;
 		debugC(1, kDebugCombat,
 			"Harvester: combat npc death complete target='%s' damage_type=%d last_frame=%d preserve_runtime_actor=%d monsterfy='%s' on_death='%s'",
 			npc.npcName.c_str(), combatState.deathDamageType, combatState.deathLastFrame,
-			preserveDeathFrame, npc.monsterfyTargetName.c_str(), npc.onDeathActionTag.c_str());
+			retainDeathEntity, npc.monsterfyTargetName.c_str(), npc.onDeathActionTag.c_str());
 		clearRoomNpcCombatState(combatState);
 
 		InteractionResult interaction;


Commit: 75a00695d129949c40db1273c45bf5be888d21a6
    https://github.com/scummvm/scummvm/commit/75a00695d129949c40db1273c45bf5be888d21a6
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:53+02:00

Commit Message:
HARVESTER: Fix Stephanie choice prompts

Changed paths:
    engines/harvester/flow.cpp


diff --git a/engines/harvester/flow.cpp b/engines/harvester/flow.cpp
index 4b65669bd2f..c5abbbed634 100644
--- a/engines/harvester/flow.cpp
+++ b/engines/harvester/flow.cpp
@@ -1020,8 +1020,21 @@ void drawRoomInspectText(Graphics::Screen &screen, const Art &art, const Graphic
 		kIdentTextColor);
 }
 
+static bool suppressesInitialObjectInspectGate(const ObjectRecord &object) {
+	// Native HARVEST.SCR uses ident key X as a no-text sentinel.
+	return object.identTextKey.equalsIgnoreCase("X");
+}
+
+static bool usesBareOperatePrompt(const ObjectRecord &object) {
+	return object.objectName.equalsIgnoreCase("HAPPLY_HS") ||
+		object.objectName.equalsIgnoreCase("KILL_STEPH_HS");
+}
+
 bool unlocksRoomObjectInteractionAfterInitialExamine(const ObjectRecord &object,
 		Script &script) {
+	if (suppressesInitialObjectInspectGate(object))
+		return false;
+
 	return object.operatable || script.isPickupObject(object);
 }
 
@@ -1060,8 +1073,11 @@ static Common::String buildRoomObjectPrompt(const ObjectRecord &object, Script &
 	if (label.empty())
 		return Common::String();
 
-	if (cursorSequence == kCursorSequenceOperate)
+	if (cursorSequence == kCursorSequenceOperate) {
+		if (usesBareOperatePrompt(object))
+			return label;
 		return Common::String::format("Operate the %s", label.c_str());
+	}
 	if (cursorSequence == kCursorSequencePickup)
 		return Common::String::format("Pick up the %s", label.c_str());
 	if (cursorSequence == kCursorSequenceTalk)


Commit: bd8bf8414587d6329022ebb7f5382b0e3f777bc7
    https://github.com/scummvm/scummvm/commit/bd8bf8414587d6329022ebb7f5382b0e3f777bc7
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:53+02:00

Commit Message:
HARVESTER: Queue Herrill death after Muckey

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 4a7764bd232..edee8c6604e 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -543,6 +543,7 @@
     - The steady-state no-item tail is also simple: if `KARIN_KIDNAPED` is still clear Edna plays `0x3bf8`, then always ends the branch on `0x3bff`.
   - `handle_talk_to_herrill @ 0x2dc00` is now bounded enough for a direct startup-room port.
     - The early lodge branch is a self-contained cutscene gate: `HERRILL_IN_LODGE` plus a clear `PC_TALKED_TO_HERRILL` flag plays `HERRILL_LOG 0x31ca`, `PC 0x31ce[4]`, `HERRILL_LOG 0x31d2`, `PC 0x31da`, and `GRAND_MUCK 0x31de[2]`, conditionally inserts `GRAND_MUCK 0x31e0[2]` plus `HERRILL_LOG 0x31e2[2]` when the shared Whaley/Herrill-photo flag is already set, then monsterfies `GRAND_MUCK`, sets `PC_TALKED_TO_HERRILL`, and returns. The normal room path only runs when that lodge gate is inactive and still begins with Herrill-local suppression / evidence-required checks.
+    - The Inner Sanctum post-Muckey path has one native-only actor-state behavior outside the parsed room script: `HARVEST.SCR` skips the commented `{COMMAND "KILL_HERR2" ...}` line, but `update_actor_runtime_state` contains a hard-coded `HERRILL_LOG` removal path while Muckey combat state is active. ScummVM queues Herrill's live NPC death when `MUCKEY` completes death and triggers `ACTV_INNERSAN_DOOR`, giving the script's `KILL_HERMUCK_T` delay time to play the death bank before the `C002A`/`C002B` flic chain.
     - The item path is mostly the shared evidence cluster, but not every branch writes the same shared state: `PHOTO_OF_WHALEY_HERRILL` is only the dedicated `0x309f` bark; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` and the dual-ledger case share `0x30a6` and route through the shared corpse-photo / ledger evidence helpers; note/checkbook evidence plays `0x30bf`; TV deed evidence plays `0x3188`; and all other presented objects fall back to `0x3099`.
     - `GASCAN` is the one special item branch. It first plays the fixed `0x30cf` / `0x30d3` / `0x30d8` exchange, then reads neutral shared talk-state bits at `0xd2e98`, `0xd2eb0`, and `0xd2eec` plus the shared Boyle gas-can application bit. When `0xd2e98` is clear the branch collapses to `PC 0x3123`; otherwise it opens zero-based `dialog.rsp` line `0x280`, where response `1` continues through `0x30e7` and the optional `0x30eb` / `0x30f4` / `0x3104` inserts before the `HAVE_LODGE_APP` split, while response `2` exits on `0x311b`.
     - The first no-item visit is gated by `g_herrill_talk_state_block`: it plays `0x2f59` / `0x2f5e` / `0x2f62` / `0x1654` / `0x2f6e` and seeds the keyword buffer from zero-based `dialog.rsp` line `0x281`. Later no-item visits start on `0x2ffb`.
diff --git a/TRACKER.md b/TRACKER.md
index ede52866fd0..17ae84d5478 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -10,8 +10,8 @@
 
 ## Last Confirmed Action
 
-- On April 16, 2026, checked native class-4 NPC death handling in `HARVEST.LE` against Ghidra. Confirmed `update_actor_runtime_state` keeps a completed non-monsterfied NPC death as current-room actor state `0x38` while setting `NpcRecord.death_or_monsterfy_flag` for later room setup suppression. Updated ScummVM room combat handling so MERCY old man/old woman death sprites remain in the current room after their on-death script refresh.
+- On April 16, 2026, checked the Inner Sanctum post-Muckey path against decoded `HARVEST.SCR` and Ghidra. Confirmed the `{COMMAND "KILL_HERR2" ...}` script line is commented out and skipped by the native parser, while `update_actor_runtime_state` has a hard-coded `HERRILL_LOG` removal path during Muckey combat state. Updated ScummVM room combat handling to queue Herrill's live NPC death when `MUCKEY` completes death and triggers `ACTV_INNERSAN_DOOR`.
 
 ## Next Suggested Action
 
-- Re-test `MERCY` from entrance `K1_2_I2`. Kill `OLDMAN` and `OLDWOMAN`, verify both death sprites remain visible after `OLD_MAN_COM` / `OLD_WOMAN_COM` actions refresh the room, and verify leaving/re-entering still suppresses the killed NPCs.
+- Re-test `INNERSANCTUM` from entrance `L2_2_M1`. Kill `GRAND_MUCK`/`MUCKEY`, verify `HERRILL_LOG` plays his death before `KILL_HERMUCK_T` starts the `C002A`/`C002B` flic chain, and verify re-entry no longer respawns Herrill.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 34637930993..2d0ddc42633 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -84,6 +84,11 @@ static const int kCombatDebugDamagePopupRisePixels = 18;
 static const int kCombatDebugDamagePopupVerticalGap = 6;
 static const char *const kCdChangePromptPalettePath = "1:/GRAPHIC/PAL/CD1.PAL";
 static const char *const kExitCloseupPendingRoomChange = "__EXIT_CLOSEUP__";
+static const char *const kInnerSanctumRoomName = "INNERSANCTUM";
+static const char *const kHerrillLogNpcName = "HERRILL_LOG";
+static const char *const kMuckeyMonsterName = "MUCKEY";
+static const char *const kActivateInnerSanctumDoorActionTag = "ACTV_INNERSAN_DOOR";
+static const int kNativeDefaultNpcDeathDamageType = 1;
 
 static bool shouldRetainNpcDeathEntityInCurrentRoom(const NpcRecord &npc) {
 	return npc.monsterfyTargetName.empty();
@@ -2796,6 +2801,34 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		auto findSceneRuntimeEntity = [&](const Common::String &entityName) -> Entity * {
 			return entityManager ? entityManager->findSceneEntityByName(entityName) : nullptr;
 		};
+		auto queueInnerSanctumHerrillDeathAfterMuck = [&](const MonsterRecord &monster) {
+			if (!monster.roomName.equalsIgnoreCase(kInnerSanctumRoomName) ||
+					!monster.monsterName.equalsIgnoreCase(kMuckeyMonsterName) ||
+					!monster.onDeathActionTag.equalsIgnoreCase(kActivateInnerSanctumDoorActionTag)) {
+				return false;
+			}
+
+			Script *script = _engine.getScript();
+			NpcRecord *herrill = findRoomNpcRecordByName(kHerrillLogNpcName);
+			if (!script || !herrill || !herrill->visible ||
+					herrill->deathOrMonsterfyFlag || herrill->deathDamageType != 0) {
+				return false;
+			}
+
+			// Native update_actor_runtime_state has a hard-coded HERRILL_LOG removal path
+			// while the Inner Sanctum Muckey combat state is active; queue the ordinary
+			// live NPC death so the room's timer delay can play Herrill's death bank.
+			if (!script->queueRuntimeNpcDeathOrMonsterfy(
+					kHerrillLogNpcName, kNativeDefaultNpcDeathDamageType)) {
+				return false;
+			}
+
+			herrill->deathDamageType = kNativeDefaultNpcDeathDamageType;
+			debugC(1, kDebugCombat,
+				"Harvester: queued Inner Sanctum Herrill death after monster='%s'",
+				monster.monsterName.c_str());
+			return true;
+		};
 		auto findMonsterTargetAtPoint = [&](const Common::Point &point) -> MonsterRecord * {
 			MonsterRecord *bestMonster = nullptr;
 			int bestZ = -0x7fffffff;
@@ -3562,6 +3595,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 		monster->active = false;
 		monster->visible = false;
+		const bool herrillDeathQueued = queueInnerSanctumHerrillDeathAfterMuck(*monster);
 		debugC(1, kDebugCombat,
 			"Harvester: combat monster defeated target='%s' on_death='%s' fallback='no death bank'",
 			monster->monsterName.c_str(), monster->onDeathActionTag.c_str());
@@ -3577,6 +3611,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				interaction.visualRuntimeStateChanged = true;
 			}
 		}
+		if (herrillDeathQueued) {
+			interaction.mutatedRuntimeState = true;
+			interaction.visualRuntimeStateChanged = true;
+		}
 
 		return handleCombatInteraction(interaction);
 	};
@@ -3654,6 +3692,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 						monster.runtimeState,
 						monster.onDeathActionTag.c_str());
 					clearRoomMonsterCombatState(combatState);
+					const bool herrillDeathQueued = queueInnerSanctumHerrillDeathAfterMuck(monster);
 
 					InteractionResult interaction;
 					interaction.mutatedRuntimeState = true;
@@ -3667,6 +3706,10 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 							interaction.visualRuntimeStateChanged = true;
 						}
 					}
+					if (herrillDeathQueued) {
+						interaction.mutatedRuntimeState = true;
+						interaction.visualRuntimeStateChanged = true;
+					}
 
 					return handleCombatInteraction(interaction);
 				}


Commit: a6305293491a0528a78791b870027f251fdd1cdb
    https://github.com/scummvm/scummvm/commit/a6305293491a0528a78791b870027f251fdd1cdb
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:54+02:00

Commit Message:
HARVESTER: Document NO87 runtime details

Update architecture and tracker notes for NO87 and exception runtime
details.

Changed paths:
    ARCHITECTURE.md
    TRACKER.md


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index edee8c6604e..e9ae6b7730b 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -34,6 +34,18 @@
 - `run_quick_tips_screen` at `0x6c890` is the startup quick-tips overlay shown on top of the already loaded `START -> PCROOM` room state.
   - It spawns the bottom action labels through `spawn_text_entity`, renders all three actions in color `0xc3`, and resolves the toggle through the `TEXT` records so `Show_Tips_ON` / `Show_Tips_OFF` display their text values rather than the raw keys.
 
+### Runtime Floating-Point And Exception Support
+
+- `dispatch_no87_x87_instruction` at `0x8c3d8` is the central NO87/x87 emulator dispatcher. It skips FWAIT and segment/operand/address prefixes, recognizes x87 opcode bytes `0xd8..0xdf`, uses ModRM-derived jump tables for memory/register forms, and restores the saved data selector before transferring into the selected emulator handler.
+- The software extended-precision arithmetic core is now bounded by the named helpers `add_extended_precision_values`, `divide_extended_precision_values`, and `multiply_extended_precision_values`.
+  - The wrapper layer loads 10-byte extended operands from memory or stack records, calls those cores, and stores results back through the destination pointer. Confirmed wrappers include `add_extended_memory_operands`, `subtract_extended_memory_operands`, `add_extended_pointer_and_stack_operands`, `divide_extended_memory_operands`, and `multiply_extended_memory_operands`.
+  - `evaluate_extended_polynomial_horner` and `evaluate_extended_odd_polynomial` are shared table-driven polynomial evaluators used by the NO87 logarithm, arctangent, and trig/range-reduction helpers. The exact opcode-level ownership of several transcendental entry points remains intentionally unnamed.
+  - `classify_extended_value_for_no87_status` classifies extended values as zero, denormal, infinity, NaN, or normal by exponent/mantissa/sign and updates the saved NO87 status/tag bits.
+- The Watcom/Borland-style exception runtime has a confirmed throw/catch dispatch path.
+  - `runtime_throw_or_rethrow_exception` validates the active throw context, handles both new throws and rethrows, scans active runtime handler records, allocates or reuses the thrown object, and routes no-handler / invalid-rethrow cases through the terminate or console-exit paths.
+  - `copy_thrown_object_to_catch_target` applies the selected catch descriptor: it can copy bytes, zero a target, store object references, or invoke a copy constructor before transferring to the selected handler.
+  - `g_exception_transfer_callback` defaults to `noop_exception_transfer_callback`; `install_exception_transfer_callback` replaces it with the real stack/context-transfer stub used by `transfer_to_selected_exception_handler`.
+
 ### Resource Layer
 
 - `initialize_extended_file_system` at `0x1c640` performs one-time setup for the game's extended/XFILE resource system.
@@ -1317,6 +1329,10 @@
       - While censorship is active, the player keeps advancing audio timing and still captures later movie palette chunks into the saved movie palette buffer, but it skips the normal frame decode/blit until a later toggle restores the movie view.
     - `upload_vga_palette` at `0x482c0` is the shared 256-entry VGA DAC upload helper used by the FST player when it restores or swaps palette pages.
     - `load_pcx_bitmap` at `0x4a230` is the direct-file `PCX` loader used by the FST censorship path; it reads the whole file through XFILE, RLE-decodes the `0x80`-byte-header payload into a `RawBitmap`, trims oversized stride bytes, and optionally uploads the trailing 256-color palette.
+  - The Chessmaster three-move puzzle uses a separate FLIC/FLC playback path rather than the FST movie decoder:
+    - `load_flic_animation_resource` opens `*.flc` through XFILE and validates the FLIC/FLC header/chunk stream.
+    - `decode_next_flic_frame`, `seek_flic_animation_frame`, `advance_flic_frame_if_due`, and `play_flic_animation_frames_to_surface` handle frame stepping and FLI/FLC chunk decoding for chunk types including color maps, line deltas, black frames, byte runs, and raw frame payloads.
+    - `update_flic_dirty_y_bounds` and `update_flic_dirty_x_bounds` maintain the dirty rectangle touched by the decoded frame, and `blit_nonzero_pixels_to_vesa_banked_surface` copies nonzero pixels through the existing banked VESA row tables.
 - `run_harvester_main_loop` maintains `g_current_interaction_text`, `g_pending_interaction_text`, and `g_interaction_text_entity` for hover / use prompts.
   - It allocates both 100-byte text buffers during startup, formats prompts such as `Examine %s`, `Talk to %s`, `Pick up the %s`, `Operate the %s`, and `Use %s on %s`, suppresses prompts when the active label is `NULL_ID`, and only rebuilds the rendered label when the pending text differs from the current text.
   - It also drives the room-loop cursor classification: sequence `0` for walkable floor-band movement, `1` for inspect / IDENT-first targets, `2` for talk, `4` for operate, `5` for pickup / use-item, `6` for region or room-transition targets, and `7` for neutral hover.
diff --git a/TRACKER.md b/TRACKER.md
index 17ae84d5478..371ca5fcdad 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,15 +3,16 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `929`
-- Named/documented: `825`
-- Remaining `FUN_*`: `104`
-- Remaining undocumented total: `104`
+- Total functions: `923`
+- Named/non-`FUN_*`: `864`
+- Remaining `FUN_*`: `59`
+- Remaining real-space `FUN_*`: `16`
+- Remaining zero-xref `.image` `FUN_*` artifacts: `43`
 
 ## Last Confirmed Action
 
-- On April 16, 2026, checked the Inner Sanctum post-Muckey path against decoded `HARVEST.SCR` and Ghidra. Confirmed the `{COMMAND "KILL_HERR2" ...}` script line is commented out and skipped by the native parser, while `update_actor_runtime_state` has a hard-coded `HERRILL_LOG` removal path during Muckey combat state. Updated ScummVM room combat handling to queue Herrill's live NPC death when `MUCKEY` completes death and triggers `ACTV_INNERSAN_DOOR`.
+- On April 16, 2026, continued the `FUN_*` triage pass in Ghidra from the remaining 104 entries and reduced the counted `FUN_*` total to 59. Renamed 39 high-confidence functions across the Chessmaster FLC path, VESA banked blit helper, FST dialogue wrappers, IRQ0 timer chaining, NO87 extended-precision arithmetic/polynomial helpers, decimal conversion helpers, and exception-runtime throw/catch transfer path; one renamed function, `runtime_throw_or_rethrow_exception`, had been custom-marked and was not included in the 104-count query. Deleted 7 bogus function starts that were proven to be continuation blocks or data, including `FUN_000c55f8`, which is now labeled as the IRQ0 timer callback far-pointer slot. Updated `ARCHITECTURE.md` with confirmed NO87/exception-runtime and FLIC playback notes.
 
 ## Next Suggested Action
 
-- Re-test `INNERSANCTUM` from entrance `L2_2_M1`. Kill `GRAND_MUCK`/`MUCKEY`, verify `HERRILL_LOG` plays his death before `KILL_HERMUCK_T` starts the `C002A`/`C002B` flic chain, and verify re-entry no longer respawns Herrill.
+- Continue with the 16 remaining real-space `FUN_*` entries, starting by proving the exact opcode contracts for the NO87 redispatch/conversion handlers (`FUN_0008c57a`, `FUN_0008c5cc`, `FUN_0008e0ab`, `FUN_0008e0b0`) before considering any rename. Treat the 43 zero-xref `.image` entries as a separate cleanup audit and delete them only after confirming each is raw-image duplicate/non-runtime code.


Commit: 7f77459b98ddcca314acf8d42529261dab0d09cb
    https://github.com/scummvm/scummvm/commit/7f77459b98ddcca314acf8d42529261dab0d09cb
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:54+02:00

Commit Message:
HARVESTER: Match native Chessmaster FLC sequencing

Changed paths:
    TRACKER.md
    engines/harvester/npc/chessmaster_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 371ca5fcdad..51afd4f37ff 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -11,8 +11,8 @@
 
 ## Last Confirmed Action
 
-- On April 16, 2026, continued the `FUN_*` triage pass in Ghidra from the remaining 104 entries and reduced the counted `FUN_*` total to 59. Renamed 39 high-confidence functions across the Chessmaster FLC path, VESA banked blit helper, FST dialogue wrappers, IRQ0 timer chaining, NO87 extended-precision arithmetic/polynomial helpers, decimal conversion helpers, and exception-runtime throw/catch transfer path; one renamed function, `runtime_throw_or_rethrow_exception`, had been custom-marked and was not included in the 104-count query. Deleted 7 bogus function starts that were proven to be continuation blocks or data, including `FUN_000c55f8`, which is now labeled as the IRQ0 timer callback far-pointer slot. Updated `ARCHITECTURE.md` with confirmed NO87/exception-runtime and FLIC playback notes.
+- On April 16, 2026, updated ScummVM's Chessmaster three-move puzzle playback to match the newly confirmed native FLC sequencing. The engine now presents the decoded first `CHESMOV*.FLC` frame before advancing and limits the three move FLC runs to the native counts `0x1d`, `0x1d`, and `0xe` instead of always playing each file to EOF. Verified `engines/harvester/npc/chessmaster_dialogue.o` builds.
 
 ## Next Suggested Action
 
-- Continue with the 16 remaining real-space `FUN_*` entries, starting by proving the exact opcode contracts for the NO87 redispatch/conversion handlers (`FUN_0008c57a`, `FUN_0008c5cc`, `FUN_0008e0ab`, `FUN_0008e0b0`) before considering any rename. Treat the 43 zero-xref `.image` entries as a separate cleanup audit and delete them only after confirming each is raw-image duplicate/non-runtime code.
+- Runtime-test the Chessmaster dialogue path: choose the chess puzzle response, click the three correct hotspots, and verify `CHESMOV1.FLC`, `CHESMOV2.FLC`, and `CHESMOV3.FLC` transition at the same points as native before `C125.FST` and the Chessmaster death/monsterfy state. Also verify a wrong hotspot still branches to the `KINGGROW.FST` failure path.
diff --git a/engines/harvester/npc/chessmaster_dialogue.cpp b/engines/harvester/npc/chessmaster_dialogue.cpp
index 27bd80362aa..8678b107943 100644
--- a/engines/harvester/npc/chessmaster_dialogue.cpp
+++ b/engines/harvester/npc/chessmaster_dialogue.cpp
@@ -53,6 +53,8 @@ struct ChessmasterHotspot {
 struct ChessmasterPuzzleStage {
 	const char *choiceFlicPath;
 	const char *moveFlicPath;
+	// Native draws the decoded first frame, then advances through this count plus one.
+	uint moveFlicFrameAdvanceCount;
 	ChessmasterHotspot successHotspot;
 	const ChessmasterHotspot *failureHotspots;
 	uint failureHotspotCount;
@@ -85,9 +87,10 @@ static const ChessmasterHotspot kChessmasterStage3FailureHotspots[] = {
 };
 
 static const ChessmasterPuzzleStage kChessmasterPuzzleStages[] = {
-		{
+	{
 		"GRAPHIC/FST/CHOOSE1.FLC",
 		"GRAPHIC/FST/CHESMOV1.FLC",
+		0x1d,
 		{ 0x8b, 0xe0, 0xc0, 0x10d },
 		kChessmasterStage1FailureHotspots,
 		ARRAYSIZE(kChessmasterStage1FailureHotspots)
@@ -95,6 +98,7 @@ static const ChessmasterPuzzleStage kChessmasterPuzzleStages[] = {
 	{
 		"GRAPHIC/FST/CHOOSE2.FLC",
 		"GRAPHIC/FST/CHESMOV2.FLC",
+		0x1d,
 		{ 0x2a, 0xfa, 0x5f, 0x12e },
 		kChessmasterStage2FailureHotspots,
 		ARRAYSIZE(kChessmasterStage2FailureHotspots)
@@ -102,6 +106,7 @@ static const ChessmasterPuzzleStage kChessmasterPuzzleStages[] = {
 	{
 		"GRAPHIC/FST/CHOOSE3.FLC",
 		"GRAPHIC/FST/CHESMOV3.FLC",
+		0xe,
 		{ 0xee, 0xa7, 0x121, 0xcb },
 		kChessmasterStage3FailureHotspots,
 		ARRAYSIZE(kChessmasterStage3FailureHotspots)
@@ -181,7 +186,9 @@ static Common::Error decodeInitialFlicFrame(DialogueRuntime &runtime, const char
 	return Common::kNoError;
 }
 
-static Common::Error advanceFlicIfNeeded(ChessmasterFlicState &state, bool loop) {
+static Common::Error advanceFlicIfNeeded(ChessmasterFlicState &state, bool loop, bool *advanced = nullptr) {
+	if (advanced)
+		*advanced = false;
 	if (!g_system)
 		return Common::kNoError;
 	if (state.nextFrameTick != 0 && (int32)(state.nextFrameTick - g_system->getMillis()) > 0)
@@ -203,6 +210,8 @@ static Common::Error advanceFlicIfNeeded(ChessmasterFlicState &state, bool loop)
 	state.frame = nextFrame;
 	const int frameDelay = state.decoder.getCurFrameDelay();
 	state.nextFrameTick = frameDelay > 0 ? g_system->getMillis() + (uint32)frameDelay : 0;
+	if (advanced)
+		*advanced = true;
 	return Common::kNoError;
 }
 
@@ -252,7 +261,7 @@ static Common::Error runTimedDelay(uint32 durationMs) {
 	return Common::kNoError;
 }
 
-static Common::Error playOneShotFlic(DialogueRuntime &runtime, const char *path) {
+static Common::Error playMoveFlic(DialogueRuntime &runtime, const char *path, uint frameAdvanceCount) {
 	Graphics::Screen *screen = runtime.engine().getScreen();
 	if (!screen)
 		return Common::kReadingFailed;
@@ -262,17 +271,28 @@ static Common::Error playOneShotFlic(DialogueRuntime &runtime, const char *path)
 	if (loadError.getCode() != Common::kNoError)
 		return loadError;
 
+	drawFlicFrame(*screen, video);
+	screen->makeAllDirty();
+	screen->update();
+
 	Graphics::FrameLimiter limiter(g_system, 60);
+	uint advancedFrames = 0;
 	for (;;) {
-		Common::Error advanceError = advanceFlicIfNeeded(video, false);
+		if (advancedFrames > frameAdvanceCount || video.decoder.endOfVideo())
+			return Common::kNoError;
+
+		bool advanced = false;
+		Common::Error advanceError = advanceFlicIfNeeded(video, false, &advanced);
 		if (advanceError.getCode() != Common::kNoError)
 			return advanceError;
+		if (advanced)
+			++advancedFrames;
 
 		drawFlicFrame(*screen, video);
 		screen->makeAllDirty();
 		screen->update();
 
-		if (video.decoder.endOfVideo())
+		if (advancedFrames > frameAdvanceCount || video.decoder.endOfVideo())
 			return Common::kNoError;
 
 		Common::Event event;
@@ -373,7 +393,8 @@ static Common::Error runChessmasterThreeMovePuzzle(DialogueRuntime &runtime, boo
 		if (!selectedSuccessHotspot)
 			return Common::kNoError;
 
-		stageError = playOneShotFlic(runtime, kChessmasterPuzzleStages[i].moveFlicPath);
+		stageError = playMoveFlic(runtime, kChessmasterPuzzleStages[i].moveFlicPath,
+			kChessmasterPuzzleStages[i].moveFlicFrameAdvanceCount);
 		if (stageError.getCode() != Common::kNoError)
 			return stageError;
 		if (runtime.engine().shouldQuit())


Commit: db4245beb29ac9f0a40526e8f28636eb9c3b478a
    https://github.com/scummvm/scummvm/commit/db4245beb29ac9f0a40526e8f28636eb9c3b478a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:54+02:00

Commit Message:
HARVESTER: Fix FST video block decoding

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/fst_player.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index e9ae6b7730b..56a2b8d069d 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1317,6 +1317,8 @@
       - The first bit controls whether a 256-color VGA palette chunk is present.
       - Each 4x4 tile then consumes one changed/unchanged bit, and changed tiles consume one raw-vs-two-color-mask bit before reading either 16 raw pixels or 4 mask bytes.
       - The frame body then decodes 4x4 tiles either from raw 16-byte pixel blocks or from compact 4-byte two-color mask blocks before blitting through the active VESA backend.
+      - The native tile loop decodes `(height >> 2) - 1` four-scanline bands, so 320x200 FST movies decode 49 tile rows and leave the bottom 4 scanlines unchanged.
+      - In two-color mask blocks, `source[0]` is color 0, `source[1]` is color 1, and the 16-bit little-endian mask maps each row right-to-left within its nibble: row 0 uses high-byte bits `0x80,0x40,0x20,0x10`, row 1 uses `0x08,0x04,0x02,0x01`, row 2 uses low-byte bits `0x80,0x40,0x20,0x10`, and row 3 uses `0x08,0x04,0x02,0x01`.
       - On banked VESA surfaces, the decoder handles those same 4x4 tiles in 4-scanline bands:
         - if all four rows in the current band have `split_x == -1`, it writes directly into the mapped VESA window
         - otherwise it expands the 4-row band into a temporary scratch buffer and then flushes each row in one or two pieces across the bank boundary, advancing banks through `select_bank_for_scanline_callback`
diff --git a/TRACKER.md b/TRACKER.md
index 51afd4f37ff..31ab0d8e194 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -11,8 +11,8 @@
 
 ## Last Confirmed Action
 
-- On April 16, 2026, updated ScummVM's Chessmaster three-move puzzle playback to match the newly confirmed native FLC sequencing. The engine now presents the decoded first `CHESMOV*.FLC` frame before advancing and limits the three move FLC runs to the native counts `0x1d`, `0x1d`, and `0xe` instead of always playing each file to EOF. Verified `engines/harvester/npc/chessmaster_dialogue.o` builds.
+- On April 16, 2026, compared ScummVM's Harvester FST decoder against native `run_fst_sequence_player @ 0x12b00` and patched the engine to match two confirmed codec details: two-color mask blocks map mask bits right-to-left within each nibble, and native decodes only `(height >> 2) - 1` four-scanline tile bands. Updated `ARCHITECTURE.md` with the confirmed FST bitstream evidence and verified `engines/harvester/fst_player.o` builds.
 
 ## Next Suggested Action
 
-- Runtime-test the Chessmaster dialogue path: choose the chess puzzle response, click the three correct hotspots, and verify `CHESMOV1.FLC`, `CHESMOV2.FLC`, and `CHESMOV3.FLC` transition at the same points as native before `C125.FST` and the Chessmaster death/monsterfy state. Also verify a wrong hotspot still branches to the `KINGGROW.FST` failure path.
+- Runtime-test representative FST playback with mask-heavy and tail-sensitive files such as `GRAPHIC/FST/FVLOGO.FST`, `GRAPHIC/FST/INTROFIN.FST`, `GRAPHIC/FST/C001B.FST`, `GRAPHIC/FST/C086.FST`, and `GRAPHIC/FST/RANGSHOT.FST` to confirm the visual artifacts are gone.
diff --git a/engines/harvester/fst_player.cpp b/engines/harvester/fst_player.cpp
index 7baad0704d5..76f2cee0e6b 100644
--- a/engines/harvester/fst_player.cpp
+++ b/engines/harvester/fst_player.cpp
@@ -295,18 +295,13 @@ static void decodeMaskBlock(byte *dest, int pitch, const byte *source) {
 	const byte color0 = source[0];
 	const byte color1 = source[1];
 	const uint16 mask = READ_LE_UINT16(source + 2);
-	const byte maskRows[4] = {
-		(byte)((mask >> 8) & 0xf0),
-		(byte)((mask >> 8) & 0x0f),
-		(byte)(mask & 0xf0),
-		(byte)(mask & 0x0f)
-	};
 
 	for (int y = 0; y < 4; ++y) {
 		byte *row = dest + y * pitch;
-		const byte rowMask = maskRows[y];
+		const byte rowMask = (y < 2) ? (byte)(mask >> 8) : (byte)mask;
+		const byte firstBit = (y & 1) ? 0x08 : 0x80;
 		for (int x = 0; x < 4; ++x) {
-			const byte bit = (y & 1) ? (1 << x) : (0x10 << x);
+			const byte bit = firstBit >> x;
 			row[x] = (rowMask & bit) ? color1 : color0;
 		}
 	}
@@ -335,7 +330,8 @@ static bool decodeFrame(const FstHeader &header, const byte *frameData, uint32 f
 	const int width = (int)header.width;
 	const int height = (int)header.height;
 	const int blocksX = width / 4;
-	const int blocksY = height / 4;
+	// Native playback leaves the last four scanlines unchanged.
+	const int blocksY = MAX(0, height / 4 - 1);
 	bool truncatedTail = false;
 
 	for (int blockY = 0; blockY < blocksY; ++blockY) {


Commit: 64fa5ea38dcccbebc34be4dd73e9c132b921649a
    https://github.com/scummvm/scummvm/commit/64fa5ea38dcccbebc34be4dd73e9c132b921649a
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:55+02:00

Commit Message:
HARVESTER: Revert Chessmaster FLC sequencing

This reverts commit 17e4038ba40684eb9da988e9d7b3761b5bd22b7b.

Changed paths:
    engines/harvester/npc/chessmaster_dialogue.cpp


diff --git a/engines/harvester/npc/chessmaster_dialogue.cpp b/engines/harvester/npc/chessmaster_dialogue.cpp
index 8678b107943..27bd80362aa 100644
--- a/engines/harvester/npc/chessmaster_dialogue.cpp
+++ b/engines/harvester/npc/chessmaster_dialogue.cpp
@@ -53,8 +53,6 @@ struct ChessmasterHotspot {
 struct ChessmasterPuzzleStage {
 	const char *choiceFlicPath;
 	const char *moveFlicPath;
-	// Native draws the decoded first frame, then advances through this count plus one.
-	uint moveFlicFrameAdvanceCount;
 	ChessmasterHotspot successHotspot;
 	const ChessmasterHotspot *failureHotspots;
 	uint failureHotspotCount;
@@ -87,10 +85,9 @@ static const ChessmasterHotspot kChessmasterStage3FailureHotspots[] = {
 };
 
 static const ChessmasterPuzzleStage kChessmasterPuzzleStages[] = {
-	{
+		{
 		"GRAPHIC/FST/CHOOSE1.FLC",
 		"GRAPHIC/FST/CHESMOV1.FLC",
-		0x1d,
 		{ 0x8b, 0xe0, 0xc0, 0x10d },
 		kChessmasterStage1FailureHotspots,
 		ARRAYSIZE(kChessmasterStage1FailureHotspots)
@@ -98,7 +95,6 @@ static const ChessmasterPuzzleStage kChessmasterPuzzleStages[] = {
 	{
 		"GRAPHIC/FST/CHOOSE2.FLC",
 		"GRAPHIC/FST/CHESMOV2.FLC",
-		0x1d,
 		{ 0x2a, 0xfa, 0x5f, 0x12e },
 		kChessmasterStage2FailureHotspots,
 		ARRAYSIZE(kChessmasterStage2FailureHotspots)
@@ -106,7 +102,6 @@ static const ChessmasterPuzzleStage kChessmasterPuzzleStages[] = {
 	{
 		"GRAPHIC/FST/CHOOSE3.FLC",
 		"GRAPHIC/FST/CHESMOV3.FLC",
-		0xe,
 		{ 0xee, 0xa7, 0x121, 0xcb },
 		kChessmasterStage3FailureHotspots,
 		ARRAYSIZE(kChessmasterStage3FailureHotspots)
@@ -186,9 +181,7 @@ static Common::Error decodeInitialFlicFrame(DialogueRuntime &runtime, const char
 	return Common::kNoError;
 }
 
-static Common::Error advanceFlicIfNeeded(ChessmasterFlicState &state, bool loop, bool *advanced = nullptr) {
-	if (advanced)
-		*advanced = false;
+static Common::Error advanceFlicIfNeeded(ChessmasterFlicState &state, bool loop) {
 	if (!g_system)
 		return Common::kNoError;
 	if (state.nextFrameTick != 0 && (int32)(state.nextFrameTick - g_system->getMillis()) > 0)
@@ -210,8 +203,6 @@ static Common::Error advanceFlicIfNeeded(ChessmasterFlicState &state, bool loop,
 	state.frame = nextFrame;
 	const int frameDelay = state.decoder.getCurFrameDelay();
 	state.nextFrameTick = frameDelay > 0 ? g_system->getMillis() + (uint32)frameDelay : 0;
-	if (advanced)
-		*advanced = true;
 	return Common::kNoError;
 }
 
@@ -261,7 +252,7 @@ static Common::Error runTimedDelay(uint32 durationMs) {
 	return Common::kNoError;
 }
 
-static Common::Error playMoveFlic(DialogueRuntime &runtime, const char *path, uint frameAdvanceCount) {
+static Common::Error playOneShotFlic(DialogueRuntime &runtime, const char *path) {
 	Graphics::Screen *screen = runtime.engine().getScreen();
 	if (!screen)
 		return Common::kReadingFailed;
@@ -271,28 +262,17 @@ static Common::Error playMoveFlic(DialogueRuntime &runtime, const char *path, ui
 	if (loadError.getCode() != Common::kNoError)
 		return loadError;
 
-	drawFlicFrame(*screen, video);
-	screen->makeAllDirty();
-	screen->update();
-
 	Graphics::FrameLimiter limiter(g_system, 60);
-	uint advancedFrames = 0;
 	for (;;) {
-		if (advancedFrames > frameAdvanceCount || video.decoder.endOfVideo())
-			return Common::kNoError;
-
-		bool advanced = false;
-		Common::Error advanceError = advanceFlicIfNeeded(video, false, &advanced);
+		Common::Error advanceError = advanceFlicIfNeeded(video, false);
 		if (advanceError.getCode() != Common::kNoError)
 			return advanceError;
-		if (advanced)
-			++advancedFrames;
 
 		drawFlicFrame(*screen, video);
 		screen->makeAllDirty();
 		screen->update();
 
-		if (advancedFrames > frameAdvanceCount || video.decoder.endOfVideo())
+		if (video.decoder.endOfVideo())
 			return Common::kNoError;
 
 		Common::Event event;
@@ -393,8 +373,7 @@ static Common::Error runChessmasterThreeMovePuzzle(DialogueRuntime &runtime, boo
 		if (!selectedSuccessHotspot)
 			return Common::kNoError;
 
-		stageError = playMoveFlic(runtime, kChessmasterPuzzleStages[i].moveFlicPath,
-			kChessmasterPuzzleStages[i].moveFlicFrameAdvanceCount);
+		stageError = playOneShotFlic(runtime, kChessmasterPuzzleStages[i].moveFlicPath);
 		if (stageError.getCode() != Common::kNoError)
 			return stageError;
 		if (runtime.engine().shouldQuit())


Commit: dc98ee4234faf9ad4131ba52e76c6e42f4166013
    https://github.com/scummvm/scummvm/commit/dc98ee4234faf9ad4131ba52e76c6e42f4166013
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:55+02:00

Commit Message:
HARVESTER: Trigger Chessmaster death after puzzle

Changed paths:
    engines/harvester/npc/chessmaster_dialogue.cpp


diff --git a/engines/harvester/npc/chessmaster_dialogue.cpp b/engines/harvester/npc/chessmaster_dialogue.cpp
index 27bd80362aa..e0cc78a4db5 100644
--- a/engines/harvester/npc/chessmaster_dialogue.cpp
+++ b/engines/harvester/npc/chessmaster_dialogue.cpp
@@ -443,6 +443,7 @@ Common::Error ChessmasterDialogueHandler::handleDialogue(DialogueRuntime &runtim
 	if (runtime.startupScript().queueRuntimeNpcDeathOrMonsterfy(
 			kChessmasterNpc, kChessmasterSlashDamageType)) {
 		interaction.mutatedRuntimeState = true;
+		interaction.visualRuntimeStateChanged = true;
 	}
 	runtime.queueDialogueInteractionIfNeeded(interaction);
 	return Common::kNoError;


Commit: d700da1ef70fa53f8e36f90e6807670433aed670
    https://github.com/scummvm/scummvm/commit/d700da1ef70fa53f8e36f90e6807670433aed670
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:55+02:00

Commit Message:
HARVESTER: Refresh room after Loomis leaves

Changed paths:
    TRACKER.md
    engines/harvester/npc/loomis_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 31ab0d8e194..4464d25d9f2 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -11,8 +11,8 @@
 
 ## Last Confirmed Action
 
-- On April 16, 2026, compared ScummVM's Harvester FST decoder against native `run_fst_sequence_player @ 0x12b00` and patched the engine to match two confirmed codec details: two-color mask blocks map mask bits right-to-left within each nibble, and native decodes only `(height >> 2) - 1` four-scanline tile bands. Updated `ARCHITECTURE.md` with the confirmed FST bitstream evidence and verified `engines/harvester/fst_player.o` builds.
+- On April 16, 2026, fixed the Loomis dirty-magazine dialogue branch so its queued runtime mutation is also marked as a visual room-state change. This lets the current room refresh remove the hidden `LOOMIS` actor and update the inventory/drawer visuals after `GRAPHIC/FST/C048.FST`. Verified `engines/harvester/npc/loomis_dialogue.o` builds.
 
 ## Next Suggested Action
 
-- Runtime-test representative FST playback with mask-heavy and tail-sensitive files such as `GRAPHIC/FST/FVLOGO.FST`, `GRAPHIC/FST/INTROFIN.FST`, `GRAPHIC/FST/C001B.FST`, `GRAPHIC/FST/C086.FST`, and `GRAPHIC/FST/RANGSHOT.FST` to confirm the visual artifacts are gone.
+- Runtime-test giving `INV_MAG` to `LOOMIS` in `SHRFOFC`: after selecting `Sure!` and playing `GRAPHIC/FST/C048.FST`, confirm Loomis is no longer visible or clickable, the dirty magazine is gone from inventory, and the sheriff drawer visuals are refreshed.
diff --git a/engines/harvester/npc/loomis_dialogue.cpp b/engines/harvester/npc/loomis_dialogue.cpp
index 9bad6396d42..726d74c312c 100644
--- a/engines/harvester/npc/loomis_dialogue.cpp
+++ b/engines/harvester/npc/loomis_dialogue.cpp
@@ -309,6 +309,7 @@ Common::Error LoomisDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 
 			InteractionResult interaction;
 			interaction.mutatedRuntimeState = changed;
+			interaction.visualRuntimeStateChanged = changed;
 			runtime.queueDialogueInteractionIfNeeded(interaction);
 			return Common::kNoError;
 		}


Commit: 4034b04b030f352a58ed71915f849dc0f5f8ccb2
    https://github.com/scummvm/scummvm/commit/4034b04b030f352a58ed71915f849dc0f5f8ccb2
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:56+02:00

Commit Message:
HARVESTER: Fix inventory use prompt labels

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/script.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 56a2b8d069d..0a8981bfdc4 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1406,6 +1406,7 @@
 
 - `Confidence:` High.
 - `Evidence:` `run_inventory_screen` sets the active-item latch when a carryable inventory object is selected, clears the previous interaction-text overlay, and rebuilds the centered `USING_ON_ID` prompt from `Use %s on ...`.
+- `Evidence:` the selected-item and target names used in the `USING_ON_ID` prompt come from `RenderEntityRuntime` label field `+0x14`, which `spawn_object_entity_from_record` copies from `ObjectRecord.interactionLabel`; this keeps inventory tooltip suffixes such as `; right_click_to_view` out of use/carry prompts.
 - `Evidence:` while that latch is set, `run_inventory_screen` treats the selected inventory object as the live dragged entity, probes overlap for `USEITEM` or dialogue routing, and returns the entity to the caller if it leaves the inventory panel rect `x=0x49..0x234`, `y=0x73..0x19b`.
 - `Evidence:` `run_harvester_main_loop` consumes that returned entity as the active selected item, repositions it under the mouse, rebuilds the same centered `USING_ON_ID` overlay with `g_medfont1_cft`, and routes left-click use through `handle_target_interaction`.
 - `Evidence:` `spawn_player_combat_avatar` seeds the live player entity with class `5` and label `"your inventory"`, and `handle_target_interaction` special-cases overlap with that target to move a non-inventory carried room item into `INVENTORY`.
diff --git a/TRACKER.md b/TRACKER.md
index 4464d25d9f2..339f7b42e14 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -11,8 +11,8 @@
 
 ## Last Confirmed Action
 
-- On April 16, 2026, fixed the Loomis dirty-magazine dialogue branch so its queued runtime mutation is also marked as a visual room-state change. This lets the current room refresh remove the hidden `LOOMIS` actor and update the inventory/drawer visuals after `GRAPHIC/FST/C048.FST`. Verified `engines/harvester/npc/loomis_dialogue.o` builds.
+- On April 16, 2026, fixed inventory use/carry prompt labels so `ObjectRecord.interactionLabel` is preferred over the inventory `*_STEXT` tooltip text, matching the Ghidra `run_inventory_screen` and `run_harvester_main_loop` label-field evidence and preventing `right_click_to_view` suffixes from leaking into `Use ... on ...` prompts. Verified `engines/harvester/script.o` builds.
 
 ## Next Suggested Action
 
-- Runtime-test giving `INV_MAG` to `LOOMIS` in `SHRFOFC`: after selecting `Sure!` and playing `GRAPHIC/FST/C048.FST`, confirm Loomis is no longer visible or clickable, the dirty magazine is gone from inventory, and the sheriff drawer visuals are refreshed.
+- Runtime-test selecting `COMPLETED_LODGE_APPLICATION` in inventory, then hovering inventory targets and room/NPC targets in `SERGEANTRM`; confirm the use prompt shows the clean interaction labels such as `completed Lodge application` and does not include the right-click tooltip suffix.
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 8e84815ac79..846440adfaa 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -3576,17 +3576,17 @@ Common::String Script::resolveInventoryTooltipText(const ObjectRecord &object) c
 }
 
 Common::String Script::resolveObjectLabel(const ObjectRecord &object) const {
+	if (!object.interactionLabel.empty() && !object.interactionLabel.equalsIgnoreCase("NULL_ID"))
+		return normalizeInteractionLabel(object.interactionLabel);
+	if (object.interactionLabel.equalsIgnoreCase("NULL_ID"))
+		return Common::String();
+
 	if (object.currentOwnerOrRoom.equalsIgnoreCase(kInventoryOwnerName)) {
 		const Common::String inventoryLabel = resolveInventoryTooltipText(object);
 		if (!inventoryLabel.empty())
 			return inventoryLabel;
 	}
 
-	if (!object.interactionLabel.empty() && !object.interactionLabel.equalsIgnoreCase("NULL_ID"))
-		return normalizeInteractionLabel(object.interactionLabel);
-	if (object.interactionLabel.equalsIgnoreCase("NULL_ID"))
-		return Common::String();
-
 	const TextRecord *textRecord = findTextRecord(object.identTextKey);
 	if (textRecord && !textRecord->value.empty())
 		return textRecord->value;


Commit: dae5bb1d2d12ca13d43593e235c80aa917fa2f50
    https://github.com/scummvm/scummvm/commit/dae5bb1d2d12ca13d43593e235c80aa917fa2f50
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:56+02:00

Commit Message:
HARVESTER: Fix Wasp Woman BYE handling

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/wasp_woman_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 0a8981bfdc4..b681be709cf 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -593,7 +593,7 @@
   - `handle_talk_to_wasp_woman @ 0x2fde0` is now bounded enough for a direct startup-room port.
     - The hidden no-item intro tail is larger than the clean decompiler body shows. The first visit clears the talk-state gate, plays `0x4bee`, optionally inserts the `0x4bf2` PC line when `get_set_wasp_woman_dialogue_state_d2f10` is set, continues through `0x4bf6` / `0x4bfc`, then adds the recovered `0x4c00` line before seeding the live keyword buffer with zero-based `dialog.rsp[0x300]`.
     - Wasp Woman's keyword state persists across revisits. Later no-item visits only replay `0x4bee`; the live keyword buffer is whatever the prior branch last wrote, and the raw hidden tail now confirms the concrete rewrites: `0x302` expands into `0x4c0a` / `0x4c13` / `0x4c17`, plays `C027A.FST`, continues through `0x4c25` / `0x4c29`, then rewrites the buffer to zero-based `dialog.rsp[0x304]`; `0x308` rewrites to `0x309`; and `0x30a` plays `0x4c74` / `0x4c7f` before rewriting to `0x30b`.
-    - The hidden loop also clarifies the ambiguous BYE handling. In the initial `dialog.rsp[0x300]` state, the synthetic `BYE` selection exits silently through the early `dialog.rsp[0x301]` compare. Once the later keyword states are active, the zero-based `dialog.rsp[0x30e]` `BYE` loops silently back into the keyword menu instead, and only an unmatched late typed topic falls through to `0x4c9a`.
+    - The hidden loop also clarifies the ambiguous BYE handling. The native code checks the selected topic against zero-based `dialog.rsp[0x301]` before each keyword dispatch, so the synthetic `BYE` selection exits silently even after the live keyword buffer has advanced to later states such as `0x30b`. The later zero-based `dialog.rsp[0x30e]` compare jumps back to the top of the loop; with the shipped `DIALOG.RSP`, both `0x301` and `0x30e` are the literal `BYE`, so the top-of-loop `0x301` compare exits before the menu is redrawn. Only an unmatched late typed topic falls through to `0x4c9a`.
 - The shared dialogue evidence/topic state shims behind those handlers are now partially explicit:
   - `get_set_shared_dialogue_state_d2e98` at `0x38230` is a neutral wrapper over `g_shared_dialogue_state_d2e98`; the currently recovered gameplay callers are Boyle's `BOYLES_BUTTON` response-2 branch, which writes it immediately before `SHERIFF_IN_DINER`, and Herrill's `GASCAN` branch at `0x2e056`, where `eax = 1` / `edx = 0` makes it a read-only gate and a zero value collapses the post-`0x30d8` continuation to `PC 0x3123`. Outside those handlers, the backing global is only touched by the shared reset path and save/load serialization.
   - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
diff --git a/TRACKER.md b/TRACKER.md
index 339f7b42e14..2f61651d118 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -11,8 +11,8 @@
 
 ## Last Confirmed Action
 
-- On April 16, 2026, fixed inventory use/carry prompt labels so `ObjectRecord.interactionLabel` is preferred over the inventory `*_STEXT` tooltip text, matching the Ghidra `run_inventory_screen` and `run_harvester_main_loop` label-field evidence and preventing `right_click_to_view` suffixes from leaking into `Use ... on ...` prompts. Verified `engines/harvester/script.o` builds.
+- On April 16, 2026, fixed Wasp Woman keyword `BYE` handling so the generic/default `BYE` exits even after later topic buffers such as zero-based `dialog.rsp[0x30b]`, matching the native `handle_talk_to_wasp_woman @ 0x2fde0` top-of-loop `dialog.rsp[0x301]` compare. Corrected the Ghidra plate comment and `ARCHITECTURE.md` note. Verified `engines/harvester/npc/wasp_woman_dialogue.o` builds.
 
 ## Next Suggested Action
 
-- Runtime-test selecting `COMPLETED_LODGE_APPLICATION` in inventory, then hovering inventory targets and room/NPC targets in `SERGEANTRM`; confirm the use prompt shows the clean interaction labels such as `completed Lodge application` and does not include the right-click tooltip suffix.
+- Runtime-test Wasp Woman after selecting `Pleasure`: when the keyword buffer is zero-based `dialog.rsp[0x30b]` (`Wasps/Sacrifice/Pleasure`), select the default `BYE` entry and confirm the dialogue closes without re-opening the keyword menu.
diff --git a/engines/harvester/npc/wasp_woman_dialogue.cpp b/engines/harvester/npc/wasp_woman_dialogue.cpp
index b575717a507..02338da6d62 100644
--- a/engines/harvester/npc/wasp_woman_dialogue.cpp
+++ b/engines/harvester/npc/wasp_woman_dialogue.cpp
@@ -165,11 +165,8 @@ Common::Error WaspWomanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		if (selectedTopic.empty())
 			return Common::kNoError;
 		if (selectedTopic.equalsIgnoreCase(runtime.genericByeTopic()) ||
-				runtime.matchesResponseLine(selectedTopic, kWaspWomanInitialExitResponseLine)) {
-			if (waspWomanTopicBufferLineIndex == kWaspWomanInitialTopicBufferResponseLine)
-				return Common::kNoError;
-			continue;
-		}
+				runtime.matchesResponseLine(selectedTopic, kWaspWomanInitialExitResponseLine))
+			return Common::kNoError;
 
 		if (runtime.matchesResponseLine(selectedTopic, kWaspWomanTopic302ResponseLine)) {
 			Common::Error lineError = runtime.playDialogueEntrySequence(


Commit: 8f5df2010a1907926bad877d63be9a3e5907e06e
    https://github.com/scummvm/scummvm/commit/8f5df2010a1907926bad877d63be9a3e5907e06e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:56+02:00

Commit Message:
HARVESTER: Hide unsigned meat permission after Dad signs it

Changed paths:
    TRACKER.md
    engines/harvester/npc/dad_dialogue.cpp


diff --git a/TRACKER.md b/TRACKER.md
index 2f61651d118..8b3d0e8dcc4 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -3,16 +3,16 @@
 ## Progress
 
 - Program: `HARVEST.LE`
-- Total functions: `923`
-- Named/non-`FUN_*`: `864`
+- Total functions: `927`
+- Named/non-`FUN_*`: `868`
 - Remaining `FUN_*`: `59`
 - Remaining real-space `FUN_*`: `16`
 - Remaining zero-xref `.image` `FUN_*` artifacts: `43`
 
 ## Last Confirmed Action
 
-- On April 16, 2026, fixed Wasp Woman keyword `BYE` handling so the generic/default `BYE` exits even after later topic buffers such as zero-based `dialog.rsp[0x30b]`, matching the native `handle_talk_to_wasp_woman @ 0x2fde0` top-of-loop `dialog.rsp[0x301]` compare. Corrected the Ghidra plate comment and `ARCHITECTURE.md` note. Verified `engines/harvester/npc/wasp_woman_dialogue.o` builds.
+- On April 17, 2026, verified the Dad meat-permission handoff against `HARVEST.SCR` and native `handle_talk_to_dad @ 0x2b020`: the script defines separate `MEAT_PERMISSION0` unsigned and `MEAT_PERMISSION` signed inventory objects, and the native Dad branch adds `MEAT_PERMISSION` then hides `MEAT_PERMISSION0` for owner `INVENTORY`. Updated the port to hide the unsigned slip after awarding the signed slip, and renamed the confirmed native helpers `get_set_shared_dialogue_state_d2eb4` and `play_c112s4_fst_sequence`.
 
 ## Next Suggested Action
 
-- Runtime-test Wasp Woman after selecting `Pleasure`: when the keyword buffer is zero-based `dialog.rsp[0x30b]` (`Wasps/Sacrifice/Pleasure`), select the default `BYE` entry and confirm the dialogue closes without re-opening the keyword menu.
+- Runtime-test the Dad permission-slip handoff: obtain `MEAT_PERMISSION0` from the Butcher, show it to Dad after the permission state is armed, then confirm the inventory contains `MEAT_PERMISSION` and no longer shows `MEAT_PERMISSION0`.
diff --git a/engines/harvester/npc/dad_dialogue.cpp b/engines/harvester/npc/dad_dialogue.cpp
index 3bfd5cab4ef..21ef8829e53 100644
--- a/engines/harvester/npc/dad_dialogue.cpp
+++ b/engines/harvester/npc/dad_dialogue.cpp
@@ -93,7 +93,7 @@ Common::Error DadDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 		sharedState.dialogueStateD2eb4 = 1;
 		(void)runtime.startupScript().addRuntimeObjectToInventory("MEAT_PERMISSION");
 		(void)runtime.startupScript().setRuntimeObjectVisible(
-			kInventoryOwnerName, "MEAT_PERMISSION0", true);
+			kInventoryOwnerName, "MEAT_PERMISSION0", false);
 		return Common::kNoError;
 	};
 	auto isBlackmailEvidence = [&](const Common::String &itemName) {


Commit: c464b55a30dcd35b99b06c8a3fa7528683f5e63e
    https://github.com/scummvm/scummvm/commit/c464b55a30dcd35b99b06c8a3fa7528683f5e63e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:57+02:00

Commit Message:
HARVESTER: Preserve live palette during room mutations

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index b681be709cf..626b5fa36ef 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -306,7 +306,9 @@
   - Save-game handoff through the `SAVE_GAME` marker
 - Action dispatch ordering is confirmed for deferred text/movie chains:
   - `SET_FLAG` mutates only the matching `FlagRecord.value`; it does not reread the current room palette, rebuild the room, or call a palette ramp by itself.
+  - `SET_REGION` mutates the matching `RegionRecord` and live region entity in place; enabling spawns a region entity and disabling removes the live entity. It does not rebuild the room or recompute the active palette.
   - `SHOW_TEXT` creates a text entity over the existing render list and repeatedly calls `flush_dirty_rects_to_screen` / `update_music_stream` until input dismissal.
+  - `CHANGE_LIGHTING` opcode `0x22` owns live palette changes: `DIM` ramps the current room palette to `0.6`, `NORMAL` reloads the current room palette and ramps to `1.0`, `NONE` frees the current palette buffer and uploads black, and `FADE_IN` sets the deferred fade flag.
   - `CHANGE_LIGHTING NONE` clears the palette/screen before a following `GOFLIC`, and `GOFLIC` immediately calls `play_fst_sequence` without an intermediate same-room `room_setup`.
 - `SET_TIMER` and `KILL_TIMER` first resolve a live class `0x17` timer entity by name. If no live timer entity is present, native prints `WARNING: Timer %s not in list` and does not update `g_timer_records`.
   - When the live entity is present, the dispatcher toggles the entity enabled byte and mirrors that enabled state into the matching `TimerRecord`.
diff --git a/TRACKER.md b/TRACKER.md
index 8b3d0e8dcc4..fb3934762a3 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -11,8 +11,8 @@
 
 ## Last Confirmed Action
 
-- On April 17, 2026, verified the Dad meat-permission handoff against `HARVEST.SCR` and native `handle_talk_to_dad @ 0x2b020`: the script defines separate `MEAT_PERMISSION0` unsigned and `MEAT_PERMISSION` signed inventory objects, and the native Dad branch adds `MEAT_PERMISSION` then hides `MEAT_PERMISSION0` for owner `INVENTORY`. Updated the port to hide the unsigned slip after awarding the signed slip, and renamed the confirmed native helpers `get_set_shared_dialogue_state_d2eb4` and `play_c112s4_fst_sequence`.
+- On April 17, 2026, checked the day/night palette handoff against native `dispatch_room_event_actions @ 0x60ee0`, `room_setup @ 0x73540`, and decoded CD1/CD2 `HARVEST.SCR`: `SET_FLAG DAY_FLAG` does not alter the active palette, live `SET_REGION` mutates region entities in place, and only `CHANGE_LIGHTING` changes the live palette (`DIM` to `0.6`, `NORMAL` to `1.0`, `NONE` to black). Updated the port so live same-room mutations preserve the current palette state while room-entry mutations can still set the initial room fade target.
 
 ## Next Suggested Action
 
-- Runtime-test the Dad permission-slip handoff: obtain `MEAT_PERMISSION0` from the Butcher, show it to Dad after the permission state is armed, then confirm the inventory contains `MEAT_PERMISSION` and no longer shows `MEAT_PERMISSION0`.
+- Runtime-test the fire-station cloth day-4 transition and a normal nightfall transition: the text box should remain on the preexisting dim/night palette until the following `CHANGE_LIGHTING NONE` clears to black, with the next loaded room fading to the brightness implied by its room setup and `DAY_FLAG`.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 2d0ddc42633..748e9f98817 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -49,6 +49,8 @@ static const uint32 kRoomExitFastClickWindowTicks = 20;
 static const char *const kPlayerInventoryLabel = "your inventory";
 static const char *const kInventoryOwnerName = "INVENTORY";
 static const byte kTransparentPaletteIndex = 0;
+static const float kNormalPaletteBrightness = 1.0f;
+static const float kDimPaletteBrightness = 0.6f;
 static const int kNativeInventoryDragCloseLeft = 0x45;
 static const int kNativeInventoryDragCloseTop = 0x4b;
 static const int kNativeInventoryDragCloseRight = 0x239;
@@ -1659,12 +1661,15 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					runtimeTimer->looping);
 			}
 		};
-		auto applyCurrentRoomRuntimeMutationsInPlace = [&]() {
+		auto applyCurrentRoomRuntimeMutationsInPlace = [&](bool preservePaletteState) {
 			Script *script = _engine.getScript();
 			ResourceManager *resources = _engine.getResources();
 			if (!script || !resources)
 				return false;
 
+			byte previousPalette[256 * 3];
+			memcpy(previousPalette, scene.palette, sizeof(previousPalette));
+			const float previousPaletteBrightness = scene.targetPaletteBrightness;
 			const Common::Array<AudioCommand> entryAudioCommands = scene.state.audioCommands;
 			RoomSetupState updatedState;
 			if (!script->materializeRoomState(
@@ -1894,8 +1899,13 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			scene.sceneObjects = updatedScene.sceneObjects;
 			scene.sceneAnimations = updatedScene.sceneAnimations;
 			scene.sceneRegions = updatedScene.sceneRegions;
-			memcpy(scene.palette, updatedScene.palette, sizeof(scene.palette));
-			scene.targetPaletteBrightness = updatedScene.targetPaletteBrightness;
+			if (preservePaletteState) {
+				memcpy(scene.palette, previousPalette, sizeof(scene.palette));
+				scene.targetPaletteBrightness = previousPaletteBrightness;
+			} else {
+				memcpy(scene.palette, updatedScene.palette, sizeof(scene.palette));
+				scene.targetPaletteBrightness = updatedScene.targetPaletteBrightness;
+			}
 			captureCurrentSaveState();
 			return _inventory.refresh();
 		};
@@ -2031,7 +2041,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			if (clearCarryState)
 				clearCarriedRoomItem();
 
-			if (!applyCurrentRoomRuntimeMutationsInPlace() && !refreshCurrentScene(true))
+			if (!applyCurrentRoomRuntimeMutationsInPlace(true) && !refreshCurrentScene(true))
 				return Common::kReadingFailed;
 			if (!_inventory.refresh())
 				return Common::kReadingFailed;
@@ -2211,12 +2221,21 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			case kStartupLightingCommandNone:
 				return Common::kNoError;
 			case kStartupLightingCommandDim:
-				drawRoomScene(_engine, *activeScreen, scene, 0.6f);
-				setScaledRoomPalette(*activeScreen, scene.palette, 0.6f);
+				scene.targetPaletteBrightness = kDimPaletteBrightness;
+				drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
+				setScaledRoomPalette(*activeScreen, scene.palette, scene.targetPaletteBrightness);
 				activeScreen->makeAllDirty();
 				activeScreen->update();
 				return Common::kNoError;
 			case kStartupLightingCommandNormal:
+				if (ResourceManager *resources = _engine.getResources()) {
+					if (!scene.state.palettePath.empty() &&
+							!loadPaletteResource(*resources, scene.state.palettePath, scene.palette)) {
+						warning("Harvester: unable to reload room palette '%s' for CHANGE_LIGHTING NORMAL",
+							scene.state.palettePath.c_str());
+					}
+				}
+				scene.targetPaletteBrightness = kNormalPaletteBrightness;
 				drawRoomScene(_engine, *activeScreen, scene, scene.targetPaletteBrightness);
 				setScaledRoomPalette(*activeScreen, scene.palette, scene.targetPaletteBrightness);
 				activeScreen->makeAllDirty();
@@ -2572,7 +2591,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 						engine.stopMusic();
 					didTransition = true;
 				} else if (interaction.visualRuntimeStateChanged) {
-					if (!applyCurrentRoomRuntimeMutationsInPlaceFn() &&
+					if (!applyCurrentRoomRuntimeMutationsInPlaceFn(true) &&
 							!refreshCurrentSceneFn(true)) {
 						return Common::kReadingFailed;
 					}
@@ -2724,7 +2743,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			// Native room_setup applies entry-time state changes before the first visible room frame.
 			if (roomEntryInteraction.mutatedRuntimeState) {
 				syncOffscreenGlobalTimerEntities(roomEntryInteraction.previousTimerRecords);
-				if (!applyCurrentRoomRuntimeMutationsInPlace() &&
+				if (!applyCurrentRoomRuntimeMutationsInPlace(false) &&
 						!refreshCurrentScene(true)) {
 					return Common::kReadingFailed;
 				}


Commit: cc613021ff909ed400f1b65f1abdaf39a0cf3685
    https://github.com/scummvm/scummvm/commit/cc613021ff909ed400f1b65f1abdaf39a0cf3685
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:57+02:00

Commit Message:
HARVESTER: Fix Moynahan glue trade state

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/npc/moynahan_dialogue.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 626b5fa36ef..eee9bdca8bf 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -582,7 +582,7 @@
   - `handle_talk_to_moynahan @ 0x24a20` is now bounded enough for a direct startup-room port.
     - `IF_TRY_TO_TAKE_THE_GLUE` is a strict early interrupt branch: it always plays `0x3e4d` / `0x3e52` / `0x3e56`, then either exits on `0x3e76` / `0x3e7a` or, once the slashes-on-body topic has been unlocked, continues through `0x3e5b` / `0x3e60` / `0x3e64` / `0x3e69` / `0x3e6d` / `0x3e71`.
     - The first no-item visit clears the Moynahan-local intro gate and plays the fixed six-line opener `0x3d33` / `0x3d37` / `0x3d3b` / `0x3d40` / `0x3d45` / `0x3d47`; later visits start on `0x3fd1`, can inject the slashes-on-body topic into the live keyword buffer through zero-based `dialog.rsp` lines `0x194` / `0x195`, can re-arm the intro gate after the shared casket-photo evidence is discussed, and then run the `HAVE_BOTH_LEDGERS`, `STEPH_MIDGAME_PLAYED`, day-5, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, Butcher-death, Jimmy-death, and `KARIN_FOUND_DEAD` follow-ups in that priority order.
-    - The item path has three real response-menu branches: `CASKET_PHOTO` / `CASKET_PHOTOCOPY` uses zero-based `dialog.rsp` lines `0x18c` and `0x18d`, returns the presented photo to `RAH`, awards `GLUE`, and sets `JUST_GOT_GLUE`; `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` uses zero-based `dialog.rsp` line `0x18e`; and note/checkbook evidence uses zero-based `dialog.rsp` line `0x190`. `PHOTO_OF_WHALEY_HERRILL`, `WED_MATCHES`, and the fallback item path stay linear.
+    - The item path has three real response-menu branches: `CASKET_PHOTO` / `CASKET_PHOTOCOPY` uses zero-based `dialog.rsp` lines `0x18c` and `0x18d`; the accepted trade calls `set_object_visibility_for_owner_or_room(..., visible=0)` for the presented photo and for `GLUE`, then calls `add_object_to_inventory(GLUE)` and sets `JUST_GOT_GLUE`; `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` uses zero-based `dialog.rsp` line `0x18e`; and note/checkbook evidence uses zero-based `dialog.rsp` line `0x190`. `PHOTO_OF_WHALEY_HERRILL`, `WED_MATCHES`, and the fallback item path stay linear.
     - Moynahan's keyword loop is typed-topic driven. Zero-based `dialog.rsp` line `0x199` is the observable farewell path behind `0x3e32`; `0x19a` / `0x19b` set the shared Mom day-5 good-cause state before `0x3d5f`; `0x19d..0x1a0`, `0x1a2..0x1a3`, and `0x1a9..0x1ab` each converge on single response lines; `0x1b2..0x1b3` is the day-5-only branch that writes the recovered shared `d2ec0` / `d2ec8` bits before `0x3df5`; `0x1b4..0x1b5` is room-specific to `MBLM`; and `0x1b8..0x1be` plus `0x1bf..0x1c2` are also day-5 gated.
     - The later hidden compare against zero-based `dialog.rsp[0x1c4]` is still present at `0x25ef3`, but the shipped `dialog.rsp` gives both `0x199` and `0x1c4` the same literal `BYE` bytes. Because the `0x1c4` match only jumps back into the top-of-loop `0x199` test, it does not produce a distinct engine-visible branch beyond the already recovered `0x3e32` farewell.
   - `handle_talk_to_stephanie @ 0x36710` is now bounded enough for a direct startup-room port.
diff --git a/TRACKER.md b/TRACKER.md
index fb3934762a3..d3997cb0194 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -11,8 +11,8 @@
 
 ## Last Confirmed Action
 
-- On April 17, 2026, checked the day/night palette handoff against native `dispatch_room_event_actions @ 0x60ee0`, `room_setup @ 0x73540`, and decoded CD1/CD2 `HARVEST.SCR`: `SET_FLAG DAY_FLAG` does not alter the active palette, live `SET_REGION` mutates region entities in place, and only `CHANGE_LIGHTING` changes the live palette (`DIM` to `0.6`, `NORMAL` to `1.0`, `NONE` to black). Updated the port so live same-room mutations preserve the current palette state while room-entry mutations can still set the initial room fade target.
+- On April 17, 2026, checked Moynahan's accepted casket-photo trade against native `handle_talk_to_moynahan @ 0x24a20`: after `dialog.rsp[0x18d]` response 1, native hides the presented photo and `GLUE`, adds `GLUE` to inventory, and sets `JUST_GOT_GLUE`. Updated the port so the direct dialogue mutation queues a live room visual refresh, removing the stale embalming-room glue entity after the trade.
 
 ## Next Suggested Action
 
-- Runtime-test the fire-station cloth day-4 transition and a normal nightfall transition: the text box should remain on the preexisting dim/night palette until the following `CHANGE_LIGHTING NONE` clears to black, with the next loaded room fading to the brightness implied by its room setup and `DAY_FLAG`.
+- Runtime-test the MBLM glue trade: give Moynahan `CASKET_PHOTO`, accept the glue response, confirm `GLUE` appears in inventory, `CASKET_PHOTO` is no longer visible in inventory, and clicking the former room glue spot no longer starts the "can't have that" interruption dialogue.
diff --git a/engines/harvester/npc/moynahan_dialogue.cpp b/engines/harvester/npc/moynahan_dialogue.cpp
index b421ddf7b14..30fa59d7479 100644
--- a/engines/harvester/npc/moynahan_dialogue.cpp
+++ b/engines/harvester/npc/moynahan_dialogue.cpp
@@ -227,10 +227,22 @@ Common::Error MoynahanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 	auto playMoynahanLine = [&](int wavId, int headVariant = 0) -> Common::Error {
 		return runtime.playDialogueLineWithVariant(wavId, kMoynahanNpc, headVariant);
 	};
+	auto queueRoomVisualMutation = [&](bool changed) {
+		if (!changed)
+			return;
+
+		InteractionResult interaction;
+		interaction.mutatedRuntimeState = true;
+		interaction.visualRuntimeStateChanged = true;
+		runtime.queueDialogueInteractionIfNeeded(interaction);
+	};
 	auto giveGlue = [&]() {
-		(void)runtime.startupScript().setRuntimeObjectVisible(kRahRoom, kGlueObject, true);
-		(void)runtime.startupScript().addRuntimeObjectToInventory(kGlueObject);
-		(void)runtime.startupScript().setRuntimeFlagValue(kJustGotGlueFlag, true);
+		bool changed = false;
+		changed |= runtime.startupScript().setRuntimeObjectVisible(kRahRoom, kGlueObject, false);
+		changed |= runtime.startupScript().addRuntimeObjectToInventory(kGlueObject);
+		changed |= runtime.startupScript().setRuntimeFlagValue(kJustGotGlueFlag, true);
+		state.gotGlue = true;
+		return changed;
 	};
 	auto isCasketPhoto = [&](const Common::String &itemName) {
 		return itemName.equalsIgnoreCase("CASKET_PHOTO") ||
@@ -313,10 +325,10 @@ Common::Error MoynahanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 				if (lineError.getCode() != Common::kNoError)
 					return lineError;
 
-				(void)runtime.startupScript().resetRuntimeObjectToInitialState(usedItemName);
-				(void)runtime.startupScript().setRuntimeObjectVisible(kRahRoom, usedItemName, true);
-				giveGlue();
-				state.gotGlue = true;
+				bool changed = false;
+				changed |= runtime.startupScript().setRuntimeObjectVisible(kRahRoom, usedItemName, false);
+				changed |= giveGlue();
+				queueRoomVisualMutation(changed);
 				return Common::kNoError;
 			}
 
@@ -709,7 +721,7 @@ Common::Error MoynahanDialogueHandler::handleDialogue(DialogueRuntime &runtime,
 						lineError = playMoynahanLine(0x4108, 2);
 						if (lineError.getCode() != Common::kNoError)
 							return lineError;
-						giveGlue();
+						queueRoomVisualMutation(giveGlue());
 					} else if (responseIndex == 2) {
 						lineError = playMoynahanLine(0x410d, 2);
 						if (lineError.getCode() != Common::kNoError)


Commit: 92d4f68713daf82ec92d399dc232f49cda53f169
    https://github.com/scummvm/scummvm/commit/92d4f68713daf82ec92d399dc232f49cda53f169
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:57+02:00

Commit Message:
HARVESTER: Fix global timer sync on room exit

Changed paths:
    TRACKER.md
    engines/harvester/room.cpp


diff --git a/TRACKER.md b/TRACKER.md
index d3997cb0194..f3232d93e77 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -11,8 +11,8 @@
 
 ## Last Confirmed Action
 
-- On April 17, 2026, checked Moynahan's accepted casket-photo trade against native `handle_talk_to_moynahan @ 0x24a20`: after `dialog.rsp[0x18d]` response 1, native hides the presented photo and `GLUE`, adds `GLUE` to inventory, and sets `JUST_GOT_GLUE`. Updated the port so the direct dialogue mutation queues a live room visual refresh, removing the stale embalming-room glue entity after the trade.
+- On April 17, 2026, fixed the room-exit global timer sync path so `DNALFT` exit commands that run `SET_TIMER EDNA_BURNING_TIMER OFF` / `KILL_TIMER EDNA_BURNING_TIMER` also reconfigure the live preserved global timer entity before the next room setup carries it forward.
 
 ## Next Suggested Action
 
-- Runtime-test the MBLM glue trade: give Moynahan `CASKET_PHOTO`, accept the glue response, confirm `GLUE` appears in inventory, `CASKET_PHOTO` is no longer visible in inventory, and clicking the former room glue spot no longer starts the "can't have that" interruption dialogue.
+- Runtime-test the DNA fire day transition: light Edna's diner, exit through `DNAEXT_X2` to Day 6, then remain in PC/Hank dialogue past the old countdown window and confirm `EDNA_BURNING_TIMER` no longer fires `GRAPHIC/FST/PCBURN.FST`.
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 748e9f98817..1b859a7555b 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -1616,8 +1616,9 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 			return false;
 		};
-		auto syncOffscreenGlobalTimerEntities =
-				[&](const Common::Array<TimerRecord> &previousTimerRecords) {
+		auto syncGlobalTimerEntities =
+				[&](const Common::Array<TimerRecord> &previousTimerRecords,
+						bool includeCurrentRoomTimers = false) {
 			Script *script = _engine.getScript();
 			if (!script || !entityManager || previousTimerRecords.empty())
 				return;
@@ -1626,7 +1627,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				const TimerRecord *runtimeTimer =
 					script->findRuntimeTimerRecord(previousTimer.timerName);
 				if (!runtimeTimer || !runtimeTimer->global ||
-						isTimerMaterializedInCurrentRoom(runtimeTimer->timerName)) {
+						(!includeCurrentRoomTimers &&
+							isTimerMaterializedInCurrentRoom(runtimeTimer->timerName))) {
 					continue;
 				}
 
@@ -1637,7 +1639,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				if (!entity) {
 					(void)script->syncRuntimeTimerRecord(previousTimer);
 					debugC(1, kDebugRoom,
-						"Harvester: offscreen global timer mutation ignored current_room='%s' timer='%s' owner_room='%s' reason='missing live timer entity'",
+						"Harvester: global timer mutation ignored current_room='%s' timer='%s' owner_room='%s' reason='missing live timer entity'",
 						scene.state.roomName.c_str(), previousTimer.timerName.c_str(),
 						previousTimer.arg1.c_str());
 					continue;
@@ -1654,7 +1656,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					runtimeTimer->initialValue, runtimeTimer->currentValue,
 					runtimeTimer->enabled, runtimeTimer->looping, runtimeTimer->global);
 				debugC(1, kDebugRoom,
-					"Harvester: offscreen global timer synced current_room='%s' timer='%s' owner_room='%s' current=%d initial=%d enabled=%d loop=%d",
+					"Harvester: global timer synced current_room='%s' timer='%s' owner_room='%s' current=%d initial=%d enabled=%d loop=%d",
 					scene.state.roomName.c_str(), runtimeTimer->timerName.c_str(),
 					runtimeTimer->arg1.c_str(), runtimeTimer->currentValue,
 					runtimeTimer->initialValue, runtimeTimer->enabled,
@@ -2177,7 +2179,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					(void)_engine.playMusic(exitInteraction.musicPath);
 				flow.executeStartupAudioCommands(exitInteraction.audioCommands);
 				if (exitInteraction.mutatedRuntimeState)
-					syncOffscreenGlobalTimerEntities(exitInteraction.previousTimerRecords);
+					syncGlobalTimerEntities(exitInteraction.previousTimerRecords, true);
 
 				if (!exitInteraction.cutscenePath.empty()) {
 					FstPlayer fstPlayer(_engine);
@@ -2411,7 +2413,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			const bool canExitCloseupToParent;
 			decltype(refreshCurrentScene) &refreshCurrentSceneFn;
 			decltype(applyCurrentRoomRuntimeMutationsInPlace) &applyCurrentRoomRuntimeMutationsInPlaceFn;
-			decltype(syncOffscreenGlobalTimerEntities) &syncOffscreenGlobalTimerEntitiesFn;
+			decltype(syncGlobalTimerEntities) &syncGlobalTimerEntitiesFn;
 			decltype(captureDialogueBackdrop) &captureDialogueBackdropFn;
 			decltype(showCdChangePrompt) &showCdChangePromptFn;
 			decltype(runRoomExitCommands) &runRoomExitCommandsFn;
@@ -2464,7 +2466,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				}
 				flow.executeStartupAudioCommands(interaction.audioCommands);
 				if (interaction.mutatedRuntimeState)
-					syncOffscreenGlobalTimerEntitiesFn(interaction.previousTimerRecords);
+					syncGlobalTimerEntitiesFn(interaction.previousTimerRecords);
 				auto queueImplicitRoomRestart = [&]() -> Common::Error {
 					pendingRoomChangeIsRoomName = false;
 					pendingRoomChangeUsesSavedRoomState = false;
@@ -2710,7 +2712,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			pendingRoomChangeSavedRoomState,
 			canExitCloseupToParent,
 			refreshCurrentScene, applyCurrentRoomRuntimeMutationsInPlace,
-			syncOffscreenGlobalTimerEntities, captureDialogueBackdrop, showCdChangePrompt,
+			syncGlobalTimerEntities, captureDialogueBackdrop, showCdChangePrompt,
 			runRoomExitCommands, applyLightingCommand, applyPlayerGotoXZ, runModalShowText,
 			resetIdleState, stopPlayerRegionInteraction, startPlayerDefeatSequence
 		};
@@ -2742,7 +2744,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 
 			// Native room_setup applies entry-time state changes before the first visible room frame.
 			if (roomEntryInteraction.mutatedRuntimeState) {
-				syncOffscreenGlobalTimerEntities(roomEntryInteraction.previousTimerRecords);
+				syncGlobalTimerEntities(roomEntryInteraction.previousTimerRecords);
 				if (!applyCurrentRoomRuntimeMutationsInPlace(false) &&
 						!refreshCurrentScene(true)) {
 					return Common::kReadingFailed;


Commit: feea0499c79e93f20f03502f09de4144fb6a3ba9
    https://github.com/scummvm/scummvm/commit/feea0499c79e93f20f03502f09de4144fb6a3ba9
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:58+02:00

Commit Message:
HARVESTER: Fix region exit movement target completion

Keep ordinary floor-click movement using the native-style depth slack,
but require queued region interactions to keep walking until the
screen-bottom target is reached so overlap checks can fire.

Changed paths:
    TRACKER.md
    engines/harvester/player.cpp
    engines/harvester/player.h
    engines/harvester/room.cpp
    engines/harvester/room_support.h


diff --git a/TRACKER.md b/TRACKER.md
index f3232d93e77..feba6c7c920 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -11,8 +11,8 @@
 
 ## Last Confirmed Action
 
-- On April 17, 2026, fixed the room-exit global timer sync path so `DNALFT` exit commands that run `SET_TIMER EDNA_BURNING_TIMER OFF` / `KILL_TIMER EDNA_BURNING_TIMER` also reconfigure the live preserved global timer entity before the next room setup carries it forward.
+- On April 17, 2026, fixed region-target movement completion so pending exits such as `BWLNT1_2_BWLNT2` keep walking until both target depth and target screen Y are reached, while ordinary floor clicks still use the native-style depth slack.
 
 ## Next Suggested Action
 
-- Runtime-test the DNA fire day transition: light Edna's diner, exit through `DNAEXT_X2` to Day 6, then remain in PC/Hank dialogue past the old countdown window and confirm `EDNA_BURNING_TIMER` no longer fires `GRAPHIC/FST/PCBURN.FST`.
+- Runtime-test `BOWLSNTRY1` after entering from `RECEPTION_2_BOWLSNTRY1`: click or double-click the bottom exit region and confirm the player reaches the trigger and runs `BWLNTR1_2_BWLNTR2`.
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index a8663d40fad..1e28d18c2bf 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -626,6 +626,10 @@ static int resolveMoveTargetDepthDirection(const RoomPlayerState &playerState) {
 		return 1;
 	if (playerState.z + kRoomPlayerDepthTargetSlack < playerState.targetZ)
 		return -1;
+	if (playerState.moveTargetRequiresScreenY && playerState.bottomY < playerState.targetBottomY)
+		return 1;
+	if (playerState.moveTargetRequiresScreenY && playerState.bottomY > playerState.targetBottomY)
+		return -1;
 
 	return 0;
 }
@@ -1003,12 +1007,13 @@ static bool recordNativePrimaryWalkBlockerHistory(const RoomSetupState &state,
 }
 
 static void setMoveTargetInternal(const RoomSetupState &state, RoomPlayerState &playerState,
-		int targetX, float targetZ, int targetBottomY) {
+		int targetX, float targetZ, int targetBottomY, bool requireScreenY) {
 	playerState.hasMoveTarget = true;
 	playerState.nextMovementTick = 0;
 	playerState.targetX = CLIP<int>(targetX, 0, 639);
 	playerState.targetBottomY = clampRoomMovementY(state, targetBottomY);
 	playerState.targetZ = clampRoomDepth(state, targetZ);
+	playerState.moveTargetRequiresScreenY = requireScreenY;
 	resetPlayerMoveTargetProgress(playerState);
 	debugC(1, kDebugPlayer,
 		"Harvester: player move target room='%s' current=(%d,%d,z=%.2f) target=(%d,%d,z=%.2f)",
@@ -1219,14 +1224,20 @@ void Player::updateIdleTrigger(RoomIdleAnimationState &idleState) {
 void Player::setMoveTarget(const RoomSetupState &state, RoomPlayerState &playerState,
 		int targetX, float targetZ) {
 	setMoveTargetInternal(state, playerState, targetX, targetZ,
-		mapRoomDepthToScreenY(state, targetZ));
+		mapRoomDepthToScreenY(state, targetZ), false);
+}
+
+void Player::setRegionMoveTarget(const RoomSetupState &state, RoomPlayerState &playerState,
+		int targetX, float targetZ) {
+	setMoveTargetInternal(state, playerState, targetX, targetZ,
+		mapRoomDepthToScreenY(state, targetZ), true);
 }
 
 void Player::setMoveTargetFromScreenPoint(const RoomSetupState &state,
 		RoomPlayerState &playerState, int targetX, int targetBottomY) {
 	const int clampedTargetBottomY = clampRoomMovementY(state, targetBottomY);
 	setMoveTargetInternal(state, playerState, targetX,
-		mapRoomScreenYToDepth(state, clampedTargetBottomY), clampedTargetBottomY);
+		mapRoomScreenYToDepth(state, clampedTargetBottomY), clampedTargetBottomY, false);
 }
 
 bool Player::resolveBlockedStartupSpawn(HarvesterEngine &engine, const RoomSetupState &state,
diff --git a/engines/harvester/player.h b/engines/harvester/player.h
index 27f7a92b247..3bed563434e 100644
--- a/engines/harvester/player.h
+++ b/engines/harvester/player.h
@@ -43,6 +43,8 @@ public:
 	static void updateIdleTrigger(RoomIdleAnimationState &idleState);
 	static void setMoveTarget(const RoomSetupState &state, RoomPlayerState &playerState,
 		int targetX, float targetZ);
+	static void setRegionMoveTarget(const RoomSetupState &state, RoomPlayerState &playerState,
+		int targetX, float targetZ);
 	static void setMoveTargetFromScreenPoint(const RoomSetupState &state,
 		RoomPlayerState &playerState, int targetX, int targetBottomY);
 	static bool resolveBlockedStartupSpawn(HarvesterEngine &engine, const RoomSetupState &state,
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 1b859a7555b..21c72af5025 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -4188,7 +4188,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 		if (playerOverlapsRegion)
 			return;
 
-		Player::setMoveTarget(scene.state, playerState,
+		Player::setRegionMoveTarget(scene.state, playerState,
 			Player::resolveRegionTargetX(region, playerState),
 			Player::resolveRegionTargetZ(region));
 		if (_engine.isPathfindingDebugEnabled()) {
diff --git a/engines/harvester/room_support.h b/engines/harvester/room_support.h
index 7c52d9df7f7..4d9733580b1 100644
--- a/engines/harvester/room_support.h
+++ b/engines/harvester/room_support.h
@@ -64,6 +64,7 @@ struct RoomPlayerState {
 	int targetX = 0;
 	int targetBottomY = 0;
 	float targetZ = 0.0f;
+	bool moveTargetRequiresScreenY = false;
 	bool moveTargetXReached = false;
 	bool moveTargetZReached = false;
 	Common::String currentBlockerName;


Commit: c879b940449862f94b59622b17ada1b68bc50fc8
    https://github.com/scummvm/scummvm/commit/c879b940449862f94b59622b17ada1b68bc50fc8
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:58+02:00

Commit Message:
HARVESTER: Avoid duplicate chessmaster death sprite

Changed paths:
    engines/harvester/npc/chessmaster_dialogue.cpp


diff --git a/engines/harvester/npc/chessmaster_dialogue.cpp b/engines/harvester/npc/chessmaster_dialogue.cpp
index e0cc78a4db5..0cebc88beb4 100644
--- a/engines/harvester/npc/chessmaster_dialogue.cpp
+++ b/engines/harvester/npc/chessmaster_dialogue.cpp
@@ -440,7 +440,8 @@ Common::Error ChessmasterDialogueHandler::handleDialogue(DialogueRuntime &runtim
 		return lineError;
 
 	InteractionResult interaction;
-	if (runtime.startupScript().queueRuntimeNpcDeathOrMonsterfy(
+	// C125.FST already shows the chessmaster death; do not queue the room death bank again.
+	if (runtime.startupScript().finalizeRuntimeNpcDeathOrMonsterfy(
 			kChessmasterNpc, kChessmasterSlashDamageType)) {
 		interaction.mutatedRuntimeState = true;
 		interaction.visualRuntimeStateChanged = true;


Commit: f9baa8c275c0e738dfbe5e89174a3b944f00b26f
    https://github.com/scummvm/scummvm/commit/f9baa8c275c0e738dfbe5e89174a3b944f00b26f
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:58+02:00

Commit Message:
HARVESTER: Fix Roman bath exit targeting

Changed paths:
    ARCHITECTURE.md
    TRACKER.md
    engines/harvester/player.cpp
    engines/harvester/player.h
    engines/harvester/room.cpp


diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index eee9bdca8bf..fc703a99b85 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1091,6 +1091,8 @@
   - It clears the linked spawned actor at `+0x11a8`, frees the runtime sound/sample fields from `+0x1148` through `+0x1168`, resets the entity to a hidden offscreen idle state, and frees the current bitmap buffers without freeing the outer runtime-entity allocation.
 - `check_player_region_interaction` at `0x559e0` scans `g_region_records` for the first enabled region whose bounds overlap the live player avatar and whose facing gate matches the player's orientation.
   - On success it returns the matching `RegionRecord *` so the main loop can dispatch the region's `action_tag`.
+  - The overlap test uses `do_entity_screen_bounds_overlap` at `0x4b700`, which compares entity draw bounds with edge-inclusive `<` rejection checks before the Z-range and facing checks.
+  - The cursor-enabled region click path in `run_harvester_main_loop` stores the region entity center X as the player's target, applies the native 10-pixel left-side bias only when that target is left of the player's current frame-left X, and stores region target Z from the required facing: `FRONT` uses max Z, `BACK` uses min Z, and side-facing exits use the region midpoint.
 - `reset_player_combat_avatar` at `0x56a40` is the player-avatar reset helper used on teardown / restart paths.
   - It zeroes transient combat/runtime state, restores the default health/state baseline, and if the current loadout is non-default it rebuilds the player combat avatar back through loadout `0`.
 - `update_actor_runtime_state` at `0x4d750` is the shared per-frame actor state machine for live actor entities.
diff --git a/TRACKER.md b/TRACKER.md
index feba6c7c920..1aa25d857d5 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -11,8 +11,8 @@
 
 ## Last Confirmed Action
 
-- On April 17, 2026, fixed region-target movement completion so pending exits such as `BWLNT1_2_BWLNT2` keep walking until both target depth and target screen Y are reached, while ordinary floor clicks still use the native-style depth slack.
+- On April 17, 2026, verified native cursor-enabled region targeting and player-region collision in Ghidra, then fixed `RMNBATH` bath-exit handling by allowing fast exit clicks there and adjusting side-region X targets so the native-style horizontal slack cannot stop the player outside narrow side exits such as `RNMBTH_BATH`.
 
 ## Next Suggested Action
 
-- Runtime-test `BOWLSNTRY1` after entering from `RECEPTION_2_BOWLSNTRY1`: click or double-click the bottom exit region and confirm the player reaches the trigger and runs `BWLNTR1_2_BWLNTR2`.
+- Runtime-test `RMNBATH` after entering from `BATHROOM_2_RMNBATH`: double-click `RNMBTH_BATH` and confirm it immediately runs `RMNBATHM_BATHROOM`, then single-click the same region and confirm walking still reaches the trigger.
diff --git a/engines/harvester/player.cpp b/engines/harvester/player.cpp
index 1e28d18c2bf..b2bd3f1dc25 100644
--- a/engines/harvester/player.cpp
+++ b/engines/harvester/player.cpp
@@ -1292,12 +1292,24 @@ bool Player::resolveBlockedStartupSpawn(HarvesterEngine &engine, const RoomSetup
 	return false;
 }
 
-int Player::resolveRegionTargetX(const RegionRecord &region,
+int Player::resolveRegionTargetX(const RoomSetupState &state, const RegionRecord &region,
 		const RoomPlayerState &playerState) {
 	const Common::Rect bounds(region.left, region.top, region.right + 1, region.bottom + 1);
 	int targetX = bounds.left + bounds.width() / 2;
-	if (playerState.entity && targetX + kRoomRegionTargetXBias < playerState.entity->getScreenRect().right)
+	if (!playerState.entity)
+		return CLIP<int>(targetX, 0, 639);
+
+	const int frameLeftX = computePlayerFrameLeftX(playerState);
+	if (targetX + kRoomRegionTargetXBias < frameLeftX)
 		targetX -= kRoomRegionTargetXBias;
+
+	const int targetSlack = MAX<int>(1,
+		roundToInt(Player::computeDepthScale(state, playerState.z) *
+			kRoomPlayerHorizontalTargetSlackBase));
+	if (region.desiredFacing == 1)
+		targetX = MIN<int>(targetX, region.right - targetSlack);
+	else if (region.desiredFacing == 2)
+		targetX = MAX<int>(targetX, region.left + targetSlack);
 	return CLIP<int>(targetX, 0, 639);
 }
 
diff --git a/engines/harvester/player.h b/engines/harvester/player.h
index 3bed563434e..867fdff5438 100644
--- a/engines/harvester/player.h
+++ b/engines/harvester/player.h
@@ -51,7 +51,7 @@ public:
 		const Common::Array<ObjectRecord> &sceneObjects,
 		const Common::Array<AnimRecord> &sceneAnimations,
 		RoomPlayerState &playerState);
-	static int resolveRegionTargetX(const RegionRecord &region,
+	static int resolveRegionTargetX(const RoomSetupState &state, const RegionRecord &region,
 		const RoomPlayerState &playerState);
 	static float resolveRegionTargetZ(const RegionRecord &region);
 	static bool syncCombatLoadoutVisual(HarvesterEngine &engine, const RoomSetupState &state,
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 21c72af5025..86b360cfb7e 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -215,7 +215,6 @@ struct ScopedDeferredLiveNpcDeathTransitions {
 
 static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
 	return !roomName.equalsIgnoreCase("LAVAPIT") &&
-		!roomName.equalsIgnoreCase("RMNBATH") &&
 		!roomName.equalsIgnoreCase("BOWLSNTRY1");
 }
 
@@ -4189,7 +4188,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			return;
 
 		Player::setRegionMoveTarget(scene.state, playerState,
-			Player::resolveRegionTargetX(region, playerState),
+			Player::resolveRegionTargetX(scene.state, region, playerState),
 			Player::resolveRegionTargetZ(region));
 		if (_engine.isPathfindingDebugEnabled()) {
 			debugC(1, kDebugPathfinding,


Commit: d2868ac68a6a8bb5b9ba75bdaff7e5dc819aa73c
    https://github.com/scummvm/scummvm/commit/d2868ac68a6a8bb5b9ba75bdaff7e5dc819aa73c
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:59+02:00

Commit Message:
HARVESTER: Split room combat and interaction code

Changed paths:
  A engines/harvester/room_combat.cpp
  A engines/harvester/room_combat.h
  A engines/harvester/room_interaction.cpp
  A engines/harvester/room_interaction.h
    engines/harvester/flow.h
    engines/harvester/module.mk
    engines/harvester/room.cpp


diff --git a/engines/harvester/flow.h b/engines/harvester/flow.h
index cf621ccdef3..0c812fcce3c 100644
--- a/engines/harvester/flow.h
+++ b/engines/harvester/flow.h
@@ -40,6 +40,7 @@ namespace Harvester {
 
 class HarvesterEngine;
 class DialogueFlowAccess;
+class RoomInteractionProcessor;
 struct IndexedBitmap;
 
 class Flow {
@@ -57,6 +58,7 @@ private:
 	friend class DialogueSystem;
 	friend class InventorySystem;
 	friend class MenuSystem;
+	friend class RoomInteractionProcessor;
 	friend class RoomSystem;
 
 	bool loadQuickTips();
diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index b09ac75d113..33e82a01c51 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -66,6 +66,8 @@ MODULE := engines/harvester
 	menu.o \
 	monster.o \
 	player.o \
+	room_combat.o \
+	room_interaction.o \
 	room.o \
 	script.o \
 	text.o \
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 86b360cfb7e..a8eb4f60f9a 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -41,6 +41,8 @@
 #include "harvester/inventory.h"
 #include "harvester/monster.h"
 #include "harvester/player.h"
+#include "harvester/room_combat.h"
+#include "harvester/room_interaction.h"
 #include "harvester/room_support.h"
 
 namespace Harvester {
@@ -64,154 +66,7 @@ static const uint32 kCombatLoadoutStatusDisplayTicks = 75;
 static const int kNativeCombatResourceIconsX = 10;
 static const int kNativeCombatResourceIconsY = 30;
 static const int kNativeCombatResourceIconSpacing = 15;
-static const int kRoomMonsterAnimationRate = 17;
-static const int kNativeMonsterAttackAnimationRate = 4;
-static const int kNativeMonsterHitAnimationRate = 5;
-static const uint32 kNativeMonsterAttackCooldownBaseTicks = 50;
-static const float kNativeNpcMonsterZExtent = 5.0f;
-static const int kRoomNpcAmbientLastFrame = 0x3b;
-static const int kDefaultMonsterAttackContactFrameOffset = 2;
-static const int kMuckeyAttackContactFrameOffset = 7;
-static const float kNativeMonsterPursuitZTolerance = 2.0f;
-static const float kNativeMonsterHorizontalWaypointTolerance = 50.0f;
-static const uint32 kNativeKeyboardAttackRepeatTicks = 25;
-static const int kNativeAttackSideWindow = 30;
-static const int kNativeCombatHitKnockbackDistance = 18;
-static const int kNativeCombatHitKnockbackDecayStep = 3;
-static const char *const kNativeHitEffectResourcePath = "blood.abm";
-static const int kNativeHitEffectAnimationRate = 10;
-static const float kNativeHitEffectRenderZBias = 0.01f;
-static const uint32 kCombatDebugDamagePopupDurationMs = 750;
-static const int kCombatDebugDamagePopupRisePixels = 18;
-static const int kCombatDebugDamagePopupVerticalGap = 6;
 static const char *const kCdChangePromptPalettePath = "1:/GRAPHIC/PAL/CD1.PAL";
-static const char *const kExitCloseupPendingRoomChange = "__EXIT_CLOSEUP__";
-static const char *const kInnerSanctumRoomName = "INNERSANCTUM";
-static const char *const kHerrillLogNpcName = "HERRILL_LOG";
-static const char *const kMuckeyMonsterName = "MUCKEY";
-static const char *const kActivateInnerSanctumDoorActionTag = "ACTV_INNERSAN_DOOR";
-static const int kNativeDefaultNpcDeathDamageType = 1;
-
-static bool shouldRetainNpcDeathEntityInCurrentRoom(const NpcRecord &npc) {
-	return npc.monsterfyTargetName.empty();
-}
-
-static bool isRetainedCurrentRoomNpcDeathRecord(const NpcRecord &npc) {
-	return npc.deathOrMonsterfyFlag &&
-		npc.monsterfyTargetName.empty() &&
-		npc.deathDamageType != 0 &&
-		npc.runtimeState >= 0;
-}
-
-static int roundRoomCombatFloat(float value);
-
-struct RoomMonsterFacingAnimationRange {
-	RoomMonsterFacingAnimationRange() {}
-	RoomMonsterFacingAnimationRange(int walkFirstFrame, int walkLastFrame, int idleFrame)
-		: walkFirstFrame(walkFirstFrame), walkLastFrame(walkLastFrame), idleFrame(idleFrame) {}
-
-	int walkFirstFrame = 0;
-	int walkLastFrame = 0;
-	int idleFrame = 0;
-};
-
-struct RoomAttackAnimationRange {
-	RoomAttackAnimationRange() {}
-	RoomAttackAnimationRange(int firstFrame, int lastFrame, int resumeFacing)
-		: firstFrame(firstFrame), lastFrame(lastFrame), resumeFacing(resumeFacing) {}
-
-	int firstFrame = 0;
-	int lastFrame = 0;
-	int resumeFacing = 0;
-};
-
-struct RoomDeathAnimationRange {
-	RoomDeathAnimationRange() {}
-	RoomDeathAnimationRange(int firstFrame, int lastFrame)
-		: firstFrame(firstFrame), lastFrame(lastFrame) {}
-
-	int firstFrame = -1;
-	int lastFrame = -1;
-};
-
-struct RoomHitAnimationRange {
-	RoomHitAnimationRange() {}
-	RoomHitAnimationRange(int firstFrame, int lastFrame, int resumeFacing, int knockbackX)
-		: firstFrame(firstFrame), lastFrame(lastFrame), resumeFacing(resumeFacing),
-		  knockbackX(knockbackX) {}
-
-	int firstFrame = -1;
-	int lastFrame = -1;
-	int resumeFacing = -1;
-	int knockbackX = 0;
-};
-
-struct RoomMonsterCombatState {
-	bool attackActive = false;
-	int attackFirstFrame = -1;
-	int attackLastFrame = -1;
-	int attackContactFrame = -1;
-	int attackResumeFacing = -1;
-	bool attackSoundPlayed = false;
-	bool attackContactResolved = false;
-	Common::String attackTargetName;
-	bool hitActive = false;
-	int hitFirstFrame = -1;
-	int hitLastFrame = -1;
-	int hitResumeFacing = -1;
-	int hitKnockbackRemainingX = 0;
-	int hitKnockbackDecayStep = 0;
-	bool hitSoundPlayed = false;
-	bool deathActive = false;
-	int deathFirstFrame = -1;
-	int deathLastFrame = -1;
-	int deathDamageType = 0;
-	bool allowInitialPursuitStep = true;
-	uint32 nextMovementTick = 0;
-	uint32 attackCooldownSeedTick = 0;
-};
-
-struct RoomNpcCombatState {
-	bool deathActive = false;
-	int deathFirstFrame = -1;
-	int deathLastFrame = -1;
-	int deathDamageType = 0;
-};
-
-struct RoomHitEffectState {
-	Common::String entityName;
-	Common::String followTargetName;
-	Common::Point anchorPoint;
-	float renderZ = 0.0f;
-	bool finished = false;
-};
-
-struct RoomCombatDamagePopupState {
-	Common::String followTargetName;
-	Common::Point anchorPoint;
-	uint32 startTick = 0;
-	int damageAmount = 0;
-};
-
-struct CombatLoadoutHudInfo {
-	uint iconIndex = 0;
-	int maxVisibleCount = 0;
-	const char *unitLabel = nullptr;
-};
-
-struct ScopedDeferredLiveNpcDeathTransitions {
-	ScopedDeferredLiveNpcDeathTransitions(Script *script) : _script(script) {
-		if (_script)
-			_script->pushDeferredLiveNpcDeathTransitions();
-	}
-
-	~ScopedDeferredLiveNpcDeathTransitions() {
-		if (_script)
-			_script->popDeferredLiveNpcDeathTransitions();
-	}
-
-	Script *_script;
-};
 
 static bool roomAllowsImmediateExitClick(const Common::String &roomName) {
 	return !roomName.equalsIgnoreCase("LAVAPIT") &&
@@ -416,90 +271,6 @@ static void drawInventoryWeekday(Graphics::Screen &screen, const Graphics::Font
 		font.getStringWidth(weekdayText), kNativeInventoryTooltipColor);
 }
 
-static bool resolveCombatLoadoutHudInfo(int loadout, CombatLoadoutHudInfo &info) {
-	switch (loadout) {
-	case 2:
-		info.iconIndex = 2;
-		info.maxVisibleCount = 16;
-		info.unitLabel = "nails";
-		return true;
-	case 3:
-		info.iconIndex = 1;
-		info.maxVisibleCount = 16;
-		info.unitLabel = "shells";
-		return true;
-	case 4:
-		info.iconIndex = 0;
-		info.maxVisibleCount = 8;
-		info.unitLabel = "bullets";
-		return true;
-	case 5:
-		info.iconIndex = 0;
-		info.maxVisibleCount = 6;
-		info.unitLabel = "bullets";
-		return true;
-	case 14:
-		info.iconIndex = 3;
-		info.maxVisibleCount = 16;
-		info.unitLabel = "gas";
-		return true;
-	default:
-		return false;
-	}
-}
-
-static Common::String resolveCombatLoadoutStatusLabel(Script &script, const ObjectRecord &object) {
-	Common::String label = script.resolveInventoryTooltipText(object);
-	if (!label.empty()) {
-		for (uint i = 0; i < label.size(); ++i) {
-			if (label[i] == '_')
-				label.setChar(' ', i);
-		}
-
-		uint trimAt = label.size();
-		for (uint i = 0; i < label.size(); ++i) {
-			if (label[i] == ',' || label[i] == ';') {
-				trimAt = i;
-				break;
-			}
-		}
-		label = label.substr(0, trimAt);
-		while (!label.empty() && label.lastChar() == ' ')
-			label.deleteLastChar();
-		if (!label.empty())
-			return label;
-	}
-
-	label = script.resolveObjectLabel(object);
-	if (!label.empty())
-		return label;
-
-	label = object.objectName;
-	for (uint i = 0; i < label.size(); ++i) {
-		if (label[i] == '_')
-			label.setChar(' ', i);
-	}
-
-	return label;
-}
-
-static Common::String buildCombatLoadoutStatusMessage(Script &script, const ObjectRecord &object,
-		int previousLoadout, int currentLoadout) {
-	const Common::String label = resolveCombatLoadoutStatusLabel(script, object);
-	if (label.empty())
-		return Common::String();
-
-	if (currentLoadout == 0 && previousLoadout != 0)
-		return Common::String::format("Disarming %s...", label.c_str());
-
-	CombatLoadoutHudInfo info;
-	if (resolveCombatLoadoutHudInfo(currentLoadout, info))
-		return Common::String::format("Arming %s, %d %s...", label.c_str(),
-			script.getPlayerCombatResourceCount(currentLoadout), info.unitLabel);
-
-	return Common::String::format("Arming %s...", label.c_str());
-}
-
 static void drawCombatLoadoutResourceIcons(Graphics::Screen &screen, const Art &art,
 		const Script &script, int loadout) {
 	CombatLoadoutHudInfo info;
@@ -558,326 +329,6 @@ static void setScaledRoomPalette(Graphics::Screen &screen, const byte *palette,
 	screen.setPalette(scaledPalette);
 }
 
-static RoomMonsterFacingAnimationRange resolveRoomMonsterFacingAnimationRange(int facing) {
-	switch (facing) {
-	case 0:
-		return RoomMonsterFacingAnimationRange(0x00, 0x09, 0x3b);
-	case 1:
-		return RoomMonsterFacingAnimationRange(0x0f, 0x18, 0x0e);
-	case 2:
-		return RoomMonsterFacingAnimationRange(0x2d, 0x36, 0x2c);
-	case 3:
-		return RoomMonsterFacingAnimationRange(0x1e, 0x27, 0x28);
-	default:
-		return RoomMonsterFacingAnimationRange(0x00, 0x09, 0x3b);
-	}
-}
-
-static bool setRoomMonsterAnimation(Entity &entity, int facing, bool walking) {
-	const RoomMonsterFacingAnimationRange range = resolveRoomMonsterFacingAnimationRange(facing);
-	if (walking) {
-		const bool changed = entity.getAnimationRate() != kRoomMonsterAnimationRate ||
-			entity.getCurrentFrame() < range.walkFirstFrame ||
-			entity.getCurrentFrame() > range.walkLastFrame;
-		entity.setAnimationRate(kRoomMonsterAnimationRate);
-		entity.setAnimationFrameRange(range.walkFirstFrame, range.walkLastFrame, true);
-		entity.setAnimationEnabled(true);
-		return changed;
-	}
-
-	const int standingFrame = entity.hasOpaqueFramesInRange(range.idleFrame, range.idleFrame)
-		? range.idleFrame
-		: range.walkFirstFrame;
-	const bool changed = entity.getAnimationRate() != 0 ||
-		entity.getCurrentFrame() != standingFrame;
-	entity.setAnimationRate(0);
-	entity.setAnimationFrameRange(standingFrame, standingFrame, false);
-	entity.setCurrentFrame(standingFrame);
-	entity.setAnimationEnabled(false);
-	return changed;
-}
-
-static bool runtimeEntityHasFrameRange(const Entity &entity, int firstFrame, int lastFrame) {
-	if (!entity.hasFrames() || firstFrame < 0 || lastFrame < firstFrame)
-		return false;
-
-	return (uint)lastFrame < entity.getFrameCount();
-}
-
-static bool runtimeEntityHasDrawableFrameRange(const Entity &entity, int firstFrame, int lastFrame) {
-	return runtimeEntityHasFrameRange(entity, firstFrame, lastFrame) &&
-		entity.hasOpaqueFramesInRange(firstFrame, lastFrame);
-}
-
-static bool resolveMonsterAttackAnimationRange(HarvesterEngine &engine,
-		const Entity &entity, int actorCenterX, int targetCenterX,
-		RoomAttackAnimationRange &range) {
-	// Native class-6 attackers pick one of the 0x16..0x1b attack states, which map to
-	// the 0x50..0x8b strike banks. The 0x8c..0x9d banks are hit reactions, not attacks.
-	static const RoomAttackAnimationRange kLeftAttackRanges[3] = {
-		RoomAttackAnimationRange(0x64, 0x6d, 1),
-		RoomAttackAnimationRange(0x5a, 0x63, 1),
-		RoomAttackAnimationRange(0x50, 0x59, 1)
-	};
-	static const RoomAttackAnimationRange kRightAttackRanges[3] = {
-		RoomAttackAnimationRange(0x82, 0x8b, 2),
-		RoomAttackAnimationRange(0x78, 0x81, 2),
-		RoomAttackAnimationRange(0x6e, 0x77, 2)
-	};
-
-	const RoomAttackAnimationRange *candidates =
-		targetCenterX < actorCenterX ? kLeftAttackRanges : kRightAttackRanges;
-	int availableCandidateIndices[3];
-	int availableCandidateCount = 0;
-	for (int i = 0; i < 3; ++i) {
-		if (!runtimeEntityHasDrawableFrameRange(entity, candidates[i].firstFrame, candidates[i].lastFrame))
-			continue;
-		availableCandidateIndices[availableCandidateCount++] = i;
-	}
-
-	if (availableCandidateCount == 0)
-		return false;
-
-	const int selectedIndex = availableCandidateIndices[
-		availableCandidateCount > 1 ? engine.getRandomNumber(availableCandidateCount - 1) : 0];
-	range = candidates[selectedIndex];
-	return true;
-}
-
-static bool resolveMonsterDeathAnimationRange(const Entity &entity, int facing,
-		int deathDamageType, bool goreEnabled, RoomDeathAnimationRange &range) {
-	const bool preferLeftBank = facing == 1;
-	const RoomDeathAnimationRange rightBludge(0xb0, 0xb9);
-	const RoomDeathAnimationRange leftBludge(0xba, 0xc3);
-	const RoomDeathAnimationRange rightSlash(0x100, 0x109);
-	const RoomDeathAnimationRange leftSlash(0x10a, 0x113);
-	const RoomDeathAnimationRange rightProjectile(0x114, 0x11d);
-	const RoomDeathAnimationRange leftProjectile(0x11e, 0x127);
-
-	const RoomDeathAnimationRange primaryBludge = preferLeftBank ? leftBludge : rightBludge;
-	const RoomDeathAnimationRange primarySlash = preferLeftBank ? leftSlash : rightSlash;
-	const RoomDeathAnimationRange primaryProjectile = preferLeftBank ? leftProjectile : rightProjectile;
-	const RoomDeathAnimationRange fallbackBludge = preferLeftBank ? rightBludge : leftBludge;
-	const RoomDeathAnimationRange fallbackSlash = preferLeftBank ? rightSlash : leftSlash;
-	const RoomDeathAnimationRange fallbackProjectile = preferLeftBank ? rightProjectile : leftProjectile;
-
-	auto chooseIfAvailable = [&](const RoomDeathAnimationRange &candidate) {
-		if (!runtimeEntityHasDrawableFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
-			return false;
-		range = candidate;
-		return true;
-	};
-
-	if ((!goreEnabled || deathDamageType == 1) &&
-			(chooseIfAvailable(primaryBludge) || chooseIfAvailable(fallbackBludge)))
-		return true;
-	if (deathDamageType == 2 &&
-			(chooseIfAvailable(primarySlash) || chooseIfAvailable(fallbackSlash)))
-		return true;
-	if (deathDamageType == 4 &&
-			(chooseIfAvailable(primaryProjectile) || chooseIfAvailable(fallbackProjectile)))
-		return true;
-
-	return chooseIfAvailable(primaryBludge) || chooseIfAvailable(primarySlash) ||
-		chooseIfAvailable(primaryProjectile) || chooseIfAvailable(fallbackBludge) ||
-		chooseIfAvailable(fallbackSlash) || chooseIfAvailable(fallbackProjectile);
-}
-
-static bool resolveNpcDeathAnimationRange(const Entity &entity, bool hasMonsterfyTarget,
-		int deathDamageType, bool goreEnabled, RoomDeathAnimationRange &range) {
-	const RoomDeathAnimationRange goreBank(0x3c, 0x45);
-	const RoomDeathAnimationRange bludgeBank(0x46, 0x4f);
-	const bool preferGoreBank = hasMonsterfyTarget || (goreEnabled && deathDamageType != 1);
-
-	auto chooseIfAvailable = [&](const RoomDeathAnimationRange &candidate) {
-		if (!runtimeEntityHasFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
-			return false;
-		range = candidate;
-		return true;
-	};
-
-	if (preferGoreBank && chooseIfAvailable(goreBank))
-		return true;
-	if (chooseIfAvailable(bludgeBank))
-		return true;
-
-	return chooseIfAvailable(goreBank);
-}
-
-static int stepTowardsRoomCombatInt(int current, int target, int step) {
-	if (step <= 0 || current == target)
-		return current;
-	if (current < target)
-		return MIN(current + step, target);
-	return MAX(current - step, target);
-}
-
-static int computeRectAxisGap(int minA, int maxA, int minB, int maxB) {
-	if (maxA <= minB)
-		return minB - maxA;
-	if (maxB <= minA)
-		return minA - maxB;
-	return 0;
-}
-
-static int computeRuntimeEntityHorizontalGap(const Entity &left, const Entity &right) {
-	const Common::Rect leftRect = left.getScreenRect();
-	const Common::Rect rightRect = right.getScreenRect();
-	return computeRectAxisGap(leftRect.left, leftRect.right, rightRect.left, rightRect.right);
-}
-
-static bool doRuntimeEntityDepthExtentsOverlap(const Entity &first, const Entity &second) {
-	const float firstZMin = first.getZ();
-	const float firstZMax = firstZMin + first.getZExtent();
-	const float secondZMin = second.getZ();
-	const float secondZMax = secondZMin + second.getZExtent();
-	return !(firstZMax < secondZMin || secondZMax < firstZMin);
-}
-
-static bool areCombatantsWithinRoomCombatReach(const RoomSetupState &state,
-		const Entity &attacker, float attackerZ,
-		const Entity &target, float targetZ, int engageDistance) {
-	if (attacker.overlapsEntity(target))
-		return true;
-
-	const float zDelta = attackerZ >= targetZ ? attackerZ - targetZ : targetZ - attackerZ;
-	if (zDelta > kNativeMonsterPursuitZTolerance)
-		return false;
-
-	const float nearZ = attackerZ < targetZ ? attackerZ : targetZ;
-	const int nativeTolerance = roundRoomCombatFloat(
-		Player::computeDepthScale(state, nearZ) * kNativeMonsterHorizontalWaypointTolerance);
-	return computeRuntimeEntityHorizontalGap(attacker, target) <= MAX(MAX(0, engageDistance), nativeTolerance);
-}
-
-// Native class-6 melee only arms after the chase path has already collapsed the
-// wider waypoint band down to a direct center-to-center engage-distance check.
-// The DOS runtime does not add a separate randomized monster attack cooldown;
-// cadence comes from the short attack banks themselves.
-static bool isWithinNativeMonsterAttackEntryRange(const Entity &monster, float monsterZ,
-		const Entity &player, float playerZ, int engageDistance) {
-	const float zDelta = monsterZ >= playerZ ? monsterZ - playerZ : playerZ - monsterZ;
-	if (zDelta > kNativeMonsterPursuitZTolerance)
-		return false;
-
-	const Common::Rect monsterRect = monster.getScreenRect();
-	const Common::Rect playerRect = player.getScreenRect();
-	const int monsterCenterX = monsterRect.left + monsterRect.width() / 2;
-	const int playerCenterX = playerRect.left + playerRect.width() / 2;
-	return ABS(playerCenterX - monsterCenterX) <= MAX(0, engageDistance);
-}
-
-static bool playRandomRoomAttackSound(HarvesterEngine &engine, const Common::String &sound1,
-		const Common::String &sound2, const Common::String &sound3) {
-	const Common::String *availableSounds[3];
-	uint availableCount = 0;
-	if (!sound1.empty())
-		availableSounds[availableCount++] = &sound1;
-	if (!sound2.empty())
-		availableSounds[availableCount++] = &sound2;
-	if (!sound3.empty())
-		availableSounds[availableCount++] = &sound3;
-	if (availableCount == 0)
-		return false;
-
-	const uint soundIndex = availableCount > 1
-		? engine.getRandomNumber(availableCount - 1)
-		: 0;
-	return engine.playSound(*availableSounds[soundIndex]);
-}
-
-static bool resolveRoomMonsterHitAnimationRange(const Entity &entity,
-		int attackerAttackFirstFrame, RoomHitAnimationRange &range) {
-	RoomHitAnimationRange candidate;
-	switch (attackerAttackFirstFrame) {
-	case 0x64:
-		candidate = RoomHitAnimationRange(0x92, 0x94, 2, -kNativeCombatHitKnockbackDistance);
-		break;
-	case 0x5a:
-		candidate = RoomHitAnimationRange(0x8f, 0x91, 2, -kNativeCombatHitKnockbackDistance);
-		break;
-	case 0x50:
-		candidate = RoomHitAnimationRange(0x8c, 0x8e, 2, -kNativeCombatHitKnockbackDistance);
-		break;
-	case 0x82:
-		candidate = RoomHitAnimationRange(0x9b, 0x9d, 1, kNativeCombatHitKnockbackDistance);
-		break;
-	case 0x78:
-		candidate = RoomHitAnimationRange(0x98, 0x9a, 1, kNativeCombatHitKnockbackDistance);
-		break;
-	case 0x6e:
-		candidate = RoomHitAnimationRange(0x95, 0x97, 1, kNativeCombatHitKnockbackDistance);
-		break;
-	default:
-		return false;
-	}
-
-	if (!runtimeEntityHasDrawableFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
-		return false;
-
-	range = candidate;
-	return true;
-}
-
-static float clampRoomDepthForEvent(const RoomSetupState &state, float z) {
-	return CLIP<float>(z,
-		(float)MIN(state.roomMinZ, state.roomMaxZ),
-		(float)MAX(state.roomMinZ, state.roomMaxZ));
-}
-
-static int roundRoomCombatFloat(float value) {
-	return value >= 0.0f ? (int)(value + 0.5f) : (int)(value - 0.5f);
-}
-
-static int mapRoomDepthToScreenYForCombat(const RoomSetupState &state, float z, int fallbackY) {
-	if (state.roomMaxZScreenY < 0 || state.roomMinZScreenY < state.roomMaxZScreenY)
-		return fallbackY;
-	if (state.roomMaxZ == state.roomMinZ)
-		return CLIP<int>(fallbackY, state.roomMaxZScreenY, state.roomMinZScreenY);
-
-	const float clampedZ = clampRoomDepthForEvent(state, z);
-	const float offset = ((float)state.roomMaxZ - clampedZ) *
-		(float)(state.roomMinZScreenY - state.roomMaxZScreenY) /
-		(float)(state.roomMaxZ - state.roomMinZ);
-	return CLIP<int>(state.roomMaxZScreenY + roundRoomCombatFloat(offset),
-		state.roomMaxZScreenY, state.roomMinZScreenY);
-}
-
-static int resolveMonsterAttackContactFrameOffset(const MonsterRecord &monster) {
-	return monster.monsterName.equalsIgnoreCase("MUCKEY")
-		? kMuckeyAttackContactFrameOffset
-		: kDefaultMonsterAttackContactFrameOffset;
-}
-
-static void clearRoomMonsterCombatState(RoomMonsterCombatState &combatState) {
-	combatState = RoomMonsterCombatState();
-}
-
-static void clearRoomNpcCombatState(RoomNpcCombatState &combatState) {
-	combatState = RoomNpcCombatState();
-}
-
-static void clearRoomMonsterAttackState(RoomMonsterCombatState &combatState) {
-	combatState.attackActive = false;
-	combatState.attackFirstFrame = -1;
-	combatState.attackLastFrame = -1;
-	combatState.attackContactFrame = -1;
-	combatState.attackResumeFacing = -1;
-	combatState.attackSoundPlayed = false;
-	combatState.attackContactResolved = false;
-	combatState.attackTargetName.clear();
-}
-
-static void clearRoomMonsterHitState(RoomMonsterCombatState &combatState) {
-	combatState.hitActive = false;
-	combatState.hitFirstFrame = -1;
-	combatState.hitLastFrame = -1;
-	combatState.hitResumeFacing = -1;
-	combatState.hitKnockbackRemainingX = 0;
-	combatState.hitKnockbackDecayStep = 0;
-	combatState.hitSoundPlayed = false;
-}
-
 RoomSystem::RoomSystem(HarvesterEngine &engine, Common::Point &mousePos,
 		InventorySystem &inventory)
 	: _engine(engine), _mousePos(mousePos), _inventory(inventory) {
@@ -2399,340 +1850,118 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 					? _engine.getScript()->getPlayerCurrentHitPoints()
 					: 0);
 		};
-		struct InteractionProcessor {
-			HarvesterEngine &engine;
-			Flow &flow;
-			RoomSceneResources &scene;
-			RoomPlayerState &playerState;
-			Common::String &pendingRegionName;
-			Common::String &pendingRoomChange;
-			bool &pendingRoomChangeIsRoomName;
-			bool &pendingRoomChangeUsesSavedRoomState;
-			SaveRoomState &pendingRoomChangeSavedRoomState;
-			const bool canExitCloseupToParent;
-			decltype(refreshCurrentScene) &refreshCurrentSceneFn;
-			decltype(applyCurrentRoomRuntimeMutationsInPlace) &applyCurrentRoomRuntimeMutationsInPlaceFn;
-			decltype(syncGlobalTimerEntities) &syncGlobalTimerEntitiesFn;
-			decltype(captureDialogueBackdrop) &captureDialogueBackdropFn;
-			decltype(showCdChangePrompt) &showCdChangePromptFn;
-			decltype(runRoomExitCommands) &runRoomExitCommandsFn;
-			decltype(applyLightingCommand) &applyLightingCommandFn;
-			decltype(applyPlayerGotoXZ) &applyPlayerGotoXZFn;
-			decltype(runModalShowText) &runModalShowTextFn;
-			decltype(resetIdleState) &resetIdleStateFn;
-			decltype(stopPlayerRegionInteraction) &stopPlayerRegionInteractionFn;
-			decltype(startPlayerDefeatSequence) &startPlayerDefeatSequenceFn;
-
-			Common::Error handleInteractionResult(const InteractionResult &interaction,
-					bool &didTransition, const Common::String &usedItemName) {
-				didTransition = false;
-
-				playerState.hasMoveTarget = false;
-				playerState.turnActive = false;
-				playerState.turnTargetFacing = -1;
-				pendingRegionName.clear();
-
-				if (interaction.requestMainMenu) {
-					engine.stopMusic();
-					engine.stopSound();
-					if (!interaction.deathFlicPath.empty()) {
-						FstPlayer fstPlayer(engine);
-						if (!fstPlayer.play(interaction.deathFlicPath))
-							return Common::kReadingFailed;
-					}
-
-					if (!interaction.deathFlicPath.empty())
-						flow.requestGameOverReturn();
-					else
-						flow.requestMainMenuReturn();
-					return Common::kNoError;
-				}
-
-				Common::String restoreMusicPath = engine.getMusicPath();
-				bool discChanged = false;
-				if (interaction.cdChangeDisc > 0) {
-					ResourceManager *resources = engine.getResources();
-					const int previousDisc = resources ? resources->getCurrentDisc() : 0;
-					Common::Error cdPromptError = showCdChangePromptFn(interaction.cdChangeDisc);
-					if (cdPromptError.getCode() != Common::kNoError)
-						return cdPromptError;
-					discChanged = resources && previousDisc > 0 &&
-						previousDisc != resources->getCurrentDisc();
-				}
-				if (!interaction.musicPath.empty()) {
-					(void)engine.playMusic(interaction.musicPath);
-					restoreMusicPath = engine.getMusicPath();
-				}
-				flow.executeStartupAudioCommands(interaction.audioCommands);
-				if (interaction.mutatedRuntimeState)
-					syncGlobalTimerEntitiesFn(interaction.previousTimerRecords);
-				auto queueImplicitRoomRestart = [&]() -> Common::Error {
-					pendingRoomChangeIsRoomName = false;
-					pendingRoomChangeUsesSavedRoomState = false;
-					pendingRoomChangeSavedRoomState.clear();
-					if (!interaction.roomRestartTargetName.empty()) {
-						pendingRoomChange = interaction.roomRestartTargetName;
-					} else if (discChanged && interaction.cdChangeDisc == 3) {
-						// Native disc-3 prompt reloads re-enter room_setup through the startup target
-						// buffer, which remains "START" during ordinary play and resolves to CD3 RECEPTION.
-						pendingRoomChange = "START";
-					} else if (!scene.state.entranceName.empty()) {
-						pendingRoomChange = scene.state.entranceName;
-					} else {
-						pendingRoomChange = scene.state.roomName;
-						pendingRoomChangeIsRoomName = true;
-					}
-					didTransition = !pendingRoomChange.empty();
-					debugC(1, kDebugRoom,
-						"Harvester: implicit room restart target='%s' explicit='%s' discChanged=%d cdChangeDisc=%d sceneEntrance='%s' sceneRoom='%s'",
-						pendingRoomChange.c_str(), interaction.roomRestartTargetName.c_str(),
-						discChanged ? 1 : 0, interaction.cdChangeDisc,
-						scene.state.entranceName.c_str(), scene.state.roomName.c_str());
-					return didTransition ? Common::kNoError : Common::kReadingFailed;
-				};
-				auto queueSavedRoomRestart = [&](const SaveRoomState &savedState) -> Common::Error {
-					pendingRoomChange.clear();
-					pendingRoomChangeIsRoomName = false;
-					pendingRoomChangeUsesSavedRoomState = false;
-					pendingRoomChangeSavedRoomState.clear();
-					pendingRoomChange = !savedState.entranceName.empty()
-						? savedState.entranceName
-						: savedState.roomName;
-					pendingRoomChangeIsRoomName = savedState.entranceName.empty();
-					pendingRoomChangeUsesSavedRoomState = !pendingRoomChange.empty();
-					pendingRoomChangeSavedRoomState = savedState;
-					debugC(1, kDebugRoom,
-						"Harvester: queued saved room restart target='%s' room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
-						pendingRoomChange.c_str(), savedState.roomName.c_str(),
-						savedState.entranceName.c_str(), savedState.playerX, savedState.playerY,
-						savedState.playerZ, savedState.playerFacing, savedState.musicPath.c_str());
-					didTransition = !pendingRoomChange.empty();
-					return didTransition ? Common::kNoError : Common::kReadingFailed;
-				};
-				if (interaction.requestRoomRestart) {
-					return queueImplicitRoomRestart();
-				}
-				if (interaction.requestCloseupExit) {
-					if (!canExitCloseupToParent) {
-						debugC(1, kDebugRoom,
-							"Harvester: EXIT_CLOSEUP ignored for room='%s' without parent room loop",
-							scene.state.roomName.c_str());
-						return Common::kNoError;
-					}
-
-					pendingRoomChange = kExitCloseupPendingRoomChange;
-					didTransition = true;
-					return Common::kNoError;
-				}
-
-				StartupRoomTransitionKind roomTransition = interaction.roomTransition;
-				if (roomTransition == kStartupRoomTransitionNone && !interaction.nextRoomName.empty())
-					roomTransition = kStartupRoomTransitionCloseup;
-
-				const bool needsDisc3RoomReload =
-					discChanged &&
-					interaction.cdChangeDisc == 3 &&
-					!interaction.requestRoomRestart &&
-					interaction.nextRoomName.empty() &&
-					!interaction.mutatedRuntimeState;
-				if (needsDisc3RoomReload) {
-					return queueImplicitRoomRestart();
-				}
-
-				if (!interaction.nextRoomName.empty() &&
-						roomTransition == kStartupRoomTransitionChangeRoom) {
-					Common::String resolvedTransitionTarget = interaction.nextRoomName;
-					Common::Error resolveError = flow.resolveRoomTransitionTarget(
-						interaction.nextRoomName, resolvedTransitionTarget);
-					if (resolveError.getCode() != Common::kNoError)
-						return resolveError;
-					if (resolvedTransitionTarget.empty())
-						return Common::kNoError;
-
-					Common::Error exitError = runRoomExitCommandsFn();
-					if (exitError.getCode() != Common::kNoError)
-						return exitError;
-
-					// Native CHANGE_ROOM queues a room handoff for the live loop instead of nesting.
-					pendingRoomChange = resolvedTransitionTarget;
-					didTransition = true;
-				} else if (!interaction.nextRoomName.empty()) {
-					Common::Error exitError = runRoomExitCommandsFn();
-					if (exitError.getCode() != Common::kNoError)
-						return exitError;
-
-					SaveRoomState parentRoomState;
-					const bool hasParentRoomState = engine.hasCurrentSaveRoomState();
-					if (hasParentRoomState)
-						parentRoomState = engine.getCurrentSaveRoomState();
-					Common::Error roomError = flow.runRoomLoop(interaction.nextRoomName);
-					if (flow.hasPendingMainMenuReturn())
-						return Common::kNoError;
-					if (flow.takePendingCloseupParentRestart()) {
-						if (hasParentRoomState && parentRoomState.valid)
-							return queueSavedRoomRestart(parentRoomState);
-						return queueImplicitRoomRestart();
-					}
-					if (flow.hasPendingDebugRoomChange()) {
-						didTransition = true;
-						return Common::kNoError;
-					}
-					if (roomError.getCode() != Common::kReadingFailed &&
-						roomError.getCode() != Common::kNoError) {
-						return roomError;
-					}
-
-					if (!refreshCurrentSceneFn(true))
-						return Common::kReadingFailed;
-
-					flow.executeStartupAudioCommands(scene.state.audioCommands);
-					if (!restoreMusicPath.empty())
-						(void)engine.playMusic(restoreMusicPath);
-					else
-						engine.stopMusic();
-					didTransition = true;
-				} else if (interaction.visualRuntimeStateChanged) {
-					if (!applyCurrentRoomRuntimeMutationsInPlaceFn(true) &&
-							!refreshCurrentSceneFn(true)) {
-						return Common::kReadingFailed;
-					}
-				}
-
-				if (didTransition)
-					return Common::kNoError;
-
-				if (interaction.requestPlayerDeath &&
-						engine.getScript() &&
-						engine.getScript()->getPlayerCurrentHitPoints() <= 0) {
-					stopPlayerRegionInteractionFn();
-					startPlayerDefeatSequenceFn("script_kill_pc", Common::String(),
-						interaction.playerDeathDamageType);
-				}
+		typedef decltype(refreshCurrentScene) RefreshCurrentSceneFn;
+		typedef decltype(applyCurrentRoomRuntimeMutationsInPlace) ApplyCurrentRoomRuntimeMutationsInPlaceFn;
+		typedef decltype(syncGlobalTimerEntities) SyncGlobalTimerEntitiesFn;
+		typedef decltype(captureDialogueBackdrop) CaptureDialogueBackdropFn;
+		typedef decltype(showCdChangePrompt) ShowCdChangePromptFn;
+		typedef decltype(runRoomExitCommands) RunRoomExitCommandsFn;
+		typedef decltype(applyLightingCommand) ApplyLightingCommandFn;
+		typedef decltype(applyPlayerGotoXZ) ApplyPlayerGotoXZFn;
+		typedef decltype(runModalShowText) RunModalShowTextFn;
+		typedef decltype(resetIdleState) ResetIdleStateFn;
+		typedef decltype(stopPlayerRegionInteraction) StopPlayerRegionInteractionFn;
+		typedef decltype(startPlayerDefeatSequence) StartPlayerDefeatSequenceFn;
 
-				if (interaction.requestPlayerGotoXZ)
-					applyPlayerGotoXZFn(interaction.playerGotoX, interaction.playerGotoZ);
+		struct RoomInteractionCallbacksImpl : RoomInteractionCallbacks {
+			RoomInteractionCallbacksImpl(
+					RefreshCurrentSceneFn &refreshCurrentSceneFn_,
+					ApplyCurrentRoomRuntimeMutationsInPlaceFn &applyCurrentRoomRuntimeMutationsInPlaceFn_,
+					SyncGlobalTimerEntitiesFn &syncGlobalTimerEntitiesFn_,
+					CaptureDialogueBackdropFn &captureDialogueBackdropFn_,
+					ShowCdChangePromptFn &showCdChangePromptFn_,
+					RunRoomExitCommandsFn &runRoomExitCommandsFn_,
+					ApplyLightingCommandFn &applyLightingCommandFn_,
+					ApplyPlayerGotoXZFn &applyPlayerGotoXZFn_,
+					RunModalShowTextFn &runModalShowTextFn_,
+					ResetIdleStateFn &resetIdleStateFn_,
+					StopPlayerRegionInteractionFn &stopPlayerRegionInteractionFn_,
+					StartPlayerDefeatSequenceFn &startPlayerDefeatSequenceFn_)
+				: refreshCurrentSceneFn(refreshCurrentSceneFn_),
+				  applyCurrentRoomRuntimeMutationsInPlaceFn(applyCurrentRoomRuntimeMutationsInPlaceFn_),
+				  syncGlobalTimerEntitiesFn(syncGlobalTimerEntitiesFn_),
+				  captureDialogueBackdropFn(captureDialogueBackdropFn_),
+				  showCdChangePromptFn(showCdChangePromptFn_),
+				  runRoomExitCommandsFn(runRoomExitCommandsFn_),
+				  applyLightingCommandFn(applyLightingCommandFn_),
+				  applyPlayerGotoXZFn(applyPlayerGotoXZFn_),
+				  runModalShowTextFn(runModalShowTextFn_),
+				  resetIdleStateFn(resetIdleStateFn_),
+				  stopPlayerRegionInteractionFn(stopPlayerRegionInteractionFn_),
+				  startPlayerDefeatSequenceFn(startPlayerDefeatSequenceFn_) {
+			}
 
-				if (interaction.lightingCommand != kStartupLightingCommandNone) {
-					Common::Error lightingError = applyLightingCommandFn(interaction.lightingCommand);
-					if (lightingError.getCode() != Common::kNoError)
-						return lightingError;
-				}
+			bool refreshCurrentScene(bool preservePlayerPlacement) override {
+				return refreshCurrentSceneFn(preservePlayerPlacement);
+			}
 
-				if (!interaction.cutscenePath.empty()) {
-					FstPlayer fstPlayer(engine);
-					if (!fstPlayer.play(interaction.cutscenePath))
-						return Common::kReadingFailed;
-				}
+			bool applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteState) override {
+				return applyCurrentRoomRuntimeMutationsInPlaceFn(preservePaletteState);
+			}
 
-				if (!interaction.modalText.value.empty()) {
-					Common::Error modalError = runModalShowTextFn(interaction.modalText);
-					if (modalError.getCode() != Common::kNoError)
-						return modalError;
-				}
+			void syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords) override {
+				syncGlobalTimerEntitiesFn(previousTimerRecords);
+			}
 
-				if (!interaction.dialogueNpcName.empty()) {
-					Common::Error dialogueError = runScriptedDialogue(
-						interaction.dialogueNpcName, usedItemName, interaction.dialogueContinuationTag,
-						didTransition);
-					if (dialogueError.getCode() != Common::kNoError)
-						return dialogueError;
-				}
+			bool captureDialogueBackdrop(IndexedBitmap &dialogueBackdrop) override {
+				return captureDialogueBackdropFn(dialogueBackdrop);
+			}
 
-				if (didTransition)
-					return Common::kNoError;
+			Common::Error showCdChangePrompt(int discNumber) override {
+				return showCdChangePromptFn(discNumber);
+			}
 
-				if (!interaction.continuationTag.empty()) {
-					InteractionResult continuationInteraction;
-					if (engine.getScript()->executeActionTag(
-							interaction.continuationTag, continuationInteraction, true,
-							scene.state.roomName)) {
-						Common::Error interactionError =
-							handleInteractionResult(continuationInteraction, didTransition, usedItemName);
-						if (interactionError.getCode() != Common::kNoError)
-							return interactionError;
-					}
-				}
+			Common::Error runRoomExitCommands() override {
+				return runRoomExitCommandsFn();
+			}
 
-				return Common::kNoError;
+			Common::Error applyLightingCommand(StartupLightingCommand lightingCommand) override {
+				return applyLightingCommandFn(lightingCommand);
 			}
 
-			Common::Error runScriptedDialogue(const Common::String &npcName, const Common::String &usedItemName,
-					const Common::String &continuationTag, bool &didTransition) {
-				didTransition = false;
+			void applyPlayerGotoXZ(int x, int z) override {
+				applyPlayerGotoXZFn(x, z);
+			}
 
-				const NpcRecord *dialogueNpc = engine.getScript()->findRuntimeNpcRecord(npcName);
-				if (dialogueNpc) {
-					IndexedBitmap dialogueBackdrop;
-					if (!captureDialogueBackdropFn(dialogueBackdrop))
-						return Common::kReadingFailed;
+			Common::Error runModalShowText(const ResolvedText &modalText) override {
+				return runModalShowTextFn(modalText);
+			}
 
-					Common::Error dialogueError = flow.runRoomNpcDialogue(
-						dialogueBackdrop, scene.palette, scene.targetPaletteBrightness, *dialogueNpc,
-						usedItemName);
-					if (dialogueError.getCode() != Common::kNoError)
-						return dialogueError;
-				} else {
-					warning("Harvester: unresolved startup dialogue npc '%s' while processing room dialogue",
-						npcName.c_str());
-				}
+			void resetIdleState() override {
+				resetIdleStateFn();
+			}
 
-				InteractionResult dialogueInteraction;
-				bool abortRemainingCommandChain = false;
-				if (flow.takeQueuedDialogueInteraction(dialogueInteraction)) {
-					abortRemainingCommandChain = dialogueInteraction.abortRemainingCommandChain;
-					Common::Error interactionError =
-						handleInteractionResult(dialogueInteraction, didTransition, usedItemName);
-					if (interactionError.getCode() != Common::kNoError)
-						return interactionError;
-					if (flow.hasPendingMainMenuReturn())
-						return Common::kNoError;
-				}
-				if (!didTransition && !abortRemainingCommandChain && !continuationTag.empty()) {
-					InteractionResult continuationInteraction;
-					if (engine.getScript()->executeActionTag(
-							continuationTag, continuationInteraction, true, scene.state.roomName)) {
-						Common::Error interactionError =
-							handleInteractionResult(continuationInteraction, didTransition, usedItemName);
-						if (interactionError.getCode() != Common::kNoError)
-							return interactionError;
-						if (flow.hasPendingMainMenuReturn())
-							return Common::kNoError;
-					}
-				}
+			void stopPlayerRegionInteraction() override {
+				stopPlayerRegionInteractionFn();
+			}
 
-				flow.resetCursorAnimationSequence();
-				resetIdleStateFn();
-				return Common::kNoError;
+			void startPlayerDefeatSequence(const char *reason, const Common::String &sourceName,
+					int damageType) override {
+				startPlayerDefeatSequenceFn(reason, sourceName, damageType);
 			}
+
+			RefreshCurrentSceneFn &refreshCurrentSceneFn;
+			ApplyCurrentRoomRuntimeMutationsInPlaceFn &applyCurrentRoomRuntimeMutationsInPlaceFn;
+			SyncGlobalTimerEntitiesFn &syncGlobalTimerEntitiesFn;
+			CaptureDialogueBackdropFn &captureDialogueBackdropFn;
+			ShowCdChangePromptFn &showCdChangePromptFn;
+			RunRoomExitCommandsFn &runRoomExitCommandsFn;
+			ApplyLightingCommandFn &applyLightingCommandFn;
+			ApplyPlayerGotoXZFn &applyPlayerGotoXZFn;
+			RunModalShowTextFn &runModalShowTextFn;
+			ResetIdleStateFn &resetIdleStateFn;
+			StopPlayerRegionInteractionFn &stopPlayerRegionInteractionFn;
+			StartPlayerDefeatSequenceFn &startPlayerDefeatSequenceFn;
 		};
-		InteractionProcessor interactionProcessor = {
-			_engine, flow, scene, playerState, pendingRegionName, pendingRoomChange,
-			pendingRoomChangeIsRoomName, pendingRoomChangeUsesSavedRoomState,
-			pendingRoomChangeSavedRoomState,
-			canExitCloseupToParent,
+		RoomInteractionCallbacksImpl interactionCallbacks(
 			refreshCurrentScene, applyCurrentRoomRuntimeMutationsInPlace,
 			syncGlobalTimerEntities, captureDialogueBackdrop, showCdChangePrompt,
 			runRoomExitCommands, applyLightingCommand, applyPlayerGotoXZ, runModalShowText,
-			resetIdleState, stopPlayerRegionInteraction, startPlayerDefeatSequence
-		};
-		auto hasRoomEntryInteraction = [](const InteractionResult &interaction) {
-			return !interaction.nextRoomName.empty() ||
-				!interaction.musicPath.empty() ||
-				!interaction.audioCommands.empty() ||
-				interaction.cdChangeDisc > 0 ||
-				!interaction.cutscenePath.empty() ||
-				!interaction.deathFlicPath.empty() ||
-				interaction.requestMainMenu ||
-				!interaction.dialogueNpcName.empty() ||
-				!interaction.dialogueContinuationTag.empty() ||
-				!interaction.continuationTag.empty() ||
-				!interaction.modalText.value.empty() ||
-				interaction.lightingCommand != kStartupLightingCommandNone ||
-				interaction.requestPlayerGotoXZ ||
-				interaction.requestPlayerDeath ||
-				interaction.requestCloseupExit ||
-				interaction.mutatedRuntimeState;
-		};
+			resetIdleState, stopPlayerRegionInteraction, startPlayerDefeatSequence);
+		RoomInteractionProcessor interactionProcessor(
+			_engine, flow, scene, playerState, pendingRegionName, pendingRoomChange,
+			pendingRoomChangeIsRoomName, pendingRoomChangeUsesSavedRoomState,
+			pendingRoomChangeSavedRoomState, canExitCloseupToParent, interactionCallbacks);
 		InteractionResult roomEntryInteraction;
 		bool hasPendingRoomEntryInteraction = false;
 		if (shouldRunRoomEntryCommands) {
diff --git a/engines/harvester/room_combat.cpp b/engines/harvester/room_combat.cpp
new file mode 100644
index 00000000000..87adaa2ac47
--- /dev/null
+++ b/engines/harvester/room_combat.cpp
@@ -0,0 +1,482 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/room_combat.h"
+
+#include "common/util.h"
+#include "harvester/harvester.h"
+#include "harvester/player.h"
+#include "harvester/runtime_entity.h"
+
+namespace Harvester {
+
+const int kRoomMonsterAnimationRate = 17;
+const int kNativeMonsterAttackAnimationRate = 4;
+const int kNativeMonsterHitAnimationRate = 5;
+const uint32 kNativeMonsterAttackCooldownBaseTicks = 50;
+const float kNativeNpcMonsterZExtent = 5.0f;
+const int kRoomNpcAmbientLastFrame = 0x3b;
+const int kDefaultMonsterAttackContactFrameOffset = 2;
+const int kMuckeyAttackContactFrameOffset = 7;
+const float kNativeMonsterPursuitZTolerance = 2.0f;
+const float kNativeMonsterHorizontalWaypointTolerance = 50.0f;
+const uint32 kNativeKeyboardAttackRepeatTicks = 25;
+const int kNativeAttackSideWindow = 30;
+const int kNativeCombatHitKnockbackDistance = 18;
+const int kNativeCombatHitKnockbackDecayStep = 3;
+const char *const kNativeHitEffectResourcePath = "blood.abm";
+const int kNativeHitEffectAnimationRate = 10;
+const float kNativeHitEffectRenderZBias = 0.01f;
+const uint32 kCombatDebugDamagePopupDurationMs = 750;
+const int kCombatDebugDamagePopupRisePixels = 18;
+const int kCombatDebugDamagePopupVerticalGap = 6;
+const char *const kInnerSanctumRoomName = "INNERSANCTUM";
+const char *const kHerrillLogNpcName = "HERRILL_LOG";
+const char *const kMuckeyMonsterName = "MUCKEY";
+const char *const kActivateInnerSanctumDoorActionTag = "ACTV_INNERSAN_DOOR";
+const int kNativeDefaultNpcDeathDamageType = 1;
+
+namespace {
+
+struct RoomMonsterFacingAnimationRange {
+	RoomMonsterFacingAnimationRange() {}
+	RoomMonsterFacingAnimationRange(int walkFirstFrame, int walkLastFrame, int idleFrame)
+		: walkFirstFrame(walkFirstFrame), walkLastFrame(walkLastFrame), idleFrame(idleFrame) {}
+
+	int walkFirstFrame = 0;
+	int walkLastFrame = 0;
+	int idleFrame = 0;
+};
+
+Common::String resolveCombatLoadoutStatusLabel(Script &script, const ObjectRecord &object) {
+	Common::String label = script.resolveInventoryTooltipText(object);
+	if (!label.empty()) {
+		for (uint i = 0; i < label.size(); ++i) {
+			if (label[i] == '_')
+				label.setChar(' ', i);
+		}
+
+		uint trimAt = label.size();
+		for (uint i = 0; i < label.size(); ++i) {
+			if (label[i] == ',' || label[i] == ';') {
+				trimAt = i;
+				break;
+			}
+		}
+		label = label.substr(0, trimAt);
+		while (!label.empty() && label.lastChar() == ' ')
+			label.deleteLastChar();
+		if (!label.empty())
+			return label;
+	}
+
+	label = script.resolveObjectLabel(object);
+	if (!label.empty())
+		return label;
+
+	label = object.objectName;
+	for (uint i = 0; i < label.size(); ++i) {
+		if (label[i] == '_')
+			label.setChar(' ', i);
+	}
+
+	return label;
+}
+
+RoomMonsterFacingAnimationRange resolveRoomMonsterFacingAnimationRange(int facing) {
+	switch (facing) {
+	case 0:
+		return RoomMonsterFacingAnimationRange(0x00, 0x09, 0x3b);
+	case 1:
+		return RoomMonsterFacingAnimationRange(0x0f, 0x18, 0x0e);
+	case 2:
+		return RoomMonsterFacingAnimationRange(0x2d, 0x36, 0x2c);
+	case 3:
+		return RoomMonsterFacingAnimationRange(0x1e, 0x27, 0x28);
+	default:
+		return RoomMonsterFacingAnimationRange(0x00, 0x09, 0x3b);
+	}
+}
+
+int computeRectAxisGap(int minA, int maxA, int minB, int maxB) {
+	if (maxA <= minB)
+		return minB - maxA;
+	if (maxB <= minA)
+		return minA - maxB;
+	return 0;
+}
+
+} // End of anonymous namespace
+
+float clampRoomDepthForEvent(const RoomSetupState &state, float z) {
+	return CLIP<float>(z,
+		(float)MIN(state.roomMinZ, state.roomMaxZ),
+		(float)MAX(state.roomMinZ, state.roomMaxZ));
+}
+
+bool shouldRetainNpcDeathEntityInCurrentRoom(const NpcRecord &npc) {
+	return npc.monsterfyTargetName.empty();
+}
+
+bool isRetainedCurrentRoomNpcDeathRecord(const NpcRecord &npc) {
+	return npc.deathOrMonsterfyFlag &&
+		npc.monsterfyTargetName.empty() &&
+		npc.deathDamageType != 0 &&
+		npc.runtimeState >= 0;
+}
+
+bool resolveCombatLoadoutHudInfo(int loadout, CombatLoadoutHudInfo &info) {
+	switch (loadout) {
+	case 2:
+		info.iconIndex = 2;
+		info.maxVisibleCount = 16;
+		info.unitLabel = "nails";
+		return true;
+	case 3:
+		info.iconIndex = 1;
+		info.maxVisibleCount = 16;
+		info.unitLabel = "shells";
+		return true;
+	case 4:
+		info.iconIndex = 0;
+		info.maxVisibleCount = 8;
+		info.unitLabel = "bullets";
+		return true;
+	case 5:
+		info.iconIndex = 0;
+		info.maxVisibleCount = 6;
+		info.unitLabel = "bullets";
+		return true;
+	case 14:
+		info.iconIndex = 3;
+		info.maxVisibleCount = 16;
+		info.unitLabel = "gas";
+		return true;
+	default:
+		return false;
+	}
+}
+
+Common::String buildCombatLoadoutStatusMessage(Script &script, const ObjectRecord &object,
+		int previousLoadout, int currentLoadout) {
+	const Common::String label = resolveCombatLoadoutStatusLabel(script, object);
+	if (label.empty())
+		return Common::String();
+
+	if (currentLoadout == 0 && previousLoadout != 0)
+		return Common::String::format("Disarming %s...", label.c_str());
+
+	CombatLoadoutHudInfo info;
+	if (resolveCombatLoadoutHudInfo(currentLoadout, info))
+		return Common::String::format("Arming %s, %d %s...", label.c_str(),
+			script.getPlayerCombatResourceCount(currentLoadout), info.unitLabel);
+
+	return Common::String::format("Arming %s...", label.c_str());
+}
+
+bool setRoomMonsterAnimation(Entity &entity, int facing, bool walking) {
+	const RoomMonsterFacingAnimationRange range = resolveRoomMonsterFacingAnimationRange(facing);
+	if (walking) {
+		const bool changed = entity.getAnimationRate() != kRoomMonsterAnimationRate ||
+			entity.getCurrentFrame() < range.walkFirstFrame ||
+			entity.getCurrentFrame() > range.walkLastFrame;
+		entity.setAnimationRate(kRoomMonsterAnimationRate);
+		entity.setAnimationFrameRange(range.walkFirstFrame, range.walkLastFrame, true);
+		entity.setAnimationEnabled(true);
+		return changed;
+	}
+
+	const int standingFrame = entity.hasOpaqueFramesInRange(range.idleFrame, range.idleFrame)
+		? range.idleFrame
+		: range.walkFirstFrame;
+	const bool changed = entity.getAnimationRate() != 0 ||
+		entity.getCurrentFrame() != standingFrame;
+	entity.setAnimationRate(0);
+	entity.setAnimationFrameRange(standingFrame, standingFrame, false);
+	entity.setCurrentFrame(standingFrame);
+	entity.setAnimationEnabled(false);
+	return changed;
+}
+
+bool runtimeEntityHasFrameRange(const Entity &entity, int firstFrame, int lastFrame) {
+	if (!entity.hasFrames() || firstFrame < 0 || lastFrame < firstFrame)
+		return false;
+
+	return (uint)lastFrame < entity.getFrameCount();
+}
+
+bool runtimeEntityHasDrawableFrameRange(const Entity &entity, int firstFrame, int lastFrame) {
+	return runtimeEntityHasFrameRange(entity, firstFrame, lastFrame) &&
+		entity.hasOpaqueFramesInRange(firstFrame, lastFrame);
+}
+
+bool resolveMonsterAttackAnimationRange(HarvesterEngine &engine,
+		const Entity &entity, int actorCenterX, int targetCenterX,
+		RoomAttackAnimationRange &range) {
+	// Native class-6 attackers pick one of the 0x16..0x1b attack states, which map to
+	// the 0x50..0x8b strike banks. The 0x8c..0x9d banks are hit reactions, not attacks.
+	static const RoomAttackAnimationRange kLeftAttackRanges[3] = {
+		RoomAttackAnimationRange(0x64, 0x6d, 1),
+		RoomAttackAnimationRange(0x5a, 0x63, 1),
+		RoomAttackAnimationRange(0x50, 0x59, 1)
+	};
+	static const RoomAttackAnimationRange kRightAttackRanges[3] = {
+		RoomAttackAnimationRange(0x82, 0x8b, 2),
+		RoomAttackAnimationRange(0x78, 0x81, 2),
+		RoomAttackAnimationRange(0x6e, 0x77, 2)
+	};
+
+	const RoomAttackAnimationRange *candidates =
+		targetCenterX < actorCenterX ? kLeftAttackRanges : kRightAttackRanges;
+	int availableCandidateIndices[3];
+	int availableCandidateCount = 0;
+	for (int i = 0; i < 3; ++i) {
+		if (!runtimeEntityHasDrawableFrameRange(entity, candidates[i].firstFrame, candidates[i].lastFrame))
+			continue;
+		availableCandidateIndices[availableCandidateCount++] = i;
+	}
+
+	if (availableCandidateCount == 0)
+		return false;
+
+	const int selectedIndex = availableCandidateIndices[
+		availableCandidateCount > 1 ? engine.getRandomNumber(availableCandidateCount - 1) : 0];
+	range = candidates[selectedIndex];
+	return true;
+}
+
+bool resolveMonsterDeathAnimationRange(const Entity &entity, int facing,
+		int deathDamageType, bool goreEnabled, RoomDeathAnimationRange &range) {
+	const bool preferLeftBank = facing == 1;
+	const RoomDeathAnimationRange rightBludge(0xb0, 0xb9);
+	const RoomDeathAnimationRange leftBludge(0xba, 0xc3);
+	const RoomDeathAnimationRange rightSlash(0x100, 0x109);
+	const RoomDeathAnimationRange leftSlash(0x10a, 0x113);
+	const RoomDeathAnimationRange rightProjectile(0x114, 0x11d);
+	const RoomDeathAnimationRange leftProjectile(0x11e, 0x127);
+
+	const RoomDeathAnimationRange primaryBludge = preferLeftBank ? leftBludge : rightBludge;
+	const RoomDeathAnimationRange primarySlash = preferLeftBank ? leftSlash : rightSlash;
+	const RoomDeathAnimationRange primaryProjectile = preferLeftBank ? leftProjectile : rightProjectile;
+	const RoomDeathAnimationRange fallbackBludge = preferLeftBank ? rightBludge : leftBludge;
+	const RoomDeathAnimationRange fallbackSlash = preferLeftBank ? rightSlash : leftSlash;
+	const RoomDeathAnimationRange fallbackProjectile = preferLeftBank ? rightProjectile : leftProjectile;
+
+	auto chooseIfAvailable = [&](const RoomDeathAnimationRange &candidate) {
+		if (!runtimeEntityHasDrawableFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
+			return false;
+		range = candidate;
+		return true;
+	};
+
+	if ((!goreEnabled || deathDamageType == 1) &&
+			(chooseIfAvailable(primaryBludge) || chooseIfAvailable(fallbackBludge)))
+		return true;
+	if (deathDamageType == 2 &&
+			(chooseIfAvailable(primarySlash) || chooseIfAvailable(fallbackSlash)))
+		return true;
+	if (deathDamageType == 4 &&
+			(chooseIfAvailable(primaryProjectile) || chooseIfAvailable(fallbackProjectile)))
+		return true;
+
+	return chooseIfAvailable(primaryBludge) || chooseIfAvailable(primarySlash) ||
+		chooseIfAvailable(primaryProjectile) || chooseIfAvailable(fallbackBludge) ||
+		chooseIfAvailable(fallbackSlash) || chooseIfAvailable(fallbackProjectile);
+}
+
+bool resolveNpcDeathAnimationRange(const Entity &entity, bool hasMonsterfyTarget,
+		int deathDamageType, bool goreEnabled, RoomDeathAnimationRange &range) {
+	const RoomDeathAnimationRange goreBank(0x3c, 0x45);
+	const RoomDeathAnimationRange bludgeBank(0x46, 0x4f);
+	const bool preferGoreBank = hasMonsterfyTarget || (goreEnabled && deathDamageType != 1);
+
+	auto chooseIfAvailable = [&](const RoomDeathAnimationRange &candidate) {
+		if (!runtimeEntityHasFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
+			return false;
+		range = candidate;
+		return true;
+	};
+
+	if (preferGoreBank && chooseIfAvailable(goreBank))
+		return true;
+	if (chooseIfAvailable(bludgeBank))
+		return true;
+
+	return chooseIfAvailable(goreBank);
+}
+
+int stepTowardsRoomCombatInt(int current, int target, int step) {
+	if (step <= 0 || current == target)
+		return current;
+	if (current < target)
+		return MIN(current + step, target);
+	return MAX(current - step, target);
+}
+
+int computeRuntimeEntityHorizontalGap(const Entity &left, const Entity &right) {
+	const Common::Rect leftRect = left.getScreenRect();
+	const Common::Rect rightRect = right.getScreenRect();
+	return computeRectAxisGap(leftRect.left, leftRect.right, rightRect.left, rightRect.right);
+}
+
+bool doRuntimeEntityDepthExtentsOverlap(const Entity &first, const Entity &second) {
+	const float firstZMin = first.getZ();
+	const float firstZMax = firstZMin + first.getZExtent();
+	const float secondZMin = second.getZ();
+	const float secondZMax = secondZMin + second.getZExtent();
+	return !(firstZMax < secondZMin || secondZMax < firstZMin);
+}
+
+bool areCombatantsWithinRoomCombatReach(const RoomSetupState &state,
+		const Entity &attacker, float attackerZ,
+		const Entity &target, float targetZ, int engageDistance) {
+	if (attacker.overlapsEntity(target))
+		return true;
+
+	const float zDelta = attackerZ >= targetZ ? attackerZ - targetZ : targetZ - attackerZ;
+	if (zDelta > kNativeMonsterPursuitZTolerance)
+		return false;
+
+	const float nearZ = attackerZ < targetZ ? attackerZ : targetZ;
+	const int nativeTolerance = roundRoomCombatFloat(
+		Player::computeDepthScale(state, nearZ) * kNativeMonsterHorizontalWaypointTolerance);
+	return computeRuntimeEntityHorizontalGap(attacker, target) <= MAX(MAX(0, engageDistance), nativeTolerance);
+}
+
+bool isWithinNativeMonsterAttackEntryRange(const Entity &monster, float monsterZ,
+		const Entity &player, float playerZ, int engageDistance) {
+	const float zDelta = monsterZ >= playerZ ? monsterZ - playerZ : playerZ - monsterZ;
+	if (zDelta > kNativeMonsterPursuitZTolerance)
+		return false;
+
+	const Common::Rect monsterRect = monster.getScreenRect();
+	const Common::Rect playerRect = player.getScreenRect();
+	const int monsterCenterX = monsterRect.left + monsterRect.width() / 2;
+	const int playerCenterX = playerRect.left + playerRect.width() / 2;
+	return ABS(playerCenterX - monsterCenterX) <= MAX(0, engageDistance);
+}
+
+bool playRandomRoomAttackSound(HarvesterEngine &engine, const Common::String &sound1,
+		const Common::String &sound2, const Common::String &sound3) {
+	const Common::String *availableSounds[3];
+	uint availableCount = 0;
+	if (!sound1.empty())
+		availableSounds[availableCount++] = &sound1;
+	if (!sound2.empty())
+		availableSounds[availableCount++] = &sound2;
+	if (!sound3.empty())
+		availableSounds[availableCount++] = &sound3;
+	if (availableCount == 0)
+		return false;
+
+	const uint soundIndex = availableCount > 1
+		? engine.getRandomNumber(availableCount - 1)
+		: 0;
+	return engine.playSound(*availableSounds[soundIndex]);
+}
+
+bool resolveRoomMonsterHitAnimationRange(const Entity &entity,
+		int attackerAttackFirstFrame, RoomHitAnimationRange &range) {
+	RoomHitAnimationRange candidate;
+	switch (attackerAttackFirstFrame) {
+	case 0x64:
+		candidate = RoomHitAnimationRange(0x92, 0x94, 2, -kNativeCombatHitKnockbackDistance);
+		break;
+	case 0x5a:
+		candidate = RoomHitAnimationRange(0x8f, 0x91, 2, -kNativeCombatHitKnockbackDistance);
+		break;
+	case 0x50:
+		candidate = RoomHitAnimationRange(0x8c, 0x8e, 2, -kNativeCombatHitKnockbackDistance);
+		break;
+	case 0x82:
+		candidate = RoomHitAnimationRange(0x9b, 0x9d, 1, kNativeCombatHitKnockbackDistance);
+		break;
+	case 0x78:
+		candidate = RoomHitAnimationRange(0x98, 0x9a, 1, kNativeCombatHitKnockbackDistance);
+		break;
+	case 0x6e:
+		candidate = RoomHitAnimationRange(0x95, 0x97, 1, kNativeCombatHitKnockbackDistance);
+		break;
+	default:
+		return false;
+	}
+
+	if (!runtimeEntityHasDrawableFrameRange(entity, candidate.firstFrame, candidate.lastFrame))
+		return false;
+
+	range = candidate;
+	return true;
+}
+
+int roundRoomCombatFloat(float value) {
+	return value >= 0.0f ? (int)(value + 0.5f) : (int)(value - 0.5f);
+}
+
+int mapRoomDepthToScreenYForCombat(const RoomSetupState &state, float z, int fallbackY) {
+	if (state.roomMaxZScreenY < 0 || state.roomMinZScreenY < state.roomMaxZScreenY)
+		return fallbackY;
+	if (state.roomMaxZ == state.roomMinZ)
+		return CLIP<int>(fallbackY, state.roomMaxZScreenY, state.roomMinZScreenY);
+
+	const float clampedZ = clampRoomDepthForEvent(state, z);
+	const float offset = ((float)state.roomMaxZ - clampedZ) *
+		(float)(state.roomMinZScreenY - state.roomMaxZScreenY) /
+		(float)(state.roomMaxZ - state.roomMinZ);
+	return CLIP<int>(state.roomMaxZScreenY + roundRoomCombatFloat(offset),
+		state.roomMaxZScreenY, state.roomMinZScreenY);
+}
+
+int resolveMonsterAttackContactFrameOffset(const MonsterRecord &monster) {
+	return monster.monsterName.equalsIgnoreCase("MUCKEY")
+		? kMuckeyAttackContactFrameOffset
+		: kDefaultMonsterAttackContactFrameOffset;
+}
+
+void clearRoomMonsterCombatState(RoomMonsterCombatState &combatState) {
+	combatState = RoomMonsterCombatState();
+}
+
+void clearRoomNpcCombatState(RoomNpcCombatState &combatState) {
+	combatState = RoomNpcCombatState();
+}
+
+void clearRoomMonsterAttackState(RoomMonsterCombatState &combatState) {
+	combatState.attackActive = false;
+	combatState.attackFirstFrame = -1;
+	combatState.attackLastFrame = -1;
+	combatState.attackContactFrame = -1;
+	combatState.attackResumeFacing = -1;
+	combatState.attackSoundPlayed = false;
+	combatState.attackContactResolved = false;
+	combatState.attackTargetName.clear();
+}
+
+void clearRoomMonsterHitState(RoomMonsterCombatState &combatState) {
+	combatState.hitActive = false;
+	combatState.hitFirstFrame = -1;
+	combatState.hitLastFrame = -1;
+	combatState.hitResumeFacing = -1;
+	combatState.hitKnockbackRemainingX = 0;
+	combatState.hitKnockbackDecayStep = 0;
+	combatState.hitSoundPlayed = false;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/room_combat.h b/engines/harvester/room_combat.h
new file mode 100644
index 00000000000..833b47e58a3
--- /dev/null
+++ b/engines/harvester/room_combat.h
@@ -0,0 +1,191 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_ROOM_COMBAT_H
+#define HARVESTER_ROOM_COMBAT_H
+
+#include "harvester/room_support.h"
+
+namespace Harvester {
+
+class Entity;
+class HarvesterEngine;
+
+extern const int kRoomMonsterAnimationRate;
+extern const int kNativeMonsterAttackAnimationRate;
+extern const int kNativeMonsterHitAnimationRate;
+extern const uint32 kNativeMonsterAttackCooldownBaseTicks;
+extern const float kNativeNpcMonsterZExtent;
+extern const int kRoomNpcAmbientLastFrame;
+extern const float kNativeMonsterPursuitZTolerance;
+extern const float kNativeMonsterHorizontalWaypointTolerance;
+extern const uint32 kNativeKeyboardAttackRepeatTicks;
+extern const int kNativeAttackSideWindow;
+extern const int kNativeCombatHitKnockbackDecayStep;
+extern const char *const kNativeHitEffectResourcePath;
+extern const int kNativeHitEffectAnimationRate;
+extern const float kNativeHitEffectRenderZBias;
+extern const uint32 kCombatDebugDamagePopupDurationMs;
+extern const int kCombatDebugDamagePopupRisePixels;
+extern const int kCombatDebugDamagePopupVerticalGap;
+extern const char *const kInnerSanctumRoomName;
+extern const char *const kHerrillLogNpcName;
+extern const char *const kMuckeyMonsterName;
+extern const char *const kActivateInnerSanctumDoorActionTag;
+extern const int kNativeDefaultNpcDeathDamageType;
+
+struct RoomAttackAnimationRange {
+	RoomAttackAnimationRange() {}
+	RoomAttackAnimationRange(int firstFrame, int lastFrame, int resumeFacing)
+		: firstFrame(firstFrame), lastFrame(lastFrame), resumeFacing(resumeFacing) {}
+
+	int firstFrame = 0;
+	int lastFrame = 0;
+	int resumeFacing = 0;
+};
+
+struct RoomDeathAnimationRange {
+	RoomDeathAnimationRange() {}
+	RoomDeathAnimationRange(int firstFrame, int lastFrame)
+		: firstFrame(firstFrame), lastFrame(lastFrame) {}
+
+	int firstFrame = -1;
+	int lastFrame = -1;
+};
+
+struct RoomHitAnimationRange {
+	RoomHitAnimationRange() {}
+	RoomHitAnimationRange(int firstFrame, int lastFrame, int resumeFacing, int knockbackX)
+		: firstFrame(firstFrame), lastFrame(lastFrame), resumeFacing(resumeFacing),
+		  knockbackX(knockbackX) {}
+
+	int firstFrame = -1;
+	int lastFrame = -1;
+	int resumeFacing = -1;
+	int knockbackX = 0;
+};
+
+struct RoomMonsterCombatState {
+	bool attackActive = false;
+	int attackFirstFrame = -1;
+	int attackLastFrame = -1;
+	int attackContactFrame = -1;
+	int attackResumeFacing = -1;
+	bool attackSoundPlayed = false;
+	bool attackContactResolved = false;
+	Common::String attackTargetName;
+	bool hitActive = false;
+	int hitFirstFrame = -1;
+	int hitLastFrame = -1;
+	int hitResumeFacing = -1;
+	int hitKnockbackRemainingX = 0;
+	int hitKnockbackDecayStep = 0;
+	bool hitSoundPlayed = false;
+	bool deathActive = false;
+	int deathFirstFrame = -1;
+	int deathLastFrame = -1;
+	int deathDamageType = 0;
+	bool allowInitialPursuitStep = true;
+	uint32 nextMovementTick = 0;
+	uint32 attackCooldownSeedTick = 0;
+};
+
+struct RoomNpcCombatState {
+	bool deathActive = false;
+	int deathFirstFrame = -1;
+	int deathLastFrame = -1;
+	int deathDamageType = 0;
+};
+
+struct RoomHitEffectState {
+	Common::String entityName;
+	Common::String followTargetName;
+	Common::Point anchorPoint;
+	float renderZ = 0.0f;
+	bool finished = false;
+};
+
+struct RoomCombatDamagePopupState {
+	Common::String followTargetName;
+	Common::Point anchorPoint;
+	uint32 startTick = 0;
+	int damageAmount = 0;
+};
+
+struct CombatLoadoutHudInfo {
+	uint iconIndex = 0;
+	int maxVisibleCount = 0;
+	const char *unitLabel = nullptr;
+};
+
+struct ScopedDeferredLiveNpcDeathTransitions {
+	ScopedDeferredLiveNpcDeathTransitions(Script *script) : _script(script) {
+		if (_script)
+			_script->pushDeferredLiveNpcDeathTransitions();
+	}
+
+	~ScopedDeferredLiveNpcDeathTransitions() {
+		if (_script)
+			_script->popDeferredLiveNpcDeathTransitions();
+	}
+
+	Script *_script;
+};
+
+bool shouldRetainNpcDeathEntityInCurrentRoom(const NpcRecord &npc);
+bool isRetainedCurrentRoomNpcDeathRecord(const NpcRecord &npc);
+bool resolveCombatLoadoutHudInfo(int loadout, CombatLoadoutHudInfo &info);
+Common::String buildCombatLoadoutStatusMessage(Script &script, const ObjectRecord &object,
+	int previousLoadout, int currentLoadout);
+bool setRoomMonsterAnimation(Entity &entity, int facing, bool walking);
+bool runtimeEntityHasFrameRange(const Entity &entity, int firstFrame, int lastFrame);
+bool runtimeEntityHasDrawableFrameRange(const Entity &entity, int firstFrame, int lastFrame);
+bool resolveMonsterAttackAnimationRange(HarvesterEngine &engine,
+	const Entity &entity, int actorCenterX, int targetCenterX,
+	RoomAttackAnimationRange &range);
+bool resolveMonsterDeathAnimationRange(const Entity &entity, int facing,
+	int deathDamageType, bool goreEnabled, RoomDeathAnimationRange &range);
+bool resolveNpcDeathAnimationRange(const Entity &entity, bool hasMonsterfyTarget,
+	int deathDamageType, bool goreEnabled, RoomDeathAnimationRange &range);
+int stepTowardsRoomCombatInt(int current, int target, int step);
+int computeRuntimeEntityHorizontalGap(const Entity &left, const Entity &right);
+bool doRuntimeEntityDepthExtentsOverlap(const Entity &first, const Entity &second);
+bool areCombatantsWithinRoomCombatReach(const RoomSetupState &state,
+	const Entity &attacker, float attackerZ,
+	const Entity &target, float targetZ, int engageDistance);
+bool isWithinNativeMonsterAttackEntryRange(const Entity &monster, float monsterZ,
+	const Entity &player, float playerZ, int engageDistance);
+bool playRandomRoomAttackSound(HarvesterEngine &engine, const Common::String &sound1,
+	const Common::String &sound2, const Common::String &sound3);
+bool resolveRoomMonsterHitAnimationRange(const Entity &entity,
+	int attackerAttackFirstFrame, RoomHitAnimationRange &range);
+float clampRoomDepthForEvent(const RoomSetupState &state, float z);
+int roundRoomCombatFloat(float value);
+int mapRoomDepthToScreenYForCombat(const RoomSetupState &state, float z, int fallbackY);
+int resolveMonsterAttackContactFrameOffset(const MonsterRecord &monster);
+void clearRoomMonsterCombatState(RoomMonsterCombatState &combatState);
+void clearRoomNpcCombatState(RoomNpcCombatState &combatState);
+void clearRoomMonsterAttackState(RoomMonsterCombatState &combatState);
+void clearRoomMonsterHitState(RoomMonsterCombatState &combatState);
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_ROOM_COMBAT_H
diff --git a/engines/harvester/room_interaction.cpp b/engines/harvester/room_interaction.cpp
new file mode 100644
index 00000000000..d07e63c6388
--- /dev/null
+++ b/engines/harvester/room_interaction.cpp
@@ -0,0 +1,354 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "harvester/room_interaction.h"
+
+#include "common/debug.h"
+#include "common/textconsole.h"
+#include "harvester/art.h"
+#include "harvester/detection.h"
+#include "harvester/flow.h"
+#include "harvester/fst_player.h"
+#include "harvester/harvester.h"
+#include "harvester/resources.h"
+
+namespace Harvester {
+
+const char *const kExitCloseupPendingRoomChange = "__EXIT_CLOSEUP__";
+
+RoomInteractionProcessor::RoomInteractionProcessor(HarvesterEngine &engine, Flow &flow,
+		RoomSceneResources &scene, RoomPlayerState &playerState,
+		Common::String &pendingRegionName, Common::String &pendingRoomChange,
+		bool &pendingRoomChangeIsRoomName, bool &pendingRoomChangeUsesSavedRoomState,
+		SaveRoomState &pendingRoomChangeSavedRoomState, bool canExitCloseupToParent,
+		RoomInteractionCallbacks &callbacks)
+	: _engine(engine), _flow(flow), _scene(scene), _playerState(playerState),
+	  _pendingRegionName(pendingRegionName), _pendingRoomChange(pendingRoomChange),
+	  _pendingRoomChangeIsRoomName(pendingRoomChangeIsRoomName),
+	  _pendingRoomChangeUsesSavedRoomState(pendingRoomChangeUsesSavedRoomState),
+	  _pendingRoomChangeSavedRoomState(pendingRoomChangeSavedRoomState),
+	  _canExitCloseupToParent(canExitCloseupToParent), _callbacks(callbacks) {
+}
+
+Common::Error RoomInteractionProcessor::handleInteractionResult(const InteractionResult &interaction,
+		bool &didTransition, const Common::String &usedItemName) {
+	didTransition = false;
+
+	_playerState.hasMoveTarget = false;
+	_playerState.turnActive = false;
+	_playerState.turnTargetFacing = -1;
+	_pendingRegionName.clear();
+
+	if (interaction.requestMainMenu) {
+		_engine.stopMusic();
+		_engine.stopSound();
+		if (!interaction.deathFlicPath.empty()) {
+			FstPlayer fstPlayer(_engine);
+			if (!fstPlayer.play(interaction.deathFlicPath))
+				return Common::kReadingFailed;
+		}
+
+		if (!interaction.deathFlicPath.empty())
+			_flow.requestGameOverReturn();
+		else
+			_flow.requestMainMenuReturn();
+		return Common::kNoError;
+	}
+
+	Common::String restoreMusicPath = _engine.getMusicPath();
+	bool discChanged = false;
+	if (interaction.cdChangeDisc > 0) {
+		ResourceManager *resources = _engine.getResources();
+		const int previousDisc = resources ? resources->getCurrentDisc() : 0;
+		Common::Error cdPromptError = _callbacks.showCdChangePrompt(interaction.cdChangeDisc);
+		if (cdPromptError.getCode() != Common::kNoError)
+			return cdPromptError;
+		discChanged = resources && previousDisc > 0 &&
+			previousDisc != resources->getCurrentDisc();
+	}
+	if (!interaction.musicPath.empty()) {
+		(void)_engine.playMusic(interaction.musicPath);
+		restoreMusicPath = _engine.getMusicPath();
+	}
+	_flow.executeStartupAudioCommands(interaction.audioCommands);
+	if (interaction.mutatedRuntimeState)
+		_callbacks.syncGlobalTimerEntities(interaction.previousTimerRecords);
+
+	auto queueImplicitRoomRestart = [&]() -> Common::Error {
+		_pendingRoomChangeIsRoomName = false;
+		_pendingRoomChangeUsesSavedRoomState = false;
+		_pendingRoomChangeSavedRoomState.clear();
+		if (!interaction.roomRestartTargetName.empty()) {
+			_pendingRoomChange = interaction.roomRestartTargetName;
+		} else if (discChanged && interaction.cdChangeDisc == 3) {
+			// Native disc-3 prompt reloads re-enter room_setup through the startup target
+			// buffer, which remains "START" during ordinary play and resolves to CD3 RECEPTION.
+			_pendingRoomChange = "START";
+		} else if (!_scene.state.entranceName.empty()) {
+			_pendingRoomChange = _scene.state.entranceName;
+		} else {
+			_pendingRoomChange = _scene.state.roomName;
+			_pendingRoomChangeIsRoomName = true;
+		}
+		didTransition = !_pendingRoomChange.empty();
+		debugC(1, kDebugRoom,
+			"Harvester: implicit room restart target='%s' explicit='%s' discChanged=%d cdChangeDisc=%d sceneEntrance='%s' sceneRoom='%s'",
+			_pendingRoomChange.c_str(), interaction.roomRestartTargetName.c_str(),
+			discChanged ? 1 : 0, interaction.cdChangeDisc,
+			_scene.state.entranceName.c_str(), _scene.state.roomName.c_str());
+		return didTransition ? Common::kNoError : Common::kReadingFailed;
+	};
+	auto queueSavedRoomRestart = [&](const SaveRoomState &savedState) -> Common::Error {
+		_pendingRoomChange.clear();
+		_pendingRoomChangeIsRoomName = false;
+		_pendingRoomChangeUsesSavedRoomState = false;
+		_pendingRoomChangeSavedRoomState.clear();
+		_pendingRoomChange = !savedState.entranceName.empty()
+			? savedState.entranceName
+			: savedState.roomName;
+		_pendingRoomChangeIsRoomName = savedState.entranceName.empty();
+		_pendingRoomChangeUsesSavedRoomState = !_pendingRoomChange.empty();
+		_pendingRoomChangeSavedRoomState = savedState;
+		debugC(1, kDebugRoom,
+			"Harvester: queued saved room restart target='%s' room='%s' entrance='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
+			_pendingRoomChange.c_str(), savedState.roomName.c_str(),
+			savedState.entranceName.c_str(), savedState.playerX, savedState.playerY,
+			savedState.playerZ, savedState.playerFacing, savedState.musicPath.c_str());
+		didTransition = !_pendingRoomChange.empty();
+		return didTransition ? Common::kNoError : Common::kReadingFailed;
+	};
+	if (interaction.requestRoomRestart)
+		return queueImplicitRoomRestart();
+	if (interaction.requestCloseupExit) {
+		if (!_canExitCloseupToParent) {
+			debugC(1, kDebugRoom,
+				"Harvester: EXIT_CLOSEUP ignored for room='%s' without parent room loop",
+				_scene.state.roomName.c_str());
+			return Common::kNoError;
+		}
+
+		_pendingRoomChange = kExitCloseupPendingRoomChange;
+		didTransition = true;
+		return Common::kNoError;
+	}
+
+	StartupRoomTransitionKind roomTransition = interaction.roomTransition;
+	if (roomTransition == kStartupRoomTransitionNone && !interaction.nextRoomName.empty())
+		roomTransition = kStartupRoomTransitionCloseup;
+
+	const bool needsDisc3RoomReload =
+		discChanged &&
+		interaction.cdChangeDisc == 3 &&
+		!interaction.requestRoomRestart &&
+		interaction.nextRoomName.empty() &&
+		!interaction.mutatedRuntimeState;
+	if (needsDisc3RoomReload)
+		return queueImplicitRoomRestart();
+
+	if (!interaction.nextRoomName.empty() &&
+			roomTransition == kStartupRoomTransitionChangeRoom) {
+		Common::String resolvedTransitionTarget = interaction.nextRoomName;
+		Common::Error resolveError = _flow.resolveRoomTransitionTarget(
+			interaction.nextRoomName, resolvedTransitionTarget);
+		if (resolveError.getCode() != Common::kNoError)
+			return resolveError;
+		if (resolvedTransitionTarget.empty())
+			return Common::kNoError;
+
+		Common::Error exitError = _callbacks.runRoomExitCommands();
+		if (exitError.getCode() != Common::kNoError)
+			return exitError;
+
+		// Native CHANGE_ROOM queues a room handoff for the live loop instead of nesting.
+		_pendingRoomChange = resolvedTransitionTarget;
+		didTransition = true;
+	} else if (!interaction.nextRoomName.empty()) {
+		Common::Error exitError = _callbacks.runRoomExitCommands();
+		if (exitError.getCode() != Common::kNoError)
+			return exitError;
+
+		SaveRoomState parentRoomState;
+		const bool hasParentRoomState = _engine.hasCurrentSaveRoomState();
+		if (hasParentRoomState)
+			parentRoomState = _engine.getCurrentSaveRoomState();
+		Common::Error roomError = _flow.runRoomLoop(interaction.nextRoomName);
+		if (_flow.hasPendingMainMenuReturn())
+			return Common::kNoError;
+		if (_flow.takePendingCloseupParentRestart()) {
+			if (hasParentRoomState && parentRoomState.valid)
+				return queueSavedRoomRestart(parentRoomState);
+			return queueImplicitRoomRestart();
+		}
+		if (_flow.hasPendingDebugRoomChange()) {
+			didTransition = true;
+			return Common::kNoError;
+		}
+		if (roomError.getCode() != Common::kReadingFailed &&
+				roomError.getCode() != Common::kNoError) {
+			return roomError;
+		}
+
+		if (!_callbacks.refreshCurrentScene(true))
+			return Common::kReadingFailed;
+
+		_flow.executeStartupAudioCommands(_scene.state.audioCommands);
+		if (!restoreMusicPath.empty())
+			(void)_engine.playMusic(restoreMusicPath);
+		else
+			_engine.stopMusic();
+		didTransition = true;
+	} else if (interaction.visualRuntimeStateChanged) {
+		if (!_callbacks.applyCurrentRoomRuntimeMutationsInPlace(true) &&
+				!_callbacks.refreshCurrentScene(true)) {
+			return Common::kReadingFailed;
+		}
+	}
+
+	if (didTransition)
+		return Common::kNoError;
+
+	if (interaction.requestPlayerDeath &&
+			_engine.getScript() &&
+			_engine.getScript()->getPlayerCurrentHitPoints() <= 0) {
+		_callbacks.stopPlayerRegionInteraction();
+		_callbacks.startPlayerDefeatSequence("script_kill_pc", Common::String(),
+			interaction.playerDeathDamageType);
+	}
+
+	if (interaction.requestPlayerGotoXZ)
+		_callbacks.applyPlayerGotoXZ(interaction.playerGotoX, interaction.playerGotoZ);
+
+	if (interaction.lightingCommand != kStartupLightingCommandNone) {
+		Common::Error lightingError = _callbacks.applyLightingCommand(interaction.lightingCommand);
+		if (lightingError.getCode() != Common::kNoError)
+			return lightingError;
+	}
+
+	if (!interaction.cutscenePath.empty()) {
+		FstPlayer fstPlayer(_engine);
+		if (!fstPlayer.play(interaction.cutscenePath))
+			return Common::kReadingFailed;
+	}
+
+	if (!interaction.modalText.value.empty()) {
+		Common::Error modalError = _callbacks.runModalShowText(interaction.modalText);
+		if (modalError.getCode() != Common::kNoError)
+			return modalError;
+	}
+
+	if (!interaction.dialogueNpcName.empty()) {
+		Common::Error dialogueError = runScriptedDialogue(
+			interaction.dialogueNpcName, usedItemName, interaction.dialogueContinuationTag,
+			didTransition);
+		if (dialogueError.getCode() != Common::kNoError)
+			return dialogueError;
+	}
+
+	if (didTransition)
+		return Common::kNoError;
+
+	if (!interaction.continuationTag.empty()) {
+		InteractionResult continuationInteraction;
+		if (_engine.getScript()->executeActionTag(
+				interaction.continuationTag, continuationInteraction, true,
+				_scene.state.roomName)) {
+			Common::Error interactionError =
+				handleInteractionResult(continuationInteraction, didTransition, usedItemName);
+			if (interactionError.getCode() != Common::kNoError)
+				return interactionError;
+		}
+	}
+
+	return Common::kNoError;
+}
+
+Common::Error RoomInteractionProcessor::runScriptedDialogue(const Common::String &npcName,
+		const Common::String &usedItemName, const Common::String &continuationTag,
+		bool &didTransition) {
+	didTransition = false;
+
+	const NpcRecord *dialogueNpc = _engine.getScript()->findRuntimeNpcRecord(npcName);
+	if (dialogueNpc) {
+		IndexedBitmap dialogueBackdrop;
+		if (!_callbacks.captureDialogueBackdrop(dialogueBackdrop))
+			return Common::kReadingFailed;
+
+		Common::Error dialogueError = _flow.runRoomNpcDialogue(
+			dialogueBackdrop, _scene.palette, _scene.targetPaletteBrightness, *dialogueNpc,
+			usedItemName);
+		if (dialogueError.getCode() != Common::kNoError)
+			return dialogueError;
+	} else {
+		warning("Harvester: unresolved startup dialogue npc '%s' while processing room dialogue",
+			npcName.c_str());
+	}
+
+	InteractionResult dialogueInteraction;
+	bool abortRemainingCommandChain = false;
+	if (_flow.takeQueuedDialogueInteraction(dialogueInteraction)) {
+		abortRemainingCommandChain = dialogueInteraction.abortRemainingCommandChain;
+		Common::Error interactionError =
+			handleInteractionResult(dialogueInteraction, didTransition, usedItemName);
+		if (interactionError.getCode() != Common::kNoError)
+			return interactionError;
+		if (_flow.hasPendingMainMenuReturn())
+			return Common::kNoError;
+	}
+	if (!didTransition && !abortRemainingCommandChain && !continuationTag.empty()) {
+		InteractionResult continuationInteraction;
+		if (_engine.getScript()->executeActionTag(
+				continuationTag, continuationInteraction, true, _scene.state.roomName)) {
+			Common::Error interactionError =
+				handleInteractionResult(continuationInteraction, didTransition, usedItemName);
+			if (interactionError.getCode() != Common::kNoError)
+				return interactionError;
+			if (_flow.hasPendingMainMenuReturn())
+				return Common::kNoError;
+		}
+	}
+
+	_flow.resetCursorAnimationSequence();
+	_callbacks.resetIdleState();
+	return Common::kNoError;
+}
+
+bool hasRoomEntryInteraction(const InteractionResult &interaction) {
+	return !interaction.nextRoomName.empty() ||
+		!interaction.musicPath.empty() ||
+		!interaction.audioCommands.empty() ||
+		interaction.cdChangeDisc > 0 ||
+		!interaction.cutscenePath.empty() ||
+		!interaction.deathFlicPath.empty() ||
+		interaction.requestMainMenu ||
+		interaction.requestCloseupExit ||
+		interaction.requestRoomRestart ||
+		interaction.requestPlayerDeath ||
+		interaction.requestPlayerGotoXZ ||
+		interaction.lightingCommand != kStartupLightingCommandNone ||
+		!interaction.modalText.value.empty() ||
+		!interaction.dialogueNpcName.empty() ||
+		!interaction.dialogueContinuationTag.empty() ||
+		!interaction.continuationTag.empty() ||
+		interaction.mutatedRuntimeState ||
+		interaction.visualRuntimeStateChanged;
+}
+
+} // End of namespace Harvester
diff --git a/engines/harvester/room_interaction.h b/engines/harvester/room_interaction.h
new file mode 100644
index 00000000000..28184482c34
--- /dev/null
+++ b/engines/harvester/room_interaction.h
@@ -0,0 +1,87 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef HARVESTER_ROOM_INTERACTION_H
+#define HARVESTER_ROOM_INTERACTION_H
+
+#include "common/error.h"
+#include "harvester/room_support.h"
+#include "harvester/saveload.h"
+
+namespace Harvester {
+
+class Flow;
+class HarvesterEngine;
+struct IndexedBitmap;
+
+extern const char *const kExitCloseupPendingRoomChange;
+
+class RoomInteractionCallbacks {
+public:
+	virtual ~RoomInteractionCallbacks() {}
+
+	virtual bool refreshCurrentScene(bool preservePlayerPlacement) = 0;
+	virtual bool applyCurrentRoomRuntimeMutationsInPlace(bool preservePaletteState) = 0;
+	virtual void syncGlobalTimerEntities(const Common::Array<TimerRecord> &previousTimerRecords) = 0;
+	virtual bool captureDialogueBackdrop(IndexedBitmap &dialogueBackdrop) = 0;
+	virtual Common::Error showCdChangePrompt(int discNumber) = 0;
+	virtual Common::Error runRoomExitCommands() = 0;
+	virtual Common::Error applyLightingCommand(StartupLightingCommand lightingCommand) = 0;
+	virtual void applyPlayerGotoXZ(int x, int z) = 0;
+	virtual Common::Error runModalShowText(const ResolvedText &modalText) = 0;
+	virtual void resetIdleState() = 0;
+	virtual void stopPlayerRegionInteraction() = 0;
+	virtual void startPlayerDefeatSequence(const char *reason, const Common::String &sourceName,
+		int damageType) = 0;
+};
+
+class RoomInteractionProcessor {
+public:
+	RoomInteractionProcessor(HarvesterEngine &engine, Flow &flow, RoomSceneResources &scene,
+		RoomPlayerState &playerState, Common::String &pendingRegionName,
+		Common::String &pendingRoomChange, bool &pendingRoomChangeIsRoomName,
+		bool &pendingRoomChangeUsesSavedRoomState, SaveRoomState &pendingRoomChangeSavedRoomState,
+		bool canExitCloseupToParent, RoomInteractionCallbacks &callbacks);
+
+	Common::Error handleInteractionResult(const InteractionResult &interaction,
+		bool &didTransition, const Common::String &usedItemName);
+	Common::Error runScriptedDialogue(const Common::String &npcName, const Common::String &usedItemName,
+		const Common::String &continuationTag, bool &didTransition);
+
+private:
+	HarvesterEngine &_engine;
+	Flow &_flow;
+	RoomSceneResources &_scene;
+	RoomPlayerState &_playerState;
+	Common::String &_pendingRegionName;
+	Common::String &_pendingRoomChange;
+	bool &_pendingRoomChangeIsRoomName;
+	bool &_pendingRoomChangeUsesSavedRoomState;
+	SaveRoomState &_pendingRoomChangeSavedRoomState;
+	bool _canExitCloseupToParent;
+	RoomInteractionCallbacks &_callbacks;
+};
+
+bool hasRoomEntryInteraction(const InteractionResult &interaction);
+
+} // End of namespace Harvester
+
+#endif // HARVESTER_ROOM_INTERACTION_H


Commit: 76fae8e7c199cf5a9079029aff63d44e810caa5b
    https://github.com/scummvm/scummvm/commit/76fae8e7c199cf5a9079029aff63d44e810caa5b
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:59+02:00

Commit Message:
HARVESTER: Fix monster depth sort after chase movement

Changed paths:
    engines/harvester/room.cpp


diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index a8eb4f60f9a..0aaae873cfb 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -3147,6 +3147,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				else if (movedY != 0)
 					monster.facing = movedY < 0 ? 3 : 0;
 				(void)applyRoomActorPlacement(scene.state, *entity, monster.posX, monster.posY, (float)monster.posZ);
+				if (monster.posZ != previousZ)
+					entityManager->reinsertSceneEntity(entity);
 				if (script)
 					(void)script->syncRuntimeMonsterRecord(monster);
 				debugC(1, kDebugCombat,


Commit: 3180cb093242456d4a10f93b2a6e0283e2fb318f
    https://github.com/scummvm/scummvm/commit/3180cb093242456d4a10f93b2a6e0283e2fb318f
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:59+02:00

Commit Message:
HARVESTER: Fix save description build

Changed paths:
    engines/harvester/menu.cpp


diff --git a/engines/harvester/menu.cpp b/engines/harvester/menu.cpp
index b430da98144..e113074fb95 100644
--- a/engines/harvester/menu.cpp
+++ b/engines/harvester/menu.cpp
@@ -1161,7 +1161,7 @@ Common::Error MenuSystem::runLoadGameMenu(const byte *palette, float paletteBrig
 		const int slot = save.getSaveSlot();
 		if (slot < 0 || slot >= kSaveSlotCount)
 			continue;
-		slotTitles[slot] = save.getDescription().encode();
+		slotTitles[slot] = save.getDescription();
 	}
 
 	auto resolveHoveredSlot = [&]() -> int {
@@ -1320,7 +1320,7 @@ Common::Error MenuSystem::runSaveGameMenu(const byte *palette, float paletteBrig
 			const int slot = save.getSaveSlot();
 			if (slot < 0 || slot >= kSaveSlotCount)
 				continue;
-			slotTitles[slot] = save.getDescription().encode();
+			slotTitles[slot] = save.getDescription();
 		}
 	};
 	reloadSlotTitles();


Commit: dc6fc5c3e7926572688c6d293cf76d18d94e0418
    https://github.com/scummvm/scummvm/commit/dc6fc5c3e7926572688c6d293cf76d18d94e0418
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:08:59+02:00

Commit Message:
HARVESTER: Remove RE docs and saveload tweak

Remove root reverse-engineering notes from the branch.

Restore the shared save/load chooser description label formatting to match upstream.

Changed paths:
  R AGENTS.md
  R ARCHITECTURE.md
  R FILE_FORMATS.md
  R TRACKER.md
    gui/saveload-dialog.cpp


diff --git a/AGENTS.md b/AGENTS.md
deleted file mode 100644
index e76483bc4b3..00000000000
--- a/AGENTS.md
+++ /dev/null
@@ -1,570 +0,0 @@
-# AGENTS.md
-
-## Purpose
-
-This project uses LLM-assisted reverse engineering with Ghidra and Ghidra MCP to analyze a DOS game binary. The goal is to progressively recover meaningful program structure by tracing execution from the application entry point, identifying functions, variables, globals, data structures, and subsystem boundaries, and renaming only when there is high confidence.
-
-This repository also maintains an `ARCHITECTURE.md` file that records confirmed subsystem discoveries and their relationships.
-
-Accuracy matters more than speed. Never guess.
-
----
-
-## Primary Objectives
-
-1. Start analysis at the application entry point.
-2. Follow control flow outward to identify:
-   - functions
-   - global variables
-   - local variables
-   - data structures
-   - tables
-   - buffers
-   - dispatch logic
-   - subsystem boundaries
-3. Rename symbols only when their purpose is supported by strong evidence.
-4. Record confirmed subsystem discoveries in `ARCHITECTURE.md`.
-5. Use strings, string cross-references, DOS interrupts, calling patterns, and data flow as primary sources of evidence.
-6. Preserve uncertainty explicitly. If confidence is low, do not rename and do not document as fact.
-
----
-
-## Ground Rules
-
-### 1. Never guess
-Do not rename a function, variable, struct, enum, field, or table unless the available evidence supports the meaning with high confidence.
-
-Avoid speculative names such as:
-- `maybe_draw_sprite`
-- `probably_load_file`
-- `unknown_audio_thing`
-
-If confidence is insufficient, leave the original name in place or apply only a strictly descriptive neutral name if justified by observable behavior, such as:
-- `memcpy_like`
-- `int21_file_io_wrapper`
-- `table_of_far_ptrs`
-- `state_dispatch_table`
-
-### 2. Evidence over intuition
-Every rename and every `ARCHITECTURE.md` update must be grounded in evidence such as:
-- string contents and string references
-- DOS interrupt usage
-- BIOS interrupt usage
-- file access patterns
-- video memory writes
-- buffer shapes and access patterns
-- call graph relationships
-- repeated call-site behavior
-- resource loading sequences
-- script interpreter patterns
-- structure layout and field usage
-
-### 3. Work from the entry point outward
-Begin at the program entry point and proceed in execution order as much as possible. Prefer understanding initialization, subsystem setup, and top-level dispatch before diving into leaf functions.
-
-### 4. Prefer reversible, minimal, precise changes
-Rename conservatively. A smaller number of correct renames is better than many wrong ones.
-
-### 5. Separate confirmed facts from working hypotheses
-Only confirmed facts belong in `ARCHITECTURE.md`.
-
-Do not write:
-- guesses
-- possibilities
-- loose speculation
-- subsystem claims based on one weak clue
-
----
-
-## Analysis Workflow
-
-## Phase 1: Entry Point and Startup Recovery
-
-Start at the application entry point and identify:
-
-- startup and initialization flow
-- memory/model setup
-- segment register initialization
-- heap/buffer setup
-- resource/bootstrap loading
-- video/audio/input initialization
-- main loop entry
-- shutdown/cleanup path
-
-Tasks:
-1. Trace the first layer of calls from the entry point.
-2. Identify initialization clusters by behavior.
-3. Mark wrappers around common DOS/BIOS interrupts.
-4. Identify central state objects, global flags, mode variables, and dispatch tables.
-5. Rename only high-confidence startup functions.
-
-Examples of acceptable names if justified:
-- `game_entry`
-- `initialize_video`
-- `initialize_audio`
-- `initialize_input`
-- `main_loop`
-- `shutdown_and_exit`
-
-Only use these names when the evidence is strong.
-
----
-
-## Phase 2: Systematic Symbol Recovery
-
-For each discovered function, variable, or data structure:
-
-### Functions
-Determine:
-- what calls it
-- what it calls
-- what data it reads/writes
-- whether it wraps a DOS/BIOS interrupt
-- whether it processes strings, files, graphics, scripts, or resources
-- whether it is a leaf helper or subsystem coordinator
-
-Rename functions using:
-- concrete behavior
-- subsystem context
-- observable side effects
-
-Good examples:
-- `open_resource_file`
-- `read_resource_chunk`
-- `draw_mouse_cursor`
-- `decode_rle_scanline`
-- `script_execute_opcode`
-- `blit_backbuffer_to_vram`
-
-Bad examples:
-- `handle_game_stuff`
-- `video_related`
-- `sound_func`
-- `do_script_maybe`
-
-### Variables and globals
-Determine:
-- lifetime
-- scope
-- initialization site
-- write/read locations
-- relation to mode/state/subsystem operation
-- whether it is a pointer, counter, flag, buffer, handle, or table
-
-Prefer names like:
-- `current_video_mode`
-- `resource_file_handle`
-- `mouse_x`
-- `mouse_y`
-- `active_script_pc`
-- `palette_buffer`
-
-Only if proven.
-
-### Data structures
-Look for:
-- repeated field offsets
-- arrays of records
-- pointer tables
-- object/state records
-- decoded resource headers
-- animation/script/resource metadata
-
-Name structures only after enough field usage is understood.
-
-Good examples:
-- `ResourceHeader`
-- `SpriteDescriptor`
-- `ScriptContext`
-- `CursorState`
-
-If not enough is known, prefer temporary neutral names such as:
-- `struct_XXXX_candidate`
-- `resource_record_candidate`
-
----
-
-## Strings-Driven Analysis
-
-The strings table is a major source of context and must be used aggressively.
-
-For each meaningful string:
-1. Identify cross-references to the string.
-2. Determine whether it is used for:
-   - error reporting
-   - debug/logging
-   - file/resource names
-   - script commands
-   - UI text
-   - copy protection
-   - device/system checks
-   - command dispatch
-3. Follow the referencing function outward and inward in the call graph.
-4. Use clustered strings to infer subsystem boundaries.
-
-Examples:
-- File extensions or resource names may indicate resource loading or archive management.
-- UI/status messages may reveal menu, inventory, cursor, or script systems.
-- Error strings may expose file handling, memory allocation, decompression, or driver init paths.
-
-Do not infer more than the string supports.
-
-A string saying `AdLib` may suggest audio relevance. It does not by itself prove the exact role of the entire function.
-
----
-
-## DOS and BIOS Interrupt Heuristics
-
-Interrupt usage is a strong clue and must be incorporated into analysis.
-
-### DOS interrupts
-Pay particular attention to:
-- `int 21h` for file management, memory allocation, program termination, device I/O, directory access, etc.
-- FCB- or handle-based file operations
-- load/execute behaviors
-- DTA manipulation
-- PSP/environment interactions
-
-### BIOS interrupts
-Pay particular attention to:
-- `int 10h` for video mode changes, cursor, text output, palette/video services
-- `int 13h` for disk access
-- `int 16h` for keyboard input
-- `int 1Ah` for timer/time services
-- `int 33h` for mouse services, if present via driver interrupt interface
-
-### Hardware-facing patterns
-Also look for:
-- direct writes to VGA memory
-- palette register I/O
-- PIT/PC speaker programming
-- AdLib/Sound Blaster port I/O
-- keyboard controller access
-- DMA-related setup
-- timer hooks or interrupt vector manipulation
-
-Use these clues to classify behavior, but only rename once supported by surrounding code and data flow.
-
-Example:
-- A function invoking `int 21h` alone is not necessarily `load_file`.
-- A function opening a named asset, seeking, reading into a buffer, and returning a handle-sized or byte-count result may justify `open_resource_file` or `read_resource_data`.
-
----
-
-## Subsystem Discovery Rules
-
-As subsystem boundaries become clear, record them in `ARCHITECTURE.md`.
-
-Candidate subsystems include:
-- video
-- graphics rendering
-- sprite or animation handling
-- palette management
-- cursor management
-- keyboard/mouse input
-- audio/music/sfx
-- script engine
-- text/dialogue
-- decoders/decompression
-- resource/archive management
-- save/load
-- memory management
-- scene/state management
-
-### Only document a subsystem when at least one of the following is true:
-1. There is a clear cluster of related functions with consistent behavior.
-2. There are clear shared globals/structures that define subsystem state.
-3. Strings or resources strongly tie the functions together.
-4. Interrupt/hardware usage and data flow clearly indicate a distinct responsibility.
-
-### For each confirmed subsystem, record:
-- subsystem name
-- confidence level: `High`
-- why it is considered confirmed
-- key functions
-- key globals/structures
-- notable strings
-- relevant interrupts or hardware clues
-- known relationships to other subsystems
-
-Do not add low-confidence or speculative subsystems.
-
----
-
-## ARCHITECTURE.md Update Policy
-
-`ARCHITECTURE.md` is a record of confirmed understanding, not a scratchpad.
-
-Only add content when:
-- the subsystem or relationship is supported by multiple strong clues
-- names used are stable and justified
-- the finding would still make sense to another analyst reviewing the evidence later
-
-Each entry should be concise and factual.
-
-Recommended format:
-
-```markdown
-## Video Subsystem
-
-**Confidence:** High
-
-**Evidence**
-- Functions at `FUN_xxxx`, `FUN_yyyy`, and `FUN_zzzz` change video mode via `int 10h`
-- Shared global buffer used as backbuffer before copy to VRAM
-- Palette update routine writes through VGA-related I/O sequence
-- Strings referencing mode/setup failure are used by the initialization path
-
-**Key Functions**
-- `initialize_video`
-- `set_video_mode`
-- `blit_backbuffer_to_vram`
-- `update_palette`
-
-**Key Data**
-- `video_state`
-- `backbuffer`
-- `palette_buffer`
-
-**Notes**
-- Video initializes before the main loop
-- Rendering appears to be separated from resource decoding
-````
-
-Do not include unresolved claims.
-
----
-
-## Confidence Standard for Renaming
-
-A rename is allowed only when the name is supported by multiple converging signals.
-
-High-confidence signals include combinations of:
-
-* clear interrupt semantics
-* clear string references
-* clear file/resource names
-* repeated consistent call-site usage
-* obvious buffer or structure behavior
-* direct hardware interaction
-* strong structural relationships in the call graph
-
-### Rename threshold
-
-Rename only if at least two or more strong signals converge, or one signal is exceptionally definitive.
-
-Examples of sufficiently strong evidence:
-
-* function opens a named asset file, uses DOS file interrupts, reads into a destination buffer, and is called by resource init code
-* function writes to video memory or uses video BIOS services and is called by rendering flow
-* function dispatches on bytecodes read from a script stream and updates script context fields
-* function uses mouse interrupt services and updates cursor coordinates/state
-
-### If evidence is incomplete
-
-Do one of the following:
-
-* leave the original name unchanged
-* apply a narrowly descriptive placeholder based on directly observable mechanics only
-
-Examples:
-
-* `reads_buffer_with_length_prefix`
-* `far_ptr_dispatcher`
-* `int10_video_service_wrapper`
-* `copies_words_to_segment`
-
-Avoid semantic overreach.
-
----
-
-## Naming Conventions
-
-Use clear, consistent, descriptive names.
-
-### Functions
-
-Use verb-oriented names:
-
-* `initialize_video`
-* `load_palette`
-* `decode_sprite_frame`
-* `execute_script_command`
-* `poll_keyboard_input`
-
-### Variables
-
-Use noun-oriented names:
-
-* `current_room_id`
-* `resource_index`
-* `cursor_visible`
-* `audio_driver_type`
-
-### Structures
-
-Use PascalCase:
-
-* `VideoState`
-* `ScriptContext`
-* `ResourceEntry`
-
-### Constants / enums
-
-Use uppercase when appropriate:
-
-* `VIDEO_MODE_13H`
-* `RESOURCE_TYPE_SPRITE`
-
-### Unknowns
-
-When forced to use an interim name, keep it descriptive and non-speculative:
-
-* `bytecode_stream_ptr`
-* `video_buffer_candidate`
-* `file_io_ctx_candidate`
-
----
-
-## Preferred Investigation Tactics
-
-When analyzing a function, prefer this order:
-
-1. Identify callers.
-2. Identify callees.
-3. Inspect strings referenced directly or indirectly.
-4. Inspect interrupts and I/O operations.
-5. Track major buffers and globals touched.
-6. Look for repeated structural patterns.
-7. Determine whether the function belongs to an already-known subsystem.
-8. Decide whether rename confidence is high enough.
-
-When analyzing a global or structure:
-
-1. Find all writes.
-2. Find all reads.
-3. Determine initialization.
-4. Determine whether access patterns imply flags, counters, coordinates, handles, or pointers.
-5. Associate with a subsystem only if the evidence is strong.
-
----
-
-## MCP / Ghidra Usage Expectations
-
-When operating through Ghidra MCP:
-
-* begin from the entry point unless continuing an already-confirmed analysis thread
-* inspect decompiler output, disassembly, xrefs, and data definitions together
-* follow string references systematically
-* inspect interrupt usage and surrounding setup/register state
-* examine tables and indirect call/jump targets
-* improve type information when supported by evidence
-* rename incrementally and conservatively
-* update `ARCHITECTURE.md` only after confirmation
-
-Do not mass-rename symbols based on pattern matching alone.
-
----
-
-## What to Avoid
-
-Do not:
-
-* invent subsystem names without proof
-* rename based on vague resemblance
-* treat every `int 21h` call as generic file loading
-* treat every memory copy as rendering
-* assume every byte stream is a script
-* collapse unrelated helpers into a subsystem prematurely
-* document tentative conclusions in `ARCHITECTURE.md`
-* overwrite neutral names with stronger semantic names unless the new evidence truly supports it
-
----
-
-## Output Expectations
-
-During analysis, produce:
-
-1. Conservative symbol renames with high confidence
-2. Confirmed subsystem notes appended to `ARCHITECTURE.md`
-3. Clear explanation of evidence for each non-trivial rename
-4. Explicit acknowledgment of uncertainty where confidence is not high enough
-
-For every important rename, include rationale in working notes or commit messages such as:
-
-* string references
-* interrupt semantics
-* caller/callee context
-* buffer usage
-* structure field evidence
-
----
-
-## Operating Principle
-
-Recover the program one confirmed fact at a time.
-
-Start from the entry point.
-Use strings and xrefs aggressively.
-Use DOS/BIOS interrupts as behavioral clues.
-Track data flow carefully.
-Rename only with high confidence.
-Record only confirmed architecture.
-Record the last confirmed action we completed, and the next suggested action in TRACKER.md
-Record high level progress indicator that summarizes how many total functions are in Ghidra, and how many are still not renamed (ex: FUN_*) in TRACKER.md
-Ensure TRACKER.md only contains the *last* action take and the *next suggested* action
-
-Never guess.
-
----
-
-## ScummVM Engine Conventions
-
-When editing ScummVM engine code in this repository, including `engines/harvester`, follow these rules in addition to the reverse-engineering workflow above.
-
-### Portability and language subset
-
-- Use ScummVM/common types and utilities in engine code instead of `std::` library types.
-- Prefer `Common::String`, `Common::Array`, `Common::HashMap`, `Common::ScopedPtr`, `Common::SharedPtr`, and other `common/` facilities.
-- If code is being ported from an external codebase and genuinely needs an STL-style wrapper, include the relevant `common/std/...` header and use `Std::`, never `std::`.
-- Do not use C++ exceptions.
-- Do not add global or function-local static objects that require constructors.
-- Non-const static locals inside function bodies are forbidden.
-- If a non-const global is unavoidable, document why it exists and where it is reset when the engine starts or shuts down.
-
-### Endianness, serialization, and struct layout
-
-- Never rely on host endianness for persisted data, imported assets, or binary parsing.
-- Use `Common::Serializer`, stream endian helpers, or `READ_/WRITE_` macros from `common/endian.h`.
-- Do not serialize raw structs directly.
-- Do not assume native struct packing or layout.
-- If packed structs are unavoidable, use `common/pack-start.h`, `PACKED_STRUCT`, and `common/pack-end.h`.
-
-### File access
-
-- Do not use `fopen`, `fread`, `fwrite`, `open`, `close`, or other raw C/POSIX file APIs in engine code.
-- Use ScummVM file APIs such as `Common::File`, `Common::DumpFile`, `Common::FSNode`, `SearchMan`, and archive streams.
-- Use `File::open("relative/name")` only for SearchMan-relative lookups.
-- Do not pass absolute paths to `File::open()`.
-- If a path comes from config or another filesystem source, first build a `Common::FSNode`, then open via `File::open(node)`, `DumpFile::open(node)`, `node.createReadStream()`, or `node.createWriteStream()`.
-- When traversing directories or handling platform-native filesystem locations, prefer `Common::FSNode` operations such as `getChild()`, `getParent()`, and `getPath()`.
-
-### Formatting and naming
-
-- Apply common sense, but default to ScummVM formatting conventions.
-- Use tabs for indentation with a tab width of 4.
-- Use hugging braces: `if (...) {` / `} else {`.
-- Avoid composite one-liners; prefer one statement per line.
-- Use braces for nested conditionals and for `if`/`else` chains when omission would reduce clarity.
-- Put spaces around operators, after commas, after comment markers, and between reserved words and `(`.
-- Use `char *ptr` and `int &ref` style spacing.
-- Keep namespace contents unindented.
-- Keep preprocessor directives starting in column 1.
-- For intentional `switch` fallthrough, use the exact comment `// fall through`.
-- Add vertical space between logical blocks when it improves readability.
-- Use ScummVM naming conventions: types in `UpperCamelCase`, methods/functions/locals in `lowerCamelCase`, member variables in `_lowerCamelCase`, globals with `g_` prefix, and constants either as `kCamelCase` or `ALL_CAPS_WITH_UNDERSCORES`.
-
-### Comments and documentation
-
-- Use `FIXME`, `TODO`, `WORKAROUND`, and `I18N:` comments only with specific explanatory text.
-- Prefer JavaDoc-style Doxygen comments for public APIs when new documentation is warranted.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
deleted file mode 100644
index fc703a99b85..00000000000
--- a/ARCHITECTURE.md
+++ /dev/null
@@ -1,1475 +0,0 @@
-# HARVEST.LE Architecture
-
-## Summary
-
-- This file records confirmed architecture and stable symbol recovery for `HARVEST.LE`.
-- The emphasis is subsystem shape, trusted names, and engine relationships already justified in Ghidra.
-- Detailed branch notes remain where they materially explain shared engine behavior, save/load semantics, or subsystem boundaries.
-
-## At A Glance
-
-- Startup flow is now bounded from the LE loader through Watcom CRT setup, `startup_main`, XFILE initialization, town-script bootstrap, and `run_harvester_main_loop`.
-- Resource loading is split cleanly between numbered archive sets (`*.DAT` + `INDEX.00N`) and direct CD-ROM file paths under the shared XFILE abstraction.
-- The main loop owns room handoff consumption, player/world interaction dispatch, startup presentation, timer expiry, and shutdown.
-- World data is script-driven: rooms, entrances, commands, text, timers, NPCs, monsters, objects, and regions all parse into stable linked-list registries.
-- Dialogue, inventory, map travel, room menus, and save/load now read as one coherent UI layer rather than isolated helper clusters.
-- Platform/runtime coverage includes DPMI helpers, VESA/VGA backends, IRQ1 keyboard handling, `int 33h` mouse integration, joystick polling, and MSCDEX/CD checks.
-
-## Runtime And Gameplay
-
-### Startup Chain
-
-- `_entry` at `0x810e8` is only an LE loader thunk.
-- `watcom_le_crt_startup` at `0x81160` is Watcom DOS-extender CRT setup. It captures the command tail and environment block, counts `NO87=` overrides, clears startup/BSS state, runs init callbacks, and hands off to C startup.
-- `run_init_callbacks_by_priority` at `0x888a6` walks a 6-byte callback table and executes pending init callbacks in priority order.
-- The callback table format is now typed as `RuntimePriorityCallbackRecord { state, priority, callback }`.
-- `g_startup_priority_callback_table` at `0xc6d92` contains 21 startup callbacks that initialize the mixed CRT/game runtime, including music stream state, video surface context, keyboard/mouse input, render-entity lists, menu/config environments, startup `argc/argv`, floating-point text conversion callbacks, timing bases, environment strings, and the buffered FILE-stream node pool.
-- `run_runtime_termination_callbacks_by_priority_range` at `0x888f1` walks the 3-record `g_runtime_termination_callback_table` at `0xc6e10` on exit paths, including `write_console_message_and_exit`. Its callbacks shut down DOS-runtime mode state, drain the intrusive runtime-module shutdown list, and tear down the buffered FILE-stream layer.
-- Several startup callbacks register per-module runtime records through `push_runtime_module_record`. The termination helper at `0x82084` moves `g_runtime_module_record_list_head` to `g_runtime_module_shutdown_list_head` and drains that list through the common runtime shutdown state machine.
-- `crt_initialize_and_run_program` at `0x88856` finishes CRT setup and transfers control into the program startup path.
-- `startup_main` at `0x10010` is the first game-specific startup routine. It stores `argc/argv`, verifies MSCDEX, resolves a writable temp directory from `TEMP` / `TMP` / `.\`, loads persisted options such as `FX_VOLUME`, `MUSIC_VOLUME`, `GAMMA`, `TEXT`, `GORE`, and `QUICK_TIPS`, handles the `Z.EXE` CLI/help path, and then enters the normal game startup chain.
-  - The temp-directory probe copies each candidate path, trims a trailing `\`, formats `"%s\\temp.fil"`, and only commits `g_temp_directory` after the test file opens and can be deleted.
-  - `TEXT` is a tri-state startup/menu option rather than a boolean: `NO` maps to `g_text_option_mode = 0`, `YES` maps to `1`, and `CLICK` maps to `2`.
-  - `g_quick_tips_enabled` defaults to `1` and is only cleared when `QUICK_TIPS` contains `OFF`.
-- `run_quick_tips_screen` at `0x6c890` is the startup quick-tips overlay shown on top of the already loaded `START -> PCROOM` room state.
-  - It spawns the bottom action labels through `spawn_text_entity`, renders all three actions in color `0xc3`, and resolves the toggle through the `TEXT` records so `Show_Tips_ON` / `Show_Tips_OFF` display their text values rather than the raw keys.
-
-### Runtime Floating-Point And Exception Support
-
-- `dispatch_no87_x87_instruction` at `0x8c3d8` is the central NO87/x87 emulator dispatcher. It skips FWAIT and segment/operand/address prefixes, recognizes x87 opcode bytes `0xd8..0xdf`, uses ModRM-derived jump tables for memory/register forms, and restores the saved data selector before transferring into the selected emulator handler.
-- The software extended-precision arithmetic core is now bounded by the named helpers `add_extended_precision_values`, `divide_extended_precision_values`, and `multiply_extended_precision_values`.
-  - The wrapper layer loads 10-byte extended operands from memory or stack records, calls those cores, and stores results back through the destination pointer. Confirmed wrappers include `add_extended_memory_operands`, `subtract_extended_memory_operands`, `add_extended_pointer_and_stack_operands`, `divide_extended_memory_operands`, and `multiply_extended_memory_operands`.
-  - `evaluate_extended_polynomial_horner` and `evaluate_extended_odd_polynomial` are shared table-driven polynomial evaluators used by the NO87 logarithm, arctangent, and trig/range-reduction helpers. The exact opcode-level ownership of several transcendental entry points remains intentionally unnamed.
-  - `classify_extended_value_for_no87_status` classifies extended values as zero, denormal, infinity, NaN, or normal by exponent/mantissa/sign and updates the saved NO87 status/tag bits.
-- The Watcom/Borland-style exception runtime has a confirmed throw/catch dispatch path.
-  - `runtime_throw_or_rethrow_exception` validates the active throw context, handles both new throws and rethrows, scans active runtime handler records, allocates or reuses the thrown object, and routes no-handler / invalid-rethrow cases through the terminate or console-exit paths.
-  - `copy_thrown_object_to_catch_target` applies the selected catch descriptor: it can copy bytes, zero a target, store object references, or invoke a copy constructor before transferring to the selected handler.
-  - `g_exception_transfer_callback` defaults to `noop_exception_transfer_callback`; `install_exception_transfer_callback` replaces it with the real stack/context-transfer stub used by `transfer_to_selected_exception_handler`.
-
-### Resource Layer
-
-- `initialize_extended_file_system` at `0x1c640` performs one-time setup for the game's extended/XFILE resource system.
-- The routine copies startup/config path strings, resolves the configured `CD_ROM` root, and builds in-memory entry lists for numbered resource sets.
-- `shutdown_extended_file_system` at `0x1ca80` is the teardown half of that subsystem.
-  - It closes open numbered-set handles, frees the per-set path strings and entry lists, clears the cached lookup tables, and drops the initialized flag so `initialize_extended_file_system` can rebuild the state.
-- The global cache state for that subsystem is now partially explicit:
-  - `g_extended_file_system_initialized` gates the one-time init/teardown pair.
-  - `g_xfile_set_root_paths[10]` stores the per-set root/path strings used to locate numbered resource sets.
-  - `g_xfile_set_handles[10]` stores the open handles for those numbered resource sets.
-  - `g_xfile_set_entry_lists[10]` stores the linked-list heads for the per-set in-memory entry catalogs.
-  - `g_xfile_set_read_offsets[10]` caches the current archive read offset per set so `xfile_read` can avoid redundant seeks.
-  - `g_xfile_stream_slots[16]` is the transient stream table shared by dialogue voice, music, sound-effect, and script/resource loads that read through the XFILE abstraction.
-- The in-memory XFILE cache records are now typed:
-  - `XFileEntryListNode` is an 8-byte linked-list node with `entry` and `next` pointers.
-  - `XFileArchiveEntry` is the 0x94-byte per-file record copied from either the numbered set index file or the inline archive headers. The confirmed fields are:
-    - `signature[4]`
-    - `path[0x80]`
-    - `archive_offset`
-    - `stored_size`
-    - `packed_flag`
-    - `unpacked_size`
-- `xfile_size` at `0x1cc80` returns file sizes either from mounted archive entries or from direct CD-ROM paths.
-- `build_cdrom_path` at `0x1ed80` resolves the configured `CD_ROM` root and appends a caller-supplied relative asset path into the shared direct-file path buffer used by FST and other non-archive loads.
-- `xfile_read` at `0x1cde0` reads through the same abstraction. It supports direct file reads and numbered archive-backed entries, including a packed-entry decode path.
-- The XFILE stream helpers are now explicit:
-  - `open_xfile_stream` at `0x1e530` reserves one of 16 transient stream slots, either binds it to a direct CD-ROM file handle or maps it onto an archive entry inside a numbered set, and seeds the slot's start/current/end offsets.
-  - `read_xfile_stream` at `0x1e750` is the chunked reader for those transient slots. Archive-backed reads reuse `g_xfile_set_read_offsets` to avoid redundant seeks and clamp reads to the mapped entry range.
-  - `seek_xfile_stream` at `0x1e900` repositions an XFILE stream slot relative to its mapped entry start, current position, or end; direct-file slots delegate to the lower handle-layer seek helper.
-  - `close_xfile_stream` at `0x1e710` releases a transient stream slot and closes the direct file handle when the slot is not archive-backed.
-- The lower DOS file-handle layer under both XFILE and the game's buffered FILE-stream helpers is now named:
-  - `open_file_handle` at `0x81576`
-  - `open_file_handle_with_flags` at `0x8159a`
-  - `seek_file_handle` at `0x81ecf`
-  - `read_file_handle` at `0x81f63`
-  - `get_file_handle_size` at `0x85d65`
-  - `close_file_handle` at `0x887ad`
-- The buffered FILE-stream layer above those raw handles is now explicit:
-  - `FileStream` is a typed 0x1a-byte buffered stream record with `cursor`, `buffer_count`, `buffer_base`, `flags`, `file_handle`, `buffer_size`, an inline one-byte fallback buffer, and a temp-file id byte.
-  - `FileStreamListNode` is the 8-byte linked-list node used to track active and recycled stream descriptors.
-  - `g_file_stream_slots` at `0xc52f4` is the 20-slot static stream table; `g_stdout_stream` and `g_stderr_stream` are the confirmed standard output/error slots inside that table.
-  - `g_open_file_stream_nodes` and `g_free_file_stream_nodes` are the active/recycled descriptor lists used when the runtime allocates or releases stream objects.
-  - `open_file_stream` allocates a `FileStream`, parses mode strings through `parse_file_stream_mode_flags`, opens the backing DOS handle, and seeks to end when the mode starts with `a`.
-  - `close_file_stream` tears the stream down through `shutdown_file_stream` and `close_and_release_file_stream`.
-  - `seek_file_stream`, `rewind_file_stream`, `flush_file_stream`, and `tell_file_stream` are the standard buffered positioning/flush helpers over that layer.
-  - `ensure_file_stream_buffer` allocates the backing buffer on demand, falling back to the inline one-byte buffer when heap allocation fails.
-  - `printf_ascii`, `fprintf_ascii`, `vfprintf_ascii`, `sprintf_ascii`, and `format_ascii_to_callback` are the shared ASCII formatting layer built on top of those streams.
-  - `write_c_string_to_file_stream` and `write_char_to_file_stream` are the low-level emitters used by `puts_ascii` and the formatting callbacks.
-  - `is_file_handle_device`, `get_cached_file_handle_flags`, and `set_cached_file_handle_flags` cache DOS handle/device state under that stream layer.
-  - `write_text_file_handle` is the translated text-mode DOS write path used by the save/load menus; it expands `\n` to `\r\n` unless the cached handle flags mark the handle as binary and seeks to end first when the append bit is set.
-  - `sync_file_stream_device_flags` is the bridge from newly opened buffered streams into that cached DOS handle flag table; it queries `int 21h AX=4400` through `is_file_handle_device` and mirrors the device bit into the stream's local flags.
-- The protected-mode/DPMI support under those DOS-facing helpers is now partially explicit:
-  - `DpmiInt31RegisterBlock` is the confirmed 0x2c-byte register marshalling block used by the recovered `int 31h` helper path.
-  - `InterruptRegisterState` is the 0x1c-byte general-register snapshot used by the software-interrupt path; the confirmed fields are `eax`, `ebx`, `ecx`, `edx`, `esi`, `edi`, and `carry_mask`.
-  - `SegmentRegisterSnapshot` is the 0x0c-byte companion segment snapshot used by that same path; the confirmed fields are `es`, `cs`, `ss`, `ds`, `fs`, and `gs`.
-  - `call_dpmi_service_with_reg_block` loads `DS`, `ES`, `EDI`, `ESI`, `EDX`, and `ECX` from that block, issues `int 31h` with caller-supplied `AX`/`BX`, then writes the post-call `EAX`, `ECX`, `EDX`, and flags back into the same block.
-  - `get_selector_base_address` is the `int 31h AX=0006` wrapper that returns the linear base for a protected-mode selector.
-  - `get_code_segment_selector` and `get_stack_segment_selector` are the tiny selector helpers used when the runtime needs to derive lockable linear addresses from the current code or stack segment.
-  - `set_linear_region_lock_state` wraps DPMI services `0x0600` / `0x0601` over a linear base/length pair, and `lock_linear_region` / `unlock_linear_region` are its direct public wrappers.
-  - `g_software_interrupt_stub_table` at `0x89acc` is the 256-entry `INT n ; RET` stub table used by the generic software-interrupt core.
-  - `dispatch_interrupt_stub_with_state` loads `ES` / `DS` from `SegmentRegisterSnapshot`, restores `EAX`..`EDI` from `InterruptRegisterState`, and returns directly into the selected `INT n ; RET` stub.
-  - `invoke_interrupt_stub_and_store_state` is the post-dispatch collector that snapshots `EAX`..`EDI`, the carry bit, and the updated `ES` / `DS` values back into those same state records.
-  - `invoke_software_interrupt_with_state` is the public wrapper over that stub-table path.
-  - `allocate_dos_memory_block` and `free_dos_memory_block` are the `int 31h AX=0100` / `AX=0101` helpers used by the VESA probe path to allocate and release conventional-memory buffers.
-  - `DpmiRealModeCallStructure` is the confirmed 0x32-byte DPMI `AX=0300` real-mode register frame: `edi`, `esi`, `ebp`, `reserved_0c`, `ebx`, `edx`, `ecx`, `eax`, `flags`, `es`, `ds`, `fs`, `gs`, `ip`, `cs`, `sp`, `ss`.
-  - `g_dpmi_real_mode_call_structure` at `0xa0000` is the shared instance of that structure used by the mouse, VESA BIOS, and MSCDEX probe paths.
-  - `simulate_real_mode_interrupt` and `simulate_real_mode_interrupt_with_stack_words` marshal `int 31h AX=0300` around that shared real-mode frame, with the second helper exposing the caller-supplied stack-word count.
-  - Those helpers are used by `install_keyboard_irq1_handler` / `remove_keyboard_irq1_handler` to lock the IRQ1 handler code and shared scancode state, by `initialize_pcm_sound_state` to lock live PCM buffers, and by the video/runtime setup path to lock persistent surface state once.
-- The temporary DOS critical-error path used by CD-ROM probing is now explicit:
-  - `install_dos_critical_error_handler` installs `dos_critical_error_handler` on `int 24h` through DOS `AH=25h` and stores a caller-supplied far callback in `g_dos_critical_error_callback_far`.
-  - `dos_critical_error_handler` snapshots the interrupted stack in `g_dos_critical_error_saved_stack_far`, records the current DOS critical-error context, and then invokes that caller callback.
-  - `dos_critical_error_handler_iret_thunk` is the chained return stub that invokes the stored callback, restores the saved stack, and resumes the interrupted DOS path with `IRETD`.
-  - `dos_find_first_with_dta` is the disc-probe helper that sets a temporary DTA with DOS `AH=1Ah`, performs `findfirst` via `AH=4Eh`, and is used by `match_requested_game_disc` and `prompt_for_cdrom_disc_change` to read the inserted disc label.
-  - `copy_dos_current_dta_record` is the DOS 9 compatibility helper under that path; it queries the current DTA pointer with `AH=2Fh` and copies the 0x2b-byte record into the caller's buffer after `findfirst`.
-  - `store_segment_registers` is the small support helper used by `invoke_real_mode_interrupt` to seed its segment snapshot before handing off to the still-untyped real-mode interrupt core.
-  - `probe_mscdex_installation_status` uses `int 2Fh AX=1100h` with the documented `DADAh` stack signature to probe MSCDEX installation state during startup.
-  - `get_mscdex_first_drive_index` uses `int 2Fh AX=1500h` and returns the starting CD-ROM drive index from `g_dpmi_real_mode_call_structure.ecx` when MSCDEX reports one or more installed drives in `ebx`.
-  - `get_mscdex_version_bcd` uses `int 2Fh AX=150Ch` and returns the MSCDEX version number from `g_dpmi_real_mode_call_structure.ebx`.
-- `prompt_for_cdrom_disc_change` at `0x488a0` is the user-facing disc-swap / drive-not-ready path reached from menus and scripted transitions.
-  - It stops the active music stream, frees dialogue/XFILE cache state, shows the `CD-ROM DRIVE NOT READY!` / `PLEASE TRY AGAIN...` prompt, then reruns `shutdown_extended_file_system`, `initialize_extended_file_system`, and the `dialogue.idx` reopen path once the requested disc becomes available.
-  - It also updates `g_active_game_disc` at `0xc0fa8`. On disc `3`, it sets `g_disc3_mode_flag` at `0xd607c`, and when the second parameter is `0` it additionally sets the one-shot latch `g_pending_disc3_room_restart` at `0xc7e49`.
-  - `run_harvester_main_loop` checks `g_pending_disc3_room_restart` after interactions; when set, it clears the byte, calls `reload_town_world_from_script`, and immediately reruns `room_setup` on the name stored in `g_room_setup_target_name_buffer` at `0xd5b80`. `dispatch_room_event_actions` case `CHANGE_CD` mirrors the same disc-3 path inline by calling `reload_town_world_from_script`, clearing `g_pending_disc3_room_restart`, and then calling `room_setup`.
-
-### Town Script Runtime
-
-- `initialize_town_script_runtime` at `0x42fc0` bootstraps the game's town script system.
-- It builds a `cold(%s)` startup expression from the configured `TOWN` value.
-- It loads the town script blob through XFILE and deobfuscates it by XORing bytes with `0xAA` except CR/LF.
-- The function then registers a large vocabulary of words/primitives, including `create`, `lit`, `var`, `const`, `if`, `else`, `branch`, `quote`, `.quote`, `do_do`, `do_loop`, and related operators.
-- This strongly suggests a compact Forth-like or stack-based scripting runtime rather than a simple linear bytecode interpreter.
-- The runtime dictionary is now structurally bounded:
-  - `g_town_script_dictionary_head` is a linked list of 16-byte word entries with fields `{ handler, value, name_record, next }`.
-  - Each entry owns a separate 16-byte name record with `{ text_length, reserved_04, registration_state, text }`.
-  - `find_town_script_dictionary_entry` searches that list from the counted token in `g_town_script_token_buffer` and leaves `g_town_script_dictionary_lookup_cursor` pointing at either the matched entry or the tail insertion point.
-- Token input is split cleanly between two confirmed source paths:
-  - `g_town_script_token_source` is a callback slot used by `read_next_town_script_word`.
-  - When the encrypted script blob is present, the runtime reads from `g_town_script_blob` / `g_town_script_blob_offset`.
-  - When no blob is active, or when nested script input unwinds back to zero depth, the callback falls back to `read_town_script_console_line`, which reads and echoes a console line into `g_town_script_input_line_buffer`, expands tabs, and then lets `read_next_town_script_word` tokenize it.
-- The interpreter and record parsers now have an explicit shared cell stack:
-  - `g_town_script_cell_stack_count` tracks the live depth.
-  - `g_town_script_cell_stack` stores mixed 32-bit cells, including numeric values, delimiter bytes, counted-string token buffers, and dictionary entry pointers.
-  - `pop_town_script_cell_or_zero` is the non-fatal pop helper used by record parsers when they consume numeric fields from that stack.
-  - `read_next_town_script_quoted_string` pushes delimiter `0x20`, routes through `read_next_town_script_word`, then strips the surrounding quote bytes and returns a heap `char *` or null for an empty quoted field.
-- `g_town_script_runtime_state` at `0xd3050` is the 0x20-byte base state block zeroed by `reset_town_script_runtime_state` at `0x48000`.
-  - The mixed Borland runtime descriptor block still binds a no-op paired callback above it, but the owning state object itself is now explicit from the `initialize_town_script_runtime` and `run_town_script_interpreter` xrefs.
-
-### Confirmed Cold-Start Resource Order
-
-- `startup_main` at `0x10010` enters the normal game boot path in this order:
-  - `initialize_extended_file_system`
-  - `initialize_town_script_runtime`
-  - `run_harvester_main_loop`
-- Before the XFILE mount, the runtime opens `CONFIG.INI`, `menu.ini`, `hmidet.386`, `SOUND.CFG`, and `hmidrv.386`; that stage is configuration, mouse setup, and HMI sound-driver bootstrap rather than archive resource loading.
-- `initialize_extended_file_system` mounts numbered resource sets before any script or UI bootstrap runs. The observed cold-start order is:
-  - `HARVEST.DAT` then `INDEX.001`
-  - `SOUND.DAT` then `INDEX.002`
-  - `HARVEST2.DAT` then `INDEX.003`
-- The sidecar `INDEX.00N` files are read as repeated `0x94`-byte records, matching the confirmed `XFileArchiveEntry` size.
-- `xfile_read` and `xfile_size` use two distinct path modes:
-  - `<digit>:\...` paths are archive-backed lookups through the numbered sets mounted by `initialize_extended_file_system`
-  - bare relative paths are opened directly from the configured `CD_ROM` root
-- Spot-checking the Sergeant room resource cluster across the disc-prefixed `INDEX.001` files shows the current-disc archive contents are not identical. `1INDEX.001` and `2INDEX.001` both carry `1:\GRAPHIC\ROOMS\NTRYHALL.BM`, `1:\GRAPHIC\PAL\NTRYHALL.PAL`, `1:\GRAPHIC\MASKS\NTRYDORS.BM`, `1:\GRAPHIC\ROOMANIM\LDGEYES.ABM`, `1:\GRAPHIC\ROOMANIM\LDGEDRIP.ABM`, and `1:\GRAPHIC\CHAR\SERGENT.ABM`, while `3INDEX.001` only carries `1:\GRAPHIC\ROOMANIM\LDGEDRIP.ABM` and `1:\GRAPHIC\CHAR\SERGENT.ABM`. No alternate `NTRYHALL.BM` entry has been recovered on disc 3.
-- `initialize_town_script_runtime` next opens `HARVEST.SCR`, loads the full script blob through XFILE, and XOR-deobfuscates it in memory before registering the script vocabulary.
-- `run_harvester_main_loop` begins its presentation bootstrap by playing three direct-path FST sequences in this exact order:
-  - `GRAPHIC\FST\VIRGLOGO.FST`
-  - `GRAPHIC\FST\FVLOGO.FST`
-  - `GRAPHIC\FST\INTROFIN.FST`
-- After the FST logo/intro playback, the startup path begins loading archive-backed UI resources from set 1, including:
-  - `1:\graphic\pal\wait.pal`
-  - `1:\graphic\other\wait.abm`
-  - `1:\graphic\pointers\pointers.abm`
-  - `1:\graphic\other\inventry.bm`
-  - `1:\graphic\other\harvlogo.bm`
-- Only after the FST playback does the main-loop startup open direct-path dialogue/font assets such as `dialogue.idx`, `graphic\font\harvfont.cft`, `graphic\font\harvfnt2.cft`, `graphic\font\textfont.cft`, `graphic\font\textfnt2.cft`, `graphic\font\medfont1.cft`, and `graphic\font\medfont2.cft`.
-
-### Main Loop
-
-- `run_harvester_main_loop` at `0x6dc70` is the primary Harvester boot and gameplay loop.
-- It prints the `Running Harvester v1.0` banner.
-- It loads startup art and UI resources including logo FSTs, palettes, fonts, textbox art, cursor resources, and room-object art.
-- The startup loading overlay is now explicit:
-  - `WAIT_BM` from `1:\graphic\other\wait.abm` stays on the render list while the dialogue index, fonts, textbox panels, and combat HUD icon strips are loaded, then is removed before the first live `START` room setup.
-  - `g_inventory_background_entity` and `g_harvester_logo_entity` are allocated only after that `WAIT_BM` setup, so the native wait overlay is not composed on top of the inventory/logo art.
-- Several startup UI globals seeded there are now named:
-  - `g_inventory_background_entity` at `0xe2498` is the preloaded `1:\graphic\other\inventry.bm` panel entity used by `run_inventory_screen`.
-  - `g_harvester_logo_entity` at `0xd60b4` is the preloaded `1:\graphic\other\harvlogo.bm` logo entity reused by the main-menu path.
-  - `g_box1_panel_entity` .. `g_box4_panel_entity` at `0xe249c` .. `0xe24a8` are the four preloaded textbox panel entities from `textbox1.bm` .. `textbox4.bm`.
-- The in-room ESC menu path is now bounded directly from `run_main_menu`:
-  - it pauses timer countdown entities, keeps the current room render list on screen, re-adds `g_harvester_logo_entity`, and does not replace the background with `g_inventory_background_entity`
-  - the six base menu rows are centered at screen Y `100 + n * (get_font_line_height(g_harvfont_cft) + 2)`
-  - unselected rows are spawned with `g_harvfnt2_cft`, while the currently selected row is respawned with `g_harvfont_cft`
-  - mouse row selection is derived only from `(g_cursor_screen_y - 100) / line_height`, so the base ESC menu does not perform X-bounded text hit testing
-  - `TEXTBOX4.BM` is only introduced by the `NEW GAME` / `QUIT GAME` confirmation branch and is not part of the base ESC overlay
-- On the cold-start path it only seeds the first town transition: after the world-record parsers and script runtime have populated the live record lists, it preloads `POINTERS.ABM`, stores the spawned cursor entity, preloads the initial `START -> PCROOM` room palette into `g_current_palette_buffer`, and then hands off to `room_setup("START")`; the quick-tips screen is a later overlay on top of that live room scene rather than a standalone room handoff.
-- That same cold-start path also stores the live player actor before the first room transition; `room_setup` later re-adds that existing actor to the room render list instead of constructing a fresh PCROOM-local record.
-- It seeds the initial `START` state, enters the main loop, processes state-driven transitions and interactions, and performs cleanup on shutdown.
-  - The startup-side helpers hanging directly off that path are now bounded:
-    - `printf_ascii` prints the copyright line through `g_stdout_stream` after the `puts_ascii` version banner.
-    - `set_random_seed` seeds `g_random_seed` from the first startup tick, and `next_random_value` is the 15-bit LCG step used by script/random branches.
-    - `configure_video_surface` swaps `g_video_surface_context` between the normal gameplay surface and the narrower FST playback surface before the movie path hands off to `run_fst_sequence_player`.
-    - `play_fst_sequence` at `0x72550` is the wrapper that makes that swap visible to the rest of the engine: when a movie is triggered from the gameplay/UI side it pauses music, clears pending keypress state, switches the active surface back to `320x200x8` through `configure_video_surface(&g_video_surface_context, 0x140, 8, 1)`, runs the decoded FST player, then restores the `640`-wide gameplay surface with `configure_video_surface(&g_video_surface_context, 0x280)`, reapplies the gameplay palette, resets mouse bounds, and resumes music.
-  - The video backend records behind that selector are now bounded as one linear VGA descriptor plus four 8bpp banked VESA descriptors:
-    - `g_vga_mode_320x200_8bpp`
-    - `g_vesa_mode_640x400_8bpp`
-    - `g_vesa_mode_640x480_8bpp`
-    - `g_vesa_mode_800x600_8bpp`
-    - `g_vesa_mode_1024x768_8bpp`
-    - `g_video_mode_backends` is the 6-entry pointer table `configure_video_surface` scans to match width / height / pixel format; it contains those five concrete backends plus the sentinel that terminates the probe loop.
-    - `configure_video_surface` matches each descriptor on width / height / bpp / backend class, calls the descriptor's probe and activate callbacks, and tears down the previously active backend through the record's shutdown callback before switching.
-    - `VesaModeBackend` is now a typed 64-byte video-mode descriptor record despite the legacy name.
-      - Confirmed fields now include width/height/pixel-format, probe/activate/shutdown callbacks, `unused_callback`, `unused_display_start_preset_callback`, pixel read/write callbacks, `fill_horizontal_span_callback`, `fill_rect_callback`, `blit_rect_callback`, and the runtime bank/window fields consumed by FST playback.
-      - `unused_callback` is now explicitly bounded as dead slot baggage in this binary: the concrete VGA/VESA implementations are all no-op stubs, and an instruction-level scan only found indirect backend calls through `blit_rect_callback` and `select_bank_for_scanline_callback`.
-      - `unused_display_start_preset_callback` is also dead in this binary: the four `set_vesa_*_display_start_preset` helpers only survive as data references from the backend records, while `configure_video_surface` only dispatches the probe/activate/shutdown slots and the per-mode activate helpers call `initialize_vesa_banked_mode` directly.
-      - The VGA `320x200x8` record makes the generic callback roles explicit through `probe_vga_320x200_mode`, `activate_vga_320x200_mode`, `write_vga_320x200_pixel`, `read_vga_320x200_pixel`, `fill_vga_320x200_horizontal_span`, `fill_vga_320x200_rect`, and `blit_vga_320x200_rect`.
-    - `VideoSurfaceContext` is now a typed 16-byte context that caches the active backend pointer plus the selected width/height/bpp/class.
-    - The mixed Borland runtime descriptor block also binds `set_vesa_video_mode_3` and `reset_video_surface_context` to `g_video_surface_context`; that pair is the small lifecycle wrapper layer above the shared context object.
-  - The shared VESA helper layer is now partially named:
-    - `query_vesa_controller_info` validates the controller signature through BIOS `int 10h AX=4f00`.
-    - `set_vesa_write_window_bank` is the write-window-fixed wrapper above `set_vesa_window_bank`.
-    - `set_vesa_video_mode` checks the current VESA mode with `AX=4f03` and only issues `AX=4f02` when a switch is required.
-    - `set_vesa_window_bank` is the shared `AX=4f05` wrapper used by the banked blitters and single-pixel helpers.
-    - `set_vesa_display_start` is the shared `AX=4f07` wrapper used by the per-mode display-start preset callbacks.
-    - `initialize_vesa_banked_mode` probes and installs a banked VESA mode by mode number, builds the per-scanline bank/offset tables, and initializes the active read/write windows.
-    - `restore_text_mode_3` restores BIOS text mode 3 for the current backend.
-    - `noop_vesa_mode_shutdown` is the no-op teardown callback used by the 640x400, 640x480, and 800x600 descriptor records.
-    - The per-mode callback sets are now explicit for the four descriptor records: each record carries a probe helper, an activation helper, a display-start preset helper, and single-pixel read/write helpers for its specific geometry.
-  - `fill_vesa_banked_rect` fills a rectangular byte region in banked VRAM using the per-scanline bank split tables.
-  - `blit_vesa_banked_rect` is the shared rectangle blitter used by the VESA mode records.
-  - `select_vesa_bank_for_scanline` adjusts the active bank/window based on a scanline index.
-
-### Render Entity Runtime
-
-- `Confidence:` High.
-- `Evidence:` `spawn_scaled_abm_entity_from_resource`, `attach_abm_resource_to_entity`, `show_entity_visual`, `hide_entity_visual`, `rescale_entity_sprite_for_depth`, `tick_entity_visual_state`, and `update_actor_runtime_state` all operate on the same `0x11d0` runtime record.
-- `Evidence:` recovered helper types line up with the entity materialization path: `BitmapBuffer` is the 12-byte `{ width, height, pixels }` bitmap header, `AbmFrameHeader` is the packed per-frame ABM prefix, `DirtyRectNode` is the 20-byte dirty-rect node, and `ActorWaypoint[10]` is the fixed array at `RenderEntityRuntime + 0x10ac`.
-- `Evidence:` switching the simple entity helpers to Borland `__fastcall` confirms that `tick_entity_visual_state` writes the current opaque overlap blocker at `+0x109c`, `update_actor_runtime_state` uses directional blocker-history slots at `+0x108c`, `+0x1090`, `+0x1094`, and `+0x1098`, and `attach_abm_resource_to_entity` builds a `1024`-entry frame-header table at `+0x5c`.
-- `Key Functions:` `spawn_scaled_abm_entity_from_resource`, `attach_abm_resource_to_entity`, `show_entity_visual`, `hide_entity_visual`, `rescale_entity_sprite_for_depth`, `tick_entity_visual_state`, `update_actor_runtime_state`.
-- `Key Data:` `RenderEntityRuntime`, `BitmapBuffer`, `AbmFrameHeader`, `DirtyRectNode`, `ActorWaypoint[10]`, `g_render_entity_list`.
-- `Notes:` the current typed runtime record cleanly covers render placement, animation timing, waypoint storage, and blocker-history detours.
-- `Notes:` the actor-only tail from `+0x1134` through `+0x118e` is still only partially typed, but it now clearly contains combat/use-state sound slots and facing-bank flags rather than generic render state.
-- `Notes:` confirmed actor-tail roles now include `+0x112c = engage_distance`, `+0x113c = attack_contact_frame_offset`, `+0x1180 = attack_damage_amount`, `+0x1188 = last_hit_combat_flags`, `+0x118c = combat_capability_flags`, `+0x11a4 = combat_target_entity`, `+0x11b8 = collision_partner_entity`, and `+0x11bc = player_combat_loadout_id`.
-
-### Room And Event Flow
-
-- `room_setup` at `0x73540` is confirmed by in-binary debug strings:
-  - `room_setup(): no START entrance`
-  - `room_setup() ERROR: No such room defined %s`
-  - `room_setup: loading_game / current_music_file = %s`
-- `room_setup` resolves the requested room or `START` entrance, finds the matching room definition, loads the room palette, instantiates exits / hotspots / overlays / room actors, positions the player, runs room-enter handlers, and updates the current music file.
-- `g_current_palette_buffer` at `0xd6084` is the shared room/menu palette buffer used by `run_harvester_main_loop`, `room_setup`, `dispatch_room_event_actions`, inventory/help screens, and the CD prompt path.
-  - `room_setup` first resolves the target room / entrance and, when `g_current_room_def->palette_path` is non-null, rereads that room palette into `g_current_palette_buffer`.
-  - Before rebuilding the wait transition, `room_setup` uses `ramp_palette_brightness` to fade the outgoing palette down to black when a current room palette is active; only the no-current-room startup case skips directly to a black palette upload.
-  - It then reloads `WAIT.PAL` and `WAIT.ABM`, hides the cursor entity, flushes the wait transition while the cursor is hidden, re-shows the cursor, uploads the wait palette, builds the room entities, and only after that uploads the room palette from `g_current_palette_buffer`.
-  - Once the wait transition is flushed, `room_setup` rebuilds the room render list in this order: matching enabled regions, room timers, visible object records whose `current_owner_or_room` matches the room, then room `ANIM` records whose `active` or `visible` state is set.
-  - Timer setup walks the global `g_timer_records` list but only materializes a class `0x17` timer entity when `TimerRecord.arg1` matches the resolved room name. It also skips spawning when a live entity with the same timer name already exists.
-  - Cross-room timer persistence is carried by live class `0x17` entities, not by `room_setup` selecting global timers in every room. `destroy_entity_list` at `0x5c9e0` preserves a timer entity instead of removing/freeing it when the entity global byte copied from `TimerRecord.global` is set.
-  - Decoding `HARVEST.SCR` confirms that room `OBJECT` records are not all named sprites. The data uses unnamed visible `OBJECT` records as blocker rectangles in many rooms, and visible no-sprite `OBJECT` records as live hotspots. Hidden no-sprite action hotspots such as `PCHOUSE / PC_WINDOW` and `MR_JOHNS / MANHOLE` are not materialized until an `ADD` opcode sets their `visible` byte.
-  - Rechecking the live CD3 handoff against `handle_talk_to_sergeant`, `prompt_for_cdrom_disc_change`, `run_harvester_main_loop`, and the decoded disc-specific `HARVEST.SCR` changed the earlier conclusion about the Sergeant remains rebuild target. Native Sergeant dialogue still ends with `C076.FST` plus `prompt_for_cdrom_disc_change(3, 0)` and no explicit `CHANGE_ROOM`, but the disc-3 branch in `run_harvester_main_loop` then calls `reload_town_world_from_script(); room_setup(g_room_setup_target_name_buffer);`. The same Ghidra xrefs show `g_room_setup_target_name_buffer` is initialized to `START` at boot and is not rewritten by ordinary room transitions, so the post-prompt CD3 rebuild re-enters the disc-3 `START` entrance rather than the pre-swap Sergeant entrance. Decoding `CD3/HARVEST.SCR` then shows that `START` resolves to `RECEPTION`, whose room-enter chain owns the lodge fall / reception sequence.
-  - Rechecking the live script against the ScummVM room/map resolver narrows the next authored step after that rebuild: `EXIT_LODGE_2` issues `CHANGE_ROOM "LODGE_2_MAP"`, and `LODGE_2_MAP` is a `MAP_ENTRANCE`, so the authored follow-up is the town-map selector handoff rather than an automatic jump into a deeper lodge interior.
-  - On the player-present branch it then re-adds the stored live player actor from `DAT_000d5bd4`, places it from `g_current_room_entrance`, and seeds exact facing frames `0x3b`, `0x0e`, `0x2c`, and `0x28` for entrance-facing values `0`, `1`, `2`, and `3`.
-  - Before the live room flush it explicitly resets the cursor entity to animation sequence `7`.
-  - The final room palette/upload order is now explicit: after optional room-enter handlers, `room_setup` uploads `g_current_palette_buffer` and then calls `flush_dirty_rects_to_screen`.
-  - Because those room-enter handlers run before the final room flush, a room-entry `SET_NPC ... F F` removes the current-room NPC before the first visible frame. DNALFT's `SET_NPC "EDNA" "F" "F"` path depends on that ordering.
-  - `flush_dirty_rects_to_screen` begins by calling `sync_cursor_entity_position(g_cursor_entity)`, so the first cursor sync / animation tick after room construction happens under the room palette rather than before that upload.
-  - After the initial black upload, `room_setup` uses `ramp_palette_brightness` at `0x23a30` to fade `g_current_palette_buffer` in from black. The helper steps brightness in `0.1` increments against the shared centisecond clock from `get_elapsed_milliseconds()` while also driving the room dimming path when the room is marked dimmable and `DAY_FLAG` is clear.
-- `upload_palette_to_vga` at `0x22770` writes a caller-supplied RGB palette to the VGA DAC, forces palette index 0 to black, and applies a caller-supplied brightness scalar before clamping each channel to the hardware `0x3f` range.
-  - Archive inspection plus the helper body confirm the room/menu `.PAL` resources are already stored as 768-byte 8-bit RGB triplets; the upload path shifts those bytes down to VGA `0..63` before brightness scaling instead of expanding 6-bit source values.
-- `ramp_palette_brightness` at `0x23a30` is the shared timed palette ramp helper.
-  - `room_setup`, `run_inventory_screen`, and `dispatch_room_event_actions` all call it on room / closeup transition paths.
-  - The helper steps brightness in `0.1` increments and waits until `get_elapsed_milliseconds() + 4` between uploads while pumping `update_music_stream`, so the native fade pacing comes from repeated short timed palette uploads rather than a single blocking delay.
-  - The helper updates music stream state between palette uploads instead of blocking in a tight delay loop.
-- `dispatch_room_event_actions` at `0x60ee0` interprets action records keyed by a tag string. It is used by `room_setup`, the main loop, and nested action groups.
-- `parse_command_record` at `0x5e410` now has enough parser evidence to anchor the room-handoff opcodes directly.
-  - `CHANGE_ROOM -> 0x02`
-  - `CLOSEUP -> 0x06`
-  - `EXIT_CLOSEUP -> 0x35`
-- Ordinary room changes are queued rather than performed directly inside the dispatcher.
-  - `CHANGE_ROOM` resolves `arg1` through `resolve_room_entrance`, stores `g_current_room_entrance`, and queues `g_pending_room_name = entrance->room_name`.
-  - `CLOSEUP` queues the raw room name from `arg1` and clears `g_player_present_in_room`, so `room_setup` builds the target without spawning the live player.
-  - `EXIT_CLOSEUP` restores `g_pending_room_name = g_player_room_name`, copies the live player position/facing into the `SAVE_GAME` entrance, and restores `g_player_present_in_room = 1`.
-- `run_harvester_main_loop` is the code that actually consumes `g_pending_room_name`.
-  - At the top of each gameplay iteration it checks for a queued handoff, clears any selected carried object / interaction text state that would leak across the boundary, and then calls `room_setup(g_pending_room_name)`.
-  - The same loop also owns the live room right-click attack selector when no carried item is active and the player is alive.
-  - When a live class `0x17` timer entity expires, the same loop resolves the backing `TimerRecord` by `timer_name` and dispatches `dispatch_room_event_actions(timer->arg2)`; the timer name is only the lookup key, not the action tag.
-  - On expiry, one-shot timer entities clear their live enabled byte and mirror that disabled state back into the matching `TimerRecord`; looping timers reseed their next-fire tick from the current centisecond clock.
-  - It sets `DAT_000d61d0 = 3`, compares cursor X against the player midpoint, and then compares cursor Y against `player_bottom - depth_scale * 144.44` and `player_bottom - depth_scale * 75.36`.
-  - Those thresholds choose the six directional attack states `0x18/0x17/0x16` on the left side and `0x1b/0x1a/0x19` on the right side.
-  - `update_actor_runtime_state` maps those states to frame banks `0x50..0x59`, `0x5a..0x63`, `0x64..0x6d`, `0x6e..0x77`, `0x78..0x81`, and `0x82..0x8b`.
-- `dispatch_room_event_actions` supports:
-  - Conditional branching between alternate action tags
-  - Recursive dispatch of named action groups
-  - Queued room handoffs via `g_pending_room_name` / `room_setup`
-  - Palette and fade mode changes
-  - FST / cutscene style playback
-  - Sound and music loading / playback / stop operations
-  - Object, hotspot, overlay, and room actor visibility or active-state changes
-  - Combat/stat side effects such as damage-type selection and player stat increments
-  - Save-game handoff through the `SAVE_GAME` marker
-- Action dispatch ordering is confirmed for deferred text/movie chains:
-  - `SET_FLAG` mutates only the matching `FlagRecord.value`; it does not reread the current room palette, rebuild the room, or call a palette ramp by itself.
-  - `SET_REGION` mutates the matching `RegionRecord` and live region entity in place; enabling spawns a region entity and disabling removes the live entity. It does not rebuild the room or recompute the active palette.
-  - `SHOW_TEXT` creates a text entity over the existing render list and repeatedly calls `flush_dirty_rects_to_screen` / `update_music_stream` until input dismissal.
-  - `CHANGE_LIGHTING` opcode `0x22` owns live palette changes: `DIM` ramps the current room palette to `0.6`, `NORMAL` reloads the current room palette and ramps to `1.0`, `NONE` frees the current palette buffer and uploads black, and `FADE_IN` sets the deferred fade flag.
-  - `CHANGE_LIGHTING NONE` clears the palette/screen before a following `GOFLIC`, and `GOFLIC` immediately calls `play_fst_sequence` without an intermediate same-room `room_setup`.
-- `SET_TIMER` and `KILL_TIMER` first resolve a live class `0x17` timer entity by name. If no live timer entity is present, native prints `WARNING: Timer %s not in list` and does not update `g_timer_records`.
-  - When the live entity is present, the dispatcher toggles the entity enabled byte and mirrors that enabled state into the matching `TimerRecord`.
-  - The `SET_TIMER ... ON` branch only reseeds the live countdown when the live timer entity was disabled. Repeating `SET_TIMER ... ON` for an already-enabled global timer does not restart its countdown.
-
-### World Record Parsers
-
-- `register_world_record_parsers` at `0x60350` registers parser callbacks for the script/world record tags:
-  - `ANIM`
-  - `COMMAND`
-  - `ENTRANCE`
-  - `EXEC_LIST`
-  - `FLAG`
-  - `HEAD`
-  - `MAP_ENTRANCE`
-  - `MAP_LOCATION`
-  - `MONSTER`
-  - `NPC`
-  - `OBJECT`
-  - `REGION`
-  - `ROOM`
-  - `TEXT`
-  - `TIMER`
-  - `USEITEM`
-- The registered callbacks are now individually named in Ghidra:
-  - `parse_anim_record` at `0x5e120`
-  - `parse_command_record` at `0x5e410`
-  - `parse_entrance_record` at `0x5eb80`
-  - `parse_exec_list_record` at `0x5ecb0`
-  - `parse_flag_record` at `0x5ed80`
-  - `parse_head_record` at `0x5ee20`
-  - `parse_map_entrance_record` at `0x5ef20`
-  - `parse_map_location_record` at `0x5efb0`
-  - `parse_monster_record` at `0x5f080`
-  - `parse_npc_record` at `0x5f7e0`
-  - `parse_object_record` at `0x5fa40`
-  - `parse_region_record` at `0x5fd30`
-  - `parse_room_record` at `0x5fea0`
-  - `parse_text_record` at `0x600b0`
-  - `parse_timer_record` at `0x60160`
-- `parse_command_record` now has enough parser evidence to anchor both room-handoff and combat-control opcodes directly.
-  - `CHANGE_ROOM -> 0x02`
-  - `CLOSEUP -> 0x06`
-  - `EXIT_CLOSEUP -> 0x35`
-  - `KILL_NPC -> 0x0c`
-  - `KILL_PC -> 0x0d`
-  - `MONSTERFY -> 0x0e`
-  - `SET_TIMER -> 0x17`
-  - `KILL_TIMER -> 0x24`
-  - `PAUSE_PC -> 0x25`
-  - `RESUME_PC -> 0x26`
-- `parse_useitem_record` at `0x602c0`
-- This parser registry is the clearest anchor for the global linked-list heads used by `room_setup`, `dispatch_room_event_actions`, inventory handling, and dialogue.
-
-### Record Lists
-
-- `g_command_records` at `0xd5a94` is the `COMMAND` list consumed by `dispatch_room_event_actions`.
-- `g_room_entrances` at `0xd5a98` is the `ENTRANCE` list. Each record carries at least:
-  - Entrance tag name at `+0x14`
-  - Destination room name at `+0x10`
-  - Facing/orientation at `+0x0c`
-  - Next pointer at `+0x18`
-- `g_current_room_entrance` at `0xd5a9c` tracks the active entrance/spawn marker.
-- `g_exec_list_records` at `0xd5aa0` is the `EXEC_LIST` list.
-- `g_flag_records` at `0xd5aa4` is the `FLAG` list used by `get_flag_value` / `set_flag_value`.
-- `g_head_records` at `0xd5aa8` is the `HEAD` list used by the dialogue/head UI system.
-- `g_town_map_entrypoints` at `0xd5aac` is the `MAP_ENTRANCE` list.
-- `g_town_map_hotspots` at `0xd5ab0` is the `MAP_LOCATION` list used by the town map selector.
-- `g_monster_records` at `0xd5ab4` is the `MONSTER` list.
-- `g_npc_records` at `0xd5ab8` is the `NPC` list.
-- `g_object_records` at `0xd5abc` is the `OBJECT` list.
-- `g_region_records` at `0xd5ac0` is the `REGION` list.
-- `g_current_room_def` at `0xd5ac4` tracks the current `ROOM` record.
-- `g_room_definitions` at `0xd5ac8` is the `ROOM` list. Observed high-confidence fields:
-  - Enter/action tag at `+0x24`
-  - Room name at `+0x2c`
-  - Current music filename at `+0x30`
-  - Palette filename/resource at `+0x34`
-  - Next pointer at `+0x48`
-- `g_text_records` at `0xd5acc` is the `TEXT` list.
-- `g_timer_records` at `0xd5ad0` is the `TIMER` list.
-- `g_useitem_records` at `0xd5ad4` is the `USEITEM` list.
-  - `UseItemRecord.owner_or_group` is the parsed second quoted field.
-  - Native dispatch stores the second quoted field, but `run_inventory_screen` and `handle_target_interaction` do not compare it when resolving a use action; they match the carried item name and overlapped target entity name, then dispatch the action tag. This is required for CD3 foyer script rows such as `KEWPIE_KEY` with owner/group `FOYER` targeting `THEATRE_DOOR` in room `FOYER1`.
-  - The list is not limited to object hotspots. The decoded script contains three confirmed NPC-targeted entries: sheriff-office `INV_MAG -> LOOMIS -> GO_LOOMISA`, which sets `GAVE_MAG_TO_LOOMIS_TODAY` and immediately hides Loomis with `SET_NPC "LOOMIS" "F" "F"`, plus post-office Boyle entries for `BOYLES_BUTTON` and `GASCAN`.
-  - Native handler comparison shows the two Boyle branches are already covered directly inside `handle_talk_to_boyle`, while Loomis was the one script-side room-state gap that ScummVM had to mirror explicitly.
-- `g_pending_room_name` at `0xd60a4` is the pending next-room string consumed by the main loop.
-- `g_player_present_in_room` at `0xc3eb1` gates whether `room_setup` re-spawns the live player from `g_current_room_entrance` or instead injects the synthetic `EXIT_BM` / `EXIT_HS` closeup exits.
-- `g_game_session_active` at `0xc3f08` is the live gameplay-session gate shared by room setup, the save/load menus, and the game-over flow.
-  - `run_save_game_menu` returns immediately when it is clear.
-  - `run_load_game_menu` sets it before applying a slot.
-  - `run_game_over_screen` clears it until the main menu starts or loads a new session again.
-- `g_loading_saved_game` at `0xd607d` is the one-shot load-restore guard.
-  - `run_load_game_menu` sets it before returning to the room loop.
-  - `room_setup` consumes it to skip outgoing room-animation state capture, suppress room `on_enter` handlers, preserve the restored music path, and then clears the byte before returning.
-
-## Interface And Input
-
-### Menus And UI States
-
-- `run_main_menu` at `0x67390` is the main-title/menu state.
-  - It loads `main_menu_1` through `main_menu_6` strings from the text system.
-  - It builds the menu UI, highlights selections, and branches into startup options.
-- `run_load_game_menu` at `0x64910` is the load-game UI.
-  - It loads `loadgame.pal` and `loadgame.bm`.
-  - It enumerates `GAME_01` through `GAME_25` slots and reads slot names from the save file.
-  - When a slot is accepted it restores the save header fields that drive room handoff: `g_player_present_in_room`, `g_pending_room_name`, `g_player_room_name`, and the saved `SAVE_GAME` entrance position/facing.
-  - Load restore differs from ordinary room transitions for timers: `run_load_game_menu` calls `destroy_entity_list(&g_render_entity_list)`, then `free_loaded_world_data`; while freeing old `TimerRecord` nodes, `free_loaded_world_data` removes any preserved live global timer entity with the same name. The loaded slot then restores each `TIMER` record by name and only `room_setup` re-materializes timers whose `TimerRecord.arg1` matches the loaded room.
-- `run_save_game_menu` at `0x632c0` is the matching save-game UI.
-  - It loads `savegame.pal` and `savegame.bm`.
-  - It enumerates the same 25 save slots and supports text entry for save names.
-  - The active slot is derived from the list rectangle `x=0x08..0x27b`, `y=0x19..0x1c2` with a native row stride of `0x11`.
-  - Clicking a slot, pressing `Enter`, or clicking the lower-left action area `x=0x03..0x13d`, `y=0x1cc..0x1dc` all route into the same `run_text_entry_dialog` path for the active slot; `ESC` and the lower-right area `x=0x141..0x27b`, `y=0x1cc..0x1dc` cancel back to gameplay.
-  - The slot list uses the medium fonts rather than the main room-menu fonts: unselected `GAME_##` labels use `g_medfont2_cft`, the active slot label is respawned with `g_medfont1_cft`, and all slot-name text plus inline slot editing also use `g_medfont1_cft` at `x=0x50`.
-  - The `SAVEGAME.BM` background already carries the lower action-strip art; the native code does not spawn extra `SAVE` / `CANCEL` text entities over that strip.
-  - Entering slot-name edit mode spawns `TEXT_ENTRY` at `(0x50, activeSlot * 0x11 + 0x19)`, temporarily moves the mouse to `(0x14, 0x1ce)`, constrains mouse travel to the bottom confirm/cancel band, and then calls `run_text_entry_dialog` with hidden max length `0xf6`.
-  - It first calls `sync_current_room_runtime_state_before_save_load`, which copies current-room monster runtime positions/facing back into `MonsterRecord` fields and refreshes the `SAVE_GAME` entrance from `g_player_combat_avatar`.
-  - The save header is now explicit: current disc id, `g_player_present_in_room`, `g_game_clock_tick_remainder_ms`, `g_current_room_def->room_name`, and `g_player_room_name`.
-  - The remainder of the save blob persists the player combat-avatar state, record lists (`ANIM`, `FLAG`, `MONSTER`, `NPC`, `OBJECT`, `REGION`, `TIMER`), the talk-state blocks, ammo counters, and `g_current_music_path`; the timer portion writes each `timer_name`, `enabled`, and `current_value`.
-  - The dialogue-state write sizes are now explicit from the save/load callsites: Sergeant writes `0x28` bytes from `g_sergeant_talk_state_block @ 0xd2d4c`, Stephanie writes `0x4c` bytes from `g_stephanie_talk_state_block @ 0xd2e4c`, and the shared discussion / follow-up block writes `0x9c` bytes from `0xd2e98`, ending immediately before `g_ryder_talk_state_block @ 0xd2f34`.
-  - Native save/load also serializes the wider talk-state inventory in dedicated block groups after the `TIMER` records: Boyle, Dwayne, Loomis, Mom, Hank, Dad, Mr. Potts aux/main, Stephanie, Johnson, Swell (`g_serialized_talk_state_block_d2c30`), Parsons, Mcknight, Edna, Moynahan, Phelps, Herrill, Wasp Woman, Sparky, Fireman 1/2, Nude Man, Butcher, Ryder, Buster, Dark Woman, Memb Dir, Mother, Mrs. Potts, Inquisitor, the standalone `g_serialized_talk_state_flag_d2cd8 @ 0xd2cd8`, and the shared dialogue dword range `0xd2e98..0xd2f30`.
-  - The object-state portion of the slot format persists only `object_name`, `current_x`, `current_y`, `current_z`, `current_owner_or_room`, `visible`, and `ident_shown`; the other object heap strings are reloaded from world data rather than restored from the save blob.
-  - `g_player_room_name` at `0xd60a8` is serialized separately from `g_current_room_def->room_name` and tracks the room string currently assigned to the live player actor.
-- `run_text_entry_dialog` at `0x59fe0` is the shared modal text-entry widget used by the save menu, password prompt, and dialogue input.
-  - Callers pass the target text-entry entity and CFT font explicitly; the initial string and maximum length arrive through hidden register arguments in this build.
-  - Input polling uses `g_buffered_console_char` plus DOS console-status queries through `is_console_char_available`.
-
-### Map And Dialogue Helpers
-
-- `resolve_room_entrance` at `0x62d80` first searches `g_room_entrances` by entrance tag, then falls back to `g_town_map_entrypoints`.
-  - On direct entrance matches it updates `g_pending_room_name`.
-  - On map-entry matches it launches the town map selector and then updates the pending room / player position from the chosen destination.
-- `MapEntranceRecord` is now stable enough for engine use:
-  - `map_x`
-  - `map_y`
-  - `initial_panel_index`
-  - `entry_name`
-  - `next`
-  - The selector currently only reads `initial_panel_index`; `map_x` / `map_y` remain stored script coordinates with no recovered read-side consumer in the current binary.
-- `select_town_map_destination` at `0x66460` is the town-map travel UI.
-  - It loads `harvmap.pal` and `harvmap1.bm` through `harvmap4.bm`.
-  - It checks `DAY_FLAG` before both map fade points; night travel uses the same dim target as room setup, `1.0 + (-0.4) = 0.6`, instead of the daytime full-bright palette.
-  - It seeds the initial map panel from `g_town_map_entrypoints`.
-  - It uses `g_town_map_hotspots` to resolve the selected destination back into a named entrance record.
-- `MapLocationRecord` is now stable enough for engine use:
-  - `min_x`
-  - `min_y`
-  - `max_x`
-  - `max_y`
-  - `panel_index`
-  - `label_x`
-  - `label_y`
-  - `label_text`
-  - `destination_entrance`
-  - `next`
-- `get_flag_value` at `0x7c790` and `set_flag_value` at `0x7c7e0` are the core `FLAG` accessors used throughout startup, room setup, and event dispatch.
-- `run_npc_dialogue` at `0x79e50` is the conversation/head UI for named NPCs.
-  - It validates the NPC through `g_npc_records`.
-  - It refuses NPC records whose persisted `death_damage_type` is already non-zero, so a `KILL_NPC` / `MONSTERFY` transition removes that NPC from the talk path as soon as the pending death type is latched, even before the live class-4 death bank finishes.
-  - It uses `g_head_records` to resolve portrait variants and keyword/head assets.
-  - It drives the textbox / keyword / voice interaction loop until the exchange completes or is interrupted.
-  - Before dispatching the per-NPC talk handler, it clears `g_dialogue_keyword_list_spec` and `g_dialogue_selected_topic_text`, spawns `g_dialogue_keyword_panel_entity` plus `g_dialogue_textbox1_entity` through `g_dialogue_textbox8_entity`, preloads the right PC portrait from the first matching `PC0`..`PC5` `HEAD` record, and preloads the left NPC portrait from `<NPC>0`.
-  - `set_dialogue_head_left_portrait` at `0x79d20` is the left-side portrait refresh helper on that path.
-    - It rebuilds the shared `g_dialogue_head_bitmap_path`, tears down any prior `g_dialogue_head_left_entity`, resolves the requested `HeadRecord.head_id`, and respawns the left portrait bitmap entity at `(6, 6)` before re-adding it to `g_render_entity_list`.
-  - The shared conversation globals on this path are now explicit:
-    - `g_dialogue_presented_object_name` is the optional inventory/evidence object name passed in from the click/use path.
-    - `g_dialogue_keyword_menu_default_topic` is the default keyword-menu topic loaded from zero-based `dialog.rsp` line `13`.
-    - `g_dialogue_skip_full_ui_flag` is the small `play_dialogue_line` bypass flag that the menu paths clear before standard portrait/textbox presentation.
-    - `g_dialogue_head_id_slots` at `0xd96c0` is the five-slot `0x1f`-byte head-ID cache used to reuse portrait resources during a conversation.
-  - `g_talk_to_handler_entries` at `0xc2e88` is the fixed pre-dialogue talk-to dispatch table used by this path.
-    - Each record is `0x24` bytes: a 32-byte NPC/object name token followed by a handler function pointer at `+0x20`.
-    - `has_talk_to_handler_for_name` at `0x3a160` linearly scans that table until the empty-name terminator and is used by `run_harvester_main_loop`, `run_inventory_screen`, and `handle_target_interaction` to decide whether a target can enter NPC dialogue.
-    - `dispatch_talk_to_handler_for_name` at `0x39f80` formats `talk_to(%s)` into the shared scratch buffer, finds the matching table entry by name, and calls the per-entry handler before `run_npc_dialogue` continues with the portrait/text loop.
-    - The indirect table targets are now explicit talk-to handlers, including `handle_talk_to_authority`, `handle_talk_to_beggar`, `handle_talk_to_boyle`, `handle_talk_to_buster`, `handle_talk_to_butcher`, `handle_talk_to_dwayne`, `handle_talk_to_karin`, `handle_talk_to_mom`, `handle_talk_to_mr_potts`, `handle_talk_to_mrs_potts`, and the remaining named NPC variants exposed by the same table.
-    - Direct table inspection also recovered handler starts that Ghidra had left undefined:
-      - `handle_talk_to_mr_potts` at `0x26000` is the shared target for `MR_POTTS`, `MR_POTTS_CEM10`, and `MR_POTTS_HALL`.
-      - `handle_talk_to_mrs_potts` at `0x2ee70` is the shared target for `MRS_POTTS` and `MRS_POTTS_ST_BEDRM`.
-      - Additional table targets that were still undefined are now explicit handlers: `handle_talk_to_whaley`, `handle_talk_to_moynahan`, `handle_talk_to_swell`, `handle_talk_to_priest`, `handle_talk_to_phelps`, `handle_talk_to_nude_man`, `handle_talk_to_valet`, `handle_talk_to_vet`, `handle_talk_to_wasp_woman`, `handle_talk_to_sergeant`, `handle_talk_to_pta_mom`, `handle_talk_to_stephanie`, `handle_talk_to_pastorelli`, `handle_talk_to_ryder`, `handle_talk_to_sparky`, and `handle_talk_to_parsons`.
-    - Shared-address alias groups now confirmed from the table include `MOYNAHAN` / `MOYNAHAN_MBLM`, `PTA_MOM1` through `PTA_MOM5`, and `RYDER` / `RYDER_TV_OUT`.
-    - The apparent `FUN_0002f7fd` helper is only an overlapping inner keyword-loop region inside `handle_talk_to_mrs_potts`, not the dispatch-table target itself.
-- `run_harvester_main_loop` only exposes the talk cursor / click path for runtime class-4 NPC entities when `has_talk_to_handler_for_name` succeeds and the NPC state byte at `+0x11a0` is not the dead-state value `'8'`.
-- `load_dialogue_index` at `0x79a20` is the dialogue subtitle/keyword bootstrap.
-  - It copies zero-based `dialog.rsp` line `0x0d` into `g_dialogue_keyword_menu_default_topic`, then opens `dialogue.idx` through the XFILE layer, XOR-decodes it, and builds the sorted `g_dialogue_index_entries` table of `{ wav_id, text_offset, text_length }` records.
-  - Because `dialogue.idx` is opened without a `1:\` / `2:\` set prefix, `open_xfile_stream` routes it through the direct-file path rather than the DAT archives.
-- `play_dialogue_line` at `0x7a690` is the single-line voice/subtitle presenter used by the talk handlers.
-  - It formats the voice sample path as `<VOICE config><wavId>.CMP`, loads that direct `CMP` file if present, seeks `g_dialogue_index_stream` to the matching `dialogue.idx` text span, XOR-decodes the subtitle text, selects the textbox art by wrapped-line count, refreshes the active portrait/head slot, and blocks until the line completes or is interrupted.
-  - `load_dialogue_voice_sample` routes those `.CMP` loads back through `open_xfile_stream`, so numbered set paths can resolve through the `INDEX.00N` XFILE catalogs while bare relative voice paths still fall through to direct CD-ROM file access.
-  - Raw call-site tracing now confirms the argument registers used by the talk handlers: `EAX = wav id`, `EDX = speaker/head id string`, and `EBX = portrait variant`.
-  - The per-NPC talk handlers do not embed literal subtitle or keyword text in code. They hardcode WAV ids, head-id strings / variants, and zero-based `dialog.rsp` line indices; the human-readable subtitle and keyword text is loaded from `dialogue.idx` and `dialog.rsp` at runtime.
-  - The dialogue resources are not a declarative conversation tree: `dialog.rsp` supplies menu/topic text, `dialogue.idx` supplies subtitle spans keyed by WAV id, and `INDEX.00N` only catalogs archive-backed file entries for XFILE lookup. The actual topic branching, state gates, and follow-up rewrites stay compiled inside the per-NPC talk handlers reached from `g_talk_to_handler_entries`.
-- `load_dialogue_response_line` at `0x3a1a0` is the indexed `dialog.rsp` line loader used by the talk-to handlers.
-  - It opens the ASCII `dialog.rsp` file, reads forward to the requested zero-based line index, strips the trailing CR/LF, and returns the shared `g_dialogue_response_line_buffer`.
-  - Handlers such as `handle_talk_to_dwayne`, `handle_talk_to_hank`, and `handle_talk_to_mom` use it to map keyword-menu selections back to concrete topic strings before branching.
-  - `handle_talk_to_hank` has hidden post-`play_dialogue_line` blocks that immediately use this loader with `run_dialogue_response_menu`: after Hank's opening `0x703` line it shows zero-based `dialog.rsp` line `0xc7` (`1. Who are you?/2. Who am I?/3. Where am I?`) and seeds `g_dialogue_keyword_list_spec` from lines `0xc8`, `0xc9`, or `0xca` based on the selected slot.
-  - The same Hank handler rewrites `g_dialogue_keyword_list_spec` from zero-based `dialog.rsp` lines `0xd5`, `0xd7`, `0xda`, `0xde`, `0xdf`, `0xe2`, `0xe4`, `0xe8`, and `0xec` inside its hidden topic tails, which confirms the keyword buffer is live dialogue state rather than a static per-NPC list.
-  - Hank's hidden topic map is now explicit: the multi-line `Mom` branch (`0x725` through `0x747`) seeds `0xd5`; `STEVE` (`0xd6`) plays `0x74f` / `0x753` / `0x758` / `0x75d` / `0x761` / `0x765` then seeds `0xd7`; `LOUSY RAT` / `RAT` (`0xd8` / `0xd9`) plays `0x76d` / `0x771` then seeds `0xda`; the `'LIGHTING OUT'` branch (`0xdb` / `0xdc`) plays `0x77b` / `0x77f` / `0x784` / `0x788` / `0x78e` / `0x793`, opens response menu `0xdd`, and then seeds `0xde` or `0xdf`; `DADDY` (`0xe1`) seeds `0xe2`; `GIRL` (`0xe3`) seeds `0xe4`; `TV` (`0xe6`) wraps `play_rangshot_fst_sequence` between `0x7fc`..`0x809` and `0x812`..`0x824`, then seeds `0xe8`; `COWBOY SHOW` (`0xe9`) plays `0x82d`..`0x84c` and opens response menu `0xea`; `SICK` (`0xeb`) seeds `0xec`; the `newspaper` / `fire` / `sentinel` group (`0xee` / `0xef` / `0xf0`) continues through `0x8a0` / `0x8a5` / `0x8a9` / `0x8af` / `0x8b3`; and `lodge` (`0xf1`) sets `get_set_discussed_lodge_topic` before `0x8bf` / `0x8c5` / `0x8c9` / `0x8cf` / `0x8d3`.
-  - `handle_talk_to_valet @ 0x2eda0` is a fixed response-menu exchange rather than a single bark. It always plays `VALET 0xf2c` (head variant `1`), `PC 0xf31`, `VALET 0xf35`, `PC 0xf39`, inserts `play_fst_sequence("GRAPHIC/FST/MIDGET02.FST", 0)`, opens zero-based `dialog.rsp` line `0x2fe`, branches to `VALET 0xf4a` (variant `0`) or `VALET 0xf50` (variant `2`), and then always closes on `VALET 0xf56`.
-  - `handle_talk_to_ryder @ 0x38c80` is a full stateful topic/menu handler, not a single-line bark. The first no-item visit plays `0x477`, `C063A.FST`, `0x485`, `PC 0x489`, `C063B.FST`, `0x49b`, and response menu `0x2a1`, optionally granting `AUTOGRAPH`; later no-item visits fall back to `0x4cf` before the shared topic loop. The handler also has a one-shot `KARIN_KIDNAPED` tail (`0x56d`, `C063D.FST`, `0x57f`, response line `0x2a3`, then either `0x589` / `0x592` / `C063E.FST` or `0x58e` / `0x4a5` plus `AUTOGRAPH`), a keyword loop seeded from response line `0x2a5`, a `0x2a7` / `0x2a8` interview branch wrapped around `C101.FST`, and a burned-TV override that ignores presented items and runs `RYDER_TV_OUT 0x5a6` (variant `3`), optional `PC 0x5ab` (variant `4`), then `RYDER_TV_OUT 0x5af` (variant `3`).
-  - Raw disassembly inside `handle_talk_to_mom` shows additional inline `load_dialogue_response_line` -> copy-to-`g_dialogue_keyword_list_spec` blocks that the initial decompiler pass did not surface cleanly. The pre-menu seeds are now confirmed directly in code: intro `0x102`, same-day follow-up `0x104`, later default `0x105`, day-5 override `0x113`, and day-6 override `0x114`.
-  - The same hidden Mom blocks also jump back to `run_dialogue_keyword_menu`, so her keyword flow is a looping dialogue state machine rather than a single-topic exchange. Confirmed keyword-buffer rewrites are `0x116->0x118`, `0x119->0x11a`, `0x11d->0x120`, `0x121->0x122`, `0x123->0x124`, `0x125..0x128->0x12a`, `0x12b->0x12c`, `0x12d->0x12e`, `0x12f->0x130`, `0x131..0x133->0x134`, `0x135->0x136`, `0x13a->0x13b`, `0x13c->0x13e`, `0x13f->0x140`, the non-`STEPH_MIDGAME_PLAYED` `0x141` branch -> `0x144`, `0x145..0x147->0x148`, `0x14b..0x14c->0x150`, `0x151..0x152` response-menu choice `2` -> `0x154`, and `0x15b->0x15c`.
-  - Raw byte disassembly also recovered the hidden straight-line `play_dialogue_line` sequences that the earlier function listing truncated after the first visible call. Confirmed multi-line Mom exchanges now include `0x116`, `0x119`, `0x11d`, `0x121`, `0x125..0x128`, `0x12b`, `0x12d`, `0x12f`, `0x131..0x133`, `0x135`, `0x137`, `0x145..0x147`, `0x14a`, `0x155`, and `0x156`.
-  - Mom `0x11d` is the hidden `PC`/`MOM` exchange `0x1e7c` / `0x1e80`, `C008A.FST`, `0x1e8e` / `0x1e94`, `dispatch_action_tag_if_set("BABY_GURGLE")`, `0x1e9d` / `0x1ea3`, `C008B.FST`, then keyword-buffer rewrite `0x120`.
-  - Mom `0x121` is the four-line `PC` / `MOM` / `PC` / `MOM` exchange `0x1ec0` / `0x1ec4` / `0x1ec9` / `0x1ecd`, then keyword-buffer rewrite `0x122`.
-  - Two hidden Mom branches use `run_dialogue_response_menu` rather than a pure keyword-buffer rewrite:
-    - `0x141` first calls `get_set_discussed_lodge_topic(0, 1)`, plays `0x1fe1` / `0x1fe5` / `0x1feb` / `0x1fef`, then shows zero-based `dialog.rsp` line `0x142`; response `1` plays `0x1ffe`, response `2` plays `0x2005` / `0x200a` / `0x200e`, and the branch then rewrites the keyword buffer to `0x144`.
-    - When `STEPH_MIDGAME_PLAYED` is already set, that same `0x141` topic instead plays `0x2154` / `0x215a` and loops on the current keyword buffer without rewriting it.
-    - `0x151..0x152` plays `0x20f7` / `0x20fb`, then shows zero-based `dialog.rsp` line `0x153`; response `1` plays `0x2106` / `0x210a` / `0x210e` and loops on the current keyword buffer, while response `2` plays `0x2114` / `0x2119` / `0x211d` and rewrites the buffer to `0x154`.
-  - Hidden `handle_talk_to_mr_potts` blocks confirm the native response-menu text format more precisely than the earlier engine port:
-    - Slash characters are explicit row breaks inside a menu entry, not generic option separators.
-    - Numeric prefixes (`1.`, `2.`, ...) start a new selectable response.
-    - The accusation branch builds a runtime menu string by taking zero-based `dialog.rsp` line `0x20f` and appending hardcoded accusation text fragments before routing the result back through the same response-menu routine.
-  - `handle_talk_to_mr_potts @ 0x26000` is now bounded enough for a direct startup-room port despite the truncated primary function stub.
-    - The auxiliary intro is the only place that reseeds Mr. Potts's keyword buffer, writing zero-based `dialog.rsp` lines `0x1d8`, `0x1d9`, `0x1da`, or `0x1db` depending on the selected response and whether Stephanie is already dead.
-    - That keyword buffer persists outside `reset_talk_to_mr_potts_aux_state`; later visits reuse it through the hidden loop rooted at `0x26fc8`, which clears the selected topic text and re-enters `run_dialogue_keyword_menu`.
-    - The hidden day-6 Karin-found-alive continuation does not end on `0x2bd1`. When the `0x1e3` response-2 path reaches `0x2bd1` with `KARIN_FOUND_ALIVE` set and the local follow-up still clear, Mr. Potts opens zero-based `dialog.rsp` line `0x1e4`: response `1` continues through `0x2be9` / `0x2bf2` / `0x2bf7` / `0x2bfe`, sets `get_set_discussed_mr_potts_tuesday_night_alibi`, then branches through `0x1e5` or `0x1e6` based on `get_set_moynahan_karin_kidnaped_discussion_state`, while response `2` exits on `0x2c2c`.
-    - On that nested Mr. Potts day-6 follow-up, zero-based `dialog.rsp[0x1e5]` / `dialog.rsp[0x1e6]` choice `1` plays `0x2c15` when the Moynahan Karin-kidnaped discussion bit is already set or `0x2c1a` / `0x2c20` when it is not; choice `2` instead plays `0x2c24`.
-    - The hidden keyword loop now has confirmed branches for `AMNESIA`, `MARRIAGE`, `COMMITMENT`, `FATHER`, `SLAUGHTERHOUSE`, `MEAT`, `STEPHANIE`, `LODGE`, `NEWSPAPER`, `MOYNAHAN`, the peephole aliases `0x204..0x20a`, the murder aliases `0x20c..0x20e`, `TOILET`, the molest/pervert aliases `0x216..0x218`, the later `BYE` topic `0x21c`, and the generic fallback line `0x2c73`.
-  - `handle_talk_to_pta_mom @ 0x34e30` is now constrained to a compact one-screen exchange.
-    - If `PTA_RESPOND_TO_TV` is set, it plays `0x3233` with `PTA_MOM1` head variant `2`, clears that flag, and returns without opening the response menu.
-    - Otherwise it chooses one of three random opener lines, `0x31ee`, `0x31f2`, or `0x31f6`, all spoken as `PTA_MOM1` with head variant `1`, then opens zero-based `dialog.rsp` line `0x297`.
-    - Response `1` plays `0x3204` as `PTA_MOM1` with head variant `2`, then `0x320b` as `PTA_MOM1` with head variant `0`.
-    - Response `2` plays `0x320f` then `0x3213`, both as `PTA_MOM1` with head variant `0`.
-    - Response `3` plays `0x3217`, `0x321c`, `0x3221`, and `0x3226`, where `0x3221` is the only `PC` line and the others stay on `PTA_MOM1` head variant `0`.
-  - `handle_talk_to_nude_man @ 0x2d9b0` is now bounded enough for a direct startup-room port.
-    - The broken one-byte Ghidra stub was reconstructed to the real `0x2d9b0-0x2db3e` body before comparing flow.
-    - The first no-item visit is not a single bark. It plays `0x606` / `0x60a` / `0x60e` / `0x612` / `0x618` / `0x61c`, then opens zero-based `dialog.rsp` line `0x25d`.
-    - On that first `0x25d` menu, response `1` plays `NUDE_MAN 0x628` with head variant `1`; if `get_set_sergeant_completed_first_task_state` is already set, the handler also plays `0x62c`, opens zero-based `dialog.rsp` line `0x25e`, and answers with `0x636` or `0x63f`. Response `2` instead plays `NUDE_MAN 0x648` with head variant `1`.
-    - Later no-item revisits are also multi-line: they play `PC 0x651`, `NUDE_MAN 0x655`, `PC 0x3063`, then `NUDE_MAN 0x628`. `DAY_FLAG` still short-circuits the whole handler to `NUDE_MAN 0x65c`.
-  - `handle_talk_to_karin @ 0x2ea90` is now bounded enough for a direct startup-room port.
-    - `KARIN_FOUND_ALIVE` plus `IN_CEM10` drives an early-return one-shot sequence: `PC 0x10a5[4]`, `KARIN 0x10a9[3]`, `PC 0x10ae[2]`, `KARIN 0x10b2[3]`, `PC 0x10b6[0]`, `KARIN 0x10ba[3]`, `PC 0x10bf[0]`, `KARIN 0x10c3[3]`, then the handler returns without falling through.
-    - The photo evidence branch is compact and local-state driven: `CASKET_PHOTO`, `CASKET_PHOTOCOPY`, and `PHOTO_OF_WHALEY_HERRILL` all answer with `KARIN 0x10e3[4]`, but the first presentation also writes the shared corpse-photo or Whaley/Herrill-photo dialogue state, continues through `EDNA 0x10e7[2]`, restores Karin's portrait, and sets the local override bit. Later photo presentations skip Edna, set `DNALFT_PERVERT`, and dispatch `CALL_POLICE`.
-    - `INV_MAG` is the one other special item branch: it plays `KARIN 0x10ee`, then `EDNA 0x10f2[2]`, sets `DNALFT_PERVERT`, and dispatches `CALL_POLICE`. All other presented items fall back to `KARIN 0x10d6[1]`.
-    - The no-item tail is linear but multi-line on first contact: before Karin is found alive, the first visit plays `PC 0x1076[1]`, `KARIN 0x107a[1]`, `PC 0x107e[1]`, `KARIN 0x1082[1]`, `PC 0x1087[0]`, `KARIN 0x108b[1]`, then always ends on `KARIN 0x10dc[0]`.
-  - `handle_talk_to_dwayne @ 0x3a310` is now bounded enough to mirror directly in the engine-side room dispatcher.
-    - The top-level no-item flow splits between the sheriff-in-diner intro (`0x3482` / `0x3486` / `0x348b` / `0x3490`, optional `EDNA 0x3496` when `KARIN_KIDNAPED`, then `0x349b` / `0x34ac`) and the outside-sheriff opener (`0x3245` / `0x324a` / `0x324e` / `0x3252`), which seeds the first keyword buffer from zero-based `dialog.rsp` line `0x78`.
-    - The arrest path clears `ARREST_FLAG` and then dispatches a strict priority bark chain over the `PC_BUSTED_KILLED_*` flags, `IF_KILL_POTTSDAM_AT_GRAVE`, `GENERIC_BUST`, `DNALFT_PERVERT`, the `PC_HAS_GOOJF_CARD` special case, and the escalating `BUSTED_ONCE` / `BUSTED_TWICE` / `BUSTED_THIRD` lines.
-    - The item path now separates Dwayne-local evidence state from shared evidence state: `BOYLES_BUTTON` and `K_PURSE` set Dwayne-local follow-up bits, while `LEDGER` / `LEDGER2`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY`, and `PHOTO_OF_WHALEY_HERRILL` write the same shared `SHOWN_*` evidence flags used by the other startup-room handlers.
-    - `CHECKBOOK` / `NOTE` is a two-step response branch: `0x33f4`, zero-based `dialog.rsp` line `0x70`, then `0x3407` / `0x340d`, followed by zero-based `dialog.rsp` line `0x71`, with the direct override exit at `0x342c`.
-    - The delayed Karin-alive follow-up is also confirmed: `BRING_KARIN_TO_SHERIFF` or `KARIN_FOUND_ALIVE` sets the pending Dwayne-local day gate, and when the story day changes the handler plays `0x3780` / `0x3786` / `0x378a`, clears the pending bit, and sets the shared completion helper that Stephanie later reads.
-    - The looping keyword branch rewrites the live topic buffer through confirmed `dialog.rsp` transitions `0x83->0x84`, `0x85..0x87->0x89`, `0x8c..0x8d->0x8e`, `0x8f->0x90`, `0x91->0x92`, `0x93->0x94`, `0x95->0x97`, `0x98->0x99`, `0x9a->0x9b`, and `0x9c->0x9d`; `0x82` exits with `0x3395`, while `0x9e` loops back to the menu without changing the buffer.
-  - `handle_talk_to_edna @ 0x358a0` is now bounded enough for a direct startup-room port.
-    - `DNA_S_SUICIDE_NOTE` is an early no-dialogue branch: it clears the flag, plays `C043K.FST` when `KARIN_FOUND_ALIVE` is already set or `C043.FST` otherwise, writes two still-unresolved shared talk-state bits at `0xd2ea4` / `0xd2ea8`, and reveals `SIGNOUT` in `DNAEXT`.
-    - `BRING_KARIN_TO_SHERIFF` is a one-shot no-item reward branch. It plays `0x3cce` / `0x3cd2` / `0x3cd7`, adds `REWARD_MONEY` to inventory, and clears `BRING_KARIN_TO_SHERIFF` before the normal Edna flow resumes on later visits.
-    - The primary intro gate is `g_edna_talk_state_block`: on the first normal no-item visit Edna plays `0x3a95`, opens zero-based `dialog.rsp` line `0x9f`, then response `1` plays `0x3aa1` / `0x3aa6` / `0x3aaa`, sets `get_set_shared_dialogue_state_d2f04`, and continues with `0x3aae` / `0x3ac6`, while response `2` plays `0x3ab2` / `0x3ab7` / `0x3abb` / `0x3ac2` / `0x3ac6`.
-    - The item path is linear and evidence-driven: `K_PURSE` plays `0x3d05` and sets the one-shot purse bit, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` and the dual-ledger case share `0x3bd6`, `PHOTO_OF_WHALEY_HERRILL` plays `0x3bde`, note/checkbook evidence plays `0x3be5`, TV deed evidence plays `0x3bee`, and all other presented objects fall back to `0x3bcf`.
-    - The steady-state no-item tail is also simple: if `KARIN_KIDNAPED` is still clear Edna plays `0x3bf8`, then always ends the branch on `0x3bff`.
-  - `handle_talk_to_herrill @ 0x2dc00` is now bounded enough for a direct startup-room port.
-    - The early lodge branch is a self-contained cutscene gate: `HERRILL_IN_LODGE` plus a clear `PC_TALKED_TO_HERRILL` flag plays `HERRILL_LOG 0x31ca`, `PC 0x31ce[4]`, `HERRILL_LOG 0x31d2`, `PC 0x31da`, and `GRAND_MUCK 0x31de[2]`, conditionally inserts `GRAND_MUCK 0x31e0[2]` plus `HERRILL_LOG 0x31e2[2]` when the shared Whaley/Herrill-photo flag is already set, then monsterfies `GRAND_MUCK`, sets `PC_TALKED_TO_HERRILL`, and returns. The normal room path only runs when that lodge gate is inactive and still begins with Herrill-local suppression / evidence-required checks.
-    - The Inner Sanctum post-Muckey path has one native-only actor-state behavior outside the parsed room script: `HARVEST.SCR` skips the commented `{COMMAND "KILL_HERR2" ...}` line, but `update_actor_runtime_state` contains a hard-coded `HERRILL_LOG` removal path while Muckey combat state is active. ScummVM queues Herrill's live NPC death when `MUCKEY` completes death and triggers `ACTV_INNERSAN_DOOR`, giving the script's `KILL_HERMUCK_T` delay time to play the death bank before the `C002A`/`C002B` flic chain.
-    - The item path is mostly the shared evidence cluster, but not every branch writes the same shared state: `PHOTO_OF_WHALEY_HERRILL` is only the dedicated `0x309f` bark; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` and the dual-ledger case share `0x30a6` and route through the shared corpse-photo / ledger evidence helpers; note/checkbook evidence plays `0x30bf`; TV deed evidence plays `0x3188`; and all other presented objects fall back to `0x3099`.
-    - `GASCAN` is the one special item branch. It first plays the fixed `0x30cf` / `0x30d3` / `0x30d8` exchange, then reads neutral shared talk-state bits at `0xd2e98`, `0xd2eb0`, and `0xd2eec` plus the shared Boyle gas-can application bit. When `0xd2e98` is clear the branch collapses to `PC 0x3123`; otherwise it opens zero-based `dialog.rsp` line `0x280`, where response `1` continues through `0x30e7` and the optional `0x30eb` / `0x30f4` / `0x3104` inserts before the `HAVE_LODGE_APP` split, while response `2` exits on `0x311b`.
-    - The first no-item visit is gated by `g_herrill_talk_state_block`: it plays `0x2f59` / `0x2f5e` / `0x2f62` / `0x1654` / `0x2f6e` and seeds the keyword buffer from zero-based `dialog.rsp` line `0x281`. Later no-item visits start on `0x2ffb`.
-    - The steady-state no-item followups are a linear one-shot bark chain over `STEPH_MIDGAME_PLAYED`, story day `5`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `KARIN_KIDNAPED`, `KARIN_FOUND_ALIVE`, `KARIN_FOUND_DEAD`, `DINER_BURNED`, and the shared Whaley/Herrill-photo evidence flag. `KARIN_FOUND_ALIVE` is only `0x304c[1]`; `KARIN_FOUND_DEAD` is `0x305e`, optional `0x3067[3]` behind `STEPH_MIDGAME_PLAYED`, then `0x306b[2]` or `0x3073[3]` depending on the shared photo flag.
-    - Herrill's keyword loop now has a clean branch map: response lines `0x283` and `0x293` are both `BYE` exits on `0x2ff4`; `0x284` / `0x286` / `0x288` / `0x28a` / `0x28c` / `0x291` each play a single line and loop; `0x28e` and `0x28f` both set `get_set_discussed_lodge_topic` before `0x2fdd`; and unmatched typed text falls back through `0x3099` then `0x2ff4`.
-  - `handle_talk_to_johnson @ 0x27dd0` is now bounded enough for a direct startup-room port.
-    - Johnson has three early short-circuit branches before the normal room flow: `2ND_SCRATCH_TUCKER` returns on `0xbe6`, `TRY_TO_SCRATCH_TUCKER` is a one-shot `0xbe0` bark keyed by the local flag at `0xd2bf8`, and `OPENING_MANHOLE_DAYTIME` plays `0xbe0`, clears that runtime flag, and returns.
-    - The item path is another shared-evidence cluster, but several branches are multi-line rather than single barks: `PHOTO_OF_WHALEY_HERRILL` plays `0xb75` / `PC 0xb7b` / `0xb80`; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` always plays `0xb89`, and when shared state `d2ebc` is set it continues through `PC 0xb90` / `0xb94` / `0xb99`; the dual-ledger case plays `0xba0` / `PC 0xba4` / `0xba8`; note/checkbook evidence plays `0xbb1`; TV deed evidence plays `0xbce`; and all other presented objects fall back to `0xb6f`.
-    - The main no-item gate is the Johnson-local intro bit at `0xd2bf4`. When set, Johnson plays `0xa86` / `0xa8a` / `0xa8e` / `0xa97` / `0xa9b`, sets `get_set_shared_dialogue_state_d2f04`, and seeds the keyword buffer from zero-based `dialog.rsp` line `0x176`; otherwise the steady revisit entry is `0xb62`.
-    - `SCRATCHED_TUCKER` has its own one-shot re-entry hook: the first time that flag is seen, Johnson sets the intro gate back on, plays `0xc25` / `0xc2b` / `0xc30`, and then continues into the later one-shot bark chain without replaying the intro on that same visit. Inside that path only, `BOLT_OF_CLOTH_TAKEN` has its own Johnson-local gate at `0xd2c04` for the additional `0xc5f` / `0xc69` pair.
-    - The steady-state no-item bark chain is fully linear and one-shot gated: `STEPH_MIDGAME_PLAYED` -> `0xbf0` / `0xbf1` / `0xbf2`, `KARIN_KIDNAPED` -> `0xbff` / `0xc06` plus a write to `get_set_shared_karin_kidnaped_dialogue_state`, `KARIN_FOUND_ALIVE` -> `0xc13` / `0xc17`, `BARBER_POLE_STOLEN` -> `0xc46` / `0xc4d` / `0xc59`, `DINER_BURNED` -> `0xc6f`, `PC_ESCAPED_JAIL` -> `0xc77`, `GOT_REMAINS_FOR_LODGE` -> `0xc7f`, and `BURNED_TV_STATION` -> `0xc8f`.
-    - Johnson's keyword loop now has a stable branch map: `0x177` exits on `0xbda`, `0x178` / `0x179` play `0xaa6`, open zero-based `dialog.rsp` line `0x17a`, then continue through `0xabd` / `0xac3` / `0xac7` and rewrite the keyword buffer to `0x17b`; `0x17c` plays `0xb17` / `0xb1c` / `0xb20` and rewrites to `0x17d`; `0x17e` plays `0xb2d` and rewrites to `0x17f`; `0x180` / `0x181` / `0x182` play `0xb3a` and rewrite to `0x183`; `0x184` / `0x185` play `0xb46` and rewrite to `0x186`; `0x187` continues through `PC 0xb52` and `JOHNSON 0xb56`, then rewrites to `0x188`; `0x189` loops silently on the current buffer; fallback still uses `0xb69` then `0xbda`.
-  - `handle_talk_to_jimmy @ 0x28880` is a compact short-circuit handler with no keyword loop.
-    - The first no-item visit clears `g_jimmy_first_no_item_line_pending`, conditionally plays `0x4a4c` / `PC 0x4a52` if `g_jimmy_talk_state_block` is still clear, then always continues through `0x4a58`, sets `PAPER_CHK_1` and `GIVEN_PAPER_TODAY`, and opens three response menus from zero-based `dialog.rsp` lines `0xf5`, `0xf6`, and `0xf7`.
-    - Jimmy's hidden first-meeting response map is now explicit:
-      - `0xf5` (`1. I've got better things to do./2. I'm supposed to?  I don't remember.`) selects `0x4a63` or `0x4a67`, then always continues through the fixed exchange `0x4a6b` / `PC 0x4a70` / `0x4a74` / `0x4a7a`.
-      - `0xf6` (`1. Yes, I do./2. Nope, sorry.`) sends response `1` into `0x4a86` / `PC 0x4a8a` / `0x4a8e` plus the third response menu `0xf7`, while response `2` exits that branch on `0x4abc`.
-      - `0xf7` (`1. Okay... take the sneakers./2. Forget it... what do I care about what they're doing?`) checks live inventory `SNEAKERS`: response `1` with sneakers plays `0x4a9e`, makes `SNEAKERS` visible for `RAH`, and adds `BROOMKEY` to inventory; response `1` without sneakers plays `0x4aa6` / `PC 0x4aaa` / `0x4aae`; response `2` plays `0x4ab4`.
-    - Presenting `NEWSPAPER` sets `GIVEN_PAPER_TODAY`, runs the `ACTV_HOUSE_EXIT` action tag, restores `NEWSPAPER` to its startup state, clears the first-no-item gate, sets `PAPER_CHK_1`, and on the first paper handoff plays `0x4a4c` / `PC 0x4a52` before the random revisit bark `0x4acc` / `0x4a4b`.
-    - The evidence-item path is otherwise linear: `PHOTO_OF_WHALEY_HERRILL` plays `0x4af8`; `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` and `CASKET_PHOTO` / `CASKET_PHOTOCOPY` share the seven-line exchange `0x4b00` / `PC 0x4b04` / `0x4b08` / `PC 0x4b0c` / `0x4b10` / `PC 0x4b15` / `0x4b19`; note/checkbook evidence plays `0x4b21` / `PC 0x4b25` / `0x4b29` / `PC 0x4b2d` / `0x4b31`; direct `SNEAKERS` use plays `0x4a9e`, returns the sneakers to `RAH`, and awards `BROOMKEY`; all other items fall back to `0x4af2`.
-    - Later no-item visits first check live inventory `SNEAKERS` without `BROOMKEY`: that path plays `0x4ac3`, opens zero-based `dialog.rsp` line `0xf8`, and reuses the same `0x4a86` / `0x4a8a` / `0x4a8e` plus `0xf7` sneakers-trade flow before the bark chain continues through `PAPER_CHK_4`, `PAPER_CHK_3`, `PAPER_CHK_2`, and the default `0x4b38`.
-  - `handle_talk_to_moynahan @ 0x24a20` is now bounded enough for a direct startup-room port.
-    - `IF_TRY_TO_TAKE_THE_GLUE` is a strict early interrupt branch: it always plays `0x3e4d` / `0x3e52` / `0x3e56`, then either exits on `0x3e76` / `0x3e7a` or, once the slashes-on-body topic has been unlocked, continues through `0x3e5b` / `0x3e60` / `0x3e64` / `0x3e69` / `0x3e6d` / `0x3e71`.
-    - The first no-item visit clears the Moynahan-local intro gate and plays the fixed six-line opener `0x3d33` / `0x3d37` / `0x3d3b` / `0x3d40` / `0x3d45` / `0x3d47`; later visits start on `0x3fd1`, can inject the slashes-on-body topic into the live keyword buffer through zero-based `dialog.rsp` lines `0x194` / `0x195`, can re-arm the intro gate after the shared casket-photo evidence is discussed, and then run the `HAVE_BOTH_LEDGERS`, `STEPH_MIDGAME_PLAYED`, day-5, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, Butcher-death, Jimmy-death, and `KARIN_FOUND_DEAD` follow-ups in that priority order.
-    - The item path has three real response-menu branches: `CASKET_PHOTO` / `CASKET_PHOTOCOPY` uses zero-based `dialog.rsp` lines `0x18c` and `0x18d`; the accepted trade calls `set_object_visibility_for_owner_or_room(..., visible=0)` for the presented photo and for `GLUE`, then calls `add_object_to_inventory(GLUE)` and sets `JUST_GOT_GLUE`; `LEDGER` / `LEDGER2` with `HAVE_BOTH_LEDGERS` uses zero-based `dialog.rsp` line `0x18e`; and note/checkbook evidence uses zero-based `dialog.rsp` line `0x190`. `PHOTO_OF_WHALEY_HERRILL`, `WED_MATCHES`, and the fallback item path stay linear.
-    - Moynahan's keyword loop is typed-topic driven. Zero-based `dialog.rsp` line `0x199` is the observable farewell path behind `0x3e32`; `0x19a` / `0x19b` set the shared Mom day-5 good-cause state before `0x3d5f`; `0x19d..0x1a0`, `0x1a2..0x1a3`, and `0x1a9..0x1ab` each converge on single response lines; `0x1b2..0x1b3` is the day-5-only branch that writes the recovered shared `d2ec0` / `d2ec8` bits before `0x3df5`; `0x1b4..0x1b5` is room-specific to `MBLM`; and `0x1b8..0x1be` plus `0x1bf..0x1c2` are also day-5 gated.
-    - The later hidden compare against zero-based `dialog.rsp[0x1c4]` is still present at `0x25ef3`, but the shipped `dialog.rsp` gives both `0x199` and `0x1c4` the same literal `BYE` bytes. Because the `0x1c4` match only jumps back into the top-of-loop `0x199` test, it does not produce a distinct engine-visible branch beyond the already recovered `0x3e32` farewell.
-  - `handle_talk_to_stephanie @ 0x36710` is now bounded enough for a direct startup-room port.
-    - The item path is not purely linear: note/checkbook evidence stays on `0x466a`, the casket-photo path now reads the recovered shared `d2ec0` and `d2ebc` bits between `0x4675` and `0x4684`, and the dual-ledger case remains the fixed `0x468f` / `0x4694` / `0x469a` / `0x469e` exchange.
-    - Stephanie's no-item opener is a three-stage day-gated ladder, not a simple intro/revisit split. The first visit seeds zero-based `dialog.rsp` line `0x2cf`; same-day revisits switch to `0x2d0`; later days run the first follow-up (`0x45a7` through `0x45e2` with zero-based `dialog.rsp` line `0x2d1`), then the second follow-up (`0x45e8` through `0x464e` with zero-based `dialog.rsp` lines `0x2d3` and `0x2d5`) before collapsing to the steady `0x4654` revisit line.
-    - The late-game no-item tail is a strict one-shot chain: day 5 -> `0x46aa`..`0x46c9`; `BURNED_TV_STATION` -> response menu `0x2d7`; `SCRATCHED_TUCKER` -> response menus `0x2d9` / `0x2da`; `BOLT_OF_CLOTH_TAKEN` -> response menu `0x2db`; `BARBER_POLE_STOLEN` -> response menu `0x2dd`; `DINER_BURNED` -> response menus `0x2df` / `0x2e0` / `0x2e1`; `PC_ESCAPED_JAIL` -> response menu `0x2e3`; `KARIN_KIDNAPED` -> `0x4957`..`0x4969`; and the Karin outcome block starts on `0x4996`, can insert `0x49c1` when Dwayne's completed Karin-alive follow-up bit is set, and always exits through `0x49ea` / `0x49ef`.
-    - The corrected raw-code tails show two hidden Stephanie continuations outside the current clean decompiler body.
-      - The `0x2f9..0x2fd` spyhole keyword branch does not return on `0x4a12`; once the active keyword buffer has advanced to zero-based `dialog.rsp[0x2f8]`, that hidden continuation is only reached from the explicit `0x2f9..0x2fd` topic matches, sets the local spyhole flag, continues through `0x4a19` or `0x4a1e` depending on whether Mr. Potts has already been confronted about the spyhole, then plays `0x4a23` and loops back into the keyword menu. The generic zero-based `dialog.rsp[0x2e4]` `BYE` still exits through `0x46a4`, including after the spyhole buffer has been seeded.
-      - The post-`KARIN_KIDNAPED` tail always continues through `0x4975` once the kidnap branch has fired. If the local spyhole flag is already set it first prepends `0x4970`, then optionally adds Mr. and Mrs. Potts's Tuesday-night alibi follow-ups (`0x4980` and `0x4984` / `0x4989` / `0x498f`) before checking the Karin-alive / Karin-dead payoff block.
-  - `handle_talk_to_wasp_woman @ 0x2fde0` is now bounded enough for a direct startup-room port.
-    - The hidden no-item intro tail is larger than the clean decompiler body shows. The first visit clears the talk-state gate, plays `0x4bee`, optionally inserts the `0x4bf2` PC line when `get_set_wasp_woman_dialogue_state_d2f10` is set, continues through `0x4bf6` / `0x4bfc`, then adds the recovered `0x4c00` line before seeding the live keyword buffer with zero-based `dialog.rsp[0x300]`.
-    - Wasp Woman's keyword state persists across revisits. Later no-item visits only replay `0x4bee`; the live keyword buffer is whatever the prior branch last wrote, and the raw hidden tail now confirms the concrete rewrites: `0x302` expands into `0x4c0a` / `0x4c13` / `0x4c17`, plays `C027A.FST`, continues through `0x4c25` / `0x4c29`, then rewrites the buffer to zero-based `dialog.rsp[0x304]`; `0x308` rewrites to `0x309`; and `0x30a` plays `0x4c74` / `0x4c7f` before rewriting to `0x30b`.
-    - The hidden loop also clarifies the ambiguous BYE handling. The native code checks the selected topic against zero-based `dialog.rsp[0x301]` before each keyword dispatch, so the synthetic `BYE` selection exits silently even after the live keyword buffer has advanced to later states such as `0x30b`. The later zero-based `dialog.rsp[0x30e]` compare jumps back to the top of the loop; with the shipped `DIALOG.RSP`, both `0x301` and `0x30e` are the literal `BYE`, so the top-of-loop `0x301` compare exits before the menu is redrawn. Only an unmatched late typed topic falls through to `0x4c9a`.
-- The shared dialogue evidence/topic state shims behind those handlers are now partially explicit:
-  - `get_set_shared_dialogue_state_d2e98` at `0x38230` is a neutral wrapper over `g_shared_dialogue_state_d2e98`; the currently recovered gameplay callers are Boyle's `BOYLES_BUTTON` response-2 branch, which writes it immediately before `SHERIFF_IN_DINER`, and Herrill's `GASCAN` branch at `0x2e056`, where `eax = 1` / `edx = 0` makes it a read-only gate and a zero value collapses the post-`0x30d8` continuation to `PC 0x3123`. Outside those handlers, the backing global is only touched by the shared reset path and save/load serialization.
-  - `get_set_boyle_gascan_application_state` at `0x38240` is the Boyle-local `GASCAN` state bit that gates the hidden gas-can-for-application continuation.
-  - `get_set_shared_dialogue_state_d2eb0` at `0x382a0` is a neutral wrapper over `g_shared_dialogue_state_d2eb0`; the currently recovered gameplay callers are Boyle's `BOYLES_BUTTON` response-2 branch, which writes it alongside `d2e98`, and the hidden Boyle dialogue branch at `0x2d6e3`, where `eax = 1` / `edx = 0` makes it a read-only gate between `BOYLE 0x10e` and `BOYLE 0x115` after the local item checks. Outside those handlers, the backing global is only touched by the shared reset path and save/load serialization.
-  - `get_set_shared_dialogue_state_d2eb8` at `0x382c0` is another neutral shared wrapper over `g_shared_dialogue_state_d2eb8`; the currently recovered gameplay caller is Mrs. Potts's `STEPHANIE_IS_DEAD` branch, where `eax = 1` / `edx = 0` makes it a read-only gate between the opening `0x283b` response menu and the later room-specific dead-Stephanie branches. The adjacent helper `play_c001b_fst_and_mark_steph_midgame_played` at `0x38930` writes that same bit, sets `STEPH_MIDGAME_PLAYED`, clears the screen, and plays `C001B.FST` on the Mr. Potts Stephanie-hall exit path, but the full gameplay-side semantics of the shared state are still not bounded tightly enough for a stronger name.
-  - `get_set_shared_dialogue_state_d2ebc` at `0x382d0` is another neutral shared wrapper over `g_shared_dialogue_state_d2ebc`; the currently recovered gameplay caller is Boyle's `CASKET_PHOTO` / `CASKET_PHOTOCOPY` branch, where `eax = 1` / `edx = 0` makes it a read-only gate for the optional `0x253` line between `0x24f` and `0x258`. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path and save/load serialization.
-  - `get_set_shared_dialogue_state_d2ec0` at `0x382e0` is a neutral wrapper over `g_shared_dialogue_state_d2ec0`; the currently recovered gameplay writer is Moynahan's day-5 `0x1b2` / `0x1b3` keyword branch, which sets it immediately before line `0x3df5` alongside the already recovered shared `d2ebc` and `d2ec8` bits. No reader is bounded tightly enough yet for a stronger semantic name.
-  - `get_set_moynahan_karin_kidnaped_discussion_state` at `0x382f0` is a Moynahan-local follow-up bit written from the `HAVE_BOTH_LEDGERS` response menu when the player takes response `2` after story day `2`, immediately before `0x3f7a`.
-  - `get_set_shared_dialogue_state_d2ec8` at `0x38300` is a neutral wrapper over `g_shared_dialogue_state_d2ec8`; the currently recovered gameplay writers are Moynahan's slashes-on-body no-item follow-up (`0x3da0` / `0x3da1` / `0x3da2`) and his day-5 `0x1b2` / `0x1b3` keyword branch. The backing global is still too weakly bounded for a stronger name.
-  - `get_set_discussed_note_checkbook_evidence` at `0x38330` gates the shared `NOTE` / `NOTE_PHOTOCOPY` / `CHECKBOOK` / `CHECKBOOK_PHOTOCOPY` evidence path used by Dwayne, Edna, Herrill, Johnson, Mom, and related handlers.
-  - `get_set_discussed_tv_deed_evidence` at `0x38340` gates the `TV_DEED` / `TV_DEED_PHOTOCOPY` evidence path used by Edna, Herrill, Johnson, and Mom.
-  - `get_set_discussed_ledger_evidence` at `0x38350` gates the shared dual-ledger evidence path reached when `HAVE_BOTH_LEDGERS` is set.
-  - `get_set_discussed_casket_photo_evidence` at `0x38360` gates the `CASKET_PHOTO` / `CASKET_PHOTOCOPY` evidence path shared across Boyle, Dwayne, Edna, Hank, Herrill, Johnson, Mom, and others.
-  - `get_set_discussed_whaley_herrill_photo` at `0x38370` gates the shared `PHOTO_OF_WHALEY_HERRILL` evidence path.
-  - `get_set_shared_dialogue_state_d2eec` at `0x38380` is a neutral wrapper over `g_shared_dialogue_state_d2eec`; Herrill's `HAVE_LODGE_APP` continuation calls it at `0x2e10f` with `eax = 1` / `edx = 0`, so the currently recovered gameplay use is read-only and decides whether `0x3104` is inserted before `0x3109`. No gameplay-side setter call has been recovered yet; outside the wrapper, the backing global is only touched by the shared reset path.
-  - `get_set_discussed_karin_purse` at `0x38390` gates Dwayne's `K_PURSE` evidence state and the later `KARIN_FOUND_DEAD` follow-up that depends on whether he has seen the purse yet.
-  - `get_set_dwayne_pending_karin_alive_followup` at `0x38260` is a Dwayne-local state bit set by the `BRING_KARIN_TO_SHERIFF` / `KARIN_FOUND_ALIVE` path, then read and cleared when the delayed next-day follow-up bark triggers.
-  - `get_set_dwayne_completed_karin_alive_followup` at `0x38480` is the shared completion bit written only after that delayed Dwayne follow-up fires; the hidden Dwayne branch plays `0x3780` / `0x378a`, clears `get_set_dwayne_pending_karin_alive_followup`, then sets this helper, and Stephanie later reads it on her hidden `KARIN_FOUND_ALIVE` path to decide whether `0x49c1` and the `0x49ea` / `0x49ef` exit lines should run.
-  - `get_set_dwayne_discussed_boyles_button` at `0x38320` is the Dwayne-local `BOYLES_BUTTON` evidence bit; `handle_talk_to_dwayne` sets it when shown the button, and nearby hidden dialogue logic reads it at `0x2d262`.
-  - `get_set_mom_father_topic_state` at `0x383b0` is a Mom-local state bit set from the `FATHER` keyword branch.
-  - `get_set_mom_good_cause_day5_state` at `0x383c0` is a Mom-local day-5 `GOOD CAUSE` state bit; the visible caller currently reads it, but the concrete setter is still outside the recovered call graph.
-  - `get_set_dad_meat_permission_state` at `0x38400` is the Dad-local `MEAT_PERMISSION0` state bit read on the related item branch.
-  - `get_set_shared_dialogue_state_d2f04` at `0x383e0` is a neutral shared wrapper over `g_shared_dialogue_state_d2f04`; raw call tracing shows it is used from hidden regions in Moynahan, Mr. Potts, Johnson, Phelps, Butcher, Dad, McKnight, Loomis, Edna, Sparky, and Dwayne, but the surrounding subtitle lines still span too many unrelated topics for a stronger semantic rename.
-    - Johnson writes that shared bit on both the primary intro branch (`0xa86` through `0xa9b`) and the `KARIN_KIDNAPED` follow-up (`0xbff` / `0xc06`).
-  - `get_set_wasp_woman_dialogue_state_d2f10` at `0x38420` is a neutral wrapper over `g_wasp_woman_mom_dialogue_state_d2f10`; a program-wide linear call scan currently only finds two call sites, both inside hidden `handle_talk_to_wasp_woman` code at `0x2ff4e` and `0x3010f`.
-  - On Wasp Woman's no-item intro path, that helper is called between `0x4bee` and the optional `0x4bf2` PC reply before the fixed `0x4bf6` / `0x4bfc` exchange.
-  - On Wasp Woman's hidden `0x305` keyword branch, the same helper is called after `0x4c31`; if it returns nonzero, the branch opens response line `0x307`, where choice `1` plays `0x4c4d` / `0x4c53` / `0x4c57` and choice `2` plays `0x4c5e`.
-  - `play_c008_prelude_fst` at `0x388c0` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C123.FST", 0)`; both normal xrefs and a full linear call scan currently show no direct callers, so it remains an orphan FST helper for now.
-  - `get_set_discussed_mr_potts_tuesday_night_alibi` at `0x38440` is set on the hidden `handle_talk_to_mr_potts` Tuesday-night branch and later read by Johnson and Stephanie; Stephanie's hidden `0x4980` line, "Pottsdam lied. He said he stayed home Tuesday night.", makes the semantics explicit.
-  - `get_set_discussed_mrs_potts_tuesday_night_alibi` at `0x38460` is set on the hidden `handle_talk_to_mrs_potts` Tuesday-night branch and later read by Stephanie; the hidden `0x4984` / `0x4989` / `0x498f` exchange anchors it to Mrs. Potts's version of Pottsdam's Tuesday-night alibi.
-  - `get_set_confronted_mr_potts_about_spyhole` at `0x384a0` is set on the hidden `handle_talk_to_mr_potts` spyhole confrontation and later read by Stephanie; the paired `0x2d35` / `0x2d39` / `0x2d3e` and `0x4a12` / `0x4a19` / `0x4a1e` / `0x4a23` subtitle lines make the semantics explicit.
-  - `get_set_discussed_lodge_topic` at `0x384c0` is the shared Lodge-topic flag set from the `dialog.rsp` keyword lines used by Dwayne, Hank, and Mom.
-  - `get_set_dwayne_whaley_discipline_followup_state` at `0x38500` is a Dwayne-local follow-up state bit read just before the `0x34b3` Miss Whaley school-discipline exchange and its immediate `0x79` response menu.
-  - Each helper is a tiny read/write shim over a dedicated global (`g_discussed_note_checkbook_evidence`, `g_discussed_tv_deed_evidence`, `g_discussed_ledger_evidence`, `g_discussed_casket_photo_evidence`, `g_discussed_whaley_herrill_photo`, `g_discussed_karin_purse`, `g_discussed_lodge_topic`).
-- `handle_talk_to_phelps @ 0x29920` is now bounded enough to rebuild from native flow.
-  - The opening no-item branch is not a single bark: Phelps's intro is the six-line `0x1b44` through `0x1b5a` exchange, and it also sets the shared `d2f04` dialogue bit before returning.
-  - Later no-item visits always play `0x1bf4`, then, before day 5, open response menu `0x22a` after `0x1b7b`; response `1` continues with `0x1b85`, while every other response falls through `0x1bbc` and `0x1c44` before the handler continues into the one-shot event chain.
-  - That one-shot chain is linear and ordered: `STEPH_MIDGAME_PLAYED` -> `0x1c4a`, day 5 -> `0x1c56`, `SCRATCHED_TUCKER` -> `0x1c65`, `BARBER_POLE_STOLEN` -> `0x1c6e` / `0x1c73`, `BOLT_OF_CLOTH_TAKEN` -> `0x1c80` with optional `0x1c87`, `DINER_BURNED` -> `0x1c8f`, `PC_ESCAPED_JAIL` -> `0x1c98`, `GOT_REMAINS_FOR_LODGE` while Moynahan is still alive -> `0x1ca0`, `BURNED_TV_STATION` -> `0x1cb4`, then the Butcher, Moynahan, and Jimmy death one-shots before the `KARIN_FOUND_DEAD` / `KARIN_KIDNAPED` endings.
-  - Phelps's item handling also expands beyond the old stub: `PHOTO_OF_WHALEY_HERRILL` is `0x1c0c` / `0x1c13` / `0x1c17`, `CASKET_PHOTO` / `CASKET_PHOTOCOPY` is `0x1c1e` / `0x1c22` / `0x1c27` / `0x1c2c` / `0x1c30`, and `QUARTER` dispatches `GET_PRN_MAG`, plays `0x1ba4` / `0x1bac` / `0x1bb0`, then dispatches `DEL_DRT_MAG`.
-- `handle_talk_to_mrs_potts @ 0x2ee70` is now bounded enough to rebuild from native flow.
-  - The non-dead intro branch clears `g_mrs_potts_talk_aux_state_block`, sets the return-visit followup bit, opens zero-based `dialog.rsp` line `0x231`, and seeds keyword buffers `0x232` or `0x233` depending on the response path. Later no-item visits either use the `0x2800` response menu gate or the direct permission branch through `PERMISSION_TO_SEE_STEPHANIE` and `STEF_HALL_EXIT`.
-  - The non-dead event followups are one-shot gates rather than terminal barks: `DAY_5` -> `0x2889`, `BURNED_TV_STATION` -> `0x2891`, `KARIN_KIDNAPED` -> `0x28dc` plus response menu `0x235`, and `DINER_BURNED` -> `0x290e` with the wider `0x2914` (`KARIN_HUNG`), `0x2918`, and response menu `0x236` continuation. Response `1` stays in dialogue with `0x2926`; response `2` reuses the shared `0x28b7` / `0x28bd` / `0x28c1` death-in-chair sequence. After those gates, the keyword loop matches `0x237` through `0x24a`.
-  - The hidden keyword loop tails are now explicit. `Pearls`, `Bake Sale`, `Wedding`, and `Grounded` expand into `0x2756`..`0x276b`, `0x2775`..`0x2783`, `0x278c`..`0x2799`, and `0x27a3`..`0x27cc`, then reseed keyword buffers `0x239`, `0x23b`, `0x23d`, and `0x240`. The later `Mr. Pottsdam`, `Moynahan`, and `Lodge` topic groups reseed `0x243`, `0x246`, and `0x249`, with the Lodge path explicitly setting `get_set_discussed_lodge_topic` before `0x27ef` / `0x27f3`.
-  - The hidden `KARIN_KIDNAPED` response-1 tail `0x28e8` / `0x28ee` / `0x28f2` is the Mrs. Potts Tuesday-night alibi branch: it sets `get_set_discussed_mrs_potts_tuesday_night_alibi`, while response `2` continues through `0x28fb` / `0x2903` / `0x2907` before the handler falls through to later event checks.
-  - The `STEPHANIE_IS_DEAD` path is split three ways: `d2eb8 == 0` opens response menu `0x230` after `0x283b`; otherwise Mrs. Potts branches on room `STKITCHN`, then the `GOT_REMAINS_FOR_LODGE` and `TAKEN_INVITE_TO_SERGEANT` one-shots drive response menus `0x22d` and `0x22e`.
-  - Both the direct `REMAINS` item branch and the `GOT_REMAINS_FOR_LODGE` response-menu branch converge on the same death sequence: `0x28b7` / `0x28bd` / `0x28c1`, a black-screen clear, `play_c040_fst`, then `dispatch_action_tag_if_set("DIE_IN_CHAIR")`.
-- `play_c040_fst` at `0x38920` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C040.FST", 0)` and is currently only recovered from Mrs. Potts's death-in-chair branch.
-- Boyle's hidden `GASCAN` return branch is now explicit through `get_set_boyle_gascan_application_state` at `0x38240`.
-  - Boyle's no-item intro is also fully bounded now. When the local intro bit is set, he clears it, plays the fixed `0x7` / `0xb` / `0xf` opener, then drives response menus `0x5`, `0x6`, `0x7`, and `0x8` before seeding keyword buffers `0x9` or `0xa`; steady revisits collapse to `0x23c` and optionally seed `0xb` when `BOYLES_BUTTON` is already in inventory.
-  - The post-intro no-item followups are one-shot gates rather than a single bark chain: the shared blackmail/button-evidence block plays `0x210` plus the optional `0x21a` / `0x215` inserts, branches through response menu `0xc` when `HAVE_LODGE_APP` is still clear, and then clears the active keyword buffer before the later `STEPH_MIDGAME_PLAYED`, `DAY_5`, and `BURNED_TV_STATION` one-shots.
-  - When that state is nonzero, `handle_talk_to_boyle` plays the `0x1f2` / `0x1f6` / `0x1fa` / `0x202` / `0x206` exchange, removes `GASCAN` from inventory, awards `LODGE_APPLICATION`, and sets `HAVE_LODGE_APP`.
-  - Boyle's hidden keyword block around `0x2d620` is now bounded enough to map its major topic groups.
-    - `Amnesia` (zero-based `dialog.rsp` line `0x17`) plays the fixed `0xee` / `0xf2` / `0xf6` / `0xfa` exchange.
-    - `Button`, `Epaulet`, `Shirt`, and `Postal Button` (`0x18` through `0x1b`) all converge on the neutral `get_set_shared_dialogue_state_d2eb0` read. When that shared bit is clear, Boyle first calls `play_c019b_fst_sequence` and then plays `0x10e`; when the bit is set, the same topic group jumps straight to `0x115`.
-    - `Arson` (`0x1d`) and the literal keyword string `Arsonist` share one branch: Boyle plays `0x11c`, opens the zero-based `dialog.rsp` response menu at `0x1e`, then takes either the `0x126` / `0x12c` / `0x130` continuation for choice `1` or the `0x135` continuation for choice `2`.
-    - `Blackmail` (`0x1f`) plays `0x13d` / `0x141` / `0x145` / `0x14a` / `0x14e`, `BYE` (`0x20`) falls through the common exit path, and unmatched keywords drop to Boyle's `0x243` fallback line.
-  - Boyle's item logic now cleanly splits into five native clusters: `BOYLES_BUTTON` uses response menu `0x2` and can arm the Boyle-local gas-can application state, shared `d2e98` / `d2eb0`, and `SHERIFF_IN_DINER`; note/checkbook evidence either reuses the blackmail sequence or branches through response menus `0x3` and `0x4`; `PHOTO_OF_WHALEY_HERRILL` is the dedicated `0x25f` path; `CASKET_PHOTO` / `CASKET_PHOTOCOPY` is the `0x24f` / optional `0x253` / `0x258` sequence; all other items fall back to `0x249`.
-  - A Boyle-local save-backed bit at `0xd2cbc` now has a confirmed behavior: intro response menu `0x7` choice `2` sets it before `0x76` / `0x77`, and later `BOYLES_BUTTON` or note/checkbook response-1 branches use it to insert `PC 0x186` before Boyle's `0x18b` / `0x1b8` evidence-reply variants instead of the shorter direct tail.
-  - `play_c019b_fst_sequence` at `0x38620` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C019B.FST", 0)` and is currently only called from Boyle's `d2eb0 == 0` evidence-topic branch.
-  - `play_c093_fst_sequence` at `0x38730` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C093.FST", 0)` and is currently only called from Parsons's `Pole` topic branch.
-  - `play_c094_fst_sequence` at `0x38740` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C094.FST", 0)` and is currently only called from Parsons's `Aliens` / `Pastorelli` topic branch.
-  - `play_c085_fst_sequence` at `0x387d0` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C085.FST", 0)` and is currently only called from Buster's shared `dialog.rsp[0x28/0x29]` topic branch.
-  - `play_c086_blackout_fst_sequence` at `0x387e0` clears the current palette to black and then wraps `play_fst_sequence("GRAPHIC/FST/C086.FST", 0)`; it is currently only called from Buster's fatal response branches immediately before `run_game_over_screen`.
-  - `play_c058_fst_sequence` at `0x38800` is a direct wrapper around `play_fst_sequence("GRAPHIC/FST/C058.FST", 0)` and is currently only called from Buster's `dialog.rsp[0x38]` topic branch.
-- The adjacent `0x383d0`-`0x384e0` range is now confirmed to be another overlapping run of tiny read/write dialogue-state wrappers over globals in the same contiguous block (`0xd2f00` through `0xd2f28`).
-  - `get_set_sergeant_completed_first_task_state` at `0x384e0` is the Sergeant-local bit set on the `SCRATCHED_TUCKER` branch immediately before dialogue `0x420d` ("So, you have completed your first task.").
-  - The `SCRATCHED_TUCKER` no-item path is not just that single bark. After `0x420d`, Sergeant continues with `0x421e`, opens zero-based `dialog.rsp` line `0x2b4`, and then either accepts the handoff with `0x422a` or pushes back through `0x422f` / `0x4239`. Both outcomes then fall through a one-shot local `d2d58` gate that dispatches action tag `GO_DAY_3`.
-  - The same block also contains the recovered Pottsdam alibi bits at `0x38440` and `0x38460`, so it is no longer Sergeant-only even though several remaining wrappers in that span are still only identified by locality.
-  - `handle_talk_to_sergeant` calls into that run for quest-driven lines tied to `REMAINS`, `INVITE`, `BARBER_POLE`, `BOLTCLTH`, `SCRATCHED_TUCKER`, and `COMPLETED_LODGE_APPLICATION`.
-  - The native item/event branches are wider than the earlier engine stubs:
-    - `REMAINS` is the late story payoff branch, not a single line. After `0x4333`, Sergeant plays `C150.FST`, continues through `0x433f` / `0x4344` / `0x4348` / `0x434f` / `0x4353`, opens response menu `0x2ad`, then continues through `0x4368` / `0x4372` / `0x4376` / `0x4381` / `0x4385` before the palette-clear / `C076.FST` tail.
-    - `INVITE` now cleanly includes `0x4305`, `0x4309`, response menu `0x2ae`, optional `0x4314` or `0x4319`, and the common `0x431d` exit while also toggling the Mrs. Potts / Dwayne room-state handoff.
-    - `DINER_BURNED` is a two-menu continuation rather than a terminal bark. Sergeant plays `0x42b5`, `0x42b9`, response menu `0x2af`, optional `0x42c5` or `0x42cb`, then `0x42d2`, response menu `0x2b0`, optional `0x42de` or `0x42e3`, and the fixed `0x42e9` / `0x42ef` / `0x42f3` exit. That tail also re-exposes `INVITE` and `STEFSKULL` in `ST_BEDRM`, reactivates `DWAYNE_ST_BEDRM`, and sets both `STEPHANIE_IS_DEAD` and `STEPHANIE_DEAD_IN_HALL_JUST_OUTSIDE`.
-    - `BARBER_POLE` is also wider than the first recovered stub: `0x426f`, `0x4274`, response menu `0x2b1`, optional `0x4281` or `0x4289`, then `0x428d`, `0x4299`, response menu `0x2b2`, and either `0x42a4` with the conditional `PC_KILLED_ANYONE -> 0x42a8` insert or the direct `0x42ad` tail, while also setting `ASSIGNED_DNA_TASK`.
-    - `BOLTCLTH` is `0x4241`, `0x4245`, `0x4252`, response menu `0x2b3`, optional `0x425e` or `0x4266`, then the firemen-suicide world-state handoff: `FIREMEN_SUICIDE`, `ASSIGNED_POLE_TASK`, and queued `SLASH` death/monsterfy transitions for `SPARKY`, `NUDE_MAN`, and `FIREMAN2`.
-  - The hidden intro / revisit tails at `0x30db4..0x30f98` are now bounded from native code and `DIALOG.RSP`: first contact is not a single bark. After `0x413f`, Sergeant runs the fixed `0x4144` / `0x4145` / `0x4146` / `0x4148` exchange, then drives response menus `0x2b7`, `0x2b8`, and `0x2b9`. Later no-item revisits start from `0x41ac`, optionally insert `0x41b0` when `HAVE_LODGE_APP` is still clear, then play `0x41b4` and branch through response menu `0x2bb`.
-  - The native `COMPLETED_LODGE_APPLICATION` Monday branch is a deferred quest handoff rather than a single bark: after restoring the item to `RAH`, the handler plays Sergeant lines `0x41cd` then `0x4292`, sets `QUEST_1`, and dispatches action tag `GO_DAY_2`, which drives the story-day advance / cutscene path.
-  - The later `QUEST_1`-set application branch is also response-driven. If the local `d2d54` bit is still clear, Sergeant plays `0x41d2`, opens response menu `0x2b6`, and either (choice `1`) continues through `0x41db` / `0x41e2` / `0x41e6` / `0x41ec` / `0x41f0` / `0x41f6` / `0x41fa` while setting `d2d54`, or (choice `2`) plays `0x4201` / `0x4202` / `0x4203` and leaves `d2d54` clear. Once `d2d54` is set, revisits skip the menu and replay the `0x41f0` / `0x41f6` / `0x41fa` task briefing directly.
-  - Those intro / revisit / post-application tails share a day-5 one-shot exit gate: if `DAY_5` is set and the Sergeant-local `d2d70` bit is still clear, the branch ends by latching `d2d70` and playing `0x4324`.
-  - `handle_talk_to_dad` also reads the neighboring `FUN_00038400` wrapper on the `MEAT_PERMISSION0` item path.
-  - The wrapper mechanics are confirmed, but most of the remaining local quest-state semantics in that run are still left unnamed pending stronger caller/readback evidence from the hidden no-function regions.
-- `reset_all_talk_to_handler_state` at `0x3a0b0` is the startup/menu bulk reset for the talk-to subsystem.
-  - It calls the per-NPC talk-to reset helpers plus `reset_shared_talk_to_discussion_state` before the handlers are reused.
-  - The reset fan-out is now mostly resolved into per-handler persisted state blocks that save/load serializes by address:
-    - `reset_talk_to_moynahan_state`, `reset_talk_to_johnson_state`, `reset_talk_to_jimmy_state`, `reset_talk_to_phelps_state`, `reset_talk_to_butcher_state`, `reset_talk_to_inquisitor_state`, `reset_talk_to_dad_state`, `reset_talk_to_mother_state`, `reset_talk_to_buster_state`, `reset_talk_to_mcknight_state`, `reset_talk_to_boyle_state`, `reset_talk_to_nude_man_state`, `reset_talk_to_herrill_state`, `reset_talk_to_mom_state`, `reset_talk_to_dark_woman_state`, `reset_talk_to_fireman1_state`, `reset_talk_to_fireman2_state`, `reset_talk_to_hank_state`, `reset_talk_to_loomis_state`, `reset_talk_to_edna_state`, `reset_talk_to_stephanie_state`, `reset_talk_to_ryder_state`, `reset_talk_to_sparky_state`, `reset_talk_to_memb_dir_state`, `reset_talk_to_parsons_state`, `reset_talk_to_wasp_woman_state`, `reset_talk_to_sergeant_state`, and `reset_talk_to_dwayne_state` each map to a same-named `g_*_talk_state_block`.
-    - `reset_talk_to_mr_potts_state` at `0x25fa0` resets the primary `g_mr_potts_talk_state_block` at `0xd2ab8`, while `reset_talk_to_mr_potts_aux_state` at `0x27d50` clears the adjacent `g_mr_potts_talk_aux_state_block` at `0xd2af4`.
-    - `reset_talk_to_mrs_potts_state` at `0x2ee60` seeds `g_mrs_potts_talk_state_block` at `0xd2d20`, while `reset_talk_to_mrs_potts_aux_state` at `0x2fce0` clears the adjacent `g_mrs_potts_talk_aux_state_block` at `0xd2d24`.
-  - A small remainder is still only mechanically identified:
-    - `reset_serialized_talk_state_block_d2c30` at `0x29700` resets `g_serialized_talk_state_block_d2c30`, and `reset_serialized_talk_state_flag_d2cd8` at `0x2dbf0` resets `g_serialized_talk_state_flag_d2cd8`.
-    - Both participate in `reset_all_talk_to_handler_state` and save/load serialization, but this pass did not find a visible talk handler that reads them directly.
-  - Two handler-local block roles are now more explicit from disassembly:
-    - `g_sergeant_talk_state_block` at `0xd2d4c` is the quest-delivery state cluster toggled across the `REMAINS`, `INVITE`, `BARBER_POLE`, `BOLTCLTH`, `SCRATCHED_TUCKER`, and `COMPLETED_LODGE_APPLICATION` branches.
-    - `g_stephanie_talk_state_block` at `0xd2e4c` begins with three day-gated intro/followup flags plus their stored story-day indices, then carries one-shot gates for `SCRATCHED_TUCKER`, `BOLT_OF_CLOTH_TAKEN`, `BARBER_POLE_STOLEN`, `BURNED_TV_STATION`, `DINER_BURNED`, the later Karin-alive diner sub-branch, day 5, `PC_ESCAPED_JAIL`, `KARIN_KIDNAPED`, and the Karin outcome payoff.
-    - Later confirmed Stephanie-local fields include `g_stephanie_played_spyhole_branch` at `+0x10`, which is set by the hidden `0x2f9..0x2fc` spyhole keyword branch and later prepends `0x4970` before `0x4975`, and `g_stephanie_played_karin_outcome_branch` at `+0x48`, the one-shot gate for the `KARIN_FOUND_ALIVE` / `KARIN_FOUND_DEAD` payoff block.
-    - `g_hank_talk_state` at `0xd2de8` is a serialized `0x2c` Hank-local record: first-visit pending at `+0x0`, last-story-day gate at `+0x4`, `rangshot` branch active/pending flags at `+0x8` / `+0xc`, the last story day at `+0x10`, and six one-shot event-response flags at `+0x14..+0x28` for `STEPH_MIDGAME_PLAYED`, `BURNED_TV_STATION`, `BUSTED_ONCE`, `KARIN_KIDNAPED`, `KARIN_FOUND_ALIVE`, and `KARIN_FOUND_DEAD`.
-    - `g_moynahan_talk_state_block` at `0xd2ad0` begins with the no-item intro gate, then carries the recovered room-local flags for the slashes-on-body topic unlock, the corresponding one-shot intro bark, the `GLUE` acquisition path, the first `HAVE_BOTH_LEDGERS` response-menu branch, and the later `STEPH_MIDGAME_PLAYED`, day-5, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, Butcher-death, Jimmy-death, and `KARIN_FOUND_DEAD` follow-ups. The adjacent `g_moynahan_ledger_evidence_line_played` at `0xd2ad4` remains the separate one-shot gate for the special ledger-evidence follow-up after the generic ledger topic has already been discussed.
-    - `g_mr_potts_talk_aux_state_block` at `0xd2af4` includes at least two proven live fields: `g_mr_potts_followup_day_index` for the same-day vs later-day branch after the auxiliary intro, and `g_mr_potts_pottsdam_digging_line_played` for the one-shot `POTTSDAM_DIGGING` reaction.
-    - `g_herrill_talk_state_block` carries a small gate tail: `g_herrill_dialogue_suppressed_flag` blocks the standard no-item branch, `g_herrill_requires_evidence_for_dialogue_flag` forces the inventory-evidence prerequisite, and `g_herrill_gascan_presented_flag` records the `GASCAN` presentation path.
-    - `g_mrs_potts_talk_aux_state_block` at `0xd2d24` is a compact return-visit/event tail; `g_mrs_potts_return_visit_followup_pending` drives the generic revisit line, while the remaining named fields are one-shot reactions for `DAY_5`, `BURNED_TV_STATION`, `KARIN_KIDNAPED`, `DINER_BURNED`, `GOT_REMAINS_FOR_LODGE`, and `TAKEN_INVITE_TO_SERGEANT`.
-    - `g_mom_talk_state_block` carries a much larger persisted tail immediately after its primary intro flag: it stores `g_mom_intro_day_index`, the two post-intro gates, and one-shot reactions for `STEPH_MIDGAME_PLAYED`, `STEPHANIE_IS_DEAD`, `BURNED_TV_STATION`, `SCRATCHED_TUCKER`, `BARBER_POLE_STOLEN`, `BOLT_OF_CLOTH_TAKEN`, the Karin outcome flags, `PC_ESCAPED_JAIL`, and several absent-NPC checks.
-    - `g_loomis_talk_state_block` adds a single extra revisit flag, `g_loomis_return_visit_followup_pending`, and `g_edna_talk_state_block` now has two proven live fields: the primary intro gate at `+0x0`, which protects Edna's first `0x3a95` + `dialog.rsp 0x9f` exchange, and `g_edna_karin_purse_line_played` at `+0x4` for the `K_PURSE` evidence branch.
-    - `g_dwayne_talk_state_block` extends its intro flag with a serialized tail that includes the sheriff-in-diner introduction gates, `g_dwayne_pending_karin_alive_followup_day_index`, two externally driven reply-override flags, and a long one-shot event sequence for the later plot-state reactions.
-    - `g_memb_dir_talk_state_block` only adds one proven auxiliary field, `g_memb_dir_kewpie_kill_line_played`, which makes the `PC_KILLED_KEWPIE` reaction one-shot.
-- `reset_shared_talk_to_discussion_state` at `0x38530` zeroes the contiguous shared dialogue-state block behind the `get_set_discussed_*` wrappers and related talk-to follow-up flags.
-- `get_current_story_day_index` at `0x38bc0` collapses the `DAY_n` / `NIGHT_n` flags into a single numeric day index and is used by the dialogue handlers to suppress repeat barks or unlock later-day follow-ups.
-- `load_dialogue_index` at `0x79a20` parses `dialogue.idx` into `g_dialogue_index_entries`.
-  - The table is now typed as `DialogueIndexEntry[3000]`, with each entry storing `wav_id`, `text_offset`, and `text_length`.
-  - `play_dialogue_line` binary-searches that table by WAV id, seeks through `g_dialogue_index_stream`, and reads the subtitle text by offset/length.
-- `play_dialogue_line` at `0x7a690` is the inner dialogue-line player.
-  - It resolves a `VOICE` asset, finds subtitle text for the WAV id, updates current head/textbox visuals, and waits for playback or interruption to finish.
-  - Its live call ABI is now confirmed as custom register storage rather than a named Borland convention: callers pass `wavId` in `EAX`, `speakerId` in `EDX`, and `headVariant` in `EBX`.
-  - The voice path is formatted from the `VOICE` config value plus the decimal WAV id (`<VOICE><wavId>.CMP`), while portrait loads use the `HEAD` `portrait_path` base plus `.bm`.
-  - Subtitle text is rendered at `(178, 12)` with width `316`, and the textbox art is selected strictly from wrapped line count:
-    - 1 line -> `TEXTBOX1`
-    - 2 lines -> `TEXTBOX2`
-    - 3 lines -> `TEXTBOX3`
-    - 4-5 lines -> `TEXTBOX4`
-    - 6 lines -> `TEXTBOX5`
-    - 7-8 lines -> `TEXTBOX6`
-    - 9-10 lines -> `TEXTBOX7`
-    - 11+ lines -> `TEXTBOX8`
-  - `decode_xor_aa_text_buffer` at `0x42f80` is the simple subtitle/text deobfuscator used by that path; it XORs each byte with `0xaa` while leaving CR/LF untouched.
-  - The live portrait globals used by that path are now explicit:
-    - `g_dialogue_head_left_entity` is the left-side spawned head bitmap entity created by `run_npc_dialogue`.
-    - `g_dialogue_head_right_entity` is the matching right-side head bitmap entity.
-    - `g_dialogue_head_bitmap_path` is the shared path buffer used by `run_npc_dialogue` and `play_dialogue_line` when selecting the current head bitmap resource.
-    - `g_dialogue_head_id_slots` is the five-slot cached head-ID table used to avoid unnecessary portrait reloads within the same conversation.
-    - `g_dialogue_skip_full_ui_flag` is checked at the top of `play_dialogue_line`; when nonzero, the function skips the full portrait/textbox presentation path.
-  - The decompiler warning `Removing unreachable block (ram,0x7adfb)` is a false positive from the post-line input-release stabilization loop, not an embedded lookup table or missing body chunk.
-  - After picking a non-`PC` speaker, it removes the right PC head entity from the render list, refreshes the left portrait if needed, and re-adds only the speaking-side portrait before the line finishes; `run_dialogue_response_menu` and `run_dialogue_keyword_menu` inherit that render-list state, which is why native conversation menus show only the last speaker's portrait.
-  - `reload_bitmap_entity_pixels_from_resource` at `0x4b7a0` refreshes the pixel payload of an already spawned bitmap entity from a same-sized XFILE bitmap resource; `play_dialogue_line` uses it to swap the currently visible left or right head portrait without respawning the entity.
-- The dialogue/text bitmap raster helpers on the same UI path are now explicit:
-  - `allocate_bitmap_buffer` at `0x229a0` allocates the 12-byte `BitmapBuffer` header plus its `width * height` pixel payload and can zero-initialize the pixel plane for dialogue/text-entry surfaces.
-  - `write_bitmap_buffer_pixel` at `0x237b0` writes a single pixel into that bitmap payload using `(x, y)` coordinates.
-  - `get_cft_glyph_width` at `0x58900` returns the width of a single CFT glyph, using the font's shared space/underscore width fallback for `' '` and `'_'`.
-- `handle_target_interaction` at `0x7ff50` is the high-level click/use dispatcher for world targets.
-  - It can branch into NPC dialogue, inventory transfer, `USEITEM` actions, or generic fallback responses depending on the clicked entity and current item state.
-- `show_target_ident_text` at `0x80270` renders `IDENT` text for the current target by resolving the matching `TEXT` record and showing it until dismissal.
-  - The callsite selects `g_box1_panel_entity` .. `g_box4_panel_entity` from `TextRecord.panel_id`, then reuses the same textbox entity anchor loaded at startup: `x = 0xb1`, `y = 0x55`.
-  - Immediately before `spawn_text_entity`, it loads `EBX` from `g_textfont_cft @ 0xd5bc4`, so IDENT overlays use `TEXTFONT.CFT` atlas pixels rather than a generic single-color font path.
-  - The text spawn is padded relative to that panel anchor: native adds `+10` on X and `+5` on Y before creating the IDENT entity, and passes `panel_width - 2` as the wrap-width argument.
-
-### Keyboard Input
-
-- `keyboard_irq1_handler` at `0x48460` is the custom IRQ1 keyboard handler.
-  - It reads scan codes from port `0x60`, updates `g_last_keyboard_scancode` plus the `g_keyboard_scancode_pressed` table, clears key-release events by masking bit `0x80`, and acknowledges the PIC with `out 0x20, 0x20`.
-- `install_keyboard_irq1_handler` at `0x484f0` and `remove_keyboard_irq1_handler` at `0x485b0` lock/unlock the handler code and data, zero the scan-code table, and install/remove interrupt vector `9`.
-- `install_keyboard_irq1_handler_state` at `0x48700` and `remove_keyboard_irq1_handler_state` at `0x48660` are the explicit-state forms of that same path.
-  - `g_keyboard_irq1_handler_state` at `0xd5900` is the small backing object that caches the previous IRQ1 vector and the installed flag used by the text-entry/dialogue code and the mixed Borland runtime descriptor block.
-- `dos_get_interrupt_vector` at `0x866f8` and `dos_set_interrupt_vector` at `0x8672c` are the DOS `int 21h` AH=`35h` / AH=`25h` wrappers used by that install/remove path.
-- The recovered input bytes now separate into concrete scan-code slots:
-  - `g_input_up_pressed` / `g_input_left_pressed` / `g_input_right_pressed` / `g_input_down_pressed` are the `0x48` / `0x4b` / `0x4d` / `0x50` slots in `g_keyboard_scancode_pressed`.
-  - `g_input_right_shift_pressed` at `0xd5942` is scan code `0x36` and is the reverse-step modifier used by `update_player_combat_avatar_state`.
-  - `g_player_attack_pressed` at `0xd5929` is the scan-code `0x1d` slot consumed by the player-combat attack path.
-  - With `g_player_attack_pressed` held, `update_player_combat_avatar_state` uses the directional scan-code slots as a keyboard-only attack selector: right picks state `0x1a` / frames `0x78..0x81`, left picks `0x17` / `0x5a..0x63`, and up/down only choose the upper/lower side-attack banks (`0x1b` / `0x6e..0x77`, `0x18` / `0x50..0x59`, `0x19` / `0x82..0x8b`, `0x16` / `0x64..0x6d`) when the current facing family is already right or left.
-
-### Pointer / Cursor Input
-
-- `g_mouse_driver_present` at `0xd5971` gates all observed `int 33h` mouse-service calls.
-  - `run_harvester_main_loop`, `select_town_map_destination`, `run_save_game_menu`, `run_load_game_menu`, `run_main_menu`, and `FUN_00072550` all test it before setting mouse bounds or pushing a new cursor position through `int 33h`.
-- `g_cursor_screen_x` / `g_cursor_screen_y` at `0xd5978` / `0xd597c` are the shared cursor coordinates.
-  - Menus, dialogue, inventory, town-map travel, and runtime target-hit helpers all consume them directly for screen-space hit testing.
-  - Keyboard-driven UI motion also writes these globals and then mirrors the result back into the DOS mouse driver when `g_mouse_driver_present` is nonzero.
-- `g_input_mouse_primary_pressed` at `0xd5974` and `g_input_mouse_secondary_pressed` at `0xd5972` are the current pointer-button state bytes.
-  - Confirmation/selection loops consume the primary byte.
-  - Cancel/back paths consume the secondary byte.
-  - Input-release loops in `dispatch_room_event_actions`, `play_dialogue_line`, and menu states wait until both bytes and `g_last_keyboard_scancode` have returned to zero.
-- `g_cursor_entity` at `0xd6094` stores the active runtime cursor entity.
-- `spawn_cursor_entity` at `0x79920` instantiates the `MOUSE` runtime entity from `graphic\\pointers\\pointers.abm` at `g_cursor_screen_x` / `g_cursor_screen_y`.
-- `set_entity_animation_sequence` at `0x79990` maps cursor sequence indices to 10-frame runs, so room sequence `7` is pointer frames `70..79` in `POINTERS.ABM`.
-- `spawn_cursor_entity` seeds the runtime cursor at sequence `0` / frames `0..9` with animation rate `10`; `room_setup` then switches `g_cursor_entity` to sequence `7`.
-- `sync_cursor_entity_position` at `0x799e0` repositions a runtime entity to the shared cursor coordinates and then advances its visual state through the normal entity animation tick path.
-- `flush_dirty_rects_to_screen` reads `g_cursor_entity` and calls `sync_cursor_entity_position` every frame before the dirty-rect blit work, so cursor placement is part of the normal render pipeline rather than a one-shot startup blit.
-- The shared runtime entity animation/timing cluster is now bounded enough to emulate conservatively:
-  - `get_elapsed_milliseconds` at `0x82102` is misnamed; it derives elapsed time from the DOS clock in centiseconds.
-  - `set_entity_animation_rate` therefore stores centisecond intervals as `100 / rate`, so the cursor's recovered rate `10` advances once every `0.1` seconds rather than every `10` wall-clock milliseconds.
-  - `+0x105c` stores the next animation tick deadline used by `tick_entity_visual_state`
-  - `+0x1060` stores the tick interval derived from `set_entity_animation_rate`
-  - `+0x1064` stores the current frame index
-  - `+0x1068` stores the caller-facing animation rate value
-  - `+0x106c` / `+0x1078` store the active sequence end/start frame indices
-  - `+0x112c` stores the active sequence index
-  - `update_player_combat_avatar_state` and `tick_monster_entity_runtime` both gate locomotion/combat on that same animation path: when `animation_advanced_this_tick` is clear they only call `tick_entity_visual_state`, and only an animation-advance tick falls through into `update_actor_runtime_state`. Native player walk and monster chase therefore use the shared centisecond `100 / rate` cadence rather than a separate faster movement timer.
-- `is_cursor_within_entity_bounds` at `0x4b670` is the rectangle-only cursor test for runtime entities.
-- `is_cursor_over_entity_hitmask` at `0x4b5b0` adds the per-pixel transparency test used by the main loop before interaction dispatch.
-  - Class `0x16` skips the cursor gate entirely.
-  - Classes `0x15`, `0x18`, and `0x19` stop at the rectangle gate and do not require an opaque pixel.
-  - For the normal sprite classes, palette index `0` fails that hitmask test and therefore behaves as transparent.
-  - Class `3` bypasses the cursor bounds/mask checks and acts as the room-background fallback entity when nothing higher in the render list matches.
-
-### Joystick Input
-
-- `initialize_joystick_input_state` at `0x14490` is the startup callback for the joystick/game-port path.
-  - It registers a runtime-module record, then probes BIOS joystick support through `probe_joystick_presence_via_bios`.
-- `probe_joystick_presence_via_bios` at `0x14540` clears `g_joystick_state` and issues `int 15h AH=84h, DX=1`; the path treats an all-zero `AX` / `BX` return pair as joystick-not-present.
-- `poll_joystick_raw_state` at `0x144c0` talks directly to game-port I/O `0x201`.
-  - It strobes the port, counts how long bits `0` / `1` stay asserted to obtain raw X/Y readings, and maps bits `4` / `5` into the two joystick button flags.
-- `poll_calibrated_joystick_state` at `0x145c0` converts those raw counts into a `0..10` range using the stored calibration bounds.
-- `run_joystick_calibration_loop` at `0x14663` captures raw X/Y minima and maxima from button-driven calibration steps, then keeps polling and formatting normalized live values until keyboard input aborts the loop.
-- `g_joystick_state` at `0xc7e74` is now typed as `JoystickState`:
-  - `present`, `button1_pressed`, `button2_pressed`
-  - `current_x`, `current_y`
-  - `x_min_raw`, `x_max_raw`, `y_min_raw`, `y_max_raw`
-
-### Idle Animation Timing
-
-- `g_idle_animation_activity_timestamp` at `0xd5968` is one side of the main-loop idle timer input.
-  - `keyboard_irq1_handler` updates it from the shared tick source on IRQ1 activity.
-  - `run_harvester_main_loop` also refreshes it after `show_target_ident_text`, which prevents the idle animation branch from immediately retriggering after the IDENT prompt is dismissed.
-- `g_idle_animation_reset_timestamp` at `0xd5980` is the non-IRQ reset side of the same timer.
-  - `room_setup`, `run_save_game_menu`, `run_load_game_menu`, the intro/logo sequence helper, the main-menu return path, and the idle-animation cleanup path all refresh it from the current tick source.
-- `g_idle_animation_trigger_time` at `0xd60c4` is the computed threshold for the `IDLE_ANIM` room-animation path.
-  - `run_harvester_main_loop` recomputes it as `max(g_idle_animation_activity_timestamp, g_idle_animation_reset_timestamp) + 3000`, skips the branch in excluded rooms, and otherwise spawns `IDLE_ANIM` from `graphic\\roomanim\\pcloun02.abm`.
-  - The same idle-animation spawn path loads `EBX = 0x0e` immediately before `spawn_scaled_abm_entity_from_resource`, so the native `PCLOUN02.ABM` animation rate is 14 rather than the previously inferred 30.
-  - After the idle animation exits, the main loop re-arms the threshold by another 3000 ticks.
-- `show_pause_message` at `0x797c0` renders the `HARVESTER IS PAUSED` overlay and backdates both idle timestamps by `0xc1c`, allowing the idle-animation gate to become eligible again immediately after pause dismissal.
-  - It reuses `g_box3_panel_entity`, while `show_target_ident_text` selects among `g_box1_panel_entity` .. `g_box4_panel_entity` by matching `TextRecord.panel_id` against the `BOX1` / `BOX2` / `BOX3` / `BOX4` strings.
-
-### CD-ROM Media Check
-
-- `startup_main` now verifies MSCDEX directly before the rest of game startup:
-  - `probe_mscdex_installation_status` is the initial install-state probe.
-  - `get_mscdex_first_drive_index` caches the base CD-ROM drive index for later disc-label path construction.
-  - `get_mscdex_version_bcd` records the detected MSCDEX version for later runtime checks.
-- `g_cdrom_drive_not_ready` at `0xd596c` is the status byte used by `prompt_for_cdrom_disc_change`'s CD/media prompt loop.
-  - When it is nonzero, the function raises the `CD-ROM DRIVE NOT READY!` and `PLEASE TRY AGAIN...` messages.
-  - The callback registered at `0x48890` is the only recovered producer-side writer so far.
-
-## Data Types And Helper Layer
-
-### Struct Recovery
-
-- The startup/menu configuration state is now bounded:
-  - `g_config_environment` at `0xe2438` is the global linked-list store used by `startup_main`, the main menu toggles, and the quick-tips path.
-  - `ConfigEnvList` is an 8-byte list header with `tail` at offset `+0` and `head` at offset `+4`.
-  - `ConfigEnvEntry` is a 12-byte node `{ char *key, char *value, ConfigEnvEntry *next }`.
-  - `find_config_value_by_key`, `get_config_integer_value`, and `set_environment_string_value` are the case-insensitive lookup/update helpers over that list, while `get_environment_value` is the separate process-environment `NAME=VALUE` probe used for `TEMP` / `TMP`.
-- Shared runtime constructor layer now identified:
-  - `initialize_entity_common_fields` at `0x4ab70` seeds the common runtime entity header from register/stack arguments for `name`, `x`, `y`, `z`, a class/type argument, and one extra init argument.
-  - `spawn_abm_entity_base` at `0x4b890` calls `initialize_entity_common_fields`, loads an ABM/bitmap resource through XFILE, decodes its frames, and seeds common animation state.
-  - `spawn_monster_entity_base` at `0x4d140` layers monster-specific blood/effect and combat/AI state on top of `spawn_abm_entity_base`.
-  - Both `spawn_monster_entity_base` and `spawn_npc_entity_from_record` allocate a hidden `"blood.abm"` helper into runtime actor field `+0x11a8` through `spawn_abm_entity_from_resource`, with `EBX = 0x0a` seeding animation rate `10`.
-  - `advance_entity_animation_frame` at `0x4c8a0` advances or forces the active frame/sequence, applies loop/ping-pong behavior, updates motion, and clamps X movement to the current active bounds.
-- The constructor helpers called by `room_setup` are now named:
-  - `spawn_object_entity_from_record` at `0x4aee0`
-  - `spawn_region_entity_from_record` at `0x4b2e0`
-  - `spawn_anim_entity_from_record` at `0x4bbe0`
-  - `spawn_npc_entity_from_record` at `0x4cd00`
-  - `spawn_monster_entity_from_record` at `0x53a10`
-  - `spawn_timer_entity_from_record` at `0x59390`
-  - `spawn_object_entity_from_record` promotes sprite-backed objects whose initial coordinates are `(0, 0)` and whose loaded bitmap is `640x480` to runtime class `3`, the room-background object class.
-  - Hotspot-only objects with no action tag become class `0x16`; the other hotspot-only objects become class `0x15`.
-  - `spawn_region_entity_from_record` builds runtime class `0x19` rectangular hotspots from the record bounds and Z span, copies `desired_facing`, and maps `cursor_enabled` onto the runtime hotspot interaction flag.
-  - `spawn_anim_entity_from_record` immediately drives `show_entity_visual`, which centers the current decoded bitmap on the record `x` / `y` pivot before later movement code applies the current frame header `x_offset` / `y_offset`.
-  - `spawn_timer_entity_from_record` builds runtime class `0x17` timer entities keyed by `TimerRecord.timer_name`; when the timer starts enabled it seeds the start tick from `DAT_000c7e44` and computes the first expiration as `start_tick + initial_value * 100`.
-  - `spawn_npc_entity_from_record` seeds ordinary room-NPC ambient playback as actor state `0x34` with a bounded frame window of `0..0x3b`; the later death / monsterfy / reaction banks begin above that range, so passive room NPCs do not free-run the whole ABM.
-- `CommandRecord` is now stable enough to use directly in the event interpreter:
-  - `opcode`, `trigger_tag`, `arg1`..`arg4`, `runtime_flag`, `next`
-- `EntranceRecord` is stable and drives room transitions and save/load handoff:
-  - `pos_x`, `pos_y`, `pos_z`, `facing`, `room_name`, `entrance_name`, `next`
-- `HeadRecord` is now explicit:
-  - `head_id`, `portrait_path`, `next`
-- `FlagRecord` is now explicit:
-  - `flag_name`, `value`, `next`
-- `ObjectRecord` now has a confirmed position/ownership split:
-  - `initial_x`, `initial_y`, `initial_z`
-  - `current_x`, `current_y`, `current_z`
-  - `bounds_x2`, `bounds_y2`
-  - `action_tag`, `sprite_path`, `alt_sprite_path`, `object_name`, `ident_text_key`, `inventory_text_key`
-  - `current_owner_or_room`, `initial_owner_or_room`, `interaction_label`
-  - parser booleans are `operatable` and `visible`
-  - `runtime_flag_4e` is initialized from `visible`
-  - `inventory_text_key` is the `TEXT` lookup used by the inventory screen tooltip/description panel.
-  - `ident_shown` defaults true when `ident_text_key` is missing, is copied into the runtime object entity, and is set after the IDENT text has been shown or when an object is moved into inventory.
-  - `action_tag` was corrected from the older `pickup_action_tag` label: the main loop dispatches it on initial object activation, on successful carry/pickup into inventory, and on later carried-object activation, so the field is broader than pickup-only handling.
-  - `alt_sprite_path` is also the native pickup-capability flag: `run_harvester_main_loop` promotes room objects with a non-null alternate sprite from IDENT-first cursor sequence `1` to pickup sequence `5`, and the post-IDENT click path moves those objects into `INVENTORY` even when `operatable` is clear.
-  - Save/load only persists the mutable object state: `current_x`, `current_y`, `current_z`, `current_owner_or_room`, `visible`, and `ident_shown`, keyed by `object_name`.
-  - `interaction_label` is copied into the runtime object entity and drives the main-loop hover / use prompt text; a `NULL_ID` label suppresses prompt generation.
-  - `z_extent` is copied into the runtime object entity at offset `+0x20`; `FUN_0004cae0` uses the live interval `[entity + 0x1c, entity + 0x1c + 0x20]` before the 2D overlap test, which confirms that the field participates in third-axis collision gating.
-  - `reserved_x_flag` is a parsed string slot that is either empty or the literal `X` in current `HARVEST.SCR` data. No read-side consumer has been recovered, so it remains a reserved script flag rather than a gameplay name.
-  - `reserved_string_40` is a heap string freed by `free_loaded_world_data`, but no confirmed live consumer has been recovered yet. In current `HARVEST.SCR` data it is always empty.
-  - `spawn_object_entity_from_record` uses `sprite_path` while the object remains at its initial position / owner outside `INVENTORY`, otherwise it switches to `alt_sprite_path`.
-  - When `sprite_path` is null, the same helper allocates a rectangle-only hotspot from `current_x`, `current_y`, `bounds_x2`, and `bounds_y2` instead of a bitmap-backed render entity.
-- `RegionRecord` is now structurally clear:
-  - `min_x`, `min_y`, `max_x`, `max_y`, `min_z`, `max_z`
-  - `desired_facing`, `region_name`, `room_name`, `action_tag`
-  - `start_enabled`, `runtime_enabled`, `cursor_enabled`, `next`
-  - `parse_region_record` maps `FRONT`, `LEFT`, `RIGHT`, and `BACK` into `desired_facing` values `0`, `1`, `2`, and `3`.
-  - `room_setup` only instantiates region hotspots when `room_name` matches the resolved room and `start_enabled` is set; the live room-state mirror is `runtime_enabled`.
-- `NpcRecord` now has stable placement/persisted-state fields:
-  - `pos_x`, `pos_y`, `pos_z`, `frame_delay`
-  - `model_path`, `npc_name`, `room_name`, `audio_path`, `entity_init_arg`
-  - `death_or_monsterfy_flag`, `death_damage_type`
-  - parser booleans are `active` and `visible`; `saved_visible` mirrors `visible` at load time, but its exact save/load role is still unresolved
-  - `HARVEST.SCR` `NPC` lines match that layout directly after the four numeric fields:
-    `room_name`, `model_path`, `npc_name`, `monsterfy_target_name`, `active`, `visible`,
-    `on_death_action_tag`, `audio_path`, `entity_init_arg`
-  - `monsterfy_target_name` is consumed by the `change2monster` / monsterfy path in `FUN_0004d750`
-  - `on_death_action_tag` is dispatched when the NPC dies without transforming
-  - `death_or_monsterfy_flag` is latched both by the no-live-entity `KILL_NPC` / `MONSTERFY` path and by the live actor completion path in `update_actor_runtime_state`, which is why later room setup no longer respawns the normal NPC actor.
-  - `death_damage_type` preserves the pending `BLUDGE` / `SLASH` / `PROJ` selector as values `1` / `2` / `4`; `spawn_npc_entity_from_record` reads that persisted value when `death_or_monsterfy_flag` is already set so it can choose the matching dead / removed-state branch.
-  - `SET_NPC` updates the live `active` / `visible` bytes on the matching record; when the target NPC belongs to the current room, the dispatcher removes its render entity when `visible` clears and spawns one through `spawn_npc_entity_from_record` when `visible` is set.
-  - `set_npc_active_visible_state` at `0x7cab0` is the concrete `SET_NPC` helper.
-    - It writes the matching record's `active` / `visible` bytes, removes the current-room render entity when visibility clears, respawns it through `spawn_npc_entity_from_record` when visibility becomes set, and mirrors the `active` byte onto the live entity state at `+0x52`.
-  - `queue_named_npc_death_or_monsterfy_transition` at `0x7c940` is the name-based `KILL_NPC` / `MONSTERFY` helper.
-    - It resolves the live NPC render entity when present, translates `BLUDGE` / `SLASH` / `PROJ` into the pending damage-type values `1` / `2` / `4`, queues `queue_npc_death_or_monsterfy_transition`, and preserves the pending damage-type selection on the record when no live entity exists yet.
-    - With a live class-4 actor present it does not immediately suppress the `NpcRecord` or dispatch `on_death_action_tag`; it only latches `death_damage_type` and desired state `0x35`, leaving `update_actor_runtime_state` to play the death bank first.
-  - `queue_live_named_npc_death_or_monsterfy_transition` at `0x7ca90` is the live-entity-only wrapper used by the Beggar talk path.
-    - It resolves the named live NPC render entity and forwards to `queue_npc_death_or_monsterfy_transition` only when that render entity exists, without touching the persisted `NpcRecord`.
-  - `is_named_npc_death_type_clear` at `0x7c840` is the talk-handler predicate shared by `handle_talk_to_mom` and `handle_talk_to_dwayne`.
-    - It resolves the named `NpcRecord` and returns true only while `death_damage_type` is still zero, so dialogue branches stop treating that NPC as available after a persisted kill / monsterfy transition has been latched.
-  - `room_setup` walks the room-matching NPC list after room animations and before the player handoff, which is why `PCLIVRM` needs the `SET_PC_LIV_DAY -> SET_NPC "HANK" "T" "T"` startup chain to materialize Hank in the living room.
-- `MonsterRecord` now has stable placement/spawn state and resource fields:
-  - `pos_x`, `pos_y`, `pos_z`, `engage_distance`
-  - `damage_amount`, `initial_hit_points`, `current_hit_points`
-  - `damage_type`, `facing`, `runtime_actor`
-  - `sprite_path`, `monster_name`, `room_name`
-  - sound slots:
-    - `attack_sound_1` through `attack_sound_3`
-    - `hit_sound_1` through `hit_sound_3`
-    - `footstep_sound_left`, `footstep_sound_right`, `death_sound`
-  - `min_x_bound`, `max_x_bound`
-  - parser booleans are `active` and `visible`; `visible` drives actor spawn/remove in `SET_MONSTER`, while `active` is copied into the live actor's `0x52` state byte
-  - `on_death_action_tag` is dispatched when the monster finishes its death/remove state
-  - `damage_amount` is copied into the live actor state at `+0x1180` and is subtracted from target HP on a successful hit; the player combat avatar seeds the same slot from weapon presets.
-  - `damage_type` is parsed from the strings `BLUDGE`, `SLASH`, and `PROJ` into the low-bit values `1`, `2`, and `4`.
-  - Monster construction copies that `damage_type` word into live actor `+0x118c` before OR-ing extra low-byte animation/capability bits from the loaded ABM frame banks; the player combat-avatar path reuses the same low damage bits on top of its own `0x00fffff8` base mask.
-  - `spawn_monster_entity_from_record` also derives the neighboring runtime capability bytes `+0x118d` and `+0x118e` directly from ABM frame-count checks, so those bytes are animation-bank availability flags rather than generic AI state.
-  - The low byte of runtime `+0x118c` now has partial facing-bank semantics from that same spawn selector:
-    - `0x08` = front-facing base bank
-    - `0x10` = left-facing base bank
-    - `0x40` = right-facing base bank
-    - `0x20` = back-facing base bank
-    - `0x80` is still only bounded as the separate case-1 stance/turn-family gate; monster construction derives it from the ABM frame slot reached through live actor pointer `+0x84`
-  - The high nibble of runtime `+0x118e` provides alternate initial stance banks for those same facings:
-    - `0x40` = front-facing alternate bank
-    - `0x10` = left-facing alternate bank
-    - `0x20` = right-facing alternate bank
-    - `0x80` = back-facing alternate bank
-  - `update_actor_runtime_state` now also confirms two higher `+0x118c` bits are attack-family compatibility gates rather than locomotion flags:
-    - attacker `0x1000` must be set before attack states `0x17/0x1a` can be chosen
-    - target `0x10000` must be set before attack states `0x18/0x1b` can be chosen
-    - the player combat-avatar base mask `0x00fffff8` keeps both bits set by default
-  - `attack_sound_trigger_frame` is the attack-sound trigger frame
-  - `hit_sound_trigger_frame` is the reserved frame slot paired with the hit-sound bank at runtime offsets `+0x1154..+0x115c` / `+0x1170`
-  - `footstep_sound_trigger_frame` is the walk / footstep-sound trigger frame
-  - `death_sound_trigger_frame` is the reserved frame slot paired with the death sound slot at runtime offsets `+0x1168` / `+0x1178`
-  - `saved_enabled`, `runtime_spawned`, `next`
-  - `current_hit_points` is the save/load-persisted monster HP field; parser initialization copies `initial_hit_points` into it.
-  - `min_x_bound` defaults to `0x14` and `max_x_bound` defaults to `0x26b` when omitted by the parser.
-  - The sound-slot mapping is confirmed both by `spawn_monster_entity_from_record` and by the player combat avatar builder, which installs the same runtime sound layout explicitly.
-  - `spawn_player_combat_avatar` seeds loadout-specific player attack samples into those same `+0x1148..+0x1150` slots, and `update_actor_runtime_state` triggers the player attack sound when `current_frame_index == first_frame_index + 1`; melee loadouts randomly choose one loaded attack sample, while ranged loadouts route that frame through the weapon fire/empty helper instead of the random selector.
-  - `reserved_string_38`, `reserved_string_3c`, `reserved_string_44`, and `reserved_string_48` are heap strings freed during world teardown, but no confirmed live consumers have been recovered yet. In the sampled shipping `HARVEST.SCR` data those columns are empty for every monster record.
-  - `reserved_dword_1c` remains zero-filled with no recovered producer beyond the initial allocator clear and no recovered read-side consumer.
-- `AnimRecord` now has stable placement/resource identity plus mirrored runtime state:
-  - `pos_x`, `pos_y`, `pos_z`, `frame_delay`
-  - `room_name`, `anim_path`, `anim_name`
-  - script booleans: `active`, `visible`, `loop`, `backward`, `ping_pong`, `remove`
-  - `advance_entity_animation_frame` copies the current ABM frame header offsets into `frame_x_offset` / `frame_y_offset`, and `update_visible_entity_screen_position` later adds those offsets onto the already-centered entity position computed by `show_entity_visual`.
-  - `room_setup` and room teardown reuse the same live bytes as runtime state: `runtime_active`, `runtime_visible`, and `runtime_state`
-  - Before tearing the outgoing room down, `room_setup` walks the live render list, finds room anim entities by `anim_name`, copies each entity's `current_frame_index` back into `runtime_state`, forces `runtime_visible = 1`, and mirrors the live animation-enabled bit back into `runtime_active`
-  - `spawn_anim_entity_from_record` uses that saved `runtime_state` as the initial frame for non-reverse anims, so same-room rebuilds and save/load do not restart a partially progressed visible animation from frame `0`
-  - `room_setup` materializes a room animation when either `active` or `visible` is set, but `spawn_anim_entity_from_record` copies only `active` into the live animation-enable flag.
-  - The `SET_ANIM` dispatcher branch updates both record bytes, respawns or removes the live entity from `visible`, and toggles ticking on the already-spawned entity from `active`, so a visible-but-inactive room anim remains on its initial frame instead of advancing.
-  - The `GOFLIC` dispatcher branch runs `play_fst_sequence` immediately after those record/entity mutations; it does not force an intermediate same-room rebuild before the FST starts.
-  - For same-room interactions such as `USE_BROOMKEY -> SCHL_DOOR_HS`, native ordering is therefore: mutate `SCHDOOR` / `WAYLHERL` with `SET_ANIM`, play `GRAPHIC/FST/C132.FST`, then let the later room refresh respawn `SCHDOOR` from the saved post-animation `runtime_state`.
-- `TextRecord` now has stable string identity:
-  - `text_id`, `panel_id`, `text_value`, `next`
-  - The parser normalizes `_` to space characters in `text_value`
-- `DialogueIndexEntry` is now explicit:
-  - `wav_id`, `text_offset`, `text_length`
-- `DirtyRectNode` is now explicit:
-  - `left`, `top`, `right`, `bottom`, `next`
-- `RawBitmap` is now explicit:
-  - `width`, `height`, `pixels`
-- `TimerRecord` now has stable timing/key fields:
-  - `initial_value`, `current_value`, `timer_name`, `arg1`, `arg2`, `enabled`, `next`
-  - parser booleans are `loop` and `global`
-  - `room_setup` treats `arg1` as the room/scope key for room-local timers and only materializes timer entities when it matches the resolved room name.
-  - `global` is copied into the live timer entity and controls whether `destroy_entity_list` preserves that entity across room transitions. It does not make `room_setup` spawn the timer in unrelated rooms.
-  - `arg2` is the action-tag target executed when the timer expires; native main-loop expiry first resolves the record by `timer_name`, then dispatches `dispatch_room_event_actions(arg2)`.
-  - `initialize_timer_entity_common_fields` at `0x4ab10` zeroes the lightweight timer-entity header before `spawn_timer_entity_from_record` installs the countdown fields and adds the entity to `g_render_entity_list`.
-- `UseItemRecord` is stable:
-  - `item_name`, `target_name`, `action_tag`, `next`
-- `RoomRecord` is partly recovered:
-  - stable fields: `room_index`, `min_z`, `max_z`, `max_z_screen_y`, `min_z_screen_y`, `full_scale_z`, `perspective_scale`, `max_z_scale_percent`, `z_velocity_step`, `on_enter_tag`, `on_exit_tag`, `room_name`, `music_path`, `palette_path`, `dimmable`, `next`
-  - `max_z_screen_y` / `min_z_screen_y` define the screen-Y band that maps cursor position to room depth between `max_z` and `min_z`
-  - `full_scale_z` is the room Z depth where entities remain at 100% sprite scale.
-  - `max_z_scale_percent` is the sprite scale percentage at `max_z`; the parser derives `perspective_scale` from `(100 - max_z_scale_percent) / (max_z - full_scale_z)`, and the runtime applies it with the additional `0.01` factor stored at `0xbed80`.
-  - `parse_room_record` initializes `z_velocity_step` to `1.0` after parsing; it is a live runtime field, but it is not currently derived from an additional room-script token.
-  - the trailing `reserved_string_38`, `reserved_string_3c`, and `reserved_string_40` slots are confirmed `char *` fields.
-  - parser order is now explicit: after `room_name` and `music_path`, the parser reads three additional strings into `reserved_string_38`, `reserved_string_3c`, and `reserved_string_40`, then reads the separately validated `palette_path`, then the `dimmable` token, then `on_enter_tag` / `on_exit_tag`.
-  - `free_loaded_world_data` frees `palette_path`, `reserved_string_38`, `reserved_string_3c`, and `reserved_string_40` as room-owned strings, which confirms that the old `runtime_40` interpretation was wrong. In the sampled shipping `HARVEST.SCR` data all three reserved strings are empty.
-
-### Helper Functions
-
-- `strcmp_ascii` at `0x86430` is a fast `strcmp`-style helper that returns `0` on equality.
-- `atoi_decimal` at `0x869d4` is a small decimal parser used heavily by the room event system.
-- `hex_nibble_to_ascii` at `0x80e82` is the small `0..15 -> 0..9/a..f` helper used while building temporary file-stream names during `shutdown_file_stream`.
-- `rescale_entity_sprite_for_depth` at `0x4b440` rescales an entity sprite from the current room depth model.
-  - It applies the room-wide depth slope derived from `full_scale_z`, `max_z_scale_percent`, and `max_z`, rescales the backing bitmap, and updates the runtime width / height / anchor offsets.
-- `scale_raw_bitmap_nearest` at `0x237c0` is the `RawBitmap` scaler under `rescale_entity_sprite_for_depth`.
-  - It grows or reallocates the destination bitmap as needed and uses nearest-neighbor duplication/sampling depending on whether the target size expands or shrinks.
-- `set_entity_screen_position` at `0x4b6f0` writes the live entity screen-space `x`, `y`, and depth/z anchor used by overlap and render ordering.
-- `update_visible_entity_screen_position` at `0x4c860` is the visible-entity wrapper used by `sync_cursor_entity_position`.
-  - It only runs while the entity's visibility nesting count is nonnegative.
-  - It forwards the entity plus the shared dirty-rect list into `update_render_entity_screen_position`, using the entity's live anchor offsets when deriving the new screen-space position.
-- `add_entity_to_render_list` at `0x5c130` inserts render entities in descending live-depth order from offset `+0x1c`, rescales them before insertion, and merges the affected dirty rect into `g_dirty_rect_list`.
-- `update_render_entity_screen_position` at `0x5cc10` is the shared screen-position updater for live render entities.
-  - When the depth/z anchor is unchanged, it keeps the entity in place in the render list and merges the old/new screen rectangles into `g_dirty_rect_list`.
-  - When the depth/z anchor changes, it removes the entity from `g_render_entity_list`, rewrites the live `x` / `y` / `z`, rescales the sprite for depth, reinserts the entity in depth order, and then merges the new dirty region.
-  - It uses `do_rects_overlap` at `0x5e0d0` and `compute_rect_area` at `0x5e0c0` to decide when dirty rectangles should be merged.
-- `do_entity_screen_bounds_overlap` at `0x4b700` is the rectangle-only overlap test used before the more specific cursor-hit and actor/region gating paths.
-- `tick_entity_visual_state` at `0x4cae0` is the shared entity animation/collision step for render-list classes below the actor layer.
-  - For moving/entity-overlap users it first checks whether the live depth intervals `[z, z + z_extent]` overlap.
-  - Only after that Z gate does it call `do_entity_screen_bounds_overlap`.
-  - The final collision confirmation is `FUN_0004a9e0`, which compares two live bitmap payloads and only reports overlap when both pixels are nonzero.
-  - Class `3`, `0x15`, and `0x19` are skipped by that entity/entity overlap path.
-  - On collision it stores the blocking render-entity pointer at runtime offset `+0x109c`; the confirmed read-side consumers of that slot are in `update_actor_runtime_state`.
-- `update_actor_runtime_state` reuses that blocking-entity slot in two confirmed ways:
-  - In the directional movement states it treats `+0x109c` as the current blocker, ignores null/hidden/self-recent blockers, and otherwise cancels the current move state, zeros the movement deltas, and copies the blocker into one of four directional blocker-history slots.
-  - The directional mapping is now explicit from the branch-local state writes and signed movement deltas:
-    - `+0x108c` is the upward blocker-history slot
-    - `+0x1090` is the downward blocker-history slot
-    - `+0x1094` is the leftward blocker-history slot
-    - `+0x1098` is the rightward blocker-history slot
-  - `set_object_visibility_for_owner_or_room` clears those four slots individually when a hidden entity still matches one of the remembered blockers, which confirms that they are persistent blocker pointers rather than generic movement flags.
-  - In the close-range attack/grapple states it treats `+0x109c` as the current target entity, links the attacker/target pair through runtime field `+0x11b8`, mirrors the attacker's current depth into the target, and can force the target into a reaction / death sequence depending on the target class and active scene flags.
-- `hide_entity_visual` at `0x4c260` and `show_entity_visual` at `0x4c290` are the nested runtime visibility wrappers for render entities.
-  - `hide_entity_visual` decrements the visibility nesting and removes the entity from `g_render_entity_list` only on the transition to hidden.
-  - `show_entity_visual` increments the nesting and, when the entity becomes visible again, refreshes the current frame bitmap and re-adds it to `g_render_entity_list`.
-  - The recentering path only runs when the internal anchor flag at `+0x4f` is clear.
-  - That path subtracts half the current bitmap width and height from the stored screen position and subtracts half `z_extent` from the live depth before the render-list update.
-  - For the normal actor path with that anchor flag left set, the stored `screen_x` / `screen_y` remain pre-offset bases and the live frame `x_offset` / `y_offset` are applied later by the visual refresh path, so `room_setup` writes actor screen bases rather than final top-left pixel coordinates.
-- `is_cursor_within_entity_bounds` at `0x4b670` is the rectangle gate for runtime entities; class `0x16` skips hit-testing entirely.
-- `is_cursor_over_entity_hitmask` at `0x4b5b0` adds the transparent-pixel test on top of that rectangle gate.
-  - Runtime classes `0x15`, `0x18`, and `0x19` bypass the per-pixel transparency check and behave as rectangle-only hotspots.
-- `spawn_scaled_abm_entity_from_resource` at `0x4bda0` is the scale-managed variant of `spawn_abm_entity_from_resource`.
-  - It loads the same ABM resource format, but additionally marks the runtime entity for depth scaling and seeds the live sprite-scale field before the first `show_entity_visual` / render-list add.
-  - `run_harvester_main_loop` uses it for `IDLE_ANIM` with `graphic\\roomanim\\pcloun02.abm`, copying the live player avatar's current scale so the temporary idle actor inherits the same room-depth sizing.
-- `spawn_bitmap_entity_from_resource` at `0x4ace0` is the raw-bitmap / UI-panel spawner used by menus, dialogue heads, startup overlays, and other non-ABM visuals.
-  - It does not build the full `RenderEntityRuntime`; instead it allocates the smaller 0x4c render-list wrapper also used by `spawn_text_entity`.
-  - Offset `+0x0` points to the primary 12-byte `BitmapBuffer { width, height, pixels }`, while `+0x4` mirrors that same pointer as the current-bitmap slot.
-  - The shared low-offset header still matches `initialize_entity_common_fields`: name at `+0x10`, depth data at `+0x1c` / `+0x20`, screen position at `+0x3c` / `+0x40`, cached width/height at `+0x2c` / `+0x30`, and render-list links at `+0x44` / `+0x48`.
-  - When the optional external pixel buffer pointer is null it allocates pixel storage itself; otherwise it reuses the caller-provided buffer and only refreshes the copied pixel payload.
-- `reload_bitmap_entity_pixels_from_resource` at `0x4b7a0` only refreshes the pixel payload of that same wrapper and aborts if the resource width or height changes, so dialogue portrait swaps and similar callers must keep the same bitmap dimensions.
-- `free_entity_bitmap_buffers` at `0x4b3a0` tears down the primary and optional secondary `BitmapBuffer` slots for the small wrapper.
-  - It intentionally skips freeing pixel storage when the entity name is `STAT` or `LOGO`, which indicates those two bitmap entities can point at shared externally owned pixel buffers.
-- `attach_abm_resource_to_entity` at `0x4c0f0` is the shared decoded-ABM binding layer under the runtime-entity spawners and combat-avatar reloads.
-  - It stores the decoded ABM blob on the live entity, builds the per-frame pointer table, allocates the current frame bitmap storage, and seeds animation frame `0`.
-  - Callers now confirmed from the binary are `spawn_abm_entity_base`, `spawn_abm_entity_from_resource`, `spawn_scaled_abm_entity_from_resource`, `spawn_anim_entity_from_record`, `set_player_combat_loadout`, and `reset_player_combat_avatar`.
-- `queue_npc_death_or_monsterfy_transition` at `0x53810` is the small helper that queues runtime actor state `0x35` for a live NPC target.
-  - `parse_command_record` now confirms the room-event callers are `KILL_NPC` and `MONSTERFY`.
-  - It is also shared by the close-range NPC hit path inside `update_actor_runtime_state`.
-  - It clears live byte `+0x50`, preserves the current animation bank, and reseeds the current animation frame after forcing state `0x35`.
-  - It does not set `NpcRecord.death_or_monsterfy_flag` on its own; that record-level suppression is deferred until the class-4 death bank reaches its last frame inside `update_actor_runtime_state`.
-- `teardown_monster_entity_runtime_state` at `0x541c0` is the monster-specific wrapper over the shared runtime-entity teardown path.
-  - `destroy_entity_list` and `dispatch_room_event_actions` use it only on class-6 monster removal flows.
-- `tick_monster_entity_runtime` at `0x54140` is the class-6 monster wrapper around `update_actor_runtime_state`.
-  - `run_harvester_main_loop` uses it for live monster entities, and the wrapper tears down the runtime object when the shared actor state machine reports removal.
-  - When `animation_advanced_this_tick` is still clear, the wrapper only refreshes the entity through `tick_entity_visual_state` and does not run the locomotion/combat state machine yet.
-- `spawn_player_combat_avatar` at `0x54220` reuses the monster runtime entity base for the player during combat / weapon-view mode.
-  - It seeds player HP/state and installs the weapon-dependent attack / hit / footstep / death sounds.
-  - The same setup now bounds four live actor runtime fields tightly enough to treat them as confirmed:
-    - `+0x11bc` is the selected player combat loadout / equipped weapon id.
-    - `+0x1160` and `+0x1164` are the alternating movement-step sample slots.
-    - `+0x1168` is the death sample slot.
-  - The same writer path now also bounds the loadout-specific attack cluster:
-    - `+0x1134` is the attack sample count.
-    - `+0x1148`, `+0x114c`, and `+0x1150` are the attack sample slots.
-    - `+0x116c` is the attack-sound trigger frame offset.
-    - `+0x113c` is the attack contact frame offset.
-    - `+0x1180` is the per-hit damage amount.
-- `set_player_combat_loadout` at `0x55c10` applies the persisted player combat loadout selection stored at live actor field `+0x11bc`.
-  - It is called by the inventory UI when the player changes weapons and by save/load restoration when the saved selection differs from the current live one.
-  - It tears down the current attack sound bank, loads the new weapon-dependent attack sounds, reloads the `pc*.abm` actor art, and refreshes the player combat avatar's attack tuning.
-  - The tuning it refreshes matches the spawn path exactly: `+0x1134` attack sample count, `+0x1148/+0x114c/+0x1150` attack sample slots, `+0x116c` attack-sound trigger frame offset, `+0x113c` attack contact frame offset, and `+0x1180` per-hit damage amount.
-  - The confirmed native per-hit damage table is `0:1`, `1:4`, `2:2`, `3:7`, `4:6`, `5:6`, `6:3`, `7:3`, `8:3`, `9:3`, `10:3`, `11:3`, `12:4`, `13:5`, `14:8`, `15:3`, `16:3`, `17:3`, `18:3`, `19:3`, `20:2`; in particular, `CHAINSAW` is the high `8`-damage loadout while `HARVEST_BLADE` stays at `3`.
-  - The ABM path table it indexes is now labeled `g_player_combat_loadout_abm_paths` at `0xc3eb4`.
-  - That table is now bounded as a direct 21-entry loadout map from ids `0..0x14` to `1:\\graphic\\monsters\\pc\\pc0.abm` through `1:\\graphic\\monsters\\pc\\pc20.abm`.
-- `sync_player_combat_weapon_resource_icons` at `0x792c0` updates the HUD resource-icon strip/count pair for the current player combat loadout.
-  - The loadout-to-resource mapping now confirmed from `set_player_combat_loadout` is:
-    - `2`: nailgun
-    - `3`: shotgun shells
-    - `4`: `9GUN` bullet strip (`MMBULLET%i`, `bullet.bm`)
-    - `5`: `38GUN` bullet strip (`bullet.bm`)
-    - `0xe`: chainsaw fuel
-  - The corresponding globals are now named `g_nailgun_ammo_icon_strip`, `g_shotgun_shell_icon_strip`, `g_9gun_bullet_icon_strip`, `g_38gun_shotshell_icon_strip`, `g_chainsaw_fuel_icon_strip`, their matching count fields, and `g_visible_weapon_resource_icon_count`.
-  - `run_harvester_main_loop` pre-spawns those icon-strip entities at `x = 10 + 15 * i`, `y = 30` with capacities `16` nails, `16` shotgun shells, `8` `9GUN` bullets, `6` `.38` bullets, and `16` gas cans.
-  - Decoded `HARVEST.SCR` confirms the story-script shell progression: `CLOAK_ADD -> ADD2INV SHOTGUN -> ADD_SHOTGUN_SHELLS 2`, while the `SHOTSHELL` pickup object in `SECRETROOM` uses `ADD12SHELLS -> ADD_SHOTGUN_SHELLS 14`. Combined with the native `dispatch_room_event_actions` clamp at `15`, that yields the expected `2` shells on shotgun pickup and `15` after the box.
-- `teardown_player_combat_avatar` at `0x55010` removes the live player combat avatar and frees its weapon-dependent runtime resources before a rebuild or shutdown path.
-- `teardown_entity_runtime_state` at `0x55030` is the shared runtime-entity cleanup layer under the combat-avatar teardown/rebuild paths.
-  - It clears the linked spawned actor at `+0x11a8`, frees the runtime sound/sample fields from `+0x1148` through `+0x1168`, resets the entity to a hidden offscreen idle state, and frees the current bitmap buffers without freeing the outer runtime-entity allocation.
-- `check_player_region_interaction` at `0x559e0` scans `g_region_records` for the first enabled region whose bounds overlap the live player avatar and whose facing gate matches the player's orientation.
-  - On success it returns the matching `RegionRecord *` so the main loop can dispatch the region's `action_tag`.
-  - The overlap test uses `do_entity_screen_bounds_overlap` at `0x4b700`, which compares entity draw bounds with edge-inclusive `<` rejection checks before the Z-range and facing checks.
-  - The cursor-enabled region click path in `run_harvester_main_loop` stores the region entity center X as the player's target, applies the native 10-pixel left-side bias only when that target is left of the player's current frame-left X, and stores region target Z from the required facing: `FRONT` uses max Z, `BACK` uses min Z, and side-facing exits use the region midpoint.
-- `reset_player_combat_avatar` at `0x56a40` is the player-avatar reset helper used on teardown / restart paths.
-  - It zeroes transient combat/runtime state, restores the default health/state baseline, and if the current loadout is non-default it rebuilds the player combat avatar back through loadout `0`.
-- `update_actor_runtime_state` at `0x4d750` is the shared per-frame actor state machine for live actor entities.
-  - It is called from `run_harvester_main_loop`, the dialogue/response/keyword modal loops, and the player-combat wrapper, so actor motion/combat continues advancing while blocking UI is active.
-  - It handles class-4/5/6 actor entities, advances animation/state transitions, maintains pursuit spacing against the player using `engage_distance`, applies room Z bounds and vertical motion, fires frame-timed sound hooks, resolves hit damage through `damage_amount`, and returns `0` when the caller should remove the actor entity from the world list.
-  - For player resource-backed loadouts `2`, `3`, `4`, `5`, and `0xe`, the player-attack branch calls `consume_weapon_ammo_and_play_fire_or_empty_sound` on the zero-ammo and fire-commit checkpoints. That helper decrements the corresponding HUD/resource count when ammo is present, falls back to the empty-click sample when the count is already `0`, and the empty-ammo path returns before the later contact-resolution branch.
-  - The close-range hit-resolution block reuses the hidden helper at live actor field `+0x11a8`: it links attacker/target runtime pointers, forces the helper back to frame `0`, repositions it over the struck actor, and shows it when the helper was still hidden.
-  - The native locomotion constants now read cleanly out of the state machine: left/right walk uses `depth_scale * 8.0`, the reverse-step families use `depth_scale * 16.0`, the class-6 monster pursue band is `g_player_combat_avatar->z +/- 2.0`, and the horizontal chase waypoint slack is `depth_scale * 50.0`.
-  - Native player room walking keeps screen-space Y separate from depth:
-    - `spawn_player_combat_avatar` seeds live actor field `+0x1140` with `1`
-    - the vertical walk states copy `abs(+0x1140)` into `frame_y_step`
-    - they apply `+/- g_current_room_def->z_velocity_step` separately through `frame_z_step`
-    - the later `set_entity_screen_position` handoff commits `screen_y` and `z` independently, so native walk updates do not derive screen Y from depth each tick
-  - Empty-floor click targets are also component-based rather than diagonal:
-    - `run_harvester_main_loop` stores cursor `x` in live actor `+0x10ac`, stores the mapped target depth in `+0x10b4`, and seeds the target/waypoint sentinel at `waypoints[8].target_z`
-    - `update_actor_runtime_state` treats horizontal target satisfaction as `frame_left_x` within `depth_scale * 50.0` of the stored target `x`, then clears that component
-    - depth target satisfaction uses the native `+/- 8.0` Z band; vertical walk advances screen Y by the `+0x1140` step and depth by `g_current_room_def->z_velocity_step`
-    - because the horizontal branch is evaluated after the depth branch and can override the desired state while X is still pending, a click that needs both rightward and downward movement walks right first, then switches to the vertical/down walk once X is satisfied
-    - when a pending horizontal click component is blocked, the state machine pushes a perpendicular Z waypoint around the blocking entity using `blocker.z - actor.z_extent - 1` or `blocker.z + blocker.z_extent + 1`, clears the pending X component, and records the blocker in the matching directional history slot
-    - when a pending vertical/depth component is blocked, it pushes a perpendicular X waypoint using `blocker.left - actor.frame_width - 0x28` or `blocker.right + 0x28`, clears the pending Z component, and uses the blocker-history slots to avoid immediately rejecting the detour step against the same blocker
-  - For class-6 monsters specifically, pursuit is not a free-running room-script heuristic: the native state machine first closes base Z to within `g_player_combat_avatar->z +/- 2.0`, then compares center-to-center X against `engage_distance`, and only arms the close-range attack picker after that spacing gate is satisfied.
-  - The same class-6 path derives its horizontal chase waypoint from the player's live left edge plus the current player/monster frame widths and treats that waypoint as satisfied within `50.0 * depth_scale` pixels, so native monster pursuit does not require exact center equality before it can stop walking and attack.
-  - Once that close-range gate is satisfied and the per-attack cooldown allows it, the class-6 branch stores `g_player_combat_avatar` into live actor field `+0x11a4` before entering the close-range attack family; the later contact block resolves against that linked target instead of re-running the coarse engage-distance spacing test.
-  - The class-6 cooldown latch is also now bounded: runtime dword `+0x1144` stores `current_tick + random(99)` when an attack is armed, and the state machine only re-arms once `stored_tick + 0x32 < current_tick` unless the target is already within `engage_distance / 3`.
-  - Fresh monster spawns do not wait in a neutral idle loop: `spawn_monster_entity_from_record` immediately seeds desired state `0x07` or `0x0e` from the monster's placement relative to the player, which is the native locomotion handoff into left/right pursuit.
-  - It also contains the confirmed NPC-to-monster replacement path used by the `change2monster` flow.
-  - The attack-state reader side is now bounded directly from states `0x16` through `0x1b`:
-    - monster melee attack-family selection is side-based rather than target-Y based: targets on the actor's left pick from `0x16/0x17/0x18`, while targets on the right pick from `0x19/0x1a/0x1b`
-    - within that side, the picker is randomized across the available family states instead of deterministically mapping upper/mid/lower target bands
-    - it treats `+0x1134` as the attack sample count and randomly selects from `+0x1148/+0x114c/+0x1150`
-    - it plays the selected attack sample when `current_frame == anim_base + +0x116c`
-    - it waits until `current_frame >= anim_base + +0x113c` before resolving contact
-    - on a confirmed hit it subtracts attacker `+0x1180` from victim HP
-    - no additional post-hit multiplier is present in the recovered native path; both player and monster hits use the per-loadout or per-record value that was already seeded into `+0x1180`
-    - for keyboard-driven player attacks, the same contact path picks the nearest live target on the chosen side; upper/lower side attacks also require the target center to stay within a `30`-pixel horizontal side window and to overlap the attacker's live Z/depth span
-  - Runtime dword `+0x118c` is now bounded as a mixed combat/capability mask rather than as a pure damage-type field.
-    - its low bits carry the attack damage type (`1 = BLUDGE`, `2 = SLASH`, `4 = PROJ`)
-    - the player combat-avatar path seeds it with `0x00fffff8` before adding those low bits, while monster construction copies `MonsterRecord.damage_type` and then ORs extra low-byte availability bits from ABM frame counts
-    - in the player attack path, `(+0x118c & 4) == 0` stays on the close-range hit-resolution branch, while class-5 attacks with bit `4` set use the separate ranged target-acquisition / ammo-count branch; the chainsaw fuel check is a separate loadout-specific pre-branch case
-    - its confirmed low-byte facing-bank bits are `0x08` front, `0x10` left, `0x40` right, and `0x20` back; monster spawn uses those as the default initial stance banks when the matching alternate `+0x118e` high-nibble bank is absent
-    - the same low-byte bits are later consumed by the locomotion/turn-family entries rooted at states `4`, `7/0xf`, `8/0xe`, and `0xb`
-    - low-byte bit `0x80` is still only bounded as the separate case-1 stance/turn-family availability gate: state-family `1` refuses its own idle/turn transitions unless that bit is present, and monster spawn derives it from the ABM frame slot exposed through live actor pointer `+0x84`
-    - outside the low byte, `0x1000` and `0x10000` are confirmed close-range attack-picker gates rather than generic locomotion bits: attacker `0x1000` is required before states `0x17/0x1a` can be chosen, while target `0x10000` is required before states `0x18/0x1b` can be chosen; the player combat-avatar base mask keeps both bits set
-  - Runtime byte `+0x118d` is an attack / hit-reaction bank-availability byte.
-    - bits `0x08`, `0x10`, and `0x20` gate the attack-state pairs `0x16/0x19`, `0x17/0x1a`, and `0x18/0x1b`
-    - bits `0x40` and `0x80`, together with `+0x118e & 0x01`, gate the hit-reaction state pairs `0x1c/0x1f`, `0x1d/0x20`, and `0x1e/0x21`
-  - Runtime byte `+0x118e` is the death-bank availability byte, with one shared hit-reaction gate in its low bit.
-    - bit `0x01` is the third hit-reaction gate used by states `0x1e/0x21`
-    - bits `0x02`, `0x04`, and `0x08` gate the death-state families `0x28/0x29/0x2e/0x2f`, `0x2a/0x2b/0x30/0x31`, and `0x2c/0x2d/0x32/0x33`
-  - Once `current_hit_points < 1`, native class-6 monsters do not disappear immediately. `update_actor_runtime_state` pushes them into facing-specific death states `0x28/0x29`, `0x2a/0x2b`, or `0x2c/0x2d` according to gore/damage-type availability, then leaves desired state `0x38` latched until the last death frame dispatches the monster record's `on_death_action_tag`.
-  - The same state machine also owns the terminal class-4 NPC death flow after desired state `0x35` has been queued.
-    - If the NPC has a monsterfy target, or if gore is enabled and the latched damage type is not `BLUDGE`, it picks death bank `0x3c..0x45`; otherwise it picks `0x46..0x4f`.
-    - The NPC remains live and untalkable during that bank because `death_damage_type` has already been latched while `death_or_monsterfy_flag` is still clear.
-    - Only after the last class-4 death frame does it set `NpcRecord.death_or_monsterfy_flag`, optionally activate the replacement monster, and dispatch the NPC record's `on_death_action_tag`.
-  - Runtime byte `+0x11b4` is now bounded for the player-combat path:
-    - `parse_command_record` maps room-event commands `PAUSE_PC` / `RESUME_PC` to action cases `0x25` / `0x26`, and those cases set and clear `g_player + 0x11b4`
-    - `update_actor_runtime_state` consults that byte only for class-5 player combat-avatar updates and returns early while it is set
-    - `run_harvester_main_loop` and `run_load_game_menu` clear it on return/reload paths
-  - Runtime dword `+0x1188` is a damage-type flag field.
-    - `spawn_npc_entity_from_record` and `spawn_monster_entity_base` clear it to `0`.
-    - `KILL_NPC` and `KILL_PC` assign `1`, `2`, and `4` for `BLUDGE`, `SLASH`, and `PROJ`.
-    - close-range hit resolution mirrors attacker `+0x118c` into victim `+0x1188`, and the death-state selector only consults the low damage-type bits there
-    - `update_actor_runtime_state` tests those bits during hit-reaction and death-state selection.
-  - Runtime dword `+0x11b8` is still only written reciprocally inside the close-range hit-resolution block and still has no recovered reader, so it remains an unresolved reciprocal combat-link slot.
-  - Runtime bytes `+0x11a1` and `+0x11a0` are now bounded separately:
-    - `+0x11a1` is the actor dispatch-state byte; `update_actor_runtime_state` switches on it directly.
-    - `+0x11a0` is the latched animation/state byte; it is usually mirrored into `+0x11a1` on direct state changes, but remains distinct during entry, turn, and terminal sequences.
-    - the room-NPC ambient state seeded by `spawn_npc_entity_from_record` is `0x34`; when both bytes remain `0x34` on a class-4 actor, the function bypasses the locomotion/combat switch and falls straight through to `tick_entity_visual_state`, so that state simply loops over the frame window the spawn path already seeded.
-  - The state families are now bounded at a subsystem level:
-    - `0` / `4` are the room-`min_z` locomotion family.
-    - `3` / `0xb` are the room-`max_z` locomotion family.
-    - `1` / `7` and `2` / `0xe` are the two opposing horizontal locomotion families.
-    - `5`, `6`, `9`, `0xa`, `0xc`, `0xd`, `0x10`, and `0x11` are bridging turn/transition handlers between those locomotion families.
-    - Those turn handlers are directional 5-frame banks rather than symmetric bank swaps:
-      `0x05` is up->left (`0x0a..0x0e` forward), `0x06` is left->up (same bank reversed),
-      `0x09` is left->down (`0x19..0x1d` forward), `0x0a` is down->left (same bank reversed),
-      `0x0c` is down->right (`0x28..0x2c` forward), `0x0d` is right->down (same bank reversed),
-      `0x10` is right->up (`0x37..0x3b` forward), and `0x11` is up->right (same bank reversed).
-    - `0x16` through `0x1b` are attack states; on a confirmed hit they assign victim states `0x1c` through `0x21` via `attacker_state + 6`, which bounds `0x1c` through `0x21` as hit-reaction states.
-    - `0x28` through `0x33` are death-animation entry states. They play the actor death sound, run the final animation bank, and fall into terminal state `0x38`.
-    - `0x35` is the NPC death / monsterfy transition state, and `0x38` is the terminal dead / removed state that dispatches `on_death_action_tag`.
-- `apply_pending_entity_knockback` at `0x53770` is the shared hit-reaction helper called by both `tick_monster_entity_runtime` and `update_player_combat_avatar_state`.
-  - Confirmed hit resolution inside `update_actor_runtime_state` seeds the victim's pending knockback pair as a signed shove at `+0x11ac` (`-18` or `+18`) plus decay step `+0x11b0 = 3`.
-  - The helper copies that pending value into the live shove slot at `+0x1088` when idle, then decays the pending value toward zero.
-- `update_player_combat_avatar_state` at `0x553a0` is the player-side wrapper around `update_actor_runtime_state`.
-  - It reads combat input globals, translates them into desired player actor states, updates the player combat avatar, and then delegates the shared animation/combat resolution to `update_actor_runtime_state`.
-  - It timestamps live activity with `get_elapsed_milliseconds`, and like the monster wrapper it only falls through into `update_actor_runtime_state` after the shared animation path has reported `animation_advanced_this_tick`; otherwise it only calls `tick_entity_visual_state`.
-  - Shared input globals recovered from both the town-map UI and player-combat wrapper are now named:
-    - `g_input_up_pressed` at `0xd5954`
-    - `g_input_left_pressed` at `0xd5957`
-    - `g_input_right_pressed` at `0xd5959`
-    - `g_input_down_pressed` at `0xd595c`
-  - `g_player_attack_pressed` at `0xd5929` is the player-only combat input that selects attack states `0x16` through `0x1b`, and `g_player_attack_cooldown_deadline` at `0xd5a74` rate-limits repeated attack entry.
-  - The same wrapper also confirms that live actor field `+0x11bc` is the player combat loadout selection: it is seeded by `spawn_player_combat_avatar`, changed by `set_player_combat_loadout`, and serialized through the save/load path.
-  - `g_input_right_shift_pressed` at `0xd5942` is the separate Right Shift modifier gate, not a duplicate attack input.
-  - The wrapper also enforces native horizontal room bounds against the live opaque sprite edges, not an abstract `0..639` center clamp:
-    left-facing states stop when `screen_x + frame_x_offset <= 4`, and right-facing states stop when
-    `screen_x + frame_x_offset + scaled_bitmap->width >= 0x27c`.
-  - Under that gate, `g_input_up_pressed` selects states `0x12` / `0x13` by facing, and those states apply horizontal velocity `-16` / `+16`, opposite the current facing family. That bounds `0x12` / `0x13` as fast reverse-step states.
-  - The paired down-input states `0x14` / `0x15` are selected from the same modifier gate and use the slower signed horizontal speeds `-8` / `+8`, again opposite the current facing family. That bounds `0x14` / `0x15` as the slower reverse-step pair.
-  - States `0x39` and `0x3a` are now bounded as forced horizontal shove states. They install fixed left/right-facing animation banks, drive `x` velocity as `-8` / `+8`, and fall back to locomotion states `1` / `2` once the actor hits world bounds or a blocking entity.
-- `set_object_visibility_for_owner_or_room` at `0x62f10` updates an object's `visible` byte for a specific owner/room string and synchronizes the live entity when that location is currently active.
-  - Callers pass `EBX = 1` for show and `EBX = 0` for hide; the helper is used by room-event opcodes and by the inventory-status panel toggles.
-- `is_object_in_inventory` at `0x7c870` checks whether a named object exists, is currently owned by `INVENTORY`, and is visible.
-- `add_object_to_inventory` at `0x7c8b0` moves a named object into `INVENTORY`, forces it visible, and marks its IDENT text as already shown.
-- `dispatch_action_tag_if_set` at `0x7cb80` is a tiny guard thunk that jumps into `dispatch_room_event_actions` only when the supplied action-tag pointer is non-null.
-- `run_inventory_screen` at `0x7df10` is the inventory UI loop.
-  - It lays out carried objects, shows `inventory_text_key` strings from `TEXT` records, handles use-on-target dispatch, and swaps the status panel object based on current health.
-  - The weapon right-click branch maps the weapon subset (`CLEAVER` through `POOLSTICK`) to loadout ids `1..20`, removes any visible weapon-resource strip, formats `Disarming %s...` or `Arming %s, %i ...` inside `BOX1`, then calls `set_player_combat_loadout`.
-  - After that `BOX1` weapon-toggle path, native clears the temporary text/panel state, sets the inventory-loop exit flag, and returns to the caller instead of remaining in the inventory loop.
-  - The formatted `%s` weapon label is not the full `_STEXT` tooltip payload. Decoded town-script data shows entries like `SHOTGUN_STEXT = "shotgun,_right_click_to_arm/disarm"` and `NAILGUN_STEXT = "nailgun;_right_click_to_arm/disarm"`, so the native inventory message path is using the base label up to the first comma or semicolon.
-  - The same native right-click branch does not expose a generic shell-box use path: after the hardcoded document/photo table it falls through only for `SANDWICH`, `SANDWICH2`, `SYRINGE`, `ST_ASPRIN`, `ST_COUGHM`, and `ST_VITAMN`. `SHOTSHELL` ammunition therefore has to be granted by the pickup action, not by later inventory use.
-- `run_game_over_screen` at `0x7c540` is the death/game-over UI branch reached from the main loop after the player combat avatar enters its terminal failure path.
-- The low-level PCM/sample helpers are now bounded:
-  - `load_sound_sample` at `0x18470` loads a one-shot sample through XFILE, accepting raw `WAVE` data or `FCMP`-compressed audio, and seeds the sample format/rate metadata used by the audio backend.
-    - On `FCMP` inputs it decodes the full payload but queues playback from `decoded_pcm + 0x1f4`, so the first `500` decoded bytes are discarded before the ring buffer fill.
-  - `start_sound_state_playback` at `0x197b0` allocates a backend voice slot for an initialized `PcmSoundState` and records the active playback token in that state.
-  - `is_sound_state_playing` at `0x198e0` polls whether the recorded voice/token pair for a `PcmSoundState` is still active.
-  - `stop_sound_state_playback` at `0x19840` stops the active backend voice for a `PcmSoundState` without freeing the sample data.
-  - `free_sound_effect_sample` at `0x18a30` stops any active playback and releases the loaded sample buffer.
-  - The backend state those helpers rely on is now partially explicit:
-    - `g_sound_driver_initialized` gates all recovered backend voice/sample operations.
-    - `g_sound_voice_bank_index` selects the current bank inside `g_sound_voice_slot_refs`.
-    - `g_sound_voice_slot_refs` at `0xc445c` is a banked table of 32 six-byte far pointers to backend voice control blocks per bank.
-    - `g_sound_voice_token_counter` increments on each new playback start and is copied into both the sample state and the backend voice block to invalidate stale slot handles.
-  - The slot-level backend helpers are now named:
-    - `allocate_sound_voice_slot` at `0x82f06`
-    - `is_sound_voice_slot_stopped` at `0x85bf1`
-    - `stop_sound_voice_slot` at `0x8351f`
-    - `set_sound_voice_volume` at `0x85510`
-    - `get_sound_voice_playback_position` at `0x856e6`
-  - The backend timer/rate control path is now explicit:
-    - `set_sound_voice_sample_rate` at `0x85050` stores a requested playback rate in `g_sound_voice_sample_rates`, recomputes the per-voice fixed-point step in `g_sound_voice_step_increments`, and lowers the shared mixer divisor when a faster voice requires it.
-    - `clear_sound_voice_sample_rate` at `0x851c8` clears one voice's requested rate, recomputes the highest remaining active rate across the 16 backend voice slots, and rebuilds the shared divisor/step table.
-    - `set_sound_mixer_timer_divisor` at `0x853a1` writes `g_sound_mixer_timer_divisor` and immediately calls `program_irq0_pit_divisor`.
-    - `program_irq0_pit_divisor` at `0x8b1ad` masks IRQ0, programs PIT channel 0 through ports `0x43` / `0x40`, and then unmasks IRQ0 again.
-    - `install_irq0_timer_handler` at `0x8b30d`, together with `mask_irq0_timer_interrupt` and `unmask_irq0_timer_interrupt`, is the low-level IRQ0 refresh path that reinstalls the recovered timer handler and restarts the counter.
-    - The constant `0x1234dc` in the rate math is the PIT input clock `1193180`, which is what anchors the divisor and step-increment interpretation.
-    - `set_selected_sound_voice_sample_rate` at `0x183c0` is the recovered wrapper above that slot layer; it forwards the caller-provided rate into `set_sound_voice_sample_rate` using the selected voice-slot index stored at offset `+0x14` of the driver callback context.
-    - `unlock_sound_driver_linear_regions` at `0x83c34` walks a fixed list of backend code/data ranges and calls DPMI `int 31h, ax=0601` through `unlock_linear_region_via_dpmi` on each one.
-    - `release_sound_driver_linear_region_locks` at `0x83f73` is the public wrapper that runs that unlock pass and clears the backing region-lock flag.
-    - `refresh_irq0_timer_handler_if_active` at `0x84e09` is the small guard wrapper that reruns the mask/install/unmask IRQ0 sequence only when the backend timer layer is active.
-    - `count_active_sound_voice_slots_in_bank` at `0x85b68` counts the backend voice slots within one bank whose status word at `+0x30` still carries the active `0x8000` bit.
-    - The callback object rooted at `0xca194` is now partially constrained:
-      - `g_sound_driver_callback_context` at `0xca194` is now typed as a minimal `SoundDriverCallbackContext`
-      - offset `+0x14` is `selected_voice_slot_index`; it is also labeled at `0xca1a8` as `g_selected_sound_voice_slot_index`
-      - offset `+0x18` overlaps `g_sound_voice_bank_index`
-      - offset `+0x20` overlaps `g_sound_driver_initialized`
-      - the descriptor blob at `0xc0c98` points at this callback object directly, which is what ties the table-driven wrappers back to the current-bank driver context
-      - `set_selected_sound_driver_control_value` at `0x183e0` writes a 16-bit value through `set_driver_control_entry_value` for the currently selected bank/control index when the backend is initialized
-      - `count_active_sound_voice_slots_for_selected_bank` at `0x18400` returns the active-slot count for that same selected bank
-      - `release_selected_sound_bank_resources` at `0x18380` clears the selected voice-rate entry, releases the selected bank through `release_sound_bank_resources`, refreshes IRQ0 timing state, and then drops the linear-region locks
-      - the shared packed callback adapter at `0x81448` is now `report_undefined_constructor_or_destructor_called`; it emits the exact runtime string `undefined constructor or destructor called!` and aborts through the Borland runtime error path.
-      - the smaller packed descriptor entries at `0xc0ccc`, `0xc0cec`, and `0xc0d0c` are therefore mixed Borland object-lifecycle records rather than a PCM-only callback table:
-        - each `0x20`-byte entry begins with `3`, then a direct callback pointer and a state-object pointer
-        - the trailing callback/size fields are stored as 24-bit values left-shifted by 8 inside 32-bit slots
-        - those packed fields resolve to an object-specific secondary callback, the shared undefined-ctor/dtor adapter, the direct callback again, and the state size
-        - representative entries pair `set_vesa_video_mode_3` / `reset_video_surface_context` with `g_video_surface_context` and size `0x10`, `FUN_00047ff0` / `reset_town_script_runtime_state` with `g_town_script_runtime_state` and size `0x20`, and `remove_keyboard_irq1_handler_state` / `install_keyboard_irq1_handler_state` with `g_keyboard_irq1_handler_state` and size `0x8`
-      - the first music-facing entry at `0xc0c90` is an extended variant of that same packed format: it binds `release_selected_sound_bank_resources` to `g_sound_driver_callback_context` and `release_music_stream_buffers` at `0x1a440` to `g_music_stream_state`, but the remaining packed size/order fields still do not type cleanly enough to formalize as a struct
-    - `release_sound_bank_resources` at `0x84873` is the shared bank teardown helper beneath that callback layer; it frees the bank's transient DOS buffer when present, clears per-bank tables, and may trigger the additional DPMI unlock/free path depending on the hidden caller flag.
-    - `initialize_sound_bank_resources` at `0x83fb3` is the matching per-bank setup path used by `initialize_sound_driver_from_sound_cfg_or_autodetect`.
-      - It scans five allocator-visible bank slots (`idx < 5`) for a free slot, loads or reuses the HMIDRV386 flag-`0x80` and optional flag-`0x40` records for that slot, allocates the transient DOS buffer through `allocate_sound_bank_temp_dos_buffer` when the selected config requests one, captures the callback-provided control-entry far pointers, runs the HMI callback init sequence, and then copies the selected driver config into the per-slot runtime tables.
-      - Those runtime tables are now partially typed in Ghidra: the live flag-`0x80` and flag-`0x40` selector pairs resolve as `g_sound_bank_flag80_primary_far_ptrs`, `g_sound_bank_flag80_secondary_far_ptrs`, `g_sound_bank_flag40_primary_far_ptrs`, and `g_sound_bank_flag40_secondary_far_ptrs`; the transient DOS buffer far pointers resolve as `g_sound_bank_temp_dos_buffer_far_ptrs`; the refreshed `0x6a` callback payload sits in `g_sound_bank_hmi_callback_records`; and the caller-provided config snapshot copied after successful init sits in `g_sound_bank_config_snapshots`.
-      - Within `g_sound_bank_hmi_callback_records`, `temp_buffer_fill_mode_code` controls the transient DOS-buffer fill pattern used during setup, `callback_flags` bit `0x02` triggers the extra op11->op12 handshake, and `flag40_record_id` supplies the fallback record ID for `load_hmidrv386_flag40_record_into_selector_pair` when the caller record leaves its explicit override dword at zero.
-      - The upstream `g_sound_bank_setup_source_record` at `0xc2d2c` is the static bank-setup source block passed into `initialize_sound_bank_resources`. Within `g_sound_bank_config_snapshots`, `requested_mix_rate_hz` is now confirmed from the caller side: `initialize_sound_driver_from_sound_cfg_or_autodetect` seeds that source record with `0x5622` (`22050`) immediately before bank setup, and `initialize_sound_bank_resources` forwards the same dword into `call_hmi_driver_callback_op2` as the only payload register on that path.
-      - The source-record dwords at `+0x14` and `+0x18` remain unresolved. In the shipped image both are zero-initialized, `+0x18` is preserved into `g_sound_bank_config_snapshots.reserved_setup_dword`, and no independent producer or consumer was confirmed beyond the opaque `call_hmi_driver_callback_op0` setup path.
-      - There is no confirmed reserved sixth bank slot behind these slabs. The flag-`0x80`, flag-`0x40`, and temp-buffer tables are packed as five 6-byte entries each, and the only normal producer of a bank index is `initialize_sound_bank_resources`, which scans `idx = 0..4`; the looser `< 6` checks in lower helpers are therefore not evidence of a live slot `5`.
-      - `refresh_hmidrv386_flag80_record_from_callback` at `0x8aa3a` is the small guard/helper immediately above that path; it verifies the per-bank flag-`0x80` selector pair exists and then refreshes the live `0x6a`-byte callback record through `copy_hmi_driver_record_from_callback_op8`.
-    - The HMI detection/bootstrap layer beneath that callback object is now partially explicit:
-      - `initialize_hmidet386_context` opens `hmidet.386`, allocates a DPMI-backed transfer block plus a cloned selector pair, locks the mapped linear region, and reuses that setup across later detection passes.
-      - `find_first_detected_hmidet_driver_record`, `find_next_detected_hmidet_driver_record`, and `load_hmidet_driver_record_by_device_id` walk the `hmidet.386` record table, read candidate records through the DPMI transfer buffer, and cache the selected file offset for later initialization.
-      - When the candidate record advertises callback-driven environment setup, the bootstrap path pulls the callback-supplied environment-variable name/value tables, resolves those names against the process environment, writes the resolved far pointers back into the callback table, and only then runs the detect callback.
-      - The staging block at `0xca2d8` is now typed as `g_sound_selected_hardware_config`: `device_port`, `device_irq`, and `device_dma` are shared between the manual `SOUND.CFG` parse path and the HMIDET auto-detect path. The fourth dword currently remains only as `callback_op1_dx_word`, because it is populated from the detect callback's `DX` result and no higher-level meaning is yet confirmed.
-      - The helpers `copy_hmi_driver_record_from_callback_op8[_raw]`, `call_hmi_driver_callback_op0`, `call_hmi_driver_callback_op1[_capture_result]`, `call_hmi_driver_callback_op2`, `call_hmi_driver_callback_op3_*`, `call_hmi_driver_callback_op4`, `call_hmi_driver_callback_op5`, `call_hmi_driver_callback_op10_capture_ptr_triplet`, and `chain_hmi_driver_callback_op11_into_op12` are now bounded as thin numeric-opcode wrappers around that far HMI callback interface rather than independent leaf algorithms.
-      - `query_windows_enhanced_mode_status` and `query_windows_enhanced_mode_signature_raw` are the paired `int 2F, AX=1600` probes used to gate the Windows 386 / `int 4B` compatibility path during HMI driver setup and teardown.
-    - The special teardown branch inside `release_sound_bank_resources` is now bounded as an `int 0x4B` service path:
-      - `call_int4b_service_8108_with_temp_dos_block` at `0x84c87` frees an existing LDT selector, allocates a temporary 32-paragraph DOS block via DPMI `int 31h, ax=0100`, copies the 16-byte `g_int4b_service_8108_request_template` into that block, simulates interrupt `0x4B` with `AX=0x8108` through `g_int4b_real_mode_registers`, and then frees the DOS block again
-      - `simulate_int4b_service_8107_and_test_status_flag` at `0x874f0` and `simulate_int4b_service_8108` at `0x8755c` are the paired simulated-real-mode wrappers for services `0x8107` and `0x8108`; the exact driver semantics behind those services are still unresolved
-      - `allocate_dos_memory_block_via_dpmi`, `free_dos_memory_block_selector_via_dpmi`, and `free_ldt_selector_via_dpmi` are the supporting DPMI helpers beneath that path
-  - The higher-level streamed-music state on top of that backend is now explicit:
-    - `g_music_stream_state` at `0xca1d4` is the persistent room/menu/game-over music stream object.
-    - `g_current_music_path` caches the current music filename so room transitions and scripted overrides can restore it.
-    - `g_music_volume_level_index` indexes `g_audio_volume_curve`, the shared 16-bit volume ramp table used by both steady-state music volume and the fade helpers.
-    - `PcmSoundState` is the confirmed 0x88-byte base header shared by music streams and one-shot PCM/sample playback.
-      - The recovered fields are the active voice slot/token, ring-buffer pointer/segment/size, current volume-curve value, backend PCM flags, sample rate, and output bits per sample.
-    - `MusicStreamState` extends that base with the backing file handle/size, refill/pause bytes, fade controller fields, decoded chunk buffer pointer, embedded `ImaAdpcmDecodeState`, the raw 14-byte `FCMP` header buffer, the transient compressed chunk buffer, and the cached current path string.
-    - `ImaAdpcmDecodeState` is the embedded 0x30-byte `FCMP` decode sub-structure at `MusicStreamState+0xbc`.
-      - The recovered fields are the source/destination far pointers, compressed and decoded byte counts, nibble index, predictor, delta, cached source byte, current nibble, step size, step index, and output bits per sample.
-  - The stream-layer helpers are now named:
-    - `start_music_stream` at `0x1bfc0` swaps to a new room/menu music file, opens the backing XFILE stream, primes the first audio chunk, and allocates a backend voice slot.
-      - On `FCMP` music it zeroes the first `0x64` decoded bytes of the primed chunk before queuing it, matching a `50`-sample warmup mute on the common 16-bit streams.
-    - `update_music_stream` at `0x1b9a0` is the per-frame pump used by menus, dialogue waits, room logic, and the main loop; it refills stream buffers and applies pending volume ramps.
-    - `release_music_stream_buffers` at `0x1a440` frees the stream's decode/ring buffers, unlocks their linear regions, and stops the active voice slot if that slot/token still belongs to the stream.
-    - `pause_music_stream` at `0x1a620` stops the active voice without closing the underlying stream.
-    - `resume_music_stream` at `0x1a740` restarts a previously paused music stream.
-    - `fade_in_music_stream` at `0x1adf0` and `fade_out_music_stream` at `0x1b3b0` are the paired ramp controllers used by town-map and scripted transition paths.
-    - `set_music_stream_volume` at `0x19950` updates the live stream volume against the active backend voice.
-    - `get_sound_state_playback_position` at `0x199f0` returns the current playback cursor for an active `PcmSoundState` and is used by the FST movie player for A/V pacing.
-  - The shared PCM setup helpers beneath the sample and movie paths are now explicit:
-    - `initialize_sound_state_defaults` zeros the generic sound-state header used by dialogue-line playback and FST one-shots, then seeds the default volume-curve entry and baseline flags.
-    - `initialize_pcm_sound_state` allocates and zeroes the ring/sample buffer for a PCM stream, seeds sample rate / sample width metadata, and installs the default effects-volume curve entry.
-    - `get_pcm_byte_rate` returns `sample_rate * (bits_per_sample / 8)` for an initialized sound state.
-    - `queue_pcm_chunk_into_ring_buffer` copies a decoded PCM chunk into the shared ring buffer without overwriting the live playback cursor reported by the backend voice slot.
-    - The `FCMP` compressed-audio path is now bounded as IMA ADPCM:
-      - `initialize_ima_adpcm_decode_state` resets the embedded decode-state predictor, nibble index, and initial step values before a new compressed stream begins.
-      - `decode_ima_adpcm_chunk` expands `FCMP` payload bytes into 8-bit or 16-bit PCM using `g_ima_adpcm_index_adjust_table` and `g_ima_adpcm_step_table`.
-    - `set_sound_state_driver_control_handle`, `set_driver_control_entry_value`, and `g_sound_driver_control_entry_refs` expose a second, driver-side control indirection table:
-      - `set_sound_state_driver_control_handle` stores a handle/index into the per-sound-state field at offset `+0x18` and marks the state with the recovered `0x40` flag at `+0x35`
-      - the separate driver callback object at `0xca194` reuses that same low-level `set_driver_control_entry_value` helper through `set_selected_sound_driver_control_value`
-      - the exact gameplay meaning of that control value is still unresolved, but the indirection itself is confirmed
-  - `load_dialogue_voice_sample` at `0x191d0` is the `play_dialogue_line`-specific loader that accepts either `WAVE` or `FCMP` voice assets and prepares them for one-shot playback through the shared sample backend.
-    - Its `FCMP` path matches `load_sound_sample`: after full decode it queues from `decoded_pcm + 0x1f4`, discarding the first `500` decoded bytes before playback begins.
-  - `stop_sound_state_and_close_stream_file` at `0x1c4e0` is the shared cleanup path that stops active backend playback for a sound state and closes its backing stream/file handle at offset `+0x88`.
-  - `run_fst_sequence_player` at `0x12b00` is the inner FST decoder/player reached from `play_fst_sequence`.
-    - `FstFileHeader` is now a confirmed 0x20-byte header with fields `magic`, `width`, `height`, `max_frame_size`, `frame_count`, `frame_rate`, `sample_rate`, and `bits_per_sample`.
-    - `FstFrameIndexEntry` is the 6-byte per-frame index record `{ video_size, audio_size }`; `run_fst_sequence_player` reads `frame_count` of them immediately after the header and then reads `video_size + audio_size` bytes for each frame.
-    - Native allocates one large payload buffer up front and reuses it for every `video_size + audio_size` frame read instead of reallocating a temporary frame buffer during playback.
-    - It assumes the wrapper has already switched back to the movie-capable surface, then configures `g_video_surface_context` again from the header width/height at 8bpp backend class `1`, seeds PCM playback from the header sample rate / bits per sample, computes `bytes_per_frame = get_pcm_byte_rate(...) / frame_rate`, and uses `get_sound_state_playback_position` to keep movie frames aligned with the loaded soundtrack.
-    - The frame loop queues each frame's PCM chunk before decode/blit and then waits until the cumulative playback cursor reaches the current frame's byte target; timer pacing is only the silent fallback.
-    - `play_fst_sequence` clears pending keypress state before entering the player, and `run_fst_sequence_player` only honors skip input after frame `0` has already started; the first frame is not skipped by a stale triggering click.
-    - `initialize_vesa_banked_mode` precomputes the banked-surface row tables that the movie decoder relies on:
-      - `g_vesa_scanline_start_bank_indices` at `0xc7fca` gives the starting write-window bank for each possible scanline
-      - `g_vesa_scanline_window_offsets` at `0xc87cc` gives the in-window linear offset for that scanline's left edge
-      - `g_vesa_scanline_bank_split_x` at `0xc97cc` is `-1` when the full row fits in one bank, otherwise it is the first `x` coordinate that spills into the next bank
-    - The per-frame payload begins with a 16-bit bit-count, followed by the packed bitstream and then the block payload bytes.
-      - The first bit controls whether a 256-color VGA palette chunk is present.
-      - Each 4x4 tile then consumes one changed/unchanged bit, and changed tiles consume one raw-vs-two-color-mask bit before reading either 16 raw pixels or 4 mask bytes.
-      - The frame body then decodes 4x4 tiles either from raw 16-byte pixel blocks or from compact 4-byte two-color mask blocks before blitting through the active VESA backend.
-      - The native tile loop decodes `(height >> 2) - 1` four-scanline bands, so 320x200 FST movies decode 49 tile rows and leave the bottom 4 scanlines unchanged.
-      - In two-color mask blocks, `source[0]` is color 0, `source[1]` is color 1, and the 16-bit little-endian mask maps each row right-to-left within its nibble: row 0 uses high-byte bits `0x80,0x40,0x20,0x10`, row 1 uses `0x08,0x04,0x02,0x01`, row 2 uses low-byte bits `0x80,0x40,0x20,0x10`, and row 3 uses `0x08,0x04,0x02,0x01`.
-      - On banked VESA surfaces, the decoder handles those same 4x4 tiles in 4-scanline bands:
-        - if all four rows in the current band have `split_x == -1`, it writes directly into the mapped VESA window
-        - otherwise it expands the 4-row band into a temporary scratch buffer and then flushes each row in one or two pieces across the bank boundary, advancing banks through `select_bank_for_scanline_callback`
-    - `g_fst_censorship_toggle_entries` at `0xc1014` is now typed as `FstCensorshipToggleEntry[25]`.
-      - Each `0x118`-byte entry contains a movie basename in `sequence_name[0x100]` followed by six `toggle_frame_indices`.
-      - `run_fst_sequence_player` scans those records by basename and, on matching frame indices, toggles between restoring the saved palette and blitting `GRAPHIC\\OTHER\\CENSORED.PCX`.
-    - `g_gore_enabled` at `0xc0fbc` is the persisted `GORE` setting gate for that censorship path.
-      - `startup_main` defaults it to `1` and only clears it when the config value `GORE` equals `NO`.
-      - `run_fst_sequence_player` only consults `g_fst_censorship_toggle_entries` when `g_gore_enabled == 0`.
-      - While censorship is active, the player keeps advancing audio timing and still captures later movie palette chunks into the saved movie palette buffer, but it skips the normal frame decode/blit until a later toggle restores the movie view.
-    - `upload_vga_palette` at `0x482c0` is the shared 256-entry VGA DAC upload helper used by the FST player when it restores or swaps palette pages.
-    - `load_pcx_bitmap` at `0x4a230` is the direct-file `PCX` loader used by the FST censorship path; it reads the whole file through XFILE, RLE-decodes the `0x80`-byte-header payload into a `RawBitmap`, trims oversized stride bytes, and optionally uploads the trailing 256-color palette.
-  - The Chessmaster three-move puzzle uses a separate FLIC/FLC playback path rather than the FST movie decoder:
-    - `load_flic_animation_resource` opens `*.flc` through XFILE and validates the FLIC/FLC header/chunk stream.
-    - `decode_next_flic_frame`, `seek_flic_animation_frame`, `advance_flic_frame_if_due`, and `play_flic_animation_frames_to_surface` handle frame stepping and FLI/FLC chunk decoding for chunk types including color maps, line deltas, black frames, byte runs, and raw frame payloads.
-    - `update_flic_dirty_y_bounds` and `update_flic_dirty_x_bounds` maintain the dirty rectangle touched by the decoded frame, and `blit_nonzero_pixels_to_vesa_banked_surface` copies nonzero pixels through the existing banked VESA row tables.
-- `run_harvester_main_loop` maintains `g_current_interaction_text`, `g_pending_interaction_text`, and `g_interaction_text_entity` for hover / use prompts.
-  - It allocates both 100-byte text buffers during startup, formats prompts such as `Examine %s`, `Talk to %s`, `Pick up the %s`, `Operate the %s`, and `Use %s on %s`, suppresses prompts when the active label is `NULL_ID`, and only rebuilds the rendered label when the pending text differs from the current text.
-  - It also drives the room-loop cursor classification: sequence `0` for walkable floor-band movement, `1` for inspect / IDENT-first targets, `2` for talk, `4` for operate, `5` for pickup / use-item, `6` for region or room-transition targets, and `7` for neutral hover.
-  - Empty-floor clicks map the cursor's screen `x` directly into the player move target and derive the target depth from the current room's `max_z_screen_y` / `min_z_screen_y` movement band; the same loop also accepts directional-key movement.
-- `run_text_entry_dialog` at `0x59fe0` is the shared keyboard text-entry helper.
-  - It is called from the save-game UI and the dialogue keyword UI, allocates a caller-sized entry buffer, handles keyboard editing and cancel/accept paths, and returns the entered string or `NULL`.
-- `spawn_text_entity` at `0x59c00` and `spawn_text_entry_entity` at `0x59f40` are the shared rendered-text constructors for menus, prompts, quick tips, and dialogue UI.
-  - They consume the `load_font_resource` font blobs, attach their results to `g_render_entity_list`, and rely on `g_dirty_rect_list` plus `flush_dirty_rects_to_screen` for incremental redraw.
-- `run_dialogue_response_menu` at `0x7af10` is the slash-delimited response list UI used by the NPC dialogue system.
-  - It parses one numbered slash-delimited `dialog.rsp` line into wrapped response rows, chooses `TEXTBOX1`..`TEXTBOX8` from the total wrapped line count, titles the panel with the literal `Responses`, and returns a 1-based selection from mouse row hit-testing or digit keys `1` through `9`.
-- `run_dialogue_keyword_menu` at `0x7ba40` is the slash-delimited keyword menu plus free-text `Other` path used by the NPC dialogue system.
-  - It always appends `g_dialogue_keyword_menu_default_topic`, loaded by `load_dialogue_index` from zero-based `dialog.rsp` line `13`, plus a literal `Other` entry that calls `run_text_entry_dialog`.
-  - Choosing a visible keyword copies that literal on-screen topic text into `g_dialogue_selected_topic_text`; the `Other` path copies the typed text there as well. There is no separate hidden topic-id remap layer behind `dialog.rsp`.
-  - ESC does not silently cancel the keyword menu; it writes that default topic into `g_dialogue_selected_topic_text` and returns through the normal handler path.
-
-## UI And Inventory Details
-
-### Room Menu Options UI
-
-- `Confidence:` High.
-- `Evidence:` `run_main_menu` handles the `OPTIONS` row inline as case `3` and loads `options_menu_1` through `options_menu_7` from `MENU.INI`.
-- `Evidence:` the shipped `MENU.INI` preserves trailing spaces in `options_menu_1..3` (`SOUND FX           `, `MUSIC         `, `GAMMA         `), and `run_main_menu` copies those raw bytes into the centered text buffers before measuring them, so the visible glyphs sit left of the mathematical center to leave room for the slider art.
-- `Evidence:` it spawns three `VOLUME.BM` bars and three `INDICATR.BM` sliders, with positions driven by `g_fx_volume_level_index`, `g_music_volume_level_index`, and `g_gamma_level_index`.
-- `Evidence:` moving the FX slider previews `1:\SOUND\EFFECTS\WHIP2.WAV`, moving the music slider updates `g_music_stream_state`, and moving the gamma slider calls `update_gamma_brightness_scale` and reuploads `g_current_palette_buffer`.
-- `Evidence:` the text, gore, and password rows append inline ` - ...` suffixes through `format_main_menu_option_status_suffix`; `QUICK TIPS` opens the same `TIPS.BM` overlay used by startup; the password row calls `prompt_for_password` and stores the result in `g_parental_password_buffer`.
-- `Key Functions:` `run_main_menu`, `format_main_menu_option_status_suffix`, `update_gamma_brightness_scale`, `prompt_for_password`, `run_text_entry_dialog`.
-- `Key Data:` `g_fx_volume_level_index`, `g_music_volume_level_index`, `g_gamma_level_index`, `g_text_option_mode`, `g_gore_enabled`, `g_parental_password_buffer`.
-- `Notes:` the native submenu persists `GAMMA`, `FX_VOLUME`, and `MUSIC_VOLUME` on exit and updates `TEXT`, `GORE`, `QUICK_TIPS`, and password state inline while active.
-- `Notes:` the options text entities are spawned before the `VOLUME.BM` bars and `INDICATR.BM` sliders, so the first three labels sit underneath the bar art where their trailing edge overlaps.
-- `Notes:` `prompt_for_password` overlays the current options menu with centered `ENTER PASSWORD` text, spawns `TEXT_ENTRY` at `(0xdc, 0xdc)` using `g_harvfnt2_cft`, hides the cursor entity while typing, and calls `run_text_entry_dialog` with hidden max length `8`.
-
-### Room Menu Help Screen
-
-- `Confidence:` High.
-- `Evidence:` `run_controls_help_screen` at `0x6c3e0` is the dedicated two-page controls help viewer, and `run_main_menu` also contains the same behavior inline for the `HELP` row.
-- `Evidence:` page `0` loads `graphic\other\moushelp.bm` with `1:\graphic\pal\moushelp.pal`; page `1` loads `graphic\other\keyshelp.bm` with `1:\graphic\pal\keyshelp.pal`.
-- `Evidence:` each page is a fullscreen bitmap entity shown at `(0, 0)` after clearing the screen and uploading the page palette; the loop exits on `ESC` or secondary mouse and toggles pages on primary mouse or any other key.
-- `Evidence:` on exit it reuploads `g_current_palette_buffer`, clears the screen, reuploads the room palette again, and resumes paused timer countdowns.
-- `Key Functions:` `run_controls_help_screen`, `run_main_menu`.
-- `Key Data:` `g_current_palette_buffer`.
-- `Notes:` the help viewer is palette-driven and does not reuse the room backdrop while active; the room/menu image only returns after the fullscreen help page is dismissed.
-
-### Room Menu Quit Confirmation
-
-- `Confidence:` High.
-- `Evidence:` `run_main_menu` handles the `QUIT GAME` row inline by spawning `textbox4.bm` at `(167, 200)` over the current room/menu backdrop and reusing the live room palette.
-- `Evidence:` the prompt text comes from the `quitgame` key in `MENU.INI`, and the native string parser treats `/` as an inline line break for the confirmation message.
-- `Evidence:` the YES/NO actions use fixed hit regions: YES at `x=0xd2..0x104`, `y=0xee..0x108`; NO at `x=0x172..0x19a`, `y=0xee..0x108`.
-- `Evidence:` the loop confirms quit on `Y` and cancels on `N`, `ESC`, or secondary mouse; a confirmed quit returns the menu sentinel that unwinds the room loop into shutdown.
-- `Key Functions:` `run_main_menu`.
-- `Key Data:` `MENU.INI:quitgame`, `textbox4.bm`.
-- `Notes:` unlike the fullscreen help viewer or save UI, the quit confirm is an inline modal layered over the current room/menu frame.
-- `Notes:` the native branch is fully self-contained inside `run_main_menu`; no separate helper crossed the rename-confidence threshold during this pass.
-
-### Player Health And Inventory Status Portrait
-
-- `Confidence:` High.
-- `Evidence:` `run_inventory_screen` reads `RenderEntityRuntime.current_hit_points` from the live player combat-avatar runtime and selects `INV_STAT1`, `INV_STAT2`, `INV_STAT3`, or `INV_STAT4` for the lower-left portrait.
-- `Evidence:` the thresholds are explicit: `23..30 -> INV_STAT1`, `15..22 -> INV_STAT2`, `8..14 -> INV_STAT3`, `0..7 -> INV_STAT4`.
-- `Evidence:` `run_harvester_main_loop` initializes `g_player_current_hit_points` to `30`, `spawn_player_combat_avatar` copies that global into `RenderEntityRuntime.current_hit_points`, and `teardown_player_combat_avatar` writes the runtime value back.
-- `Evidence:` `dispatch_room_event_actions` handles `HEAL_PC`, `ADJ_HP`, and `KILL_PC` against the same HP state, and `run_save_game_menu` / `run_load_game_menu` persist it.
-- `Key Functions:` `run_inventory_screen`, `dispatch_room_event_actions`, `spawn_player_combat_avatar`, `teardown_player_combat_avatar`, `run_save_game_menu`, `run_load_game_menu`.
-- `Key Data:` `g_player_current_hit_points`, `RenderEntityRuntime.current_hit_points`.
-- `Notes:` the inventory status portrait is a presentation of persistent player HP, not a separate UI-only counter.
-- `Notes:` the portrait is recalculated inside the inventory loop, so healing or damage that lands while inventory is open updates the selected `INV_STAT*` image immediately.
-- `Notes:` the portrait is materialized through `spawn_object_entity_from_record` and the shared render-entity path, so the rotated photo depends on palette-index-0 transparency rather than an opaque rectangular blit.
-
-### Inventory Item Carry / Use Handoff
-
-- `Confidence:` High.
-- `Evidence:` `run_inventory_screen` sets the active-item latch when a carryable inventory object is selected, clears the previous interaction-text overlay, and rebuilds the centered `USING_ON_ID` prompt from `Use %s on ...`.
-- `Evidence:` the selected-item and target names used in the `USING_ON_ID` prompt come from `RenderEntityRuntime` label field `+0x14`, which `spawn_object_entity_from_record` copies from `ObjectRecord.interactionLabel`; this keeps inventory tooltip suffixes such as `; right_click_to_view` out of use/carry prompts.
-- `Evidence:` while that latch is set, `run_inventory_screen` treats the selected inventory object as the live dragged entity, probes overlap for `USEITEM` or dialogue routing, and returns the entity to the caller if it leaves the inventory panel rect `x=0x49..0x234`, `y=0x73..0x19b`.
-- `Evidence:` `run_harvester_main_loop` consumes that returned entity as the active selected item, repositions it under the mouse, rebuilds the same centered `USING_ON_ID` overlay with `g_medfont1_cft`, and routes left-click use through `handle_target_interaction`.
-- `Evidence:` `spawn_player_combat_avatar` seeds the live player entity with class `5` and label `"your inventory"`, and `handle_target_interaction` special-cases overlap with that target to move a non-inventory carried room item into `INVENTORY`.
-- `Evidence:` `run_harvester_main_loop` branches pickup handling on the room-player presence flag at `0xd596c`: player-present pickup enters the same cursor-follow carry state, while player-absent pickup moves the object straight into `INVENTORY`, removes its live render entity, and only then considers its action tag.
-- `Key Functions:` `run_inventory_screen`, `run_harvester_main_loop`, `handle_target_interaction`.
-- `Key Data:` `g_useitem_records`, `ObjectRecord.current_owner_or_room`, the active-item latch at `0xd60b8`.
-- `Notes:` the standalone inventory UI does not use room-region exits while an item is active; the active item must first return to the room loop.
-- `Notes:` native bottom-of-screen carry prompts are materialized as the `USING_ON_ID` text-entry overlay using `MEDFONT1.CFT` at y `0x1ce`, not the fallback GUI font path.
-- `Notes:` plain pickup does not rerun full room materialization; native mutates the live scene in place, and only action-driven runtime mutations or room transitions trigger the heavier refresh paths.
-
-### Inventory Secondary-Click Actions
-
-- `Confidence:` High.
-- `Evidence:` in `run_inventory_screen`, the right-click branch first maps the weapon subset (`CLEAVER` through `POOLSTICK`) to fixed combat-loadout ids `1..20`, shows the `BOX1` arming/disarming message, then calls `set_player_combat_loadout`, tears down the temporary `BOX1` state, and exits the inventory loop.
-- `Evidence:` the same branch maps a separate hardcoded document/photo subset to fixed action tags rather than object-record metadata: `NOTE_PHOTOCOPY -> GO_BOYLCOPYCU`, `NOTE -> GO_BOYLNOTECU`, `CHECKBOOK -> GO_REGISTERCU`, `CHECKBOOK_PHOTOCOPY -> GO_RGSTRCPYCU`, `TV_DEED -> GO_TVDEED1CU`, `TV_DEED_PHOTOCOPY -> GO_TVDEED2CU`, `CLUE -> GOTO_CLUE_CU`, `AUTOGRAPH -> GO_AUTOGRPHCU`, plus the corresponding Whaley photo, casket photo, permit, lodge-application, safebook, patrol-schedule, and invite entries.
-- `Evidence:` after dispatching one of those hardcoded closeup tags, `run_inventory_screen` tears down the inventory render state and returns to the caller instead of staying in the loop.
-- `Evidence:` only the six consumable or self-use items `SANDWICH`, `SANDWICH2`, `SYRINGE`, `ST_ASPRIN`, `ST_COUGHM`, and `ST_VITAMN` fall back to the clicked object's own `action_tag`; that branch stays inside the inventory loop so HP and status changes remain visible.
-- `Key Functions:` `run_inventory_screen`, `dispatch_room_event_actions`, `set_player_combat_loadout`.
-- `Key Data:` the weapon-name to loadout-id mapping inside `run_inventory_screen`, the document/photo action-tag table at `0xc40c8`, `ObjectRecord.action_tag`.
-- `Notes:` native inventory secondary-click is not a generic inspect path; it is a hardcoded dispatcher with three distinct behaviors: weapon loadout toggle, document/photo closeup, and object-defined consumable self-use.
-- `Notes:` the document/photo closeups are driven by global action tags rather than per-object metadata, which is why the ScummVM inventory overlay needs its own recovered mapping table for parity.
-
-### Inventory Hover Tooltip
-
-- `Confidence:` High.
-- `Evidence:` in `run_inventory_screen`, the plain hover branch only runs when no inventory item is actively selected and neither mouse button is pressed; it walks `g_text_records`, matches the hovered inventory object's `inventory_text_key`, and rebuilds the hover label only when the resolved text changes.
-- `Evidence:` the rebuilt label is created through `spawn_text_entity(..., "INV_TEXT", 0xbc, 0x19e, 0xfffffff4, 0)`, which confirms a fixed left-aligned tooltip anchor at `(188, 414)` with color `-12` rather than the centered bottom-of-screen interaction prompt path.
-- `Evidence:` the callsite loads `EBX` from `g_textfont_cft @ 0xd5bc4` immediately before `spawn_text_entity`, so the inventory hover label uses `TEXTFONT.CFT`, not `MEDFONT1.CFT`.
-- `Evidence:` the separate carry or use branch still measures text with `g_medfont1_cft` and spawns the centered `USING_ON_ID` overlay at y `0x1ce`, so the inventory tooltip and carry prompt are distinct native UI elements.
-- `Key Functions:` `run_inventory_screen`, `spawn_text_entity`, `spawn_text_entry_entity`.
-- `Key Data:` `ObjectRecord.inventory_text_key`, `g_textfont_cft`, `g_medfont1_cft`.
-- `Notes:` native inventory hover text is not centered and does not reuse the room hover prompt renderer.
-- `Notes:` the inventory exit entity is handled separately; the fixed `INV_TEXT` hover path applies to inventory objects and the `INV_STAT*` status entities that resolve through `inventory_text_key`.
-
-### Inventory Weekday Label
-
-- `Confidence:` High.
-- `Evidence:` early in `run_inventory_screen`, the setup branch calls `is_object_in_inventory("HARVEST_BLADE")` and only spawns the weekday label when that check is false.
-- `Evidence:` the same branch calls `get_current_story_day_index`, indexes the pointer table at `0xc4110`, and resolves the visible strings `Monday` through `Saturday` from that table.
-- `Evidence:` the resulting text entity is spawned through the inventory text-font path with text id `DAYS_TEXT` at `(0x1e0, 0x18c)` and color `0xfffffff4`, which matches the recovered `TEXTFONT.CFT` inventory-label styling rather than the centered bottom prompt path.
-- `Key Functions:` `run_inventory_screen`, `get_current_story_day_index`, `is_object_in_inventory`.
-- `Key Data:` the weekday string table at `0xc4110`, `g_textfont_cft`, `DAYS_TEXT`.
-- `Notes:` native inventory weekday display is a persistent overlay element, not part of the hover-tooltip or carry-prompt branches.
-- `Notes:` the visible label is explicitly suppressed once `HARVEST_BLADE` has entered inventory.
-
-## Open Questions
-
-### Current Blockers
-
-- The remaining work is no longer list recovery. The blocker is semantic naming for fields whose shape is clear but whose gameplay meaning is still ambiguous.
-- The main unresolved clusters are:
-  - the exact persisted-visibility semantics of `NpcRecord.saved_visible`
-  - `ObjectRecord.reserved_x_flag`, which is a parsed heap string that is either empty or the literal `X`, but is still absent from the direct `find_object_record_by_name` consumers and from the object save/load blob
-  - `ObjectRecord.reserved_string_40`, which is now bounded as an object-owned heap string, but still has no confirmed live consumer and is empty in current shipping script data
-  - `RoomRecord.reserved_string_38`, `reserved_string_3c`, `reserved_string_40`
-  - `MonsterRecord.reserved_dword_1c`, which remains zero-filled with no recovered producer or consumer
-  - `MonsterRecord.reserved_string_38`, `reserved_string_3c`, `reserved_string_44`, and `reserved_string_48`
-  - `TextRecord.field_00` / `field_04`
-- The remaining actor-state work is narrower now:
-  - the shared input/UI bytes at `0xd596c` and `0xd5971` through `0xd597c`; the keyboard producer side is now resolved through the IRQ1 handler, but the mouse/button producer path is still not named
-- On March 13, 2026 I resumed with a live bridge and used Java Ghidra scripts to push the recovered struct names back into `HARVEST.LE`.
-- The active Ghidra session is still not PyGhidra-enabled, so Python scripts remain unavailable inside Ghidra. That is no longer a hard blocker, but it makes broad decompiler-search automation heavier and pushes this pass toward narrower Java-script or endpoint-driven searches.
diff --git a/FILE_FORMATS.md b/FILE_FORMATS.md
deleted file mode 100644
index b2b9bac80b6..00000000000
--- a/FILE_FORMATS.md
+++ /dev/null
@@ -1,553 +0,0 @@
-# File Formats
-
-This document tracks file-format facts that are confirmed by the current Harvester reverse-engineering work. It is intentionally conservative: if a field or behavior is not supported by code or verified runtime notes, it is left out.
-
-The sections below combine three sources of evidence: the current ScummVM Harvester engine code, named native functions and data types in Ghidra, and spot-checks against extracted sample files from the Harvester CD image. Unknown or only partially understood fields stay marked as such.
-
-## DAT (XFILE archive payload)
-
-Harvester's `.DAT` files act as payload containers for the game's numbered XFILE resource sets. The important wrinkle is that the `.DAT` file is not the whole format by itself: the loader depends on a sidecar `INDEX.00N` file for the directory. In other words, the `.DAT` holds the bytes, while the `INDEX.00N` records tell the engine where each member starts, how large it is, and whether it must be unpacked after reading.
-
-At confirmed cold start, the original game mounts these pairs in this order:
-
-| Set | Index file | Data file | Notes |
-| --- | --- | --- | --- |
-| 1 | `INDEX.001` | `HARVEST.DAT` | First archive-backed set mounted during startup. Confirmed startup art and UI assets are loaded through this set. |
-| 2 | `INDEX.002` | `SOUND.DAT` | Second numbered set mounted during startup. |
-| 3 | `INDEX.003` | `HARVEST2.DAT` | Third numbered set mounted during startup. |
-
-### Summary
-
-| Property | Value |
-| --- | --- |
-| Format role | Archive payload file used by the XFILE resource layer |
-| Companion metadata | Required sidecar `INDEX.00N` directory file |
-| Archive-wide header in `.DAT` | No confirmed archive-wide header or in-file directory has been required by the current loader |
-| Addressing model | Members are looked up by path in `INDEX.00N`, then read from absolute offsets in the `.DAT` |
-| Path syntax in game code | Archive-backed lookups use `<set-number>:\path\to\file.ext` |
-| Endianness | Mixed in the sidecar index: signature is checked as big-endian `XFLE`; numeric fields are little-endian 32-bit values |
-| Compression | Optional per-entry packing; unpacked entries are read directly, packed entries are expanded after read |
-
-### `.DAT` payload layout
-
-| Offset | Size | Description |
-| --- | --- | --- |
-| `0x00` | variable | Raw member data addressed by the sidecar index. The current loader treats the file as a byte reservoir and does not require a confirmed global header before opening entries. |
-
-This is sparse compared with many archive formats, but that sparseness is itself useful for a blog post: the format's structure is directory-driven rather than self-describing. All meaningful per-file metadata currently comes from the matching `INDEX.00N` file.
-
-### `INDEX.00N` directory record layout
-
-Each confirmed sidecar index is read as a flat array of `0x94`-byte records:
-
-| Offset | Size | Type | Name | Description |
-| --- | --- | --- | --- | --- |
-| `0x00` | `0x04` | ASCII | `signature` | `XFLE` magic. The loader rejects records whose first four bytes do not match this tag. |
-| `0x04` | `0x80` | char[128] | `path` | NUL-terminated resource path string. This is the logical member name used for later lookups. |
-| `0x84` | `0x04` | uint32le | `archive_offset` | Absolute offset of the member data inside the companion `.DAT` file. |
-| `0x88` | `0x04` | uint32le | `stored_size` | Number of bytes stored in the `.DAT` for this member. |
-| `0x8c` | `0x04` | uint32le | `packed_flag` | `0` means the member is stored verbatim. Any nonzero value takes the packed-entry decode path. The current loader does not distinguish between different nonzero flag values. |
-| `0x90` | `0x04` | uint32le | `unpacked_size` | Expected output size after decode for packed entries. |
-
-### Packed entry stream
-
-When `packed_flag != 0`, the loader reads `stored_size` bytes from the `.DAT` and expands them into an `unpacked_size` output buffer using a simple control-byte stream:
-
-| Control byte range | Meaning |
-| --- | --- |
-| `0x00` to `0x80` | Copy the next `control` bytes literally into the output stream. |
-| `0x81` to `0xff` | Read one byte and repeat it `control - 0x80` times. |
-
-The decode loop stops once either the compressed input is exhausted or the output buffer reaches `unpacked_size`. Entries that fail to produce exactly `unpacked_size` bytes are treated as invalid.
-
-### Path handling notes
-
-| Behavior | Detail |
-| --- | --- |
-| Separators | Resource paths are normalized from DOS-style backslashes to forward slashes during lookup. |
-| Prefix stripping | Leading `./`, leading `/`, and `<digit>:/` prefixes are stripped during normalization. |
-| Case handling | Archive member lookups are case-insensitive in the current implementation. |
-| Loose files vs archive paths | Bare relative paths are handled separately by the direct-file path builder; `<digit>:\...` paths select an archive set instead. |
-
-### Practical interpretation
-
-For extraction or tooling work, the current evidence supports treating Harvester's `.DAT` archives as a two-file format:
-
-1. Read the matching `INDEX.00N` file as a sequence of `0x94`-byte `XFLE` records.
-2. Use each record's `archive_offset` and `stored_size` to slice bytes from the companion `.DAT`.
-3. If `packed_flag` is zero, the slice is the final file.
-4. If `packed_flag` is nonzero, expand the slice with the literal/repeat decoder above until `unpacked_size` bytes are produced.
-
-That model is enough to explain the startup resource mounts already confirmed in the reverse-engineering notes, and it matches the current ScummVM-side archive loader.
-
-## RCS (Quick-tip text lists)
-
-The only confirmed `.RCS` use so far is `ADJHEAD.RCS`, the quick-tips file shown by the startup/options overlay. Both native `run_quick_tips_screen` and the ScummVM `Flow::loadQuickTips` path treat it as plain text rather than as a binary container.
-
-### Summary
-
-| Property | Value |
-| --- | --- |
-| Format role | Plaintext list of quick-tip strings |
-| Confirmed consumer | Startup quick-tips overlay and options-menu quick-tips screen |
-| Encoding | Plain ASCII text in the sampled file |
-| Header | None |
-| Delimiters | CR/LF line endings in the sampled file; loaders split on `\n` and ignore `\r` |
-
-### File layout
-
-| Offset / unit | Size | Description |
-| --- | --- | --- |
-| `0x00..EOF` | variable | Sequence of text lines. Each non-empty trimmed line becomes one quick-tip string. |
-
-### Notes
-
-- The sampled `ADJHEAD.RCS` begins directly with readable text: `Double click the left mouse button...`.
-- The native quick-tips path advances through the file with `read_line_from_file_stream`, wraps back to the start on EOF, and chooses a random starting point the first time it runs.
-- This is a good example of Harvester's resource pipeline staying pragmatic: not every gameplay-facing asset is wrapped in a custom binary format.
-
-## DIALOG.RSP (Dialogue response and keyword text)
-
-`DIALOG.RSP` is a plaintext line table used by the dialogue UI. It does not appear to contain ids, offsets, or a binary header; the engine indexes it by zero-based line number and then uses the line text directly.
-
-### Summary
-
-| Property | Value |
-| --- | --- |
-| Format role | Plaintext dialogue response / keyword string table |
-| Confirmed consumers | `load_dialogue_response_line`, response menu, keyword menu |
-| Encoding | Plain ASCII text in the sampled file |
-| Header | None |
-| Addressing model | Zero-based line index |
-
-### File layout
-
-| Unit | Description |
-| --- | --- |
-| Line `n` | One response, keyword label, or menu text string |
-| `/` within a line | In response/keyword UI contexts, splits one source line into multiple visible menu items |
-
-### Notes
-
-- The native loader reads a requested zero-based line, strips the trailing CR/LF, and returns the shared text buffer directly.
-- `run_dialogue_response_menu` and `run_dialogue_keyword_menu` split visible options on `/`; there is no confirmed hidden topic-id layer behind those labels.
-- `load_dialogue_index` also pulls zero-based line `13` from `DIALOG.RSP` to seed the default keyword topic; in the sampled file that line is `BYE` (one-based line 14).
-- As a narrative point for a blog post, `DIALOG.RSP` shows Harvester leaning on designer-editable text tables even inside a fairly custom dialogue stack.
-
-## DIALOGUE.IDX (Subtitle index)
-
-`DIALOGUE.IDX` is an XOR-obfuscated text file that maps voice sample ids to subtitle strings. The native `load_dialogue_index` path and the ScummVM `Text::loadDialogueIndex` reimplementation both decode it with XOR `0xAA` while leaving CR/LF intact.
-
-### Summary
-
-| Property | Value |
-| --- | --- |
-| Format role | Voice-id to subtitle-text index |
-| Encoding on disk | ASCII text obfuscated with XOR `0xAA` on every byte except CR/LF |
-| Header | None |
-| Confirmed delimiters | `NUL`, `LF`, `CR`, and form-feed (`0x0c`) |
-| Key native type | `DialogueIndexEntry { wav_id, text_offset, text_length }` |
-
-### Decoded stream layout
-
-| Sequence element | Description |
-| --- | --- |
-| ASCII decimal token | `wav_id` for one spoken line |
-| Delimiter run | One or more of `NUL`, `LF`, `CR`, `FF` |
-| ASCII text token | Subtitle text for that voice id |
-| Delimiter run | Ends the subtitle record and starts the next id |
-
-### Derived index entry
-
-| Field | Meaning |
-| --- | --- |
-| `wav_id` | Positive decimal voice/sample id |
-| `text_offset` | Byte offset of the decoded subtitle text inside the decoded blob |
-| `text_length` | Subtitle length, clamped to `0x19a` bytes by the native loader |
-
-### Notes
-
-- After XOR decode, the sampled file starts with a simple alternating pattern: `1`, `"junk init"`, `7`, `"Yes?"`, `11`, `"I need some help..."`, and so on.
-- The native loader builds a 3000-entry in-memory table and keeps the decoded text blob around so `play_dialogue_line` can seek back into it cheaply.
-- Architecturally, this splits dialogue cleanly in two: `DIALOGUE.IDX` carries spoken-line subtitles keyed by numeric wav ids, while `DIALOG.RSP` carries menu-facing response text.
-
-```python
-python3 -c 'import sys,pathlib,signal; signal.signal(signal.SIGPIPE, signal.SIG_DFL); d=pathlib.Path(sys.argv[1]).read_bytes(); sys.stdout.buffer.write(bytes(b if b in (10,13) else b ^ 0xAA for b in d))' DIALOGUE.IDX | head -n 10
-1
-"junk init"
-7
-"Yes?"
-11
-"I need some help... Mister...?"
-15
-"Postmaster Boyle.  What can I do you for today?"
-25
-"Sorry, youngster, we're out of applications right now."
-```
-
-
-## SCR (Town/world startup script)
-
-`HARVEST.SCR` is the central world-definition and startup-script file. It is XOR-obfuscated text, not bytecode. The native `load_xor_obfuscated_town_script` path and the ScummVM `Script::decode` path both XOR each non-CR/LF byte with `0xAA`, then parse whitespace-separated records with quoted strings preserved.
-
-### Summary
-
-| Property | Value |
-| --- | --- |
-| Format role | World database, startup configuration script, and command graph |
-| Encoding on disk | ASCII text obfuscated with XOR `0xAA` on every byte except CR/LF |
-| Header | None |
-| Comment syntax | Lines whose first non-space character is `{` are skipped |
-| Tokenization | Whitespace-delimited tokens with quoted strings kept intact |
-
-### Confirmed record forms
-
-| Tag | Leading numeric fields | Fields after the tag |
-| --- | --- | --- |
-| `ENTRANCE` | `x y z` | `direction roomName entranceName` |
-| `MAP_ENTRANCE` | `mapX mapY initialPanelIndex` | `entryName` |
-| `MAP_LOCATION` | `minX minY maxX maxY panelIndex labelX labelY` | `labelText destinationEntranceName` |
-| `ROOM` | `minZ maxZ maxZScreenY minZScreenY fullScaleZ maxZScalePercent` | `roomName musicPath reservedString38 reservedString3c reservedString40 palettePath dimmable onEnterCommand onExitCommand` |
-| `OBJECT` | `initialX initialY boundsX2 boundsY2 initialZ zExtent` | `initialOwnerOrRoom objectName spritePath altSpritePath reservedString40 inventoryTextKey reservedXFlag identTextKey operatable visible actionTag interactionLabel` |
-| `ANIM` | `x y z frameDelay` | `roomName resourcePath animName active visible looping backward pingPong remove` |
-| `NPC` | `x y z frameDelay` | `roomName modelPath npcName monsterfyTargetName active visible onDeathActionTag audioPath entityInitArg` |
-| `MONSTER` | `x y z engageDistance initialHitPoints damageAmount attackSoundTriggerFrame hitSoundTriggerFrame footstepSoundTriggerFrame deathSoundTriggerFrame` | Sample data and native analysis confirm those first six combat columns map to `MonsterRecord.engage_distance`, `initial_hit_points`, and `damage_amount`, followed by the four sound-trigger timing columns. The intermediate string columns at offsets `0x38`, `0x3c`, `0x44`, and `0x48` remain reserved in current data and have no recovered read-side consumers. |
-| `REGION` | `left top right bottom minZ maxZ` | `regionName direction roomName actionTag startEnabled cursorEnabled` |
-| `FLAG` | none | `name value` |
-| `COMMAND` | none | `triggerTag opcodeName arg1 arg2 arg3 [arg4]` |
-| `TEXT` | none | `key boxName value` |
-| `HEAD` | none | `headId portraitPath` |
-| `USEITEM` | none | `itemName ownerOrRoom targetName actionTag` |
-
-### Notes
-
-- The sampled file starts with readable structural records immediately after XOR decode, for example `ENTRANCE`, `OBJECT`, and later `ROOM`, `TEXT`, `HEAD`, and `COMMAND`.
-- Paths embedded in script records are not uniform: archive-backed resources use `1:\...`, `2:\...`, `3:\...`, while some direct-file assets are bare relative paths such as `dialogue.idx`.
-- `GOFLIC` and `GODEATHFLIC` are especially revealing command names. In the sampled script, those opcodes point to `.FST` paths, which suggests the script vocabulary preserved older naming while the shipping runtime movie path used FST files.
-- As a blog-post narrative, `HARVEST.SCR` is the clearest proof that Harvester's resource architecture is data-driven at the top level: rooms, objects, dialogue portraits, commands, music, palettes, and cutscene triggers all converge here.
-
-```bash
-python3 -c 'import sys,pathlib,signal; signal.signal(signal.SIGPIPE, signal.SIG_DFL); d=pathlib.Path(sys.argv[1]).read_bytes(); sys.stdout.buffer.write(bytes(b if b in (10,13) else b ^ 0xAA for b in d))' HARVEST.SCR | head -n 10
-{// HARVESTER (c) 1995-96 Scripting Language
-{// Town
-        0   0   0   ENTRANCE "BACK" "TOWN_2_LODGE" "SERGEANT_2_LODGE"
-        0   0   0   ENTRANCE "FRONT" "" "SAVE_GAME"
-        492 67  554 96   0  1 OBJECT "INVENTORY" "INV_EXIT" "" "" "" "" "" "X" "F" "T" "" "Inventory"
-        299 0   0   0    2  1 OBJECT "NULL_ID"          "EXIT_BM"  "1:\GRAPHIC\OTHER\EXITSIGN.BM" "" "" "" ""  "" "F" "T" "" "exit"
-        299 0   375 61   0  1 OBJECT "NULL_ID"          "EXIT_HS"  ""                             "" "" "" "X" "" "T" "T" "" "exit"
-
-{// inventory health indicator
-     72 314 0   0  -12  1 OBJECT "INVENTORY" "INV_STAT1" "1:\graphic\other\head-a1.bm" "" "" "INV_STAT_ST" "" "" "F" "F" "" "Health_Indicator"
-```
-
-## BM (Static indexed bitmap)
-
-`.BM` is Harvester's simplest custom image format: a small fixed header followed by raw 8-bit indexed pixels. It is used for UI panels, portraits, inventory art, help screens, and other non-animated images.
-
-### Summary
-
-| Property | Value |
-| --- | --- |
-| Format role | Raw 8-bit indexed bitmap |
-| Confirmed consumers | `spawn_bitmap_entity_from_resource`, `reload_bitmap_entity_pixels_from_resource`, menu/help/dialogue overlay loaders |
-| Compression | None confirmed |
-| Pixel format | 1 byte per pixel, palette-indexed |
-
-### File layout
-
-| Offset | Size | Type | Description |
-| --- | --- | --- | --- |
-| `0x00` | `0x04` | uint32le | `width` |
-| `0x04` | `0x04` | uint32le | `height` |
-| `0x08` | `0x04` | uint32le | Unused/reserved in current analysis. Both native and ScummVM loaders ignore it; sampled `MOUSHELP.BM` stores zero here. |
-| `0x0c` | `width * height` | bytes | Raw indexed pixel payload, row-major |
-
-### Notes
-
-- The native binary reuses the same `RawBitmap`/`BitmapBuffer` shape in memory: `{ width, height, pixels }`.
-- The format itself does not encode transparency, but many callers render it with palette index `0` treated as transparent.
-- Compared with many later adventure engines, this is an aggressively direct format: no chunking, no palette sidecar inside the file, and no per-row metadata.
-
-## PAL (Standalone palette)
-
-Harvester's `.PAL` files are raw palette payloads with no header. The native palette upload helpers and ScummVM `Art::loadPalette` both treat them as 256 RGB triplets.
-
-### Summary
-
-| Property | Value |
-| --- | --- |
-| Format role | Standalone 256-color palette resource |
-| Confirmed consumers | Room setup, menus, help screens, wait overlay, town map |
-| Header | None |
-| Payload size | 768 bytes (`256 * 3`) |
-
-### File layout
-
-| Offset / range | Size | Description |
-| --- | --- | --- |
-| `0x000..0x2ff` | 768 bytes | 256 consecutive `(R, G, B)` triplets stored as 8-bit channel values |
-
-### Notes
-
-- Native `upload_palette_to_vga` forces palette index `0` to black at upload time, applies brightness scaling, and then shifts the stored 8-bit channels down to VGA's `0..63` DAC range.
-- The sampled `INVHELP.PAL` contains full-range byte values up to `0xff`, which matches the native analysis that `.PAL` is stored as 8-bit RGB, not pre-divided 6-bit VGA values.
-- This makes `.PAL` a nice contrast with FST's embedded movie palettes, which are stored in 6-bit VGA form inside each frame payload.
-
-
-```bash
-python3 -m pip install pillow
-python3 -c 'from PIL import Image; import sys,struct; d=open(sys.argv[1],"rb").read(); w,h=struct.unpack_from("<II",d,0); i=Image.frombytes("P",(w,h),d[12:12+w*h]); p=open(sys.argv[2],"rb").read()[:768] if len(sys.argv)>2 else bytes(c for n in range(256) for c in (n,n,n)); i.putpalette(p); i.show()' GRAPHIC/OTHER/INVHELP.BM GRAPHIC/PAL/INVHELP.PAL
-```
-
-## CFT (Bitmap font)
-
-`.CFT` packages a bitmap font as a fixed metrics header plus one raw 8-bit atlas image. The native `load_font_resource` and the ScummVM `Text::loadFont` / `HarvesterCftFont` code agree on the basic structure.
-
-### Summary
-
-| Property | Value |
-| --- | --- |
-| Format role | Bitmap font resource with metrics tables and atlas |
-| Glyph count | 256 slots |
-| Rendering model | Each glyph is a horizontal slice out of one shared atlas bitmap |
-| Confirmed consumers | Menu text, room labels, dialogue, save/load UI, text-entry widgets |
-
-### File layout
-
-| Offset | Size | Type | Description |
-| --- | --- | --- | --- |
-| `0x000` | `0x40` | char[64] | NUL-terminated font name (`HARVFONT` in the sampled file) |
-| `0x040` | `0x02` | uint16le | Font height |
-| `0x042` | `0x200` | uint16le[256] | Glyph start-X table |
-| `0x242` | `0x200` | uint16le[256] | Glyph width table |
-| `0x442` | `0x02` | uint16le | Space width |
-| `0x444` | `0x04` | unknown | Unused/reserved in current analysis |
-| `0x448` | `0x04` | uint32le | Atlas width |
-| `0x44c` | `0x04` | uint32le | Atlas height |
-| `0x450` | `0x04` | unknown | Unused/reserved in current analysis; sampled `HARVFONT.CFT` stores zero here |
-| `0x454` | `atlasWidth * atlasHeight` | bytes | Raw atlas pixels, row-major, 8-bit indexed |
-
-### Notes
-
-- The renderer derives each glyph by slicing `width` pixels from `x` inside the shared atlas. There is no per-glyph bitmap chunking.
-- `drawHeight` is effectively `atlasHeight - 1` in the current font renderer, which matches the native behavior of treating the last row as non-drawing padding.
-- The font renderer treats both `' '` and `'_'` as space-width characters. That ties neatly back to the script/text resources, where underscore-heavy identifiers and UI labels coexist with visible text.
-
-```bash
-python3 -c 'from PIL import Image; import sys,struct; d=open(sys.argv[1],"rb").read(); aw,ah=struct.unpack_from("<II",d,0x448); h=max(1,min(struct.unpack_from("<H",d,0x40)[0] or ah-1,ah-1)); sw=struct.unpack_from("<H",d,0x442)[0] or 1; s=[struct.unpack_from("<H",d,0x42+i*2)[0] for i in range(256)]; w=[struct.unpack_from("<H",d,0x242+i*2)[0] for i in range(256)]; a=Image.frombytes("L",(aw,ah),d[0x454:0x454+aw*ah]); t=sys.argv[2]; W=sum(sw if ord(c)>255 or c in " _" or w[ord(c)]<=0 else w[ord(c)] for c in t); i=Image.new("L",(max(1,W),h),0); x=0; exec("for c in t:\n o=ord(c)\n gw=sw if o>255 or c in \" _\" or w[o]<=0 or s[o]>=aw else w[o]\n if gw and o<=255 and c not in \" _\" and w[o]>0 and s[o]<aw: i.paste(255,(x,0,x+gw,h),a.crop((s[o],0,s[o]+gw,h)))\n x+=gw"); i.resize((max(1,i.width*4),max(1,i.height*4)),Image.NEAREST).show()' "GRAPHIC/FONT/HARVFNT2.CFT" "Hello World"
-```
-
-## ABM (Animated bitmap / sprite strip)
-
-`.ABM` is Harvester's main custom sprite/animation format. It backs cursor art, actor sprites, room animations, combat entities, and wait-overlay animation frames.
-
-### Summary
-
-| Property | Value |
-| --- | --- |
-| Format role | Multi-frame indexed animation resource |
-| Confirmed consumers | `attach_abm_resource_to_entity`, `spawn_abm_entity_from_resource`, startup art loaders, room animation runtime |
-| Pixel format | 8-bit indexed pixels |
-| Compression | Optional per-frame RLE-like stream |
-
-### File header
-
-| Offset | Size | Type | Description |
-| --- | --- | --- | --- |
-| `0x00` | `0x04` | uint32le | `frame_count` |
-| `0x04` | `0x04` | uint32le | Native runtime uses this value to size the temporary decoded-frame buffer before adding `0x10` bytes of slack. The current ScummVM loader does not otherwise interpret it. Sampled `BLOOD.ABM` stores `0x888` here. |
-| `0x08` | variable | sequence | First frame record begins here |
-
-### Per-frame record
-
-| Offset within frame | Size | Type | Description |
-| --- | --- | --- | --- |
-| `0x00` | `0x04` | int32le | `x_offset` |
-| `0x04` | `0x04` | int32le | `y_offset` |
-| `0x08` | `0x04` | uint32le | `width` |
-| `0x0c` | `0x04` | uint32le | `height` |
-| `0x10` | `0x01` | byte | `compressed_flag` |
-| `0x11` | `0x04` | uint32le | `encoded_size` |
-| `0x15` | `encoded_size` | bytes | Encoded or raw pixel payload for this frame |
-
-### Compressed frame stream
-
-| Control byte form | Meaning |
-| --- | --- |
-| `0x00..0x7f` | Copy the next `control` bytes literally |
-| `0x80..0xff` | Read one byte and repeat it `control & 0x7f` times |
-
-### Notes
-
-- Each frame decodes to exactly `width * height` bytes of indexed pixels.
-- The sampled `BLOOD.ABM` starts with 5 frames; its first frame is offset `(4, 0)`, size `38 x 50`, and marked compressed.
-- ABM is central to Harvester's scene graph. The same format scales from tiny cursor sprites up to actor and monster visual state banks, which makes it a strong candidate for a blog-post "one format, many subsystems" section.
-
-```bash
-python3 -c $'from PIL import Image\nimport sys,struct,tempfile,pathlib,webbrowser\n\ndef dec(s,n):\n i=0; o=bytearray()\n while i<len(s) and len(o)<n:\n  k=s[i]; i+=1\n  if k<128:\n   c=min(k,len(s)-i,n-len(o)); o+=s[i:i+c]; i+=c\n  elif i<len(s):\n   o.extend([s[i]]*min(k&127,n-len(o))); i+=1\n return bytes(o)\n\nd=open(sys.argv[1],"rb").read()\npal=open(sys.argv[2],"rb").read()[:768] if len(sys.argv)>2 else bytes(c for n in range(256) for c in (n,n,n))\nfc=struct.unpack_from("<I",d,0)[0]; off=8; F=[]; minx=miny=10**9; maxx=maxy=-10**9\nfor _ in range(fc):\n x,y,w,h=struct.unpack_from("<iiii",d,off); c=d[off+16]; n=struct.unpack_from("<I",d,off+17)[0]; s=d[off+25:off+25+n]; off+=25+n; p=s[:w*h] if not c else dec(s,w*h); F.append((x,y,w,h,p)); minx=min(minx,x); miny=min(miny,y); maxx=max(maxx,x+w); maxy=max(maxy,y+h)\nminx=min(0,minx); miny=min(0,miny); W=maxx-minx; H=maxy-miny; G=[]\nfor x,y,w,h,p in F:\n src=Image.frombytes("P",(w,h),p); src.putpalette(pal); m=src.point(lambda v:0 if v==0 else 255,"L"); fr=Image.new("RGBA",(W,H),(0,0,0,0)); fr.paste(src.convert("RGBA"),(x-minx,y-miny),m); G.append(fr)\nf=tempfile.NamedTemporaryFile(suffix=".gif",delete=False).name; G[0].save(f,save_all=True,append_images=G[1:],duration=100,loop=0,disposal=2,transparency=0); webbrowser.open(pathlib.Path(f).as_uri())' "BLOOD.ABM" "WAIT.PAL"
-```
-
-## CMP (FCMP-compressed audio)
-
-`.CMP` is Harvester's custom compressed audio wrapper. The sampled files begin with `FCMP`, and the native `load_sound_sample` / `load_dialogue_voice_sample` code plus the ScummVM `decodeHarvesterFcmp` path all treat that payload as IMA-ADPCM-like compressed audio.
-
-### Summary
-
-| Property | Value |
-| --- | --- |
-| Format role | Compressed audio for music, dialogue voice, and sound effects |
-| Confirmed magic | `FCMP` |
-| Confirmed codec | IMA-ADPCM-style nibble stream using the standard step/index tables recovered in the binary |
-| Supported output depths | 8-bit and 16-bit PCM |
-
-### File layout
-
-| Offset | Size | Type | Description |
-| --- | --- | --- | --- |
-| `0x00` | `0x04` | ASCII | `FCMP` magic |
-| `0x04` | `0x04` | uint32le | Compressed payload size |
-| `0x08` | `0x04` | uint32le | Sample rate in Hz |
-| `0x0c` | `0x02` | uint16le | Output bits per sample (`8` or `16`) |
-| `0x0e` | variable | bytes | ADPCM payload |
-
-### Notes
-
-- Sampled `MENACE.CMP` starts with `FCMP`, a payload size of `0x0db48b`, sample rate `22050`, and `16` bits per sample.
-- The loader family is tolerant: some call paths accept either `FCMP` or raw `WAVE` data. The `.CMP` extension itself points to the compressed path, but the runtime checks the actual file signature before decoding.
-- From an architectural perspective, this is the audio-side equivalent of Harvester's XFILE abstraction: one wrapper format reused across music, speech, and effects.
-
-## FST (Cutscene / streamed animation)
-
-`.FST` is Harvester's custom streamed movie format. It combines a file header, a compact per-frame index table, block-coded video payloads, optional per-frame palettes, and per-frame audio chunks.
-
-### Summary
-
-| Property | Value |
-| --- | --- |
-| Format role | Cutscene / transition movie format |
-| Confirmed magic | `FST2` (`0x32545346` on disk, little-endian) |
-| Video model | 8-bit indexed frames decoded in `4 x 4` blocks |
-| Audio model | Per-frame audio chunks described by the frame index table |
-| Confirmed consumers | `run_fst_sequence_player`, `play_fst_sequence`, startup intro path, scripted room transitions |
-
-### File header
-
-| Offset | Size | Type | Description |
-| --- | --- | --- | --- |
-| `0x00` | `0x04` | ASCII | `FST2` magic |
-| `0x04` | `0x04` | uint32le | Frame width |
-| `0x08` | `0x04` | uint32le | Frame height |
-| `0x0c` | `0x04` | uint32le | `max_frame_size` |
-| `0x10` | `0x04` | uint32le | `frame_count` |
-| `0x14` | `0x04` | uint32le | `frame_rate` |
-| `0x18` | `0x04` | uint32le | `sample_rate` |
-| `0x1c` | `0x04` | uint32le | `bits_per_sample` |
-| `0x20` | `frame_count * 6` | array | `FstFrameIndexEntry[frame_count]` |
-
-### Frame index entry
-
-| Offset within entry | Size | Type | Description |
-| --- | --- | --- | --- |
-| `0x00` | `0x04` | uint32le | `video_size` |
-| `0x04` | `0x02` | uint16le | `audio_size` |
-
-### Video payload for one frame
-
-| Sequence element | Description |
-| --- | --- |
-| `uint16 bit_count` | Number of bits in the control bitstream |
-| `((bit_count >> 3) + 1)` bytes | Packed control-bit stream |
-| Optional `256 * 3` bytes | Present only when the first control bit is set; this is a VGA-style 6-bit palette block, not a `.PAL` resource block |
-| Block payload stream | One record per changed `4 x 4` tile across the frame |
-
-### Block coding
-
-| Control bits / payload | Meaning |
-| --- | --- |
-| Changed-bit `0` | Leave the existing `4 x 4` block unchanged |
-| Changed-bit `1`, mode-bit `0`, 16-byte payload | Literal `4 x 4` pixel block |
-| Changed-bit `1`, mode-bit `1`, 4-byte payload | Two colors plus a 16-bit mask describing a `4 x 4` block |
-
-### Notes
-
-- Sampled `VIRGLOGO.FST` is `320 x 200`, stores `131` frames, plays at `15` fps, and carries `22050 Hz` / `16-bit` audio.
-- The native and ScummVM players both treat FST as a streaming format: read one frame, queue its audio, decode its video, and advance without loading the whole movie at once.
-- `HARVEST.SCR` uses `GOFLIC`/`GODEATHFLIC` opcodes to trigger `.FST` files. That mismatch between opcode names and file extension is a strong architectural clue that FST replaced or wrapped an older movie concept without rewriting the script vocabulary.
-- The censorship path also shows how self-contained the format is: FST can carry its own palette updates frame-by-frame, while the player temporarily swaps in an external `CENSORED.PCX` overlay when gore is disabled.
-
-## PCX (Standard indexed still image)
-
-Harvester does use standard `.PCX` files in at least one confirmed place: the censorship overlay shown during certain FST sequences when gore is disabled.
-
-### Summary
-
-| Property | Value |
-| --- | --- |
-| Format role | Standard 8-bit indexed PCX still image |
-| Confirmed consumer | Native `load_pcx_bitmap`; ScummVM FST censorship overlay loader |
-| Encoding | PCX RLE |
-| Palette | Trailing 256-color palette block when present |
-
-### Confirmed header fields
-
-| Offset | Size | Type | Description |
-| --- | --- | --- | --- |
-| `0x00` | `0x01` | byte | Manufacturer (`0x0a` in sampled `CENSORED.PCX`) |
-| `0x01` | `0x01` | byte | Version (`0x05` in the sample) |
-| `0x02` | `0x01` | byte | Encoding (`0x01` = RLE) |
-| `0x03` | `0x01` | byte | Bits per pixel per plane (`0x08` in the sample) |
-| `0x04` | `0x02` | uint16le | `xMin` |
-| `0x06` | `0x02` | uint16le | `yMin` |
-| `0x08` | `0x02` | uint16le | `xMax` |
-| `0x0a` | `0x02` | uint16le | `yMax` |
-| `0x41` | `0x01` | byte | Color planes (`0x01` in the sample) |
-| `0x42` | `0x02` | uint16le | Bytes per line (`320` in the sample) |
-| `0x80` | variable | bytes | RLE-compressed image data |
-| `EOF - 769` | `0x01` | byte | Standard palette marker `0x0c` in the sampled file |
-| `EOF - 768` | `0x300` | bytes | 256-color palette block when present |
-
-### Notes
-
-- Sampled `CENSORED.PCX` is a textbook single-plane `320 x 200` PCX, which is why the ScummVM port can use the generic PCX decoder for it.
-- The native loader contains two Harvester-specific wrinkles: it trims the optional `0x0c` palette marker by jumping straight to the last `0x300` bytes, and when the logical width from `xMax` is one pixel smaller than `bytesPerLine`, it trims the padded stride byte after decode.
-- This is another useful contrast point for a blog post: Harvester mostly ships custom image formats (`BM`, `ABM`, `FST`), but it still keeps a standards-based escape hatch where that was convenient.
-
-## FLC (Standard Autodesk FLIC animation)
-
-Harvester does ship `.FLC` files, and the sampled files match the standard Autodesk FLIC/FLC header rather than a Harvester-specific wrapper. The current Harvester engine work has not yet recovered the exact native Harvester-side call path that consumes them, so this section stays limited to what is directly supported by sample bytes, binary strings, and the generic ScummVM FLIC decoder.
-
-### Summary
-
-| Property | Value |
-| --- | --- |
-| Format role | Standard 8-bit FLIC/FLC animation files bundled with Harvester |
-| Evidence | Sample files such as `GRAPHIC/ROOMANIM/HARVPNTR.FLC` and `GRAPHIC/FST/CHESMOV1.FLC`; native strings `Could not load flic.` and `flic.cpp`; hardcoded `.flc` paths in the binary |
-| Harvester-specific loader status | Not yet fully recovered in current Harvester analysis |
-| Decoder in repo | Generic ScummVM `video/flic_decoder.cpp` |
-
-### Confirmed standard header fields
-
-| Offset | Size | Type | Description |
-| --- | --- | --- | --- |
-| `0x00` | `0x04` | uint32le | File size |
-| `0x04` | `0x02` | uint16le | Magic `0xaf12` |
-| `0x06` | `0x02` | uint16le | Frame count |
-| `0x08` | `0x02` | uint16le | Width |
-| `0x0a` | `0x02` | uint16le | Height |
-| `0x0c` | `0x02` | uint16le | Color depth (`8` in the sampled file) |
-| `0x0e` | `0x02` | uint16le | Flags |
-| `0x10` | `0x04` | uint32le | Frame delay in milliseconds |
-| `0x50` | `0x04` | uint32le | Offset of frame 1 |
-| `0x54` | `0x04` | uint32le | Offset of frame 2 |
-| `0x80` | variable | bytes | First frame/chunk stream begins here in the generic decoder |
-
-### Notes
-
-- Sampled `HARVPNTR.FLC` starts with a valid FLC header: size `0x214a`, magic `0xaf12`, `10` frames, `26 x 26`, and `8`-bit color.
-- The decoded `HARVEST.SCR` sample does not reference `.FLC` files directly; its `GOFLIC` opcodes currently point to `.FST` files instead. That suggests `.FLC` belongs to a parallel or older asset path rather than the main shipping story-transition pipeline. This last point is an inference from the sampled script and should stay provisional.
-- Taken together, the resource set shows three animation strata: lightweight sprite ABMs, streamed FST movies, and a residual standard FLC layer. That is a strong narrative hook for explaining how mixed-tool resource pipelines often survive into shipped games.
-
-```
-ffplay -loop -1 "GRAPHIC/ROOMANIM/HARVPNTR.FLC"
-ffmpeg -i "GRAPHIC/ROOMANIM/HARVPNTR.FLC" out.gif
-```
diff --git a/TRACKER.md b/TRACKER.md
deleted file mode 100644
index 1aa25d857d5..00000000000
--- a/TRACKER.md
+++ /dev/null
@@ -1,18 +0,0 @@
-# TRACKER
-
-## Progress
-
-- Program: `HARVEST.LE`
-- Total functions: `927`
-- Named/non-`FUN_*`: `868`
-- Remaining `FUN_*`: `59`
-- Remaining real-space `FUN_*`: `16`
-- Remaining zero-xref `.image` `FUN_*` artifacts: `43`
-
-## Last Confirmed Action
-
-- On April 17, 2026, verified native cursor-enabled region targeting and player-region collision in Ghidra, then fixed `RMNBATH` bath-exit handling by allowing fast exit clicks there and adjusting side-region X targets so the native-style horizontal slack cannot stop the player outside narrow side exits such as `RNMBTH_BATH`.
-
-## Next Suggested Action
-
-- Runtime-test `RMNBATH` after entering from `BATHROOM_2_RMNBATH`: double-click `RNMBTH_BATH` and confirm it immediately runs `RMNBATHM_BATHROOM`, then single-click the same region and confirm walking still reaches the trigger.
diff --git a/gui/saveload-dialog.cpp b/gui/saveload-dialog.cpp
index 2f58f9a5476..abfe10e1a66 100644
--- a/gui/saveload-dialog.cpp
+++ b/gui/saveload-dialog.cpp
@@ -1180,7 +1180,7 @@ void SaveLoadChooserGrid::updateSaves() {
 		} else {
 			curButton.button->setGfx(kThumbnailWidth, kThumbnailHeight2, 0, 0, 0);
 		}
-		curButton.description->setLabel(Common::U32String(Common::String::format("%d. ", saveSlot)) + _saveList[i].getDescription().decode());
+		curButton.description->setLabel(Common::U32String::format("%d. %s", saveSlot, _saveList[i].getDescription().c_str()));
 
 		Common::U32String tooltip(_("Name: "));
 		tooltip += _saveList[i].getDescription().decode();


Commit: 6539c386fae40fcdac1f7c9a2f79b8aa022c9584
    https://github.com/scummvm/scummvm/commit/6539c386fae40fcdac1f7c9a2f79b8aa022c9584
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:09:00+02:00

Commit Message:
HARVESTER: Fix cross-disc save room restores

Changed paths:
    engines/harvester/dialogue.cpp
    engines/harvester/harvester.h
    engines/harvester/resources.cpp
    engines/harvester/resources.h
    engines/harvester/room.cpp
    engines/harvester/room_interaction.cpp
    engines/harvester/saveload.cpp
    engines/harvester/saveload.h
    engines/harvester/script.cpp
    engines/harvester/script.h


diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index f8d39fdd833..18480c4ad4d 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -794,7 +794,8 @@ public:
 			warning("Harvester: unable to activate disc %d resources", discNumber);
 			return Common::kReadingFailed;
 		}
-		if (discNumber == 3 && previousDisc > 0 && previousDisc != resources->getCurrentDisc()) {
+		if ((discNumber == 1 || discNumber == 3) &&
+				previousDisc > 0 && previousDisc != resources->getCurrentDisc()) {
 			Script *script = _engine.getScript();
 			if (!script || !script->reloadTownWorld(*resources)) {
 				warning("Harvester: unable to reload town script after disc prompt %d -> %d",
diff --git a/engines/harvester/harvester.h b/engines/harvester/harvester.h
index 997dad3001a..f363128bbb3 100644
--- a/engines/harvester/harvester.h
+++ b/engines/harvester/harvester.h
@@ -123,8 +123,8 @@ public:
 	bool requestDebugCommand(const CommandRecord &command);
 	bool requestDebugRoomChange(const Common::String &roomName);
 	void captureCurrentSaveRoomState(const Common::String &entranceName,
-		const Common::String &roomName, int playerX, int playerY, int playerZ, int playerFacing,
-		const Common::String &musicPath);
+		const Common::String &roomName, int playerX, int playerY, int playerZ,
+		int playerFacing, int discNumber, const Common::String &musicPath);
 	void clearCurrentSaveRoomState();
 	bool hasCurrentSaveRoomState() const { return _currentSaveRoomState.valid; }
 	const SaveRoomState &getCurrentSaveRoomState() const {
diff --git a/engines/harvester/resources.cpp b/engines/harvester/resources.cpp
index 6b562925b34..a87178e8923 100644
--- a/engines/harvester/resources.cpp
+++ b/engines/harvester/resources.cpp
@@ -43,9 +43,6 @@ static const ArchiveSpec kArchiveSpecs[] = {
 	{ '3', "INDEX.003", "HARVEST2.DAT", 28 }
 };
 
-static const int kFirstDiscNumber = 1;
-static const int kLastDiscNumber = 3;
-
 static bool hasArchiveSetPrefix(const Common::String &path) {
 	return path.size() >= 3 &&
 		path[0] >= '0' && path[0] <= '9' &&
@@ -85,7 +82,7 @@ static Common::String resolveDiscArchiveStoragePath(int discNumber, const Common
 	if (SearchMan.hasFile(Common::Path(discPath, '/')))
 		return discPath;
 
-	if (discNumber == kFirstDiscNumber && SearchMan.hasFile(Common::Path(normalized, '/')))
+	if (discNumber == ResourceManager::kFirstDiscNumber && SearchMan.hasFile(Common::Path(normalized, '/')))
 		return normalized;
 
 	return Common::String();
@@ -227,7 +224,8 @@ bool ResourceManager::ensureDiscMounted(int discNumber) {
 }
 
 void ResourceManager::unmountOtherDiscArchives(int keepDiscNumber) {
-	for (int discNumber = kFirstDiscNumber; discNumber <= kLastDiscNumber; ++discNumber) {
+	for (int discNumber = ResourceManager::kFirstDiscNumber;
+			discNumber <= ResourceManager::kLastDiscNumber; ++discNumber) {
 		if (discNumber == keepDiscNumber)
 			continue;
 
diff --git a/engines/harvester/resources.h b/engines/harvester/resources.h
index e952adb88ae..853685eb687 100644
--- a/engines/harvester/resources.h
+++ b/engines/harvester/resources.h
@@ -31,6 +31,11 @@ Common::String normalizeHarvesterResourcePath(const Common::String &path);
 
 class ResourceManager {
 public:
+	enum {
+		kFirstDiscNumber = 1,
+		kLastDiscNumber = 3
+	};
+
 	ResourceManager();
 	~ResourceManager();
 
diff --git a/engines/harvester/room.cpp b/engines/harvester/room.cpp
index 0aaae873cfb..b3cbcade050 100644
--- a/engines/harvester/room.cpp
+++ b/engines/harvester/room.cpp
@@ -631,7 +631,7 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 			const int facing = playerState.facing >= 0 ? playerState.facing : scene.state.playerFacing;
 			_engine.captureCurrentSaveRoomState(scene.state.entranceName, scene.state.roomName,
 				playerState.centerX, playerState.bottomY, (int)playerState.z, facing,
-				_engine.getMusicPath());
+				scene.state.discNumber, _engine.getMusicPath());
 		};
 		auto getSceneObjectBounds = [&](const ObjectRecord &object) {
 			if (object.boundsX2 > object.currentX && object.boundsY2 > object.currentY)
@@ -1603,7 +1603,8 @@ Common::Error RoomSystem::runRoomLoop(Flow &flow, const Common::String &targetNa
 				warning("Harvester: unable to activate disc %d resources", discNumber);
 				return Common::kReadingFailed;
 			}
-			if (discNumber == 3 && previousDisc > 0 && previousDisc != resources->getCurrentDisc()) {
+			if ((discNumber == 1 || discNumber == 3) &&
+					previousDisc > 0 && previousDisc != resources->getCurrentDisc()) {
 				Script *script = _engine.getScript();
 				if (!script || !script->reloadTownWorld(*resources)) {
 					warning("Harvester: unable to reload town script after disc prompt %d -> %d",
diff --git a/engines/harvester/room_interaction.cpp b/engines/harvester/room_interaction.cpp
index d07e63c6388..87337305134 100644
--- a/engines/harvester/room_interaction.cpp
+++ b/engines/harvester/room_interaction.cpp
@@ -154,13 +154,13 @@ Common::Error RoomInteractionProcessor::handleInteractionResult(const Interactio
 	if (roomTransition == kStartupRoomTransitionNone && !interaction.nextRoomName.empty())
 		roomTransition = kStartupRoomTransitionCloseup;
 
-	const bool needsDisc3RoomReload =
+	const bool needsTownScriptDiscReload =
 		discChanged &&
-		interaction.cdChangeDisc == 3 &&
+		(interaction.cdChangeDisc == 1 || interaction.cdChangeDisc == 3) &&
 		!interaction.requestRoomRestart &&
 		interaction.nextRoomName.empty() &&
 		!interaction.mutatedRuntimeState;
-	if (needsDisc3RoomReload)
+	if (needsTownScriptDiscReload)
 		return queueImplicitRoomRestart();
 
 	if (!interaction.nextRoomName.empty() &&
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index 11803909b0c..e3e37947366 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -46,8 +46,8 @@ static const uint32 kHarvesterSaveVersion = 18;
 
 static void logStartupSaveRoomState(const char *operation, const SaveRoomState &state) {
 	debugC(1, kDebugRoom,
-		"Harvester: %s startup save room state valid=%d entrance='%s' room='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
-		operation, state.valid, state.entranceName.c_str(), state.roomName.c_str(),
+		"Harvester: %s startup save room state valid=%d disc=%d entrance='%s' room='%s' spawn=(%d,%d,%d) facing=%d music='%s'",
+		operation, state.valid, state.discNumber, state.entranceName.c_str(), state.roomName.c_str(),
 		state.playerX, state.playerY, state.playerZ, state.playerFacing, state.musicPath.c_str());
 }
 
@@ -62,11 +62,98 @@ static void syncStartupSaveRoomState(Common::Serializer &s, SaveRoomState &state
 	s.syncAsSint32LE(state.playerFacing);
 }
 
+static int normalizeDiscNumber(int discNumber) {
+	return discNumber >= ResourceManager::kFirstDiscNumber &&
+			discNumber <= ResourceManager::kLastDiscNumber
+		? discNumber
+		: ResourceManager::kFirstDiscNumber;
+}
+
+static bool reloadTownWorldForSaveRestore(HarvesterEngine &engine, int previousDisc,
+		int discNumber) {
+	Script *script = engine.getScript();
+	ResourceManager *resources = engine.getResources();
+	if (!script || !resources)
+		return false;
+
+	if (!script->reloadTownWorld(*resources)) {
+		warning("Harvester: unable to reload town script after save restore disc switch %d -> %d",
+			previousDisc, discNumber);
+		return false;
+	}
+
+	debugC(1, kDebugResources,
+		"Harvester: reloaded town script after save restore disc switch %d -> %d",
+		previousDisc, discNumber);
+	return true;
+}
+
+static bool loadedRoomTargetAvailableOnDisc(HarvesterEngine &engine,
+		const SaveRoomState &roomState, int discNumber) {
+	ResourceManager *resources = engine.getResources();
+	if (!resources)
+		return false;
+	if (!engine.activateDisc(discNumber))
+		return false;
+
+	Script probeScript;
+	if (!probeScript.load(*resources))
+		return false;
+
+	if (probeScript.hasRoomSetupTarget(roomState.entranceName, roomState.roomName))
+		return true;
+
+	debugC(1, kDebugResources,
+		"Harvester: loaded room target entrance='%s' room='%s' not present on disc %d",
+		roomState.entranceName.c_str(), roomState.roomName.c_str(), discNumber);
+	return false;
+}
+
+static bool prepareLoadedRoomDisc(HarvesterEngine &engine, SaveRoomState &roomState,
+		int preferredDisc) {
+	Script *script = engine.getScript();
+	ResourceManager *resources = engine.getResources();
+	if (!script || !resources)
+		return false;
+
+	const int originalDisc = resources->getCurrentDisc();
+	preferredDisc = normalizeDiscNumber(preferredDisc);
+	int resolvedDisc = 0;
+	if (loadedRoomTargetAvailableOnDisc(engine, roomState, preferredDisc)) {
+		resolvedDisc = preferredDisc;
+	} else {
+		for (int discNumber = ResourceManager::kFirstDiscNumber;
+				discNumber <= ResourceManager::kLastDiscNumber; ++discNumber) {
+			if (discNumber == preferredDisc)
+				continue;
+			if (!loadedRoomTargetAvailableOnDisc(engine, roomState, discNumber))
+				continue;
+
+			debugC(1, kDebugResources,
+				"Harvester: resolved loaded room target entrance='%s' room='%s' on fallback disc %d",
+				roomState.entranceName.c_str(), roomState.roomName.c_str(), discNumber);
+			resolvedDisc = discNumber;
+			break;
+		}
+	}
+
+	if (resolvedDisc <= 0) {
+		warning("Harvester: unable to resolve loaded room target entrance='%s' room='%s' on any mounted disc",
+			roomState.entranceName.c_str(), roomState.roomName.c_str());
+		return false;
+	}
+
+	roomState.discNumber = resolvedDisc;
+	if (!engine.activateDisc(roomState.discNumber))
+		return false;
+	return reloadTownWorldForSaveRestore(engine, originalDisc, roomState.discNumber);
+}
+
 } // End of anonymous namespace
 
 void HarvesterEngine::captureCurrentSaveRoomState(const Common::String &entranceName,
-		const Common::String &roomName, int playerX, int playerY, int playerZ, int playerFacing,
-		const Common::String &musicPath) {
+		const Common::String &roomName, int playerX, int playerY, int playerZ,
+		int playerFacing, int discNumber, const Common::String &musicPath) {
 	_currentSaveRoomState.entranceName = entranceName;
 	_currentSaveRoomState.roomName = roomName;
 	_currentSaveRoomState.musicPath =
@@ -75,6 +162,7 @@ void HarvesterEngine::captureCurrentSaveRoomState(const Common::String &entrance
 	_currentSaveRoomState.playerY = playerY;
 	_currentSaveRoomState.playerZ = playerZ;
 	_currentSaveRoomState.playerFacing = playerFacing;
+	_currentSaveRoomState.discNumber = normalizeDiscNumber(discNumber);
 	_currentSaveRoomState.valid = !roomName.empty();
 }
 
@@ -101,15 +189,22 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 	if (!s.syncVersion(kHarvesterSaveVersion))
 		return Common::kReadingFailed;
 
-	int32 serializedDisc = (_resources && _resources->getCurrentDisc() > 0) ? _resources->getCurrentDisc() : 1;
+	SaveRoomState roomState = s.isLoading()
+		? SaveRoomState()
+		: _currentSaveRoomState;
+
+	int32 serializedDisc = roomState.discNumber > 0
+		? roomState.discNumber
+		: ((_resources && _resources->getCurrentDisc() > 0) ? _resources->getCurrentDisc() : 1);
+	// Save the room's source disc rather than the transient active asset disc.
+	// CD prompts can leave CD3 resources active while a CD1 room is still current.
 	// FIXME: Remove the pre-release version-14 fallback once Harvester ships and only
 	// clean release-era saves remain.
 	s.syncAsSint32LE(serializedDisc, 14);
-	const int restoredDisc = serializedDisc > 0 ? serializedDisc : 1;
+	const int restoredDisc = normalizeDiscNumber(serializedDisc);
+	if (s.isLoading())
+		roomState.discNumber = restoredDisc;
 
-	SaveRoomState roomState = s.isLoading()
-		? SaveRoomState()
-		: _currentSaveRoomState;
 	if (s.isSaving())
 		logStartupSaveRoomState("saving", roomState);
 	syncStartupSaveRoomState(s, roomState);
@@ -144,25 +239,11 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 	if (s.isLoading()) {
 		if (!roomState.valid || roomState.roomName.empty())
 			return Common::kReadingFailed;
-		const int previousDisc = (_resources && _resources->getCurrentDisc() > 0)
-			? _resources->getCurrentDisc()
-			: 0;
-		if (!activateDisc(restoredDisc))
+		if (!prepareLoadedRoomDisc(*this, roomState, restoredDisc))
 			return Common::kReadingFailed;
-		if (previousDisc > 0 && previousDisc != restoredDisc) {
-			if (!_script->reloadTownWorld(*_resources)) {
-				warning("Harvester: unable to reload town script after save restore disc switch %d -> %d",
-					previousDisc, restoredDisc);
-				return Common::kReadingFailed;
-			}
-
-			debugC(1, kDebugResources,
-				"Harvester: reloaded town script after save restore disc switch %d -> %d",
-				previousDisc, restoredDisc);
-		}
 		_currentSaveRoomState = roomState;
 		_pendingLoadedSaveRoomState = roomState;
-		_pendingLoadedDisc = restoredDisc;
+		_pendingLoadedDisc = roomState.discNumber;
 		logStartupSaveRoomState("loaded", roomState);
 	}
 	return Common::kNoError;
diff --git a/engines/harvester/saveload.h b/engines/harvester/saveload.h
index 5167b05457e..b2bdde087a7 100644
--- a/engines/harvester/saveload.h
+++ b/engines/harvester/saveload.h
@@ -34,6 +34,7 @@ struct SaveRoomState {
 	int playerY = 0;
 	int playerZ = 0;
 	int playerFacing = -1;
+	int discNumber = 0;
 	bool valid = false;
 
 	void clear() {
@@ -44,6 +45,7 @@ struct SaveRoomState {
 		playerY = 0;
 		playerZ = 0;
 		playerFacing = -1;
+		discNumber = 0;
 		valid = false;
 	}
 };
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index 846440adfaa..d4ece71c5f5 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -1795,6 +1795,18 @@ bool Script::materializeRoomState(const Common::String &entranceName, const Comm
 	return buildRuntimeRoomState(*room, entrance, resources, state);
 }
 
+bool Script::hasRoomSetupTarget(const Common::String &entranceName,
+		const Common::String &roomName) const {
+	const EntranceRecord *entrance = findEntranceRecord(entranceName);
+	const Common::String resolvedRoomName = !roomName.empty()
+		? roomName
+		: (entrance ? entrance->roomName : Common::String());
+	if (resolvedRoomName.empty())
+		return false;
+
+	return findRoomRecord(resolvedRoomName) != nullptr;
+}
+
 bool Script::executeRoomEnterCommands(const Common::String &roomName,
 		InteractionResult &result) {
 	result = InteractionResult();
@@ -2726,6 +2738,7 @@ bool Script::buildRuntimeRoomState(const RoomRecord &room, const EntranceRecord
 	state.backgroundObjectName = background->objectName;
 	state.palettePath = room.palettePath;
 	state.backgroundPath = background->spritePath;
+	state.discNumber = resources.getCurrentDisc();
 	state.roomMinZ = room.minZ;
 	state.roomMaxZ = room.maxZ;
 	state.roomMaxZScreenY = room.maxZScreenY;
diff --git a/engines/harvester/script.h b/engines/harvester/script.h
index 27c768b0ae5..39daf619a45 100644
--- a/engines/harvester/script.h
+++ b/engines/harvester/script.h
@@ -241,6 +241,7 @@ struct RoomSetupState {
 	Common::String palettePath;
 	Common::String backgroundPath;
 	Common::String musicPath;
+	int discNumber = 0;
 	int roomMinZ = 0;
 	int roomMaxZ = 0;
 	int roomMaxZScreenY = 0;
@@ -361,6 +362,7 @@ public:
 		ResourceManager &resources);
 	bool materializeRoomState(const Common::String &entranceName, const Common::String &roomName,
 		RoomSetupState &state, ResourceManager &resources) const;
+	bool hasRoomSetupTarget(const Common::String &entranceName, const Common::String &roomName) const;
 	bool executeRoomEnterCommands(const Common::String &roomName, InteractionResult &result);
 	bool executeRoomExitCommands(const Common::String &roomName, InteractionResult &result);
 	bool resolveObjectInteraction(const ObjectRecord &object, InteractionResult &result,


Commit: 6a8282bc75e8f3e02e7be8ef243dc2153f77ce9e
    https://github.com/scummvm/scummvm/commit/6a8282bc75e8f3e02e7be8ef243dc2153f77ce9e
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:09:00+02:00

Commit Message:
HARVESTER: Finalize save format version

Changed paths:
    engines/harvester/dialogue.cpp
    engines/harvester/npc/boyle_dialogue.h
    engines/harvester/npc/buster_dialogue.h
    engines/harvester/npc/butcher_dialogue.h
    engines/harvester/npc/edna_dialogue.h
    engines/harvester/npc/fireman1_dialogue.h
    engines/harvester/npc/fireman2_dialogue.h
    engines/harvester/npc/johnson_dialogue.h
    engines/harvester/npc/parsons_dialogue.h
    engines/harvester/npc/swell_dialogue.h
    engines/harvester/saveload.cpp
    engines/harvester/script.cpp


diff --git a/engines/harvester/dialogue.cpp b/engines/harvester/dialogue.cpp
index 18480c4ad4d..fce77fbff5e 100644
--- a/engines/harvester/dialogue.cpp
+++ b/engines/harvester/dialogue.cpp
@@ -173,20 +173,20 @@ static void syncDialogueSharedState(Common::Serializer &s, DialogueSharedState &
 	syncDialogueBool(s, state.discussedMrPottsTuesdayNightAlibi);
 	syncDialogueBool(s, state.discussedMrsPottsTuesdayNightAlibi);
 	syncDialogueBool(s, state.confrontedMrPottsAboutSpyhole);
-	syncDialogueInt(s, state.serializedTalkStateFlagD2cd8, 4);
-	syncDialogueInt(s, state.dwaynePendingKarinAliveFollowupState, 4);
-	syncDialogueInt(s, state.dialogueStateD2eac, 4);
-	syncDialogueInt(s, state.dialogueStateD2eb4, 4);
-	syncDialogueInt(s, state.discussedNoteCheckbookEvidence, 4);
-	syncDialogueInt(s, state.discussedTvDeedEvidence, 4);
-	syncDialogueInt(s, state.discussedLedgerEvidence, 4);
-	syncDialogueInt(s, state.discussedCasketPhotoEvidence, 4);
-	syncDialogueInt(s, state.discussedWhaleyHerrillPhoto, 4);
-	syncDialogueInt(s, state.discussedKarinPurse, 4);
-	syncDialogueInt(s, state.momFatherTopicState, 4);
-	syncDialogueInt(s, state.dadMeatPermissionState, 4);
-	syncDialogueInt(s, state.sergeantCompletedFirstTaskState, 4);
-	syncDialogueInt(s, state.dialogueStateD2f30, 4);
+	syncDialogueInt(s, state.serializedTalkStateFlagD2cd8);
+	syncDialogueInt(s, state.dwaynePendingKarinAliveFollowupState);
+	syncDialogueInt(s, state.dialogueStateD2eac);
+	syncDialogueInt(s, state.dialogueStateD2eb4);
+	syncDialogueInt(s, state.discussedNoteCheckbookEvidence);
+	syncDialogueInt(s, state.discussedTvDeedEvidence);
+	syncDialogueInt(s, state.discussedLedgerEvidence);
+	syncDialogueInt(s, state.discussedCasketPhotoEvidence);
+	syncDialogueInt(s, state.discussedWhaleyHerrillPhoto);
+	syncDialogueInt(s, state.discussedKarinPurse);
+	syncDialogueInt(s, state.momFatherTopicState);
+	syncDialogueInt(s, state.dadMeatPermissionState);
+	syncDialogueInt(s, state.sergeantCompletedFirstTaskState);
+	syncDialogueInt(s, state.dialogueStateD2f30);
 }
 
 static const CftFontResource *findStartupFontByName(const HarvesterEngine &engine, const char *fontName) {
diff --git a/engines/harvester/npc/boyle_dialogue.h b/engines/harvester/npc/boyle_dialogue.h
index 843bb0cf3f1..e9453e8217e 100644
--- a/engines/harvester/npc/boyle_dialogue.h
+++ b/engines/harvester/npc/boyle_dialogue.h
@@ -41,7 +41,7 @@ public:
 	void resetState() override { _state = BoyleRoomDialogueState(); }
 	void syncState(Common::Serializer &s) override {
 		syncDialogueBool(s, _state.introPending);
-		syncDialogueBool(s, _state.extraEvidenceReplyEnabled, 8);
+		syncDialogueBool(s, _state.extraEvidenceReplyEnabled);
 		syncDialogueBool(s, _state.evidenceFollowupShown);
 		syncDialogueBool(s, _state.stephMidgameShown);
 		syncDialogueBool(s, _state.dayFiveShown);
diff --git a/engines/harvester/npc/buster_dialogue.h b/engines/harvester/npc/buster_dialogue.h
index cab792e8c97..dde1dd192ba 100644
--- a/engines/harvester/npc/buster_dialogue.h
+++ b/engines/harvester/npc/buster_dialogue.h
@@ -41,7 +41,7 @@ public:
 		syncDialogueBool(s, _state.introPending);
 		syncDialogueBool(s, _state.secondIntroPending);
 		syncDialogueBool(s, _state.thirdIntroPending);
-		syncDialogueBool(s, _state.secondIntroResponseTwoSeen, 10);
+		syncDialogueBool(s, _state.secondIntroResponseTwoSeen);
 	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
diff --git a/engines/harvester/npc/butcher_dialogue.h b/engines/harvester/npc/butcher_dialogue.h
index a670528bf7c..9a63fd69e29 100644
--- a/engines/harvester/npc/butcher_dialogue.h
+++ b/engines/harvester/npc/butcher_dialogue.h
@@ -40,10 +40,10 @@ public:
 	void resetState() override { _state = ButcherRoomDialogueState(); }
 	void syncState(Common::Serializer &s) override {
 		syncDialogueBool(s, _state.introPending);
-		syncDialogueBool(s, _state.stephMidgameShown, 7);
-		syncDialogueBool(s, _state.dayFiveShown, 7);
-		syncDialogueString(s, _state.currentTopicBuffer, 7);
-		syncDialogueInt(s, _state.currentTopicBufferLineIndex, 7);
+		syncDialogueBool(s, _state.stephMidgameShown);
+		syncDialogueBool(s, _state.dayFiveShown);
+		syncDialogueString(s, _state.currentTopicBuffer);
+		syncDialogueInt(s, _state.currentTopicBufferLineIndex);
 	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
diff --git a/engines/harvester/npc/edna_dialogue.h b/engines/harvester/npc/edna_dialogue.h
index e37e893db2c..5db136900d3 100644
--- a/engines/harvester/npc/edna_dialogue.h
+++ b/engines/harvester/npc/edna_dialogue.h
@@ -45,13 +45,13 @@ public:
 	void syncState(Common::Serializer &s) override {
 		syncDialogueBool(s, _state.introPending);
 		syncDialogueBool(s, _state.karinPurseLinePlayed);
-		syncDialogueBool(s, _state.dayFiveShown, 6);
-		syncDialogueBool(s, _state.scratchedTuckerShown, 6);
-		syncDialogueBool(s, _state.barberPoleStolenShown, 6);
-		syncDialogueBool(s, _state.boltOfClothTakenShown, 6);
-		syncDialogueBool(s, _state.escapedJailShown, 6);
-		syncDialogueBool(s, _state.burnedTvStationShown, 6);
-		syncDialogueBool(s, _state.karinFoundDeadShown, 6);
+		syncDialogueBool(s, _state.dayFiveShown);
+		syncDialogueBool(s, _state.scratchedTuckerShown);
+		syncDialogueBool(s, _state.barberPoleStolenShown);
+		syncDialogueBool(s, _state.boltOfClothTakenShown);
+		syncDialogueBool(s, _state.escapedJailShown);
+		syncDialogueBool(s, _state.burnedTvStationShown);
+		syncDialogueBool(s, _state.karinFoundDeadShown);
 	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
diff --git a/engines/harvester/npc/fireman1_dialogue.h b/engines/harvester/npc/fireman1_dialogue.h
index c096a1c9541..e907af54a4a 100644
--- a/engines/harvester/npc/fireman1_dialogue.h
+++ b/engines/harvester/npc/fireman1_dialogue.h
@@ -38,8 +38,8 @@ public:
 	void resetState() override { _state = Fireman1RoomDialogueState(); }
 	void syncState(Common::Serializer &s) override {
 		syncDialogueBool(s, _state.talkStatePending);
-		syncDialogueBool(s, _state.stephMidgamePlayedShown, 11);
-		syncDialogueBool(s, _state.boltOfClothTakenShown, 11);
+		syncDialogueBool(s, _state.stephMidgamePlayedShown);
+		syncDialogueBool(s, _state.boltOfClothTakenShown);
 	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
diff --git a/engines/harvester/npc/fireman2_dialogue.h b/engines/harvester/npc/fireman2_dialogue.h
index 02e19af4792..329ddddb895 100644
--- a/engines/harvester/npc/fireman2_dialogue.h
+++ b/engines/harvester/npc/fireman2_dialogue.h
@@ -38,8 +38,8 @@ public:
 	void resetState() override { _state = Fireman2RoomDialogueState(); }
 	void syncState(Common::Serializer &s) override {
 		syncDialogueBool(s, _state.talkStateBlock);
-		syncDialogueInt(s, _state.dialogueStateD2de0, 12);
-		syncDialogueInt(s, _state.dialogueStateD2de4, 12);
+		syncDialogueInt(s, _state.dialogueStateD2de0);
+		syncDialogueInt(s, _state.dialogueStateD2de4);
 	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
diff --git a/engines/harvester/npc/johnson_dialogue.h b/engines/harvester/npc/johnson_dialogue.h
index 125c3d2ddef..53ceed08417 100644
--- a/engines/harvester/npc/johnson_dialogue.h
+++ b/engines/harvester/npc/johnson_dialogue.h
@@ -52,7 +52,7 @@ public:
 		syncDialogueBool(s, _state.tryToScratchTuckerShown);
 		syncDialogueBool(s, _state.scratchedTuckerReintroShown);
 		syncDialogueBool(s, _state.stephMidgamePlayedShown);
-		syncDialogueBool(s, _state.boltOfClothTakenShown, 5);
+		syncDialogueBool(s, _state.boltOfClothTakenShown);
 		syncDialogueBool(s, _state.karinKidnapedShown);
 		syncDialogueBool(s, _state.karinFoundAliveShown);
 		syncDialogueBool(s, _state.barberPoleStolenShown);
diff --git a/engines/harvester/npc/parsons_dialogue.h b/engines/harvester/npc/parsons_dialogue.h
index dba26849b7d..ffd8ae2adc5 100644
--- a/engines/harvester/npc/parsons_dialogue.h
+++ b/engines/harvester/npc/parsons_dialogue.h
@@ -42,12 +42,12 @@ public:
 	void resetState() override { _state = ParsonsRoomDialogueState(); }
 	void syncState(Common::Serializer &s) override {
 		syncDialogueBool(s, _state.introPending);
-		syncDialogueBool(s, _state.stephMidgameShown, 9);
-		syncDialogueBool(s, _state.scratchedTuckerShown, 9);
-		syncDialogueBool(s, _state.barberPoleStolenShown, 9);
-		syncDialogueBool(s, _state.dinerBurnedShown, 9);
-		syncDialogueBool(s, _state.burnedTvStationShown, 9);
-		syncDialogueBool(s, _state.karinKidnapedShown, 9);
+		syncDialogueBool(s, _state.stephMidgameShown);
+		syncDialogueBool(s, _state.scratchedTuckerShown);
+		syncDialogueBool(s, _state.barberPoleStolenShown);
+		syncDialogueBool(s, _state.dinerBurnedShown);
+		syncDialogueBool(s, _state.burnedTvStationShown);
+		syncDialogueBool(s, _state.karinKidnapedShown);
 	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
diff --git a/engines/harvester/npc/swell_dialogue.h b/engines/harvester/npc/swell_dialogue.h
index e0c2fac5844..7d990e272e1 100644
--- a/engines/harvester/npc/swell_dialogue.h
+++ b/engines/harvester/npc/swell_dialogue.h
@@ -41,11 +41,11 @@ public:
 	void resetState() override { _state = SwellRoomDialogueState(); }
 	void syncState(Common::Serializer &s) override {
 		syncDialogueBool(s, _state.talkStatePending);
-		syncDialogueBool(s, _state.stephMidgameShown, 13);
-		syncDialogueBool(s, _state.barberPoleStolenShown, 13);
-		syncDialogueBool(s, _state.boltOfClothTakenShown, 13);
-		syncDialogueBool(s, _state.burnedTvStationShown, 13);
-		syncDialogueBool(s, _state.karinKidnapedShown, 13);
+		syncDialogueBool(s, _state.stephMidgameShown);
+		syncDialogueBool(s, _state.barberPoleStolenShown);
+		syncDialogueBool(s, _state.boltOfClothTakenShown);
+		syncDialogueBool(s, _state.burnedTvStationShown);
+		syncDialogueBool(s, _state.karinKidnapedShown);
 	}
 	Common::Error handleDialogue(DialogueRuntime &runtime,
 		const Common::String &usedItemName, DialogueSharedState &sharedState) override;
diff --git a/engines/harvester/saveload.cpp b/engines/harvester/saveload.cpp
index e3e37947366..5ba9628c66d 100644
--- a/engines/harvester/saveload.cpp
+++ b/engines/harvester/saveload.cpp
@@ -39,10 +39,9 @@ static void syncSerializedBool(Common::Serializer &s, bool &value) {
 }
 
 static const char kHarvesterSaveMagic[] = { 'H', 'S', 'A', 'V' };
-// FIXME: These pre-release save-version bumps exist only to stabilize the
-// Harvester room-runtime payload before release. Fold them into the final
-// shipped save layout once the engine lands.
-static const uint32 kHarvesterSaveVersion = 18;
+// Increment this if a future save layout change intentionally breaks
+// compatibility with existing Harvester saves.
+static const uint32 kHarvesterSaveVersion = 1;
 
 static void logStartupSaveRoomState(const char *operation, const SaveRoomState &state) {
 	debugC(1, kDebugRoom,
@@ -186,8 +185,13 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 		return Common::kWritingFailed;
 	if (!s.matchBytes(kHarvesterSaveMagic, sizeof(kHarvesterSaveMagic)))
 		return Common::kReadingFailed;
-	if (!s.syncVersion(kHarvesterSaveVersion))
-		return Common::kReadingFailed;
+	if (!s.syncVersion(kHarvesterSaveVersion) || s.getVersion() != kHarvesterSaveVersion) {
+		if (s.isLoading()) {
+			warning("Harvester: unsupported save version %u, expected %u",
+				(uint)s.getVersion(), (uint)kHarvesterSaveVersion);
+		}
+		return s.isLoading() ? Common::kReadingFailed : Common::kWritingFailed;
+	}
 
 	SaveRoomState roomState = s.isLoading()
 		? SaveRoomState()
@@ -198,9 +202,7 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 		: ((_resources && _resources->getCurrentDisc() > 0) ? _resources->getCurrentDisc() : 1);
 	// Save the room's source disc rather than the transient active asset disc.
 	// CD prompts can leave CD3 resources active while a CD1 room is still current.
-	// FIXME: Remove the pre-release version-14 fallback once Harvester ships and only
-	// clean release-era saves remain.
-	s.syncAsSint32LE(serializedDisc, 14);
+	s.syncAsSint32LE(serializedDisc);
 	const int restoredDisc = normalizeDiscNumber(serializedDisc);
 	if (s.isLoading())
 		roomState.discNumber = restoredDisc;
@@ -209,32 +211,29 @@ Common::Error HarvesterEngine::syncGame(Common::Serializer &s) {
 		logStartupSaveRoomState("saving", roomState);
 	syncStartupSaveRoomState(s, roomState);
 	_script->syncRuntimeSaveState(s);
-	// FIXME: Drop the pre-release version-3 dialogue blob fallback after release; clean
-	// Harvester saves will always carry dialogue state.
-	if (s.getVersion() >= 3) {
-		Common::Array<byte> dialogueStateBlob;
-		uint32 dialogueStateSize = 0;
-		if (s.isSaving()) {
-			if (!_activeFlow || !_activeFlow->buildDialogueSaveStateBlob(dialogueStateBlob, s.getVersion()))
-				return Common::kWritingFailed;
-			dialogueStateSize = dialogueStateBlob.size();
-			if (dialogueStateSize == 0)
-				return Common::kWritingFailed;
-		}
-		s.syncAsUint32LE(dialogueStateSize);
-		if (s.isLoading()) {
-			if (dialogueStateSize == 0)
-				return Common::kReadingFailed;
-			dialogueStateBlob.resize(dialogueStateSize);
-			}
-			s.syncBytes(dialogueStateBlob.data(), dialogueStateSize);
-			if (s.isLoading()) {
-				_pendingLoadedDialogueStateBlob = dialogueStateBlob;
-				_pendingLoadedDialogueStateBlobVersion = s.getVersion();
-			}
-		}
-		if (s.err())
-			return s.isLoading() ? Common::kReadingFailed : Common::kWritingFailed;
+
+	Common::Array<byte> dialogueStateBlob;
+	uint32 dialogueStateSize = 0;
+	if (s.isSaving()) {
+		if (!_activeFlow || !_activeFlow->buildDialogueSaveStateBlob(dialogueStateBlob, s.getVersion()))
+			return Common::kWritingFailed;
+		dialogueStateSize = dialogueStateBlob.size();
+		if (dialogueStateSize == 0)
+			return Common::kWritingFailed;
+	}
+	s.syncAsUint32LE(dialogueStateSize);
+	if (s.isLoading()) {
+		if (dialogueStateSize == 0)
+			return Common::kReadingFailed;
+		dialogueStateBlob.resize(dialogueStateSize);
+	}
+	s.syncBytes(dialogueStateBlob.data(), dialogueStateSize);
+	if (s.isLoading()) {
+		_pendingLoadedDialogueStateBlob = dialogueStateBlob;
+		_pendingLoadedDialogueStateBlobVersion = s.getVersion();
+	}
+	if (s.err())
+		return s.isLoading() ? Common::kReadingFailed : Common::kWritingFailed;
 
 	if (s.isLoading()) {
 		if (!roomState.valid || roomState.roomName.empty())
diff --git a/engines/harvester/script.cpp b/engines/harvester/script.cpp
index d4ece71c5f5..4d926ab7c1b 100644
--- a/engines/harvester/script.cpp
+++ b/engines/harvester/script.cpp
@@ -156,10 +156,7 @@ static void syncStartupNpcRecord(Common::Serializer &s, NpcRecord &record) {
 	s.syncString(record.roomName);
 	syncBool(s, record.deathOrMonsterfyFlag);
 	syncBool(s, record.runtimeSpawned);
-	// FIXME: Remove this pre-release version-17 gate once Harvester ships and all
-	// saves carry room-NPC corpse frame state.
-	if (s.getVersion() >= 17)
-		s.syncAsSint32LE(record.runtimeState);
+	s.syncAsSint32LE(record.runtimeState);
 	syncBool(s, record.active);
 	syncBool(s, record.visible);
 	syncBool(s, record.savedVisible);
@@ -179,11 +176,6 @@ static void syncStartupMonsterRecord(Common::Serializer &s, MonsterRecord &recor
 	s.syncString(record.onDeathActionTag);
 	syncBool(s, record.active);
 	syncBool(s, record.visible);
-	// FIXME: Remove the pre-v2 monster save-layout fallback after release; clean
-	// Harvester saves will always serialize the full combat payload.
-	if (s.getVersion() < 2)
-		return;
-
 	s.syncAsSint32LE(record.initialHitPoints);
 	s.syncAsSint32LE(record.currentHitPoints);
 	s.syncAsSint32LE(record.damageAmount);
@@ -210,31 +202,7 @@ static void syncStartupMonsterRecord(Common::Serializer &s, MonsterRecord &recor
 	s.syncAsSint32LE(record.hitSoundTriggerFrame);
 	s.syncAsSint32LE(record.footstepSoundTriggerFrame);
 	s.syncAsSint32LE(record.deathSoundTriggerFrame);
-	// FIXME: Remove this version-16 gate before release; clean Harvester saves
-	// should always serialize monster runtimeState.
-	if (s.getVersion() >= 16)
-		s.syncAsSint32LE(record.runtimeState);
-}
-
-// FIXME: Remove this pre-release monster combat migration after Harvester ships and
-// only clean release-era saves remain.
-static void migrateLegacyMonsterCombatFields(const MonsterRecord &baseMonster,
-		MonsterRecord &currentMonster) {
-	const int legacyInitialHitPoints = MAX(0, currentMonster.initialHitPoints);
-	const int legacyCurrentHitPoints = CLIP<int>(currentMonster.currentHitPoints, 0, legacyInitialHitPoints);
-	const int damageTaken = legacyInitialHitPoints - legacyCurrentHitPoints;
-
-	currentMonster.engageDistance = baseMonster.engageDistance;
-	currentMonster.initialHitPoints = baseMonster.initialHitPoints;
-	currentMonster.currentHitPoints = MAX(0, baseMonster.initialHitPoints - damageTaken);
-	currentMonster.damageAmount = baseMonster.damageAmount;
-}
-
-static bool hasLegacyMonsterCombatFieldLayout(const MonsterRecord &baseMonster,
-		const MonsterRecord &currentMonster) {
-	return currentMonster.initialHitPoints != baseMonster.initialHitPoints ||
-		currentMonster.damageAmount != baseMonster.damageAmount ||
-		currentMonster.engageDistance != baseMonster.engageDistance;
+	s.syncAsSint32LE(record.runtimeState);
 }
 
 static bool matchesMonsterIdentity(const MonsterRecord &candidate, const MonsterRecord &monster) {
@@ -1666,108 +1634,22 @@ void Script::syncRuntimeSaveState(Common::Serializer &s) {
 	if (s.isLoading())
 		restoreMonsterRecordIndices(_monsters, _currentMonsters);
 	s.syncAsSint32LE(_playerCurrentHitPoints);
-	// FIXME: Remove this pre-v2 save-layout gate after release; clean Harvester
-	// saves always carry timer state, combat loadout, and paused-state fields.
-	if (s.getVersion() >= 2) {
-		syncRecordArray(s, _currentTimers, syncStartupTimerRecord);
-		s.syncAsSint32LE(_playerCombatLoadout);
-		syncBool(s, _playerControlPaused);
-		// FIXME: Remove this pre-release version-18 gate once Harvester ships and all
-		// saves carry combat resource counts for the room HUD and ranged weapons.
-		if (s.getVersion() >= 18) {
-			s.syncAsSint32LE(_nailgunAmmoCount);
-			s.syncAsSint32LE(_shotgunShellCount);
-			s.syncAsSint32LE(_nineGunBulletCount);
-			s.syncAsSint32LE(_thirtyEightGunBulletCount);
-			s.syncAsSint32LE(_chainsawFuelCount);
-		}
-	}
+	syncRecordArray(s, _currentTimers, syncStartupTimerRecord);
+	s.syncAsSint32LE(_playerCombatLoadout);
+	syncBool(s, _playerControlPaused);
+	s.syncAsSint32LE(_nailgunAmmoCount);
+	s.syncAsSint32LE(_shotgunShellCount);
+	s.syncAsSint32LE(_nineGunBulletCount);
+	s.syncAsSint32LE(_thirtyEightGunBulletCount);
+	s.syncAsSint32LE(_chainsawFuelCount);
 	if (s.isLoading()) {
 		_playerCurrentHitPoints = clampPlayerHitPoints(_playerCurrentHitPoints);
 		_playerCombatLoadout = clampPlayerCombatLoadout(_playerCombatLoadout);
-		if (s.getVersion() >= 18) {
-			_nailgunAmmoCount = clampCombatResourceCount(2, _nailgunAmmoCount);
-			_shotgunShellCount = clampCombatResourceCount(3, _shotgunShellCount);
-			_nineGunBulletCount = clampCombatResourceCount(4, _nineGunBulletCount);
-			_thirtyEightGunBulletCount = clampCombatResourceCount(5, _thirtyEightGunBulletCount);
-			_chainsawFuelCount = clampCombatResourceCount(14, _chainsawFuelCount);
-		} else {
-			_nailgunAmmoCount = 0;
-			_shotgunShellCount = 0;
-			_nineGunBulletCount = 0;
-			_thirtyEightGunBulletCount = 0;
-			_chainsawFuelCount = 0;
-
-			// Native HARVEST.SCR couples the cloak-room shotgun pickup to
-			// CLOAK_ADD -> ADD2INV SHOTGUN -> ADD_SHOTGUN_SHELLS 2, and the
-			// shell-box pickup to ADD12SHELLS -> ADD_SHOTGUN_SHELLS 14 with a
-			// native cap of 15. Pre-v18 ScummVM saves had no serialized combat
-			// resource counts, so infer the confirmed shotgun baseline from the
-			// persisted inventory objects rather than dropping legacy saves to 0.
-			if (isObjectInInventory("SHOTGUN"))
-				_shotgunShellCount = 2;
-			if (isObjectInInventory("SHOTSHELL")) {
-				_shotgunShellCount = CLIP<int>(_shotgunShellCount + 14, 0,
-					resolveCombatResourcePickupMax(3));
-			}
-			debugC(1, kDebugGeneral,
-				"Harvester: inferred legacy pre-v18 combat resources shells=%d shotgun=%d shell_box=%d",
-				_shotgunShellCount, isObjectInInventory("SHOTGUN"),
-				isObjectInInventory("SHOTSHELL"));
-		}
-		// FIXME: Drop this compatibility pass after release; it exists only to repair
-		// pre-release saves written before the monster combat field layout stabilized.
-		if (s.getVersion() >= 2) {
-			for (MonsterRecord &currentMonster : _currentMonsters) {
-				const MonsterRecord *baseMonster = findBaseMonster(currentMonster);
-				if (!baseMonster)
-					continue;
-				if (!hasLegacyMonsterCombatFieldLayout(*baseMonster, currentMonster))
-					continue;
-
-				migrateLegacyMonsterCombatFields(*baseMonster, currentMonster);
-			}
-		}
-		// FIXME: Remove the pre-v2 save fallback after release; clean Harvester saves will
-		// always serialize timer state and the full monster combat payload.
-		if (s.getVersion() < 2) {
-			_currentTimers = _timers;
-			for (MonsterRecord &currentMonster : _currentMonsters) {
-				const MonsterRecord *baseMonster = findBaseMonster(currentMonster);
-				if (!baseMonster)
-					continue;
-
-				currentMonster.initialHitPoints = baseMonster->initialHitPoints;
-				currentMonster.currentHitPoints = currentMonster.initialHitPoints;
-				currentMonster.damageAmount = baseMonster->damageAmount;
-				currentMonster.engageDistance = baseMonster->engageDistance;
-				currentMonster.damageType = baseMonster->damageType;
-				currentMonster.reservedString38 = baseMonster->reservedString38;
-				currentMonster.reservedString3c = baseMonster->reservedString3c;
-				currentMonster.reservedString44 = baseMonster->reservedString44;
-				currentMonster.reservedString48 = baseMonster->reservedString48;
-				currentMonster.attackSound1 = baseMonster->attackSound1;
-				currentMonster.attackSound2 = baseMonster->attackSound2;
-				currentMonster.attackSound3 = baseMonster->attackSound3;
-				currentMonster.hitSound1 = baseMonster->hitSound1;
-				currentMonster.hitSound2 = baseMonster->hitSound2;
-				currentMonster.hitSound3 = baseMonster->hitSound3;
-				currentMonster.footstepSoundLeft = baseMonster->footstepSoundLeft;
-				currentMonster.footstepSoundRight = baseMonster->footstepSoundRight;
-				currentMonster.deathSound = baseMonster->deathSound;
-				currentMonster.savedVisible = currentMonster.visible;
-				currentMonster.runtimeSpawned = false;
-				currentMonster.runtimeState = -1;
-				currentMonster.screenMinXBound = baseMonster->screenMinXBound;
-				currentMonster.screenMaxXBound = baseMonster->screenMaxXBound;
-				currentMonster.attackSoundTriggerFrame = baseMonster->attackSoundTriggerFrame;
-				currentMonster.hitSoundTriggerFrame = baseMonster->hitSoundTriggerFrame;
-				currentMonster.footstepSoundTriggerFrame = baseMonster->footstepSoundTriggerFrame;
-				currentMonster.deathSoundTriggerFrame = baseMonster->deathSoundTriggerFrame;
-			}
-			_playerCombatLoadout = kDefaultPlayerCombatLoadout;
-			_playerControlPaused = false;
-		}
+		_nailgunAmmoCount = clampCombatResourceCount(2, _nailgunAmmoCount);
+		_shotgunShellCount = clampCombatResourceCount(3, _shotgunShellCount);
+		_nineGunBulletCount = clampCombatResourceCount(4, _nineGunBulletCount);
+		_thirtyEightGunBulletCount = clampCombatResourceCount(5, _thirtyEightGunBulletCount);
+		_chainsawFuelCount = clampCombatResourceCount(14, _chainsawFuelCount);
 		_playerCombatLoadout = validatePlayerCombatLoadoutAgainstInventory(
 			_currentObjects, _playerCombatLoadout, "save load");
 		logRuntimeSaveState("loaded");


Commit: 07edd4e4078df6f514ab6d0657cdeda0ecae2a15
    https://github.com/scummvm/scummvm/commit/07edd4e4078df6f514ab6d0657cdeda0ecae2a15
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:09:00+02:00

Commit Message:
HARVESTER: Adjust animation clock divisor for improved timing accuracy

Changed paths:
    engines/harvester/runtime_entity.cpp


diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index 410ef4cde09..8857bfcc6b5 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -40,7 +40,14 @@ static const char *const kCursorResourcePath = "1:/GRAPHIC/POINTERS/POINTERS.ABM
 static const float kCursorEntityZ = -100.0f;
 static const int kCursorAnimationRate = 10;
 static const int kFramesPerSequence = 10;
-static const uint32 kAnimationClockDivisorMs = 10;
+// FIXME: The original runtime entity animation timer is driven from a millisecond DOS clock, 
+// but the actual animation frame update logic is more complex than a simple fixed interval. 
+// The current implementation may cause some animations to run faster or slower than intended, 
+// especially if the game is paused for a long time or if the system is under heavy load. 
+// A more accurate implementation would need to track the elapsed time more precisely and update 
+//the animation frames accordingly.
+// kAnimationClockDivisorMs should be 10, but that "feels" too fast, so bump it to 15 for now. 
+static const uint32 kAnimationClockDivisorMs = 15;
 static const byte kTransparentPaletteIndex = 0;
 
 static int roundToInt(float value) {


Commit: 8aa7e788557a5875e8c6ebf3014608bd2de48d80
    https://github.com/scummvm/scummvm/commit/8aa7e788557a5875e8c6ebf3014608bd2de48d80
Author: Alex Bevilacqua (alex at alexbevi.com)
Date: 2026-06-28T23:09:01+02:00

Commit Message:
HARVESTER: Clean up animation clock comments

Changed paths:
    engines/harvester/runtime_entity.cpp


diff --git a/engines/harvester/runtime_entity.cpp b/engines/harvester/runtime_entity.cpp
index 8857bfcc6b5..38cf3b8eb44 100644
--- a/engines/harvester/runtime_entity.cpp
+++ b/engines/harvester/runtime_entity.cpp
@@ -40,13 +40,9 @@ static const char *const kCursorResourcePath = "1:/GRAPHIC/POINTERS/POINTERS.ABM
 static const float kCursorEntityZ = -100.0f;
 static const int kCursorAnimationRate = 10;
 static const int kFramesPerSequence = 10;
-// FIXME: The original runtime entity animation timer is driven from a millisecond DOS clock, 
-// but the actual animation frame update logic is more complex than a simple fixed interval. 
-// The current implementation may cause some animations to run faster or slower than intended, 
-// especially if the game is paused for a long time or if the system is under heavy load. 
-// A more accurate implementation would need to track the elapsed time more precisely and update 
-//the animation frames accordingly.
-// kAnimationClockDivisorMs should be 10, but that "feels" too fast, so bump it to 15 for now. 
+// Animation pacing remains provisional while we compare against the original.
+// Native rate values still feed the recovered 100 / rate interval formula, but
+// this clock divisor is tuned separately from the script/timer countdown clock.
 static const uint32 kAnimationClockDivisorMs = 15;
 static const byte kTransparentPaletteIndex = 0;
 
@@ -75,7 +71,6 @@ static void scaleIndexedBitmapNearest(const IndexedBitmap &source, IndexedBitmap
 }
 
 static uint32 getAnimationClockTicks() {
-	// The original runtime entity animation timer is driven from a centisecond DOS clock.
 	return g_system ? (g_system->getMillis() / kAnimationClockDivisorMs) : 0;
 }
 
@@ -820,8 +815,9 @@ Entity *EntityManager::spawnCursorEntity(const Common::Point &position) {
 		const uint32 animationInterval = _cursorEntity->getAnimationRate() == 0 ? 0 :
 			(100U / (uint32)_cursorEntity->getAnimationRate());
 		debugC(1, kDebugCursor,
-			"Harvester: spawned cursor entity rate=%d intervalTicks=%u clock=centiseconds frame=%d..%d pos=(%d,%d)",
-			_cursorEntity->getAnimationRate(), animationInterval, _cursorEntity->getCurrentFrame(),
+			"Harvester: spawned cursor entity rate=%d intervalTicks=%u clock_divisor_ms=%u frame=%d..%d pos=(%d,%d)",
+			_cursorEntity->getAnimationRate(), animationInterval, kAnimationClockDivisorMs,
+			_cursorEntity->getCurrentFrame(),
 			_cursorEntity->getLastFrame(), position.x, position.y);
 	}
 


Commit: 9ecd0e3ebf06b9c6ae3ccad0cfbfd5dde38ee272
    https://github.com/scummvm/scummvm/commit/9ecd0e3ebf06b9c6ae3ccad0cfbfd5dde38ee272
Author: Eugene Sandulenko (sev at scummvm.org)
Date: 2026-06-28T23:13:25+02:00

Commit Message:
HARVESTER: Sort module.mk files

Changed paths:
    engines/harvester/module.mk


diff --git a/engines/harvester/module.mk b/engines/harvester/module.mk
index 33e82a01c51..0778bb3eba3 100644
--- a/engines/harvester/module.mk
+++ b/engines/harvester/module.mk
@@ -1,21 +1,35 @@
 MODULE := engines/harvester
 
-	MODULE_OBJS = \
+MODULE_OBJS = \
+	art.o \
+	cft_font.o \
+	console.o \
+	dialogue.o \
+	flow.o \
+	fst_player.o \
+	harvester.o \
+	inventory.o \
+	media_manager.o \
+	menu.o \
+	metaengine.o \
+	monster.o \
+	player.o \
+	resources.o \
+	room_combat.o \
+	room_interaction.o \
+	room.o \
+	runtime_entity.o \
+	saveload.o \
+	script.o \
+	text.o \
+	xfile_archive.o \
 	npc/authority_dialogue.o \
 	npc/beggar_dialogue.o \
 	npc/boyle_dialogue.o \
 	npc/buster_dialogue.o \
 	npc/butcher_dialogue.o \
-	cft_font.o \
-	harvester.o \
 	npc/chessmaster_dialogue.o \
 	npc/cloak_atnd_dialogue.o \
-	console.o \
-	dialogue.o \
-	fst_player.o \
-	metaengine.o \
-	media_manager.o \
-	saveload.o \
 	npc/curator_dialogue.o \
 	npc/dad_dialogue.o \
 	npc/dark_woman_dialogue.o \
@@ -31,12 +45,12 @@ MODULE := engines/harvester
 	npc/johnson_dialogue.o \
 	npc/karin_dialogue.o \
 	npc/librarian_dialogue.o \
-	npc/loomis_dialogue.o \
 	npc/lodge_chef_dialogue.o \
+	npc/loomis_dialogue.o \
+	npc/madam_dialogue.o \
+	npc/maint_man_dialogue.o \
 	npc/mcknight_dialogue.o \
 	npc/memb_dir_dialogue.o \
-	npc/maint_man_dialogue.o \
-	npc/madam_dialogue.o \
 	npc/mom_dialogue.o \
 	npc/mother_dialogue.o \
 	npc/moynahan_dialogue.o \
@@ -50,28 +64,14 @@ MODULE := engines/harvester
 	npc/pta_mom_dialogue.o \
 	npc/ryder_dialogue.o \
 	npc/sergeant_dialogue.o \
-	npc/stephanie_dialogue.o \
 	npc/sparky_dialogue.o \
+	npc/stephanie_dialogue.o \
 	npc/stub_dialogue.o \
 	npc/swell_dialogue.o \
 	npc/valet_dialogue.o \
 	npc/vet_dialogue.o \
-	npc/whaley_dialogue.o \
 	npc/wasp_woman_dialogue.o \
-	resources.o \
-	runtime_entity.o \
-	art.o \
-	flow.o \
-	inventory.o \
-	menu.o \
-	monster.o \
-	player.o \
-	room_combat.o \
-	room_interaction.o \
-	room.o \
-	script.o \
-	text.o \
-	xfile_archive.o
+	npc/whaley_dialogue.o
 
 # This module can be built as a plugin
 ifeq ($(ENABLE_HARVESTER), DYNAMIC_PLUGIN)




More information about the Scummvm-git-logs mailing list