Skip to content
Merged
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
58 changes: 47 additions & 11 deletions dylib/feats/webpatch.c
Original file line number Diff line number Diff line change
Expand Up @@ -1038,7 +1038,7 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) {
"as=w=>/^[A-Za-z_]\\w*=/.test(o.slice(w.start,w.end))," \
"b=G.length>1&&ev(G[0])&&(as(G[1])||m(G[1]))?1:0," \
"n=G.slice(b).findIndex(w=>m(w)||!as(w));" \
"return{p:G[b].start,e:G.slice(b,n<0?G.length:b+n),ev:1===b}})(),"
"return{p:G[b].start,e:G.slice(b,n<0?G.length:b+n),ev:1===b,c:G.find(m)}})(),"

// Rewrites KEY=value entries before %command% and leaves the rest of the text byte for byte.
#define NP_CX_LAUNCH \
Expand All @@ -1055,7 +1055,24 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) {
"E.e.forEach(w=>{if(ps.some(p=>o.startsWith(p[0]+\"=\",w.start))){" \
"r+=o.slice(at,w.start);at=w.end;while(o[at]===\" \"||o[at]===\"\\t\")at++}});r+=o.slice(at)}" \
"SteamClient.Apps.SetAppLaunchOptions(id,r.trim()===\"%command%\"?\"\":r)};" \
"return{g,s,k:E.e.map(w=>o.slice(w.start,w.end).split(\"=\")[0])}}"
"return{g,s,k:E.e.map(w=>o.slice(w.start,w.end).split(\"=\")[0]),c:E.c}}"

// The chosen program replaces the argument after waitforexitandrun and starts in its own folder, which Mod Engine 2's .bat needs. Other verbs run unchanged.
#define NP_CX_TARGET \
"(o,id,home)=>{" \
"const q=v=>\"'\"+v.replace(/'/g,\"'\\\\''\").replace(/%(?=command%)/gi,\"%'\\\"\\\"'\")+\"'\"," \
"h=\"sh -c \"+q('exe=$1 dir=$2 found= prev=; shift 2; for arg in \"$@\"; do shift; '+" \
"'if [ -z \"$found\" ] && [ \"$prev\" = waitforexitandrun ]; then arg=$exe found=1; fi; prev=$arg; set -- \"$@\" \"$arg\"; done; '+" \
"'if [ \"$found\" ]; then cd \"${dir:-${exe%/*}/}\" || exit; fi; exec \"$@\"')+\" sh \"," \
"at=o.indexOf(h)," \
"m=at<0?null:/^('(?:[^']|'\\\\''|'\"\"')*') ('(?:[^']|'\\\\''|'\"\"')*') %command%/.exec(o.slice(at+h.length))," \
"rd=v=>v.slice(1,-1).replace(/'\\\\''|'\"\"'/g,x=>x[1]===\"\\\\\"?\"'\":\"\")," \
"b=m?o.slice(0,at)+\"%command%\"+o.slice(at+h.length+m[0].length):o;" \
"return{home,ex:m?rd(m[1]):\"\",sd:m?rd(m[2]):\"\",write:(e,d)=>{" \
"const B=/%command%/i.test(b)?b:\"%command%\"+(b.trim()?\" \"+b:\"\"),c=MSCXLaunch(B,id).c;" \
"if(!c||!/^%command%$/i.test(B.slice(c.start,c.end)))return;" \
"const r=B.slice(0,c.start)+(e?h+q(e)+\" \"+q(d)+\" %command%\":\"%command%\")+B.slice(c.end);" \
"SteamClient.Apps.SetAppLaunchOptions(id,r.trim()===\"%command%\"?\"\":r)}}}"

#define NP_CX_PICK \
"(t,n)=>{const o=t.strLaunchOptions||\"\";" \
Expand Down Expand Up @@ -1208,7 +1225,7 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) {

// FEX runs arm64 Wine, which has no D3DMetal and never runs under Rosetta.
#define NP_CX_PANEL(RT, BARREL) \
"({fex,get:G,saved:readSaved,set:s,clearUnused,head,dflt,dev,tool,ours=1})=>{" \
"({fex,get:G,saved:readSaved,set:s,clearUnused,head,dflt,dev,tool,ours=1,target:X})=>{" \
"const npZh=" NP_WEB_IS_ZH ",npIt=" NP_WEB_IS_IT ",npL=(en,zh,it)=>npZh?zh:npIt?it:en," \
/* A plugin may name the tools it is for. Filtered here rather than at render, so the
dependency chain and the indent depth see what the panel shows and nothing else. */ \
Expand Down Expand Up @@ -1252,20 +1269,20 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) {
".concat((f.ev||[]).map(e=>[e.slice(0,e.indexOf(\"=\")),v?e.slice(e.indexOf(\"=\")+1):\"\"])),1)," \
"TP=(f,d,lv)=>{const rd=MSCXReady[f.k]!==0;" \
"return(0," RT ".jsx)(" BARREL ".y4," \
"{label:rd?f.l:f.l+npL(\" (not installed)\",\" (未安装)\",\" (not installed)\"),disabled:!rd," \
"{label:rd?f.l:f.l+npL(\" (Not Installed)\",\" (未安装)\",\" (Not Installed)\"),disabled:!rd," \
"description:d||undefined,checked:rd&&npHas(f)," \
"indentLevel:lv||undefined," \
"onChange:v=>npSet(f,v)},f.k)}," \
"TD=(k,l,on,off,d,lv)=>{const rd=MSCXReady[k]!==0;" \
"return(0," RT ".jsx)(" BARREL ".y4," \
"{label:rd?l:l+npL(\" (not installed)\",\" (未安装)\",\" (not installed)\"),disabled:!rd," \
"{label:rd?l:l+npL(\" (Not Installed)\",\" (未安装)\",\" (Not Installed)\"),disabled:!rd," \
"description:d||undefined,checked:rd&&G(k)===on," \
"indentLevel:lv||undefined," \
"onChange:v=>s([[k,v?on:(off||\"\")]])},k)}," \
/* A row that holds one of its declared values, with Default for none of them. */ \
"TC=(k,l,d,lv,ch,pe)=>{const rd=MSCXReady[k]!==0,v=G(k);" \
"return(0," RT ".jsx)(" BARREL ".m," \
"{label:rd?l:l+npL(\" (not installed)\",\" (未安装)\",\" (not installed)\"),disabled:!rd," \
"{label:rd?l:l+npL(\" (Not Installed)\",\" (未安装)\",\" (Not Installed)\"),disabled:!rd," \
"description:d||undefined,layout:\"inline\"," \
"indentLevel:lv||undefined," \
"rgOptions:[{data:\"\",label:npL(\"Default\",\"默认\",\"Default\")}]" \
Expand All @@ -1276,7 +1293,7 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) {
"TS=(k,l,d,lv,mn,mx,st,df,sf,pe)=>{const rd=MSCXReady[k]!==0,v=G(k)," \
"n=!rd||\"\"===v.trim()||!isFinite(+v)?(undefined===df?mn:df):+v;" \
"return(0," RT ".jsx)(" BARREL ".d3," \
"{label:rd?l:l+npL(\" (not installed)\",\" (未安装)\",\" (not installed)\"),disabled:!rd," \
"{label:rd?l:l+npL(\" (Not Installed)\",\" (未安装)\",\" (Not Installed)\"),disabled:!rd," \
"description:d||undefined," \
"indentLevel:lv||undefined," \
"value:n,min:mn,max:mx,step:st||undefined,showValue:true," \
Expand All @@ -1289,7 +1306,7 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) {
"TL=(k,l,d,lv,ch,df,pe)=>{const rd=MSCXReady[k]!==0,V=ch.map(c=>c.v),v=G(k)," \
"n=rd&&V.indexOf(v)>=0?v:(undefined===df?V[0]:df);" \
"return(0," RT ".jsx)(" BARREL ".gz," \
"{label:rd?l:l+npL(\" (not installed)\",\" (未安装)\",\" (not installed)\"),disabled:!rd," \
"{label:rd?l:l+npL(\" (Not Installed)\",\" (未安装)\",\" (Not Installed)\"),disabled:!rd," \
"description:d||undefined," \
"indentLevel:lv||undefined," \
"rValues:V,value:n,showValue:true," \
Expand All @@ -1302,6 +1319,25 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) {
"checked:\"1\"===G(k),onChange:v=>s([[k,w(k,v?\"1\":\"0\",\"0\")]])},k)," \
"D=(k,l)=>(0," RT ".jsx)(" BARREL ".Yh,{className:\"MSCXRow\",label:l," \
"checked:\"0\"!==G(k),onChange:v=>s([[k,w(k,v?\"1\":\"0\",\"1\")]])},k)," \
"SL=(k,en)=>globalThis.LocalizationManager?.LocalizeString?.(k,!0)??en," \
"ex=X?X.ex:\"\",exd=ex.replace(/\\/[^\\/]*$/,\"\")||\"/\",sd=X&&X.sd||exd," \
"pk=(q,f)=>SteamClient.System.OpenFileDialog(q).then(p=>p&&f(p),()=>{})," \
"bt=(l,f,k)=>(0," RT ".jsx)(" BARREL ".$n,{className:\"MSCXSetBtn\",onClick:f,children:l},k)," \
"br=SL(\"#AppProperties_Shortcut_BrowseFiles\",\"Browse...\")," \
"XF=(k,l,d,c)=>(0," RT ".jsx)(" BARREL ".D0,{label:l,description:d,childrenContainerWidth:\"min\"," \
"children:(0," RT ".jsx)(\"div\",{className:\"MSCXBtns\",children:c})},k)," \
"exe=X&&(ours||ex)&&(0," RT ".jsxs)(" BARREL ".XY,{label:npL(\"Launch Executable\",\"Launch Executable\",\"Launch Executable\"),children:[" \
"XF(\"target\",npL(\"Executable\",\"Executable\",\"Executable\"),ex||undefined,[" \
"ex&&bt(SL(\"#Button_Remove\",\"Remove\"),()=>X.write(\"\",\"\"),\"remove\")," \
"ours&&bt(br,()=>pk({strTitle:npL(\"Choose a Program to Launch\",\"选择要启动的程序\",\"Scegli il programma da avviare\")," \
"rgFilters:[{strFileTypeName:npL(\"Windows Programs (*.exe, *.bat)\",\"Windows 程序 (*.exe, *.bat)\"," \
"\"Programmi Windows (*.exe, *.bat)\"),rFilePatterns:[\"*.exe\",\"*.bat\"],bUseAsDefault:!0}]," \
"strInitialFile:ex||X.home},p=>X.write(p,\"\")),\"pick\")])," \
"ours&&ex&&XF(\"dir\",npL(\"Start Executable From\",\"Start Executable From\",\"Start Executable From\"),sd,[" \
"sd!==exd&&bt(SL(\"#AppProperties_Customization_Reset\",\"Reset\"),()=>X.write(ex,\"\"),\"reset\")," \
"bt(br,()=>pk({strTitle:SL(\"#AppProperties_Shortcut_ChooseStartDirectoryDialogTitle\",\"Choose Start Directory\")," \
"strInitialFile:sd,bChooseDirectory:!0},p=>{const n=p.replace(/\\/+$/,\"\")||\"/\";X.write(ex,n===exd?\"\":n)}),\"pick\")])" \
"]},\"exe\")," \
"bv=G(\"CX_GRAPHICS_BACKEND\"),b=\"auto\"===bv||fex&&\"d3dmetal\"===bv?\"\":bv," \
"dm=!fex&&(\"\"===b||\"d3dmetal\"===b)," \
"dx=\"\"===b||\"dxmt\"===b," \
Expand Down Expand Up @@ -1357,14 +1393,14 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) {
"?[[\"DXMT_METALFX_SPATIAL_SWAPCHAIN\",\"\"],[\"DXMT_CONFIG\",fx(\"\")]]" \
":[[\"DXMT_METALFX_SPATIAL_SWAPCHAIN\",v.data?\"1\":\"0\"],[\"DXMT_CONFIG\",fx(v.data)]])})},\"usf\")," \
"ours&&(0," RT ".jsxs)(" BARREL ".XY,{label:npL(\"Controllers\",\"控制器\",\"Controller\"),children:[" \
"T(\"NOTPROTON_RAW_CONTROLLERS\",npL(\"Let games read controllers directly (May break Steam Input. Not recommended)\"," \
"T(\"NOTPROTON_RAW_CONTROLLERS\",npL(\"Let Games Read Controllers Directly (May break Steam Input. Not recommended)\"," \
"\"允许游戏直接读取控制器(可能导致 Steam Input 失效,不推荐)\"," \
"\"Lascia che i giochi leggano direttamente i controller (pu\\u00f2 interferire con Steam Input, sconsigliato)\"))," \
"T(\"NOTPROTON_DISABLE_HIDRAW\",npL(\"Disable hidraw\",\"停用 hidraw\",\"Disattiva hidraw\"))" \
"]},\"ctl\")," \
"fex&&(0," RT ".jsx)(" BARREL ".XY,{label:\"FEX\"," \
"children:D(\"FEX_X87REDUCEDPRECISION\"," \
"\"Reduced x87 Precision (Reducing precision improves game performance, is enabled by default in CrossOver Preview)\")},\"fex\")," \
"\"Reduced x87 Precision (Reducing precision improves game performance, is enabled by default in CrossOver Preview)\")},\"fex\"),exe," \
"dev&&(npRows.length||vids.length)?(0," RT ".jsx)(" BARREL ".J1,{children:(0," RT ".jsx)(" BARREL ".XY," \
"{label:npL(\"Developer\",\"开发者\",\"Developer\")," \
"children:vids.map(V).concat(npRows.filter(f=>!f.dep||on1(f.dep))" \
Expand Down Expand Up @@ -1399,7 +1435,7 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) {
"&&!t.strCompatToolName)return null;" \
"return MSCXPanel({fex,get:G,saved:g,set:L.s,clearUnused:1,dev:1,tool:tn," \
"ours:tn===\"" NP_FALLBACK_TOOL "\"||" NP_OUR_TOOLS ".indexOf(tn)>=0," \
"dflt:lost?null:k=>DF[k],head:" \
"dflt:lost?null:k=>DF[k],target:(" NP_CX_TARGET ")(o,t.unAppID,t.strInstallFolder||\"\"),head:" \
"typeof MSCXPrefixRow==\"function\"&&(0," RT ".jsx)(\"div\",{className:\"MSCXRow\",ref:e=>e&&MSCXPrefixRow(e,t.unAppID)},\"npstate\")" \
"})}"

Expand Down
21 changes: 17 additions & 4 deletions dylib/tests/panel-behavior/behavior.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ for (const form of Object.keys(FORMS)) {
ty: 'choice', ch: [{ v: 'x', l: 'Ex' }] },
]).replace(/"(\w+)":/g, '$1:');
{
const { render: PV, written: vwritten } = panel(emit, form, undefined, { feats: PVALUES });
const { render: PV, written: vwritten, files: vfiles } = panel(emit, form, undefined, { feats: PVALUES });
const nodesFor = (tool, opts) =>
walk(PV({ details: details(opts, { strCompatToolName: tool }) }));
const labelsFor = (tool, opts) => nodesFor(tool, opts)
Expand All @@ -112,10 +112,23 @@ for (const form of Object.keys(FORMS)) {
t.ok(nodesFor('notproton-26.3', 'NOTPROTON_HUD=1 NOTPROTON_ENV_HUD_POS=tl %command%')
.find(x => x.props.label === 'HUD position').props.selectedOption === 'tl',
'while under one of ours the key keeps its prefix');
t.ok(!labelsFor('fexmac', on).includes('Launch Executable') &&
labelsFor('notproton-26.3', '').includes('Launch Executable'),
'Launch Executable is offered only for NotProton tools, since its wrapper needs the shell the launch hook runs');
vfiles.push('/g/a.exe');
nodesFor('notproton-26.3', '').find(x => x.props.children === 'Browse...').props.onClick();
const picked = vwritten.at(-1).opts;
const other = nodesFor('fexmac', picked);
t.ok(JSON.stringify(other.filter(x => x.type === 'Button').map(x => x.props.children)) === '["Remove"]' &&
other.some(x => x.props.label === 'Executable' && x.props.description === '/g/a.exe') &&
!other.some(x => x.props.label === 'Start Executable From'),
'a pick made under one of ours still shows on another tool, with only Remove');
other.find(x => x.props.children === 'Remove').props.onClick();
t.ok(vwritten.at(-1).opts === '', 'and Remove there takes the wrapper out');
}

let r = nodes('');
t.ok(toggles(r).length === 7 && sections(r).length === 3, 'automatic shows seven toggles in three sections');
t.ok(toggles(r).length === 7 && sections(r).length === 4, 'automatic shows seven toggles in four sections');
t.ok(r.filter(x => x.type === 'Dropdown').length === 2, 'two dropdowns');
t.ok(!('label' in r.find(x => x.type === 'Dropdown').props), 'the backend dropdown carries no label column');

Expand Down Expand Up @@ -174,7 +187,7 @@ for (const form of Object.keys(FORMS)) {
t.ok(root.props.className === 'MSCXPanel', 'the panel root carries its own class');
const classes = nodes('').filter(x => x.type === 'Toggle').map(x => x.props.className);
t.ok(classes.length === 7 && classes.every(c => c === 'MSCXRow'), 'every checkbox carries the row class');
const raw = o => nodes(o).find(x => x.props.label === 'Let games read controllers directly (May break Steam Input. Not recommended)');
const raw = o => nodes(o).find(x => x.props.label === 'Let Games Read Controllers Directly (May break Steam Input. Not recommended)');
t.ok(sections(nodes('')).includes('Controllers') && raw('').props.checked === false,
'controller hiding is on unless the game says otherwise');
written.length = 0;
Expand Down Expand Up @@ -349,7 +362,7 @@ for (const form of Object.keys(FORMS)) {
'a parent at its off value does not count as on');
t.ok(alpha('') && alpha('').props.disabled === true,
'a plugin whose assets are missing renders disabled');
t.ok(alpha('').props.label.endsWith('(not installed)'),
t.ok(alpha('').props.label.endsWith('(Not Installed)'),
'it says why it cannot be used');
t.ok(alpha('NOTPROTON_ALPHA=1 %command%').props.checked === false,
'a stale launch option does not show it as on');
Expand Down
4 changes: 3 additions & 1 deletion dylib/tests/panel-behavior/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,8 @@ for (const form of Object.keys(FORMS)) {
t.ok(row(r, 'Metal HUD').props.checked === true, 'an unset row shows the default');
t.ok(row(r, AVX).props.checked === false, 'the AVX2 default of 0 shows off');
t.ok(dropdowns(r)[1].props.selectedOption === '1.5', 'an unset upscaler shows the default factor');
t.ok(!r.some(x => x.type === 'Button' || x.type === 'button'), 'a game has no Reset to defaults');
t.ok(!r.some(x => (x.type === 'Button' || x.type === 'button') && x.props.children === 'Reset to defaults'),
'a game has no Reset to defaults');

r = nodes('MTL_HUD_ENABLED=0 CX_GRAPHICS_BACKEND=auto %command%');
t.ok(row(r, 'Metal HUD').props.checked === false, "the game's 0 wins over the default");
Expand Down Expand Up @@ -410,6 +411,7 @@ for (const form of Object.keys(FORMS)) {
'Rosetta shows its saved defaults');
t.ok(!p.some(x => x.props && x.type === 'Button' && x.props.children === 'Reset to defaults'),
'the page has no Reset to defaults');
t.ok(!p.some(x => x.type === 'Section' && x.props.label === 'Launch Executable'), 'the page has no Launch Executable section');

const runnerLines = i => sent[i].text.split('\n').sort().join(';');
row(p, 'MSync').props.onChange(true);
Expand Down
9 changes: 8 additions & 1 deletion dylib/tests/panel-behavior/harness.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,15 @@ function panel(emit, form, globals = { document: { documentElement: { lang: 'en'
const body = expand(raw, { [f.react]: names[0], [f.barrel]: names[1] });
const src = form === 'component' ? 'var ' + body.replace(/,$/, '') : body.replace(/;$/, '');
const written = [];
// A file dialog answers synchronously with the next entry in `files`, and rejects like Steam's when there is none.
const dialogs = [], files = [];
const SteamClient = {
Apps: { SetAppLaunchOptions: (appid, opts) => written.push({ appid, opts }) },
System: { OpenFileDialog: q => {
dialogs.push(q);
const p = files.shift();
return { then: (f, r) => p === undefined ? r && r({ result: 2 }) : f(p) };
} },
};
const build = new Function(names[0], names[1], 'SteamClient', 'globalThis',
src + '\nreturn { MSCXOpts, MSCXPick: typeof MSCXPick === "function" ? MSCXPick : null,' +
Expand All @@ -66,7 +73,7 @@ function panel(emit, form, globals = { document: { documentElement: { lang: 'en'
const { MSCXOpts, MSCXPick, MSCXToolQuery, MSCXDefaultsPage } =
build(react, barrel, SteamClient, globals);
return { render: MSCXOpts, pick: MSCXPick, toolQuery: MSCXToolQuery,
defaultsPage: MSCXDefaultsPage, written, globals };
defaultsPage: MSCXDefaultsPage, written, globals, dialogs, files };
}

// Function components are resolved rather than recorded, so a check sees the nodes the
Expand Down
Loading
Loading