Forager: Forager Co-op Mod write-up, directory entry, GameMaker (YYC) notes (#252)

This commit is contained in:
melogo5 authored and cranebeaker committed 2026-10-30 12:55:04 +00:00
1 parent 4e4f55c4f3
commit ea3f8ac214
7 files changed
+316 -2

No files matched your search

+2 -1
View File
@@ -2,7 +2,7 @@
<!-- Generated by tools/build_directory.py from data/directory.yaml. Edit the YAML, not this file. -->
302 co-op projects and 38 open gaps across 218 games. Status counts: playable 64, official 12, partial 57, alpha 66, prototype 68, abandoned 35.
303 co-op projects and 38 open gaps across 219 games. Status counts: playable 65, official 12, partial 57, alpha 66, prototype 68, abandoned 35.
**QA** is the column that matters: it says how the claims were checked. See [data/SCHEMA.md](data/SCHEMA.md). Found something wrong or new? Edit `data/directory.yaml` and open a pull request, or let your agent do it (see [AGENTS.md](AGENTS.md)).
@@ -122,6 +122,7 @@
| Far Cry 4 | 2014 | Dunia | [Campaign Co-op](https://www.nexusmods.com/farcry4/mods/153) | mod | partial | story: partial, enemies: unknown, world state: unknown, items: unknown | 2 | unknown | player reports | unknown |
| Fear & Hunger | 2020 | RPG Maker MV | [Fear & Hunger Together](https://github.com/glynn568-glitch/fear-and-hunger-together) | mod | playable | players visible: yes, story: yes, enemies: yes, world state: yes, items: per-player | 2-4 | hosted-relay | automated tests | 2026-10 |
| Final Fantasy VII Remake Intergrade | 2021 | Unreal Engine 4 | [Final Fantasy VII Remake and Rebirth Co-op (Co-op Anything Discord)](https://discord.com/channels/1561411702082510879/1562185682934435900) | mod | prototype | ? | unknown | unknown | untested | 2026-10 |
| Forager | 2019 | GameMaker Studio 2 (YYC) | [Forager Co-op Mod](https://github.com/melogo5/forager-coop) | mod | playable | players visible: yes, story: partial, enemies: yes, world state: yes, items: per-player | 2-4 | steam-relay | automated tests | 2026-10 |
| Gedonia | 2022 | Unity 2019.4 (Mono) | [Gedonia Co-op](https://github.com/Zerbith/gedonia-coop) | mod | alpha | players visible: yes, story: yes, enemies: yes, world state: yes, items: per-player | 2-4 | steam-relay | automated tests | 2026-10 |
| Genesis Alpha One | 2019 | Unreal Engine 4 | [genesis-alpha-one-coop](https://github.com/Blackouest/genesis-alpha-one-coop) | mod | prototype | players visible: partial, story: no, enemies: unknown, world state: partial, items: no | 2 | direct-ip | untested | 2026-10 |
| GoldenEye 007 | 1997 | N64 decompilation (goldeneye_src) fork | [GoldenEye 007 Plus](https://github.com/Joshua-1248/GoldenEye-007-Plus) | fork | partial | players visible: unknown, story: partial, enemies: unknown, world state: unknown, items: partial | 1-4 | unknown | untested | 2026-10 |
+35
View File
@@ -8902,3 +8902,38 @@
sources:
- https://github.com/vaiserYT/DeadCellsMultiplayerMod
checked: '2026-10-23'
- game: Forager
year: 2019
engine: GameMaker Studio 2 (YYC)
project: Forager Co-op Mod
url: https://github.com/melogo5/forager-coop
other_urls:
- https://github.com/cranebeaker/coop-anything/tree/main/games/forager
kind: mod
status: playable
coop_scope:
players_visible: 'yes'
story: partial
enemies: 'yes'
world_state: 'yes'
items: per-player
players: 2-4
networking: steam-relay
open_source: 'yes'
last_activity: 2026-10
qa: automated
notes: Online co-op for up to 4 players (host and 3 guests) as one native version.dll next to Forager.exe (proxy
DLL with MinHook hooks into the YYC-compiled game; no game files changed). Steam build 6160665 only (in-game
4.1.9). Join by Steam invite (friends-only lobby, Steam P2P), LAN discovery or IP. Host-authoritative world; other
players are puppet objPlayer instances, and the host runs a guest's actions through the game's own code by
swapping the instance PlayerFind returns. Shared world, enemies, buildings, chests, islands, time and weather, level
and XP, coins, skill tree, museum, NPC quests and dungeons; inventory, gear, familiars and costume per player.
Classic worlds only; the Void trial does not work in co-op. Scripted tests drive 2 to 4 real game clients on one PC
with PASS/FAIL checks and mutation twins; nine two-PC playtests, eight over Steam. 3 and 4 players tested only as
copies on one PC. MIT. Write-up in games/forager.
sources:
- https://github.com/melogo5/forager-coop
- https://github.com/melogo5/forager-coop/releases/tag/v0.1.1
- https://github.com/cranebeaker/coop-anything/tree/main/games/forager
checked: '2026-10-30'
made_with_coop_anything: true
+135
View File
@@ -0,0 +1,135 @@
# Forager: engineering notes
Notes behind [README.md](README.md), for the next agent working on this game or on another GameMaker YYC game.
Everything here is for Steam build 6160665 (`Forager.exe` PE timestamp `0x60148428`); addresses are VAs for image
base `0x400000` (the exe has ASLR: address in the process = VA - `0x400000` + module base). Source of the mod:
https://github.com/melogo5/forager-coop. The decompiled game code is not published anywhere; it is regenerated from
your own copy of the game with the steps below.
## Getting readable code out of a YYC exe
1. `data.win` metadata: UndertaleModTool CLI 0.9.2.0 with C# scripts dumps objects (parent, sprite, mask, events),
rooms and their instances, sprites, sounds, strings and extensions. A YYC `data.win` has no code, but object and
room data are complete, which was enough to rebuild interiors from a room dump.
2. Function names: every YYC function pushes a pointer to its name string (`gml_Script_PlayerFind`,
`gml_Object_objPlayer_Step_0`) onto the YYC stack trace (top of stack at `01B77490`). Find the string, find the one
function that references it: that is the function. A Python script builds the name map from the exe's strings, a
Ghidra script renames the functions and exports pseudo-C per object event and script, with GML-to-GML calls
renamed too.
3. Ghidra 12.1.4 headless: of about 4080 names, 4015 bound to functions (66 not found, for example
`objPlayer/KeyPress_17`), 3969 decompiled. 46 failed: 300 s timeout or more than 14 GB RAM. A retry one function
at a time with a 1 h timeout and a 2000 MB response buffer did not help (`objPlayer/Step_0` times out;
`WorldControl/Step_2` and `objPlayer/Step_2` exceed the response buffer, and with a bigger buffer the decompiler
hangs; 4096 MB does not start at all). For those, export the full listing plus a summary of called functions and
referenced strings, and trace the running game.
4. Annotate the pseudo-C: the YYVAR table (`{const char* name; int id}` in `.data`, ids filled in by the runtime at
start) maps `DAT_xxxxxxxx` to GML builtins and variables, and variable slot numbers map to instance variable names
(2349 of them). After that, `GETVAR(self, inventory)` reads close to GML.
5. A tracer in the DLL hooks any function listed in a text file next to the exe (`name<TAB>VA`) and logs the GML
call chain. This answered most questions the 46 undecompiled functions left open.
6. A probe DLL confirmed the basics before any co-op code: the `version.dll` proxy loads, MinHook hooks on
`gml_Script_PlayerFind` and `gml_Object_objPlayer_Step_0` work, reading `x`/`y`/`id`, a global by slot and calling
a builtin by name (`ds_list_size`) work.
## Runtime (GameMaker 2.2.5.378, x86, cdecl)
| What | Signature |
|---|---|
| Script `gml_Script_X` | `RValue* (CInstance* self, CInstance* other, RValue* result, int argc, RValue** args)` |
| Object event `gml_Object_X_Event` | `void (CInstance* self, CInstance* other)` |
| Builtin (table entry) | `void (RValue* result, CInstance* self, CInstance* other, int argc, RValue* args)` |
- **RValue** (16 bytes): `+0` value (double, int32, int64 or pointer), `+8` flags, `+0xC` kind. Kind `& 0xFFFFFF`:
0 real, 1 string, 2 array, 3 ptr, 5 undefined, 6 object, 7 int32, 10 int64, 13 bool, 14 iterator. Only kinds 1 to 3
own memory (`FREE_RValue`). Pre-2.3 runtime: no structs or methods.
- **CInstance**: `+0` vtable (`[1]` = `InternalGetYYVarRef(slot)`, thiscall), `+4` `RValue* yyvars` (by slot, 16-byte
stride, may be NULL; then go through the vtable), `+0x74` flags (bit 0 destroyed, bit 1 deactivated), `+0x7C`
object_index, `+0x130` next active instance.
- **Object** (resource): `+0x44` linked list of its instances `{next, ?, CInstance*}`.
- **RFunction** table of builtins (`01B8C014`), 0x4C bytes per entry: `char name[64]`, `+0x40` fn, `+0x44` argc.
2502 entries in this build; builtins past that (`layer_*`, `tilemap_*`) are found by name, not by index.
| VA | Function |
|---|---|
| `016AB490` | `YYGML_CallBuiltin(self, other, &ret, argc, fnIndex, RValue** args)` |
| `016CAD50` / `016CAF50` | `Variable_GetValue` / `Variable_SetValue(int instId, int slot, int arrayIndex, RValue*)` (instId >= 100000 instance, < 100000 object, -3 all) |
| `016CA140` / `016CACD0` | `Variable_GetBuiltin` / `Variable_SetBuiltin(CInstance*, varId, arrayIndex, RValue*)` (`x`, `y`, `sprite_index`; index `0x80000000` = none) |
| `004012B0` | `FREE_RValue` |
| `00409120` | `RValue::operator=` (thiscall, this = destination) |
| `016A52A0` | instance lookup by id |
| `016ABC10` | `YYSetString` |
| `016AADE0` | `YYError` (fatal GML error) |
| `016F2D00` | `event_user(self, other, n)` = `Perform_Event(self, other, 7, 10 + n)` at `017DA800` |
| `016E6BA0` | `instance_destroy` core `(self, other, id, executeEvent)`: every destroy ends here |
| `01B7CA98` | `global` instance |
| `01B8E0D0` | current room; `+0x80` first active instance |
YYC calls some builtins directly instead of through the table (`YYGML_instance_destroy` at `016EC110`), so hook the
core function, not the table entry.
## Facts about the game that shaped the design
- `PlayerFind(n)` = `ds_list_find_value(global.players, n)`, always called with 0. It is the single choke point for
"the player", which made the actor swap possible: hook it and return the puppet while performing a remote player's
action. It runs about 350 times per frame, so the hook must be cheap.
- State on the player instance: `inventory`, `inventorySize`, `coins`, `hp`, `invulFrames`, `energy`, `level`,
`xpToGain`, `gear`, `tool`, `toolSelected`, `buff`, `cosmetic`, `bomb`. Global: `skillUnlocked`, `featProgress`,
`droids`, `assignedDroids`, `ownedLands`, `zoneCurrent`, `deathCount`.
- Why a plain second `objPlayer` does not work: it reacts to the same global input, and its own Step and Draw call
`PlayerFind(0)` and read the first player's state. Skipping its events and drawing it is what makes it a puppet.
- A hit on a resource: `objPlayer_Step_0 -> UseItem -> UsePickaxe`, which does
`with (target) { killedByPlayer = room_speed; event_user(0) }`. A resource dies in its Step at `hp <= 0`;
`Destroy_0` calls `GainXp` and `DropItem`.
- Death: `objPlayer/Step_1` calls `SaveGame` and creates `objGameOver` at `hp <= 0`.
- Cell sleep lists hold `[id, box]` arrays. A destroyed or woken id left on them crashed the host later, when
`ProcessCulls` touched the stale id. Take ids off every list on destroy and on wake.
- The blue dungeon chest is the same `objTreasureChest` with another sprite and opens without a key; treating every
`objTreasureChest` as a key chest spent the host's key and left it waiting for the guest's.
- `global.droidNames` is a ds_grid, not an array (one row per droid: name, look, colour).
- Looped sounds (`sndBombFuse`, the `objWatcher` laser) are stopped only in the object's `Destroy_0`; a replica
removed silently keeps the sound playing forever. Stop an object's looped sounds when removing its replica.
- The archery crosshair is drawn by `objPlayer Draw_0`, so a puppet draws the remote player's crosshair unless that
sprite is skipped while a puppet draws.
- `COOP_DEBUG_GODMODE` turns on the game's own god mode, which also lets the player walk on water: tests that move a
player with it on put buildings over water.
## Official Modding API: what stopped the first prototype
Mod API 1.0.1, a GML interpreter inside the game. Learned while building the prototype:
- Returning to the main menu unloads all mods and wipes their `global.*`. Anything that must survive goes into the
game's own globals (`variable_global_set`).
- A network event while mods unload crashes the game (`ref_map_wrap` in `WorldControl`). Close the session before the
room change and delay the change.
- Never wrap the game's data structures (`variable_global_get(..., "grid")`): unloading destroys them with the mod.
- `OnStep` does not run while a menu is open; `OnDrawGUI` does.
- `instance_activate_object` wakes instances only on the next frame, so a world snapshot takes two frames.
- `instance_destroy()` without an argument is rejected; `-1` means `self`, use `noone` for an empty reference.
- `OnResourceSpawn`/`OnMobSpawn` fire before the object is fully set up (ore type); send a frame later.
- Sockets are TCP only; no `buffer_seek`, UDP or `json_encode`.
- No file access, so the host's world could not be handed to a guest: the guest had to copy the host's save by hand
before every session and after every island purchase.
## Bugs from playtests worth knowing about
Each was found in a two-PC playtest, root-caused from both logs, fixed and given a scripted check. Full tables are in
`docs/coop/PLAYTEST*.md` (Russian) in the mod repo.
| Seen | Cause | Fix |
|---|---|---|
| Guest's sword, arrows and staffs did no damage to enemies | they hit the guest's replica; the next hp from the host overwrote it | the hp drop since the last host write is sent as a hit |
| Guest's skill points wrong | written to a variable the game never reads; `objPlayer.skills` is level - 1 - learned | write the real field |
| Museum shelves empty in co-op | interiors rebuilt from `data.win` lacked the per-instance room creation code (`gml_RoomCC_rmMuseum_*`), which YYC keeps only in the exe | find those functions by their name strings in Ghidra and call them after creating each instance |
| Host crash after many destroys | stale id on a cell sleep list | remove ids from the lists |
| Broken world saves | a zone file with a short `Entities` list | repair zone files before the world loads, writing atomically and keeping the first `.broken` copy |
| Guest's drone gone after rejoin | the guest plays in the host's world, so its own droids were saved nowhere | the host stores each guest's familiars next to its character file and restores them on join |
| Infinite fuse sound at the guest | looped sound stopped only in `Destroy_0` | see above |
## Code review findings (2026-10-25)
A review of all 13 400 lines of the DLL at the time found, among others: handlers left armed after a guest left (a
later connection could rewrite the host's slot 99), an array length taken from the network unchecked, NaN
coordinates reaching the world, non-atomic writes of the guest character file, a vectored exception handler that
could loop on a fault inside the GML stack reader, and guests that vanished without closing TCP staying in the
session forever. All were fixed; the second pass added a writer thread and queue per connection, a hello frame
before a connection counts, and validation of every value a guest sends.
+138
View File
@@ -0,0 +1,138 @@
# Forager (2019)
| | |
|---|---|
| Engine | GameMaker Studio 2, compiled with **YYC** (GML compiled to x86 machine code; `data.win` has no CODE/VARI/FUNC chunks). GameMaker runtime 2.2.5.378 |
| Versions tested | Steam build 6160665 (in-game version 4.1.9, GEN8 2.2.2.302, built 2021-01-29), Windows. `Forager.exe` SHA-256 `ad2b90f164320f646c340b0be60af966851dfdc74dce366580f3dcd531853ae6` (PE timestamp `0x60148428`), `data.win` `0f5ccbfbbd80dd7eb5f1c4fde676c4c1f8f1dc77e24bcce9086d8ec2b38144e8`, `steam_api.dll` `af17df745250d1814eaa274fff7b0faeb43381e6762e026267e5859778477abd` |
| Projects | [Forager Co-op Mod](https://github.com/melogo5/forager-coop) (MIT; releases v0.1.0 and v0.1.1) |
| QA level | automated: scripted sessions drive 2 to 4 real game clients on one PC and print PASS/FAIL per check, with mutation twins. Also nine two-PC playtests, eight of them over Steam |
| Last updated | 2026-10-30 |
## Where things stand
Up to four players can play one Classic world online: a host and up to three guests, joined by a Steam invite
(F2 in the world) or over LAN/VPN. The world, enemies, buildings, chests, islands, time and weather, level and XP,
coins, the skill tree, the museum, NPC quests and dungeons are shared; each guest keeps their own inventory, gear,
familiars and costume. The mod is a single `version.dll` next to `Forager.exe`; no game files are changed. It is
released as a beta: the Void trial does not work in co-op, and a guest occasionally loads a slightly different world,
which the mod corrects within seconds.
## Existing work
- **Forager Co-op Mod** (https://github.com/melogo5/forager-coop): started 2026-10-16 as a prototype on the game's
official Modding API (`mods/CoopTest` in the repo, frozen), rewritten as a native `version.dll` patch on
2026-10-17. 149 commits up to 2026-10-29, about 16 500 lines of C++. Public since 2026-10-28.
## How the game works (for co-op)
- **No scripts to edit.** YYC compiles every GML script and object event to a native function inside `Forager.exe`.
The names survive: each compiled function writes a pointer to its own name (`gml_Object_objPlayer_Step_0`) into
the YYC stack trace, so the function that references that string is the one you want. Ghidra 12.1.4 (headless)
named and decompiled 3969 of about 4080 functions this way. 46 of them (among them `objPlayer/Step_0`, `Step_2`,
`WorldControl/Step_2`, `SaveGame`) time out or run out of memory in the decompiler; those were read from listings
and by tracing the running game. UndertaleModTool CLI dumps objects, rooms, sprites and strings from `data.win`.
- **One player, behind one function.** `objPlayer` adds itself to `global.players`, and 365 functions get the
player through `PlayerFind(n)`. A probe hooked on it saw only `n = 0` (about 1.4 million calls in 4 minutes, about
350 per frame), and no code loops over the list: the abstraction is there, nothing uses it.
- **Input is global** (`InputCheck*` read `global.inputStatus`), so a second `objPlayer` reacts to the same keys.
- **No pause.** Open menus do not pause the game; about 30 objects check `IsMenuOpen` themselves and stop.
- **Culling.** `WorldControl/Step_1` calls `CullObject` (deactivates everything outside `global.camera*`) and
`ProcessCulls` (wakes what came back into view). Objects culled on the host are invisible to all its code, so a
guest far from the host plays in a frozen world unless the host keeps it awake.
- **The world is random and timed.** `ResourceSpawn` puts resources and enemies on random owned land; drops come from
`DropItem` in dozens of `Destroy_0` events. Only one machine can run this.
- **Saves** hold one player (`SaveGame`, `SerializeCharacterChunk`); the world is in `saveData<N>/Zones/*.cin`.
- **Steam.** The game already loads and initialises `steam_api.dll` (Steamworks SDK about 1.41) and calls
`SteamAPI_RunCallbacks` every frame, so a DLL can use lobbies, invites and `ISteamNetworking` P2P through the flat
API, with callbacks arriving on the game thread. Lobbies and P2P work under Forager's own app id 751780.
- **The official Modding API** (1.0.1) has TCP sockets but no `buffer_seek`, UDP or JSON. Mods are unloaded on every
return to the main menu (their `global.*` and any game structure they wrapped are destroyed, and a network event
during unloading crashes the game), `OnStep` does not run while a menu is open, and mods cannot write files, so the
host's save could not be handed to a guest. These limits are why the first prototype was frozen and the mod moved
to a native DLL.
Runtime structures, the addresses the DLL uses and the hook list are in [NOTES.md](NOTES.md).
## What we changed
The mod: https://github.com/melogo5/forager-coop (MIT). Its developer docs, playtest analyses and code review are in
Russian, in `docs/coop/`.
**Model.** `version.dll` proxies the Windows library of the same name and hooks game functions with MinHook. It
checks the exe's PE timestamp and only forwards calls on any other build. The host is authoritative: the world lives
and is saved only on the host; a guest gets the host's save in a temporary slot (99, deleted afterwards) and then a
stream of changes. Every other player is a second `objPlayer` whose own events (Step, Alarm, Mouse) are skipped and
whose Draw runs (a puppet). Because `objPlayer` inherits `parAlly`, enemies target puppets with no extra code.
**Actor swap.** The `PlayerFind` hook returns the current actor: normally the local player, but while the host
performs a guest's action, that guest's puppet. The game's own code then reads the guest's tool damage, skills, buffs
and gear from the puppet, and outputs aimed at the player (`GainXp`, `GainItem`, coins, `DmgPlayer`, toasts) go to
the guest instead of being applied on the host. A guest's hit travels as `hit(hostInstanceId, dmg)`; the host runs the
object's `event_user(0)` with the puppet as actor.
Main pieces, with the problem each one solved:
| Area | Problem | Fix |
|---|---|---|
| Culling | the world around a guest far from the host was asleep on the host | hooks on `instance_deactivate_object` and `ds_list_add` keep anything inside a camera-sized box around each guest awake; `ProcessCulls` runs again per guest box |
| Destroys | objects the guest had culled survived the host's destroy (20 trees at the host against 71 at the guest) | destroyed ids are taken off every cell sleep list; culled replicas are destroyed too |
| Reconcile | guests sometimes loaded extra or missing trees | every 5 s the host sends the objects around each guest; a difference seen twice in a row is fixed |
| Ghost enemies | guest hits on enemies missed | enemies at the guest are exactly the host's replicas; local spawns and stale replicas are removed |
| Ranged and sword damage | arrows, staffs and sword swings hit the guest's replica, and the host's hp overwrote the damage | before the host's hp is written into a replica, any drop since the last write is sent to the host as a hit |
| Drops | items doubled or taken by the wrong player | drops go straight into the inventory of whoever broke or killed the object, sent only to that guest |
| Chests, stations | two players in one chest or furnace | the host arbitrates; the later opener is told it is busy and its menu closes |
| Skills | the guest's points were written to an unused variable; trees diverged | one tree for everyone: a skill bought by anyone is repeated for all through the game's own `ItemEdit`/`StructureEdit` calls; points stay personal |
| Shared progress | level, XP, coins, structure unlocks | kept by the host and broadcast; unlocks copied with `StructureEdit(s, 11, true)` |
| Museum, quests | lost donations, "Quest not found" | donations merge as a grid (max per cell); NPC quest state is polled every second and applied with `NPCSetQuest`/`NPCSetState` once the NPC exists |
| Dungeons | first blocked, then players were split | everyone enters and leaves together; puzzle pieces, lanterns, keys, the boss key's ice and chests are synced; death in a dungeon respawns at its entrance and resets that player's minecarts |
| Interiors | entering the wizard tower or museum stopped the world for the others | interiors are rebuilt inside the world room from a room dump of `data.win`; the museum's per-instance creation code is called by address |
| Death | game over ended the session | respawn on the starting island, no save, no game over |
| Players | 2 only | a star through the host, up to 3 guests, a peer id per connection that is never reused within a session |
| Transport | IP, Hamachi or Radmin needed | Steam friends-only lobby, F2 invite, `+connect_lobby`; frames cut into 512 KB P2P packets; TCP and Steam guests can share one session |
| Robustness | a slow guest froze the host; a vanished guest stayed forever | a writer thread and queue per connection, TCP keepalive, a hello frame before a connection counts, the host validates every value a guest sends |
## QA
- **Several copies on one PC.** With `steam_appid.txt` (`751780`) next to the exe, `Forager.exe` starts without
relaunching through Steam, and two to four copies run side by side.
- `tools/gametest/coopflow.ps1` starts a host and a guest and drives the menus with window messages (no real mouse);
`regress.ps1` runs one fresh-world session with debug switches (`COOP_DEBUG_AUTOHIT`, `_BUILD`, `_STORAGE`,
`_COINS`, ...) and prints PASS/FAIL per check from both logs and the game's `error.log`. About 40 scenario scripts
cover single features (`sharedskills.ps1`, `museum.ps1`, `traps.ps1`, `relay.ps1` for three players, `four.ps1`
for four).
- `COOP_DEBUG_PORT=1` opens a query port into the running game: read objects and variables, call scripts
(`query.ps1`).
- `mutants.ps1` runs each check plain (must PASS) and with its fix turned off through `COOP_DEBUG_MUTANT` (must FAIL).
- Last recorded results (from the mod's docs, not re-run for this write-up): `regress.ps1` 9/9 PASS after the code
review (2026-10-25). After playtest 9 (2026-10-27, `regress.ps1 -HostSlot 5`) all PASS except "shared chest": on that
save the debug helper's `instance_nearest` could not see the chest because it was culled; the storage code had not
changed. Four players (`four.ps1`, 2026-10-20): every copy sees the three others; drops reached only their owner
(guest 1: 10 of 10, guest 2: 35 of 35, guest 3: 0).
- Nine playtests on two PCs from 2026-10-19 to 2026-10-27, eight of them through Steam invites, the longest
3 h 15 min. Both players' logs were analysed after each; the analyses are `docs/coop/PLAYTEST*.md` in the mod repo.
Three and four players were tested only as copies on one PC.
## How to play
Every player needs Forager on Steam at the build above. Download `version.dll` from
https://github.com/melogo5/forager-coop/releases/latest and put it next to `Forager.exe` in `<game dir>`.
Host: main menu, COOP, CREATE, pick a save or a new Classic world, then F2 in the world to invite. Guest: accept the
Steam invite, or COOP, type a name, pick the host's LAN server, JOIN. LAN and VPN play use TCP 7790 and UDP 7791.
To uninstall, delete `version.dll`.
## Known issues
- The Void trial does not work in co-op (respawn outside the arena, timer and enemy count stuck at the guest).
- A guest occasionally loads a slightly different world (extra or missing trees); it is corrected within seconds.
- Only Classic worlds. Forager's own Mod API mods are not supported.
- Only the host buys islands and gets Steam achievements.
## Next steps
- Void: find how the game builds and removes the arena (callers, world and global changes), the way interiors and the
land map after an island purchase were handled.
- Crafting from a chest that another player holds open: the closer's contents overwrite what the craft used. It needs
a merge, not only a lock.
- Root cause of the occasional different world at the guest. The mod logs `world: WARNING the world came incomplete`
when a snapshot is short.
- Test three and four players on separate PCs.
+1
View File
@@ -26,6 +26,7 @@ sources; this page is about what carries over.
| **Unity / XNA** | Subnautica (Nitrox), Hollow Knight (HKMP), Outer Wilds (QSB), Celeste (CelesteNet) | C# mod loaders (BepInEx, MelonLoader, Everest) make Unity games the easiest modern targets. |
| **SAGE** (EA Los Angeles RTS) | C&C3 Tiberium Wars (campaign co-op prototype, games/command-and-conquer-3); Generals Zero Hour source is public (GPL, EA) | Lockstep with a lobby; campaign maps can run inside a lobby game if the side stripping (`prepareForMP_or_Skirmish`) and slot sides are skipped. The Generals source names most C&C3/KW/RA3 seams. Watch the per-slot anti-spoofing check when two humans share one side. |
| **ScrapEngine** (MercurySteam) | Clive Barker's Jericho (Jericho Co-op+, [games/clive-barkers-jericho](../games/clive-barkers-jericho/README.md)); Scrapland had official multiplayer | Gameplay is Lua 5.1 (`.lc` = u32 header + zlib bytecode, decompiles with unluac); `-InitLuaLine:` runs your Lua at startup without touching game files, and `OnRenderDebug` gives a per-frame hook. The exe keeps Scrapland's UDP client/server and a Lua `Net` library, but its join packet carries Scrapland ship data; a proxy DLL can repoint unused `Net` entries to your own transport. |
| **GameMaker (YYC)** | Forager (Forager Co-op Mod, 2 to 4 players, [games/forager](../games/forager/README.md)) | YYC games have no GML in `data.win`: every script and object event is a native function, but each one pushes its own name string (`gml_Object_objPlayer_Step_0`) onto the YYC stack trace, so names come back by following the string references, and Ghidra then gives readable pseudo-C (UndertaleModTool still dumps objects and rooms from `data.win`). The runtime structures (RValue, CInstance, the builtin table, `Variable_GetValue`/`SetValue`) are shared by games on the same runtime version, so a `version.dll` proxy with MinHook and a small runtime layer carry over; the addresses do not. A game's own modding API (Forager has one) may be too limited for co-op: Forager's unloads mods on every return to the menu and gives them no file access. |
| **IW engine** (Call of Duty, id Tech 3 derived) | Modern Warfare 2 (2009): campaign co-op alpha [mw2_coop](https://github.com/stxlvn/mw2_coop), [games/modern-warfare-2](../games/modern-warfare-2/README.md); Modern Warfare 3 (2011): from-zero attempt, [games/modern-warfare-3](../games/modern-warfare-3/README.md); Spec Ops (MW2, MW3) is official 2-player co-op | The MW2 singleplayer exe carries a lockstep 2-player co-op netcode (Spec Ops), and a campaign map loads as a co-op map, so campaign co-op is script work on top: turn off the Spec Ops branches (`is_specialop()`), give player 2 the scripted moments. Scripts are zlib text inside the zones; a `winmm.dll` proxy can replace the script loader to patch them. In co-op, script `getdvar` only sees flagged dvars and retail script errors are silent. The 2026 Steam updates made MW2 and MW3 64-bit (64-bit zones), which broke every 32-bit mod and tool. Whether MW3's `iw5sp.exe` has the same co-op netcode is unchecked. |
| **Java (JVM)** | The Doors of Trithius (two-player co-op mod, [games/the-doors-of-trithius](../games/the-doors-of-trithius/README.md)) | A `-javaagent` (`java.lang.instrument`) loads the mod into the unmodified game JVM, so no game file changes. The game's public classes and its event bus are the hook points; `javap` from the bundled JDK gives every API fact, and a decompiler (CFR, Vineflower) reads the logic. For a single-player game with no netcode, an impersonation window reuses every rule: the host swaps the game's current-player fields (entity, bag, skills, abilities, field of view) to the guest's, runs the vanilla code for the guest's command, then restores them. Turn-based games also need a clock rule for two players far apart. |
| **Santa Monica Studio Kinetica** | God of War 2018 ([games/god-of-war-2018](../games/god-of-war-2018/README.md)); God of War Ragnarök | Despite `.bind` (SteamStub v3.1), `GoW.exe`'s `.text`, `.rdata` and `.data` are unencrypted on disk (flags `0x06`), and jumping `.bind` (`0x14519f310`) to `OriginalEntryPoint` (`0x1403ec680`) lets a second instance run on one PC. Gameplay, quests and Atreus AI run on 1,423 Lua 5.2 scripts (32-bit `float` numbers, unstripped debug info) stored as uncompressed `IFFLuaScriptChunkHeader` chunks inside `.wad` files (`exec/wad/pc_le/`; `IFFHeader.m_Length` is measured from the `ScriptChunk` start). Each character (`heroa00`, `son00`) has its own `lua_State*` with `socket.core` (UDP) and `cmsgpack` built in, but `io`/`os` are omitted and any unhandled Lua error hits `int 3` (`0x14063d629`), so inject config in C++ when compiling overrides in `IFFProcessLuaScriptChunk` (`0x140466de0`) and run the co-op loop inside `son00`'s VM (`crt == game.AI.FindSon()`). |
+4
View File
@@ -127,3 +127,7 @@ Drawn from six co-op projects (STALKER, Fallout New Vegas, Dungeon Keeper 1, Oca
62. **A hook can change a function it only passes through, if that function reads stack it never wrote.** In Thief 2 NewDark's MP exe, the remote player steering code (`0x553dc0`) decides whether to pull a moving avatar toward its predicted position by testing three stack floats it never writes on that path, so the leftovers of earlier calls decide. A pure refactor of the hook in front of it, with no change to what it passed on, made avatars trail 3x further behind, and the "stock" control measured through that hook had never been the real game (path deviation max 3.0 through the old hook, 7.7 in the plain game). When a refactor changes a stock or control number, A/B old and new builds split by machine, then read the function's machine code for loads from locals that aren't written first. Measure the stock control with nothing hooked in that call chain (here telemetry walks the engine's ghost table instead), and fix such a bug as its own switchable patch rather than by luck of stack layout. Example: Thief 2 (`fix_steer.c` and `docs/NOTES.md` in T2MPFix, `games/thief-2/NOTES.md`).
63. **Stopping a background shell task may leave its child running.** On Windows, stopping an agent's background Bash task ended the bash wrapper but not the `sh.exe` running the script. The script kept sending stick input to a test copy, and when that copy was restarted it steered the title menu: the copy loaded another campaign and autosaved it into its own save folder. Before restarting a copy, list and stop leftover script processes (Shadow of War).
64. **If the game reaches "the player" through one function, swap what it returns instead of reimplementing actions.** Forager reads the player through `PlayerFind(0)` in 365 functions (about 350 calls per frame). Hooking it to return the current actor, normally the local player and a guest's puppet while the host performs that guest's hit, build or purchase, made the game's own code use the guest's tool damage, skills, buffs and gear; outputs aimed at the player (`GainXp`, `GainItem`, coins, damage) are then forwarded to the guest instead of applied on the host. Keep the hook cheap and restore the actor on every exit path. Example: Forager (games/forager).
65. **A replica that takes damage locally must report it, or the authority's next update erases it.** In Forager a guest's arrows, staffs and sword swings are separate objects that hit the guest's copy of an enemy; the host never heard of the hit, and its next hp update undid it, so only the pickaxe (which sends an explicit hit) worked. Before writing the authority's hp into a replica, compare the replica's hp with the value written last time and send any drop as a hit, and do not count the authority's own hp updates as damage. Example: Forager (`mobs.cpp` in forager-coop, games/forager/NOTES.md).
+1 -1
View File
@@ -135,7 +135,7 @@ SIGNATURES = [
("REDengine 4", None, lambda f, d: _ext(f, ".archive") if "archive" in d else None),
("LithTech", None, lambda f, d: _ext(f, ".rez")),
("Godot", None, lambda f, d: _magic(f, ".pck", b"GDPC")),
("GameMaker", None, lambda f, d: _has(f, "data.win")),
("GameMaker", "GameMaker", lambda f, d: _has(f, "data.win")),
("RPG Maker", None, lambda f, d: _ext(f, ".rgss3a", ".rgss2a", ".rgssad") or _has(f, "rpg_core.js", "rmmz_core.js")),
("XNA / FNA / MonoGame", None, lambda f, d: _has(f, "fna.dll", "monogame.framework.dll", "microsoft.xna.framework.dll")),
# Java games ship a .jar plus a bundled runtime (jre or jdk folder), or a launcher that starts java.exe.