From 70c664bffc2d09655d0149a4a21b7c41e73799c5 Mon Sep 17 00:00:00 2001 From: eden <269277775+edenfps@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:33:41 -0400 Subject: [PATCH] Add the fishing mini-game (server-driven, reverse-engineered from the client) Implements Free Realms fishing end-to-end on the server (opcode 138). The client is a terminal that animates the bobber/fish but the bite simulation is driven entirely server-side. Core loop: cast -> bobber + underwater school -> fish gets interested -> bites (lunge + splash) -> fight -> reel-up -> catch banner + inventory grant. Reel timing gates a catch vs a miss. Catches persist to the DB and survive relog. Content: - Per-hole fish tables for all six holes, feeding the Fish Finder with real species names and icons. - Treasure chest (static catchable-head + money-shot burst) and junk catches. - Multiplayer sync of fishing visuals to nearby players. Gear: - Equipped rod -> cast-distance tier (short/greater/deepest). - Lures -> +10% catch chance for their fish; Treasure Magnet -> +treasure. Activated from the tackle box via the action bar. Ambient scenery (WIP): wandering fish + fishing-spot markers in the water. Debug: tp/tporigin and /pos chat commands. Docs: FISHING_HANDOFF.md (map), FISHING_RE_NOTES.md (wire-level RE), FISHING_WIKI.md (hole/fish/lure data), FISHING_1TO1_PLAN.md (roadmap). --- FISHING_1TO1_PLAN.md | 209 +++++ FISHING_HANDOFF.md | 424 +++++++++ FISHING_RE_NOTES.md | 455 +++++++++ FISHING_WIKI.md | 116 +++ src/Sanctuary.Game/Entities/AmbientFishNpc.cs | 102 ++ src/Sanctuary.Game/Entities/Player.cs | 51 + src/Sanctuary.Game/Zones/BaseZone.cs | 16 +- src/Sanctuary.Game/Zones/StartingZone.cs | 68 ++ .../Fishing/FishingActivityZones.cs | 41 + .../Fishing/FishingSession.cs | 868 ++++++++++++++++++ .../Fishing/FishingSessionState.cs | 137 +++ src/Sanctuary.Gateway/GatewayConnection.cs | 2 + src/Sanctuary.Gateway/GatewayService.cs | 3 + ...yPacketClientRequestStartAbilityHandler.cs | 32 + ...ctivityPacketJoinActivityRequestHandler.cs | 75 ++ .../FishingPacketCastAnimRequestHandler.cs | 39 + .../FishingPacketCastRequestHandler.cs | 37 + .../FishingPacketReelInRequestHandler.cs | 36 + ...shingPacketRegisterPlayerRequestHandler.cs | 119 +++ .../FishingPacketSpecialRequestHandler.cs | 39 + .../Handlers/BaseFishingPacketHandler.cs | 54 ++ ...ventoryPacketItemActionBarAssignHandler.cs | 9 + .../MiniGameStartGamePacketHandler.cs | 35 + .../Handlers/PacketChat/PacketChatHandler.cs | 57 ++ .../PacketTunneledClientPacketHandler.cs | 1 + .../WallOfDataUIEventPacketHandler.cs | 13 + src/Sanctuary.Gateway/NLog.Development.config | 4 + src/Sanctuary.Gateway/NLog.config | 4 + .../Fishing/ClientFishEntryInfo.cs | 45 + .../Fishing/FishingPlayerConfig.cs | 53 ++ .../Fishing/FishingZoneConfig.cs | 31 + .../Fishing/UnderwaterFishSpawnInfo.cs | 61 ++ .../BaseChatPacket/ChatPacketDebugChat.cs | 29 + src/Sanctuary.Packet/BaseFishingPacket.cs | 32 + .../FishingPacketCastAnimRequest.cs | 38 + .../FishingPacketCastRequest.cs | 38 + ...ishingPacketDespawnProxiedFishingSchool.cs | 21 + .../FishingPacketFishInfoUpdate.cs | 23 + .../FishingPacketFishingResult.cs | 58 ++ .../FishingPacketReelInRequest.cs | 34 + .../FishingPacketRegisterPlayerResponse.cs | 38 + .../FishingPacketSpawnFishRun.cs | 30 + .../FishingPacketSpawnProxiedFishingBobber.cs | 29 + .../FishingPacketSpawnProxiedFishingSchool.cs | 50 + .../FishingPacketSpecialRequest.cs | 34 + .../FishingPacketSpecialResponse.cs | 25 + .../FishingPacketUpdateData.cs | 29 + ...FishingPacketUpdateProxiedFishingBobber.cs | 27 + ...FishingPacketUpdateProxiedFishingSchool.cs | 25 + .../PlayerUpdatePacketUpdateCharacterState.cs | 29 + .../RewardBundlePacketSingleItem.cs | 38 + 51 files changed, 3861 insertions(+), 2 deletions(-) create mode 100644 FISHING_1TO1_PLAN.md create mode 100644 FISHING_HANDOFF.md create mode 100644 FISHING_RE_NOTES.md create mode 100644 FISHING_WIKI.md create mode 100644 src/Sanctuary.Game/Entities/AmbientFishNpc.cs create mode 100644 src/Sanctuary.Gateway/Fishing/FishingActivityZones.cs create mode 100644 src/Sanctuary.Gateway/Fishing/FishingSession.cs create mode 100644 src/Sanctuary.Gateway/Fishing/FishingSessionState.cs create mode 100644 src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketCastAnimRequestHandler.cs create mode 100644 src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketCastRequestHandler.cs create mode 100644 src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketReelInRequestHandler.cs create mode 100644 src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketRegisterPlayerRequestHandler.cs create mode 100644 src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketSpecialRequestHandler.cs create mode 100644 src/Sanctuary.Gateway/Handlers/BaseFishingPacketHandler.cs create mode 100644 src/Sanctuary.Packet.Common/Fishing/ClientFishEntryInfo.cs create mode 100644 src/Sanctuary.Packet.Common/Fishing/FishingPlayerConfig.cs create mode 100644 src/Sanctuary.Packet.Common/Fishing/FishingZoneConfig.cs create mode 100644 src/Sanctuary.Packet.Common/Fishing/UnderwaterFishSpawnInfo.cs create mode 100644 src/Sanctuary.Packet/BaseChatPacket/ChatPacketDebugChat.cs create mode 100644 src/Sanctuary.Packet/BaseFishingPacket.cs create mode 100644 src/Sanctuary.Packet/BaseFishingPacket/FishingPacketCastAnimRequest.cs create mode 100644 src/Sanctuary.Packet/BaseFishingPacket/FishingPacketCastRequest.cs create mode 100644 src/Sanctuary.Packet/BaseFishingPacket/FishingPacketDespawnProxiedFishingSchool.cs create mode 100644 src/Sanctuary.Packet/BaseFishingPacket/FishingPacketFishInfoUpdate.cs create mode 100644 src/Sanctuary.Packet/BaseFishingPacket/FishingPacketFishingResult.cs create mode 100644 src/Sanctuary.Packet/BaseFishingPacket/FishingPacketReelInRequest.cs create mode 100644 src/Sanctuary.Packet/BaseFishingPacket/FishingPacketRegisterPlayerResponse.cs create mode 100644 src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpawnFishRun.cs create mode 100644 src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpawnProxiedFishingBobber.cs create mode 100644 src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpawnProxiedFishingSchool.cs create mode 100644 src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpecialRequest.cs create mode 100644 src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpecialResponse.cs create mode 100644 src/Sanctuary.Packet/BaseFishingPacket/FishingPacketUpdateData.cs create mode 100644 src/Sanctuary.Packet/BaseFishingPacket/FishingPacketUpdateProxiedFishingBobber.cs create mode 100644 src/Sanctuary.Packet/BaseFishingPacket/FishingPacketUpdateProxiedFishingSchool.cs create mode 100644 src/Sanctuary.Packet/PlayerUpdatePacketUpdateCharacterState.cs create mode 100644 src/Sanctuary.Packet/RewardBundlePacketSingleItem.cs diff --git a/FISHING_1TO1_PLAN.md b/FISHING_1TO1_PLAN.md new file mode 100644 index 0000000..6c13b44 --- /dev/null +++ b/FISHING_1TO1_PLAN.md @@ -0,0 +1,209 @@ +# Free Realms Fishing — Research + 1:1 Fidelity Plan + +> Consolidated research on how real FR fishing worked, a gap matrix against our current +> server, and a phased plan to close the gaps. Companion to `FISHING_HANDOFF.md` (the map), +> `FISHING_RE_NOTES.md` (wire-level RE), and `FISHING_WIKI.md` (raw wiki data). +> Ground rule still applies: **the client binary + client assets are the source of truth**; +> the wiki fills in the data model but does NOT document rewards/XP/scoring numbers. + +--- + +## Part A — What "1:1 Free Realms fishing" actually is + +Fishing is the **Fisherman job** (`Profiles.json`: **Id 137, Type 21**, NameId 20178, Icon +20740, `MembersOnly:false`, `Trial:true`). A job in this engine is a *Profile* that carries a +persisted **Level + LevelXP** (`DbProfile`) and a client-facing XP bar (`AbilityExperience`: +Level / Progress / TotalForLevel). So fishing has the same leveling spine every other job has — +we just aren't feeding it yet. + +The full 1:1 loop, from the wiki + binary + video: + +1. **Unlock** the job (Sports Club Message Board quest in Sanctuary) → train with **Reed + Stillwater** (Stillwater Crossing). Quests: *Becoming a Better Fisherman*, *Testing the + Waters*, *Itty Bitty Bait* (Jonah Relicreel, Rainbow Lake). +2. **Equip a rod** (5 rods, each a cast-distance tier) and optionally a **lure** (13 lures, + each +10% to 3 named fish; **Treasure Magnet** +10% treasure). +3. **Enter a fishing hole** (6 holes, all "Difficulty 1"): Sacred Grove Shallows, Rainbow Lake, + Brambleback's Bayou, Darklit Lagoon, Wintery Basin, Frostbitten Banks. +4. **Aim + cast** — raycast to water, distance must be within the rod's Min/Max cast range; + power = where in that range you land. +5. **Bite** — a fish gets interested, nibbles, then bites (server-driven). +6. **Reel** — bring the fish in. The real game has a **tension / reel-distance mechanic** + (see the "reeling minigame" gap below): reel too hard and the line snaps, too slow and it + escapes. +7. **Catch resolves** to one of: a **fish** (species gated by your fishing level; rolled with + rarity weighting + lure bonus; each has a **size** Small/Medium/Large/Extra-Large that + drives weight, held model scale, and the banner), **treasure** (chest → coins/loot/tools), + or **junk** (boot / socks / dynamite gag). +8. **Rewards** — the caught item enters the bag, you gain **fishing job XP** (levels the job, + unlocking higher fish), likely some **coin**, and the species is recorded in the relevant + **fish Collection** ("What I've Caught"). Completing a fish collection awards job XP + a + collection reward item. +9. Fish feed the **Chef** job as cooking ingredients (the `icon_activateable_fishing_*_bits/oil/ + soup` assets are cooked-fish consumables) — cross-job, out of scope for the core pass. + +### Authoritative data model (all six holes) + +Fish unlock at tiers **1 / 5 / 10 / 14 / 17 / 20**. Per-hole fish lists are already baked into +`FishingSession.ZoneFishTables` with real item/name/icon ids and verified against the wiki +(`FISHING_WIKI.md`). Fish → collection membership (from each hole's wiki "Collections"): + +| Collection | Holes / fish source | +|---|---| +| Wilds Pond Fish + Wilds Stream Fish | Sacred Grove Shallows, Rainbow Lake | +| Briarwood Pond Fish + Briarwood Stream Fish | Brambleback's Bayou, Darklit Lagoon | +| Snowhill Pond Fish + Snowhill Stream Fish | Wintery Basin, Frostbitten Banks | +| Extra Large Catch of the Day | XL-size catches (Snowhill + Wilds holes) | +| Extra Large and in Charge | XL-size catches (Briarwood holes) | + +**Pond vs Stream:** each hole lists a *Pond* and a *Stream* collection; the per-fish split +(which species is "pond" vs "stream") is **not on the wiki** — needs extraction from client +collection data (see Part D). + +### The scoring fields (from the binary, `FishingResult` case 5) + +The catch banner + FishScored UI consume: **FishName, IconId, Difficulty, Rarity, Size (1–4), +Score, TimeToCatch, Weight**. We currently fill these with *guessed* formulas. Real values come +from the client fish definitions (difficulty/rarity per species) — flagged in Part D. + +--- + +## Part B — Current state (what already works) + +From `FishingSession.cs` / `FISHING_HANDOFF.md`, verified end-to-end and multiplayer: + +- ✅ Full cast→bite→fight→reel→catch loop, server-driven, correct camera/animation. +- ✅ Bobber, rod→line, underwater school, lunge/splash, reel-up, money shot. +- ✅ All 6 holes' real fish tables + Fish Finder (plain species names via Jenkins-hash string ids). +- ✅ Rarity-weighted roll (by min-level), size class drives weight + held model + banner. +- ✅ Treasure chest (static catchable head + gold burst) and junk (boot/socks/dynamite). +- ✅ **DB persistence of catches** (per-item immediate `DbItem` insert; bag survives relog). +- ✅ **Multiplayer sync** of fishing visuals to nearby players. + +Infrastructure present but **not yet wired to fishing**: job Profiles with persisted Level/LevelXP, +the `AbilityExperience` XP-bar packet, the coin store's DB-write pattern, size-variant fish items +(the `68xxx` "Small/Medium/… " item ids already exist in `ClientItemDefinitions.json`). + +--- + +## Part C — Gap matrix (current → 1:1) + +| # | Gap | Fidelity impact | Have the pieces? | +|---|-----|-----------------|------------------| +| G1 | **Level gating** — fish rolled/greyed by fishing level | High (core progression) | ✅ Profile Level exists; min-levels in table | +| G2 | **Job XP on catch** — level the Fisherman job | High (core progression) | ✅ DbProfile.LevelXP + AbilityExperience; ❓ XP curve | +| G3 | **Catch-size variety** — roll Small/Med/Large/XL variant | Med (visible per catch) | ✅ size-variant item ids exist | +| G4 | **Lures** — +10% to 3 named fish + Treasure Magnet | Med | 🟢 **DONE (v1)** — activation via ability handler, +10% roll bonus, magnet→treasure. Open: lure duration model, item ids in finder (`FishLureRequirement`), whether client acks activation | +| G5 | **Rods → cast distance** — Min/Max per rod tier | Med (feel) | 🟢 **DONE (v1)** — equipped rod (slot 7) → 3 tiers → cast Min/Max. Open: real distance numbers (approximated; better rods only extend past baseline) | +| G6 | **Collections ("What I've Caught")** | High (headline missing feature) | ❌ from-scratch system (DB + packets + membership data) | +| G7 | **Reeling / tension minigame** — line snap, reel distance | High (the *skill* of fishing) | ⚠️ config defaults known; interaction model needs RE | +| G8 | **Coin rewards** on catch / treasure | Low–Med | ✅ coin-grant path exists; ❓ amounts | +| G9 | **Verify 5 holes' overworld spawns** (only 563 done) | Med (other holes untested) | ⚠️ per-zone Areas.xml + live test | +| G10 | **Scoring accuracy** — real difficulty/rarity/score/time | Low (cosmetic banner) | ❓ per-species values from client data | +| G11 | **Quests / trainer / unlock flow** | Low (meta, not minigame) | depends on quest system state | + +--- + +## Part D — Data / RE that must be extracted first (unblocks the rest) + +These are **research tasks**, grounded in the ground rule (binary + assets over guesses). Do these +before or alongside Phase 1; several gaps are blocked on them. + +1. **Fishing XP curve (G2).** Find the per-level XP thresholds for a job and the per-catch XP + award. Check client data for a profile/level table (`ClientProfileDefinitions` or a rank/XP + curve); check the binary near the profile/rank code. If truly unavailable, adopt a documented + default curve and per-fish XP = base × rarity, and mark it "approximated". +2. **Lure id map (G4).** Map each of the 13 lures + Treasure Magnet to (a) its equippable item id + in `ClientItemDefinitions.json`, and (b) the `FishingLureRequirement` id the client expects in + `ClientFishEntryInfo` (`FishingLureDataSource`). Confirm how the client reads the *equipped* lure + (is the +10% purely server-side, or does the client's finder show it via the requirement field?). +3. **Rod → cast-distance tiers (G5).** Map the 5 rods to item ids and to Min/Max cast distances. + Wiki gives 3 qualitative tiers ("short" / "greater" / "deepest"); real numbers should come from + the rod item defs or `ServerFishingPlayerConfig` (defaults: Min 3.0 / Max 20.0). +4. **Collections system RE (G6).** The big one. Determine the client collection packet(s) and + definition format: opcode/layout for the collection list + "collection item discovered" + + "collection completed" events, and where `ClientCollectionDefinitions` live (client data). Map + the 8 fish collections (Pond/Stream × 3 realms + 2 XL) to their member species and rewards. + The Collections browser is a Scaleform UI — decompile it with FFDec like the fish finder was. +5. **Reeling/tension model (G7).** Decide whether real FR had an interactive tension meter or a + scripted reel. Evidence *for* interactive: `FishingDistanceMeter:FishHooked`, reel-distance + display models 1596–1619 (2fish/3fish at 3/6/9/12 m), and `ServerFishingPlayerConfig` + (LineSnapChance, LineSnapTensionMinPercent, FishEscapeTensionMaxPercent, ReelSpeed/HookingReel/ + SuperReel speeds, TensionMeterSize). Trace `ControllerFishing` reel input + the distance-meter UI + to see how much is client-simulated vs server-adjudicated. +6. **Per-species difficulty/rarity/size (G10) + coin amounts (G8).** From client fish/item defs. +7. **Verify holes 560/561/562/564/565 overworld spawns (G9).** From each `Areas.xml`. + +--- + +## Part E — Phased implementation plan + +Ordered by value-per-effort and dependency. Each phase is independently shippable and testable at +Sacred Grove (563), then swept across the other holes. + +### Phase 1 — Progression core (G1 + G2) ← highest value, pieces already exist +Make fishing *level up the job* and *gate fish by level*. This is the single biggest step toward +"feels like Free Realms." +- Read the player's Fisherman profile (Id 137) Level in `RollCatch` / `BuildFishFinderEntries`. +- **Gate the roll:** exclude fish whose `MinLevel > fishing level`; in the Fish Finder mark + over-level fish `FishCatchable = false` (greyed) instead of dropping them. +- **Award XP on catch** (`Update` → Reeling, next to `GrantCaughtItem`): add LevelXP to the + Fisherman profile, handle level-up (recompute Level from the curve), persist (DbProfile), and + push the updated `AbilityExperience` bar to the client. +- Files: `FishingSession.cs`, `FishingSessionState.cs`, `Player.cs` (a `GrantJobXp(profileId, xp)` + helper mirroring `GrantCaughtItem`), the profile/XP curve from Part D#1. +- Verify: catch fish → XP bar moves → cross a tier → a new fish appears catchable in the finder; + relog → level/XP persisted. + +### Phase 2 — Catch-size variety + accurate rewards (G3 + G8 + G10) +- Roll a **size** (Small/Med/Large/XL) per catch and grant the **size-variant item** (`68xxx`), + not one representative id. Wire real per-species difficulty/rarity/score/time into the banner. +- Award **coins** on catch/treasure (amounts from Part D#6). +- XL catches are the trigger for the "Extra Large Catch of the Day" / "Extra Large and in Charge" + collections — sets up Phase 4. +- Files: `FishingSession.cs` (RollCatch/RollMiscCatch, size→item map), `Player.cs` (coin grant). + +### Phase 3 — Gear: rods + lures (G4 + G5) +- **Rods:** read the equipped rod → set `FishingPlayerConfig` Min/Max cast distance per tier. +- **Lures:** read the equipped lure → apply +10% to its 3 fish in the roll; Treasure Magnet → +10% + treasure share (currently a flat `MiscCatchChance`). Populate `ClientFishEntryInfo. + FishLureRequirement` so the finder reflects lure-boosted fish. +- Files: `FishingSession.cs`, the register handler (`FishingPacketRegisterPlayerRequestHandler.cs`), + lure/rod maps from Part D#2/#3. + +### Phase 4 — Collections / "What I've Caught" (G6) ← headline missing feature, largest build +Depends on Part D#4 RE. Standalone subsystem: +- New DB table (SqLite + MySql migration) for per-character caught-species / collection progress. +- Server collection registry (the 8 fish collections + membership + rewards). +- On catch: record species (and XL for the XL collections); on collection completion award job XP + + reward item. +- Send collection state on login + deltas on catch so the Collections browser renders "What I've + Caught". (Mirror the `GrantCaughtItem` immediate-persist pattern.) +- Files: new `Sanctuary.Gateway/Collections/*`, DB entities + migration, login + fishing hooks. +- **Scope check:** this touches core systems (DB, login) and needs its own 1-client test pass; + keep it on its own branch/commit series. + +### Phase 5 — Reeling / tension minigame (G7) ← deepest fidelity, do last / optional +Depends on Part D#5. If real FR was interactive, implement the tension/reel-distance loop using the +`ServerFishingPlayerConfig` defaults (reel speeds, snap/escape tension, distance meter, super-reel), +driving `FishingDistanceMeter` + the 1596–1619 distance models. This converts "reel = auto-catch +after fight" into the actual skill mechanic. High risk, high polish — gate on confirming it existed. + +### Phase 6 — Sweep + polish (G9 + G11) +- Verify/fix the other 5 holes' overworld spawn positions; confirm activity→hole mapping (only 563 + is verified). Junk/treasure per-hole tuning. Optionally the unlock quest/trainer flow (G11) if the + quest system supports it. + +--- + +## Part F — Suggested order & rationale + +**P1 → P2 → P3 → P4 → (P5) → P6.** P1 delivers the most "it's really Free Realms now" per unit +effort and reuses existing infra. P2/P3 are medium, self-contained polish. P4 is the headline +feature but a big standalone build (do it deliberately, with RE first). P5 is the deepest fidelity +and the riskiest — only commit once we've confirmed the interactive reel actually existed. P6 is +cleanup that can trail throughout. + +**Blocking research (Part D) to schedule up front:** XP curve (P1), lure/rod maps (P3), collections +packet RE (P4), reel-model RE (P5). D#1–#3 are quick asset/def lookups; D#4–#5 are real IDA/FFDec RE +sessions. diff --git a/FISHING_HANDOFF.md b/FISHING_HANDOFF.md new file mode 100644 index 0000000..8c346bd --- /dev/null +++ b/FISHING_HANDOFF.md @@ -0,0 +1,424 @@ +# Fishing Mini-Game — Handoff / Onboarding + +> **Read this first.** This is the entry point for a fresh session. It covers *what works +> now*, *where everything lives*, and *how to keep going*. The deep reverse-engineering log +> (packet offsets, IDA addresses, client function names) is in **`FISHING_RE_NOTES.md`** — +> refer to it when you need the wire-level details; this file is the map. + +--- + +## 1. Goal & ground rules + +**Goal:** Reverse engineer the Free Realms fishing mini-game and get it working end-to-end +on the emulated **Sanctuary** game server. + +**Ground rules the user set (important):** +- The **game binary (IDA Pro MCP) and packet logs are the absolute reference.** Do *not* + treat the pre-existing server fishing code as authoritative — verify against the client. +- Reference gameplay video: https://www.youtube.com/watch?v=lM7Pzhp9h6k +- This work runs under `/loop` (self-paced). Commit after each meaningful change. + +**Status: fishing works end-to-end.** Cast → bobber + underwater fish school → fish gets +interested & swims in → bites (lunge + splash) → fights → reel → reel-up animation → +inventory grant + yellow "item received" text → catch banner (name / size / weight) → +auto-return to normal camera. Reel-timing gate is implemented (early reel = miss). + +### Latest state (read this — supersedes stale details below) +- **Bobber: FIXED & visible.** It was invisible because we sent the bobber's second Vector4 as + `(0,0,0,1)`. That field is a **forward DIRECTION, not a quaternion**; a zero direction made the + client's `sub_770A70` collapse the bobber's matrix to zero-scale (placed correctly, rendered at + size 0). Now sends `(0,0,1,0)`. See FISHING_RE_NOTES.md "BREAKTHROUGH". +- **Rod→bobber line: FIXED (timing).** The line (`sub_CD0150`) builds only once BOTH the bobber actor + (with its `LINE` socket) exists AND our proxied char has attachment **group 7** (the rod, read for its + `EMITTER2` socket). Group 7 rides the fishing-pose bit — set from the cast (`CastAnimRequest`→state 2, + relayed to self) — so the rod is attached early. The *line* itself, though, is only (re)built when our + local `FishingProcessor` reaches its **state 4** ("bobber out & fishing"), which it does ~2s AFTER the + server's bobber spawn (`Process` case 3 → `sub_CCFFB0(player,4)` recreates the bobber into a line-ready + form). The old bite timeline made the fish bite at ~spawn+3s, right on top of that ~spawn+2s mark, so + the line only ever *seemed* to appear "at the bite". **Fix: delay interest to ~3.5-5s post-spawn** (in + `OnCast`) so the line settles visibly before the fish engages. `IsCurrentPlayer` is always true for us + (`m_pProxiedCharacter == this`), so the bobber-spawn packet takes the no-op state-3 path and we depend + on that local state-4 transition — do NOT preload the bobber model (it would make the line build on the + case-8 bobber, which state 4 then destroys → orphaned line). +- **Docs:** `FISHING_WIKI.md` = full wiki data (all 6 fishing holes + per-zone fish/level tables + + lure→fish map). `FISHING_RE_NOTES.md` = wire-level RE. This file = the map. +- **Debug:** type `tporigin` (no slash) in chat to TP to the world origin; `tp x y z` for coords. +- **FishTable + Fish Finder: DONE for all six holes.** `FishingSession.ZoneFishTables` (keyed by + activity id 560-565) holds every hole's real wiki fish; `RollCatch` rolls them (rarity-weighted by + min-level) and `BuildFishFinderEntries()` feeds the Fish Finder (`FishInfoUpdate`). The client dedupes + finder entries by `Type` and skips `Unknown4=true`, so each fish gets a unique `Type` (its item id). + **Names are the PLAIN species name, not size-prefixed** (`NameId` = the species-name string id, e.g. + Slugmud Skipper = 99924, resolved by the client as `"Global.Text."`). The string-id space is + a **Jenkins lookup2 hash** of `"Global.Text."` into `en_us_data.dat` (the `ucdt` table) — see + [[fishing-key-re-facts]] for how the plain-name ids were recovered. Caveat: only 563 (Sacred Grove) + has a verified overworld spawn + confirmed hole identity; the other activity→hole assignments are + best-effort from the `bw_/sh_` pond/stream zone-name hints in `FishingActivityZones`. Still TODO: + gate the Fish Finder / roll by the player's fishing level, and the +10% lure bonus. +- **Models + misc catches: DONE.** The minigame has NO per-species fish meshes — species = name/icon; the + 3D model is a generic shape. Each fish now carries a **Shape** (thin/medium/fat → underwater biter mesh + 1670-1672, so sharks read thin/long, groupers/globfish fat). The catch banner's held model (`@80`) uses + that **same underwater fish model** — the money-shot `sg_fish_catch_*` meshes (1620-1623) rendered wrong, + so `@80` was reverted to the underwater model (the known-good behavior). **Misc catches** (`MiscCatches`, + ~15% of casts): Treasure Chest (money-shot model 1624 = `sg_fishing_treasure_chest_bbe`), Old Boot, Soggy + Socks, Soggy Stick of Dynamite. + - **Treasure Chest** has a real model (1624), so it uses the client's static **catchable-head** mechanic: + it's spawned as a `Unknown7=true` head fish (frozen at the hook — `CatchableFish()`), so the chest is + already ON THE LINE when the underwater cam arrives and the player just reels it up (no swim/bite). The + session goes straight to `Hooked` on cast and waits (no auto-escape). Being the catchable head keeps it + the "current fish" the reel-up (RT4) pulls, and suppresses the decoy. + - **Junk** (boot/socks/dynamite) has no model, so it rides the normal fish-bite path and is revealed as a + generic fish carrying the item's name/icon (a "gotcha"). + TODO: Treasure Magnet lure should raise the treasure share; chests could grant loot on open. + +### Session log — persistence, multiplayer, treasure polish (latest; all committed on `fishing-minigame`) +Chronological git trail (newest last): `9f16553` revert held money-shot to underwater model · `0de82fd` +junk shows a fish not a chest in the money shot · `6e8bb0f` treasure chest is a static catchable-head on +the line · `c2ec600` money-shot item mode (`Caught=true`) + burst effects · `846a086` fix chest teleport +on reel-up (restore glide params) · `0caad26` persist catches to DB · `1956829` relay fishing visuals to +peers · `7905e01` stop `SpawnFishRun` crashing peers · `8ac567e` fix one-way player visibility · `369d615` +fix DB persistence (per-character item id) · `c9d0642`/`e5ef926` "What I've Caught" experiment + revert. + +- **DB persistence of catches: DONE & VERIFIED.** Catches now persist in the bag across relog. + `FishingSessions.GrantCaughtItem` (in `FishingSessionState.cs`) inserts a `DbItem` immediately (crash- + safe, like the coin store) then grants in-memory with `GiveItem(def,count,id)`. **Gotcha:** `DbItem`'s + key is the composite `(Id, CharacterId)` and is NOT auto-generated — you must assign the id yourself + (`player.Items` max+1); leaving it 0 hits `UNIQUE constraint failed: Items.Id, Items.CharacterId` and it + silently falls back to in-memory. `SavePlayerToDatabase` still does NOT reconcile Items on logout — this + per-catch immediate write is the pattern. `GiveItem` gained an explicit-id overload for this. +- **Money-shot item mode + effects: DONE.** `FishingResult.Caught` (@28) is the "item vs fish" flag: false + = fish (size-scaled hold-up + size/weight banner), true = ITEM (fixed scale, name-only banner). Set true + for treasure/junk (`_isItemCatch`). The money-shot composite effect is `@116 Unknown9` (adjacency to the + @120 size class confirms it): fish → `BBE_PFX_fishing_moneyshot_fish` (15880), chest → + `BBE_PFX_fishing_moneyshot_chest` (15881, the gold burst). Effect ids from `ActorCompositeEffectDefinitions.xml`. +- **Treasure chest on the line + glide: DONE.** Spawned as a frozen catchable head (`CatchableFish()`, + `Unknown7=true`, model 1624) so it sits ON the hook (no swim/bite); phase goes straight to `Hooked`, + `_nextEventAtMs=long.MaxValue` (waits, no escape). **Gotcha:** the reel-up GLIDE (client `sub_CD1A10` + state 7) moves the object by speed = param/param from the `UnderwaterFishSpawnInfo` movement fields, so + `CatchableFish` must keep the normal (ambient) movement params — zeroing them made the chest TELEPORT up. +- **Multiplayer sync: WORKING (user-confirmed).** Fishing keeps you in the shared zone, all fishing guids + are your player guid, and the client's `sub_B64300` is find-or-create — so relaying the fishing VISUALS + to visible players makes their client spawn a proxied fishing player and render you fishing. Route them + via `SendProxied` (SendTunneledToVisible + self). **Do NOT relay:** `SpawnFishRun` (its handler null- + derefs the bobber on a peer that hasn't created it → CRASH; keep the underwater sim self-only — peers only + need the null-safe bobber/pose/bite/result); and `RegisterPlayerResponse`/`FishInfoUpdate`/`UpdateData` + (case 3 writes the GLOBAL FishingProcessor config + the current player's camera → would corrupt peers' + own fishing). Inventory/reward grants stay private. +- **Player visibility: FIXED (concurrent-join race).** Was intermittent one-way ("I see them, they don't + see me"). `UpdateEntityZoneTile` (BaseZone.cs) cross-notified nearby players BEFORE adding the entity to + its own tile, so two players loading at once each finished scanning before the other had added itself. + Fix: add the entity to its tile FIRST, then cross-notify. (Core-server fix, not fishing-specific.) +- **"What I've Caught": NOT persisted — needs the COLLECTIONS system.** It's fishing ability/tab 3 (abilities + are Fish Finder / Tackle Box / What I've Caught). It fills client-side from catch events and resets on + relog; there is NO fishing packet or fish-finder flag that restores it (verified — decompiled the + Scaleform `.gfx` UIs with FFDec at `C:\Program Files (x86)\FFDec\ffdec-cli.exe`, and every fishing + sub-opcode is accounted for). Persisting it requires implementing FR **collections** (per-player + caught-species tracking + a new DB table/migration for SqLite+MySql + collection packets on login). The + BAG is already the durable catch record; only the collections *browser view* is missing. See + [[fishing-key-re-facts]]. + +### Session log — ambient fishing scenery: wandering fish + spot markers [WIP, movement unconfirmed] +**Goal (user):** fill the water of every fishing hole with fish that swim around like the underwater +minigame fish, plus the light-blue "whirlpool" markers that show where you can fish. Visible in the +water, not just during fish-cam. + +**Status:** partial. Server-spawned fish **render** (user confirmed seeing a school), but **movement is +not yet confirmed** and they currently spawn at a **diagnostic location**, not the real ponds. Commits: +`842ae0e` (initial) + the follow-up commit this entry documents. + +**Confirmed this session:** +- Server-spawned NPCs DO render for the player (user saw the fish). The entity/visibility/AddNpc path works. +- Models: **418** `fish.adr` (the fish), **1684** `sg_fishing_node_01` (the fishing-spot whirlpool marker). + Other candidates found: 1064 fish_bbe, 1670-1672 underwater fish, 1727 sg_fishing_run_school_bbe, + 1704/1705 sg_fishing_area_bubbles/particle, 1685 node_02. + +**Architecture found (important):** +- The server has ONE zone: **FabledRealms** (`StartingZone`, Id 1), player spawn `(-1904.883, -39.7098, + 412.6024)` (from `src/Resources/Zones/FabledRealms.json`). +- **Each fishing hole is a SEPARATE CLIENT SCENE**, not a server zone. `MiniGameStartGamePacketHandler` + sends `PacketClientBeginZoning` with `Name = fishingZone.ZoneName` (`sg_fishing_medpond`, + `sg_fishing_stream`, `bw_fishing_medpond`, `bw_fishing_stream`, `sh_fishing_medpond`, + `sh_fishing_stream`), `GeometryId 214`, `Position = fishingZone.SpawnPosition`. The client loads that + scene; the server does NOT create a zone object for it. So a fish spawned in FabledRealms may or may not + render once the client has zoned into a fishing scene — **unverified**, and a likely reason the first + placement at `(435,-64,370)` showed nothing at the hole (player's server position/scene didn't match). +- **Real water positions live in the user's Unity project** (ForgeLightToolkit import of the FR client): + `C:\Users\bobya\Documents\FLTKSample\Assets\ForgeLight\FreeRealms\*.gzne` — `FabledRealms.gzne` + the six + fishing-hole zone files above. `.gzne` = zone header/eco/flora/invisible-walls (parser: the toolkit's + `GzneFile.cs`); the actual placed-object instances (model + position) are in the per-chunk `.gcnk` files + (`Gcnk/RuntimeObject.cs`). This is the data source for exact per-hole pond coords (not yet mined). + +**Implementation (files):** +- `src/Sanctuary.Game/Entities/AmbientFishNpc.cs` (new): a fish NPC that wanders within a radius of a home + point on the water plane, driven by the zone's 10 Hz tick. Broadcasts its position each tick via + `PlayerUpdatePacketUpdatePosition` (opcode 125) — the same packet that relays player movement. +- `src/Sanctuary.Game/Zones/BaseZone.cs`: protected `NextEntityGuid()` + `TryRegisterNpc(Npc)` so a zone + can spawn custom-subclass NPCs (guid space starts at 100 billion — no collision with player guids). +- `src/Sanctuary.Game/Zones/StartingZone.cs`: `SpawnFishingScenery()` (called from the ctor) spawns the + fish + a spot-marker NPC. **Currently spawns at the zone login point `(-1904.883, -39.7098, 412.6024)` + as a DIAGNOSTIC** (`SpawnPosition`), NOT the real ponds. `tp -1904.883 -39.7098 412.6024` to reach them. + +**Movement debugging (the open problem):** +- v1 broadcast to the fish's `VisiblePlayers` → fish rendered but were **static**. Likely cause: a fish + only learns about a player (populating its `VisiblePlayers`) if that player was flagged `Visible` during + the tile scan, which isn't guaranteed — so the movement updates were sent to nobody. +- v2 (this commit) broadcasts to **`Zone.Players`** (all players in the zone) + sets the opcode-125 + `State` byte to `1` (moving) instead of `0` (idle/teleport). **UNTESTED.** +- If still static after v2: opcode 125 may not drive NPC actors on the client at all → next step is to + verify against the client binary (IDA: find the opcode-125 / position-update handler and whether it + resolves NPC actors) and, if needed, find/add the correct NPC-movement packet. + +**Next steps (in order):** +1. Confirm v2 makes the fish swim (`tp` to the diagnostic spot above). If not → IDA the movement path. +2. Mine real per-hole water coords from the `.gzne`/`.gcnk` files (FabledRealms + the 6 fishing scenes). +3. Verify whether FabledRealms server-NPCs render *inside* a fishing scene after `BeginZoning`; if not, + spawn the ambient fish via the fishing session / a fishing packet instead (the channel the underwater + fish already use), or create server-side fishing-scene zones. +4. Relocate `SpawnFishingScenery` from the diagnostic login point to a data-driven per-hole placement. + +### Session log — gear: rods (cast distance) + lures (+10% / Treasure Magnet) [builds clean; live-test pending] +Phase 3 of `FISHING_1TO1_PLAN.md` (G4+G5). Files: `FishingSession.cs`, `FishingSessionState.cs`, +`FishingPacketRegisterPlayerRequestHandler.cs`, `AbilityPacketClientRequestStartAbilityHandler.cs`. +- **Rods → cast distance.** The equipped rod (Fisherman profile weapon **slot 7** → `ClientItem.Definition`) + maps by id range to 3 cast tiers, sent as `FishingPlayerConfig` Min/Max. Rod id ranges (verified from + `ClientItemDefinitions.json`, each rod = 35 tint variants + 1 high id): Bamboo 68185-68219/76687 & + Golden Reel 68220-68254/76688 = "short" (3..20, = tested baseline); Metal 68255-68289/76689 & Red + Scoped 68290-68324/76690 = "greater" (3..25); Golden Scoped 68325-68359/76691 = "deepest" (3..32). + **Better rods only EXTEND past the tested Max 20 — never shorten it — so the known-good 563 cast can't + regress.** Absolute numbers are approximate (no real cast data yet — plan D#3). +- **Lures → +10% / Treasure Magnet.** Lures are `Class 142` SingleUse consumables, item ids **68152-68164**, + `ActivatableAbilityId` **4287-4299** (both consecutive/aligned). Activating a lure comes in as + `AbilityPacketClientRequestStartAbility` (`Data.Id` = the ability id) — the handler (which used to reject + everything with "can't use that") now, if the ability maps to a lure AND the player has a fishing session, + records the active lure, **consumes one** from the bag (`FishingSessions.ConsumeItem` → `ItemUpdate`/ + `ItemDelete` + DB), and returns without the failure. `RollCatch` gives the lure's 3 named fish +10% of the + base weight total; **Treasure Magnet** (68164) instead raises the misc share +10% and 4× the chest weight. + Active lure persists for the hole session (cleared on `Reset`). +- **Open / not 1:1 yet:** real per-rod cast distances + per-fish lure-requirement ids in the Fish Finder + (`ClientFishEntryInfo.FishLureRequirement` left 0); lure duration model (currently "until you leave"); + and whether the client needs an ability-activation ack (only `AbilityPacketFailed` exists S→C — we send + nothing on success, the bag update is the feedback). Verify these live. +- **Live-test checklist:** equip each rod tier → confirm you can cast further with Metal/Golden Scoped; + activate a lure at a hole that has one of its fish (e.g. Perch Pinpointer at Bayou → Globfish) → confirm + the lure count drops in the bag and that fish shows up more; Treasure Magnet → more chests. + +### Next up (agreed with user) +1. **Collections system** for "What I've Caught" (the above) — a scoped standalone effort, needs 1-client testing. +2. Fishing polish still open: **level gating** (grey/roll by fishing level — min-levels already in the table), + **rods → cast distance** (`FishingPlayerConfig` Min/Max), **lures** (+10% + Treasure Magnet; needs the + FishingLureDataSource id map), **catch-size variety** (roll Small/Med/Large/XL variant items). +3. Verify the other 5 holes' real overworld spawns + confirm activity→hole mapping (only 563 verified). + +--- + +## 2. Environment & paths + +| What | Where | +|------|-------| +| Workspace root | `c:\Users\bobya\FRController\Sanctuary-minigame - backup - fish spawning over water` | +| Server source | `src/` (C# .NET) | +| Git branch | `fishing-minigame` (this repo is a working copy; nested repos are gitignored) | +| IDA target | Free Realms client via **ida-pro-mcp** MCP server (decompile/disasm/xrefs/py_eval) | +| Dumped client assets | `C:\Users\bobya\Documents\Free Realms Unpacker\editz fr assets\FR Assets 2025-07-07` | +| Live client folder | `C:\Users\bobya\AppData\Local\OSFRLauncher\Servers\EDITz's Local Server\Client` | +| Client logs | `…\EDITz's Local Server\Client\Logs` | +| Gateway console log | `src/bin/Debug/Logs/Sanctuary.Gateway-Console-.log` | +| Packet log dumps | `…\packet logs\` (no fishing packets in them — do not expect any) | + +--- + +## 3. Build & test loop + +**Build (from `src/`):** +``` +dotnet build Sanctuary.Gateway/Sanctuary.Gateway.csproj -c Debug +``` +> A running Gateway **locks the DLL** — if the build fails with a file-lock error, the +> server is still running. Stop it, rebuild, restart. + +**Test:** +1. Build, then start the Gateway server. +2. In the client, go to a fishing spot and fish. **Sacred Grove (activity 563)** is the + one with a real overworld spawn position — use it for testing. +3. Watch `src/bin/Debug/Logs/Sanctuary.Gateway-Console-.log` — the `FishingSession` + logger narrates the whole bite timeline (cast → interested → bit → reel → catch). +4. For client-side crashes/errors, check `…\Client\Logs`. + +--- + +## 4. The architecture in one screen + +The client is a **terminal**: it animates the bobber and fish autonomously, but the **bite +is 100% server-driven**. The server-side fishing simulation does **not** exist in the client +binary (its config loaders are dead code). We drive everything from the C# server. + +**Server projects:** +- `Sanctuary.Gateway` — UDP packet handlers + the fishing session state machine (our code). +- `Sanctuary.Game` — `Player` and game entities (inventory `GiveItem` lives here). +- `Sanctuary.Packet` / `Sanctuary.Packet.Common` — packet definitions / shared structs. +- `Sanctuary.Core.IO` — `PacketWriter` / `PacketReader`. + +**Tick:** the Gateway main loop (`GatewayService.cs`) calls `Fishing.FishingSessions.Tick()` +every iteration, which calls `Update()` on each active session — that's what advances the +bite timeline on timers. + +### 4.1 Packet flow (opcode 138 = 0x8A, header = int16 opcode + int16 sub-opcode, LE) + +``` +C→S RegisterPlayerRequest S→C RegisterPlayerResponse (+ UpdateData + FishInfoUpdate) +C→S CastRequest S→C SpawnProxiedFishingBobber + SpawnFishRun (school) + S→C (timer) UpdateProxiedFishingBobber Flag2=true = interested (swims in) + S→C (timer) UpdateProxiedFishingBobber Flag1=true = bite (lunge + fight) +C→S ReelInRequest S→C FishingResult ResultType 4 (reel-up) then 5 (catch) + …or, reeled too early → escape + ResultType 0 (nothing) +``` + +All S→C guids are the **PLAYER guid** — the client resolves the proxied fishing player by it. + +--- + +## 5. Key files & their roles + +**State machine (the heart):** +- `src/Sanctuary.Gateway/Fishing/FishingSession.cs` — per-player state machine. Read this + first; it's heavily commented. Phases: `Idle → BobberOut → Nibbling → Hooked → + ReelPending → Reeling`. Contains the `FishTable`, timings, and all S→C packet builders. +- `src/Sanctuary.Gateway/Fishing/FishingSessionState.cs` — `FishingSessions` static registry + (ConcurrentDictionary keyed by player guid), the static `Logger`, `GetOrCreate/Remove`, + and `Tick()`. +- `src/Sanctuary.Gateway/Fishing/FishingActivityZones.cs` — per-activity zone config incl. + the critical **`UnderwaterBedX`** (see §6.2). Values read from each zone's + `Areas.xml` "Underwater_Bed" area. + +**Handlers (C→S):** +- `src/Sanctuary.Gateway/Handlers/BaseFishingPacketHandler.cs` — dispatch. **Passes + `reader.Span` (full payload incl. header)**, not `RemainingSpan`; every fishing + `TryDeserialize` re-validates the 138+subop header. (This was a real bug — casts silently + failed to deserialize when the header was stripped.) +- `…/Handlers/BaseFishingPacket/FishingPacketRegisterPlayerRequestHandler.cs` — sends the + config packets (player config, zone config w/ `UnderwaterBedX`, fish model ids, fish info). + **Deliberately does NOT send `SpawnProxiedFishingSchool`** (see §6.3). +- `…/BaseFishingPacket/FishingPacketCastRequestHandler.cs` → `session.OnCast(position)`. +- `…/BaseFishingPacket/FishingPacketReelInRequestHandler.cs` → `session.OnReel()`. +- `…/Handlers/BaseMiniGamePacket/MiniGameStartGamePacketHandler.cs` — creates the session, + calls `SetZone(stateId, fishingZone)` + `Reset()`. + +**Packets / structs:** +- `src/Sanctuary.Packet/BaseFishingPacket/FishingPacketFishingResult.cs` — the catch/result + packet. `Unknown2`/`Unknown4` are **floats** (weight). Field-offset map in the file & notes. +- `src/Sanctuary.Packet.Common/Fishing/UnderwaterFishSpawnInfo.cs` — fish spawn struct. + `Unknown8..17` are **floats** (movement params). *Int values froze the fish* — `int 1` + reinterpreted as float ≈ 1.4e-45 ≈ 0. +- `src/Sanctuary.Packet.Common/Fishing/FishingPlayerConfig.cs` — `Unknown2`/`Unknown3` are + **floats** (min/max cast distance). +- `src/Sanctuary.Packet.Common/Fishing/FishingZoneConfig.cs` — `Unknown3`/`Unknown6` **floats**. +- `src/Sanctuary.Packet/RewardBundlePacketSingleItem.cs` — **opcode 50, sub-type 2**: the + yellow "You received: X" bottom-screen text. **Display-only** (does not grant the item). +- `src/Sanctuary.Game/Entities/Player.cs` — `GiveItem(int definitionId, int count=1)`: + creates a `ClientItem`, adds to `Items`, sends `ClientUpdatePacketItemAdd` (bag) + + `RewardBundlePacketSingleItem` (yellow text). **In-memory only — not persisted to DB.** + +**Wiring:** +- `src/Sanctuary.Gateway/GatewayService.cs` — main loop calls `FishingSessions.Tick()`. +- `src/Sanctuary.Gateway/GatewayConnection.cs` — `OnTerminated` calls `FishingSessions.Remove()`. + +--- + +## 6. Critical RE findings (the "why", condensed) + +These are the non-obvious things that cost real debugging time. Full detail in +`FISHING_RE_NOTES.md`. + +### 6.1 The bite is server-driven; ambient vs. catchable fish +`UpdateProxiedFishingBobber` (sub-opcode 10) with `Flag2` = interested, `Flag1` = bite is +what makes a fish swim in, **lunge (anim 103/104 + splash CE 16265)**, and fight. **Only +*ambient* fish (`Unknown7=false`) animate these states** — the "catchable" fish is frozen by +design. So we spawn an **ambient "biter"** fish (`BiterFishId = 2`) and drive *it*. Driving +the catchable fish produced a motionless fish that ignored the flags (an early bug). Setting +the biter as the update target also makes it the client's "current fish", so the reel-up +(ResultType 4) pulls *that* fish. + +### 6.2 "Fish in the sky" — the Underwater_Bed positioning +The client **hardcodes the underwater fish arena at world Y=-8, Z=485**; only **X** is +configurable (via `FishingZoneConfig.Unknown3`). Sending the *overworld pond X* put the fish +above the wrong water (they appeared "in the sky"). Fix: send the zone's **`Underwater_Bed` +area X** (found in each `Areas.xml` in the dumped assets). Per-activity X values live in +`FishingActivityZones.cs` (e.g. Sacred Grove `sg_fishing_medpond` = 563 → X≈68). + +### 6.3 "Stacked fish in the center" — don't send SpawnProxiedFishingSchool +The client's `SpawnProxiedFishingSchool` path (`sub_CD12A0`) places **every** fish in the +school at the **same point** → a clump of fish stacked on one spot. We removed those packets +entirely. The lively school comes from **`SpawnFishRun`** (sent on cast) with per-fish +positions/movement instead. (The user twice reported stacked fish; this was the cause. +Note: they *do* want wandering fish — just not stacked ones.) + +### 6.4 Catch banner requirements +`FishingResult` for the catch (ResultType 5): the banner only shows if **@80 (held-fish +model) > 0** and **@32 (fish-name id) is a real string-table id**. Placeholders gave +"STRING 0 NOT FOUND". We use real fish (see FishTable below). `Caught=false` (@28) makes it +show *size word + weight*. + +### 6.5 Reel timing gate +- Reel while `Hooked` (fish has bitten, hasn't fled) → **catch** (reel-up waits out the + ~1.9s fight so it animates, then the catch banner). +- Reel while `BobberOut`/`Nibbling` (before the bite) → **fish spooks & runs off + "Nothing + Caught"**. +- Reel while `ReelPending`/`Reeling`/`Idle` → ignored. + +### 6.6 The FishTable (real item/name/icon ids from ClientItemDefinitions.json) +| Model | Name | Item def id | Name-string id (@32) | Icon id | Size | +|-------|------|-------------|----------------------|---------|------| +| 1670 | Swurgle Fish | 2148 | 6170 | 4594 | 1 | +| 1671 | Calico Catfish | 2149 | 6171 | 4595 | 2 | +| 1672 | Globfish | 2147 | 6169 | 4593 | 3 | + +Bobber model = **1063** (`fishing_bobber_bbe.adr`) — `SpawnProxiedFishingBobber.Unknown` is +the bobber **model id**; if 0 the client never creates the bobber and the following +`SpawnFishRun` null-derefs → **client crash**. Must be > 0. + +--- + +## 7. Git history (branch `fishing-minigame`) + +Latest at top. Tree is clean as of this handoff. +``` +5d2655f Restore the wandering fish; remove the stacked school clumps +1d6c137 Spawn only the biter fish (remove motionless school) +9aa21e9 Document RewardBundle opcode-50 yellow-text packet format +8a1851b Show the yellow "item received" notification on catch +b3471a0 Reel-before-bite = miss; keep ambient fish actively swimming +99a0029 Grant the caught fish to the inventory with a pickup popup +1efefac Remove the stationary catchable fish from the fishing scene +0e34a73 Document animation choreography (catchable frozen; drive ambient biter) +78dc9d5 Drive the bite/reel animations on an ambient fish, not the frozen catchable one +c576a4c Document Underwater_Bed positioning fix + real fish name/icon ids +6256066 Fix "fish in the sky": send the Underwater_Bed X per zone +3b0f13d Use real fish names/icons in the catch banner +``` + +--- + +## 8. Pending / next steps + +1. **Live-test the latest changes** (commit `5d2655f`): the user hasn't yet confirmed that + restoring the wanderers + removing the stacked schools looks right in-game. Verify: + fish wander lively, no stacked clump in the center, yellow item-received text shows, + catch banner correct. +2. **Uncertain:** whether the client's autonomous wander actually keeps `SpawnFishRun` fish + moving, or if we need to periodically re-position them from the server. Confirm visually. +3. **DB persistence** — caught fish are **in-memory only** (`Player.GiveItem` does not + persist; `SavePlayerToDatabase` doesn't save `Items`). Add persistence if desired. +4. **Per-zone fish tables** — currently 3 generic fish rolled uniformly + (`RollCatch`, `TODO(fish-table)`). Build real per-zone loot tables with rarity weighting. +5. **Other zones** — only Sacred Grove (563) has a verified overworld spawn position; the + other activities (560, 561, 562, 564, 565) have Underwater_Bed X set but placeholder + overworld spawns. Verify/fill them in from the assets. + +--- + +## 9. Where to look for wire-level detail + +`FISHING_RE_NOTES.md` (~300 lines) has: the full opcode-138 sub-opcode table, every +`FishingResult` field offset with its client meaning, the fish-arena math, the animation +choreography (anim ids, splash composite-effect ids, the fish state machine `sub_CD1A10`), +the RewardBundle opcode-50 byte layout, and IDA function references (`sub_CCFDB0`, +`sub_CD12A0`, `sub_CD1A10`, `sub_B8A640`, `sub_B891F0`, `sub_AF85E0`, etc.). diff --git a/FISHING_RE_NOTES.md b/FISHING_RE_NOTES.md new file mode 100644 index 0000000..9355b41 --- /dev/null +++ b/FISHING_RE_NOTES.md @@ -0,0 +1,455 @@ +# Free Realms Fishing — Reverse Engineering Notes (working doc) + +Sources of truth: `FreeRealms_2014-03-13` client binary (IDA: `FreeRealms_Admin.exe`), packet captures (no fishing traffic present — confirmed), video https://www.youtube.com/watch?v=lM7Pzhp9h6k +Server repo: this workspace (`src/`). Existing server fishing code matches wire layouts but game logic is guessed. + +## Packet family — opcode 138 (0x8A), header: short opcode + short sub-opcode (LE) + +Confirmed from `BaseFishingPacket::BaseFishingPacket` @ 0xB60910 (`m_nOpCode = 138`) and per-packet ctors: + +| Sub | Name | Dir | Client handler evidence | +|---|---|---|---| +| 1 | UpdateData | S→C | u64 Guid, Vector4 Position (4 floats) | +| 2 | RegisterPlayerRequest | C→S | (client sends; payload = header only?) TBD | +| 3 | RegisterPlayerResponse | S→C | FishingPlayerConfig(9i+4f+3i), FishingZoneConfig(str+2i+f+2i+f? see below), List ModelIds, List | +| 4 | FishInfoUpdate | S→C | List | +| 5 | CastAnimRequest | C→S (server relays to others) | u64 Guid, int, Vector4 Pos | +| 6 | CastRequest | C→S | u64 Guid, Vector4 Pos, bool | +| 7 | ReelInRequest | C→S (server relays) | u64 Guid, bool | +| 8 | SpawnProxiedFishingBobber | S→C | u64 Guid, int, Vector4 Pos, Vector4 Rot | +| 10 | UpdateProxiedFishingBobber | S→C | u64 Guid, int fishId, bool hooked, bool b2 — drives hook/escape/lost UI | +| 11 | SpawnProxiedFishingSchool | S→C | int SchoolId, Vec4 Pos, Vec4 Rot, List, List ModelIds, int, int | +| 12 | DespawnProxiedFishingSchool | S→C | int SchoolId | +| 13 | UpdateProxiedFishingSchool | S→C | int SchoolId, Vector4 Pos | +| 14 | FishingResult | S→C | u64 Guid, int ResultType, bool Caught, int FishId, int, string FishName, 7×int, string, string, int(+116), int(+124), bool(+128), int(+120) — NOTE: last 4 fields wire order = int,int,bool,int but struct offsets show the third int lands at +120 (read order on wire: +116, +124, +128(bool), +120) | +| 15 | SpecialRequest | C→S | u64 Guid, u64 Data | +| 16 | SpecialResponse | S→C | u64 Guid, int, bool | +| 18 | SpawnFishRun | S→C | bool, int ModelId, string TextureAlias, List | + +Sub 9 and 17 are not handled by the client (gaps, likely deprecated). + +Key client funcs: +- `FishingProcessor::OnRoutePacket` @ 0xB6CD80 — parses all S→C, huge switch. Dump: scratchpad/FishingProcessor_OnRoutePacket.c +- Field readers dumped to scratchpad/fishing_readers*.c +- FishingResult(14) inner switch on ResultType, cases 0–8: + - 0 = nothing caught ("bbe_fishing_nothing_caught"), 1 = ?, 2 = ?, 4 = caught-fish presentation (attaches fish to line "mouth"/"hinge_2"), 5 = scored catch (FishScoringData list gets 7 ints; shows name), 6/7 = "Fishing:EndCasting", 8 = special (StringHash 730504514, fallback stringId 436799) +- ClientFishEntryInfo: int FishType, int FishNameId(string id), int FishIconId, bool, bool FishSpecial, int FishLureRequirement, string, int, bool FishCatchable, int +- UnderwaterFishSpawnInfo: int, int ModelId, string TintAlias, string TextureAlias, int5, int6, bool7, int8..int17, float18 + +## MAJOR FIND: server-side fishing logic embedded in binary (~0x1076000–0x1090000) + +`sub_107B140` = ServerFishingPlayerConfig loader from `bbe_FishingPlayerConfig.txt` with ALL semantic names (struct offsets): +``` ++20 LineSnapChance +24 MinTimeBeforeFishEscapes +28 MinTimeBeforeFishStopsNibbling ++32 BobberMinDistanceFromPlayer +36 BobberRunAgroundCatchMaxDistanceFromPlayer ++40 ReelSpeedMetersPerSecond +44 HookingReelSpeedMetersPerSecond ++48 LineSnapTensionMinPercent +52 FishEscapeTensionMaxPercent ++56 MinTimeBeforeSpecial +60 MinTimeBetweenSpecials ++64 SuperReelSpeedMetersPerSecond +68 SuperReelDuration +72 MaxNibbleTime ++76 FishMaxDistanceFromHook +80 FishStartDistanceFromHook ++84 BobberModelId(int) +88 BobberSplashCompositeEffectId(int) ++92 MinCastDistance +96 MaxCastDistance ++100 MinTargetRadius +104 MaxTargetRadius +108 PerfectDistance ++112 MinBiteChance +116 MaxBiteChance +120 TensionMeterSize ++152 CameraDistance +156 CameraPitch +160 CameraHeading +164 CameraTargetHLQ +``` +- `sub_1079260` = FishingZoneConfig loader (`bbe_FishingZoneConfig.txt` + `.Node.txt` + `.SchoolPath.txt` + `.School.txt`) +- `sub_107AAE0` = another config consumer (same field names) +- FishingErrors.txt users: 0x1079260 area + 0x1079D80 +- Server-side RTTI cluster @ 0x1ba2xxx: FishingSchoolInstanceDefinition, FishingSchoolPathDefinition (HashListMap16), FishingSchoolPathNodeDefinition (HashList64), FishingZoneConfig, FishingPlayerConfig +- Player skill stats (client strings @0x175aab8): FishingPerfectCastSkill, FishingLuck, FishingReelingSpeed, FishingLineStrength, FishingCastingStrength, FishingCastingSkill (no direct code xrefs — likely used via stat-name lookup tables) + +## Client-side classes +- FishingProcessor (vft 0x1822a44), ControllerFishing (vft 0x181c2c4, 31 virtuals) +- ProxiedFishingPlayer / ProxiedFishingSchool / ProxiedFishingUnderwaterFish / ProxiedFishingBobber +- UI: FishingDataSource ("BaseClient.FishingProcessor.FishTypes"), FishingScoringDataSource (".FishScored"), FishingLureDataSource (".FishingLures") +- GUI events: FishingStatusText:Show/DisplayText, FishingCaughtStatusText, FishingDistanceMeter:FishHooked, Fishing:StartCasting/EndCasting/StartUnderwater/EndUnderwater +- Loading gate: "WaitForWorldReady: waiting for fishing processor" — client's world-ready wait includes fishing registration; if server never sends RegisterPlayerResponse in a fishing zone, loading screen never drops. + +## Client fishing flow (from FishingProcessor::Process @0xB6C290, sub_B6BB90, OnRoutePacket) + +Entering fishing: +1. Server sets character state bit 0x400000 (UpdateCharacterState) → `BaseClient::HandlePlayerUpdatePacket` calls `FishingProcessor::SetIsInFishing(true)` @0xB6BB90. +2. Client preloads models 1698/1700/1697/1699 (rod/line gear) and 1624/1670/1671/1672 (underwater fish), switches to ControllerFishing, hides nameplates, **sends RegisterPlayerRequest (sub 2, payload = u64 playerGuid)**. +3. World-ready loading loop (`BaseClient::sub_936D00`) blocks until FishingProcessor "pending registration" flag clears — cleared by receiving **RegisterPlayerResponse (sub 3)**. Server MUST respond or loading screen never drops. + +Client state machine (state = FishingProcessor+0x?? "GAP_2[3]"), per-frame Process(): +- **0 aiming**: raycast camera→water (physics hit type 8 = water). dist = |hit - player|; valid if MinCastDistance ≤ dist ≤ MaxCastDistance (packet PlayerConfig fields n2, n3 as floats). power = (dist-min)/(max-min). On cast press → anim state 1 ("Fishing:StartCasting", movement disabled). +- **1**: stores target/distance, **sends CastAnimRequest (sub 5: guid, int = distance-as-float-bits, Vector4 = direction)** relayed by server to other players; starts local cast animation → state 2. +- **2**: when anim progress ≥ 0.2 → **sends CastRequest (sub 6: guid, Vector4 = target pos, bool = ?"valid/normal cast" flag GAP_5[22])**; computes local landing point using packet PlayerConfig n1 (float: forward offset multiplier) and ZoneConfig field6 (water surface Y). +- **3**: waits for server **SpawnProxiedFishingBobber (sub 8)** (OnRoutePacket case 8: attaches bobber sim; uses PlayerConfig n7 and 3.0f and zoneWaterY and n1) — after 2s grace, sets proxied anim 4 → state 4. +- **4 bobber out / waiting bite**: server drives fish via **UpdateProxiedFishingBobber (sub 10: guid, int fishIndex, bool hooked, bool lost)**: + - hooked=false, lost=false → fish nibbling (starts nibble anim on fish index) + - hooked=true → fish hooked state (tension UI "FishingDistanceMeter:FishHooked") + - hooked=false, lost=true → escape/lost: shows "bbe_fishing_fish_escaped" or "bbe_fishing_item_lost" (if flag on fish), unhooks meter. + On reel press (with fish hooked or bobber aground) → **sends ReelInRequest (sub 7: guid, bool=1)**, proxied anim 5 → state 5. +- **5 reeling**: ticks fish anim; waits for server **FishingResult (sub 14)**. +- **6 result shown**: when anim done → anim state 0, movement re-enabled → state 0. + +FishingResult(14) ResultType switch: 0=nothing caught ("bbe_fishing_nothing_caught", proxied anim 6), 1=?(clears cast), 2=fail-type (anim 6), 4=caught-item/fish attach presentation (attaches to "mouth"/"hinge_2", uses tint/texture aliases), 5=scored catch (fills FishScored row; shows catch), 6/7="Fishing:EndCasting", 8=special message (stringHash 730504514 / fallback 436799). + +## FishingResult (sub 14) semantic layout (offsets in packet struct; wire = sequential) +``` ++16 u64 PlayerGuid ++24 int ResultType (see above) ++28 bool Caught (drives moneyshot/attach variant) ++32 int FishNameId (string table id — used for i18n lookup) ++36 int FishIconId ++40 string FishName (raw string, used if id lookup fails?) ++56 int FishModelId ++60 int FishSize (1=small 2=medium 3=large 4=extra_large; scales model 1x-4x) ++64 float TimeToCatch (seconds) ++68 int FishDifficulty ++72 int FishRarity ++76 int FishScore ++80 int ? (unused by scored UI; maybe special/loot id) ++84 string TintAlias ++100 string TextureAlias ++116 int ? +124 int ? +128 bool (checked as v200 in ResultType 1/8: "reset anim to idle") +120 int ? +``` +FishScored UI columns (sub_CD44E0): FishName, FishIconId, FishDifficulty, FishRarity, FishSize, FishScore, TimeToCatch. +FishTypes UI columns (sub_CD4420): FishType, FishName, FishIcon, FishSpecial, FishCatchable, FishLureRequirement (= ClientFishEntryInfo fields). + +## Server FishingPlayerConfig DEFAULTS (ctor @0x107B6D0; bbe_FishingPlayerConfig.txt was server-side and is lost — these compiled defaults are ground truth) +``` +LineSnapChance = 0.0 MinTimeBeforeFishEscapes = 0.0 +MinTimeBeforeFishStopsNibbling = 0.0 BobberMinDistanceFromPlayer = 0.5 +BobberRunAgroundCatchMaxDistanceFromPlayer = 5.0 +ReelSpeedMetersPerSecond = 2.0 HookingReelSpeedMetersPerSecond = 1.0 +LineSnapTensionMinPercent = 0.9 FishEscapeTensionMaxPercent = 0.1 +MinTimeBeforeSpecial = 3.0 MinTimeBetweenSpecials = 5.0 +SuperReelSpeedMetersPerSecond = 4.0 SuperReelDuration = 1.0 +MaxNibbleTime = 12.0 FishMaxDistanceFromHook = 10.0 +FishStartDistanceFromHook = 5.0 BobberModelId = 1063 (fishing_bobber_bbe.adr ✓) +BobberSplashCompositeEffectId = -1 MinCastDistance = 3.0 +MaxCastDistance = 20.0 MinTargetRadius = 0.2 +MaxTargetRadius = 5.0 PerfectDistance = 0.5 +MinBiteChance = 0.0 MaxBiteChance = 1.0 +TensionMeterSize = 1.0 (+124 unknown float = 2.0; +128..+148 six ints = 0) +CameraDistance = 6.0 CameraPitch = 0.445 CameraHeading = 1.85 CameraTargetHLQ = 0.2 +``` +Camera block matches the 4 floats (f10..f13) in the wire FishingPlayerConfig → wire packet config fields are a SUBSET of the server config. Known wire mappings: n1 = forward-offset multiplier (likely BobberMinDistanceFromPlayer or similar), n2 = MinCastDistance, n3 = MaxCastDistance, n7 = bobber-sim param, f10..f13 = camera block. (Exact full mapping pending server-side response writer.) + +## DEFINITIVE: bite is SERVER-DRIVEN via UpdateProxiedFishingBobber (sub 10) + +`UpdateProxiedFishingBobber` wire = [u64 Guid(player), int FishIndex, bool Flag1, bool Flag2]. +FishIndex matches `UnderwaterFishSpawnInfo.Unknown` (the fish id) — resolved by `sub_B64710(fishIndex)` which walks the underwater-fish list matching `fish+20 == index`. + +OnRoutePacket case 10 flag→fish-flag mapping (fish object byte offsets): +- **Flag1=true** → fish[+217]=1 (HOOKED), clears +216/+218/+220. → tension/"FishHooked" UI; fish anim state → 4 (splash CE 16265) → 5 (fighting). This is the bite/hook. +- **Flag1=false, Flag2=true** → fish[+216]=1 (INTERESTED). Fish swims toward hook & nibbles (anim state 1→2). Player-side nibble anim via sub_CCF6D0. +- **Flag1=false, Flag2=false** → fish[+218]=1 (ESCAPED/LOST), clears +216/+217. Shows "bbe_fishing_fish_escaped" or "bbe_fishing_item_lost" (if fish+16 set). Fish anim → 8 (gone). + +Fish internal state machine `sub_CD1A10(fish, dtSeconds)` (state @ fish+32, anim @ fish+28): +1 = wander (initial post-spawn) → when +216 set, seeks hook → 2 +2 = approach hook; when close & +217 set → 4; when +220 set → 1 +3 = circle/retreat; +217→4; +220→1 +4 = HOOK transition: plays splash composite effect 16265, → 5 +5 = hooked/fighting; timer>~1.2 clears +217 sets +219; timer>threshold → 6 +6 = nibble-wander/flee; +220→7; +218==1→8 +7 = reeled toward player +8 = escaped/gone +Flags: +216 interested, +217 hooked, +218 escaped, +219 (transient), +220 landed/reeled-success. + +**m_ProxiedFishingUnderwaterFish is ONLY populated by SpawnFishRun (sub 18).** Without it, `m_ProxiedFishingUnderwaterFish.m_head == null` and the reel path in Process case 4 can't fire → no catch possible. So the server MUST send SpawnFishRun after the cast to make a catchable fish. + +`sub_CD1720(fish)` = returns fish[+217] (the hooked flag). Reel gate in Process case 4: +`reelPressed && fishHead && (fishHead.GAP[12] || !hooked)`. + +## Reconstructed REQUIRED server→client sequence for one catch (RE-grounded) +1. Recv RegisterPlayerRequest(2) → send RegisterPlayerResponse(3): FishingPlayerConfig (camera floats f10..13 = 6.0/0.445/1.85/0.2 matter for camera), FishingZoneConfig (Unknown6 → water Y baseline; Unknown3 → fish-run X baseline), FishModelIds, ClientFishEntries (≥1 catchable). Unblocks loading. + Also send UpdateData(1) (player guid+pos). [SpawnProxiedFishingSchool(11) is ambient decoration — optional, and must be at/below water, not +2 above.] +2. Recv CastRequest(6) → send SpawnProxiedFishingBobber(8){Guid=bobberGuid, Position=cast target}; then SpawnFishRun(18){one UnderwaterFishSpawnInfo, ModelId∈{1670 thin,1671 med,1672 fat}, Unknown=fishId}. +3. After nibble delay → UpdateProxiedFishingBobber(10){Guid=player, FishIndex=fishId, Flag1=false, Flag2=true} (interested/nibble). +4. After hook delay → UpdateProxiedFishingBobber(10){..., Flag1=true} (hooked). Client shows tension UI; player reels. +5. Recv ReelInRequest(7) → send FishingResult(14){ResultType=5 (scored catch), Caught=true, FishId, FishName, scoring ints}. [If player never reels within escape window → send UpdateProxiedFishingBobber(0,0) escape + FishingResult ResultType=0 nothing-caught.] + +## SERVER BUGS IDENTIFIED in current implementation +1. **SpawnProxiedFishingBobber.Guid = Random** (CastRequestHandler) — WRONG. Client `sub_B64300(guid)` looks the bobber's Guid up as the ProxiedFishingPlayer. Must be the **player's guid**. Because of this the `IsCurrentPlayer` check fails and the local player's state machine never advances to state 3 → fishing is stuck. Same applies to UpdateProxiedFishingBobber.Guid, UpdateData.Guid, FishingResult.Guid — all = player guid. +2. Never sends SpawnFishRun(18) → no catchable underwater fish → reel path unreachable → no catch. +3. Never sends UpdateProxiedFishingBobber(10) → fish never becomes interested/hooked. +4. ReelIn immediately returns FishingResult ResultType=1 (likely wrong; scored catch = 5), regardless of hook state. +5. RegisterPlayer hardcodes zone 563; ignores which activity the player joined. +6. Ambient schools spawn at spawnPos.Y + 2f (above water) — the folder-name bug. Should be at/below the water plane. + +## Server implementation plan (state machine, RE-grounded) +Per-player FishingSession, ticked from zone tick (Player.UpdateEveryTick) or gateway loop: +- OnCast(target): send SpawnProxiedFishingBobber{Guid=player, Pos=target}; send SpawnFishRun{1 catchable fish id=1, model 1670/1671/1672}; state=BobberOut; interestAt=now+rand(1-3s) +- tick BobberOut→now>=interestAt: UpdateProxiedFishingBobber{player, fishId, Flag1=0,Flag2=1}; state=Nibbling; hookAt=now+rand(2-4s) +- tick Nibbling→now>=hookAt: UpdateProxiedFishingBobber{player, fishId, Flag1=1}; state=Hooked; escapeAt=now+~8s +- tick Hooked→now>=escapeAt: UpdateProxiedFishingBobber{player,fishId,0,0}(escape) + FishingResult{ResultType=0 nothing}; state=Idle +- OnReel: if Hooked → FishingResult{ResultType=5,Caught=true,...}; else nothing-caught; state=Idle + +## IMPLEMENTATION STATUS (v1 — builds clean, full solution) +Done in this session: +- New `FishingSession` (Sanctuary.Gateway/Fishing/FishingSession.cs): per-player state machine (Idle→BobberOut→Nibbling→Hooked), server-driven bite via UpdateProxiedFishingBobber, resolves catch on reel. +- `FishingSessions` (FishingSessionState.cs rewritten): registry keyed by player guid + `Tick()` driven from GatewayService main loop. +- Handlers rewired: MiniGameStart creates session + records zone; Register uses recorded zone + real water-Y/X floats + fish entries for models 1670/1671/1672 + schools moved to waterY-0.5 (fixes "fish over water"); Cast→session.OnCast (bobber guid = PLAYER guid, +SpawnFishRun); ReelIn→session.OnReel; disconnect→FishingSessions.Remove. +- FishingZoneConfig.Unknown3/Unknown6 retyped int→float (client reads them as floats). + +REMAINING TO VERIFY (needs live test + catch-sequence agent): +- FishingResult ResultType for a *successful* catch (using 5) and the 7-int scoring field mapping (FishName/IconId/Difficulty/Rarity/Size/Score/TimeToCatch order per client case-5). +- SpawnFishRun UnderwaterFishSpawnInfo.Unknown7 semantics (catchable vs ambient) and whether one fish suffices. +- Reel gate: confirm client allows ReelInRequest while hooked (Process case 4 gate). +- Bite timing values (interest 1-3s, hook after 2-4s, escape window 8s) — tune to feel. + +## v2 — catch-sequence agent findings integrated (builds clean) +Confirmed by client-enforcement analysis (FishingProcessor::Process / OnRoutePacket / fish tick sub_CD1A10): +- **Cast validation gate**: aiming only fires if raycast distance ∈ [PlayerConfig.Unknown2, Unknown3] read as FLOATS. v1 had int 10/1 (≈0, inverted) → casting impossible. FIXED: Unknown2=3.0f (min), Unknown3=20.0f (max). Also requires attachment group 7 (fishing pole equipped) — client prerequisite. +- **Required S→C for a catch**: RegisterPlayerResponse(3) [sane cast dists + water Y + camera], SpawnProxiedFishingBobber(8) [guid=player, BEFORE fish run — case 18 derefs bobber], SpawnFishRun(18) [≥1 fish, FIRST must have Unknown7=1 catchable, sizeClass 1..4], FishingResult(14) ResultType=5. +- **UpdateProxiedFishingBobber(10) is COSMETIC** (not enforced): (0,1)=approach/nibble, (1,x)=strike/bite visual, (0,0)=escape. Reel is permitted anytime for a catchable head fish; the server adjudicates via FishingResult. We still send them for correct visuals. +- **FishingResult scoring map** (case-5 fill → columns FishName/IconId/Difficulty/Rarity/Size/Score/TimeToCatch): FishName=@40, IconId=@36(Unknown1), Difficulty=@68(Unknown5), Rarity=@72(Unknown6), Size=@60(Unknown3), Score=@76(Unknown7), TimeToCatch=@64(Unknown4). Held-fish size scale=@120(Unknown12, 1..4). Caught(@28)= "junk item" flag → **0/false = real fish scaled by size** (set Caught=false for a normal fish). +- **Client does NOT grant the item** — FishingResult is display-only. Inventory must be updated by a separate packet (TODO, follow-up). +- Client→server messages in the whole cycle: RegisterPlayerRequest(2), CastAnimRequest(5), CastRequest(6), ReelInRequest(7), SpecialRequest(15). No bite ack. + +## TO TEST (needs user) +1. Restart Sanctuary.Gateway (running instance locks the old DLL; new build won't load until restart). +2. Equip a fishing pole, enter a fishing activity (560-565), cast, wait for the bite (~3-7s), reel. +3. Watch Gateway logs: register → cast → (interested→hooked timers) → reel → result. +Follow-ups after it visually works: grant the caught fish to inventory (separate packet), real per-zone fish/loot table, tune bite timing, verify ambient school placement in-water. + +## Live test #2 (02:19-02:21): cast worked, client CRASHED on cast — root cause found +- Dispatch fix confirmed: server logged "Player 17 cast at <449,-65,365> flag=True". Register/zone/castanim/cast all deserialize now. +- Client hard-crashed right after cast (no lua/asset error at cast time; native fault). Root cause via IDA: + - `SpawnProxiedFishingBobber.Unknown` (int, 2nd field) = the **bobber model id**, stored to ProxiedFishingPlayer+216. + - Client `sub_CCFDB0` only constructs the bobber object (ProxiedFishingBobber via `sub_CD3CF0`, stored at +212=[16]) when that model id `> 0`. + - We sent `Unknown=0` → bobber never created → `SpawnFishRun` (case 18) derefs the null bobber `[16]` (via `sub_CD3C90`/`sub_CD3CD0` = ptr+320) to position the fish at the hook → access violation → crash. + - FIX (commit 9afdf74): send `Unknown = BobberModelId = 1063` (fishing_bobber_bbe.adr). Bobber packet + fish-run sent in-order over reliable channel, so the bobber object exists before the fish-run derefs it. Bonus: bobber now visually appears. +- Client logs dir: `C:\Users\bobya\AppData\Local\OSFRLauncher\Servers\EDITz's Local Server\Client\Logs` (FreeRealms.log, LuaErrors.log, ActorFailures.log, AssetFailure.log). Asset 404s for sg_fishing_medpond tiles are a separate asset-server issue, not the crash. + +## Live test #3+#4: core loop works; two issues +### "Nothing Caught" (FIXED, commit 41e0bed) +Client shows the fish on the line as soon as we signal interest; its reel gate lets the player reel +whenever a catchable fish exists; server is authoritative. Our OnReel only caught in strict Hooked +phase, so reeling during Nibbling returned nothing. Now: reel during Nibbling|Hooked -> catch. + +### "Fish/camera up in the sky instead of underwater" (POSITIONING — needs correct spawn coords) +`FishingProcessor::sub_B640E0` @0xB640E0 places the underwater fish at world position: + X = ZoneConfig.Unknown3 + distance*ZoneConfig.Unknown2 ; **Y = -8.0 (HARDCODED)** ; **Z = 485.0 (HARDCODED)** +(during reel state 5, Y interpolates -8..-5). So the underwater "arena" is anchored at a FIXED world +Y=-8, Z=485 (only X is config-driven). We spawn the player in the overworld pond at (435, -64, 370) +where the water surface is ~Y=-65 → the fish arena renders 57 units ABOVE the real water = "in the sky". +Client Y/Z are immutable literals; no packet/config can move the fish to the overworld water. +=> FIX must be POSITIONAL: spawn the player in sg_fishing_medpond where the water is at Y≈-8 near Z≈485, + and set ZoneConfig.Unknown3 to that water's X. Current (435,-64,370) is the wrong elevation/spot. + Need the real sg_fishing_medpond fishing coordinates (no zone geometry on server side to derive them). + Experiment to try: spawn ≈ (435, -8, 470) facing +Z, Unknown3=435 (risk: may break the working cast + if no water at Y=-8 there — easily reverted in FishingActivityZones.cs). + +## Fish movement params + catch banner (commit 040d25b) +### UnderwaterFishSpawnInfo Unknown8..17 are FLOATS (int 1 = 1.4e-45 ~0 = frozen fish) +Wire field -> fish tick meaning -> lively value (ambient value): +Unknown5=sizeClass int 1..4 | Unknown6 unused | Unknown7 catchable bool (catchable MUST be head entry) +Unknown8 approach time (>0.25) 1.5 | Unknown9 reel divisor 2.0 | Unknown10 nibble/flee speed 2.0(1.0) +Unknown11 unused 1.0 | Unknown12 reel base offset 1.0 | Unknown13 turn speed 3.0(2.0) +Unknown14 WANDER speed 1.0(0.75) | Unknown15 wander decel 0.5 | Unknown16 approach/wander turn 3.0(2.0) +Unknown17 wander idle-min 1.0(2.0) | Unknown18 wander idle-max 3.0(5.0) +Transitions: wander->approach when interested(216) set at end of state1; approach->bite when fish reaches hook AND hooked(217) set. `this+58`(reel numerator) and `this+85`(stop dist) are auto-computed, not from server. + +### Bite: UpdateProxiedFishingBobber flags are EXCLUSIVE not additive +Must send interested(Flag2=true) FIRST (state1 only checks 216); sending hooked while wandering does nothing (it clears 216). Fish swims to hook then bites on arrival (hooked latches). Hardcoded: 1.2s into fight hooked auto-clears, 1.7s state5->6(nibble); reel works once in state6. + +### FishingResult field offsets (verified) + why RT5 showed nothing +@16 guid(=local player) @24 ResultType @28 bool(false=show size+weight) @32 NAME string-table id(NOT model) +@36 int @40 string(unused) @56 float WEIGHT("%2.2f") @60 int SIZE 1..4 @64 float @68/@72/@76 scoring +@80 int held-fish MODEL — **MUST be >0 or NO banner** (we sent 0!) @84/@100 tint/texture @116 sparkle CE +@124 int @128 bool @120 int size class (LAST on wire). +3 gates block the banner (any one): @32 name id invalid->blank name; @80<=0 -> no show-off -> no banner block; guid != local player. +Full catch sequence: interested -> hooked -> (fish bites/fights) -> on ReelIn: RT4(drag anim, fish->player) -> ~1.8s -> RT5(catch banner + show-off, auto-returns camera). Optional RT7=EndCasting. + +## SOLVED: "fish in the sky" = wrong Unknown3 (commit 6256066) +Client hardcodes underwater fish arena Y=-8, Z=485; only X = ZoneConfig.Unknown3 (+dist*Unknown2). +Every fishing map's `Areas.xml` (dumped client assets: C:\Users\bobya\Documents\Free Realms Unpacker\editz fr assets\FR Assets 2025-07-07) has an **Underwater_Bed** area — the dedicated underwater scene — at consistent X~55-74, Y~-2..0, Z~482-486. The hardcoded arena sits inside it (fish ~6-8 below the bed surface = underwater). Water heights DIFFER per map (sg pond -65, bw -2, sh 0) — the user was right that a single hardcode can't be universal; the trick is the client anchors to the per-map bed and only X varies. +Underwater_Bed X per activity: 560/562 bw_medpond=74, 561 bw_stream=58, 563 sg_medpond=68, 564 sh_medpond=55, 565 sh_stream=69. +FIX: FishingZoneConfig.Unknown3 = zone.UnderwaterBedX (was overworld pond X=435 -> fish above the -65 pond). Unknown6 stays the overworld water Y (bobber height where you cast). This is decoupled: bobber in overworld pond, fish in the underwater bed; the fishing camera dives to the bed. + +## Real fish name/icon ids (ClientItemDefinitions.json), commit 3b0f13d +Swurgle Fish name 6170 icon 4594 | Calico Catfish 6171/4595 | Globfish 6169/4593. FishingResult @32=nameId, @36=iconId. + +## Animation choreography (commit 78dc9d5) — catchable fish is FROZEN; animate an AMBIENT fish +Client sub_CD1A10: the catchable fish (Unknown7=true) has both +16=1 (catchable) AND starts in state 6. +- state 2 begins `if(+16){state=6}` — catchable can never approach. +- state 6 begins `if(+16) goto LABEL_93` — skips wander/nibble anim; only checks +220(reel)/+218(escape); NEVER checks +216/+217. +=> Sending interested/hooked to the catchable head fish does NOTHING. Only AMBIENT fish (Unknown7=false, spawn in state 1) do approach(2)/lunge-bite(4, anim 103 size1-2 / 104 size3-4 + splash CE 16265)/fight(5)/nibble(6, anim 151)/reel-up(7). +The 3-D "!"/"?" marks (15940-15955) are VESTIGIAL in this build (stored in sub_CD3A70, never read). Hooked cue = GUI meter/status text (already fires from packet handlers). Don't chase them. +Reel gate (Process case 4) requires the HEAD fish to be catchable (GAP[12]=+16) → keep a catchable head fish (frozen at hook) + drive an ambient biter. +"Current fish" UNK[8] (which RT4 reels) = head fish id at SpawnFishRun, OVERWRITTEN by each UpdateProxiedFishingBobber to its FishIndex. So aim the biter's bobber packets AND RT4 at the same ambient index. +RT4 reels only a fish in state 6 (post-fight, ~1.7s after hook; hardcoded dword_1B79D1C). Player/rod/camera anims are automatic from the packets (proxied anims 7001-7013 via sub_CCFFB0; no extra server packets needed). +SERVER SEQUENCE: cast → SpawnFishRun[catchable head id1 + ambient biter id2 (caught model) + wanderers] → UpdateBobber(interested,idx2) → ~1.5s → UpdateBobber(hooked,idx2) [lunge/bite/fight] → on ReelIn wait FightDurationMs(1.9s) → RT4 [reel-up] → RT5 [catch]. + +## Yellow "item received" text = RewardBundle opcode 50, sub-type 2 (commit 8a1851b) +Plain ClientUpdatePacketItemAdd (opcode 2) adds to bag SILENTLY (no bottom text). The yellow text is a +RewardBundle packet: opcode 50 → client OnTunneledClientPacket2 case 50 → ClientRewardManager sub_B8A640 +→ switch on a byte type (1=bundle,2=single item,6/7=xp/coin bank). Type 2 → sub_B891F0 read → sub_B899F0 +PlayItemReceivedNotification → sub_A0C3A0 AddItemReceived → GUI event "ItemReceived" (yellow, 6s). +Wire (27 bytes, LE): int16 opcode=50 | uint8 type=2 | int32 itemDefId | int32 itemDefId2(=itemDefId) | +int32 iconId(0=default) | int32 tintId(0=default) | int32 count | int32 trailing(0). All 27 bytes required. +DISPLAY-ONLY (read-only def lookup) — still grant the item via ClientUpdatePacketItemAdd. Sent from Player.GiveItem. + +## Open questions +- [ ] Exact trigger/conditions for client sending CastRequest(6) vs CastAnimRequest(5), and bool meanings +- [ ] Who simulates bite timing in live FR (server sends UpdateProxiedFishingBobber → server-side sim) +- [ ] RegisterPlayerRequest(2) payload +- [ ] FishingResult(14) semantic mapping of the 7 ints + 2 strings (likely score/size/rarity/difficulty + tint/texture alias) +- [ ] SpecialRequest/Response (15/16) purpose ("special" = treasure/rare event timer in config) +- [ ] Server-side session tick logic in 0x107x–0x108x region (bite chance roll, tension, line snap) +- [ ] bbe_*.txt config assets — extract actual values from game assets (fr-adr-toolkit may read pack files) + +## SpawnFishRun (sub 18) — the "stationary fish in the center" / Unknown2 decoy + +`FishingProcessor::OnRoutePacket` case 18 @ 0xB6D3AB, per-fish creator `ProxiedFishingUnderwaterFish::sub_CD3A70` @ 0xCD3A70. + +UnderwaterFishSpawnInfo.Unknown7 is the **catchable** byte (struct off +0x30). Per fish: +- **Non-catchable** → created as a wandering fish (random target, state 1/wander). +- **Catchable** → `sub_CD3A70` starts it in **state 6** (nibble-at-hook) and attaches the `!`/`?` alert + composite effects sized by Unknown5 (1→15940/15941, 2→15950/15953, 3→15951/15954, 4→15952/15955). +- Loop rule @ 0xB6D4C4: a **non-head** fish that is catchable is **skipped** (not created); the head fish + is always created; catchable fish are parked at the hook, non-catchable get a random wander pos. + +After building the list, @ 0xB6D806 the client checks the **head** fish's catchable byte: +- **head NOT catchable** → `loc_B6D9F9`: spawns a **separate decoy actor** from the packet's `Unknown2` + model (+ optional `Unknown3` texture) parked at the hook center, stored at proc+0x304/+0x308, plus the + `sg_fishing_lure_bbe` lure. This un-animated decoy is the **"stationary fish in the center"**. + Note: `Unknown2 = 0` **crashes** — `ModelDefinitionManager::sub_726E30(0)` returns null and the caller + immediately does `mov esi,[eax+24h]` @ 0xB6DA36 (null deref). Do NOT zero it. +- **head catchable** → `hinge_2` branch (fish attached to the line hinge) with a clean null-actor skip; **no decoy**. + +**Fix:** make the FIRST fish in the run catchable (Unknown7=true). This removes the decoy and gives the +native "fish nibbling at your lure" (state 6 + !/? bubbles). Keep a separate non-catchable biter fish to +retain the swim-in + lunge (state 2→4) bite animation; drive it via UpdateProxiedFishingBobber as before. + +Bite-flag wiring confirmed from case 10 @ 0xB6DD83 → fish bytes: interested(Flag2)→fish+0xD8 (this+216), +hooked(Flag1)→fish+0xD9 (this+217, triggers state 2→4 lunge), neither→fish+0xDA (this+218, state 6→8 escape). + +### Hiding the forced hook decoy (chosen fix) + +The decoy actor at proc+0x304 is built from the packet's Unknown2 model; the only packet levers are +Unknown2 (model) and Unknown3 (texture). `ActorManager::CreateActor` @ 0x79D5F0 never returns null for a +bad model (geometry loads async), so the null-actor skip is unreachable; `SetTextureAlias` with a bogus +alias doesn't reliably hide it; scale/tint/position are not packet-controlled for the decoy. + +Fix: set **Unknown2 = 69** (`widget_01.adr`, "Invisible Block" in ClientModelDefinitions) so the forced +hook actor has no visible mesh. It is registered (no `sub_726E30` null-deref crash) and invisible. The +wandering fish and the driven biter use their own model ids and are unaffected; the decoy still +self-despawns on bite, so this only hides the otherwise-visible pre-bite phase. + +## Bobber / line placement + +The bobber comes from SpawnProxiedFishingBobber (sub 8) @ OnRoutePacket case 8 (0xB6D1CA): it sets the +fishing player's bobber-model field (+0xD8 = packet Unknown) and calls `sub_CCFDB0`, which builds the +bobber actor + fishing line (`sub_CD42A0` → `sub_CD3E20` raycast) at the packet Position. The bobber +model/assets (fishing_bobber_bbe.adr, model 1063) load fine (confirmed in the client IndirectAssets log). + +Bug: we were sending the bobber at the **overworld cast spot** (~<450, -65, 380>), but the fishing camera +frames the hardcoded underwater bed (X=UnderwaterBedX, Y=-8, Z=485), ~400 units away — so the bobber was +spawned off-camera. Fix: send the bobber at the bed's water surface (UnderwaterBedX, +2, 485). Y=+2 = the +arena floor (-8) + the client's surface-lure offset (dword_18227AC = 10). + +## Fishing line (rod -> bobber) — why it only shows at the bite + +Line render lives in the bobber tick `sub_CD0150` (called per ProxiedFishingPlayer from Process). It +builds ONE line: from the rod's `EMITTER2` socket to the bobber's `LINE` socket. The line target field +ProxiedFishingPlayer+0xD4 is set to the bobber object in `sub_CCFDB0`. The line object (+0x130) is created +once, when ALL hold: bobber(+0xD4) set, bobber actor has a `LINE` socket, and the proxied character's +attachment group 7 (the fishing rod) resolves (`GetProxiedCharacterAttachmentGroupManager` -> +`sub_72A110(7)` -> `sub_72F4D0`). + +Case 8 (SpawnProxiedFishingBobber) calls `sub_B64580(this, IsCurrentPlayer ? 3 : 4)` — the CURRENT player +gets fishing-player state 3 (no `sub_CCFFB0` case), observers get state 4 (-> `sub_963DA0(char,1)`, which +just sets the 0x40 "fishing" bit). `sub_963DA0` does NOT attach the rod; group 7 is the player's equipped +pole mirrored onto the proxied character. + +Empirical (live test): lengthening the approach to 5-7s did NOT reveal the line earlier — it still appears +exactly at the hooked update, not on a timer. So the gate is the hooked/reeling state (rod attachment / +reeling pose making the EMITTER2<->LINE endpoints valid), not elapsed time. Pre-bite line for the CURRENT +player is the local ControllerFishing cast, which the server-driven proxied path does not currently engage. +Open problem: get group-7 (rod) valid, or drive the local cast, before the bite. + +## Correction: the current player DOES have a proxied character + +The fishing-avatar experiment (route the bobber to a separate proxied char) CRASHED the client on +cast. That is diagnostic: `SpawnFishRun` (case 18, no guid — operates on the current player's fishing +entry) null-derefs the bobber if the current player's fishing entry has none. It only had one before +because case 8 (SpawnProxiedFishingBobber, player guid) created it — and case 8 REQUIRES +`GetProxiedCharacterByGuid(player)` non-null. So the current player DOES have a proxied character; the +"you're culled, you have no proxied char" theory was wrong, and the avatar approach is invalid (reverted). + +Revised model of the remaining gap: +- The bobber object + actor and the rod->bobber line are built off the player's own proxied character. +- The line is gated on the hooked/reeling state (delay test proved it is bite-driven, not time-driven) — + the rod's reeling pose is what makes the EMITTER2<->bobber-LINE endpoints valid. In live FR the taut + line generally appears when the line has tension (reeling), so "no line before the bite" is plausibly + partly normal. +- The bobber itself is built around state 4 (~2s post-cast) at whatever position we send; by then the + camera has dived to the far underwater bed, so a world-position bobber is off-camera and a bed-position + bobber only appears ~state4≈bite. Making the bobber appear during the brief pre-cam world cast needs it + created at cast time (proxied char ready at the cast instant), which the client may not satisfy yet. + +## RegisterPlayerResponse model-id list = scene preload (FishModelIds) + +`FishingProcessor::sub_B68600` (called from the case-3 register handler) takes the response's model-id +list and, for EACH id, looks up the model and `CreateActor`s it, storing the actor in `m_ActorIds` +(FishingProcessor+0x270). `m_ActorIds` is otherwise only touched by the ctor/dtor — so this list is a +**preload/instantiate set**, not a per-frame thing. We were sending only the 3 underwater fish +(1670-1672), so the bobber (1063), lure (1673, sg_fishing_lure_bbe) and catch models streamed in lazily, +which fits the bobber/line only appearing around the bite. Fix: preload the full set +(FishingSession.PreloadModelIds = bobber + lure + underwater fish + catch fish). Watch for stray preload +actors at the world origin (CreateActor with no transform set); the 3 fish already did this unnoticed. + +Fishing model map (Models.txt): 1063 fishing_bobber_bbe (animated bobber); 1443 sg_fishing_pole_01_bbe +(rod); 1596-1619 sg_2fish/3fish_trout_{big,biggest,med}_{3,6,9,12}meter (reel-distance display); 1620-1623 +sg_fish_catch_{large,medium,xlarge,small}_bbe (held catch); 1624 treasure chest; 1641-1645 +sg_fishing_bobber01-05_bbe (in-world bobber variants); 1670-1672 fishing_{thin,medium,fat}_fish_bbe +(underwater minigame fish); 1673 sg_fishing_lure_bbe (in-world lure); 1690-1691 + 1697-1699 reticles. + +## BREAKTHROUGH: the bobber was invisible due to a zero-scale matrix (fixed) + +SpawnProxiedFishingBobber's SECOND Vector4 is NOT a quaternion — it is a FORWARD DIRECTION. Case 8 of +OnRoutePacket drops the two vectors into a matrix: row 2 = the "Rotation" vector (forward/Z axis), row 3 += Position (translation). Then `sub_770A70` @ 0x770A70 orthonormalizes it: it normalizes the rows and +REBUILDS the basis via cross products seeded from row 2. We were sending Rotation = (0,0,0,1), whose xyz +direction is (0,0,0), so every cross product came out zero and the ENTIRE 3x3 basis collapsed to zero — +the bobber matrix was zero-scale, i.e. placed at the correct position but rendered at size zero +(invisible). This is why the bobber was never visible anywhere (and it showed fine at the world origin +via the FishModelIds preload, because that path uses a normal identity transform). +Fix: send Rotation = (0,0,1,0) (a real +Z forward). Any non-zero xyz direction works. Verified the +math: forward=(0,0,1) → row0=cross((0,0,1),(0,1,0))=(-1,0,0), a valid orthonormal basis. +The wire layout of the packet is correct: Guid(8) + model(4) + Vector4 Position + Vector4 Rotation +(client reader FishingPacketSpawnBobber_Read @ 0xB66A00, two sub_8E2410 Vector reads). + +## The rod->bobber line (sub_CD0150) and its group-7 gate + +`sub_CD0150` (bobber tick, called per fishing player from Process) is the ONLY place the rod->bobber line +is built. It creates the line object ONCE (ProxiedFishingPlayer+0x130 == 0) when ALL hold: +- bobber object exists (ProxiedFishingPlayer+0xD4, set by sub_CCFDB0 at cast), +- the bobber ACTOR exists (bobber+24 = actor id) and has a `LINE` socket (Actor::sub_77A130), +- the fishing player's proxied character resolves (GetProxiedCharacterByGuid(m_llGuid @ +0x10 = our guid)), +- **`BaseAttachmentGroupManager::sub_72A110(mgr, 7)` → `BaseAttachment::sub_72F4D0` resolves, and its + `EMITTER2` socket** — i.e. the fishing pole rig as attachment GROUP 7 on the proxied character. +The line runs rod EMITTER2 socket -> bobber LINE socket via sub_B62F30. `sub_10C1270` just clamps a 0..1 +alpha onto it each tick; `sub_CD3F80` is the bobber bob/splash (uses sub_770A70 + a landing composite +effect). Neither gates line creation — the gate is purely group 7. +`ProxiedCharacter::sub_963DA0` sets a 0x40 "fishing" bit (which the pole/group-7 rig rides on); it is set +by `sub_CCFFB0` for proxied-fishing-player states 1/2/4/6. State 2 = cast pose (CastAnimRequest, case 5, +which does `sub_B64580(this,2)` → `sub_CCFFB0(player,2)`). We relay CastAnimRequest to self (sendToSelf: +true) so the rig attaches from the cast — the client already sends CastAnimRequest (confirmed in the +gateway log), so the rod IS on from the cast. + +RESOLVED — it was a TIMING/masking issue, not a missing rig. `IsCurrentPlayer` (`m_pBaseClient-> +m_pProxiedCharacter == this`) is ALWAYS true for us, so `SpawnProxiedFishingBobber` (case 8) runs +`sub_B64580(this, IsCurrentPlayer ? 3 : 4)` = **state 3**, and state 3 is a NO-OP in `sub_CCFFB0` (no +case 3). So the bobber-spawn packet never sets the fishing bit or builds the line for us; it just sets +FishingProcessor state 3 and relies on the client's LOCAL `Process` case 3 to reach **state 4** +(`sub_CCFFB0(player,4)` → `sub_CCFDB0` recreates the bobber into a line-ready form + sets the bit) +~2s later. `sub_CD0150` only builds the line on THAT state-4 bobber. The old bite timeline (interest at +spawn+1-2s, bite at spawn+~3s) landed the bite right on the spawn+2s state-4 mark, so the line only +seemed to appear "at the bite." Fix: in `OnCast`, delay interest to spawn+3.5-5s so the line settles +first. NOTE: do NOT preload the bobber model — that would let the line build on the case-8 bobber, which +state 4 then destroys (`sub_CCFDB0` frees the old bobber at its top), orphaning the line. + +## Debug: /tporigin + +Type `tporigin` (NO leading slash — the client swallows unknown /commands) in chat to teleport to (0,0,0); +`tp ` for arbitrary coords. Handler: PacketChatHandler.TryHandleCommand via +ClientUpdatePacketUpdateLocation(Teleport=true). Used to confirm the FishModelIds preload actors (fish + +whatever else) park at the world origin unpositioned, and that the bobber model itself renders. diff --git a/FISHING_WIKI.md b/FISHING_WIKI.md new file mode 100644 index 0000000..574a66e --- /dev/null +++ b/FISHING_WIKI.md @@ -0,0 +1,116 @@ +# Free Realms Fishing — Wiki reference (freerealms.fandom.com) + +Captured from the Fisherman job page and every page in `Category:Fishing` (the 6 fishing holes), +plus the fisherman NPCs and bait/lure/rod data. This is the authoritative **data model** for a 1:1 +fishing pass. The moment-to-moment mini-game mechanics (bobber/bite/reel timing, camera) are NOT on +the wiki — those are reverse-engineered from the client in `FISHING_RE_NOTES.md`. + +Source pages: Fisherman, Sacred Grove Shallows, Rainbow Lake, Darklit Lagoon, Brambleback's Bayou, +Wintery Basin, Frostbitten Banks, Chip Numbwing, Bait Bucket. (`Category:Fishing` = those 6 holes only.) + +## The job +"Ever wonder what lurks within the waters of Sacred Grove? Fishermen know all the secret spots to cast +their lines, and reel in everything from exotic fish to treasure and coin!" +- Unlock: `` Message Board in Sanctuary. +- Trainer: **Reed Stillwater** in Stillwater Crossing. +- NPCs: **Jonah Relicreel** (Rainbow Lake, "Itty Bitty Bait" quest); **Chip Numbwing** (pixie merchant/ + fisherman in Snowhill/Seaside). +- Quests: Becoming a Better Fisherman (Sanctuary); Testing the Waters (Reed Stillwater); Itty Bitty Bait. + +## Fishing holes (all "Difficulty 1") — fish by unlock level +Each hole is its own fishing mini-game instance in a zone. **Sacred Grove Shallows is our test spot** +(activity 563 / sg_fishing_medpond). + +### Sacred Grove Shallows (zone: Stillwater Crossing) +Collections: Extra Large Catch of the Day, Wilds Stream Fish, Wilds Pond Fish +| Fish | Min level | +|------|-----------| +| Slugmud Skipper | 1 | +| Tickled Trout | 1 | +| Flutterfish | 1 | +| Butter Flyfish | 5 | +| Cheery Salmon | 10 | +| Chipsen Fish | 14 | +| Feral Catfish | 17 | +| Baconfish | 20 | +| Tangletooth Shark | 20 | + +### Rainbow Lake (zone: Lakeshore) +Collections: Extra Large Catch of the Day, Wilds Stream Fish, Wilds Pond Fish +Flutterfish (1), Calico Catfish (1), Tickled Trout (1), Toothy Tetra (5), Peachy Perch (10), +Finless Fish (14), Lady Tetra (17), Tangletooth Shark (20) + +### Brambleback's Bayou (zone: Bristlewood) +Collections: Briarwood Pond Fish, Briarwood Stream Fish, Extra Large and in Charge +Creeping Cod (1), Bitter Betta (1), Globfish (1), Blind Swurglefish (5), Changed Salmon (10), +Fanged Grouper (14), Briar Nibbler (17), Bitter Betta (20) + +### Darklit Lagoon (zone: Bristlewood) +Collections: Briarwood Pond Fish, Briarwood Stream Fish, Extra Large and in Charge +Ink Cod (1), Creeping Cod (1), Golden Scaled Nettler (1), Thorny Trout (5), Old Sole (10), +Purplenosed Shark (14), Roach Loach (17) + +### Wintery Basin (zone: Snowhill) +Collections: Snowhill Pond Fish, Snowhill Stream Fish, Extra Large Catch of the Day +Frozen Char (1), Winter Piranha (1), Frostgill Smelt (5), Spineless Stickleback (10), +Blubracuda (14), Coach Loach (17), Ferocious Fangler (20) + +### Frostbitten Banks (zone: Snowhill) +Collections: Snowhill Pond Fish, Snowhill Stream Fish, Extra Large Catch of the Day +Chilly Grouper (1), Winter Piranha (1), Frostgill Smelt (5), Pacu Pacu (5), Blue Thornfin (10), +Spineless Stickleback (10), Goofy Grouper (14), Talking Bass (17), Ferocious Fangler (20), +Plattypus (20) + +## Rods (→ cast distance; maps to FishingPlayerConfig Min/MaxCastDistance) +- Simple Bamboo Fishing Rod — "cast a short distance" +- Golden Reel Fishing Rod — "cast a short distance" +- Metal Fishing Rod — "cast a greater distance" +- Red Scoped Fishing Rod — "cast a greater distance" +- Golden Scoped Fishing Rod — "fish in the deepest of waters!" +Tools come from the Coin Shop, quests, or reeled-in treasure chests. + +## Lures → +10% catch chance for 3 specific fish each +Client system: `FishingProcessor.m_FishLureRequirementList` + `FishingLureDataSource`; each +ClientFishEntryInfo carries a lure-requirement id (see FISHING_RE_NOTES.md). A matching equipped lure +raises that fish's odds ~10%. + +| Lure | +10% chance for | +|------|-----------------| +| 16oz Steak | Winter Piranha, Toothy Tetra, Blind Swurglefish | +| Flyfisher 3000 | Chilly Grouper, Butter Flyfish, Briar Nibbler | +| French Fry | Blubbercuda, Baconfish, Changed Salmon | +| Frostflies | Frostgill Smelt, Flutterfish, Ink Cod | +| Mega Slider | Goofy Grouper, Finless Fish, Fanged Grouper | +| Nightcrawlers | Plattypus, Lady Tetra, Roach Loach | +| Perch Pinpointer | Pacu Pacu, Peachy Perch, Globfish | +| Shiny Crankbait | Coach Loach, Feral Catfish, Creeping Cod | +| Skipper Seeker | Frozen Char, Slugmud Skipper, Old Sole | +| Sleek Clicker | Talking Bass, Cheery Salmon, Bitter Betta | +| Thorn Jig | Blue Thornfin, Chipsen Fish, Thorny Trout | +| Tiny Rib | Spineless Stickleback, Calico Catfish, Purplenosed Shark | +| Treasure Magnet | Treasure (sg_fishing_treasure_chest_bbe, model 1624) | + +## Master fish list (min level; holes) +Baconfish(20; SG) · Bitter Betta(1/20; Bayou) · Blind Swurglefish(5; Bayou) · Blubracuda(14; Wintery) · +Blue Thornfin(10; Frostbitten) · Briar Nibbler(17; Bayou) · Butter Flyfish(5; SG) · Calico Catfish(1; +Rainbow) · Changed Salmon(10; Bayou) · Cheery Salmon(10; SG) · Chilly Grouper(1; Frostbitten) · +Chipsen Fish(14; SG) · Coach Loach(17; Wintery) · Creeping Cod(1; Bayou/Darklit) · Fanged Grouper(14; +Bayou) · Feral Catfish(17; SG) · Ferocious Fangler(20; Wintery/Frostbitten) · Finless Fish(14; Rainbow) +· Flutterfish(1; SG/Rainbow) · Frostgill Smelt(5; Wintery/Frostbitten) · Frozen Char(1; Wintery) · +Globfish(1; Bayou) · Goofy Grouper(14; Frostbitten) · Golden Scaled Nettler(1; Darklit) · Ink Cod(1; +Darklit) · Lady Tetra(17; Rainbow) · Old Sole(10; Darklit) · Pacu Pacu(5; Frostbitten) · Peachy Perch(10; +Rainbow) · Plattypus(20; Frostbitten) · Purplenosed Shark(14; Darklit) · Roach Loach(17; Darklit) · +Slugmud Skipper(1; SG) · Spineless Stickleback(10; Wintery/Frostbitten) · Talking Bass(17; Frostbitten) +· Tangletooth Shark(20; SG/Rainbow) · Thorny Trout(5; Darklit) · Tickled Trout(1; SG/Rainbow) · Toothy +Tetra(5; Rainbow) · Winter Piranha(1; Wintery/Frostbitten) + +## Notes for the 1:1 pass +- **Our current FishTable is wrong for Sacred Grove.** We roll "Swurgle Fish / Calico Catfish / Globfish" + — but those belong to Brambleback's Bayou (Globfish, Blind Swurglefish) and Rainbow Lake (Calico + Catfish). Sacred Grove Shallows' real fish are Slugmud Skipper, Tickled Trout, Flutterfish, Butter + Flyfish, Cheery Salmon, Chipsen Fish, Feral Catfish, Baconfish, Tangletooth Shark. +- A proper pass builds a **per-activity fish table** keyed by the zone's Underwater_Bed, each fish with + its ClientItemDefinitions item/name/icon id, min level, size, and lure requirement, then rolls by + player level + rarity (and applies the +10% lure bonus). +- Higher fish require higher fisherman level (1/5/10/14/17/20 tiers). All holes are "Difficulty 1". +- Treasure (chest, model 1624) is a possible catch, boosted by the Treasure Magnet lure. diff --git a/src/Sanctuary.Game/Entities/AmbientFishNpc.cs b/src/Sanctuary.Game/Entities/AmbientFishNpc.cs new file mode 100644 index 0000000..0e95f6e --- /dev/null +++ b/src/Sanctuary.Game/Entities/AmbientFishNpc.cs @@ -0,0 +1,102 @@ +using System; +using System.Numerics; + +using Sanctuary.Game.Zones; +using Sanctuary.Packet; + +namespace Sanctuary.Game.Entities; + +/// +/// A decorative fish that slowly wanders within a radius of a home point on a pond/stream surface, +/// so the water is populated with visibly-swimming fish in the overworld (before/while fishing) — like +/// the fish you can see in the water in real Free Realms fishing holes. +/// +/// Movement is server-driven: each tick the fish steps toward a random wander target and broadcasts its +/// new position to nearby players with (opcode 125) — the +/// same packet the server uses to relay player movement, keyed here to the fish's own NPC guid. +/// +public sealed class AmbientFishNpc : Npc +{ + private const float TickSeconds = 0.1f; // zone tick is 10 Hz (see BaseZone.FrameRate) + + private readonly Vector3 _home; + private readonly float _radius; + private readonly float _speed; // units per second + private readonly Random _random; + + private Vector3 _target; + private int _idleTicksLeft; + + public AmbientFishNpc(IZone zone, Vector3 home, float radius, float speed, int seed) : base(zone) + { + _home = home; + _radius = radius; + _speed = speed; + _random = new Random(seed); + _target = PickTarget(); + } + + /// A random point within the wander radius, on the water plane (varies X/Z, keeps the home Y). + public Vector3 PickTarget() + { + var angle = _random.NextDouble() * Math.PI * 2.0; + var dist = _radius * (float)Math.Sqrt(_random.NextDouble()); // sqrt = uniform over the disc + return new Vector3( + _home.X + (float)Math.Cos(angle) * dist, + _home.Y, + _home.Z + (float)Math.Sin(angle) * dist); + } + + public override void UpdateEveryTick() + { + if (!Visible) + return; + + // Pause briefly on arrival so the wandering doesn't look robotic. + if (_idleTicksLeft > 0) + { + _idleTicksLeft--; + return; + } + + var pos = new Vector3(Position.X, Position.Y, Position.Z); + var toTarget = _target - pos; + var distance = toTarget.Length(); + var step = _speed * TickSeconds; + + var rotation = Rotation; // Npc.Rotation is set only via UpdatePosition, so carry it locally + + if (distance <= step || distance <= 0.0001f) + { + pos = _target; + _target = PickTarget(); + _idleTicksLeft = _random.Next(5, 20); // ~0.5-2.0s settle before moving again + } + else + { + var dir = toTarget / distance; + pos += dir * step; + + // Face the swim direction (yaw from the X/Z heading). + var yaw = (float)Math.Atan2(dir.X, dir.Z); + rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, yaw); + } + + var newPosition = new Vector4(pos.X, pos.Y, pos.Z, 1f); + UpdatePosition(newPosition, rotation); // keeps our zone tile / visibility current as we roam + + var packet = new PlayerUpdatePacketUpdatePosition + { + Guid = Guid, + Position = newPosition, + Rotation = rotation, + State = 1 // moving (players send a non-zero state while walking; 0 reads as idle/teleport) + }; + + // Broadcast to every player in the zone, not just our VisiblePlayers set: a fish learns about a + // player only if that player was flagged Visible during the tile scan, which isn't guaranteed — + // so relying on VisiblePlayers can silently drop the movement updates (fish render but never move). + foreach (var player in Zone.Players) + player.SendTunneled(packet); + } +} diff --git a/src/Sanctuary.Game/Entities/Player.cs b/src/Sanctuary.Game/Entities/Player.cs index 0e9415f..0679256 100644 --- a/src/Sanctuary.Game/Entities/Player.cs +++ b/src/Sanctuary.Game/Entities/Player.cs @@ -50,6 +50,11 @@ public sealed class Player : ClientPcData, IEntity public Vector4 StartingZonePosition { get; set; } public Quaternion StartingZoneRotation { get; set; } + // Action-bar slot -> item instance id (ClientItem.Id) the player assigned there. The client's + // "use ability" request (AbilityPacketClientRequestStartAbility) only carries the action-bar slot, + // not the item, so we track assignments here to resolve which item (e.g. a fishing lure) was used. + public readonly Dictionary ActionBarAssignments = new(); + public Player(BaseZone zone, UdpConnection connection, IResourceManager resourceManager) { Zone = zone; @@ -112,6 +117,52 @@ public void SendTunneledToVisible(ISerializablePacket packet, bool sendToSelf = #endregion + #region Inventory + + /// + /// Grants an item to the player's inventory and sends the client the "item added" update + /// (which shows the pickup popup). Returns false if the item definition is unknown. + /// The item id is generated in-memory; callers that persist the item to the DB should use the + /// overload with the DB-assigned id so the in-memory + /// matches the DbItem.Id (the key other systems match on). + /// + public bool GiveItem(int definitionId, int count = 1) + => GiveItem(definitionId, count, Items.Count == 0 ? 1 : Items.Max(x => x.Id) + 1); + + /// + /// Grants an item using an explicit item id (e.g. a DB-assigned id) so the in-memory item lines up + /// with its persisted row. Returns false if the item definition is unknown. + /// + public bool GiveItem(int definitionId, int count, int itemId) + { + if (!_resourceManager.ClientItemDefinitions.TryGetValue(definitionId, out var definition)) + return false; + + var item = new ClientItem + { + Id = itemId, + Definition = definitionId, + Count = count, + Tint = 0 + }; + + Items.Add(item); + + // Add the item to the bag. + using var writer = new PacketWriter(); + item.Serialize(writer); + definition.Serialize(writer); + SendTunneled(new ClientUpdatePacketItemAdd { Payload = writer.Buffer }); + + // Show the yellow "You received: x" text at the bottom of the screen + // (RewardBundle opcode 50, single-item sub-type; display-only). + SendTunneled(new RewardBundlePacketSingleItem { ItemDefinitionId = definitionId, Count = count }); + + return true; + } + + #endregion + #region Update public void UpdateEveryTick() diff --git a/src/Sanctuary.Game/Zones/BaseZone.cs b/src/Sanctuary.Game/Zones/BaseZone.cs index e014f24..49b5e02 100644 --- a/src/Sanctuary.Game/Zones/BaseZone.cs +++ b/src/Sanctuary.Game/Zones/BaseZone.cs @@ -121,6 +121,13 @@ public bool TryCreateNpc([MaybeNullWhen(false)] out Npc npc) return _npcs.TryAdd(npc.Guid, npc) && _entities.TryAdd(npc.Guid, npc); } + /// Allocates the next unique entity guid (for zone-owned NPCs a subclass constructs itself). + protected ulong NextEntityGuid() => _uniqueGuid++; + + /// Registers an already-constructed NPC (e.g. a custom subclass) into the zone's entity set. + protected bool TryRegisterNpc(Npc npc) => + _npcs.TryAdd(npc.Guid, npc) && _entities.TryAdd(npc.Guid, npc); + public bool TryCreateMount(Player rider, MountDefinition definition, [MaybeNullWhen(false)] out Mount mount) { mount = new Mount(this, rider, definition) @@ -231,10 +238,15 @@ public void UpdateEntityZoneTile(IEntity entity, ZoneTile from, ZoneTile to) var tilesToAdd = newVisibleTiles.Except(oldVisibleTiles); var tilesToRemove = oldVisibleTiles.Except(newVisibleTiles); + // Become discoverable in our own tile BEFORE cross-notifying. Otherwise two players entering a + // tile concurrently each finish AddEntityToZoneTiles (which scans the other's tile) before the + // other has added itself here, so they miss each other — intermittent, one-directional visibility + // that only a later move fixes. Adding first guarantees the later scanner sees us (it also + // cross-notifies both directions), so co-located players always end up mutually visible. + to.Entities.TryAdd(entity.Guid, entity); + AddEntityToZoneTiles(entity, tilesToAdd); RemoveEntityFromZoneTiles(entity, tilesToRemove); - - to.Entities.TryAdd(entity.Guid, entity); } private void AddEntityToZoneTiles(IEntity entity, IEnumerable zoneTiles) diff --git a/src/Sanctuary.Game/Zones/StartingZone.cs b/src/Sanctuary.Game/Zones/StartingZone.cs index 1a13ac1..67afd8c 100644 --- a/src/Sanctuary.Game/Zones/StartingZone.cs +++ b/src/Sanctuary.Game/Zones/StartingZone.cs @@ -4,6 +4,7 @@ using System.Numerics; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Sanctuary.Core.Extensions; using Sanctuary.Core.IO; @@ -27,8 +28,75 @@ public StartingZone(StartingZoneDefinition zoneDefinition, IServiceProvider serv _zoneManager = serviceProvider.GetRequiredService(); _resourceManager = serviceProvider.GetRequiredService(); + + _logger = serviceProvider.GetRequiredService().CreateLogger("FishingScenery"); + + SpawnFishingScenery(); + } + + private readonly ILogger _logger; + + #region Fishing scenery (ambient fish + fishing-spot markers) + + // Model ids (Models.txt): 418 = fish.adr (generic overworld fish); 1684 = sg_fishing_node_01, the + // fishing-spot "whirlpool" node that marks where you can fish. Populates each fishing hole's water + // with slowly-wandering fish + spot markers, visible in the overworld before/while fishing. + private const int OverworldFishModelId = 418; + private const int FishingSpotNodeModelId = 1684; + + private void SpawnFishingScenery() + { + // TEMP/DIAGNOSTIC: spawn right at the zone login point so we can confirm the fish render + move at + // all. The player logs in here, so the school should be right in front of them on login. Once + // confirmed, we relocate to each hole's real overworld pond coords (FISHING_1TO1_PLAN.md G9). + var spawn = SpawnPosition; + SpawnFishingSpot(new Vector3(spawn.X, spawn.Y, spawn.Z), fishCount: 8, wanderRadius: 6f, seed: 1); } + private void SpawnFishingSpot(Vector3 center, int fishCount, float wanderRadius, int seed) + { + // The fishing-spot marker (the light-blue whirlpool node that shows where you can fish). + var node = new Npc(this) + { + Guid = NextEntityGuid(), + ModelId = FishingSpotNodeModelId, + Scale = 1f, + IsInteractable = false, + HideNamePlate = true + }; + + if (TryRegisterNpc(node)) + { + node.Visible = true; + node.UpdatePosition(new Vector4(center.X, center.Y, center.Z, 1f), Quaternion.Identity); + } + + // A small school of fish wandering the pond. + for (var i = 0; i < fishCount; i++) + { + var fish = new AmbientFishNpc(this, center, wanderRadius, speed: 1.2f, seed: seed * 100 + i) + { + Guid = NextEntityGuid(), + ModelId = OverworldFishModelId, + Scale = 1f, + IsInteractable = false, + HideNamePlate = true + }; + + if (!TryRegisterNpc(fish)) + continue; + + fish.Visible = true; + + var start = fish.PickTarget(); // scatter the fish across the pond to start + fish.UpdatePosition(new Vector4(start.X, start.Y, start.Z, 1f), Quaternion.Identity); + } + + _logger.LogInformation("Spawned fishing scenery: {n} fish + 1 spot marker around {center}", fishCount, center); + } + + #endregion + #region Client Is Ready public override void OnClientIsReady(Player player) diff --git a/src/Sanctuary.Gateway/Fishing/FishingActivityZones.cs b/src/Sanctuary.Gateway/Fishing/FishingActivityZones.cs new file mode 100644 index 0000000..a692544 --- /dev/null +++ b/src/Sanctuary.Gateway/Fishing/FishingActivityZones.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Numerics; + +namespace Sanctuary.Gateway.Fishing; + +public readonly record struct FishingZoneConfig( + string ZoneName, + string? Sky, + Vector4 SpawnPosition, + Quaternion SpawnRotation, + float UnderwaterBedX); + +public static class FishingActivityZones +{ + public const int FishingCharacterState = 0x400000; + + // Each fishing map has a dedicated "Underwater_Bed" area (from Areas.xml) near + // X≈55-74, Y≈-2..0, Z≈482-486. The client renders the underwater fishing scene there and + // hardcodes the fish arena at Y=-8, Z=485; only the X is configurable (ZoneConfig.Unknown3). + // So UnderwaterBedX MUST be the bed's X or the fish appears over the wrong (overworld) water. + private static readonly Dictionary ZonesByActivityId = new() + { + // Darklit Lagoon — bw_fishing_medpond UnderWater_Bed (74, -2, 482) + [560] = new("bw_fishing_medpond", null, new Vector4(200f, 0f, 200f, 1f), Quaternion.Identity, 74f), + // Brambleback's Bayou — bw_fishing_stream Underwater_Bed (58, -2, 484) + [561] = new("bw_fishing_stream", null, new Vector4(200f, 0f, 200f, 1f), Quaternion.Identity, 58f), + // Rainbow Lake — bw_fishing_medpond UnderWater_Bed (74, -2, 482) + [562] = new("bw_fishing_medpond", null, new Vector4(200f, 0f, 200f, 1f), Quaternion.Identity, 74f), + // Sacred Grove Shallows — sg_fishing_medpond Underwater_Bed (68, -2, 476) + [563] = new("sg_fishing_medpond", null, new Vector4(435.05676f, -64.46508f, 370.70682f, 1f), Quaternion.Identity, 68f), + // Wintery Basin — sh_fishing_medpond Underwater_Bed (55, 0, 486) + [564] = new("sh_fishing_medpond", null, new Vector4(200f, 0f, 200f, 1f), Quaternion.Identity, 55f), + // Frostbitten Banks — sh_fishing_stream Underwater_Bed (69, -1, 484) + [565] = new("sh_fishing_stream", null, new Vector4(200f, 0f, 200f, 1f), Quaternion.Identity, 69f), + }; + + public static bool IsFishingActivity(int activityId) => ZonesByActivityId.ContainsKey(activityId); + + public static bool TryGet(int activityId, out FishingZoneConfig config) => + ZonesByActivityId.TryGetValue(activityId, out config); +} diff --git a/src/Sanctuary.Gateway/Fishing/FishingSession.cs b/src/Sanctuary.Gateway/Fishing/FishingSession.cs new file mode 100644 index 0000000..57911de --- /dev/null +++ b/src/Sanctuary.Gateway/Fishing/FishingSession.cs @@ -0,0 +1,868 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +using Microsoft.Extensions.Logging; + +using Sanctuary.Core.IO; +using Sanctuary.Game.Entities; +using Sanctuary.Packet; +using Sanctuary.Packet.Common; + +namespace Sanctuary.Gateway.Fishing; + +/// +/// Server-side fishing state machine for a single player. +/// +/// The Free Realms client is a "terminal" for fishing: it animates the bobber and fish +/// autonomously but the *bite* is driven entirely by the server via +/// (sub-opcode 10). See FISHING_RE_NOTES.md. +/// +/// Flow (all S->C guids are the PLAYER guid — the client resolves the proxied fishing player by it): +/// RegisterPlayerRequest -> RegisterPlayerResponse (+ UpdateData + FishInfoUpdate) +/// CastRequest -> SpawnProxiedFishingBobber + SpawnFishRun (school of ambient fish) +/// (timer) -> UpdateProxiedFishingBobber(Flag2=true) = biter interested / swims in +/// (timer) -> UpdateProxiedFishingBobber(Flag1=true) = biter bites (lunge + fight) +/// ReelInRequest -> FishingResult(caught) | (timeout) escape + FishingResult(nothing) +/// +public sealed class FishingSession +{ + // Underwater-minigame fish models (client preloads these in FishingProcessor::SetInFishing). + public const int ThinFishModelId = 1670; + public const int MediumFishModelId = 1671; + public const int FatFishModelId = 1672; + + // Bobber model (fishing_bobber_bbe.adr). The SpawnProxiedFishingBobber "Unknown" field is the + // bobber model id: the client only constructs the bobber object when it is > 0 (sub_CCFDB0). + // With 0 the bobber is never created and the subsequent SpawnFishRun null-derefs it -> crash. + public const int BobberModelId = 1063; + + // sg_fishing_lure_bbe (models.txt 1673, "in-world fishing lure") — the lure on the line/water. + public const int LureModelId = 1673; + + // Held "catch" models shown in the money shot (sg_fish_catch_*_bbe), by size. The fishing minigame + // has NO per-species fish meshes — species identity is the name + icon; the 3D model is one of these + // generic "held up your catch" shapes chosen by size (matches the client's own fishing models). + public const int CatchFishSmallModelId = 1623; + public const int CatchFishMediumModelId = 1621; + public const int CatchFishLargeModelId = 1620; + public const int CatchFishXLargeModelId = 1622; + + // sg_fishing_treasure_chest_bbe (models.txt 1624) — the reeled-up treasure chest in the money shot. + public const int TreasureChestModelId = 1624; + + // Money-shot composite effects (ActorCompositeEffectDefinitions.xml). The fish sparkle plays when you + // hold up a caught fish; the chest burst is the treasure chest popping open with a gold sparkle. + public const int MoneyShotFishEffectId = 15880; // BBE_PFX_fishing_moneyshot_fish + public const int MoneyShotChestEffectId = 15881; // BBE_PFX_fishing_moneyshot_chest (bursts the chest open) + + // Models sent in the RegisterPlayerResponse model-id list. NOTE: the client CreateActors each id + // (sub_B68600 -> m_ActorIds) at the WORLD ORIGIN and never repositions them (m_ActorIds is only + // touched by the ctor/dtor) — so every id here becomes a stray actor parked at (0,0,0). We keep it + // to the underwater fish only (matches the original), so the origin isn't cluttered and a bobber + // appearing at (0,0,0) can only be the real cast bobber (a placement bug), not a preload artifact. + public static readonly int[] PreloadModelIds = + { + ThinFishModelId, MediumFishModelId, FatFishModelId, + }; + + // The client's SpawnFishRun handler ALWAYS spawns one actor at the hook from the packet's Unknown2 + // model (the "decoy" that self-despawns on bite) — there is no zero-fish path, and Unknown2=0 + // null-derefs. Point that decoy at model 69 (widget_01.adr, "Invisible Block") so the forced hook + // actor renders nothing. It is registered (so no crash) but has no visible mesh. See FISHING_RE_NOTES.md. + private const int InvisibleDecoyModelId = 69; + + // The underwater fish-arena world coords the client HARDCODES (only X is configurable, via + // FishingZoneConfig.Unknown3 = the zone's Underwater_Bed X). Confirmed as flt_18227B0 / dword_18227B4. + private const float UnderwaterBedY = -8f; + private const float UnderwaterBedZ = 485f; + // The bobber floats at the bed's water surface. The client parks the surface lure ~10 above the + // arena floor (dword_18227AC), so the surface sits at Y = -8 + 10 = +2. The bobber MUST be here + // (not at the overworld cast spot ~400 units away) or it renders outside the fishing camera. + private const float BobberSurfaceY = UnderwaterBedY + 10f; + + // FishingResult.ResultType values (client OnRoutePacket case 14 inner switch). + private const int ResultNothingCaught = 0; + private const int ResultScoredCatch = 5; + + // The ambient fish (Unknown7=false) we drive with interested/hooked so it visibly swims to the + // rod, LUNGES and bites, fights, then gets reeled up. Only ambient fish animate those states. + // UpdateProxiedFishingBobber(FishIndex=Biter) also sets the client's "current fish" so the + // subsequent reel (ResultType 4) pulls up THIS fish. + private const int BiterFishId = 2; + + // Time from hooking to the ambient fish finishing its lunge+fight and dropping into the reelable + // nibble state (client hardcodes ~1.7s). Reel (ResultType 4) only pulls the fish up once there. + private const int FightDurationMs = 1900; + + private enum Phase + { + Idle, + BobberOut, // bobber + fish spawned, ambient fish wandering + Nibbling, // biter is interested and swimming to the hook + Hooked, // biter has bitten and is fighting/on the line; player may reel + ReelPending, // player reeled; waiting for the fight to finish before the reel-up + Reeling // reel-up animation playing, catch banner pending + } + + // FishingResult ResultType values (client OnRoutePacket case 14 inner switch). + private const int ResultReelDrag = 4; // fish dragged toward the player (reel-in animation) + + private readonly object _gate = new(); + private readonly Player _player; + + private Phase _phase = Phase.Idle; + + private ulong _bobberGuid; + private long _nextEventAtMs; + private long _hookedAtMs; + private long _lastCastAtMs = long.MinValue; + + // The lure the player has active (item-def id; 0 = none). A lure gives +10% catch chance to its + // three named fish; the Treasure Magnet raises the treasure share instead. Set when the player + // activates a lure consumable (AbilityPacketClientRequestStartAbility) and cleared when they leave. + private int _activeLureItemId; + + // Selected catch for the active cast. + private int _fishModelId = ThinFishModelId; // UNDERWATER biter model (thin/medium/fat) by fish shape + private int _catchModelId = CatchFishSmallModelId; // HELD money-shot model (catch fish by size, or chest) + private bool _staticOnLine; // misc catch (treasure/junk): sits on the hook, no bite + private int _lineModelId = ThinFishModelId; // the object parked on the hook for a static catch + private bool _isItemCatch; // treasure/junk -> FishingResult.Caught=true (item, not fish) + private int _moneyShotEffectId = MoneyShotFishEffectId; // composite effect burst in the money shot + private int _fishNameId; // fish-name string-table id (@32) — localized caught message + private int _fishIconId; + private int _fishSize = 1; // 1..4, size selector (small/medium/large/xlarge) + hand scale + private int _fishDifficulty = 1; + private int _fishRarity = 1; + private int _fishScore = 10; + private float _fishWeight = 1f; // shown in the caught banner as "%2.2f" + private string _fishName = "Trout"; + + public int ActivityId { get; private set; } + public FishingZoneConfig ZoneConfig { get; private set; } + + public FishingSession(Player player) + { + _player = player; + } + + /// Records which fishing activity/zone the player entered (from the minigame-start handler). + public void SetZone(int activityId, FishingZoneConfig zoneConfig) + { + lock (_gate) + { + ActivityId = activityId; + ZoneConfig = zoneConfig; + } + } + + private static long NowMs => Environment.TickCount64; + + /// Handles a CastRequest: spawn the bobber and the catchable fish, begin the bite timeline. + public void OnCast(Vector4 targetPosition) + { + lock (_gate) + { + // The client's CastRequest is delivered several times per cast; ignore the duplicates so + // we don't spawn a stack of bobbers/fish. Guard the sentinel: on the very first cast + // `_lastCastAtMs` is long.MinValue and `castNow - long.MinValue` overflows to a negative + // value (< 500), which would drop EVERY cast and leave the player stuck casting. + var castNow = NowMs; + if (_lastCastAtMs != long.MinValue && castNow - _lastCastAtMs < 500) + return; + _lastCastAtMs = castNow; + + _bobberGuid = _player.Guid; + + // Place the bobber at the overworld cast spot (on the pond, where the player is looking). + var bobberPosition = targetPosition; + bobberPosition.W = 1f; + + // The bobber's second Vector4 is NOT a quaternion — the client (case 8 + sub_770A70) drops + // it into matrix row 2 as the FORWARD direction and rebuilds the basis from it via cross + // products. A zero direction (0,0,0,1) collapses the whole 3x3 to zero => the bobber is + // scaled to nothing (placed correctly but invisible). Send a real forward direction (+Z). + SendProxied(new FishingPacketSpawnProxiedFishingBobber + { + Guid = _player.Guid, + Unknown = BobberModelId, // bobber model id — must be > 0 or the client never creates the bobber + Position = bobberPosition, + Rotation = new Vector4(0f, 0f, 1f, 0f) // forward = +Z (must be non-zero or the bobber is zero-scaled) + }); + + // Choose the catch for this cast. + RollCatch(); + + // The client (case 18 of FishingProcessor::OnRoutePacket) ALWAYS renders one object at the + // hook. If the head fish is CATCHABLE (Unknown7=true) it parks at the hook, FROZEN in the + // nibble state, and stays the "current fish" the reel-up pulls — reeling is permitted anytime. + // If the head is NOT catchable, the client spawns a self-removing decoy from Unknown2 and we + // drive a separate ambient biter for the swim-in + lunge. See FISHING_RE_NOTES.md. + var ambientModels = new[] { ThinFishModelId, MediumFishModelId, FatFishModelId }; + + if (_staticOnLine) + { + // Treasure / junk: the item is ALREADY on the line as a static catchable head — no swim, + // no bite; the player just left-clicks (reels) to bring it up (matches the real game). + var fish = new List { CatchableFish(1, _lineModelId) }; + for (var i = 0; i < 3; i++) + fish.Add(AmbientFish(11 + i, ambientModels[i % ambientModels.Length])); // scenery wanderers + + // SELF ONLY: the client's SpawnFishRun handler (case 18) derefs the bobber with no null + // check, so a peer that hasn't created our bobber (e.g. it can't resolve our proxied + // character) CRASHES on it. The underwater fish are our private sim anyway — peers only + // need the bobber + poses + result, which all null-check safely. + _player.SendTunneled(new FishingPacketSpawnFishRun + { + Unknown = true, + Unknown2 = InvisibleDecoyModelId, + Unknown3 = string.Empty, + UnderwaterFishSpawns = fish + }); + + // Ready to reel immediately; it waits on the hook until reeled (no auto-escape). + _phase = Phase.Hooked; + _hookedAtMs = NowMs; + _nextEventAtMs = long.MaxValue; + + FishingSessions.Logger?.LogInformation( + "Fishing[{guid}] cast -> {name} is on the line (static, model {model}); reel to bring it up", + _player.Guid, _fishName, _lineModelId); + return; + } + + var biterFish = new List + { + AmbientFish(10, ambientModels[0]), // head wanderer — keeps the decoy self-removing on bite + AmbientFish(BiterFishId, _fishModelId) // the biter that swims in and lunges (matches the catch) + }; + for (var i = 0; i < 4; i++) + biterFish.Add(AmbientFish(11 + i, ambientModels[i % ambientModels.Length])); + + // SELF ONLY (see the static-catch path above): SpawnFishRun null-derefs the bobber on a peer + // that hasn't created it, so keep the underwater fish private. + _player.SendTunneled(new FishingPacketSpawnFishRun + { + Unknown = true, + Unknown2 = InvisibleDecoyModelId, // hide the forced hook decoy (invisible-block model) + Unknown3 = string.Empty, + UnderwaterFishSpawns = biterFish + }); + + _phase = Phase.BobberOut; + // Wait for the rod->bobber line to appear before engaging the fish. The client only builds + // that line once OUR proxied fishing player reaches its "bobber out & fishing" pose (state 4), + // which its local FishingProcessor does ~2s AFTER this bobber spawn (it recreates the bobber + // into a line-ready form there — see FISHING_RE_NOTES.md "group-7 gate"). Engaging the fish + // sooner made the bite land on top of that ~2s mark, so the line only ever showed up "at the + // bite". Hold interest to ~3.5-5s post-spawn so the line settles visibly first (and it reads + // like real FR, where the bobber floats a beat before a fish notices it). + _nextEventAtMs = NowMs + Random.Shared.Next(3500, 5000); + + FishingSessions.Logger?.LogInformation( + "Fishing[{guid}] cast -> spawned bobber (model {bobber}) + biter fish {fish} (model {model}) at {pos}; interest in ~{ms}ms", + _player.Guid, BobberModelId, BiterFishId, _fishModelId, bobberPosition, _nextEventAtMs - NowMs); + } + } + + /// Handles a ReelInRequest: resolve the cast to a catch or a miss. + public void OnReel() + { + lock (_gate) + { + var now = NowMs; + + if (_phase == Phase.Hooked) + { + // Reeled once the fish has bitten -> a catch. The reel-up (ResultType 4) only pulls the + // fish up after its lunge+fight finishes (~FightDurationMs after the bite); if the + // player reels sooner, wait out the fight before the reel-up so it actually animates. + var reelReadyAt = _hookedAtMs + FightDurationMs; + _phase = Phase.ReelPending; + _nextEventAtMs = reelReadyAt > now ? reelReadyAt : now; + + FishingSessions.Logger?.LogInformation( + "Fishing[{guid}] reel-in after bite -> reel-up in ~{ms}ms then catch {name} (size {size}, weight {weight})", + _player.Guid, _nextEventAtMs - now, _fishName, _fishSize, _fishWeight); + } + else if (_phase is Phase.BobberOut or Phase.Nibbling) + { + // Reeled BEFORE the bite -> the fish spooks and runs off; no catch. + FishingSessions.Logger?.LogInformation( + "Fishing[{guid}] reel-in before the bite ({phase}) -> fish runs off, nothing caught", + _player.Guid, _phase); + SendUpdateBobber(hooked: false, interested: false); // escape -> the interested fish flees + SendNothingCaught(); + _phase = Phase.Idle; + } + else + { + // ReelPending / Reeling (already resolving) or Idle: ignore. + FishingSessions.Logger?.LogInformation( + "Fishing[{guid}] reel-in while {phase} -> ignored", _player.Guid, _phase); + } + } + } + + /// Drives the bite timeline. Call periodically (server tick). + public void Update() + { + lock (_gate) + { + if (_phase == Phase.Idle) + return; + + var now = NowMs; + if (now < _nextEventAtMs) + return; + + switch (_phase) + { + case Phase.BobberOut: + // The biter becomes interested and swims to the hook. + SendUpdateBobber(hooked: false, interested: true); + _phase = Phase.Nibbling; + _nextEventAtMs = now + Random.Shared.Next(1200, 1800); // time to reach the hook + FishingSessions.Logger?.LogInformation( + "Fishing[{guid}] fish INTERESTED (swimming to hook); bite in ~{ms}ms", _player.Guid, _nextEventAtMs - now); + break; + + case Phase.Nibbling: + // The biter reaches the hook and bites (lunge + splash), then fights. + SendUpdateBobber(hooked: true, interested: false); + _phase = Phase.Hooked; + _hookedAtMs = now; + // Give the player a window to reel before the fish escapes. + _nextEventAtMs = now + 12000; + FishingSessions.Logger?.LogInformation( + "Fishing[{guid}] fish BIT (lunge + fight); reel now! escapes in 12s", _player.Guid); + break; + + case Phase.Hooked: + // Player was too slow — the fish escapes. + FishingSessions.Logger?.LogInformation("Fishing[{guid}] fish ESCAPED (not reeled in time)", _player.Guid); + SendUpdateBobber(hooked: false, interested: false); + SendNothingCaught(); + _phase = Phase.Idle; + break; + + case Phase.ReelPending: + // Fight finished (fish is in the reelable nibble state) — send the reel-up. + FishingSessions.Logger?.LogInformation("Fishing[{guid}] reel-up (fish pulled to the rod)", _player.Guid); + SendReelDrag(); + _phase = Phase.Reeling; + _nextEventAtMs = now + 1800; // let the reel-up play before the catch show-off + break; + + case Phase.Reeling: + // Reel-up finished. Grant the fish to the inventory first (the "item added" + // popup fires right as the reel ends), then show the catch banner (name + size). + var granted = FishingSessions.GrantCaughtItem(_player, _fishItemId); + FishingSessions.Logger?.LogInformation( + "Fishing[{guid}] caught {name} (item {item}, granted {granted}); showing catch banner (size {size}, weight {weight})", + _player.Guid, _fishName, _fishItemId, granted, _fishSize, _fishWeight); + SendCatchResult(); + _phase = Phase.Idle; + break; + } + } + } + + public void Reset() + { + lock (_gate) + { + _phase = Phase.Idle; + _activeLureItemId = 0; // a fresh hole session starts with no lure active + } + } + + // A catchable fish. Size (1..4) = small/medium/large/xlarge (the catch "size word", the held-catch + // model, and the weight). Shape (1..3) = thin/medium/fat, the UNDERWATER biter mesh (the fishing + // minigame has no per-species meshes, so a shark reads as thin/long, a grouper/globfish as fat). + // NameId is the PLAIN species-name string id (NOT a size-prefixed item name): the client resolves it + // as "Global.Text." (Jenkins lookup2 hash into en_us_data.dat). ItemId is a representative + // catch item (granted to the bag); IconId is the fish icon. MinLevel is the wiki unlock tier (weights + // rarity). Ids from ClientItemDefinitions.json / FISHING_WIKI.md (freerealms.fandom.com Category:Fishing). + private readonly record struct FishEntry(string Name, int ItemId, int NameId, int IconId, int Size, int MinLevel, int Shape); + + // Sacred Grove Shallows (activity 563). + private static readonly FishEntry[] SacredGroveFish = + [ + new("Slugmud Skipper", 68049, 99924, 4276, 1, 1, 1), + new("Tickled Trout", 68001, 99963, 4422, 1, 1, 2), + new("Flutterfish", 2868, 23278, 3737, 1, 1, 2), + new("Butter Flyfish", 68053, 99925, 4254, 2, 5, 3), + new("Cheery Salmon", 68063, 99927, 4257, 2, 10, 2), + new("Chipsen Fish", 68067, 99928, 4258, 3, 14, 2), + new("Feral Catfish", 68056, 99926, 4262, 3, 17, 3), + new("Baconfish", 68069, 99929, 4249, 4, 20, 3), + new("Tangletooth Shark", 68113, 407895, 4421, 4, 20, 1), + ]; + + // Rainbow Lake (activity 562). + private static readonly FishEntry[] RainbowLakeFish = + [ + new("Flutterfish", 2868, 23278, 3737, 1, 1, 2), + new("Calico Catfish", 2149, 6171, 4595, 1, 1, 3), + new("Tickled Trout", 68001, 99963, 4422, 1, 1, 2), + new("Toothy Tetra", 68066, 99923, 4281, 2, 5, 1), + new("Peachy Perch", 68051, 99920, 4341, 2, 10, 2), + new("Finless Fish", 68046, 99919, 4263, 3, 14, 1), + new("Lady Tetra", 68061, 99922, 4270, 3, 17, 1), + new("Tangletooth Shark", 68113, 407895, 4421, 4, 20, 1), + ]; + + // Brambleback's Bayou (activity 561). + private static readonly FishEntry[] BramblebacksBayouFish = + [ + new("Creeping Cod", 68081, 99930, 4260, 1, 1, 2), + new("Bitter Betta", 68097, 99935, 4250, 1, 1, 2), + new("Globfish", 2147, 6169, 4593, 1, 1, 3), + new("Blind Swurglefish", 2865, 23275, 195, 2, 5, 1), + new("Changed Salmon", 68094, 99940, 4256, 2, 10, 2), + new("Fanged Grouper", 68091, 99939, 4261, 3, 14, 3), + new("Briar Nibbler", 68101, 99941, 4556, 3, 17, 3), + ]; + + // Darklit Lagoon (activity 560). + private static readonly FishEntry[] DarklitLagoonFish = + [ + new("Ink Cod", 68073, 99931, 4635, 1, 1, 2), + new("Creeping Cod", 68081, 99930, 4260, 1, 1, 2), + new("Golden Scaled Nettler", 68009, 99971, 4420, 1, 1, 2), + new("Thorny Trout", 68090, 99934, 4280, 2, 5, 2), + new("Old Sole", 68087, 99933, 4271, 2, 10, 2), + new("Purplenosed Shark", 68075, 99932, 4274, 3, 14, 1), + new("Roach Loach", 68099, 99936, 4275, 3, 17, 1), + ]; + + // Wintery Basin (activity 564). + private static readonly FishEntry[] WinteryBasinFish = + [ + new("Frozen Char", 68027, 99945, 4266, 1, 1, 2), + new("Winter Piranha", 68022, 99942, 4282, 1, 1, 2), + new("Frostgill Smelt", 2864, 23274, 196, 2, 5, 1), + new("Spineless Stickleback", 68039, 99947, 4277, 2, 10, 1), + new("Blubracuda", 68036, 99946, 4252, 3, 14, 3), + new("Coach Loach", 68024, 99944, 4259, 3, 17, 1), + new("Ferocious Fangler", 68008, 99970, 4419, 4, 20, 3), + ]; + + // Frostbitten Banks (activity 565). + private static readonly FishEntry[] FrostbittenBanksFish = + [ + new("Chilly Grouper", 68011, 99948, 4267, 1, 1, 3), + new("Winter Piranha", 68022, 99942, 4282, 1, 1, 2), + new("Frostgill Smelt", 2864, 23274, 196, 2, 5, 1), + new("Pacu Pacu", 68028, 99951, 4272, 2, 5, 2), + new("Blue Thornfin", 68014, 99949, 4253, 2, 10, 1), + new("Spineless Stickleback", 68039, 99947, 4277, 2, 10, 1), + new("Goofy Grouper", 68030, 99952, 4269, 3, 14, 3), + new("Talking Bass", 68019, 99950, 4279, 3, 17, 2), + new("Ferocious Fangler", 68008, 99970, 4419, 4, 20, 3), + new("Plattypus", 68033, 99953, 4273, 4, 20, 3), + ]; + + // Per-activity fish tables, keyed by fishing-hole activity id (all six holes from the wiki). + private static readonly Dictionary ZoneFishTables = new() + { + [563] = SacredGroveFish, + [562] = RainbowLakeFish, + [561] = BramblebacksBayouFish, + [560] = DarklitLagoonFish, + [564] = WinteryBasinFish, + [565] = FrostbittenBanksFish, + }; + + private FishEntry[] CurrentFishTable => + ZoneFishTables.TryGetValue(ActivityId, out var table) ? table : SacredGroveFish; + + // A non-fish catch (treasure/junk) — "reel in everything from exotic fish to treasure and coin!" + // CatchModel is the held money-shot model. Fish gear/junk has no dedicated held mesh, so junk reuses + // the same generic held model the underwater biter used (a fish), so the money shot matches what was + // on the line, with the name + icon identifying the real item. CatchModel = 0 means "use that biter + // model"; a real model id (e.g. the treasure chest) overrides it. + private readonly record struct MiscCatch(string Name, int ItemId, int NameId, int IconId, int Size, int CatchModel, float Weight); + + private static readonly MiscCatch[] MiscCatches = + [ + new("Treasure Chest", 3016, 5091, 6347, 3, TreasureChestModelId, 5f), + new("Old Boot", 68103, 99954, 4430, 1, 0, 3f), + new("Soggy Socks", 68104, 99955, 4435, 1, 0, 3f), + new("Soggy Stick of Dynamite", 68002, 99964, 4436, 1, 0, 2f), + ]; + + // Fraction of casts that pull up a misc catch (treasure/junk) instead of a fish. TODO(lure): the + // Treasure Magnet lure should raise the treasure share once equipped lures are read (FISHING_WIKI.md). + private const float MiscCatchChance = 0.15f; + + private static int BiterModelForShape(int shape) => shape switch + { + 1 => ThinFishModelId, + 2 => MediumFishModelId, + _ => FatFishModelId, + }; + + private int _fishItemId; + + // ----- Gear: rods (cast distance) + lures (+10% catch chance) ------------------------------------ + // Rod item-def id ranges from ClientItemDefinitions.json: 5 rods, each with 35 tint variants + // (68185-68359) plus one high id (76687-76691). The wiki groups them into three cast tiers: + // Bamboo/Golden Reel "a short distance", Metal/Red Scoped "a greater distance", Golden Scoped "the + // deepest of waters". We map each to a Min/Max cast distance sent in FishingPlayerConfig (the client + // validates a cast only when the water-raycast distance is within [Min, Max]). NOTE: better rods only + // EXTEND the range past the tested baseline (Max 20) — never shorten it — so the known-good Sacred + // Grove flow can't regress. Absolute numbers are approximate pending real cast data (FISHING_1TO1_PLAN.md D#3). + private readonly record struct RodTier(string Name, float MinCast, float MaxCast); + + private static RodTier RodTierForDefinition(int rodDef) + { + if ((rodDef is >= 68185 and <= 68219) || rodDef == 76687) return new("Simple Bamboo Fishing Rod", 3f, 20f); + if ((rodDef is >= 68220 and <= 68254) || rodDef == 76688) return new("Golden Reel Fishing Rod", 3f, 20f); + if ((rodDef is >= 68255 and <= 68289) || rodDef == 76689) return new("Metal Fishing Rod", 3f, 25f); + if ((rodDef is >= 68290 and <= 68324) || rodDef == 76690) return new("Red Scoped Fishing Rod", 3f, 25f); + if ((rodDef is >= 68325 and <= 68359) || rodDef == 76691) return new("Golden Scoped Fishing Rod", 3f, 32f); + return new("(no rod)", 3f, 20f); // no/unknown rod -> the tested default cast range + } + + /// The rod equipped in the active (Fisherman) profile's weapon slot (slot 7); 0 if none. + private int EquippedRodDefinitionId() + { + var profile = _player.Profiles.FirstOrDefault(x => x.Id == _player.ActiveProfileId); + if (profile is not null && profile.Items.TryGetValue(7, out var profileItem)) + { + var clientItem = _player.Items.FirstOrDefault(x => x.Id == profileItem.Id); + if (clientItem is not null) + return clientItem.Definition; + } + return 0; + } + + /// Min/max cast distance for the player's equipped rod (sent in FishingPlayerConfig). + public (float Min, float Max) GetCastDistance() + { + var tier = RodTierForDefinition(EquippedRodDefinitionId()); + FishingSessions.Logger?.LogInformation( + "Fishing[{guid}] rod = {rod} -> cast distance {min}..{max}", _player.Guid, tier.Name, tier.MinCast, tier.MaxCast); + return (tier.MinCast, tier.MaxCast); + } + + // Lure item-def id (68152-68164) -> the three fish it gives +10% catch chance (wiki). The names MUST + // match the FishEntry.Name strings in the zone tables. Treasure Magnet (68164) boosts treasure and is + // handled separately (EffectiveMiscCatchChance + RollMiscCatch's chest bias). + private const int TreasureMagnetItemId = 68164; + + private static readonly Dictionary LureFishBonus = new() + { + [68152] = ["Winter Piranha", "Toothy Tetra", "Blind Swurglefish"], // 16oz Steak + [68153] = ["Chilly Grouper", "Butter Flyfish", "Briar Nibbler"], // Flyfisher 3000 + [68154] = ["Blubracuda", "Baconfish", "Changed Salmon"], // French Fry + [68155] = ["Frostgill Smelt", "Flutterfish", "Ink Cod"], // Frostflies + [68156] = ["Goofy Grouper", "Finless Fish", "Fanged Grouper"], // Mega Slider + [68157] = ["Plattypus", "Lady Tetra", "Roach Loach"], // Nightcrawlers + [68158] = ["Pacu Pacu", "Peachy Perch", "Globfish"], // Perch Pinpointer + [68159] = ["Coach Loach", "Feral Catfish", "Creeping Cod"], // Shiny Crankbait + [68160] = ["Frozen Char", "Slugmud Skipper", "Old Sole"], // Skipper Seeker + [68161] = ["Talking Bass", "Cheery Salmon", "Bitter Betta"], // Sleek Clicker + [68162] = ["Blue Thornfin", "Chipsen Fish", "Thorny Trout"], // Thorn Jig + [68163] = ["Spineless Stickleback", "Calico Catfish", "Purplenosed Shark"], // Tiny Rib + }; + + // Maps the client's ability request id to a lure item-def id (68152-68164). We accept EITHER the + // ActivatableAbilityId (4287-4299, aligned/consecutive with the item ids) OR the item def id itself, + // because it isn't yet confirmed which the client sends in AbilityPacketClientRequestStartAbility. + public static int LureItemIdForAbility(int abilityOrItemId) => + abilityOrItemId is >= 4287 and <= 4299 ? 68152 + (abilityOrItemId - 4287) + : abilityOrItemId is >= 68152 and <= 68164 ? abilityOrItemId + : 0; + + /// Records the lure the player just activated. Persists until they leave the hole (Reset). + public void SetActiveLure(int lureItemId) + { + lock (_gate) + { + _activeLureItemId = lureItemId; + FishingSessions.Logger?.LogInformation("Fishing[{guid}] active lure = item {lure}", _player.Guid, lureItemId); + } + } + + // The base misc (treasure/junk) share of casts; the Treasure Magnet lure raises it by +10%. + private float EffectiveMiscCatchChance() => + _activeLureItemId == TreasureMagnetItemId ? MiscCatchChance + 0.10f : MiscCatchChance; + + private void RollCatch() + { + // A fraction of casts surface treasure or junk instead of a fish (raised by the Treasure Magnet). + if (Random.Shared.NextDouble() < EffectiveMiscCatchChance()) + { + RollMiscCatch(); + return; + } + + // Roll among the zone's fish, weighting rarer (higher-tier) fish down so low-level fish dominate. + // TODO(fish-table): gate by the player's fishing level (FISHING_1TO1_PLAN.md P1). + var table = CurrentFishTable; + + // The active lure (if any) gives its three named fish a +10% catch chance. + var lureFish = _activeLureItemId != 0 && LureFishBonus.TryGetValue(_activeLureItemId, out var lf) ? lf : null; + + var weights = new float[table.Length]; + var total = 0f; + for (var i = 0; i < table.Length; i++) + { + weights[i] = 6f / (table[i].MinLevel + 5f); // ~1.0 at level 1, tapering to ~0.24 at level 20 + total += weights[i]; + } + + // Apply the lure bonus: add +10% of the base total to each matching fish (an ~absolute +10% chance). + if (lureFish is not null) + { + var boost = 0.10f * total; + for (var i = 0; i < table.Length; i++) + { + if (Array.IndexOf(lureFish, table[i].Name) >= 0) + { + weights[i] += boost; + total += boost; + } + } + } + + var roll = (float)Random.Shared.NextDouble() * total; + var idx = 0; + for (; idx < table.Length - 1; idx++) + { + if (roll < weights[idx]) break; + roll -= weights[idx]; + } + + var f = table[idx]; + _fishName = f.Name; + _fishItemId = f.ItemId; // inventory item definition id (granted on catch) + _fishNameId = f.NameId; // @32 localized name id + _fishIconId = f.IconId; // @36 icon id + _fishSize = f.Size; + _fishModelId = BiterModelForShape(f.Shape);// underwater biter mesh (thin/medium/fat) by body shape + _catchModelId = _fishModelId; // held money-shot mesh = the same underwater fish model + _staticOnLine = false; // a real fish swims in and bites + _isItemCatch = false; // a real fish (Caught=false -> size + weight banner) + _moneyShotEffectId = MoneyShotFishEffectId; // fish sparkle on the hold-up + _fishDifficulty = 1; // all holes are "Difficulty 1" per the wiki + _fishRarity = Math.Clamp(f.MinLevel / 5 + 1, 1, 5); + _fishScore = 10 * _fishSize; + _fishWeight = _fishSize * 1.5f + (float)Random.Shared.NextDouble() * _fishSize * 2f; + } + + // With the Treasure Magnet active, the treasure chest is strongly favored within the misc pool. + private float MiscWeight(in MiscCatch m) => + _activeLureItemId == TreasureMagnetItemId && m.Name == "Treasure Chest" ? m.Weight * 4f : m.Weight; + + private void RollMiscCatch() + { + var total = 0f; + foreach (var m in MiscCatches) total += MiscWeight(m); + + var roll = (float)Random.Shared.NextDouble() * total; + var pick = MiscCatches[0]; + foreach (var m in MiscCatches) + { + var w = MiscWeight(m); + if (roll < w) { pick = m; break; } + roll -= w; + } + + _fishName = pick.Name; + _fishItemId = pick.ItemId; + _fishNameId = pick.NameId; + _fishIconId = pick.IconId; + _fishSize = pick.Size; + // Catches WITH their own model (the treasure chest) sit STATIC on the hook and are reeled + // straight up — the chest is visible on the line, no swim/bite. Junk (boot/socks/dynamite) has no + // model, so it rides the normal fish-bite path and is revealed as a generic fish with the item's + // name/icon (a "gotcha" — you get a bite, reel up, and it's an Old Boot). + _staticOnLine = pick.CatchModel > 0; + _catchModelId = _staticOnLine ? pick.CatchModel : MediumFishModelId; + _fishModelId = MediumFishModelId; // a generic fish is on the line (the chest overrides via _lineModelId) + _lineModelId = _catchModelId; // what's parked on the hook for a static catch + _isItemCatch = true; // treasure/junk -> item money shot (Caught=true), not a fish + // The treasure chest bursts open with its own effect; junk (shown as a fish) uses the fish sparkle. + _moneyShotEffectId = _staticOnLine ? MoneyShotChestEffectId : MoneyShotFishEffectId; + _fishDifficulty = 1; + _fishRarity = 1; + _fishScore = 5 * _fishSize; + _fishWeight = pick.Size * 1.5f + (float)Random.Shared.NextDouble() * pick.Size; + } + + /// + /// Builds the "Fish Finder" list (the fishing UI panel of catchable fish for this hole) from the + /// current zone's fish table, so it matches what can actually roll. + /// Sent in FishingPacketFishInfoUpdate. NOTE: the client (FishingProcessor::sub_B65ED0) dedupes + /// entries by and skips any with Unknown4=true, so each fish + /// needs a UNIQUE Type — reusing the three underwater models (thin/medium/fat) would collapse the + /// list to three rows. We key Type on the item-def id (unique + stable); the name/icon come straight + /// from NameId/IconId. + /// + public List BuildFishFinderEntries() + { + var table = CurrentFishTable; + var entries = new List(table.Length); + foreach (var f in table) + { + // NOTE: "What I've Caught" is NOT driven by these entries — it's the fish COLLECTIONS system + // (CollectionsBrowser), a separate per-player tracked+persisted feature we don't implement yet. + // FishSpecial/FishCatchable here only affect the Fish Finder's Fish tab. + entries.Add(new ClientFishEntryInfo + { + Type = f.ItemId, // unique dedup key per fish + NameId = f.NameId, // localized fish name shown in the finder + IconId = f.IconId, // fish icon shown in the finder + FishSpecial = false, + FishCatchable = true, // TODO(fish-table): false when the fish is above the player's level + FishLureRequirement = 0 // TODO(lure): map the wiki lure -> FishingLureDataSource id (FISHING_WIKI.md) + }); + } + + return entries; + } + + /// An ambient (decorative) fish that actively wanders the school; not catchable. + private static UnderwaterFishSpawnInfo AmbientFish(int id, int modelId) => new() + { + Unknown = id, + ModelId = modelId, + TintAlias = string.Empty, + TextureAlias = string.Empty, + Unknown5 = 1, + Unknown6 = 0, + Unknown7 = false, // ambient + Unknown8 = 1.5f, + Unknown9 = 2.0f, + Unknown10 = 1.5f, + Unknown11 = 1.0f, + Unknown12 = 1.0f, + Unknown13 = 2.5f, + Unknown14 = 1.5f, // wander speed — keep them moving so none look stationary + Unknown15 = 0.5f, + Unknown16 = 2.5f, + Unknown17 = 0.3f, // short idle between moves + Unknown18 = 1.2f + }; + + /// + /// The catchable HEAD object, parked on the hook and FROZEN (client sub_CD3A70 starts a catchable + /// fish in the nibble state 6, and state 6 skips the wander for a catchable fish — so it never + /// swims/lunges). Used for static catches like a treasure chest that sit on the line ready to reel. + /// Being the head + catchable also makes it the "current fish" the reel-up pulls, and suppresses the + /// forced hook decoy. The movement params are still needed: the reel-up GLIDE (state 7, sub_CD1A10) + /// moves the object by speed = param/param, so zeroing them makes it teleport instead of gliding up. + /// See FISHING_RE_NOTES.md. + /// + private static UnderwaterFishSpawnInfo CatchableFish(int id, int modelId) => new() + { + Unknown = id, + ModelId = modelId, + TintAlias = string.Empty, + TextureAlias = string.Empty, + Unknown5 = 1, + Unknown6 = 0, + Unknown7 = true, // catchable -> parked at the hook, frozen (no swim/bite); reeled straight up + Unknown8 = 1.5f, + Unknown9 = 2.0f, + Unknown10 = 1.5f, + Unknown11 = 1.0f, + Unknown12 = 1.0f, + Unknown13 = 2.5f, + Unknown14 = 1.5f, + Unknown15 = 0.5f, + Unknown16 = 2.5f, + Unknown17 = 0.3f, + Unknown18 = 1.2f + }; + + /// + /// Sends a fishing VISUAL packet to nearby players AND ourselves. All fishing S->C guids are our + /// player guid, so each visible player's client renders us as a "proxied fishing player" (bobber, + /// cast/reel poses, bite, catch animation) at our world spot — multiplayer sync. A FishingResult + /// keyed to our guid animates our proxied character on their screen without diving THEIR camera + /// (that money shot is only for a player's own catch). Inventory/reward grants stay private (they go + /// straight to us via GiveItem), so only the visuals are shared. + /// + private void SendProxied(ISerializablePacket packet) => + _player.SendTunneledToVisible(packet, sendToSelf: true); + + private void SendUpdateBobber(bool hooked, bool interested) + { + SendProxied(new FishingPacketUpdateProxiedFishingBobber + { + Guid = _player.Guid, + Unknown = BiterFishId, // drive the ambient biter (the catchable head fish ignores flags), + // which also makes it the "current fish" the reel-up pulls in + Flag1 = hooked, + Flag2 = interested + }); + } + + /// ResultType 4: plays the reel-in drag animation (fish dragged to the player, line attached). + private void SendReelDrag() + { + SendProxied(new FishingPacketFishingResult + { + Guid = _player.Guid, + ResultType = ResultReelDrag, + Caught = _isItemCatch, // treasure/junk are items, not fish (no size scaling on the reel-up) + FishId = _fishNameId, + FishName = string.Empty, + Unknown8 = _catchModelId, // held-catch model (>0) — sg_fish_catch by size, or the chest + Unknown12 = _fishSize + }); + } + + /// + /// ResultType 5: the catch — plays the show-off ("money shot"), fires the caught banner + /// (name + size word + weight), and auto-returns the player to the normal camera. + /// Field offsets verified in FISHING_RE_NOTES.md; the banner needs @80 (held-fish model) > 0. + /// NOTE: the client does NOT grant the item — inventory must be updated by a separate packet. + /// + private void SendCatchResult() + { + SendProxied(new FishingPacketFishingResult + { + Guid = _player.Guid, + ResultType = ResultScoredCatch, + // @28 Caught: false = a fish (money shot scaled by size + shows size word & weight); true = an + // ITEM like the treasure chest (fixed hold-up scale, name-only banner — no fish size/weight). + Caught = _isItemCatch, + FishId = _fishNameId, // @32 fish-name string-table id (localized) + Unknown1 = _fishIconId, // @36 scoring / numeric param + FishName = string.Empty, // @40 unused by the banner + Unknown2 = _fishWeight, // @56 WEIGHT ("%2.2f") + Unknown3 = _fishSize, // @60 size selector 1..4 (small/medium/large/xlarge) + Unknown4 = _fishWeight, // @64 scoring weight/score + Unknown5 = _fishDifficulty,// @68 scoring + Unknown6 = _fishRarity, // @72 scoring + Unknown7 = _fishScore, // @76 scoring + Unknown8 = _catchModelId, // @80 show-off held MODEL (>0 or no banner) — catch fish / chest + UnknownStr1 = string.Empty,// @84 held-fish tint + UnknownStr2 = string.Empty,// @100 held-fish texture + Unknown9 = _moneyShotEffectId, // @116 money-shot composite effect (fish sparkle / chest burst) + Unknown10 = 0, + Unknown11 = false, + Unknown12 = _fishSize // @120 show-off size class 1..4 + }); + } + + private void SendNothingCaught() + { + SendProxied(new FishingPacketFishingResult + { + Guid = _player.Guid, + ResultType = ResultNothingCaught, + Caught = false, + FishId = 0, + FishName = string.Empty + }); + } +} diff --git a/src/Sanctuary.Gateway/Fishing/FishingSessionState.cs b/src/Sanctuary.Gateway/Fishing/FishingSessionState.cs new file mode 100644 index 0000000..2990429 --- /dev/null +++ b/src/Sanctuary.Gateway/Fishing/FishingSessionState.cs @@ -0,0 +1,137 @@ +using System.Collections.Concurrent; +using System.Linq; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using Sanctuary.Core.Helpers; +using Sanctuary.Database; +using Sanctuary.Database.Entities; +using Sanctuary.Game.Entities; + +namespace Sanctuary.Gateway.Fishing; + +/// +/// Registry of active s keyed by player guid, plus the periodic tick +/// that drives their bite timelines. Ticked from the gateway main loop (see GatewayService). +/// +public static class FishingSessions +{ + private static readonly ConcurrentDictionary Sessions = new(); + + /// Shared logger for fishing sessions (set from a fishing packet handler at startup). + public static ILogger? Logger { get; set; } + + /// DB access for persisting caught items (set from a fishing packet handler at startup). + public static IDbContextFactory? DbContextFactory { get; set; } + + /// + /// Grants a caught item to the player AND persists it immediately (crash-safe, like the coin store), + /// so the catch survives logout. The DB row's id is used as the in-memory item id so the two stay in + /// sync (other systems — selling, equipping — match items by that id). Falls back to an in-memory-only + /// grant if the DB is unavailable. Returns true if the item was granted. + /// + public static bool GrantCaughtItem(Player player, int definitionId, int count = 1) + { + if (DbContextFactory is not null) + { + try + { + var characterId = GuidHelper.GetPlayerId(player.Guid); + + // DbItem's key is per-character (Id, CharacterId) and is NOT auto-generated, so we must + // assign the next free id ourselves — leaving it 0 collides ("UNIQUE constraint failed"). + // Player.Items already mirrors the character's rows (loaded on login), so max+1 is free in + // both the bag and the DB and keeps ClientItem.Id == DbItem.Id (what selling/equipping match on). + var nextId = player.Items.Count == 0 ? 1 : player.Items.Max(x => x.Id) + 1; + + using var db = DbContextFactory.CreateDbContext(); + + db.Items.Add(new DbItem + { + Id = nextId, + CharacterId = characterId, + Definition = definitionId, + Count = count, + Tint = 0 + }); + + if (db.SaveChanges() > 0) + return player.GiveItem(definitionId, count, nextId); + } + catch (System.Exception ex) + { + Logger?.LogError(ex, "Failed to persist caught item (def {def}); granting in-memory only", definitionId); + } + } + + // No DB (or it failed): grant in-memory so the catch still shows this session. + return player.GiveItem(definitionId, count); + } + + /// + /// Consumes one of a SingleUse item (e.g. an activated fishing lure): decrements the in-memory bag, + /// notifies the client, and persists the change. No-op if the player has none of it. + /// + public static void ConsumeItem(Player player, int definitionId) + { + var clientItem = player.Items.FirstOrDefault(x => x.Definition == definitionId); + if (clientItem is null) + return; + + if (clientItem.Count > 1) + { + clientItem.Count -= 1; + player.SendTunneled(new Sanctuary.Packet.ClientUpdatePacketItemUpdate + { + ItemGuid = clientItem.Id, + Count = clientItem.Count + }); + } + else + { + player.Items.Remove(clientItem); + player.SendTunneled(new Sanctuary.Packet.ClientUpdatePacketItemDelete { ItemGuid = clientItem.Id }); + } + + if (DbContextFactory is null) + return; + + try + { + var characterId = GuidHelper.GetPlayerId(player.Guid); + + using var db = DbContextFactory.CreateDbContext(); + + var dbItem = db.Items.SingleOrDefault(x => x.CharacterId == characterId && x.Id == clientItem.Id); + if (dbItem is not null) + { + if (dbItem.Count > 1) + dbItem.Count -= 1; + else + db.Items.Remove(dbItem); + + db.SaveChanges(); + } + } + catch (System.Exception ex) + { + Logger?.LogError(ex, "Failed to persist item consumption (def {def})", definitionId); + } + } + + public static FishingSession GetOrCreate(Player player) => + Sessions.GetOrAdd(player.Guid, _ => new FishingSession(player)); + + public static bool TryGet(ulong playerGuid, out FishingSession session) => + Sessions.TryGetValue(playerGuid, out session!); + + public static void Remove(ulong playerGuid) => Sessions.TryRemove(playerGuid, out _); + + /// Advances every active session's bite timeline. Cheap when sessions are idle. + public static void Tick() + { + foreach (var session in Sessions.Values) + session.Update(); + } +} diff --git a/src/Sanctuary.Gateway/GatewayConnection.cs b/src/Sanctuary.Gateway/GatewayConnection.cs index e0544d7..f8f9f63 100644 --- a/src/Sanctuary.Gateway/GatewayConnection.cs +++ b/src/Sanctuary.Gateway/GatewayConnection.cs @@ -77,6 +77,8 @@ public override void OnTerminated() if (Player is null) return; + Fishing.FishingSessions.Remove(Player.Guid); + SendFriendOffline(); _loginClient.SendCharacterLogout(GuidHelper.GetPlayerId(Player.Guid)); diff --git a/src/Sanctuary.Gateway/GatewayService.cs b/src/Sanctuary.Gateway/GatewayService.cs index bb169ba..602ce02 100644 --- a/src/Sanctuary.Gateway/GatewayService.cs +++ b/src/Sanctuary.Gateway/GatewayService.cs @@ -128,6 +128,9 @@ protected override Task ExecuteAsync(CancellationToken cancellationToken) { _server.GiveTime(); _client.GiveTime(); + + // Drive server-side minigame timers (e.g. fishing bite timing). + Fishing.FishingSessions.Tick(); } return Task.CompletedTask; diff --git a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs index 2da0b71..d4ee63d 100644 --- a/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/BaseAbilityPacket/AbilityPacketClientRequestStartAbilityHandler.cs @@ -1,8 +1,10 @@ using System; +using System.Linq; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Sanctuary.Gateway.Fishing; using Sanctuary.Packet; using Sanctuary.Packet.Common.Attributes; @@ -29,6 +31,36 @@ public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan _logger.LogTrace("Received {name} packet. ( {packet} )", nameof(AbilityPacketClientRequestStartAbility), packet); + var hasSession = FishingSessions.TryGet(connection.Player.Guid, out var session); + + // The client's use-ability request only carries the action-bar slot (Data.Slot), not the item, so + // resolve the item from the assignments we tracked (InventoryPacketItemActionBarAssign). Then, if + // it's a fishing lure and the player is fishing, activate it (+10% to its fish / +treasure for the + // Treasure Magnet), consume one from the bag, and finish — do NOT send the generic "can't use that" + // failure below. Any other ability still falls through to the failure (nothing else is implemented). + var lureDefinitionId = 0; + if (connection.Player.ActionBarAssignments.TryGetValue(packet.Data.Slot, out var itemInstanceId)) + { + var clientItem = connection.Player.Items.FirstOrDefault(x => x.Id == itemInstanceId); + if (clientItem is not null) + lureDefinitionId = FishingSession.LureItemIdForAbility(clientItem.Definition); + } + + _logger.LogInformation( + "Ability request: Data.Id={id} Data.Slot={slot} -> item {item} (lure def {lure}) inFishingSession={session}", + packet.Data.Id, packet.Data.Slot, itemInstanceId, lureDefinitionId, hasSession); + + if (lureDefinitionId != 0 && hasSession) + { + session.SetActiveLure(lureDefinitionId); + FishingSessions.ConsumeItem(connection.Player, lureDefinitionId); + + _logger.LogInformation("Player {guid} activated fishing lure (def {lure})", + connection.Player.Guid, lureDefinitionId); + + return true; + } + var abilityPacketFailed = new AbilityPacketFailed { // You can't use that ability right now. diff --git a/src/Sanctuary.Gateway/Handlers/BaseActivityServicePacket/BaseActivityPacket/ActivityPacketJoinActivityRequestHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseActivityServicePacket/BaseActivityPacket/ActivityPacketJoinActivityRequestHandler.cs index 6f4aaad..d4a9ba5 100644 --- a/src/Sanctuary.Gateway/Handlers/BaseActivityServicePacket/BaseActivityPacket/ActivityPacketJoinActivityRequestHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/BaseActivityServicePacket/BaseActivityPacket/ActivityPacketJoinActivityRequestHandler.cs @@ -6,6 +6,7 @@ using Sanctuary.Core.Helpers; using Sanctuary.Core.IO; using Sanctuary.Game; +using Sanctuary.Gateway.Fishing; using Sanctuary.Packet; using Sanctuary.Packet.Common.Attributes; @@ -189,6 +190,80 @@ public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan connection.SendTunneled(miniGameInfoPacket); } + else if (FishingActivityZones.IsFishingActivity(packet.ActivityId)) + { + var miniGameInfo = new MiniGameInfo() + { + NameId = clientActivityDefinition.DisplayNameId, + IconId = clientActivityDefinition.ImageSetId, + DescriptionId = clientActivityDefinition.DisplayDescriptionId, + Difficulty = clientActivityDefinition.Difficulty, + ProfileType = 21, + Type = 21, + PreselectedGameId = packet.ActivityId, + ShowActionBar = true + }; + + var miniGameGroupInfo = new MiniGameGroupInfo() + { + Id = 10, + NameId = clientActivityDefinition.DisplayNameId, + DescriptionId = clientActivityDefinition.DisplayDescriptionId, + IconId = clientActivityDefinition.ImageSetId + }; + + using var writer = new PacketWriter(); + + miniGameInfo.Serialize(writer); + + writer.Write(0); + writer.Write(0); + + writer.Write(miniGameGroupInfo.Serialize()); + + var clientActivityLaunchPacketInviteDetails = new ClientActivityLaunchPacketInviteDetails(packet.ActivityId, 0) + { + Guid = connection.Player.Guid, + Inviter = "Test", + Members = + { + new() + { + Id = 1, + Guid = connection.Player.Guid, + InviteStatus = 2, + IsFoundingMember = true + } + }, + Request = + { + RequestorGuid = connection.Player.Guid, + SysHashkey = JenkinsHelper.OneAtATimeHash("Minigame"), + ReqId = 69420, + MinMembers = 1, + MaxMembers = 1, + ImageSetId = clientActivityDefinition.ImageSetId, + NameStringId = clientActivityDefinition.DisplayNameId, + DescStringId = clientActivityDefinition.DisplayDescriptionId, + SysSpecificData = writer.Buffer + } + }; + + connection.SendTunneled(clientActivityLaunchPacketInviteDetails); + + var clientActivityLaunchPacketActivityLaunched = new ClientActivityLaunchPacketActivityLaunched(packet.ActivityId, 0); + + clientActivityLaunchPacketActivityLaunched.Guids.Add(connection.Player.Guid); + + connection.SendTunneled(clientActivityLaunchPacketActivityLaunched); + + var miniGameInfoPacket = new MiniGameInfoPacket(packet.ActivityId, -1, -1) + { + Info = miniGameInfo + }; + + connection.SendTunneled(miniGameInfoPacket); + } return true; } diff --git a/src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketCastAnimRequestHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketCastAnimRequestHandler.cs new file mode 100644 index 0000000..8261394 --- /dev/null +++ b/src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketCastAnimRequestHandler.cs @@ -0,0 +1,39 @@ +using System; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using Sanctuary.Packet; +using Sanctuary.Packet.Common.Attributes; + +namespace Sanctuary.Gateway.Handlers; + +[PacketHandler] +public static class FishingPacketCastAnimRequestHandler +{ + private static ILogger _logger = null!; + + public static void ConfigureServices(IServiceProvider serviceProvider) + { + _logger = serviceProvider.GetRequiredService().CreateLogger(nameof(FishingPacketCastAnimRequestHandler)); + } + + public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan data) + { + if (!FishingPacketCastAnimRequest.TryDeserialize(data, out var pkt)) + { + _logger.LogError("Failed deserialize CastAnimRequest: {data}", Convert.ToHexString(data)); + return false; + } + _logger.LogInformation("Player {g} cast anim at {p}", pkt.Guid, pkt.Position); + + // sendToSelf: the client (case 5) puts the fishing player into the cast pose (state 2 -> + // sub_CCFFB0 -> sub_95DAE0 face + sub_963DA0 "fishing" bit), which is what makes our OWN + // proxied character attach its fishing rig (attachment group 7). Without this, our proxied + // char only enters a fishing state at the bite, so the rod->bobber line (sub_CD0150, which + // anchors to group 7's EMITTER2 socket) never builds until then. Relaying to visible players + // already gives THEM the rig for our proxied char; we need it for ourselves too. + connection.Player.SendTunneledToVisible(pkt, sendToSelf: true); + return true; + } +} diff --git a/src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketCastRequestHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketCastRequestHandler.cs new file mode 100644 index 0000000..cdd7e26 --- /dev/null +++ b/src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketCastRequestHandler.cs @@ -0,0 +1,37 @@ +using System; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using Sanctuary.Packet; +using Sanctuary.Packet.Common.Attributes; + +namespace Sanctuary.Gateway.Handlers; + +[PacketHandler] +public static class FishingPacketCastRequestHandler +{ + private static ILogger _logger = null!; + + public static void ConfigureServices(IServiceProvider serviceProvider) + { + _logger = serviceProvider.GetRequiredService().CreateLogger(nameof(FishingPacketCastRequestHandler)); + } + + public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan data) + { + if (!FishingPacketCastRequest.TryDeserialize(data, out var pkt)) + { + _logger.LogError("Failed deserialize CastRequest: {data}", Convert.ToHexString(data)); + return false; + } + _logger.LogInformation("Player {g} cast at {p} flag={f}", pkt.Guid, pkt.Position, pkt.Flag); + + // Drive the fishing session: spawns the bobber (guid MUST be the player guid so the client + // resolves it to the local fishing player) and the catchable fish, then times the bite. + var session = Fishing.FishingSessions.GetOrCreate(connection.Player); + session.OnCast(pkt.Position); + + return true; + } +} diff --git a/src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketReelInRequestHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketReelInRequestHandler.cs new file mode 100644 index 0000000..904ee49 --- /dev/null +++ b/src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketReelInRequestHandler.cs @@ -0,0 +1,36 @@ +using System; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using Sanctuary.Packet; +using Sanctuary.Packet.Common.Attributes; + +namespace Sanctuary.Gateway.Handlers; + +[PacketHandler] +public static class FishingPacketReelInRequestHandler +{ + private static ILogger _logger = null!; + + public static void ConfigureServices(IServiceProvider serviceProvider) + { + _logger = serviceProvider.GetRequiredService().CreateLogger(nameof(FishingPacketReelInRequestHandler)); + } + + public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan data) + { + if (!FishingPacketReelInRequest.TryDeserialize(data, out var pkt)) + { + _logger.LogError("Failed deserialize ReelInRequest: {data}", Convert.ToHexString(data)); + return false; + } + _logger.LogInformation("Player {g} reel-in flag={f}", pkt.Guid, pkt.Flag); + + // Resolve the reel-in against the session state (catch if hooked, otherwise a miss). + var session = Fishing.FishingSessions.GetOrCreate(connection.Player); + session.OnReel(); + + return true; + } +} diff --git a/src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketRegisterPlayerRequestHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketRegisterPlayerRequestHandler.cs new file mode 100644 index 0000000..e53d9aa --- /dev/null +++ b/src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketRegisterPlayerRequestHandler.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using Sanctuary.Gateway.Fishing; +using Sanctuary.Packet; +using Sanctuary.Packet.Common; +using Sanctuary.Packet.Common.Attributes; + +namespace Sanctuary.Gateway.Handlers; + +[PacketHandler] +public static class FishingPacketRegisterPlayerRequestHandler +{ + private static ILogger _logger = null!; + + public static void ConfigureServices(IServiceProvider serviceProvider) + { + var loggerFactory = serviceProvider.GetRequiredService(); + _logger = loggerFactory.CreateLogger(nameof(FishingPacketRegisterPlayerRequestHandler)); + + // Give the fishing sessions a logger so their server-driven bite timeline is visible. + Fishing.FishingSessions.Logger = loggerFactory.CreateLogger("FishingSession"); + + // Give the fishing sessions DB access so caught items are persisted (survive logout). + Fishing.FishingSessions.DbContextFactory = + serviceProvider.GetRequiredService>(); + } + + public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan data) + { + _logger.LogTrace("Received fishing register player request. Payload: {payload}", Convert.ToHexString(data)); + + var player = connection.Player; + + // NOTE: a self-AddPc (proxied character of ourselves) was tried here to satisfy the client's + // bobber/line rendering, but the client ignores a proxied character keyed to the current player + // (IsCurrentPlayer path) — no proxied character is created. The self bobber/line must come from + // the local ControllerFishing instead. + + // Use the zone the player actually entered (recorded at minigame start); fall back to + // their current position if we have no recorded zone. + var session = FishingSessions.GetOrCreate(player); + + var spawnPos = player.Position; + string? zoneName = session.ZoneConfig.ZoneName; + if (zoneName is not null) + spawnPos = session.ZoneConfig.SpawnPosition; + + // Water-surface Y baseline the client uses for bobber/fish placement. + var waterY = spawnPos.Y; + + // Cast distance comes from the equipped rod's tier (short/greater/deepest). The client validates + // a cast only when the water-raycast distance is within [min, max]. + var (minCast, maxCast) = session.GetCastDistance(); + + connection.SendTunneled(new FishingPacketRegisterPlayerResponse + { + FishingPlayerConfig = new FishingPlayerConfig + { + Unknown = 4, + Unknown2 = minCast, // MinCastDistance (float) — from the equipped rod's tier + Unknown3 = maxCast, // MaxCastDistance (float) — from the equipped rod's tier + Unknown4 = 1, + Unknown5 = 5, + Unknown6 = 100, + Unknown7 = 21, + Unknown8 = 50, + Unknown9 = 1, + // Camera block (client ControllerFishing::sub_AF85E0): distance/pitch/heading/target. + Unknown10 = 6.0f, + Unknown11 = 0.444f, + Unknown12 = 1.85f, + Unknown13 = 0.2f, + Unknown14 = 1, + Unknown15 = 1, + Unknown16 = 1 + }, + // Preload ALL fishing-scene models (bobber, lure, underwater fish, catch fish), not just + // the 3 underwater fish. The client instantiates each (sub_B68600 -> m_ActorIds), so the + // bobber/lure are ready immediately at cast time instead of streaming in late. + FishModelIds = [.. FishingSession.PreloadModelIds], + FishingZoneConfig = new Sanctuary.Packet.Common.FishingZoneConfig + { + Unknown = zoneName, + // Fish-arena X: MUST be the zone's Underwater_Bed X (the client hardcodes the + // arena Y=-8/Z=485 to sit inside that bed). Using the overworld pond X puts the + // fish above the wrong water — the "fish in the sky" bug. + Unknown3 = session.ZoneConfig.UnderwaterBedX, + Unknown6 = waterY // overworld water-surface Y (bobber height where you cast) + } + }); + + connection.SendTunneled(new FishingPacketUpdateData + { + Guid = player.Guid, + Position = player.Position + }); + + // Populate the Fish Finder with this hole's real fish (name/icon per the wiki), matching what + // the session can actually roll as a catch. Built from the per-zone fish table. + connection.SendTunneled(new FishingPacketFishInfoUpdate + { + ClientFishEntries = session.BuildFishFinderEntries() + }); + + // NOTE: SpawnProxiedFishingSchool packets are intentionally NOT sent. The client places every + // fish in a school at the SAME point (sub_CD12A0), so a school renders as a clump of fish + // stacked on one spot — the "multiple fish stacked in the center" the player saw. The lively + // underwater fish come from SpawnFishRun (sent on cast) instead. + + _logger.LogInformation("Player {guid} registered for fishing (zone {zone}) at {pos}", player.Guid, zoneName, spawnPos); + return true; + } +} diff --git a/src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketSpecialRequestHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketSpecialRequestHandler.cs new file mode 100644 index 0000000..64e1558 --- /dev/null +++ b/src/Sanctuary.Gateway/Handlers/BaseFishingPacket/FishingPacketSpecialRequestHandler.cs @@ -0,0 +1,39 @@ +using System; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using Sanctuary.Packet; +using Sanctuary.Packet.Common.Attributes; + +namespace Sanctuary.Gateway.Handlers; + +[PacketHandler] +public static class FishingPacketSpecialRequestHandler +{ + private static ILogger _logger = null!; + + public static void ConfigureServices(IServiceProvider serviceProvider) + { + _logger = serviceProvider.GetRequiredService().CreateLogger(nameof(FishingPacketSpecialRequestHandler)); + } + + public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan data) + { + if (!FishingPacketSpecialRequest.TryDeserialize(data, out var pkt)) + { + _logger.LogError("Failed deserialize SpecialRequest: {data}", Convert.ToHexString(data)); + return false; + } + _logger.LogInformation("Player {g} special request data={d}", pkt.Guid, pkt.Data); + + connection.SendTunneled(new FishingPacketSpecialResponse + { + Guid = pkt.Guid, + Unknown = 0, + Flag = true + }); + + return true; + } +} diff --git a/src/Sanctuary.Gateway/Handlers/BaseFishingPacketHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseFishingPacketHandler.cs new file mode 100644 index 0000000..4e4b4a3 --- /dev/null +++ b/src/Sanctuary.Gateway/Handlers/BaseFishingPacketHandler.cs @@ -0,0 +1,54 @@ +using System; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using Sanctuary.Core.IO; +using Sanctuary.Packet; +using Sanctuary.Packet.Common.Attributes; + +namespace Sanctuary.Gateway.Handlers; + +[PacketHandler] +public static class BaseFishingPacketHandler +{ + private static ILogger _logger = null!; + + public static void ConfigureServices(IServiceProvider serviceProvider) + { + var loggerFactory = serviceProvider.GetRequiredService(); + _logger = loggerFactory.CreateLogger(nameof(BaseFishingPacketHandler)); + } + + public static bool HandlePacket(GatewayConnection connection, PacketReader reader) + { + if (!reader.TryRead(out short subOpCode)) + { + _logger.LogError("Failed to read fishing sub-opcode. Data: {data}", Convert.ToHexString(reader.Span)); + return false; + } + + // Pass the FULL payload (including the 138 opcode + sub-opcode header), matching the + // convention used by BaseMiniGamePacketHandler: each fishing packet's TryDeserialize + // re-reads and validates that header via BaseFishingPacket.TryRead. Passing RemainingSpan + // (header stripped) makes every TryDeserialize fail its header check. + var data = reader.Span; + + return subOpCode switch + { + 2 => FishingPacketRegisterPlayerRequestHandler.HandlePacket(connection, data), + 5 => FishingPacketCastAnimRequestHandler.HandlePacket(connection, data), + 6 => FishingPacketCastRequestHandler.HandlePacket(connection, data), + 7 => FishingPacketReelInRequestHandler.HandlePacket(connection, data), + 15 => FishingPacketSpecialRequestHandler.HandlePacket(connection, data), + _ => HandleUnhandled(subOpCode, data) + }; + } + + private static bool HandleUnhandled(short subOpCode, ReadOnlySpan payload) + { + _logger.LogInformation("Unhandled fishing sub-opcode {subOpCode}. Payload: {payload}", + subOpCode, Convert.ToHexString(payload)); + return true; + } +} diff --git a/src/Sanctuary.Gateway/Handlers/BaseInventoryPacket/InventoryPacketItemActionBarAssignHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseInventoryPacket/InventoryPacketItemActionBarAssignHandler.cs index 869c596..7e86e95 100644 --- a/src/Sanctuary.Gateway/Handlers/BaseInventoryPacket/InventoryPacketItemActionBarAssignHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/BaseInventoryPacket/InventoryPacketItemActionBarAssignHandler.cs @@ -34,6 +34,15 @@ public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan _logger.LogTrace("Received {name} packet. ( {packet} )", nameof(InventoryPacketItemActionBarAssign), packet); + // Track which item instance sits in each action-bar slot so a later "use ability" request (which + // only carries the slot, not the item) can be resolved back to the item (e.g. a fishing lure). + if (packet.Guid == 0) + connection.Player.ActionBarAssignments.Remove(packet.Slot); + else + connection.Player.ActionBarAssignments[packet.Slot] = packet.Guid; + + _logger.LogInformation("ActionBar assign: Slot={slot} ItemGuid={guid}", packet.Slot, packet.Guid); + var clientUpdatePacketUpdateActionBarSlot = new ClientUpdatePacketUpdateActionBarSlot { Data = diff --git a/src/Sanctuary.Gateway/Handlers/BaseMiniGamePacket/MiniGameStartGamePacketHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseMiniGamePacket/MiniGameStartGamePacketHandler.cs index 81c135c..0910949 100644 --- a/src/Sanctuary.Gateway/Handlers/BaseMiniGamePacket/MiniGameStartGamePacketHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/BaseMiniGamePacket/MiniGameStartGamePacketHandler.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Sanctuary.Gateway.Fishing; using Sanctuary.Packet; using Sanctuary.Packet.Common.Attributes; @@ -44,6 +45,40 @@ public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan connection.SendTunneled(commandPacketStartFlashGame); } + else if (FishingActivityZones.TryGet(packet.StateId, out var fishingZone)) + { + var packetClientBeginZoning = new PacketClientBeginZoning + { + Name = fishingZone.ZoneName, + Type = 2, + Position = fishingZone.SpawnPosition, + Rotation = fishingZone.SpawnRotation, + Sky = fishingZone.Sky, + Unknown = 1, + Id = packet.StateId, + GeometryId = 214, + OverrideUpdateRadius = true + }; + + connection.SendTunneled(packetClientBeginZoning); + + connection.SendTunneled(new PlayerUpdatePacketUpdateCharacterState + { + Guid = connection.Player.Guid, + State = FishingActivityZones.FishingCharacterState + }); + + // Create/refresh the fishing session and record which zone the player entered so the + // RegisterPlayerResponse can report the correct zone config. + var session = FishingSessions.GetOrCreate(connection.Player); + session.SetZone(packet.StateId, fishingZone); + session.Reset(); + + _logger.LogInformation( + "Started fishing minigame activity {activityId}, zoning to {zoneName}", + packet.StateId, + fishingZone.ZoneName); + } return true; } diff --git a/src/Sanctuary.Gateway/Handlers/PacketChat/PacketChatHandler.cs b/src/Sanctuary.Gateway/Handlers/PacketChat/PacketChatHandler.cs index a46988d..97645e0 100644 --- a/src/Sanctuary.Gateway/Handlers/PacketChat/PacketChatHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/PacketChat/PacketChatHandler.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Numerics; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -40,6 +41,10 @@ public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan packet.FromGuid = connection.Player.Guid; packet.FromName = connection.Player.Name; + // Debug commands (not broadcast to other players). + if (TryHandleCommand(connection, packet.Message)) + return true; + switch (packet.Channel) { case ChatChannel.Tell: @@ -137,4 +142,56 @@ public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan return true; } + + /// + /// Handles debug commands typed in chat. Returns true if the message was a command (and therefore + /// should not be broadcast as normal chat). The client swallows most unknown "/slash" commands and + /// never sends them, so these also match with NO leading slash (e.g. just type "tporigin"). + /// + private static bool TryHandleCommand(GatewayConnection connection, string? message) + { + if (string.IsNullOrWhiteSpace(message)) + return false; + + var text = message.Trim().TrimStart('/'); + + var parts = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + return false; + + var command = parts[0].ToLowerInvariant(); + + switch (command) + { + // Teleport to a world position (default: the world origin 0,0,0). Debug tool for checking + // where stray/preloaded actors end up. Usage: tporigin or tp + case "tporigin": + case "tp": + { + float x = 0f, y = 0f, z = 0f; + if (parts.Length >= 4) + { + float.TryParse(parts[1], out x); + float.TryParse(parts[2], out y); + float.TryParse(parts[3], out z); + } + + var pos = new Vector4(x, y, z, 1f); + + // Same teleport path the real teleport handler uses. + connection.SendTunneled(new ClientUpdatePacketUpdateLocation + { + Position = pos, + Rotation = connection.Player.Rotation, + Teleport = true + }); + + _logger.LogInformation("Player {guid} teleported via '{command}' -> {pos}", connection.Player.Guid, command, pos); + return true; + } + + default: + return false; + } + } } \ No newline at end of file diff --git a/src/Sanctuary.Gateway/Handlers/PacketTunneledClientPacketHandler.cs b/src/Sanctuary.Gateway/Handlers/PacketTunneledClientPacketHandler.cs index 13e6219..5a736c4 100644 --- a/src/Sanctuary.Gateway/Handlers/PacketTunneledClientPacketHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/PacketTunneledClientPacketHandler.cs @@ -62,6 +62,7 @@ public static bool HandlePacket(GatewayConnection connection, Span data) PlayerUpdatePacketJump.OpCode => PlayerUpdatePacketJumpHandler.HandlePacket(connection, packet.Payload), BaseCoinStorePacket.OpCode => BaseCoinStorePacketHandler.HandlePacket(connection, reader), BaseActivityServicePacket.OpCode => BaseActivityServicePacketHandler.HandlePacket(connection, reader, 2), + BaseFishingPacket.OpCode => BaseFishingPacketHandler.HandlePacket(connection, reader), MountBasePacket.OpCode => MountBasePacketHandler.HandlePacket(connection, reader), PacketClientInitializationDetails.OpCode => PacketClientInitializationDetailsHandler.HandlePacket(connection, packet.Payload), BaseNameChangePacket.OpCode => BaseNameChangePacketHandler.HandlePacket(connection, reader), diff --git a/src/Sanctuary.Gateway/Handlers/WallOfDataBasePacket/WallOfDataUIEventPacketHandler.cs b/src/Sanctuary.Gateway/Handlers/WallOfDataBasePacket/WallOfDataUIEventPacketHandler.cs index 98d6aad..fbce37d 100644 --- a/src/Sanctuary.Gateway/Handlers/WallOfDataBasePacket/WallOfDataUIEventPacketHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/WallOfDataBasePacket/WallOfDataUIEventPacketHandler.cs @@ -33,6 +33,19 @@ public static bool HandlePacket(GatewayConnection connection, ReadOnlySpan _logger.LogTrace("Received {name} packet. ( {packet} )", nameof(WallOfDataUIEventPacket), packet); + if (packet.TableName == "ChatLog" && + packet.Callback == "sendChatMessage" && + packet.Param is not null && packet.Param.StartsWith("/pos")) + { + var chatPacketDebugChat = new ChatPacketDebugChat + { + Message = $"Position (X,Y,Z): {connection.Player.Position.X} {connection.Player.Position.Y} {connection.Player.Position.Z}", + PrintToChat = true + }; + + connection.SendTunneled(chatPacketDebugChat); + } + return true; } } \ No newline at end of file diff --git a/src/Sanctuary.Gateway/NLog.Development.config b/src/Sanctuary.Gateway/NLog.Development.config index e7d644d..48dec87 100644 --- a/src/Sanctuary.Gateway/NLog.Development.config +++ b/src/Sanctuary.Gateway/NLog.Development.config @@ -6,12 +6,16 @@ + + + diff --git a/src/Sanctuary.Gateway/NLog.config b/src/Sanctuary.Gateway/NLog.config index 8274f37..2060746 100644 --- a/src/Sanctuary.Gateway/NLog.config +++ b/src/Sanctuary.Gateway/NLog.config @@ -6,12 +6,16 @@ + + + diff --git a/src/Sanctuary.Packet.Common/Fishing/ClientFishEntryInfo.cs b/src/Sanctuary.Packet.Common/Fishing/ClientFishEntryInfo.cs new file mode 100644 index 0000000..3345afc --- /dev/null +++ b/src/Sanctuary.Packet.Common/Fishing/ClientFishEntryInfo.cs @@ -0,0 +1,45 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet.Common; + +public class ClientFishEntryInfo : ISerializableType +{ + public int Type; + + public int NameId; + + public int IconId; + + public bool Unknown4; + public bool FishSpecial; + + public int FishLureRequirement; + + public string? Unknown7; + + public int Unknown8; + + public bool FishCatchable; + + public int Unknown10; + + public void Serialize(PacketWriter writer) + { + writer.Write(Type); + writer.Write(NameId); + writer.Write(IconId); + + writer.Write(Unknown4); + writer.Write(FishSpecial); + + writer.Write(FishLureRequirement); + + writer.Write(Unknown7); + + writer.Write(Unknown8); + + writer.Write(FishCatchable); + + writer.Write(Unknown10); + } +} \ No newline at end of file diff --git a/src/Sanctuary.Packet.Common/Fishing/FishingPlayerConfig.cs b/src/Sanctuary.Packet.Common/Fishing/FishingPlayerConfig.cs new file mode 100644 index 0000000..bfef9b1 --- /dev/null +++ b/src/Sanctuary.Packet.Common/Fishing/FishingPlayerConfig.cs @@ -0,0 +1,53 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet.Common; + +public class FishingPlayerConfig : ISerializableType +{ + public int Unknown; + + public int Unknown16; + public int Unknown14; + public int Unknown15; + + public int Unknown7; + + // Client reads these two as floats: min/max cast distance. The aiming state only validates a + // cast when the water-raycast hit distance is within [Unknown2, Unknown3]; leaving them 0 + // makes casting impossible. (Server config defaults: MinCastDistance 3.0, MaxCastDistance 20.0.) + public float Unknown2; + public float Unknown3; + + public int Unknown4; + public int Unknown5; + public int Unknown6; + public int Unknown8; + public int Unknown9; + + public float Unknown10; // 6.0f + public float Unknown11; // 0.444f + public float Unknown12; // 1.85f + public float Unknown13; // 0.2f + + public void Serialize(PacketWriter writer) + { + writer.Write(Unknown); + writer.Write(Unknown2); + writer.Write(Unknown3); + writer.Write(Unknown4); + writer.Write(Unknown5); + writer.Write(Unknown6); + writer.Write(Unknown7); + writer.Write(Unknown8); + writer.Write(Unknown9); + + writer.Write(Unknown10); + writer.Write(Unknown11); + writer.Write(Unknown12); + writer.Write(Unknown13); + + writer.Write(Unknown14); + writer.Write(Unknown15); + writer.Write(Unknown16); + } +} \ No newline at end of file diff --git a/src/Sanctuary.Packet.Common/Fishing/FishingZoneConfig.cs b/src/Sanctuary.Packet.Common/Fishing/FishingZoneConfig.cs new file mode 100644 index 0000000..750d11e --- /dev/null +++ b/src/Sanctuary.Packet.Common/Fishing/FishingZoneConfig.cs @@ -0,0 +1,31 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet.Common; + +public class FishingZoneConfig : ISerializableType +{ + // Client reads Unknown3 and Unknown6 as floats (fUnknown3 = fish-run X baseline, + // fUnknown6 -> m_fUnknownFloat = water-surface Y used for bobber/fish placement). + public float Unknown6; + + public string? Unknown; + + public int Unknown2; + public float Unknown3; + public int Unknown4; + public int Unknown5; + + // public List FishingSchoolInstances = []; + // public Dictionary FishingSchoolPaths = []; + + public void Serialize(PacketWriter writer) + { + writer.Write(Unknown); + + writer.Write(Unknown2); + writer.Write(Unknown3); + writer.Write(Unknown4); + writer.Write(Unknown5); + writer.Write(Unknown6); + } +} \ No newline at end of file diff --git a/src/Sanctuary.Packet.Common/Fishing/UnderwaterFishSpawnInfo.cs b/src/Sanctuary.Packet.Common/Fishing/UnderwaterFishSpawnInfo.cs new file mode 100644 index 0000000..f815e73 --- /dev/null +++ b/src/Sanctuary.Packet.Common/Fishing/UnderwaterFishSpawnInfo.cs @@ -0,0 +1,61 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet.Common; + +public class UnderwaterFishSpawnInfo : ISerializableType +{ + public int Unknown; + + public int ModelId; + + public string? TintAlias; + public string? TextureAlias; + + public int Unknown5; // size class 1..4 + public int Unknown6; + + public bool Unknown7; // catchable flag + + // Unknown8..17 are read by the client as FLOATS (fish swim/turn speeds and wander timers). + // Sending int 1 here = 1.4e-45 (denormal ~0) which FREEZES the fish. See FISHING_RE_NOTES.md. + public float Unknown8; // approach time (>0.25) + public float Unknown9; // reel-in speed divisor + public float Unknown10; // nibble/flee swim speed + public float Unknown11; // (unused by tick) + public float Unknown12; // reel-in base speed offset + public float Unknown13; // turn/rotation speed + public float Unknown14; // wander speed + public float Unknown15; // wander deceleration + public float Unknown16; // approach/wander turn rate + public float Unknown17; // wander idle-time min + + public float Unknown18; // wander idle-time max + + public void Serialize(PacketWriter writer) + { + writer.Write(Unknown); + + writer.Write(ModelId); + + writer.Write(TintAlias); + writer.Write(TextureAlias); + + writer.Write(Unknown5); + writer.Write(Unknown6); + + writer.Write(Unknown7); + + writer.Write(Unknown8); + writer.Write(Unknown9); + writer.Write(Unknown10); + writer.Write(Unknown11); + writer.Write(Unknown12); + writer.Write(Unknown13); + writer.Write(Unknown14); + writer.Write(Unknown15); + writer.Write(Unknown16); + writer.Write(Unknown17); + + writer.Write(Unknown18); + } +} \ No newline at end of file diff --git a/src/Sanctuary.Packet/BaseChatPacket/ChatPacketDebugChat.cs b/src/Sanctuary.Packet/BaseChatPacket/ChatPacketDebugChat.cs new file mode 100644 index 0000000..d4fdbfc --- /dev/null +++ b/src/Sanctuary.Packet/BaseChatPacket/ChatPacketDebugChat.cs @@ -0,0 +1,29 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class ChatPacketDebugChat : BaseChatPacket, ISerializablePacket +{ + public new const short OpCode = 3; + + public string? Message = null!; + + public bool PrintToChat; + + public ChatPacketDebugChat() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + base.Write(writer); + + writer.Write(Message); + + writer.Write(PrintToChat); + + return writer.Buffer; + } +} \ No newline at end of file diff --git a/src/Sanctuary.Packet/BaseFishingPacket.cs b/src/Sanctuary.Packet/BaseFishingPacket.cs new file mode 100644 index 0000000..47e6bd4 --- /dev/null +++ b/src/Sanctuary.Packet/BaseFishingPacket.cs @@ -0,0 +1,32 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class BaseFishingPacket +{ + public const short OpCode = 138; + + private short SubOpCode; + + public BaseFishingPacket(short subOpCode) + { + SubOpCode = subOpCode; + } + + public void Write(PacketWriter writer) + { + writer.Write(OpCode); + writer.Write(SubOpCode); + } + + public bool TryRead(ref PacketReader reader) + { + if (!reader.TryRead(out short opCode) || opCode != OpCode) + return false; + + if (!reader.TryRead(out short subOpCode) || subOpCode != SubOpCode) + return false; + + return true; + } +} diff --git a/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketCastAnimRequest.cs b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketCastAnimRequest.cs new file mode 100644 index 0000000..2c70dc9 --- /dev/null +++ b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketCastAnimRequest.cs @@ -0,0 +1,38 @@ +using System; +using System.Numerics; + +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class FishingPacketCastAnimRequest : BaseFishingPacket, ISerializablePacket, IDeserializable +{ + public new const short OpCode = 5; + + public ulong Guid; + public int UnknownInt; + public Vector4 Position; + + public FishingPacketCastAnimRequest() : base(OpCode) { } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + Write(writer); + writer.Write(Guid); + writer.Write(UnknownInt); + writer.Write(Position); + return writer.Buffer; + } + + public static bool TryDeserialize(ReadOnlySpan data, out FishingPacketCastAnimRequest value) + { + value = new(); + var r = new PacketReader(data); + if (!value.TryRead(ref r)) return false; + if (!r.TryRead(out ulong g)) return false; value.Guid = g; + if (!r.TryRead(out int i)) return false; value.UnknownInt = i; + if (!r.TryRead(out Vector4 p)) return false; value.Position = p; + return true; + } +} diff --git a/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketCastRequest.cs b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketCastRequest.cs new file mode 100644 index 0000000..d8fde29 --- /dev/null +++ b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketCastRequest.cs @@ -0,0 +1,38 @@ +using System; +using System.Numerics; + +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class FishingPacketCastRequest : BaseFishingPacket, ISerializablePacket, IDeserializable +{ + public new const short OpCode = 6; + + public ulong Guid; + public Vector4 Position; + public bool Flag; + + public FishingPacketCastRequest() : base(OpCode) { } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + Write(writer); + writer.Write(Guid); + writer.Write(Position); + writer.Write(Flag); + return writer.Buffer; + } + + public static bool TryDeserialize(ReadOnlySpan data, out FishingPacketCastRequest value) + { + value = new(); + var r = new PacketReader(data); + if (!value.TryRead(ref r)) return false; + if (!r.TryRead(out ulong g)) return false; value.Guid = g; + if (!r.TryRead(out Vector4 p)) return false; value.Position = p; + if (!r.TryRead(out byte f)) return false; value.Flag = f != 0; + return true; + } +} diff --git a/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketDespawnProxiedFishingSchool.cs b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketDespawnProxiedFishingSchool.cs new file mode 100644 index 0000000..3054995 --- /dev/null +++ b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketDespawnProxiedFishingSchool.cs @@ -0,0 +1,21 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +/// Sub-opcode 12: int SchoolId (4 bytes after header) +public class FishingPacketDespawnProxiedFishingSchool : BaseFishingPacket, ISerializablePacket +{ + public new const short OpCode = 12; + + public int SchoolId; + + public FishingPacketDespawnProxiedFishingSchool() : base(OpCode) { } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + Write(writer); + writer.Write(SchoolId); + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketFishInfoUpdate.cs b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketFishInfoUpdate.cs new file mode 100644 index 0000000..fcf8cb5 --- /dev/null +++ b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketFishInfoUpdate.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Numerics; + +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class FishingPacketFishInfoUpdate : BaseFishingPacket, ISerializablePacket +{ + public new const short OpCode = 4; + + public List ClientFishEntries = []; + + public FishingPacketFishInfoUpdate() : base(OpCode) { } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + Write(writer); + writer.Write(ClientFishEntries); + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketFishingResult.cs b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketFishingResult.cs new file mode 100644 index 0000000..3279cdd --- /dev/null +++ b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketFishingResult.cs @@ -0,0 +1,58 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +/// Sub-opcode 14: ulong Guid + int + bool + int + int + string + int + int + int +/// + int + int + int + int + string + string + int + int + bool + int +public class FishingPacketFishingResult : BaseFishingPacket, ISerializablePacket +{ + public new const short OpCode = 14; + + public ulong Guid; // @16 target player (must be local player guid) + public int ResultType; // @24 + public bool Caught; // @28 "special/no-fish" flag — false to show size+weight + public int FishId; // @32 fish NAME string-table id (localized), NOT the model + public int Unknown1; // @36 scoring[1] + public string? FishName; // @40 (unused by catch banner) + public float Unknown2; // @56 WEIGHT (banner "%2.2f") + public int Unknown3; // @60 size selector 1=small 2=med 3=large 4=xlarge + public float Unknown4; // @64 scoring[6] (weight/score) + public int Unknown5; // @68 scoring[2] + public int Unknown6; // @72 scoring[3] + public int Unknown7; // @76 scoring[5] + public int Unknown8; // @80 show-off held-fish MODEL id — MUST be > 0 or no banner + public string? UnknownStr1;// @84 held-fish tint alias + public string? UnknownStr2;// @100 held-fish texture alias + public int Unknown9; // @116 show-off sparkle composite-effect id + public int Unknown10; // @124 + public bool Unknown11; // @128 + public int Unknown12; // @120 show-off size class 1..4 (serialized last) + + public FishingPacketFishingResult() : base(OpCode) { } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + Write(writer); + writer.Write(Guid); + writer.Write(ResultType); + writer.Write(Caught); + writer.Write(FishId); + writer.Write(Unknown1); + writer.Write(FishName ?? ""); + writer.Write(Unknown2); + writer.Write(Unknown3); + writer.Write(Unknown4); + writer.Write(Unknown5); + writer.Write(Unknown6); + writer.Write(Unknown7); + writer.Write(Unknown8); + writer.Write(UnknownStr1 ?? ""); + writer.Write(UnknownStr2 ?? ""); + writer.Write(Unknown9); + writer.Write(Unknown10); + writer.Write(Unknown11); + writer.Write(Unknown12); + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketReelInRequest.cs b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketReelInRequest.cs new file mode 100644 index 0000000..b29ccb5 --- /dev/null +++ b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketReelInRequest.cs @@ -0,0 +1,34 @@ +using System; + +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class FishingPacketReelInRequest : BaseFishingPacket, ISerializablePacket, IDeserializable +{ + public new const short OpCode = 7; + + public ulong Guid; + public bool Flag; + + public FishingPacketReelInRequest() : base(OpCode) { } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + Write(writer); + writer.Write(Guid); + writer.Write(Flag); + return writer.Buffer; + } + + public static bool TryDeserialize(ReadOnlySpan data, out FishingPacketReelInRequest value) + { + value = new(); + var r = new PacketReader(data); + if (!value.TryRead(ref r)) return false; + if (!r.TryRead(out ulong g)) return false; value.Guid = g; + if (!r.TryRead(out byte f)) return false; value.Flag = f != 0; + return true; + } +} diff --git a/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketRegisterPlayerResponse.cs b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketRegisterPlayerResponse.cs new file mode 100644 index 0000000..b6d9a7b --- /dev/null +++ b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketRegisterPlayerResponse.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; + +using Sanctuary.Core.IO; +using Sanctuary.Packet.Common; + +namespace Sanctuary.Packet; + +public class FishingPacketRegisterPlayerResponse : BaseFishingPacket, ISerializablePacket +{ + public new const short OpCode = 3; + + public FishingPlayerConfig FishingPlayerConfig = new(); + public FishingZoneConfig FishingZoneConfig = new(); + + public List FishModelIds = []; + + public List ClientFishEntries = []; + + public FishingPacketRegisterPlayerResponse() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + FishingPlayerConfig.Serialize(writer); + FishingZoneConfig.Serialize(writer); + + writer.Write(FishModelIds); + + writer.Write(ClientFishEntries); + + return writer.Buffer; + } +} \ No newline at end of file diff --git a/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpawnFishRun.cs b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpawnFishRun.cs new file mode 100644 index 0000000..b8c671f --- /dev/null +++ b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpawnFishRun.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +using Sanctuary.Core.IO; +using Sanctuary.Packet.Common; + +namespace Sanctuary.Packet; + +/// Sub-opcode 18: bool Flag + int Unknown2 + string Name + UnderwaterFishSpawnInfo[] +public class FishingPacketSpawnFishRun : BaseFishingPacket, ISerializablePacket +{ + public new const short OpCode = 18; + + public bool Unknown; + public int Unknown2; + public string? Unknown3; + public List UnderwaterFishSpawns = []; + + public FishingPacketSpawnFishRun() : base(OpCode) { } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + Write(writer); + writer.Write(Unknown); + writer.Write(Unknown2); + writer.Write(Unknown3 ?? ""); + writer.Write(UnderwaterFishSpawns); + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpawnProxiedFishingBobber.cs b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpawnProxiedFishingBobber.cs new file mode 100644 index 0000000..eac9311 --- /dev/null +++ b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpawnProxiedFishingBobber.cs @@ -0,0 +1,29 @@ +using System.Numerics; + +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +/// Sub-opcode 8: ulong Guid + int Unknown + Vector4 Position + Vector4 Rotation +public class FishingPacketSpawnProxiedFishingBobber : BaseFishingPacket, ISerializablePacket +{ + public new const short OpCode = 8; + + public ulong Guid; + public int Unknown; + public Vector4 Position; + public Vector4 Rotation; + + public FishingPacketSpawnProxiedFishingBobber() : base(OpCode) { } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + Write(writer); + writer.Write(Guid); + writer.Write(Unknown); + writer.Write(Position); + writer.Write(Rotation); + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpawnProxiedFishingSchool.cs b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpawnProxiedFishingSchool.cs new file mode 100644 index 0000000..7ee3cfd --- /dev/null +++ b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpawnProxiedFishingSchool.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.Numerics; + +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +/// Sub-opcode 11: int SchoolId + Vector4 Position + Vector4 Rotation + int FishCount + FishItem[FishCount] + int + int +/// FishItem = 3 ints (last has bool flag in high byte) +public class FishingPacketSpawnProxiedFishingSchool : BaseFishingPacket, ISerializablePacket +{ + public new const short OpCode = 11; + + public int SchoolId; + public Vector4 Position; + public Vector4 Rotation; + public List Fish = []; + public List ModelIds = []; + public int Unknown1; + public int Unknown2; + + public FishingPacketSpawnProxiedFishingSchool() : base(OpCode) { } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + Write(writer); + writer.Write(SchoolId); + writer.Write(Position); + writer.Write(Rotation); + writer.Write(Fish.Count); + foreach (var f in Fish) + { + writer.Write(f.ModelId); + writer.Write(f.Unknown2); + writer.Write(f.Unknown3); + } + writer.Write(ModelIds); + writer.Write(Unknown1); + writer.Write(Unknown2); + return writer.Buffer; + } +} + +public class FishingSchoolFishItem +{ + public int ModelId; + public int Unknown2; + public int Unknown3; +} diff --git a/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpecialRequest.cs b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpecialRequest.cs new file mode 100644 index 0000000..cd13629 --- /dev/null +++ b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpecialRequest.cs @@ -0,0 +1,34 @@ +using System; + +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class FishingPacketSpecialRequest : BaseFishingPacket, ISerializablePacket, IDeserializable +{ + public new const short OpCode = 15; + + public ulong Guid; + public ulong Data; + + public FishingPacketSpecialRequest() : base(OpCode) { } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + Write(writer); + writer.Write(Guid); + writer.Write(Data); + return writer.Buffer; + } + + public static bool TryDeserialize(ReadOnlySpan data, out FishingPacketSpecialRequest value) + { + value = new(); + var r = new PacketReader(data); + if (!value.TryRead(ref r)) return false; + if (!r.TryRead(out ulong g)) return false; value.Guid = g; + if (!r.TryRead(out ulong d)) return false; value.Data = d; + return true; + } +} diff --git a/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpecialResponse.cs b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpecialResponse.cs new file mode 100644 index 0000000..38208b7 --- /dev/null +++ b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketSpecialResponse.cs @@ -0,0 +1,25 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +/// Sub-opcode 16: ulong Guid + int Unknown + byte Flag +public class FishingPacketSpecialResponse : BaseFishingPacket, ISerializablePacket +{ + public new const short OpCode = 16; + + public ulong Guid; + public int Unknown; + public bool Flag; + + public FishingPacketSpecialResponse() : base(OpCode) { } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + Write(writer); + writer.Write(Guid); + writer.Write(Unknown); + writer.Write(Flag); + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketUpdateData.cs b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketUpdateData.cs new file mode 100644 index 0000000..189cd57 --- /dev/null +++ b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketUpdateData.cs @@ -0,0 +1,29 @@ +using System.Numerics; + +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class FishingPacketUpdateData : BaseFishingPacket, ISerializablePacket +{ + public new const short OpCode = 1; + + public ulong Guid; + public Vector4 Position; + + public FishingPacketUpdateData() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + writer.Write(Guid); + writer.Write(Position); + + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketUpdateProxiedFishingBobber.cs b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketUpdateProxiedFishingBobber.cs new file mode 100644 index 0000000..0291682 --- /dev/null +++ b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketUpdateProxiedFishingBobber.cs @@ -0,0 +1,27 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +/// Sub-opcode 10: ulong Guid + int Unknown + byte Flag1 + byte Flag2 +public class FishingPacketUpdateProxiedFishingBobber : BaseFishingPacket, ISerializablePacket +{ + public new const short OpCode = 10; + + public ulong Guid; + public int Unknown; + public bool Flag1; + public bool Flag2; + + public FishingPacketUpdateProxiedFishingBobber() : base(OpCode) { } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + Write(writer); + writer.Write(Guid); + writer.Write(Unknown); + writer.Write(Flag1); + writer.Write(Flag2); + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketUpdateProxiedFishingSchool.cs b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketUpdateProxiedFishingSchool.cs new file mode 100644 index 0000000..a5c55f7 --- /dev/null +++ b/src/Sanctuary.Packet/BaseFishingPacket/FishingPacketUpdateProxiedFishingSchool.cs @@ -0,0 +1,25 @@ +using System.Numerics; + +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +/// Sub-opcode 13: int SchoolId + Vector4 Position +public class FishingPacketUpdateProxiedFishingSchool : BaseFishingPacket, ISerializablePacket +{ + public new const short OpCode = 13; + + public int SchoolId; + public Vector4 Position; + + public FishingPacketUpdateProxiedFishingSchool() : base(OpCode) { } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + Write(writer); + writer.Write(SchoolId); + writer.Write(Position); + return writer.Buffer; + } +} diff --git a/src/Sanctuary.Packet/PlayerUpdatePacketUpdateCharacterState.cs b/src/Sanctuary.Packet/PlayerUpdatePacketUpdateCharacterState.cs new file mode 100644 index 0000000..9123db5 --- /dev/null +++ b/src/Sanctuary.Packet/PlayerUpdatePacketUpdateCharacterState.cs @@ -0,0 +1,29 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +public class PlayerUpdatePacketUpdateCharacterState : BasePlayerUpdatePacket, ISerializablePacket +{ + public new const short OpCode = 20; + + public ulong Guid; + + public int State; + + public PlayerUpdatePacketUpdateCharacterState() : base(OpCode) + { + } + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + Write(writer); + + writer.Write(Guid); + + writer.Write(State); + + return writer.Buffer; + } +} \ No newline at end of file diff --git a/src/Sanctuary.Packet/RewardBundlePacketSingleItem.cs b/src/Sanctuary.Packet/RewardBundlePacketSingleItem.cs new file mode 100644 index 0000000..7c2a8e9 --- /dev/null +++ b/src/Sanctuary.Packet/RewardBundlePacketSingleItem.cs @@ -0,0 +1,38 @@ +using Sanctuary.Core.IO; + +namespace Sanctuary.Packet; + +/// +/// Opcode 50 (RewardBundle), sub-type 2 = single item. Shows the yellow "You received: X x N" +/// notification at the bottom of the screen (client ClientRewardManager -> "ItemReceived" GUI event). +/// +/// This is DISPLAY-ONLY: it does a read-only item-definition lookup for the name/icon/tint and fires +/// the notification. The item itself must still be granted separately (ClientUpdatePacketItemAdd). +/// Wire layout reverse-engineered from the client (sub_B8A640 / sub_B891F0) — see FISHING_RE_NOTES.md. +/// +public class RewardBundlePacketSingleItem : ISerializablePacket +{ + public const short OpCode = 50; + private const byte RewardTypeSingleItem = 2; + + public int ItemDefinitionId; + public int IconId; // 0 = use the item's default icon + public int TintId; // 0 = use the item's default tint + public int Count = 1; + + public byte[] Serialize() + { + using var writer = new PacketWriter(); + + writer.Write(OpCode); // int16 = 50 (re-read by the outer dispatcher to route here) + writer.Write(RewardTypeSingleItem);// uint8 = 2 (single-item sub-type) + writer.Write(ItemDefinitionId); // item definition id (drives the notification name/icon/tint) + writer.Write(ItemDefinitionId); // second id — only used to build a discarded string; mirror it + writer.Write(IconId); // IconData.m_nId (0 = default) + writer.Write(TintId); // IconData.m_nTintId (0 = default) + writer.Write(Count); // quantity ("x N") + writer.Write(0); // trailing dword — unused but must be present (27 bytes total) + + return writer.Buffer; + } +}