Files

201 lines
10 KiB
Python

"""Add an About and controls note through Steam's own live Game Notes API.
Steam Notes are private notes on the game details page. They do not replace
Steam's non-Steam description or create store metadata, achievements, or an
official Steam application. No Steam files or sessions are changed here.
"""
from __future__ import annotations
from steam_live import Client, GAME, HOME, NAME, expression, native_spec
NOTE_ID = "haloframeaboutv1"
NOTE_TITLE = "Halo CE VR — About & controls"
NOTE_CONTENT = """[h1]Halo: Combat Evolved VR (Native)[/h1]
Play the original 2001 Halo: Combat Evolved campaign as Master Chief, fighting
the Covenant and uncovering the secrets of a mysterious ringworld.
This independent community installation uses your original Xbox game data
with an experimental native ARM64 Linux/OpenXR VR port on Steam Frame.
It is unaffiliated with Microsoft, Bungie, or Valve. VR support and performance
vary by scene, headset software, and settings.
[h2]Xbox-style Frame controls[/h2]
Point the right controller to aim; move your head to look around.
Left stick: move. Left stick click: crouch.
Right stick left/right: turn. Right stick click: zoom.
Right trigger: fire. Left trigger: throw grenade.
A: jump; accept menu selections. B: melee; go back in menus.
X: reload/use; hold for context actions such as exchanging a weapon.
Y: change weapon. Left bumper: change grenade type.
Right bumper: toggle flashlight. Left controller d-pad: navigate menus.
View: scoreboard/back. Menu: pause.
Both grips held: recenter the headset view; release a held foregrip first.
Left grip near a long gun's foregrip: aim the gun with both hands.
These controls assume the game's default profile button layout.
VR options are available in Settings > VR Setup.
[h2]Multiplayer[/h2]
LAN and internet play require compatible native/OpenCE protocol builds.
Halo PC, Custom Edition, MCC, and retail Xbox server compatibility is not
provided by this installer.
[h2]Sources and setup help[/h2]
[url=https://github.com/ClassicWasTaken/HaloCE-SteamFrame]Installer, controls, and troubleshooting[/url]
[url=https://github.com/startupfoundry/halo-ce-universal/tree/88142798513ebd99fc7c6224023e8b44c05d0106]Pinned native port source[/url]
"""
MAX_DOCUMENT_BYTES = 1024 * 1024
MAX_NOTES = 512
NOTES = r"""(async () => {
const deadline = Date.now() + 23000;
const apps = typeof SteamClient !== 'undefined' && SteamClient.Apps;
const notes = typeof SteamClient !== 'undefined' && SteamClient.GameNotes;
const store = typeof appStore !== 'undefined' && appStore.m_mapApps;
const user = typeof loginStore !== 'undefined' && loginStore.currentUser;
const manual = message => ({ok:false,status:'manual',message});
if (!apps || typeof apps.RegisterForAppDetails !== 'function' || !notes ||
['SyncToClient','GetNotes','SaveNotes','SyncToServer'].some(k => typeof notes[k] !== 'function') ||
!store || typeof store.values !== 'function' || typeof store.get !== 'function' ||
!user || typeof user.accountName !== 'string' || !user.accountName ||
typeof TextEncoder !== 'function')
return manual('Steam Game Notes is unavailable. Add the supplied text as a note manually.');
const accountName = user.accountName;
async function bounded(value, limit=3500) {
let timer;
try {
return await Promise.race([Promise.resolve(value),new Promise((_,reject) => {
timer=setTimeout(() => reject(new Error('Steam Game Notes timed out')),
Math.max(1,Math.min(limit,deadline-Date.now())));
})]);
} finally { clearTimeout(timer); }
}
async function identity() {
if (Date.now() >= deadline) throw new Error('Steam Game Notes timed out');
if (loginStore.currentUser?.accountName !== accountName)
throw new Error('The active Steam account changed');
let count=0;
for (const entry of store.values()) {
if (++count > 100000) throw new Error('Steam library exceeds supported bounds');
if (entry.appid !== S.appid && entry.display_name === S.name)
throw new Error('Another game uses the native Halo shortcut name');
}
const overview=store.get(S.appid);
if (!overview || overview.app_type !== 1073741824 || overview.display_name !== S.name)
throw new Error('The native Halo shortcut identity changed');
await new Promise((resolve,reject) => {
let subscription,done=false;
const cleanup=() => queueMicrotask(() => subscription?.unregister());
const timer=setTimeout(() => {done=true;cleanup();
reject(new Error('Native Halo notes identity timed out'));},
Math.max(1,Math.min(3500,deadline-Date.now())));
try {
subscription=apps.RegisterForAppDetails(S.appid,data => {
if (done) return;
done=true;clearTimeout(timer);cleanup();
if (data.unAppID !== S.appid ||
![S.exe,'"'+S.exe+'"'].includes(data.strShortcutExe) ||
loginStore.currentUser?.accountName !== accountName)
reject(new Error('Native Halo notes account or executable changed'));
else resolve();
});
} catch(error) {done=true;clearTimeout(timer);cleanup();reject(error);}
});
}
function parse(response, missingAllowed=false) {
if (!response || typeof response !== 'object')
throw new Error('Steam returned an invalid notes response');
if (response.result === 9 && missingAllowed)
return {notes:[],shortcut_name:S.name};
if (response.result !== 1 || typeof response.notes !== 'string' ||
new TextEncoder().encode(response.notes).length > S.maxBytes)
throw new Error('Steam notes could not be read within supported bounds');
const doc=JSON.parse(response.notes);
if (!doc || typeof doc !== 'object' || Array.isArray(doc) ||
doc.shortcut_name !== S.name || !Array.isArray(doc.notes) ||
doc.notes.length > S.maxNotes ||
doc.notes.some(note => !note || typeof note !== 'object' || Array.isArray(note)))
throw new Error('Steam notes belong to another shortcut or have an unsupported format');
return doc;
}
function encoded(doc) {
const text=JSON.stringify(doc);
if (new TextEncoder().encode(text).length > S.maxBytes)
throw new Error('Steam notes exceed supported bounds');
return text;
}
// These name and filename rules match Steam's own GameNotesCloudStore.
const filename='notes_shortcut_'+S.name.trim().replace(/[!-/:-@ [\\\]^`]/g,'_');
const images=filename+'_images/';
try {
await identity();
if (await bounded(notes.SyncToClient(),7000) !== 1)
throw new Error('Steam notes cloud synchronization is unavailable');
await identity();
const first=await bounded(notes.GetNotes(filename,images));
const doc=parse(first,true);
const matches=doc.notes.filter(note => note.id === S.noteId);
if (matches.length > 1) throw new Error('The Halo notes identifier is ambiguous');
if (matches.length) {
await identity();
if (matches[0].title !== S.title || matches[0].content !== S.content ||
matches[0].shortcut_name !== S.name)
return {ok:true,status:'custom',message:'Your edited Halo note was kept unchanged.'};
return {ok:true,status:'existing',message:'The Halo About and controls note is already present.'};
}
if (doc.notes.length >= S.maxNotes) throw new Error('Steam notes exceed supported bounds');
const timestamp=Math.floor(Date.now()/1000);
const note={id:S.noteId,shortcut_name:S.name,ordinal:0,time_created:timestamp,
time_modified:timestamp,title:S.title,content:S.content};
const updated={...doc,notes:[...doc.notes,note]};
const text=encoded(updated);
// Do not overwrite edits made while the installer was preparing the note.
const reread=await bounded(notes.GetNotes(filename,images));
if (encoded(parse(reread,true)) !== encoded(doc))
throw new Error('Steam notes changed while setup was preparing the Halo note');
await identity();
if (await bounded(notes.SaveNotes(filename,text),7000) !== 1)
throw new Error('Steam could not save the Halo note');
await identity();
if (await bounded(notes.SyncToServer(),7000) !== 1)
throw new Error('The Halo note was saved locally but cloud synchronization is pending');
await identity();
const readback=parse(await bounded(notes.GetNotes(filename,images)));
if (encoded(readback) !== text)
throw new Error('The Halo note could not be verified after saving');
await identity();
return {ok:true,status:'added',message:'Added the Halo About and controls note to Steam Game Notes.'};
} catch(error) {
return manual('Steam could not verify the Halo note. Your other notes were preserved; add the supplied text manually.');
}
})()"""
def add_notes(home, game, appid, name=NAME):
"""Return independent notes status without interrupting game registration."""
result = {"ok": False, "status": "manual",
"message": "Steam Game Notes is unavailable. Add the supplied text as a note manually.",
"title": NOTE_TITLE, "content": NOTE_CONTENT}
try:
spec = native_spec(home, game, name)
if type(appid) is not int or not 2 ** 31 <= appid < 2 ** 32:
return result
spec.update({"appid": appid, "noteId": NOTE_ID, "title": NOTE_TITLE,
"content": NOTE_CONTENT, "maxBytes": MAX_DOCUMENT_BYTES,
"maxNotes": MAX_NOTES})
with Client() as client:
value = client.evaluate(expression(NOTES, spec))
status = value.get("status")
if status in ("added", "existing", "custom") and value.get("ok") is True:
result.update({"ok": True, "status": status,
"message": {"added": "Added the Halo About and controls note to Steam Game Notes.",
"existing": "The Halo About and controls note is already present.",
"custom": "Your edited Halo note was kept unchanged."}[status]})
elif status == "manual":
result["message"] = "Steam could not verify the Halo note. Add the supplied text to Steam Game Notes manually."
except Exception:
# Notes are optional enrichment: failure never controls Steam, edits its
# files, or changes a successfully installed game's registration.
pass
return result