Dave1707 from the Codea forum provided an interesting program. I would like to bring it under control.

In refactoring and generally trying to improve the Dung program, I often wish for some analytical features from Codea, such as information on the number of classes, methods, tests, and so on. The more we know about those things, the better sense we can get about what the program wants us to do next.

So yesterday I asked whether there is a way to get the number of classes in a Codea program. Naturally, I figured that if there was, it would work by counting the classes and after that we’d have a handle on getting more and more information. Sort of a Pandora’s box question.

Dave1707, who is always helpful and often surprising, came right back and pasted in a program that we’ll be looking at this morning, and perhaps off and on in the future. Long story short, the program can be pointed at any Codea project and it produces an on-screen display like this:

making

This is really nifty in my view. I like it. I would like to include it in my development cycle, even though I’m not sure just how and when it will come in handy. Certainly knowing the protocol for all my classes has value.

There are things that could be better. As written, the program displays things in the order of the program’s tabs, and inside a tab, displays classes, functions, and methods in the order they appear. That’s not bad, but it’s not always what we want.

For a general report, we’d probably like to see things in alphabetical order by class name, and method name within class. We’d probably like to have some elementary statistical information, like the number of methods and number of lines in the class (and method). Those may not be easy to do, starting from here.

We’ll look at the program in a moment, but suffice to say that this information is produced on a tab by tab line by line basis. Aside from a bit of special handling for comments, the program just detects lines containing “class” or “function” and displays them, tabbed nicely.

The program never really knows that it is processing a given class, never really knows that a method is part of a class, and has absolutely no notion of where the end of a function is. There’s no real parsing of the program.

And, in my view, that is as it should be. Now if Codea had calls you could make to inspect the program, returning patches of code or a parse tree or something, I might think differently. But what we have here is a simple program that just spins through the lines of your program, emitting interesting lines in order.

Wild, uncontrolled thought

My plan going in this morning was to refactor Dave’s program, and to get it under test. But now, I find myself thinking “It is simple. Maybe it would be better to write a program of my own, copyingXXX adopting Dave’s ideas.

I always prefer to program something new: this is one of my good, and bad, properties. But if I can successfully get this program refactored and under test, we may have a nice small lesson in how that can be done.

OK, we’ll stick with Plan A.

Dave’s Program

Here, in its entirety, is Dave’s program:

viewer.mode=FULLSCREEN

function setup()    
    project="D2"
    lst=listProjectTabs(project)
    for a,b in pairs(lst) do
        str=readProjectTab(project..":"..b)
    end 
    font("Courier")
    textMode(CORNER)
    dy=0
    tab={}   
    r=listProjectTabs(project)
    for a,b in pairs(r) do
        str=readProjectTab(project..":"..b)
        table.insert(tab,"\n")
        table.insert(tab,"\n")
        table.insert(tab,project.." : "..b)
        b1=1
        s=1
        comment=false
        startComment=false
        while true do
            s,e=string.find(str,"\n",s)
            if s==nil then
                break
            end
            s=e+1                        
            str1=string.sub(str,b1,e)   
            
            sc1,ec1=string.find(str1,"%-%-%[%[")  
            if sc1 ~= nil then
                startComment=true
            end            
            --sc2,ec21=string.find(str1,"%-%-%]%]")  
            sc2,ec21=string.find(str1,"%]%]")  
            if sc2 ~= nil then
                startComment=false
            end  
            
            comment=false
            sc3,ec3=string.find(str1,"%-%-")            
            if sc3 ~= nil then
                comment=true
                sc4,ec4=string.find(str1,"class")
                if sc4~=nil and sc4<sc3 then
                    comment=false
                end
                sc5,ec5=string.find(str1,"function")
                if sc5~=nil and sc5<sc3 then
                    comment=false
                end
            end  
            
            process=true
            if comment or startComment then
                process=false
            end 
            
            if process then
                s1,e1=string.find(str1,"class")
                if s1~=nil then
                    s2,e2=string.find(str1,")")
                    if s2~= nil then
                        str1=removeSpaces(string.sub(str1,1,e2))                            
                        table.insert(tab,"    "..str1)
                        b1=e2+1
                    end
                else
                    s2,e2=string.find(str1,"function ")
                    if s2~=nil then
                        s3,e3=string.find(str1,")")
                        if s3~= nil then
                            table.insert(tab,"        "..string.sub(str1,s2,e3))
                            b1=e3+1
                        end
                    end
                end
                if s==nil then
                    break
                end
            end
            if e~=nil then
                b1=e+1
            end
        end
    end
    table.insert(tab,"\n")
    table.insert(tab,"\n")
    table.insert(tab,"END of "..project)
end

function removeSpaces(s)
    for z=1,#s do
        if string.sub(s,z,z)~=" " then
            return(string.sub(s,z))
        end
    end
end

function draw()
    background(40, 40, 50)
    fill(255)
    for a,b in pairs(tab) do
        text(b,100,HEIGHT-a*20+dy)
    end
end

function touched(t)
    if t.state==BEGAN or t.state==MOVING then
        dy=dy+t.deltaY
        if dy<0 then
            dy=0
        end
    end
end

What do we see here? Well, we see that the setup function is just shy of 90 lines long, including a for loop of about 73 lines, containing a while loop of about 62 lines. Inside there, we have a 22-line if nest, containing an if-else, each branch another one or two levels deep in if.

Prior to that there are 19ish lines deciding whether we’re currently processing one or more comment lines (and therefore shouldn’t look for “class” or “function”. To make that decision, those lines also look for “class” and “function”, in case we have a class or function line containing a comment but not commented-out.

The program uses a few key state variables, notably startComment and comment, the former having to do with multi-line comments “–[[ –]]” and the latter with single line comments “–”.

Wait up here, Ron. We see that?

OK, you got me. We don’t just oh yes here we go this is easy “see” it. I looked hard at the program last night, and in the light of that, again this morning. It’s not bad code at all, it’s very straightforward for the most part, and it’s pretty easy to understand.

I think I could even change it, at least to handle some of the things I might like. I’d have to be careful, but hey, you’re supposed to be careful when you’re programming.

Important Note

I want to say clearly here, that this is a very nice little program, and what I’m saying, and what I’m about to do, are in no way a criticism of Dave or his program. What I’m going to do is to use a very reasonable procedural program to demonstrate how we might get a much more complicated and messy program under test, and under team control.

Often we just use the tools we’ve found “out there” somewhere. I think that usually those tools can be made more useful when we can get them under our own team’s control and modify them to better fit our local needs.

That’s what I’m going to do here, and I’m fortunate to have a clean, working program to start with.

Thanks, Dave!

Where We Might Be Going

I propose to get this program running with associated unit tests, and to move it to a more object-oriented style, ultimately, I hope, turning it into a small set of classes that can be included into any program as a dependency and used to display useful info about that program.

I have some basic ideas about how to do that. Here are a few:

  • Put the app into Working Copy so that I can version it;
  • Include CodeaUnit as a dependency, so that I can write tests for it;
  • Move the existing program into a separate function, and perhaps into classes;
  • Use Codea’s project data or local data capability to store golden master versions of the output, for large-scale testing.

I’m sure that more ideas will arise.

I think this may take me a couple of passes. Right now, I have the program in a project called Making, which is such a good name that I am tempted to name this something else. It’s in the form you saw above, all that code in one Main tab.

My first move is going to be to move the guts into a separate tab from Main, as a function that Main calls. That should be “easy”.

And it was.

function setup()
    report()
end

That’s the Main setup, and the rest of the code is in a new tab, Report, containing the whole program, as written, inside

function report()
...
end

I’ll move over the removeSpaces function as well.

Next, I think I’ll add CodeaUnit as a dependency on this project. Doing that just means pressing Codea’s “DO” button, selecting “Dependencies”, and checking the box on CodeaUnit, once you find it in the list.

Now I should find that the program has a CodeaUnit button that will run the tests we don’t have.

Let’s put this baby into Working Copy now, then start working.

I close Codea for this step, because file access is still pretty weird on iPads, and this ensures that the code is all saved where Working Copy can find it.

In Working Copy’s Repo list, hit + to add one, Init new project Making. In Repo list, long-press Making, and set up project sync.

It gives me a weird diagnostic that I’ve not seen before about Missing directory, but I’m going to ignore that. I think it’s warning me that I’m looking into Codea’s folders, not Working Copy’s. I’ll ask Anders later.

The commit comes up, looking right. Commit: initial commit.

Let’s add a test tab and a hookup test.

-- RJ 20210525
-- Tests for Making App

function testMakingApp()
    CodeaUnit.detailed = true
    
    _:describe("Making App", function()
        
        _:before(function()
        end)
        
        _:after(function()
        end)
        
        _:test("HOOKUP", function()
            _:expect("Foo", "testing fooness").is("Bar")
        end)
        
        _:test("Floating point epsilon", function()
            _:expect(1.45).is(1.5, 0.1)
        end)
        
    end)
end

The tests run, providing one failure (Foo~=Bar) and one success. We’ll fix that up. I generally remove the floating point one, which is just there to remind me where the epsilon goes in an FP test. And I change “Bar” to “Foo” to make that test pass.

Tests are green. (Whee!)

Now I think we’ll start to do some actual work. It seems clear to me that the bulk of the work is done on a per-tab basis, and we should be able to test just a single tab for most of our testing. Let’s look at the function we have in hand:

function report()
    project="D2"
    lst=listProjectTabs(project)
    for a,b in pairs(lst) do
        str=readProjectTab(project..":"..b)
    end 
    font("Courier")
    textMode(CORNER)
    dy=0
    tab={}   
    r=listProjectTabs(project)
    for a,b in pairs(r) do
        str=readProjectTab(project..":"..b)
        table.insert(tab,"\n")
        table.insert(tab,"\n")
        table.insert(tab,project.." : "..b)
        b1=1
        s=1
        comment=false
        startComment=false
        while true do
            s,e=string.find(str,"\n",s)
            if s==nil then
                break
            end
            s=e+1                        
            str1=string.sub(str,b1,e)   
            
            sc1,ec1=string.find(str1,"%-%-%[%[")  
            if sc1 ~= nil then
                startComment=true
            end            
            --sc2,ec21=string.find(str1,"%-%-%]%]")  
            sc2,ec21=string.find(str1,"%]%]")  
            if sc2 ~= nil then
                startComment=false
            end  
            
            comment=false
            sc3,ec3=string.find(str1,"%-%-")            
            if sc3 ~= nil then
                comment=true
                sc4,ec4=string.find(str1,"class")
                if sc4~=nil and sc4<sc3 then
                    comment=false
                end
                sc5,ec5=string.find(str1,"function")
                if sc5~=nil and sc5<sc3 then
                    comment=false
                end
            end  
            
            process=true
            if comment or startComment then
                process=false
            end 
            
            if process then
                s1,e1=string.find(str1,"class")
                if s1~=nil then
                    s2,e2=string.find(str1,")")
                    if s2~= nil then
                        str1=removeSpaces(string.sub(str1,1,e2))                            
                        table.insert(tab,"    "..str1)
                        b1=e2+1
                    end
                else
                    s2,e2=string.find(str1,"function ")
                    if s2~=nil then
                        s3,e3=string.find(str1,")")
                        if s3~= nil then
                            table.insert(tab,"        "..string.sub(str1,s2,e3))
                            b1=e3+1
                        end
                    end
                end
                if s==nil then
                    break
                end
            end
            if e~=nil then
                b1=e+1
            end
        end
    end
    table.insert(tab,"\n")
    table.insert(tab,"\n")
    table.insert(tab,"END of "..project)
end

The first thing I notice is that loop at the beginning:

    lst=listProjectTabs(project)
    for a,b in pairs(lst) do
        str=readProjectTab(project..":"..b)
    end

What’s that about? It seems to have no purpose. I remove it. (I also drop Dave a note asking whether it has a purpose that I don’t recognize.)

Now I want to begin to test a single tab, so with the existing display of D2 showing, I extract the inside of the report program into a new function:

function report()
    project="D2"
    font("Courier")
    textMode(CORNER)
    dy=0
    tab={}   
    r=listProjectTabs(project)
    for a,b in pairs(r) do
        str=readProjectTab(project..":"..b)
        processTab(str)
    end
    table.insert(tab,"\n")
    table.insert(tab,"\n")
    table.insert(tab,"END of "..project)
end

function processTab(str)
    
    table.insert(tab,"\n")
    table.insert(tab,"\n")
    table.insert(tab,project.." : "..b)
    b1=1
    s=1
    comment=false
    startComment=false
    while true do
        s,e=string.find(str,"\n",s)
        if s==nil then
            break
        end
        s=e+1                        
        str1=string.sub(str,b1,e)   
        
        sc1,ec1=string.find(str1,"%-%-%[%[")  
        if sc1 ~= nil then
            startComment=true
        end            
        --sc2,ec21=string.find(str1,"%-%-%]%]")  
        sc2,ec21=string.find(str1,"%]%]")  
        if sc2 ~= nil then
            startComment=false
        end  
        
        comment=false
        sc3,ec3=string.find(str1,"%-%-")            
        if sc3 ~= nil then
            comment=true
            sc4,ec4=string.find(str1,"class")
            if sc4~=nil and sc4<sc3 then
                comment=false
            end
            sc5,ec5=string.find(str1,"function")
            if sc5~=nil and sc5<sc3 then
                comment=false
            end
        end  
        
        process=true
        if comment or startComment then
            process=false
        end 
        
        if process then
            s1,e1=string.find(str1,"class")
            if s1~=nil then
                s2,e2=string.find(str1,")")
                if s2~= nil then
                    str1=removeSpaces(string.sub(str1,1,e2))                            
                    table.insert(tab,"    "..str1)
                    b1=e2+1
                end
            else
                s2,e2=string.find(str1,"function ")
                if s2~=nil then
                    s3,e3=string.find(str1,")")
                    if s3~= nil then
                        table.insert(tab,"        "..string.sub(str1,s2,e3))
                        b1=e3+1
                    end
                end
            end
            if s==nil then
                break
            end
        end
        if e~=nil then
            b1=e+1
        end
    end
end

That is surprisingly tricky to do, because in Codea, I can’t see both ends of the function at the same time in the editor.

I quickly learn that I need to pass “b” into the function:

function report()
    project="D2"
    font("Courier")
    textMode(CORNER)
    dy=0
    tab={}   
    r=listProjectTabs(project)
    for a,b in pairs(r) do
        str=readProjectTab(project..":"..b)
        processTab(str,b)
    end
    table.insert(tab,"\n")
    table.insert(tab,"\n")
    table.insert(tab,"END of "..project)
end

function processTab(str,b)
    table.insert(tab,"\n")
    table.insert(tab,"\n")
    table.insert(tab,project.." : "..b)
...

The program is working again.

We should commit: Tests tab added, reportTab function broken out.

Now we need to create something we can actually test. I propose to have a new tab in this project. But I do have a concern.

I imagine using this project by including it as a dependency in the project we’re actually working on, and calling it with a button or something like that. For that to be ideal, we don’t want a lot of extra tabs in our app.

I think we’ll burn that bridge when we come to it. Right now, let’s get this thing under control.

I start with this new tab, named “Sample”:

-- Sample
-- RJ 20250525

function sample(a,b,c)
    local x = 15
end

Barker = class()

function Barker:init(dog, name)
    self.dog = dog
    self.name = name
end

function Barker:speak()
    SoundMachine:sound(self:woof())
end

function Barker:woof()
   return "woof" 
end

Now let’s stop running the program from setup, and start running it from the tests.

It takes me a little messing about, since our draw and touch functions in Main are relying on some globals that the original sets up. I wind up here:

        _:test("Sample", function()
            project = "Making"
            font("Courier")
            textMode(CORNER)
            dy=0
            tab={}   
            local str = readProjectTab("Sample")
            processTab(str,"Sample")
        end)

The general scheme here is that the original program computes the table tab and the draw displays it, and touch, using dy, scrolls it. We don’t exactly want that, though we certainly do want to view the output.

Irritatingly, the output comes out under the pull-over part of Codea:

hidden

I think I’ll just see where the tabbing is and tab it further out. From looking at that picture, I think tabbing in an additional number of spaces slightly longer than “Making : Sample” should do it. I’ll try 20 spaces. How does the tabbing work? It’s of course quite ad-hoc. I’ll do this:

function processTab(str,b)
    local extra = "                    "
    table.insert(tab,"\n")
    table.insert(tab,"\n")
    table.insert(tab,extra..project.." : "..b)
...
    table.insert(tab,extra.."    "..str1)
...
    table.insert(tab,extra.."        "..string.sub(str1,s2,e3))
...

Those seem to be the only lines that insert actual text, and I just prepended the new extra spaces. Let’s see how it looks.

sweet

That’ll do. Let’s add some stuff to sample, including some comments that we think the program can handle:

-- Sample
-- RJ 20250525

--[[ some kind of comment
more comment
--]]

function sample(a,b,c)
    local x = 15
end

Barker = class()

function Barker:init(dog, name)
    self.dog = dog
    self.name = name
end

function Barker:speak()
    SoundMachine:sound(self:woof())
end

function Barker:woof()
   return "woof" 
end

Porker = class()

function Porker:init(piggie, piggieName)
    
end

--[[
function shouldIgnore()
end
--]]

math.nothing = function(value)
    return value
end

function nothing(value)
    return value
end

If everything were perfect, the shouldIgnore function would not show up, and the math.nothing one would show up. In fact the latter does not show up. If we want it, we’re going to have to do some much more sophisticated parsing.

There’s another kind of function to think about, the anonymous kind, such as we get in a test:

        _:test("HOOKUP", function()
            _:expect("Foo", "testing fooness").is("Foo")
        end)

This is really the same as the math.nothing one. The function has no name (like the girl who has no name). As such, we can decide not to display it, and given that we’re mostly interested in classes and methods, I think we’re fine to do that.

Here’s the output from things as they stand now:

stand

So that’s good. Let’s turn our current output into a golden master, and then check it.

I think I’ll add a new button. What should it do? Well, it should make and save a golden master. Unfortunately, Codea can only store a string or a number as local, project, or global data. We’ll need to compute a string to use for our comparison.

Let’s first write a check that compares our output tab to the master. This is going to make me want to change how my outer calling sequence works here.

Here’s my new processTab:

function processTab(str,b)
    local tab = {}
    local extra = "                    "
    table.insert(tab,"\n")
    table.insert(tab,"\n")
    table.insert(tab,extra..project.." : "..b)
    b1=1
...

        if e~=nil then
            b1=e+1
        end
    end
    return tab
end

I put that into the report function thusly:

function report()
    project="D2"
    font("Courier")
    textMode(CORNER)
    dy=0
    tab={}   
    r=listProjectTabs(project)
    local tabData
    for a,b in pairs(r) do
        str=readProjectTab(project..":"..b)
        tabData = processTab(str,b)
        for i,line in ipairs(tabData) do
            table.insert(tab,line)
        end
    end
    table.insert(tab,"\n")
    table.insert(tab,"\n")
    table.insert(tab,"END of "..project)
end

I “just” return the result of processTab into a local table tabData, and append it to the output table tab. I added a button to run the full report on “D2” and it works.

We are still refactoring without much support from tests, so we’re not where we’d like to be, yet. One thing at a time: I’m working toward the golden master. I have this:

function setup()
    tab = {}
    dy = 0
    --report()
    parameter.action("Make Golden", function()
        makeGolden()
    end)
    parameter.action("report D2", function()
        report()
    end)
end

function makeGolden()
    if #tab > 0 then
        saveLocalData("tab", table.concat(tab))
    end
end

Conveniently, table.concat concatenates the print values of a table into a string. We can actually test this now, but we aren’t going to like it much.

        _:test("Sample", function()
            project = "Making"
            font("Courier")
            textMode(CORNER)
            dy=0
            tab={}   
            local str = readProjectTab("Sample")
            local result = processTab(str,"Sample")
            tab = result
            local tabString = table.concat(result)
            local tstString = readLocalData("tab")
            _:expect(tabString).is(tstString)
        end)

First time thru, this should fail, since the read won’t return anything good. After that, if I like the output, I’ll press the golden master button and save that copy. Oh. Except it won’t be right, I’m not saving the right thing. Let me try my makeGolden again:

function makeGolden()
    local str = readProjectTab("Sample")
    local result = processTab(str,"Sample")
    local resultString = table.concat(result)
    saveLocalData("tab", resultString)
end

That’s more like it. Let’s test and see what we see.

Test fails first time through, as expected:

2: Sample  -- Actual: 

                    Making : Sample                            function sample(a,b,c)                        Barker = class()                            function Barker:init(dog, name)                            function Barker:speak()                            function Barker:woof()                        Porker = class()                            function Porker:init(piggie, piggieName)                            function nothing(value), Expected: nil

We seem to have stripped out all the newlines. Let’s presume that won’t harm us.

Now to press the Make Golden button, then run again.

It doesn’t say anything, and probably should, or flash the screen or something. Anyway, it probably saved, so let’s run the tests again:

2: Sample  -- OK

Test passes! We have a Golden Master compared with our output.

Ah. Reading the FM, I see that table.concat can take a separator. I’m all for that. Let’s do it.

        _:test("Sample", function()
            project = "Making"
            font("Courier")
            textMode(CORNER)
            dy=0
            tab={}   
            local str = readProjectTab("Sample")
            local result = processTab(str,"Sample")
            tab = result
            local tabString = table.concat(result, "\n")
            local tstString = readLocalData("tab")
            _:expect(tabString).is(tstString)
        end)
        
    end)
end

function makeGolden()
    local str = readProjectTab("Sample")
    local result = processTab(str,"Sample")
    local resultString = table.concat(result, "\n")
    saveLocalData("tab", resultString)
end

Do again:

2: Sample  -- Actual: 



                    Making : Sample
                            function sample(a,b,c)
                        Barker = class()
                            function Barker:init(dog, name)
                            function Barker:speak()
                            function Barker:woof()
                        Porker = class()
                            function Porker:init(piggie, piggieName)
                            function nothing(value), Expected: 

                    Making : Sample                            function sample(a,b,c)                        Barker = class()                            function Barker:init(dog, name)                            function Barker:speak()                            function Barker:woof()                        Porker = class()                            function Porker:init(piggie, piggieName)                            function nothing(value)

Nicer. Make the Master. Test again, expecting OK.

2: Sample  -- OK

OK, let’s commit: test uses Sample tab. Can make and check Golden Master from local data.

What Now?

I can think of three things to do now.

  1. Stop, because between interruptions and such, our morning session has run long enough;
  2. Start working on the necessary changes to make this program useful in making D2;
  3. Refactor a bit for clarity.

From the viewpoint of personal benefit, I’d like to stop soon. From the viewpoint of project benefit, the sooner I bring this new find back to the team, the sooner it can be useful.

So let’s at least think about item #2, making it useful for D2.

If I were just to change the Main thus:

function setup()
    tab = {}
    dy = 0
    --report()
    parameter.action("Make Golden", function()
        makeGolden()
    end)
    parameter.action("report D2", function()
        report()
    end)
end

When we run this app and press the “report D2” button, we get the original output we saw at the top of this article, the full report for the D2 program. Just for fun, I’ll display it in multiple panes (or pains) below. It takes 19 photos to take it all in: I’ll just show a few. We can see that this tool will need enhancement, but it’s fun and enlightening just to scroll through and observe things.

I think we’ll declare the program somewhat useful as it is, and sum up:

Working Effectively with Legacy Code

That title is the title of one of my most admired books, by Michael Feathers. It is a large compendium of techniques for getting legacy code under test and refactoring it into better shape.

The book includes examples in C++, Java, and C#, and its ideas are more generally applicable. Here in this article, we’ve just begun to scratch the surface of the good material you’ll find in WELC, should you decide to get a copy, which I recommend to anyone dealing with messy legacy code.

Here, we’ve done just a few things. We pulled out a function that processes a single string of code, typically a Codea tab’s worth. As we go forward with the program, we’ll surely begin passing in smaller snippets of code.

We saved a local “golden” copy of the test’s output, and compare its current output to that, emitting an error if they don’t match. If our tests are small enough, that may suffice. If not, we may find that we want to do some kind of “diff” or other useful display of what has happened.

Frankly, I’d rather not enhance the error-reporting, instead using small enough tests to make reading the output easy. We’ll see how that goes.

Looking forward, I suspect we’ll want the thing to generate some summary information, tab, class, number of methods, lines of code, and then to allow us to drill down to the details. I expect that we’ll be able to do a decent job of that, although not a perfect one.

We’ll probably assume that we’re presented with a well-formatted file, so that you can expect a correct line count if you give us this:

function foo(a)
    local b = a*a
    if b < a then
        return "small"
    else
        return "large"
    end
end

But not if you give us this:

function foo(a)
    local b = a*a
    if b < a then return "small"
    else return "large" end end

We’ll see. I don’t think we want to write, nor steal, a lua syntax analyzer.

Anyway, we have a nice addition to our toolset, big thanks to Dave, and it’s starting to get into condition to be refactored and extended, small thanks to me.

See you next time. Here’s today’s code, and a few of the 19 pictures:


Making.zip


one

two

three