Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion App/Sources/SaveEditorModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ final class SaveEditorModel {
func inventoryRows() -> [InventoryRow] {
guard let document, let ref = resolvedReferenceDB() else { return [] }
return document.inventoryItems().map { slot in
InventoryRow(id: slot.itemID, name: ref.itemName(id: slot.itemID) ?? "#\(slot.itemID)", count: slot.count)
InventoryRow(id: slot.itemID, name: ref.itemName(id: slot.itemID) ?? String(localized: "item"), count: slot.count)
}
}

Expand Down
2 changes: 1 addition & 1 deletion App/Tests/SaveEditorModelTests/ExpandedEditingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ import DaveSaveCore
let model = SaveEditorModel(referenceDB: try ReferenceDB.bundled())
model.load(data: SaveCodec.encode(json), sourceURL: nil)
model.addInventoryItem(itemID: 1014980, count: 99)
#expect(model.ingredientStatus == "Set Dredge ResearchPart (1014980) → 99.")
#expect(model.ingredientStatus == "Set Machine Component (1014980) → 99.")
#expect(model.hasChanges)
}

Expand Down
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,19 @@ the part of a release worth reading before you upgrade.

## [Unreleased]

Nothing yet.
### Added

- **Complete Item & Equipment Catalog**: Added 601 equipment items (weapons, harpoon guns, charms, diving gear) and 48 subequipment items from game data into `reference.sqlite`. Weapons (Basic Underwater Rifle, Triple Axel), harpoon guns (Steel Harpoon Gun, Dragonbite Harpoon Gun), and charms (Dolphin Necklace, Octopus Bracelet) are now fully recognized by their save-side equipment IDs instead of appearing unidentified (`#id #id`).
- **Official In-Game Localizations**: Integrated official game translations across English, Korean, Simplified Chinese, and Traditional Chinese for 1,236 item and equipment text keys. Item names now display in the user's active language with fallback to English.
- **Multi-Language Item Search**: The item search in the Advanced section now matches across in-game names in all supported languages, developer keys, and numeric IDs.
- **In the Jungle & May DLC Support**: Added DLC mappings for DLC 2 (May / Guilty Gear) and DLC 4 (In the Jungle) in `IngredientOps` and `CraftMaterialOps`, enabling recognition and maxing of Jungle crafting materials and ingredients.
- **Reference Database Generator**: Added `Scripts/build_reference_db.py` to deterministically rebuild `reference.sql` and `reference.sqlite` from game assets.

### Fixed

- Charm equipment IDs (e.g. `3017001`) no longer resolve to raw unappraised pickup keys like `Unidentified LongDash #3017001`; they now resolve to their actual equipped charm names (e.g. `Dolphin Necklace`).
- Cutscene and DLC items without translations (such as Yoshie memory items `1019500`–`1019506`) now cleanly split CamelCase names (`Memory of Yoshie`) rather than rendering unspaced developer keys or duplicate `#id #id` strings.
- Inventory rows without a reference database name now fall back to the localized label `item` rather than repeating the slot ID twice (`#id #id`).

## [1.0.1] — 2026-08-12

Expand Down
318 changes: 318 additions & 0 deletions Scripts/build_reference_db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
#!/usr/bin/env python3
"""Build reference.sql and compile reference.sqlite from extracted game data and text assets.

Reads:
- DR_GameData_Item.json (Items, Ingredients)
- DR_GameData_Equipment.json (EquipmentItem, SubEquipment)
- 5eb3c6ccaf987c93a8ee4b94efa02f21.bundle (official localization tables)

Emits:
- reference.sql (checked in)
- Sources/DaveSaveCore/Resources/reference.sqlite (shipped bundle resource)
"""

import argparse
import json
import os
import pathlib
import re
import sqlite3
import sys

DEFAULT_GAME_ROOT = pathlib.Path.home() / (
"Library/Application Support/Steam/steamapps/common/Dave the Diver/"
"DaveTheDiver.app/Contents/Resources/Data/StreamingAssets/aa/StandaloneOSX"
)
DEFAULT_TEXT_BUNDLE = DEFAULT_GAME_ROOT / "5eb3c6ccaf987c93a8ee4b94efa02f21.bundle"
DEFAULT_TMP_DIR = pathlib.Path("/tmp/dtd_gamedata")


def parse_split_tables(filepath: pathlib.Path) -> dict[str, list[dict]]:
"""Parse text file structured as TableName@/[JSON]@/TableName2@/[JSON]..."""
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
tokens = f.read().split("@/")
tables = {}
for i in range(0, len(tokens) - 1, 2):
tname = tokens[i].strip()
raw = tokens[i + 1].strip()
if tname and raw:
tables[tname] = json.loads(raw)
return tables


def load_translations(bundle_path: pathlib.Path) -> dict[str, dict[str, str]]:
"""Extract all text asset entries from the Unity localization bundle."""
import UnityPy

if not bundle_path.exists():
raise FileNotFoundError(f"Localization bundle not found at {bundle_path}")

env = UnityPy.load(str(bundle_path))
translations = {}
for obj in env.objects:
if obj.type.name == "MonoBehaviour":
try:
tree = obj.read_typetree()
except Exception:
continue
for row in tree.get("dataArray", []):
name = row.get("name")
if name:
translations[name] = {
"en": (row.get("english") or "").strip(),
"ko": (row.get("korean") or "").strip(),
"zh_hans": (row.get("chinese") or "").strip(),
"zh_hant": (row.get("chinesetraditional") or "").strip(),
}
return translations


def escape_sql_str(val: str | None) -> str:
if val is None:
return "NULL"
escaped = str(val).replace("'", "''")
return f"'{escaped}'"


def build(
item_json_path: pathlib.Path,
eq_json_path: pathlib.Path,
text_bundle_path: pathlib.Path,
sql_out: pathlib.Path,
db_out: pathlib.Path,
):
print(f"Reading items from {item_json_path}...")
item_tables = parse_split_tables(item_json_path)
items = item_tables.get("Items", [])
ingredients = item_tables.get("Ingredients", [])

print(f"Reading equipment from {eq_json_path}...")
eq_tables = parse_split_tables(eq_json_path)
equipment = eq_tables.get("EquipmentItem", [])
sub_equipment = eq_tables.get("SubEquipment", [])

print(f"Loading translations from {text_bundle_path}...")
all_translations = load_translations(text_bundle_path)

referenced_keys = set()
for it in items:
k = it.get("ItemTextID")
if k:
referenced_keys.add(k)
for eq in equipment:
k = eq.get("ItemTextID")
if k:
referenced_keys.add(k)
for sub in sub_equipment:
k = sub.get("NameTextID")
if k:
referenced_keys.add(k)

print(f"Items: {len(items)}, Ingredients: {len(ingredients)}")
print(f"Equipment: {len(equipment)}, SubEquipment: {len(sub_equipment)}")
print(f"Unique referenced text keys: {len(referenced_keys)}")

matched_keys = sum(1 for k in referenced_keys if k in all_translations)
print(f"Keys with translations in bundle: {matched_keys} / {len(referenced_keys)}")

sql_lines = [
"BEGIN TRANSACTION;",
"CREATE TABLE Ingredients (",
" TID INTEGER PRIMARY KEY,",
" Type INTEGER",
");",
]

for ing in ingredients:
sql_lines.append(f"INSERT INTO \"Ingredients\" VALUES({ing['TID']},{ing.get('Type', 0)});")

sql_lines.extend([
"CREATE TABLE \"Items\" (",
" \"TID\" INTEGER NOT NULL CONSTRAINT \"PK_Items\" PRIMARY KEY AUTOINCREMENT,",
" \"ItemTextID\" TEXT NOT NULL,",
" \"ItemDescID\" TEXT NOT NULL,",
" \"ItemIcon\" TEXT NOT NULL,",
" \"ItemUIIcon\" TEXT NOT NULL,",
" \"ItemType\" INTEGER NOT NULL,",
" \"ItemLevel\" INTEGER NOT NULL,",
" \"ItemGrade\" INTEGER NOT NULL,",
" \"ItemRank\" INTEGER NOT NULL,",
" \"ItemMaxStackCount\" INTEGER NOT NULL,",
" \"ItemWeight\" REAL NOT NULL,",
" \"IsDisposable\" INTEGER NOT NULL,",
" \"ItemBuyPrice\" INTEGER NOT NULL,",
" \"ItemSellPrice\" INTEGER NOT NULL,",
" \"IsNotSale\" INTEGER NOT NULL,",
" \"ItemDataID\" INTEGER NOT NULL,",
" \"SpawnObject\" TEXT NOT NULL,",
" \"MaxCount\" INTEGER NOT NULL,",
" \"DataExchangeFormula\" TEXT NOT NULL,",
" \"SourcePathID\" INTEGER NOT NULL,",
" \"ItemDetailImage\" TEXT NOT NULL,",
" \"DLCType\" INTEGER NOT NULL",
");",
])

for it in items:
is_disp = 1 if it.get("IsDisposable") else 0
is_not_sale = 1 if it.get("IsNotSale") else 0
row_vals = [
str(it["TID"]),
escape_sql_str(it.get("ItemTextID", "")),
escape_sql_str(it.get("ItemDescID", "")),
escape_sql_str(it.get("ItemIcon", "")),
escape_sql_str(it.get("ItemUIIcon", "")),
str(it.get("ItemType", 0)),
str(it.get("ItemLevel", 0)),
str(it.get("ItemGrade", -1)),
str(it.get("ItemRank", -1)),
str(it.get("ItemMaxStackCount", 9999)),
str(it.get("ItemWeight", 0.0)),
str(is_disp),
str(it.get("ItemBuyPrice", -1)),
str(it.get("ItemSellPrice", -1)),
str(is_not_sale),
str(it.get("ItemDataID", -1)),
escape_sql_str(it.get("SpawnObject", "")),
str(it.get("MaxCount", 9999)),
escape_sql_str(it.get("DataExchangeFormula", "")),
str(it.get("SourcePathID", -1)),
escape_sql_str(it.get("ItemDetailImage", "")),
str(it.get("DLCType", 0)),
]
sql_lines.append(f"INSERT INTO \"Items\" VALUES({','.join(row_vals)});")

sql_lines.extend([
"CREATE TABLE IF NOT EXISTS \"Equipment\" (",
" \"TID\" INTEGER NOT NULL PRIMARY KEY,",
" \"ItemTextID\" TEXT,",
" \"ItemDescID\" TEXT,",
" \"ItemType\" INTEGER NOT NULL,",
" \"DLCType\" INTEGER NOT NULL",
");",
])

for eq in equipment:
row_vals = [
str(eq["TID"]),
escape_sql_str(eq.get("ItemTextID", "")),
escape_sql_str(eq.get("ItemDescID", "")),
str(eq.get("ItemType", 0)),
str(eq.get("DLCType", 0)),
]
sql_lines.append(f"INSERT INTO \"Equipment\" VALUES({','.join(row_vals)});")

sql_lines.extend([
"CREATE TABLE IF NOT EXISTS \"SubEquipment\" (",
" \"TID\" INTEGER NOT NULL PRIMARY KEY,",
" \"NameTextID\" TEXT,",
" \"DescTextID\" TEXT,",
" \"SubEquipmentType\" INTEGER NOT NULL",
");",
])

for sub in sub_equipment:
row_vals = [
str(sub["TID"]),
escape_sql_str(sub.get("NameTextID", "")),
escape_sql_str(sub.get("DescTextID", "")),
str(sub.get("SubEquipmentType", 0)),
]
sql_lines.append(f"INSERT INTO \"SubEquipment\" VALUES({','.join(row_vals)});")

sql_lines.extend([
"CREATE TABLE IF NOT EXISTS \"Translations\" (",
" \"TextID\" TEXT NOT NULL PRIMARY KEY,",
" \"en\" TEXT,",
" \"ko\" TEXT,",
" \"zh_hans\" TEXT,",
" \"zh_hant\" TEXT",
");",
])

for k in sorted(referenced_keys):
t = all_translations.get(k)
if t:
row_vals = [
escape_sql_str(k),
escape_sql_str(t["en"]),
escape_sql_str(t["ko"]),
escape_sql_str(t["zh_hans"]),
escape_sql_str(t["zh_hant"]),
]
sql_lines.append(f"INSERT INTO \"Translations\" VALUES({','.join(row_vals)});")

sql_lines.extend([
"CREATE INDEX IF NOT EXISTS \"idx_items_itemdataid\" ON \"Items\" (\"ItemDataID\");",
"CREATE INDEX IF NOT EXISTS \"idx_items_itemtype\" ON \"Items\" (\"ItemType\");",
"CREATE INDEX IF NOT EXISTS \"idx_items_dlctype\" ON \"Items\" (\"DLCType\");",
"CREATE INDEX IF NOT EXISTS \"idx_translations_en\" ON \"Translations\" (\"en\");",
"CREATE INDEX IF NOT EXISTS \"idx_translations_ko\" ON \"Translations\" (\"ko\");",
"CREATE INDEX IF NOT EXISTS \"idx_translations_zh_hans\" ON \"Translations\" (\"zh_hans\");",
"CREATE INDEX IF NOT EXISTS \"idx_translations_zh_hant\" ON \"Translations\" (\"zh_hant\");",
"CREATE VIEW IF NOT EXISTS \"ItemLookup\" AS",
" SELECT TID, ItemTextID, DLCType, 0 AS IsEquipment FROM Items",
" UNION ALL",
" SELECT TID, ItemTextID, DLCType, 1 AS IsEquipment FROM Equipment",
" UNION ALL",
" SELECT TID, NameTextID AS ItemTextID, 0 AS DLCType, 2 AS IsEquipment FROM SubEquipment;",
"COMMIT;",
])

print(f"Writing {sql_out}...")
sql_out.write_text("\n".join(sql_lines) + "\n", encoding="utf-8")

print(f"Building SQLite database at {db_out}...")
if db_out.exists():
db_out.unlink()
db_out.parent.mkdir(parents=True, exist_ok=True)

con = sqlite3.connect(str(db_out))
con.executescript(sql_out.read_text(encoding="utf-8"))
con.close()

con = sqlite3.connect(str(db_out))
cur = con.cursor()
n_items = cur.execute("SELECT COUNT(*) FROM Items;").fetchone()[0]
n_ings = cur.execute("SELECT COUNT(*) FROM Ingredients;").fetchone()[0]
n_eq = cur.execute("SELECT COUNT(*) FROM Equipment;").fetchone()[0]
n_sub = cur.execute("SELECT COUNT(*) FROM SubEquipment;").fetchone()[0]
n_trans = cur.execute("SELECT COUNT(*) FROM Translations;").fetchone()[0]
con.close()

print(f"Verified counts in {db_out}:")
print(f" Items: {n_items}")
print(f" Ingredients: {n_ings}")
print(f" Equipment: {n_eq}")
print(f" SubEquipment: {n_sub}")
print(f" Translations: {n_trans}")

if n_items != 1063 or n_ings != 462 or n_eq != 601 or n_sub != 48:
sys.exit("Error: Table row counts do not match expected totals!")
print("Database build successful!")


def main():
parser = argparse.ArgumentParser(description="Build reference.sql and reference.sqlite")
parser.add_argument("--items", type=pathlib.Path, default=DEFAULT_TMP_DIR / "DR_GameData_Item.json")
parser.add_argument("--equipment", type=pathlib.Path, default=DEFAULT_TMP_DIR / "DR_GameData_Equipment.json")
parser.add_argument("--bundle", type=pathlib.Path, default=DEFAULT_TEXT_BUNDLE)
parser.add_argument("--sql-out", type=pathlib.Path, default=pathlib.Path("reference.sql"))
parser.add_argument(
"--db-out",
type=pathlib.Path,
default=pathlib.Path("Sources/DaveSaveCore/Resources/reference.sqlite"),
)
args = parser.parse_args()

build(
item_json_path=args.items,
eq_json_path=args.equipment,
text_bundle_path=args.bundle,
sql_out=args.sql_out,
db_out=args.db_out,
)


if __name__ == "__main__":
main()
Loading