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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ Crafted weapons and shields carry no secondary stats of their own. Without a cho

Each hand is ranked on its own. What the reference profile happens to wield in the other hand decides how much a swap is worth — a Fury Warrior whose main hand is item level 344 and off hand 331 gains from every off-hand swap and loses from every main-hand one — so a single list across both hands would rank the hands rather than the weapons. Main hand and off hand therefore get a list each, and each hand gets its own share of the final round.

Each specialization runs on its own. A list no longer than the final round size is simulated once with your iteration and target-error settings; a longer one is screened first at up to 2,000 iterations and a 0.5% target error, and the best 12, 24 or 48 go on to the full round. Screening-only numbers are labelled. Tank specializations are ranked on the same weighted DPS and survival score as the rest of the app, with a boss calibrated once for each of them, so shields can be judged on more than damage. A job is limited to 2,400 candidate runs, counting every scenario.
Each specialization runs on its own. A list no longer than the final round size is simulated once with your iteration and target-error settings; a longer one is screened first at up to 2,000 iterations and a 0.5% target error, and the best 12, 24 or 48 go on to the full round. Screening-only numbers are labelled. Tank specializations are ranked on the same weighted DPS and survival score as the rest of the app, with a boss calibrated once for each of them, so shields can be judged on more than damage.

Tiers measure the distance behind the best weapon of the same specialization: S under 0.5, A under 1.5, B under 3, C under 5, then D — in percent of DPS, or in score points for tanks. Weapons within the combined 95% uncertainty of the best one are marked as such. Absolute numbers are comparable within a specialization only; reference profiles are built by different authors and are not a class ranking.

Expand Down Expand Up @@ -143,7 +143,7 @@ Trinket Lab is one trinket tier list per specialization, charted the way Bloodma

The candidates are every trinket in the active season's loot tables — raid, Mythic+ (reissued older dungeon trinkets included, through the season's own tables), delves and crafting — filtered for the specialization as Upgrade Finder filters them. Each is simulated at every chosen item level, the top of an upgrade track (Champion, Hero and Myth by default), below the level its own source can give it, and then at that level: a delve or dungeon trinket stops at the top of the Hero track, a raid trinket reaches the Myth track and a last boss's its own higher drop level, and a crafted trinket its crafting cap. Source limits are set as in Weapon Lab. The Great Vault is on by default: it gives Mythic+ loot on the Myth track, above the dungeon chest's Hero track, so a dungeon trinket runs up to the vault's level (Myth 6/6 unless you choose another) and the chest's level stays one of its steps. Crafted trinkets have no track; their level is chosen from the item levels the season uses, up to the crafting cap SimulationCraft's own profiles craft to. Equal footing shows every trinket at every level instead. The bar for a trinket is what it adds over no trinket, split at each level; its tier is read at its top level, with the same thresholds as Weapon Lab.

A specialization with more trinkets than the final round size (16, 32 or 64; 64 covers every list in Midnight Season 2) is screened first, one run per trinket at its top level, and the best go on to be simulated at every level; the rest keep their screened number and no curve. Tanks are ranked on the weighted DPS and survival score with a calibrated boss each, and Augmentation on the raid's damage against its measured share, as in Weapon Lab. A trinket that completes an item set with the reference gear — Zul'jin's Guillotine Technique beside Maze'roa — is marked, and the rest are measured from the best trinket without one. A job is limited to 8,000 trinket and item level runs, counting every scenario; a full season list is about 2,400.
A specialization with more trinkets than the final round size (16, 32 or 64; 64 covers every list in Midnight Season 2) is screened first, one run per trinket at its top level, and the best go on to be simulated at every level; the rest keep their screened number and no curve. Tanks are ranked on the weighted DPS and survival score with a calibrated boss each, and Augmentation on the raid's damage against its measured share, as in Weapon Lab. A trinket that completes an item set with the reference gear — Zul'jin's Guillotine Technique beside Maze'roa — is marked, and the rest are measured from the best trinket without one. A full season list is about 2,700 trinket and item level runs per scenario, with the vault on.

**The tier list page.** A finished job offers one page with every class and specialization, each trinket in its tier with its split bar, source, item level and Wowhead tooltip, and the gain at every level in the row's data so the website can import it. Two trinkets together are not the sum of their bars; the page says so.

Expand Down Expand Up @@ -177,7 +177,7 @@ The search is bounded and deterministic. It is not exhaustive, adaptive across s

Results include DPS, differences from baseline, approximate 95% confidence intervals, actual SimC input and downloadable HTML/JSON reports. The History tab persists completed jobs across server restarts. Unfinished jobs are marked interrupted after a restart.

Jobs run sequentially with configurable CPU threads. Limits are 128 alternative builds, 256 runs per job (Upgrade Finder: 800 candidates, two profileset runs per scenario; Weapon Lab: 2,400 candidate runs, one or two profileset runs per specialization and scenario; Trinket Lab: 8,000 trinket and item level runs) and 5 queued/running jobs. Active and queued jobs can be cancelled. Files are stored in runs/; the import field is also remembered in browser localStorage.
Jobs run sequentially with configurable CPU threads. There is no cap on how much a job may simulate — enchant combinations, alternatives, Upgrade Finder candidates, Crest Planner upgrades, and Weapon Lab and Trinket Lab lists all run in full, however long that takes on your PC; the count before you start says how many runs it will be. Up to 5 jobs can be queued or running at once. Active and queued jobs can be cancelled. Files are stored in runs/; the import field is also remembered in browser localStorage.

The server listens only on 127.0.0.1, verifies Host and POST tokens, rejects file/network/output directives in profile imports, and launches SimC without a shell. The compiled engine has networking disabled.

Expand Down
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "simc-lab",
"productName": "SimC Lab",
"desktopName": "simc-lab.desktop",
"version": "1.18.0",
"version": "1.19.0",
"description": "Local SimulationCraft workbench with upgrade finder and tank simulation.",
"author": "Roburmaster",
"private": true,
Expand Down
3 changes: 1 addition & 2 deletions lib/crests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {slots as allSlots} from './profile.mjs';

// Crest Planner: every equipped item on a season upgrade track is simulated at each higher level of that track,
// and the cost of getting there is counted in the track's own crests.
export const limits={candidates:120};
export const limits={};

// Enum.ItemRedundancySlot, the slots the game keeps an upgrade high watermark for.
const redundancy={head:0,neck:1,shoulder:2,chest:3,waist:4,legs:5,feet:6,wrist:7,hands:8,finger1:9,finger2:9,trinket1:10,trinket2:10,back:11};
Expand Down Expand Up @@ -82,7 +82,6 @@ export function buildCrestCandidates(profile,text,request,season,catalog){
if(!items.length)throw new Error('None of the equipped items is on one of this season’s upgrade tracks.');
if(!list.length&&cheapest)throw new Error(`Nothing is affordable with these crests. The cheapest upgrade needs ${cheapest.crests} ${season.crests?.currencies.find(c=>c.id===cheapest.currencyId)?.name||'crests'}.`);
if(!list.length)throw new Error('Every equipped item on an upgrade track is already fully upgraded.');
if(list.length>limits.candidates)throw new Error(`${list.length} upgrades to simulate. Choose fewer slots or only fully upgraded items (at most ${limits.candidates}).`);
list.forEach((c,i)=>c.key='u'+String(i+1).padStart(3,'0'));
return {candidates:list,items,levels,affordable,state:{exported:state.exported,watermarks:!!state.watermarks},budget:budget||null,currencies:season.crests?.currencies||[]};
}
Expand Down
9 changes: 5 additions & 4 deletions lib/engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,6 @@ export async function prepare(request,catalog,talentData,season) {
}
const optimization=request.mode==='talents'?await prepareTalents(profile,request,talentData,source):null;
const variants=optimization?.variants||createVariants(profile,request,catalog);
if(variants.length*scenarios.length>256) throw new Error('Maximum 256 runs per job.');
return {engine,profile,settings,scenarios,variants,search:optimization?.search};
}
export function inputFor(variant,settings,scenario,paths) {
Expand Down Expand Up @@ -439,7 +438,7 @@ export class Jobs {
await fs.writeFile(input,inputFor({text:profile.text},settings,scenario,paths)+(settings.tank?`profileset_metric=${tankMetricList}\nprofileset_main_actor_index=1\n`:'')+(spec?.support?'profileset_metric=raid_dps,dps\n':'')+profilesetLines(list,profile,this.catalog).join('\n')+'\n');
await this.save(this.public(job));
try{
await this.execute(input,dir,job,6*3600000);
await this.execute(input,dir,job);
if(job.status==='cancelled')return null;
const report=JSON.parse(await fs.readFile(paths.json,'utf8'));
const result=profilesetResults(report,settings.tank,{raid:!!spec?.support});
Expand Down Expand Up @@ -470,10 +469,12 @@ export class Jobs {
job.done++;await this.save(this.public(job));
return boss;
}
execute(input,dir,job,timeout=3600000){return new Promise((resolve,reject)=>{
// A run takes as long as it takes: no time limit, since a job is cancelled from the app when it should stop.
// A timeout is only set where a caller asks for one.
execute(input,dir,job,timeout=null){return new Promise((resolve,reject)=>{
const child=spawn(executable,[input],{cwd:dir,windowsHide:true,shell:false});this.child=child;let tail='';job.progress=null;
let localeMissing=false;const capture=data=>{tail=(tail+data.toString()).slice(-12000);if(tail.includes('_S_create_c_locale'))localeMissing=true;job.log=tail;const progress=parseProgress(tail.slice(-2000));if(progress)job.progress=progress;};child.stdout.on('data',capture);child.stderr.on('data',capture);
const timer=setTimeout(()=>{child.kill();reject(new Error(`The run exceeded ${timeout/60000} minutes.`));},timeout);
const timer=Number.isFinite(timeout)?setTimeout(()=>{child.kill();reject(new Error(`The run exceeded ${timeout/60000} minutes.`));},timeout):null;
child.on('error',e=>{clearTimeout(timer);reject(e);});child.on('close',code=>{clearTimeout(timer);if(code===0)resolve();
// On Linux SimC writes its HTML report under the en_US.UTF-8 locale and fails when the system lacks it.
// The JSON result is complete by then, so the run counts; only the HTML download is missing.
Expand Down
1 change: 0 additions & 1 deletion lib/profile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ export function createVariants(profile, request, catalog) {
});
let combinations = request.combine ? [{name:'',lines:[]}] : [];
if (request.combine) for (const group of groups) {
if (combinations.length * group.length > 128) throw new Error('Maximum 128 enchant combinations per job.');
combinations = combinations.flatMap(c=>group.map(v=>({name:c.name ? `${c.name} + ${v.name}` : v.name,lines:[...c.lines,v.line]})));
} else combinations = groups.flat().map(v=>({name:v.name,lines:[v.line]}));
for (const c of combinations) variants.push({name:c.name,text:applyOverrides(profile.text,c.lines.join('\n'))});
Expand Down
4 changes: 2 additions & 2 deletions lib/trinkets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {eligible,sourceKinds,carried} from './upgrades.mjs';
import {loadReferenceSpecs,dropLevels,craftedItemLevel,completedSet} from './weapons.mjs';
import {normalizeTank} from './tank.mjs';

export const limits={specs:48,candidates:8000,finalists:[16,32,64]};
// No cap on how much a job may simulate: the app runs on your own PC, and a long job is your call to make.
export const limits={specs:48,finalists:[16,32,64]};
export const trinketSlots=['trinket1','trinket2'];
const isTrinket=item=>item?.itemClass===4&&item?.inventoryType===12;

Expand Down Expand Up @@ -107,7 +108,6 @@ export async function prepareTrinkets(request,catalog,season,talentData,source,s
}
if(!specs.length)throw new Error('No selected specialization has a trinket to rank.');
const total=specs.reduce((n,s)=>n+s.candidates.length,0);
if(total*scenarios>limits.candidates)throw new Error(`The selection produces ${total} trinket and item level pairs across ${scenarios} scenario${scenarios===1?'':'s'}. Narrow it to ${limits.candidates} runs or fewer.`);
const levels=specs.flatMap(s=>s.candidates.map(c=>c.itemLevel));
return {season:season.season,sources:equal?{equal:true,steps:steps.map(s=>s.label)}:drops.public,equal,steps,finalists,specs,skipped,candidates:total,
trinkets:specs.reduce((n,s)=>n+trinketCount(s),0),levels:{min:Math.min(...levels),max:Math.max(...levels)}};
Expand Down
3 changes: 1 addition & 2 deletions lib/upgrades.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const shieldClasses=new Set(['warrior','paladin','shaman']);
const statNames={32:'Critical Strike',36:'Haste',40:'Versatility',49:'Mastery'};
export const weaponKinds={17:'two',15:'two',26:'two',13:'one',21:'one',22:'one',14:'shield',23:'held'};
export const sourceKinds={raid:'Raid',mplus:'Mythic+',delves:'Delves',vault:'Great Vault',crafted:'Crafted'};
export const limits={candidates:800,finalists:[24,48,96]};
export const limits={finalists:[24,48,96]};

// Season pools come from the pinned client data: the active season's bonus-roll group names the raid
// encounters and Mythic+ dungeons, so no instance or item ID is hardcoded here.
Expand Down Expand Up @@ -263,7 +263,6 @@ export function buildCandidates(profile,request,data,catalog,specId){
}
const list=[...candidates.values()].sort((a,b)=>allSlots.indexOf(a.slot)-allSlots.indexOf(b.slot)||b.itemLevel-a.itemLevel||a.name.localeCompare(b.name));
if(!list.length)throw new Error('No usable items were found in the selected sources for this character.');
if(list.length>limits.candidates)throw new Error(`The selected sources produce ${list.length} candidates. Narrow the search to ${limits.candidates} or fewer.`);
list.forEach((c,i)=>c.key='c'+String(i+1).padStart(3,'0'));
// What already holds each limit, so a result can say why an embellished piece is missing.
const holders=Object.entries(equipped).map(([slot,used])=>({slot,name:catalog.items.get(profile.gear[slot].id)?.name||`Item ${profile.gear[slot].id}`,limits:[...used]}));
Expand Down
3 changes: 1 addition & 2 deletions lib/weapons.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {healerSpecs,healerCandidates,contents as healerContents} from './healers
export const isSupport=info=>info?.class==='evoker'&&info?.spec==='augmentation';

export const kinds={main:'Main hand',offhand:'Off-hand weapon',shield:'Shield',held:'Held in off hand'};
export const limits={specs:48,candidates:2400,finalists:[12,24,48]};
export const limits={specs:48,finalists:[12,24,48]};
// Distance behind the best result in the same list: percent of DPS, or score points for tanks.
export const tiers=[{tier:'S',behind:0.5},{tier:'A',behind:1.5},{tier:'B',behind:3},{tier:'C',behind:5},{tier:'D',behind:Infinity}];

Expand Down Expand Up @@ -271,7 +271,6 @@ export async function prepareWeapons(request,catalog,season,talentData,source,sc
if(!specs.length)throw new Error('No selected specialization can use the chosen weapon categories.');
// Healer weapons are read once rather than simulated per scenario, so they do not count against the limit.
const total=specs.reduce((n,s)=>n+(s.healer?s.candidates.length:simulatedCount(s)),0),simulated=specs.filter(s=>!s.healer).reduce((n,s)=>n+simulatedCount(s),0);
if(simulated*scenarios>limits.candidates)throw new Error(`The selection produces ${simulated} simulated candidates across ${scenarios} scenario${scenarios===1?'':'s'}. Narrow it to ${limits.candidates} candidate runs or fewer.`);
// Healer scores are measured against a whole character: SimC's own Shadow Priest, an intellect caster of the
// season, stands in for the healer's other gear. Its text stays on the server like every reference profile's.
const standIn=specs.some(s=>s.healer)?available.find(s=>s.key==='priest-shadow')||available.find(s=>!s.healer&&['mage','warlock'].includes(s.class)):null;
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "simc-lab",
"version": "1.18.0",
"version": "1.19.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
3 changes: 2 additions & 1 deletion tests/weapons.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,8 @@ test('a plan counts its runs and refuses a selection that cannot be simulated',a
await assert.rejects(prepareWeapons({weapons:{kinds:['nonsense']}},catalog,season,talentData,dir,1),/weapon category/);
await assert.rejects(prepareWeapons({weapons:{equal:true,track:track.id,level:9}},catalog,season,talentData,dir,1),/upgrade track and level/);
await assert.rejects(prepareWeapons({weapons:{sources:{crafted:{itemLevel:0}}}},catalog,season,talentData,dir,1),/Crafted item level/);
await assert.rejects(prepareWeapons({weapons:{specs:['warrior-arms']}},catalog,season,talentData,dir,3000),/Narrow it/);
// No cap on how much a job may simulate: many scenarios are still accepted.
assert.equal((await prepareWeapons({weapons:{specs:['warrior-arms']}},catalog,season,talentData,dir,3000)).specs.length,1);
await fs.rm(dir,{recursive:true,force:true});
});

Expand Down
Loading