# MacProject Pro 1.5 resource leveler

Spec for Analyze ▸ Level Resources (⌘L), Unlevel Resources and View
Leveler Results, read from the original's code and resources. No sample was
leveled through the full path, so the code is the main evidence. One sample
(Product Design Subproject) carries the result of a leveling substitution,
which confirms the stored fields.

Markers:
- **[code]**: read from the decompiled code, with addresses.
- **[resource]**: read from a resource.
- **[data]**: seen in the samples.
- **guess**: inferred.

Segments: CLeveler 0xe2f00–0xe7a50, CLevelerDlg 0x120600–0x122138,
CLevelResults 0x122200–0x123510, CXProjectLeveler 0x123600–0x123f14.
Flag labels follow `MPXKit`'s decoders (see file-format.md). A flag label's suffix maps to a bit mask:
.a = 0x80, .b = 0x40, .c = 0x20, .d = 0x10, .e = 0x08, .f = 0x04, .10 = 0x02,
.11 = 0x01. This is checked against +0x112.c = 0x20 in the scheduler notes,
and against the code below.

## 1. Commands and settings

| Command | Number | Enabled when | Runs |
|---|---|---|---|
| Level Resources ⌘L | 1046 | always | `CLeveler_3e60` |
| Unlevel Resources | 1047 | `dcst` +0x23e.b set (the "leveled" flag), or, with Cross-Project Leveling on, a family member qualifies | `CLeveler_488c` |
| View Leveler Results | 1048 | always | opens window kind 0xb (view 1012, `TLevelChangesWindow`) |

All three are [code]: menu setup at 0x7102c–0x710a0, dispatch at 0x5b040–0x5b180.

Document settings (`dcst`; Preferences ▸ Resources, view 3403). The labels
are [resource] and the masks [code]:

| Field | Meaning | Samples [data] |
|---|---|---|
| +0x23d.b (0x40) | Level During Calculate | off in all 23 |
| +0x23d.d (0x10) | Interactive Leveling: ask at each conflict | on in all 23 |
| +0x23d.e (0x08) | Cross-Project Leveling (project family) | off |
| +0x23f.b (0x40) | Allow Changes Only to Task Starting Dates | off |
| +0x23e.b (0x40) | the document has been leveled. Set by every applied change; cleared by Unlevel; enables Unlevel | set in Product Design Subproject only |
| +0x23e.f (0x04) | guess: the schedule needs recalculating. With it set and Level During Calculate off, leveling recalculates first | |

The resource fields:

| Field | Meaning | Status |
|---|---|---|
| +0x44.40 (0x40) | **Level**: only these resources take part. In every one of 331 sample resources | [code] `FUN_000e6374`; [data] |
| +0x20 | **# Available** (float) | |
| +0x1c | in memory only: the leveler's segment list | |

The task fields:

| Field | Meaning | Status |
|---|---|---|
| +0x100 | Leveling Priority. 0 is "Do Not Level"; 100 is "Highest" (Task Info label) | |
| +0xa2 | Early Start Type: bit set, below | |
| +0xa4 | Actual Start Type: same bits | |
| +0xa5 | Actual Finish Type: same bits | |
| +0xe4 | the Earliest Start before leveling | [code] |
| +0xe8 | the Actual Start before leveling | [code] |

Start-type bits:

| Bit | Name |
|---|---|
| 0x01 | User set |
| 0x02 | Leveler set |
| 0x04 | Swept |
| 0x08 | Bad |
| 0x10 | Constrained |

They are [resource]: STR# 11137, a 32-entry table indexed by the value. The
table shows "Leveler set" over "User set" over "Swept", with a "Bad " prefix
for 0x08, and "Constrained" only when it is the only bit set. In the samples,
13 tasks hold 0x10 (the constrained start milestones) and 606 hold 0 [data].

Allocation fields (`iall`). They are all serialized, so Unlevel works after
the file is saved and reopened:

| Field | Meaning | Status |
|---|---|---|
| +0x2e | the resource before a substitution | [code] `FUN_000e72cc`; [data] |
| +0x3a | the Number before leveling (float) | [code] |
| +0x4a | the % Effort before leveling | [code] |
| +0x4c.a (0x80) | the leveler changed the effort | [code] `CLeveler_0608` |
| +0x4c.b (0x40) | the leveler changed the number | [code] |
| +0x4c.c (0x20) | the leveler substituted the resource | [code] |

More on these:
- **+0x2e:** in Product Design Subproject, "Process Considerations" has
  resource 11, +0x2e = resource 29, and +0x4c.c set.
- **+0x3a and +0x4a:** in all 902 sample allocations they equal the current
  values (+0x1a, +0x1e) [data]. So write them as copies, and change only the
  current value when leveling.
- **+0x4c.d (0x10):** the driving-resource flag (already in file-format.md).

Leveling Priority names (STR# 6289) [resource]: Do Not Level, Lowest, Very
Low, Lower, Low, Medium, High, Higher, Very High, Highest. How the numbers
0–100 map to these names: guess.

## 2. Who takes part

- **A resource takes part** if its Level flag is set (+0x44 & 0x40)
  [code `FUN_000e6374`, called from `FUN_000e6730`].
- **A task is fixed** — never moved, its allocations never changed — if any
  of these hold [code `FUN_000e3d84`]:
  - % Done (+0xc6) ≥ 1 (it has started);
  - Leveling Priority (+0x100) = 0;
  - it is a supertask (class 6).
  - Its allocations still count toward the load.
- **The critical path gets no special treatment.** It enters only through
  the ranking (§5), as lateness past the Latest Finish.
- **An allocation is "clamped"** when its Number > the resource's # Available
  or its % Effort > 100. The load then uses the clamped values. Clamped
  allocations, allocations of fixed tasks, and every allocation when Allow
  Changes Only to Task Starting Dates is on are marked *rate-fixed*: no
  effort or number change is offered for them [code `FUN_000e2ff8`,
  `FUN_000e47a0`].

## 3. Load and conflicts

All times are seconds (the MacDate axis).

- **Load profile.** Each leveled resource keeps a chronological chain of
  segments. A segment holds start (+0x1c), end (+0x20), load (+0x24, float),
  an "ignored" flag (+0x28), and the list of *contributions* in it [code
  `FUN_000e44cc`, `FUN_000e46f4`, `FUN_000e47a0`].
- **Contribution span.** Each allocation of a task overlapping the window
  contributes over [task Earliest Start, allocation end). The allocation end
  is the task start plus the allocation's Duration (+0x46), worked on the
  task's calendar [code `FUN_000e30dc` = `FUN_000e3ce4(task,
  FUN_000e308c(alloc))`; the calendar used is a guess].
- **Segments split at every contribution boundary** [code `FUN_000e46f4`].
  So there is **no fixed time granularity**: the profile is exact, event to
  event.
- **Load of a contribution** = min(Number, # Available) × min(% Effort, 100)
  [code `FUN_000e2ff8`; SANE extended arithmetic]. It is in "percent of one
  unit": one person at 100% contributes 100.
- **Capacity of a resource** = # Available × 100.0 (the extended constant
  100.0 at 0xe31c6 / 0xe6478) [code `FUN_000e313c`, `FUN_000e63d4`].
- **A conflict** is a segment, not ignored, whose load > capacity, strictly
  (FOCPX then `ble`) [code `FUN_000e63d4`].
  - The leveler takes the earliest-starting conflict across all leveled
    resources.
  - On a tie, the first resource in the resource list wins (strict `<`)
    [code `FUN_000e6484`].
- **Contribution record** [code `FUN_000e47a0`]:

  | Offset | Contents |
  |---|---|
  | [0] | fixed (task fixed) |
  | [1] | rate-fixed |
  | [2] | working "kept" mark |
  | [4] | load (float) |
  | [8] | allocation end |
  | [0xc] | cross-project flag |
  | [0xe] | allocation reference |
  | [0x12] | task Leveling Priority |

## 4. The main loop (`CLeveler_3e60` @ 0xe6d64, `FUN_000e64e8` @ 0xe64e8) [code]

1. If Level During Calculate is off and +0x23e.f is set, calculate the
   schedule first (`CAnalysis_0080`).
2. Find the overall range: the earliest task start to the latest end, over
   tasks and gathered allocations (`FUN_000e6ca0`). Process it as one window.
   On a memory-full failure (−108) the window is halved, up to 5 times, and
   retried.
3. Without Interactive Leveling, show a progress window titled "Leveling
   resources" (STR 15003) with the conflict's date. Cancelling stops.
4. Gather the load profile for the window (`FUN_000e6a10` →
   `FUN_000e6814` over tasks, `FUN_000e69bc` over resources).
5. Repeat until there are no conflicts, or Stop:
   1. Take the next conflict (§3).
   2. Build its solutions (§5): at most 3, sorted best first.
   3. Choose one:
      - **Not interactive:** the first solution, applied automatically.
      - **Interactive:** the Interactive Leveling™ dialog (view 11000,
        `CLevelerDlg_0d00`). It shows the conflict text, up to four
        solutions as radio buttons each with a before/after picture
        (`TSolutionPict`), and the solution text. Its buttons are Make
        Changes, Ignore Conflict and Stop Leveling.
   4. If there's no solution, or the user chose Ignore Conflict: mark the
      segment ignored (+0x28). Rescan from its end later (re-gather once
      the scan passes it).
   5. Otherwise:
      - record the conflict and the solution for the results (`FUN_000e4c3c`);
      - truncate the profiles at the conflict start (`FUN_000e6bc0`);
      - apply the solution (§6);
      - recalculate the schedule (`CAnalysis_07dc`, which honours leveled
        starts, §7);
      - re-gather the profile from the conflict start to the window end.
6. Mark the document changed. Without Cross-Project Leveling, run a final
   calculation and a redraw (`CAnalysis_0460`).
7. If any changes were recorded, show the results (`CLevelerDlg_0e38`).

## 5. Solutions for one conflict (`FUN_000e504c`, `FUN_000e6010`, `FUN_000e5e78`, `FUN_000e5b78`) [code]

The conflict context holds:
- capacity = # Available × 100;
- n, the number of contributions;
- the number of movable contributions (not fixed);
- the number of rate-changeable contributions (not rate-fixed);
- maxPriority, the highest Leveling Priority in the segment
  (`FUN_000e5e28`).

There are three kinds of solution. Each is a list of changes: per
allocation, *none*, *substitute* (type 1), *move* (type 2), or *effort or
number change* (types 3 and 4).

### Moves

The leveler enumerates the sets K of contributions that **stay** where they
are.
- Fixed contributions are always in K.
- Contributions are added to K while Σ load(K) ≤ capacity.
- Only **maximal** sets count: no further contribution would fit.
- The search keeps the set with the **least leftover** capacity
  (capacity − Σ load). An exact fit (leftover = 0) is taken at once.
- The recursion is `FUN_000e5b78`; finding the next contribution that fits
  is `FUN_000e5af8`.
- If at most one contribution is movable and it is the one being
  considered, it is skipped [`FUN_000e5e78`].

For a set K, every movable contribution not in K is **moved** (type 2) to
`moveDate`, computed by `FUN_000e5a64` / `FUN_000e5958`:
- `moveDate` = max(m, x).
- m = the earliest allocation end among the contributions in K and the
  fixed ones. It is when the first staying allocation frees the resource.
- x = for each task in K, the latest end of its allocations that also sit in
  this conflict's segment for another resource (`FUN_000e58e8` /
  `FUN_000e5868`). Exact scope: guess.

The moved task's new Earliest Start = `CAnalysis_1314(task, moveDate,
flag)`, which is the scheduler's start_at(W(moveDate)): the next working
start on the task's calendar [`CLeveler_0568`]. `flag` is set when the task
has no predecessor or no successor list (+0xdc / +0xde = −1) and controls
whether a zero-duration task stays on the instant.

### Effort or number changes

These are only for rate-changeable contributions, so never when Allow
Changes Only to Task Starting Dates is on.

`FUN_000e51d0(ctx, k)` gives k overlapping allocations an equal share:
- **k ≤ 5:** each gets % Effort = 100 ÷ k (integer division) and Number =
  min(Number, # Available). The share is # Available × 100 ÷ k.
- **k ≥ 6:** a small search for a number and an effort of 100 ÷ s
  (s = 2…5) that fit.

`FUN_000e537c` then:
- writes the new Number and/or % Effort for each chosen allocation whose
  load exceeds the share. It's type 3; type 4 when the allocation already
  ends at the segment start (guess);
- recomputes that allocation's end with the lower rate: same work, longer
  duration (`CLeveler_0514` → `FUN_000e31d0`).

It combines up to `depth` allocations, where depth comes from n:

| n | depth |
|---|---|
| < 8 | n − 1 |
| 8 | 4 |
| 9–10 | 3 |
| 11–19 | 2 |
| ≥ 20 | 1 |

### Substitution

Only with Interactive Leveling on, Allow Changes Only to Task Starting Dates
off, and more than one resource in the document. One "Substitute for
<resource>…" solution is added (type 1, `FUN_000e4e0c`). Choosing it opens
Resource Substitution (view 11001): a "Substitute For" resource list and a
task list. This is the dialog's fourth radio button (guess).

### Ranking

Solutions are kept in a sorted array whose class overrides Compare with
`CLeveler_1314` @ 0xe4218. Only the best 3 are kept (`FUN_000e5650`).

Each solution carries a score, filled in by `FUN_000e4194` /
`FUN_000e4070`. For each changed allocation, `CLeveler_0360` gives *f*, the
new finish of its task after the change, and *LF*, the task's Latest Finish
(+0xb6). The score fields:

| Field | Meaning |
|---|---|
| +0x14 | total load of the unchanged contributions |
| +0x18 | moved tasks whose priority < maxPriority |
| +0x1a | Σ (f − LF) over changed tasks at maxPriority |
| +0x1e | Σ (f − LF) over all changed tasks |
| +0x22 | Σ max(0, LF − f) at maxPriority, the slack left |
| +0x26 | Σ max(0, LF − f), all |
| +0x2a | the number of changes |
| +0x2c | moved elements carrying mark [2]; exact meaning guess |

The order, best first:
1. Move or combined solutions before a lone substitution (a single type-1
   change).
2. Fewer +0x2c.
3. A comparison of +0x14 against the other solution and the capacity
   (SANE branches): prefer the solution that fills capacity exactly.
   Details: guess.
4. **More** low-priority tasks moved (+0x18): lower-priority tasks are
   moved first.
5. Less lateness past LF for the highest-priority tasks (+0x1a), then for
   all tasks (+0x1e).
6. More slack left for the highest-priority tasks (+0x22), then for all
   (+0x26).
7. The +0x14 comparison again, then fewer changes (+0x2a).

So by default the leveler delays **the lowest-priority tasks**, choosing the
moves that push tasks **least past their Latest Finish** and keep the most
slack. Moves are **not limited to slack**: lateness is only penalised.

## 6. Applying a change (`CLeveler_0608` @ 0xe350c, an `iall` method) [code]

- **Move, planned mode** (`dcst` +0x112.c off):
  - save the task's ES into +0xe4;
  - Early Start Type (field 112, +0xa2) |= 0x02;
  - Earliest Start (field 8) = the new date;
  - Actual Start Type (field 113) = +0xa4 & ~0x04.
- **Move, actuals mode:**
  - save +0x5c into +0xe8;
  - Actual Start (field 13) = the new date;
  - Actual Start Type = (+0xa4 & ~0x04) | 0x02.
- **Effort or number change:**
  - if the effort changed: % Effort (field 79) = the new effort, and
    +0x4c |= 0x80;
  - if the number changed: Number (field 78) = the new number, and
    +0x4c |= 0x40.
- **Substitution:** Resource (field 52) = the new resource, and
  +0x4c |= 0x20.
- **After any change:**
  - the task is marked for recalculation (`CAnalysis_1590`);
  - `dcst` +0x23e |= 0x40 (leveled);
  - with Cross-Project Leveling on, also +0x23e |= 0x80.

## 7. How the scheduler uses a leveled start [code `FUN_0004812c` @ 0x4812c]

When the Early Start Type has 0x02 (Leveler set), the forward pass **pins
the task's start to its stored Earliest Start (+0x54)**. That overrides both
the predecessors' bound and the start constraint, like Start On. Otherwise
the constraint rules in `scheduling.md` apply.

"Bad leveler set" (0x08 | 0x02) is presumably shown when a pinned start
falls before what the dependencies require. Guess: the code that sets 0x08
wasn't traced.

The Swift scheduler must add this rule. In the samples no task has 0x02, so
the 618 of 619 match is unaffected.

## 8. Unlevel (`CLeveler_488c` @ 0xe7790, `FUN_000e7388`, `FUN_000e72cc`) [code]

For every task that isn't fixed:
- **If Early Start Type has 0x02:**
  - Earliest Start (field 8) ← +0xe4;
  - Early Start Type ← clear 0x02, and also 0x10 if it was set.
- **If Actual Start Type has 0x02:**
  - Actual Start (field 13) ← +0xe8;
  - Actual Start Type ← clear 0x02 (and 0x10).
- **For each allocation:**
  - if +0x4c.c is set and +0x2e is a valid reference: Resource ← +0x2e,
    clear 0x20;
  - if +0x4c.a: % Effort ← +0x4a, clear 0x80;
  - if +0x4c.b: Number ← +0x3a, clear 0x40.

If anything changed, recalculate with Level During Calculate temporarily
off. In every case, clear `dcst` +0x23e.b (leveled).

With Cross-Project Leveling, the same is done in each family file
(`FUN_000e7604`, `FUN_000e7520`).

## 9. View Leveler Results (CLevelResults, view 1012 `TLevelChangesWindow` / `TLevelChangesView`)

A scrolling list of what the last leveling run did. The message templates
are [resource]: STR 11000–11016. The joiners are ", " (11001), " and "
(11000), "; " (11004) and "." (11002).

Per conflict:
- "^2 is over-allocated between ^0 and ^1.  The tasks are ^3" (11003):
  ^2 is the resource, ^0 and ^1 the conflict's start and end, ^3 the task
  names.

Then its changes, joined by "; ":

| Change | Template |
|---|---|
| move | "move start of ^4 to ^1" (11006) |
| effort down | "decrease effort from ^0 to ^1 in ^4" (11007) |
| effort up | "increase effort from ^0 to ^1 in task ^4" (11008) |
| number down | "decrease number from ^2 to ^3 in task ^4" (11009) |
| both | 11010–11014 |
| substitution | "Substitute for ^0…" / "Substitute For ^0" (11005, 11016) |
| ellipsis | "…" (11015) |

The records are added with `CDocumentDataManager_28ac`
(`CLeveler_0b8c`): kind 2 a planned move, 1 an actual move, 3 an
allocation change, plus the date or rate and the resource and task. They
appear to live in a document list, `dcst` +0x158, which Unlevel clears
(guess). Whether they are saved: guess, probably not.

## 10. What the editor needs to decide

1. **Interactive Leveling is on in every sample.** So the original's
   default experience is the per-conflict dialog: Make Changes / Ignore
   Conflict / Stop Leveling with up to 4 solutions. Implement the dialog, or
   default to automatic (the first solution) and offer the dialog behind
   the preference.
2. **Substitution** needs the Resource Substitution dialog. It can follow
   later; the other solutions don't depend on it.
3. **The scheduler must honour Early Start Type 0x02** (§7) before leveling
   can work. Unlevel needs +0xe4/+0xe8 and the allocation originals; the
   model should carry them, and the MPRX writer should round-trip them.
4. **The +0x14 tie-break and the `x` bound on moveDate** are only partly
   decoded (guesses). A port that skips them still follows the main order:
   low priority first, then least lateness, then most slack, then fewest
   changes.

## Where things are

- **Command numbers:** `mntb` 128, Analyze items 14–16 = 1046–1048.
- **Vtables:**
  - the solutions array is the vtable at A5 −0x1300 → 0xffffb770, whose
    Compare slot is `CLeveler_1314`;
  - the allocation class is vtable 0xffffaecc, with slots +0x34…+0x54 =
    `CLeveler_0360`…`089c`.
  - Calls go through table − 4, so a call offset is the slot + 4.
- **SANE opcodes:** source format 0x0000 extended, 0x1000 single, 0x2000
  integer. Operations: 0x00 add, 0x02 sub, 0x04 mul, 0x06 div, 0x08 cmp,
  0x0A cmpx, 0x0E to extended, 0x10 from extended.

---
Source: https://msitarzewski.github.io/GlassProject/leveling.html · How MacProject Pro 1.5's resource leveler chooses what to move.
