feat: add local achievements tab

This commit is contained in:
mikl 2026-08-10 21:29:56 +03:00
commit c4f18d7485
8 changed files with 223 additions and 4 deletions

52
src/css/achievements.css Normal file
View file

@ -0,0 +1,52 @@
.achievement-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 16px 0;
}
.achievement-entry {
margin: 0.55rem 0;
padding: 0.75rem 0.9rem;
border: 1px solid rgba(89, 209, 218, 0.18);
border-radius: 3px;
background: rgba(0, 0, 0, 0.12);
line-height: 1.35;
}
.achievement-status {
display: inline-block;
width: 1.15rem;
margin-right: 0.2rem;
font-weight: 700;
vertical-align: -0.08rem;
}
.achievement-status-unlocked {
color: #73e6a4;
}
.achievement-status-missing {
color: #d98a8a;
}
.achievement-points {
float: right;
opacity: 0.75;
}
#tab-achievements h3 {
margin: 1.45rem 0 0.55rem;
padding-left: 0.25rem;
color: rgba(231, 251, 255, 0.72);
font-size: 1.15rem;
font-weight: 500;
}
#tab-achievements .code-little {
opacity: 0.55;
}
#tab-achievements .achievement-points {
color: rgba(231, 251, 255, 0.65);
}

View file

@ -81,6 +81,8 @@
<!-- <input id="save-area-file" class="" type="file" name="file" accept=".dat,.bak*" onchange="LoadSaveFile(this);" onclick="this.value = '';"> -->
<input id="save-area-file" class="input-file" type="file" name="file" accept=".dat,.bak*">
<label for="save-area-file" id="file-input-label" class="button button-large" title="Navigate to Hollow Knight's Save File Location and select a user*.dat File."><i class="icon-doc-text-inv"></i>Choose Save File...</label>
<input id="shared-area-file" class="input-file" type="file" name="shared-file" accept=".dat">
<label for="shared-area-file" id="shared-file-input-label" class="button button-large" title="Load shared.dat to show local achievements."><i class="icon-ok-squared"></i>Choose shared.dat...</label>
<button id="save-area-read" class="button button-large hidden" type="button" title="Press this button after pasting your Save Text from the Save Editor into the text box below.">Analyze Save Text</button>

154
src/js/achievements.js Normal file
View file

@ -0,0 +1,154 @@
import aesjs from "./aes-js.js";
const CSHARP_HEADER_LENGTH = 22;
const AES_KEY = new TextEncoder().encode("UKu52ePUBwetZ9wNX88o54dnfKRu0T1l");
const ACHIEVEMENTS = [
["FIRST_TOOL", "Equipped", "Tools", "Acquire your first Tool", 5],
["ALL_TOOLS", "Arsenal", "Tools", "Acquire all Tools", 15],
["FIRST_SILK_SKILL", "Bound", "Silk Skills", "Bind your first Silk Skill", 5],
["ALL_SILK_SKILLS", "Woven", "Silk Skills", "Bind all Silk Skills", 15],
["FIRST_CREST", "Claimed", "Crests", "Claim your first Crest", 5],
["ALL_CRESTS", "Consumed", "Crests", "Claim all Crests", 15],
["FIRST_MASK", "Protected", "Mask Shards", "Acquire 4 Mask Shards", 5],
["ALL_MASKS", "Masked", "Mask Shards", "Acquire all Mask Shards", 15],
["FIRST_SILK_SPOOL", "Restored", "Spool Fragments", "Acquire 2 Spool Fragments", 5],
["ALL_SILK_SPOOLS", "Extended", "Spool Fragments", "Acquire all Spool Fragments", 15],
["ALL_SILK_HEARTS", "Regenerated", "Silk Hearts", "Acquire all Silk Hearts", 15],
["ALL_MAPS", "Cartographer", "Exploration", "Acquire a map of each area", 15],
["BELLWAYS_FULL", "Connected", "Exploration", "Open all of Pharloom's Bellways", 15],
["BELLWAY_MELODY", "Bonded", "Quests", "Learn the Beastling Call", 15],
["TUBES_FULL", "Transported", "Exploration", "Open all of the Citadel's Ventrica Stations", 15],
["JOURNAL_HALF", "Keen Hunter", "Hunter", "Grant Nuu's wish", 10],
["JOURNAL_FULL", "True Hunter", "Hunter", "Receive the Hunter's Memento", 15],
["FLEAS_HALF", "Flea Finder", "Fleas", "Rescue half of Pharloom's lost fleas", 10],
["FLEAS_ALL", "Fleafriend", "Fleas", "Rescue all of Pharloom's lost fleas and receive their final gift", 15],
["DEFEATED_BELLBEAST", "Liberated", "Bosses", "Defeat the Bell Beast", 5],
["DEFEATED_LACE_1", "Pharloom's Welcome", "Bosses", "Defeat Lace in Deep Docks", 15],
["DEFEATED_SONG_GOLEM", "Servant", "Bosses", "Defeat Fourth Chorus", 15],
["DEFEATED_WIDOW", "Fanatic", "Bosses", "Defeat Widow", 15],
["DEFEATED_LAST_JUDGE_1", "Judge", "Bosses", "Defeat the Last Judge", 15],
["DEFEATED_COGWORK_DANCERS", "Last Dance", "Bosses", "Defeat the Cogwork Dancers", 15],
["DEFEATED_TROBBIO", "Tragedian", "Bosses", "Defeat Trobbio", 15],
["DEFEATED_LACE_2", "White Knight", "Bosses", "Defeat Lace in the Cradle", 15],
["DEFEATED_PHANTOM_1", "Grey Ghost", "Bosses", "Defeat Phantom", 15],
["DEFEATED_FIRST_SINNER", "Heretic", "Bosses", "Defeat First Sinner", 15],
["DEFEATED_CORAL_KING", "Tyrant", "Bosses", "Defeat Crust King Khann", 15],
["DEFEATED_FLOWER_QUEEN", "Seed", "Bosses", "Defeat Nyleth", 15],
["DEFEATED_HUNTER_QUEEN", "Diva", "Bosses", "Defeat Skarrsinger Karmelita", 15],
["DEFEATED_GREEN_PRINCE", "Lamenter", "Bosses", "Defeat the Clover Dancers", 15],
["FIRST_WISH", "Granted", "Quests", "Grant your first wish", 5],
["GOURMAND_WISH", "Glutton", "Quests", "Satiate the Grand Gourmand", 15],
["SHAKRA_FINAL_QUEST", "Trail's End", "Quests", "Grant Shakra's wish", 30],
["GARMOND_FINAL_QUEST", "Hero's Call", "Quests", "Defeat Lost Garmond", 30],
["PINSTRESS_WISH", "Fatal Resolve", "Quests", "Defeat Pinstress", 30],
["EVA_FINAL_QUEST", "Entwined", "Quests", "Bind Eva", 30],
["BELLHOME", "Resident", "Quests", "Acquire your own Bellhome", 5],
["CITADEL_SONG", "Harmonious", "Quests", "Learn the Citadel's Threefold song", 15],
["WHITE_FLOWER_GAINED", "Remembrance", "Quests", "Claim the Everbloom from within a distant memory", 15],
["ENDING_A", "Weaver Queen", "Endings", "Defeat Grand Mother Silk and bind her power", 15],
["ENDING_C", "Snared Silk", "Endings", "Defeat Grand Mother Silk and entrap her with the Soul Snare", 15],
["ENDING_D", "Twisted Child", "Endings", "Defeat Grand Mother Silk while cursed", 15],
["ENDING_E", "Sister of the Void", "Endings", "Defeat Lost Lace and free Pharloom", 15],
["HERALD_WISH", "Passing of the Age", "Endings", "Grant the Herald's wish and finish the game", 30],
["COMPLETION", "Completion", "Challenges", "Achieve 100% game completion and finish the game", 50],
["SPEEDRUN_1", "Speedrunner", "Challenges", "Complete the game in under 5 hours", 50],
["SPEED_COMPLETION", "Speed Completion", "Challenges", "Achieve 100% game completion and finish the game in under 30 hours", 70],
["STEEL_SOUL", "Steel Soul", "Challenges", "Finish the game in Steel Soul mode", 70],
["STEEL_SOUL_FULL", "Steel Heart", "Challenges", "Achieve 100% game completion and finish the game in Steel Soul mode", 70],
].map(([key, name, category, description, points]) => ({ key, name, category, description, points }));
let sharedState = null;
function decodeSharedFile(file) {
return file.arrayBuffer().then((arrayBuffer) => {
let bytes = new Uint8Array(arrayBuffer);
bytes = bytes.subarray(CSHARP_HEADER_LENGTH, bytes.length - 1);
let lengthCount = 0;
for (let i = 0; i < 5; i += 1) {
lengthCount += 1;
if ((bytes[i] & 0x80) === 0) break;
}
const base64 = new TextDecoder().decode(bytes.subarray(lengthCount));
const encrypted = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
const decrypted = new aesjs.ModeOfOperation.ecb(AES_KEY).decrypt(encrypted);
const padding = decrypted[decrypted.length - 1];
return JSON.parse(new TextDecoder().decode(decrypted.subarray(0, -padding)));
});
}
function pairsToMap(decoded) {
const map = new Map();
for (const pair of decoded?.pairs || []) {
map.set(pair.Key, String(pair.Value) === "1");
}
return map;
}
function achievementMarkup() {
const panel = document.getElementById("tab-achievements");
if (!panel) return;
if (!sharedState) {
panel.innerHTML = "<h2>Achievements</h2><p class='section-description'>Choose shared.dat to read the achievements shared by all save profiles.</p>";
return;
}
const unlocked = ACHIEVEMENTS.filter((achievement) => sharedState.get(achievement.key) === true);
const byCategory = new Map();
for (const achievement of ACHIEVEMENTS) {
if (!byCategory.has(achievement.category)) byCategory.set(achievement.category, []);
byCategory.get(achievement.category).push(achievement);
}
let html = `<h2>Achievements: ${unlocked.length} / ${ACHIEVEMENTS.length}</h2>`;
html += "<p class='section-description'>Local achievement state read from shared.dat. This is the game's own state, not a platform account status.</p>";
html += "<div class='achievement-filters'><button type='button' class='button tab-switch achievement-filter tab-active' data-filter='all'>All</button><button type='button' class='button tab-switch achievement-filter' data-filter='missing'>Missing</button><button type='button' class='button tab-switch achievement-filter' data-filter='unlocked'>Unlocked</button></div>";
for (const [category, entries] of byCategory) {
html += `<h3>${category}</h3>`;
for (const achievement of entries) {
const isUnlocked = sharedState.get(achievement.key) === true;
const state = isUnlocked ? "unlocked" : "missing";
const icon = isUnlocked ? "<i class='icon-ok-squared achievement-status achievement-status-unlocked'></i>" : "<i class='icon-cancel achievement-status achievement-status-missing'></i>";
html += `<div class='single-entry achievement-entry' data-achievement-state='${state}'>${icon} <strong>${achievement.name}</strong> <span class='achievement-points'>${achievement.points}G</span><br><span class='p-left-small'></span>${achievement.description}<br><span class='code-little'>${achievement.key}</span></div>`;
}
}
panel.innerHTML = html;
panel.querySelectorAll(".achievement-filter").forEach((button) => {
button.addEventListener("click", () => {
panel.querySelectorAll(".achievement-filter").forEach((item) => item.classList.remove("tab-active"));
button.classList.add("tab-active");
const filter = button.dataset.filter;
panel.querySelectorAll(".achievement-entry").forEach((entry) => {
entry.classList.toggle("hidden", filter !== "all" && entry.dataset.achievementState !== filter);
});
});
});
}
function loadSharedFile(file) {
decodeSharedFile(file).then((decoded) => {
sharedState = pairsToMap(decoded);
achievementMarkup();
const label = document.getElementById("shared-file-input-label");
if (label) label.textContent = `${file.name} loaded`;
}).catch((error) => {
sharedState = null;
achievementMarkup();
alert(`This does not seem to be a valid shared.dat file. ${error}`);
});
}
document.getElementById("shared-area-file")?.addEventListener("change", (event) => {
if (event.target.files?.[0]) loadSharedFile(event.target.files[0]);
});
document.addEventListener("click", (event) => {
if (event.target.closest("#button-switch-achievements")) achievementMarkup();
});
document.addEventListener("DOMContentLoaded", achievementMarkup);
export { loadSharedFile, achievementMarkup };

View file

@ -6,6 +6,7 @@ require("../css/fontello.css");
require("../css/simpleicon.css");
require("../css/reznoricon.css");
require("../css/style.css");
require("../css/achievements.css");
// required for meta tags (social) to be visible by Webpack
require("../img/thumbnail1200x628.jpg");
@ -22,4 +23,7 @@ require("./page-functions.js");
require("./HKCheckCompletion.js");
// Load Save File for opening files, decoding, decryption
require("./LoadSaveFile.js");
require("./LoadSaveFile.js");
// Local achievement/Feat state from shared.dat.
require("./achievements.js");

View file

@ -212,6 +212,7 @@ function GenerateInnerHTML(db) {
`<button id="button-switch-essentials" name="essentials" class="button tab-switch" type="button">Essentials %</button>`,
`<button id="button-switch-journal" name="journal" class="button tab-switch" type="button">Journal</button>`,
`<button id="button-switch-collectibles" name="collectibles" class="button tab-switch" type="button">Collectibles</button>`,
`<button id="button-switch-achievements" name="achievements" class="button tab-switch" type="button">Achievements</button>`,
//`<button id="button-switch-geocaches" name="geocaches" class="button tab-switch" type="button">Geo Caches</button>`,
//`<button id="button-switch-secrets" name="secrets" class="button tab-switch" type="button">Secrets</button>`,
//`<button id="button-switch-godhome" name="godhome" class="button tab-switch" type="button">Godmaster</button>`,
@ -679,6 +680,7 @@ function GenerateInnerHTML(db) {
case "huntersJournal": // Journal
case "flea": // Collectibles
case "essentialsKeys": // Essentials %
case "achievements": // Achievements
/*case "toolPouchUpgrades": // Main %
case "achievementsBosses": // Essentials %
@ -701,6 +703,11 @@ function GenerateInnerHTML(db) {
console.log(finalHTMLFill);
console.groupEnd(); */
/* ################################## Achievements tab ########################################################################### */
// Achievement definitions are loaded separately from shared.dat.
finalHTMLFill += `<div id="tab-achievements" class="large-section hidden"></div>`;
/* ################################## Horizontal line ############################################################################# */
finalHTMLFill += FLEUR_DIVIDE;