diff --git a/Cargo.lock b/Cargo.lock index be6a964..bc528e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,32 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "blake3" +version = "1.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + [[package]] name = "cc" version = "1.4.0" @@ -62,6 +88,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.6.4" @@ -108,6 +140,27 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "equivalent" version = "1.0.2" @@ -124,8 +177,10 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" name = "gdse" version = "0.1.0" dependencies = [ + "blake3", "clap", "lib_gddb", + "time", ] [[package]] @@ -190,12 +245,33 @@ dependencies = [ "libc", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -214,6 +290,26 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "shlex" version = "2.0.1" @@ -237,6 +333,38 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 55325e0..b612cae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,5 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +blake3 = "1.8" clap = { version = "4.5", features = ["derive"] } lib_gddb = { git = "https://github.com/gregates/lib-gddb.git" } +time = { version = "0.3", features = ["formatting", "local-offset", "macros"] } diff --git a/README.md b/README.md index 78c8ee9..3b71d5c 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,279 @@ -## Overview +# Contents -gdse is a simple, fully-automatic alternative to the popular [Rainbow -Filter](https://forums.crateentertainment.com/t/tool-rainbow-filter-item-highlighting/42765/917) mod -for the action RPG Grim Dawn, created by [Crate Entertainment](https://www.crateentertainment.com/). +- [Contents](#contents) + - [Overview](#overview) + - [Why not just use Rainbow Filter?](#why-not-just-use-rainbow-filter) + - [Installation \& Usage](#installation--usage) + - [Setup - Windows OS](#setup---windows-os) + - [Quick Start (Linux)](#quick-start-linux) + - [Environment Variable Requirement](#environment-variable-requirement) + - [Current Color Palette](#current-color-palette) + - [Rarity Colors](#rarity-colors) + - [Damage-Type Property Colors](#damage-type-property-colors) + - [Non-Damage Property Colors](#non-damage-property-colors) + - [Grim Dawn Color Code Reference](#grim-dawn-color-code-reference) + - [Custom Palette File](#custom-palette-file) + - [Common Windows Issues](#common-windows-issues) + - [Notes for AI Coding Agents](#notes-for-ai-coding-agents) +--- +## Overview +gdse is a simple, fully-automatic alternative to the popular [Rainbow Filter](https://forums.crateentertainment.com/t/tool-rainbow-filter-item-highlighting/42765/917) mod for the action RPG Grim Dawn, created by [Crate Entertainment](https://www.crateentertainment.com/). It recolors game text for items in two ways: -1. Separate coloring for affix rarity and base item rarity, for Common, Magic, and Rare items that - roll affixes. +1. Separate coloring for affix rarity and base item rarity, for Common, Magic, and Rare items that roll affixes. 2. Colored text in item tooltips for different damage types. image -These are the two features of rainbow filter that the author of gdse finds essential and can't play -Grim Dawn without. All other features of Rainbow Filter are omitted. One other small difference from -Rainbow Filter is that gdse colors Pierce damage pink, not red, to distinguish it from Bleed (you can -opt-in to Rainbow Filter's exact damage color scheme if you prefer it with the -`--rainbow-filter-damage-colors` flag). +These are the two features of Rainbow Filter that the author of gdse finds essential and can't play Grim Dawn without. All other features of Rainbow Filter are omitted. One other small difference from Rainbow Filter is that gdse colors Pierce damage pink, not red, to distinguish it from Bleed (you can opt in to Rainbow Filter-like Pierce coloring by setting `damage.pierce=r` in your custom palette file (see [Custom Palette File](#custom-palette-file)). -gdse works by writing modified copies of some game resources to the `/settings/` directory of your -Grim Dawn installation, just like Rainbow Filter. It's not compatible with Rainbow Filter or other -mods that change game text. If new text is added to the game by a patch, you may see missing tag -warnings until you re-run gdse. +gdse works by writing modified copies of some game resources to the `/settings/` directory of your Grim Dawn installation, just like Rainbow Filter. It's not compatible with Rainbow Filter or other mods that change game text. If new text is added to the game by a patch, you may see missing tag warnings until you re-run gdse. + +--- ## Why not just use Rainbow Filter? -Great question! I used Rainbow Filter for years. It's a great mod. You should continue to use it if -it works for you. +Great question! I used Rainbow Filter for years. It's a great mod. You should continue to use it if it works for you. + +The main motivation for gdse was that Rainbow Filter depends on hand-crafted configuration that needs to be updated after every game patch. That gives Rainbow Filter a lot of power and flexibility, but it also causes delays — it takes time to produce a new Rainbow Filter mod after each patch. -The main motivation for gdse was that Rainbow Filter depends on hand-crafted configuration that -needs to be updated after every game patch. That gives Rainbow Filter a lot of power and -flexibility, but it also causes delays — it takes time to produce a new Rainbow Filter mod after -each patch. +gdse depends on zero manually configured colors. Instead, it infers correct coloring based on game database files and a few simple heuristics. Therefore, when a new patch lands, all you need to do to get updated, recolored text files is rerun gdse. No waiting for me to release a new version in the typical case (major changes to the game may require updates to handle new game features, but it should mostly just work). -gdse depends on zero manually configured colors. Instead, it infers correct coloring based on game -database files and a few simple heuristics. Therefore, when a new patch lands, all you need to do to -get updated, recolored text files is rerun gdse. No waiting for me to release a new version in the -typical case (major changes to the game may require updates to handle new game features, but it -should mostly just work). +Additionally, I find many of Rainbow Filter's choices to be a little too much. I don't need an (S) to get inserted on every set item, or MIs to have a special color, or "Empowered" and "Mythical" to be a different color than the base item name, or for "Physique" to be colored pink. Your preferences may not match mine, and that's OK! One of Rainbow Filter's strengths is that it's highly customizable (it just takes a lot of effort to customize). gdse is simpler and more opinionated. This can be a virtue if you don't strongly disagree with its opinions. -Additionally, I find many of Rainbow Filter's choices to be a little too much. I don't need an (S) -to get inserted on every set item, or MIs to have a special color, or "Empowered" and "Mythical" to -be a different color than the base item name, or for "Physique" to be colored pink. Your preferences -may not match mine, and that's OK! One of Rainbow Filter's strengths is that it's highly -customizable (it just takes a lot of effort to customize). gdse is simpler and more opinionated. -This can be a virtue if you don't strongly disagree with its opinions. +--- ## Installation & Usage -gdse has been tested with game version 1.3.0 on linux only, and only for -English text files, but in theory it should work on windows and with localizations. +gdse was originally developed and tested on Linux by the original author (game version 1.3.0, English text files). Additional features , including recent palette and workflow additions, were developed by a contributor and tested on Windows only. In theory it should also work with localizations. + +There are not currently any precompiled binaries available. You must build from source. + +--- + +### Setup - Windows OS + +1. Install Rust (choose one option): + + Option A (recommended, PowerShell or cmd with winget): + + ```powershell + winget install --id Rustlang.Rustup -e --accept-source-agreements --accept-package-agreements + ``` + + Option B (manual installer from rust-lang.org): + - Go to https://www.rust-lang.org/tools/install + - Download and run `rustup-init.exe` + - Keep the default installation settings unless you have a specific reason to change them + +2. Open a new terminal, then verify tools are available with these commands: + + ```powershell + rustc --version + cargo --version + ``` + +3. Find your Grim Dawn install path in Steam: + - Open Steam library. + - Right-click Grim Dawn -> Manage -> Browse local files. + - Copy the folder path from the file explorer address bar. + +4. Set the required environment variable (replace the example path with your own): + + ```powershell + setx GRIM_DAWN_INSTALL_PATH "C:\Program Files (x86)\Steam\steamapps\common\Grim Dawn" + ``` + +5. Clone and run from the repo root: + + When you clone any repo (including gdse), you choose a local folder on your PC where it will be saved. + You must `cd` into that exact local folder before running cargo commands. + `C:\repos` below is only an example location: + + ```powershell + cd C:\repos + git clone git@github.com:gregates/gdse.git + cd C:\repos\gdse + cargo run --release + ``` + + gdse will first ask whether to use default colors or a custom palette file, then show whether it detected changes since your last run, then prompt: + `Proceed with recoloring run? [Y/N]`. + + If you cloned somewhere else, replace `C:\repos\gdse` with your own full path. + Example: if you cloned into Downloads, use `cd C:\Users\\Downloads\gdse`. + +Important: the Grim Dawn install path above is only an example. Use the actual path from your own installation. + +--- + +### Quick Start (Linux) + +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source "$HOME/.cargo/env" +rustc --version +cargo --version +export GRIM_DAWN_INSTALL_PATH="/path/to/Grim Dawn" +git clone git@github.com:gregates/gdse.git +cd /path/where/you/cloned/gdse +cargo run --release +``` + +gdse will first ask whether to use default colors or a custom palette file, then show whether it detected changes since your last run, then prompt: +`Proceed with recoloring run? [Y/N]`. + +### Environment Variable Requirement + +`GRIM_DAWN_INSTALL_PATH` is required. gdse writes modified text resources to: + +`$GRIM_DAWN_INSTALL_PATH/settings/` + +Re-run gdse after game patches if you see missing tags or incorrect colors. + +To make this easier to track, gdse also writes `gdse-db-hash.txt` in the output folder. Each run of `cargo run --release` appends one line in this format (local computer time, minute precision): + +` hash= steam_build_id= patch_versions=` + +`patch_versions` is inferred from release-marker comments found in game text (for example, lines starting with `#Patch`, `#Hotfix`, or `#Update`). + +--- + +### Current Color Palette + +gdse uses Grim Dawn's built-in color-code letters from `gd-colorcodes.json`. The tables below show the current mappings in plain English plus the game code letter. + +Note: item/tooltip text is rendered on dark/black backgrounds in Grim Dawn, so high-contrast bright colors are strongly recommended for readability. + +#### Rarity Colors + +| Category | Palette Key | Color Name | Game Code | +|---|---|---|---| +| Common item/affix | `rarity.common` | White | `w` | +| Magical item/affix | `rarity.magical` | Yellow | `y` | +| Rare item/affix | `rarity.rare` | Green | `g` | +| Epic item/affix (optional override) | `rarity.epic` | Uncolored by default | `b` (engine default blue) | +| Legendary item/affix (optional override) | `rarity.legendary` | Uncolored by default | `i` (engine default indigo) | + +#### Damage-Type Property Colors + +| Category | Palette Key | Color Name | Game Code | +|---|---|---|---| +| Physical | `damage.physical` | Khaki | `k` | +| Pierce (gdse default) | `damage.pierce` | Fuchsia/Pink | `f` | +| Bleeding | `damage.bleeding` | Red | `r` | +| Fire | `damage.fire` | Orange | `o` | +| Cold | `damage.cold` | Cyan | `c` | +| Lightning | `damage.lightning` | Cobalt | `z` | +| Poison/Acid | `damage.poison` | Olive | `l` | +| Vitality/Life | `damage.vitality`, `damage.life` | Maroon | `m` | +| Aether | `damage.aether` | Aqua | `a` | +| Chaos | `damage.chaos` | Purple | `p` | +| Elemental | `damage.elemental` | Yellow | `y` | + +#### Non-Damage Property Colors + +| Category | Palette Key | Color Name | Game Code | +|---|---|---|---| +| Cunning/Spirit/Physique aggregate (`tagCharAttribute0`) | `nondamage.attribute0` | Grayish Orange (highlight) | `h` | +| Mastery increment | `nondamage.mastery_increment` | Teal | `t` | +| All Skills increment | `nondamage.all_skill_increment` | Teal | `t` | +| Run speed | `nondamage.run_speed` | Uncolored by default | (none) | +| Cast speed | `nondamage.cast_speed` | Uncolored by default | (none) | +| Attack speed | `nondamage.attack_speed` | Uncolored by default | (none) | +| Total speed modifier | `nondamage.total_speed` | Uncolored by default | (none) | +| Run speed modifier | `nondamage.run_speed_modifier` | Uncolored by default | (none) | +| Offensive Ability | `nondamage.offensive_ability` | Uncolored by default | (none) | +| Defensive Ability | `nondamage.defensive_ability` | Uncolored by default | (none) | +| Crit damage modifier | `nondamage.crit_damage` | Uncolored by default | (none) | +| Damage multiplier | `nondamage.damage_mult` | Uncolored by default | (none) | +| Total damage modifier | `nondamage.total_damage` | Uncolored by default | (none) | + +#### Grim Dawn Color Code Reference + +The game uses single-letter color codes in tag text (for example `{^f}`). + +| Code | Name | Notes | +|---|---|---| +| `a` | Aqua | Bright, readable | +| `b` | Blue | Engine default Epic item color | +| `c` | Cyan | Bright, readable | +| `d` | Dark Gray | Usually too dark on tooltip background | +| `e` | Brown | Engine body text color | +| `f` | Fuchsia/Pink | Bright, readable | +| `g` | Green | Bright, readable | +| `h` | Grayish Orange | Engine highlight color | +| `i` | Indigo | Engine default Legendary item color | +| `j` | (disabled) | Disabled in game color table | +| `k` | Khaki | Bright, readable | +| `l` | Olive | Bright enough for many uses | +| `m` | Maroon | Darker than most bright colors | +| `n` | (disabled) | Disabled in game color table | +| `o` | Orange | Bright, readable | +| `p` | Purple | Bright, readable | +| `q` | Grayish Magenta | Engine lore-item color | +| `r` | Red | Bright, readable | +| `s` | Silver | Engine dynamic "inactive bonus" cue | +| `t` | Teal | Unused by engine defaults, good override candidate | +| `u` | (disabled) | Disabled in game color table | +| `v` | (disabled) | Disabled in game color table | +| `w` | White | Bright, readable | +| `x` | Dark Green | Usually too dark on tooltip background | +| `y` | Yellow | Bright, readable | +| `z` | Cobalt | Bright, readable | + +--- + +### Custom Palette File + +If you want your own colors, create `gdse-palette.txt` in the same folder where you run `cargo run --release`, or pass a custom file path with `--palette-file`. + +In the interactive run flow, if you answer `N` to `Use default gdse palette? [Y/N]`, gdse exits immediately and asks you to create `gdse-palette.txt`, then run again. +If you prefer another location or filename, use `--palette-file `. + +Format is one `key=letter` per line. Blank lines and `# comments` are allowed. + +Example (single override): + +```text +damage.chaos=f +``` + +This is totally valid: one line is enough. Any key you do not include keeps the built-in gdse default for that category. + +You can override just one category if you want. Example: a file containing only `damage.chaos=f` changes Chaos to hot pink and leaves every other color on gdse defaults. + +--- + +### Common Windows Issues + +- `rustc` or `cargo` not found: + - Close and reopen your terminal after installing Rust. + - Confirm `%USERPROFILE%\\.cargo\\bin` is in your PATH. +- linker error about `link.exe` not found: + - Install Visual Studio Build Tools with the "Desktop development with C++" workload. +- `GRIM_DAWN_INSTALL_PATH` missing or wrong: + - Print it with `echo $env:GRIM_DAWN_INSTALL_PATH` in PowerShell. + - Update it with `setx GRIM_DAWN_INSTALL_PATH ""`. + +--- + +### Notes for AI Coding Agents + +If you are using Copilot, Claude Code, Codex, or another coding agent: + +1. Run commands from the repository root so Cargo can find `Cargo.toml`. +2. Check `GRIM_DAWN_INSTALL_PATH` before running: + + ```powershell + echo $env:GRIM_DAWN_INSTALL_PATH + ``` -There are not currently any precompiled binaries available. You must build from source. To use gdse, -you can: +3. Prefer this command order when validating changes: -1. [Install rust](https://www.rust-lang.org/tools/install) -2. `git clone git@github.com:gregates/gdse.git` -3. `cd gdse` -4. `cargo run --release` + ```powershell + cargo build --release + cargo run --release + ``` -You must set GRIM_DAWN_INSTALL_PATH environment to the path to your game installation for the -program to work. By default, it will write the modified text resources to -`$GRIM_DAWN_INSTALL_PATH/settings/`. Re-run any time Grim Dawn gets patched, if you see any missing -tag errors or incorrect colorings. +4. Do not run destructive git commands on a user's repo (for example, `git reset --hard`). diff --git a/src/color.rs b/src/color.rs index e6ec337..4a2ce13 100644 --- a/src/color.rs +++ b/src/color.rs @@ -3,8 +3,8 @@ //! scope-decision memory). //! //! Only item & affix rarity is colored, and only the three rarities that can -//! carry a name-altering affix: Common=White, Magical=Yellow, Rare=Green. Epic/Legendary -//! names never take a prefix/suffix, so they're left to the engine's native +//! carry a name-altering affix: Common=White, Magical=Yellow, Rare=Green. +//! Higher rarities keep native game colors by default, but can be overridden. //! rarity color. Faction / Set / Skill / Quality / Style get no special cue //! either, matching Full Rainbow. Damage-type Property colors live separately in //! `property.rs`. Colors are the single-letter codes from gd-colorcodes.json. @@ -12,19 +12,23 @@ use crate::infer::TagInfo; use crate::keywords::{Kind, Rarity}; use crate::palette::{GREEN, WHITE, YELLOW}; +use crate::user_palette::UserPalette; /// The color letter to bake into the tag's value, or `None` if it's left /// untouched. Base item names are only colored when they can take a name- /// altering affix (otherwise the engine's native rarity color is fine and there /// is no bleed to guard against); affix tags are always colored by their rarity. -pub fn color_for(info: &TagInfo) -> Option { +pub fn color_for(info: &TagInfo, user_palette: &UserPalette) -> Option { if info.kind == Kind::Item && !info.affixable { return None; } match info.rarity { - Rarity::Common => Some(WHITE), - Rarity::Magical => Some(YELLOW), - Rarity::Rare => Some(GREEN), - Rarity::Epic | Rarity::Legendary => None, + Rarity::Common => Some(user_palette.rarity(Rarity::Common).unwrap_or(WHITE)), + Rarity::Magical => Some(user_palette.rarity(Rarity::Magical).unwrap_or(YELLOW)), + Rarity::Rare => Some(user_palette.rarity(Rarity::Rare).unwrap_or(GREEN)), + // Optional override key: rarity.epic + Rarity::Epic => user_palette.rarity(Rarity::Epic), + // Optional override key: rarity.legendary + Rarity::Legendary => user_palette.rarity(Rarity::Legendary), } } diff --git a/src/colorize.rs b/src/colorize.rs index 4c107e6..cd5beed 100644 --- a/src/colorize.rs +++ b/src/colorize.rs @@ -7,7 +7,7 @@ //! WanezGD's placement cascade), and any file that changed is written whole — //! comments, Desc lines and untouched tags preserved — to the output directory. -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::io::{BufRead, Seek}; use std::path::Path; @@ -18,6 +18,7 @@ use crate::color; use crate::db::install_path; use crate::infer; use crate::property::{self, DamageColors}; +use crate::user_palette::UserPalette; /// The text bundles for a language, base game + expansions, in load order. EN /// ships one arc per part; other languages bundle everything into the base arc @@ -33,6 +34,90 @@ fn text_arcs(lang: &str) -> [String; 4] { ] } +/// Best-effort patch-version detection from decoded tag text. +/// +/// Looks for comment markers like `#Patch v1.3.1` inside `tags*.txt` records +/// from the language text archives and returns distinct discovered versions. +pub fn detect_patch_versions(lang: &str) -> Vec { + let base = install_path(); + let mut versions = BTreeSet::new(); + + for rel in text_arcs(lang) { + let Ok(mut arc) = Archive::open(base.join(&rel)) else { + continue; + }; + let Ok(records) = arc.iter_records() else { + continue; + }; + for record in records.flatten() { + if !record.id.contains("tag") || !record.id.ends_with(".txt") { + continue; + } + let text = String::from_utf8_lossy(&record.data); + for line in text.lines() { + if let Some(v) = parse_patch_version(line) { + versions.insert(v.to_string()); + } + } + } + } + + versions.into_iter().collect() +} + +fn parse_patch_version(line: &str) -> Option<&str> { + // Most releases use comment markers like `#Patch v1.3.1`, but some builds + // may use other words such as `Hotfix` or `Update`. + let lower = line.to_ascii_lowercase(); + let marker_idx = ["#patch", "#hotfix", "#update"] + .iter() + .filter_map(|m| lower.find(m)) + .min()?; + + let tail = line[marker_idx..].trim(); + extract_version_token(tail) +} + +fn extract_version_token(s: &str) -> Option<&str> { + for raw in s.split_whitespace() { + let trimmed = raw.trim_matches(|c: char| ",;:()[]{}\"'".contains(c)); + let core = trimmed + .strip_prefix('v') + .or_else(|| trimmed.strip_prefix('V')) + .unwrap_or(trimmed); + if is_versionish(core) { + return Some(core); + } + } + None +} + +fn is_versionish(s: &str) -> bool { + let mut chars = s.chars().peekable(); + let mut saw_dot = false; + let mut saw_digit = false; + let mut starts_with_digit = false; + + if let Some(c) = chars.peek().copied() { + starts_with_digit = c.is_ascii_digit(); + } + + for c in chars { + if c.is_ascii_digit() { + saw_digit = true; + } else if c == '.' { + saw_dot = true; + } else if c == '-' || c == '_' || c.is_ascii_alphabetic() { + // Allow suffixes like 1.3.1a or 1.3.1-hotfix. + continue; + } else { + return false; + } + } + + starts_with_digit && saw_digit && saw_dot +} + /// Infers tag colors, rewrites `lang`'s text bundles, and writes the changed /// `.txt` files under `out_dir`, printing one line per file written. pub fn run( @@ -40,8 +125,9 @@ pub fn run( out_dir: &Path, lang: &str, damage_colors: DamageColors, + user_palette: &UserPalette, ) { - let colors = color_map(dbs); + let colors = color_map(dbs, user_palette); if let Err(e) = std::fs::create_dir_all(out_dir) { eprintln!("Could not create {}: {e}", out_dir.display()); @@ -63,7 +149,7 @@ pub fn run( continue; } let text = String::from_utf8_lossy(&record.data); - let (rewritten, colored) = recolor_file(&text, &colors, damage_colors); + let (rewritten, colored) = recolor_file(&text, &colors, damage_colors, user_palette); if colored == 0 { continue; } @@ -87,10 +173,13 @@ pub fn run( /// Builds the tag -> color-letter map for the DB-inferred item / affix tags. /// Damage-type Property tags aren't listed here: they're recognized by name on /// the fly in `recolor_file` (see `property::color_for`). -fn color_map(dbs: &mut [Database]) -> HashMap { +fn color_map( + dbs: &mut [Database], + user_palette: &UserPalette, +) -> HashMap { infer::infer(dbs) .into_iter() - .filter_map(|(tag, info)| color::color_for(&info).map(|c| (tag, c))) + .filter_map(|(tag, info)| color::color_for(&info, user_palette).map(|c| (tag, c))) .collect() } @@ -101,9 +190,11 @@ fn recolor_file( text: &str, colors: &HashMap, damage_colors: DamageColors, + user_palette: &UserPalette, ) -> (String, usize) { let mut out = String::with_capacity(text.len()); let mut colored = 0usize; + let mut class_values: Vec<(String, String)> = Vec::new(); for segment in text.split_inclusive('\n') { let (line, eol) = split_eol(segment); if let Some((tag, value)) = line.split_once('=') { @@ -111,7 +202,7 @@ fn recolor_file( if let Some(color) = colors .get(tag) .copied() - .or_else(|| property::color_for(tag, damage_colors)) + .or_else(|| property::color_for(tag, damage_colors, user_palette)) { let mut new_value = apply_color(value, color); // Conversion labels carry no placeholder, so the color would @@ -128,6 +219,39 @@ fn recolor_file( out.push_str(eol); continue; } + if let Some(color) = property::color_other_for(tag, user_palette) { + let new_value = apply_color(value, color); + if new_value != value { + colored += 1; + } + out.push_str(tag); + out.push('='); + out.push_str(&new_value); + out.push_str(eol); + continue; + } + + if let Some((name, class_value)) = property::text_class(tag, value) { + // Capture localized class names and append them to class skill names later. + class_values.push((name, class_value)); + out.push_str(tag); + out.push('='); + out.push_str(value); + out.push_str(eol); + continue; + } + + if let Some(suffix) = property::text_for(tag, &class_values) { + let new_value = format!("{value} {suffix}"); + if new_value != value { + colored += 1; + } + out.push_str(tag); + out.push('='); + out.push_str(&new_value); + out.push_str(eol); + continue; + } } out.push_str(segment); } diff --git a/src/db.rs b/src/db.rs index a1f2bc3..cbc9a8b 100644 --- a/src/db.rs +++ b/src/db.rs @@ -6,6 +6,7 @@ use std::fs::{File, canonicalize}; use std::io::{BufRead, BufReader, Seek}; use std::path::PathBuf; +use blake3::Hasher; use lib_gddb::arz::{Database, RawRecord, Record}; /// Relative paths to the base game + expansion databases, in load order. @@ -42,6 +43,95 @@ pub fn open_all() -> Vec>> { dbs } +/// Computes a combined content hash across all available game database files. +/// +/// The hash is based on bytes from the same ARZ files used for data inference, +/// in load order, and includes each relative path as a separator/input. +pub fn database_hash() -> String { + let base = install_path(); + let mut hasher = Hasher::new(); + let mut saw_any = false; + + for rel in DBS { + let path = base.join(rel); + if !path.exists() { + continue; + } + saw_any = true; + hasher.update(rel.as_bytes()); + hasher.update(&[0]); + + let mut f = File::open(&path).unwrap_or_else(|e| { + eprintln!("Could not open database file {}: {e}", path.display()); + std::process::exit(1); + }); + std::io::copy(&mut f, &mut hasher).unwrap_or_else(|e| { + eprintln!("Could not read database file {}: {e}", path.display()); + std::process::exit(1); + }); + hasher.update(&[0xFF]); + } + + if !saw_any { + eprintln!("Could not read any database files under {}", base.display()); + std::process::exit(1); + } + + hasher.finalize().to_hex().to_string() +} + +/// Best-effort Steam build id lookup for Grim Dawn (App ID 219990). +/// +/// Returns None when the game was not installed via Steam, the manifest file +/// is not reachable from the install path, or the build id key is missing. +pub fn steam_build_id() -> Option { + let install = install_path(); + let mut candidates = Vec::new(); + + // Typical layout: /steamapps/common/Grim Dawn + if let Some(common_dir) = install.parent() { + if common_dir + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case("common")) + { + if let Some(steamapps_dir) = common_dir.parent() { + candidates.push(steamapps_dir.join("appmanifest_219990.acf")); + } + } + } + + // Also try any ancestor that is itself a steamapps directory. + for anc in install.ancestors() { + if anc + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case("steamapps")) + { + candidates.push(anc.join("appmanifest_219990.acf")); + } + } + + for manifest in candidates { + let Ok(text) = std::fs::read_to_string(&manifest) else { + continue; + }; + if let Some(v) = parse_acf_value(&text, "buildid") { + return Some(v.to_string()); + } + } + + None +} + +fn parse_acf_value<'a>(acf: &'a str, key: &str) -> Option<&'a str> { + for line in acf.lines() { + let parts: Vec<_> = line.trim().split('"').collect(); + if parts.len() >= 4 && parts[1] == key { + return Some(parts[3]); + } + } + None +} + /// Resolves every record whose id satisfies `keep`, across all databases. /// /// Filtering on the (cheap) record id before resolving avoids decompressing diff --git a/src/main.rs b/src/main.rs index 27e6956..cc52686 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,9 @@ use std::path::PathBuf; +use std::{fs::OpenOptions, io::Write}; use clap::Parser; +use time::macros::format_description; +use time::{OffsetDateTime, UtcOffset}; mod color; mod colorize; @@ -9,6 +12,7 @@ mod infer; mod keywords; mod palette; mod property; +mod user_palette; use property::DamageColors; @@ -27,11 +31,19 @@ struct Args { /// Paints Pierce red, like rainbow filter, not pink. #[arg(long)] rainbow_filter_damage_colors: bool, + /// Optional palette config file (key=value lines). Defaults to ./gdse-palette.txt if present. + #[arg(long)] + palette_file: Option, } fn main() { let args = Args::parse(); let lang = args.language.to_lowercase(); + let db_hash = db::database_hash(); + let steam_build_id = db::steam_build_id().unwrap_or_else(|| "unknown".to_string()); + let patch_versions = colorize::detect_patch_versions(&lang); + let palette_file = choose_palette_file(args.palette_file.as_deref()); + let palette_load = user_palette::load(palette_file.as_deref()); let mut dbs = db::open_all(); let out = args.out.unwrap_or_else(|| { db::install_path() @@ -43,5 +55,164 @@ fn main() { } else { DamageColors::Default }; - colorize::run(&mut dbs, &out, &lang, damage_colors); + if let Some(source) = &palette_load.source { + println!("Loaded custom palette from {}", source.display()); + } else { + println!("Using default gdse palette."); + } + + let hash_file = out.join("gdse-db-hash.txt"); + let previous = std::fs::read_to_string(&hash_file) + .ok() + .and_then(|log| latest_run_info(&log)); + print_change_summary(previous.as_ref(), &db_hash, &steam_build_id, &patch_versions); + if !confirm_run() { + println!("Canceled by user; no output files were changed."); + return; + } + + colorize::run(&mut dbs, &out, &lang, damage_colors, &palette_load.palette); + + let now = local_timestamp_minute(); + let patch_versions_field = if patch_versions.is_empty() { + "unknown".to_string() + } else { + patch_versions.join(",") + }; + let mut f = OpenOptions::new() + .create(true) + .append(true) + .open(&hash_file) + .unwrap_or_else(|e| { + eprintln!("Could not open {}: {e}", hash_file.display()); + std::process::exit(1); + }); + if let Err(e) = writeln!( + f, + "{now} hash={db_hash} steam_build_id={steam_build_id} patch_versions={patch_versions_field}" + ) { + eprintln!("Could not write {}: {e}", hash_file.display()); + std::process::exit(1); + } +} + +#[derive(Debug)] +struct RunInfo { + hash: String, + steam_build_id: Option, + patch_versions: Option, +} + +fn latest_run_info(log: &str) -> Option { + for line in log.lines().rev() { + let mut hash = None; + let mut steam_build_id = None; + let mut patch_versions = None; + + for token in line.split_whitespace() { + if let Some(v) = token.strip_prefix("hash=") { + hash = Some(v.to_string()); + } else if let Some(v) = token.strip_prefix("steam_build_id=") { + steam_build_id = Some(v.to_string()); + } else if let Some(v) = token.strip_prefix("patch_versions=") { + patch_versions = Some(v.to_string()); + } + } + + if let Some(hash) = hash { + return Some(RunInfo { + hash, + steam_build_id, + patch_versions, + }); + } + + // Backward compatibility with old lines: "YYYY-MM-DD HH:MM " + let tokens: Vec<_> = line.split_whitespace().collect(); + if tokens.len() >= 3 { + return Some(RunInfo { + hash: tokens[2].to_string(), + steam_build_id: None, + patch_versions: None, + }); + } + } + None +} + +fn print_change_summary( + previous: Option<&RunInfo>, + current_hash: &str, + current_build_id: &str, + current_patch_versions: &[String], +) { + let current_patch_field = if current_patch_versions.is_empty() { + "unknown".to_string() + } else { + current_patch_versions.join(",") + }; + + if let Some(prev) = previous { + let hash_changed = prev.hash != current_hash; + let prev_build = prev.steam_build_id.as_deref().unwrap_or("unknown"); + let prev_patch = prev.patch_versions.as_deref().unwrap_or("unknown"); + let build_changed = prev_build != current_build_id; + let patch_changed = prev_patch != current_patch_field; + + if hash_changed || build_changed || patch_changed { + println!("Changes detected since last run."); + } else { + println!("No changes detected since last run."); + } + println!("Steam build id: last={prev_build} current={current_build_id}"); + println!("Patch version label(s): last={prev_patch} current={current_patch_field}"); + } else { + println!("No previous run marker found."); + println!("Steam build id: last=unknown current={current_build_id}"); + println!("Patch version label(s): last=unknown current={current_patch_field}"); + } +} + +fn confirm_run() -> bool { + confirm_yes_no("Proceed with recoloring run? [Y/N]: ") +} + +fn choose_palette_file(cli_palette_file: Option<&std::path::Path>) -> Option { + if let Some(path) = cli_palette_file { + return Some(path.to_path_buf()); + } + + if confirm_yes_no("Use default gdse palette? [Y/N]: ") { + return None; + } + + println!( + "Custom palette selected. Create gdse-palette.txt in this folder, then run gdse again." + ); + std::process::exit(0); +} + +fn confirm_yes_no(prompt: &str) -> bool { + print!("{prompt}"); + if let Err(e) = std::io::stdout().flush() { + eprintln!("Could not flush prompt to stdout: {e}"); + std::process::exit(1); + } + + let mut input = String::new(); + if let Err(e) = std::io::stdin().read_line(&mut input) { + eprintln!("Could not read user input: {e}"); + std::process::exit(1); + } + matches!(input.trim().to_ascii_lowercase().as_str(), "y" | "yes") +} + +fn local_timestamp_minute() -> String { + // Prefer local machine time for user-facing history output. + let now = UtcOffset::current_local_offset() + .map(|offset| OffsetDateTime::now_utc().to_offset(offset)) + .unwrap_or_else(|_| OffsetDateTime::now_utc()); + let fmt = format_description!("[year]-[month]-[day] [hour]:[minute]"); + now.format(&fmt) + .unwrap_or_else(|_| "unknown-time".to_string()) } diff --git a/src/palette.rs b/src/palette.rs index d7ce01a..c2891e0 100644 --- a/src/palette.rs +++ b/src/palette.rs @@ -39,3 +39,7 @@ pub const MAROON: char = 'm'; pub const AQUA: char = 'a'; /// `BD94C6` pub const PURPLE: char = 'p'; +/// Grayish Orange (engine highlight color) +pub const HIGHLIGHT_ORANGE: char = 'h'; +/// `00FFD2` +pub const TEAL: char = 't'; diff --git a/src/property.rs b/src/property.rs index 99d158e..acdcc34 100644 --- a/src/property.rs +++ b/src/property.rs @@ -12,8 +12,10 @@ //! no element token and so are left untouched. use crate::palette::{ - AQUA, COBALT, CYAN, FUSHIA, KHAKI, MAROON, OLIVE, ORANGE, PURPLE, RED, YELLOW, + AQUA, COBALT, CYAN, FUSHIA, HIGHLIGHT_ORANGE, KHAKI, MAROON, OLIVE, ORANGE, + PURPLE, RED, TEAL, YELLOW, }; +use crate::user_palette::UserPalette; /// Structural prefixes that mark a damage / resistance / retaliation / /// conversion stat label. A property tag always starts with one of these. @@ -61,7 +63,7 @@ const TOKENS: [(&str, char); 12] = [ ]; /// The color letter for a damage-type Property tag, or `None` if `tag` isn't one. -pub fn color_for(tag: &str, damage_colors: DamageColors) -> Option { +pub fn color_for(tag: &str, damage_colors: DamageColors, user_palette: &UserPalette) -> Option { if !PREFIXES.iter().any(|p| tag.starts_with(p)) { return None; } @@ -96,7 +98,7 @@ pub fn color_for(tag: &str, damage_colors: DamageColors) -> Option { let color = TOKENS .iter() .find(|(tok, _)| tag.contains(tok)) - .map(|(_, color)| *color)?; + .map(|(tok, color)| user_palette.damage(tok).unwrap_or(*color))?; // Opt back in to Full Rainbow's Red Pierce. Fushia is the only entry gdse // departs on, so folding it back to Red is the whole of the flag. if damage_colors == DamageColors::RainbowFilter && color == FUSHIA { @@ -104,3 +106,85 @@ pub fn color_for(tag: &str, damage_colors: DamageColors) -> Option { } Some(color) } + +// Additional non-damage stat labels that benefit from consistent coloring. +const PREFIXES_OTHER: [&str; 4] = [ + "tagChar", + "tagDamageModifier", + "ItemMasteryIncrement", + "ItemAllSkillIncrement", +]; + +const TOKENS_OTHER: [(&str, &str, Option); 13] = [ + ("tagCharAttribute0", "nondamage.attribute0", Some(HIGHLIGHT_ORANGE)), + ( + "ItemMasteryIncrement", + "nondamage.mastery_increment", + Some(TEAL), + ), + ( + "ItemAllSkillIncrement", + "nondamage.all_skill_increment", + Some(TEAL), + ), + ("tagCharRunSpeed", "nondamage.run_speed", None), + ("tagCharSpellCastSpeed", "nondamage.cast_speed", None), + ("tagCharAttackSpeed", "nondamage.attack_speed", None), + ("tagCharTotalSpeedModifier", "nondamage.total_speed", None), + ( + "tagCharRunSpeedModifier", + "nondamage.run_speed_modifier", + None, + ), + ("tagCharOffensiveAbility", "nondamage.offensive_ability", None), + ("tagCharDefensiveAbility", "nondamage.defensive_ability", None), + ("tagDamageModifierCritDamage", "nondamage.crit_damage", None), + ("tagDamageModifierDamageMult", "nondamage.damage_mult", None), + ("tagDamageModifierTotalDamage", "nondamage.total_damage", None), +]; + +pub fn color_other_for(tag: &str, user_palette: &UserPalette) -> Option { + if !PREFIXES_OTHER.iter().any(|p| tag.starts_with(p)) { + return None; + } + TOKENS_OTHER + .iter() + .find(|(tok, _, _)| tag.contains(tok)) + .and_then(|(_, key, default)| user_palette.non_damage(key).or(*default)) +} + +const CLASSES: [&str; 4] = ["tagClass", "tagGDX1Class", "tagGDX2Class", "tagGDX3Class"]; + +pub fn text_class(tag: &str, value: &str) -> Option<(String, String)> { + match tag { + "tagSkillClassName01" => Some(("Class01Skill".to_string(), value.to_string())), + "tagSkillClassName02" => Some(("Class02Skill".to_string(), value.to_string())), + "tagSkillClassName03" => Some(("Class03Skill".to_string(), value.to_string())), + "tagSkillClassName04" => Some(("Class04Skill".to_string(), value.to_string())), + "tagSkillClassName05" => Some(("Class05Skill".to_string(), value.to_string())), + "tagSkillClassName06" => Some(("Class06Skill".to_string(), value.to_string())), + "tagSkillClassName07" => Some(("Class07Skill".to_string(), value.to_string())), + "tagSkillClassName08" => Some(("Class08Skill".to_string(), value.to_string())), + "tagSkillClassName09" => Some(("Class09Skill".to_string(), value.to_string())), + "tagSkillClassName10" => Some(("Class10Skill".to_string(), value.to_string())), + _ => None, + } +} + +pub fn text_for(tag: &str, class_values: &[(String, String)]) -> Option { + if !CLASSES.iter().any(|p| tag.starts_with(p)) { + return None; + } + if !tag.contains("Name") { + return None; + } + // This contains the class name itself, not a skill. + if tag.contains("SkillName00A") { + return None; + } + let class = class_values + .iter() + .find(|(tok, _)| tag.contains(tok)) + .map(|(_, class)| class)?; + Some(format!("({class})")) +} diff --git a/src/user_palette.rs b/src/user_palette.rs new file mode 100644 index 0000000..87f974e --- /dev/null +++ b/src/user_palette.rs @@ -0,0 +1,180 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::keywords::Rarity; + +#[derive(Debug, Clone)] +pub struct UserPalette { + values: HashMap, +} + +impl UserPalette { + pub fn empty() -> Self { + Self { + values: HashMap::new(), + } + } + + pub fn get(&self, key: &str) -> Option { + self.values.get(key).copied() + } + + pub fn rarity(&self, rarity: Rarity) -> Option { + match rarity { + Rarity::Common => self.get("rarity.common"), + Rarity::Magical => self.get("rarity.magical"), + Rarity::Rare => self.get("rarity.rare"), + Rarity::Epic => self.get("rarity.epic"), + Rarity::Legendary => self.get("rarity.legendary"), + } + } + + pub fn damage(&self, token: &str) -> Option { + match token { + "Physical" => self.get("damage.physical"), + "Pierce" => self.get("damage.pierce"), + "Bleeding" => self.get("damage.bleeding"), + "Fire" => self.get("damage.fire"), + "Cold" => self.get("damage.cold"), + "Lightning" => self.get("damage.lightning"), + "Poison" => self.get("damage.poison"), + "Vitality" => self.get("damage.vitality"), + "Life" => self.get("damage.life"), + "Aether" => self.get("damage.aether"), + "Chaos" => self.get("damage.chaos"), + "Elemental" => self.get("damage.elemental"), + _ => None, + } + } + + pub fn non_damage(&self, key: &str) -> Option { + self.get(key) + } +} + +pub struct PaletteLoad { + pub palette: UserPalette, + pub source: Option, +} + +pub fn load(path_arg: Option<&Path>) -> PaletteLoad { + if let Some(path) = path_arg { + let palette = read_palette_file(path); + return PaletteLoad { + palette, + source: Some(path.to_path_buf()), + }; + } + + let default = Path::new("gdse-palette.txt"); + if default.exists() { + let palette = read_palette_file(default); + PaletteLoad { + palette, + source: Some(default.to_path_buf()), + } + } else { + PaletteLoad { + palette: UserPalette::empty(), + source: None, + } + } +} + +fn read_palette_file(path: &Path) -> UserPalette { + let text = std::fs::read_to_string(path).unwrap_or_else(|e| { + eprintln!("Could not read palette file {}: {e}", path.display()); + std::process::exit(1); + }); + + let mut values = HashMap::new(); + for (line_no, raw) in text.lines().enumerate() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key_raw, val_raw)) = line.split_once('=') else { + eprintln!( + "Invalid palette line {} in {}: expected key=value", + line_no + 1, + path.display() + ); + std::process::exit(1); + }; + + let key = key_raw.trim().to_ascii_lowercase(); + if !is_known_key(&key) { + eprintln!( + "Unknown palette key '{}' on line {} in {}", + key, + line_no + 1, + path.display() + ); + std::process::exit(1); + } + + let value = val_raw.trim(); + let code = parse_color_code(value).unwrap_or_else(|| { + eprintln!( + "Invalid color code '{}' on line {} in {}. Use one letter like w, y, g, f, r.", + value, + line_no + 1, + path.display() + ); + std::process::exit(1); + }); + + values.insert(key, code); + } + + UserPalette { values } +} + +fn parse_color_code(s: &str) -> Option { + let mut chars = s.chars(); + let c = chars.next()?; + if chars.next().is_some() { + return None; + } + if c.is_ascii_alphabetic() { + Some(c.to_ascii_lowercase()) + } else { + None + } +} + +fn is_known_key(key: &str) -> bool { + matches!( + key, + "rarity.common" + | "rarity.magical" + | "rarity.rare" + | "rarity.epic" + | "rarity.legendary" + | "damage.physical" + | "damage.pierce" + | "damage.bleeding" + | "damage.fire" + | "damage.cold" + | "damage.lightning" + | "damage.poison" + | "damage.vitality" + | "damage.life" + | "damage.aether" + | "damage.chaos" + | "damage.elemental" + | "nondamage.attribute0" + | "nondamage.mastery_increment" + | "nondamage.all_skill_increment" + | "nondamage.run_speed" + | "nondamage.cast_speed" + | "nondamage.attack_speed" + | "nondamage.total_speed" + | "nondamage.run_speed_modifier" + | "nondamage.offensive_ability" + | "nondamage.defensive_ability" + | "nondamage.crit_damage" + | "nondamage.damage_mult" + | "nondamage.total_damage" + ) +}