forked from Ports/goldeneye-pc-port
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:
1 parent
4b38c2566e
commit
daa2522511
63 files changed
+2693
-1419
No files matched your search
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user