D489-D502: settings persistence, collision, A/B harnesses, D441 census

F10 pad slider persistence (D489), glass collision hull (D490), A/B input
harnesses (1964 virtual pad, GE_INPUTSCRIPT, live one-pad launcher), adjacent
global struct reads (D491), vibration percent, D441 complete pointer-width
census with high-arena tier-1 build, fix batches 1-5 and an ABI warning
ratchet, playtest verification of D493-D502.

Refs: D489 D490 D491 D492-D502 D441
This commit is contained in:
James Dansereau committed 2026-10-06 18:35:42 -04:00
1 parent 4b38c2566e
commit daa2522511
63 files changed
+2693 -1419

No files matched your search

+130
View File
@@ -0,0 +1,130 @@
# abpad — 1964 vs PC port A/B input harness
One millisecond timeline drives both sides of a faithful-vs-port comparison:
- **1964** reads a ViGEmBus virtual Xbox 360 pad (`vgamepad`) through NRage
2.3c in XInput mode. abpad reads NRage's own mapping file and presses
whatever Xbox input your profile maps to each N64 input, so you can keep
your normal play profile.
- **The port** is launched with the same timeline converted to its own
`GE_INPUTSCRIPT` (exact N64 bits, works unfocused, reproducible). It does
not read the virtual pad. Its default pad layout (Jinx 1.1) maps buttons
by context: in a level Y = N64 A and pad A = N64 B, while in menus A/X =
N64 A. A single NRage mapping can't follow that switch.
Runs are **not frame-identical**: 1964 has no seed pin or frame dump, and the
port's script counts controller reads, not milliseconds. Use it to compare
timing and feel (fire rates, AI reactions, menus), not pixels.
## One-time setup
1. ViGEmBus 1.22 installed (it is: Sunshine's driver). `pip install vgamepad`
is done. Note that a plain `pip install vgamepad` runs its bundled
ViGEmBus 1.17 MSI if it doesn't recognise the installed bus. On
2026-10-02 that downgraded the bus and had to be reverted.
2. 1964 → Plugins → Input = `NRage_Input_V2.dll` (2.3c), Controller 1 with
**XInput** ticked. Any button layout works. abpad inverts
`plugin/XInput Controller 1 Config.xcc` (`--nrage-dir`, default the
`ge-port-reference` 1964 copy) and errors if a timeline needs an N64
input that the profile leaves unmapped. File format, from NRage's
`XInputController.cpp` ([libertyernie/nrage-input](https://github.com/libertyernie/nrage-input)):
- Button keys (`A=`, `Z=`, `DUp=`, ...) are XInput button bitmasks.
- `LeftTrigger=`/`RightTrigger=` are N64 button codes (Z = 0x20,
L = 0x2000, ...).
- Each `*Axis=` is either the N64 analog axis (0x4000 X, 0x8000 Y) or
`(positive-direction code << 16) | negative-direction code` for a
stick used as C or D buttons.
3. **The virtual pad must be XInput slot 0.** NRage reads N64 controller 1
from XInput slot 0 only, and a real pad that is already connected holds
it. Switch the real pad off, or unplug it, before a run. abpad probes
slot 0 at startup and refuses to run otherwise.
4. Stick range is handled automatically. The port's script deflects the
stick to ±80. abpad computes the thumb value that NRage turns into 80
after its `PadDeadZone` (21248 at the default 5%); `--stick` changes the
target.
5. `1964.cfg`: `PauseWhenInactive 0` (set 2026-10-02). Only one window
has focus, and 1964 pauses when it's in the background.
## Timeline
`<ms>:<tok>[,<tok>...]` entries separated by `;` or newlines; `#` starts a
comment. Times are from harness start.
| Tokens | Meaning |
|---|---|
| `A B Z START L R UP DOWN LEFT RIGHT CUP CDOWN CLEFT CRIGHT` | 50 ms press (the port's `INPUTSCRIPT_PULSE_MS`) |
| `SUP SDOWN SLEFT SRIGHT` (combinable) / `SNONE` | analog stick, held until changed / recentre |
| `ZHOLD` / `ZREL` | hold / release fire |
| `RHOLD` / `LHOLD` / `RREL` | hold R or L (one aim hold) / release |
Port-only `GE_INPUTSCRIPT` tokens (`MDX`/`MDY`, `CHOLD`, `UHOLD`,
`RELOADHOLD`, ...) are rejected. The port caps a script at 64 entries.
## Running
The full side-by-side run (PowerShell; the real pad must be off):
```powershell
tools_pc\abpad\run_ab.ps1 -Timeline "19000:START;25000:SRIGHT;25300:SNONE;27000:SLEFT;27300:SNONE;29000:A" `
-QuitFrame 2400 -Seconds 36 -Step 0.5
python tools_pc\abpad\sheet.py $env:TEMP\abpad-run 16 36 1 # side-by-side contact sheet
```
`run_ab.ps1` starts abpad (which launches the port), then 1964 with
`-c NRage_Input_V2.dll -g <Rom>` 1.2 s later, and screenshots both windows
every `-Step` seconds on one clock (`capture.ps1`, DPI-aware PrintWindow, so
overlapping windows are fine). 1964's `-g` takes a file name inside its
configured ROM directory and can't contain spaces, so keep a copy such as
`GoldenEyeUSA.n64` there. The port exits by itself through `GE_QUITFRAME`;
the script closes 1964 normally.
abpad on its own:
```sh
python tools_pc/abpad/abpad.py t.txt --print-script # the port's GE_INPUTSCRIPT
python tools_pc/abpad/abpad.py t.txt --quitframe 2400 # both sides
python tools_pc/abpad/abpad.py t.txt --no-port # 1964 only
python tools_pc/abpad/abpad.py t.txt --no-pad # port only
```
- **Port timing:** abpad sets `GE_INPUTSCRIPT_MS=1`, so the port reads entry
times as milliseconds since its SDL init instead of controller reads. Read
rates vary through the intro and menus, so read counts can't be lined up
with 1964. `--reads-per-ms` converts to a read-count script instead, for
exes that don't have the millisecond mode.
- **1964 lags by ~2 s** (measured 2026-10-02): the 1.2 s launch gap plus
1964's slower startup. `run_ab.ps1` defaults `-PadOffset 2000`, which
delivers each input at the same game state on both sides. The screenshots
then show 1964 about 2 s behind the port throughout.
- **Menus use the analog stick** (`SUP`/`SDOWN`/`SLEFT`/`SRIGHT` + `SNONE`),
not the D-pad.
- The port is `build-pc/ge007.x86_64.exe`, launched from the repo root, so
it uses the repo's `data/` (`SkipIntro = 0`). `build-pc/data` has the
maintainer's play settings and skips the intro.
Verified 2026-10-02: START during the gun-barrel dots skipped to the title,
file select came up, stick right and left moved the folder cursor, and A
opened the folder. Both sides went through the same sequence.
## Checks before trusting a run
1. With `--no-port` and a long `0:ZHOLD` timeline, NRage's XInput dialog
(or 1964 firing in a level) should show the profile's Z input held.
2. The port side needs no pad check. `GE_INPUTSCRIPT` is the port's sole
controller-0 source when set, focused or not.
3. The two sides use different save files (the port's `data/ge007.eep`,
1964's `save/`), so file-select contents can differ.
## Live A/B (one real pad)
`live_ab.ps1` launches 1964 GEPD and the port side by side at 1280x720 each (1964: GLideN64 windowed size; port: `data/ge007.ini` [Window]; the script only moves windows, never resizes), driven by ONE
real gamepad: the port reads it unfocused (`GE_PAD_BACKGROUND=1`), 1964 gets
it on click. 1964 starts ~0.8 s slower, so it launches first and the port
follows after `-PortDelayMs` (tune if the intros drift).
```
powershell -ExecutionPolicy Bypass -File tools_pc\abpad\live_ab.ps1 [-PortDelayMs 800] [-NoArrange]
```
The real pad must be ON (no virtual pad), and the NRage profile should match
the port's pad layout. `-DryRun` prints the launch lines without launching.
+390
View File
@@ -0,0 +1,390 @@
#!/usr/bin/env python3
"""A/B fidelity harness: one millisecond timeline drives 1964 and the PC port.
1964 gets the input from a ViGEmBus virtual Xbox 360 pad (vgamepad), read by
NRage 2.3c in XInput mode. The harness reads NRage's own mapping file
(`plugin/XInput Controller 1 Config.xcc`) and presses whatever Xbox input
that profile maps to each N64 input, so any profile works. The port does
NOT read that pad: its default Jinx 1.1 pad layout is context-dependent (in a
level Y = N64 A, pad A = N64 B; in menus A/X = N64 A), so no single NRage
mapping could line up with it. The same timeline is converted to the port's
own GE_INPUTSCRIPT (exact N64 bits, focus-independent) and the port is
launched with it.
Timeline: `<ms>:<tok>[,<tok>...];...` (also one entry per line; `#` comments).
Tokens are the subset of GE_INPUTSCRIPT that 1964 can reproduce:
buttons (pulsed 50 ms = the port's INPUTSCRIPT_PULSE_MS):
A B Z START L R UP DOWN LEFT RIGHT CUP CDOWN CLEFT CRIGHT
analog stick (sustained until changed): SUP SDOWN SLEFT SRIGHT SNONE
holds: ZHOLD ZREL (fire), RHOLD LHOLD RREL (one aim hold: R or L),
CUPHOLD CDOWNHOLD CLEFTHOLD CRIGHTHOLD (combinable) / CNONE (C buttons)
Port-only tokens (MDX/MDY, CHOLD/CREL, UHOLD/UREL, RELOADHOLD/RELOADREL)
are rejected: 1964 has no equivalent.
Examples:
python tools_pc/abpad/abpad.py t.txt --print-script
python tools_pc/abpad/abpad.py t.txt --quitframe 3600
python tools_pc/abpad/abpad.py "2000:START;4000:A" --no-port
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
DEFAULT_NRAGE = ROOT.parent / "ge-port-reference" / "1964_GEPD_Edition" / "1964" / "plugin"
PULSE_MS = 50 # port: INPUTSCRIPT_PULSE_MS (GE_INPUTSCRIPT_MS mode)
PORT_SCRIPT_MAX = 64 # port: INPUTSCRIPT_MAX
TICK_HZ = 240 # pad update loop rate
BUTTONS = ("A", "B", "Z", "START", "L", "R", "UP", "DOWN", "LEFT", "RIGHT",
"CUP", "CDOWN", "CLEFT", "CRIGHT")
STICK = {"SUP": (0, 1), "SDOWN": (0, -1), "SLEFT": (-1, 0), "SRIGHT": (1, 0)}
HOLDS = ("ZHOLD", "ZREL", "RHOLD", "LHOLD", "RREL")
# C-button holds (the port forces 1.2 SOLITARE: C-up/down walk, C-left/right strafe)
CHOLDS = {"CUPHOLD": "CUP", "CDOWNHOLD": "CDOWN", "CLEFTHOLD": "CLEFT", "CRIGHTHOLD": "CRIGHT"}
PORT_ONLY = ("CHOLD", "CREL", "UHOLD", "UREL", "RELOADHOLD", "RELOADREL")
@dataclass
class Entry:
ms: int
toks: list[str]
buttons: set[str] = field(default_factory=set)
stick: tuple[int, int] | None = None # (x, y) in -1..1, None = unchanged
zhold: bool | None = None
aim: int | None = None # 0 none, 1 R, 2 L
chold: frozenset | None = None # held C buttons, None = unchanged
def parse_timeline(text: str) -> list[Entry]:
entries: list[Entry] = []
raw = []
for line in text.splitlines():
line = line.split("#", 1)[0]
raw.extend(p for p in line.split(";") if p.strip())
for part in raw:
if ":" not in part:
raise SystemExit(f"abpad: entry without ':' -> {part.strip()!r}")
ms_s, toks_s = part.split(":", 1)
try:
ms = int(ms_s.strip())
except ValueError:
raise SystemExit(f"abpad: bad ms {ms_s.strip()!r}")
toks = [t.strip().upper() for t in toks_s.split(",") if t.strip()]
e = Entry(ms, toks)
sx = sy = 0
has_stick = False
for t in toks:
if t in BUTTONS:
e.buttons.add(t)
elif t in STICK:
dx, dy = STICK[t]
sx, sy = sx or dx, sy or dy
has_stick = True
elif t == "SNONE":
has_stick = True
elif t == "ZHOLD":
e.zhold = True
elif t == "ZREL":
e.zhold = False
elif t in CHOLDS:
e.chold = (e.chold or frozenset()) | {CHOLDS[t]}
elif t == "CNONE":
e.chold = frozenset()
elif t in ("RHOLD", "LHOLD", "RREL"):
e.aim = {"RHOLD": 1, "LHOLD": 2, "RREL": 0}[t]
elif t in PORT_ONLY or t.startswith(("MDX", "MDY")):
raise SystemExit(f"abpad: {t} is port-only (no 1964 equivalent)")
else:
raise SystemExit(f"abpad: unknown token {t!r}")
if has_stick:
e.stick = (sx, sy)
entries.append(e)
entries.sort(key=lambda e: e.ms)
return entries
def to_inputscript(entries: list[Entry], reads_per_ms: float | None, offset_ms: int) -> str:
"""reads_per_ms None = GE_INPUTSCRIPT_MS mode (times stay in ms)."""
if len(entries) > PORT_SCRIPT_MAX:
raise SystemExit(f"abpad: {len(entries)} entries > port limit {PORT_SCRIPT_MAX}")
out, last = [], -1
for e in entries:
t = e.ms + offset_ms
frame = max(0, round(t if reads_per_ms is None else t * reads_per_ms))
if frame <= last: # two entries collapsed onto one read
frame = last + 1
last = frame
out.append(f"{frame}:{','.join(e.toks)}")
return ";".join(out)
def pad_state(entries: list[Entry], t_ms: float):
"""(buttons, stick, zhold, aim, chold) at t_ms, mirroring the port's script semantics."""
buttons: set[str] = set()
stick, zhold, aim, chold = (0, 0), False, 0, frozenset()
for e in entries:
if e.ms > t_ms:
break
if t_ms < e.ms + PULSE_MS:
buttons |= e.buttons
if e.stick is not None:
stick = e.stick
if e.zhold is not None:
zhold = e.zhold
if e.aim is not None:
aim = e.aim
if e.chold is not None:
chold = e.chold
return buttons, stick, zhold, aim, chold
# NRage 2.3c XInputController.h N64_BUTTONS codes (libertyernie/nrage-input).
N64_CODE = {"A": 0x0080, "B": 0x0040, "Z": 0x0020, "R": 0x1000, "L": 0x2000,
"START": 0x0010, "UP": 0x0008, "DOWN": 0x0004, "LEFT": 0x0002,
"RIGHT": 0x0001, "CUP": 0x0800, "CDOWN": 0x0400, "CLEFT": 0x0200,
"CRIGHT": 0x0100}
N64_XAXIS, N64_YAXIS = 0x4000, 0x8000
XCC_BUTTON_KEY = {"A": "A", "B": "B", "Z": "Z", "R": "R", "L": "L", "START": "Start",
"UP": "DUp", "DOWN": "DDown", "LEFT": "DLeft", "RIGHT": "DRight",
"CUP": "CUp", "CDOWN": "CDown", "CLEFT": "CLeft", "CRIGHT": "CRight"}
XCC_AXES = {"LeftXAxis": ("L", 0), "LeftYAxis": ("L", 1),
"RightXAxis": ("R", 0), "RightYAxis": ("R", 1)}
def load_nrage(plugin_dir: Path):
"""Invert NRage's XInput mapping: N64 input -> the Xbox input that makes it.
Returns (sources, stick_axes, deadzone_pct). sources[name] is one of
("btn", xusb_bit), ("trig", "L"|"R"), ("axis", "L"|"R", axis, +1|-1).
stick_axes = {0: (stick, axis) driving N64 X, 1: ... N64 Y}.
"""
xcc = plugin_dir / "XInput Controller 1 Config.xcc"
if not xcc.is_file():
raise SystemExit(f"abpad: no NRage XInput config at {xcc} (--nrage-dir)")
cfg = {}
for line in xcc.read_text(encoding="ascii", errors="replace").splitlines():
if "=" in line:
k, v = line.split("=", 1)
cfg[k.strip()] = int(v.strip() or 0)
sources, stick_axes = {}, {}
for name, code in N64_CODE.items():
mask = cfg.get(XCC_BUTTON_KEY[name], 0)
if mask:
sources[name] = ("btn", mask & -mask) # lowest bound Xbox button
elif cfg.get("LeftTrigger") == code:
sources[name] = ("trig", "L")
elif cfg.get("RightTrigger") == code:
sources[name] = ("trig", "R")
else:
for key, (stick, axis) in XCC_AXES.items():
v = cfg.get(key, 0)
if v in (N64_XAXIS, N64_YAXIS):
continue
if (v >> 16) & code: # positive deflection
sources[name] = ("axis", stick, axis, +1)
break
if v & 0xFFFF & code: # negative deflection
sources[name] = ("axis", stick, axis, -1)
break
for key, (stick, axis) in XCC_AXES.items():
if cfg.get(key) == N64_XAXIS:
stick_axes.setdefault(0, (stick, axis))
elif cfg.get(key) == N64_YAXIS:
stick_axes.setdefault(1, (stick, axis))
deadzone = 0
ini = plugin_dir / "NRage.ini"
if ini.is_file():
sect = None
for line in ini.read_text(encoding="ascii", errors="replace").splitlines():
line = line.strip()
if line.startswith("["):
sect = line
elif sect == "[Controller 1]" and line.startswith("PadDeadZone="):
deadzone = int(line.split("=", 1)[1])
return sources, stick_axes, deadzone
def nrage_stick_value(n64: int, deadzone_pct: int) -> int:
"""Smallest XInput thumb value NRage turns into n64 (AxisDeadzone, then *127/32767)."""
if n64 <= 0:
return 0
dz = deadzone_pct * 32767 // 100
rel = 32767.0 / (32767 - dz) if dz else 1.0
for raw in range(1, 32768):
v = raw
if dz:
v = 0 if raw < dz else min(32767.0, (raw - dz) * rel)
if int(v) * 127 // 32767 >= n64:
return raw
return 32767
def xinput_slot0_rx() -> int | None:
import ctypes
class GP(ctypes.Structure):
_fields_ = [("w", ctypes.c_ushort), ("lt", ctypes.c_ubyte), ("rt", ctypes.c_ubyte),
("lx", ctypes.c_short), ("ly", ctypes.c_short),
("rx", ctypes.c_short), ("ry", ctypes.c_short)]
class ST(ctypes.Structure):
_fields_ = [("pkt", ctypes.c_uint), ("gp", GP)]
st = ST()
if ctypes.WinDLL("xinput1_4").XInputGetState(0, ctypes.byref(st)) != 0:
return None
return st.gp.rx
def run_pad(entries: list[Entry], t0: float, offset_ms: int, linger_ms: int,
plugin_dir: Path, stick_n64: int) -> None:
import vgamepad as vg # imported late so --print-script works without it
sources, stick_axes, deadzone = load_nrage(plugin_dir)
used = set()
for e in entries:
used |= e.buttons
if e.zhold:
used.add("Z")
if e.aim:
used.add("R" if e.aim == 1 else "L")
if e.chold:
used |= e.chold
missing = sorted(n for n in used if n not in sources)
if missing:
raise SystemExit(f"abpad: the NRage profile maps nothing to {', '.join(missing)}")
if any(e.stick not in (None, (0, 0)) for e in entries) and len(stick_axes) < 2:
raise SystemExit("abpad: the NRage profile maps no Xbox stick to the N64 analog stick")
full = nrage_stick_value(stick_n64, deadzone)
pad = vg.VX360Gamepad()
# NRage reads N64 controller 1 from XInput slot 0. Probe with an
# odd right-stick X value inside NRage's deadzone (no N64 effect) to
# confirm the virtual pad got slot 0 rather than a real pad.
probe = 777
# A new pad takes ~0.3 s to enumerate and drops reports sent before that
# (it then reads back uninitialised stick values), so keep re-sending.
deadline = time.perf_counter() + 3.0
slot0 = None
while time.perf_counter() < deadline and slot0 != probe:
pad.right_joystick(x_value=probe, y_value=0)
pad.update()
time.sleep(0.05)
slot0 = xinput_slot0_rx()
pad.reset()
pad.update()
if slot0 != probe:
del pad
raise SystemExit("abpad: the virtual pad is not XInput slot 0 (a real pad holds it). "
"NRage reads N64 controller 1 from slot 0: disconnect the real pad "
"(or start abpad before plugging it in) and retry.")
end_ms = (entries[-1].ms + PULSE_MS if entries else 0) + linger_ms
prev, last_send = None, 0.0
try:
while True:
t_ms = (time.perf_counter() - t0) * 1000.0 - offset_ms
if t_ms > end_ms:
break
buttons, stick, zhold, aim, chold = pad_state(entries, t_ms)
state = (frozenset(buttons), stick, zhold, aim, chold)
now = time.perf_counter()
if state != prev or now - last_send > 0.1: # periodic resend: dropped reports
held = set(buttons) | chold
if zhold:
held.add("Z")
if aim:
held.add("R" if aim == 1 else "L")
wbuttons, trig = 0, {"L": 0, "R": 0}
axes = {("L", 0): 0, ("L", 1): 0, ("R", 0): 0, ("R", 1): 0}
for i, d in enumerate(stick):
if d:
axes[stick_axes[i]] = d * full
for n in held:
src = sources[n]
if src[0] == "btn":
wbuttons |= src[1]
elif src[0] == "trig":
trig[src[1]] = 255
else:
axes[(src[1], src[2])] = src[3] * 32767
pad.report.wButtons = wbuttons
pad.left_trigger(value=trig["L"])
pad.right_trigger(value=trig["R"])
pad.left_joystick(x_value=axes[("L", 0)], y_value=axes[("L", 1)])
pad.right_joystick(x_value=axes[("R", 0)], y_value=axes[("R", 1)])
pad.update()
prev, last_send = state, now
time.sleep(1.0 / TICK_HZ)
finally:
pad.reset()
pad.update()
del pad
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("timeline", help="timeline file, or an inline timeline string")
ap.add_argument("--print-script", action="store_true",
help="print the port GE_INPUTSCRIPT and exit")
ap.add_argument("--no-port", action="store_true", help="drive the pad (1964) only")
ap.add_argument("--no-pad", action="store_true", help="launch the port only")
ap.add_argument("--port-exe", default=str(ROOT / "build-pc" / "ge007.x86_64.exe"))
ap.add_argument("--port-cwd", default=None, help="default: the exe's directory")
ap.add_argument("--port-offset-ms", type=int, default=0,
help="shift the port's timeline (e.g. to absorb a boot-time difference)")
ap.add_argument("--pad-offset-ms", type=int, default=0,
help="shift the 1964 pad timeline")
ap.add_argument("--reads-per-ms", type=float, default=None,
help="convert to the port's read-count script instead of GE_INPUTSCRIPT_MS "
"(older exes; ~0.12 in a level, lower in the intro)")
ap.add_argument("--quitframe", type=int, default=None,
help="GE_QUITFRAME for the port (always set one for unattended runs)")
ap.add_argument("--nrage-dir", default=str(DEFAULT_NRAGE),
help="1964 plugin dir holding NRage.ini and 'XInput Controller 1 Config.xcc'")
ap.add_argument("--stick", type=int, default=80,
help="N64 stick deflection for SUP/SDOWN/SLEFT/SRIGHT (the port script uses 80)")
ap.add_argument("--linger-ms", type=int, default=1000,
help="keep the virtual pad plugged this long after the last entry")
args = ap.parse_args()
p = Path(args.timeline)
text = p.read_text(encoding="utf-8") if p.is_file() else args.timeline
entries = parse_timeline(text)
if not entries:
raise SystemExit("abpad: empty timeline")
script = to_inputscript(entries, args.reads_per_ms, args.port_offset_ms)
if args.print_script:
print(script)
return 0
proc = None
t0 = time.perf_counter()
if not args.no_port:
env = dict(os.environ, GE_INPUTSCRIPT=script)
if args.reads_per_ms is None:
env["GE_INPUTSCRIPT_MS"] = "1" # port times are ms since its SDL init
if args.quitframe is not None:
env["GE_QUITFRAME"] = str(args.quitframe)
exe = Path(args.port_exe)
proc = subprocess.Popen([str(exe)], cwd=args.port_cwd or str(exe.parent), env=env)
print(f"abpad: port pid {proc.pid}, GE_INPUTSCRIPT={script}")
if not args.no_pad:
print(f"abpad: driving virtual pad, {len(entries)} entries, last at {entries[-1].ms} ms")
run_pad(entries, t0, args.pad_offset_ms, args.linger_ms,
Path(args.nrage_dir), args.stick)
print("abpad: pad timeline done")
if proc is not None:
return proc.wait()
return 0
if __name__ == "__main__":
sys.exit(main())
+45
View File
@@ -0,0 +1,45 @@
param([string]$OutDir, [int]$Seconds = 24, [double]$Step = 1.0)
Add-Type -AssemblyName System.Drawing
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class W {
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
[DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr hdc, uint flags);
}
"@
[W]::SetProcessDPIAware() | Out-Null
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
$t0 = Get-Date
$log = Join-Path $OutDir "cap.log"
"start $($t0.ToString('HH:mm:ss.fff'))" | Out-File $log -Encoding ascii
function Find-Win($name) {
$p = Get-Process $name -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne [IntPtr]::Zero } | Select-Object -First 1
if ($p) { return $p.MainWindowHandle } else { return [IntPtr]::Zero }
}
function Snap($h, $path) {
if ($h -eq [IntPtr]::Zero) { return $false }
$r = New-Object W+RECT; [W]::GetClientRect($h, [ref]$r) | Out-Null
$w = $r.R - $r.L; $hh = $r.B - $r.T
if ($w -le 0 -or $hh -le 0) { return $false }
$bmp = New-Object System.Drawing.Bitmap $w, $hh
$g = [System.Drawing.Graphics]::FromImage($bmp)
$hdc = $g.GetHdc()
[W]::PrintWindow($h, $hdc, 3) | Out-Null # PW_CLIENTONLY | PW_RENDERFULLCONTENT
$g.ReleaseHdc($hdc)
$bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
return $true
}
$n = [int]([math]::Floor($Seconds / $Step))
for ($i = 1; $i -le $n; $i++) {
$t = $i * $Step
while (((Get-Date) - $t0).TotalSeconds -lt $t) { Start-Sleep -Milliseconds 5 }
$tag = "{0:D5}" -f [int]($t * 1000)
$a = Snap (Find-Win '1964') (Join-Path $OutDir "1964_$tag.png")
$b = Snap (Find-Win 'ge007.x86_64') (Join-Path $OutDir "port_$tag.png")
"t=$t 1964=$a port=$b" | Out-File $log -Append -Encoding ascii
}
"done" | Out-File $log -Append -Encoding ascii
+87
View File
@@ -0,0 +1,87 @@
# Live side-by-side A/B: 1964 GEPD (left) and the GE007 PC port (right), both
# driven by ONE real gamepad. The port reads the pad even unfocused via
# GE_PAD_BACKGROUND=1; 1964 gets focus when you click it (PauseWhenInactive 0
# is already set in 1964.cfg).
#
# 1964 starts ~0.8 s slower than the port (measured 2026-10-02: 1964 launched
# 1.2 s after the port ran ~2 s behind), so 1964 launches first and the port
# PortDelayMs later. Tune -PortDelayMs if the intros drift.
#
# The real pad must be ON (no virtual pad / abpad). The NRage profile should
# match the port's pad layout. Does not wait for the games to exit.
param([int]$PortDelayMs = 800,
[string]$Emu = (Join-Path $PSScriptRoot '..\..\..\ge-port-reference\1964_GEPD_Edition\1964'),
[string]$Rom = 'GoldenEyeUSA.n64',
[string]$PortExe = (Join-Path $PSScriptRoot '..\..\build-pc\ge007.x86_64.exe'),
[int]$HoldSec = 30, # keep re-applying the positions this long (both games move themselves at startup)
[switch]$NoArrange, [switch]$DryRun)
$repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$emu = (Resolve-Path $Emu).Path
$PortExe = (Resolve-Path $PortExe).Path
if (-not (Test-Path "$emu\1964.exe")) { Write-Error "1964 not found: $emu\1964.exe"; exit 1 }
if (-not (Test-Path $PortExe)) { Write-Error "port exe not found: $PortExe"; exit 1 }
$emuLine = "Start-Process -FilePath `"$emu\1964.exe`" -ArgumentList @('-c','NRage_Input_V2.dll','-g','$Rom') -WorkingDirectory '$emu'"
$portLine = "Start-Process -FilePath '$PortExe' -WorkingDirectory '$repo' (env GE_PAD_BACKGROUND=1)"
if ($DryRun) {
Write-Output "1964 : $emuLine"
Write-Output "port : $portLine"
Write-Output "delay: port launches ${PortDelayMs} ms after 1964"
Write-Output "arrange: $(if ($NoArrange) {'off'} else {'on'})"
exit 0
}
$emuProc = Start-Process -FilePath "$emu\1964.exe" -ArgumentList @('-c','NRage_Input_V2.dll','-g',$Rom) `
-WorkingDirectory $emu -PassThru
Start-Sleep -Milliseconds $PortDelayMs
$env:GE_PAD_BACKGROUND = '1'
$portProc = Start-Process -FilePath $PortExe -WorkingDirectory $repo -PassThru
# Wait up to 15 s (200 ms polls) for both windows to appear.
$deadline = (Get-Date).AddSeconds(15)
do {
$emuProc.Refresh(); $portProc.Refresh()
Start-Sleep -Milliseconds 200
} while (((($emuProc.MainWindowHandle -eq 0) -or ($portProc.MainWindowHandle -eq 0)) -and (Get-Date) -lt $deadline))
$emuProc.Refresh(); $portProc.Refresh()
if (($emuProc.MainWindowHandle -eq 0) -or ($portProc.MainWindowHandle -eq 0)) {
Write-Warning "one or both windows not up after 15 s; continuing"
}
if (-not $NoArrange) {
Add-Type -AssemblyName System.Windows.Forms
Add-Type -Namespace Win32 -Name User32 -MemberDefinition @'
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int X, int Y, int W, int H, uint flags);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT r);
'@
# Position only, never resize: GLideN64 renders 1964 at its own windowed
# size (plugin/GLideN64.ini video\windowedWidth/Height = 1280x720) and draws
# into a corner if the window is resized; the port takes its size from
# data/ge007.ini [Window] (1280x720). 1964 goes top-left, the port to its
# right. Re-applied for HoldSec since both reposition themselves at startup.
$wa = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea
$SWP = 0x0001 -bor 0x0004 # NOSIZE | NOZORDER
$end = (Get-Date).AddSeconds($HoldSec)
Write-Output "READY: start pad input once both show the same screen (holding positions for $HoldSec s)."
while ((Get-Date) -lt $end) {
$emuProc.Refresh(); $portProc.Refresh()
$x = $wa.X
foreach ($p in @($emuProc, $portProc)) {
if ($p.HasExited -or $p.MainWindowHandle -eq 0) { continue }
$r = New-Object Win32.User32+RECT
[Win32.User32]::GetWindowRect($p.MainWindowHandle, [ref]$r) | Out-Null
if ($r.L -ne $x -or $r.T -ne $wa.Y) {
[Win32.User32]::SetWindowPos($p.MainWindowHandle, [IntPtr]::Zero, $x, $wa.Y, 0, 0, $SWP) | Out-Null
}
$x += ($r.R - $r.L)
}
Start-Sleep -Milliseconds 250
}
}
if ($NoArrange) { Write-Output "READY: start pad input once both show the same screen." }
Write-Output "1964 pid : $($emuProc.Id)"
Write-Output "port pid : $($portProc.Id)"
+30
View File
@@ -0,0 +1,30 @@
# Boot 1964 and the port together, drive both from one abpad timeline, and
# screenshot both windows on one clock (capture.ps1). Compare with sheet.py.
# Needs: the real pad off (abpad must own XInput slot 0), a port exe with
# GE_INPUTSCRIPT_MS, and a ROM name without spaces in 1964's ROM directory.
param([string]$Timeline, [int]$QuitFrame = 1500, [int]$Seconds = 24, [double]$Step = 1.0,
[int]$PadOffset = 2000,
[string]$Emu = (Join-Path $PSScriptRoot '..\..\..\ge-port-reference\1964_GEPD_Edition\1964'),
[string]$Rom = 'GoldenEyeUSA.n64',
[string]$Out = (Join-Path $env:TEMP 'abpad-run'))
$repo = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$emu = (Resolve-Path $Emu).Path
Remove-Item -Recurse -Force $Out -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Out | Out-Null
Remove-Item Env:GE_PCDUMP -ErrorAction SilentlyContinue
$ab = Start-Process -FilePath python -ArgumentList @("$repo\tools_pc\abpad\abpad.py", "`"$Timeline`"",
'--quitframe', "$QuitFrame", '--port-cwd', $repo, '--linger-ms', '6000', '--pad-offset-ms', "$PadOffset") `
-WorkingDirectory $repo -PassThru -NoNewWindow `
-RedirectStandardOutput "$Out\abpad.out" -RedirectStandardError "$Out\abpad.err"
Start-Sleep -Milliseconds 1200 # the virtual pad exists before NRage initialises
$emuProc = Start-Process -FilePath "$emu\1964.exe" -ArgumentList @('-c', 'NRage_Input_V2.dll', '-g', $Rom) `
-WorkingDirectory $emu -PassThru
& powershell -NoProfile -File "$PSScriptRoot\capture.ps1" -OutDir $Out -Seconds $Seconds -Step $Step
$ab.WaitForExit(60000) | Out-Null
"abpad exit: $($ab.ExitCode)" | Out-File "$Out\run.log" -Encoding ascii
if (-not $emuProc.HasExited) {
$emuProc.CloseMainWindow() | Out-Null
if (-not $emuProc.WaitForExit(8000)) { "1964 did not close; left running" | Out-File "$Out\run.log" -Append -Encoding ascii }
else { "1964 closed" | Out-File "$Out\run.log" -Append -Encoding ascii }
}
"output: $Out" | Write-Output
+25
View File
@@ -0,0 +1,25 @@
import sys
from pathlib import Path
from PIL import Image, ImageDraw
D = Path(sys.argv[1]) # run dir: 1964_<ms>.png + port_<ms>.png (capture.ps1)
t0, t1, step = float(sys.argv[2]), float(sys.argv[3]), float(sys.argv[4])
W, H = 320, 240
rows = []
t = t0
while t <= t1 + 1e-6:
tag = f"{int(round(t * 1000)):05d}"
row = Image.new("RGB", (W * 2 + 70, H), "black")
for i, side in enumerate(("1964", "port")):
f = D / f"{side}_{tag}.png"
if f.exists():
row.paste(Image.open(f).convert("RGB").resize((W, H)), (70 + i * W, 0))
ImageDraw.Draw(row).text((5, H // 2), f"{t:.1f}s", fill="white")
rows.append(row)
t += step
sheet = Image.new("RGB", (W * 2 + 70, H * len(rows)), "black")
for i, r in enumerate(rows):
sheet.paste(r, (0, i * H))
out = D / f"sheet2_{t0:04.1f}-{t1:04.1f}.png"
sheet.save(out)
print(out)