I may have found a bug regarding how SQL Server calls CLR functions when using columns from tables joined using non-indexed columns. I'm including a SQL script, with comments on what to do with it, and hopefully anyone reading this will try it for themselves and tell me if what I'm seeing happens for them and whether I'm misinterpreting things. I'm willing to admit if I'm misunderstanding how things are supposed to work but so far everyone that I have spoken with about this is as perplexed as I am. And yet none of us can imagine this has never been noticed before and also is working as intended.
For starters, I discovered this in 64 bit SQL Server 2008 R2. One of my coworkers, our DBA, also reproduced this on 64 bit SQL Server 2012. I posted the issue at stackoverflow but no one even commented on it. My summary of the problem as I posted there is as follows:
SQL Server (at least on 2008 R2, 64 bit), when processing a query that includes a CLR function call in the SELECT clause and when using at least one column from a table that is included via JOIN on non-indexed columns, appears to invoke that CLR function for every row not constrained by either an indexed column in the JOIN or by an explicit WHERE clause item.
Now for some more details. This all has to do with the Microsoft spatial objects CLR functionality. Suffice to say I have a table full of extents, latitude/longitude pairs marking the opposing corners of a region. Additionally I have an SRID (spatial reference ID) to indicate what coordinate system was used. Some use meters offset, some use degrees. Also, the data I have is dirty. It has NULL values, invalid SRID values, coordinates that don't match the SRID (i.e. meter offsets recorded for a supposedly degree based SRID). Finally, not only did I need to filter out potential offending values I also had additional filters being joined in from other tables. So my original SQL was actually doing a pre-filter pass to generate a valid set of ID values followed by the actual SELECT which joined this list of pre-filtered keys in a table variable with the original data table.
Part of the SELECT was to make use of the geography::STGeomFromText() call to return a geography object as a column in the result set. No sweat, my tests showed only valid rows were returning. But when I added that call, I began getting exceptions from the underlying CLR complaining about invalid geographies, invalid SRID values, insufficient numbers of points in the ring, etc. After a considerable amount of debugging and testing, I came up with the script listed below.
The first chunk of the script is commented out and includes two sections. One section creates a FUNCTION that takes a VARCHAR and an INT and just passes them directly to geography::STGeomFromText(). It is a simple wrapper. More on that in a bit.
The second section in the commented out area sets up the data table. The data table happens to include 7 test records but could probably work with fewer. I hit the exact results I was hunting down after I had added the 7th record and as much out of superstitious need to cling to my voodoo doll as anything else, I did not alter the data set. In any case, the CREATE TABLE is most notable for NOT indexing the ID column. The column is also not an IDENTITY but honestly making it an IDENTITY column did not impact the outcome and just added needless complexity.
The uncommented code that follows declares a table variable to hold valid keys, populates the table variable, and then performs a SELECT, returning not only all columns from the original data table but also additional columns which directly invoke the CLR function as well as call the UDF created up top. There is also a commented out WHERE clause.
I'll summarize what I saw before I dump the script.
- If I do not include the CLR call as a column in the SELECT, the SELECT works and I only see records which have valid values that would work in a call to the CLR function.
- If I call the UDF, a wrapper around the CLR, I again only see the expected records and I get valid geography objects returned for each.
- If I call the CLR, I receive an exception about an invalid SRID (being NULL). Depending on the results of the full table scan, you may also receive errors about invalid geography point values. Regardless, the query fails.
- If I uncomment the WHERE clause, I can now include the CLR call as a column with no errors.
- If the WHERE clause is commented out but the original data table is instead recreated such that the id field is a PRIMARY KEY field (or really, indexed in any way but the obvious approach would be to make it a PRIMARY KEY), the CLR function may also be included in the SELECT without issue.
What I believe is happening is that the main query is joining two tables, the data table and the table with a simple list of keys. It joins these tables on the ID column. When there is an INDEX on the data table's ID column, SQL Server first joins on the results of the INDEX lookup, then fetches only the rows corresponding to the joined keys. Since the key table only has keys to rows with valid data, no invalid data is ever even seen. But when there is no INDEX, a table scan must be performed on the data table. While it is true that the result set will only end up including rows with valid data since that's the only keys in the key table, it seems as though the CLR function is still being called for each row in the scan, regardless of whether it will end up in the final result set.
The interesting thing about the UDF is that it seems to mask whatever behavior is going on with the CLR function call. That is, the UDF does not get called for every row of a table scan, only for the rows which would be returned in the result set.
Aside from simple stating the obvious, that the lack of an INDEX forces a full table scan and the database is giving the CLR function a shot on each row before it is filtered out by the JOIN restriction (though, oddly enough, after any WHERE clause), I can't see why this would happen. Wrapping it in a UDF seems to use a slightly different logic path. Perhaps it is a bug in the CLR assembly from Microsoft (this is Microsoft's geography implementation) but I don't have any other assemblies to test and our environment doesn't allow us to install anything else.
Anyhow, here's the script to reproduce the problem.
/* * This part of the script makes a UDF available to wrap the CLR call * * Only run this once -- -- -- -- BEGIN RUN ONCE -- -- -- IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[fLPPTestMakeGeography]') AND type in (N'FN', N'IF', N'TF', N'FS', N'FT')) DROP FUNCTION [dbo].[fMakeGeography] GO CREATE FUNCTION [dbo].[fLPPTestMakeGeography] ( @p1 varchar(max) , @p2 int ) RETURNS geography AS BEGIN DECLARE @Result geography -- just a wrapper around the geography::STGeomFromText call SELECT @Result = geography::STGeomFromText(@p1,@p2) RETURN @Result END GO -- -- -- -- END RUN ONCE -- -- -- */ /* * Likewise this sets up the test table. * Note that this all goes away when a PRIMARY KEY is placed on the id column * Commentary: by making id a PRIMARY KEY, it causes the join in the main query * to do an index lookup rather than doing a full scan of the data table. Without * that PRIMARY KEY setting, even though the table variable is joined to the data * table in such a way as to eliminate any potential invalid rows, every row of * the data table gets examined. The fact that the CLR call throws an exception * in this case but the UDF call does not suggests the following: * For each row of a table that is not filtered in the JOIN clause via an index * and not filtered in the WHERE clause directly, any CLR call in the SELECT * clause will be called if it involves any columns from that row. * Note that this behavior differs from that of UDFs, where the UDF call only * takes place once *all* filtering is done, including non-INDEXed portions of * any JOIN statements. -- -- -- -- BEGIN RUN ONCE -- -- -- drop table dbo.LPPTestEnvelope create table dbo.LPPTestEnvelope ( id int not null , geog int , minx decimal(38,16) , maxx decimal(38,16) , miny decimal(38,16) , maxy decimal(38,16) ) insert into dbo.LPPTestEnvelope (id, minx, maxx, miny, maxy, geog) select 1,-80,-70,20,30,4326 union all select 2,-80,-80,20,20,NULL union all select 3,-80,-75,20,25,4326 union all select 4,NULL,NULL,NULL,NULL,4326 union all select 5,-85,-70,25,40,4326 union all select 6,NULL,NULL,NULL,NULL,4326 union all select 7,-100000,-90000,3000000,3100000,4326 -- -- -- -- END RUN ONCE -- -- -- */ -- select * from dbo.LPPTestEnvelope set nocount on; -- in a more complex setting this table variable -- would be used to do some complex pre-queries and hold the -- key values which would be joined to the original data -- table to get the actual rows desired -- -- for this example it's just dumbed down but the concept holds declare @envtbl table ( eid int not null primary key ) INSERT INTO @envtbl (eid) SELECT id FROM dbo.LPPTestEnvelope env WHERE -- exclude invalid SRID values env.geog=4326 -- exclude invalid lat/long values (e.g. UTM values with improper SRID assignments) AND env.minx > -180 AND env.minx < 180 AND env.maxx > -180 AND env.maxx < 180 AND env.miny > -90 AND env.miny < 90 AND env.maxy > -90 AND env.maxy < 90 -- avoid precision bug AND ABS(env.maxx-env.minx)>1E-7 AND ABS(env.maxy-env.miny)>1E-7 -- avoid crossing hemispheres AND SIGN(env.maxx)=SIGN(env.minx) AND SIGN(env.maxy)=SIGN(env.miny) SELECT -- display the raw data from the table env.* -- directly invoke the CLR to create a geography object -- NOTE: COMMENT THIS COLUMN OUT TO MAKE THE QUERY WORK ,geography::STGeomFromText('POLYGON((' + CAST(env.minx AS VARCHAR) + ' ' + CAST (env.maxy AS VARCHAR) + ', ' + CAST(env.minx AS VARCHAR) + ' ' + CAST (env.miny AS VARCHAR) + ', ' + CAST(env.maxx AS VARCHAR) + ' ' + CAST (env.miny AS VARCHAR) + ', ' + CAST(env.maxx AS VARCHAR) + ' ' + CAST (env.maxy AS VARCHAR) + ', ' + CAST(env.minx AS VARCHAR) + ' ' + CAST (env.maxy AS VARCHAR) + '))', env.geog) ,[dbo].[fLPPTestMakeGeography]('POLYGON((' + CAST(env.minx AS VARCHAR) + ' ' + CAST (env.maxy AS VARCHAR) + ', ' + CAST(env.minx AS VARCHAR) + ' ' + CAST (env.miny AS VARCHAR) + ', ' + CAST(env.maxx AS VARCHAR) + ' ' + CAST (env.miny AS VARCHAR) + ', ' + CAST(env.maxx AS VARCHAR) + ' ' + CAST (env.maxy AS VARCHAR) + ', ' + CAST(env.minx AS VARCHAR) + ' ' + CAST (env.maxy AS VARCHAR) + '))',env.geog) ,'SELECT geography::STPolyFromText(''POLYGON((' + CAST(env.minx AS VARCHAR) + ' ' + CAST (env.maxy AS VARCHAR) + ', ' + CAST(env.minx AS VARCHAR) + ' ' + CAST (env.miny AS VARCHAR) + ', ' + CAST(env.maxx AS VARCHAR) + ' ' + CAST (env.miny AS VARCHAR) + ', ' + CAST(env.maxx AS VARCHAR) + ' ' + CAST (env.maxy AS VARCHAR) + ', ' + CAST(env.minx AS VARCHAR) + ' ' + CAST (env.maxy AS VARCHAR) + '))'',' + CAST(env.geog AS VARCHAR) + ')' FROM -- join our data table to our table variable to fetch only the desired rows dbo.LPPTestEnvelope env INNER JOIN @envtbl et on et.eid=env.id -- NOTE: UNCOMMENT THE WHERE CLAUSE TO MAKE THE QUERY WORK REGARDLESS OF INDEX --WHERE -- -- exclude invalid SRID values -- env.geog=4326 -- -- exclude invalid lat/long values (e.g. UTM values with improper SRID assignments) -- AND env.minx > -180 AND env.minx < 180 AND env.maxx > -180 AND env.maxx < 180 -- AND env.miny > -90 AND env.miny < 90 AND env.maxy > -90 AND env.maxy < 90 -- -- avoid precision bug -- AND ABS(env.maxx-env.minx)>1E-7 AND ABS(env.maxy-env.miny)>1E-7 -- -- avoid crossing hemispheres -- AND SIGN(env.maxx)=SIGN(env.minx) AND SIGN(env.maxy)=SIGN(env.miny)
A recent story that's shown up a number of places concerns a Houston couple giving away around $4 billion for causes they deem worthwhile and, more importantly, that appear to be provably effective. The idea being that they want to make sure their dollars go toward things which have a higher likelihood of permanent change and for which metrics are in place or can be put in place to measure that effectiveness. Additionally they want proposals to have already thought things through, showing the path from getting to where the problem is now to where the solution may be. That said, supposedly there is some sort of backlash over this because they are spending their money on things which do not necessarily help people in need right now.
Let's overlook the absurdity of telling them how they can spend their money for a moment. After all, if that were an issue we should be storming the gates of every rich person out there who lives the playboy/playgirl lifestyle and does precious little for their fellow human and we don't see that. As far as I'm concerned the fact that this couple is spending any of their money on social reform is a good thing and to be lauded.
I dislike the argument that they should focus their efforts on short term solutions to long term problems, which is one aspect of the argument being made against their activities. To begin with there are already a lot of entities providing help to the needy in many aspects. Could more money help some of them? Absolutely. But wouldn't it be better if, instead of pouring money into addressing the symptom you put money into finding a cure? If instead of simply buying more aspirin, you could stop the fever and watch it die out?
There are some, however, who don't so much disagree with the idea of finding a permanent solution as they do with the hubris associated for daring to believe that some solutions can be found or even paid for. And yet, why not at least try? If nothing else some of the money spent on trying to find and enact a solution to a social ill could turn up information that leads to an actual approach that works or just increased awareness of the problem. Again, all of this is a positive outcome. At worst, the money simply disappears into a black hole with zero results. That would be unfortunate on many levels but not a catastrophe for anyone besides the funding couple.
Personally I am excited to see this approach taken. While I firmly believe that too much measurement can bog down a process or a system, I also believe the opposite end of the spectrum is adverse as well; too little measurement results in massive inefficiency. In this case the measurement isn't even focused on whether an organization is spending their money on those in need, getting by on as little as possible, etc. It is solely focused on getting the most long term bang for the buck, seeing to it that the focus is on eliminating or at least scaling down the problem as much as possible rather than just treating the symptoms. If this works, and any effects would necessarily be felt only in the long term, it could become a blueprint for how to best go about addressing issues that have plagued mankind for far too long. I can't wait to see how it turns out!
Like it says on the tin, the new update for Stalemate is available, get it while it's hot!
Also, it's free.. well the update was free, but now I've made the app free too. And Centripetal as well! Y'all have fun!
I've recently been revisiting my little iOS chess like app, Stalemate. The latest update is currently in review and includes two major revisions. The first and most important is the addition of a tutorial mode. That is a feature that has been requested and honestly should have been there from the word go. When I put Centripetal together, the game play was simple enough that a simple help screen was sufficient with some explanation of the goal and obstacles. With Stalemate the scoring needed to be explained, the fact that pieces don't move once placed was probably not what folks expected and though simple it is still more complex than Centripetal. So yeah, should have made a tutorial to begin with.
The second and less important but more visible change is with the look and feel. I moved away from my attempt at aged parchment with some sort of Renaissance gradient font (seriously, what was I thinking?) to a simple black and white glossy look more in line with the icon set I used for the pieces in the first place. Here's the new splash page:
Okay, yes, I've picked another custom font. But this time there is no gradient at least. When I created the reflection, at first I just made it a normal reflection but then it occurred to me to reflect (see what I did there?) the oppositional nature of the game and therefore made the reflected portion a negative of the full portion. I may not have a future as a graphic design artist but I thought it was a nice touch.
Update - We're now live with the new look. In addition, I've made Stalemate and Centripetal free. I think I would rather see them getting played than continue to see a trickle of income off of them. Have fun!
Greetings and salutations and welcome to 2013! For my first new post of the year I am very happy to announce that I have re-released Centripetal onto the iTunes App Store under my own name. Click here to purchase Centripetal on the iTunes App Store.
As an aside, a fair bit has changed since I first released Centripetal. I had used cocos2d 0.99 at the time. XCode 3 was all the rage. iOS was at v4.x and OpenGL ES 1.0 was the standard.
Now cocos2d 2.0 is out, XCode is at 4.5, iOS is up to v6.x and OpenGL ES 2.0 is en vogue on our favorite mobile platform. As a result, it wasn't enough to just recompile and resubmit. I ended up upgrading cocos2d, box2d, CocosDenshion and the other related libs. I also needed to tweak some of the code that I happened to have which made use of the GL_POINTS_SMOOTH parameter in the call to glEnable. By tweak I mean remove. You'll see that on the help screens where the dot indicator showing which page you are on is now a square and not a circle. Yay progress?!
I do, however, want to also release this for OS X on the Mac App Store as well. Oddly enough I discovered it is enjoyable to play on the desktop due to running it in the iOS simulator.
Anyhow, if you already have your original copy, know that this is purely a re-release. If you haven't tried it out before, you can check out my Centripetal page here (or click the link at the top of the page in the header) and watch the gameplay video.
Mitt Romney has been proclaiming his tax plan is designed to lower tax rates in a revenue neutral manner without reducing the share of taxes paid by the wealthiest Americans and without increasing the tax burdens on middle class and poorer Americans. His general theme includes several points:
- Cut all tax rates by 20% in all brackets
- Eliminate a number of deductions
- Provide a boost to the economy to increase revenue through tax collection
Note this is not the same as his five point plan. I'm just talking about the general tax related portions. I don't see how this works and I'm not the only one.
Imagine for a moment that the taxes collected by the IRS form a pie. A very small number of tax payers, the wealthiest 5% or so in America, provide 60% of that pie. Other tax brackets make up the remaining 40%. If you cut all tax rates by 20%, it would be akin to cutting off the outermost portion of that whole pie. The relative sizes of the slice remain the same but you have a smaller pie. He said he doesn't want the rich to pay a smaller share of overall tax revenue, and this is in keeping with that. He says he wants middle class families to not have to pay so much money and this works there too. He wants to do this in a revenue neutral manner and now we have some problems. What next?
Thing is, the US government is pretty hungry and wants that pie. You've made it smaller. We're not revenue neutral. So now we eliminate some deductions. This would add some pie back into the mix. But, and here's a problem that Romney has yet to address, he has not told us what deductions he would suggest eliminating. That's pretty important. At one point, he suggested possibly eliminating the mortgage interest deduction. While his campaign later backed off the idea, that would have been problematic as it would disproportionately affect the middle class he says he doesn't want to hurt. How? Simple... the mortgage interest deduction represents, in relative terms, a larger write off for middle class households than upper class. Take that away and that family is now paying a higher amount of their own money in taxes than they were before. But it gets better.
I watched the debate last night and was surprised that Romney was pitching the idea of eliminating capital gains taxes as a boon to the middle class. The fact is that upper class earners would benefit far more than middle class earners if the capital gains tax were eliminated. So if this were pushed through, not only would middle class families not see much of a positive from this, now the taxes paid by upper class earners are going to go down, quite a bit. For the wealthiest earners, most of their income comes from capital gains, not from regular income. That is why their overall tax rate is so much lower than the rest of us. Eliminate taxes on capital gains and you eliminate taxes on the bulk of the money they earn. All of a sudden, not only is the pie much smaller but that 60% has shrunk a bit too.
The final piece of the puzzle is stimulating the economy. This is where Romney pitches his various plans to help create jobs and increase trade. With more money earned, more taxes will be paid. Great! Except capital gains are now off the table, so the business owners who are now making more money aren't paying any more in taxes. So who is picking up the tab here? Oh, the middle and lower classes of income earners. The ones Romney says he doesn't want to make pay more.
Now, whether you think it is fair that 5% of Americans are responsible for providing 60% of the tax revenue is an entirely different question. The point here is that Romney's plan does not work as he says it does. And the biggest tell is that he refuses to provide any details. There's a lot of hand waving but when it comes down to it, his plan is less of a plan than a wishlist. If he starts laying out details and those details make sense, I'll be the first to agree that it is a workable solution. But until then all I've seen are smoke and mirrors.
So you might have heard about a new phone that's out now. They call it the iPhone 5 and it is apparently the best phone Apple has ever created.
I don't disagree. I like that they've gone back to a non-glass back. I like even more that it's smaller, lighter, more powerful and let's be honest -- it has an improved, albeit still proprietary, plug. No more trying to plug it in the wrong way? Yes please. Still, these are evolutionary in nature and really only mildly so. It's going to sell like hotcakes, I'm sure. Though why people would want to use hotcakes as a cellphone is outside the scope of this post. And if I weren't already happy with my iPhone 4 (not even a 4S), I would upgrade. I'll take a pass for now and wait awhile. Still, there are some things I would have liked to have seen.
As I said, I like the new non-glass backing. But perhaps they could go further? With the cash reserves Apple has on hand, they could buy Nokia and make an iPhone capable of surviving a gunshot. At point blank range. With a .50 cal. Seriously, have you seen those things? The Nokias, not the .50 cals. The point being anything Apple can do to ruggedize the iPhone (while still maintaining that famous iPhone design aesthetic) would be much appreciated.
Speaking of an iPhone surviving catastrophes, what about water? I've reviewed the Otterbox case for iPhone 4 but have since quit using it because the rubber shell was beginning to tear near the headphone flap and I decided I was going to try using my iPhone au naturale. So far I haven't had any disasters and I'm doing alright with transporting my iPhone in my pocket but while I would dislike dropping it, I don't think it would cause the same amount of panic as dipping it in liquid would. To that end, would it be possible to use some sort of liquid repelling construction or substance to at least provide a little peace of mind? It would be nice to know my iPhone could be used in the rain, or on a train, and on a bus, with a friend named Gus... ok, I'll stop now.
Moving down the wishlist, I'm going to add something that I suspect could be just an iOS feature as opposed to a hardware update. Wi-fi only network mode. Currently iOS presents you the option to either disable all networking (Airplane Mode), only use cell networking (by turning off Wi-Fi) or to use Wi-Fi/cell networking (using Wi-Fi when available and falling back to cell otherwise). But what if I don't want to use cell networking at all, and just want to make use of Wi-Fi? Apple, can you give me some of that, please? (Look, I just said please. That means you have to now, right? Tim?) Okay, those of you in the know are aware of my failure here. Head over to the Settings app, go to General->Cellular, turn off the cellular data option. Grats, you are now WiFi only. *sigh*
Right now, you might be wondering about the dock connector. There's been a number of comments about Apple missed a chance to win some points by switching to the ubiquitous micro-USB connector rather than their new proprietary Lightning connector (seriously guys? Thunderbolt and Lightning? really?). If this article is to be believed, there are some solid reasons not to use micro-USB and in fact I think it would be reasonable to offer it as a new standard and to start switching phones to support it. That said, others have commented that while Apple might be justified in using their new connector, they should have offered adapters. That I also disagree with. If you're buying a new iPhone 5, you're getting a new Lightning adapter. If you have devices which work with the old Apple dock connector, in many cases an adapter won't work purely because of the physical layout, not to mention the iPhone 5's new dimensions. So in this case, my request is simple... Apple, offer your new connector up as a new standard, available for the industry to move to. It does seem to have some keen advantages and it would be nice to have everyone on the same page, widening the availability of third party add-ons that work with any phone, as opposed to having to pick one ecosystem or the other.
That's all for me. There are other things that could be mentioned (NFC anyone?) but for me personally, this much and I'd be a very happy camper. That's not to say the iPhone 5 isn't a nice phone as is. Sure it is. But it could just be that much nicer. Tim? Are you taking requests?
Years ago (2004?) I wrote a little command line utility that allowed users to alter their screen resolutions, bit depths and refresh rates. To be entirely honest I don't even remember why I did it in the first place. However it seems to have been somewhat useful to a handful of folks over the years. I still see requests for it and occasionally get emails asking if I've updated cscreen for the Intel machines. The answer, now, is yes. I even created a page for cscreen for OS X so folks can link to it as needed.
That said... I do not have a Retina display and do not know anyone with a Retina display, so I cannot test it on such devices. If you have a Retina display and wish to give it a whirl, be my guest. In fact, if anyone wants to provide any feedback, please don't hesitate.
In the process of getting started on Centripetal 2, I ended up needing to make use of Hinge Joints. I've put together a Hinge Tutorial Project project with a sequence of simple scenes that might help explain and demonstrate the various options associated with Hinge Joints. Enjoy!
As some of you are aware, I created a little game called Centripetal and had it up on the iPhone/iPad app store. Wait, don't go trying to download it now. My former company, Phoenix Networking Group, who had rights to the game, has transferred those rights over to me. As a result, the game has been taken off of the app store.
"So what's this about Centripetal 2, then?" you ask. Well put. I'm rebuilding Centripetal.
For starters, I'm going to be using the Unity Engine this time around. When I first developed Centripetal, I made use of cocos2d for iPhone for sprite management and object handling and box2d for the physics. I was focusing on iPhone (and eventually iPad) development so the fact that this arrangement tied me to these platforms wasn't a showstopper. And to be clear they are very useful frameworks. But it did mean that some of my friends who do not use iOS devices were unable to play Centripetal.
I had discovered the Unity Engine some time ago and grabbed one of their free copies to tinker with but never really got into it. Recently I took the plunge, grabbed a tutorial, went through it and came out the other side not only impressed with Unity but also confident that I could make good use of it myself. I created a really simple game to become more comfortable with the editor, which I'll have to post about some time, and then started working on concepts for other games I might pursue. In the back of my mind, I wondered about converting Centripetal but for a number of reasons I put that aside.
That has changed though. I've started rebuilding it for Unity, with the goal of making it available for more folks to play (yes Kris, I'm hoping you and the wife and kids will finally get a turn . I also want to add more to it. Inasmuch as Centripetal represents my first polished and completed game, I know there was more that could have been done. I hope to do that with Centripetal 2.
I don't imagine the new revision will make much more money, if any, than its predecessor but that's not really the point. I want to make something fun and keep building on something that began as my own creative endeavour. I'm glad I'm getting that chance.