Commit Graph

76 Commits

Author SHA1 Message Date
Houmgaor
47277c712d feat(savedata): parse zenny/gzenny/CP from ZZ save blob
Adds read-only parsing for three scalar fields in the ZZ character save
blob: zenny (0xB0), gzenny (0x1FF64) and caravan points (0x212E4). Also
registers an offset for current_equip (0x1F604); extraction deferred
until its length is reverse-engineered. Offsets sourced from
Chakratos/mhf-save-manager and validated against a live HR999 blob.

Scope is intentionally ZZ-only: mhf-save-manager's F5 and G1-G5.2 maps
are not validated against live data, and the dormant pPlaytime vs
item_pouch collision in those versions is not resolved yet. Non-ZZ
modes leave the new pointers unmapped, and the read path is guarded by
`ok && off > 0 && off+size <= len(blob)` so unverified versions cannot
accidentally read from the blob.

Tests cover positive-path roundtrip (including live kirito blob),
regression guards for existing fields, non-ZZ isolation, new-character
skip, and bounds safety against truncated blobs.
2026-04-17 23:04:30 +02:00
Houmgaor
538724e6c9 fix(savedata): skip bookshelf read on pre-G1 clients
Bookshelf was introduced after Forward.5 (verified: F5 mhfo.dll has no
Bookshelf symbols, modern clients export .?AVBookshelfForm@@). For
F4/F5/S6 the configured pointers place the bookshelf region past the
end of the smaller save blob, causing a slice-bounds panic on every
MSG_MHF_SAVEDATA and rolling characters back to creation state. The
read is now bounds-checked and skipped when absent; bookshelf state
is persisted via house packets into user_binary.bookshelf, so leaving
BookshelfData nil is safe.
2026-04-07 21:56:40 +02:00
Houmgaor
44fd637a59 fix(api): use configured channel port in dashboard stats
The dashboard JSON hardcoded `54000 + server_id` as the channel port,
which is wrong whenever operators configure non-default ports in
config.json. Resolve the actual port from `Entrance.Entries[].Channels[]`
via a server_id map mirroring main.go's sid formula.
2026-04-07 09:20:50 +02:00
Houmgaor
0da28b42eb feat(i18n): add Chinese (zh) language support 2026-04-06 20:28:08 +02:00
Houmgaor
3dc74b0515 docs(changelog): condense #188 into a single entry 2026-04-06 20:09:05 +02:00
Houmgaor
5361e67b1a feat(i18n): per-session i18n routing and localized scenarios
Phase C of #188 — the last phase of server-side multi-language support.

Adds Session.I18n(), a cached per-session i18n table resolver built via
getLangStringsFor(s.Lang()). The pointer is stable until SetLang
invalidates the cache, so hot-path handlers pay zero allocations on
repeated calls. All 51 s.server.i18n.* call sites across commands,
guild, guild scout, cafe, and cast-binary handlers now route through
s.I18n().*, so chat replies, guild invite mail templates, cafe reset
notices, and quest-timer broadcasts are served in the player's
preferred language instead of the server-wide default.

Scenario JSON gets the same plain-or-map LocalizedString treatment
that quests received in phase B: subheader Strings and inline entry
Text accept either a plain string (backwards compatible) or a
language-keyed object. CompileScenarioJSON takes the compiling
session's language, loadScenarioBinary passes s.Lang(), and
ParseScenarioBinary emits plain-string LocalizedStrings so existing
.bin files round-trip byte-for-byte through the JSON path.

World-wide broadcasts (Raviente siege announcements via
BroadcastRaviente) intentionally stay on the server default — they
have no single-session context to resolve against.
2026-04-06 20:08:27 +02:00
Houmgaor
f7ea275540 feat(i18n): localized quest text and per-lang quest cache
Phase B of #188. Quest JSON title/description/text_main/text_sub_a/
text_sub_b/success_cond/fail_cond/contractor now accept either a plain
string (existing behaviour) or a language-keyed object like
  "title": { "jp": "...", "en": "...", "fr": "..." }
CompileQuestJSON takes the compiling session's language and resolves
each field through a fallback chain (requested -> plain -> jp -> en ->
any non-empty), so existing single-language quest JSONs keep working
byte-for-byte unchanged.

The quest cache is re-keyed on (questID, language) so compiled binaries
for different languages never leak between sessions on a multi-language
server. loadQuestBinary and loadQuestFile now pass s.Lang() into both
the compiler and the cache.

ParseQuestBinary emits plain-string LocalizedStrings, so the
binary -> JSON -> binary round-trip still produces identical output.

The new LocalizedString type lives in its own file and is reusable by
phase C (scenarios, mail templates, shop text). Shift-JIS encoding
still applies to the wire format, so localized values must use
characters representable in Shift-JIS — ASCII, kana, CJK — which is
documented on the type.
2026-04-06 20:00:43 +02:00
Houmgaor
5b38bfde3f feat(i18n): per-session language preference and !lang command
Phase A plumbing for #188. Adds a users.language column (migration
0022), UserRepo.GetLanguage/SetLanguage, and Session.Lang()/SetLang
accessors so future phases can resolve localized content per session
instead of falling back to the server-wide config.Language.

The preference is loaded from the DB on login and persisted via a new
!lang <en|jp|fr|es> chat command that shows the current language when
called without an argument, validates the code (case-insensitive), and
replies in the newly selected language so the switch is visible
immediately. An empty stored value falls back to config.Language.

sys_language.go exposes getLangStringsFor(code) as the new dispatch
primitive; getLangStrings(server) is now a thin wrapper so existing
callers keep working unchanged. isSupportedLang + supportedLangs keep
the !lang validator in sync with the dispatcher.

Localized quest/scenario content and per-session i18n lookups in
existing handlers are deliberately out of scope for phase A — this
commit ships only the plumbing so it can be reviewed and deployed
independently.
2026-04-06 19:52:19 +02:00
Houmgaor
e48d33ca76 feat(shutdown): passive drain and rename DisableSoftCrash
Shutdown now proceeds in three phases: close listeners immediately on
signal, broadcast the in-game countdown, wait up to ShutdownDrainSeconds
(default 30) for sessions to disconnect naturally via DrainPassive, then
force-close any stragglers. This prevents players from entering new
quests after the countdown starts, and lets mid-quest sessions finish
saving without being killed mid-write. A second SIGINT during passive
drain cancels it so the force-close phase runs immediately.

Renamed DisableSoftCrash -> DisableShutdownCountdown since the flag
controls the countdown, not crash behaviour. Existing config.json files
keep working via a Viper alias on the legacy key.

Closes #179.
2026-04-06 19:32:35 +02:00
Houmgaor
9b0f735335 chore(merge): merge develop into main for 9.4.0 cycle
Brings 53 develop commits (i18n, Diva, campaign, guild invites, save
transfer, return/rookie guilds, hunting tournament, JSON quest/scenario
loaders, Ghidra-derived user binary parsing, and misc fixes) onto main
now that 9.3.2 has been tagged and released.

Resolves two overlap zones:

1. Migration number collision. Main shipped 0010_fix_zero_rasta_id and
   0011_fix_stale_boost_time in 9.3.2; develop had independently
   numbered 0010_campaign..0015_tournament. The migration runner keys
   applied versions by integer, so coexisting files with the same
   numeric prefix would silently skip each other. Develop's files have
   been renumbered to 0016..0021, leaving main's 0010/0011 intact. A
   schema_version rename script is required on any server that had
   already applied the old develop numbers (only frontier.mogapedia.fr
   at the time of this merge).

2. CHANGELOG.md. Develop's in-progress feature entries move into
   [Unreleased] with updated migration references; the [9.3.2] section
   is preserved verbatim.

main.go version string bumped to 9.4.0-dev to mark the new cycle.

Full test suite (go test -race ./...) passes.
2026-04-06 19:06:09 +02:00
Houmgaor
e9510c3b3b chore(release): 9.3.2 2026-04-06 18:37:18 +02:00
Houmgaor
90875c602a docs(changelog): cover gacha, boost and protbot changes for 9.3.2
Catch Unreleased up to the commits pushed this session:

- empty-bytea gacha crash fix (#175) with the RE citation for the
  ZZ client's CSync_man::putReceive_gacha_item response buffer path
- StructScan warn-logging for misconfigured gacha_items rows
- stray double-ACK removal in handleMsgMhfGetBoostTimeLimit
- pre-1970 boost_time wraparound fix + 0011 healing migration
- protbot --action boost / gacha inspection scenarios
2026-04-06 18:26:24 +02:00
Houmgaor
05ea321d9e fix(migrations): heal rasta_id=0 rows (#163)
The pre-106cf85 SaveMercenary bug could overwrite a character's NULL
rasta_id with 0, a value not drawn from rasta_id_seq that causes silent
save failures. The code fix prevents new occurrences but leaves already
affected rows stuck. Migration 0010 sets rasta_id=NULL where it is 0 so
existing users auto-heal on upgrade.
2026-04-06 17:10:33 +02:00
Houmgaor
49a5069e3d fix(handlers): correct quest tune-value multiplier handling
Two bugs in handleMsgMhfEnumerateQuest affecting reward multipliers:

1. A Value > 0 filter silently dropped any multiplier set to exactly
   0.0 in config, causing the client to fall back to its hardcoded
   default (100%). So ZennyMultiplier: 0.0 produced *full* zenny
   instead of none. Removed the filter so zero values are sent
   verbatim.

2. uint16(float32(0.20) * 100) yields 19, not 20, due to float32
   representation of 0.20 being ~0.19999998. Added a
   multiplierToTuneValue helper using math.Round and applied it to
   all 18 multiplier call sites (HRP/SRP/GRP/GSRP/Zenny/GZenny/
   Material/GMaterial/GCP/GUrgent and NC variants).
2026-04-06 16:45:56 +02:00
Houmgaor
84e72f7d35 fix(handlers): honor DisableLoginBoost and DisableBoostTime fully (#187)
GetBoostTimeLimit and GetBoostRight now respect DisableBoostTime, and
UseKeepLoginBoost now respects DisableLoginBoost. Also fix a latent
zero-time.Time wraparound in GetBoostTimeLimit that caused the
"Boost Time" overlay to appear on fresh characters regardless of
config, since time.Time{}.Unix() cast to uint32 yields a large value
the client interprets as an active timestamp.
2026-04-06 16:16:05 +02:00
Houmgaor
99e6ea26f1 feat(handlers): parse and log user binary types 1-3
Reverse-engineered from mhfo-hd.dll via Ghidra: type 1 = character name,
type 2 = player profile (208B), type 3 = equipment snapshot (384B).
Adds structured zap logging and size validation warnings to
handleMsgSysSetUserBinary.
2026-04-06 16:05:19 +02:00
Houmgaor
9b3884fc26 chore(merge): merge main into develop
Backports two softlock fixes and playtime regression fix from main.
Also removes handleMsgMhfEnterTournamentQuest from the nil-stub test
list — it's a real DB-backed handler and has its own dedicated test.
2026-03-23 22:50:27 +01:00
Houmgaor
abab6dc3a1 fix(handlers): fix softlock on forge purchases and N-points
MSG_CA_EXCHANGE_ITEM and MSG_MHF_USE_UD_SHOP_COIN had Parse() returning
"NOT IMPLEMENTED". The dispatch loop in handlePacketGroup treats any
Parse error as a silent drop — no ACK is sent, causing the client to
wait indefinitely (softlock). Reported on 9.3.0-rc1 for forge item
purchases and Hunting Road N-point interactions.

Fix follows the pattern from d27da5e: parse only the AckHandle, return
nil from Parse, and respond with doAckBufFail so the client's error
branch exits cleanly without reading response fields.
2026-03-23 22:20:32 +01:00
Houmgaor
72088db4ff fix(savedata): write playtime back to binary blob on save
updateSaveDataWithStruct only wrote RP and KQF into the blob, leaving
the playtime field stale. On each reconnect, GetCharacterSaveData read
the old in-game counter from the blob and reset s.playtime to it,
rolling back all progress accumulated during the previous session.

Playtime is now persisted into the blob alongside RP, using the same
S6 mode guard as the read path in updateStructWithSaveData.
2026-03-23 22:00:06 +01:00
Houmgaor
d1d3bb8698 chore(merge): merge main into develop
Resolves CHANGELOG.md conflict: preserve develop's [Unreleased] block,
insert the [9.3.1] section from main, remove the duplicate
DisableSaveIntegrityCheck entry that had been in [Unreleased].
2026-03-23 11:15:20 +01:00
Houmgaor
d0efc4e81a test(channelserver): replace time.Sleep with polling loops
Blind sleeps accumulate serially (no t.Parallel anywhere) and inflate
under the race detector's scheduling overhead — contributing to the
~136s channelserver test run time.

Replace ~75 arbitrary sleeps (50ms–1s) across 7 test files with 2s
polling loops that exit as soon as the expected condition holds. Sleeps
that are genuinely intentional (race-condition stress tests, cache
expiry, temporal spacing in timestamp tests, backpressure pacing) are
left untouched.
2026-03-23 10:57:01 +01:00
Houmgaor
3803fd431b chore(release): prepare 9.3.1 2026-03-23 10:13:00 +01:00
Houmgaor
05adda00d7 feat(i18n): add completeness test and fix missing JP strings
TestLangCompleteness uses reflection to walk the i18n struct and fail
on any empty string field, catching incomplete translations at CI time.
Running it immediately found three missing strings in lang_jp.go
(playtime, timer.enabled, timer.disabled), which are now filled in.
CHANGELOG updated with i18n refactor, FR/ES languages, and the new test.
2026-03-22 17:09:16 +01:00
Houmgaor
20ea925359 docs(changelog): add hunting tournament entry to Unreleased 2026-03-22 14:36:56 +01:00
Houmgaor
5ee9a0e635 feat(guild): implement rookie and return guild assignment
New/returning players are now auto-assigned to temporary holding guilds
on MSG_MHF_ENTRY_ROOKIE_GUILD (pkt.Unk=0 → rookie guild, ≥1 → comeback
guild). Guilds are created on demand and capped at 60 members. Players
leave via the OperateGuildGraduateRookie/Return actions. The guild info
response now reports isReturnGuild from the DB instead of hardcoded false.
Migration 0014_return_guilds adds return_type to the guilds table.
2026-03-22 00:27:05 +01:00
Houmgaor
5fe1b22550 feat(save-transfer): add saveutil CLI and token-gated import endpoint
Adds two complementary paths for transferring character save data between
Erupe instances without breaking the SHA-256 integrity check system:

- `cmd/saveutil/`: admin CLI with `import`, `export`, `grant-import`, and
  `revoke-import` subcommands. Direct DB access; no server running required.
- `POST /v2/characters/{id}/import`: player-facing API endpoint gated behind
  a one-time token issued by `saveutil grant-import` (default TTL 24 h).
  Token is validated and consumed atomically to prevent TOCTOU races.
- Migration `0013_save_transfer`: `savedata_import_token` and
  `savedata_import_token_expiry` columns on `characters` table.
- Both paths decompress incoming savedata and recompute the SHA-256 hash
  server-side, so the integrity check remains valid after import.
- README documents both methods and the per-character hash-reset workaround.

Closes #183.
2026-03-21 20:14:58 +01:00
Houmgaor
0ea399f135 feat(config): add DisableSaveIntegrityCheck flag for save transfers
The SHA-256 integrity check introduced in migration 0007 blocks saves
when a character's savedata blob is imported from another server instance,
because the stored hash in the target DB no longer matches the new blob.

Adding DisableSaveIntegrityCheck (default: false) lets server operators
bypass the check to unblock cross-server save transfers. A warning is
logged each time the check is skipped so the flag's use is auditable.

Documents the per-character SQL alternative in CHANGELOG:
  UPDATE characters SET savedata_hash = NULL WHERE id = <id>

Closes #183.
2026-03-21 19:38:16 +01:00
Houmgaor
dbbfb927f8 feat(guild): separate scout invitations into guild_invites table
Scout invitations were stored in guild_applications with type 'invited',
forcing the scout list response to use charID as the invitation ID — a
known hack that made CancelGuildScout semantically incorrect.

Introduce a dedicated guild_invites table (migration 0012) with a serial
PK. The scout list now returns real invite IDs and actual InvitedAt
timestamps. CancelGuildScout cancels by PK. AcceptInvite and DeclineInvite
operate on guild_invites while player-applied applications remain in
guild_applications unchanged.
2026-03-21 17:59:25 +01:00
Houmgaor
366aad0172 docs(changelog): document Diva Defense system with attribution 2026-03-20 17:55:04 +01:00
Houmgaor
7ff033e36e docs(changelog): remove fix entries already covered by Added section
The rengoku/loadQuestFile fix notes were redundant — the behaviour is
already documented under the JSON loader Added entries.
2026-03-20 17:12:41 +01:00
Houmgaor
fec2793ccc docs: document JSON format support in README and CHANGELOG
Add a JSON Format Support section to the README covering quests,
scenarios, and Hunting Road config. Add two missing CHANGELOG entries
for the rengoku .bin-first fix and the quest event board JSON fallback.
2026-03-20 16:41:36 +01:00
Houmgaor
a1dfdd330a feat(scenario): add JSON scenario support and JKR type-3 compressor
Closes #172. Scenario files in bin/scenarios/ can now be authored as
.json instead of .bin — the server compiles them to wire format on
load, falling back to .bin if no .json is present.

- Add ParseScenarioBinary / CompileScenarioJSON in scenario_json.go;
  supports sub-header format (strings as UTF-8, metadata as base64),
  inline format, and raw JKR blobs.
- Add PackSimple JKR type-3 (LZ77) compressor in jpk_compress.go,
  ported from ReFrontier JPKEncodeLz.cs; round-trip tested against
  UnpackSimple.
- Fix off-by-one in processDecode (jpk.go): last literal byte was
  silently dropped for data that does not end on a back-reference.
- Wire loadScenarioBinary into handleMsgSysGetFile replacing the
  inline os.ReadFile call; mirrors the existing loadQuestBinary pattern.
- Rewrite docs/scenario-format.md with full container/sub-header spec
  and JSON schema examples.
2026-03-20 13:55:40 +01:00
Houmgaor
71b675bf3e docs: rename AUTHORS.md to HISTORY.md and update references
The file documents project lineage and development phases, not just
a flat list of authors — HISTORY.md is a more accurate name and
follows common open-source convention.
2026-03-20 11:56:19 +01:00
Houmgaor
e7180deb77 docs(changelog): reorder Unreleased sections to Added before Fixed 2026-03-20 11:49:27 +01:00
Houmgaor
34f0e89e7b fix(savedata): guard against sub-minimum backup data in recovery
nullcomp's passthrough path returns non-cmp-header data as-is without
error, which is correct for old uncompressed saves. However, a corrupt
backup slot containing garbage shorter than the minimum save layout
(100 bytes) would pass Decompress() and then panic in
updateStructWithSaveData() with a slice-bounds error at the name field
read (offset 88–100).

Add a minSaveSize check after each backup decompresses; skip the slot
if the result is too small. Also document the campaign system and the
fix in CHANGELOG under Unreleased.
2026-03-20 11:46:01 +01:00
Houmgaor
97ef09be64 docs(changelog): add rengoku JSON config to Unreleased 2026-03-20 00:08:48 +01:00
Houmgaor
6139e90968 docs(changelog): add quest JSON feature to Unreleased 2026-03-19 18:23:46 +01:00
Houmgaor
90948bfb71 chore(release): prepare 9.3.0
Promote [Unreleased] to [9.3.0] - 2026-03-19 in CHANGELOG and mark
the bookshelf save pointer fix as Done in improvements.md (it was
applied post-RC1 but the doc still said Pending).
2026-03-19 15:18:33 +01:00
Houmgaor
39d93f6eed docs(changelog): log G-rank Workshop/Cog softlock fix (#180) 2026-03-19 14:37:11 +01:00
Houmgaor
a6025be8b7 fix(festa): filter trials and rewards for Forward.5 compatibility (#156)
Skip trials referencing monsters added after em106 (Odibatorasu, the
last F5 monster) and filter out item 7011 which does not exist before
G1, preventing client crashes on Forward.4/5 servers.

Also logs the pre-existing bookshelf save data pointer fix (already
applied during the savedata refactor) in the CHANGELOG.
2026-03-18 23:19:25 +01:00
Houmgaor
835f97d3c2 fix(shutdown): force-stop on second SIGINT during countdown
A second Ctrl+C/SIGINT while the 10-second shutdown countdown is
running now exits immediately instead of being silently dropped.
The signal channel is buffered to 2 so the second signal is captured,
and the countdown select exits via os.Exit(1) on receipt.
2026-03-18 21:36:24 +01:00
Houmgaor
792dcd5d91 feat(diva): implement Diva Defense point accumulation (#168)
RE'd putAdd_ud_point (FUN_114fd490) and putAdd_ud_tactics_point
(FUN_114fe9c0) from the ZZ client DLL via Ghidra decompilation.

MsgMhfAddUdPoint fields: QuestPoints (sum of 11 category accumulators
earned per quest) and BonusPoints (kiju prayer song multiplier extra).
MsgMhfAddUdTacticsPoint fields: QuestID and TacticsPoints.

Adds diva_points table (migration 0009) for per-character per-event
point tracking, with UPSERT-based atomic accumulation in the handler.
2026-03-18 12:09:44 +01:00
Houmgaor
61d85e749f feat(achievement): add rank-up notifications (#165)
RE'd putDisplayed_achievement from ZZ client DLL via Ghidra: the packet
sends opcode + 1 zero byte with no achievement ID, acting as a blanket
"I saw everything" signal.

Server changes:
- Track per-character last-displayed levels in new displayed_levels
  column (migration 0008)
- GetAchievement compares current vs displayed levels per entry
- DisplayedAchievement snapshots current levels to clear notifications
- Repo, service, mock, and 3 new service tests

Protbot changes:
- New --action achievement: fetches achievements, shows rank-up markers,
  sends DISPLAYED_ACHIEVEMENT, re-fetches to verify notifications clear
- Packet builders for GET/ADD/DISPLAYED_ACHIEVEMENT
2026-03-18 11:35:31 +01:00
Houmgaor
197164bc94 docs: log savedata corruption defense in CHANGELOG
Merge migrations 0007 and 0008 into a single 0007_savedata_integrity
migration since neither has been released yet.
2026-03-17 19:30:43 +01:00
Houmgaor
03adb21e99 fix(channelserver): post-RC1 stabilization sprint
Fix rasta_id=0 overwriting NULL in SaveMercenary, which prevented
game state saving for characters without a mercenary (#163).

Also includes:
- CHANGELOG updated with all 10 post-RC1 commits
- Setup wizard fmt.Printf replaced with zap structured logging
- technical-debt.md updated with 6 newly completed items
- Scenario binary format documented (docs/scenario-format.md)
- Tests: alliance nil-guard (#171), handler dispatch table,
  migrations (sorted/SQL/baseline), setup wizard (10 tests),
  protbot protocol sign/entrance/channel (23 tests)
2026-03-05 16:39:15 +01:00
Houmgaor
5106b905de docs: update changelog for v9.3.0-rc1 release
Backfill key changes from 900 commits since 9.2.0 including
architecture rewrite, game system overhauls, client version
support, and developer tooling. Tag Unreleased as 9.3.0-rc1.
2026-02-28 19:18:11 +01:00
Houmgaor
fa09e4a39c fix(migrations): drop unused data column from distribution table (#169)
The distribution table had a `data bytea NOT NULL` column that was never
read by the Go code — item data is stored in distribution_items instead.
The NOT NULL constraint forced dummy values in seed data and test inserts.

Remove the column from the baseline schema, seed data, and tests, and
add migration 0005 to drop it from existing databases.
2026-02-27 18:19:57 +01:00
Houmgaor
21f9a79b62 fix(channelserver): correct session handler retail mismatches (#167)
Lobby search now returns only quest-bound players (QuestReserved) instead
of all reserved slots, matching retail behavior. The new field is
pre-collected under server lock before stage iteration to respect
Server.Mutex → Stage.RWMutex lock ordering.

Replaced three TODOs with RE documentation from Ghidra decompilation of
mhfo-hd.dll ZZ:
- Log key off-by-one: putRecord_log/putTerminal_log pass size 0 for the
  key field in ZZ, so the stored key is unused beyond issuance
- User search padding: ZZ per-entry parser confirms 40-byte block via
  memcpy(dst, src+8, 0x28); G2 DLL analysis inconclusive (stripped)
- Player count: field at entry offset 0x08 maps to struct param_1[0xe]
2026-02-27 17:29:32 +01:00
Houmgaor
649eebe67c docs: log bookshelf data pointer fix in changelog and technical debt tracker 2026-02-27 16:53:35 +01:00
Houmgaor
7911d84d48 docs: log alliance application toggle fix in changelog 2026-02-27 15:02:30 +01:00