diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index f6f6f984..df332444 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -119,7 +119,7 @@ below).
| Item | Status | Refs | Notes |
|---|---|---|---|
-| **2–4 player split-screen multiplayer** | parked | #99, D416–D423, branch `feat/mp-splitscreen-99` | The largest missing N64 feature. 2P has been live-tested (M&K + pad, respawn, 16:9 HUD). Before merge: Rule-2 review of `PROP__PORT_SIGNED` (D417) and a golden gate run on the maintainer's console. Open on the branch: D420 (stats screen shows a large number), D421 (gadget cycling off; needs a Rule-2 game-thread hook), D422 (no centred aim for P2+), D423 (black arrowhead in P2 view), and D428's sway accumulator is player-0-only. Untested: 3P/4P, pads-only mode, pause/watch/F10 during a match, MP audio, most stages, and the full N64 MP setup screens (scenarios, handicaps, teams). Needs per-pad seats (§5). An outside restore exists for reference: birdturtle `feature/local-mp-input`. |
+| **2–4 player split-screen multiplayer** | parked | #99, D416–D423, branch `feat/mp-splitscreen-99` | The largest missing N64 feature. 2P has been live-tested (M&K + pad, respawn, 16:9 HUD). Before merge: Rule-2 review of `PROP__PORT_SIGNED` (D417) and a golden gate run on the maintainer's console. Open on the branch: D422 (no centred aim for P2+), and D428's sway accumulator is player-0-only. D420 (Scores page big number) FIXED, D421 closed (moot), D423 closed (faithful level shadow), all 2026-10-01; pause/unpause, watch-menu pages, exit-confirm and the F10 overlay during a match run clean headlessly in 2P/3P/4P. Untested: pads-only mode, MP audio, most stages, and the full N64 MP setup screens (scenarios, handicaps, teams). Needs per-pad seats (§5). An outside restore exists for reference: birdturtle `feature/local-mp-input`. |
| **PAL and JP ROM support** | open | D258, D75 (pal/jpn sidecar regen), #85 | **Scheduled for Stage B (decided 2026-09-30):** done just before 1.0 so that region-specific issues can be unwound in isolation. Conversion is broken at the source-data level (filelist naming). A repair path has been verified but not applied. Needs PAL/JP ROMs to test. #85 was closed as "retired for now", not won't-do, so public users currently have no tracker. Reopen it or file a fresh one when work starts. |
| Drop-in ROM converter, remaining parts | decision | #6, [`dev/release-dropin-rom-plan.md`](dev/release-dropin-rom-plan.md) | NTSC-U drop-in shipped. Left: per-region output names and re-exec guard (with PAL/JP), native C emitters to replace the PyInstaller converter. |
| Cut content (TCRF weapons/items) | decision | [`dev/CUT-CONTENT-BACKLOG.md`](dev/CUT-CONTENT-BACKLOG.md) | Not part of the shipped game. Tier 0 already works via the debug/cheat menus. Tier 1 (config-toggle grants) is small and port-only. Tier 2/3 (placing props, re-enabling unused MP maps) need game data changes and are out of scope for this repo. |
diff --git a/docs/building.md b/docs/building.md
index e406456c..b06d9139 100644
--- a/docs/building.md
+++ b/docs/building.md
@@ -136,7 +136,7 @@ to d69's output):
```sh
python3 tools_pc/d43_emit.py ntsc-final # -> data/pcmodels-ntsc-final/{pcmodels.bin,manifest.csv} (~1.3 MB)
python3 tools_pc/d69_emit.py ntsc-final # -> data/pccg-ntsc-final/{pccg.bin,manifest.csv} (bg + stan)
-python3 tools_pc/d88_emit.py ntsc-final --regen # appends the 21 per-level Usetup*Z stage-setup files -> ~3.6 MB
+python3 tools_pc/d88_emit.py ntsc-final --regen # appends the 21 solo Usetup*Z + 13 multiplayer Ump_setup*Z stage-setup files -> ~3.6 MB
```
**PAL / JP note:** sidecar generation for these regions is currently broken at
diff --git a/docs/dev/GE-ENV-PROBES.md b/docs/dev/GE-ENV-PROBES.md
index 4fc27ddb..bb99a028 100644
--- a/docs/dev/GE-ENV-PROBES.md
+++ b/docs/dev/GE-ENV-PROBES.md
@@ -23,6 +23,8 @@ Two classes:
| `GE_INPUTSCRIPT` | `port/src/input.c:306` | Headless scripted controller-0 input: `":,…;…"` (`SUP/SDOWN/SLEFT/SRIGHT/SNONE` sustain, buttons pulse; `ZHOLD`/`ZREL` sustain fire, D207 M-201). Sole input source when set. | **live** |
| `GE_INPUTLOG` | `port/src/input.c:329`, `port/src/config.c:53` (`configGetInputLog`) | Logs computed pad state (buttons + stick) each poll when non-zero. Also `[Debug] InputLog` ini. Also traces D165 `menuptr est/tgt/eff/stick`. | **live** |
| `GE_STARTMENU` (+ `GE_STARTMENU_PAGE`, `GE_STARTMENU_DIFF`) | `src/game/lv.c:398-402` (`#ifdef PORT`) | Boot straight into a front-end menu id (13=MISSION_COMPLETE, 10=BRIEFING, 7=MISSION_SELECT, 12=MISSION_FAILED, 6=MODE_SELECT); `_PAGE`=folder row (def 1=Dam), `_DIFF`=0..3. Crash-test a screen fast. | **live** |
+| `GE_STARTMP` | `src/game/lv.c:403` (`#ifdef PORT`) | D416: `GE_STARTMP=[,]` starts a local multiplayer match with the menu's default options (as MP options -> START does); pair with `GE_MPVIRT=` for the controller count. Unattended split-screen testing. | **live** (D416) |
+| `GE_MPVIRT` | `port/src/input.c:686` | D416: report `n` extra idle controllers (1..3) so the MP menu unlocks / a 2P+ stage loads on a machine with no gamepads. Test harness only; P2..Pn get no input. | **live** (D416) |
| `GE_UNLOCK_ALL` | `src/game/debugmenu_handler.c:1098` (`get_debug_enable_all_levels_flag`, `#ifdef PORT`) | Mission-select shows every solo level (playtest jump-to-any-level aid). Cached in a `static int c`. | **live** |
| `GE_DEBUG_UNLOCKALL` | `src/game/file2.c:404` (`#ifdef PORT`) | Forces the "all levels unlocked" flag TRUE regardless of the shipped save — same effect as `GE_UNLOCK_ALL` but at the file2 unlock-query site. Cached in a `static int`. | **live** (playtest aid) |
| `GE_DEBUGMENU` | `src/boss.c:566` (`#ifdef PORT`) | Makes the L+R debug menu respond to live controller input (C-up+C-down open/close, stick + buttons read from pad 0) instead of the `#ifndef DEBUGMENU` path; unset reproduces that path exactly. Cached in a `static int`. | **live** (playtest aid) |
diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv
index d65b5682..2bbef952 100644
--- a/docs/dev/findings-index.csv
+++ b/docs/dev/findings-index.csv
@@ -286,6 +286,14 @@ D406,RESOLVED,front-end PC Options screen:,"RESOLVED (2026-09-28, port-layer onl
D407,CLOSED,Tanks cannot be boarded/exited on PC —,CLOSED (2026-09-28 bookkeeping:
D408,FIXED,Game.SkipIntro skips the post-mission failure dossier:,FIXED 2026-09-30 (SkipIntro hook gated to first boot; live dossier check owed).
D415,FIXED,"Shooting a light fixture does not ""kill"" its flickering light (Bunker, Caverns):",FIXED (2026-09-29 bookkeeping:
+D416,RESOLVED,"2-player split-screen crashes (issue #99, v0.3.0 4827bc8, fault addr 0xa43a0ed4, PC 0x14009e758):",RESOLVED in-harness (converter + D417 + D418 fix the 2P crash and split-screen flicker; 18/18 2P/3P/4P x 6-stage load runs clean; live pad play still owed).
+D417,RESOLVED,enum PROP unsigned on GCC:,"RESOLVED (src/bondconstants.h PROP__PORT_SIGNED = -1, D3/D142 precedent; Rule-2 review owed)."
+D418,RESOLVED,"Split-screen: fast3d safe-area crop stretched the last-set (half-height) viewport over the whole window, so each player's view filled the screen and the last-d…","RESOLVED (gfx_set_split_screen, port-only)."
+D419,RESOLVED,Split-screen input shared between players / P1 lost solo controls:,"RESOLVED (per-slot slotPlayer(), port-only; no current-player swaps from the poll thread; live-tested OK)."
+D420,FIXED,MP score / stats screen shows an odd big number (issue #99) —,FIXED 2026-10-01 (mp/virtual-seats); 2P/3P/4P watch-menu runs show 0s.
+D421,CLOSED,Gadget cycling (pad B / gadget key) disabled in 2+ player matches —,"CLOSED 2026-10-01: moot, no MP inventory holds a gadget (mp_weapon.c); hook reviewed + reverted."
+D422,FIXED,P2+ have no centred-aim mode in split-screen (single-slot aim state) —,FIXED in tree (commit ac92e0ab:
+D423,CLOSED,Unidentified black arrowhead in P2's split-screen view (stage 38) —,"CLOSED 2026-10-01: faithful (level shadow at a doorway top), maintainer-confirmed live."
D424,FIXED,WeaponStats.RecoilSpeed/b44[] byte order:,"FIXED in tree (WS_B44, port-guarded); user-verified vs GEPD/1964 2026-09-29."
D425,FIXED,Tank run-over death sound loops:,FIXED (2026-09-29 bookkeeping:
D426,RESOLVED,Tank cannot be steered with the mouse out of aim mode (issue #116).,RESOLVED (user 2026-09-29:
@@ -309,6 +317,10 @@ D455,FIXED,Binary-first census of exe-static addresses truncated to 32 bits (D45
D447,FIXED,Pillarbox output mode (Video.AspectMode = Original) for the Original N64 preset; crop overscan no longer cancels Wide/Cinema.,"FIXED (build + 3 captures verified; Wide/Cinema, 16:9 Ratio, mouse and 1964 comparison owed to a human)"
D445,FIXED,Crouched-rifle firing animation (crouched_rifle_firing_animation_groupA) never resolved on PC -> SIGSEGV in modelConstrainOrWrapAnimFrame when an MP player cro…,"FIXED — layout only (§D5 linker adjacency, D164 class):"
D446,FIXED,Couch 2P watch-menu text (the MP pause page) differs by +-1-2 per channel between builds whose only difference is data layout (2026-09-29).,FIXED — port-only (fast3d):
+D448,PARTIAL,Split-screen pad seats:,"PARTIAL (committed 918ac5f9 on feat/mp-splitscreen-99, build + 2P smoke OK; plug/unplug behaviour not exercised at runtime)."
D456,?,"Font-pointer / text-string-pointer slots retyped from s32 to real pointers (ABI/layout exception; front.c, options.c, language.c, bondview*, mpmenu.c, propobj.…",
D453,?,"memp allocator API made 64-bit clean (D441 step 0; ABI/layout exception, §A1).",2026-09-30
D451,FIXED,Automatic-fire gate at unsteady frame rates (D427 follow-up) and D173 residual.,"FIXED (RULE-2-SIGNOFF, maintainer 2026-09-30:"
+D463,FIXED,fast3d divided by zero (0xc0000094) importing a texture for a zero-area TextureRectangle on a tile with line = 0:,FIXED (port-layer guard in import_texture; 8 repeats of the crashing config + the 18-run sweep + solo -level_09 + menu boot all rc=0 with no crash; the guard fired in 3 of 8 repeats).
+D464,PARTIAL,"GE_MEMPREDZONE=1: 64-byte red-zone checker for the memp bump allocator (all banks), side table + checks at level unload / every 60 frames / orderly quit; re-in…","TOOL LANDED (local, feat/memp-redzone); MP sweep owed."
+D450,FIXED,"Unplugging and replugging a controller was not picked up (user, GameSir-G7 Pro; 2026-09-29).","FIXED — port-only: the render thread's gfx_sdl_handle_events drains the same SDL queue as video.c's host pump but had no case for SDL_CONTROLLERDEVICEADDED/REMOVED, so it usually swallowed them and i…"
diff --git a/docs/dev/findings.md b/docs/dev/findings.md
index 3651a262..45ed421f 100644
--- a/docs/dev/findings.md
+++ b/docs/dev/findings.md
@@ -694,6 +694,14 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D407 | **Tanks cannot be boarded/exited on PC — the v0.4.0 use/reload split (D378/D393) removed the B-button tap the engine's tank handlers in `bondview2.c` consume (user report 2026-09-28, Runway/Streets)** — full `## D407` entry at file tail | **CLOSED (2026-09-28 bookkeeping: tank board/exit accepted by the user; shipped in v0.4.0).** Earlier: PARTIAL (fix landed in v0.4.0, port-only): present `GE_CONT_B` on the use (E / pad A) rising edge only while `g_PlayerIsInTank == 1` or `g_BondCanEnterTank != 0`; E keeps its D378 no-reload-fallback semantics elsewhere, N64 layout unaffected. D407(b) same cycle: front PC Options page-edge highlight clamp + mouse-wheel / W-S paging (wheel queue consumed on the menu, D223); its wheel mapping shipped inverted and was fixed to match W/S (wheel up = step up). D407(c): board-animation lockout, menu-accept B gate in tank states, in-tank aim routed through the legacy velocity stick + `Input.TankAimScale` knob. RESOLVED 2026-09-28: all port-only (src/game zero-diff), 10 TANKDBG probes stripped, release binary verified clean, user live tank drive signed off (board + exit OK, aim feel good). |
| D408 | **`Game.SkipIntro` skips the post-mission failure dossier: with it on, entering a level then aborting (watch Z+A) or dying (KIA) returns straight to the menus — no REPORT / "Mission status: KILLED IN ACTION / ABORTED" screen** (user report 2026-09-28; save/AllUnlocked ruled out) — full `## D408` entry at file tail | FIXED 2026-09-30 (SkipIntro hook gated to first boot; live dossier check owed). |
| D415 | **Shooting a light fixture does not "kill" its flickering light (Bunker, Caverns): the fixture takes the hit but the flicker keeps going (community report, issue #87 comment by the reporter, 2026-09-28, 00 Agent playthrough of v0.4.0).** — full `## D415` entry at file tail | FIXED (2026-09-29 bookkeeping: root-caused and fixed as D430 + D431, user-verified vs GEPD/1964 on Caverns 2026-09-29; #119). Earlier: ROOT-CAUSED as D430 (see there); fix in tree, live check owed. |
+| D416 | **2-player split-screen crashes (issue #99, v0.3.0 `4827bc8`, fault addr 0xa43a0ed4, PC 0x14009e758): MP setup files `Ump_setup*Z` were never converted N64→PC** — full `## D416` entry at file tail | RESOLVED in-harness (converter + D417 + D418 fix the 2P crash and split-screen flicker; 18/18 2P/3P/4P x 6-stage load runs clean; live pad play still owed). Also D417, D418.
+| D417 | **`enum PROP` unsigned on GCC: `prop >= 0` passed for `getPropForHeldItem()`'s -1 (fist) -> `modelLoad(-1)` null deref in any 2P+ stage (player.c `sub_GAME_7F09B398`)** — full `## D417` entry at file tail | RESOLVED (src/bondconstants.h `PROP__PORT_SIGNED = -1`, D3/D142 precedent; **Rule-2 review owed**). |
+| D418 | **Split-screen: fast3d safe-area crop stretched the last-set (half-height) viewport over the whole window, so each player's view filled the screen and the last-drawn (shuffled) player won -> alternating full-screen flicker** — full `## D418` entry at file tail | RESOLVED (`gfx_set_split_screen`, port-only). |
+| D419 | **Split-screen input shared between players / P1 lost solo controls: poll thread used g_CurrentPlayer for per-slot aim/stance/style/use** — full `## D419` entry at file tail | RESOLVED (per-slot `slotPlayer()`, port-only; no current-player swaps from the poll thread; live-tested OK). |
+| D420 | **MP score / stats screen shows an odd big number (issue #99)** — `char rankbuffer[4]` overrun ("Rank: 1st") clobbered the adjacent `scores[]` (D8 class); widened under `#ifdef PORT` — full `## D420` entry at file tail | FIXED 2026-10-01 (`mp/virtual-seats`); 2P/3P/4P watch-menu runs show 0s. |
+| D421 | **Gadget cycling (pad B / gadget key) disabled in 2+ player matches** — full `## D421` entry at file tail | CLOSED 2026-10-01: moot, no MP inventory holds a gadget (mp_weapon.c); hook reviewed + reverted. |
+| D422 | **P2+ have no centred-aim mode in split-screen (single-slot aim state)** — full `## D422` entry at file tail | FIXED in tree (commit `ac92e0ab`: per-slot aim state `s_aimSt[4]`, `aimModeGet()` returns the configured mode for every slot); live split-screen verification of centred aim on slot 1+ still owed (needs a non-slot-0 aim device). |
+| D423 | **Unidentified black arrowhead in P2's split-screen view (stage 38)** — full `## D423` entry at file tail | CLOSED 2026-10-01: faithful (level shadow at a doorway top), maintainer-confirmed live. |
| D424 | **`WeaponStats.RecoilSpeed`/`b44[]` byte order: no grenade-launcher recoil, shortened recoil on every gun (issue #118).** — full `## D424` entry at file tail | FIXED in tree (`WS_B44`, port-guarded); user-verified vs GEPD/1964 2026-09-29. |
| D425 | **Tank run-over death sound loops: `(chrflags << 7) >= 0` is always true on unsigned GCC enum (issue #115).** — full `## D425` entry at file tail | FIXED (2026-09-29 bookkeeping: use-site `(s32)` cast; user-verified vs GEPD/1964 2026-09-29, #115). Earlier: FIXED in tree (use-site `(s32)` cast); live check owed. |
| D426 | **Tank cannot be steered with the mouse out of aim mode (issue #116).** — full `## D426` entry at file tail | RESOLVED (user 2026-09-29: tank controls now match GEPD/1964; see D429). |
@@ -717,9 +725,15 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D447 | **Pillarbox output mode (`Video.AspectMode` = Original) for the Original N64 preset; crop overscan no longer cancels Wide/Cinema.** — full `## D447` entry at file tail | FIXED (build + 3 captures verified; Wide/Cinema, 16:9 Ratio, mouse and 1964 comparison owed to a human) |
| D445 | **Crouched-rifle firing animation (`crouched_rifle_firing_animation_groupA`) never resolved on PC -> SIGSEGV in modelConstrainOrWrapAnimFrame when an MP player crouch-fires a rifle (2026-09-28).** — full `## D445` entry at file tail | FIXED — layout only (§D5 linker adjacency, D164 class): `crouched_rifle_firing_animation_group1` has no zero terminator and on N64 falls through into `...groupA` (source-order .data); GCC places groupA first, so groupA[0] kept the raw anim offset 0xB84. `#ifdef PORT` one array {right, left, terminator} + `groupA` alias (chr.c/chr.h); N64 resolution restored. |
| D446 | **Couch 2P watch-menu text (the MP pause page) differs by +-1-2 per channel between builds whose only difference is data layout (2026-09-29).** — full `## D446` entry at file tail | FIXED — port-only (fast3d): `GL_TEXTURE_MAX_ANISOTROPY` is per-texture-object state that `gfx_opengl_set_sampler_parameters` set only when mipmapped and never reset; a GL texture id freed by `gfx_texture_cache_delete_range` (unordered_map order = texture address order) and reused for a non-mipmapped glyph kept 4x. Now always written (level or 1). Render-only; sim never affected. |
+| D448 | **Split-screen pad seats: `pads[]` indexed by seat and keyed by SDL instance id; seats compact outside a match and stay reserved during one (an unplug no longer shifts other players or flips keyboard/mouse-as-P1 routing; a rescan no longer reopens attached pads).** — full `## D448` entry at file tail | PARTIAL (committed 918ac5f9 on `feat/mp-splitscreen-99`, build + 2P smoke OK; plug/unplug behaviour not exercised at runtime). |
| D456 | **Font-pointer / text-string-pointer slots retyped from `s32` to real pointers (ABI/layout exception; front.c, options.c, language.c, bondview*, mpmenu.c, propobj.c:3909 alias replaced by named field).** `frontPrintText`/`display_aligned_white_text_to_screen` font params, `copy_1st/2ndfonttable`, `setFontTables`, ~26 options.c locals, `g_LangBanks[]` + `langGet` result, `player.ptr_text_*_mp_award`. front.c 186->2, options.c 139->0 int-conversion warnings. FIXED (static + 1 launch; not gameplay-verified). |
| D453 | **memp allocator API made 64-bit clean (D441 step 0; ABI/layout exception, §A1).** `mempCheckMemflagTokens` took the arena start as `s32`, `mempSetBankStarts` carried it in an `s32 mempStart`, and `mempAllocPackedBytesInBank` returned a pointer as `u32`; all truncate/sign-extend an arena at or above 0x80000000. Widened to `uintptr_t`/`void*` under `#ifdef PORT` (N64 arm unchanged). mema.c NOT changed (structural, see entry). | 2026-09-30 | fix/abi-memp-widen | [H] for memp, mema deferred |
| D451 | **Automatic-fire gate at unsteady frame rates (D427 follow-up) and D173 residual.** — full `## D451` entry at file tail | FIXED (**RULE-2-SIGNOFF**, maintainer 2026-09-30: covered by the D427 grant; tick-crossing gate, `gunfire.c` `d427Gate`); headless A/B at FpsCap 45 measured; maintainer hold-fire test at 60/30 fps showed no perceptible difference (expected: identical at steady rates). D173 not the same class (see entry). |
+| D463 | **fast3d divided by zero (0xc0000094) importing a texture for a zero-area TextureRectangle on a tile with `line = 0`: the outlined-text path drew a zeroed font glyph (`w=h=0`, `pixeldata=NULL`), found by the scripted 3P/4P stability sweep (crash rate roughly 2 in 14 on the Library stage; section D).** Full `## D463` entry at file tail | FIXED (port-layer guard in `import_texture`; 8 repeats of the crashing config + the 18-run sweep + solo `-level_09` + menu boot all rc=0 with no crash; the guard fired in 3 of 8 repeats). |
+| D464 | **`GE_MEMPREDZONE=1`: 64-byte red-zone checker for the `memp` bump allocator (all banks), side table + checks at level unload / every 60 frames / orderly quit; re-introducing the D462 `0x14` stride is detected (falsifier PASS); 21 solo levels + menu clean (0 hits); MP matrix not run (GE_STARTMP lives only on feat/mp-splitscreen-99).** Full `## D464` entry at file tail | TOOL LANDED (local, `feat/memp-redzone`); MP sweep owed. |
+| D407 | **Tanks cannot be boarded/exited on PC — the v0.4.0 use/reload split (D378/D393) removed the B-button tap the engine's tank handlers in `bondview2.c` consume (user report 2026-09-28, Runway/Streets)** — full `## D407` entry at file tail | PARTIAL (fix landed in v0.4.0, port-only): present `GE_CONT_B` on the use (E / pad A) rising edge only while `g_PlayerIsInTank == 1` or `g_BondCanEnterTank != 0`; E keeps its D378 no-reload-fallback semantics elsewhere, N64 layout unaffected. D407(b) same cycle: front PC Options page-edge highlight clamp + mouse-wheel / W-S paging (wheel queue consumed on the menu, D223); its wheel mapping shipped inverted and was fixed to match W/S (wheel up = step up). D407(c): board-animation lockout, menu-accept B gate in tank states, in-tank aim routed through the legacy velocity stick + `Input.TankAimScale` knob. RESOLVED 2026-09-28: all port-only (src/game zero-diff), 10 TANKDBG probes stripped, release binary verified clean, user live tank drive signed off (board + exit OK, aim feel good). |
+| D408 | **`Game.SkipIntro` skips the post-mission failure dossier: with it on, entering a level then aborting (watch Z+A) or dying (KIA) returns straight to the menus — no REPORT / "Mission status: KILLED IN ACTION / ABORTED" screen** (user report 2026-09-28; save/AllUnlocked ruled out) — full `## D408` entry at file tail | OPEN (cosmetic, not root-caused past the handoff site). v0.4.0 decision: SkipIntro stays EXPERIMENTAL / not recommended for regular users — F10 row relabelled "Skip intro (EXPERIMENTAL)". |
+| D450 | **Unplugging and replugging a controller was not picked up (user, GameSir-G7 Pro; 2026-09-29).** — full `## D450` entry at file tail | FIXED — port-only: the render thread's `gfx_sdl_handle_events` drains the same SDL queue as video.c's host pump but had no case for `SDL_CONTROLLERDEVICEADDED/REMOVED`, so it usually swallowed them and `inputRescanPads` never ran. Both pumps now post a request; the rescan runs at the top of `inputUpdate`, on the thread that reads the pads. |
Phase 2 replaced the Phase-1 demo loop with the real `mainproc()` on real OS
@@ -15632,6 +15646,89 @@ flickering fixture), then repro on PC.
**Current status (2026-09-29 bookkeeping):** the Status line above is historical; the authoritative verdict is the §F row — FIXED (2026-09-29 bookkeeping: root-caused and fixed as D430 + D431, user-verified vs GEPD/1964 on Caverns 2026-09-29; #119). Earlier: ROOT-CAUSED as D430 (see there); fix in tree, live check owed.
+## D416 — Local split-screen multiplayer crash: MP setup files never converted (issue #99)
+
+**Report:** issue #99, 2P split screen, v0.3.0 `4827bc8`, fault addr `0xa43a0ed4`, crash PC `0x14009e758`.
+
+**Symbolised (2026-09-29, `dist/goldeneye-pc-port-0.3.0-win64` exe, addr2line):** `load_proptype` (prop.c) called from `proplvreset2` (prop.c) — the propDefs record walk over `g_CurrentSetup`, i.e. the setup-file load, not menu code.
+
+**Root cause:** `prop.c` loads `"U" + "mp_" + ` (`Ump_setup*Z`) whenever `getPlayerCount() >= 2`. `tools_pc/d88_emit.py` (and `d88_propdefs.py`, `d88_propdef_scan.py`, `d125_check.py`) only matched `Usetup*Z`, so the 13 MP setups in `file_resource_table.inc.c` came out of pccg as raw big-endian N64 data and the walk read garbage. (Not the D190 class: D190 is closed as `-level_45` being MP-only with no solo setup.)
+
+**Fix (tools only, no game code):** single predicate `is_setup_name()` = (`Usetup`|`Ump_setup`) + `Z`, used for the name list, the `--regen` row-drop, and the analysis tools. `convert_usetup()`'s full-tiling assertion is the gate that MP files share the solo format.
+
+**Verified:** `d88_emit.py ntsc-final --regen` → `converted: 34/34`, ALL CHECKS PASSED; `d125_check.py` MATCH on all 13 MP + 21 solo files; pccg manifest 73→86 rows with all 73 pre-existing rows byte-identical. PAL/JP not run (NTSC ROM only).
+
+**Follow-up (2026-09-29):** in-game 2P now runs (see D417, D418). Also added: `Input.MPMode` (Auto/PadsOnly/KbmP1) + slot->pad shift so one pad or kbm+pad can be P1/P2 and the game's `joyGetControllerCount() >= 2` MP gate opens; `GE_MPVIRT` / `GE_STARTMP` harness flags (this machine has 0 SDL gamepads). Input layer is still single-player shaped (see memory: mp-input-layer-direction). Separately, PC input hooks are pad-0-only and `input.c` re-asserts control type on the current player (can rewrite another player's control style in MP) — port-layer work planned for v0.5.0.
+
+**Status:** RESOLVED in-harness (see header row).
+
+
+## D417 — `enum PROP` unsigned: fist held-prop lookup (-1) passes `>= 0` and crashes 2P+ (issue #99)
+
+**Crash (gdb, 2026-09-29):** `modelLoad(modelid=-1)` <- `something_with_generating_object(propid=-1, itemid=ITEM_FIST)` <- `sub_GAME_7F09B398` (player.c) <- `gunTickHandState` <- `bondviewProcessInput`. Fault: `PitemZ_entries[-1].header->RootNode`, header NULL.
+
+**Root cause:** `getPropForHeldItem()` returns -1 for items with no held model (fist, taser). `sub_GAME_7F09B398` guards with `if (prop >= 0)`; `PROP` is an all-non-negative enum, so GCC gives it an unsigned type and -1 passes. Solo never runs this path (only other players' visible weapons need it). Same class as porting-notes D3 / D142.
+
+**Fix:** `PROP__PORT_SIGNED = -1` sentinel under `#ifdef PORT` at the head of `enum PROP` (`src/bondconstants.h`), following the `SP_LEVEL__PORT_SIGNED` precedent. `PROP_ALARM1` stays 0, sizeof unchanged, no stored value changes. Also corrects `drop_inventory` (`propid >= 0`). **This is a `src/game`-header edit: Rule-2 review owed.**
+
+**Status:** RESOLVED.
+
+## D418 — Split-screen alternating full-screen views: safe-area crop remapped sub-rect viewports (issue #99)
+
+**Symptom (user, live):** in 2P the view flickers between both players constantly. GE_MPDUMP (temp) frame capture: each frame is ONE player's view filling the whole window, alternating in shuffled draw order (`get_nth_player_from_shuffled`) — no split visible.
+
+**Root cause:** `gfx_adjust_viewport_or_scissor` (fast3d) maps the last-set SP viewport's Y range onto the whole window when `Video.SafeAreaCrop` is on (default; designed for the solo 220-line viewport). In 2P each player's viewport is 109 lines, so each was stretched to full height and the last drawn covered the other.
+
+**Fix (port only):** `gfx_set_split_screen()` turns the crop (Y and X margin) off while a 2+ player stage runs; `videoSyncSplitScreen()` (video.c) sets it from `getPlayerCount()` + stage != title, called before every `gfx_run` — **including the scheduler's direct call in `libultra.c`**, which bypasses `videoSubmitCommands` (my first attempt hooked only that and had no effect: worth remembering, see porting-notes). Verified by frame capture: two stable stacked views over 4 consecutive frames, original N64 letterbox, viewports y=119/230 h=109; solo golden gate 3/3.
+
+**Status:** RESOLVED (harness); live pad play + HUD/widescreen (plan 2c, D334/D335 in MP) still owed.
+
+**2026-09-30 note (merge of release/v0.4.1, D447 interaction):** D447 gated the crop cache so only viewports >= 90% of the canvas height define the crop band (Wide/Cinema and split halves keep their own letterbox) and added an output rect (Original aspect = centred 4:3/16:9 rect, `gfx_current_game_window_viewport` = that rect). The two compose without overlap: in a 2+ player stage D418's `g_split_screen` still bypasses the crop entirely (`safeTop/safeHeight = SCREEN_HEIGHT`, no X margin), so the N64 split layout maps 1:1 onto the canvas; D447's seed/gating only touches the cached band, which is unused while split. The canvas itself is then placed in the window or, in Original mode, inside the pillarboxed output rect by `gfx_start_frame`. Merge conflict was textual only (adjacent globals in gfx_pc.cpp).
+
+## D419 — Split-screen input shared between players / P1 lost solo controls (issue #99)
+
+**Symptom (user, live):** in a 2P match one player's input drove both views, and P1 keyboard/mouse or pad did not behave like solo (no mouse look, no SOLITARE layout, no crouch/use/reload).
+
+**Root cause:** the slot plumbing (`contSnapshotFromKeyboard` polls 0..3, player N reads controller N) was already per-slot, but `inputComputePad` ran on the scheduler thread and read/wrote `g_CurrentPlayer` — whichever player the game thread last selected — for aim, stance, style and use/reload. The D416 stopgap disabled those paths in a match, which removed them from P1 too.
+
+**Fix (port only, `port/src/input.c`):** `slotPlayer()` resolves the player that owns the slot being polled (`s_slot`, set by a thin `inputComputePad` wrapper); all player-struct access goes through it. In a 2+ player stage the poll thread NEVER calls a game entry point that acts on `g_CurrentPlayer`, and never re-points the current-player globals: control style is written straight onto the slot's player (mirror of `cur_player_set_control_type`), the crouch weapon check reads `p->hands[GUNRIGHT].weaponnum`, use/reload go out as the native B button, gadget cycling is solo-only. Crouch state is per slot; slots 1+ get pad A/X = B, stick click = crouch; slots 1+ use the N64 aim integrator (centred aim state is single-slot); `portMouseAimPdGetTurn` applies to player 0 only; unused slots return neutral.
+
+**Lesson (first attempt hung the game):** the first version wrapped those calls in a save/swap/restore of `g_CurrentPlayer`/`player_num`/`g_playerPerm`. The poll thread is a real thread, not the game thread, and the style check ran the swap every poll. When the game thread did its own `set_cur_player` inside the window, the restore left it on the wrong player and `bondinvCycleForward` (bondinv.c:523) looped forever (circular list walked against another player's list head; kernel heartbeat dump showed mainThread spinning there after a respawn). Never swap the current-player globals from the poll thread. See porting-notes.
+
+**Status:** RESOLVED (user live-tested M&K P1 + pad P2 and the kill/respawn sequence, 2026-09-29). Open: P2+ centred aim; gadget cycling in matches; MP score screen shows an odd big number (not investigated).
+
+## D420 — MP score / stats screen shows an odd big number (issue #99)
+
+**FIXED 2026-10-01 (`mp/virtual-seats`): a D8-class stack overrun, not a scoring bug.** Reproduced headlessly (2P, stage 38, `GE_STARTMP=2 GE_MPVIRT=1 GE_INPUTSCRIPT="400:START"`, frame 450): the watch menu's Scores page showed `1932599354` / `116` at 0 kills. A temp probe in `display_text_for_playerdata_on_MP_menu` showed `get_points_for_mp_player(0/1)` = 0 / 0 while the passed `points` were the garbage values. `1932599354` = 0x7331203A = bytes `": 1s"` and `116` = `'t'`: the tail of **"Rank: 1st"**. `mp_watch_menu_display` (mpmenu.c) declares `char rankbuffer[4]` (decomp size) but `write_playerrank_to_buffer` writes "Rank: 1st" (10 bytes incl. NUL) into it, and `sprintf` writes "P KILLS"/"P LOSSES"; GCC places `scores[4]` right after it, so the rank text overwrote `scores[0..1]` after they were filled. On N64 the IDO frame layout absorbs the overrun (the menu shows 0). **Fix:** `rankbuffer[64]` under `#ifdef PORT` (D8 policy: pure local stack scratch, zero ABI role, same as D295's `difficultytext`); the N64 build keeps `[4]`. Not a Rule-2 change (removes a PC-only clobber). **Verified:** the same 2P frame shows 0 / 0; 2P/3P (Library)/4P watch-menu runs with pause, unpause, exit-cancel and exit-confirm show 0s in every layout, all rc=0, `GE_MEMPREDZONE` 0 hits. Not checked: the post-match awards screen (the `GE_STARTMP` harness relaunches a match instead of returning to the menus), and the maintainer's original live report (assumed to be this page). The `u16 *text` sprintf slot in `display_text_for_playerdata_on_MP_menu` (8 bytes on PC) still overruns for a score of 8+ chars (only possible far beyond any point limit); left as is.
+
+**Symptom (user, live, 2026-09-29):** in a 2P match the player score / position display shows "a big number"; at match start both players show 1st place. Still present after a kill + respawn.
+
+**Read so far (code only, no capture):** `write_playerrank_to_buffer` / `get_points_for_mp_player` (mpmenu.c ~903/826) rank by kills-minus-suicides with ties sharing the better rank, so 0-0 at start = both 1st is N64-faithful. A big number could be the stats screen's `shortest_inning` (init `S32_MAX` = 2147483647 in lv.c:548, only lowered on a death), but it persisted after a kill/respawn, so that is NOT confirmed. Nothing 64-bit-suspect found in the rank/points path (`kill_counts` is a plain `int[4]`); `display_text_for_playerdata_on_MP_menu` does `sprintf(&text, "%d", …)` into a `u16 *` local (8 bytes on x64, 4 on N64) and `char rankbuffer[4]` holds "1st" exactly — both worth a second look if a capture shows garbage.
+
+**Next:** get the exact screen (pause/watch menu vs end-of-match) via `GE_PCDUMP` or a screenshot; identify which field prints the number; check against the 1964/N64 reference before deciding whether it is a port bug.
+
+**2026-09-30 code-only pass (no fix, hypothesis unconfirmed):** the in-match number is drawn by `display_text_for_playerdata_on_MP_menu` (mpmenu.c:760) from `get_points_for_mp_player` (mpmenu.c:826) = kill_counts[] (s32, player.h:19) minus suicides + `killed_gg_owner_count*(n-2)`; all are zeroed per player in initBondDATA.c:267-269, so no uninitialised read found and the struct fields are plain s32/int (no 32->64 widening). Candidates not ruled out: (1) mpmenu.c:771 `u16 *text; sprintf(&text, "%d", points)` uses a pointer-sized local as a char buffer (4 bytes on N64, 8 on PC; a value with >7 chars overruns it and the neighbouring locals) -- any large/negative score would print big and could corrupt adjacent stack; (2) `s32 q/h2 = (s32)langGet(...)` then `(char*)q` (mpmenu.c:1408-1410, 1478-1480) truncates string pointers on 64-bit (works only while strings sit below 4 GB; the label prints correctly, so probably benign here). Next step: dump `scores[]` at mpmenu.c:1240 with GE_PCDUMP at the moment the number shows.
+
+## D421 — Gadget cycling disabled in 2+ player matches
+
+**CLOSED 2026-10-01: moot, no fix needed.** The pad-B cycle only selects items `ITEM_BOMBCASE..ITEM_WATCHMAGNETATTRACT` (mission gadgets). No MP weapon set (`src/game/mp_weapon.c`; every `INLINE_S_MP_WEAPON_SET` entry tallied) contains one: MP 'devices' are the timed/proximity/remote mines, which sit below `ITEM_BOMBCASE` and already cycle natively. So a match inventory never holds a gadget, and B would select nothing even with a working hook. A design review (Sonnet subagent, read-only, delegated by the maintainer) had approved a game-thread drain hook (`inputGadgetDrain()` inside `if (canCycleWeapons)` in `MoveBond`'s input step, bondview2.c ~6325, per-seat `SDL_atomic_t` one-shot, gated on alive/watch/lock/stop/game-over). It was implemented and built, then reverted unmerged once the empty gadget range was found. The early return in `inputCycleGadget()` stays. If a mode ever puts gadgets into an MP inventory, that design is the one to use. Side note from the review: solo's `inputCycleGadget()` also runs on the poll thread and mutates `hands[]` while the game thread may read it. No hang is possible in solo (`g_CurrentPlayer` never changes), but it is a latent data race; low priority.
+
+`inputCycleGadget()` (port/src/input.c) acts on `g_CurrentPlayer` via game entry points with no player argument; calling it from the poll thread raced the game tick (D419 hang), so it now returns immediately in a match. Solo unchanged. Restoring it needs a game-thread hook (src/game edit, Rule-2 sign-off) or a native-button equivalent. Native N64 weapon cycling (C-buttons / pad Y) still works.
+
+## D422 — No centred aim for P2+ in split-screen
+
+**Update (2026-09-30, verified by code reading):** fixed by `ac92e0ab` (an ancestor of `mp/virtual-seats`): `s_aimSt[4]` with the `AIMS` slot accessor, a stateless `padDirectCompute` on `slotPlayer()`, and `aimModeGet()` no longer forces `AIMMODE_N64` for slots > 0. Slot 0 stays byte-for-byte (`AIMS` falls back to slot 0). This entry was left saying 'parked'. Untested live on slots 1+.
+
+Centred aim (`aimModeGet()==AIMMODE_CENTRED`, `padDirectCompute`, `s_gepdCross*`) keeps single-slot static state, so `aimModeGet()` returns `AIMMODE_N64` for slots > 0 (port/src/input.c). P1 keeps its full aim set. Making the state per slot is mechanical but touches the aim accumulators; parked as low priority.
+
+## D423 — Unidentified black arrowhead in P2's split-screen view
+
+**CLOSED 2026-10-01: faithful, not a bug.** The maintainer played a live 4P Temple match (mouse/keyboard P1 + Xbox pad P2, split-screen build) and confirmed the black triangle is the level's own shadow/shading at the top of a doorway. The session exited cleanly (window closed, rc=0, no crash log).
+
+**2026-10-01:** seen again headlessly in ONE frame of a 4P run on stage 38 (`GE_STARTMP=4 GE_MPVIRT=3`, scripted seats): P4's quadrant at frame 920, a flat, untextured near-black (RGB ~10-13) right-pointing triangle with sharp edges at a wall/ceiling edge; it looks in-world, not HUD. (An earlier note here also claimed P2 at frame 380; that was a thumbnail misread, and the full-res crop shows nothing.) So it is real rendered pixels and not 2P-only, but it has not been shown to be a port bug: it could be legitimate geometry, e.g. part of another player's model or a level object, until checked against 1964 or traced.
+
+Seen in a `GE_PCDUMP` frame (2P, stage 38, frame 500) as a black right-pointing triangle at the left of the lower (P2) view, and again in live play (user: "not sure what it is"). Not identified: could be an in-world object/marker or a HUD/radar element drawn with a bad texture. Next: capture several frames, `GE_DTEX`/DL dump around that screen position, check whether it also appears in P1's view or in solo MP-stage loads.
+
## D424 — `WeaponStats.RecoilSpeed`/`b44[]` byte order: no recoil animation on the grenade launcher, shortened recoil on every gun (issue #118)
**Root cause:** `b44[4]` aliases the four bytes of the big-endian `RecoilSpeed` word (`gun.h`). Byte 0 is the MSB on N64 but the LSB on little-endian PC, so `gunfire.c` read rise/fall/retrigger/offset reversed. Grenade launcher `0x081000FF` (8,16,0,-1) read as (-1,0,16,8): `field_890 >= sp1A4+sp1A0` was true at once, so the recoil phase never ran. KF7/Klobb 4+12 -> 6+0 ticks, PP7 4+8 -> 0+3.
@@ -15683,6 +15780,8 @@ After D426, non-aim mouse turning in the tank is good. Still open: (1) right-mou
## D428 second pass (2026-09-29)
+**Split-screen update (2026-09-30):** the sway accumulator is now one per player (`s_swayThetaMdeg[4]`, `ac92e0ab`). The game-side drain in `bondview2.c` is still gated to player 0 (`get_cur_playernum() == 0`), which is correct today: `swayFeedTheta` is only called from the slot-0 mouse block, so slots > 0 are never fed. Widening the gate is only needed if a non-zero slot ever gets a mouse (a `src/game` edit inside the D428 Rule-2 block; ask for sign-off then).
+
Probe (`GE_D428`) showed the hook firing with the input clamped at 0.7 on most fast turns. The game's natural stick turn sets `speedtheta = (stick/70)^2 * fov/60` with NO ramp (max ~1.0) and integrates `vv_theta += speedtheta*dt*3.5`, so the exact inverse for a mouse turn is `deg / (3.5*dt)` clamped to `fov/60`. The first pass used the vertical axis' `delta/(2*dt)` clamped to 0.7: too weak at full speed, too strong at slow speed. Switched; user reports a slight difference turning fast, which this addresses.
## D433 — Automatic-gun shot sound still audibly differs slightly from GEPD/1964 (user by-ear, Caverns, 2026-09-29)
@@ -15925,6 +16024,13 @@ About 55-922 pixels differ, a few glyphs only, each by +-1-2 (mostly green). Eve
The general fix would reset the whole object on reuse, or sort the ids that `delete_range` frees. Neither is needed for this symptom. **Confidence: [H]** for the cause and the fix (A/B reproduced, fixed, bisected to the one GL parameter).
+## D448 — Split-screen pad seats keyed by SDL instance id (issue #99, D416 lineage, 2026-09-30)
+
+**Status: PARTIAL.** Committed as `918ac5f9` on `feat/mp-splitscreen-99` (the commit message calls it "DNEW-mp-1"; this is its label). It was recovered from a crashed session's uncommitted worktree (archive `2026-09-30-recovery/mp-uncommitted-input-bg.patch`). Its TEMP-VPADSEAT probe was removed before commit.
+
+**What:** `port/src/input.c` `pads[]` is indexed by *seat*, not by SDL device index, and each seat remembers its SDL joystick instance id. Outside a match, seats compact (the first attached pad is seat 0, and so on). During a match they stay reserved, so unplugging one pad no longer shifts the other players' seats or flips the Auto → keyboard/mouse-as-P1 routing. A rescan keeps pads that are still attached open instead of closing and reopening them. Port-only.
+
+**Verified:** build, plus a 2P smoke (`GE_STARTMP=2 GE_MPVIRT=1`, 1280x720) in Window and Original aspect modes after merging `release/v0.4.1`. **Owed (maintainer, live):** plug/unplug pads mid-match (the seats must not shift), and check the keyboard/mouse-as-P1 routing is unchanged after an unplug. Related: outside PR #122 (hot-plug reopen, dolent) overlaps this area. Review it against this design before merging it.
## D454 — knife slash crash: keyframe pointer held in `u32` (ABI/layout exception, 2026-09-30)
@@ -16005,6 +16111,23 @@ The general fix would reset the whole object on reuse, or sort the ids that `del
**By-eye test (maintainer):** AK47/Uzi held fire with `FpsCap` 60, 45 (or any unsteady load) and 30; empties the magazine in about the same wall time as 1964/GEPD at every setting; no stutter-bursts.
**Probes left in tree:** none (env-gated `GE_D427`/`GE_D427_OLD` probes stripped).
+## D450 — a replugged controller is never reopened: the render thread's event pump swallows the hot-plug events (2026-09-29)
+
+**Status:** FIXED, port-only. User report (GameSir-G7 Pro): "disconnecting the controller, then reconnecting it did not seem to be picked up". The play log had only the startup `input: rescanned pads` line.
+
+**Root cause.** Two threads drain the one SDL event queue:
+- `port/src/video.c` `videoPumpEvents`: `case SDL_CONTROLLERDEVICEADDED / REMOVED: inputRescanPads();`.
+- `port/fast3d/gfx_sdl2.cpp` `gfx_sdl_handle_events` (called from `gfx_run` on the render / scheduler thread every frame): **no case for them**. Whatever it dequeues is dropped.
+
+Whichever loop runs `SDL_PollEvent` first gets the event, and the render loop usually wins (the same race WI-1 documented for ESC). So the pads were never rescanned and `pads[]` kept the closed controller's handle. Headless repro with a virtual pad (attach, detach, re-attach): one rescan at startup, none at the detach, and at the re-attach the process aborted with `malloc(): unaligned tcache chunk detected` (stale handle). A second latent problem: `inputRescanPads` ran on the host thread while the scheduler thread reads `pads[]` in `inputComputePad`; closing pads under a reader is a race.
+
+**Fix.**
+- `gfx_sdl2.cpp`: new `case SDL_CONTROLLERDEVICEADDED / REMOVED: inputRequestRescan();`.
+- `video.c`: the case now calls `inputRequestRescan()` instead of `inputRescanPads()`.
+- `input.c`: `inputRequestRescan()` sets an atomic; `inputUpdate()` consumes it first thing and runs `inputRescanPads()` there, i.e. on the thread that is about to read the pads (the scheduler thread, or the game thread under tick input).
+
+**Verified** (on the netplay line, headless, virtual pads through a test seam): attach -> detach -> attach -> detach -> attach with no tick input: 5 rescans (startup + one per event), no crash, against 1 rescan then a heap-corruption abort before the fix; with tick input the pad's buttons are seen before, after the first and after the second re-attach; solo `-level_09` frame 1800 byte-identical to the base. Real-pad retest by the user (GameSir-G7 Pro, 2026-09-29): passed.
+
## D461 — `p_itemcur` allocated with the N64 stride; overran into the first prop model file (ABI/layout exception, 2026-09-30)
@@ -16028,3 +16151,34 @@ The general fix would reset the whole object on reuse, or sort the ids that `del
**Found by.** The local-Qwen read-only audit of every `mempAlloc*` size expression (top suspects #1 and #2); the lead verified the literal at `vtxstore.c`, the C-stride indexing, and the D457(d) diff.
**Verified.** Build clean; solo `-level_09` to frame 420 and a 4-player `GE_STARTMP=4 GE_MPVIRT=3` run to frame 500 both exit rc=0 with no crash log. **Confidence: [H]** on the overrun, [M] on any visible effect (none expected). Remaining audit suspects: `size_item_buffer` weapon model buffers (`initBondDATA.c`), `model.c` rwdata word count vs converted data, `image_bank.c` +0x1000 slack: all UNKNOWN, need a converted-size check.
+
+**Residual (pre-existing):** `inputRumble` can run on the game thread while a rescan runs on the scheduler thread in non-tick mode: one poll of exposure instead of an arbitrary host-thread moment.
+
+
+## D463 — zero-line tile texrect divided by zero in the texture importers (fast3d, 2026-10-01)
+
+**Symptom.** The scripted split-screen sweep (`GE_STARTMP`, `GE_MPVIRT`, per-slot `GE_INPUTSCRIPT_P1..P3`) crashed once in 18 runs (3P, stage_id 48, Library) with `EXCEPTION 0xc0000094` = integer divide by zero (the sweep report's 'stack-buffer-overrun/fastfail' label was wrong; fastfail is 0xc0000409). A repro with `GE_D116=1` gave 2 crashes in 14 runs (3P and 4P, stage index 4), so it is not player-count specific.
+
+**Cause.** Crash PC = `import_texture_i8` (`gfx_pc.cpp`, `height = size_bytes / rdp.texture_tile[tile].line_size_bytes`) with `line_size_bytes == 0`, reached via `gfx_dp_texture_rectangle` -> `gfx_draw_rectangle` -> `gfx_sp_tri1` -> `import_texture`. In both crashes the log shows, immediately before the fault, `[D116] O-glyph ... idx=0 w=0 loadw=0 h=0 pd=0000000000000000` (a zeroed font record drawn by `textRenderGlyphOutlined`, `src/game/textrelated.c`) followed by a zero-area `[D116/f3d]` texrect on a tile with `line_bytes=0`. `gDPLoadTextureBlock` with width 0 expands to `gsDPTile(line=0)` plus a zero-area `gSPTextureRectangle`; the N64 RDP rasterises nothing for it, so the decomp's output is faithful and fast3d must tolerate it. All the importers share the unguarded division (rgba16, ia8, i8, ci8, ...). **Not established:** which text produces the zeroed glyph (the outlined-text path reads `chars[*text - 0x21]`, so a character below 0x21 or an unpopulated font slot is the suspect). Rare because it depends on the string being drawn.
+
+**Fix (port-layer, `port/fast3d/gfx_pc.cpp`, commit `81b57504`).** At the top of `import_texture`, if the tile's `line_size_bytes == 0`: log one capped line (`D463: texrect on zero-line tile -- import skipped`, at most 8 per run) and return without importing (no cache entry, no upload). The downstream triangle path already treats a zero line size as 1, and the degenerate draw rasterises no fragments. Nonzero line sizes take the original path unchanged. No game-logic change.
+
+**Verified (local Qwen driver, lead re-read the logs).** After the fix: 8 repeats of the crashing 3P/stage-4 configuration, the full 18-run 2P/3P/4P matrix, solo `-level_09` and a menu boot all exit rc=0 with no crash log; the guard fired in 3 of the 8 repeats (so the condition recurs and is now harmless). 3P renders three viewports with an empty fourth quadrant (as on the N64) and 4P a clean 2x2. **Confidence: [H]** for the crash and the guard; [M] on the upstream glyph cause.
+
+**Generalisable.** Any importer that divides by a tile field must tolerate 0; the game emits zero-sized loads legitimately. `0xc0000094` is a divide by zero, `0xc0000005` an access violation, `0xc0000409` a fastfail.
+
+
+
+## D464 — GE_MEMPREDZONE: red-zone checker for the bump allocator (diagnostic tool, 2026-10-01)
+
+**Falsifier (written before any run).** Re-introduce the D462 bug in a throwaway, uncommitted edit (`tmp = 0x14;` in `src/game/vtxstore.c` instead of `sizeof(struct unk_09B7A0_struct_parent)`), build, run `GE_MEMPREDZONE=1 GE_QUITFRAME=300 ./data/ge007.x86_64.exe -level_09`. Expected: at least one `MEMPREDZONE: HIT` whose `caller=` resolves (addr2line) to `vtxstore.c`'s allocation of the two parent tables, with `size` = count*0x14. If no hit names vtxstore, the checker is not trustworthy.
+
+**What was built.** `src/memp.c` under `#ifdef PORT`, gated by `GE_ENVFLAG("GE_MEMPREDZONE")` (cached; one branch per allocation when unset, the original allocator body is kept verbatim as `mempAllocBytesInBankRaw`). When set, every allocation with room is padded by a 64-byte trailer (pattern `0xA5 + 13*i`), recorded in an 8192-entry side table (block, size, trailer, id, caller = `__builtin_return_address(0)` of `mempAllocBytesInBank` / `mempAllocPackedBytesInBank`, bank). Alignment is unchanged (pos advances 64 more). `mempRedzoneCheck(why)` logs one `[ERROR] MEMPREDZONE: HIT id= bank= size= block= rz_first_off= changed=n/64 caller=` per damaged entry and one `[INFO]` summary per check. Check points: `mempResetBank`, `mempNullNextEntryInBank` (level unload; the bank's entries are dropped afterwards), `videoEndFrame` every 60th frame, and the orderly-quit path (`videoHostExitIfRequested`, render parked). Resolve a caller with `addr2line -f -C -i -e build-pc/ge007.x86_64.exe 0x` (PE image base 0x140000000 is already in the logged address). Covers all banks (the bank is derived from the block address). No `src/game` edit.
+
+**Bump-pointer users audited.** `mempAddEntryOfSizeToBank` (callers: `fileIndexLoadToBank`, `fileSetSize` in ob.c) shrinks the *last* allocation, and `fileIndexLoadToBank` allocates the whole remaining bank (`mempGetBankSizeLeft`) then shrinks. A trailer cannot fit on the whole-bank allocation, so that block is left unpadded (`untracked(no room)` in the summary) and gains its trailer when the shrink leaves room; `mempAddEntryOfSizeToBank` re-places the trailer for tracked blocks (`newsize + 64` accounting). Nothing else computes sizes from pointer differences or reads `prevpos` (grep: only memp.c). `bg.c:612` only logs pool pointers. No bank had to be excluded.
+
+**Falsifier result: PASS.** With `tmp = 0x14` temporarily restored in `vtxstore.c` (uncommitted; reverted, `git diff -- src` empty afterwards), `GE_MEMPREDZONE=1 GE_QUITFRAME=300 -level_09` reported `HIT id=21 bank=4 size=400 ... caller=0x1400d7c85` and `HIT id=23 size=800 ... caller=0x1400d7cbc`; addr2line resolved both to `sub_GAME_7F09B820`, `src/game/vtxstore.c:117` and `:119` (the two table allocations). So the checker catches the D462 class on a normal solo load. (Hit trailer damage was only 6/64 bytes: the overrun writes -1/0 into a few fields, so a pattern-equal byte can hide, but any real overrun beyond the first stride row hits.)
+
+**Sweep (build with the D462 fix restored, flag set, 37 launches).** All 21 solo levels (`-level_XX`, 420 frames; names/numbers from `kSoloLevels`): rc=0, no `ge007.crash.log`, **0 red-zone hits**, tracked blocks 126 (Cuba) to 788 (Jungle), 19..59 untracked-no-room. Menu boot (900 frames): rc=0, 0 hits. **The MP matrix did not exercise multiplayer:** `GE_STARTMP`/`GE_MPVIRT` exist only on `feat/mp-splitscreen-99` (`src/game/lv.c`), not on `release/v0.4.1`, so those 15 runs were menu boots (identical 84-tracked-block logs). The split-screen branch is too far behind (pre-D453, conflicts in memp.h) to cherry-pick onto; the launch cap (40) was already nearly spent. **Owed:** rebuild the checker onto the split-screen branch and run the 2P/3P/4P matrix there (the D461 victim was an MP-only `equipmaxitems`-sized path, and MP stage allocations differ).
+
+**Verdict.** On the solo levels and the front end, no remaining allocation overrun is visible at the 64-byte granularity, including after D461/D462. Not covered: overruns landing in a block's *interior* neighbour that never reaches the trailer (stride error smaller than the trailer is caught; one far beyond 64 bytes skips the trailer and corrupts the next block's data, but still damages the next block's own trailer only if it reaches it), writes into the unpadded whole-bank allocations, blocks beyond the 8192-entry table (none reached it), under-runs (no leading zone). **Confidence: [H]** that the tool detects the D462 class; [M] that solo levels are clean (420 frames only, load-time allocations; runtime allocations after frame 420 not covered).
diff --git a/docs/porting-notes.md b/docs/porting-notes.md
index 165ee69a..5d22b428 100644
--- a/docs/porting-notes.md
+++ b/docs/porting-notes.md
@@ -1016,7 +1016,10 @@ for (s = SP_LEVEL_EGYPT; s >= SP_LEVEL_DAM /* == 0 */; s--) // never ends
- Symptom: a silent hang (kernel-heartbeat stall, no crash log) inside a
loop over an enum range; the counter holds a huge value in gdb.
-- Instances: **D142**; `LEVEL_SOLO_SEQUENCE` in
+- Instances: **D417** (`enum PROP`: `getPropForHeldItem()` returns -1 for the
+ fist and callers test `prop >= 0` -> unsigned enum lets -1 through ->
+ `modelLoad(-1)` crash in any 2P+ stage; sentinel `PROP__PORT_SIGNED`);
+ **D142**; `LEVEL_SOLO_SEQUENCE` in
`fileGetHighestStageDifficultyCompletedForFolder` froze the SELECT FILE
screen. `DIFFICULTY` was already safe (`DIFFICULTY_MULTI = -1`).
- Fix: add a never-used negative sentinel enumerator under `#ifdef PORT`
@@ -1204,6 +1207,11 @@ and turns every such overrun into a fatal `*** stack smashing detected ***`
the identical overflow is cosmetic (the JPN-cache pointer is valid memory in
any language); the crash severity is a PC artifact of that global being NULL
outside Japanese mode.
+- Instance (D420): `mpmenu.c` `mp_watch_menu_display` `char rankbuffer[4]`
+ receives "Rank: 1st" / "P KILLS"; GCC puts `scores[4]` next to it, so
+ the MP Scores page printed the rank text's bytes as numbers (1932599354 =
+ ": 1s"). Tell: a displayed number whose hex is printable ASCII from a
+ neighbouring string. Widened to `[64]` under `#ifdef PORT`.
- Fix policy: **`-fno-stack-protector` globally** (`CMakeLists.txt`) so Linux
matches the N64 and MinGW builds; the code is byte-matched to the ROM and
cannot be "fixed" per site without diverging. Where a case is cleanly
@@ -1350,6 +1358,27 @@ darkening from a nonzero `ENV_ALPHA` LERP factor is very likely faithful
(possibly authored/original-dev-intentional tinting), not a bug — do not
"fix" it without an N64/1964-GEPD reference screenshot proving otherwise.
+## D16. Split-screen: port-side "one full-screen viewport" assumptions break 2P+ (D418)
+
+Two traps from the #99 split-screen bring-up:
+
+- **Safe-area crop (`gfx_adjust_viewport_or_scissor`) caches the LAST SP
+ viewport's Y range and stretches it over the whole window.** Right for the
+ solo 220-line viewport, wrong for 2P+ (each player's viewport is a sub-rect):
+ every player's view filled the window and the last-drawn (order is shuffled
+ per frame, `get_nth_player_from_shuffled`) covered the rest -> alternating
+ full-screen flicker. Fix = `gfx_set_split_screen()`. Any new port feature that
+ assumes "the viewport is the screen" needs the same 2P+ gate.
+- **Per-frame port hooks must sit on the path frames really take.** The
+ scheduler calls `gfx_run` directly (`port/src/libultra.c`), NOT through
+ `videoSubmitCommands`; a hook placed only in the latter silently never ran.
+ Log once on the first state change when adding such a hook.
+
+Debug recipe that found it: capture consecutive frames after the stage starts
+(GE_PCDUMP range, or the harness `GE_STARTMP=[,]` +
+`GE_MPVIRT=` to start a match with no one at the menu) and look at
+them — the symptom was obvious in one montage.
+
## D17. Un-stubbing a `-1` sentinel re-activates every dormant consumer of the value (D431/D434)
A port stub that forces a value to `-1` (D154 `texnum`, D135) can hide latent layout bugs in its other readers. When restoring a real value, grep ALL readers of the field first and check each for raw-layout reads and table-bounds assumptions. Instance: D431 made light-fixture hits return real image ids, which reached `chrprop.c`/`propobj.c` reading `((u8*)&g_Textures[n])[0] & 0xf` as a hit type -- on PC byte 0 is the low byte of `dataoffset` (LE bitfield layout, D67), so it indexed `g_HitTypeSounds[13]` with 0..15. Use the `.hitTexture`/`.hitSound` bitfields (as `chr.c` does).
@@ -1368,6 +1397,11 @@ A port stub that forces a value to `-1` (D154 `texnum`, D135) can hide latent la
`[Input] MouseEnabled = 0` in the pinned test ini. Script "frames" were
about one per rendered frame on the file select screen; calibrate with a
log, not by assumption.
+- **Per-slot headless scripts: `GE_INPUTSCRIPT_P1..P3` drive seats 2-4.**
+ Same entry syntax as `GE_INPUTSCRIPT` (slot 0); "frame" counts that
+ controller's reads. Mouse tokens and CHOLD/CREL are ignored on slots 1-3
+ (one warning per script). Recipe for a scripted 4P split-screen run:
+ `GE_STARTMP=4, GE_MPVIRT=3 GE_INPUTSCRIPT_P1=... GE_INPUTSCRIPT_P2=... GE_INPUTSCRIPT_P3=... GE_QUITFRAME=...`.
- **`textMeasure` height is 0 without a trailing newline (D343).** It only
counts completed lines. Measure `"text\n"` when you need a height (front.c's
folder text does this); the width is unaffected.
@@ -1770,4 +1804,10 @@ A port stub that forces a value to `-1` (D154 `texnum`, D135) can hide latent la
(different Windows dirs). Never trust `command -v cmake` for the MSYS2
toolchain either: a pip-installed cmake can shadow it (2026-09-30).
+### Poll-thread input must never re-point `g_CurrentPlayer` (D419)
+
+`inputComputePad` runs on the scheduler thread (`joyPoll`), a real pthread, not the game thread. Any save/swap/restore of `g_CurrentPlayer`/`player_num`/`g_playerPerm` from there races the game tick's own `set_cur_player`: the restore can leave the game thread on the wrong player and it then spins in list walks that compare against `g_CurrentPlayer->...` (seen: `bondinvCycleForward` infinite loop after a split-screen respawn). Resolve the owning player explicitly (`g_playerPointers[slot]`) and touch its fields directly; for game functions that take no player argument, present a native button bit instead of calling them.
+
**A1 cross-tag (D455, 2026-09-30):** an exe-static address (array/global/function in `.data`/`.rodata`/`.text`) held in a `u32`/`s32` truncates on Windows (image base `0x140000000`; Linux 0x20000000 hides it). Binary tell: `mov r64,[rip+.refptr.X]` ... `mov r32,r32` before the pointer is used or passed. The address load may precede a `jmp` join, so an adjacent-instruction scan misses it; follow jumps. A census of the whole exe found only the D454 site; `_*Segment*` linker symbols are ROM constants (`.set` in `romassets_u.s`), not exe addresses, and are benign in `(u32)` casts.
+
+**`GE_MEMPREDZONE=1` (D464): catch allocator overruns deterministically.** Set it before any run to pad every `mempAlloc*` block with a 64-byte pattern trailer and check at level unload, every 60 frames and on orderly quit. A hit prints `MEMPREDZONE: HIT ... caller=0x`; resolve with `addr2line -f -C -i -e build-pc/ge007.x86_64.exe 0x` to the allocation site, then compare its size expression with `sizeof` of how the buffer is indexed (an N64 literal stride vs a widened struct is the D461/D462 class). Zero hits is only "no overrun reached the trailer"; whole-bank allocations are unpadded until shrunk. Unset = one cached branch per allocation.
diff --git a/port/fast3d/gfx_api.h b/port/fast3d/gfx_api.h
index 2725000a..c677baa3 100644
--- a/port/fast3d/gfx_api.h
+++ b/port/fast3d/gfx_api.h
@@ -50,6 +50,7 @@ void gfx_set_fix_mip_textures(int on);
void gfx_set_detail_base_tile(int on); /* D236: sample the base image of a DETAIL binding */
void gfx_set_wrap_fix(int on);
void gfx_set_anisotropy_level(int level); /* 1 = off; clamped to GL max */
+void gfx_set_split_screen(int on); /* D416: 2+ player stage running -> viewports are sub-rects; safe-area crop must not remap them */
void gfx_set_safe_area_crop(int on); /* crop the N64 TV-overscan safe-area margin instead of showing it as black bars */
/* On-window pixel rect (top-left origin) the full VI logical canvas (0,0)-
* (SCREEN_WIDTH, SCREEN_HEIGHT) currently maps to, honoring the safe-area
diff --git a/port/fast3d/gfx_pc.cpp b/port/fast3d/gfx_pc.cpp
index c3762e6b..e8ec9203 100644
--- a/port/fast3d/gfx_pc.cpp
+++ b/port/fast3d/gfx_pc.cpp
@@ -262,6 +262,17 @@ extern "C" void gfx_set_safe_area_crop(int on) {
g_safe_area_crop_enabled = !!on;
}
+/* D416: the crop above stretches the LAST set viewport's Y range over the
+ * whole window. Correct for one full-screen viewport; in split-screen every
+ * player's (half/quarter) viewport got stretched to fill the window, so the
+ * last-drawn player covered the others (and the shuffled draw order made the
+ * winner alternate frame to frame). While a 2+ player stage is running the
+ * N64's own split layout is used unmodified. */
+static bool g_split_screen = false;
+extern "C" void gfx_set_split_screen(int on) {
+ g_split_screen = !!on;
+}
+
/* D447: output rect (Video.AspectMode = Original). 0 = fill the window (the
* historical behaviour, bit-identical). >0 = letter/pillarbox the frame to
* exactly this aspect, centred; set once per frame by the port layer before
@@ -1037,6 +1048,28 @@ static void import_texture(int i, int tile, bool importReplacement) {
const uint32_t tex_flags = loaded_texture.tex_flags;
const uint8_t palette_index = rdp.texture_tile[tile].palette;
+ /* D463: a SETTILE with line=0 makes every importer divide by zero
+ * (height = size_bytes / line_size_bytes -- 0xc0000094 integer-divide on
+ * Windows x64; the crash PC was import_texture_i8). The game emits this
+ * legitimately: textrelated.c's outlined-text path renders ANY byte
+ * < 0x21 through chars[*text - 0x21], a zeroed fontchar (w=h=0,
+ * pixeldata=NULL), and gDPLoadTextureBlock(width=0) then expands to
+ * gsDPTile(line=0) + a zero-area gSPTextureRectangle. The N64 RDP draws
+ * nothing for a zero-area rect, so the decomp is correct -- fast3d must
+ * tolerate it. Skip the import entirely (no cache entry, no upload);
+ * the degenerate draw rasterizes no fragments, so whatever stays bound
+ * on this unit is never sampled. Nonzero line sizes take the exact
+ * original path below. */
+ if (rdp.texture_tile[tile].line_size_bytes == 0) {
+ static int d463_warned = 0;
+ if (d463_warned < 8)
+ sysLogPrintf(LOG_NOTE, "D463: texrect on zero-line tile -- import skipped "
+ "(tile=%d fmt=%u siz=%u tmem=%u)",
+ tile, fmt, siz, rdp.texture_tile[tile].tmem);
+ d463_warned++;
+ return;
+ }
+
// D74: only fall back when the tmem slot was never written by a load
// command. The old `rdp.tex_lod && tile >= first+detail` branch also
// overwrote valid gDPLoadBlock data with line*tile.height, which (a) dropped
@@ -2831,7 +2864,7 @@ static void gfx_adjust_viewport_or_scissor(XYWidthHeight* area, bool preserve_as
// g_gpSafeTop already plays the exact role SCREEN_HEIGHT plays below
// (both are the bottom-up Y value of the mapped region's TOP edge) --
// this reduces to the untouched original formula when crop is off.
- const bool crop = g_safe_area_crop_enabled && g_gpSafeHeight > 0.0f;
+ const bool crop = g_safe_area_crop_enabled && !g_split_screen && g_gpSafeHeight > 0.0f;
const float safeTop = crop ? g_gpSafeTop : (float)SCREEN_HEIGHT;
const float safeHeight = crop ? g_gpSafeHeight : (float)SCREEN_HEIGHT;
const float ratioY = gfx_current_dimensions.height / safeHeight;
@@ -2849,8 +2882,8 @@ static void gfx_adjust_viewport_or_scissor(XYWidthHeight* area, bool preserve_as
// TV-overscan margin) and fold it into the same toggle: trim the same
// fixed 1-unit margin from both edges rather than trying to force
// content to reach a boundary it may never actually be drawn to.
- const float safeLeft = g_safe_area_crop_enabled ? 1.0f : 0.0f;
- const float safeWidth = g_safe_area_crop_enabled ? (float)SCREEN_WIDTH - 2.0f : (float)SCREEN_WIDTH;
+ const float safeLeft = (g_safe_area_crop_enabled && !g_split_screen) ? 1.0f : 0.0f;
+ const float safeWidth = (g_safe_area_crop_enabled && !g_split_screen) ? (float)SCREEN_WIDTH - 2.0f : (float)SCREEN_WIDTH;
const float ratioX = gfx_current_dimensions.width / safeWidth;
float x1 = (area->x - safeLeft) * ratioX;
diff --git a/port/fast3d/gfx_sdl2.cpp b/port/fast3d/gfx_sdl2.cpp
index e32611e5..347d9023 100644
--- a/port/fast3d/gfx_sdl2.cpp
+++ b/port/fast3d/gfx_sdl2.cpp
@@ -10,6 +10,7 @@
#include "input.h"
extern "C" void videoRequestQuit(const char *why); // port/src/video.c (D344)
+extern "C" void inputRequestRescan(void); // port/src/input.c (D450)
static SDL_Window* wnd;
static SDL_GLContext ctx;
@@ -339,6 +340,10 @@ static void gfx_sdl_handle_events(void) {
case SDL_MOUSEWHEEL:
inputPostWheel(event.wheel.y); // weapon cycle
break;
+ case SDL_CONTROLLERDEVICEADDED: // D450: this pump and video.c's drain the same
+ case SDL_CONTROLLERDEVICEREMOVED: // SDL queue; request a rescan, never rescan here
+ inputRequestRescan();
+ break;
case SDL_WINDOWEVENT:
if (event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
SDL_GL_GetDrawableSize(wnd, &window_width, &window_height);
diff --git a/port/include/input.h b/port/include/input.h
index 8d454224..57379343 100644
--- a/port/include/input.h
+++ b/port/include/input.h
@@ -87,6 +87,7 @@ int inputMenuPointerLive(void);
/* Re-enumerate gamepads after a hotplug (SDL_CONTROLLERDEVICEADDED/REMOVED). */
void inputRescanPads(void);
+void inputRequestRescan(void); /* D450: from either SDL event pump; consumed by inputUpdate() */
/* D401: Rumble Pak -> real gamepad haptics. The osMotor* shims in
* libultra.c (game's src/joy.c rumble state machine) route here.
diff --git a/port/src/input.c b/port/src/input.c
index d1fdd0f6..39ba0d80 100644
--- a/port/src/input.c
+++ b/port/src/input.c
@@ -57,7 +57,7 @@
*
* "aim button held" is read from our own RMB/LShift state -- exact for
* the default hold-to-aim scheme; a toggle-aim scheme would need a read
- * of g_CurrentPlayer->insightaimmode (still no logic change).
+ * of slotPlayer()->insightaimmode (still no logic change).
*
* GE's native pitch is inverted ("flight" style: C-up -> look down). We
* hide that: mouse-down looks down by default, MouseInvertY flips it.
@@ -88,6 +88,7 @@
* Game header pulled in through the same shim path every other compiled game
* file uses; we only READ vv_theta/vv_verta/speedtheta/speedverta/aspect. */
#include "player.h"
+#include "language.h" /* j_text_trigger (control-type mirror) */
#include "gun.h" /* native weapon flags + dedicated reload entry points */
#include "bondinv.h" /* inventory list for the Xbox-style gadget cycle */
@@ -292,8 +293,99 @@ static int numControllers = 1;
static int connectedMask = 0x1; /* controller 0 always present */
static SDL_GameController *pads[MAX_PADS];
+
+/* Local multiplayer controller assignment (issue #99 / D416).
+ * Input.MPMode: 0 Auto, 1 PadsOnly, 2 KbmP1.
+ * - Auto: 2+ pads -> pads are P1..PN and keyboard/mouse is ignored in a
+ * match; exactly 1 pad -> keyboard/mouse is P1 and the pad is P2.
+ * - PadsOnly: pads are P1..PN always (1 pad = MP stays locked, as stock).
+ * - KbmP1: keyboard/mouse is P1, pads are P2.. even with 2+ pads.
+ * The shift applies only while a 2+ player stage is running; front-end menus
+ * keep the pad on controller 0, so nothing changes for solo play or menus.
+ * The reported controller mask still counts the shifted slots so the game's
+ * own "needs 2 controllers" gates open. */
+static int mpInputMode = 0;
+static int nRealPads = 0;
+extern s32 getPlayerCount(void);
+
+static int inputMpSplit(void)
+{
+ return current_menu == GE_MENU_RUN_STAGE && getPlayerCount() >= 2;
+}
+
+/* DNEW-mp-1 (issue #99): pad SEATS. pads[] is indexed by seat, not by SDL
+ * device index. A seat is taken in connection order (boot: SDL device order)
+ * and keyed by the pad's SDL instance id, so a rescan keeps every still-attached
+ * pad in its seat. Outside a split-screen match the seats are compacted (P1..PN
+ * dense, which the game's joyGetControllerCount needs: it counts contiguous
+ * connected bits). During a match a vacated seat stays reserved and its mask bit
+ * stays set, so an unplug neither shifts the other players' pads nor tells the
+ * game a controller vanished, and a re-plugged pad takes the first free seat,
+ * i.e. back into its old one. Compaction runs lazily once the match is over.
+ * Seat -> controller slot: slot = seat, or seat + 1 when keyboard/mouse is P1.
+ * The routing decision (keyboard/mouse P1 or not) counts the match's sticky
+ * seats for the same reason: an Auto-mode unplug from 2 pads to 1 must not flip
+ * the keyboard onto P1 and shift every pad by one slot mid-match. */
+static SDL_JoystickID padInst[MAX_PADS] = { -1, -1, -1, -1 };
+static int s_matchSeatMask = 0; /* seats occupied at any point this match */
+static int s_seatCompactOwed = 0; /* a seat was vacated in a match */
+
+enum { MPR_KBMP1 = 1, MPR_PADSP1 = 2 };
+static int mpRoute(void)
+{
+ /* In a match, count the seats used at any point this match (the sticky
+ * mask only grows), so an unplug never changes the routing. */
+ int n = nRealPads;
+ if (inputMpSplit()) {
+ n = 0;
+ for (int s = 0; s < MAX_PADS; ++s) n += (s_matchSeatMask >> s) & 1;
+ }
+ if ((mpInputMode == 2 && n >= 1) || (mpInputMode == 0 && n == 1))
+ return MPR_KBMP1;
+ return n >= 2 ? MPR_PADSP1 : 0;
+}
+static int inputMpKbmP1(void)
+{
+ return (mpRoute() & MPR_KBMP1) != 0;
+}
+/* Pads own P1 (2+ pads, not KbmP1): the keyboard/mouse block is skipped. */
+static int inputMpPadsOwnP1(void)
+{
+ return inputMpSplit() && (mpRoute() & MPR_PADSP1) != 0;
+}
+static SDL_GameController *slotPad(int idx)
+{
+ if (idx < 0 || idx >= MAX_PADS) return NULL;
+ if (inputMpSplit() && inputMpKbmP1()) return idx == 0 ? NULL : pads[idx - 1];
+ return pads[idx];
+}
+
+/* D416 per-slot input: the controller poll runs on the scheduler thread, where
+ * g_CurrentPlayer is whichever player the game thread last selected -- wrong
+ * for a split-screen slot. While a slot is being polled (s_slot >= 0) in a
+ * 2+ player stage, every player-struct access in this file goes through
+ * slotPlayer(), which resolves the player that owns the slot (player N reads
+ * controller N, as on the N64). Outside a poll, or in solo, it is exactly
+ * g_CurrentPlayer. */
+static int s_slot = -1;
+static struct player *slotPlayer(void)
+{
+ if (s_slot >= 0 && s_slot < 4 && inputMpSplit() && g_playerPointers[s_slot])
+ return g_playerPointers[s_slot];
+ return g_CurrentPlayer;
+}
+
+/* In a 2+ player stage this file never calls a game entry point that acts on
+ * g_CurrentPlayer (bond_interact_object, reload, gadget cycle, control-type
+ * setter): the poll thread is not the game thread, so re-pointing the
+ * current-player globals from here races the game tick (first attempt did,
+ * and hung the game in bondinvCycleForward after a respawn). Match play
+ * routes use/reload through the native B button instead, and reads/writes
+ * the slot player's fields directly. */
static int padBPrev[MAX_PADS]; /* B/Y edges; track through menus too */
static int padYPrev[MAX_PADS];
+static int padAPrev[MAX_PADS]; /* D416: slots 1+ use/reload edges */
+static int padXPrev[MAX_PADS];
static int padSelectPrev = 0; /* Select (BACK) edge: overlay toggle */
/* D401: per-pad haptics (N64 Rumble Pak -> SDL_GameControllerRumble).
@@ -463,6 +555,8 @@ static int aimModeGet(void)
}
aimStyleLegacy = 0;
pdMouseAim = 0;
+ /* D422: every slot gets the configured mode (the aim state is per slot;
+ * padDirectCompute is stateless and acts on slotPlayer()). */
return aimMode;
}
@@ -483,6 +577,20 @@ static int aimRange = 0;
static double aimRangeScale(void) { return aimRange ? 0.65 : 1.0; }
static double aimEdgeThreshold(void) { return aimRange ? 0.75 : (double)GEPD_EDGE_THRESHOLD; }
+/* D422: FOV of the player that owns the polled slot. In a split-screen
+ * stage the poll thread's viGetFovY() is whichever player the game thread
+ * last set up (bondview2 viSetFovY(g_CurrentPlayer->zoominfovy)), so read the
+ * slot player's own zoominfovy there. Solo: exactly viGetFovY(), as before. */
+static f32 slotFovY(void)
+{
+ if (inputMpSplit() && s_slot >= 0) {
+ struct player *p = slotPlayer();
+ if (p != NULL && p->zoominfovy > 0.0f)
+ return p->zoominfovy;
+ }
+ return viGetFovY();
+}
+
static int pdMouseAimEnabled(void)
{
const char *e = GE_ENVSTR("GE_PDMOUSEAIM");
@@ -497,11 +605,22 @@ static int pdMouseAimEnabled(void)
* this poll's stick quantization. */
/* D194 GEPD-mirror aim state: the crosshair position accumulator in game
* units (±GEPD_CROSSHAIR_LIMIT at the screen edge). Written straight into
- * g_CurrentPlayer->crosshair_x/y_pos + gun_azimuth_angle/turning each tick;
+ * slotPlayer()->crosshair_x/y_pos + gun_azimuth_angle/turning each tick;
* bondview2's damped crosshair update keeps running and is simply
* overwritten each tick (GEPD model, D194). */
-static double s_gepdCrossX = 0.0, s_gepdCrossY = 0.0;
-static int s_gepdHeldPrev = 0; /* aim held last tick -> adopt on entry */
+/* D422: the aim accumulators are per controller slot (seat), so any slot can
+ * own a centred / GEPD aim state (online-ready input layer). Only the
+ * keyboard/mouse block touches them today, and it runs for slot 0 only, so
+ * solo reads/writes exactly slot 0 as before. AIMS = the slot being polled;
+ * off the poll (s_slot == -1, game thread) it is slot 0, as before. */
+struct aimSlotState {
+ double crossX, crossY; /* was s_gepdCrossX/Y */
+ int gepdHeldPrev; /* aim held last tick -> adopt on entry */
+ int aimHeldPrev; /* was s_aimHeldPrev */
+ int centreClearTicks; /* was s_centreClearTicks */
+};
+static struct aimSlotState s_aimSt[4];
+#define AIMS (s_aimSt[(s_slot > 0 && s_slot < 4) ? s_slot : 0])
static int aimGepdAccumulate(double dxPx, double dyLook);
static void aimGepdEdgeScroll(void);
static int aimGepdCompute(double dxPx, double dyLook);
@@ -519,8 +638,7 @@ static int padDirectCompute(int dx, int dy); /* D404: pad twin of the above */
* (manual input clears docentreupdown) disarms it; aiming is then free and
* holds when the mouse stops. No game logic touched -- this is just what
* stick we choose to present. */
-static int s_aimHeldPrev = 0;
-static int s_centreClearTicks = 0;
+/* (s_aimHeldPrev / s_centreClearTicks live in AIMS since D422.) */
/* D194: always 0 since the free-cursor experiment was reverted (aim uses
* grabbed relative deltas). Kept because reconcileGrab(),
* applyCursorVisibility() and the mouse-button mask still branch on it --
@@ -606,43 +724,120 @@ static int s_menuPointerLive = 0;
/* ------------------------------------------------------------------------ */
+static void padDetectRumble(int i)
+{
+ padRumbleOn[i] = 0;
+ /* D401: haptics detection (PD pattern, pd_port input.c:320-324).
+ * SDL_GameControllerHasRumble() landed in SDL 2.0.18 even though
+ * SDL_GameControllerRumble() is from 2.0.9. */
+#if SDL_VERSION_ATLEAST(2, 0, 18)
+ padRumbleOn[i] = SDL_GameControllerHasRumble(pads[i]);
+#else
+ padRumbleOn[i] = SDL_JoystickIsHaptic(SDL_GameControllerGetJoystick(pads[i]));
+ if (!padRumbleOn[i]) {
+ /* At least on Windows some controllers report no haptics but
+ * rumble will still function: assume it's supported for a
+ * known controller type. */
+ const SDL_GameControllerType ctype = SDL_GameControllerGetType(pads[i]);
+ padRumbleOn[i] = ctype && (ctype != SDL_CONTROLLER_TYPE_VIRTUAL);
+ }
+#endif
+}
+
+static int padSeatOf(SDL_JoystickID inst)
+{
+ for (int s = 0; s < MAX_PADS; ++s) {
+ if (pads[s] && padInst[s] == inst) return s;
+ }
+ return -1;
+}
+
+/* Seat table maintenance (DNEW-mp-1, see padInst above). Called at boot and on
+ * every rescan: drops detached pads, compacts outside a match, opens any new
+ * controller into the first free seat, then rebuilds the controller mask. */
static void inputOpenPads(void)
{
- connectedMask = 0x1;
+ const int inMatch = inputMpSplit();
+
+ /* 1. drop pads that are gone (the handle of a detached pad is dead) */
+ for (int s = 0; s < MAX_PADS; ++s) {
+ if (pads[s] && !SDL_GameControllerGetAttached(pads[s])) {
+ sysLogPrintf(LOG_NOTE, "input: seat %d pad detached%s", s,
+ inMatch ? " (seat kept for the match)" : "");
+ SDL_GameControllerClose(pads[s]);
+ pads[s] = NULL;
+ padInst[s] = -1;
+ padRumbleOn[s] = 0;
+ if (inMatch) s_seatCompactOwed = 1;
+ }
+ }
+
+ /* 2. outside a match, seats are dense in their existing order */
+ if (!inMatch) {
+ int d = 0;
+ for (int s = 0; s < MAX_PADS; ++s) {
+ if (!pads[s]) continue;
+ if (s != d) {
+ pads[d] = pads[s]; pads[s] = NULL;
+ padInst[d] = padInst[s]; padInst[s] = -1;
+ padRumbleOn[d] = padRumbleOn[s]; padRumbleOn[s] = 0;
+ sysLogPrintf(LOG_NOTE, "input: pad '%s' moved seat %d -> %d",
+ SDL_GameControllerName(pads[d]), s, d);
+ }
+ d++;
+ }
+ s_seatCompactOwed = 0;
+ }
+
+ /* 3. open new controllers into the first free seat */
int n = SDL_NumJoysticks();
- for (int i = 0; i < n && i < MAX_PADS; ++i) {
+ for (int i = 0; i < n; ++i) {
if (!SDL_IsGameController(i)) {
continue;
}
- if (pads[i]) {
- continue;
+ if (padSeatOf(SDL_JoystickGetDeviceInstanceID(i)) >= 0) {
+ continue; /* already seated */
}
- padRumbleOn[i] = 0; /* D401: (re)detected below when the pad opens */
- pads[i] = SDL_GameControllerOpen(i);
- if (pads[i]) {
- connectedMask |= (1 << i);
- /* D401: haptics detection (PD pattern, pd_port input.c:320-324).
- * SDL_GameControllerHasRumble() landed in SDL 2.0.18 even though
- * SDL_GameControllerRumble() is from 2.0.9. */
-#if SDL_VERSION_ATLEAST(2, 0, 18)
- padRumbleOn[i] = SDL_GameControllerHasRumble(pads[i]);
-#else
- padRumbleOn[i] = SDL_JoystickIsHaptic(SDL_GameControllerGetJoystick(pads[i]));
- if (!padRumbleOn[i]) {
- /* At least on Windows some controllers report no haptics but
- * rumble will still function: assume it's supported for a
- * known controller type. */
- const SDL_GameControllerType ctype = SDL_GameControllerGetType(pads[i]);
- padRumbleOn[i] = ctype && (ctype != SDL_CONTROLLER_TYPE_VIRTUAL);
- }
-#endif
- sysLogPrintf(LOG_NOTE, "input: opened gamepad %d '%s' as controller %d (rumble %s)",
- i, SDL_GameControllerName(pads[i]), i, padRumbleOn[i] ? "yes" : "no");
+ int seat = -1;
+ for (int s = 0; s < MAX_PADS; ++s) {
+ if (!pads[s]) { seat = s; break; }
+ }
+ if (seat < 0) {
+ break; /* all seats taken */
+ }
+ pads[seat] = SDL_GameControllerOpen(i);
+ if (pads[seat]) {
+ padInst[seat] = SDL_JoystickInstanceID(SDL_GameControllerGetJoystick(pads[seat]));
+ padDetectRumble(seat);
+ sysLogPrintf(LOG_NOTE, "input: opened gamepad %d '%s' as seat %d (rumble %s)",
+ i, SDL_GameControllerName(pads[seat]), seat, padRumbleOn[seat] ? "yes" : "no");
}
}
- for (int i = 0; i < MAX_PADS; ++i) {
- if (pads[i]) {
- connectedMask |= (1 << i);
+
+ /* 4. controller mask: seat bits, shifted up one when keyboard/mouse is P1 */
+ int seatMask = 0;
+ nRealPads = 0;
+ for (int s = 0; s < MAX_PADS; ++s) {
+ if (pads[s]) { seatMask |= 1 << s; nRealPads++; }
+ }
+ if (inMatch) {
+ s_matchSeatMask |= seatMask; /* reserved seats stay "connected, idle" */
+ seatMask = s_matchSeatMask;
+ } else {
+ s_matchSeatMask = seatMask;
+ }
+ connectedMask = 0x1 | seatMask;
+ if (inputMpKbmP1()) {
+ /* keyboard/mouse is controller 0; seat s is controller s + 1 */
+ connectedMask = (0x1 | (seatMask << 1)) & ((1 << MAX_PADS) - 1);
+ }
+ { /* D416 test harness: GE_MPVIRT= reports n extra idle controllers
+ * (1..3) so the MP menu unlocks and a 2P+ stage loads with no pads. */
+ const char *v = getenv("GE_MPVIRT");
+ int n = v ? atoi(v) : 0;
+ if (n > 0) {
+ if (n > MAX_PADS - 1) n = MAX_PADS - 1;
+ connectedMask |= (1 << (n + 1)) - 1;
}
}
numControllers = 1;
@@ -667,7 +862,16 @@ static void inputOpenPads(void)
* UHOLD/UREL and RELOADHOLD/RELOADREL exercise dedicated use/reload (D378).
* "Frame" = count of controller-0 reads since launch (roughly
* 2 per rendered frame -- watch GE_INPUTLOG to calibrate). Unset env => no
- * effect; when set it is the ONLY controller-0 input source. */
+ * effect; when set it is the ONLY controller-0 input source.
+ *
+ * GE_INPUTSCRIPT_P1 / _P2 / _P3 do the same for slots 1-3 (split-screen
+ * seats), so a headless run can drive players 2-4 behind GE_MPVIRT/
+ * GE_STARTMP with no physical pads:
+ * GE_INPUTSCRIPT_P1="600:ZHOLD;900:SUP;1200:SNONE,A"
+ * Same entry syntax; "frame" = count of THAT controller's reads since
+ * launch. Slots 1-3 have no mouse path, so mouse tokens (MDX/MDY) and
+ * CHOLD/CREL are ignored there (one warning per script if they appear).
+ * Unset => the seat stays idle exactly as before. */
#define INPUTSCRIPT_MAX 64
#define INPUTSCRIPT_PULSE 6
@@ -676,16 +880,41 @@ struct scriptEntry { long frame; unsigned mask; int sx, sy; int hasStick;
int hasZHold, zhold; /* D207: sustained fire */
int hasCrouch, crouch; /* D377: free-crouch QA */
int hasUse, use, hasReload, reload; }; /* D378: split-action QA */
-static struct scriptEntry scriptEntries[INPUTSCRIPT_MAX];
-static int scriptCount = -1; /* -1 = not parsed yet, 0 = parsed empty */
-static long scriptFrame = 0;
-static int scriptCurSX = 0; /* stick set by the last scriptApply() */
-static int scriptCurSY = 0;
+/* Per-slot state. Slot 0 is the original GE_INPUTSCRIPT harness (byte-for-
+ * byte unchanged); slots 1-3 are GE_INPUTSCRIPT_P1..P3 for split-screen test
+ * seats (virtual or physical pads). */
+struct scriptSlot {
+ struct scriptEntry entries[INPUTSCRIPT_MAX];
+ int count; /* -1 = not parsed yet, 0 = parsed empty */
+ long frame; /* controller reads for this slot since launch */
+ int curSX, curSY; /* stick set by the last scriptApply() for this slot */
+ int mouseOn, mdx, mdy; /* sustained scripted mouse (slot 0 only) */
+ int hold, zhold, crouch, use, reload; /* latest sustained holds */
+ int ignoredWarned; /* P1..P3: warned once about mouse/crouch tokens */
+};
+static struct scriptSlot scriptSlots[4] = {
+ { .count = -1 }, { .count = -1 }, { .count = -1 }, { .count = -1 },
+};
+
+static const char *scriptTag(int slot)
+{
+ if (slot == 0) return "GE_INPUTSCRIPT";
+ return slot == 1 ? "GE_INPUTSCRIPT_P1" : slot == 2 ? "GE_INPUTSCRIPT_P2"
+ : "GE_INPUTSCRIPT_P3";
+}
+
+static const char *scriptEnv(int slot)
+{
+ if (slot == 0) return getenv("GE_INPUTSCRIPT");
+ return slot == 1 ? getenv("GE_INPUTSCRIPT_P1")
+ : slot == 2 ? getenv("GE_INPUTSCRIPT_P2") : getenv("GE_INPUTSCRIPT_P3");
+}
/* Apply one token to `e`. Buttons: A B Z START L R UP DOWN LEFT RIGHT CUP
* CDOWN CLEFT CRIGHT (D-pad/C-buttons). Analog stick: SUP SDOWN SLEFT SRIGHT
* (full +/-80 deflection -- moves menu cursors). */
-static void scriptApplyToken(struct scriptEntry *e, const char *s, int n)
+static void scriptApplyToken(struct scriptEntry *e, const char *s, int n,
+ int slot)
{
struct { const char *k; unsigned v; } btn[] = {
{"A",GE_CONT_A}, {"B",GE_CONT_B}, {"Z",GE_CONT_G}, {"START",GE_CONT_START},
@@ -703,6 +932,15 @@ static void scriptApplyToken(struct scriptEntry *e, const char *s, int n)
* aim-model measurements: MDX MDY (latest entry wins, 0 stops),
* RHOLD / RREL (hold / release the game's R aim button). */
if (n > 3 && (SDL_strncasecmp("MDX", s, 3) == 0 || SDL_strncasecmp("MDY", s, 3) == 0)) {
+ if (slot > 0) { /* no mouse path on slots 1-3: ignore, warn once */
+ struct scriptSlot *st = &scriptSlots[slot];
+ if (!st->ignoredWarned) {
+ st->ignoredWarned = 1;
+ sysLogPrintf(LOG_WARNING, "%s: mouse tokens are ignored for slots 1-3 ('%.*s')",
+ scriptTag(slot), n, s);
+ }
+ return;
+ }
char num[16]; int k = n - 3; if (k > 15) k = 15;
memcpy(num, s + 3, k); num[k] = 0;
e->hasMouse = 1;
@@ -716,8 +954,20 @@ static void scriptApplyToken(struct scriptEntry *e, const char *s, int n)
* independent of RHOLD so aim + fire can be held together. */
if (n == 5 && SDL_strncasecmp("ZHOLD", s, 5) == 0) { e->hasZHold = 1; e->zhold = 1; return; }
if (n == 4 && SDL_strncasecmp("ZREL", s, 4) == 0) { e->hasZHold = 1; e->zhold = 0; return; }
- if (n == 5 && SDL_strncasecmp("CHOLD", s, 5) == 0) { e->hasCrouch = 1; e->crouch = 1; return; }
- if (n == 4 && SDL_strncasecmp("CREL", s, 4) == 0) { e->hasCrouch = 1; e->crouch = 0; return; }
+ if ((n == 5 && SDL_strncasecmp("CHOLD", s, 5) == 0) ||
+ (n == 4 && SDL_strncasecmp("CREL", s, 4) == 0)) {
+ if (slot > 0) { /* no crouch path on slots 1-3: ignore, warn once */
+ struct scriptSlot *st = &scriptSlots[slot];
+ if (!st->ignoredWarned) {
+ st->ignoredWarned = 1;
+ sysLogPrintf(LOG_WARNING, "%s: CHOLD/CREL are ignored for slots 1-3 ('%.*s')",
+ scriptTag(slot), n, s);
+ }
+ return;
+ }
+ e->hasCrouch = 1; e->crouch = (SDL_strncasecmp("CHOLD", s, 5) == 0);
+ return;
+ }
if (n == 5 && SDL_strncasecmp("UHOLD", s, 5) == 0) { e->hasUse = 1; e->use = 1; return; }
if (n == 4 && SDL_strncasecmp("UREL", s, 4) == 0) { e->hasUse = 1; e->use = 0; return; }
if (n == 10 && SDL_strncasecmp("RELOADHOLD", s, 10) == 0) { e->hasReload = 1; e->reload = 1; return; }
@@ -729,26 +979,28 @@ static void scriptApplyToken(struct scriptEntry *e, const char *s, int n)
if (n == 6 && SDL_strncasecmp("SRIGHT", s, 6) == 0) { e->sx = STICK_MAX; return; }
if (n == 5 && SDL_strncasecmp("SNONE", s, 5) == 0) { return; } /* recentre */
e->hasStick = 0;
- sysLogPrintf(LOG_WARNING, "GE_INPUTSCRIPT: unknown token '%.*s'", n, s);
+ sysLogPrintf(LOG_WARNING, "%s: unknown token '%.*s'", scriptTag(slot), n, s);
}
-static void scriptParse(void)
+static void scriptParse(int slot)
{
- scriptCount = 0;
- const char *env = getenv("GE_INPUTSCRIPT");
+ struct scriptSlot *st = &scriptSlots[slot];
+ const char *tag = scriptTag(slot);
+ st->count = 0;
+ const char *env = scriptEnv(slot);
if (!env || !*env) {
return;
}
const char *p = env;
- while (*p && scriptCount < INPUTSCRIPT_MAX) {
+ while (*p && st->count < INPUTSCRIPT_MAX) {
char *end = NULL;
long fr = strtol(p, &end, 10);
if (end == p || *end != ':') {
- sysLogPrintf(LOG_WARNING, "GE_INPUTSCRIPT: bad entry near '%s'", p);
+ sysLogPrintf(LOG_WARNING, "%s: bad entry near '%s'", tag, p);
break;
}
p = end + 1;
- struct scriptEntry *e = &scriptEntries[scriptCount];
+ struct scriptEntry *e = &st->entries[st->count];
e->frame = fr;
e->mask = 0;
e->sx = e->sy = 0;
@@ -761,22 +1013,23 @@ static void scriptParse(void)
while (*p && *p != ';') {
const char *tok = p;
while (*p && *p != ',' && *p != ';') ++p;
- scriptApplyToken(e, tok, (int)(p - tok));
+ scriptApplyToken(e, tok, (int)(p - tok), slot);
if (*p == ',') ++p;
}
if (*p == ';') ++p;
- scriptCount++;
+ st->count++;
}
- sysLogPrintf(LOG_INFO, "GE_INPUTSCRIPT: %d entr%s parsed",
- scriptCount, scriptCount == 1 ? "y" : "ies");
+ sysLogPrintf(LOG_INFO, "%s: %d entr%s parsed",
+ tag, st->count, st->count == 1 ? "y" : "ies");
}
-static int scriptIsActive(void)
+static int scriptIsActive(int slot)
{
- if (scriptCount < 0) {
- scriptParse();
+ struct scriptSlot *st = &scriptSlots[slot];
+ if (st->count < 0) {
+ scriptParse(slot);
}
- return scriptCount > 0;
+ return st->count > 0;
}
/* When a script is loaded it is the SOLE source of controller-0 input: real
@@ -784,67 +1037,71 @@ static int scriptIsActive(void)
* (a relative-mouse SDL window with no focus otherwise spews phantom deltas).
* Returns the scripted button mask for the current frame; advances the frame
* counter (call exactly once per controller-0 read). */
-/* D337: current sustained scripted mouse delta / aim hold (latest entry). */
-static int s_scriptMouseOn = 0, s_scriptMDX = 0, s_scriptMDY = 0, s_scriptHold = 0, s_scriptZHold = 0, s_scriptCrouch = 0, s_scriptUse = 0, s_scriptReload = 0;
-static void scriptPreMouse(void)
+/* Refresh the latest sustained holds for one slot from its entries (latest
+ * entry wins). Slot 0's mouse fields are read by the keyboard/mouse path; on
+ * slots 1-3 only hold/zhold/use/reload can be set (mouse and crouch tokens
+ * are ignored at parse time). */
+static void scriptRefreshHolds(int slot)
{
+ struct scriptSlot *st = &scriptSlots[slot];
long bestM = -1, bestH = -1, bestZ = -1, bestC = -1, bestU = -1, bestR = -1;
- for (int i = 0; i < scriptCount; ++i) {
- long d = scriptFrame - scriptEntries[i].frame;
+ for (int i = 0; i < st->count; ++i) {
+ long d = st->frame - st->entries[i].frame;
if (d < 0) continue;
- if (scriptEntries[i].hasMouse && scriptEntries[i].frame > bestM) {
- bestM = scriptEntries[i].frame;
- s_scriptMouseOn = 1;
- s_scriptMDX = scriptEntries[i].mdx;
- s_scriptMDY = scriptEntries[i].mdy;
+ if (st->entries[i].hasMouse && st->entries[i].frame > bestM) {
+ bestM = st->entries[i].frame;
+ st->mouseOn = 1;
+ st->mdx = st->entries[i].mdx;
+ st->mdy = st->entries[i].mdy;
}
- if (scriptEntries[i].hasHold && scriptEntries[i].frame > bestH) {
- bestH = scriptEntries[i].frame;
- s_scriptHold = scriptEntries[i].hold;
+ if (st->entries[i].hasHold && st->entries[i].frame > bestH) {
+ bestH = st->entries[i].frame;
+ st->hold = st->entries[i].hold;
}
- if (scriptEntries[i].hasZHold && scriptEntries[i].frame > bestZ) {
- bestZ = scriptEntries[i].frame;
- s_scriptZHold = scriptEntries[i].zhold;
+ if (st->entries[i].hasZHold && st->entries[i].frame > bestZ) {
+ bestZ = st->entries[i].frame;
+ st->zhold = st->entries[i].zhold;
}
- if (scriptEntries[i].hasCrouch && scriptEntries[i].frame > bestC) {
- bestC = scriptEntries[i].frame;
- s_scriptCrouch = scriptEntries[i].crouch;
+ if (st->entries[i].hasCrouch && st->entries[i].frame > bestC) {
+ bestC = st->entries[i].frame;
+ st->crouch = st->entries[i].crouch;
}
- if (scriptEntries[i].hasUse && scriptEntries[i].frame > bestU) {
- bestU = scriptEntries[i].frame;
- s_scriptUse = scriptEntries[i].use;
+ if (st->entries[i].hasUse && st->entries[i].frame > bestU) {
+ bestU = st->entries[i].frame;
+ st->use = st->entries[i].use;
}
- if (scriptEntries[i].hasReload && scriptEntries[i].frame > bestR) {
- bestR = scriptEntries[i].frame;
- s_scriptReload = scriptEntries[i].reload;
+ if (st->entries[i].hasReload && st->entries[i].frame > bestR) {
+ bestR = st->entries[i].frame;
+ st->reload = st->entries[i].reload;
}
}
}
-static unsigned scriptApply(unsigned button)
+static unsigned scriptApply(int slot, unsigned button)
{
- if (!scriptIsActive()) {
+ struct scriptSlot *st = &scriptSlots[slot];
+ if (!scriptIsActive(slot)) {
return button;
}
- scriptPreMouse(); /* D207: refresh sustained holds even off the mouse path (idempotent) */
+ scriptRefreshHolds(slot); /* D207: refresh sustained holds even off the mouse path (idempotent) */
unsigned m = 0;
long bestStickFrame = -1;
- for (int i = 0; i < scriptCount; ++i) {
- long d = scriptFrame - scriptEntries[i].frame;
+ for (int i = 0; i < st->count; ++i) {
+ long d = st->frame - st->entries[i].frame;
if (d >= 0 && d < INPUTSCRIPT_PULSE) {
- m |= scriptEntries[i].mask;
+ m |= st->entries[i].mask;
}
/* sustained stick: the latest-starting entry that carried a stick token */
- if (d >= 0 && scriptEntries[i].hasStick && scriptEntries[i].frame > bestStickFrame) {
- bestStickFrame = scriptEntries[i].frame;
- scriptCurSX = scriptEntries[i].sx;
- scriptCurSY = scriptEntries[i].sy;
+ if (d >= 0 && st->entries[i].hasStick && st->entries[i].frame > bestStickFrame) {
+ bestStickFrame = st->entries[i].frame;
+ st->curSX = st->entries[i].sx;
+ st->curSY = st->entries[i].sy;
}
}
- if (s_scriptHold == 1) m |= GE_CONT_R; /* D337 RHOLD */
- if (s_scriptHold == 2) m |= GE_CONT_L; /* D337 LHOLD (Q / "LeanLeft") */
- if (s_scriptZHold) m |= GE_CONT_G; /* D207 ZHOLD (fire) */
- scriptFrame++;
+ if (st->hold == 1) m |= GE_CONT_R; /* D337 RHOLD */
+ if (st->hold == 2) m |= GE_CONT_L; /* D337 LHOLD (Q / "LeanLeft") */
+ if (st->zhold) m |= GE_CONT_G; /* D207 ZHOLD (fire) */
+ st->frame++;
return m;
}
@@ -873,6 +1130,7 @@ int inputInit(void)
for (int i = 0; i < MAX_PADS; ++i) {
pads[i] = NULL;
+ padInst[i] = -1;
}
inputOpenPads();
@@ -920,6 +1178,7 @@ void inputDestroy(void)
SDL_GameControllerClose(pads[i]);
pads[i] = NULL;
}
+ padInst[i] = -1;
padRumbleOn[i] = 0; /* D401: closed */
}
if (SDL_WasInit(SDL_INIT_GAMECONTROLLER)) {
@@ -929,9 +1188,27 @@ void inputDestroy(void)
/* Poll once per controller-read: refresh SDL device state and integrate the
* mouse-aim delta into the accumulator. */
+/* D450: controller hot-plug. SDL_CONTROLLERDEVICEADDED / REMOVED land on
+ * whichever of the two event pumps dequeues them first: video.c's host loop
+ * or fast3d's gfx_sdl_handle_events (render thread, every frame), and the
+ * latter used to drop them, so a replugged pad was never reopened. Both pumps
+ * now only post a request; the rescan runs here, at the top of inputUpdate(),
+ * on the thread that is about to read pads[], so a pad is never closed under
+ * a reader. */
+static SDL_atomic_t s_rescanReq;
+
+void inputRequestRescan(void)
+{
+ SDL_AtomicSet(&s_rescanReq, 1);
+}
+
void inputUpdate(void)
{
+ if (SDL_AtomicSet(&s_rescanReq, 0)) inputRescanPads(); /* D450 */
SDL_GameControllerUpdate();
+ if (s_seatCompactOwed && !inputMpSplit()) {
+ inputOpenPads(); /* DNEW-mp-1: match over -> compact the seats */
+ }
/* D287: skip until the host thread has actually enabled relative mode
* (a benign racy read of a host-thread int), so the few ms between the
@@ -1019,10 +1296,14 @@ static const char *const kGepdPreset[IA_COUNT] = {
[IA_RELOAD] = "R",
[IA_CROUCH] = "Left Ctrl",
};
-static int s_crouchLatch = 0;
-static int s_crouchHeldPrev = 0;
-static int s_crouchApplied = 0; /* port-owned stance; not the native C-down crouch */
-static struct player *s_crouchPlayer = NULL;
+/* D416: crouch state is per controller slot (slot 0 only in solo). */
+static int s_crouchLatchA[4], s_crouchHeldPrevA[4], s_crouchAppliedA[4];
+static struct player *s_crouchPlayerA[4];
+#define CSLOT() (s_slot > 0 && s_slot < 4 ? s_slot : 0)
+#define s_crouchLatch (s_crouchLatchA[CSLOT()])
+#define s_crouchHeldPrev (s_crouchHeldPrevA[CSLOT()])
+#define s_crouchApplied (s_crouchAppliedA[CSLOT()]) /* port-owned stance; not the native C-down crouch */
+#define s_crouchPlayer (s_crouchPlayerA[CSLOT()])
static int s_useHeldPrev = 0, s_reloadHeldPrev = 0;
/* GEPD-style crouch operates on the game's existing stance field, without
@@ -1033,9 +1314,9 @@ static void inputDropCrouch(void)
{
/* The engine can take over the stance (tank, respawn, etc.) between
* polls; do not undo its new value or write into a different player. */
- if (s_crouchApplied && g_CurrentPlayer == s_crouchPlayer &&
- g_CurrentPlayer->crouchpos == CROUCH_SQUAT)
- g_CurrentPlayer->crouchpos = CROUCH_STAND;
+ if (s_crouchApplied && slotPlayer() == s_crouchPlayer &&
+ slotPlayer()->crouchpos == CROUCH_SQUAT)
+ slotPlayer()->crouchpos = CROUCH_STAND;
s_crouchApplied = 0;
s_crouchPlayer = NULL;
}
@@ -1369,9 +1650,9 @@ static void pcOptionsKeyboardPad(const Uint8 *ks, Uint32 mb, int blocked,
* In watch, pause, tank, front end or scripted cameras use native menu bits. */
static int inputCanUseGameplayActions(int menuMode)
{
- return !menuMode && g_CurrentPlayer && !g_CurrentPlayer->bonddead &&
- g_CurrentPlayer->outside_watch_menu && !g_CurrentPlayer->pause_state &&
- !g_CurrentPlayer->mpmenuon && !g_PlayerIsInTank &&
+ return !menuMode && slotPlayer() && !slotPlayer()->bonddead &&
+ slotPlayer()->outside_watch_menu && !slotPlayer()->pause_state &&
+ !slotPlayer()->mpmenuon && !g_PlayerIsInTank &&
!lvlGetControlsLockedFlag() && !gameScriptedCameraActive();
}
@@ -1382,7 +1663,8 @@ static int inputCanUseGameplayActions(int menuMode)
* from a gun selects the lowest gadget; the next press wraps the list. */
static void inputCycleGadget(void)
{
- struct player *p = g_CurrentPlayer;
+ if (inputMpSplit()) return; /* D419: acts on g_CurrentPlayer; solo only */
+ struct player *p = slotPlayer();
if (!p || !p->ptr_inventory_first_in_cycle) return;
int current = get_next_weapon_in_cycle_for_hand(GUNRIGHT, 1);
@@ -1408,7 +1690,22 @@ static void inputCycleGadget(void)
}
/* Fill button mask + stick for controller idx. Returns the 16-bit mask. */
+static unsigned inputComputePadSlot(int idx, signed char *stick_x, signed char *stick_y);
unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
+{
+ unsigned r;
+ if (idx > 0 && inputMpSplit() && idx >= getPlayerCount()) { /* D416: no player owns this slot */
+ if (stick_x) *stick_x = 0;
+ if (stick_y) *stick_y = 0;
+ return 0;
+ }
+ s_slot = idx;
+ r = inputComputePadSlot(idx, stick_x, stick_y);
+ s_slot = -1;
+ return r;
+}
+
+static unsigned inputComputePadSlot(int idx, signed char *stick_x, signed char *stick_y)
{
unsigned button = 0;
int sx = 0, sy = 0;
@@ -1417,10 +1714,20 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
/* D194/D238: self-correcting every poll -- cheap (plain field writes,
* see options.c cur_player_set_control_type), and re-asserts itself if
* anything else ever calls the setter (menu, save load) in between. */
- if (idx == 0 && g_CurrentPlayer != NULL) {
+ if ((idx == 0 || inputMpSplit()) && slotPlayer() != NULL &&
+ (idx == 0 || idx < getPlayerCount())) { /* D416: each slot rewrites only its own player's style */
int wantSolitare = naturalPitchMode ? CONTROLLER_CONFIG_SOLITARE_ : CONTROLLER_CONFIG_HONEY_;
- if (cur_player_get_control_type() != wantSolitare) {
- cur_player_set_control_type(wantSolitare);
+ struct player *sp = slotPlayer();
+ if (!inputMpSplit()) {
+ if (cur_player_get_control_type() != wantSolitare)
+ cur_player_set_control_type(wantSolitare);
+ } else if (sp->cur_player_control_type_0 != wantSolitare) {
+ /* mirror of options.c cur_player_set_control_type on the slot's own player */
+ sp->cur_player_control_type_0 = wantSolitare;
+ sp->cur_player_control_type_1 = wantSolitare;
+ sp->cur_player_control_type_2 = (float)wantSolitare;
+ sp->neg_vspacing_for_control_type_entry = -((j_text_trigger ? 14 : 10) * wantSolitare);
+ sp->has_set_control_type_data = TRUE;
}
}
@@ -1459,8 +1766,8 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
return 0;
}
- /* ---- keyboard + mouse: controller 0 only ---- */
- if (idx == 0) {
+ /* ---- keyboard + mouse: controller 0 only (not when pads own P1, D416) ---- */
+ if (idx == 0 && !inputMpPadsOwnP1()) {
const Uint8 *ks = SDL_GetKeyboardState(NULL);
Uint32 mb = mouseEnabled ? SDL_GetMouseState(NULL, NULL) : 0;
int menuMode = (current_menu != GE_MENU_RUN_STAGE &&
@@ -1489,11 +1796,11 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
reconcileGrab(menuMode);
/* D337 harness: scripted sustained mouse replaces the real deltas and
* behaves as a grabbed mouse (headless windows are never focused). */
- if (scriptIsActive()) {
- scriptPreMouse();
- if (s_scriptMouseOn) {
- mouseDX = (double)s_scriptMDX;
- mouseDY = (double)s_scriptMDY;
+ if (scriptIsActive(0)) {
+ scriptRefreshHolds(0);
+ if (scriptSlots[0].mouseOn) {
+ mouseDX = (double)scriptSlots[0].mdx;
+ mouseDY = (double)scriptSlots[0].mdy;
mouseGrabbed = 1;
}
}
@@ -1554,33 +1861,33 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
* stays tied to the physical button so Toggle still sees edges. */
int aimButton = actHeld(ks, IA_AIM);
int aimHeld = aimButton || actHeld(ks, IA_LEAN_L) ||
- (g_CurrentPlayer != NULL && g_CurrentPlayer->insightaimmode);
- int aimRisingEdgeAim = aimHeld && !s_aimHeldPrev;
- if (aimRisingEdgeAim && g_CurrentPlayer && g_CurrentPlayer->docentreupdown)
- s_centreClearTicks = 2; /* see D194 centre-spring note above */
+ (slotPlayer() != NULL && slotPlayer()->insightaimmode);
+ int aimRisingEdgeAim = aimHeld && !AIMS.aimHeldPrev;
+ if (aimRisingEdgeAim && slotPlayer() && slotPlayer()->docentreupdown)
+ AIMS.centreClearTicks = 2; /* see D194 centre-spring note above */
if (!aimHeld) {
- s_centreClearTicks = 0;
+ AIMS.centreClearTicks = 0;
/* GEPD adopts the game's current crosshair pos every non-aim
* frame so re-entry starts where the game left it. */
- s_gepdHeldPrev = 0;
- if (g_CurrentPlayer) {
+ AIMS.gepdHeldPrev = 0;
+ if (slotPlayer()) {
if (pdMouseAimEnabled()) {
/* D338: the accumulator is in GEPD units (±LIMIT = edge);
* adopt through the game's screen mapping (offset =
* pos*(1-damp), gunfire.c) and the aim-range scale, so aim
* entry starts exactly where the crosshair is drawn. */
- double k = (1.0 - (double) g_CurrentPlayer->guncrossdamp) / 0.99
+ double k = (1.0 - (double) slotPlayer()->guncrossdamp) / 0.99
* GEPD_CROSSHAIR_LIMIT / aimRangeScale();
- s_gepdCrossX = (double) g_CurrentPlayer->crosshair_x_pos * k;
- s_gepdCrossY = (double) g_CurrentPlayer->crosshair_y_pos * k;
+ AIMS.crossX = (double) slotPlayer()->crosshair_x_pos * k;
+ AIMS.crossY = (double) slotPlayer()->crosshair_y_pos * k;
} else {
- s_gepdCrossX = (double) g_CurrentPlayer->crosshair_x_pos / aimRangeScale();
- s_gepdCrossY = (double) g_CurrentPlayer->crosshair_y_pos / aimRangeScale();
+ AIMS.crossX = (double) slotPlayer()->crosshair_x_pos / aimRangeScale();
+ AIMS.crossY = (double) slotPlayer()->crosshair_y_pos / aimRangeScale();
}
}
}
- s_aimHeldPrev = aimHeld;
- if (aimRisingEdgeAim && g_CurrentPlayer && g_CurrentPlayer->docentreupdown
+ AIMS.aimHeldPrev = aimHeld;
+ if (aimRisingEdgeAim && slotPlayer() && slotPlayer()->docentreupdown
&& configGetInputLog()) {
sysLogPrintf(LOG_NOTE,
"GE_INPUTLOG absaim centre-spring armed at aim entry; nudging to clear");
@@ -1617,10 +1924,11 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
* interaction with NO fallback; R and pad X only reload. Their
* combined edges prevent a held input from repeatedly using a door.
* In menus/watch/tank the pad keeps native accept/cancel buttons. */
- int padUse = pads[0] && SDL_GameControllerGetButton(pads[0], SDL_CONTROLLER_BUTTON_A);
- int padReload = pads[0] && SDL_GameControllerGetButton(pads[0], SDL_CONTROLLER_BUTTON_X);
- int useNow = scriptIsActive() ? s_scriptUse : (actHeld(ks, IA_CANCEL) || padUse);
- int reloadNow = scriptIsActive() ? s_scriptReload : (actHeld(ks, IA_RELOAD) || padReload);
+ SDL_GameController *slot0Pad = slotPad(0);
+ int padUse = slot0Pad && SDL_GameControllerGetButton(slot0Pad, SDL_CONTROLLER_BUTTON_A);
+ int padReload = slot0Pad && SDL_GameControllerGetButton(slot0Pad, SDL_CONTROLLER_BUTTON_X);
+ int useNow = scriptIsActive(0) ? scriptSlots[0].use : (actHeld(ks, IA_CANCEL) || padUse);
+ int reloadNow = scriptIsActive(0) ? scriptSlots[0].reload : (actHeld(ks, IA_RELOAD) || padReload);
int playable = inputCanUseGameplayActions(menuMode);
/* D407: on the N64 the B bit also drives bondview2.c's tank
* handlers (board when g_BondCanEnterTank, exit while
@@ -1654,11 +1962,13 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
s_tankBoardLock = (g_PlayerIsInTank == 1 &&
g_EnterTankAudioState == TANK_RUN_STATE_NOT_RUNNING);
if (playable) {
- if (useNow && !s_useHeldPrev && !tankState) {
+ if (useNow && !s_useHeldPrev && !tankState && inputMpSplit()) {
+ button |= GE_CONT_B; /* D419: native use (+ reload fallback); no cross-thread game call in a match */
+ } else if (useNow && !s_useHeldPrev && !tankState) {
bool empty = bond_interact_object();
if (configGetInputLog()) sysLogPrintf(LOG_NOTE, "GE_INPUTLOG dedicated use (no target=%d)", empty);
}
- } else if ((scriptIsActive() ? s_scriptUse : actHeld(ks, IA_CANCEL)) && !tankState) {
+ } else if ((scriptIsActive(0) ? scriptSlots[0].use : actHeld(ks, IA_CANCEL)) && !tankState) {
/* A on the pad is still native accept outside playable stages.
* D407: NOT in tank states -- there B must come only from the
* locked edge path below. This mapping is level-triggered on E;
@@ -1669,7 +1979,9 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
}
if (useNow && !s_useHeldPrev && tankState && !s_tankBoardLock)
button |= GE_CONT_B;
- if (reloadNow && !s_reloadHeldPrev && playable) {
+ if (reloadNow && !s_reloadHeldPrev && playable && inputMpSplit()) {
+ button |= GE_CONT_B;
+ } else if (reloadNow && !s_reloadHeldPrev && playable) {
attempt_reload_item_in_hand(GUNRIGHT);
attempt_reload_item_in_hand(GUNLEFT);
if (configGetInputLog()) sysLogPrintf(LOG_NOTE, "GE_INPUTLOG dedicated reload (no use)");
@@ -1844,9 +2156,9 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
"GE_INPUTLOG menuptr abs=%d cursor=(%.1f,%.1f) stick=(%d,%d)",
haveAbs, (double)cursor_h_pos, (double)cursor_v_pos, sx, sy);
}
- } else if (g_CurrentPlayer != NULL &&
- (!g_CurrentPlayer->outside_watch_menu ||
- g_CurrentPlayer->pause_state != 0)) {
+ } else if (slotPlayer() != NULL &&
+ (!slotPlayer()->outside_watch_menu ||
+ slotPlayer()->pause_state != 0)) {
/* D330 (#103): in-stage watch / pause -- the mouse drives
* nothing. menuMode is 0 here (current_menu is still
* RUN_STAGE) and hipDirectCompute declines on this same gate,
@@ -1912,10 +2224,10 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
* of ticks -- enough for bondview2's manual-input rule to clear
* docentreupdown, small enough (~0.1-0.3 deg) not to read as a
* jerk. Only when we are not already pitching this poll. */
- if (s_centreClearTicks > 0 && g_CurrentPlayer &&
+ if (AIMS.centreClearTicks > 0 && slotPlayer() &&
sy >= -60 && sy <= 60) {
- sy = (g_CurrentPlayer->speedverta >= 0.0f) ? -61 : 61;
- s_centreClearTicks--;
+ sy = (slotPlayer()->speedverta >= 0.0f) ? -61 : 61;
+ AIMS.centreClearTicks--;
}
} else {
double hipEdx = edx * lookDtScale, hipDyLook = dyLook * lookDtScale;
@@ -1983,7 +2295,7 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
* and chasing the menu cursor (user repro, bind log). Physical arrow
* keys and Enter/Escape are fixed menu controls; mouse/pad still
* work. Scripted QA input remains the sole source when active. */
- if (current_menu == MENU_PC_OPTIONS && !scriptIsActive())
+ if (current_menu == MENU_PC_OPTIONS && !scriptIsActive(0))
{
pcOptionsKeyboardPad(ks, mb, optionsBindingInputBlocked(), &button, &sx, &sy);
/* D407(b): on this screen the mouse wheel scrolls the row list
@@ -2007,7 +2319,7 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
}
/* ---- gamepad ---- */
- SDL_GameController *pad = pads[idx];
+ SDL_GameController *pad = slotPad(idx);
if (pad) {
int lx = SDL_GameControllerGetAxis(pad, SDL_CONTROLLER_AXIS_LEFTX);
int ly = SDL_GameControllerGetAxis(pad, SDL_CONTROLLER_AXIS_LEFTY);
@@ -2053,7 +2365,7 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
if (padLookSensY != 100 && rys) rys = rys * padLookSensY / 100;
if (rxs > STICK_MAX) rxs = STICK_MAX; else if (rxs < -STICK_MAX) rxs = -STICK_MAX;
if (rys > STICK_MAX) rys = STICK_MAX; else if (rys < -STICK_MAX) rys = -STICK_MAX;
- if (rxs || rys) s_aimDevMouse = 0; /* D337: pad aim active */
+ if (idx == 0 && (rxs || rys)) s_aimDevMouse = 0; /* D337: pad aim active */
if (padLookSmooth > 0) {
double a = padLookSmooth * 0.09; /* 10 -> 0.9 (heaviest); 0 = off */
padSmSX[idx] = padSmSX[idx] * a + (double)rxs * (1.0 - a);
@@ -2083,7 +2395,7 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
int py = -scaleAxis(ly, padDeadzoneL); /* SDL up = negative -> N64 up = positive */
if (px) sx = px;
if (py) sy = py;
- if (px || py) s_aimDevMouse = 0; /* D337: pad aim active */
+ if (idx == 0 && (px || py)) s_aimDevMouse = 0; /* D337: pad aim active */
if (padLookInvertY) ry = -ry;
if (rx > RSTICK_THRESHOLD) button |= GE_CONT_F;
@@ -2114,11 +2426,17 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
int padX = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_X);
int padB = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_B);
int padY = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_Y);
- int facePlayable = idx == 0 && inputCanUseGameplayActions(padMenuMode);
+ int facePlayable = (idx == 0 || inputMpSplit()) && inputCanUseGameplayActions(padMenuMode);
if (facePlayable) {
if (SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_LEFTSTICK) ||
SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_RIGHTSTICK))
crouchNow = 1;
+ if (idx > 0 || inputMpPadsOwnP1()) {
+ /* Slots 1+ (and a pad-owned P1) have no keyboard block: pad A / X = the native B
+ * button on the press edge (use, reload when nothing to use). */
+ if ((padA && !padAPrev[idx]) || (padX && !padXPrev[idx]))
+ button |= GE_CONT_B;
+ }
if (padB && !padBPrev[idx]) inputCycleGadget();
if (padY && !padYPrev[idx]) button |= GE_CONT_A;
} else {
@@ -2127,6 +2445,8 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
}
padBPrev[idx] = padB;
padYPrev[idx] = padY;
+ padAPrev[idx] = padA;
+ padXPrev[idx] = padX;
if (facePlayable && SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_LEFTSHOULDER))
button |= GE_CONT_R;
if (SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_START))
@@ -2151,14 +2471,14 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
}
}
- if (idx == 0 && scriptIsActive()) crouchNow = s_scriptCrouch;
+ if (idx == 0 && scriptIsActive(0)) crouchNow = scriptSlots[0].crouch;
/* The GEPD preset's crouch key is independent of aim. Do not turn it
* into C-down: outside aim C-down moves backwards in 1.2, and inside
* aim it also zooms on weapons that disable crouch. This is a PC input
* adapter writing the same stance field the native game adjusts, not
* a change to the N64 game logic. */
- if (idx == 0) {
+ if (idx == 0 || inputMpSplit()) {
int stage = current_menu == GE_MENU_RUN_STAGE || current_menu == GE_MENU_INVALID;
if (!stage) s_crouchLatch = 0;
if (crouchMode == 1) {
@@ -2166,13 +2486,15 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
s_crouchHeldPrev = crouchNow;
crouchNow = s_crouchLatch;
}
- struct player *p = g_CurrentPlayer;
+ struct player *p = slotPlayer();
int canCrouch = stage && p && !p->bonddead && p->outside_watch_menu &&
!p->pause_state && !p->mpmenuon && !g_PlayerIsInTank &&
!lvlGetControlsLockedFlag() &&
- !gameScriptedCameraActive() &&
- !bondwalkItemCheckBitflags(getCurrentPlayerWeaponId(GUNRIGHT),
+ !gameScriptedCameraActive();
+ if (canCrouch && (crouchNow || s_crouchApplied)) { /* weapon lookup only when it can matter */
+ canCrouch = !bondwalkItemCheckBitflags(p->hands[GUNRIGHT].weaponnum,
WEAPONSTATBITFLAG_DISABLE_CROUCH);
+ }
if (canCrouch && crouchNow) {
/* In aim mode the native crouchUp branch runs every tick without
* C-down, undoing the port stance by one step. Feed the native
@@ -2196,12 +2518,21 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
}
}
- if (idx == 0 && scriptIsActive()) {
+ if (idx == 0 && scriptIsActive(0)) {
/* D263: apply BEFORE the stick is written out -- scripted stick
* tokens (SUP/SDOWN/SLEFT/SRIGHT) used to be discarded. */
- button = scriptApply(button);
- sx = scriptCurSX;
- sy = scriptCurSY;
+ button = scriptApply(0, button);
+ sx = scriptSlots[0].curSX;
+ sy = scriptSlots[0].curSY;
+ } else if (idx > 0 && idx <= 3 && scriptIsActive(idx)) {
+ /* Per-slot harness (GE_INPUTSCRIPT_P1..P3): same sole-source
+ * semantics for a split-screen test seat. Virtual seats behind
+ * GE_MPVIRT reach here with button/sx/sy still at their idle zeros
+ * (the gamepad block above is guarded by `if (pad)`), so an
+ * unscripted seat stays idle exactly as before. */
+ button = scriptApply(idx, button);
+ sx = scriptSlots[idx].curSX;
+ sy = scriptSlots[idx].curSY;
}
if (sx > STICK_MAX) sx = STICK_MAX;
@@ -2214,13 +2545,13 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
/* D337: GE_AIMLOG=1 -- per-poll aim-model trace (the game's own crosshair
* state), for measuring jitter / range / edge-scroll headlessly. */
- if (idx == 0 && g_CurrentPlayer != NULL && GE_ENVFLAG("GE_AIMLOG") &&
+ if (idx == 0 && slotPlayer() != NULL && GE_ENVFLAG("GE_AIMLOG") &&
(current_menu == GE_MENU_RUN_STAGE || current_menu == GE_MENU_INVALID)) {
sysLogPrintf(LOG_NOTE, "AIMLOG f=%ld aim=%d dev=%d drawx=%.3f cx=%.5f cy=%.5f theta=%.4f verta=%.4f acc=%.4f",
- scriptFrame, (int)g_CurrentPlayer->insightaimmode, s_aimDevMouse,
- (double)g_CurrentPlayer->crosshair_angle.f[0],
- (double)g_CurrentPlayer->crosshair_x_pos, (double)g_CurrentPlayer->crosshair_y_pos,
- (double)g_CurrentPlayer->vv_theta, (double)g_CurrentPlayer->vv_verta, s_gepdCrossX);
+ scriptSlots[0].frame, (int)slotPlayer()->insightaimmode, s_aimDevMouse,
+ (double)slotPlayer()->crosshair_angle.f[0],
+ (double)slotPlayer()->crosshair_x_pos, (double)slotPlayer()->crosshair_y_pos,
+ (double)slotPlayer()->vv_theta, (double)slotPlayer()->vv_verta, AIMS.crossX);
}
if (configGetInputLog() && (button || sx || sy)) {
sysLogPrintf(LOG_NOTE, "GE_INPUTLOG cont%d: btn=%04x stick=(%d,%d)",
@@ -2347,19 +2678,18 @@ void inputPostWheel(int notches)
else if (notches < 0) { wheelFwd = WHEEL_FWD_POLLS; wheelBack = 0; }
}
-/* Called from the host event pump on SDL_CONTROLLERDEVICEADDED/REMOVED.
- * Closes every open pad and re-opens whatever is present now. `connectedMask`
- * bit 0 (keyboard/mouse) is always kept. Note: the game latches the mask at
- * osContInit (boot), so a pad added later still merges into controller 0 for
- * play -- it just won't appear as a separate controller channel. */
+/* D450: rescan requested by inputRequestRescan() from either SDL event pump
+ * on SDL_CONTROLLERDEVICEADDED/REMOVED and performed at the top of
+ * inputUpdate(), on the scheduler thread, before pads[] is read. Re-syncs
+ * the pad seats (inputOpenPads); `connectedMask` bit 0 (keyboard/mouse) is
+ * always kept. The game re-reads the mask through joyCheckStatus (every 120
+ * polls and on any errno edge, src/joy.c), so a newly seated pad becomes its
+ * own controller channel within ~2 s. */
void inputRescanPads(void)
{
+ /* DNEW-mp-1: still-attached pads keep their open handle and seat;
+ * inputOpenPads closes only detached ones and seats new ones. */
for (int i = 0; i < MAX_PADS; ++i) {
- if (pads[i]) {
- SDL_GameControllerClose(pads[i]);
- pads[i] = NULL;
- }
- padRumbleOn[i] = 0; /* D401: re-detected by inputOpenPads below */
padSmSX[i] = 0.0; /* Wave A: clear the look-smoothing EMA so a stick
padSmSY[i] = 0.0; * snap-back after re-plug can't lag from a stale value */
}
@@ -2382,14 +2712,18 @@ void inputRescanPads(void)
int inputRumbleSupported(int idx)
{
if (idx < 0 || idx >= MAX_PADS) return 0;
+ if (inputMpSplit() && inputMpKbmP1()) return idx >= 1 ? padRumbleOn[idx - 1] : 0;
return padRumbleOn[idx];
}
void inputRumble(int idx, f32 strength, f32 time)
{
- if (idx < 0 || idx >= MAX_PADS || !pads[idx]) return;
+ if (idx < 0 || idx >= MAX_PADS) return;
+ SDL_GameController *rpad = slotPad(idx);
+ const int rIdx = (inputMpSplit() && inputMpKbmP1()) ? idx - 1 : idx;
+ if (!rpad) return;
if (gRumbleScale <= 0.f) return;
- if (padRumbleOn[idx]) {
+ if (padRumbleOn[rIdx]) {
strength *= gRumbleScale;
if (strength <= 0.f) {
strength = 0.f;
@@ -2398,7 +2732,7 @@ void inputRumble(int idx, f32 strength, f32 time)
strength *= 65535.f;
time *= 1000.f;
}
- SDL_GameControllerRumble(pads[idx], (Uint16)strength, (Uint16)strength, (Uint32)time);
+ SDL_GameControllerRumble(rpad, (Uint16)strength, (Uint16)strength, (Uint32)time);
}
}
@@ -2470,7 +2804,7 @@ int inputGetNumControllers(void)
* if aim is not usable this poll. */
static int aimGepdAccumulate(double dxPx, double dyLook)
{
- struct player *p = g_CurrentPlayer;
+ struct player *p = slotPlayer();
/* Needs the grabbed-cursor relative deltas (capture mode, locked in a
* stage) and a live player. dxPx/dyLook are this poll's px. */
@@ -2479,11 +2813,11 @@ static int aimGepdAccumulate(double dxPx, double dyLook)
/* On entry adopt the game's current position (GEPD adopts every
* non-aim frame; input.c does that in the !aimHeld branch). */
- if (!s_gepdHeldPrev) {
- s_gepdCrossX = (double) p->crosshair_x_pos;
- s_gepdCrossY = (double) p->crosshair_y_pos;
+ if (!AIMS.gepdHeldPrev) {
+ AIMS.crossX = (double) p->crosshair_x_pos;
+ AIMS.crossY = (double) p->crosshair_y_pos;
}
- s_gepdHeldPrev = 1;
+ AIMS.gepdHeldPrev = 1;
/* Crosshair position: GEPD crosshairpos += delta/10 * (SENS/292). */
/* D194/D238: master sensitivity scales aim mode too, so MouseSensitivity
@@ -2491,12 +2825,12 @@ static int aimGepdAccumulate(double dxPx, double dyLook)
* aim-mode offset from that shared baseline. At master=100 this is
* exactly the M-123-calibrated feel. */
double sens = (double) gepdSens / 2920.0 * (mouseSensitivity / 100.0);
- s_gepdCrossX += dxPx * sens;
- s_gepdCrossY += dyLook * sens; /* +dyLook = look down = crosshair down */
- if (s_gepdCrossX > GEPD_CROSSHAIR_LIMIT) s_gepdCrossX = GEPD_CROSSHAIR_LIMIT;
- if (s_gepdCrossX < -GEPD_CROSSHAIR_LIMIT) s_gepdCrossX = -GEPD_CROSSHAIR_LIMIT;
- if (s_gepdCrossY > GEPD_CROSSHAIR_LIMIT) s_gepdCrossY = GEPD_CROSSHAIR_LIMIT;
- if (s_gepdCrossY < -GEPD_CROSSHAIR_LIMIT) s_gepdCrossY = -GEPD_CROSSHAIR_LIMIT;
+ AIMS.crossX += dxPx * sens;
+ AIMS.crossY += dyLook * sens; /* +dyLook = look down = crosshair down */
+ if (AIMS.crossX > GEPD_CROSSHAIR_LIMIT) AIMS.crossX = GEPD_CROSSHAIR_LIMIT;
+ if (AIMS.crossX < -GEPD_CROSSHAIR_LIMIT) AIMS.crossX = -GEPD_CROSSHAIR_LIMIT;
+ if (AIMS.crossY > GEPD_CROSSHAIR_LIMIT) AIMS.crossY = GEPD_CROSSHAIR_LIMIT;
+ if (AIMS.crossY < -GEPD_CROSSHAIR_LIMIT) AIMS.crossY = -GEPD_CROSSHAIR_LIMIT;
return 1;
}
@@ -2507,11 +2841,11 @@ static int aimGepdAccumulate(double dxPx, double dyLook)
* too, or dragging the crosshair to the edge no longer moves the camera. */
static void aimGepdEdgeScroll(void)
{
- struct player *p = g_CurrentPlayer;
- f32 fov = viGetFovY();
+ struct player *p = slotPlayer();
+ f32 fov = slotFovY();
- double rX = s_gepdCrossX / GEPD_CROSSHAIR_LIMIT;
- double rY = s_gepdCrossY / GEPD_CROSSHAIR_LIMIT;
+ double rX = AIMS.crossX / GEPD_CROSSHAIR_LIMIT;
+ double rY = AIMS.crossY / GEPD_CROSSHAIR_LIMIT;
double aimx = 0.0, aimy = 0.0;
const double th = aimEdgeThreshold(); /* D338 */
if (rX > th) aimx = (rX - th) * GEPD_SCROLL_SPEED / 60.0;
@@ -2538,12 +2872,12 @@ static void aimGepdEdgeScroll(void)
static int aimGepdCompute(double dxPx, double dyLook)
{
- struct player *p = g_CurrentPlayer;
+ struct player *p = slotPlayer();
if (!aimGepdAccumulate(dxPx, dyLook))
return 0;
- f32 fov = viGetFovY();
+ f32 fov = slotFovY();
f32 fovratio = (fov > 0.0f) ? fov / GEPD_BASE_FOV : 1.0f;
/* Pre-overwrite residue: what last tick's damped update left behind
@@ -2556,10 +2890,10 @@ static int aimGepdCompute(double dxPx, double dyLook)
/* Crosshair + gun/arm pose (GEPD formulas, RATIOFACTOR=1 for our 4:3
* viewport; failsafe weapon offsets 0.15/0 as in goldeneye.c). */
- p->crosshair_x_pos = (f32) (s_gepdCrossX * aimRangeScale()); /* D338 */
- p->crosshair_y_pos = (f32) (s_gepdCrossY * aimRangeScale());
- p->gun_azimuth_angle = (f32) (s_gepdCrossX * (1.11f + 0.15f * 1.5f) + fovratio - 1.0f);
- p->gun_azimuth_turning = (f32) (s_gepdCrossY * 1.11f + fovratio - 1.0f);
+ p->crosshair_x_pos = (f32) (AIMS.crossX * aimRangeScale()); /* D338 */
+ p->crosshair_y_pos = (f32) (AIMS.crossY * aimRangeScale());
+ p->gun_azimuth_angle = (f32) (AIMS.crossX * (1.11f + 0.15f * 1.5f) + fovratio - 1.0f);
+ p->gun_azimuth_turning = (f32) (AIMS.crossY * 1.11f + fovratio - 1.0f);
aimGepdEdgeScroll();
@@ -2570,7 +2904,7 @@ static int aimGepdCompute(double dxPx, double dyLook)
* at d=(0,0) -- the multi-tick spazz hypothesis. */
sysLogPrintf(LOG_NOTE,
"GE_INPUTLOG gepdaim d=(%.1f,%.1f) cross=(%.2f,%.2f) game=(%.2f,%.2f) aa=(%.2f,%.2f) st=%.3f sv=%.3f ct=%d cam=(%.1f,%.1f)",
- dxPx, dyLook, s_gepdCrossX, s_gepdCrossY, resX, resY,
+ dxPx, dyLook, AIMS.crossX, AIMS.crossY, resX, resY,
(double)p->autoaimx, (double)p->autoaimy,
(double)p->speedtheta, (double)p->speedverta,
g_ClockTimer, (double)p->vv_theta, (double)p->vv_verta);
@@ -2611,16 +2945,24 @@ static int aimGepdCompute(double dxPx, double dyLook)
* direct path applied (poll thread) and hand the game thread an equivalent
* turn speed once per tick: the exact inverse of the game's own yaw
* integration (see portGunSwayTheta). Hipfire only; display-side. */
-static SDL_atomic_t s_swayThetaMdeg;
+/* D428 follow-up (#99): one accumulator per player. Fed on the poll thread
+ * for the polled slot (player N owns slot N), drained on the game thread for
+ * the current player. Solo = slot/player 0 only, as before. The game-side hook
+ * (bondview2.c, D428 Rule-2 block) still drains player 0 only; see findings
+ * D428 "split-screen note" for the one-line widening it would need. */
+static SDL_atomic_t s_swayThetaMdeg[4];
static void swayFeedTheta(double deg)
{
- SDL_AtomicAdd(&s_swayThetaMdeg, (int)lround(deg * 1000.0));
+ SDL_AtomicAdd(&s_swayThetaMdeg[(s_slot > 0 && s_slot < 4) ? s_slot : 0],
+ (int)lround(deg * 1000.0));
}
float portGunSwayTheta(float dt)
{
- int m = SDL_AtomicSet(&s_swayThetaMdeg, 0);
+ extern s32 get_cur_playernum(void);
+ s32 pn = get_cur_playernum();
+ int m = SDL_AtomicSet(&s_swayThetaMdeg[(pn > 0 && pn < 4) ? pn : 0], 0);
if (dt <= 0.0f)
return 0.0f;
/* Exact inverse of the game's own yaw integration (bondview2.c:
@@ -2642,7 +2984,7 @@ float portGunSwayTheta(float dt)
static int hipDirectCompute(double dxPx, double dyLook)
{
- struct player *p = g_CurrentPlayer;
+ struct player *p = slotPlayer();
if (!mouseDirectLook || !mouseGrabbed || p == NULL)
return 0;
@@ -2668,7 +3010,7 @@ static int hipDirectCompute(double dxPx, double dyLook)
if (gameScriptedCameraActive())
return 0;
- f32 fov = viGetFovY();
+ f32 fov = slotFovY();
f32 scale = (fov > 0.0f) ? fov / GEPD_BASE_FOV : 1.0f;
double sens = (mouseTurnSpeed / 100.0) * (mouseSensitivity / 100.0);
@@ -2711,7 +3053,7 @@ static int hipDirectCompute(double dxPx, double dyLook)
* behaviour, not the feel. */
static int padDirectCompute(int dx, int dy)
{
- struct player *p = g_CurrentPlayer;
+ struct player *p = slotPlayer();
if (p == NULL)
return 0;
@@ -2723,7 +3065,7 @@ static int padDirectCompute(int dx, int dy)
if (dx == 0 && dy == 0)
return 1; /* nothing to turn; caller simply emits no stick */
- f32 fov = viGetFovY();
+ f32 fov = slotFovY();
double k = (fov > 0.0f) ? (double)fov / 60.0 : 1.0; /* bondview2's base */
double v;
@@ -2769,13 +3111,18 @@ int portMouseAimPdGetTurn(f32 *tx, f32 *ty)
{
if (!portMouseAimPdActive() || g_CurrentPlayer == NULL)
return 0;
- if (tx) *tx = (f32) (s_gepdCrossX / GEPD_CROSSHAIR_LIMIT * aimRangeScale()); /* D338 */
- if (ty) *ty = (f32) (s_gepdCrossY / GEPD_CROSSHAIR_LIMIT * aimRangeScale());
+ if (inputMpSplit() && player_num != 0) /* D416: the mouse owns player 0 only */
+ return 0;
+ /* Game thread: never AIMS (s_slot belongs to the poll thread). The mouse
+ * lives on slot 0 (D422). */
+ if (tx) *tx = (f32) (s_aimSt[0].crossX / GEPD_CROSSHAIR_LIMIT * aimRangeScale()); /* D338 */
+ if (ty) *ty = (f32) (s_aimSt[0].crossY / GEPD_CROSSHAIR_LIMIT * aimRangeScale());
return 1;
}
PD_CONSTRUCTOR static void inputConfigInit(void)
{
+ configRegisterInt("Input.MPMode", &mpInputMode, 0, 2); /* D416: 0 Auto, 1 PadsOnly, 2 KbmP1 */
configRegisterInt("Input.MouseEnabled", &mouseEnabled, 0, 1);
configRegisterInt("Input.MouseAimSpeed", &mouseAimSpeed, 1, 500);
configRegisterInt("Input.PdMouseAim", &pdMouseAim, 0, 1); /* legacy -> AimStyle 1 */
diff --git a/port/src/libultra.c b/port/src/libultra.c
index 8d7e81ce..b4ab556c 100644
--- a/port/src/libultra.c
+++ b/port/src/libultra.c
@@ -59,6 +59,7 @@
/* fast3d (C++): the software RSP entry point. */
extern void gfx_run(Gfx *commands);
+extern void videoSyncSplitScreen(void); /* D416 */
/* ------------------------------------------------------------------------ */
/* Globals the game expects to exist (normally set by osInitialize). */
@@ -1327,6 +1328,7 @@ void osSpTaskStartGo(OSTask *t)
/* Graphics task: run the software RSP on the display list. */
uint64_t t0 = sysGetMicroseconds();
videoStartFrame();
+ videoSyncSplitScreen();
gfx_run((Gfx *)t->t.data_ptr);
videoEndFrame();
g_lastFrameUs = sysGetMicroseconds();
diff --git a/port/src/video.c b/port/src/video.c
index fd78341a..012a495d 100644
--- a/port/src/video.c
+++ b/port/src/video.c
@@ -950,6 +950,10 @@ static void videoHostExitIfRequested(void)
SDL_AtomicGet(&s_parked) ? "parked"
: (SDL_AtomicGet(&s_inFrame) ? "STILL IN FRAME (timeout)" : "idle"),
(unsigned)(SDL_GetTicks() - start));
+ {
+ extern void mempRedzoneCheck(const char *why);
+ mempRedzoneCheck("orderly quit");
+ }
exit(0);
}
@@ -979,6 +983,11 @@ void videoStartFrame(void)
}
/* D344: enter the frame first, then check for a quit (see above). */
SDL_AtomicSet(&s_inFrame, 1);
+ /* D464: GE_MEMPREDZONE periodic stage-bank red-zone check (cached env). */
+ if (frames % 60 == 59) {
+ extern void mempRedzoneCheck(const char *why);
+ mempRedzoneCheck("videoEndFrame/60");
+ }
if (SDL_AtomicGet(&s_quitReq)) {
videoRenderPark();
}
@@ -1082,7 +1091,7 @@ void videoPumpEvents(void)
break;
case SDL_CONTROLLERDEVICEADDED:
case SDL_CONTROLLERDEVICEREMOVED:
- inputRescanPads();
+ inputRequestRescan(); /* D450: the rescan runs on the pad-reading thread */
break;
case SDL_WINDOWEVENT:
if (ev.window.event == SDL_WINDOWEVENT_CLOSE) {
@@ -1116,11 +1125,26 @@ void videoPumpEvents(void)
inputApplyMouseRequests();
}
+/* D416: split-screen viewports are sub-rects; tell fast3d so its safe-area
+ * crop stays off. Called before every gfx_run (scheduler + videoSubmitCommands). */
+void videoSyncSplitScreen(void)
+{
+ {
+ extern s32 getPlayerCount(void);
+ extern s32 lvlGetCurrentStageToLoad(void);
+ static int lastSplit = -1;
+ int split = getPlayerCount() >= 2 && lvlGetCurrentStageToLoad() != LEVELID_TITLE;
+ if (split != lastSplit) { sysLogPrintf(LOG_NOTE, "D416 split=%d players=%d stage=%d", split, (int)getPlayerCount(), (int)lvlGetCurrentStageToLoad()); lastSplit = split; }
+ gfx_set_split_screen(split);
+ }
+}
+
void videoSubmitCommands(Gfx *cmds)
{
if (!initDone) {
return;
}
+ videoSyncSplitScreen();
gfx_run(cmds);
}
diff --git a/src/bondconstants.h b/src/bondconstants.h
index 63f1b0f9..d8de7ebc 100644
--- a/src/bondconstants.h
+++ b/src/bondconstants.h
@@ -3126,6 +3126,14 @@ enum CCRMLUT
//Canonical name and style "ai_destroyobj 2 : (def->obj == PROP_ELVIS_SAUCER)\n"
typedef enum PROP
{
+#ifdef PORT
+ /* D417 (porting-notes D3): getPropForHeldItem() returns -1 for items with
+ * no held model (fist), and player.c tests `prop >= 0`. With all-non-negative
+ * enumerators GCC makes this enum unsigned so -1 passed the test and
+ * modelLoad(-1) crashed in 2P. Never-used negative sentinel forces it signed;
+ * PROP_ALARM1 stays 0, sizeof stays 4, no stored value changes. */
+ PROP__PORT_SIGNED = -1,
+#endif
PROP_ALARM1, /* Beta Alarm / Default Multi Weapon */
PROP_ALARM2, /* Alarm */
PROP_EXPLOSIONBIT, /* White Pyramid (Explosion Bit) */
diff --git a/src/game/lv.c b/src/game/lv.c
index 8f6e11ef..c98b30af 100644
--- a/src/game/lv.c
+++ b/src/game/lv.c
@@ -2,6 +2,7 @@
#include
#ifdef PORT
#include
+#include /* D416 GE_STARTMP harness: strchr */
#endif
#include
#include
@@ -396,6 +397,31 @@ void lvlStageLoad(s32 stage)
* Not compiled without PORT; no effect unless the env var is set. */
{
const char *sm = getenv("GE_STARTMENU");
+ /* D416 harness: GE_STARTMP="[,]" starts a local
+ * multiplayer match with the menu's own default options (what
+ * MP options -> START does), so split-screen can be tested with no
+ * one at the menu. Needs GE_MPVIRT= for the controller count. */
+ const char *smp = getenv("GE_STARTMP");
+ if (smp && *smp) {
+ extern s32 MP_stage_selected;
+ extern struct mp_stage_setup multi_stage_setups[];
+ extern void init_mp_options_for_scenario(s32 numplayers);
+ extern s32 selected_num_players;
+ s32 np = (s32)strtol(smp, NULL, 0);
+ const char *comma = strchr(smp, ',');
+ if (np < 2) np = 2;
+ if (np > 4) np = 4;
+ gamemode = GAMEMODE_MULTI;
+ init_mp_options_for_scenario(np);
+ if (comma) MP_stage_selected = (s32)strtol(comma + 1, NULL, 0);
+ selected_stage = multi_stage_setups[MP_stage_selected].stage_id;
+ briefingpage = -1;
+ prev_keypresses = TRUE;
+ maybe_is_in_menu = TRUE;
+ menu_update = MENU_RUN_STAGE;
+ osSyncPrintf("GE_STARTMP: players=%d mpstage=%d stage_id=%d\n",
+ np, MP_stage_selected, selected_stage);
+ } else
if (sm && *sm) {
s32 want = (s32)strtol(sm, NULL, 0);
const char *pg = getenv("GE_STARTMENU_PAGE");
diff --git a/src/game/mpmenu.c b/src/game/mpmenu.c
index bcce9e28..642cd7cc 100644
--- a/src/game/mpmenu.c
+++ b/src/game/mpmenu.c
@@ -1065,7 +1065,16 @@ Gfx *mp_watch_menu_display(Gfx *gdl)
#else
s32 h2;
#endif
+#ifdef PORT
+ // D420 (D8 class): the decomp's [4] receives "Rank: 1st" (10 bytes incl.
+ // NUL) and "P KILLS"/"P LOSSES"; GCC places scores[] right after
+ // it, so the rank text overwrote the Scores page values ("1932599354" =
+ // ": 1s", "116" = 't'). Pure local stack scratch, zero ABI role; N64
+ // build keeps the decomp size.
+ char rankbuffer[64];
+#else
char rankbuffer[4];
+#endif
s32 two_player_x_offset;
char *text;
s32 scores[4];
diff --git a/src/memp.c b/src/memp.c
index b450f159..17353b89 100644
--- a/src/memp.c
+++ b/src/memp.c
@@ -175,7 +175,11 @@ void mempSetBankStarts(s32 poolSizes[MEMPOOL_COUNT+1])
}
+#ifdef PORT
+static void *mempAllocBytesInBankRaw(u32 bytes, u8 poolnum)
+#else
void *mempAllocBytesInBank(u32 bytes, u8 poolnum)
+#endif
{
/*
* Retain this address expression. Using
@@ -220,7 +224,11 @@ void *mempAllocBytesInBank(u32 bytes, u8 poolnum)
needmemallocation = TRUE;
+#ifdef PORT
+ return mempAllocBytesInBankRaw(bytes, MEMPOOL_PERMANENT);
+#else
return mempAllocBytesInBank(bytes, MEMPOOL_PERMANENT);
+#endif
}
nulled_mempLoopAllMemBanks();
@@ -237,6 +245,159 @@ void *mempAllocBytesInBank(u32 bytes, u8 poolnum)
}
+#ifdef PORT
+/* D464: GE_MEMPREDZONE=1 -- red-zone checker for the bump allocator.
+ * Every allocation that has room is padded with MEMP_RZ_SIZE trailing
+ * pattern bytes and recorded in a side table; mempRedzoneCheck() reports
+ * blocks whose trailer was scribbled (an overrun: N64-sized allocation
+ * indexed at the larger PC stride, the D461/D462 class). The returned
+ * pointer, 16-byte alignment and pool bookkeeping are otherwise unchanged
+ * (pos just advances RZ more, a multiple of 16). With the flag unset, the
+ * only cost is one cached branch per allocation. Game-thread design; the
+ * periodic check from the video hook can race a resize and report a
+ * transient false positive, so confirm a hit by re-running. */
+#include
+#include
+#include
+#include "envflag.h"
+#include "system.h"
+
+#define MEMP_RZ_SIZE 64
+#define MEMP_RZ_MAX 8192
+
+typedef struct MempRzEntry {
+ u8 *block;
+ u8 *rz;
+ u32 size;
+ u32 id;
+ void *caller;
+ u8 bank;
+ u8 reported;
+} MempRzEntry;
+
+static MempRzEntry s_rz[MEMP_RZ_MAX];
+static u32 s_rzCount;
+static u32 s_rzNextId;
+static u32 s_rzNoRoom; /* allocations that had no room for a red zone */
+static int s_rzFullSaid;
+
+static u8 mempRzPat(u32 i) { return (u8)(0xA5u + i * 13u); }
+
+static void mempRzFill(u8 *rz)
+{
+ u32 i;
+ for (i = 0; i < MEMP_RZ_SIZE; i++) rz[i] = mempRzPat(i);
+}
+
+static int mempRzBankOf(u8 *p)
+{
+ int i;
+ for (i = MEMPOOL_MF; i < MEMPOOL_COUNT; i++) {
+ if (g_mempPools[i].start != g_mempPools[i].end &&
+ p >= g_mempPools[i].start && p < g_mempPools[i].end) return i;
+ }
+ return -1;
+}
+
+static void mempRzRecord(u8 *block, u32 size, void *caller)
+{
+ MempRzEntry *e;
+ u32 id = s_rzNextId++;
+ if (s_rzCount >= MEMP_RZ_MAX) {
+ if (!s_rzFullSaid) {
+ s_rzFullSaid = 1;
+ sysLogPrintf(LOG_WARNING, "MEMPREDZONE: side table full (%d); later allocations are padded but not tracked", MEMP_RZ_MAX);
+ }
+ return;
+ }
+ e = &s_rz[s_rzCount];
+ e->block = block; e->rz = block + size; e->size = size; e->id = id;
+ e->caller = caller; e->bank = (u8)mempRzBankOf(block); e->reported = 0;
+ s_rzCount++;
+}
+
+static int mempRzFindLast(u8 *block)
+{
+ int i;
+ for (i = (int)s_rzCount - 1; i >= 0; i--) {
+ if (s_rz[i].block == block) return i;
+ }
+ return -1;
+}
+
+static void mempRzDropBank(u8 bank)
+{
+ u32 i, j = 0;
+ for (i = 0; i < s_rzCount; i++) {
+ if (s_rz[i].bank != bank) s_rz[j++] = s_rz[i];
+ }
+ s_rzCount = j;
+}
+
+void mempRedzoneCheck(const char *why)
+{
+ u32 i, k, nbad = 0;
+ if (!GE_ENVFLAG("GE_MEMPREDZONE")) return;
+ for (i = 0; i < s_rzCount; i++) {
+ MempRzEntry *e = &s_rz[i];
+ u32 first = 0xFFFFFFFFu, changed = 0;
+ if (e->reported) continue;
+ for (k = 0; k < MEMP_RZ_SIZE; k++) {
+ if (e->rz[k] != mempRzPat(k)) {
+ if (first == 0xFFFFFFFFu) first = k;
+ changed++;
+ }
+ }
+ if (changed) {
+ e->reported = 1;
+ nbad++;
+ sysLogPrintf(LOG_ERROR,
+ "MEMPREDZONE: HIT id=%u bank=%u size=%u block=%p rz_first_off=%u changed=%u/%d caller=%p (%s)",
+ e->id, (unsigned)e->bank, e->size, (void *)e->block, first, changed, MEMP_RZ_SIZE, e->caller,
+ why ? why : "?");
+ }
+ }
+ sysLogPrintf(LOG_INFO, "MEMPREDZONE: check '%s': %u tracked, %u new hit(s), %u untracked(no room)",
+ why ? why : "?", s_rzCount, nbad, s_rzNoRoom);
+}
+
+static void *mempAllocImpl(u32 bytes, u8 poolnum, void *caller)
+{
+ MemoryPool *pool = &g_mempPools[poolnum];
+ u8 *p;
+
+ if (!GE_ENVFLAG("GE_MEMPREDZONE")) {
+ return mempAllocBytesInBankRaw(bytes, poolnum);
+ }
+ if (pool->pos != NULL && pool->pos <= pool->end &&
+ pool->pos + bytes + MEMP_RZ_SIZE <= pool->end) {
+ p = (u8 *)mempAllocBytesInBankRaw(bytes + MEMP_RZ_SIZE, poolnum);
+ mempRzFill(p + bytes);
+ mempRzRecord(p, bytes, caller);
+ return p;
+ }
+ s_rzNoRoom++;
+ return mempAllocBytesInBankRaw(bytes, poolnum);
+}
+
+static void mempRzResized(u8 *block, u32 newsize, int idx)
+{
+ mempRzFill(block + newsize);
+ if (idx >= 0) {
+ s_rz[idx].size = newsize;
+ s_rz[idx].rz = block + newsize;
+ s_rz[idx].reported = 0;
+ } else {
+ mempRzRecord(block, newsize, __builtin_return_address(0));
+ }
+}
+
+__attribute__((noinline)) void *mempAllocBytesInBank(u32 bytes, u8 poolnum)
+{
+ return mempAllocImpl(bytes, poolnum, __builtin_return_address(0));
+}
+#endif
+
/**
* Resize the most recent allocation in a pool without moving it.
*/
@@ -245,6 +406,10 @@ MEMP_ADD_ENTRY_RESULT mempAddEntryOfSizeToBank(void *allocation, s32 newsize, u8
MemoryPool *pool;
s32 origsize;
s32 growsize;
+#ifdef PORT
+ int rzIdx = -1;
+ int rzNew = 0;
+#endif
if (needmemallocation && allocation == g_mempPools[MEMPOOL_PERMANENT].prevpos)
{
@@ -265,11 +430,27 @@ MEMP_ADD_ENTRY_RESULT mempAddEntryOfSizeToBank(void *allocation, s32 newsize, u8
}
origsize = pool->pos - pool->prevpos;
+#ifdef PORT
+ /* D464: a tracked block carries a trailer; keep it around the new size.
+ * An untracked block (whole-bank allocation, shrunk right after the
+ * load) gains one when the shrink leaves room for it. */
+ if (GE_ENVFLAG("GE_MEMPREDZONE")) {
+ rzIdx = mempRzFindLast((u8 *)allocation);
+ if (rzIdx >= 0 || (origsize >= newsize + MEMP_RZ_SIZE)) {
+ rzNew = 1;
+ }
+ }
+ growsize = newsize + (rzNew ? MEMP_RZ_SIZE : 0) - origsize;
+#else
growsize = newsize - origsize;
+#endif
if (growsize <= 0)
{
pool->pos += growsize;
+#ifdef PORT
+ if (rzNew) mempRzResized((u8 *)allocation, newsize, rzIdx);
+#endif
return MEMP_ADD_ENTRY_SUCCESS;
}
@@ -286,6 +467,9 @@ MEMP_ADD_ENTRY_RESULT mempAddEntryOfSizeToBank(void *allocation, s32 newsize, u8
}
pool->pos += growsize;
+#ifdef PORT
+ if (rzNew) mempRzResized((u8 *)allocation, newsize, rzIdx);
+#endif
return MEMP_ADD_ENTRY_SUCCESS;
}
@@ -311,19 +495,29 @@ s32 mempGetBankSizeLeft(u8 bank) {
// Last three bits contains the bank, the rest contains the size.
#ifdef PORT
-void *mempAllocPackedBytesInBank(u32 sizeandbank) { /* D453: was u32 */
+__attribute__((noinline)) void *mempAllocPackedBytesInBank(u32 sizeandbank) { /* D453: was u32 */
+ return mempAllocImpl((sizeandbank >> 3), (sizeandbank & 7), __builtin_return_address(0));
+}
#else
u32 mempAllocPackedBytesInBank(u32 sizeandbank) {
-#endif
return mempAllocBytesInBank((sizeandbank >> 3), (sizeandbank & 7));
}
+#endif
void mempResetBank(u8 bank) {
+#ifdef PORT
+ mempRedzoneCheck("mempResetBank");
+ mempRzDropBank(bank);
+#endif
g_mempPools[bank].prevpos = 0;
g_mempPools[bank].pos = g_mempPools[bank].start;
}
void mempNullNextEntryInBank(u8 bank) {
+#ifdef PORT
+ mempRedzoneCheck("mempNullNextEntryInBank");
+ mempRzDropBank(bank);
+#endif
nulled_mempLoopAllMemBanks();
if (g_mempPools[bank].pos != 0) {
g_mempPools[bank].pos = 0;
diff --git a/src/memp.h b/src/memp.h
index 6aa9cf85..f3c96de8 100644
--- a/src/memp.h
+++ b/src/memp.h
@@ -61,6 +61,9 @@ void *mempAllocPackedBytesInBank(u32 param_1);
#else
u32 mempAllocPackedBytesInBank(u32 param_1);
#endif
+#ifdef PORT
+void mempRedzoneCheck(const char *why); /* D464: GE_MEMPREDZONE */
+#endif
void mempResetBank(u8 bank);
void mempNullNextEntryInBank(u8 bank);
diff --git a/tools_pc/README.md b/tools_pc/README.md
index f0eef27f..efb4f23b 100644
--- a/tools_pc/README.md
+++ b/tools_pc/README.md
@@ -18,7 +18,7 @@ investigation artifacts.
| `d43_emit.py` | Offline N64→PC model-file converter → RZ sidecar + `manifest.csv` (D50 / Plan B). Regen after any model-format change. |
| `d43_convert.py` | Reference single-file model converter + full-512 layout/pointer validator (D43). |
| `d69_emit.py` | Offline converter for stage `bg/*.seg` + `Tbg_*_stanZ` → concatenated sidecar (D69/D78–D82). |
-| `d88_emit.py` | Offline converter for per-level `Usetup*Z` stage-setup files → appended to the `pccg.bin` sidecar (D88). |
+| `d88_emit.py` | Offline converter for per-level `Usetup*Z` (solo, 21) and `Ump_setup*Z` (multiplayer, 13; D416) stage-setup files → appended to the `pccg.bin` sidecar (D88). |
| `d88_propdefs.py` | The `propDefs` polymorphic-record stream N64→PC converter used by `d88_emit.py` (D88.4). |
| `d69_emit.py` / `d88_emit.py` / `d43_emit.py` | Run all three (+ `d88_propdefs`) to rebuild `data/` sidecars from the ROM. |
diff --git a/tools_pc/d125_check.py b/tools_pc/d125_check.py
index acf6fbfe..1fce40b4 100644
--- a/tools_pc/d125_check.py
+++ b/tools_pc/d125_check.py
@@ -21,7 +21,7 @@ for r in csv.reader(open("scripts/filelist.u.csv")):
man = {}
for r in csv.reader(open(f"data/pccg-{REGION}/manifest.csv")):
- if r and r[0].startswith("Usetup"):
+ if r and (r[0].startswith("Usetup") or r[0].startswith("Ump_setup")):
man[r[0]] = (int(r[1]), int(r[2]))
sidebin = open(f"data/pccg-{REGION}/pccg.bin", "rb").read()
diff --git a/tools_pc/d88_emit.py b/tools_pc/d88_emit.py
index b86ffefd..60b5e7c1 100644
--- a/tools_pc/d88_emit.py
+++ b/tools_pc/d88_emit.py
@@ -494,8 +494,11 @@ for line in open(TABLE):
if m:
table_names.append(m.group(1))
-usetup_names = sorted(set(n for n in table_names
- if n.startswith("Usetup") and n.endswith("Z")))
+def is_setup_name(n):
+ # Solo "Usetup*Z" and multiplayer "Ump_setup*Z" share the setup format.
+ return (n.startswith("Usetup") or n.startswith("Ump_setup")) and n.endswith("Z")
+
+usetup_names = sorted(set(n for n in table_names if is_setup_name(n)))
manifest = []
chunks = []
@@ -505,7 +508,7 @@ os.makedirs(OUT_DIR, exist_ok=True)
bin_path = os.path.join(OUT_DIR, "pccg.bin")
man_path = os.path.join(OUT_DIR, "manifest.csv")
def is_usetup_row(nm):
- return nm.startswith("Usetup") and nm.endswith("Z")
+ return is_setup_name(nm)
def emit(name, data):
global cur_off
@@ -573,7 +576,7 @@ for name in usetup_names:
emit(name, comp_out)
n_ok += 1
-print(f"converted: {n_ok}/{len(usetup_names)} Usetup*Z files")
+print(f"converted: {n_ok}/{len(usetup_names)} setup*Z files (solo + MP)")
if errors:
print(f"\n{len(errors)} ERRORS:")
diff --git a/tools_pc/d88_propdef_scan.py b/tools_pc/d88_propdef_scan.py
index 4bbf35ca..7f4225fa 100644
--- a/tools_pc/d88_propdef_scan.py
+++ b/tools_pc/d88_propdef_scan.py
@@ -76,7 +76,7 @@ SIZEOF_N64 = {
from collections import Counter
for name, (addr, size) in sorted(fl_by_base.items()):
- if not (name.startswith("Usetup") and name.endswith("Z")) or size == 0:
+ if not ((name.startswith("Usetup") or name.startswith("Ump_setup")) and name.endswith("Z")) or size == 0:
continue
comp = rom[addr:addr + size]
if comp[:2] != b"\x11\x72":
diff --git a/tools_pc/d88_propdefs.py b/tools_pc/d88_propdefs.py
index 55d2378b..f9b446b5 100644
--- a/tools_pc/d88_propdefs.py
+++ b/tools_pc/d88_propdefs.py
@@ -409,7 +409,7 @@ if __name__ == "__main__":
b = r[2].rsplit("/", 1)[-1]
b = b[:-4] if b.endswith(".bin") else b
fl[b] = (int(r[0]), int(r[1]))
- names = sorted(n for n in fl if n.startswith("Usetup") and n.endswith("Z"))
+ names = sorted(n for n in fl if (n.startswith("Usetup") or n.startswith("Ump_setup")) and n.endswith("Z"))
fails = 0
for nm in names:
a, s = fl[nm]
diff --git a/tools_pc/dist/prepare-assets/prepare-assets.py b/tools_pc/dist/prepare-assets/prepare-assets.py
index 5ae176e1..f8ca028c 100644
--- a/tools_pc/dist/prepare-assets/prepare-assets.py
+++ b/tools_pc/dist/prepare-assets/prepare-assets.py
@@ -51,7 +51,7 @@ ROM_SHA1 = {
}
# (script, args, produces) — run in order. d88 has no output of its own: it
-# appends the 21 per-level stage-setup files to the pccg dir d69 just made,
+# appends the 21 solo + 13 multiplayer stage-setup files to the pccg dir d69 just made,
# and --regen makes that idempotent.
EMIT_STEPS = (
("d43_emit.py", (), "pcmodels-{region}/pcmodels.bin"),