feat: add local achievements tab

This commit is contained in:
mikl 2026-08-10 21:29:42 +03:00
parent b812003807
commit ba129bffd9
8 changed files with 225 additions and 4 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

@ -0,0 +1,36 @@
.achievement-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 16px 0;
}
.achievement-entry {
margin: 8px 0;
padding: 10px 12px;
border: 1px solid rgba(89, 209, 218, 0.18);
background: rgba(0, 0, 0, 0.12);
}
.achievement-status {
display: inline-block;
min-width: 34px;
font-weight: 700;
}
.achievement-status-unlocked {
color: #73e6a4;
}
.achievement-status-missing {
color: #d98a8a;
}
.achievement-points {
float: right;
opacity: 0.75;
}
#tab-achievements h3 {
margin-top: 22px;
}

View file

@ -79,6 +79,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="" 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*"> <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> <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> <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>

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

@ -0,0 +1,178 @@
import aesjs from "./aes-js.js";
const CSHARP_HEADER_LENGTH = 22;
const AES_KEY = new TextEncoder().encode("UKu52ePUBwetZ9wNX88o54dnfKRu0T1l");
// Achievement keys are the game's local keys in shared.dat. Some older builds
// used slightly different internal names, so aliases are kept for reading.
const ACHIEVEMENTS = [
["CHARMED", "Charmed", "Charms", "Acquire your first Charm", 5],
["ENCHANTED", "Enchanted", "Charms", "Acquire half of Hallownest's Charms", 10],
["BLESSED", "Blessed", "Charms", "Acquire all Charms and receive Salubra's blessing", 15],
["PROTECTED", "Protected", "Mask Shards", "Acquire 4 Mask Shards", 5],
["MASKED", "Masked", "Mask Shards", "Acquire all Mask Shards", 15],
["SOULFUL", "Soulful", "Vessel Fragments", "Acquire 3 Vessel Fragments", 5],
["WORLDSOUL", "Worldsoul", "Vessel Fragments", "Acquire all Vessel Fragments", 15],
["FALSEHOOD", "Falsehood", "Bosses", "Defeat the False Knight", 5],
["STRENGTH", "Strength", "Bosses", "Defeat the Failed Champion", 10],
["HORNET_1", "Test of Resolve", "Bosses", "Defeat Hornet in Greenpath", 5],
["HORNET_2", "Proof of Resolve", "Bosses", "Defeat Hornet in Kingdom's Edge", 10],
["SOUL_MASTER_DEFEAT", "Illumination", "Bosses", "Defeat the Soul Master", 5],
["SOUL_TYRANT_DEFEAT", "Mortality", "Bosses", "Defeat the Soul Tyrant", 10],
["BROKEN_VESSEL_DEFEAT", "Release", "Bosses", "Defeat the Broken Vessel", 5],
["LOST_KIN_DEFEAT", "Peace", "Bosses", "Defeat the Lost Kin", 10],
["DUNG_DEFENDER_DEFEAT", "Honour", "Bosses", "Defeat the Dung Defender", 5],
["MANTIS_LORDS_DEFEAT", "Respect", "Bosses", "Defeat the Mantis Lords", 10],
["COLLECTOR_DEFEAT", "Obsession", "Bosses", "Defeat the Collector", 10],
["TRAITOR_LORD_DEFEAT", "Execution", "Bosses", "Defeat the Traitor Lord", 5],
["ZOTE_DEFEAT", "Rivalry", "Bosses", "Defeat Zote in the Colosseum of Fools", 10],
["ATTUNEMENT", "Attunement", "Dream Essence", "Collect 600 Essence", 5],
["AWAKENING", "Awakening", "Dream Essence", "Collect 1800 Essence and awaken the Dream Nail", 10],
["ASCENSION", "Ascension", "Dream Essence", "Collect 2400 Essence and hear the Seer's final words", 15],
["GRUBS_HALF", "Grubfriend", "Grubs", "Rescue half of the imprisoned grubs", 5],
["GRUBS_ALL", "Metamorphosis", "Grubs", "Rescue all of the imprisoned grubs", 15],
["STAG_STATION_HALF", "Connection", "Stag Stations", "Open half of Hallownest's Stag Stations", 5],
["STAG_STATION_ALL", "Hope", "Stag Stations", "Open all of Hallownest's Stag Stations and discover the Stag Nest", 15],
["ZOTE_DEAD", "Neglect", "Miscellaneous", "Leave Zote to die", 5],
["QUIRELL_FINAL", "Witness", "Miscellaneous", "Spend a final moment with Quirrel", 10],
["NAILSMITH_DEFEAT", "Purity", "Miscellaneous", "Slay the Nailsmith with the Pure Nail", 10],
["NAILSMITH_SPARED", "Happy Couple", "Miscellaneous", "Allow the Nailsmith to find a new calling", 10],
["GREY_MOURNER", "Solace", "Miscellaneous", "Bring peace to the Grey Mourner", 5],
["VOID_HEART", "Void", "Miscellaneous", "Remember the past and unite the Abyss", 30],
["TEACHER", "Teacher", "Miscellaneous", "Destroy Monomon the Teacher", 5],
["WATCHER", "Watcher", "Miscellaneous", "Destroy Lurien the Watcher", 5],
["BEAST", "Beast", "Miscellaneous", "Destroy Herrah the Beast", 5],
["MAPS", "Cartographer", "Miscellaneous", "Acquire a map of each area", 15],
["COMPLETION", "Completion", "Challenges", "Achieve 100% game completion and finish the game", 50],
["SPEED_COMPLETION", "Speed Completion", "Challenges", "Achieve 100% game completion and finish the game in under 20 hours", 50],
["JOURNAL_FULL", "Keen Hunter", "Challenges", "Record all of Hallownest's creatures in the Hunter's Journal", 10],
["HUNTER_MARK", "True Hunter", "Challenges", "Receive the Hunter's Mark", 15],
["STEEL_SOUL", "Steel Soul", "Challenges", "Finish the game in Steel Soul mode", 50],
["STEEL_SOUL_FULL", "Steel Heart", "Challenges", "Achieve 100% game completion and finish the game in Steel Soul mode", 50],
["SPEEDRUN_1", "Speedrun 1", "Challenges", "Complete the game in under 10 hours", 30],
["SPEEDRUN_2", "Speedrun 2", "Challenges", "Complete the game in under 5 hours", 50],
["TRIAL_1", "Warrior", "Colosseum", "Complete the Trial of the Warrior", 5],
["TRIAL_2", "Conqueror", "Colosseum", "Complete the Trial of the Conqueror", 15],
["TRIAL_3", "Fool", "Colosseum", "Complete the Trial of the Fool", 20],
["HOLLOW_KNIGHT", "The Hollow Knight", "Endings", "Defeat the Hollow Knight and become the Vessel", 30],
["SEALED_SIBLINGS", "Sealed Siblings", "Endings", "Defeat the Hollow Knight with Hornet by your side", 30],
["RADIANCE", "Dream No More", "Endings", "Defeat the Radiance and consume the light", 50],
["PASSING_OF_THE_AGE", "Passing of the Age", "Endings", "Aid the Herald in moving on", 15],
["WHITE_DEFENDER", "Memory", "Hidden Dreams", "Defeat White Defender", 10],
["GREY_PRINCE", "Dark Romance", "Hidden Dreams", "Defeat Grey Prince Zote", 10],
["GRIMM", "Grand Performance", "Grimm Troupe", "Defeat Troupe Leader Grimm", 10],
["NIGHTMARE_GRIMM", "Ritual", "Grimm Troupe", "Defeat the Nightmare King and complete the Ritual", 10],
["BANISHMENT", "Banishment", "Grimm Troupe", "Banish the Grimm Troupe from Hallownest", 10],
["PANTHEON_1", "Brotherhood", "Godmaster", "Complete the Pantheon of the Master", 10],
["PANTHEON_2", "Inspiration", "Godmaster", "Complete the Pantheon of the Artist", 10],
["PANTHEON_3", "Focus", "Godmaster", "Complete the Pantheon of the Sage", 10],
["PANTHEON_4", "Soul & Shade", "Godmaster", "Complete the Pantheon of the Knight", 10],
["PANTHEON_5", "Embrace the Void", "Godmaster", "Ascend the Pantheon of Hallownest and take your place at its peak", 50],
["PURE_COMPLETION", "Pure Completion", "Godmaster", "Achieve 112% game completion and finish the game", 70],
].map(([key, name, category, description, points]) => ({ key, name, category, description, points }));
const KEY_ALIASES = {
SOUL_TYRANT_DEFEAT: ["MAGE_LORD_DEFEAT", "SOUL_TYRANT_DEFEAT"],
BROKEN_VESSEL_DEFEAT: ["INFECTED_KNIGHT_DEFEAT", "BROKEN_VESSEL_DEFEAT"],
LOST_KIN_DEFEAT: ["INFECTED_KIN_DEFEAT", "LOST_KIN_DEFEAT"],
STAG_STATION_ALL: ["STAG_STATION_FULL", "STAG_STATION_ALL"],
MAPS: ["MAPS", "AREA_MAPS"],
JOURNAL_FULL: ["JOURNAL_FULL", "HUNTER_MARK"],
HUNTER_MARK: ["HUNTER_MARK", "TRUE_HUNTER"],
PASSING_OF_THE_AGE: ["PASSING_OF_THE_AGE", "MUSHROOM"],
PURE_COMPLETION: ["PURE_COMPLETION", "COMPLETION_112"],
};
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 isUnlocked(achievement) {
const keys = KEY_ALIASES[achievement.key] || [achievement.key];
return keys.some((key) => sharedState.get(key) === true);
}
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(isUnlocked);
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.</p>";
html += "<div class='achievement-filters'><button type='button' class='button achievement-filter tab-active' data-filter='all'>All</button><button type='button' class='button achievement-filter' data-filter='missing'>Missing</button><button type='button' class='button achievement-filter' data-filter='unlocked'>Unlocked</button></div>";
for (const [category, entries] of byCategory) {
html += `<h3>${category}</h3>`;
for (const achievement of entries) {
const unlockedEntry = isUnlocked(achievement);
const state = unlockedEntry ? "unlocked" : "missing";
const icon = unlockedEntry ? "<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("DOMContentLoaded", achievementMarkup);
document.addEventListener("click", (event) => {
if (event.target.closest("#button-switch-achievements")) achievementMarkup();
});
export { loadSharedFile, achievementMarkup };

View file

@ -6,6 +6,7 @@ require("../css/fontello.css");
require("../css/simpleicon.css"); require("../css/simpleicon.css");
require("../css/reznoricon.css"); require("../css/reznoricon.css");
require("../css/style.css"); require("../css/style.css");
require("../css/achievements.css");
// required for meta tags (social) to be visible by Webpack // required for meta tags (social) to be visible by Webpack
require("../img/thumbnail1200x628.jpg"); require("../img/thumbnail1200x628.jpg");
@ -23,3 +24,4 @@ require("./HKCheckCompletion.js");
// Load Save File for opening files, decoding, decryption // Load Save File for opening files, decoding, decryption
require("./LoadSaveFile.js"); require("./LoadSaveFile.js");
require("./achievements.js");

View file

@ -187,6 +187,7 @@ function GenerateInnerHTML(db) {
`<button id="button-switch-essentials" name="essentials" class="button tab-switch" type="button">Essentials %</button>`, `<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-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-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-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-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>`, `<button id="button-switch-godhome" name="godhome" class="button tab-switch" type="button">Godmaster</button>`,
@ -645,6 +646,8 @@ function GenerateInnerHTML(db) {
console.log(finalHTMLFill); console.log(finalHTMLFill);
console.groupEnd(); */ console.groupEnd(); */
finalHTMLFill += `<div id="tab-achievements" class="large-section hidden"></div>`;
/* ################################## Horizontal line ############################################################################# */ /* ################################## Horizontal line ############################################################################# */
finalHTMLFill += FLEUR_DIVIDE; finalHTMLFill += FLEUR_DIVIDE;